From ca4d4c348fefa22779924068c3112d8c5bd62dfd Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 11:05:36 +0200 Subject: [PATCH 001/138] Reject oversized splice-out amounts Validate splice-out requests against outbound capacity after converting the requested satoshi amount to millisatoshis with overflow handling. This prevents values above the spendable channel balance from slipping past the guard due to a unit mismatch. Keep splice integration coverage aligned with the corrected capacity semantics by rejecting an amount one satoshi above outbound capacity and deriving the full-cycle splice-out amount from the channel's current spendable capacity. AI-Assisted-By: OpenAI Codex Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- src/lib.rs | 4 +++- tests/common/mod.rs | 4 +++- tests/integration_tests_rust.rs | 12 ++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7465dfabf5..e4d4ea1c9e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1700,7 +1700,9 @@ impl Node { if let Some(channel_details) = open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) { - if splice_amount_sats > channel_details.outbound_capacity_msat { + let splice_amount_msat = + splice_amount_sats.checked_mul(1_000).ok_or(Error::ChannelSplicingFailed)?; + if splice_amount_msat > channel_details.outbound_capacity_msat { return Err(Error::ChannelSplicingFailed); } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d7775e67b3..adeb327bf0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1364,7 +1364,9 @@ pub(crate) async fn do_channel_full_cycle( println!("\nB splices out to pay A"); let addr_a = node_a.onchain_payment().new_address().unwrap(); - let splice_out_sat = funding_amount_sat / 2; + let available_splice_out_sat = node_b.list_channels()[0].outbound_capacity_msat / 1000; + let splice_out_sat = available_splice_out_sat / 2; + assert!(splice_out_sat > 500_000); node_b.splice_out(&user_channel_id_b, node_a.node_id(), &addr_a, splice_out_sat).unwrap(); expect_splice_negotiated_event!(node_a, node_b.node_id()); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 1ea6c45845..4e901e7e96 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1036,6 +1036,18 @@ async fn splice_channel() { ); assert_eq!(node_b.list_balances().total_lightning_balance_sats, 0); + let address = node_a.onchain_payment().new_address().unwrap(); + let excessive_splice_out_sats = node_a.list_channels()[0].outbound_capacity_msat / 1000 + 1; + assert_eq!( + node_a.splice_out( + &user_channel_id_a, + node_b.node_id(), + &address, + excessive_splice_out_sats + ), + Err(NodeError::ChannelSplicingFailed), + ); + // Test that splicing and payments fail when there are insufficient funds let address = node_b.onchain_payment().new_address().unwrap(); let amount_msat = 400_000_000; From 10ac9331e1ea01a23b22e28e2fe34277bcb28843 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 11:42:23 +0200 Subject: [PATCH 002/138] Prevent late cancellable runtime tasks during shutdown A cancellable task spawned during shutdown could otherwise outlive the shutdown sequence instead of being cancelled with the rest of the cancellable runtime work. Reject late spawns while shutdown is draining tasks and reopen that path when a stopped node starts again. Co-Authored-By: HAL 9000 This finding was discovered by Project Loupe --- src/lib.rs | 2 ++ src/runtime.rs | 91 ++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7465dfabf5..4cd7ec4bb3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -274,6 +274,8 @@ impl Node { self.config.network ); + self.runtime.allow_cancellable_background_task_spawns(); + // Start up any runtime-dependant chain sources (e.g. Electrum) self.chain_source.start(Arc::clone(&self.runtime)).map_err(|e| { log_error!(self.logger, "Failed to start chain syncing: {}", e); diff --git a/src/runtime.rs b/src/runtime.rs index 9673d0eb7a..3f82d704ec 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -22,11 +22,22 @@ use crate::logger::{log_debug, log_error, log_trace, LdkLogger, Logger}; pub(crate) struct Runtime { mode: RuntimeMode, background_tasks: Mutex>, - cancellable_background_tasks: Mutex>, + cancellable_background_tasks: Mutex, background_processor_task: Mutex>>, logger: Arc, } +struct CancellableBackgroundTasks { + tasks: JoinSet<()>, + accepting_tasks: bool, +} + +impl CancellableBackgroundTasks { + fn new() -> Self { + Self { tasks: JoinSet::new(), accepting_tasks: true } + } +} + impl Runtime { pub fn new(logger: Arc) -> Result { let mode = match tokio::runtime::Handle::try_current() { @@ -55,7 +66,7 @@ impl Runtime { }, }; let background_tasks = Mutex::new(JoinSet::new()); - let cancellable_background_tasks = Mutex::new(JoinSet::new()); + let cancellable_background_tasks = Mutex::new(CancellableBackgroundTasks::new()); let background_processor_task = Mutex::new(None); Ok(Self { @@ -70,7 +81,7 @@ impl Runtime { pub fn with_handle(handle: tokio::runtime::Handle, logger: Arc) -> Self { let mode = RuntimeMode::Handle(handle); let background_tasks = Mutex::new(JoinSet::new()); - let cancellable_background_tasks = Mutex::new(JoinSet::new()); + let cancellable_background_tasks = Mutex::new(CancellableBackgroundTasks::new()); let background_processor_task = Mutex::new(None); Self { @@ -100,11 +111,22 @@ impl Runtime { { let mut cancellable_background_tasks = self.cancellable_background_tasks.lock().expect("lock"); + if !cancellable_background_tasks.accepting_tasks { + log_trace!( + self.logger, + "Ignoring cancellable background task spawned during shutdown." + ); + return; + } let runtime_handle = self.handle(); // Since it seems to make a difference to `tokio` (see // https://docs.rs/tokio/latest/tokio/time/fn.timeout.html#panics) we make sure the futures // are always put in an `async` / `.await` closure. - cancellable_background_tasks.spawn_on(async { future.await }, runtime_handle); + cancellable_background_tasks.tasks.spawn_on(async { future.await }, runtime_handle); + } + + pub fn allow_cancellable_background_task_spawns(&self) { + self.cancellable_background_tasks.lock().expect("lock").accepting_tasks = true; } pub fn spawn_background_processor_task(&self, future: F) @@ -142,8 +164,12 @@ impl Runtime { } pub fn abort_cancellable_background_tasks(&self) { - let mut tasks = - core::mem::take(&mut *self.cancellable_background_tasks.lock().expect("lock")); + let mut tasks = { + let mut cancellable_background_tasks = + self.cancellable_background_tasks.lock().expect("lock"); + cancellable_background_tasks.accepting_tasks = false; + core::mem::take(&mut cancellable_background_tasks.tasks) + }; debug_assert!(tasks.len() > 0, "Expected some cancellable background_tasks"); tasks.abort_all(); self.block_on(async { while let Some(_) = tasks.join_next().await {} }) @@ -352,3 +378,56 @@ impl FutureSpawner for RuntimeSpawner { output } } + +#[cfg(test)] +mod tests { + use super::*; + + use tokio::sync::oneshot; + + fn test_runtime() -> Runtime { + Runtime::new(Arc::new(Logger::new_log_facade())).unwrap() + } + + #[test] + fn late_cancellable_spawns_are_not_polled_after_abort() { + let runtime = test_runtime(); + let (started_sender, started_receiver) = oneshot::channel(); + runtime.spawn_cancellable_background_task(async move { + let _ = started_sender.send(()); + std::future::pending::<()>().await; + }); + runtime.block_on(async { + started_receiver.await.expect("initial task should start"); + }); + + runtime.abort_cancellable_background_tasks(); + + let (late_spawn_sender, late_spawn_receiver) = oneshot::channel(); + runtime.spawn_cancellable_background_task(async move { + let _ = late_spawn_sender.send(()); + }); + let late_spawn_was_polled = runtime.block_on(async { + match tokio::time::timeout(Duration::from_secs(1), late_spawn_receiver).await { + Ok(Ok(())) => true, + Ok(Err(_)) | Err(_) => false, + } + }); + + assert!( + !late_spawn_was_polled, + "cancellable task spawned after shutdown started should not be polled" + ); + + runtime.allow_cancellable_background_task_spawns(); + + let (restarted_sender, restarted_receiver) = oneshot::channel(); + runtime.spawn_cancellable_background_task(async move { + let _ = restarted_sender.send(()); + }); + runtime.block_on(async { + restarted_receiver.await.expect("spawn should be allowed after restart"); + }); + runtime.abort_cancellable_background_tasks(); + } +} From 0713f6d88210ac59dab9f28ec73b9227e762b25e Mon Sep 17 00:00:00 2001 From: Camillarhi Date: Mon, 30 Mar 2026 15:07:50 +0100 Subject: [PATCH 003/138] Move `liquidity.rs` to `liquidity/mod.rs` module directory --- src/{liquidity.rs => liquidity/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{liquidity.rs => liquidity/mod.rs} (100%) diff --git a/src/liquidity.rs b/src/liquidity/mod.rs similarity index 100% rename from src/liquidity.rs rename to src/liquidity/mod.rs From 07dbde9c7a70d1919f984102983c1fa81d0d5e8e Mon Sep 17 00:00:00 2001 From: Camillarhi Date: Thu, 16 Apr 2026 05:33:05 +0100 Subject: [PATCH 004/138] Move LSPS1 client logic into `liquidity/client/lsps1.rs` --- src/liquidity/client/lsps1.rs | 348 ++++++++++++++++++++++++++++++++++ src/liquidity/client/mod.rs | 8 + src/liquidity/mod.rs | 328 +------------------------------- 3 files changed, 363 insertions(+), 321 deletions(-) create mode 100644 src/liquidity/client/lsps1.rs create mode 100644 src/liquidity/client/mod.rs diff --git a/src/liquidity/client/lsps1.rs b/src/liquidity/client/lsps1.rs new file mode 100644 index 0000000000..4d4e3a245f --- /dev/null +++ b/src/liquidity/client/lsps1.rs @@ -0,0 +1,348 @@ +// 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::collections::HashMap; +use std::ops::Deref; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bitcoin::secp256k1::PublicKey; +use lightning::ln::msgs::SocketAddress; +use lightning_liquidity::lsps0::ser::LSPSRequestId; +use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfig; +use lightning_liquidity::lsps1::msgs::{ + LSPS1ChannelInfo, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, +}; +use tokio::sync::oneshot; + +use crate::connection::ConnectionManager; +use crate::logger::{log_error, log_info, LdkLogger, Logger}; +use crate::runtime::Runtime; +use crate::types::Wallet; +use crate::Error; + +use super::super::{LiquiditySource, LIQUIDITY_REQUEST_TIMEOUT_SECS}; + +pub(crate) struct LSPS1Client { + pub(crate) lsp_node_id: PublicKey, + pub(crate) lsp_address: SocketAddress, + pub(crate) token: Option, + pub(crate) ldk_client_config: LdkLSPS1ClientConfig, + pub(crate) pending_opening_params_requests: + Mutex>>, + pub(crate) pending_create_order_requests: + Mutex>>, + pub(crate) pending_check_order_status_requests: + Mutex>>, +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS1ClientConfig { + pub node_id: PublicKey, + pub address: SocketAddress, + pub token: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS1OpeningParamsResponse { + pub(crate) supported_options: LSPS1Options, +} + +/// Represents the status of an LSPS1 channel request. +#[derive(Debug, Clone)] +pub struct LSPS1OrderStatus { + /// The id of the channel order. + pub order_id: LSPS1OrderId, + /// The parameters of channel order. + pub order_params: LSPS1OrderParams, + /// Contains details about how to pay for the order. + pub payment_options: LSPS1PaymentInfo, + /// Contains information about the channel state. + pub channel_state: Option, +} + +#[cfg(not(feature = "uniffi"))] +type LSPS1PaymentInfo = lightning_liquidity::lsps1::msgs::LSPS1PaymentInfo; + +#[cfg(feature = "uniffi")] +type LSPS1PaymentInfo = crate::ffi::LSPS1PaymentInfo; + +impl LiquiditySource +where + L::Target: LdkLogger, +{ + pub(crate) fn get_lsps1_lsp_details(&self) -> Option<(PublicKey, SocketAddress)> { + self.lsps1_client.as_ref().map(|s| (s.lsp_node_id, s.lsp_address.clone())) + } + + pub(crate) async fn lsps1_request_opening_params( + &self, + ) -> Result { + let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + + let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { + log_error!(self.logger, "LSPS1 liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let (request_sender, request_receiver) = oneshot::channel(); + { + let mut pending_opening_params_requests_lock = + lsps1_client.pending_opening_params_requests.lock().expect("lock"); + let request_id = client_handler.request_supported_options(lsps1_client.lsp_node_id); + pending_opening_params_requests_lock.insert(request_id, request_sender); + } + + tokio::time::timeout(Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), request_receiver) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request timed out: {}", e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); + Error::LiquidityRequestFailed + }) + } + + pub(crate) async fn lsps1_request_channel( + &self, lsp_balance_sat: u64, client_balance_sat: u64, channel_expiry_blocks: u32, + announce_channel: bool, refund_address: bitcoin::Address, + ) -> Result { + let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { + log_error!(self.logger, "LSPS1 liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let lsp_limits = self.lsps1_request_opening_params().await?.supported_options; + let channel_size_sat = lsp_balance_sat + client_balance_sat; + + if channel_size_sat < lsp_limits.min_channel_balance_sat + || channel_size_sat > lsp_limits.max_channel_balance_sat + { + log_error!( + self.logger, + "Requested channel size of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", + channel_size_sat, + lsp_limits.min_channel_balance_sat, + lsp_limits.max_channel_balance_sat + ); + return Err(Error::LiquidityRequestFailed); + } + + if lsp_balance_sat < lsp_limits.min_initial_lsp_balance_sat + || lsp_balance_sat > lsp_limits.max_initial_lsp_balance_sat + { + log_error!( + self.logger, + "Requested LSP-side balance of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", + lsp_balance_sat, + lsp_limits.min_initial_lsp_balance_sat, + lsp_limits.max_initial_lsp_balance_sat + ); + return Err(Error::LiquidityRequestFailed); + } + + if client_balance_sat < lsp_limits.min_initial_client_balance_sat + || client_balance_sat > lsp_limits.max_initial_client_balance_sat + { + log_error!( + self.logger, + "Requested client-side balance of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", + client_balance_sat, + lsp_limits.min_initial_client_balance_sat, + lsp_limits.max_initial_client_balance_sat + ); + return Err(Error::LiquidityRequestFailed); + } + + let order_params = LSPS1OrderParams { + lsp_balance_sat, + client_balance_sat, + required_channel_confirmations: lsp_limits.min_required_channel_confirmations, + funding_confirms_within_blocks: lsp_limits.min_funding_confirms_within_blocks, + channel_expiry_blocks, + token: lsps1_client.token.clone(), + announce_channel, + }; + + let (request_sender, request_receiver) = oneshot::channel(); + let request_id; + { + let mut pending_create_order_requests_lock = + lsps1_client.pending_create_order_requests.lock().expect("lock"); + request_id = client_handler.create_order( + &lsps1_client.lsp_node_id, + order_params.clone(), + Some(refund_address), + ); + pending_create_order_requests_lock.insert(request_id.clone(), request_sender); + } + + let response = tokio::time::timeout( + Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), + request_receiver, + ) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request with ID {:?} timed out: {}", request_id, e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); + Error::LiquidityRequestFailed + })?; + + if response.order_params != order_params { + log_error!( + self.logger, + "Aborting LSPS1 request as LSP-provided parameters don't match our order. Expected: {:?}, Received: {:?}", order_params, response.order_params + ); + return Err(Error::LiquidityRequestFailed); + } + + Ok(response) + } + + pub(crate) async fn lsps1_check_order_status( + &self, order_id: LSPS1OrderId, + ) -> Result { + let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { + log_error!(self.logger, "LSPS1 liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let (request_sender, request_receiver) = oneshot::channel(); + { + let mut pending_check_order_status_requests_lock = + lsps1_client.pending_check_order_status_requests.lock().expect("lock"); + let request_id = client_handler.check_order_status(&lsps1_client.lsp_node_id, order_id); + pending_check_order_status_requests_lock.insert(request_id, request_sender); + } + + let response = tokio::time::timeout( + Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), + request_receiver, + ) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request timed out: {}", e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); + Error::LiquidityRequestFailed + })?; + + Ok(response) + } +} + +/// A liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. +/// +/// Should be retrieved by calling [`Node::lsps1_liquidity`]. +/// +/// To open [bLIP-52 / LSPS2] JIT channels, please refer to +/// [`Bolt11Payment::receive_via_jit_channel`]. +/// +/// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md +/// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md +/// [`Node::lsps1_liquidity`]: crate::Node::lsps1_liquidity +/// [`Bolt11Payment::receive_via_jit_channel`]: crate::payment::Bolt11Payment::receive_via_jit_channel +#[derive(Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct LSPS1Liquidity { + runtime: Arc, + wallet: Arc, + connection_manager: Arc>>, + liquidity_source: Option>>>, + logger: Arc, +} + +impl LSPS1Liquidity { + pub(crate) fn new( + runtime: Arc, wallet: Arc, + connection_manager: Arc>>, + liquidity_source: Option>>>, logger: Arc, + ) -> Self { + Self { runtime, wallet, connection_manager, liquidity_source, logger } + } +} + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl LSPS1Liquidity { + /// Connects to the configured LSP and places an order for an inbound channel. + /// + /// The channel will be opened after one of the returned payment options has successfully been + /// paid. + pub fn request_channel( + &self, lsp_balance_sat: u64, client_balance_sat: u64, channel_expiry_blocks: u32, + announce_channel: bool, + ) -> Result { + let liquidity_source = + self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + + let (lsp_node_id, lsp_address) = + liquidity_source.get_lsps1_lsp_details().ok_or(Error::LiquiditySourceUnavailable)?; + + let con_node_id = lsp_node_id; + let con_addr = lsp_address.clone(); + let con_cm = Arc::clone(&self.connection_manager); + + // We need to use our main runtime here as a local runtime might not be around to poll + // connection futures going forward. + self.runtime.block_on(async move { + con_cm.connect_peer_if_necessary(con_node_id, con_addr).await + })?; + + log_info!(self.logger, "Connected to LSP {}@{}. ", lsp_node_id, lsp_address); + + let refund_address = self.wallet.get_new_address()?; + + let liquidity_source = Arc::clone(&liquidity_source); + let response = self.runtime.block_on(async move { + liquidity_source + .lsps1_request_channel( + lsp_balance_sat, + client_balance_sat, + channel_expiry_blocks, + announce_channel, + refund_address, + ) + .await + })?; + + Ok(response) + } + + /// Connects to the configured LSP and checks for the status of a previously-placed order. + pub fn check_order_status(&self, order_id: LSPS1OrderId) -> Result { + let liquidity_source = + self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + + let (lsp_node_id, lsp_address) = + liquidity_source.get_lsps1_lsp_details().ok_or(Error::LiquiditySourceUnavailable)?; + + let con_node_id = lsp_node_id; + let con_addr = lsp_address.clone(); + let con_cm = Arc::clone(&self.connection_manager); + + // We need to use our main runtime here as a local runtime might not be around to poll + // connection futures going forward. + self.runtime.block_on(async move { + con_cm.connect_peer_if_necessary(con_node_id, con_addr).await + })?; + + let liquidity_source = Arc::clone(&liquidity_source); + let response = self + .runtime + .block_on(async move { liquidity_source.lsps1_check_order_status(order_id).await })?; + Ok(response) + } +} diff --git a/src/liquidity/client/mod.rs b/src/liquidity/client/mod.rs new file mode 100644 index 0000000000..3342192b22 --- /dev/null +++ b/src/liquidity/client/mod.rs @@ -0,0 +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 lsps1; diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 3cd6d110da..cee6390036 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -7,6 +7,8 @@ //! Objects related to liquidity management. +pub(crate) mod client; + use std::collections::HashMap; use std::ops::Deref; use std::sync::{Arc, Mutex, RwLock, Weak}; @@ -27,9 +29,6 @@ use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfig; use lightning_liquidity::lsps1::event::LSPS1ClientEvent; -use lightning_liquidity::lsps1::msgs::{ - LSPS1ChannelInfo, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, -}; use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; use lightning_liquidity::lsps2::event::{LSPS2ClientEvent, LSPS2ServiceEvent}; use lightning_liquidity::lsps2::msgs::{LSPS2OpeningFeeParams, LSPS2RawOpeningFeeParams}; @@ -40,40 +39,22 @@ use lightning_types::payment::PaymentHash; use tokio::sync::oneshot; use crate::builder::BuildError; -use crate::connection::ConnectionManager; -use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; +use crate::logger::{log_debug, log_error, log_info, LdkLogger}; use crate::payment::store::LSPS2Parameters; use crate::payment::PaymentMetadata; -use crate::runtime::Runtime; use crate::types::{ Broadcaster, ChannelManager, DynStore, KeysManager, LiquidityManager, PeerManager, Wallet, }; use crate::{total_anchor_channels_reserve_sats, Config, Error}; -const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; +pub(crate) use client::lsps1::{LSPS1Client, LSPS1ClientConfig, LSPS1OpeningParamsResponse}; +pub use client::lsps1::{LSPS1Liquidity, LSPS1OrderStatus}; + +pub(crate) const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; -struct LSPS1Client { - lsp_node_id: PublicKey, - lsp_address: SocketAddress, - token: Option, - ldk_client_config: LdkLSPS1ClientConfig, - pending_opening_params_requests: - Mutex>>, - pending_create_order_requests: Mutex>>, - pending_check_order_status_requests: - Mutex>>, -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS1ClientConfig { - pub node_id: PublicKey, - pub address: SocketAddress, - pub token: Option, -} - struct LSPS2Client { lsp_node_id: PublicKey, lsp_address: SocketAddress, @@ -320,10 +301,6 @@ where Arc::clone(&self.liquidity_manager) } - pub(crate) fn get_lsps1_lsp_details(&self) -> Option<(PublicKey, SocketAddress)> { - self.lsps1_client.as_ref().map(|s| (s.lsp_node_id, s.lsp_address.clone())) - } - pub(crate) fn get_lsps2_lsp_details(&self) -> Option<(PublicKey, SocketAddress)> { self.lsps2_client.as_ref().map(|s| (s.lsp_node_id, s.lsp_address.clone())) } @@ -949,170 +926,6 @@ where } } - pub(crate) async fn lsps1_request_opening_params( - &self, - ) -> Result { - let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { - log_error!(self.logger, "LSPS1 liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let (request_sender, request_receiver) = oneshot::channel(); - { - let mut pending_opening_params_requests_lock = - lsps1_client.pending_opening_params_requests.lock().expect("lock"); - let request_id = client_handler.request_supported_options(lsps1_client.lsp_node_id); - pending_opening_params_requests_lock.insert(request_id, request_sender); - } - - tokio::time::timeout(Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), request_receiver) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request timed out: {}", e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); - Error::LiquidityRequestFailed - }) - } - - pub(crate) async fn lsps1_request_channel( - &self, lsp_balance_sat: u64, client_balance_sat: u64, channel_expiry_blocks: u32, - announce_channel: bool, refund_address: bitcoin::Address, - ) -> Result { - let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { - log_error!(self.logger, "LSPS1 liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let lsp_limits = self.lsps1_request_opening_params().await?.supported_options; - let channel_size_sat = lsp_balance_sat + client_balance_sat; - - if channel_size_sat < lsp_limits.min_channel_balance_sat - || channel_size_sat > lsp_limits.max_channel_balance_sat - { - log_error!( - self.logger, - "Requested channel size of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", - channel_size_sat, - lsp_limits.min_channel_balance_sat, - lsp_limits.max_channel_balance_sat - ); - return Err(Error::LiquidityRequestFailed); - } - - if lsp_balance_sat < lsp_limits.min_initial_lsp_balance_sat - || lsp_balance_sat > lsp_limits.max_initial_lsp_balance_sat - { - log_error!( - self.logger, - "Requested LSP-side balance of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", - lsp_balance_sat, - lsp_limits.min_initial_lsp_balance_sat, - lsp_limits.max_initial_lsp_balance_sat - ); - return Err(Error::LiquidityRequestFailed); - } - - if client_balance_sat < lsp_limits.min_initial_client_balance_sat - || client_balance_sat > lsp_limits.max_initial_client_balance_sat - { - log_error!( - self.logger, - "Requested client-side balance of {}sat doesn't meet the LSP-provided limits (min: {}sat, max: {}sat).", - client_balance_sat, - lsp_limits.min_initial_client_balance_sat, - lsp_limits.max_initial_client_balance_sat - ); - return Err(Error::LiquidityRequestFailed); - } - - let order_params = LSPS1OrderParams { - lsp_balance_sat, - client_balance_sat, - required_channel_confirmations: lsp_limits.min_required_channel_confirmations, - funding_confirms_within_blocks: lsp_limits.min_funding_confirms_within_blocks, - channel_expiry_blocks, - token: lsps1_client.token.clone(), - announce_channel, - }; - - let (request_sender, request_receiver) = oneshot::channel(); - let request_id; - { - let mut pending_create_order_requests_lock = - lsps1_client.pending_create_order_requests.lock().expect("lock"); - request_id = client_handler.create_order( - &lsps1_client.lsp_node_id, - order_params.clone(), - Some(refund_address), - ); - pending_create_order_requests_lock.insert(request_id.clone(), request_sender); - } - - let response = tokio::time::timeout( - Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), - request_receiver, - ) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request with ID {:?} timed out: {}", request_id, e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); - Error::LiquidityRequestFailed - })?; - - if response.order_params != order_params { - log_error!( - self.logger, - "Aborting LSPS1 request as LSP-provided parameters don't match our order. Expected: {:?}, Received: {:?}", order_params, response.order_params - ); - return Err(Error::LiquidityRequestFailed); - } - - Ok(response) - } - - pub(crate) async fn lsps1_check_order_status( - &self, order_id: LSPS1OrderId, - ) -> Result { - let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { - log_error!(self.logger, "LSPS1 liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let (request_sender, request_receiver) = oneshot::channel(); - { - let mut pending_check_order_status_requests_lock = - lsps1_client.pending_check_order_status_requests.lock().expect("lock"); - let request_id = client_handler.check_order_status(&lsps1_client.lsp_node_id, order_id); - pending_check_order_status_requests_lock.insert(request_id, request_sender); - } - - let response = tokio::time::timeout( - Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), - request_receiver, - ) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request timed out: {}", e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); - Error::LiquidityRequestFailed - })?; - - Ok(response) - } - pub(crate) async fn lsps2_receive_to_jit_channel( &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, payment_hash: Option, @@ -1461,30 +1274,6 @@ where } } -#[derive(Debug, Clone)] -pub(crate) struct LSPS1OpeningParamsResponse { - supported_options: LSPS1Options, -} - -/// Represents the status of an LSPS1 channel request. -#[derive(Debug, Clone)] -pub struct LSPS1OrderStatus { - /// The id of the channel order. - pub order_id: LSPS1OrderId, - /// The parameters of channel order. - pub order_params: LSPS1OrderParams, - /// Contains details about how to pay for the order. - pub payment_options: LSPS1PaymentInfo, - /// Contains information about the channel state. - pub channel_state: Option, -} - -#[cfg(not(feature = "uniffi"))] -type LSPS1PaymentInfo = lightning_liquidity::lsps1::msgs::LSPS1PaymentInfo; - -#[cfg(feature = "uniffi")] -type LSPS1PaymentInfo = crate::ffi::LSPS1PaymentInfo; - #[derive(Debug, Clone)] pub(crate) struct LSPS2FeeResponse { opening_fee_params_menu: Vec, @@ -1495,106 +1284,3 @@ pub(crate) struct LSPS2BuyResponse { intercept_scid: u64, cltv_expiry_delta: u32, } - -/// A liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. -/// -/// Should be retrieved by calling [`Node::lsps1_liquidity`]. -/// -/// To open [bLIP-52 / LSPS2] JIT channels, please refer to -/// [`Bolt11Payment::receive_via_jit_channel`]. -/// -/// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md -/// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md -/// [`Node::lsps1_liquidity`]: crate::Node::lsps1_liquidity -/// [`Bolt11Payment::receive_via_jit_channel`]: crate::payment::Bolt11Payment::receive_via_jit_channel -#[derive(Clone)] -#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] -pub struct LSPS1Liquidity { - runtime: Arc, - wallet: Arc, - connection_manager: Arc>>, - liquidity_source: Option>>>, - logger: Arc, -} - -impl LSPS1Liquidity { - pub(crate) fn new( - runtime: Arc, wallet: Arc, - connection_manager: Arc>>, - liquidity_source: Option>>>, logger: Arc, - ) -> Self { - Self { runtime, wallet, connection_manager, liquidity_source, logger } - } -} - -#[cfg_attr(feature = "uniffi", uniffi::export)] -impl LSPS1Liquidity { - /// Connects to the configured LSP and places an order for an inbound channel. - /// - /// The channel will be opened after one of the returned payment options has successfully been - /// paid. - pub fn request_channel( - &self, lsp_balance_sat: u64, client_balance_sat: u64, channel_expiry_blocks: u32, - announce_channel: bool, - ) -> Result { - let liquidity_source = - self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let (lsp_node_id, lsp_address) = - liquidity_source.get_lsps1_lsp_details().ok_or(Error::LiquiditySourceUnavailable)?; - - let con_node_id = lsp_node_id; - let con_addr = lsp_address.clone(); - let con_cm = Arc::clone(&self.connection_manager); - - // We need to use our main runtime here as a local runtime might not be around to poll - // connection futures going forward. - self.runtime.block_on(async move { - con_cm.connect_peer_if_necessary(con_node_id, con_addr).await - })?; - - log_info!(self.logger, "Connected to LSP {}@{}. ", lsp_node_id, lsp_address); - - let refund_address = self.wallet.get_new_address()?; - - let liquidity_source = Arc::clone(&liquidity_source); - let response = self.runtime.block_on(async move { - liquidity_source - .lsps1_request_channel( - lsp_balance_sat, - client_balance_sat, - channel_expiry_blocks, - announce_channel, - refund_address, - ) - .await - })?; - - Ok(response) - } - - /// Connects to the configured LSP and checks for the status of a previously-placed order. - pub fn check_order_status(&self, order_id: LSPS1OrderId) -> Result { - let liquidity_source = - self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let (lsp_node_id, lsp_address) = - liquidity_source.get_lsps1_lsp_details().ok_or(Error::LiquiditySourceUnavailable)?; - - let con_node_id = lsp_node_id; - let con_addr = lsp_address.clone(); - let con_cm = Arc::clone(&self.connection_manager); - - // We need to use our main runtime here as a local runtime might not be around to poll - // connection futures going forward. - self.runtime.block_on(async move { - con_cm.connect_peer_if_necessary(con_node_id, con_addr).await - })?; - - let liquidity_source = Arc::clone(&liquidity_source); - let response = self - .runtime - .block_on(async move { liquidity_source.lsps1_check_order_status(order_id).await })?; - Ok(response) - } -} From 7890b78321305cf6092980cddc675c4062f1142d Mon Sep 17 00:00:00 2001 From: Camillarhi Date: Wed, 29 Apr 2026 15:36:43 +0100 Subject: [PATCH 005/138] Move LSPS1 client event handling into `liquidity/client/lsps1.rs` --- src/liquidity/client/lsps1.rs | 162 ++++++++++++++++++++++++++++++++ src/liquidity/mod.rs | 171 +--------------------------------- 2 files changed, 166 insertions(+), 167 deletions(-) diff --git a/src/liquidity/client/lsps1.rs b/src/liquidity/client/lsps1.rs index 4d4e3a245f..edd344f501 100644 --- a/src/liquidity/client/lsps1.rs +++ b/src/liquidity/client/lsps1.rs @@ -14,6 +14,7 @@ use bitcoin::secp256k1::PublicKey; use lightning::ln::msgs::SocketAddress; use lightning_liquidity::lsps0::ser::LSPSRequestId; use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfig; +use lightning_liquidity::lsps1::event::LSPS1ClientEvent; use lightning_liquidity::lsps1::msgs::{ LSPS1ChannelInfo, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, }; @@ -40,6 +41,167 @@ pub(crate) struct LSPS1Client { Mutex>>, } +impl LSPS1Client { + pub(crate) async fn handle_event(&self, event: LSPS1ClientEvent, logger: &L) + where + L::Target: LdkLogger, + { + match event { + LSPS1ClientEvent::SupportedOptionsReady { + request_id, + counterparty_node_id, + supported_options, + } => { + if counterparty_node_id != self.lsp_node_id { + debug_assert!( + false, + "Received response from unexpected LSP counterparty. This should never happen." + ); + log_error!( + logger, + "Received response from unexpected LSP counterparty. This should never happen." + ); + return; + } + + if let Some(sender) = + self.pending_opening_params_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS1OpeningParamsResponse { supported_options }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + logger, + "Received response from liquidity service for unknown request." + ); + } + }, + LSPS1ClientEvent::OrderCreated { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + } => { + if counterparty_node_id != self.lsp_node_id { + debug_assert!( + false, + "Received response from unexpected LSP counterparty. This should never happen." + ); + log_error!( + logger, + "Received response from unexpected LSP counterparty. This should never happen." + ); + return; + } + + if let Some(sender) = + self.pending_create_order_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS1OrderStatus { + order_id, + order_params: order, + payment_options: payment.into(), + channel_state: channel, + }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + logger, + "Received response from liquidity service for unknown request." + ); + } + }, + LSPS1ClientEvent::OrderStatus { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + } => { + if counterparty_node_id != self.lsp_node_id { + debug_assert!( + false, + "Received response from unexpected LSP counterparty. This should never happen." + ); + log_error!( + logger, + "Received response from unexpected LSP counterparty. This should never happen." + ); + return; + } + + if let Some(sender) = self + .pending_check_order_status_requests + .lock() + .expect("lock") + .remove(&request_id) + { + let response = LSPS1OrderStatus { + order_id, + order_params: order, + payment_options: payment.into(), + channel_state: channel, + }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + logger, + "Received response from liquidity service for unknown request." + ); + } + }, + _ => { + log_error!(logger, "Received unexpected LSPS1Client liquidity event!"); + }, + } + } +} + #[derive(Debug, Clone)] pub(crate) struct LSPS1ClientConfig { pub node_id: PublicKey, diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index cee6390036..4b7e7c4d30 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -28,7 +28,6 @@ use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::{LSPSDateTime, LSPSRequestId}; use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfig; -use lightning_liquidity::lsps1::event::LSPS1ClientEvent; use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; use lightning_liquidity::lsps2::event::{LSPS2ClientEvent, LSPS2ServiceEvent}; use lightning_liquidity::lsps2::msgs::{LSPS2OpeningFeeParams, LSPS2RawOpeningFeeParams}; @@ -47,7 +46,7 @@ use crate::types::{ }; use crate::{total_anchor_channels_reserve_sats, Config, Error}; -pub(crate) use client::lsps1::{LSPS1Client, LSPS1ClientConfig, LSPS1OpeningParamsResponse}; +pub(crate) use client::lsps1::{LSPS1Client, LSPS1ClientConfig}; pub use client::lsps1::{LSPS1Liquidity, LSPS1OrderStatus}; pub(crate) const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; @@ -374,173 +373,11 @@ where pub(crate) async fn handle_next_event(&self) { match self.liquidity_manager.next_event_async().await { - LiquidityEvent::LSPS1Client(LSPS1ClientEvent::SupportedOptionsReady { - request_id, - counterparty_node_id, - supported_options, - }) => { - if let Some(lsps1_client) = self.lsps1_client.as_ref() { - if counterparty_node_id != lsps1_client.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - self.logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = lsps1_client - .pending_opening_params_requests - .lock() - .expect("lock") - .remove(&request_id) - { - let response = LSPS1OpeningParamsResponse { supported_options }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } - } else { - log_error!( - self.logger, - "Received unexpected LSPS1Client::SupportedOptionsReady event!" - ); - } - }, - LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderCreated { - request_id, - counterparty_node_id, - order_id, - order, - payment, - channel, - }) => { + LiquidityEvent::LSPS1Client(event) => { if let Some(lsps1_client) = self.lsps1_client.as_ref() { - if counterparty_node_id != lsps1_client.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - self.logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = lsps1_client - .pending_create_order_requests - .lock() - .expect("lock") - .remove(&request_id) - { - let response = LSPS1OrderStatus { - order_id, - order_params: order, - payment_options: payment.into(), - channel_state: channel, - }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } - } else { - log_error!(self.logger, "Received unexpected LSPS1Client::OrderCreated event!"); - } - }, - LiquidityEvent::LSPS1Client(LSPS1ClientEvent::OrderStatus { - request_id, - counterparty_node_id, - order_id, - order, - payment, - channel, - }) => { - if let Some(lsps1_client) = self.lsps1_client.as_ref() { - if counterparty_node_id != lsps1_client.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - self.logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = lsps1_client - .pending_check_order_status_requests - .lock() - .expect("lock") - .remove(&request_id) - { - let response = LSPS1OrderStatus { - order_id, - order_params: order, - payment_options: payment.into(), - channel_state: channel, - }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } + lsps1_client.handle_event(event, &self.logger).await; } else { - log_error!(self.logger, "Received unexpected LSPS1Client::OrderStatus event!"); + log_error!(self.logger, "Received unexpected LSPS1Client event!"); } }, LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::GetInfo { From cab8892abebfc4553f9f32d97277819f9024de3e Mon Sep 17 00:00:00 2001 From: Camillarhi Date: Thu, 16 Apr 2026 05:39:23 +0100 Subject: [PATCH 006/138] Move LSPS2 client logic into `liquidity/client/lsps2.rs` --- src/liquidity/client/lsps2.rs | 346 ++++++++++++++++++++++++++++++++++ src/liquidity/client/mod.rs | 1 + src/liquidity/mod.rs | 330 +------------------------------- 3 files changed, 356 insertions(+), 321 deletions(-) create mode 100644 src/liquidity/client/lsps2.rs diff --git a/src/liquidity/client/lsps2.rs b/src/liquidity/client/lsps2.rs new file mode 100644 index 0000000000..3de6e6631b --- /dev/null +++ b/src/liquidity/client/lsps2.rs @@ -0,0 +1,346 @@ +// 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::collections::HashMap; +use std::ops::Deref; +use std::sync::Mutex; +use std::time::Duration; + +use bitcoin::secp256k1::{PublicKey, Secp256k1}; +use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; +use lightning::ln::msgs::SocketAddress; +use lightning::routing::router::{RouteHint, RouteHintHop}; +use lightning::util::ser::Writeable; +use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, RoutingFees}; +use lightning_liquidity::lsps0::ser::LSPSRequestId; +use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; +use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; +use lightning_liquidity::lsps2::utils::compute_opening_fee; +use lightning_types::payment::PaymentHash; +use tokio::sync::oneshot; + +use crate::logger::{log_debug, log_error, log_info, LdkLogger}; +use crate::payment::store::LSPS2Parameters; +use crate::payment::PaymentMetadata; +use crate::Error; + +use super::super::{LiquiditySource, LIQUIDITY_REQUEST_TIMEOUT_SECS}; + +pub(crate) struct LSPS2Client { + pub(crate) lsp_node_id: PublicKey, + pub(crate) lsp_address: SocketAddress, + pub(crate) token: Option, + pub(crate) ldk_client_config: LdkLSPS2ClientConfig, + pub(crate) pending_fee_requests: + Mutex>>, + pub(crate) pending_buy_requests: + Mutex>>, +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS2ClientConfig { + pub node_id: PublicKey, + pub address: SocketAddress, + pub token: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS2FeeResponse { + pub(crate) opening_fee_params_menu: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS2BuyResponse { + pub(crate) intercept_scid: u64, + pub(crate) cltv_expiry_delta: u32, +} + +impl LiquiditySource +where + L::Target: LdkLogger, +{ + pub(crate) fn get_lsps2_lsp_details(&self) -> Option<(PublicKey, SocketAddress)> { + self.lsps2_client.as_ref().map(|s| (s.lsp_node_id, s.lsp_address.clone())) + } + + pub(crate) async fn lsps2_receive_to_jit_channel( + &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, + max_total_lsp_fee_limit_msat: Option, payment_hash: Option, + ) -> Result { + let fee_response = self.lsps2_request_opening_fee_params().await?; + + let (min_total_fee_msat, min_opening_params) = fee_response + .opening_fee_params_menu + .into_iter() + .filter_map(|params| { + if amount_msat < params.min_payment_size_msat + || amount_msat > params.max_payment_size_msat + { + log_debug!(self.logger, + "Skipping LSP-offered JIT parameters as the payment of {}msat doesn't meet LSP limits (min: {}msat, max: {}msat)", + amount_msat, + params.min_payment_size_msat, + params.max_payment_size_msat + ); + None + } else { + compute_opening_fee(amount_msat, params.min_fee_msat, params.proportional as u64) + .map(|fee| (fee, params)) + } + }) + .min_by_key(|p| p.0) + .ok_or_else(|| { + log_error!(self.logger, "Failed to handle response from liquidity service",); + Error::LiquidityRequestFailed + })?; + + if let Some(max_total_lsp_fee_limit_msat) = max_total_lsp_fee_limit_msat { + if min_total_fee_msat > max_total_lsp_fee_limit_msat { + log_error!(self.logger, + "Failed to request inbound JIT channel as LSP's requested total opening fee of {}msat exceeds our fee limit of {}msat", + min_total_fee_msat, max_total_lsp_fee_limit_msat + ); + return Err(Error::LiquidityFeeTooHigh); + } + } + + log_debug!( + self.logger, + "Choosing cheapest liquidity offer, will pay {}msat in total LSP fees", + min_total_fee_msat + ); + + let buy_response = + self.lsps2_send_buy_request(Some(amount_msat), min_opening_params).await?; + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: Some(min_total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }; + let invoice = self.lsps2_create_jit_invoice( + buy_response, + Some(amount_msat), + description, + expiry_secs, + payment_hash, + lsps2_parameters, + )?; + + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + Ok(invoice) + } + + pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( + &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, + max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, + ) -> Result { + let fee_response = self.lsps2_request_opening_fee_params().await?; + + let (min_prop_fee_ppm_msat, min_opening_params) = fee_response + .opening_fee_params_menu + .into_iter() + .map(|params| (params.proportional as u64, params)) + .min_by_key(|p| p.0) + .ok_or_else(|| { + log_error!(self.logger, "Failed to handle response from liquidity service",); + Error::LiquidityRequestFailed + })?; + + if let Some(max_proportional_lsp_fee_limit_ppm_msat) = + max_proportional_lsp_fee_limit_ppm_msat + { + if min_prop_fee_ppm_msat > max_proportional_lsp_fee_limit_ppm_msat { + log_error!(self.logger, + "Failed to request inbound JIT channel as LSP's requested proportional opening fee of {} ppm msat exceeds our fee limit of {} ppm msat", + min_prop_fee_ppm_msat, + max_proportional_lsp_fee_limit_ppm_msat + ); + return Err(Error::LiquidityFeeTooHigh); + } + } + + log_debug!( + self.logger, + "Choosing cheapest liquidity offer, will pay {}ppm msat in proportional LSP fees", + min_prop_fee_ppm_msat + ); + + let buy_response = self.lsps2_send_buy_request(None, min_opening_params).await?; + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: None, + max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), + }; + let invoice = self.lsps2_create_jit_invoice( + buy_response, + None, + description, + expiry_secs, + payment_hash, + lsps2_parameters, + )?; + + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + Ok(invoice) + } + + async fn lsps2_request_opening_fee_params(&self) -> Result { + let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + + let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { + log_error!(self.logger, "Liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let (fee_request_sender, fee_request_receiver) = oneshot::channel(); + { + let mut pending_fee_requests_lock = + lsps2_client.pending_fee_requests.lock().expect("lock"); + let request_id = client_handler + .request_opening_params(lsps2_client.lsp_node_id, lsps2_client.token.clone()); + pending_fee_requests_lock.insert(request_id, fee_request_sender); + } + + tokio::time::timeout( + Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), + fee_request_receiver, + ) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request timed out: {}", e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); + Error::LiquidityRequestFailed + }) + } + + async fn lsps2_send_buy_request( + &self, amount_msat: Option, opening_fee_params: LSPS2OpeningFeeParams, + ) -> Result { + let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + + let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { + log_error!(self.logger, "Liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let (buy_request_sender, buy_request_receiver) = oneshot::channel(); + { + let mut pending_buy_requests_lock = + lsps2_client.pending_buy_requests.lock().expect("lock"); + let request_id = client_handler + .select_opening_params(lsps2_client.lsp_node_id, amount_msat, opening_fee_params) + .map_err(|e| { + log_error!( + self.logger, + "Failed to send buy request to liquidity service: {:?}", + e + ); + Error::LiquidityRequestFailed + })?; + pending_buy_requests_lock.insert(request_id, buy_request_sender); + } + + let buy_response = tokio::time::timeout( + Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), + buy_request_receiver, + ) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request timed out: {}", e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {:?}", e); + Error::LiquidityRequestFailed + })?; + + Ok(buy_response) + } + + fn lsps2_create_jit_invoice( + &self, buy_response: LSPS2BuyResponse, amount_msat: Option, + description: &Bolt11InvoiceDescription, expiry_secs: u32, + payment_hash: Option, lsps2_parameters: LSPS2Parameters, + ) -> Result { + let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + + // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. + let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; + let encoded_payment_metadata = + PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }.encode(); + let (payment_hash, payment_secret, payment_metadata) = match payment_hash { + Some(payment_hash) => { + let (payment_secret, payment_metadata) = self + .channel_manager + .create_inbound_payment_for_hash( + payment_hash, + None, + expiry_secs, + Some(min_final_cltv_expiry_delta), + Some(encoded_payment_metadata), + ) + .map_err(|e| { + log_error!(self.logger, "Failed to register inbound payment: {:?}", e); + Error::InvoiceCreationFailed + })?; + (payment_hash, payment_secret, payment_metadata) + }, + None => self + .channel_manager + .create_inbound_payment( + None, + expiry_secs, + Some(min_final_cltv_expiry_delta), + Some(encoded_payment_metadata), + ) + .map_err(|e| { + log_error!(self.logger, "Failed to register inbound payment: {:?}", e); + Error::InvoiceCreationFailed + })?, + }; + + let route_hint = RouteHint(vec![RouteHintHop { + src_node_id: lsps2_client.lsp_node_id, + short_channel_id: buy_response.intercept_scid, + fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, + cltv_expiry_delta: buy_response.cltv_expiry_delta as u16, + htlc_minimum_msat: None, + htlc_maximum_msat: None, + }]); + + let currency = self.config.network.into(); + let mut invoice_builder = InvoiceBuilder::new(currency) + .invoice_description(description.clone()) + .payment_hash(payment_hash) + .payment_secret(payment_secret) + .current_timestamp() + .min_final_cltv_expiry_delta(min_final_cltv_expiry_delta.into()) + .expiry_time(Duration::from_secs(expiry_secs.into())) + .private_route(route_hint); + + if let Some(amount_msat) = amount_msat { + invoice_builder = invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); + } + + let invoice = if let Some(payment_metadata) = payment_metadata { + invoice_builder.payment_metadata(payment_metadata).build_signed(|hash| { + Secp256k1::new() + .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) + }) + } else { + invoice_builder.build_signed(|hash| { + Secp256k1::new() + .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) + }) + }; + invoice.map_err(|e| { + log_error!(self.logger, "Failed to build and sign invoice: {}", e); + Error::InvoiceCreationFailed + }) + } +} diff --git a/src/liquidity/client/mod.rs b/src/liquidity/client/mod.rs index 3342192b22..2a236d492b 100644 --- a/src/liquidity/client/mod.rs +++ b/src/liquidity/client/mod.rs @@ -6,3 +6,4 @@ // accordance with one or both of these licenses. pub(crate) mod lsps1; +pub(crate) mod lsps2; diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 4b7e7c4d30..ba29f656ab 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -14,62 +14,42 @@ use std::ops::Deref; use std::sync::{Arc, Mutex, RwLock, Weak}; use std::time::Duration; -use bitcoin::secp256k1::{PublicKey, Secp256k1}; +use bitcoin::secp256k1::PublicKey; use bitcoin::Transaction; use chrono::Utc; use lightning::events::HTLCHandlingFailureType; -use lightning::ln::channelmanager::{InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; +use lightning::ln::channelmanager::InterceptId; use lightning::ln::msgs::SocketAddress; use lightning::ln::types::ChannelId; -use lightning::routing::router::{RouteHint, RouteHintHop}; use lightning::sign::EntropySource; -use lightning::util::ser::Writeable; -use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, RoutingFees}; use lightning_liquidity::events::LiquidityEvent; -use lightning_liquidity::lsps0::ser::{LSPSDateTime, LSPSRequestId}; +use lightning_liquidity::lsps0::ser::LSPSDateTime; use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfig; use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; use lightning_liquidity::lsps2::event::{LSPS2ClientEvent, LSPS2ServiceEvent}; -use lightning_liquidity::lsps2::msgs::{LSPS2OpeningFeeParams, LSPS2RawOpeningFeeParams}; +use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; -use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; use lightning_types::payment::PaymentHash; -use tokio::sync::oneshot; use crate::builder::BuildError; -use crate::logger::{log_debug, log_error, log_info, LdkLogger}; -use crate::payment::store::LSPS2Parameters; -use crate::payment::PaymentMetadata; +use crate::logger::{log_error, LdkLogger}; use crate::types::{ Broadcaster, ChannelManager, DynStore, KeysManager, LiquidityManager, PeerManager, Wallet, }; -use crate::{total_anchor_channels_reserve_sats, Config, Error}; +use crate::{total_anchor_channels_reserve_sats, Config}; pub(crate) use client::lsps1::{LSPS1Client, LSPS1ClientConfig}; pub use client::lsps1::{LSPS1Liquidity, LSPS1OrderStatus}; +pub(crate) use client::lsps2::{ + LSPS2BuyResponse, LSPS2Client, LSPS2ClientConfig, LSPS2FeeResponse, +}; pub(crate) const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; -struct LSPS2Client { - lsp_node_id: PublicKey, - lsp_address: SocketAddress, - token: Option, - ldk_client_config: LdkLSPS2ClientConfig, - pending_fee_requests: Mutex>>, - pending_buy_requests: Mutex>>, -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS2ClientConfig { - pub node_id: PublicKey, - pub address: SocketAddress, - pub token: Option, -} - struct LSPS2Service { service_config: LSPS2ServiceConfig, ldk_service_config: LdkLSPS2ServiceConfig, @@ -300,10 +280,6 @@ where Arc::clone(&self.liquidity_manager) } - pub(crate) fn get_lsps2_lsp_details(&self) -> Option<(PublicKey, SocketAddress)> { - self.lsps2_client.as_ref().map(|s| (s.lsp_node_id, s.lsp_address.clone())) - } - pub(crate) fn lsps2_channel_needs_manual_broadcast( &self, counterparty_node_id: PublicKey, user_channel_id: u128, ) -> bool { @@ -763,283 +739,6 @@ where } } - pub(crate) async fn lsps2_receive_to_jit_channel( - &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_total_lsp_fee_limit_msat: Option, payment_hash: Option, - ) -> Result { - let fee_response = self.lsps2_request_opening_fee_params().await?; - - let (min_total_fee_msat, min_opening_params) = fee_response - .opening_fee_params_menu - .into_iter() - .filter_map(|params| { - if amount_msat < params.min_payment_size_msat - || amount_msat > params.max_payment_size_msat - { - log_debug!(self.logger, - "Skipping LSP-offered JIT parameters as the payment of {}msat doesn't meet LSP limits (min: {}msat, max: {}msat)", - amount_msat, - params.min_payment_size_msat, - params.max_payment_size_msat - ); - None - } else { - compute_opening_fee(amount_msat, params.min_fee_msat, params.proportional as u64) - .map(|fee| (fee, params)) - } - }) - .min_by_key(|p| p.0) - .ok_or_else(|| { - log_error!(self.logger, "Failed to handle response from liquidity service",); - Error::LiquidityRequestFailed - })?; - - if let Some(max_total_lsp_fee_limit_msat) = max_total_lsp_fee_limit_msat { - if min_total_fee_msat > max_total_lsp_fee_limit_msat { - log_error!(self.logger, - "Failed to request inbound JIT channel as LSP's requested total opening fee of {}msat exceeds our fee limit of {}msat", - min_total_fee_msat, max_total_lsp_fee_limit_msat - ); - return Err(Error::LiquidityFeeTooHigh); - } - } - - log_debug!( - self.logger, - "Choosing cheapest liquidity offer, will pay {}msat in total LSP fees", - min_total_fee_msat - ); - - let buy_response = - self.lsps2_send_buy_request(Some(amount_msat), min_opening_params).await?; - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: Some(min_total_fee_msat), - max_proportional_opening_fee_ppm_msat: None, - }; - let invoice = self.lsps2_create_jit_invoice( - buy_response, - Some(amount_msat), - description, - expiry_secs, - payment_hash, - lsps2_parameters, - )?; - - log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok(invoice) - } - - pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( - &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, - ) -> Result { - let fee_response = self.lsps2_request_opening_fee_params().await?; - - let (min_prop_fee_ppm_msat, min_opening_params) = fee_response - .opening_fee_params_menu - .into_iter() - .map(|params| (params.proportional as u64, params)) - .min_by_key(|p| p.0) - .ok_or_else(|| { - log_error!(self.logger, "Failed to handle response from liquidity service",); - Error::LiquidityRequestFailed - })?; - - if let Some(max_proportional_lsp_fee_limit_ppm_msat) = - max_proportional_lsp_fee_limit_ppm_msat - { - if min_prop_fee_ppm_msat > max_proportional_lsp_fee_limit_ppm_msat { - log_error!(self.logger, - "Failed to request inbound JIT channel as LSP's requested proportional opening fee of {} ppm msat exceeds our fee limit of {} ppm msat", - min_prop_fee_ppm_msat, - max_proportional_lsp_fee_limit_ppm_msat - ); - return Err(Error::LiquidityFeeTooHigh); - } - } - - log_debug!( - self.logger, - "Choosing cheapest liquidity offer, will pay {}ppm msat in proportional LSP fees", - min_prop_fee_ppm_msat - ); - - let buy_response = self.lsps2_send_buy_request(None, min_opening_params).await?; - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: None, - max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), - }; - let invoice = self.lsps2_create_jit_invoice( - buy_response, - None, - description, - expiry_secs, - payment_hash, - lsps2_parameters, - )?; - - log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok(invoice) - } - - async fn lsps2_request_opening_fee_params(&self) -> Result { - let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { - log_error!(self.logger, "Liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let (fee_request_sender, fee_request_receiver) = oneshot::channel(); - { - let mut pending_fee_requests_lock = - lsps2_client.pending_fee_requests.lock().expect("lock"); - let request_id = client_handler - .request_opening_params(lsps2_client.lsp_node_id, lsps2_client.token.clone()); - pending_fee_requests_lock.insert(request_id, fee_request_sender); - } - - tokio::time::timeout( - Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), - fee_request_receiver, - ) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request timed out: {}", e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); - Error::LiquidityRequestFailed - }) - } - - async fn lsps2_send_buy_request( - &self, amount_msat: Option, opening_fee_params: LSPS2OpeningFeeParams, - ) -> Result { - let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { - log_error!(self.logger, "Liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let (buy_request_sender, buy_request_receiver) = oneshot::channel(); - { - let mut pending_buy_requests_lock = - lsps2_client.pending_buy_requests.lock().expect("lock"); - let request_id = client_handler - .select_opening_params(lsps2_client.lsp_node_id, amount_msat, opening_fee_params) - .map_err(|e| { - log_error!( - self.logger, - "Failed to send buy request to liquidity service: {:?}", - e - ); - Error::LiquidityRequestFailed - })?; - pending_buy_requests_lock.insert(request_id, buy_request_sender); - } - - let buy_response = tokio::time::timeout( - Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), - buy_request_receiver, - ) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request timed out: {}", e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {:?}", e); - Error::LiquidityRequestFailed - })?; - - Ok(buy_response) - } - - fn lsps2_create_jit_invoice( - &self, buy_response: LSPS2BuyResponse, amount_msat: Option, - description: &Bolt11InvoiceDescription, expiry_secs: u32, - payment_hash: Option, lsps2_parameters: LSPS2Parameters, - ) -> Result { - let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. - let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; - let encoded_payment_metadata = - PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }.encode(); - let (payment_hash, payment_secret, payment_metadata) = match payment_hash { - Some(payment_hash) => { - let (payment_secret, payment_metadata) = self - .channel_manager - .create_inbound_payment_for_hash( - payment_hash, - None, - expiry_secs, - Some(min_final_cltv_expiry_delta), - Some(encoded_payment_metadata), - ) - .map_err(|e| { - log_error!(self.logger, "Failed to register inbound payment: {:?}", e); - Error::InvoiceCreationFailed - })?; - (payment_hash, payment_secret, payment_metadata) - }, - None => self - .channel_manager - .create_inbound_payment( - None, - expiry_secs, - Some(min_final_cltv_expiry_delta), - Some(encoded_payment_metadata), - ) - .map_err(|e| { - log_error!(self.logger, "Failed to register inbound payment: {:?}", e); - Error::InvoiceCreationFailed - })?, - }; - - let route_hint = RouteHint(vec![RouteHintHop { - src_node_id: lsps2_client.lsp_node_id, - short_channel_id: buy_response.intercept_scid, - fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, - cltv_expiry_delta: buy_response.cltv_expiry_delta as u16, - htlc_minimum_msat: None, - htlc_maximum_msat: None, - }]); - - let currency = self.config.network.into(); - let mut invoice_builder = InvoiceBuilder::new(currency) - .invoice_description(description.clone()) - .payment_hash(payment_hash) - .payment_secret(payment_secret) - .current_timestamp() - .min_final_cltv_expiry_delta(min_final_cltv_expiry_delta.into()) - .expiry_time(Duration::from_secs(expiry_secs.into())) - .private_route(route_hint); - - if let Some(amount_msat) = amount_msat { - invoice_builder = invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); - } - - let invoice = if let Some(payment_metadata) = payment_metadata { - invoice_builder.payment_metadata(payment_metadata).build_signed(|hash| { - Secp256k1::new() - .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) - }) - } else { - invoice_builder.build_signed(|hash| { - Secp256k1::new() - .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) - }) - }; - invoice.map_err(|e| { - log_error!(self.logger, "Failed to build and sign invoice: {}", e); - Error::InvoiceCreationFailed - }) - } - pub(crate) async fn handle_channel_ready( &self, user_channel_id: u128, channel_id: &ChannelId, counterparty_node_id: &PublicKey, ) { @@ -1110,14 +809,3 @@ where } } } - -#[derive(Debug, Clone)] -pub(crate) struct LSPS2FeeResponse { - opening_fee_params_menu: Vec, -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS2BuyResponse { - intercept_scid: u64, - cltv_expiry_delta: u32, -} From 1f02b86a1b2e9d5fef47de11eebeec0b4d9c4e6e Mon Sep 17 00:00:00 2001 From: Camillarhi Date: Wed, 29 Apr 2026 15:38:28 +0100 Subject: [PATCH 007/138] Move LSPS2 client event handling into `liquidity/client/lsps2.rs` --- src/liquidity/client/lsps2.rs | 102 ++++++++++++++++++++++++++++++++ src/liquidity/mod.rs | 107 ++-------------------------------- 2 files changed, 107 insertions(+), 102 deletions(-) diff --git a/src/liquidity/client/lsps2.rs b/src/liquidity/client/lsps2.rs index 3de6e6631b..befd5e1362 100644 --- a/src/liquidity/client/lsps2.rs +++ b/src/liquidity/client/lsps2.rs @@ -18,6 +18,7 @@ use lightning::util::ser::Writeable; use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, RoutingFees}; use lightning_liquidity::lsps0::ser::LSPSRequestId; use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; +use lightning_liquidity::lsps2::event::LSPS2ClientEvent; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::PaymentHash; @@ -41,6 +42,107 @@ pub(crate) struct LSPS2Client { Mutex>>, } +impl LSPS2Client { + pub(crate) async fn handle_event(&self, event: LSPS2ClientEvent, logger: &L) + where + L::Target: LdkLogger, + { + match event { + LSPS2ClientEvent::OpeningParametersReady { + request_id, + counterparty_node_id, + opening_fee_params_menu, + } => { + if counterparty_node_id != self.lsp_node_id { + debug_assert!( + false, + "Received response from unexpected LSP counterparty. This should never happen." + ); + log_error!( + logger, + "Received response from unexpected LSP counterparty. This should never happen." + ); + return; + } + + if let Some(sender) = + self.pending_fee_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS2FeeResponse { opening_fee_params_menu }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + logger, + "Received response from liquidity service for unknown request." + ); + } + }, + LSPS2ClientEvent::InvoiceParametersReady { + request_id, + counterparty_node_id, + intercept_scid, + cltv_expiry_delta, + .. + } => { + if counterparty_node_id != self.lsp_node_id { + debug_assert!( + false, + "Received response from unexpected LSP counterparty. This should never happen." + ); + log_error!( + logger, + "Received response from unexpected LSP counterparty. This should never happen." + ); + return; + } + + if let Some(sender) = + self.pending_buy_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS2BuyResponse { intercept_scid, cltv_expiry_delta }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + logger, + "Received response from liquidity service for unknown request." + ); + } + }, + _ => { + log_error!(logger, "Received unexpected LSPS2Client liquidity event!"); + }, + } + } +} + #[derive(Debug, Clone)] pub(crate) struct LSPS2ClientConfig { pub node_id: PublicKey, diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index ba29f656ab..9a45652546 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -26,7 +26,7 @@ use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::LSPSDateTime; use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfig; use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; -use lightning_liquidity::lsps2::event::{LSPS2ClientEvent, LSPS2ServiceEvent}; +use lightning_liquidity::lsps2::event::LSPS2ServiceEvent; use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; @@ -41,9 +41,7 @@ use crate::{total_anchor_channels_reserve_sats, Config}; pub(crate) use client::lsps1::{LSPS1Client, LSPS1ClientConfig}; pub use client::lsps1::{LSPS1Liquidity, LSPS1OrderStatus}; -pub(crate) use client::lsps2::{ - LSPS2BuyResponse, LSPS2Client, LSPS2ClientConfig, LSPS2FeeResponse, -}; +pub(crate) use client::lsps2::{LSPS2Client, LSPS2ClientConfig}; pub(crate) const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; @@ -631,106 +629,11 @@ where }, } }, - LiquidityEvent::LSPS2Client(LSPS2ClientEvent::OpeningParametersReady { - request_id, - counterparty_node_id, - opening_fee_params_menu, - }) => { + LiquidityEvent::LSPS2Client(event) => { if let Some(lsps2_client) = self.lsps2_client.as_ref() { - if counterparty_node_id != lsps2_client.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - self.logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = - lsps2_client.pending_fee_requests.lock().expect("lock").remove(&request_id) - { - let response = LSPS2FeeResponse { opening_fee_params_menu }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } + lsps2_client.handle_event(event, &self.logger).await; } else { - log_error!( - self.logger, - "Received unexpected LSPS2Client::OpeningParametersReady event!" - ); - } - }, - LiquidityEvent::LSPS2Client(LSPS2ClientEvent::InvoiceParametersReady { - request_id, - counterparty_node_id, - intercept_scid, - cltv_expiry_delta, - .. - }) => { - if let Some(lsps2_client) = self.lsps2_client.as_ref() { - if counterparty_node_id != lsps2_client.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - self.logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = - lsps2_client.pending_buy_requests.lock().expect("lock").remove(&request_id) - { - let response = LSPS2BuyResponse { intercept_scid, cltv_expiry_delta }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } - } else { - log_error!( - self.logger, - "Received unexpected LSPS2Client::InvoiceParametersReady event!" - ); + log_error!(self.logger, "Received unexpected LSPS2Client event!"); } }, e => { From aa2cbfb0cf186f8036e4e8d613cedfcccb6032c8 Mon Sep 17 00:00:00 2001 From: Camillarhi Date: Thu, 16 Apr 2026 13:17:27 +0100 Subject: [PATCH 008/138] Move LSPS2 service logic into `liquidity/service/lsps2.rs` --- src/liquidity/mod.rs | 216 +----------------------------- src/liquidity/service/lsps2.rs | 231 +++++++++++++++++++++++++++++++++ src/liquidity/service/mod.rs | 8 ++ 3 files changed, 244 insertions(+), 211 deletions(-) create mode 100644 src/liquidity/service/lsps2.rs create mode 100644 src/liquidity/service/mod.rs diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 9a45652546..315e7f30f1 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -8,19 +8,15 @@ //! Objects related to liquidity management. pub(crate) mod client; +pub(crate) mod service; use std::collections::HashMap; use std::ops::Deref; use std::sync::{Arc, Mutex, 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::msgs::SocketAddress; -use lightning::ln::types::ChannelId; use lightning::sign::EntropySource; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::LSPSDateTime; @@ -30,7 +26,6 @@ use lightning_liquidity::lsps2::event::LSPS2ServiceEvent; use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; -use lightning_types::payment::PaymentHash; use crate::builder::BuildError; use crate::logger::{log_error, LdkLogger}; @@ -42,77 +37,13 @@ use crate::{total_anchor_channels_reserve_sats, Config}; pub(crate) use client::lsps1::{LSPS1Client, LSPS1ClientConfig}; pub use client::lsps1::{LSPS1Liquidity, LSPS1OrderStatus}; pub(crate) use client::lsps2::{LSPS2Client, LSPS2ClientConfig}; +pub use service::lsps2::LSPS2ServiceConfig; +pub(crate) use service::lsps2::{ + LSPS2Service, LSPS2_CHANNEL_CLTV_EXPIRY_DELTA, LSPS2_GETINFO_REQUEST_EXPIRY, +}; pub(crate) const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; -const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); -const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; - -struct LSPS2Service { - service_config: LSPS2ServiceConfig, - ldk_service_config: LdkLSPS2ServiceConfig, -} - -/// 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, -} - pub(crate) struct LiquiditySourceBuilder where L::Target: LdkLogger, @@ -278,73 +209,6 @@ where 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, - ) { - if self.lsps2_service.as_ref().map_or(false, |svc| !svc.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, - ) { - if self.lsps2_service.as_ref().map_or(false, |svc| !svc.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_next_event(&self) { match self.liquidity_manager.next_event_async().await { LiquidityEvent::LSPS1Client(event) => { @@ -641,74 +505,4 @@ where }, } } - - 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 - ); - } - } - } - } } diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs new file mode 100644 index 0000000000..c30df2e3a8 --- /dev/null +++ b/src/liquidity/service/lsps2.rs @@ -0,0 +1,231 @@ +// 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::time::Duration; + +use bitcoin::secp256k1::PublicKey; +use bitcoin::Transaction; +use lightning::events::HTLCHandlingFailureType; +use lightning::ln::channelmanager::InterceptId; +use lightning::ln::types::ChannelId; +use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; +use lightning_types::payment::PaymentHash; + +use crate::logger::{log_error, LdkLogger}; + +use super::super::LiquiditySource; + +pub(crate) const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); +pub(crate) const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; + +pub(crate) struct LSPS2Service { + pub(crate) service_config: LSPS2ServiceConfig, + pub(crate) ldk_service_config: LdkLSPS2ServiceConfig, +} + +/// 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 LiquiditySource +where + L::Target: LdkLogger, +{ + 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, + ) { + if self.lsps2_service.as_ref().map_or(false, |svc| !svc.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, + ) { + if self.lsps2_service.as_ref().map_or(false, |svc| !svc.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 + ); + } + } + } + } +} diff --git a/src/liquidity/service/mod.rs b/src/liquidity/service/mod.rs new file mode 100644 index 0000000000..cdbaf54265 --- /dev/null +++ b/src/liquidity/service/mod.rs @@ -0,0 +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; From 96ba539cff90c1cd3adf63dc6c454db1f1c4e315 Mon Sep 17 00:00:00 2001 From: Camillarhi Date: Wed, 29 Apr 2026 15:44:43 +0100 Subject: [PATCH 009/138] Move LSPS2 service event handling into `liquidity/service/lsps2.rs` --- src/liquidity/mod.rs | 294 ++------------------------------- src/liquidity/service/lsps2.rs | 268 ++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+), 279 deletions(-) diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 315e7f30f1..fae3f0875f 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -15,15 +15,10 @@ use std::ops::Deref; use std::sync::{Arc, Mutex, RwLock, Weak}; use bitcoin::secp256k1::PublicKey; -use chrono::Utc; use lightning::ln::msgs::SocketAddress; -use lightning::sign::EntropySource; use lightning_liquidity::events::LiquidityEvent; -use lightning_liquidity::lsps0::ser::LSPSDateTime; use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfig; use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; -use lightning_liquidity::lsps2::event::LSPS2ServiceEvent; -use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; @@ -32,15 +27,13 @@ use crate::logger::{log_error, LdkLogger}; use crate::types::{ Broadcaster, ChannelManager, DynStore, KeysManager, LiquidityManager, PeerManager, Wallet, }; -use crate::{total_anchor_channels_reserve_sats, Config}; +use crate::Config; pub(crate) use client::lsps1::{LSPS1Client, LSPS1ClientConfig}; pub use client::lsps1::{LSPS1Liquidity, LSPS1OrderStatus}; pub(crate) use client::lsps2::{LSPS2Client, LSPS2ClientConfig}; +pub(crate) use service::lsps2::LSPS2Service; pub use service::lsps2::LSPS2ServiceConfig; -pub(crate) use service::lsps2::{ - LSPS2Service, LSPS2_CHANNEL_CLTV_EXPIRY_DELTA, LSPS2_GETINFO_REQUEST_EXPIRY, -}; pub(crate) const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; @@ -218,279 +211,22 @@ where log_error!(self.logger, "Received unexpected LSPS1Client event!"); } }, - LiquidityEvent::LSPS2Service(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; - } - }, - LiquidityEvent::LSPS2Service(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, + LiquidityEvent::LSPS2Service(event) => { + if let Some(lsps2_service) = self.lsps2_service.as_ref() { + lsps2_service + .handle_event( + event, + &self.liquidity_manager, + &self.channel_manager, + &self.keys_manager, + &self.peer_manager, + &self.wallet, + &self.config, + &self.logger, ) - .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; - } - }, - LiquidityEvent::LSPS2Service(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 + .await; } 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; - }, } }, LiquidityEvent::LSPS2Client(event) => { diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index c30df2e3a8..67b4737a40 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -6,17 +6,25 @@ // 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}; use super::super::LiquiditySource; @@ -28,6 +36,266 @@ pub(crate) struct LSPS2Service { pub(crate) ldk_service_config: LdkLSPS2ServiceConfig, } +impl LSPS2Service { + pub(crate) async fn handle_event( + &self, event: LSPS2ServiceEvent, liquidity_manager: &Arc, + channel_manager: &Arc, keys_manager: &Arc, + peer_manager: &RwLock>>, wallet: &Arc, + config: &Arc, logger: &L, + ) where + L::Target: LdkLogger, + { + match event { + LSPS2ServiceEvent::GetInfo { request_id, counterparty_node_id, token } => { + if let Some(lsps2_service_handler) = + liquidity_manager.lsps2_service_handler().as_ref() + { + let service_config = self.service_config.clone(); + + if let Some(required) = service_config.require_token { + if token != Some(required) { + log_error!( + 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!( + 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!( + logger, + "Failed to handle generated opening fee params: {:?}", + e + ); + } + } else { + log_error!(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) = + liquidity_manager.lsps2_service_handler().as_ref() + { + let service_config = self.service_config.clone(); + + let user_channel_id: u128 = u128::from_ne_bytes( + keys_manager.get_secure_random_bytes()[..16] + .try_into() + .expect("a 16-byte slice should convert into a [u8; 16]"), + ); + let intercept_scid = 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!( + 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!(logger, "Failed to provide invoice parameters: {:?}", e); + return; + }, + } + } else { + log_error!(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 liquidity_manager.lsps2_service_handler().is_none() { + log_error!(logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); + return; + }; + + let service_config = self.service_config.clone(); + + let init_features = if let Some(Some(peer_manager)) = + 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!( + 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!(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(channel_manager, config); + let spendable_amount_sats = + wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); + let required_funds_sats = channel_amount_sats + + 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!(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 = 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 { + channel_manager.create_channel_to_trusted_peer_0reserve( + their_network_key, + channel_amount_sats, + 0, + user_channel_id, + None, + Some(config), + ) + } else { + 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!( + logger, + "Failed to open LSPS2 {}channel to {}: {:?}", + zero_reserve_string, + their_network_key, + e + ); + return; + }, + } + }, + } + } +} + /// Represents the configuration of the LSPS2 service. /// /// See [bLIP-52 / LSPS2] for more information. From 988ba66e90a6fc7e9d6467d82f0165240de12ca8 Mon Sep 17 00:00:00 2001 From: Camillarhi Date: Wed, 29 Apr 2026 15:48:02 +0100 Subject: [PATCH 010/138] Refactor liquidity source to support multiple LSP nodes Replace per-protocol single-LSP configuration `LSPS1Client` and `LSPS2Client` with a unified `Vec` model where users configure LSP nodes via `add_liquidity_source()` at build time or runtime and per-LSP protocol support is discovered via the LSPS0 `list_protocols`. - Introduce a per-LSP `trust_peer_0conf` flag to `LspConfig`/`LspNode` structs that controls whether 0-conf channels from that LSP are accepted - Add LSPS0 protocol discovery `discover_lsp_protocols` with event handling for `ListProtocolsResponse` - Update events to also use each LSP's `trust_peer_0conf` flag when deciding whether to allow 0-conf channels - Replace `set_liquidity_source_lsps1` and `set_liquidity_source_lsps2` builder methods with a single `add_liquidity_source()` that takes a `trust_peer_0conf` flag - Rename `set_liquidity_provider_lsps2` to `enable_liquidity_provider` - LSPS2 JIT channels now query all LSPS2-capable LSPs and automatically select the cheapest fee offer across all of them - Spawn background discovery task on `Node::start()` and expose a watch channel so dependent flows can wait for discovery to complete - Add a new `Liquidity` handler `Node::liquidity()` exposing `add_liquidity_source()` API for adding LSPs at runtime, and `lsps1()` for the existing LSPS1 surface --- bindings/ldk_node.udl | 8 +- src/builder.rs | 160 ++--- src/event.rs | 147 ++--- src/lib.rs | 119 ++-- src/liquidity/client/lsps1.rs | 522 ++++++++-------- src/liquidity/client/lsps2.rs | 451 ++++++++------ src/liquidity/client/mod.rs | 20 +- src/liquidity/mod.rs | 502 +++++++++++---- src/liquidity/service/lsps2.rs | 1036 ++++++++++++++++--------------- src/liquidity/service/mod.rs | 16 +- src/payment/bolt11.rs | 43 +- tests/integration_tests_rust.rs | 107 +++- 12 files changed, 1838 insertions(+), 1293 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 7e9e61f5d5..851583c5ad 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -43,8 +43,7 @@ interface Builder { void set_gossip_source_p2p(); void set_gossip_source_rgs(string rgs_server_url); void set_pathfinding_scores_source(string url); - void set_liquidity_source_lsps1(PublicKey node_id, SocketAddress address, string? token); - void set_liquidity_source_lsps2(PublicKey node_id, SocketAddress address, string? token); + void add_liquidity_source(PublicKey node_id, SocketAddress address, string? token, boolean trust_peer_0conf); void set_storage_dir_path(string storage_dir_path); void set_filesystem_logger(string? log_file_path, LogLevel? max_log_level); void set_log_facade_logger(); @@ -99,7 +98,7 @@ interface Node { SpontaneousPayment spontaneous_payment(); OnchainPayment onchain_payment(); UnifiedPayment unified_payment(); - LSPS1Liquidity lsps1_liquidity(); + Liquidity liquidity(); [Throws=NodeError] void lnurl_auth(string lnurl); [Throws=NodeError] @@ -167,7 +166,7 @@ interface FeeRate { typedef interface UnifiedPayment; -typedef interface LSPS1Liquidity; +typedef interface Liquidity; [Error] enum NodeError { @@ -275,6 +274,7 @@ dictionary LSPS1OrderStatus { LSPS1OrderParams order_params; LSPS1PaymentInfo payment_options; LSPS1ChannelInfo? channel_state; + PublicKey counterparty_node_id; }; [Remote] diff --git a/src/builder.rs b/src/builder.rs index c88c867cc1..15f49fb546 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -68,9 +68,7 @@ use crate::io::{ PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; -use crate::liquidity::{ - LSPS1ClientConfig, LSPS2ClientConfig, LSPS2ServiceConfig, LiquiditySourceBuilder, -}; +use crate::liquidity::{LSPS2ServiceConfig, LiquiditySourceBuilder, LspConfig}; use crate::lnurl_auth::LnurlAuth; use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; @@ -123,10 +121,8 @@ struct PathfindingScoresSyncConfig { #[derive(Debug, Clone, Default)] struct LiquiditySourceConfig { - // Act as an LSPS1 client connecting to the given service. - lsps1_client: Option, - // Act as an LSPS2 client connecting to the given service. - lsps2_client: Option, + // Acts for both LSPS1 and LSPS2 clients connecting to the given service. + lsp_nodes: Vec, // Act as an LSPS2 service. lsps2_service: Option, } @@ -443,45 +439,36 @@ impl NodeBuilder { self } - /// Configures the [`Node`] instance to source inbound liquidity from the given - /// [bLIP-51 / LSPS1] service. + /// Configures the [`Node`] instance to source inbound liquidity from the given LSP. /// - /// Will mark the LSP as trusted for 0-confirmation channels, see [`Config::trusted_peers_0conf`]. + /// The node will discover the LSP's supported protocols (LSPS1/LSPS2) on startup via [bLIP-50 / LSPS0] + /// and select the appropriate protocol per request automatically. /// /// The given `token` will be used by the LSP to authenticate the user. + /// `trust_peer_0conf` controls whether the node will additionally accept + /// 0-confirmation channels opened by this LSP. If `false`, 0-confirmation + /// acceptance for this peer falls back to [`Config::trusted_peers_0conf`]. + /// + /// May be called multiple times to register several LSPs. Duplicate `node_id`s are ignored. /// - /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md - pub fn set_liquidity_source_lsps1( + /// [bLIP-50 / LSPS0]: https://github.com/lightning/blips/blob/master/blip-0050.md + pub fn add_liquidity_source( &mut self, node_id: PublicKey, address: SocketAddress, token: Option, + trust_peer_0conf: bool, ) -> &mut Self { - // Mark the LSP as trusted for 0conf - self.config.trusted_peers_0conf.push(node_id.clone()); - let liquidity_source_config = self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default()); - let lsps1_client_config = LSPS1ClientConfig { node_id, address, token }; - liquidity_source_config.lsps1_client = Some(lsps1_client_config); - self - } - /// Configures the [`Node`] instance to source just-in-time inbound liquidity from the given - /// [bLIP-52 / LSPS2] service. - /// - /// Will mark the LSP as trusted for 0-confirmation channels, see [`Config::trusted_peers_0conf`]. - /// - /// The given `token` will be used by the LSP to authenticate the user. - /// - /// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md - pub fn set_liquidity_source_lsps2( - &mut self, node_id: PublicKey, address: SocketAddress, token: Option, - ) -> &mut Self { - // Mark the LSP as trusted for 0conf - self.config.trusted_peers_0conf.push(node_id.clone()); + if liquidity_source_config.lsp_nodes.iter().any(|n| n.node_id == node_id) { + return self; + } - let liquidity_source_config = - self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default()); - let lsps2_client_config = LSPS2ClientConfig { node_id, address, token }; - liquidity_source_config.lsps2_client = Some(lsps2_client_config); + liquidity_source_config.lsp_nodes.push(LspConfig { + node_id, + address, + token, + trust_peer_0conf, + }); self } @@ -491,12 +478,12 @@ impl NodeBuilder { /// **Caution**: LSP service support is in **alpha** and is considered an experimental feature. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md - pub fn set_liquidity_provider_lsps2( - &mut self, service_config: LSPS2ServiceConfig, + pub fn enable_liquidity_provider( + &mut self, lsps2_service_config: LSPS2ServiceConfig, ) -> &mut Self { let liquidity_source_config = self.liquidity_source_config.get_or_insert(LiquiditySourceConfig::default()); - liquidity_source_config.lsps2_service = Some(service_config); + liquidity_source_config.lsps2_service = Some(lsps2_service_config); self } @@ -1032,32 +1019,29 @@ impl ArcedNodeBuilder { self.inner.write().expect("lock").set_pathfinding_scores_source(url); } - /// Configures the [`Node`] instance to source inbound liquidity from the given - /// [bLIP-51 / LSPS1] service. + /// Configures the [`Node`] instance to source inbound liquidity from the given LSP. /// - /// Will mark the LSP as trusted for 0-confirmation channels, see [`Config::trusted_peers_0conf`]. + /// The node will discover the LSP's supported protocols (LSPS1/LSPS2) on startup via [bLIP-50 / LSPS0] + /// and select the appropriate protocol per request automatically. /// /// The given `token` will be used by the LSP to authenticate the user. + /// `trust_peer_0conf` controls whether the node will additionally accept + /// 0-confirmation channels opened by this LSP. If `false`, 0-confirmation + /// acceptance for this peer falls back to [`Config::trusted_peers_0conf`]. /// - /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md - pub fn set_liquidity_source_lsps1( - &self, node_id: PublicKey, address: SocketAddress, token: Option, - ) { - self.inner.write().expect("lock").set_liquidity_source_lsps1(node_id, address, token); - } - - /// Configures the [`Node`] instance to source just-in-time inbound liquidity from the given - /// [bLIP-52 / LSPS2] service. - /// - /// Will mark the LSP as trusted for 0-confirmation channels, see [`Config::trusted_peers_0conf`]. - /// - /// The given `token` will be used by the LSP to authenticate the user. + /// May be called multiple times to register several LSPs. Duplicate `node_id`s are ignored. /// - /// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md - pub fn set_liquidity_source_lsps2( + /// [bLIP-50 / LSPS0]: https://github.com/lightning/blips/blob/master/blip-0050.md + pub fn add_liquidity_source( &self, node_id: PublicKey, address: SocketAddress, token: Option, + trust_peer_0conf: bool, ) { - self.inner.write().expect("lock").set_liquidity_source_lsps2(node_id, address, token); + self.inner.write().expect("lock").add_liquidity_source( + node_id, + address, + token, + trust_peer_0conf, + ); } /// Configures the [`Node`] instance to provide an [LSPS2] service, issuing just-in-time @@ -1066,8 +1050,8 @@ impl ArcedNodeBuilder { /// **Caution**: LSP service support is in **alpha** and is considered an experimental feature. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md - pub fn set_liquidity_provider_lsps2(&self, service_config: LSPS2ServiceConfig) { - self.inner.write().expect("lock").set_liquidity_provider_lsps2(service_config); + pub fn enable_liquidity_provider(&self, lsps2_service_config: LSPS2ServiceConfig) { + self.inner.write().expect("lock").enable_liquidity_provider(lsps2_service_config); } /// Sets the used storage directory path. @@ -1975,33 +1959,19 @@ fn build_with_store_internal( }, }; - let (liquidity_source, custom_message_handler) = - if let Some(lsc) = liquidity_source_config.as_ref() { - let mut liquidity_source_builder = LiquiditySourceBuilder::new( - Arc::clone(&wallet), - Arc::clone(&channel_manager), - Arc::clone(&keys_manager), - Arc::clone(&tx_broadcaster), - Arc::clone(&kv_store), - Arc::clone(&config), - Arc::clone(&logger), - ); - - lsc.lsps1_client.as_ref().map(|config| { - liquidity_source_builder.lsps1_client( - config.node_id, - config.address.clone(), - config.token.clone(), - ) - }); + let (liquidity_source, custom_message_handler) = { + let mut liquidity_source_builder = LiquiditySourceBuilder::new( + Arc::clone(&wallet), + Arc::clone(&channel_manager), + Arc::clone(&keys_manager), + Arc::clone(&tx_broadcaster), + Arc::clone(&kv_store), + Arc::clone(&config), + Arc::clone(&logger), + ); - lsc.lsps2_client.as_ref().map(|config| { - liquidity_source_builder.lsps2_client( - config.node_id, - config.address.clone(), - config.token.clone(), - ) - }); + if let Some(lsc) = liquidity_source_config.as_ref() { + liquidity_source_builder.set_lsp_nodes(lsc.lsp_nodes.clone()); let promise_secret = { let lsps_xpriv = derive_xprv( @@ -2015,15 +1985,15 @@ fn build_with_store_internal( lsc.lsps2_service.as_ref().map(|config| { liquidity_source_builder.lsps2_service(promise_secret, config.clone()) }); + } - let liquidity_source = runtime - .block_on(async move { liquidity_source_builder.build().await.map(Arc::new) })?; - let custom_message_handler = - Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source))); - (Some(liquidity_source), custom_message_handler) - } else { - (None, Arc::new(NodeCustomMessageHandler::new_ignoring())) - }; + let liquidity_source = runtime + .block_on(async move { liquidity_source_builder.build().await.map(Arc::new) })?; + let custom_message_handler = + Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source))); + + (liquidity_source, custom_message_handler) + }; let msg_handler = match gossip_source.as_gossip_sync() { GossipSync::P2P(p2p_gossip_sync) => MessageHandler { @@ -2072,7 +2042,7 @@ fn build_with_store_internal( })); } - liquidity_source.as_ref().map(|l| l.set_peer_manager(Arc::downgrade(&peer_manager))); + liquidity_source.lsps2_service().set_peer_manager(Arc::downgrade(&peer_manager)); let connection_manager = Arc::new(ConnectionManager::new( Arc::clone(&peer_manager), diff --git a/src/event.rs b/src/event.rs index 86ee7bb05a..80acd0690e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -533,7 +533,7 @@ where connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, - liquidity_source: Option>>>, + liquidity_source: Arc>>, payment_store: Arc, peer_store: Arc>, keys_manager: Arc, @@ -554,11 +554,11 @@ where bump_tx_event_handler: Arc, channel_manager: Arc, connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, - liquidity_source: Option>>>, - payment_store: Arc, peer_store: Arc>, - keys_manager: Arc, static_invoice_store: Option, - onion_messenger: Arc, om_mailbox: Option>, - runtime: Arc, logger: L, config: Arc, + liquidity_source: Arc>>, payment_store: Arc, + peer_store: Arc>, keys_manager: Arc, + static_invoice_store: Option, onion_messenger: Arc, + om_mailbox: Option>, runtime: Arc, logger: L, + config: Arc, ) -> Self { Self { event_queue, @@ -637,22 +637,21 @@ where locktime, ) { Ok(final_tx) => { - let needs_manual_broadcast = - self.liquidity_source.as_ref().map_or(false, |ls| { - ls.as_ref().lsps2_channel_needs_manual_broadcast( - counterparty_node_id, - user_channel_id, - ) - }); + let needs_manual_broadcast = self + .liquidity_source + .lsps2_service() + .lsps2_channel_needs_manual_broadcast( + counterparty_node_id, + user_channel_id, + ); let result = if needs_manual_broadcast { - self.liquidity_source.as_ref().map(|ls| { - ls.lsps2_store_funding_transaction( - user_channel_id, - counterparty_node_id, - final_tx.clone(), - ); - }); + self.liquidity_source.lsps2_service().lsps2_store_funding_transaction( + user_channel_id, + counterparty_node_id, + final_tx.clone(), + ); + self.channel_manager.funding_transaction_generated_manual_broadcast( temporary_channel_id, counterparty_node_id, @@ -710,9 +709,9 @@ where } }, LdkEvent::FundingTxBroadcastSafe { user_channel_id, counterparty_node_id, .. } => { - self.liquidity_source.as_ref().map(|ls| { - ls.lsps2_funding_tx_broadcast_safe(user_channel_id, counterparty_node_id); - }); + self.liquidity_source + .lsps2_service() + .lsps2_funding_tx_broadcast_safe(user_channel_id, counterparty_node_id); }, LdkEvent::PaymentClaimable { payment_hash, @@ -1213,9 +1212,10 @@ where LdkEvent::ProbeSuccessful { .. } => {}, LdkEvent::ProbeFailed { .. } => {}, LdkEvent::HTLCHandlingFailed { failure_type, .. } => { - if let Some(liquidity_source) = self.liquidity_source.as_ref() { - liquidity_source.handle_htlc_handling_failed(failure_type).await; - } + self.liquidity_source + .lsps2_service() + .handle_htlc_handling_failed(failure_type) + .await; }, LdkEvent::SpendableOutputs { outputs, channel_id, counterparty_node_id } => { match self @@ -1315,35 +1315,36 @@ where .try_into() .expect("slice is exactly 16 bytes"), ); - let allow_0conf = self.config.trusted_peers_0conf.contains(&counterparty_node_id); + let mut allow_0conf = + self.config.trusted_peers_0conf.contains(&counterparty_node_id); let mut channel_override_config = None; - if let Some((lsp_node_id, _)) = self - .liquidity_source - .as_ref() - .and_then(|ls| ls.as_ref().get_lsps2_lsp_details()) + + // If the peer is a configured LSP node, additionally honor its trust_peer_0conf flag. + if let Some(lsp) = + self.liquidity_source.get_lsp_config(&counterparty_node_id, 2).await { - if lsp_node_id == counterparty_node_id { - // When we're an LSPS2 client, allow claiming underpaying HTLCs as the LSP will skim off some fee. We'll - // check that they don't take too much before claiming. - channel_override_config = Some(ChannelConfigOverrides { - update_overrides: Some(ChannelConfigUpdate { - accept_underpaying_htlcs: Some(true), - ..Default::default() - }), + allow_0conf = allow_0conf || lsp.trust_peer_0conf; + + // When we're an LSPS2 client, allow claiming underpaying HTLCs as the LSP will skim off some fee. We'll + // check that they don't take too much before claiming. + channel_override_config = Some(ChannelConfigOverrides { + update_overrides: Some(ChannelConfigUpdate { + accept_underpaying_htlcs: Some(true), ..Default::default() - }); + }), + ..Default::default() + }); - // LSPS2 channels are unannounced; rely on LDK's default of 100% - // inbound HTLC value-in-flight so the LSP can forward the initial - // payment in full. - debug_assert_eq!( - self.channel_manager - .get_current_config() - .channel_handshake_config - .unannounced_channel_max_inbound_htlc_value_in_flight_percentage, - 100 - ); - } + // LSPS2 channels are unannounced; rely on LDK's default of 100% + // inbound HTLC value-in-flight so the LSP can forward the initial + // payment in full. + debug_assert_eq!( + self.channel_manager + .get_current_config() + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage, + 100 + ); } let res = if allow_0conf { self.channel_manager.accept_inbound_channel_from_trusted_peer( @@ -1468,13 +1469,15 @@ where "unexpected skimmed fee for trampoline forward, fee may be double counted" ); } - if let Some(liquidity_source) = self.liquidity_source.as_ref() { - let skimmed_fee_msat = skimmed_fee_msat.unwrap_or(0); - for next_htlc in next_htlcs.iter() { - liquidity_source - .handle_payment_forwarded(Some(next_htlc.channel_id), skimmed_fee_msat) - .await; - } + + for next_htlc in next_htlcs.iter() { + self.liquidity_source + .lsps2_service() + .handle_payment_forwarded( + Some(next_htlc.channel_id), + skimmed_fee_msat.unwrap_or(0), + ) + .await; } let event = Event::PaymentForwarded { @@ -1582,11 +1585,10 @@ where ); } - if let Some(liquidity_source) = self.liquidity_source.as_ref() { - liquidity_source - .handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id) - .await; - } + self.liquidity_source + .lsps2_service() + .handle_channel_ready(user_channel_id, &channel_id, &counterparty_node_id) + .await; let event = Event::ChannelReady { channel_id, @@ -1659,16 +1661,15 @@ where payment_hash, .. } => { - if let Some(liquidity_source) = self.liquidity_source.as_ref() { - liquidity_source - .handle_htlc_intercepted( - requested_next_hop_scid, - intercept_id, - expected_outbound_amount_msat, - payment_hash, - ) - .await; - } + self.liquidity_source + .lsps2_service() + .handle_htlc_intercepted( + requested_next_hop_scid, + intercept_id, + expected_outbound_amount_msat, + payment_hash, + ) + .await; }, LdkEvent::InvoiceReceived { .. } => { debug_assert!(false, "We currently don't handle BOLT12 invoices manually, so this event should never be emitted."); diff --git a/src/lib.rs b/src/lib.rs index 7ed69031c3..005094f877 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -162,7 +162,7 @@ pub use lightning_invoice; pub use lightning_liquidity; pub use lightning_types; use lightning_types::features::NodeFeatures as LdkNodeFeatures; -use liquidity::{LSPS1Liquidity, LiquiditySource}; +use liquidity::LiquiditySource; use lnurl_auth::LnurlAuth; use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use payment::asynchronous::om_mailbox::OnionMessageMailbox; @@ -183,6 +183,7 @@ pub use types::{ChannelDetails, CustomTlvRecord, PeerDetails, UserChannelId}; pub use vss_client; use crate::ffi::maybe_wrap; +use crate::liquidity::Liquidity; use crate::scoring::setup_background_pathfinding_scores_sync; use crate::wallet::FundingAmount; @@ -234,7 +235,7 @@ pub struct Node { network_graph: Arc, gossip_source: Arc, pathfinding_scores_sync_url: Option, - liquidity_source: Option>>>, + liquidity_source: Arc>>, kv_store: Arc, logger: Arc, _router: Arc, @@ -598,7 +599,7 @@ impl Node { Arc::clone(&self.connection_manager), Arc::clone(&self.output_sweeper), Arc::clone(&self.network_graph), - self.liquidity_source.clone(), + Arc::clone(&self.liquidity_source), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), Arc::clone(&self.keys_manager), @@ -617,8 +618,7 @@ impl Node { let background_chan_man = Arc::clone(&self.channel_manager); let background_gossip_sync = self.gossip_source.as_gossip_sync(); let background_peer_man = Arc::clone(&self.peer_manager); - let background_liquidity_man_opt = - self.liquidity_source.as_ref().map(|ls| ls.liquidity_manager()); + let background_liquidity_man = self.liquidity_source.liquidity_manager(); let background_sweeper = Arc::clone(&self.output_sweeper); let background_onion_messenger = Arc::clone(&self.onion_messenger); let background_logger = Arc::clone(&self.logger); @@ -654,7 +654,7 @@ impl Node { Some(background_onion_messenger), background_gossip_sync, background_peer_man, - background_liquidity_man_opt, + Some(background_liquidity_man), Some(background_sweeper), background_logger, Some(background_scorer), @@ -675,25 +675,74 @@ impl Node { }); }); - if let Some(liquidity_source) = self.liquidity_source.as_ref() { - let mut stop_liquidity_handler = self.stop_sender.subscribe(); - let liquidity_handler = Arc::clone(&liquidity_source); - let liquidity_logger = Arc::clone(&self.logger); - self.runtime.spawn_background_task(async move { - loop { - tokio::select! { - _ = stop_liquidity_handler.changed() => { - log_debug!( + let mut stop_liquidity_handler = self.stop_sender.subscribe(); + let liquidity_handler = Arc::clone(&self.liquidity_source); + let liquidity_logger = Arc::clone(&self.logger); + let discovery_cm = Arc::clone(&self.connection_manager); + self.runtime.spawn_background_task(async move { + // Spawn discovery for configured LSPs in parallel. + let discovery_logger = Arc::clone(&liquidity_logger); + let mut discovery_set = tokio::task::JoinSet::new(); + for (node_id, address) in liquidity_handler.get_all_lsp_details() { + let cm = Arc::clone(&discovery_cm); + let logger = Arc::clone(&discovery_logger); + let ls = Arc::clone(&liquidity_handler); + discovery_set.spawn(async move { + if let Err(e) = cm.connect_peer_if_necessary(node_id, address.clone()).await { + log_error!( + logger, + "Failed to connect to LSP {} for protocol discovery: {}", + node_id, + e + ); + return; + } + match ls.discover_lsp_protocols(&node_id).await { + Ok(protocols) => { + log_info!( + logger, + "Discovered protocols for LSP {}: {:?}", + node_id, + protocols + ); + }, + Err(e) => { + log_error!( + logger, + "Failed to discover protocols for LSP {}: {:?}", + node_id, + e + ); + }, + } + }); + } + + let mut discovery_done = false; + loop { + tokio::select! { + _ = stop_liquidity_handler.changed() => { + log_debug!( + liquidity_logger, + "Stopping processing liquidity events.", + ); + discovery_set.shutdown().await; + return; + } + _ = liquidity_handler.handle_next_event() => {} + res = discovery_set.join_next(), if !discovery_done => { + if res.is_none() { + liquidity_handler.mark_discovery_done(); + discovery_done = true; + log_info!( liquidity_logger, - "Stopping processing liquidity events.", + "LSP protocols discovery complete.", ); - return; } - _ = liquidity_handler.handle_next_event() => {} } } - }); - } + } + }); log_info!(self.logger, "Startup complete."); *is_running_lock = true; @@ -893,7 +942,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.connection_manager), - self.liquidity_source.clone(), + Arc::clone(&self.liquidity_source), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), Arc::clone(&self.config), @@ -911,7 +960,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.connection_manager), - self.liquidity_source.clone(), + Arc::clone(&self.liquidity_source), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), Arc::clone(&self.config), @@ -1068,30 +1117,26 @@ impl Node { }) } - /// Returns a liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. - /// - /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md + /// Returns a liquidity handler allowing to manage LSP connections and request channels. #[cfg(not(feature = "uniffi"))] - pub fn lsps1_liquidity(&self) -> LSPS1Liquidity { - LSPS1Liquidity::new( + pub fn liquidity(&self) -> Liquidity { + Liquidity::new( Arc::clone(&self.runtime), Arc::clone(&self.wallet), Arc::clone(&self.connection_manager), - self.liquidity_source.clone(), + Arc::clone(&self.liquidity_source), Arc::clone(&self.logger), ) } - /// Returns a liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. - /// - /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md + /// Returns a liquidity handler allowing to manage LSP connections and request channels. #[cfg(feature = "uniffi")] - pub fn lsps1_liquidity(&self) -> Arc { - Arc::new(LSPS1Liquidity::new( + pub fn liquidity(&self) -> Arc { + Arc::new(Liquidity::new( Arc::clone(&self.runtime), Arc::clone(&self.wallet), Arc::clone(&self.connection_manager), - self.liquidity_source.clone(), + Arc::clone(&self.liquidity_source), Arc::clone(&self.logger), )) } @@ -2094,11 +2139,7 @@ impl Node { | self.chain_monitor.provided_node_features() | self.onion_messenger.provided_node_features() | gossip_features - | self - .liquidity_source - .as_ref() - .map(|ls| ls.liquidity_manager().provided_node_features()) - .unwrap_or_else(LdkNodeFeatures::empty) + | self.liquidity_source.liquidity_manager().provided_node_features() } } diff --git a/src/liquidity/client/lsps1.rs b/src/liquidity/client/lsps1.rs index edd344f501..dff374fe2d 100644 --- a/src/liquidity/client/lsps1.rs +++ b/src/liquidity/client/lsps1.rs @@ -7,13 +7,12 @@ use std::collections::HashMap; use std::ops::Deref; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; use bitcoin::secp256k1::PublicKey; -use lightning::ln::msgs::SocketAddress; +use lightning::log_debug; use lightning_liquidity::lsps0::ser::LSPSRequestId; -use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfig; use lightning_liquidity::lsps1::event::LSPS1ClientEvent; use lightning_liquidity::lsps1::msgs::{ LSPS1ChannelInfo, LSPS1Options, LSPS1OrderId, LSPS1OrderParams, @@ -21,230 +20,40 @@ use lightning_liquidity::lsps1::msgs::{ use tokio::sync::oneshot; use crate::connection::ConnectionManager; +use crate::liquidity::{ + select_lsps_for_protocol, LspConfig, LspNode, LIQUIDITY_REQUEST_TIMEOUT_SECS, + LSPS_DISCOVERY_WAIT_TIMEOUT_SECS, +}; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::runtime::Runtime; -use crate::types::Wallet; +use crate::types::{LiquidityManager, Wallet}; use crate::Error; -use super::super::{LiquiditySource, LIQUIDITY_REQUEST_TIMEOUT_SECS}; - -pub(crate) struct LSPS1Client { - pub(crate) lsp_node_id: PublicKey, - pub(crate) lsp_address: SocketAddress, - pub(crate) token: Option, - pub(crate) ldk_client_config: LdkLSPS1ClientConfig, +pub(crate) struct LSPS1Client +where + L::Target: LdkLogger, +{ + pub(crate) lsp_nodes: Arc>>, pub(crate) pending_opening_params_requests: Mutex>>, pub(crate) pending_create_order_requests: Mutex>>, pub(crate) pending_check_order_status_requests: Mutex>>, + pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, + pub(crate) liquidity_manager: Arc, + pub(crate) logger: L, } -impl LSPS1Client { - pub(crate) async fn handle_event(&self, event: LSPS1ClientEvent, logger: &L) - where - L::Target: LdkLogger, - { - match event { - LSPS1ClientEvent::SupportedOptionsReady { - request_id, - counterparty_node_id, - supported_options, - } => { - if counterparty_node_id != self.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = - self.pending_opening_params_requests.lock().expect("lock").remove(&request_id) - { - let response = LSPS1OpeningParamsResponse { supported_options }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - logger, - "Received response from liquidity service for unknown request." - ); - } - }, - LSPS1ClientEvent::OrderCreated { - request_id, - counterparty_node_id, - order_id, - order, - payment, - channel, - } => { - if counterparty_node_id != self.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = - self.pending_create_order_requests.lock().expect("lock").remove(&request_id) - { - let response = LSPS1OrderStatus { - order_id, - order_params: order, - payment_options: payment.into(), - channel_state: channel, - }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - logger, - "Received response from liquidity service for unknown request." - ); - } - }, - LSPS1ClientEvent::OrderStatus { - request_id, - counterparty_node_id, - order_id, - order, - payment, - channel, - } => { - if counterparty_node_id != self.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = self - .pending_check_order_status_requests - .lock() - .expect("lock") - .remove(&request_id) - { - let response = LSPS1OrderStatus { - order_id, - order_params: order, - payment_options: payment.into(), - channel_state: channel, - }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - logger, - "Received response from liquidity service for unknown request." - ); - } - }, - _ => { - log_error!(logger, "Received unexpected LSPS1Client liquidity event!"); - }, - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS1ClientConfig { - pub node_id: PublicKey, - pub address: SocketAddress, - pub token: Option, -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS1OpeningParamsResponse { - pub(crate) supported_options: LSPS1Options, -} - -/// Represents the status of an LSPS1 channel request. -#[derive(Debug, Clone)] -pub struct LSPS1OrderStatus { - /// The id of the channel order. - pub order_id: LSPS1OrderId, - /// The parameters of channel order. - pub order_params: LSPS1OrderParams, - /// Contains details about how to pay for the order. - pub payment_options: LSPS1PaymentInfo, - /// Contains information about the channel state. - pub channel_state: Option, -} - -#[cfg(not(feature = "uniffi"))] -type LSPS1PaymentInfo = lightning_liquidity::lsps1::msgs::LSPS1PaymentInfo; - -#[cfg(feature = "uniffi")] -type LSPS1PaymentInfo = crate::ffi::LSPS1PaymentInfo; - -impl LiquiditySource +impl LSPS1Client where L::Target: LdkLogger, { - pub(crate) fn get_lsps1_lsp_details(&self) -> Option<(PublicKey, SocketAddress)> { - self.lsps1_client.as_ref().map(|s| (s.lsp_node_id, s.lsp_address.clone())) - } - pub(crate) async fn lsps1_request_opening_params( - &self, + &self, node_id: &PublicKey, ) -> Result { - let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + let lsps1_node = select_lsps_for_protocol(&self.lsp_nodes, 1, Some(node_id)) + .ok_or(Error::LiquiditySourceUnavailable)?; let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { log_error!(self.logger, "LSPS1 liquidity client was not configured.",); @@ -254,8 +63,8 @@ where let (request_sender, request_receiver) = oneshot::channel(); { let mut pending_opening_params_requests_lock = - lsps1_client.pending_opening_params_requests.lock().expect("lock"); - let request_id = client_handler.request_supported_options(lsps1_client.lsp_node_id); + self.pending_opening_params_requests.lock().expect("lock"); + let request_id = client_handler.request_supported_options(lsps1_node.node_id); pending_opening_params_requests_lock.insert(request_id, request_sender); } @@ -273,15 +82,17 @@ where pub(crate) async fn lsps1_request_channel( &self, lsp_balance_sat: u64, client_balance_sat: u64, channel_expiry_blocks: u32, - announce_channel: bool, refund_address: bitcoin::Address, + announce_channel: bool, refund_address: bitcoin::Address, node_id: &PublicKey, ) -> Result { - let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + let lsps1_node = select_lsps_for_protocol(&self.lsp_nodes, 1, Some(node_id)) + .ok_or(Error::LiquiditySourceUnavailable)?; + let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { log_error!(self.logger, "LSPS1 liquidity client was not configured.",); Error::LiquiditySourceUnavailable })?; - let lsp_limits = self.lsps1_request_opening_params().await?.supported_options; + let lsp_limits = self.lsps1_request_opening_params(node_id).await?.supported_options; let channel_size_sat = lsp_balance_sat + client_balance_sat; if channel_size_sat < lsp_limits.min_channel_balance_sat @@ -329,7 +140,7 @@ where required_channel_confirmations: lsp_limits.min_required_channel_confirmations, funding_confirms_within_blocks: lsp_limits.min_funding_confirms_within_blocks, channel_expiry_blocks, - token: lsps1_client.token.clone(), + token: lsps1_node.token.clone(), announce_channel, }; @@ -337,9 +148,9 @@ where let request_id; { let mut pending_create_order_requests_lock = - lsps1_client.pending_create_order_requests.lock().expect("lock"); + self.pending_create_order_requests.lock().expect("lock"); request_id = client_handler.create_order( - &lsps1_client.lsp_node_id, + &lsps1_node.node_id, order_params.clone(), Some(refund_address), ); @@ -372,9 +183,8 @@ where } pub(crate) async fn lsps1_check_order_status( - &self, order_id: LSPS1OrderId, + &self, order_id: LSPS1OrderId, lsp_node_id: PublicKey, ) -> Result { - let lsps1_client = self.lsps1_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; let client_handler = self.liquidity_manager.lsps1_client_handler().ok_or_else(|| { log_error!(self.logger, "LSPS1 liquidity client was not configured.",); Error::LiquiditySourceUnavailable @@ -383,8 +193,8 @@ where let (request_sender, request_receiver) = oneshot::channel(); { let mut pending_check_order_status_requests_lock = - lsps1_client.pending_check_order_status_requests.lock().expect("lock"); - let request_id = client_handler.check_order_status(&lsps1_client.lsp_node_id, order_id); + self.pending_check_order_status_requests.lock().expect("lock"); + let request_id = client_handler.check_order_status(&lsp_node_id, order_id); pending_check_order_status_requests_lock.insert(request_id, request_sender); } @@ -404,18 +214,230 @@ where Ok(response) } + + pub(crate) async fn handle_event(&self, event: LSPS1ClientEvent) { + match event { + LSPS1ClientEvent::SupportedOptionsReady { + request_id, + counterparty_node_id, + supported_options, + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = self + .pending_opening_params_requests + .lock() + .expect("lock") + .remove(&request_id) + { + let response = LSPS1OpeningParamsResponse { supported_options }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!( + self.logger, + "Received unexpected LSPS1Client::SupportedOptionsReady event!" + ); + } + }, + LSPS1ClientEvent::OrderCreated { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = + self.pending_create_order_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS1OrderStatus { + order_id, + order_params: order, + payment_options: payment.into(), + channel_state: channel, + counterparty_node_id, + }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!(self.logger, "Received unexpected LSPS1Client::OrderCreated event!"); + } + }, + LSPS1ClientEvent::OrderStatus { + request_id, + counterparty_node_id, + order_id, + order, + payment, + channel, + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = self + .pending_check_order_status_requests + .lock() + .expect("lock") + .remove(&request_id) + { + let response = LSPS1OrderStatus { + order_id, + order_params: order, + payment_options: payment.into(), + channel_state: channel, + counterparty_node_id, + }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!(self.logger, "Received unexpected LSPS1Client::OrderStatus event!"); + } + }, + _ => { + log_error!(self.logger, "Received unexpected LSPS1Client liquidity event!"); + }, + } + } + + async fn get_lsps1_node( + &self, override_node_id: Option<&PublicKey>, + ) -> Result { + if let Some(node) = select_lsps_for_protocol(&self.lsp_nodes, 1, override_node_id) { + return Ok(node); + } + + let has_undiscovered_protocol = + self.lsp_nodes.read().expect("lock").iter().any(|n| n.supported_protocols.is_none()); + + // LSP protocol discovery may still be in flight, we wait briefly for it to finish, then re-check. + if has_undiscovered_protocol && !*self.discovery_done_rx.borrow() { + log_debug!( + self.logger, + "No LSPS1 node available yet, waiting for protocol discovery to complete." + ); + let mut rx = self.discovery_done_rx.clone(); + let _ = tokio::time::timeout( + Duration::from_secs(LSPS_DISCOVERY_WAIT_TIMEOUT_SECS), + rx.wait_for(|done| *done), + ) + .await; + } + + select_lsps_for_protocol(&self.lsp_nodes, 1, override_node_id) + .ok_or(Error::LiquiditySourceUnavailable) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS1OpeningParamsResponse { + supported_options: LSPS1Options, } +/// Represents the status of an LSPS1 channel request. +#[derive(Debug, Clone)] +pub struct LSPS1OrderStatus { + /// The id of the channel order. + pub order_id: LSPS1OrderId, + /// The parameters of channel order. + pub order_params: LSPS1OrderParams, + /// Contains details about how to pay for the order. + pub payment_options: LSPS1PaymentInfo, + /// Contains information about the channel state. + pub channel_state: Option, + /// The node id of the LSP. + pub counterparty_node_id: PublicKey, +} + +#[cfg(not(feature = "uniffi"))] +type LSPS1PaymentInfo = lightning_liquidity::lsps1::msgs::LSPS1PaymentInfo; + +#[cfg(feature = "uniffi")] +type LSPS1PaymentInfo = crate::ffi::LSPS1PaymentInfo; + /// A liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. /// -/// Should be retrieved by calling [`Node::lsps1_liquidity`]. +/// Should be retrieved by calling [`Node::liquidity`]. /// /// To open [bLIP-52 / LSPS2] JIT channels, please refer to /// [`Bolt11Payment::receive_via_jit_channel`]. /// /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md /// [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md -/// [`Node::lsps1_liquidity`]: crate::Node::lsps1_liquidity +/// [`Node::liquidity`]: crate::Node::liquidity /// [`Bolt11Payment::receive_via_jit_channel`]: crate::payment::Bolt11Payment::receive_via_jit_channel #[derive(Clone)] #[cfg_attr(feature = "uniffi", derive(uniffi::Object))] @@ -423,7 +445,7 @@ pub struct LSPS1Liquidity { runtime: Arc, wallet: Arc, connection_manager: Arc>>, - liquidity_source: Option>>>, + liquidity_source: Arc>>, logger: Arc, } @@ -431,7 +453,7 @@ impl LSPS1Liquidity { pub(crate) fn new( runtime: Arc, wallet: Arc, connection_manager: Arc>>, - liquidity_source: Option>>>, logger: Arc, + liquidity_source: Arc>>, logger: Arc, ) -> Self { Self { runtime, wallet, connection_manager, liquidity_source, logger } } @@ -443,18 +465,19 @@ impl LSPS1Liquidity { /// /// The channel will be opened after one of the returned payment options has successfully been /// paid. + /// + /// If `node_id` is `None` and multiple LSPs support LSPS1, the first one registered + /// via [`crate::Builder::add_liquidity_source`] or [`crate::Liquidity::add_liquidity_source`] is used. pub fn request_channel( &self, lsp_balance_sat: u64, client_balance_sat: u64, channel_expiry_blocks: u32, - announce_channel: bool, + announce_channel: bool, node_id: Option, ) -> Result { - let liquidity_source = - self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let (lsp_node_id, lsp_address) = - liquidity_source.get_lsps1_lsp_details().ok_or(Error::LiquiditySourceUnavailable)?; + let lsps1_node = self + .runtime + .block_on(async { self.liquidity_source.get_lsps1_node(node_id.as_ref()).await })?; - let con_node_id = lsp_node_id; - let con_addr = lsp_address.clone(); + let con_node_id = lsps1_node.node_id; + let con_addr = lsps1_node.address.clone(); let con_cm = Arc::clone(&self.connection_manager); // We need to use our main runtime here as a local runtime might not be around to poll @@ -463,11 +486,11 @@ impl LSPS1Liquidity { con_cm.connect_peer_if_necessary(con_node_id, con_addr).await })?; - log_info!(self.logger, "Connected to LSP {}@{}. ", lsp_node_id, lsp_address); + log_info!(self.logger, "Connected to LSP {}@{}. ", lsps1_node.node_id, lsps1_node.address); let refund_address = self.wallet.get_new_address()?; - let liquidity_source = Arc::clone(&liquidity_source); + let liquidity_source = Arc::clone(&self.liquidity_source); let response = self.runtime.block_on(async move { liquidity_source .lsps1_request_channel( @@ -476,6 +499,7 @@ impl LSPS1Liquidity { channel_expiry_blocks, announce_channel, refund_address, + &con_node_id, ) .await })?; @@ -483,16 +507,16 @@ impl LSPS1Liquidity { Ok(response) } - /// Connects to the configured LSP and checks for the status of a previously-placed order. - pub fn check_order_status(&self, order_id: LSPS1OrderId) -> Result { - let liquidity_source = - self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let (lsp_node_id, lsp_address) = - liquidity_source.get_lsps1_lsp_details().ok_or(Error::LiquiditySourceUnavailable)?; + /// Connects to the configured LSP and checks for the status of a previously-placed order with the given node ID. + pub fn check_order_status( + &self, order_id: LSPS1OrderId, lsp_node_id: PublicKey, + ) -> Result { + let lsps1_node = self + .runtime + .block_on(async { self.liquidity_source.get_lsps1_node(Some(&lsp_node_id)).await })?; - let con_node_id = lsp_node_id; - let con_addr = lsp_address.clone(); + let con_node_id = lsps1_node.node_id; + let con_addr = lsps1_node.address.clone(); let con_cm = Arc::clone(&self.connection_manager); // We need to use our main runtime here as a local runtime might not be around to poll @@ -501,10 +525,10 @@ impl LSPS1Liquidity { con_cm.connect_peer_if_necessary(con_node_id, con_addr).await })?; - let liquidity_source = Arc::clone(&liquidity_source); - let response = self - .runtime - .block_on(async move { liquidity_source.lsps1_check_order_status(order_id).await })?; + let liquidity_source = Arc::clone(&self.liquidity_source); + let response = self.runtime.block_on(async move { + liquidity_source.lsps1_check_order_status(order_id, lsp_node_id).await + })?; Ok(response) } } diff --git a/src/liquidity/client/lsps2.rs b/src/liquidity/client/lsps2.rs index befd5e1362..3033f8d827 100644 --- a/src/liquidity/client/lsps2.rs +++ b/src/liquidity/client/lsps2.rs @@ -7,183 +7,76 @@ use std::collections::HashMap; use std::ops::Deref; -use std::sync::Mutex; +use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; use bitcoin::secp256k1::{PublicKey, Secp256k1}; use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; -use lightning::ln::msgs::SocketAddress; +use lightning::log_warn; use lightning::routing::router::{RouteHint, RouteHintHop}; use lightning::util::ser::Writeable; use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, RoutingFees}; use lightning_liquidity::lsps0::ser::LSPSRequestId; -use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; use lightning_liquidity::lsps2::event::LSPS2ClientEvent; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::PaymentHash; use tokio::sync::oneshot; +use tokio::task::JoinSet; +use crate::connection::ConnectionManager; +use crate::liquidity::{ + select_all_lsps_for_protocol, select_lsps_for_protocol, LspConfig, LspNode, + LIQUIDITY_REQUEST_TIMEOUT_SECS, LSPS_DISCOVERY_WAIT_TIMEOUT_SECS, +}; use crate::logger::{log_debug, log_error, log_info, LdkLogger}; use crate::payment::store::LSPS2Parameters; use crate::payment::PaymentMetadata; -use crate::Error; +use crate::types::{ChannelManager, KeysManager, LiquidityManager}; +use crate::{Config, Error}; -use super::super::{LiquiditySource, LIQUIDITY_REQUEST_TIMEOUT_SECS}; - -pub(crate) struct LSPS2Client { - pub(crate) lsp_node_id: PublicKey, - pub(crate) lsp_address: SocketAddress, - pub(crate) token: Option, - pub(crate) ldk_client_config: LdkLSPS2ClientConfig, - pub(crate) pending_fee_requests: +pub(crate) struct LSPS2Client +where + L::Target: LdkLogger, +{ + pub(crate) lsp_nodes: Arc>>, + pub(crate) pending_lsps2_fee_requests: Mutex>>, pub(crate) pending_buy_requests: Mutex>>, + pub(crate) channel_manager: Arc, + pub(crate) keys_manager: Arc, + pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, + pub(crate) liquidity_manager: Arc, + pub(crate) config: Arc, + pub(crate) logger: L, } -impl LSPS2Client { - pub(crate) async fn handle_event(&self, event: LSPS2ClientEvent, logger: &L) - where - L::Target: LdkLogger, - { - match event { - LSPS2ClientEvent::OpeningParametersReady { - request_id, - counterparty_node_id, - opening_fee_params_menu, - } => { - if counterparty_node_id != self.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = - self.pending_fee_requests.lock().expect("lock").remove(&request_id) - { - let response = LSPS2FeeResponse { opening_fee_params_menu }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - logger, - "Received response from liquidity service for unknown request." - ); - } - }, - LSPS2ClientEvent::InvoiceParametersReady { - request_id, - counterparty_node_id, - intercept_scid, - cltv_expiry_delta, - .. - } => { - if counterparty_node_id != self.lsp_node_id { - debug_assert!( - false, - "Received response from unexpected LSP counterparty. This should never happen." - ); - log_error!( - logger, - "Received response from unexpected LSP counterparty. This should never happen." - ); - return; - } - - if let Some(sender) = - self.pending_buy_requests.lock().expect("lock").remove(&request_id) - { - let response = LSPS2BuyResponse { intercept_scid, cltv_expiry_delta }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - logger, - "Received response from liquidity service for unknown request." - ); - } - }, - _ => { - log_error!(logger, "Received unexpected LSPS2Client liquidity event!"); - }, - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS2ClientConfig { - pub node_id: PublicKey, - pub address: SocketAddress, - pub token: Option, -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS2FeeResponse { - pub(crate) opening_fee_params_menu: Vec, -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS2BuyResponse { - pub(crate) intercept_scid: u64, - pub(crate) cltv_expiry_delta: u32, -} - -impl LiquiditySource +impl LSPS2Client where L::Target: LdkLogger, { - pub(crate) fn get_lsps2_lsp_details(&self) -> Option<(PublicKey, SocketAddress)> { - self.lsps2_client.as_ref().map(|s| (s.lsp_node_id, s.lsp_address.clone())) - } - pub(crate) async fn lsps2_receive_to_jit_channel( - &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_total_lsp_fee_limit_msat: Option, payment_hash: Option, - ) -> Result { - let fee_response = self.lsps2_request_opening_fee_params().await?; - - let (min_total_fee_msat, min_opening_params) = fee_response - .opening_fee_params_menu + self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, + expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, + payment_hash: Option, connection_manager: Arc>, + ) -> Result<(Bolt11Invoice, LspConfig), Error> { + // Connect to all candidate LSPs before querying fees. + let all_offers = self.gather_lsps2_offers(&connection_manager).await?; + let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers .into_iter() - .filter_map(|params| { + .flat_map(|(lsp, resp)| { + resp.opening_fee_params_menu + .into_iter() + .map(move |params| (lsp.clone(), params)) + }) + .filter_map(|(lsp, params)| { if amount_msat < params.min_payment_size_msat || amount_msat > params.max_payment_size_msat { log_debug!(self.logger, - "Skipping LSP-offered JIT parameters as the payment of {}msat doesn't meet LSP limits (min: {}msat, max: {}msat)", + "Skipping LSP {}'s JIT offer as the payment of {}msat doesn't meet LSP limits (min: {}msat, max: {}msat)", + lsp.node_id, amount_msat, params.min_payment_size_msat, params.max_payment_size_msat @@ -191,10 +84,10 @@ where None } else { compute_opening_fee(amount_msat, params.min_fee_msat, params.proportional as u64) - .map(|fee| (fee, params)) + .map(|fee| (lsp, fee, params)) } }) - .min_by_key(|p| p.0) + .min_by_key(|(_, fee, _)| *fee) .ok_or_else(|| { log_error!(self.logger, "Failed to handle response from liquidity service",); Error::LiquidityRequestFailed @@ -212,16 +105,23 @@ where log_debug!( self.logger, - "Choosing cheapest liquidity offer, will pay {}msat in total LSP fees", + "Choosing cheapest liquidity offer from LSP {}, will pay {}msat in total LSP fees", + cheapest_lsp.node_id, min_total_fee_msat ); - let buy_response = - self.lsps2_send_buy_request(Some(amount_msat), min_opening_params).await?; + let buy_response = self + .lsps2_send_buy_request( + Some(amount_msat), + min_opening_params, + Some(&cheapest_lsp.node_id), + ) + .await?; let lsps2_parameters = LSPS2Parameters { max_total_opening_fee_msat: Some(min_total_fee_msat), max_proportional_opening_fee_ppm_msat: None, }; + let invoice = self.lsps2_create_jit_invoice( buy_response, Some(amount_msat), @@ -229,23 +129,30 @@ where expiry_secs, payment_hash, lsps2_parameters, + Some(&cheapest_lsp.node_id), )?; log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok(invoice) + Ok((invoice, cheapest_lsp)) } pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( - &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, + self: Arc, description: &Bolt11InvoiceDescription, expiry_secs: u32, max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, - ) -> Result { - let fee_response = self.lsps2_request_opening_fee_params().await?; - - let (min_prop_fee_ppm_msat, min_opening_params) = fee_response - .opening_fee_params_menu + connection_manager: Arc>, + ) -> Result<(Bolt11Invoice, LspConfig), Error> { + // Connect to all candidate LSPs before querying fees. + let all_offers = self.gather_lsps2_offers(&connection_manager).await?; + let (cheapest_lsp, min_prop_fee_ppm_msat, min_opening_params) = all_offers .into_iter() - .map(|params| (params.proportional as u64, params)) - .min_by_key(|p| p.0) + .flat_map(|(lsp, resp)| { + resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params)) + }) + .map(|(lsp, params)| { + let ppm = params.proportional as u64; + (lsp, ppm, params) + }) + .min_by_key(|(_, ppm, _)| *ppm) .ok_or_else(|| { log_error!(self.logger, "Failed to handle response from liquidity service",); Error::LiquidityRequestFailed @@ -266,11 +173,14 @@ where log_debug!( self.logger, - "Choosing cheapest liquidity offer, will pay {}ppm msat in proportional LSP fees", + "Choosing cheapest liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees", + cheapest_lsp.node_id, min_prop_fee_ppm_msat ); - let buy_response = self.lsps2_send_buy_request(None, min_opening_params).await?; + let buy_response = self + .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) + .await?; let lsps2_parameters = LSPS2Parameters { max_total_opening_fee_msat: None, max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), @@ -282,14 +192,69 @@ where expiry_secs, payment_hash, lsps2_parameters, + Some(&cheapest_lsp.node_id), )?; log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok(invoice) + Ok((invoice, cheapest_lsp)) } - async fn lsps2_request_opening_fee_params(&self) -> Result { - let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + async fn gather_lsps2_offers( + self: &Arc, connection_manager: &Arc>, + ) -> Result, Error> { + let lsps2_nodes = self.get_lsps2_nodes().await?; + + // Connect to all candidate LSPs in parallel. + let mut connect_set = JoinSet::new(); + for lsp_node in &lsps2_nodes { + let cm = Arc::clone(connection_manager); + let node_id = lsp_node.node_id; + let addr = lsp_node.address.clone(); + let logger = self.logger.clone(); + connect_set.spawn(async move { + if let Err(e) = cm.connect_peer_if_necessary(node_id, addr).await { + log_warn!(logger, "Failed to connect to LSP {} for fee query: {}", node_id, e); + } + }); + } + while connect_set.join_next().await.is_some() {} + + let mut all_offers: Vec<(LspConfig, LSPS2FeeResponse)> = + Vec::with_capacity(lsps2_nodes.len()); + let mut fee_set: JoinSet<(LspConfig, Result)> = JoinSet::new(); + for lsp_node in &lsps2_nodes { + let lsp = lsp_node.clone(); + let client = Arc::clone(self); + fee_set.spawn(async move { + let res = client.lsps2_request_opening_fee_params(Some(&lsp.node_id)).await; + (lsp, res) + }); + } + while let Some(join_result) = fee_set.join_next().await { + match join_result { + Ok((lsp, Ok(fees))) => all_offers.push((lsp, fees)), + Ok((lsp, Err(e))) => { + log_warn!(self.logger, "Failed to get fees from LSP {}: {}", lsp.node_id, e) + }, + Err(e) => { + log_warn!(self.logger, "Failed to get fees from LSP: {}", e) + }, + } + } + + Ok(all_offers) + } +} + +impl LSPS2Client +where + L::Target: LdkLogger, +{ + async fn lsps2_request_opening_fee_params( + &self, node_id: Option<&PublicKey>, + ) -> Result { + let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) + .ok_or(Error::LiquiditySourceUnavailable)?; let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { log_error!(self.logger, "Liquidity client was not configured.",); @@ -299,9 +264,9 @@ where let (fee_request_sender, fee_request_receiver) = oneshot::channel(); { let mut pending_fee_requests_lock = - lsps2_client.pending_fee_requests.lock().expect("lock"); - let request_id = client_handler - .request_opening_params(lsps2_client.lsp_node_id, lsps2_client.token.clone()); + self.pending_lsps2_fee_requests.lock().expect("lock"); + let request_id = + client_handler.request_opening_params(lsps2_node.node_id, lsps2_node.token.clone()); pending_fee_requests_lock.insert(request_id, fee_request_sender); } @@ -322,8 +287,10 @@ where async fn lsps2_send_buy_request( &self, amount_msat: Option, opening_fee_params: LSPS2OpeningFeeParams, + node_id: Option<&PublicKey>, ) -> Result { - let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) + .ok_or(Error::LiquiditySourceUnavailable)?; let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { log_error!(self.logger, "Liquidity client was not configured.",); @@ -332,10 +299,9 @@ where let (buy_request_sender, buy_request_receiver) = oneshot::channel(); { - let mut pending_buy_requests_lock = - lsps2_client.pending_buy_requests.lock().expect("lock"); + let mut pending_buy_requests_lock = self.pending_buy_requests.lock().expect("lock"); let request_id = client_handler - .select_opening_params(lsps2_client.lsp_node_id, amount_msat, opening_fee_params) + .select_opening_params(lsps2_node.node_id, amount_msat, opening_fee_params) .map_err(|e| { log_error!( self.logger, @@ -368,8 +334,10 @@ where &self, buy_response: LSPS2BuyResponse, amount_msat: Option, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: Option, lsps2_parameters: LSPS2Parameters, + node_id: Option<&PublicKey>, ) -> Result { - let lsps2_client = self.lsps2_client.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; + let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) + .ok_or(Error::LiquiditySourceUnavailable)?; // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; @@ -407,7 +375,7 @@ where }; let route_hint = RouteHint(vec![RouteHintHop { - src_node_id: lsps2_client.lsp_node_id, + src_node_id: lsps2_node.node_id, short_channel_id: buy_response.intercept_scid, fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, cltv_expiry_delta: buy_response.cltv_expiry_delta as u16, @@ -445,4 +413,141 @@ where Error::InvoiceCreationFailed }) } + + pub(crate) async fn handle_event(&self, event: LSPS2ClientEvent) { + match event { + LSPS2ClientEvent::OpeningParametersReady { + request_id, + counterparty_node_id, + opening_fee_params_menu, + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = + self.pending_lsps2_fee_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS2FeeResponse { opening_fee_params_menu }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!( + self.logger, + "Received unexpected LSPS2Client::OpeningParametersReady event!" + ); + } + }, + LSPS2ClientEvent::InvoiceParametersReady { + request_id, + counterparty_node_id, + intercept_scid, + cltv_expiry_delta, + .. + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = + self.pending_buy_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS2BuyResponse { intercept_scid, cltv_expiry_delta }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!( + self.logger, + "Received unexpected LSPS2Client::InvoiceParametersReady event!" + ); + } + }, + _ => { + log_error!(self.logger, "Received unexpected LSPS2Client liquidity event!"); + }, + } + } + + async fn get_lsps2_nodes(&self) -> Result, Error> { + let has_undiscovered_protocol = + self.lsp_nodes.read().expect("lock").iter().any(|n| n.supported_protocols.is_none()); + + if has_undiscovered_protocol { + // LSP protocol discovery is still in flight, we wait briefly for it to finish, then re-check. + let mut rx = self.discovery_done_rx.clone(); + if !*rx.borrow() { + log_debug!( + self.logger, + "Waiting for LSP protocol discovery to complete before selecting LSPS2 nodes." + ); + let _ = tokio::time::timeout( + Duration::from_secs(LSPS_DISCOVERY_WAIT_TIMEOUT_SECS), + rx.wait_for(|done| *done), + ) + .await; + } + } + + let lsps2_nodes = select_all_lsps_for_protocol(&self.lsp_nodes, 2); + if lsps2_nodes.is_empty() { + log_error!(self.logger, "No LSPs available for LSPS2 protocol."); + return Err(Error::LiquiditySourceUnavailable); + }; + Ok(lsps2_nodes) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS2FeeResponse { + opening_fee_params_menu: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS2BuyResponse { + intercept_scid: u64, + cltv_expiry_delta: u32, } diff --git a/src/liquidity/client/mod.rs b/src/liquidity/client/mod.rs index 2a236d492b..15ca7e9650 100644 --- a/src/liquidity/client/mod.rs +++ b/src/liquidity/client/mod.rs @@ -1,9 +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; +// 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/mod.rs b/src/liquidity/mod.rs index fae3f0875f..c2cdb4de0f 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -10,39 +10,197 @@ pub(crate) mod client; pub(crate) mod service; +pub use client::lsps1::LSPS1Liquidity; +pub use client::LSPS1OrderStatus; +pub use service::lsps2::LSPS2ServiceConfig; + +use std::collections::hash_map::Entry; use std::collections::HashMap; use std::ops::Deref; -use std::sync::{Arc, Mutex, RwLock, Weak}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; use bitcoin::secp256k1::PublicKey; use lightning::ln::msgs::SocketAddress; use lightning_liquidity::events::LiquidityEvent; +use lightning_liquidity::lsps0::event::LSPS0ClientEvent; use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfig; use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; +use tokio::sync::oneshot; use crate::builder::BuildError; -use crate::logger::{log_error, LdkLogger}; -use crate::types::{ - Broadcaster, ChannelManager, DynStore, KeysManager, LiquidityManager, PeerManager, Wallet, -}; -use crate::Config; - -pub(crate) use client::lsps1::{LSPS1Client, LSPS1ClientConfig}; -pub use client::lsps1::{LSPS1Liquidity, LSPS1OrderStatus}; -pub(crate) use client::lsps2::{LSPS2Client, LSPS2ClientConfig}; -pub(crate) use service::lsps2::LSPS2Service; -pub use service::lsps2::LSPS2ServiceConfig; +use crate::connection::ConnectionManager; +use crate::liquidity::client::lsps1::LSPS1Client; +use crate::liquidity::client::lsps2::LSPS2Client; +use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; +use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; +use crate::runtime::Runtime; +use crate::types::{Broadcaster, ChannelManager, DynStore, KeysManager, LiquidityManager, Wallet}; +use crate::{Config, Error}; + +const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; +const LSPS_DISCOVERY_WAIT_TIMEOUT_SECS: u64 = 10; + +fn select_lsps_for_protocol( + lsp_nodes: &Arc>>, protocol: u16, override_node_id: Option<&PublicKey>, +) -> Option { + lsp_nodes + .read() + .expect("lock") + .iter() + .find(|lsp_node| { + if let Some(override_node_id) = override_node_id { + lsp_node.node_id == *override_node_id + && lsp_node.supported_protocols.as_ref().is_some_and(|p| p.contains(&protocol)) + } else { + lsp_node.supported_protocols.as_ref().is_some_and(|p| p.contains(&protocol)) + } + }) + .map(|n| LspConfig { + node_id: n.node_id, + address: n.address.clone(), + token: n.token.clone(), + trust_peer_0conf: n.trust_peer_0conf, + }) +} -pub(crate) const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; +fn select_all_lsps_for_protocol( + lsp_nodes: &Arc>>, protocol: u16, +) -> Vec { + lsp_nodes + .read() + .expect("lock") + .iter() + .filter(|lsp_node| { + lsp_node.supported_protocols.as_ref().is_some_and(|p| p.contains(&protocol)) + }) + .map(|n| LspConfig { + node_id: n.node_id, + address: n.address.clone(), + token: n.token.clone(), + trust_peer_0conf: n.trust_peer_0conf, + }) + .collect() +} + +/// A liquidity handler allowing to manage LSP connections and request channels. +/// +/// Should be retrieved by calling [`Node::liquidity`]. +/// +/// [`Node::liquidity`]: crate::Node::liquidity +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct Liquidity { + runtime: Arc, + wallet: Arc, + connection_manager: Arc>>, + liquidity_source: Arc>>, + logger: Arc, +} + +impl Liquidity { + pub(crate) fn new( + runtime: Arc, wallet: Arc, + connection_manager: Arc>>, + liquidity_source: Arc>>, logger: Arc, + ) -> Self { + Self { runtime, wallet, connection_manager, liquidity_source, logger } + } +} + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl Liquidity { + /// Adds an LSP as an inbound liquidity source at runtime. + /// + /// The given `token` will be used by the LSP to authenticate the user. + /// `trust_peer_0conf` controls whether the node will accept 0-confirmation channels opened by this + /// LSP. Note this supersedes [`Config::trusted_peers_0conf`] for this peer. + /// Duplicate `node_id`s are ignored. + pub fn add_liquidity_source( + &self, node_id: PublicKey, address: SocketAddress, token: Option, + trust_peer_0conf: bool, + ) -> Result<(), Error> { + { + let mut lsp_nodes = self.liquidity_source.lsp_nodes.write().expect("lock"); + if lsp_nodes.iter().any(|n| n.node_id == node_id) { + log_info!(self.logger, "LSP node {} already added, skipping.", node_id); + return Ok(()); + } + + lsp_nodes.push(LspNode { + node_id, + address: address.clone(), + token: token.clone(), + trust_peer_0conf, + supported_protocols: None, + }); + } + + // If anything below fails, drop the half-initialized entry so the user can retry cleanly. + let lsp_nodes = Arc::clone(&self.liquidity_source.lsp_nodes); + let cleanup = move || { + lsp_nodes.write().expect("lock").retain(|n| n.node_id != node_id); + }; + + let con_cm = Arc::clone(&self.connection_manager); + let connect_addr = address.clone(); + if let Err(e) = self + .runtime + .block_on(async move { con_cm.connect_peer_if_necessary(node_id, connect_addr).await }) + { + cleanup(); + return Err(e); + } + log_info!(self.logger, "Connected to LSP {}@{}.", node_id, address); + + if let Err(e) = self + .runtime + .block_on(async { self.liquidity_source.discover_lsp_protocols(&node_id).await }) + { + cleanup(); + return Err(e); + } + + Ok(()) + } + + /// Returns a liquidity handler allowing to request channels via the [bLIP-51 / LSPS1] protocol. + /// + /// [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md + pub fn lsps1(&self) -> LSPS1Liquidity { + LSPS1Liquidity::new( + Arc::clone(&self.runtime), + Arc::clone(&self.wallet), + Arc::clone(&self.connection_manager), + self.liquidity_source.lsps1_client(), + Arc::clone(&self.logger), + ) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct LspConfig { + pub node_id: PublicKey, + pub address: SocketAddress, + pub token: Option, + pub trust_peer_0conf: bool, +} + +pub(crate) struct LspNode { + node_id: PublicKey, + address: SocketAddress, + token: Option, + trust_peer_0conf: bool, + // Protocol numbers discovered via LSPS0 (e.g., 1 = LSPS1, 2 = LSPS2, 5 = LSPS5). + supported_protocols: Option>, +} pub(crate) struct LiquiditySourceBuilder where L::Target: LdkLogger, { - lsps1_client: Option, - lsps2_client: Option, + lsp_nodes: Vec, lsps2_service: Option, wallet: Arc, channel_manager: Arc, @@ -53,7 +211,7 @@ where logger: L, } -impl LiquiditySourceBuilder +impl LiquiditySourceBuilder where L::Target: LdkLogger, { @@ -61,12 +219,10 @@ where wallet: Arc, channel_manager: Arc, keys_manager: Arc, tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: L, ) -> Self { - let lsps1_client = None; - let lsps2_client = None; + let lsp_nodes = Vec::new(); let lsps2_service = None; Self { - lsps1_client, - lsps2_client, + lsp_nodes, lsps2_service, wallet, channel_manager, @@ -78,40 +234,8 @@ where } } - pub(crate) fn lsps1_client( - &mut self, lsp_node_id: PublicKey, lsp_address: SocketAddress, token: Option, - ) -> &mut Self { - // TODO: allow to set max_channel_fees_msat - let ldk_client_config = LdkLSPS1ClientConfig { max_channel_fees_msat: None }; - let pending_opening_params_requests = Mutex::new(HashMap::new()); - let pending_create_order_requests = Mutex::new(HashMap::new()); - let pending_check_order_status_requests = Mutex::new(HashMap::new()); - self.lsps1_client = Some(LSPS1Client { - lsp_node_id, - lsp_address, - token, - ldk_client_config, - pending_opening_params_requests, - pending_create_order_requests, - pending_check_order_status_requests, - }); - self - } - - pub(crate) fn lsps2_client( - &mut self, lsp_node_id: PublicKey, lsp_address: SocketAddress, token: Option, - ) -> &mut Self { - let ldk_client_config = LdkLSPS2ClientConfig {}; - let pending_fee_requests = Mutex::new(HashMap::new()); - let pending_buy_requests = Mutex::new(HashMap::new()); - self.lsps2_client = Some(LSPS2Client { - lsp_node_id, - lsp_address, - token, - ldk_client_config, - pending_fee_requests, - pending_buy_requests, - }); + pub(crate) fn set_lsp_nodes(&mut self, lsp_nodes: Vec) -> &mut Self { + self.lsp_nodes = lsp_nodes; self } @@ -136,13 +260,14 @@ where } }); - let lsps1_client_config = self.lsps1_client.as_ref().map(|s| s.ldk_client_config.clone()); - let lsps2_client_config = self.lsps2_client.as_ref().map(|s| s.ldk_client_config.clone()); - let lsps5_client_config = None; + let (discovery_done_tx, discovery_done_rx) = tokio::sync::watch::channel(false); + + // Adding LSPS at runtime is now supported, so we create the client + // config regardless of whether LSPs exist at build time let liquidity_client_config = Some(LiquidityClientConfig { - lsps1_client_config, - lsps2_client_config, - lsps5_client_config, + lsps1_client_config: Some(LdkLSPS1ClientConfig { max_channel_fees_msat: None }), + lsps2_client_config: Some(LdkLSPS2ClientConfig {}), + lsps5_client_config: None, }); let liquidity_manager = Arc::new( @@ -159,16 +284,55 @@ where .map_err(|_| BuildError::ReadFailed)?, ); + let lsp_nodes = Arc::new(RwLock::new( + self.lsp_nodes + .into_iter() + .map(|cfg| LspNode { + node_id: cfg.node_id, + address: cfg.address, + token: cfg.token, + trust_peer_0conf: cfg.trust_peer_0conf, + supported_protocols: None, + }) + .collect(), + )); + Ok(LiquiditySource { - lsps1_client: self.lsps1_client, - lsps2_client: self.lsps2_client, - lsps2_service: self.lsps2_service, - wallet: self.wallet, - channel_manager: self.channel_manager, - peer_manager: RwLock::new(None), - keys_manager: self.keys_manager, + lsp_nodes: Arc::clone(&lsp_nodes), + lsps1_client: Arc::new(LSPS1Client { + lsp_nodes: Arc::clone(&lsp_nodes), + pending_opening_params_requests: Mutex::new(HashMap::new()), + pending_create_order_requests: Mutex::new(HashMap::new()), + pending_check_order_status_requests: Mutex::new(HashMap::new()), + discovery_done_rx: discovery_done_rx.clone(), + liquidity_manager: Arc::clone(&liquidity_manager), + logger: self.logger.clone(), + }), + lsps2_client: Arc::new(LSPS2Client { + lsp_nodes: Arc::clone(&lsp_nodes), + pending_lsps2_fee_requests: Mutex::new(HashMap::new()), + pending_buy_requests: Mutex::new(HashMap::new()), + channel_manager: self.channel_manager.clone(), + keys_manager: self.keys_manager.clone(), + discovery_done_rx: discovery_done_rx.clone(), + liquidity_manager: Arc::clone(&liquidity_manager), + config: self.config.clone(), + logger: self.logger.clone(), + }), + lsps2_service: Arc::new(LSPS2ServiceLiquiditySource { + lsps2_service: self.lsps2_service, + wallet: self.wallet, + channel_manager: self.channel_manager, + peer_manager: RwLock::new(None), + keys_manager: self.keys_manager, + liquidity_manager: Arc::clone(&liquidity_manager), + config: self.config.clone(), + logger: self.logger.clone(), + }), + pending_lsps0_discovery: Mutex::new(HashMap::new()), + discovery_done_tx, + discovery_done_rx, liquidity_manager, - config: self.config, logger: self.logger, }) } @@ -178,15 +342,14 @@ pub(crate) struct LiquiditySource where L::Target: LdkLogger, { - lsps1_client: Option, - lsps2_client: Option, - lsps2_service: Option, - wallet: Arc, - channel_manager: Arc, - peer_manager: RwLock>>, - keys_manager: Arc, + lsp_nodes: Arc>>, + lsps1_client: Arc>, + lsps2_client: Arc>, + lsps2_service: Arc>, + pending_lsps0_discovery: Mutex>>>, + discovery_done_tx: tokio::sync::watch::Sender, + discovery_done_rx: tokio::sync::watch::Receiver, liquidity_manager: Arc, - config: Arc, logger: L, } @@ -194,46 +357,61 @@ impl LiquiditySource 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 lsps1_client(&self) -> Arc> { + Arc::clone(&self.lsps1_client) + } + + pub(crate) fn lsps2_client(&self) -> Arc> { + Arc::clone(&self.lsps2_client) + } + + pub(crate) fn lsps2_service(&self) -> Arc> { + Arc::clone(&self.lsps2_service) + } + pub(crate) async fn handle_next_event(&self) { match self.liquidity_manager.next_event_async().await { - LiquidityEvent::LSPS1Client(event) => { - if let Some(lsps1_client) = self.lsps1_client.as_ref() { - lsps1_client.handle_event(event, &self.logger).await; - } else { - log_error!(self.logger, "Received unexpected LSPS1Client event!"); - } - }, - LiquidityEvent::LSPS2Service(event) => { - if let Some(lsps2_service) = self.lsps2_service.as_ref() { - lsps2_service - .handle_event( - event, - &self.liquidity_manager, - &self.channel_manager, - &self.keys_manager, - &self.peer_manager, - &self.wallet, - &self.config, - &self.logger, - ) - .await; - } else { - log_error!(self.logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - } - }, - LiquidityEvent::LSPS2Client(event) => { - if let Some(lsps2_client) = self.lsps2_client.as_ref() { - lsps2_client.handle_event(event, &self.logger).await; + LiquidityEvent::LSPS1Client(event) => self.lsps1_client.handle_event(event).await, + LiquidityEvent::LSPS2Client(event) => self.lsps2_client.handle_event(event).await, + LiquidityEvent::LSPS2Service(event) => self.lsps2_service.handle_event(event).await, + + LiquidityEvent::LSPS0Client(LSPS0ClientEvent::ListProtocolsResponse { + counterparty_node_id, + protocols, + }) => { + if self.is_lsps_node(&counterparty_node_id) { + if let Some(sender) = self + .pending_lsps0_discovery + .lock() + .expect("lock") + .remove(&counterparty_node_id) + { + match sender.send(protocols) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + counterparty_node_id + ); + }, + } + } else { + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } } else { - log_error!(self.logger, "Received unexpected LSPS2Client event!"); + log_error!( + self.logger, + "Received LSPS0 ListProtocolsResponse from unexpected counterparty {}.", + counterparty_node_id + ); } }, e => { @@ -241,4 +419,110 @@ where }, } } + + pub(crate) fn is_lsps_node(&self, node_id: &PublicKey) -> bool { + self.lsp_nodes.read().expect("lock").iter().any(|n| n.node_id == *node_id) + } + + pub(crate) fn get_all_lsp_details(&self) -> Vec<(PublicKey, SocketAddress)> { + self.lsp_nodes + .read() + .expect("lock") + .iter() + .map(|n| (n.node_id, n.address.clone())) + .collect() + } + + pub(crate) async fn discover_lsp_protocols( + &self, node_id: &PublicKey, + ) -> Result, Error> { + let lsps0_handler = self.liquidity_manager.lsps0_client_handler(); + + let (sender, receiver) = oneshot::channel(); + { + let mut pending_discovery = self.pending_lsps0_discovery.lock().expect("lock"); + match pending_discovery.entry(*node_id) { + Entry::Occupied(_) => { + log_error!( + self.logger, + "LSPS0 protocol discovery already in flight for {}", + node_id + ); + return Err(Error::LiquidityRequestFailed); + }, + Entry::Vacant(v) => { + v.insert(sender); + lsps0_handler.list_protocols(node_id); + }, + } + } + + let protocols = + tokio::time::timeout(Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), receiver) + .await + .map_err(|e| { + log_error!( + self.logger, + "LSPS0 discovery request timed out for {}: {}", + node_id, + e + ); + self.pending_lsps0_discovery.lock().expect("lock").remove(node_id); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!( + self.logger, + "Failed to handle LSPS0 discovery response from {}: {}", + node_id, + e + ); + self.pending_lsps0_discovery.lock().expect("lock").remove(node_id); + Error::LiquidityRequestFailed + })?; + + if let Some(lsp_node) = + self.lsp_nodes.write().expect("lock").iter_mut().find(|n| &n.node_id == node_id) + { + lsp_node.supported_protocols = Some(protocols.clone()); + } + + Ok(protocols) + } + + pub(crate) async fn get_lsp_config( + &self, node_id: &PublicKey, protocol: u16, + ) -> Option { + if let Some(node) = select_lsps_for_protocol(&self.lsp_nodes, protocol, Some(node_id)) { + return Some(node); + } + + let has_undiscovered_protocol = + self.lsp_nodes.read().expect("lock").iter().any(|n| n.supported_protocols.is_none()); + + // LSP protocol discovery may still be in flight, we wait briefly for it to finish, then re-check. + if has_undiscovered_protocol && !*self.discovery_done_rx.borrow() { + log_debug!( + self.logger, + "LSP {} protocols not yet discovered, waiting for protocol discovery to complete.", + node_id + ); + let mut rx = self.discovery_done_rx.clone(); + let _ = tokio::time::timeout( + Duration::from_secs(LSPS_DISCOVERY_WAIT_TIMEOUT_SECS), + rx.wait_for(|done| *done), + ) + .await; + } + + select_lsps_for_protocol(&self.lsp_nodes, protocol, Some(node_id)) + } + + /// Flips the `discovery_done` watch to `true`. + /// + /// Called once after the *initial* batch of LSPs configured at build time has been + /// discovered by the background task spawned in `Node::start`. + pub(crate) fn mark_discovery_done(&self) { + let _ = self.discovery_done_tx.send(true); + } } diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index 67b4737a40..875438b0fb 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -1,499 +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}; - -use super::super::LiquiditySource; - -pub(crate) const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); -pub(crate) const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; - -pub(crate) struct LSPS2Service { - pub(crate) service_config: LSPS2ServiceConfig, - pub(crate) ldk_service_config: LdkLSPS2ServiceConfig, -} - -impl LSPS2Service { - pub(crate) async fn handle_event( - &self, event: LSPS2ServiceEvent, liquidity_manager: &Arc, - channel_manager: &Arc, keys_manager: &Arc, - peer_manager: &RwLock>>, wallet: &Arc, - config: &Arc, logger: &L, - ) where - L::Target: LdkLogger, - { - match event { - LSPS2ServiceEvent::GetInfo { request_id, counterparty_node_id, token } => { - if let Some(lsps2_service_handler) = - liquidity_manager.lsps2_service_handler().as_ref() - { - let service_config = self.service_config.clone(); - - if let Some(required) = service_config.require_token { - if token != Some(required) { - log_error!( - 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!( - 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!( - logger, - "Failed to handle generated opening fee params: {:?}", - e - ); - } - } else { - log_error!(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) = - liquidity_manager.lsps2_service_handler().as_ref() - { - let service_config = self.service_config.clone(); - - let user_channel_id: u128 = u128::from_ne_bytes( - keys_manager.get_secure_random_bytes()[..16] - .try_into() - .expect("a 16-byte slice should convert into a [u8; 16]"), - ); - let intercept_scid = 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!( - 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!(logger, "Failed to provide invoice parameters: {:?}", e); - return; - }, - } - } else { - log_error!(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 liquidity_manager.lsps2_service_handler().is_none() { - log_error!(logger, "Failed to handle LSPS2ServiceEvent as LSPS2 liquidity service was not configured.",); - return; - }; - - let service_config = self.service_config.clone(); - - let init_features = if let Some(Some(peer_manager)) = - 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!( - 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!(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(channel_manager, config); - let spendable_amount_sats = - wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); - let required_funds_sats = channel_amount_sats - + 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!(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 = 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 { - channel_manager.create_channel_to_trusted_peer_0reserve( - their_network_key, - channel_amount_sats, - 0, - user_channel_id, - None, - Some(config), - ) - } else { - 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!( - logger, - "Failed to open LSPS2 {}channel to {}: {:?}", - zero_reserve_string, - their_network_key, - e - ); - return; - }, - } - }, - } - } -} - -/// 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 LiquiditySource -where - L::Target: LdkLogger, -{ - 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, - ) { - if self.lsps2_service.as_ref().map_or(false, |svc| !svc.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, - ) { - if self.lsps2_service.as_ref().map_or(false, |svc| !svc.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 - ); - } - } - } - } -} +// 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 cdbaf54265..5e3a3b1833 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; diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 068269997f..74a7608013 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -70,7 +70,7 @@ pub struct Bolt11Payment { runtime: Arc, channel_manager: Arc, connection_manager: Arc>>, - liquidity_source: Option>>>, + liquidity_source: Arc>>, payment_store: Arc, peer_store: Arc>>, config: Arc, @@ -82,9 +82,9 @@ impl Bolt11Payment { pub(crate) fn new( runtime: Arc, channel_manager: Arc, connection_manager: Arc>>, - liquidity_source: Option>>>, - payment_store: Arc, peer_store: Arc>>, - config: Arc, is_running: Arc>, logger: Arc, + liquidity_source: Arc>>, payment_store: Arc, + peer_store: Arc>>, config: Arc, + is_running: Arc>, logger: Arc, ) -> Self { Self { runtime, @@ -168,45 +168,29 @@ impl Bolt11Payment { expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, ) -> Result { - let liquidity_source = - self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; - - let (node_id, address) = - liquidity_source.get_lsps2_lsp_details().ok_or(Error::LiquiditySourceUnavailable)?; - - let peer_info = PeerInfo { node_id, address }; - - let con_node_id = peer_info.node_id; - let con_addr = peer_info.address.clone(); - let con_cm = Arc::clone(&self.connection_manager); - - // We need to use our main runtime here as a local runtime might not be around to poll - // connection futures going forward. - self.runtime.block_on(async move { - con_cm.connect_peer_if_necessary(con_node_id, con_addr).await - })?; - - log_info!(self.logger, "Connected to LSP {}@{}. ", peer_info.node_id, peer_info.address); - - let liquidity_source = Arc::clone(&liquidity_source); - let invoice = self.runtime.block_on(async move { + let connection_manager = Arc::clone(&self.connection_manager); + let (invoice, chosen_lsp) = self.runtime.block_on(async move { if let Some(amount_msat) = amount_msat { - liquidity_source + self.liquidity_source + .lsps2_client() .lsps2_receive_to_jit_channel( amount_msat, description, expiry_secs, max_total_lsp_fee_limit_msat, payment_hash, + connection_manager, ) .await } else { - liquidity_source + self.liquidity_source + .lsps2_client() .lsps2_receive_variable_amount_to_jit_channel( description, expiry_secs, max_proportional_lsp_fee_limit_ppm_msat, payment_hash, + connection_manager, ) .await } @@ -241,7 +225,8 @@ impl Bolt11Payment { ); self.runtime.block_on(self.payment_store.insert(payment))?; - // Persist LSP peer to make sure we reconnect on restart. + // Persist the chosen LSP peer to make sure we reconnect on restart. + let peer_info = PeerInfo { node_id: chosen_lsp.node_id, address: chosen_lsp.address }; self.runtime.block_on(self.peer_store.add_peer(peer_info))?; Ok(invoice) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 91cc8f3620..2aacc5c972 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1813,7 +1813,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { let service_config = random_config(true); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); - service_builder.set_liquidity_provider_lsps2(lsps2_service_config); + service_builder.enable_liquidity_provider(lsps2_service_config); let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); service_node.start().unwrap(); @@ -1823,7 +1823,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { 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.set_liquidity_source_lsps2(service_node_id, service_addr, None); + 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(); @@ -2132,7 +2132,7 @@ async fn lsps2_client_trusts_lsp() { let service_config = random_config(true); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); - service_builder.set_liquidity_provider_lsps2(lsps2_service_config); + 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(); @@ -2141,7 +2141,7 @@ async fn lsps2_client_trusts_lsp() { 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.set_liquidity_source_lsps2(service_node_id, service_addr.clone(), None); + client_builder.add_liquidity_source(service_node_id, service_addr.clone(), 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(); @@ -2307,7 +2307,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { let service_config = random_config(true); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); - service_builder.set_liquidity_provider_lsps2(lsps2_service_config); + service_builder.enable_liquidity_provider(lsps2_service_config); let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); service_node.start().unwrap(); @@ -2317,7 +2317,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { 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.set_liquidity_source_lsps2(service_node_id, service_addr.clone(), None); + client_builder.add_liquidity_source(service_node_id, service_addr.clone(), None, true); let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); client_node.start().unwrap(); @@ -3021,3 +3021,98 @@ async fn splice_in_with_all_balance() { node_a.stop().unwrap(); node_b.stop().unwrap(); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn lsps2_multi_lsp_picks_cheapest() { + do_lsps2_multi_lsp_picks_cheapest(false).await; + do_lsps2_multi_lsp_picks_cheapest(true).await; +} + +async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { + 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; + + // Cheap LSP: 10_000 ppm. + let cheap_cfg = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm: 10_000, + channel_over_provisioning_ppm: 100_000, + max_payment_size_msat: 1_000_000_000, + min_payment_size_msat: 0, + min_channel_lifetime: 100, + min_channel_opening_fee_msat: 10, + max_client_to_self_delay: 1024, + client_trusts_lsp: true, + disable_client_reserve: false, + }; + let cheap_node_config = random_config(true); + setup_builder!(cheap_builder, cheap_node_config.node_config); + cheap_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + cheap_builder.enable_liquidity_provider(cheap_cfg); + let cheap = cheap_builder.build(cheap_node_config.node_entropy.into()).unwrap(); + cheap.start().unwrap(); + let cheap_id = cheap.node_id(); + let cheap_addr = cheap.listening_addresses().unwrap().first().unwrap().clone(); + + // Expensive LSP: 20_000 ppm. + let expensive_cfg = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm: 20_000, + channel_over_provisioning_ppm: 200_000, + max_payment_size_msat: 1_000_000_000, + min_payment_size_msat: 0, + min_channel_lifetime: 100, + min_channel_opening_fee_msat: 5, + max_client_to_self_delay: 1024, + client_trusts_lsp: true, + disable_client_reserve: false, + }; + let expensive_node_config = random_config(true); + setup_builder!(expensive_builder, expensive_node_config.node_config); + expensive_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + expensive_builder.enable_liquidity_provider(expensive_cfg); + let expensive = expensive_builder.build(expensive_node_config.node_entropy.into()).unwrap(); + expensive.start().unwrap(); + let expensive_id = expensive.node_id(); + let expensive_addr = expensive.listening_addresses().unwrap().first().unwrap().clone(); + + // Client knows both LSPs. Registration order is varied to confirm selection isn't order-based. + let client_config = random_config(true); + setup_builder!(client_builder, client_config.node_config); + client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + if reverse_order { + client_builder.add_liquidity_source(expensive_id, expensive_addr, None, true); + client_builder.add_liquidity_source(cheap_id, cheap_addr, None, true); + } else { + client_builder.add_liquidity_source(cheap_id, cheap_addr, None, true); + client_builder.add_liquidity_source(expensive_id, expensive_addr, None, true); + } + let client = client_builder.build(client_config.node_entropy.into()).unwrap(); + client.start().unwrap(); + + let invoice_description = + Bolt11InvoiceDescription::Direct(Description::new(String::from("asdf")).unwrap()).into(); + let jit_invoice = client + .bolt11_payment() + .receive_via_jit_channel(100_000_000, &invoice_description, 1024, None) + .unwrap(); + + // The route hint's src_node_id is the LSP the client picked. + let route_hints = jit_invoice.route_hints(); + let first_hint = route_hints.first().expect("JIT invoice should have a route hint"); + #[cfg(feature = "uniffi")] + let first_hop = first_hint.first(); + #[cfg(not(feature = "uniffi"))] + let first_hop = first_hint.0.first(); + let route_hint_src = first_hop.expect("route hint should have at least one hop").src_node_id; + assert_eq!(route_hint_src, cheap_id, "expected cheaper LSP to be selected."); + + client.stop().unwrap(); + cheap.stop().unwrap(); + expensive.stop().unwrap(); +} From 51912175940c9f2422c91fa2efe1ee12098b2c28 Mon Sep 17 00:00:00 2001 From: Camillarhi Date: Thu, 7 May 2026 10:41:41 +0100 Subject: [PATCH 011/138] Collapse NodeCustomMessageHandler enum into a struct Remove the `Ignoring` variant now that the liquidity source is always built, so the enum and its match arms are now pure overhead. Replace it with a struct that holds the `LiquiditySource` directly and have each trait method delegate straight to `liquidity_manager()`. --- src/builder.rs | 2 +- src/message_handler.rs | 62 ++++++++---------------------------------- 2 files changed, 12 insertions(+), 52 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 15f49fb546..3df594b7cf 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1990,7 +1990,7 @@ fn build_with_store_internal( let liquidity_source = runtime .block_on(async move { liquidity_source_builder.build().await.map(Arc::new) })?; let custom_message_handler = - Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source))); + Arc::new(NodeCustomMessageHandler::new(Arc::clone(&liquidity_source))); (liquidity_source, custom_message_handler) }; diff --git a/src/message_handler.rs b/src/message_handler.rs index fc206ec4da..9c4010458b 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -18,24 +18,19 @@ use lightning_types::features::{InitFeatures, NodeFeatures}; use crate::liquidity::LiquiditySource; -pub(crate) enum NodeCustomMessageHandler +pub(crate) struct NodeCustomMessageHandler where L::Target: Logger, { - Ignoring, - Liquidity { liquidity_source: Arc> }, + liquidity_source: Arc>, } impl NodeCustomMessageHandler where L::Target: Logger, { - pub(crate) fn new_liquidity(liquidity_source: Arc>) -> Self { - Self::Liquidity { liquidity_source } - } - - pub(crate) fn new_ignoring() -> Self { - Self::Ignoring + pub(crate) fn new(liquidity_source: Arc>) -> Self { + Self { liquidity_source } } } @@ -48,12 +43,7 @@ where fn read( &self, message_type: u16, buffer: &mut RD, ) -> Result, lightning::ln::msgs::DecodeError> { - match self { - Self::Ignoring => Ok(None), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().read(message_type, buffer) - }, - } + self.liquidity_source.liquidity_manager().read(message_type, buffer) } } @@ -64,58 +54,28 @@ where fn handle_custom_message( &self, msg: Self::CustomMessage, sender_node_id: PublicKey, ) -> Result<(), lightning::ln::msgs::LightningError> { - match self { - Self::Ignoring => Ok(()), // Should be unreachable!() as the reader will return `None` - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().handle_custom_message(msg, sender_node_id) - }, - } + self.liquidity_source.liquidity_manager().handle_custom_message(msg, sender_node_id) } fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Self::CustomMessage)> { - match self { - Self::Ignoring => Vec::new(), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().get_and_clear_pending_msg() - }, - } + self.liquidity_source.liquidity_manager().get_and_clear_pending_msg() } fn provided_node_features(&self) -> NodeFeatures { - match self { - Self::Ignoring => NodeFeatures::empty(), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().provided_node_features() - }, - } + self.liquidity_source.liquidity_manager().provided_node_features() } fn provided_init_features(&self, their_node_id: PublicKey) -> InitFeatures { - match self { - Self::Ignoring => InitFeatures::empty(), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().provided_init_features(their_node_id) - }, - } + self.liquidity_source.liquidity_manager().provided_init_features(their_node_id) } fn peer_connected( &self, their_node_id: PublicKey, msg: &lightning::ln::msgs::Init, inbound: bool, ) -> Result<(), ()> { - match self { - Self::Ignoring => Ok(()), - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound) - }, - } + self.liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound) } fn peer_disconnected(&self, their_node_id: PublicKey) { - match self { - Self::Ignoring => {}, - Self::Liquidity { liquidity_source, .. } => { - liquidity_source.liquidity_manager().peer_disconnected(their_node_id) - }, - } + self.liquidity_source.liquidity_manager().peer_disconnected(their_node_id) } } From ef413be1bf9b8b9bce07ad5e84339c4dbfdc4f03 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 10:28:07 +0200 Subject: [PATCH 012/138] Document pre-1.0 compatibility guarantees Clarify that public APIs remain unstable before 1.0 while persisted node state is intended to remain readable by newer releases. Co-Authored-By: HAL 9000 --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 0068b6e07a..2a981a007c 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,10 @@ LDK Node currently comes with a decidedly opinionated set of design choices: - Gossip data may be sourced via Lightning's peer-to-peer network or the [Rapid Gossip Sync](https://docs.rs/lightning-rapid-gossip-sync/*/lightning_rapid_gossip_sync/) protocol. - Entropy for the Lightning and on-chain wallets may be sourced from raw bytes or a [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) mnemonic. In addition, LDK Node offers the means to generate and persist the entropy bytes to disk. +## Compatibility + +LDK Node does not provide a stable public API until v1.0. Persisted node state is backwards compatible: newer releases are guaranteed to load state written by older releases. Downgrades are not supported, so state written by a newer release may not load with an older release. + ## Language Support LDK Node itself is written in [Rust][rust] and may therefore be natively added as a library dependency to any `std` Rust program. However, beyond its Rust API it also offers language bindings for [Swift][swift], [Kotlin][kotlin], and [Python][python] based on the [UniFFI](https://github.com/mozilla/uniffi-rs/). From 65f774dfcf224b474e437778263ea24058d865c0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 10:52:06 +0200 Subject: [PATCH 013/138] Monitor v0.7.0 serialization downgrade compatibility Add a downgrade canary that writes current node state through the legacy v1 filesystem store and reopens it with ldk-node v0.7.0. This monitors whether serialized node, channel, and payment state remains usable by v0.7.0, including a restored channel and a post-restart payment. This does not assert that the current filesystem-store v2 IO layout can downgrade to v0.7.0's v1 layout. That IO-layer downgrade is unsupported: v2 stores empty namespaces under [empty], which v1 readers do not look up. Co-Authored-By: HAL 9000 --- tests/upgrade_downgrade_tests.rs | 419 +++++++++++++++++++++++++++++++ 1 file changed, 419 insertions(+) create mode 100644 tests/upgrade_downgrade_tests.rs diff --git a/tests/upgrade_downgrade_tests.rs b/tests/upgrade_downgrade_tests.rs new file mode 100644 index 0000000000..b30b5a33c4 --- /dev/null +++ b/tests/upgrade_downgrade_tests.rs @@ -0,0 +1,419 @@ +// 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. + +// This file is a downgrade monitoring canary for serialized LDK Node state, not a +// compatibility test for the filesystem-store IO layout itself. The current +// `build_with_fs_store` path writes filesystem-store v2 data, while LDK Node v0.7.0 +// reads filesystem-store v1 data. There is no supported v2-to-v1 IO-layer downgrade: +// v2 stores empty namespaces under `[empty]`, which v1 readers do not look up. +// +// To keep monitoring whether the serialized node/channel/payment state remains +// understandable by v0.7.0, these tests intentionally write current state through +// the legacy v1 filesystem-store implementation via `build_with_store`, then +// reopen it with v0.7.0's `build_with_fs_store`. + +#[allow(unused_imports, unused_macros)] +mod common; + +use std::path::PathBuf; +use std::time::Duration; + +use bitcoin::secp256k1::PublicKey; +use bitcoin::Amount; +use common::{ + generate_blocks_and_wait, generate_listening_addresses, premine_and_distribute_funds, + random_storage_path, setup_bitcoind_and_electrsd, wait_for_tx, +}; +use ldk_node::config::{Config, EsploraSyncConfig}; +use ldk_node::entropy::NodeEntropy; +use ldk_node::lightning::ln::msgs::SocketAddress as CurrentSocketAddress; +use ldk_node::lightning_invoice::{ + Bolt11InvoiceDescription as CurrentBolt11InvoiceDescription, Description as CurrentDescription, +}; +use lightning_persister::fs_store::v1::FilesystemStore; + +#[cfg(feature = "uniffi")] +type CurrentNode = std::sync::Arc; +#[cfg(not(feature = "uniffi"))] +type CurrentNode = ldk_node::Node; + +const NODE_A_SEED_BYTES: [u8; 64] = [42; 64]; +const NODE_B_SEED_BYTES: [u8; 64] = [43; 64]; +const FUNDING_AMOUNT_SAT: u64 = 2_000_000; +const CHANNEL_AMOUNT_SAT: u64 = 1_000_000; +const PUSH_AMOUNT_MSAT: u64 = 500_000_000; +const PRE_DOWNGRADE_PAYMENT_MSAT: u64 = 100_000; +const POST_DOWNGRADE_PAYMENT_MSAT: u64 = 200_000; + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn monitor_v0_7_0_serialization_downgrade_channel_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + + let storage_path_a = random_storage_path().to_str().unwrap().to_owned(); + let storage_path_b = random_storage_path().to_str().unwrap().to_owned(); + let current_addresses_a = generate_listening_addresses(); + let current_addresses_b = generate_listening_addresses(); + let v070_addresses_a = to_v070_socket_addresses(¤t_addresses_a); + let v070_addresses_b = to_v070_socket_addresses(¤t_addresses_b); + + let node_id_a; + let node_id_b; + let pre_downgrade_payment_id; + + { + let node_a = build_current_node( + storage_path_a.clone(), + NODE_A_SEED_BYTES, + current_addresses_a.clone(), + "downgrade-a", + &esplora_url, + ); + let node_b = build_current_node( + storage_path_b.clone(), + NODE_B_SEED_BYTES, + current_addresses_b.clone(), + "downgrade-b", + &esplora_url, + ); + node_id_a = node_a.node_id(); + node_id_b = node_b.node_id(); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b], + Amount::from_sat(FUNDING_AMOUNT_SAT), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, FUNDING_AMOUNT_SAT); + assert_eq!(node_b.list_balances().spendable_onchain_balance_sats, FUNDING_AMOUNT_SAT); + + let funding_txo = open_current_channel(&node_a, &node_b).await; + wait_for_tx(&electrsd.client, funding_txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + expect_current_channel_ready(&node_a, node_id_b).await; + expect_current_channel_ready(&node_b, node_id_a).await; + assert_current_channel_ready(&node_a, node_id_b); + assert_current_channel_ready(&node_b, node_id_a); + + pre_downgrade_payment_id = send_current_bolt11_payment( + &node_a, + &node_b, + PRE_DOWNGRADE_PAYMENT_MSAT, + "pre-downgrade", + ) + .await; + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + let node_a_v070 = build_v070_node( + storage_path_a, + NODE_A_SEED_BYTES, + v070_addresses_a.clone(), + "downgrade-a", + &esplora_url, + ); + let node_b_v070 = build_v070_node( + storage_path_b, + NODE_B_SEED_BYTES, + v070_addresses_b.clone(), + "downgrade-b", + &esplora_url, + ); + + assert_eq!(node_a_v070.node_id(), node_id_a); + assert_eq!(node_b_v070.node_id(), node_id_b); + + let pre_downgrade_payment_id = + ldk_node_070::lightning::ln::channelmanager::PaymentId(pre_downgrade_payment_id.0); + assert_v070_bolt11_payment( + &node_a_v070, + &pre_downgrade_payment_id, + ldk_node_070::payment::PaymentDirection::Outbound, + PRE_DOWNGRADE_PAYMENT_MSAT, + ); + assert_v070_bolt11_payment( + &node_b_v070, + &pre_downgrade_payment_id, + ldk_node_070::payment::PaymentDirection::Inbound, + PRE_DOWNGRADE_PAYMENT_MSAT, + ); + + node_a_v070.sync_wallets().unwrap(); + node_b_v070.sync_wallets().unwrap(); + node_a_v070.connect(node_id_b, v070_addresses_b.first().unwrap().clone(), true).unwrap(); + wait_for_v070_usable_channel(&node_a_v070, node_id_b).await; + wait_for_v070_usable_channel(&node_b_v070, node_id_a).await; + drain_v070_events(&node_a_v070).await; + drain_v070_events(&node_b_v070).await; + + send_v070_bolt11_payment( + &node_a_v070, + &node_b_v070, + POST_DOWNGRADE_PAYMENT_MSAT, + "post-downgrade", + ) + .await; + + node_a_v070.stop().unwrap(); + node_b_v070.stop().unwrap(); +} + +fn build_current_node( + storage_path: String, seed_bytes: [u8; 64], listening_addresses: Vec, + alias: &str, esplora_url: &str, +) -> CurrentNode { + let mut config = Config::default(); + config.network = bitcoin::Network::Regtest; + config.storage_dir_path = storage_path; + config.listening_addresses = Some(listening_addresses); + config.anchor_channels_config = None; + + // Use the v1 filesystem layout that v0.7.0's filesystem builder can reopen. + let mut fs_store_path = PathBuf::from(&config.storage_dir_path); + fs_store_path.push("fs_store"); + #[allow(unused_mut)] + let mut builder = ldk_node::Builder::from_config(config); + builder.set_node_alias(alias.to_string()).unwrap(); + + let mut sync_config = EsploraSyncConfig::default(); + sync_config.background_sync_config = None; + builder.set_chain_source_esplora(esplora_url.to_owned(), Some(sync_config)); + + #[cfg(feature = "uniffi")] + let node_entropy = std::sync::Arc::new(NodeEntropy::from_seed_bytes(seed_bytes.to_vec()).unwrap()); + #[cfg(not(feature = "uniffi"))] + let node_entropy = NodeEntropy::from_seed_bytes(seed_bytes); + + let kv_store = FilesystemStore::new(fs_store_path); + let node = builder.build_with_store(node_entropy.into(), kv_store).unwrap(); + node.start().unwrap(); + node +} + +fn build_v070_node( + storage_path: String, seed_bytes: [u8; 64], + listening_addresses: Vec, alias: &str, + esplora_url: &str, +) -> ldk_node_070::Node { + let mut builder = ldk_node_070::Builder::new(); + builder.set_network(bitcoin::Network::Regtest); + builder.set_storage_dir_path(storage_path); + builder.set_entropy_seed_bytes(seed_bytes); + builder.set_listening_addresses(listening_addresses).unwrap(); + builder.set_node_alias(alias.to_string()).unwrap(); + builder.set_chain_source_esplora(esplora_url.to_owned(), None); + let node = builder.build_with_fs_store().unwrap(); + node.start().unwrap(); + node +} + +async fn open_current_channel(node_a: &CurrentNode, node_b: &CurrentNode) -> bitcoin::OutPoint { + node_a + .open_channel( + node_b.node_id(), + node_b.listening_addresses().unwrap().first().unwrap().clone(), + CHANNEL_AMOUNT_SAT, + Some(PUSH_AMOUNT_MSAT), + None, + ) + .unwrap(); + + let funding_txo_a = expect_current_channel_pending(node_a, node_b.node_id()).await; + let funding_txo_b = expect_current_channel_pending(node_b, node_a.node_id()).await; + assert_eq!(funding_txo_a, funding_txo_b); + funding_txo_a +} + +async fn send_current_bolt11_payment( + payer: &CurrentNode, payee: &CurrentNode, amount_msat: u64, description: &str, +) -> ldk_node::lightning::ln::channelmanager::PaymentId { + let invoice_description = CurrentBolt11InvoiceDescription::Direct( + CurrentDescription::new(description.to_owned()).unwrap(), + ); + let invoice = payee + .bolt11_payment() + .receive(amount_msat, &invoice_description.clone().into(), 3600) + .unwrap(); + let payment_id = payer.bolt11_payment().send(&invoice, None).unwrap(); + expect_current_payment_successful(payer, &payment_id).await; + expect_current_payment_received(payee, amount_msat).await; + assert_eq!( + payer.payment(&payment_id).unwrap().status, + ldk_node::payment::PaymentStatus::Succeeded + ); + payment_id +} + +async fn send_v070_bolt11_payment( + payer: &ldk_node_070::Node, payee: &ldk_node_070::Node, amount_msat: u64, description: &str, +) { + let invoice_description = ldk_node_070::lightning_invoice::Bolt11InvoiceDescription::Direct( + ldk_node_070::lightning_invoice::Description::new(description.to_owned()).unwrap(), + ); + let invoice = payee.bolt11_payment().receive(amount_msat, &invoice_description, 3600).unwrap(); + let payment_id = payer.bolt11_payment().send(&invoice, None).unwrap(); + expect_v070_payment_successful(payer, &payment_id).await; + expect_v070_payment_received(payee, amount_msat).await; + assert_eq!( + payer.payment(&payment_id).unwrap().status, + ldk_node_070::payment::PaymentStatus::Succeeded + ); +} + +async fn expect_current_channel_pending( + node: &CurrentNode, expected_counterparty: PublicKey, +) -> bitcoin::OutPoint { + match next_current_event(node).await { + ldk_node::Event::ChannelPending { counterparty_node_id, funding_txo, .. } => { + assert_eq!(counterparty_node_id, expected_counterparty); + node.event_handled().unwrap(); + funding_txo + }, + event => panic!("{} got unexpected event: {:?}", node.node_id(), event), + } +} + +async fn expect_current_channel_ready(node: &CurrentNode, expected_counterparty: PublicKey) { + match next_current_event(node).await { + ldk_node::Event::ChannelReady { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, Some(expected_counterparty)); + node.event_handled().unwrap(); + }, + event => panic!("{} got unexpected event: {:?}", node.node_id(), event), + } +} + +async fn expect_current_payment_successful( + node: &CurrentNode, expected_payment_id: &ldk_node::lightning::ln::channelmanager::PaymentId, +) { + match next_current_event(node).await { + ldk_node::Event::PaymentSuccessful { payment_id, .. } => { + assert_eq!(payment_id.as_ref(), Some(expected_payment_id)); + node.event_handled().unwrap(); + }, + event => panic!("{} got unexpected event: {:?}", node.node_id(), event), + } +} + +async fn expect_current_payment_received(node: &CurrentNode, expected_amount_msat: u64) { + match next_current_event(node).await { + ldk_node::Event::PaymentReceived { amount_msat, payment_id, .. } => { + assert_eq!(amount_msat, expected_amount_msat); + assert!(payment_id.is_some()); + node.event_handled().unwrap(); + }, + event => panic!("{} got unexpected event: {:?}", node.node_id(), event), + } +} + +async fn expect_v070_payment_successful( + node: &ldk_node_070::Node, + expected_payment_id: &ldk_node_070::lightning::ln::channelmanager::PaymentId, +) { + match next_v070_event(node).await { + ldk_node_070::Event::PaymentSuccessful { payment_id, .. } => { + assert_eq!(payment_id.as_ref(), Some(expected_payment_id)); + node.event_handled().unwrap(); + }, + event => panic!("{} got unexpected event: {:?}", node.node_id(), event), + } +} + +async fn expect_v070_payment_received(node: &ldk_node_070::Node, expected_amount_msat: u64) { + match next_v070_event(node).await { + ldk_node_070::Event::PaymentReceived { amount_msat, payment_id, .. } => { + assert_eq!(amount_msat, expected_amount_msat); + assert!(payment_id.is_some()); + node.event_handled().unwrap(); + }, + event => panic!("{} got unexpected event: {:?}", node.node_id(), event), + } +} + +async fn next_current_event(node: &CurrentNode) -> ldk_node::Event { + tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), node.next_event_async()) + .await + .unwrap_or_else(|_| panic!("{} timed out waiting for event", node.node_id())) +} + +async fn next_v070_event(node: &ldk_node_070::Node) -> ldk_node_070::Event { + tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), node.next_event_async()) + .await + .unwrap_or_else(|_| panic!("{} timed out waiting for event", node.node_id())) +} + +async fn drain_v070_events(node: &ldk_node_070::Node) { + while tokio::time::timeout(Duration::from_millis(250), node.next_event_async()).await.is_ok() { + node.event_handled().unwrap(); + } +} + +async fn wait_for_v070_usable_channel(node: &ldk_node_070::Node, counterparty_node_id: PublicKey) { + for _ in 0..40 { + let channels = node.list_channels(); + if let Some(channel) = + channels.iter().find(|c| c.counterparty_node_id == counterparty_node_id) + { + assert_eq!(channel.channel_value_sats, CHANNEL_AMOUNT_SAT); + if channel.is_channel_ready && channel.is_usable { + return; + } + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + + panic!( + "{} failed to restore a usable v0.7.0 channel with {}", + node.node_id(), + counterparty_node_id + ); +} + +fn assert_current_channel_ready(node: &CurrentNode, counterparty_node_id: PublicKey) { + let channels = node.list_channels(); + let channel = channels.iter().find(|c| c.counterparty_node_id == counterparty_node_id).unwrap(); + assert_eq!(channel.channel_value_sats, CHANNEL_AMOUNT_SAT); + assert!(channel.is_channel_ready); +} + +fn assert_v070_bolt11_payment( + node: &ldk_node_070::Node, payment_id: &ldk_node_070::lightning::ln::channelmanager::PaymentId, + expected_direction: ldk_node_070::payment::PaymentDirection, expected_amount_msat: u64, +) { + let payment = node.payment(payment_id).unwrap(); + assert_eq!(payment.amount_msat, Some(expected_amount_msat)); + assert_eq!(payment.direction, expected_direction); + assert_eq!(payment.status, ldk_node_070::payment::PaymentStatus::Succeeded); + assert!(matches!(payment.kind, ldk_node_070::payment::PaymentKind::Bolt11 { .. })); +} + +fn to_v070_socket_addresses( + addresses: &[CurrentSocketAddress], +) -> Vec { + addresses + .iter() + .map(|address| match address { + CurrentSocketAddress::TcpIpV4 { addr, port } => { + ldk_node_070::lightning::ln::msgs::SocketAddress::TcpIpV4 { + addr: *addr, + port: *port, + } + }, + _ => panic!("unexpected non-IPv4 test address: {:?}", address), + }) + .collect() +} From a3a760615949054571e33e8565a331f84f3d2d05 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 11:27:52 +0200 Subject: [PATCH 014/138] Mention Postgres support in README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a981a007c..289ada1792 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ LDK Node currently comes with a decidedly opinionated set of design choices: - On-chain data is handled by the integrated [BDK][bdk] wallet. - Chain data may currently be sourced from the Bitcoin Core RPC interface, or from an [Electrum][electrum] or [Esplora][esplora] server. -- Wallet and channel state may be persisted to an [SQLite][sqlite] database, to file system, or to a custom back-end to be implemented by the user. +- Wallet and channel state may be persisted to an [SQLite][sqlite] or [PostgreSQL][postgresql] database, to file system, or to a custom back-end to be implemented by the user. - Gossip data may be sourced via Lightning's peer-to-peer network or the [Rapid Gossip Sync](https://docs.rs/lightning-rapid-gossip-sync/*/lightning_rapid_gossip_sync/) protocol. - Entropy for the Lightning and on-chain wallets may be sourced from raw bytes or a [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) mnemonic. In addition, LDK Node offers the means to generate and persist the entropy bytes to disk. @@ -85,6 +85,7 @@ The Minimum Supported Rust Version (MSRV) is currently 1.85.0. [electrum]: https://github.com/spesmilo/electrum-protocol [esplora]: https://github.com/Blockstream/esplora [sqlite]: https://sqlite.org/ +[postgresql]: https://www.postgresql.org/ [rust]: https://www.rust-lang.org/ [swift]: https://www.swift.org/ [kotlin]: https://kotlinlang.org/ From 2769deff0a95fdfa65833eec67eb34ebb2b19d68 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Jun 2026 13:42:35 +0200 Subject: [PATCH 015/138] Reuse Electrum client for transaction sync Electrum transaction sync now reuses the client already shared by BDK and direct Electrum calls. This avoids opening a second Electrum connection and completes the reuse intended by #488. Co-Authored-By: HAL 9000 --- src/chain/electrum.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index ad0ef1b7ba..7406f06b4b 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -426,10 +426,11 @@ impl ElectrumRuntimeClient { ); let bdk_electrum_client = Arc::new(BdkElectrumClient::new(Arc::clone(&electrum_client))); let tx_sync = Arc::new( - ElectrumSyncClient::new(server_url.clone(), Arc::clone(&logger)).map_err(|e| { - log_error!(logger, "Failed to connect to electrum server: {}", e); - Error::ConnectionFailed - })?, + ElectrumSyncClient::from_client(Arc::clone(&electrum_client), Arc::clone(&logger)) + .map_err(|e| { + log_error!(logger, "Failed to connect to electrum server: {}", e); + Error::ConnectionFailed + })?, ); Ok(Self { electrum_client, From df8bf2711ce59016c189022c14fdfa4b168796a4 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 11 Jun 2026 14:16:50 -0500 Subject: [PATCH 016/138] Update vss-client to 0.6 Switches vss-client-ng to the crates.io 0.6 release. Generated with OpenAI Codex. --- CHANGELOG.md | 2 ++ Cargo.toml | 2 +- src/io/vss_store.rs | 7 +++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93c7cf59b2..c9f15e61f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Compatibility Notes - Pending JIT-channel payments created before upgrading may fail after upgrade because the 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. # 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/Cargo.toml b/Cargo.toml index bed984f071..aa9df0b18e 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,7 +82,7 @@ async-trait = { version = "0.1", default-features = false } tokio-postgres = { version = "0.7", default-features = false, features = ["runtime"], optional = true } native-tls = { version = "0.2", default-features = false, optional = true } postgres-native-tls = { version = "0.5", default-features = false, features = ["runtime"], optional = true } -vss-client = { package = "vss-client-ng", version = "0.5" } +vss-client = { package = "vss-client-ng", version = "0.6" } prost = { version = "0.11.6", default-features = false} #bitcoin-payment-instructions = { version = "0.6" } bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "ff09ce9401afa448549a8f101172700bcd14d7bb" } diff --git a/src/io/vss_store.rs b/src/io/vss_store.rs index 6c3535627a..b87b94dab1 100644 --- a/src/io/vss_store.rs +++ b/src/io/vss_store.rs @@ -626,6 +626,7 @@ fn retry_policy() -> CustomRetryPolicy { VssError::NoSuchKeyError(..) | VssError::InvalidRequestError(..) | VssError::ConflictError(..) + | VssError::VSSVersionMismatchError { .. } ) }) as _) } @@ -647,6 +648,12 @@ async fn determine_and_write_schema_version( // The value is not set. None }, + Err(VssError::VSSVersionMismatchError { version_served, version_expected }) => { + let msg = format!( + "VSS version mismatch, expected: {version_expected}, got: {version_served:?}" + ); + return Err(Error::new(ErrorKind::Other, msg)); + }, Err(e) => { let msg = format!("Failed to read schema version: {}", e); return Err(Error::new(ErrorKind::Other, msg)); From 593315543546f157f505fd16e9af64c30d69d9b5 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 14 Apr 2026 13:40:01 -0500 Subject: [PATCH 017/138] Extract build_vss_store test helper Move repeated VssStore construction logic into a shared build_vss_store() helper and have existing tests use it. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/io/vss_store.rs | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/src/io/vss_store.rs b/src/io/vss_store.rs index b87b94dab1..d3bd77e2e6 100644 --- a/src/io/vss_store.rs +++ b/src/io/vss_store.rs @@ -948,34 +948,27 @@ mod tests { use super::*; use crate::io::test_utils::do_read_write_remove_list_persist; - #[tokio::test] - async fn vss_read_write_remove_list_persist() { + fn build_vss_store() -> VssStore { let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap(); let mut rng = rng(); let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect(); let mut node_seed = [0u8; 64]; rng.fill_bytes(&mut node_seed); let entropy = NodeEntropy::from_seed_bytes(node_seed); - let vss_store = - VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet) - .build_with_sigs_auth(HashMap::new()) - .unwrap(); + VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet) + .build_with_sigs_auth(HashMap::new()) + .unwrap() + } + + #[tokio::test] + async fn vss_read_write_remove_list_persist() { + let vss_store = build_vss_store(); do_read_write_remove_list_persist(&vss_store).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn vss_read_write_remove_list_persist_in_runtime_context() { - let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap(); - let mut rng = rng(); - let rand_store_id: String = (0..7).map(|_| rng.sample(Alphanumeric) as char).collect(); - let mut node_seed = [0u8; 64]; - rng.fill_bytes(&mut node_seed); - let entropy = NodeEntropy::from_seed_bytes(node_seed); - let vss_store = - VssStoreBuilder::new(entropy, vss_base_url, rand_store_id, Network::Testnet) - .build_with_sigs_auth(HashMap::new()) - .unwrap(); - + let vss_store = build_vss_store(); do_read_write_remove_list_persist(&vss_store).await; drop(vss_store) } From 2c8b658293830b8a958f001eb28981da4a680e2d Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 14 Apr 2026 14:47:15 -0500 Subject: [PATCH 018/138] Refactor list_all_keys into reusable list_keys Extract the single-page VSS listing logic into a list_keys method that accepts page_token and page_size parameters. list_internal now drives the pagination loop itself, calling list_keys per page. This prepares for PaginatedKVStore support which will reuse list_keys for single-page queries. This also fixes a potential issue where if the VSS server returned None for the page token we could enter into an infinite loop. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/io/vss_store.rs | 70 ++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/src/io/vss_store.rs b/src/io/vss_store.rs index d3bd77e2e6..81a63b0c5b 100644 --- a/src/io/vss_store.rs +++ b/src/io/vss_store.rs @@ -391,35 +391,34 @@ impl VssStoreInner { } } - async fn list_all_keys( + async fn list_keys( &self, client: &VssClient, primary_namespace: &str, - secondary_namespace: &str, - ) -> io::Result> { - let mut page_token = None; - let mut keys = vec![]; + secondary_namespace: &str, page_token: Option, page_size: Option, + ) -> io::Result<(Vec, Option)> { let key_prefix = self.build_obfuscated_prefix(primary_namespace, secondary_namespace); - while page_token != Some("".to_string()) { - let request = ListKeyVersionsRequest { - store_id: self.store_id.clone(), - key_prefix: Some(key_prefix.clone()), - page_token, - page_size: None, - }; + let request = ListKeyVersionsRequest { + store_id: self.store_id.clone(), + key_prefix: Some(key_prefix), + page_token, + page_size, + }; - let response = client.list_key_versions(&request).await.map_err(|e| { - let msg = format!( - "Failed to list keys in {}/{}: {}", - primary_namespace, secondary_namespace, e - ); - Error::new(ErrorKind::Other, msg) - })?; + let response = client.list_key_versions(&request).await.map_err(|e| { + let msg = format!( + "Failed to list keys in {}/{}: {}", + primary_namespace, secondary_namespace, e + ); + Error::new(ErrorKind::Other, msg) + })?; - for kv in response.key_versions { - keys.push(self.extract_key(&kv.key)?); - } - page_token = response.next_page_token; + let mut keys = Vec::with_capacity(response.key_versions.len()); + for kv in response.key_versions { + keys.push(self.extract_key(&kv.key)?); } - Ok(keys) + + // VSS may return an empty string instead of None to signal the last page. + let next_page_token = response.next_page_token.filter(|t| !t.is_empty()); + Ok((keys, next_page_token)) } async fn read_internal( @@ -543,17 +542,18 @@ impl VssStoreInner { ) -> io::Result> { check_namespace_key_validity(&primary_namespace, &secondary_namespace, None, "list")?; - let keys = self - .list_all_keys(client, &primary_namespace, &secondary_namespace) - .await - .map_err(|e| { - let msg = format!( - "Failed to retrieve keys in namespace: {}/{} : {}", - primary_namespace, secondary_namespace, e - ); - Error::new(ErrorKind::Other, msg) - })?; - + let mut page_token: Option = None; + let mut keys = vec![]; + loop { + let (page_keys, next_page_token) = self + .list_keys(client, &primary_namespace, &secondary_namespace, page_token, None) + .await?; + keys.extend(page_keys); + match next_page_token { + Some(t) => page_token = Some(t), + None => break, + } + } Ok(keys) } From 38b65b2cccb16867f0cda95b3fdf676d1e370442 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 12 Jun 2026 15:05:03 -0500 Subject: [PATCH 019/138] Extract BOLT11 send helper Share the common BOLT11 payment send flow between fixed-amount and explicit-amount sends so follow-up API variants can reuse the same payment-store and error handling path. AI-Tool-Disclosure: Created with OpenAI Codex. --- src/payment/bolt11.rs | 150 +++++++++++++----------------------------- 1 file changed, 47 insertions(+), 103 deletions(-) diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 068269997f..1761133f20 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -279,20 +279,15 @@ mod tests { } } -#[cfg_attr(feature = "uniffi", uniffi::export)] impl Bolt11Payment { - /// Send a payment given an invoice. - /// - /// If `route_parameters` are provided they will override the default as well as the - /// node-wide parameters configured via [`Config::route_parameters`] on a per-field basis. - pub fn send( - &self, invoice: &Bolt11Invoice, route_parameters: Option, + fn send_internal( + &self, invoice: &LdkBolt11Invoice, amount_msat: Option, + route_parameters: Option, invalid_amount_log: &'static str, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } - let invoice = maybe_deref(invoice); let payment_hash = invoice.payment_hash(); let payment_id = PaymentId(invoice.payment_hash().0); if let Some(payment) = self.payment_store.get(&payment_id) { @@ -308,6 +303,13 @@ impl Bolt11Payment { route_parameters.or(self.config.route_parameters).unwrap_or_default(); let retry_strategy = Retry::Timeout(LDK_PAYMENT_RETRY_TIMEOUT); let payment_secret = Some(*invoice.payment_secret()); + let payment_amount_msat = match amount_msat.or_else(|| invoice.amount_milli_satoshis()) { + Some(amount_msat) => amount_msat, + None => { + log_error!(self.logger, "{}", invalid_amount_log); + return Err(Error::InvalidInvoice); + }, + }; let optional_params = OptionalBolt11PaymentParams { retry_strategy, @@ -317,14 +319,17 @@ impl Bolt11Payment { match self.channel_manager.pay_for_bolt11_invoice( invoice, payment_id, - None, + amount_msat, optional_params, ) { Ok(()) => { let payee_pubkey = invoice.recover_payee_pub_key(); - let amt_msat = - invoice.amount_milli_satoshis().expect("invoice amount should be set"); - log_info!(self.logger, "Initiated sending {}msat to {}", amt_msat, payee_pubkey); + log_info!( + self.logger, + "Initiated sending {} msat to {}", + payment_amount_msat, + payee_pubkey + ); let kind = PaymentKind::Bolt11 { hash: payment_hash, @@ -335,7 +340,7 @@ impl Bolt11Payment { let payment = PaymentDetails::new( payment_id, kind, - invoice.amount_milli_satoshis(), + Some(payment_amount_msat), None, PaymentDirection::Outbound, PaymentStatus::Pending, @@ -346,9 +351,7 @@ impl Bolt11Payment { Ok(payment_id) }, Err(Bolt11PaymentError::InvalidAmount) => { - log_error!(self.logger, - "Failed to send payment due to the given invoice being \"zero-amount\". Please use send_using_amount instead." - ); + log_error!(self.logger, "{}", invalid_amount_log); return Err(Error::InvalidInvoice); }, Err(Bolt11PaymentError::SendingFailed(e)) => { @@ -365,7 +368,7 @@ impl Bolt11Payment { let payment = PaymentDetails::new( payment_id, kind, - invoice.amount_milli_satoshis(), + Some(payment_amount_msat), None, PaymentDirection::Outbound, PaymentStatus::Failed, @@ -378,6 +381,29 @@ impl Bolt11Payment { }, } } +} + +#[cfg_attr(feature = "uniffi", uniffi::export)] +impl Bolt11Payment { + /// Send a payment given an invoice. + /// + /// If `route_parameters` are provided they will override the default as well as the + /// node-wide parameters configured via [`Config::route_parameters`] on a per-field basis. + pub fn send( + &self, invoice: &Bolt11Invoice, route_parameters: Option, + ) -> Result { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + + let invoice = maybe_deref(invoice); + self.send_internal( + invoice, + None, + route_parameters, + "Failed to send payment due to the given invoice being \"zero-amount\". Please use send_using_amount instead.", + ) + } /// Send a payment given an invoice and an amount in millisatoshis. /// @@ -406,94 +432,12 @@ impl Bolt11Payment { } } - let payment_hash = invoice.payment_hash(); - let payment_id = PaymentId(invoice.payment_hash().0); - if let Some(payment) = self.payment_store.get(&payment_id) { - if payment.status == PaymentStatus::Pending - || payment.status == PaymentStatus::Succeeded - { - log_error!(self.logger, "Payment error: an invoice must not be paid twice."); - return Err(Error::DuplicatePayment); - } - } - - let route_params_config = - route_parameters.or(self.config.route_parameters).unwrap_or_default(); - let retry_strategy = Retry::Timeout(LDK_PAYMENT_RETRY_TIMEOUT); - let payment_secret = Some(*invoice.payment_secret()); - - let optional_params = OptionalBolt11PaymentParams { - retry_strategy, - route_params_config, - ..Default::default() - }; - match self.channel_manager.pay_for_bolt11_invoice( + self.send_internal( invoice, - payment_id, Some(amount_msat), - optional_params, - ) { - Ok(()) => { - let payee_pubkey = invoice.recover_payee_pub_key(); - log_info!( - self.logger, - "Initiated sending {} msat to {}", - amount_msat, - payee_pubkey - ); - - let kind = PaymentKind::Bolt11 { - hash: payment_hash, - preimage: None, - secret: payment_secret, - counterparty_skimmed_fee_msat: None, - }; - - let payment = PaymentDetails::new( - payment_id, - kind, - Some(amount_msat), - None, - PaymentDirection::Outbound, - PaymentStatus::Pending, - ); - self.runtime.block_on(self.payment_store.insert(payment))?; - - Ok(payment_id) - }, - Err(Bolt11PaymentError::InvalidAmount) => { - log_error!( - self.logger, - "Failed to send payment due to amount given being insufficient." - ); - return Err(Error::InvalidInvoice); - }, - Err(Bolt11PaymentError::SendingFailed(e)) => { - log_error!(self.logger, "Failed to send payment: {:?}", e); - match e { - RetryableSendFailure::DuplicatePayment => Err(Error::DuplicatePayment), - _ => { - let kind = PaymentKind::Bolt11 { - hash: payment_hash, - preimage: None, - secret: payment_secret, - counterparty_skimmed_fee_msat: None, - }; - let payment = PaymentDetails::new( - payment_id, - kind, - Some(amount_msat), - None, - PaymentDirection::Outbound, - PaymentStatus::Failed, - ); - - self.runtime.block_on(self.payment_store.insert(payment))?; - Err(Error::PaymentSendingFailed) - }, - } - }, - } + route_parameters, + "Failed to send payment due to amount given being insufficient.", + ) } /// Allows to attempt manually claiming payments with the given preimage that have previously From d3faf8e68660b7b676b9f85a075feda0fef536d5 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 12 Jun 2026 15:17:20 -0500 Subject: [PATCH 020/138] Expose BOLT11 underpayment sends Add a BOLT11 payment API for sending less than the invoice amount while using the invoice amount as the declared total MPP value. Cover the path with an integration test where two nodes each pay half of one invoice and the receiver claims the full amount. AI-Tool-Disclosure: Created with OpenAI Codex. --- src/payment/bolt11.rs | 51 +++++++++++++++++- tests/integration_tests_rust.rs | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 1761133f20..bda11b96d3 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -282,7 +282,8 @@ mod tests { impl Bolt11Payment { fn send_internal( &self, invoice: &LdkBolt11Invoice, amount_msat: Option, - route_parameters: Option, invalid_amount_log: &'static str, + route_parameters: Option, + declared_total_mpp_value_msat_override: Option, invalid_amount_log: &'static str, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); @@ -314,6 +315,7 @@ impl Bolt11Payment { let optional_params = OptionalBolt11PaymentParams { retry_strategy, route_params_config, + declared_total_mpp_value_msat_override, ..Default::default() }; match self.channel_manager.pay_for_bolt11_invoice( @@ -401,6 +403,7 @@ impl Bolt11Payment { invoice, None, route_parameters, + None, "Failed to send payment due to the given invoice being \"zero-amount\". Please use send_using_amount instead.", ) } @@ -436,6 +439,52 @@ impl Bolt11Payment { invoice, Some(amount_msat), route_parameters, + None, + "Failed to send payment due to amount given being insufficient.", + ) + } + + /// Send a payment given an invoice and an amount lower than the invoice amount. + /// + /// This uses LDK's partial MPP support by declaring the invoice amount as the total MPP value + /// while only sending `amount_msat` from this node. The receiving node must be willing to + /// accept underpaying HTLCs for the payment to complete. + /// + /// This will fail if the invoice is a zero-amount invoice, or if the amount given is greater + /// than or equal to the value required by the invoice. Use [`Self::send_using_amount`] instead + /// when paying a zero-amount invoice or paying at least the invoice amount. + /// + /// If `route_parameters` are provided they will override the default as well as the + /// node-wide parameters configured via [`Config::route_parameters`] on a per-field basis. + pub fn send_using_amount_underpaying( + &self, invoice: &Bolt11Invoice, amount_msat: u64, + route_parameters: Option, + ) -> Result { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + + let invoice = maybe_deref(invoice); + let invoice_amount_msat = invoice.amount_milli_satoshis().ok_or_else(|| { + log_error!(self.logger, "Failed to underpay as the given invoice is \"zero-amount\"."); + Error::InvalidInvoice + })?; + + if amount_msat >= invoice_amount_msat { + log_error!( + self.logger, + "Failed to underpay as the given amount needs to be less than the invoice amount: required less than {}msat, gave {}msat.", + invoice_amount_msat, + amount_msat + ); + return Err(Error::InvalidAmount); + } + + self.send_internal( + invoice, + Some(amount_msat), + route_parameters, + Some(invoice_amount_msat), "Failed to send payment due to amount given being insufficient.", ) } diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 309d5bf4d3..b1aa090a21 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -304,6 +304,101 @@ async fn multi_hop_sending() { expect_payment_successful_event!(nodes[0], payment_id, Some(fee_paid_msat)); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn split_underpaid_bolt11_payment() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let node_c = setup_node(&chain_source, random_config(true)); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + let addr_c = node_c.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b, addr_c], + Amount::from_sat(premine_amount_sat), + ) + .await; + + for node in [&node_a, &node_b, &node_c] { + node.sync_wallets().unwrap(); + assert_eq!(node.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + } + + // The receiver opens both channels and pushes liquidity to both payers so each payer can send + // half of the invoice back. + let channel_amount_sat = 1_000_000; + let push_amount_msat = Some(500_000_000); + for payer in [&node_a, &node_b] { + node_c + .open_channel( + payer.node_id(), + payer.listening_addresses().unwrap().first().unwrap().clone(), + channel_amount_sat, + push_amount_msat, + None, + ) + .unwrap(); + + let funding_txo_c = expect_channel_pending_event!(node_c, payer.node_id()); + let funding_txo_payer = expect_channel_pending_event!(payer, node_c.node_id()); + assert_eq!(funding_txo_c, funding_txo_payer); + wait_for_tx(&electrsd.client, funding_txo_c.txid).await; + + node_c.sync_wallets().unwrap(); + } + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + for node in [&node_a, &node_b, &node_c] { + node.sync_wallets().unwrap(); + } + + expect_channel_ready_events!(node_c, node_a.node_id(), node_b.node_id()); + expect_channel_ready_event!(node_a, node_c.node_id()); + expect_channel_ready_event!(node_b, node_c.node_id()); + + let amount_msat = 100_000_000; + let half_amount_msat = amount_msat / 2; + let invoice_description = + Bolt11InvoiceDescription::Direct(Description::new(String::from("split")).unwrap()); + let invoice = + node_c.bolt11_payment().receive(amount_msat, &invoice_description.into(), 3600).unwrap(); + + // Each payer sends only half the invoice amount, while declaring the full invoice amount as + // the total MPP value. The receiver should claim only once both HTLCs arrive. + let payment_id_a = node_a + .bolt11_payment() + .send_using_amount_underpaying(&invoice, half_amount_msat, None) + .unwrap(); + let payment_id_b = node_b + .bolt11_payment() + .send_using_amount_underpaying(&invoice, half_amount_msat, None) + .unwrap(); + + let receiver_payment_id = expect_payment_received_event!(node_c, amount_msat); + assert_eq!(receiver_payment_id, Some(PaymentId(invoice.payment_hash().0))); + expect_payment_successful_event!(node_a, Some(payment_id_a), None); + expect_payment_successful_event!(node_b, Some(payment_id_b), None); + + // The receiver records the full invoice amount; each payer records only its own half. + let receiver_payments = + node_c.list_payments_with_filter(|p| p.id == receiver_payment_id.unwrap()); + assert_eq!(receiver_payments.len(), 1); + assert_eq!(receiver_payments.first().unwrap().amount_msat, Some(amount_msat)); + + let node_a_payments = node_a.list_payments_with_filter(|p| p.id == payment_id_a); + assert_eq!(node_a_payments.len(), 1); + assert_eq!(node_a_payments.first().unwrap().amount_msat, Some(half_amount_msat)); + + let node_b_payments = node_b.list_payments_with_filter(|p| p.id == payment_id_b); + assert_eq!(node_b_payments.len(), 1); + assert_eq!(node_b_payments.first().unwrap().amount_msat, Some(half_amount_msat)); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn start_stop_reinit() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 9ab21064d13cea191c4cd936ef686476f87bdd71 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 12 Jun 2026 14:29:30 -0500 Subject: [PATCH 021/138] Reject overflowing unified receive amounts Return InvalidAmount when converting the requested satoshi amount to millisatoshis would overflow. This keeps debug and release behavior consistent and avoids producing a URI whose on-chain amount differs from its Lightning payment amount. This commit was created with assistance from OpenAI Codex. This finding was discovered by Project Loupe --- src/payment/unified.rs | 4 ++-- tests/integration_tests_rust.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/payment/unified.rs b/src/payment/unified.rs index 3708afe8e6..2ad77f7728 100644 --- a/src/payment/unified.rs +++ b/src/payment/unified.rs @@ -129,9 +129,9 @@ impl UnifiedPayment { pub fn receive( &self, amount_sats: u64, description: &str, expiry_sec: u32, ) -> Result { - let onchain_address = self.onchain_payment.new_address()?; + let amount_msats = amount_sats.checked_mul(1_000).ok_or(Error::InvalidAmount)?; - let amount_msats = amount_sats * 1_000; + let onchain_address = self.onchain_payment.new_address()?; let bolt12_offer = match self.bolt12_payment.receive_inner(amount_msats, description, None, None) { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 309d5bf4d3..76835b38a8 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1680,6 +1680,18 @@ async fn generate_bip21_uri() { assert!(uni_payment.contains("lno=")); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn unified_receive_rejects_msat_overflow() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let node = setup_node(&chain_source, random_config(true)); + + assert_eq!( + Err(NodeError::InvalidAmount), + node.unified_payment().receive(u64::MAX, "asdf", 4_000) + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn unified_send_receive_bip21_uri() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 187056b750c926b6cf4bd5d9ffcfe9bc1125d520 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 12 Jun 2026 14:52:23 -0500 Subject: [PATCH 022/138] Avoid Bolt11 claim amount underflow Use saturating arithmetic when accounting for skimmed JIT-channel fees while validating manually claimed payments. This prevents an oversized skimmed fee from underflowing the expected claimable amount. This commit was created with assistance from OpenAI Codex. This finding was discovered by Project Loupe --- src/payment/bolt11.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 068269997f..e3cb948a1b 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -539,7 +539,7 @@ impl Bolt11Payment { _ => 0, }; if let Some(invoice_amount_msat) = details.amount_msat { - if claimable_amount_msat < invoice_amount_msat - skimmed_fee_msat { + if claimable_amount_msat < invoice_amount_msat.saturating_sub(skimmed_fee_msat) { log_error!( self.logger, "Failed to manually claim payment {} as the claimable amount is less than expected", From 4a449fcdef20972713952cb4084c45bb6b75b2ef Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 12 Jun 2026 15:42:11 -0500 Subject: [PATCH 023/138] Deduplicate registered chain txids Track registered transaction IDs in a set so repeated filter registrations do not grow the collection or slow block-connected checks. This keeps the wallet's registered-transaction lookup bounded by unique transaction IDs. This commit was created with assistance from OpenAI Codex. This finding was discovered by Project Loupe --- src/chain/mod.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 92c4bdb641..5a326be97b 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -9,7 +9,7 @@ pub(crate) mod bitcoind; mod electrum; mod esplora; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -84,7 +84,7 @@ impl WalletSyncStatus { pub(crate) struct ChainSource { kind: ChainSourceKind, - registered_txids: Mutex>, + registered_txids: Mutex>, tx_broadcaster: Arc, logger: Arc, } @@ -113,7 +113,7 @@ impl ChainSource { node_metrics, )?; let kind = ChainSourceKind::Esplora(esplora_chain_source); - let registered_txids = Mutex::new(Vec::new()); + let registered_txids = Mutex::new(HashSet::new()); Ok((Self { kind, registered_txids, tx_broadcaster, logger }, None)) } @@ -133,7 +133,7 @@ impl ChainSource { node_metrics, ); let kind = ChainSourceKind::Electrum(electrum_chain_source); - let registered_txids = Mutex::new(Vec::new()); + let registered_txids = Mutex::new(HashSet::new()); (Self { kind, registered_txids, tx_broadcaster, logger }, None) } @@ -156,7 +156,7 @@ impl ChainSource { ); let best_block = bitcoind_chain_source.poll_best_block().await.ok(); let kind = ChainSourceKind::Bitcoind(bitcoind_chain_source); - let registered_txids = Mutex::new(Vec::new()); + let registered_txids = Mutex::new(HashSet::new()); (Self { kind, registered_txids, tx_broadcaster, logger }, best_block) } @@ -180,7 +180,7 @@ impl ChainSource { ); let best_block = bitcoind_chain_source.poll_best_block().await.ok(); let kind = ChainSourceKind::Bitcoind(bitcoind_chain_source); - let registered_txids = Mutex::new(Vec::new()); + let registered_txids = Mutex::new(HashSet::new()); (Self { kind, registered_txids, tx_broadcaster, logger }, best_block) } @@ -214,7 +214,7 @@ impl ChainSource { } } - pub(crate) fn registered_txids(&self) -> Vec { + pub(crate) fn registered_txids(&self) -> HashSet { self.registered_txids.lock().expect("lock").clone() } @@ -472,7 +472,7 @@ impl ChainSource { impl Filter for ChainSource { fn register_tx(&self, txid: &Txid, script_pubkey: &Script) { - self.registered_txids.lock().expect("lock").push(*txid); + self.registered_txids.lock().expect("lock").insert(*txid); match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { esplora_chain_source.register_tx(txid, script_pubkey) From 1f371c5fcbaeef9b797a8404c03fee78ed2cd28f Mon Sep 17 00:00:00 2001 From: Enigbe Date: Wed, 17 Jun 2026 07:49:59 +0100 Subject: [PATCH 024/138] Replace deprecated active_tasks_count with num_alive_tasks --- Cargo.toml | 2 +- src/runtime.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bed984f071..58913d77c9 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ bip21 = { version = "0.5", features = ["std"], default-features = false } base64 = { version = "0.22.1", default-features = false, features = ["std"] } getrandom = { version = "0.3", default-features = false } chrono = { version = "0.4", default-features = false, features = ["clock"] } -tokio = { version = "1.37", default-features = false, features = [ "rt-multi-thread", "time", "sync", "macros", "net" ] } +tokio = { version = "1.39", default-features = false, features = [ "rt-multi-thread", "time", "sync", "macros", "net" ] } esplora-client = { version = "0.12", default-features = false, features = ["tokio", "async-https-rustls"] } electrum-client = { version = "0.25", default-features = false, features = ["proxy", "use-rustls-ring"] } libc = "0.2" diff --git a/src/runtime.rs b/src/runtime.rs index 3f82d704ec..9fb4116e03 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -252,7 +252,7 @@ impl Runtime { log_trace!( self.logger, "Active runtime tasks left prior to shutdown: {}", - runtime_handle.metrics().active_tasks_count() + runtime_handle.metrics().num_alive_tasks() ); } From 5429331f908de595a06cca6e1ffd54c915f94d30 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 14 Apr 2026 14:54:24 -0500 Subject: [PATCH 025/138] Add PaginatedKVStore support to VssStore --- src/io/vss_store.rs | 146 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 4 deletions(-) diff --git a/src/io/vss_store.rs b/src/io/vss_store.rs index 81a63b0c5b..fb4ec9d761 100644 --- a/src/io/vss_store.rs +++ b/src/io/vss_store.rs @@ -24,7 +24,7 @@ use bitcoin::Network; use lightning::impl_writeable_tlv_based_enum; use lightning::io::{self, Error, ErrorKind}; use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes}; -use lightning::util::persist::KVStore; +use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning::util::ser::{Readable, Writeable}; use prost::Message; use vss_client::client::VssClient; @@ -70,6 +70,8 @@ impl_writeable_tlv_based_enum!(VssSchemaVersion, (1, V1) => {}, ); +const PAGE_SIZE: i32 = 50; + const VSS_HARDENED_CHILD_INDEX: u32 = 877; const VSS_SIGS_AUTH_HARDENED_CHILD_INDEX: u32 = 139; const VSS_SCHEMA_VERSION_KEY: &str = "vss_schema_version"; @@ -293,6 +295,32 @@ impl KVStore for VssStore { } } +impl PaginatedKVStore for VssStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + 'static + Send { + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let inner = Arc::clone(&self.inner); + let runtime = self.internal_runtime(); + async move { + let task = runtime.spawn(async move { + inner + .list_paginated_internal( + &inner.async_client, + primary_namespace, + secondary_namespace, + page_token, + ) + .await + }); + task.await.map_err(|e| { + io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e)) + })? + } + } +} + impl Drop for VssStore { fn drop(&mut self) { if let Some(runtime) = self.internal_runtime.take() { @@ -393,9 +421,8 @@ impl VssStoreInner { async fn list_keys( &self, client: &VssClient, primary_namespace: &str, - secondary_namespace: &str, page_token: Option, page_size: Option, + secondary_namespace: &str, key_prefix: String, page_token: Option, page_size: Option, ) -> io::Result<(Vec, Option)> { - let key_prefix = self.build_obfuscated_prefix(primary_namespace, secondary_namespace); let request = ListKeyVersionsRequest { store_id: self.store_id.clone(), key_prefix: Some(key_prefix), @@ -542,11 +569,12 @@ impl VssStoreInner { ) -> io::Result> { check_namespace_key_validity(&primary_namespace, &secondary_namespace, None, "list")?; + let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace); let mut page_token: Option = None; let mut keys = vec![]; loop { let (page_keys, next_page_token) = self - .list_keys(client, &primary_namespace, &secondary_namespace, page_token, None) + .list_keys(client, &primary_namespace, &secondary_namespace, key_prefix.clone(), page_token, None) .await?; keys.extend(page_keys); match next_page_token { @@ -557,6 +585,35 @@ impl VssStoreInner { Ok(keys) } + async fn list_paginated_internal( + &self, client: &VssClient, primary_namespace: String, + secondary_namespace: String, page_token: Option, + ) -> io::Result { + check_namespace_key_validity( + &primary_namespace, + &secondary_namespace, + None, + "list_paginated", + )?; + + let key_prefix = self.build_obfuscated_prefix(&primary_namespace, &secondary_namespace); + let vss_page_token = page_token.map(|t| t.to_string()); + let (keys, next_page_token) = self + .list_keys( + client, + &primary_namespace, + &secondary_namespace, + key_prefix, + vss_page_token, + Some(PAGE_SIZE), + ) + .await?; + + let next_page_token = next_page_token.map(PageToken::new); + + Ok(PaginatedListResponse { keys, next_page_token }) + } + async fn execute_locked_write< F: Future>, FN: FnOnce() -> F, @@ -972,4 +1029,85 @@ mod tests { do_read_write_remove_list_persist(&vss_store).await; drop(vss_store) } + + #[tokio::test] + async fn vss_paginated_listing() { + let store = build_vss_store(); + let ns = "test_paginated"; + let sub = "listing"; + let num_entries = 5; + + for i in 0..num_entries { + let key = format!("key_{:04}", i); + let data = vec![i as u8; 32]; + KVStore::write(&store, ns, sub, &key, data).await.unwrap(); + } + + let mut all_keys = Vec::new(); + let mut page_token = None; + + loop { + let response = + PaginatedKVStore::list_paginated(&store, ns, sub, page_token).await.unwrap(); + all_keys.extend(response.keys); + match response.next_page_token { + Some(token) => page_token = Some(token), + _ => break, + } + } + + assert_eq!(all_keys.len(), num_entries); + + // Verify no duplicates + let mut unique = all_keys.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), num_entries); + } + + #[tokio::test] + async fn vss_paginated_empty_namespace() { + let store = build_vss_store(); + let response = + PaginatedKVStore::list_paginated(&store, "nonexistent", "ns", None).await.unwrap(); + assert!(response.keys.is_empty()); + assert!(response.next_page_token.is_none()); + } + + #[tokio::test] + async fn vss_paginated_removal() { + let store = build_vss_store(); + let ns = "test_paginated"; + let sub = "removal"; + + KVStore::write(&store, ns, sub, "a", vec![1u8; 8]).await.unwrap(); + KVStore::write(&store, ns, sub, "b", vec![2u8; 8]).await.unwrap(); + KVStore::write(&store, ns, sub, "c", vec![3u8; 8]).await.unwrap(); + + KVStore::remove(&store, ns, sub, "b", false).await.unwrap(); + + let response = PaginatedKVStore::list_paginated(&store, ns, sub, None).await.unwrap(); + assert_eq!(response.keys.len(), 2); + assert!(response.keys.contains(&"a".to_string())); + assert!(!response.keys.contains(&"b".to_string())); + assert!(response.keys.contains(&"c".to_string())); + } + + #[tokio::test] + async fn vss_paginated_namespace_isolation() { + let store = build_vss_store(); + + KVStore::write(&store, "ns_a", "sub", "key_1", vec![1u8; 8]).await.unwrap(); + KVStore::write(&store, "ns_a", "sub", "key_2", vec![2u8; 8]).await.unwrap(); + KVStore::write(&store, "ns_b", "sub", "key_3", vec![3u8; 8]).await.unwrap(); + + let response = PaginatedKVStore::list_paginated(&store, "ns_a", "sub", None).await.unwrap(); + assert_eq!(response.keys.len(), 2); + assert!(response.keys.contains(&"key_1".to_string())); + assert!(response.keys.contains(&"key_2".to_string())); + + let response = PaginatedKVStore::list_paginated(&store, "ns_b", "sub", None).await.unwrap(); + assert_eq!(response.keys.len(), 1); + assert!(response.keys.contains(&"key_3".to_string())); + } } From 4fa33ed240ea730fb0e17fe90c117f5398871b16 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 18 Jun 2026 10:05:20 +0200 Subject: [PATCH 026/138] Run `cargo +nightly fmt` on main Unfortunately PR #864 was merged without checking that `cargo fmt` passes, given that our CI is still broken. Turns out it doesn't. Here we fix this by running `cargo +nightly fmt` (given that our weekly job doing that is also on vacation right now). --- src/io/vss_store.rs | 12 ++++++++++-- src/liquidity/mod.rs | 7 +++---- src/runtime.rs | 4 ++-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/io/vss_store.rs b/src/io/vss_store.rs index fb4ec9d761..f6e865bd87 100644 --- a/src/io/vss_store.rs +++ b/src/io/vss_store.rs @@ -421,7 +421,8 @@ impl VssStoreInner { async fn list_keys( &self, client: &VssClient, primary_namespace: &str, - secondary_namespace: &str, key_prefix: String, page_token: Option, page_size: Option, + secondary_namespace: &str, key_prefix: String, page_token: Option, + page_size: Option, ) -> io::Result<(Vec, Option)> { let request = ListKeyVersionsRequest { store_id: self.store_id.clone(), @@ -574,7 +575,14 @@ impl VssStoreInner { let mut keys = vec![]; loop { let (page_keys, next_page_token) = self - .list_keys(client, &primary_namespace, &secondary_namespace, key_prefix.clone(), page_token, None) + .list_keys( + client, + &primary_namespace, + &secondary_namespace, + key_prefix.clone(), + page_token, + None, + ) .await?; keys.extend(page_keys); match next_page_token { diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index c2cdb4de0f..87a0650c83 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -10,10 +10,6 @@ pub(crate) mod client; pub(crate) mod service; -pub use client::lsps1::LSPS1Liquidity; -pub use client::LSPS1OrderStatus; -pub use service::lsps2::LSPS2ServiceConfig; - use std::collections::hash_map::Entry; use std::collections::HashMap; use std::ops::Deref; @@ -21,6 +17,8 @@ use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; use bitcoin::secp256k1::PublicKey; +pub use client::lsps1::LSPS1Liquidity; +pub use client::LSPS1OrderStatus; use lightning::ln::msgs::SocketAddress; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::event::LSPS0ClientEvent; @@ -28,6 +26,7 @@ use lightning_liquidity::lsps1::client::LSPS1ClientConfig as LdkLSPS1ClientConfi use lightning_liquidity::lsps2::client::LSPS2ClientConfig as LdkLSPS2ClientConfig; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig as LdkLSPS2ServiceConfig; use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; +pub use service::lsps2::LSPS2ServiceConfig; use tokio::sync::oneshot; use crate::builder::BuildError; diff --git a/src/runtime.rs b/src/runtime.rs index 9fb4116e03..7e29996e62 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -381,10 +381,10 @@ impl FutureSpawner for RuntimeSpawner { #[cfg(test)] mod tests { - use super::*; - use tokio::sync::oneshot; + use super::*; + fn test_runtime() -> Runtime { Runtime::new(Arc::new(Logger::new_log_facade())).unwrap() } From 5744459c758da441e2ee489607738006d439de8b Mon Sep 17 00:00:00 2001 From: Enigbe Date: Mon, 23 Mar 2026 12:06:07 +0100 Subject: [PATCH 027/138] Expose per-channel features in ChannelDetails We previously flattened ChannelCounterparty fields into ChannelDetails as individual counterparty_* fields, and InitFeatures was entirely omitted. This made it impossible for consumers to access per-peer feature flags, and awkward to access counterparty forwarding information without navigating the flattened field names. This commit replaces the flattened fields with a structured ChannelCounterparty type that mirrors LDK's ChannelCounterparty, exposing InitFeatures and CounterpartyForwardingInfo that were previously inaccessible. We keep outbound_htlc_minimum_msat optional because it is unavailable before receiving OpenChannel or AcceptChannel, and re-export ChannelCounterparty so Rust consumers can name the type. --- src/ffi/types.rs | 244 ++++++++++++++++++++++++++++++- src/lib.rs | 2 +- src/types.rs | 96 ++++++------ tests/integration_tests_rust.rs | 16 +- tests/upgrade_downgrade_tests.rs | 2 +- 5 files changed, 302 insertions(+), 58 deletions(-) diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 7380d75cac..c2cf483880 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -25,7 +25,7 @@ pub use bitcoin::{Address, BlockHash, Network, OutPoint, ScriptBuf, Txid}; pub use lightning::chain::channelmonitor::BalanceSource; use lightning::events::PaidBolt12Invoice as LdkPaidBolt12Invoice; pub use lightning::events::{ClosureReason, PaymentFailureReason}; -use lightning::ln::channel_state::ChannelShutdownState; +use lightning::ln::channel_state::{ChannelShutdownState, CounterpartyForwardingInfo}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::DecodeError; pub use lightning::ln::types::ChannelId; @@ -44,7 +44,7 @@ pub use lightning_liquidity::lsps0::ser::LSPSDateTime; pub use lightning_liquidity::lsps1::msgs::{ LSPS1ChannelInfo, LSPS1OrderId, LSPS1OrderParams, LSPS1PaymentState, }; -use lightning_types::features::NodeFeatures as LdkNodeFeatures; +use lightning_types::features::{InitFeatures as LdkInitFeatures, NodeFeatures as LdkNodeFeatures}; pub use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; pub use lightning_types::string::UntrustedString; use vss_client::headers::{ @@ -1815,6 +1815,246 @@ impl From for NodeFeatures { } } +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Object)] +pub struct InitFeatures { + pub(crate) inner: LdkInitFeatures, +} + +impl InitFeatures { + /// Constructs init features from big-endian BOLT 9 encoded bytes. + #[uniffi::constructor] + pub fn from_bytes(bytes: &[u8]) -> Self { + Self { inner: LdkInitFeatures::from_be_bytes(bytes.to_vec()).into() } + } + + /// Returns the BOLT 9 big-endian encoded representation of these features. + pub fn to_bytes(&self) -> Vec { + self.inner.encode() + } + + /// Whether the peer supports `option_static_remotekey`. + /// + /// This ensures the non-broadcaster's output pays directly to their specified key, + /// simplifying recovery if a channel is force-closed. + pub fn supports_static_remote_key(&self) -> bool { + self.inner.supports_static_remote_key() + } + + /// Whether the peer supports `option_anchors_zero_fee_htlc_tx`. + /// + /// Anchor channels allow fee-bumping commitment transactions after broadcast, + /// improving on-chain fee management. + pub fn supports_anchors_zero_fee_htlc_tx(&self) -> bool { + self.inner.supports_anchors_zero_fee_htlc_tx() + } + + /// Whether the peer supports `option_anchors_nonzero_fee_htlc_tx`. + /// + /// The initial version of anchor outputs, which was later found to be + /// vulnerable and superseded by `option_anchors_zero_fee_htlc_tx`. + pub fn supports_anchors_nonzero_fee_htlc_tx(&self) -> bool { + self.inner.supports_anchors_nonzero_fee_htlc_tx() + } + + /// Whether the peer supports `option_support_large_channel`. + /// + /// When supported, channels larger than 2^24 satoshis (≈0.168 BTC) may be opened. + pub fn supports_wumbo(&self) -> bool { + self.inner.supports_wumbo() + } + + /// Whether the peer supports `option_route_blinding`. + /// + /// Route blinding allows the recipient to hide their node identity and + /// last-hop channel from the sender. + pub fn supports_route_blinding(&self) -> bool { + self.inner.supports_route_blinding() + } + + /// Whether the peer supports `option_onion_messages`. + /// + /// Onion messages enable communication over the Lightning Network without + /// requiring a payment, used by BOLT 12 offers and async payments. + pub fn supports_onion_messages(&self) -> bool { + self.inner.supports_onion_messages() + } + + /// Whether the peer supports `option_scid_alias`. + /// + /// When supported, the peer will only forward using short channel ID aliases, + /// preventing the real channel UTXO from being revealed during routing. + pub fn supports_scid_privacy(&self) -> bool { + self.inner.supports_scid_privacy() + } + + /// Whether the peer supports `option_zeroconf`. + /// + /// Zero-conf channels can be used immediately without waiting for + /// on-chain funding confirmations. + pub fn supports_zero_conf(&self) -> bool { + self.inner.supports_zero_conf() + } + + /// Whether the peer supports `option_dual_fund`. + /// + /// Dual-funded channels allow both parties to contribute funds + /// to the channel opening transaction. + pub fn supports_dual_fund(&self) -> bool { + self.inner.supports_dual_fund() + } + + /// Whether the peer supports `option_quiesce`. + /// + /// Quiescence is a prerequisite for splicing, allowing both sides to + /// pause HTLC activity before modifying the funding transaction. + pub fn supports_quiescence(&self) -> bool { + self.inner.supports_quiescence() + } + + /// Whether the peer supports `option_data_loss_protect`. + /// + /// Allows a node that has fallen behind (e.g., restored from backup) + /// to detect that it is out of date and close the channel safely. + pub fn supports_data_loss_protect(&self) -> bool { + self.inner.supports_data_loss_protect() + } + + /// Whether the peer supports `option_upfront_shutdown_script`. + /// + /// Commits to a shutdown scriptpubkey when opening a channel, + /// preventing a compromised key from redirecting closing funds. + pub fn supports_upfront_shutdown_script(&self) -> bool { + self.inner.supports_upfront_shutdown_script() + } + + /// Whether the peer supports `gossip_queries`. + /// + /// Indicates the peer has useful gossip to share and supports + /// gossip query messages for synchronization. + pub fn supports_gossip_queries(&self) -> bool { + self.inner.supports_gossip_queries() + } + + /// Whether the peer supports `var_onion_optin`. + /// + /// Requires variable-length routing onion payloads, which is + /// assumed to be supported by all modern Lightning nodes. + pub fn supports_variable_length_onion(&self) -> bool { + self.inner.supports_variable_length_onion() + } + + /// Whether the peer supports `payment_secret`. + /// + /// Payment secrets prevent forwarding nodes from probing + /// payment recipients. Assumed to be supported by all modern nodes. + pub fn supports_payment_secret(&self) -> bool { + self.inner.supports_payment_secret() + } + + /// Whether the peer supports `basic_mpp`. + /// + /// Multi-part payments allow splitting a payment across multiple + /// routes for improved reliability and liquidity utilization. + pub fn supports_basic_mpp(&self) -> bool { + self.inner.supports_basic_mpp() + } + + /// Whether the peer supports `opt_shutdown_anysegwit`. + /// + /// Allows future segwit versions in the shutdown script, + /// enabling closing to Taproot or later output types. + pub fn supports_shutdown_anysegwit(&self) -> bool { + self.inner.supports_shutdown_anysegwit() + } + + /// Whether the peer supports `option_channel_type`. + /// + /// Supports explicit channel type negotiation during channel opening. + pub fn supports_channel_type(&self) -> bool { + self.inner.supports_channel_type() + } + + /// Whether the peer supports `option_trampoline`. + /// + /// Trampoline routing allows lightweight nodes to delegate + /// pathfinding to an intermediate trampoline node. + pub fn supports_trampoline_routing(&self) -> bool { + self.inner.supports_trampoline_routing() + } + + /// Whether the peer supports `option_simple_close`. + /// + /// Simplified closing negotiation reduces the number of + /// round trips needed for a cooperative channel close. + pub fn supports_simple_close(&self) -> bool { + self.inner.supports_simple_close() + } + + /// Whether the peer supports `option_splice`. + /// + /// Splicing allows replacing the funding transaction with a new one, + /// enabling on-the-fly capacity changes without closing the channel. + pub fn supports_splicing(&self) -> bool { + self.inner.supports_splicing() + } + + /// Whether the peer supports `option_provide_storage`. + /// + /// Indicates the node offers to store encrypted backup data + /// on behalf of its peers. + pub fn supports_provide_storage(&self) -> bool { + self.inner.supports_provide_storage() + } + + /// Whether the peer set `initial_routing_sync`. + /// + /// Indicates the sending node needs a complete routing information dump. + /// Per BOLT #9, this feature has no even (required) bit. + pub fn initial_routing_sync(&self) -> bool { + self.inner.initial_routing_sync() + } + + /// Whether the peer supports `option_taproot`. + /// + /// Taproot channels use MuSig2-based multisig for funding outputs, + /// improving privacy and efficiency. + pub fn supports_taproot(&self) -> bool { + self.inner.supports_taproot() + } + + /// Whether the peer supports `option_zero_fee_commitments`. + /// + /// A channel type which always uses zero transaction fee on commitment + /// transactions, combined with anchor outputs. + pub fn supports_anchor_zero_fee_commitments(&self) -> bool { + self.inner.supports_anchor_zero_fee_commitments() + } + + /// Whether the peer supports HTLC hold. + /// + /// Supports holding HTLCs and forwarding on receipt of an onion message. + pub fn supports_htlc_hold(&self) -> bool { + self.inner.supports_htlc_hold() + } +} + +impl From for InitFeatures { + fn from(ldk_init: LdkInitFeatures) -> Self { + Self { inner: ldk_init } + } +} +/// Information needed for constructing an invoice route hint for this channel. +#[uniffi::remote(Record)] +pub struct CounterpartyForwardingInfo { + /// Base routing fee in millisatoshis. + pub fee_base_msat: u32, + /// Amount in millionths of a satoshi the channel will charge per transferred satoshi. + pub fee_proportional_millionths: u32, + /// The minimum difference in cltv_expiry between an ingoing HTLC and its outgoing counterpart, + /// such that the outgoing HTLC is forwardable to this counterparty. + pub cltv_expiry_delta: u16, +} + #[cfg(test)] mod tests { use std::num::NonZeroU64; diff --git a/src/lib.rs b/src/lib.rs index b450642879..3ddae3b5a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -179,7 +179,7 @@ use types::{ HRNResolver, KeysManager, OnionMessenger, PaymentStore, PeerManager, Router, Scorer, Sweeper, Wallet, }; -pub use types::{ChannelDetails, CustomTlvRecord, PeerDetails, UserChannelId}; +pub use types::{ChannelCounterparty, ChannelDetails, CustomTlvRecord, PeerDetails, UserChannelId}; pub use vss_client; use crate::ffi::maybe_wrap; diff --git a/src/types.rs b/src/types.rs index 64209430be..bc8759142f 100644 --- a/src/types.rs +++ b/src/types.rs @@ -20,7 +20,9 @@ use bitcoin_payment_instructions::hrn_resolution::{ use bitcoin_payment_instructions::onion_message_resolver::LDKOnionMessageDNSSECHrnResolver; use lightning::chain::chainmonitor; use lightning::impl_writeable_tlv_based; -use lightning::ln::channel_state::{ChannelDetails as LdkChannelDetails, ChannelShutdownState}; +use lightning::ln::channel_state::{ + ChannelDetails as LdkChannelDetails, ChannelShutdownState, CounterpartyForwardingInfo, +}; use lightning::ln::msgs::{RoutingMessageHandler, SocketAddress}; use lightning::ln::peer_handler::IgnoringMessageHandler; use lightning::ln::types::ChannelId; @@ -41,11 +43,17 @@ use crate::chain::ChainSource; use crate::config::ChannelConfig; use crate::data_store::DataStore; use crate::fee_estimator::OnchainFeeEstimator; +use crate::ffi::maybe_wrap; use crate::logger::Logger; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::{PaymentDetails, PendingPaymentDetails}; use crate::runtime::RuntimeSpawner; +#[cfg(not(feature = "uniffi"))] +type InitFeatures = lightning::types::features::InitFeatures; +#[cfg(feature = "uniffi")] +type InitFeatures = Arc; + pub(crate) trait DynStoreTrait: Send + Sync { fn read_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, @@ -341,6 +349,37 @@ impl fmt::Display for UserChannelId { } } +/// Channel parameters which apply to our counterparty. These are split out from [`ChannelDetails`] +/// to better separate parameters. +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct ChannelCounterparty { + /// The node_id of our counterparty + pub node_id: PublicKey, + /// The Features the channel counterparty provided upon last connection. + /// Useful for routing as it is the most up-to-date copy of the counterparty's features and + /// many routing-relevant features are present in the init context. + pub features: InitFeatures, + /// The value, in satoshis, that must always be held in the channel for our counterparty. This + /// value ensures that if our counterparty broadcasts a revoked state, we can punish them by + /// claiming at least this value on chain. + /// + /// This value is not included in [`inbound_capacity_msat`] as it can never be spent. + /// + /// [`inbound_capacity_msat`]: ChannelDetails::inbound_capacity_msat + pub unspendable_punishment_reserve: u64, + /// Information on the fees and requirements that the counterparty requires when forwarding + /// payments to us through this channel. + pub forwarding_info: Option, + /// The smallest value HTLC (in msat) the remote peer will accept, for this channel. + /// + /// Will be `None` before we have received the `OpenChannel` or `AcceptChannel` message + /// from the remote peer. + pub outbound_htlc_minimum_msat: Option, + /// The largest value HTLC (in msat) the remote peer currently will accept, for this channel. + pub outbound_htlc_maximum_msat: Option, +} + /// Details of a channel as returned by [`Node::list_channels`]. /// /// When a channel is spliced, most fields continue to refer to the original pre-splice channel @@ -357,8 +396,8 @@ pub struct ChannelDetails { /// Note that this means this value is *not* persistent - it can change once during the /// lifetime of the channel. pub channel_id: ChannelId, - /// The node ID of our the channel's counterparty. - pub counterparty_node_id: PublicKey, + /// Parameters which apply to our counterparty. See individual fields for more information. + pub counterparty: ChannelCounterparty, /// The channel's funding transaction output, if we've negotiated the funding transaction with /// our counterparty already. /// @@ -474,28 +513,6 @@ pub struct ChannelDetails { /// The difference in the CLTV value between incoming HTLCs and an outbound HTLC forwarded over /// the channel. pub cltv_expiry_delta: Option, - /// The value, in satoshis, that must always be held in the channel for our counterparty. This - /// value ensures that if our counterparty broadcasts a revoked state, we can punish them by - /// claiming at least this value on chain. - /// - /// This value is not included in [`inbound_capacity_msat`] as it can never be spent. - /// - /// [`inbound_capacity_msat`]: ChannelDetails::inbound_capacity_msat - pub counterparty_unspendable_punishment_reserve: u64, - /// The smallest value HTLC (in msat) the remote peer will accept, for this channel. - /// - /// This field is only `None` before we have received either the `OpenChannel` or - /// `AcceptChannel` message from the remote peer. - pub counterparty_outbound_htlc_minimum_msat: Option, - /// The largest value HTLC (in msat) the remote peer currently will accept, for this channel. - pub counterparty_outbound_htlc_maximum_msat: Option, - /// Base routing fee in millisatoshis. - pub counterparty_forwarding_info_fee_base_msat: Option, - /// Proportional fee, in millionths of a satoshi the channel will charge per transferred satoshi. - pub counterparty_forwarding_info_fee_proportional_millionths: Option, - /// The minimum difference in CLTV expiry between an ingoing HTLC and its outgoing counterpart, - /// such that the outgoing HTLC is forwardable to this counterparty. - pub counterparty_forwarding_info_cltv_expiry_delta: Option, /// The available outbound capacity for sending a single HTLC to the remote peer. This is /// similar to [`ChannelDetails::outbound_capacity_msat`] but it may be further restricted by /// the current state and per-HTLC limit(s). This is intended for use when routing, allowing us @@ -533,7 +550,14 @@ impl From for ChannelDetails { fn from(value: LdkChannelDetails) -> Self { ChannelDetails { channel_id: value.channel_id, - counterparty_node_id: value.counterparty.node_id, + counterparty: ChannelCounterparty { + node_id: value.counterparty.node_id, + features: maybe_wrap(value.counterparty.features), + unspendable_punishment_reserve: value.counterparty.unspendable_punishment_reserve, + forwarding_info: value.counterparty.forwarding_info, + outbound_htlc_minimum_msat: value.counterparty.outbound_htlc_minimum_msat, + outbound_htlc_maximum_msat: value.counterparty.outbound_htlc_maximum_msat, + }, funding_txo: value.funding_txo.map(|o| o.into_bitcoin_outpoint()), funding_redeem_script: value.funding_redeem_script, short_channel_id: value.short_channel_id, @@ -554,26 +578,6 @@ impl From for ChannelDetails { is_usable: value.is_usable, is_announced: value.is_announced, cltv_expiry_delta: value.config.map(|c| c.cltv_expiry_delta), - counterparty_unspendable_punishment_reserve: value - .counterparty - .unspendable_punishment_reserve, - counterparty_outbound_htlc_minimum_msat: value.counterparty.outbound_htlc_minimum_msat, - counterparty_outbound_htlc_maximum_msat: value.counterparty.outbound_htlc_maximum_msat, - counterparty_forwarding_info_fee_base_msat: value - .counterparty - .forwarding_info - .as_ref() - .map(|f| f.fee_base_msat), - counterparty_forwarding_info_fee_proportional_millionths: value - .counterparty - .forwarding_info - .as_ref() - .map(|f| f.fee_proportional_millionths), - counterparty_forwarding_info_cltv_expiry_delta: value - .counterparty - .forwarding_info - .as_ref() - .map(|f| f.cltv_expiry_delta), next_outbound_htlc_limit_msat: value.next_outbound_htlc_limit_msat, next_outbound_htlc_minimum_msat: value.next_outbound_htlc_minimum_msat, force_close_spend_delay: value.force_close_spend_delay, diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 5b07ab50d0..6e68db878c 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2242,7 +2242,7 @@ async fn lsps2_client_trusts_lsp() { client_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == service_node_id) + .find(|c| c.counterparty.node_id == service_node_id) .unwrap() .confirmations, Some(0) @@ -2251,7 +2251,7 @@ async fn lsps2_client_trusts_lsp() { service_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == client_node_id) + .find(|c| c.counterparty.node_id == client_node_id) .unwrap() .confirmations, Some(0) @@ -2286,7 +2286,7 @@ async fn lsps2_client_trusts_lsp() { client_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == service_node_id) + .find(|c| c.counterparty.node_id == service_node_id) .unwrap() .confirmations, Some(6) @@ -2295,7 +2295,7 @@ async fn lsps2_client_trusts_lsp() { service_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == client_node_id) + .find(|c| c.counterparty.node_id == client_node_id) .unwrap() .confirmations, Some(6) @@ -2415,7 +2415,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { client_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == service_node_id) + .find(|c| c.counterparty.node_id == service_node_id) .unwrap() .confirmations, Some(6) @@ -2424,7 +2424,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { service_node .list_channels() .iter() - .find(|c| c.counterparty_node_id == client_node_id) + .find(|c| c.counterparty.node_id == client_node_id) .unwrap() .confirmations, Some(6) @@ -2910,7 +2910,7 @@ async fn open_channel_with_all_with_anchors() { assert_eq!(channels.len(), 1); let channel = &channels[0]; assert!(channel.channel_value_sats > premine_amount_sat - anchor_reserve_sat - 500); - assert_eq!(channel.counterparty_node_id, node_b.node_id()); + assert_eq!(channel.counterparty.node_id, node_b.node_id()); assert_eq!(channel.funding_txo.unwrap(), funding_txo); node_a.stop().unwrap(); @@ -2961,7 +2961,7 @@ async fn open_channel_with_all_without_anchors() { assert_eq!(channels.len(), 1); let channel = &channels[0]; assert!(channel.channel_value_sats > premine_amount_sat - 500); - assert_eq!(channel.counterparty_node_id, node_b.node_id()); + assert_eq!(channel.counterparty.node_id, node_b.node_id()); assert_eq!(channel.funding_txo.unwrap(), funding_txo); node_a.stop().unwrap(); diff --git a/tests/upgrade_downgrade_tests.rs b/tests/upgrade_downgrade_tests.rs index b30b5a33c4..f07e49427a 100644 --- a/tests/upgrade_downgrade_tests.rs +++ b/tests/upgrade_downgrade_tests.rs @@ -385,7 +385,7 @@ async fn wait_for_v070_usable_channel(node: &ldk_node_070::Node, counterparty_no fn assert_current_channel_ready(node: &CurrentNode, counterparty_node_id: PublicKey) { let channels = node.list_channels(); - let channel = channels.iter().find(|c| c.counterparty_node_id == counterparty_node_id).unwrap(); + let channel = channels.iter().find(|c| c.counterparty.node_id == counterparty_node_id).unwrap(); assert_eq!(channel.channel_value_sats, CHANNEL_AMOUNT_SAT); assert!(channel.is_channel_ready); } From 8048359a91043b9c1534da68ee558e842baab1d1 Mon Sep 17 00:00:00 2001 From: Enigbe Date: Mon, 23 Mar 2026 19:47:05 +0100 Subject: [PATCH 028/138] Add ReserveType to ChannelDetails We expose the reserve type of each channel through a new ReserveType enum on ChannelDetails. This tells users whether a channel uses adaptive anchor reserves, has no reserve due to a trusted peer, or is a legacy pre-anchor channel. The reserve type is derived at query time in list_channels by checking the channel's type features against trusted_peers_no_reserve. We replace the From implementation with an explicit from_ldk method that takes the anchor channels config. Additionally, we document the rationale behind selecting adaptive reserve type in the unlikely event the anchor channels config was previously set and then later removed. --- src/lib.rs | 10 +++++-- src/types.rs | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3ddae3b5a7..34fa7f54d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -179,7 +179,9 @@ use types::{ HRNResolver, KeysManager, OnionMessenger, PaymentStore, PeerManager, Router, Scorer, Sweeper, Wallet, }; -pub use types::{ChannelCounterparty, ChannelDetails, CustomTlvRecord, PeerDetails, UserChannelId}; +pub use types::{ + ChannelCounterparty, ChannelDetails, CustomTlvRecord, PeerDetails, ReserveType, UserChannelId, +}; pub use vss_client; use crate::ffi::maybe_wrap; @@ -1145,7 +1147,11 @@ impl Node { /// Retrieve a list of known channels. pub fn list_channels(&self) -> Vec { - self.channel_manager.list_channels().into_iter().map(|c| c.into()).collect() + self.channel_manager + .list_channels() + .into_iter() + .map(|c| ChannelDetails::from_ldk(c, self.config.anchor_channels_config.as_ref())) + .collect() } /// Connect to a node on the peer-to-peer network. diff --git a/src/types.rs b/src/types.rs index bc8759142f..914b5dc153 100644 --- a/src/types.rs +++ b/src/types.rs @@ -40,7 +40,7 @@ use lightning_net_tokio::SocketDescriptor; use crate::chain::bitcoind::UtxoSourceClient; use crate::chain::ChainSource; -use crate::config::ChannelConfig; +use crate::config::{AnchorChannelsConfig, ChannelConfig}; use crate::data_store::DataStore; use crate::fee_estimator::OnchainFeeEstimator; use crate::ffi::maybe_wrap; @@ -380,6 +380,47 @@ pub struct ChannelCounterparty { pub outbound_htlc_maximum_msat: Option, } +/// Describes the reserve behavior of a channel based on its type and trust configuration. +/// +/// This captures the combination of the channel's on-chain construction (anchor outputs vs legacy +/// static_remote_key) and whether the counterparty is in our trusted peers list. It tells the +/// user what reserve obligations exist for this channel without exposing internal protocol details. +/// +/// See [`AnchorChannelsConfig`] for how reserve behavior is configured. +/// +/// [`AnchorChannelsConfig`]: crate::config::AnchorChannelsConfig +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum ReserveType { + /// An anchor outputs channel where we maintain a per-channel on-chain reserve for fee + /// bumping force-close transactions. + /// + /// Anchor channels allow either party to fee-bump commitment transactions via CPFP + /// at broadcast time. Because the pre-signed commitment fee may be insufficient under + /// current fee conditions, the broadcaster must supply additional funds (hence adaptive) + /// through an anchor output spend. The reserve ensures sufficient on-chain funds are + /// available to cover this. + /// + /// This is the default for anchor channels when the counterparty is not in + /// [`trusted_peers_no_reserve`]. + /// + /// [`trusted_peers_no_reserve`]: crate::config::AnchorChannelsConfig::trusted_peers_no_reserve + Adaptive, + /// An anchor outputs channel where we do not maintain any reserve, because the counterparty + /// is in our [`trusted_peers_no_reserve`] list. + /// + /// In this mode, we trust the counterparty to broadcast a valid commitment transaction on + /// our behalf and do not set aside funds for fee bumping. + /// + /// [`trusted_peers_no_reserve`]: crate::config::AnchorChannelsConfig::trusted_peers_no_reserve + TrustedPeersNoReserve, + /// A legacy (pre-anchor) channel using only `option_static_remotekey`. + /// + /// These channels do not use anchor outputs and therefore do not require an on-chain reserve + /// for fee bumping. Commitment transaction fees are pre-committed at channel open time. + Legacy, +} + /// Details of a channel as returned by [`Node::list_channels`]. /// /// When a channel is spliced, most fields continue to refer to the original pre-splice channel @@ -544,10 +585,42 @@ pub struct ChannelDetails { /// /// Will be `None` for objects serialized with LDK Node v0.1 and earlier. pub channel_shutdown_state: Option, + /// The type of on-chain reserve maintained for this channel. + /// + /// Will be `None` until channel negotiation has completed and determined whether + /// this channel uses anchor or legacy reserve behavior. + /// + /// See [`ReserveType`] for details on how reserves differ between anchor and legacy channels. + pub reserve_type: Option, } -impl From for ChannelDetails { - fn from(value: LdkChannelDetails) -> Self { +impl ChannelDetails { + pub(crate) fn from_ldk( + 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 let Some(config) = anchor_channels_config { + if config.trusted_peers_no_reserve.contains(&value.counterparty.node_id) { + ReserveType::TrustedPeersNoReserve + } else { + ReserveType::Adaptive + } + } else { + // Edge case: if `AnchorChannelsConfig` was previously set and later + // removed, we can no longer distinguish whether this anchor channel's + // reserve was `Adaptive` or `TrustedPeersNoReserve`. We default to + // `Adaptive` here, which may incorrectly override a prior + // `TrustedPeersNoReserve` designation. This is acceptable since + // unsetting `AnchorChannelsConfig` on a node with existing anchor + // channels is not an expected operation. + ReserveType::Adaptive + } + } else { + ReserveType::Legacy + } + }); + ChannelDetails { channel_id: value.channel_id, counterparty: ChannelCounterparty { @@ -590,6 +663,7 @@ impl From for ChannelDetails { .map(|c| c.into()) .expect("value is set for objects serialized with LDK v0.0.109+"), channel_shutdown_state: value.channel_shutdown_state, + reserve_type, } } } From fee7a73cccf7096f0139364166bfbe89aec6f395 Mon Sep 17 00:00:00 2001 From: Enigbe Date: Wed, 22 Apr 2026 08:51:57 +0100 Subject: [PATCH 029/138] Expose feature helper APIs through FFI We add requires_* counterparts for every supports_* method on InitFeatures, completing the BOLT 9 feature flag coverage for FFI consumers. We export the existing feature helper methods for both InitFeatures and NodeFeatures through UniFFI. NodeFeatures had the same missing export path as InitFeatures and was noticed while addressing the InitFeatures FFI exposure. Additionally, we extend the Python full-cycle test to cover node features and init features on their real runtime paths. --- bindings/python/src/ldk_node/test_ldk_node.py | 31 +++ src/ffi/types.rs | 255 +++++++++++------- 2 files changed, 185 insertions(+), 101 deletions(-) diff --git a/bindings/python/src/ldk_node/test_ldk_node.py b/bindings/python/src/ldk_node/test_ldk_node.py index 4f53dbabfc..177ae1d75a 100644 --- a/bindings/python/src/ldk_node/test_ldk_node.py +++ b/bindings/python/src/ldk_node/test_ldk_node.py @@ -121,6 +121,29 @@ def expect_event(node, expected_event_type): return event +def assert_feature_helpers_return_bool(test_case, features): + feature_methods = [ + method_name for method_name in dir(features) + if method_name.startswith("supports_") or method_name.startswith("requires_") + ] + + test_case.assertGreater(len(feature_methods), 0) + for method_name in feature_methods: + with test_case.subTest(method_name=method_name): + test_case.assertIsInstance(getattr(features, method_name)(), bool) + + +def node_features_exposed(test_case, node_features): + test_case.assertIsInstance(node_features, NodeFeatures) + assert_feature_helpers_return_bool(test_case, node_features) + + +def init_features_exposed(test_case, init_features): + test_case.assertIsInstance(init_features, InitFeatures) + assert_feature_helpers_return_bool(test_case, init_features) + test_case.assertIsInstance(init_features.initial_routing_sync(), bool) + + class TestLdkNode(unittest.TestCase): def setUp(self): @@ -153,6 +176,10 @@ def test_channel_full_cycle(self): node_id_2 = node_2.node_id() print("Node ID 2:", node_id_2) + # Check node-announcement features exposed through NodeStatus. + for node in [node_1, node_2]: + node_features_exposed(self, node.status().node_features) + address_1 = node_1.onchain_payment().new_address() txid_1 = send_to_address(address_1, 100000) address_2 = node_2.onchain_payment().new_address() @@ -200,6 +227,10 @@ def test_channel_full_cycle(self): channel_ready_event_2 = expect_event(node_2, Event.CHANNEL_READY) + # Check negotiated init features exposed through ChannelDetails. + for channel in [node_1.list_channels()[0], node_2.list_channels()[0]]: + init_features_exposed(self, channel.counterparty.features) + description = Bolt11InvoiceDescription.DIRECT("asdf") invoice = node_2.bolt11_payment().receive(2500000, description, 9217) node_1.bolt11_payment().send(invoice, None) diff --git a/src/ffi/types.rs b/src/ffi/types.rs index c2cf483880..9bb03bb075 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -1526,6 +1526,7 @@ pub struct NodeFeatures { pub(crate) inner: LdkNodeFeatures, } +#[uniffi::export] impl NodeFeatures { /// Constructs node features from big-endian BOLT 9 encoded bytes. #[uniffi::constructor] @@ -1816,10 +1817,12 @@ impl From for NodeFeatures { } #[derive(Debug, Clone, PartialEq, Eq, uniffi::Object)] +#[uniffi::export(Debug, Eq)] pub struct InitFeatures { pub(crate) inner: LdkInitFeatures, } +#[uniffi::export] impl InitFeatures { /// Constructs init features from big-endian BOLT 9 encoded bytes. #[uniffi::constructor] @@ -1832,210 +1835,260 @@ impl InitFeatures { self.inner.encode() } - /// Whether the peer supports `option_static_remotekey`. - /// - /// This ensures the non-broadcaster's output pays directly to their specified key, - /// simplifying recovery if a channel is force-closed. + /// Whether the peer's `init` message advertises support for `option_static_remotekey`. pub fn supports_static_remote_key(&self) -> bool { self.inner.supports_static_remote_key() } - /// Whether the peer supports `option_anchors_zero_fee_htlc_tx`. - /// - /// Anchor channels allow fee-bumping commitment transactions after broadcast, - /// improving on-chain fee management. + /// Whether the peer's `init` message requires `option_static_remotekey`. + pub fn requires_static_remote_key(&self) -> bool { + self.inner.requires_static_remote_key() + } + + /// Whether the peer's `init` message advertises support for `option_anchors_zero_fee_htlc_tx`. pub fn supports_anchors_zero_fee_htlc_tx(&self) -> bool { self.inner.supports_anchors_zero_fee_htlc_tx() } - /// Whether the peer supports `option_anchors_nonzero_fee_htlc_tx`. - /// - /// The initial version of anchor outputs, which was later found to be - /// vulnerable and superseded by `option_anchors_zero_fee_htlc_tx`. + /// Whether the peer's `init` message requires `option_anchors_zero_fee_htlc_tx`. + pub fn requires_anchors_zero_fee_htlc_tx(&self) -> bool { + self.inner.requires_anchors_zero_fee_htlc_tx() + } + + /// Whether the peer's `init` message advertises support for `option_anchors_nonzero_fee_htlc_tx`. pub fn supports_anchors_nonzero_fee_htlc_tx(&self) -> bool { self.inner.supports_anchors_nonzero_fee_htlc_tx() } - /// Whether the peer supports `option_support_large_channel`. - /// - /// When supported, channels larger than 2^24 satoshis (≈0.168 BTC) may be opened. + /// Whether the peer's `init` message requires `option_anchors_nonzero_fee_htlc_tx`. + pub fn requires_anchors_nonzero_fee_htlc_tx(&self) -> bool { + self.inner.requires_anchors_nonzero_fee_htlc_tx() + } + + /// Whether the peer's `init` message advertises support for `option_support_large_channel`. pub fn supports_wumbo(&self) -> bool { self.inner.supports_wumbo() } - /// Whether the peer supports `option_route_blinding`. - /// - /// Route blinding allows the recipient to hide their node identity and - /// last-hop channel from the sender. + /// Whether the peer's `init` message requires `option_support_large_channel`. + pub fn requires_wumbo(&self) -> bool { + self.inner.requires_wumbo() + } + + /// Whether the peer's `init` message advertises support for `option_route_blinding`. pub fn supports_route_blinding(&self) -> bool { self.inner.supports_route_blinding() } - /// Whether the peer supports `option_onion_messages`. - /// - /// Onion messages enable communication over the Lightning Network without - /// requiring a payment, used by BOLT 12 offers and async payments. + /// Whether the peer's `init` message requires `option_route_blinding`. + pub fn requires_route_blinding(&self) -> bool { + self.inner.requires_route_blinding() + } + + /// Whether the peer's `init` message advertises support for `option_onion_messages`. pub fn supports_onion_messages(&self) -> bool { self.inner.supports_onion_messages() } - /// Whether the peer supports `option_scid_alias`. - /// - /// When supported, the peer will only forward using short channel ID aliases, - /// preventing the real channel UTXO from being revealed during routing. + /// Whether the peer's `init` message requires `option_onion_messages`. + pub fn requires_onion_messages(&self) -> bool { + self.inner.requires_onion_messages() + } + + /// Whether the peer's `init` message advertises support for `option_scid_alias`. pub fn supports_scid_privacy(&self) -> bool { self.inner.supports_scid_privacy() } - /// Whether the peer supports `option_zeroconf`. - /// - /// Zero-conf channels can be used immediately without waiting for - /// on-chain funding confirmations. + /// Whether the peer's `init` message requires `option_scid_alias`. + pub fn requires_scid_privacy(&self) -> bool { + self.inner.requires_scid_privacy() + } + + /// Whether the peer's `init` message advertises support for `option_zeroconf`. pub fn supports_zero_conf(&self) -> bool { self.inner.supports_zero_conf() } - /// Whether the peer supports `option_dual_fund`. - /// - /// Dual-funded channels allow both parties to contribute funds - /// to the channel opening transaction. + /// Whether the peer's `init` message requires `option_zeroconf`. + pub fn requires_zero_conf(&self) -> bool { + self.inner.requires_zero_conf() + } + + /// Whether the peer's `init` message advertises support for `option_dual_fund`. pub fn supports_dual_fund(&self) -> bool { self.inner.supports_dual_fund() } - /// Whether the peer supports `option_quiesce`. - /// - /// Quiescence is a prerequisite for splicing, allowing both sides to - /// pause HTLC activity before modifying the funding transaction. + /// Whether the peer's `init` message requires `option_dual_fund`. + pub fn requires_dual_fund(&self) -> bool { + self.inner.requires_dual_fund() + } + + /// Whether the peer's `init` message advertises support for `option_quiesce`. pub fn supports_quiescence(&self) -> bool { self.inner.supports_quiescence() } - /// Whether the peer supports `option_data_loss_protect`. - /// - /// Allows a node that has fallen behind (e.g., restored from backup) - /// to detect that it is out of date and close the channel safely. + /// Whether the peer's `init` message requires `option_quiesce`. + pub fn requires_quiescence(&self) -> bool { + self.inner.requires_quiescence() + } + + /// Whether the peer's `init` message advertises support for `option_data_loss_protect`. pub fn supports_data_loss_protect(&self) -> bool { self.inner.supports_data_loss_protect() } - /// Whether the peer supports `option_upfront_shutdown_script`. - /// - /// Commits to a shutdown scriptpubkey when opening a channel, - /// preventing a compromised key from redirecting closing funds. + /// Whether the peer's `init` message requires `option_data_loss_protect`. + pub fn requires_data_loss_protect(&self) -> bool { + self.inner.requires_data_loss_protect() + } + + /// Whether the peer's `init` message advertises support for `option_upfront_shutdown_script`. pub fn supports_upfront_shutdown_script(&self) -> bool { self.inner.supports_upfront_shutdown_script() } - /// Whether the peer supports `gossip_queries`. - /// - /// Indicates the peer has useful gossip to share and supports - /// gossip query messages for synchronization. + /// Whether the peer's `init` message requires `option_upfront_shutdown_script`. + pub fn requires_upfront_shutdown_script(&self) -> bool { + self.inner.requires_upfront_shutdown_script() + } + + /// Whether the peer's `init` message advertises support for `gossip_queries`. pub fn supports_gossip_queries(&self) -> bool { self.inner.supports_gossip_queries() } - /// Whether the peer supports `var_onion_optin`. - /// - /// Requires variable-length routing onion payloads, which is - /// assumed to be supported by all modern Lightning nodes. + /// Whether the peer's `init` message requires `gossip_queries`. + pub fn requires_gossip_queries(&self) -> bool { + self.inner.requires_gossip_queries() + } + + /// Whether the peer's `init` message advertises support for `var_onion_optin`. pub fn supports_variable_length_onion(&self) -> bool { self.inner.supports_variable_length_onion() } - /// Whether the peer supports `payment_secret`. - /// - /// Payment secrets prevent forwarding nodes from probing - /// payment recipients. Assumed to be supported by all modern nodes. + /// Whether the peer's `init` message requires `var_onion_optin`. + pub fn requires_variable_length_onion(&self) -> bool { + self.inner.requires_variable_length_onion() + } + + /// Whether the peer's `init` message advertises support for `payment_secret`. pub fn supports_payment_secret(&self) -> bool { self.inner.supports_payment_secret() } - /// Whether the peer supports `basic_mpp`. - /// - /// Multi-part payments allow splitting a payment across multiple - /// routes for improved reliability and liquidity utilization. + /// Whether the peer's `init` message requires `payment_secret`. + pub fn requires_payment_secret(&self) -> bool { + self.inner.requires_payment_secret() + } + + /// Whether the peer's `init` message advertises support for `basic_mpp`. pub fn supports_basic_mpp(&self) -> bool { self.inner.supports_basic_mpp() } - /// Whether the peer supports `opt_shutdown_anysegwit`. - /// - /// Allows future segwit versions in the shutdown script, - /// enabling closing to Taproot or later output types. + /// Whether the peer's `init` message requires `basic_mpp`. + pub fn requires_basic_mpp(&self) -> bool { + self.inner.requires_basic_mpp() + } + + /// Whether the peer's `init` message advertises support for `opt_shutdown_anysegwit`. pub fn supports_shutdown_anysegwit(&self) -> bool { self.inner.supports_shutdown_anysegwit() } - /// Whether the peer supports `option_channel_type`. - /// - /// Supports explicit channel type negotiation during channel opening. + /// Whether the peer's `init` message requires `opt_shutdown_anysegwit`. + pub fn requires_shutdown_anysegwit(&self) -> bool { + self.inner.requires_shutdown_anysegwit() + } + + /// Whether the peer's `init` message advertises support for `option_channel_type`. pub fn supports_channel_type(&self) -> bool { self.inner.supports_channel_type() } - /// Whether the peer supports `option_trampoline`. - /// - /// Trampoline routing allows lightweight nodes to delegate - /// pathfinding to an intermediate trampoline node. + /// Whether the peer's `init` message requires `option_channel_type`. + pub fn requires_channel_type(&self) -> bool { + self.inner.requires_channel_type() + } + + /// Whether the peer's `init` message advertises support for `option_trampoline`. pub fn supports_trampoline_routing(&self) -> bool { self.inner.supports_trampoline_routing() } - /// Whether the peer supports `option_simple_close`. - /// - /// Simplified closing negotiation reduces the number of - /// round trips needed for a cooperative channel close. + /// Whether the peer's `init` message requires `option_trampoline`. + pub fn requires_trampoline_routing(&self) -> bool { + self.inner.requires_trampoline_routing() + } + + /// Whether the peer's `init` message advertises support for `option_simple_close`. pub fn supports_simple_close(&self) -> bool { self.inner.supports_simple_close() } - /// Whether the peer supports `option_splice`. - /// - /// Splicing allows replacing the funding transaction with a new one, - /// enabling on-the-fly capacity changes without closing the channel. + /// Whether the peer's `init` message requires `option_simple_close`. + pub fn requires_simple_close(&self) -> bool { + self.inner.requires_simple_close() + } + + /// Whether the peer's `init` message advertises support for `option_splice`. pub fn supports_splicing(&self) -> bool { self.inner.supports_splicing() } - /// Whether the peer supports `option_provide_storage`. - /// - /// Indicates the node offers to store encrypted backup data - /// on behalf of its peers. + /// Whether the peer's `init` message requires `option_splice`. + pub fn requires_splicing(&self) -> bool { + self.inner.requires_splicing() + } + + /// Whether the peer's `init` message advertises support for `option_provide_storage`. pub fn supports_provide_storage(&self) -> bool { self.inner.supports_provide_storage() } - /// Whether the peer set `initial_routing_sync`. - /// - /// Indicates the sending node needs a complete routing information dump. - /// Per BOLT #9, this feature has no even (required) bit. + /// Whether the peer's `init` message requires `option_provide_storage`. + pub fn requires_provide_storage(&self) -> bool { + self.inner.requires_provide_storage() + } + + /// Whether the peer's `init` message set `initial_routing_sync`. pub fn initial_routing_sync(&self) -> bool { self.inner.initial_routing_sync() } - /// Whether the peer supports `option_taproot`. - /// - /// Taproot channels use MuSig2-based multisig for funding outputs, - /// improving privacy and efficiency. + /// Whether the peer's `init` message advertises support for `option_taproot`. pub fn supports_taproot(&self) -> bool { self.inner.supports_taproot() } - /// Whether the peer supports `option_zero_fee_commitments`. - /// - /// A channel type which always uses zero transaction fee on commitment - /// transactions, combined with anchor outputs. + /// Whether the peer's `init` message requires `option_taproot`. + pub fn requires_taproot(&self) -> bool { + self.inner.requires_taproot() + } + + /// Whether the peer's `init` message advertises support for `option_zero_fee_commitments`. pub fn supports_anchor_zero_fee_commitments(&self) -> bool { self.inner.supports_anchor_zero_fee_commitments() } - /// Whether the peer supports HTLC hold. - /// - /// Supports holding HTLCs and forwarding on receipt of an onion message. + /// Whether the peer's `init` message requires `option_zero_fee_commitments`. + pub fn requires_anchor_zero_fee_commitments(&self) -> bool { + self.inner.requires_anchor_zero_fee_commitments() + } + + /// Whether the peer's `init` message advertises support for HTLC hold. pub fn supports_htlc_hold(&self) -> bool { self.inner.supports_htlc_hold() } + + /// Whether the peer's `init` message requires HTLC hold. + pub fn requires_htlc_hold(&self) -> bool { + self.inner.requires_htlc_hold() + } } impl From for InitFeatures { From 5907738506c784ae3ffb069f082b50f591665d4c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 24 Jun 2026 10:58:25 +0200 Subject: [PATCH 030/138] Fix 0.6.2 compatibility test shutdown Stop and drop the old compatibility node from a blocking region. This avoids a Tokio runtime-drop panic in the async test. Co-Authored-By: HAL 9000 --- tests/integration_tests_rust.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 6e68db878c..3393c2cd07 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2580,7 +2580,11 @@ async fn build_0_6_2_node( assert!(balance > 0); let node_id = node_old.node_id(); - node_old.stop().unwrap(); + // Workaround necessary as v0.6.2's runtime wasn't dropsafe in a tokio context. + tokio::task::block_in_place(move || { + node_old.stop().unwrap(); + drop(node_old); + }); (balance, node_id) } From 7fa8d0f84c4d25b4bac678bf8c8e7079198735bb Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Mon, 15 Jun 2026 18:06:37 +0100 Subject: [PATCH 031/138] Extract shared setup helpers in Python binding tests Duplicated node setup, funding, channel opening, and teardown logic made integration tests harder to read and maintain. Hard-coded ports also risked collisions when tests run in parallel. Introduce reusable helpers for two-node setup, funding, channel ready waiting, and cleanup. Bind ephemeral ports to avoid conflicts, and refactor test_channel_full_cycle to use them without changing test behavior. --- bindings/python/src/ldk_node/test_ldk_node.py | 119 ++++++++++-------- 1 file changed, 68 insertions(+), 51 deletions(-) diff --git a/bindings/python/src/ldk_node/test_ldk_node.py b/bindings/python/src/ldk_node/test_ldk_node.py index 177ae1d75a..9395a6b316 100644 --- a/bindings/python/src/ldk_node/test_ldk_node.py +++ b/bindings/python/src/ldk_node/test_ldk_node.py @@ -5,6 +5,7 @@ import os import re import requests +import socket from ldk_node import * @@ -118,8 +119,68 @@ def expect_event(node, expected_event_type): assert isinstance(event, expected_event_type) print("EVENT:", event) node.event_handled() - return event - + return event + +def find_two_free_ports(): + with socket.socket() as s1, socket.socket() as s2: + s1.bind(("127.0.0.1", 0)) + s2.bind(("127.0.0.1",0)) + port_1 = s1.getsockname()[1] + port_2 = s2.getsockname()[1] + return port_1, port_2 + +def setup_two_nodes(esplora_endpoint): + port_1, port_2 = find_two_free_ports() + tmp_dir_1 = tempfile.TemporaryDirectory("_ldk_node_1") + listening_addresses_1 = [f"127.0.0.1:{port_1}"] + node_1 = setup_node(tmp_dir_1.name, esplora_endpoint, listening_addresses_1) + node_1.start() + node_id_1 = node_1.node_id() + + tmp_dir_2 = tempfile.TemporaryDirectory("_ldk_node_2") + listening_addresses_2 = [f"127.0.0.1:{port_2}"] + node_2 = setup_node(tmp_dir_2.name, esplora_endpoint, listening_addresses_2) + node_2.start() + node_id_2 = node_2.node_id() + + return node_1, node_2, tmp_dir_1, tmp_dir_2, node_id_1, node_id_2, listening_addresses_2 + +def fund_nodes(node_1, node_2, esplora_endpoint, amount_sats=100000): + address_1 = node_1.onchain_payment().new_address() + txid_1 = send_to_address(address_1, amount_sats) + address_2 = node_2.onchain_payment().new_address() + txid_2 = send_to_address(address_2, amount_sats) + + wait_for_tx(esplora_endpoint, txid_1) + wait_for_tx(esplora_endpoint, txid_2) + mine_and_wait(esplora_endpoint, 6) + + node_1.sync_wallets() + node_2.sync_wallets() + +def open_channel_and_wait_ready(node_1, node_2, node_id_2, listening_address_2, esplora_endpoint, channel_amount_sats=50000): + node_1.open_channel(node_id_2, listening_address_2, channel_amount_sats, None, None) + + channel_pending_event_1 = expect_event(node_1, Event.CHANNEL_PENDING) + expect_event(node_2, Event.CHANNEL_PENDING) + + funding_txid = channel_pending_event_1.funding_txo.txid + wait_for_tx(esplora_endpoint, funding_txid) + mine_and_wait(esplora_endpoint, 6) + + node_1.sync_wallets() + node_2.sync_wallets() + + channel_ready_event_1 = expect_event(node_1, Event.CHANNEL_READY) + channel_ready_event_2 = expect_event(node_2, Event.CHANNEL_READY) + return channel_ready_event_1, channel_ready_event_2, funding_txid + +def stop_and_cleanup(node_1, node_2, tmp_dir_1, tmp_dir_2): + node_1.stop() + node_2.stop() + time.sleep(1) + tmp_dir_1.cleanup() + tmp_dir_2.cleanup() def assert_feature_helpers_return_bool(test_case, features): feature_methods = [ @@ -156,42 +217,17 @@ def setUp(self): def test_channel_full_cycle(self): esplora_endpoint = get_esplora_endpoint() - ## Setup Node 1 - tmp_dir_1 = tempfile.TemporaryDirectory("_ldk_node_1") - print("TMP DIR 1:", tmp_dir_1.name) - - listening_addresses_1 = ["127.0.0.1:2323"] - node_1 = setup_node(tmp_dir_1.name, esplora_endpoint, listening_addresses_1) - node_1.start() - node_id_1 = node_1.node_id() + ## Setup two nodes + node_1, node_2, tmp_dir_1, tmp_dir_2, node_id_1, node_id_2, listening_addresses_2 = setup_two_nodes(esplora_endpoint) print("Node ID 1:", node_id_1) - - # Setup Node 2 - tmp_dir_2 = tempfile.TemporaryDirectory("_ldk_node_2") - print("TMP DIR 2:", tmp_dir_2.name) - - listening_addresses_2 = ["127.0.0.1:2324"] - node_2 = setup_node(tmp_dir_2.name, esplora_endpoint, listening_addresses_2) - node_2.start() - node_id_2 = node_2.node_id() print("Node ID 2:", node_id_2) # Check node-announcement features exposed through NodeStatus. for node in [node_1, node_2]: node_features_exposed(self, node.status().node_features) - address_1 = node_1.onchain_payment().new_address() - txid_1 = send_to_address(address_1, 100000) - address_2 = node_2.onchain_payment().new_address() - txid_2 = send_to_address(address_2, 100000) + fund_nodes(node_1, node_2, esplora_endpoint) - wait_for_tx(esplora_endpoint, txid_1) - wait_for_tx(esplora_endpoint, txid_2) - - mine_and_wait(esplora_endpoint, 6) - - node_1.sync_wallets() - node_2.sync_wallets() spendable_balance_1 = node_1.list_balances().spendable_onchain_balance_sats spendable_balance_2 = node_2.list_balances().spendable_onchain_balance_sats @@ -210,22 +246,9 @@ def test_channel_full_cycle(self): print("TOTAL 2:", total_balance_2) self.assertEqual(total_balance_2, 100000) - node_1.open_channel(node_id_2, listening_addresses_2[0], 50000, None, None) - - - channel_pending_event_1 = expect_event(node_1, Event.CHANNEL_PENDING) - channel_pending_event_2 = expect_event(node_2, Event.CHANNEL_PENDING) - funding_txid = channel_pending_event_1.funding_txo.txid - wait_for_tx(esplora_endpoint, funding_txid) - mine_and_wait(esplora_endpoint, 6) - - node_1.sync_wallets() - node_2.sync_wallets() - - channel_ready_event_1 = expect_event(node_1, Event.CHANNEL_READY) + channel_ready_event_1, channel_ready_event_2, funding_txid = open_channel_and_wait_ready(node_1, node_2, node_id_2, listening_addresses_2[0], esplora_endpoint) print("funding_txo:", funding_txid) - channel_ready_event_2 = expect_event(node_2, Event.CHANNEL_READY) # Check negotiated init features exposed through ChannelDetails. for channel in [node_1.list_channels()[0], node_2.list_channels()[0]]: @@ -259,13 +282,7 @@ def test_channel_full_cycle(self): self.assertEqual(spendable_balance_after_close_2, 102500) # Stop nodes - node_1.stop() - node_2.stop() - - # Cleanup - time.sleep(1) # Wait a sec so our logs can finish writing - tmp_dir_1.cleanup() - tmp_dir_2.cleanup() + stop_and_cleanup(node_1, node_2, tmp_dir_1, tmp_dir_2) if __name__ == '__main__': unittest.main() From b318ccdef5b5fd1e5899a04280522740b63cfcda Mon Sep 17 00:00:00 2001 From: heyolaniran Date: Mon, 15 Jun 2026 18:21:45 +0100 Subject: [PATCH 032/138] Add spontaneous payment Python binding test Exercise the spontaneous payment (keysend) path through the Python UniFFI bindings after a channel is ready. Assert events, custom TLV records, and persisted payment metadata on both sender and receiver. --- bindings/python/src/ldk_node/test_ldk_node.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/bindings/python/src/ldk_node/test_ldk_node.py b/bindings/python/src/ldk_node/test_ldk_node.py index 9395a6b316..304caf9c04 100644 --- a/bindings/python/src/ldk_node/test_ldk_node.py +++ b/bindings/python/src/ldk_node/test_ldk_node.py @@ -214,6 +214,43 @@ def setUp(self): esplora_endpoint = get_esplora_endpoint() mine_and_wait(esplora_endpoint, 1) + def test_spontaneous_payment(self): + """Spontaneous payment test in python: keysend after channel ready.""" + esplora_endpoint = get_esplora_endpoint() + + node_1, node_2, tmp_dir_1, tmp_dir_2, node_id_1, node_id_2, listening_addresses_2 = setup_two_nodes(esplora_endpoint) + fund_nodes(node_1, node_2, esplora_endpoint) + open_channel_and_wait_ready(node_1, node_2, node_id_2, listening_addresses_2[0], esplora_endpoint) + + keysend_amount_msat = 2_500_000 + custom_tlvs = [CustomTlvRecord(type_num=13377331, value=bytes([1, 2, 3]))] + keysend_payment_id = node_1.spontaneous_payment().send_with_custom_tlvs( + keysend_amount_msat, node_id_2, None, custom_tlvs + ) + + expect_event(node_1, Event.PAYMENT_SUCCESSFUL) + received_event = expect_event(node_2, Event.PAYMENT_RECEIVED) + + self.assertEqual(received_event.amount_msat, keysend_amount_msat) + self.assertEqual(received_event.custom_records, custom_tlvs) + + sender_payment = node_1.payment(keysend_payment_id) + receiver_payment = node_2.payment(keysend_payment_id) + + self.assertIsNotNone(sender_payment) + self.assertIsNotNone(receiver_payment) + self.assertEqual(sender_payment.status, PaymentStatus.SUCCEEDED) + self.assertEqual(sender_payment.direction, PaymentDirection.OUTBOUND) + self.assertEqual(sender_payment.amount_msat, keysend_amount_msat) + self.assertTrue(sender_payment.kind.is_spontaneous()) + + self.assertEqual(receiver_payment.status, PaymentStatus.SUCCEEDED) + self.assertEqual(receiver_payment.direction, PaymentDirection.INBOUND) + self.assertEqual(receiver_payment.amount_msat, keysend_amount_msat) + self.assertTrue(receiver_payment.kind.is_spontaneous()) + + stop_and_cleanup(node_1, node_2, tmp_dir_1, tmp_dir_2) + def test_channel_full_cycle(self): esplora_endpoint = get_esplora_endpoint() From 6560dffb931077f6e9914a229e3048d7e48d92b3 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 15 Jun 2026 15:26:43 -0500 Subject: [PATCH 033/138] Keep insert-or-update cache unchanged on persist failure Build the updated object separately and persist it before replacing the cached entry, so failed writes leave memory aligned with storage. This finding was discovered by Project Loupe AI-Assisted-By: OpenAI Codex --- src/data_store.rs | 102 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 19 deletions(-) diff --git a/src/data_store.rs b/src/data_store.rs index 70abfcc3fd..1fbc6e7288 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -5,7 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::collections::{hash_map, HashMap}; +use std::collections::HashMap; use std::ops::Deref; use std::sync::{Arc, Mutex}; @@ -83,28 +83,32 @@ where pub(crate) async fn insert_or_update(&self, object: SO) -> Result { let _guard = self.mutation_lock.lock().await; - let (updated, data_to_persist) = { - let mut locked_objects = self.objects.lock().expect("lock"); - match locked_objects.entry(object.id()) { - hash_map::Entry::Occupied(mut e) => { - let update = object.to_update(); - let updated = e.get_mut().update(update); - let data_to_persist = - if updated { Some(Self::encode_object(e.get())) } else { None }; - (updated, data_to_persist) - }, - hash_map::Entry::Vacant(e) => { - let data_to_persist = Self::encode_object(&object); - e.insert(object); - (true, Some(data_to_persist)) - }, + + let id = object.id(); + let data_to_persist = { + let locked_objects = self.objects.lock().expect("lock"); + if let Some(existing_object) = locked_objects.get(&id) { + let mut updated_object = existing_object.clone(); + let updated = updated_object.update(object.to_update()); + if updated { + Some(updated_object) + } else { + None + } + } else { + Some(object) } }; - if let Some((store_key, data)) = data_to_persist { - self.persist_encoded(store_key, data).await?; + match data_to_persist { + Some(updated_object) => { + self.persist(&updated_object).await?; + let mut locked_objects = self.objects.lock().expect("lock"); + locked_objects.insert(id, updated_object); + Ok(true) + }, + None => Ok(false), } - Ok(updated) } pub(crate) async fn remove(&self, id: &SO::Id) -> Result<(), Error> { @@ -219,6 +223,7 @@ where #[cfg(test)] mod tests { use lightning::impl_writeable_tlv_based; + use lightning::io; use lightning::util::test_utils::TestLogger; use super::*; @@ -281,6 +286,46 @@ mod tests { (2, data, required), }); + struct FailingStore; + + impl KVStore for FailingStore { + fn read( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, + ) -> impl std::future::Future, io::Error>> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "read failed")) } + } + + fn write( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, _buf: Vec, + ) -> impl std::future::Future> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "write failed")) } + } + + fn remove( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, _lazy: bool, + ) -> impl std::future::Future> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "remove failed")) } + } + + fn list( + &self, _primary_namespace: &str, _secondary_namespace: &str, + ) -> impl std::future::Future, io::Error>> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "list failed")) } + } + } + + fn new_failing_data_store(objects: Vec) -> DataStore> { + let store: Arc = Arc::new(DynStoreWrapper(FailingStore)); + let logger = Arc::new(TestLogger::new()); + DataStore::new( + objects, + "datastore_test_primary".to_string(), + "datastore_test_secondary".to_string(), + store, + logger, + ) + } + #[tokio::test] async fn data_is_persisted() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -346,4 +391,23 @@ mod tests { new_iou_object.data[0] += 1; assert_eq!(Ok(true), data_store.insert_or_update(new_iou_object).await); } + + #[tokio::test] + async fn insert_or_update_does_not_mutate_memory_if_persist_fails() { + let existing_id = TestObjectId { id: [42u8; 4] }; + let existing_object = TestObject { id: existing_id, data: [23u8; 3] }; + let data_store = new_failing_data_store(vec![existing_object]); + + let updated_object = TestObject { id: existing_id, data: [24u8; 3] }; + assert_eq!( + Err(Error::PersistenceFailed), + data_store.insert_or_update(updated_object).await + ); + assert_eq!(Some(existing_object), data_store.get(&existing_id)); + + let new_id = TestObjectId { id: [55u8; 4] }; + let new_object = TestObject { id: new_id, data: [34u8; 3] }; + assert_eq!(Err(Error::PersistenceFailed), data_store.insert_or_update(new_object).await); + assert!(data_store.get(&new_id).is_none()); + } } From e3793b858c8cb482a0e6db32176010d8349e8a49 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 15 Jun 2026 15:27:27 -0500 Subject: [PATCH 034/138] Keep update cache unchanged on persist failure Apply updates to a cloned object and only replace the cached entry after the backing store write succeeds. This finding was discovered by Project Loupe AI-Assisted-By: OpenAI Codex --- src/data_store.rs | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/data_store.rs b/src/data_store.rs index 1fbc6e7288..4ae43d2f39 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -150,23 +150,23 @@ where pub(crate) async fn update(&self, update: SO::Update) -> Result { let _guard = self.mutation_lock.lock().await; - let (res, data_to_persist) = { - let mut locked_objects = self.objects.lock().expect("lock"); - if let Some(object) = locked_objects.get_mut(&update.id()) { - let updated = object.update(update); - if updated { - (DataStoreUpdateResult::Updated, Some(Self::encode_object(object))) - } else { - (DataStoreUpdateResult::Unchanged, None) - } - } else { - (DataStoreUpdateResult::NotFound, None) + let id = update.id(); + let updated_object = { + let locked_objects = self.objects.lock().expect("lock"); + let Some(object) = locked_objects.get(&id) else { + return Ok(DataStoreUpdateResult::NotFound); + }; + let mut updated_object = object.clone(); + if !updated_object.update(update) { + return Ok(DataStoreUpdateResult::Unchanged); } + updated_object }; - if let Some((store_key, data)) = data_to_persist { - self.persist_encoded(store_key, data).await?; - } - Ok(res) + + self.persist(&updated_object).await?; + let mut locked_objects = self.objects.lock().expect("lock"); + locked_objects.insert(id, updated_object); + Ok(DataStoreUpdateResult::Updated) } /// Returns in-memory objects matching `f`. @@ -410,4 +410,15 @@ mod tests { assert_eq!(Err(Error::PersistenceFailed), data_store.insert_or_update(new_object).await); assert!(data_store.get(&new_id).is_none()); } + + #[tokio::test] + async fn update_does_not_mutate_memory_if_persist_fails() { + let id = TestObjectId { id: [42u8; 4] }; + let object = TestObject { id, data: [23u8; 3] }; + let data_store = new_failing_data_store(vec![object]); + + let update = TestObjectUpdate { id, data: [24u8; 3] }; + assert_eq!(Err(Error::PersistenceFailed), data_store.update(update).await); + assert_eq!(Some(object), data_store.get(&id)); + } } From 531d52d670b54f1762aaf4efac9f66bd3eb70729 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 15 Jun 2026 15:28:11 -0500 Subject: [PATCH 035/138] Keep removed objects cached on persist failure Check for the object first, remove it from the backing store, and only then delete it from the in-memory map. This finding was discovered by Project Loupe AI-Assisted-By: OpenAI Codex --- src/data_store.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/data_store.rs b/src/data_store.rs index 4ae43d2f39..b9b045e444 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -113,8 +113,8 @@ where pub(crate) async fn remove(&self, id: &SO::Id) -> Result<(), Error> { let _guard = self.mutation_lock.lock().await; - let removed = { self.objects.lock().expect("lock").remove(id).is_some() }; - if removed { + let should_remove = { self.objects.lock().expect("lock").contains_key(id) }; + if should_remove { let store_key = id.encode_to_hex_str(); KVStore::remove( &*self.kv_store, @@ -135,6 +135,7 @@ where ); Error::PersistenceFailed })?; + self.objects.lock().expect("lock").remove(id); } Ok(()) } @@ -421,4 +422,14 @@ mod tests { assert_eq!(Err(Error::PersistenceFailed), data_store.update(update).await); assert_eq!(Some(object), data_store.get(&id)); } + + #[tokio::test] + async fn remove_does_not_mutate_memory_if_persist_fails() { + let id = TestObjectId { id: [42u8; 4] }; + let object = TestObject { id, data: [23u8; 3] }; + let data_store = new_failing_data_store(vec![object]); + + assert_eq!(Err(Error::PersistenceFailed), data_store.remove(&id).await); + assert_eq!(Some(object), data_store.get(&id)); + } } From 0d2d71ab1273eb388186bd96da94e0f999add71a Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 15 Jun 2026 15:28:45 -0500 Subject: [PATCH 036/138] Cover insert cache behavior on persist failure Add regression coverage for insert's existing persist-before-cache behavior and update datastore reader comments to match the completed write ordering. This finding was discovered by Project Loupe AI-Assisted-By: OpenAI Codex --- src/data_store.rs | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/data_store.rs b/src/data_store.rs index b9b045e444..3176e7ce2c 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -143,8 +143,8 @@ where /// Returns the current in-memory object for `id`. /// /// The async mutation lock serializes writers, but this synchronous reader cannot wait on it. - /// Until store reads are async, callers may temporarily see in-memory state that is either - /// still being persisted or has not yet caught up to a write in progress. + /// Until store reads are async, callers may temporarily see in-memory state that has not yet + /// caught up to a write in progress. pub(crate) fn get(&self, id: &SO::Id) -> Option { self.objects.lock().expect("lock").get(id).cloned() } @@ -173,8 +173,8 @@ where /// Returns in-memory objects matching `f`. /// /// The async mutation lock serializes writers, but this synchronous reader cannot wait on it. - /// Until store reads are async, callers may temporarily see in-memory state that is either - /// still being persisted or has not yet caught up to a write in progress. + /// Until store reads are async, callers may temporarily see in-memory state that has not yet + /// caught up to a write in progress. pub(crate) fn list_filter bool>(&self, f: F) -> Vec { self.objects.lock().expect("lock").values().filter(f).cloned().collect::>() } @@ -214,8 +214,8 @@ where /// Returns whether the in-memory store contains `id`. /// /// The async mutation lock serializes writers, but this synchronous reader cannot wait on it. - /// Until store reads are async, callers may temporarily see in-memory state that is either - /// still being persisted or has not yet caught up to a write in progress. + /// Until store reads are async, callers may temporarily see in-memory state that has not yet + /// caught up to a write in progress. pub(crate) fn contains_key(&self, id: &SO::Id) -> bool { self.objects.lock().expect("lock").contains_key(id) } @@ -412,6 +412,16 @@ mod tests { assert!(data_store.get(&new_id).is_none()); } + #[tokio::test] + async fn insert_does_not_mutate_memory_if_persist_fails() { + let id = TestObjectId { id: [42u8; 4] }; + let object = TestObject { id, data: [23u8; 3] }; + let data_store = new_failing_data_store(vec![]); + + assert_eq!(Err(Error::PersistenceFailed), data_store.insert(object).await); + assert!(data_store.get(&id).is_none()); + } + #[tokio::test] async fn update_does_not_mutate_memory_if_persist_fails() { let id = TestObjectId { id: [42u8; 4] }; From 2288ab565d2f3a3e9b256eaed311243268c36a67 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 18 Jun 2026 10:44:57 -0500 Subject: [PATCH 037/138] Require PaginatedKVStore for all stores Now that all of our KVStore impl's also impl PaginatedKVStore we can require it when building a node. One caveat being that our upgrade downgrade test no longer works given that these new PaginatedKVStores don't support downgrading to their old, non paginated versions. Because of this we are commenting it out and will bring it back once we tag 0.8 --- src/builder.rs | 20 +- src/types.rs | 56 ++- tests/common/mod.rs | 48 +- tests/upgrade_downgrade_tests.rs | 804 ++++++++++++++++--------------- 4 files changed, 513 insertions(+), 415 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 3df594b7cf..d142f51afc 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -37,8 +37,8 @@ use lightning::routing::scoring::{ use lightning::sign::{EntropySource, NodeSigner}; use lightning::util::config::HTLCInterceptionFlags; use lightning::util::persist::{ - KVStore, CHANNEL_MANAGER_PERSISTENCE_KEY, CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, - CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, + KVStore, PaginatedKVStore, CHANNEL_MANAGER_PERSISTENCE_KEY, + CHANNEL_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, CHANNEL_MANAGER_PERSISTENCE_SECONDARY_NAMESPACE, }; use lightning::util::ser::ReadableArgs; use lightning::util::sweep::OutputSweeper; @@ -254,7 +254,7 @@ impl std::error::Error for BuildError {} /// - [`build`] uses an SQLite database (recommended default). /// - [`build_with_fs_store`] uses a filesystem-based store. /// - [`build_with_vss_store`] and variants use a [VSS] remote store (**experimental**). -/// - [`build_with_store`] allows providing a custom [`KVStore`] implementation. +/// - [`build_with_store`] allows providing a custom [`PaginatedKVStore`] implementation. /// /// ### Logging /// @@ -270,7 +270,7 @@ impl std::error::Error for BuildError {} /// [`build_with_vss_store`]: Self::build_with_vss_store /// [`build_with_store`]: Self::build_with_store /// [VSS]: https://github.com/lightningdevkit/vss-server/blob/main/README.md -/// [`KVStore`]: lightning::util::persist::KVStore +/// [`PaginatedKVStore`]: lightning::util::persist::PaginatedKVStore /// [`DEFAULT_LOG_LEVEL`]: crate::config::DEFAULT_LOG_LEVEL /// [`set_filesystem_logger`]: Self::set_filesystem_logger /// [`set_log_facade_logger`]: Self::set_log_facade_logger @@ -813,7 +813,7 @@ impl NodeBuilder { } /// Builds a [`Node`] instance according to the options previously configured. - pub fn build_with_store( + pub fn build_with_store( &self, node_entropy: NodeEntropy, kv_store: S, ) -> Result { let logger = setup_logger(&self.log_writer_config, &self.config)?; @@ -832,14 +832,14 @@ impl NodeBuilder { } } - fn build_with_store_and_logger( + fn build_with_store_and_logger( &self, node_entropy: NodeEntropy, kv_store: S, logger: Arc, ) -> Result { let runtime = self.setup_runtime(&logger)?; self.build_with_store_runtime_and_logger(node_entropy, kv_store, runtime, logger) } - fn build_with_store_runtime_and_logger( + fn build_with_store_runtime_and_logger( &self, node_entropy: NodeEntropy, kv_store: S, runtime: Arc, logger: Arc, ) -> Result { let seed_bytes = node_entropy.to_seed_bytes(); @@ -876,7 +876,7 @@ impl NodeBuilder { /// - [`build`] uses an SQLite database (recommended default). /// - [`build_with_fs_store`] uses a filesystem-based store. /// - [`build_with_vss_store`] and variants use a [VSS] remote store (**experimental**). -/// - [`build_with_store`] allows providing a custom [`KVStore`] implementation. +/// - [`build_with_store`] allows providing a custom [`PaginatedKVStore`] implementation. /// /// ### Logging /// @@ -892,7 +892,7 @@ impl NodeBuilder { /// [`build_with_vss_store`]: Self::build_with_vss_store /// [`build_with_store`]: Self::build_with_store /// [VSS]: https://github.com/lightningdevkit/vss-server/blob/main/README.md -/// [`KVStore`]: lightning::util::persist::KVStore +/// [`PaginatedKVStore`]: lightning::util::persist::PaginatedKVStore /// [`DEFAULT_LOG_LEVEL`]: crate::config::DEFAULT_LOG_LEVEL /// [`set_filesystem_logger`]: Self::set_filesystem_logger /// [`set_log_facade_logger`]: Self::set_log_facade_logger @@ -1330,7 +1330,7 @@ impl ArcedNodeBuilder { /// Builds a [`Node`] instance according to the options previously configured. // Note that the generics here don't actually work for Uniffi, but we don't currently expose // this so its not needed. - pub fn build_with_store( + pub fn build_with_store( &self, node_entropy: Arc, kv_store: S, ) -> Result, BuildError> { self.inner.read().expect("lock").build_with_store(*node_entropy, kv_store).map(Arc::new) diff --git a/src/types.rs b/src/types.rs index 914b5dc153..e24db4d253 100644 --- a/src/types.rs +++ b/src/types.rs @@ -31,7 +31,9 @@ use lightning::routing::gossip; use lightning::routing::router::DefaultRouter; use lightning::routing::scoring::{CombinedScorer, ProbabilisticScoringFeeParameters}; use lightning::sign::InMemorySigner; -use lightning::util::persist::{KVStore, MonitorUpdatingPersisterAsync}; +use lightning::util::persist::{ + KVStore, MonitorUpdatingPersisterAsync, PageToken, PaginatedKVStore, PaginatedListResponse, +}; use lightning::util::ser::{Readable, Writeable, Writer}; use lightning::util::sweep::OutputSweeper; use lightning_block_sync::gossip::GossipVerifier; @@ -67,6 +69,13 @@ pub(crate) trait DynStoreTrait: Send + Sync { fn list_async( &self, primary_namespace: &str, secondary_namespace: &str, ) -> Pin, bitcoin::io::Error>> + Send + 'static>>; + fn list_paginated_async( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> Pin< + Box< + dyn Future> + Send + 'static, + >, + >; } impl<'a> KVStore for dyn DynStoreTrait + 'a { @@ -95,6 +104,19 @@ impl<'a> KVStore for dyn DynStoreTrait + 'a { } } +impl<'a> PaginatedKVStore for dyn DynStoreTrait + 'a { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + Send + 'static { + DynStoreTrait::list_paginated_async( + self, + primary_namespace, + secondary_namespace, + page_token, + ) + } +} + pub(crate) type DynStore = dyn DynStoreTrait; // Newtype wrapper that implements `KVStore` for `Arc`. This is needed because `KVStore` @@ -130,9 +152,22 @@ impl KVStore for DynStoreRef { } } -pub(crate) struct DynStoreWrapper(pub(crate) T); +impl PaginatedKVStore for DynStoreRef { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + Send + 'static { + DynStoreTrait::list_paginated_async( + &*self.0, + primary_namespace, + secondary_namespace, + page_token, + ) + } +} + +pub(crate) struct DynStoreWrapper(pub(crate) T); -impl DynStoreTrait for DynStoreWrapper { +impl DynStoreTrait for DynStoreWrapper { fn read_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, ) -> Pin, bitcoin::io::Error>> + Send + 'static>> { @@ -156,6 +191,21 @@ impl DynStoreTrait for DynStoreWrapper { ) -> Pin, bitcoin::io::Error>> + Send + 'static>> { Box::pin(KVStore::list(&self.0, primary_namespace, secondary_namespace)) } + + fn list_paginated_async( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> Pin< + Box< + dyn Future> + Send + 'static, + >, + > { + Box::pin(PaginatedKVStore::list_paginated( + &self.0, + primary_namespace, + secondary_namespace, + page_token, + )) + } } pub(crate) type AsyncPersister = MonitorUpdatingPersisterAsync< diff --git a/tests/common/mod.rs b/tests/common/mod.rs index adeb327bf0..1f5753e55a 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -50,7 +50,7 @@ use ldk_node::{ use lightning::io; use lightning::ln::msgs::SocketAddress; use lightning::routing::gossip::NodeAlias; -use lightning::util::persist::KVStore; +use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning_invoice::{Bolt11InvoiceDescription, Description}; use lightning_persister::fs_store::v1::FilesystemStore; use lightning_types::payment::{PaymentHash, PaymentPreimage}; @@ -1702,6 +1702,21 @@ impl KVStore for TestSyncStore { } } +impl PaginatedKVStore for TestSyncStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + 'static + Send { + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let inner = Arc::clone(&self.inner); + async move { + inner + .list_paginated_internal_async(&primary_namespace, &secondary_namespace, page_token) + .await + } + } +} + struct TestSyncStoreInner { serializer: tokio::sync::RwLock<()>, test_store: InMemoryStore, @@ -1765,6 +1780,37 @@ impl TestSyncStoreInner { self.do_list_async(primary_namespace, secondary_namespace).await } + async fn list_paginated_internal_async( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> lightning::io::Result { + let _guard = self.serializer.read().await; + let sqlite_res = PaginatedKVStore::list_paginated( + &self.sqlite_store, + primary_namespace, + secondary_namespace, + page_token.clone(), + ) + .await; + let test_res = PaginatedKVStore::list_paginated( + &self.test_store, + primary_namespace, + secondary_namespace, + page_token, + ) + .await; + + match sqlite_res { + Ok(sqlite_response) => { + assert_eq!(sqlite_response, test_res.unwrap()); + Ok(sqlite_response) + }, + Err(e) => { + assert!(test_res.is_err()); + Err(e) + }, + } + } + async fn read_internal_async( &self, primary_namespace: &str, secondary_namespace: &str, key: &str, ) -> lightning::io::Result> { diff --git a/tests/upgrade_downgrade_tests.rs b/tests/upgrade_downgrade_tests.rs index f07e49427a..de5bef96e8 100644 --- a/tests/upgrade_downgrade_tests.rs +++ b/tests/upgrade_downgrade_tests.rs @@ -11,409 +11,411 @@ // reads filesystem-store v1 data. There is no supported v2-to-v1 IO-layer downgrade: // v2 stores empty namespaces under `[empty]`, which v1 readers do not look up. // +// TODO(@benthecarman) Bring back after 0.8 is cut. + // To keep monitoring whether the serialized node/channel/payment state remains // understandable by v0.7.0, these tests intentionally write current state through // the legacy v1 filesystem-store implementation via `build_with_store`, then // reopen it with v0.7.0's `build_with_fs_store`. -#[allow(unused_imports, unused_macros)] -mod common; - -use std::path::PathBuf; -use std::time::Duration; - -use bitcoin::secp256k1::PublicKey; -use bitcoin::Amount; -use common::{ - generate_blocks_and_wait, generate_listening_addresses, premine_and_distribute_funds, - random_storage_path, setup_bitcoind_and_electrsd, wait_for_tx, -}; -use ldk_node::config::{Config, EsploraSyncConfig}; -use ldk_node::entropy::NodeEntropy; -use ldk_node::lightning::ln::msgs::SocketAddress as CurrentSocketAddress; -use ldk_node::lightning_invoice::{ - Bolt11InvoiceDescription as CurrentBolt11InvoiceDescription, Description as CurrentDescription, -}; -use lightning_persister::fs_store::v1::FilesystemStore; - -#[cfg(feature = "uniffi")] -type CurrentNode = std::sync::Arc; -#[cfg(not(feature = "uniffi"))] -type CurrentNode = ldk_node::Node; - -const NODE_A_SEED_BYTES: [u8; 64] = [42; 64]; -const NODE_B_SEED_BYTES: [u8; 64] = [43; 64]; -const FUNDING_AMOUNT_SAT: u64 = 2_000_000; -const CHANNEL_AMOUNT_SAT: u64 = 1_000_000; -const PUSH_AMOUNT_MSAT: u64 = 500_000_000; -const PRE_DOWNGRADE_PAYMENT_MSAT: u64 = 100_000; -const POST_DOWNGRADE_PAYMENT_MSAT: u64 = 200_000; - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn monitor_v0_7_0_serialization_downgrade_channel_payment() { - let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); - - let storage_path_a = random_storage_path().to_str().unwrap().to_owned(); - let storage_path_b = random_storage_path().to_str().unwrap().to_owned(); - let current_addresses_a = generate_listening_addresses(); - let current_addresses_b = generate_listening_addresses(); - let v070_addresses_a = to_v070_socket_addresses(¤t_addresses_a); - let v070_addresses_b = to_v070_socket_addresses(¤t_addresses_b); - - let node_id_a; - let node_id_b; - let pre_downgrade_payment_id; - - { - let node_a = build_current_node( - storage_path_a.clone(), - NODE_A_SEED_BYTES, - current_addresses_a.clone(), - "downgrade-a", - &esplora_url, - ); - let node_b = build_current_node( - storage_path_b.clone(), - NODE_B_SEED_BYTES, - current_addresses_b.clone(), - "downgrade-b", - &esplora_url, - ); - node_id_a = node_a.node_id(); - node_id_b = node_b.node_id(); - - let addr_a = node_a.onchain_payment().new_address().unwrap(); - let addr_b = node_b.onchain_payment().new_address().unwrap(); - premine_and_distribute_funds( - &bitcoind.client, - &electrsd.client, - vec![addr_a, addr_b], - Amount::from_sat(FUNDING_AMOUNT_SAT), - ) - .await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, FUNDING_AMOUNT_SAT); - assert_eq!(node_b.list_balances().spendable_onchain_balance_sats, FUNDING_AMOUNT_SAT); - - let funding_txo = open_current_channel(&node_a, &node_b).await; - wait_for_tx(&electrsd.client, funding_txo.txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - expect_current_channel_ready(&node_a, node_id_b).await; - expect_current_channel_ready(&node_b, node_id_a).await; - assert_current_channel_ready(&node_a, node_id_b); - assert_current_channel_ready(&node_b, node_id_a); - - pre_downgrade_payment_id = send_current_bolt11_payment( - &node_a, - &node_b, - PRE_DOWNGRADE_PAYMENT_MSAT, - "pre-downgrade", - ) - .await; - - node_a.stop().unwrap(); - node_b.stop().unwrap(); - } - - let node_a_v070 = build_v070_node( - storage_path_a, - NODE_A_SEED_BYTES, - v070_addresses_a.clone(), - "downgrade-a", - &esplora_url, - ); - let node_b_v070 = build_v070_node( - storage_path_b, - NODE_B_SEED_BYTES, - v070_addresses_b.clone(), - "downgrade-b", - &esplora_url, - ); - - assert_eq!(node_a_v070.node_id(), node_id_a); - assert_eq!(node_b_v070.node_id(), node_id_b); - - let pre_downgrade_payment_id = - ldk_node_070::lightning::ln::channelmanager::PaymentId(pre_downgrade_payment_id.0); - assert_v070_bolt11_payment( - &node_a_v070, - &pre_downgrade_payment_id, - ldk_node_070::payment::PaymentDirection::Outbound, - PRE_DOWNGRADE_PAYMENT_MSAT, - ); - assert_v070_bolt11_payment( - &node_b_v070, - &pre_downgrade_payment_id, - ldk_node_070::payment::PaymentDirection::Inbound, - PRE_DOWNGRADE_PAYMENT_MSAT, - ); - - node_a_v070.sync_wallets().unwrap(); - node_b_v070.sync_wallets().unwrap(); - node_a_v070.connect(node_id_b, v070_addresses_b.first().unwrap().clone(), true).unwrap(); - wait_for_v070_usable_channel(&node_a_v070, node_id_b).await; - wait_for_v070_usable_channel(&node_b_v070, node_id_a).await; - drain_v070_events(&node_a_v070).await; - drain_v070_events(&node_b_v070).await; - - send_v070_bolt11_payment( - &node_a_v070, - &node_b_v070, - POST_DOWNGRADE_PAYMENT_MSAT, - "post-downgrade", - ) - .await; - - node_a_v070.stop().unwrap(); - node_b_v070.stop().unwrap(); -} - -fn build_current_node( - storage_path: String, seed_bytes: [u8; 64], listening_addresses: Vec, - alias: &str, esplora_url: &str, -) -> CurrentNode { - let mut config = Config::default(); - config.network = bitcoin::Network::Regtest; - config.storage_dir_path = storage_path; - config.listening_addresses = Some(listening_addresses); - config.anchor_channels_config = None; - - // Use the v1 filesystem layout that v0.7.0's filesystem builder can reopen. - let mut fs_store_path = PathBuf::from(&config.storage_dir_path); - fs_store_path.push("fs_store"); - #[allow(unused_mut)] - let mut builder = ldk_node::Builder::from_config(config); - builder.set_node_alias(alias.to_string()).unwrap(); - - let mut sync_config = EsploraSyncConfig::default(); - sync_config.background_sync_config = None; - builder.set_chain_source_esplora(esplora_url.to_owned(), Some(sync_config)); - - #[cfg(feature = "uniffi")] - let node_entropy = std::sync::Arc::new(NodeEntropy::from_seed_bytes(seed_bytes.to_vec()).unwrap()); - #[cfg(not(feature = "uniffi"))] - let node_entropy = NodeEntropy::from_seed_bytes(seed_bytes); - - let kv_store = FilesystemStore::new(fs_store_path); - let node = builder.build_with_store(node_entropy.into(), kv_store).unwrap(); - node.start().unwrap(); - node -} - -fn build_v070_node( - storage_path: String, seed_bytes: [u8; 64], - listening_addresses: Vec, alias: &str, - esplora_url: &str, -) -> ldk_node_070::Node { - let mut builder = ldk_node_070::Builder::new(); - builder.set_network(bitcoin::Network::Regtest); - builder.set_storage_dir_path(storage_path); - builder.set_entropy_seed_bytes(seed_bytes); - builder.set_listening_addresses(listening_addresses).unwrap(); - builder.set_node_alias(alias.to_string()).unwrap(); - builder.set_chain_source_esplora(esplora_url.to_owned(), None); - let node = builder.build_with_fs_store().unwrap(); - node.start().unwrap(); - node -} - -async fn open_current_channel(node_a: &CurrentNode, node_b: &CurrentNode) -> bitcoin::OutPoint { - node_a - .open_channel( - node_b.node_id(), - node_b.listening_addresses().unwrap().first().unwrap().clone(), - CHANNEL_AMOUNT_SAT, - Some(PUSH_AMOUNT_MSAT), - None, - ) - .unwrap(); - - let funding_txo_a = expect_current_channel_pending(node_a, node_b.node_id()).await; - let funding_txo_b = expect_current_channel_pending(node_b, node_a.node_id()).await; - assert_eq!(funding_txo_a, funding_txo_b); - funding_txo_a -} - -async fn send_current_bolt11_payment( - payer: &CurrentNode, payee: &CurrentNode, amount_msat: u64, description: &str, -) -> ldk_node::lightning::ln::channelmanager::PaymentId { - let invoice_description = CurrentBolt11InvoiceDescription::Direct( - CurrentDescription::new(description.to_owned()).unwrap(), - ); - let invoice = payee - .bolt11_payment() - .receive(amount_msat, &invoice_description.clone().into(), 3600) - .unwrap(); - let payment_id = payer.bolt11_payment().send(&invoice, None).unwrap(); - expect_current_payment_successful(payer, &payment_id).await; - expect_current_payment_received(payee, amount_msat).await; - assert_eq!( - payer.payment(&payment_id).unwrap().status, - ldk_node::payment::PaymentStatus::Succeeded - ); - payment_id -} - -async fn send_v070_bolt11_payment( - payer: &ldk_node_070::Node, payee: &ldk_node_070::Node, amount_msat: u64, description: &str, -) { - let invoice_description = ldk_node_070::lightning_invoice::Bolt11InvoiceDescription::Direct( - ldk_node_070::lightning_invoice::Description::new(description.to_owned()).unwrap(), - ); - let invoice = payee.bolt11_payment().receive(amount_msat, &invoice_description, 3600).unwrap(); - let payment_id = payer.bolt11_payment().send(&invoice, None).unwrap(); - expect_v070_payment_successful(payer, &payment_id).await; - expect_v070_payment_received(payee, amount_msat).await; - assert_eq!( - payer.payment(&payment_id).unwrap().status, - ldk_node_070::payment::PaymentStatus::Succeeded - ); -} - -async fn expect_current_channel_pending( - node: &CurrentNode, expected_counterparty: PublicKey, -) -> bitcoin::OutPoint { - match next_current_event(node).await { - ldk_node::Event::ChannelPending { counterparty_node_id, funding_txo, .. } => { - assert_eq!(counterparty_node_id, expected_counterparty); - node.event_handled().unwrap(); - funding_txo - }, - event => panic!("{} got unexpected event: {:?}", node.node_id(), event), - } -} - -async fn expect_current_channel_ready(node: &CurrentNode, expected_counterparty: PublicKey) { - match next_current_event(node).await { - ldk_node::Event::ChannelReady { counterparty_node_id, .. } => { - assert_eq!(counterparty_node_id, Some(expected_counterparty)); - node.event_handled().unwrap(); - }, - event => panic!("{} got unexpected event: {:?}", node.node_id(), event), - } -} - -async fn expect_current_payment_successful( - node: &CurrentNode, expected_payment_id: &ldk_node::lightning::ln::channelmanager::PaymentId, -) { - match next_current_event(node).await { - ldk_node::Event::PaymentSuccessful { payment_id, .. } => { - assert_eq!(payment_id.as_ref(), Some(expected_payment_id)); - node.event_handled().unwrap(); - }, - event => panic!("{} got unexpected event: {:?}", node.node_id(), event), - } -} - -async fn expect_current_payment_received(node: &CurrentNode, expected_amount_msat: u64) { - match next_current_event(node).await { - ldk_node::Event::PaymentReceived { amount_msat, payment_id, .. } => { - assert_eq!(amount_msat, expected_amount_msat); - assert!(payment_id.is_some()); - node.event_handled().unwrap(); - }, - event => panic!("{} got unexpected event: {:?}", node.node_id(), event), - } -} - -async fn expect_v070_payment_successful( - node: &ldk_node_070::Node, - expected_payment_id: &ldk_node_070::lightning::ln::channelmanager::PaymentId, -) { - match next_v070_event(node).await { - ldk_node_070::Event::PaymentSuccessful { payment_id, .. } => { - assert_eq!(payment_id.as_ref(), Some(expected_payment_id)); - node.event_handled().unwrap(); - }, - event => panic!("{} got unexpected event: {:?}", node.node_id(), event), - } -} - -async fn expect_v070_payment_received(node: &ldk_node_070::Node, expected_amount_msat: u64) { - match next_v070_event(node).await { - ldk_node_070::Event::PaymentReceived { amount_msat, payment_id, .. } => { - assert_eq!(amount_msat, expected_amount_msat); - assert!(payment_id.is_some()); - node.event_handled().unwrap(); - }, - event => panic!("{} got unexpected event: {:?}", node.node_id(), event), - } -} - -async fn next_current_event(node: &CurrentNode) -> ldk_node::Event { - tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), node.next_event_async()) - .await - .unwrap_or_else(|_| panic!("{} timed out waiting for event", node.node_id())) -} - -async fn next_v070_event(node: &ldk_node_070::Node) -> ldk_node_070::Event { - tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), node.next_event_async()) - .await - .unwrap_or_else(|_| panic!("{} timed out waiting for event", node.node_id())) -} - -async fn drain_v070_events(node: &ldk_node_070::Node) { - while tokio::time::timeout(Duration::from_millis(250), node.next_event_async()).await.is_ok() { - node.event_handled().unwrap(); - } -} - -async fn wait_for_v070_usable_channel(node: &ldk_node_070::Node, counterparty_node_id: PublicKey) { - for _ in 0..40 { - let channels = node.list_channels(); - if let Some(channel) = - channels.iter().find(|c| c.counterparty_node_id == counterparty_node_id) - { - assert_eq!(channel.channel_value_sats, CHANNEL_AMOUNT_SAT); - if channel.is_channel_ready && channel.is_usable { - return; - } - } - tokio::time::sleep(Duration::from_millis(250)).await; - } - - panic!( - "{} failed to restore a usable v0.7.0 channel with {}", - node.node_id(), - counterparty_node_id - ); -} - -fn assert_current_channel_ready(node: &CurrentNode, counterparty_node_id: PublicKey) { - let channels = node.list_channels(); - let channel = channels.iter().find(|c| c.counterparty.node_id == counterparty_node_id).unwrap(); - assert_eq!(channel.channel_value_sats, CHANNEL_AMOUNT_SAT); - assert!(channel.is_channel_ready); -} - -fn assert_v070_bolt11_payment( - node: &ldk_node_070::Node, payment_id: &ldk_node_070::lightning::ln::channelmanager::PaymentId, - expected_direction: ldk_node_070::payment::PaymentDirection, expected_amount_msat: u64, -) { - let payment = node.payment(payment_id).unwrap(); - assert_eq!(payment.amount_msat, Some(expected_amount_msat)); - assert_eq!(payment.direction, expected_direction); - assert_eq!(payment.status, ldk_node_070::payment::PaymentStatus::Succeeded); - assert!(matches!(payment.kind, ldk_node_070::payment::PaymentKind::Bolt11 { .. })); -} - -fn to_v070_socket_addresses( - addresses: &[CurrentSocketAddress], -) -> Vec { - addresses - .iter() - .map(|address| match address { - CurrentSocketAddress::TcpIpV4 { addr, port } => { - ldk_node_070::lightning::ln::msgs::SocketAddress::TcpIpV4 { - addr: *addr, - port: *port, - } - }, - _ => panic!("unexpected non-IPv4 test address: {:?}", address), - }) - .collect() -} +// #[allow(unused_imports, unused_macros)] +// mod common; +// +// use std::path::PathBuf; +// use std::time::Duration; +// +// use bitcoin::secp256k1::PublicKey; +// use bitcoin::Amount; +// use common::{ +// generate_blocks_and_wait, generate_listening_addresses, premine_and_distribute_funds, +// random_storage_path, setup_bitcoind_and_electrsd, wait_for_tx, +// }; +// use ldk_node::config::{Config, EsploraSyncConfig}; +// use ldk_node::entropy::NodeEntropy; +// use ldk_node::lightning::ln::msgs::SocketAddress as CurrentSocketAddress; +// use ldk_node::lightning_invoice::{ +// Bolt11InvoiceDescription as CurrentBolt11InvoiceDescription, Description as CurrentDescription, +// }; +// use lightning_persister::fs_store::v1::FilesystemStore; +// +// #[cfg(feature = "uniffi")] +// type CurrentNode = std::sync::Arc; +// #[cfg(not(feature = "uniffi"))] +// type CurrentNode = ldk_node::Node; +// +// const NODE_A_SEED_BYTES: [u8; 64] = [42; 64]; +// const NODE_B_SEED_BYTES: [u8; 64] = [43; 64]; +// const FUNDING_AMOUNT_SAT: u64 = 2_000_000; +// const CHANNEL_AMOUNT_SAT: u64 = 1_000_000; +// const PUSH_AMOUNT_MSAT: u64 = 500_000_000; +// const PRE_DOWNGRADE_PAYMENT_MSAT: u64 = 100_000; +// const POST_DOWNGRADE_PAYMENT_MSAT: u64 = 200_000; +// +// #[tokio::test(flavor = "multi_thread", worker_threads = 1)] +// async fn monitor_v0_7_0_serialization_downgrade_channel_payment() { +// let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); +// let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); +// +// let storage_path_a = random_storage_path().to_str().unwrap().to_owned(); +// let storage_path_b = random_storage_path().to_str().unwrap().to_owned(); +// let current_addresses_a = generate_listening_addresses(); +// let current_addresses_b = generate_listening_addresses(); +// let v070_addresses_a = to_v070_socket_addresses(¤t_addresses_a); +// let v070_addresses_b = to_v070_socket_addresses(¤t_addresses_b); +// +// let node_id_a; +// let node_id_b; +// let pre_downgrade_payment_id; +// +// { +// let node_a = build_current_node( +// storage_path_a.clone(), +// NODE_A_SEED_BYTES, +// current_addresses_a.clone(), +// "downgrade-a", +// &esplora_url, +// ); +// let node_b = build_current_node( +// storage_path_b.clone(), +// NODE_B_SEED_BYTES, +// current_addresses_b.clone(), +// "downgrade-b", +// &esplora_url, +// ); +// node_id_a = node_a.node_id(); +// node_id_b = node_b.node_id(); +// +// let addr_a = node_a.onchain_payment().new_address().unwrap(); +// let addr_b = node_b.onchain_payment().new_address().unwrap(); +// premine_and_distribute_funds( +// &bitcoind.client, +// &electrsd.client, +// vec![addr_a, addr_b], +// Amount::from_sat(FUNDING_AMOUNT_SAT), +// ) +// .await; +// node_a.sync_wallets().unwrap(); +// node_b.sync_wallets().unwrap(); +// assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, FUNDING_AMOUNT_SAT); +// assert_eq!(node_b.list_balances().spendable_onchain_balance_sats, FUNDING_AMOUNT_SAT); +// +// let funding_txo = open_current_channel(&node_a, &node_b).await; +// wait_for_tx(&electrsd.client, funding_txo.txid).await; +// generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; +// node_a.sync_wallets().unwrap(); +// node_b.sync_wallets().unwrap(); +// expect_current_channel_ready(&node_a, node_id_b).await; +// expect_current_channel_ready(&node_b, node_id_a).await; +// assert_current_channel_ready(&node_a, node_id_b); +// assert_current_channel_ready(&node_b, node_id_a); +// +// pre_downgrade_payment_id = send_current_bolt11_payment( +// &node_a, +// &node_b, +// PRE_DOWNGRADE_PAYMENT_MSAT, +// "pre-downgrade", +// ) +// .await; +// +// node_a.stop().unwrap(); +// node_b.stop().unwrap(); +// } +// +// let node_a_v070 = build_v070_node( +// storage_path_a, +// NODE_A_SEED_BYTES, +// v070_addresses_a.clone(), +// "downgrade-a", +// &esplora_url, +// ); +// let node_b_v070 = build_v070_node( +// storage_path_b, +// NODE_B_SEED_BYTES, +// v070_addresses_b.clone(), +// "downgrade-b", +// &esplora_url, +// ); +// +// assert_eq!(node_a_v070.node_id(), node_id_a); +// assert_eq!(node_b_v070.node_id(), node_id_b); +// +// let pre_downgrade_payment_id = +// ldk_node_070::lightning::ln::channelmanager::PaymentId(pre_downgrade_payment_id.0); +// assert_v070_bolt11_payment( +// &node_a_v070, +// &pre_downgrade_payment_id, +// ldk_node_070::payment::PaymentDirection::Outbound, +// PRE_DOWNGRADE_PAYMENT_MSAT, +// ); +// assert_v070_bolt11_payment( +// &node_b_v070, +// &pre_downgrade_payment_id, +// ldk_node_070::payment::PaymentDirection::Inbound, +// PRE_DOWNGRADE_PAYMENT_MSAT, +// ); +// +// node_a_v070.sync_wallets().unwrap(); +// node_b_v070.sync_wallets().unwrap(); +// node_a_v070.connect(node_id_b, v070_addresses_b.first().unwrap().clone(), true).unwrap(); +// wait_for_v070_usable_channel(&node_a_v070, node_id_b).await; +// wait_for_v070_usable_channel(&node_b_v070, node_id_a).await; +// drain_v070_events(&node_a_v070).await; +// drain_v070_events(&node_b_v070).await; +// +// send_v070_bolt11_payment( +// &node_a_v070, +// &node_b_v070, +// POST_DOWNGRADE_PAYMENT_MSAT, +// "post-downgrade", +// ) +// .await; +// +// node_a_v070.stop().unwrap(); +// node_b_v070.stop().unwrap(); +// } +// +// fn build_current_node( +// storage_path: String, seed_bytes: [u8; 64], listening_addresses: Vec, +// alias: &str, esplora_url: &str, +// ) -> CurrentNode { +// let mut config = Config::default(); +// config.network = bitcoin::Network::Regtest; +// config.storage_dir_path = storage_path; +// config.listening_addresses = Some(listening_addresses); +// config.anchor_channels_config = None; +// +// // Use the v1 filesystem layout that v0.7.0's filesystem builder can reopen. +// let mut fs_store_path = PathBuf::from(&config.storage_dir_path); +// fs_store_path.push("fs_store"); +// #[allow(unused_mut)] +// let mut builder = ldk_node::Builder::from_config(config); +// builder.set_node_alias(alias.to_string()).unwrap(); +// +// let mut sync_config = EsploraSyncConfig::default(); +// sync_config.background_sync_config = None; +// builder.set_chain_source_esplora(esplora_url.to_owned(), Some(sync_config)); +// +// #[cfg(feature = "uniffi")] +// let node_entropy = std::sync::Arc::new(NodeEntropy::from_seed_bytes(seed_bytes.to_vec()).unwrap()); +// #[cfg(not(feature = "uniffi"))] +// let node_entropy = NodeEntropy::from_seed_bytes(seed_bytes); +// +// let kv_store = FilesystemStore::new(fs_store_path); +// let node = builder.build_with_store(node_entropy.into(), kv_store).unwrap(); +// node.start().unwrap(); +// node +// } +// +// fn build_v070_node( +// storage_path: String, seed_bytes: [u8; 64], +// listening_addresses: Vec, alias: &str, +// esplora_url: &str, +// ) -> ldk_node_070::Node { +// let mut builder = ldk_node_070::Builder::new(); +// builder.set_network(bitcoin::Network::Regtest); +// builder.set_storage_dir_path(storage_path); +// builder.set_entropy_seed_bytes(seed_bytes); +// builder.set_listening_addresses(listening_addresses).unwrap(); +// builder.set_node_alias(alias.to_string()).unwrap(); +// builder.set_chain_source_esplora(esplora_url.to_owned(), None); +// let node = builder.build_with_fs_store().unwrap(); +// node.start().unwrap(); +// node +// } +// +// async fn open_current_channel(node_a: &CurrentNode, node_b: &CurrentNode) -> bitcoin::OutPoint { +// node_a +// .open_channel( +// node_b.node_id(), +// node_b.listening_addresses().unwrap().first().unwrap().clone(), +// CHANNEL_AMOUNT_SAT, +// Some(PUSH_AMOUNT_MSAT), +// None, +// ) +// .unwrap(); +// +// let funding_txo_a = expect_current_channel_pending(node_a, node_b.node_id()).await; +// let funding_txo_b = expect_current_channel_pending(node_b, node_a.node_id()).await; +// assert_eq!(funding_txo_a, funding_txo_b); +// funding_txo_a +// } +// +// async fn send_current_bolt11_payment( +// payer: &CurrentNode, payee: &CurrentNode, amount_msat: u64, description: &str, +// ) -> ldk_node::lightning::ln::channelmanager::PaymentId { +// let invoice_description = CurrentBolt11InvoiceDescription::Direct( +// CurrentDescription::new(description.to_owned()).unwrap(), +// ); +// let invoice = payee +// .bolt11_payment() +// .receive(amount_msat, &invoice_description.clone().into(), 3600) +// .unwrap(); +// let payment_id = payer.bolt11_payment().send(&invoice, None).unwrap(); +// expect_current_payment_successful(payer, &payment_id).await; +// expect_current_payment_received(payee, amount_msat).await; +// assert_eq!( +// payer.payment(&payment_id).unwrap().status, +// ldk_node::payment::PaymentStatus::Succeeded +// ); +// payment_id +// } +// +// async fn send_v070_bolt11_payment( +// payer: &ldk_node_070::Node, payee: &ldk_node_070::Node, amount_msat: u64, description: &str, +// ) { +// let invoice_description = ldk_node_070::lightning_invoice::Bolt11InvoiceDescription::Direct( +// ldk_node_070::lightning_invoice::Description::new(description.to_owned()).unwrap(), +// ); +// let invoice = payee.bolt11_payment().receive(amount_msat, &invoice_description, 3600).unwrap(); +// let payment_id = payer.bolt11_payment().send(&invoice, None).unwrap(); +// expect_v070_payment_successful(payer, &payment_id).await; +// expect_v070_payment_received(payee, amount_msat).await; +// assert_eq!( +// payer.payment(&payment_id).unwrap().status, +// ldk_node_070::payment::PaymentStatus::Succeeded +// ); +// } +// +// async fn expect_current_channel_pending( +// node: &CurrentNode, expected_counterparty: PublicKey, +// ) -> bitcoin::OutPoint { +// match next_current_event(node).await { +// ldk_node::Event::ChannelPending { counterparty_node_id, funding_txo, .. } => { +// assert_eq!(counterparty_node_id, expected_counterparty); +// node.event_handled().unwrap(); +// funding_txo +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn expect_current_channel_ready(node: &CurrentNode, expected_counterparty: PublicKey) { +// match next_current_event(node).await { +// ldk_node::Event::ChannelReady { counterparty_node_id, .. } => { +// assert_eq!(counterparty_node_id, Some(expected_counterparty)); +// node.event_handled().unwrap(); +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn expect_current_payment_successful( +// node: &CurrentNode, expected_payment_id: &ldk_node::lightning::ln::channelmanager::PaymentId, +// ) { +// match next_current_event(node).await { +// ldk_node::Event::PaymentSuccessful { payment_id, .. } => { +// assert_eq!(payment_id.as_ref(), Some(expected_payment_id)); +// node.event_handled().unwrap(); +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn expect_current_payment_received(node: &CurrentNode, expected_amount_msat: u64) { +// match next_current_event(node).await { +// ldk_node::Event::PaymentReceived { amount_msat, payment_id, .. } => { +// assert_eq!(amount_msat, expected_amount_msat); +// assert!(payment_id.is_some()); +// node.event_handled().unwrap(); +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn expect_v070_payment_successful( +// node: &ldk_node_070::Node, +// expected_payment_id: &ldk_node_070::lightning::ln::channelmanager::PaymentId, +// ) { +// match next_v070_event(node).await { +// ldk_node_070::Event::PaymentSuccessful { payment_id, .. } => { +// assert_eq!(payment_id.as_ref(), Some(expected_payment_id)); +// node.event_handled().unwrap(); +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn expect_v070_payment_received(node: &ldk_node_070::Node, expected_amount_msat: u64) { +// match next_v070_event(node).await { +// ldk_node_070::Event::PaymentReceived { amount_msat, payment_id, .. } => { +// assert_eq!(amount_msat, expected_amount_msat); +// assert!(payment_id.is_some()); +// node.event_handled().unwrap(); +// }, +// event => panic!("{} got unexpected event: {:?}", node.node_id(), event), +// } +// } +// +// async fn next_current_event(node: &CurrentNode) -> ldk_node::Event { +// tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), node.next_event_async()) +// .await +// .unwrap_or_else(|_| panic!("{} timed out waiting for event", node.node_id())) +// } +// +// async fn next_v070_event(node: &ldk_node_070::Node) -> ldk_node_070::Event { +// tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), node.next_event_async()) +// .await +// .unwrap_or_else(|_| panic!("{} timed out waiting for event", node.node_id())) +// } +// +// async fn drain_v070_events(node: &ldk_node_070::Node) { +// while tokio::time::timeout(Duration::from_millis(250), node.next_event_async()).await.is_ok() { +// node.event_handled().unwrap(); +// } +// } +// +// async fn wait_for_v070_usable_channel(node: &ldk_node_070::Node, counterparty_node_id: PublicKey) { +// for _ in 0..40 { +// let channels = node.list_channels(); +// if let Some(channel) = +// channels.iter().find(|c| c.counterparty_node_id == counterparty_node_id) +// { +// assert_eq!(channel.channel_value_sats, CHANNEL_AMOUNT_SAT); +// if channel.is_channel_ready && channel.is_usable { +// return; +// } +// } +// tokio::time::sleep(Duration::from_millis(250)).await; +// } +// +// panic!( +// "{} failed to restore a usable v0.7.0 channel with {}", +// node.node_id(), +// counterparty_node_id +// ); +// } +// +// fn assert_current_channel_ready(node: &CurrentNode, counterparty_node_id: PublicKey) { +// let channels = node.list_channels(); +// let channel = channels.iter().find(|c| c.counterparty.node_id == counterparty_node_id).unwrap(); +// assert_eq!(channel.channel_value_sats, CHANNEL_AMOUNT_SAT); +// assert!(channel.is_channel_ready); +// } +// +// fn assert_v070_bolt11_payment( +// node: &ldk_node_070::Node, payment_id: &ldk_node_070::lightning::ln::channelmanager::PaymentId, +// expected_direction: ldk_node_070::payment::PaymentDirection, expected_amount_msat: u64, +// ) { +// let payment = node.payment(payment_id).unwrap(); +// assert_eq!(payment.amount_msat, Some(expected_amount_msat)); +// assert_eq!(payment.direction, expected_direction); +// assert_eq!(payment.status, ldk_node_070::payment::PaymentStatus::Succeeded); +// assert!(matches!(payment.kind, ldk_node_070::payment::PaymentKind::Bolt11 { .. })); +// } +// +// fn to_v070_socket_addresses( +// addresses: &[CurrentSocketAddress], +// ) -> Vec { +// addresses +// .iter() +// .map(|address| match address { +// CurrentSocketAddress::TcpIpV4 { addr, port } => { +// ldk_node_070::lightning::ln::msgs::SocketAddress::TcpIpV4 { +// addr: *addr, +// port: *port, +// } +// }, +// _ => panic!("unexpected non-IPv4 test address: {:?}", address), +// }) +// .collect() +// } From 83f71f0828e706ee13a5b4ff231032055b48b8ee Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 5 Jun 2026 10:56:06 -0500 Subject: [PATCH 038/138] Add 60 minute timeout to CI jobs We ran out of our CI limit largely from ldk-node. We had a few jobs this week run for multiple hours because of a hanging test. Add 60 minute timeout to all our jobs to prevent this in the future. --- .github/workflows/audit.yml | 1 + .github/workflows/benchmarks.yml | 1 + .github/workflows/cln-integration.yml | 1 + .github/workflows/cron-weekly-rustfmt.yml | 1 + .github/workflows/eclair-integration.yml | 1 + .github/workflows/hrn-integration.yml | 3 ++- .github/workflows/kotlin.yml | 1 + .github/workflows/lnd-integration.yml | 1 + .github/workflows/python.yml | 1 + .github/workflows/rust.yml | 3 +++ .github/workflows/semver.yml | 1 + .github/workflows/swift.yml | 1 + .github/workflows/vss-integration.yml | 1 + .github/workflows/vss-no-auth-integration.yml | 1 + 14 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index e2ae378dd7..5e5149ac5a 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -6,6 +6,7 @@ on: jobs: audit: + timeout-minutes: 60 permissions: issues: write checks: write diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index cd3980b9af..32cd4782b9 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -8,6 +8,7 @@ concurrency: jobs: benchmark: + timeout-minutes: 60 runs-on: ubuntu-latest env: TOOLCHAIN: stable diff --git a/.github/workflows/cln-integration.yml b/.github/workflows/cln-integration.yml index 81eb822502..3c1a8f5809 100644 --- a/.github/workflows/cln-integration.yml +++ b/.github/workflows/cln-integration.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-cln: + timeout-minutes: 60 runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/cron-weekly-rustfmt.yml b/.github/workflows/cron-weekly-rustfmt.yml index 9e54ab9f32..7bb55a86d1 100644 --- a/.github/workflows/cron-weekly-rustfmt.yml +++ b/.github/workflows/cron-weekly-rustfmt.yml @@ -11,6 +11,7 @@ on: jobs: format: name: Nightly rustfmt + timeout-minutes: 60 runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/eclair-integration.yml b/.github/workflows/eclair-integration.yml index 56d51b77ee..daa4572ccd 100644 --- a/.github/workflows/eclair-integration.yml +++ b/.github/workflows/eclair-integration.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-eclair: + timeout-minutes: 60 runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/hrn-integration.yml b/.github/workflows/hrn-integration.yml index f7ded7bc56..767210f100 100644 --- a/.github/workflows/hrn-integration.yml +++ b/.github/workflows/hrn-integration.yml @@ -8,6 +8,7 @@ concurrency: jobs: build-and-test: + timeout-minutes: 60 runs-on: ubuntu-latest steps: @@ -42,4 +43,4 @@ jobs: - name: Run HRN Integration Tests run: | RUSTFLAGS="--cfg no_download --cfg hrn_tests $RUSTFLAGS" cargo test --test integration_tests_hrn - RUSTFLAGS="--cfg no_download --cfg hrn_tests $RUSTFLAGS" cargo test --test integration_tests_hrn --features uniffi \ No newline at end of file + RUSTFLAGS="--cfg no_download --cfg hrn_tests $RUSTFLAGS" cargo test --test integration_tests_hrn --features uniffi diff --git a/.github/workflows/kotlin.yml b/.github/workflows/kotlin.yml index f4d55e3bcc..f3066e4c7e 100644 --- a/.github/workflows/kotlin.yml +++ b/.github/workflows/kotlin.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-kotlin: + timeout-minutes: 60 runs-on: ubuntu-latest env: diff --git a/.github/workflows/lnd-integration.yml b/.github/workflows/lnd-integration.yml index caefbdb6b2..6006ecf2ba 100644 --- a/.github/workflows/lnd-integration.yml +++ b/.github/workflows/lnd-integration.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-lnd: + timeout-minutes: 60 runs-on: ubuntu-latest steps: - name: Checkout repository diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index e154faa7e9..be5bbeb25a 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-python: + timeout-minutes: 60 runs-on: ubuntu-latest env: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 16064fa45c..d8e0932b18 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -8,6 +8,7 @@ concurrency: jobs: build: + timeout-minutes: 60 strategy: matrix: platform: [ @@ -92,6 +93,7 @@ jobs: linting: name: Linting + timeout-minutes: 60 runs-on: ubuntu-latest steps: - name: Checkout source code @@ -107,6 +109,7 @@ jobs: doc: name: Documentation + timeout-minutes: 60 runs-on: ubuntu-latest env: RUSTDOCFLAGS: -Dwarnings diff --git a/.github/workflows/semver.yml b/.github/workflows/semver.yml index 0fdfbe2137..52c505b5b8 100644 --- a/.github/workflows/semver.yml +++ b/.github/workflows/semver.yml @@ -3,6 +3,7 @@ on: [push, pull_request] jobs: semver-checks: + timeout-minutes: 60 runs-on: ubuntu-latest steps: - name: Checkout source code diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index c1e385e2d3..2973892bf9 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -8,6 +8,7 @@ concurrency: jobs: check-swift: + timeout-minutes: 60 runs-on: macos-latest steps: diff --git a/.github/workflows/vss-integration.yml b/.github/workflows/vss-integration.yml index c67e9194e1..7ffea3dd67 100644 --- a/.github/workflows/vss-integration.yml +++ b/.github/workflows/vss-integration.yml @@ -8,6 +8,7 @@ concurrency: jobs: build-and-test: + timeout-minutes: 60 runs-on: ubuntu-latest services: diff --git a/.github/workflows/vss-no-auth-integration.yml b/.github/workflows/vss-no-auth-integration.yml index 35666df038..8ee2fe54b9 100644 --- a/.github/workflows/vss-no-auth-integration.yml +++ b/.github/workflows/vss-no-auth-integration.yml @@ -8,6 +8,7 @@ concurrency: jobs: build-and-test: + timeout-minutes: 60 runs-on: ubuntu-latest services: From c2ee8b061ac8c35fcf9d77a00da63e83c822c35d Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 5 Jun 2026 11:08:06 -0500 Subject: [PATCH 039/138] Split CI between self-hosted and GitHub runners Run the Rust build/test matrix, linting, docs, and benchmarks on the self-hosted runner, but keep jobs the runner cannot serve on GitHub's ubuntu-latest: - Docker-based integration tests (cln, eclair, lnd, python, kotlin) and the Postgres/VSS service-container jobs, since the self-hosted runner has no Docker installed. - Third-party node-action jobs (semver checks, security audit, nightly rustfmt), since the runner is too old to load actions that require the node24 runtime. For the jobs that stay self-hosted, adapt to the runner environment: - Pin actions/checkout to v4 and actions/cache to v4; their newer releases run on node24, which the self-hosted runner does not support. - Install the Rust toolchain in its own step so rustup and cargo land on PATH for the steps that follow; invoking them in the same step as the rustup install fails because PATH is not refreshed mid-step. Assisted by Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/benchmarks.yml | 11 ++++++----- .github/workflows/cron-weekly-rustfmt.yml | 2 +- .github/workflows/hrn-integration.yml | 2 +- .github/workflows/rust.yml | 23 ++++++++++++----------- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 32cd4782b9..4a884ab2a6 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -9,25 +9,26 @@ concurrency: jobs: benchmark: timeout-minutes: 60 - runs-on: ubuntu-latest + runs-on: self-hosted env: TOOLCHAIN: stable steps: - name: Checkout source code - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Install Rust toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable - rustup override set stable + - name: Set Rust override + run: rustup override set stable - name: Enable caching for bitcoind id: cache-bitcoind - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} key: bitcoind-29.0-${{ runner.os }}-${{ runner.arch }} - name: Enable caching for electrs id: cache-electrs - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} key: electrs-${{ runner.os }}-${{ runner.arch }} diff --git a/.github/workflows/cron-weekly-rustfmt.yml b/.github/workflows/cron-weekly-rustfmt.yml index 7bb55a86d1..65ca21511e 100644 --- a/.github/workflows/cron-weekly-rustfmt.yml +++ b/.github/workflows/cron-weekly-rustfmt.yml @@ -12,7 +12,7 @@ jobs: format: name: Nightly rustfmt timeout-minutes: 60 - runs-on: ubuntu-24.04 + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@nightly diff --git a/.github/workflows/hrn-integration.yml b/.github/workflows/hrn-integration.yml index 767210f100..76a95f93de 100644 --- a/.github/workflows/hrn-integration.yml +++ b/.github/workflows/hrn-integration.yml @@ -9,7 +9,7 @@ concurrency: jobs: build-and-test: timeout-minutes: 60 - runs-on: ubuntu-latest + runs-on: self-hosted steps: - name: Checkout source code diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d8e0932b18..106f2c4f95 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: platform: [ - ubuntu-latest, + self-hosted, macos-latest, windows-latest, ] @@ -25,7 +25,7 @@ jobs: - toolchain: stable check-fmt: true build-uniffi: true - platform: ubuntu-latest + platform: self-hosted - toolchain: stable platform: macos-latest - toolchain: stable @@ -35,7 +35,7 @@ jobs: runs-on: ${{ matrix.platform }} steps: - name: Checkout source code - uses: actions/checkout@v6 + uses: actions/checkout@v4 - name: Install Rust ${{ matrix.toolchain }} toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain ${{ matrix.toolchain }} @@ -51,13 +51,13 @@ jobs: run: echo "RUSTFLAGS=-D warnings" >> "$GITHUB_ENV" - name: Enable caching for bitcoind id: cache-bitcoind - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} key: bitcoind-29.0-${{ runner.os }}-${{ runner.arch }} - name: Enable caching for electrs id: cache-electrs - uses: actions/cache@v5 + uses: actions/cache@v4 with: path: bin/electrs-${{ runner.os }}-${{ runner.arch }} key: electrs-${{ runner.os }}-${{ runner.arch }} @@ -94,14 +94,15 @@ jobs: linting: name: Linting timeout-minutes: 60 - runs-on: ubuntu-latest + runs-on: self-hosted steps: - name: Checkout source code - uses: actions/checkout@v6 - - name: Install Rust and clippy + uses: actions/checkout@v4 + - name: Install Rust stable toolchain run: | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable - rustup component add clippy + - name: Add clippy component + run: rustup component add clippy - name: Ban `unwrap` in library code run: | cargo clippy --lib --verbose --color always -- -A warnings -D clippy::unwrap_used -A clippy::tabs_in_doc_comments @@ -110,11 +111,11 @@ jobs: doc: name: Documentation timeout-minutes: 60 - runs-on: ubuntu-latest + runs-on: self-hosted env: RUSTDOCFLAGS: -Dwarnings steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@nightly - uses: dtolnay/install@cargo-docs-rs - run: cargo docs-rs From 678732d43fd98eda6f39bde62ba88e1cfd365625 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Jun 2026 10:59:12 +0200 Subject: [PATCH 040/138] Bump BDK wallet dependencies Update the direct BDK wallet stack to the latest crate releases. This lets follow-up wallet event code use the upstream BDK API. It also preserves temporary transaction cleanup after BDK removed its cancel_tx helper. Co-Authored-By: HAL 9000 --- Cargo.toml | 6 +++--- src/event.rs | 2 +- src/wallet/mod.rs | 24 +++++++++++++++++------- tests/integration_tests_rust.rs | 14 ++++++-------- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c9ce29d32f..800ce0e139 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,10 +54,10 @@ lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -bdk_chain = { version = "0.23.0", default-features = false, features = ["std"] } -bdk_esplora = { version = "0.22.0", default-features = false, features = ["async-https-rustls", "tokio"]} +bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } +bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"]} bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"]} -bdk_wallet = { version = "2.3.0", default-features = false, features = ["std", "keys-bip39"]} +bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]} bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } diff --git a/src/event.rs b/src/event.rs index 80acd0690e..93d274ff7f 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1648,7 +1648,7 @@ where }) .collect(), }; - if let Err(e) = self.wallet.cancel_tx(&tx) { + if let Err(e) = self.wallet.cancel_tx(tx) { log_error!(self.logger, "Failed reclaiming unused addresses: {}", e); return Err(ReplayEvent()); } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 76f2aa9ce6..c379ae2fca 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -13,10 +13,9 @@ use std::sync::{Arc, Mutex}; use bdk_chain::spk_client::{FullScanRequest, SyncRequest}; use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; -use bdk_wallet::event::WalletEvent; #[allow(deprecated)] use bdk_wallet::SignOptions; -use bdk_wallet::{Balance, KeychainKind, PersistedWallet, Update}; +use bdk_wallet::{Balance, KeychainKind, PersistedWallet, Update, WalletEvent}; use bitcoin::address::NetworkUnchecked; use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; use bitcoin::blockdata::locktime::absolute::LockTime; @@ -513,11 +512,11 @@ impl Wallet { Ok(address_info.address) } - pub(crate) fn cancel_tx(&self, tx: &Transaction) -> Result<(), Error> { + pub(crate) fn cancel_tx(&self, tx: Transaction) -> Result<(), Error> { let mut locked_wallet = self.inner.lock().expect("lock"); let mut locked_persister = self.persister.lock().expect("lock"); - locked_wallet.cancel_tx(tx); + Self::cancel_tx_inner(&mut locked_wallet, tx); self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed @@ -526,6 +525,17 @@ impl Wallet { Ok(()) } + fn cancel_tx_inner( + locked_wallet: &mut PersistedWallet, tx: Transaction, + ) { + for txout in tx.output { + if let Some((keychain, index)) = locked_wallet.derivation_of_spk(txout.script_pubkey) { + // This mirrors the removed BDK helper: it only frees superficial usage marks. + locked_wallet.unmark_used(keychain, index); + } + } + } + pub(crate) fn get_balances( &self, total_anchor_channels_reserve_sats: u64, ) -> Result<(u64, u64), Error> { @@ -678,7 +688,7 @@ impl Wallet { None, )?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, tmp_psbt.unsigned_tx); Ok(max_amount) } @@ -708,7 +718,7 @@ impl Wallet { Some(&shared_input), )?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, tmp_psbt.unsigned_tx); Ok(splice_amount) } @@ -764,7 +774,7 @@ impl Wallet { e })?; - locked_wallet.cancel_tx(&tmp_psbt.unsigned_tx); + Self::cancel_tx_inner(&mut locked_wallet, tmp_psbt.unsigned_tx); let mut tx_builder = locked_wallet.build_tx(); tx_builder diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index fab73ed0c5..38a66b1841 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1175,15 +1175,13 @@ async fn splice_channel() { expect_channel_ready_event!(node_b, node_a.node_id()); let expected_splice_in_fee_sat = 251; - let expected_splice_in_onchain_cost_sat = 254; + let expected_splice_in_onchain_cost_sat = 253; - // LDK's fee calculation differs from BDK wallet's, which over pays on fees. Rather than giving - // the extra fees to the miner, LDK sends it to the channel balance since there may not be a - // change output. - // - // TODO: Some of the discrepancy is addressed upstream, so this number should be adjusted when - // updating the BDK wallet dependency. See: https://github.com/bitcoindevkit/bdk_wallet/pull/479 - let expected_splice_in_lightning_balance_sat = 4_000_003; + // BDK 3.1.0 avoids the previous per-UTXO fee rounding during coin selection. Keep the + // remaining 2-sat LDK/BDK fee-accounting drift explicit so a dependency change cannot silently + // reintroduce the larger surplus. Rather than giving the extra sats to the miner, LDK sends + // them to the channel balance since there may not be a change output. + let expected_splice_in_lightning_balance_sat = 4_000_002; let payments = node_b.list_payments(); let payment = From b3844878094de03ebcd488fc47efbc6a4ccc7cea Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 12 Jun 2026 11:00:31 +0200 Subject: [PATCH 041/138] Use BDK mempool wallet events Use BDK's wallet event helper for mempool updates. This removes the local event diffing copy now that BDK exposes the needed event API. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 132 +++------------------------------------------- 1 file changed, 7 insertions(+), 125 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index c379ae2fca..b5a4e09018 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -185,29 +185,13 @@ impl Wallet { let mut locked_wallet = self.inner.lock().expect("lock"); - let chain_tip1 = locked_wallet.latest_checkpoint().block_id(); - let wallet_txs1 = locked_wallet - .transactions() - .map(|wtx| (wtx.tx_node.txid, (wtx.tx_node.tx.clone(), wtx.chain_position))) - .collect::, bdk_chain::ChainPosition), - >>(); - - locked_wallet.apply_unconfirmed_txs(unconfirmed_txs); - locked_wallet.apply_evicted_txs(evicted_txids); - - let chain_tip2 = locked_wallet.latest_checkpoint().block_id(); - let wallet_txs2 = locked_wallet - .transactions() - .map(|wtx| (wtx.tx_node.txid, (wtx.tx_node.tx.clone(), wtx.chain_position))) - .collect::, bdk_chain::ChainPosition), - >>(); - - let events = - wallet_events(&mut *locked_wallet, chain_tip1, chain_tip2, wallet_txs1, wallet_txs2); + let events = locked_wallet + .events_helper(|wallet| -> Result<(), std::convert::Infallible> { + wallet.apply_unconfirmed_txs(unconfirmed_txs); + wallet.apply_evicted_txs(evicted_txids); + Ok(()) + }) + .expect("applying mempool updates cannot fail"); self.update_payment_store(&mut *locked_wallet, events).map_err(|e| { log_error!(self.logger, "Failed to update payment store: {}", e); @@ -1765,105 +1749,3 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { .saturating_sub(EMPTY_SCRIPT_SIG_WEIGHT + EMPTY_WITNESS_COUNT_WEIGHT), ) } - -// FIXME/TODO: This is copied-over from bdk_wallet and only used to generate `WalletEvent`s after -// applying mempool transactions. We should drop this when BDK offers to generate events for -// mempool transactions natively. -pub(crate) fn wallet_events( - wallet: &mut bdk_wallet::Wallet, chain_tip1: bdk_chain::BlockId, - chain_tip2: bdk_chain::BlockId, - wallet_txs1: std::collections::BTreeMap< - Txid, - (Arc, bdk_chain::ChainPosition), - >, - wallet_txs2: std::collections::BTreeMap< - Txid, - (Arc, bdk_chain::ChainPosition), - >, -) -> Vec { - let mut events: Vec = Vec::new(); - - if chain_tip1 != chain_tip2 { - events.push(WalletEvent::ChainTipChanged { old_tip: chain_tip1, new_tip: chain_tip2 }); - } - - wallet_txs2.iter().for_each(|(txid2, (tx2, cp2))| { - if let Some((tx1, cp1)) = wallet_txs1.get(txid2) { - assert_eq!(tx1.compute_txid(), *txid2); - match (cp1, cp2) { - ( - bdk_chain::ChainPosition::Unconfirmed { .. }, - bdk_chain::ChainPosition::Confirmed { anchor, .. }, - ) => { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor, - old_block_time: None, - }); - }, - ( - bdk_chain::ChainPosition::Confirmed { anchor, .. }, - bdk_chain::ChainPosition::Unconfirmed { .. }, - ) => { - events.push(WalletEvent::TxUnconfirmed { - txid: *txid2, - tx: tx2.clone(), - old_block_time: Some(*anchor), - }); - }, - ( - bdk_chain::ChainPosition::Confirmed { anchor: anchor1, .. }, - bdk_chain::ChainPosition::Confirmed { anchor: anchor2, .. }, - ) => { - if *anchor1 != *anchor2 { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor2, - old_block_time: Some(*anchor1), - }); - } - }, - ( - bdk_chain::ChainPosition::Unconfirmed { .. }, - bdk_chain::ChainPosition::Unconfirmed { .. }, - ) => { - // do nothing if still unconfirmed - }, - } - } else { - match cp2 { - bdk_chain::ChainPosition::Confirmed { anchor, .. } => { - events.push(WalletEvent::TxConfirmed { - txid: *txid2, - tx: tx2.clone(), - block_time: *anchor, - old_block_time: None, - }); - }, - bdk_chain::ChainPosition::Unconfirmed { .. } => { - events.push(WalletEvent::TxUnconfirmed { - txid: *txid2, - tx: tx2.clone(), - old_block_time: None, - }); - }, - } - } - }); - - // find tx that are no longer canonical - wallet_txs1.iter().for_each(|(txid1, (tx1, _))| { - if !wallet_txs2.contains_key(txid1) { - let conflicts = wallet.tx_graph().direct_conflicts(tx1).collect::>(); - if !conflicts.is_empty() { - events.push(WalletEvent::TxReplaced { txid: *txid1, tx: tx1.clone(), conflicts }); - } else { - events.push(WalletEvent::TxDropped { txid: *txid1, tx: tx1.clone() }); - } - } - }); - - events -} From f5094893789234eace200b1315e5d54384ee28c4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:48:09 +0200 Subject: [PATCH 042/138] Track reorged on-chain payments as pending Move affected on-chain payments back to pending when BDK reports that their transaction is unconfirmed again. This keeps payment history aligned with wallet events after a reorg. It does not update payment records directly from disconnected-block notifications. Co-Authored-By: HAL 9000 --- src/payment/pending_payment_store.rs | 55 +++++++++++++++++++- src/wallet/mod.rs | 2 +- tests/integration_tests_rust.rs | 78 ++++++++++++++++++++++++++-- 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index eb72f89ec9..dfcb6fd558 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -11,7 +11,7 @@ use lightning::ln::channelmanager::PaymentId; use crate::data_store::{StorableObject, StorableObjectUpdate}; use crate::payment::store::PaymentDetailsUpdate; -use crate::payment::PaymentDetails; +use crate::payment::{PaymentDetails, PaymentKind}; /// Represents a pending payment #[derive(Clone, Debug, PartialEq, Eq)] @@ -68,6 +68,12 @@ impl StorableObject for PendingPaymentDetails { } } + if let PaymentKind::Onchain { txid, .. } = &self.details.kind { + let conflicts_len = self.conflicting_txids.len(); + self.conflicting_txids.retain(|conflicting_txid| conflicting_txid != txid); + updated |= self.conflicting_txids.len() != conflicts_len; + } + updated } @@ -92,3 +98,50 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids } } } + +#[cfg(test)] +mod tests { + use bitcoin::hashes::Hash; + + use super::*; + use crate::payment::{ConfirmationStatus, PaymentDirection, PaymentKind, PaymentStatus}; + + fn test_txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + fn pending_onchain_payment(payment_id: PaymentId, txid: Txid) -> PaymentDetails { + PaymentDetails::new( + payment_id, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed }, + Some(1_000), + Some(100), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ) + } + + #[test] + fn pending_onchain_conflicts_exclude_current_txid_after_txid_rotation() { + let original_txid = test_txid(1); + let replacement_txid = test_txid(2); + let payment_id = PaymentId(original_txid.to_byte_array()); + + let mut pending_payment = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, replacement_txid), + vec![original_txid], + ); + let update = PendingPaymentDetails::new( + pending_onchain_payment(payment_id, original_txid), + Vec::new(), + ) + .to_update(); + + assert!(pending_payment.update(update)); + assert_eq!( + pending_payment.conflicting_txids, + Vec::::new(), + "current txid must not remain in its own conflict list" + ); + } +} diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b5a4e09018..1be31f6b9f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -345,7 +345,7 @@ impl Wallet { } } }, - WalletEvent::TxUnconfirmed { txid, tx, old_block_time: None } => { + WalletEvent::TxUnconfirmed { txid, tx, .. } => { let payment_id = self .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 38a66b1841..4a66deb5f7 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -21,10 +21,11 @@ use common::{ expect_channel_pending_event, expect_channel_ready_event, expect_channel_ready_events, expect_event, expect_payment_claimable_event, expect_payment_received_event, expect_payment_successful_event, expect_splice_negotiated_event, generate_blocks_and_wait, - generate_listening_addresses, open_channel, open_channel_push_amt, open_channel_with_all, - premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, - setup_bitcoind_and_electrsd, setup_builder, setup_node, setup_two_nodes, splice_in_with_all, - wait_for_tx, TestChainSource, TestConfig, TestStoreType, TestSyncStore, + generate_listening_addresses, invalidate_blocks, open_channel, open_channel_push_amt, + open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf, + random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder, setup_node, + setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, TestChainSource, TestConfig, + TestStoreType, TestSyncStore, }; use electrsd::corepc_node::Node as BitcoinD; use electrsd::ElectrsD; @@ -42,6 +43,7 @@ use lightning::routing::router::RouteParametersConfig; use lightning_invoice::{Bolt11InvoiceDescription, Description}; use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; +use serde_json::json; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle() { @@ -672,6 +674,74 @@ async fn onchain_send_receive() { assert_eq!(node_b_payments.len(), 5); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn reorged_onchain_payment_returns_to_unconfirmed() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 500_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let amount_to_send_sats = 100_000; + let txid = + node_b.onchain_payment().send_to_address(&addr_a, amount_to_send_sats, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(txid.to_byte_array()); + for node in [&node_a, &node_b] { + let payment = node.payment(&payment_id).unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + match payment.kind { + PaymentKind::Onchain { status, .. } => { + assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + }, + _ => panic!("Unexpected payment kind"), + } + } + + let original_height = + bitcoind.client.get_blockchain_info().expect("failed to get blockchain info").blocks; + invalidate_blocks(&bitcoind.client, 1); + let replacement_address = bitcoind.client.new_address().expect("failed to get new address"); + for _ in 0..2 { + let _res: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(replacement_address.to_string()), json!([])]) + .expect("failed to generate empty block"); + } + wait_for_block(&electrsd.client, original_height as usize + 1).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + for node in [&node_a, &node_b] { + let payment = node.payment(&payment_id).unwrap(); + assert_eq!(payment.status, PaymentStatus::Pending); + match payment.kind { + PaymentKind::Onchain { status, .. } => { + assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + }, + _ => panic!("Unexpected payment kind"), + } + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn onchain_send_all_retains_reserve() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 7d9afb32ab2443d1c5a997d14f4179566b38355e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:48:55 +0200 Subject: [PATCH 043/138] Group pending payment storage constants Keep pending payment namespace constants next to the primary payment store constants. This keeps related persistence keys discoverable together. Co-Authored-By: HAL 9000 --- src/io/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/io/mod.rs b/src/io/mod.rs index e16a999752..a01aa59a83 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -29,6 +29,10 @@ pub(crate) const PEER_INFO_PERSISTENCE_KEY: &str = "peers"; pub(crate) const PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "payments"; pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// The pending payment information will be persisted under this prefix. +pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; +pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + /// The node metrics will be persisted under this key. pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = ""; pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = ""; @@ -80,7 +84,3 @@ pub(crate) const BDK_WALLET_INDEXER_KEY: &str = "indexer"; /// /// [`StaticInvoice`]: lightning::offers::static_invoice::StaticInvoice pub(crate) const STATIC_INVOICE_STORE_PRIMARY_NAMESPACE: &str = "static_invoices"; - -/// The pending payment information will be persisted under this prefix. -pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; -pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; From 65ee795669a132574bf21bed080bc6f78e549e7e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jun 2026 15:49:47 +0200 Subject: [PATCH 044/138] Keep pending payment details internal Stop exporting the pending payment index record from the public payment module. The pending index is an internal persistence detail and should not become public API before this ships. Co-Authored-By: HAL 9000 --- src/payment/mod.rs | 2 +- src/payment/pending_payment_store.rs | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 71daa48b0a..ee53ed7f8e 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -20,7 +20,7 @@ pub use bolt11::Bolt11Payment; pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; pub use onchain::OnchainPayment; -pub use pending_payment_store::PendingPaymentDetails; +pub(crate) use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; pub use store::{ ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index dfcb6fd558..a7dd916b06 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -26,11 +26,6 @@ impl PendingPaymentDetails { pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { Self { details, conflicting_txids } } - - /// Convert to finalized payment for the main payment store - pub fn into_payment_details(self) -> PaymentDetails { - self.details - } } impl_writeable_tlv_based!(PendingPaymentDetails, { From c82c2e5567a22545da9af13d1d34368156186040 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 15 Jun 2026 10:26:34 +0200 Subject: [PATCH 045/138] Preserve anchor reserve during RBF RBF can spend fee increases from the original transaction's change output. Check the replacement fee increase against the current anchor-channel reserve before signing. This prevents high manual fee rates from consuming funds reserved for anchor spends. This finding was discovered by Project Loupe. Co-Authored-By: HAL 9000 --- src/payment/onchain.rs | 9 +++++- src/wallet/mod.rs | 37 ++++++++++++++++++++++++- tests/integration_tests_rust.rs | 49 +++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index 9d00968fcc..da2685970c 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -134,11 +134,18 @@ impl OnchainPayment { /// The new transaction will have the same outputs as the original but with a /// higher fee, resulting in faster confirmation potential. /// + /// This will respect any on-chain reserve we need to keep, i.e., won't allow to cut into + /// [`BalanceDetails::total_anchor_channels_reserve_sats`]. + /// /// Returns the [`Txid`] of the new replacement transaction if successful. + /// + /// [`BalanceDetails::total_anchor_channels_reserve_sats`]: crate::BalanceDetails::total_anchor_channels_reserve_sats pub fn bump_fee_rbf( &self, payment_id: PaymentId, fee_rate: Option, ) -> Result { + let cur_anchor_reserve_sats = + crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); - self.wallet.bump_fee_rbf(payment_id, fee_rate_opt) + self.wallet.bump_fee_rbf(payment_id, fee_rate_opt, cur_anchor_reserve_sats) } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 1be31f6b9f..f3429afbfc 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1232,7 +1232,7 @@ impl Wallet { #[allow(deprecated)] pub(crate) fn bump_fee_rbf( - &self, payment_id: PaymentId, fee_rate: Option, + &self, payment_id: PaymentId, fee_rate: Option, cur_anchor_reserve_sats: u64, ) -> Result { let payment = self.payment_store.get(&payment_id).ok_or_else(|| { log_error!(self.logger, "Payment {} not found in payment store", payment_id); @@ -1380,6 +1380,41 @@ impl Wallet { }? }; + let old_fee_sats = locked_wallet + .calculate_fee(&old_tx) + .map_err(|e| { + log_error!(self.logger, "Failed to calculate fee of transaction {}: {}", txid, e); + Error::WalletOperationFailed + })? + .to_sat(); + let replacement_fee_sats = locked_wallet + .calculate_fee(&psbt.unsigned_tx) + .map_err(|e| { + log_error!( + self.logger, + "Failed to calculate fee of replacement transaction for {}: {}", + txid, + e + ); + Error::WalletOperationFailed + })? + .to_sat(); + let additional_fee_sats = replacement_fee_sats.saturating_sub(old_fee_sats); + let balance = locked_wallet.balance(); + let spendable_amount_sats = + self.get_balances_inner(balance, cur_anchor_reserve_sats).map(|(_, s)| s).unwrap_or(0); + if spendable_amount_sats < additional_fee_sats { + log_error!( + self.logger, + "Unable to bump fee due to insufficient reserve-preserving funds. \ + Available: {}sats, required additional fee: {}sats, reserve: {}sats", + spendable_amount_sats, + additional_fee_sats, + cur_anchor_reserve_sats, + ); + return Err(Error::InsufficientFunds); + } + match locked_wallet.sign(&mut psbt, SignOptions::default()) { Ok(finalized) => { if !finalized { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 4a66deb5f7..521cb74caf 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3031,6 +3031,55 @@ async fn onchain_fee_bump_rbf() { assert_eq!(node_a_received_payment[0].status, PaymentStatus::Succeeded); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_fee_bump_rbf_respects_anchor_reserve() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + + let premine_amount_sat = 1_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a.clone(), addr_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_b, &node_a, 200_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + expect_channel_ready_event!(node_b, node_a.node_id()); + + let balances_before = node_b.list_balances(); + let reserve = balances_before.total_anchor_channels_reserve_sats; + assert!(reserve > 0, "Anchor reserve should be non-zero after channel open"); + let spendable_before = balances_before.spendable_onchain_balance_sats; + + let buffer_sats = 5_000; + assert!(spendable_before > buffer_sats); + let amount_to_send_sats = spendable_before - buffer_sats; + let txid = + node_b.onchain_payment().send_to_address(&addr_a, amount_to_send_sats, None).unwrap(); + wait_for_tx(&electrsd.client, txid).await; + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(txid.to_byte_array()); + let high_fee_rate = bitcoin::FeeRate::from_sat_per_kwu(20_000); + assert_eq!( + Err(NodeError::InsufficientFunds), + node_b.onchain_payment().bump_fee_rbf(payment_id, Some(high_fee_rate.into())) + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn open_channel_with_all_with_anchors() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 558ec8222dcb80422c725126b3113659b6d7094d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 26 Jun 2026 10:43:46 +0200 Subject: [PATCH 046/138] Fix DataStore failing store pagination DataStore persistence failure tests use FailingStore through DynStoreWrapper. That wrapper now requires paginated store support, so make the helper fail paginated listings the same way it fails the other store calls. Co-Authored-By: HAL 9000 --- src/data_store.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/data_store.rs b/src/data_store.rs index 3176e7ce2c..13afeca7e3 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -225,6 +225,7 @@ where mod tests { use lightning::impl_writeable_tlv_based; use lightning::io; + use lightning::util::persist::{PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning::util::test_utils::TestLogger; use super::*; @@ -315,6 +316,16 @@ mod tests { } } + impl PaginatedKVStore for FailingStore { + fn list_paginated( + &self, _primary_namespace: &str, _secondary_namespace: &str, + _page_token: Option, + ) -> impl std::future::Future> + 'static + Send + { + async { Err(io::Error::new(io::ErrorKind::Other, "list_paginated failed")) } + } + } + fn new_failing_data_store(objects: Vec) -> DataStore> { let store: Arc = Arc::new(DynStoreWrapper(FailingStore)); let logger = Arc::new(TestLogger::new()); From 2bcd421c13bd498ee93f3a829532dc60eda13345 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 23 Jun 2026 14:17:57 -0500 Subject: [PATCH 047/138] Implement `MigratableKVStore` for sqlite This was unimplemented for the sqlite kv store. Useful if the user wants to migrate to a different database and also in tests so we don't have to re-init and setup a node. AI-assisted-by: OpenAI Codex --- src/io/sqlite_store/mod.rs | 83 +++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/src/io/sqlite_store/mod.rs b/src/io/sqlite_store/mod.rs index 076aeef9bd..2587220598 100644 --- a/src/io/sqlite_store/mod.rs +++ b/src/io/sqlite_store/mod.rs @@ -14,7 +14,9 @@ use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use lightning::io; -use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; +use lightning::util::persist::{ + KVStore, MigratableKVStore, PageToken, PaginatedKVStore, PaginatedListResponse, +}; use lightning_types::string::PrintableString; use rusqlite::{named_params, Connection}; @@ -202,6 +204,21 @@ impl PaginatedKVStore for SqliteStore { } } +impl MigratableKVStore for SqliteStore { + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + 'static + Send { + let inner = Arc::clone(&self.inner); + let fut = tokio::task::spawn_blocking(move || inner.list_all_keys_internal()); + async move { + fut.await.unwrap_or_else(|e| { + let msg = format!("Failed to IO operation due join error: {}", e); + Err(io::Error::new(io::ErrorKind::Other, msg)) + }) + } + } +} + struct SqliteStoreInner { connection: Arc>, data_dir: PathBuf, @@ -486,6 +503,42 @@ impl SqliteStoreInner { Ok(keys) } + fn list_all_keys_internal(&self) -> io::Result> { + let locked_conn = self.connection.lock().expect("lock"); + + let sql = format!( + "SELECT primary_namespace, secondary_namespace, key FROM {}", + self.kv_table_name + ); + let count_sql = format!("SELECT COUNT(*) FROM {}", self.kv_table_name); + let count: usize = + locked_conn.query_row(&count_sql, [], |row| row.get(0)).map_err(|e| { + let msg = format!("Failed to count rows: {}", e); + io::Error::new(io::ErrorKind::Other, msg) + })?; + + let mut stmt = locked_conn.prepare_cached(&sql).map_err(|e| { + let msg = format!("Failed to prepare statement: {}", e); + io::Error::new(io::ErrorKind::Other, msg) + })?; + + let mut keys = Vec::with_capacity(count); + let rows_iter = + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).map_err(|e| { + let msg = format!("Failed to retrieve queried rows: {}", e); + io::Error::new(io::ErrorKind::Other, msg) + })?; + + for key in rows_iter { + keys.push(key.map_err(|e| { + let msg = format!("Failed to retrieve queried rows: {}", e); + io::Error::new(io::ErrorKind::Other, msg) + })?); + } + + Ok(keys) + } + fn list_paginated_internal( &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, ) -> io::Result { @@ -679,6 +732,34 @@ mod tests { do_test_store(&store_0, &store_1) } + #[tokio::test] + async fn test_sqlite_store_list_all_keys() { + let mut temp_path = random_storage_path(); + temp_path.push("test_sqlite_store_list_all_keys"); + let store = SqliteStore::new( + temp_path, + Some("test_db".to_string()), + Some("test_table".to_string()), + ) + .unwrap(); + + KVStore::write(&store, "ns_a", "sub_a", "key_a", vec![1u8]).await.unwrap(); + KVStore::write(&store, "ns_a", "sub_b", "key_b", vec![2u8]).await.unwrap(); + KVStore::write(&store, "ns_b", "", "key_c", vec![3u8]).await.unwrap(); + + let mut keys = MigratableKVStore::list_all_keys(&store).await.unwrap(); + keys.sort(); + + assert_eq!( + keys, + vec![ + ("ns_a".to_string(), "sub_a".to_string(), "key_a".to_string()), + ("ns_a".to_string(), "sub_b".to_string(), "key_b".to_string()), + ("ns_b".to_string(), "".to_string(), "key_c".to_string()), + ] + ); + } + #[tokio::test] async fn test_sqlite_store_paginated_listing() { let mut temp_path = random_storage_path(); From 3fa778c0b22742acdb5bc18816cf9d40a33ab03e Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 23 Jun 2026 14:18:27 -0500 Subject: [PATCH 048/138] Implement `MigratableKVStore` for postgres This was unimplemented for the postgres kv store. Useful if the user wants to migrate to a different database and also in tests so we don't have to re-init and setup a node. AI-assisted-by: OpenAI Codex --- src/io/postgres_store/mod.rs | 64 +++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/io/postgres_store/mod.rs b/src/io/postgres_store/mod.rs index c0770de5f0..90b8cdc391 100644 --- a/src/io/postgres_store/mod.rs +++ b/src/io/postgres_store/mod.rs @@ -12,7 +12,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use lightning::io; -use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; +use lightning::util::persist::{ + KVStore, MigratableKVStore, PageToken, PaginatedKVStore, PaginatedListResponse, +}; use lightning_types::string::PrintableString; use native_tls::TlsConnector; use postgres_native_tls::MakeTlsConnector; @@ -351,6 +353,24 @@ impl PaginatedKVStore for PostgresStore { } } +impl MigratableKVStore for PostgresStore { + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + 'static + Send { + let inner = Arc::clone(&self.inner); + let runtime = self.internal_runtime(); + async move { + let task = runtime.spawn(async move { inner.list_all_keys_internal().await }); + task.await.map_err(|e| { + io::Error::new( + io::ErrorKind::Other, + format!("PostgreSQL runtime task failed: {}", e), + ) + })? + } + } +} + struct PostgresStoreInner { pool: SmallPool, config: Config, @@ -725,6 +745,25 @@ impl PostgresStoreInner { Ok(keys) } + async fn list_all_keys_internal(&self) -> io::Result> { + let sql = format!( + "SELECT primary_namespace, secondary_namespace, key FROM {}", + self.kv_table_name_sql + ); + + let err_map = |e: PgError| { + let msg = format!("Failed to retrieve queried rows: {e}"); + io::Error::new(io::ErrorKind::Other, msg) + }; + + let mut locked = self.locked_client().await?; + let rows = query_with_retry!(self, locked, err_map, locked.query(sql.as_str(), &[]))?; + + let keys: Vec<(String, String, String)> = + rows.iter().map(|row| (row.get(0), row.get(1), row.get(2))).collect(); + Ok(keys) + } + async fn list_paginated_internal( &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, ) -> io::Result { @@ -904,6 +943,29 @@ mod tests { cleanup_store(&store_1).await; } + #[tokio::test(flavor = "multi_thread")] + async fn test_postgres_store_list_all_keys() { + let store = create_test_store("test_pg_list_all_keys").await; + + KVStore::write(&store, "ns_a", "sub_a", "key_a", vec![1u8]).await.unwrap(); + KVStore::write(&store, "ns_a", "sub_b", "key_b", vec![2u8]).await.unwrap(); + KVStore::write(&store, "ns_b", "", "key_c", vec![3u8]).await.unwrap(); + + let mut keys = MigratableKVStore::list_all_keys(&store).await.unwrap(); + keys.sort(); + + assert_eq!( + keys, + vec![ + ("ns_a".to_string(), "sub_a".to_string(), "key_a".to_string()), + ("ns_a".to_string(), "sub_b".to_string(), "key_b".to_string()), + ("ns_b".to_string(), "".to_string(), "key_c".to_string()), + ] + ); + + cleanup_store(&store).await; + } + async fn kill_connection(store: &PostgresStore) { // Terminate every backend in the pool so the next op deterministically // hits a closed connection regardless of which slot `get` selects. From 43078aa7e5f997f7333c471f4635fe8677f8a438 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 23 Jun 2026 14:30:37 -0500 Subject: [PATCH 049/138] Refactor VSS key extraction Extract the existing obfuscated key selection so later VSS listing changes can reuse it without changing the parsing behavior. AI-assisted-by: OpenAI Codex --- src/io/vss_store.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/io/vss_store.rs b/src/io/vss_store.rs index f6e865bd87..3dbaf80a48 100644 --- a/src/io/vss_store.rs +++ b/src/io/vss_store.rs @@ -399,7 +399,7 @@ impl VssStoreInner { } } - fn extract_key(&self, unified_key: &str) -> io::Result { + fn extract_obfuscated_key<'a>(&self, unified_key: &'a str) -> io::Result<&'a str> { let mut parts = if self.schema_version == VssSchemaVersion::V1 { let mut parts = unified_key.splitn(2, '#'); let _obfuscated_namespace = parts.next(); @@ -411,14 +411,17 @@ impl VssStoreInner { parts }; match parts.next() { - Some(obfuscated_key) => { - let actual_key = self.key_obfuscator.deobfuscate(obfuscated_key)?; - Ok(actual_key) - }, + Some(obfuscated_key) => Ok(obfuscated_key), None => Err(Error::new(ErrorKind::InvalidData, "Invalid key format")), } } + fn extract_key(&self, unified_key: &str) -> io::Result { + let obfuscated_key = self.extract_obfuscated_key(unified_key)?; + let actual_key = self.key_obfuscator.deobfuscate(obfuscated_key)?; + Ok(actual_key) + } + async fn list_keys( &self, client: &VssClient, primary_namespace: &str, secondary_namespace: &str, key_prefix: String, page_token: Option, From 18fa93ac80c273331d197aab850ce4640f1c1cae Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 23 Jun 2026 14:32:46 -0500 Subject: [PATCH 050/138] Implement `MigratableKVStore` for VSS This was unimplemented for the VSS kv store. Useful if the user wants to migrate to a different database and keeps VSS aligned with the other persistent stores. AI-assisted-by: OpenAI Codex --- src/io/vss_store.rs | 122 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/src/io/vss_store.rs b/src/io/vss_store.rs index 3dbaf80a48..61d4e7abc2 100644 --- a/src/io/vss_store.rs +++ b/src/io/vss_store.rs @@ -24,7 +24,9 @@ use bitcoin::Network; use lightning::impl_writeable_tlv_based_enum; use lightning::io::{self, Error, ErrorKind}; use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes}; -use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; +use lightning::util::persist::{ + KVStore, MigratableKVStore, PageToken, PaginatedKVStore, PaginatedListResponse, +}; use lightning::util::ser::{Readable, Writeable}; use prost::Message; use vss_client::client::VssClient; @@ -321,6 +323,22 @@ impl PaginatedKVStore for VssStore { } } +impl MigratableKVStore for VssStore { + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + 'static + Send { + let inner = Arc::clone(&self.inner); + let runtime = self.internal_runtime(); + async move { + let task = runtime + .spawn(async move { inner.list_all_keys_internal(&inner.async_client).await }); + task.await.map_err(|e| { + io::Error::new(io::ErrorKind::Other, format!("VSS runtime task failed: {}", e)) + })? + } + } +} + impl Drop for VssStore { fn drop(&mut self) { if let Some(runtime) = self.internal_runtime.take() { @@ -422,6 +440,41 @@ impl VssStoreInner { Ok(actual_key) } + fn extract_namespaces(&self, unified_key: &str) -> io::Result<(String, String)> { + if self.schema_version == VssSchemaVersion::V1 { + let mut parts = unified_key.splitn(2, '#'); + let obfuscated_namespace = parts.next(); + let _obfuscated_key = parts.next(); + match (obfuscated_namespace, _obfuscated_key) { + (Some(obfuscated_namespace), Some(_obfuscated_key)) => { + let namespace = self.key_obfuscator.deobfuscate(obfuscated_namespace)?; + let mut namespace_parts = namespace.splitn(2, '#'); + let primary_namespace = namespace_parts.next(); + let secondary_namespace = namespace_parts.next(); + match (primary_namespace, secondary_namespace) { + (Some(primary_namespace), Some(secondary_namespace)) => { + Ok((primary_namespace.to_string(), secondary_namespace.to_string())) + }, + _ => Err(Error::new(ErrorKind::InvalidData, "Invalid namespace format")), + } + }, + _ => Err(Error::new(ErrorKind::InvalidData, "Invalid key format")), + } + } else { + // Default to V0 schema. + let mut parts = unified_key.splitn(3, '#'); + let primary_namespace = parts.next(); + let secondary_namespace = parts.next(); + match (primary_namespace, secondary_namespace) { + (Some(_obfuscated_key), None) => Ok(("".to_string(), "".to_string())), + (Some(primary_namespace), Some(secondary_namespace)) => { + Ok((primary_namespace.to_string(), secondary_namespace.to_string())) + }, + _ => Err(Error::new(ErrorKind::InvalidData, "Invalid key format")), + } + } + } + async fn list_keys( &self, client: &VssClient, primary_namespace: &str, secondary_namespace: &str, key_prefix: String, page_token: Option, @@ -625,6 +678,52 @@ impl VssStoreInner { Ok(PaginatedListResponse { keys, next_page_token }) } + async fn list_all_keys_internal( + &self, client: &VssClient, + ) -> io::Result> { + let mut page_token: Option = None; + let mut keys = vec![]; + loop { + let request = ListKeyVersionsRequest { + store_id: self.store_id.clone(), + key_prefix: None, + page_token, + page_size: Some(PAGE_SIZE), + }; + + let response = client.list_key_versions(&request).await.map_err(|e| { + let msg = format!("Failed to list all keys: {}", e); + Error::new(ErrorKind::Other, msg) + })?; + + for kv in response.key_versions { + let (primary_namespace, secondary_namespace) = self.extract_namespaces(&kv.key)?; + let key = match self.extract_key(&kv.key) { + Ok(key) => key, + Err(_) + if self.schema_version == VssSchemaVersion::V0 && !kv.key.contains('#') => + { + self.key_obfuscator.deobfuscate(&kv.key)? + }, + Err(e) => return Err(e), + }; + if primary_namespace.is_empty() + && secondary_namespace.is_empty() + && key == VSS_SCHEMA_VERSION_KEY + { + continue; + } + keys.push((primary_namespace, secondary_namespace, key)); + } + + match response.next_page_token.filter(|t| !t.is_empty()) { + Some(t) => page_token = Some(t), + None => break, + } + } + Ok(keys) + } + async fn execute_locked_write< F: Future>, FN: FnOnce() -> F, @@ -1041,6 +1140,27 @@ mod tests { drop(vss_store) } + #[tokio::test] + async fn vss_list_all_keys() { + let store = build_vss_store(); + + KVStore::write(&store, "ns_a", "sub_a", "key_a", vec![1u8]).await.unwrap(); + KVStore::write(&store, "ns_a", "sub_b", "key_b", vec![2u8]).await.unwrap(); + KVStore::write(&store, "ns_b", "", "key_c", vec![3u8]).await.unwrap(); + + let mut keys = MigratableKVStore::list_all_keys(&store).await.unwrap(); + keys.sort(); + + assert_eq!( + keys, + vec![ + ("ns_a".to_string(), "sub_a".to_string(), "key_a".to_string()), + ("ns_a".to_string(), "sub_b".to_string(), "key_b".to_string()), + ("ns_b".to_string(), "".to_string(), "key_c".to_string()), + ] + ); + } + #[tokio::test] async fn vss_paginated_listing() { let store = build_vss_store(); From 43b122b08faa4f192bf9da26c46feae827b84d7c Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 23 Jun 2026 14:46:44 -0500 Subject: [PATCH 051/138] Implement `MigratableKVStore` for InMemoryStore This was unimplemented for the in-memory kv store. Useful in tests so we can migrate data across all supported store implementations. AI-assisted-by: OpenAI Codex --- src/io/in_memory_store.rs | 61 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/src/io/in_memory_store.rs b/src/io/in_memory_store.rs index 8b7d41c843..156fef3a38 100644 --- a/src/io/in_memory_store.rs +++ b/src/io/in_memory_store.rs @@ -11,7 +11,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; use lightning::io; -use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; +use lightning::util::persist::{ + KVStore, MigratableKVStore, PageToken, PaginatedKVStore, PaginatedListResponse, +}; const IN_MEMORY_PAGE_SIZE: usize = 50; @@ -96,6 +98,28 @@ impl InMemoryStore { hash_map::Entry::Vacant(_) => Ok(Vec::new()), } } + + fn list_all_keys_internal(&self) -> io::Result> { + let persisted_lock = self.persisted_bytes.lock().unwrap(); + let capacity = persisted_lock.values().map(|entries| entries.len()).sum(); + let mut keys = Vec::with_capacity(capacity); + + for (prefixed_namespace, namespace_entries) in persisted_lock.iter() { + let (primary_namespace, secondary_namespace) = + prefixed_namespace.split_once('/').ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "Invalid namespace format") + })?; + for key in namespace_entries.keys() { + keys.push(( + primary_namespace.to_string(), + secondary_namespace.to_string(), + key.clone(), + )); + } + } + + Ok(keys) + } } impl KVStore for InMemoryStore { @@ -187,5 +211,40 @@ impl PaginatedKVStore for InMemoryStore { } } +impl MigratableKVStore for InMemoryStore { + fn list_all_keys( + &self, + ) -> impl Future, io::Error>> + 'static + Send { + let res = self.list_all_keys_internal(); + async move { res } + } +} + unsafe impl Sync for InMemoryStore {} unsafe impl Send for InMemoryStore {} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn in_memory_store_list_all_keys() { + let store = InMemoryStore::new(); + + KVStore::write(&store, "ns_a", "sub_a", "key_a", vec![1u8]).await.unwrap(); + KVStore::write(&store, "ns_a", "sub_b", "key_b", vec![2u8]).await.unwrap(); + KVStore::write(&store, "ns_b", "", "key_c", vec![3u8]).await.unwrap(); + + let mut keys = MigratableKVStore::list_all_keys(&store).await.unwrap(); + keys.sort(); + + assert_eq!( + keys, + vec![ + ("ns_a".to_string(), "sub_a".to_string(), "key_a".to_string()), + ("ns_a".to_string(), "sub_b".to_string(), "key_b".to_string()), + ("ns_b".to_string(), "".to_string(), "key_c".to_string()), + ] + ); + } +} From 93797a20b474fa8b9ff06af340710d4b02872b87 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 11:24:58 -0500 Subject: [PATCH 052/138] Classify on-chain payments with a durable transaction type On-chain payment records don't capture what a transaction was for -- a channel open, splice, close, sweep, or a plain send. Record that classification on each on-chain payment, derived from the type LDK reports when broadcasting the transaction, so it survives restarts alongside the payment. The tag keeps only which channels a transaction relates to; amounts and fees stay on the payment. Existing records keep decoding unchanged. Compatible with the on-chain transaction classification proposed in #791. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/payment/mod.rs | 4 +- src/payment/pending_payment_store.rs | 2 +- src/payment/store.rs | 230 ++++++++++++++++++++++++++- src/wallet/mod.rs | 3 +- tests/integration_tests_rust.rs | 4 +- 5 files changed, 234 insertions(+), 9 deletions(-) diff --git a/src/payment/mod.rs b/src/payment/mod.rs index ee53ed7f8e..bdc2fe96aa 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -23,7 +23,7 @@ pub use onchain::OnchainPayment; pub(crate) use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; pub use store::{ - ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, - PaymentStatus, + Channel, ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, + PaymentStatus, TransactionType, }; pub use unified::{UnifiedPayment, UnifiedPaymentResult}; diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index a7dd916b06..311fdbf34e 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -108,7 +108,7 @@ mod tests { fn pending_onchain_payment(payment_id: PaymentId, txid: Txid) -> PaymentDetails { PaymentDetails::new( payment_id, - PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed }, + PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, tx_type: None }, Some(1_000), Some(100), PaymentDirection::Outbound, diff --git a/src/payment/store.rs b/src/payment/store.rs index f80ab6f8a5..1608908958 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -7,9 +7,12 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use bitcoin::secp256k1::PublicKey; use bitcoin::{BlockHash, Txid}; +use lightning::chain::chaininterface::TransactionType as LdkTransactionType; use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::DecodeError; +use lightning::ln::types::ChannelId; use lightning::offers::offer::OfferId; use lightning::util::ser::{Readable, Writeable}; use lightning::{ @@ -282,6 +285,15 @@ impl StorableObject for PaymentDetails { } } + if let Some(tx_type_update) = update.tx_type { + match self.kind { + PaymentKind::Onchain { ref mut tx_type, .. } => { + update_if_necessary!(*tx_type, tx_type_update); + }, + _ => {}, + } + } + if updated { self.latest_update_timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -330,6 +342,156 @@ impl_writeable_tlv_based_enum!(PaymentStatus, (4, Failed) => {} ); +/// A channel referenced by a [`TransactionType`]. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +pub struct Channel { + /// The `node_id` of the channel counterparty. + pub counterparty_node_id: PublicKey, + /// The ID of the channel. + pub channel_id: ChannelId, +} + +impl_writeable_tlv_based!(Channel, { + (0, counterparty_node_id, required), + (2, channel_id, required), +}); + +/// The classification of a [`PaymentKind::Onchain`] transaction, as reported by LDK when the +/// transaction was broadcast. +/// +/// Mirrors [`lightning::chain::chaininterface::TransactionType`], retaining the channel references +/// but dropping the broadcast-time contribution data; a transaction's amount and fee are tracked on +/// the [`PaymentDetails`] itself. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum TransactionType { + /// A funding transaction establishing one or more new channels. + Funding { + /// The channels being funded. + channels: Vec, + }, + /// A transaction cooperatively closing a channel. + CooperativeClose { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel being closed. + channel_id: ChannelId, + }, + /// A transaction force-closing a channel. + UnilateralClose { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel being force-closed. + channel_id: ChannelId, + }, + /// An anchor transaction CPFP fee-bumping a closing transaction. + AnchorBump { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel whose closing transaction is being fee-bumped. + channel_id: ChannelId, + }, + /// A transaction resolving an output spendable by both us and our counterparty. + Claim { + /// The `node_id` of the channel counterparty. + counterparty_node_id: PublicKey, + /// The ID of the channel from which outputs are being claimed. + channel_id: ChannelId, + }, + /// A transaction sweeping spendable outputs to the on-chain wallet. + Sweep { + /// The channels from which outputs are being swept, if known. + channels: Vec, + }, + /// An interactively-negotiated funding transaction: a splice, or (once supported) a V2 + /// dual-funded channel open. + InteractiveFunding { + /// The channels participating in the negotiation. + channels: Vec, + }, +} + +impl_writeable_tlv_based_enum!(TransactionType, + (0, Funding) => { + (0, channels, optional_vec), + }, + (2, CooperativeClose) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (4, UnilateralClose) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (6, AnchorBump) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (8, Claim) => { + (0, counterparty_node_id, required), + (2, channel_id, required), + }, + (10, Sweep) => { + (0, channels, optional_vec), + }, + (12, InteractiveFunding) => { + (0, channels, optional_vec), + } +); + +impl From for TransactionType { + fn from(tx_type: LdkTransactionType) -> Self { + let to_channels = |channels: Vec<(PublicKey, ChannelId)>| -> Vec { + channels + .into_iter() + .map(|(counterparty_node_id, channel_id)| Channel { + counterparty_node_id, + channel_id, + }) + .collect() + }; + match tx_type { + LdkTransactionType::Funding { channels } => { + TransactionType::Funding { channels: to_channels(channels) } + }, + LdkTransactionType::CooperativeClose { counterparty_node_id, channel_id } => { + TransactionType::CooperativeClose { counterparty_node_id, channel_id } + }, + LdkTransactionType::UnilateralClose { counterparty_node_id, channel_id } => { + TransactionType::UnilateralClose { counterparty_node_id, channel_id } + }, + LdkTransactionType::AnchorBump { counterparty_node_id, channel_id } => { + TransactionType::AnchorBump { counterparty_node_id, channel_id } + }, + LdkTransactionType::Claim { counterparty_node_id, channel_id } => { + TransactionType::Claim { counterparty_node_id, channel_id } + }, + LdkTransactionType::Sweep { channels } => { + TransactionType::Sweep { channels: to_channels(channels) } + }, + LdkTransactionType::InteractiveFunding { candidates } => { + // Every candidate (the original negotiation plus any RBF replacements) references + // the same channel(s); take the active (last) candidate's channel references. + let channels = candidates + .last() + .map(|candidate| { + candidate + .channels + .iter() + .map(|cf| Channel { + counterparty_node_id: cf.counterparty_node_id, + channel_id: cf.channel_id, + }) + .collect() + }) + .unwrap_or_default(); + TransactionType::InteractiveFunding { channels } + }, + } + } +} + /// Represents the kind of a payment. #[derive(Clone, Debug, PartialEq, Eq)] #[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] @@ -345,6 +507,11 @@ pub enum PaymentKind { txid: Txid, /// The confirmation status of this payment. status: ConfirmationStatus, + /// The classification of this transaction, if known. + /// + /// `None` for plain on-chain sends, and for records written by versions of LDK Node that + /// predate on-chain transaction classification. + tx_type: Option, }, /// A [BOLT 11] payment. /// @@ -423,6 +590,7 @@ pub enum PaymentKind { impl_writeable_tlv_based_enum!(PaymentKind, (0, Onchain) => { (0, txid, required), + (1, tx_type, option), (2, status, required), }, (2, Bolt11) => { @@ -522,6 +690,7 @@ pub(crate) struct PaymentDetailsUpdate { pub status: Option, pub confirmation_status: Option, pub txid: Option, + pub tx_type: Option>, } impl PaymentDetailsUpdate { @@ -538,6 +707,7 @@ impl PaymentDetailsUpdate { status: None, confirmation_status: None, txid: None, + tx_type: None, } } } @@ -552,9 +722,11 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate { _ => (None, None, None), }; - let (confirmation_status, txid) = match &value.kind { - PaymentKind::Onchain { status, txid, .. } => (Some(*status), Some(*txid)), - _ => (None, None), + let (confirmation_status, txid, tx_type) = match &value.kind { + PaymentKind::Onchain { status, txid, tx_type } => { + (Some(*status), Some(*txid), Some(tx_type.clone())) + }, + _ => (None, None, None), }; let counterparty_skimmed_fee_msat = match value.kind { @@ -576,6 +748,7 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate { status: Some(value.status), confirmation_status, txid, + tx_type, } } } @@ -697,6 +870,57 @@ mod tests { } } + #[derive(Clone, Debug, PartialEq, Eq)] + struct OldOnchainKind { + txid: Txid, + status: ConfirmationStatus, + } + + impl_writeable_tlv_based!(OldOnchainKind, { + (0, txid, required), + (2, status, required), + }); + + #[test] + fn onchain_tx_type_deser_compat() { + use bitcoin::hashes::Hash; + use std::str::FromStr; + + let txid = Txid::from_byte_array([7u8; 32]); + let status = ConfirmationStatus::Unconfirmed; + + // An `Onchain` record written before `tx_type` existed (only txid + status) must read back + // with `tx_type: None`. + let old = OldOnchainKind { txid, status }; + let mut on_disk = Vec::new(); + 0u8.write(&mut on_disk).unwrap(); // the `Onchain` enum discriminant + on_disk.extend_from_slice(&old.encode()); + match PaymentKind::read(&mut &*on_disk).unwrap() { + PaymentKind::Onchain { txid: t, status: s, tx_type } => { + assert_eq!(t, txid); + assert_eq!(s, status); + assert_eq!(tx_type, None); + }, + other => panic!("Unexpected kind: {:?}", other), + } + + // A populated `tx_type` round-trips. + let kind = PaymentKind::Onchain { + txid, + status, + tx_type: Some(TransactionType::InteractiveFunding { + channels: vec![Channel { + counterparty_node_id: PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(), + channel_id: ChannelId([3u8; 32]), + }], + }), + }; + assert_eq!(kind, PaymentKind::read(&mut &*kind.encode()).unwrap()); + } + #[derive(Clone, Debug, PartialEq, Eq)] struct LegacyBolt11JitKind { hash: PaymentHash, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f3429afbfc..4b83c64e5e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -310,6 +310,7 @@ impl Wallet { PaymentKind::Onchain { txid, status: ConfirmationStatus::Unconfirmed, + .. } if payment.details.direction == PaymentDirection::Outbound => { unconfirmed_outbound_txids.push(txid); }, @@ -1171,7 +1172,7 @@ impl Wallet { // here to determine the `PaymentKind`, but that's not really satisfactory, so // we're punting on it until we can come up with a better solution. - let kind = PaymentKind::Onchain { txid, status: confirmation_status }; + let kind = PaymentKind::Onchain { txid, status: confirmation_status, tx_type: None }; let fee = locked_wallet.calculate_fee(tx).unwrap_or(Amount::ZERO); let (sent, received) = locked_wallet.sent_and_received(tx); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 521cb74caf..404b1a1db5 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -610,7 +610,7 @@ async fn onchain_send_receive() { let payment_a = node_a.payment(&payment_id).unwrap(); match payment_a.kind { - PaymentKind::Onchain { txid: _txid, status } => { + PaymentKind::Onchain { txid: _txid, status, .. } => { assert_eq!(_txid, txid); assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); }, @@ -619,7 +619,7 @@ async fn onchain_send_receive() { let payment_b = node_a.payment(&payment_id).unwrap(); match payment_b.kind { - PaymentKind::Onchain { txid: _txid, status } => { + PaymentKind::Onchain { txid: _txid, status, .. } => { assert_eq!(_txid, txid); assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); }, From 79fd087e5679360fa074125a1d5ede304c69587a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 11:59:40 -0500 Subject: [PATCH 053/138] Track channel-open and splice payments through wallet sync Record channel-open and splice funding transactions as on-chain payments at broadcast, and carry them to Succeeded through ANTI_REORG_DELAY confirmations like any other on-chain payment, instead of tying their status to the Lightning channel lifecycle. A splice's recorded amount and fee are this node's share of the funding contribution, which wallet sync preserves rather than overwriting with its own view of the (possibly multi-party) transaction. On-chain RBF of these payments is rejected: LDK drives funding and splice transactions, so replacing one would broadcast a transaction it isn't tracking and, for a splice, can't re-sign. Addresses review feedback to keep on-chain payment status confirmation- driven rather than gated on ChannelReady. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/builder.rs | 2 + src/chain/mod.rs | 25 +++- src/tx_broadcaster.rs | 69 +++++++++- src/wallet/mod.rs | 302 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 384 insertions(+), 14 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index d142f51afc..7a26ce24f6 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1618,6 +1618,8 @@ fn build_with_store_internal( Arc::clone(&pending_payment_store), )); + tx_broadcaster.set_wallet(Arc::downgrade(&wallet)); + // Initialize the KeysManager let cur_time = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).map_err(|e| { log_error!(logger, "Failed to get current time: {}", e); diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 5a326be97b..8a8115e4f5 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -13,7 +13,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bitcoin::{Script, Txid}; +use bitcoin::{Script, Transaction, Txid}; use lightning::chain::{BlockLocator, Filter}; use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient}; @@ -24,7 +24,7 @@ use crate::config::{ WALLET_SYNC_INTERVAL_MINIMUM_SECS, }; use crate::fee_estimator::OnchainFeeEstimator; -use crate::logger::{log_debug, log_info, log_trace, LdkLogger, Logger}; +use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -453,15 +453,30 @@ impl ChainSource { return; } Some(next_package) = receiver.recv() => { + // Classify funding broadcasts into payment records before sending. If + // classification fails we skip the broadcast, since broadcasting a tx we + // failed to record would leave it on-chain without a payment. + let package = match self.tx_broadcaster.classify_package(next_package).await { + Ok(package) => package, + Err(e) => { + log_error!( + tx_bcast_logger, + "Skipping broadcast: failed to persist payment records: {:?}", + e, + ); + continue; + }, + }; + let txs: Vec = package.into_transactions(); match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_broadcast_package(next_package).await + esplora_chain_source.process_broadcast_package(txs).await }, ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_broadcast_package(next_package).await + electrum_chain_source.process_broadcast_package(txs).await }, ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_broadcast_package(next_package).await + bitcoind_chain_source.process_broadcast_package(txs).await }, } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 7084135b00..5722a3ebe3 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -6,21 +6,52 @@ // accordance with one or both of these licenses. use std::ops::Deref; +use std::sync::{Mutex as StdMutex, Weak}; use bitcoin::Transaction; use lightning::chain::chaininterface::{BroadcasterInterface, TransactionType}; use tokio::sync::{mpsc, Mutex, MutexGuard}; use crate::logger::{log_error, LdkLogger}; +use crate::types::Wallet; +use crate::Error; const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; +/// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` +/// call, along with each transaction's type. Queued until the background task classifies and +/// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated +/// transactions can't be grouped into one package by accident. +pub(crate) struct BroadcastPackage(Vec<(Transaction, TransactionType)>); + +impl BroadcastPackage { + /// Builds a package from the transactions of a single `broadcast_transactions` call. + fn new(txs: &[(&Transaction, TransactionType)]) -> Self { + Self(txs.iter().map(|(tx, tx_type)| ((*tx).clone(), tx_type.clone())).collect()) + } + + /// The packaged transactions and their types, for classification. + fn transactions(&self) -> &[(Transaction, TransactionType)] { + &self.0 + } + + /// Consumes the package into its transactions, ready for the chain client. + pub(crate) fn into_transactions(self) -> Vec { + self.0.into_iter().map(|(tx, _)| tx).collect() + } +} + pub(crate) struct TransactionBroadcaster where L::Target: LdkLogger, { - queue_sender: mpsc::Sender>, - queue_receiver: Mutex>>, + queue_sender: mpsc::Sender, + queue_receiver: Mutex>, + /// Weak handle to the [`Wallet`] that classifies funding broadcasts (channel opens and + /// splices) into payment records. Remains `None` while the builder is wiring the node up, + /// during which broadcasts are forwarded to the queue but no payment record is written. + /// [`Self::set_wallet`] installs the handle once the [`Wallet`] exists. + wallet: StdMutex>>, logger: L, } @@ -30,14 +61,41 @@ where { pub(crate) fn new(logger: L) -> Self { let (queue_sender, queue_receiver) = mpsc::channel(BCAST_PACKAGE_QUEUE_SIZE); - Self { queue_sender, queue_receiver: Mutex::new(queue_receiver), logger } + Self { + queue_sender, + queue_receiver: Mutex::new(queue_receiver), + wallet: StdMutex::new(None), + logger, + } + } + + /// Installs the [`Wallet`] handle used to classify funding broadcasts (channel opens and + /// splices) into payment records. Called once the builder has constructed both the + /// broadcaster and the wallet. + pub(crate) fn set_wallet(&self, wallet: Weak) { + *self.wallet.lock().expect("lock") = Some(wallet); } pub(crate) async fn get_broadcast_queue( &self, - ) -> MutexGuard<'_, mpsc::Receiver>> { + ) -> MutexGuard<'_, mpsc::Receiver> { self.queue_receiver.lock().await } + + /// Classifies a queued package into payment records and returns the package ready for the + /// chain client. Returns `Err` if any classification fails; callers must not broadcast the + /// package in that case, since a crash would leave the transaction on-chain without a record. + pub(crate) async fn classify_package( + &self, package: BroadcastPackage, + ) -> Result { + let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade); + if let Some(wallet) = wallet_opt { + for (tx, tx_type) in package.transactions() { + wallet.classify_broadcast(tx, tx_type).await?; + } + } + Ok(package) + } } impl BroadcasterInterface for TransactionBroadcaster @@ -45,8 +103,7 @@ 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).unwrap_or_else(|e| { + self.queue_sender.try_send(BroadcastPackage::new(txs)).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 4b83c64e5e..28a4a3d802 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -27,11 +27,12 @@ use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{All, PublicKey, Scalar, Secp256k1, SecretKey}; use bitcoin::transaction::Sequence; use bitcoin::{ - Address, Amount, FeeRate, OutPoint, ScriptBuf, Transaction, TxOut, Txid, WPubkeyHash, Weight, - WitnessProgram, WitnessVersion, + Address, Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid, + WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::{ - BroadcasterInterface, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, + BroadcasterInterface, FundingCandidate, TransactionType as LdkTransactionType, + INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; use lightning::chain::{BlockLocator, ClaimId, Listen}; @@ -39,6 +40,7 @@ use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; use lightning::ln::script::ShutdownScript; +use lightning::ln::types::ChannelId; use lightning::sign::{ ChangeDestinationSource, EntropySource, InMemorySigner, KeysManager, NodeSigner, OutputSpender, PeerStorageKey, Recipient, SignerProvider, SpendableOutputDescriptor, @@ -56,6 +58,7 @@ use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger use crate::payment::store::ConfirmationStatus; use crate::payment::{ PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, PendingPaymentDetails, + TransactionType, }; use crate::runtime::Runtime; use crate::types::{Broadcaster, PaymentStore, PendingPaymentStore}; @@ -257,6 +260,10 @@ impl Wallet { .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); + if self.apply_funding_status_update(payment_id, txid, confirmation_status)? { + continue; + } + let payment = self.create_payment_from_tx( locked_wallet, txid, @@ -351,6 +358,14 @@ impl Wallet { .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); + if self.apply_funding_status_update( + payment_id, + txid, + ConfirmationStatus::Unconfirmed, + )? { + continue; + } + let payment = self.create_payment_from_tx( locked_wallet, txid, @@ -401,6 +416,15 @@ impl Wallet { let payment_id = self .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); + + if self.apply_funding_status_update( + payment_id, + txid, + ConfirmationStatus::Unconfirmed, + )? { + continue; + } + let payment = self.create_payment_from_tx( locked_wallet, txid, @@ -1155,6 +1179,181 @@ impl Wallet { Ok(tx) } + /// Classifies a funding broadcast (channel open or splice) handed to the broadcaster by LDK, + /// recording a payment for it before it is sent. Other transaction types are left for wallet + /// sync to record normally. + pub(crate) async fn classify_broadcast( + &self, tx: &Transaction, tx_type: &LdkTransactionType, + ) -> Result<(), Error> { + match tx_type { + LdkTransactionType::Funding { channels } => { + self.classify_funding(tx, channels, tx_type.clone().into()).await + }, + LdkTransactionType::InteractiveFunding { candidates } => { + self.classify_interactive_funding(tx, candidates, tx_type.clone().into()).await + }, + _ => Ok(()), + } + } + + /// Records a single-channel funding (channel open) broadcast as a pending on-chain payment, + /// tagged with its transaction type. Amount and fee come from the wallet's view of the + /// transaction. Batched funding is left for wallet sync. + async fn classify_funding( + &self, tx: &Transaction, channels: &[(PublicKey, ChannelId)], tx_type: TransactionType, + ) -> Result<(), Error> { + if channels.len() != 1 { + if channels.len() > 1 { + log_trace!( + self.logger, + "Skipping funding classification for batched broadcast ({} channels)", + channels.len() + ); + } + return Ok(()); + } + + let (_counterparty_node_id, channel_id) = channels[0]; + let txid = tx.compute_txid(); + let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); + + let payment_id = PaymentId(txid.to_byte_array()); + let details = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(tx_type), + }, + amount_msat, + fee_paid_msat, + direction, + PaymentStatus::Pending, + ); + self.persist_funding_payment(details).await?; + log_debug!( + self.logger, + "Recorded channel-funding broadcast {} for channel {}", + txid, + channel_id, + ); + Ok(()) + } + + /// Records an interactive-funding broadcast (splice, or a V2 dual-funded open) as a pending + /// on-chain payment, tagged with its transaction type. Amount and fee are this node's share, + /// derived from the active candidate's contributions; broadcasts we didn't contribute to, or + /// that don't move wallet funds, are left for wallet sync. + async fn classify_interactive_funding( + &self, tx: &Transaction, candidates: &[FundingCandidate], tx_type: TransactionType, + ) -> Result<(), Error> { + // `InteractiveFunding` carries the full negotiated history; the currently-broadcast + // candidate is the last entry, earlier entries are RBF predecessors. + let active = match candidates.last() { + Some(c) => c, + None => return Ok(()), + }; + let first = match candidates.first() { + Some(c) => c, + None => return Ok(()), + }; + + let txid = tx.compute_txid(); + debug_assert_eq!(active.txid, txid, "broadcast tx must match the active candidate"); + + let aggregate = aggregate_local_stakes(active); + let amount_msat = match aggregate.amount_msat { + Some(amt) => Some(amt), + None => { + log_trace!( + self.logger, + "Not recording interactive-funding broadcast {} as a payment: no local contribution", + txid, + ); + return Ok(()); + }, + }; + let fee_paid_msat = aggregate.fee_paid_msat; + let direction = aggregate.direction; + + // A contribution doesn't mean the tx touches our on-chain wallet: a splice-out to an + // external address sends channel funds to a third party, which BDK sees as zero wallet + // movement. Nothing for the on-chain payment store to record, so skip it. + let (wallet_amount_msat, _wallet_fee_msat, _wallet_direction) = + self.onchain_payment_fields(tx); + if wallet_amount_msat == Some(0) { + log_trace!( + self.logger, + "Not recording interactive-funding broadcast {} as a payment: no wallet-level activity", + txid, + ); + return Ok(()); + } + + // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable + // across RBF replacements. + let payment_id = PaymentId(first.txid.to_byte_array()); + let details = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(tx_type), + }, + amount_msat, + fee_paid_msat, + direction, + PaymentStatus::Pending, + ); + self.persist_funding_payment(details).await?; + log_debug!( + self.logger, + "Recorded interactive-funding broadcast {} ({} candidates, {} channels)", + txid, + candidates.len(), + active.channels.len(), + ); + Ok(()) + } + + /// Writes a freshly-classified funding payment to the authoritative payment store and adds a + /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. + async fn persist_funding_payment(&self, details: PaymentDetails) -> Result<(), Error> { + self.payment_store.insert_or_update(details.clone()).await?; + let pending = PendingPaymentDetails::new(details, Vec::new()); + self.pending_payment_store.insert_or_update(pending).await?; + Ok(()) + } + + /// Returns the wallet's view of a transaction as `(amount_msat, fee_msat, direction)`. + pub(crate) fn onchain_payment_fields( + &self, tx: &Transaction, + ) -> (Option, Option, PaymentDirection) { + let locked_wallet = self.inner.lock().expect("lock"); + let fee = locked_wallet.calculate_fee(tx).unwrap_or(Amount::ZERO); + let (sent, received) = locked_wallet.sent_and_received(tx); + let fee_sat = fee.to_sat(); + + let (direction, amount_msat) = if sent > received { + ( + PaymentDirection::Outbound, + Some( + (sent.to_sat().saturating_sub(fee_sat).saturating_sub(received.to_sat())) + * 1000, + ), + ) + } else { + ( + PaymentDirection::Inbound, + Some( + received.to_sat().saturating_sub(sent.to_sat().saturating_sub(fee_sat)) * 1000, + ), + ) + }; + + (amount_msat, Some(fee_sat * 1000), direction) + } + fn create_payment_from_tx( &self, locked_wallet: &PersistedWallet, txid: Txid, payment_id: PaymentId, tx: &Transaction, payment_status: PaymentStatus, @@ -1231,6 +1430,43 @@ impl Wallet { None } + /// If `payment_id` refers to a classified funding payment, refreshes its confirmation status + /// and the candidate txid the event refers to, while preserving the contribution-derived + /// amount/fee and `tx_type` that wallet sync must not recompute from its own view: the wallet's + /// `sent`/`received` don't capture our contribution to a shared funding output. Returns `true` + /// when it handled the payment, so the caller skips the default on-chain path. Graduation to + /// `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. + fn apply_funding_status_update( + &self, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, + ) -> Result { + let Some(mut payment) = self.payment_store.get(&payment_id) else { + return Ok(false); + }; + let tx_type = match &payment.kind { + PaymentKind::Onchain { + tx_type: + tx_type @ Some( + TransactionType::Funding { .. } + | TransactionType::InteractiveFunding { .. }, + ), + .. + } => tx_type.clone(), + _ => return Ok(false), + }; + payment.kind = + PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; + self.runtime.block_on(self.payment_store.insert_or_update(payment.clone()))?; + // Mirror the refreshed confirmation status onto the pending entry: `ChainTipChanged` + // graduates by reading the pending entry's details, so it must see the new status. This is + // the same dual-write the default `TxConfirmed` path performs; an empty conflicting-txids + // list leaves any stored conflicts intact (the update treats absent as "unchanged"). + if payment.status == PaymentStatus::Pending { + let pending = self.create_pending_payment_from_tx(payment, Vec::new()); + self.runtime.block_on(self.pending_payment_store.insert_or_update(pending))?; + } + Ok(true) + } + #[allow(deprecated)] pub(crate) fn bump_fee_rbf( &self, payment_id: PaymentId, fee_rate: Option, cur_anchor_reserve_sats: u64, @@ -1240,6 +1476,24 @@ impl Wallet { Error::InvalidPaymentId })?; + // Funding transactions (channel opens and splices) are driven by LDK's funding/splice + // lifecycle, not the on-chain wallet. Replacing one via on-chain RBF would broadcast a + // transaction LDK isn't tracking (and, for splices, can't sign). Fee-bumping a pending + // splice goes through `bump_channel_funding_fee` instead. + if let PaymentKind::Onchain { + tx_type: + Some(TransactionType::Funding { .. } | TransactionType::InteractiveFunding { .. }), + .. + } = &payment.kind + { + log_error!( + self.logger, + "Cannot RBF funding payment {} via bump_fee_rbf; use bump_channel_funding_fee instead", + payment_id, + ); + return Err(Error::InvalidPaymentId); + } + if let PaymentKind::Onchain { status, .. } = &payment.kind { match status { ConfirmationStatus::Confirmed { .. } => { @@ -1474,6 +1728,48 @@ impl Wallet { } } +struct LocalStakeAggregate { + amount_msat: Option, + fee_paid_msat: Option, + direction: PaymentDirection, +} + +/// Aggregates our net stake across the channels of a single [`FundingCandidate`] by summing each +/// channel's signed [`FundingContribution::net_value`]. Returns no amount if we contributed to none +/// of them. +fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { + let mut net_stake = SignedAmount::ZERO; + let mut fee = Amount::ZERO; + let mut have_contribution = false; + for channel in &candidate.channels { + if let Some(contribution) = channel.contribution.as_ref() { + have_contribution = true; + net_stake += contribution.net_value(); + // `estimated_fee` is our per-contributor share, so summing across channels is correct. + fee += contribution.estimated_fee(); + } + } + if !have_contribution { + return LocalStakeAggregate { + amount_msat: None, + fee_paid_msat: None, + direction: PaymentDirection::Outbound, + }; + } + // Direction is from our on-chain wallet's perspective: a positive net stake funds the channel + // (Outbound), while a negative one is a splice-out that returns funds to the wallet (Inbound). + let direction = if net_stake >= SignedAmount::ZERO { + PaymentDirection::Outbound + } else { + PaymentDirection::Inbound + }; + LocalStakeAggregate { + amount_msat: Some(net_stake.unsigned_abs().to_sat() * 1000), + fee_paid_msat: Some(fee.to_sat() * 1000), + direction, + } +} + impl Listen for Wallet { fn filtered_block_connected( &self, _header: &bitcoin::block::Header, From c8bc878143ee47a9c8573b35fa9241fd0f281844 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 26 Jun 2026 10:06:06 -0500 Subject: [PATCH 054/138] Derive on-chain payment fields in a single place `create_payment_from_tx` duplicated the amount/fee/direction derivation that `onchain_payment_fields` already performs. Share it via a helper that operates on the already-locked wallet, so both paths agree by construction. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/wallet/mod.rs | 39 +++++++++++---------------------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 28a4a3d802..f8208fb0b2 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1330,6 +1330,14 @@ impl Wallet { &self, tx: &Transaction, ) -> (Option, Option, PaymentDirection) { let locked_wallet = self.inner.lock().expect("lock"); + self.onchain_payment_fields_locked(&locked_wallet, tx) + } + + /// [`Self::onchain_payment_fields`] against an already-locked wallet, so callers that hold the + /// lock (e.g. [`Self::create_payment_from_tx`]) can reuse the derivation without re-locking. + fn onchain_payment_fields_locked( + &self, locked_wallet: &PersistedWallet, tx: &Transaction, + ) -> (Option, Option, PaymentDirection) { let fee = locked_wallet.calculate_fee(tx).unwrap_or(Amount::ZERO); let (sent, received) = locked_wallet.sent_and_received(tx); let fee_sat = fee.to_sat(); @@ -1373,35 +1381,10 @@ impl Wallet { let kind = PaymentKind::Onchain { txid, status: confirmation_status, tx_type: None }; - let fee = locked_wallet.calculate_fee(tx).unwrap_or(Amount::ZERO); - let (sent, received) = locked_wallet.sent_and_received(tx); - let fee_sat = fee.to_sat(); + let (amount_msat, fee_paid_msat, direction) = + self.onchain_payment_fields_locked(locked_wallet, tx); - let (direction, amount_msat) = if sent > received { - ( - PaymentDirection::Outbound, - Some( - (sent.to_sat().saturating_sub(fee_sat).saturating_sub(received.to_sat())) - * 1000, - ), - ) - } else { - ( - PaymentDirection::Inbound, - Some( - received.to_sat().saturating_sub(sent.to_sat().saturating_sub(fee_sat)) * 1000, - ), - ) - }; - - PaymentDetails::new( - payment_id, - kind, - amount_msat, - Some(fee_sat * 1000), - direction, - payment_status, - ) + PaymentDetails::new(payment_id, kind, amount_msat, fee_paid_msat, direction, payment_status) } fn create_pending_payment_from_tx( From c34473ddb48505a235781888114d9d38f31262f9 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 12:04:48 -0500 Subject: [PATCH 055/138] Add bump_channel_funding_fee to fee-bump a pending splice A splice's funding transaction can be stuck at too low a fee rate with no way to raise it: on-chain RBF is rejected for funding transactions, and re-issuing splice_in / splice_out errors while a splice is already pending. Add bump_channel_funding_fee, which replaces the pending splice's funding transaction at a higher fee rate while preserving its amount and destination, and point the "a prior splice contribution is pending" errors at it. Replacing the transaction also requires signing a funding input the wallet already treats as spent by the splice being replaced, which it would otherwise skip after syncing. Co-Authored-By: Claude Opus 4.8 (1M context) --- bindings/ldk_node.udl | 2 + src/lib.rs | 107 +++++++++++++++++++++++++++++++++++++++++- src/wallet/mod.rs | 11 +++-- 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 851583c5ad..5621f17514 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -124,6 +124,8 @@ interface Node { [Throws=NodeError] void splice_out([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, [ByRef]Address address, u64 splice_amount_sats); [Throws=NodeError] + void bump_channel_funding_fee([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); + [Throws=NodeError] void close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id); [Throws=NodeError] void force_close_channel([ByRef]UserChannelId user_channel_id, PublicKey counterparty_node_id, string? reason); diff --git a/src/lib.rs b/src/lib.rs index 34fa7f54d6..a3410db1f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1653,7 +1653,7 @@ impl Node { if funding_template.prior_contribution().is_some() { log_error!( self.logger, - "Failed to splice channel: a prior splice contribution is pending" + "Failed to splice channel: a prior splice contribution is pending; use bump_channel_funding_fee to bump its fee" ); return Err(Error::ChannelSplicingFailed); } @@ -1776,7 +1776,7 @@ impl Node { if funding_template.prior_contribution().is_some() { log_error!( self.logger, - "Failed to splice channel: a prior splice contribution is pending" + "Failed to splice channel: a prior splice contribution is pending; use bump_channel_funding_fee to bump its fee" ); return Err(Error::ChannelSplicingFailed); } @@ -1813,6 +1813,77 @@ impl Node { } } + /// Fee-bumps the pending splice on a channel by replacing its in-flight funding transaction + /// (RBF). The splice's amount and destination are preserved; only the fee rate is raised. + /// Errors if the channel has no pending splice to bump. + pub fn bump_channel_funding_fee( + &self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey, + ) -> Result<(), Error> { + let open_channels = + self.channel_manager.list_channels_with_counterparty(&counterparty_node_id); + if let Some(channel_details) = + open_channels.iter().find(|c| c.user_channel_id == user_channel_id.0) + { + let min_feerate = + self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); + + let funding_template = self + .channel_manager + .splice_channel(&channel_details.channel_id, &counterparty_node_id) + .map_err(|e| { + log_error!(self.logger, "Failed to RBF channel: {:?}", e); + Error::ChannelSplicingFailed + })?; + + let Some(min_rbf_feerate) = funding_template.min_rbf_feerate() else { + log_error!(self.logger, "Failed to RBF channel: no pending splice to replace"); + return Err(Error::ChannelSplicingFailed); + }; + + let Some((target_feerate, max_feerate)) = + rbf_splice_feerates(min_feerate, min_rbf_feerate) + else { + log_error!( + self.logger, + "Failed to RBF channel: the RBF minimum feerate exceeds our maximum" + ); + return Err(Error::ChannelSplicingFailed); + }; + + let contribution = self + .runtime + .block_on(funding_template.rbf_prior_contribution( + Some(target_feerate), + max_feerate, + Arc::clone(&self.wallet), + )) + .map_err(|e| { + log_error!(self.logger, "Failed to RBF channel: {}", e); + Error::ChannelSplicingFailed + })?; + + self.channel_manager + .funding_contributed( + &channel_details.channel_id, + &counterparty_node_id, + contribution, + None, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to RBF channel: {:?}", e); + Error::ChannelSplicingFailed + }) + } else { + log_error!( + self.logger, + "Channel not found for user_channel_id {} and counterparty {}", + user_channel_id, + counterparty_node_id + ); + Err(Error::ChannelSplicingFailed) + } + } + /// Manually sync the LDK and BDK wallets with the current chain state and update the fee rate /// cache. /// @@ -2322,12 +2393,44 @@ pub(crate) fn new_channel_anchor_reserve_sats( }) } +/// The most we are willing to pay for a channel funding transaction: `1.5x` our funding feerate +/// estimate. Used as the `max_feerate` ceiling for splices and their RBF fee bumps. +fn max_funding_feerate(estimate: FeeRate) -> FeeRate { + FeeRate::from_sat_per_kwu(estimate.to_sat_per_kwu() * 3 / 2) +} + +/// Picks the `(target, max)` feerates for replacing a pending splice's in-flight funding +/// transaction via RBF, or `None` if the RBF can't be done within our fee ceiling. +/// +/// `max` is the most we are willing to pay (see [`max_funding_feerate`]), which tracks our current +/// estimate and so may have risen or fallen since the original splice; it is never inflated to meet +/// the RBF minimum. `target` is what we actually pay — our current estimate, or the template's RBF +/// minimum if that is higher (required to replace the transaction). If that minimum exceeds `max`, +/// we can't RBF. +fn rbf_splice_feerates(estimate: FeeRate, min_rbf_feerate: FeeRate) -> Option<(FeeRate, FeeRate)> { + let max = max_funding_feerate(estimate); + let target = estimate.max(min_rbf_feerate); + (target <= max).then_some((target, max)) +} + #[cfg(test)] mod tests { use lightning::util::ser::{Readable, Writeable}; use super::*; + #[test] + fn rbf_splice_feerates_target_and_max() { + let kwu = FeeRate::from_sat_per_kwu; + // Estimate below the RBF minimum but within our ceiling: pay the minimum to replace the + // transaction; the max stays 1.5x the estimate (never inflated) and already clears it. + assert_eq!(rbf_splice_feerates(kwu(253), kwu(278)), Some((kwu(278), kwu(253 * 3 / 2)))); + // Estimate risen above the RBF minimum: pay the higher estimate, not the stale minimum. + assert_eq!(rbf_splice_feerates(kwu(500), kwu(278)), Some((kwu(500), kwu(500 * 3 / 2)))); + // RBF minimum above our max (1.5x a fallen estimate): we can't RBF within our ceiling. + assert_eq!(rbf_splice_feerates(kwu(100), kwu(278)), None); + } + #[test] fn node_metrics_reads_legacy_rgs_snapshot_timestamp() { // Pre-#615, `NodeMetrics` persisted `latest_rgs_snapshot_timestamp` as an optional diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f8208fb0b2..be5c7e503c 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -5,6 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +use std::collections::HashMap; use std::future::Future; use std::ops::Deref; use std::str::FromStr; @@ -15,7 +16,7 @@ use bdk_wallet::descriptor::ExtendedDescriptor; use bdk_wallet::error::{BuildFeeBumpError, CreateTxError}; #[allow(deprecated)] use bdk_wallet::SignOptions; -use bdk_wallet::{Balance, KeychainKind, PersistedWallet, Update, WalletEvent}; +use bdk_wallet::{Balance, KeychainKind, LocalOutput, PersistedWallet, Update, WalletEvent}; use bitcoin::address::NetworkUnchecked; use bitcoin::blockdata::constants::WITNESS_SCALE_FACTOR; use bitcoin::blockdata::locktime::absolute::LockTime; @@ -1119,9 +1120,13 @@ impl Wallet { let mut psbt = Psbt::from_unsigned_tx(unsigned_tx).map_err(|e| { log_error!(self.logger, "Failed to construct PSBT: {}", e); })?; + // Use list_output rather than get_utxo to include outputs spent by unconfirmed + // transactions (e.g., a prior splice being replaced via RBF), which a synced wallet would + // otherwise no longer treat as an owned UTXO. + let mut wallet_outputs: HashMap = + locked_wallet.list_output().map(|output| (output.outpoint, output)).collect(); for (i, txin) in psbt.unsigned_tx.input.iter().enumerate() { - if let Some(utxo) = locked_wallet.get_utxo(txin.previous_output) { - debug_assert!(!utxo.is_spent); + if let Some(utxo) = wallet_outputs.remove(&txin.previous_output) { psbt.inputs[i] = locked_wallet.get_psbt_input(utxo, None, true).map_err(|e| { log_error!(self.logger, "Failed to construct PSBT input: {}", e); })?; From 9f98db5983ef20fea930afadf374190009ecb320 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 14:30:42 -0500 Subject: [PATCH 056/138] Test funding-payment tracking through wallet sync Cover the wallet-event-driven funding payment lifecycle end to end: a channel-open funding payment reaches Succeeded from wallet sync alone, asserted before any ChannelReady event is drained to show payment status no longer depends on the channel-ready signal; and a splice fee-bumped via RBF stays a single on-chain payment that follows the winning candidate while keeping its interactive-funding classification across the replacement. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration_tests_rust.rs | 240 +++++++++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 404b1a1db5..bd0068458a 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -27,14 +27,14 @@ use common::{ setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, TestChainSource, TestConfig, TestStoreType, TestSyncStore, }; -use electrsd::corepc_node::Node as BitcoinD; +use electrsd::corepc_node::{self, Node as BitcoinD}; use electrsd::ElectrsD; use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig}; use ldk_node::entropy::NodeEntropy; use ldk_node::liquidity::LSPS2ServiceConfig; use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, - UnifiedPaymentResult, + TransactionType, UnifiedPaymentResult, }; use ldk_node::{Builder, Event, NodeError}; use lightning::ln::channelmanager::PaymentId; @@ -1317,6 +1317,242 @@ async fn splice_channel() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn rbf_splice_channel() { + // Use a custom bitcoind config with a lower incrementalrelayfee so that the +25 sat/kwu + // (0.1 sat/vB) RBF feerate bump satisfies BIP125's absolute fee increase requirement. + let bitcoind_exe = std::env::var("BITCOIND_EXE") + .ok() + .or_else(|| corepc_node::downloaded_exe_path().ok()) + .expect( + "you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature", + ); + let mut bitcoind_conf = corepc_node::Conf::default(); + bitcoind_conf.network = "regtest"; + bitcoind_conf.args.push("-rest"); + bitcoind_conf.args.push("-incrementalrelayfee=0.00000100"); + let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap(); + + let electrs_exe = std::env::var("ELECTRS_EXE") + .ok() + .or_else(electrsd::downloaded_exe_path) + .expect("you need to provide env var ELECTRS_EXE or specify an electrsd version feature"); + let mut electrsd_conf = electrsd::Conf::default(); + electrsd_conf.http_enabled = true; + electrsd_conf.network = "regtest"; + let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + // bump_channel_funding_fee should fail when there's no pending splice + assert_eq!( + node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id()), + Err(NodeError::ChannelSplicingFailed), + ); + + // Initiate a splice-in to create a pending splice + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + + let original_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + + // Sync so the original splice candidate is recorded as a canonical wallet transaction before + // the RBF below replaces it. The post-RBF sync then observes the original candidate being + // replaced (a `WalletEvent::TxReplaced`), which must not drop the payment's durable funding + // classification — the `tx_type` assertion below catches a regression deterministically. + wait_for_tx(&electrsd.client, original_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // splice_in should fail when there's a pending splice (RBF guard) + assert_eq!( + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000), + Err(NodeError::ChannelSplicingFailed), + ); + + // splice_out should fail when there's a pending splice (RBF guard) + let address = node_a.onchain_payment().new_address().unwrap(); + assert_eq!( + node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 100_000), + Err(NodeError::ChannelSplicingFailed), + ); + + // bump_channel_funding_fee should succeed when there's a pending splice + node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id()).unwrap(); + + let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + + assert_ne!(original_txo, rbf_txo, "RBF should produce a different funding txo"); + + // Wait for the RBF transaction to replace the original in the mempool. + wait_for_tx(&electrsd.client, rbf_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // After RBF but before confirmation, node_b (the initiator) should have a single on-chain + // payment covering both candidates: id anchored to the first broadcast, `kind.txid` pointing + // at the latest (RBF) candidate, and the durable interactive-funding `tx_type` preserved across + // the replacement. + { + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let payment = node_b.payment(&payment_id).expect("splice payment exists"); + match payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + } => { + assert_eq!(txid, rbf_txo.txid); + }, + ref other => { + panic!("expected Onchain Unconfirmed interactive-funding, got {:?}", other) + }, + } + assert_eq!(payment.status, PaymentStatus::Pending); + // Only one Onchain Pending payment for this splice attempt (not one per candidate). + let splice_payments = node_b.list_payments_with_filter(|p| { + p.direction == PaymentDirection::Outbound + && matches!(p.kind, PaymentKind::Onchain { .. }) + && p.status == PaymentStatus::Pending + }); + assert_eq!( + splice_payments.len(), + 1, + "expected exactly one pending Onchain payment for the splice, got {}: {:#?}", + splice_payments.len(), + splice_payments, + ); + } + + // Mine blocks and confirm the RBF splice + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // Verify the RBF transaction is the one that locked, not the original + match node_a.next_event_async().await { + Event::ChannelReady { funding_txo, counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, Some(node_b.node_id())); + assert_eq!(funding_txo, Some(rbf_txo)); + node_a.event_handled().unwrap(); + }, + ref e => panic!("node_a got unexpected event: {:?}", e), + } + match node_b.next_event_async().await { + Event::ChannelReady { funding_txo, counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, Some(node_a.node_id())); + assert_eq!(funding_txo, Some(rbf_txo)); + node_b.event_handled().unwrap(); + }, + ref e => panic!("node_b got unexpected event: {:?}", e), + } + + // The splice payment graduates to `Succeeded` purely from wallet sync reaching + // `ANTI_REORG_DELAY` confirmations — the `ChannelReady` events above are a separate + // channel-lifecycle signal, not what drives payment status. Its `kind.txid` reflects the + // winning RBF candidate, and `fee_paid_msat` carries this node's `FundingContribution` fee. + { + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let payment = node_b.payment(&payment_id).expect("splice payment graduated"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + match payment.kind { + PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } => { + assert_eq!(txid, rbf_txo.txid); + }, + ref other => panic!("expected Onchain Confirmed, got {:?}", other), + } + assert!( + payment.fee_paid_msat.is_some(), + "splice payment should carry a fee from its FundingContribution", + ); + } + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn funding_payment_graduates_without_channel_ready() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // node_a funds the channel, so it holds the funding payment. `open_channel` drains only the + // `ChannelPending` events, leaving any `ChannelReady` queued and undrained. + let funding_txo = open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + + // Mine past `ANTI_REORG_DELAY` and sync only node_a. node_b stays behind, so it cannot yet + // send `channel_ready` and node_a therefore cannot have emitted a `ChannelReady` event — any + // graduation below must come from wallet sync alone. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + + // The funding payment is `Succeeded` purely from wallet sync reaching `ANTI_REORG_DELAY` + // confirmations, asserted before draining any LDK event — so graduation is not driven by the + // Lightning `ChannelReady` signal. + let payment_id = PaymentId(funding_txo.txid.to_byte_array()); + let payment = node_a.payment(&payment_id).expect("funding payment exists"); + assert_eq!(payment.status, PaymentStatus::Succeeded); + match payment.kind { + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::Funding { .. }), + } => assert_eq!(txid, funding_txo.txid), + ref other => panic!("expected Onchain Confirmed funding payment, got {:?}", other), + } + + // Let node_b catch up so the channel completes; the `ChannelReady` events follow the + // already-`Succeeded` payment rather than driving it. + node_b.sync_wallets().unwrap(); + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_channel_ready_event!(node_b, node_a.node_id()); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From e541265e5e63733adcd3537630d3eba363f22eba Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 16:06:24 -0500 Subject: [PATCH 057/138] Report the confirmed splice candidate's fee, not the last broadcast's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A splice funding payment can be fee-bumped via RBF, producing several candidate transactions with increasing fees. The payment recorded the last-broadcast candidate's amount and fee and kept them on confirmation, but the candidate that actually confirms need not be the last one broadcast — so an earlier, lower-fee candidate confirming left the payment over-reporting its fee. Record each candidate's amount and fee, keyed by txid, so that on confirmation the payment reflects the candidate that actually confirmed. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/payment/mod.rs | 1 + src/payment/pending_payment_store.rs | 112 +++++++++++++++++++++++++-- src/wallet/mod.rs | 44 +++++++++-- tests/integration_tests_rust.rs | 72 ++++++++++++++--- 4 files changed, 205 insertions(+), 24 deletions(-) diff --git a/src/payment/mod.rs b/src/payment/mod.rs index bdc2fe96aa..2d3acf90e1 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -20,6 +20,7 @@ pub use bolt11::Bolt11Payment; pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; pub use onchain::OnchainPayment; +pub(crate) use pending_payment_store::FundingTxCandidate; pub(crate) use pending_payment_store::PendingPaymentDetails; pub use spontaneous::SpontaneousPayment; pub use store::{ diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 311fdbf34e..c8b792ccb1 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -13,6 +13,29 @@ use crate::data_store::{StorableObject, StorableObjectUpdate}; use crate::payment::store::PaymentDetailsUpdate; use crate::payment::{PaymentDetails, PaymentKind}; +/// One candidate transaction in an interactive-funding (splice) RBF history, holding this node's +/// share of the funding amount and fee for that candidate. Both are `None` for a candidate this +/// node did not contribute to — e.g. a counterparty-initiated round before our `splice_in` joined +/// it via RBF. Recorded per pending payment so that, on confirmation, the payment reports the +/// figures of the candidate that actually confirmed, which need not be the last one broadcast. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FundingTxCandidate { + /// The candidate's broadcast transaction id. + pub txid: Txid, + /// This node's share of the funding amount for this candidate, in millisatoshis, or `None` if + /// this node did not contribute to it. + pub amount_msat: Option, + /// This node's share of the on-chain fee for this candidate, in millisatoshis, or `None` if + /// this node did not contribute to it. + pub fee_paid_msat: Option, +} + +impl_writeable_tlv_based!(FundingTxCandidate, { + (0, txid, required), + (2, amount_msat, option), + (4, fee_paid_msat, option), +}); + /// Represents a pending payment #[derive(Clone, Debug, PartialEq, Eq)] pub struct PendingPaymentDetails { @@ -20,17 +43,29 @@ pub struct PendingPaymentDetails { pub details: PaymentDetails, /// Transaction IDs that have replaced or conflict with this payment. pub conflicting_txids: Vec, + /// For interactive funding (splices), this node's per-candidate funding figures across the + /// RBF history, keyed by each candidate's txid. Empty for non-funding payments and for + /// records written before per-candidate tracking existed. + pub(crate) candidates: Vec, } impl PendingPaymentDetails { - pub(crate) fn new(details: PaymentDetails, conflicting_txids: Vec) -> Self { - Self { details, conflicting_txids } + pub(crate) fn new( + details: PaymentDetails, conflicting_txids: Vec, candidates: Vec, + ) -> Self { + Self { details, conflicting_txids, candidates } + } + + /// Returns this node's recorded funding figures for the candidate with the given txid, if any. + pub(crate) fn candidate(&self, txid: Txid) -> Option<&FundingTxCandidate> { + self.candidates.iter().find(|candidate| candidate.txid == txid) } } impl_writeable_tlv_based!(PendingPaymentDetails, { (0, details, required), (2, conflicting_txids, optional_vec), + (4, candidates, optional_vec), }); #[derive(Clone, Debug, PartialEq, Eq)] @@ -38,6 +73,7 @@ pub(crate) struct PendingPaymentDetailsUpdate { pub id: PaymentId, pub payment_update: Option, pub conflicting_txids: Option>, + pub candidates: Vec, } impl StorableObject for PendingPaymentDetails { @@ -69,6 +105,13 @@ impl StorableObject for PendingPaymentDetails { updated |= self.conflicting_txids.len() != conflicts_len; } + // Each classify passes the complete candidate history, so a non-empty update replaces the + // stored list. An empty update (e.g. a non-funding payment) leaves it untouched. + if !update.candidates.is_empty() && self.candidates != update.candidates { + self.candidates = update.candidates; + updated = true; + } + updated } @@ -90,16 +133,73 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { } else { Some(value.conflicting_txids.clone()) }; - Self { id: value.id(), payment_update: Some(value.details.to_update()), conflicting_txids } + Self { + id: value.id(), + payment_update: Some(value.details.to_update()), + conflicting_txids, + candidates: value.candidates.clone(), + } } } #[cfg(test)] mod tests { + use super::*; + use crate::payment::store::ConfirmationStatus; + use crate::payment::{PaymentDirection, PaymentKind, PaymentStatus}; use bitcoin::hashes::Hash; - use super::*; - use crate::payment::{ConfirmationStatus, PaymentDirection, PaymentKind, PaymentStatus}; + #[test] + fn pending_payment_candidate_lookup() { + let payment_id = PaymentId([1u8; 32]); + let first_txid = Txid::from_byte_array([2u8; 32]); + let rbf_txid = Txid::from_byte_array([3u8; 32]); + + // A leading counterparty-initiated round we didn't contribute to (no figures), then our own + // original and RBF candidates. + let counterparty_txid = Txid::from_byte_array([4u8; 32]); + let candidates = vec![ + FundingTxCandidate { txid: counterparty_txid, amount_msat: None, fee_paid_msat: None }, + FundingTxCandidate { + txid: first_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(1_000), + }, + FundingTxCandidate { + txid: rbf_txid, + amount_msat: Some(1_000_000), + fee_paid_msat: Some(5_000), + }, + ]; + + // The stored details only need to be a valid funding payment; `candidate` resolves figures + // purely from the recorded candidate list. + let details = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid: rbf_txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: None, + }, + Some(1_000_000), + Some(5_000), + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + let pending = + PendingPaymentDetails::new(details, vec![first_txid, counterparty_txid], candidates); + + // Each candidate resolves to its own figures, so a non-last candidate that confirms reports + // its own (lower) fee rather than the last-broadcast candidate's. + assert_eq!(pending.candidate(first_txid).and_then(|c| c.fee_paid_msat), Some(1_000)); + assert_eq!(pending.candidate(rbf_txid).and_then(|c| c.fee_paid_msat), Some(5_000)); + // A candidate we didn't contribute to carries no figures, so the payment reports `None` + // rather than another candidate's stale figures. + let counterparty = pending.candidate(counterparty_txid).expect("candidate is recorded"); + assert_eq!(counterparty.amount_msat, None); + assert_eq!(counterparty.fee_paid_msat, None); + assert_eq!(pending.candidate(Txid::from_byte_array([9u8; 32])), None); + } fn test_txid(byte: u8) -> Txid { Txid::from_byte_array([byte; 32]) @@ -125,10 +225,12 @@ mod tests { let mut pending_payment = PendingPaymentDetails::new( pending_onchain_payment(payment_id, replacement_txid), vec![original_txid], + Vec::new(), ); let update = PendingPaymentDetails::new( pending_onchain_payment(payment_id, original_txid), Vec::new(), + Vec::new(), ) .to_update(); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index be5c7e503c..ad4f8d45ee 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -58,8 +58,8 @@ use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::payment::store::ConfirmationStatus; use crate::payment::{ - PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, PendingPaymentDetails, - TransactionType, + FundingTxCandidate, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, + PendingPaymentDetails, TransactionType, }; use crate::runtime::Runtime; use crate::types::{Broadcaster, PaymentStore, PendingPaymentStore}; @@ -1235,7 +1235,7 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.persist_funding_payment(details).await?; + self.persist_funding_payment(details, Vec::new()).await?; log_debug!( self.logger, "Recorded channel-funding broadcast {} for channel {}", @@ -1298,6 +1298,23 @@ impl Wallet { // Anchor the `PaymentId` to the first negotiated candidate so the record stays stable // across RBF replacements. let payment_id = PaymentId(first.txid.to_byte_array()); + + // Record every candidate's figures (`None` for any round we didn't contribute to, e.g. a + // counterparty-initiated splice our `splice_in` later joined via RBF) so the confirmed + // candidate's amount/fee can be applied on confirmation, even if it isn't the last one + // broadcast or one we contributed to. + let candidate_records: Vec = candidates + .iter() + .map(|candidate| { + let aggregate = aggregate_local_stakes(candidate); + FundingTxCandidate { + txid: candidate.txid, + amount_msat: aggregate.amount_msat, + fee_paid_msat: aggregate.fee_paid_msat, + } + }) + .collect(); + let details = PaymentDetails::new( payment_id, PaymentKind::Onchain { @@ -1310,7 +1327,7 @@ impl Wallet { direction, PaymentStatus::Pending, ); - self.persist_funding_payment(details).await?; + self.persist_funding_payment(details, candidate_records).await?; log_debug!( self.logger, "Recorded interactive-funding broadcast {} ({} candidates, {} channels)", @@ -1323,9 +1340,11 @@ impl Wallet { /// Writes a freshly-classified funding payment to the authoritative payment store and adds a /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. - async fn persist_funding_payment(&self, details: PaymentDetails) -> Result<(), Error> { + async fn persist_funding_payment( + &self, details: PaymentDetails, candidates: Vec, + ) -> Result<(), Error> { self.payment_store.insert_or_update(details.clone()).await?; - let pending = PendingPaymentDetails::new(details, Vec::new()); + let pending = PendingPaymentDetails::new(details, Vec::new(), candidates); self.pending_payment_store.insert_or_update(pending).await?; Ok(()) } @@ -1395,7 +1414,7 @@ impl Wallet { fn create_pending_payment_from_tx( &self, payment: PaymentDetails, conflicting_txids: Vec, ) -> PendingPaymentDetails { - PendingPaymentDetails::new(payment, conflicting_txids) + PendingPaymentDetails::new(payment, conflicting_txids, Vec::new()) } fn find_payment_by_txid(&self, target_txid: Txid) -> Option { @@ -1441,6 +1460,17 @@ impl Wallet { } => tx_type.clone(), _ => return Ok(false), }; + // Report the figures of the candidate that actually confirmed, which need not be the last + // one broadcast (an earlier, lower-fee candidate may win) and may carry no figures at all + // (`None`) for a round we didn't contribute to. (`direction` is invariant across a splice's + // candidates and cannot be changed through the store anyway.) + if let Some(pending) = self.pending_payment_store.get(&payment_id) { + if let Some(candidate) = pending.candidate(event_txid) { + payment.amount_msat = candidate.amount_msat; + payment.fee_paid_msat = candidate.fee_paid_msat; + } + } + payment.kind = PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; self.runtime.block_on(self.payment_store.insert_or_update(payment.clone()))?; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index bd0068458a..e19a1ca1e6 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1319,6 +1319,15 @@ async fn splice_channel() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn rbf_splice_channel() { + run_rbf_splice_channel_test(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn rbf_splice_channel_original_candidate_confirms() { + run_rbf_splice_channel_test(true).await; +} + +async fn run_rbf_splice_channel_test(confirm_original: bool) { // Use a custom bitcoind config with a lower incrementalrelayfee so that the +25 sat/kwu // (0.1 sat/vB) RBF feerate bump satisfies BIP125's absolute fee increase requirement. let bitcoind_exe = std::env::var("BITCOIND_EXE") @@ -1389,6 +1398,20 @@ async fn rbf_splice_channel() { node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + // For `confirm_original`, capture the original candidate's fee and raw transaction now, before + // the RBF replaces it, so it can be force-confirmed (instead of the RBF) further below. + let original_candidate: Option<(Option, String)> = if confirm_original { + let payment_id = PaymentId(original_txo.txid.to_byte_array()); + let fee = node_b.payment(&payment_id).expect("splice payment exists").fee_paid_msat; + let raw_tx: String = bitcoind + .client + .call("getrawtransaction", &[json!(original_txo.txid.to_string())]) + .expect("failed to fetch the original splice transaction"); + Some((fee, raw_tx)) + } else { + None + }; + // splice_in should fail when there's a pending splice (RBF guard) assert_eq!( node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000), @@ -1419,7 +1442,7 @@ async fn rbf_splice_channel() { // payment covering both candidates: id anchored to the first broadcast, `kind.txid` pointing // at the latest (RBF) candidate, and the durable interactive-funding `tx_type` preserved across // the replacement. - { + let rbf_candidate_fee = { let payment_id = PaymentId(original_txo.txid.to_byte_array()); let payment = node_b.payment(&payment_id).expect("splice payment exists"); match payment.kind { @@ -1448,19 +1471,35 @@ async fn rbf_splice_channel() { splice_payments.len(), splice_payments, ); - } - // Mine blocks and confirm the RBF splice - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + // The fee recorded for the latest (RBF) candidate, which is the one that confirms below. + assert!(payment.fee_paid_msat.is_some()); + payment.fee_paid_msat + }; + + // Confirm the splice. Normally the latest (RBF) candidate wins through the mempool; for + // `confirm_original` we instead mine the original candidate directly into a block so an + // earlier, lower-fee candidate is the one that confirms. + let winning_txo = if confirm_original { original_txo } else { rbf_txo }; + if let Some((_, ref original_tx_hex)) = original_candidate { + let address = bitcoind.client.new_address().expect("failed to get new address"); + let _: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(address.to_string()), json!([original_tx_hex])]) + .expect("failed to mine the original splice candidate"); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await; + } else { + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + } node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); - // Verify the RBF transaction is the one that locked, not the original + // Verify the candidate that locked is the one that confirmed, not necessarily the last broadcast. match node_a.next_event_async().await { Event::ChannelReady { funding_txo, counterparty_node_id, .. } => { assert_eq!(counterparty_node_id, Some(node_b.node_id())); - assert_eq!(funding_txo, Some(rbf_txo)); + assert_eq!(funding_txo, Some(winning_txo)); node_a.event_handled().unwrap(); }, ref e => panic!("node_a got unexpected event: {:?}", e), @@ -1468,7 +1507,7 @@ async fn rbf_splice_channel() { match node_b.next_event_async().await { Event::ChannelReady { funding_txo, counterparty_node_id, .. } => { assert_eq!(counterparty_node_id, Some(node_a.node_id())); - assert_eq!(funding_txo, Some(rbf_txo)); + assert_eq!(funding_txo, Some(winning_txo)); node_b.event_handled().unwrap(); }, ref e => panic!("node_b got unexpected event: {:?}", e), @@ -1484,14 +1523,23 @@ async fn rbf_splice_channel() { assert_eq!(payment.status, PaymentStatus::Succeeded); match payment.kind { PaymentKind::Onchain { txid, status: ConfirmationStatus::Confirmed { .. }, .. } => { - assert_eq!(txid, rbf_txo.txid); + assert_eq!(txid, winning_txo.txid); }, ref other => panic!("expected Onchain Confirmed, got {:?}", other), } - assert!( - payment.fee_paid_msat.is_some(), - "splice payment should carry a fee from its FundingContribution", - ); + // Graduation stamps the economics of the candidate that actually confirmed. For + // `confirm_original` that is the earlier, lower-fee candidate, whose fee differs from the + // last-broadcast (RBF) candidate's — so this would fail if the payment kept the + // last-broadcast figures instead of the confirmed candidate's. + let expected_fee = match original_candidate { + Some((original_fee, _)) => { + assert_ne!(original_fee, rbf_candidate_fee); + original_fee + }, + None => rbf_candidate_fee, + }; + assert!(expected_fee.is_some()); + assert_eq!(payment.fee_paid_msat, expected_fee); } node_a.stop().unwrap(); From 5135534ed38c01338785765f39a992fa8b38230d Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 17:39:25 -0500 Subject: [PATCH 058/138] Cover splice-out classification and funding-payment reorg splice_channel only checked the splice-out fee; also assert it is recorded as a confirmed interactive-funding payment. Add a test that a confirmed splice payment returns to unconfirmed when its block is reorged out, exercising the unconfirm path for funding payments. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration_tests_rust.rs | 86 +++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index e19a1ca1e6..f45b31f28d 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1306,6 +1306,18 @@ async fn splice_channel() { let payment = payments.into_iter().find(|p| p.id == PaymentId(txo.txid.to_byte_array())).unwrap(); assert_eq!(payment.fee_paid_msat, Some(expected_splice_out_fee_sat * 1_000)); + // The splice-out graduated to a confirmed interactive-funding payment. Its `direction` is left + // unasserted on purpose: the destination is our own address, so it is a self-transfer (channel + // balance -> on-chain wallet) whose inbound/outbound sense is ambiguous. + assert_eq!(payment.status, PaymentStatus::Succeeded); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { + status: ConfirmationStatus::Confirmed { .. }, + tx_type: Some(TransactionType::InteractiveFunding { .. }), + .. + } + )); assert_eq!( node_a.list_balances().total_onchain_balance_sats, @@ -1601,6 +1613,80 @@ async fn funding_payment_graduates_without_channel_ready() { node_b.stop().unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_payment_reorged_to_unconfirmed() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + // node_b splices in, recording a funding payment it contributed to. + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + let splice_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, splice_txo.txid).await; + + // Confirm the splice with a single block — confirmed, but short of `ANTI_REORG_DELAY`, so the + // payment is `Confirmed`/`Pending` rather than graduated. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + + let payment_id = PaymentId(splice_txo.txid.to_byte_array()); + let payment = node_b.payment(&payment_id).expect("splice payment exists"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Confirmed { .. }, .. } + )); + + // Reorg the splice transaction out by replacing its block with a longer, transaction-free chain. + let original_height = + bitcoind.client.get_blockchain_info().expect("failed to get blockchain info").blocks; + invalidate_blocks(&bitcoind.client, 1); + let replacement_address = bitcoind.client.new_address().expect("failed to get new address"); + for _ in 0..2 { + let _res: serde_json::Value = bitcoind + .client + .call("generateblock", &[json!(replacement_address.to_string()), json!([])]) + .expect("failed to generate empty block"); + } + wait_for_block(&electrsd.client, original_height as usize + 1).await; + node_b.sync_wallets().unwrap(); + + // The funding payment returns to `Unconfirmed` and stays `Pending`, exercising the + // `TxUnconfirmed` arm for a funding payment. + let payment = node_b.payment(&payment_id).expect("splice payment still exists"); + assert_eq!(payment.status, PaymentStatus::Pending); + assert!(matches!( + payment.kind, + PaymentKind::Onchain { status: ConfirmationStatus::Unconfirmed, .. } + )); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 54eb085f61ad7736c1e40e3330f604b9759f93df Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Jun 2026 23:29:03 -0500 Subject: [PATCH 059/138] Honor the funding template's RBF minimum feerate when splicing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contributing to an already-pending splice — e.g. adding our funds to a counterparty-initiated splice via splice_in or splice_out — replaces the in-flight funding transaction, so the funding template requires at least the RBF minimum feerate. We passed our plain ChannelFunding feerate estimate, which can sit below that minimum (it does at the regtest floor), so the contribution was rejected with FeeRateBelowRbfMinimum. Raise the contribution feerate to the template's RBF minimum when one applies, capped by our max, so it can replace the pending splice. A node can therefore now contribute to a counterparty's pending splice; the rbf_splice_channel check that expected splice_out to fail while a splice was pending relied on this very bug and is dropped. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib.rs | 24 +++++++++++-- tests/integration_tests_rust.rs | 61 ++++++++++++++++++++++++++++----- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index a3410db1f6..46db6d80c7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1658,11 +1658,21 @@ impl Node { return Err(Error::ChannelSplicingFailed); } + // When contributing to a pending splice, the funding template requires at least the RBF + // minimum feerate to replace the in-flight transaction. Use it in place of our funding + // feerate estimate when it's higher, as long as it stays within our max. + let feerate = match funding_template.min_rbf_feerate() { + Some(min_rbf_feerate) if min_rbf_feerate <= max_feerate => { + min_feerate.max(min_rbf_feerate) + }, + _ => min_feerate, + }; + let contribution = self .runtime .block_on(funding_template.splice_in( Amount::from_sat(splice_amount_sats), - min_feerate, + feerate, max_feerate, Arc::clone(&self.wallet), )) @@ -1781,12 +1791,22 @@ impl Node { return Err(Error::ChannelSplicingFailed); } + // When contributing to a pending splice, the funding template requires at least the RBF + // minimum feerate to replace the in-flight transaction. Use it in place of our funding + // feerate estimate when it's higher, as long as it stays within our max. + let feerate = match funding_template.min_rbf_feerate() { + Some(min_rbf_feerate) if min_rbf_feerate <= max_feerate => { + min_feerate.max(min_rbf_feerate) + }, + _ => min_feerate, + }; + let outputs = vec![bitcoin::TxOut { value: Amount::from_sat(splice_amount_sats), script_pubkey: address.script_pubkey(), }]; let contribution = - funding_template.splice_out(outputs, min_feerate, max_feerate).map_err(|e| { + funding_template.splice_out(outputs, feerate, max_feerate).map_err(|e| { log_error!(self.logger, "Failed to splice channel: {}", e); Error::ChannelSplicingFailed })?; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index f45b31f28d..8d71faed5f 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1387,7 +1387,7 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { 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_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()); // bump_channel_funding_fee should fail when there's no pending splice @@ -1424,19 +1424,13 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { None }; - // splice_in should fail when there's a pending splice (RBF guard) + // Re-splicing the pending splice we already contributed to is rejected; the RBF guard points at + // bump_channel_funding_fee instead. assert_eq!( node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000), Err(NodeError::ChannelSplicingFailed), ); - // splice_out should fail when there's a pending splice (RBF guard) - let address = node_a.onchain_payment().new_address().unwrap(); - assert_eq!( - node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 100_000), - Err(NodeError::ChannelSplicingFailed), - ); - // bump_channel_funding_fee should succeed when there's a pending splice node_b.bump_channel_funding_fee(&user_channel_id_b, node_a.node_id()).unwrap(); @@ -1687,6 +1681,55 @@ async fn splice_payment_reorged_to_unconfirmed() { node_b.stop().unwrap(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn splice_in_rbf_joins_counterparty_splice() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let address_b = node_b.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a, address_b], + Amount::from_sat(premine_amount_sat), + ) + .await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + // node_b (which didn't fund the channel open, so holds the on-chain balance) initiates a + // splice-in; node_a does not contribute to this first candidate. + node_b.splice_in(&user_channel_id_b, node_a.node_id(), 1_000_000).unwrap(); + let counterparty_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + wait_for_tx(&electrsd.client, counterparty_txo.txid).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + // node_a contributes to the pending splice via RBF. Before honoring the funding template's RBF + // minimum feerate, this was rejected with FeeRateBelowRbfMinimum because node_a's funding + // feerate estimate sat below the minimum required to replace the in-flight transaction. + node_a.splice_in(&user_channel_id_a, node_b.node_id(), 100_000).unwrap(); + let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); + expect_splice_negotiated_event!(node_b, node_a.node_id()); + assert_ne!(counterparty_txo, rbf_txo, "node_a's RBF should produce a different funding txo"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From f9a64a09a24078674d8cefe9c2a01fdcec392e8c Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 24 Jun 2026 15:40:14 -0500 Subject: [PATCH 060/138] Refactor the splice funding-feerate helpers into fee_estimator.rs The 1.5x-of-estimate funding feerate ceiling was open-coded identically in splice_in and splice_out. Route both through a max_funding_feerate helper and keep it, alongside rbf_splice_feerates, in fee_estimator.rs so the splice funding-feerate policy lives in one place. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fee_estimator.rs | 39 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 42 +++++------------------------------------- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/src/fee_estimator.rs b/src/fee_estimator.rs index 34fe7b64ca..b785bfca40 100644 --- a/src/fee_estimator.rs +++ b/src/fee_estimator.rs @@ -164,3 +164,42 @@ pub(crate) fn apply_post_estimation_adjustments( _ => estimated_rate, } } + +/// The most we are willing to pay for a channel funding transaction: `1.5x` our funding feerate +/// estimate. Used as the `max_feerate` ceiling for splices and their RBF fee bumps. +pub(crate) fn max_funding_feerate(estimate: FeeRate) -> FeeRate { + FeeRate::from_sat_per_kwu(estimate.to_sat_per_kwu() * 3 / 2) +} + +/// Picks the `(target, max)` feerates for replacing a pending splice's in-flight funding +/// transaction via RBF, or `None` if the RBF can't be done within our fee ceiling. +/// +/// `max` is the most we are willing to pay (see [`max_funding_feerate`]), which tracks our current +/// estimate and so may have risen or fallen since the original splice; it is never inflated to meet +/// the RBF minimum. `target` is what we actually pay — our current estimate, or the template's RBF +/// minimum if that is higher (required to replace the transaction). If that minimum exceeds `max`, +/// we can't RBF. +pub(crate) fn rbf_splice_feerates( + estimate: FeeRate, min_rbf_feerate: FeeRate, +) -> Option<(FeeRate, FeeRate)> { + let max = max_funding_feerate(estimate); + let target = estimate.max(min_rbf_feerate); + (target <= max).then_some((target, max)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rbf_splice_feerates_target_and_max() { + let kwu = FeeRate::from_sat_per_kwu; + // Estimate below the RBF minimum but within our ceiling: pay the minimum to replace the + // transaction; the max stays 1.5x the estimate (never inflated) and already clears it. + assert_eq!(rbf_splice_feerates(kwu(253), kwu(278)), Some((kwu(278), kwu(253 * 3 / 2)))); + // Estimate risen above the RBF minimum: pay the higher estimate, not the stale minimum. + assert_eq!(rbf_splice_feerates(kwu(500), kwu(278)), Some((kwu(500), kwu(500 * 3 / 2)))); + // RBF minimum above our max (1.5x a fallen estimate): we can't RBF within our ceiling. + assert_eq!(rbf_splice_feerates(kwu(100), kwu(278)), None); + } +} diff --git a/src/lib.rs b/src/lib.rs index 46db6d80c7..c97e16fe67 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -119,8 +119,6 @@ pub use bitcoin; use bitcoin::secp256k1::PublicKey; #[cfg(feature = "uniffi")] pub use bitcoin::FeeRate; -#[cfg(not(feature = "uniffi"))] -use bitcoin::FeeRate; use bitcoin::{Address, Amount, BlockHash, Network}; #[cfg(feature = "uniffi")] pub use builder::ArcedNodeBuilder as Builder; @@ -138,7 +136,9 @@ pub use error::Error as NodeError; use error::Error; pub use event::Event; use event::{EventHandler, EventQueue}; -use fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; +use fee_estimator::{ + max_funding_feerate, rbf_splice_feerates, ConfirmationTarget, FeeEstimator, OnchainFeeEstimator, +}; #[cfg(feature = "uniffi")] use ffi::*; use gossip::GossipSource; @@ -1584,7 +1584,7 @@ impl Node { { let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); - let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2); + let max_feerate = max_funding_feerate(min_feerate); let splice_amount_sats = match splice_amount_sats { FundingAmount::Exact { amount_sats } => amount_sats, @@ -1773,7 +1773,7 @@ impl Node { let min_feerate = self.fee_estimator.estimate_fee_rate(ConfirmationTarget::ChannelFunding); - let max_feerate = FeeRate::from_sat_per_kwu(min_feerate.to_sat_per_kwu() * 3 / 2); + let max_feerate = max_funding_feerate(min_feerate); let funding_template = self .channel_manager @@ -2413,44 +2413,12 @@ pub(crate) fn new_channel_anchor_reserve_sats( }) } -/// The most we are willing to pay for a channel funding transaction: `1.5x` our funding feerate -/// estimate. Used as the `max_feerate` ceiling for splices and their RBF fee bumps. -fn max_funding_feerate(estimate: FeeRate) -> FeeRate { - FeeRate::from_sat_per_kwu(estimate.to_sat_per_kwu() * 3 / 2) -} - -/// Picks the `(target, max)` feerates for replacing a pending splice's in-flight funding -/// transaction via RBF, or `None` if the RBF can't be done within our fee ceiling. -/// -/// `max` is the most we are willing to pay (see [`max_funding_feerate`]), which tracks our current -/// estimate and so may have risen or fallen since the original splice; it is never inflated to meet -/// the RBF minimum. `target` is what we actually pay — our current estimate, or the template's RBF -/// minimum if that is higher (required to replace the transaction). If that minimum exceeds `max`, -/// we can't RBF. -fn rbf_splice_feerates(estimate: FeeRate, min_rbf_feerate: FeeRate) -> Option<(FeeRate, FeeRate)> { - let max = max_funding_feerate(estimate); - let target = estimate.max(min_rbf_feerate); - (target <= max).then_some((target, max)) -} - #[cfg(test)] mod tests { use lightning::util::ser::{Readable, Writeable}; use super::*; - #[test] - fn rbf_splice_feerates_target_and_max() { - let kwu = FeeRate::from_sat_per_kwu; - // Estimate below the RBF minimum but within our ceiling: pay the minimum to replace the - // transaction; the max stays 1.5x the estimate (never inflated) and already clears it. - assert_eq!(rbf_splice_feerates(kwu(253), kwu(278)), Some((kwu(278), kwu(253 * 3 / 2)))); - // Estimate risen above the RBF minimum: pay the higher estimate, not the stale minimum. - assert_eq!(rbf_splice_feerates(kwu(500), kwu(278)), Some((kwu(500), kwu(500 * 3 / 2)))); - // RBF minimum above our max (1.5x a fallen estimate): we can't RBF within our ceiling. - assert_eq!(rbf_splice_feerates(kwu(100), kwu(278)), None); - } - #[test] fn node_metrics_reads_legacy_rgs_snapshot_timestamp() { // Pre-#615, `NodeMetrics` persisted `latest_rgs_snapshot_timestamp` as an optional From 5d21bd7ba65556fe0e3084327407e52cf07f556b Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Fri, 26 Jun 2026 15:29:25 -0500 Subject: [PATCH 061/138] Wait for funding classification before syncing in splice tests In a splice, both channel parties broadcast the funding transaction, and the tests drive a single shared bitcoind, so the counterparty's broadcast can surface it to this node's wallet sync before this node's own funding classification has run. Under parallel test execution that classification can lag far enough behind for the sync to record the transaction as a plain on-chain payment, failing the funding-payment assertions. Wait for the funding broadcast to be classified before each affected splice test syncs its wallets. This is test-only: on a real node the classification runs locally, well ahead of a counterparty's broadcast arriving over the network, so the race does not occur. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration_tests_rust.rs | 47 ++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 8d71faed5f..41028b662e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -36,7 +36,7 @@ use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{Builder, Event, NodeError}; +use ldk_node::{Builder, Event, Node, NodeError}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -45,6 +45,34 @@ use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; use serde_json::json; +/// Waits until `node` has classified the funding broadcast `funding_txid` (a channel open or splice +/// candidate) into a payment record carrying a `tx_type`. Classification runs off the broadcaster's +/// queue, which can lag a `sync_wallets` call under load — and for a splice the counterparty also +/// broadcasts the same tx, so a racing sync can see it before this node classifies. Waiting here +/// keeps the next sync on the funding short-circuit instead of recording a generic on-chain payment +/// that clobbers the classification. +async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { + let poll = async { + loop { + let classified = node.list_payments().into_iter().any(|p| { + matches!( + p.kind, + PaymentKind::Onchain { txid, tx_type: Some(_), .. } if txid == funding_txid + ) + }); + if classified { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + }; + tokio::time::timeout(std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), poll) + .await + .unwrap_or_else(|_| { + panic!("timed out waiting for funding broadcast {} to be classified", funding_txid) + }); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -1236,6 +1264,10 @@ async fn splice_channel() { let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); + // Node B contributed to this splice, so wait for its funding broadcast to be classified before + // syncing — otherwise a sync racing the broadcaster's queue records a generic on-chain payment. + wait_for_classified_funding_payment(&node_b, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); @@ -1292,6 +1324,10 @@ async fn splice_channel() { let txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); + // Node A contributed to this splice, so wait for its funding broadcast to be classified before + // syncing — otherwise a sync racing the broadcaster's queue records a generic on-chain payment. + wait_for_classified_funding_payment(&node_a, txo.txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); @@ -1407,6 +1443,9 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // replaced (a `WalletEvent::TxReplaced`), which must not drop the payment's durable funding // classification — the `tx_type` assertion below catches a regression deterministically. wait_for_tx(&electrsd.client, original_txo.txid).await; + // Node B contributed to this splice; wait for its classification before syncing so the sync + // takes the funding short-circuit rather than racing the broadcaster's queue. + wait_for_classified_funding_payment(&node_b, original_txo.txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1441,6 +1480,9 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { // Wait for the RBF transaction to replace the original in the mempool. wait_for_tx(&electrsd.client, rbf_txo.txid).await; + // Wait for node_b's re-classification of the RBF candidate before syncing, so the recorded + // candidate figures reflect the replacement rather than racing the broadcaster's queue. + wait_for_classified_funding_payment(&node_b, rbf_txo.txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1640,6 +1682,9 @@ async fn splice_payment_reorged_to_unconfirmed() { let splice_txo = expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); wait_for_tx(&electrsd.client, splice_txo.txid).await; + // Ensure node_b classified the splice before syncing so the test exercises a funding payment's + // reorg rather than a generic on-chain payment's. + wait_for_classified_funding_payment(&node_b, splice_txo.txid).await; // Confirm the splice with a single block — confirmed, but short of `ANTI_REORG_DELAY`, so the // payment is `Confirmed`/`Pending` rather than graduated. From 42276a140349a9b4cffbca0b9bd441c82368fbd7 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Thu, 25 Jun 2026 17:30:05 -0500 Subject: [PATCH 062/138] Add end-to-end KV store migration test Creates a node with LN and on-chain state and then randomly migrates through all the KV store options and checks it still has its state. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 1 + tests/common/mod.rs | 23 +++ tests/integration_tests_migration.rs | 263 +++++++++++++++++++++++++++ tests/integration_tests_postgres.rs | 18 +- 4 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 tests/integration_tests_migration.rs diff --git a/Cargo.toml b/Cargo.toml index c9ce29d32f..322e765ed6 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,7 @@ winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["std", "_test_utils"] } +lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["tokio"] } rand = { version = "0.9.2", default-features = false, features = ["std", "thread_rng", "os_rng"] } proptest = "1.0.0" regex = "1.5.6" diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 1f5753e55a..68ace9179d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1922,3 +1922,26 @@ impl TestSyncStoreInner { } } } + +/// The PostgreSQL connection string used by the Postgres-backed tests, overridable via the +/// `TEST_POSTGRES_URL` environment variable. +#[cfg(feature = "postgres")] +pub(crate) fn test_connection_string() -> String { + std::env::var("TEST_POSTGRES_URL") + .unwrap_or_else(|_| "host=localhost user=postgres password=postgres".to_string()) +} + +/// Drops the given table from the `ldk_db` database, ignoring the case where the database doesn't +/// exist yet. Used to ensure a clean slate before and after Postgres-backed tests. +#[cfg(feature = "postgres")] +pub(crate) async fn drop_table(table_name: &str) { + let connection_string = format!("{} dbname=ldk_db", test_connection_string()); + let Ok((client, connection)) = + tokio_postgres::connect(&connection_string, tokio_postgres::NoTls).await + else { + // Database doesn't exist yet — nothing to drop. + return; + }; + tokio::spawn(connection); + let _ = client.execute(&format!("DROP TABLE IF EXISTS {table_name}"), &[]).await; +} diff --git a/tests/integration_tests_migration.rs b/tests/integration_tests_migration.rs new file mode 100644 index 0000000000..ee5ad26c8e --- /dev/null +++ b/tests/integration_tests_migration.rs @@ -0,0 +1,263 @@ +// 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. + +// The migration test exercises the filesystem, SQLite, and Postgres stores. It is gated on the +// `postgres` feature because Postgres is the only one of the three that needs an external service. +#![cfg(feature = "postgres")] + +mod common; + +use std::path::PathBuf; + +use common::{ + drop_table, expect_channel_ready_event, expect_payment_received_event, + expect_payment_successful_event, test_connection_string, +}; +use ldk_node::entropy::NodeEntropy; +use ldk_node::io::postgres_store::PostgresStore; +use ldk_node::io::sqlite_store::{SqliteStore, KV_TABLE_NAME, SQLITE_DB_FILE_NAME}; +use ldk_node::{Builder, Event}; +use lightning::util::persist::migrate_kv_store_data_async; +use lightning_invoice::{Bolt11InvoiceDescription, Description}; +use lightning_persister::fs_store::v2::FilesystemStoreV2; +use rand::seq::SliceRandom; + +async fn drop_tables<'a>(table_names: impl IntoIterator) { + for table_name in table_names { + drop_table(table_name).await; + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum MigrationBackend { + FilesystemStore, + Sqlite, + Postgres, +} + +struct BackendInstance { + backend: MigrationBackend, + path: String, + connection_string: String, + table: String, +} + +impl BackendInstance { + fn new( + backend: MigrationBackend, base_dir: &str, connection_string: &str, table: &str, + ) -> Self { + let path = match backend { + MigrationBackend::FilesystemStore => format!("{base_dir}/fs_store"), + MigrationBackend::Sqlite => format!("{base_dir}/sqlite_store"), + MigrationBackend::Postgres => base_dir.to_string(), + }; + BackendInstance { + backend, + path, + connection_string: connection_string.to_string(), + table: table.to_string(), + } + } +} + +macro_rules! with_opened_store { + ($instance:expr, |$store:ident| $body:expr) => {{ + let instance = $instance; + match instance.backend { + MigrationBackend::FilesystemStore => { + let $store = open_fs_store(&instance.path); + $body + }, + MigrationBackend::Sqlite => { + let $store = open_sqlite_store(&instance.path); + $body + }, + MigrationBackend::Postgres => { + let $store = + open_postgres_store(&instance.connection_string, &instance.table).await; + $body + }, + } + }}; +} + +async fn build_migration_node( + instance: &BackendInstance, node_config: ldk_node::config::Config, node_entropy: NodeEntropy, + esplora_url: &str, +) -> ldk_node::Node { + let mut builder = Builder::from_config(node_config); + builder.set_chain_source_esplora(esplora_url.to_string(), None); + with_opened_store!(instance, |store| builder.build_with_store(node_entropy, store).unwrap()) +} + +fn open_fs_store(data_dir: &str) -> FilesystemStoreV2 { + std::fs::create_dir_all(data_dir).unwrap(); + FilesystemStoreV2::new(PathBuf::from(data_dir)).unwrap() +} + +fn open_sqlite_store(data_dir: &str) -> SqliteStore { + std::fs::create_dir_all(data_dir).unwrap(); + SqliteStore::new( + PathBuf::from(data_dir), + Some(SQLITE_DB_FILE_NAME.to_string()), + Some(KV_TABLE_NAME.to_string()), + ) + .unwrap() +} + +async fn open_postgres_store(connection_string: &str, table: &str) -> PostgresStore { + PostgresStore::new(connection_string.to_string(), None, Some(table.to_string()), None) + .await + .unwrap() +} + +/// Migrates all data from a freshly-opened handle on the `source` backend to a freshly-opened +/// handle on the `dest` backend. The node owning the source store must be stopped beforehand. +async fn migrate_between_backends(source: &BackendInstance, dest: &BackendInstance) { + with_opened_store!(source, |source_store| { + with_opened_store!(dest, |dest_store| { + migrate_kv_store_data_async(&source_store, &dest_store).await.unwrap(); + }) + }) +} + +/// Spins up a node on a KV store backend, creates some on-chain and Lightning transaction history, +/// then migrates its data through every other backend in turn. After each migration it restarts +/// the node on the new backend and verifies that the node identity, on-chain balance, channel, and +/// payment history are all preserved. +/// +/// The order in which the backends are visited is randomized. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn migrate_node_across_all_backends() { + let mut order = + [MigrationBackend::FilesystemStore, MigrationBackend::Sqlite, MigrationBackend::Postgres]; + order.shuffle(&mut rand::rng()); + println!("Migrating node across backends in order: {:?}", order); + + // Tables we might use: one per hop plus node B's. (Only the Postgres hops actually use them.) + let tables: Vec = (0..order.len()).map(|i| format!("migrate_chain_{i}")).collect(); + let node_b_table = "migrate_chain_node_b".to_string(); + drop_tables(tables.iter().chain(std::iter::once(&node_b_table))).await; + + let (bitcoind, electrsd) = common::setup_bitcoind_and_electrsd(); + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + let connection_string = test_connection_string(); + + // Set up node B, the Lightning counterparty. + let config_b = common::random_config(false); + let node_b_instance = BackendInstance::new( + MigrationBackend::Postgres, + &config_b.node_config.storage_dir_path, + &connection_string, + &node_b_table, + ); + let node_b = build_migration_node( + &node_b_instance, + config_b.node_config, + config_b.node_entropy, + &esplora_url, + ) + .await; + node_b.start().unwrap(); + + // Spin up the node we'll migrate on the first backend. The same node config (storage dir, + // listening addresses, identity) is reused across every hop — only the backend changes — so + // each backend's store lives in its own subdirectory of the one storage dir. + let config = common::random_config(false); + let node_entropy = config.node_entropy; + let node_config = config.node_config; + let base_dir = node_config.storage_dir_path.clone(); + + let mut current = BackendInstance::new(order[0], &base_dir, &connection_string, &tables[0]); + let mut node = + build_migration_node(¤t, node_config.clone(), node_entropy, &esplora_url).await; + node.start().unwrap(); + let expected_node_id = node.node_id(); + + // On-chain receive: fund the node. + let addr = node.onchain_payment().new_address().unwrap(); + common::premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr], + bitcoin::Amount::from_sat(1_000_000), + ) + .await; + node.sync_wallets().unwrap(); + + // Open a channel to node B (pushing half so both sides can route) and let it confirm. + common::open_channel_push_amt(&node, &node_b, 200_000, Some(100_000_000), false, &electrsd) + .await; + common::generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + expect_channel_ready_event!(node, node_b.node_id()); + expect_channel_ready_event!(node_b, node.node_id()); + + // Lightning send: node -> node B. + let description = + Bolt11InvoiceDescription::Direct(Description::new("ln send".to_string()).unwrap()); + let invoice = node_b.bolt11_payment().receive(10_000, &description.into(), 3600).unwrap(); + let ln_send_id = node.bolt11_payment().send(&invoice, None).unwrap(); + expect_payment_successful_event!(node, Some(ln_send_id), None); + expect_payment_received_event!(node_b, 10_000); + + // Lightning receive: node B -> node. + let description = + Bolt11InvoiceDescription::Direct(Description::new("ln receive".to_string()).unwrap()); + let invoice = node.bolt11_payment().receive(5_000, &description.into(), 3600).unwrap(); + let ln_receive_id = node_b.bolt11_payment().send(&invoice, None).unwrap(); + expect_payment_successful_event!(node_b, Some(ln_receive_id), None); + expect_payment_received_event!(node, 5_000); + + // On-chain send: node -> a foreign address. + let bitcoind_addr = bitcoind.client.new_address().unwrap(); + let txid = node.onchain_payment().send_to_address(&bitcoind_addr, 50_000, None).unwrap(); + common::wait_for_tx(&electrsd.client, txid).await; + common::generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node.sync_wallets().unwrap(); + + // Capture the state we expect to survive every migration. + let expected_balance_sats = node.list_balances().total_onchain_balance_sats; + let expected_ln_balance_sats = node.list_balances().total_lightning_balance_sats; + let mut expected_payments = node.list_payments(); + expected_payments.sort_by_key(|p| p.id.0); + assert!(expected_payments.len() >= 4); + + for (i, &next_backend) in order.iter().enumerate().skip(1) { + println!("Migrating from {:?} to {:?}", current.backend, next_backend); + + let next = BackendInstance::new(next_backend, &base_dir, &connection_string, &tables[i]); + + // Spin the node down so the source store is no longer being written to. + node.stop().unwrap(); + drop(node); + + migrate_between_backends(¤t, &next).await; + + // Spin the node back up on the new backend. + node = build_migration_node(&next, node_config.clone(), node_entropy, &esplora_url).await; + node.start().unwrap(); + node.sync_wallets().unwrap(); + + // The balance, channel, and transaction history are preserved across the migration. + assert_eq!(node.node_id(), expected_node_id); + assert_eq!(node.list_balances().total_onchain_balance_sats, expected_balance_sats); + assert_eq!(node.list_balances().total_lightning_balance_sats, expected_ln_balance_sats); + assert_eq!(node.list_channels().len(), 1); + let mut migrated_payments = node.list_payments(); + migrated_payments.sort_by_key(|p| p.id.0); + assert_eq!(migrated_payments, expected_payments); + + current = next; + } + + node.stop().unwrap(); + node_b.stop().unwrap(); + + drop_tables(tables.iter().chain(std::iter::once(&node_b_table))).await; +} diff --git a/tests/integration_tests_postgres.rs b/tests/integration_tests_postgres.rs index b96b0c277c..0c93c705c2 100644 --- a/tests/integration_tests_postgres.rs +++ b/tests/integration_tests_postgres.rs @@ -9,27 +9,11 @@ mod common; +use common::{drop_table, test_connection_string}; use ldk_node::entropy::NodeEntropy; use ldk_node::Builder; use rand::RngCore; -fn test_connection_string() -> String { - std::env::var("TEST_POSTGRES_URL") - .unwrap_or_else(|_| "host=localhost user=postgres password=postgres".to_string()) -} - -async fn drop_table(table_name: &str) { - let connection_string = format!("{} dbname=ldk_db", test_connection_string()); - let Ok((client, connection)) = - tokio_postgres::connect(&connection_string, tokio_postgres::NoTls).await - else { - // Database doesn't exist yet — nothing to drop. - return; - }; - tokio::spawn(connection); - let _ = client.execute(&format!("DROP TABLE IF EXISTS {table_name}"), &[]).await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn channel_full_cycle_with_postgres_store() { drop_table("channel_cycle_a").await; From d0ed6a3d640a6647bcbe82b8c28f33e7c36ccc5d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Apr 2026 14:37:34 +0200 Subject: [PATCH 063/138] Abort on first-startup chain tip fetch failure When a fresh node's bitcoind RPC/REST chain source fails to return the current chain tip, we previously silently fell back to the genesis block as the wallet birthday. The next successful startup would then force a full-history rescan of the whole chain. Instead, return a new BuildError::ChainTipFetchFailed on the first build so the misconfiguration surfaces immediately and no stale fresh state is persisted. Restarts with a previously-persisted wallet are unaffected: a transient chain source outage on an existing node still allows startup to proceed. Esplora/Electrum backends currently never expose a tip at build time so the guard only fires for bitcoind sources; the latent wallet-birthday-at-genesis issue on those backends is left for a follow-up. Co-Authored-By: HAL 9000 --- CHANGELOG.md | 6 ++++++ src/builder.rs | 30 ++++++++++++++++++++++++++++++ tests/integration_tests_rust.rs | 32 +++++++++++++++++++++++++++++++- 3 files changed, 67 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9f15e61f5..e482de6a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ - Users of the VSS storage backend must upgrade their VSS server to at least version `v0.1.0-alpha.0` before upgrading LDK Node. +## Bug Fixes and Improvements +- Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the + current chain tip now aborts with a new `BuildError::ChainTipFetchFailed` variant instead of + silently pinning the wallet birthday to genesis, which would have forced a full-history rescan + once the chain source became reachable again. (#884) + # 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/src/builder.rs b/src/builder.rs index 7a26ce24f6..085ff7d207 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -196,6 +196,13 @@ pub enum BuildError { AsyncPaymentsConfigMismatch, /// An attempt to setup a DNS Resolver failed. DNSResolverSetupFailed, + /// We failed to determine the current chain tip on first startup. + /// + /// Returned when a fresh node is built against a Bitcoin Core RPC or REST chain source that + /// is unreachable or misconfigured, so we cannot learn the tip height/hash to use as the + /// wallet birthday. Falling back to genesis would silently force a full-history rescan on + /// the next successful startup, so we abort instead. + ChainTipFetchFailed, } impl fmt::Display for BuildError { @@ -233,6 +240,12 @@ impl fmt::Display for BuildError { Self::DNSResolverSetupFailed => { write!(f, "An attempt to setup a DNS resolver has failed.") }, + Self::ChainTipFetchFailed => { + write!( + f, + "Failed to determine the current chain tip on first startup. Verify the chain data source is reachable and correctly configured." + ) + }, } } } @@ -1557,6 +1570,23 @@ fn build_with_store_internal( let bdk_wallet = match wallet_opt { Some(wallet) => wallet, None => { + // Guard against silently setting the wallet birthday to genesis on a fresh node: + // if we are creating a new wallet but failed to learn the current chain tip from + // a Bitcoin Core RPC/REST backend, we'd otherwise persist fresh wallet state + // pinned at height 0 and force a full-history rescan once the backend comes back. + // Abort cleanly instead so the misconfiguration surfaces on the first startup. + // Esplora/Electrum backends currently never return a tip at build time, so they + // retain their existing behavior. + let is_bitcoind_source = + matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. })); + if !recovery_mode && chain_tip_opt.is_none() && is_bitcoind_source { + log_error!( + logger, + "Failed to determine chain tip on first startup. Aborting to avoid pinning the wallet birthday to genesis." + ); + return Err(BuildError::ChainTipFetchFailed); + } + let mut wallet = runtime .block_on(async { BdkWallet::create(descriptor, change_descriptor) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 41028b662e..fece76884e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -36,7 +36,7 @@ use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{Builder, Event, Node, NodeError}; +use ldk_node::{BuildError, Builder, Event, Node, NodeError}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -936,6 +936,36 @@ async fn onchain_wallet_recovery() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn build_aborts_on_first_startup_bitcoind_tip_fetch_failure() { + // A fresh node pointed at an unreachable bitcoind RPC endpoint must not silently + // fall back to genesis as the wallet birthday. The build must abort cleanly so the + // misconfiguration surfaces immediately. + let config = random_config(false); + let entropy = config.node_entropy; + + setup_builder!(builder, config.node_config); + // Pick a localhost port that is extremely unlikely to be bound. The kernel will + // refuse the connection immediately so the test does not have to wait for the + // chain-polling timeout. + let unreachable_port: u16 = 1; + builder.set_chain_source_bitcoind_rpc( + "127.0.0.1".to_string(), + unreachable_port, + "user".to_string(), + "password".to_string(), + ); + + let res = builder.build(entropy.into()); + match res { + Err(BuildError::ChainTipFetchFailed) => {}, + other => panic!( + "expected BuildError::ChainTipFetchFailed on fresh node with unreachable bitcoind, got {:?}", + other.map(|_| "Ok(_)") + ), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_rbf_via_mempool() { run_rbf_test(false).await; From 72d2414fbe198010a2465051e4cce3396dfb87a5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 26 Jun 2026 13:09:55 +0200 Subject: [PATCH 064/138] Add bitcoind wallet rescan height Allow Bitcoin Core RPC and REST chain-source configuration to specify the wallet birthday height used when creating a fresh wallet. This lets restored wallets rescan from a known height, including genesis, without overloading a global recovery toggle. Reject requested heights above the current chain tip with an explicit build error before wallet state is created. Existing wallets are not rewound by this option because a safe rewind must invalidate persisted wallet and LDK state before replaying blocks. Co-Authored-By: HAL 9000 --- CHANGELOG.md | 8 ++ bindings/ldk_node.udl | 5 +- src/builder.rs | 156 ++++++++++++++++++++++---------- tests/common/mod.rs | 23 +++-- tests/integration_tests_rust.rs | 133 ++++++++++++++++++++++++++- 5 files changed, 262 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e482de6a4b..88aed9a762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ - Users of the VSS storage backend must upgrade their VSS server to at least version `v0.1.0-alpha.0` before upgrading LDK Node. +## Feature and API updates +- The Bitcoin Core RPC and REST chain-source builder methods now accept an optional + `wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known + birthday block instead of checkpointing at the current tip, which is useful when restoring a + wallet on a pruned node where the full history is unavailable but the wallet birthday height is + known. Existing wallets are not rewound, and future heights fail the build. Passing `Some(0)` + rescans from genesis; passing `None` keeps the default current-tip checkpoint behavior. (#884) + ## Bug Fixes and Improvements - Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the current chain tip now aborts with a new `BuildError::ChainTipFetchFailed` variant instead of diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 5621f17514..7c0edc5359 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -38,8 +38,8 @@ interface Builder { constructor(Config config); void set_chain_source_esplora(string server_url, EsploraSyncConfig? config); void set_chain_source_electrum(string server_url, ElectrumSyncConfig? config); - void set_chain_source_bitcoind_rpc(string rpc_host, u16 rpc_port, string rpc_user, string rpc_password); - void set_chain_source_bitcoind_rest(string rest_host, u16 rest_port, string rpc_host, u16 rpc_port, string rpc_user, string rpc_password); + void set_chain_source_bitcoind_rpc(string rpc_host, u16 rpc_port, string rpc_user, string rpc_password, u32? wallet_rescan_from_height); + void set_chain_source_bitcoind_rest(string rest_host, u16 rest_port, string rpc_host, u16 rpc_port, string rpc_user, string rpc_password, u32? wallet_rescan_from_height); void set_gossip_source_p2p(); void set_gossip_source_rgs(string rgs_server_url); void set_pathfinding_scores_source(string url); @@ -59,7 +59,6 @@ interface Builder { void set_node_alias(string node_alias); [Throws=BuildError] void set_async_payments_role(AsyncPaymentsRole? role); - void set_wallet_recovery_mode(); [Throws=BuildError] Node build(NodeEntropy node_entropy); [Throws=BuildError] diff --git a/src/builder.rs b/src/builder.rs index 085ff7d207..8b575cc3f2 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -105,6 +105,7 @@ enum ChainDataSourceConfig { rpc_user: String, rpc_password: String, rest_client_config: Option, + wallet_rescan_from_height: Option, }, } @@ -203,6 +204,8 @@ pub enum BuildError { /// wallet birthday. Falling back to genesis would silently force a full-history rescan on /// the next successful startup, so we abort instead. ChainTipFetchFailed, + /// The configured wallet rescan height is above the current chain tip. + WalletRescanHeightTooHigh, } impl fmt::Display for BuildError { @@ -246,6 +249,9 @@ impl fmt::Display for BuildError { "Failed to determine the current chain tip on first startup. Verify the chain data source is reachable and correctly configured." ) }, + Self::WalletRescanHeightTooHigh => { + write!(f, "Wallet rescan height is above the current chain tip.") + }, } } } @@ -300,7 +306,6 @@ pub struct NodeBuilder { async_payments_role: Option, runtime_handle: Option, pathfinding_scores_sync_config: Option, - recovery_mode: bool, } impl NodeBuilder { @@ -318,7 +323,6 @@ impl NodeBuilder { let log_writer_config = None; let runtime_handle = None; let pathfinding_scores_sync_config = None; - let recovery_mode = false; Self { config, chain_data_source_config, @@ -328,7 +332,6 @@ impl NodeBuilder { runtime_handle, async_payments_role: None, pathfinding_scores_sync_config, - recovery_mode, } } @@ -393,8 +396,13 @@ impl NodeBuilder { /// ## Parameters: /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection. + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rpc( &mut self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String, + wallet_rescan_from_height: Option, ) -> &mut Self { self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind { rpc_host, @@ -402,6 +410,7 @@ impl NodeBuilder { rpc_user, rpc_password, rest_client_config: None, + wallet_rescan_from_height, }); self } @@ -415,9 +424,13 @@ impl NodeBuilder { /// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection. /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rest( &mut self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16, - rpc_user: String, rpc_password: String, + rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option, ) -> &mut Self { self.chain_data_source_config = Some(ChainDataSourceConfig::Bitcoind { rpc_host, @@ -425,6 +438,7 @@ impl NodeBuilder { rpc_user, rpc_password, rest_client_config: Some(BitcoindRestClientConfig { rest_host, rest_port }), + wallet_rescan_from_height, }); self @@ -615,16 +629,6 @@ impl NodeBuilder { Ok(self) } - /// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any - /// historical wallet funds. - /// - /// This should only be set on first startup when importing an older wallet from a previously - /// used [`NodeEntropy`]. - pub fn set_wallet_recovery_mode(&mut self) -> &mut Self { - self.recovery_mode = true; - self - } - /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: NodeEntropy) -> Result { @@ -865,7 +869,6 @@ impl NodeBuilder { self.liquidity_source_config.as_ref(), self.pathfinding_scores_sync_config.as_ref(), self.async_payments_role, - self.recovery_mode, seed_bytes, runtime, logger, @@ -979,14 +982,20 @@ impl ArcedNodeBuilder { /// ## Parameters: /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection. + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rpc( &self, rpc_host: String, rpc_port: u16, rpc_user: String, rpc_password: String, + wallet_rescan_from_height: Option, ) { self.inner.write().expect("lock").set_chain_source_bitcoind_rpc( rpc_host, rpc_port, rpc_user, rpc_password, + wallet_rescan_from_height, ); } @@ -999,9 +1008,13 @@ impl ArcedNodeBuilder { /// * `rest_host`, `rest_port` - Required parameters for the Bitcoin Core REST connection. /// * `rpc_host`, `rpc_port`, `rpc_user`, `rpc_password` - Required parameters for the Bitcoin Core RPC /// connection + /// * `wallet_rescan_from_height` - Optional wallet birthday height to rescan from on first + /// startup, before wallet state exists. Existing wallets are not rewound. The height must + /// be at or below the current tip. Passing `Some(0)` rescans from genesis; passing `None` + /// checkpoints at the current tip. pub fn set_chain_source_bitcoind_rest( &self, rest_host: String, rest_port: u16, rpc_host: String, rpc_port: u16, - rpc_user: String, rpc_password: String, + rpc_user: String, rpc_password: String, wallet_rescan_from_height: Option, ) { self.inner.write().expect("lock").set_chain_source_bitcoind_rest( rest_host, @@ -1010,6 +1023,7 @@ impl ArcedNodeBuilder { rpc_port, rpc_user, rpc_password, + wallet_rescan_from_height, ); } @@ -1152,15 +1166,6 @@ impl ArcedNodeBuilder { self.inner.write().expect("lock").set_async_payments_role(role).map(|_| ()) } - /// Configures the [`Node`] to resync chain data from genesis on first startup, recovering any - /// historical wallet funds. - /// - /// This should only be set on first startup when importing an older wallet from a previously - /// used [`NodeEntropy`]. - pub fn set_wallet_recovery_mode(&self) { - self.inner.write().expect("lock").set_wallet_recovery_mode(); - } - /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: Arc) -> Result, BuildError> { @@ -1356,8 +1361,8 @@ fn build_with_store_internal( gossip_source_config: Option<&GossipSourceConfig>, liquidity_source_config: Option<&LiquiditySourceConfig>, pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>, - async_payments_role: Option, recovery_mode: bool, seed_bytes: [u8; 64], - runtime: Arc, logger: Arc, kv_store: Arc, + async_payments_role: Option, seed_bytes: [u8; 64], runtime: Arc, + logger: Arc, kv_store: Arc, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -1473,6 +1478,7 @@ fn build_with_store_internal( rpc_user, rpc_password, rest_client_config, + .. }) => match rest_client_config { Some(rest_client_config) => runtime.block_on(async { ChainSource::new_bitcoind_rest( @@ -1526,6 +1532,12 @@ fn build_with_store_internal( }, }; let chain_source = Arc::new(chain_source); + let wallet_rescan_from_height = match chain_data_source_config { + Some(ChainDataSourceConfig::Bitcoind { wallet_rescan_from_height, .. }) => { + *wallet_rescan_from_height + }, + _ => None, + }; // Initialize the on-chain wallet and chain access let xprv = bitcoin::bip32::Xpriv::new_master(config.network, &seed_bytes).map_err(|e| { @@ -1568,7 +1580,14 @@ fn build_with_store_internal( }, })?; let bdk_wallet = match wallet_opt { - Some(wallet) => wallet, + Some(wallet) => { + // `wallet_rescan_from_height`, when set, is fresh-wallet-only. Rewinding a + // persisted wallet is not just replacing BDK's best block: its local-chain and + // tx-graph changesets are already persisted, and LDK state may also have synced + // to a later tip. A safe rewind needs an explicit recovery flow that invalidates + // all dependent state before replaying blocks. + wallet + }, None => { // Guard against silently setting the wallet birthday to genesis on a fresh node: // if we are creating a new wallet but failed to learn the current chain tip from @@ -1577,9 +1596,10 @@ fn build_with_store_internal( // Abort cleanly instead so the misconfiguration surfaces on the first startup. // Esplora/Electrum backends currently never return a tip at build time, so they // retain their existing behavior. - let is_bitcoind_source = - matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. })); - if !recovery_mode && chain_tip_opt.is_none() && is_bitcoind_source { + if wallet_rescan_from_height.is_none() + && chain_tip_opt.is_none() + && matches!(chain_data_source_config, Some(ChainDataSourceConfig::Bitcoind { .. })) + { log_error!( logger, "Failed to determine chain tip on first startup. Aborting to avoid pinning the wallet birthday to genesis." @@ -1599,23 +1619,67 @@ fn build_with_store_internal( BuildError::WalletSetupFailed })?; - if !recovery_mode { - if let Some(best_block) = chain_tip_opt { - // Insert the first checkpoint if we have it, to avoid resyncing from genesis. - // TODO: Use a proper wallet birthday once BDK supports it. - let mut latest_checkpoint = wallet.latest_checkpoint(); - let block_id = bdk_chain::BlockId { - height: best_block.height, - hash: best_block.block_hash, - }; - latest_checkpoint = latest_checkpoint.insert(block_id); - let update = - bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() }; - wallet.apply_update(update).map_err(|e| { - log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e); + // Decide which block (if any) to insert as the initial BDK checkpoint. If the + // bitcoind config provides a wallet rescan height, resolve that block and use it as + // the checkpoint. Otherwise, use the current chain tip to avoid any rescan. + let checkpoint_block = match wallet_rescan_from_height { + None => chain_tip_opt, + Some(height) => { + if let Some(chain_tip) = chain_tip_opt { + if height > chain_tip.height { + log_error!( + logger, + "Wallet rescan height {} is above current chain tip {}.", + height, + chain_tip.height + ); + return Err(BuildError::WalletRescanHeightTooHigh); + } + } + + let utxo_source = chain_source.as_utxo_source().ok_or_else(|| { + log_error!( + logger, + "Wallet rescan height requested but the chain source does not support block-by-height lookups.", + ); BuildError::WalletSetupFailed })?; - } + let hash_res = runtime.block_on(async { + lightning_block_sync::gossip::UtxoSource::get_block_hash_by_height( + &utxo_source, + height, + ) + .await + }); + match hash_res { + Ok(hash) => Some(BlockLocator::new(hash, height)), + Err(e) => { + log_error!( + logger, + "Failed to resolve block hash at height {} for wallet rescan: {:?}", + height, + e, + ); + return Err(BuildError::WalletSetupFailed); + }, + } + }, + }; + + if let Some(best_block) = checkpoint_block { + // Insert the checkpoint so BDK starts scanning from there instead of from + // genesis. + // TODO: Use a proper wallet birthday once BDK supports it. + let mut latest_checkpoint = wallet.latest_checkpoint(); + let block_id = + bdk_chain::BlockId { height: best_block.height, hash: best_block.block_hash }; + latest_checkpoint = latest_checkpoint.insert(block_id); + let update = + bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() }; + wallet.apply_update(update).map_err(|e| { + log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e); + BuildError::WalletSetupFailed + })?; } wallet }, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 68ace9179d..22809a26d5 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -435,7 +435,7 @@ pub(crate) struct TestConfig { pub store_type: TestStoreType, pub node_entropy: NodeEntropy, pub async_payments_role: Option, - pub recovery_mode: bool, + pub wallet_rescan_from_height: Option, } impl Default for TestConfig { @@ -447,14 +447,14 @@ impl Default for TestConfig { let mnemonic = generate_entropy_mnemonic(None); let node_entropy = NodeEntropy::from_bip39_mnemonic(mnemonic, None); let async_payments_role = None; - let recovery_mode = false; + let wallet_rescan_from_height = None; TestConfig { node_config, log_writer, store_type, node_entropy, async_payments_role, - recovery_mode, + wallet_rescan_from_height, } } } @@ -551,7 +551,13 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> let values = bitcoind.params.get_cookie_values().unwrap().unwrap(); let rpc_user = values.user; let rpc_password = values.password; - builder.set_chain_source_bitcoind_rpc(rpc_host, rpc_port, rpc_user, rpc_password); + builder.set_chain_source_bitcoind_rpc( + rpc_host, + rpc_port, + rpc_user, + rpc_password, + config.wallet_rescan_from_height, + ); }, TestChainSource::BitcoindRestSync(bitcoind) => { let rpc_host = bitcoind.params.rpc_socket.ip().to_string(); @@ -568,6 +574,7 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> rpc_port, rpc_user, rpc_password, + config.wallet_rescan_from_height, ); }, } @@ -586,10 +593,6 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> builder.set_async_payments_role(config.async_payments_role).unwrap(); - if config.recovery_mode { - builder.set_wallet_recovery_mode(); - } - let node = match config.store_type { TestStoreType::TestSyncStore => { let kv_store = TestSyncStore::new(config.node_config.storage_dir_path.into()); @@ -601,10 +604,6 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> }, }; - if config.recovery_mode { - builder.set_wallet_recovery_mode(); - } - node.start().unwrap(); assert!(node.status().is_running); assert!(node.status().latest_fee_rate_cache_update_timestamp.is_some()); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index fece76884e..2e7ad0ef2d 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -76,7 +76,7 @@ async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = random_chain_source(&bitcoind, &electrsd); + let chain_source = TestChainSource::BitcoindRpcSync(&bitcoind); let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); do_channel_full_cycle( node_a, @@ -903,7 +903,7 @@ async fn onchain_wallet_recovery() { // Now we start from scratch, only the seed remains the same. let mut recovered_config = random_config(true); recovered_config.node_entropy = original_node_entropy; - recovered_config.recovery_mode = true; + recovered_config.wallet_rescan_from_height = Some(0); let recovered_node = setup_node(&chain_source, recovered_config); recovered_node.sync_wallets().unwrap(); @@ -936,6 +936,134 @@ async fn onchain_wallet_recovery() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_wallet_recovery_rescans_from_birthday_height() { + // End-to-end test for `wallet_rescan_from_height` against a bitcoind chain source. The + // scenario: + // + // 1. Create a node at some "birthday" height and generate two receive addresses. + // 2. Shut the node down and drop all persisted state except the seed. + // 3. Advance the chain past the birthday. + // 4. Send funds to the addresses generated at the birthday height and confirm them. + // 5. Restart a fresh node with just the seed and no rescan height. Its wallet birthday + // is pinned at the current tip, which is above the blocks containing the funding + // transactions — so the node must not see the funds. + // 6. Restart again with `wallet_rescan_from_height: Some(birthday)`. Now the wallet must + // find and report both funding transactions. + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + // We specifically exercise the bitcoind RPC backend because that's where + // `rescan_from_height` is honored precisely (via `get_block_hash_by_height`). + let chain_source = TestChainSource::BitcoindRpcSync(&bitcoind); + + // Mine the initial 101 blocks so bitcoind's wallet can fund our later sends. + premine_blocks(&bitcoind.client, &electrsd.client).await; + + // Step 1: bring up an "original" node at the birthday height and generate addresses. + let original_config = random_config(true); + let original_node_entropy = original_config.node_entropy; + let original_node = setup_node(&chain_source, original_config); + + let premine_amount_sat = 100_000; + + let addr_1 = original_node.onchain_payment().new_address().unwrap(); + let addr_2 = original_node.onchain_payment().new_address().unwrap(); + + let birthday_height: u32 = bitcoind + .client + .get_blockchain_info() + .expect("failed to get blockchain info") + .blocks + .try_into() + .unwrap(); + + // Step 2: shut the node down and drop its state. + original_node.stop().unwrap(); + drop(original_node); + + // Step 3: advance the chain past the birthday, so a fresh node would otherwise pin its + // wallet birthday at a height above the funding transactions in step 4. + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 10).await; + + // Step 4: fund both addresses and confirm them. + let txid_1 = bitcoind + .client + .send_to_address(&addr_1, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid_1).await; + let txid_2 = bitcoind + .client + .send_to_address(&addr_2, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid_2).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + + // Step 5: restart a fresh node with only the seed and no rescan height. It must NOT see + // the funds, because its wallet birthday sits above the funding transactions. + let mut pinned_config = random_config(true); + pinned_config.node_entropy = original_node_entropy; + let pinned_node = setup_node(&chain_source, pinned_config); + pinned_node.sync_wallets().unwrap(); + assert_eq!( + pinned_node.list_balances().spendable_onchain_balance_sats, + 0, + "fresh node without rescan height should not find funds below its wallet birthday" + ); + pinned_node.stop().unwrap(); + drop(pinned_node); + + // Step 6: restart with a rescan height set to the birthday height. Funds must be + // re-discovered. + let mut recovered_config = random_config(true); + recovered_config.node_entropy = original_node_entropy; + recovered_config.wallet_rescan_from_height = Some(birthday_height); + let recovered_node = setup_node(&chain_source, recovered_config); + recovered_node.sync_wallets().unwrap(); + assert_eq!( + recovered_node.list_balances().spendable_onchain_balance_sats, + premine_amount_sat * 2, + "node recovered with rescan_from_height should see funds sent to pre-birthday addresses" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn build_fails_when_wallet_rescan_height_is_above_tip() { + let (bitcoind, _electrsd) = setup_bitcoind_and_electrsd(); + let current_tip_height: u32 = bitcoind + .client + .get_blockchain_info() + .expect("failed to get blockchain info") + .blocks + .try_into() + .unwrap(); + + let config = random_config(false); + let entropy = config.node_entropy; + + setup_builder!(builder, config.node_config); + let values = bitcoind.params.get_cookie_values().unwrap().unwrap(); + builder.set_chain_source_bitcoind_rpc( + bitcoind.params.rpc_socket.ip().to_string(), + bitcoind.params.rpc_socket.port(), + values.user, + values.password, + Some(current_tip_height + 1), + ); + + match builder.build(entropy.into()) { + Err(err) => { + assert_eq!(err, BuildError::WalletRescanHeightTooHigh); + assert_eq!(err.to_string(), "Wallet rescan height is above the current chain tip."); + }, + Ok(_) => panic!("expected build to fail for future wallet rescan height"), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn build_aborts_on_first_startup_bitcoind_tip_fetch_failure() { // A fresh node pointed at an unreachable bitcoind RPC endpoint must not silently @@ -954,6 +1082,7 @@ async fn build_aborts_on_first_startup_bitcoind_tip_fetch_failure() { unreachable_port, "user".to_string(), "password".to_string(), + None, ); let res = builder.build(entropy.into()); From 101c827c17d2bf68def45bc0aae19ccd4539d7bd Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 26 Jun 2026 13:37:31 +0200 Subject: [PATCH 065/138] Add forced wallet full scans Let Esplora and Electrum sync configs request BDK full scans until one succeeds. This keeps recovery scans retryable after transient sync failures while preserving normal incremental syncs once recovery has completed. Co-Authored-By: HAL 9000 --- CHANGELOG.md | 3 ++ src/chain/electrum.rs | 13 +++++-- src/chain/esplora.rs | 16 +++++++-- src/config.rs | 12 +++++++ tests/common/mod.rs | 5 +++ tests/integration_tests_rust.rs | 63 +++++++++++++++++++++++++++++++++ 6 files changed, 107 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88aed9a762..e7e012a146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ wallet on a pruned node where the full history is unavailable but the wallet birthday height is known. Existing wallets are not rewound, and future heights fail the build. Passing `Some(0)` rescans from genesis; passing `None` keeps the default current-tip checkpoint behavior. (#884) +- `EsploraSyncConfig` and `ElectrumSyncConfig` now support `force_wallet_full_scan`. When set, + the on-chain wallet keeps using BDK `full_scan` instead of incremental sync until a full scan + succeeds, allowing restored wallets to rediscover funds sent to previously-unknown addresses. ## Bug Fixes and Improvements - Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 7406f06b4b..23c930d983 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -6,6 +6,7 @@ // accordance with one or both of these licenses. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -50,6 +51,7 @@ pub(super) struct ElectrumChainSource { config: Arc, logger: Arc, node_metrics: Arc, + force_wallet_full_scan: AtomicBool, } impl ElectrumChainSource { @@ -61,6 +63,7 @@ impl ElectrumChainSource { let electrum_runtime_status = RwLock::new(ElectrumRuntimeStatus::new()); let onchain_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); let lightning_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); + let force_wallet_full_scan = AtomicBool::new(sync_config.force_wallet_full_scan); Self { server_url, sync_config, @@ -72,6 +75,7 @@ impl ElectrumChainSource { config, logger: Arc::clone(&logger), node_metrics, + force_wallet_full_scan, } } @@ -125,9 +129,11 @@ impl ElectrumChainSource { return Err(Error::FeerateEstimationUpdateFailed); }; // If this is our first sync, do a full scan with the configured gap limit. - // Otherwise just do an incremental sync. - let incremental_sync = + // Otherwise just do an incremental sync, unless a forced full scan is still pending. + let has_prior_sync = self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some(); + let forced_full_scan = self.force_wallet_full_scan.load(Ordering::Acquire); + let incremental_sync = has_prior_sync && !forced_full_scan; let cached_txs = onchain_wallet.get_cached_txs(); @@ -160,6 +166,9 @@ impl ElectrumChainSource { .await }; + if forced_full_scan && res.is_ok() { + self.force_wallet_full_scan.store(false, Ordering::Release); + } res } diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index eb23a395d3..0754986e8b 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -6,6 +6,7 @@ // accordance with one or both of these licenses. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -38,6 +39,7 @@ pub(super) struct EsploraChainSource { config: Arc, logger: Arc, node_metrics: Arc, + force_wallet_full_scan: AtomicBool, } impl EsploraChainSource { @@ -62,6 +64,7 @@ impl EsploraChainSource { let onchain_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); let lightning_wallet_sync_status = Mutex::new(WalletSyncStatus::Completed); + let force_wallet_full_scan = AtomicBool::new(sync_config.force_wallet_full_scan); Ok(Self { sync_config, esplora_client, @@ -73,6 +76,7 @@ impl EsploraChainSource { config, logger, node_metrics, + force_wallet_full_scan, }) } @@ -101,9 +105,11 @@ impl EsploraChainSource { async fn sync_onchain_wallet_inner(&self, onchain_wallet: Arc) -> Result<(), Error> { // If this is our first sync, do a full scan with the configured gap limit. - // Otherwise just do an incremental sync. - let incremental_sync = + // Otherwise just do an incremental sync, unless a forced full scan is still pending. + let has_prior_sync = self.node_metrics.read().expect("lock").latest_onchain_wallet_sync_timestamp.is_some(); + let forced_full_scan = self.force_wallet_full_scan.load(Ordering::Acquire); + let incremental_sync = has_prior_sync && !forced_full_scan; macro_rules! get_and_apply_wallet_update { ($sync_future: expr) => {{ @@ -177,7 +183,7 @@ impl EsploraChainSource { }} } - if incremental_sync { + let res = if incremental_sync { let sync_request = onchain_wallet.get_incremental_sync_request(); let wallet_sync_timeout_fut = tokio::time::timeout( Duration::from_secs( @@ -199,7 +205,11 @@ impl EsploraChainSource { ), ); get_and_apply_wallet_update!(wallet_sync_timeout_fut) + }; + if forced_full_scan && res.is_ok() { + self.force_wallet_full_scan.store(false, Ordering::Release); } + res } pub(super) async fn sync_lightning_wallet( diff --git a/src/config.rs b/src/config.rs index 558a4d0618..ad1b911819 100644 --- a/src/config.rs +++ b/src/config.rs @@ -506,6 +506,11 @@ pub struct EsploraSyncConfig { pub background_sync_config: Option, /// Sync timeouts configuration. pub timeouts_config: SyncTimeoutsConfig, + /// Whether to force BDK full scans until one succeeds. + /// + /// This can be useful when restoring a wallet from seed on a node that has already synced + /// before, but may be missing funds sent to previously-unknown addresses. + pub force_wallet_full_scan: bool, } impl Default for EsploraSyncConfig { @@ -513,6 +518,7 @@ impl Default for EsploraSyncConfig { Self { background_sync_config: Some(BackgroundSyncConfig::default()), timeouts_config: SyncTimeoutsConfig::default(), + force_wallet_full_scan: false, } } } @@ -533,6 +539,11 @@ pub struct ElectrumSyncConfig { pub background_sync_config: Option, /// Sync timeouts configuration. pub timeouts_config: SyncTimeoutsConfig, + /// Whether to force BDK full scans until one succeeds. + /// + /// This can be useful when restoring a wallet from seed on a node that has already synced + /// before, but may be missing funds sent to previously-unknown addresses. + pub force_wallet_full_scan: bool, } impl Default for ElectrumSyncConfig { @@ -540,6 +551,7 @@ impl Default for ElectrumSyncConfig { Self { background_sync_config: Some(BackgroundSyncConfig::default()), timeouts_config: SyncTimeoutsConfig::default(), + force_wallet_full_scan: false, } } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 22809a26d5..a56d46e056 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -436,6 +436,7 @@ pub(crate) struct TestConfig { pub node_entropy: NodeEntropy, pub async_payments_role: Option, pub wallet_rescan_from_height: Option, + pub force_wallet_full_scan: bool, } impl Default for TestConfig { @@ -448,6 +449,7 @@ impl Default for TestConfig { let node_entropy = NodeEntropy::from_bip39_mnemonic(mnemonic, None); let async_payments_role = None; let wallet_rescan_from_height = None; + let force_wallet_full_scan = false; TestConfig { node_config, log_writer, @@ -455,6 +457,7 @@ impl Default for TestConfig { node_entropy, async_payments_role, wallet_rescan_from_height, + force_wallet_full_scan, } } } @@ -537,12 +540,14 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); let mut sync_config = EsploraSyncConfig::default(); sync_config.background_sync_config = None; + sync_config.force_wallet_full_scan = config.force_wallet_full_scan; builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); }, TestChainSource::Electrum(electrsd) => { let electrum_url = format!("tcp://{}", electrsd.electrum_url); let mut sync_config = ElectrumSyncConfig::default(); sync_config.background_sync_config = None; + sync_config.force_wallet_full_scan = config.force_wallet_full_scan; builder.set_chain_source_electrum(electrum_url.clone(), Some(sync_config)); }, TestChainSource::BitcoindRpcSync(bitcoind) => { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 2e7ad0ef2d..c3c2f4262b 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -936,6 +936,69 @@ async fn onchain_wallet_recovery() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_wallet_force_full_scan_rediscovers_esplora_funds() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = TestChainSource::Esplora(&electrsd); + + premine_blocks(&bitcoind.client, &electrsd.client).await; + + let address_source_config = random_config(true); + let node_entropy = address_source_config.node_entropy; + let address_source_node = setup_node(&chain_source, address_source_config); + let addr_1 = address_source_node.onchain_payment().new_address().unwrap(); + let addr_2 = address_source_node.onchain_payment().new_address().unwrap(); + address_source_node.stop().unwrap(); + drop(address_source_node); + + let premine_amount_sat = 100_000; + let mut stale_config = random_config(true); + stale_config.node_entropy = node_entropy; + stale_config.store_type = TestStoreType::Sqlite; + let stale_node = setup_node(&chain_source, stale_config.clone()); + stale_node.sync_wallets().unwrap(); + assert_eq!(stale_node.list_balances().spendable_onchain_balance_sats, 0); + stale_node.stop().unwrap(); + drop(stale_node); + + let txid_1 = bitcoind + .client + .send_to_address(&addr_1, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid_1).await; + let txid_2 = bitcoind + .client + .send_to_address(&addr_2, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid_2).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + + let normal_node = setup_node(&chain_source, stale_config.clone()); + normal_node.sync_wallets().unwrap(); + assert_eq!( + normal_node.list_balances().spendable_onchain_balance_sats, + 0, + "normal incremental sync should not rediscover previously-unknown addresses" + ); + normal_node.stop().unwrap(); + drop(normal_node); + + stale_config.force_wallet_full_scan = true; + let recovered_node = setup_node(&chain_source, stale_config); + recovered_node.sync_wallets().unwrap(); + assert_eq!( + recovered_node.list_balances().spendable_onchain_balance_sats, + premine_amount_sat * 2, + "forced full scan should rediscover funds sent to previously-unknown addresses" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn onchain_wallet_recovery_rescans_from_birthday_height() { // End-to-end test for `wallet_rescan_from_height` against a bitcoind chain source. The From aec47c59a0bb09aa727aae9ddfc939c40029f037 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 1 Jul 2026 13:01:10 +0200 Subject: [PATCH 066/138] Configure Esplora full-scan stop gap Expose the BDK full-scan stop gap on Esplora sync config so wallet recovery can scan past the previous fixed gap when needed. Clamp out-of-range values at full-scan time and warn about the effective value, keeping the default behavior unchanged. Co-Authored-By: HAL 9000 --- src/chain/esplora.rs | 26 +++++++++++-- src/config.rs | 65 ++++++++++++++++++++++++++++++-- src/logger.rs | 2 +- tests/common/mod.rs | 6 +++ tests/integration_tests_rust.rs | 66 ++++++++++++++++++++++++++++++++- 5 files changed, 156 insertions(+), 9 deletions(-) diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 0754986e8b..b46fe183ce 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -18,13 +18,16 @@ use lightning::util::ser::Writeable; use lightning_transaction_sync::EsploraSyncClient; use super::WalletSyncStatus; -use crate::config::{Config, EsploraSyncConfig, BDK_CLIENT_CONCURRENCY, BDK_CLIENT_STOP_GAP}; +use crate::config::{ + clamp_full_scan_stop_gap, Config, EsploraSyncConfig, BDK_CLIENT_CONCURRENCY, + MAX_FULL_SCAN_STOP_GAP, MIN_FULL_SCAN_STOP_GAP, +}; use crate::fee_estimator::{ apply_post_estimation_adjustments, get_all_conf_targets, get_num_block_defaults_for_target, OnchainFeeEstimator, }; use crate::io::utils::update_and_persist_node_metrics; -use crate::logger::{log_bytes, log_debug, log_error, log_trace, LdkLogger, Logger}; +use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -194,13 +197,14 @@ impl EsploraChainSource { get_and_apply_wallet_update!(wallet_sync_timeout_fut) } else { let full_scan_request = onchain_wallet.get_full_scan_request(); + let full_scan_stop_gap = self.bounded_full_scan_stop_gap(); let wallet_sync_timeout_fut = tokio::time::timeout( Duration::from_secs( self.sync_config.timeouts_config.onchain_wallet_sync_timeout_secs, ), self.esplora_client.full_scan( full_scan_request, - BDK_CLIENT_STOP_GAP, + full_scan_stop_gap, BDK_CLIENT_CONCURRENCY, ), ); @@ -212,6 +216,22 @@ impl EsploraChainSource { res } + fn bounded_full_scan_stop_gap(&self) -> usize { + let configured = self.sync_config.full_scan_stop_gap; + let bounded = clamp_full_scan_stop_gap(configured); + if bounded != configured { + log_warn!( + self.logger, + "Configured Esplora on-chain wallet full-scan stop gap {} is outside the allowed range {}..={}; using {}.", + configured, + MIN_FULL_SCAN_STOP_GAP, + MAX_FULL_SCAN_STOP_GAP, + bounded + ); + } + bounded as usize + } + pub(super) async fn sync_lightning_wallet( &self, channel_manager: Arc, chain_monitor: Arc, output_sweeper: Arc, diff --git a/src/config.rs b/src/config.rs index ad1b911819..f4adf4db18 100644 --- a/src/config.rs +++ b/src/config.rs @@ -57,9 +57,23 @@ pub const DEFAULT_STORAGE_DIR_PATH: &str = "/tmp/ldk_node"; // The default Esplora server we're using. 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 -// number of derivation indexes after which BDK stops looking for new scripts belonging to the wallet. -pub(crate) const BDK_CLIENT_STOP_GAP: usize = 20; +/// The default stop gap used for BDK full scans of the on-chain wallet. +/// +/// The current default is 20. +pub const DEFAULT_FULL_SCAN_STOP_GAP: u32 = 20; + +/// The minimum allowed stop gap used for BDK full scans of the on-chain wallet. +/// +/// Values below 1 are clamped to 1 when a full scan runs. +pub const MIN_FULL_SCAN_STOP_GAP: u32 = 1; + +/// The maximum allowed stop gap used for BDK full scans of the on-chain wallet. +/// +/// Values above 1000 are clamped to 1000 when a full scan runs. +pub const MAX_FULL_SCAN_STOP_GAP: u32 = 1000; + +// The fixed stop gap used by backends that don't yet expose a configurable value. +pub(crate) const BDK_CLIENT_STOP_GAP: usize = DEFAULT_FULL_SCAN_STOP_GAP as usize; // The number of concurrent requests made against the API provider. pub(crate) const BDK_CLIENT_CONCURRENCY: usize = 4; @@ -506,6 +520,23 @@ pub struct EsploraSyncConfig { pub background_sync_config: Option, /// Sync timeouts configuration. pub timeouts_config: SyncTimeoutsConfig, + /// The stop gap used for BDK full scans of the on-chain wallet. + /// + /// A full scan for each keychain stops after this many consecutive script pubkeys + /// with no associated transactions. This value is only used for BDK `full_scan` + /// calls, which ldk-node performs on the first on-chain wallet sync or when + /// [`Self::force_wallet_full_scan`] is set. Incremental BDK `sync` calls do not use it. + /// + /// **Default:** 20 ([`DEFAULT_FULL_SCAN_STOP_GAP`]) + /// + /// **Allowed values:** 1 ([`MIN_FULL_SCAN_STOP_GAP`]) to 1000 + /// ([`MAX_FULL_SCAN_STOP_GAP`]), inclusive. Values outside this range will be clamped to the + /// nearest bound and a warning will be logged when the full scan runs. + /// + /// **Note:** Large values can cause many Esplora requests, hit server rate limits, + /// take a long time to complete, or cause syncs to fail with + /// [`SyncTimeoutsConfig::onchain_wallet_sync_timeout_secs`]. + pub full_scan_stop_gap: u32, /// Whether to force BDK full scans until one succeeds. /// /// This can be useful when restoring a wallet from seed on a node that has already synced @@ -518,6 +549,7 @@ impl Default for EsploraSyncConfig { Self { background_sync_config: Some(BackgroundSyncConfig::default()), timeouts_config: SyncTimeoutsConfig::default(), + full_scan_stop_gap: DEFAULT_FULL_SCAN_STOP_GAP, force_wallet_full_scan: false, } } @@ -556,6 +588,10 @@ impl Default for ElectrumSyncConfig { } } +pub(crate) fn clamp_full_scan_stop_gap(full_scan_stop_gap: u32) -> u32 { + full_scan_stop_gap.clamp(MIN_FULL_SCAN_STOP_GAP, MAX_FULL_SCAN_STOP_GAP) +} + /// Configuration for syncing with Bitcoin Core backend via REST. #[derive(Debug, Clone)] pub struct BitcoindRestClientConfig { @@ -711,7 +747,11 @@ pub enum AsyncPaymentsRole { mod tests { use std::str::FromStr; - use super::{may_announce_channel, AnnounceError, Config, NodeAlias, SocketAddress}; + use super::{ + clamp_full_scan_stop_gap, may_announce_channel, AnnounceError, Config, EsploraSyncConfig, + NodeAlias, SocketAddress, DEFAULT_FULL_SCAN_STOP_GAP, MAX_FULL_SCAN_STOP_GAP, + MIN_FULL_SCAN_STOP_GAP, + }; #[test] fn node_announce_channel() { @@ -758,4 +798,21 @@ mod tests { } assert!(may_announce_channel(&node_config).is_ok()); } + + #[test] + fn full_scan_stop_gap_defaults() { + assert_eq!(EsploraSyncConfig::default().full_scan_stop_gap, DEFAULT_FULL_SCAN_STOP_GAP); + } + + #[test] + fn full_scan_stop_gap_is_clamped_to_valid_range() { + assert_eq!(clamp_full_scan_stop_gap(MIN_FULL_SCAN_STOP_GAP), MIN_FULL_SCAN_STOP_GAP); + assert_eq!( + clamp_full_scan_stop_gap(DEFAULT_FULL_SCAN_STOP_GAP), + DEFAULT_FULL_SCAN_STOP_GAP + ); + assert_eq!(clamp_full_scan_stop_gap(MAX_FULL_SCAN_STOP_GAP), MAX_FULL_SCAN_STOP_GAP); + assert_eq!(clamp_full_scan_stop_gap(0), MIN_FULL_SCAN_STOP_GAP); + assert_eq!(clamp_full_scan_stop_gap(MAX_FULL_SCAN_STOP_GAP + 1), MAX_FULL_SCAN_STOP_GAP); + } } diff --git a/src/logger.rs b/src/logger.rs index 3ef939b6d4..c5a4584a18 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -19,7 +19,7 @@ use lightning::ln::types::ChannelId; use lightning::types::payment::PaymentHash; pub use lightning::util::logger::Level as LogLevel; pub(crate) use lightning::util::logger::{Logger as LdkLogger, Record as LdkRecord}; -pub(crate) use lightning::{log_bytes, log_debug, log_error, log_info, log_trace}; +pub(crate) use lightning::{log_bytes, log_debug, log_error, log_info, log_trace, log_warn}; use log::{Level as LogFacadeLevel, Record as LogFacadeRecord}; /// A unit of logging output with metadata to enable filtering `module_path`, diff --git a/tests/common/mod.rs b/tests/common/mod.rs index a56d46e056..d4a4207e96 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -437,6 +437,7 @@ pub(crate) struct TestConfig { pub async_payments_role: Option, pub wallet_rescan_from_height: Option, pub force_wallet_full_scan: bool, + pub full_scan_stop_gap: Option, } impl Default for TestConfig { @@ -450,6 +451,7 @@ impl Default for TestConfig { let async_payments_role = None; let wallet_rescan_from_height = None; let force_wallet_full_scan = false; + let full_scan_stop_gap = None; TestConfig { node_config, log_writer, @@ -458,6 +460,7 @@ impl Default for TestConfig { async_payments_role, wallet_rescan_from_height, force_wallet_full_scan, + full_scan_stop_gap, } } } @@ -541,6 +544,9 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> let mut sync_config = EsploraSyncConfig::default(); sync_config.background_sync_config = None; sync_config.force_wallet_full_scan = config.force_wallet_full_scan; + if let Some(full_scan_stop_gap) = config.full_scan_stop_gap { + sync_config.full_scan_stop_gap = full_scan_stop_gap; + } builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); }, TestChainSource::Electrum(electrsd) => { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index c3c2f4262b..e1a9eaeeab 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -29,7 +29,7 @@ use common::{ }; use electrsd::corepc_node::{self, Node as BitcoinD}; use electrsd::ElectrsD; -use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig}; +use ldk_node::config::{AsyncPaymentsRole, EsploraSyncConfig, DEFAULT_FULL_SCAN_STOP_GAP}; use ldk_node::entropy::NodeEntropy; use ldk_node::liquidity::LSPS2ServiceConfig; use ldk_node::payment::{ @@ -999,6 +999,70 @@ async fn onchain_wallet_force_full_scan_rediscovers_esplora_funds() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn onchain_wallet_full_scan_stop_gap_recovers_far_esplora_funds() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + premine_blocks(&bitcoind.client, &electrsd.client).await; + + do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( + TestChainSource::Esplora(&electrsd), + &bitcoind, + &electrsd, + ) + .await; +} + +async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( + chain_source: TestChainSource<'_>, bitcoind: &BitcoinD, electrsd: &ElectrsD, +) { + let configured_stop_gap = DEFAULT_FULL_SCAN_STOP_GAP + 5; + + let address_source_config = random_config(true); + let node_entropy = address_source_config.node_entropy; + let address_source_node = setup_node(&chain_source, address_source_config); + let mut far_address = None; + for _ in 0..configured_stop_gap { + far_address = Some(address_source_node.onchain_payment().new_address().unwrap()); + } + address_source_node.stop().unwrap(); + drop(address_source_node); + let far_address = far_address.unwrap(); + + let premine_amount_sat = 100_000; + let txid = bitcoind + .client + .send_to_address(&far_address, Amount::from_sat(premine_amount_sat)) + .unwrap() + .0 + .parse() + .unwrap(); + wait_for_tx(&electrsd.client, txid).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + + let mut default_gap_config = random_config(true); + default_gap_config.node_entropy = node_entropy.clone(); + let default_gap_node = setup_node(&chain_source, default_gap_config); + default_gap_node.sync_wallets().unwrap(); + assert_eq!( + default_gap_node.list_balances().spendable_onchain_balance_sats, + 0, + "default full-scan stop gap should not recover funds past its address gap" + ); + default_gap_node.stop().unwrap(); + drop(default_gap_node); + + let mut configured_gap_config = random_config(true); + configured_gap_config.node_entropy = node_entropy; + configured_gap_config.full_scan_stop_gap = Some(configured_stop_gap); + let configured_gap_node = setup_node(&chain_source, configured_gap_config); + configured_gap_node.sync_wallets().unwrap(); + assert_eq!( + configured_gap_node.list_balances().spendable_onchain_balance_sats, + premine_amount_sat, + "configured full-scan stop gap should recover funds past the default address gap" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn onchain_wallet_recovery_rescans_from_birthday_height() { // End-to-end test for `wallet_rescan_from_height` against a bitcoind chain source. The From da465a4740ea640c7fca9bd9088089cce502ae50 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 1 Jul 2026 13:05:31 +0200 Subject: [PATCH 067/138] Configure Electrum full-scan stop gap Expose the bounded BDK full-scan stop gap for Electrum so restore behavior can be tuned consistently across remote backends. Document that the setting applies only to BDK full_scan calls and keeps incremental sync unaffected. Co-Authored-By: HAL 9000 --- src/chain/electrum.rs | 26 +++++++++++++++++++++++--- src/config.rs | 28 ++++++++++++++++++++++------ tests/common/mod.rs | 3 +++ tests/integration_tests_rust.rs | 8 +++++++- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 23c930d983..e255158ca9 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -25,14 +25,17 @@ use lightning::util::ser::Writeable; use lightning_transaction_sync::ElectrumSyncClient; use super::WalletSyncStatus; -use crate::config::{Config, ElectrumSyncConfig, BDK_CLIENT_STOP_GAP}; +use crate::config::{ + clamp_full_scan_stop_gap, Config, ElectrumSyncConfig, MAX_FULL_SCAN_STOP_GAP, + MIN_FULL_SCAN_STOP_GAP, +}; use crate::error::Error; use crate::fee_estimator::{ apply_post_estimation_adjustments, get_all_conf_targets, get_num_block_defaults_for_target, ConfirmationTarget, OnchainFeeEstimator, }; use crate::io::utils::update_and_persist_node_metrics; -use crate::logger::{log_bytes, log_debug, log_error, log_trace, LdkLogger, Logger}; +use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; use crate::runtime::Runtime; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::PersistedNodeMetrics; @@ -496,11 +499,12 @@ impl ElectrumRuntimeClient { ) -> Result, Error> { let bdk_electrum_client = Arc::clone(&self.bdk_electrum_client); bdk_electrum_client.populate_tx_cache(cached_txs); + let full_scan_stop_gap = self.bounded_full_scan_stop_gap(); let spawn_fut = self.runtime.spawn_blocking(move || { bdk_electrum_client.full_scan( request, - BDK_CLIENT_STOP_GAP, + full_scan_stop_gap, BDK_ELECTRUM_CLIENT_BATCH_SIZE, true, ) @@ -526,6 +530,22 @@ impl ElectrumRuntimeClient { }) } + fn bounded_full_scan_stop_gap(&self) -> usize { + let configured = self.sync_config.full_scan_stop_gap; + let bounded = clamp_full_scan_stop_gap(configured); + if bounded != configured { + log_warn!( + self.logger, + "Configured Electrum on-chain wallet full-scan stop gap {} is outside the allowed range {}..={}; using {}.", + configured, + MIN_FULL_SCAN_STOP_GAP, + MAX_FULL_SCAN_STOP_GAP, + bounded + ); + } + bounded as usize + } + async fn get_incremental_sync_wallet_update( &self, request: BdkSyncRequest<(BdkKeyChainKind, u32)>, cached_txs: impl IntoIterator>>, diff --git a/src/config.rs b/src/config.rs index f4adf4db18..f83cf3d437 100644 --- a/src/config.rs +++ b/src/config.rs @@ -72,9 +72,6 @@ pub const MIN_FULL_SCAN_STOP_GAP: u32 = 1; /// Values above 1000 are clamped to 1000 when a full scan runs. pub const MAX_FULL_SCAN_STOP_GAP: u32 = 1000; -// The fixed stop gap used by backends that don't yet expose a configurable value. -pub(crate) const BDK_CLIENT_STOP_GAP: usize = DEFAULT_FULL_SCAN_STOP_GAP as usize; - // The number of concurrent requests made against the API provider. pub(crate) const BDK_CLIENT_CONCURRENCY: usize = 4; @@ -571,6 +568,23 @@ pub struct ElectrumSyncConfig { pub background_sync_config: Option, /// Sync timeouts configuration. pub timeouts_config: SyncTimeoutsConfig, + /// The stop gap used for BDK full scans of the on-chain wallet. + /// + /// A full scan for each keychain stops after this many consecutive script pubkeys + /// with no associated transactions. This value is only used for BDK `full_scan` + /// calls, which ldk-node performs on the first on-chain wallet sync or when + /// [`Self::force_wallet_full_scan`] is set. Incremental BDK `sync` calls do not use it. + /// + /// **Default:** 20 ([`DEFAULT_FULL_SCAN_STOP_GAP`]) + /// + /// **Allowed values:** 1 ([`MIN_FULL_SCAN_STOP_GAP`]) to 1000 + /// ([`MAX_FULL_SCAN_STOP_GAP`]), inclusive. Values outside this range will be clamped to the + /// nearest bound and a warning will be logged when the full scan runs. + /// + /// **Note:** Large values can cause many Electrum requests, hit server rate limits, + /// take a long time to complete, or cause syncs to fail with + /// [`SyncTimeoutsConfig::onchain_wallet_sync_timeout_secs`]. + pub full_scan_stop_gap: u32, /// Whether to force BDK full scans until one succeeds. /// /// This can be useful when restoring a wallet from seed on a node that has already synced @@ -583,6 +597,7 @@ impl Default for ElectrumSyncConfig { Self { background_sync_config: Some(BackgroundSyncConfig::default()), timeouts_config: SyncTimeoutsConfig::default(), + full_scan_stop_gap: DEFAULT_FULL_SCAN_STOP_GAP, force_wallet_full_scan: false, } } @@ -748,9 +763,9 @@ mod tests { use std::str::FromStr; use super::{ - clamp_full_scan_stop_gap, may_announce_channel, AnnounceError, Config, EsploraSyncConfig, - NodeAlias, SocketAddress, DEFAULT_FULL_SCAN_STOP_GAP, MAX_FULL_SCAN_STOP_GAP, - MIN_FULL_SCAN_STOP_GAP, + clamp_full_scan_stop_gap, may_announce_channel, AnnounceError, Config, ElectrumSyncConfig, + EsploraSyncConfig, NodeAlias, SocketAddress, DEFAULT_FULL_SCAN_STOP_GAP, + MAX_FULL_SCAN_STOP_GAP, MIN_FULL_SCAN_STOP_GAP, }; #[test] @@ -802,6 +817,7 @@ mod tests { #[test] fn full_scan_stop_gap_defaults() { assert_eq!(EsploraSyncConfig::default().full_scan_stop_gap, DEFAULT_FULL_SCAN_STOP_GAP); + assert_eq!(ElectrumSyncConfig::default().full_scan_stop_gap, DEFAULT_FULL_SCAN_STOP_GAP); } #[test] diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d4a4207e96..f0148da8a4 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -554,6 +554,9 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> let mut sync_config = ElectrumSyncConfig::default(); sync_config.background_sync_config = None; sync_config.force_wallet_full_scan = config.force_wallet_full_scan; + if let Some(full_scan_stop_gap) = config.full_scan_stop_gap { + sync_config.full_scan_stop_gap = full_scan_stop_gap; + } builder.set_chain_source_electrum(electrum_url.clone(), Some(sync_config)); }, TestChainSource::BitcoindRpcSync(bitcoind) => { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index e1a9eaeeab..eedd62ebe0 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1000,7 +1000,7 @@ async fn onchain_wallet_force_full_scan_rediscovers_esplora_funds() { } #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn onchain_wallet_full_scan_stop_gap_recovers_far_esplora_funds() { +async fn onchain_wallet_full_scan_stop_gap_recovers_far_esplora_and_electrum_funds() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); premine_blocks(&bitcoind.client, &electrsd.client).await; @@ -1010,6 +1010,12 @@ async fn onchain_wallet_full_scan_stop_gap_recovers_far_esplora_funds() { &electrsd, ) .await; + do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( + TestChainSource::Electrum(&electrsd), + &bitcoind, + &electrsd, + ) + .await; } async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( From 35adcf5044ddf80419eff11cfe844e304d296415 Mon Sep 17 00:00:00 2001 From: Fmt Bot Date: Sun, 5 Jul 2026 02:33:41 +0000 Subject: [PATCH 068/138] 2026-07-05 automated rustfmt nightly --- src/data_store.rs | 3 +- src/liquidity/client/mod.rs | 22 +- src/liquidity/service/lsps2.rs | 1074 +++++++++++++------------- src/liquidity/service/mod.rs | 16 +- src/payment/mod.rs | 3 +- src/payment/pending_payment_store.rs | 3 +- src/payment/store.rs | 3 +- tests/integration_tests_migration.rs | 2 +- 8 files changed, 563 insertions(+), 563 deletions(-) diff --git a/src/data_store.rs b/src/data_store.rs index 13afeca7e3..b1ed816df9 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -223,10 +223,9 @@ where #[cfg(test)] mod tests { - use lightning::impl_writeable_tlv_based; - use lightning::io; use lightning::util::persist::{PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning::util::test_utils::TestLogger; + use lightning::{impl_writeable_tlv_based, io}; use super::*; use crate::hex_utils; 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; diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 2d3acf90e1..fd75322ceb 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -20,8 +20,7 @@ pub use bolt11::Bolt11Payment; pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; pub use onchain::OnchainPayment; -pub(crate) use pending_payment_store::FundingTxCandidate; -pub(crate) use pending_payment_store::PendingPaymentDetails; +pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails}; pub use spontaneous::SpontaneousPayment; pub use store::{ Channel, ConfirmationStatus, LSPS2Parameters, PaymentDetails, PaymentDirection, PaymentKind, diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index c8b792ccb1..f5f2fa40a2 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -144,10 +144,11 @@ impl From<&PendingPaymentDetails> for PendingPaymentDetailsUpdate { #[cfg(test)] mod tests { + use bitcoin::hashes::Hash; + use super::*; use crate::payment::store::ConfirmationStatus; use crate::payment::{PaymentDirection, PaymentKind, PaymentStatus}; - use bitcoin::hashes::Hash; #[test] fn pending_payment_candidate_lookup() { diff --git a/src/payment/store.rs b/src/payment/store.rs index 1608908958..9bf402e59c 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -883,9 +883,10 @@ mod tests { #[test] fn onchain_tx_type_deser_compat() { - use bitcoin::hashes::Hash; use std::str::FromStr; + use bitcoin::hashes::Hash; + let txid = Txid::from_byte_array([7u8; 32]); let status = ConfirmationStatus::Unconfirmed; diff --git a/tests/integration_tests_migration.rs b/tests/integration_tests_migration.rs index ee5ad26c8e..c4e63451a8 100644 --- a/tests/integration_tests_migration.rs +++ b/tests/integration_tests_migration.rs @@ -65,7 +65,7 @@ impl BackendInstance { } macro_rules! with_opened_store { - ($instance:expr, |$store:ident| $body:expr) => {{ + ($instance:expr, | $store:ident | $body:expr) => {{ let instance = $instance; match instance.backend { MigrationBackend::FilesystemStore => { From 5d4afa336410a560b2f8382da0e5785ae0c759e7 Mon Sep 17 00:00:00 2001 From: jolah1 Date: Tue, 7 Jul 2026 13:03:37 +0100 Subject: [PATCH 069/138] node: consolidate peer-store cleanup into ChannelClosed handler Peer-store cleanup on channel closure was split across two places: close_channel_internal removed the peer on a cooperative close, while the ChannelClosed handler removed it for an allowlist of counterparty/on-chain reasons. That allowlist missed terminal cases such as a channel closing before funding (CounterpartyCoopClosedUnfundedChannel), leaving those peers in the store and the reconnection loop retrying them indefinitely. Make the ChannelClosed handler the single owner of the decision: retain the peer only for HolderForceClosed -- where we deliberately keep reconnecting so channel_reestablish can drive recovery, important against LND peers that don't always handle force-closure error messages -- and drop it for every other terminal reason once no other channel with the peer remains. close_channel_internal no longer touches the peer store. Add an integration test for the counterparty force-close path and keep the retain/remove assertions in do_channel_full_cycle. Co-Authored-By: Claude Opus 4.8 --- src/event.rs | 37 +++++++++++++++++++++++- src/lib.rs | 11 +++++--- tests/common/mod.rs | 14 +++++++++ tests/integration_tests_rust.rs | 50 +++++++++++++++++++++++++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/event.rs b/src/event.rs index 80acd0690e..7f3c898f8d 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1613,10 +1613,45 @@ where } => { log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason); + // `counterparty_node_id` has been set on every `ChannelClosed` since LDK 0.0.117. + let counterparty_node_id = counterparty_node_id + .expect("counterparty_node_id is always set since LDK 0.0.117"); + + // Drop the peer once its last channel with us has reached a terminal state + // that reconnection cannot recover. Every closure reason is terminal except + // `HolderForceClosed`: when *we* force-close, we keep reconnecting so that + // `channel_reestablish` can drive recovery (see `Node::close_channel_internal`). + // This also cleans up peers persisted for a channel that closed before funding + // (e.g. `CounterpartyCoopClosedUnfundedChannel`), which would otherwise be + // retried forever. + // We exclude `channel_id` from the count because LDK emits `ChannelClosed` + // before removing it from its internal list. + let dont_reconnect = !matches!(reason, ClosureReason::HolderForceClosed { .. }); + + if dont_reconnect { + let has_other_channels = self + .channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .iter() + .any(|c| c.channel_id != channel_id); + + if !has_other_channels { + if let Err(e) = self.peer_store.remove_peer(&counterparty_node_id).await { + log_error!( + self.logger, + "Failed to remove peer {} from peer store: {}", + counterparty_node_id, + e + ); + return Err(ReplayEvent()); + } + } + } + let event = Event::ChannelClosed { channel_id, user_channel_id: UserChannelId(user_channel_id), - counterparty_node_id, + counterparty_node_id: Some(counterparty_node_id), reason: Some(reason), }; diff --git a/src/lib.rs b/src/lib.rs index 34fa7f54d6..5831093563 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1919,10 +1919,13 @@ impl Node { })?; } - // Check if this was the last open channel, if so, forget the peer. - if open_channels.len() == 1 { - self.runtime.block_on(self.peer_store.remove_peer(&counterparty_node_id))?; - } + // Peer store cleanup is handled centrally in the `ChannelClosed` event handler, + // which drops the peer once its last channel reaches a terminal state that + // reconnection cannot recover. We intentionally do nothing here so that a + // force-closed peer is retained, letting the background reconnection task keep + // firing and drive the `channel_reestablish` recovery flow. This is especially + // important against LND peers, which don't always handle force-closure error + // messages correctly. } Ok(()) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index adeb327bf0..bde783456d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1597,6 +1597,20 @@ pub(crate) async fn do_channel_full_cycle( assert!(node_b.list_balances().pending_balances_from_channel_closures.is_empty()); } + if force_close { + // Peer retained after local force-close to allow channel_reestablish recovery. + assert!( + node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), + "node_b should remain persisted in node_a peer store after locally-initiated force-close" + ); + } else { + // Peer removed after cooperative close — no further reason to reconnect. + assert!( + !node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), + "node_b should be removed from node_a peer store after cooperative close" + ); + } + let sum_of_all_payments_sat = (push_msat + invoice_amount_1_msat + overpaid_amount_msat diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index fab73ed0c5..d53d8119d8 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -97,6 +97,56 @@ async fn channel_full_cycle_force_close_trusted_no_reserve() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn peer_removed_when_counterparty_force_closes_last_channel() { + // When we open a channel outbound, we persist the counterparty so the background + // reconnection task can reach them. If the counterparty then force-closes what turns out + // to be their last channel with us, the channel is terminal and there is nothing left for + // `channel_reestablish` to recover, so the peer should be dropped from the store rather + // than reconnected to forever. + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + + let address_a = node_a.onchain_payment().new_address().unwrap(); + let premine_amount_sat = 5_000_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![address_a], + Amount::from_sat(premine_amount_sat), + ) + .await; + node_a.sync_wallets().unwrap(); + + // node_a opens the channel, so node_a persists node_b in its peer store. + open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + assert!( + node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), + "node_a should persist node_b after opening a channel to it" + ); + + // The counterparty force-closes their last channel with us. + node_b.force_close_channel(&user_channel_id_b, node_a.node_id(), None).unwrap(); + + expect_event!(node_a, ChannelClosed); + expect_event!(node_b, ChannelClosed); + + // node_a should have dropped node_b from its peer store. We assert on `is_persisted` rather + // than peer presence so a lingering transient TCP connection doesn't mask the removal. + assert!( + !node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), + "node_a should drop node_b from its peer store after node_b force-closed the last channel" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle_0conf() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From dea5ca02d19a0130713d9a7bc75c440a81366372 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 8 Jul 2026 08:45:49 +0200 Subject: [PATCH 070/138] Bump LDK dependency Update LDK to revision 506cb91f2e0fb87906188b79777bcf42595d3623 from the 0.3 branch, and adapt onion-message interception to the new event and messenger APIs so the crate compiles. Co-Authored-By: HAL 9000 --- Cargo.toml | 30 +++++++++++++++--------------- src/builder.rs | 1 + src/event.rs | 19 ++++++++++++------- 3 files changed, 28 insertions(+), 22 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9f1c257cb8..c9ff50d22d 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,18 +41,18 @@ postgres = ["dep:tokio-postgres", "dep:native-tls", "dep:postgres-native-tls"] #lightning-macros = { version = "0.2.0" } #lightning-dns-resolver = { version = "0.3.0" } -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["std"] } -lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -lightning-invoice = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["tokio"] } -lightning-background-processor = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -lightning-rapid-gossip-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["rest-client", "rpc-client", "tokio"] } -lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } -lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["std"] } -lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } -lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c" } +lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["std"] } +lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } +lightning-invoice = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["std"] } +lightning-net-tokio = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } +lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["tokio"] } +lightning-background-processor = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } +lightning-rapid-gossip-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } +lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["rest-client", "rpc-client", "tokio"] } +lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["esplora-async-https", "time", "electrum-rustls-ring"] } +lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["std"] } +lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } +lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623" } bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"]} @@ -85,14 +85,14 @@ postgres-native-tls = { version = "0.5", default-features = false, features = [" vss-client = { package = "vss-client-ng", version = "0.6" } prost = { version = "0.11.6", default-features = false} #bitcoin-payment-instructions = { version = "0.6" } -bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "ff09ce9401afa448549a8f101172700bcd14d7bb" } +bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "0e430be98c09540624a68a68022ee0551e86d1be" } [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["std", "_test_utils"] } -lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "3dfcc4cca1866c5e5d4d4eaf3b82e09584e2ce5c", features = ["tokio"] } +lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["std", "_test_utils"] } +lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "506cb91f2e0fb87906188b79777bcf42595d3623", features = ["tokio"] } rand = { version = "0.9.2", default-features = false, features = ["std", "thread_rng", "os_rng"] } proptest = "1.0.0" regex = "1.5.6" diff --git a/src/builder.rs b/src/builder.rs index 8b575cc3f2..2f6fdd2871 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2012,6 +2012,7 @@ fn build_with_store_internal( Arc::clone(&channel_manager), Arc::clone(&om_resolver), IgnoringMessageHandler {}, + false, )) } else { Arc::new(OnionMessenger::new( diff --git a/src/event.rs b/src/event.rs index 93d274ff7f..25ae6b550c 100644 --- a/src/event.rs +++ b/src/event.rs @@ -14,6 +14,7 @@ use std::sync::{Arc, Mutex}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; use bitcoin::{Amount, OutPoint}; +use lightning::blinded_path::message::NextMessageHop; use lightning::events::bump_transaction::BumpTransactionEvent; #[cfg(not(feature = "uniffi"))] use lightning::events::PaidBolt12Invoice; @@ -1725,14 +1726,18 @@ where self.bump_tx_event_handler.handle_event(&bte).await; }, - LdkEvent::OnionMessageIntercepted { peer_node_id, message } => { - if let Some(om_mailbox) = self.om_mailbox.as_ref() { - om_mailbox.onion_message_intercepted(peer_node_id, message); + LdkEvent::OnionMessageIntercepted { next_hop, message, .. } => { + if let NextMessageHop::NodeId(peer_node_id) = next_hop { + if let Some(om_mailbox) = self.om_mailbox.as_ref() { + om_mailbox.onion_message_intercepted(peer_node_id, message); + } else { + log_trace!( + self.logger, + "Onion message intercepted, but no onion message mailbox available" + ); + } } else { - log_trace!( - self.logger, - "Onion message intercepted, but no onion message mailbox available" - ); + log_error!(self.logger, "Onion message intercepted for unknown SCID"); } }, LdkEvent::OnionMessagePeerConnected { peer_node_id } => { From 9fd3cab578b0e89d67aea61c9a7a54fb9ef4c1db Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 8 Jul 2026 14:44:32 +0200 Subject: [PATCH 071/138] Require ChannelClosed counterparty id Require ChannelClosed events to carry the counterparty node id. This removes a legacy missing value that callers could no longer handle. Persisted v0.1 queues with missing ids now fail to read. Newer optional encodings with the id still read correctly. Co-Authored-By: HAL 9000 --- CHANGELOG.md | 2 ++ src/event.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e012a146..115b0ed058 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Compatibility Notes - Pending JIT-channel payments created before upgrading may fail after upgrade because the prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated. +- Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains + a persisted `ChannelClosed` event. - Users of the VSS storage backend must upgrade their VSS server to at least version `v0.1.0-alpha.0` before upgrading LDK Node. diff --git a/src/event.rs b/src/event.rs index cd9635662f..3c1fc573b0 100644 --- a/src/event.rs +++ b/src/event.rs @@ -266,9 +266,7 @@ pub enum Event { /// The `user_channel_id` of the channel. user_channel_id: UserChannelId, /// The `node_id` of the channel counterparty. - /// - /// This will be `None` for events serialized by LDK Node v0.1.0 and prior. - counterparty_node_id: Option, + counterparty_node_id: PublicKey, /// This will be `None` for events serialized by LDK Node v0.2.1 and prior. reason: Option, }, @@ -329,7 +327,7 @@ impl_writeable_tlv_based_enum!(Event, }, (5, ChannelClosed) => { (0, channel_id, required), - (1, counterparty_node_id, option), + (1, counterparty_node_id, required), (2, user_channel_id, required), (3, reason, upgradable_option), }, @@ -1652,7 +1650,7 @@ where let event = Event::ChannelClosed { channel_id, user_channel_id: UserChannelId(user_channel_id), - counterparty_node_id: Some(counterparty_node_id), + counterparty_node_id, reason: Some(reason), }; @@ -1951,6 +1949,8 @@ where #[cfg(test)] mod tests { + use std::collections::VecDeque; + use std::str::FromStr; use std::sync::atomic::{AtomicU16, Ordering}; use std::time::Duration; @@ -2048,6 +2048,84 @@ mod tests { assert_eq!(event_queue.next_event(), None); } + #[derive(Clone, Debug, PartialEq, Eq)] + enum LegacyEvent { + ChannelClosed { + channel_id: ChannelId, + user_channel_id: UserChannelId, + counterparty_node_id: Option, + reason: Option, + }, + } + + impl_writeable_tlv_based_enum!(LegacyEvent, + (5, ChannelClosed) => { + (0, channel_id, required), + (1, counterparty_node_id, option), + (2, user_channel_id, required), + (3, reason, upgradable_option), + }, + ); + + fn encode_legacy_event_queue(event: LegacyEvent) -> Vec { + let mut queue = VecDeque::new(); + queue.push_back(event); + + let mut bytes = Vec::new(); + (queue.len() as u16).write(&mut bytes).unwrap(); + for event in queue.iter() { + event.write(&mut bytes).unwrap(); + } + bytes + } + + #[test] + fn event_queue_reads_legacy_channel_closed_with_counterparty() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let counterparty_node_id = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channel_id = ChannelId([42u8; 32]); + let user_channel_id = UserChannelId(4242); + let legacy_event = LegacyEvent::ChannelClosed { + channel_id, + user_channel_id, + counterparty_node_id: Some(counterparty_node_id), + reason: None, + }; + let persisted_bytes = encode_legacy_event_queue(legacy_event); + + let event_queue = + EventQueue::read(&mut &persisted_bytes[..], (Arc::clone(&store), logger)).unwrap(); + assert_eq!( + event_queue.next_event(), + Some(Event::ChannelClosed { + channel_id, + user_channel_id, + counterparty_node_id, + reason: None, + }) + ); + } + + #[test] + fn event_queue_rejects_legacy_channel_closed_without_counterparty() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + let legacy_event = LegacyEvent::ChannelClosed { + channel_id: ChannelId([42u8; 32]), + user_channel_id: UserChannelId(4242), + counterparty_node_id: None, + reason: None, + }; + let persisted_bytes = encode_legacy_event_queue(legacy_event); + + let res = EventQueue::read(&mut &persisted_bytes[..], (Arc::clone(&store), logger)); + assert!(res.is_err()); + } + #[tokio::test] async fn event_queue_concurrency() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); From cd1c3e15eb0cf13a2e9e2608018952fcc13af82d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 29 Jun 2026 15:43:20 +0200 Subject: [PATCH 072/138] Fix on-chain receive test lookup Use the receiver node for receive-side on-chain checks. Co-Authored-By: HAL 9000 --- tests/integration_tests_rust.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index d91697c4fb..0952af3c27 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -657,7 +657,7 @@ async fn onchain_send_receive() { assert!(payment_a.fee_paid_msat > Some(0)); let payment_b = node_b.payment(&payment_id).unwrap(); assert_eq!(payment_b.status, PaymentStatus::Pending); - match payment_a.kind { + match payment_b.kind { PaymentKind::Onchain { status, .. } => { assert!(matches!(status, ConfirmationStatus::Unconfirmed)); }, @@ -695,7 +695,7 @@ async fn onchain_send_receive() { _ => panic!("Unexpected payment kind"), } - let payment_b = node_a.payment(&payment_id).unwrap(); + let payment_b = node_b.payment(&payment_id).unwrap(); match payment_b.kind { PaymentKind::Onchain { txid: _txid, status, .. } => { assert_eq!(_txid, txid); From 8efba39eaef1da8add34b89b535ddf8db9093d6a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 29 Jun 2026 15:45:04 +0200 Subject: [PATCH 073/138] Keep wallet broadcasts unclassified Avoid tagging ordinary wallet sends and rebroadcasts as LDK sweeps. They should remain on-chain payments with no transaction type. Co-Authored-By: HAL 9000 --- src/tx_broadcaster.rs | 29 ++++++++++++++++++++++------- src/wallet/mod.rs | 32 ++++++++------------------------ tests/integration_tests_rust.rs | 12 ++++++++---- 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 5722a3ebe3..ccf2298b08 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -9,7 +9,9 @@ use std::ops::Deref; use std::sync::{Mutex as StdMutex, Weak}; use bitcoin::Transaction; -use lightning::chain::chaininterface::{BroadcasterInterface, TransactionType}; +use lightning::chain::chaininterface::{ + BroadcasterInterface, TransactionType as LdkTransactionType, +}; use tokio::sync::{mpsc, Mutex, MutexGuard}; use crate::logger::{log_error, LdkLogger}; @@ -22,16 +24,21 @@ const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; /// call, along with each transaction's type. Queued until the background task classifies and /// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated /// transactions can't be grouped into one package by accident. -pub(crate) struct BroadcastPackage(Vec<(Transaction, TransactionType)>); +pub(crate) struct BroadcastPackage(Vec<(Transaction, Option)>); impl BroadcastPackage { /// Builds a package from the transactions of a single `broadcast_transactions` call. - fn new(txs: &[(&Transaction, TransactionType)]) -> Self { - Self(txs.iter().map(|(tx, tx_type)| ((*tx).clone(), tx_type.clone())).collect()) + fn new(txs: &[(&Transaction, LdkTransactionType)]) -> Self { + Self(txs.iter().map(|(tx, tx_type)| ((*tx).clone(), Some(tx_type.clone()))).collect()) + } + + /// Builds a package for wallet-originated broadcasts that have no LDK classification. + fn unclassified(txs: Vec) -> Self { + Self(txs.into_iter().map(|tx| (tx, None)).collect()) } /// The packaged transactions and their types, for classification. - fn transactions(&self) -> &[(Transaction, TransactionType)] { + fn transactions(&self) -> &[(Transaction, Option)] { &self.0 } @@ -91,18 +98,26 @@ where let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade); if let Some(wallet) = wallet_opt { for (tx, tx_type) in package.transactions() { - wallet.classify_broadcast(tx, tx_type).await?; + if let Some(tx_type) = tx_type { + wallet.classify_broadcast(tx, tx_type).await?; + } } } Ok(package) } + + pub(crate) fn broadcast_unclassified_transactions(&self, txs: Vec) { + self.queue_sender.try_send(BroadcastPackage::unclassified(txs)).unwrap_or_else(|e| { + log_error!(self.logger, "Failed to broadcast transactions: {}", e); + }); + } } impl BroadcasterInterface for TransactionBroadcaster where L::Target: LdkLogger, { - fn broadcast_transactions(&self, txs: &[(&Transaction, TransactionType)]) { + fn broadcast_transactions(&self, txs: &[(&Transaction, LdkTransactionType)]) { self.queue_sender.try_send(BroadcastPackage::new(txs)).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index ad4f8d45ee..8128dbbcd3 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -32,7 +32,7 @@ use bitcoin::{ WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::{ - BroadcasterInterface, FundingCandidate, TransactionType as LdkTransactionType, + FundingCandidate, TransactionType as LdkTransactionType, INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; @@ -335,21 +335,12 @@ impl Wallet { .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); + let tx_count = txs_to_broadcast.len(); + self.broadcaster.broadcast_unclassified_transactions(txs_to_broadcast); log_info!( self.logger, "Rebroadcast {} unconfirmed transactions on chain tip change", - txs_to_broadcast.len() + tx_count ); } } @@ -889,12 +880,8 @@ impl Wallet { })? }; - self.broadcaster.broadcast_transactions(&[( - &tx, - lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] }, - )]); - let txid = tx.compute_txid(); + self.broadcaster.broadcast_unclassified_transactions(vec![tx]); match send_amount { OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { @@ -1719,11 +1706,6 @@ impl Wallet { let new_txid = fee_bumped_tx.compute_txid(); - self.broadcaster.broadcast_transactions(&[( - &fee_bumped_tx, - lightning::chain::chaininterface::TransactionType::Sweep { channels: vec![] }, - )]); - let new_payment = self.create_payment_from_tx( &locked_wallet, new_txid, @@ -1736,9 +1718,11 @@ impl Wallet { let pending_payment_store = self.create_pending_payment_from_tx(new_payment.clone(), Vec::new()); + self.runtime.block_on(self.payment_store.insert_or_update(new_payment))?; self.runtime .block_on(self.pending_payment_store.insert_or_update(pending_payment_store))?; - self.runtime.block_on(self.payment_store.insert_or_update(new_payment))?; + + self.broadcaster.broadcast_unclassified_transactions(vec![fee_bumped_tx]); log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 0952af3c27..b07a90629f 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -649,8 +649,9 @@ async fn onchain_send_receive() { let payment_a = node_a.payment(&payment_id).unwrap(); assert_eq!(payment_a.status, PaymentStatus::Pending); match payment_a.kind { - PaymentKind::Onchain { status, .. } => { + PaymentKind::Onchain { status, tx_type, .. } => { assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert_eq!(tx_type, None); }, _ => panic!("Unexpected payment kind"), } @@ -658,8 +659,9 @@ async fn onchain_send_receive() { let payment_b = node_b.payment(&payment_id).unwrap(); assert_eq!(payment_b.status, PaymentStatus::Pending); match payment_b.kind { - PaymentKind::Onchain { status, .. } => { + PaymentKind::Onchain { status, tx_type, .. } => { assert!(matches!(status, ConfirmationStatus::Unconfirmed)); + assert_eq!(tx_type, None); }, _ => panic!("Unexpected payment kind"), } @@ -688,18 +690,20 @@ async fn onchain_send_receive() { let payment_a = node_a.payment(&payment_id).unwrap(); match payment_a.kind { - PaymentKind::Onchain { txid: _txid, status, .. } => { + PaymentKind::Onchain { txid: _txid, status, tx_type } => { assert_eq!(_txid, txid); assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + assert_eq!(tx_type, None); }, _ => panic!("Unexpected payment kind"), } let payment_b = node_b.payment(&payment_id).unwrap(); match payment_b.kind { - PaymentKind::Onchain { txid: _txid, status, .. } => { + PaymentKind::Onchain { txid: _txid, status, tx_type } => { assert_eq!(_txid, txid); assert!(matches!(status, ConfirmationStatus::Confirmed { .. })); + assert_eq!(tx_type, None); }, _ => panic!("Unexpected payment kind"), } From 061e0bde96e6d12e84521c1a288377263a025568 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 29 Jun 2026 15:45:38 +0200 Subject: [PATCH 074/138] Preserve classified on-chain types Keep known on-chain transaction types across wallet sync updates. Co-Authored-By: HAL 9000 --- src/payment/store.rs | 101 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/src/payment/store.rs b/src/payment/store.rs index 9bf402e59c..38583dd7e7 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -288,7 +288,9 @@ impl StorableObject for PaymentDetails { if let Some(tx_type_update) = update.tx_type { match self.kind { PaymentKind::Onchain { ref mut tx_type, .. } => { - update_if_necessary!(*tx_type, tx_type_update); + if tx_type.is_none() || tx_type_update.is_some() { + update_if_necessary!(*tx_type, tx_type_update); + } }, _ => {}, } @@ -922,6 +924,103 @@ mod tests { assert_eq!(kind, PaymentKind::read(&mut &*kind.encode()).unwrap()); } + #[test] + fn known_onchain_tx_type_survives_unknown_update() { + use bitcoin::hashes::Hash; + use std::str::FromStr; + + let txid = Txid::from_byte_array([8u8; 32]); + let payment_id = PaymentId(txid.to_byte_array()); + let pubkey = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let tx_type = TransactionType::CooperativeClose { + counterparty_node_id: pubkey, + channel_id: ChannelId([4u8; 32]), + }; + let mut classified = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(tx_type.clone()), + }, + Some(1_000), + Some(100), + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + let wallet_sync_update = PaymentDetails::new( + payment_id, + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Confirmed { + block_hash: BlockHash::from_byte_array([9u8; 32]), + height: 42, + timestamp: 123, + }, + tx_type: None, + }, + Some(1_000), + Some(100), + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + + assert!(classified.update(PaymentDetailsUpdate::from(&wallet_sync_update))); + match classified.kind { + PaymentKind::Onchain { status, tx_type: Some(updated_tx_type), .. } => { + assert!(matches!(status, ConfirmationStatus::Confirmed { height: 42, .. })); + assert_eq!(updated_tx_type, tx_type); + }, + other => panic!("Unexpected payment kind: {:?}", other), + } + } + + #[test] + fn transaction_type_from_ldk_variants() { + use std::str::FromStr; + + let pubkey = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + let channel_id = ChannelId([5u8; 32]); + let channel = Channel { counterparty_node_id: pubkey, channel_id }; + + let variants = vec![ + ( + LdkTransactionType::Funding { channels: vec![(pubkey, channel_id)] }, + TransactionType::Funding { channels: vec![channel.clone()] }, + ), + ( + LdkTransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id }, + TransactionType::CooperativeClose { counterparty_node_id: pubkey, channel_id }, + ), + ( + LdkTransactionType::UnilateralClose { counterparty_node_id: pubkey, channel_id }, + TransactionType::UnilateralClose { counterparty_node_id: pubkey, channel_id }, + ), + ( + LdkTransactionType::AnchorBump { counterparty_node_id: pubkey, channel_id }, + TransactionType::AnchorBump { counterparty_node_id: pubkey, channel_id }, + ), + ( + LdkTransactionType::Claim { counterparty_node_id: pubkey, channel_id }, + TransactionType::Claim { counterparty_node_id: pubkey, channel_id }, + ), + ( + LdkTransactionType::Sweep { channels: vec![(pubkey, channel_id)] }, + TransactionType::Sweep { channels: vec![channel] }, + ), + ]; + + for (ldk_type, expected_type) in variants { + assert_eq!(TransactionType::from(ldk_type), expected_type); + } + } + #[derive(Clone, Debug, PartialEq, Eq)] struct LegacyBolt11JitKind { hash: PaymentHash, From d66e53f7456f475c8268cd6753b232ba4f47446c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 29 Jun 2026 15:46:22 +0200 Subject: [PATCH 075/138] Classify LDK on-chain broadcasts Record close, claim, anchor-bump, and sweep broadcasts. Wallet sync can then retain the LDK transaction type. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 47 ++++++++- tests/common/mod.rs | 233 +++++++++++++++++++++++++++----------------- 2 files changed, 186 insertions(+), 94 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 8128dbbcd3..a13019df11 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1171,9 +1171,8 @@ impl Wallet { Ok(tx) } - /// Classifies a funding broadcast (channel open or splice) handed to the broadcaster by LDK, - /// recording a payment for it before it is sent. Other transaction types are left for wallet - /// sync to record normally. + /// Classifies an on-chain broadcast handed to the broadcaster by LDK, recording a payment for it + /// before it is sent when it affects this node's wallet. pub(crate) async fn classify_broadcast( &self, tx: &Transaction, tx_type: &LdkTransactionType, ) -> Result<(), Error> { @@ -1184,7 +1183,13 @@ impl Wallet { LdkTransactionType::InteractiveFunding { candidates } => { self.classify_interactive_funding(tx, candidates, tx_type.clone().into()).await }, - _ => Ok(()), + LdkTransactionType::CooperativeClose { .. } + | LdkTransactionType::UnilateralClose { .. } + | LdkTransactionType::AnchorBump { .. } + | LdkTransactionType::Claim { .. } + | LdkTransactionType::Sweep { .. } => { + self.classify_regular_broadcast(tx, tx_type.clone().into()).await + }, } } @@ -1325,6 +1330,40 @@ impl Wallet { Ok(()) } + /// Records a non-funding LDK broadcast as an on-chain payment, tagged with its transaction type. + /// Wallet sync later refreshes confirmation status while preserving the type. + async fn classify_regular_broadcast( + &self, tx: &Transaction, tx_type: TransactionType, + ) -> Result<(), Error> { + let txid = tx.compute_txid(); + let (amount_msat, fee_paid_msat, direction) = self.onchain_payment_fields(tx); + + if amount_msat == Some(0) && fee_paid_msat == Some(0) { + log_trace!( + self.logger, + "Not recording classified broadcast {} as a payment: no wallet-level activity", + txid, + ); + return Ok(()); + } + + let details = PaymentDetails::new( + PaymentId(txid.to_byte_array()), + PaymentKind::Onchain { + txid, + status: ConfirmationStatus::Unconfirmed, + tx_type: Some(tx_type), + }, + amount_msat, + fee_paid_msat, + direction, + PaymentStatus::Pending, + ); + self.payment_store.insert_or_update(details).await?; + log_debug!(self.logger, "Recorded classified on-chain broadcast {}", txid); + Ok(()) + } + /// Writes a freshly-classified funding payment to the authoritative payment store and adds a /// pending-store index entry, so wallet sync graduates it through `ANTI_REORG_DELAY`. async fn persist_funding_payment( diff --git a/tests/common/mod.rs b/tests/common/mod.rs index ba6d323680..518d09bf3c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -30,6 +30,7 @@ use std::time::Duration; use bitcoin::hashes::hex::FromHex; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; +use bitcoin::secp256k1::PublicKey; use bitcoin::{ Address, Amount, Network, OutPoint, ScriptBuf, Sequence, Transaction, Txid, Witness, }; @@ -42,7 +43,7 @@ use ldk_node::config::{ }; use ldk_node::entropy::{generate_entropy_mnemonic, NodeEntropy}; use ldk_node::io::sqlite_store::SqliteStore; -use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; +use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus, TransactionType}; use ldk_node::{ Builder, ChannelShutdownState, CustomTlvRecord, Event, LightningBalance, Node, NodeError, PendingSweepBalance, UserChannelId, @@ -407,6 +408,116 @@ type TestNode = Arc; #[cfg(not(feature = "uniffi"))] type TestNode = Node; +fn has_onchain_tx_type bool>(node: &TestNode, predicate: F) -> bool { + node.list_payments().into_iter().any(|payment| { + matches!( + payment.kind, + PaymentKind::Onchain { tx_type: Some(ref tx_type), .. } if predicate(tx_type) + ) + }) +} + +fn assert_any_node_has_onchain_tx_type bool + Copy>( + nodes: &[(&str, &TestNode)], tx_type_name: &str, predicate: F, +) { + if nodes.iter().any(|(_, node)| has_onchain_tx_type(node, predicate)) { + return; + } + + let observed: Vec = nodes + .iter() + .flat_map(|(name, node)| { + node.list_payments().into_iter().filter_map(move |payment| match payment.kind { + PaymentKind::Onchain { tx_type, .. } => Some(format!("{}:{:?}", name, tx_type)), + _ => None, + }) + }) + .collect(); + panic!("Expected on-chain payment with tx_type {}; observed {:?}", tx_type_name, observed); +} + +fn assert_all_nodes_have_onchain_tx_type bool + Copy>( + nodes: &[(&str, &TestNode)], tx_type_name: &str, predicate: F, +) { + if nodes.iter().all(|(_, node)| has_onchain_tx_type(node, predicate)) { + return; + } + + let observed: Vec = nodes + .iter() + .flat_map(|(name, node)| { + node.list_payments().into_iter().filter_map(move |payment| match payment.kind { + PaymentKind::Onchain { tx_type, .. } => Some(format!("{}:{:?}", name, tx_type)), + _ => None, + }) + }) + .collect(); + panic!( + "Expected all nodes to have on-chain payment with tx_type {}; observed {:?}", + tx_type_name, observed + ); +} + +async fn settle_force_close_balance( + node: &TestNode, counterparty_node_id: PublicKey, peer_node: &TestNode, + bitcoind: &BitcoindClient, electrsd: &E, +) { + let balances = node.list_balances(); + if balances.lightning_balances.len() == 1 { + match balances.lightning_balances[0] { + LightningBalance::ClaimableAwaitingConfirmations { + counterparty_node_id: actual_counterparty_node_id, + confirmation_height, + .. + } => { + assert_eq!(actual_counterparty_node_id, counterparty_node_id); + let cur_height = node.status().current_best_block.height; + let blocks_to_go = confirmation_height - cur_height; + generate_blocks_and_wait(bitcoind, electrsd, blocks_to_go as usize).await; + node.sync_wallets().unwrap(); + peer_node.sync_wallets().unwrap(); + }, + _ => panic!("Unexpected balance state!"), + } + } else { + assert!(balances.lightning_balances.is_empty(), "Unexpected balance state: {:?}", balances); + assert_eq!(balances.pending_balances_from_channel_closures.len(), 1); + } + + for _ in 0..6 { + if node.list_balances().lightning_balances.is_empty() { + break; + } + generate_blocks_and_wait(bitcoind, electrsd, 1).await; + node.sync_wallets().unwrap(); + peer_node.sync_wallets().unwrap(); + } + + let balances = node.list_balances(); + assert!(balances.lightning_balances.is_empty(), "Unexpected balance state: {:?}", balances); + assert_eq!(balances.pending_balances_from_channel_closures.len(), 1); + match balances.pending_balances_from_channel_closures[0] { + PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => { + generate_blocks_and_wait(bitcoind, electrsd, 1).await; + node.sync_wallets().unwrap(); + peer_node.sync_wallets().unwrap(); + + assert!(node.list_balances().lightning_balances.is_empty()); + assert_eq!(node.list_balances().pending_balances_from_channel_closures.len(), 1); + match node.list_balances().pending_balances_from_channel_closures[0] { + PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, + _ => panic!("Unexpected balance state!"), + } + }, + PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, + _ => panic!("Unexpected balance state!"), + } + + generate_blocks_and_wait(bitcoind, electrsd, 5).await; + node.sync_wallets().unwrap(); + peer_node.sync_wallets().unwrap(); +} + #[derive(Clone)] pub(crate) enum TestChainSource<'a> { Esplora(&'a ElectrsD), @@ -1487,84 +1598,11 @@ pub(crate) async fn do_channel_full_cycle( node_b.sync_wallets().unwrap(); if force_close { - // Check node_b properly sees all balances and sweeps them. - assert_eq!(node_b.list_balances().lightning_balances.len(), 1); - match node_b.list_balances().lightning_balances[0] { - LightningBalance::ClaimableAwaitingConfirmations { - counterparty_node_id, - confirmation_height, - .. - } => { - assert_eq!(counterparty_node_id, node_a.node_id()); - let cur_height = node_b.status().current_best_block.height; - let blocks_to_go = confirmation_height - cur_height; - generate_blocks_and_wait(&bitcoind, electrsd, blocks_to_go as usize).await; - node_b.sync_wallets().unwrap(); - node_a.sync_wallets().unwrap(); - }, - _ => panic!("Unexpected balance state!"), - } - + settle_force_close_balance(&node_b, node_a.node_id(), &node_a, &bitcoind, electrsd).await; assert!(node_b.list_balances().lightning_balances.is_empty()); assert_eq!(node_b.list_balances().pending_balances_from_channel_closures.len(), 1); - match node_b.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => {}, - _ => panic!("Unexpected balance state!"), - } - generate_blocks_and_wait(&bitcoind, electrsd, 1).await; - node_b.sync_wallets().unwrap(); - node_a.sync_wallets().unwrap(); - - assert!(node_b.list_balances().lightning_balances.is_empty()); - assert_eq!(node_b.list_balances().pending_balances_from_channel_closures.len(), 1); - match node_b.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, - _ => panic!("Unexpected balance state!"), - } - generate_blocks_and_wait(&bitcoind, electrsd, 5).await; - node_b.sync_wallets().unwrap(); - node_a.sync_wallets().unwrap(); - - assert!(node_b.list_balances().lightning_balances.is_empty()); - assert_eq!(node_b.list_balances().pending_balances_from_channel_closures.len(), 1); - - // Check node_a properly sees all balances and sweeps them. - assert_eq!(node_a.list_balances().lightning_balances.len(), 1); - match node_a.list_balances().lightning_balances[0] { - LightningBalance::ClaimableAwaitingConfirmations { - counterparty_node_id, - confirmation_height, - .. - } => { - assert_eq!(counterparty_node_id, node_b.node_id()); - let cur_height = node_a.status().current_best_block.height; - let blocks_to_go = confirmation_height - cur_height; - generate_blocks_and_wait(&bitcoind, electrsd, blocks_to_go as usize).await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - }, - _ => panic!("Unexpected balance state!"), - } - - assert!(node_a.list_balances().lightning_balances.is_empty()); - assert_eq!(node_a.list_balances().pending_balances_from_channel_closures.len(), 1); - match node_a.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => {}, - _ => panic!("Unexpected balance state!"), - } - generate_blocks_and_wait(&bitcoind, electrsd, 1).await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - assert!(node_a.list_balances().lightning_balances.is_empty()); - assert_eq!(node_a.list_balances().pending_balances_from_channel_closures.len(), 1); - match node_a.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, - _ => panic!("Unexpected balance state!"), - } - generate_blocks_and_wait(&bitcoind, electrsd, 5).await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); + settle_force_close_balance(&node_a, node_b.node_id(), &node_b, &bitcoind, electrsd).await; } else { assert_eq!(node_a.list_balances().lightning_balances.len(), 1); assert!(node_a.list_balances().pending_balances_from_channel_closures.is_empty()); @@ -1616,7 +1654,22 @@ pub(crate) async fn do_channel_full_cycle( node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), "node_b should remain persisted in node_a peer store after locally-initiated force-close" ); + assert_any_node_has_onchain_tx_type( + &[("node_a", &node_a), ("node_b", &node_b)], + "UnilateralClose", + |tx_type| matches!(tx_type, TransactionType::UnilateralClose { .. }), + ); + assert_any_node_has_onchain_tx_type( + &[("node_a", &node_a), ("node_b", &node_b)], + "Sweep", + |tx_type| matches!(tx_type, TransactionType::Sweep { .. }), + ); } else { + assert_all_nodes_have_onchain_tx_type( + &[("node_a", &node_a), ("node_b", &node_b)], + "CooperativeClose", + |tx_type| matches!(tx_type, TransactionType::CooperativeClose { .. }), + ); // Peer removed after cooperative close — no further reason to reconnect. assert!( !node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), @@ -1646,20 +1699,20 @@ pub(crate) async fn do_channel_full_cycle( assert_eq!(node_b.list_balances().total_anchor_channels_reserve_sats, 0); // Now we should have seen the channel closing transaction on-chain. - assert_eq!( - node_a - .list_payments_with_filter(|p| p.direction == PaymentDirection::Inbound - && matches!(p.kind, PaymentKind::Onchain { .. })) - .len(), - 3 - ); - assert_eq!( - node_b - .list_payments_with_filter(|p| p.direction == PaymentDirection::Inbound - && matches!(p.kind, PaymentKind::Onchain { .. })) - .len(), - 2 - ); + let node_a_inbound_onchain_count = node_a + .list_payments_with_filter(|p| { + p.direction == PaymentDirection::Inbound + && matches!(p.kind, PaymentKind::Onchain { .. }) + }) + .len(); + let node_b_inbound_onchain_count = node_b + .list_payments_with_filter(|p| { + p.direction == PaymentDirection::Inbound + && matches!(p.kind, PaymentKind::Onchain { .. }) + }) + .len(); + assert!(node_a_inbound_onchain_count >= 3); + assert!(node_b_inbound_onchain_count >= 2); // Check we handled all events assert_eq!(node_a.next_event(), None); From c3bdca81850cad92417cdffd8046c8e1158e337c Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Mon, 29 Jun 2026 02:51:42 +0300 Subject: [PATCH 076/138] Add probing service Introduce a background probing service that periodically sends payment probes to discover liquidity along Lightning routes. Probes update the local scorer with channel liquidity information, improving pathfinding for subsequent real payments. The service supports three strategies: - HighDegreeStrategy: probes nodes with the most channels in the network graph - RandomWalkStrategy: walks random paths from the local node - Custom: user-supplied strategy via the ProbingStrategy trait A dedicated ProbingConfigBuilder exposes amount bounds, locked-msat caps, probing intervals, and per-node cooldowns, with sensible defaults. The service runs as a cancellable background task driven by the existing Runtime, and budget accounting tracks both in-flight and locked amounts to bound outbound liquidity exposure. UniFFI bindings expose the probing service to the Swift, Kotlin, and Python language bindings. Co-Authored-By: Claude Sonnet 4.6 --- bindings/ldk_node.udl | 15 + src/builder.rs | 93 ++++- src/config.rs | 6 + src/event.rs | 31 +- src/ffi/types.rs | 1 + src/lib.rs | 19 + src/probing.rs | 834 ++++++++++++++++++++++++++++++++++++++++++ src/util.rs | 37 ++ 8 files changed, 1024 insertions(+), 12 deletions(-) create mode 100644 src/probing.rs create mode 100644 src/util.rs diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 7c0edc5359..d7e9a774f9 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -13,6 +13,8 @@ typedef dictionary TorConfig; typedef interface NodeEntropy; +typedef interface ProbingConfig; + typedef enum WordCount; [Remote] @@ -32,6 +34,18 @@ interface LogWriter { void log(LogRecord record); }; +interface ProbingConfigBuilder { + [Name=high_degree] + constructor(u64 top_node_count); + [Name=random_walk] + constructor(u64 max_hops); + void set_interval(u64 secs); + void set_max_locked_msat(u64 max_msat); + void set_diversity_penalty_msat(u64 penalty_msat); + void set_cooldown(u64 secs); + ProbingConfig build(); +}; + interface Builder { constructor(); [Name=from_config] @@ -59,6 +73,7 @@ interface Builder { void set_node_alias(string node_alias); [Throws=BuildError] void set_async_payments_role(AsyncPaymentsRole? role); + void set_probing_config(ProbingConfig config); [Throws=BuildError] Node build(NodeEntropy node_entropy); [Throws=BuildError] diff --git a/src/builder.rs b/src/builder.rs index 2f6fdd2871..639838ff3a 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -50,6 +50,7 @@ use crate::config::{ default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, TorConfig, DEFAULT_ESPLORA_SERVER_URL, DEFAULT_LOG_FILENAME, DEFAULT_LOG_LEVEL, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, }; use crate::connection::ConnectionManager; use crate::entropy::NodeEntropy; @@ -74,6 +75,10 @@ use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::peer_store::PeerStore; +use crate::probing::{ + HighDegreeStrategy, Prober, ProbingConfig, ProbingStrategy, ProbingStrategyKind, + RandomWalkStrategy, +}; use crate::runtime::{Runtime, RuntimeSpawner}; use crate::tx_broadcaster::TransactionBroadcaster; use crate::types::{ @@ -306,6 +311,7 @@ pub struct NodeBuilder { async_payments_role: Option, runtime_handle: Option, pathfinding_scores_sync_config: Option, + probing_config: Option, } impl NodeBuilder { @@ -323,6 +329,7 @@ impl NodeBuilder { let log_writer_config = None; let runtime_handle = None; let pathfinding_scores_sync_config = None; + let probing_config = None; Self { config, chain_data_source_config, @@ -332,6 +339,7 @@ impl NodeBuilder { runtime_handle, async_payments_role: None, pathfinding_scores_sync_config, + probing_config, } } @@ -629,6 +637,31 @@ impl NodeBuilder { Ok(self) } + /// Sets background probing config. + /// + /// Use [`ProbingConfigBuilder`] to build the configuration: + /// ```no_run + /// # #[cfg(not(feature = "uniffi"))] + /// # { + /// use std::time::Duration; + /// use ldk_node::Builder; + /// use ldk_node::probing::ProbingConfigBuilder; + /// + /// let mut builder = Builder::new(); + /// builder.set_probing_config( + /// ProbingConfigBuilder::high_degree(100) + /// .interval(Duration::from_secs(30)) + /// .build() + /// ); + /// # } + /// ``` + /// + /// [`ProbingConfigBuilder`]: crate::probing::ProbingConfigBuilder + pub fn set_probing_config(&mut self, config: ProbingConfig) -> &mut Self { + self.probing_config = Some(config); + self + } + /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: NodeEntropy) -> Result { @@ -868,6 +901,7 @@ impl NodeBuilder { self.gossip_source_config.as_ref(), self.liquidity_source_config.as_ref(), self.pathfinding_scores_sync_config.as_ref(), + self.probing_config.as_ref(), self.async_payments_role, seed_bytes, runtime, @@ -1166,6 +1200,15 @@ impl ArcedNodeBuilder { self.inner.write().expect("lock").set_async_payments_role(role).map(|_| ()) } + /// Configures background probing. + /// + /// Use [`ProbingConfigBuilder`] to build the configuration. + /// + /// [`ProbingConfigBuilder`]: crate::probing::ProbingConfigBuilder + pub fn set_probing_config(&self, config: Arc) { + self.inner.write().expect("lock").set_probing_config((*config).clone()); + } + /// Builds a [`Node`] instance with a [`SqliteStore`] backend and according to the options /// previously configured. pub fn build(&self, node_entropy: Arc) -> Result, BuildError> { @@ -1361,8 +1404,8 @@ fn build_with_store_internal( gossip_source_config: Option<&GossipSourceConfig>, liquidity_source_config: Option<&LiquiditySourceConfig>, pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>, - async_payments_role: Option, seed_bytes: [u8; 64], runtime: Arc, - logger: Arc, kv_store: Arc, + probing_config: Option<&ProbingConfig>, async_payments_role: Option, + seed_bytes: [u8; 64], runtime: Arc, logger: Arc, kv_store: Arc, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -2219,6 +2262,51 @@ fn build_with_store_internal( _leak_checker.0.push(Arc::downgrade(&wallet) as Weak); } + let prober = probing_config.map(|probing_cfg| { + let strategy: Arc = match &probing_cfg.kind { + ProbingStrategyKind::HighDegree { top_node_count } => { + // Dedicated router for probing so the diversity penalty doesn't interfere + // with real payments; shares the scorer so probe results still train it. + let mut probing_fee_params = ProbabilisticScoringFeeParameters::default(); + if let Some(penalty) = probing_cfg.diversity_penalty_msat { + probing_fee_params.probing_diversity_penalty_msat = penalty; + } + let probing_router = Arc::new(DefaultRouter::new( + Arc::clone(&network_graph), + Arc::clone(&logger), + Arc::clone(&keys_manager), + Arc::clone(&scorer), + probing_fee_params, + )); + Arc::new(HighDegreeStrategy::new( + Arc::clone(&network_graph), + Arc::clone(&channel_manager), + probing_router, + *top_node_count, + DEFAULT_MIN_PROBE_AMOUNT_MSAT, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, + probing_cfg.cooldown, + config.probing_liquidity_limit_multiplier, + )) + }, + ProbingStrategyKind::RandomWalk { max_hops } => Arc::new(RandomWalkStrategy::new( + Arc::clone(&network_graph), + Arc::clone(&channel_manager), + *max_hops, + DEFAULT_MIN_PROBE_AMOUNT_MSAT, + DEFAULT_MAX_PROBE_AMOUNT_MSAT, + )), + ProbingStrategyKind::Custom(s) => Arc::clone(s), + }; + Arc::new(Prober { + channel_manager: Arc::clone(&channel_manager), + logger: Arc::clone(&logger), + strategy, + interval: probing_cfg.interval, + max_locked_msat: probing_cfg.max_locked_msat, + }) + }); + Ok(Node { runtime, stop_sender, @@ -2252,6 +2340,7 @@ fn build_with_store_internal( om_mailbox, async_payments_role, hrn_resolver, + prober, #[cfg(cycle_tests)] _leak_checker, }) diff --git a/src/config.rs b/src/config.rs index f83cf3d437..f168df94ef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -28,6 +28,12 @@ const DEFAULT_BDK_WALLET_SYNC_INTERVAL_SECS: u64 = 80; const DEFAULT_LDK_WALLET_SYNC_INTERVAL_SECS: u64 = 30; const DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS: u64 = 60 * 10; const DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER: u64 = 3; +pub(crate) const DEFAULT_PROBING_INTERVAL_SECS: u64 = 10; +pub(crate) const MIN_PROBING_INTERVAL: Duration = Duration::from_millis(100); +pub(crate) const DEFAULT_PROBED_NODE_COOLDOWN_SECS: u64 = 60 * 60; // 1 hour +pub(crate) const DEFAULT_MAX_PROBE_LOCKED_MSAT: u64 = 100_000_000; // 100k sats +pub(crate) const DEFAULT_MIN_PROBE_AMOUNT_MSAT: u64 = 1_000_000; // 1k sats +pub(crate) const DEFAULT_MAX_PROBE_AMOUNT_MSAT: u64 = 10_000_000; // 10k sats const DEFAULT_ANCHOR_PER_CHANNEL_RESERVE_SATS: u64 = 25_000; // The default timeout after which we abort a wallet syncing operation. diff --git a/src/event.rs b/src/event.rs index 3c1fc573b0..91ab7b27de 100644 --- a/src/event.rs +++ b/src/event.rs @@ -52,6 +52,7 @@ use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; use crate::payment::PaymentMetadata; +use crate::probing::Prober; use crate::runtime::Runtime; use crate::types::{ CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, @@ -536,12 +537,13 @@ where payment_store: Arc, peer_store: Arc>, keys_manager: Arc, - runtime: Arc, - logger: L, - config: Arc, static_invoice_store: Option, onion_messenger: Arc, om_mailbox: Option>, + prober: Option>, + runtime: Arc, + logger: L, + config: Arc, } impl EventHandler @@ -556,8 +558,8 @@ where liquidity_source: Arc>>, payment_store: Arc, peer_store: Arc>, keys_manager: Arc, static_invoice_store: Option, onion_messenger: Arc, - om_mailbox: Option>, runtime: Arc, logger: L, - config: Arc, + om_mailbox: Option>, prober: Option>, + runtime: Arc, logger: L, config: Arc, ) -> Self { Self { event_queue, @@ -571,12 +573,13 @@ where payment_store, peer_store, keys_manager, - logger, - runtime, - config, static_invoice_store, onion_messenger, om_mailbox, + prober, + runtime, + logger, + config, } } @@ -1208,8 +1211,16 @@ where LdkEvent::PaymentPathSuccessful { .. } => {}, LdkEvent::PaymentPathFailed { .. } => {}, - LdkEvent::ProbeSuccessful { .. } => {}, - LdkEvent::ProbeFailed { .. } => {}, + LdkEvent::ProbeSuccessful { path, payment_id, .. } => { + if let Some(prober) = &self.prober { + prober.handle_background_probe_successful(&path, payment_id); + } + }, + LdkEvent::ProbeFailed { path, payment_id, .. } => { + if let Some(prober) = &self.prober { + prober.handle_background_probe_failed(&path, payment_id); + } + }, LdkEvent::HTLCHandlingFailed { failure_type, .. } => { self.liquidity_source .lsps2_service() diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 9bb03bb075..c6b48dc961 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -149,6 +149,7 @@ pub use crate::entropy::{generate_entropy_mnemonic, NodeEntropy, WordCount}; use crate::error::Error; pub use crate::liquidity::LSPS1OrderStatus; pub use crate::logger::{LogLevel, LogRecord, LogWriter}; +pub use crate::probing::ProbingConfig; use crate::{hex_utils, SocketAddress, UserChannelId}; uniffi::custom_type!(PublicKey, String, { diff --git a/src/lib.rs b/src/lib.rs index a2c4cf3be1..acfcbc0d48 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,10 +101,12 @@ pub mod logger; mod message_handler; pub mod payment; mod peer_store; +pub mod probing; mod runtime; mod scoring; mod tx_broadcaster; mod types; +mod util; mod wallet; use std::default::Default; @@ -172,6 +174,9 @@ use payment::{ UnifiedPayment, }; use peer_store::{PeerInfo, PeerStore}; +#[cfg(feature = "uniffi")] +pub use probing::ArcedProbingConfigBuilder as ProbingConfigBuilder; +use probing::{run_prober, Prober}; use runtime::Runtime; pub use tokio; use types::{ @@ -250,6 +255,7 @@ pub struct Node { om_mailbox: Option>, async_payments_role: Option, hrn_resolver: HRNResolver, + prober: Option>, #[cfg(cycle_tests)] _leak_checker: LeakChecker, } @@ -610,11 +616,19 @@ impl Node { static_invoice_store, Arc::clone(&self.onion_messenger), self.om_mailbox.clone(), + self.prober.clone(), Arc::clone(&self.runtime), Arc::clone(&self.logger), Arc::clone(&self.config), )); + if let Some(prober) = self.prober.clone() { + let stop_rx = self.stop_sender.subscribe(); + self.runtime.spawn_cancellable_background_task(async move { + run_prober(prober, stop_rx).await; + }); + } + // Setup background processing let background_persister = Arc::clone(&self.kv_store); let background_event_handler = Arc::clone(&event_handler); @@ -1145,6 +1159,11 @@ impl Node { )) } + /// Returns a reference to the [`Prober`], or `None` if no probing strategy is configured. + pub fn prober(&self) -> Option<&Prober> { + self.prober.as_deref() + } + /// Retrieve a list of known channels. pub fn list_channels(&self) -> Vec { self.channel_manager diff --git a/src/probing.rs b/src/probing.rs new file mode 100644 index 0000000000..840a73a2e8 --- /dev/null +++ b/src/probing.rs @@ -0,0 +1,834 @@ +// 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. + +//! Background probing for training the payment scorer. +//! +//! Lightning Network nodes only know channels' capacities via their initially announced limits; +//! the real values change unpredictably after payments have been sent, which makes some of +//! the channels inoperable (capacity has been depleted). The only way to know about channel +//! depletion is to attempt sending a payment through it. Thus, sending a live payment +//! might involve a significant time delay for finding an appropriate channel with enough capacity, +//! up to complete failure when a route with enough capacity cannot be found. +//! +//! The background probing service fires probes to learn about the live state of channels and +//! their capacities, providing accurate data to the scorer and router. +//! +//! This module provides the configuration for such a service. There are two pre-built strategies, +//! [`RandomWalkStrategy`] and [`HighDegreeStrategy`], as well as a [`ProbingStrategy`] trait which +//! allows defining a custom probing strategy (for example if there is an established payment +//! pattern). +//! +//! # Configuration +//! +//! Probing is opt-in: a node only runs the service if a [`ProbingConfig`] has been registered +//! on the [`Builder`] via [`Builder::set_probing_config`] before [`Builder::build`]. Without a +//! config, no probes are sent. +//! +//! # Example +//! +//! ```no_run +//! # #[cfg(not(feature = "uniffi"))] +//! # { +//! use std::time::Duration; +//! use ldk_node::Builder; +//! use ldk_node::probing::ProbingConfigBuilder; +//! +//! let probing_config = ProbingConfigBuilder::high_degree(100) +//! .interval(Duration::from_secs(30)) +//! .max_locked_msat(500_000) +//! .diversity_penalty_msat(250) +//! .build(); +//! +//! let mut builder = Builder::new(); +//! builder.set_probing_config(probing_config); +//! # } +//! ``` +//! +//! # Caution +//! +//! Probes send real HTLCs along real paths. If an intermediate hop is offline or +//! misbehaving, the probe HTLC can remain in-flight — locking outbound liquidity +//! on the first-hop channel until the HTLC timeout elapses (potentially hours). +//! `max_locked_msat` caps the total outbound capacity that in-flight probes may +//! hold at any one time; tune it conservatively for nodes with tight liquidity. +//! +//! [`Builder`]: crate::Builder +//! [`Builder::set_probing_config`]: crate::Builder::set_probing_config +//! [`Builder::build`]: crate::Builder::build + +use std::collections::HashMap; +use std::fmt; +#[cfg(feature = "uniffi")] +use std::sync::RwLock; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use bitcoin::secp256k1::PublicKey; +use lightning::ln::channelmanager::{PaymentId, RecentPaymentDetails}; +use lightning::routing::gossip::NodeId; +use lightning::routing::router::Router as LdkRouter; +use lightning::routing::router::{ + Path, PaymentParameters, RouteHop, RouteParameters, MAX_PATH_LENGTH_ESTIMATE, +}; +use lightning_invoice::DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA; +use lightning_types::features::{ChannelFeatures, NodeFeatures}; + +use crate::config::{ + DEFAULT_MAX_PROBE_LOCKED_MSAT, DEFAULT_PROBED_NODE_COOLDOWN_SECS, + DEFAULT_PROBING_INTERVAL_SECS, MIN_PROBING_INTERVAL, +}; +use crate::logger::{log_debug, LdkLogger, Logger}; +use crate::types::{ChannelManager, Graph, Router}; +use crate::util::random_range; + +/// Which built-in probing strategy to use, or a custom one. +#[derive(Clone)] +pub(crate) enum ProbingStrategyKind { + HighDegree { top_node_count: usize }, + RandomWalk { max_hops: usize }, + Custom(Arc), +} + +impl fmt::Debug for ProbingStrategyKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::HighDegree { top_node_count } => { + f.debug_struct("HighDegree").field("top_node_count", top_node_count).finish() + }, + Self::RandomWalk { max_hops } => { + f.debug_struct("RandomWalk").field("max_hops", max_hops).finish() + }, + Self::Custom(_) => f.write_str("Custom()"), + } + } +} + +/// Configuration for the background probing subsystem. +/// +/// Instances are produced by [`ProbingConfigBuilder`], which exposes three strategy +/// constructors: [`ProbingConfigBuilder::high_degree`], [`ProbingConfigBuilder::random_walk`], +/// and [`ProbingConfigBuilder::custom`]. +/// +/// Optional setters on the builder tune timing and liquidity limits, and +/// [`ProbingConfigBuilder::build`] finalizes the value. +/// +/// # Examples +/// +/// Using pre-built strategy: +/// ```no_run +/// # #[cfg(not(feature = "uniffi"))] +/// # { +/// use std::time::Duration; +/// use ldk_node::Builder; +/// use ldk_node::probing::ProbingConfigBuilder; +/// +/// let config = ProbingConfigBuilder::high_degree(100) +/// .interval(Duration::from_secs(30)) +/// .max_locked_msat(500_000) +/// .diversity_penalty_msat(250) +/// .build(); +/// +/// let mut builder = Builder::new(); +/// builder.set_probing_config(config); +/// # } +/// ``` +/// +/// Creating a custom strategy that always probes the same path: +/// ``` +/// use ldk_node::lightning::routing::router::Path; +/// use ldk_node::probing::ProbingStrategy; +/// +/// struct FixedPathStrategy { +/// path: Path, +/// } +/// impl ProbingStrategy for FixedPathStrategy { +/// fn next_probe(&self) -> Option { +/// if self.path.hops.len() > 1 { +/// Some(self.path.clone()) +/// } else { +/// None +/// } +/// } +/// } +/// ``` +#[derive(Clone, Debug)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +pub struct ProbingConfig { + pub(crate) kind: ProbingStrategyKind, + pub(crate) interval: Duration, + pub(crate) max_locked_msat: u64, + pub(crate) diversity_penalty_msat: Option, + pub(crate) cooldown: Duration, +} + +/// Builder for [`ProbingConfig`]. +/// +/// A new instance starts from one of three strategy constructors — [`high_degree`], +/// [`random_walk`], or [`custom`] — and is finalized through [`build`]. Optional setters +/// in between override the timing and liquidity defaults. +/// +/// [`high_degree`]: Self::high_degree +/// [`random_walk`]: Self::random_walk +/// [`custom`]: Self::custom +/// [`build`]: Self::build +pub struct ProbingConfigBuilder { + kind: ProbingStrategyKind, + interval: Duration, + max_locked_msat: u64, + diversity_penalty_msat: Option, + cooldown: Duration, +} + +impl ProbingConfigBuilder { + fn with_kind(kind: ProbingStrategyKind) -> Self { + Self { + kind, + interval: Duration::from_secs(DEFAULT_PROBING_INTERVAL_SECS), + max_locked_msat: DEFAULT_MAX_PROBE_LOCKED_MSAT, + diversity_penalty_msat: None, + cooldown: Duration::from_secs(DEFAULT_PROBED_NODE_COOLDOWN_SECS), + } + } + + /// Start building a config that probes toward the highest-degree nodes in the graph. + /// + /// `top_node_count` controls how many of the most-connected nodes are cycled through. + pub fn high_degree(top_node_count: usize) -> Self { + Self::with_kind(ProbingStrategyKind::HighDegree { top_node_count }) + } + + /// Start building a config that probes via random graph walks. + /// + /// `max_hops` is the upper bound on the number of hops in a randomly constructed path. + /// Values below `2` are clamped to `2`. + pub fn random_walk(max_hops: usize) -> Self { + Self::with_kind(ProbingStrategyKind::RandomWalk { max_hops }) + } + + /// Start building a config with a custom [`ProbingStrategy`] implementation. + pub fn custom(strategy: Arc) -> Self { + Self::with_kind(ProbingStrategyKind::Custom(strategy)) + } + + /// Overrides the interval between probe attempts. + /// + /// Defaults to 10 seconds. + pub fn interval(&mut self, interval: Duration) -> &mut Self { + self.interval = interval; + self + } + + /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. + /// + /// Defaults to 100 000 000 msat (100k sats). + pub fn max_locked_msat(&mut self, max_msat: u64) -> &mut Self { + self.max_locked_msat = max_msat; + self + } + + /// Sets the probing diversity penalty applied by the probabilistic scorer. + /// + /// When set, the scorer will penalize channels that have been recently probed, + /// encouraging path diversity during background probing. The penalty decays + /// quadratically over 24 hours. + /// + /// This is only useful for probing strategies that route through the scorer + /// (e.g., [`HighDegreeStrategy`]). Strategies that build paths manually + /// (e.g., [`RandomWalkStrategy`]) bypass the scorer entirely. + /// + /// If unset, LDK's default of `0` (no penalty) is used. + pub fn diversity_penalty_msat(&mut self, penalty_msat: u64) -> &mut Self { + self.diversity_penalty_msat = Some(penalty_msat); + self + } + + /// Sets how long a probed node stays ineligible before being probed again. + /// + /// Only applies to [`HighDegreeStrategy`]. Defaults to 1 hour. + pub fn cooldown(&mut self, cooldown: Duration) -> &mut Self { + self.cooldown = cooldown; + self + } + + /// Builds the [`ProbingConfig`]. + pub fn build(&self) -> ProbingConfig { + ProbingConfig { + kind: self.kind.clone(), + interval: self.interval.max(MIN_PROBING_INTERVAL), + max_locked_msat: self.max_locked_msat, + diversity_penalty_msat: self.diversity_penalty_msat, + cooldown: self.cooldown, + } + } +} + +/// Builder for [`ProbingConfig`]. +/// +/// A new instance starts from one of two strategy constructors — [`high_degree`] or +/// [`random_walk`] — and is finalized through [`build`]. Optional setters in between +/// override the timing and liquidity defaults. +/// +/// [`high_degree`]: Self::high_degree +/// [`random_walk`]: Self::random_walk +/// [`build`]: Self::build +#[cfg(feature = "uniffi")] +pub struct ArcedProbingConfigBuilder { + inner: RwLock, +} + +#[cfg(feature = "uniffi")] +impl ArcedProbingConfigBuilder { + /// Start building a config that probes toward the highest-degree nodes in the graph. + /// + /// `top_node_count` controls how many of the most-connected nodes are cycled through. + pub fn high_degree(top_node_count: u64) -> Self { + Self { inner: RwLock::new(ProbingConfigBuilder::high_degree(top_node_count as usize)) } + } + + /// Start building a config that probes via random graph walks. + /// + /// `max_hops` is the upper bound on the number of hops in a randomly constructed path. + /// Values below `2` are clamped to `2`. + pub fn random_walk(max_hops: u64) -> Self { + Self { inner: RwLock::new(ProbingConfigBuilder::random_walk(max_hops as usize)) } + } + + /// Overrides the interval between probe attempts. + /// + /// Defaults to 10 seconds. + pub fn set_interval(&self, secs: u64) { + self.inner.write().expect("lock").interval(Duration::from_secs(secs)); + } + + /// Overrides the maximum millisatoshis that may be locked in in-flight probes at any time. + /// + /// Defaults to 100 000 000 msat (100k sats). + pub fn set_max_locked_msat(&self, max_msat: u64) { + self.inner.write().expect("lock").max_locked_msat(max_msat); + } + + /// Sets the probing diversity penalty applied by the probabilistic scorer. + /// + /// When set, the scorer will penalize channels that have been recently probed, + /// encouraging path diversity during background probing. The penalty decays + /// quadratically over 24 hours. + /// + /// This is only useful for probing strategies that route through the scorer + /// (e.g., [`HighDegreeStrategy`]). Strategies that build paths manually + /// (e.g., [`RandomWalkStrategy`]) bypass the scorer entirely. + /// + /// If unset, LDK's default of `0` (no penalty) is used. + pub fn set_diversity_penalty_msat(&self, penalty_msat: u64) { + self.inner.write().expect("lock").diversity_penalty_msat(penalty_msat); + } + + /// Sets how long a probed node stays ineligible before being probed again. + /// + /// Only applies to [`HighDegreeStrategy`]. Defaults to 1 hour. + pub fn set_cooldown(&self, secs: u64) { + self.inner.write().expect("lock").cooldown(Duration::from_secs(secs)); + } + + /// Builds the [`ProbingConfig`]. + pub fn build(&self) -> Arc { + Arc::new(self.inner.read().expect("lock").build()) + } +} + +/// A strategy that decides which path the probing service should probe next. +pub trait ProbingStrategy: Send + Sync + 'static { + /// Returns the next probe path to run, or `None` to skip this tick. + fn next_probe(&self) -> Option; +} + +/// Probes toward the most-connected nodes in the graph. +/// +/// On each tick the strategy reads the current gossip graph, sorts nodes by +/// channel count, and picks the highest-degree node from the top +/// `top_node_count` that has not been probed within `cooldown`. +/// Nodes probed more recently are skipped so that the strategy +/// naturally spreads across the top nodes and picks up graph changes. +/// If all top nodes are on cooldown, the cooldown map is cleared and a new cycle begins +/// immediately. +/// +/// The probe amount is chosen uniformly at random from +/// `[min_amount_msat, max_amount_msat]`. +/// +/// `HighDegreeStrategy` can only use publicly announced channels for probing. +pub struct HighDegreeStrategy { + network_graph: Arc, + channel_manager: Arc, + router: Arc, + /// How many of the highest-degree nodes to cycle through. + pub top_node_count: usize, + /// Lower bound for the randomly chosen probe amount. + pub min_amount_msat: u64, + /// Upper bound for the randomly chosen probe amount. + pub max_amount_msat: u64, + /// How long a node stays ineligible after being probed. + pub cooldown: Duration, + /// Skip a path when the first-hop outbound liquidity is less than + /// `path_value * liquidity_limit_multiplier`. + pub liquidity_limit_multiplier: u64, + /// Nodes probed recently, with the time they were last probed. + recently_probed: Mutex>, +} + +impl HighDegreeStrategy { + /// Creates a new high-degree probing strategy. + pub(crate) fn new( + network_graph: Arc, channel_manager: Arc, router: Arc, + top_node_count: usize, min_amount_msat: u64, max_amount_msat: u64, cooldown: Duration, + liquidity_limit_multiplier: u64, + ) -> Self { + assert!( + min_amount_msat <= max_amount_msat, + "min_amount_msat must not exceed max_amount_msat" + ); + Self { + network_graph, + channel_manager, + router, + top_node_count, + min_amount_msat, + max_amount_msat, + cooldown, + liquidity_limit_multiplier, + recently_probed: Mutex::new(HashMap::new()), + } + } +} + +impl ProbingStrategy for HighDegreeStrategy { + fn next_probe(&self) -> Option { + let graph = self.network_graph.read_only(); + + let mut nodes_by_degree: Vec<(NodeId, usize)> = + graph.nodes().unordered_iter().map(|(id, info)| (*id, info.channels.len())).collect(); + + if nodes_by_degree.is_empty() { + return None; + } + + nodes_by_degree.sort_unstable_by(|a, b| b.1.cmp(&a.1)); + + let top_node_count = self.top_node_count.min(nodes_by_degree.len()); + let now = Instant::now(); + + let mut probed = self.recently_probed.lock().unwrap_or_else(|e| e.into_inner()); + + // We could check staleness when we use the entry, but that way we'd not clear cache at + // all. For hundreds of top nodes it's okay to call retain each tick. + probed.retain(|_, probed_at| now.duration_since(*probed_at) < self.cooldown); + + // If all top nodes are on cooldown, reset and start a new cycle. + let final_node_id = match nodes_by_degree[..top_node_count] + .iter() + .find(|(node_id, _)| !probed.contains_key(node_id)) + { + Some((node_id, _)) => *node_id, + None => { + probed.clear(); + nodes_by_degree[0].0 + }, + }; + + probed.insert(final_node_id, now); + drop(probed); + drop(graph); + + let final_node = PublicKey::try_from(final_node_id).ok()?; + + let amount_msat = random_range(self.min_amount_msat, self.max_amount_msat); + let payment_params = + PaymentParameters::from_node_id(final_node, DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA as u32); + let route_params = + RouteParameters::from_payment_params_and_value(payment_params, amount_msat); + + let payer = self.channel_manager.get_our_node_id(); + let usable_channels = self.channel_manager.list_usable_channels(); + let first_hops: Vec<&_> = usable_channels.iter().collect(); + let inflight_htlcs = self.channel_manager.compute_inflight_htlcs(); + + let route = self + .router + .find_route(&payer, &route_params, Some(&first_hops), inflight_htlcs) + .ok()?; + + let path = route.paths.into_iter().next()?; + + if path.hops.len() < 2 && path.blinded_tail.is_none() { + return None; + } + + // Liquidity-limit check (mirrors send_preflight_probes): skip the path when the + // first-hop outbound liquidity is less than path_value * liquidity_limit_multiplier. + if let Some(first_hop_hop) = path.hops.first() { + if let Some(ch) = usable_channels + .iter() + .find(|h| h.get_outbound_payment_scid() == Some(first_hop_hop.short_channel_id)) + { + let path_value = path.final_value_msat() + path.fee_msat(); + if ch.next_outbound_htlc_limit_msat + < path_value.saturating_mul(self.liquidity_limit_multiplier) + { + return None; + } + } + } + + Some(path) + } +} + +/// Explores the graph by walking a random number (≥2) of hops outward from one of our own +/// channels, constructing the [`Path`] explicitly. +/// +/// On each tick: +/// 1. Picks one of our confirmed, usable channels to start from. +/// 2. Performs a random walk of a chosen depth (up to [`MAX_PATH_LENGTH_ESTIMATE`]) through the +/// gossip graph, skipping disabled channels and dead-ends. +/// +/// The probe amount is chosen uniformly at random from `[min_amount_msat, max_amount_msat]`. +/// +/// Because path selection ignores the scorer, this probes channels the router +/// would never try on its own, teaching the scorer about previously unknown paths. +/// +/// `RandomWalkStrategy` can only use publicly announced channels for probing. +pub struct RandomWalkStrategy { + network_graph: Arc, + channel_manager: Arc, + /// Upper bound on the number of hops in a randomly constructed path. + pub max_hops: usize, + /// Lower bound for the randomly chosen probe amount. + pub min_amount_msat: u64, + /// Upper bound for the randomly chosen probe amount. + pub max_amount_msat: u64, +} + +impl RandomWalkStrategy { + /// Creates a new random-walk probing strategy. + pub(crate) fn new( + network_graph: Arc, channel_manager: Arc, max_hops: usize, + min_amount_msat: u64, max_amount_msat: u64, + ) -> Self { + assert!( + min_amount_msat <= max_amount_msat, + "min_amount_msat must not exceed max_amount_msat" + ); + Self { + network_graph, + channel_manager, + max_hops: max_hops.clamp(2, MAX_PATH_LENGTH_ESTIMATE as usize), + min_amount_msat, + max_amount_msat, + } + } + + /// Tries to build a path of `target_hops` hops. Returns `None` if the local node has no + /// usable channels, or the walk terminates before reaching `target_hops`. + fn try_build_path(&self, target_hops: usize, amount_msat: u64) -> Option { + let initial_channels = self + .channel_manager + .list_channels() + .into_iter() + .filter(|c| c.is_usable && c.short_channel_id.is_some()) + .collect::>(); + + if initial_channels.is_empty() { + return None; + } + + let graph = self.network_graph.read_only(); + let first_hop = + &initial_channels[random_range(0, initial_channels.len() as u64 - 1) as usize]; + let first_hop_scid = first_hop.short_channel_id?; + let next_peer_pubkey = first_hop.counterparty.node_id; + let next_peer_node_id = NodeId::from_pubkey(&next_peer_pubkey); + + // Track the tightest HTLC limit across all hops to cap the probe amount. + // The first hop limit comes from our live channel state; subsequent hops use htlc_maximum_msat from the gossip channel update. + let mut route_least_htlc_upper_bound = first_hop.next_outbound_htlc_limit_msat; + let mut route_greatest_htlc_lower_bound = first_hop.next_outbound_htlc_minimum_msat; + + // Walk the graph: each entry is (node_id, arrived_via_scid, pubkey); first entry is set: + let mut route: Vec<(NodeId, u64, PublicKey)> = + vec![(next_peer_node_id, first_hop_scid, next_peer_pubkey)]; + + let mut prev_scid = first_hop_scid; + let mut current_node_id = next_peer_node_id; + + for _ in 1..target_hops { + let node_info = match graph.node(¤t_node_id) { + Some(n) => n, + None => break, + }; + + // Skip the edge we arrived on. Longer cycles aren't filtered — probes fail at + // the destination anyway, so revisiting nodes is harmless. + let candidates: Vec = + node_info.channels.iter().copied().filter(|&scid| scid != prev_scid).collect(); + + if candidates.is_empty() { + break; + } + + let next_scid = candidates[random_range(0, candidates.len() as u64 - 1) as usize]; + let next_channel = match graph.channel(next_scid) { + Some(c) => c, + None => break, + }; + + // as_directed_from validates that current_node_id is a channel endpoint and that + // both direction updates are present; effective_capacity covers both htlc_maximum_msat + // and funding capacity. + let Some((directed, next_node_id)) = next_channel.as_directed_from(¤t_node_id) + else { + break; + }; + // Retrieve the direction-specific update via the public ChannelInfo fields. + // as_directed_from already checked both directions are Some, but we break + // defensively rather than unwrap. + let update = match if directed.source() == &next_channel.node_one { + next_channel.one_to_two.as_ref() + } else { + next_channel.two_to_one.as_ref() + } { + Some(u) => u, + None => break, + }; + + if !update.enabled { + break; + } + + route_least_htlc_upper_bound = + route_least_htlc_upper_bound.min(update.htlc_maximum_msat); + + route_greatest_htlc_lower_bound = + route_greatest_htlc_lower_bound.max(update.htlc_minimum_msat); + + let next_pubkey = match PublicKey::try_from(*next_node_id) { + Ok(pk) => pk, + Err(_) => break, + }; + + route.push((*next_node_id, next_scid, next_pubkey)); + prev_scid = next_scid; + current_node_id = *next_node_id; + } + + if route_greatest_htlc_lower_bound > route_least_htlc_upper_bound { + return None; + } + let amount_msat = + amount_msat.max(route_greatest_htlc_lower_bound).min(route_least_htlc_upper_bound); + if amount_msat < self.min_amount_msat || amount_msat > self.max_amount_msat { + return None; + } + + // Assemble hops backwards so each hop's proportional fee is computed on the amount it actually forwards + let mut hops = Vec::with_capacity(route.len()); + let mut forwarded = amount_msat; + let last = route.len() - 1; + + // Resolve (node_features, channel_features, maybe_announced_channel) for a hop. + // The first hop is our local channel and may be unannounced, so its ChannelFeatures + // are not in the gossip graph — match on SCID to detect it and fall back to local-state + // defaults. All other (walked) hops were picked from the graph and must resolve there. + let hop_features = + |node_id: &NodeId, via_scid: u64| -> Option<(NodeFeatures, ChannelFeatures, bool)> { + let node_features = graph + .node(node_id) + .and_then(|n| n.announcement_info.as_ref().map(|a| a.features().clone())) + .unwrap_or_else(NodeFeatures::empty); + let (channel_features, maybe_announced_channel) = if via_scid == first_hop_scid { + (ChannelFeatures::empty(), false) + } else { + (graph.channel(via_scid)?.features.clone(), true) + }; + Some((node_features, channel_features, maybe_announced_channel)) + }; + + // Final hop: fee_msat carries the delivery amount; cltv_expiry_delta carries the + // destination's final CLTV (matching LDK's shifted-by-one RouteHop convention). + { + let (node_id, via_scid, pubkey) = route[last]; + let (node_features, channel_features, maybe_announced_channel) = + hop_features(&node_id, via_scid)?; + hops.push(RouteHop { + pubkey, + node_features, + short_channel_id: via_scid, + channel_features, + fee_msat: amount_msat, + cltv_expiry_delta: DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA as u32, + maybe_announced_channel, + }); + } + + // Non-final hops, from second-to-last back to first. + for i in (0..last).rev() { + let (node_id, via_scid, pubkey) = route[i]; + let (node_features, channel_features, maybe_announced_channel) = + hop_features(&node_id, via_scid)?; + + let (_, next_scid, _) = route[i + 1]; + let next_channel = graph.channel(next_scid)?; + let (directed, _) = next_channel.as_directed_from(&node_id)?; + let update = match if directed.source() == &next_channel.node_one { + next_channel.one_to_two.as_ref() + } else { + next_channel.two_to_one.as_ref() + } { + Some(u) => u, + None => return None, + }; + let fee = update.fees.base_msat as u64 + + (forwarded * update.fees.proportional_millionths as u64 / 1_000_000); + forwarded += fee; + + hops.push(RouteHop { + pubkey, + node_features, + short_channel_id: via_scid, + channel_features, + fee_msat: fee, + cltv_expiry_delta: update.cltv_expiry_delta as u32, + maybe_announced_channel, + }); + } + + hops.reverse(); + + if hops.len() < 2 { + return None; + } + + // The first-hop HTLC carries amount_msat + all intermediate fees. + // Verify the total fits within our live outbound limit before returning. + let total_outgoing: u64 = hops.iter().map(|h| h.fee_msat).sum(); + if total_outgoing > first_hop.next_outbound_htlc_limit_msat { + return None; + } + + Some(Path { hops, blinded_tail: None }) + } +} + +impl ProbingStrategy for RandomWalkStrategy { + fn next_probe(&self) -> Option { + let target_hops = random_range(2, self.max_hops as u64) as usize; + let amount_msat = random_range(self.min_amount_msat, self.max_amount_msat); + + self.try_build_path(target_hops, amount_msat) + } +} + +/// Periodically dispatches probes according to a [`ProbingStrategy`]. +pub struct Prober { + pub(crate) channel_manager: Arc, + pub(crate) logger: Arc, + /// The strategy that decides what to probe. + pub strategy: Arc, + /// How often to fire a probe attempt. + pub interval: Duration, + /// Maximum total millisatoshis that may be locked in in-flight probes at any time. + pub max_locked_msat: u64, +} + +fn fmt_path(path: &lightning::routing::router::Path) -> String { + path.hops + .iter() + .map(|h| format!("{}(scid={})", h.pubkey, h.short_channel_id)) + .collect::>() + .join(" -> ") +} + +impl Prober { + /// Returns the total millisatoshis currently locked in in-flight probes. + pub fn locked_msat(&self) -> u64 { + return self + .channel_manager + .list_recent_payments() + .into_iter() + .filter_map(|p| match p { + RecentPaymentDetails::Pending { + is_probe: true, + total_msat, + pending_fee_msat, + .. + } => Some(total_msat + pending_fee_msat.unwrap_or(0)), + _ => None, + }) + .sum(); + } + + pub(crate) fn handle_background_probe_successful(&self, path: &Path, payment_id: PaymentId) { + log_debug!( + self.logger, + "Background probe with payment_id: {} succeeded along the path: {}", + payment_id, + fmt_path(path) + ); + } + + pub(crate) fn handle_background_probe_failed(&self, path: &Path, payment_id: PaymentId) { + log_debug!( + self.logger, + "Background probe with payment_id: {} failed along the path: {}", + payment_id, + fmt_path(path) + ); + } +} + +/// Runs the probing loop for the given [`Prober`] until `stop_rx` fires. +pub(crate) async fn run_prober(prober: Arc, mut stop_rx: tokio::sync::watch::Receiver<()>) { + let mut ticker = tokio::time::interval(prober.interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + biased; + _ = stop_rx.changed() => { + log_debug!(prober.logger, "Stopping background probing."); + return; + } + _ = ticker.tick() => { + let path = match prober.strategy.next_probe() { + Some(p) => p, + None => continue, + }; + let amount: u64 = path.hops.iter().map(|h| h.fee_msat).sum(); + if prober.locked_msat() + amount > prober.max_locked_msat { + log_debug!(prober.logger, "Skipping probe: locked-msat budget exceeded."); + continue; + } + match prober.channel_manager.send_probe(path.clone()) { + Ok((_, payment_id)) => { + log_debug!( + prober.logger, + "Background probe with payment_id {} sent: locked {} msat, path: {}", + payment_id, + amount, + fmt_path(&path) + ); + } + Err(e) => { + log_debug!( + prober.logger, + "Background probe send failed: {:?}, path: {}", + e, + fmt_path(&path) + ); + } + } + } + } + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000000..3350ad2c70 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,37 @@ +// 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. + +/// Returns a random `u64` uniformly distributed in `[min, max]` (inclusive). +pub(crate) fn random_range(min: u64, max: u64) -> u64 { + debug_assert!(min <= max); + if min == max { + return min; + } + let range = match (max - min).checked_add(1) { + Some(r) => r, + None => { + // overflowed — full u64::MAX range + let mut buf = [0u8; 8]; + getrandom::fill(&mut buf).expect("getrandom failed"); + return u64::from_ne_bytes(buf); + }, + }; + // We remove bias due to the fact that the range does not evenly divide 2⁶⁴. + // Imagine we had a range from 0 to 2⁶⁴-2 (of length 2⁶⁴-1), then + // the outcomes of 0 would be twice as frequent as any other, as 0 can be produced + // as randomly drawn 0 % 2⁶⁴-1 and as well as 2⁶⁴-1 % 2⁶⁴-1 + let limit = u64::MAX - (u64::MAX % range); + loop { + let mut buf = [0u8; 8]; + getrandom::fill(&mut buf).expect("getrandom failed"); + let val = u64::from_ne_bytes(buf); + if val < limit { + return min + (val % range); + } + // loop runs ~1 iteration on average, in worst case it's ~2 iterations on average + } +} From f5fbf42ac1eda189e1b401cb7d0b7c6e47d1f9a7 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Mon, 29 Jun 2026 02:52:27 +0300 Subject: [PATCH 077/138] Add probing service tests Add integration tests that verify the probing service fires probes on the configured interval and respects the locked-msat budget cap. Shared helpers in tests/common are extended with probing-aware setup. Co-Authored-By: Claude Sonnet 4.6 --- tests/common/mod.rs | 67 +++++- tests/probing_tests.rs | 480 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 540 insertions(+), 7 deletions(-) create mode 100644 tests/probing_tests.rs diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 518d09bf3c..deb8790ea9 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -44,6 +44,7 @@ use ldk_node::config::{ use ldk_node::entropy::{generate_entropy_mnemonic, NodeEntropy}; use ldk_node::io::sqlite_store::SqliteStore; use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus, TransactionType}; +use ldk_node::probing::ProbingConfig; use ldk_node::{ Builder, ChannelShutdownState, CustomTlvRecord, Event, LightningBalance, Node, NodeError, PendingSweepBalance, UserChannelId, @@ -404,9 +405,9 @@ pub(crate) fn random_config(anchor_channels: bool) -> TestConfig { } #[cfg(feature = "uniffi")] -type TestNode = Arc; +pub(crate) type TestNode = Arc; #[cfg(not(feature = "uniffi"))] -type TestNode = Node; +pub(crate) type TestNode = Node; fn has_onchain_tx_type bool>(node: &TestNode, predicate: F) -> bool { node.list_payments().into_iter().any(|payment| { @@ -549,6 +550,7 @@ pub(crate) struct TestConfig { pub wallet_rescan_from_height: Option, pub force_wallet_full_scan: bool, pub full_scan_stop_gap: Option, + pub probing: Option, } impl Default for TestConfig { @@ -572,6 +574,7 @@ impl Default for TestConfig { wallet_rescan_from_height, force_wallet_full_scan, full_scan_stop_gap, + probing: None, } } } @@ -718,6 +721,10 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> builder.set_async_payments_role(config.async_payments_role).unwrap(); + if let Some(probing) = config.probing { + builder.set_probing_config(probing.into()); + } + let node = match config.store_type { TestStoreType::TestSyncStore => { let kv_store = TestSyncStore::new(config.node_config.storage_dir_path.into()); @@ -819,6 +826,37 @@ pub(crate) async fn wait_for_outpoint_spend(electrs: &E, outpoin .await; } +/// Polls the channel from `source_node` to `counterparty_node` until it reports `is_usable` +/// and can carry an HTLC of `min_amount_msat` from `source_node`'s side. +/// +/// After `ChannelReady`, channel-monitor persistence can lag for tens of seconds on slow +/// CI runners; during that window `send_probe`/`send_payment` reject with +/// `ParameterError("...monitor update is in progress...")`. This helper gives tests a +/// deterministic readiness gate instead of racing the monitor-update pipeline. +pub(crate) async fn wait_for_channel_ready_to_send( + source_node: &TestNode, counterparty_node: &TestNode, min_amount_msat: u64, +) { + let counterparty = counterparty_node.node_id(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(180); + while tokio::time::Instant::now() < deadline { + let ready = source_node.list_channels().iter().any(|c| { + c.counterparty.node_id == counterparty + && c.is_usable + && c.next_outbound_htlc_limit_msat >= min_amount_msat + }); + if ready { + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!( + "channel from {} to {} not ready to send {} msat within 180s", + source_node.node_id(), + counterparty, + min_amount_msat, + ); +} + pub(crate) async fn exponential_backoff_poll(mut poll: F) -> T where F: FnMut() -> Option, @@ -944,12 +982,18 @@ pub async fn open_channel( node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, should_announce: bool, electrsd: &ElectrsD, ) -> OutPoint { - open_channel_push_amt(node_a, node_b, funding_amount_sat, None, should_announce, electrsd).await + let funding_txo = + open_channel_no_wait(node_a, node_b, funding_amount_sat, None, should_announce).await; + wait_for_tx(&electrsd.client, funding_txo.txid).await; + funding_txo } -pub async fn open_channel_push_amt( +/// Like [`open_channel`] but skips the `wait_for_tx` electrum check so that +/// multiple channels can be opened back-to-back before any blocks are mined. +/// The caller is responsible for mining blocks and confirming the funding txs. +pub async fn open_channel_no_wait( node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, push_amount_msat: Option, - should_announce: bool, electrsd: &ElectrsD, + should_announce: bool, ) -> OutPoint { if should_announce { node_a @@ -977,11 +1021,20 @@ pub async fn open_channel_push_amt( 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); - wait_for_tx(&electrsd.client, funding_txo_a.txid).await; - funding_txo_a } +pub async fn open_channel_push_amt( + node_a: &TestNode, node_b: &TestNode, funding_amount_sat: u64, push_amount_msat: Option, + should_announce: bool, electrsd: &ElectrsD, +) -> OutPoint { + let funding_txo = + open_channel_no_wait(node_a, node_b, funding_amount_sat, push_amount_msat, should_announce) + .await; + wait_for_tx(&electrsd.client, funding_txo.txid).await; + funding_txo +} + pub async fn open_channel_with_all( node_a: &TestNode, node_b: &TestNode, should_announce: bool, electrsd: &ElectrsD, ) -> OutPoint { diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs new file mode 100644 index 0000000000..a26127d735 --- /dev/null +++ b/tests/probing_tests.rs @@ -0,0 +1,480 @@ +// Integration tests for the probing service. +// +// Budget tests – linear A ──[1M sats]──▶ B ──[1M sats]──▶ C topology: +// +// probe_budget_increments_and_decrements +// Verifies locked_msat rises when a probe is dispatched and returns +// to zero once the probe resolves. +// +// locked_msat_accounts_for_routing_fees +// Asserts the exact locked_msat (delivered amount + per-hop fee) for a single +// in-flight probe, proving fees are tracked and not just the delivered amount. +// +// exhausted_probe_budget_blocks_new_probes +// Samples locked_msat across multiple probe cycles and asserts it never +// exceeds the configured max_locked_msat budget cap. +// +// probing_budget_restored_after_node_restart +// Dispatches a probe, then stops node_b before the failure can propagate +// back so the pending probe HTLC is preserved. Restarts node_a and asserts +// the prober's locked_msat is rebuilt non-zero from list_recent_payments(). + +mod common; +use std::sync::atomic::{AtomicBool, Ordering}; + +use common::{ + expect_channel_ready_event, expect_event, generate_blocks_and_wait, open_channel, + premine_and_distribute_funds, random_chain_source, random_config, setup_bitcoind_and_electrsd, + setup_node, wait_for_channel_ready_to_send, TestNode, TestStoreType, +}; + +use ldk_node::bitcoin::Amount; +use ldk_node::probing::{ProbingConfigBuilder, ProbingStrategy}; +use ldk_node::Event; + +use lightning::routing::router::Path; + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +const PROBE_AMOUNT_MSAT: u64 = 1_000_000; +const PROBING_INTERVAL_MILLISECONDS: u64 = 100; + +/// FixedPathStrategy — returns a fixed pre-built path; used by budget tests. +/// +/// The path is set after node and channel setup via [`set_path`]. +struct FixedPathStrategy { + path: Mutex>, + ready_to_probe: AtomicBool, +} + +impl FixedPathStrategy { + fn new() -> Arc { + Arc::new(Self { path: Mutex::new(None), ready_to_probe: AtomicBool::new(false) }) + } + + fn set_path(&self, path: Path) { + *self.path.lock().unwrap() = Some(path); + } + + fn start_probing(&self) { + self.ready_to_probe.store(true, Ordering::Relaxed); + } + + fn stop_probing(&self) { + self.ready_to_probe.store(false, Ordering::Relaxed); + } +} + +impl ProbingStrategy for FixedPathStrategy { + fn next_probe(&self) -> Option { + if self.ready_to_probe.load(Ordering::Relaxed) { + self.path.lock().unwrap().clone() + } else { + None + } + } +} + +/// Builds a 2-hop probe path: node_a → node_b → node_c using live channel info. +fn build_probe_path( + node_a: &TestNode, node_b: &TestNode, node_c: &TestNode, amount_msat: u64, +) -> Path { + use lightning::routing::router::RouteHop; + use lightning_types::features::{ChannelFeatures, NodeFeatures}; + + let ch_ab = node_a + .list_channels() + .into_iter() + .find(|ch| ch.counterparty.node_id == node_b.node_id() && ch.short_channel_id.is_some()) + .expect("A→B channel not found"); + let ch_bc = node_b + .list_channels() + .into_iter() + .find(|ch| ch.counterparty.node_id == node_c.node_id() && ch.short_channel_id.is_some()) + .expect("B→C channel not found"); + + Path { + hops: vec![ + RouteHop { + pubkey: node_b.node_id(), + node_features: NodeFeatures::empty(), + short_channel_id: ch_ab.short_channel_id.unwrap(), + channel_features: ChannelFeatures::empty(), + fee_msat: 1000, + cltv_expiry_delta: 144, + maybe_announced_channel: true, + }, + RouteHop { + pubkey: node_c.node_id(), + node_features: NodeFeatures::empty(), + short_channel_id: ch_bc.short_channel_id.unwrap(), + channel_features: ChannelFeatures::empty(), + fee_msat: amount_msat, + cltv_expiry_delta: 18, + maybe_announced_channel: true, + }, + ], + blinded_tail: None, + } +} + +/// Verifies that `locked_msat` increases when a probe is dispatched and returns +/// to zero once the probe resolves (succeeds or fails). +#[tokio::test(flavor = "multi_thread")] +async fn probe_budget_increments_and_decrements() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let node_b = setup_node(&chain_source, random_config(false)); + let node_c = setup_node(&chain_source, random_config(false)); + + let mut config_a = random_config(false); + let strategy = FixedPathStrategy::new(); + config_a.probing = Some( + ProbingConfigBuilder::custom(strategy.clone()) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + .max_locked_msat(10 * PROBE_AMOUNT_MSAT) + .build(), + ); + let node_a = setup_node(&chain_source, config_a); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b], + Amount::from_sat(2_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + open_channel(&node_b, &node_c, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_event!(node_b, ChannelReady); + expect_event!(node_b, ChannelReady); + expect_event!(node_c, ChannelReady); + + // Build the probe path now that channels are ready, then enable probing. + strategy.set_path(build_probe_path(&node_a, &node_b, &node_c, PROBE_AMOUNT_MSAT)); + // First hop carries amount + per-hop fee; second hop carries just amount. + wait_for_channel_ready_to_send(&node_a, &node_b, PROBE_AMOUNT_MSAT + 1000).await; + wait_for_channel_ready_to_send(&node_b, &node_c, PROBE_AMOUNT_MSAT).await; + strategy.start_probing(); + + let went_up = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if node_a.prober().unwrap().locked_msat() > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_ok(); + assert!(went_up, "locked_msat never increased — no probe was dispatched"); + println!("First probe dispatched; locked_msat = {}", node_a.prober().unwrap().locked_msat()); + + strategy.stop_probing(); + let cleared = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if node_a.prober().unwrap().locked_msat() == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .is_ok(); + assert!(cleared, "locked_msat never returned to zero after probe resolved"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} + +/// Verifies that `locked_msat` accounts for routing fees, not just the delivered amount: +/// a probe along A→B→C locks `delivered amount + per-hop fee` on the first-hop channel. +/// +/// The budget is sized to exactly one probe's worth, so at most one probe is in flight and +/// the observed `locked_msat` is deterministic. The existing budget test only checks that it +/// is non-zero; this asserts the precise value, which a fees-excluded accounting would miss. +#[tokio::test(flavor = "multi_thread")] +async fn locked_msat_accounts_for_routing_fees() { + // First hop carries the delivered amount plus this per-hop fee (see `build_probe_path`). + const FIRST_HOP_FEE_MSAT: u64 = 1000; + const LOCKED_PER_PROBE_MSAT: u64 = PROBE_AMOUNT_MSAT + FIRST_HOP_FEE_MSAT; + + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let node_b = setup_node(&chain_source, random_config(false)); + let node_c = setup_node(&chain_source, random_config(false)); + + let mut config_a = random_config(false); + let strategy = FixedPathStrategy::new(); + config_a.probing = Some( + ProbingConfigBuilder::custom(strategy.clone()) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + // Budget for exactly one in-flight probe so locked_msat is deterministic. + .max_locked_msat(LOCKED_PER_PROBE_MSAT) + .build(), + ); + let node_a = setup_node(&chain_source, config_a); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b], + Amount::from_sat(2_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + open_channel(&node_b, &node_c, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_event!(node_b, ChannelReady); + expect_event!(node_b, ChannelReady); + expect_event!(node_c, ChannelReady); + + strategy.set_path(build_probe_path(&node_a, &node_b, &node_c, PROBE_AMOUNT_MSAT)); + wait_for_channel_ready_to_send(&node_a, &node_b, LOCKED_PER_PROBE_MSAT).await; + wait_for_channel_ready_to_send(&node_b, &node_c, PROBE_AMOUNT_MSAT).await; + strategy.start_probing(); + + // Capture locked_msat the moment the first probe goes in flight. With a single-probe + // budget the value is only ever 0 or exactly one probe's worth, so the first non-zero + // reading is the full first-hop HTLC. + let locked = tokio::time::timeout(Duration::from_secs(30), async { + loop { + let locked = node_a.prober().unwrap().locked_msat(); + if locked > 0 { + break locked; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("locked_msat never increased — no probe was dispatched"); + + assert_eq!( + locked, LOCKED_PER_PROBE_MSAT, + "locked_msat must equal the delivered amount plus routing fees, not just the delivered amount" + ); + + strategy.stop_probing(); + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} + +/// Verifies that `locked_msat` is restored after the node is stopped and restarted +/// while a probe is still in flight. +/// +/// Race-sensitive: once a probe is dispatched, the failure round-trip +/// (`A→B→C → C fails back → B → A`) resolves it within milliseconds. To keep the +/// HTLC pending across the restart we observe `locked_msat > 0` and then *immediately* +/// call `node_a.disconnect(node_b)`, which closes A's socket to B in-process — much +/// faster than `node_b.stop()` — so any failure message from B is dropped before A +/// processes it. If the race is lost on a given probe (locked_msat drops back to 0 +/// after the disconnect), we reconnect and let the next probe tick try again. +/// The pending Probe entry persists in `node_a`'s channel manager and must be +/// rebuilt by the prober's `locked_msat` on restart via `list_recent_payments()`. +#[tokio::test(flavor = "multi_thread")] +async fn probing_budget_restored_after_node_restart() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let node_b = setup_node(&chain_source, random_config(false)); + let node_c = setup_node(&chain_source, random_config(false)); + + let mut config_a = random_config(false); + // Use a pure on-disk store so state survives the restart. + config_a.store_type = TestStoreType::Sqlite; + let strategy = FixedPathStrategy::new(); + config_a.probing = Some( + ProbingConfigBuilder::custom(strategy.clone()) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + .max_locked_msat(10 * PROBE_AMOUNT_MSAT) + .build(), + ); + let restart_config = config_a.clone(); + let node_a = setup_node(&chain_source, config_a); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b], + Amount::from_sat(2_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + open_channel(&node_b, &node_c, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_event!(node_b, ChannelReady); + expect_event!(node_b, ChannelReady); + expect_event!(node_c, ChannelReady); + + strategy.set_path(build_probe_path(&node_a, &node_b, &node_c, PROBE_AMOUNT_MSAT)); + wait_for_channel_ready_to_send(&node_a, &node_b, PROBE_AMOUNT_MSAT + 1000).await; + wait_for_channel_ready_to_send(&node_b, &node_c, PROBE_AMOUNT_MSAT).await; + + let node_b_id = node_b.node_id(); + let node_b_addr = node_b.listening_addresses().unwrap().into_iter().next().unwrap(); + + strategy.start_probing(); + + // Dispatch a probe and isolate node_a from node_b before the failure can + // propagate back. Tight polling + in-process disconnect minimises the race + // window; on a lost race we reconnect and let the prober's next tick try. + let isolated = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if node_a.prober().unwrap().locked_msat() > 0 { + node_a.disconnect(node_b_id).ok(); + if node_a.prober().unwrap().locked_msat() > 0 { + return true; + } + node_a.connect(node_b_id, node_b_addr.clone(), false).ok(); + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .unwrap_or(false); + assert!(isolated, "could not preserve in-flight probe long enough to restart"); + strategy.stop_probing(); + + let locked_before = node_a.prober().unwrap().locked_msat(); + println!("Before restart: locked_msat = {}", locked_before); + assert!(locked_before > 0, "probe resolved before we could isolate node_a — flaky timing"); + + node_a.stop().unwrap(); + + // Restart node_a from the same persisted state. + let node_a = setup_node(&chain_source, restart_config); + + let locked_after = node_a.prober().unwrap().locked_msat(); + println!("After restart: locked_msat = {}", locked_after); + assert!( + locked_after > 0, + "locked_msat was not restored after restart (before={} after={})", + locked_before, + locked_after + ); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} + +/// Verifies that `locked_msat` never exceeds `max_locked_msat` across multiple probe cycles. +#[tokio::test(flavor = "multi_thread")] +async fn exhausted_probe_budget_blocks_new_probes() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let node_b = setup_node(&chain_source, random_config(false)); + let node_c = setup_node(&chain_source, random_config(false)); + + let mut config_a = random_config(false); + let strategy = FixedPathStrategy::new(); + let max_locked_msat = 2 * PROBE_AMOUNT_MSAT; + config_a.probing = Some( + ProbingConfigBuilder::custom(strategy.clone()) + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) + .max_locked_msat(max_locked_msat) + .build(), + ); + let node_a = setup_node(&chain_source, config_a); + + let addr_a = node_a.onchain_payment().new_address().unwrap(); + let addr_b = node_b.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr_a, addr_b], + Amount::from_sat(2_000_000), + ) + .await; + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + open_channel(&node_a, &node_b, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + node_b.sync_wallets().unwrap(); + open_channel(&node_b, &node_c, 1_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + node_c.sync_wallets().unwrap(); + + expect_channel_ready_event!(node_a, node_b.node_id()); + expect_event!(node_b, ChannelReady); + expect_event!(node_b, ChannelReady); + expect_event!(node_c, ChannelReady); + + assert_eq!(node_a.prober().map_or(1, |p| p.locked_msat()), 0, "initial locked_msat is nonzero"); + + strategy.set_path(build_probe_path(&node_a, &node_b, &node_c, PROBE_AMOUNT_MSAT)); + wait_for_channel_ready_to_send(&node_a, &node_b, PROBE_AMOUNT_MSAT + 1000).await; + wait_for_channel_ready_to_send(&node_b, &node_c, PROBE_AMOUNT_MSAT).await; + strategy.start_probing(); + + // Sample locked_msat across multiple probe cycles and assert the budget cap is never exceeded + let mut observed_locked = false; + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + while tokio::time::Instant::now() < deadline { + let msat = node_a.prober().map_or(0, |p| p.locked_msat()); + if msat > 0 { + observed_locked = true; + } + assert!( + msat <= max_locked_msat, + "locked_msat {msat} exceeded budget cap {max_locked_msat}" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + + assert!(observed_locked, "no probe was dispatched during the observation window"); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + node_c.stop().unwrap(); +} From da7c7eb9f0fa19723ae3976f8cb5fed0fde0eea8 Mon Sep 17 00:00:00 2001 From: Fmt Bot Date: Sun, 12 Jul 2026 02:09:15 +0000 Subject: [PATCH 078/138] 2026-07-12 automated rustfmt nightly --- src/builder.rs | 7 +++---- src/payment/store.rs | 3 ++- src/probing.rs | 42 ++++++++++++++++++++++-------------------- tests/probing_tests.rs | 7 ++----- 4 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 639838ff3a..a70b04b2ab 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -644,14 +644,13 @@ impl NodeBuilder { /// # #[cfg(not(feature = "uniffi"))] /// # { /// use std::time::Duration; - /// use ldk_node::Builder; + /// /// use ldk_node::probing::ProbingConfigBuilder; + /// use ldk_node::Builder; /// /// let mut builder = Builder::new(); /// builder.set_probing_config( - /// ProbingConfigBuilder::high_degree(100) - /// .interval(Duration::from_secs(30)) - /// .build() + /// ProbingConfigBuilder::high_degree(100).interval(Duration::from_secs(30)).build(), /// ); /// # } /// ``` diff --git a/src/payment/store.rs b/src/payment/store.rs index 38583dd7e7..d2b92747a2 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -926,9 +926,10 @@ mod tests { #[test] fn known_onchain_tx_type_survives_unknown_update() { - use bitcoin::hashes::Hash; use std::str::FromStr; + use bitcoin::hashes::Hash; + let txid = Txid::from_byte_array([8u8; 32]); let payment_id = PaymentId(txid.to_byte_array()); let pubkey = PublicKey::from_str( diff --git a/src/probing.rs b/src/probing.rs index 840a73a2e8..ecb8bf6891 100644 --- a/src/probing.rs +++ b/src/probing.rs @@ -34,14 +34,15 @@ //! # #[cfg(not(feature = "uniffi"))] //! # { //! use std::time::Duration; -//! use ldk_node::Builder; +//! //! use ldk_node::probing::ProbingConfigBuilder; +//! use ldk_node::Builder; //! //! let probing_config = ProbingConfigBuilder::high_degree(100) -//! .interval(Duration::from_secs(30)) -//! .max_locked_msat(500_000) -//! .diversity_penalty_msat(250) -//! .build(); +//! .interval(Duration::from_secs(30)) +//! .max_locked_msat(500_000) +//! .diversity_penalty_msat(250) +//! .build(); //! //! let mut builder = Builder::new(); //! builder.set_probing_config(probing_config); @@ -70,9 +71,9 @@ use std::time::{Duration, Instant}; use bitcoin::secp256k1::PublicKey; use lightning::ln::channelmanager::{PaymentId, RecentPaymentDetails}; use lightning::routing::gossip::NodeId; -use lightning::routing::router::Router as LdkRouter; use lightning::routing::router::{ - Path, PaymentParameters, RouteHop, RouteParameters, MAX_PATH_LENGTH_ESTIMATE, + Path, PaymentParameters, RouteHop, RouteParameters, Router as LdkRouter, + MAX_PATH_LENGTH_ESTIMATE, }; use lightning_invoice::DEFAULT_MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning_types::features::{ChannelFeatures, NodeFeatures}; @@ -123,14 +124,15 @@ impl fmt::Debug for ProbingStrategyKind { /// # #[cfg(not(feature = "uniffi"))] /// # { /// use std::time::Duration; -/// use ldk_node::Builder; +/// /// use ldk_node::probing::ProbingConfigBuilder; +/// use ldk_node::Builder; /// /// let config = ProbingConfigBuilder::high_degree(100) -/// .interval(Duration::from_secs(30)) -/// .max_locked_msat(500_000) -/// .diversity_penalty_msat(250) -/// .build(); +/// .interval(Duration::from_secs(30)) +/// .max_locked_msat(500_000) +/// .diversity_penalty_msat(250) +/// .build(); /// /// let mut builder = Builder::new(); /// builder.set_probing_config(config); @@ -143,16 +145,16 @@ impl fmt::Debug for ProbingStrategyKind { /// use ldk_node::probing::ProbingStrategy; /// /// struct FixedPathStrategy { -/// path: Path, +/// path: Path, /// } /// impl ProbingStrategy for FixedPathStrategy { -/// fn next_probe(&self) -> Option { -/// if self.path.hops.len() > 1 { -/// Some(self.path.clone()) -/// } else { -/// None -/// } -/// } +/// fn next_probe(&self) -> Option { +/// if self.path.hops.len() > 1 { +/// Some(self.path.clone()) +/// } else { +/// None +/// } +/// } /// } /// ``` #[derive(Clone, Debug)] diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index a26127d735..c5ed0226b5 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -21,22 +21,19 @@ mod common; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; use common::{ expect_channel_ready_event, expect_event, generate_blocks_and_wait, open_channel, premine_and_distribute_funds, random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_node, wait_for_channel_ready_to_send, TestNode, TestStoreType, }; - use ldk_node::bitcoin::Amount; use ldk_node::probing::{ProbingConfigBuilder, ProbingStrategy}; use ldk_node::Event; - use lightning::routing::router::Path; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - const PROBE_AMOUNT_MSAT: u64 = 1_000_000; const PROBING_INTERVAL_MILLISECONDS: u64 = 100; From dc237f3a4c58eb7fcf32a50f032844ac77b04a1a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 14 Jul 2026 14:07:53 +0200 Subject: [PATCH 079/138] Preserve peers on failed removal Persist the prospective peer set before publishing the in-memory removal. This keeps failed storage updates retryable and prevents the runtime view from diverging from durable state. AI tools were used in preparing this commit. Co-Authored-By: HAL 9000 --- src/peer_store.rs | 70 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/src/peer_store.rs b/src/peer_store.rs index 8037f93471..8345bf7111 100644 --- a/src/peer_store.rs +++ b/src/peer_store.rs @@ -58,11 +58,14 @@ where pub(crate) async fn remove_peer(&self, node_id: &PublicKey) -> Result<(), Error> { let _guard = self.mutation_lock.lock().await; let data = { - let mut locked_peers = self.peers.write().expect("lock"); - locked_peers.remove(node_id); - PeerStoreSerWrapper(&locked_peers).encode() + let locked_peers = self.peers.read().expect("lock"); + let mut updated_peers = locked_peers.clone(); + updated_peers.remove(node_id); + PeerStoreSerWrapper(&updated_peers).encode() }; - self.persist_peers(data).await + self.persist_peers(data).await?; + self.peers.write().expect("lock").remove(node_id); + Ok(()) } /// Returns the current in-memory peer set. @@ -170,12 +173,52 @@ mod tests { use std::str::FromStr; use std::sync::Arc; + use bitcoin::io; + use lightning::util::persist::{PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning::util::test_utils::TestLogger; use super::*; use crate::io::test_utils::InMemoryStore; use crate::types::DynStoreWrapper; + struct FailingStore; + + impl KVStore for FailingStore { + fn read( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, + ) -> impl std::future::Future, io::Error>> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "read failed")) } + } + + fn write( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, _buf: Vec, + ) -> impl std::future::Future> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "write failed")) } + } + + fn remove( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, _lazy: bool, + ) -> impl std::future::Future> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "remove failed")) } + } + + fn list( + &self, _primary_namespace: &str, _secondary_namespace: &str, + ) -> impl std::future::Future, io::Error>> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "list failed")) } + } + } + + impl PaginatedKVStore for FailingStore { + fn list_paginated( + &self, _primary_namespace: &str, _secondary_namespace: &str, + _page_token: Option, + ) -> impl std::future::Future> + 'static + Send + { + async { Err(io::Error::new(io::ErrorKind::Other, "list_paginated failed")) } + } + } + #[tokio::test] async fn peer_info_persistence() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); @@ -215,4 +258,23 @@ mod tests { assert_eq!(peers[0], expected_peer_info); assert_eq!(deser_peer_store.get_peer(&node_id), Some(expected_peer_info)); } + + #[tokio::test] + async fn remove_peer_does_not_mutate_memory_if_persist_fails() { + let store: Arc = Arc::new(DynStoreWrapper(FailingStore)); + let logger = Arc::new(TestLogger::new()); + let node_id = PublicKey::from_str( + "0276607124ebe6a6c9338517b6f485825b27c2dcc0b9fc2aa6a4c0df91194e5993", + ) + .unwrap(); + let peer_info = + PeerInfo { node_id, address: SocketAddress::from_str("127.0.0.1:9738").unwrap() }; + let mut peers = HashMap::new(); + peers.insert(node_id, peer_info.clone()); + let persisted_bytes = PeerStoreSerWrapper(&peers).encode(); + let peer_store = PeerStore::read(&mut &persisted_bytes[..], (store, logger)).unwrap(); + + assert_eq!(Err(Error::PersistenceFailed), peer_store.remove_peer(&node_id).await); + assert_eq!(Some(peer_info), peer_store.get_peer(&node_id)); + } } From 5e241f78d127c9a7a6b4d94217c6b3b6f4770202 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 14 Jul 2026 14:20:26 +0200 Subject: [PATCH 080/138] Remove force-close peers after reconnect Retain a force-closed peer until one recovery reconnect completes, then stop persisting it. This gives channel_reestablish a chance to drive recovery without retrying a peer indefinitely after its last channel has closed. AI tools were used in preparing this commit. Co-Authored-By: HAL 9000 --- src/connection.rs | 4 ++ src/event.rs | 100 +++++++++++++++++++++++++++++++++++++------- src/lib.rs | 10 ++--- tests/common/mod.rs | 7 ++-- 4 files changed, 96 insertions(+), 25 deletions(-) diff --git a/src/connection.rs b/src/connection.rs index b8946ffe3a..88135e841e 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -53,6 +53,10 @@ where self.do_connect_peer(node_id, addr).await } + pub(crate) fn disconnect_peer(&self, node_id: PublicKey) { + self.peer_manager.disconnect_by_node_id(node_id); + } + pub(crate) async fn do_connect_peer( &self, node_id: PublicKey, addr: SocketAddress, ) -> Result<(), Error> { diff --git a/src/event.rs b/src/event.rs index 91ab7b27de..89f7b555ec 100644 --- a/src/event.rs +++ b/src/event.rs @@ -34,7 +34,7 @@ use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::{PaymentHash, PaymentPreimage}; -use crate::config::{may_announce_channel, Config}; +use crate::config::{may_announce_channel, Config, PEER_RECONNECTION_INTERVAL}; use crate::connection::ConnectionManager; use crate::data_store::DataStoreUpdateResult; use crate::fee_estimator::ConfirmationTarget; @@ -583,6 +583,68 @@ where } } + fn remove_peer_after_reconnect(&self, peer_info: PeerInfo, closed_channel_id: ChannelId) { + let channel_manager = Arc::clone(&self.channel_manager); + let connection_manager = Arc::clone(&self.connection_manager); + let peer_store = Arc::clone(&self.peer_store); + let logger = self.logger.clone(); + self.runtime.spawn_cancellable_background_task(async move { + let has_other_channels = || { + channel_manager + .list_channels_with_counterparty(&peer_info.node_id) + .iter() + .any(|c| c.channel_id != closed_channel_id) + }; + + if peer_store.get_peer(&peer_info.node_id).is_none() || has_other_channels() { + return; + } + + // Ensure a connected peer cannot be mistaken for a completed recovery reconnect. + // With no other channels left, reconnecting once gives `channel_reestablish` a chance + // to retransmit the force-close error before we stop persisting the peer. + connection_manager.disconnect_peer(peer_info.node_id); + + loop { + if peer_store.get_peer(&peer_info.node_id).is_none() || has_other_channels() { + return; + } + + match connection_manager + .connect_peer_if_necessary(peer_info.node_id, peer_info.address.clone()) + .await + { + Ok(()) => { + if peer_store.get_peer(&peer_info.node_id).is_none() || has_other_channels() + { + return; + } + if let Err(e) = peer_store.remove_peer(&peer_info.node_id).await { + log_error!( + logger, + "Failed to remove peer {} from peer store: {}", + peer_info.node_id, + e + ); + } else { + return; + } + }, + Err(e) => { + log_debug!( + logger, + "Failed to reconnect peer {} before removing from peer store: {}", + peer_info.node_id, + e + ); + }, + } + + tokio::time::sleep(PEER_RECONNECTION_INTERVAL).await; + } + }); + } + async fn fail_claimable_payment( &self, payment_id: PaymentId, payment_hash: &PaymentHash, ) -> Result<(), ReplayEvent> { @@ -1627,25 +1689,24 @@ where let counterparty_node_id = counterparty_node_id .expect("counterparty_node_id is always set since LDK 0.0.117"); - // Drop the peer once its last channel with us has reached a terminal state - // that reconnection cannot recover. Every closure reason is terminal except - // `HolderForceClosed`: when *we* force-close, we keep reconnecting so that - // `channel_reestablish` can drive recovery (see `Node::close_channel_internal`). + // Drop the peer once its last channel with us has reached a terminal state. + // For `HolderForceClosed`, retain it through one recovery reconnect so that + // `channel_reestablish` can retransmit the force-close error before cleanup. // This also cleans up peers persisted for a channel that closed before funding // (e.g. `CounterpartyCoopClosedUnfundedChannel`), which would otherwise be // retried forever. // We exclude `channel_id` from the count because LDK emits `ChannelClosed` // before removing it from its internal list. - let dont_reconnect = !matches!(reason, ClosureReason::HolderForceClosed { .. }); - - if dont_reconnect { - let has_other_channels = self - .channel_manager - .list_channels_with_counterparty(&counterparty_node_id) - .iter() - .any(|c| c.channel_id != channel_id); - - if !has_other_channels { + let has_other_channels = self + .channel_manager + .list_channels_with_counterparty(&counterparty_node_id) + .iter() + .any(|c| c.channel_id != channel_id); + + let peer_to_reconnect = if !has_other_channels { + if matches!(reason, ClosureReason::HolderForceClosed { .. }) { + self.peer_store.get_peer(&counterparty_node_id) + } else { if let Err(e) = self.peer_store.remove_peer(&counterparty_node_id).await { log_error!( self.logger, @@ -1655,8 +1716,11 @@ where ); return Err(ReplayEvent()); } + None } - } + } else { + None + }; let event = Event::ChannelClosed { channel_id, @@ -1672,6 +1736,10 @@ where return Err(ReplayEvent()); }, }; + + if let Some(peer_info) = peer_to_reconnect { + self.remove_peer_after_reconnect(peer_info, channel_id); + } }, LdkEvent::DiscardFunding { channel_id, funding_info } => { if let FundingInfo::Contribution { inputs: _, outputs } = funding_info { diff --git a/src/lib.rs b/src/lib.rs index acfcbc0d48..3022755453 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2030,12 +2030,10 @@ impl Node { } // Peer store cleanup is handled centrally in the `ChannelClosed` event handler, - // which drops the peer once its last channel reaches a terminal state that - // reconnection cannot recover. We intentionally do nothing here so that a - // force-closed peer is retained, letting the background reconnection task keep - // firing and drive the `channel_reestablish` recovery flow. This is especially - // important against LND peers, which don't always handle force-closure error - // messages correctly. + // which retains a force-closed peer through one recovery reconnect before + // dropping it. This lets `channel_reestablish` drive the recovery flow, which is + // especially important against LND peers that don't always handle force-closure + // error messages correctly. } Ok(()) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index deb8790ea9..2c8e84c539 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1702,10 +1702,11 @@ pub(crate) async fn do_channel_full_cycle( } if force_close { - // Peer retained after local force-close to allow channel_reestablish recovery. + // The recovery reconnect completed while the force-close settled, so the peer no longer + // needs to remain persisted. assert!( - node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), - "node_b should remain persisted in node_a peer store after locally-initiated force-close" + !node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), + "node_b should be removed from node_a peer store after the recovery reconnect" ); assert_any_node_has_onchain_tx_type( &[("node_a", &node_a), ("node_b", &node_b)], From b34fb66e229efb83732c43d64cdd70200115afb9 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 14 Jul 2026 05:43:03 +0000 Subject: [PATCH 081/138] Do not record unilateral closes as payments Unilateral closes are not onchain payments. The real on-chain credits to our onchain balance after a unilateral close happen on `Sweep` and `Claim` transactions. Also, `UnilateralClose` payments previously never graduated from the `Pending` state, as no BDK events are emitted when these transactions confirm. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 2 +- tests/common/mod.rs | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index a13019df11..3e606d766e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1183,8 +1183,8 @@ impl Wallet { LdkTransactionType::InteractiveFunding { candidates } => { self.classify_interactive_funding(tx, candidates, tx_type.clone().into()).await }, + LdkTransactionType::UnilateralClose { .. } => Ok(()), LdkTransactionType::CooperativeClose { .. } - | LdkTransactionType::UnilateralClose { .. } | LdkTransactionType::AnchorBump { .. } | LdkTransactionType::Claim { .. } | LdkTransactionType::Sweep { .. } => { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index deb8790ea9..aeacef464b 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -438,7 +438,7 @@ fn assert_any_node_has_onchain_tx_type bool + Copy>( } fn assert_all_nodes_have_onchain_tx_type bool + Copy>( - nodes: &[(&str, &TestNode)], tx_type_name: &str, predicate: F, + nodes: &[(&str, &TestNode)], panic_msg: &str, tx_type_name: &str, predicate: F, ) { if nodes.iter().all(|(_, node)| has_onchain_tx_type(node, predicate)) { return; @@ -454,8 +454,8 @@ fn assert_all_nodes_have_onchain_tx_type bool + Copy> }) .collect(); panic!( - "Expected all nodes to have on-chain payment with tx_type {}; observed {:?}", - tx_type_name, observed + "Expected {}nodes to have on-chain payment with tx_type {}; observed {:?}", + panic_msg, tx_type_name, observed ); } @@ -1707,10 +1707,11 @@ pub(crate) async fn do_channel_full_cycle( node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted), "node_b should remain persisted in node_a peer store after locally-initiated force-close" ); - assert_any_node_has_onchain_tx_type( + assert_all_nodes_have_onchain_tx_type( &[("node_a", &node_a), ("node_b", &node_b)], + "no ", "UnilateralClose", - |tx_type| matches!(tx_type, TransactionType::UnilateralClose { .. }), + |tx_type| !matches!(tx_type, TransactionType::UnilateralClose { .. }), ); assert_any_node_has_onchain_tx_type( &[("node_a", &node_a), ("node_b", &node_b)], @@ -1720,6 +1721,7 @@ pub(crate) async fn do_channel_full_cycle( } else { assert_all_nodes_have_onchain_tx_type( &[("node_a", &node_a), ("node_b", &node_b)], + "all ", "CooperativeClose", |tx_type| matches!(tx_type, TransactionType::CooperativeClose { .. }), ); From 0bb047f7b54bedc9428400ffc065c5c91de5e834 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Jul 2026 12:41:36 +0200 Subject: [PATCH 082/138] Keep funding confirmed through test reorgs The test gave funding six confirmations while allowing six-block reorgs. That drops regular channel funding to zero confirmations and changes the scenario from a close reorg into a funding force-close. rust-lightning PR #4231 keeps trusted zero-conf channels open after funding is reorged out, but regular channels still force-close at zero confirmations. Mine one extra block so the deepest reorg leaves one confirmation. Preserve the CI counterexample that exposed this boundary. Co-Authored-By: HAL 9000 --- tests/reorg_test.proptest-regressions | 1 + tests/reorg_test.rs | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 tests/reorg_test.proptest-regressions diff --git a/tests/reorg_test.proptest-regressions b/tests/reorg_test.proptest-regressions new file mode 100644 index 0000000000..eba903fd03 --- /dev/null +++ b/tests/reorg_test.proptest-regressions @@ -0,0 +1 @@ +cc 06354c9b049db51c31557bf46d86a68bdd4049577cbd9190fb81c7824b18f0e6 # shrinks to reorg_depth = 6, force_close = false diff --git a/tests/reorg_test.rs b/tests/reorg_test.rs index 295d9fdd24..10f1d44511 100644 --- a/tests/reorg_test.rs +++ b/tests/reorg_test.rs @@ -76,7 +76,9 @@ proptest! { nodes_funding_tx.insert(node.node_id(), funding_txo); } - generate_blocks_and_wait(bitcoind, electrs, 6).await; + // Keep funding confirmed across the deepest reorg. rust-lightning PR #4231 exempts + // only trusted zero-conf channels; regular channels still force-close at zero confirmations. + generate_blocks_and_wait(bitcoind, electrs, 7).await; sync_wallets!(); reorg!(reorg_depth); From 5ddf27061226de95255ff20160608355d99e1824 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Jul 2026 12:43:26 +0200 Subject: [PATCH 083/138] Wait for replacement blocks after reorgs The block helper only compared heights. Replacing invalidated blocks with the same number of new blocks leaves the height unchanged, so it could return while Electrum still exposed the old chain. Wallet syncs then observed stale state and made reorg assertions timing-dependent. Require Electrum's target-height hash to match bitcoind's replacement block before returning. Co-Authored-By: HAL 9000 --- tests/common/mod.rs | 40 ++++++++++++++++----------------- tests/integration_tests_rust.rs | 4 ++-- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index aeacef464b..3e8b60fe43 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -753,7 +753,7 @@ pub(crate) async fn generate_blocks_and_wait( let address = bitcoind.new_address().expect("failed to get new address"); // TODO: expect this Result once the WouldBlock issue is resolved upstream. let _block_hashes_res = bitcoind.generate_to_address(num, &address); - wait_for_block(electrs, cur_height as usize + num).await; + wait_for_block(bitcoind, electrs, cur_height as usize + num).await; print!(" Done!"); println!("\n"); } @@ -773,27 +773,25 @@ pub(crate) fn invalidate_blocks(bitcoind: &BitcoindClient, num_blocks: usize) { assert!(new_cur_height + num_blocks == cur_height); } -pub(crate) async fn wait_for_block(electrs: &E, min_height: usize) { - let mut header = match electrs.block_headers_subscribe() { - Ok(header) => header, - Err(_) => { - // While subscribing should succeed the first time around, we ran into some cases where - // it didn't. Since we can't proceed without subscribing, we try again after a delay - // and panic if it still fails. - tokio::time::sleep(Duration::from_secs(3)).await; - electrs.block_headers_subscribe().expect("failed to subscribe to block headers") - }, - }; - loop { - if header.height >= min_height { - break; +pub(crate) async fn wait_for_block( + bitcoind: &BitcoindClient, electrs: &E, min_height: usize, +) { + let expected_block_hash = exponential_backoff_poll(|| { + let bitcoind_height = + bitcoind.get_blockchain_info().expect("failed to get blockchain info").blocks as usize; + if bitcoind_height < min_height { + return None; } - header = exponential_backoff_poll(|| { - electrs.ping().expect("failed to ping electrs"); - electrs.block_headers_pop().expect("failed to pop block header") - }) - .await; - } + bitcoind.get_block_hash(min_height as u64).ok()?.block_hash().ok() + }) + .await; + // A height-only wait can return the old header during a same-height reorg. Require the + // replacement hash so callers cannot sync against the stale chain by mistake. + exponential_backoff_poll(|| { + let header = electrs.block_header(min_height).ok()?; + (header.block_hash() == expected_block_hash).then_some(()) + }) + .await; } pub(crate) async fn wait_for_tx(electrs: &E, txid: Txid) { diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index b07a90629f..054b9d7588 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -807,7 +807,7 @@ async fn reorged_onchain_payment_returns_to_unconfirmed() { .call("generateblock", &[json!(replacement_address.to_string()), json!([])]) .expect("failed to generate empty block"); } - wait_for_block(&electrsd.client, original_height as usize + 1).await; + wait_for_block(&bitcoind.client, &electrsd.client, original_height as usize + 1).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -2056,7 +2056,7 @@ async fn splice_payment_reorged_to_unconfirmed() { .call("generateblock", &[json!(replacement_address.to_string()), json!([])]) .expect("failed to generate empty block"); } - wait_for_block(&electrsd.client, original_height as usize + 1).await; + wait_for_block(&bitcoind.client, &electrsd.client, original_height as usize + 1).await; node_b.sync_wallets().unwrap(); // The funding payment returns to `Unconfirmed` and stays `Pending`, exercising the From 79a97945526b226a70ff55344d596424c9dca0d8 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Jul 2026 12:45:11 +0200 Subject: [PATCH 084/138] Wait for actual funding outpoint spends The spend helper treated any script history as proof of a spend. The funding transaction itself already creates such a history entry, so the helper normally returned before Electrum indexed the closing transaction. A following reorg could therefore begin while the funding outpoint was still unspent. Wait until the exact transaction output leaves Electrum's unspent set. Co-Authored-By: HAL 9000 --- tests/common/mod.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 3e8b60fe43..3eb12a76aa 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -810,15 +810,14 @@ pub(crate) async fn wait_for_outpoint_spend(electrs: &E, outpoin let tx = electrs.transaction_get(&outpoint.txid).unwrap(); let txout_script = tx.output.get(outpoint.vout as usize).unwrap().clone().script_pubkey; - let is_spent = !electrs.script_get_history(&txout_script).unwrap().is_empty(); - if is_spent { - return; - } - + // Script history already contains the funding transaction itself, so wait until the exact + // funding outpoint leaves the unspent set instead of treating any history as a spend. exponential_backoff_poll(|| { electrs.ping().unwrap(); - let is_spent = !electrs.script_get_history(&txout_script).unwrap().is_empty(); + let is_spent = !electrs.script_list_unspent(&txout_script).unwrap().iter().any(|output| { + output.tx_hash == outpoint.txid && output.tx_pos == outpoint.vout as usize + }); is_spent.then_some(()) }) .await; From 87b780e9d968ed642524c79410fefe969036f804 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Jul 2026 12:49:33 +0200 Subject: [PATCH 085/138] Synchronize force-close sweep checks The force-close loop mined separately for each node even though every node shares one chain. Mining for the first node advanced later nodes past the exact intermediate state the test required. Sweep publication and Electrum indexing are also asynchronous, so immediate assertions could observe either valid state. Advance the shared chain once and poll every claimable sweep through broadcast and confirmation. Preserve the force-close counterexample that fails when only the funding-depth fix is applied. Co-Authored-By: HAL 9000 --- tests/reorg_test.proptest-regressions | 1 + tests/reorg_test.rs | 104 +++++++++++++++++--------- 2 files changed, 71 insertions(+), 34 deletions(-) diff --git a/tests/reorg_test.proptest-regressions b/tests/reorg_test.proptest-regressions index eba903fd03..74be29aeb1 100644 --- a/tests/reorg_test.proptest-regressions +++ b/tests/reorg_test.proptest-regressions @@ -1 +1,2 @@ cc 06354c9b049db51c31557bf46d86a68bdd4049577cbd9190fb81c7824b18f0e6 # shrinks to reorg_depth = 6, force_close = false +cc ffba5725835411b0948e834640dd37fc9a35696a301d7f2d8f2054f158bd2cae # shrinks to reorg_depth = 1, force_close = true diff --git a/tests/reorg_test.rs b/tests/reorg_test.rs index 10f1d44511..3a87ca7626 100644 --- a/tests/reorg_test.rs +++ b/tests/reorg_test.rs @@ -8,11 +8,27 @@ use proptest::prelude::prop; use proptest::proptest; use crate::common::{ - expect_event, generate_blocks_and_wait, invalidate_blocks, open_channel, - premine_and_distribute_funds, random_chain_source, random_config, setup_bitcoind_and_electrsd, - setup_node, wait_for_outpoint_spend, + expect_event, exponential_backoff_poll, generate_blocks_and_wait, invalidate_blocks, + open_channel, premine_and_distribute_funds, random_chain_source, random_config, + setup_bitcoind_and_electrsd, setup_node, wait_for_outpoint_spend, wait_for_tx, }; +async fn wait_for_pending_sweep_balance( + node: &ldk_node::Node, mut matches_balance: F, +) -> PendingSweepBalance +where + F: FnMut(&PendingSweepBalance) -> bool, +{ + exponential_backoff_poll(|| { + node.sync_wallets().unwrap(); + node.list_balances() + .pending_balances_from_channel_closures + .into_iter() + .find(|balance| matches_balance(balance)) + }) + .await +} + proptest! { #![proptest_config(proptest::test_runner::Config::with_cases(5))] #[test] @@ -146,40 +162,60 @@ proptest! { sync_wallets!(); if force_close { - for node in &nodes { - node.sync_wallets().unwrap(); - // If there is no more balance, there is nothing to process here. - if node.list_balances().lightning_balances.len() < 1 { - return; - } - match node.list_balances().lightning_balances[0] { - LightningBalance::ClaimableAwaitingConfirmations { - confirmation_height, - .. - } => { - let cur_height = node.status().current_best_block.height; - let blocks_to_go = confirmation_height - cur_height; - generate_blocks_and_wait(bitcoind, electrs, blocks_to_go as usize).await; - node.sync_wallets().unwrap(); - }, - _ => panic!("Unexpected balance state for node_hub!"), - } + let claimable_nodes = nodes + .iter() + .filter_map(|node| { + node.list_balances().lightning_balances.iter().find_map(|balance| { + match balance { + LightningBalance::ClaimableAwaitingConfirmations { + confirmation_height, + .. + } => Some((node, *confirmation_height)), + _ => None, + } + }) + }) + .collect::>(); + let confirmation_height = claimable_nodes + .iter() + .map(|(_, confirmation_height)| *confirmation_height) + .max() + .expect("Missing claimable force-close balance"); + let cur_height = nodes[0].status().current_best_block.height; + let blocks_to_go = confirmation_height.saturating_sub(cur_height); + if blocks_to_go > 0 { + generate_blocks_and_wait(bitcoind, electrs, blocks_to_go as usize).await; + sync_wallets!(); + } - assert!(node.list_balances().lightning_balances.len() < 2); - assert!(node.list_balances().pending_balances_from_channel_closures.len() > 0); - match node.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => {}, - _ => panic!("Unexpected balance state!"), + // Mining for one node advances the shared chain for every node. Mature all + // claimable outputs together, wait for every sweep to reach the mempool, then + // confirm them and wait for `AwaitingThresholdConfirmations`. + for (node, _) in &claimable_nodes { + let pending_balance = wait_for_pending_sweep_balance(node, |balance| { + matches!( + balance, + PendingSweepBalance::BroadcastAwaitingConfirmation { .. } + | PendingSweepBalance::AwaitingThresholdConfirmations { .. } + ) + }) + .await; + if let PendingSweepBalance::BroadcastAwaitingConfirmation { + latest_spending_txid, + .. + } = pending_balance + { + wait_for_tx(electrs, latest_spending_txid).await; } + } - generate_blocks_and_wait(&bitcoind, electrs, 1).await; - node.sync_wallets().unwrap(); - assert!(node.list_balances().lightning_balances.len() < 2); - assert!(node.list_balances().pending_balances_from_channel_closures.len() > 0); - match node.list_balances().pending_balances_from_channel_closures[0] { - PendingSweepBalance::AwaitingThresholdConfirmations { .. } => {}, - _ => panic!("Unexpected balance state!"), - } + generate_blocks_and_wait(bitcoind, electrs, 1).await; + sync_wallets!(); + for (node, _) in &claimable_nodes { + wait_for_pending_sweep_balance(node, |balance| { + matches!(balance, PendingSweepBalance::AwaitingThresholdConfirmations { .. }) + }) + .await; } } From 202474948014c5d9b43ba17b31263704717a7c54 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 00:27:51 +0000 Subject: [PATCH 086/138] Use a vec of length 1 to broadcast unrelated txs `BroadcasterInterface::broadcast_transactions` requires that any passed vector containing multiple transactions must be a single child together with its parents. We will lean on this contract in upcoming commits, so here we fix a case where we broke this contract. --- src/wallet/mod.rs | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3e606d766e..d500d4d013 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -326,23 +326,20 @@ impl Wallet { } } - if !unconfirmed_outbound_txids.is_empty() { - let txs_to_broadcast: Vec = unconfirmed_outbound_txids - .iter() - .filter_map(|txid| { - locked_wallet.tx_details(*txid).map(|d| (*d.tx).clone()) - }) - .collect(); - - if !txs_to_broadcast.is_empty() { - let tx_count = txs_to_broadcast.len(); - self.broadcaster.broadcast_unclassified_transactions(txs_to_broadcast); - log_info!( - self.logger, - "Rebroadcast {} unconfirmed transactions on chain tip change", - tx_count - ); - } + let count: usize = unconfirmed_outbound_txids + .into_iter() + .filter_map(|txid| { + let tx = locked_wallet.tx_details(txid).map(|d| (*d.tx).clone())?; + self.broadcaster.broadcast_unclassified_transactions(vec![tx]); + Some(()) + }) + .count(); + if count != 0 { + log_info!( + self.logger, + "Rebroadcast {} unconfirmed transactions on chain tip change", + count, + ); } }, WalletEvent::TxUnconfirmed { txid, tx, .. } => { From 4c8ecb28303f07ca7aebd42d1e9195dc074feedd Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Sat, 27 Jun 2026 19:43:51 +0000 Subject: [PATCH 087/138] Fix anchor reserves when splicing in all funds We stop requiring that splice-ins leave an anchor reserve for an additional anchor channel on top of the existing set of anchor channels; after splice-ins, our anchor reserve only needs to cover the existing set of anchor channels. --- src/lib.rs | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index acfcbc0d48..e216f82fdf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1251,7 +1251,7 @@ impl Node { FundingAmount::Exact { amount_sats } => { // Check funds availability after connection (includes anchor reserve // calculation). - self.check_sufficient_funds_for_channel(amount_sats, &peer_info.node_id)?; + self.check_sufficient_onchain_funds(amount_sats, &peer_info.node_id, true)?; amount_sats }, FundingAmount::Max => { @@ -1357,33 +1357,37 @@ impl Node { Ok(new_channel_anchor_reserve_sats(&self.config, peer_node_id, anchor_channel)) } - fn check_sufficient_funds_for_channel( - &self, amount_sats: u64, peer_node_id: &PublicKey, + fn check_sufficient_onchain_funds( + &self, amount_sats: u64, peer_node_id: &PublicKey, for_new_channel: bool, ) -> Result<(), Error> { + let action_str = if for_new_channel { "create channel" } else { "splice-in" }; let cur_anchor_reserve_sats = total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); let spendable_amount_sats = self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); - // Fail early if we have less than the channel value available. if spendable_amount_sats < amount_sats { - log_error!(self.logger, - "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", - spendable_amount_sats, amount_sats + log_error!( + self.logger, + "Unable to {} due to insufficient funds. Available: {}sats, Required: {}sats", + action_str, + spendable_amount_sats, + amount_sats ); return Err(Error::InsufficientFunds); } - // Fail if we have less than the channel value + anchor reserve available (if applicable). - let required_funds_sats = - amount_sats + self.new_channel_anchor_reserve_sats(peer_node_id)?; + if for_new_channel { + let required_funds_sats = + amount_sats + self.new_channel_anchor_reserve_sats(peer_node_id)?; - if spendable_amount_sats < required_funds_sats { - log_error!(self.logger, - "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", - spendable_amount_sats, required_funds_sats - ); - return Err(Error::InsufficientFunds); + if spendable_amount_sats < required_funds_sats { + log_error!(self.logger, + "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", + spendable_amount_sats, required_funds_sats + ); + return Err(Error::InsufficientFunds); + } } Ok(()) @@ -1659,7 +1663,7 @@ impl Node { }, }; - self.check_sufficient_funds_for_channel(splice_amount_sats, &counterparty_node_id)?; + self.check_sufficient_onchain_funds(splice_amount_sats, &counterparty_node_id, false)?; let funding_template = self .channel_manager From 2c2c2870fe755fd01641046a6801af51d3bd7154 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Fri, 26 Jun 2026 19:37:40 +0000 Subject: [PATCH 088/138] Reserve onchain funds for anchor channels when peer sets them optional When we are preparing to open a channel to a peer, we should reserve onchain funds for an anchor channel when the peer's init features signals anchor channels as optional, as channel negotiation with such a peer can result in an anchor channel. Tests written with codex. --- src/lib.rs | 2 +- src/liquidity/service/lsps2.rs | 4 +- tests/integration_tests_rust.rs | 157 +++++++++++++++++++++++++++++++- 3 files changed, 159 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e216f82fdf..e82edf625c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1353,7 +1353,7 @@ impl Node { .peer_by_node_id(peer_node_id) .ok_or(Error::ConnectionFailed)? .init_features; - let anchor_channel = init_features.requires_anchors_zero_fee_htlc_tx(); + let anchor_channel = init_features.supports_anchors_zero_fee_htlc_tx(); Ok(new_channel_anchor_reserve_sats(&self.config, peer_node_id, anchor_channel)) } diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index 1143a08d73..524157a671 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -454,7 +454,7 @@ where self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); let required_funds_sats = channel_amount_sats + self.config.anchor_channels_config.as_ref().map_or(0, |c| { - if init_features.requires_anchors_zero_fee_htlc_tx() + if init_features.supports_anchors_zero_fee_htlc_tx() && !c.trusted_peers_no_reserve.contains(&their_network_key) { c.per_channel_reserve_sats @@ -465,7 +465,7 @@ where if spendable_amount_sats < required_funds_sats { log_error!(self.logger, "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", - spendable_amount_sats, channel_amount_sats + spendable_amount_sats, required_funds_sats, ); // TODO: We just silently fail here. Eventually we will need to remember // the pending requests and regularly retry opening the channel until we diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index b07a90629f..dffb8386f8 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -36,7 +36,7 @@ use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, TransactionType, UnifiedPaymentResult, }; -use ldk_node::{BuildError, Builder, Event, Node, NodeError}; +use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -3937,6 +3937,161 @@ async fn open_channel_with_all_with_anchors() { node_b.stop().unwrap(); } +#[derive(Clone, Copy)] +enum OpenChannelVariant { + Standard, + Announced, + ZeroReserve, + StandardWithAll, + AnnouncedWithAll, + ZeroReserveWithAll, +} + +impl OpenChannelVariant { + fn label(&self) -> &'static str { + match self { + Self::Standard => "open_channel", + Self::Announced => "open_announced_channel", + Self::ZeroReserve => "open_0reserve_channel", + Self::StandardWithAll => "open_channel_with_all", + Self::AnnouncedWithAll => "open_announced_channel_with_all", + Self::ZeroReserveWithAll => "open_0reserve_channel_with_all", + } + } +} + +fn open_channel_variant( + variant: OpenChannelVariant, node_a: &Node, node_b: &Node, channel_amount_sats: u64, +) -> Result<(), NodeError> { + let address = node_b.listening_addresses().unwrap().first().unwrap().clone(); + match variant { + OpenChannelVariant::Standard => node_a + .open_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .map(|_| ()), + OpenChannelVariant::Announced => node_a + .open_announced_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .map(|_| ()), + OpenChannelVariant::ZeroReserve => node_a + .open_0reserve_channel(node_b.node_id(), address, channel_amount_sats, None, None) + .map(|_| ()), + OpenChannelVariant::StandardWithAll => { + node_a.open_channel_with_all(node_b.node_id(), address, None, None).map(|_| ()) + }, + OpenChannelVariant::AnnouncedWithAll => node_a + .open_announced_channel_with_all(node_b.node_id(), address, None, None) + .map(|_| ()), + OpenChannelVariant::ZeroReserveWithAll => { + node_a.open_0reserve_channel_with_all(node_b.node_id(), address, None, None).map(|_| ()) + }, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn open_channel_variants_reserve_funds_for_anchor_peers() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let chain_source = random_chain_source(&bitcoind, &electrsd); + + let exact_variants = [ + OpenChannelVariant::Standard, + OpenChannelVariant::Announced, + OpenChannelVariant::ZeroReserve, + ]; + let with_all_variants = [ + OpenChannelVariant::StandardWithAll, + OpenChannelVariant::AnnouncedWithAll, + OpenChannelVariant::ZeroReserveWithAll, + ]; + + let premine_amount_sat = 1_000_000; + let exact_channel_amount_sat = premine_amount_sat - 10_000; + let anchor_reserve_sat = 25_000; + + let mut addresses = Vec::new(); + let mut exact_cases = Vec::new(); + for variant in exact_variants { + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + addresses.push(node_a.onchain_payment().new_address().unwrap()); + addresses.push(node_b.onchain_payment().new_address().unwrap()); + exact_cases.push((variant, node_a, node_b)); + } + + let mut with_all_cases = Vec::new(); + for variant in with_all_variants { + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + addresses.push(node_a.onchain_payment().new_address().unwrap()); + addresses.push(node_b.onchain_payment().new_address().unwrap()); + with_all_cases.push((variant, node_a, node_b)); + } + + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + addresses, + Amount::from_sat(premine_amount_sat), + ) + .await; + + for (_, node_a, node_b) in exact_cases.iter().chain(with_all_cases.iter()) { + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + assert_eq!(node_b.list_balances().spendable_onchain_balance_sats, premine_amount_sat); + } + + for (variant, node_a, node_b) in exact_cases { + assert_eq!( + Err(NodeError::InsufficientFunds), + open_channel_variant(variant, &node_a, &node_b, exact_channel_amount_sat), + "{} should require funds for the channel amount plus anchor reserve", + variant.label() + ); + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } + + let mut opened_with_all_cases = Vec::new(); + for (variant, node_a, node_b) in with_all_cases { + open_channel_variant(variant, &node_a, &node_b, 0) + .unwrap_or_else(|e| panic!("{} failed: {e:?}", variant.label())); + + let funding_txo_a = expect_channel_pending_event!(node_a, node_b.node_id()); + let funding_txo_b = expect_channel_pending_event!(node_b, node_a.node_id()); + assert_eq!(funding_txo_a, funding_txo_b, "{} funding txo mismatch", variant.label()); + wait_for_tx(&electrsd.client, funding_txo_a.txid).await; + + opened_with_all_cases.push((variant, node_a, node_b, funding_txo_a)); + } + + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + + for (variant, node_a, node_b, funding_txo) in opened_with_all_cases { + node_a.sync_wallets().unwrap(); + node_b.sync_wallets().unwrap(); + + let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); + let _user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); + + let balances = node_a.list_balances(); + assert_eq!(balances.total_onchain_balance_sats, anchor_reserve_sat - 1); + assert_eq!(balances.total_anchor_channels_reserve_sats, anchor_reserve_sat - 1); + assert_eq!(balances.spendable_onchain_balance_sats, 0); + + let channels = node_a.list_channels(); + assert_eq!(channels.len(), 1, "{} should have one channel", variant.label()); + let channel = &channels[0]; + // Also subtract the fees spent to open the channel + assert_eq!(channel.channel_value_sats, premine_amount_sat - anchor_reserve_sat - 155); + assert_eq!(channel.counterparty.node_id, node_b.node_id()); + assert!(channel.counterparty.features.supports_anchors_zero_fee_htlc_tx()); + assert!(!channel.counterparty.features.requires_anchors_zero_fee_htlc_tx()); + assert_eq!(channel.funding_txo.unwrap(), funding_txo); + assert_eq!(channel.reserve_type, Some(ReserveType::Adaptive)); + + node_a.stop().unwrap(); + node_b.stop().unwrap(); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn open_channel_with_all_without_anchors() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From fac8a1f5d88825b13de9d52933d1a91b028cb11f Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Tue, 30 Jun 2026 17:06:05 +0000 Subject: [PATCH 089/138] Remove the ability to disable anchor channels We previously allowed users to disable anchor channels and drain their anchor reserve while still having anchor channels open or pending resolution. This was acceptable for keyed anchor channels, as the commitment transaction therein still contained some fees, and had some chance of getting mined into a block without any anchor bumps. In upcoming commits, we will add support for 0FC channels, and their commitment transactions have zero fees and depend entirely on the anchor reserve to reach miners and get confirmed in a block. It is thus dangerous to disable anchor channels and drain the reserve after 0FC channels have been opened. Therefore, we make `AnchorChannelsConfig` required, and prevent this case from ever happening. --- CHANGELOG.md | 2 + benches/payments.rs | 9 +- src/config.rs | 20 +-- src/event.rs | 40 ++--- src/lib.rs | 43 +++--- src/liquidity/service/lsps2.rs | 20 +-- src/types.rs | 20 +-- tests/common/mod.rs | 31 ++-- tests/common/scenarios/mod.rs | 2 +- tests/integration_tests_hrn.rs | 2 +- tests/integration_tests_migration.rs | 4 +- tests/integration_tests_postgres.rs | 4 +- tests/integration_tests_rust.rs | 218 +++++++++------------------ tests/integration_tests_vss.rs | 4 +- tests/probing_tests.rs | 24 +-- tests/reorg_test.rs | 11 +- 16 files changed, 163 insertions(+), 291 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 115b0ed058..b231e8d1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ - `EsploraSyncConfig` and `ElectrumSyncConfig` now support `force_wallet_full_scan`. When set, the on-chain wallet keeps using BDK `full_scan` instead of incremental sync until a full scan succeeds, allowing restored wallets to rediscover funds sent to previously-unknown addresses. +- `Config::anchor_channels_config` is no longer optional, hence anchor channels can no longer be + disabled. We still negotiate legacy channels if the peer does not support anchor channels. ## Bug Fixes and Improvements - Building a fresh node against a Bitcoin Core RPC or REST chain source that fails to return the diff --git a/benches/payments.rs b/benches/payments.rs index 52769d7949..926dc5dade 100644 --- a/benches/payments.rs +++ b/benches/payments.rs @@ -121,13 +121,8 @@ fn payment_benchmark(c: &mut Criterion) { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes_with_store( - &chain_source, - false, - true, - false, - common::TestStoreType::Sqlite, - ); + let (node_a, node_b) = + setup_two_nodes_with_store(&chain_source, false, false, common::TestStoreType::Sqlite); let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(4).enable_all().build().unwrap(); diff --git a/src/config.rs b/src/config.rs index f168df94ef..b446ee2982 100644 --- a/src/config.rs +++ b/src/config.rs @@ -143,7 +143,7 @@ pub(crate) const LNURL_AUTH_TIMEOUT_SECS: u64 = 15; /// | `node_alias` | None | /// | `trusted_peers_0conf` | [] | /// | `probing_liquidity_limit_multiplier` | 3 | -/// | `anchor_channels_config` | Some(..) | +/// | `anchor_channels_config` | AnchorChannelsConfig::default() | /// | `route_parameters` | None | /// | `tor_config` | None | /// | `hrn_config` | HumanReadableNamesConfig::default() | @@ -190,19 +190,7 @@ pub struct Config { /// `option_anchors_zero_fee_htlc_tx` channel type is negotiated. /// /// Please refer to [`AnchorChannelsConfig`] for further information on Anchor channels. - /// - /// If set to `Some`, we'll try to open new channels with Anchors enabled, i.e., new channels - /// will be negotiated with the `option_anchors_zero_fee_htlc_tx` channel type if supported by - /// the counterparty. Note that this won't prevent us from opening non-Anchor channels if the - /// counterparty doesn't support `option_anchors_zero_fee_htlc_tx`. If set to `None`, new - /// channels will be negotiated with the legacy `option_static_remotekey` channel type only. - /// - /// **Note:** If set to `None` *after* some Anchor channels have already been - /// opened, no dedicated emergency on-chain reserve will be maintained for these channels, - /// which can be dangerous if only insufficient funds are available at the time of channel - /// closure. We *will* however still try to get the Anchor spending transactions confirmed - /// on-chain with the funds available. - pub anchor_channels_config: Option, + pub anchor_channels_config: AnchorChannelsConfig, /// Configuration options for payment routing and pathfinding. /// /// Setting the [`RouteParametersConfig`] provides flexibility to customize how payments are routed, @@ -233,7 +221,7 @@ impl Default for Config { announcement_addresses: None, trusted_peers_0conf: Vec::new(), probing_liquidity_limit_multiplier: DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER, - anchor_channels_config: Some(AnchorChannelsConfig::default()), + anchor_channels_config: AnchorChannelsConfig::default(), tor_config: None, route_parameters: None, node_alias: None, @@ -418,8 +406,6 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { // will mostly be relevant for inbound channels. let mut user_config = UserConfig::default(); user_config.channel_handshake_limits.force_announced_channel_preference = false; - user_config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = - config.anchor_channels_config.is_some(); user_config.reject_inbound_splices = false; if may_announce_channel(config).is_err() { diff --git a/src/event.rs b/src/event.rs index 91ab7b27de..1fb148bd12 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1267,24 +1267,6 @@ where } let anchor_channel = channel_type.requires_anchors_zero_fee_htlc_tx(); - if anchor_channel && self.config.anchor_channels_config.is_none() { - log_error!( - self.logger, - "Rejecting inbound channel from peer {} due to Anchor channels being disabled.", - counterparty_node_id, - ); - self.channel_manager - .force_close_broadcasting_latest_txn( - &temporary_channel_id, - &counterparty_node_id, - "Channel request rejected".to_string(), - ) - .unwrap_or_else(|e| { - log_error!(self.logger, "Failed to reject channel: {:?}", e) - }); - return Ok(()); - } - let required_reserve_sats = crate::new_channel_anchor_reserve_sats( &self.config, &counterparty_node_id, @@ -1750,19 +1732,17 @@ where .. } => { // Skip bumping channel closes if our counterparty is trusted. - if let Some(anchor_channels_config) = - self.config.anchor_channels_config.as_ref() + if self + .config + .anchor_channels_config + .trusted_peers_no_reserve + .contains(counterparty_node_id) { - if anchor_channels_config - .trusted_peers_no_reserve - .contains(counterparty_node_id) - { - log_debug!(self.logger, - "Ignoring BumpTransactionEvent::ChannelClose for channel {} due to trusted counterparty {}", - channel_id, counterparty_node_id - ); - return Ok(()); - } + log_debug!(self.logger, + "Ignoring BumpTransactionEvent::ChannelClose for channel {} due to trusted counterparty {}", + channel_id, counterparty_node_id + ); + return Ok(()); } }, BumpTransactionEvent::HTLCResolution { .. } => {}, diff --git a/src/lib.rs b/src/lib.rs index e82edf625c..0d50977e53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1169,7 +1169,7 @@ impl Node { self.channel_manager .list_channels() .into_iter() - .map(|c| ChannelDetails::from_ldk(c, self.config.anchor_channels_config.as_ref())) + .map(|c| ChannelDetails::from_ldk(c, &self.config.anchor_channels_config)) .collect() } @@ -2406,21 +2406,20 @@ impl_writeable_tlv_based!(NodeMetrics, { pub(crate) fn total_anchor_channels_reserve_sats( channel_manager: &ChannelManager, config: &Config, ) -> u64 { - config.anchor_channels_config.as_ref().map_or(0, |anchor_channels_config| { - channel_manager - .list_channels() - .into_iter() - .filter(|c| { - !anchor_channels_config.trusted_peers_no_reserve.contains(&c.counterparty.node_id) - && c.channel_shutdown_state - .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) - && c.channel_type - .as_ref() - .map_or(false, |t| t.requires_anchors_zero_fee_htlc_tx()) - }) - .count() as u64 - * anchor_channels_config.per_channel_reserve_sats - }) + channel_manager + .list_channels() + .into_iter() + .filter(|c| { + !config + .anchor_channels_config + .trusted_peers_no_reserve + .contains(&c.counterparty.node_id) + && c.channel_shutdown_state + .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) + && c.channel_type.as_ref().map_or(false, |t| t.requires_anchors_zero_fee_htlc_tx()) + }) + .count() as u64 + * config.anchor_channels_config.per_channel_reserve_sats } pub(crate) fn new_channel_anchor_reserve_sats( @@ -2430,13 +2429,11 @@ pub(crate) fn new_channel_anchor_reserve_sats( return 0; } - config.anchor_channels_config.as_ref().map_or(0, |c| { - if c.trusted_peers_no_reserve.contains(peer_node_id) { - 0 - } else { - c.per_channel_reserve_sats - } - }) + if config.anchor_channels_config.trusted_peers_no_reserve.contains(peer_node_id) { + 0 + } else { + config.anchor_channels_config.per_channel_reserve_sats + } } #[cfg(test)] diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index 524157a671..ca70cd8d8e 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -453,15 +453,17 @@ where let spendable_amount_sats = self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); let required_funds_sats = channel_amount_sats - + self.config.anchor_channels_config.as_ref().map_or(0, |c| { - if init_features.supports_anchors_zero_fee_htlc_tx() - && !c.trusted_peers_no_reserve.contains(&their_network_key) - { - c.per_channel_reserve_sats - } else { - 0 - } - }); + + if init_features.supports_anchors_zero_fee_htlc_tx() + && !self + .config + .anchor_channels_config + .trusted_peers_no_reserve + .contains(&their_network_key) + { + self.config.anchor_channels_config.per_channel_reserve_sats + } else { + 0 + }; if spendable_amount_sats < required_funds_sats { log_error!(self.logger, "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", diff --git a/src/types.rs b/src/types.rs index e24db4d253..2ac08a48f8 100644 --- a/src/types.rs +++ b/src/types.rs @@ -646,24 +646,16 @@ pub struct ChannelDetails { impl ChannelDetails { pub(crate) fn from_ldk( - value: LdkChannelDetails, anchor_channels_config: Option<&AnchorChannelsConfig>, + value: LdkChannelDetails, anchor_channels_config: &AnchorChannelsConfig, ) -> Self { let reserve_type = value.channel_type.as_ref().map(|channel_type| { if channel_type.supports_anchors_zero_fee_htlc_tx() { - if let Some(config) = anchor_channels_config { - if config.trusted_peers_no_reserve.contains(&value.counterparty.node_id) { - ReserveType::TrustedPeersNoReserve - } else { - ReserveType::Adaptive - } + if anchor_channels_config + .trusted_peers_no_reserve + .contains(&value.counterparty.node_id) + { + ReserveType::TrustedPeersNoReserve } else { - // Edge case: if `AnchorChannelsConfig` was previously set and later - // removed, we can no longer distinguish whether this anchor channel's - // reserve was `Adaptive` or `TrustedPeersNoReserve`. We default to - // `Adaptive` here, which may incorrectly override a prior - // `TrustedPeersNoReserve` designation. This is acceptable since - // unsetting `AnchorChannelsConfig` on a node with existing anchor - // channels is not an expected operation. ReserveType::Adaptive } } else { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index aeacef464b..5026780de0 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -379,13 +379,9 @@ pub(crate) fn random_node_alias() -> Option { Some(NodeAlias(bytes)) } -pub(crate) fn random_config(anchor_channels: bool) -> TestConfig { +pub(crate) fn random_config() -> TestConfig { let mut node_config = Config::default(); - if !anchor_channels { - node_config.anchor_channels_config = None; - } - node_config.network = Network::Regtest; println!("Setting network: {}", node_config.network); @@ -594,24 +590,22 @@ pub(crate) use setup_builder; pub(crate) mod scenarios; pub(crate) fn setup_two_nodes( - chain_source: &TestChainSource, allow_0conf: bool, anchor_channels: bool, - anchors_trusted_no_reserve: bool, + chain_source: &TestChainSource, allow_0conf: bool, anchors_trusted_no_reserve: bool, ) -> (TestNode, TestNode) { setup_two_nodes_with_store( chain_source, allow_0conf, - anchor_channels, anchors_trusted_no_reserve, TestStoreType::TestSyncStore, ) } pub(crate) fn setup_two_nodes_with_store( - chain_source: &TestChainSource, allow_0conf: bool, anchor_channels: bool, - anchors_trusted_no_reserve: bool, store_type: TestStoreType, + chain_source: &TestChainSource, allow_0conf: bool, anchors_trusted_no_reserve: bool, + store_type: TestStoreType, ) -> (TestNode, TestNode) { println!("== Node A =="); - let mut config_a = random_config(anchor_channels); + let mut config_a = random_config(); config_a.store_type = store_type; if cfg!(hrn_tests) { @@ -622,7 +616,7 @@ pub(crate) fn setup_two_nodes_with_store( let node_a = setup_node(chain_source, config_a); println!("\n== Node B =="); - let mut config_b = random_config(anchor_channels); + let mut config_b = random_config(); config_b.store_type = store_type; if cfg!(hrn_tests) { @@ -637,14 +631,8 @@ pub(crate) fn setup_two_nodes_with_store( if allow_0conf { config_b.node_config.trusted_peers_0conf.push(node_a.node_id()); } - if anchor_channels && anchors_trusted_no_reserve { - config_b - .node_config - .anchor_channels_config - .as_mut() - .unwrap() - .trusted_peers_no_reserve - .push(node_a.node_id()); + if anchors_trusted_no_reserve { + config_b.node_config.anchor_channels_config.trusted_peers_no_reserve.push(node_a.node_id()); } let node_b = setup_node(chain_source, config_b); (node_a, node_b) @@ -1199,7 +1187,8 @@ pub(crate) async fn do_channel_full_cycle( let node_b_anchor_reserve_sat = if node_b .config() .anchor_channels_config - .map_or(true, |acc| acc.trusted_peers_no_reserve.contains(&node_a.node_id())) + .trusted_peers_no_reserve + .contains(&node_a.node_id()) { 0 } else { diff --git a/tests/common/scenarios/mod.rs b/tests/common/scenarios/mod.rs index 7cbf56b8e1..ffbfc2b007 100644 --- a/tests/common/scenarios/mod.rs +++ b/tests/common/scenarios/mod.rs @@ -90,7 +90,7 @@ pub(crate) async fn wait_for_htlcs_settled( /// Build a fresh LDK node configured for interop tests. Uses electrum at the /// docker-compose default port and bumps sync timeouts for combo stress. pub(crate) fn setup_ldk_node() -> Node { - let config = crate::common::random_config(true); + let config = crate::common::random_config(); let mut builder = ldk_node::Builder::from_config(config.node_config); let mut sync_config = ldk_node::config::ElectrumSyncConfig::default(); sync_config.timeouts_config.onchain_wallet_sync_timeout_secs = 180; diff --git a/tests/integration_tests_hrn.rs b/tests/integration_tests_hrn.rs index 9102400398..6e758105a2 100644 --- a/tests/integration_tests_hrn.rs +++ b/tests/integration_tests_hrn.rs @@ -24,7 +24,7 @@ async fn unified_send_to_hrn() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premined_sats = 5_000_000; diff --git a/tests/integration_tests_migration.rs b/tests/integration_tests_migration.rs index c4e63451a8..7e5767dca6 100644 --- a/tests/integration_tests_migration.rs +++ b/tests/integration_tests_migration.rs @@ -148,7 +148,7 @@ async fn migrate_node_across_all_backends() { let connection_string = test_connection_string(); // Set up node B, the Lightning counterparty. - let config_b = common::random_config(false); + let config_b = common::random_config(); let node_b_instance = BackendInstance::new( MigrationBackend::Postgres, &config_b.node_config.storage_dir_path, @@ -167,7 +167,7 @@ async fn migrate_node_across_all_backends() { // Spin up the node we'll migrate on the first backend. The same node config (storage dir, // listening addresses, identity) is reused across every hop — only the backend changes — so // each backend's store lives in its own subdirectory of the one storage dir. - let config = common::random_config(false); + let config = common::random_config(); let node_entropy = config.node_entropy; let node_config = config.node_config; let base_dir = node_config.storage_dir_path.clone(); diff --git a/tests/integration_tests_postgres.rs b/tests/integration_tests_postgres.rs index 0c93c705c2..889d681ba4 100644 --- a/tests/integration_tests_postgres.rs +++ b/tests/integration_tests_postgres.rs @@ -22,7 +22,7 @@ async fn channel_full_cycle_with_postgres_store() { let (bitcoind, electrsd) = common::setup_bitcoind_and_electrsd(); println!("== Node A =="); let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); - let config_a = common::random_config(true); + let config_a = common::random_config(); let mut builder_a = Builder::from_config(config_a.node_config); builder_a.set_chain_source_esplora(esplora_url.clone(), None); let node_a = builder_a @@ -37,7 +37,7 @@ async fn channel_full_cycle_with_postgres_store() { node_a.start().unwrap(); println!("\n== Node B =="); - let config_b = common::random_config(true); + let config_b = common::random_config(); let mut builder_b = Builder::from_config(config_b.node_config); builder_b.set_chain_source_esplora(esplora_url.clone(), None); let node_b = builder_b diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index dffb8386f8..7852e73b76 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -77,7 +77,7 @@ async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { async fn channel_full_cycle() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = TestChainSource::BitcoindRpcSync(&bitcoind); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); do_channel_full_cycle( node_a, node_b, @@ -95,7 +95,7 @@ async fn channel_full_cycle() { async fn channel_full_cycle_force_close() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); do_channel_full_cycle( node_a, node_b, @@ -113,7 +113,7 @@ async fn channel_full_cycle_force_close() { async fn channel_full_cycle_force_close_trusted_no_reserve() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, true); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, true); do_channel_full_cycle( node_a, node_b, @@ -136,7 +136,7 @@ async fn peer_removed_when_counterparty_force_closes_last_channel() { // than reconnected to forever. let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premine_amount_sat = 5_000_000; @@ -181,7 +181,7 @@ async fn peer_removed_when_counterparty_force_closes_last_channel() { async fn channel_full_cycle_0conf() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, true, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, true, false); do_channel_full_cycle( node_a, node_b, @@ -195,29 +195,11 @@ async fn channel_full_cycle_0conf() { .await; } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn channel_full_cycle_legacy_staticremotekey() { - let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, false, false); - do_channel_full_cycle( - node_a, - node_b, - &bitcoind.client, - &electrsd.client, - false, - false, - false, - false, - ) - .await; -} - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle_0reserve() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); do_channel_full_cycle( node_a, node_b, @@ -235,7 +217,7 @@ async fn channel_full_cycle_0reserve() { async fn channel_full_cycle_0conf_0reserve() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, true, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, true, false); do_channel_full_cycle( node_a, node_b, @@ -253,7 +235,7 @@ async fn channel_full_cycle_0conf_0reserve() { async fn channel_open_fails_when_funds_insufficient() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -293,7 +275,7 @@ async fn multi_hop_sending() { // Setup and fund 5 nodes let mut nodes = Vec::new(); for _ in 0..5 { - let config = random_config(true); + let config = random_config(); let mut sync_config = EsploraSyncConfig::default(); sync_config.background_sync_config = None; setup_builder!(builder, config.node_config); @@ -388,8 +370,8 @@ async fn multi_hop_sending() { async fn split_underpaid_bolt11_payment() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); - let node_c = setup_node(&chain_source, random_config(true)); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); + let node_c = setup_node(&chain_source, random_config()); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -482,7 +464,7 @@ async fn split_underpaid_bolt11_payment() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn start_stop_reinit() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let config = random_config(true); + let config = random_config(); let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); @@ -555,7 +537,7 @@ async fn start_stop_reinit() { async fn onchain_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -760,7 +742,7 @@ async fn onchain_send_receive() { async fn reorged_onchain_payment_returns_to_unconfirmed() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -828,7 +810,7 @@ async fn reorged_onchain_payment_returns_to_unconfirmed() { async fn onchain_send_all_retains_reserve() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); // Setup nodes let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -914,7 +896,7 @@ async fn onchain_wallet_recovery() { let chain_source = random_chain_source(&bitcoind, &electrsd); - let original_config = random_config(true); + let original_config = random_config(); let original_node_entropy = original_config.node_entropy; let original_node = setup_node(&chain_source, original_config); @@ -955,7 +937,7 @@ async fn onchain_wallet_recovery() { drop(original_node); // Now we start from scratch, only the seed remains the same. - let mut recovered_config = random_config(true); + let mut recovered_config = random_config(); recovered_config.node_entropy = original_node_entropy; recovered_config.wallet_rescan_from_height = Some(0); let recovered_node = setup_node(&chain_source, recovered_config); @@ -997,7 +979,7 @@ async fn onchain_wallet_force_full_scan_rediscovers_esplora_funds() { premine_blocks(&bitcoind.client, &electrsd.client).await; - let address_source_config = random_config(true); + let address_source_config = random_config(); let node_entropy = address_source_config.node_entropy; let address_source_node = setup_node(&chain_source, address_source_config); let addr_1 = address_source_node.onchain_payment().new_address().unwrap(); @@ -1006,7 +988,7 @@ async fn onchain_wallet_force_full_scan_rediscovers_esplora_funds() { drop(address_source_node); let premine_amount_sat = 100_000; - let mut stale_config = random_config(true); + let mut stale_config = random_config(); stale_config.node_entropy = node_entropy; stale_config.store_type = TestStoreType::Sqlite; let stale_node = setup_node(&chain_source, stale_config.clone()); @@ -1077,7 +1059,7 @@ async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( ) { let configured_stop_gap = DEFAULT_FULL_SCAN_STOP_GAP + 5; - let address_source_config = random_config(true); + let address_source_config = random_config(); let node_entropy = address_source_config.node_entropy; let address_source_node = setup_node(&chain_source, address_source_config); let mut far_address = None; @@ -1099,7 +1081,7 @@ async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( wait_for_tx(&electrsd.client, txid).await; generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; - let mut default_gap_config = random_config(true); + let mut default_gap_config = random_config(); default_gap_config.node_entropy = node_entropy.clone(); let default_gap_node = setup_node(&chain_source, default_gap_config); default_gap_node.sync_wallets().unwrap(); @@ -1111,7 +1093,7 @@ async fn do_onchain_wallet_full_scan_stop_gap_recovers_far_funds( default_gap_node.stop().unwrap(); drop(default_gap_node); - let mut configured_gap_config = random_config(true); + let mut configured_gap_config = random_config(); configured_gap_config.node_entropy = node_entropy; configured_gap_config.full_scan_stop_gap = Some(configured_stop_gap); let configured_gap_node = setup_node(&chain_source, configured_gap_config); @@ -1146,7 +1128,7 @@ async fn onchain_wallet_recovery_rescans_from_birthday_height() { premine_blocks(&bitcoind.client, &electrsd.client).await; // Step 1: bring up an "original" node at the birthday height and generate addresses. - let original_config = random_config(true); + let original_config = random_config(); let original_node_entropy = original_config.node_entropy; let original_node = setup_node(&chain_source, original_config); @@ -1192,7 +1174,7 @@ async fn onchain_wallet_recovery_rescans_from_birthday_height() { // Step 5: restart a fresh node with only the seed and no rescan height. It must NOT see // the funds, because its wallet birthday sits above the funding transactions. - let mut pinned_config = random_config(true); + let mut pinned_config = random_config(); pinned_config.node_entropy = original_node_entropy; let pinned_node = setup_node(&chain_source, pinned_config); pinned_node.sync_wallets().unwrap(); @@ -1206,7 +1188,7 @@ async fn onchain_wallet_recovery_rescans_from_birthday_height() { // Step 6: restart with a rescan height set to the birthday height. Funds must be // re-discovered. - let mut recovered_config = random_config(true); + let mut recovered_config = random_config(); recovered_config.node_entropy = original_node_entropy; recovered_config.wallet_rescan_from_height = Some(birthday_height); let recovered_node = setup_node(&chain_source, recovered_config); @@ -1229,7 +1211,7 @@ async fn build_fails_when_wallet_rescan_height_is_above_tip() { .try_into() .unwrap(); - let config = random_config(false); + let config = random_config(); let entropy = config.node_entropy; setup_builder!(builder, config.node_config); @@ -1256,7 +1238,7 @@ async fn build_aborts_on_first_startup_bitcoind_tip_fetch_failure() { // A fresh node pointed at an unreachable bitcoind RPC endpoint must not silently // fall back to genesis as the wallet birthday. The build must abort cleanly so the // misconfiguration surfaces immediately. - let config = random_config(false); + let config = random_config(); let entropy = config.node_entropy; setup_builder!(builder, config.node_config); @@ -1302,17 +1284,16 @@ async fn run_rbf_test(is_insert_block: bool) { let chain_source_esplora = TestChainSource::Esplora(&electrsd); macro_rules! config_node { - ($chain_source:expr, $anchor_channels:expr) => {{ - let config_a = random_config($anchor_channels); + ($chain_source:expr) => {{ + let config_a = random_config(); let node = setup_node(&$chain_source, config_a); node }}; } - let anchor_channels = false; let nodes = vec![ - config_node!(chain_source_electrsd, anchor_channels), - config_node!(chain_source_bitcoind, anchor_channels), - config_node!(chain_source_esplora, anchor_channels), + config_node!(chain_source_electrsd), + config_node!(chain_source_bitcoind), + config_node!(chain_source_esplora), ]; let (bitcoind, electrs) = (&bitcoind.client, &electrsd.client); @@ -1421,7 +1402,7 @@ async fn run_rbf_test(is_insert_block: bool) { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn sign_verify_msg() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let config = random_config(true); + let config = random_config(); let chain_source = random_chain_source(&bitcoind, &electrsd); let node = setup_node(&chain_source, config); @@ -1436,7 +1417,7 @@ async fn sign_verify_msg() { async fn connection_multi_listen() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, false, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let node_id_b = node_b.node_id(); @@ -1456,7 +1437,7 @@ async fn connection_restart_behavior() { async fn do_connection_restart_behavior(persist: bool) { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, false, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let node_id_a = node_a.node_id(); let node_id_b = node_b.node_id(); @@ -1503,7 +1484,7 @@ async fn do_connection_restart_behavior(persist: bool) { async fn concurrent_connections_succeed() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let node_a = Arc::new(node_a); let node_b = Arc::new(node_b); @@ -1531,7 +1512,7 @@ async fn splice_channel() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -1746,7 +1727,7 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -1944,7 +1925,7 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) { async fn funding_payment_graduates_without_channel_ready() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -1999,7 +1980,7 @@ async fn funding_payment_graduates_without_channel_ready() { async fn splice_payment_reorged_to_unconfirmed() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -2076,7 +2057,7 @@ async fn splice_payment_reorged_to_unconfirmed() { async fn splice_in_rbf_joins_counterparty_splice() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let address_b = node_b.onchain_payment().new_address().unwrap(); @@ -2125,7 +2106,7 @@ async fn splice_in_rbf_joins_counterparty_splice() { async fn simple_bolt12_send_receive() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premine_amount_sat = 5_000_000; @@ -2367,7 +2348,7 @@ async fn async_payment() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let mut config_sender = random_config(true); + let mut config_sender = random_config(); config_sender.node_config.listening_addresses = None; config_sender.node_config.node_alias = None; config_sender.log_writer = @@ -2375,20 +2356,20 @@ async fn async_payment() { config_sender.async_payments_role = Some(AsyncPaymentsRole::Client); let node_sender = setup_node(&chain_source, config_sender); - let mut config_sender_lsp = random_config(true); + let mut config_sender_lsp = random_config(); config_sender_lsp.log_writer = TestLogWriter::Custom(Arc::new(MultiNodeLogger::new("sender_lsp ".to_string()))); config_sender_lsp.async_payments_role = Some(AsyncPaymentsRole::Server); let node_sender_lsp = setup_node(&chain_source, config_sender_lsp); - let mut config_receiver_lsp = random_config(true); + let mut config_receiver_lsp = random_config(); config_receiver_lsp.log_writer = TestLogWriter::Custom(Arc::new(MultiNodeLogger::new("receiver_lsp".to_string()))); config_receiver_lsp.async_payments_role = Some(AsyncPaymentsRole::Server); let node_receiver_lsp = setup_node(&chain_source, config_receiver_lsp); - let mut config_receiver = random_config(true); + let mut config_receiver = random_config(); config_receiver.node_config.listening_addresses = None; config_receiver.node_config.node_alias = None; config_receiver.log_writer = @@ -2500,7 +2481,7 @@ async fn test_node_announcement_propagation() { let chain_source = random_chain_source(&bitcoind, &electrsd); // Node A will use both listening and announcement addresses - let mut config_a = random_config(true); + let mut config_a = random_config(); let node_a_alias_string = "ldk-node-a".to_string(); let mut node_a_alias_bytes = [0u8; 32]; node_a_alias_bytes[..node_a_alias_string.as_bytes().len()] @@ -2512,7 +2493,7 @@ async fn test_node_announcement_propagation() { config_a.node_config.announcement_addresses = Some(node_a_announcement_addresses.clone()); // Node B will only use listening addresses - let mut config_b = random_config(true); + let mut config_b = random_config(); let node_b_alias_string = "ldk-node-b".to_string(); let mut node_b_alias_bytes = [0u8; 32]; node_b_alias_bytes[..node_b_alias_string.as_bytes().len()] @@ -2597,7 +2578,7 @@ async fn generate_bip21_uri() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premined_sats = 5_000_000; @@ -2651,7 +2632,7 @@ async fn generate_bip21_uri() { async fn unified_receive_rejects_msat_overflow() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let node = setup_node(&chain_source, random_config(true)); + let node = setup_node(&chain_source, random_config()); assert_eq!( Err(NodeError::InvalidAmount), @@ -2664,7 +2645,7 @@ async fn unified_send_receive_bip21_uri() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premined_sats = 5_000_000; @@ -2801,7 +2782,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { disable_client_reserve: false, }; - let service_config = random_config(true); + let service_config = random_config(); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); service_builder.enable_liquidity_provider(lsps2_service_config); @@ -2811,14 +2792,14 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { let service_node_id = service_node.node_id(); let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); - let client_config = random_config(true); + let client_config = random_config(); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); client_builder.add_liquidity_source(service_node_id, service_addr, None, true); let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); client_node.start().unwrap(); - let payer_config = random_config(true); + let payer_config = random_config(); setup_builder!(payer_builder, payer_config.node_config); payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); @@ -3006,7 +2987,7 @@ async fn facade_logging() { let chain_source = random_chain_source(&bitcoind, &electrsd); let logger = init_log_logger(LevelFilter::Trace); - let mut config = random_config(false); + let mut config = random_config(); config.log_writer = TestLogWriter::LogFacade; println!("== Facade logging starts =="); @@ -3022,7 +3003,7 @@ async fn facade_logging() { async fn spontaneous_send_with_custom_preimage() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let address_a = node_a.onchain_payment().new_address().unwrap(); let premine_sat = 1_000_000; @@ -3089,7 +3070,7 @@ async fn spontaneous_send_with_custom_preimage() { async fn drop_in_async_context() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let config = random_config(true); + let config = random_config(); let node = setup_node(&chain_source, config); node.stop().unwrap(); } @@ -3120,7 +3101,7 @@ async fn lsps2_client_trusts_lsp() { disable_client_reserve: false, }; - let service_config = random_config(true); + let service_config = random_config(); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); service_builder.enable_liquidity_provider(lsps2_service_config); @@ -3129,7 +3110,7 @@ async fn lsps2_client_trusts_lsp() { let service_node_id = service_node.node_id(); let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); - let client_config = random_config(true); + let client_config = random_config(); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); client_builder.add_liquidity_source(service_node_id, service_addr.clone(), None, true); @@ -3137,7 +3118,7 @@ async fn lsps2_client_trusts_lsp() { client_node.start().unwrap(); let client_node_id = client_node.node_id(); - let payer_config = random_config(true); + let payer_config = random_config(); setup_builder!(payer_builder, payer_config.node_config); payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); @@ -3295,7 +3276,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { disable_client_reserve: false, }; - let service_config = random_config(true); + let service_config = random_config(); setup_builder!(service_builder, service_config.node_config); service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); service_builder.enable_liquidity_provider(lsps2_service_config); @@ -3305,7 +3286,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { let service_node_id = service_node.node_id(); let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); - let client_config = random_config(true); + let client_config = random_config(); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); client_builder.add_liquidity_source(service_node_id, service_addr.clone(), None, true); @@ -3314,7 +3295,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { let client_node_id = client_node.node_id(); - let payer_config = random_config(true); + let payer_config = random_config(); setup_builder!(payer_builder, payer_config.node_config); payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); @@ -3405,7 +3386,7 @@ async fn payment_persistence_after_restart() { // Setup nodes manually so we can restart node_a with the same config println!("== Node A =="); - let mut config_a = random_config(true); + let mut config_a = random_config(); config_a.store_type = TestStoreType::Sqlite; let num_payments = 200; @@ -3415,7 +3396,7 @@ async fn payment_persistence_after_restart() { let node_a = setup_node(&chain_source, config_a.clone()); println!("\n== Node B =="); - let config_b = random_config(true); + let config_b = random_config(); let node_b = setup_node(&chain_source, config_b); let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -3697,7 +3678,7 @@ async fn fs_store_persistence_backwards_compatibility() { async fn onchain_fee_bump_rbf() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); // Fund both nodes let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -3839,7 +3820,7 @@ async fn onchain_fee_bump_rbf() { async fn onchain_fee_bump_rbf_respects_anchor_reserve() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -3888,7 +3869,7 @@ async fn onchain_fee_bump_rbf_respects_anchor_reserve() { async fn open_channel_with_all_with_anchors() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -4009,7 +3990,7 @@ async fn open_channel_variants_reserve_funds_for_anchor_peers() { let mut addresses = Vec::new(); let mut exact_cases = Vec::new(); for variant in exact_variants { - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); addresses.push(node_a.onchain_payment().new_address().unwrap()); addresses.push(node_b.onchain_payment().new_address().unwrap()); exact_cases.push((variant, node_a, node_b)); @@ -4017,7 +3998,7 @@ async fn open_channel_variants_reserve_funds_for_anchor_peers() { let mut with_all_cases = Vec::new(); for variant in with_all_variants { - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); addresses.push(node_a.onchain_payment().new_address().unwrap()); addresses.push(node_b.onchain_payment().new_address().unwrap()); with_all_cases.push((variant, node_a, node_b)); @@ -4092,62 +4073,11 @@ async fn open_channel_variants_reserve_funds_for_anchor_peers() { } } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn open_channel_with_all_without_anchors() { - let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); - let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, false, false); - - let addr_a = node_a.onchain_payment().new_address().unwrap(); - let addr_b = node_b.onchain_payment().new_address().unwrap(); - - let premine_amount_sat = 1_000_000; - - premine_and_distribute_funds( - &bitcoind.client, - &electrsd.client, - vec![addr_a, addr_b], - Amount::from_sat(premine_amount_sat), - ) - .await; - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, premine_amount_sat); - - let funding_txo = open_channel_with_all(&node_a, &node_b, false, &electrsd).await; - - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - - node_a.sync_wallets().unwrap(); - node_b.sync_wallets().unwrap(); - - let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id()); - let _user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); - - // Without anchors, there should be no remaining balance - let remaining_balance = node_a.list_balances().spendable_onchain_balance_sats; - assert_eq!( - remaining_balance, 0, - "Remaining balance {remaining_balance} should be zero without anchor reserve" - ); - - // Verify a channel was opened with all the funds accounting for fees - let channels = node_a.list_channels(); - assert_eq!(channels.len(), 1); - let channel = &channels[0]; - assert!(channel.channel_value_sats > premine_amount_sat - 500); - assert_eq!(channel.counterparty.node_id, node_b.node_id()); - assert_eq!(channel.funding_txo.unwrap(), funding_txo); - - node_a.stop().unwrap(); - node_b.stop().unwrap(); -} - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn splice_in_with_all_balance() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false); + let (node_a, node_b) = setup_two_nodes(&chain_source, false, false); let addr_a = node_a.onchain_payment().new_address().unwrap(); let addr_b = node_b.onchain_payment().new_address().unwrap(); @@ -4248,7 +4178,7 @@ async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { client_trusts_lsp: true, disable_client_reserve: false, }; - let cheap_node_config = random_config(true); + let cheap_node_config = random_config(); setup_builder!(cheap_builder, cheap_node_config.node_config); cheap_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); cheap_builder.enable_liquidity_provider(cheap_cfg); @@ -4271,7 +4201,7 @@ async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { client_trusts_lsp: true, disable_client_reserve: false, }; - let expensive_node_config = random_config(true); + let expensive_node_config = random_config(); setup_builder!(expensive_builder, expensive_node_config.node_config); expensive_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); expensive_builder.enable_liquidity_provider(expensive_cfg); @@ -4281,7 +4211,7 @@ async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { let expensive_addr = expensive.listening_addresses().unwrap().first().unwrap().clone(); // Client knows both LSPs. Registration order is varied to confirm selection isn't order-based. - let client_config = random_config(true); + let client_config = random_config(); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); if reverse_order { diff --git a/tests/integration_tests_vss.rs b/tests/integration_tests_vss.rs index 210e9a8b25..f0838585f7 100644 --- a/tests/integration_tests_vss.rs +++ b/tests/integration_tests_vss.rs @@ -20,7 +20,7 @@ async fn channel_full_cycle_with_vss_store() { let (bitcoind, electrsd) = common::setup_bitcoind_and_electrsd(); println!("== Node A =="); let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); - let config_a = common::random_config(true); + let config_a = common::random_config(); let mut builder_a = Builder::from_config(config_a.node_config); builder_a.set_chain_source_esplora(esplora_url.clone(), None); let vss_base_url = std::env::var("TEST_VSS_BASE_URL").unwrap(); @@ -35,7 +35,7 @@ async fn channel_full_cycle_with_vss_store() { node_a.start().unwrap(); println!("\n== Node B =="); - let config_b = common::random_config(true); + let config_b = common::random_config(); let mut builder_b = Builder::from_config(config_b.node_config); builder_b.set_chain_source_esplora(esplora_url.clone(), None); let node_b = builder_b diff --git a/tests/probing_tests.rs b/tests/probing_tests.rs index c5ed0226b5..f0480bc5e9 100644 --- a/tests/probing_tests.rs +++ b/tests/probing_tests.rs @@ -123,10 +123,10 @@ async fn probe_budget_increments_and_decrements() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let node_b = setup_node(&chain_source, random_config(false)); - let node_c = setup_node(&chain_source, random_config(false)); + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); - let mut config_a = random_config(false); + let mut config_a = random_config(); let strategy = FixedPathStrategy::new(); config_a.probing = Some( ProbingConfigBuilder::custom(strategy.clone()) @@ -216,10 +216,10 @@ async fn locked_msat_accounts_for_routing_fees() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let node_b = setup_node(&chain_source, random_config(false)); - let node_c = setup_node(&chain_source, random_config(false)); + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); - let mut config_a = random_config(false); + let mut config_a = random_config(); let strategy = FixedPathStrategy::new(); config_a.probing = Some( ProbingConfigBuilder::custom(strategy.clone()) @@ -305,10 +305,10 @@ async fn probing_budget_restored_after_node_restart() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let node_b = setup_node(&chain_source, random_config(false)); - let node_c = setup_node(&chain_source, random_config(false)); + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); - let mut config_a = random_config(false); + let mut config_a = random_config(); // Use a pure on-disk store so state survives the restart. config_a.store_type = TestStoreType::Sqlite; let strategy = FixedPathStrategy::new(); @@ -406,10 +406,10 @@ async fn exhausted_probe_budget_blocks_new_probes() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let chain_source = random_chain_source(&bitcoind, &electrsd); - let node_b = setup_node(&chain_source, random_config(false)); - let node_c = setup_node(&chain_source, random_config(false)); + let node_b = setup_node(&chain_source, random_config()); + let node_c = setup_node(&chain_source, random_config()); - let mut config_a = random_config(false); + let mut config_a = random_config(); let strategy = FixedPathStrategy::new(); let max_locked_msat = 2 * PROBE_AMOUNT_MSAT; config_a.probing = Some( diff --git a/tests/reorg_test.rs b/tests/reorg_test.rs index 295d9fdd24..e44d50cb07 100644 --- a/tests/reorg_test.rs +++ b/tests/reorg_test.rs @@ -29,17 +29,16 @@ proptest! { let chain_source_c = random_chain_source(&bitcoind, &electrsd); macro_rules! config_node { - ($chain_source: expr, $anchor_channels: expr) => {{ - let config_a = random_config($anchor_channels); + ($chain_source: expr) => {{ + let config_a = random_config(); let node = setup_node(&$chain_source, config_a); node }}; } - let anchor_channels = true; let nodes = vec![ - config_node!(chain_source_a, anchor_channels), - config_node!(chain_source_b, anchor_channels), - config_node!(chain_source_c, anchor_channels), + config_node!(chain_source_a), + config_node!(chain_source_b), + config_node!(chain_source_c), ]; let (bitcoind, electrs) = (&bitcoind.client, &electrsd.client); From 8963226784a4a2f5da0128c865e5c977e7c2f98a Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 28 May 2026 01:28:46 +0000 Subject: [PATCH 090/138] Add configuration knob to enable 0FC channels In upcoming commits we will read this knob to determine whether to negotiate 0FC channels. For now, we make a best-effort attempt to make sure the configured chain source supports 0FC channels if this knob is set. Do this roundtrip at the same time we make a roundtrip to retrieve the feerates to keep startup as fast as possible. --- bindings/ldk_node.udl | 1 + src/chain/bitcoind.rs | 49 +++++++++++++++++++++++++++++++++++++++++ src/chain/electrum.rs | 47 +++++++++++++++++++++++++++++++++++++++ src/chain/esplora.rs | 25 +++++++++++++++++++++ src/chain/mod.rs | 51 +++++++++++++++++++++++++++++++++++++++++++ src/config.rs | 28 +++++++++++++++++++----- src/error.rs | 5 +++++ src/lib.rs | 38 ++++++++++++++++++++++++++++++-- 8 files changed, 237 insertions(+), 7 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index d7e9a774f9..c1a926f2fd 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -246,6 +246,7 @@ enum NodeError { "LnurlAuthFailed", "LnurlAuthTimeout", "InvalidLnurl", + "ChainSourceNotSupported", }; typedef dictionary NodeStatus; diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 6bfa8ffd27..0899d8dcac 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -119,6 +119,30 @@ impl BitcoindChainSource { self.api_client.utxo_source() } + pub(super) async fn validate_zero_fee_commitments_support(&self) -> Result<(), Error> { + let node_version_result = tokio::time::timeout( + Duration::from_secs(CHAIN_POLLING_TIMEOUT_SECS), + self.api_client.get_node_version(), + ) + .await + .map_err(|e| { + log_error!(self.logger, "Failed to get node version: {:?}", e); + Error::ConnectionFailed + })?; + + let node_version = node_version_result.map_err(|e| { + log_error!(self.logger, "Failed to get node version: {:?}", e); + Error::ConnectionFailed + })?; + + // v26 first shipped the `submitpackage` RPC, but we need v29 to relay ephemeral dust + if node_version < 290000 { + log_error!(self.logger, "Bitcoin backend MUST be greater than or equal to v29"); + return Err(Error::ChainSourceNotSupported); + } + Ok(()) + } + pub(super) async fn continuously_sync_wallets( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, @@ -748,6 +772,31 @@ impl BitcoindClient { } } + pub(crate) async fn get_node_version(&self) -> Result { + match self { + BitcoindClient::Rpc { rpc_client, .. } => { + Self::get_node_version_inner(Arc::clone(rpc_client)) + .await + .map_err(BitcoindClientError::Rpc) + }, + BitcoindClient::Rest { rpc_client, .. } => { + // Bitcoin Core's REST interface does not support `getnetworkinfo` + // so we use the RPC client. + Self::get_node_version_inner(Arc::clone(rpc_client)) + .await + .map_err(BitcoindClientError::Rpc) + }, + } + } + + async fn get_node_version_inner(rpc_client: Arc) -> Result { + rpc_client.call_method::("getnetworkinfo", &[]).await.and_then(|value| { + value["version"].as_u64().ok_or(RpcClientError::InvalidData(String::from( + "The version field in the `getnetworkinfo` response should be a u64", + ))) + }) + } + /// Broadcasts the provided transaction. pub(crate) async fn broadcast_transaction( &self, tx: &Transaction, diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index e255158ca9..7a505e0451 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -306,6 +306,53 @@ impl ElectrumChainSource { Ok(()) } + pub(crate) async fn validate_zero_fee_commitments_support(&self) -> Result<(), Error> { + let electrum_client: Arc = if let Some(client) = + self.electrum_runtime_status.read().expect("lock").client().as_ref() + { + Arc::clone(client) + } else { + debug_assert!( + false, + "We should have started the chain source before checking submitpackage support" + ); + return Err(Error::ConnectionFailed); + }; + + // TODO: Use `protocol_version` API once shipped in + // https://github.com/bitcoindevkit/rust-electrum-client/pull/213. + // + // This could still accept an Electrum server running against Bitcoin Core v26 + // through v28, which does not relay ephemeral dust. + let spawn_fut = electrum_client.runtime.spawn_blocking({ + let electrum_client = Arc::clone(&electrum_client.electrum_client); + move || electrum_client.transaction_broadcast_package(&super::dummy_package()) + }); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), + spawn_fut, + ); + + match timeout_fut.await { + Ok(Ok(Ok(_))) => Ok(()), + Ok(Ok(Err( + e @ (electrum_client::Error::Protocol(_) + | electrum_client::Error::AllAttemptsErrored(_)), + ))) => { + log_error!(self.logger, "Electrum server does not support submitpackage: {:?}", e); + Err(Error::ChainSourceNotSupported) + }, + e => { + log_error!( + self.logger, + "Failed to check support for submitpackage on the Electrum server: {:?}", + e + ); + Err(Error::ConnectionFailed) + }, + } + } + pub(crate) async fn process_broadcast_package(&self, package: Vec) { let electrum_client: Arc = if let Some(client) = self.electrum_runtime_status.read().expect("lock").client().as_ref() diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index b46fe183ce..2fba634ab6 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -83,6 +83,31 @@ impl EsploraChainSource { }) } + pub(super) async fn validate_zero_fee_commitments_support(&self) -> Result<(), Error> { + // This could still accept an Esplora server running against Bitcoin Core v26 + // through v28, which does not relay ephemeral dust. + self.esplora_client.submit_package(&super::dummy_package(), None, None).await.map_err( + |e| { + if let esplora_client::Error::HttpResponse { status: 404, message } = e { + log_error!( + self.logger, + "Esplora server does not support submitpackage: {}", + message + ); + Error::ChainSourceNotSupported + } else { + log_error!( + self.logger, + "Failed to check support for submitpackage on the Esplora server: {}", + e + ); + Error::ConnectionFailed + } + }, + )?; + Ok(()) + } + pub(super) async fn sync_onchain_wallet( &self, onchain_wallet: Arc, ) -> Result<(), Error> { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 8a8115e4f5..59ae0c64de 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -29,6 +29,37 @@ use crate::runtime::Runtime; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; +/// We use this parent-child TRUC package to make sure the configured chain source supports +/// broadcasting packages via the `submitpackage` Bitcoin Core RPC. +const PARENT_TXID: &str = "9a015f93fac6cb203c2b994e18b85176eb0354a22a468255516f3c6002d3f696"; +const PARENT_HEX: &str = + "0300000000010160d0cdb72f2ddf719f40ca32f44614c67577fc75996140544003915683c34a310000000000fd\ + ffffff0201000000000000000451024e73876100000000000022512042731375894dad3b25092cd0f713dc5bee4\ + a71e30a95e1db3d880906d7eba1fa01409327942924218e4eb1635a7cce6706fcb37b8bbb61a2f0b86357356681\ + 4e09419a3501e02252043bb237d479304632282fe9159db9e9a6ae6ec5bedea9f0f115a97b0e00"; +const CHILD_TXID: &str = "d011b3ff78cdfb8b93822639ea87771847936b04bb83afc8763a7c02a386ae26"; +const CHILD_HEX: &str = + "0300000000010296f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0000000000ff\ + ffffff96f6d302603c6f515582462aa25403eb7651b8184e992b3c20cbc6fa935f019a0100000000fdffffff015\ + 660000000000000225120ac18cd599a1be003595854e2eeec18dbe1c92d04b0ba05812d04445e3fcf16bc000140\ + 1462a35808d77a164f0a23a84c4721d1545befd09ad19945bb8aa0ea5576953a9699038725f944b1bc429942ef4\ + 7e6504a554babf022cb15db53be2d8c1dbfe5a97b0e00"; + +fn dummy_package() -> [bitcoin::Transaction; 2] { + use bitcoin::consensus::Decodable; + use bitcoin::hex::FromHex; + use bitcoin::Transaction; + let parent_tx_bytes = Vec::from_hex(PARENT_HEX).expect("read from a constant"); + let child_tx_bytes = Vec::from_hex(CHILD_HEX).expect("read from a constant"); + let parent = + Transaction::consensus_decode(&mut &parent_tx_bytes[..]).expect("read from a constant"); + let child = + Transaction::consensus_decode(&mut &child_tx_bytes[..]).expect("read from a constant"); + assert_eq!(parent.compute_txid().to_string(), PARENT_TXID); + assert_eq!(child.compute_txid().to_string(), CHILD_TXID); + [parent, child] +} + pub(crate) enum WalletSyncStatus { Completed, InProgress { subscribers: tokio::sync::broadcast::Sender> }, @@ -438,6 +469,26 @@ impl ChainSource { } } + pub(crate) async fn validate_zero_fee_commitments_support_if_required( + &self, zero_fee_commitments_support_required: bool, + ) -> Result<(), Error> { + if !zero_fee_commitments_support_required { + return Ok(()); + } + + match &self.kind { + ChainSourceKind::Esplora(esplora_chain_source) => { + esplora_chain_source.validate_zero_fee_commitments_support().await + }, + ChainSourceKind::Electrum(electrum_chain_source) => { + electrum_chain_source.validate_zero_fee_commitments_support().await + }, + ChainSourceKind::Bitcoind(bitcoind_chain_source) => { + bitcoind_chain_source.validate_zero_fee_commitments_support().await + }, + } + } + pub(crate) async fn continuously_process_broadcast_queue( &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, ) { diff --git a/src/config.rs b/src/config.rs index b446ee2982..421920f8cb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -60,7 +60,8 @@ pub const DEFAULT_LOG_FILENAME: &'static str = "ldk_node.log"; /// The default storage directory. pub const DEFAULT_STORAGE_DIR_PATH: &str = "/tmp/ldk_node"; -// The default Esplora server we're using. +// The default Esplora server we're using. It supports `submitpackage`, check using POST on the +// `/txs/package` endpoint. pub(crate) const DEFAULT_ESPLORA_SERVER_URL: &str = "https://blockstream.info/api"; /// The default stop gap used for BDK full scans of the on-chain wallet. @@ -305,10 +306,11 @@ impl Default for HumanReadableNamesConfig { /// /// ### Defaults /// -/// | Parameter | Value | -/// |----------------------------|--------| -/// | `trusted_peers_no_reserve` | [] | -/// | `per_channel_reserve_sats` | 25000 | +/// | Parameter | Value | +/// |-------------------------------|--------| +/// | `trusted_peers_no_reserve` | [] | +/// | `per_channel_reserve_sats` | 25000 | +/// | `enable_zero_fee_commitments` | false | /// /// /// [BOLT 3]: https://github.com/lightning/bolts/blob/master/03-transactions.md#htlc-timeout-and-htlc-success-transactions @@ -344,6 +346,21 @@ pub struct AnchorChannelsConfig { /// might not suffice to successfully spend the Anchor output and have the HTLC transactions /// confirmed on-chain, i.e., you may want to adjust this value accordingly. pub per_channel_reserve_sats: u64, + /// If set, we will first attempt to negotiate `option_zero_fee_commitments` before falling + /// back to `option_anchors_zero_fee_htlc_tx` and `option_static_remotekey`, as supported by + /// the peer. Zero-fee commitment channels remove all commitment feerate negotiation from + /// the channel, which eliminates a very common source of channel force-closures. These + /// channels instead source *all* the fees required to confirm the commitment from the + /// anchor reserve of the channel closer at the time of force-close. If set, your chain + /// source *must* support the `submitpackage` Bitcoin Core RPC, and relay [TRUC], [P2A], + /// and [Ephemeral Dust]. + /// See [BOLT 3] for more technical details. + /// + /// [TRUC]: https://github.com/bitcoin/bips/blob/master/bip-0431.mediawiki + /// [P2A]: https://github.com/bitcoin/bips/blob/master/bip-0433.mediawiki + /// [Ephemeral Dust]: https://bitcoincore.org/en/releases/29.0 + /// [BOLT 3]: https://github.com/lightning/bolts/blob/master/03-transactions.md#shared_anchor-output-zero_fee_commitments + pub enable_zero_fee_commitments: bool, } impl Default for AnchorChannelsConfig { @@ -351,6 +368,7 @@ impl Default for AnchorChannelsConfig { Self { trusted_peers_no_reserve: Vec::new(), per_channel_reserve_sats: DEFAULT_ANCHOR_PER_CHANNEL_RESERVE_SATS, + enable_zero_fee_commitments: false, } } } diff --git a/src/error.rs b/src/error.rs index 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 0d50977e53..c43b773d6d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -291,9 +291,43 @@ impl Node { e })?; - // Block to ensure we update our fee rate cache once on startup + let manager_owns_any_0fc_channels = + self.channel_manager.list_channels().into_iter().any(|channel| { + channel + .channel_shutdown_state + .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) + && channel + .channel_type + .as_ref() + .map_or(false, |c| c.requires_anchor_zero_fee_commitments()) + }); + let monitor_owns_any_0fc_channels = + self.chain_monitor.list_monitors().into_iter().any(|channel_id| { + self.chain_monitor + .get_monitor(channel_id) + .map(|monitor| { + monitor.channel_type_features().requires_anchor_zero_fee_commitments() + }) + .unwrap_or(false) + }); + let zero_fee_commitments_support_required = manager_owns_any_0fc_channels + || monitor_owns_any_0fc_channels + || self.config.anchor_channels_config.enable_zero_fee_commitments; + + // Block to ensure we update our fee rate cache once on startup. + // Also take this opportunity to make sure our chain source supports 0FC channels + // if they are enabled. + // + // TODO: drop 0FC chain source validation when support is ubiquitous let chain_source = Arc::clone(&self.chain_source); - self.runtime.block_on(async move { chain_source.update_fee_rate_estimates().await })?; + self.runtime.block_on(async move { + tokio::try_join!( + chain_source.update_fee_rate_estimates(), + chain_source.validate_zero_fee_commitments_support_if_required( + zero_fee_commitments_support_required + ) + ) + })?; // Spawn background task continuously syncing onchain, lightning, and fee rate cache. let stop_sync_receiver = self.stop_sender.subscribe(); From 430432673f5a9c17578aa17513ac6f8b57c0cb3c Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 13 Jul 2026 17:36:49 +0000 Subject: [PATCH 091/138] Remove single transaction vecs in wallet broadcast path --- src/tx_broadcaster.rs | 8 ++++---- src/wallet/mod.rs | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index ccf2298b08..0d9e5cac50 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -33,8 +33,8 @@ impl BroadcastPackage { } /// Builds a package for wallet-originated broadcasts that have no LDK classification. - fn unclassified(txs: Vec) -> Self { - Self(txs.into_iter().map(|tx| (tx, None)).collect()) + fn unclassified(tx: Transaction) -> Self { + Self(vec![(tx, None)]) } /// The packaged transactions and their types, for classification. @@ -106,8 +106,8 @@ where Ok(package) } - pub(crate) fn broadcast_unclassified_transactions(&self, txs: Vec) { - self.queue_sender.try_send(BroadcastPackage::unclassified(txs)).unwrap_or_else(|e| { + pub(crate) fn broadcast_unclassified_transaction(&self, tx: Transaction) { + self.queue_sender.try_send(BroadcastPackage::unclassified(tx)).unwrap_or_else(|e| { log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d500d4d013..29c1c75345 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -330,7 +330,7 @@ impl Wallet { .into_iter() .filter_map(|txid| { let tx = locked_wallet.tx_details(txid).map(|d| (*d.tx).clone())?; - self.broadcaster.broadcast_unclassified_transactions(vec![tx]); + self.broadcaster.broadcast_unclassified_transaction(tx); Some(()) }) .count(); @@ -878,7 +878,7 @@ impl Wallet { }; let txid = tx.compute_txid(); - self.broadcaster.broadcast_unclassified_transactions(vec![tx]); + self.broadcaster.broadcast_unclassified_transaction(tx); match send_amount { OnchainSendAmount::ExactRetainingReserve { amount_sats, .. } => { @@ -1758,7 +1758,7 @@ impl Wallet { self.runtime .block_on(self.pending_payment_store.insert_or_update(pending_payment_store))?; - self.broadcaster.broadcast_unclassified_transactions(vec![fee_bumped_tx]); + self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); log_info!(self.logger, "RBF successful: replaced {} with {}", txid, new_txid); From a9040a938aee01ec5d33c81caf3f1b7b443ee3a2 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 27 May 2026 17:35:10 +0000 Subject: [PATCH 092/138] Sort packages received via `BroadcasterInterface` Implementations of `BroadcasterInterface` cannot assume any topological ordering on the transactions received, so here we order the received transactions before adding them to the broadcast queue. Any consumers of the queue can now assume all transactions received to be topologically sorted. Codex wrote the tests. --- src/chain/bitcoind.rs | 5 +- src/chain/electrum.rs | 5 +- src/chain/esplora.rs | 7 +- src/chain/mod.rs | 10 +-- src/tx_broadcaster.rs | 196 +++++++++++++++++++++++++++++++++++++++++- 5 files changed, 209 insertions(+), 14 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 0899d8dcac..0b18224472 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -41,6 +41,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -595,12 +596,12 @@ impl BitcoindChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { // While it's a bit unclear when we'd be able to lean on Bitcoin Core >v28 // features, we should eventually switch to use `submitpackage` via the // `rust-bitcoind-json-rpc` crate rather than just broadcasting individual // transactions. - for tx in &package { + for tx in txs.iter() { let txid = tx.compute_txid(); let timeout_fut = tokio::time::timeout( Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 7a505e0451..baf6d35d9e 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -37,6 +37,7 @@ use crate::fee_estimator::{ use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; use crate::runtime::Runtime; +use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::PersistedNodeMetrics; @@ -353,7 +354,7 @@ impl ElectrumChainSource { } } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { let electrum_client: Arc = if let Some(client) = self.electrum_runtime_status.read().expect("lock").client().as_ref() { @@ -363,7 +364,7 @@ impl ElectrumChainSource { return; }; - for tx in package { + for tx in txs.into_inner() { electrum_client.broadcast(tx).await; } } diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 2fba634ab6..ea76dcc723 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -11,7 +11,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use bdk_esplora::EsploraAsyncExt; -use bitcoin::{FeeRate, Network, Script, Transaction, Txid}; +use bitcoin::{FeeRate, Network, Script, Txid}; use esplora_client::AsyncClient as EsploraAsyncClient; use lightning::chain::{Confirm, Filter, WatchedOutput}; use lightning::util::ser::Writeable; @@ -28,6 +28,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; +use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -410,8 +411,8 @@ impl EsploraChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { - for tx in &package { + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { + for tx in txs.iter() { let txid = tx.compute_txid(); let timeout_fut = tokio::time::timeout( Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 59ae0c64de..0f96c409f8 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -13,7 +13,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bitcoin::{Script, Transaction, Txid}; +use bitcoin::{Script, Txid}; use lightning::chain::{BlockLocator, Filter}; use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient}; @@ -518,16 +518,16 @@ impl ChainSource { continue; }, }; - let txs: Vec = package.into_transactions(); + let package = package.into_sorted_transactions(); match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { - esplora_chain_source.process_broadcast_package(txs).await + esplora_chain_source.process_transaction_broadcast(package).await }, ChainSourceKind::Electrum(electrum_chain_source) => { - electrum_chain_source.process_broadcast_package(txs).await + electrum_chain_source.process_transaction_broadcast(package).await }, ChainSourceKind::Bitcoind(bitcoind_chain_source) => { - bitcoind_chain_source.process_broadcast_package(txs).await + bitcoind_chain_source.process_transaction_broadcast(package).await }, } } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 0d9e5cac50..491a9cbde5 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -43,8 +43,52 @@ impl BroadcastPackage { } /// Consumes the package into its transactions, ready for the chain client. - pub(crate) fn into_transactions(self) -> Vec { - self.0.into_iter().map(|(tx, _)| tx).collect() + pub(crate) fn into_sorted_transactions(self) -> SortedTransactions { + let txs = self.0.into_iter().map(|(tx, _)| tx).collect(); + SortedTransactions::sort_parents_child_package_topologically(txs) + } +} + +pub(crate) struct SortedTransactions(Vec); + +impl SortedTransactions { + pub(crate) fn sort_parents_child_package_topologically( + mut txs: Vec, + ) -> SortedTransactions { + if txs.len() == 0 || txs.len() == 1 { + return SortedTransactions(txs); + } + let txids: Vec<_> = txs.iter().map(|tx| tx.compute_txid()).collect(); + let any_spends_from_package = |tx: &Transaction| -> bool { + tx.input.iter().any(|input| txids.contains(&input.previous_output.txid)) + }; + txs.sort_by_key(any_spends_from_package); + + #[cfg(debug_assertions)] + { + let child = txs.last().expect("txs is not empty"); + let child_input_txids: Vec<_> = + child.input.iter().map(|input| input.previous_output.txid).collect(); + let parents = &txs[..txs.len() - 1]; + let parent_txids: Vec<_> = parents.iter().map(|parent| parent.compute_txid()).collect(); + // Make sure all the parent txids are parents of the child transaction + debug_assert!(parent_txids.iter().all(|txid| child_input_txids.contains(&txid))); + // Make sure there are no grandparents + debug_assert_eq!(txs.iter().filter(|tx| any_spends_from_package(tx)).count(), 1); + } + + SortedTransactions(txs) + } + + pub(crate) fn into_inner(self) -> Vec { + self.0 + } +} + +impl Deref for SortedTransactions { + type Target = Vec; + fn deref(&self) -> &Self::Target { + &self.0 } } @@ -123,3 +167,151 @@ where }); } } + +#[cfg(test)] +mod tests { + use bitcoin::hashes::Hash; + use bitcoin::{Amount, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness}; + + use super::SortedTransactions; + + fn txin(txid: Txid, vout: u32) -> TxIn { + TxIn { + previous_output: OutPoint { txid, vout }, + script_sig: ScriptBuf::new(), + sequence: Sequence::MAX, + witness: Witness::new(), + } + } + + fn txout(value_sat: u64) -> TxOut { + TxOut { value: Amount::from_sat(value_sat), script_pubkey: ScriptBuf::new() } + } + + fn parent_tx(seed: u8) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![txin(Txid::from_byte_array([seed; 32]), 0)], + output: vec![txout(1_000 + u64::from(seed))], + } + } + + fn child_tx(parents: &[&Transaction]) -> Transaction { + Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: parents + .iter() + .enumerate() + .map(|(idx, parent)| txin(parent.compute_txid(), idx as u32)) + .collect(), + output: vec![txout(1_000)], + } + } + + fn assert_parents_before_child( + txs: &[Transaction], expected_child: Txid, expected_parents: &[Txid], + ) { + assert_eq!(txs.last().map(Transaction::compute_txid), Some(expected_child)); + assert_eq!(txs.len(), expected_parents.len() + 1); + + let parent_txids = + txs[..txs.len() - 1].iter().map(Transaction::compute_txid).collect::>(); + for expected_parent in expected_parents { + assert!(parent_txids.contains(expected_parent)); + } + } + + #[test] + fn topological_sort_leaves_sorted_package_unchanged() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + + let original_txids = + [parent_a.compute_txid(), parent_b.compute_txid(), child.compute_txid()]; + let txs = vec![parent_a, parent_b, child]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_eq!( + package.iter().map(Transaction::compute_txid).collect::>(), + original_txids + ); + } + + #[test] + fn topological_sort_moves_single_parent_child_from_front_to_end() { + let parent = parent_tx(1); + let child = child_tx(&[&parent]); + let parent_txids = [parent.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![child, parent]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_front_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + let parent_txids = [parent_a.compute_txid(), parent_b.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![child, parent_a, parent_b]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_front_with_multiple_parents_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let parent_c = parent_tx(3); + let child = child_tx(&[&parent_a, &parent_b, &parent_c]); + let parent_txids = + [parent_a.compute_txid(), parent_b.compute_txid(), parent_c.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![child, parent_a, parent_b, parent_c]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_moves_child_from_middle_to_end() { + let parent_a = parent_tx(1); + let parent_b = parent_tx(2); + let child = child_tx(&[&parent_a, &parent_b]); + let parent_txids = [parent_a.compute_txid(), parent_b.compute_txid()]; + let child_txid = child.compute_txid(); + let txs = vec![parent_a, child, parent_b]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_parents_before_child(&package, child_txid, &parent_txids); + } + + #[test] + fn topological_sort_leaves_single_transaction_package_unchanged() { + let parent = parent_tx(1); + let parent_txid = parent.compute_txid(); + let txs = vec![parent]; + + let package = SortedTransactions::sort_parents_child_package_topologically(txs); + + assert_eq!(package.len(), 1); + assert_eq!(package[0].compute_txid(), parent_txid); + } + + #[test] + fn topological_sort_accepts_empty_vec() { + SortedTransactions::sort_parents_child_package_topologically(Vec::new()); + } +} From cc85b4df03be79f48c00d2a8217f849706355cf7 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 25 Jun 2026 16:43:19 +0000 Subject: [PATCH 093/138] Use helper functions to log broadcast errors These will be useful when we add support for broadcasting packages in an upcoming commit. --- src/chain/bitcoind.rs | 36 ++++++--------- src/chain/electrum.rs | 42 ++++++++--------- src/chain/esplora.rs | 105 ++++++++++++++++++++---------------------- 3 files changed, 82 insertions(+), 101 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 0b18224472..330578121c 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -596,16 +596,25 @@ impl BitcoindChainSource { Ok(()) } + fn log_broadcast_error(&self, e: impl core::fmt::Display, txids: &[Txid], txs: &[Transaction]) { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { // While it's a bit unclear when we'd be able to lean on Bitcoin Core >v28 // features, we should eventually switch to use `submitpackage` via the // `rust-bitcoind-json-rpc` crate rather than just broadcasting individual // transactions. - for tx in txs.iter() { + let txs = txs.into_inner(); + for tx in txs { let txid = tx.compute_txid(); let timeout_fut = tokio::time::timeout( Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), - self.api_client.broadcast_transaction(tx), + self.api_client.broadcast_transaction(&tx), ); match timeout_fut.await { Ok(res) => match res { @@ -613,28 +622,9 @@ impl BitcoindChainSource { debug_assert_eq!(id, txid); log_trace!(self.logger, "Successfully broadcast transaction {}", txid); }, - Err(e) => { - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, - }, - Err(e) => { - log_error!( - self.logger, - "Failed to broadcast transaction due to timeout {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); + Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), }, + Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), } } } diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index baf6d35d9e..8108504e1d 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -625,14 +625,24 @@ impl ElectrumRuntimeClient { }) } + fn log_broadcast_error(&self, e: impl core::fmt::Display, txids: &[Txid], txs: &[Transaction]) { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction bytes:"); + for tx in txs { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + async fn broadcast(&self, tx: Transaction) { let electrum_client = Arc::clone(&self.electrum_client); let txid = tx.compute_txid(); - let tx_bytes = tx.encode(); + let tx = Arc::new(tx); - let spawn_fut = - self.runtime.spawn_blocking(move || electrum_client.transaction_broadcast(&tx)); + let spawn_fut = self.runtime.spawn_blocking({ + let tx = Arc::clone(&tx); + move || electrum_client.transaction_broadcast(tx.as_ref()) + }); let timeout_fut = tokio::time::timeout( Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), spawn_fut, @@ -640,31 +650,15 @@ impl ElectrumRuntimeClient { match timeout_fut.await { Ok(res) => match res { - Ok(_) => { + Ok(Ok(txid)) => { log_trace!(self.logger, "Successfully broadcast transaction {}", txid); }, - Err(e) => { - log_error!(self.logger, "Failed to broadcast transaction {}: {}", txid, e); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx_bytes) - ); + Ok(Err(e)) => { + self.log_broadcast_error(e, &[txid], core::slice::from_ref(tx.as_ref())) }, + Err(e) => self.log_broadcast_error(e, &[txid], core::slice::from_ref(tx.as_ref())), }, - Err(e) => { - log_error!( - self.logger, - "Failed to broadcast transaction due to timeout {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx_bytes) - ); - }, + Err(e) => self.log_broadcast_error(e, &[txid], core::slice::from_ref(tx.as_ref())), } } diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index ea76dcc723..2e3e88d7bd 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -411,6 +411,55 @@ impl EsploraChainSource { Ok(()) } + fn log_http_error(&self, e: esplora_client::Error, txids: &[Txid], txs: &SortedTransactions) { + match e { + esplora_client::Error::HttpResponse { status, message } => { + if status == 400 && txs.len() == 1 { + // Log 400 at lesser level, as this often just means bitcoind already knows the + // transaction. + // FIXME: We can further differentiate here based on the error + // message which will be available with rust-esplora-client 0.7 and + // later. + log_trace!( + self.logger, + "Failed to broadcast due to HTTP connection error: {}", + message + ); + log_trace!(self.logger, "Failed to broadcast transaction(s) {:?}", txids); + } else { + log_error!( + self.logger, + "Failed to broadcast due to HTTP connection error: {} - {}", + status, + message + ); + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}", txids); + } + log_trace!(self.logger, "Failed broadcast transaction(s) bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + _ => { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction(s) bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + }, + } + } + + fn log_broadcast_error( + &self, e: impl core::fmt::Display, txids: &[Txid], txs: &SortedTransactions, + ) { + log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); + log_trace!(self.logger, "Failed broadcast transaction bytes:"); + for tx in txs.iter() { + log_trace!(self.logger, "{}", log_bytes!(tx.encode())); + } + } + pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { for tx in txs.iter() { let txid = tx.compute_txid(); @@ -423,61 +472,9 @@ impl EsploraChainSource { Ok(()) => { log_trace!(self.logger, "Successfully broadcast transaction {}", txid); }, - Err(e) => match e { - esplora_client::Error::HttpResponse { status, message } => { - if status == 400 { - // Log 400 at lesser level, as this often just means bitcoind already knows the - // transaction. - // FIXME: We can further differentiate here based on the error - // message which will be available with rust-esplora-client 0.7 and - // later. - log_trace!( - self.logger, - "Failed to broadcast due to HTTP connection error: {}", - message - ); - } else { - log_error!( - self.logger, - "Failed to broadcast due to HTTP connection error: {} - {}", - status, - message - ); - } - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, - _ => { - log_error!( - self.logger, - "Failed to broadcast transaction {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); - }, - }, - }, - Err(e) => { - log_error!( - self.logger, - "Failed to broadcast transaction due to timeout {}: {}", - txid, - e - ); - log_trace!( - self.logger, - "Failed broadcast transaction bytes: {}", - log_bytes!(tx.encode()) - ); + Err(e) => self.log_http_error(e, &[txid], &txs), }, + Err(e) => self.log_broadcast_error(e, &[txid], &txs), } } } From 195df39a7dff2a747638f37519a6d802bee22958 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Thu, 25 Jun 2026 16:02:34 +0000 Subject: [PATCH 094/138] Submit TRUC packages via all chain sources We rely on the `BroadcasterInterface` contract whereby any multi-transaction vector must be a single child and its parents. In a prior commit, we added the guarantee that any packages received from the broadcast queue are already topologically sorted, and hence can be passed directly to the `submit_package` Bitcoin Core RPC. We avoid broadcasting non-TRUC parents-child packages via `submitpackage` for now to avoid adding a requirement to support `submitpackage` for users that don't enable zero-fee commitment channels. We will do so once support for `submitpackage` is more ubiquitous. --- src/chain/bitcoind.rs | 115 ++++++++++++++++++++++++++++++++++-------- src/chain/electrum.rs | 51 ++++++++++++++++++- src/chain/esplora.rs | 69 ++++++++++++++++++++----- 3 files changed, 198 insertions(+), 37 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 330578121c..1c392b0554 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -14,6 +14,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use base64::prelude::BASE64_STANDARD; use base64::Engine; +use bitcoin::transaction::Version; use bitcoin::{BlockHash, FeeRate, Network, OutPoint, Transaction, Txid}; use lightning::chain::chaininterface::ConfirmationTarget as LdkConfirmationTarget; use lightning::chain::{BlockLocator, Listen}; @@ -596,7 +597,9 @@ impl BitcoindChainSource { Ok(()) } - fn log_broadcast_error(&self, e: impl core::fmt::Display, txids: &[Txid], txs: &[Transaction]) { + fn log_broadcast_error( + &self, e: impl core::fmt::Display, txids: &[Txid], txs: &SortedTransactions, + ) { log_error!(self.logger, "Failed to broadcast transaction(s) {:?}: {}", txids, e); log_trace!(self.logger, "Failed broadcast transaction bytes:"); for tx in txs.iter() { @@ -605,27 +608,48 @@ impl BitcoindChainSource { } pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { - // While it's a bit unclear when we'd be able to lean on Bitcoin Core >v28 - // features, we should eventually switch to use `submitpackage` via the - // `rust-bitcoind-json-rpc` crate rather than just broadcasting individual - // transactions. - let txs = txs.into_inner(); - for tx in txs { - let txid = tx.compute_txid(); - let timeout_fut = tokio::time::timeout( - Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), - self.api_client.broadcast_transaction(&tx), - ); - match timeout_fut.await { - Ok(res) => match res { - Ok(id) => { - debug_assert_eq!(id, txid); - log_trace!(self.logger, "Successfully broadcast transaction {}", txid); + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); + match txs.len() { + 2.. if all_txs_are_v3 => { + let txids: Vec<_> = txs.iter().map(|tx| tx.compute_txid()).collect(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), + self.api_client.submit_package(&txs), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(result) => { + log_trace!(self.logger, "Successfully broadcast package {:?}", txids); + log_trace!(self.logger, "Successfully broadcast package {}", result); + }, + Err(e) => self.log_broadcast_error(e, &txids, &txs), }, - Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), - }, - Err(e) => self.log_broadcast_error(e, &[txid], &[tx]), - } + Err(e) => self.log_broadcast_error(e, &txids, &txs), + } + }, + _ => { + for tx in txs.iter() { + let txid = tx.compute_txid(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(DEFAULT_TX_BROADCAST_TIMEOUT_SECS), + self.api_client.broadcast_transaction(tx), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(id) => { + debug_assert_eq!(id, txid); + log_trace!( + self.logger, + "Successfully broadcast transaction {}", + txid + ); + }, + Err(e) => self.log_broadcast_error(e, &[txid], &txs), + }, + Err(e) => self.log_broadcast_error(e, &[txid], &txs), + } + } + }, } } } @@ -816,6 +840,38 @@ impl BitcoindClient { rpc_client.call_method::("sendrawtransaction", &[tx_json]).await } + /// Submits the provided package + pub(crate) async fn submit_package( + &self, package: &SortedTransactions, + ) -> Result { + match self { + BitcoindClient::Rpc { rpc_client, .. } => { + Self::submit_package_inner(Arc::clone(rpc_client), package) + .await + .map_err(BitcoindClientError::Rpc) + }, + BitcoindClient::Rest { rpc_client, .. } => { + // Bitcoin Core's REST interface does not support submitting packages + // so we use the RPC client. + Self::submit_package_inner(Arc::clone(rpc_client), package) + .await + .map_err(BitcoindClientError::Rpc) + }, + } + } + + async fn submit_package_inner( + rpc_client: Arc, package: &SortedTransactions, + ) -> Result { + let package_serialized: Vec<_> = + package.iter().map(|tx| bitcoin::consensus::encode::serialize_hex(tx)).collect(); + let package_json = serde_json::json!(package_serialized); + rpc_client + .call_method::("submitpackage", &[package_json]) + .await + .map(|response| response.0) + } + /// Retrieve the fee estimate needed for a transaction to begin /// confirmation within the provided `num_blocks`. pub(crate) async fn get_fee_estimate_for_target( @@ -1367,6 +1423,23 @@ impl TryInto for JsonResponse { } } +pub struct SubmitPackageResponse(String); + +impl TryInto for JsonResponse { + type Error = String; + fn try_into(self) -> Result { + let response = self.0.to_string(); + let res = self.0.as_object().ok_or("Failed to parse submitpackage response".to_string())?; + + match res["package_msg"].as_str() { + Some("success") => Ok(SubmitPackageResponse(response)), + Some(_) | None => { + return Err(response); + }, + } + } +} + #[derive(Debug, Clone)] pub(crate) struct MempoolEntry { /// The transaction id diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 8108504e1d..2c058910dc 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -16,6 +16,7 @@ use bdk_chain::bdk_core::spk_client::{ }; use bdk_electrum::BdkElectrumClient; use bdk_wallet::{KeychainKind as BdkKeyChainKind, Update as BdkUpdate}; +use bitcoin::transaction::Version; use bitcoin::{FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; use electrum_client::{ Batch, Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder, ElectrumApi, @@ -364,8 +365,14 @@ impl ElectrumChainSource { return; }; - for tx in txs.into_inner() { - electrum_client.broadcast(tx).await; + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); + match txs.len() { + 2.. if all_txs_are_v3 => electrum_client.submit_package(txs).await, + _ => { + for tx in txs.into_inner() { + electrum_client.broadcast(tx).await + } + }, } } } @@ -662,6 +669,46 @@ impl ElectrumRuntimeClient { } } + async fn submit_package(&self, package: SortedTransactions) { + let electrum_client = Arc::clone(&self.electrum_client); + + let txids: Vec<_> = package.iter().map(|tx| tx.compute_txid()).collect(); + let package = Arc::new(package); + + let spawn_fut = self.runtime.spawn_blocking({ + let package = Arc::clone(&package); + move || electrum_client.transaction_broadcast_package(&package) + }); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), + spawn_fut, + ); + + match timeout_fut.await { + Ok(res) => match res { + Ok(Ok(result)) => { + if result.success { + log_trace!( + self.logger, + "Successfully broadcast transaction(s) {:?}", + txids + ); + log_trace!( + self.logger, + "Successfully broadcast transaction(s) {:?}", + result + ); + } else { + self.log_broadcast_error(format!("{:?}", result), &txids, &package); + } + }, + Ok(Err(e)) => self.log_broadcast_error(e, &txids, &package), + Err(e) => self.log_broadcast_error(e, &txids, &package), + }, + Err(e) => self.log_broadcast_error(e, &txids, &package), + } + } + async fn get_fee_rate_cache_update( &self, ) -> Result, Error> { diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 2e3e88d7bd..7a59c8d462 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -11,6 +11,7 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use bdk_esplora::EsploraAsyncExt; +use bitcoin::transaction::Version; use bitcoin::{FeeRate, Network, Script, Txid}; use esplora_client::AsyncClient as EsploraAsyncClient; use lightning::chain::{Confirm, Filter, WatchedOutput}; @@ -461,21 +462,61 @@ impl EsploraChainSource { } pub(crate) async fn process_transaction_broadcast(&self, txs: SortedTransactions) { - for tx in txs.iter() { - let txid = tx.compute_txid(); - let timeout_fut = tokio::time::timeout( - Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), - self.esplora_client.broadcast(tx), - ); - match timeout_fut.await { - Ok(res) => match res { - Ok(()) => { - log_trace!(self.logger, "Successfully broadcast transaction {}", txid); + let all_txs_are_v3 = txs.iter().all(|tx| tx.version == Version::non_standard(3)); + match txs.len() { + 2.. if all_txs_are_v3 => { + let txids: Vec<_> = txs.iter().map(|tx| tx.compute_txid()).collect(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.tx_broadcast_timeout_secs), + self.esplora_client.submit_package(&txs, None, None), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(result) => { + if result.package_msg.eq_ignore_ascii_case("success") { + log_trace!( + self.logger, + "Successfully broadcast transactions {:?}", + txids + ); + log_trace!( + self.logger, + "Successfully broadcast transactions {:?}", + result + ); + } else { + self.log_broadcast_error(format!("{:?}", result), &txids, &txs); + } + }, + Err(e) => self.log_http_error(e, &txids, &txs), }, - Err(e) => self.log_http_error(e, &[txid], &txs), - }, - Err(e) => self.log_broadcast_error(e, &[txid], &txs), - } + Err(e) => self.log_broadcast_error(e, &txids, &txs), + } + }, + _ => { + for tx in txs.iter() { + let txid = tx.compute_txid(); + let timeout_fut = tokio::time::timeout( + Duration::from_secs( + self.sync_config.timeouts_config.tx_broadcast_timeout_secs, + ), + self.esplora_client.broadcast(tx), + ); + match timeout_fut.await { + Ok(res) => match res { + Ok(()) => { + log_trace!( + self.logger, + "Successfully broadcast transaction {}", + txid + ); + }, + Err(e) => self.log_http_error(e, &[txid], &txs), + }, + Err(e) => self.log_broadcast_error(e, &[txid], &txs), + } + } + }, } } } From cf044ac55696a29522141572daa3b24720910865 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Mon, 29 Jun 2026 23:21:46 +0000 Subject: [PATCH 095/138] Read even bits to check the anchor channel type --- src/types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.rs b/src/types.rs index 2ac08a48f8..5198d12cf4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -649,7 +649,7 @@ impl ChannelDetails { value: LdkChannelDetails, anchor_channels_config: &AnchorChannelsConfig, ) -> Self { let reserve_type = value.channel_type.as_ref().map(|channel_type| { - if channel_type.supports_anchors_zero_fee_htlc_tx() { + if channel_type.requires_anchors_zero_fee_htlc_tx() { if anchor_channels_config .trusted_peers_no_reserve .contains(&value.counterparty.node_id) From cada867b1eed37147f5a1a4e4d6d5c789848223c Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 29 Oct 2025 07:00:04 +0000 Subject: [PATCH 096/138] Include 0FC channels in anchor channel checks --- src/event.rs | 2 +- src/lib.rs | 21 ++++++++++++++++++--- src/liquidity/service/lsps2.rs | 20 ++++++++------------ src/types.rs | 2 +- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/src/event.rs b/src/event.rs index 1fb148bd12..93a09cd9b9 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1266,7 +1266,7 @@ where } } - let anchor_channel = channel_type.requires_anchors_zero_fee_htlc_tx(); + let anchor_channel = crate::requires_anchor_channel_type(&channel_type); let required_reserve_sats = crate::new_channel_anchor_reserve_sats( &self.config, &counterparty_node_id, diff --git a/src/lib.rs b/src/lib.rs index c43b773d6d..a534841411 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -163,7 +163,9 @@ use lightning_background_processor::process_events_async; pub use lightning_invoice; pub use lightning_liquidity; pub use lightning_types; -use lightning_types::features::NodeFeatures as LdkNodeFeatures; +use lightning_types::features::{ + ChannelTypeFeatures, InitFeatures, NodeFeatures as LdkNodeFeatures, +}; use liquidity::LiquiditySource; use lnurl_auth::LnurlAuth; use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; @@ -219,6 +221,19 @@ impl LeakChecker { } } +fn peer_may_negotiate_anchor_channel_type( + config: &Config, their_init_features: &InitFeatures, +) -> bool { + their_init_features.supports_anchors_zero_fee_htlc_tx() + || (config.anchor_channels_config.enable_zero_fee_commitments + && their_init_features.supports_anchor_zero_fee_commitments()) +} + +fn requires_anchor_channel_type(channel_type: &ChannelTypeFeatures) -> bool { + channel_type.requires_anchors_zero_fee_htlc_tx() + || channel_type.requires_anchor_zero_fee_commitments() +} + /// The main interface object of LDK Node, wrapping the necessary LDK and BDK functionalities. /// /// Needs to be initialized and instantiated through [`Builder::build`]. @@ -1387,7 +1402,7 @@ impl Node { .peer_by_node_id(peer_node_id) .ok_or(Error::ConnectionFailed)? .init_features; - let anchor_channel = init_features.supports_anchors_zero_fee_htlc_tx(); + let anchor_channel = peer_may_negotiate_anchor_channel_type(&self.config, &init_features); Ok(new_channel_anchor_reserve_sats(&self.config, peer_node_id, anchor_channel)) } @@ -2450,7 +2465,7 @@ pub(crate) fn total_anchor_channels_reserve_sats( .contains(&c.counterparty.node_id) && c.channel_shutdown_state .map_or(true, |s| s != ChannelShutdownState::ShutdownComplete) - && c.channel_type.as_ref().map_or(false, |t| t.requires_anchors_zero_fee_htlc_tx()) + && c.channel_type.as_ref().map_or(false, requires_anchor_channel_type) }) .count() as u64 * config.anchor_channels_config.per_channel_reserve_sats diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index ca70cd8d8e..946511c5d9 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -452,18 +452,14 @@ where total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); let spendable_amount_sats = self.wallet.get_spendable_amount_sats(cur_anchor_reserve_sats).unwrap_or(0); - let required_funds_sats = channel_amount_sats - + if init_features.supports_anchors_zero_fee_htlc_tx() - && !self - .config - .anchor_channels_config - .trusted_peers_no_reserve - .contains(&their_network_key) - { - self.config.anchor_channels_config.per_channel_reserve_sats - } else { - 0 - }; + let anchor_channel = + crate::peer_may_negotiate_anchor_channel_type(&self.config, &init_features); + let additional_reserve_required = crate::new_channel_anchor_reserve_sats( + &self.config, + &their_network_key, + anchor_channel, + ); + let required_funds_sats = channel_amount_sats + additional_reserve_required; if spendable_amount_sats < required_funds_sats { log_error!(self.logger, "Unable to create channel due to insufficient funds. Available: {}sats, Required: {}sats", diff --git a/src/types.rs b/src/types.rs index 5198d12cf4..5552877ef8 100644 --- a/src/types.rs +++ b/src/types.rs @@ -649,7 +649,7 @@ impl ChannelDetails { value: LdkChannelDetails, anchor_channels_config: &AnchorChannelsConfig, ) -> Self { let reserve_type = value.channel_type.as_ref().map(|channel_type| { - if channel_type.requires_anchors_zero_fee_htlc_tx() { + if crate::requires_anchor_channel_type(channel_type) { if anchor_channels_config .trusted_peers_no_reserve .contains(&value.counterparty.node_id) From a868de891c620153911c26ffb8586e105cfabd93 Mon Sep 17 00:00:00 2001 From: Leo Nash Date: Wed, 15 Jul 2026 18:27:05 +0000 Subject: [PATCH 097/138] Negotiate 0FC channels if configured --- .github/workflows/0fc-integration.yml | 49 +++++++++++++++++++++++++++ Cargo.toml | 1 + scripts/build_electrs.sh | 35 +++++++++++++++++++ src/config.rs | 7 ++-- tests/common/mod.rs | 11 +++--- tests/integration_tests_rust.rs | 5 +-- 6 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/0fc-integration.yml create mode 100755 scripts/build_electrs.sh diff --git a/.github/workflows/0fc-integration.yml b/.github/workflows/0fc-integration.yml new file mode 100644 index 0000000000..02a25eef5a --- /dev/null +++ b/.github/workflows/0fc-integration.yml @@ -0,0 +1,49 @@ +name: CI Checks - 0FC Integration Tests + +on: [push, pull_request] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + timeout-minutes: 60 + runs-on: self-hosted + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Rust stable toolchain + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile=minimal --default-toolchain stable + - name: Enable caching for bitcoind + id: cache-bitcoind + uses: actions/cache@v4 + with: + path: bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + key: bitcoind-29.0-${{ runner.os }}-${{ runner.arch }} + - name: Enable caching for electrs + id: cache-electrs + uses: actions/cache@v4 + with: + path: bin/electrs-${{ runner.os }}-${{ runner.arch }} + key: electrs-submit-package-${{ runner.os }}-${{ runner.arch }} + - name: Download bitcoind + if: "steps.cache-bitcoind.outputs.cache-hit != 'true'" + run: | + source ./scripts/download_bitcoind_electrs.sh + mkdir -p bin + mv "$BITCOIND_EXE" bin/bitcoind-${{ runner.os }}-${{ runner.arch }} + - name: Download electrs + if: "steps.cache-electrs.outputs.cache-hit != 'true'" + run: | + source ./scripts/build_electrs.sh + mkdir -p bin + mv "$ELECTRS_EXE" bin/electrs-${{ runner.os }}-${{ runner.arch }} + - name: Set bitcoind/electrs environment variables + run: | + echo "BITCOIND_EXE=$( pwd )/bin/bitcoind-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + echo "ELECTRS_EXE=$( pwd )/bin/electrs-${{ runner.os }}-${{ runner.arch }}" >> "$GITHUB_ENV" + - name: Test with 0FC enabled + run: | + RUSTFLAGS="--cfg no_download --cfg cycle_tests --cfg tokio_unstable --cfg zero_fee_commitment_tests" cargo test -- --test-threads=1 diff --git a/Cargo.toml b/Cargo.toml index c9ff50d22d..7fc945833e 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,6 +135,7 @@ check-cfg = [ "cfg(eclair_test)", "cfg(cycle_tests)", "cfg(hrn_tests)", + "cfg(zero_fee_commitment_tests)", ] [[bench]] diff --git a/scripts/build_electrs.sh b/scripts/build_electrs.sh new file mode 100755 index 0000000000..2235986c8c --- /dev/null +++ b/scripts/build_electrs.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -eox pipefail + +# Our Esplora-based tests require `electrs` binaries. Here, we +# download the code, build the binaries, and export their location +# via `ELECTRS_EXE` which will be used by the `electrsd` crates in +# our tests. + +HOST_PLATFORM="$(rustc --version --verbose | grep "host:" | awk '{ print $2 }')" +ELECTRS_GIT_REPO="https://github.com/tankyleo/blockstream-electrs.git" +ELECTRS_TAG="2026-05-26-electrum-submit-package" +ELECTRS_REV="8c06d8010e43f793b1a65f83695ea846e5cd83ed" +if [[ "$HOST_PLATFORM" != *linux* && "$HOST_PLATFORM" != *darwin* ]]; then + printf "\n\n" + echo "Unsupported platform: $HOST_PLATFORM Exiting.." + exit 1 +fi + +DL_TMP_DIR=$(mktemp -d) +trap 'rm -rf -- "$DL_TMP_DIR"' EXIT + +pushd "$DL_TMP_DIR" +git clone --branch "$ELECTRS_TAG" --depth 1 "$ELECTRS_GIT_REPO" blockstream-electrs +cd blockstream-electrs +CURRENT_HEAD=$(git rev-parse HEAD) +if [ "$CURRENT_HEAD" != "$ELECTRS_REV" ]; then + echo "ERROR: HEAD does not match expected commit" + echo "expected: $ELECTRS_REV" + echo "actual: $CURRENT_HEAD" + exit 1 +fi +RUSTFLAGS="" cargo build +export ELECTRS_EXE="$DL_TMP_DIR"/blockstream-electrs/target/debug/electrs +chmod +x "$ELECTRS_EXE" +popd diff --git a/src/config.rs b/src/config.rs index 421920f8cb..958eb14fcd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -188,7 +188,8 @@ pub struct Config { /// used to send pre-flight probes. pub probing_liquidity_limit_multiplier: u64, /// Configuration options pertaining to Anchor channels, i.e., channels for which the - /// `option_anchors_zero_fee_htlc_tx` channel type is negotiated. + /// `option_zero_fee_commitments` or `option_anchors_zero_fee_htlc_tx` channel type is + /// negotiated. /// /// Please refer to [`AnchorChannelsConfig`] for further information on Anchor channels. pub anchor_channels_config: AnchorChannelsConfig, @@ -287,7 +288,7 @@ impl Default for HumanReadableNamesConfig { } /// Configuration options pertaining to 'Anchor' channels, i.e., channels for which the -/// `option_anchors_zero_fee_htlc_tx` channel type is negotiated. +/// `option_zero_fee_commitments` or `option_anchors_zero_fee_htlc_tx` channel type is negotiated. /// /// Prior to the introduction of Anchor channels, the on-chain fees paying for the transactions /// issued on channel closure were pre-determined and locked-in at the time of the channel @@ -424,6 +425,8 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { // will mostly be relevant for inbound channels. let mut user_config = UserConfig::default(); user_config.channel_handshake_limits.force_announced_channel_preference = false; + user_config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = + config.anchor_channels_config.enable_zero_fee_commitments; user_config.reject_inbound_splices = false; if may_announce_channel(config).is_err() { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 5026780de0..8aee93b1a8 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -382,6 +382,11 @@ pub(crate) fn random_node_alias() -> Option { pub(crate) fn random_config() -> TestConfig { let mut node_config = Config::default(); + #[cfg(zero_fee_commitment_tests)] + { + node_config.anchor_channels_config.enable_zero_fee_commitments = true; + } + node_config.network = Network::Regtest; println!("Setting network: {}", node_config.network); @@ -1579,10 +1584,8 @@ pub(crate) async fn do_channel_full_cycle( let node_a_outbound_capacity_msat = node_a.list_channels()[0].outbound_capacity_msat; let node_a_reserve_msat = node_a.list_channels()[0].unspendable_punishment_reserve.unwrap() * 1000; - // TODO: Zero-fee commitment channels are anchor channels, but do not allocate any - // funds to the anchor, so this will need to be updated when we ship these channels - // in ldk-node. - let node_a_anchors_msat = if expect_anchor_channel { 2 * 330 * 1000 } else { 0 }; + let zero_fee_commitments = node_a.list_channels()[0].feerate_sat_per_1000_weight == 0; + let node_a_anchors_msat = if zero_fee_commitments { 0 } else { 2 * 330 * 1000 }; let funding_amount_msat = node_a.list_channels()[0].channel_value_sats * 1000; // Node B does not have any reserve, so we only subtract a few items on node A's // side to arrive at node B's capacity diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 7852e73b76..a8491ae4e0 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -1543,8 +1543,9 @@ async fn splice_channel() { let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id()); let opening_transaction_fee_sat = 156; - let closing_transaction_fee_sat = 614; - let anchor_output_sat = 330; + let zero_fee_commitments = node_a.list_channels()[0].feerate_sat_per_1000_weight == 0; + let closing_transaction_fee_sat = if zero_fee_commitments { 0 } else { 614 }; + let anchor_output_sat = if zero_fee_commitments { 0 } else { 330 }; assert_eq!( node_a.list_balances().total_onchain_balance_sats, From d70c0978df2923ae4f6b76c69013155395170ed7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 14 Jul 2026 08:05:02 +0200 Subject: [PATCH 098/138] Avoid wallet persistence deadlocks Release the synchronous wallet lock before awaiting store I/O while serializing wallet mutations with an asynchronous persistence gate. Retain failed change sets so retries preserve BDK persistence semantics. Co-Authored-By: HAL 9000 --- src/chain/bitcoind.rs | 9 +- src/chain/electrum.rs | 2 +- src/chain/esplora.rs | 2 +- src/event.rs | 18 +- src/lib.rs | 2 + src/liquidity/client/lsps1.rs | 2 +- src/payment/onchain.rs | 40 +- src/payment/unified.rs | 5 +- src/wallet/mod.rs | 651 +++++++++++++++++--------------- src/wallet/persist.rs | 162 +++++++- tests/common/mod.rs | 2 +- tests/integration_tests_rust.rs | 149 +++++++- 12 files changed, 696 insertions(+), 348 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 1c392b0554..f857ef5333 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -461,11 +461,12 @@ impl BitcoindChainSource { evicted_txids.len(), elapsed_ms, ); - onchain_wallet.apply_mempool_txs(unconfirmed_txs, evicted_txids).unwrap_or_else( - |e| { + onchain_wallet + .apply_mempool_txs(unconfirmed_txs, evicted_txids) + .await + .unwrap_or_else(|e| { log_error!(self.logger, "Failed to apply mempool transactions: {:?}", e); - }, - ); + }); }, Err(e) => { log_error!(self.logger, "Failed to poll for mempool transactions: {:?}", e); diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 2c058910dc..59fa23a6ca 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -182,7 +182,7 @@ impl ElectrumChainSource { update_res: Result, now: Instant, ) -> Result<(), Error> { match update_res { - Ok(update) => match onchain_wallet.apply_update(update) { + Ok(update) => match onchain_wallet.apply_update(update).await { Ok(()) => { log_debug!( self.logger, diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 7a59c8d462..21205bd252 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -146,7 +146,7 @@ impl EsploraChainSource { let now = Instant::now(); match $sync_future.await { Ok(res) => match res { - Ok(update) => match onchain_wallet.apply_update(update) { + Ok(update) => match onchain_wallet.apply_update(update).await { Ok(()) => { log_debug!( self.logger, diff --git a/src/event.rs b/src/event.rs index 0af8deb5aa..528a7085ed 100644 --- a/src/event.rs +++ b/src/event.rs @@ -694,12 +694,16 @@ where // Sign the final funding transaction and broadcast it. let channel_amount = Amount::from_sat(channel_value_satoshis); - match self.wallet.create_funding_transaction( - output_script, - channel_amount, - confirmation_target, - locktime, - ) { + let funding_transaction = self + .wallet + .create_funding_transaction( + output_script, + channel_amount, + confirmation_target, + locktime, + ) + .await; + match funding_transaction { Ok(final_tx) => { let needs_manual_broadcast = self .liquidity_source @@ -1743,7 +1747,7 @@ where }) .collect(), }; - if let Err(e) = self.wallet.cancel_tx(tx) { + if let Err(e) = self.wallet.cancel_tx(tx).await { log_error!(self.logger, "Failed reclaiming unused addresses: {}", e); return Err(ReplayEvent()); } diff --git a/src/lib.rs b/src/lib.rs index aa234bb639..cb570243cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1106,6 +1106,7 @@ impl Node { Arc::clone(&self.channel_manager), Arc::clone(&self.config), Arc::clone(&self.is_running), + Arc::clone(&self.runtime), Arc::clone(&self.logger), ) } @@ -1118,6 +1119,7 @@ impl Node { Arc::clone(&self.channel_manager), Arc::clone(&self.config), Arc::clone(&self.is_running), + Arc::clone(&self.runtime), Arc::clone(&self.logger), )) } diff --git a/src/liquidity/client/lsps1.rs b/src/liquidity/client/lsps1.rs index dff374fe2d..6082414c12 100644 --- a/src/liquidity/client/lsps1.rs +++ b/src/liquidity/client/lsps1.rs @@ -488,7 +488,7 @@ impl LSPS1Liquidity { log_info!(self.logger, "Connected to LSP {}@{}. ", lsps1_node.node_id, lsps1_node.address); - let refund_address = self.wallet.get_new_address()?; + let refund_address = self.runtime.block_on(self.wallet.get_new_address())?; let liquidity_source = Arc::clone(&self.liquidity_source); let response = self.runtime.block_on(async move { diff --git a/src/payment/onchain.rs b/src/payment/onchain.rs index da2685970c..ad0a2d46c7 100644 --- a/src/payment/onchain.rs +++ b/src/payment/onchain.rs @@ -15,6 +15,7 @@ use lightning::ln::channelmanager::PaymentId; use crate::config::Config; use crate::error::Error; use crate::logger::{log_info, LdkLogger, Logger}; +use crate::runtime::Runtime; use crate::types::{ChannelManager, Wallet}; use crate::wallet::OnchainSendAmount; @@ -47,15 +48,30 @@ pub struct OnchainPayment { channel_manager: Arc, config: Arc, is_running: Arc>, + runtime: Arc, logger: Arc, } impl OnchainPayment { pub(crate) fn new( wallet: Arc, channel_manager: Arc, config: Arc, - is_running: Arc>, logger: Arc, + is_running: Arc>, runtime: Arc, logger: Arc, ) -> Self { - Self { wallet, channel_manager, config, is_running, logger } + Self { wallet, channel_manager, config, is_running, runtime, logger } + } + + pub(crate) async fn send_to_address_inner( + &self, address: &bitcoin::Address, amount_sats: u64, fee_rate: Option, + ) -> Result { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + + let cur_anchor_reserve_sats = + crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); + let send_amount = + OnchainSendAmount::ExactRetainingReserve { amount_sats, cur_anchor_reserve_sats }; + self.wallet.send_to_address(address, send_amount, fee_rate).await } } @@ -63,7 +79,7 @@ impl OnchainPayment { impl OnchainPayment { /// Retrieve a new on-chain/funding address. pub fn new_address(&self) -> Result { - let funding_address = self.wallet.get_new_address()?; + let funding_address = self.runtime.block_on(self.wallet.get_new_address())?; log_info!(self.logger, "Generated new funding address: {}", funding_address); Ok(funding_address) } @@ -80,16 +96,8 @@ impl OnchainPayment { pub fn send_to_address( &self, address: &bitcoin::Address, amount_sats: u64, fee_rate: Option, ) -> Result { - if !*self.is_running.read().expect("lock") { - return Err(Error::NotRunning); - } - - let cur_anchor_reserve_sats = - crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); - let send_amount = - OnchainSendAmount::ExactRetainingReserve { amount_sats, cur_anchor_reserve_sats }; let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); - self.wallet.send_to_address(address, send_amount, fee_rate_opt) + self.runtime.block_on(self.send_to_address_inner(address, amount_sats, fee_rate_opt)) } /// Send an on-chain payment to the given address, draining the available funds. @@ -123,7 +131,7 @@ impl OnchainPayment { }; let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); - self.wallet.send_to_address(address, send_amount, fee_rate_opt) + self.runtime.block_on(self.wallet.send_to_address(address, send_amount, fee_rate_opt)) } /// Attempt to bump the fee of an unconfirmed transaction using Replace-by-Fee (RBF). @@ -146,6 +154,10 @@ impl OnchainPayment { let cur_anchor_reserve_sats = crate::total_anchor_channels_reserve_sats(&self.channel_manager, &self.config); let fee_rate_opt = maybe_map_fee_rate_opt!(fee_rate); - self.wallet.bump_fee_rbf(payment_id, fee_rate_opt, cur_anchor_reserve_sats) + self.runtime.block_on(self.wallet.bump_fee_rbf( + payment_id, + fee_rate_opt, + cur_anchor_reserve_sats, + )) } } diff --git a/src/payment/unified.rs b/src/payment/unified.rs index 2ad77f7728..cb51174140 100644 --- a/src/payment/unified.rs +++ b/src/payment/unified.rs @@ -328,7 +328,10 @@ impl UnifiedPayment { Error::InvalidAmount })?; - let txid = self.onchain_payment.send_to_address(&address, amt_sats, None)?; + let txid = self + .onchain_payment + .send_to_address_inner(&address, amt_sats, None) + .await?; return Ok(UnifiedPaymentResult::Onchain { txid }); }, } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 29c1c75345..595b3fb840 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -84,7 +84,7 @@ const DUST_LIMIT_SATS: u64 = 546; pub(crate) struct Wallet { // A BDK on-chain wallet. inner: Mutex>, - persister: Mutex, + persister: tokio::sync::Mutex, broadcaster: Arc, fee_estimator: Arc, chain_source: Arc, @@ -104,7 +104,7 @@ impl Wallet { logger: Arc, pending_payment_store: Arc, ) -> Self { let inner = Mutex::new(wallet); - let persister = Mutex::new(wallet_persister); + let persister = tokio::sync::Mutex::new(wallet_persister); Self { inner, persister, @@ -154,56 +154,57 @@ impl Wallet { BlockLocator { block_hash: checkpoint.hash(), height: checkpoint.height(), previous_blocks } } - pub(crate) fn apply_update(&self, update: impl Into) -> Result<(), Error> { - let mut locked_wallet = self.inner.lock().expect("lock"); - match locked_wallet.apply_update_events(update) { - Ok(events) => { - self.update_payment_store(&mut *locked_wallet, events).map_err(|e| { - log_error!(self.logger, "Failed to update payment store: {}", e); - Error::PersistenceFailed - })?; - - let mut locked_persister = self.persister.lock().expect("lock"); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err( - |e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - }, - )?; + pub(crate) async fn apply_update(&self, update: impl Into) -> Result<(), Error> { + let mut locked_persister = self.persister.lock().await; + let events = { + let mut locked_wallet = self.inner.lock().expect("lock"); + match locked_wallet.apply_update_events(update) { + Ok(events) => events, + Err(e) => { + log_error!(self.logger, "Sync failed due to chain connection error: {}", e); + return Err(Error::WalletOperationFailed); + }, + } + }; + self.update_payment_store(events).await.map_err(|e| { + log_error!(self.logger, "Failed to update payment store: {}", e); + Error::PersistenceFailed + })?; - Ok(()) - }, - Err(e) => { - log_error!(self.logger, "Sync failed due to chain connection error: {}", e); - Err(Error::WalletOperationFailed) - }, - } + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + })?; + Ok(()) } - pub(crate) fn apply_mempool_txs( + pub(crate) async fn apply_mempool_txs( &self, unconfirmed_txs: Vec<(Transaction, u64)>, evicted_txids: Vec<(Txid, u64)>, ) -> Result<(), Error> { if unconfirmed_txs.is_empty() && evicted_txids.is_empty() { return Ok(()); } - let mut locked_wallet = self.inner.lock().expect("lock"); - - let events = locked_wallet - .events_helper(|wallet| -> Result<(), std::convert::Infallible> { - wallet.apply_unconfirmed_txs(unconfirmed_txs); - wallet.apply_evicted_txs(evicted_txids); - Ok(()) - }) - .expect("applying mempool updates cannot fail"); + let mut locked_persister = self.persister.lock().await; + let events = { + let mut locked_wallet = self.inner.lock().expect("lock"); + locked_wallet + .events_helper(|wallet| -> Result<(), std::convert::Infallible> { + wallet.apply_unconfirmed_txs(unconfirmed_txs); + wallet.apply_evicted_txs(evicted_txids); + Ok(()) + }) + .expect("applying mempool updates cannot fail") + }; - self.update_payment_store(&mut *locked_wallet, events).map_err(|e| { + self.update_payment_store(events).await.map_err(|e| { log_error!(self.logger, "Failed to update payment store: {}", e); Error::PersistenceFailed })?; - let mut locked_persister = self.persister.lock().expect("lock"); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; @@ -211,10 +212,7 @@ impl Wallet { Ok(()) } - fn update_payment_store<'a>( - &self, locked_wallet: &'a mut PersistedWallet, - mut events: Vec, - ) -> Result<(), Error> { + async fn update_payment_store(&self, mut events: Vec) -> Result<(), Error> { if events.is_empty() { return Ok(()); } @@ -242,7 +240,7 @@ impl Wallet { for event in events { match event { WalletEvent::TxConfirmed { txid, tx, block_time, .. } => { - let cur_height = locked_wallet.latest_checkpoint().height(); + let cur_height = self.inner.lock().expect("lock").latest_checkpoint().height(); let confirmation_height = block_time.block_id.height; let payment_status = if cur_height >= confirmation_height + ANTI_REORG_DELAY - 1 { @@ -261,28 +259,32 @@ impl Wallet { .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self.apply_funding_status_update(payment_id, txid, confirmation_status)? { + if self + .apply_funding_status_update(payment_id, txid, confirmation_status) + .await? + { continue; } - let payment = self.create_payment_from_tx( - locked_wallet, - txid, - payment_id, - &tx, - payment_status, - confirmation_status, - ); + let payment = { + let locked_wallet = self.inner.lock().expect("lock"); + self.create_payment_from_tx( + &locked_wallet, + txid, + payment_id, + &tx, + payment_status, + confirmation_status, + ) + }; - self.runtime.block_on(self.payment_store.insert_or_update(payment.clone()))?; + self.payment_store.insert_or_update(payment.clone()).await?; if payment_status == PaymentStatus::Pending { let pending_payment = self.create_pending_payment_from_tx(payment, Vec::new()); - self.runtime.block_on( - self.pending_payment_store.insert_or_update(pending_payment), - )?; + self.pending_payment_store.insert_or_update(pending_payment).await?; } }, WalletEvent::ChainTipChanged { new_tip, .. } => { @@ -308,11 +310,8 @@ impl Wallet { let payment_id = payment.details.id; if new_tip.height >= height + ANTI_REORG_DELAY - 1 { payment.details.status = PaymentStatus::Succeeded; - self.runtime.block_on( - self.payment_store.insert_or_update(payment.details), - )?; - self.runtime - .block_on(self.pending_payment_store.remove(&payment_id))?; + self.payment_store.insert_or_update(payment.details).await?; + self.pending_payment_store.remove(&payment_id).await?; } }, PaymentKind::Onchain { @@ -326,20 +325,28 @@ impl Wallet { } } - let count: usize = unconfirmed_outbound_txids - .into_iter() - .filter_map(|txid| { - let tx = locked_wallet.tx_details(txid).map(|d| (*d.tx).clone())?; - self.broadcaster.broadcast_unclassified_transaction(tx); - Some(()) - }) - .count(); - if count != 0 { - log_info!( - self.logger, - "Rebroadcast {} unconfirmed transactions on chain tip change", - count, - ); + if !unconfirmed_outbound_txids.is_empty() { + let txs_to_broadcast: Vec = { + let locked_wallet = self.inner.lock().expect("lock"); + unconfirmed_outbound_txids + .iter() + .filter_map(|txid| { + locked_wallet.tx_details(*txid).map(|d| (*d.tx).clone()) + }) + .collect() + }; + + if !txs_to_broadcast.is_empty() { + let tx_count = txs_to_broadcast.len(); + for tx in txs_to_broadcast { + self.broadcaster.broadcast_unclassified_transaction(tx); + } + log_info!( + self.logger, + "Rebroadcast {} unconfirmed transactions on chain tip change", + tx_count + ); + } } }, WalletEvent::TxUnconfirmed { txid, tx, .. } => { @@ -347,27 +354,32 @@ impl Wallet { .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self.apply_funding_status_update( - payment_id, - txid, - ConfirmationStatus::Unconfirmed, - )? { + if self + .apply_funding_status_update( + payment_id, + txid, + ConfirmationStatus::Unconfirmed, + ) + .await? + { continue; } - let payment = self.create_payment_from_tx( - locked_wallet, - txid, - payment_id, - &tx, - PaymentStatus::Pending, - ConfirmationStatus::Unconfirmed, - ); + let payment = { + let locked_wallet = self.inner.lock().expect("lock"); + self.create_payment_from_tx( + &locked_wallet, + txid, + payment_id, + &tx, + PaymentStatus::Pending, + ConfirmationStatus::Unconfirmed, + ) + }; let pending_payment = self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.runtime.block_on(self.payment_store.insert_or_update(payment))?; - self.runtime - .block_on(self.pending_payment_store.insert_or_update(pending_payment))?; + self.payment_store.insert_or_update(payment).await?; + self.pending_payment_store.insert_or_update(pending_payment).await?; }, WalletEvent::TxReplaced { txid, conflicts, .. } => { let Some(payment_id) = self.find_payment_by_txid(txid) else { @@ -397,36 +409,39 @@ impl Wallet { let pending_payment_details = self.create_pending_payment_from_tx(payment, conflict_txids.clone()); - self.runtime.block_on( - self.pending_payment_store.insert_or_update(pending_payment_details), - )?; + self.pending_payment_store.insert_or_update(pending_payment_details).await?; }, WalletEvent::TxDropped { txid, tx } => { let payment_id = self .find_payment_by_txid(txid) .unwrap_or_else(|| PaymentId(txid.to_byte_array())); - if self.apply_funding_status_update( - payment_id, - txid, - ConfirmationStatus::Unconfirmed, - )? { + if self + .apply_funding_status_update( + payment_id, + txid, + ConfirmationStatus::Unconfirmed, + ) + .await? + { continue; } - let payment = self.create_payment_from_tx( - locked_wallet, - txid, - payment_id, - &tx, - PaymentStatus::Pending, - ConfirmationStatus::Unconfirmed, - ); + let payment = { + let locked_wallet = self.inner.lock().expect("lock"); + self.create_payment_from_tx( + &locked_wallet, + txid, + payment_id, + &tx, + PaymentStatus::Pending, + ConfirmationStatus::Unconfirmed, + ) + }; let pending_payment = self.create_pending_payment_from_tx(payment.clone(), Vec::new()); - self.runtime.block_on(self.payment_store.insert_or_update(payment))?; - self.runtime - .block_on(self.pending_payment_store.insert_or_update(pending_payment))?; + self.payment_store.insert_or_update(payment).await?; + self.pending_payment_store.insert_or_update(pending_payment).await?; }, _ => { continue; @@ -438,42 +453,43 @@ impl Wallet { } #[allow(deprecated)] - pub(crate) fn create_funding_transaction( + pub(crate) async fn create_funding_transaction( &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, locktime: LockTime, ) -> Result { let fee_rate = self.fee_estimator.estimate_fee_rate(confirmation_target); + let mut locked_persister = self.persister.lock().await; + let (psbt, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); + let mut tx_builder = locked_wallet.build_tx(); + tx_builder.add_recipient(output_script, amount).fee_rate(fee_rate).nlocktime(locktime); - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut tx_builder = locked_wallet.build_tx(); + let mut psbt = match tx_builder.finish() { + Ok(psbt) => { + log_trace!(self.logger, "Created funding PSBT: {:?}", psbt); + psbt + }, + Err(err) => { + log_error!(self.logger, "Failed to create funding transaction: {}", err); + return Err(err.into()); + }, + }; - tx_builder.add_recipient(output_script, amount).fee_rate(fee_rate).nlocktime(locktime); + match locked_wallet.sign(&mut psbt, SignOptions::default()) { + Ok(finalized) => { + if !finalized { + return Err(Error::OnchainTxCreationFailed); + } + }, + Err(err) => { + log_error!(self.logger, "Failed to create funding transaction: {}", err); + return Err(err.into()); + }, + } - let mut psbt = match tx_builder.finish() { - Ok(psbt) => { - log_trace!(self.logger, "Created funding PSBT: {:?}", psbt); - psbt - }, - Err(err) => { - log_error!(self.logger, "Failed to create funding transaction: {}", err); - return Err(err.into()); - }, + (psbt, locked_wallet.take_staged().unwrap_or_default()) }; - - match locked_wallet.sign(&mut psbt, SignOptions::default()) { - Ok(finalized) => { - if !finalized { - return Err(Error::OnchainTxCreationFailed); - } - }, - Err(err) => { - log_error!(self.logger, "Failed to create funding transaction: {}", err); - return Err(err.into()); - }, - } - - let mut locked_persister = self.persister.lock().expect("lock"); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; @@ -486,36 +502,42 @@ impl Wallet { Ok(tx) } - pub(crate) fn get_new_address(&self) -> Result { - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut locked_persister = self.persister.lock().expect("lock"); - - let address_info = locked_wallet.reveal_next_address(KeychainKind::External); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + pub(crate) async fn get_new_address(&self) -> Result { + let mut locked_persister = self.persister.lock().await; + let (address_info, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); + let address_info = locked_wallet.reveal_next_address(KeychainKind::External); + (address_info, locked_wallet.take_staged().unwrap_or_default()) + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; Ok(address_info.address) } - pub(crate) fn get_new_internal_address(&self) -> Result { - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut locked_persister = self.persister.lock().expect("lock"); - - let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + pub(crate) async fn get_new_internal_address(&self) -> Result { + let mut locked_persister = self.persister.lock().await; + let (address_info, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); + let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); + (address_info, locked_wallet.take_staged().unwrap_or_default()) + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; Ok(address_info.address) } - pub(crate) fn cancel_tx(&self, tx: Transaction) -> Result<(), Error> { - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut locked_persister = self.persister.lock().expect("lock"); - - Self::cancel_tx_inner(&mut locked_wallet, tx); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + pub(crate) async fn cancel_tx(&self, tx: Transaction) -> Result<(), Error> { + let mut locked_persister = self.persister.lock().await; + let change_set = { + let mut locked_wallet = self.inner.lock().expect("lock"); + Self::cancel_tx_inner(&mut locked_wallet, tx); + locked_wallet.take_staged().unwrap_or_default() + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; @@ -729,7 +751,7 @@ impl Wallet { } #[allow(deprecated)] - pub(crate) fn send_to_address( + pub(crate) async fn send_to_address( &self, address: &bitcoin::Address, send_amount: OnchainSendAmount, fee_rate: Option, ) -> Result { @@ -740,7 +762,8 @@ impl Wallet { let fee_rate = fee_rate.unwrap_or_else(|| self.fee_estimator.estimate_fee_rate(confirmation_target)); - let tx = { + let mut locked_persister = self.persister.lock().await; + let (psbt, change_set) = { let mut locked_wallet = self.inner.lock().expect("lock"); // Prepare the tx_builder. We properly check the reserve requirements (again) further down. @@ -863,19 +886,17 @@ impl Wallet { }, } - let mut locked_persister = self.persister.lock().expect("lock"); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err( - |e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - Error::PersistenceFailed - }, - )?; - - psbt.extract_tx().map_err(|e| { - log_error!(self.logger, "Failed to extract transaction: {}", e); - e - })? + (psbt, locked_wallet.take_staged().unwrap_or_default()) }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + })?; + + let tx = psbt.extract_tx().map_err(|e| { + log_error!(self.logger, "Failed to extract transaction: {}", e); + e + })?; let txid = tx.compute_txid(); self.broadcaster.broadcast_unclassified_transaction(tx); @@ -912,83 +933,91 @@ impl Wallet { Ok(txid) } - pub(crate) fn select_confirmed_utxos( + pub(crate) async fn select_confirmed_utxos( &self, must_spend: Vec, must_pay_to: &[TxOut], fee_rate: FeeRate, ) -> Result { - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut locked_persister = self.persister.lock().expect("lock"); - - debug_assert!(matches!( - locked_wallet.public_descriptor(KeychainKind::External), - ExtendedDescriptor::Wpkh(_) - )); - debug_assert!(matches!( - locked_wallet.public_descriptor(KeychainKind::Internal), - ExtendedDescriptor::Wpkh(_) - )); + let mut locked_persister = self.persister.lock().await; + let (coin_selection, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); - let mut tx_builder = locked_wallet.build_tx(); - tx_builder.only_witness_utxo(); + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::External), + ExtendedDescriptor::Wpkh(_) + )); + debug_assert!(matches!( + locked_wallet.public_descriptor(KeychainKind::Internal), + ExtendedDescriptor::Wpkh(_) + )); + + let mut tx_builder = locked_wallet.build_tx(); + tx_builder.only_witness_utxo(); + + for input in &must_spend { + let psbt_input = psbt::Input { + witness_utxo: Some(input.previous_utxo.clone()), + ..Default::default() + }; + let weight = ldk_to_bdk_satisfaction_weight(input.satisfaction_weight); + tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|_| ())?; + } - for input in &must_spend { - let psbt_input = psbt::Input { - witness_utxo: Some(input.previous_utxo.clone()), - ..Default::default() - }; - let weight = ldk_to_bdk_satisfaction_weight(input.satisfaction_weight); - tx_builder.add_foreign_utxo(input.outpoint, psbt_input, weight).map_err(|_| ())?; - } + for output in must_pay_to { + tx_builder.add_recipient(output.script_pubkey.clone(), output.value); + } - for output in must_pay_to { - tx_builder.add_recipient(output.script_pubkey.clone(), output.value); - } + tx_builder.fee_rate(fee_rate); + tx_builder.exclude_unconfirmed(); - tx_builder.fee_rate(fee_rate); - tx_builder.exclude_unconfirmed(); + let unsigned_tx = tx_builder + .finish() + .map_err(|e| { + log_error!(self.logger, "Failed to select confirmed UTXOs: {}", e); + })? + .unsigned_tx; + + let confirmed_utxos = unsigned_tx + .input + .iter() + .filter(|txin| { + must_spend.iter().all(|input| input.outpoint != txin.previous_output) + }) + .filter_map(|txin| { + locked_wallet + .tx_details(txin.previous_output.txid) + .map(|tx_details| tx_details.tx.deref().clone()) + .map(|prevtx| ConfirmedUtxo::new_p2wpkh(prevtx, txin.previous_output.vout)) + }) + .collect::, ()>>()?; - let unsigned_tx = tx_builder - .finish() - .map_err(|e| { - log_error!(self.logger, "Failed to select confirmed UTXOs: {}", e); - })? - .unsigned_tx; + if unsigned_tx.output.len() > must_pay_to.len() + 1 { + log_error!( + self.logger, + "Unexpected number of change outputs during coin selection: {}", + unsigned_tx.output.len() - must_pay_to.len(), + ); + return Err(()); + } - let confirmed_utxos = unsigned_tx - .input - .iter() - .filter(|txin| must_spend.iter().all(|input| input.outpoint != txin.previous_output)) - .filter_map(|txin| { - locked_wallet - .tx_details(txin.previous_output.txid) - .map(|tx_details| tx_details.tx.deref().clone()) - .map(|prevtx| ConfirmedUtxo::new_p2wpkh(prevtx, txin.previous_output.vout)) - }) - .collect::, ()>>()?; + let change_output = unsigned_tx + .output + .into_iter() + .find(|txout| must_pay_to.iter().all(|output| output != txout)); + let change_set = if change_output.is_some() { + Some(locked_wallet.take_staged().unwrap_or_default()) + } else { + None + }; - if unsigned_tx.output.len() > must_pay_to.len() + 1 { - log_error!( - self.logger, - "Unexpected number of change outputs during coin selection: {}", - unsigned_tx.output.len() - must_pay_to.len(), - ); - return Err(()); - } + (CoinSelection { confirmed_utxos, change_output }, change_set) + }; - let change_output = unsigned_tx - .output - .into_iter() - .find(|txout| must_pay_to.iter().all(|output| output != txout)); - - if change_output.is_some() { - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err( - |e| { - log_error!(self.logger, "Failed to persist wallet: {}", e); - () - }, - )?; + if let Some(change_set) = change_set { + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + })?; } - Ok(CoinSelection { confirmed_utxos, change_output }) + Ok(coin_selection) } fn list_confirmed_utxos_inner(&self) -> Result, ()> { @@ -1085,14 +1114,15 @@ impl Wallet { } #[allow(deprecated)] - fn get_change_script_inner(&self) -> Result { - let mut locked_wallet = self.inner.lock().expect("lock"); - let mut locked_persister = self.persister.lock().expect("lock"); - - let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { + async fn get_change_script_inner(&self) -> Result { + let mut locked_persister = self.persister.lock().await; + let (address_info, change_set) = { + let mut locked_wallet = self.inner.lock().expect("lock"); + let address_info = locked_wallet.next_unused_address(KeychainKind::Internal); + (address_info, locked_wallet.take_staged().unwrap_or_default()) + }; + locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); - () })?; Ok(address_info.address.script_pubkey()) } @@ -1466,7 +1496,7 @@ impl Wallet { /// `sent`/`received` don't capture our contribution to a shared funding output. Returns `true` /// when it handled the payment, so the caller skips the default on-chain path. Graduation to /// `Succeeded` is left to `ChainTipChanged` after `ANTI_REORG_DELAY`. - fn apply_funding_status_update( + async fn apply_funding_status_update( &self, payment_id: PaymentId, event_txid: Txid, confirmation_status: ConfirmationStatus, ) -> Result { let Some(mut payment) = self.payment_store.get(&payment_id) else { @@ -1496,20 +1526,20 @@ impl Wallet { payment.kind = PaymentKind::Onchain { txid: event_txid, status: confirmation_status, tx_type }; - self.runtime.block_on(self.payment_store.insert_or_update(payment.clone()))?; + self.payment_store.insert_or_update(payment.clone()).await?; // Mirror the refreshed confirmation status onto the pending entry: `ChainTipChanged` // graduates by reading the pending entry's details, so it must see the new status. This is // the same dual-write the default `TxConfirmed` path performs; an empty conflicting-txids // list leaves any stored conflicts intact (the update treats absent as "unchanged"). if payment.status == PaymentStatus::Pending { let pending = self.create_pending_payment_from_tx(payment, Vec::new()); - self.runtime.block_on(self.pending_payment_store.insert_or_update(pending))?; + self.pending_payment_store.insert_or_update(pending).await?; } Ok(true) } #[allow(deprecated)] - pub(crate) fn bump_fee_rbf( + pub(crate) async fn bump_fee_rbf( &self, payment_id: PaymentId, fee_rate: Option, cur_anchor_reserve_sats: u64, ) -> Result { let payment = self.payment_store.get(&payment_id).ok_or_else(|| { @@ -1570,6 +1600,7 @@ impl Wallet { }, }; + let mut locked_persister = self.persister.lock().await; let mut locked_wallet = self.inner.lock().expect("lock"); debug_assert!( @@ -1729,12 +1760,6 @@ impl Wallet { }, } - let mut locked_persister = self.persister.lock().expect("lock"); - self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)).map_err(|e| { - log_error!(self.logger, "Failed to persist wallet after fee bump of {}: {}", txid, e); - Error::PersistenceFailed - })?; - let fee_bumped_tx = psbt.extract_tx().map_err(|e| { log_error!(self.logger, "Failed to extract fee bump transaction for {}: {}", txid, e); e @@ -1753,10 +1778,15 @@ impl Wallet { let pending_payment_store = self.create_pending_payment_from_tx(new_payment.clone(), Vec::new()); + let change_set = locked_wallet.take_staged().unwrap_or_default(); + drop(locked_wallet); + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet after fee bump of {}: {}", txid, e); + Error::PersistenceFailed + })?; - self.runtime.block_on(self.payment_store.insert_or_update(new_payment))?; - self.runtime - .block_on(self.pending_payment_store.insert_or_update(pending_payment_store))?; + self.payment_store.insert_or_update(new_payment).await?; + self.pending_payment_store.insert_or_update(pending_payment_store).await?; self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx); @@ -1820,64 +1850,66 @@ impl Listen for Wallet { } fn block_connected(&self, block: &bitcoin::Block, height: u32) { - let mut locked_wallet = self.inner.lock().expect("lock"); - - let pre_checkpoint = locked_wallet.latest_checkpoint(); - if pre_checkpoint.height() != height - 1 - || pre_checkpoint.hash() != block.header.prev_blockhash - { - log_debug!( - self.logger, - "Detected reorg while applying a connected block to on-chain wallet: new block with hash {} at height {}", - block.header.block_hash(), - height - ); - } + self.runtime.block_on(async { + let mut locked_persister = self.persister.lock().await; + let events = { + let mut locked_wallet = self.inner.lock().expect("lock"); + + let pre_checkpoint = locked_wallet.latest_checkpoint(); + if pre_checkpoint.height() != height - 1 + || pre_checkpoint.hash() != block.header.prev_blockhash + { + log_debug!( + self.logger, + "Detected reorg while applying a connected block to on-chain wallet: new block with hash {} at height {}", + block.header.block_hash(), + height + ); + } - // In order to be able to reliably calculate fees the `Wallet` needs access to the previous - // ouput data. To this end, we here insert any ouputs of transactions that LDK is intersted - // in (e.g., funding transaction ouputs) into the wallet's transaction graph when we see - // them, so it is reliably able to calculate fees for subsequent spends. - // - // FIXME: technically, we should also do this for mempool transactions. However, at the - // current time fixing the edge case doesn't seem worth the additional conplexity / - // additional overhead.. - let registered_txids = self.chain_source.registered_txids(); - for tx in &block.txdata { - let txid = tx.compute_txid(); - if registered_txids.contains(&txid) { - for (vout, txout) in tx.output.iter().enumerate() { - let outpoint = OutPoint { txid, vout: vout as u32 }; - locked_wallet.insert_txout(outpoint, txout.clone()); + // In order to be able to reliably calculate fees the `Wallet` needs access to the previous + // ouput data. To this end, we here insert any ouputs of transactions that LDK is intersted + // in (e.g., funding transaction ouputs) into the wallet's transaction graph when we see + // them, so it is reliably able to calculate fees for subsequent spends. + // + // FIXME: technically, we should also do this for mempool transactions. However, at the + // current time fixing the edge case doesn't seem worth the additional conplexity / + // additional overhead.. + let registered_txids = self.chain_source.registered_txids(); + for tx in &block.txdata { + let txid = tx.compute_txid(); + if registered_txids.contains(&txid) { + for (vout, txout) in tx.output.iter().enumerate() { + let outpoint = OutPoint { txid, vout: vout as u32 }; + locked_wallet.insert_txout(outpoint, txout.clone()); + } + } } - } - } - match locked_wallet.apply_block_events(block, height) { - Ok(events) => { - if let Err(e) = self.update_payment_store(&mut *locked_wallet, events) { - log_error!(self.logger, "Failed to update payment store: {}", e); - return; + match locked_wallet.apply_block_events(block, height) { + Ok(events) => events, + Err(e) => { + log_error!( + self.logger, + "Failed to apply connected block to on-chain wallet: {}", + e + ); + return; + }, } - }, - Err(e) => { - log_error!( - self.logger, - "Failed to apply connected block to on-chain wallet: {}", - e - ); + }; + + if let Err(e) = self.update_payment_store(events).await { + log_error!(self.logger, "Failed to update payment store: {}", e); return; - }, - }; + } - let mut locked_persister = self.persister.lock().expect("lock"); - match self.runtime.block_on(locked_wallet.persist_async(&mut locked_persister)) { - Ok(_) => (), - Err(e) => { + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + if let Err(e) = locked_persister.persist_changeset(change_set).await { log_error!(self.logger, "Failed to persist on-chain wallet: {}", e); return; - }, - }; + } + }); } fn blocks_disconnected(&self, _fork_point_block: BlockLocator) { @@ -1895,7 +1927,7 @@ impl WalletSource for Wallet { } fn get_change_script<'a>(&'a self) -> impl Future> + Send + 'a { - async move { self.get_change_script_inner() } + async move { self.get_change_script_inner().await } } fn get_prevtx<'a>( @@ -1932,7 +1964,7 @@ impl CoinSelectionSource for Wallet { ) -> impl Future> + Send + 'a { debug_assert!(claim_id.is_none()); let fee_rate = FeeRate::from_sat_per_kwu(target_feerate_sat_per_1000_weight as u64); - async move { self.select_confirmed_utxos(must_spend, must_pay_to, fee_rate) } + async move { self.select_confirmed_utxos(must_spend, must_pay_to, fee_rate).await } } fn sign_psbt<'a>( @@ -2056,14 +2088,14 @@ impl SignerProvider for WalletKeysManager { } fn get_destination_script(&self, _channel_keys_id: [u8; 32]) -> Result { - let address = self.wallet.get_new_address().map_err(|e| { + let address = self.wallet.runtime.block_on(self.wallet.get_new_address()).map_err(|e| { log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); })?; Ok(address.script_pubkey()) } fn get_shutdown_scriptpubkey(&self) -> Result { - let address = self.wallet.get_new_address().map_err(|e| { + let address = self.wallet.runtime.block_on(self.wallet.get_new_address()).map_err(|e| { log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); })?; @@ -2089,6 +2121,7 @@ impl ChangeDestinationSource for WalletKeysManager { async move { self.wallet .get_new_internal_address() + .await .map_err(|e| { log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e); }) diff --git a/src/wallet/persist.rs b/src/wallet/persist.rs index 364dc4b475..9d33a09f93 100644 --- a/src/wallet/persist.rs +++ b/src/wallet/persist.rs @@ -22,13 +22,14 @@ use crate::types::DynStore; pub(crate) struct KVStoreWalletPersister { latest_change_set: Option, + pending_change_set: ChangeSet, kv_store: Arc, logger: Arc, } impl KVStoreWalletPersister { pub(crate) fn new(kv_store: Arc, logger: Arc) -> Self { - Self { latest_change_set: None, kv_store, logger } + Self { latest_change_set: None, pending_change_set: ChangeSet::default(), kv_store, logger } } async fn initialize_inner(&mut self) -> Result { @@ -52,17 +53,19 @@ impl KVStoreWalletPersister { Ok(change_set) } - async fn persist_inner(&mut self, change_set: &ChangeSet) -> Result<(), std::io::Error> { + async fn persist_inner( + latest_change_set_opt: &mut Option, kv_store: &Arc, + logger: &Arc, change_set: &ChangeSet, + ) -> Result<(), std::io::Error> { if change_set.is_empty() { return Ok(()); } - let kv_store = Arc::clone(&self.kv_store); - let logger = Arc::clone(&self.logger); + let kv_store = kv_store.as_ref(); // We're allowed to fail here if we're not initialized, BDK docs state: "This method can fail if the // persister is not initialized." - let latest_change_set = self.latest_change_set.as_mut().ok_or_else(|| { + let latest_change_set = latest_change_set_opt.as_mut().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::Other, "Wallet must be initialized before calling persist", @@ -169,6 +172,21 @@ impl KVStoreWalletPersister { Ok(()) } + + pub(super) async fn persist_changeset( + &mut self, change_set: ChangeSet, + ) -> Result<(), std::io::Error> { + self.pending_change_set.merge(change_set); + Self::persist_inner( + &mut self.latest_change_set, + &self.kv_store, + &self.logger, + &self.pending_change_set, + ) + .await?; + let _ = std::mem::take(&mut self.pending_change_set); + Ok(()) + } } impl AsyncWalletPersister for KVStoreWalletPersister { @@ -189,6 +207,138 @@ impl AsyncWalletPersister for KVStoreWalletPersister { where Self: 'a, { - Box::pin(persister.persist_inner(change_set)) + Box::pin(Self::persist_inner( + &mut persister.latest_change_set, + &persister.kv_store, + &persister.logger, + change_set, + )) + } +} + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::sync::Arc; + use std::time::Duration; + + use bdk_wallet::{AsyncWalletPersister, ChangeSet, Wallet as BdkWallet}; + use bitcoin::Network; + use lightning::io; + use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; + + use super::KVStoreWalletPersister; + use crate::io::test_utils::InMemoryStore; + use crate::logger::Logger; + use crate::types::{DynStore, DynStoreWrapper}; + + const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; + const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; + + #[derive(Clone)] + struct GatedStore { + inner: Arc, + write_gate: Arc>, + } + + impl KVStore for GatedStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let write_gate = Arc::clone(&self.write_gate); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + let _guard = write_gate.read().await; + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + } + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for GatedStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl Future> + 'static + Send { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + + #[tokio::test] + async fn retains_pending_changes_when_persist_is_cancelled() { + let gated_store = GatedStore { + inner: Arc::new(InMemoryStore::new()), + write_gate: Arc::new(tokio::sync::RwLock::new(())), + }; + let store: Arc = Arc::new(DynStoreWrapper(gated_store.clone())); + let logger = Arc::new(Logger::new_log_facade()); + let mut persister = KVStoreWalletPersister::new(Arc::clone(&store), Arc::clone(&logger)); + AsyncWalletPersister::initialize(&mut persister).await.unwrap(); + + let mut wallet = BdkWallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let change_set = wallet.take_staged().unwrap(); + + let gate_guard = gated_store.write_gate.write().await; + { + let persist_fut = persister.persist_changeset(change_set); + tokio::pin!(persist_fut); + let poll_res = tokio::time::timeout(Duration::from_millis(100), &mut persist_fut).await; + assert!(poll_res.is_err(), "persist should be parked on the gated store write"); + } + drop(gate_guard); + + persister.persist_changeset(ChangeSet::default()).await.unwrap(); + + let mut reloaded_persister = KVStoreWalletPersister::new(store, logger); + let reloaded = AsyncWalletPersister::initialize(&mut reloaded_persister).await.unwrap(); + assert_eq!(reloaded.network, Some(Network::Regtest)); + } + + #[tokio::test] + async fn retries_changes_after_persistence_failure() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(Logger::new_log_facade()); + let mut persister = KVStoreWalletPersister::new(Arc::clone(&store), Arc::clone(&logger)); + let mut wallet = BdkWallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let change_set = wallet.take_staged().unwrap(); + + assert!(persister.persist_changeset(change_set).await.is_err()); + AsyncWalletPersister::initialize(&mut persister).await.unwrap(); + persister.persist_changeset(ChangeSet::default()).await.unwrap(); + + let mut reloaded_persister = KVStoreWalletPersister::new(store, logger); + let reloaded = AsyncWalletPersister::initialize(&mut reloaded_persister).await.unwrap(); + assert_eq!(reloaded.network, Some(Network::Regtest)); } } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 0b5d9c3504..50e2b993c8 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -63,7 +63,7 @@ use serde_json::{json, Value}; #[path = "../../src/io/in_memory_store.rs"] mod in_memory_store; -use in_memory_store::InMemoryStore; +pub(crate) use in_memory_store::InMemoryStore; /// Shared timeout (in seconds) for waiting on LDK events and external node operations. pub(crate) const INTEROP_TIMEOUT_SECS: u64 = 60; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index a180e4c559..ba15f6fa30 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -8,8 +8,11 @@ mod common; use std::collections::HashSet; +use std::future::Future; use std::str::FromStr; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc}; +use std::time::Duration; use bitcoin::address::NetworkUnchecked; use bitcoin::hashes::sha256::Hash as Sha256Hash; @@ -24,8 +27,8 @@ use common::{ generate_listening_addresses, invalidate_blocks, open_channel, open_channel_push_amt, open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder, setup_node, - setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, TestChainSource, TestConfig, - TestStoreType, TestSyncStore, + setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, InMemoryStore, + TestChainSource, TestConfig, TestStoreType, TestSyncStore, }; use electrsd::corepc_node::{self, Node as BitcoinD}; use electrsd::ElectrsD; @@ -40,6 +43,7 @@ use ldk_node::{BuildError, Builder, Event, Node, NodeError, ReserveType}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; +use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning_invoice::{Bolt11InvoiceDescription, Description}; use lightning_types::payment::{PaymentHash, PaymentPreimage}; use log::LevelFilter; @@ -73,6 +77,145 @@ async fn wait_for_classified_funding_payment(node: &Node, funding_txid: Txid) { }); } +#[derive(Clone)] +struct ContendedStore { + inner: Arc, + serializer: Arc>, + block_writes: Arc, + wallet_write_started: Arc, +} + +impl KVStore for ContendedStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl Future, lightning::io::Error>> + 'static + Send { + KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl Future> + 'static + Send { + let inner = Arc::clone(&self.inner); + let serializer = Arc::clone(&self.serializer); + let block_writes = Arc::clone(&self.block_writes); + let wallet_write_started = Arc::clone(&self.wallet_write_started); + let primary_namespace = primary_namespace.to_string(); + let secondary_namespace = secondary_namespace.to_string(); + let key = key.to_string(); + async move { + if block_writes.load(Ordering::Acquire) { + wallet_write_started.notify_one(); + } + let _guard = serializer.read().await; + KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await + } + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl Future> + 'static + Send { + KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy) + } + + fn list( + &self, primary_namespace: &str, secondary_namespace: &str, + ) -> impl Future, lightning::io::Error>> + 'static + Send { + KVStore::list(&*self.inner, primary_namespace, secondary_namespace) + } +} + +impl PaginatedKVStore for ContendedStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, page_token: Option, + ) -> impl Future> + 'static + Send + { + PaginatedKVStore::list_paginated( + &*self.inner, + primary_namespace, + secondary_namespace, + page_token, + ) + } +} + +#[test] +fn wallet_store_contention_does_not_stall_runtime() { + let (ready_sender, ready_receiver) = mpsc::sync_channel(1); + let (result_sender, result_receiver) = mpsc::sync_channel(1); + std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .expect("test runtime"); + let result = runtime.block_on(async move { + let test_config = random_config(); + let builder = Builder::from_config(test_config.node_config.clone()); + let store = ContendedStore { + inner: Arc::new(InMemoryStore::new()), + serializer: Arc::new(tokio::sync::RwLock::new(())), + block_writes: Arc::new(AtomicBool::new(false)), + wallet_write_started: Arc::new(tokio::sync::Notify::new()), + }; + let node = builder + .build_with_store(test_config.node_entropy.into(), store.clone()) + .map_err(|e| format!("failed to build node: {e:?}"))?; + #[cfg(not(feature = "uniffi"))] + let node = Arc::new(node); + + let serializer = Arc::clone(&store.serializer); + let release_store = Arc::new(tokio::sync::Notify::new()); + let release_store_task = Arc::clone(&release_store); + let (store_locked_sender, store_locked_receiver) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + let _guard = serializer.write().await; + let _ = store_locked_sender.send(()); + release_store_task.notified().await; + }); + store_locked_receiver.await.map_err(|e| format!("store lock task failed: {e}"))?; + store.block_writes.store(true, Ordering::Release); + let _ = ready_sender.send(()); + + let address_node = Arc::clone(&node); + let (address_sender, address_receiver) = tokio::sync::oneshot::channel(); + std::thread::spawn(move || { + let result = address_node.onchain_payment().new_address().map(|_| ()); + let _ = address_sender.send(result); + }); + store.wallet_write_started.notified().await; + + let balances_node = Arc::clone(&node); + let (balances_started_sender, balances_started_receiver) = + tokio::sync::oneshot::channel(); + let balances_task = tokio::spawn(async move { + let _ = balances_started_sender.send(()); + balances_node.list_balances() + }); + balances_started_receiver + .await + .map_err(|e| format!("balance task failed to start: {e}"))?; + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + release_store.notify_one(); + }); + balances_task.await.map_err(|e| format!("balance task failed: {e}"))?; + address_receiver + .await + .map_err(|e| format!("address task failed: {e}"))? + .map_err(|e| format!("address generation failed: {e}")) + }); + let _ = result_sender.send(result); + }); + + ready_receiver + .recv_timeout(Duration::from_secs(30)) + .expect("failed to set up wallet contention test"); + let result = result_receiver + .recv_timeout(Duration::from_secs(3)) + .expect("wallet contention stalled the single-thread runtime"); + result.unwrap_or_else(|e| panic!("wallet contention test failed: {e}")); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_full_cycle() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From d5c60b168c8f88e6efda089d3fbcfd64c4c10824 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 10:22:31 +0200 Subject: [PATCH 099/138] Avoid redundant wallet transaction details Skip computing wallet amounts and fees when rebroadcasting pending transactions because only the transaction itself is needed. Co-Authored-By: HAL 9000 --- src/wallet/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 595b3fb840..f8d9d521eb 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -331,7 +331,9 @@ impl Wallet { unconfirmed_outbound_txids .iter() .filter_map(|txid| { - locked_wallet.tx_details(*txid).map(|d| (*d.tx).clone()) + locked_wallet + .get_tx(*txid) + .map(|tx| tx.tx_node.tx.as_ref().clone()) }) .collect() }; From 0df5b766c5ee97cfa240691262e660c87753123b Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Fri, 5 Jun 2026 07:12:23 +0200 Subject: [PATCH 100/138] Cbf chain source (#25) * Add CBF chain source stubs for starting Add stub methods/functions, add basic build and start of the CBF chain source as well as basic struct containing the fields which undoubtedtly are needed. * Add waiting for gossip propagation in tests Previously tests assumed that the chain source of the lightning node and is node which mines. This is not the case with CBF chain source which needs to wait until after mining a new block a new tips propagates to it. `wait_for_block` is made to return a new height and a new function `wait_for_node_tip` is added which waits until the given height is processed (returned via `status.best_block` ) on a given node. * Populate revealed spks for CBF Ask wallet for revealed spks, register them. Implement `Listen` trait ans add register_script method as well as implementation of registered scripts/outputs. --- Cargo.toml | 1 + src/builder.rs | 2 + src/chain/bitcoind.rs | 25 +++ src/chain/cbf.rs | 316 ++++++++++++++++++++++++++++++++ src/chain/mod.rs | 107 +++++++++-- src/lib.rs | 18 +- src/wallet/mod.rs | 27 ++- tests/common/mod.rs | 55 ++++-- tests/integration_tests_rust.rs | 120 ++++++++---- 9 files changed, 606 insertions(+), 65 deletions(-) create mode 100644 src/chain/cbf.rs diff --git a/Cargo.toml b/Cargo.toml index 7fc945833e..8b441fa58e 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,6 +58,7 @@ bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"]} bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"]} bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]} +bip157 = { version = "0.6.0", default-features = false } bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } diff --git a/src/builder.rs b/src/builder.rs index a70b04b2ab..821aec091a 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1514,6 +1514,8 @@ fn build_with_store_internal( Arc::clone(&node_metrics), ) }, + //TODO add here an arm + // Some(ChainDataSoucrConfig::Cbf) Some(ChainDataSourceConfig::Bitcoind { rpc_host, rpc_port, diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index f857ef5333..75e9869651 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -1458,6 +1458,7 @@ pub(crate) enum FeeRateEstimationMode { Conservative, } +#[derive(Clone)] pub(crate) struct ChainListener { pub(crate) onchain_wallet: Arc, pub(crate) channel_manager: Arc, @@ -1465,6 +1466,30 @@ pub(crate) struct ChainListener { pub(crate) output_sweeper: Arc, } +impl ChainListener { + pub(crate) fn get_best_block(&self) -> BlockLocator { + let candidates = [ + self.onchain_wallet.current_best_block(), + self.channel_manager.current_best_block(), + self.output_sweeper.current_best_block(), + ]; + let mut min = candidates.into_iter().min_by_key(|b| b.height).expect("non-empty"); + if let Some(worst_monitor) = self + .chain_monitor + .list_monitors() + .iter() + .flat_map(|id| self.chain_monitor.get_monitor(*id)) + .map(|m| m.current_best_block()) + .min_by_key(|b| b.height) + { + if worst_monitor.height < min.height { + min = worst_monitor; + } + } + min + } +} + impl Listen for ChainListener { fn filtered_block_connected( &self, header: &bitcoin::block::Header, diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs new file mode 100644 index 0000000000..1b16f2f91b --- /dev/null +++ b/src/chain/cbf.rs @@ -0,0 +1,316 @@ +use std::collections::{HashSet, VecDeque}; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use bip157::chain::ChainState; +use bip157::{ + Builder as KyotoBuilder, Client, HashCheckpoint, Info, Node as KyotoNode, Requester, + TrustedPeer, Warning, +}; +use bitcoin::{BlockHash, FeeRate, Script, ScriptBuf, Txid}; +use lightning::chain::WatchedOutput; +use tokio::sync::mpsc; + +use crate::chain::bitcoind::ChainListener; +use crate::chain::CbfFeeSourceConfig; +use crate::config::Config; +use crate::error::Error; +use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; +use crate::runtime::Runtime; +use crate::types::{ChainMonitor, ChannelManager, Sweeper, Wallet}; + +/// Walk back this many blocks from the wallet's persisted tip when deriving +/// the kyoto resume checkpoint, so a recent reorg cannot strand the node +/// above the new best chain. +const REORG_SAFETY_BLOCKS: u32 = 7; +const BLOCK_FEE_CACHE_CAPACITY: usize = REORG_SAFETY_BLOCKS as usize * 2; + +/// Peer response timeout passed to kyoto's `Builder::response_timeout`. +const DEFAULT_RESPONSE_TIMEOUT_SECS: u64 = 30; + +/// Number of peers that must agree on filter headers before they're accepted. +const DEFAULT_REQUIRED_PEERS: u8 = 1; + +/// Maximum consecutive `node.run()` failures before the restart loop gives up. +const MAX_RESTART_RETRIES: u32 = 5; + +/// Initial backoff delay between restart attempts; doubles each failure. +const INITIAL_BACKOFF_MS: u64 = 500; + +const ESPLORA_TIMEOUT: u64 = 2; + +/// Runtime status of the underlying kyoto node. +enum CbfRuntimeStatus { + Started { requester: Requester }, + Stopped, +} + +/// Struct for holding cbf chain source +pub struct CbfChainSource { + /// Trusted peer addresses for kyoto's `Builder::add_peers`. + trusted_peers: Vec, + registered_scripts: Mutex>, + fee_source: FeeSource, + /// Tracks whether the kyoto node is running and holds the live requester. + cbf_runtime_status: Arc>, + /// Node configuration (network, storage path). + config: Arc, + logger: Arc, +} + +enum FeeSource { + /// Derive fee rates from the coinbase reward of recent blocks. Downloads full blocks in order to calculate fee estimation. + Cbf { block_fee_cache: Mutex> }, + /// Delegate fee estimation to an Esplora HTTP server. + Esplora { client: esplora_client::AsyncClient }, + /// Delegate fee estimation to an Electrum server. + /// + /// A fresh connection is opened for each estimation cycle. + Electrum { server_url: String }, +} + +impl CbfChainSource { + pub(crate) fn new( + peers: Vec, fee_source_config: Option, config: Arc, + logger: Arc, + ) -> Result { + let trusted_peers: Vec = peers + .iter() + .filter_map(|peer_str| { + peer_str.parse::().ok().map(TrustedPeer::from_socket_addr) + }) + .collect(); + + let fee_source = match fee_source_config { + Some(CbfFeeSourceConfig::Esplora(server_url)) => { + let mut esplora_builder = esplora_client::Builder::new(&server_url); + esplora_builder = esplora_builder.timeout(ESPLORA_TIMEOUT); + let client = esplora_builder.build_async().map_err(|e| { + log_error!(logger, "Failed to build esplora client: {}", e); + Error::ConnectionFailed + })?; + FeeSource::Esplora { client } + }, + Some(CbfFeeSourceConfig::Electrum(server_url)) => FeeSource::Electrum { server_url }, + None => FeeSource::Cbf { + block_fee_cache: Mutex::new(VecDeque::with_capacity(BLOCK_FEE_CACHE_CAPACITY)), + }, + }; + let registered_scripts = Mutex::new(HashSet::new()); + let cbf_runtime_status = Arc::new(Mutex::new(CbfRuntimeStatus::Stopped)); + Ok(Self { + trusted_peers, + fee_source, + registered_scripts, + cbf_runtime_status, + config, + logger, + }) + } + + //builds kyoto + fn build( + trusted_peers: &[TrustedPeer], config: &Config, logger: &Logger, + chain_listener: &ChainListener, + ) -> (KyotoNode, Client) { + let mut kyoto_builder = KyotoBuilder::new(config.network); + + let data_dir = std::path::PathBuf::from(&config.storage_dir_path).join("bip157_data"); + kyoto_builder = kyoto_builder.data_dir(data_dir); + + if !trusted_peers.is_empty() { + kyoto_builder = kyoto_builder.add_peers(trusted_peers.to_vec()); + } + + kyoto_builder = kyoto_builder.required_peers(DEFAULT_REQUIRED_PEERS); + kyoto_builder = kyoto_builder.fetch_witness_data(); + kyoto_builder = + kyoto_builder.response_timeout(Duration::from_secs(DEFAULT_RESPONSE_TIMEOUT_SECS)); + + if let Some(header_cp) = Self::resume_checkpoint(logger, chain_listener) { + log_debug!( + logger, + "CBF builder: resuming from checkpoint height={}, hash={}", + header_cp.height, + header_cp.hash, + ); + kyoto_builder = kyoto_builder.chain_state(ChainState::Checkpoint(header_cp)); + } + + kyoto_builder.build() + } + + fn resume_checkpoint( + logger: &Logger, chain_listener: &ChainListener, + ) -> Option { + let min_best_block = chain_listener.get_best_block(); + let bdk_cp = chain_listener.onchain_wallet.latest_checkpoint(); + + if let Some(bdk_at_height) = bdk_cp.get(min_best_block.height) { + if bdk_at_height.hash() != min_best_block.block_hash { + log_error!( + logger, + "CBF resume: listener best block at height {} has hash {} but BDK has {}; \ + a component may be on a stale fork. Anchoring on BDK's chain.", + min_best_block.height, + min_best_block.block_hash, + bdk_at_height.hash(), + ); + } + } + + // Walk BDK's checkpoint chain back to the reorg-safe anchor height. + let target_height = min_best_block.height.saturating_sub(REORG_SAFETY_BLOCKS); + let mut cursor = bdk_cp; + while cursor.height() > target_height { + match cursor.prev() { + Some(prev) => cursor = prev, + None => break, + } + } + + (cursor.height() > 0).then(|| HashCheckpoint::new(cursor.height(), cursor.hash())) + } + + pub(crate) fn start(&self, runtime: Arc, chain_listener: ChainListener) { + //we populate registered scripts with all the scripts from the onchain wallet + for script in chain_listener.onchain_wallet.list_revealed_scripts() { + self.register_script(script); + } + + let (node, client) = + Self::build(&self.trusted_peers, &self.config, &self.logger, &chain_listener); + let Client { requester, info_rx, warn_rx, event_rx: _ } = client; + + { + let mut status = self.cbf_runtime_status.lock().expect("lock"); + if matches!(*status, CbfRuntimeStatus::Started { .. }) { + debug_assert!(false, "start() called while CBF chain source is already running"); + return; + } + *status = CbfRuntimeStatus::Started { requester }; + } + + log_info!(self.logger, "CBF chain source started."); + + let restart_status = Arc::clone(&self.cbf_runtime_status); + let restart_logger = Arc::clone(&self.logger); + let restart_peers = self.trusted_peers.clone(); + let restart_config = Arc::clone(&self.config); + let restart_listener = chain_listener; + + runtime.spawn_background_task(async move { + let mut current_node = node; + let mut current_info_rx = info_rx; + let mut current_warn_rx = warn_rx; + let mut retries = 0u32; + let mut backoff_ms = INITIAL_BACKOFF_MS; + + loop { + let info_handle = tokio::spawn(Self::process_info_messages( + current_info_rx, + Arc::clone(&restart_logger), + )); + let warn_handle = tokio::spawn(Self::process_warn_messages( + current_warn_rx, + Arc::clone(&restart_logger), + )); + + match current_node.run().await { + Ok(()) => { + log_info!(restart_logger, "CBF node shut down cleanly."); + break; + }, + Err(e) => { + retries += 1; + if retries > MAX_RESTART_RETRIES { + log_error!( + restart_logger, + "CBF node failed {} times, giving up: {:?}", + retries, + e, + ); + *restart_status.lock().expect("lock") = CbfRuntimeStatus::Stopped; + break; + } + log_error!( + restart_logger, + "CBF node exited with error (attempt {}/{}): {:?}. Restarting in {}ms.", + retries, + MAX_RESTART_RETRIES, + e, + backoff_ms, + ); + + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + backoff_ms = backoff_ms.saturating_mul(2); + + // Abort the old log consumers before rebuilding. + info_handle.abort(); + warn_handle.abort(); + + let (new_node, new_client) = Self::build( + &restart_peers, + &restart_config, + &restart_logger, + &restart_listener, + ); + let Client { + requester: new_requester, + info_rx: new_info_rx, + warn_rx: new_warn_rx, + event_rx: _, + } = new_client; + + *restart_status.lock().expect("lock") = + CbfRuntimeStatus::Started { requester: new_requester }; + + current_node = new_node; + current_info_rx = new_info_rx; + current_warn_rx = new_warn_rx; + }, + } + } + }); + } + + pub(crate) fn stop(&self) { + todo!(); + } + + async fn process_info_messages(mut info_rx: mpsc::Receiver, logger: Arc) { + while let Some(info) = info_rx.recv().await { + log_debug!(logger, "CBF node info: {}", info); + } + } + + async fn process_warn_messages( + mut warn_rx: mpsc::UnboundedReceiver, logger: Arc, + ) { + while let Some(warning) = warn_rx.recv().await { + log_debug!(logger, "CBF node warning: {}", warning); + } + } + + pub(crate) fn process_kyoto_events( + &self, _stop_sync_receiver: tokio::sync::watch::Receiver<()>, _onchain_wallet: Arc, + _channel_manager: Arc, _chain_monitor: Arc, + _output_sweeper: Arc, + ) { + //here we need to calculate chain update and feed to all listeners + todo!(); + } + + pub(crate) fn register_tx(&self, _txid: &Txid, script_pubkey: &Script) { + self.registered_scripts.lock().expect("lock").insert(script_pubkey.into()); + } + + pub(crate) fn register_output(&self, output: WatchedOutput) { + self.registered_scripts.lock().expect("lock").insert(output.script_pubkey); + } + + pub(crate) fn register_script(&self, script: ScriptBuf) { + self.registered_scripts.lock().expect("lock").insert(script); + } +} diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 0f96c409f8..f1058f8be9 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -6,6 +6,7 @@ // accordance with one or both of these licenses. pub(crate) mod bitcoind; +mod cbf; mod electrum; mod esplora; @@ -13,10 +14,11 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bitcoin::{Script, Txid}; +use bitcoin::{Script, ScriptBuf, Transaction, Txid}; use lightning::chain::{BlockLocator, Filter}; -use crate::chain::bitcoind::{BitcoindChainSource, UtxoSourceClient}; +use crate::chain::bitcoind::{BitcoindChainSource, ChainListener, UtxoSourceClient}; +use crate::chain::cbf::CbfChainSource; use crate::chain::electrum::ElectrumChainSource; use crate::chain::esplora::EsploraChainSource; use crate::config::{ @@ -113,6 +115,20 @@ impl WalletSyncStatus { } } +/// Optional external fee estimation backend for the CBF chain source. +/// +/// By default CBF derives fee rates from recent blocks' coinbase outputs. +/// Setting an external source provides more accurate, per-target estimates +/// from a mempool-aware server. +#[derive(Debug, Clone)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum CbfFeeSourceConfig { + /// Use an Esplora HTTP server for fee rate estimation. + Esplora(String), + /// Use an Electrum server for fee rate estimation. + Electrum(String), +} + pub(crate) struct ChainSource { kind: ChainSourceKind, registered_txids: Mutex>, @@ -124,6 +140,7 @@ enum ChainSourceKind { Esplora(EsploraChainSource), Electrum(ElectrumChainSource), Bitcoind(BitcoindChainSource), + Cbf(CbfChainSource), } impl ChainSource { @@ -215,11 +232,41 @@ impl ChainSource { (Self { kind, registered_txids, tx_broadcaster, logger }, best_block) } - pub(crate) fn start(&self, runtime: Arc) -> Result<(), Error> { + pub(crate) fn new_cbf( + peers: Vec, fee_source_config: Option, + fee_estimator: Arc, tx_broadcaster: Arc, + kv_store: Arc, config: Arc, logger: Arc, + node_metrics: Arc, + ) -> Result<(Self, Option), Error> { + let cbf_chain_source = CbfChainSource::new( + peers, + fee_source_config, + Arc::clone(&config), + Arc::clone(&logger), + )?; + let kind = ChainSourceKind::Cbf(cbf_chain_source); + let registered_txids = Mutex::new(HashSet::new()); + Ok((Self { kind, registered_txids, tx_broadcaster, logger }, None)) + } + + pub(crate) fn start( + &self, runtime: Arc, onchain_wallet: Arc, + channel_manager: Arc, chain_monitor: Arc, + output_sweeper: Arc, + ) -> Result<(), Error> { match &self.kind { ChainSourceKind::Electrum(electrum_chain_source) => { electrum_chain_source.start(runtime)? }, + ChainSourceKind::Cbf(cbf_chain_source) => { + let chain_listener = ChainListener { + onchain_wallet, + channel_manager, + chain_monitor, + output_sweeper, + }; + cbf_chain_source.start(runtime, chain_listener); + }, _ => { // Nothing to do for other chain sources. }, @@ -245,6 +292,13 @@ impl ChainSource { } } + pub(crate) fn register_script(&self, script: ScriptBuf) { + match &self.kind { + ChainSourceKind::Cbf(cbf) => cbf.register_script(script), + _ => {}, // no-op: Esplora/Electrum/bitcoind don't need a watch set + } + } + pub(crate) fn registered_txids(&self) -> HashSet { self.registered_txids.lock().expect("lock").clone() } @@ -254,6 +308,7 @@ impl ChainSource { ChainSourceKind::Esplora(_) => true, ChainSourceKind::Electrum { .. } => true, ChainSourceKind::Bitcoind { .. } => false, + ChainSourceKind::Cbf { .. } => false, } } @@ -280,9 +335,9 @@ impl ChainSource { } else { // Background syncing is disabled log_info!( - self.logger, - "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", - ); + self.logger, + "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", + ); return; } }, @@ -303,9 +358,9 @@ impl ChainSource { } else { // Background syncing is disabled log_info!( - self.logger, - "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", - ); + self.logger, + "Background syncing is disabled. Manual syncing required for onchain wallet, lightning wallet, and fee rate updates.", + ); return; } }, @@ -320,6 +375,15 @@ impl ChainSource { ) .await }, + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source.process_kyoto_events( + stop_sync_receiver, + onchain_wallet, + channel_manager, + chain_monitor, + output_sweeper, + ); + }, } } @@ -362,7 +426,7 @@ impl ChainSource { log_trace!( logger, "Stopping background syncing on-chain wallet.", - ); + ); return; } _ = onchain_wallet_sync_interval.tick() => { @@ -376,7 +440,7 @@ impl ChainSource { Arc::clone(&channel_manager), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper), - ).await; + ).await; } } } @@ -399,6 +463,9 @@ impl ChainSource { // `ChainPoller`. So nothing to do here. unreachable!("Onchain wallet will be synced via chain polling") }, + ChainSourceKind::Cbf { .. } => { + unreachable!("Onchain wallet synchronizes in background") + }, } } @@ -424,6 +491,9 @@ impl ChainSource { // `ChainPoller`. So nothing to do here. unreachable!("Lightning wallet will be synced via chain polling") }, + ChainSourceKind::Cbf { .. } => { + unreachable!("Lightning wallet synchronizes in background") + }, } } @@ -452,6 +522,9 @@ impl ChainSource { ) .await }, + ChainSourceKind::Cbf { .. } => { + todo!(); + }, } } @@ -466,6 +539,9 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.update_fee_rate_estimates().await }, + ChainSourceKind::Cbf { .. } => { + todo!(); + }, } } @@ -529,6 +605,9 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.process_transaction_broadcast(package).await }, + ChainSourceKind::Cbf { ..} => { + todo!(); + } } } } @@ -547,6 +626,9 @@ impl Filter for ChainSource { electrum_chain_source.register_tx(txid, script_pubkey) }, ChainSourceKind::Bitcoind { .. } => (), + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source.register_tx(txid, script_pubkey); + }, } } fn register_output(&self, output: lightning::chain::WatchedOutput) { @@ -558,6 +640,9 @@ impl Filter for ChainSource { electrum_chain_source.register_output(output) }, ChainSourceKind::Bitcoind { .. } => (), + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source.register_output(output); + }, } } } diff --git a/src/lib.rs b/src/lib.rs index cb570243cc..859486fe9b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -300,11 +300,19 @@ impl Node { self.runtime.allow_cancellable_background_task_spawns(); - // Start up any runtime-dependant chain sources (e.g. Electrum) - self.chain_source.start(Arc::clone(&self.runtime)).map_err(|e| { - log_error!(self.logger, "Failed to start chain syncing: {}", e); - e - })?; + // Start up any runtime-dependant chain sources (e.g. Electrum, CBF) + self.chain_source + .start( + Arc::clone(&self.runtime), + Arc::clone(&self.wallet), + Arc::clone(&self.channel_manager), + Arc::clone(&self.chain_monitor), + Arc::clone(&self.output_sweeper), + ) + .map_err(|e| { + log_error!(self.logger, "Failed to start chain syncing: {}", e); + e + })?; let manager_owns_any_0fc_channels = self.channel_manager.list_channels().into_iter().any(|channel| { diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index f8d9d521eb..d15e1dab80 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -36,7 +36,7 @@ use lightning::chain::chaininterface::{ INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; -use lightning::chain::{BlockLocator, ClaimId, Listen}; +use lightning::chain::{BlockLocator, ClaimId, Filter, Listen}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; @@ -141,6 +141,10 @@ impl Wallet { .collect() } + pub(crate) fn latest_checkpoint(&self) -> bdk_chain::local_chain::CheckPoint { + self.inner.lock().expect("lock").latest_checkpoint() + } + pub(crate) fn current_best_block(&self) -> BlockLocator { let checkpoint = self.inner.lock().expect("lock").latest_checkpoint(); let mut current_block = Some(checkpoint.clone()); @@ -212,6 +216,24 @@ impl Wallet { Ok(()) } + pub(crate) fn list_revealed_scripts(&self) -> Vec { + self.inner + .lock() + .expect("lock") + .spk_index() + .revealed_spks(..) + .map(|((_keychain, _index), spk)| spk) + .collect() + } + + /// Register scripts that BDK revealed at index time (e.g. change outputs, which `create_tx` + /// only peeks) with the chain source's watch set. No-op for non-CBF backends. + fn register_revealed_scripts(&self, _locked_wallet: &PersistedWallet) { + // TODO(cbf): diff `last_revealed_index(keychain)` against a per-keychain cursor and + // `chain_source.register_script(spk)` the delta for both keychains. + todo!() + } + async fn update_payment_store(&self, mut events: Vec) -> Result<(), Error> { if events.is_empty() { return Ok(()); @@ -515,6 +537,7 @@ impl Wallet { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; + self.chain_source.register_script(address_info.script_pubkey()); Ok(address_info.address) } @@ -529,6 +552,7 @@ impl Wallet { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; + self.chain_source.register_script(address_info.script_pubkey()); Ok(address_info.address) } @@ -1126,6 +1150,7 @@ impl Wallet { locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); })?; + self.chain_source.register_script(address_info.script_pubkey()); Ok(address_info.address.script_pubkey()) } diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 50e2b993c8..dc4d8079cf 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -475,7 +475,10 @@ async fn settle_force_close_balance( assert_eq!(actual_counterparty_node_id, counterparty_node_id); let cur_height = node.status().current_best_block.height; let blocks_to_go = confirmation_height - cur_height; - generate_blocks_and_wait(bitcoind, electrsd, blocks_to_go as usize).await; + let new_height = + generate_blocks_and_wait(bitcoind, electrsd, blocks_to_go as usize).await; + wait_for_node_tip(node, new_height).await; + wait_for_node_tip(peer_node, new_height).await; node.sync_wallets().unwrap(); peer_node.sync_wallets().unwrap(); }, @@ -490,7 +493,9 @@ async fn settle_force_close_balance( if node.list_balances().lightning_balances.is_empty() { break; } - generate_blocks_and_wait(bitcoind, electrsd, 1).await; + let new_height = generate_blocks_and_wait(bitcoind, electrsd, 1).await; + wait_for_node_tip(node, new_height).await; + wait_for_node_tip(peer_node, new_height).await; node.sync_wallets().unwrap(); peer_node.sync_wallets().unwrap(); } @@ -500,7 +505,9 @@ async fn settle_force_close_balance( assert_eq!(balances.pending_balances_from_channel_closures.len(), 1); match balances.pending_balances_from_channel_closures[0] { PendingSweepBalance::BroadcastAwaitingConfirmation { .. } => { - generate_blocks_and_wait(bitcoind, electrsd, 1).await; + let new_height = generate_blocks_and_wait(bitcoind, electrsd, 1).await; + wait_for_node_tip(node, new_height).await; + wait_for_node_tip(peer_node, new_height).await; node.sync_wallets().unwrap(); peer_node.sync_wallets().unwrap(); @@ -515,7 +522,9 @@ async fn settle_force_close_balance( _ => panic!("Unexpected balance state!"), } - generate_blocks_and_wait(bitcoind, electrsd, 5).await; + let new_height = generate_blocks_and_wait(bitcoind, electrsd, 5).await; + wait_for_node_tip(node, new_height).await; + wait_for_node_tip(peer_node, new_height).await; node.sync_wallets().unwrap(); peer_node.sync_wallets().unwrap(); } @@ -737,7 +746,7 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> pub(crate) async fn generate_blocks_and_wait( bitcoind: &BitcoindClient, electrs: &E, num: usize, -) { +) -> usize { let _ = bitcoind.create_wallet("ldk_node_test"); let _ = bitcoind.load_wallet("ldk_node_test"); print!("Generating {} blocks...", num); @@ -746,9 +755,11 @@ pub(crate) async fn generate_blocks_and_wait( let address = bitcoind.new_address().expect("failed to get new address"); // TODO: expect this Result once the WouldBlock issue is resolved upstream. let _block_hashes_res = bitcoind.generate_to_address(num, &address); - wait_for_block(bitcoind, electrs, cur_height as usize + num).await; + let new_height = cur_height as usize + num; + wait_for_block(bitcoind, electrs, new_height).await; print!(" Done!"); println!("\n"); + return new_height; } pub(crate) fn invalidate_blocks(bitcoind: &BitcoindClient, num_blocks: usize) { @@ -847,6 +858,13 @@ pub(crate) async fn wait_for_channel_ready_to_send( ); } +pub(crate) async fn wait_for_node_tip(node: &Node, height: usize) { + exponential_backoff_poll(|| { + (node.status().current_best_block.height as usize >= height).then_some(()) + }) + .await; +} + pub(crate) async fn exponential_backoff_poll(mut poll: F) -> T where F: FnMut() -> Option, @@ -1156,7 +1174,9 @@ pub(crate) async fn do_channel_full_cycle( wait_for_tx(electrsd, funding_txo_a.txid).await; if !allow_0conf { - generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; } node_a.sync_wallets().unwrap(); @@ -1528,7 +1548,9 @@ pub(crate) async fn do_channel_full_cycle( ); // Mine a block to give time for the HTLC to resolve - generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; println!("\nB splices out to pay A"); let addr_a = node_a.onchain_payment().new_address().unwrap(); @@ -1540,7 +1562,9 @@ pub(crate) async fn do_channel_full_cycle( expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); - generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1562,7 +1586,9 @@ pub(crate) async fn do_channel_full_cycle( expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); - generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1635,7 +1661,9 @@ pub(crate) async fn do_channel_full_cycle( wait_for_outpoint_spend(electrsd, funding_txo_b).await; - generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 1).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); @@ -1680,7 +1708,10 @@ pub(crate) async fn do_channel_full_cycle( assert_eq!(node_a_blocks_to_go, node_b_blocks_to_go); - generate_blocks_and_wait(&bitcoind, electrsd, node_a_blocks_to_go as usize).await; + let new_height = + generate_blocks_and_wait(&bitcoind, electrsd, node_a_blocks_to_go as usize).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index ba15f6fa30..f8aef3838a 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -27,8 +27,8 @@ use common::{ generate_listening_addresses, invalidate_blocks, open_channel, open_channel_push_amt, open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf, random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder, setup_node, - setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, InMemoryStore, - TestChainSource, TestConfig, TestStoreType, TestSyncStore, + setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_node_tip, wait_for_tx, + InMemoryStore, TestChainSource, TestConfig, TestStoreType, TestSyncStore, }; use electrsd::corepc_node::{self, Node as BitcoinD}; use electrsd::ElectrsD; @@ -724,10 +724,11 @@ async fn onchain_send_receive() { let channel_amount_sat = 1_000_000; let reserve_amount_sat = 25_000; open_channel(&node_b, &node_a, channel_amount_sat, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -795,9 +796,11 @@ async fn onchain_send_receive() { assert_eq!(payment_a.amount_msat, payment_b.amount_msat); assert_eq!(payment_a.fee_paid_msat, payment_b.fee_paid_msat); - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; let expected_node_a_balance = expected_node_a_balance + amount_to_send_sats; let expected_node_b_balance_lower = expected_node_b_balance_lower - amount_to_send_sats; @@ -835,12 +838,12 @@ async fn onchain_send_receive() { let addr_b = node_b.onchain_payment().new_address().unwrap(); let txid = node_a.onchain_payment().send_all_to_address(&addr_b, true, None).unwrap(); - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; wait_for_tx(&electrsd.client, txid).await; - node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); - + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; let expected_node_b_balance_lower = expected_node_b_balance_lower + expected_node_a_balance; let expected_node_b_balance_upper = expected_node_b_balance_upper + expected_node_a_balance; let expected_node_a_balance = 0; @@ -858,11 +861,13 @@ async fn onchain_send_receive() { let addr_b = node_b.onchain_payment().new_address().unwrap(); let txid = node_a.onchain_payment().send_all_to_address(&addr_b, false, None).unwrap(); - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; wait_for_tx(&electrsd.client, txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; let expected_node_b_balance_lower = expected_node_b_balance_lower + reserve_amount_sat; let expected_node_b_balance_upper = expected_node_b_balance_upper + reserve_amount_sat; @@ -979,10 +984,11 @@ async fn onchain_send_all_retains_reserve() { let txid = node_a.onchain_payment().send_all_to_address(&addr_b, true, None).unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; // Check node a sent all and node b received it assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, 0); assert!(((premine_amount_sat * 2 - onchain_fee_buffer_sat)..=(premine_amount_sat * 2)) @@ -997,16 +1003,20 @@ async fn onchain_send_all_retains_reserve() { .parse() .unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; assert_eq!(node_a.list_balances().spendable_onchain_balance_sats, reserve_amount_sat); // Open a channel. open_channel(&node_b, &node_a, premine_amount_sat, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -1020,10 +1030,12 @@ async fn onchain_send_all_retains_reserve() { let txid = node_b.onchain_payment().send_all_to_address(&addr_a, true, None).unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; // Check node b sent all and node a received it assert_eq!(node_b.list_balances().total_onchain_balance_sats, reserve_amount_sat); @@ -1068,9 +1080,9 @@ async fn onchain_wallet_recovery() { .unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; original_node.sync_wallets().unwrap(); + wait_for_node_tip(&original_node, new_height).await; assert_eq!( original_node.list_balances().spendable_onchain_balance_sats, premine_amount_sat * 2 @@ -1106,9 +1118,9 @@ async fn onchain_wallet_recovery() { .unwrap(); wait_for_tx(&electrsd.client, txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; - + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; recovered_node.sync_wallets().unwrap(); + wait_for_node_tip(&recovered_node, new_height).await; assert_eq!( recovered_node.list_balances().spendable_onchain_balance_sats, premine_amount_sat * 3 @@ -1677,10 +1689,12 @@ async fn splice_channel() { open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await; // Open a channel with Node A contributing the funding - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; 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()); @@ -1777,7 +1791,9 @@ async fn splice_channel() { expect_payment_received_event!(node_a, amount_msat); // Mine a block to give time for the HTLC to resolve - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; assert_eq!( node_a.list_balances().total_lightning_balance_sats, @@ -2265,10 +2281,12 @@ async fn simple_bolt12_send_receive() { node_a.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -2550,12 +2568,16 @@ async fn async_payment() { ) .await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_sender.sync_wallets().unwrap(); node_sender_lsp.sync_wallets().unwrap(); node_receiver_lsp.sync_wallets().unwrap(); node_receiver.sync_wallets().unwrap(); + wait_for_node_tip(&node_sender, new_height).await; + wait_for_node_tip(&node_sender_lsp, new_height).await; + wait_for_node_tip(&node_receiver_lsp, new_height).await; + wait_for_node_tip(&node_receiver, new_height).await; expect_channel_ready_event!(node_sender, node_sender_lsp.node_id()); expect_channel_ready_events!( @@ -2752,10 +2774,12 @@ async fn generate_bip21_uri() { node_a.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -2804,10 +2828,12 @@ async fn unified_send_receive_bip21_uri() { node_a.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 4_000_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -2886,11 +2912,13 @@ async fn unified_send_receive_bip21_uri() { }, }; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; wait_for_tx(&electrsd.client, txid).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; assert_eq!(node_b.list_balances().total_onchain_balance_sats, 800_000); assert_eq!(node_b.list_balances().total_lightning_balance_sats, 200_000); @@ -2970,7 +2998,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { println!("Opening channel payer_node -> service_node!"); open_channel(&payer_node, &service_node, 5_000_000, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = 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()); @@ -3161,9 +3189,11 @@ async fn spontaneous_send_with_custom_preimage() { node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); open_channel(&node_a, &node_b, 500_000, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -3371,9 +3401,11 @@ async fn lsps2_client_trusts_lsp() { // Check the nodes pick up on the confirmed funding tx now. wait_for_tx(&electrsd.client, funding_txo.txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); client_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await; + wait_for_node_tip(&payer_node, new_height).await; assert_eq!( client_node .list_channels() @@ -3465,9 +3497,11 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { // Open a channel payer -> service that will allow paying the JIT invoice open_channel(&payer_node, &service_node, 5_000_000, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); payer_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await; + wait_for_node_tip(&payer_node, new_height).await; expect_channel_ready_event!(payer_node, service_node.node_id()); expect_channel_ready_event!(service_node, payer_node.node_id()); @@ -3500,9 +3534,11 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { // Check the nodes pick up on the confirmed funding tx now. wait_for_tx(&electrsd.client, funding_txo.txid).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); client_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await; + wait_for_node_tip(&client_node, new_height).await; assert_eq!( client_node .list_channels() @@ -3563,9 +3599,11 @@ async fn payment_persistence_after_restart() { // Open a large channel from node_a to node_b let channel_amount_sat = 5_000_000; open_channel(&node_a, &node_b, channel_amount_sat, true, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; expect_channel_ready_event!(node_a, node_b.node_id()); expect_channel_ready_event!(node_b, node_a.node_id()); @@ -3922,9 +3960,11 @@ async fn onchain_fee_bump_rbf() { } // Confirm the transaction and try to bump again (should fail) - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; assert_eq!( Err(NodeError::InvalidPaymentId), @@ -4033,10 +4073,12 @@ async fn open_channel_with_all_with_anchors() { let funding_txo = open_channel_with_all(&node_a, &node_b, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; 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()); @@ -4187,11 +4229,13 @@ async fn open_channel_variants_reserve_funds_for_anchor_peers() { opened_with_all_cases.push((variant, node_a, node_b, funding_txo_a)); } - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = 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(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; 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()); @@ -4243,10 +4287,12 @@ async fn splice_in_with_all_balance() { // Open a channel with a fixed amount first let funding_txo = open_channel(&node_a, &node_b, channel_amount_sat, false, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; 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()); @@ -4262,10 +4308,12 @@ async fn splice_in_with_all_balance() { // Splice in with all remaining on-chain funds splice_in_with_all(&node_a, &node_b, &user_channel_id_a, &electrsd).await; - generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; node_a.sync_wallets().unwrap(); node_b.sync_wallets().unwrap(); + wait_for_node_tip(&node_a, new_height).await; + wait_for_node_tip(&node_b, new_height).await; let _user_channel_id_a2 = expect_channel_ready_event!(node_a, node_b.node_id()); let _user_channel_id_b2 = expect_channel_ready_event!(node_b, node_a.node_id()); From 5415c368fe09a2721c3dd14340d85e6e05dec1d1 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Sun, 7 Jun 2026 03:33:05 +0200 Subject: [PATCH 101/138] Cbf chain source (#26) * Add CBF chain source stubs for starting * Add waiting for gossip propagation in tests * Populate revealed spks for CBF * Add sender and listener of `ChainOp`s --------- Co-authored-by: Yeji Han --- src/chain/cbf.rs | 200 +++++++++++++++++++++++++++++++++++------------ src/chain/mod.rs | 15 ++-- 2 files changed, 156 insertions(+), 59 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 1b16f2f91b..d9d70cc5ce 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -5,12 +5,13 @@ use std::time::Duration; use bip157::chain::ChainState; use bip157::{ - Builder as KyotoBuilder, Client, HashCheckpoint, Info, Node as KyotoNode, Requester, - TrustedPeer, Warning, + chain::BlockHeaderChanges, error::FetchBlockError, Builder as KyotoBuilder, Client, Event, + HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Requester, TrustedPeer, Warning, }; use bitcoin::{BlockHash, FeeRate, Script, ScriptBuf, Txid}; -use lightning::chain::WatchedOutput; -use tokio::sync::mpsc; +use lightning::chain::{Listen, WatchedOutput}; + +use tokio::sync::{mpsc, oneshot}; use crate::chain::bitcoind::ChainListener; use crate::chain::CbfFeeSourceConfig; @@ -50,7 +51,7 @@ enum CbfRuntimeStatus { pub struct CbfChainSource { /// Trusted peer addresses for kyoto's `Builder::add_peers`. trusted_peers: Vec, - registered_scripts: Mutex>, + registered_scripts: Arc>>, fee_source: FeeSource, /// Tracks whether the kyoto node is running and holds the live requester. cbf_runtime_status: Arc>, @@ -59,6 +60,36 @@ pub struct CbfChainSource { logger: Arc, } +enum ChainOp { + ConnectFull { block_rx: oneshot::Receiver> }, + ConnectFiltered { header: Header, height: u32 }, + //Reorg { /* accepted / reorganized from BlockHeaderChanges */ }, +} + +struct BlockApplicator { + chain_listener: ChainListener, + ops_rx: mpsc::UnboundedReceiver, + logger: Arc, +} + +impl BlockApplicator { + async fn run(mut self) { + while let Some(op) = self.ops_rx.recv().await { + match op { + ChainOp::ConnectFull { block_rx } => match block_rx.await { + Ok(Ok(ib)) => self.chain_listener.block_connected(&ib.block, ib.height), + Ok(Err(e)) => log_error!(self.logger, "block fetch failed: {:?}", e), + Err(_) => log_error!(self.logger, "block oneshot dropped"), + }, + ChainOp::ConnectFiltered { header, height } => { + self.chain_listener.filtered_block_connected(&header, &[], height) + }, + //ChainOp::Reorg { .. } => {}, + } + } + } +} + enum FeeSource { /// Derive fee rates from the coinbase reward of recent blocks. Downloads full blocks in order to calculate fee estimation. Cbf { block_fee_cache: Mutex> }, @@ -70,6 +101,17 @@ enum FeeSource { Electrum { server_url: String }, } +impl FeeSource { + fn insert_cached_block(&self, block_hash: BlockHash, fee_rate: FeeRate) { + match &self { + Self::Cbf { block_fee_cache } => { + block_fee_cache.lock().expect("lock").push_back((block_hash, fee_rate)); + }, + _ => {}, + } + } +} + impl CbfChainSource { pub(crate) fn new( peers: Vec, fee_source_config: Option, config: Arc, @@ -97,7 +139,7 @@ impl CbfChainSource { block_fee_cache: Mutex::new(VecDeque::with_capacity(BLOCK_FEE_CACHE_CAPACITY)), }, }; - let registered_scripts = Mutex::new(HashSet::new()); + let registered_scripts = Arc::new(Mutex::new(HashSet::new())); let cbf_runtime_status = Arc::new(Mutex::new(CbfRuntimeStatus::Stopped)); Ok(Self { trusted_peers, @@ -109,8 +151,7 @@ impl CbfChainSource { }) } - //builds kyoto - fn build( + fn build_kyoto( trusted_peers: &[TrustedPeer], config: &Config, logger: &Logger, chain_listener: &ChainListener, ) -> (KyotoNode, Client) { @@ -128,7 +169,7 @@ impl CbfChainSource { kyoto_builder = kyoto_builder.response_timeout(Duration::from_secs(DEFAULT_RESPONSE_TIMEOUT_SECS)); - if let Some(header_cp) = Self::resume_checkpoint(logger, chain_listener) { + if let Some(header_cp) = resume_checkpoint(logger, chain_listener) { log_debug!( logger, "CBF builder: resuming from checkpoint height={}, hash={}", @@ -141,47 +182,15 @@ impl CbfChainSource { kyoto_builder.build() } - fn resume_checkpoint( - logger: &Logger, chain_listener: &ChainListener, - ) -> Option { - let min_best_block = chain_listener.get_best_block(); - let bdk_cp = chain_listener.onchain_wallet.latest_checkpoint(); - - if let Some(bdk_at_height) = bdk_cp.get(min_best_block.height) { - if bdk_at_height.hash() != min_best_block.block_hash { - log_error!( - logger, - "CBF resume: listener best block at height {} has hash {} but BDK has {}; \ - a component may be on a stale fork. Anchoring on BDK's chain.", - min_best_block.height, - min_best_block.block_hash, - bdk_at_height.hash(), - ); - } - } - - // Walk BDK's checkpoint chain back to the reorg-safe anchor height. - let target_height = min_best_block.height.saturating_sub(REORG_SAFETY_BLOCKS); - let mut cursor = bdk_cp; - while cursor.height() > target_height { - match cursor.prev() { - Some(prev) => cursor = prev, - None => break, - } - } - - (cursor.height() > 0).then(|| HashCheckpoint::new(cursor.height(), cursor.hash())) - } - pub(crate) fn start(&self, runtime: Arc, chain_listener: ChainListener) { - //we populate registered scripts with all the scripts from the onchain wallet + //populate registered scripts with all the scripts from the onchain wallet for script in chain_listener.onchain_wallet.list_revealed_scripts() { self.register_script(script); } let (node, client) = - Self::build(&self.trusted_peers, &self.config, &self.logger, &chain_listener); - let Client { requester, info_rx, warn_rx, event_rx: _ } = client; + Self::build_kyoto(&self.trusted_peers, &self.config, &self.logger, &chain_listener); + let Client { requester, info_rx, warn_rx, event_rx } = client; { let mut status = self.cbf_runtime_status.lock().expect("lock"); @@ -192,6 +201,14 @@ impl CbfChainSource { *status = CbfRuntimeStatus::Started { requester }; } + let (ops_tx, ops_rx) = mpsc::unbounded_channel(); + let block_applicator = BlockApplicator { + chain_listener: chain_listener.clone(), + ops_rx, + logger: Arc::clone(&self.logger), + }; + runtime.spawn_background_task(block_applicator.run()); + log_info!(self.logger, "CBF chain source started."); let restart_status = Arc::clone(&self.cbf_runtime_status); @@ -199,11 +216,15 @@ impl CbfChainSource { let restart_peers = self.trusted_peers.clone(); let restart_config = Arc::clone(&self.config); let restart_listener = chain_listener; + let restart_registered_scripts = Arc::clone(&self.registered_scripts); + let restart_cbf_runtime_status = Arc::clone(&self.cbf_runtime_status); + // let restart_block_applicator = runtime.spawn_background_task(async move { let mut current_node = node; let mut current_info_rx = info_rx; let mut current_warn_rx = warn_rx; + let mut current_event_rx = event_rx; let mut retries = 0u32; let mut backoff_ms = INITIAL_BACKOFF_MS; @@ -217,6 +238,13 @@ impl CbfChainSource { Arc::clone(&restart_logger), )); + let event_handle = tokio::spawn(Self::process_kyoto_events( + current_event_rx, + Arc::clone(&restart_registered_scripts), + Arc::clone(&restart_cbf_runtime_status), + ops_tx.clone(), + )); + match current_node.run().await { Ok(()) => { log_info!(restart_logger, "CBF node shut down cleanly."); @@ -249,8 +277,9 @@ impl CbfChainSource { // Abort the old log consumers before rebuilding. info_handle.abort(); warn_handle.abort(); + event_handle.abort(); - let (new_node, new_client) = Self::build( + let (new_node, new_client) = Self::build_kyoto( &restart_peers, &restart_config, &restart_logger, @@ -260,7 +289,7 @@ impl CbfChainSource { requester: new_requester, info_rx: new_info_rx, warn_rx: new_warn_rx, - event_rx: _, + event_rx: new_event_rx, } = new_client; *restart_status.lock().expect("lock") = @@ -269,6 +298,7 @@ impl CbfChainSource { current_node = new_node; current_info_rx = new_info_rx; current_warn_rx = new_warn_rx; + current_event_rx = new_event_rx; }, } } @@ -293,13 +323,49 @@ impl CbfChainSource { } } - pub(crate) fn process_kyoto_events( - &self, _stop_sync_receiver: tokio::sync::watch::Receiver<()>, _onchain_wallet: Arc, - _channel_manager: Arc, _chain_monitor: Arc, - _output_sweeper: Arc, + async fn process_kyoto_events( + mut event_rx: mpsc::UnboundedReceiver, + registered_scripts: Arc>>, + cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, ) { - //here we need to calculate chain update and feed to all listeners - todo!(); + while let Some(event) = event_rx.recv().await { + match event { + // match download + Event::IndexedFilter(indexed_filter) => { + let matched = indexed_filter + .contains_any(registered_scripts.lock().expect("lock").iter()); + if matched { + let rtm = &*cbf_runtime_status.lock().expect("lock"); + let requestor = match rtm { + CbfRuntimeStatus::Started { requester } => requester.clone(), + CbfRuntimeStatus::Stopped => { + //panic + // todo!(); + continue; + }, + }; + let block_rx = requestor + .request_block(indexed_filter.block_hash()) + .expect("cannot request block"); + let chop = ChainOp::ConnectFull { block_rx }; + //here we feed evets to the driver + ops_tx.send(chop); + } + }, + Event::FiltersSynced(sync_update) => { + todo!(); + }, + Event::ChainUpdate(BlockHeaderChanges::Connected(connected_blocks)) => { + todo!(); + }, + Event::ChainUpdate(BlockHeaderChanges::Reorganized { reorganized, accepted }) => { + todo!(); + }, + Event::ChainUpdate(BlockHeaderChanges::ForkAdded(fork)) => { + todo!(); + }, + } + } } pub(crate) fn register_tx(&self, _txid: &Txid, script_pubkey: &Script) { @@ -314,3 +380,33 @@ impl CbfChainSource { self.registered_scripts.lock().expect("lock").insert(script); } } + +fn resume_checkpoint(logger: &Logger, chain_listener: &ChainListener) -> Option { + let min_best_block = chain_listener.get_best_block(); + let bdk_cp = chain_listener.onchain_wallet.latest_checkpoint(); + + if let Some(bdk_at_height) = bdk_cp.get(min_best_block.height) { + if bdk_at_height.hash() != min_best_block.block_hash { + log_error!( + logger, + "CBF resume: listener best block at height {} has hash {} but BDK has {}; \ + a component may be on a stale fork. Anchoring on BDK's chain.", + min_best_block.height, + min_best_block.block_hash, + bdk_at_height.hash(), + ); + } + } + + // Walk BDK's checkpoint chain back to the reorg-safe anchor height. + let target_height = min_best_block.height.saturating_sub(REORG_SAFETY_BLOCKS); + let mut cursor = bdk_cp; + while cursor.height() > target_height { + match cursor.prev() { + Some(prev) => cursor = prev, + None => break, + } + } + + (cursor.height() > 0).then(|| HashCheckpoint::new(cursor.height(), cursor.hash())) +} diff --git a/src/chain/mod.rs b/src/chain/mod.rs index f1058f8be9..84d41bddb4 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -376,13 +376,14 @@ impl ChainSource { .await }, ChainSourceKind::Cbf(cbf_chain_source) => { - cbf_chain_source.process_kyoto_events( - stop_sync_receiver, - onchain_wallet, - channel_manager, - chain_monitor, - output_sweeper, - ); + todo!(); + // cbf_chain_source.process_kyoto_events( + // stop_sync_receiver, + // onchain_wallet, + // channel_manager, + // chain_monitor, + // output_sweeper, + // ); }, } } From fabdb11e08a3c6b08fc3b42ca6fc8165ee79d6b7 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Mon, 8 Jun 2026 13:56:14 +0900 Subject: [PATCH 102/138] fix(cbf): shut down chain source cleanly - Implement CBF requester shutdown and mark runtime status stopped on clean exit. - Route generic chain source stop calls to the CBF backend. AI-assisted-by: OpenAI Codex --- src/chain/cbf.rs | 19 ++++++++++++++++++- src/chain/mod.rs | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index d9d70cc5ce..037e037771 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -248,6 +248,7 @@ impl CbfChainSource { match current_node.run().await { Ok(()) => { log_info!(restart_logger, "CBF node shut down cleanly."); + *restart_status.lock().expect("lock") = CbfRuntimeStatus::Stopped; break; }, Err(e) => { @@ -306,7 +307,23 @@ impl CbfChainSource { } pub(crate) fn stop(&self) { - todo!(); + let requester = { + let mut status = self.cbf_runtime_status.lock().expect("lock"); + match &*status { + CbfRuntimeStatus::Started { requester } => { + let requester = requester.clone(); + *status = CbfRuntimeStatus::Stopped; + Some(requester) + }, + CbfRuntimeStatus::Stopped => None, + } + }; + + if let Some(requester) = requester { + if let Err(e) = requester.shutdown() { + log_error!(self.logger, "Failed to shut down CBF node: {:?}", e); + } + } } async fn process_info_messages(mut info_rx: mpsc::Receiver, logger: Arc) { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 84d41bddb4..ee7a16c29b 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -277,6 +277,7 @@ impl ChainSource { pub(crate) fn stop(&self) { match &self.kind { ChainSourceKind::Electrum(electrum_chain_source) => electrum_chain_source.stop(), + ChainSourceKind::Cbf(cbf_chain_source) => cbf_chain_source.stop(), _ => { // Nothing to do for other chain sources. }, From 90b9718045213994f8e344bfa3dc08b7a6c31b78 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Fri, 22 May 2026 13:05:22 +0900 Subject: [PATCH 103/138] fix(cbf): stop chain source before waiting on tasks - Stop runtime-dependent chain sources before waiting for non-cancellable background tasks. - Allow the CBF requester shutdown to unblock the node run loop during Node::stop. --- src/lib.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 859486fe9b..29315c1750 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -857,13 +857,15 @@ impl Node { self.peer_manager.disconnect_all_peers(); log_debug!(self.logger, "Disconnected all network peers."); - // Wait until non-cancellable background tasks (mod LDK's background processor) are done. - self.runtime.wait_on_background_tasks(); - - // Stop any runtime-dependant chain sources. + // Stop any runtime-dependant chain sources before waiting on non-cancellable + // background tasks. Some chain sources own background tasks that only exit + // after their client/requester is shut down. self.chain_source.stop(); log_debug!(self.logger, "Stopped chain sources."); + // Wait until non-cancellable background tasks (mod LDK's background processor) are done. + self.runtime.wait_on_background_tasks(); + // Stop the background processor. self.background_processor_stop_sender .send(()) From a51fe2b3391a7d1fbbdbb96422ef9eb30107ac95 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Tue, 9 Jun 2026 05:05:38 +0900 Subject: [PATCH 104/138] fix(cbf): abort restart when stopped during backoff - Check CBF runtime status before publishing a rebuilt requester after restart backoff. - Shut down the newly built requester and exit the restart loop if stop() ran during the backoff. --- src/chain/cbf.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 037e037771..baca0ced50 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -293,8 +293,18 @@ impl CbfChainSource { event_rx: new_event_rx, } = new_client; - *restart_status.lock().expect("lock") = - CbfRuntimeStatus::Started { requester: new_requester }; + { + let mut status = restart_status.lock().expect("lock"); + if matches!(*status, CbfRuntimeStatus::Stopped) { + let _ = new_requester.shutdown(); + log_info!( + restart_logger, + "CBF restart aborted: stop() called during backoff." + ); + break; + } + *status = CbfRuntimeStatus::Started { requester: new_requester }; + } current_node = new_node; current_info_rx = new_info_rx; From bcc663be502c5cbe74335d96af545a8cf61dbb89 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 9 Jun 2026 05:05:56 +0200 Subject: [PATCH 105/138] Implement `process_kyoto_events` (#28) * Add CBF chain source stubs for starting * Implement `process_kyoto_events` and `ChainOp` Co-authored-by: febyeji --- src/chain/cbf.rs | 128 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 100 insertions(+), 28 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index baca0ced50..b76650cf5f 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -9,7 +9,7 @@ use bip157::{ HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Requester, TrustedPeer, Warning, }; use bitcoin::{BlockHash, FeeRate, Script, ScriptBuf, Txid}; -use lightning::chain::{Listen, WatchedOutput}; +use lightning::chain::{BlockLocator, Listen, WatchedOutput}; use tokio::sync::{mpsc, oneshot}; @@ -61,9 +61,20 @@ pub struct CbfChainSource { } enum ChainOp { - ConnectFull { block_rx: oneshot::Receiver> }, - ConnectFiltered { header: Header, height: u32 }, - //Reorg { /* accepted / reorganized from BlockHeaderChanges */ }, + ConnectFull { + block_rx: oneshot::Receiver>, + }, + ConnectFiltered { + header: Header, + height: u32, + }, + Disconnect { + fork_point: BlockLocator, + }, + /// Marks reaching the chain tip. + Synced { + tip_height: u32, + }, } struct BlockApplicator { @@ -82,9 +93,16 @@ impl BlockApplicator { Err(_) => log_error!(self.logger, "block oneshot dropped"), }, ChainOp::ConnectFiltered { header, height } => { - self.chain_listener.filtered_block_connected(&header, &[], height) + self.chain_listener.filtered_block_connected(&header, &[], height); + }, + ChainOp::Disconnect { fork_point } => { + self.chain_listener.blocks_disconnected(fork_point); + }, + ChainOp::Synced { tip_height } => { + log_info!(self.logger, "CBF caught up to tip {}", tip_height); + // TODO: notify sync-completion waiters (start()/sync_wallets()/tests) once + // a notification primitive is plumbed through. }, - //ChainOp::Reorg { .. } => {}, } } } @@ -239,6 +257,7 @@ impl CbfChainSource { )); let event_handle = tokio::spawn(Self::process_kyoto_events( + Arc::clone(&restart_logger), current_event_rx, Arc::clone(&restart_registered_scripts), Arc::clone(&restart_cbf_runtime_status), @@ -351,45 +370,98 @@ impl CbfChainSource { } async fn process_kyoto_events( - mut event_rx: mpsc::UnboundedReceiver, + logger: Arc, mut event_rx: mpsc::UnboundedReceiver, registered_scripts: Arc>>, cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, ) { while let Some(event) = event_rx.recv().await { match event { - // match download Event::IndexedFilter(indexed_filter) => { + let requester = match &*cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => requester.clone(), + CbfRuntimeStatus::Stopped => { + //TODO should we panic here? what do we do if we have no requester? + continue; + }, + }; + let block_hash = indexed_filter.block_hash(); let matched = indexed_filter .contains_any(registered_scripts.lock().expect("lock").iter()); - if matched { - let rtm = &*cbf_runtime_status.lock().expect("lock"); - let requestor = match rtm { - CbfRuntimeStatus::Started { requester } => requester.clone(), - CbfRuntimeStatus::Stopped => { - //panic - // todo!(); + + let chop: ChainOp = if matched { + let block_rx = + requester.request_block(block_hash).expect("cannot request block"); + ChainOp::ConnectFull { block_rx } + } else { + let height = indexed_filter.height(); + //TODO we need to recheck that a particular height has not been + //reorganized, and we retrieve indeed the same block header that we + //received `IndexedFilter` event of. right now this would block + //the further sync, as we cannot apply blocks in order. + //Future solution would use something like `get_header_by_hash`. + match requester.get_header(height).await { + Ok(Some(indexed_header)) => { + if indexed_header.block_hash() != block_hash { + log_debug!( + logger, + "Filter for {} reorged; skipping", + block_hash + ); + continue; + } + ChainOp::ConnectFiltered { + header: indexed_header.header, + height: indexed_header.height, + } + }, + Ok(None) => { + log_error!(logger, "No header at height {}", height,); continue; }, - }; - let block_rx = requestor - .request_block(indexed_filter.block_hash()) - .expect("cannot request block"); - let chop = ChainOp::ConnectFull { block_rx }; - //here we feed evets to the driver - ops_tx.send(chop); + Err(e) => { + log_error!( + logger, + "Failed to fetch header at height {}: {:?}", + height, + e, + ); + continue; + }, + } + }; + if let Err(e) = ops_tx.send(chop) { + log_debug!(logger, "ops_rx gone: {}", e); } }, Event::FiltersSynced(sync_update) => { - todo!(); + //Because application of blocks is async, the fact that kyoto synced up to the + //tip does NOT mean that we caught everything up, that's why we send a ChainOp, + //only processing of which means we processed all blocks up to the tip. + log_info!(logger, "Kyoto synced up to the tip {}", sync_update.tip().height); + let _ = ops_tx.send(ChainOp::Synced { tip_height: sync_update.tip().height }); }, - Event::ChainUpdate(BlockHeaderChanges::Connected(connected_blocks)) => { - todo!(); + Event::ChainUpdate(BlockHeaderChanges::Connected(indexed_header)) => { + log_debug!( + logger, + "Kyoto connected header at height {}", + indexed_header.height + ); }, - Event::ChainUpdate(BlockHeaderChanges::Reorganized { reorganized, accepted }) => { - todo!(); + Event::ChainUpdate(BlockHeaderChanges::Reorganized { + reorganized, + accepted: _, + }) => { + // Rewind to the fork point; kyoto will re-deliver the new chain's filters. + if let Some(lowest) = reorganized.first() { + let fork_point = BlockLocator::new( + lowest.prev_blockhash(), + lowest.height.saturating_sub(1), + ); + let _ = ops_tx.send(ChainOp::Disconnect { fork_point }); + } }, Event::ChainUpdate(BlockHeaderChanges::ForkAdded(fork)) => { - todo!(); + log_debug!(logger, "Kyoto added fork header at height {}", fork.height); }, } } From 9a7a69d13b72a5141a226ef56726b6b1cbff4b71 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Fri, 12 Jun 2026 03:46:21 +0200 Subject: [PATCH 106/138] Implement fee source (#29) * Add CBF chain source stubs for starting * Add waiting for gossip propagation in tests * Populate revealed spks for CBF * Implement `process_kyoto_events` and `ChainOp` Co-authored-by: febyeji --- src/builder.rs | 33 +++- src/chain/cbf.rs | 356 +++++++++++++++++++++++++++++++++++++----- src/chain/electrum.rs | 156 +++++++++--------- src/chain/mod.rs | 30 ++-- src/config.rs | 2 +- src/util.rs | 55 +++++++ src/wallet/mod.rs | 13 +- 7 files changed, 495 insertions(+), 150 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 821aec091a..616e6ff5c9 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -45,7 +45,7 @@ use lightning::util::sweep::OutputSweeper; use lightning_dns_resolver::OMDomainResolver; use vss_client::headers::VssHeaderProvider; -use crate::chain::ChainSource; +use crate::chain::{CbfFeeSourceConfig, ChainSource}; use crate::config::{ default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, @@ -112,6 +112,10 @@ enum ChainDataSourceConfig { rest_client_config: Option, wallet_rescan_from_height: Option, }, + Cbf { + peers: Vec, + fee_source_config: Option, + }, } #[derive(Debug, Clone)] @@ -396,6 +400,19 @@ impl NodeBuilder { self } + /// Configures the [`Node`] instance to source chain data via compact block filters + /// (BIP157/BIP158), connecting to the given peers (`ip:port`). + /// + /// `fee_source_config` optionally delegates fee estimation to an Esplora or Electrum server; + /// if `None`, fee rates are derived from recent blocks. + pub fn set_chain_source_cbf( + &mut self, peers: Vec, fee_source_config: Option, + ) -> &mut Self { + self.chain_data_source_config = + Some(ChainDataSourceConfig::Cbf { peers, fee_source_config }); + self + } + /// Configures the [`Node`] instance to connect to a Bitcoin Core node via RPC. /// /// This method establishes an RPC connection that enables all essential chain operations including @@ -1514,8 +1531,18 @@ fn build_with_store_internal( Arc::clone(&node_metrics), ) }, - //TODO add here an arm - // Some(ChainDataSoucrConfig::Cbf) + Some(ChainDataSourceConfig::Cbf { peers, fee_source_config }) => ChainSource::new_cbf( + peers.clone(), + fee_source_config.clone(), + Arc::clone(&runtime), + Arc::clone(&fee_estimator), + Arc::clone(&tx_broadcaster), + Arc::clone(&kv_store), + Arc::clone(&config), + Arc::clone(&logger), + Arc::clone(&node_metrics), + ) + .map_err(|_| BuildError::ChainSourceSetupFailed)?, Some(ChainDataSourceConfig::Bitcoind { rpc_host, rpc_port, diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index b76650cf5f..b4bcb8811b 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -1,25 +1,35 @@ -use std::collections::{HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::net::SocketAddr; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bip157::chain::ChainState; use bip157::{ chain::BlockHeaderChanges, error::FetchBlockError, Builder as KyotoBuilder, Client, Event, HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Requester, TrustedPeer, Warning, }; -use bitcoin::{BlockHash, FeeRate, Script, ScriptBuf, Txid}; +use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Txid}; +use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder}; use lightning::chain::{BlockLocator, Listen, WatchedOutput}; -use tokio::sync::{mpsc, oneshot}; +use tokio::sync::{mpsc, oneshot, watch}; use crate::chain::bitcoind::ChainListener; +use crate::chain::electrum::get_electrum_fee_rate_cache_update; use crate::chain::CbfFeeSourceConfig; -use crate::config::Config; +use crate::config::{Config, DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS}; use crate::error::Error; -use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; +use crate::fee_estimator::{ + apply_post_estimation_adjustments, get_all_conf_targets, get_fallback_rate_for_target, + get_num_block_defaults_for_target, ConfirmationTarget, OnchainFeeEstimator, +}; +use crate::io::utils::update_and_persist_node_metrics; +use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; -use crate::types::{ChainMonitor, ChannelManager, Sweeper, Wallet}; +use crate::types::DynStore; +use crate::util::{cbf_percentile_for_target, coinbase_fee_rate, percentile_of_sorted}; +use crate::wallet::Wallet; +use crate::PersistedNodeMetrics; /// Walk back this many blocks from the wallet's persisted tip when deriving /// the kyoto resume checkpoint, so a recent reorg cannot strand the node @@ -41,6 +51,10 @@ const INITIAL_BACKOFF_MS: u64 = 500; const ESPLORA_TIMEOUT: u64 = 2; +/// Retries and per-request timeout for the fresh Electrum connection opened each fee cycle. +const ELECTRUM_FEE_NUM_RETRIES: u8 = 3; +const ELECTRUM_FEE_TIMEOUT_SECS: u64 = 10; + /// Runtime status of the underlying kyoto node. enum CbfRuntimeStatus { Started { requester: Requester }, @@ -51,12 +65,18 @@ enum CbfRuntimeStatus { pub struct CbfChainSource { /// Trusted peer addresses for kyoto's `Builder::add_peers`. trusted_peers: Vec, + /// Scripts tracked by LDK, onchain wallet's scripts are pulled from the onchain wallet registered_scripts: Arc>>, fee_source: FeeSource, /// Tracks whether the kyoto node is running and holds the live requester. cbf_runtime_status: Arc>, + /// Handle used to spawn the background tasks and offload blocking work. + runtime: Arc, /// Node configuration (network, storage path). config: Arc, + fee_estimator: Arc, + kv_store: Arc, + node_metrics: Arc, logger: Arc, } @@ -80,6 +100,9 @@ enum ChainOp { struct BlockApplicator { chain_listener: ChainListener, ops_rx: mpsc::UnboundedReceiver, + /// Present only for the native CBF fee source: lets us cache the fee rate of blocks we download + /// here, so the fee estimator doesn't have to re-download them. + block_fee_cache: Option, logger: Arc, } @@ -88,7 +111,16 @@ impl BlockApplicator { while let Some(op) = self.ops_rx.recv().await { match op { ChainOp::ConnectFull { block_rx } => match block_rx.await { - Ok(Ok(ib)) => self.chain_listener.block_connected(&ib.block, ib.height), + Ok(Ok(ib)) => { + self.chain_listener.block_connected(&ib.block, ib.height); + if let Some(cache) = &self.block_fee_cache { + let fee_rate = coinbase_fee_rate(&ib.block, ib.height); + cache + .lock() + .expect("lock") + .insert(ib.height, (ib.block.block_hash(), fee_rate)); + } + }, Ok(Err(e)) => log_error!(self.logger, "block fetch failed: {:?}", e), Err(_) => log_error!(self.logger, "block oneshot dropped"), }, @@ -108,9 +140,28 @@ impl BlockApplicator { } } +/// Number of most recent blocks whose coinbase-derived fee rates feed the native CBF estimator. +const FEE_WINDOW_BLOCKS: u32 = BLOCK_FEE_CACHE_CAPACITY as u32; + +/// Lower bound for native CBF fee estimates (1 sat/vB), matching the floor used by the Esplora and +/// Electrum fee sources. Coinbase-derived rates are frequently zero on regtest/signet. +const CBF_MIN_FEERATE_SAT_PER_KWU: u64 = 250; + +/// Per-block timeout when downloading a block to derive its coinbase fee rate. Kept short so a +/// slow peer only delays a single sample rather than the whole fee update. +const CBF_FEE_BLOCK_FETCH_TIMEOUT_SECS: u64 = 10; + +/// Recent per-block coinbase-derived fee rates, keyed by height so we can window on the tip, evict +/// stale entries, and detect reorged-out blocks (a height whose cached hash no longer matches the +/// canonical chain). Shared via `Arc` between the fee estimator and the [`BlockApplicator`]. +type BlockFeeCache = Arc>>; + enum FeeSource { /// Derive fee rates from the coinbase reward of recent blocks. Downloads full blocks in order to calculate fee estimation. - Cbf { block_fee_cache: Mutex> }, + /// + /// The [`BlockApplicator`] also opportunistically inserts the fee rate of any block it already + /// downloads on a filter match, saving a re-download in the reconciliation loop. + Cbf { block_fee_cache: BlockFeeCache }, /// Delegate fee estimation to an Esplora HTTP server. Esplora { client: esplora_client::AsyncClient }, /// Delegate fee estimation to an Electrum server. @@ -119,21 +170,11 @@ enum FeeSource { Electrum { server_url: String }, } -impl FeeSource { - fn insert_cached_block(&self, block_hash: BlockHash, fee_rate: FeeRate) { - match &self { - Self::Cbf { block_fee_cache } => { - block_fee_cache.lock().expect("lock").push_back((block_hash, fee_rate)); - }, - _ => {}, - } - } -} - impl CbfChainSource { pub(crate) fn new( - peers: Vec, fee_source_config: Option, config: Arc, - logger: Arc, + peers: Vec, fee_source_config: Option, runtime: Arc, + fee_estimator: Arc, kv_store: Arc, config: Arc, + logger: Arc, node_metrics: Arc, ) -> Result { let trusted_peers: Vec = peers .iter() @@ -153,9 +194,7 @@ impl CbfChainSource { FeeSource::Esplora { client } }, Some(CbfFeeSourceConfig::Electrum(server_url)) => FeeSource::Electrum { server_url }, - None => FeeSource::Cbf { - block_fee_cache: Mutex::new(VecDeque::with_capacity(BLOCK_FEE_CACHE_CAPACITY)), - }, + None => FeeSource::Cbf { block_fee_cache: Arc::new(Mutex::new(BTreeMap::new())) }, }; let registered_scripts = Arc::new(Mutex::new(HashSet::new())); let cbf_runtime_status = Arc::new(Mutex::new(CbfRuntimeStatus::Stopped)); @@ -164,7 +203,11 @@ impl CbfChainSource { fee_source, registered_scripts, cbf_runtime_status, + runtime, config, + fee_estimator, + kv_store, + node_metrics, logger, }) } @@ -200,12 +243,7 @@ impl CbfChainSource { kyoto_builder.build() } - pub(crate) fn start(&self, runtime: Arc, chain_listener: ChainListener) { - //populate registered scripts with all the scripts from the onchain wallet - for script in chain_listener.onchain_wallet.list_revealed_scripts() { - self.register_script(script); - } - + pub(crate) fn start(&self, chain_listener: ChainListener) { let (node, client) = Self::build_kyoto(&self.trusted_peers, &self.config, &self.logger, &chain_listener); let Client { requester, info_rx, warn_rx, event_rx } = client; @@ -220,12 +258,17 @@ impl CbfChainSource { } let (ops_tx, ops_rx) = mpsc::unbounded_channel(); + let block_fee_cache = match &self.fee_source { + FeeSource::Cbf { block_fee_cache } => Some(Arc::clone(block_fee_cache)), + _ => None, + }; let block_applicator = BlockApplicator { chain_listener: chain_listener.clone(), ops_rx, + block_fee_cache, logger: Arc::clone(&self.logger), }; - runtime.spawn_background_task(block_applicator.run()); + self.runtime.spawn_background_task(block_applicator.run()); log_info!(self.logger, "CBF chain source started."); @@ -238,7 +281,7 @@ impl CbfChainSource { let restart_cbf_runtime_status = Arc::clone(&self.cbf_runtime_status); // let restart_block_applicator = - runtime.spawn_background_task(async move { + self.runtime.spawn_background_task(async move { let mut current_node = node; let mut current_info_rx = info_rx; let mut current_warn_rx = warn_rx; @@ -262,6 +305,7 @@ impl CbfChainSource { Arc::clone(&restart_registered_scripts), Arc::clone(&restart_cbf_runtime_status), ops_tx.clone(), + Arc::clone(&restart_listener.onchain_wallet), )); match current_node.run().await { @@ -373,6 +417,7 @@ impl CbfChainSource { logger: Arc, mut event_rx: mpsc::UnboundedReceiver, registered_scripts: Arc>>, cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, + onchain_wallet: Arc, ) { while let Some(event) = event_rx.recv().await { match event { @@ -384,9 +429,16 @@ impl CbfChainSource { continue; }, }; + //registered_scripts contains only LDK scripts, not onchain wallet's scripts, + //as don't want to track them twice: once in bdk, once in CbfChainSource, thus + //each time we receive an IndexedFilter event, we ask bdk to give us all + //revealed scripts. We create all_scripts starting from onchain wallet's + //scripts and extend them with LDK's ones + let mut all_scripts = onchain_wallet.list_revealed_scripts(); + all_scripts.extend(registered_scripts.lock().expect("lock").iter().cloned()); + let block_hash = indexed_filter.block_hash(); - let matched = indexed_filter - .contains_any(registered_scripts.lock().expect("lock").iter()); + let matched = indexed_filter.contains_any(all_scripts.iter()); let chop: ChainOp = if matched { let block_rx = @@ -475,8 +527,238 @@ impl CbfChainSource { self.registered_scripts.lock().expect("lock").insert(output.script_pubkey); } - pub(crate) fn register_script(&self, script: ScriptBuf) { - self.registered_scripts.lock().expect("lock").insert(script); + // pub(crate) fn register_script(&self, script: ScriptBuf) { + // self.registered_scripts.lock().expect("lock").insert(script); + // } + + pub(crate) async fn continuously_update_fee_rate_estimates( + &self, mut stop_sync_receiver: watch::Receiver<()>, + ) { + let mut fee_rate_update_interval = + tokio::time::interval(Duration::from_secs(DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS)); + // We primed the cache once on startup, so skip the immediate first tick. + fee_rate_update_interval.reset(); + fee_rate_update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + loop { + tokio::select! { + _ = stop_sync_receiver.changed() => { + log_trace!(self.logger, "Stopping CBF fee-rate update loop."); + return; + } + _ = fee_rate_update_interval.tick() => { + if let Err(e) = self.update_fee_rate_estimates().await { + log_error!(self.logger, "Failed to update fee rate estimates: {:?}", e); + } + } + } + } + } + + pub(crate) async fn update_fee_rate_estimates(&self) -> Result<(), Error> { + let new_fee_rate_cache = match &self.fee_source { + FeeSource::Esplora { client } => { + let estimates = client.get_fee_estimates().await.map_err(|e| { + log_error!(self.logger, "Failed to retrieve fee rate estimates: {}", e); + Error::FeerateEstimationUpdateFailed + })?; + + if estimates.is_empty() && self.config.network == Network::Bitcoin { + log_error!( + self.logger, + "Failed to retrieve fee rate: empty fee estimates are disallowed on Mainnet." + ); + return Err(Error::FeerateEstimationUpdateFailed); + } + + let mut new_fee_rate_cache = HashMap::with_capacity(10); + for target in get_all_conf_targets() { + let num_blocks = get_num_block_defaults_for_target(target); + // Fall back to 1 sat/vb if we fail or it yields less than that, mostly to keep + // going on signet/regtest where estimates may be missing or bogus. + let converted_estimate_sat_vb = + esplora_client::convert_fee_rate(num_blocks, estimates.clone()) + .map_or(1.0, |converted| converted.max(1.0)); + let fee_rate = + FeeRate::from_sat_per_kwu((converted_estimate_sat_vb * 250.0) as u64); + let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); + new_fee_rate_cache.insert(target, adjusted_fee_rate); + } + new_fee_rate_cache + }, + FeeSource::Electrum { server_url } => { + let electrum_config = ElectrumConfigBuilder::new() + .retry(ELECTRUM_FEE_NUM_RETRIES) + .timeout(Some(Duration::from_secs(ELECTRUM_FEE_TIMEOUT_SECS))) + .build(); + + let server_url = server_url.clone(); + let electrum_client = self + .runtime + .spawn_blocking(move || { + ElectrumClient::from_config(&server_url, electrum_config) + }) + .await + .map_err(|e| { + log_error!(self.logger, "Fee rate estimation task panicked: {}", e); + Error::FeerateEstimationUpdateFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to connect to electrum server: {}", e); + Error::ConnectionFailed + })?; + + get_electrum_fee_rate_cache_update( + Arc::clone(&self.runtime), + Arc::new(electrum_client), + self.config.network, + ELECTRUM_FEE_TIMEOUT_SECS, + Arc::clone(&self.logger), + ) + .await? + }, + FeeSource::Cbf { block_fee_cache } => { + let requester = self.requester()?; + let mut samples_sat_per_kwu: Vec = self + .refresh_block_fee_window(&requester, block_fee_cache) + .await + .iter() + .map(|rate| rate.to_sat_per_kwu()) + .collect(); + samples_sat_per_kwu.sort_unstable(); + + let mut new_fee_rate_cache = HashMap::with_capacity(10); + for target in get_all_conf_targets() { + let fee_rate = if samples_sat_per_kwu.is_empty() { + FeeRate::from_sat_per_kwu(get_fallback_rate_for_target(target) as u64) + } else { + let percentile = cbf_percentile_for_target(target); + let sat_per_kwu = percentile_of_sorted(&samples_sat_per_kwu, percentile) + .max(CBF_MIN_FEERATE_SAT_PER_KWU); + FeeRate::from_sat_per_kwu(sat_per_kwu) + }; + let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); + new_fee_rate_cache.insert(target, adjusted_fee_rate); + } + new_fee_rate_cache + }, + }; + + self.commit_fee_rate_cache(new_fee_rate_cache).await + } + + /// Writes a freshly computed per-target fee-rate map into the estimator cache and records the + /// update timestamp in the node metrics. + async fn commit_fee_rate_cache( + &self, new_fee_rate_cache: HashMap, + ) -> Result<(), Error> { + self.fee_estimator.set_fee_rate_cache(new_fee_rate_cache); + let unix_time_secs_opt = + SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()); + update_and_persist_node_metrics(&self.node_metrics, &*self.kv_store, &*self.logger, |m| { + m.latest_fee_rate_cache_update_timestamp = unix_time_secs_opt + }) + .await?; + Ok(()) + } + + /// Returns a clone of the live kyoto requester, or an error if the node isn't running. + fn requester(&self) -> Result { + match &*self.cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => Ok(requester.clone()), + CbfRuntimeStatus::Stopped => { + debug_assert!( + false, + "We should have started the chain source before updating fees" + ); + Err(Error::FeerateEstimationUpdateFailed) + }, + } + } + + /// Reconciles the block-fee cache against the canonical chain and returns the per-block fee + /// rates for the most recent [`FEE_WINDOW_BLOCKS`] blocks. + /// + /// For each height in the window we fetch the canonical block hash; if the cached entry still + /// matches we reuse its rate, otherwise (new block, or a block that was reorged out) we download + /// it via [`Requester::average_fee_rate`]. Heights outside the window are evicted by replacing + /// the cache with the freshly built window. + /// + /// This is best-effort: a height we can't fetch a header or block for is simply skipped (so a + /// slow or unresponsive peer can't stall or void the whole update), and an empty result just + /// means we have no recent data yet. The window therefore fills incrementally over successive + /// updates rather than requiring all [`FEE_WINDOW_BLOCKS`] downloads to succeed at once. + async fn refresh_block_fee_window( + &self, requester: &Requester, cache: &Mutex>, + ) -> Vec { + let tip_height = match requester.chain_tip().await { + Ok(tip) => tip.height, + Err(e) => { + log_error!(self.logger, "CBF fee update: failed to fetch chain tip: {:?}", e); + return Vec::new(); + }, + }; + let lo = tip_height.saturating_sub(FEE_WINDOW_BLOCKS - 1); + + // Snapshot the cache so we never hold the std `Mutex` across an `.await`. + let cached = cache.lock().expect("lock").clone(); + + let mut window = BTreeMap::new(); + for height in lo..=tip_height { + let canonical_hash = match requester.get_header(height).await { + // Height not available (yet); skip it. + Ok(None) => continue, + Ok(Some(header)) => header.block_hash(), + Err(e) => { + log_debug!( + self.logger, + "CBF fee update: failed to fetch header at height {}, skipping: {:?}", + height, + e + ); + continue; + }, + }; + + // Reuse the cached rate while the block is still canonical; otherwise download it. + if let Some((hash, fee_rate)) = cached.get(&height) { + if *hash == canonical_hash { + window.insert(height, (canonical_hash, *fee_rate)); + continue; + } + } + + match tokio::time::timeout( + Duration::from_secs(CBF_FEE_BLOCK_FETCH_TIMEOUT_SECS), + requester.average_fee_rate(canonical_hash), + ) + .await + { + Ok(Ok(fee_rate)) => { + window.insert(height, (canonical_hash, fee_rate)); + }, + Ok(Err(e)) => { + log_debug!( + self.logger, + "CBF fee update: failed to fetch fee rate for block {}, skipping: {:?}", + canonical_hash, + e + ); + }, + Err(_) => { + log_debug!( + self.logger, + "CBF fee update: timed out fetching block {} for fee estimation, skipping.", + canonical_hash, + ); + }, + } + } + + let samples = window.values().map(|(_, fee_rate)| *fee_rate).collect(); + // Replacing the cache wholesale also evicts any entries that fell out of the window. + *cache.lock().expect("lock") = window; + samples } } diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 59fa23a6ca..c1d04dc6f6 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -289,7 +289,14 @@ impl ElectrumChainSource { let now = Instant::now(); - let new_fee_rate_cache = electrum_client.get_fee_rate_cache_update().await?; + let new_fee_rate_cache = get_electrum_fee_rate_cache_update( + Arc::clone(&electrum_client.runtime), + Arc::clone(&electrum_client.electrum_client), + self.config.network, + self.sync_config.timeouts_config.fee_rate_cache_update_timeout_secs, + Arc::clone(&self.logger), + ) + .await?; self.fee_estimator.set_fee_rate_cache(new_fee_rate_cache); log_debug!( @@ -708,91 +715,84 @@ impl ElectrumRuntimeClient { Err(e) => self.log_broadcast_error(e, &txids, &package), } } +} - async fn get_fee_rate_cache_update( - &self, - ) -> Result, Error> { - let electrum_client = Arc::clone(&self.electrum_client); - - let mut batch = Batch::default(); - let confirmation_targets = get_all_conf_targets(); - for target in confirmation_targets { - let num_blocks = get_num_block_defaults_for_target(target); - batch.estimate_fee(num_blocks, None); - } - - let spawn_fut = self.runtime.spawn_blocking(move || electrum_client.batch_call(&batch)); +pub(crate) async fn get_electrum_fee_rate_cache_update( + runtime: Arc, electrum_client: Arc, network: Network, + fee_rate_cache_update_timeout_secs: u64, logger: Arc, +) -> Result, Error> { + let mut batch = Batch::default(); + let confirmation_targets = get_all_conf_targets(); + for target in confirmation_targets { + let num_blocks = get_num_block_defaults_for_target(target); + batch.estimate_fee(num_blocks, None); + } - let timeout_fut = tokio::time::timeout( - Duration::from_secs( - self.sync_config.timeouts_config.fee_rate_cache_update_timeout_secs, - ), - spawn_fut, + let spawn_fut = runtime.spawn_blocking(move || electrum_client.batch_call(&batch)); + + let timeout_fut = + tokio::time::timeout(Duration::from_secs(fee_rate_cache_update_timeout_secs), spawn_fut); + + let raw_estimates_btc_kvb = timeout_fut + .await + .map_err(|e| { + log_error!(logger, "Updating fee rate estimates timed out: {}", e); + Error::FeerateEstimationUpdateTimeout + })? + .map_err(|e| { + log_error!(logger, "Failed to retrieve fee rate estimates: {}", e); + Error::FeerateEstimationUpdateFailed + })? + .map_err(|e| { + log_error!(logger, "Failed to retrieve fee rate estimates: {}", e); + Error::FeerateEstimationUpdateFailed + })?; + + if raw_estimates_btc_kvb.len() != confirmation_targets.len() && network == Network::Bitcoin { + // Ensure we fail if we didn't receive all estimates. + debug_assert!( + false, + "Electrum server didn't return all expected results. This is disallowed on Mainnet." ); - - let raw_estimates_btc_kvb = timeout_fut - .await - .map_err(|e| { - log_error!(self.logger, "Updating fee rate estimates timed out: {}", e); - Error::FeerateEstimationUpdateTimeout - })? - .map_err(|e| { - log_error!(self.logger, "Failed to retrieve fee rate estimates: {}", e); - Error::FeerateEstimationUpdateFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to retrieve fee rate estimates: {}", e); - Error::FeerateEstimationUpdateFailed - })?; - - if raw_estimates_btc_kvb.len() != confirmation_targets.len() - && self.config.network == Network::Bitcoin - { - // Ensure we fail if we didn't receive all estimates. - debug_assert!(false, - "Electrum server didn't return all expected results. This is disallowed on Mainnet." - ); - log_error!(self.logger, + log_error!(logger, "Failed to retrieve fee rate estimates: Electrum server didn't return all expected results. This is disallowed on Mainnet." ); - return Err(Error::FeerateEstimationUpdateFailed); - } + return Err(Error::FeerateEstimationUpdateFailed); + } - let mut new_fee_rate_cache = HashMap::with_capacity(10); - for (target, raw_fee_rate_btc_per_kvb) in - confirmation_targets.into_iter().zip(raw_estimates_btc_kvb.into_iter()) - { - // Parse the retrieved serde_json::Value and fall back to 1 sat/vb (10^3 / 10^8 = 10^-5 - // = 0.00001 btc/kvb) if we fail or it yields less than that. This is mostly necessary - // to continue on `signet`/`regtest` where we might not get estimates (or bogus - // values). - let fee_rate_btc_per_kvb = raw_fee_rate_btc_per_kvb - .as_f64() - .map_or(0.00001, |converted| converted.max(0.00001)); - - // Electrum, just like Bitcoin Core, gives us a feerate in BTC/KvB. - // Thus, we multiply by 25_000_000 (10^8 / 4) to get satoshis/kwu. - let fee_rate = { - let fee_rate_sat_per_kwu = (fee_rate_btc_per_kvb * 25_000_000.0).round() as u64; - FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu) - }; - - // LDK 0.0.118 introduced changes to the `ConfirmationTarget` semantics that - // require some post-estimation adjustments to the fee rates, which we do here. - let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); - - new_fee_rate_cache.insert(target, adjusted_fee_rate); - - log_trace!( - self.logger, - "Fee rate estimation updated for {:?}: {} sats/kwu", - target, - adjusted_fee_rate.to_sat_per_kwu(), - ); - } + let mut new_fee_rate_cache = HashMap::with_capacity(10); + for (target, raw_fee_rate_btc_per_kvb) in + confirmation_targets.into_iter().zip(raw_estimates_btc_kvb.into_iter()) + { + // Parse the retrieved serde_json::Value and fall back to 1 sat/vb (10^3 / 10^8 = 10^-5 + // = 0.00001 btc/kvb) if we fail or it yields less than that. This is mostly necessary + // to continue on `signet`/`regtest` where we might not get estimates (or bogus + // values). + let fee_rate_btc_per_kvb = + raw_fee_rate_btc_per_kvb.as_f64().map_or(0.00001, |converted| converted.max(0.00001)); + + // Electrum, just like Bitcoin Core, gives us a feerate in BTC/KvB. + // Thus, we multiply by 25_000_000 (10^8 / 4) to get satoshis/kwu. + let fee_rate = { + let fee_rate_sat_per_kwu = (fee_rate_btc_per_kvb * 25_000_000.0).round() as u64; + FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu) + }; + + // LDK 0.0.118 introduced changes to the `ConfirmationTarget` semantics that + // require some post-estimation adjustments to the fee rates, which we do here. + let adjusted_fee_rate = apply_post_estimation_adjustments(target, fee_rate); - Ok(new_fee_rate_cache) + new_fee_rate_cache.insert(target, adjusted_fee_rate); + + log_trace!( + logger, + "Fee rate estimation updated for {:?}: {} sats/kwu", + target, + adjusted_fee_rate.to_sat_per_kwu(), + ); } + + Ok(new_fee_rate_cache) } impl Filter for ElectrumRuntimeClient { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index ee7a16c29b..451ea5bf0e 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -233,7 +233,7 @@ impl ChainSource { } pub(crate) fn new_cbf( - peers: Vec, fee_source_config: Option, + peers: Vec, fee_source_config: Option, runtime: Arc, fee_estimator: Arc, tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: Arc, node_metrics: Arc, @@ -241,8 +241,12 @@ impl ChainSource { let cbf_chain_source = CbfChainSource::new( peers, fee_source_config, + runtime, + Arc::clone(&fee_estimator), + Arc::clone(&kv_store), Arc::clone(&config), Arc::clone(&logger), + Arc::clone(&node_metrics), )?; let kind = ChainSourceKind::Cbf(cbf_chain_source); let registered_txids = Mutex::new(HashSet::new()); @@ -265,7 +269,7 @@ impl ChainSource { chain_monitor, output_sweeper, }; - cbf_chain_source.start(runtime, chain_listener); + cbf_chain_source.start(chain_listener); }, _ => { // Nothing to do for other chain sources. @@ -293,13 +297,6 @@ impl ChainSource { } } - pub(crate) fn register_script(&self, script: ScriptBuf) { - match &self.kind { - ChainSourceKind::Cbf(cbf) => cbf.register_script(script), - _ => {}, // no-op: Esplora/Electrum/bitcoind don't need a watch set - } - } - pub(crate) fn registered_txids(&self) -> HashSet { self.registered_txids.lock().expect("lock").clone() } @@ -377,14 +374,9 @@ impl ChainSource { .await }, ChainSourceKind::Cbf(cbf_chain_source) => { - todo!(); - // cbf_chain_source.process_kyoto_events( - // stop_sync_receiver, - // onchain_wallet, - // channel_manager, - // chain_monitor, - // output_sweeper, - // ); + //CBF cannot run without background syncing, when the chain source is running, it + //syncs. Thus we don't have anything similar to other chain sources. + cbf_chain_source.continuously_update_fee_rate_estimates(stop_sync_receiver).await }, } } @@ -541,8 +533,8 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.update_fee_rate_estimates().await }, - ChainSourceKind::Cbf { .. } => { - todo!(); + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source.update_fee_rate_estimates().await }, } } diff --git a/src/config.rs b/src/config.rs index 958eb14fcd..d4ac480936 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,7 +26,7 @@ use crate::logger::LogLevel; const DEFAULT_NETWORK: Network = Network::Bitcoin; const DEFAULT_BDK_WALLET_SYNC_INTERVAL_SECS: u64 = 80; const DEFAULT_LDK_WALLET_SYNC_INTERVAL_SECS: u64 = 30; -const DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS: u64 = 60 * 10; +pub(crate) const DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS: u64 = 60 * 10; const DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER: u64 = 3; pub(crate) const DEFAULT_PROBING_INTERVAL_SECS: u64 = 10; pub(crate) const MIN_PROBING_INTERVAL: Duration = Duration::from_millis(100); diff --git a/src/util.rs b/src/util.rs index 3350ad2c70..8cd86665a2 100644 --- a/src/util.rs +++ b/src/util.rs @@ -5,6 +5,61 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +//! Miscellaneous pure helper functions. + +use bitcoin::constants::SUBSIDY_HALVING_INTERVAL; +use bitcoin::{Amount, Block, FeeRate}; + +use crate::fee_estimator::{get_num_block_defaults_for_target, ConfirmationTarget}; + +/// Block subsidy at the given height (approximate on regtest). +pub(crate) fn block_subsidy(height: u32) -> Amount { + let halvings = height / SUBSIDY_HALVING_INTERVAL; + if halvings >= 64 { + return Amount::ZERO; + } + Amount::from_sat((Amount::ONE_BTC.to_sat() * 50) >> halvings) +} + +/// Average fee rate of a block, derived from its coinbase: `(coinbase output total - subsidy) / +/// weight`. Lets us compute the fee rate of a block we already hold without a re-download. +pub(crate) fn coinbase_fee_rate(block: &Block, height: u32) -> FeeRate { + let revenue: Amount = block + .txdata + .first() + .map(|coinbase| coinbase.output.iter().map(|txout| txout.value).sum()) + .unwrap_or(Amount::ZERO); + let block_fees = revenue.checked_sub(block_subsidy(height)).unwrap_or(Amount::ZERO); + let fee_rate = block_fees.to_sat().checked_div(block.weight().to_kwu_floor()).unwrap_or(0); + FeeRate::from_sat_per_kwu(fee_rate) +} + +/// Maps a confirmation target to the percentile of the recent-block fee-rate window we read for it. +/// +/// More urgent targets (shorter confirmation horizon) read a higher percentile; relaxed targets +/// read a lower one. This is a coarse stand-in for the per-horizon estimates a mempool-aware +/// backend would provide. +pub(crate) fn cbf_percentile_for_target(target: ConfirmationTarget) -> f64 { + match get_num_block_defaults_for_target(target) { + 0..=2 => 90.0, + 3..=6 => 75.0, + 7..=12 => 50.0, + 13..=144 => 25.0, + _ => 10.0, + } +} + +/// Returns the value at the given percentile of an ascending-sorted slice using nearest-rank. +/// Returns `0` for an empty slice. +pub(crate) fn percentile_of_sorted(sorted: &[u64], percentile: f64) -> u64 { + if sorted.is_empty() { + return 0; + } + let rank = ((percentile / 100.0) * sorted.len() as f64).ceil() as usize; + let idx = rank.saturating_sub(1).min(sorted.len() - 1); + sorted[idx] +} + /// Returns a random `u64` uniformly distributed in `[min, max]` (inclusive). pub(crate) fn random_range(min: u64, max: u64) -> u64 { debug_assert!(min <= max); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d15e1dab80..44586bb28e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -36,7 +36,7 @@ use lightning::chain::chaininterface::{ INCREMENTAL_RELAY_FEE_SAT_PER_1000_WEIGHT, }; use lightning::chain::channelmonitor::ANTI_REORG_DELAY; -use lightning::chain::{BlockLocator, ClaimId, Filter, Listen}; +use lightning::chain::{BlockLocator, ClaimId, Listen}; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::ln::msgs::UnsignedGossipMessage; @@ -226,14 +226,6 @@ impl Wallet { .collect() } - /// Register scripts that BDK revealed at index time (e.g. change outputs, which `create_tx` - /// only peeks) with the chain source's watch set. No-op for non-CBF backends. - fn register_revealed_scripts(&self, _locked_wallet: &PersistedWallet) { - // TODO(cbf): diff `last_revealed_index(keychain)` against a per-keychain cursor and - // `chain_source.register_script(spk)` the delta for both keychains. - todo!() - } - async fn update_payment_store(&self, mut events: Vec) -> Result<(), Error> { if events.is_empty() { return Ok(()); @@ -537,7 +529,6 @@ impl Wallet { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; - self.chain_source.register_script(address_info.script_pubkey()); Ok(address_info.address) } @@ -552,7 +543,6 @@ impl Wallet { log_error!(self.logger, "Failed to persist wallet: {}", e); Error::PersistenceFailed })?; - self.chain_source.register_script(address_info.script_pubkey()); Ok(address_info.address) } @@ -1150,7 +1140,6 @@ impl Wallet { locked_persister.persist_changeset(change_set).await.map_err(|e| { log_error!(self.logger, "Failed to persist wallet: {}", e); })?; - self.chain_source.register_script(address_info.script_pubkey()); Ok(address_info.address.script_pubkey()) } From d02570b060e648eadfbcbc1f1bacd290414b88e4 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 9 Jun 2026 15:54:35 +0200 Subject: [PATCH 107/138] cbf: implement package broadcasting Co-authored-by: febyeji --- src/chain/cbf.rs | 45 +++++++++++++++++++++++++++++++++------------ src/chain/mod.rs | 15 ++++++++++++--- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index b4bcb8811b..bf26bd6a55 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -6,9 +6,10 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bip157::chain::ChainState; use bip157::{ chain::BlockHeaderChanges, error::FetchBlockError, Builder as KyotoBuilder, Client, Event, - HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Requester, TrustedPeer, Warning, + HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, + Warning, }; -use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Txid}; +use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder}; use lightning::chain::{BlockLocator, Listen, WatchedOutput}; @@ -618,7 +619,10 @@ impl CbfChainSource { .await? }, FeeSource::Cbf { block_fee_cache } => { - let requester = self.requester()?; + let requester = match &*self.cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => requester.clone(), + CbfRuntimeStatus::Stopped => return Err(Error::FeerateEstimationUpdateFailed), + }; let mut samples_sat_per_kwu: Vec = self .refresh_block_fee_window(&requester, block_fee_cache) .await @@ -662,16 +666,33 @@ impl CbfChainSource { Ok(()) } - /// Returns a clone of the live kyoto requester, or an error if the node isn't running. - fn requester(&self) -> Result { - match &*self.cbf_runtime_status.lock().expect("lock") { - CbfRuntimeStatus::Started { requester } => Ok(requester.clone()), + pub(crate) async fn process_broadcast_package(&self, package: Vec) { + let requester = match &*self.cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => requester.clone(), CbfRuntimeStatus::Stopped => { - debug_assert!( - false, - "We should have started the chain source before updating fees" - ); - Err(Error::FeerateEstimationUpdateFailed) + debug_assert!(false, "We should have started the chain source before broadcasting"); + return; + }, + }; + + match Package::from_vec(package.clone()) { + Ok(package) => { + if let Err(e) = requester.submit_package(package).await { + log_error!(self.logger, "Failed to broadcast transaction package: {:?}", e); + } + }, + Err(_) => { + for tx in package { + let txid = tx.compute_txid(); + if let Err(e) = requester.submit_package(tx).await { + log_error!( + self.logger, + "Failed to broadcast transaction {}: {:?}", + txid, + e + ); + } + } }, } } diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 451ea5bf0e..76c03b481f 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -556,6 +556,13 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.validate_zero_fee_commitments_support().await }, + ChainSourceKind::Cbf(_) => { + log_error!( + self.logger, + "CBF chain sources cannot verify zero-fee commitment package relay support" + ); + Err(Error::ChainSourceNotSupported) + }, } } @@ -599,9 +606,11 @@ impl ChainSource { ChainSourceKind::Bitcoind(bitcoind_chain_source) => { bitcoind_chain_source.process_transaction_broadcast(package).await }, - ChainSourceKind::Cbf { ..} => { - todo!(); - } + ChainSourceKind::Cbf(cbf_chain_source) => { + cbf_chain_source + .process_broadcast_package(package.into_inner()) + .await + }, } } } From 53999d17a1a201b4b9ce8e864350af168be07641 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Fri, 12 Jun 2026 00:35:52 +0300 Subject: [PATCH 108/138] Add `next_height` to the block applicator Also added env var for the CBF tests, also waiting for tx gossip for broadcast in some of the tests. --- src/chain/cbf.rs | 41 +++++++++++++++++++++++++++------ src/chain/mod.rs | 3 ++- src/wallet/mod.rs | 13 ++++++----- tests/common/mod.rs | 28 +++++++++++++++++++++- tests/integration_tests_rust.rs | 2 ++ 5 files changed, 72 insertions(+), 15 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index bf26bd6a55..9a0c483f87 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -101,6 +101,7 @@ enum ChainOp { struct BlockApplicator { chain_listener: ChainListener, ops_rx: mpsc::UnboundedReceiver, + next_height: u32, /// Present only for the native CBF fee source: lets us cache the fee rate of blocks we download /// here, so the fee estimator doesn't have to re-download them. block_fee_cache: Option, @@ -113,7 +114,17 @@ impl BlockApplicator { match op { ChainOp::ConnectFull { block_rx } => match block_rx.await { Ok(Ok(ib)) => { + if ib.height != self.next_height { + log_debug!( + self.logger, + "CBF skipping out-of-sequence block at height {} (expected {})", + ib.height, + self.next_height + ); + continue; + } self.chain_listener.block_connected(&ib.block, ib.height); + self.next_height += 1; if let Some(cache) = &self.block_fee_cache { let fee_rate = coinbase_fee_rate(&ib.block, ib.height); cache @@ -126,10 +137,21 @@ impl BlockApplicator { Err(_) => log_error!(self.logger, "block oneshot dropped"), }, ChainOp::ConnectFiltered { header, height } => { + if height != self.next_height { + log_debug!( + self.logger, + "CBF skipping out-of-sequence block at height {} (expected {})", + height, + self.next_height + ); + continue; + } self.chain_listener.filtered_block_connected(&header, &[], height); + self.next_height += 1; }, ChainOp::Disconnect { fork_point } => { self.chain_listener.blocks_disconnected(fork_point); + self.next_height = fork_point.height + 1; }, ChainOp::Synced { tip_height } => { log_info!(self.logger, "CBF caught up to tip {}", tip_height); @@ -264,6 +286,7 @@ impl CbfChainSource { _ => None, }; let block_applicator = BlockApplicator { + next_height: chain_listener.get_best_block().height + 1, chain_listener: chain_listener.clone(), ops_rx, block_fee_cache, @@ -336,14 +359,13 @@ impl CbfChainSource { backoff_ms, ); - tokio::time::sleep(Duration::from_millis(backoff_ms)).await; - backoff_ms = backoff_ms.saturating_mul(2); - // Abort the old log consumers before rebuilding. info_handle.abort(); warn_handle.abort(); event_handle.abort(); + tokio::time::sleep(Duration::from_millis(backoff_ms)).await; + backoff_ms = backoff_ms.saturating_mul(2); let (new_node, new_client) = Self::build_kyoto( &restart_peers, &restart_config, @@ -442,9 +464,11 @@ impl CbfChainSource { let matched = indexed_filter.contains_any(all_scripts.iter()); let chop: ChainOp = if matched { - let block_rx = - requester.request_block(block_hash).expect("cannot request block"); - ChainOp::ConnectFull { block_rx } + if let Ok(handle) = requester.request_block(block_hash) { + ChainOp::ConnectFull { block_rx: handle } + } else { + break; + } } else { let height = indexed_filter.height(); //TODO we need to recheck that a particular height has not been @@ -468,6 +492,8 @@ impl CbfChainSource { } }, Ok(None) => { + //TODO what do we do? + todo!(); log_error!(logger, "No header at height {}", height,); continue; }, @@ -478,7 +504,8 @@ impl CbfChainSource { height, e, ); - continue; + break; + // continue; }, } }; diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 76c03b481f..13ac0d4ad1 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -14,7 +14,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bitcoin::{Script, ScriptBuf, Transaction, Txid}; +use bitcoin::{Script, Txid}; use lightning::chain::{BlockLocator, Filter}; use crate::chain::bitcoind::{BitcoindChainSource, ChainListener, UtxoSourceClient}; @@ -517,6 +517,7 @@ impl ChainSource { .await }, ChainSourceKind::Cbf { .. } => { + return Ok(()); todo!(); }, } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 44586bb28e..e25c259d44 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1856,13 +1856,14 @@ fn aggregate_local_stakes(candidate: &FundingCandidate) -> LocalStakeAggregate { impl Listen for Wallet { fn filtered_block_connected( - &self, _header: &bitcoin::block::Header, - _txdata: &lightning::chain::transaction::TransactionData, _height: u32, + &self, header: &bitcoin::block::Header, + _txdata: &lightning::chain::transaction::TransactionData, height: u32, ) { - debug_assert!(false, "Syncing filtered blocks is currently not supported"); - // As far as we can tell this would be a no-op anyways as we don't have to tell BDK about - // the header chain of intermediate blocks. According to the BDK team, it's sufficient to - // only connect full blocks starting from the last point of disagreement. + // A non-matching filter means none of this block's transactions are relevant to us, so there + // is nothing but the header to apply. We still connect an empty block built from the header + // to keep the on-chain wallet's chain contiguous with the listeners. + let block = bitcoin::Block { header: *header, txdata: Vec::new() }; + self.block_connected(&block, height); } fn block_connected(&self, block: &bitcoin::Block, height: u32) { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index dc4d8079cf..ab911c6974 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -314,6 +314,10 @@ pub(crate) fn setup_bitcoind_and_electrsd() -> (BitcoinD, ElectrsD) { let mut bitcoind_conf = corepc_node::Conf::default(); bitcoind_conf.network = "regtest"; bitcoind_conf.args.push("-rest"); + // Enable P2P and compact block filters so the CBF (BIP157) chain source can connect and sync. + bitcoind_conf.p2p = corepc_node::P2P::Yes; + bitcoind_conf.args.push("-blockfilterindex=1"); + bitcoind_conf.args.push("-peerblockfilters=1"); let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap(); let electrs_exe = env::var("ELECTRS_EXE") @@ -330,7 +334,14 @@ pub(crate) fn setup_bitcoind_and_electrsd() -> (BitcoinD, ElectrsD) { pub(crate) fn random_chain_source<'a>( bitcoind: &'a BitcoinD, electrsd: &'a ElectrsD, ) -> TestChainSource<'a> { - let r = rand::random_range(0..3); + let r = match std::env::var("LDK_TEST_CHAIN_SOURCE").ok().as_deref() { + Some("esplora") => 0, + Some("electrum") => 1, + Some("bitcoind-rpc") => 2, + Some("bitcoind-rest") => 3, + Some("cbf") => 4, + _ => rand::random_range(0..3), + }; match r { 0 => { println!("Randomly setting up Esplora chain syncing..."); @@ -348,6 +359,10 @@ pub(crate) fn random_chain_source<'a>( println!("Randomly setting up Bitcoind REST chain syncing..."); TestChainSource::BitcoindRestSync(bitcoind) }, + 4 => { + println!("Randomly setting up CBF compact block filter syncing..."); + TestChainSource::Cbf(bitcoind) + }, _ => unreachable!(), } } @@ -535,6 +550,7 @@ pub(crate) enum TestChainSource<'a> { Electrum(&'a ElectrsD), BitcoindRpcSync(&'a BitcoinD), BitcoindRestSync(&'a BitcoinD), + Cbf(&'a BitcoinD), } #[derive(Clone, Copy)] @@ -707,6 +723,11 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> config.wallet_rescan_from_height, ); }, + TestChainSource::Cbf(bitcoind) => { + let p2p_socket = bitcoind.params.p2p_socket.expect("P2P must be enabled for CBF"); + let peer_addr = format!("{}", p2p_socket); + builder.set_chain_source_cbf(vec![peer_addr], None); + }, } match &config.log_writer { @@ -1562,6 +1583,8 @@ pub(crate) async fn do_channel_full_cycle( expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); + tokio::time::sleep(Duration::from_secs(2)).await; + let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; wait_for_node_tip(&node_a, new_height).await; wait_for_node_tip(&node_b, new_height).await; @@ -1586,6 +1609,7 @@ pub(crate) async fn do_channel_full_cycle( expect_splice_negotiated_event!(node_a, node_b.node_id()); expect_splice_negotiated_event!(node_b, node_a.node_id()); + tokio::time::sleep(Duration::from_secs(5)).await; let new_height = generate_blocks_and_wait(&bitcoind, electrsd, 6).await; wait_for_node_tip(&node_a, new_height).await; wait_for_node_tip(&node_b, new_height).await; @@ -1638,8 +1662,10 @@ pub(crate) async fn do_channel_full_cycle( tokio::time::sleep(Duration::from_secs(1)).await; if force_close { node_a.force_close_channel(&user_channel_id_a, node_b.node_id(), None).unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; } else { node_a.close_channel(&user_channel_id_a, node_b.node_id()).unwrap(); + tokio::time::sleep(Duration::from_secs(2)).await; // The cooperative shutdown may complete before we get to check, but if the channel // is still visible it must already be in a shutdown state. if let Some(channel) = diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index f8aef3838a..9e762d9c6a 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3001,6 +3001,8 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { let new_height = generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; service_node.sync_wallets().unwrap(); payer_node.sync_wallets().unwrap(); + wait_for_node_tip(&service_node, new_height).await; + wait_for_node_tip(&payer_node, new_height).await; expect_channel_ready_event!(payer_node, service_node.node_id()); expect_channel_ready_event!(service_node, payer_node.node_id()); From 4d18bdc1442fa8b0c1c89939845c5747c863f2a3 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Wed, 8 Jul 2026 19:33:28 +0100 Subject: [PATCH 109/138] cbf: make sync_wallets wait for applied tip --- src/chain/cbf.rs | 244 ++++++++++++++++++++++++++++++++++++++--------- src/chain/mod.rs | 5 +- src/lib.rs | 3 + 3 files changed, 205 insertions(+), 47 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 9a0c483f87..20b52fd847 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -5,15 +5,14 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bip157::chain::ChainState; use bip157::{ - chain::BlockHeaderChanges, error::FetchBlockError, Builder as KyotoBuilder, Client, Event, - HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, - Warning, + chain::BlockHeaderChanges, Builder as KyotoBuilder, Client, Event, HashCheckpoint, Header, + IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, Warning, }; use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder}; use lightning::chain::{BlockLocator, Listen, WatchedOutput}; -use tokio::sync::{mpsc, oneshot, watch}; +use tokio::sync::{mpsc, watch}; use crate::chain::bitcoind::ChainListener; use crate::chain::electrum::get_electrum_fee_rate_cache_update; @@ -50,6 +49,9 @@ const MAX_RESTART_RETRIES: u32 = 5; /// Initial backoff delay between restart attempts; doubles each failure. const INITIAL_BACKOFF_MS: u64 = 500; +/// Retry matched block downloads before surfacing a CBF sync failure. +const CBF_BLOCK_FETCH_RETRIES: u8 = 3; + const ESPLORA_TIMEOUT: u64 = 2; /// Retries and per-request timeout for the fresh Electrum connection opened each fee cycle. @@ -62,6 +64,12 @@ enum CbfRuntimeStatus { Stopped, } +#[derive(Clone, Copy)] +enum CbfSyncState { + Active { applied_tip: Option }, + Failed(Error), +} + /// Struct for holding cbf chain source pub struct CbfChainSource { /// Trusted peer addresses for kyoto's `Builder::add_peers`. @@ -71,6 +79,8 @@ pub struct CbfChainSource { fee_source: FeeSource, /// Tracks whether the kyoto node is running and holds the live requester. cbf_runtime_status: Arc>, + /// Highest CBF sync tip whose preceding chain updates have been applied to all listeners. + sync_state_tx: watch::Sender, /// Handle used to spawn the background tasks and offload blocking work. runtime: Arc, /// Node configuration (network, storage path). @@ -83,7 +93,7 @@ pub struct CbfChainSource { enum ChainOp { ConnectFull { - block_rx: oneshot::Receiver>, + block: IndexedBlock, }, ConnectFiltered { header: Header, @@ -96,15 +106,21 @@ enum ChainOp { Synced { tip_height: u32, }, + Failed { + error: Error, + }, } struct BlockApplicator { chain_listener: ChainListener, ops_rx: mpsc::UnboundedReceiver, next_height: u32, + sync_state_tx: watch::Sender, /// Present only for the native CBF fee source: lets us cache the fee rate of blocks we download /// here, so the fee estimator doesn't have to re-download them. block_fee_cache: Option, + kv_store: Arc, + node_metrics: Arc, logger: Arc, } @@ -112,29 +128,25 @@ impl BlockApplicator { async fn run(mut self) { while let Some(op) = self.ops_rx.recv().await { match op { - ChainOp::ConnectFull { block_rx } => match block_rx.await { - Ok(Ok(ib)) => { - if ib.height != self.next_height { - log_debug!( - self.logger, - "CBF skipping out-of-sequence block at height {} (expected {})", - ib.height, - self.next_height - ); - continue; - } - self.chain_listener.block_connected(&ib.block, ib.height); - self.next_height += 1; - if let Some(cache) = &self.block_fee_cache { - let fee_rate = coinbase_fee_rate(&ib.block, ib.height); - cache - .lock() - .expect("lock") - .insert(ib.height, (ib.block.block_hash(), fee_rate)); - } - }, - Ok(Err(e)) => log_error!(self.logger, "block fetch failed: {:?}", e), - Err(_) => log_error!(self.logger, "block oneshot dropped"), + ChainOp::ConnectFull { block: ib } => { + if ib.height != self.next_height { + log_debug!( + self.logger, + "CBF skipping out-of-sequence block at height {} (expected {})", + ib.height, + self.next_height + ); + continue; + } + self.chain_listener.block_connected(&ib.block, ib.height); + self.next_height += 1; + if let Some(cache) = &self.block_fee_cache { + let fee_rate = coinbase_fee_rate(&ib.block, ib.height); + cache + .lock() + .expect("lock") + .insert(ib.height, (ib.block.block_hash(), fee_rate)); + } }, ChainOp::ConnectFiltered { header, height } => { if height != self.next_height { @@ -152,13 +164,56 @@ impl BlockApplicator { ChainOp::Disconnect { fork_point } => { self.chain_listener.blocks_disconnected(fork_point); self.next_height = fork_point.height + 1; + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(fork_point.height), + }); }, ChainOp::Synced { tip_height } => { log_info!(self.logger, "CBF caught up to tip {}", tip_height); - // TODO: notify sync-completion waiters (start()/sync_wallets()/tests) once - // a notification primitive is plumbed through. + if self.next_height > tip_height { + self.publish_synced_tip(tip_height).await; + } else { + log_debug!( + self.logger, + "CBF waiting to apply blocks through tip {} (next height {})", + tip_height, + self.next_height + ); + } }, + ChainOp::Failed { error } => { + self.sync_state_tx.send_replace(CbfSyncState::Failed(error)); + }, + } + } + } + + async fn publish_synced_tip(&self, tip_height: u32) { + let already_published = { + let sync_state = *self.sync_state_tx.borrow(); + match sync_state { + CbfSyncState::Active { applied_tip } => applied_tip, + CbfSyncState::Failed(_) => None, } + }; + if already_published.map_or(false, |published_height| published_height >= tip_height) { + return; + } + self.sync_state_tx.send_replace(CbfSyncState::Active { applied_tip: Some(tip_height) }); + let unix_time_secs_opt = + SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()); + if let Err(e) = update_and_persist_node_metrics( + &self.node_metrics, + &*self.kv_store, + &*self.logger, + |m| { + m.latest_lightning_wallet_sync_timestamp = unix_time_secs_opt; + m.latest_onchain_wallet_sync_timestamp = unix_time_secs_opt; + }, + ) + .await + { + log_error!(self.logger, "Failed to persist CBF sync metrics: {:?}", e); } } } @@ -221,11 +276,13 @@ impl CbfChainSource { }; let registered_scripts = Arc::new(Mutex::new(HashSet::new())); let cbf_runtime_status = Arc::new(Mutex::new(CbfRuntimeStatus::Stopped)); + let (sync_state_tx, _) = watch::channel(CbfSyncState::Active { applied_tip: None }); Ok(Self { trusted_peers, fee_source, registered_scripts, cbf_runtime_status, + sync_state_tx, runtime, config, fee_estimator, @@ -285,11 +342,17 @@ impl CbfChainSource { FeeSource::Cbf { block_fee_cache } => Some(Arc::clone(block_fee_cache)), _ => None, }; + let best_block_height = chain_listener.get_best_block().height; + self.sync_state_tx + .send_replace(CbfSyncState::Active { applied_tip: Some(best_block_height) }); let block_applicator = BlockApplicator { - next_height: chain_listener.get_best_block().height + 1, + next_height: best_block_height + 1, + sync_state_tx: self.sync_state_tx.clone(), chain_listener: chain_listener.clone(), ops_rx, block_fee_cache, + kv_store: Arc::clone(&self.kv_store), + node_metrics: Arc::clone(&self.node_metrics), logger: Arc::clone(&self.logger), }; self.runtime.spawn_background_task(block_applicator.run()); @@ -303,7 +366,7 @@ impl CbfChainSource { let restart_listener = chain_listener; let restart_registered_scripts = Arc::clone(&self.registered_scripts); let restart_cbf_runtime_status = Arc::clone(&self.cbf_runtime_status); - // let restart_block_applicator = + let restart_sync_state_tx = self.sync_state_tx.clone(); self.runtime.spawn_background_task(async move { let mut current_node = node; @@ -336,6 +399,7 @@ impl CbfChainSource { Ok(()) => { log_info!(restart_logger, "CBF node shut down cleanly."); *restart_status.lock().expect("lock") = CbfRuntimeStatus::Stopped; + restart_sync_state_tx.send_replace(CbfSyncState::Failed(Error::NotRunning)); break; }, Err(e) => { @@ -348,6 +412,8 @@ impl CbfChainSource { e, ); *restart_status.lock().expect("lock") = CbfRuntimeStatus::Stopped; + restart_sync_state_tx + .send_replace(CbfSyncState::Failed(Error::TxSyncFailed)); break; } log_error!( @@ -383,6 +449,8 @@ impl CbfChainSource { let mut status = restart_status.lock().expect("lock"); if matches!(*status, CbfRuntimeStatus::Stopped) { let _ = new_requester.shutdown(); + restart_sync_state_tx + .send_replace(CbfSyncState::Failed(Error::NotRunning)); log_info!( restart_logger, "CBF restart aborted: stop() called during backoff." @@ -390,6 +458,9 @@ impl CbfChainSource { break; } *status = CbfRuntimeStatus::Started { requester: new_requester }; + restart_sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(restart_listener.get_best_block().height), + }); } current_node = new_node; @@ -420,6 +491,37 @@ impl CbfChainSource { log_error!(self.logger, "Failed to shut down CBF node: {:?}", e); } } + self.sync_state_tx.send_replace(CbfSyncState::Failed(Error::NotRunning)); + } + + pub(crate) async fn wait_until_synced(&self) -> Result<(), Error> { + let requester = match &*self.cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => requester.clone(), + CbfRuntimeStatus::Stopped => return Err(Error::NotRunning), + }; + let target_tip = requester.chain_tip().await.map_err(|e| { + log_error!(self.logger, "Failed to fetch CBF chain tip before syncing: {:?}", e); + Error::TxSyncFailed + })?; + let target_height = target_tip.height; + let mut sync_state_rx = self.sync_state_tx.subscribe(); + + loop { + match *sync_state_rx.borrow() { + CbfSyncState::Active { applied_tip } => { + if applied_tip.map_or(false, |applied_height| applied_height >= target_height) { + return Ok(()); + } + }, + CbfSyncState::Failed(error) => return Err(error), + } + + if let Err(e) = sync_state_rx.changed().await { + debug_assert!(false, "Failed to receive CBF sync result: {:?}", e); + log_error!(self.logger, "Failed to receive CBF sync result: {:?}", e); + return Err(Error::TxSyncFailed); + } + } } async fn process_info_messages(mut info_rx: mpsc::Receiver, logger: Arc) { @@ -448,8 +550,8 @@ impl CbfChainSource { let requester = match &*cbf_runtime_status.lock().expect("lock") { CbfRuntimeStatus::Started { requester } => requester.clone(), CbfRuntimeStatus::Stopped => { - //TODO should we panic here? what do we do if we have no requester? - continue; + let _ = ops_tx.send(ChainOp::Failed { error: Error::NotRunning }); + return; }, }; //registered_scripts contains only LDK scripts, not onchain wallet's scripts, @@ -464,11 +566,68 @@ impl CbfChainSource { let matched = indexed_filter.contains_any(all_scripts.iter()); let chop: ChainOp = if matched { - if let Ok(handle) = requester.request_block(block_hash) { - ChainOp::ConnectFull { block_rx: handle } - } else { - break; - } + let mut attempt = 0; + let block = loop { + attempt += 1; + let handle = match requester.request_block(block_hash) { + Ok(handle) => handle, + Err(_) => { + log_error!( + logger, + "Failed to obtain receiver for matched CBF block {}; node is stopped", + block_hash + ); + let _ = + ops_tx.send(ChainOp::Failed { error: Error::NotRunning }); + return; + }, + }; + + match handle.await { + Ok(Ok(block)) => break block, + Ok(Err(e)) if attempt < CBF_BLOCK_FETCH_RETRIES => { + log_debug!( + logger, + "CBF block fetch for {} failed on attempt {}: {:?}; retrying", + block_hash, + attempt, + e + ); + }, + Ok(Err(e)) => { + log_error!( + logger, + "CBF block fetch for {} failed after {} attempts: {:?}", + block_hash, + CBF_BLOCK_FETCH_RETRIES, + e + ); + let _ = + ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); + return; + }, + Err(_) if attempt < CBF_BLOCK_FETCH_RETRIES => { + log_debug!( + logger, + "CBF block receiver for {} dropped on attempt {}; retrying", + block_hash, + attempt + ); + }, + Err(_) => { + log_error!( + logger, + "CBF block receiver for {} dropped after {} attempts", + block_hash, + CBF_BLOCK_FETCH_RETRIES + ); + let _ = + ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); + return; + }, + } + }; + ChainOp::ConnectFull { block } } else { let height = indexed_filter.height(); //TODO we need to recheck that a particular height has not been @@ -492,10 +651,9 @@ impl CbfChainSource { } }, Ok(None) => { - //TODO what do we do? - todo!(); log_error!(logger, "No header at height {}", height,); - continue; + let _ = ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); + break; }, Err(e) => { log_error!( @@ -504,8 +662,8 @@ impl CbfChainSource { height, e, ); + let _ = ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); break; - // continue; }, } }; diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 13ac0d4ad1..f4673da2eb 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -516,10 +516,7 @@ impl ChainSource { ) .await }, - ChainSourceKind::Cbf { .. } => { - return Ok(()); - todo!(); - }, + ChainSourceKind::Cbf(cbf_chain_source) => cbf_chain_source.wait_until_synced().await, } } diff --git a/src/lib.rs b/src/lib.rs index 29315c1750..06c782d82b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1997,6 +1997,9 @@ impl Node { /// However, if background syncing is disabled (i.e., `background_sync_config` is set to `None`), /// this method must be called manually to keep wallets in sync with the chain state. /// + /// When using the CBF chain source, syncing always runs in the background. In that mode this + /// method waits until the background sync has applied chain updates through the current tip. + /// /// [`EsploraSyncConfig::background_sync_config`]: crate::config::EsploraSyncConfig::background_sync_config pub fn sync_wallets(&self) -> Result<(), Error> { if !*self.is_running.read().expect("lock") { From 539d7bd79806db8f84f497942a2fbc41a13a26e9 Mon Sep 17 00:00:00 2001 From: Yeji Han Date: Fri, 17 Jul 2026 06:47:22 +0900 Subject: [PATCH 110/138] Cbf fix block fetch (#35) * bump kyoto version * Add `synced_to_tip` to CbfSyncState Previously we did not track the `FiltersSynced` kyoto event, so we could not tell when we had applied all blocks up to the tip. For example, when we stop and restart the node, kyoto's tip is 0 at the instant of start (it does not persist its chain), so our applied height trivially matches kyoto's tip and we would falsely conclude we had reached it. That is only actually true once we have received `FiltersSynced`. * Add lookahead addresses to `list_revealed_scripts`. Now the function is called `list_watched_scripts`. * Add timeout to block fetch attempts. Previously stalled fetch would hang indefinitely. --------- Co-authored-by: Alexander Shevtsov --- Cargo.toml | 2 +- src/chain/cbf.rs | 181 +++++++++++++++++++++++++++------------------- src/wallet/mod.rs | 14 ++-- 3 files changed, 114 insertions(+), 83 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8b441fa58e..520dcec4da 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"]} bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"]} bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]} -bip157 = { version = "0.6.0", default-features = false } +bip157 = { version = "0.6.1", default-features = false } bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 20b52fd847..7649370d72 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -66,10 +66,38 @@ enum CbfRuntimeStatus { #[derive(Clone, Copy)] enum CbfSyncState { - Active { applied_tip: Option }, + Active { + /// Highest tip whose preceding chain updates have been applied to all listeners. + applied_tip: Option, + /// Whether kyoto has reported catching up to the network tip (via `FiltersSynced`) and + /// the resulting blocks have been applied. `wait_until_synced` blocks until this is set. + /// + /// This must not be derived from a locally-sampled chain tip: kyoto does not persist, so a + /// freshly (re)started node's local header chain sits at genesis until it syncs from peers. + /// Comparing against that would make `wait_until_synced` return before any sync happens. + synced_to_tip: bool, + }, Failed(Error), } +/// Marks that we are applying a block past the last `FiltersSynced` tip, so a `sync_wallets` call +/// issued after new blocks are mined waits for the next `FiltersSynced` rather than returning on a +/// stale `synced_to_tip`. Only flips (and notifies waiters) when currently set. +/// +/// Called both when a new block's filter is received (before it is fetched and applied) and after +/// it is applied, so `synced_to_tip` reflects "behind by an unapplied block" as soon as we learn +/// that block exists, not only once we've finished catching up to it. +fn mark_syncing(sync_state_tx: &watch::Sender) { + // Copy the current state out and drop the `watch` read guard before calling `send_replace`: + // `borrow()` holds a read lock for the lifetime of its temporary, and `send_replace` takes + // the write lock, so holding the borrow across it deadlocks. `CbfSyncState` is `Copy`, so the + // deref copies and the guard is released at the end of this statement. + let current = *sync_state_tx.borrow(); + if let CbfSyncState::Active { applied_tip, synced_to_tip: true } = current { + sync_state_tx.send_replace(CbfSyncState::Active { applied_tip, synced_to_tip: false }); + } +} + /// Struct for holding cbf chain source pub struct CbfChainSource { /// Trusted peer addresses for kyoto's `Builder::add_peers`. @@ -91,24 +119,13 @@ pub struct CbfChainSource { logger: Arc, } +#[derive(Debug)] enum ChainOp { - ConnectFull { - block: IndexedBlock, - }, - ConnectFiltered { - header: Header, - height: u32, - }, - Disconnect { - fork_point: BlockLocator, - }, - /// Marks reaching the chain tip. - Synced { - tip_height: u32, - }, - Failed { - error: Error, - }, + ConnectFull { block: IndexedBlock }, + ConnectFiltered { header: Header, height: u32 }, + Disconnect { fork_point: BlockLocator }, + Synced { tip_height: u32 }, + Failed { error: Error }, } struct BlockApplicator { @@ -140,6 +157,7 @@ impl BlockApplicator { } self.chain_listener.block_connected(&ib.block, ib.height); self.next_height += 1; + mark_syncing(&self.sync_state_tx); if let Some(cache) = &self.block_fee_cache { let fee_rate = coinbase_fee_rate(&ib.block, ib.height); cache @@ -160,12 +178,14 @@ impl BlockApplicator { } self.chain_listener.filtered_block_connected(&header, &[], height); self.next_height += 1; + mark_syncing(&self.sync_state_tx); }, ChainOp::Disconnect { fork_point } => { self.chain_listener.blocks_disconnected(fork_point); self.next_height = fork_point.height + 1; self.sync_state_tx.send_replace(CbfSyncState::Active { applied_tip: Some(fork_point.height), + synced_to_tip: false, }); }, ChainOp::Synced { tip_height } => { @@ -180,8 +200,10 @@ impl BlockApplicator { self.next_height ); } + log_info!(self.logger, "we set new tip and published at {}", tip_height); }, ChainOp::Failed { error } => { + log_info!(self.logger, "we received error chain op {}", error); self.sync_state_tx.send_replace(CbfSyncState::Failed(error)); }, } @@ -192,14 +214,23 @@ impl BlockApplicator { let already_published = { let sync_state = *self.sync_state_tx.borrow(); match sync_state { - CbfSyncState::Active { applied_tip } => applied_tip, + CbfSyncState::Active { applied_tip, .. } => applied_tip, CbfSyncState::Failed(_) => None, } }; if already_published.map_or(false, |published_height| published_height >= tip_height) { + // Even if the applied tip is unchanged, we have now confirmed we are caught up to the + // network tip, so ensure the synced flag is set for any `wait_until_synced` waiter. + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: already_published, + synced_to_tip: true, + }); return; } - self.sync_state_tx.send_replace(CbfSyncState::Active { applied_tip: Some(tip_height) }); + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(tip_height), + synced_to_tip: true, + }); let unix_time_secs_opt = SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs()); if let Err(e) = update_and_persist_node_metrics( @@ -225,9 +256,12 @@ const FEE_WINDOW_BLOCKS: u32 = BLOCK_FEE_CACHE_CAPACITY as u32; /// Electrum fee sources. Coinbase-derived rates are frequently zero on regtest/signet. const CBF_MIN_FEERATE_SAT_PER_KWU: u64 = 250; -/// Per-block timeout when downloading a block to derive its coinbase fee rate. Kept short so a -/// slow peer only delays a single sample rather than the whole fee update. -const CBF_FEE_BLOCK_FETCH_TIMEOUT_SECS: u64 = 10; +/// Per-attempt timeout when downloading a block from a peer — used both for matched blocks we apply +/// to the listeners and for the coinbase-fee-rate samples. Kyoto queues the request and awaits a +/// peer response with no timeout of its own, so a slow or unresponsive peer would otherwise park the +/// fetch forever. Kept short so a single request is bounded and can be retried (or, for fees, only +/// delays one sample) rather than stalling. +const CBF_BLOCK_FETCH_TIMEOUT_SECS: u64 = 10; /// Recent per-block coinbase-derived fee rates, keyed by height so we can window on the tip, evict /// stale entries, and detect reorged-out blocks (a height whose cached hash no longer matches the @@ -276,7 +310,8 @@ impl CbfChainSource { }; let registered_scripts = Arc::new(Mutex::new(HashSet::new())); let cbf_runtime_status = Arc::new(Mutex::new(CbfRuntimeStatus::Stopped)); - let (sync_state_tx, _) = watch::channel(CbfSyncState::Active { applied_tip: None }); + let (sync_state_tx, _) = + watch::channel(CbfSyncState::Active { applied_tip: None, synced_to_tip: false }); Ok(Self { trusted_peers, fee_source, @@ -343,8 +378,10 @@ impl CbfChainSource { _ => None, }; let best_block_height = chain_listener.get_best_block().height; - self.sync_state_tx - .send_replace(CbfSyncState::Active { applied_tip: Some(best_block_height) }); + self.sync_state_tx.send_replace(CbfSyncState::Active { + applied_tip: Some(best_block_height), + synced_to_tip: false, + }); let block_applicator = BlockApplicator { next_height: best_block_height + 1, sync_state_tx: self.sync_state_tx.clone(), @@ -393,6 +430,7 @@ impl CbfChainSource { Arc::clone(&restart_cbf_runtime_status), ops_tx.clone(), Arc::clone(&restart_listener.onchain_wallet), + restart_sync_state_tx.clone(), )); match current_node.run().await { @@ -460,6 +498,7 @@ impl CbfChainSource { *status = CbfRuntimeStatus::Started { requester: new_requester }; restart_sync_state_tx.send_replace(CbfSyncState::Active { applied_tip: Some(restart_listener.get_best_block().height), + synced_to_tip: false, }); } @@ -495,21 +534,20 @@ impl CbfChainSource { } pub(crate) async fn wait_until_synced(&self) -> Result<(), Error> { - let requester = match &*self.cbf_runtime_status.lock().expect("lock") { - CbfRuntimeStatus::Started { requester } => requester.clone(), - CbfRuntimeStatus::Stopped => return Err(Error::NotRunning), - }; - let target_tip = requester.chain_tip().await.map_err(|e| { - log_error!(self.logger, "Failed to fetch CBF chain tip before syncing: {:?}", e); - Error::TxSyncFailed - })?; - let target_height = target_tip.height; + if matches!(&*self.cbf_runtime_status.lock().expect("lock"), CbfRuntimeStatus::Stopped) { + return Err(Error::NotRunning); + } let mut sync_state_rx = self.sync_state_tx.subscribe(); + // Wait for kyoto to report catching up to the network tip (a `FiltersSynced`-driven + // `synced_to_tip`) and for the resulting blocks to be applied. We must not target a + // locally-sampled `chain_tip()`: kyoto does not persist, so a freshly (re)started node's + // local header chain sits at genesis until it syncs from peers, which would let this return + // before any sync happens. loop { match *sync_state_rx.borrow() { - CbfSyncState::Active { applied_tip } => { - if applied_tip.map_or(false, |applied_height| applied_height >= target_height) { + CbfSyncState::Active { synced_to_tip, .. } => { + if synced_to_tip { return Ok(()); } }, @@ -542,11 +580,17 @@ impl CbfChainSource { logger: Arc, mut event_rx: mpsc::UnboundedReceiver, registered_scripts: Arc>>, cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, - onchain_wallet: Arc, + onchain_wallet: Arc, sync_state_tx: watch::Sender, ) { while let Some(event) = event_rx.recv().await { match event { Event::IndexedFilter(indexed_filter) => { + // A new block's filter arrived, so we're behind by at least this block until it + // is fetched (if matched) and applied. Flip this before the fetch, not after, + // so a `sync_wallets` call issued in between doesn't return on a stale + // `synced_to_tip` that predates this block. + mark_syncing(&sync_state_tx); + let requester = match &*cbf_runtime_status.lock().expect("lock") { CbfRuntimeStatus::Started { requester } => requester.clone(), CbfRuntimeStatus::Stopped => { @@ -559,7 +603,7 @@ impl CbfChainSource { //each time we receive an IndexedFilter event, we ask bdk to give us all //revealed scripts. We create all_scripts starting from onchain wallet's //scripts and extend them with LDK's ones - let mut all_scripts = onchain_wallet.list_revealed_scripts(); + let mut all_scripts = onchain_wallet.list_watched_scripts(); all_scripts.extend(registered_scripts.lock().expect("lock").iter().cloned()); let block_hash = indexed_filter.block_hash(); @@ -583,42 +627,37 @@ impl CbfChainSource { }, }; - match handle.await { - Ok(Ok(block)) => break block, - Ok(Err(e)) if attempt < CBF_BLOCK_FETCH_RETRIES => { + // Bound the download so an unresponsive peer can't park the fetch forever, + // then flatten the three error layers (timeout / receiver dropped / fetch + // error) into a single reason so the retry-or-fail decision is written once. + let fetched = tokio::time::timeout( + Duration::from_secs(CBF_BLOCK_FETCH_TIMEOUT_SECS), + handle, + ) + .await + .map_err(|_| { + format!("timed out after {}s", CBF_BLOCK_FETCH_TIMEOUT_SECS) + }) + .and_then(|recv| recv.map_err(|_| "receiver was dropped".to_string())) + .and_then(|fetch| fetch.map_err(|e| format!("failed: {:?}", e))); + + match fetched { + Ok(block) => break block, + Err(reason) if attempt < CBF_BLOCK_FETCH_RETRIES => { log_debug!( logger, - "CBF block fetch for {} failed on attempt {}: {:?}; retrying", - block_hash, - attempt, - e - ); - }, - Ok(Err(e)) => { - log_error!( - logger, - "CBF block fetch for {} failed after {} attempts: {:?}", - block_hash, - CBF_BLOCK_FETCH_RETRIES, - e - ); - let _ = - ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); - return; - }, - Err(_) if attempt < CBF_BLOCK_FETCH_RETRIES => { - log_debug!( - logger, - "CBF block receiver for {} dropped on attempt {}; retrying", + "CBF block fetch for {} {} on attempt {}; retrying", block_hash, + reason, attempt ); }, - Err(_) => { + Err(reason) => { log_error!( logger, - "CBF block receiver for {} dropped after {} attempts", + "CBF block fetch for {} {} after {} attempts; giving up", block_hash, + reason, CBF_BLOCK_FETCH_RETRIES ); let _ = @@ -632,9 +671,7 @@ impl CbfChainSource { let height = indexed_filter.height(); //TODO we need to recheck that a particular height has not been //reorganized, and we retrieve indeed the same block header that we - //received `IndexedFilter` event of. right now this would block - //the further sync, as we cannot apply blocks in order. - //Future solution would use something like `get_header_by_hash`. + //received `IndexedFilter` event of. match requester.get_header(height).await { Ok(Some(indexed_header)) => { if indexed_header.block_hash() != block_hash { @@ -713,10 +750,6 @@ impl CbfChainSource { self.registered_scripts.lock().expect("lock").insert(output.script_pubkey); } - // pub(crate) fn register_script(&self, script: ScriptBuf) { - // self.registered_scripts.lock().expect("lock").insert(script); - // } - pub(crate) async fn continuously_update_fee_rate_estimates( &self, mut stop_sync_receiver: watch::Receiver<()>, ) { @@ -935,7 +968,7 @@ impl CbfChainSource { } match tokio::time::timeout( - Duration::from_secs(CBF_FEE_BLOCK_FETCH_TIMEOUT_SECS), + Duration::from_secs(CBF_BLOCK_FETCH_TIMEOUT_SECS), requester.average_fee_rate(canonical_hash), ) .await diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index e25c259d44..690ab7caad 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -216,14 +216,12 @@ impl Wallet { Ok(()) } - pub(crate) fn list_revealed_scripts(&self) -> Vec { - self.inner - .lock() - .expect("lock") - .spk_index() - .revealed_spks(..) - .map(|((_keychain, _index), spk)| spk) - .collect() + /// Returns every script pubkey the wallet is watching for on-chain activity: all revealed + /// SPKs plus the lookahead window BDK derives beyond the last revealed index on each keychain. + /// A block may pay an address we have not explicitly revealed yet (e.g. on recovery, where a fresh + /// wallet has revealed nothing) but which is still within the gap limit. + pub(crate) fn list_watched_scripts(&self) -> Vec { + self.inner.lock().expect("lock").spk_index().inner().all_spks().values().cloned().collect() } async fn update_payment_store(&self, mut events: Vec) -> Result<(), Error> { From e9cfbced52b55cc26128252241c0230a5bf85ea1 Mon Sep 17 00:00:00 2001 From: Alexander Shevtsov Date: Tue, 21 Jul 2026 15:34:58 +0200 Subject: [PATCH 111/138] Use block header from IndexedFilter event After the new kyoto release (v0.6.3) `Indexedfilter` event has a `header` field which is used directly (previously we fetched header as an additional action). Also renamed import of kyoto `Event` into `KyotoEvent` for readability. --- Cargo.toml | 2 +- src/chain/cbf.rs | 54 +++++++++++------------------------------------- 2 files changed, 13 insertions(+), 43 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 520dcec4da..38b94ae8f4 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,7 +58,7 @@ bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"]} bdk_electrum = { version = "0.24.0", default-features = false, features = ["use-rustls-ring"]} bdk_wallet = { version = "3.1.0", default-features = false, features = ["std", "keys-bip39"]} -bip157 = { version = "0.6.1", default-features = false } +bip157 = { version = "0.6.3", default-features = false } bitreq = { version = "0.3", default-features = false, features = ["async-https", "json-using-serde"] } rustls = { version = "0.23", default-features = false } diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 7649370d72..7baa4a0873 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -5,8 +5,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bip157::chain::ChainState; use bip157::{ - chain::BlockHeaderChanges, Builder as KyotoBuilder, Client, Event, HashCheckpoint, Header, - IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, Warning, + chain::BlockHeaderChanges, Builder as KyotoBuilder, Client, Event as KyotoEvent, + HashCheckpoint, Header, IndexedBlock, Info, Node as KyotoNode, Package, Requester, TrustedPeer, + Warning, }; use bitcoin::{BlockHash, FeeRate, Network, Script, ScriptBuf, Transaction, Txid}; use electrum_client::{Client as ElectrumClient, ConfigBuilder as ElectrumConfigBuilder}; @@ -577,14 +578,14 @@ impl CbfChainSource { } async fn process_kyoto_events( - logger: Arc, mut event_rx: mpsc::UnboundedReceiver, + logger: Arc, mut event_rx: mpsc::UnboundedReceiver, registered_scripts: Arc>>, cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, onchain_wallet: Arc, sync_state_tx: watch::Sender, ) { while let Some(event) = event_rx.recv().await { match event { - Event::IndexedFilter(indexed_filter) => { + KyotoEvent::IndexedFilter(indexed_filter) => { // A new block's filter arrived, so we're behind by at least this block until it // is fetched (if matched) and applied. Flip this before the fetch, not after, // so a `sync_wallets` call issued in between doesn't return on a stale @@ -668,61 +669,30 @@ impl CbfChainSource { }; ChainOp::ConnectFull { block } } else { - let height = indexed_filter.height(); - //TODO we need to recheck that a particular height has not been - //reorganized, and we retrieve indeed the same block header that we - //received `IndexedFilter` event of. - match requester.get_header(height).await { - Ok(Some(indexed_header)) => { - if indexed_header.block_hash() != block_hash { - log_debug!( - logger, - "Filter for {} reorged; skipping", - block_hash - ); - continue; - } - ChainOp::ConnectFiltered { - header: indexed_header.header, - height: indexed_header.height, - } - }, - Ok(None) => { - log_error!(logger, "No header at height {}", height,); - let _ = ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); - break; - }, - Err(e) => { - log_error!( - logger, - "Failed to fetch header at height {}: {:?}", - height, - e, - ); - let _ = ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); - break; - }, + ChainOp::ConnectFiltered { + header: indexed_filter.header(), + height: indexed_filter.height(), } }; if let Err(e) = ops_tx.send(chop) { log_debug!(logger, "ops_rx gone: {}", e); } }, - Event::FiltersSynced(sync_update) => { + KyotoEvent::FiltersSynced(sync_update) => { //Because application of blocks is async, the fact that kyoto synced up to the //tip does NOT mean that we caught everything up, that's why we send a ChainOp, //only processing of which means we processed all blocks up to the tip. log_info!(logger, "Kyoto synced up to the tip {}", sync_update.tip().height); let _ = ops_tx.send(ChainOp::Synced { tip_height: sync_update.tip().height }); }, - Event::ChainUpdate(BlockHeaderChanges::Connected(indexed_header)) => { + KyotoEvent::ChainUpdate(BlockHeaderChanges::Connected(indexed_header)) => { log_debug!( logger, "Kyoto connected header at height {}", indexed_header.height ); }, - Event::ChainUpdate(BlockHeaderChanges::Reorganized { + KyotoEvent::ChainUpdate(BlockHeaderChanges::Reorganized { reorganized, accepted: _, }) => { @@ -735,7 +705,7 @@ impl CbfChainSource { let _ = ops_tx.send(ChainOp::Disconnect { fork_point }); } }, - Event::ChainUpdate(BlockHeaderChanges::ForkAdded(fork)) => { + KyotoEvent::ChainUpdate(BlockHeaderChanges::ForkAdded(fork)) => { log_debug!(logger, "Kyoto added fork header at height {}", fork.height); }, } From d6f63a3600c4aacd5dc774b15ba1fae4cb39f1ee Mon Sep 17 00:00:00 2001 From: Vu Lam Date: Thu, 3 Jul 2025 21:48:06 +0700 Subject: [PATCH 112/138] added the ability to customize the gossip data --- examples/custom_gossip_example.rs | 154 +++++++++++++++ src/builder.rs | 48 ++++- src/custom_gossip.rs | 319 ++++++++++++++++++++++++++++++ src/lib.rs | 27 +++ src/logger.rs | 5 +- src/message_handler.rs | 237 ++++++++++++++++++++-- 6 files changed, 774 insertions(+), 16 deletions(-) create mode 100644 examples/custom_gossip_example.rs create mode 100644 src/custom_gossip.rs diff --git a/examples/custom_gossip_example.rs b/examples/custom_gossip_example.rs new file mode 100644 index 0000000000..42fc96e271 --- /dev/null +++ b/examples/custom_gossip_example.rs @@ -0,0 +1,154 @@ +// Example demonstrating how to use custom gossip metadata in LDK Node + +use ldk_node::{Builder, Event, Node}; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::bitcoin::Network; +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +fn main() -> Result<(), Box> { + // Create and configure a node with custom gossip enabled + let mut builder = Builder::new(); + builder.set_network(Network::Testnet); + builder.set_chain_source_esplora("https://blockstream.info/testnet/api".to_string(), None); + builder.set_gossip_source_rgs("https://rapidsync.lightningdevkit.org/testnet/snapshot".to_string()); + + // Enable custom gossip functionality + builder.enable_custom_gossip(); + + let node = builder.build()?; + + // Start the node + node.start()?; + + // Get the custom gossip handler + if let Some(custom_gossip) = node.custom_gossip() { + println!("Custom gossip handler is available!"); + + // Set our own metadata to advertise + let our_metadata = serde_json::json!({ + "version": "1.0", + "features": ["feature_a", "feature_b"], + "timestamp": chrono::Utc::now().timestamp(), + "description": "LDK Node with custom features" + }).to_string().into_bytes(); + + custom_gossip.set_our_metadata(our_metadata); + + // Example: Send custom metadata to a specific peer + // (This would typically be done after connecting to a peer) + let peer_metadata = serde_json::json!({ + "message": "Hello from custom gossip!", + "data": { + "custom_field": "custom_value" + } + }).to_string().into_bytes(); + + // In a real scenario, you'd have connected peers + // custom_gossip.send_metadata_to_peer(peer_node_id, peer_metadata); + + // Example: Get stored metadata for all nodes + let all_metadata = custom_gossip.get_all_metadata().clone(); + println!("Currently have metadata for {} nodes", all_metadata.len()); + + // Print metadata information + for (node_id, metadata) in all_metadata.clone() { + println!("Node {}: {} bytes of metadata", node_id, metadata.metadata.len()); + + // Try to parse as JSON + if let Ok(json_str) = String::from_utf8(metadata.metadata.clone()) { + if let Ok(json_value) = serde_json::from_str::(&json_str) { + println!(" Parsed JSON: {}", json_value); + } + } + } + + // Demonstrate event handling with custom gossip + println!("Monitoring for custom gossip events..."); + + // In a real application, you would handle events in a loop + // This is just a demonstration + for _ in 0..5 { + // Wait for events (timeout after 1 second) + std::thread::sleep(Duration::from_secs(1)); + + // In a real application, you would process events like this: + // match node.wait_next_event() { + // Event::... => { + // // Handle other events + // } + // // Custom gossip events would be handled through the custom_gossip handler + // // as they are processed automatically when messages are received + // } + } + + // Example: Check if we received any new metadata + let updated_metadata = custom_gossip.get_all_metadata(); + if updated_metadata.len() > all_metadata.clone().len() { + println!("Received new metadata from {} nodes", + updated_metadata.len() - all_metadata.len()); + } + + } else { + println!("Custom gossip not enabled. Use builder.enable_custom_gossip() to enable it."); + } + + // Stop the node + node.stop()?; + + peer_to_peer_example()?; + + Ok(()) +} + +/// Example of how to integrate custom gossip in a peer-to-peer scenario +#[allow(dead_code)] +fn peer_to_peer_example() -> Result<(), Box> { + // Create two nodes for demonstration + let mut builder1 = Builder::new(); + builder1.set_network(Network::Regtest); + builder1.enable_custom_gossip(); + let node1 = builder1.build()?; + + let mut builder2 = Builder::new(); + builder2.set_network(Network::Regtest); + builder2.enable_custom_gossip(); + let node2 = builder2.build()?; + + // Start both nodes + node1.start()?; + node2.start()?; + + // Get custom gossip handlers + let gossip1 = node1.custom_gossip().unwrap(); + let gossip2 = node2.custom_gossip().unwrap(); + + // Set metadata for each node + let metadata1 = b"Node 1 custom data".to_vec(); + let metadata2 = b"Node 2 custom data".to_vec(); + + gossip1.set_our_metadata(metadata1); + gossip2.set_our_metadata(metadata2); + + // In a real scenario, you would: + // 1. Connect the nodes to each other + // 2. Custom metadata would be automatically exchanged when peers connect + // 3. Monitor the get_all_metadata() results to see received data + + println!("Peer-to-peer custom gossip example completed"); + + // Stop nodes + node1.stop()?; + node2.stop()?; + + Ok(()) +} + +/// Example showing custom feature flags (placeholder for future implementation) +#[allow(dead_code)] +fn custom_features_example() { + println!("Custom feature flags would be implemented in the provided_node_features() method"); + println!("This allows advertising custom capabilities to peers during connection"); +} \ No newline at end of file diff --git a/src/builder.rs b/src/builder.rs index 616e6ff5c9..87e500d0bb 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -53,6 +53,7 @@ use crate::config::{ DEFAULT_MAX_PROBE_AMOUNT_MSAT, DEFAULT_MIN_PROBE_AMOUNT_MSAT, }; use crate::connection::ConnectionManager; +use crate::custom_gossip::CustomGossipMessageHandler; use crate::entropy::NodeEntropy; use crate::event::EventQueue; use crate::fee_estimator::OnchainFeeEstimator; @@ -316,6 +317,7 @@ pub struct NodeBuilder { runtime_handle: Option, pathfinding_scores_sync_config: Option, probing_config: Option, + custom_gossip_enabled: bool, } impl NodeBuilder { @@ -334,6 +336,7 @@ impl NodeBuilder { let runtime_handle = None; let pathfinding_scores_sync_config = None; let probing_config = None; + let custom_gossip_enabled = false; Self { config, chain_data_source_config, @@ -344,6 +347,7 @@ impl NodeBuilder { async_payments_role: None, pathfinding_scores_sync_config, probing_config, + custom_gossip_enabled, } } @@ -539,6 +543,18 @@ impl NodeBuilder { self } + /// Enables custom gossip message support for the [`Node`] instance. + /// + /// When enabled, the node will be able to send and receive custom gossip messages + /// containing metadata extensions to the standard Lightning gossip protocol. + /// + /// Custom gossip messages use message type 32769 and can contain arbitrary metadata + /// up to 4096 bytes in length. + pub fn enable_custom_gossip(&mut self) -> &mut Self { + self.custom_gossip_enabled = true; + self + } + /// Sets the used storage directory path. pub fn set_storage_dir_path(&mut self, storage_dir_path: String) -> &mut Self { self.config.storage_dir_path = storage_dir_path; @@ -919,6 +935,7 @@ impl NodeBuilder { self.pathfinding_scores_sync_config.as_ref(), self.probing_config.as_ref(), self.async_payments_role, + self.custom_gossip_enabled, seed_bytes, runtime, logger, @@ -1131,6 +1148,17 @@ impl ArcedNodeBuilder { self.inner.write().expect("lock").enable_liquidity_provider(lsps2_service_config); } + /// Enables custom gossip message support for the [`Node`] instance. + /// + /// When enabled, the node will be able to send and receive custom gossip messages + /// containing metadata extensions to the standard Lightning gossip protocol. + /// + /// Custom gossip messages use message type 32769 and can contain arbitrary metadata + /// up to 4096 bytes in length. + pub fn enable_custom_gossip(&self) { + self.inner.write().unwrap().enable_custom_gossip(); + } + /// Sets the used storage directory path. pub fn set_storage_dir_path(&self, storage_dir_path: String) { self.inner.write().expect("lock").set_storage_dir_path(storage_dir_path); @@ -1421,7 +1449,8 @@ fn build_with_store_internal( liquidity_source_config: Option<&LiquiditySourceConfig>, pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>, probing_config: Option<&ProbingConfig>, async_payments_role: Option, - seed_bytes: [u8; 64], runtime: Arc, logger: Arc, kv_store: Arc, + custom_gossip_enabled: bool, seed_bytes: [u8; 64], runtime: Arc, logger: Arc, + kv_store: Arc, ) -> Result { optionally_install_rustls_cryptoprovider(); @@ -2157,12 +2186,24 @@ fn build_with_store_internal( let liquidity_source = runtime .block_on(async move { liquidity_source_builder.build().await.map(Arc::new) })?; - let custom_message_handler = - Arc::new(NodeCustomMessageHandler::new(Arc::clone(&liquidity_source))); + + // The liquidity handler is always wired up; custom gossip rides alongside it when enabled. + let custom_message_handler = if custom_gossip_enabled { + let gossip_handler = Arc::new(CustomGossipMessageHandler::new(Arc::clone(&logger))); + Arc::new(NodeCustomMessageHandler::new_combined( + Arc::clone(&liquidity_source), + gossip_handler, + )) + } else { + Arc::new(NodeCustomMessageHandler::new_liquidity(Arc::clone(&liquidity_source))) + }; (liquidity_source, custom_message_handler) }; + // Extract custom gossip handler for later use + let custom_gossip_handler = custom_message_handler.custom_gossip_handler(); + let msg_handler = match gossip_source.as_gossip_sync() { GossipSync::P2P(p2p_gossip_sync) => MessageHandler { chan_handler: Arc::clone(&channel_manager), @@ -2356,6 +2397,7 @@ fn build_with_store_internal( gossip_source, pathfinding_scores_sync_url, liquidity_source, + custom_gossip_handler, kv_store, logger, _router: router, diff --git a/src/custom_gossip.rs b/src/custom_gossip.rs new file mode 100644 index 0000000000..23de196825 --- /dev/null +++ b/src/custom_gossip.rs @@ -0,0 +1,319 @@ +// 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. + +//! Custom gossip message handling for extending P2P gossip sync with custom metadata. + +use crate::logger::{log_debug, log_trace}; +use lightning::util::logger::Logger as LightningLogger; + +use lightning::io::{self, Read}; +use lightning::ln::msgs::LightningError; +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::ln::wire::CustomMessageReader; +use lightning::ln::wire::Type; +use lightning::util::ser::{LengthLimitedRead, Readable, Writeable, Writer}; +use lightning_types::features::{InitFeatures, NodeFeatures}; + +use bitcoin::secp256k1::PublicKey; + +use std::collections::HashMap; +use std::ops::Deref; +use std::sync::{Arc, Mutex}; + +/// Custom message type for gossip metadata extensions +pub const CUSTOM_GOSSIP_MESSAGE_TYPE: u16 = 32769; // Odd number in custom range + +/// Custom gossip message containing metadata extensions +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomGossipMessage { + /// The metadata payload + pub metadata: Vec, +} + +impl CustomGossipMessage { + /// Create a new custom gossip message with the given metadata + pub fn new(metadata: Vec) -> Self { + Self { metadata } + } + + /// Get the metadata payload + pub fn metadata(&self) -> &[u8] { + &self.metadata + } +} + +impl Type for CustomGossipMessage { + fn type_id(&self) -> u16 { + CUSTOM_GOSSIP_MESSAGE_TYPE + } +} + +impl Writeable for CustomGossipMessage { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + // Write length prefix (u16) followed by the metadata + (self.metadata.len() as u16).write(writer)?; + writer.write_all(&self.metadata) + } +} + +impl Readable for CustomGossipMessage { + fn read(reader: &mut R) -> Result { + let length = ::read(reader)? as usize; + + // Limit metadata size to prevent DoS attacks + if length > 4096 { + return Err(lightning::ln::msgs::DecodeError::InvalidValue); + } + + let mut metadata = vec![0u8; length]; + reader.read_exact(&mut metadata).map_err(|_| { + lightning::ln::msgs::DecodeError::ShortRead + })?; + + Ok(Self { metadata }) + } +} + +/// Metadata entry for a node +#[derive(Clone, Debug)] +pub struct NodeMetadata { + /// Node's public key + pub node_id: PublicKey, + /// Custom metadata payload + pub metadata: Vec, + /// Timestamp when metadata was received + pub timestamp: u32, +} + +/// Handler for custom gossip messages +pub struct CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + /// Logger instance + logger: L, + /// Store for node metadata + node_metadata: Arc>>, + /// Pending messages to send + pending_messages: Arc>>, + /// Our own metadata to advertise + our_metadata: Arc>>>, +} + +impl CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + /// Create a new custom gossip message handler + pub fn new(logger: L) -> Self { + Self { + logger, + node_metadata: Arc::new(Mutex::new(HashMap::new())), + pending_messages: Arc::new(Mutex::new(Vec::new())), + our_metadata: Arc::new(Mutex::new(None)), + } + } + + /// Set our own metadata to advertise to peers + pub fn set_our_metadata(&self, metadata: Vec) { + let mut our_metadata = self.our_metadata.lock().unwrap(); + *our_metadata = Some(metadata); + } + + /// Get metadata for a specific node + pub fn get_node_metadata(&self, node_id: &PublicKey) -> Option { + let metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.get(node_id).cloned() + } + + /// Get all stored node metadata + pub fn get_all_metadata(&self) -> HashMap { + let metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.clone() + } + + /// Send custom metadata to a specific peer + pub fn send_metadata_to_peer(&self, peer_node_id: PublicKey, metadata: Vec) { + let message = CustomGossipMessage::new(metadata); + let mut pending = self.pending_messages.lock().unwrap(); + pending.push((peer_node_id, message)); + } + + /// Broadcast our metadata to all peers + pub fn broadcast_our_metadata(&self, peer_node_ids: Vec) { + let our_metadata = self.our_metadata.lock().unwrap(); + if let Some(ref metadata) = *our_metadata { + let message = CustomGossipMessage::new(metadata.clone()); + let mut pending = self.pending_messages.lock().unwrap(); + + for node_id in peer_node_ids { + pending.push((node_id, message.clone())); + } + } + } + + /// Handle received custom gossip message + fn handle_gossip_message(&self, msg: &CustomGossipMessage, sender_node_id: PublicKey) { + log_debug!( + self.logger, + "Received custom gossip metadata from {}: {} bytes", + sender_node_id, + msg.metadata.len() + ); + + let metadata_entry = NodeMetadata { + node_id: sender_node_id, + metadata: msg.metadata.clone(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32, + }; + + let mut metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.insert(sender_node_id, metadata_entry); + + log_trace!( + self.logger, + "Stored metadata for node {}, total nodes: {}", + sender_node_id, + metadata_store.len() + ); + } +} + +impl CustomMessageReader for CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + type CustomMessage = CustomGossipMessage; + + fn read( + &self, message_type: u16, buffer: &mut RD, + ) -> Result, lightning::ln::msgs::DecodeError> { + if message_type == CUSTOM_GOSSIP_MESSAGE_TYPE { + log_trace!(self.logger, "Reading custom gossip message type {}", message_type); + Ok(Some(CustomGossipMessage::read(buffer)?)) + } else { + Ok(None) + } + } +} + +impl CustomMessageHandler for CustomGossipMessageHandler +where + L::Target: LightningLogger, +{ + fn handle_custom_message( + &self, msg: Self::CustomMessage, sender_node_id: PublicKey, + ) -> Result<(), LightningError> { + self.handle_gossip_message(&msg, sender_node_id); + Ok(()) + } + + fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Self::CustomMessage)> { + let mut pending = self.pending_messages.lock().unwrap(); + std::mem::take(&mut *pending) + } + + fn provided_node_features(&self) -> NodeFeatures { + // Advertise that we support custom gossip messages + // You can extend this to include specific feature flags + NodeFeatures::empty() + } + + fn provided_init_features(&self, _their_node_id: PublicKey) -> InitFeatures { + // Advertise init features for custom gossip support + InitFeatures::empty() + } + + fn peer_connected( + &self, their_node_id: PublicKey, _msg: &lightning::ln::msgs::Init, _inbound: bool, + ) -> Result<(), ()> { + log_debug!(self.logger, "Peer {} connected, will broadcast our metadata", their_node_id); + + // Optionally broadcast our metadata when a peer connects + let our_metadata = self.our_metadata.lock().unwrap(); + if let Some(ref metadata) = *our_metadata { + let message = CustomGossipMessage::new(metadata.clone()); + let mut pending = self.pending_messages.lock().unwrap(); + pending.push((their_node_id, message)); + } + + Ok(()) + } + + fn peer_disconnected(&self, their_node_id: PublicKey) { + log_debug!(self.logger, "Peer {} disconnected", their_node_id); + // Optionally clean up metadata for disconnected peers + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::logger::test_logger; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use lightning::util::ser::{Readable, Writeable}; + use std::io::Cursor; + + #[test] + fn test_custom_gossip_message_serialization() { + let metadata = b"custom_metadata_payload".to_vec(); + let msg = CustomGossipMessage::new(metadata.clone()); + + assert_eq!(msg.metadata(), &metadata); + assert_eq!(msg.type_id(), CUSTOM_GOSSIP_MESSAGE_TYPE); + + // Test serialization + let mut buffer = Vec::new(); + msg.write(&mut buffer).unwrap(); + + // Test deserialization + let mut cursor = Cursor::new(buffer); + let deserialized = CustomGossipMessage::read(&mut cursor).unwrap(); + + assert_eq!(msg, deserialized); + } + + #[test] + fn test_custom_gossip_handler() { + let logger = test_logger(); + let handler = CustomGossipMessageHandler::new(logger); + + // Test setting our metadata + let our_metadata = b"our_node_metadata".to_vec(); + handler.set_our_metadata(our_metadata.clone()); + + // Test handling a message + let secp_ctx = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[1; 32]).unwrap(); + let sender_node_id = PublicKey::from_secret_key(&secp_ctx, &secret_key); + + let msg = CustomGossipMessage::new(b"peer_metadata".to_vec()); + handler.handle_custom_message(msg, sender_node_id).unwrap(); + + // Verify metadata was stored + let stored_metadata = handler.get_node_metadata(&sender_node_id).unwrap(); + assert_eq!(stored_metadata.metadata, b"peer_metadata"); + assert_eq!(stored_metadata.node_id, sender_node_id); + } + + #[test] + fn test_message_size_limit() { + let large_metadata = vec![0u8; 5000]; // Exceeds 4096 byte limit + let msg = CustomGossipMessage::new(large_metadata); + + let mut buffer = Vec::new(); + msg.write(&mut buffer).unwrap(); + + let mut cursor = Cursor::new(buffer); + let result = CustomGossipMessage::read(&mut cursor); + + assert!(result.is_err()); + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 06c782d82b..9ed6dc9cc3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -85,6 +85,7 @@ mod builder; mod chain; pub mod config; mod connection; +pub mod custom_gossip; mod data_store; pub mod entropy; mod error; @@ -134,6 +135,7 @@ use config::{ RGS_SYNC_INTERVAL, }; use connection::ConnectionManager; +use custom_gossip::CustomGossipMessageHandler; pub use error::Error as NodeError; use error::Error; pub use event::Event; @@ -258,6 +260,7 @@ pub struct Node { gossip_source: Arc, pathfinding_scores_sync_url: Option, liquidity_source: Arc>>, + custom_gossip_handler: Option>>>, kv_store: Arc, logger: Arc, _router: Arc, @@ -1176,6 +1179,30 @@ impl Node { )) } + /// Returns a custom gossip handler allowing to send and receive custom gossip messages. + /// + /// This returns `None` if custom gossip was not enabled during node construction. + /// To enable custom gossip, call [`Builder::enable_custom_gossip`] before building the node. + /// + /// Custom gossip messages can contain arbitrary metadata up to 4096 bytes in length + /// and use message type 32769 to extend the Lightning gossip protocol. + #[cfg(not(feature = "uniffi"))] + pub fn custom_gossip(&self) -> Option<&CustomGossipMessageHandler>> { + self.custom_gossip_handler.as_ref().map(|h| h.as_ref()) + } + + /// Returns a custom gossip handler allowing to send and receive custom gossip messages. + /// + /// This returns `None` if custom gossip was not enabled during node construction. + /// To enable custom gossip, call [`Builder::enable_custom_gossip`] before building the node. + /// + /// Custom gossip messages can contain arbitrary metadata up to 4096 bytes in length + /// and use message type 32769 to extend the Lightning gossip protocol. + #[cfg(feature = "uniffi")] + pub fn custom_gossip(&self) -> Option>>> { + self.custom_gossip_handler.clone() + } + /// Authenticates the user via [LNURL-auth] for the given LNURL string. /// /// [LNURL-auth]: https://github.com/lnurl/luds/blob/luds/04.md diff --git a/src/logger.rs b/src/logger.rs index c5a4584a18..2ca87e4590 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -251,7 +251,8 @@ impl LogWriter for Writer { } } -pub(crate) struct Logger { +/// A logger for LDK Node that can write to files, the log facade, or custom writers. +pub struct Logger { /// Specifies the logger's writer. writer: Writer, } @@ -275,10 +276,12 @@ impl Logger { Ok(Self { writer: Writer::FileWriter { file_path, max_log_level } }) } + /// Creates a new logger that forwards logs to the `log` facade. pub fn new_log_facade() -> Self { Self { writer: Writer::LogFacadeWriter } } + /// Creates a new logger with a custom writer. pub fn new_custom_writer(log_writer: Arc) -> Self { Self { writer: Writer::CustomWriter(log_writer) } } diff --git a/src/message_handler.rs b/src/message_handler.rs index 9c4010458b..56096cbac3 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -16,21 +16,83 @@ use lightning::util::ser::LengthLimitedRead; use lightning_liquidity::lsps0::ser::RawLSPSMessage; use lightning_types::features::{InitFeatures, NodeFeatures}; +use crate::custom_gossip::{CustomGossipMessage, CustomGossipMessageHandler}; use crate::liquidity::LiquiditySource; -pub(crate) struct NodeCustomMessageHandler +pub(crate) enum NodeCustomMessageHandler where L::Target: Logger, { - liquidity_source: Arc>, + Ignoring, + Liquidity { + liquidity_source: Arc>, + }, + CustomGossip { + gossip_handler: Arc>, + }, + Combined { + liquidity_source: Arc>, + gossip_handler: Arc>, + }, } impl NodeCustomMessageHandler where L::Target: Logger, { - pub(crate) fn new(liquidity_source: Arc>) -> Self { - Self { liquidity_source } + pub(crate) fn new_liquidity(liquidity_source: Arc>) -> Self { + Self::Liquidity { liquidity_source } + } + + pub(crate) fn new_ignoring() -> Self { + Self::Ignoring + } + + pub(crate) fn new_custom_gossip(gossip_handler: Arc>) -> Self { + Self::CustomGossip { gossip_handler } + } + + pub(crate) fn new_combined( + liquidity_source: Arc>, + gossip_handler: Arc>, + ) -> Self { + Self::Combined { liquidity_source, gossip_handler } + } + + /// Returns the custom gossip handler if available + pub(crate) fn custom_gossip_handler(&self) -> Option>> { + match self { + Self::CustomGossip { gossip_handler } => Some(Arc::clone(gossip_handler)), + Self::Combined { gossip_handler, .. } => Some(Arc::clone(gossip_handler)), + _ => None, + } + } +} + +/// Combined custom message type that can handle both LSPS and custom gossip messages +#[derive(Clone, Debug)] +pub(crate) enum NodeCustomMessage { + Lsps(RawLSPSMessage), + CustomGossip(CustomGossipMessage), +} + +impl lightning::ln::wire::Type for NodeCustomMessage { + fn type_id(&self) -> u16 { + match self { + Self::Lsps(msg) => msg.type_id(), + Self::CustomGossip(msg) => msg.type_id(), + } + } +} + +impl lightning::util::ser::Writeable for NodeCustomMessage { + fn write( + &self, writer: &mut W, + ) -> Result<(), lightning::io::Error> { + match self { + Self::Lsps(msg) => msg.write(writer), + Self::CustomGossip(msg) => msg.write(writer), + } } } @@ -38,12 +100,42 @@ impl CustomMessageReader for NodeCustomMessageHandler where L::Target: Logger, { - type CustomMessage = RawLSPSMessage; + type CustomMessage = NodeCustomMessage; fn read( &self, message_type: u16, buffer: &mut RD, ) -> Result, lightning::ln::msgs::DecodeError> { - self.liquidity_source.liquidity_manager().read(message_type, buffer) + match self { + Self::Ignoring => Ok(None), + Self::Liquidity { liquidity_source, .. } => { + if let Ok(Some(lsps_msg)) = + liquidity_source.liquidity_manager().read(message_type, buffer) + { + Ok(Some(NodeCustomMessage::Lsps(lsps_msg))) + } else { + Ok(None) + } + }, + Self::CustomGossip { gossip_handler, .. } => { + if let Ok(Some(gossip_msg)) = gossip_handler.read(message_type, buffer) { + Ok(Some(NodeCustomMessage::CustomGossip(gossip_msg))) + } else { + Ok(None) + } + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Try LSPS first, then custom gossip + if let Ok(Some(lsps_msg)) = + liquidity_source.liquidity_manager().read(message_type, buffer) + { + Ok(Some(NodeCustomMessage::Lsps(lsps_msg))) + } else if let Ok(Some(gossip_msg)) = gossip_handler.read(message_type, buffer) { + Ok(Some(NodeCustomMessage::CustomGossip(gossip_msg))) + } else { + Ok(None) + } + }, + } } } @@ -54,28 +146,149 @@ where fn handle_custom_message( &self, msg: Self::CustomMessage, sender_node_id: PublicKey, ) -> Result<(), lightning::ln::msgs::LightningError> { - self.liquidity_source.liquidity_manager().handle_custom_message(msg, sender_node_id) + match self { + Self::Ignoring => Ok(()), // Should be unreachable!() as the reader will return `None` + Self::Liquidity { liquidity_source, .. } => match msg { + NodeCustomMessage::Lsps(lsps_msg) => liquidity_source + .liquidity_manager() + .handle_custom_message(lsps_msg, sender_node_id), + NodeCustomMessage::CustomGossip(_) => { + // Ignoring custom gossip in liquidity-only mode + Ok(()) + }, + }, + Self::CustomGossip { gossip_handler, .. } => match msg { + NodeCustomMessage::CustomGossip(gossip_msg) => { + gossip_handler.handle_custom_message(gossip_msg, sender_node_id) + }, + NodeCustomMessage::Lsps(_) => { + // Ignoring LSPS in gossip-only mode + Ok(()) + }, + }, + Self::Combined { liquidity_source, gossip_handler } => match msg { + NodeCustomMessage::Lsps(lsps_msg) => liquidity_source + .liquidity_manager() + .handle_custom_message(lsps_msg, sender_node_id), + NodeCustomMessage::CustomGossip(gossip_msg) => { + gossip_handler.handle_custom_message(gossip_msg, sender_node_id) + }, + }, + } } fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Self::CustomMessage)> { - self.liquidity_source.liquidity_manager().get_and_clear_pending_msg() + match self { + Self::Ignoring => Vec::new(), + Self::Liquidity { liquidity_source, .. } => liquidity_source + .liquidity_manager() + .get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::Lsps(msg))) + .collect(), + Self::CustomGossip { gossip_handler, .. } => gossip_handler + .get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::CustomGossip(msg))) + .collect(), + Self::Combined { liquidity_source, gossip_handler } => { + let mut pending = Vec::new(); + + // Get LSPS messages + pending.extend( + liquidity_source + .liquidity_manager() + .get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::Lsps(msg))), + ); + + // Get custom gossip messages + pending.extend( + gossip_handler + .get_and_clear_pending_msg() + .into_iter() + .map(|(node_id, msg)| (node_id, NodeCustomMessage::CustomGossip(msg))), + ); + + pending + }, + } } fn provided_node_features(&self) -> NodeFeatures { - self.liquidity_source.liquidity_manager().provided_node_features() + match self { + Self::Ignoring => NodeFeatures::empty(), + Self::Liquidity { liquidity_source, .. } => { + liquidity_source.liquidity_manager().provided_node_features() + }, + Self::CustomGossip { gossip_handler, .. } => gossip_handler.provided_node_features(), + Self::Combined { liquidity_source, gossip_handler } => { + // Combine features from both handlers + let features = liquidity_source.liquidity_manager().provided_node_features(); + let _gossip_features = gossip_handler.provided_node_features(); + // Note: In a real implementation, you'd need to properly merge features + // For now, we'll use the liquidity features as base + features + }, + } } fn provided_init_features(&self, their_node_id: PublicKey) -> InitFeatures { - self.liquidity_source.liquidity_manager().provided_init_features(their_node_id) + match self { + Self::Ignoring => InitFeatures::empty(), + Self::Liquidity { liquidity_source, .. } => { + liquidity_source.liquidity_manager().provided_init_features(their_node_id) + }, + Self::CustomGossip { gossip_handler, .. } => { + gossip_handler.provided_init_features(their_node_id) + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Combine init features from both handlers + let features = + liquidity_source.liquidity_manager().provided_init_features(their_node_id); + let _gossip_features = gossip_handler.provided_init_features(their_node_id); + // Note: In a real implementation, you'd need to properly merge features + // For now, we'll use the liquidity features as base + features + }, + } } fn peer_connected( &self, their_node_id: PublicKey, msg: &lightning::ln::msgs::Init, inbound: bool, ) -> Result<(), ()> { - self.liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound) + match self { + Self::Ignoring => Ok(()), + Self::Liquidity { liquidity_source, .. } => { + liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound) + }, + Self::CustomGossip { gossip_handler, .. } => { + gossip_handler.peer_connected(their_node_id, msg, inbound) + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Notify both handlers + let _ = + liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound); + gossip_handler.peer_connected(their_node_id, msg, inbound) + }, + } } fn peer_disconnected(&self, their_node_id: PublicKey) { - self.liquidity_source.liquidity_manager().peer_disconnected(their_node_id) + match self { + Self::Ignoring => {}, + Self::Liquidity { liquidity_source, .. } => { + liquidity_source.liquidity_manager().peer_disconnected(their_node_id) + }, + Self::CustomGossip { gossip_handler, .. } => { + gossip_handler.peer_disconnected(their_node_id) + }, + Self::Combined { liquidity_source, gossip_handler } => { + // Notify both handlers + liquidity_source.liquidity_manager().peer_disconnected(their_node_id); + gossip_handler.peer_disconnected(their_node_id); + }, + } } } From 503281e7d6631d2c2b15c0c0498a3077172e8686 Mon Sep 17 00:00:00 2001 From: Vu Lam Date: Thu, 25 Sep 2025 04:10:48 +0700 Subject: [PATCH 113/138] added sync events action --- src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 9ed6dc9cc3..b4bc688fb4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1013,6 +1013,11 @@ impl Node { self.config.node_alias } + pub fn process_events(&self) -> Arc { + self.peer_manager.process_events(); + Arc::clone(&self.peer_manager) + } + /// Returns a payment handler allowing to create and pay [BOLT 11] invoices. /// /// [BOLT 11]: https://github.com/lightning/bolts/blob/master/11-payment-encoding.md From 774868406f7eecde838edb08e47901031fba8b79 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Wed, 29 Apr 2026 15:07:27 +0700 Subject: [PATCH 114/138] fix: improve to suitable with onboarding flow --- src/config.rs | 11 +++ src/lib.rs | 3 + src/payment/bolt11.rs | 155 +++++++++++++++++++++++++++++++++++++++++- src/tx_broadcaster.rs | 10 ++- 4 files changed, 176 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index d4ac480936..0e68604d96 100644 --- a/src/config.rs +++ b/src/config.rs @@ -428,6 +428,17 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { user_config.channel_handshake_config.negotiate_anchor_zero_fee_commitments = config.anchor_channels_config.enable_zero_fee_commitments; user_config.reject_inbound_splices = false; + // Allow full-capacity HTLCs. LDK's max-inbound-HTLC-in-flight percentages default well below + // 100%, which fails any forward larger than that fraction of the channel with + // `temporary_channel_failure`. Onboarding sweep needs to push ~70% of a single-channel + // capacity through its LSP in one HTLC, so we raise the cap to 100% for every channel this + // node negotiates (both as opener and as accepting peer). + user_config + .channel_handshake_config + .announced_channel_max_inbound_htlc_value_in_flight_percentage = 100; + user_config + .channel_handshake_config + .unannounced_channel_max_inbound_htlc_value_in_flight_percentage = 100; if may_announce_channel(config).is_err() { user_config.accept_forwards_to_priv_channels = false; diff --git a/src/lib.rs b/src/lib.rs index b4bc688fb4..58d8ebb666 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1013,6 +1013,7 @@ impl Node { self.config.node_alias } + /// Processes pending peer manager events and returns a handle to the peer manager. pub fn process_events(&self) -> Arc { self.peer_manager.process_events(); Arc::clone(&self.peer_manager) @@ -1027,6 +1028,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.connection_manager), + Arc::clone(&self.keys_manager), Arc::clone(&self.liquidity_source), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), @@ -1045,6 +1047,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.connection_manager), + Arc::clone(&self.keys_manager), Arc::clone(&self.liquidity_source), Arc::clone(&self.payment_store), Arc::clone(&self.peer_store), diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 4503dfa061..1f51bb748c 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -10,17 +10,23 @@ //! [BOLT 11]: https://github.com/lightning/bolts/blob/master/11-payment-encoding.md use std::sync::{Arc, RwLock}; +use std::time::Duration; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; +use bitcoin::secp256k1::Secp256k1; use lightning::impl_writeable_tlv_based; use lightning::ln::channelmanager::{ Bolt11InvoiceParameters, OptionalBolt11PaymentParams, PaymentId, + MIN_FINAL_CLTV_EXPIRY_DELTA, }; use lightning::ln::outbound_payment::{Bolt11PaymentError, Retry, RetryableSendFailure}; -use lightning::routing::router::{PaymentParameters, RouteParameters, RouteParametersConfig}; +use lightning::routing::router::{ + PaymentParameters, RouteHint, RouteParameters, RouteParametersConfig, +}; use lightning_invoice::{ Bolt11Invoice as LdkBolt11Invoice, Bolt11InvoiceDescription as LdkBolt11InvoiceDescription, + InvoiceBuilder, }; use lightning_types::payment::{PaymentHash, PaymentPreimage}; @@ -37,7 +43,8 @@ use crate::payment::store::{ }; use crate::peer_store::{PeerInfo, PeerStore}; use crate::runtime::Runtime; -use crate::types::{ChannelManager, PaymentStore}; +use crate::types::{ChannelManager, KeysManager, PaymentStore}; + #[cfg(not(feature = "uniffi"))] type Bolt11Invoice = LdkBolt11Invoice; @@ -70,6 +77,7 @@ pub struct Bolt11Payment { runtime: Arc, channel_manager: Arc, connection_manager: Arc>>, + keys_manager: Arc, liquidity_source: Arc>>, payment_store: Arc, peer_store: Arc>>, @@ -82,6 +90,7 @@ impl Bolt11Payment { pub(crate) fn new( runtime: Arc, channel_manager: Arc, connection_manager: Arc>>, + keys_manager: Arc, liquidity_source: Arc>>, payment_store: Arc, peer_store: Arc>>, config: Arc, is_running: Arc>, logger: Arc, @@ -90,6 +99,7 @@ impl Bolt11Payment { runtime, channel_manager, connection_manager, + keys_manager, liquidity_source, payment_store, peer_store, @@ -655,6 +665,147 @@ impl Bolt11Payment { Ok(maybe_wrap(invoice)) } + /// Returns a payable invoice whose route hints are supplied by the caller, bypassing + /// `ChannelManager::create_bolt11_invoice`'s filter that drops any inbound channel whose + /// counterparty has not yet sent a `channel_update` (i.e. `forwarding_info = None`). + /// + /// This is required for topologies where a leaf LSP keeps the channel private and never + /// issues a unicast `channel_update`, making the default invoice builder return an empty + /// route-hint list. The caller is responsible for building hints that match the peer's + /// actual forwarding policy. + pub fn receive_with_hints( + &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, + route_hints: Vec, + ) -> Result { + let description = maybe_try_convert_enum(description)?; + let invoice = + self.receive_with_hints_inner(Some(amount_msat), &description, expiry_secs, None, route_hints)?; + Ok(maybe_wrap(invoice)) + } + + /// HODL variant of [`receive_with_hints`] — registers a caller-supplied `payment_hash` + /// so the caller can later release the preimage via [`claim_for_hash`]. + /// + /// [`claim_for_hash`]: Self::claim_for_hash + pub fn receive_for_hash_with_hints( + &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, + payment_hash: PaymentHash, route_hints: Vec, + ) -> Result { + let description = maybe_try_convert_enum(description)?; + let invoice = self.receive_with_hints_inner( + Some(amount_msat), + &description, + expiry_secs, + Some(payment_hash), + route_hints, + )?; + Ok(maybe_wrap(invoice)) + } + + pub(crate) fn receive_with_hints_inner( + &self, amount_msat: Option, invoice_description: &LdkBolt11InvoiceDescription, + expiry_secs: u32, manual_claim_payment_hash: Option, + route_hints: Vec, + ) -> Result { + let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA; + + let (payment_hash_ldk, payment_secret, mut payment_metadata) = if let Some(manual_hash) = + manual_claim_payment_hash + { + let (secret, metadata) = self + .channel_manager + .create_inbound_payment_for_hash( + manual_hash, + amount_msat, + expiry_secs, + Some(min_final_cltv_expiry_delta), + None, + ) + .map_err(|e| { + log_error!( + self.logger, + "Failed to register inbound payment for hash: {:?}", + e + ); + Error::InvoiceCreationFailed + })?; + (manual_hash, secret, metadata) + } else { + self.channel_manager + .create_inbound_payment( + amount_msat, + expiry_secs, + Some(min_final_cltv_expiry_delta), + None, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to register inbound payment: {:?}", e); + Error::InvoiceCreationFailed + })? + }; + + let currency = self.config.network.into(); + let mut invoice_builder = InvoiceBuilder::new(currency) + .invoice_description(invoice_description.clone()) + .payment_hash(payment_hash_ldk) + .payment_secret(payment_secret) + .current_timestamp() + .min_final_cltv_expiry_delta(min_final_cltv_expiry_delta.into()) + .expiry_time(Duration::from_secs(expiry_secs.into())); + + for hint in route_hints { + invoice_builder = invoice_builder.private_route(hint); + } + + if let Some(amount_msat) = amount_msat { + invoice_builder = + invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); + } + + let invoice = invoice_builder + .build_signed(|hash| { + Secp256k1::new() + .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) + }) + .map_err(|e| { + log_error!(self.logger, "Failed to build and sign invoice: {}", e); + Error::InvoiceCreationFailed + })?; + + log_info!(self.logger, "Invoice (with manual route hints) created: {}", invoice); + + let payment_hash = invoice.payment_hash(); + let id = PaymentId(payment_hash.0); + let preimage = if manual_claim_payment_hash.is_none() { + self.channel_manager + .get_payment_preimage_decrypt_metadata( + payment_hash, + invoice.payment_secret().clone(), + payment_metadata.as_deref_mut(), + ) + .ok() + } else { + None + }; + let kind = PaymentKind::Bolt11 { + hash: payment_hash, + preimage, + secret: Some(invoice.payment_secret().clone()), + counterparty_skimmed_fee_msat: None, + }; + let payment = PaymentDetails::new( + id, + kind, + amount_msat, + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + self.runtime.block_on(self.payment_store.insert(payment))?; + + Ok(invoice) + } + /// Returns a payable invoice that can be used to request a payment of the amount given and /// receive it via a newly created just-in-time (JIT) channel. /// diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 491a9cbde5..09a825f87c 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -18,7 +18,15 @@ use crate::logger::{log_error, LdkLogger}; use crate::types::Wallet; use crate::Error; -const BCAST_PACKAGE_QUEUE_SIZE: usize = 50; +// Bumped from 50 to 500 because LDK's onchain claim-bump logic floods the +// queue with rebroadcasts of stuck force-close commitment TXs (each new +// block triggers another retry). Once the queue fills up, new broadcasts — +// including one-shot sweep/funding TXs the onboarding flow depends on — +// are silently dropped with `try_send` returning `Full`. 500 is generous +// enough that legitimate sweep/funding broadcasts always make it through +// even when an old monitor's commitment-tx is stuck looping against +// bitcoind's "Transaction outputs already in utxo set" rejection. +const BCAST_PACKAGE_QUEUE_SIZE: usize = 500; /// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions` /// call, along with each transaction's type. Queued until the background task classifies and From 1bb74c32dad3fe2ba7c30e7a6e72cef7348dcc9c Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Fri, 8 May 2026 23:59:21 +0700 Subject: [PATCH 115/138] fix: opt in to forwarding HTLCs over private channelsupport paris version --- src/config.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 0e68604d96..d4825636f7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -439,9 +439,23 @@ pub(crate) fn default_user_config(config: &Config) -> UserConfig { user_config .channel_handshake_config .unannounced_channel_max_inbound_htlc_value_in_flight_percentage = 100; + // Permit forwarding HTLCs over private channels regardless of whether + // this node has a publicly-announceable identity. Onboarding's HODL-peer + // flow relies on a private last hop (Alice→Bob inbound channel kept + // private so the buyer's invoice carries a route hint). Without this, + // ChannelManager's `can_forward_htlc_to_outgoing_channel` short-circuits + // with `unknown_next_peer (0x400a)` whenever an HTLC is targeted at a + // private channel — this is by design in upstream LDK to hide + // private-channel existence from forwarders, but it breaks any + // leaf-LSP topology that relies on private channels as forwardable + // hops. The two settings (forwarding-over-private-channels vs + // announcing-our-own-channels) are conceptually independent, so we + // leave this enabled even when `may_announce_channel` reports the + // node is missing alias/addresses; only the gossip-related toggles + // are gated on announceability. + user_config.accept_forwards_to_priv_channels = true; if may_announce_channel(config).is_err() { - user_config.accept_forwards_to_priv_channels = false; user_config.channel_handshake_config.announce_for_forwarding = false; user_config.channel_handshake_limits.force_announced_channel_preference = true; } From b0e8199ba163a0bee142563732ce0d114848099a Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Fri, 5 Jun 2026 08:56:41 +0700 Subject: [PATCH 116/138] fix(fee): target 3 blocks for channel funding txs so they confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ChannelFunding confirmation target resolved to a 12-block fee tier, which during normal mempool congestion maps to a rate low enough that funding transactions can sit unconfirmed for hours. The channel never reaches channel_ready and the UI is stuck on `sync`. Lower the target to ~3 blocks (mempool's "fast" tier) so funding txs are mined promptly. The fee is still sourced from the chain source's recommended estimates (esplora get_fee_estimates / electrum estimatefee / bitcoind estimatesmartfee) — only the targeted confirmation window changes, and only for funding transactions. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fee_estimator.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/fee_estimator.rs b/src/fee_estimator.rs index b785bfca40..299c4a23c9 100644 --- a/src/fee_estimator.rs +++ b/src/fee_estimator.rs @@ -87,7 +87,11 @@ impl LdkFeeEstimator for OnchainFeeEstimator { pub(crate) fn get_num_block_defaults_for_target(target: ConfirmationTarget) -> usize { match target { ConfirmationTarget::OnchainPayment => 6, - ConfirmationTarget::ChannelFunding => 12, + // Funding txs target ~3 blocks (mempool's "fast" tier) so they confirm + // promptly. The prior 12-block target resolved to a fee low enough that + // funding txs could sit unconfirmed for hours during normal congestion, + // stalling channels in `sync` (never reaching `channel_ready`). + ConfirmationTarget::ChannelFunding => 3, ConfirmationTarget::Lightning(ldk_target) => match ldk_target { LdkConfirmationTarget::MaximumFeeEstimate => 1, LdkConfirmationTarget::UrgentOnChainSweep => 6, From b6ffd8e2765efe35bfe9a5e6ec1693bd5acd10e9 Mon Sep 17 00:00:00 2001 From: tonible14012002 Date: Tue, 30 Jun 2026 19:03:43 +0700 Subject: [PATCH 117/138] fix(chain/bitcoind): swap_tx_confirmations queried txid in reversed byte order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getrawtransaction wants the txid in RPC/display (big-endian) order (Txid Display), but serialize_hex(txid) emits internal little-endian (reversed) bytes → bitcoind returns -5, mapped to Ok(None)=NotFound. The native swap watcher therefore never saw a confirmed opening tx on the bitcoind backend, so the CSV/confirmation ladder never armed and swaps wedged at AWAIT_CONFIRM/AWAIT_CLAIM_PAYMENT. Fix: pass txid.to_string() (display order). Proven on regtest (display txid=10 confs, reversed=-5). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/chain/bitcoind.rs | 77 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 75e9869651..a257e598de 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -1005,6 +1005,68 @@ impl BitcoindClient { } } + /// Confirmation depth for an ARBITRARY `txid` via verbose `getrawtransaction` + /// (Peerswap native primitive B5). + /// + /// To locate a tx by its id alone (the swap case — a counterparty opening tx + /// that is not in our wallet), the backend must be able to find it, i.e. a + /// `-txindex` node (or the tx still resident in the mempool). Returns: + /// - `Ok(Some(n))` with `n >= 1` for a tx confirmed `n` blocks deep, + /// - `Ok(Some(0))` for a tx seen in the mempool but unconfirmed, + /// - `Ok(None)` when the node does not know the tx (RPC error code -5), + /// - `Err(..)` for any transport/other failure, so the caller fails closed. + #[cfg(feature = "swaps")] + pub(crate) async fn swap_tx_confirmations( + &self, txid: &Txid, + ) -> std::io::Result> { + let rpc_client = match self { + BitcoindClient::Rpc { rpc_client, .. } => Arc::clone(rpc_client), + BitcoindClient::Rest { rpc_client, .. } => Arc::clone(rpc_client), + }; + // `getrawtransaction` expects the txid in RPC/display (big-endian) order — + // exactly `Txid`'s `Display`. `consensus::encode::serialize_hex` emits the + // INTERNAL (little-endian) bytes, i.e. the REVERSED hex, which bitcoind rejects + // with -5 "No such transaction". That -5 is mapped to `Ok(None)` below, so the + // swap watcher would mistake EVERY confirmed opening tx for NotFound and never + // arm the confirmation/CSV ladder — wedging every swap on the bitcoind backend. + let txid_hex = txid.to_string(); + let txid_json = serde_json::json!(txid_hex); + let verbose_json = serde_json::json!(true); + match rpc_client + .call_method::( + "getrawtransaction", + &[txid_json, verbose_json], + ) + .await + { + Ok(resp) => Ok(Some(resp.0)), + Err(e) => match e.into_inner() { + Some(inner) => { + let rpc_error_res: Result, _> = inner.downcast(); + + match rpc_error_res { + Ok(rpc_error) => { + // -5 == "No such mempool or blockchain transaction". + if rpc_error.code == -5 { + Ok(None) + } else { + Err(std::io::Error::new(std::io::ErrorKind::Other, rpc_error)) + } + }, + Err(_) => Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to process verbose getrawtransaction response", + )), + } + }, + None => Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Failed to process verbose getrawtransaction response", + )), + }, + } + } + /// Retrieves the raw mempool. pub(crate) async fn get_raw_mempool(&self) -> Result, BitcoindClientError> { match self { @@ -1367,6 +1429,21 @@ impl TryInto for JsonResponse { } } +/// Confirmation depth parsed from a verbose `getrawtransaction` result +/// (Peerswap native primitive B5). The `confirmations` field is absent for an +/// unconfirmed (mempool) transaction, which we map to `0`. +#[cfg(feature = "swaps")] +pub(crate) struct SwapTxConfirmationResponse(pub u32); + +#[cfg(feature = "swaps")] +impl TryInto for JsonResponse { + type Error = std::io::Error; + fn try_into(self) -> std::io::Result { + let confirmations = self.0["confirmations"].as_u64().unwrap_or(0); + Ok(SwapTxConfirmationResponse(confirmations as u32)) + } +} + pub struct GetRawMempoolResponse(Vec); impl TryInto for JsonResponse { From 6439e56d102c97387eeb9e6baf84d96ea9185c7f Mon Sep 17 00:00:00 2001 From: tonible14012002 Date: Tue, 30 Jun 2026 19:57:57 +0700 Subject: [PATCH 118/138] =?UTF-8?q?feat(swaps):=20native=20PeerSwap=20on-c?= =?UTF-8?q?hain=20primitives=20(B1=E2=80=93B7)=20behind=20the=20`swaps`=20?= =?UTF-8?q?feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the on-chain building blocks the native PeerSwap engine (modules/ldk-node in the consumer) needs, all gated behind a new `swaps` cargo feature so the default build is byte-for-byte unaffected: - B1–B3 funding: create_swap_funding_tx (P2WSH HTLC opening, signed, not broadcast), swap_list_confirmed_utxos, swap_sign_psbt (wallet/mod.rs). - B4 broadcast: broadcast_swap_tx over the bounded broadcast queue (tx_broadcaster.rs). - B5 reorg-aware per-txid confirmation tracking: watch_txid + get_tx_confirmations → TxStatus/ChainStatus + derive_tx_status, with a swap_query_tx backed by whichever chain source is configured (Esplora/Electrum/Bitcoind) and FAIL-CLOSED (NoChainSource) when it cannot answer (chain/mod.rs, chain/electrum.rs). - B6 feerate: estimate_onchain_feerate → source-bearing FeerateQuote so callers can refuse a stale/fallback estimate (fee_estimator.rs). - B7 discovery: swap-capability custom gossip plumbing (custom_gossip.rs). - Builder wiring (builder.rs), public surface (lib.rs). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.toml | 3 + src/builder.rs | 2 + src/chain/mod.rs | 508 ++++++++++++++++++++++++++++++++++++++++++ src/custom_gossip.rs | 12 +- src/fee_estimator.rs | 156 +++++++++++++ src/lib.rs | 152 +++++++++++++ src/tx_broadcaster.rs | 11 + src/wallet/mod.rs | 197 +++++++++++++++- 8 files changed, 1038 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 38b94ae8f4..9129d169bd 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,9 @@ panic = 'abort' # Abort on panic [features] default = [] postgres = ["dep:tokio-postgres", "dep:native-tls", "dep:postgres-native-tls"] +# Peerswap native primitives (B-series). Empty for now; gates all swap +# additions so the default build is byte-for-byte unaffected. +swaps = [] [dependencies] #lightning = { version = "0.2.0", features = ["std"] } diff --git a/src/builder.rs b/src/builder.rs index 87e500d0bb..7d8998353a 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2413,6 +2413,8 @@ fn build_with_store_internal( prober, #[cfg(cycle_tests)] _leak_checker, + #[cfg(feature = "swaps")] + swap_tx_watch: Arc::new(crate::chain::SwapTxWatch::new()), }) } diff --git a/src/chain/mod.rs b/src/chain/mod.rs index f4673da2eb..2a669c2f8c 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -144,6 +144,18 @@ enum ChainSourceKind { } impl ChainSource { + /// Returns the shared on-chain fee estimator backing this chain source + /// (Peerswap native primitive B6). Used by [`crate::Node`] to surface + /// source-bearing swap feerate quotes. + #[cfg(feature = "swaps")] + pub(crate) fn fee_estimator(&self) -> &Arc { + match self { + Self::Esplora { fee_estimator, .. } => fee_estimator, + Self::Electrum { fee_estimator, .. } => fee_estimator, + Self::Bitcoind { fee_estimator, .. } => fee_estimator, + } + } + pub(crate) fn new_esplora( server_url: String, headers: HashMap, sync_config: EsploraSyncConfig, fee_estimator: Arc, tx_broadcaster: Arc, @@ -614,6 +626,154 @@ impl ChainSource { } } } + + /// Reorg-aware confirmation/eviction query for an ARBITRARY `txid` (Peerswap + /// native primitive B5). + /// + /// Unlike the wallet-owned confirmation lookups, this works on a + /// counterparty's swap opening tx that the local wallet does not own. It + /// returns a backend-agnostic [`RawTxObservation`]; the caller + /// ([`crate::Node::get_tx_confirmations`]) folds in the previously-observed + /// confirmation anchor to distinguish a first `Mempool`/`Dropped` sighting + /// from a `Reorged` un-confirmation. + /// + /// FAIL-CLOSED (E6): any chain source that cannot answer — an unstarted + /// backend client, a transport error, or a missing scriptPubKey for the + /// Electrum scriptHash lookup — yields [`RawTxObservation::Unreachable`], so + /// the public API never reports a falsely-confirmed result. + #[cfg(feature = "swaps")] + pub(crate) async fn swap_query_tx( + &self, txid: Txid, script_pubkey: Option<&ScriptBuf>, + ) -> RawTxObservation { + match self { + Self::Esplora { esplora_client, logger, .. } => { + let status = match esplora_client.get_tx_status(&txid).await { + Ok(status) => status, + Err(esplora_client::Error::HttpResponse { status: 404, .. }) => { + // Definitive "not in the chain or mempool" answer. + return RawTxObservation::NotFound; + }, + Err(e) => { + log_error!( + logger, + "swap_query_tx: Esplora status query failed for {}: {}", + txid, + e + ); + return RawTxObservation::Unreachable; + }, + }; + if !status.confirmed { + return RawTxObservation::InMempool; + } + let height = match status.block_height { + Some(height) => height, + None => { + log_error!( + logger, + "swap_query_tx: Esplora reported a confirmed tx {} without a block height", + txid + ); + return RawTxObservation::Unreachable; + }, + }; + // B5 LOW-2: the confirming-block height and the tip come from two + // separate Esplora calls; a block/reorg in the gap can make them + // inconsistent. Detect the one observable inconsistency — a tip BELOW + // the tx's confirming block (impossible on a single consistent chain) + // — and FAIL CLOSED (treat as unverifiable) rather than reporting a + // bogus `1`-confirmation from the saturating arithmetic. The benign + // gap (tip one block ahead of the status snapshot) only over-counts + // confirmations by ≤1, which errs on the safe/late side for deadlines. + match esplora_client.get_height().await { + Ok(tip_height) if tip_height >= height => { + let confirmations = + tip_height.saturating_sub(height).saturating_add(1); + RawTxObservation::Confirmed { height: Some(height), confirmations } + }, + Ok(tip_height) => { + log_error!( + logger, + "swap_query_tx: Esplora tip {} below confirming-block height {} for {} (reorg/race); failing closed", + tip_height, + height, + txid + ); + RawTxObservation::Unreachable + }, + Err(e) => { + log_error!(logger, "swap_query_tx: Esplora tip query failed: {}", e); + RawTxObservation::Unreachable + }, + } + }, + Self::Electrum { electrum_runtime_status, logger, .. } => { + let script_pubkey = match script_pubkey { + Some(script_pubkey) => script_pubkey.clone(), + None => { + log_error!( + logger, + "swap_query_tx: Electrum backend requires a watched scriptPubKey for {} (register via watch_txid)", + txid + ); + return RawTxObservation::Unreachable; + }, + }; + let client = match electrum_runtime_status.read().unwrap().client() { + Some(client) => client, + None => { + log_error!(logger, "swap_query_tx: Electrum chain source not started"); + return RawTxObservation::Unreachable; + }, + }; + client.swap_query_tx(txid, script_pubkey).await + }, + Self::Bitcoind { api_client, latest_chain_tip, logger, .. } => { + match api_client.swap_tx_confirmations(&txid).await { + Ok(Some(0)) => RawTxObservation::InMempool, + Ok(Some(confirmations)) => { + // `getrawtransaction` returns the depth but not the height; derive + // it as `tip - (confs - 1)`. B5 LOW-2: read a FRESH best-chain tip + // (`get_best_block`) rather than the cached `latest_chain_tip`, + // which can lag the real tip and yield a height that is too low — + // and thus a CSV/claim deadline armed slightly EARLY. A fresh (or + // even a one-block-stale-newer) tip can only err on the LATE/safe + // side. Fail-soft on the HEIGHT ONLY: the depth is already + // authoritative, so on a tip-read error we fall back to the cached + // tip rather than failing the whole query closed. + let tip_height = match api_client.get_best_block().await { + Ok((_, Some(h))) => Some(h), + Ok((_, None)) => { + latest_chain_tip.read().unwrap().as_ref().map(|tip| tip.height) + }, + Err(e) => { + log_error!( + logger, + "swap_query_tx: Bitcoind fresh-tip read failed for {} ({:?}); falling back to cached tip for height", + txid, + e + ); + latest_chain_tip.read().unwrap().as_ref().map(|tip| tip.height) + }, + }; + let height = tip_height + .map(|t| t.saturating_sub(confirmations.saturating_sub(1))); + RawTxObservation::Confirmed { height, confirmations } + }, + Ok(None) => RawTxObservation::NotFound, + Err(e) => { + log_error!( + logger, + "swap_query_tx: Bitcoind query failed for {}: {}", + txid, + e + ); + RawTxObservation::Unreachable + }, + } + }, + } + } } impl Filter for ChainSource { @@ -647,3 +807,351 @@ impl Filter for ChainSource { } } } + +#[cfg(feature = "swaps")] +use bitcoin::{Network, ScriptBuf}; + +// ============================================================================ +// Peerswap native primitive B5 — reorg-aware per-txid confirmation tracking. +// +// `register_tx`/`onchain_tx_confirmations` only cover wallet-owned txids; a +// swap taker must verify the *counterparty's* opening tx, which the wallet does +// not own. The types and helpers below add a brand-new, reorg-aware, per-txid +// chain query over whichever chain source the deployment configured +// (Esplora/Electrum/Bitcoind), and FAIL CLOSED (never a falsely-confirmed +// result) when that source cannot answer (E6). Everything here is gated behind +// the `swaps` cargo feature so the default build is byte-for-byte unaffected. +// ============================================================================ + +/// Reorg-aware chain status of a watched transaction (Peerswap native +/// primitive B5). +/// +/// [`ChainStatus::NoChainSource`] is the fail-closed sentinel returned when the +/// configured chain source cannot answer — it is never conflated with a +/// confirmed result (E6). [`ChainStatus::Reorged`] is reported when a tx that +/// was previously observed confirmed is no longer in the best chain, so the +/// caller can re-anchor CSV/claim deadlines (F4). +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChainStatus { + /// Included in a block on the current best chain. + Confirmed, + /// Known to the chain source but still unconfirmed (in the mempool). + Mempool, + /// Previously observed confirmed, but no longer in the best chain (re-orged + /// back to the mempool or evicted). Deadlines must be re-anchored. + Reorged, + /// Unknown to the chain source and never observed confirmed (never broadcast + /// or evicted from the mempool before confirming). + Dropped, + /// The chain source is unconfigured/unreachable and could not answer. The + /// caller MUST treat this as "unverifiable", never as confirmed + /// (fail-closed, E6). + NoChainSource, +} + +#[cfg(feature = "swaps")] +impl ChainStatus { + /// Stable lowercase string form for capability payloads and logs. + pub fn as_str(&self) -> &'static str { + match self { + ChainStatus::Confirmed => "confirmed", + ChainStatus::Mempool => "mempool", + ChainStatus::Reorged => "reorged", + ChainStatus::Dropped => "dropped", + ChainStatus::NoChainSource => "no_chain_source", + } + } +} + +/// Reorg-aware confirmation/eviction status of a watched transaction +/// (Peerswap native primitive B5). +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TxStatus { + /// Confirmation depth on the current best chain (`0` when unconfirmed, + /// reorged, dropped, or unverifiable). + pub confirmations: u32, + /// Height of the confirming block, re-derived from the current best chain + /// (`None` when unconfirmed/reorged/dropped/unverifiable). + pub height: Option, + /// Reorg-aware chain status. + pub status: ChainStatus, +} + +/// Backend-agnostic raw observation of a `txid` against a chain source, before +/// the previously-observed confirmation anchor is folded in (Peerswap B5). +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RawTxObservation { + /// Included in a best-chain block at the given height/depth. + Confirmed { height: Option, confirmations: u32 }, + /// Known to the chain source but unconfirmed. + InMempool, + /// Unknown to the chain source. + NotFound, + /// The chain source could not answer (fail-closed sentinel, E6). + Unreachable, +} + +/// Fold a raw observation together with whether the tx was previously observed +/// confirmed into the reorg-aware [`TxStatus`] (Peerswap B5). +/// +/// Pure function — unit-tested independently of any live chain source. The +/// load-bearing invariant: a [`RawTxObservation::Unreachable`] can never become +/// [`ChainStatus::Confirmed`], regardless of prior confirmation history. +#[cfg(feature = "swaps")] +pub(crate) fn derive_tx_status( + observation: RawTxObservation, previously_confirmed: bool, +) -> TxStatus { + match observation { + RawTxObservation::Confirmed { height, confirmations } => TxStatus { + // A tx in the tip block is 1 confirmation deep, never 0. + confirmations: confirmations.max(1), + height, + status: ChainStatus::Confirmed, + }, + RawTxObservation::InMempool => TxStatus { + confirmations: 0, + height: None, + status: if previously_confirmed { + ChainStatus::Reorged + } else { + ChainStatus::Mempool + }, + }, + RawTxObservation::NotFound => TxStatus { + confirmations: 0, + height: None, + status: if previously_confirmed { + ChainStatus::Reorged + } else { + ChainStatus::Dropped + }, + }, + RawTxObservation::Unreachable => TxStatus { + confirmations: 0, + height: None, + status: ChainStatus::NoChainSource, + }, + } +} + +/// In-memory registry of swap txids being watched (Peerswap B5). +/// +/// Stores the scriptPubKey (required by the Electrum scriptHash lookup) and the +/// last observed confirmation height, which lets +/// [`crate::Node::get_tx_confirmations`] distinguish a first unconfirmed +/// sighting from a reorg-induced un-confirmation. Durable reorg state +/// additionally lives in the native `swap.db` on the consumer side; this cache +/// is best-effort and is rebuilt by re-registering after a restart. +#[cfg(feature = "swaps")] +pub(crate) struct SwapTxWatch { + entries: Mutex>, +} + +#[cfg(feature = "swaps")] +#[derive(Clone)] +struct SwapWatchEntry { + script_pubkey: ScriptBuf, + last_confirmed_height: Option, +} + +#[cfg(feature = "swaps")] +impl SwapTxWatch { + pub(crate) fn new() -> Self { + Self { entries: Mutex::new(HashMap::new()) } + } + + /// Register (idempotently) a txid + its scriptPubKey for watching. A repeat + /// registration refreshes the scriptPubKey but preserves the prior + /// confirmation anchor, so reorg detection survives a re-arm. + pub(crate) fn register(&self, txid: Txid, script_pubkey: ScriptBuf) { + let mut entries = self.entries.lock().unwrap(); + entries + .entry(txid) + .and_modify(|entry| entry.script_pubkey = script_pubkey.clone()) + .or_insert(SwapWatchEntry { script_pubkey, last_confirmed_height: None }); + } + + /// Drop the watch entry for `txid` (B5 LOW-1). Without this the map grows + /// unbounded for the process lifetime — every distinct watched txid (each + /// swap's opening + spend txs) accumulates forever. The consumer calls this + /// once a swap reaches a terminal, settled state and no longer needs reorg + /// tracking. A no-op if `txid` was never registered. + pub(crate) fn unregister(&self, txid: &Txid) { + self.entries.lock().unwrap().remove(txid); + } + + /// Number of currently-watched txids (test/observability only). + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.entries.lock().unwrap().len() + } + + /// The watched scriptPubKey for `txid`, if registered. + pub(crate) fn script_pubkey(&self, txid: &Txid) -> Option { + self.entries.lock().unwrap().get(txid).map(|entry| entry.script_pubkey.clone()) + } + + /// Whether `txid` was ever observed confirmed (arms reorg detection). + pub(crate) fn previously_confirmed(&self, txid: &Txid) -> bool { + self.entries + .lock() + .unwrap() + .get(txid) + .map_or(false, |entry| entry.last_confirmed_height.is_some()) + } + + /// Persist the latest confirmation anchor after a query so subsequent + /// queries can detect a reorg/un-confirmation. Only a fresh confirmation + /// advances the anchor; a non-confirmed status never disarms it. + pub(crate) fn record(&self, txid: &Txid, status: &TxStatus) { + if status.status == ChainStatus::Confirmed { + if let Some(entry) = self.entries.lock().unwrap().get_mut(txid) { + entry.last_confirmed_height = status.height; + } + } + } +} + +#[cfg(all(test, feature = "swaps"))] +mod swap_b5_tests { + use super::{derive_tx_status, ChainStatus, RawTxObservation, SwapTxWatch}; + use bitcoin::hashes::Hash; + use bitcoin::{ScriptBuf, Txid}; + + fn dummy_txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + #[test] + fn confirmed_reports_depth_and_height() { + let status = derive_tx_status( + RawTxObservation::Confirmed { height: Some(100), confirmations: 6 }, + false, + ); + assert_eq!(status.status, ChainStatus::Confirmed); + assert_eq!(status.confirmations, 6); + assert_eq!(status.height, Some(100)); + } + + #[test] + fn confirmed_depth_is_floored_to_one() { + // A tx in the tip block is 1 confirmation deep, never 0. + let status = derive_tx_status( + RawTxObservation::Confirmed { height: Some(100), confirmations: 0 }, + false, + ); + assert_eq!(status.status, ChainStatus::Confirmed); + assert_eq!(status.confirmations, 1); + } + + #[test] + fn first_sighting_distinguishes_mempool_from_dropped() { + let mempool = derive_tx_status(RawTxObservation::InMempool, false); + assert_eq!(mempool.status, ChainStatus::Mempool); + assert_eq!(mempool.confirmations, 0); + assert_eq!(mempool.height, None); + + let dropped = derive_tx_status(RawTxObservation::NotFound, false); + assert_eq!(dropped.status, ChainStatus::Dropped); + assert_eq!(dropped.confirmations, 0); + assert_eq!(dropped.height, None); + } + + #[test] + fn unconfirmation_after_confirm_is_reorg() { + // Previously confirmed, now back to the mempool or gone => Reorged, + // not Mempool/Dropped, so the caller re-anchors deadlines (F4). + let back_to_mempool = derive_tx_status(RawTxObservation::InMempool, true); + assert_eq!(back_to_mempool.status, ChainStatus::Reorged); + assert_eq!(back_to_mempool.confirmations, 0); + + let evicted = derive_tx_status(RawTxObservation::NotFound, true); + assert_eq!(evicted.status, ChainStatus::Reorged); + assert_eq!(evicted.confirmations, 0); + } + + #[test] + fn unreachable_fails_closed_and_is_never_confirmed() { + // The load-bearing E6 invariant: an unanswerable chain source is never + // reported as confirmed, regardless of prior confirmation history. + for previously_confirmed in [false, true] { + let status = + derive_tx_status(RawTxObservation::Unreachable, previously_confirmed); + assert_eq!(status.status, ChainStatus::NoChainSource); + assert_ne!(status.status, ChainStatus::Confirmed); + assert_eq!(status.confirmations, 0); + assert_eq!(status.height, None); + } + } + + #[test] + fn unreachable_status_string_is_stable() { + assert_eq!(ChainStatus::NoChainSource.as_str(), "no_chain_source"); + assert_eq!(ChainStatus::Confirmed.as_str(), "confirmed"); + assert_eq!(ChainStatus::Reorged.as_str(), "reorged"); + assert_eq!(ChainStatus::Dropped.as_str(), "dropped"); + assert_eq!(ChainStatus::Mempool.as_str(), "mempool"); + } + + #[test] + fn watch_registry_tracks_spk_and_reorg_anchor() { + let watch = SwapTxWatch::new(); + let txid = dummy_txid(7); + let spk = ScriptBuf::from_bytes(vec![0x00, 0x14, 0x11, 0x22, 0x33]); + + // Unregistered: no scriptPubKey, not previously confirmed. + assert!(watch.script_pubkey(&txid).is_none()); + assert!(!watch.previously_confirmed(&txid)); + + watch.register(txid, spk.clone()); + assert_eq!(watch.script_pubkey(&txid), Some(spk)); + assert!(!watch.previously_confirmed(&txid)); + + // Recording a confirmation arms the reorg anchor. + let confirmed = derive_tx_status( + RawTxObservation::Confirmed { height: Some(200), confirmations: 3 }, + false, + ); + watch.record(&txid, &confirmed); + assert!(watch.previously_confirmed(&txid)); + + // A later mempool sighting for the now-armed txid derives Reorged. + let reorged = + derive_tx_status(RawTxObservation::InMempool, watch.previously_confirmed(&txid)); + assert_eq!(reorged.status, ChainStatus::Reorged); + + // Recording a non-confirmed status does not disarm the anchor. + watch.record(&txid, &reorged); + assert!(watch.previously_confirmed(&txid)); + } + + #[test] + fn unregister_drops_the_watch_entry_and_is_idempotent() { + // B5 LOW-1: the watch map must not grow unbounded — a terminalized swap's + // entry is dropped, and unregistering an unknown txid is a harmless no-op. + let watch = SwapTxWatch::new(); + let a = dummy_txid(1); + let b = dummy_txid(2); + let spk = ScriptBuf::from_bytes(vec![0x00, 0x14, 0xaa, 0xbb]); + + watch.register(a, spk.clone()); + watch.register(b, spk.clone()); + assert_eq!(watch.len(), 2); + + watch.unregister(&a); + assert_eq!(watch.len(), 1, "the terminalized txid's entry is dropped"); + assert!(watch.script_pubkey(&a).is_none()); + assert!(watch.script_pubkey(&b).is_some(), "unrelated entry untouched"); + + // Idempotent: dropping the same (or an unknown) txid again is a no-op. + watch.unregister(&a); + watch.unregister(&dummy_txid(99)); + assert_eq!(watch.len(), 1); + + watch.unregister(&b); + assert_eq!(watch.len(), 0); + } +} diff --git a/src/custom_gossip.rs b/src/custom_gossip.rs index 23de196825..7b449fc9f4 100644 --- a/src/custom_gossip.rs +++ b/src/custom_gossip.rs @@ -124,6 +124,14 @@ where *our_metadata = Some(metadata); } + /// Get OUR OWN advertised metadata blob (the one broadcast to peers), if set. + /// Distinct from [`get_all_metadata`], which returns PEERS' received blobs and + /// NEVER our own — so this is the only way for the owning node to read back what + /// it is currently advertising (needed for a correct read-merge-write of our blob). + pub fn get_our_metadata(&self) -> Option> { + self.our_metadata.lock().unwrap().clone() + } + /// Get metadata for a specific node pub fn get_node_metadata(&self, node_id: &PublicKey) -> Option { let metadata_store = self.node_metadata.lock().unwrap(); @@ -256,8 +264,8 @@ where #[cfg(test)] mod tests { use super::*; - use crate::logger::test_logger; use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use lightning::util::test_utils::TestLogger; use lightning::util::ser::{Readable, Writeable}; use std::io::Cursor; @@ -282,7 +290,7 @@ mod tests { #[test] fn test_custom_gossip_handler() { - let logger = test_logger(); + let logger = Arc::new(TestLogger::new()); let handler = CustomGossipMessageHandler::new(logger); // Test setting our metadata diff --git a/src/fee_estimator.rs b/src/fee_estimator.rs index 299c4a23c9..d087727171 100644 --- a/src/fee_estimator.rs +++ b/src/fee_estimator.rs @@ -207,3 +207,159 @@ mod tests { assert_eq!(rbf_splice_feerates(kwu(100), kwu(278)), None); } } + +/// Public fee-priority selector for on-chain swap transactions (Peerswap +/// native primitives, B-series). +/// +/// This is the **public** surface used by swap code to ask for a fee rate +/// without exposing the crate-internal [`ConfirmationTarget`] enum. Each +/// variant maps onto an existing internal target via [`From`], so no new +/// `ConfirmationTarget` variant is introduced and every existing exhaustive +/// match is left untouched. +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub enum SwapFeeTarget { + /// Fee target for broadcasting a swap funding (HTLC opening) transaction. + /// + /// Maps to [`ConfirmationTarget::ChannelFunding`] so the funding output + /// confirms promptly (~3 blocks) and the swap can proceed without stalling. + Funding, + /// Fee target for time-sensitive claim/sweep transactions. + /// + /// A swap claim is bounded by an on-chain timelock, so it must confirm + /// urgently. Maps to [`LdkConfirmationTarget::UrgentOnChainSweep`]. + Claim, + /// Fee target for refund / cooperative-spend transactions. + /// + /// Less time-critical than a [`SwapFeeTarget::Claim`]; maps to the standard + /// [`ConfirmationTarget::OnchainPayment`] priority. + Refund, +} + +#[cfg(feature = "swaps")] +impl From for ConfirmationTarget { + fn from(value: SwapFeeTarget) -> Self { + match value { + SwapFeeTarget::Funding => ConfirmationTarget::ChannelFunding, + SwapFeeTarget::Claim => { + ConfirmationTarget::Lightning(LdkConfirmationTarget::UrgentOnChainSweep) + }, + SwapFeeTarget::Refund => ConfirmationTarget::OnchainPayment, + } + } +} + +/// Provenance of a swap feerate estimate (Peerswap native primitive B6 / +/// plan FIX-B). +/// +/// Lets a swap caller distinguish a live estimate sourced from the chain +/// backend from a static fallback/relay-floor value, so it can refuse to fund +/// (fail-closed) on an estimate it does not trust. +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SwapFeerateSource { + /// A live estimate sourced from the chain backend's fee-rate cache. + Native, + /// No live estimate was available; a per-target fallback rate (or the + /// `FEERATE_FLOOR_SATS_PER_KW` relay floor) was used instead. + Static, +} + +/// A swap feerate estimate carrying its [`SwapFeerateSource`] provenance +/// (Peerswap native primitive B6 / plan FIX-B). +/// +/// This is intentionally NOT a bare `u64`/[`FeeRate`]: swap funding decisions +/// are fail-closed, so the consumer must be able to tell a live estimate from a +/// fallback/floor before committing funds. +#[cfg(feature = "swaps")] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FeerateQuote { + /// The estimated feerate in satoshis per virtual byte (rounded up so the + /// transaction is never under-funded relative to the estimate). + pub sat_vb: u64, + /// Whether `sat_vb` came from a live estimate or a static fallback/floor. + pub source: SwapFeerateSource, +} + +#[cfg(feature = "swaps")] +impl OnchainFeeEstimator { + /// Estimate the on-chain fee rate for a swap transaction at the requested + /// [`SwapFeeTarget`] priority. + /// + /// Thin wrapper over [`FeeEstimator::estimate_fee_rate`] that maps the + /// public [`SwapFeeTarget`] onto the internal [`ConfirmationTarget`]. The + /// returned [`FeeRate`] is subject to the same `FEERATE_FLOOR_SATS_PER_KW` + /// lower bound as every other estimate, and falls back to the per-target + /// fallback rate when the cache is empty. + pub(crate) fn estimate_swap_fee_rate(&self, target: SwapFeeTarget) -> FeeRate { + self.estimate_fee_rate(target.into()) + } + + /// Source-bearing swap feerate estimate (B6 / FIX-B). + /// + /// Returns the estimate in sat/vB together with its [`SwapFeerateSource`]: + /// [`SwapFeerateSource::Native`] when a live cached estimate exists for the + /// mapped target, [`SwapFeerateSource::Static`] when the per-target + /// fallback / relay floor had to be used (cache empty). Callers MUST treat + /// a `Static` quote as untrusted for fail-closed funding decisions. + pub(crate) fn estimate_swap_feerate_quote(&self, target: SwapFeeTarget) -> FeerateQuote { + let conf_target: ConfirmationTarget = target.into(); + let source = if self.fee_rate_cache.read().unwrap().contains_key(&conf_target) { + SwapFeerateSource::Native + } else { + SwapFeerateSource::Static + }; + let rate = self.estimate_fee_rate(conf_target); + FeerateQuote { sat_vb: rate.to_sat_per_vb_ceil(), source } + } +} + +#[cfg(all(test, feature = "swaps"))] +mod swap_b6_tests { + use super::*; + + // An empty cache must yield a `Static` quote (fallback/floor), never a + // `Native` one — the fail-closed default for swap funding decisions. + #[test] + fn empty_cache_quote_is_static() { + let estimator = OnchainFeeEstimator::new(); + for target in [SwapFeeTarget::Funding, SwapFeeTarget::Claim, SwapFeeTarget::Refund] { + let quote = estimator.estimate_swap_feerate_quote(target); + assert_eq!(quote.source, SwapFeerateSource::Static); + // The fallback is always at least the relay floor, so sat/vB is > 0. + assert!(quote.sat_vb > 0); + } + } + + // A live cached estimate for the mapped target must yield a `Native` quote + // whose sat/vB reflects the cached rate (here well above the relay floor). + #[test] + fn cached_estimate_quote_is_native() { + let estimator = OnchainFeeEstimator::new(); + let target = SwapFeeTarget::Funding; + let conf_target: ConfirmationTarget = target.into(); + // 2500 sat/kwu == 10 sat/vB, comfortably above FEERATE_FLOOR_SATS_PER_KW. + let mut update = HashMap::new(); + update.insert(conf_target, FeeRate::from_sat_per_kwu(2500)); + estimator.set_fee_rate_cache(update); + + let quote = estimator.estimate_swap_feerate_quote(target); + assert_eq!(quote.source, SwapFeerateSource::Native); + assert_eq!(quote.sat_vb, 10); + } + + // A target absent from a populated cache still fails closed to `Static`. + #[test] + fn missing_target_in_populated_cache_is_static() { + let estimator = OnchainFeeEstimator::new(); + let mut update = HashMap::new(); + update.insert( + Into::::into(SwapFeeTarget::Funding), + FeeRate::from_sat_per_kwu(2500), + ); + estimator.set_fee_rate_cache(update); + + let quote = estimator.estimate_swap_feerate_quote(SwapFeeTarget::Claim); + assert_eq!(quote.source, SwapFeerateSource::Static); + } +} diff --git a/src/lib.rs b/src/lib.rs index 58d8ebb666..fd8ddecc22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -120,6 +120,40 @@ pub use balance::{BalanceDetails, LightningBalance, PendingSweepBalance}; pub use bip39; pub use bitcoin; use bitcoin::secp256k1::PublicKey; + +/// Public swap fee-priority selector (Peerswap native primitives, B-series). +/// +/// Re-exported from the otherwise-private `fee_estimator` module so swap code +/// can request on-chain fee rates without the crate-internal +/// `ConfirmationTarget` leaking into the public API. +#[cfg(feature = "swaps")] +pub use fee_estimator::SwapFeeTarget; + +/// Public source-bearing swap feerate quote types (Peerswap native primitive +/// B6 / plan FIX-B). Re-exported from the otherwise-private `fee_estimator` +/// module so swap callers can detect estimate provenance (live vs fallback). +#[cfg(feature = "swaps")] +pub use fee_estimator::{FeerateQuote, SwapFeerateSource}; + +/// Public reorg-aware chain-status types for the swap-txid watch primitive +/// (Peerswap native primitives, B5). Re-exported from the otherwise-private +/// `chain` module. +#[cfg(feature = "swaps")] +pub use chain::{ChainStatus, TxStatus}; + +/// Public types appearing in the swap-primitive signatures on [`Node`] +/// (Peerswap native primitives, B-series). Re-exported so the consumer crate +/// can name them without reaching into the (otherwise-private) LDK/bitcoin +/// module paths. +#[cfg(feature = "swaps")] +pub use bitcoin::psbt::Psbt; +/// Swap keypair type returned by [`Node::derive_swap_keypair`] (B7). +#[cfg(feature = "swaps")] +pub use bitcoin::secp256k1::Keypair as SwapKeypair; +/// Confirmed wallet UTXO type returned by [`Node::swap_list_confirmed_utxos`] +/// (B2). +#[cfg(feature = "swaps")] +pub use lightning::util::wallet_utils::Utxo; #[cfg(feature = "uniffi")] pub use bitcoin::FeeRate; use bitcoin::{Address, Amount, BlockHash, Network}; @@ -276,6 +310,9 @@ pub struct Node { prober: Option>, #[cfg(cycle_tests)] _leak_checker: LeakChecker, + /// Reorg-aware swap-txid watch registry (Peerswap native primitive B5). + #[cfg(feature = "swaps")] + swap_tx_watch: Arc, } impl Node { @@ -1132,6 +1169,121 @@ impl Node { ) } + /// Registers an arbitrary transaction (e.g. a counterparty's swap opening tx + /// that the local wallet does not own) for reorg-aware confirmation tracking + /// via [`Node::get_tx_confirmations`] (Peerswap native primitive B5). + /// + /// `scriptpubkey` is the output script being watched; it is required by the + /// Electrum chain source (which locates a tx through its scriptHash history) + /// and ignored by the Esplora/Bitcoind backends. Registration is idempotent. + #[cfg(feature = "swaps")] + pub fn watch_txid(&self, txid: bitcoin::Txid, scriptpubkey: bitcoin::ScriptBuf) { + self.swap_tx_watch.register(txid, scriptpubkey); + } + + /// Drops the reorg-aware watch for `txid` registered via [`Node::watch_txid`] + /// (Peerswap native primitive B5, LOW-1). The consumer calls this once a swap + /// reaches a terminal, settled state so the in-memory watch map does not grow + /// unbounded for the process lifetime. A no-op for a txid never watched. + #[cfg(feature = "swaps")] + pub fn unwatch_txid(&self, txid: bitcoin::Txid) { + self.swap_tx_watch.unregister(&txid); + } + + /// Queries the reorg-aware confirmation status of an arbitrary `txid` + /// against the configured chain source (Peerswap native primitive B5). + /// + /// Unlike wallet-owned confirmation lookups, this works on a counterparty's + /// opening tx. Confirmations are re-derived from the tx's *current* + /// best-chain block on every call, so a previously-confirmed tx that has + /// re-orged out is reported as [`ChainStatus::Reorged`] (and a never-confirmed + /// tx gone from the mempool as [`ChainStatus::Dropped`]) with zero + /// confirmations, allowing the caller to re-anchor deadlines (F4). + /// + /// FAIL-CLOSED (E6): if the chain source is unconfigured/unreachable, or an + /// Electrum lookup is attempted for a `txid` never registered via + /// [`Node::watch_txid`], the returned status is [`ChainStatus::NoChainSource`] + /// — never a confirmed result. Callers MUST NOT advance any state that + /// depends on a confirmation they could not verify. + #[cfg(feature = "swaps")] + pub async fn get_tx_confirmations(&self, txid: bitcoin::Txid) -> Result { + let script_pubkey = self.swap_tx_watch.script_pubkey(&txid); + let previously_confirmed = self.swap_tx_watch.previously_confirmed(&txid); + let observation = self.chain_source.swap_query_tx(txid, script_pubkey.as_ref()).await; + let status = chain::derive_tx_status(observation, previously_confirmed); + self.swap_tx_watch.record(&txid, &status); + Ok(status) + } + + /// Builds a fully-signed swap funding (HTLC opening) transaction paying + /// `amount` to `output_script` (e.g. a P2WSH submarine-swap HTLC output) at + /// the feerate implied by `fee_target`, with the supplied `locktime` + /// (Peerswap native primitive B1). + /// + /// The returned [`bitcoin::Transaction`] is signed and persisted but **not** + /// broadcast — call [`Node::broadcast_swap_tx`] to publish it. The public + /// [`SwapFeeTarget`] is used in place of the crate-internal confirmation + /// target so no internal type leaks across the crate boundary. + #[cfg(feature = "swaps")] + pub fn create_swap_funding_tx( + &self, output_script: bitcoin::ScriptBuf, amount: bitcoin::Amount, + fee_target: SwapFeeTarget, locktime: bitcoin::blockdata::locktime::absolute::LockTime, + ) -> Result { + self.wallet.create_swap_funding_tx(output_script, amount, fee_target.into(), locktime) + } + + /// Lists the wallet's confirmed, unspent outputs as [`Utxo`]s for use as + /// swap funding inputs (Peerswap native primitive B2). + #[cfg(feature = "swaps")] + pub fn swap_list_confirmed_utxos(&self) -> Result, Error> { + self.wallet.swap_list_confirmed_utxos() + } + + /// Signs a swap [`Psbt`] with the on-chain wallet, returning the extracted + /// [`bitcoin::Transaction`] (Peerswap native primitive B3). + /// + /// LDK-provided inputs are not finalized by BDK; the caller is responsible + /// for finalizing any swap-script (HTLC) inputs it owns. + #[cfg(feature = "swaps")] + pub fn swap_sign_psbt(&self, psbt: Psbt) -> Result { + self.wallet.swap_sign_psbt(psbt) + } + + /// Enqueues a fully-signed swap transaction for broadcast on the configured + /// chain backend (Peerswap native primitive B4). + /// + /// Fire-and-forget: the transaction is placed on the bounded broadcast queue + /// drained by the chain source and this returns immediately; it does not + /// confirm acceptance by the backend. + #[cfg(feature = "swaps")] + pub fn broadcast_swap_tx(&self, tx: &bitcoin::Transaction) { + self.tx_broadcaster.broadcast_tx(tx); + } + + /// Estimates the on-chain feerate for a swap transaction at the requested + /// [`SwapFeeTarget`] priority, returning a source-bearing [`FeerateQuote`] + /// (Peerswap native primitive B6 / plan FIX-B). + /// + /// The quote's [`SwapFeerateSource`] lets a fail-closed caller distinguish a + /// live backend estimate from a static fallback/relay-floor value and refuse + /// to fund on an untrusted estimate. + #[cfg(feature = "swaps")] + pub fn estimate_onchain_feerate(&self, target: SwapFeeTarget) -> FeerateQuote { + self.chain_source.fee_estimator().estimate_swap_feerate_quote(target) + } + + /// Derives a deterministic swap [`SwapKeypair`] at `index` from a dedicated, + /// swaps-only BIP-32 derivation path (Peerswap native primitive B7). + /// + /// The keypair is NEVER derived from the node identity secret key: it comes + /// from a hardened path reserved exclusively for swaps, isolated from the + /// identity/channel keys. The returned keypair carries both the secret and + /// public key for building and signing swap HTLC scripts. + #[cfg(feature = "swaps")] + pub fn derive_swap_keypair(&self, index: u32) -> Result { + self.keys_manager.derive_swap_keypair(index) + } + /// Returns a payment handler allowing to send and receive on-chain payments. #[cfg(feature = "uniffi")] pub fn onchain_payment(&self) -> Arc { diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index 09a825f87c..a709433fa6 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -163,6 +163,17 @@ where log_error!(self.logger, "Failed to broadcast transactions: {}", e); }); } + + /// Enqueues a single fully-signed transaction for broadcast (swaps B4). + /// + /// Thin wrapper over the [`BroadcasterInterface::broadcast_transactions`] impl below: + /// it enqueues the transaction onto the bounded broadcast queue drained by the chain + /// source's `process_broadcast_queue` loop. The actual network send happens there, + /// so this returns immediately and does not confirm acceptance by the backend. + #[cfg(feature = "swaps")] + pub(crate) fn broadcast_tx(&self, tx: &Transaction) { + ::broadcast_transactions(self, &[tx]); + } } impl BroadcasterInterface for TransactionBroadcaster diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 690ab7caad..71dc5aa5dc 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -53,6 +53,11 @@ use lightning::util::wallet_utils::{ use lightning_invoice::RawBolt11Invoice; use persist::KVStoreWalletPersister; +#[cfg(feature = "swaps")] +use bitcoin::bip32::{ChildNumber, Xpriv}; +#[cfg(feature = "swaps")] +use bitcoin::secp256k1::Keypair; + use crate::config::Config; use crate::fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; @@ -530,6 +535,42 @@ impl Wallet { Ok(address_info.address) } + /// Builds a fully-signed funding transaction paying `amount` to an arbitrary `output_script` + /// (e.g. a P2WSH submarine-swap HTLC output) at the fee rate implied by `confirmation_target`, + /// with the supplied `locktime`. The returned [`Transaction`] is signed and persisted but **not** + /// broadcast. + /// + /// This is a thin swaps-gated wrapper over [`Wallet::create_funding_transaction`]; it does not + /// alter the existing behaviour of that method in any way. + #[cfg(feature = "swaps")] + pub(crate) fn create_swap_funding_tx( + &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, + locktime: LockTime, + ) -> Result { + self.create_funding_transaction(output_script, amount, confirmation_target, locktime) + } + + /// Lists the wallet's confirmed, unspent outputs as [`Utxo`]s. + /// + /// This is a thin swaps-gated inherent wrapper over the [`WalletSource::list_confirmed_utxos`] + /// trait method. Unlike the trait method (whose error type is `()`), it surfaces a real + /// [`Error`] so swap call sites get a meaningful failure value. + #[cfg(feature = "swaps")] + pub(crate) fn swap_list_confirmed_utxos(&self) -> Result, Error> { + WalletSource::list_confirmed_utxos(self).map_err(|()| Error::WalletOperationFailed) + } + + /// Signs a PSBT with the BDK wallet, returning the extracted [`Transaction`]. + /// + /// This is a thin swaps-gated inherent wrapper over the [`WalletSource::sign_psbt`] trait + /// method. Unlike the trait method (whose error type is `()`), it surfaces a real [`Error`] so + /// swap call sites get a meaningful failure value. As with the trait method, LDK-provided inputs + /// are not finalized by BDK and the `finalized` bool is intentionally ignored. + #[cfg(feature = "swaps")] + pub(crate) fn swap_sign_psbt(&self, psbt: Psbt) -> Result { + WalletSource::sign_psbt(self, psbt).map_err(|()| Error::WalletOperationFailed) + } + pub(crate) async fn get_new_internal_address(&self) -> Result { let mut locked_persister = self.persister.lock().await; let (address_info, change_set) = { @@ -1996,6 +2037,14 @@ pub(crate) struct WalletKeysManager { inner: KeysManager, wallet: Arc, logger: Arc, + /// Dedicated swap-key derivation master (Peerswap native primitive B7). + /// + /// Derived from the wallet seed at a hardened BIP-32 index reserved + /// exclusively for swaps. It is fully isolated from the node identity + /// secret key (which LDK derives at the low reserved children of the same + /// master), so a swap keypair can NEVER coincide with the node identity. + #[cfg(feature = "swaps")] + swap_master_xprv: Xpriv, } impl WalletKeysManager { @@ -2008,7 +2057,15 @@ impl WalletKeysManager { logger: Arc, ) -> Self { let inner = KeysManager::new(seed, starting_time_secs, starting_time_nanos, true); - Self { inner, wallet, logger } + #[cfg(feature = "swaps")] + let swap_master_xprv = Self::derive_swap_master_xprv(seed); + Self { + inner, + wallet, + logger, + #[cfg(feature = "swaps")] + swap_master_xprv, + } } pub fn sign_message(&self, msg: &[u8]) -> String { @@ -2022,6 +2079,66 @@ impl WalletKeysManager { pub fn verify_signature(&self, msg: &[u8], sig: &str, pkey: &PublicKey) -> bool { message_signing::verify(msg, sig, pkey) } + + /// Hardened BIP-32 child index of the dedicated swap-key domain (B7). + /// + /// Value is the ASCII bytes of `"swap"` (`0x73776170`), which is `< 2^31` + /// so it is a valid hardened index. It sits far outside the low children + /// (`0..=6`) that LDK's `KeysManager` reserves for the node identity, + /// channel, destination, shutdown, and inbound-payment keys — guaranteeing + /// the swap key tree never overlaps the node identity secret key. + #[cfg(feature = "swaps")] + const SWAP_KEY_HARDENED_CHILD_INDEX: u32 = 0x7377_6170; + + /// Derives the dedicated swap-domain master xpriv from the wallet `seed`. + /// + /// BIP-32 child-key derivation is network-independent for the secret + /// material, so the fixed network used to construct the master only affects + /// the (unused) serialization version bytes — never the derived keys. + #[cfg(feature = "swaps")] + fn derive_swap_master_xprv(seed: &[u8; 32]) -> Xpriv { + let secp = Secp256k1::new(); + let master = Xpriv::new_master(Network::Bitcoin, seed) + .expect("a 32-byte seed is always a valid BIP-32 master key"); + master + .derive_priv( + &secp, + &[ChildNumber::Hardened { index: Self::SWAP_KEY_HARDENED_CHILD_INDEX }], + ) + .expect("hardened derivation from a valid master key is infallible") + } + + /// Derives a deterministic swap [`Keypair`] at `index` from the dedicated, + /// swaps-only BIP-32 path (B7). + /// + /// The key is derived from [`Self::swap_master_xprv`], i.e. a hardened path + /// reserved exclusively for swaps; it is NEVER derived from the node + /// identity secret key. The returned [`Keypair`] carries both the secret + /// and the public key so callers can build and sign swap HTLC scripts. + #[cfg(feature = "swaps")] + pub(crate) fn derive_swap_keypair(&self, index: u32) -> Result { + swap_keypair_from_master(&self.swap_master_xprv, index).map_err(|e| { + log_error!(self.logger, "Failed to derive swap keypair at index {}: {}", index, e); + Error::InvalidSecretKey + }) + } +} + +/// Derives the swap [`Keypair`] at hardened `index` from an already-derived +/// swap-domain master xpriv (Peerswap native primitive B7). +/// +/// Split out from [`WalletKeysManager::derive_swap_keypair`] as a generic-free, +/// `self`-free helper so the deterministic derivation can be exercised by unit +/// test vectors without constructing a full wallet/keys-manager. The instance +/// method adds error logging on top of this pure derivation. Secret material is +/// never logged here. +#[cfg(feature = "swaps")] +fn swap_keypair_from_master( + master: &Xpriv, index: u32, +) -> Result { + let secp = Secp256k1::new(); + let child = master.derive_priv(&secp, &[ChildNumber::Hardened { index }])?; + Ok(Keypair::from_secret_key(&secp, &child.private_key)) } impl NodeSigner for WalletKeysManager { @@ -2167,3 +2284,81 @@ fn ldk_to_bdk_satisfaction_weight(ldk_satisfaction_weight: u64) -> Weight { .saturating_sub(EMPTY_SCRIPT_SIG_WEIGHT + EMPTY_WITNESS_COUNT_WEIGHT), ) } + +#[cfg(all(test, feature = "swaps"))] +mod swap_b7_tests { + //! Test vectors for the B7 dedicated swap-key derivation. + //! + //! These exercise the exact production derivation path used by + //! [`WalletKeysManager::derive_swap_keypair`] — namely + //! [`WalletKeysManager::derive_swap_master_xprv`] (the swaps-only hardened + //! BIP-32 domain) followed by [`swap_keypair_from_master`] — without having + //! to construct a full BDK-backed wallet/keys-manager. + + use super::swap_keypair_from_master; + use crate::types::KeysManager; + use bitcoin::secp256k1::{PublicKey, Secp256k1}; + use lightning::sign::KeysManager as LdkKeysManager; + + /// Fixed 32-byte seed used by every vector below. + const TEST_SEED: [u8; 32] = [ + 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff, 0x0f, 0x1e, 0x2d, 0x3c, 0x4b, 0x5a, 0x69, 0x78, 0x87, 0x96, 0xa5, 0xb4, 0xc3, 0xd2, + 0xe1, 0xf0, + ]; + + /// Derives the swap public key for `index` from `TEST_SEED` over the full + /// production path and returns it as a lowercase compressed-hex string. + fn swap_pubkey_hex(index: u32) -> String { + let master = KeysManager::derive_swap_master_xprv(&TEST_SEED); + let keypair = swap_keypair_from_master(&master, index).expect("derivation must succeed"); + keypair.public_key().to_string() + } + + #[test] + fn swap_keypair_matches_fixed_vector() { + // Fixed seed + index => fixed compressed public key. Regenerating this + // value would signal an (unintended) change to the swap derivation path. + assert_eq!( + swap_pubkey_hex(0), + "03d6c52bcef058703ff78e4d765f7b114ff5ad13f222596049b6a7bb66406bc6b6" + ); + assert_eq!( + swap_pubkey_hex(1), + "0203784b06423d07485e4378ebce2eca4c7db3caa15426d52715c2414f4b0cebd9" + ); + } + + #[test] + fn swap_keypair_is_deterministic() { + assert_eq!(swap_pubkey_hex(0), swap_pubkey_hex(0)); + // Distinct indices yield distinct keys. + assert_ne!(swap_pubkey_hex(0), swap_pubkey_hex(1)); + } + + #[test] + fn swap_key_differs_from_node_identity() { + // The node identity secret key is what LDK's KeysManager derives from the + // same seed. The swap key MUST come from a different (dedicated) path. + let ldk = LdkKeysManager::new(&TEST_SEED, 0, 0); + let node_secret = ldk.get_node_secret_key(); + let secp = Secp256k1::new(); + let node_pubkey = PublicKey::from_secret_key(&secp, &node_secret); + + let master = KeysManager::derive_swap_master_xprv(&TEST_SEED); + for index in 0..8u32 { + let swap_keypair = + swap_keypair_from_master(&master, index).expect("derivation must succeed"); + assert_ne!( + swap_keypair.secret_key(), + node_secret, + "swap secret at index {index} must never equal the node identity secret" + ); + assert_ne!( + swap_keypair.public_key(), + node_pubkey, + "swap pubkey at index {index} must never equal the node identity pubkey" + ); + } + } +} From d849751c21055d50b709daa54a7a64f42b06f5c3 Mon Sep 17 00:00:00 2001 From: tonible14012002 Date: Fri, 3 Jul 2026 17:51:44 +0700 Subject: [PATCH 119/138] feat(cycles): manual-route circular self-payment behind the `cycles` feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the cooperative cycle-balance primitive: `Node::send_along_route(route, amount_msat, payment_hash, preimage)` sends a spontaneous payment along a caller-supplied route back to self, recorded as `PaymentKind::Rebalance` (TLV type 12, ungated for record compatibility). The `PaymentClaimable` handler scopes the circular-payment and spontaneous-duplicate guards to exclude Rebalance records and claims the looped HTLC inline with the locally-held preimage, marking the single outbound record Succeeded — settlement is observed by polling the payment store, no user-facing event is emitted. Only the `Node` method is gated behind the new `cycles` feature; the default build is unaffected. Co-Authored-By: Claude Fable 5 --- Cargo.toml | 4 ++ src/event.rs | 39 ++++++++++++++++++- src/lib.rs | 89 ++++++++++++++++++++++++++++++++++++++++++++ src/payment/store.rs | 25 +++++++++++++ 4 files changed, 155 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9129d169bd..b9c6866a43 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,10 @@ postgres = ["dep:tokio-postgres", "dep:native-tls", "dep:postgres-native-tls"] # Peerswap native primitives (B-series). Empty for now; gates all swap # additions so the default build is byte-for-byte unaffected. swaps = [] +# Cooperative cycle-balance primitive (manual-route circular self-payment). +# Gates the `Node::send_along_route` entry point; the `PaymentKind::Rebalance` +# variant and its `event.rs` claim path stay ungated for TLV compatibility. +cycles = [] [dependencies] #lightning = { version = "0.2.0", features = ["std"] } diff --git a/src/event.rs b/src/event.rs index 528a7085ed..f4aa476906 100644 --- a/src/event.rs +++ b/src/event.rs @@ -793,7 +793,11 @@ where let payment_id = PaymentId(payment_hash.0); let payment_info = self.payment_store.get(&payment_id); if let Some(info) = payment_info.as_ref() { - if info.direction == PaymentDirection::Outbound { + // Guard 1: refuse circular (self-loop) payments, EXCEPT for + // self-rebalance loops tagged as PaymentKind::Rebalance. Cross-node + // payments are never caught here (the remote recipient has no local + // Outbound record under the inbound hash). + if info.direction == PaymentDirection::Outbound && !info.is_rebalance() { log_info!( self.logger, "Refused inbound payment with ID {}: circular payments are unsupported.", @@ -814,8 +818,13 @@ where }; } + // Guard 2: refuse duplicate Succeeded payments and plain Spontaneous + // inbound payments. Self-rebalance loops (PaymentKind::Rebalance) must + // fall through here so we can claim the HTLC using our locally-held + // preimage and settle the loop. if info.status == PaymentStatus::Succeeded - || matches!(&info.kind, PaymentKind::Spontaneous { .. }) + || (matches!(&info.kind, PaymentKind::Spontaneous { .. }) + && !info.is_rebalance()) { let stored_preimage = match &info.kind { PaymentKind::Bolt11 { preimage, .. } @@ -913,6 +922,32 @@ where } if let Some(info) = payment_info { + // For self-rebalance loops the preimage is held locally in the + // Rebalance record. Claim immediately without inserting a new + // payment record (the outbound record already exists). + if let PaymentKind::Rebalance { preimage, .. } = info.kind { + log_info!( + self.logger, + "Claiming self-rebalance loop for payment hash {} of {}msat", + hex_utils::to_string(&payment_hash.0), + amount_msat, + ); + self.channel_manager.claim_funds(preimage); + + let update = PaymentDetailsUpdate { + status: Some(PaymentStatus::Succeeded), + amount_msat: Some(Some(amount_msat)), + ..PaymentDetailsUpdate::new(payment_id) + }; + match self.payment_store.update(update).await { + Ok(_) => return Ok(()), + Err(e) => { + log_error!(self.logger, "Failed to access payment store: {}", e); + return Err(ReplayEvent()); + }, + }; + } + // If this is known by the store but ChannelManager doesn't know the preimage, // the payment has been registered via `_for_hash` variants and needs to be manually claimed via // user interaction. diff --git a/src/lib.rs b/src/lib.rs index fd8ddecc22..67bcc525cf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1156,6 +1156,95 @@ impl Node { )) } + /// Sends a circular self-payment along a caller-supplied route (cooperative + /// cycle-balance primitive). + /// + /// The caller builds the exact [`Route`] (first hop = drain channel A, last hop = + /// fill channel B → self), generates a `preimage`/`payment_hash` pair (held + /// locally), and passes them here. The payment record is stored with + /// [`PaymentKind::Rebalance`] so the `event.rs` `PaymentClaimable` handler allows + /// the self-loop to settle instead of refusing it as a circular payment. + /// + /// Settlement is observed by polling [`Node::payment`] for the returned + /// [`PaymentId`]; no user-facing event is emitted for the loop. + /// + /// The raw [`ChannelManager`] is NOT exposed; this method is the sole entry + /// point for route-controlled self-pays. + /// + /// [`Route`]: lightning::routing::router::Route + /// [`PaymentKind::Rebalance`]: crate::payment::PaymentKind::Rebalance + /// [`ChannelManager`]: crate::types::ChannelManager + #[cfg(feature = "cycles")] + pub fn send_along_route( + &self, route: lightning::routing::router::Route, amount_msat: u64, + payment_hash: lightning_types::payment::PaymentHash, + preimage: lightning_types::payment::PaymentPreimage, + ) -> Result { + use lightning::ln::channelmanager::{RecipientOnionFields, RetryableSendFailure}; + + let rt_lock = self.runtime.read().unwrap(); + if rt_lock.is_none() { + return Err(Error::NotRunning); + } + + let payment_id = PaymentId(payment_hash.0); + + if let Some(existing) = self.payment_store.get(&payment_id) { + if existing.status == payment::PaymentStatus::Pending + || existing.status == payment::PaymentStatus::Succeeded + { + log_error!(self.logger, "Rebalance payment error: duplicate payment_id."); + return Err(Error::DuplicatePayment); + } + } + + let kind = payment::PaymentKind::Rebalance { hash: payment_hash, preimage }; + let payment_record = PaymentDetails::new( + payment_id, + kind, + Some(amount_msat), + None, + payment::PaymentDirection::Outbound, + payment::PaymentStatus::Pending, + ); + self.payment_store.insert(payment_record).map_err(|e| { + log_error!(self.logger, "Failed to insert rebalance payment record: {}", e); + e + })?; + + match self.channel_manager.send_payment_with_route( + route, + payment_hash, + RecipientOnionFields::spontaneous_empty(), + payment_id, + ) { + Ok(()) => { + log_info!( + self.logger, + "Initiated self-rebalance of {}msat (payment_id: {}).", + amount_msat, + payment_id, + ); + Ok(payment_id) + }, + Err(RetryableSendFailure::DuplicatePayment) => Err(Error::DuplicatePayment), + Err(e) => { + let update = payment::store::PaymentDetailsUpdate { + status: Some(payment::PaymentStatus::Failed), + ..payment::store::PaymentDetailsUpdate::new(payment_id) + }; + let _ = self.payment_store.update(&update); + log_error!( + self.logger, + "Self-rebalance send failed ({:?}) for payment_id {}.", + e, + payment_id, + ); + Err(Error::PaymentSendingFailed) + }, + } + } + /// Returns a payment handler allowing to send and receive on-chain payments. #[cfg(not(feature = "uniffi"))] pub fn onchain_payment(&self) -> OnchainPayment { diff --git a/src/payment/store.rs b/src/payment/store.rs index d2b92747a2..0de5cdc771 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -63,6 +63,15 @@ impl PaymentDetails { .as_secs(); Self { id, kind, amount_msat, fee_paid_msat, direction, status, latest_update_timestamp } } + + /// Returns `true` if this is a circular self-rebalance payment sent along a + /// caller-supplied route. + /// + /// Used by the `event.rs` `PaymentClaimable` handler to allow the self-loop to + /// settle instead of being refused as a circular payment. + pub(crate) fn is_rebalance(&self) -> bool { + matches!(self.kind, PaymentKind::Rebalance { .. }) + } } impl Writeable for PaymentDetails { @@ -587,6 +596,18 @@ pub enum PaymentKind { /// The pre-image used by the payment. preimage: Option, }, + /// A circular self-rebalance payment sent along a caller-supplied route. + /// + /// The sender generates the preimage locally, sends the payment over a pinned + /// route (out-channel A → intermediaries → in-channel B → self), and claims it on + /// receipt. The `event.rs` `PaymentClaimable` guard falls through for this kind so + /// the loop is allowed to settle — all other self-loops are still refused. + Rebalance { + /// The payment hash, i.e., the hash of the `preimage`. + hash: PaymentHash, + /// The pre-image used by the payment (held locally by the initiating node). + preimage: PaymentPreimage, + }, } impl_writeable_tlv_based_enum!(PaymentKind, @@ -629,6 +650,10 @@ impl_writeable_tlv_based_enum!(PaymentKind, (2, preimage, option), (3, quantity, option), (4, secret, option), + }, + (12, Rebalance) => { + (0, hash, required), + (2, preimage, required), } ); From 01d22257e85e7674663a5d257ae4c2f0ce7b0760 Mon Sep 17 00:00:00 2001 From: tonible14012002 Date: Fri, 3 Jul 2026 20:16:50 +0700 Subject: [PATCH 120/138] fix(cycles): make the rebalance loop actually receivable + pin the claim amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send_along_route sent the final onion with RecipientOnionFields:: spontaneous_empty() and no keysend TLV, so lightning's final-hop parser (create_recv_pending_htlc_info) failed every looped HTLC with "We require payment_secrets" BEFORE PaymentClaimable could fire — the patched claim path was unreachable and no cycle could ever settle (fail-safe, but feature-dead). Fix: register the hash with the ChannelManager's STATELESS inbound-payment verifier (create_inbound_payment_for_hash, min_value_msat = amount) and send secret_only(payment_secret). No payment-store record is created, so the scoped circular guard still sees only the single Outbound Rebalance record; the secret never leaves the onion we build, so nobody else can construct a claimable HTLC for the hash. event.rs: belt-and-braces amount pin in the Rebalance claim — never claim_funds (= reveal the preimage) for less than the recorded loop amount; fail the HTLC backwards instead. Co-Authored-By: Claude Fable 5 --- src/event.rs | 15 +++++++++++++++ src/lib.rs | 25 ++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/event.rs b/src/event.rs index f4aa476906..85c7288eb9 100644 --- a/src/event.rs +++ b/src/event.rs @@ -926,6 +926,21 @@ where // Rebalance record. Claim immediately without inserting a new // payment record (the outbound record already exists). if let PaymentKind::Rebalance { preimage, .. } = info.kind { + // Belt-and-braces amount pin: the stateless inbound registration + // already enforces `min_value_msat = amount` before this event can + // fire, but never reveal the preimage for less than the recorded + // loop amount. + if amount_msat < info.amount_msat.unwrap_or(0) { + log_error!( + self.logger, + "Refusing underpaying self-rebalance HTLC for payment hash {}: got {}msat, expected {}msat", + hex_utils::to_string(&payment_hash.0), + amount_msat, + info.amount_msat.unwrap_or(0), + ); + self.channel_manager.fail_htlc_backwards(&payment_hash); + return Ok(()); + } log_info!( self.logger, "Claiming self-rebalance loop for payment hash {} of {}msat", diff --git a/src/lib.rs b/src/lib.rs index 67bcc525cf..90b9fe1b66 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1198,6 +1198,29 @@ impl Node { } } + // Register `payment_hash` with the ChannelManager's STATELESS inbound-payment + // verifier so the looped HTLC is receivable at the final hop. Without this the + // final onion payload carries neither a payment secret nor a keysend preimage + // and LDK fails it with "We require payment_secrets" BEFORE any + // `PaymentClaimable` fires — the loop could never settle. This creates NO + // payment-store record (unlike `Bolt11Payment::receive_for_hash`), so the + // scoped circular guard still sees only our single Outbound Rebalance record. + // `min_value_msat = amount_msat` means an underpaying HTLC never even surfaces + // a claimable event (no proof-of-payment leak); the secret only ever travels + // inside the onion we build, so no third party can construct a claimable HTLC + // for this hash. + let payment_secret = self + .channel_manager + .create_inbound_payment_for_hash(payment_hash, Some(amount_msat), 3600, None) + .map_err(|()| { + log_error!( + self.logger, + "Failed to register rebalance inbound payment for payment_id {}.", + payment_id, + ); + Error::PaymentSendingFailed + })?; + let kind = payment::PaymentKind::Rebalance { hash: payment_hash, preimage }; let payment_record = PaymentDetails::new( payment_id, @@ -1215,7 +1238,7 @@ impl Node { match self.channel_manager.send_payment_with_route( route, payment_hash, - RecipientOnionFields::spontaneous_empty(), + RecipientOnionFields::secret_only(payment_secret), payment_id, ) { Ok(()) => { From 2e00412af081e5e5d73472ba58dc7fad93a40d8b Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Tue, 4 Aug 2026 15:08:12 +0700 Subject: [PATCH 121/138] style: apply rustfmt to previously unformatted files No logic changes; produced by the mandated `cargo fmt --all` run. AI disclosure: formatting applied during an AI-assisted session (Claude Code). Co-Authored-By: Claude Opus 5 (1M context) --- examples/custom_gossip_example.rs | 275 ++++++++-------- src/builder.rs | 4 +- src/custom_gossip.rs | 506 +++++++++++++++--------------- src/message_handler.rs | 7 +- src/payment/bolt11.rs | 25 +- 5 files changed, 412 insertions(+), 405 deletions(-) diff --git a/examples/custom_gossip_example.rs b/examples/custom_gossip_example.rs index 42fc96e271..27f444139e 100644 --- a/examples/custom_gossip_example.rs +++ b/examples/custom_gossip_example.rs @@ -1,154 +1,161 @@ // Example demonstrating how to use custom gossip metadata in LDK Node -use ldk_node::{Builder, Event, Node}; use ldk_node::bitcoin::secp256k1::PublicKey; use ldk_node::bitcoin::Network; +use ldk_node::{Builder, Event, Node}; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; fn main() -> Result<(), Box> { - // Create and configure a node with custom gossip enabled - let mut builder = Builder::new(); - builder.set_network(Network::Testnet); - builder.set_chain_source_esplora("https://blockstream.info/testnet/api".to_string(), None); - builder.set_gossip_source_rgs("https://rapidsync.lightningdevkit.org/testnet/snapshot".to_string()); - - // Enable custom gossip functionality - builder.enable_custom_gossip(); - - let node = builder.build()?; - - // Start the node - node.start()?; - - // Get the custom gossip handler - if let Some(custom_gossip) = node.custom_gossip() { - println!("Custom gossip handler is available!"); - - // Set our own metadata to advertise - let our_metadata = serde_json::json!({ - "version": "1.0", - "features": ["feature_a", "feature_b"], - "timestamp": chrono::Utc::now().timestamp(), - "description": "LDK Node with custom features" - }).to_string().into_bytes(); - - custom_gossip.set_our_metadata(our_metadata); - - // Example: Send custom metadata to a specific peer - // (This would typically be done after connecting to a peer) - let peer_metadata = serde_json::json!({ - "message": "Hello from custom gossip!", - "data": { - "custom_field": "custom_value" - } - }).to_string().into_bytes(); - - // In a real scenario, you'd have connected peers - // custom_gossip.send_metadata_to_peer(peer_node_id, peer_metadata); - - // Example: Get stored metadata for all nodes - let all_metadata = custom_gossip.get_all_metadata().clone(); - println!("Currently have metadata for {} nodes", all_metadata.len()); - - // Print metadata information - for (node_id, metadata) in all_metadata.clone() { - println!("Node {}: {} bytes of metadata", node_id, metadata.metadata.len()); - - // Try to parse as JSON - if let Ok(json_str) = String::from_utf8(metadata.metadata.clone()) { - if let Ok(json_value) = serde_json::from_str::(&json_str) { - println!(" Parsed JSON: {}", json_value); - } - } - } - - // Demonstrate event handling with custom gossip - println!("Monitoring for custom gossip events..."); - - // In a real application, you would handle events in a loop - // This is just a demonstration - for _ in 0..5 { - // Wait for events (timeout after 1 second) - std::thread::sleep(Duration::from_secs(1)); - - // In a real application, you would process events like this: - // match node.wait_next_event() { - // Event::... => { - // // Handle other events - // } - // // Custom gossip events would be handled through the custom_gossip handler - // // as they are processed automatically when messages are received - // } - } - - // Example: Check if we received any new metadata - let updated_metadata = custom_gossip.get_all_metadata(); - if updated_metadata.len() > all_metadata.clone().len() { - println!("Received new metadata from {} nodes", - updated_metadata.len() - all_metadata.len()); - } - - } else { - println!("Custom gossip not enabled. Use builder.enable_custom_gossip() to enable it."); - } - - // Stop the node - node.stop()?; - - peer_to_peer_example()?; - - Ok(()) + // Create and configure a node with custom gossip enabled + let mut builder = Builder::new(); + builder.set_network(Network::Testnet); + builder.set_chain_source_esplora("https://blockstream.info/testnet/api".to_string(), None); + builder.set_gossip_source_rgs( + "https://rapidsync.lightningdevkit.org/testnet/snapshot".to_string(), + ); + + // Enable custom gossip functionality + builder.enable_custom_gossip(); + + let node = builder.build()?; + + // Start the node + node.start()?; + + // Get the custom gossip handler + if let Some(custom_gossip) = node.custom_gossip() { + println!("Custom gossip handler is available!"); + + // Set our own metadata to advertise + let our_metadata = serde_json::json!({ + "version": "1.0", + "features": ["feature_a", "feature_b"], + "timestamp": chrono::Utc::now().timestamp(), + "description": "LDK Node with custom features" + }) + .to_string() + .into_bytes(); + + custom_gossip.set_our_metadata(our_metadata); + + // Example: Send custom metadata to a specific peer + // (This would typically be done after connecting to a peer) + let peer_metadata = serde_json::json!({ + "message": "Hello from custom gossip!", + "data": { + "custom_field": "custom_value" + } + }) + .to_string() + .into_bytes(); + + // In a real scenario, you'd have connected peers + // custom_gossip.send_metadata_to_peer(peer_node_id, peer_metadata); + + // Example: Get stored metadata for all nodes + let all_metadata = custom_gossip.get_all_metadata().clone(); + println!("Currently have metadata for {} nodes", all_metadata.len()); + + // Print metadata information + for (node_id, metadata) in all_metadata.clone() { + println!("Node {}: {} bytes of metadata", node_id, metadata.metadata.len()); + + // Try to parse as JSON + if let Ok(json_str) = String::from_utf8(metadata.metadata.clone()) { + if let Ok(json_value) = serde_json::from_str::(&json_str) { + println!(" Parsed JSON: {}", json_value); + } + } + } + + // Demonstrate event handling with custom gossip + println!("Monitoring for custom gossip events..."); + + // In a real application, you would handle events in a loop + // This is just a demonstration + for _ in 0..5 { + // Wait for events (timeout after 1 second) + std::thread::sleep(Duration::from_secs(1)); + + // In a real application, you would process events like this: + // match node.wait_next_event() { + // Event::... => { + // // Handle other events + // } + // // Custom gossip events would be handled through the custom_gossip handler + // // as they are processed automatically when messages are received + // } + } + + // Example: Check if we received any new metadata + let updated_metadata = custom_gossip.get_all_metadata(); + if updated_metadata.len() > all_metadata.clone().len() { + println!( + "Received new metadata from {} nodes", + updated_metadata.len() - all_metadata.len() + ); + } + } else { + println!("Custom gossip not enabled. Use builder.enable_custom_gossip() to enable it."); + } + + // Stop the node + node.stop()?; + + peer_to_peer_example()?; + + Ok(()) } /// Example of how to integrate custom gossip in a peer-to-peer scenario #[allow(dead_code)] fn peer_to_peer_example() -> Result<(), Box> { - // Create two nodes for demonstration - let mut builder1 = Builder::new(); - builder1.set_network(Network::Regtest); - builder1.enable_custom_gossip(); - let node1 = builder1.build()?; - - let mut builder2 = Builder::new(); - builder2.set_network(Network::Regtest); - builder2.enable_custom_gossip(); - let node2 = builder2.build()?; - - // Start both nodes - node1.start()?; - node2.start()?; - - // Get custom gossip handlers - let gossip1 = node1.custom_gossip().unwrap(); - let gossip2 = node2.custom_gossip().unwrap(); - - // Set metadata for each node - let metadata1 = b"Node 1 custom data".to_vec(); - let metadata2 = b"Node 2 custom data".to_vec(); - - gossip1.set_our_metadata(metadata1); - gossip2.set_our_metadata(metadata2); - - // In a real scenario, you would: - // 1. Connect the nodes to each other - // 2. Custom metadata would be automatically exchanged when peers connect - // 3. Monitor the get_all_metadata() results to see received data - - println!("Peer-to-peer custom gossip example completed"); - - // Stop nodes - node1.stop()?; - node2.stop()?; - - Ok(()) + // Create two nodes for demonstration + let mut builder1 = Builder::new(); + builder1.set_network(Network::Regtest); + builder1.enable_custom_gossip(); + let node1 = builder1.build()?; + + let mut builder2 = Builder::new(); + builder2.set_network(Network::Regtest); + builder2.enable_custom_gossip(); + let node2 = builder2.build()?; + + // Start both nodes + node1.start()?; + node2.start()?; + + // Get custom gossip handlers + let gossip1 = node1.custom_gossip().unwrap(); + let gossip2 = node2.custom_gossip().unwrap(); + + // Set metadata for each node + let metadata1 = b"Node 1 custom data".to_vec(); + let metadata2 = b"Node 2 custom data".to_vec(); + + gossip1.set_our_metadata(metadata1); + gossip2.set_our_metadata(metadata2); + + // In a real scenario, you would: + // 1. Connect the nodes to each other + // 2. Custom metadata would be automatically exchanged when peers connect + // 3. Monitor the get_all_metadata() results to see received data + + println!("Peer-to-peer custom gossip example completed"); + + // Stop nodes + node1.stop()?; + node2.stop()?; + + Ok(()) } /// Example showing custom feature flags (placeholder for future implementation) #[allow(dead_code)] fn custom_features_example() { - println!("Custom feature flags would be implemented in the provided_node_features() method"); - println!("This allows advertising custom capabilities to peers during connection"); -} \ No newline at end of file + println!("Custom feature flags would be implemented in the provided_node_features() method"); + println!("This allows advertising custom capabilities to peers during connection"); +} diff --git a/src/builder.rs b/src/builder.rs index 7d8998353a..d5fde14d76 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -547,7 +547,7 @@ impl NodeBuilder { /// /// When enabled, the node will be able to send and receive custom gossip messages /// containing metadata extensions to the standard Lightning gossip protocol. - /// + /// /// Custom gossip messages use message type 32769 and can contain arbitrary metadata /// up to 4096 bytes in length. pub fn enable_custom_gossip(&mut self) -> &mut Self { @@ -1152,7 +1152,7 @@ impl ArcedNodeBuilder { /// /// When enabled, the node will be able to send and receive custom gossip messages /// containing metadata extensions to the standard Lightning gossip protocol. - /// + /// /// Custom gossip messages use message type 32769 and can contain arbitrary metadata /// up to 4096 bytes in length. pub fn enable_custom_gossip(&self) { diff --git a/src/custom_gossip.rs b/src/custom_gossip.rs index 7b449fc9f4..a59f01943a 100644 --- a/src/custom_gossip.rs +++ b/src/custom_gossip.rs @@ -30,298 +30,298 @@ pub const CUSTOM_GOSSIP_MESSAGE_TYPE: u16 = 32769; // Odd number in custom range /// Custom gossip message containing metadata extensions #[derive(Clone, Debug, PartialEq, Eq)] pub struct CustomGossipMessage { - /// The metadata payload - pub metadata: Vec, + /// The metadata payload + pub metadata: Vec, } impl CustomGossipMessage { - /// Create a new custom gossip message with the given metadata - pub fn new(metadata: Vec) -> Self { - Self { metadata } - } - - /// Get the metadata payload - pub fn metadata(&self) -> &[u8] { - &self.metadata - } + /// Create a new custom gossip message with the given metadata + pub fn new(metadata: Vec) -> Self { + Self { metadata } + } + + /// Get the metadata payload + pub fn metadata(&self) -> &[u8] { + &self.metadata + } } impl Type for CustomGossipMessage { - fn type_id(&self) -> u16 { - CUSTOM_GOSSIP_MESSAGE_TYPE - } + fn type_id(&self) -> u16 { + CUSTOM_GOSSIP_MESSAGE_TYPE + } } impl Writeable for CustomGossipMessage { - fn write(&self, writer: &mut W) -> Result<(), io::Error> { - // Write length prefix (u16) followed by the metadata - (self.metadata.len() as u16).write(writer)?; - writer.write_all(&self.metadata) - } + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + // Write length prefix (u16) followed by the metadata + (self.metadata.len() as u16).write(writer)?; + writer.write_all(&self.metadata) + } } impl Readable for CustomGossipMessage { - fn read(reader: &mut R) -> Result { - let length = ::read(reader)? as usize; - - // Limit metadata size to prevent DoS attacks - if length > 4096 { - return Err(lightning::ln::msgs::DecodeError::InvalidValue); - } - - let mut metadata = vec![0u8; length]; - reader.read_exact(&mut metadata).map_err(|_| { - lightning::ln::msgs::DecodeError::ShortRead - })?; - - Ok(Self { metadata }) - } + fn read(reader: &mut R) -> Result { + let length = ::read(reader)? as usize; + + // Limit metadata size to prevent DoS attacks + if length > 4096 { + return Err(lightning::ln::msgs::DecodeError::InvalidValue); + } + + let mut metadata = vec![0u8; length]; + reader + .read_exact(&mut metadata) + .map_err(|_| lightning::ln::msgs::DecodeError::ShortRead)?; + + Ok(Self { metadata }) + } } /// Metadata entry for a node #[derive(Clone, Debug)] pub struct NodeMetadata { - /// Node's public key - pub node_id: PublicKey, - /// Custom metadata payload - pub metadata: Vec, - /// Timestamp when metadata was received - pub timestamp: u32, + /// Node's public key + pub node_id: PublicKey, + /// Custom metadata payload + pub metadata: Vec, + /// Timestamp when metadata was received + pub timestamp: u32, } /// Handler for custom gossip messages pub struct CustomGossipMessageHandler where - L::Target: LightningLogger, + L::Target: LightningLogger, { - /// Logger instance - logger: L, - /// Store for node metadata - node_metadata: Arc>>, - /// Pending messages to send - pending_messages: Arc>>, - /// Our own metadata to advertise - our_metadata: Arc>>>, + /// Logger instance + logger: L, + /// Store for node metadata + node_metadata: Arc>>, + /// Pending messages to send + pending_messages: Arc>>, + /// Our own metadata to advertise + our_metadata: Arc>>>, } impl CustomGossipMessageHandler where - L::Target: LightningLogger, + L::Target: LightningLogger, { - /// Create a new custom gossip message handler - pub fn new(logger: L) -> Self { - Self { - logger, - node_metadata: Arc::new(Mutex::new(HashMap::new())), - pending_messages: Arc::new(Mutex::new(Vec::new())), - our_metadata: Arc::new(Mutex::new(None)), - } - } - - /// Set our own metadata to advertise to peers - pub fn set_our_metadata(&self, metadata: Vec) { - let mut our_metadata = self.our_metadata.lock().unwrap(); - *our_metadata = Some(metadata); - } - - /// Get OUR OWN advertised metadata blob (the one broadcast to peers), if set. - /// Distinct from [`get_all_metadata`], which returns PEERS' received blobs and - /// NEVER our own — so this is the only way for the owning node to read back what - /// it is currently advertising (needed for a correct read-merge-write of our blob). - pub fn get_our_metadata(&self) -> Option> { - self.our_metadata.lock().unwrap().clone() - } - - /// Get metadata for a specific node - pub fn get_node_metadata(&self, node_id: &PublicKey) -> Option { - let metadata_store = self.node_metadata.lock().unwrap(); - metadata_store.get(node_id).cloned() - } - - /// Get all stored node metadata - pub fn get_all_metadata(&self) -> HashMap { - let metadata_store = self.node_metadata.lock().unwrap(); - metadata_store.clone() - } - - /// Send custom metadata to a specific peer - pub fn send_metadata_to_peer(&self, peer_node_id: PublicKey, metadata: Vec) { - let message = CustomGossipMessage::new(metadata); - let mut pending = self.pending_messages.lock().unwrap(); - pending.push((peer_node_id, message)); - } - - /// Broadcast our metadata to all peers - pub fn broadcast_our_metadata(&self, peer_node_ids: Vec) { - let our_metadata = self.our_metadata.lock().unwrap(); - if let Some(ref metadata) = *our_metadata { - let message = CustomGossipMessage::new(metadata.clone()); - let mut pending = self.pending_messages.lock().unwrap(); - - for node_id in peer_node_ids { - pending.push((node_id, message.clone())); - } - } - } - - /// Handle received custom gossip message - fn handle_gossip_message(&self, msg: &CustomGossipMessage, sender_node_id: PublicKey) { - log_debug!( - self.logger, - "Received custom gossip metadata from {}: {} bytes", - sender_node_id, - msg.metadata.len() - ); - - let metadata_entry = NodeMetadata { - node_id: sender_node_id, - metadata: msg.metadata.clone(), - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as u32, - }; - - let mut metadata_store = self.node_metadata.lock().unwrap(); - metadata_store.insert(sender_node_id, metadata_entry); - - log_trace!( - self.logger, - "Stored metadata for node {}, total nodes: {}", - sender_node_id, - metadata_store.len() - ); - } + /// Create a new custom gossip message handler + pub fn new(logger: L) -> Self { + Self { + logger, + node_metadata: Arc::new(Mutex::new(HashMap::new())), + pending_messages: Arc::new(Mutex::new(Vec::new())), + our_metadata: Arc::new(Mutex::new(None)), + } + } + + /// Set our own metadata to advertise to peers + pub fn set_our_metadata(&self, metadata: Vec) { + let mut our_metadata = self.our_metadata.lock().unwrap(); + *our_metadata = Some(metadata); + } + + /// Get OUR OWN advertised metadata blob (the one broadcast to peers), if set. + /// Distinct from [`get_all_metadata`], which returns PEERS' received blobs and + /// NEVER our own — so this is the only way for the owning node to read back what + /// it is currently advertising (needed for a correct read-merge-write of our blob). + pub fn get_our_metadata(&self) -> Option> { + self.our_metadata.lock().unwrap().clone() + } + + /// Get metadata for a specific node + pub fn get_node_metadata(&self, node_id: &PublicKey) -> Option { + let metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.get(node_id).cloned() + } + + /// Get all stored node metadata + pub fn get_all_metadata(&self) -> HashMap { + let metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.clone() + } + + /// Send custom metadata to a specific peer + pub fn send_metadata_to_peer(&self, peer_node_id: PublicKey, metadata: Vec) { + let message = CustomGossipMessage::new(metadata); + let mut pending = self.pending_messages.lock().unwrap(); + pending.push((peer_node_id, message)); + } + + /// Broadcast our metadata to all peers + pub fn broadcast_our_metadata(&self, peer_node_ids: Vec) { + let our_metadata = self.our_metadata.lock().unwrap(); + if let Some(ref metadata) = *our_metadata { + let message = CustomGossipMessage::new(metadata.clone()); + let mut pending = self.pending_messages.lock().unwrap(); + + for node_id in peer_node_ids { + pending.push((node_id, message.clone())); + } + } + } + + /// Handle received custom gossip message + fn handle_gossip_message(&self, msg: &CustomGossipMessage, sender_node_id: PublicKey) { + log_debug!( + self.logger, + "Received custom gossip metadata from {}: {} bytes", + sender_node_id, + msg.metadata.len() + ); + + let metadata_entry = NodeMetadata { + node_id: sender_node_id, + metadata: msg.metadata.clone(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as u32, + }; + + let mut metadata_store = self.node_metadata.lock().unwrap(); + metadata_store.insert(sender_node_id, metadata_entry); + + log_trace!( + self.logger, + "Stored metadata for node {}, total nodes: {}", + sender_node_id, + metadata_store.len() + ); + } } impl CustomMessageReader for CustomGossipMessageHandler where - L::Target: LightningLogger, + L::Target: LightningLogger, { - type CustomMessage = CustomGossipMessage; - - fn read( - &self, message_type: u16, buffer: &mut RD, - ) -> Result, lightning::ln::msgs::DecodeError> { - if message_type == CUSTOM_GOSSIP_MESSAGE_TYPE { - log_trace!(self.logger, "Reading custom gossip message type {}", message_type); - Ok(Some(CustomGossipMessage::read(buffer)?)) - } else { - Ok(None) - } - } + type CustomMessage = CustomGossipMessage; + + fn read( + &self, message_type: u16, buffer: &mut RD, + ) -> Result, lightning::ln::msgs::DecodeError> { + if message_type == CUSTOM_GOSSIP_MESSAGE_TYPE { + log_trace!(self.logger, "Reading custom gossip message type {}", message_type); + Ok(Some(CustomGossipMessage::read(buffer)?)) + } else { + Ok(None) + } + } } impl CustomMessageHandler for CustomGossipMessageHandler where - L::Target: LightningLogger, + L::Target: LightningLogger, { - fn handle_custom_message( - &self, msg: Self::CustomMessage, sender_node_id: PublicKey, - ) -> Result<(), LightningError> { - self.handle_gossip_message(&msg, sender_node_id); - Ok(()) - } - - fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Self::CustomMessage)> { - let mut pending = self.pending_messages.lock().unwrap(); - std::mem::take(&mut *pending) - } - - fn provided_node_features(&self) -> NodeFeatures { - // Advertise that we support custom gossip messages - // You can extend this to include specific feature flags - NodeFeatures::empty() - } - - fn provided_init_features(&self, _their_node_id: PublicKey) -> InitFeatures { - // Advertise init features for custom gossip support - InitFeatures::empty() - } - - fn peer_connected( - &self, their_node_id: PublicKey, _msg: &lightning::ln::msgs::Init, _inbound: bool, - ) -> Result<(), ()> { - log_debug!(self.logger, "Peer {} connected, will broadcast our metadata", their_node_id); - - // Optionally broadcast our metadata when a peer connects - let our_metadata = self.our_metadata.lock().unwrap(); - if let Some(ref metadata) = *our_metadata { - let message = CustomGossipMessage::new(metadata.clone()); - let mut pending = self.pending_messages.lock().unwrap(); - pending.push((their_node_id, message)); - } - - Ok(()) - } - - fn peer_disconnected(&self, their_node_id: PublicKey) { - log_debug!(self.logger, "Peer {} disconnected", their_node_id); - // Optionally clean up metadata for disconnected peers - } + fn handle_custom_message( + &self, msg: Self::CustomMessage, sender_node_id: PublicKey, + ) -> Result<(), LightningError> { + self.handle_gossip_message(&msg, sender_node_id); + Ok(()) + } + + fn get_and_clear_pending_msg(&self) -> Vec<(PublicKey, Self::CustomMessage)> { + let mut pending = self.pending_messages.lock().unwrap(); + std::mem::take(&mut *pending) + } + + fn provided_node_features(&self) -> NodeFeatures { + // Advertise that we support custom gossip messages + // You can extend this to include specific feature flags + NodeFeatures::empty() + } + + fn provided_init_features(&self, _their_node_id: PublicKey) -> InitFeatures { + // Advertise init features for custom gossip support + InitFeatures::empty() + } + + fn peer_connected( + &self, their_node_id: PublicKey, _msg: &lightning::ln::msgs::Init, _inbound: bool, + ) -> Result<(), ()> { + log_debug!(self.logger, "Peer {} connected, will broadcast our metadata", their_node_id); + + // Optionally broadcast our metadata when a peer connects + let our_metadata = self.our_metadata.lock().unwrap(); + if let Some(ref metadata) = *our_metadata { + let message = CustomGossipMessage::new(metadata.clone()); + let mut pending = self.pending_messages.lock().unwrap(); + pending.push((their_node_id, message)); + } + + Ok(()) + } + + fn peer_disconnected(&self, their_node_id: PublicKey) { + log_debug!(self.logger, "Peer {} disconnected", their_node_id); + // Optionally clean up metadata for disconnected peers + } } #[cfg(test)] mod tests { - use super::*; - use bitcoin::secp256k1::{Secp256k1, SecretKey}; - use lightning::util::test_utils::TestLogger; - use lightning::util::ser::{Readable, Writeable}; - use std::io::Cursor; - - #[test] - fn test_custom_gossip_message_serialization() { - let metadata = b"custom_metadata_payload".to_vec(); - let msg = CustomGossipMessage::new(metadata.clone()); - - assert_eq!(msg.metadata(), &metadata); - assert_eq!(msg.type_id(), CUSTOM_GOSSIP_MESSAGE_TYPE); - - // Test serialization - let mut buffer = Vec::new(); - msg.write(&mut buffer).unwrap(); - - // Test deserialization - let mut cursor = Cursor::new(buffer); - let deserialized = CustomGossipMessage::read(&mut cursor).unwrap(); - - assert_eq!(msg, deserialized); - } - - #[test] - fn test_custom_gossip_handler() { - let logger = Arc::new(TestLogger::new()); - let handler = CustomGossipMessageHandler::new(logger); - - // Test setting our metadata - let our_metadata = b"our_node_metadata".to_vec(); - handler.set_our_metadata(our_metadata.clone()); - - // Test handling a message - let secp_ctx = Secp256k1::new(); - let secret_key = SecretKey::from_slice(&[1; 32]).unwrap(); - let sender_node_id = PublicKey::from_secret_key(&secp_ctx, &secret_key); - - let msg = CustomGossipMessage::new(b"peer_metadata".to_vec()); - handler.handle_custom_message(msg, sender_node_id).unwrap(); - - // Verify metadata was stored - let stored_metadata = handler.get_node_metadata(&sender_node_id).unwrap(); - assert_eq!(stored_metadata.metadata, b"peer_metadata"); - assert_eq!(stored_metadata.node_id, sender_node_id); - } - - #[test] - fn test_message_size_limit() { - let large_metadata = vec![0u8; 5000]; // Exceeds 4096 byte limit - let msg = CustomGossipMessage::new(large_metadata); - - let mut buffer = Vec::new(); - msg.write(&mut buffer).unwrap(); - - let mut cursor = Cursor::new(buffer); - let result = CustomGossipMessage::read(&mut cursor); - - assert!(result.is_err()); - } -} \ No newline at end of file + use super::*; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use lightning::util::ser::{Readable, Writeable}; + use lightning::util::test_utils::TestLogger; + use std::io::Cursor; + + #[test] + fn test_custom_gossip_message_serialization() { + let metadata = b"custom_metadata_payload".to_vec(); + let msg = CustomGossipMessage::new(metadata.clone()); + + assert_eq!(msg.metadata(), &metadata); + assert_eq!(msg.type_id(), CUSTOM_GOSSIP_MESSAGE_TYPE); + + // Test serialization + let mut buffer = Vec::new(); + msg.write(&mut buffer).unwrap(); + + // Test deserialization + let mut cursor = Cursor::new(buffer); + let deserialized = CustomGossipMessage::read(&mut cursor).unwrap(); + + assert_eq!(msg, deserialized); + } + + #[test] + fn test_custom_gossip_handler() { + let logger = Arc::new(TestLogger::new()); + let handler = CustomGossipMessageHandler::new(logger); + + // Test setting our metadata + let our_metadata = b"our_node_metadata".to_vec(); + handler.set_our_metadata(our_metadata.clone()); + + // Test handling a message + let secp_ctx = Secp256k1::new(); + let secret_key = SecretKey::from_slice(&[1; 32]).unwrap(); + let sender_node_id = PublicKey::from_secret_key(&secp_ctx, &secret_key); + + let msg = CustomGossipMessage::new(b"peer_metadata".to_vec()); + handler.handle_custom_message(msg, sender_node_id).unwrap(); + + // Verify metadata was stored + let stored_metadata = handler.get_node_metadata(&sender_node_id).unwrap(); + assert_eq!(stored_metadata.metadata, b"peer_metadata"); + assert_eq!(stored_metadata.node_id, sender_node_id); + } + + #[test] + fn test_message_size_limit() { + let large_metadata = vec![0u8; 5000]; // Exceeds 4096 byte limit + let msg = CustomGossipMessage::new(large_metadata); + + let mut buffer = Vec::new(); + msg.write(&mut buffer).unwrap(); + + let mut cursor = Cursor::new(buffer); + let result = CustomGossipMessage::read(&mut cursor); + + assert!(result.is_err()); + } +} diff --git a/src/message_handler.rs b/src/message_handler.rs index 56096cbac3..48049e751f 100644 --- a/src/message_handler.rs +++ b/src/message_handler.rs @@ -268,8 +268,11 @@ where }, Self::Combined { liquidity_source, gossip_handler } => { // Notify both handlers - let _ = - liquidity_source.liquidity_manager().peer_connected(their_node_id, msg, inbound); + let _ = liquidity_source.liquidity_manager().peer_connected( + their_node_id, + msg, + inbound, + ); gossip_handler.peer_connected(their_node_id, msg, inbound) }, } diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 1f51bb748c..8654ef4abd 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -17,8 +17,7 @@ use bitcoin::hashes::Hash; use bitcoin::secp256k1::Secp256k1; use lightning::impl_writeable_tlv_based; use lightning::ln::channelmanager::{ - Bolt11InvoiceParameters, OptionalBolt11PaymentParams, PaymentId, - MIN_FINAL_CLTV_EXPIRY_DELTA, + Bolt11InvoiceParameters, OptionalBolt11PaymentParams, PaymentId, MIN_FINAL_CLTV_EXPIRY_DELTA, }; use lightning::ln::outbound_payment::{Bolt11PaymentError, Retry, RetryableSendFailure}; use lightning::routing::router::{ @@ -45,7 +44,6 @@ use crate::peer_store::{PeerInfo, PeerStore}; use crate::runtime::Runtime; use crate::types::{ChannelManager, KeysManager, PaymentStore}; - #[cfg(not(feature = "uniffi"))] type Bolt11Invoice = LdkBolt11Invoice; #[cfg(feature = "uniffi")] @@ -89,8 +87,7 @@ pub struct Bolt11Payment { impl Bolt11Payment { pub(crate) fn new( runtime: Arc, channel_manager: Arc, - connection_manager: Arc>>, - keys_manager: Arc, + connection_manager: Arc>>, keys_manager: Arc, liquidity_source: Arc>>, payment_store: Arc, peer_store: Arc>>, config: Arc, is_running: Arc>, logger: Arc, @@ -678,8 +675,13 @@ impl Bolt11Payment { route_hints: Vec, ) -> Result { let description = maybe_try_convert_enum(description)?; - let invoice = - self.receive_with_hints_inner(Some(amount_msat), &description, expiry_secs, None, route_hints)?; + let invoice = self.receive_with_hints_inner( + Some(amount_msat), + &description, + expiry_secs, + None, + route_hints, + )?; Ok(maybe_wrap(invoice)) } @@ -722,11 +724,7 @@ impl Bolt11Payment { None, ) .map_err(|e| { - log_error!( - self.logger, - "Failed to register inbound payment for hash: {:?}", - e - ); + log_error!(self.logger, "Failed to register inbound payment for hash: {:?}", e); Error::InvoiceCreationFailed })?; (manual_hash, secret, metadata) @@ -758,8 +756,7 @@ impl Bolt11Payment { } if let Some(amount_msat) = amount_msat { - invoice_builder = - invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); + invoice_builder = invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); } let invoice = invoice_builder From 891e8c66aa61ed3e165b5e4906c718c86d820d06 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Tue, 4 Aug 2026 15:08:32 +0700 Subject: [PATCH 122/138] fix(cycles): adapt manual rebalance path to current LDK APIs Track upstream signature changes: RecipientOnionFields moved to ln::outbound_payment and secret_only() now takes the amount; create_inbound_payment_for_hash() gained a payment-metadata parameter and returns a tuple; the payment store is async (block_on at this sync call site); running state is checked via is_running instead of the runtime slot. AI disclosure: change verified and committed in an AI-assisted session (Claude Code). Co-Authored-By: Claude Opus 5 (1M context) --- src/lib.rs | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 90b9fe1b66..d68fd393dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -150,10 +150,6 @@ pub use bitcoin::psbt::Psbt; /// Swap keypair type returned by [`Node::derive_swap_keypair`] (B7). #[cfg(feature = "swaps")] pub use bitcoin::secp256k1::Keypair as SwapKeypair; -/// Confirmed wallet UTXO type returned by [`Node::swap_list_confirmed_utxos`] -/// (B2). -#[cfg(feature = "swaps")] -pub use lightning::util::wallet_utils::Utxo; #[cfg(feature = "uniffi")] pub use bitcoin::FeeRate; use bitcoin::{Address, Amount, BlockHash, Network}; @@ -194,6 +190,10 @@ use lightning::ln::peer_handler::CustomMessageHandler; use lightning::routing::gossip::NodeAlias; use lightning::sign::EntropySource; use lightning::util::persist::KVStore; +/// Confirmed wallet UTXO type returned by [`Node::swap_list_confirmed_utxos`] +/// (B2). +#[cfg(feature = "swaps")] +pub use lightning::util::wallet_utils::Utxo; use lightning::util::wallet_utils::{Input, Wallet as LdkWallet}; use lightning_background_processor::process_events_async; pub use lightning_invoice; @@ -1180,10 +1180,9 @@ impl Node { payment_hash: lightning_types::payment::PaymentHash, preimage: lightning_types::payment::PaymentPreimage, ) -> Result { - use lightning::ln::channelmanager::{RecipientOnionFields, RetryableSendFailure}; + use lightning::ln::outbound_payment::{RecipientOnionFields, RetryableSendFailure}; - let rt_lock = self.runtime.read().unwrap(); - if rt_lock.is_none() { + if !*self.is_running.read().unwrap() { return Err(Error::NotRunning); } @@ -1209,9 +1208,9 @@ impl Node { // a claimable event (no proof-of-payment leak); the secret only ever travels // inside the onion we build, so no third party can construct a claimable HTLC // for this hash. - let payment_secret = self + let (payment_secret, _payment_metadata) = self .channel_manager - .create_inbound_payment_for_hash(payment_hash, Some(amount_msat), 3600, None) + .create_inbound_payment_for_hash(payment_hash, Some(amount_msat), 3600, None, None) .map_err(|()| { log_error!( self.logger, @@ -1230,7 +1229,7 @@ impl Node { payment::PaymentDirection::Outbound, payment::PaymentStatus::Pending, ); - self.payment_store.insert(payment_record).map_err(|e| { + self.runtime.block_on(self.payment_store.insert(payment_record)).map_err(|e| { log_error!(self.logger, "Failed to insert rebalance payment record: {}", e); e })?; @@ -1238,7 +1237,7 @@ impl Node { match self.channel_manager.send_payment_with_route( route, payment_hash, - RecipientOnionFields::secret_only(payment_secret), + RecipientOnionFields::secret_only(payment_secret, amount_msat), payment_id, ) { Ok(()) => { @@ -1256,7 +1255,7 @@ impl Node { status: Some(payment::PaymentStatus::Failed), ..payment::store::PaymentDetailsUpdate::new(payment_id) }; - let _ = self.payment_store.update(&update); + let _ = self.runtime.block_on(self.payment_store.update(update)); log_error!( self.logger, "Self-rebalance send failed ({:?}) for payment_id {}.", From f1e0740be6d18cf266d4d4647f1126012f960073 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Tue, 4 Aug 2026 15:08:32 +0700 Subject: [PATCH 123/138] fix(chain): crash-safe CBF block delivery + batched chain persistence P0 (crash safety): ChainListener now classifies every incoming block against each listener's BlockLocator (Deliver / AlreadyApplied / Diverged). Replayed blocks after a restart are skipped per listener instead of tripping ChannelManager's chain-order assert; a genuinely diverged chain latches an error that fails the CBF sync loop loudly instead of panicking or silently advancing monitors on a stale fork. The on-chain wallet stays ungated because BDK expects reconnection from the point of disagreement. The CBF chain-op queue is now bounded (depth 64) with backpressure on the kyoto event side. P1 (write amplification): the wallet persister can defer local_chain writes (bulk mode) while tx_graph/indexer keep writing through synchronously, since only local_chain grows quadratically and it is reconstructible. The CBF applicator enables bulk mode for the life of the sync loop and flushes chain state every 2016 blocks, on divergence, and on shutdown. On a crash the wallet resumes at most one flush interval back; no funds data is lost. Adds 8 unit tests covering the classifier (replay, fork at same height, ancestor replay, gap) and persister deferral/flush/failure-retry paths. AI disclosure: implemented with AI assistance (Claude Code), reviewed in two adversarial rounds by Codex; classifier hash-awareness fix came out of round 1. Co-Authored-By: Claude Opus 5 (1M context) --- src/chain/bitcoind.rs | 297 +++++++++++++++++++++++++++++++++++++++--- src/chain/cbf.rs | 137 +++++++++++++++++-- src/chain/mod.rs | 30 ++--- src/wallet/mod.rs | 20 ++- src/wallet/persist.rs | 80 ++++++++++-- 5 files changed, 501 insertions(+), 63 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index a257e598de..534a95d064 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -425,6 +425,8 @@ impl BitcoindChainSource { channel_manager: Arc::clone(&channel_manager), chain_monitor: Arc::clone(&chain_monitor), output_sweeper, + logger: Arc::clone(&self.logger), + divergence: Arc::new(Mutex::new(None)), }; let mut spv_client = SpvClient::new(chain_tip, chain_poller, HeaderCache::new(), &chain_listener); @@ -1016,9 +1018,7 @@ impl BitcoindClient { /// - `Ok(None)` when the node does not know the tx (RPC error code -5), /// - `Err(..)` for any transport/other failure, so the caller fails closed. #[cfg(feature = "swaps")] - pub(crate) async fn swap_tx_confirmations( - &self, txid: &Txid, - ) -> std::io::Result> { + pub(crate) async fn swap_tx_confirmations(&self, txid: &Txid) -> std::io::Result> { let rpc_client = match self { BitcoindClient::Rpc { rpc_client, .. } => Arc::clone(rpc_client), BitcoindClient::Rest { rpc_client, .. } => Arc::clone(rpc_client), @@ -1541,9 +1541,81 @@ pub(crate) struct ChainListener { pub(crate) channel_manager: Arc, pub(crate) chain_monitor: Arc, pub(crate) output_sweeper: Arc, + pub(crate) logger: Arc, + /// Records the first listener divergence seen since the last drain. + /// + /// `Listen` returns `()`, so divergence cannot be propagated through the trait. The chain + /// source drains this after each block and must stop advancing when it is set: continuing + /// would publish a "synced" tip while a listener sits on a stale chain. + pub(crate) divergence: Arc>>, +} + +/// Whether a listener should be handed a given block. +/// +/// `ChannelManager` and `OutputSweeper` enforce LDK's `Listen` contract with `assert_eq!` on both +/// the previous block hash and `height == best + 1`, so handing either one a block it has already +/// applied panics the node rather than returning an error. Listener durability is not +/// synchronized — `ChannelManager` is persisted asynchronously by the background processor while +/// `OutputSweeper` is only marked dirty and flushed periodically — so after a crash they can be +/// durable at different heights. The chain source resumes from the *minimum* height across all +/// listeners, which replays blocks to any listener that got further ahead. +/// +/// Classification is deliberately hash-aware. Deciding on height alone would treat a *different* +/// block at an already-seen height as an ordinary replay and skip it, silently stranding the +/// listener on a stale fork — a worse failure than the panic being avoided, because it is silent. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ListenerAction { + /// The listener expects exactly this block. + Deliver, + /// The listener already has this exact block on its chain; skip it. + AlreadyApplied, + /// The listener cannot accept this block without first being rewound. + Diverged, +} + +pub(crate) fn listener_action( + best: &BlockLocator, block_hash: bitcoin::BlockHash, prev_blockhash: bitcoin::BlockHash, + height: u32, +) -> ListenerAction { + if height == best.height + 1 { + // The ordinary case: extends the listener's tip. + return if best.block_hash == prev_blockhash { + ListenerAction::Deliver + } else { + ListenerAction::Diverged + }; + } + + if height == best.height { + // Same height: an exact replay is safe to skip, a different block is a fork. + return if best.block_hash == block_hash { + ListenerAction::AlreadyApplied + } else { + ListenerAction::Diverged + }; + } + + if height < best.height { + // Below the tip: only a skip if this exact block is on the listener's own chain. + // `previous_blocks` holds ancestors in reverse chronological order starting at + // `best.height - 1`, so the ancestor at `height` sits at index `best.height - height - 1`. + let idx = (best.height - height - 1) as usize; + return match best.previous_blocks.get(idx) { + Some(Some(ancestor)) if *ancestor == block_hash => ListenerAction::AlreadyApplied, + // Either the ancestor differs (a fork), or we have no record that far back and + // therefore cannot prove this block is ours. Both must be treated as divergence. + _ => ListenerAction::Diverged, + }; + } + + // height > best.height + 1: a gap. Delivering would violate the one-call-per-block contract. + ListenerAction::Diverged } impl ChainListener { + /// Logs a diverged listener. Delivering anyway would panic on LDK's chain-order assertion, and + /// silently skipping would strand the listener on a stale chain, so the condition is surfaced + /// loudly rather than swallowed. pub(crate) fn get_best_block(&self) -> BlockLocator { let candidates = [ self.onchain_wallet.current_best_block(), @@ -1551,19 +1623,45 @@ impl ChainListener { self.output_sweeper.current_best_block(), ]; let mut min = candidates.into_iter().min_by_key(|b| b.height).expect("non-empty"); - if let Some(worst_monitor) = self - .chain_monitor + if let Some(worst_monitor) = self.min_monitor_best_block() { + if worst_monitor.height < min.height { + min = worst_monitor; + } + } + min + } + + /// The furthest-behind channel monitor, or `None` when there are no monitors. + fn min_monitor_best_block(&self) -> Option { + self.chain_monitor .list_monitors() .iter() .flat_map(|id| self.chain_monitor.get_monitor(*id)) .map(|m| m.current_best_block()) .min_by_key(|b| b.height) - { - if worst_monitor.height < min.height { - min = worst_monitor; - } + } + + pub(crate) fn take_divergence(&self) -> Option { + self.divergence.lock().unwrap().take() + } + + fn log_divergence(&self, who: &str, best: &BlockLocator, height: u32) { + let mut recorded = self.divergence.lock().unwrap(); + if recorded.is_none() { + *recorded = Some(format!( + "{} diverged at height {} (listener at {}, hash {})", + who, height, best.height, best.block_hash + )); } - min + log_error!( + self.logger, + "{} cannot accept the block at height {}: it is at height {} (hash {}). It must be \ + rewound before it can continue; skipping to avoid a chain-order panic.", + who, + height, + best.height, + best.block_hash, + ); } } @@ -1572,16 +1670,89 @@ impl Listen for ChainListener { &self, header: &bitcoin::block::Header, txdata: &lightning::chain::transaction::TransactionData, height: u32, ) { + // The on-chain wallet is deliberately not gated. `Wallet::blocks_disconnected` is a no-op + // because BDK expects blocks to be reconnected starting from the point of disagreement, so + // a height-based gate would starve it of the new chain after a reorg. BDK also tolerates an + // exact duplicate, which is the only case a gate would otherwise guard against. self.onchain_wallet.filtered_block_connected(header, txdata, height); - self.channel_manager.filtered_block_connected(header, txdata, height); - self.chain_monitor.filtered_block_connected(header, txdata, height); - self.output_sweeper.filtered_block_connected(header, txdata, height); + + let block_hash = header.block_hash(); + + let cm_best = self.channel_manager.current_best_block(); + match listener_action(&cm_best, block_hash, header.prev_blockhash, height) { + ListenerAction::Deliver => { + self.channel_manager.filtered_block_connected(header, txdata, height) + }, + ListenerAction::AlreadyApplied => {}, + ListenerAction::Diverged => self.log_divergence("ChannelManager", &cm_best, height), + } + + // `ChainMonitor` has no chain-order assertion of its own, but `ChannelMonitor` advances its + // tip whenever the incoming height is greater *without validating the parent*, so replaying + // a different chain would silently graft a stale ancestor. Gate it on the furthest-behind + // monitor: monitors ahead of that point ignore heights at or below their own tip. + match self.min_monitor_best_block() { + Some(monitor_best) => { + match listener_action(&monitor_best, block_hash, header.prev_blockhash, height) { + ListenerAction::Deliver | ListenerAction::AlreadyApplied => { + self.chain_monitor.filtered_block_connected(header, txdata, height) + }, + ListenerAction::Diverged => { + self.log_divergence("ChainMonitor", &monitor_best, height) + }, + } + }, + // No monitors: nothing to strand. + None => self.chain_monitor.filtered_block_connected(header, txdata, height), + } + + let sweeper_best = self.output_sweeper.current_best_block(); + match listener_action(&sweeper_best, block_hash, header.prev_blockhash, height) { + ListenerAction::Deliver => { + self.output_sweeper.filtered_block_connected(header, txdata, height) + }, + ListenerAction::AlreadyApplied => {}, + ListenerAction::Diverged => self.log_divergence("OutputSweeper", &sweeper_best, height), + } } + fn block_connected(&self, block: &bitcoin::Block, height: u32) { self.onchain_wallet.block_connected(block, height); - self.channel_manager.block_connected(block, height); - self.chain_monitor.block_connected(block, height); - self.output_sweeper.block_connected(block, height); + + let block_hash = block.header.block_hash(); + + let cm_best = self.channel_manager.current_best_block(); + match listener_action(&cm_best, block_hash, block.header.prev_blockhash, height) { + ListenerAction::Deliver => self.channel_manager.block_connected(block, height), + ListenerAction::AlreadyApplied => {}, + ListenerAction::Diverged => self.log_divergence("ChannelManager", &cm_best, height), + } + + match self.min_monitor_best_block() { + Some(monitor_best) => { + match listener_action( + &monitor_best, + block_hash, + block.header.prev_blockhash, + height, + ) { + ListenerAction::Deliver | ListenerAction::AlreadyApplied => { + self.chain_monitor.block_connected(block, height) + }, + ListenerAction::Diverged => { + self.log_divergence("ChainMonitor", &monitor_best, height) + }, + } + }, + None => self.chain_monitor.block_connected(block, height), + } + + let sweeper_best = self.output_sweeper.current_best_block(); + match listener_action(&sweeper_best, block_hash, block.header.prev_blockhash, height) { + ListenerAction::Deliver => self.output_sweeper.block_connected(block, height), + ListenerAction::AlreadyApplied => {}, + ListenerAction::Diverged => self.log_divergence("OutputSweeper", &sweeper_best, height), + } } fn blocks_disconnected(&self, fork_point_block: lightning::chain::BlockLocator) { @@ -1621,6 +1792,7 @@ impl std::error::Error for BitcoindClientError {} mod tests { use bitcoin::hashes::Hash; use bitcoin::{FeeRate, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness}; + use lightning::chain::BlockLocator; use lightning_block_sync::http::JsonResponse; use proptest::arbitrary::any; use proptest::collection::vec; @@ -1628,10 +1800,99 @@ mod tests { use serde_json::json; use crate::chain::bitcoind::{ - FeeResponse, GetMempoolEntryResponse, GetRawMempoolResponse, GetRawTransactionResponse, - MempoolMinFeeResponse, + listener_action, FeeResponse, GetMempoolEntryResponse, GetRawMempoolResponse, + GetRawTransactionResponse, ListenerAction, MempoolMinFeeResponse, }; + fn hash(byte: u8) -> bitcoin::BlockHash { + bitcoin::BlockHash::from_byte_array([byte; 32]) + } + + /// A listener tip at `height`/`hash_byte` whose ancestors are byte-tagged `hash_byte - n`. + fn locator(height: u32, hash_byte: u8) -> BlockLocator { + let mut loc = BlockLocator::new(hash(hash_byte), height); + for (i, slot) in loc.previous_blocks.iter_mut().enumerate() { + *slot = Some(hash(hash_byte.wrapping_sub(i as u8 + 1))); + } + loc + } + + #[test] + fn listener_action_delivers_the_next_block_in_order() { + // Extends the tip: parent matches, height is best + 1. + assert_eq!( + listener_action(&locator(100, 50), hash(51), hash(50), 101), + ListenerAction::Deliver + ); + } + + #[test] + fn listener_action_rejects_a_next_height_block_with_the_wrong_parent() { + assert_eq!( + listener_action(&locator(100, 50), hash(99), hash(200), 101), + ListenerAction::Diverged + ); + } + + #[test] + fn listener_action_skips_an_exact_replay_at_the_tip() { + // The crash case the gating exists for: the resume floor is the minimum height across all + // listeners, so a listener that persisted further ahead is replayed its own blocks. + // `ChannelManager` and `OutputSweeper` assert on chain order, so delivering would panic. + assert_eq!( + listener_action(&locator(100, 50), hash(50), hash(49), 100), + ListenerAction::AlreadyApplied + ); + } + + #[test] + fn listener_action_skips_an_exact_replay_below_the_tip() { + // Two blocks back on the listener's own chain: ancestors are tagged 49, 48, ... + assert_eq!( + listener_action(&locator(100, 50), hash(48), hash(47), 98), + ListenerAction::AlreadyApplied + ); + } + + #[test] + fn listener_action_reports_a_different_block_at_the_same_height() { + // The bug this classifier exists to prevent. Height alone would call this an ordinary + // replay and skip it, silently stranding the listener on the stale fork — worse than the + // panic being avoided, because nothing reports it. + assert_eq!( + listener_action(&locator(100, 50), hash(0xbb), hash(0xaa), 100), + ListenerAction::Diverged + ); + } + + #[test] + fn listener_action_reports_a_different_block_below_the_tip() { + // Right height, but not the block on the listener's chain (ancestor there is tagged 48). + assert_eq!( + listener_action(&locator(100, 50), hash(0xbb), hash(0xaa), 98), + ListenerAction::Diverged + ); + } + + #[test] + fn listener_action_reports_divergence_beyond_known_ancestry() { + // `previous_blocks` holds 12 ancestors; past that we cannot prove the block is ours, so it + // must not be assumed to be a safe replay. + assert_eq!( + listener_action(&locator(100, 50), hash(1), hash(0), 50), + ListenerAction::Diverged + ); + } + + #[test] + fn listener_action_reports_a_gap() { + // More than one block ahead: delivering violates LDK's one-call-per-block contract. + assert_eq!( + listener_action(&locator(90, 50), hash(60), hash(59), 101), + ListenerAction::Diverged + ); + } + prop_compose! { fn arbitrary_witness()( witness_elements in vec(vec(any::(), 0..100), 0..20) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 7baa4a0873..33ee646397 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -53,6 +53,27 @@ const INITIAL_BACKOFF_MS: u64 = 500; /// Retry matched block downloads before surfacing a CBF sync failure. const CBF_BLOCK_FETCH_RETRIES: u8 = 3; +/// Bound on the queue between the kyoto event loop and [`BlockApplicator`]. +/// +/// This queue was previously unbounded. During a bulk sync, filter processing outruns listener +/// application (each applied block costs a BDK persist), so an unbounded queue lets hundreds of +/// thousands of `ChainOp` values accumulate with no backpressure — a concrete OOM risk on a +/// 512 MiB device. A bound makes the event loop wait for the applicator instead. +/// +/// Kept small because a `ConnectFull` op carries an entire block: at 64 slots the worst case is +/// bounded even when every queued op is a matched block. Most ops are `ConnectFiltered`, which +/// carries only an 80-byte header, so this depth is ample for pipelining in the common case. +const CBF_CHAIN_OP_QUEUE_DEPTH: usize = 64; + +/// How many applied blocks may accumulate before the deferred on-chain chain tip is written. +/// +/// While catching up, the wallet's `local_chain` write is deferred (see +/// `Wallet::set_bulk_chain_persistence`) so the growing full-chain map is not re-serialized on +/// every block. Flushing every retarget period bounds how many blocks a crash can force us to +/// replay, while collapsing ~2016 full-map writes into one. Once caught up, every block is +/// followed by a `Synced` op, which flushes — so at the tip this reverts to one write per block. +const CBF_CHAIN_FLUSH_INTERVAL_BLOCKS: u32 = 2016; + const ESPLORA_TIMEOUT: u64 = 2; /// Retries and per-request timeout for the fresh Electrum connection opened each fee cycle. @@ -131,8 +152,10 @@ enum ChainOp { struct BlockApplicator { chain_listener: ChainListener, - ops_rx: mpsc::UnboundedReceiver, + ops_rx: mpsc::Receiver, next_height: u32, + /// Blocks applied since the last deferred-chain-state flush. + blocks_since_flush: u32, sync_state_tx: watch::Sender, /// Present only for the native CBF fee source: lets us cache the fee rate of blocks we download /// here, so the fee estimator doesn't have to re-download them. @@ -143,7 +166,64 @@ struct BlockApplicator { } impl BlockApplicator { + /// Writes the deferred on-chain chain tip, logging (but not propagating) a failure: the chain + /// state is reconstructible by replay, so a failed flush must not abort block application. + async fn flush_chain_state(&mut self) { + match self.chain_listener.onchain_wallet.flush_chain_persistence().await { + Ok(()) => self.blocks_since_flush = 0, + Err(e) => { + // Deliberately do NOT reset the counter: the chain state is still unwritten, so the + // next applied block should retry promptly rather than wait another full interval. + // `local_chain_dirty` likewise stays set, so no state is dropped on the floor. + log_error!( + self.logger, + "Failed to flush deferred CBF chain state ({}); will retry on the next block.", + e + ); + }, + } + } + + /// Drains any listener divergence recorded during the last block application. + /// + /// Returns `true` when the applicator must stop. A diverged listener is sitting on a chain we + /// cannot extend, so advancing `next_height` would march past it and eventually publish a + /// "synced" tip while that listener is stale — silent, and unrecoverable without a reorg whose + /// fork point happens to fall below it. Failing loudly instead surfaces the condition to + /// `wait_until_synced` and stops further damage. + async fn fail_on_divergence(&mut self) -> bool { + let Some(reason) = self.chain_listener.take_divergence() else { + return false; + }; + log_error!( + self.logger, + "Halting CBF block application: {}. The node must be restarted to re-derive a common \ + chain state from the persisted listener heights.", + reason + ); + // Publish the failure BEFORE flushing. The flush is an unbounded KV write; if it hangs, a + // `wait_until_synced` caller would otherwise block forever waiting for a state that is + // already decided. Signalling first makes the failure observable regardless. + self.sync_state_tx.send_replace(CbfSyncState::Failed(Error::TxSyncFailed)); + // Persist what was applied before the divergence so the resume floor reflects it. + self.flush_chain_state().await; + true + } + + /// Counts an applied block and flushes once the interval has elapsed. + async fn note_block_applied(&mut self) { + self.blocks_since_flush += 1; + if self.blocks_since_flush >= CBF_CHAIN_FLUSH_INTERVAL_BLOCKS { + self.flush_chain_state().await; + } + } + async fn run(mut self) { + // Defer the wallet's full-chain map write for as long as this applicator runs. Every path + // that leaves the catching-up state (`Synced`, `Failed`, and the periodic interval) flushes, + // so deferral never outlives a sync boundary. + self.chain_listener.onchain_wallet.set_bulk_chain_persistence(true).await; + while let Some(op) = self.ops_rx.recv().await { match op { ChainOp::ConnectFull { block: ib } => { @@ -157,7 +237,11 @@ impl BlockApplicator { continue; } self.chain_listener.block_connected(&ib.block, ib.height); + if self.fail_on_divergence().await { + return; + } self.next_height += 1; + self.note_block_applied().await; mark_syncing(&self.sync_state_tx); if let Some(cache) = &self.block_fee_cache { let fee_rate = coinbase_fee_rate(&ib.block, ib.height); @@ -178,7 +262,11 @@ impl BlockApplicator { continue; } self.chain_listener.filtered_block_connected(&header, &[], height); + if self.fail_on_divergence().await { + return; + } self.next_height += 1; + self.note_block_applied().await; mark_syncing(&self.sync_state_tx); }, ChainOp::Disconnect { fork_point } => { @@ -192,6 +280,9 @@ impl BlockApplicator { ChainOp::Synced { tip_height } => { log_info!(self.logger, "CBF caught up to tip {}", tip_height); if self.next_height > tip_height { + // Reaching the tip is the durability boundary: write the deferred chain state + // before publishing, so the tip we advertise as applied is also persisted. + self.flush_chain_state().await; self.publish_synced_tip(tip_height).await; } else { log_debug!( @@ -205,10 +296,17 @@ impl BlockApplicator { }, ChainOp::Failed { error } => { log_info!(self.logger, "we received error chain op {}", error); + // Persist whatever we applied before the failure so the resume floor reflects it. + self.flush_chain_state().await; self.sync_state_tx.send_replace(CbfSyncState::Failed(error)); }, } } + + // The channel closed, which is how a normal shutdown reaches us. Deferred chain state lives + // only in memory, so without this flush a clean stop would silently discard every block + // applied since the last interval flush and force them to be re-synced on next start. + self.flush_chain_state().await; } async fn publish_synced_tip(&self, tip_height: u32) { @@ -373,7 +471,7 @@ impl CbfChainSource { *status = CbfRuntimeStatus::Started { requester }; } - let (ops_tx, ops_rx) = mpsc::unbounded_channel(); + let (ops_tx, ops_rx) = mpsc::channel(CBF_CHAIN_OP_QUEUE_DEPTH); let block_fee_cache = match &self.fee_source { FeeSource::Cbf { block_fee_cache } => Some(Arc::clone(block_fee_cache)), _ => None, @@ -385,6 +483,7 @@ impl CbfChainSource { }); let block_applicator = BlockApplicator { next_height: best_block_height + 1, + blocks_since_flush: 0, sync_state_tx: self.sync_state_tx.clone(), chain_listener: chain_listener.clone(), ops_rx, @@ -580,7 +679,7 @@ impl CbfChainSource { async fn process_kyoto_events( logger: Arc, mut event_rx: mpsc::UnboundedReceiver, registered_scripts: Arc>>, - cbf_runtime_status: Arc>, ops_tx: mpsc::UnboundedSender, + cbf_runtime_status: Arc>, ops_tx: mpsc::Sender, onchain_wallet: Arc, sync_state_tx: watch::Sender, ) { while let Some(event) = event_rx.recv().await { @@ -592,10 +691,17 @@ impl CbfChainSource { // `synced_to_tip` that predates this block. mark_syncing(&sync_state_tx); - let requester = match &*cbf_runtime_status.lock().expect("lock") { - CbfRuntimeStatus::Started { requester } => requester.clone(), - CbfRuntimeStatus::Stopped => { - let _ = ops_tx.send(ChainOp::Failed { error: Error::NotRunning }); + // Copy the requester out and release the lock before any `.await` below: this is a + // `std::sync::Mutex`, so holding its guard across an await point would make this + // future non-`Send` and it could not be spawned. + let requester_opt = match &*cbf_runtime_status.lock().expect("lock") { + CbfRuntimeStatus::Started { requester } => Some(requester.clone()), + CbfRuntimeStatus::Stopped => None, + }; + let requester = match requester_opt { + Some(requester) => requester, + None => { + let _ = ops_tx.send(ChainOp::Failed { error: Error::NotRunning }).await; return; }, }; @@ -622,8 +728,9 @@ impl CbfChainSource { "Failed to obtain receiver for matched CBF block {}; node is stopped", block_hash ); - let _ = - ops_tx.send(ChainOp::Failed { error: Error::NotRunning }); + let _ = ops_tx + .send(ChainOp::Failed { error: Error::NotRunning }) + .await; return; }, }; @@ -661,8 +768,9 @@ impl CbfChainSource { reason, CBF_BLOCK_FETCH_RETRIES ); - let _ = - ops_tx.send(ChainOp::Failed { error: Error::TxSyncFailed }); + let _ = ops_tx + .send(ChainOp::Failed { error: Error::TxSyncFailed }) + .await; return; }, } @@ -674,7 +782,7 @@ impl CbfChainSource { height: indexed_filter.height(), } }; - if let Err(e) = ops_tx.send(chop) { + if let Err(e) = ops_tx.send(chop).await { log_debug!(logger, "ops_rx gone: {}", e); } }, @@ -683,7 +791,8 @@ impl CbfChainSource { //tip does NOT mean that we caught everything up, that's why we send a ChainOp, //only processing of which means we processed all blocks up to the tip. log_info!(logger, "Kyoto synced up to the tip {}", sync_update.tip().height); - let _ = ops_tx.send(ChainOp::Synced { tip_height: sync_update.tip().height }); + let _ = + ops_tx.send(ChainOp::Synced { tip_height: sync_update.tip().height }).await; }, KyotoEvent::ChainUpdate(BlockHeaderChanges::Connected(indexed_header)) => { log_debug!( @@ -702,7 +811,7 @@ impl CbfChainSource { lowest.prev_blockhash(), lowest.height.saturating_sub(1), ); - let _ = ops_tx.send(ChainOp::Disconnect { fork_point }); + let _ = ops_tx.send(ChainOp::Disconnect { fork_point }).await; } }, KyotoEvent::ChainUpdate(BlockHeaderChanges::ForkAdded(fork)) => { diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 2a669c2f8c..a4a64def96 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -280,6 +280,8 @@ impl ChainSource { channel_manager, chain_monitor, output_sweeper, + logger: Arc::clone(&self.logger), + divergence: Arc::new(Mutex::new(None)), }; cbf_chain_source.start(chain_listener); }, @@ -687,8 +689,7 @@ impl ChainSource { // confirmations by ≤1, which errs on the safe/late side for deadlines. match esplora_client.get_height().await { Ok(tip_height) if tip_height >= height => { - let confirmations = - tip_height.saturating_sub(height).saturating_add(1); + let confirmations = tip_height.saturating_sub(height).saturating_add(1); RawTxObservation::Confirmed { height: Some(height), confirmations } }, Ok(tip_height) => { @@ -756,8 +757,8 @@ impl ChainSource { latest_chain_tip.read().unwrap().as_ref().map(|tip| tip.height) }, }; - let height = tip_height - .map(|t| t.saturating_sub(confirmations.saturating_sub(1))); + let height = + tip_height.map(|t| t.saturating_sub(confirmations.saturating_sub(1))); RawTxObservation::Confirmed { height, confirmations } }, Ok(None) => RawTxObservation::NotFound, @@ -914,25 +915,15 @@ pub(crate) fn derive_tx_status( RawTxObservation::InMempool => TxStatus { confirmations: 0, height: None, - status: if previously_confirmed { - ChainStatus::Reorged - } else { - ChainStatus::Mempool - }, + status: if previously_confirmed { ChainStatus::Reorged } else { ChainStatus::Mempool }, }, RawTxObservation::NotFound => TxStatus { confirmations: 0, height: None, - status: if previously_confirmed { - ChainStatus::Reorged - } else { - ChainStatus::Dropped - }, + status: if previously_confirmed { ChainStatus::Reorged } else { ChainStatus::Dropped }, }, - RawTxObservation::Unreachable => TxStatus { - confirmations: 0, - height: None, - status: ChainStatus::NoChainSource, + RawTxObservation::Unreachable => { + TxStatus { confirmations: 0, height: None, status: ChainStatus::NoChainSource } }, } } @@ -1078,8 +1069,7 @@ mod swap_b5_tests { // The load-bearing E6 invariant: an unanswerable chain source is never // reported as confirmed, regardless of prior confirmation history. for previously_confirmed in [false, true] { - let status = - derive_tx_status(RawTxObservation::Unreachable, previously_confirmed); + let status = derive_tx_status(RawTxObservation::Unreachable, previously_confirmed); assert_eq!(status.status, ChainStatus::NoChainSource); assert_ne!(status.status, ChainStatus::Confirmed); assert_eq!(status.confirmations, 0); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 71dc5aa5dc..ddd595bc28 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -229,6 +229,22 @@ impl Wallet { self.inner.lock().expect("lock").spk_index().inner().all_spks().values().cloned().collect() } + /// Defers persistence of the wallet's chain tip while a bulk chain sync is running. + /// + /// See `KVStoreWalletPersister::set_defer_local_chain` for why only the chain is deferred. + /// Callers must pair this with [`Self::flush_chain_persistence`]; nothing flushes implicitly. + pub(crate) async fn set_bulk_chain_persistence(&self, enabled: bool) { + self.persister.lock().await.set_defer_local_chain(enabled); + } + + /// Persists any chain state deferred by [`Self::set_bulk_chain_persistence`]. + pub(crate) async fn flush_chain_persistence(&self) -> Result<(), Error> { + self.persister.lock().await.flush_local_chain().await.map_err(|e| { + log_error!(self.logger, "Failed to flush deferred on-chain wallet chain state: {}", e); + Error::PersistenceFailed + }) + } + async fn update_payment_store(&self, mut events: Vec) -> Result<(), Error> { if events.is_empty() { return Ok(()); @@ -2133,9 +2149,7 @@ impl WalletKeysManager { /// method adds error logging on top of this pure derivation. Secret material is /// never logged here. #[cfg(feature = "swaps")] -fn swap_keypair_from_master( - master: &Xpriv, index: u32, -) -> Result { +fn swap_keypair_from_master(master: &Xpriv, index: u32) -> Result { let secp = Secp256k1::new(); let child = master.derive_priv(&secp, &[ChildNumber::Hardened { index }])?; Ok(Keypair::from_secret_key(&secp, &child.private_key)) diff --git a/src/wallet/persist.rs b/src/wallet/persist.rs index 9d33a09f93..4d76dfe260 100644 --- a/src/wallet/persist.rs +++ b/src/wallet/persist.rs @@ -25,11 +25,62 @@ pub(crate) struct KVStoreWalletPersister { pending_change_set: ChangeSet, kv_store: Arc, logger: Arc, + /// While set, `local_chain` updates are merged into the in-memory aggregate but not written to + /// the KV store. See [`Self::set_defer_local_chain`]. + defer_local_chain: bool, + /// Whether the in-memory `local_chain` aggregate holds changes not yet written to the KV store. + local_chain_dirty: bool, } impl KVStoreWalletPersister { pub(crate) fn new(kv_store: Arc, logger: Arc) -> Self { - Self { latest_change_set: None, pending_change_set: ChangeSet::default(), kv_store, logger } + Self { + latest_change_set: None, + pending_change_set: ChangeSet::default(), + kv_store, + logger, + defer_local_chain: false, + local_chain_dirty: false, + } + } + + /// Defers `local_chain` writes while a bulk chain sync is in progress. + /// + /// The persisted `local_chain` is a `BTreeMap` covering the whole chain, and it is + /// re-serialized and re-written in full on every applied block. During an initial sync that + /// makes the total bytes written quadratic in chain height — the dominant cost on a + /// flash-storage device, and the reason a from-scratch sync is impractical there. + /// + /// Deferring is safe because `local_chain` is the one part of the wallet's state that is + /// reconstructible: a stale persisted chain simply lowers the resume floor, and the chain source + /// replays the missing blocks on restart. `indexer` and `tx_graph` are deliberately *not* + /// deferred — address-derivation indices and transactions are funds-critical and are not + /// cheaply reconstructible, so they keep writing through synchronously. + pub(super) fn set_defer_local_chain(&mut self, defer: bool) { + self.defer_local_chain = defer; + } + + /// Writes the in-memory `local_chain` aggregate if it has deferred changes. + pub(super) async fn flush_local_chain(&mut self) -> Result<(), std::io::Error> { + if !self.local_chain_dirty { + return Ok(()); + } + + let latest_change_set = self.latest_change_set.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::Other, + "Wallet must be initialized before flushing the local chain", + ) + })?; + + write_bdk_wallet_local_chain( + &latest_change_set.local_chain, + &*self.kv_store, + Arc::clone(&self.logger), + ) + .await?; + self.local_chain_dirty = false; + Ok(()) } async fn initialize_inner(&mut self) -> Result { @@ -55,7 +106,8 @@ impl KVStoreWalletPersister { async fn persist_inner( latest_change_set_opt: &mut Option, kv_store: &Arc, - logger: &Arc, change_set: &ChangeSet, + logger: &Arc, change_set: &ChangeSet, defer_local_chain: bool, + local_chain_dirty: &mut bool, ) -> Result<(), std::io::Error> { if change_set.is_empty() { return Ok(()); @@ -162,12 +214,20 @@ impl KVStoreWalletPersister { if !change_set.local_chain.is_empty() { latest_change_set.local_chain.merge(change_set.local_chain.clone()); - write_bdk_wallet_local_chain( - &latest_change_set.local_chain, - &*kv_store, - Arc::clone(&logger), - ) - .await?; + if defer_local_chain { + // Merged in memory only; `flush_local_chain` writes the aggregate later. A crash + // before that flush leaves an older persisted chain, which lowers the resume floor + // and causes the missing blocks to be replayed. + *local_chain_dirty = true; + } else { + write_bdk_wallet_local_chain( + &latest_change_set.local_chain, + &*kv_store, + Arc::clone(&logger), + ) + .await?; + *local_chain_dirty = false; + } } Ok(()) @@ -182,6 +242,8 @@ impl KVStoreWalletPersister { &self.kv_store, &self.logger, &self.pending_change_set, + self.defer_local_chain, + &mut self.local_chain_dirty, ) .await?; let _ = std::mem::take(&mut self.pending_change_set); @@ -212,6 +274,8 @@ impl AsyncWalletPersister for KVStoreWalletPersister { &persister.kv_store, &persister.logger, change_set, + persister.defer_local_chain, + &mut persister.local_chain_dirty, )) } } From 36b8ab1e76c562cf0ac2c6baf0031b33808fd149 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Tue, 4 Aug 2026 15:50:52 +0700 Subject: [PATCH 124/138] feat(chain/cbf): optional wallet birthday for fresh CBF wallets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NodeBuilder::set_chain_source_cbf` gains `wallet_rescan_from_height`: the lowest height whose block the wallet must still scan. Because CBF has no chain backend at build time, the height resolves to the highest bip157 compiled checkpoint STRICTLY below it (mainnet: 481,823 / 709,631, each one block before its softfork activation; other networks scan from genesis). Strictly below because everything downstream treats the anchor as already applied and scans from anchor+1. `None` keeps today's scan-from-genesis behavior. The resolved locator flows through `new_cbf` as the initial chain tip: a fresh wallet inserts it as its first BDK checkpoint — now persisted immediately, since `apply_update` only stages and a crash before the first block would resurrect a genesis-rooted wallet beside listeners persisted at the birthday, which the divergence gate then latches on every start. The residual creation window is healed on load: a CBF wallet whose chain state never persisted a block past genesis is re-anchored at the deterministic compiled checkpoint. `resume_checkpoint`'s walk-back is extracted into `resume_anchor`, which never steps onto genesis (the fresh chain [genesis, birthday] previously slid to genesis, returned None, and kyoto rescanned every filter from block 1), and the anchor is clamped to the furthest-behind listener so a mixed state cannot stall the applicator on blocks it refuses to apply. The birthday is an explicit input by design: NodeEntropy carries no provenance, so the library cannot distinguish a freshly generated seed from a restored one; only the caller that created the seed can safely choose an anchor above genesis. Adds 4 unit tests (strict boundary, mainnet-only resolution, dense walk-back, sparse never-genesis). cargo test --lib: 93 passed. AI disclosure: implemented with AI assistance (Claude Code); reviewed adversarially by Codex — the strict-below boundary, the genesis fallback in resume, the unpersisted-checkpoint crash window, and the anchor-above-listener clamp all came out of those review rounds. Co-Authored-By: Claude Opus 5 (1M context) --- src/builder.rs | 90 ++++++++++++++++++++++-- src/chain/cbf.rs | 165 +++++++++++++++++++++++++++++++++++++++++++- src/chain/mod.rs | 7 +- tests/common/mod.rs | 2 +- 4 files changed, 251 insertions(+), 13 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index d5fde14d76..b93e659787 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -72,7 +72,7 @@ use crate::io::{ }; use crate::liquidity::{LSPS2ServiceConfig, LiquiditySourceBuilder, LspConfig}; use crate::lnurl_auth::LnurlAuth; -use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; +use crate::logger::{log_error, log_info, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::peer_store::PeerStore; @@ -116,6 +116,7 @@ enum ChainDataSourceConfig { Cbf { peers: Vec, fee_source_config: Option, + wallet_rescan_from_height: Option, }, } @@ -409,11 +410,30 @@ impl NodeBuilder { /// /// `fee_source_config` optionally delegates fee estimation to an Esplora or Electrum server; /// if `None`, fee rates are derived from recent blocks. + /// + /// `wallet_rescan_from_height` is an optional wallet birthday: the lowest height whose block + /// the wallet must still scan. It applies only while the wallet's persisted chain state is + /// still rooted at genesis; a wallet with a persisted block is never rewound. Because no + /// chain backend is reachable at build time, the wallet is anchored on the highest + /// checkpoint compiled into the `bip157` crate *strictly below* the given height — scanning + /// starts at the block after the anchor, so the block at the given height is always scanned + /// (heights at or below the lowest anchor fall back to a full scan from block 1; the + /// genesis block itself is unspendable by consensus and needs no scan). Mainnet anchors are + /// 481,823 (one block before SegWit activation) and 709,631 (one block before taproot + /// activation). On all other networks a fresh wallet scans from genesis. Passing `None` + /// also scans from genesis — unlike the Bitcoin Core sources, where `None` anchors at the + /// current tip — because CBF has no trusted tip oracle at build time and anchoring lower is + /// the only direction that cannot skip wallet history. For a restored seed with older + /// on-chain activity, pass a height at or below its first transaction (or `None`). pub fn set_chain_source_cbf( &mut self, peers: Vec, fee_source_config: Option, + wallet_rescan_from_height: Option, ) -> &mut Self { - self.chain_data_source_config = - Some(ChainDataSourceConfig::Cbf { peers, fee_source_config }); + self.chain_data_source_config = Some(ChainDataSourceConfig::Cbf { + peers, + fee_source_config, + wallet_rescan_from_height, + }); self } @@ -1560,9 +1580,14 @@ fn build_with_store_internal( Arc::clone(&node_metrics), ) }, - Some(ChainDataSourceConfig::Cbf { peers, fee_source_config }) => ChainSource::new_cbf( + Some(ChainDataSourceConfig::Cbf { + peers, + fee_source_config, + wallet_rescan_from_height, + }) => ChainSource::new_cbf( peers.clone(), fee_source_config.clone(), + *wallet_rescan_from_height, Arc::clone(&runtime), Arc::clone(&fee_estimator), Arc::clone(&tx_broadcaster), @@ -1680,12 +1705,55 @@ fn build_with_store_internal( }, })?; let bdk_wallet = match wallet_opt { - Some(wallet) => { - // `wallet_rescan_from_height`, when set, is fresh-wallet-only. Rewinding a - // persisted wallet is not just replacing BDK's best block: its local-chain and + Some(mut wallet) => { + // `wallet_rescan_from_height`, when set, applies only while the wallet's + // persisted chain state is still rooted at genesis. Rewinding a wallet with a + // persisted block is not just replacing BDK's best block: its local-chain and // tx-graph changesets are already persisted, and LDK state may also have synced // to a later tip. A safe rewind needs an explicit recovery flow that invalidates // all dependent state before replaying blocks. + // + // One exception heals the crash window between wallet creation and the initial + // checkpoint persist: a CBF wallet whose chain state never persisted a block + // past genesis is re-anchored at the compiled birthday checkpoint. The + // anchor is a deterministic constant, so this is exactly the checkpoint the + // wallet received at creation; without it, listeners already persisted at the + // birthday would latch divergence against the genesis-rooted wallet on every + // subsequent start. + if wallet.latest_checkpoint().height() == 0 + && matches!(chain_data_source_config, Some(ChainDataSourceConfig::Cbf { .. })) + { + if let Some(best_block) = chain_tip_opt { + let block_id = bdk_chain::BlockId { + height: best_block.height, + hash: best_block.block_hash, + }; + let latest_checkpoint = wallet.latest_checkpoint().insert(block_id); + let update = + bdk_wallet::Update { chain: Some(latest_checkpoint), ..Default::default() }; + wallet.apply_update(update).map_err(|e| { + log_error!( + logger, + "Failed to re-apply the wallet birthday checkpoint: {}", + e + ); + BuildError::WalletSetupFailed + })?; + runtime.block_on(wallet.persist_async(&mut wallet_persister)).map_err(|e| { + log_error!( + logger, + "Failed to persist the wallet birthday checkpoint: {}", + e + ); + BuildError::WalletSetupFailed + })?; + log_info!( + logger, + "Re-anchored an unscanned wallet at the configured CBF birthday (height {}).", + best_block.height + ); + } + } wallet }, None => { @@ -1780,6 +1848,14 @@ fn build_with_store_internal( log_error!(logger, "Failed to apply checkpoint during wallet setup: {}", e); BuildError::WalletSetupFailed })?; + // `apply_update` only stages the checkpoint. Persist it now: a crash before + // the first persisted block would otherwise resurrect a genesis-rooted + // wallet next to listeners already initialized at the checkpoint, which the + // CBF divergence gate then latches on every subsequent start. + runtime.block_on(wallet.persist_async(&mut wallet_persister)).map_err(|e| { + log_error!(logger, "Failed to persist checkpoint during wallet setup: {}", e); + BuildError::WalletSetupFailed + })?; } wallet }, diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 33ee646397..e00fa19ca4 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -1099,13 +1099,172 @@ fn resume_checkpoint(logger: &Logger, chain_listener: &ChainListener) -> Option< // Walk BDK's checkpoint chain back to the reorg-safe anchor height. let target_height = min_best_block.height.saturating_sub(REORG_SAFETY_BLOCKS); + let cursor = resume_anchor(bdk_cp, target_height); + + if cursor.height() > min_best_block.height { + // The wallet's lowest usable checkpoint sits above a listener (e.g. a wallet anchored + // at its birthday next to Lightning state persisted below it). Anchoring kyoto there + // would make it emit only blocks the applicator refuses to apply — `next_height` + // derives from the *minimum* listener — stalling sync forever without ever tripping + // the divergence gate. Fall back to a full scan so the lagging listener can catch up. + log_error!( + logger, + "CBF resume: wallet's lowest usable checkpoint (height {}) is above the \ + furthest-behind listener (height {}); falling back to a scan from genesis.", + cursor.height(), + min_best_block.height, + ); + return None; + } + + (cursor.height() > 0).then(|| HashCheckpoint::new(cursor.height(), cursor.hash())) +} + +/// Walks `bdk_cp` back toward `target_height` without ever stepping onto genesis. +/// +/// On a dense chain this lands on the checkpoint at `target_height`, exactly like a plain +/// walk. The genesis guard matters for sparse chains — most importantly a fresh wallet whose +/// only real anchor is its birthday checkpoint (`[genesis, birthday]`): stepping onto genesis +/// there would make [`resume_checkpoint`] return `None` and silently demote the node to a full +/// filter scan from block 1, with every block below the birthday discarded on arrival. +fn resume_anchor(bdk_cp: bdk_chain::CheckPoint, target_height: u32) -> bdk_chain::CheckPoint { let mut cursor = bdk_cp; while cursor.height() > target_height { match cursor.prev() { - Some(prev) => cursor = prev, - None => break, + Some(prev) if prev.height() > 0 => cursor = prev, + _ => break, } } + cursor +} - (cursor.height() > 0).then(|| HashCheckpoint::new(cursor.height(), cursor.hash())) +/// Returns the highest checkpoint compiled into the `bip157` crate strictly below +/// `first_scan_height`, or `None` when the wallet should root at genesis. +/// +/// Strictly below, because everything downstream — the initial BDK checkpoint, the listeners' +/// best block, kyoto's `ChainState::Checkpoint` — treats the anchor as already applied, and +/// scanning begins at the block after it. An anchor *at* `first_scan_height` would silently +/// skip that block's filters, losing a transaction confirmed exactly there. +/// +/// Only compiled-in constants are used: CBF has no chain backend at build time, and consulting +/// a third-party tip oracle would reintroduce exactly the dependency this chain source removes. +/// Rounding down can only extend the scanned range, never shrink it. Mainnet ships two such +/// anchors — 481,823 (one block before SegWit activation) and 709,631 (one block before taproot +/// activation); all other networks resolve to `None`. +pub(crate) fn birthday_checkpoint( + network: Network, first_scan_height: u32, +) -> Option { + if network != Network::Bitcoin { + return None; + } + [HashCheckpoint::segwit_activation(), HashCheckpoint::taproot_activation()] + .into_iter() + .filter(|cp| cp.height < first_scan_height) + .max_by_key(|cp| cp.height) +} + +/// Resolves a configured `wallet_rescan_from_height` into the initial chain tip handed to the +/// builder, logging the anchor and its provenance. +/// +/// The returned locator seeds the initial BDK checkpoint of a wallet whose persisted chain +/// state is still rooted at genesis, the best block of a freshly created `ChannelManager` and +/// sweeper, and — through them — kyoto's resume checkpoint and the block applicator's +/// `next_height`. A wallet with a persisted block is never rewound, but absent Lightning +/// components are still initialized from it. +pub(crate) fn resolve_birthday( + logger: &Logger, network: Network, wallet_rescan_from_height: Option, +) -> Option { + let requested = wallet_rescan_from_height?; + match birthday_checkpoint(network, requested) { + Some(cp) => { + let provenance = if cp.height == HashCheckpoint::taproot_activation().height { + "bip157 taproot_activation constant" + } else { + "bip157 segwit_activation constant" + }; + log_info!( + logger, + "CBF wallet birthday: requested height {} resolved to compiled checkpoint at \ + height {} (hash {}, {}); scanning starts at height {}. Applied only while the \ + wallet's persisted chain state is still rooted at genesis.", + requested, + cp.height, + cp.hash, + provenance, + cp.height + 1, + ); + Some(BlockLocator::new(cp.hash, cp.height)) + }, + None => { + log_info!( + logger, + "CBF wallet birthday: no compiled checkpoint strictly below requested height {} \ + on {}; a fresh wallet will scan from genesis.", + requested, + network, + ); + None + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::hashes::Hash; + + #[test] + fn birthday_anchors_strictly_below_the_first_scan_height() { + let taproot = HashCheckpoint::taproot_activation(); + let segwit = HashCheckpoint::segwit_activation(); + + assert_eq!(birthday_checkpoint(Network::Bitcoin, u32::MAX), Some(taproot)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, 900_000), Some(taproot)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, taproot.height + 1), Some(taproot)); + // Scanning starts strictly after the anchor, so a first transaction exactly at a + // compiled anchor height must fall through to the next-lower anchor or that block's + // filters would never be checked. + assert_eq!(birthday_checkpoint(Network::Bitcoin, taproot.height), Some(segwit)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, segwit.height + 1), Some(segwit)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, segwit.height), None); + assert_eq!(birthday_checkpoint(Network::Bitcoin, 0), None); + } + + #[test] + fn birthday_is_mainnet_only() { + for network in [Network::Testnet, Network::Signet, Network::Regtest] { + assert_eq!(birthday_checkpoint(network, u32::MAX), None); + } + } + + fn chain_of(heights: &[u32]) -> bdk_chain::CheckPoint { + bdk_chain::CheckPoint::from_block_ids( + heights + .iter() + .map(|h| bdk_chain::BlockId { height: *h, hash: bitcoin::BlockHash::all_zeros() }), + ) + .expect("strictly increasing heights") + } + + #[test] + fn resume_anchor_walks_dense_chains_to_the_target() { + let heights: Vec = (0..=10).collect(); + let cp = chain_of(&heights); + assert_eq!(resume_anchor(cp.clone(), 3).height(), 3); + assert_eq!(resume_anchor(cp.clone(), 10).height(), 10); + // Target 0 stops at height 1: the anchor never falls onto genesis. + assert_eq!(resume_anchor(cp, 0).height(), 1); + } + + #[test] + fn resume_anchor_never_falls_onto_genesis_on_sparse_chains() { + // A fresh wallet with a birthday checkpoint: [genesis, birthday]. The reorg-safety + // walk-back must anchor on the birthday, not slide onto genesis and force a full + // scan from block 1. + let cp = chain_of(&[0, 709_631]); + assert_eq!(resume_anchor(cp, 709_624).height(), 709_631); + + let genesis_only = chain_of(&[0]); + assert_eq!(resume_anchor(genesis_only, 0).height(), 0); + } } diff --git a/src/chain/mod.rs b/src/chain/mod.rs index a4a64def96..ac507d7473 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -245,11 +245,14 @@ impl ChainSource { } pub(crate) fn new_cbf( - peers: Vec, fee_source_config: Option, runtime: Arc, + peers: Vec, fee_source_config: Option, + wallet_rescan_from_height: Option, runtime: Arc, fee_estimator: Arc, tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: Arc, node_metrics: Arc, ) -> Result<(Self, Option), Error> { + let birthday_tip = + cbf::resolve_birthday(&logger, config.network, wallet_rescan_from_height); let cbf_chain_source = CbfChainSource::new( peers, fee_source_config, @@ -262,7 +265,7 @@ impl ChainSource { )?; let kind = ChainSourceKind::Cbf(cbf_chain_source); let registered_txids = Mutex::new(HashSet::new()); - Ok((Self { kind, registered_txids, tx_broadcaster, logger }, None)) + Ok((Self { kind, registered_txids, tx_broadcaster, logger }, birthday_tip)) } pub(crate) fn start( diff --git a/tests/common/mod.rs b/tests/common/mod.rs index ab911c6974..3216c4c548 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -726,7 +726,7 @@ pub(crate) fn setup_node(chain_source: &TestChainSource, config: TestConfig) -> TestChainSource::Cbf(bitcoind) => { let p2p_socket = bitcoind.params.p2p_socket.expect("P2P must be enabled for CBF"); let peer_addr = format!("{}", p2p_socket); - builder.set_chain_source_cbf(vec![peer_addr], None); + builder.set_chain_source_cbf(vec![peer_addr], None, config.wallet_rescan_from_height); }, } From d8504e8d4a68252ec118854ea4ad3fba6fd89c11 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 01:08:11 +0700 Subject: [PATCH 125/138] fix(chain): adapt swaps matches to four-variant ChainSource, fail-closed Cbf arms AI-assisted (Claude Code). --- src/chain/bitcoind.rs | 50 ++++++++++++++ src/chain/cbf.rs | 10 +++ src/chain/electrum.rs | 132 +++++++++++++++++++++++++++++++++++++ src/chain/esplora.rs | 72 ++++++++++++++++++++ src/chain/mod.rs | 148 ++++++------------------------------------ src/wallet/mod.rs | 2 +- 6 files changed, 286 insertions(+), 128 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 534a95d064..80e569f977 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -121,6 +121,56 @@ impl BitcoindChainSource { self.api_client.utxo_source() } + /// Returns the shared on-chain fee estimator backing this chain source + /// (Peerswap native primitive B6). + #[cfg(feature = "swaps")] + pub(super) fn fee_estimator(&self) -> &Arc { + &self.fee_estimator + } + + /// Reorg-aware confirmation/eviction query for an ARBITRARY `txid` against + /// this Bitcoind backend (Peerswap native primitive B5). See + /// [`super::ChainSource::swap_query_tx`] for the fail-closed contract. + #[cfg(feature = "swaps")] + pub(super) async fn swap_query_tx(&self, txid: Txid) -> super::RawTxObservation { + match self.api_client.swap_tx_confirmations(&txid).await { + Ok(Some(0)) => super::RawTxObservation::InMempool, + Ok(Some(confirmations)) => { + // `getrawtransaction` returns the depth but not the height; derive + // it as `tip - (confs - 1)`. B5 LOW-2: read a FRESH best-chain tip + // (`get_best_block`) rather than the cached `latest_chain_tip`, + // which can lag the real tip and yield a height that is too low — + // and thus a CSV/claim deadline armed slightly EARLY. A fresh (or + // even a one-block-stale-newer) tip can only err on the LATE/safe + // side. Fail-soft on the HEIGHT ONLY: the depth is already + // authoritative, so on a tip-read error we fall back to the cached + // tip rather than failing the whole query closed. + let tip_height = match self.api_client.get_best_block().await { + Ok((_, Some(h))) => Some(h), + Ok((_, None)) => { + self.latest_chain_tip.read().unwrap().as_ref().map(|tip| tip.height) + }, + Err(e) => { + log_error!( + self.logger, + "swap_query_tx: Bitcoind fresh-tip read failed for {} ({:?}); falling back to cached tip for height", + txid, + e + ); + self.latest_chain_tip.read().unwrap().as_ref().map(|tip| tip.height) + }, + }; + let height = tip_height.map(|t| t.saturating_sub(confirmations.saturating_sub(1))); + super::RawTxObservation::Confirmed { height, confirmations } + }, + Ok(None) => super::RawTxObservation::NotFound, + Err(e) => { + log_error!(self.logger, "swap_query_tx: Bitcoind query failed for {}: {}", txid, e); + super::RawTxObservation::Unreachable + }, + } + } + 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), diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index e00fa19ca4..1f2cac8fa1 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -457,6 +457,16 @@ impl CbfChainSource { kyoto_builder.build() } + /// Returns the shared on-chain fee estimator backing this chain source + /// (Peerswap native primitive B6). CBF derives real fee-rate estimates + /// (from an external source or recent-block coinbase outputs, see + /// [`CbfFeeSourceConfig`]), so — unlike arbitrary raw-tx confirmation + /// queries — feerate estimation is fully supported under CBF. + #[cfg(feature = "swaps")] + pub(super) fn fee_estimator(&self) -> &Arc { + &self.fee_estimator + } + pub(crate) fn start(&self, chain_listener: ChainListener) { let (node, client) = Self::build_kyoto(&self.trusted_peers, &self.config, &self.logger, &chain_listener); diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index c1d04dc6f6..574d7e2836 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -98,6 +98,41 @@ impl ElectrumChainSource { self.electrum_runtime_status.write().expect("lock").stop(); } + /// Returns the shared on-chain fee estimator backing this chain source + /// (Peerswap native primitive B6). + #[cfg(feature = "swaps")] + pub(super) fn fee_estimator(&self) -> &Arc { + &self.fee_estimator + } + + /// Reorg-aware confirmation/eviction query for an ARBITRARY `txid` against + /// this Electrum backend (Peerswap native primitive B5). See + /// [`super::ChainSource::swap_query_tx`] for the fail-closed contract. + #[cfg(feature = "swaps")] + pub(super) async fn swap_query_tx( + &self, txid: Txid, script_pubkey: Option<&ScriptBuf>, + ) -> super::RawTxObservation { + let script_pubkey = match script_pubkey { + Some(script_pubkey) => script_pubkey.clone(), + None => { + log_error!( + self.logger, + "swap_query_tx: Electrum backend requires a watched scriptPubKey for {} (register via watch_txid)", + txid + ); + return super::RawTxObservation::Unreachable; + }, + }; + let client = match self.electrum_runtime_status.read().unwrap().client() { + Some(client) => client, + None => { + log_error!(self.logger, "swap_query_tx: Electrum chain source not started"); + return super::RawTxObservation::Unreachable; + }, + }; + client.swap_query_tx(txid, script_pubkey).await + } + pub(crate) async fn sync_onchain_wallet( &self, onchain_wallet: Arc, ) -> Result<(), Error> { @@ -517,6 +552,103 @@ impl ElectrumRuntimeClient { }) } + /// Reorg-aware confirmation/eviction query for an ARBITRARY `txid` + + /// `script_pubkey` (Peerswap native primitive B5). + /// + /// Electrum exposes no `getrawtransaction`-by-txid-alone RPC; the only + /// reorg-aware confirmation signal it exposes is per-scriptPubKey history + /// (`blockchain.scripthash.get_history`), so this scans that history for a + /// matching `tx_hash` and derives the confirmation depth against a freshly + /// polled tip. FAIL-CLOSED (E6) on any transport error or timeout. + #[cfg(feature = "swaps")] + async fn swap_query_tx(&self, txid: Txid, script_pubkey: ScriptBuf) -> super::RawTxObservation { + let electrum_client = Arc::clone(&self.electrum_client); + let history_spawn_fut = + self.runtime.spawn_blocking(move || electrum_client.script_get_history(&script_pubkey)); + let history_timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.per_request_timeout_secs as u64), + history_spawn_fut, + ); + let history = match history_timeout_fut.await { + Ok(Ok(Ok(history))) => history, + Ok(Ok(Err(e))) => { + log_error!( + self.logger, + "swap_query_tx: Electrum history query failed for {}: {}", + txid, + e + ); + return super::RawTxObservation::Unreachable; + }, + Ok(Err(e)) => { + log_error!( + self.logger, + "swap_query_tx: Electrum history query task failed for {}: {}", + txid, + e + ); + return super::RawTxObservation::Unreachable; + }, + Err(e) => { + log_error!( + self.logger, + "swap_query_tx: Electrum history query timed out for {}: {}", + txid, + e + ); + return super::RawTxObservation::Unreachable; + }, + }; + + let entry = match history.into_iter().find(|entry| entry.tx_hash == txid) { + Some(entry) => entry, + None => return super::RawTxObservation::NotFound, + }; + + // Electrum reports height `0` for a mempool tx, and a negative height for + // a mempool tx with an unconfirmed parent; neither is a confirmed height. + if entry.height <= 0 { + return super::RawTxObservation::InMempool; + } + let height = entry.height as u32; + + let electrum_client = Arc::clone(&self.electrum_client); + let tip_spawn_fut = + self.runtime.spawn_blocking(move || electrum_client.block_headers_subscribe()); + let tip_timeout_fut = tokio::time::timeout( + Duration::from_secs(self.sync_config.timeouts_config.per_request_timeout_secs as u64), + tip_spawn_fut, + ); + match tip_timeout_fut.await { + Ok(Ok(Ok(tip))) if tip.height as u32 >= height => { + let confirmations = (tip.height as u32).saturating_sub(height).saturating_add(1); + super::RawTxObservation::Confirmed { height: Some(height), confirmations } + }, + Ok(Ok(Ok(tip))) => { + log_error!( + self.logger, + "swap_query_tx: Electrum tip {} below confirming-block height {} for {} (reorg/race); failing closed", + tip.height, + height, + txid + ); + super::RawTxObservation::Unreachable + }, + Ok(Ok(Err(e))) => { + log_error!(self.logger, "swap_query_tx: Electrum tip query failed: {}", e); + super::RawTxObservation::Unreachable + }, + Ok(Err(e)) => { + log_error!(self.logger, "swap_query_tx: Electrum tip query task failed: {}", e); + super::RawTxObservation::Unreachable + }, + Err(e) => { + log_error!(self.logger, "swap_query_tx: Electrum tip query timed out: {}", e); + super::RawTxObservation::Unreachable + }, + } + } + async fn sync_confirmables( &self, confirmables: Vec>, ) -> Result<(), Error> { diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 21205bd252..1cab71f3b3 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -85,6 +85,78 @@ impl EsploraChainSource { }) } + /// Returns the shared on-chain fee estimator backing this chain source + /// (Peerswap native primitive B6). + #[cfg(feature = "swaps")] + pub(super) fn fee_estimator(&self) -> &Arc { + &self.fee_estimator + } + + /// Reorg-aware confirmation/eviction query for an ARBITRARY `txid` against + /// this Esplora backend (Peerswap native primitive B5). See + /// [`super::ChainSource::swap_query_tx`] for the fail-closed contract. + #[cfg(feature = "swaps")] + pub(super) async fn swap_query_tx(&self, txid: Txid) -> super::RawTxObservation { + let status = match self.esplora_client.get_tx_status(&txid).await { + Ok(status) => status, + Err(esplora_client::Error::HttpResponse { status: 404, .. }) => { + // Definitive "not in the chain or mempool" answer. + return super::RawTxObservation::NotFound; + }, + Err(e) => { + log_error!( + self.logger, + "swap_query_tx: Esplora status query failed for {}: {}", + txid, + e + ); + return super::RawTxObservation::Unreachable; + }, + }; + if !status.confirmed { + return super::RawTxObservation::InMempool; + } + let height = match status.block_height { + Some(height) => height, + None => { + log_error!( + self.logger, + "swap_query_tx: Esplora reported a confirmed tx {} without a block height", + txid + ); + return super::RawTxObservation::Unreachable; + }, + }; + // B5 LOW-2: the confirming-block height and the tip come from two + // separate Esplora calls; a block/reorg in the gap can make them + // inconsistent. Detect the one observable inconsistency — a tip BELOW + // the tx's confirming block (impossible on a single consistent chain) + // — and FAIL CLOSED (treat as unverifiable) rather than reporting a + // bogus `1`-confirmation from the saturating arithmetic. The benign + // gap (tip one block ahead of the status snapshot) only over-counts + // confirmations by ≤1, which errs on the safe/late side for deadlines. + match self.esplora_client.get_height().await { + Ok(tip_height) if tip_height >= height => { + let confirmations = tip_height.saturating_sub(height).saturating_add(1); + super::RawTxObservation::Confirmed { height: Some(height), confirmations } + }, + Ok(tip_height) => { + log_error!( + self.logger, + "swap_query_tx: Esplora tip {} below confirming-block height {} for {} (reorg/race); failing closed", + tip_height, + height, + txid + ); + super::RawTxObservation::Unreachable + }, + Err(e) => { + log_error!(self.logger, "swap_query_tx: Esplora tip query failed: {}", e); + super::RawTxObservation::Unreachable + }, + } + } + 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. diff --git a/src/chain/mod.rs b/src/chain/mod.rs index ac507d7473..c78d83fce5 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -149,10 +149,15 @@ impl ChainSource { /// source-bearing swap feerate quotes. #[cfg(feature = "swaps")] pub(crate) fn fee_estimator(&self) -> &Arc { - match self { - Self::Esplora { fee_estimator, .. } => fee_estimator, - Self::Electrum { fee_estimator, .. } => fee_estimator, - Self::Bitcoind { fee_estimator, .. } => fee_estimator, + match &self.kind { + ChainSourceKind::Esplora(esplora_chain_source) => esplora_chain_source.fee_estimator(), + ChainSourceKind::Electrum(electrum_chain_source) => { + electrum_chain_source.fee_estimator() + }, + ChainSourceKind::Bitcoind(bitcoind_chain_source) => { + bitcoind_chain_source.fee_estimator() + }, + ChainSourceKind::Cbf(cbf_chain_source) => cbf_chain_source.fee_estimator(), } } @@ -650,131 +655,20 @@ impl ChainSource { pub(crate) async fn swap_query_tx( &self, txid: Txid, script_pubkey: Option<&ScriptBuf>, ) -> RawTxObservation { - match self { - Self::Esplora { esplora_client, logger, .. } => { - let status = match esplora_client.get_tx_status(&txid).await { - Ok(status) => status, - Err(esplora_client::Error::HttpResponse { status: 404, .. }) => { - // Definitive "not in the chain or mempool" answer. - return RawTxObservation::NotFound; - }, - Err(e) => { - log_error!( - logger, - "swap_query_tx: Esplora status query failed for {}: {}", - txid, - e - ); - return RawTxObservation::Unreachable; - }, - }; - if !status.confirmed { - return RawTxObservation::InMempool; - } - let height = match status.block_height { - Some(height) => height, - None => { - log_error!( - logger, - "swap_query_tx: Esplora reported a confirmed tx {} without a block height", - txid - ); - return RawTxObservation::Unreachable; - }, - }; - // B5 LOW-2: the confirming-block height and the tip come from two - // separate Esplora calls; a block/reorg in the gap can make them - // inconsistent. Detect the one observable inconsistency — a tip BELOW - // the tx's confirming block (impossible on a single consistent chain) - // — and FAIL CLOSED (treat as unverifiable) rather than reporting a - // bogus `1`-confirmation from the saturating arithmetic. The benign - // gap (tip one block ahead of the status snapshot) only over-counts - // confirmations by ≤1, which errs on the safe/late side for deadlines. - match esplora_client.get_height().await { - Ok(tip_height) if tip_height >= height => { - let confirmations = tip_height.saturating_sub(height).saturating_add(1); - RawTxObservation::Confirmed { height: Some(height), confirmations } - }, - Ok(tip_height) => { - log_error!( - logger, - "swap_query_tx: Esplora tip {} below confirming-block height {} for {} (reorg/race); failing closed", - tip_height, - height, - txid - ); - RawTxObservation::Unreachable - }, - Err(e) => { - log_error!(logger, "swap_query_tx: Esplora tip query failed: {}", e); - RawTxObservation::Unreachable - }, - } + match &self.kind { + ChainSourceKind::Esplora(esplora_chain_source) => { + esplora_chain_source.swap_query_tx(txid).await }, - Self::Electrum { electrum_runtime_status, logger, .. } => { - let script_pubkey = match script_pubkey { - Some(script_pubkey) => script_pubkey.clone(), - None => { - log_error!( - logger, - "swap_query_tx: Electrum backend requires a watched scriptPubKey for {} (register via watch_txid)", - txid - ); - return RawTxObservation::Unreachable; - }, - }; - let client = match electrum_runtime_status.read().unwrap().client() { - Some(client) => client, - None => { - log_error!(logger, "swap_query_tx: Electrum chain source not started"); - return RawTxObservation::Unreachable; - }, - }; - client.swap_query_tx(txid, script_pubkey).await + ChainSourceKind::Electrum(electrum_chain_source) => { + electrum_chain_source.swap_query_tx(txid, script_pubkey).await }, - Self::Bitcoind { api_client, latest_chain_tip, logger, .. } => { - match api_client.swap_tx_confirmations(&txid).await { - Ok(Some(0)) => RawTxObservation::InMempool, - Ok(Some(confirmations)) => { - // `getrawtransaction` returns the depth but not the height; derive - // it as `tip - (confs - 1)`. B5 LOW-2: read a FRESH best-chain tip - // (`get_best_block`) rather than the cached `latest_chain_tip`, - // which can lag the real tip and yield a height that is too low — - // and thus a CSV/claim deadline armed slightly EARLY. A fresh (or - // even a one-block-stale-newer) tip can only err on the LATE/safe - // side. Fail-soft on the HEIGHT ONLY: the depth is already - // authoritative, so on a tip-read error we fall back to the cached - // tip rather than failing the whole query closed. - let tip_height = match api_client.get_best_block().await { - Ok((_, Some(h))) => Some(h), - Ok((_, None)) => { - latest_chain_tip.read().unwrap().as_ref().map(|tip| tip.height) - }, - Err(e) => { - log_error!( - logger, - "swap_query_tx: Bitcoind fresh-tip read failed for {} ({:?}); falling back to cached tip for height", - txid, - e - ); - latest_chain_tip.read().unwrap().as_ref().map(|tip| tip.height) - }, - }; - let height = - tip_height.map(|t| t.saturating_sub(confirmations.saturating_sub(1))); - RawTxObservation::Confirmed { height, confirmations } - }, - Ok(None) => RawTxObservation::NotFound, - Err(e) => { - log_error!( - logger, - "swap_query_tx: Bitcoind query failed for {}: {}", - txid, - e - ); - RawTxObservation::Unreachable - }, - } + ChainSourceKind::Bitcoind(bitcoind_chain_source) => { + bitcoind_chain_source.swap_query_tx(txid).await + }, + ChainSourceKind::Cbf(_) => { + // BIP157 cannot query arbitrary raw transactions; PeerSwap is + // unavailable under CBF (fail-closed, spec E6). + RawTxObservation::Unreachable }, } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index ddd595bc28..9f6a103083 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -28,7 +28,7 @@ use bitcoin::secp256k1::ecdsa::{RecoverableSignature, Signature}; use bitcoin::secp256k1::{All, PublicKey, Scalar, Secp256k1, SecretKey}; use bitcoin::transaction::Sequence; use bitcoin::{ - Address, Amount, FeeRate, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid, + Address, Amount, FeeRate, Network, OutPoint, ScriptBuf, SignedAmount, Transaction, TxOut, Txid, WPubkeyHash, Weight, WitnessProgram, WitnessVersion, }; use lightning::chain::chaininterface::{ From fc9d5ee899202e79138b235e8889e7fe2c9c263e Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 01:09:37 +0700 Subject: [PATCH 126/138] fix(wallet): bridge sync swaps wrappers over now-async wallet ops Public signatures unchanged. AI-assisted (Claude Code). --- src/wallet/mod.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 9f6a103083..6fb0c37507 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -563,7 +563,12 @@ impl Wallet { &self, output_script: ScriptBuf, amount: Amount, confirmation_target: ConfirmationTarget, locktime: LockTime, ) -> Result { - self.create_funding_transaction(output_script, amount, confirmation_target, locktime) + self.runtime.block_on(self.create_funding_transaction( + output_script, + amount, + confirmation_target, + locktime, + )) } /// Lists the wallet's confirmed, unspent outputs as [`Utxo`]s. @@ -573,7 +578,9 @@ impl Wallet { /// [`Error`] so swap call sites get a meaningful failure value. #[cfg(feature = "swaps")] pub(crate) fn swap_list_confirmed_utxos(&self) -> Result, Error> { - WalletSource::list_confirmed_utxos(self).map_err(|()| Error::WalletOperationFailed) + self.runtime + .block_on(WalletSource::list_confirmed_utxos(self)) + .map_err(|()| Error::WalletOperationFailed) } /// Signs a PSBT with the BDK wallet, returning the extracted [`Transaction`]. @@ -584,7 +591,9 @@ impl Wallet { /// are not finalized by BDK and the `finalized` bool is intentionally ignored. #[cfg(feature = "swaps")] pub(crate) fn swap_sign_psbt(&self, psbt: Psbt) -> Result { - WalletSource::sign_psbt(self, psbt).map_err(|()| Error::WalletOperationFailed) + self.runtime + .block_on(WalletSource::sign_psbt(self, psbt)) + .map_err(|()| Error::WalletOperationFailed) } pub(crate) async fn get_new_internal_address(&self) -> Result { From 8218b27dfb3ba324ac0be7ffa51def058baaf6d0 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 01:12:32 +0700 Subject: [PATCH 127/138] fix: adapt swaps build to upstream lightning broadcast/classify API AI-assisted (Claude Code). --- src/chain/bitcoind.rs | 31 +++++-------------------------- src/tx_broadcaster.rs | 18 +++++++++++++----- 2 files changed, 18 insertions(+), 31 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 80e569f977..f2e055298d 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -1090,30 +1090,9 @@ impl BitcoindClient { .await { Ok(resp) => Ok(Some(resp.0)), - Err(e) => match e.into_inner() { - Some(inner) => { - let rpc_error_res: Result, _> = inner.downcast(); - - match rpc_error_res { - Ok(rpc_error) => { - // -5 == "No such mempool or blockchain transaction". - if rpc_error.code == -5 { - Ok(None) - } else { - Err(std::io::Error::new(std::io::ErrorKind::Other, rpc_error)) - } - }, - Err(_) => Err(std::io::Error::new( - std::io::ErrorKind::Other, - "Failed to process verbose getrawtransaction response", - )), - } - }, - None => Err(std::io::Error::new( - std::io::ErrorKind::Other, - "Failed to process verbose getrawtransaction response", - )), - }, + // -5 == "No such mempool or blockchain transaction". + Err(RpcClientError::Rpc(rpc_error)) if rpc_error.code == -5 => Ok(None), + Err(e) => Err(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())), } } @@ -1487,8 +1466,8 @@ pub(crate) struct SwapTxConfirmationResponse(pub u32); #[cfg(feature = "swaps")] impl TryInto for JsonResponse { - type Error = std::io::Error; - fn try_into(self) -> std::io::Result { + type Error = String; + fn try_into(self) -> Result { let confirmations = self.0["confirmations"].as_u64().unwrap_or(0); Ok(SwapTxConfirmationResponse(confirmations as u32)) } diff --git a/src/tx_broadcaster.rs b/src/tx_broadcaster.rs index a709433fa6..caa86ce7e2 100644 --- a/src/tx_broadcaster.rs +++ b/src/tx_broadcaster.rs @@ -166,13 +166,21 @@ where /// Enqueues a single fully-signed transaction for broadcast (swaps B4). /// - /// Thin wrapper over the [`BroadcasterInterface::broadcast_transactions`] impl below: - /// it enqueues the transaction onto the bounded broadcast queue drained by the chain - /// source's `process_broadcast_queue` loop. The actual network send happens there, - /// so this returns immediately and does not confirm acceptance by the backend. + /// A swap transaction isn't tied to any LDK channel, so none of + /// [`LdkTransactionType`]'s variants (all of which carry a channel/counterparty + /// identity) describe it. Like [`Self::broadcast_unclassified_transaction`], it + /// enqueues directly as an unclassified package onto the bounded broadcast queue + /// drained by the chain source's `process_broadcast_queue` loop, skipping the + /// [`BroadcasterInterface::broadcast_transactions`] classification path below. The + /// actual network send happens there, so this returns immediately and does not + /// confirm acceptance by the backend. #[cfg(feature = "swaps")] pub(crate) fn broadcast_tx(&self, tx: &Transaction) { - ::broadcast_transactions(self, &[tx]); + self.queue_sender.try_send(BroadcastPackage::unclassified(tx.clone())).unwrap_or_else( + |e| { + log_error!(self.logger, "Failed to broadcast transactions: {}", e); + }, + ); } } From a6f60a7088e6e073e45b0b30fe9911b24d89eb45 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 01:25:01 +0700 Subject: [PATCH 128/138] fix(wallet): drop needless block_on from the two swap wrappers that never await WalletSource::list_confirmed_utxos/sign_psbt are async fns whose bodies (list_confirmed_utxos_inner/sign_psbt_inner) are plain sync code with zero await points. Routing them through Runtime::block_on -> block_in_place panics on a current-thread runtime (e.g. any #[tokio::test] caller). swap_list_confirmed_utxos/swap_sign_psbt now call the sync *_inner helpers directly. create_swap_funding_tx keeps the block_on bridge since create_funding_transaction genuinely awaits the persister. AI-assisted (Claude Code). --- src/wallet/mod.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 6fb0c37507..8ab4484b10 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -578,9 +578,7 @@ impl Wallet { /// [`Error`] so swap call sites get a meaningful failure value. #[cfg(feature = "swaps")] pub(crate) fn swap_list_confirmed_utxos(&self) -> Result, Error> { - self.runtime - .block_on(WalletSource::list_confirmed_utxos(self)) - .map_err(|()| Error::WalletOperationFailed) + self.list_confirmed_utxos_inner().map_err(|()| Error::WalletOperationFailed) } /// Signs a PSBT with the BDK wallet, returning the extracted [`Transaction`]. @@ -591,9 +589,7 @@ impl Wallet { /// are not finalized by BDK and the `finalized` bool is intentionally ignored. #[cfg(feature = "swaps")] pub(crate) fn swap_sign_psbt(&self, psbt: Psbt) -> Result { - self.runtime - .block_on(WalletSource::sign_psbt(self, psbt)) - .map_err(|()| Error::WalletOperationFailed) + self.sign_psbt_inner(psbt).map_err(|()| Error::WalletOperationFailed) } pub(crate) async fn get_new_internal_address(&self) -> Result { From 8ccc58e8b25fc9ffe7beeb83caf8c271e866f3f2 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 01:38:48 +0700 Subject: [PATCH 129/138] fix(wallet): pass the required v2_remote_key_derivation arg in a swaps test Pre-existing test-only compile break (unrelated to CBF): the swap_b7_tests call to lightning::sign::KeysManager::new was missing the 4th v2_remote_key_derivation bool that upstream lightning added; every other call site in this crate already passes it. Without this fix, `cargo test --features swaps,cycles` cannot compile at all, blocking verification of the CBF hostname-peers and chain-service-hooks work in this branch. AI-assisted (Claude Code). --- src/wallet/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 8ab4484b10..954ae2fa19 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -2359,7 +2359,7 @@ mod swap_b7_tests { fn swap_key_differs_from_node_identity() { // The node identity secret key is what LDK's KeysManager derives from the // same seed. The swap key MUST come from a different (dedicated) path. - let ldk = LdkKeysManager::new(&TEST_SEED, 0, 0); + let ldk = LdkKeysManager::new(&TEST_SEED, 0, 0, true); let node_secret = ldk.get_node_secret_key(); let secp = Secp256k1::new(); let node_pubkey = PublicKey::from_secret_key(&secp, &node_secret); From 17821ce5ba2b8755edb3de61c9ffb1ce20157d58 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 01:38:53 +0700 Subject: [PATCH 130/138] feat(cbf): accept hostname trusted peers, error on unparseable entries AI-assisted (Claude Code). --- src/chain/cbf.rs | 68 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 6 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 1f2cac8fa1..7777bf52c6 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -381,18 +381,62 @@ enum FeeSource { Electrum { server_url: String }, } +/// Result of parsing a single configured trusted-peer entry (`ip:port` or `host:port`). +/// +/// Kept distinct from [`TrustedPeer`] so the parse step is unit-testable without depending on +/// kyoto's internal representation; [`ParsedPeer::into_trusted_peer`] converts to the real type. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ParsedPeer { + /// A literal IPv4/IPv6 socket address. + Addr(SocketAddr), + /// A hostname/port pair. Resolution happens at kyoto connect time, not here. + Hostname { host: String, port: u16 }, +} + +impl ParsedPeer { + fn into_trusted_peer(self) -> TrustedPeer { + match self { + ParsedPeer::Addr(addr) => TrustedPeer::from_socket_addr(addr), + ParsedPeer::Hostname { host, port } => TrustedPeer::from_hostname(host, port), + } + } +} + +/// Parses a single configured CBF trusted-peer entry. +/// +/// Tries a literal `SocketAddr` first (covers IPv4/IPv6). On failure, splits on the last `:` +/// and treats the left side as a hostname to be resolved at kyoto connect time via +/// [`TrustedPeer::from_hostname`] (backed by [`tokio::net::lookup_host`]). Returns `Err` for +/// entries with no parseable port, rather than silently dropping them — a mistyped peer should +/// surface as a startup error, not vanish from the trusted-peer list. +fn parse_trusted_peer(peer_str: &str) -> Result { + if let Ok(addr) = peer_str.parse::() { + return Ok(ParsedPeer::Addr(addr)); + } + + let (host, port_str) = peer_str.rsplit_once(':').ok_or(Error::InvalidSocketAddress)?; + if host.is_empty() { + return Err(Error::InvalidSocketAddress); + } + let port: u16 = port_str.parse().map_err(|_| Error::InvalidSocketAddress)?; + + Ok(ParsedPeer::Hostname { host: host.to_string(), port }) +} + impl CbfChainSource { pub(crate) fn new( peers: Vec, fee_source_config: Option, runtime: Arc, fee_estimator: Arc, kv_store: Arc, config: Arc, logger: Arc, node_metrics: Arc, ) -> Result { - let trusted_peers: Vec = peers - .iter() - .filter_map(|peer_str| { - peer_str.parse::().ok().map(TrustedPeer::from_socket_addr) - }) - .collect(); + let mut trusted_peers = Vec::with_capacity(peers.len()); + for peer_str in &peers { + let parsed = parse_trusted_peer(peer_str).map_err(|e| { + log_error!(logger, "Invalid CBF trusted peer '{}': {}", peer_str, e); + e + })?; + trusted_peers.push(parsed.into_trusted_peer()); + } let fee_source = match fee_source_config { Some(CbfFeeSourceConfig::Esplora(server_url)) => { @@ -1223,6 +1267,18 @@ mod tests { use super::*; use bitcoin::hashes::Hash; + #[test] + fn parse_peer_accepts_hostname() { + let p = parse_trusted_peer("bitcoind.local:18444").expect("hostname peer"); + // shape assertion only — resolution happens at connect time + assert!( + matches!(p, ParsedPeer::Hostname { ref host, port } if host == "bitcoind.local" && port == 18444) + ); + let p2 = parse_trusted_peer("127.0.0.1:18444").expect("socketaddr peer"); + assert!(matches!(p2, ParsedPeer::Addr(_))); + assert!(parse_trusted_peer("no-port-here").is_err()); + } + #[test] fn birthday_anchors_strictly_below_the_first_scan_height() { let taproot = HashCheckpoint::taproot_activation(); From d4b32b80feb14dfa729f1313f47227eb53944351 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 01:48:13 +0700 Subject: [PATCH 131/138] feat(cbf): payment-agnostic external chain-service hooks for fees and broadcast App-supplied async callbacks tried before native fee sources / P2P broadcast. AI-assisted (Claude Code). --- src/builder.rs | 27 +++- src/chain/cbf.rs | 326 ++++++++++++++++++++++++++++++++++++++++++++--- src/chain/mod.rs | 40 +++++- 3 files changed, 368 insertions(+), 25 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index b93e659787..050fe28a81 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -45,7 +45,7 @@ use lightning::util::sweep::OutputSweeper; use lightning_dns_resolver::OMDomainResolver; use vss_client::headers::VssHeaderProvider; -use crate::chain::{CbfFeeSourceConfig, ChainSource}; +use crate::chain::{CbfFeeSourceConfig, ChainServiceHooks, ChainSource}; use crate::config::{ default_user_config, may_announce_channel, AnnounceError, AsyncPaymentsRole, BitcoindRestClientConfig, Config, ElectrumSyncConfig, EsploraSyncConfig, HRNResolverConfig, @@ -311,6 +311,7 @@ impl std::error::Error for BuildError {} pub struct NodeBuilder { config: Config, chain_data_source_config: Option, + cbf_chain_service_hooks: ChainServiceHooks, gossip_source_config: Option, liquidity_source_config: Option, log_writer_config: Option, @@ -341,6 +342,7 @@ impl NodeBuilder { Self { config, chain_data_source_config, + cbf_chain_service_hooks: ChainServiceHooks::default(), gossip_source_config, liquidity_source_config, log_writer_config, @@ -437,6 +439,25 @@ impl NodeBuilder { self } + /// Configures app-supplied external chain-service hooks for the CBF chain source. + /// + /// `hooks.fee_estimates`, if set, is tried before CBF's own fee estimation (native + /// block-derived, or the [`CbfFeeSourceConfig`] passed to [`set_chain_source_cbf`]) on every + /// fee-update cycle; an `Err` result (or no hook at all) falls through to that configured + /// behavior unchanged. `hooks.broadcast`, if set, is tried before CBF's P2P broadcast on + /// every outgoing transaction package; an `Err` result (or no hook at all) falls through to + /// P2P broadcast unchanged. + /// + /// Only meaningful when paired with [`set_chain_source_cbf`]; a no-op for every other chain + /// source. This is intentionally payment-agnostic: the fork never learns anything about how + /// (or whether) a hook is backed by L402 or any other payment protocol. + /// + /// [`set_chain_source_cbf`]: Self::set_chain_source_cbf + pub fn set_cbf_chain_service_hooks(&mut self, hooks: ChainServiceHooks) -> &mut Self { + self.cbf_chain_service_hooks = hooks; + self + } + /// Configures the [`Node`] instance to connect to a Bitcoin Core node via RPC. /// /// This method establishes an RPC connection that enables all essential chain operations including @@ -950,6 +971,7 @@ impl NodeBuilder { build_with_store_internal( config, self.chain_data_source_config.as_ref(), + self.cbf_chain_service_hooks.clone(), self.gossip_source_config.as_ref(), self.liquidity_source_config.as_ref(), self.pathfinding_scores_sync_config.as_ref(), @@ -1465,7 +1487,7 @@ impl ArcedNodeBuilder { /// Builds a [`Node`] instance according to the options previously configured. fn build_with_store_internal( config: Arc, chain_data_source_config: Option<&ChainDataSourceConfig>, - gossip_source_config: Option<&GossipSourceConfig>, + cbf_chain_service_hooks: ChainServiceHooks, gossip_source_config: Option<&GossipSourceConfig>, liquidity_source_config: Option<&LiquiditySourceConfig>, pathfinding_scores_sync_config: Option<&PathfindingScoresSyncConfig>, probing_config: Option<&ProbingConfig>, async_payments_role: Option, @@ -1595,6 +1617,7 @@ fn build_with_store_internal( Arc::clone(&config), Arc::clone(&logger), Arc::clone(&node_metrics), + cbf_chain_service_hooks.clone(), ) .map_err(|_| BuildError::ChainSourceSetupFailed)?, Some(ChainDataSourceConfig::Bitcoind { diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 7777bf52c6..343e5413c0 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -17,7 +17,7 @@ use tokio::sync::{mpsc, watch}; use crate::chain::bitcoind::ChainListener; use crate::chain::electrum::get_electrum_fee_rate_cache_update; -use crate::chain::CbfFeeSourceConfig; +use crate::chain::{CbfFeeSourceConfig, ChainServiceHooks}; use crate::config::{Config, DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS}; use crate::error::Error; use crate::fee_estimator::{ @@ -139,6 +139,9 @@ pub struct CbfChainSource { kv_store: Arc, node_metrics: Arc, logger: Arc, + /// App-supplied external chain-service hooks (fee estimates / broadcast); empty (no-op) + /// unless configured via [`crate::builder::NodeBuilder::set_cbf_chain_service_hooks`]. + hooks: ChainServiceHooks, } #[derive(Debug)] @@ -423,11 +426,93 @@ fn parse_trusted_peer(peer_str: &str) -> Result { Ok(ParsedPeer::Hostname { host: host.to_string(), port }) } +/// Resolves the per-target fee-rate cache for a single fee-update cycle. +/// +/// If `hooks.fee_estimates` is configured, it is tried first: an `Ok(by_blocks)` map (keyed by +/// conf-target in blocks, valued in sat/vB) is converted into the estimator's internal +/// per-[`ConfirmationTarget`] cache and returned WITHOUT calling `fallback`. A target whose +/// block count is absent from `by_blocks` is simply omitted from the returned cache — reads for +/// that target fall back to [`OnchainFeeEstimator`]'s own per-target static fallback, exactly as +/// they do today when the cache has never been populated for it. +/// +/// An `Err(())` result (or no hook configured at all) falls through to `fallback` — the +/// existing (Esplora / Electrum / native-CBF) fee computation, unchanged. +/// +/// A free function (not a method) so it is unit-testable with a fake hook and a fake fallback, +/// without a live kyoto node. +async fn resolve_fee_estimates( + hooks: &ChainServiceHooks, logger: &Logger, fallback: F, +) -> Result, Error> +where + F: FnOnce() -> Fut, + Fut: std::future::Future, Error>>, +{ + if let Some(hook) = hooks.fee_estimates.as_ref() { + match hook().await { + Ok(by_blocks) => { + let mut cache = HashMap::with_capacity(10); + for target in get_all_conf_targets() { + let num_blocks = get_num_block_defaults_for_target(target) as u16; + if let Some(&sat_per_vb) = by_blocks.get(&num_blocks) { + // sat/vB -> sat/kwu: 1 vB = 4 WU, 1 kwu = 1000 WU, so *1000/4 == *250. + let sat_per_kwu = (sat_per_vb * 250.0).max(1.0) as u64; + let fee_rate = FeeRate::from_sat_per_kwu(sat_per_kwu); + cache.insert(target, apply_post_estimation_adjustments(target, fee_rate)); + } + } + return Ok(cache); + }, + Err(()) => { + log_debug!( + logger, + "External chain-service fee-estimates hook declined; falling back to the \ + configured fee source." + ); + }, + } + } + fallback().await +} + +/// Outcome of trying the app-supplied broadcast hook before falling back to P2P. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BroadcastOutcome { + /// The hook accepted the package; P2P broadcast was not used. + HookHandled, + /// No hook configured, or the hook declined (`Err(())`); P2P broadcast handled it. + FellThroughToP2p, +} + +/// Tries the app-supplied broadcast hook before falling back to the node's own P2P broadcast. +/// +/// If `hooks.broadcast` is configured, it is called with the package's transactions: `Ok(())` +/// means the external service accepted the broadcast, so `p2p_send` is NOT called. `Err(())` (or +/// no hook configured at all) falls through to `p2p_send` — the existing kyoto +/// `submit_package`/per-tx broadcast path, unchanged. +/// +/// A free function (not a method) so it is unit-testable with a fake hook and a fake +/// `p2p_send`, without a live kyoto node. +async fn dispatch_broadcast( + hooks: &ChainServiceHooks, txs: Vec, p2p_send: F, +) -> BroadcastOutcome +where + F: FnOnce(Vec) -> Fut, + Fut: std::future::Future, +{ + if let Some(hook) = hooks.broadcast.as_ref() { + if hook(txs.clone()).await.is_ok() { + return BroadcastOutcome::HookHandled; + } + } + p2p_send(txs).await; + BroadcastOutcome::FellThroughToP2p +} + impl CbfChainSource { pub(crate) fn new( peers: Vec, fee_source_config: Option, runtime: Arc, fee_estimator: Arc, kv_store: Arc, config: Arc, - logger: Arc, node_metrics: Arc, + logger: Arc, node_metrics: Arc, hooks: ChainServiceHooks, ) -> Result { let mut trusted_peers = Vec::with_capacity(peers.len()); for peer_str in &peers { @@ -467,6 +552,7 @@ impl CbfChainSource { kv_store, node_metrics, logger, + hooks, }) } @@ -908,6 +994,20 @@ impl CbfChainSource { } pub(crate) async fn update_fee_rate_estimates(&self) -> Result<(), Error> { + let new_fee_rate_cache = + resolve_fee_estimates(&self.hooks, &self.logger, || self.fee_rate_cache_from_source()) + .await?; + + self.commit_fee_rate_cache(new_fee_rate_cache).await + } + + /// The existing (Esplora / Electrum / native-CBF) fee computation, unchanged from before the + /// [`ChainServiceHooks`] fee-estimates hook was introduced. Called by + /// [`update_fee_rate_estimates`](Self::update_fee_rate_estimates) as the `fallback` passed to + /// [`resolve_fee_estimates`]. + async fn fee_rate_cache_from_source( + &self, + ) -> Result, Error> { let new_fee_rate_cache = match &self.fee_source { FeeSource::Esplora { client } => { let estimates = client.get_fee_estimates().await.map_err(|e| { @@ -999,7 +1099,7 @@ impl CbfChainSource { }, }; - self.commit_fee_rate_cache(new_fee_rate_cache).await + Ok(new_fee_rate_cache) } /// Writes a freshly computed per-target fee-rate map into the estimator cache and records the @@ -1025,26 +1125,33 @@ impl CbfChainSource { return; }, }; + let logger = Arc::clone(&self.logger); - match Package::from_vec(package.clone()) { - Ok(package) => { - if let Err(e) = requester.submit_package(package).await { - log_error!(self.logger, "Failed to broadcast transaction package: {:?}", e); - } - }, - Err(_) => { - for tx in package { - let txid = tx.compute_txid(); - if let Err(e) = requester.submit_package(tx).await { - log_error!( - self.logger, - "Failed to broadcast transaction {}: {:?}", - txid, - e - ); + let outcome = dispatch_broadcast(&self.hooks, package, move |package| async move { + match Package::from_vec(package.clone()) { + Ok(package) => { + if let Err(e) = requester.submit_package(package).await { + log_error!(logger, "Failed to broadcast transaction package: {:?}", e); } - } - }, + }, + Err(_) => { + for tx in package { + let txid = tx.compute_txid(); + if let Err(e) = requester.submit_package(tx).await { + log_error!(logger, "Failed to broadcast transaction {}: {:?}", txid, e); + } + } + }, + } + }) + .await; + + if outcome == BroadcastOutcome::HookHandled { + log_debug!( + self.logger, + "External chain-service broadcast hook accepted the transaction package; P2P \ + relay skipped." + ); } } @@ -1333,4 +1440,181 @@ mod tests { let genesis_only = chain_of(&[0]); assert_eq!(resume_anchor(genesis_only, 0).height(), 0); } + + // ------------------------------------------------------------------------------------------ + // ChainServiceHooks (Task 5): fee-estimates and broadcast decision functions. + // ------------------------------------------------------------------------------------------ + + use std::sync::atomic::{AtomicBool, Ordering}; + + use lightning::chain::chaininterface::ConfirmationTarget as LdkConfirmationTarget; + + use crate::chain::{BroadcastFuture, FeeEstimatesFuture}; + + fn test_logger() -> Logger { + Logger::new_log_facade() + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_hook_overrides_matching_targets() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { + Box::pin(async { + let mut by_blocks = HashMap::new(); + by_blocks.insert(1u16, 50.0); + by_blocks.insert(6u16, 20.0); + Ok(by_blocks) + }) + })), + broadcast: None, + }; + + let cache = resolve_fee_estimates(&hooks, &logger, || async { + panic!("fallback must not run when the hook succeeds") + }) + .await + .expect("hook path succeeds"); + + // 1 block -> MaximumFeeEstimate; verified via the same adjustment the production code + // applies, so this doesn't hardcode (and risk drifting from) the bump formula. + let max_fee_target = + ConfirmationTarget::Lightning(LdkConfirmationTarget::MaximumFeeEstimate); + let expected_max_fee = + apply_post_estimation_adjustments(max_fee_target, FeeRate::from_sat_per_kwu(50 * 250)); + assert_eq!(cache.get(&max_fee_target), Some(&expected_max_fee)); + + // 6 blocks -> both OnchainPayment and UrgentOnChainSweep get the same input rate. + assert_eq!( + cache.get(&ConfirmationTarget::OnchainPayment), + Some(&FeeRate::from_sat_per_kwu(20 * 250)) + ); + assert_eq!( + cache.get(&ConfirmationTarget::Lightning(LdkConfirmationTarget::UrgentOnChainSweep)), + Some(&FeeRate::from_sat_per_kwu(20 * 250)) + ); + + // A target whose block count the hook didn't provide is simply absent from the cache + // (reads for it fall back to the estimator's own static per-target fallback). + assert!(!cache.contains_key(&ConfirmationTarget::ChannelFunding)); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_hook_error_falls_through() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { Box::pin(async { Err(()) }) })), + broadcast: None, + }; + + let mut configured_source_cache = HashMap::new(); + configured_source_cache + .insert(ConfirmationTarget::OnchainPayment, FeeRate::from_sat_per_kwu(999)); + let expected = configured_source_cache.clone(); + + let fallback_called = Arc::new(AtomicBool::new(false)); + let fallback_called_clone = Arc::clone(&fallback_called); + let cache = resolve_fee_estimates(&hooks, &logger, move || { + let fallback_called = Arc::clone(&fallback_called_clone); + let configured_source_cache = configured_source_cache.clone(); + async move { + fallback_called.store(true, Ordering::SeqCst); + Ok(configured_source_cache) + } + }) + .await + .expect("fallback succeeds"); + + assert!( + fallback_called.load(Ordering::SeqCst), + "an errored hook must fall through to the configured fee source" + ); + assert_eq!(cache, expected); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_no_hook_falls_through() { + let logger = test_logger(); + let hooks = ChainServiceHooks::default(); + + let fallback_called = Arc::new(AtomicBool::new(false)); + let fallback_called_clone = Arc::clone(&fallback_called); + let _ = resolve_fee_estimates(&hooks, &logger, move || { + let fallback_called = Arc::clone(&fallback_called_clone); + async move { + fallback_called.store(true, Ordering::SeqCst); + Ok(HashMap::new()) + } + }) + .await + .expect("fallback succeeds"); + + assert!(fallback_called.load(Ordering::SeqCst), "no hook configured must fall through"); + } + + #[tokio::test] + async fn chain_service_hooks_broadcast_hook_ok_skips_p2p() { + let hooks = ChainServiceHooks { + fee_estimates: None, + broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { Box::pin(async { Ok(()) }) })), + }; + let p2p_called = Arc::new(AtomicBool::new(false)); + let p2p_called_clone = Arc::clone(&p2p_called); + + let outcome = dispatch_broadcast(&hooks, Vec::new(), move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }) + .await; + + assert_eq!(outcome, BroadcastOutcome::HookHandled); + assert!( + !p2p_called.load(Ordering::SeqCst), + "P2P broadcast must not run when the hook accepts the package" + ); + } + + #[tokio::test] + async fn chain_service_hooks_broadcast_hook_err_falls_through_to_p2p() { + let hooks = ChainServiceHooks { + fee_estimates: None, + broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { Box::pin(async { Err(()) }) })), + }; + let p2p_called = Arc::new(AtomicBool::new(false)); + let p2p_called_clone = Arc::clone(&p2p_called); + + let outcome = dispatch_broadcast(&hooks, Vec::new(), move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }) + .await; + + assert_eq!(outcome, BroadcastOutcome::FellThroughToP2p); + assert!( + p2p_called.load(Ordering::SeqCst), + "P2P broadcast must run when the hook declines the package" + ); + } + + #[tokio::test] + async fn chain_service_hooks_broadcast_no_hook_falls_through_to_p2p() { + let hooks = ChainServiceHooks::default(); + let p2p_called = Arc::new(AtomicBool::new(false)); + let p2p_called_clone = Arc::clone(&p2p_called); + + let outcome = dispatch_broadcast(&hooks, Vec::new(), move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }) + .await; + + assert_eq!(outcome, BroadcastOutcome::FellThroughToP2p); + assert!(p2p_called.load(Ordering::SeqCst), "no hook configured must fall through to P2P"); + } } diff --git a/src/chain/mod.rs b/src/chain/mod.rs index c78d83fce5..af7a91c270 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -14,7 +14,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use bitcoin::{Script, Txid}; +use bitcoin::{Script, Transaction, Txid}; use lightning::chain::{BlockLocator, Filter}; use crate::chain::bitcoind::{BitcoindChainSource, ChainListener, UtxoSourceClient}; @@ -129,6 +129,41 @@ pub enum CbfFeeSourceConfig { Electrum(String), } +/// A future resolving to per-conf-target (blocks) fee estimates in sat/vB, or `Err(())` if the +/// external chain service could not provide them this cycle. +pub type FeeEstimatesFuture = std::pin::Pin< + Box, ()>> + Send>, +>; +/// A future resolving to `Ok(())` if the external chain service accepted a raw-tx broadcast, or +/// `Err(())` to fall back to the node's own P2P broadcast. +pub type BroadcastFuture = + std::pin::Pin> + Send>>; + +/// App-supplied hooks that let an external chain service short-circuit the CBF chain source's +/// native fee estimation and P2P transaction broadcast. +/// +/// Only meaningful when paired with the CBF chain source (see +/// [`crate::builder::NodeBuilder::set_cbf_chain_service_hooks`]); ignored for every other chain +/// source. This type is payment-agnostic — the fork never learns about L402, or any other +/// payment protocol a caller might use to obtain fee data or relay a broadcast. +#[derive(Clone, Default)] +pub struct ChainServiceHooks { + /// conf-target (blocks) → sat/vB. Err/None → fall through to fee_source / block-derived. + pub fee_estimates: Option FeeEstimatesFuture + Send + Sync>>, + /// Attempt external broadcast of raw txs. Err → fall through to P2P broadcast. + pub broadcast: + Option) -> BroadcastFuture + Send + Sync>>, +} + +impl std::fmt::Debug for ChainServiceHooks { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ChainServiceHooks") + .field("fee_estimates", &self.fee_estimates.is_some()) + .field("broadcast", &self.broadcast.is_some()) + .finish() + } +} + pub(crate) struct ChainSource { kind: ChainSourceKind, registered_txids: Mutex>, @@ -254,7 +289,7 @@ impl ChainSource { wallet_rescan_from_height: Option, runtime: Arc, fee_estimator: Arc, tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: Arc, - node_metrics: Arc, + node_metrics: Arc, chain_service_hooks: ChainServiceHooks, ) -> Result<(Self, Option), Error> { let birthday_tip = cbf::resolve_birthday(&logger, config.network, wallet_rescan_from_height); @@ -267,6 +302,7 @@ impl ChainSource { Arc::clone(&config), Arc::clone(&logger), Arc::clone(&node_metrics), + chain_service_hooks, )?; let kind = ChainSourceKind::Cbf(cbf_chain_source); let registered_txids = Mutex::new(HashSet::new()); From bfb87e37797e26fdcc439121a6ce446c6889b720 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 02:17:46 +0700 Subject: [PATCH 132/138] fix(cbf): address chain-service-hooks review findings (fix round 1) Task review of the payment-agnostic ChainServiceHooks work (d4b32b8) found 1 Critical + 4 Important findings. This fixes all of them: - C-1: ChainServiceHooks (plus FeeEstimatesFuture, BroadcastFuture, CbfFeeSourceConfig) lived in the private `chain` module with no re-export, so a downstream crate could not name the type to call NodeBuilder::set_cbf_chain_service_hooks (E0422). Added additive-only `pub use chain::{...}` re-exports in src/lib.rs, matching these types' own ungated visibility (not gated behind the `swaps` feature). Verified `git diff a6f60a7..HEAD -- src/lib.rs` still contains only added lines. - I-1: process_broadcast_package early-returned once the kyoto runtime was Stopped, before ever trying the broadcast hook -- exactly when an external relay might be the only way to get a time-sensitive transaction (e.g. a force-close) out. Restructured to capture an Option and always invoke dispatch_broadcast; the P2P leg itself now degrades to an error log when there is no live requester, instead of gating the whole broadcast attempt. - I-2: the hook awaits were the only unbounded awaits in the fee-update and broadcast-drain paths (Esplora/Electrum are already timeout-wrapped), so a hung hook could stall the fee cycle or the broadcast queue indefinitely. Added CHAIN_SERVICE_HOOK_TIMEOUT_SECS (10s, matching the file's existing external-service timeouts) and wrapped both hook invocations in tokio::time::timeout; a timeout is treated identically to Err(()). Threaded hook_timeout as an explicit Duration parameter (rather than a hardcoded const) so tests can inject a short timeout without needing tokio's paused-clock test-util feature (no Cargo.toml change). - I-3 / I-4: a sparse or empty fee_estimates hook map was previously applied as a full cache replacement, silently pinning any uncovered target to the crate's static fallback (worse than any pre-hook source) and, for an empty map, still stamping the fee-cache timestamp. Added required_hook_fee_targets() (the six distinct block-count targets the estimator needs) and made application all-or-nothing: a hook result missing a finite value for even one required target is rejected in full and treated exactly like Err(()) -- same fallback path, so the timestamp is never stamped from a rejected result. Documented the required target set on ChainServiceHooks::fee_estimates. - Input validation (folds into I-3): accepted hook sat/vB values are now clamped to [CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB, CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB] = [1.0, 10_000.0] before unit conversion, fixing both the `.max(1.0)` floor being applied 250x too weak (it was checked in sat/kwu, not sat/vB) and a reachable overflow panic in a downstream to_sat_per_vb_ceil() read from an unclamped extreme hook value. Tests: rewrote/extended the chain_service_hooks test group in src/chain/cbf.rs (12 tests total) to cover: full-map application per ConfirmationTarget, sparse-map fallback, empty-map fallback (with no fallback-cache mutation), out-of-range value clamping, fee-hook timeout fallback, broadcast-hook timeout fallback, and broadcast-hook success when no requester is available (kyoto stopped). Gates: cargo test --features swaps,cycles --lib (121 passed, 0 failed), cargo check --features swaps,cycles (exit 0), cargo check (exit 0), cargo fmt --all. git diff a6f60a7..HEAD -- src/lib.rs remains additive only. AI disclosure: implemented with Claude Code (Anthropic) per this repo's CLAUDE.md AI-disclosure convention, addressing reviewer findings from an automated task review. Co-Authored-By: Claude Fable 5 --- src/chain/cbf.rs | 590 +++++++++++++++++++++++++++++++++++++++-------- src/chain/mod.rs | 18 +- src/lib.rs | 15 ++ 3 files changed, 525 insertions(+), 98 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 343e5413c0..f27d7030ff 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -80,6 +80,25 @@ const ESPLORA_TIMEOUT: u64 = 2; const ELECTRUM_FEE_NUM_RETRIES: u8 = 3; const ELECTRUM_FEE_TIMEOUT_SECS: u64 = 10; +/// Timeout applied to every [`ChainServiceHooks`] invocation (both `fee_estimates` and +/// `broadcast`). Unlike the Esplora/Electrum fee paths, an app-supplied hook has no client-level +/// timeout of its own, so without this an indefinitely-hanging hook would stall the fee-update +/// cycle, or the 500-deep broadcast-queue drain loop (`CBF_CHAIN_OP_QUEUE_DEPTH`-adjacent — +/// `continuously_process_broadcast_queue` in `chain/mod.rs`), for as long as the hook never +/// resolves. A timeout is treated identically to `Err(())`: fall through to the native path. +/// Matches the file's existing 10s external-service timeouts (`ELECTRUM_FEE_TIMEOUT_SECS`, +/// `CBF_BLOCK_FETCH_TIMEOUT_SECS`). +const CHAIN_SERVICE_HOOK_TIMEOUT_SECS: u64 = 10; + +/// Sat/vB bounds clamped onto every value in an accepted `ChainServiceHooks::fee_estimates` +/// result before unit conversion. Defends against a malformed/hostile hook in two ways: a value +/// below the floor would starve fee bumping/relay, and an unclamped extreme value can overflow +/// `FeeRate::to_sat_per_vb_ceil` when a swap caller later reads the cached estimate back out in +/// sat/vB. `10_000` sat/vB is roughly two orders of magnitude above any real-world mempool +/// congestion spike, so this never clips a legitimate estimate. +const CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB: f64 = 1.0; +const CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB: f64 = 10_000.0; + /// Runtime status of the underlying kyoto node. enum CbfRuntimeStatus { Started { requester: Requester }, @@ -426,49 +445,102 @@ fn parse_trusted_peer(peer_str: &str) -> Result { Ok(ParsedPeer::Hostname { host: host.to_string(), port }) } +/// The distinct block-count keys the fee-rate cache needs — the set of +/// `get_num_block_defaults_for_target(target)` values across every [`get_all_conf_targets`] +/// entry. A `ChainServiceHooks::fee_estimates` hook result is only ever applied if it covers +/// every one of these; see [`resolve_fee_estimates`]. +fn required_hook_fee_targets() -> HashSet { + get_all_conf_targets() + .into_iter() + .map(|target| get_num_block_defaults_for_target(target) as u16) + .collect() +} + /// Resolves the per-target fee-rate cache for a single fee-update cycle. /// -/// If `hooks.fee_estimates` is configured, it is tried first: an `Ok(by_blocks)` map (keyed by -/// conf-target in blocks, valued in sat/vB) is converted into the estimator's internal -/// per-[`ConfirmationTarget`] cache and returned WITHOUT calling `fallback`. A target whose -/// block count is absent from `by_blocks` is simply omitted from the returned cache — reads for -/// that target fall back to [`OnchainFeeEstimator`]'s own per-target static fallback, exactly as -/// they do today when the cache has never been populated for it. +/// If `hooks.fee_estimates` is configured, it is tried first, bounded by `hook_timeout` +/// (production callers pass [`CHAIN_SERVICE_HOOK_TIMEOUT_SECS`]; tests inject a short duration +/// so a deliberately-hung fake hook doesn't slow the test suite down). It is applied +/// **all-or-nothing**: an `Ok(by_blocks)` +/// map (keyed by conf-target in blocks, valued in sat/vB) is only converted into the estimator's +/// internal per-[`ConfirmationTarget`] cache and returned WITHOUT calling `fallback` if it has a +/// finite value for every key in [`required_hook_fee_targets`]. Every accepted value is clamped +/// to `[CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB, CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB]` before unit +/// conversion. /// -/// An `Err(())` result (or no hook configured at all) falls through to `fallback` — the -/// existing (Esplora / Electrum / native-CBF) fee computation, unchanged. +/// A `by_blocks` map that is missing one or more required keys (including an empty map) is +/// **rejected in full** and treated exactly like `Err(())` — NOT partially applied. This matters +/// because [`OnchainFeeEstimator::set_fee_rate_cache`] replaces the whole cache rather than +/// merging: applying a partial map would silently pin every uncovered target to the crate's +/// static conservative fallback rate (worse than any pre-hook source) instead of falling through +/// to a live estimate from the configured source, and would also bypass that source's own +/// safety guards (e.g. the Esplora path's "empty estimates disallowed on Mainnet" check) for a +/// cycle that never actually reaches it. Because this function returns early only on full +/// acceptance, [`CbfChainSource::commit_fee_rate_cache`]'s timestamp stamp downstream is never +/// applied for a rejected/timed-out/errored hook result — only for whichever cache (hook or +/// fallback) was actually accepted. +/// +/// An `Err(())` result, a timeout, an incomplete/empty map, or no hook configured at all all +/// fall through to `fallback` — the existing (Esplora / Electrum / native-CBF) fee computation, +/// unchanged. /// /// A free function (not a method) so it is unit-testable with a fake hook and a fake fallback, /// without a live kyoto node. async fn resolve_fee_estimates( - hooks: &ChainServiceHooks, logger: &Logger, fallback: F, + hooks: &ChainServiceHooks, logger: &Logger, hook_timeout: Duration, fallback: F, ) -> Result, Error> where F: FnOnce() -> Fut, Fut: std::future::Future, Error>>, { if let Some(hook) = hooks.fee_estimates.as_ref() { - match hook().await { - Ok(by_blocks) => { - let mut cache = HashMap::with_capacity(10); - for target in get_all_conf_targets() { - let num_blocks = get_num_block_defaults_for_target(target) as u16; - if let Some(&sat_per_vb) = by_blocks.get(&num_blocks) { + let hook_result = tokio::time::timeout(hook_timeout, hook()).await; + match hook_result { + Ok(Ok(by_blocks)) => { + let required = required_hook_fee_targets(); + let covers_all = required + .iter() + .all(|blocks| by_blocks.get(blocks).is_some_and(|v| v.is_finite())); + if covers_all { + let mut cache = HashMap::with_capacity(10); + for target in get_all_conf_targets() { + let num_blocks = get_num_block_defaults_for_target(target) as u16; + // Presence + finiteness guaranteed by `covers_all` above. + let sat_per_vb = by_blocks[&num_blocks].clamp( + CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB, + CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB, + ); // sat/vB -> sat/kwu: 1 vB = 4 WU, 1 kwu = 1000 WU, so *1000/4 == *250. - let sat_per_kwu = (sat_per_vb * 250.0).max(1.0) as u64; + let sat_per_kwu = (sat_per_vb * 250.0) as u64; let fee_rate = FeeRate::from_sat_per_kwu(sat_per_kwu); cache.insert(target, apply_post_estimation_adjustments(target, fee_rate)); } + return Ok(cache); } - return Ok(cache); + log_debug!( + logger, + "External chain-service fee-estimates hook returned an incomplete map \ + (missing or non-finite for one or more of the {} required block-count \ + targets); rejecting it in full and falling back to the configured fee \ + source.", + required.len() + ); }, - Err(()) => { + Ok(Err(())) => { log_debug!( logger, "External chain-service fee-estimates hook declined; falling back to the \ configured fee source." ); }, + Err(_elapsed) => { + log_debug!( + logger, + "External chain-service fee-estimates hook timed out after {:?}; falling \ + back to the configured fee source.", + hook_timeout + ); + }, } } fallback().await @@ -485,23 +557,38 @@ enum BroadcastOutcome { /// Tries the app-supplied broadcast hook before falling back to the node's own P2P broadcast. /// -/// If `hooks.broadcast` is configured, it is called with the package's transactions: `Ok(())` -/// means the external service accepted the broadcast, so `p2p_send` is NOT called. `Err(())` (or -/// no hook configured at all) falls through to `p2p_send` — the existing kyoto -/// `submit_package`/per-tx broadcast path, unchanged. +/// If `hooks.broadcast` is configured, it is called with the package's transactions, bounded by +/// `hook_timeout` (production callers pass [`CHAIN_SERVICE_HOOK_TIMEOUT_SECS`]; tests inject a +/// short duration so a deliberately-hung fake hook doesn't slow the test suite down): `Ok(())` +/// means the external service accepted the broadcast, so `p2p_send` is NOT called. `Err(())`, a +/// timeout, or no hook configured at all falls through to `p2p_send` — the existing kyoto +/// `submit_package`/per-tx broadcast path, unchanged. `p2p_send` is called (and must itself +/// degrade gracefully, e.g. by logging) even when the underlying kyoto runtime is stopped — +/// only `p2p_send`'s own implementation depends on kyoto health, not this decision function. /// /// A free function (not a method) so it is unit-testable with a fake hook and a fake /// `p2p_send`, without a live kyoto node. async fn dispatch_broadcast( - hooks: &ChainServiceHooks, txs: Vec, p2p_send: F, + hooks: &ChainServiceHooks, logger: &Logger, hook_timeout: Duration, txs: Vec, + p2p_send: F, ) -> BroadcastOutcome where F: FnOnce(Vec) -> Fut, Fut: std::future::Future, { if let Some(hook) = hooks.broadcast.as_ref() { - if hook(txs.clone()).await.is_ok() { - return BroadcastOutcome::HookHandled; + let hook_result = tokio::time::timeout(hook_timeout, hook(txs.clone())).await; + match hook_result { + Ok(Ok(())) => return BroadcastOutcome::HookHandled, + Ok(Err(())) => {}, + Err(_elapsed) => { + log_debug!( + logger, + "External chain-service broadcast hook timed out after {:?}; falling back \ + to P2P.", + hook_timeout + ); + }, } } p2p_send(txs).await; @@ -994,9 +1081,13 @@ impl CbfChainSource { } pub(crate) async fn update_fee_rate_estimates(&self) -> Result<(), Error> { - let new_fee_rate_cache = - resolve_fee_estimates(&self.hooks, &self.logger, || self.fee_rate_cache_from_source()) - .await?; + let new_fee_rate_cache = resolve_fee_estimates( + &self.hooks, + &self.logger, + Duration::from_secs(CHAIN_SERVICE_HOOK_TIMEOUT_SECS), + || self.fee_rate_cache_from_source(), + ) + .await?; self.commit_fee_rate_cache(new_fee_rate_cache).await } @@ -1118,32 +1209,53 @@ impl CbfChainSource { } pub(crate) async fn process_broadcast_package(&self, package: Vec) { + // Read the requester (if any) up front, but do NOT bail out when the kyoto runtime is + // stopped: the broadcast hook must still be tried (it's payment-agnostic and may be the + // only working relay left once the CBF restart loop has given up, e.g. for a + // time-sensitive force-close tx). Only the P2P fallback leg below actually needs a live + // `Requester` — it degrades to an error log when there isn't one. let requester = match &*self.cbf_runtime_status.lock().expect("lock") { - CbfRuntimeStatus::Started { requester } => requester.clone(), - CbfRuntimeStatus::Stopped => { - debug_assert!(false, "We should have started the chain source before broadcasting"); - return; - }, + CbfRuntimeStatus::Started { requester } => Some(requester.clone()), + CbfRuntimeStatus::Stopped => None, }; let logger = Arc::clone(&self.logger); - let outcome = dispatch_broadcast(&self.hooks, package, move |package| async move { - match Package::from_vec(package.clone()) { - Ok(package) => { - if let Err(e) = requester.submit_package(package).await { - log_error!(logger, "Failed to broadcast transaction package: {:?}", e); - } - }, - Err(_) => { - for tx in package { - let txid = tx.compute_txid(); - if let Err(e) = requester.submit_package(tx).await { - log_error!(logger, "Failed to broadcast transaction {}: {:?}", txid, e); + let outcome = dispatch_broadcast( + &self.hooks, + &self.logger, + Duration::from_secs(CHAIN_SERVICE_HOOK_TIMEOUT_SECS), + package, + move |package| async move { + let Some(requester) = requester else { + log_error!( + logger, + "Cannot P2P-broadcast transaction package: CBF chain source is stopped \ + and no external chain-service broadcast hook accepted it." + ); + return; + }; + match Package::from_vec(package.clone()) { + Ok(package) => { + if let Err(e) = requester.submit_package(package).await { + log_error!(logger, "Failed to broadcast transaction package: {:?}", e); } - } - }, - } - }) + }, + Err(_) => { + for tx in package { + let txid = tx.compute_txid(); + if let Err(e) = requester.submit_package(tx).await { + log_error!( + logger, + "Failed to broadcast transaction {}: {:?}", + txid, + e + ); + } + } + }, + } + }, + ) .await; if outcome == BroadcastOutcome::HookHandled { @@ -1442,7 +1554,7 @@ mod tests { } // ------------------------------------------------------------------------------------------ - // ChainServiceHooks (Task 5): fee-estimates and broadcast decision functions. + // ChainServiceHooks (Task 5 + fix round 1): fee-estimates and broadcast decision functions. // ------------------------------------------------------------------------------------------ use std::sync::atomic::{AtomicBool, Ordering}; @@ -1455,48 +1567,223 @@ mod tests { Logger::new_log_facade() } + /// Generous timeout for tests where the hook resolves immediately — long enough to never be + /// mistaken for the hung-hook case, short enough to never meaningfully slow the suite down. + fn generous_test_timeout() -> Duration { + Duration::from_secs(5) + } + + /// A full, valid `fee_estimates` map: one entry per distinct block-count target the + /// estimator needs (`1, 3, 6, 12, 144, 1008`), each with a different sat/vB value so + /// per-target application can be told apart in assertions. + fn full_by_blocks_map() -> HashMap { + let mut by_blocks = HashMap::new(); + by_blocks.insert(1u16, 50.0); + by_blocks.insert(3u16, 30.0); + by_blocks.insert(6u16, 20.0); + by_blocks.insert(12u16, 15.0); + by_blocks.insert(144u16, 10.0); + by_blocks.insert(1008u16, 5.0); + by_blocks + } + + /// Mirrors `resolve_fee_estimates`'s own sat/vB -> sat/kwu conversion + post-estimation + /// adjustment, so tests assert against the real formula instead of a hardcoded, driftable + /// number. + fn expected_rate(sat_per_vb: f64, target: ConfirmationTarget) -> FeeRate { + apply_post_estimation_adjustments( + target, + FeeRate::from_sat_per_kwu((sat_per_vb * 250.0) as u64), + ) + } + + #[test] + fn required_hook_fee_targets_is_the_six_distinct_block_counts() { + // Sanity check on the fixture used by every full-map test below: if the per-target + // block-count defaults ever change, this (and `full_by_blocks_map`) should be the first + // thing to fail, not a confusing downstream assertion. + let required = required_hook_fee_targets(); + let mut expected: Vec = vec![1, 3, 6, 12, 144, 1008]; + expected.sort_unstable(); + let mut actual: Vec = required.into_iter().collect(); + actual.sort_unstable(); + assert_eq!(actual, expected); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_full_map_is_applied_per_target() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { + Box::pin(async { Ok(full_by_blocks_map()) }) + })), + broadcast: None, + }; + + let cache = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), || async { + panic!("fallback must not run once the map covers every required target") + }) + .await + .expect("hook path succeeds"); + + // Every one of the 10 conf-targets must be present -- a full map is applied in full. + assert_eq!(cache.len(), get_all_conf_targets().len()); + + let max_fee = ConfirmationTarget::Lightning(LdkConfirmationTarget::MaximumFeeEstimate); + assert_eq!(cache[&max_fee], expected_rate(50.0, max_fee)); + + assert_eq!( + cache[&ConfirmationTarget::ChannelFunding], + expected_rate(30.0, ConfirmationTarget::ChannelFunding) + ); + + assert_eq!( + cache[&ConfirmationTarget::OnchainPayment], + expected_rate(20.0, ConfirmationTarget::OnchainPayment) + ); + let urgent_sweep = ConfirmationTarget::Lightning(LdkConfirmationTarget::UrgentOnChainSweep); + assert_eq!(cache[&urgent_sweep], expected_rate(20.0, urgent_sweep)); + + let non_anchor_fee = + ConfirmationTarget::Lightning(LdkConfirmationTarget::NonAnchorChannelFee); + assert_eq!(cache[&non_anchor_fee], expected_rate(15.0, non_anchor_fee)); + let output_spending = + ConfirmationTarget::Lightning(LdkConfirmationTarget::OutputSpendingFee); + assert_eq!(cache[&output_spending], expected_rate(15.0, output_spending)); + + // The special-cased adjustment (trims towards the relay floor) still applies to a + // hook-sourced rate, exactly as it does for the native sources. + let min_non_anchor = ConfirmationTarget::Lightning( + LdkConfirmationTarget::MinAllowedNonAnchorChannelRemoteFee, + ); + assert_eq!(cache[&min_non_anchor], expected_rate(10.0, min_non_anchor)); + let close_min = ConfirmationTarget::Lightning(LdkConfirmationTarget::ChannelCloseMinimum); + assert_eq!(cache[&close_min], expected_rate(10.0, close_min)); + + let min_anchor = + ConfirmationTarget::Lightning(LdkConfirmationTarget::MinAllowedAnchorChannelRemoteFee); + assert_eq!(cache[&min_anchor], expected_rate(5.0, min_anchor)); + let anchor_fee = ConfirmationTarget::Lightning(LdkConfirmationTarget::AnchorChannelFee); + assert_eq!(cache[&anchor_fee], expected_rate(5.0, anchor_fee)); + } + #[tokio::test] - async fn chain_service_hooks_fee_estimates_hook_overrides_matching_targets() { + async fn chain_service_hooks_fee_estimates_sparse_map_falls_through_to_fallback() { let logger = test_logger(); + // Covers every required block count except 1008 -- a realistic "hook forgot one tier" + // scenario, not just a totally empty map. let hooks = ChainServiceHooks { fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { Box::pin(async { - let mut by_blocks = HashMap::new(); - by_blocks.insert(1u16, 50.0); - by_blocks.insert(6u16, 20.0); + let mut by_blocks = full_by_blocks_map(); + by_blocks.remove(&1008u16); Ok(by_blocks) }) })), broadcast: None, }; - let cache = resolve_fee_estimates(&hooks, &logger, || async { - panic!("fallback must not run when the hook succeeds") + let mut configured_source_cache = HashMap::new(); + configured_source_cache + .insert(ConfirmationTarget::OnchainPayment, FeeRate::from_sat_per_kwu(999)); + let expected = configured_source_cache.clone(); + + let fallback_called = Arc::new(AtomicBool::new(false)); + let fallback_called_clone = Arc::clone(&fallback_called); + let cache = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), move || { + let fallback_called = Arc::clone(&fallback_called_clone); + let configured_source_cache = configured_source_cache.clone(); + async move { + fallback_called.store(true, Ordering::SeqCst); + Ok(configured_source_cache) + } }) .await - .expect("hook path succeeds"); + .expect("fallback succeeds"); + + assert!( + fallback_called.load(Ordering::SeqCst), + "a map missing even one required block-count target must be rejected in full, not \ + partially applied" + ); + assert_eq!(cache, expected); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_empty_map_falls_through_to_fallback() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { + Box::pin(async { Ok(HashMap::new()) }) + })), + broadcast: None, + }; - // 1 block -> MaximumFeeEstimate; verified via the same adjustment the production code - // applies, so this doesn't hardcode (and risk drifting from) the bump formula. - let max_fee_target = - ConfirmationTarget::Lightning(LdkConfirmationTarget::MaximumFeeEstimate); - let expected_max_fee = - apply_post_estimation_adjustments(max_fee_target, FeeRate::from_sat_per_kwu(50 * 250)); - assert_eq!(cache.get(&max_fee_target), Some(&expected_max_fee)); + let mut configured_source_cache = HashMap::new(); + configured_source_cache + .insert(ConfirmationTarget::OnchainPayment, FeeRate::from_sat_per_kwu(999)); + let expected = configured_source_cache.clone(); + + let fallback_called = Arc::new(AtomicBool::new(false)); + let fallback_called_clone = Arc::clone(&fallback_called); + let cache = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), move || { + let fallback_called = Arc::clone(&fallback_called_clone); + let configured_source_cache = configured_source_cache.clone(); + async move { + fallback_called.store(true, Ordering::SeqCst); + Ok(configured_source_cache) + } + }) + .await + .expect("fallback succeeds"); + + assert!( + fallback_called.load(Ordering::SeqCst), + "an empty hook map must fall through to the configured source, never be silently \ + committed as a no-op cache with a fresh timestamp" + ); + // `resolve_fee_estimates` has exactly one return path per call: since the empty hook + // result was rejected, the only cache that could ever reach `commit_fee_rate_cache` + // (and therefore ever get a timestamp stamped) is this fallback cache. + assert_eq!(cache, expected); + } + + #[tokio::test] + async fn chain_service_hooks_fee_estimates_out_of_range_values_are_clamped() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { + Box::pin(async { + let mut by_blocks = full_by_blocks_map(); + // Below the floor -> must clamp to CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB, not to + // whatever a post-conversion `.max()` in sat/kwu units would have floored it + // to (that was 250x weaker than intended). + by_blocks.insert(3u16, 0.000_001); + // Absurdly large -> must clamp to CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB rather + // than risk an overflow downstream in `FeeRate::to_sat_per_vb_ceil`. + by_blocks.insert(12u16, 1.0e12); + Ok(by_blocks) + }) + })), + broadcast: None, + }; + + let cache = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), || async { + panic!("fallback must not run once the map covers every required target") + }) + .await + .expect("hook path succeeds (all required targets present, just out of range)"); - // 6 blocks -> both OnchainPayment and UrgentOnChainSweep get the same input rate. assert_eq!( - cache.get(&ConfirmationTarget::OnchainPayment), - Some(&FeeRate::from_sat_per_kwu(20 * 250)) + cache[&ConfirmationTarget::ChannelFunding], + expected_rate(CHAIN_SERVICE_HOOK_MIN_SAT_PER_VB, ConfirmationTarget::ChannelFunding) ); + let non_anchor_fee = + ConfirmationTarget::Lightning(LdkConfirmationTarget::NonAnchorChannelFee); assert_eq!( - cache.get(&ConfirmationTarget::Lightning(LdkConfirmationTarget::UrgentOnChainSweep)), - Some(&FeeRate::from_sat_per_kwu(20 * 250)) + cache[&non_anchor_fee], + expected_rate(CHAIN_SERVICE_HOOK_MAX_SAT_PER_VB, non_anchor_fee) ); - - // A target whose block count the hook didn't provide is simply absent from the cache - // (reads for it fall back to the estimator's own static per-target fallback). - assert!(!cache.contains_key(&ConfirmationTarget::ChannelFunding)); } #[tokio::test] @@ -1514,7 +1801,7 @@ mod tests { let fallback_called = Arc::new(AtomicBool::new(false)); let fallback_called_clone = Arc::clone(&fallback_called); - let cache = resolve_fee_estimates(&hooks, &logger, move || { + let cache = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), move || { let fallback_called = Arc::clone(&fallback_called_clone); let configured_source_cache = configured_source_cache.clone(); async move { @@ -1539,7 +1826,7 @@ mod tests { let fallback_called = Arc::new(AtomicBool::new(false)); let fallback_called_clone = Arc::clone(&fallback_called); - let _ = resolve_fee_estimates(&hooks, &logger, move || { + let _ = resolve_fee_estimates(&hooks, &logger, generous_test_timeout(), move || { let fallback_called = Arc::clone(&fallback_called_clone); async move { fallback_called.store(true, Ordering::SeqCst); @@ -1552,8 +1839,46 @@ mod tests { assert!(fallback_called.load(Ordering::SeqCst), "no hook configured must fall through"); } + #[tokio::test] + async fn chain_service_hooks_fee_estimates_hook_timeout_falls_through() { + let logger = test_logger(); + // Never resolves -- combined with a short injected timeout below, this proves the + // timeout actually fires rather than hanging the fee-update cycle forever. + let hooks = ChainServiceHooks { + fee_estimates: Some(Arc::new(|| -> FeeEstimatesFuture { + Box::pin(std::future::pending()) + })), + broadcast: None, + }; + + let mut configured_source_cache = HashMap::new(); + configured_source_cache + .insert(ConfirmationTarget::OnchainPayment, FeeRate::from_sat_per_kwu(999)); + let expected = configured_source_cache.clone(); + + let fallback_called = Arc::new(AtomicBool::new(false)); + let fallback_called_clone = Arc::clone(&fallback_called); + let cache = resolve_fee_estimates(&hooks, &logger, Duration::from_millis(10), move || { + let fallback_called = Arc::clone(&fallback_called_clone); + let configured_source_cache = configured_source_cache.clone(); + async move { + fallback_called.store(true, Ordering::SeqCst); + Ok(configured_source_cache) + } + }) + .await + .expect("fallback succeeds"); + + assert!( + fallback_called.load(Ordering::SeqCst), + "a hung hook must time out and fall through" + ); + assert_eq!(cache, expected); + } + #[tokio::test] async fn chain_service_hooks_broadcast_hook_ok_skips_p2p() { + let logger = test_logger(); let hooks = ChainServiceHooks { fee_estimates: None, broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { Box::pin(async { Ok(()) }) })), @@ -1561,13 +1886,14 @@ mod tests { let p2p_called = Arc::new(AtomicBool::new(false)); let p2p_called_clone = Arc::clone(&p2p_called); - let outcome = dispatch_broadcast(&hooks, Vec::new(), move |_txs| { - let p2p_called = Arc::clone(&p2p_called_clone); - async move { - p2p_called.store(true, Ordering::SeqCst); - } - }) - .await; + let outcome = + dispatch_broadcast(&hooks, &logger, generous_test_timeout(), Vec::new(), move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }) + .await; assert_eq!(outcome, BroadcastOutcome::HookHandled); assert!( @@ -1578,6 +1904,7 @@ mod tests { #[tokio::test] async fn chain_service_hooks_broadcast_hook_err_falls_through_to_p2p() { + let logger = test_logger(); let hooks = ChainServiceHooks { fee_estimates: None, broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { Box::pin(async { Err(()) }) })), @@ -1585,13 +1912,14 @@ mod tests { let p2p_called = Arc::new(AtomicBool::new(false)); let p2p_called_clone = Arc::clone(&p2p_called); - let outcome = dispatch_broadcast(&hooks, Vec::new(), move |_txs| { - let p2p_called = Arc::clone(&p2p_called_clone); - async move { - p2p_called.store(true, Ordering::SeqCst); - } - }) - .await; + let outcome = + dispatch_broadcast(&hooks, &logger, generous_test_timeout(), Vec::new(), move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }) + .await; assert_eq!(outcome, BroadcastOutcome::FellThroughToP2p); assert!( @@ -1602,19 +1930,89 @@ mod tests { #[tokio::test] async fn chain_service_hooks_broadcast_no_hook_falls_through_to_p2p() { + let logger = test_logger(); let hooks = ChainServiceHooks::default(); let p2p_called = Arc::new(AtomicBool::new(false)); let p2p_called_clone = Arc::clone(&p2p_called); - let outcome = dispatch_broadcast(&hooks, Vec::new(), move |_txs| { - let p2p_called = Arc::clone(&p2p_called_clone); - async move { - p2p_called.store(true, Ordering::SeqCst); - } - }) - .await; + let outcome = + dispatch_broadcast(&hooks, &logger, generous_test_timeout(), Vec::new(), move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }) + .await; assert_eq!(outcome, BroadcastOutcome::FellThroughToP2p); assert!(p2p_called.load(Ordering::SeqCst), "no hook configured must fall through to P2P"); } + + #[tokio::test] + async fn chain_service_hooks_broadcast_hook_timeout_falls_through_to_p2p() { + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: None, + broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { + Box::pin(std::future::pending()) + })), + }; + let p2p_called = Arc::new(AtomicBool::new(false)); + let p2p_called_clone = Arc::clone(&p2p_called); + + let outcome = dispatch_broadcast( + &hooks, + &logger, + Duration::from_millis(10), + Vec::new(), + move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }, + ) + .await; + + assert_eq!(outcome, BroadcastOutcome::FellThroughToP2p); + assert!( + p2p_called.load(Ordering::SeqCst), + "a hung broadcast hook must time out and fall through to P2P" + ); + } + + #[tokio::test] + async fn chain_service_hooks_broadcast_hook_succeeds_when_requester_unavailable() { + // Models `process_broadcast_package` once the CBF restart loop has given up + // (`CbfRuntimeStatus::Stopped`, no live `Requester`): the broadcast hook must still be + // tried, and if it accepts the package, the P2P leg (which needs the unavailable + // requester) is never reached at all. This is the fix for I-1: previously + // `process_broadcast_package` bailed out before even trying the hook once kyoto had + // given up -- exactly when an external hook might be the only working relay left (e.g. + // for a time-sensitive force-close tx). + let logger = test_logger(); + let hooks = ChainServiceHooks { + fee_estimates: None, + broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { Box::pin(async { Ok(()) }) })), + }; + let p2p_attempted = Arc::new(AtomicBool::new(false)); + let p2p_attempted_clone = Arc::clone(&p2p_attempted); + + let outcome = + dispatch_broadcast(&hooks, &logger, generous_test_timeout(), Vec::new(), move |_txs| { + let p2p_attempted = Arc::clone(&p2p_attempted_clone); + async move { + // Stands in for `process_broadcast_package`'s real closure when there is no + // live `Requester` -- it would log an error and return without broadcasting. + p2p_attempted.store(true, Ordering::SeqCst); + } + }) + .await; + + assert_eq!(outcome, BroadcastOutcome::HookHandled); + assert!( + !p2p_attempted.load(Ordering::SeqCst), + "the broadcast hook must succeed without the (unavailable) P2P leg ever running" + ); + } } diff --git a/src/chain/mod.rs b/src/chain/mod.rs index af7a91c270..3459255a55 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -148,9 +148,23 @@ pub type BroadcastFuture = /// payment protocol a caller might use to obtain fee data or relay a broadcast. #[derive(Clone, Default)] pub struct ChainServiceHooks { - /// conf-target (blocks) → sat/vB. Err/None → fall through to fee_source / block-derived. + /// conf-target (blocks) → sat/vB. + /// + /// Applied **all-or-nothing**: the returned map MUST include a finite entry for every one + /// of the six distinct block-count targets the estimator needs — `1`, `3`, `6`, `12`, + /// `144`, and `1008` blocks — or the whole result is discarded and treated exactly like + /// `Err(())`. A map covering only some of these is never partially applied (the underlying + /// cache is a full replace, not a merge, so a partial map would silently pin the omitted + /// targets to the crate's static fallback rate rather than a live estimate). Accepted + /// sat/vB values are clamped to a sane range before use. The app-side endpoint backing this + /// hook must serve estimates for exactly these six block counts every time it is called. + /// + /// `Err(())`, an incomplete/empty map, a timeout, or leaving this unset all fall through to + /// `fee_source` / block-derived estimation. pub fee_estimates: Option FeeEstimatesFuture + Send + Sync>>, - /// Attempt external broadcast of raw txs. Err → fall through to P2P broadcast. + /// Attempt external broadcast of raw txs. Tried even if the underlying CBF/kyoto runtime is + /// not currently running. `Err(())`, a timeout, or leaving this unset falls through to P2P + /// broadcast. pub broadcast: Option) -> BroadcastFuture + Send + Sync>>, } diff --git a/src/lib.rs b/src/lib.rs index d68fd393dd..f8068d331f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -141,6 +141,21 @@ pub use fee_estimator::{FeerateQuote, SwapFeerateSource}; #[cfg(feature = "swaps")] pub use chain::{ChainStatus, TxStatus}; +/// Public external chain-service hook types for the CBF chain source (fee estimates + +/// broadcast short-circuit). Re-exported from the otherwise-private `chain` module so a +/// consumer can name [`ChainServiceHooks`] to call +/// [`crate::builder::NodeBuilder::set_cbf_chain_service_hooks`]. Unlike the swap-primitive +/// re-exports above, these are CBF-only and not gated behind the `swaps` feature — matching +/// [`chain::ChainServiceHooks`]'s own (ungated) definition. +pub use chain::{BroadcastFuture, ChainServiceHooks, FeeEstimatesFuture}; + +/// Optional external fee-estimation backend for [`set_chain_source_cbf`], re-exported from the +/// otherwise-private `chain` module so a consumer can name it. Not gated behind `swaps`, matching +/// [`chain::CbfFeeSourceConfig`]'s own (ungated) definition. +/// +/// [`set_chain_source_cbf`]: crate::builder::NodeBuilder::set_chain_source_cbf +pub use chain::CbfFeeSourceConfig; + /// Public types appearing in the swap-primitive signatures on [`Node`] /// (Peerswap native primitives, B-series). Re-exported so the consumer crate /// can name them without reaching into the (otherwise-private) LDK/bitcoin From cd8dd58701ba2ce34b5bad44a622ce68dbb57cbf Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 03:15:39 +0700 Subject: [PATCH 133/138] feat(cbf): expose simplified public CBF sync status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `pub enum CbfSyncStatus { Syncing, Synced, Failed }` (chain/mod.rs, re-exported at the crate root) and `Node::cbf_sync_status(&self) -> Option` — `None` for non-CBF chain sources. Deliberately does NOT publicize the internal, error-carrying `CbfSyncState` (still private to chain/cbf.rs); the mapping is a pure `simplify_sync_state` function so it's unit-testable without a live kyoto node. Additive only: `git diff bfb87e3 -- src/lib.rs` shows one new re-export line and one new `impl Node` method, nothing else touched — no existing public item changed. This closes the gap the app-side (modules/ldk-node) Task 8 dispatch identified: the host API's `/api/v2/chain-mode` GET `running.sync_state` needs to surface a parked CBF restart-loop failure, and the app's get_sync_status classifier needs the real initial-sync signal rather than inferring it from the absence of a sync timestamp alone. AI disclosure: implemented with Claude Code (Claude Fable 5, Anthropic), under human review. Co-Authored-By: Claude Fable 5 --- src/chain/cbf.rs | 55 +++++++++++++++++++++++++++++++++++++++++++++++- src/chain/mod.rs | 33 +++++++++++++++++++++++++++++ src/lib.rs | 16 ++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index f27d7030ff..a01adef452 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -17,7 +17,7 @@ use tokio::sync::{mpsc, watch}; use crate::chain::bitcoind::ChainListener; use crate::chain::electrum::get_electrum_fee_rate_cache_update; -use crate::chain::{CbfFeeSourceConfig, ChainServiceHooks}; +use crate::chain::{CbfFeeSourceConfig, CbfSyncStatus, ChainServiceHooks}; use crate::config::{Config, DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS}; use crate::error::Error; use crate::fee_estimator::{ @@ -121,6 +121,17 @@ enum CbfSyncState { Failed(Error), } +/// Pure mapping from the internal, error-carrying [`CbfSyncState`] to the +/// externally-consumable [`CbfSyncStatus`] — extracted so the mapping is +/// unit-testable without a live kyoto node or `watch` channel. +fn simplify_sync_state(state: CbfSyncState) -> CbfSyncStatus { + match state { + CbfSyncState::Active { synced_to_tip: true, .. } => CbfSyncStatus::Synced, + CbfSyncState::Active { synced_to_tip: false, .. } => CbfSyncStatus::Syncing, + CbfSyncState::Failed(_) => CbfSyncStatus::Failed, + } +} + /// Marks that we are applying a block past the last `FiltersSynced` tip, so a `sync_wallets` call /// issued after new blocks are mined waits for the next `FiltersSynced` rather than returning on a /// stale `synced_to_tip`. Only flips (and notifies waiters) when currently set. @@ -684,6 +695,13 @@ impl CbfChainSource { &self.fee_estimator } + /// Returns a simplified, externally-consumable snapshot of the current + /// CBF sync state. Never blocks — reads the current value of the + /// `watch` channel without waiting for a change. + pub(super) fn sync_status(&self) -> CbfSyncStatus { + simplify_sync_state(*self.sync_state_tx.borrow()) + } + pub(crate) fn start(&self, chain_listener: ChainListener) { let (node, client) = Self::build_kyoto(&self.trusted_peers, &self.config, &self.logger, &chain_listener); @@ -1486,6 +1504,41 @@ mod tests { use super::*; use bitcoin::hashes::Hash; + // ------------------------------------------------------------------------------------------ + // simplify_sync_state: internal CbfSyncState -> public CbfSyncStatus mapping. + // ------------------------------------------------------------------------------------------ + + #[test] + fn simplify_sync_state_active_not_synced_is_syncing() { + let state = CbfSyncState::Active { applied_tip: Some(100), synced_to_tip: false }; + assert_eq!(simplify_sync_state(state), CbfSyncStatus::Syncing); + } + + #[test] + fn simplify_sync_state_active_no_applied_tip_is_syncing() { + // Freshly constructed state before `start()` ever ran. + let state = CbfSyncState::Active { applied_tip: None, synced_to_tip: false }; + assert_eq!(simplify_sync_state(state), CbfSyncStatus::Syncing); + } + + #[test] + fn simplify_sync_state_active_synced_to_tip_is_synced() { + let state = CbfSyncState::Active { applied_tip: Some(900_000), synced_to_tip: true }; + assert_eq!(simplify_sync_state(state), CbfSyncStatus::Synced); + } + + #[test] + fn simplify_sync_state_failed_is_failed_regardless_of_error_variant() { + assert_eq!( + simplify_sync_state(CbfSyncState::Failed(Error::NotRunning)), + CbfSyncStatus::Failed + ); + assert_eq!( + simplify_sync_state(CbfSyncState::Failed(Error::TxSyncFailed)), + CbfSyncStatus::Failed + ); + } + #[test] fn parse_peer_accepts_hostname() { let p = parse_trusted_peer("bitcoind.local:18444").expect("hostname peer"); diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 3459255a55..0e0b2647b5 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -129,6 +129,28 @@ pub enum CbfFeeSourceConfig { Electrum(String), } +/// A simplified, externally-consumable snapshot of the CBF chain source's sync +/// state. +/// +/// This deliberately does NOT expose the crate-internal, error-carrying sync +/// state type the CBF chain source tracks internally — only whether it is +/// still catching up, has caught up to the network tip, or has given up. +/// Returned by [`crate::Node::cbf_sync_status`]; `None` for every other chain +/// source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +pub enum CbfSyncStatus { + /// CBF has not yet caught up to the network tip — either the initial + /// compact-filter sync, or catching up again after falling behind. + Syncing, + /// CBF has caught up to the network tip and applied all pending blocks. + Synced, + /// The CBF background restart loop gave up after repeated failures, or + /// the chain source has been cleanly stopped (e.g. during node + /// shutdown/reload). Not currently making sync progress either way. + Failed, +} + /// A future resolving to per-conf-target (blocks) fee estimates in sat/vB, or `Err(())` if the /// external chain service could not provide them this cycle. pub type FeeEstimatesFuture = std::pin::Pin< @@ -210,6 +232,17 @@ impl ChainSource { } } + /// Returns a snapshot of the CBF chain source's sync status + /// ([`CbfSyncStatus`]), or `None` if this chain source is not CBF. + pub(crate) fn cbf_sync_status(&self) -> Option { + match &self.kind { + ChainSourceKind::Cbf(cbf_chain_source) => Some(cbf_chain_source.sync_status()), + ChainSourceKind::Esplora(_) + | ChainSourceKind::Electrum(_) + | ChainSourceKind::Bitcoind(_) => None, + } + } + pub(crate) fn new_esplora( server_url: String, headers: HashMap, sync_config: EsploraSyncConfig, fee_estimator: Arc, tx_broadcaster: Arc, diff --git a/src/lib.rs b/src/lib.rs index f8068d331f..2e56326e22 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -156,6 +156,11 @@ pub use chain::{BroadcastFuture, ChainServiceHooks, FeeEstimatesFuture}; /// [`set_chain_source_cbf`]: crate::builder::NodeBuilder::set_chain_source_cbf pub use chain::CbfFeeSourceConfig; +/// Simplified CBF sync-status snapshot, re-exported from the otherwise-private +/// `chain` module so a consumer can name the return type of +/// [`Node::cbf_sync_status`]. +pub use chain::CbfSyncStatus; + /// Public types appearing in the swap-primitive signatures on [`Node`] /// (Peerswap native primitives, B-series). Re-exported so the consumer crate /// can name them without reaching into the (otherwise-private) LDK/bitcoin @@ -986,6 +991,17 @@ impl Node { self.config.as_ref().clone() } + /// Returns a snapshot of the CBF chain source's sync status + /// ([`CbfSyncStatus`]), or `None` if this [`Node`] is not configured with + /// the CBF chain source (i.e. [`set_chain_source_cbf`] was not called). + /// + /// Never blocks — reads the current value without waiting for a change. + /// + /// [`set_chain_source_cbf`]: crate::builder::NodeBuilder::set_chain_source_cbf + pub fn cbf_sync_status(&self) -> Option { + self.chain_source.cbf_sync_status() + } + /// Returns the next event in the event queue, if currently available. /// /// Will return `Some(..)` if an event is available and `None` otherwise. From d3fcad0f50c89bba800daa7182f1a03f04007b9f Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 16:42:47 +0700 Subject: [PATCH 134/138] fix(cbf): bound the kyoto P2P broadcast handoff so one un-pulled tx cannot wedge the queue `Requester::submit_package` does NOT resolve when a transaction reaches the network. It resolves when a peer PULLS it: kyoto announces the wtxid in an `inv` and completes the caller's oneshot only from the code path that answers that peer's `getdata` (bip157 network/peer.rs -> BroadcastQueue:: sent_transaction_payload). A peer that ALREADY HAS the transaction never sends `getdata` -- Bitcoin Core evaluates AlreadyHaveTx and logs `got inv: wtx have peer=N` -- so the future never resolves, and kyoto attaches no timeout of its own. process_broadcast_package awaited it unbounded, on the SINGLE SERIAL drain loop ChainSource::continuously_process_broadcast_queue. One duplicate broadcast therefore wedged every LATER broadcast for the lifetime of the node. Re-broadcasting a transaction the network already has is routine, not exceptional: both sides of a cooperative close broadcast the same closing tx, both sides of a force close may broadcast the same commitment, and LDK re-broadcasts unconfirmed transactions on a timer. Measured on a live regtest pair: the Verification node re-broadcast the cooperative-close transaction its Pro counterparty had already relayed, Core answered the `inv` with silence, and from that second on NOTHING the node broadcast ever reached the network -- 18+ regenerated force-close `to_remote` sweeps over nine minutes, plus a brand-new unrelated on-chain send whose capability call returned success and whose txid does not exist anywhere. Wire counters for the whole session: inv=122 bytes (two announcements), getdata=61 (one request), tx=246 (one payload). Fix: CBF_P2P_BROADCAST_TIMEOUT_SECS (10s, a sibling of the file's existing CBF_BLOCK_FETCH_TIMEOUT_SECS, which carries the same rationale) plus `bounded_p2p_handoff`, a free function -- unit-testable without a live kyoto node, same shape as the existing dispatch_broadcast -- that awaits one submit_package future under that budget. A submit error keeps the existing log_error!; expiry logs at INFO, because the common cause is benign. Timing out does not retract the announcement: kyoto keeps the transaction in its BroadcastQueue, still answers a later `getdata` for it, and re-announces every pending wtxid to any peer that completes a handshake afterwards. LDK re-broadcasts on its own timer regardless. The only thing that changes is that the queue behind it keeps moving. Public API unchanged (both new items are private). No bip157 change. Tests: 4 new in chain::cbf::tests -- an un-pulled handoff is abandoned rather than awaited forever; a serial drain of [duplicate close (never pulled), sweep #1, sweep #2] finishes with BOTH sweeps relayed (the fund-safety property, in the shape of the failing E2E scenario); a pulled handoff is still awaited to completion (non-vacuity); a submit error stays a fast, distinct failure. Reverting the timeout fails exactly the first two and passes the other two. Gates: cargo test --lib --features swaps,cycles = 129 passed (125 -> +4); cargo check --features swaps,cycles --tests clean; clippy finding multiset identical before/after (git stash + diff), zero findings in the new code; cargo fmt --all. Live: `make e2e-chain-modes` scenario 10 (force close: CBF node detects + recovers funds) 561s FAIL -> 60s PASS, bob's ~100k-sat to_remote recovered, sweep observed in bitcoind's mempool. AI disclosure: implemented with Claude Code (Claude Fable 5, Anthropic), under human review. Co-Authored-By: Claude Fable 5 --- src/chain/cbf.rs | 224 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 211 insertions(+), 13 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index a01adef452..45d85be20d 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -395,6 +395,27 @@ const CBF_MIN_FEERATE_SAT_PER_KWU: u64 = 250; /// delays one sample) rather than stalling. const CBF_BLOCK_FETCH_TIMEOUT_SECS: u64 = 10; +/// Per-transaction timeout on the kyoto P2P broadcast handoff. +/// +/// [`Requester::submit_package`] resolves only once a peer has actually PULLED the transaction: +/// kyoto announces the wtxid in an `inv` and completes the caller's oneshot when it answers the +/// peer's `getdata`. A peer that already knows the transaction never sends `getdata` — Bitcoin +/// Core logs `got inv: wtx have peer=N` and drops it — so that future NEVER resolves, and +/// kyoto attaches no timeout of its own. +/// +/// Re-broadcasting a transaction the network already has is routine, not exceptional: both sides +/// of a cooperative close broadcast the same closing transaction, both sides of a force close may +/// broadcast the same commitment, and LDK re-broadcasts unconfirmed transactions on a timer. Since +/// [`crate::chain::ChainSource::continuously_process_broadcast_queue`] drains SERIALLY, a single +/// unresolvable handoff would wedge every LATER broadcast for the lifetime of the node — +/// including the force-close sweeps that recover a channel balance. Hence the bound, matching the +/// file's other kyoto-request timeout ([`CBF_BLOCK_FETCH_TIMEOUT_SECS`]). +/// +/// Timing out does not retract the announcement: the transaction stays in kyoto's broadcast queue +/// and is still served if a peer asks for it later, and is re-announced to every peer that +/// completes a handshake afterwards. +const CBF_P2P_BROADCAST_TIMEOUT_SECS: u64 = 10; + /// Recent per-block coinbase-derived fee rates, keyed by height so we can window on the tip, evict /// stale entries, and detect reorged-out blocks (a height whose cached hash no longer matches the /// canonical chain). Shared via `Arc` between the fee estimator and the [`BlockApplicator`]. @@ -606,6 +627,46 @@ where BroadcastOutcome::FellThroughToP2p } +/// Awaits ONE kyoto broadcast handoff under `hold_timeout`, never propagating a failure. +/// +/// `submit` is a [`Requester::submit_package`] future; `what` names the transaction (or package) +/// for the log. Returns `true` only when a peer pulled the transaction inside the budget. +/// +/// The timeout is the whole point — see [`CBF_P2P_BROADCAST_TIMEOUT_SECS`] for why an unbounded +/// await here wedges the entire serial broadcast queue. Expiring is logged at INFO rather than +/// ERROR because the overwhelmingly common cause is benign (the peer already has the transaction); +/// a genuine relay failure shows up as the transaction never confirming, which the caller's own +/// re-broadcast timer keeps retrying. +/// +/// A free function (not a method), generic over the future, so it is unit-testable without a live +/// kyoto node — the same pattern as [`dispatch_broadcast`]. +async fn bounded_p2p_handoff( + logger: &Logger, hold_timeout: Duration, what: &str, submit: Fut, +) -> bool +where + Fut: std::future::Future>, + E: std::fmt::Debug, +{ + match tokio::time::timeout(hold_timeout, submit).await { + Ok(Ok(_)) => true, + Ok(Err(e)) => { + log_error!(logger, "Failed to broadcast {}: {:?}", what, e); + false + }, + Err(_elapsed) => { + log_info!( + logger, + "No peer requested {} within {:?}; it stays queued for relay in the CBF client \ + (a peer that already has a transaction never asks for it) and the broadcast \ + queue moves on.", + what, + hold_timeout, + ); + false + }, + } +} + impl CbfChainSource { pub(crate) fn new( peers: Vec, fee_source_config: Option, runtime: Arc, @@ -1252,23 +1313,27 @@ impl CbfChainSource { ); return; }; + let hold_timeout = Duration::from_secs(CBF_P2P_BROADCAST_TIMEOUT_SECS); match Package::from_vec(package.clone()) { - Ok(package) => { - if let Err(e) = requester.submit_package(package).await { - log_error!(logger, "Failed to broadcast transaction package: {:?}", e); - } + Ok(kyoto_package) => { + bounded_p2p_handoff( + &logger, + hold_timeout, + "the transaction package", + requester.submit_package(kyoto_package), + ) + .await; }, Err(_) => { for tx in package { - let txid = tx.compute_txid(); - if let Err(e) = requester.submit_package(tx).await { - log_error!( - logger, - "Failed to broadcast transaction {}: {:?}", - txid, - e - ); - } + let what = format!("transaction {}", tx.compute_txid()); + bounded_p2p_handoff( + &logger, + hold_timeout, + &what, + requester.submit_package(tx), + ) + .await; } }, } @@ -2068,4 +2133,137 @@ mod tests { "the broadcast hook must succeed without the (unavailable) P2P leg ever running" ); } + + // ------------------------------------------------------------------------------------------ + // P2P broadcast handoff (defect P2). `Requester::submit_package` completes only when a peer + // PULLS the transaction, so it never completes for a transaction the peer already has — and + // `continuously_process_broadcast_queue` drains serially, so one such handoff used to wedge + // every broadcast behind it (measured live: a cooperative-close re-broadcast stalled the + // queue, and NONE of the force-close sweeps generated over the next nine minutes ever + // reached the network). + // ------------------------------------------------------------------------------------------ + + /// Stand-in for `Requester::submit_package` against a peer that already knows the + /// transaction: kyoto queued the announcement, Bitcoin Core answered the `inv` with silence + /// (`got inv: wtx have peer=N`), and the oneshot is therefore never completed. + fn never_pulled_by_a_peer() -> impl std::future::Future> { + std::future::pending() + } + + /// Short enough that the never-pulled cases cost the suite nothing, long enough that the + /// pulled cases below are never mistaken for one. + fn short_handoff_timeout() -> Duration { + Duration::from_millis(50) + } + + #[tokio::test] + async fn a_handoff_no_peer_pulls_is_abandoned_rather_than_awaited_forever() { + let logger = test_logger(); + + // The outer timeout is what makes this a test rather than a hang: without the bound + // inside `bounded_p2p_handoff` the inner future is `Pending` forever. + let relayed = tokio::time::timeout( + Duration::from_secs(5), + bounded_p2p_handoff( + &logger, + short_handoff_timeout(), + "the transaction under test", + never_pulled_by_a_peer(), + ), + ) + .await + .expect( + "the P2P handoff must return on its own; an unbounded await here is the P2 wedge \ + that stops every later broadcast, force-close sweeps included", + ); + + assert!(!relayed, "a transaction no peer asked for was not relayed"); + } + + #[tokio::test] + async fn a_handoff_a_peer_does_pull_is_awaited_to_completion() { + // Non-vacuity for the bound: it must not turn every handoff into a timeout. + let logger = test_logger(); + + let relayed = bounded_p2p_handoff( + &logger, + generous_test_timeout(), + "the transaction under test", + async { + tokio::time::sleep(Duration::from_millis(5)).await; + Ok::<(), ()>(()) + }, + ) + .await; + + assert!(relayed, "a transaction a peer pulled must be reported as relayed"); + } + + #[tokio::test] + async fn a_handoff_rejected_by_the_cbf_client_reports_failure_without_stalling() { + // `submit_package` errors when the kyoto node has stopped. That is a fast, honest + // failure and must stay distinct from the timeout path. + let logger = test_logger(); + + let relayed = bounded_p2p_handoff( + &logger, + generous_test_timeout(), + "the transaction under test", + async { Err::<(), &str>("the CBF node has stopped") }, + ) + .await; + + assert!(!relayed, "a submit error must not be reported as a relay"); + } + + #[tokio::test] + async fn a_handoff_no_peer_pulls_does_not_wedge_the_broadcasts_behind_it() { + // The scenario-10 shape in miniature: the node re-broadcasts a cooperative-close + // transaction its counterparty already relayed (so no peer ever pulls it), and the + // force-close sweeps queued behind it must still go out. This is the property that + // makes the bound load-bearing, because the real drain loop is serial. + let logger = test_logger(); + let relayed: Arc>> = Arc::new(Mutex::new(Vec::new())); + + // (what, will a peer pull it?) + let queue = vec![ + ("the duplicate cooperative-close tx", false), + ("force-close sweep #1", true), + ("force-close sweep #2", true), + ]; + + let drained = tokio::time::timeout(Duration::from_secs(5), async { + for (what, pulled) in queue { + let ok = if pulled { + bounded_p2p_handoff(&logger, short_handoff_timeout(), what, async { + Ok::<(), ()>(()) + }) + .await + } else { + bounded_p2p_handoff( + &logger, + short_handoff_timeout(), + what, + never_pulled_by_a_peer(), + ) + .await + }; + if ok { + relayed.lock().expect("lock").push(what); + } + } + }) + .await; + + assert!( + drained.is_ok(), + "the serial broadcast drain must finish; hanging here is exactly the fund-safety \ + defect (sweeps generated forever, none relayed)" + ); + assert_eq!( + *relayed.lock().expect("lock"), + vec!["force-close sweep #1", "force-close sweep #2"], + "every broadcast queued behind an un-pulled one must still reach a peer" + ); + } } From 51d113de6e4fcbb03c88860b1b84b7a49f467a69 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 17:22:45 +0700 Subject: [PATCH 135/138] fix(chain): an unprovable replay below a listener's tip is not divergence `ListenerAction`'s own doc comment states the design: listener durability is not synchronized, so the chain source resumes from the MINIMUM height across listeners and replays blocks to any listener that persisted further ahead. The classifier then contradicted that design. Below a listener's tip it proved membership from `BlockLocator::previous_blocks`, and treated BOTH "the ancestor I hold differs" (a real fork) and "I hold no ancestor that far back" (undecidable) as `Diverged` -- which halts CBF block application permanently. `previous_blocks` is `[Option; ANTI_REORG_DELAY * 2]`, i.e. 12 entries, capped by LDK; a locator restored from persistence can carry none at all. Real skew is larger than that. Measured live on a healthy regtest node right after a force-close sweep: wallet/sweeper 7871, ChainMonitor 7888, ChannelManager 7891 -- a 20-block spread, all three tips on the canonical chain (both hashes appear in the blocks kyoto downloaded seconds later). The replay from 7872 therefore hit "no ancestry that far back" on the two listeners that were AHEAD, and: ERROR Halting CBF block application: ChannelManager diverged at height 7872 (listener at 7891, hash 2fcdb185...). The node must be restarted to re-derive a common chain state ... Restarting does not help -- it re-derives the same minimum and re-halts. It fired on 11 consecutive restarts and `sync_state` latched `failed` for the rest of the session on a node whose chain state was fine. Fix: split the undecidable case out as `ListenerAction::ReplayUnprovable`. The block is still WITHHELD from that listener (it is not `AlreadyApplied`) and still logged, but it no longer records a halting divergence. An ancestor we DO hold that differs remains `Diverged`, as do a wrong parent at `best.height + 1`, a different block at `best.height`, and a gap. Nothing is given up. The replay keeps climbing, and when it reaches `best.height` the classifier compares the block hash exactly, and at `best.height + 1` the parent hash -- both PROVABLE. A listener genuinely on a fork is still caught, a few blocks later, by a check that can actually decide it. What changes is only that we stop halting the whole chain source on a question we cannot answer, in the exact situation resume-from-minimum is designed to create. Tests: `listener_action_reports_divergence_beyond_known_ancestry` becomes `..._reports_an_unprovable_replay_beyond_known_ancestry` (its intent -- "must not be assumed to be a safe replay" -- is preserved; the block is still not delivered). Two added: an ancestry-free locator inside the window is unprovable, not forked; and an unprovable replay still meets a provable check at the listener's own tip, where a fork IS reported. Reverting the fix fails exactly those three and leaves the five fork-detection tests green. Gates: cargo test --lib --features swaps,cycles = 131 passed; clippy finding multiset identical to cd8dd58; cargo fmt --all. Live: `make e2e-chain-modes` 10/10 (scenario 8 "failure paths: bad config, bad peers, recovery" 199s FAIL -> 24s PASS, `sync_state == synced after recovery` on the first poll). Found while fixing the CBF broadcast wedge (d3fcad0): with force-close sweeps finally reaching the network, the suite reached a post-sweep restart for the first time and walked straight into this. AI disclosure: implemented with Claude Code (Claude Fable 5, Anthropic), under human review. Co-Authored-By: Claude Fable 5 --- src/chain/bitcoind.rs | 102 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 6 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index f2e055298d..2bca4d2d80 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -1598,6 +1598,22 @@ pub(crate) enum ListenerAction { Deliver, /// The listener already has this exact block on its chain; skip it. AlreadyApplied, + /// The block sits BELOW the listener's tip, but our ancestry record does not reach that far, + /// so we can neither confirm nor refute that it is on the listener's chain. Skip it — but, + /// unlike [`Self::Diverged`], do NOT halt the chain source. + /// + /// This is the *expected* case whenever listener durability skews by more than + /// `previous_blocks.len()` (LDK's `BlockLocator` carries `ANTI_REORG_DELAY * 2` = 12 + /// ancestors, and a locator restored from persistence can carry none at all), which is + /// exactly the situation the resume-from-minimum design above sets up. Treating it as + /// divergence halts CBF block application permanently for a listener that is merely further + /// ahead on the same chain. + /// + /// Nothing is given up by not halting here: the replay keeps climbing, and when it reaches + /// `best.height` the same-height arm compares the block hash exactly, and at `best.height + 1` + /// the parent hash — both PROVABLE. A listener genuinely sitting on a fork is therefore still + /// caught, a few blocks later, by a check that can actually decide it. + ReplayUnprovable, /// The listener cannot accept this block without first being rewound. Diverged, } @@ -1631,9 +1647,12 @@ pub(crate) fn listener_action( let idx = (best.height - height - 1) as usize; return match best.previous_blocks.get(idx) { Some(Some(ancestor)) if *ancestor == block_hash => ListenerAction::AlreadyApplied, - // Either the ancestor differs (a fork), or we have no record that far back and - // therefore cannot prove this block is ours. Both must be treated as divergence. - _ => ListenerAction::Diverged, + // An ancestor we DO hold at that height and that differs is a proven fork. + Some(Some(_)) => ListenerAction::Diverged, + // Empty slot (a locator restored from persistence carries no ancestry) or past the + // end of the 12-deep window: unprovable either way, and NOT evidence of a fork. See + // `ListenerAction::ReplayUnprovable`. + Some(None) | None => ListenerAction::ReplayUnprovable, }; } @@ -1674,6 +1693,21 @@ impl ChainListener { self.divergence.lock().unwrap().take() } + /// Logs a replay this listener is too far ahead of for us to prove ancestry for. + /// + /// Deliberately does NOT touch `self.divergence`: this is the ordinary consequence of the + /// resume-from-minimum design, not a fork. See [`ListenerAction::ReplayUnprovable`]. + fn log_unprovable_replay(&self, who: &str, best: &BlockLocator, height: u32) { + log_debug!( + self.logger, + "{} is at height {} and holds no ancestry back to height {}; skipping the replay of \ + that block for it (its own tip is re-checked by hash when the replay reaches it).", + who, + best.height, + height, + ); + } + fn log_divergence(&self, who: &str, best: &BlockLocator, height: u32) { let mut recorded = self.divergence.lock().unwrap(); if recorded.is_none() { @@ -1713,6 +1747,9 @@ impl Listen for ChainListener { self.channel_manager.filtered_block_connected(header, txdata, height) }, ListenerAction::AlreadyApplied => {}, + ListenerAction::ReplayUnprovable => { + self.log_unprovable_replay("ChannelManager", &cm_best, height) + }, ListenerAction::Diverged => self.log_divergence("ChannelManager", &cm_best, height), } @@ -1726,6 +1763,9 @@ impl Listen for ChainListener { ListenerAction::Deliver | ListenerAction::AlreadyApplied => { self.chain_monitor.filtered_block_connected(header, txdata, height) }, + ListenerAction::ReplayUnprovable => { + self.log_unprovable_replay("ChainMonitor", &monitor_best, height) + }, ListenerAction::Diverged => { self.log_divergence("ChainMonitor", &monitor_best, height) }, @@ -1741,6 +1781,9 @@ impl Listen for ChainListener { self.output_sweeper.filtered_block_connected(header, txdata, height) }, ListenerAction::AlreadyApplied => {}, + ListenerAction::ReplayUnprovable => { + self.log_unprovable_replay("OutputSweeper", &sweeper_best, height) + }, ListenerAction::Diverged => self.log_divergence("OutputSweeper", &sweeper_best, height), } } @@ -1754,6 +1797,9 @@ impl Listen for ChainListener { match listener_action(&cm_best, block_hash, block.header.prev_blockhash, height) { ListenerAction::Deliver => self.channel_manager.block_connected(block, height), ListenerAction::AlreadyApplied => {}, + ListenerAction::ReplayUnprovable => { + self.log_unprovable_replay("ChannelManager", &cm_best, height) + }, ListenerAction::Diverged => self.log_divergence("ChannelManager", &cm_best, height), } @@ -1768,6 +1814,9 @@ impl Listen for ChainListener { ListenerAction::Deliver | ListenerAction::AlreadyApplied => { self.chain_monitor.block_connected(block, height) }, + ListenerAction::ReplayUnprovable => { + self.log_unprovable_replay("ChainMonitor", &monitor_best, height) + }, ListenerAction::Diverged => { self.log_divergence("ChainMonitor", &monitor_best, height) }, @@ -1780,6 +1829,9 @@ impl Listen for ChainListener { match listener_action(&sweeper_best, block_hash, block.header.prev_blockhash, height) { ListenerAction::Deliver => self.output_sweeper.block_connected(block, height), ListenerAction::AlreadyApplied => {}, + ListenerAction::ReplayUnprovable => { + self.log_unprovable_replay("OutputSweeper", &sweeper_best, height) + }, ListenerAction::Diverged => self.log_divergence("OutputSweeper", &sweeper_best, height), } } @@ -1904,13 +1956,51 @@ mod tests { } #[test] - fn listener_action_reports_divergence_beyond_known_ancestry() { + fn listener_action_reports_an_unprovable_replay_beyond_known_ancestry() { // `previous_blocks` holds 12 ancestors; past that we cannot prove the block is ours, so it - // must not be assumed to be a safe replay. + // must not be assumed to be a safe replay — it is NOT `AlreadyApplied`, the block is still + // withheld from the listener. + // + // It is not `Diverged` either. Resume-from-minimum deliberately replays blocks to any + // listener that persisted further ahead (see `ListenerAction`'s own doc comment), and + // listener durability routinely skews by more than 12 blocks — measured live at 20 + // (wallet 7871 / ChainMonitor 7888 / ChannelManager 7891, all three on the canonical + // chain). Calling that divergence halted CBF block application PERMANENTLY on a healthy + // node: every later restart re-derived the same minimum, re-hit the same unprovable + // replay, and re-halted, so `sync_state` latched `failed` forever. assert_eq!( listener_action(&locator(100, 50), hash(1), hash(0), 50), - ListenerAction::Diverged + ListenerAction::ReplayUnprovable + ); + } + + #[test] + fn listener_action_reports_an_unprovable_replay_when_the_locator_carries_no_ancestry() { + // `BlockLocator::new` leaves every ancestry slot empty, which is what a listener restored + // from persistence can look like. Inside the window but with nothing recorded is still + // "cannot decide", not "forked". + let no_ancestry = BlockLocator::new(hash(50), 100); + assert_eq!( + listener_action(&no_ancestry, hash(48), hash(47), 98), + ListenerAction::ReplayUnprovable + ); + } + + #[test] + fn an_unprovable_replay_still_meets_a_provable_check_at_the_listeners_own_tip() { + // The safety property that makes `ReplayUnprovable` safe to not halt on: the replay keeps + // climbing, and at the listener's own height the classifier compares block hashes + // exactly. A listener genuinely on a fork is caught there instead of below it. + let ahead = locator(100, 50); + // Below the tip, past the ancestry window: undecidable, keep going. + assert_eq!(listener_action(&ahead, hash(1), hash(0), 50), ListenerAction::ReplayUnprovable); + // At the tip on the same chain: a plain replay. + assert_eq!( + listener_action(&ahead, hash(50), hash(49), 100), + ListenerAction::AlreadyApplied ); + // At the tip on a DIFFERENT chain: still caught, still halts. + assert_eq!(listener_action(&ahead, hash(0xbb), hash(0xaa), 100), ListenerAction::Diverged); } #[test] From fb8ce39c14a278ce21b63c2c5f47f36b18d45434 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Sun, 9 Aug 2026 17:51:41 +0700 Subject: [PATCH 136/138] fix(chain): fail closed when a replay can never reach a listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ReplayUnprovable` defers a decision it cannot make, on the argument that the replay keeps climbing and will meet the listener at its own tip, where the hash comparison is provable. That argument holds only while the chain being replayed is at least as long as the listener's chain. It is not, in one geometry: a reorg that leaves the canonical tip BELOW an ahead listener's persisted tip — deeper than the 12-entry ancestry window, or any depth at all against a locator restored from persistence with no ancestors. The replay then ends before it can prove anything, every block is skipped as unprovable at debug level, no divergence is recorded, and the node advertises a synced tip while that listener sits on a chain we can neither extend nor refute. Deferring forever is dropping. Tally each listener's decisions per replay batch and judge them where the batch ends — at the tip. A listener that skipped a stretch and then reconnected is the benign resume-from-minimum case and is reported at info level (so it no longer depends on debug logging to be visible at all). A listener that saw nothing but unprovable skips, and whose own tip is at or above the tip we reached, is stranded rather than lagging: no later replay of this chain reaches it either. That one is recorded as divergence, which halts the chain source — the pre-`ReplayUnprovable` behaviour, now applied only to the case that is a true positive. Every decision routes through `note_decision` so none can bypass the ledger, and the ledger resets on `blocks_disconnected`, where the tips it was tallied against stop existing. Tests pin both sides: the two stranded geometries trip the detection, and the behind-listener skew — the one whose false positive bricked CBF on healthy nodes — still heals. Reverting the detection fails exactly the three stranded tests; making it fire on any unprovable skip fails exactly the three healing/control tests. Assisted-by: Claude Code (Opus 5) --- src/chain/bitcoind.rs | 381 +++++++++++++++++++++++++++++++++++------- src/chain/cbf.rs | 9 + src/chain/mod.rs | 1 + 3 files changed, 333 insertions(+), 58 deletions(-) diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index 2bca4d2d80..3f8f8b84e7 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -5,7 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fmt; use std::future::Future; use std::sync::atomic::{AtomicU64, Ordering}; @@ -477,6 +477,7 @@ impl BitcoindChainSource { output_sweeper, logger: Arc::clone(&self.logger), divergence: Arc::new(Mutex::new(None)), + replay_batch: Arc::new(Mutex::new(BTreeMap::new())), }; let mut spv_client = SpvClient::new(chain_tip, chain_poller, HeaderCache::new(), &chain_listener); @@ -1577,6 +1578,70 @@ pub(crate) struct ChainListener { /// source drains this after each block and must stop advancing when it is set: continuing /// would publish a "synced" tip while a listener sits on a stale chain. pub(crate) divergence: Arc>>, + /// Per-listener tally of the replay batch in flight, drained at every tip boundary. + /// + /// Exists to tell two outcomes of [`ListenerAction::ReplayUnprovable`] apart, which are + /// indistinguishable block by block: a listener that skips a stretch it cannot prove and then + /// *reconnects* to the chain (benign — the resume-from-minimum design), and a listener the + /// replay can never reach at all (stranded on a fork). See + /// [`ChainListener::record_stranded_listeners`]. + pub(crate) replay_batch: Arc>>, +} + +/// What one listener decided about the blocks of the replay batch currently in flight. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ReplayTally { + /// Blocks skipped because ancestry could be neither confirmed nor refuted. + pub(crate) unprovable: u32, + /// Decisions that could actually be proven: delivered, an exact replay, or a fork. + pub(crate) provable: u32, + /// The listener's own tip height as of its most recent unprovable skip. + pub(crate) tip_height: u32, +} + +/// What a listener's replay batch amounts to once the replay has reached the chain's tip. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BatchVerdict { + /// Nothing to say: the listener was never asked about a block it could not prove. + Ordinary, + /// Skipped a stretch it could not prove and then reconnected to the chain. Benign — this is + /// what the resume-from-minimum design does on every restart with skewed listener durability. + SkippedThenReconnected, + /// The replay ended without this listener ever reaching a block it could prove, and its own + /// tip is at or above the tip we ended at, so no later replay of this chain can reach it + /// either. It is on a chain we can neither extend nor refute. + Stranded, +} + +impl ReplayTally { + /// Folds one decision into the batch tally. + fn note(&mut self, best: &BlockLocator, action: ListenerAction) { + match action { + ListenerAction::ReplayUnprovable => { + self.unprovable = self.unprovable.saturating_add(1); + self.tip_height = best.height; + }, + ListenerAction::Deliver | ListenerAction::AlreadyApplied | ListenerAction::Diverged => { + self.provable = self.provable.saturating_add(1) + }, + } + } + + /// Judges the batch against the tip the replay ended at. + /// + /// `provable == 0` on its own already implies the listener sits at or above `tip_height` — a + /// replay passes through every height at or below a listener's tip, and those compare + /// provably — but being unreachable is what actually makes the case terminal, so it is checked + /// rather than assumed. + fn verdict(&self, tip_height: u32) -> BatchVerdict { + if self.unprovable == 0 { + BatchVerdict::Ordinary + } else if self.provable > 0 || self.tip_height < tip_height { + BatchVerdict::SkippedThenReconnected + } else { + BatchVerdict::Stranded + } + } } /// Whether a listener should be handed a given block. @@ -1592,7 +1657,7 @@ pub(crate) struct ChainListener { /// Classification is deliberately hash-aware. Deciding on height alone would treat a *different* /// block at an already-seen height as an ordinary replay and skip it, silently stranding the /// listener on a stale fork — a worse failure than the panic being avoided, because it is silent. -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ListenerAction { /// The listener expects exactly this block. Deliver, @@ -1609,10 +1674,19 @@ pub(crate) enum ListenerAction { /// divergence halts CBF block application permanently for a listener that is merely further /// ahead on the same chain. /// - /// Nothing is given up by not halting here: the replay keeps climbing, and when it reaches - /// `best.height` the same-height arm compares the block hash exactly, and at `best.height + 1` - /// the parent hash — both PROVABLE. A listener genuinely sitting on a fork is therefore still - /// caught, a few blocks later, by a check that can actually decide it. + /// The decision is deferred, not dropped — but only for as long as the replay can still reach + /// the listener. While it climbs towards `best.height` the same-height arm will compare the + /// block hash exactly, and at `best.height + 1` the parent hash — both PROVABLE — so a + /// listener genuinely sitting on a fork is caught a few blocks later by a check that can + /// decide it. + /// + /// That argument holds only while the chain being replayed is at least as long as the + /// listener's own chain. It does NOT cover a reorg that leaves the canonical tip *below* an + /// ahead listener's persisted tip (needs to be deeper than the ancestry window, or any depth + /// at all against a locator restored from persistence with no ancestors): the replay ends + /// before it can prove anything, every block is unprovable, and deferring forever is the same + /// as dropping. That geometry is caught at the tip boundary instead — see + /// [`ChainListener::record_stranded_listeners`], which fails closed on it. ReplayUnprovable, /// The listener cannot accept this block without first being rewound. Diverged, @@ -1693,10 +1767,85 @@ impl ChainListener { self.divergence.lock().unwrap().take() } + /// Records one listener's decision about one replay block in the batch ledger, logs it, and + /// hands it back so the caller can act on it. + /// + /// EVERY decision must flow through here. The ledger's whole value is the contrast between a + /// listener that skipped some blocks and one that skipped *only* blocks, so a decision that + /// bypassed it would read as an absence of evidence. + fn note_decision( + &self, who: &'static str, best: &BlockLocator, height: u32, action: ListenerAction, + ) -> ListenerAction { + self.replay_batch.lock().unwrap().entry(who).or_default().note(best, action); + match action { + ListenerAction::ReplayUnprovable => self.log_unprovable_replay(who, best, height), + ListenerAction::Diverged => self.log_divergence(who, best, height), + ListenerAction::Deliver | ListenerAction::AlreadyApplied => {}, + } + action + } + + /// Drains the batch ledger at a tip boundary and fails closed on any listener the replay could + /// never prove a connection to. + /// + /// A batch that ends at `tip_height` is the last chance a listener gets: the replay is not + /// coming back for it. So a listener that saw nothing but [`ListenerAction::ReplayUnprovable`] + /// across the whole batch, and whose own tip is at or above the tip we just reached, is not + /// waiting to be caught up — it is stranded on a chain we cannot extend or refute, and the + /// deferral promised by `ReplayUnprovable` can never be honoured. Recording it as divergence + /// halts the chain source, which is the pre-`ReplayUnprovable` behaviour for exactly this + /// (true-positive) case. + /// + /// A listener that skipped a stretch and then reconnected — the ordinary consequence of the + /// resume-from-minimum design, and the case `ReplayUnprovable` exists for — has `provable > 0` + /// and is merely reported, at a level that does not depend on debug logging being on. + /// + /// Returns `true` when at least one listener was recorded as stranded. + pub(crate) fn record_stranded_listeners(&self, tip_height: u32) -> bool { + let batch = std::mem::take(&mut *self.replay_batch.lock().unwrap()); + let mut stranded = false; + for (who, tally) in batch { + match tally.verdict(tip_height) { + BatchVerdict::Ordinary => {}, + BatchVerdict::SkippedThenReconnected => log_info!( + self.logger, + "{} skipped {} block(s) of the replay it could not prove ancestry for, then \ + reconnected to the chain ({} proven decision(s), listener tip {}).", + who, + tally.unprovable, + tally.provable, + tally.tip_height, + ), + BatchVerdict::Stranded => { + stranded = true; + self.record_divergence(format!( + "{} is stranded at height {} above the synced tip {} ({} block(s) \ + replayed, none provable): the replay ended without ever reaching a block \ + it could prove", + who, tally.tip_height, tip_height, tally.unprovable + )); + log_error!( + self.logger, + "{} sits at height {} while the chain we just synced ends at {}, and none \ + of the {} replayed block(s) could be proven to be on its chain. It is on \ + a chain we can neither extend nor refute, so it is stranded rather than \ + lagging.", + who, + tally.tip_height, + tip_height, + tally.unprovable, + ); + }, + } + } + stranded + } + /// Logs a replay this listener is too far ahead of for us to prove ancestry for. /// - /// Deliberately does NOT touch `self.divergence`: this is the ordinary consequence of the - /// resume-from-minimum design, not a fork. See [`ListenerAction::ReplayUnprovable`]. + /// Deliberately does NOT touch `self.divergence`: on its own this is the ordinary consequence + /// of the resume-from-minimum design, not a fork. Whether it stayed ordinary is decided at the + /// tip boundary by [`Self::record_stranded_listeners`], so this stays at debug level. fn log_unprovable_replay(&self, who: &str, best: &BlockLocator, height: u32) { log_debug!( self.logger, @@ -1708,14 +1857,20 @@ impl ChainListener { ); } - fn log_divergence(&self, who: &str, best: &BlockLocator, height: u32) { + /// Records the first divergence seen since the last drain. Later ones are dropped: the chain + /// source halts on the first, and the first is the one that explains the rest. + fn record_divergence(&self, reason: String) { let mut recorded = self.divergence.lock().unwrap(); if recorded.is_none() { - *recorded = Some(format!( - "{} diverged at height {} (listener at {}, hash {})", - who, height, best.height, best.block_hash - )); + *recorded = Some(reason); } + } + + fn log_divergence(&self, who: &str, best: &BlockLocator, height: u32) { + self.record_divergence(format!( + "{} diverged at height {} (listener at {}, hash {})", + who, height, best.height, best.block_hash + )); log_error!( self.logger, "{} cannot accept the block at height {}: it is at height {} (hash {}). It must be \ @@ -1741,16 +1896,17 @@ impl Listen for ChainListener { let block_hash = header.block_hash(); + // `note_decision` does the logging and the batch tally; the arms below decide only what + // reaches the listener. let cm_best = self.channel_manager.current_best_block(); - match listener_action(&cm_best, block_hash, header.prev_blockhash, height) { + let cm_action = listener_action(&cm_best, block_hash, header.prev_blockhash, height); + match self.note_decision("ChannelManager", &cm_best, height, cm_action) { ListenerAction::Deliver => { self.channel_manager.filtered_block_connected(header, txdata, height) }, - ListenerAction::AlreadyApplied => {}, - ListenerAction::ReplayUnprovable => { - self.log_unprovable_replay("ChannelManager", &cm_best, height) - }, - ListenerAction::Diverged => self.log_divergence("ChannelManager", &cm_best, height), + ListenerAction::AlreadyApplied + | ListenerAction::ReplayUnprovable + | ListenerAction::Diverged => {}, } // `ChainMonitor` has no chain-order assertion of its own, but `ChannelMonitor` advances its @@ -1759,16 +1915,13 @@ impl Listen for ChainListener { // monitor: monitors ahead of that point ignore heights at or below their own tip. match self.min_monitor_best_block() { Some(monitor_best) => { - match listener_action(&monitor_best, block_hash, header.prev_blockhash, height) { + let action = + listener_action(&monitor_best, block_hash, header.prev_blockhash, height); + match self.note_decision("ChainMonitor", &monitor_best, height, action) { ListenerAction::Deliver | ListenerAction::AlreadyApplied => { self.chain_monitor.filtered_block_connected(header, txdata, height) }, - ListenerAction::ReplayUnprovable => { - self.log_unprovable_replay("ChainMonitor", &monitor_best, height) - }, - ListenerAction::Diverged => { - self.log_divergence("ChainMonitor", &monitor_best, height) - }, + ListenerAction::ReplayUnprovable | ListenerAction::Diverged => {}, } }, // No monitors: nothing to strand. @@ -1776,15 +1929,15 @@ impl Listen for ChainListener { } let sweeper_best = self.output_sweeper.current_best_block(); - match listener_action(&sweeper_best, block_hash, header.prev_blockhash, height) { + let sweeper_action = + listener_action(&sweeper_best, block_hash, header.prev_blockhash, height); + match self.note_decision("OutputSweeper", &sweeper_best, height, sweeper_action) { ListenerAction::Deliver => { self.output_sweeper.filtered_block_connected(header, txdata, height) }, - ListenerAction::AlreadyApplied => {}, - ListenerAction::ReplayUnprovable => { - self.log_unprovable_replay("OutputSweeper", &sweeper_best, height) - }, - ListenerAction::Diverged => self.log_divergence("OutputSweeper", &sweeper_best, height), + ListenerAction::AlreadyApplied + | ListenerAction::ReplayUnprovable + | ListenerAction::Diverged => {}, } } @@ -1794,45 +1947,36 @@ impl Listen for ChainListener { let block_hash = block.header.block_hash(); let cm_best = self.channel_manager.current_best_block(); - match listener_action(&cm_best, block_hash, block.header.prev_blockhash, height) { + let cm_action = listener_action(&cm_best, block_hash, block.header.prev_blockhash, height); + match self.note_decision("ChannelManager", &cm_best, height, cm_action) { ListenerAction::Deliver => self.channel_manager.block_connected(block, height), - ListenerAction::AlreadyApplied => {}, - ListenerAction::ReplayUnprovable => { - self.log_unprovable_replay("ChannelManager", &cm_best, height) - }, - ListenerAction::Diverged => self.log_divergence("ChannelManager", &cm_best, height), + ListenerAction::AlreadyApplied + | ListenerAction::ReplayUnprovable + | ListenerAction::Diverged => {}, } match self.min_monitor_best_block() { Some(monitor_best) => { - match listener_action( - &monitor_best, - block_hash, - block.header.prev_blockhash, - height, - ) { + let action = + listener_action(&monitor_best, block_hash, block.header.prev_blockhash, height); + match self.note_decision("ChainMonitor", &monitor_best, height, action) { ListenerAction::Deliver | ListenerAction::AlreadyApplied => { self.chain_monitor.block_connected(block, height) }, - ListenerAction::ReplayUnprovable => { - self.log_unprovable_replay("ChainMonitor", &monitor_best, height) - }, - ListenerAction::Diverged => { - self.log_divergence("ChainMonitor", &monitor_best, height) - }, + ListenerAction::ReplayUnprovable | ListenerAction::Diverged => {}, } }, None => self.chain_monitor.block_connected(block, height), } let sweeper_best = self.output_sweeper.current_best_block(); - match listener_action(&sweeper_best, block_hash, block.header.prev_blockhash, height) { + let sweeper_action = + listener_action(&sweeper_best, block_hash, block.header.prev_blockhash, height); + match self.note_decision("OutputSweeper", &sweeper_best, height, sweeper_action) { ListenerAction::Deliver => self.output_sweeper.block_connected(block, height), - ListenerAction::AlreadyApplied => {}, - ListenerAction::ReplayUnprovable => { - self.log_unprovable_replay("OutputSweeper", &sweeper_best, height) - }, - ListenerAction::Diverged => self.log_divergence("OutputSweeper", &sweeper_best, height), + ListenerAction::AlreadyApplied + | ListenerAction::ReplayUnprovable + | ListenerAction::Diverged => {}, } } @@ -1841,6 +1985,10 @@ impl Listen for ChainListener { self.channel_manager.blocks_disconnected(fork_point_block); self.chain_monitor.blocks_disconnected(fork_point_block); self.output_sweeper.blocks_disconnected(fork_point_block); + // Every listener just moved back to the fork point, so the batch tallied against their old + // tips describes a chain none of them are on any more. The replay that follows is the one + // that gets judged. + self.replay_batch.lock().unwrap().clear(); } } @@ -1881,8 +2029,8 @@ mod tests { use serde_json::json; use crate::chain::bitcoind::{ - listener_action, FeeResponse, GetMempoolEntryResponse, GetRawMempoolResponse, - GetRawTransactionResponse, ListenerAction, MempoolMinFeeResponse, + listener_action, BatchVerdict, FeeResponse, GetMempoolEntryResponse, GetRawMempoolResponse, + GetRawTransactionResponse, ListenerAction, MempoolMinFeeResponse, ReplayTally, }; fn hash(byte: u8) -> bitcoin::BlockHash { @@ -1991,6 +2139,10 @@ mod tests { // The safety property that makes `ReplayUnprovable` safe to not halt on: the replay keeps // climbing, and at the listener's own height the classifier compares block hashes // exactly. A listener genuinely on a fork is caught there instead of below it. + // + // This holds only while the replay can actually GET to that height. When it cannot — a + // chain shorter than the listener's own — the tip-boundary check below is what decides, + // see `a_replay_that_ends_below_an_ahead_listener_strands_it`. let ahead = locator(100, 50); // Below the tip, past the ancestry window: undecidable, keep going. assert_eq!(listener_action(&ahead, hash(1), hash(0), 50), ListenerAction::ReplayUnprovable); @@ -2003,6 +2155,119 @@ mod tests { assert_eq!(listener_action(&ahead, hash(0xbb), hash(0xaa), 100), ListenerAction::Diverged); } + /// Replays the blocks of one batch over a single listener locator and returns what the batch + /// ledger would hold, using the same fold the live path uses. + /// + /// The locator is held fixed, so this models the listener for as long as the replay has not + /// advanced it — which is the entire batch for a listener the replay never reaches, and up to + /// its own tip for one it does. + fn replay_over( + best: &BlockLocator, from: u32, through: u32, chain: impl Fn(u32) -> bitcoin::BlockHash, + ) -> ReplayTally { + let mut tally = ReplayTally::default(); + for height in from..=through { + tally.note(best, listener_action(best, chain(height), chain(height - 1), height)); + } + tally + } + + #[test] + fn a_replay_that_ends_below_an_ahead_listener_strands_it() { + // The geometry the per-block classifier cannot decide: a reorg deeper than the 12-entry + // ancestry window that leaves the canonical tip BELOW an ahead listener's persisted tip. + // The replay walks the canonical chain and stops at 85, so it never climbs to the + // listener's own height where a hash comparison could rule — every block is + // `ReplayUnprovable`, and "we'll decide a few blocks later" never comes due. + let ahead = locator(100, 100); + let canonical = |h: u32| hash(h as u8 + 128); + + let tally = replay_over(&ahead, 76, 85, canonical); + + assert_eq!(tally.unprovable, 10, "every block of the batch was undecidable"); + assert_eq!(tally.provable, 0, "nothing in the batch could be proven either way"); + assert_eq!( + tally.verdict(85), + BatchVerdict::Stranded, + "a listener at 100 that the canonical chain ends 15 blocks below is stranded, not \ + lagging: no later replay of THIS chain reaches it either" + ); + } + + #[test] + fn a_replay_that_ends_below_a_listener_with_no_ancestry_strands_it_at_any_depth() { + // The same trap without needing a deep reorg: a locator restored from persistence carries + // no ancestors at all, so nothing below its tip is provable and the whole 12-block window + // stops helping. Any canonical tip below the listener's own is then unreachable. + let restored = BlockLocator::new(hash(100), 100); + let canonical = |h: u32| hash(h as u8); + + let tally = replay_over(&restored, 90, 99, canonical); + + assert_eq!(tally.provable, 0); + assert_eq!(tally.verdict(99), BatchVerdict::Stranded); + } + + #[test] + fn a_behind_listener_the_replay_catches_up_to_still_heals() { + // The case `ReplayUnprovable` exists for, and the one a stranded-detector must not eat: + // listener durability skews on every restart (measured live at 20 blocks), the resume + // floor is the minimum across listeners, and a listener with no persisted ancestry cannot + // prove ANY of the replay below its tip. It is still perfectly healthy — the proof + // arrives when the replay reaches its own height. + // + // Stopping the replay at the listener's tip is the pessimistic cut: every block above it + // is `Deliver`, which only adds proof. + let restored = BlockLocator::new(hash(100), 100); + let canonical = |h: u32| hash(h as u8); + + let tally = replay_over(&restored, 81, 100, canonical); + + assert_eq!(tally.unprovable, 19, "81..=99 could not be proven"); + assert_eq!(tally.provable, 1, "its own tip at 100 compares by hash, and matches"); + assert_eq!( + tally.verdict(120), + BatchVerdict::SkippedThenReconnected, + "skipping a stretch and then reconnecting must never halt the chain source — that \ + halt bricked CBF on healthy nodes" + ); + } + + #[test] + fn a_batch_that_proved_a_fork_is_not_also_reported_as_stranded() { + // Divergence is a proven decision. It halts through its own path, and reporting the same + // listener twice for the same batch would misdescribe why. + let ahead = locator(100, 100); + let forked = |h: u32| hash(h as u8 + 128); + + // 88..=99 are inside the ancestry window and disagree; 100 is the tip and disagrees. + let tally = replay_over(&ahead, 80, 100, forked); + + assert!(tally.provable > 0, "the window and the tip both ruled"); + assert_eq!(tally.verdict(100), BatchVerdict::SkippedThenReconnected); + } + + #[test] + fn the_stranded_verdict_needs_both_no_proof_and_an_unreachable_tip() { + let nothing_skipped = ReplayTally { unprovable: 0, provable: 5, tip_height: 0 }; + assert_eq!(nothing_skipped.verdict(90), BatchVerdict::Ordinary); + + let skipped_then_proved = ReplayTally { unprovable: 3, provable: 1, tip_height: 100 }; + assert_eq!(skipped_then_proved.verdict(90), BatchVerdict::SkippedThenReconnected); + + // Defensive: a listener below the tip the replay reached is reachable by construction, so + // however it got here it is not the terminal case. + let below_the_tip = ReplayTally { unprovable: 3, provable: 0, tip_height: 89 }; + assert_eq!(below_the_tip.verdict(90), BatchVerdict::SkippedThenReconnected); + + let unreachable = ReplayTally { unprovable: 3, provable: 0, tip_height: 100 }; + assert_eq!(unreachable.verdict(90), BatchVerdict::Stranded); + assert_eq!( + unreachable.verdict(100), + BatchVerdict::Stranded, + "at the tip it is still ahead" + ); + } + #[test] fn listener_action_reports_a_gap() { // More than one block ahead: delivering violates LDK's one-call-per-block contract. diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 45d85be20d..99905f394b 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -313,6 +313,15 @@ impl BlockApplicator { ChainOp::Synced { tip_height } => { log_info!(self.logger, "CBF caught up to tip {}", tip_height); if self.next_height > tip_height { + // The last chance a listener gets to be proven on this chain: the replay + // ends here and is not coming back. A listener the replay never reached is + // stranded, not lagging, and must not be left silently on its own chain + // while we advertise this one as synced. + if self.chain_listener.record_stranded_listeners(tip_height) + && self.fail_on_divergence().await + { + return; + } // Reaching the tip is the durability boundary: write the deferred chain state // before publishing, so the tip we advertise as applied is also persisted. self.flush_chain_state().await; diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 0e0b2647b5..fe80a7f36a 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -373,6 +373,7 @@ impl ChainSource { output_sweeper, logger: Arc::clone(&self.logger), divergence: Arc::new(Mutex::new(None)), + replay_batch: Arc::new(Mutex::new(std::collections::BTreeMap::new())), }; cbf_chain_source.start(chain_listener); }, From 9698cd5027c92cce915ad1301a063fbccebc5969 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Wed, 9 Sep 2026 00:41:56 +0700 Subject: [PATCH 137/138] feat(cbf): compile a third mainnet birthday anchor at block 965,999 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `birthday_checkpoint` knew two mainnet anchors, 481,823 (SegWit) and 709,631 (taproot), so every `wallet_rescan_from_height` above 709,631 rounded down to the taproot anchor and a fresh CBF wallet scanned some 256,000 blocks of filters — hours on Pi-class hardware — for a node born this month. Add block 965,999 (mined 2026-09-08; hash cross-checked on mempool.space, blockstream.info and blockcypher.com on 2026-09-09, ~100 blocks below the tip) as the newest anchor, in a single `mainnet_anchors` table that also carries the provenance label `resolve_birthday` logs. A birthday of 966,000 now anchors at 965,999 and scans from 966,000; everything at or below 965,999 resolves exactly as before, and non-mainnet networks still return `None`. Tests pin the height, the published hash, ascending distinct anchors, and the 966,000 → 965,999 resolution end to end. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ku7pgAakXpVYe52wSnbRME --- CHANGELOG.md | 4 +++ src/builder.rs | 5 +-- src/chain/cbf.rs | 81 ++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b231e8d1c4..cd7c654125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ wallet on a pruned node where the full history is unavailable but the wallet birthday height is known. Existing wallets are not rewound, and future heights fail the build. Passing `Some(0)` rescans from genesis; passing `None` keeps the default current-tip checkpoint behavior. (#884) +- The compact-block-filter chain source gains a third compiled mainnet birthday anchor at block + 965,999 (hash `00000000000000000000dbb4d1e55ad22ed5b5a7d81d4c0fe992fceb8a5302d0`), so a fresh + wallet built with `set_chain_source_cbf(.., Some(966_000))` scans from block 966,000 instead of + falling back to the taproot-activation anchor and roughly 256,000 blocks of filters. - `EsploraSyncConfig` and `ElectrumSyncConfig` now support `force_wallet_full_scan`. When set, the on-chain wallet keeps using BDK `full_scan` instead of incremental sync until a full scan succeeds, allowing restored wallets to rediscover funds sent to previously-unknown addresses. diff --git a/src/builder.rs b/src/builder.rs index 050fe28a81..125743aedd 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -421,8 +421,9 @@ impl NodeBuilder { /// starts at the block after the anchor, so the block at the given height is always scanned /// (heights at or below the lowest anchor fall back to a full scan from block 1; the /// genesis block itself is unspendable by consensus and needs no scan). Mainnet anchors are - /// 481,823 (one block before SegWit activation) and 709,631 (one block before taproot - /// activation). On all other networks a fresh wallet scans from genesis. Passing `None` + /// 481,823 (one block before SegWit activation), 709,631 (one block before taproot + /// activation) and 965,999 (mined 2026-09-08; a birthday of 966,000 scans from that block). + /// On all other networks a fresh wallet scans from genesis. Passing `None` /// also scans from genesis — unlike the Bitcoin Core sources, where `None` anchors at the /// current tip — because CBF has no trusted tip oracle at build time and anchoring lower is /// the only direction that cannot skip wallet history. For a restored seed with older diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 99905f394b..1433f42d8f 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -1513,21 +1513,46 @@ fn resume_anchor(bdk_cp: bdk_chain::CheckPoint, target_height: u32) -> bdk_chain /// /// Only compiled-in constants are used: CBF has no chain backend at build time, and consulting /// a third-party tip oracle would reintroduce exactly the dependency this chain source removes. -/// Rounding down can only extend the scanned range, never shrink it. Mainnet ships two such -/// anchors — 481,823 (one block before SegWit activation) and 709,631 (one block before taproot -/// activation); all other networks resolve to `None`. +/// Rounding down can only extend the scanned range, never shrink it. Mainnet ships three such +/// anchors — 481,823 (one block before SegWit activation), 709,631 (one block before taproot +/// activation) and 965,999 (see [`anchor_965_999`]); all other networks resolve to `None`. pub(crate) fn birthday_checkpoint( network: Network, first_scan_height: u32, ) -> Option { if network != Network::Bitcoin { return None; } - [HashCheckpoint::segwit_activation(), HashCheckpoint::taproot_activation()] + mainnet_anchors() .into_iter() + .map(|(cp, _)| cp) .filter(|cp| cp.height < first_scan_height) .max_by_key(|cp| cp.height) } +/// Mainnet block 965,999 (mined 2026-09-08), the newest compiled birthday anchor: a wallet +/// born at block 966,000 or later scans nothing older than that, instead of falling through to +/// the taproot anchor and ~256,000 blocks of filters. The hash was cross-checked against three +/// independent explorers (mempool.space, blockstream.info, blockcypher.com) on 2026-09-09, +/// about 100 blocks below the tip, so no reorg can reach it. Add a newer entry to +/// [`mainnet_anchors`] when a later birthday is wanted; never edit an existing one — wallets +/// already anchored on it would latch divergence at the next start. +fn anchor_965_999() -> HashCheckpoint { + let hash = "00000000000000000000dbb4d1e55ad22ed5b5a7d81d4c0fe992fceb8a5302d0" + .parse::() + .expect("compiled block hash"); + HashCheckpoint::new(965_999, hash) +} + +/// Every compiled mainnet birthday anchor, ascending, with the provenance label the startup +/// log prints beside it. +fn mainnet_anchors() -> [(HashCheckpoint, &'static str); 3] { + [ + (HashCheckpoint::segwit_activation(), "bip157 segwit_activation constant"), + (HashCheckpoint::taproot_activation(), "bip157 taproot_activation constant"), + (anchor_965_999(), "ldk-node block 965,999 constant"), + ] +} + /// Resolves a configured `wallet_rescan_from_height` into the initial chain tip handed to the /// builder, logging the anchor and its provenance. /// @@ -1542,11 +1567,11 @@ pub(crate) fn resolve_birthday( let requested = wallet_rescan_from_height?; match birthday_checkpoint(network, requested) { Some(cp) => { - let provenance = if cp.height == HashCheckpoint::taproot_activation().height { - "bip157 taproot_activation constant" - } else { - "bip157 segwit_activation constant" - }; + let provenance = mainnet_anchors() + .into_iter() + .find(|(anchor, _)| *anchor == cp) + .map(|(_, label)| label) + .unwrap_or("compiled anchor"); log_info!( logger, "CBF wallet birthday: requested height {} resolved to compiled checkpoint at \ @@ -1627,10 +1652,15 @@ mod tests { #[test] fn birthday_anchors_strictly_below_the_first_scan_height() { + let newest = anchor_965_999(); let taproot = HashCheckpoint::taproot_activation(); let segwit = HashCheckpoint::segwit_activation(); - assert_eq!(birthday_checkpoint(Network::Bitcoin, u32::MAX), Some(taproot)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, u32::MAX), Some(newest)); + // A wallet born at block 966,000 anchors one block below it and scans from 966,000. + assert_eq!(birthday_checkpoint(Network::Bitcoin, 966_000), Some(newest)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, newest.height + 1), Some(newest)); + assert_eq!(birthday_checkpoint(Network::Bitcoin, newest.height), Some(taproot)); assert_eq!(birthday_checkpoint(Network::Bitcoin, 900_000), Some(taproot)); assert_eq!(birthday_checkpoint(Network::Bitcoin, taproot.height + 1), Some(taproot)); // Scanning starts strictly after the anchor, so a first transaction exactly at a @@ -1649,6 +1679,37 @@ mod tests { } } + #[test] + fn newest_anchor_is_block_965_999_with_its_published_hash() { + let newest = anchor_965_999(); + assert_eq!(newest.height, 965_999); + assert_eq!( + newest.hash.to_string(), + "00000000000000000000dbb4d1e55ad22ed5b5a7d81d4c0fe992fceb8a5302d0" + ); + } + + #[test] + fn mainnet_anchors_are_distinct_and_ascend() { + let anchors = mainnet_anchors(); + for pair in anchors.windows(2) { + assert!(pair[0].0.height < pair[1].0.height, "anchors must ascend: {:?}", anchors); + assert_ne!(pair[0].0.hash, pair[1].0.hash); + } + } + + #[test] + fn resolve_birthday_anchors_a_966_000_birthday_at_block_965_999() { + let logger = test_logger(); + let anchor = + resolve_birthday(&logger, Network::Bitcoin, Some(966_000)).expect("a mainnet anchor"); + assert_eq!(anchor.height, 965_999); + assert_eq!(anchor.block_hash, anchor_965_999().hash); + // `None` and non-mainnet networks still root a fresh wallet at genesis. + assert!(resolve_birthday(&logger, Network::Bitcoin, None).is_none()); + assert!(resolve_birthday(&logger, Network::Regtest, Some(966_000)).is_none()); + } + fn chain_of(heights: &[u32]) -> bdk_chain::CheckPoint { bdk_chain::CheckPoint::from_block_ids( heights From 880b105787748a365e95946bac4904b3e60982a5 Mon Sep 17 00:00:00 2001 From: datphamcode295 Date: Wed, 9 Sep 2026 20:38:13 +0700 Subject: [PATCH 138/138] feat(wallet,cbf): an own send is applied as unconfirmed at once; a rejected broadcast releases its inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CBF chain source has no mempool view, so the on-chain wallet never learned about its own broadcasts until they confirmed: the coins a just-sent transaction spent stayed listed as unspent, the next send re-selected them, and bitcoind refused that second send as an underpaid replacement ("insufficient fee, rejecting replacement"). Three pieces close this: - `Wallet::send_to_address` applies the signed transaction as unconfirmed (and persists it, and records the Pending on-chain payment) BEFORE the broadcast queue takes it, so two back-to-back sends never race the queue and the second one spends the first one's unconfirmed change. - `CbfChainSource::process_broadcast_package` now receives the on-chain wallet and applies every transaction the hook or P2P carried (LDK's funding/sweep transactions included; BDK keeps the relevant ones). - The broadcast hook can now say WHY it declined: `BroadcastHookError:: Unavailable` falls through to P2P as before, while `Rejected(txid, reason)` — the service's bitcoind refusing our transaction — is final: no P2P relay, and the rejected transactions are evicted so their inputs become spendable again. `BroadcastOutcome::Rejected` carries it. - `Node::evict_unconfirmed_txs` / `Node::rebroadcast_unconfirmed_tx` let the app's transaction watcher drop or re-queue a transaction the network has since lost. Tests: the dispatch decision for a rejected hook, and the BDK semantics the fix relies on (own spend locks its inputs and exposes its change; the next build chains on that change; eviction hands the inputs back; a reload from the persisted change set keeps all of it). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Ku7pgAakXpVYe52wSnbRME --- src/chain/cbf.rs | 139 ++++++++++++++++++++++++++++++---- src/chain/mod.rs | 46 ++++++++++-- src/lib.rs | 51 ++++++++++++- src/wallet/mod.rs | 185 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 398 insertions(+), 23 deletions(-) diff --git a/src/chain/cbf.rs b/src/chain/cbf.rs index 1433f42d8f..013ecf53c5 100644 --- a/src/chain/cbf.rs +++ b/src/chain/cbf.rs @@ -17,7 +17,7 @@ use tokio::sync::{mpsc, watch}; use crate::chain::bitcoind::ChainListener; use crate::chain::electrum::get_electrum_fee_rate_cache_update; -use crate::chain::{CbfFeeSourceConfig, CbfSyncStatus, ChainServiceHooks}; +use crate::chain::{BroadcastHookError, CbfFeeSourceConfig, CbfSyncStatus, ChainServiceHooks}; use crate::config::{Config, DEFAULT_FEE_RATE_CACHE_UPDATE_INTERVAL_SECS}; use crate::error::Error; use crate::fee_estimator::{ @@ -588,12 +588,17 @@ where } /// Outcome of trying the app-supplied broadcast hook before falling back to P2P. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum BroadcastOutcome { /// The hook accepted the package; P2P broadcast was not used. HookHandled, - /// No hook configured, or the hook declined (`Err(())`); P2P broadcast handled it. + /// No hook configured, or the hook could not reach its service + /// ([`BroadcastHookError::Unavailable`]) or timed out; P2P broadcast handled it. FellThroughToP2p, + /// The hook's service REFUSED the listed transactions ([`BroadcastHookError::Rejected`]); + /// P2P broadcast was deliberately not attempted. Package transactions not listed here were + /// accepted. + Rejected(Vec<(Txid, String)>), } /// Tries the app-supplied broadcast hook before falling back to the node's own P2P broadcast. @@ -601,11 +606,14 @@ enum BroadcastOutcome { /// If `hooks.broadcast` is configured, it is called with the package's transactions, bounded by /// `hook_timeout` (production callers pass [`CHAIN_SERVICE_HOOK_TIMEOUT_SECS`]; tests inject a /// short duration so a deliberately-hung fake hook doesn't slow the test suite down): `Ok(())` -/// means the external service accepted the broadcast, so `p2p_send` is NOT called. `Err(())`, a -/// timeout, or no hook configured at all falls through to `p2p_send` — the existing kyoto -/// `submit_package`/per-tx broadcast path, unchanged. `p2p_send` is called (and must itself -/// degrade gracefully, e.g. by logging) even when the underlying kyoto runtime is stopped — -/// only `p2p_send`'s own implementation depends on kyoto health, not this decision function. +/// means the external service accepted the broadcast, so `p2p_send` is NOT called. +/// `Err(BroadcastHookError::Unavailable)`, a timeout, or no hook configured at all falls through +/// to `p2p_send` — the existing kyoto `submit_package`/per-tx broadcast path, unchanged. +/// `Err(BroadcastHookError::Rejected(..))` is a verdict on the transactions themselves, so +/// `p2p_send` is NOT called either and the rejections are handed back to the caller. `p2p_send` +/// is called (and must itself degrade gracefully, e.g. by logging) even when the underlying +/// kyoto runtime is stopped — only `p2p_send`'s own implementation depends on kyoto health, not +/// this decision function. /// /// A free function (not a method) so it is unit-testable with a fake hook and a fake /// `p2p_send`, without a live kyoto node. @@ -621,7 +629,10 @@ where let hook_result = tokio::time::timeout(hook_timeout, hook(txs.clone())).await; match hook_result { Ok(Ok(())) => return BroadcastOutcome::HookHandled, - Ok(Err(())) => {}, + Ok(Err(BroadcastHookError::Rejected(rejected))) => { + return BroadcastOutcome::Rejected(rejected); + }, + Ok(Err(BroadcastHookError::Unavailable)) => {}, Err(_elapsed) => { log_debug!( logger, @@ -1296,7 +1307,23 @@ impl CbfChainSource { Ok(()) } - pub(crate) async fn process_broadcast_package(&self, package: Vec) { + /// Relays one broadcast package (hook first, P2P fallback second) and then tells the on-chain + /// wallet what just left the node. + /// + /// A CBF chain source has no mempool view of its own: nothing ever feeds it unconfirmed + /// transactions the way the bitcoind chain source's mempool poll does. Left alone, the wallet + /// would keep treating the coins a just-broadcast transaction spent as unspent until the + /// transaction confirms, and the next send would happily double-spend them (bitcoind then + /// refuses the second send as an underpaid replacement). So every transaction that was + /// handed to the hook or to P2P — the wallet's own sends and LDK's funding/sweep + /// transactions alike — is applied as unconfirmed here (BDK keeps only the ones relevant to + /// the wallet), and every transaction the hook's service REJECTED is evicted instead, which + /// hands its inputs back to the wallet. `Wallet::send_to_address` applies its own + /// transaction even earlier, before it is queued, so two back-to-back sends never race the + /// broadcast queue; the re-application here is a harmless `last_seen` refresh for those. + pub(crate) async fn process_broadcast_package( + &self, package: Vec, onchain_wallet: &Wallet, + ) { // Read the requester (if any) up front, but do NOT bail out when the kyoto runtime is // stopped: the broadcast hook must still be tried (it's payment-agnostic and may be the // only working relay left once the CBF restart loop has given up, e.g. for a @@ -1312,7 +1339,7 @@ impl CbfChainSource { &self.hooks, &self.logger, Duration::from_secs(CHAIN_SERVICE_HOOK_TIMEOUT_SECS), - package, + package.clone(), move |package| async move { let Some(requester) = requester else { log_error!( @@ -1350,11 +1377,45 @@ impl CbfChainSource { ) .await; - if outcome == BroadcastOutcome::HookHandled { - log_debug!( + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let (unconfirmed, evicted): (Vec<(Transaction, u64)>, Vec<(Txid, u64)>) = match outcome { + BroadcastOutcome::HookHandled => { + log_debug!( + self.logger, + "External chain-service broadcast hook accepted the transaction package; P2P \ + relay skipped." + ); + (package.into_iter().map(|tx| (tx, now)).collect(), Vec::new()) + }, + BroadcastOutcome::FellThroughToP2p => { + (package.into_iter().map(|tx| (tx, now)).collect(), Vec::new()) + }, + BroadcastOutcome::Rejected(rejected) => { + let rejected_txids: HashSet = + rejected.iter().map(|(txid, _)| *txid).collect(); + for (txid, reason) in &rejected { + log_error!( + self.logger, + "External chain service rejected transaction {}; giving it up (no P2P \ + relay) and releasing its inputs in the on-chain wallet: {}", + txid, + reason + ); + } + let accepted = package + .into_iter() + .filter(|tx| !rejected_txids.contains(&tx.compute_txid())) + .map(|tx| (tx, now)) + .collect(); + (accepted, rejected.into_iter().map(|(txid, _)| (txid, now)).collect()) + }, + }; + + if let Err(e) = onchain_wallet.apply_mempool_txs(unconfirmed, evicted).await { + log_error!( self.logger, - "External chain-service broadcast hook accepted the transaction package; P2P \ - relay skipped." + "Failed to record the broadcast package in the on-chain wallet: {}", + e ); } } @@ -2095,7 +2156,9 @@ mod tests { let logger = test_logger(); let hooks = ChainServiceHooks { fee_estimates: None, - broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { Box::pin(async { Err(()) }) })), + broadcast: Some(Arc::new(|_txs| -> BroadcastFuture { + Box::pin(async { Err(BroadcastHookError::Unavailable) }) + })), }; let p2p_called = Arc::new(AtomicBool::new(false)); let p2p_called_clone = Arc::clone(&p2p_called); @@ -2116,6 +2179,50 @@ mod tests { ); } + #[tokio::test] + async fn chain_service_hooks_broadcast_hook_rejected_skips_p2p_and_reports_the_verdict() { + // A rejection is bitcoind's policy verdict on OUR transaction (e.g. "insufficient fee, + // rejecting replacement"): P2P relay would only collect the same verdict elsewhere, so + // the package must NOT fall through, and the verdict must reach the caller so the + // on-chain wallet can release the inputs of the refused transaction. + let logger = test_logger(); + let rejected_txid = Txid::all_zeros(); + let hooks = ChainServiceHooks { + fee_estimates: None, + broadcast: Some(Arc::new(move |_txs| -> BroadcastFuture { + Box::pin(async move { + Err(BroadcastHookError::Rejected(vec![( + rejected_txid, + "insufficient fee, rejecting replacement".to_string(), + )])) + }) + })), + }; + let p2p_called = Arc::new(AtomicBool::new(false)); + let p2p_called_clone = Arc::clone(&p2p_called); + + let outcome = + dispatch_broadcast(&hooks, &logger, generous_test_timeout(), Vec::new(), move |_txs| { + let p2p_called = Arc::clone(&p2p_called_clone); + async move { + p2p_called.store(true, Ordering::SeqCst); + } + }) + .await; + + assert_eq!( + outcome, + BroadcastOutcome::Rejected(vec![( + rejected_txid, + "insufficient fee, rejecting replacement".to_string() + )]) + ); + assert!( + !p2p_called.load(Ordering::SeqCst), + "a rejected package must never be relayed over P2P" + ); + } + #[tokio::test] async fn chain_service_hooks_broadcast_no_hook_falls_through_to_p2p() { let logger = test_logger(); diff --git a/src/chain/mod.rs b/src/chain/mod.rs index fe80a7f36a..cedbf16d45 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -156,10 +156,44 @@ pub enum CbfSyncStatus { pub type FeeEstimatesFuture = std::pin::Pin< Box, ()>> + Send>, >; +/// Why an app-supplied broadcast hook did not accept a transaction package. +/// +/// The two variants ask for opposite handling, which is the whole point of telling them apart: +/// an [`Unavailable`](Self::Unavailable) service is routed around (the node's own P2P broadcast +/// carries the package instead), while a [`Rejected`](Self::Rejected) package is a verdict from +/// the service's own bitcoind on OUR transactions — relaying it over P2P would only buy the +/// same verdict from every other node, so the CBF chain source stops there and forgets the +/// rejected transactions in the on-chain wallet, freeing the coins they tried to spend. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum BroadcastHookError { + /// The external chain service could not be reached, could not be paid, or gave no usable + /// answer. Falls through to the node's own P2P broadcast, exactly like a timeout. + Unavailable, + /// The external chain service's bitcoind refused these transactions (a mempool-policy + /// verdict such as `insufficient fee, rejecting replacement`), each with the reason it gave. + /// No P2P fallback is attempted; the on-chain wallet evicts every listed transaction it + /// knows about so their inputs become spendable again. Transactions of the package that are + /// NOT listed here are treated as accepted. + Rejected(Vec<(Txid, String)>), +} + +impl std::fmt::Display for BroadcastHookError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unavailable => write!(f, "external chain service unavailable"), + Self::Rejected(rejected) => { + write!(f, "external chain service rejected {} transaction(s)", rejected.len()) + }, + } + } +} + /// A future resolving to `Ok(())` if the external chain service accepted a raw-tx broadcast, or -/// `Err(())` to fall back to the node's own P2P broadcast. +/// to a [`BroadcastHookError`] saying whether to fall back to the node's own P2P broadcast +/// ([`BroadcastHookError::Unavailable`]) or to give the package up as refused +/// ([`BroadcastHookError::Rejected`]). pub type BroadcastFuture = - std::pin::Pin> + Send>>; + std::pin::Pin> + Send>>; /// App-supplied hooks that let an external chain service short-circuit the CBF chain source's /// native fee estimation and P2P transaction broadcast. @@ -185,8 +219,9 @@ pub struct ChainServiceHooks { /// `fee_source` / block-derived estimation. pub fee_estimates: Option FeeEstimatesFuture + Send + Sync>>, /// Attempt external broadcast of raw txs. Tried even if the underlying CBF/kyoto runtime is - /// not currently running. `Err(())`, a timeout, or leaving this unset falls through to P2P - /// broadcast. + /// not currently running. `Err(BroadcastHookError::Unavailable)`, a timeout, or leaving + /// this unset falls through to P2P broadcast; `Err(BroadcastHookError::Rejected(..))` does + /// NOT — see [`BroadcastHookError`]. pub broadcast: Option) -> BroadcastFuture + Send + Sync>>, } @@ -672,6 +707,7 @@ impl ChainSource { pub(crate) async fn continuously_process_broadcast_queue( &self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>, + onchain_wallet: Arc, ) { let mut receiver = self.tx_broadcaster.get_broadcast_queue().await; loop { @@ -712,7 +748,7 @@ impl ChainSource { }, ChainSourceKind::Cbf(cbf_chain_source) => { cbf_chain_source - .process_broadcast_package(package.into_inner()) + .process_broadcast_package(package.into_inner(), &onchain_wallet) .await }, } diff --git a/src/lib.rs b/src/lib.rs index 2e56326e22..f41c212c50 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -147,7 +147,7 @@ pub use chain::{ChainStatus, TxStatus}; /// [`crate::builder::NodeBuilder::set_cbf_chain_service_hooks`]. Unlike the swap-primitive /// re-exports above, these are CBF-only and not gated behind the `swaps` feature — matching /// [`chain::ChainServiceHooks`]'s own (ungated) definition. -pub use chain::{BroadcastFuture, ChainServiceHooks, FeeEstimatesFuture}; +pub use chain::{BroadcastFuture, BroadcastHookError, ChainServiceHooks, FeeEstimatesFuture}; /// Optional external fee-estimation backend for [`set_chain_source_cbf`], re-exported from the /// otherwise-private `chain` module so a consumer can name it. Not gated behind `swaps`, matching @@ -700,8 +700,9 @@ impl Node { let stop_tx_bcast = self.stop_sender.subscribe(); let chain_source = Arc::clone(&self.chain_source); + let bcast_wallet = Arc::clone(&self.wallet); self.runtime.spawn_cancellable_background_task(async move { - chain_source.continuously_process_broadcast_queue(stop_tx_bcast).await + chain_source.continuously_process_broadcast_queue(stop_tx_bcast, bcast_wallet).await }); let bump_tx_event_handler = Arc::new(BumpTransactionEventHandler::new( @@ -1402,6 +1403,52 @@ impl Node { self.tx_broadcaster.broadcast_tx(tx); } + /// Forgets the given unconfirmed transactions in the on-chain wallet, handing the coins they + /// spent back to the spendable balance. + /// + /// Meant for a transaction this node broadcast that the network has since dropped (the app's + /// transaction watcher learns this from its chain-service provider): under the CBF chain + /// source nothing else ever evicts an unconfirmed transaction, so without this the inputs of + /// a dropped send would stay locked until it confirmed — which it never will. Transactions + /// the wallet does not know, or already sees confirmed, are ignored. The eviction is + /// reversible: seeing the transaction again (a later [`Self::rebroadcast_unconfirmed_tx`], + /// or its confirmation) makes it canonical again. + pub fn evict_unconfirmed_txs(&self, txids: Vec) -> Result<(), Error> { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + if txids.is_empty() { + return Ok(()); + } + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let evicted = txids.into_iter().map(|txid| (txid, now)).collect(); + let wallet = Arc::clone(&self.wallet); + self.runtime.block_on(async move { wallet.apply_mempool_txs(Vec::new(), evicted).await }) + } + + /// Re-queues an unconfirmed transaction the on-chain wallet already holds for broadcast, + /// over the same path (chain-service hook first, P2P second) a fresh send takes. + /// + /// Returns [`Error::WalletOperationFailed`] when the wallet does not hold the transaction or + /// already sees it confirmed. Fire-and-forget like every other broadcast: the transaction is + /// placed on the bounded broadcast queue and this returns immediately. + pub fn rebroadcast_unconfirmed_tx(&self, txid: bitcoin::Txid) -> Result<(), Error> { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + let Some(tx) = self.wallet.get_unconfirmed_transaction(&txid) else { + log_debug!( + self.logger, + "Not rebroadcasting {}: the on-chain wallet holds no unconfirmed transaction by \ + that id.", + txid + ); + return Err(Error::WalletOperationFailed); + }; + self.tx_broadcaster.broadcast_unclassified_transaction(tx); + Ok(()) + } + /// Estimates the on-chain feerate for a swap transaction at the requested /// [`SwapFeeTarget`] priority, returning a source-bearing [`FeerateQuote`] /// (Peerswap native primitive B6 / plan FIX-B). diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 954ae2fa19..98199270dc 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -146,6 +146,17 @@ impl Wallet { .collect() } + /// The full transaction behind `txid` if the wallet holds it and still sees it unconfirmed + /// (the only kind worth rebroadcasting); `None` for unknown or confirmed transactions. + pub(crate) fn get_unconfirmed_transaction(&self, txid: &Txid) -> Option { + self.inner + .lock() + .expect("lock") + .get_tx(*txid) + .filter(|t| t.chain_position.is_unconfirmed()) + .map(|t| (*t.tx_node.tx).clone()) + } + pub(crate) fn latest_checkpoint(&self) -> bdk_chain::local_chain::CheckPoint { self.inner.lock().expect("lock").latest_checkpoint() } @@ -975,6 +986,39 @@ impl Wallet { })?; let txid = tx.compute_txid(); + + // Teach the wallet about its own spend BEFORE the broadcast queue takes it. Until the + // transaction is applied as unconfirmed, BDK still lists the coins it spends as unspent, + // and a second send built in the meantime re-selects them — a double-spend the network + // then refuses as an underpaid replacement. Chain sources with a mempool view would + // eventually catch up on their own; the CBF chain source never would. Applying here also + // records the Pending on-chain payment right away, and makes the unconfirmed change + // output spendable by the very next send. The persister is still locked from the build + // above, so this deliberately does NOT go through `apply_mempool_txs` (which would take + // that same lock). + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let events = { + let mut locked_wallet = self.inner.lock().expect("lock"); + locked_wallet + .events_helper(|wallet| -> Result<(), std::convert::Infallible> { + wallet.apply_unconfirmed_txs(vec![(tx.clone(), now)]); + Ok(()) + }) + .expect("applying an unconfirmed transaction cannot fail") + }; + self.update_payment_store(events).await.map_err(|e| { + log_error!(self.logger, "Failed to update payment store: {}", e); + Error::PersistenceFailed + })?; + let change_set = self.inner.lock().expect("lock").take_staged().unwrap_or_default(); + locked_persister.persist_changeset(change_set).await.map_err(|e| { + log_error!(self.logger, "Failed to persist wallet: {}", e); + Error::PersistenceFailed + })?; + self.broadcaster.broadcast_unclassified_transaction(tx); match send_amount { @@ -2381,3 +2425,144 @@ mod swap_b7_tests { } } } + +#[cfg(test)] +mod tests { + //! The BDK behaviour the "own send is applied as unconfirmed" fix relies on, pinned down on a + //! bare `bdk_wallet::Wallet` so it needs no chain source: an own unconfirmed spend takes the + //! coins it spent out of `list_unspent` and exposes its change; the next transaction builds + //! on that unconfirmed change instead of re-selecting the spent coins; an eviction hands the + //! original coins back; and all of it survives a reload from the persisted change set. + use bdk_chain::Merge; + use bdk_wallet::{ChangeSet, KeychainKind, SignOptions, Wallet as BdkWallet}; + use bitcoin::hashes::Hash; + use bitcoin::{ + absolute, transaction, Amount, FeeRate, Network, OutPoint, ScriptBuf, Sequence, + Transaction, TxIn, TxOut, Txid, WPubkeyHash, Witness, + }; + + const EXTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/0/*)"; + const INTERNAL_DESCRIPTOR: &str = "wpkh(tprv8ZgxMBicQKsPdy6LMhUtFHAgpocR8GC6QmwMSFpZs7h6Eziw3SpThFfczTDh5rW2krkqffa11UpX3XkeTTB2FvzZKWXqPY54Y6Rq4AQ5R8L/84'/1'/0'/1/*)"; + const FUNDING_SATS: u64 = 100_000; + + fn new_wallet() -> BdkWallet { + BdkWallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR) + .network(Network::Regtest) + .create_wallet_no_persist() + .expect("valid test descriptors") + } + + fn someone_elses_script() -> ScriptBuf { + ScriptBuf::new_p2wpkh(&WPubkeyHash::hash(&[0x42u8; 33])) + } + + /// An unconfirmed deposit into the wallet, spending an outpoint nobody checks. + fn fund(wallet: &mut BdkWallet, last_seen: u64) -> OutPoint { + let address = wallet.reveal_next_address(KeychainKind::External).address; + let funding = Transaction { + version: transaction::Version::TWO, + lock_time: absolute::LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { txid: Txid::from_byte_array([7u8; 32]), vout: 0 }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: Amount::from_sat(FUNDING_SATS), + script_pubkey: address.script_pubkey(), + }], + }; + let outpoint = OutPoint { txid: funding.compute_txid(), vout: 0 }; + wallet.apply_unconfirmed_txs(vec![(funding, last_seen)]); + outpoint + } + + /// Builds, signs and returns a send of `sats` to a foreign script — exactly what + /// `Wallet::send_to_address` does before it applies and queues the result. + fn build_send(wallet: &mut BdkWallet, sats: u64) -> Transaction { + let mut builder = wallet.build_tx(); + builder + .add_recipient(someone_elses_script(), Amount::from_sat(sats)) + .fee_rate(FeeRate::from_sat_per_vb_u32(1)); + let mut psbt = builder.finish().expect("the wallet can fund this send"); + assert!(wallet.sign(&mut psbt, SignOptions::default()).expect("signing works")); + psbt.extract_tx().expect("finalized psbt extracts") + } + + fn unspent_outpoints(wallet: &BdkWallet) -> Vec { + wallet.list_unspent().map(|u| u.outpoint).collect() + } + + #[test] + fn own_unconfirmed_spend_locks_its_inputs_and_exposes_its_change() { + let mut wallet = new_wallet(); + let deposit = fund(&mut wallet, 1); + assert_eq!(unspent_outpoints(&wallet), vec![deposit]); + + let first = build_send(&mut wallet, 30_000); + assert_eq!(first.input[0].previous_output, deposit); + wallet.apply_unconfirmed_txs(vec![(first.clone(), 2)]); + + let unspent = unspent_outpoints(&wallet); + assert_eq!(unspent.len(), 1, "only the change output is left to spend"); + assert_eq!(unspent[0].txid, first.compute_txid(), "and it is the first send's change"); + assert!(!unspent.contains(&deposit), "the spent deposit is no longer offered"); + + // The very next send builds on the unconfirmed change instead of re-selecting the + // deposit — the second transaction of a block, chained on the first. + let second = build_send(&mut wallet, 20_000); + assert_eq!(second.input.len(), 1); + assert_eq!(second.input[0].previous_output.txid, first.compute_txid()); + } + + #[test] + fn evicting_an_unconfirmed_spend_hands_its_inputs_back() { + let mut wallet = new_wallet(); + let deposit = fund(&mut wallet, 1); + let first = build_send(&mut wallet, 30_000); + let first_txid = first.compute_txid(); + wallet.apply_unconfirmed_txs(vec![(first, 2)]); + assert!(!unspent_outpoints(&wallet).contains(&deposit)); + + // An eviction stamped no earlier than the last sighting wins (BDK: a transaction whose + // `last_evicted >= last_seen` is no longer canonical). + wallet.apply_evicted_txs(vec![(first_txid, 2)]); + assert_eq!(unspent_outpoints(&wallet), vec![deposit], "the deposit is spendable again"); + + // The eviction also hides the spend from the canonical view (`get_tx`), which is what + // keeps `Wallet::get_unconfirmed_transaction` from rebroadcasting an evicted one... + assert!(wallet.get_tx(first_txid).is_none()); + // ...but the transaction itself is kept, and seeing it again later (a rebroadcast, or + // the mempool) makes the spend canonical once more. + let again = wallet.tx_graph().get_tx(first_txid).expect("evicted, not forgotten"); + wallet.apply_unconfirmed_txs(vec![((*again).clone(), 3)]); + assert!(!unspent_outpoints(&wallet).contains(&deposit)); + } + + #[test] + fn an_unconfirmed_spend_survives_a_reload_from_the_persisted_change_set() { + let mut wallet = new_wallet(); + let deposit = fund(&mut wallet, 1); + let first = build_send(&mut wallet, 30_000); + let first_txid = first.compute_txid(); + wallet.apply_unconfirmed_txs(vec![(first, 2)]); + + let mut persisted = ChangeSet::default(); + persisted.merge(wallet.take_staged().expect("the wallet staged its creation and spend")); + + let reloaded = BdkWallet::load() + .descriptor(KeychainKind::External, Some(EXTERNAL_DESCRIPTOR)) + .descriptor(KeychainKind::Internal, Some(INTERNAL_DESCRIPTOR)) + .extract_keys() + .check_network(Network::Regtest) + .load_wallet_no_persist(persisted) + .expect("the change set loads") + .expect("the change set describes a wallet"); + + let unspent = unspent_outpoints(&reloaded); + assert_eq!(unspent.len(), 1); + assert_eq!(unspent[0].txid, first_txid, "the reloaded wallet still spends only the change"); + assert!(!unspent.contains(&deposit), "and still knows the deposit is spent"); + } +}