From 50d0e6210fe5395d79e7c91cf408b1ba3d1b75fa Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 26 Jun 2026 19:43:06 +0000 Subject: [PATCH 01/20] Remove all carriage returns at end-of-lines We only do this for rust files, and leave bat files untouched. Use git show --ignore-cr-at-eol to check that this commit has no other edits. --- src/liquidity/client/mod.rs | 22 +- src/liquidity/service/lsps2.rs | 1074 ++++++++++++++++---------------- src/liquidity/service/mod.rs | 16 +- 3 files changed, 556 insertions(+), 556 deletions(-) diff --git a/src/liquidity/client/mod.rs b/src/liquidity/client/mod.rs index 15ca7e9650..52fad2da20 100644 --- a/src/liquidity/client/mod.rs +++ b/src/liquidity/client/mod.rs @@ -1,11 +1,11 @@ -// This file is Copyright its original authors, visible in version control history. -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in -// accordance with one or both of these licenses. - -pub(crate) mod lsps1; -pub(crate) mod lsps2; - -pub use lsps1::LSPS1OrderStatus; +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +pub(crate) mod lsps1; +pub(crate) mod lsps2; + +pub use lsps1::LSPS1OrderStatus; diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index 875438b0fb..1143a08d73 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -1,537 +1,537 @@ -// This file is Copyright its original authors, visible in version control history. -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in -// accordance with one or both of these licenses. - -use std::ops::Deref; -use std::sync::{Arc, RwLock, Weak}; -use std::time::Duration; - -use bitcoin::secp256k1::PublicKey; -use bitcoin::Transaction; -use chrono::Utc; -use lightning::events::HTLCHandlingFailureType; -use lightning::ln::channelmanager::InterceptId; -use lightning::ln::types::ChannelId; -use lightning::sign::EntropySource; -use lightning_liquidity::lsps0::ser::LSPSDateTime; -use lightning_liquidity::lsps2::event::LSPS2ServiceEvent; -use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; -use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; -use lightning_types::payment::PaymentHash; - -use crate::logger::{log_error, LdkLogger}; -use crate::types::{ChannelManager, KeysManager, LiquidityManager, PeerManager, Wallet}; -use crate::{total_anchor_channels_reserve_sats, Config}; - -const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); -const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; - -pub(crate) struct LSPS2Service { - pub(crate) service_config: LSPS2ServiceConfig, - pub(crate) ldk_service_config: LdkLSPS2ServiceConfig, -} - -pub(crate) struct LSPS2ServiceLiquiditySource -where - L::Target: LdkLogger, -{ - pub(crate) lsps2_service: Option, - pub(crate) wallet: Arc, - pub(crate) channel_manager: Arc, - pub(crate) peer_manager: RwLock>>, - pub(crate) keys_manager: Arc, - pub(crate) liquidity_manager: Arc, - pub(crate) config: Arc, - pub(crate) logger: L, -} - -/// Represents the configuration of the LSPS2 service. -/// -/// See [bLIP-52 / LSPS2] for more information. -/// -/// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md -#[derive(Debug, Clone)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] -pub struct LSPS2ServiceConfig { - /// A token we may require to be sent by the clients. - /// - /// If set, only requests matching this token will be accepted. - pub require_token: Option, - /// Indicates whether the LSPS service will be announced via the gossip network. - pub advertise_service: bool, - /// The fee we withhold for the channel open from the initial payment. - /// - /// This fee is proportional to the client-requested amount, in parts-per-million. - pub channel_opening_fee_ppm: u32, - /// The proportional overprovisioning for the channel. - /// - /// This determines, in parts-per-million, how much value we'll provision on top of the amount - /// we need to forward the payment to the client. - /// - /// For example, setting this to `100_000` will result in a channel being opened that is 10% - /// larger than then the to-be-forwarded amount (i.e., client-requested amount minus the - /// channel opening fee fee). - pub channel_over_provisioning_ppm: u32, - /// The minimum fee required for opening a channel. - pub min_channel_opening_fee_msat: u64, - /// The minimum number of blocks after confirmation we promise to keep the channel open. - pub min_channel_lifetime: u32, - /// The maximum number of blocks that the client is allowed to set its `to_self_delay` parameter. - pub max_client_to_self_delay: u32, - /// The minimum payment size that we will accept when opening a channel. - pub min_payment_size_msat: u64, - /// The maximum payment size that we will accept when opening a channel. - pub max_payment_size_msat: u64, - /// Use the 'client-trusts-LSP' trust model. - /// - /// When set, the service will delay *broadcasting* the JIT channel's funding transaction until - /// the client claimed sufficient HTLC parts to pay for the channel open. - /// - /// Note this will render the flow incompatible with clients utilizing the 'LSP-trust-client' - /// trust model, i.e., in turn delay *claiming* any HTLCs until they see the funding - /// transaction in the mempool. - /// - /// Please refer to [`bLIP-52`] for more information. - /// - /// [`bLIP-52`]: https://github.com/lightning/blips/blob/master/blip-0052.md#trust-models - pub client_trusts_lsp: bool, - /// When set, we will allow clients to spend their entire channel balance in the channels - /// we open to them. This allows clients to try to steal your channel balance with - /// no financial penalty, so this should only be set if you trust your clients. - /// - /// See [`Node::open_0reserve_channel`] to manually open these channels. - /// - /// [`Node::open_0reserve_channel`]: crate::Node::open_0reserve_channel - pub disable_client_reserve: bool, -} - -impl LSPS2ServiceLiquiditySource -where - L::Target: LdkLogger, -{ - pub(crate) fn set_peer_manager(&self, peer_manager: Weak) { - *self.peer_manager.write().expect("lock") = Some(peer_manager); - } - - pub(crate) fn liquidity_manager(&self) -> Arc { - Arc::clone(&self.liquidity_manager) - } - - pub(crate) fn lsps2_channel_needs_manual_broadcast( - &self, counterparty_node_id: PublicKey, user_channel_id: u128, - ) -> bool { - self.lsps2_service.as_ref().map_or(false, |lsps2_service| { - lsps2_service.service_config.client_trusts_lsp - && self - .liquidity_manager() - .lsps2_service_handler() - .and_then(|handler| { - handler - .channel_needs_manual_broadcast(user_channel_id, &counterparty_node_id) - .ok() - }) - .unwrap_or(false) - }) - } - - pub(crate) fn lsps2_store_funding_transaction( - &self, user_channel_id: u128, counterparty_node_id: PublicKey, funding_tx: Transaction, - ) { - let Some(lsps2_service) = self.lsps2_service.as_ref() else { return }; - if !lsps2_service.service_config.client_trusts_lsp { - // Only necessary for client-trusts-LSP flow - return; - } - - let lsps2_service_handler = self.liquidity_manager.lsps2_service_handler(); - if let Some(handler) = lsps2_service_handler { - handler - .store_funding_transaction(user_channel_id, &counterparty_node_id, funding_tx) - .unwrap_or_else(|e| { - debug_assert!(false, "Failed to store funding transaction: {:?}", e); - log_error!(self.logger, "Failed to store funding transaction: {:?}", e); - }); - } else { - log_error!(self.logger, "LSPS2 service handler is not available."); - } - } - - pub(crate) fn lsps2_funding_tx_broadcast_safe( - &self, user_channel_id: u128, counterparty_node_id: PublicKey, - ) { - let Some(lsps2_service) = self.lsps2_service.as_ref() else { return }; - if !lsps2_service.service_config.client_trusts_lsp { - // Only necessary for client-trusts-LSP flow - return; - } - - let lsps2_service_handler = self.liquidity_manager.lsps2_service_handler(); - if let Some(handler) = lsps2_service_handler { - handler - .set_funding_tx_broadcast_safe(user_channel_id, &counterparty_node_id) - .unwrap_or_else(|e| { - debug_assert!( - false, - "Failed to mark funding transaction safe to broadcast: {:?}", - e - ); - log_error!( - self.logger, - "Failed to mark funding transaction safe to broadcast: {:?}", - e - ); - }); - } else { - log_error!(self.logger, "LSPS2 service handler is not available."); - } - } - - pub(crate) async fn handle_channel_ready( - &self, user_channel_id: u128, channel_id: &ChannelId, counterparty_node_id: &PublicKey, - ) { - if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { - if let Err(e) = lsps2_service_handler - .channel_ready(user_channel_id, channel_id, counterparty_node_id) - .await - { - log_error!( - self.logger, - "LSPS2 service failed to handle ChannelReady event: {:?}", - e - ); - } - } - } - - pub(crate) async fn handle_htlc_intercepted( - &self, intercept_scid: u64, intercept_id: InterceptId, expected_outbound_amount_msat: u64, - payment_hash: PaymentHash, - ) { - if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { - if let Err(e) = lsps2_service_handler - .htlc_intercepted( - intercept_scid, - intercept_id, - expected_outbound_amount_msat, - payment_hash, - ) - .await - { - log_error!( - self.logger, - "LSPS2 service failed to handle HTLCIntercepted event: {:?}", - e - ); - } - } - } - - pub(crate) async fn handle_htlc_handling_failed(&self, failure_type: HTLCHandlingFailureType) { - if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { - if let Err(e) = lsps2_service_handler.htlc_handling_failed(failure_type).await { - log_error!( - self.logger, - "LSPS2 service failed to handle HTLCHandlingFailed event: {:?}", - e - ); - } - } - } - - pub(crate) async fn handle_payment_forwarded( - &self, next_channel_id: Option, skimmed_fee_msat: u64, - ) { - if let Some(next_channel_id) = next_channel_id { - if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { - if let Err(e) = - lsps2_service_handler.payment_forwarded(next_channel_id, skimmed_fee_msat).await - { - log_error!( - self.logger, - "LSPS2 service failed to handle PaymentForwarded: {:?}", - e - ); - } - } - } - } - - pub(crate) async fn handle_event(&self, event: LSPS2ServiceEvent) { - match event { - LSPS2ServiceEvent::GetInfo { request_id, counterparty_node_id, token } => { - if let Some(lsps2_service_handler) = - self.liquidity_manager.lsps2_service_handler().as_ref() - { - let service_config = if let Some(service_config) = - self.lsps2_service.as_ref().map(|s| s.service_config.clone()) - { - service_config - } else { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - }; - - if let Some(required) = service_config.require_token { - if token != Some(required) { - log_error!( - self.logger, - "Rejecting LSPS2 request {:?} from counterparty {} as the client provided an invalid token.", - request_id, - counterparty_node_id - ); - lsps2_service_handler.invalid_token_provided(&counterparty_node_id, request_id.clone()).unwrap_or_else(|e| { - debug_assert!(false, "Failed to reject LSPS2 request. This should never happen."); - log_error!( - self.logger, - "Failed to reject LSPS2 request {:?} from counterparty {} due to: {:?}. This should never happen.", - request_id, - counterparty_node_id, - e - ); - }); - return; - } - } - - let valid_until = LSPSDateTime(Utc::now() + LSPS2_GETINFO_REQUEST_EXPIRY); - let opening_fee_params = LSPS2RawOpeningFeeParams { - min_fee_msat: service_config.min_channel_opening_fee_msat, - proportional: service_config.channel_opening_fee_ppm, - valid_until, - min_lifetime: service_config.min_channel_lifetime, - max_client_to_self_delay: service_config.max_client_to_self_delay, - min_payment_size_msat: service_config.min_payment_size_msat, - max_payment_size_msat: service_config.max_payment_size_msat, - }; - - let opening_fee_params_menu = vec![opening_fee_params]; - - if let Err(e) = lsps2_service_handler.opening_fee_params_generated( - &counterparty_node_id, - request_id, - opening_fee_params_menu, - ) { - log_error!( - self.logger, - "Failed to handle generated opening fee params: {:?}", - e - ); - } - } else { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - } - }, - LSPS2ServiceEvent::BuyRequest { - request_id, - counterparty_node_id, - opening_fee_params: _, - payment_size_msat, - } => { - if let Some(lsps2_service_handler) = - self.liquidity_manager.lsps2_service_handler().as_ref() - { - let service_config = if let Some(service_config) = - self.lsps2_service.as_ref().map(|s| s.service_config.clone()) - { - service_config - } else { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - }; - - let user_channel_id: u128 = u128::from_ne_bytes( - self.keys_manager.get_secure_random_bytes()[..16] - .try_into() - .expect("a 16-byte slice should convert into a [u8; 16]"), - ); - let intercept_scid = self.channel_manager.get_intercept_scid(); - - if let Some(payment_size_msat) = payment_size_msat { - // We already check this in `lightning-liquidity`, but better safe than - // sorry. - // - // TODO: We might want to eventually send back an error here, but we - // currently can't and have to trust `lightning-liquidity` is doing the - // right thing. - // - // TODO: Eventually we also might want to make sure that we have sufficient - // liquidity for the channel opening here. - if payment_size_msat > service_config.max_payment_size_msat - || payment_size_msat < service_config.min_payment_size_msat - { - log_error!( - self.logger, - "Rejecting to handle LSPS2 buy request {:?} from counterparty {} as the client requested an invalid payment size.", - request_id, - counterparty_node_id - ); - return; - } - } - - match lsps2_service_handler - .invoice_parameters_generated( - &counterparty_node_id, - request_id, - intercept_scid, - LSPS2_CHANNEL_CLTV_EXPIRY_DELTA, - service_config.client_trusts_lsp, - user_channel_id, - ) - .await - { - Ok(()) => {}, - Err(e) => { - log_error!( - self.logger, - "Failed to provide invoice parameters: {:?}", - e - ); - return; - }, - } - } else { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - } - }, - LSPS2ServiceEvent::OpenChannel { - their_network_key, - amt_to_forward_msat, - opening_fee_msat: _, - user_channel_id, - intercept_scid: _, - } => { - if self.liquidity_manager.lsps2_service_handler().is_none() { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - }; - - let service_config = if let Some(service_config) = - self.lsps2_service.as_ref().map(|s| s.service_config.clone()) - { - service_config - } else { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - }; - - let init_features = if let Some(Some(peer_manager)) = - self.peer_manager.read().expect("lock").as_ref().map(|weak| weak.upgrade()) - { - // Fail if we're not connected to the prospective channel partner. - if let Some(peer) = peer_manager.peer_by_node_id(&their_network_key) { - peer.init_features - } else { - // TODO: We just silently fail here. Eventually we will need to remember - // the pending requests and regularly retry opening the channel until we - // succeed. - log_error!( - self.logger, - "Failed to open LSPS2 channel to {} due to peer not being not connected.", - their_network_key, - ); - return; - } - } else { - debug_assert!(false, "Failed to handle LSPS2ServiceEvent as peer manager isn't available. This should never happen.",); - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as peer manager isn't available. This should never happen.",); - return; - }; - - // Fail if we have insufficient onchain funds available. - let over_provisioning_msat = (amt_to_forward_msat - * service_config.channel_over_provisioning_ppm as u64) - / 1_000_000; - let channel_amount_sats = (amt_to_forward_msat + over_provisioning_msat) / 1000; - 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); - 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() - && !c.trusted_peers_no_reserve.contains(&their_network_key) - { - c.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", - spendable_amount_sats, channel_amount_sats - ); - // TODO: We just silently fail here. Eventually we will need to remember - // the pending requests and regularly retry opening the channel until we - // succeed. - return; - } - - let mut config = self.channel_manager.get_current_config().clone(); - - // If we act as an LSPS2 service, the HTLC-value-in-flight must be 100% of the - // channel value to ensure we can forward the initial payment. That cap only - // applies to unannounced channels, so the channel must also be unannounced. - debug_assert_eq!( - config - .channel_handshake_config - .unannounced_channel_max_inbound_htlc_value_in_flight_percentage, - 100 - ); - debug_assert!(!config.channel_handshake_config.announce_for_forwarding); - debug_assert!(config.accept_forwards_to_priv_channels); - - // We set the forwarding fee to 0 for now as we're getting paid by the channel fee. - // - // TODO: revisit this decision eventually. - config.channel_config.forwarding_fee_base_msat = 0; - config.channel_config.forwarding_fee_proportional_millionths = 0; - - let result = if service_config.disable_client_reserve { - self.channel_manager.create_channel_to_trusted_peer_0reserve( - their_network_key, - channel_amount_sats, - 0, - user_channel_id, - None, - Some(config), - ) - } else { - self.channel_manager.create_channel( - their_network_key, - channel_amount_sats, - 0, - user_channel_id, - None, - Some(config), - ) - }; - - match result { - Ok(_) => {}, - Err(e) => { - // TODO: We just silently fail here. Eventually we will need to remember - // the pending requests and regularly retry opening the channel until we - // succeed. - let zero_reserve_string = - if service_config.disable_client_reserve { "0reserve " } else { "" }; - log_error!( - self.logger, - "Failed to open LSPS2 {}channel to {}: {:?}", - zero_reserve_string, - their_network_key, - e - ); - return; - }, - } - }, - } - } -} +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::ops::Deref; +use std::sync::{Arc, RwLock, Weak}; +use std::time::Duration; + +use bitcoin::secp256k1::PublicKey; +use bitcoin::Transaction; +use chrono::Utc; +use lightning::events::HTLCHandlingFailureType; +use lightning::ln::channelmanager::InterceptId; +use lightning::ln::types::ChannelId; +use lightning::sign::EntropySource; +use lightning_liquidity::lsps0::ser::LSPSDateTime; +use lightning_liquidity::lsps2::event::LSPS2ServiceEvent; +use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; +use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; +use lightning_types::payment::PaymentHash; + +use crate::logger::{log_error, LdkLogger}; +use crate::types::{ChannelManager, KeysManager, LiquidityManager, PeerManager, Wallet}; +use crate::{total_anchor_channels_reserve_sats, Config}; + +const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); +const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; + +pub(crate) struct LSPS2Service { + pub(crate) service_config: LSPS2ServiceConfig, + pub(crate) ldk_service_config: LdkLSPS2ServiceConfig, +} + +pub(crate) struct LSPS2ServiceLiquiditySource +where + L::Target: LdkLogger, +{ + pub(crate) lsps2_service: Option, + pub(crate) wallet: Arc, + pub(crate) channel_manager: Arc, + pub(crate) peer_manager: RwLock>>, + pub(crate) keys_manager: Arc, + pub(crate) liquidity_manager: Arc, + pub(crate) config: Arc, + pub(crate) logger: L, +} + +/// Represents the configuration of the LSPS2 service. +/// +/// See [bLIP-52 / LSPS2] for more information. +/// +/// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md +#[derive(Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct LSPS2ServiceConfig { + /// A token we may require to be sent by the clients. + /// + /// If set, only requests matching this token will be accepted. + pub require_token: Option, + /// Indicates whether the LSPS service will be announced via the gossip network. + pub advertise_service: bool, + /// The fee we withhold for the channel open from the initial payment. + /// + /// This fee is proportional to the client-requested amount, in parts-per-million. + pub channel_opening_fee_ppm: u32, + /// The proportional overprovisioning for the channel. + /// + /// This determines, in parts-per-million, how much value we'll provision on top of the amount + /// we need to forward the payment to the client. + /// + /// For example, setting this to `100_000` will result in a channel being opened that is 10% + /// larger than then the to-be-forwarded amount (i.e., client-requested amount minus the + /// channel opening fee fee). + pub channel_over_provisioning_ppm: u32, + /// The minimum fee required for opening a channel. + pub min_channel_opening_fee_msat: u64, + /// The minimum number of blocks after confirmation we promise to keep the channel open. + pub min_channel_lifetime: u32, + /// The maximum number of blocks that the client is allowed to set its `to_self_delay` parameter. + pub max_client_to_self_delay: u32, + /// The minimum payment size that we will accept when opening a channel. + pub min_payment_size_msat: u64, + /// The maximum payment size that we will accept when opening a channel. + pub max_payment_size_msat: u64, + /// Use the 'client-trusts-LSP' trust model. + /// + /// When set, the service will delay *broadcasting* the JIT channel's funding transaction until + /// the client claimed sufficient HTLC parts to pay for the channel open. + /// + /// Note this will render the flow incompatible with clients utilizing the 'LSP-trust-client' + /// trust model, i.e., in turn delay *claiming* any HTLCs until they see the funding + /// transaction in the mempool. + /// + /// Please refer to [`bLIP-52`] for more information. + /// + /// [`bLIP-52`]: https://github.com/lightning/blips/blob/master/blip-0052.md#trust-models + pub client_trusts_lsp: bool, + /// When set, we will allow clients to spend their entire channel balance in the channels + /// we open to them. This allows clients to try to steal your channel balance with + /// no financial penalty, so this should only be set if you trust your clients. + /// + /// See [`Node::open_0reserve_channel`] to manually open these channels. + /// + /// [`Node::open_0reserve_channel`]: crate::Node::open_0reserve_channel + pub disable_client_reserve: bool, +} + +impl LSPS2ServiceLiquiditySource +where + L::Target: LdkLogger, +{ + pub(crate) fn set_peer_manager(&self, peer_manager: Weak) { + *self.peer_manager.write().expect("lock") = Some(peer_manager); + } + + pub(crate) fn liquidity_manager(&self) -> Arc { + Arc::clone(&self.liquidity_manager) + } + + pub(crate) fn lsps2_channel_needs_manual_broadcast( + &self, counterparty_node_id: PublicKey, user_channel_id: u128, + ) -> bool { + self.lsps2_service.as_ref().map_or(false, |lsps2_service| { + lsps2_service.service_config.client_trusts_lsp + && self + .liquidity_manager() + .lsps2_service_handler() + .and_then(|handler| { + handler + .channel_needs_manual_broadcast(user_channel_id, &counterparty_node_id) + .ok() + }) + .unwrap_or(false) + }) + } + + pub(crate) fn lsps2_store_funding_transaction( + &self, user_channel_id: u128, counterparty_node_id: PublicKey, funding_tx: Transaction, + ) { + let Some(lsps2_service) = self.lsps2_service.as_ref() else { return }; + if !lsps2_service.service_config.client_trusts_lsp { + // Only necessary for client-trusts-LSP flow + return; + } + + let lsps2_service_handler = self.liquidity_manager.lsps2_service_handler(); + if let Some(handler) = lsps2_service_handler { + handler + .store_funding_transaction(user_channel_id, &counterparty_node_id, funding_tx) + .unwrap_or_else(|e| { + debug_assert!(false, "Failed to store funding transaction: {:?}", e); + log_error!(self.logger, "Failed to store funding transaction: {:?}", e); + }); + } else { + log_error!(self.logger, "LSPS2 service handler is not available."); + } + } + + pub(crate) fn lsps2_funding_tx_broadcast_safe( + &self, user_channel_id: u128, counterparty_node_id: PublicKey, + ) { + let Some(lsps2_service) = self.lsps2_service.as_ref() else { return }; + if !lsps2_service.service_config.client_trusts_lsp { + // Only necessary for client-trusts-LSP flow + return; + } + + let lsps2_service_handler = self.liquidity_manager.lsps2_service_handler(); + if let Some(handler) = lsps2_service_handler { + handler + .set_funding_tx_broadcast_safe(user_channel_id, &counterparty_node_id) + .unwrap_or_else(|e| { + debug_assert!( + false, + "Failed to mark funding transaction safe to broadcast: {:?}", + e + ); + log_error!( + self.logger, + "Failed to mark funding transaction safe to broadcast: {:?}", + e + ); + }); + } else { + log_error!(self.logger, "LSPS2 service handler is not available."); + } + } + + pub(crate) async fn handle_channel_ready( + &self, user_channel_id: u128, channel_id: &ChannelId, counterparty_node_id: &PublicKey, + ) { + if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { + if let Err(e) = lsps2_service_handler + .channel_ready(user_channel_id, channel_id, counterparty_node_id) + .await + { + log_error!( + self.logger, + "LSPS2 service failed to handle ChannelReady event: {:?}", + e + ); + } + } + } + + pub(crate) async fn handle_htlc_intercepted( + &self, intercept_scid: u64, intercept_id: InterceptId, expected_outbound_amount_msat: u64, + payment_hash: PaymentHash, + ) { + if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { + if let Err(e) = lsps2_service_handler + .htlc_intercepted( + intercept_scid, + intercept_id, + expected_outbound_amount_msat, + payment_hash, + ) + .await + { + log_error!( + self.logger, + "LSPS2 service failed to handle HTLCIntercepted event: {:?}", + e + ); + } + } + } + + pub(crate) async fn handle_htlc_handling_failed(&self, failure_type: HTLCHandlingFailureType) { + if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { + if let Err(e) = lsps2_service_handler.htlc_handling_failed(failure_type).await { + log_error!( + self.logger, + "LSPS2 service failed to handle HTLCHandlingFailed event: {:?}", + e + ); + } + } + } + + pub(crate) async fn handle_payment_forwarded( + &self, next_channel_id: Option, skimmed_fee_msat: u64, + ) { + if let Some(next_channel_id) = next_channel_id { + if let Some(lsps2_service_handler) = self.liquidity_manager.lsps2_service_handler() { + if let Err(e) = + lsps2_service_handler.payment_forwarded(next_channel_id, skimmed_fee_msat).await + { + log_error!( + self.logger, + "LSPS2 service failed to handle PaymentForwarded: {:?}", + e + ); + } + } + } + } + + pub(crate) async fn handle_event(&self, event: LSPS2ServiceEvent) { + match event { + LSPS2ServiceEvent::GetInfo { request_id, counterparty_node_id, token } => { + if let Some(lsps2_service_handler) = + self.liquidity_manager.lsps2_service_handler().as_ref() + { + let service_config = if let Some(service_config) = + self.lsps2_service.as_ref().map(|s| s.service_config.clone()) + { + service_config + } else { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + }; + + if let Some(required) = service_config.require_token { + if token != Some(required) { + log_error!( + self.logger, + "Rejecting LSPS2 request {:?} from counterparty {} as the client provided an invalid token.", + request_id, + counterparty_node_id + ); + lsps2_service_handler.invalid_token_provided(&counterparty_node_id, request_id.clone()).unwrap_or_else(|e| { + debug_assert!(false, "Failed to reject LSPS2 request. This should never happen."); + log_error!( + self.logger, + "Failed to reject LSPS2 request {:?} from counterparty {} due to: {:?}. This should never happen.", + request_id, + counterparty_node_id, + e + ); + }); + return; + } + } + + let valid_until = LSPSDateTime(Utc::now() + LSPS2_GETINFO_REQUEST_EXPIRY); + let opening_fee_params = LSPS2RawOpeningFeeParams { + min_fee_msat: service_config.min_channel_opening_fee_msat, + proportional: service_config.channel_opening_fee_ppm, + valid_until, + min_lifetime: service_config.min_channel_lifetime, + max_client_to_self_delay: service_config.max_client_to_self_delay, + min_payment_size_msat: service_config.min_payment_size_msat, + max_payment_size_msat: service_config.max_payment_size_msat, + }; + + let opening_fee_params_menu = vec![opening_fee_params]; + + if let Err(e) = lsps2_service_handler.opening_fee_params_generated( + &counterparty_node_id, + request_id, + opening_fee_params_menu, + ) { + log_error!( + self.logger, + "Failed to handle generated opening fee params: {:?}", + e + ); + } + } else { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + } + }, + LSPS2ServiceEvent::BuyRequest { + request_id, + counterparty_node_id, + opening_fee_params: _, + payment_size_msat, + } => { + if let Some(lsps2_service_handler) = + self.liquidity_manager.lsps2_service_handler().as_ref() + { + let service_config = if let Some(service_config) = + self.lsps2_service.as_ref().map(|s| s.service_config.clone()) + { + service_config + } else { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + }; + + let user_channel_id: u128 = u128::from_ne_bytes( + self.keys_manager.get_secure_random_bytes()[..16] + .try_into() + .expect("a 16-byte slice should convert into a [u8; 16]"), + ); + let intercept_scid = self.channel_manager.get_intercept_scid(); + + if let Some(payment_size_msat) = payment_size_msat { + // We already check this in `lightning-liquidity`, but better safe than + // sorry. + // + // TODO: We might want to eventually send back an error here, but we + // currently can't and have to trust `lightning-liquidity` is doing the + // right thing. + // + // TODO: Eventually we also might want to make sure that we have sufficient + // liquidity for the channel opening here. + if payment_size_msat > service_config.max_payment_size_msat + || payment_size_msat < service_config.min_payment_size_msat + { + log_error!( + self.logger, + "Rejecting to handle LSPS2 buy request {:?} from counterparty {} as the client requested an invalid payment size.", + request_id, + counterparty_node_id + ); + return; + } + } + + match lsps2_service_handler + .invoice_parameters_generated( + &counterparty_node_id, + request_id, + intercept_scid, + LSPS2_CHANNEL_CLTV_EXPIRY_DELTA, + service_config.client_trusts_lsp, + user_channel_id, + ) + .await + { + Ok(()) => {}, + Err(e) => { + log_error!( + self.logger, + "Failed to provide invoice parameters: {:?}", + e + ); + return; + }, + } + } else { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + } + }, + LSPS2ServiceEvent::OpenChannel { + their_network_key, + amt_to_forward_msat, + opening_fee_msat: _, + user_channel_id, + intercept_scid: _, + } => { + if self.liquidity_manager.lsps2_service_handler().is_none() { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + }; + + let service_config = if let Some(service_config) = + self.lsps2_service.as_ref().map(|s| s.service_config.clone()) + { + service_config + } else { + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + }; + + let init_features = if let Some(Some(peer_manager)) = + self.peer_manager.read().expect("lock").as_ref().map(|weak| weak.upgrade()) + { + // Fail if we're not connected to the prospective channel partner. + if let Some(peer) = peer_manager.peer_by_node_id(&their_network_key) { + peer.init_features + } else { + // TODO: We just silently fail here. Eventually we will need to remember + // the pending requests and regularly retry opening the channel until we + // succeed. + log_error!( + self.logger, + "Failed to open LSPS2 channel to {} due to peer not being not connected.", + their_network_key, + ); + return; + } + } else { + debug_assert!(false, "Failed to handle LSPS2ServiceEvent as peer manager isn't available. This should never happen.",); + log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as peer manager isn't available. This should never happen.",); + return; + }; + + // Fail if we have insufficient onchain funds available. + let over_provisioning_msat = (amt_to_forward_msat + * service_config.channel_over_provisioning_ppm as u64) + / 1_000_000; + let channel_amount_sats = (amt_to_forward_msat + over_provisioning_msat) / 1000; + 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); + 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() + && !c.trusted_peers_no_reserve.contains(&their_network_key) + { + c.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", + spendable_amount_sats, channel_amount_sats + ); + // TODO: We just silently fail here. Eventually we will need to remember + // the pending requests and regularly retry opening the channel until we + // succeed. + return; + } + + let mut config = self.channel_manager.get_current_config().clone(); + + // If we act as an LSPS2 service, the HTLC-value-in-flight must be 100% of the + // channel value to ensure we can forward the initial payment. That cap only + // applies to unannounced channels, so the channel must also be unannounced. + debug_assert_eq!( + config + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage, + 100 + ); + debug_assert!(!config.channel_handshake_config.announce_for_forwarding); + debug_assert!(config.accept_forwards_to_priv_channels); + + // We set the forwarding fee to 0 for now as we're getting paid by the channel fee. + // + // TODO: revisit this decision eventually. + config.channel_config.forwarding_fee_base_msat = 0; + config.channel_config.forwarding_fee_proportional_millionths = 0; + + let result = if service_config.disable_client_reserve { + self.channel_manager.create_channel_to_trusted_peer_0reserve( + their_network_key, + channel_amount_sats, + 0, + user_channel_id, + None, + Some(config), + ) + } else { + self.channel_manager.create_channel( + their_network_key, + channel_amount_sats, + 0, + user_channel_id, + None, + Some(config), + ) + }; + + match result { + Ok(_) => {}, + Err(e) => { + // TODO: We just silently fail here. Eventually we will need to remember + // the pending requests and regularly retry opening the channel until we + // succeed. + let zero_reserve_string = + if service_config.disable_client_reserve { "0reserve " } else { "" }; + log_error!( + self.logger, + "Failed to open LSPS2 {}channel to {}: {:?}", + zero_reserve_string, + their_network_key, + e + ); + return; + }, + } + }, + } + } +} diff --git a/src/liquidity/service/mod.rs b/src/liquidity/service/mod.rs index 5e3a3b1833..cdbaf54265 100644 --- a/src/liquidity/service/mod.rs +++ b/src/liquidity/service/mod.rs @@ -1,8 +1,8 @@ -// This file is Copyright its original authors, visible in version control history. -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in -// accordance with one or both of these licenses. - -pub(crate) mod lsps2; +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +pub(crate) mod lsps2; From c2f5c984fcd17a8bd2f38e950215dd2f4dce2df2 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 00:27:51 +0000 Subject: [PATCH 02/20] 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 76f2aa9ce6..691ef36463 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -334,32 +334,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, old_block_time: None } => { From 0b6a2feec70d7b5f04a39f25b326737b88447654 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sat, 27 Jun 2026 19:43:51 +0000 Subject: [PATCH 03/20] 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 34fa7f54d6..b19434674b 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 11a850b120822a4a87f5e720d733a309ad2ea6a7 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 26 Jun 2026 19:37:40 +0000 Subject: [PATCH 04/20] 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. --- src/lib.rs | 2 +- src/liquidity/service/lsps2.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index b19434674b..d20badd94e 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 1143a08d73..b16abff60d 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 From 5acaebc1712656431bea63c1b0e556ec531ec42c Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sun, 28 Jun 2026 00:33:21 +0000 Subject: [PATCH 05/20] f: Cover anchor reserve checks for optional feature bits AI assistance: generated with OpenAI Codex. --- tests/integration_tests_rust.rs | 158 +++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index fab73ed0c5..6afb6729ec 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -35,7 +35,7 @@ use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, UnifiedPaymentResult, }; -use ldk_node::{Builder, Event, NodeError}; +use ldk_node::{Builder, Event, Node, NodeError, ReserveType}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -3016,6 +3016,162 @@ 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 reserve_margin_sat = 500; + + 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 b5a819a6626b93e7624c964b1001926a6ab3025b Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sun, 28 Jun 2026 00:33:27 +0000 Subject: [PATCH 06/20] f: Test LSPS2 anchor reserve enforcement AI-Generated-By: OpenAI Codex --- tests/integration_tests_rust.rs | 163 +++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 6afb6729ec..289a53e37a 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, @@ -2128,6 +2130,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(); From fb3f977f43c4f73d18ff0bba1442f19e10ca1c24 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sun, 28 Jun 2026 00:33:33 +0000 Subject: [PATCH 07/20] f: Log LSPS2 reserve-inclusive funding requirement AI-Generated-By: OpenAI Codex --- src/liquidity/service/lsps2.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index b16abff60d..524157a671 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -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 From 5d174c917963278e3a4bf2181de785882702123e Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 17:35:10 +0000 Subject: [PATCH 08/20] 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 | 6 +- src/tx_broadcaster.rs | 202 +++++++++++++++++++++++++++++++++++++++++- 5 files changed, 211 insertions(+), 14 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 6bfa8ffd27..ce349d74de 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::TransactionBroadcast; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -571,12 +572,12 @@ impl BitcoindChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn process_transaction_broadcast(&self, txs: TransactionBroadcast) { // 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 7406f06b4b..cc2c2a709e 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -33,6 +33,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, LdkLogger, Logger}; use crate::runtime::Runtime; +use crate::tx_broadcaster::TransactionBroadcast; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::PersistedNodeMetrics; @@ -294,7 +295,7 @@ impl ElectrumChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn process_transaction_broadcast(&self, txs: TransactionBroadcast) { let electrum_client: Arc = if let Some(client) = self.electrum_runtime_status.read().expect("lock").client().as_ref() { @@ -304,7 +305,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 eb23a395d3..6c7afdcd12 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -10,7 +10,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; @@ -24,6 +24,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, LdkLogger, Logger}; +use crate::tx_broadcaster::TransactionBroadcast; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -355,8 +356,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: TransactionBroadcast) { + 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 5a326be97b..66a74b8c37 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -455,13 +455,13 @@ impl ChainSource { Some(next_package) = receiver.recv() => { match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_broadcast_package(next_package).await + esplora_chain_source.process_transaction_broadcast(next_package).await }, ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_broadcast_package(next_package).await + electrum_chain_source.process_transaction_broadcast(next_package).await }, ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_broadcast_package(next_package).await + bitcoind_chain_source.process_transaction_broadcast(next_package).await }, } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 7084135b00..207c8b64f2 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -15,12 +15,34 @@ use crate::logger::{log_error, LdkLogger}; const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; +pub(crate) struct TransactionBroadcast(Vec); + +impl TransactionBroadcast { + pub(crate) fn into_inner(self) -> Vec { + self.0 + } +} + +impl Deref for TransactionBroadcast { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From> for TransactionBroadcast { + fn from(mut value: Vec) -> Self { + sort_parents_child_package_topologically(&mut value); + TransactionBroadcast(value) + } +} + pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, { - queue_sender: mpsc::Sender>, - queue_receiver: Mutex>>, + queue_sender: mpsc::Sender, + queue_receiver: Mutex>, logger: L, } @@ -35,7 +57,7 @@ where pub(crate) async fn get_broadcast_queue( &self, - ) -> MutexGuard<'_, mpsc::Receiver>> { + ) -> MutexGuard<'_, mpsc::Receiver> { self.queue_receiver.lock().await } } @@ -46,8 +68,180 @@ where { fn broadcast_transactions(&self, txs: &[(&Transaction, TransactionType)]) { let package = txs.iter().map(|(t, _)| (*t).clone()).collect::>(); - self.queue_sender.try_send(package).unwrap_or_else(|e| { + self.queue_sender.try_send(package.into()).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); } } + +fn sort_parents_child_package_topologically(txs: &mut [Transaction]) { + if txs.len() == 0 || txs.len() == 1 { + return; + } + 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); + } +} + +#[cfg(test)] +mod tests { + use bitcoin::hashes::Hash; + use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; + + use super::sort_parents_child_package_topologically; + + 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 mut package = vec![parent_a, parent_b, child]; + + sort_parents_child_package_topologically(&mut package); + + 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 mut package = vec![child, parent]; + + sort_parents_child_package_topologically(&mut package); + + 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 mut package = vec![child, parent_a, parent_b]; + + sort_parents_child_package_topologically(&mut package); + + 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 mut package = vec![child, parent_a, parent_b, parent_c]; + + sort_parents_child_package_topologically(&mut package); + + 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 mut package = vec![parent_a, child, parent_b]; + + sort_parents_child_package_topologically(&mut package); + + 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 mut package = vec![parent]; + + sort_parents_child_package_topologically(&mut package); + + assert_eq!(package.len(), 1); + assert_eq!(package[0].compute_txid(), parent_txid); + } + + #[test] + fn topological_sort_accepts_empty_vec() { + sort_parents_child_package_topologically(&mut []); + } +} From be34e057cdeff07f78211c3a0e6d40fdf79701a3 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 25 Jun 2026 18:46:56 +0000 Subject: [PATCH 09/20] f: call sort_parents_child_package_topologically explicitly The From trait is usually reserved for cheap and straightforward conversions which was not a good fit here. --- src/tx_broadcaster.rs | 95 +++++++++++++++++++++---------------------- 1 file changed, 46 insertions(+), 49 deletions(-) diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 207c8b64f2..c61bb83fb2 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -18,6 +18,32 @@ const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; pub(crate) struct TransactionBroadcast(Vec); impl TransactionBroadcast { + fn sort_parents_child_package_topologically(mut txs: Vec) -> TransactionBroadcast { + if txs.len() == 0 || txs.len() == 1 { + return TransactionBroadcast(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); + } + + TransactionBroadcast(txs) + } + pub(crate) fn into_inner(self) -> Vec { self.0 } @@ -30,13 +56,6 @@ impl Deref for TransactionBroadcast { } } -impl From> for TransactionBroadcast { - fn from(mut value: Vec) -> Self { - sort_parents_child_package_topologically(&mut value); - TransactionBroadcast(value) - } -} - pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, @@ -67,34 +86,12 @@ where L::Target: LdkLogger, { fn broadcast_transactions(&self, txs: &[(&Transaction, TransactionType)]) { - let package = txs.iter().map(|(t, _)| (*t).clone()).collect::>(); - self.queue_sender.try_send(package.into()).unwrap_or_else(|e| { - log_error!(self.logger, "Failed to broadcast transactions: {}", e); - }); - } -} - -fn sort_parents_child_package_topologically(txs: &mut [Transaction]) { - if txs.len() == 0 || txs.len() == 1 { - return; - } - 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); + let txs = txs.iter().map(|(t, _)| (*t).clone()).collect::>(); + self.queue_sender + .try_send(TransactionBroadcast::sort_parents_child_package_topologically(txs)) + .unwrap_or_else(|e| { + log_error!(self.logger, "Failed to broadcast transactions: {}", e); + }); } } @@ -103,7 +100,7 @@ mod tests { use bitcoin::hashes::Hash; use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; - use super::sort_parents_child_package_topologically; + use super::TransactionBroadcast; fn txin(txid: Txid, vout: u32) -> TxIn { TxIn { @@ -161,9 +158,9 @@ mod tests { let original_txids = [parent_a.compute_txid(), parent_b.compute_txid(), child.compute_txid()]; - let mut package = vec![parent_a, parent_b, child]; + let txs = vec![parent_a, parent_b, child]; - sort_parents_child_package_topologically(&mut package); + let package = TransactionBroadcast::sort_parents_child_package_topologically(txs); assert_eq!( package.iter().map(Transaction::compute_txid).collect::>(), @@ -177,9 +174,9 @@ mod tests { let child = child_tx(&[&parent]); let parent_txids = [parent.compute_txid()]; let child_txid = child.compute_txid(); - let mut package = vec![child, parent]; + let txs = vec![child, parent]; - sort_parents_child_package_topologically(&mut package); + let package = TransactionBroadcast::sort_parents_child_package_topologically(txs); assert_parents_before_child(&package, child_txid, &parent_txids); } @@ -191,9 +188,9 @@ mod tests { 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 mut package = vec![child, parent_a, parent_b]; + let txs = vec![child, parent_a, parent_b]; - sort_parents_child_package_topologically(&mut package); + let package = TransactionBroadcast::sort_parents_child_package_topologically(txs); assert_parents_before_child(&package, child_txid, &parent_txids); } @@ -207,9 +204,9 @@ mod tests { let parent_txids = [parent_a.compute_txid(), parent_b.compute_txid(), parent_c.compute_txid()]; let child_txid = child.compute_txid(); - let mut package = vec![child, parent_a, parent_b, parent_c]; + let txs = vec![child, parent_a, parent_b, parent_c]; - sort_parents_child_package_topologically(&mut package); + let package = TransactionBroadcast::sort_parents_child_package_topologically(txs); assert_parents_before_child(&package, child_txid, &parent_txids); } @@ -221,9 +218,9 @@ mod tests { 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 mut package = vec![parent_a, child, parent_b]; + let txs = vec![parent_a, child, parent_b]; - sort_parents_child_package_topologically(&mut package); + let package = TransactionBroadcast::sort_parents_child_package_topologically(txs); assert_parents_before_child(&package, child_txid, &parent_txids); } @@ -232,9 +229,9 @@ mod tests { fn topological_sort_leaves_single_transaction_package_unchanged() { let parent = parent_tx(1); let parent_txid = parent.compute_txid(); - let mut package = vec![parent]; + let txs = vec![parent]; - sort_parents_child_package_topologically(&mut package); + let package = TransactionBroadcast::sort_parents_child_package_topologically(txs); assert_eq!(package.len(), 1); assert_eq!(package[0].compute_txid(), parent_txid); @@ -242,6 +239,6 @@ mod tests { #[test] fn topological_sort_accepts_empty_vec() { - sort_parents_child_package_topologically(&mut []); + TransactionBroadcast::sort_parents_child_package_topologically(Vec::new()); } } From 7064ce8fbca5e62fbbf7e6d46ae77246712152f7 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 18:16:49 +0000 Subject: [PATCH 10/20] 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} | 19 ++-------- 8 files changed, 108 insertions(+), 34 deletions(-) create mode 100755 scripts/build_electrs.sh rename scripts/{download_bitcoind_electrs.sh => download_bitcoind.sh} (55%) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index cd3980b9af..1cd39ff698 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -29,13 +29,18 @@ jobs: uses: actions/cache@v5 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 f7ded7bc56..466886eb40 100644 --- a/.github/workflows/hrn-integration.yml +++ b/.github/workflows/hrn-integration.yml @@ -27,13 +27,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 410136928a..3764d454b1 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 16064fa45c..af7edf366c 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -59,23 +59,30 @@ jobs: uses: actions/cache@v5 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 c67e9194e1..a788644cd5 100644 --- a/.github/workflows/vss-integration.yml +++ b/.github/workflows/vss-integration.yml @@ -30,6 +30,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 35666df038..5d81c1a44e 100644 --- a/.github/workflows/vss-no-auth-integration.yml +++ b/.github/workflows/vss-no-auth-integration.yml @@ -30,6 +30,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 0000000000..6130ca5085 --- /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`/`BITCOIND_EXE` which will be used by the +# `electrsd`/`bitcoind` 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 55% rename from scripts/download_bitcoind_electrs.sh rename to scripts/download_bitcoind.sh index f94e280e3b..102cf826f3 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 +# Our Esplora-based tests require `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. 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 48f429843656db1145327ba5124bfedd2e9093bd Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 24 Jun 2026 05:48:23 +0000 Subject: [PATCH 11/20] 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 7cbf56b8e1..6c2564b764 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(true); 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 d92271a6a0a635dbcdcfae1d9b2d59705b6a98bf Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 24 Jun 2026 05:48:54 +0000 Subject: [PATCH 12/20] 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 e71fd70fba..5459e8eda7 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 841f1cb21dfce39cb57ba57c51702a69c6445a6b Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 01:28:46 +0000 Subject: [PATCH 13/20] Check that the chain source supports 0FC channels Do this roundtrip at the same time we make a roundtrip to retrieve the feerates to keep startup as fast as possible. --- CHANGELOG.md | 2 ++ bindings/ldk_node.udl | 1 + src/chain/bitcoind.rs | 49 +++++++++++++++++++++++++++++++++++++++++ src/chain/electrum.rs | 25 +++++++++++++++++++++ src/chain/esplora.rs | 10 +++++++++ src/chain/mod.rs | 51 +++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 3 ++- src/error.rs | 5 +++++ src/lib.rs | 23 +++++++++++++++++-- 9 files changed, 166 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f15e61f5..dfb7854675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated. - Users of the VSS storage backend must upgrade their VSS server to at least version `v0.1.0-alpha.0` before upgrading LDK Node. +- Usage of anchor channels now requires an Esplora or Electrum chain source that supports + `submitpackage`, or a Bitcoin Core RPC/REST chain source against Bitcoin Core v29 and above. # 0.7.0 - Dec. 3, 2025 This seventh minor release introduces numerous new features, bug fixes, and API improvements. In particular, it adds support for channel Splicing, Async Payments, as well as sourcing chain data from a Bitcoin Core REST backend. diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 851583c5ad..de62f90da3 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -230,6 +230,7 @@ enum NodeError { "LnurlAuthFailed", "LnurlAuthTimeout", "InvalidLnurl", + "ChainSourceNotSupported", }; typedef dictionary NodeStatus; diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index ce349d74de..c3477da442 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -120,6 +120,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::ChainSourceNotSupported + })?; + + let node_version = node_version_result.map_err(|e| { + log_error!(self.logger, "Failed to get node version: {:?}", e); + Error::ChainSourceNotSupported + })?; + + // 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, @@ -749,6 +773,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 cc2c2a709e..92edef0e5d 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -295,6 +295,31 @@ 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::ChainSourceNotSupported); + }; + + // TODO: Use `protocol_version` API once shipped in + // https://github.com/bitcoindevkit/rust-electrum-client/pull/213 + electrum_client + .electrum_client + .transaction_broadcast_package(&super::dummy_package()) + .map_err(|e| { + log_error!(self.logger, "Electrum server does not support submitpackage: {:?}", e); + Error::ChainSourceNotSupported + })?; + Ok(()) + } + pub(crate) async fn process_transaction_broadcast(&self, txs: TransactionBroadcast) { 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 6c7afdcd12..d88b4be27f 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -77,6 +77,16 @@ impl EsploraChainSource { }) } + pub(super) async fn validate_zero_fee_commitments_support(&self) -> Result<(), Error> { + self.esplora_client.submit_package(&super::dummy_package(), None, None).await.map_err( + |e| { + log_error!(self.logger, "Esplora server does not support submitpackage: {:?}", e); + Error::ChainSourceNotSupported + }, + )?; + 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 66a74b8c37..0fab226afb 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 558a4d0618..0402ebb461 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 'stop gap' parameter used by BDK's wallet sync. This seems to configure the threshold diff --git a/src/error.rs b/src/error.rs index d07212b008..8546af0dd2 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 d20badd94e..e63459d86f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -285,9 +285,28 @@ impl Node { e })?; - // Block to ensure we update our fee rate cache once on startup + let any_current_0fc_channels = + self.chain_monitor.list_monitors().into_iter().any(|channel_id| { + self.chain_monitor + .get_monitor(channel_id) + .map(|monitor| { + monitor.channel_type_features().requires_anchor_zero_fee_commitments() + }) + .unwrap_or(false) + }); + + // Block to ensure we update our fee rate cache once on startup. + // Also take this opportunity to make sure our chain source supports any current or + // future 0FC channels. 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( + any_current_0fc_channels || self.config.anchor_channels_config.is_some() + ) + ) + })?; // Spawn background task continuously syncing onchain, lightning, and fee rate cache. let stop_sync_receiver = self.stop_sender.subscribe(); From 758671bd215e637ac6000cd393c473df176ca082 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 26 Jun 2026 18:02:29 +0000 Subject: [PATCH 14/20] f: classify ChainSourceNotSupported more precisely --- src/chain/bitcoind.rs | 6 +++--- src/chain/electrum.rs | 24 ++++++++++++++++++++---- src/chain/esplora.rs | 19 +++++++++++++++++-- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index c3477da442..523ba7d805 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -128,15 +128,15 @@ impl BitcoindChainSource { .await .map_err(|e| { log_error!(self.logger, "Failed to get node version: {:?}", e); - Error::ChainSourceNotSupported + Error::ConnectionFailed })?; let node_version = node_version_result.map_err(|e| { log_error!(self.logger, "Failed to get node version: {:?}", e); - Error::ChainSourceNotSupported + Error::ConnectionFailed })?; - // v26 first shipped the `submitpackage` RPC, but we need v29 to relay ephemeral dust. + // 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); diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 92edef0e5d..62fbee6a88 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -305,17 +305,33 @@ impl ElectrumChainSource { false, "We should have started the chain source before checking submitpackage support" ); - return Err(Error::ChainSourceNotSupported); + return Err(Error::ConnectionFailed); }; // TODO: Use `protocol_version` API once shipped in - // https://github.com/bitcoindevkit/rust-electrum-client/pull/213 + // 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. electrum_client .electrum_client .transaction_broadcast_package(&super::dummy_package()) .map_err(|e| { - log_error!(self.logger, "Electrum server does not support submitpackage: {:?}", e); - Error::ChainSourceNotSupported + if let electrum_client::Error::AllAttemptsErrored(_) = e { + log_error!( + self.logger, + "Electrum server does not support submitpackage: {:?}", + e + ); + Error::ChainSourceNotSupported + } else { + log_error!( + self.logger, + "Failed to check support for submitpackage on the Electrum server: {}", + e + ); + Error::ConnectionFailed + } })?; Ok(()) } diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index d88b4be27f..cd495ffe98 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -78,10 +78,25 @@ 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| { - log_error!(self.logger, "Esplora server does not support submitpackage: {:?}", e); - Error::ChainSourceNotSupported + 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(()) From dd06c37768f4d865ca7c71e04f3f025acedca9eb Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 25 Jun 2026 16:43:19 +0000 Subject: [PATCH 15/20] 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 | 41 +++++++---------- src/chain/esplora.rs | 105 ++++++++++++++++++++---------------------- 3 files changed, 80 insertions(+), 102 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 523ba7d805..55b5fad2a1 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: TransactionBroadcast) { // 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 62fbee6a88..cfd93cb8f1 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -590,14 +590,23 @@ 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 spawn_fut = - self.runtime.spawn_blocking(move || electrum_client.transaction_broadcast(&tx)); + let spawn_fut = self.runtime.spawn_blocking({ + let tx = tx.clone(); + move || electrum_client.transaction_broadcast(&tx) + }); let timeout_fut = tokio::time::timeout( Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), spawn_fut, @@ -605,31 +614,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]), + Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), }, + Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), } } diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index cd495ffe98..5e79c1ad66 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -381,6 +381,55 @@ impl EsploraChainSource { Ok(()) } + fn log_http_error(&self, e: esplora_client::Error, txids: &[Txid], txs: &TransactionBroadcast) { + 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: &TransactionBroadcast, + ) { + 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: TransactionBroadcast) { for tx in txs.iter() { let txid = tx.compute_txid(); @@ -393,61 +442,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 37b931d13bc0b696e64d8aa6b7fd52d7bb3cef6a Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 26 Jun 2026 18:39:07 +0000 Subject: [PATCH 16/20] f: Arc transactions to be broadcast with electrum This allows the thread that broadcasts and the thread that logs to share ownership of the transaction, and avoids cloning or encoding the transaction unnecessarily. --- src/chain/electrum.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index cfd93cb8f1..8e54233912 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -602,10 +602,11 @@ impl ElectrumRuntimeClient { let electrum_client = Arc::clone(&self.electrum_client); let txid = tx.compute_txid(); + let tx = Arc::new([tx]); let spawn_fut = self.runtime.spawn_blocking({ - let tx = tx.clone(); - move || electrum_client.transaction_broadcast(&tx) + let tx = Arc::clone(&tx); + move || electrum_client.transaction_broadcast(&tx[0]) }); let timeout_fut = tokio::time::timeout( Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), @@ -617,10 +618,10 @@ impl ElectrumRuntimeClient { Ok(Ok(txid)) => { log_trace!(self.logger, "Successfully broadcast transaction {}", txid); }, - Ok(Err(e)) => self.log_broadcast_error(e, &[txid], &[tx]), - Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), + 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]), + Err(e) => self.log_broadcast_error(e, &[txid], tx.as_ref()), } } From 6a3e34d92dc39d1d2e598adf426cd11369026c47 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 25 Jun 2026 16:02:34 +0000 Subject: [PATCH 17/20] 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 | 103 ++++++++++++++++++++++++++++++++++-------- src/chain/electrum.rs | 45 +++++++++++++++++- src/chain/esplora.rs | 61 +++++++++++++++++++------ 3 files changed, 174 insertions(+), 35 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 55b5fad2a1..be9563bf7b 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -605,27 +605,43 @@ impl BitcoindChainSource { } pub(crate) async fn process_transaction_broadcast(&self, txs: TransactionBroadcast) { - // 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.into_inner().pop().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], &[tx]), }, Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), - }, - Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), - } + } + }, + 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 +832,38 @@ impl BitcoindClient { rpc_client.call_method::("sendrawtransaction", &[tx_json]).await } + /// Submits the provided package + pub(crate) async fn submit_package( + &self, package: &[Transaction], + ) -> 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: &[Transaction], + ) -> 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 +1415,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 8e54233912..99bdad675c 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -346,8 +346,10 @@ impl ElectrumChainSource { return; }; - for tx in txs.into_inner() { - electrum_client.broadcast(tx).await; + match txs.len() { + 0 => (), + 1 => electrum_client.broadcast(txs.into_inner().pop().expect("The length is 1")).await, + 2.. => electrum_client.submit_package(txs.into_inner()).await, } } } @@ -625,6 +627,45 @@ impl ElectrumRuntimeClient { } } + async fn submit_package(&self, package: Vec) { + let electrum_client = Arc::clone(&self.electrum_client); + + let txids: Vec<_> = package.iter().map(|tx| tx.compute_txid()).collect(); + + let spawn_fut = self.runtime.spawn_blocking({ + let package = package.clone(); + 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 5e79c1ad66..4a9f9815cb 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -431,21 +431,54 @@ impl EsploraChainSource { } pub(crate) async fn process_transaction_broadcast(&self, txs: TransactionBroadcast) { - 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), + } + }, } } } From 6468d54781dc176802b3acb3ea3581a91267cc97 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 26 Jun 2026 18:39:58 +0000 Subject: [PATCH 18/20] f: Arc packages to be broadcast with electrum This allows the thread that broadcasts and the thread that logs to share ownership of the package, and avoids cloning or encoding the package unnecessarily. --- src/chain/electrum.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 99bdad675c..3f90e6d6c1 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -631,9 +631,10 @@ impl ElectrumRuntimeClient { 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 = package.clone(); + let package = Arc::clone(&package); move || electrum_client.transaction_broadcast_package(&package) }); let timeout_fut = tokio::time::timeout( @@ -656,13 +657,13 @@ impl ElectrumRuntimeClient { result ); } else { - self.log_broadcast_error(format!("{:?}", result), &txids, &package); + self.log_broadcast_error(format!("{:?}", result), &txids, package.as_ref()); } }, - Ok(Err(e)) => self.log_broadcast_error(e, &txids, &package), - Err(e) => self.log_broadcast_error(e, &txids, &package), + Ok(Err(e)) => self.log_broadcast_error(e, &txids, package.as_ref()), + Err(e) => self.log_broadcast_error(e, &txids, package.as_ref()), }, - Err(e) => self.log_broadcast_error(e, &txids, &package), + Err(e) => self.log_broadcast_error(e, &txids, package.as_ref()), } } From a1a616b73b00b0213c0e1f1e25f61dd10463d630 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 29 Oct 2025 07:00:04 +0000 Subject: [PATCH 19/20] Include 0FC channels in anchor channel checks --- src/event.rs | 3 ++- src/lib.rs | 10 ++++++---- src/liquidity/service/lsps2.rs | 4 +++- src/types.rs | 4 +++- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/event.rs b/src/event.rs index 80acd0690e..393a6d8b1a 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1256,7 +1256,8 @@ where } } - let anchor_channel = channel_type.requires_anchors_zero_fee_htlc_tx(); + let anchor_channel = channel_type.requires_anchors_zero_fee_htlc_tx() + || channel_type.requires_anchor_zero_fee_commitments(); if anchor_channel && self.config.anchor_channels_config.is_none() { log_error!( self.logger, diff --git a/src/lib.rs b/src/lib.rs index e63459d86f..5a3f9a8855 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1353,7 +1353,8 @@ 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 = init_features.supports_anchors_zero_fee_htlc_tx() + || init_features.supports_anchor_zero_fee_commitments(); Ok(new_channel_anchor_reserve_sats(&self.config, peer_node_id, anchor_channel)) } @@ -2333,9 +2334,10 @@ pub(crate) fn total_anchor_channels_reserve_sats( !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()) + && c.channel_type.as_ref().map_or(false, |t| { + t.requires_anchors_zero_fee_htlc_tx() + || t.requires_anchor_zero_fee_commitments() + }) }) .count() as u64 * anchor_channels_config.per_channel_reserve_sats diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index 524157a671..5dbb5588e1 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -452,9 +452,11 @@ 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 = init_features.supports_anchors_zero_fee_htlc_tx() + || init_features.supports_anchor_zero_fee_commitments(); 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() + if anchor_channel && !c.trusted_peers_no_reserve.contains(&their_network_key) { c.per_channel_reserve_sats diff --git a/src/types.rs b/src/types.rs index 914b5dc153..ba1885e754 100644 --- a/src/types.rs +++ b/src/types.rs @@ -599,7 +599,9 @@ impl ChannelDetails { value: LdkChannelDetails, anchor_channels_config: Option<&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.supports_anchors_zero_fee_htlc_tx() + || channel_type.supports_anchor_zero_fee_commitments() + { if let Some(config) = anchor_channels_config { if config.trusted_peers_no_reserve.contains(&value.counterparty.node_id) { ReserveType::TrustedPeersNoReserve From 5eab461e8ef700c4f47d23ea5f0cc5c93d77932b Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 13 Oct 2025 13:11:21 +0000 Subject: [PATCH 20/20] Negotiate 0FC channels if the anchor config is set --- src/config.rs | 16 ++++++++++------ tests/common/mod.rs | 7 +++---- tests/integration_tests_rust.rs | 14 +++----------- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src/config.rs b/src/config.rs index 0402ebb461..0b43232080 100644 --- a/src/config.rs +++ b/src/config.rs @@ -171,15 +171,17 @@ 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. /// /// 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. + /// will be negotiated with the `option_zero_fee_commitments` channel type first, then 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, @@ -282,7 +284,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 @@ -404,6 +406,8 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { 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.channel_handshake_config.negotiate_anchor_zero_fee_commitments = + config.anchor_channels_config.is_some(); 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 adeb327bf0..e8865a4fb1 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1413,10 +1413,9 @@ 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 }; + // If we expect an anchor channel, this will be a 0FC channel, so no funds will be + // allocated to the anchor. + let node_a_anchors_msat = 0; 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 289a53e37a..8fbcd90cd4 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1120,17 +1120,12 @@ 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; assert_eq!( node_a.list_balances().total_onchain_balance_sats, premine_amount_sat - 4_000_000 - opening_transaction_fee_sat ); - assert_eq!( - node_a.list_balances().total_lightning_balance_sats, - 4_000_000 - closing_transaction_fee_sat - anchor_output_sat - ); + assert_eq!(node_a.list_balances().total_lightning_balance_sats, 4_000_000); assert_eq!(node_b.list_balances().total_lightning_balance_sats, 0); let address = node_a.onchain_payment().new_address().unwrap(); @@ -1210,10 +1205,7 @@ async fn splice_channel() { // Mine a block to give time for the HTLC to resolve generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; - assert_eq!( - node_a.list_balances().total_lightning_balance_sats, - 4_000_000 - closing_transaction_fee_sat - anchor_output_sat + amount_msat / 1000 - ); + assert_eq!(node_a.list_balances().total_lightning_balance_sats, 4_000_000 + amount_msat / 1000); assert_eq!( node_b.list_balances().total_lightning_balance_sats, expected_splice_in_lightning_balance_sat - amount_msat / 1000 @@ -1247,7 +1239,7 @@ async fn splice_channel() { ); assert_eq!( node_a.list_balances().total_lightning_balance_sats, - 4_000_000 - closing_transaction_fee_sat - anchor_output_sat - expected_splice_out_fee_sat + 4_000_000 - expected_splice_out_fee_sat ); }