From ca4d4c348fefa22779924068c3112d8c5bd62dfd Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 10 Jun 2026 11:05:36 +0200 Subject: [PATCH 01/65] 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 7465dfabf..e4d4ea1c9 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 d7775e67b..adeb327bf 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 1ea6c4584..4e901e7e9 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 02/65] 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 7465dfabf..4cd7ec4bb 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 9673d0eb7..3f82d704e 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 03/65] 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 04/65] 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 000000000..4d4e3a245 --- /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 000000000..3342192b2 --- /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 3cd6d110d..cee639003 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 05/65] 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 4d4e3a245..edd344f50 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 cee639003..4b7e7c4d3 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 06/65] 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 000000000..3de6e6631 --- /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 3342192b2..2a236d492 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 4b7e7c4d3..ba29f656a 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 07/65] 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 3de6e6631..befd5e136 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 ba29f656a..9a4565254 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 08/65] 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 9a4565254..315e7f30f 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 000000000..c30df2e3a --- /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 000000000..cdbaf5426 --- /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 09/65] 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 315e7f30f..fae3f0875 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 c30df2e3a..67b4737a4 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 10/65] 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 7e9e61f5d..851583c5a 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 c88c867cc..15f49fb54 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 86ee7bb05..80acd0690 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 7ed69031c..005094f87 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 edd344f50..dff374fe2 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 befd5e136..3033f8d82 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 2a236d492..15ca7e965 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 fae3f0875..c2cdb4de0 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 67b4737a4..875438b0f 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 cdbaf5426..5e3a3b183 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 068269997..74a760801 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 91cc8f362..2aacc5c97 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 11/65] 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 15f49fb54..3df594b7c 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 fc206ec4d..9c4010458 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 12/65] 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 0068b6e07..2a981a007 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 13/65] 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 000000000..b30b5a33c --- /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 14/65] Mention Postgres support in README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a981a007..289ada179 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 15/65] 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 ad0ef1b7b..7406f06b4 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 16/65] 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 93c7cf59b..c9f15e61f 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 bed984f07..aa9df0b18 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 6c3535627..b87b94dab 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 17/65] 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 b87b94dab..d3bd77e2e 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 18/65] 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 d3bd77e2e..81a63b0c5 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 19/65] 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 068269997..1761133f2 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 20/65] 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 1761133f2..bda11b96d 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 309d5bf4d..b1aa090a2 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 21/65] 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 3708afe8e..2ad77f772 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 309d5bf4d..76835b38a 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 22/65] 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 068269997..e3cb948a1 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 23/65] 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 92c4bdb64..5a326be97 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 24/65] 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 bed984f07..58913d77c 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 3f82d704e..9fb4116e0 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 25/65] 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 81a63b0c5..fb4ec9d76 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 26/65] 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 fb4ec9d76..f6e865bd8 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 c2cdb4de0..87a0650c8 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 9fb4116e0..7e29996e6 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 27/65] 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 7380d75ca..c2cf48388 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 b45064287..3ddae3b5a 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 64209430b..bc8759142 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 5b07ab50d..6e68db878 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 b30b5a33c..f07e49427 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 28/65] 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 3ddae3b5a..34fa7f54d 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 bc8759142..914b5dc15 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 29/65] 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 4f53dbabf..177ae1d75 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 c2cf48388..9bb03bb07 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 30/65] 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 6e68db878..3393c2cd0 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 31/65] 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 177ae1d75..9395a6b31 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 32/65] 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 9395a6b31..304caf9c0 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 33/65] 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 70abfcc3f..1fbc6e728 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 34/65] 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 1fbc6e728..4ae43d2f3 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 35/65] 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 4ae43d2f3..b9b045e44 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 36/65] 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 b9b045e44..3176e7ce2 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 37/65] 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 3df594b7c..d142f51af 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 914b5dc15..e24db4d25 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 adeb327bf..1f5753e55 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 f07e49427..de5bef96e 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 38/65] 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 e2ae378dd..5e5149ac5 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 cd3980b9a..32cd4782b 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 81eb82250..3c1a8f580 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 9e54ab9f3..7bb55a86d 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 56d51b77e..daa4572cc 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 f7ded7bc5..767210f10 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 f4d55e3bc..f3066e4c7 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 caefbdb6b..6006ecf2b 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 e154faa7e..be5bbeb25 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 16064fa45..d8e0932b1 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 0fdfbe213..52c505b5b 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 c1e385e2d..2973892bf 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 c67e9194e..7ffea3dd6 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 35666df03..8ee2fe54b 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 39/65] 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 32cd4782b..4a884ab2a 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 7bb55a86d..65ca21511 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 767210f10..76a95f93d 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 d8e0932b1..106f2c4f9 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 40/65] 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 c9ce29d32..800ce0e13 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 80acd0690..93d274ff7 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 76f2aa9ce..c379ae2fc 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 fab73ed0c..38a66b184 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 41/65] 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 c379ae2fc..b5a4e0901 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 42/65] 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 eb72f89ec..dfcb6fd55 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 b5a4e0901..1be31f6b9 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 38a66b184..4a66deb5f 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 43/65] 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 e16a99975..a01aa59a8 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 44/65] 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 71daa48b0..ee53ed7f8 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 dfcb6fd55..a7dd916b0 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 45/65] 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 9d00968fc..da2685970 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 1be31f6b9..f3429afbf 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 4a66deb5f..521cb74ca 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 46/65] 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 3176e7ce2..13afeca7e 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 47/65] 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 076aeef9b..258722059 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 48/65] 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 c0770de5f..90b8cdc39 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 49/65] 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 f6e865bd8..3dbaf80a4 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 50/65] 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 3dbaf80a4..61d4e7abc 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 51/65] 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 8b7d41c84..156fef3a3 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 52/65] 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 ee53ed7f8..bdc2fe96a 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 a7dd916b0..311fdbf34 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 f80ab6f8a..160890895 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 f3429afbf..4b83c64e5 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 521cb74ca..404b1a1db 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 53/65] 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 d142f51af..7a26ce24f 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 5a326be97..8a8115e4f 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 7084135b0..5722a3ebe 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 4b83c64e5..28a4a3d80 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 54/65] 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 28a4a3d80..f8208fb0b 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 55/65] 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 851583c5a..5621f1751 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 34fa7f54d..a3410db1f 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 f8208fb0b..be5c7e503 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 56/65] 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 404b1a1db..bd0068458 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 57/65] 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 bdc2fe96a..2d3acf90e 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 311fdbf34..c8b792ccb 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 be5c7e503..ad4f8d45e 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 bd0068458..e19a1ca1e 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 58/65] 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 e19a1ca1e..f45b31f28 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 59/65] 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 a3410db1f..46db6d80c 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 f45b31f28..8d71faed5 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 60/65] 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 34fe7b64c..b785bfca4 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 46db6d80c..c97e16fe6 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 61/65] 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 8d71faed5..41028b662 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 62/65] 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 c9ce29d32..322e765ed 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 1f5753e55..68ace9179 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 000000000..ee5ad26c8 --- /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 b96b0c277..0c93c705c 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 63/65] 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 c9f15e61f..e482de6a4 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 7a26ce24f..085ff7d20 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 41028b662..fece76884 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 64/65] 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 e482de6a4..88aed9a76 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 5621f1751..7c0edc535 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 085ff7d20..8b575cc3f 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 68ace9179..22809a26d 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 fece76884..2e7ad0ef2 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 65/65] 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 88aed9a76..e7e012a14 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 7406f06b4..23c930d98 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 eb23a395d..0754986e8 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 558a4d061..ad1b91181 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 22809a26d..a56d46e05 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 2e7ad0ef2..c3c2f4262 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