From 202474948014c5d9b43ba17b31263704717a7c54 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 00:27:51 +0000 Subject: [PATCH 01/12] Use a vec of length 1 to broadcast unrelated txs `BroadcasterInterface::broadcast_transactions` requires that any passed vector containing multiple transactions must be a single child together with its parents. We will lean on this contract in upcoming commits, so here we fix a case where we broke this contract. --- src/wallet/mod.rs | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3e606d766..d500d4d01 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -326,23 +326,20 @@ impl Wallet { } } - if !unconfirmed_outbound_txids.is_empty() { - let txs_to_broadcast: Vec = unconfirmed_outbound_txids - .iter() - .filter_map(|txid| { - locked_wallet.tx_details(*txid).map(|d| (*d.tx).clone()) - }) - .collect(); - - if !txs_to_broadcast.is_empty() { - let tx_count = txs_to_broadcast.len(); - self.broadcaster.broadcast_unclassified_transactions(txs_to_broadcast); - log_info!( - self.logger, - "Rebroadcast {} unconfirmed transactions on chain tip change", - tx_count - ); - } + let count: usize = unconfirmed_outbound_txids + .into_iter() + .filter_map(|txid| { + let tx = locked_wallet.tx_details(txid).map(|d| (*d.tx).clone())?; + self.broadcaster.broadcast_unclassified_transactions(vec![tx]); + Some(()) + }) + .count(); + if count != 0 { + log_info!( + self.logger, + "Rebroadcast {} unconfirmed transactions on chain tip change", + count, + ); } }, WalletEvent::TxUnconfirmed { txid, tx, .. } => { From 4c8ecb28303f07ca7aebd42d1e9195dc074feedd Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sat, 27 Jun 2026 19:43:51 +0000 Subject: [PATCH 02/12] Fix anchor reserves when splicing in all funds We stop requiring that splice-ins leave an anchor reserve for an additional anchor channel on top of the existing set of anchor channels; after splice-ins, our anchor reserve only needs to cover the existing set of anchor channels. --- src/lib.rs | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index acfcbc0d4..e216f82fd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1251,7 +1251,7 @@ impl Node { FundingAmount::Exact { amount_sats } => { // Check funds availability after connection (includes anchor reserve // calculation). - self.check_sufficient_funds_for_channel(amount_sats, &peer_info.node_id)?; + self.check_sufficient_onchain_funds(amount_sats, &peer_info.node_id, true)?; amount_sats }, FundingAmount::Max => { @@ -1357,33 +1357,37 @@ impl Node { Ok(new_channel_anchor_reserve_sats(&self.config, peer_node_id, anchor_channel)) } - fn check_sufficient_funds_for_channel( - &self, amount_sats: u64, peer_node_id: &PublicKey, + fn check_sufficient_onchain_funds( + &self, amount_sats: u64, peer_node_id: &PublicKey, for_new_channel: bool, ) -> Result<(), Error> { + let action_str = if for_new_channel { "create channel" } else { "splice-in" }; let cur_anchor_reserve_sats = total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); let spendable_amount_sats = self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); - // Fail early if we have less than the channel value available. if spendable_amount_sats < amount_sats { - log_error!(self.logger, - "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", - spendable_amount_sats, amount_sats + log_error!( + self.logger, + "Unable to {} due to insufficient funds. Available: {}sats, Required: {}sats", + action_str, + spendable_amount_sats, + amount_sats ); return Err(Error::InsufficientFunds); } - // Fail if we have less than the channel value + anchor reserve available (if applicable). - let required_funds_sats = - amount_sats + self.new_channel_anchor_reserve_sats(peer_node_id)?; + if for_new_channel { + let required_funds_sats = + amount_sats + self.new_channel_anchor_reserve_sats(peer_node_id)?; - if spendable_amount_sats < required_funds_sats { - log_error!(self.logger, - "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", - spendable_amount_sats, required_funds_sats - ); - return Err(Error::InsufficientFunds); + if spendable_amount_sats < required_funds_sats { + log_error!(self.logger, + "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", + spendable_amount_sats, required_funds_sats + ); + return Err(Error::InsufficientFunds); + } } Ok(()) @@ -1659,7 +1663,7 @@ impl Node { }, }; - self.check_sufficient_funds_for_channel(splice_amount_sats, &counterparty_node_id)?; + self.check_sufficient_onchain_funds(splice_amount_sats, &counterparty_node_id, false)?; let funding_template = self .channel_manager From 2c2c2870fe755fd01641046a6801af51d3bd7154 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 26 Jun 2026 19:37:40 +0000 Subject: [PATCH 03/12] Reserve onchain funds for anchor channels when peer sets them optional When we are preparing to open a channel to a peer, we should reserve onchain funds for an anchor channel when the peer's init features signals anchor channels as optional, as channel negotiation with such a peer can result in an anchor channel. Tests written with codex. --- src/lib.rs | 2 +- src/liquidity/service/lsps2.rs | 4 +- tests/integration_tests_rust.rs | 157 +++++++++++++++++++++++++++++++- 3 files changed, 159 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e216f82fd..e82edf625 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1353,7 +1353,7 @@ impl Node { .peer_by_node_id(peer_node_id) .ok_or(Error::ConnectionFailed)? .init_features; - let anchor_channel = init_features.requires_anchors_zero_fee_htlc_tx(); + let anchor_channel = init_features.supports_anchors_zero_fee_htlc_tx(); Ok(new_channel_anchor_reserve_sats(&self.config, peer_node_id, anchor_channel)) } diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index 1143a08d7..524157a67 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -454,7 +454,7 @@ where self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); let required_funds_sats = channel_amount_sats + self.config.anchor_channels_config.as_ref().map_or(0, |c| { - if init_features.requires_anchors_zero_fee_htlc_tx() + if init_features.supports_anchors_zero_fee_htlc_tx() && !c.trusted_peers_no_reserve.contains(&their_network_key) { c.per_channel_reserve_sats @@ -465,7 +465,7 @@ where if spendable_amount_sats < required_funds_sats { log_error!(self.logger, "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", - spendable_amount_sats, channel_amount_sats + spendable_amount_sats, required_funds_sats, ); // TODO: We just silently fail here. Eventually we will need to remember // the pending requests and regularly retry opening the channel until we diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index b07a90629..dffb8386f 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -36,7 +36,7 @@ use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{BuildError, Builder, Event, Node, NodeError}; +use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -3937,6 +3937,161 @@ async fn open_channel_with_all_with_anchors() { node_b.stop().unwrap(); } +#[derive(Clone, Copy)] +enum OpenChannelVariant { + Standard, + Announced, + ZeroReserve, + StandardWithAll, + AnnouncedWithAll, + ZeroReserveWithAll, +} + +impl OpenChannelVariant { + fn label(&self) -> &'static str { + match self { + Self::Standard => "open_channel", + Self::Announced => "open_announced_channel", + Self::ZeroReserve => "open_0reserve_channel", + Self::StandardWithAll => "open_channel_with_all", + Self::AnnouncedWithAll => "open_announced_channel_with_all", + Self::ZeroReserveWithAll => "open_0reserve_channel_with_all", + } + } +} + +fn open_channel_variant( + variant: OpenChannelVariant, node_a: &Node, node_b: &Node, channel_amount_sats: u64, +) -> Result<(), NodeError> { + let address = node_b.listening_addresses().unwrap().first().unwrap().clone(); + match variant { + OpenChannelVariant::Standard => node_a + .open_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .map(|_| ()), + OpenChannelVariant::Announced => node_a + .open_announced_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .map(|_| ()), + OpenChannelVariant::ZeroReserve => node_a + .open_0reserve_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .map(|_| ()), + OpenChannelVariant::StandardWithAll => { + node_a.open_channel_with_all(node_b.node_id(), address, None, None).map(|_| ()) + }, + OpenChannelVariant::AnnouncedWithAll => node_a + .open_announced_channel_with_all(node_b.node_id(), address, None, None) + .map(|_| ()), + OpenChannelVariant::ZeroReserveWithAll => { + node_a.open_0reserve_channel_with_all(node_b.node_id(), address, None, None).map(|_| ()) + }, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn open_channel_variants_reserve_funds_for_anchor_peers() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let exact_variants = [ + OpenChannelVariant::Standard, + OpenChannelVariant::Announced, + OpenChannelVariant::ZeroReserve, + ]; + let with_all_variants = [ + OpenChannelVariant::StandardWithAll, + OpenChannelVariant::AnnouncedWithAll, + OpenChannelVariant::ZeroReserveWithAll, + ]; + + let premine_amount_sat = 1_000_000; + let exact_channel_amount_sat = premine_amount_sat - 10_000; + let anchor_reserve_sat = 25_000; + + let mut addresses = Vec::new(); + let mut exact_cases = Vec::new(); + for variant in exact_variants { + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + addresses.push(node_a.onchain_payment().new_address().unwrap()); + addresses.push(node_b.onchain_payment().new_address().unwrap()); + exact_cases.push((variant, node_a, node_b)); + } + + let mut with_all_cases = Vec::new(); + for variant in with_all_variants { + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + addresses.push(node_a.onchain_payment().new_address().unwrap()); + addresses.push(node_b.onchain_payment().new_address().unwrap()); + with_all_cases.push((variant, node_a, node_b)); + } + + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + addresses, + Amount::from_sat(premine_amount_sat), + ) + .await; + + for (_, node_a, node_b) in exact_cases.iter().chain(with_all_cases.iter()) { + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + assert_eq!(node_b.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + } + + for (variant, node_a, node_b) in exact_cases { + assert_eq!( + Err(NodeError::InsufficientFunds), + open_channel_variant(variant, &node_a, &node_b, exact_channel_amount_sat), + "{} should require funds for the channel amount plus anchor reserve", + variant.label() + ); + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + let mut opened_with_all_cases = Vec::new(); + for (variant, node_a, node_b) in with_all_cases { + open_channel_variant(variant, &node_a, &node_b, 0) + .unwrap_or_else(|e| panic!("{} failed: {e:?}", variant.label())); + + let funding_txo_a = expect_channel_pending_event!(node_a, node_b.node_id()); + let funding_txo_b = expect_channel_pending_event!(node_b, node_a.node_id()); + assert_eq!(funding_txo_a, funding_txo_b, "{} funding txo mismatch", variant.label()); + wait_for_tx(&electrsd.client, funding_txo_a.txid).await; + + opened_with_all_cases.push((variant, node_a, node_b, funding_txo_a)); + } + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + for (variant, node_a, node_b, funding_txo) in opened_with_all_cases { + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let _user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + let balances = node_a.list_balances(); + assert_eq!(balances.total_onchain_balance_sats, anchor_reserve_sat - 1); + assert_eq!(balances.total_anchor_channels_reserve_sats, anchor_reserve_sat - 1); + assert_eq!(balances.spendable_onchain_balance_sats, 0); + + let channels = node_a.list_channels(); + assert_eq!(channels.len(), 1, "{} should have one channel", variant.label()); + let channel = &channels[0]; + // Also subtract the fees spent to open the channel + assert_eq!(channel.channel_value_sats, premine_amount_sat - anchor_reserve_sat - 155); + assert_eq!(channel.counterparty.node_id, node_b.node_id()); + assert!(channel.counterparty.features.supports_anchors_zero_fee_htlc_tx()); + assert!(!channel.counterparty.features.requires_anchors_zero_fee_htlc_tx()); + assert_eq!(channel.funding_txo.unwrap(), funding_txo); + assert_eq!(channel.reserve_type, Some(ReserveType::Adaptive)); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn open_channel_with_all_without_anchors() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From fac8a1f5d88825b13de9d52933d1a91b028cb11f Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 30 Jun 2026 17:06:05 +0000 Subject: [PATCH 04/12] Remove the ability to disable anchor channels We previously allowed users to disable anchor channels and drain their anchor reserve while still having anchor channels open or pending resolution. This was acceptable for keyed anchor channels, as the commitment transaction therein still contained some fees, and had some chance of getting mined into a block without any anchor bumps. In upcoming commits, we will add support for 0FC channels, and their commitment transactions have zero fees and depend entirely on the anchor reserve to reach miners and get confirmed in a block. It is thus dangerous to disable anchor channels and drain the reserve after 0FC channels have been opened. Therefore, we make `AnchorChannelsConfig` required, and prevent this case from ever happening. --- CHANGELOG.md | 2 + benches/payments.rs | 9 +- src/config.rs | 20 +-- src/event.rs | 40 ++--- src/lib.rs | 43 +++--- src/liquidity/service/lsps2.rs | 20 +-- src/types.rs | 20 +-- tests/common/mod.rs | 31 ++-- tests/common/scenarios/mod.rs | 2 +- tests/integration_tests_hrn.rs | 2 +- tests/integration_tests_migration.rs | 4 +- tests/integration_tests_postgres.rs | 4 +- tests/integration_tests_rust.rs | 218 +++++++++------------------ tests/integration_tests_vss.rs | 4 +- tests/probing_tests.rs | 24 +-- tests/reorg_test.rs | 11 +- 16 files changed, 163 insertions(+), 291 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 115b0ed05..b231e8d1c 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. +- `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. ## Bug Fixes and Improvements - Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the diff --git a/benches/payments.rs b/benches/payments.rs index 52769d794..926dc5dad 100644 --- a/benches/payments.rs +++ b/benches/payments.rs @@ -121,13 +121,8 @@ fn payment_benchmark(c: &mut Criterion) { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes_with_store( - &chain_source, - false, - true, - false, - common::TestStoreType::Sqlite, - ); + let (node_a, node_b) = + setup_two_nodes_with_store(&chain_source, false, false, common::TestStoreType::Sqlite); let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(4).enable_all().build().unwrap(); diff --git a/src/config.rs b/src/config.rs index f168df94e..b446ee298 100644 --- a/src/config.rs +++ b/src/config.rs @@ -143,7 +143,7 @@ pub(crate) const LNURL_AUTH_TIMEOUT_SECS: u64 = 15; /// | `node_alias` | None | /// | `trusted_peers_0conf` | [] | /// | `probing_liquidity_limit_multiplier` | 3 | -/// | `anchor_channels_config` | Some(..) | +/// | `anchor_channels_config` | AnchorChannelsConfig::default() | /// | `route_parameters` | None | /// | `tor_config` | None | /// | `hrn_config` | HumanReadableNamesConfig::default() | @@ -190,19 +190,7 @@ pub struct Config { /// `option_anchors_zero_fee_htlc_tx` channel type is negotiated. /// /// Please refer to [`AnchorChannelsConfig`] for further information on Anchor channels. - /// - /// If set to `Some`, we'll try to open new channels with Anchors enabled, i.e., new channels - /// will be negotiated with the `option_anchors_zero_fee_htlc_tx` channel type if supported by - /// the counterparty. Note that this won't prevent us from opening non-Anchor channels if the - /// counterparty doesn't support `option_anchors_zero_fee_htlc_tx`. If set to `None`, new - /// channels will be negotiated with the legacy `option_static_remotekey` channel type only. - /// - /// **Note:** If set to `None` *after* some Anchor channels have already been - /// opened, no dedicated emergency on-chain reserve will be maintained for these channels, - /// which can be dangerous if only insufficient funds are available at the time of channel - /// closure. We *will* however still try to get the Anchor spending transactions confirmed - /// on-chain with the funds available. - pub anchor_channels_config: Option, + pub anchor_channels_config: AnchorChannelsConfig, /// Configuration options for payment routing and pathfinding. /// /// Setting the [`RouteParametersConfig`] provides flexibility to customize how payments are routed, @@ -233,7 +221,7 @@ impl Default for Config { announcement_addresses: None, trusted_peers_0conf: Vec::new(), probing_liquidity_limit_multiplier: DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER, - anchor_channels_config: Some(AnchorChannelsConfig::default()), + anchor_channels_config: AnchorChannelsConfig::default(), tor_config: None, route_parameters: None, node_alias: None, @@ -418,8 +406,6 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { // will mostly be relevant for inbound channels. let mut user_config = UserConfig::default(); user_config.channel_handshake_limits.force_announced_channel_preference = false; - user_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = - config.anchor_channels_config.is_some(); user_config.reject_inbound_splices = false; if may_announce_channel(config).is_err() { diff --git a/src/event.rs b/src/event.rs index 91ab7b27d..1fb148bd1 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1267,24 +1267,6 @@ where } let anchor_channel = channel_type.requires_anchors_zero_fee_htlc_tx(); - if anchor_channel && self.config.anchor_channels_config.is_none() { - log_error!( - self.logger, - "Rejecting inbound channel from peer {} due to Anchor channels being disabled.", - counterparty_node_id, - ); - self.channel_manager - .force_close_broadcasting_latest_txn( - &temporary_channel_id, - &counterparty_node_id, - "Channel request rejected".to_string(), - ) - .unwrap_or_else(|e| { - log_error!(self.logger, "Failed to reject channel: {:?}", e) - }); - return Ok(()); - } - let required_reserve_sats = crate::new_channel_anchor_reserve_sats( &self.config, &counterparty_node_id, @@ -1750,19 +1732,17 @@ where .. } => { // Skip bumping channel closes if our counterparty is trusted. - if let Some(anchor_channels_config) = - self.config.anchor_channels_config.as_ref() + if self + .config + .anchor_channels_config + .trusted_peers_no_reserve + .contains(counterparty_node_id) { - if anchor_channels_config - .trusted_peers_no_reserve - .contains(counterparty_node_id) - { - log_debug!(self.logger, - "Ignoring BumpTransactionEvent::ChannelClose for channel {} due to trusted counterparty {}", - channel_id, counterparty_node_id - ); - return Ok(()); - } + log_debug!(self.logger, + "Ignoring BumpTransactionEvent::ChannelClose for channel {} due to trusted counterparty {}", + channel_id, counterparty_node_id + ); + return Ok(()); } }, BumpTransactionEvent::HTLCResolution { .. } => {}, diff --git a/src/lib.rs b/src/lib.rs index e82edf625..0d50977e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1169,7 +1169,7 @@ impl Node { self.channel_manager .list_channels() .into_iter() - .map(|c| ChannelDetails::from_ldk(c, self.config.anchor_channels_config.as_ref())) + .map(|c| ChannelDetails::from_ldk(c, &self.config.anchor_channels_config)) .collect() } @@ -2406,21 +2406,20 @@ impl_writeable_tlv_based!(NodeMetrics, { pub(crate) fn total_anchor_channels_reserve_sats( channel_manager: &ChannelManager, config: &Config, ) -> u64 { - config.anchor_channels_config.as_ref().map_or(0, |anchor_channels_config| { - channel_manager - .list_channels() - .into_iter() - .filter(|c| { - !anchor_channels_config.trusted_peers_no_reserve.contains(&c.counterparty.node_id) - && c.channel_shutdown_state - .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) - && c.channel_type - .as_ref() - .map_or(false, |t| t.requires_anchors_zero_fee_htlc_tx()) - }) - .count() as u64 - * anchor_channels_config.per_channel_reserve_sats - }) + channel_manager + .list_channels() + .into_iter() + .filter(|c| { + !config + .anchor_channels_config + .trusted_peers_no_reserve + .contains(&c.counterparty.node_id) + && c.channel_shutdown_state + .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) + && c.channel_type.as_ref().map_or(false, |t| t.requires_anchors_zero_fee_htlc_tx()) + }) + .count() as u64 + * config.anchor_channels_config.per_channel_reserve_sats } pub(crate) fn new_channel_anchor_reserve_sats( @@ -2430,13 +2429,11 @@ pub(crate) fn new_channel_anchor_reserve_sats( return 0; } - config.anchor_channels_config.as_ref().map_or(0, |c| { - if c.trusted_peers_no_reserve.contains(peer_node_id) { - 0 - } else { - c.per_channel_reserve_sats - } - }) + if config.anchor_channels_config.trusted_peers_no_reserve.contains(peer_node_id) { + 0 + } else { + config.anchor_channels_config.per_channel_reserve_sats + } } #[cfg(test)] diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index 524157a67..ca70cd8d8 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -453,15 +453,17 @@ where let spendable_amount_sats = self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); let required_funds_sats = channel_amount_sats - + self.config.anchor_channels_config.as_ref().map_or(0, |c| { - if init_features.supports_anchors_zero_fee_htlc_tx() - && !c.trusted_peers_no_reserve.contains(&their_network_key) - { - c.per_channel_reserve_sats - } else { - 0 - } - }); + + if init_features.supports_anchors_zero_fee_htlc_tx() + && !self + .config + .anchor_channels_config + .trusted_peers_no_reserve + .contains(&their_network_key) + { + self.config.anchor_channels_config.per_channel_reserve_sats + } else { + 0 + }; if spendable_amount_sats < required_funds_sats { log_error!(self.logger, "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", diff --git a/src/types.rs b/src/types.rs index e24db4d25..2ac08a48f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -646,24 +646,16 @@ pub struct ChannelDetails { impl ChannelDetails { pub(crate) fn from_ldk( - value: LdkChannelDetails, anchor_channels_config: Option<&AnchorChannelsConfig>, + value: LdkChannelDetails, anchor_channels_config: &AnchorChannelsConfig, ) -> Self { let reserve_type = value.channel_type.as_ref().map(|channel_type| { if channel_type.supports_anchors_zero_fee_htlc_tx() { - if let Some(config) = anchor_channels_config { - if config.trusted_peers_no_reserve.contains(&value.counterparty.node_id) { - ReserveType::TrustedPeersNoReserve - } else { - ReserveType::Adaptive - } + if anchor_channels_config + .trusted_peers_no_reserve + .contains(&value.counterparty.node_id) + { + ReserveType::TrustedPeersNoReserve } else { - // Edge case: if `AnchorChannelsConfig` was previously set and later - // removed, we can no longer distinguish whether this anchor channel's - // reserve was `Adaptive` or `TrustedPeersNoReserve`. We default to - // `Adaptive` here, which may incorrectly override a prior - // `TrustedPeersNoReserve` designation. This is acceptable since - // unsetting `AnchorChannelsConfig` on a node with existing anchor - // channels is not an expected operation. ReserveType::Adaptive } } else { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index aeacef464..5026780de 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -379,13 +379,9 @@ pub(crate) fn random_node_alias() -> Option { Some(NodeAlias(bytes)) } -pub(crate) fn random_config(anchor_channels: bool) -> TestConfig { +pub(crate) fn random_config() -> TestConfig { let mut node_config = Config::default(); - if !anchor_channels { - node_config.anchor_channels_config = None; - } - node_config.network = Network::Regtest; println!("Setting network: {}", node_config.network); @@ -594,24 +590,22 @@ pub(crate) use setup_builder; pub(crate) mod scenarios; pub(crate) fn setup_two_nodes( - chain_source: &TestChainSource, allow_0conf: bool, anchor_channels: bool, - anchors_trusted_no_reserve: bool, + chain_source: &TestChainSource, allow_0conf: bool, anchors_trusted_no_reserve: bool, ) -> (TestNode, TestNode) { setup_two_nodes_with_store( chain_source, allow_0conf, - anchor_channels, anchors_trusted_no_reserve, TestStoreType::TestSyncStore, ) } pub(crate) fn setup_two_nodes_with_store( - chain_source: &TestChainSource, allow_0conf: bool, anchor_channels: bool, - anchors_trusted_no_reserve: bool, store_type: TestStoreType, + chain_source: &TestChainSource, allow_0conf: bool, anchors_trusted_no_reserve: bool, + store_type: TestStoreType, ) -> (TestNode, TestNode) { println!("== Node A =="); - let mut config_a = random_config(anchor_channels); + let mut config_a = random_config(); config_a.store_type = store_type; if cfg!(hrn_tests) { @@ -622,7 +616,7 @@ pub(crate) fn setup_two_nodes_with_store( let node_a = setup_node(chain_source, config_a); println!("\n== Node B =="); - let mut config_b = random_config(anchor_channels); + let mut config_b = random_config(); config_b.store_type = store_type; if cfg!(hrn_tests) { @@ -637,14 +631,8 @@ pub(crate) fn setup_two_nodes_with_store( if allow_0conf { config_b.node_config.trusted_peers_0conf.push(node_a.node_id()); } - if anchor_channels && anchors_trusted_no_reserve { - config_b - .node_config - .anchor_channels_config - .as_mut() - .unwrap() - .trusted_peers_no_reserve - .push(node_a.node_id()); + if anchors_trusted_no_reserve { + config_b.node_config.anchor_channels_config.trusted_peers_no_reserve.push(node_a.node_id()); } let node_b = setup_node(chain_source, config_b); (node_a, node_b) @@ -1199,7 +1187,8 @@ pub(crate) async fn do_channel_full_cycle( let node_b_anchor_reserve_sat = if node_b .config() .anchor_channels_config - .map_or(true, |acc| acc.trusted_peers_no_reserve.contains(&node_a.node_id())) + .trusted_peers_no_reserve + .contains(&node_a.node_id()) { 0 } else { diff --git a/tests/common/scenarios/mod.rs b/tests/common/scenarios/mod.rs index 7cbf56b8e..ffbfc2b00 100644 --- a/tests/common/scenarios/mod.rs +++ b/tests/common/scenarios/mod.rs @@ -90,7 +90,7 @@ 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(true); + 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(); sync_config.timeouts_config.onchain_wallet_sync_timeout_secs = 180; diff --git a/tests/integration_tests_hrn.rs b/tests/integration_tests_hrn.rs index 910240039..6e758105a 100644 --- a/tests/integration_tests_hrn.rs +++ b/tests/integration_tests_hrn.rs @@ -24,7 +24,7 @@ async fn unified_send_to_hrn() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premined_sats = 5_000_000; diff --git a/tests/integration_tests_migration.rs b/tests/integration_tests_migration.rs index c4e63451a..7e5767dca 100644 --- a/tests/integration_tests_migration.rs +++ b/tests/integration_tests_migration.rs @@ -148,7 +148,7 @@ async fn migrate_node_across_all_backends() { let connection_string = test_connection_string(); // Set up node B, the Lightning counterparty. - let config_b = common::random_config(false); + let config_b = common::random_config(); let node_b_instance = BackendInstance::new( MigrationBackend::Postgres, &config_b.node_config.storage_dir_path, @@ -167,7 +167,7 @@ async fn migrate_node_across_all_backends() { // Spin up the node we'll migrate on the first backend. The same node config (storage dir, // listening addresses, identity) is reused across every hop — only the backend changes — so // each backend's store lives in its own subdirectory of the one storage dir. - let config = common::random_config(false); + let config = common::random_config(); let node_entropy = config.node_entropy; let node_config = config.node_config; let base_dir = node_config.storage_dir_path.clone(); diff --git a/tests/integration_tests_postgres.rs b/tests/integration_tests_postgres.rs index 0c93c705c..889d681ba 100644 --- a/tests/integration_tests_postgres.rs +++ b/tests/integration_tests_postgres.rs @@ -22,7 +22,7 @@ async fn channel_full_cycle_with_postgres_store() { let (bitcoind, electrsd) = common::setup_bitcoind_and_electrsd(); println!("== Node A =="); let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); - let config_a = common::random_config(true); + let config_a = common::random_config(); let mut builder_a = Builder::from_config(config_a.node_config); builder_a.set_chain_source_esplora(esplora_url.clone(), None); let node_a = builder_a @@ -37,7 +37,7 @@ async fn channel_full_cycle_with_postgres_store() { node_a.start().unwrap(); println!("\n== Node B =="); - let config_b = common::random_config(true); + let config_b = common::random_config(); let mut builder_b = Builder::from_config(config_b.node_config); builder_b.set_chain_source_esplora(esplora_url.clone(), None); let node_b = builder_b diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index dffb8386f..7852e73b7 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -77,7 +77,7 @@ async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { async fn channel_full_cycle() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = TestChainSource::BitcoindRpcSync(&bitcoind); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); do_channel_full_cycle( node_a, node_b, @@ -95,7 +95,7 @@ async fn channel_full_cycle() { async fn channel_full_cycle_force_close() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); do_channel_full_cycle( node_a, node_b, @@ -113,7 +113,7 @@ async fn channel_full_cycle_force_close() { async fn channel_full_cycle_force_close_trusted_no_reserve() { 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, true); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true); do_channel_full_cycle( node_a, node_b, @@ -136,7 +136,7 @@ async fn peer_removed_when_counterparty_force_closes_last_channel() { // than reconnected to forever. 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premine_amount_sat = 5_000_000; @@ -181,7 +181,7 @@ async fn peer_removed_when_counterparty_force_closes_last_channel() { async fn channel_full_cycle_0conf() { 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, true, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, true, false); do_channel_full_cycle( node_a, node_b, @@ -195,29 +195,11 @@ async fn channel_full_cycle_0conf() { .await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn channel_full_cycle_legacy_staticremotekey() { - 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, false, false); - do_channel_full_cycle( - node_a, - node_b, - &bitcoind.client, - &electrsd.client, - false, - false, - false, - false, - ) - .await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle_0reserve() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); do_channel_full_cycle( node_a, node_b, @@ -235,7 +217,7 @@ async fn channel_full_cycle_0reserve() { async fn channel_full_cycle_0conf_0reserve() { 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, true, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, true, false); do_channel_full_cycle( node_a, node_b, @@ -253,7 +235,7 @@ async fn channel_full_cycle_0conf_0reserve() { async fn channel_open_fails_when_funds_insufficient() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -293,7 +275,7 @@ async fn multi_hop_sending() { // Setup and fund 5 nodes let mut nodes = Vec::new(); for _ in 0..5 { - let config = random_config(true); + let config = random_config(); let mut sync_config = EsploraSyncConfig::default(); sync_config.background_sync_config = None; setup_builder!(builder, config.node_config); @@ -388,8 +370,8 @@ async fn multi_hop_sending() { async fn split_underpaid_bolt11_payment() { 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 node_c = setup_node(&chain_source, random_config(true)); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); + let node_c = setup_node(&chain_source, random_config()); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -482,7 +464,7 @@ async fn split_underpaid_bolt11_payment() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn start_stop_reinit() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let config = random_config(true); + let config = random_config(); let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); @@ -555,7 +537,7 @@ async fn start_stop_reinit() { async fn onchain_send_receive() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -760,7 +742,7 @@ async fn onchain_send_receive() { async fn reorged_onchain_payment_returns_to_unconfirmed() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -828,7 +810,7 @@ async fn reorged_onchain_payment_returns_to_unconfirmed() { async fn onchain_send_all_retains_reserve() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); // Setup nodes let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -914,7 +896,7 @@ async fn onchain_wallet_recovery() { let chain_source = random_chain_source(&bitcoind, &electrsd); - let original_config = random_config(true); + let original_config = random_config(); let original_node_entropy = original_config.node_entropy; let original_node = setup_node(&chain_source, original_config); @@ -955,7 +937,7 @@ async fn onchain_wallet_recovery() { drop(original_node); // Now we start from scratch, only the seed remains the same. - let mut recovered_config = random_config(true); + let mut recovered_config = random_config(); recovered_config.node_entropy = original_node_entropy; recovered_config.wallet_rescan_from_height = Some(0); let recovered_node = setup_node(&chain_source, recovered_config); @@ -997,7 +979,7 @@ async fn onchain_wallet_force_full_scan_rediscovers_esplora_funds() { premine_blocks(&bitcoind.client, &electrsd.client).await; - let address_source_config = random_config(true); + let address_source_config = random_config(); let node_entropy = address_source_config.node_entropy; let address_source_node = setup_node(&chain_source, address_source_config); let addr_1 = address_source_node.onchain_payment().new_address().unwrap(); @@ -1006,7 +988,7 @@ async fn onchain_wallet_force_full_scan_rediscovers_esplora_funds() { drop(address_source_node); let premine_amount_sat = 100_000; - let mut stale_config = random_config(true); + let mut stale_config = random_config(); stale_config.node_entropy = node_entropy; stale_config.store_type = TestStoreType::Sqlite; let stale_node = setup_node(&chain_source, stale_config.clone()); @@ -1077,7 +1059,7 @@ async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( ) { let configured_stop_gap = DEFAULT_FULL_SCAN_STOP_GAP + 5; - let address_source_config = random_config(true); + let address_source_config = random_config(); let node_entropy = address_source_config.node_entropy; let address_source_node = setup_node(&chain_source, address_source_config); let mut far_address = None; @@ -1099,7 +1081,7 @@ async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( wait_for_tx(&electrsd.client, txid).await; generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; - let mut default_gap_config = random_config(true); + let mut default_gap_config = random_config(); default_gap_config.node_entropy = node_entropy.clone(); let default_gap_node = setup_node(&chain_source, default_gap_config); default_gap_node.sync_wallets().unwrap(); @@ -1111,7 +1093,7 @@ async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( default_gap_node.stop().unwrap(); drop(default_gap_node); - let mut configured_gap_config = random_config(true); + let mut configured_gap_config = random_config(); configured_gap_config.node_entropy = node_entropy; configured_gap_config.full_scan_stop_gap = Some(configured_stop_gap); let configured_gap_node = setup_node(&chain_source, configured_gap_config); @@ -1146,7 +1128,7 @@ async fn onchain_wallet_recovery_rescans_from_birthday_height() { premine_blocks(&bitcoind.client, &electrsd.client).await; // Step 1: bring up an "original" node at the birthday height and generate addresses. - let original_config = random_config(true); + let original_config = random_config(); let original_node_entropy = original_config.node_entropy; let original_node = setup_node(&chain_source, original_config); @@ -1192,7 +1174,7 @@ async fn onchain_wallet_recovery_rescans_from_birthday_height() { // Step 5: restart a fresh node with only the seed and no rescan height. It must NOT see // the funds, because its wallet birthday sits above the funding transactions. - let mut pinned_config = random_config(true); + let mut pinned_config = random_config(); pinned_config.node_entropy = original_node_entropy; let pinned_node = setup_node(&chain_source, pinned_config); pinned_node.sync_wallets().unwrap(); @@ -1206,7 +1188,7 @@ async fn onchain_wallet_recovery_rescans_from_birthday_height() { // Step 6: restart with a rescan height set to the birthday height. Funds must be // re-discovered. - let mut recovered_config = random_config(true); + let mut recovered_config = random_config(); recovered_config.node_entropy = original_node_entropy; recovered_config.wallet_rescan_from_height = Some(birthday_height); let recovered_node = setup_node(&chain_source, recovered_config); @@ -1229,7 +1211,7 @@ async fn build_fails_when_wallet_rescan_height_is_above_tip() { .try_into() .unwrap(); - let config = random_config(false); + let config = random_config(); let entropy = config.node_entropy; setup_builder!(builder, config.node_config); @@ -1256,7 +1238,7 @@ async fn build_aborts_on_first_startup_bitcoind_tip_fetch_failure() { // A fresh node pointed at an unreachable bitcoind RPC endpoint must not silently // fall back to genesis as the wallet birthday. The build must abort cleanly so the // misconfiguration surfaces immediately. - let config = random_config(false); + let config = random_config(); let entropy = config.node_entropy; setup_builder!(builder, config.node_config); @@ -1302,17 +1284,16 @@ async fn run_rbf_test(is_insert_block: bool) { let chain_source_esplora = TestChainSource::Esplora(&electrsd); macro_rules! config_node { - ($chain_source:expr, $anchor_channels:expr) => {{ - let config_a = random_config($anchor_channels); + ($chain_source:expr) => {{ + let config_a = random_config(); let node = setup_node(&$chain_source, config_a); node }}; } - let anchor_channels = false; let nodes = vec![ - config_node!(chain_source_electrsd, anchor_channels), - config_node!(chain_source_bitcoind, anchor_channels), - config_node!(chain_source_esplora, anchor_channels), + config_node!(chain_source_electrsd), + config_node!(chain_source_bitcoind), + config_node!(chain_source_esplora), ]; let (bitcoind, electrs) = (&bitcoind.client, &electrsd.client); @@ -1421,7 +1402,7 @@ async fn run_rbf_test(is_insert_block: bool) { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn sign_verify_msg() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let config = random_config(true); + let config = random_config(); let chain_source = random_chain_source(&bitcoind, &electrsd); let node = setup_node(&chain_source, config); @@ -1436,7 +1417,7 @@ async fn sign_verify_msg() { async fn connection_multi_listen() { 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, false, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let node_id_b = node_b.node_id(); @@ -1456,7 +1437,7 @@ async fn connection_restart_behavior() { async fn do_connection_restart_behavior(persist: bool) { 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, false, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let node_id_a = node_a.node_id(); let node_id_b = node_b.node_id(); @@ -1503,7 +1484,7 @@ async fn do_connection_restart_behavior(persist: bool) { async fn concurrent_connections_succeed() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let node_a = Arc::new(node_a); let node_b = Arc::new(node_b); @@ -1531,7 +1512,7 @@ async fn splice_channel() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -1746,7 +1727,7 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -1944,7 +1925,7 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { async fn funding_payment_graduates_without_channel_ready() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -1999,7 +1980,7 @@ async fn funding_payment_graduates_without_channel_ready() { async fn splice_payment_reorged_to_unconfirmed() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -2076,7 +2057,7 @@ async fn splice_payment_reorged_to_unconfirmed() { async fn splice_in_rbf_joins_counterparty_splice() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -2125,7 +2106,7 @@ async fn splice_in_rbf_joins_counterparty_splice() { async fn simple_bolt12_send_receive() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premine_amount_sat = 5_000_000; @@ -2367,7 +2348,7 @@ async fn async_payment() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let mut config_sender = random_config(true); + let mut config_sender = random_config(); config_sender.node_config.listening_addresses = None; config_sender.node_config.node_alias = None; config_sender.log_writer = @@ -2375,20 +2356,20 @@ async fn async_payment() { config_sender.async_payments_role = Some(AsyncPaymentsRole::Client); let node_sender = setup_node(&chain_source, config_sender); - let mut config_sender_lsp = random_config(true); + let mut config_sender_lsp = random_config(); config_sender_lsp.log_writer = TestLogWriter::Custom(Arc::new(MultiNodeLogger::new("sender_lsp ".to_string()))); config_sender_lsp.async_payments_role = Some(AsyncPaymentsRole::Server); let node_sender_lsp = setup_node(&chain_source, config_sender_lsp); - let mut config_receiver_lsp = random_config(true); + let mut config_receiver_lsp = random_config(); config_receiver_lsp.log_writer = TestLogWriter::Custom(Arc::new(MultiNodeLogger::new("receiver_lsp".to_string()))); config_receiver_lsp.async_payments_role = Some(AsyncPaymentsRole::Server); let node_receiver_lsp = setup_node(&chain_source, config_receiver_lsp); - let mut config_receiver = random_config(true); + let mut config_receiver = random_config(); config_receiver.node_config.listening_addresses = None; config_receiver.node_config.node_alias = None; config_receiver.log_writer = @@ -2500,7 +2481,7 @@ async fn test_node_announcement_propagation() { let chain_source = random_chain_source(&bitcoind, &electrsd); // Node A will use both listening and announcement addresses - let mut config_a = random_config(true); + let mut config_a = random_config(); let node_a_alias_string = "ldk-node-a".to_string(); let mut node_a_alias_bytes = [0u8; 32]; node_a_alias_bytes[..node_a_alias_string.as_bytes().len()] @@ -2512,7 +2493,7 @@ async fn test_node_announcement_propagation() { config_a.node_config.announcement_addresses = Some(node_a_announcement_addresses.clone()); // Node B will only use listening addresses - let mut config_b = random_config(true); + let mut config_b = random_config(); let node_b_alias_string = "ldk-node-b".to_string(); let mut node_b_alias_bytes = [0u8; 32]; node_b_alias_bytes[..node_b_alias_string.as_bytes().len()] @@ -2597,7 +2578,7 @@ async fn generate_bip21_uri() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premined_sats = 5_000_000; @@ -2651,7 +2632,7 @@ async fn generate_bip21_uri() { async fn unified_receive_rejects_msat_overflow() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let node = setup_node(&chain_source, random_config(true)); + let node = setup_node(&chain_source, random_config()); assert_eq!( Err(NodeError::InvalidAmount), @@ -2664,7 +2645,7 @@ async fn unified_send_receive_bip21_uri() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premined_sats = 5_000_000; @@ -2801,7 +2782,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { disable_client_reserve: false, }; - let service_config = random_config(true); + let service_config = random_config(); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); service_builder.enable_liquidity_provider(lsps2_service_config); @@ -2811,14 +2792,14 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { let service_node_id = service_node.node_id(); let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); - let client_config = random_config(true); + let client_config = random_config(); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); client_builder.add_liquidity_source(service_node_id, service_addr, None, true); let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); client_node.start().unwrap(); - let payer_config = random_config(true); + let payer_config = random_config(); setup_builder!(payer_builder, payer_config.node_config); payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); @@ -3006,7 +2987,7 @@ async fn facade_logging() { let chain_source = random_chain_source(&bitcoind, &electrsd); let logger = init_log_logger(LevelFilter::Trace); - let mut config = random_config(false); + let mut config = random_config(); config.log_writer = TestLogWriter::LogFacade; println!("== Facade logging starts =="); @@ -3022,7 +3003,7 @@ async fn facade_logging() { async fn spontaneous_send_with_custom_preimage() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premine_sat = 1_000_000; @@ -3089,7 +3070,7 @@ async fn spontaneous_send_with_custom_preimage() { async fn drop_in_async_context() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let config = random_config(true); + let config = random_config(); let node = setup_node(&chain_source, config); node.stop().unwrap(); } @@ -3120,7 +3101,7 @@ async fn lsps2_client_trusts_lsp() { disable_client_reserve: false, }; - let service_config = random_config(true); + let service_config = random_config(); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); service_builder.enable_liquidity_provider(lsps2_service_config); @@ -3129,7 +3110,7 @@ async fn lsps2_client_trusts_lsp() { let service_node_id = service_node.node_id(); let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); - let client_config = random_config(true); + let client_config = random_config(); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); client_builder.add_liquidity_source(service_node_id, service_addr.clone(), None, true); @@ -3137,7 +3118,7 @@ async fn lsps2_client_trusts_lsp() { client_node.start().unwrap(); let client_node_id = client_node.node_id(); - let payer_config = random_config(true); + let payer_config = random_config(); setup_builder!(payer_builder, payer_config.node_config); payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); @@ -3295,7 +3276,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { disable_client_reserve: false, }; - let service_config = random_config(true); + let service_config = random_config(); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); service_builder.enable_liquidity_provider(lsps2_service_config); @@ -3305,7 +3286,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { let service_node_id = service_node.node_id(); let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); - let client_config = random_config(true); + let client_config = random_config(); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); client_builder.add_liquidity_source(service_node_id, service_addr.clone(), None, true); @@ -3314,7 +3295,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { let client_node_id = client_node.node_id(); - let payer_config = random_config(true); + let payer_config = random_config(); setup_builder!(payer_builder, payer_config.node_config); payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); @@ -3405,7 +3386,7 @@ async fn payment_persistence_after_restart() { // Setup nodes manually so we can restart node_a with the same config println!("== Node A =="); - let mut config_a = random_config(true); + let mut config_a = random_config(); config_a.store_type = TestStoreType::Sqlite; let num_payments = 200; @@ -3415,7 +3396,7 @@ async fn payment_persistence_after_restart() { let node_a = setup_node(&chain_source, config_a.clone()); println!("\n== Node B =="); - let config_b = random_config(true); + let config_b = random_config(); let node_b = setup_node(&chain_source, config_b); let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -3697,7 +3678,7 @@ async fn fs_store_persistence_backwards_compatibility() { async fn onchain_fee_bump_rbf() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); // Fund both nodes let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -3839,7 +3820,7 @@ async fn onchain_fee_bump_rbf() { async fn onchain_fee_bump_rbf_respects_anchor_reserve() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -3888,7 +3869,7 @@ async fn onchain_fee_bump_rbf_respects_anchor_reserve() { async fn open_channel_with_all_with_anchors() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -4009,7 +3990,7 @@ async fn open_channel_variants_reserve_funds_for_anchor_peers() { let mut addresses = Vec::new(); let mut exact_cases = Vec::new(); for variant in exact_variants { - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); addresses.push(node_a.onchain_payment().new_address().unwrap()); addresses.push(node_b.onchain_payment().new_address().unwrap()); exact_cases.push((variant, node_a, node_b)); @@ -4017,7 +3998,7 @@ async fn open_channel_variants_reserve_funds_for_anchor_peers() { let mut with_all_cases = Vec::new(); for variant in with_all_variants { - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); addresses.push(node_a.onchain_payment().new_address().unwrap()); addresses.push(node_b.onchain_payment().new_address().unwrap()); with_all_cases.push((variant, node_a, node_b)); @@ -4092,62 +4073,11 @@ async fn open_channel_variants_reserve_funds_for_anchor_peers() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn open_channel_with_all_without_anchors() { - 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, false, false); - - let addr_a = node_a.onchain_payment().new_address().unwrap(); - let addr_b = node_b.onchain_payment().new_address().unwrap(); - - let premine_amount_sat = 1_000_000; - - premine_and_distribute_funds( - &bitcoind.client, - &electrsd.client, - vec![addr_a, addr_b], - Amount::from_sat(premine_amount_sat), - ) - .await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, premine_amount_sat); - - let funding_txo = open_channel_with_all(&node_a, &node_b, false, &electrsd).await; - - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - - let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); - let _user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); - - // Without anchors, there should be no remaining balance - let remaining_balance = node_a.list_balances().spendable_onchain_balance_sats; - assert_eq!( - remaining_balance, 0, - "Remaining balance {remaining_balance} should be zero without anchor reserve" - ); - - // Verify a channel was opened with all the funds accounting for fees - let channels = node_a.list_channels(); - assert_eq!(channels.len(), 1); - let channel = &channels[0]; - assert!(channel.channel_value_sats > premine_amount_sat - 500); - assert_eq!(channel.counterparty.node_id, node_b.node_id()); - assert_eq!(channel.funding_txo.unwrap(), funding_txo); - - node_a.stop().unwrap(); - node_b.stop().unwrap(); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn splice_in_with_all_balance() { 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 (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -4248,7 +4178,7 @@ async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { client_trusts_lsp: true, disable_client_reserve: false, }; - let cheap_node_config = random_config(true); + let cheap_node_config = random_config(); setup_builder!(cheap_builder, cheap_node_config.node_config); cheap_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); cheap_builder.enable_liquidity_provider(cheap_cfg); @@ -4271,7 +4201,7 @@ async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { client_trusts_lsp: true, disable_client_reserve: false, }; - let expensive_node_config = random_config(true); + let expensive_node_config = random_config(); setup_builder!(expensive_builder, expensive_node_config.node_config); expensive_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); expensive_builder.enable_liquidity_provider(expensive_cfg); @@ -4281,7 +4211,7 @@ async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { let expensive_addr = expensive.listening_addresses().unwrap().first().unwrap().clone(); // Client knows both LSPs. Registration order is varied to confirm selection isn't order-based. - let client_config = random_config(true); + let client_config = random_config(); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); if reverse_order { diff --git a/tests/integration_tests_vss.rs b/tests/integration_tests_vss.rs index 210e9a8b2..f0838585f 100644 --- a/tests/integration_tests_vss.rs +++ b/tests/integration_tests_vss.rs @@ -20,7 +20,7 @@ async fn channel_full_cycle_with_vss_store() { let (bitcoind, electrsd) = common::setup_bitcoind_and_electrsd(); println!("== Node A =="); let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); - let config_a = common::random_config(true); + let config_a = common::random_config(); let mut builder_a = Builder::from_config(config_a.node_config); builder_a.set_chain_source_esplora(esplora_url.clone(), None); let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap(); @@ -35,7 +35,7 @@ async fn channel_full_cycle_with_vss_store() { node_a.start().unwrap(); println!("\n== Node B =="); - let config_b = common::random_config(true); + let config_b = common::random_config(); let mut builder_b = Builder::from_config(config_b.node_config); builder_b.set_chain_source_esplora(esplora_url.clone(), None); let node_b = builder_b diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index c5ed0226b..f0480bc5e 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -123,10 +123,10 @@ async fn probe_budget_increments_and_decrements() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let node_b = setup_node(&chain_source, random_config(false)); - let node_c = setup_node(&chain_source, random_config(false)); + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); - let mut config_a = random_config(false); + let mut config_a = random_config(); let strategy = FixedPathStrategy::new(); config_a.probing = Some( ProbingConfigBuilder::custom(strategy.clone()) @@ -216,10 +216,10 @@ async fn locked_msat_accounts_for_routing_fees() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let node_b = setup_node(&chain_source, random_config(false)); - let node_c = setup_node(&chain_source, random_config(false)); + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); - let mut config_a = random_config(false); + let mut config_a = random_config(); let strategy = FixedPathStrategy::new(); config_a.probing = Some( ProbingConfigBuilder::custom(strategy.clone()) @@ -305,10 +305,10 @@ async fn probing_budget_restored_after_node_restart() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let node_b = setup_node(&chain_source, random_config(false)); - let node_c = setup_node(&chain_source, random_config(false)); + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); - let mut config_a = random_config(false); + let mut config_a = random_config(); // Use a pure on-disk store so state survives the restart. config_a.store_type = TestStoreType::Sqlite; let strategy = FixedPathStrategy::new(); @@ -406,10 +406,10 @@ async fn exhausted_probe_budget_blocks_new_probes() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let node_b = setup_node(&chain_source, random_config(false)); - let node_c = setup_node(&chain_source, random_config(false)); + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); - let mut config_a = random_config(false); + let mut config_a = random_config(); let strategy = FixedPathStrategy::new(); let max_locked_msat = 2 * PROBE_AMOUNT_MSAT; config_a.probing = Some( diff --git a/tests/reorg_test.rs b/tests/reorg_test.rs index 295d9fdd2..e44d50cb0 100644 --- a/tests/reorg_test.rs +++ b/tests/reorg_test.rs @@ -29,17 +29,16 @@ proptest! { let chain_source_c = random_chain_source(&bitcoind, &electrsd); macro_rules! config_node { - ($chain_source: expr, $anchor_channels: expr) => {{ - let config_a = random_config($anchor_channels); + ($chain_source: expr) => {{ + let config_a = random_config(); let node = setup_node(&$chain_source, config_a); node }}; } - let anchor_channels = true; let nodes = vec![ - config_node!(chain_source_a, anchor_channels), - config_node!(chain_source_b, anchor_channels), - config_node!(chain_source_c, anchor_channels), + config_node!(chain_source_a), + config_node!(chain_source_b), + config_node!(chain_source_c), ]; let (bitcoind, electrs) = (&bitcoind.client, &electrsd.client); From 8963226784a4a2f5da0128c865e5c977e7c2f98a Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 01:28:46 +0000 Subject: [PATCH 05/12] Add configuration knob to enable 0FC channels In upcoming commits we will read this knob to determine whether to negotiate 0FC channels. For now, we make a best-effort attempt to make sure the configured chain source supports 0FC channels if this knob is set. Do this roundtrip at the same time we make a roundtrip to retrieve the feerates to keep startup as fast as possible. --- bindings/ldk_node.udl | 1 + src/chain/bitcoind.rs | 49 +++++++++++++++++++++++++++++++++++++++++ src/chain/electrum.rs | 47 +++++++++++++++++++++++++++++++++++++++ src/chain/esplora.rs | 25 +++++++++++++++++++++ src/chain/mod.rs | 51 +++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 28 +++++++++++++++++++----- src/error.rs | 5 +++++ src/lib.rs | 38 ++++++++++++++++++++++++++++++-- 8 files changed, 237 insertions(+), 7 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index d7e9a774f..c1a926f2f 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -246,6 +246,7 @@ enum NodeError { "LnurlAuthFailed", "LnurlAuthTimeout", "InvalidLnurl", + "ChainSourceNotSupported", }; typedef dictionary NodeStatus; diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 6bfa8ffd2..0899d8dca 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -119,6 +119,30 @@ impl BitcoindChainSource { self.api_client.utxo_source() } + pub(super) async fn validate_zero_fee_commitments_support(&self) -> Result<(), Error> { + let node_version_result = tokio::time::timeout( + Duration::from_secs(CHAIN_POLLING_TIMEOUT_SECS), + self.api_client.get_node_version(), + ) + .await + .map_err(|e| { + log_error!(self.logger, "Failed to get node version: {:?}", e); + Error::ConnectionFailed + })?; + + let node_version = node_version_result.map_err(|e| { + log_error!(self.logger, "Failed to get node version: {:?}", e); + Error::ConnectionFailed + })?; + + // v26 first shipped the `submitpackage` RPC, but we need v29 to relay ephemeral dust + if node_version < 290000 { + log_error!(self.logger, "Bitcoin backend MUST be greater than or equal to v29"); + return Err(Error::ChainSourceNotSupported); + } + Ok(()) + } + pub(super) async fn continuously_sync_wallets( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, @@ -748,6 +772,31 @@ impl BitcoindClient { } } + pub(crate) async fn get_node_version(&self) -> Result { + match self { + BitcoindClient::Rpc { rpc_client, .. } => { + Self::get_node_version_inner(Arc::clone(rpc_client)) + .await + .map_err(BitcoindClientError::Rpc) + }, + BitcoindClient::Rest { rpc_client, .. } => { + // Bitcoin Core's REST interface does not support `getnetworkinfo` + // so we use the RPC client. + Self::get_node_version_inner(Arc::clone(rpc_client)) + .await + .map_err(BitcoindClientError::Rpc) + }, + } + } + + async fn get_node_version_inner(rpc_client: Arc) -> Result { + rpc_client.call_method::("getnetworkinfo", &[]).await.and_then(|value| { + value["version"].as_u64().ok_or(RpcClientError::InvalidData(String::from( + "The version field in the `getnetworkinfo` response should be a u64", + ))) + }) + } + /// Broadcasts the provided transaction. pub(crate) async fn broadcast_transaction( &self, tx: &Transaction, diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index e255158ca..7a505e045 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -306,6 +306,53 @@ impl ElectrumChainSource { Ok(()) } + pub(crate) async fn validate_zero_fee_commitments_support(&self) -> Result<(), Error> { + let electrum_client: Arc = if let Some(client) = + self.electrum_runtime_status.read().expect("lock").client().as_ref() + { + Arc::clone(client) + } else { + debug_assert!( + false, + "We should have started the chain source before checking submitpackage support" + ); + 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()) + }); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), + spawn_fut, + ); + + match timeout_fut.await { + Ok(Ok(Ok(_))) => Ok(()), + Ok(Ok(Err( + e @ (electrum_client::Error::Protocol(_) + | electrum_client::Error::AllAttemptsErrored(_)), + ))) => { + log_error!(self.logger, "Electrum server does not support submitpackage: {:?}", e); + Err(Error::ChainSourceNotSupported) + }, + e => { + log_error!( + self.logger, + "Failed to check support for submitpackage on the Electrum server: {:?}", + e + ); + Err(Error::ConnectionFailed) + }, + } + } + pub(crate) async fn process_broadcast_package(&self, package: Vec) { let electrum_client: Arc = if let Some(client) = self.electrum_runtime_status.read().expect("lock").client().as_ref() diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index b46fe183c..2fba634ab 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -83,6 +83,31 @@ 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 + ); + Error::ChainSourceNotSupported + } else { + log_error!( + self.logger, + "Failed to check support for submitpackage on the Esplora server: {}", + e + ); + Error::ConnectionFailed + } + }, + )?; + Ok(()) + } + pub(super) async fn sync_onchain_wallet( &self, onchain_wallet: Arc, ) -> Result<(), Error> { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 8a8115e4f..59ae0c64d 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -29,6 +29,37 @@ use crate::runtime::Runtime; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; 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 PARENT_HEX: &str = + "0300000000010160d0cdb72f2ddf719f40ca32f44614c67577fc75996140544003915683c34a310000000000fd\ + ffffff0201000000000000000451024e73876100000000000022512042731375894dad3b25092cd0f713dc5bee4\ + a71e30a95e1db3d880906d7eba1fa01409327942924218e4eb1635a7cce6706fcb37b8bbb61a2f0b86357356681\ + 4e09419a3501e02252043bb237d479304632282fe9159db9e9a6ae6ec5bedea9f0f115a97b0e00"; +const CHILD_TXID: &str = "d011b3ff78cdfb8b93822639ea87771847936b04bb83afc8763a7c02a386ae26"; +const CHILD_HEX: &str = + "0300000000010296f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0000000000ff\ + ffffff96f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0100000000fdffffff015\ + 660000000000000225120ac18cd599a1be003595854e2eeec18dbe1c92d04b0ba05812d04445e3fcf16bc000140\ + 1462a35808d77a164f0a23a84c4721d1545befd09ad19945bb8aa0ea5576953a9699038725f944b1bc429942ef4\ + 7e6504a554babf022cb15db53be2d8c1dbfe5a97b0e00"; + +fn dummy_package() -> [bitcoin::Transaction; 2] { + use bitcoin::consensus::Decodable; + use bitcoin::hex::FromHex; + use bitcoin::Transaction; + let parent_tx_bytes = Vec::from_hex(PARENT_HEX).expect("read from a constant"); + let child_tx_bytes = Vec::from_hex(CHILD_HEX).expect("read from a constant"); + let parent = + 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); + [parent, child] +} + pub(crate) enum WalletSyncStatus { Completed, InProgress { subscribers: tokio::sync::broadcast::Sender> }, @@ -438,6 +469,26 @@ impl ChainSource { } } + pub(crate) async fn validate_zero_fee_commitments_support_if_required( + &self, zero_fee_commitments_support_required: bool, + ) -> Result<(), Error> { + if !zero_fee_commitments_support_required { + return Ok(()); + } + + match &self.kind { + ChainSourceKind::Esplora(esplora_chain_source) => { + esplora_chain_source.validate_zero_fee_commitments_support().await + }, + ChainSourceKind::Electrum(electrum_chain_source) => { + electrum_chain_source.validate_zero_fee_commitments_support().await + }, + ChainSourceKind::Bitcoind(bitcoind_chain_source) => { + bitcoind_chain_source.validate_zero_fee_commitments_support().await + }, + } + } + pub(crate) async fn continuously_process_broadcast_queue( &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, ) { diff --git a/src/config.rs b/src/config.rs index b446ee298..421920f8c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,7 +60,8 @@ pub const DEFAULT_LOG_FILENAME: &'static str = "ldk_node.log"; /// The default storage directory. pub const DEFAULT_STORAGE_DIR_PATH: &str = "/tmp/ldk_node"; -// The default Esplora server we're using. +// The default Esplora server we're using. It supports `submitpackage`, check using POST on the +// `/txs/package` endpoint. pub(crate) const DEFAULT_ESPLORA_SERVER_URL: &str = "https://blockstream.info/api"; /// The default stop gap used for BDK full scans of the on-chain wallet. @@ -305,10 +306,11 @@ impl Default for HumanReadableNamesConfig { /// /// ### Defaults /// -/// | Parameter | Value | -/// |----------------------------|--------| -/// | `trusted_peers_no_reserve` | [] | -/// | `per_channel_reserve_sats` | 25000 | +/// | Parameter | Value | +/// |-------------------------------|--------| +/// | `trusted_peers_no_reserve` | [] | +/// | `per_channel_reserve_sats` | 25000 | +/// | `enable_zero_fee_commitments` | false | /// /// /// [BOLT 3]: https://github.com/lightning/bolts/blob/master/03-transactions.md#htlc-timeout-and-htlc-success-transactions @@ -344,6 +346,21 @@ pub struct AnchorChannelsConfig { /// might not suffice to successfully spend the Anchor output and have the HTLC transactions /// confirmed on-chain, i.e., you may want to adjust this value accordingly. pub per_channel_reserve_sats: u64, + /// If set, we will first attempt to negotiate `option_zero_fee_commitments` before falling + /// back to `option_anchors_zero_fee_htlc_tx` and `option_static_remotekey`, as supported by + /// the peer. Zero-fee commitment channels remove all commitment feerate negotiation from + /// the channel, which eliminates a very common source of channel force-closures. These + /// channels instead source *all* the fees required to confirm the commitment from the + /// anchor reserve of the channel closer at the time of force-close. If set, your chain + /// source *must* support the `submitpackage` Bitcoin Core RPC, and relay [TRUC], [P2A], + /// and [Ephemeral Dust]. + /// See [BOLT 3] for more technical details. + /// + /// [TRUC]: https://github.com/bitcoin/bips/blob/master/bip-0431.mediawiki + /// [P2A]: https://github.com/bitcoin/bips/blob/master/bip-0433.mediawiki + /// [Ephemeral Dust]: https://bitcoincore.org/en/releases/29.0 + /// [BOLT 3]: https://github.com/lightning/bolts/blob/master/03-transactions.md#shared_anchor-output-zero_fee_commitments + pub enable_zero_fee_commitments: bool, } impl Default for AnchorChannelsConfig { @@ -351,6 +368,7 @@ impl Default for AnchorChannelsConfig { Self { trusted_peers_no_reserve: Vec::new(), per_channel_reserve_sats: DEFAULT_ANCHOR_PER_CHANNEL_RESERVE_SATS, + enable_zero_fee_commitments: false, } } } diff --git a/src/error.rs b/src/error.rs index d07212b00..8546af0dd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -137,6 +137,8 @@ pub enum Error { LnurlAuthTimeout, /// The provided lnurl is invalid. InvalidLnurl, + /// The configured chain source is not supported. + ChainSourceNotSupported, } impl fmt::Display for Error { @@ -222,6 +224,9 @@ impl fmt::Display for Error { Self::LnurlAuthFailed => write!(f, "LNURL-auth authentication failed."), Self::LnurlAuthTimeout => write!(f, "LNURL-auth authentication timed out."), Self::InvalidLnurl => write!(f, "The provided lnurl is invalid."), + Self::ChainSourceNotSupported => { + write!(f, "The configured chain source is not supported.") + }, } } } diff --git a/src/lib.rs b/src/lib.rs index 0d50977e5..c43b773d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -291,9 +291,43 @@ impl Node { e })?; - // Block to ensure we update our fee rate cache once on startup + let manager_owns_any_0fc_channels = + self.channel_manager.list_channels().into_iter().any(|channel| { + channel + .channel_shutdown_state + .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) + && channel + .channel_type + .as_ref() + .map_or(false, |c| c.requires_anchor_zero_fee_commitments()) + }); + let monitor_owns_any_0fc_channels = + self.chain_monitor.list_monitors().into_iter().any(|channel_id| { + self.chain_monitor + .get_monitor(channel_id) + .map(|monitor| { + monitor.channel_type_features().requires_anchor_zero_fee_commitments() + }) + .unwrap_or(false) + }); + let zero_fee_commitments_support_required = manager_owns_any_0fc_channels + || monitor_owns_any_0fc_channels + || self.config.anchor_channels_config.enable_zero_fee_commitments; + + // Block to ensure we update our fee rate cache once on startup. + // Also take this opportunity to make sure our chain source supports 0FC channels + // if they are enabled. + // + // TODO: drop 0FC chain source validation when support is ubiquitous let chain_source = Arc::clone(&self.chain_source); - self.runtime.block_on(async move { chain_source.update_fee_rate_estimates().await })?; + 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 + ) + ) + })?; // Spawn background task continuously syncing onchain, lightning, and fee rate cache. let stop_sync_receiver = self.stop_sender.subscribe(); From 430432673f5a9c17578aa17513ac6f8b57c0cb3c Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 13 Jul 2026 17:36:49 +0000 Subject: [PATCH 06/12] Remove single transaction vecs in wallet broadcast path --- src/tx_broadcaster.rs | 8 ++++---- src/wallet/mod.rs | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index ccf2298b0..0d9e5cac5 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -33,8 +33,8 @@ impl BroadcastPackage { } /// Builds a package for wallet-originated broadcasts that have no LDK classification. - fn unclassified(txs: Vec) -> Self { - Self(txs.into_iter().map(|tx| (tx, None)).collect()) + fn unclassified(tx: Transaction) -> Self { + Self(vec![(tx, None)]) } /// The packaged transactions and their types, for classification. @@ -106,8 +106,8 @@ where Ok(package) } - pub(crate) fn broadcast_unclassified_transactions(&self, txs: Vec) { - self.queue_sender.try_send(BroadcastPackage::unclassified(txs)).unwrap_or_else(|e| { + pub(crate) fn broadcast_unclassified_transaction(&self, tx: Transaction) { + self.queue_sender.try_send(BroadcastPackage::unclassified(tx)).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d500d4d01..29c1c7534 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -330,7 +330,7 @@ impl Wallet { .into_iter() .filter_map(|txid| { let tx = locked_wallet.tx_details(txid).map(|d| (*d.tx).clone())?; - self.broadcaster.broadcast_unclassified_transactions(vec![tx]); + self.broadcaster.broadcast_unclassified_transaction(tx); Some(()) }) .count(); @@ -878,7 +878,7 @@ impl Wallet { }; let txid = tx.compute_txid(); - self.broadcaster.broadcast_unclassified_transactions(vec![tx]); + self.broadcaster.broadcast_unclassified_transaction(tx); match send_amount { OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { @@ -1758,7 +1758,7 @@ impl Wallet { self.runtime .block_on(self.pending_payment_store.insert_or_update(pending_payment_store))?; - self.broadcaster.broadcast_unclassified_transactions(vec![fee_bumped_tx]); + self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid); From a9040a938aee01ec5d33c81caf3f1b7b443ee3a2 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 17:35:10 +0000 Subject: [PATCH 07/12] Sort packages received via `BroadcasterInterface` Implementations of `BroadcasterInterface` cannot assume any topological ordering on the transactions received, so here we order the received transactions before adding them to the broadcast queue. Any consumers of the queue can now assume all transactions received to be topologically sorted. Codex wrote the tests. --- src/chain/bitcoind.rs | 5 +- src/chain/electrum.rs | 5 +- src/chain/esplora.rs | 7 +- src/chain/mod.rs | 10 +-- src/tx_broadcaster.rs | 196 +++++++++++++++++++++++++++++++++++++++++- 5 files changed, 209 insertions(+), 14 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 0899d8dca..0b1822447 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -41,6 +41,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -595,12 +596,12 @@ impl BitcoindChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { // While it's a bit unclear when we'd be able to lean on Bitcoin Core >v28 // features, we should eventually switch to use `submitpackage` via the // `rust-bitcoind-json-rpc` crate rather than just broadcasting individual // transactions. - for tx in &package { + for tx in txs.iter() { let txid = tx.compute_txid(); let timeout_fut = tokio::time::timeout( Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 7a505e045..baf6d35d9 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -37,6 +37,7 @@ use crate::fee_estimator::{ use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; use crate::runtime::Runtime; +use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::PersistedNodeMetrics; @@ -353,7 +354,7 @@ impl ElectrumChainSource { } } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { let electrum_client: Arc = if let Some(client) = self.electrum_runtime_status.read().expect("lock").client().as_ref() { @@ -363,7 +364,7 @@ impl ElectrumChainSource { return; }; - for tx in package { + for tx in txs.into_inner() { electrum_client.broadcast(tx).await; } } diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 2fba634ab..ea76dcc72 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use bdk_esplora::EsploraAsyncExt; -use bitcoin::{FeeRate, Network, Script, Transaction, Txid}; +use bitcoin::{FeeRate, Network, Script, Txid}; use esplora_client::AsyncClient as EsploraAsyncClient; use lightning::chain::{Confirm, Filter, WatchedOutput}; use lightning::util::ser::Writeable; @@ -28,6 +28,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; +use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -410,8 +411,8 @@ impl EsploraChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { - for tx in &package { + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { + for tx in txs.iter() { let txid = tx.compute_txid(); let timeout_fut = tokio::time::timeout( Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 59ae0c64d..0f96c409f 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -13,7 +13,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bitcoin::{Script, Transaction, Txid}; +use bitcoin::{Script, Txid}; use lightning::chain::{BlockLocator, Filter}; use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient}; @@ -518,16 +518,16 @@ impl ChainSource { continue; }, }; - let txs: Vec = package.into_transactions(); + let package = package.into_sorted_transactions(); match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_broadcast_package(txs).await + esplora_chain_source.process_transaction_broadcast(package).await }, ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_broadcast_package(txs).await + electrum_chain_source.process_transaction_broadcast(package).await }, ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_broadcast_package(txs).await + bitcoind_chain_source.process_transaction_broadcast(package).await }, } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 0d9e5cac5..491a9cbde 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -43,8 +43,52 @@ impl BroadcastPackage { } /// Consumes the package into its transactions, ready for the chain client. - pub(crate) fn into_transactions(self) -> Vec { - self.0.into_iter().map(|(tx, _)| tx).collect() + pub(crate) fn into_sorted_transactions(self) -> SortedTransactions { + let txs = self.0.into_iter().map(|(tx, _)| tx).collect(); + SortedTransactions::sort_parents_child_package_topologically(txs) + } +} + +pub(crate) struct SortedTransactions(Vec); + +impl SortedTransactions { + pub(crate) fn sort_parents_child_package_topologically( + mut txs: Vec, + ) -> SortedTransactions { + if txs.len() == 0 || txs.len() == 1 { + return SortedTransactions(txs); + } + let txids: Vec<_> = txs.iter().map(|tx| tx.compute_txid()).collect(); + let any_spends_from_package = |tx: &Transaction| -> bool { + tx.input.iter().any(|input| txids.contains(&input.previous_output.txid)) + }; + txs.sort_by_key(any_spends_from_package); + + #[cfg(debug_assertions)] + { + let child = txs.last().expect("txs is not empty"); + let child_input_txids: Vec<_> = + child.input.iter().map(|input| input.previous_output.txid).collect(); + let parents = &txs[..txs.len() - 1]; + let parent_txids: Vec<_> = parents.iter().map(|parent| parent.compute_txid()).collect(); + // Make sure all the parent txids are parents of the child transaction + debug_assert!(parent_txids.iter().all(|txid| child_input_txids.contains(&txid))); + // Make sure there are no grandparents + debug_assert_eq!(txs.iter().filter(|tx| any_spends_from_package(tx)).count(), 1); + } + + SortedTransactions(txs) + } + + pub(crate) fn into_inner(self) -> Vec { + self.0 + } +} + +impl Deref for SortedTransactions { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 } } @@ -123,3 +167,151 @@ where }); } } + +#[cfg(test)] +mod tests { + use bitcoin::hashes::Hash; + use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; + + use super::SortedTransactions; + + fn txin(txid: Txid, vout: u32) -> TxIn { + TxIn { + previous_output: OutPoint { txid, vout }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + } + } + + fn txout(value_sat: u64) -> TxOut { + TxOut { value: Amount::from_sat(value_sat), script_pubkey: ScriptBuf::new() } + } + + fn parent_tx(seed: u8) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([seed; 32]), 0)], + output: vec![txout(1_000 + u64::from(seed))], + } + } + + fn child_tx(parents: &[&Transaction]) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: parents + .iter() + .enumerate() + .map(|(idx, parent)| txin(parent.compute_txid(), idx as u32)) + .collect(), + output: vec![txout(1_000)], + } + } + + fn assert_parents_before_child( + txs: &[Transaction], expected_child: Txid, expected_parents: &[Txid], + ) { + assert_eq!(txs.last().map(Transaction::compute_txid), Some(expected_child)); + assert_eq!(txs.len(), expected_parents.len() + 1); + + let parent_txids = + txs[..txs.len() - 1].iter().map(Transaction::compute_txid).collect::>(); + for expected_parent in expected_parents { + assert!(parent_txids.contains(expected_parent)); + } + } + + #[test] + fn topological_sort_leaves_sorted_package_unchanged() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + + let original_txids = + [parent_a.compute_txid(), parent_b.compute_txid(), child.compute_txid()]; + let txs = vec![parent_a, parent_b, child]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_eq!( + package.iter().map(Transaction::compute_txid).collect::>(), + original_txids + ); + } + + #[test] + fn topological_sort_moves_single_parent_child_from_front_to_end() { + let parent = parent_tx(1); + let child = child_tx(&[&parent]); + let parent_txids = [parent.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![child, parent]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_front_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + let parent_txids = [parent_a.compute_txid(), parent_b.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![child, parent_a, parent_b]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_front_with_multiple_parents_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let parent_c = parent_tx(3); + let child = child_tx(&[&parent_a, &parent_b, &parent_c]); + let parent_txids = + [parent_a.compute_txid(), parent_b.compute_txid(), parent_c.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![child, parent_a, parent_b, parent_c]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_middle_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + let parent_txids = [parent_a.compute_txid(), parent_b.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![parent_a, child, parent_b]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_leaves_single_transaction_package_unchanged() { + let parent = parent_tx(1); + let parent_txid = parent.compute_txid(); + let txs = vec![parent]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_eq!(package.len(), 1); + assert_eq!(package[0].compute_txid(), parent_txid); + } + + #[test] + fn topological_sort_accepts_empty_vec() { + SortedTransactions::sort_parents_child_package_topologically(Vec::new()); + } +} From cc85b4df03be79f48c00d2a8217f849706355cf7 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 25 Jun 2026 16:43:19 +0000 Subject: [PATCH 08/12] Use helper functions to log broadcast errors These will be useful when we add support for broadcasting packages in an upcoming commit. --- src/chain/bitcoind.rs | 36 ++++++--------- src/chain/electrum.rs | 42 ++++++++--------- src/chain/esplora.rs | 105 ++++++++++++++++++++---------------------- 3 files changed, 82 insertions(+), 101 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 0b1822447..330578121 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -596,16 +596,25 @@ impl BitcoindChainSource { Ok(()) } + fn log_broadcast_error(&self, e: impl core::fmt::Display, txids: &[Txid], txs: &[Transaction]) { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { // While it's a bit unclear when we'd be able to lean on Bitcoin Core >v28 // features, we should eventually switch to use `submitpackage` via the // `rust-bitcoind-json-rpc` crate rather than just broadcasting individual // transactions. - for tx in txs.iter() { + let txs = txs.into_inner(); + for tx in txs { let txid = tx.compute_txid(); let timeout_fut = tokio::time::timeout( Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), - self.api_client.broadcast_transaction(tx), + self.api_client.broadcast_transaction(&tx), ); match timeout_fut.await { Ok(res) => match res { @@ -613,28 +622,9 @@ impl BitcoindChainSource { debug_assert_eq!(id, txid); log_trace!(self.logger, "Successfully broadcast transaction {}", txid); }, - Err(e) => { - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, - }, - Err(e) => { - log_error!( - self.logger, - "Failed to broadcast transaction due to timeout {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); + Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), }, + Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), } } } diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index baf6d35d9..8108504e1 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -625,14 +625,24 @@ impl ElectrumRuntimeClient { }) } + fn log_broadcast_error(&self, e: impl core::fmt::Display, txids: &[Txid], txs: &[Transaction]) { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction bytes:"); + for tx in txs { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + async fn broadcast(&self, tx: Transaction) { let electrum_client = Arc::clone(&self.electrum_client); let txid = tx.compute_txid(); - let tx_bytes = tx.encode(); + let tx = Arc::new(tx); - let spawn_fut = - self.runtime.spawn_blocking(move || electrum_client.transaction_broadcast(&tx)); + let spawn_fut = self.runtime.spawn_blocking({ + let tx = Arc::clone(&tx); + move || electrum_client.transaction_broadcast(tx.as_ref()) + }); let timeout_fut = tokio::time::timeout( Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), spawn_fut, @@ -640,31 +650,15 @@ impl ElectrumRuntimeClient { match timeout_fut.await { Ok(res) => match res { - Ok(_) => { + Ok(Ok(txid)) => { log_trace!(self.logger, "Successfully broadcast transaction {}", txid); }, - Err(e) => { - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx_bytes) - ); + Ok(Err(e)) => { + self.log_broadcast_error(e, &[txid], core::slice::from_ref(tx.as_ref())) }, + Err(e) => self.log_broadcast_error(e, &[txid], core::slice::from_ref(tx.as_ref())), }, - Err(e) => { - log_error!( - self.logger, - "Failed to broadcast transaction due to timeout {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx_bytes) - ); - }, + Err(e) => self.log_broadcast_error(e, &[txid], core::slice::from_ref(tx.as_ref())), } } diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index ea76dcc72..2e3e88d7b 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -411,6 +411,55 @@ impl EsploraChainSource { Ok(()) } + fn log_http_error(&self, e: esplora_client::Error, txids: &[Txid], txs: &SortedTransactions) { + match e { + esplora_client::Error::HttpResponse { status, message } => { + if status == 400 && txs.len() == 1 { + // Log 400 at lesser level, as this often just means bitcoind already knows the + // transaction. + // FIXME: We can further differentiate here based on the error + // message which will be available with rust-esplora-client 0.7 and + // later. + log_trace!( + self.logger, + "Failed to broadcast due to HTTP connection error: {}", + message + ); + log_trace!(self.logger, "Failed to broadcast transaction(s) {:?}", txids); + } else { + log_error!( + self.logger, + "Failed to broadcast due to HTTP connection error: {} - {}", + status, + message + ); + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}", txids); + } + log_trace!(self.logger, "Failed broadcast transaction(s) bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + _ => { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction(s) bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + } + } + + fn log_broadcast_error( + &self, e: impl core::fmt::Display, txids: &[Txid], txs: &SortedTransactions, + ) { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { for tx in txs.iter() { let txid = tx.compute_txid(); @@ -423,61 +472,9 @@ impl EsploraChainSource { Ok(()) => { log_trace!(self.logger, "Successfully broadcast transaction {}", txid); }, - Err(e) => match e { - esplora_client::Error::HttpResponse { status, message } => { - if status == 400 { - // Log 400 at lesser level, as this often just means bitcoind already knows the - // transaction. - // FIXME: We can further differentiate here based on the error - // message which will be available with rust-esplora-client 0.7 and - // later. - log_trace!( - self.logger, - "Failed to broadcast due to HTTP connection error: {}", - message - ); - } else { - log_error!( - self.logger, - "Failed to broadcast due to HTTP connection error: {} - {}", - status, - message - ); - } - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, - _ => { - log_error!( - self.logger, - "Failed to broadcast transaction {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, - }, - }, - Err(e) => { - log_error!( - self.logger, - "Failed to broadcast transaction due to timeout {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); + Err(e) => self.log_http_error(e, &[txid], &txs), }, + Err(e) => self.log_broadcast_error(e, &[txid], &txs), } } } From 195df39a7dff2a747638f37519a6d802bee22958 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 25 Jun 2026 16:02:34 +0000 Subject: [PATCH 09/12] Submit TRUC packages via all chain sources We rely on the `BroadcasterInterface` contract whereby any multi-transaction vector must be a single child and its parents. In a prior commit, we added the guarantee that any packages received from the broadcast queue are already topologically sorted, and hence can be passed directly to the `submit_package` Bitcoin Core RPC. We avoid broadcasting non-TRUC parents-child packages via `submitpackage` for now to avoid adding a requirement to support `submitpackage` for users that don't enable zero-fee commitment channels. We will do so once support for `submitpackage` is more ubiquitous. --- src/chain/bitcoind.rs | 115 ++++++++++++++++++++++++++++++++++-------- src/chain/electrum.rs | 51 ++++++++++++++++++- src/chain/esplora.rs | 69 ++++++++++++++++++++----- 3 files changed, 198 insertions(+), 37 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 330578121..1c392b055 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -14,6 +14,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use base64::prelude::BASE64_STANDARD; use base64::Engine; +use bitcoin::transaction::Version; use bitcoin::{BlockHash, FeeRate, Network, OutPoint, Transaction, Txid}; use lightning::chain::chaininterface::ConfirmationTarget as LdkConfirmationTarget; use lightning::chain::{BlockLocator, Listen}; @@ -596,7 +597,9 @@ impl BitcoindChainSource { Ok(()) } - fn log_broadcast_error(&self, e: impl core::fmt::Display, txids: &[Txid], txs: &[Transaction]) { + fn log_broadcast_error( + &self, e: impl core::fmt::Display, txids: &[Txid], txs: &SortedTransactions, + ) { log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); log_trace!(self.logger, "Failed broadcast transaction bytes:"); for tx in txs.iter() { @@ -605,27 +608,48 @@ impl BitcoindChainSource { } pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { - // While it's a bit unclear when we'd be able to lean on Bitcoin Core >v28 - // features, we should eventually switch to use `submitpackage` via the - // `rust-bitcoind-json-rpc` crate rather than just broadcasting individual - // transactions. - let txs = txs.into_inner(); - for tx in txs { - let txid = tx.compute_txid(); - let timeout_fut = tokio::time::timeout( - Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), - self.api_client.broadcast_transaction(&tx), - ); - match timeout_fut.await { - Ok(res) => match res { - Ok(id) => { - debug_assert_eq!(id, txid); - log_trace!(self.logger, "Successfully broadcast transaction {}", txid); + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); + match txs.len() { + 2.. if all_txs_are_v3 => { + let txids: Vec<_> = txs.iter().map(|tx| tx.compute_txid()).collect(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), + self.api_client.submit_package(&txs), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(result) => { + log_trace!(self.logger, "Successfully broadcast package {:?}", txids); + log_trace!(self.logger, "Successfully broadcast package {}", result); + }, + Err(e) => self.log_broadcast_error(e, &txids, &txs), }, - Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), - }, - Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), - } + Err(e) => self.log_broadcast_error(e, &txids, &txs), + } + }, + _ => { + for tx in txs.iter() { + let txid = tx.compute_txid(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), + self.api_client.broadcast_transaction(tx), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(id) => { + debug_assert_eq!(id, txid); + log_trace!( + self.logger, + "Successfully broadcast transaction {}", + txid + ); + }, + Err(e) => self.log_broadcast_error(e, &[txid], &txs), + }, + Err(e) => self.log_broadcast_error(e, &[txid], &txs), + } + } + }, } } } @@ -816,6 +840,38 @@ impl BitcoindClient { rpc_client.call_method::("sendrawtransaction", &[tx_json]).await } + /// Submits the provided package + pub(crate) async fn submit_package( + &self, package: &SortedTransactions, + ) -> Result { + match self { + BitcoindClient::Rpc { rpc_client, .. } => { + Self::submit_package_inner(Arc::clone(rpc_client), package) + .await + .map_err(BitcoindClientError::Rpc) + }, + BitcoindClient::Rest { rpc_client, .. } => { + // Bitcoin Core's REST interface does not support submitting packages + // so we use the RPC client. + Self::submit_package_inner(Arc::clone(rpc_client), package) + .await + .map_err(BitcoindClientError::Rpc) + }, + } + } + + async fn submit_package_inner( + rpc_client: Arc, package: &SortedTransactions, + ) -> Result { + let package_serialized: Vec<_> = + package.iter().map(|tx| bitcoin::consensus::encode::serialize_hex(tx)).collect(); + let package_json = serde_json::json!(package_serialized); + rpc_client + .call_method::("submitpackage", &[package_json]) + .await + .map(|response| response.0) + } + /// Retrieve the fee estimate needed for a transaction to begin /// confirmation within the provided `num_blocks`. pub(crate) async fn get_fee_estimate_for_target( @@ -1367,6 +1423,23 @@ impl TryInto for JsonResponse { } } +pub struct SubmitPackageResponse(String); + +impl TryInto for JsonResponse { + type Error = String; + fn try_into(self) -> Result { + let response = self.0.to_string(); + let res = self.0.as_object().ok_or("Failed to parse submitpackage response".to_string())?; + + match res["package_msg"].as_str() { + Some("success") => Ok(SubmitPackageResponse(response)), + Some(_) | None => { + return Err(response); + }, + } + } +} + #[derive(Debug, Clone)] pub(crate) struct MempoolEntry { /// The transaction id diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 8108504e1..2c058910d 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -16,6 +16,7 @@ use bdk_chain::bdk_core::spk_client::{ }; use bdk_electrum::BdkElectrumClient; 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, @@ -364,8 +365,14 @@ impl ElectrumChainSource { return; }; - for tx in txs.into_inner() { - electrum_client.broadcast(tx).await; + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); + match txs.len() { + 2.. if all_txs_are_v3 => electrum_client.submit_package(txs).await, + _ => { + for tx in txs.into_inner() { + electrum_client.broadcast(tx).await + } + }, } } } @@ -662,6 +669,46 @@ impl ElectrumRuntimeClient { } } + async fn submit_package(&self, package: SortedTransactions) { + let electrum_client = Arc::clone(&self.electrum_client); + + let txids: Vec<_> = package.iter().map(|tx| tx.compute_txid()).collect(); + let package = Arc::new(package); + + let spawn_fut = self.runtime.spawn_blocking({ + let package = Arc::clone(&package); + move || electrum_client.transaction_broadcast_package(&package) + }); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), + spawn_fut, + ); + + match timeout_fut.await { + Ok(res) => match res { + Ok(Ok(result)) => { + if result.success { + log_trace!( + self.logger, + "Successfully broadcast transaction(s) {:?}", + txids + ); + log_trace!( + self.logger, + "Successfully broadcast transaction(s) {:?}", + result + ); + } else { + self.log_broadcast_error(format!("{:?}", result), &txids, &package); + } + }, + Ok(Err(e)) => self.log_broadcast_error(e, &txids, &package), + Err(e) => self.log_broadcast_error(e, &txids, &package), + }, + Err(e) => self.log_broadcast_error(e, &txids, &package), + } + } + async fn get_fee_rate_cache_update( &self, ) -> Result, Error> { diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 2e3e88d7b..7a59c8d46 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex}; 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 lightning::chain::{Confirm, Filter, WatchedOutput}; @@ -461,21 +462,61 @@ impl EsploraChainSource { } pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { - for tx in txs.iter() { - let txid = tx.compute_txid(); - let timeout_fut = tokio::time::timeout( - Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), - self.esplora_client.broadcast(tx), - ); - match timeout_fut.await { - Ok(res) => match res { - Ok(()) => { - log_trace!(self.logger, "Successfully broadcast transaction {}", txid); + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); + match txs.len() { + 2.. if all_txs_are_v3 => { + let txids: Vec<_> = txs.iter().map(|tx| tx.compute_txid()).collect(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), + self.esplora_client.submit_package(&txs, None, None), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(result) => { + if result.package_msg.eq_ignore_ascii_case("success") { + log_trace!( + self.logger, + "Successfully broadcast transactions {:?}", + txids + ); + log_trace!( + self.logger, + "Successfully broadcast transactions {:?}", + result + ); + } else { + self.log_broadcast_error(format!("{:?}", result), &txids, &txs); + } + }, + Err(e) => self.log_http_error(e, &txids, &txs), }, - Err(e) => self.log_http_error(e, &[txid], &txs), - }, - Err(e) => self.log_broadcast_error(e, &[txid], &txs), - } + Err(e) => self.log_broadcast_error(e, &txids, &txs), + } + }, + _ => { + for tx in txs.iter() { + let txid = tx.compute_txid(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs( + self.sync_config.timeouts_config.tx_broadcast_timeout_secs, + ), + self.esplora_client.broadcast(tx), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(()) => { + log_trace!( + self.logger, + "Successfully broadcast transaction {}", + txid + ); + }, + Err(e) => self.log_http_error(e, &[txid], &txs), + }, + Err(e) => self.log_broadcast_error(e, &[txid], &txs), + } + } + }, } } } From cf044ac55696a29522141572daa3b24720910865 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 29 Jun 2026 23:21:46 +0000 Subject: [PATCH 10/12] Read even bits to check the anchor channel type --- src/types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.rs b/src/types.rs index 2ac08a48f..5198d12cf 100644 --- a/src/types.rs +++ b/src/types.rs @@ -649,7 +649,7 @@ impl ChannelDetails { value: LdkChannelDetails, anchor_channels_config: &AnchorChannelsConfig, ) -> Self { let reserve_type = value.channel_type.as_ref().map(|channel_type| { - if channel_type.supports_anchors_zero_fee_htlc_tx() { + if channel_type.requires_anchors_zero_fee_htlc_tx() { if anchor_channels_config .trusted_peers_no_reserve .contains(&value.counterparty.node_id) From cada867b1eed37147f5a1a4e4d6d5c789848223c Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 29 Oct 2025 07:00:04 +0000 Subject: [PATCH 11/12] Include 0FC channels in anchor channel checks --- src/event.rs | 2 +- src/lib.rs | 21 ++++++++++++++++++--- src/liquidity/service/lsps2.rs | 20 ++++++++------------ src/types.rs | 2 +- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/event.rs b/src/event.rs index 1fb148bd1..93a09cd9b 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1266,7 +1266,7 @@ where } } - let anchor_channel = channel_type.requires_anchors_zero_fee_htlc_tx(); + let anchor_channel = crate::requires_anchor_channel_type(&channel_type); let required_reserve_sats = crate::new_channel_anchor_reserve_sats( &self.config, &counterparty_node_id, diff --git a/src/lib.rs b/src/lib.rs index c43b773d6..a53484141 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -163,7 +163,9 @@ use lightning_background_processor::process_events_async; pub use lightning_invoice; pub use lightning_liquidity; pub use lightning_types; -use lightning_types::features::NodeFeatures as LdkNodeFeatures; +use lightning_types::features::{ + ChannelTypeFeatures, InitFeatures, NodeFeatures as LdkNodeFeatures, +}; use liquidity::LiquiditySource; use lnurl_auth::LnurlAuth; use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; @@ -219,6 +221,19 @@ impl LeakChecker { } } +fn peer_may_negotiate_anchor_channel_type( + config: &Config, their_init_features: &InitFeatures, +) -> bool { + their_init_features.supports_anchors_zero_fee_htlc_tx() + || (config.anchor_channels_config.enable_zero_fee_commitments + && their_init_features.supports_anchor_zero_fee_commitments()) +} + +fn requires_anchor_channel_type(channel_type: &ChannelTypeFeatures) -> bool { + channel_type.requires_anchors_zero_fee_htlc_tx() + || channel_type.requires_anchor_zero_fee_commitments() +} + /// The main interface object of LDK Node, wrapping the necessary LDK and BDK functionalities. /// /// Needs to be initialized and instantiated through [`Builder::build`]. @@ -1387,7 +1402,7 @@ impl Node { .peer_by_node_id(peer_node_id) .ok_or(Error::ConnectionFailed)? .init_features; - let anchor_channel = init_features.supports_anchors_zero_fee_htlc_tx(); + let anchor_channel = peer_may_negotiate_anchor_channel_type(&self.config, &init_features); Ok(new_channel_anchor_reserve_sats(&self.config, peer_node_id, anchor_channel)) } @@ -2450,7 +2465,7 @@ pub(crate) fn total_anchor_channels_reserve_sats( .contains(&c.counterparty.node_id) && c.channel_shutdown_state .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) - && c.channel_type.as_ref().map_or(false, |t| t.requires_anchors_zero_fee_htlc_tx()) + && c.channel_type.as_ref().map_or(false, requires_anchor_channel_type) }) .count() as u64 * config.anchor_channels_config.per_channel_reserve_sats diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index ca70cd8d8..946511c5d 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -452,18 +452,14 @@ where total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); let spendable_amount_sats = self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); - let required_funds_sats = channel_amount_sats - + if init_features.supports_anchors_zero_fee_htlc_tx() - && !self - .config - .anchor_channels_config - .trusted_peers_no_reserve - .contains(&their_network_key) - { - self.config.anchor_channels_config.per_channel_reserve_sats - } else { - 0 - }; + let anchor_channel = + crate::peer_may_negotiate_anchor_channel_type(&self.config, &init_features); + let additional_reserve_required = crate::new_channel_anchor_reserve_sats( + &self.config, + &their_network_key, + anchor_channel, + ); + let required_funds_sats = channel_amount_sats + additional_reserve_required; if spendable_amount_sats < required_funds_sats { log_error!(self.logger, "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", diff --git a/src/types.rs b/src/types.rs index 5198d12cf..5552877ef 100644 --- a/src/types.rs +++ b/src/types.rs @@ -649,7 +649,7 @@ impl ChannelDetails { value: LdkChannelDetails, anchor_channels_config: &AnchorChannelsConfig, ) -> Self { let reserve_type = value.channel_type.as_ref().map(|channel_type| { - if channel_type.requires_anchors_zero_fee_htlc_tx() { + if crate::requires_anchor_channel_type(channel_type) { if anchor_channels_config .trusted_peers_no_reserve .contains(&value.counterparty.node_id) From a868de891c620153911c26ffb8586e105cfabd93 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 15 Jul 2026 18:27:05 +0000 Subject: [PATCH 12/12] Negotiate 0FC channels if configured --- .github/workflows/0fc-integration.yml | 49 +++++++++++++++++++++++++++ Cargo.toml | 1 + scripts/build_electrs.sh | 35 +++++++++++++++++++ src/config.rs | 7 ++-- tests/common/mod.rs | 11 +++--- tests/integration_tests_rust.rs | 5 +-- 6 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/0fc-integration.yml create mode 100755 scripts/build_electrs.sh diff --git a/.github/workflows/0fc-integration.yml b/.github/workflows/0fc-integration.yml new file mode 100644 index 000000000..02a25eef5 --- /dev/null +++ b/.github/workflows/0fc-integration.yml @@ -0,0 +1,49 @@ +name: CI Checks - 0FC Integration Tests + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + timeout-minutes: 60 + runs-on: self-hosted + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust stable toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable + - name: Enable caching for bitcoind + id: cache-bitcoind + uses: actions/cache@v4 + with: + path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + key: bitcoind-29.0-${{ runner.os }}-${{ runner.arch }} + - name: Enable caching for electrs + id: cache-electrs + uses: actions/cache@v4 + with: + path: bin/electrs-${{ runner.os }}-${{ runner.arch }} + key: electrs-submit-package-${{ runner.os }}-${{ runner.arch }} + - name: Download bitcoind + if: "steps.cache-bitcoind.outputs.cache-hit != 'true'" + run: | + source ./scripts/download_bitcoind_electrs.sh + mkdir -p bin + mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + - name: Download electrs + if: "steps.cache-electrs.outputs.cache-hit != 'true'" + run: | + source ./scripts/build_electrs.sh + mkdir -p bin + mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} + - name: Set bitcoind/electrs environment variables + run: | + echo "BITCOIND_EXE=$( pwd )/bin/bitcoind-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + - 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 diff --git a/Cargo.toml b/Cargo.toml index c9ff50d22..7fc945833 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,6 +135,7 @@ check-cfg = [ "cfg(eclair_test)", "cfg(cycle_tests)", "cfg(hrn_tests)", + "cfg(zero_fee_commitment_tests)", ] [[bench]] diff --git a/scripts/build_electrs.sh b/scripts/build_electrs.sh new file mode 100755 index 000000000..2235986c8 --- /dev/null +++ b/scripts/build_electrs.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -eox pipefail + +# Our Esplora-based tests require `electrs` binaries. Here, we +# download the code, build the binaries, and export their location +# via `ELECTRS_EXE` which will be used by the `electrsd` crates in +# our tests. + +HOST_PLATFORM="$(rustc --version --verbose | grep "host:" | awk '{ print $2 }')" +ELECTRS_GIT_REPO="https://github.com/tankyleo/blockstream-electrs.git" +ELECTRS_TAG="2026-05-26-electrum-submit-package" +ELECTRS_REV="8c06d8010e43f793b1a65f83695ea846e5cd83ed" +if [[ "$HOST_PLATFORM" != *linux* && "$HOST_PLATFORM" != *darwin* ]]; then + printf "\n\n" + echo "Unsupported platform: $HOST_PLATFORM Exiting.." + exit 1 +fi + +DL_TMP_DIR=$(mktemp -d) +trap 'rm -rf -- "$DL_TMP_DIR"' EXIT + +pushd "$DL_TMP_DIR" +git clone --branch "$ELECTRS_TAG" --depth 1 "$ELECTRS_GIT_REPO" blockstream-electrs +cd blockstream-electrs +CURRENT_HEAD=$(git rev-parse HEAD) +if [ "$CURRENT_HEAD" != "$ELECTRS_REV" ]; then + echo "ERROR: HEAD does not match expected commit" + echo "expected: $ELECTRS_REV" + echo "actual: $CURRENT_HEAD" + exit 1 +fi +RUSTFLAGS="" cargo build +export ELECTRS_EXE="$DL_TMP_DIR"/blockstream-electrs/target/debug/electrs +chmod +x "$ELECTRS_EXE" +popd diff --git a/src/config.rs b/src/config.rs index 421920f8c..958eb14fc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -188,7 +188,8 @@ pub struct Config { /// used to send pre-flight probes. pub probing_liquidity_limit_multiplier: u64, /// Configuration options pertaining to Anchor channels, i.e., channels for which the - /// `option_anchors_zero_fee_htlc_tx` channel type is negotiated. + /// `option_zero_fee_commitments` or `option_anchors_zero_fee_htlc_tx` channel type is + /// negotiated. /// /// Please refer to [`AnchorChannelsConfig`] for further information on Anchor channels. pub anchor_channels_config: AnchorChannelsConfig, @@ -287,7 +288,7 @@ impl Default for HumanReadableNamesConfig { } /// Configuration options pertaining to 'Anchor' channels, i.e., channels for which the -/// `option_anchors_zero_fee_htlc_tx` channel type is negotiated. +/// `option_zero_fee_commitments` or `option_anchors_zero_fee_htlc_tx` channel type is negotiated. /// /// Prior to the introduction of Anchor channels, the on-chain fees paying for the transactions /// issued on channel closure were pre-determined and locked-in at the time of the channel @@ -424,6 +425,8 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { // will mostly be relevant for inbound channels. let mut user_config = UserConfig::default(); user_config.channel_handshake_limits.force_announced_channel_preference = false; + user_config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = + config.anchor_channels_config.enable_zero_fee_commitments; user_config.reject_inbound_splices = false; if may_announce_channel(config).is_err() { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 5026780de..8aee93b1a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -382,6 +382,11 @@ pub(crate) fn random_node_alias() -> Option { pub(crate) fn random_config() -> TestConfig { let mut node_config = Config::default(); + #[cfg(zero_fee_commitment_tests)] + { + node_config.anchor_channels_config.enable_zero_fee_commitments = true; + } + node_config.network = Network::Regtest; println!("Setting network: {}", node_config.network); @@ -1579,10 +1584,8 @@ pub(crate) async fn do_channel_full_cycle( 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; - // TODO: Zero-fee commitment channels are anchor channels, but do not allocate any - // funds to the anchor, so this will need to be updated when we ship these channels - // in ldk-node. - let node_a_anchors_msat = if expect_anchor_channel { 2 * 330 * 1000 } else { 0 }; + let zero_fee_commitments = node_a.list_channels()[0].feerate_sat_per_1000_weight == 0; + 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; // 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 diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 7852e73b7..a8491ae4e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1543,8 +1543,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 closing_transaction_fee_sat = 614; - let anchor_output_sat = 330; + let zero_fee_commitments = node_a.list_channels()[0].feerate_sat_per_1000_weight == 0; + let closing_transaction_fee_sat = if zero_fee_commitments { 0 } else { 614 }; + let anchor_output_sat = if zero_fee_commitments { 0 } else { 330 }; assert_eq!( node_a.list_balances().total_onchain_balance_sats,