From 13529e633cc35ab7f4555b1dbd8b2cffbcf067c0 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 00:27:51 +0000 Subject: [PATCH 01/17] Only pass TRUC packages as multi-transaction vecs `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 | 45 +++++++++++++++++++-------------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index ad4f8d45e..216d12e31 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -326,32 +326,25 @@ 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_refs: Vec<( - &Transaction, - lightning::chain::chaininterface::TransactionType, - )> = - txs_to_broadcast - .iter() - .map(|tx| { - (tx, lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] }) - }) - .collect(); - self.broadcaster.broadcast_transactions(&tx_refs); - log_info!( - self.logger, - "Rebroadcast {} unconfirmed transactions on chain tip change", - txs_to_broadcast.len() - ); - } + let count: usize = unconfirmed_outbound_txids + .into_iter() + .filter_map(|txid| { + let tx = locked_wallet.tx_details(txid).map(|d| d.tx)?; + let transaction_type = + lightning::chain::chaininterface::TransactionType::Sweep { + channels: vec![], + }; + self.broadcaster + .broadcast_transactions(&[(tx.as_ref(), transaction_type)]); + Some(()) + }) + .count(); + if count != 0 { + log_info!( + self.logger, + "Rebroadcast {} unconfirmed transactions on chain tip change", + count, + ); } }, WalletEvent::TxUnconfirmed { txid, tx, .. } => { From 199c346c830f7d245aa2b8e6bfac34380394e7c5 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sat, 27 Jun 2026 19:43:51 +0000 Subject: [PATCH 02/17] Fix anchor reserves when splicing in all funds In an upcoming commit, we will fix `check_sufficient_funds_for_channel` to check that we have on-chain funds to cover the anchor reserve for an additional anchor channel in the validation of outbound channel opens. Before we do this, we stop using this function to check that any splice-ins leave enough on-chain anchor reserves. This function keeps an anchor reserve for an additional anchor channel on top of the existing set of anchor channels, but after splice-ins, our anchor reserve only needs to cover the existing set of anchor channels. --- src/lib.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index c97e16fe6..df4ff4153 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1370,6 +1370,23 @@ impl Node { Ok(()) } + fn check_sufficient_funds_for_splice_in(&self, amount_sats: u64) -> Result<(), Error> { + 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); + + if spendable_amount_sats < amount_sats { + log_error!(self.logger, + "Unable to splice channel due to insufficient funds. Available: {}sats, Requested: {}sats", + spendable_amount_sats, amount_sats + ); + return Err(Error::InsufficientFunds); + } + + Ok(()) + } + /// Connect to a node and open a new unannounced channel. /// /// To open an announced channel, see [`Node::open_announced_channel`]. @@ -1640,7 +1657,7 @@ impl Node { }, }; - self.check_sufficient_funds_for_channel(splice_amount_sats, &counterparty_node_id)?; + self.check_sufficient_funds_for_splice_in(splice_amount_sats)?; let funding_template = self .channel_manager From 948d65e2f8fae33d9375e103182a668b1642f496 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 26 Jun 2026 19:37:40 +0000 Subject: [PATCH 03/17] 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/common/logging.rs | 17 ++ tests/integration_tests_rust.rs | 320 +++++++++++++++++++++++++++++++- 4 files changed, 338 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index df4ff4153..a5f2fa537 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1334,7 +1334,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/common/logging.rs b/tests/common/logging.rs index 1e3a8a1c2..6dfd53e33 100644 --- a/tests/common/logging.rs +++ b/tests/common/logging.rs @@ -61,6 +61,23 @@ impl LogWriter for MockLogFacadeLogger { } } +#[cfg(feature = "uniffi")] +impl LogWriter for MockLogFacadeLogger { + fn log(&self, record: LogRecord) { + let level = MockLogLevel(record.level).into(); + let mut record_builder = log::Record::builder(); + LogFacadeLog::log( + self, + &record_builder + .level(level) + .module_path(Some(&record.module_path)) + .line(Some(record.line)) + .args(format_args!("{}", record.args)) + .build(), + ); + } +} + #[cfg(not(feature = "uniffi"))] struct MockLogRecord<'a>(LogRecord<'a>); struct MockLogLevel(LogLevel); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index eedd62ebe..99ac71bbb 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -15,7 +15,9 @@ use bitcoin::address::NetworkUnchecked; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; use bitcoin::{Address, Amount, ScriptBuf, Txid}; -use common::logging::{init_log_logger, validate_log_entry, MultiNodeLogger, TestLogWriter}; +use common::logging::{ + init_log_logger, validate_log_entry, MockLogFacadeLogger, MultiNodeLogger, TestLogWriter, +}; use common::{ bump_fee_and_broadcast, distribute_funds_unconfirmed, do_channel_full_cycle, expect_channel_pending_event, expect_channel_ready_event, expect_channel_ready_events, @@ -36,7 +38,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; @@ -2946,6 +2948,165 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { assert_eq!(client_node.payment(&payment_id).unwrap().status, PaymentStatus::Failed); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn lsps2_rejects_jit_channel_without_anchor_reserve() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + + let mut sync_config = EsploraSyncConfig::default(); + sync_config.background_sync_config = None; + + let channel_opening_fee_ppm = 10_000; + let channel_over_provisioning_ppm = 100_000; + let lsps2_service_config = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm, + channel_over_provisioning_ppm, + max_payment_size_msat: 1_000_000_000, + min_payment_size_msat: 0, + min_channel_lifetime: 100, + min_channel_opening_fee_msat: 0, + max_client_to_self_delay: 1024, + client_trusts_lsp: false, + disable_client_reserve: false, + }; + + let service_logger = Arc::new(MockLogFacadeLogger::new()); + let service_config = random_config(true); + let anchor_reserve_sats = service_config + .node_config + .anchor_channels_config + .as_ref() + .unwrap() + .per_channel_reserve_sats; + setup_builder!(service_builder, service_config.node_config); + service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + service_builder.set_custom_logger(service_logger.clone()); + service_builder.enable_liquidity_provider(lsps2_service_config); + let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); + service_node.start().unwrap(); + 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); + 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 client_node_id = client_node.node_id(); + + let payer_config = random_config(true); + 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(); + payer_node.start().unwrap(); + + let service_addr = service_node.onchain_payment().new_address().unwrap(); + let client_addr = client_node.onchain_payment().new_address().unwrap(); + let payer_addr = payer_node.onchain_payment().new_address().unwrap(); + + let reserve_shortfall_margin_sat = 5_000; + let jit_amount_msat = 100_000_000; + let service_fee_msat = (jit_amount_msat * channel_opening_fee_ppm as u64) / 1_000_000; + let amount_to_forward_msat = jit_amount_msat - service_fee_msat; + let channel_overprovisioning_msat = + (amount_to_forward_msat * channel_over_provisioning_ppm as u64) / 1_000_000; + let expected_channel_size_sat = (amount_to_forward_msat + channel_overprovisioning_msat) / 1000; + let service_funding_sats = + anchor_reserve_sats + expected_channel_size_sat + reserve_shortfall_margin_sat; + assert!( + service_funding_sats + < anchor_reserve_sats + expected_channel_size_sat + anchor_reserve_sats + ); + + premine_blocks(&bitcoind.client, &electrsd.client).await; + distribute_funds_unconfirmed( + &bitcoind.client, + &electrsd.client, + vec![service_addr], + Amount::from_sat(service_funding_sats), + ) + .await; + distribute_funds_unconfirmed( + &bitcoind.client, + &electrsd.client, + vec![client_addr], + Amount::from_sat(1_000_000), + ) + .await; + distribute_funds_unconfirmed( + &bitcoind.client, + &electrsd.client, + vec![payer_addr], + Amount::from_sat(10_000_000), + ) + .await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + service_node.sync_wallets().unwrap(); + client_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + + open_channel(&payer_node, &service_node, 5_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + service_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + expect_channel_ready_event!(payer_node, service_node.node_id()); + expect_channel_ready_event!(service_node, payer_node.node_id()); + + let service_balances = service_node.list_balances(); + assert_eq!(service_balances.total_anchor_channels_reserve_sats, anchor_reserve_sats); + assert_eq!( + service_balances.spendable_onchain_balance_sats, + expected_channel_size_sat + reserve_shortfall_margin_sat + ); + + let invoice_description = + Bolt11InvoiceDescription::Direct(Description::new(String::from("asdf")).unwrap()); + let jit_invoice = client_node + .bolt11_payment() + .receive_via_jit_channel(jit_amount_msat, &invoice_description.into(), 1024, None) + .unwrap(); + + let _payment_id = payer_node.bolt11_payment().send(&jit_invoice, None).unwrap(); + + tokio::time::timeout( + std::time::Duration::from_secs(crate::common::INTEROP_TIMEOUT_SECS), + async { + loop { + if service_logger + .retrieve_logs() + .iter() + .any(|log| log.contains("Unable to create channel due to insufficient funds")) + { + break; + } + assert!( + service_node + .list_channels() + .iter() + .all(|c| c.counterparty.node_id != client_node_id), + "LSPS2 service opened a channel without retaining the optional anchor reserve" + ); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + }, + ) + .await + .expect(&format!( + "Timed out waiting for LSPS2 insufficient-funds log. Logs: {:?}", + service_logger.retrieve_logs() + )); + + assert!(service_node.list_channels().iter().all(|c| c.counterparty.node_id != client_node_id)); + assert!(client_node.list_channels().iter().all(|c| c.counterparty.node_id != service_node_id)); + + service_node.stop().unwrap(); + client_node.stop().unwrap(); + payer_node.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn facade_logging() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -3883,6 +4044,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 657b116607981b317a1ad49b7c1c765157452b8d Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 30 Jun 2026 17:06:05 +0000 Subject: [PATCH 04/17] 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. --- 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 | 230 +++++++++------------------ tests/integration_tests_vss.rs | 4 +- tests/reorg_test.rs | 11 +- 14 files changed, 153 insertions(+), 287 deletions(-) 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 f83cf3d43..8e6b52cb2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -137,7 +137,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() | @@ -184,19 +184,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, @@ -227,7 +215,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, @@ -412,8 +400,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 93d274ff7..1013b8783 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1257,24 +1257,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, @@ -1705,19 +1687,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 a5f2fa537..4bae85c64 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1150,7 +1150,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() } @@ -2397,21 +2397,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( @@ -2421,13 +2420,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 f0148da8a..1caa36c09 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -377,13 +377,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); @@ -480,24 +476,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) { @@ -508,7 +502,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) { @@ -523,14 +517,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) @@ -1035,7 +1023,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 99ac71bbb..175aded6a 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -79,7 +79,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, @@ -97,7 +97,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, @@ -115,7 +115,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, @@ -133,7 +133,7 @@ async fn channel_full_cycle_force_close_trusted_no_reserve() { 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, @@ -147,29 +147,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, @@ -187,7 +169,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, @@ -205,7 +187,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(); @@ -245,7 +227,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); @@ -340,8 +322,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(); @@ -434,7 +416,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()); @@ -507,7 +489,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(); @@ -708,7 +690,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(); @@ -776,7 +758,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(); @@ -862,7 +844,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); @@ -903,7 +885,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); @@ -945,7 +927,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(); @@ -954,7 +936,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()); @@ -1025,7 +1007,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; @@ -1047,7 +1029,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(); @@ -1059,7 +1041,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); @@ -1094,7 +1076,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); @@ -1140,7 +1122,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(); @@ -1154,7 +1136,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); @@ -1177,7 +1159,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); @@ -1204,7 +1186,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); @@ -1250,17 +1232,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); @@ -1369,7 +1350,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); @@ -1384,7 +1365,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(); @@ -1404,7 +1385,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(); @@ -1451,7 +1432,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); @@ -1479,7 +1460,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(); @@ -1694,7 +1675,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(); @@ -1892,7 +1873,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(); @@ -1947,7 +1928,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(); @@ -2024,7 +2005,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(); @@ -2073,7 +2054,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; @@ -2315,7 +2296,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 = @@ -2323,20 +2304,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 = @@ -2448,7 +2429,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()] @@ -2460,7 +2441,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()] @@ -2545,7 +2526,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; @@ -2599,7 +2580,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), @@ -2612,7 +2593,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; @@ -2749,7 +2730,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); @@ -2759,14 +2740,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(); @@ -2973,13 +2954,9 @@ async fn lsps2_rejects_jit_channel_without_anchor_reserve() { }; let service_logger = Arc::new(MockLogFacadeLogger::new()); - let service_config = random_config(true); - let anchor_reserve_sats = service_config - .node_config - .anchor_channels_config - .as_ref() - .unwrap() - .per_channel_reserve_sats; + let service_config = random_config(); + let anchor_reserve_sats = + service_config.node_config.anchor_channels_config.per_channel_reserve_sats; setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); service_builder.set_custom_logger(service_logger.clone()); @@ -2989,7 +2966,7 @@ async fn lsps2_rejects_jit_channel_without_anchor_reserve() { 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); @@ -2997,7 +2974,7 @@ async fn lsps2_rejects_jit_channel_without_anchor_reserve() { 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(); @@ -3113,7 +3090,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 =="); @@ -3129,7 +3106,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; @@ -3196,7 +3173,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(); } @@ -3227,7 +3204,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); @@ -3236,7 +3213,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); @@ -3244,7 +3221,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(); @@ -3402,7 +3379,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); @@ -3412,7 +3389,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); @@ -3421,7 +3398,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(); @@ -3512,7 +3489,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; @@ -3522,7 +3499,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(); @@ -3804,7 +3781,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(); @@ -3946,7 +3923,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(); @@ -3995,7 +3972,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(); @@ -4116,7 +4093,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)); @@ -4124,7 +4101,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)); @@ -4199,62 +4176,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(); @@ -4355,7 +4281,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); @@ -4378,7 +4304,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); @@ -4388,7 +4314,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/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 56dc0662742217fef710fe88bfb5e42783ec2282 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 18:16:49 +0000 Subject: [PATCH 05/17] Use a patched blockstream-electrs in CI The patch adds support for the `broadcast_package` method added in electrum protocol v1.6. Upcoming commits will require this patch to pass CI. --- .github/workflows/benchmarks.yml | 13 ++++--- .github/workflows/hrn-integration.yml | 13 ++++--- .github/workflows/postgres-integration.yml | 13 ++++--- .github/workflows/rust.yml | 19 ++++++---- .github/workflows/vss-integration.yml | 15 ++++++++ .github/workflows/vss-no-auth-integration.yml | 15 ++++++++ scripts/build_electrs.sh | 35 +++++++++++++++++++ ...tcoind_electrs.sh => download_bitcoind.sh} | 21 +++-------- 8 files changed, 109 insertions(+), 35 deletions(-) create mode 100755 scripts/build_electrs.sh rename scripts/{download_bitcoind_electrs.sh => download_bitcoind.sh} (53%) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 4a884ab2a..0584e321e 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -31,13 +31,18 @@ jobs: uses: actions/cache@v4 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} - key: electrs-${{ runner.os }}-${{ runner.arch }} - - name: Download bitcoind/electrs - if: "(steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true')" + 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 + source ./scripts/download_bitcoind.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: | diff --git a/.github/workflows/hrn-integration.yml b/.github/workflows/hrn-integration.yml index 76a95f93d..bd3e2e2d6 100644 --- a/.github/workflows/hrn-integration.yml +++ b/.github/workflows/hrn-integration.yml @@ -28,13 +28,18 @@ jobs: uses: actions/cache@v4 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} - key: electrs-${{ runner.os }}-${{ runner.arch }} - - name: Download bitcoind/electrs - if: "steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true'" + 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 + source ./scripts/download_bitcoind.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: | diff --git a/.github/workflows/postgres-integration.yml b/.github/workflows/postgres-integration.yml index 410136928..3764d454b 100644 --- a/.github/workflows/postgres-integration.yml +++ b/.github/workflows/postgres-integration.yml @@ -43,13 +43,18 @@ jobs: uses: actions/cache@v4 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} - key: electrs-esplora_a33e97e1-${{ runner.os }}-${{ runner.arch }} - - name: Download bitcoind/electrs - if: "steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true'" + 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 + source ./scripts/download_bitcoind.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: | diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 106f2c4f9..8eea352cf 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -60,23 +60,30 @@ jobs: uses: actions/cache@v4 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} - key: electrs-${{ runner.os }}-${{ runner.arch }} - - name: Download bitcoind/electrs - if: "matrix.platform != 'windows-latest' && (steps.cache-bitcoind.outputs.cache-hit != 'true' || steps.cache-electrs.outputs.cache-hit != 'true')" + key: electrs-submit-package-${{ runner.os }}-${{ runner.arch }} + - name: Download bitcoind + if: "matrix.platform != 'windows-latest' && steps.cache-bitcoind.outputs.cache-hit != 'true'" run: | - source ./scripts/download_bitcoind_electrs.sh + source ./scripts/download_bitcoind.sh mkdir -p bin mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + - name: Download electrs + if: "matrix.platform != 'windows-latest' && 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: Build on Rust ${{ matrix.toolchain }} - run: cargo build --verbose --color always + run: | + cargo build --verbose --color always - name: Build with UniFFI support on Rust ${{ matrix.toolchain }} if: matrix.build-uniffi - run: cargo build --features uniffi --verbose --color always + run: | + cargo build --features uniffi --verbose --color always - name: Check release build on Rust ${{ matrix.toolchain }} run: cargo check --release --verbose --color always - name: Check release build with UniFFI support on Rust ${{ matrix.toolchain }} diff --git a/.github/workflows/vss-integration.yml b/.github/workflows/vss-integration.yml index 7ffea3dd6..24417c88f 100644 --- a/.github/workflows/vss-integration.yml +++ b/.github/workflows/vss-integration.yml @@ -31,6 +31,21 @@ jobs: uses: actions/checkout@v6 with: path: ldk-node + - name: Enable caching for electrs + id: cache-electrs + uses: actions/cache@v5 + with: + path: bin/electrs-${{ runner.os }}-${{ runner.arch }} + key: electrs-submit-package-${{ runner.os }}-${{ runner.arch }} + - name: Download electrs + if: "steps.cache-electrs.outputs.cache-hit != 'true'" + run: | + source ./ldk-node/scripts/build_electrs.sh + mkdir -p bin + mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} + - name: Set electrs environment variable + run: | + echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" - name: Checkout VSS uses: actions/checkout@v6 with: diff --git a/.github/workflows/vss-no-auth-integration.yml b/.github/workflows/vss-no-auth-integration.yml index 8ee2fe54b..dc3963f00 100644 --- a/.github/workflows/vss-no-auth-integration.yml +++ b/.github/workflows/vss-no-auth-integration.yml @@ -31,6 +31,21 @@ jobs: uses: actions/checkout@v6 with: path: ldk-node + - name: Enable caching for electrs + id: cache-electrs + uses: actions/cache@v5 + with: + path: bin/electrs-${{ runner.os }}-${{ runner.arch }} + key: electrs-submit-package-${{ runner.os }}-${{ runner.arch }} + - name: Download electrs + if: "steps.cache-electrs.outputs.cache-hit != 'true'" + run: | + source ./ldk-node/scripts/build_electrs.sh + mkdir -p bin + mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} + - name: Set electrs environment variable + run: | + echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" - name: Checkout VSS uses: actions/checkout@v6 with: 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/scripts/download_bitcoind_electrs.sh b/scripts/download_bitcoind.sh similarity index 53% rename from scripts/download_bitcoind_electrs.sh rename to scripts/download_bitcoind.sh index f94e280e3..7582329a9 100755 --- a/scripts/download_bitcoind_electrs.sh +++ b/scripts/download_bitcoind.sh @@ -1,24 +1,18 @@ #!/bin/bash set -eox pipefail -# Our Esplora-based tests require `electrs` and `bitcoind` -# binaries. Here, we download the binaries, validate them, and export their -# location via `ELECTRS_EXE`/`BITCOIND_EXE` which will be used by the -# `electrsd`/`bitcoind` crates in our tests. +# Our Esplora-based tests require `bitcoind` binaries. Here, we +# download the binaries, validate them, and export their location +# via `BITCOIND_EXE` which will be used by the `bitcoind` crates +# in our tests. HOST_PLATFORM="$(rustc --version --verbose | grep "host:" | awk '{ print $2 }')" -ELECTRS_DL_ENDPOINT="https://github.com/RCasatta/electrsd/releases/download/electrs_releases" -ELECTRS_VERSION="esplora_a33e97e1a1fc63fa9c20a116bb92579bbf43b254" BITCOIND_DL_ENDPOINT="https://bitcoincore.org/bin/" BITCOIND_VERSION="29.0" if [[ "$HOST_PLATFORM" == *linux* ]]; then - ELECTRS_DL_FILE_NAME=electrs_linux_"$ELECTRS_VERSION".zip - ELECTRS_DL_HASH="865e26a96e8df77df01d96f2f569dcf9622fc87a8d99a9b8fe30861a4db9ddf1" BITCOIND_DL_FILE_NAME=bitcoin-"$BITCOIND_VERSION"-x86_64-linux-gnu.tar.gz BITCOIND_DL_HASH="a681e4f6ce524c338a105f214613605bac6c33d58c31dc5135bbc02bc458bb6c" elif [[ "$HOST_PLATFORM" == *darwin* ]]; then - ELECTRS_DL_FILE_NAME=electrs_macos_"$ELECTRS_VERSION".zip - ELECTRS_DL_HASH="2d5ff149e8a2482d3658e9b386830dfc40c8fbd7c175ca7cbac58240a9505bcd" BITCOIND_DL_FILE_NAME=bitcoin-"$BITCOIND_VERSION"-x86_64-apple-darwin.tar.gz BITCOIND_DL_HASH="5bb824fc86a15318d6a83a1b821ff4cd4b3d3d0e1ec3d162b805ccf7cae6fca8" else @@ -31,13 +25,6 @@ DL_TMP_DIR=$(mktemp -d) trap 'rm -rf -- "$DL_TMP_DIR"' EXIT pushd "$DL_TMP_DIR" -ELECTRS_DL_URL="$ELECTRS_DL_ENDPOINT"/"$ELECTRS_DL_FILE_NAME" -curl -L -o "$ELECTRS_DL_FILE_NAME" "$ELECTRS_DL_URL" -echo "$ELECTRS_DL_HASH $ELECTRS_DL_FILE_NAME"|shasum -a 256 -c -unzip "$ELECTRS_DL_FILE_NAME" -export ELECTRS_EXE="$DL_TMP_DIR"/electrs -chmod +x "$ELECTRS_EXE" - BITCOIND_DL_URL="$BITCOIND_DL_ENDPOINT"/bitcoin-core-"$BITCOIND_VERSION"/"$BITCOIND_DL_FILE_NAME" curl -L -o "$BITCOIND_DL_FILE_NAME" "$BITCOIND_DL_URL" echo "$BITCOIND_DL_HASH $BITCOIND_DL_FILE_NAME"|shasum -a 256 -c From 59b08b41c2aef3117280883143df041d0a736605 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 24 Jun 2026 05:48:23 +0000 Subject: [PATCH 06/17] Switch cln lnd and eclair interop tests to esplora The mempool/electrs docker image used in those tests only supports submitpackage via the esplora interface, not the electrum interface. --- tests/common/scenarios/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/common/scenarios/mod.rs b/tests/common/scenarios/mod.rs index ffbfc2b00..15d02409e 100644 --- a/tests/common/scenarios/mod.rs +++ b/tests/common/scenarios/mod.rs @@ -92,10 +92,10 @@ pub(crate) async fn wait_for_htlcs_settled( pub(crate) fn setup_ldk_node() -> Node { let config = crate::common::random_config(); let mut builder = ldk_node::Builder::from_config(config.node_config); - let mut sync_config = ldk_node::config::ElectrumSyncConfig::default(); + let mut sync_config = ldk_node::config::EsploraSyncConfig::default(); sync_config.timeouts_config.onchain_wallet_sync_timeout_secs = 180; sync_config.timeouts_config.lightning_wallet_sync_timeout_secs = 120; - builder.set_chain_source_electrum("tcp://127.0.0.1:50001".to_string(), Some(sync_config)); + builder.set_chain_source_esplora("http://127.0.0.1:3002".to_string(), Some(sync_config)); let node = builder.build(config.node_entropy).unwrap(); node.start().unwrap(); node From 7c28111cf06289daac540208952a6c5ade623cef Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 24 Jun 2026 05:48:54 +0000 Subject: [PATCH 07/17] Bump Bitcoin Core version used in kotlin and python tests We bump the Bitcoin Core version used in kotlin and python tests to support ephemeral dust. This is required for 0FC channels. --- tests/docker/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/docker/docker-compose.yml b/tests/docker/docker-compose.yml index e71fd70fb..5459e8eda 100644 --- a/tests/docker/docker-compose.yml +++ b/tests/docker/docker-compose.yml @@ -2,7 +2,7 @@ version: '3' services: bitcoin: - image: blockstream/bitcoind:27.2 + image: blockstream/bitcoind:29.1 platform: linux/amd64 command: [ From e129bd4632c0a0677f31d02a5d5d0b848b464185 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 01:28:46 +0000 Subject: [PATCH 08/17] 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 | 44 +++++++++++++++++++++++++++++++++++++ src/chain/esplora.rs | 25 +++++++++++++++++++++ src/chain/mod.rs | 51 +++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 19 +++++++++++----- src/error.rs | 5 +++++ src/lib.rs | 15 +++++++++++-- 8 files changed, 202 insertions(+), 7 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 7c0edc535..46814f6d2 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -231,6 +231,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..acda1e6b2 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -306,6 +306,50 @@ 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(electrum_client::Error::AllAttemptsErrored(e)))) => { + 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..4490b3a04 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, submit_package_support_required: bool, + ) -> Result<(), Error> { + if !submit_package_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 8e6b52cb2..67c12266c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -54,7 +54,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. @@ -299,10 +300,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 @@ -338,6 +340,12 @@ 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, + /// In addition to `option_anchors_zero_fee_htlc_tx`, we will also attempt to negotiate + /// `option_zero_fee_commitments`. Zero-fee commitment channels remove all commitment + /// feerate negotiation from the channel, and instead source *all* the fees required to + /// confirm the commitment from the anchor reserve at the time of broadcast. + /// See [BOLT 3] for more technical details. + pub enable_zero_fee_commitments: bool, } impl Default for AnchorChannelsConfig { @@ -345,6 +353,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 4bae85c64..85c39612d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -285,9 +285,20 @@ impl Node { e })?; - // Block to ensure we update our fee rate cache once on startup + // 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( + self.config.anchor_channels_config.enable_zero_fee_commitments + ) + ) + })?; // Spawn background task continuously syncing onchain, lightning, and fee rate cache. let stop_sync_receiver = self.stop_sender.subscribe(); From 202459b8759591379d8ebe73bb248516871e4177 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 7 Jul 2026 22:57:49 +0000 Subject: [PATCH 09/17] f: validate chain source on any 0fc channels --- src/lib.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index 85c39612d..90a1db9be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -285,6 +285,24 @@ impl Node { e })?; + let any_current_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()) + }) || 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) + }); + // 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. @@ -295,7 +313,8 @@ impl Node { tokio::try_join!( chain_source.update_fee_rate_estimates(), chain_source.validate_zero_fee_commitments_support_if_required( - self.config.anchor_channels_config.enable_zero_fee_commitments + any_current_0fc_channels + || self.config.anchor_channels_config.enable_zero_fee_commitments ) ) })?; From 8f68eeddf27722fe59bf529db1ec6a8179d56f04 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 7 Jul 2026 23:23:05 +0000 Subject: [PATCH 10/17] f: fixup documentation on 0fc channels --- src/config.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 67c12266c..ac38c8d5f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -343,7 +343,9 @@ pub struct AnchorChannelsConfig { /// In addition to `option_anchors_zero_fee_htlc_tx`, we will also attempt to negotiate /// `option_zero_fee_commitments`. Zero-fee commitment channels remove all commitment /// feerate negotiation from the channel, and instead source *all* the fees required to - /// confirm the commitment from the anchor reserve at the time of broadcast. + /// confirm the commitment from the anchor reserve at the time of broadcast. 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. pub enable_zero_fee_commitments: bool, } From 6625d2810498becba61c27d718fbdcc1ea4d0b81 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 17:35:10 +0000 Subject: [PATCH 11/17] 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 acda1e6b2..d43a38fb6 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; @@ -350,7 +351,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() { @@ -360,7 +361,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 4490b3a04..a0d2eb0bb 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 5722a3ebe..013fd7adf 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -36,8 +36,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 } } @@ -108,3 +152,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 4bc11003e9b16646fe3ca55667c8619b61c1af35 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 25 Jun 2026 16:43:19 +0000 Subject: [PATCH 12/17] 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, 81 insertions(+), 102 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 d43a38fb6..a4d23a8cd 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -622,14 +622,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.first().expect("The length is 1")) + }); let timeout_fut = tokio::time::timeout( Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), spawn_fut, @@ -637,31 +647,13 @@ 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) - ); - }, - }, - 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) - ); + Ok(Err(e)) => self.log_broadcast_error(e, &[txid], tx.as_ref()), + Err(e) => self.log_broadcast_error(e, &[txid], tx.as_ref()), }, + Err(e) => self.log_broadcast_error(e, &[txid], 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 0a98e3ba560863b44695314c7da340ce01f91873 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 25 Jun 2026 16:02:34 +0000 Subject: [PATCH 13/17] 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, and must be broadcasted together as a package using `submitpackage`. 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. --- src/chain/bitcoind.rs | 109 ++++++++++++++++++++++++++++++++++-------- src/chain/electrum.rs | 48 ++++++++++++++++++- src/chain/esplora.rs | 61 +++++++++++++++++------ src/tx_broadcaster.rs | 8 +++- 4 files changed, 187 insertions(+), 39 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 330578121..0ff648ba7 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -596,7 +596,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 +607,43 @@ 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); + match txs.len() { + 0 => (), + 1 => { + let tx = txs.first().expect("The length is 1"); + 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], &[tx]), - }, - Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), - } + Err(e) => self.log_broadcast_error(e, &[txid], &txs), + } + }, + 2.. => { + 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, &txids, &txs), + } + }, } } } @@ -816,6 +834,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 +1417,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 a4d23a8cd..7560690dd 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -361,8 +361,12 @@ impl ElectrumChainSource { return; }; - for tx in txs.into_inner() { - electrum_client.broadcast(tx).await; + match txs.len() { + 0 => (), + 1 => { + electrum_client.broadcast(txs.try_into_single_tx().expect("The length is 1")).await + }, + 2.. => electrum_client.submit_package(txs).await, } } } @@ -657,6 +661,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..4ca2476e6 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -461,21 +461,54 @@ 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); + match txs.len() { + 0 => (), + 1 => { + let tx = txs.first().expect("The length is 1"); + 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_http_error(e, &[txid], &txs), - }, - Err(e) => self.log_broadcast_error(e, &[txid], &txs), - } + Err(e) => self.log_broadcast_error(e, &[txid], &txs), + } + }, + 2.. => { + 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_broadcast_error(e, &txids, &txs), + } + }, } } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 013fd7adf..30b128cd1 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -73,8 +73,12 @@ impl SortedTransactions { SortedTransactions(txs) } - pub(crate) fn into_inner(self) -> Vec { - self.0 + pub(crate) fn try_into_single_tx(mut self) -> Result { + if self.0.len() == 1 { + Ok(self.0.pop().expect("The length is 1")) + } else { + Err(()) + } } } From 97c45e0d03541ddf35867e824f2be54a258eed0c Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 7 Jul 2026 22:32:26 +0000 Subject: [PATCH 14/17] f: only use submitpackage in the multi-tx v3 case --- src/chain/bitcoind.rs | 46 ++++++++++++++++++++++++------------------- src/chain/electrum.rs | 11 +++++++---- src/chain/esplora.rs | 46 +++++++++++++++++++++++++------------------ src/tx_broadcaster.rs | 8 ++------ 4 files changed, 62 insertions(+), 49 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 0ff648ba7..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}; @@ -607,27 +608,9 @@ impl BitcoindChainSource { } pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); match txs.len() { - 0 => (), - 1 => { - let tx = txs.first().expect("The length is 1"); - 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), - } - }, - 2.. => { + 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), @@ -644,6 +627,29 @@ impl BitcoindChainSource { 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), + } + } + }, } } } diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 7560690dd..d19b2d0dc 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, @@ -361,12 +362,14 @@ impl ElectrumChainSource { return; }; + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); match txs.len() { - 0 => (), - 1 => { - electrum_client.broadcast(txs.try_into_single_tx().expect("The length is 1")).await + 2.. if all_txs_are_v3 => electrum_client.submit_package(txs).await, + _ => { + for tx in txs.into_inner() { + electrum_client.broadcast(tx).await + } }, - 2.. => electrum_client.submit_package(txs).await, } } } diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 4ca2476e6..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,26 +462,9 @@ impl EsploraChainSource { } pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); match txs.len() { - 0 => (), - 1 => { - let tx = txs.first().expect("The length is 1"); - 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), - } - }, - 2.. => { + 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), @@ -509,6 +493,30 @@ impl EsploraChainSource { 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), + } + } + }, } } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 30b128cd1..013fd7adf 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -73,12 +73,8 @@ impl SortedTransactions { SortedTransactions(txs) } - pub(crate) fn try_into_single_tx(mut self) -> Result { - if self.0.len() == 1 { - Ok(self.0.pop().expect("The length is 1")) - } else { - Err(()) - } + pub(crate) fn into_inner(self) -> Vec { + self.0 } } From a96d6bdee1dccc478122305ad07db4476b47ec19 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 29 Jun 2026 23:21:46 +0000 Subject: [PATCH 15/17] 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 a5f3f70d9f351db9b2ad45553ae49c0e816a9247 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 29 Oct 2025 07:00:04 +0000 Subject: [PATCH 16/17] Include 0FC channels in anchor channel checks --- src/event.rs | 2 +- src/lib.rs | 18 +++++++++++++++--- src/liquidity/service/lsps2.rs | 3 ++- src/types.rs | 2 +- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/event.rs b/src/event.rs index 1013b8783..066460ee4 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1256,7 +1256,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 90a1db9be..9f14a3aeb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -161,7 +161,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}; @@ -214,6 +216,16 @@ impl LeakChecker { } } +fn supports_anchor_channel_type(init_features: &InitFeatures) -> bool { + init_features.supports_anchors_zero_fee_htlc_tx() + || 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`]. @@ -1364,7 +1376,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 = supports_anchor_channel_type(&init_features); Ok(new_channel_anchor_reserve_sats(&self.config, peer_node_id, anchor_channel)) } @@ -2437,7 +2449,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..6d2f62c9f 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -452,8 +452,9 @@ 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 anchor_channel = crate::supports_anchor_channel_type(&init_features); let required_funds_sats = channel_amount_sats - + if init_features.supports_anchors_zero_fee_htlc_tx() + + if anchor_channel && !self .config .anchor_channels_config 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 0726159ce133d31c80883b9798502636e3728404 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 1 Jul 2026 05:50:32 +0000 Subject: [PATCH 17/17] Negotiate 0FC channels if configured --- src/config.rs | 7 +++++-- tests/common/mod.rs | 9 +++++---- tests/integration_tests_rust.rs | 5 +++-- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/config.rs b/src/config.rs index ac38c8d5f..be8e4cdfe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -182,7 +182,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, @@ -281,7 +282,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 @@ -411,6 +412,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 1caa36c09..7b50265f2 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -380,6 +380,9 @@ pub(crate) fn random_node_alias() -> Option { pub(crate) fn random_config() -> TestConfig { let mut node_config = Config::default(); + let zero_fee_commitments = rand::random_bool(0.5); + node_config.anchor_channels_config.enable_zero_fee_commitments = zero_fee_commitments; + node_config.network = Network::Regtest; println!("Setting network: {}", node_config.network); @@ -1415,10 +1418,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 175aded6a..0b4759066 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1491,8 +1491,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,