From 8d64787b05d8125226bce2dac38cd3f55c07c997 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:13:16 +0200 Subject: [PATCH 01/41] lsps2: Move client into module directory Prepare the LSPS2 client for dedicated state and storage modules. Co-Authored-By: HAL 9000 --- src/liquidity/client/{lsps2.rs => lsps2/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/liquidity/client/{lsps2.rs => lsps2/mod.rs} (100%) diff --git a/src/liquidity/client/lsps2.rs b/src/liquidity/client/lsps2/mod.rs similarity index 100% rename from src/liquidity/client/lsps2.rs rename to src/liquidity/client/lsps2/mod.rs From 270b9a5b06f5aeeb3c7501c13a10cd2e82ac114d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:13:41 +0200 Subject: [PATCH 02/41] lsps2: Add single-use payment leases Track negotiated parameters as expiring leases so intercept identifiers cannot be reused across payments. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 1 + src/liquidity/client/lsps2/state.rs | 150 ++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/liquidity/client/lsps2/state.rs diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 3033f8d827..923db45e57 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -551,3 +551,4 @@ pub(crate) struct LSPS2BuyResponse { intercept_scid: u64, cltv_expiry_delta: u32, } +pub(crate) mod state; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs new file mode 100644 index 0000000000..0e479e2c43 --- /dev/null +++ b/src/liquidity/client/lsps2/state.rs @@ -0,0 +1,150 @@ +use std::collections::HashMap; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use bitcoin::secp256k1::PublicKey; +use lightning::impl_writeable_tlv_based; +use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; + +use crate::data_store::{DataStore, StorableObject, StorableObjectId, StorableObjectUpdate}; +use crate::hex_utils; + +pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; + +pub(crate) type PaymentLeaseStore = DataStore; + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub(crate) struct PaymentLeaseId { + pub(crate) lsp_node_id: PublicKey, + pub(crate) intercept_scid: u64, +} + +impl_writeable_tlv_based!(PaymentLeaseId, { + (0, lsp_node_id, required), + (2, intercept_scid, required), +}); + +impl StorableObjectId for PaymentLeaseId { + fn encode_to_hex_str(&self) -> String { + format!( + "{}{:016x}", + hex_utils::to_string(&self.lsp_node_id.serialize()), + self.intercept_scid + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PaymentLease { + pub(crate) id: PaymentLeaseId, + pub(crate) params: LSPS2OpeningFeeParams, + pub(crate) cltv_expiry_delta: u32, + pub(crate) payment_size_msat: Option, + pub(crate) valid_until: u64, +} + +impl_writeable_tlv_based!(PaymentLease, { + (0, id, required), + (2, params, required), + (4, valid_until, required), + (6, cltv_expiry_delta, required), + (8, payment_size_msat, option), +}); + +#[derive(Clone, Debug)] +pub(crate) struct PaymentLeaseUpdate(PaymentLease); + +impl StorableObjectUpdate for PaymentLeaseUpdate { + fn id(&self) -> PaymentLeaseId { + self.0.id + } +} + +impl StorableObject for PaymentLease { + type Id = PaymentLeaseId; + type Update = PaymentLeaseUpdate; + + fn id(&self) -> Self::Id { + self.id + } + + fn update(&mut self, update: Self::Update) -> bool { + if *self == update.0 { + false + } else { + *self = update.0; + true + } + } + + fn to_update(&self) -> Self::Update { + PaymentLeaseUpdate(self.clone()) + } +} + +#[derive(Default)] +pub(crate) struct LSPS2LeaseState { + leases: HashMap, +} + +impl LSPS2LeaseState { + pub(crate) fn from_leases(leases: Vec) -> Self { + Self { leases: leases.into_iter().map(|lease| (lease.id, lease)).collect() } + } + + pub(crate) fn insert(&mut self, lease: PaymentLease) { + self.leases.insert(lease.id, lease); + } + + pub(crate) fn take_valid(&mut self, id: &PaymentLeaseId) -> Option { + let lease = self.leases.remove(id)?; + if is_lease_usable(&lease) { + Some(lease) + } else { + None + } + } + + pub(crate) fn prune(&mut self) { + self.leases.retain(|_, lease| is_lease_usable(lease)); + } +} + +pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { + lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS +} + +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leases_are_consumed_once() { + let id = PaymentLeaseId { + lsp_node_id: PublicKey::from_slice(&[2; 33]).unwrap(), + intercept_scid: 42, + }; + let mut state = LSPS2LeaseState::default(); + state.insert(PaymentLease { + id, + params: LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: "2030-01-01T00:00:00Z".parse().unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 2, + promise: String::new(), + }, + cltv_expiry_delta: 18, + payment_size_msat: Some(1_000), + valid_until: now_secs() + MIN_LEASE_REMAINING_SECS + 60, + }); + assert!(state.take_valid(&id).is_some()); + assert!(state.take_valid(&id).is_none()); + } +} From e9a117785bea161e6dba3162610b56a85ea85f53 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:27:14 +0200 Subject: [PATCH 03/41] lsps2: Persist payment leases Load cached leases in parallel at startup and remove entries that are expired or too close to expiry for safe reuse. Co-Authored-By: HAL 9000 --- src/io/mod.rs | 4 ++++ src/liquidity/client/lsps2/mod.rs | 4 ++++ src/liquidity/mod.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/io/mod.rs b/src/io/mod.rs index a01aa59a83..ab4f4108ff 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -33,6 +33,10 @@ pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// Cached LSPS2 payment leases are persisted under this prefix. +pub(crate) const LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE: &str = "lsps2_leases"; +pub(crate) const LSPS2_LEASE_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 = ""; diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 923db45e57..09baa422a5 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -35,6 +35,8 @@ use crate::payment::PaymentMetadata; use crate::types::{ChannelManager, KeysManager, LiquidityManager}; use crate::{Config, Error}; +use self::state::{LSPS2LeaseState, PaymentLeaseStore}; + pub(crate) struct LSPS2Client where L::Target: LdkLogger, @@ -44,6 +46,8 @@ where Mutex>>, pub(crate) pending_buy_requests: Mutex>>, + pub(crate) lease_store: Arc>, + pub(crate) lease_state: Mutex, pub(crate) channel_manager: Arc, pub(crate) keys_manager: Arc, pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 0eddde1ae8..428ba31f5c 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -31,7 +31,12 @@ use tokio::sync::oneshot; use crate::builder::BuildError; use crate::connection::ConnectionManager; +use crate::io::utils::read_all_objects; +use crate::io::{ + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, +}; use crate::liquidity::client::lsps1::LSPS1Client; +use crate::liquidity::client::lsps2::state::{is_lease_usable, LSPS2LeaseState, PaymentLeaseStore}; use crate::liquidity::client::lsps2::LSPS2Client; use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; @@ -247,6 +252,26 @@ where } pub(crate) async fn build(self) -> Result, BuildError> { + let leases = read_all_objects( + &*self.kv_store, + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + self.logger.clone(), + ) + .await + .map_err(|_| BuildError::ReadFailed)?; + let lease_store = Arc::new(PaymentLeaseStore::new( + leases.clone(), + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&self.kv_store), + self.logger.clone(), + )); + for lease in leases.iter().filter(|lease| !is_lease_usable(lease)) { + lease_store.remove(&lease.id).await.map_err(|_| BuildError::WriteFailed)?; + } + let lease_state = + LSPS2LeaseState::from_leases(leases.into_iter().filter(is_lease_usable).collect()); let liquidity_service_config = self.lsps2_service.as_ref().map(|s| { let lsps2_service_config = Some(s.ldk_service_config.clone()); let lsps5_service_config = None; @@ -311,6 +336,8 @@ where lsp_nodes: Arc::clone(&lsp_nodes), pending_lsps2_fee_requests: Mutex::new(HashMap::new()), pending_buy_requests: Mutex::new(HashMap::new()), + lease_store, + lease_state: Mutex::new(lease_state), channel_manager: self.channel_manager.clone(), keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), From f2deb5797293fa445ab14a6695e23772e10b8871 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:28:41 +0200 Subject: [PATCH 04/41] lsps2: Prune stale payment leases Remove unusable leases hourly so persisted cache data cannot grow without bound. Co-Authored-By: HAL 9000 --- src/lib.rs | 19 +++++++++++++++++++ src/liquidity/client/lsps2/mod.rs | 14 ++++++++++++++ src/liquidity/client/lsps2/state.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 14ee734a3d..a60f9fcd00 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -555,6 +555,25 @@ impl Node { } }); + // Periodically prune LSPS2 leases before they expire so stale parameters don't accumulate. + let lsps2_client = self.liquidity_source.lsps2_client(); + let prune_logger = Arc::clone(&self.logger); + let mut stop_pruning = self.stop_sender.subscribe(); + self.runtime.spawn_cancellable_background_task(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60 * 60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = stop_pruning.changed() => return, + _ = interval.tick() => { + if let Err(error) = lsps2_client.prune_stale_leases().await { + log_error!(prune_logger, "Failed pruning stale LSPS2 leases: {}", error); + } + }, + } + } + }); + // Regularly broadcast node announcements. let bcast_cm = Arc::clone(&self.channel_manager); let bcast_pm = Arc::clone(&self.peer_manager); diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 09baa422a5..6ae95225ab 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -60,6 +60,20 @@ impl LSPS2Client where L::Target: LdkLogger, { + pub(crate) async fn prune_stale_leases(&self) -> Result<(), Error> { + let stale_ids = self + .lease_store + .list_filter(|lease| !state::is_lease_usable(lease)) + .into_iter() + .map(|lease| lease.id) + .collect::>(); + for id in stale_ids { + self.lease_store.remove(&id).await?; + } + self.lease_state.lock().expect("lock").prune(); + Ok(()) + } + pub(crate) async fn lsps2_receive_to_jit_channel( self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 0e479e2c43..73282a2c31 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -147,4 +147,31 @@ mod tests { assert!(state.take_valid(&id).is_some()); assert!(state.take_valid(&id).is_none()); } + + #[test] + fn prunes_leases_close_to_expiry() { + let id = PaymentLeaseId { + lsp_node_id: PublicKey::from_slice(&[2; 33]).unwrap(), + intercept_scid: 43, + }; + let mut state = LSPS2LeaseState::default(); + state.insert(PaymentLease { + id, + params: LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: "2030-01-01T00:00:00Z".parse().unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 2, + promise: String::new(), + }, + cltv_expiry_delta: 18, + payment_size_msat: Some(1_000), + valid_until: now_secs() + MIN_LEASE_REMAINING_SECS - 1, + }); + state.prune(); + assert!(state.take_valid(&id).is_none()); + } } From d2f4966294c225a01abc9cb7c05b59828b87be08 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:29:52 +0200 Subject: [PATCH 05/41] lsps2: Cache negotiated payment leases Persist negotiated parameters before making them available and consume them before constructing existing BOLT11 invoices. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 53 ++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 6ae95225ab..af99feae55 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -35,7 +35,7 @@ use crate::payment::PaymentMetadata; use crate::types::{ChannelManager, KeysManager, LiquidityManager}; use crate::{Config, Error}; -use self::state::{LSPS2LeaseState, PaymentLeaseStore}; +use self::state::{LSPS2LeaseState, PaymentLease, PaymentLeaseId, PaymentLeaseStore}; pub(crate) struct LSPS2Client where @@ -128,20 +128,21 @@ where min_total_fee_msat ); - let buy_response = self + let negotiated_lease = self .lsps2_send_buy_request( Some(amount_msat), min_opening_params, Some(&cheapest_lsp.node_id), ) .await?; + let lease = self.consume_lease(&negotiated_lease.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, + LSPS2BuyResponse::from(&lease), Some(amount_msat), description, expiry_secs, @@ -196,15 +197,16 @@ where min_prop_fee_ppm_msat ); - let buy_response = self + let negotiated_lease = self .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) .await?; + let lease = self.consume_lease(&negotiated_lease.id).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, + LSPS2BuyResponse::from(&lease), None, description, expiry_secs, @@ -306,7 +308,7 @@ where async fn lsps2_send_buy_request( &self, amount_msat: Option, opening_fee_params: LSPS2OpeningFeeParams, node_id: Option<&PublicKey>, - ) -> Result { + ) -> Result { let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) .ok_or(Error::LiquiditySourceUnavailable)?; @@ -319,7 +321,7 @@ where { let mut pending_buy_requests_lock = self.pending_buy_requests.lock().expect("lock"); let request_id = client_handler - .select_opening_params(lsps2_node.node_id, amount_msat, opening_fee_params) + .select_opening_params(lsps2_node.node_id, amount_msat, opening_fee_params.clone()) .map_err(|e| { log_error!( self.logger, @@ -345,7 +347,36 @@ where Error::LiquidityRequestFailed })?; - Ok(buy_response) + let valid_until = opening_fee_params + .valid_until + .0 + .timestamp() + .try_into() + .map_err(|_| Error::LiquidityRequestFailed)?; + let lease = PaymentLease { + id: PaymentLeaseId { + lsp_node_id: lsps2_node.node_id, + intercept_scid: buy_response.intercept_scid, + }, + params: opening_fee_params, + cltv_expiry_delta: buy_response.cltv_expiry_delta, + payment_size_msat: amount_msat, + valid_until, + }; + self.lease_store.insert(lease.clone()).await?; + self.lease_state.lock().expect("lock").insert(lease.clone()); + Ok(lease) + } + + async fn consume_lease(&self, id: &PaymentLeaseId) -> Result { + let lease = self + .lease_state + .lock() + .expect("lock") + .take_valid(id) + .ok_or(Error::LiquidityRequestFailed)?; + self.lease_store.remove(id).await?; + Ok(lease) } fn lsps2_create_jit_invoice( @@ -569,4 +600,10 @@ pub(crate) struct LSPS2BuyResponse { intercept_scid: u64, cltv_expiry_delta: u32, } + +impl From<&PaymentLease> for LSPS2BuyResponse { + fn from(lease: &PaymentLease) -> Self { + Self { intercept_scid: lease.id.intercept_scid, cltv_expiry_delta: lease.cltv_expiry_delta } + } +} pub(crate) mod state; From 773f2b68161d12b56b0ae0f2784e46010bbb785d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 14:38:11 +0200 Subject: [PATCH 06/41] lsps2: Configure a global fee limit Move LSPS2 fee policy out of individual BOLT11 receive calls and into node configuration. A shared total limit gives every receive flow one policy across all configured liquidity sources. Co-Authored-By: HAL 9000 --- src/config.rs | 9 ++++ src/liquidity/client/lsps2/mod.rs | 49 +++++++++-------- src/payment/bolt11.rs | 87 ++++++++++++------------------- tests/integration_tests_rust.rs | 36 ++++++++----- 4 files changed, 91 insertions(+), 90 deletions(-) diff --git a/src/config.rs b/src/config.rs index 772c4bd806..d85f9bbc34 100644 --- a/src/config.rs +++ b/src/config.rs @@ -151,6 +151,7 @@ pub(crate) const LIQUIDITY_DISCOVERY_RETRY_MAX_DELAY: Duration = Duration::from_ /// | `node_alias` | None | /// | `trusted_peers_0conf` | [] | /// | `probing_liquidity_limit_multiplier` | 3 | +/// | `lsps2_max_total_lsp_fee_limit_msat` | None | /// | `anchor_channels_config` | AnchorChannelsConfig::default() | /// | `route_parameters` | None | /// | `tor_config` | None | @@ -194,6 +195,13 @@ pub struct Config { /// Channels with available liquidity less than the required amount times this value won't be /// used to send pre-flight probes. pub probing_liquidity_limit_multiplier: u64, + /// The maximum total fee in millisatoshis that an LSPS2 liquidity provider may charge for a + /// just-in-time channel. + /// + /// This limit applies to all configured LSPS2 liquidity sources. If unset, fixed-amount + /// payments accept the fee negotiated for that payment, while variable-amount payments use the + /// negotiated proportional fee as their limit. + pub lsps2_max_total_lsp_fee_limit_msat: Option, /// Configuration options pertaining to Anchor channels, i.e., channels for which the /// `option_zero_fee_commitments` or `option_anchors_zero_fee_htlc_tx` channel type is /// negotiated. @@ -230,6 +238,7 @@ impl Default for Config { announcement_addresses: None, trusted_peers_0conf: Vec::new(), probing_liquidity_limit_multiplier: DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER, + lsps2_max_total_lsp_fee_limit_msat: None, anchor_channels_config: AnchorChannelsConfig::default(), tor_config: None, route_parameters: None, diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index af99feae55..9c40e97300 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -76,8 +76,8 @@ where pub(crate) async fn lsps2_receive_to_jit_channel( self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, - expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, - payment_hash: Option, connection_manager: Arc>, + expiry_secs: u32, 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?; @@ -111,10 +111,10 @@ where Error::LiquidityRequestFailed })?; - if let Some(max_total_lsp_fee_limit_msat) = max_total_lsp_fee_limit_msat { + if let Some(max_total_lsp_fee_limit_msat) = self.config.lsps2_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", + "Failed to request inbound JIT channel as LSP's requested total opening fee of {}msat exceeds our configured fee limit of {}msat", min_total_fee_msat, max_total_lsp_fee_limit_msat ); return Err(Error::LiquidityFeeTooHigh); @@ -157,11 +157,11 @@ where pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( self: Arc, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, - connection_manager: Arc>, + 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 mut rejected_for_fee = false; let (cheapest_lsp, min_prop_fee_ppm_msat, min_opening_params) = all_offers .into_iter() .flat_map(|(lsp, resp)| { @@ -171,25 +171,26 @@ where let ppm = params.proportional as u64; (lsp, ppm, params) }) + .filter(|(_, _, params)| { + let allowed = self + .config + .lsps2_max_total_lsp_fee_limit_msat + .map_or(true, |limit| params.min_fee_msat <= limit); + rejected_for_fee |= !allowed; + allowed + }) .min_by_key(|(_, ppm, _)| *ppm) .ok_or_else(|| { + if rejected_for_fee { + log_error!( + self.logger, + "Failed to request inbound JIT channel as all LSP offers exceed our configured fee limit" + ); + return Error::LiquidityFeeTooHigh; + } 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 from LSP {}, will pay {}ppm msat in proportional LSP fees", @@ -202,8 +203,12 @@ where .await?; let lease = self.consume_lease(&negotiated_lease.id).await?; let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: None, - max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), + max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, + max_proportional_opening_fee_ppm_msat: self + .config + .lsps2_max_total_lsp_fee_limit_msat + .is_none() + .then_some(min_prop_fee_ppm_msat), }; let invoice = self.lsps2_create_jit_invoice( LSPS2BuyResponse::from(&lease), diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 4503dfa061..4cd1dadf71 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -165,8 +165,7 @@ impl Bolt11Payment { fn receive_via_jit_channel_inner( &self, amount_msat: Option, description: &LdkBolt11InvoiceDescription, - expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, - max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, + expiry_secs: u32, payment_hash: Option, ) -> Result { let connection_manager = Arc::clone(&self.connection_manager); let (invoice, chosen_lsp) = self.runtime.block_on(async move { @@ -177,7 +176,6 @@ impl Bolt11Payment { amount_msat, description, expiry_secs, - max_total_lsp_fee_limit_msat, payment_hash, connection_manager, ) @@ -188,7 +186,6 @@ impl Bolt11Payment { .lsps2_receive_variable_amount_to_jit_channel( description, expiry_secs, - max_proportional_lsp_fee_limit_ppm_msat, payment_hash, connection_manager, ) @@ -655,40 +652,33 @@ impl Bolt11Payment { Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a payment of the amount given and - /// receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a payment of + /// the amount given when additional inbound liquidity is needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_total_lsp_fee_limit_msat` will limit how much fee we allow the LSP to take for opening the - /// channel to us. We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced before creating the invoice. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md pub fn receive_via_jit_channel( &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_total_lsp_fee_limit_msat: Option, ) -> Result { let description = maybe_try_convert_enum(description)?; - let invoice = self.receive_via_jit_channel_inner( - Some(amount_msat), - &description, - expiry_secs, - max_total_lsp_fee_limit_msat, - None, - None, - )?; + let invoice = + self.receive_via_jit_channel_inner(Some(amount_msat), &description, expiry_secs, None)?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a payment of the amount given and - /// receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a payment of + /// the amount given when additional inbound liquidity is needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_total_lsp_fee_limit_msat` will limit how much fee we allow the LSP to take for opening the - /// channel to us. We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced before creating the invoice. /// /// We will register the given payment hash and emit a [`PaymentClaimable`] event once /// the inbound payment arrives. The check that [`counterparty_skimmed_fee_msat`] is within the limits @@ -706,56 +696,46 @@ impl Bolt11Payment { /// [`counterparty_skimmed_fee_msat`]: crate::payment::PaymentKind::Bolt11::counterparty_skimmed_fee_msat pub fn receive_via_jit_channel_for_hash( &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_total_lsp_fee_limit_msat: Option, payment_hash: PaymentHash, + payment_hash: PaymentHash, ) -> Result { let description = maybe_try_convert_enum(description)?; let invoice = self.receive_via_jit_channel_inner( Some(amount_msat), &description, expiry_secs, - max_total_lsp_fee_limit_msat, - None, Some(payment_hash), )?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a variable amount payment (also known - /// as "zero-amount" invoice) and receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a variable + /// amount payment, also known as a "zero-amount" invoice, when additional inbound liquidity is + /// needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_proportional_lsp_fee_limit_ppm_msat` will limit how much proportional fee, in - /// parts-per-million millisatoshis, we allow the LSP to take for opening the channel to us. - /// We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced when the payment arrives. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md pub fn receive_variable_amount_via_jit_channel( &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_proportional_lsp_fee_limit_ppm_msat: Option, ) -> Result { let description = maybe_try_convert_enum(description)?; - let invoice = self.receive_via_jit_channel_inner( - None, - &description, - expiry_secs, - None, - max_proportional_lsp_fee_limit_ppm_msat, - None, - )?; + let invoice = self.receive_via_jit_channel_inner(None, &description, expiry_secs, None)?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a variable amount payment (also known - /// as "zero-amount" invoice) and receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a variable + /// amount payment, also known as a "zero-amount" invoice, when additional inbound liquidity is + /// needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_proportional_lsp_fee_limit_ppm_msat` will limit how much proportional fee, in - /// parts-per-million millisatoshis, we allow the LSP to take for opening the channel to us. - /// We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced when the payment arrives. /// /// We will register the given payment hash and emit a [`PaymentClaimable`] event once /// the inbound payment arrives. The check that [`counterparty_skimmed_fee_msat`] is within the limits @@ -772,16 +752,13 @@ impl Bolt11Payment { /// [`fail_for_hash`]: Self::fail_for_hash /// [`counterparty_skimmed_fee_msat`]: crate::payment::PaymentKind::Bolt11::counterparty_skimmed_fee_msat pub fn receive_variable_amount_via_jit_channel_for_hash( - &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: PaymentHash, + &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: PaymentHash, ) -> Result { let description = maybe_try_convert_enum(description)?; let invoice = self.receive_via_jit_channel_inner( None, &description, expiry_secs, - None, - max_proportional_lsp_fee_limit_ppm_msat, Some(payment_hash), )?; Ok(maybe_wrap(invoice)) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index df477588f1..09a5adbc0e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2999,7 +2999,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { println!("Generating JIT invoice!"); let jit_invoice = client_node .bolt11_payment() - .receive_via_jit_channel(jit_amount_msat, &invoice_description.into(), 1024, None) + .receive_via_jit_channel(jit_amount_msat, &invoice_description.into(), 1024) .unwrap(); // Have the payer_node pay the invoice, therby triggering channel open service_node -> client_node. @@ -3060,7 +3060,6 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, manual_payment_hash, ) .unwrap(); @@ -3113,7 +3112,6 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, manual_payment_hash, ) .unwrap(); @@ -3323,7 +3321,6 @@ async fn lsps2_client_trusts_lsp() { jit_amount_msat, &invoice_description.into(), 1024, - None, manual_payment_hash, ) .unwrap(); @@ -3500,7 +3497,6 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { jit_amount_msat, &invoice_description.into(), 1024, - None, manual_payment_hash, ) .unwrap(); @@ -4313,11 +4309,18 @@ async fn splice_in_with_all_balance() { #[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; + do_lsps2_multi_lsp_picks_cheapest(false, None).await; + do_lsps2_multi_lsp_picks_cheapest(true, None).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn lsps2_multi_lsp_rejects_fees_above_limit() { + do_lsps2_multi_lsp_picks_cheapest(false, Some(999_999)).await; } -async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { +async fn do_lsps2_multi_lsp_picks_cheapest( + reverse_order: bool, max_total_lsp_fee_limit_msat: Option, +) { let (_bitcoind, electrsd) = setup_bitcoind_and_electrsd(); let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); @@ -4371,7 +4374,8 @@ async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { let expensive_addr = expensive.listening_addresses().unwrap().first().unwrap().clone(); // Client knows both LSPs. Registration order is varied to confirm selection isn't order-based. - let client_config = random_config(); + let mut client_config = random_config(); + client_config.node_config.lsps2_max_total_lsp_fee_limit_msat = max_total_lsp_fee_limit_msat; setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); if reverse_order { @@ -4386,10 +4390,16 @@ async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { 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(); + let jit_invoice_result = + client.bolt11_payment().receive_via_jit_channel(100_000_000, &invoice_description, 1024); + if max_total_lsp_fee_limit_msat.is_some() { + assert!(matches!(jit_invoice_result, Err(NodeError::LiquidityFeeTooHigh))); + client.stop().unwrap(); + cheap.stop().unwrap(); + expensive.stop().unwrap(); + return; + } + let jit_invoice = jit_invoice_result.unwrap(); // The route hint's src_node_id is the LSP the client picked. let route_hints = jit_invoice.route_hints(); From e382208506331a06f9e509cf284a1ecfa66be55d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:32:11 +0200 Subject: [PATCH 07/41] bolt11: Consume cached LSPS2 leases Prefer the cheapest eligible cached lease across configured LSPs and negotiate only when the cache has no parameters allowed by the node-wide fee policy. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 132 ++++++++++++++++++++++++- src/liquidity/client/lsps2/state.rs | 147 +++++++++++++++++++--------- 2 files changed, 231 insertions(+), 48 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 9c40e97300..71a479531b 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -6,6 +6,7 @@ // accordance with one or both of these licenses. use std::collections::HashMap; +use std::future::Future; use std::ops::Deref; use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; @@ -37,6 +38,20 @@ use crate::{Config, Error}; use self::state::{LSPS2LeaseState, PaymentLease, PaymentLeaseId, PaymentLeaseStore}; +async fn consume_after_persisted_removal( + value: T, persist_removal: RF, consume: CF, +) -> Result +where + T: Clone, + RF: FnOnce(T) -> Fut, + CF: FnOnce(&T), + Fut: Future>, +{ + persist_removal(value.clone()).await?; + consume(&value); + Ok(value) +} + pub(crate) struct LSPS2Client where L::Target: LdkLogger, @@ -79,6 +94,24 @@ where expiry_secs: u32, payment_hash: Option, connection_manager: Arc>, ) -> Result<(Bolt11Invoice, LspConfig), Error> { + if let Some((lease, total_fee_msat, lsp)) = + self.take_cached_fixed_lease(amount_msat).await? + { + let invoice = self.lsps2_create_jit_invoice( + LSPS2BuyResponse::from(&lease), + Some(amount_msat), + description, + expiry_secs, + payment_hash, + LSPS2Parameters { + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }, + Some(&lsp.node_id), + )?; + return Ok((invoice, lsp)); + } + // 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 @@ -159,6 +192,26 @@ where self: Arc, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: Option, connection_manager: Arc>, ) -> Result<(Bolt11Invoice, LspConfig), Error> { + if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { + let invoice = self.lsps2_create_jit_invoice( + LSPS2BuyResponse::from(&lease), + None, + description, + expiry_secs, + payment_hash, + LSPS2Parameters { + max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, + max_proportional_opening_fee_ppm_msat: self + .config + .lsps2_max_total_lsp_fee_limit_msat + .is_none() + .then_some(proportional_fee), + }, + Some(&lsp.node_id), + )?; + return Ok((invoice, lsp)); + } + // Connect to all candidate LSPs before querying fees. let all_offers = self.gather_lsps2_offers(&connection_manager).await?; let mut rejected_for_fee = false; @@ -378,10 +431,63 @@ where .lease_state .lock() .expect("lock") - .take_valid(id) + .valid(id) .ok_or(Error::LiquidityRequestFailed)?; - self.lease_store.remove(id).await?; - Ok(lease) + self.consume_selected_lease(lease).await + } + + async fn consume_selected_lease(&self, lease: PaymentLease) -> Result { + let lease_store = Arc::clone(&self.lease_store); + consume_after_persisted_removal( + lease, + move |lease| async move { lease_store.remove(&lease.id).await }, + |lease| { + self.lease_state.lock().expect("lock").remove(&lease.id); + }, + ) + .await + } + + async fn take_cached_fixed_lease( + &self, amount_msat: u64, + ) -> Result, Error> { + loop { + let Some((lease, fee_msat)) = self + .lease_state + .lock() + .expect("lock") + .fixed_amount(amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat) + else { + return Ok(None); + }; + let lease = self.consume_selected_lease(lease).await?; + if let Some(lsp) = + select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id)) + { + return Ok(Some((lease, fee_msat, lsp))); + } + } + } + + async fn take_cached_variable_lease( + &self, + ) -> Result, Error> { + loop { + let Some((lease, proportional_fee)) = self + .lease_state + .lock() + .expect("lock") + .variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat) + else { + return Ok(None); + }; + let lease = self.consume_selected_lease(lease).await?; + if let Some(lsp) = + select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id)) + { + return Ok(Some((lease, proportional_fee, lsp))); + } + } } fn lsps2_create_jit_invoice( @@ -611,4 +717,24 @@ impl From<&PaymentLease> for LSPS2BuyResponse { Self { intercept_scid: lease.id.intercept_scid, cltv_expiry_delta: lease.cltv_expiry_delta } } } +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn failed_persisted_removal_does_not_consume() { + let consumed = Arc::new(Mutex::new(false)); + let consumed_ref = Arc::clone(&consumed); + let result = consume_after_persisted_removal( + 42, + |_| async { Err(()) }, + move |_| *consumed_ref.lock().unwrap() = true, + ) + .await; + + assert_eq!(result, Err(())); + assert!(!*consumed.lock().unwrap()); + } +} + pub(crate) mod state; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 73282a2c31..2b6505f24a 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -4,6 +4,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; use lightning::impl_writeable_tlv_based; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; +use lightning_liquidity::lsps2::utils::compute_opening_fee; use crate::data_store::{DataStore, StorableObject, StorableObjectId, StorableObjectUpdate}; use crate::hex_utils; @@ -95,13 +96,49 @@ impl LSPS2LeaseState { self.leases.insert(lease.id, lease); } - pub(crate) fn take_valid(&mut self, id: &PaymentLeaseId) -> Option { - let lease = self.leases.remove(id)?; - if is_lease_usable(&lease) { - Some(lease) - } else { - None - } + pub(crate) fn valid(&self, id: &PaymentLeaseId) -> Option { + self.leases.get(id).filter(|lease| is_lease_usable(lease)).cloned() + } + + pub(crate) fn remove(&mut self, id: &PaymentLeaseId) -> Option { + self.leases.remove(id) + } + + pub(crate) fn fixed_amount( + &self, amount_msat: u64, max_fee_msat: Option, + ) -> Option<(PaymentLease, u64)> { + let (id, fee_msat) = self + .leases + .iter() + .filter(|(_, lease)| lease.payment_size_msat == Some(amount_msat)) + .filter(|(_, lease)| is_lease_usable(lease)) + .filter_map(|(id, lease)| { + compute_opening_fee( + amount_msat, + lease.params.min_fee_msat, + lease.params.proportional as u64, + ) + .map(|fee_msat| (*id, fee_msat)) + }) + .filter(|(_, fee_msat)| max_fee_msat.map_or(true, |max| *fee_msat <= max)) + .min_by_key(|(_, fee_msat)| *fee_msat)?; + self.leases.get(&id).cloned().map(|lease| (lease, fee_msat)) + } + + pub(crate) fn variable_amount( + &self, max_total_fee_msat: Option, + ) -> Option<(PaymentLease, u64)> { + let (id, proportional_fee) = self + .leases + .iter() + .filter(|(_, lease)| lease.payment_size_msat.is_none()) + .filter(|(_, lease)| is_lease_usable(lease)) + .filter(|(_, lease)| { + max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max) + }) + .map(|(id, lease)| (*id, lease.params.proportional as u64)) + .min_by_key(|(_, fee)| *fee)?; + self.leases.get(&id).cloned().map(|lease| (lease, proportional_fee)) } pub(crate) fn prune(&mut self) { @@ -120,58 +157,78 @@ fn now_secs() -> u64 { #[cfg(test)] mod tests { use super::*; - - #[test] - fn leases_are_consumed_once() { - let id = PaymentLeaseId { - lsp_node_id: PublicKey::from_slice(&[2; 33]).unwrap(), - intercept_scid: 42, - }; - let mut state = LSPS2LeaseState::default(); - state.insert(PaymentLease { - id, + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + + fn lease( + node_key_byte: u8, intercept_scid: u64, min_fee_msat: u64, payment_size_msat: Option, + valid_until: u64, + ) -> PaymentLease { + PaymentLease { + id: PaymentLeaseId { + lsp_node_id: PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[node_key_byte; 32]).unwrap(), + ), + intercept_scid, + }, params: LSPS2OpeningFeeParams { - min_fee_msat: 1, + min_fee_msat, proportional: 1, valid_until: "2030-01-01T00:00:00Z".parse().unwrap(), min_lifetime: 1, max_client_to_self_delay: 1, min_payment_size_msat: 1, - max_payment_size_msat: 2, + max_payment_size_msat: 2_000, promise: String::new(), }, cltv_expiry_delta: 18, - payment_size_msat: Some(1_000), - valid_until: now_secs() + MIN_LEASE_REMAINING_SECS + 60, - }); - assert!(state.take_valid(&id).is_some()); - assert!(state.take_valid(&id).is_none()); + payment_size_msat, + valid_until, + } + } + + #[test] + fn leases_are_consumed_once() { + let lease = lease(2, 42, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); + let id = lease.id; + let mut state = LSPS2LeaseState::default(); + state.insert(lease); + assert!(state.valid(&id).is_some()); + assert!(state.remove(&id).is_some()); + assert!(state.valid(&id).is_none()); } #[test] fn prunes_leases_close_to_expiry() { - let id = PaymentLeaseId { - lsp_node_id: PublicKey::from_slice(&[2; 33]).unwrap(), - intercept_scid: 43, - }; + let lease = lease(2, 43, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS - 1); + let id = lease.id; let mut state = LSPS2LeaseState::default(); - state.insert(PaymentLease { - id, - params: LSPS2OpeningFeeParams { - min_fee_msat: 1, - proportional: 1, - valid_until: "2030-01-01T00:00:00Z".parse().unwrap(), - min_lifetime: 1, - max_client_to_self_delay: 1, - min_payment_size_msat: 1, - max_payment_size_msat: 2, - promise: String::new(), - }, - cltv_expiry_delta: 18, - payment_size_msat: Some(1_000), - valid_until: now_secs() + MIN_LEASE_REMAINING_SECS - 1, - }); + state.insert(lease); state.prune(); - assert!(state.take_valid(&id).is_none()); + assert!(state.valid(&id).is_none()); + } + + #[test] + fn selects_cheapest_matching_lease_across_lsps() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let expensive = lease(2, 44, 100, Some(1_000), valid_until); + let cheap = lease(3, 45, 50, Some(1_000), valid_until); + let mut state = LSPS2LeaseState::from_leases(vec![expensive.clone(), cheap.clone()]); + + let (selected, _) = state.fixed_amount(1_000, None).unwrap(); + assert_eq!(selected.id, cheap.id); + state.remove(&selected.id); + let (remaining, _) = state.fixed_amount(1_000, None).unwrap(); + assert_eq!(remaining.id, expensive.id); + } + + #[test] + fn variable_lease_honors_total_fee_limit() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let variable = lease(2, 46, 50, None, valid_until); + let state = LSPS2LeaseState::from_leases(vec![variable.clone()]); + + assert!(state.variable_amount(Some(49)).is_none()); + assert_eq!(state.variable_amount(Some(50)).unwrap().0.id, variable.id); } } From 97eef81ffc7af66f544e45c4feafdd4994bdd935 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 7 Aug 2026 11:22:00 +0200 Subject: [PATCH 08/41] f Preserve undiscovered LSP leases Select cached leases only after protocol discovery and only from the available LSPS2 nodes. This keeps requests made during discovery from removing leases belonging to an LSP that has not been discovered yet. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 63 ++++++++++++++--------------- src/liquidity/client/lsps2/state.rs | 27 ++++++++++--- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 71a479531b..1b574550b3 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -451,43 +451,42 @@ where async fn take_cached_fixed_lease( &self, amount_msat: u64, ) -> Result, Error> { - loop { - let Some((lease, fee_msat)) = self - .lease_state - .lock() - .expect("lock") - .fixed_amount(amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat) - else { - return Ok(None); - }; - let lease = self.consume_selected_lease(lease).await?; - if let Some(lsp) = - select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id)) - { - return Ok(Some((lease, fee_msat, lsp))); - } - } + let lsps2_nodes = self.get_lsps2_nodes().await?; + let available_lsps = lsps2_nodes.iter().map(|lsp| lsp.node_id).collect::>(); + let Some((lease, fee_msat)) = self.lease_state.lock().expect("lock").fixed_amount( + amount_msat, + self.config.lsps2_max_total_lsp_fee_limit_msat, + &available_lsps, + ) else { + return Ok(None); + }; + let lsp = lsps2_nodes + .into_iter() + .find(|lsp| lsp.node_id == lease.id.lsp_node_id) + .expect("lease was selected from available LSPs"); + let lease = self.consume_selected_lease(lease).await?; + Ok(Some((lease, fee_msat, lsp))) } async fn take_cached_variable_lease( &self, ) -> Result, Error> { - loop { - let Some((lease, proportional_fee)) = self - .lease_state - .lock() - .expect("lock") - .variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat) - else { - return Ok(None); - }; - let lease = self.consume_selected_lease(lease).await?; - if let Some(lsp) = - select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id)) - { - return Ok(Some((lease, proportional_fee, lsp))); - } - } + let lsps2_nodes = self.get_lsps2_nodes().await?; + let available_lsps = lsps2_nodes.iter().map(|lsp| lsp.node_id).collect::>(); + let Some((lease, proportional_fee)) = self + .lease_state + .lock() + .expect("lock") + .variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat, &available_lsps) + else { + return Ok(None); + }; + let lsp = lsps2_nodes + .into_iter() + .find(|lsp| lsp.node_id == lease.id.lsp_node_id) + .expect("lease was selected from available LSPs"); + let lease = self.consume_selected_lease(lease).await?; + Ok(Some((lease, proportional_fee, lsp))) } fn lsps2_create_jit_invoice( diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 2b6505f24a..f43f68e7ff 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -105,11 +105,12 @@ impl LSPS2LeaseState { } pub(crate) fn fixed_amount( - &self, amount_msat: u64, max_fee_msat: Option, + &self, amount_msat: u64, max_fee_msat: Option, available_lsps: &[PublicKey], ) -> Option<(PaymentLease, u64)> { let (id, fee_msat) = self .leases .iter() + .filter(|(id, _)| available_lsps.contains(&id.lsp_node_id)) .filter(|(_, lease)| lease.payment_size_msat == Some(amount_msat)) .filter(|(_, lease)| is_lease_usable(lease)) .filter_map(|(id, lease)| { @@ -126,11 +127,12 @@ impl LSPS2LeaseState { } pub(crate) fn variable_amount( - &self, max_total_fee_msat: Option, + &self, max_total_fee_msat: Option, available_lsps: &[PublicKey], ) -> Option<(PaymentLease, u64)> { let (id, proportional_fee) = self .leases .iter() + .filter(|(id, _)| available_lsps.contains(&id.lsp_node_id)) .filter(|(_, lease)| lease.payment_size_msat.is_none()) .filter(|(_, lease)| is_lease_usable(lease)) .filter(|(_, lease)| { @@ -215,20 +217,33 @@ mod tests { let cheap = lease(3, 45, 50, Some(1_000), valid_until); let mut state = LSPS2LeaseState::from_leases(vec![expensive.clone(), cheap.clone()]); - let (selected, _) = state.fixed_amount(1_000, None).unwrap(); + let available_lsps = [expensive.id.lsp_node_id, cheap.id.lsp_node_id]; + let (selected, _) = state.fixed_amount(1_000, None, &available_lsps).unwrap(); assert_eq!(selected.id, cheap.id); state.remove(&selected.id); - let (remaining, _) = state.fixed_amount(1_000, None).unwrap(); + let (remaining, _) = state.fixed_amount(1_000, None, &available_lsps).unwrap(); assert_eq!(remaining.id, expensive.id); } + #[test] + fn ignores_leases_from_unavailable_lsps() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let unavailable = lease(2, 46, 1, Some(1_000), valid_until); + let available = lease(3, 47, 2, Some(1_000), valid_until); + let state = LSPS2LeaseState::from_leases(vec![unavailable, available.clone()]); + + let (selected, _) = state.fixed_amount(1_000, None, &[available.id.lsp_node_id]).unwrap(); + assert_eq!(selected.id, available.id, "lease selection ignored the set of available LSPs"); + } + #[test] fn variable_lease_honors_total_fee_limit() { let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; let variable = lease(2, 46, 50, None, valid_until); let state = LSPS2LeaseState::from_leases(vec![variable.clone()]); - assert!(state.variable_amount(Some(49)).is_none()); - assert_eq!(state.variable_amount(Some(50)).unwrap().0.id, variable.id); + let available_lsps = [variable.id.lsp_node_id]; + assert!(state.variable_amount(Some(49), &available_lsps).is_none()); + assert_eq!(state.variable_amount(Some(50), &available_lsps).unwrap().0.id, variable.id); } } From 6989fcf6c83387ae4f7d1809da1a62eccbaae1b1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:36:48 +0200 Subject: [PATCH 09/41] bolt12: Move payment into module directory Make room for a dedicated offers message handler without mixing networking concerns into the public BOLT12 payment API. Co-Authored-By: HAL 9000 --- src/payment/{bolt12.rs => bolt12/mod.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/payment/{bolt12.rs => bolt12/mod.rs} (100%) diff --git a/src/payment/bolt12.rs b/src/payment/bolt12/mod.rs similarity index 100% rename from src/payment/bolt12.rs rename to src/payment/bolt12/mod.rs From 32f4acce2ced36b687088ada7d75a474e50965bf Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 14:50:26 +0200 Subject: [PATCH 10/41] lsps2: Persist lease cache targets Persist a bounded LRU of fixed and variable payment requirements so restarts can pre-negotiate useful leases without making offer recognition depend on per-offer state. Prune expired targets to bound durable storage. Co-Authored-By: HAL 9000 --- src/io/mod.rs | 5 + src/lib.rs | 5 +- src/liquidity/client/lsps2/mod.rs | 21 +- src/liquidity/client/lsps2/state.rs | 413 +++++++++++++++++++++++++++- src/liquidity/mod.rs | 41 ++- 5 files changed, 472 insertions(+), 13 deletions(-) diff --git a/src/io/mod.rs b/src/io/mod.rs index ab4f4108ff..470d4c391e 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -37,6 +37,11 @@ pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = "" pub(crate) const LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE: &str = "lsps2_leases"; pub(crate) const LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// LSPS2 lease cache targets are persisted under this key. +pub(crate) const LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE: &str = "lsps2_cache_targets"; +pub(crate) const LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +pub(crate) const LSPS2_CACHE_TARGET_PERSISTENCE_KEY: &str = "targets"; + /// 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 = ""; diff --git a/src/lib.rs b/src/lib.rs index a60f9fcd00..4385d32a10 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -555,7 +555,7 @@ impl Node { } }); - // Periodically prune LSPS2 leases before they expire so stale parameters don't accumulate. + // Periodically prune LSPS2 state so expired leases and cache targets don't accumulate. let lsps2_client = self.liquidity_source.lsps2_client(); let prune_logger = Arc::clone(&self.logger); let mut stop_pruning = self.stop_sender.subscribe(); @@ -569,6 +569,9 @@ impl Node { if let Err(error) = lsps2_client.prune_stale_leases().await { log_error!(prune_logger, "Failed pruning stale LSPS2 leases: {}", error); } + if let Err(error) = lsps2_client.prune_stale_cache_targets().await { + log_error!(prune_logger, "Failed pruning stale LSPS2 cache targets: {}", error); + } }, } } diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 1b574550b3..9de6df943c 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -36,7 +36,10 @@ use crate::payment::PaymentMetadata; use crate::types::{ChannelManager, KeysManager, LiquidityManager}; use crate::{Config, Error}; -use self::state::{LSPS2LeaseState, PaymentLease, PaymentLeaseId, PaymentLeaseStore}; +use self::state::{ + now_secs, LSPS2LeaseState, LeaseCacheTarget, LeaseCacheTargetId, LeaseCacheTargetStore, + PaymentLease, PaymentLeaseId, PaymentLeaseStore, +}; async fn consume_after_persisted_removal( value: T, persist_removal: RF, consume: CF, @@ -63,6 +66,7 @@ where Mutex>>, pub(crate) lease_store: Arc>, pub(crate) lease_state: Mutex, + pub(crate) cache_target_store: Arc>, pub(crate) channel_manager: Arc, pub(crate) keys_manager: Arc, pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, @@ -89,6 +93,21 @@ where Ok(()) } + pub(crate) async fn prune_stale_cache_targets(&self) -> Result<(), Error> { + self.cache_target_store.prune(now_secs()).await + } + + pub(crate) async fn register_cache_target( + &self, id: LeaseCacheTargetId, absolute_expiry: Option, + ) -> Result<(), Error> { + let target = LeaseCacheTarget::new(id, absolute_expiry); + self.cache_target_store.register(target).await + } + + pub(crate) fn cache_targets(&self) -> Vec { + self.cache_target_store.targets() + } + pub(crate) async fn lsps2_receive_to_jit_channel( self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: Option, diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index f43f68e7ff..e1e5b16dbb 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -1,18 +1,270 @@ use std::collections::HashMap; +use std::ops::Deref; +use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; -use lightning::impl_writeable_tlv_based; +use lightning::util::persist::KVStore; +use lightning::util::ser::{Readable, Writeable}; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; use lightning_liquidity::lsps2::utils::compute_opening_fee; use crate::data_store::{DataStore, StorableObject, StorableObjectId, StorableObjectUpdate}; use crate::hex_utils; +use crate::io::{ + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::logger::{log_error, LdkLogger}; +use crate::types::DynStore; +use crate::Error; pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; pub(crate) type PaymentLeaseStore = DataStore; +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum LeaseCacheTargetId { + Fixed { amount_msat: u64 }, + Variable, +} + +impl_writeable_tlv_based_enum!(LeaseCacheTargetId, + (0, Fixed) => { + (0, amount_msat, required), + }, + (2, Variable) => {}, +); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LeaseCacheTarget { + pub(crate) id: LeaseCacheTargetId, + pub(crate) absolute_expiry: Option, + last_accessed: u64, +} + +impl LeaseCacheTarget { + pub(crate) fn new(id: LeaseCacheTargetId, absolute_expiry: Option) -> Self { + Self { id, absolute_expiry, last_accessed: now_secs() } + } +} + +impl_writeable_tlv_based!(LeaseCacheTarget, { + (0, id, required), + (2, absolute_expiry, option), + (4, last_accessed, required), +}); + +#[derive(Clone, Debug, PartialEq, Eq)] +struct LeaseCacheTargets { + targets: Vec, +} + +impl_writeable_tlv_based!(LeaseCacheTargets, { + (0, targets, required_vec), +}); + +#[derive(Clone)] +pub(crate) struct LeaseCacheTargetState { + targets: HashMap, + capacity: usize, +} + +impl LeaseCacheTargetState { + pub(crate) fn from_targets( + targets: Vec, capacity: usize, now: u64, + ) -> (Self, Vec) { + let mut removed = Vec::new(); + let mut targets = targets + .into_iter() + .filter_map(|target| { + if is_cache_target_expired(&target, now) { + removed.push(target.id); + None + } else { + Some((target.id, target)) + } + }) + .collect::>(); + let capacity = capacity.max(1); + while targets.len() > capacity { + let id = least_recently_used(&targets).expect("targets are non-empty"); + targets.remove(&id); + removed.push(id); + } + (Self { targets, capacity }, removed) + } + + pub(crate) fn register(&mut self, mut target: LeaseCacheTarget) -> Option { + let last_accessed = self.next_access_timestamp(target.last_accessed); + if let Some(existing) = self.targets.get_mut(&target.id) { + existing.absolute_expiry = + merge_absolute_expiry(existing.absolute_expiry, target.absolute_expiry); + existing.last_accessed = last_accessed; + return None; + } + + let evicted = if self.targets.len() == self.capacity { + let id = least_recently_used(&self.targets).expect("targets are non-empty"); + self.targets.remove(&id); + Some(id) + } else { + None + }; + target.last_accessed = last_accessed; + self.targets.insert(target.id, target); + evicted + } + + pub(crate) fn targets(&self) -> Vec { + let mut targets = self.targets.values().cloned().collect::>(); + targets.sort_unstable_by_key(|target| target.id); + targets + } + + #[cfg(test)] + pub(crate) fn contains(&self, id: &LeaseCacheTargetId) -> bool { + self.targets.contains_key(id) + } + + pub(crate) fn prune(&mut self, now: u64) -> Vec { + let removed = self + .targets + .iter() + .filter(|(_, target)| is_cache_target_expired(target, now)) + .map(|(id, _)| *id) + .collect::>(); + for id in &removed { + self.targets.remove(id); + } + removed + } + + fn next_access_timestamp(&self, now: u64) -> u64 { + self.targets + .values() + .map(|target| target.last_accessed) + .max() + .map_or(now, |last_accessed| now.max(last_accessed.saturating_add(1))) + } +} + +pub(crate) struct LeaseCacheTargetStore +where + L::Target: LdkLogger, +{ + state: Mutex, + mutation_lock: tokio::sync::Mutex<()>, + kv_store: Arc, + logger: L, +} + +impl LeaseCacheTargetStore +where + L::Target: LdkLogger, +{ + pub(crate) fn new(state: LeaseCacheTargetState, kv_store: Arc, logger: L) -> Self { + Self { + state: Mutex::new(state), + mutation_lock: tokio::sync::Mutex::new(()), + kv_store, + logger, + } + } + + pub(crate) async fn register(&self, target: LeaseCacheTarget) -> Result<(), Error> { + let _guard = self.mutation_lock.lock().await; + let mut updated_state = self.state.lock().expect("lock").clone(); + updated_state.register(target); + self.persist_state(&updated_state).await?; + *self.state.lock().expect("lock") = updated_state; + Ok(()) + } + + pub(crate) async fn prune(&self, now: u64) -> Result<(), Error> { + let _guard = self.mutation_lock.lock().await; + let mut updated_state = self.state.lock().expect("lock").clone(); + if updated_state.prune(now).is_empty() { + return Ok(()); + } + self.persist_state(&updated_state).await?; + *self.state.lock().expect("lock") = updated_state; + Ok(()) + } + + pub(crate) async fn persist(&self) -> Result<(), Error> { + let _guard = self.mutation_lock.lock().await; + let state = self.state.lock().expect("lock").clone(); + self.persist_state(&state).await + } + + pub(crate) fn targets(&self) -> Vec { + self.state.lock().expect("lock").targets() + } + + async fn persist_state(&self, state: &LeaseCacheTargetState) -> Result<(), Error> { + let data = LeaseCacheTargets { targets: state.targets() }.encode(); + KVStore::write( + &*self.kv_store, + LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, + data, + ) + .await + .map_err(|error| { + log_error!(self.logger, "Writing LSPS2 lease cache targets failed due to: {}", error); + Error::PersistenceFailed + }) + } +} + +pub(crate) async fn read_lease_cache_targets( + kv_store: &DynStore, logger: L, +) -> Result, lightning::io::Error> +where + L::Target: LdkLogger, +{ + match KVStore::read( + kv_store, + LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, + ) + .await + { + Ok(data) => LeaseCacheTargets::read(&mut &data[..]).map(|targets| targets.targets).map_err( + |error| { + log_error!(logger, "Failed to deserialize LSPS2 lease cache targets: {}", error); + lightning::io::Error::new( + lightning::io::ErrorKind::InvalidData, + "Failed to deserialize LSPS2 lease cache targets", + ) + }, + ), + Err(error) if error.kind() == lightning::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(error), + } +} + +fn least_recently_used( + targets: &HashMap, +) -> Option { + targets.values().min_by_key(|target| (target.last_accessed, target.id)).map(|target| target.id) +} + +fn merge_absolute_expiry(current: Option, new: Option) -> Option { + match (current, new) { + (None, _) | (_, None) => None, + (Some(current), Some(new)) => Some(current.max(new)), + } +} + +fn is_cache_target_expired(target: &LeaseCacheTarget, now: u64) -> bool { + target.absolute_expiry.is_some_and(|expiry| expiry <= now) +} + #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub(crate) struct PaymentLeaseId { pub(crate) lsp_node_id: PublicKey, @@ -152,14 +404,65 @@ pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS } -fn now_secs() -> u64 { +pub(crate) fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_secs() } #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use lightning::io; + use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; + use lightning::util::ser::{Readable, Writeable}; + use lightning::util::test_utils::TestLogger; + + use crate::io::test_utils::InMemoryStore; + use crate::io::{ + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, + }; + use crate::types::{DynStore, DynStoreWrapper}; + + struct FailingStore; + + impl KVStore for FailingStore { + fn read( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, + ) -> impl std::future::Future, io::Error>> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "read failed")) } + } + + fn write( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, _buf: Vec, + ) -> impl std::future::Future> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "write failed")) } + } + + fn remove( + &self, _primary_namespace: &str, _secondary_namespace: &str, _key: &str, _lazy: bool, + ) -> impl std::future::Future> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "remove failed")) } + } + + fn list( + &self, _primary_namespace: &str, _secondary_namespace: &str, + ) -> impl std::future::Future, io::Error>> + 'static + Send { + async { Err(io::Error::new(io::ErrorKind::Other, "list failed")) } + } + } + + impl PaginatedKVStore for FailingStore { + fn list_paginated( + &self, _primary_namespace: &str, _secondary_namespace: &str, + _page_token: Option, + ) -> impl std::future::Future> + 'static + Send + { + async { Err(io::Error::new(io::ErrorKind::Other, "list_paginated failed")) } + } + } fn lease( node_key_byte: u8, intercept_scid: u64, min_fee_msat: u64, payment_size_msat: Option, @@ -246,4 +549,110 @@ mod tests { assert!(state.variable_amount(Some(49), &available_lsps).is_none()); assert_eq!(state.variable_amount(Some(50), &available_lsps).unwrap().0.id, variable.id); } + + fn cache_target( + id: LeaseCacheTargetId, absolute_expiry: Option, last_accessed: u64, + ) -> LeaseCacheTarget { + LeaseCacheTarget { id, absolute_expiry, last_accessed } + } + + #[test] + fn cache_targets_roundtrip() { + let target = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, Some(42), 7); + + let encoded = target.encode(); + let decoded = LeaseCacheTarget::read(&mut &encoded[..]).unwrap(); + assert_eq!(decoded, target); + } + + #[tokio::test] + async fn cache_targets_persist_as_single_blob() { + let kv_store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (state, removed) = LeaseCacheTargetState::from_targets(Vec::new(), 2, 0); + assert!(removed.is_empty()); + let store = + LeaseCacheTargetStore::new(state, Arc::clone(&kv_store), Arc::new(TestLogger::new())); + store + .register(cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, None, 1)) + .await + .unwrap(); + store.register(cache_target(LeaseCacheTargetId::Variable, None, 2)).await.unwrap(); + + let keys = KVStore::list( + &*kv_store, + LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, + ) + .await + .unwrap(); + assert_eq!(keys, vec![LSPS2_CACHE_TARGET_PERSISTENCE_KEY.to_string()]); + + let restored = + read_lease_cache_targets(&*kv_store, Arc::new(TestLogger::new())).await.unwrap(); + assert_eq!(restored, store.targets()); + } + + #[tokio::test] + async fn cache_target_store_keeps_memory_on_persist_failure() { + let initial = cache_target(LeaseCacheTargetId::Variable, None, 1); + let (state, removed) = LeaseCacheTargetState::from_targets(vec![initial.clone()], 2, 0); + assert!(removed.is_empty()); + let kv_store: Arc = Arc::new(DynStoreWrapper(FailingStore)); + let store = LeaseCacheTargetStore::new(state, kv_store, Arc::new(TestLogger::new())); + + let result = store + .register(cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, None, 2)) + .await; + + assert_eq!(result, Err(Error::PersistenceFailed)); + assert_eq!(store.targets(), vec![initial]); + } + + #[test] + fn cache_target_registry_evicts_least_recently_used() { + let first = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, None, 1); + let second = cache_target(LeaseCacheTargetId::Variable, None, 2); + let third = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 3_000 }, None, 3); + let first_id = first.id; + let second_id = second.id; + let third_id = third.id; + let (mut state, removed) = LeaseCacheTargetState::from_targets(vec![first, second], 2, 10); + assert!(removed.is_empty()); + + assert_eq!(state.register(cache_target(first_id, None, 11)), None); + assert_eq!(state.register(third), Some(second_id)); + assert!(state.contains(&first_id)); + assert!(!state.contains(&second_id)); + assert!(state.contains(&third_id)); + } + + #[test] + fn cache_target_registry_prunes_only_expired_targets() { + let expired = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, Some(9), 1); + let unexpired = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 2_000 }, Some(11), 2); + let long_lived = cache_target(LeaseCacheTargetId::Variable, None, 3); + let expired_id = expired.id; + let unexpired_id = unexpired.id; + let long_lived_id = long_lived.id; + + let (state, removed) = + LeaseCacheTargetState::from_targets(vec![expired, unexpired, long_lived], 3, 10); + + assert_eq!(removed, vec![expired_id]); + assert!(state.contains(&unexpired_id)); + assert!(state.contains(&long_lived_id)); + } + + #[test] + fn cache_target_registry_keeps_longest_expiry() { + let id = LeaseCacheTargetId::Fixed { amount_msat: 1_000 }; + let (mut state, removed) = + LeaseCacheTargetState::from_targets(vec![cache_target(id, Some(20), 1)], 2, 10); + assert!(removed.is_empty()); + + state.register(cache_target(id, Some(30), 2)); + assert_eq!(state.targets().first().unwrap().absolute_expiry, Some(30)); + state.register(cache_target(id, None, 3)); + assert_eq!(state.targets().first().unwrap().absolute_expiry, None); + } } diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 428ba31f5c..f91f56a1c7 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -36,7 +36,10 @@ use crate::io::{ LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::liquidity::client::lsps1::LSPS1Client; -use crate::liquidity::client::lsps2::state::{is_lease_usable, LSPS2LeaseState, PaymentLeaseStore}; +use crate::liquidity::client::lsps2::state::{ + is_lease_usable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, + LeaseCacheTargetStore, PaymentLeaseStore, +}; use crate::liquidity::client::lsps2::LSPS2Client; use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; @@ -46,6 +49,9 @@ use crate::{Config, Error}; const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; const LSPS_DISCOVERY_WAIT_TIMEOUT_SECS: u64 = 10; +// Keep the proactively refreshed target set bounded. Evicted requirements still fall back to +// on-demand lease negotiation, so this limits durable state without limiting offer validity. +const LSPS2_LEASE_CACHE_TARGET_SIZE: usize = 100; fn select_lsps_for_protocol( lsp_nodes: &Arc>>, protocol: u16, override_node_id: Option<&PublicKey>, @@ -252,14 +258,17 @@ where } pub(crate) async fn build(self) -> Result, BuildError> { - let leases = read_all_objects( - &*self.kv_store, - LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, - LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, - self.logger.clone(), - ) - .await - .map_err(|_| BuildError::ReadFailed)?; + let (leases, cache_targets) = tokio::join!( + read_all_objects( + &*self.kv_store, + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + self.logger.clone(), + ), + read_lease_cache_targets(&*self.kv_store, self.logger.clone()), + ); + let leases = leases.map_err(|_| BuildError::ReadFailed)?; + let cache_targets = cache_targets.map_err(|_| BuildError::ReadFailed)?; let lease_store = Arc::new(PaymentLeaseStore::new( leases.clone(), LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), @@ -272,6 +281,19 @@ where } let lease_state = LSPS2LeaseState::from_leases(leases.into_iter().filter(is_lease_usable).collect()); + let (cache_target_state, removed_target_ids) = LeaseCacheTargetState::from_targets( + cache_targets, + LSPS2_LEASE_CACHE_TARGET_SIZE, + now_secs(), + ); + let cache_target_store = Arc::new(LeaseCacheTargetStore::new( + cache_target_state, + Arc::clone(&self.kv_store), + self.logger.clone(), + )); + if !removed_target_ids.is_empty() { + cache_target_store.persist().await.map_err(|_| BuildError::WriteFailed)?; + } let liquidity_service_config = self.lsps2_service.as_ref().map(|s| { let lsps2_service_config = Some(s.ldk_service_config.clone()); let lsps5_service_config = None; @@ -338,6 +360,7 @@ where pending_buy_requests: Mutex::new(HashMap::new()), lease_store, lease_state: Mutex::new(lease_state), + cache_target_store, channel_manager: self.channel_manager.clone(), keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), From ad0b847aa8a0d2ca0a4bfcea95a7f2e4b5a1e4a6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 14:34:47 +0200 Subject: [PATCH 11/41] bolt12: Warm LSPS2 lease targets Warm bounded fixed and variable lease targets when ordinary offers may need just-in-time liquidity. Cache state remains advisory so offers stay valid and cold invoice requests can negotiate on demand. Co-Authored-By: HAL 9000 --- src/lib.rs | 2 ++ src/payment/bolt12/mod.rs | 42 +++++++++++++++++++++++++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4385d32a10..7a1c7e72be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1084,6 +1084,7 @@ impl Node { Bolt12Payment::new( Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), + Arc::clone(&self.liquidity_source), Arc::clone(&self.keys_manager), Arc::clone(&self.payment_store), Arc::clone(&self.config), @@ -1101,6 +1102,7 @@ impl Node { Arc::new(Bolt12Payment::new( Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), + Arc::clone(&self.liquidity_source), Arc::clone(&self.keys_manager), Arc::clone(&self.payment_store), Arc::clone(&self.config), diff --git a/src/payment/bolt12/mod.rs b/src/payment/bolt12/mod.rs index d79aca6c24..9dce173cf3 100644 --- a/src/payment/bolt12/mod.rs +++ b/src/payment/bolt12/mod.rs @@ -27,6 +27,8 @@ use lightning_types::string::UntrustedString; use crate::config::{AsyncPaymentsRole, Config, LDK_PAYMENT_RETRY_TIMEOUT}; use crate::error::Error; use crate::ffi::{maybe_deref, maybe_wrap}; +use crate::liquidity::client::lsps2::state::LeaseCacheTargetId; +use crate::liquidity::LiquiditySource; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::store::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; use crate::runtime::Runtime; @@ -62,6 +64,7 @@ type HumanReadableName = Arc; pub struct Bolt12Payment { runtime: Arc, channel_manager: Arc, + liquidity_source: Arc>>, keys_manager: Arc, payment_store: Arc, config: Arc, @@ -73,13 +76,14 @@ pub struct Bolt12Payment { impl Bolt12Payment { pub(crate) fn new( runtime: Arc, channel_manager: Arc, - keys_manager: Arc, payment_store: Arc, config: Arc, - is_running: Arc>, logger: Arc, - async_payments_role: Option, + liquidity_source: Arc>>, keys_manager: Arc, + payment_store: Arc, config: Arc, is_running: Arc>, + logger: Arc, async_payments_role: Option, ) -> Self { Self { runtime, channel_manager, + liquidity_source, keys_manager, payment_store, config, @@ -237,6 +241,25 @@ impl Bolt12Payment { Ok(finalized_offer) } + fn register_cache_target(&self, offer: &LdkOffer, id: LeaseCacheTargetId) { + let absolute_expiry = offer.absolute_expiry().map(|expiry| expiry.as_secs()); + if let Err(error) = self.runtime.block_on( + self.liquidity_source.lsps2_client().register_cache_target(id, absolute_expiry), + ) { + // Cache targets only reduce invoice-request latency. Failing to persist one must not make a + // long-lived offer unusable because the request path can negotiate a lease on demand. + log_error!(self.logger, "Failed to cache LSPS2 lease target: {}", error); + } + } + + fn has_sufficient_inbound_liquidity(&self, amount_msat: u64) -> bool { + self.channel_manager + .list_usable_channels() + .into_iter() + .fold(0u64, |total, channel| total.saturating_add(channel.inbound_capacity_msat)) + >= amount_msat + } + fn blinded_paths_for_async_recipient_internal( &self, recipient_id: Vec, ) -> Result, Error> { @@ -391,15 +414,26 @@ impl Bolt12Payment { /// Returns a payable offer that can be used to request and receive a payment of the amount /// given. + /// + /// If LSPS2 is configured and existing inbound liquidity is insufficient when an invoice is + /// requested, the payment may be received through a just-in-time channel. Otherwise, it may be + /// received entirely over pre-existing channels. pub fn receive( &self, amount_msat: u64, description: &str, expiry_secs: Option, quantity: Option, ) -> Result { let offer = self.receive_inner(amount_msat, description, expiry_secs, quantity)?; + if !self.has_sufficient_inbound_liquidity(amount_msat) { + self.register_cache_target(&offer, LeaseCacheTargetId::Fixed { amount_msat }); + } Ok(maybe_wrap(offer)) } /// Returns a payable offer that can be used to request and receive a payment for which the /// amount is to be determined by the user, also known as a "zero-amount" offer. + /// + /// If LSPS2 is configured and existing inbound liquidity is insufficient when an invoice is + /// requested, the payment may be received through a just-in-time channel. Otherwise, it may be + /// received entirely over pre-existing channels. pub fn receive_variable_amount( &self, description: &str, expiry_secs: Option, ) -> Result { @@ -419,7 +453,7 @@ impl Bolt12Payment { log_error!(self.logger, "Failed to create offer: {:?}", e); Error::OfferCreationFailed })?; - + self.register_cache_target(&offer, LeaseCacheTargetId::Variable); Ok(maybe_wrap(offer)) } From 82a80cf791ac130c8380ae0c3fa20ea6987d538c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 14:54:56 +0200 Subject: [PATCH 12/41] payment: Move payment metadata Move shared payment metadata out of the BOLT11 module. This lets other protocols reuse it without a BOLT11 dependency. Co-Authored-By: HAL 9000 --- src/payment/bolt11.rs | 45 +----------------------------------- src/payment/metadata.rs | 51 +++++++++++++++++++++++++++++++++++++++++ src/payment/mod.rs | 3 ++- 3 files changed, 54 insertions(+), 45 deletions(-) create mode 100644 src/payment/metadata.rs diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 4cd1dadf71..651da77b2a 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -13,7 +13,6 @@ use std::sync::{Arc, RwLock}; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; -use lightning::impl_writeable_tlv_based; use lightning::ln::channelmanager::{ Bolt11InvoiceParameters, OptionalBolt11PaymentParams, PaymentId, }; @@ -32,8 +31,7 @@ use crate::ffi::{maybe_deref, maybe_try_convert_enum, maybe_wrap}; use crate::liquidity::LiquiditySource; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::store::{ - LSPS2Parameters, PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, - PaymentStatus, + PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; use crate::peer_store::{PeerInfo, PeerStore}; use crate::runtime::Runtime; @@ -49,16 +47,6 @@ type Bolt11InvoiceDescription = LdkBolt11InvoiceDescription; #[cfg(feature = "uniffi")] type Bolt11InvoiceDescription = crate::ffi::Bolt11InvoiceDescription; -/// Metadata carried in BOLT11 invoice `payment_metadata`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct PaymentMetadata { - pub(crate) lsps2_parameters: Option, -} - -impl_writeable_tlv_based!(PaymentMetadata, { - (0, lsps2_parameters, option), -}); - /// A payment handler allowing to create and pay [BOLT 11] invoices. /// /// Should be retrieved by calling [`Node::bolt11_payment`]. @@ -230,37 +218,6 @@ impl Bolt11Payment { } } -#[cfg(test)] -mod tests { - use lightning::util::ser::{Readable, Writeable}; - - use super::*; - - #[test] - fn empty_metadata_roundtrips() { - let metadata = PaymentMetadata { lsps2_parameters: None }; - - let encoded = metadata.encode(); - let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); - - assert_eq!(metadata, decoded); - } - - #[test] - fn lsps2_parameters_roundtrip() { - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: Some(42_000), - max_proportional_opening_fee_ppm_msat: Some(17_000), - }; - let metadata = PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }; - - let encoded = metadata.encode(); - let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); - - assert_eq!(metadata, decoded); - } -} - impl Bolt11Payment { fn send_internal( &self, invoice: &LdkBolt11Invoice, amount_msat: Option, diff --git a/src/payment/metadata.rs b/src/payment/metadata.rs new file mode 100644 index 0000000000..8782767710 --- /dev/null +++ b/src/payment/metadata.rs @@ -0,0 +1,51 @@ +// 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 lightning::impl_writeable_tlv_based; + +use crate::payment::store::LSPS2Parameters; + +/// Metadata carried in BOLT11 invoice `payment_metadata`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PaymentMetadata { + pub(crate) lsps2_parameters: Option, +} + +impl_writeable_tlv_based!(PaymentMetadata, { + (0, lsps2_parameters, option), +}); + +#[cfg(test)] +mod tests { + use lightning::util::ser::{Readable, Writeable}; + + use super::*; + + #[test] + fn empty_metadata_roundtrips() { + let metadata = PaymentMetadata { lsps2_parameters: None }; + + let encoded = metadata.encode(); + let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); + + assert_eq!(metadata, decoded); + } + + #[test] + fn lsps2_parameters_roundtrip() { + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: Some(42_000), + max_proportional_opening_fee_ppm_msat: Some(17_000), + }; + let metadata = PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }; + + let encoded = metadata.encode(); + let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); + + assert_eq!(metadata, decoded); + } +} diff --git a/src/payment/mod.rs b/src/payment/mod.rs index fd75322ceb..4685a4d520 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod asynchronous; mod bolt11; mod bolt12; +mod metadata; mod onchain; pub(crate) mod pending_payment_store; mod spontaneous; @@ -17,8 +18,8 @@ pub(crate) mod store; mod unified; pub use bolt11::Bolt11Payment; -pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; +pub(crate) use metadata::PaymentMetadata; pub use onchain::OnchainPayment; pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails}; pub use spontaneous::SpontaneousPayment; From 22a10cd7617c4576a7094df92c663f7670e1d6dc Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 10:24:44 +0200 Subject: [PATCH 13/41] lsps2: Add JIT payment router Keep LSPS2 path construction in LDK Node and fall back to exact- amount JIT paths only when ordinary blinded paths are unavailable. This avoids unnecessary channel opens and unsafe MPP splits. Decode node-local single-use lease metadata directly in the router so the internal wrapper needs no pluggable metadata abstraction. Co-Authored-By: HAL 9000 --- src/event.rs | 5 +- src/liquidity/client/lsps2/mod.rs | 8 +- src/liquidity/client/lsps2/router.rs | 392 +++++++++++++++++++++++++++ src/payment/metadata.rs | 35 ++- 4 files changed, 434 insertions(+), 6 deletions(-) create mode 100644 src/liquidity/client/lsps2/router.rs diff --git a/src/event.rs b/src/event.rs index 36c1d05897..a8b30ed32b 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2042,6 +2042,7 @@ mod tests { max_total_opening_fee_msat: Some(42_000), max_proportional_opening_fee_ppm_msat: None, }), + lsps2_lease_parameters: None, }; assert_eq!( @@ -2055,12 +2056,14 @@ mod tests { #[test] fn lsps2_payment_metadata_missing_or_malformed_limit_is_rejected() { - let empty_metadata = PaymentMetadata { lsps2_parameters: None }.encode(); + let empty_metadata = + PaymentMetadata { lsps2_parameters: None, lsps2_lease_parameters: None }.encode(); let metadata_without_fee_limit = PaymentMetadata { lsps2_parameters: Some(LSPS2Parameters { max_total_opening_fee_msat: None, max_proportional_opening_fee_ppm_msat: None, }), + lsps2_lease_parameters: None, } .encode(); diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 9de6df943c..df0470764f 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -519,8 +519,11 @@ where // 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 encoded_payment_metadata = PaymentMetadata { + lsps2_parameters: Some(lsps2_parameters), + lsps2_lease_parameters: None, + } + .encode(); let (payment_hash, payment_secret, payment_metadata) = match payment_hash { Some(payment_hash) => { let (payment_secret, payment_metadata) = self @@ -755,4 +758,5 @@ mod tests { } } +pub(crate) mod router; pub(crate) mod state; diff --git a/src/liquidity/client/lsps2/router.rs b/src/liquidity/client/lsps2/router.rs new file mode 100644 index 0000000000..92d0aa816b --- /dev/null +++ b/src/liquidity/client/lsps2/router.rs @@ -0,0 +1,392 @@ +// 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 +// . + +//! Router helpers for injecting LSPS2 blinded payment paths. + +use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; + +use lightning::blinded_path::payment::{ + BlindedPaymentPath, ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, + PaymentRelay, ReceiveTlvs, +}; +use lightning::impl_writeable_tlv_based; +use lightning::ln::channel_state::ChannelDetails; +use lightning::ln::channelmanager::{PaymentId, MIN_FINAL_CLTV_EXPIRY_DELTA}; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::{EntropySource, ReceiveAuthKey}; +use lightning::types::features::BlindedHopFeatures; +use lightning::types::payment::PaymentHash; + +use crate::payment::PaymentMetadata; + +/// Parameters needed to construct an LSPS2 blinded payment path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LSPS2LeaseParameters { + /// The LSP serving as the blinded path introduction node. + pub lsp_node_id: PublicKey, + /// The single-use intercept short channel id negotiated with the LSP. + pub intercept_scid: u64, + /// The CLTV delta required by the LSP. + pub cltv_expiry_delta: u16, + /// The fixed amount negotiated for this intercept SCID, or `None` for a variable amount. + pub payment_size_msat: Option, + /// Unix timestamp at which these parameters expire. + pub valid_until: u64, +} + +impl_writeable_tlv_based!(LSPS2LeaseParameters, { + (0, lsp_node_id, required), + (2, intercept_scid, required), + (4, cltv_expiry_delta, required), + (6, payment_size_msat, option), + (8, valid_until, required), +}); + +/// A router wrapper that uses ordinary payment paths when available and LSPS2 JIT paths otherwise. +pub struct LSPS2Router { + inner_router: R, + entropy_source: ES, +} + +impl LSPS2Router { + /// Constructs an LSPS2-aware wrapper around `inner_router`. + pub fn new(inner_router: R, entropy_source: ES) -> Self { + Self { inner_router, entropy_source } + } + + fn payment_parameters(&self, payment_context: &PaymentContext) -> Vec { + match payment_context { + PaymentContext::Bolt12Offer(_) | PaymentContext::AsyncBolt12Offer(_) => {}, + _ => return Vec::new(), + } + payment_context + .payment_metadata() + .and_then(PaymentMetadata::decode_from_bolt12_payment_metadata) + .and_then(|metadata| metadata.lsps2_lease_parameters) + .into_iter() + .collect() + } +} + +impl Router for LSPS2Router { + fn find_route( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + ) -> Result { + self.inner_router.find_route(payer, route_params, first_hops, inflight_htlcs) + } + + fn find_route_with_id( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + payment_hash: PaymentHash, payment_id: PaymentId, + ) -> Result { + self.inner_router.find_route_with_id( + payer, + route_params, + first_hops, + inflight_htlcs, + payment_hash, + payment_id, + ) + } + + fn create_blinded_payment_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, + secp_ctx: &Secp256k1, + ) -> Result, ()> { + let inner_paths = self.inner_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs.clone(), + amount_msats, + secp_ctx, + ); + // The inner router was given the resolved payment amount and only returns ordinary paths when + // the existing inbound liquidity can receive all of it. Always prefer those paths. Besides + // avoiding an unnecessary channel open, this prevents an MPP payer from splitting one payment + // across regular and JIT paths. The LSP only opens its channel after receiving the complete + // negotiated amount on the intercept SCID, so a partial JIT shard could otherwise remain stuck + // indefinitely. + if matches!(&inner_paths, Ok(paths) if !paths.is_empty()) { + return inner_paths; + } + + let parameters = self.payment_parameters(&tlvs.payment_context); + if parameters.is_empty() { + return inner_paths; + } + let Some(amount_msats) = amount_msats else { + // Invoice construction supplies the resolved amount even for a variable-amount offer. Without + // it, we cannot constrain the JIT path to the full channel-open trigger amount. + return inner_paths; + }; + + let mut paths = Vec::new(); + for params in parameters { + // A fixed lease is valid only for the exact amount negotiated with the LSP. A mismatch means + // these parameters were selected for a different response and must never be exposed. + if params.payment_size_msat.is_some_and(|fixed_amount| fixed_amount != amount_msats) { + continue; + } + // Both fixed and variable invoices require the entire resolved payment on any selected JIT + // path. Fixed invoices may advertise MPP across multiple regular paths, while variable + // invoices disable MPP at response construction. Setting both bounds here additionally makes + // every JIT candidate indivisible and lets us include several candidates for robustness. + let htlc_amount_msat = amount_msats; + let payment_constraints = PaymentConstraints { + max_cltv_expiry: tlvs + .payment_constraints + .max_cltv_expiry + .saturating_add(params.cltv_expiry_delta as u32), + htlc_minimum_msat: htlc_amount_msat, + }; + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: params.intercept_scid, + payment_relay: PaymentRelay { + cltv_expiry_delta: params.cltv_expiry_delta, + fee_proportional_millionths: 0, + fee_base_msat: 0, + }, + payment_constraints, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: params.lsp_node_id, + htlc_maximum_msat: htlc_amount_msat, + }; + if let Ok(path) = BlindedPaymentPath::new( + &[forward_node], + recipient, + local_node_receive_key, + tlvs.clone(), + htlc_amount_msat, + MIN_FINAL_CLTV_EXPIRY_DELTA, + &self.entropy_source, + secp_ctx, + ) { + paths.push(path); + } + } + if paths.is_empty() { + // A capacity race may make ordinary paths available after a lease was negotiated. That case + // returned above and intentionally discards the single-use lease. Reaching here means neither + // ordinary nor valid JIT paths can receive the payment. + Err(()) + } else { + Ok(paths) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use bitcoin::secp256k1::SecretKey; + use core::sync::atomic::{AtomicUsize, Ordering}; + use lightning::blinded_path::payment::{Bolt12OfferContext, PaymentConstraints}; + use lightning::offers::invoice_request::InvoiceRequestFields; + use lightning::offers::offer::OfferId; + use lightning::types::payment::PaymentSecret; + use std::collections::BTreeMap; + + #[derive(Clone)] + struct TestEntropy; + + impl EntropySource for TestEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } + + struct MockRouter { + calls: AtomicUsize, + return_regular_path: bool, + } + + impl Router for MockRouter { + fn find_route( + &self, _payer: &PublicKey, _route_params: &RouteParameters, + _first_hops: Option<&[&ChannelDetails]>, _inflight_htlcs: InFlightHtlcs, + ) -> Result { + Err("mock router") + } + + fn create_blinded_payment_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + _first_hops: Vec, tlvs: ReceiveTlvs, _amount_msats: Option, + secp_ctx: &Secp256k1, + ) -> Result, ()> { + self.calls.fetch_add(1, Ordering::AcqRel); + if self.return_regular_path { + BlindedPaymentPath::one_hop( + recipient, + local_node_receive_key, + tlvs, + MIN_FINAL_CLTV_EXPIRY_DELTA, + TestEntropy, + secp_ctx, + ) + .map(|path| vec![path]) + } else { + Err(()) + } + } + } + + fn pubkey(byte: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[byte; 32]).unwrap()) + } + + fn payment_tlvs(metadata: BTreeMap>) -> ReceiveTlvs { + ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: OfferId([8; 32]), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: pubkey(9), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + payment_metadata: Some(metadata), + }), + } + } + + fn payment_metadata(parameters: LSPS2LeaseParameters) -> BTreeMap> { + PaymentMetadata { lsps2_parameters: None, lsps2_lease_parameters: Some(parameters) } + .encode_as_bolt12_payment_metadata() + } + + #[test] + fn constrains_fixed_jit_path_to_negotiated_amount() { + let lsp_node_id = pubkey(11); + let parameters = LSPS2LeaseParameters { + lsp_node_id, + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: false }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + let paths = router + .create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(router.inner_router.calls.load(Ordering::Acquire), 1); + assert_eq!(paths.len(), 1); + assert_eq!( + paths[0].introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id) + ); + assert_eq!(paths[0].payinfo.htlc_minimum_msat, 3_000); + assert_eq!(paths[0].payinfo.htlc_maximum_msat, 3_000); + } + + #[test] + fn rejects_fixed_jit_path_for_another_amount() { + let parameters = LSPS2LeaseParameters { + lsp_node_id: pubkey(11), + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: false }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + assert!(router + .create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(5_000), + &Secp256k1::new(), + ) + .is_err()); + } + + #[test] + fn constrains_variable_jit_path_to_invoice_amount() { + let parameters = LSPS2LeaseParameters { + lsp_node_id: pubkey(11), + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: None, + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: false }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + let paths = router + .create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(5_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(paths[0].payinfo.htlc_minimum_msat, 5_000); + assert_eq!(paths[0].payinfo.htlc_maximum_msat, 5_000); + } + + #[test] + fn omits_jit_path_when_regular_paths_are_available() { + let lsp_node_id = pubkey(11); + let parameters = LSPS2LeaseParameters { + lsp_node_id, + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: true }; + let router = LSPS2Router::new(inner_router, TestEntropy); + let recipient = pubkey(10); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(router.inner_router.calls.load(Ordering::Acquire), 1); + assert_eq!(paths.len(), 1); + assert_ne!( + paths[0].introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id) + ); + } +} diff --git a/src/payment/metadata.rs b/src/payment/metadata.rs index 8782767710..1d8ec15e59 100644 --- a/src/payment/metadata.rs +++ b/src/payment/metadata.rs @@ -5,18 +5,44 @@ // 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::BTreeMap; + use lightning::impl_writeable_tlv_based; +use lightning::util::ser::{Readable, Writeable}; +use crate::liquidity::client::lsps2::router::LSPS2LeaseParameters; use crate::payment::store::LSPS2Parameters; -/// Metadata carried in BOLT11 invoice `payment_metadata`. +pub(crate) const LDK_NODE_BOLT12_PAYMENT_METADATA_KEY: u64 = 0; + +/// Metadata carried in BOLT11 invoice metadata or BOLT12 payment-context metadata. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PaymentMetadata { + /// Fee limits retained until payment claim to validate the LSP's withholding. pub(crate) lsps2_parameters: Option, + /// Single-use routing parameters consumed while constructing blinded payment paths. + pub(crate) lsps2_lease_parameters: Option, +} + +impl PaymentMetadata { + pub(crate) fn encode_as_bolt12_payment_metadata(&self) -> BTreeMap> { + let mut metadata = BTreeMap::new(); + metadata.insert(LDK_NODE_BOLT12_PAYMENT_METADATA_KEY, self.encode()); + metadata + } + + pub(crate) fn decode_from_bolt12_payment_metadata( + payment_metadata: &BTreeMap>, + ) -> Option { + payment_metadata + .get(&LDK_NODE_BOLT12_PAYMENT_METADATA_KEY) + .and_then(|encoded| Self::read(&mut &encoded[..]).ok()) + } } impl_writeable_tlv_based!(PaymentMetadata, { (0, lsps2_parameters, option), + (2, lsps2_lease_parameters, option), }); #[cfg(test)] @@ -27,7 +53,7 @@ mod tests { #[test] fn empty_metadata_roundtrips() { - let metadata = PaymentMetadata { lsps2_parameters: None }; + let metadata = PaymentMetadata { lsps2_parameters: None, lsps2_lease_parameters: None }; let encoded = metadata.encode(); let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); @@ -41,7 +67,10 @@ mod tests { max_total_opening_fee_msat: Some(42_000), max_proportional_opening_fee_ppm_msat: Some(17_000), }; - let metadata = PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }; + let metadata = PaymentMetadata { + lsps2_parameters: Some(lsps2_parameters), + lsps2_lease_parameters: None, + }; let encoded = metadata.encode(); let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); From 0a1a4e55dcf812f7ef3958da56768ad2d2c037f7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 29 Jul 2026 13:54:12 +0200 Subject: [PATCH 14/41] f Apply LSPS2 final CLTV buffer BOLT12 JIT paths need the same two-block final-hop buffer as BOLT11 JIT invoices to satisfy LSPS2 forwarding requirements. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/router.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/liquidity/client/lsps2/router.rs b/src/liquidity/client/lsps2/router.rs index 92d0aa816b..836d37a7a8 100644 --- a/src/liquidity/client/lsps2/router.rs +++ b/src/liquidity/client/lsps2/router.rs @@ -168,7 +168,8 @@ impl Router for LSPS2Router { local_node_receive_key, tlvs.clone(), htlc_amount_msat, - MIN_FINAL_CLTV_EXPIRY_DELTA, + // LSPS2 requires two blocks more than the usual final CLTV delta. + MIN_FINAL_CLTV_EXPIRY_DELTA + 2, &self.entropy_source, secp_ctx, ) { @@ -301,6 +302,7 @@ mod tests { ); assert_eq!(paths[0].payinfo.htlc_minimum_msat, 3_000); assert_eq!(paths[0].payinfo.htlc_maximum_msat, 3_000); + assert_eq!(paths[0].payinfo.cltv_expiry_delta, 48 + MIN_FINAL_CLTV_EXPIRY_DELTA + 2); } #[test] From f0db6a21e336c2fa0a566708d688725733e27531 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 5 Aug 2026 13:54:15 +0200 Subject: [PATCH 15/41] f Reject insufficient direct BOLT12 paths Treat announced-recipient fallbacks as usable only when locally known channels can receive the resolved amount. This allows LSPS2 negotiation when the fallback would otherwise hide missing inbound liquidity. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/router.rs | 161 +++++++++++++++++++++++---- 1 file changed, 142 insertions(+), 19 deletions(-) diff --git a/src/liquidity/client/lsps2/router.rs b/src/liquidity/client/lsps2/router.rs index 836d37a7a8..7318ef4394 100644 --- a/src/liquidity/client/lsps2/router.rs +++ b/src/liquidity/client/lsps2/router.rs @@ -13,6 +13,7 @@ use lightning::blinded_path::payment::{ BlindedPaymentPath, ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, }; +use lightning::blinded_path::IntroductionNode; use lightning::impl_writeable_tlv_based; use lightning::ln::channel_state::ChannelDetails; use lightning::ln::channelmanager::{PaymentId, MIN_FINAL_CLTV_EXPIRY_DELTA}; @@ -100,6 +101,23 @@ impl Router for LSPS2Router { first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, secp_ctx: &Secp256k1, ) -> Result, ()> { + let parameters = self.payment_parameters(&tlvs.payment_context); + let allow_mpp = parameters.iter().all(|params| params.payment_size_msat.is_some()); + let direct_path_has_sufficient_liquidity = amount_msats.map_or(true, |amount_msats| { + if allow_mpp { + first_hops + .iter() + .map(|channel| channel.inbound_capacity_msat) + .fold(0u64, u64::saturating_add) + >= amount_msats + } else { + first_hops.iter().any(|channel| { + channel.inbound_capacity_msat >= amount_msats + && channel.inbound_htlc_minimum_msat.unwrap_or(0) <= amount_msats + && channel.inbound_htlc_maximum_msat.unwrap_or(u64::MAX) >= amount_msats + }) + } + }); let inner_paths = self.inner_router.create_blinded_payment_paths( recipient, local_node_receive_key, @@ -108,19 +126,31 @@ impl Router for LSPS2Router { amount_msats, secp_ctx, ); - // The inner router was given the resolved payment amount and only returns ordinary paths when - // the existing inbound liquidity can receive all of it. Always prefer those paths. Besides - // avoiding an unnecessary channel open, this prevents an MPP payer from splitting one payment - // across regular and JIT paths. The LSP only opens its channel after receiving the complete - // negotiated amount on the intercept SCID, so a partial JIT shard could otherwise remain stuck - // indefinitely. - if matches!(&inner_paths, Ok(paths) if !paths.is_empty()) { + let is_direct_recipient_path = matches!( + &inner_paths, + Ok(paths) if paths.len() == 1 && matches!( + paths[0].introduction_node(), + IntroductionNode::NodeId(node_id) if *node_id == recipient + ) + ); + // The default router may fall back to a direct path for an announced recipient even when no + // channel-backed paths have enough inbound liquidity. Only accept that fallback when the + // locally known channels can receive the resolved amount: across channels when MPP is allowed, + // or on one channel, including its HTLC bounds, when MPP is disabled. Otherwise it would hide + // the need for a JIT path and leave an invoice that cannot be paid. + // + // Always prefer usable ordinary paths. Besides avoiding an unnecessary channel open, this + // prevents an MPP payer from splitting one payment across regular and JIT paths. The LSP only + // opens its channel after receiving the complete negotiated amount on the intercept SCID, so a + // partial JIT shard could otherwise remain stuck indefinitely. + if matches!(&inner_paths, Ok(paths) if !paths.is_empty()) + && (!is_direct_recipient_path || direct_path_has_sufficient_liquidity) + { return inner_paths; } - let parameters = self.payment_parameters(&tlvs.payment_context); if parameters.is_empty() { - return inner_paths; + return if is_direct_recipient_path { Err(()) } else { inner_paths }; } let Some(amount_msats) = amount_msats else { // Invoice construction supplies the resolved amount even for a variable-amount offer. Without @@ -210,7 +240,14 @@ mod tests { struct MockRouter { calls: AtomicUsize, - return_regular_path: bool, + path_kind: MockPathKind, + } + + #[derive(Clone, Copy)] + enum MockPathKind { + None, + ChannelBacked, + DirectRecipient, } impl Router for MockRouter { @@ -227,8 +264,9 @@ mod tests { secp_ctx: &Secp256k1, ) -> Result, ()> { self.calls.fetch_add(1, Ordering::AcqRel); - if self.return_regular_path { - BlindedPaymentPath::one_hop( + match self.path_kind { + MockPathKind::None => Err(()), + MockPathKind::DirectRecipient => BlindedPaymentPath::one_hop( recipient, local_node_receive_key, tlvs, @@ -236,9 +274,38 @@ mod tests { TestEntropy, secp_ctx, ) - .map(|path| vec![path]) - } else { - Err(()) + .map(|path| vec![path]), + MockPathKind::ChannelBacked => { + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: 43, + payment_relay: PaymentRelay { + cltv_expiry_delta: 18, + fee_proportional_millionths: 0, + fee_base_msat: 0, + }, + payment_constraints: PaymentConstraints { + max_cltv_expiry: 118, + htlc_minimum_msat: 0, + }, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: pubkey(12), + htlc_maximum_msat: u64::MAX, + }; + BlindedPaymentPath::new( + &[forward_node], + recipient, + local_node_receive_key, + tlvs, + u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, + TestEntropy, + secp_ctx, + ) + .map(|path| vec![path]) + }, } } } @@ -280,7 +347,7 @@ mod tests { valid_until: u64::MAX, }; let metadata = payment_metadata(parameters); - let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: false }; + let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::None }; let router = LSPS2Router::new(inner_router, TestEntropy); let paths = router @@ -315,7 +382,7 @@ mod tests { valid_until: u64::MAX, }; let metadata = payment_metadata(parameters); - let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: false }; + let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::None }; let router = LSPS2Router::new(inner_router, TestEntropy); assert!(router @@ -340,7 +407,7 @@ mod tests { valid_until: u64::MAX, }; let metadata = payment_metadata(parameters); - let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: false }; + let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::None }; let router = LSPS2Router::new(inner_router, TestEntropy); let paths = router @@ -369,7 +436,8 @@ mod tests { valid_until: u64::MAX, }; let metadata = payment_metadata(parameters); - let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: true }; + let inner_router = + MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::ChannelBacked }; let router = LSPS2Router::new(inner_router, TestEntropy); let recipient = pubkey(10); @@ -391,4 +459,59 @@ mod tests { &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id) ); } + + #[test] + fn rejects_insufficient_direct_recipient_fallback() { + let recipient = pubkey(10); + let inner_router = + MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + assert!( + router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(BTreeMap::new()), + Some(3_000), + &Secp256k1::new(), + ) + .is_err(), + "announced-recipient fallback hid missing inbound liquidity" + ); + } + + #[test] + fn replaces_insufficient_direct_fallback_with_jit_path() { + let recipient = pubkey(10); + let lsp_node_id = pubkey(11); + let parameters = LSPS2LeaseParameters { + lsp_node_id, + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let inner_router = + MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(payment_metadata(parameters)), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!( + paths[0].introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id), + "insufficient direct fallback hid the JIT path" + ); + } } From 3142ce8d4d7005618df17597aeecafa08b0a6e66 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:42:26 +0200 Subject: [PATCH 16/41] bolt12: Wrap offers message handling Install a node-local handler while preserving ChannelManager behavior. This creates an isolated integration point for asynchronous JIT replies. Co-Authored-By: HAL 9000 --- src/builder.rs | 7 +++++-- src/payment/bolt12/mod.rs | 4 ++++ src/payment/bolt12/offers.rs | 39 ++++++++++++++++++++++++++++++++++++ src/payment/mod.rs | 1 + src/types.rs | 4 ++-- 5 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 src/payment/bolt12/offers.rs diff --git a/src/builder.rs b/src/builder.rs index f117800996..506903c085 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -74,6 +74,7 @@ use crate::lnurl_auth::LnurlAuth; use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; +use crate::payment::NodeOffersMessageHandler; use crate::peer_store::PeerStore; use crate::probing::{ HighDegreeStrategy, Prober, ProbingConfig, ProbingStrategy, ProbingStrategyKind, @@ -1980,6 +1981,8 @@ fn build_with_store_internal( }; let channel_manager = Arc::new(channel_manager); + let offers_message_handler = + Arc::new(NodeOffersMessageHandler::new(Arc::clone(&channel_manager))); // Give ChannelMonitors to ChainMonitor for (_blockhash, channel_monitor) in channel_monitors.into_iter() { @@ -2050,7 +2053,7 @@ fn build_with_store_internal( Arc::clone(&logger), Arc::clone(&channel_manager), message_router, - Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&channel_manager), Arc::clone(&om_resolver), IgnoringMessageHandler {}, @@ -2063,7 +2066,7 @@ fn build_with_store_internal( Arc::clone(&logger), Arc::clone(&channel_manager), message_router, - Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&channel_manager), Arc::clone(&om_resolver), IgnoringMessageHandler {}, diff --git a/src/payment/bolt12/mod.rs b/src/payment/bolt12/mod.rs index 9dce173cf3..e542dfbdce 100644 --- a/src/payment/bolt12/mod.rs +++ b/src/payment/bolt12/mod.rs @@ -9,6 +9,10 @@ //! //! [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md +mod offers; + +pub(crate) use offers::NodeOffersMessageHandler; + use std::num::NonZeroU64; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs new file mode 100644 index 0000000000..d5f21746b4 --- /dev/null +++ b/src/payment/bolt12/offers.rs @@ -0,0 +1,39 @@ +// 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::sync::Arc; + +use lightning::blinded_path::message::OffersContext; +use lightning::onion_message::messenger::{ + MessageSendInstructions, Responder, ResponseInstruction, +}; +use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; + +use crate::types::ChannelManager; + +/// Routes offers messages through node-local handling before falling back to the channel manager. +pub(crate) struct NodeOffersMessageHandler { + channel_manager: Arc, +} + +impl NodeOffersMessageHandler { + pub(crate) fn new(channel_manager: Arc) -> Self { + Self { channel_manager } + } +} + +impl OffersMessageHandler for NodeOffersMessageHandler { + fn handle_message( + &self, message: OffersMessage, context: Option, responder: Option, + ) -> Option<(OffersMessage, ResponseInstruction)> { + self.channel_manager.handle_message(message, context, responder) + } + + fn release_pending_messages(&self) -> Vec<(OffersMessage, MessageSendInstructions)> { + self.channel_manager.release_pending_messages() + } +} diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 4685a4d520..4b80711289 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -19,6 +19,7 @@ mod unified; pub use bolt11::Bolt11Payment; pub use bolt12::Bolt12Payment; +pub(crate) use bolt12::NodeOffersMessageHandler; pub(crate) use metadata::PaymentMetadata; pub use onchain::OnchainPayment; pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails}; diff --git a/src/types.rs b/src/types.rs index 22429d980b..e9c11a37c8 100644 --- a/src/types.rs +++ b/src/types.rs @@ -50,7 +50,7 @@ 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::payment::{NodeOffersMessageHandler, PaymentDetails, PendingPaymentDetails}; use crate::runtime::RuntimeSpawner; #[cfg(feature = "uniffi")] @@ -310,7 +310,7 @@ pub(crate) type OnionMessenger = lightning::onion_message::messenger::OnionMesse Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, IgnoringMessageHandler, From 7cdcc83029789d4c512f5b3cb428b3a7f0985c59 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:48:01 +0200 Subject: [PATCH 17/41] bolt12: Sync offers flow block state Feed every supported chain source into the node-local offers flow so invoice expiry and blinded-path CLTV limits use the current tip. Co-Authored-By: HAL 9000 --- src/builder.rs | 11 ++++-- src/chain/bitcoind.rs | 60 +++++++++++++++++++++++++------ src/chain/electrum.rs | 19 +++++++--- src/chain/esplora.rs | 19 +++++++--- src/chain/mod.rs | 34 ++++++++++++++---- src/lib.rs | 16 +++++++-- src/payment/bolt12/offers.rs | 70 +++++++++++++++++++++++++++++++++--- 7 files changed, 194 insertions(+), 35 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 506903c085..12665bc2fc 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1981,8 +1981,14 @@ fn build_with_store_internal( }; let channel_manager = Arc::new(channel_manager); - let offers_message_handler = - Arc::new(NodeOffersMessageHandler::new(Arc::clone(&channel_manager))); + let offers_message_handler = Arc::new(NodeOffersMessageHandler::new( + config.network, + cur_time.as_secs().try_into().map_err(|_| BuildError::InvalidSystemTime)?, + Arc::clone(&channel_manager), + Arc::clone(&keys_manager), + Arc::clone(&message_router), + Arc::clone(&logger), + )); // Give ChannelMonitors to ChainMonitor for (_blockhash, channel_monitor) in channel_monitors.into_iter() { @@ -2324,6 +2330,7 @@ fn build_with_store_internal( output_sweeper, peer_manager, onion_messenger, + offers_message_handler, connection_manager, keys_manager, network_graph, diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index f7589671b8..dda881352a 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -42,6 +42,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -156,7 +157,8 @@ impl BitcoindChainSource { pub(super) async fn continuously_sync_wallets( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) { // First register for the wallet polling status to make sure `Node::sync_wallets` calls // wait on the result before proceeding. @@ -184,11 +186,13 @@ impl BitcoindChainSource { let onchain_wallet_best_block = onchain_wallet.current_best_block(); let channel_manager_best_block = channel_manager.current_best_block(); + let offers_best_block = offers_message_handler.current_best_block(); let sweeper_best_block = output_sweeper.current_best_block(); let mut chain_listeners = vec![ (onchain_wallet_best_block, &*onchain_wallet as &(dyn Listen + Send + Sync)), (channel_manager_best_block, &*channel_manager as &(dyn Listen + Send + Sync)), + (offers_best_block, &*offers_message_handler as &(dyn Listen + Send + Sync)), (sweeper_best_block, &*output_sweeper as &(dyn Listen + Send + Sync)), ]; @@ -224,6 +228,7 @@ impl BitcoindChainSource { header_cache, Arc::clone(&onchain_wallet), Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper), ); @@ -341,6 +346,7 @@ impl BitcoindChainSource { _ = self.poll_and_update_listeners( Arc::clone(&onchain_wallet), Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper) ) => {} @@ -398,7 +404,8 @@ impl BitcoindChainSource { pub(super) async fn poll_and_update_listeners( &self, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> Result<(), Error> { let receiver_res = { let mut status_lock = self.wallet_polling_status.lock().expect("lock"); @@ -418,6 +425,7 @@ impl BitcoindChainSource { .poll_and_update_listeners_inner( onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) @@ -430,7 +438,8 @@ impl BitcoindChainSource { async fn poll_and_update_listeners_inner( &self, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> Result<(), Error> { let mut spv_client_lock = self.spv_client.lock().await; if spv_client_lock.is_none() { @@ -445,6 +454,7 @@ impl BitcoindChainSource { HeaderCache::new(), Arc::clone(&onchain_wallet), Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), chain_monitor, output_sweeper, )); @@ -511,12 +521,14 @@ impl BitcoindChainSource { fn new_spv_client( &self, chain_tip: ValidatedBlockHeader, header_cache: HeaderCache, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> BitcoindSpvClient { let chain_poller = ChainPoller::new(Arc::clone(&self.api_client), self.config.network); let chain_listener = Arc::new(ChainListener { onchain_wallet: Arc::downgrade(&onchain_wallet), channel_manager: Arc::downgrade(&channel_manager), + offers_message_handler: Arc::downgrade(&offers_message_handler), chain_monitor: Arc::downgrade(&chain_monitor), output_sweeper: Arc::downgrade(&output_sweeper), }); @@ -1507,6 +1519,7 @@ pub(crate) enum FeeRateEstimationMode { pub(crate) struct ChainListener { pub(crate) onchain_wallet: std::sync::Weak, pub(crate) channel_manager: std::sync::Weak, + pub(crate) offers_message_handler: std::sync::Weak, pub(crate) chain_monitor: std::sync::Weak, pub(crate) output_sweeper: std::sync::Weak, } @@ -1514,10 +1527,17 @@ pub(crate) struct ChainListener { impl ChainListener { fn upgrade( &self, - ) -> Option<(Arc, Arc, Arc, Arc)> { + ) -> Option<( + Arc, + Arc, + Arc, + Arc, + Arc, + )> { Some(( self.onchain_wallet.upgrade()?, self.channel_manager.upgrade()?, + self.offers_message_handler.upgrade()?, self.chain_monitor.upgrade()?, self.output_sweeper.upgrade()?, )) @@ -1529,32 +1549,50 @@ impl Listen for ChainListener { &self, header: &bitcoin::block::Header, txdata: &lightning::chain::transaction::TransactionData, height: u32, ) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() + if let Some(( + onchain_wallet, + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + )) = self.upgrade() { onchain_wallet.filtered_block_connected(header, txdata, height); channel_manager.filtered_block_connected(header, txdata, height); + offers_message_handler.filtered_block_connected(header, txdata, height); chain_monitor.filtered_block_connected(header, txdata, height); output_sweeper.filtered_block_connected(header, txdata, height); } } fn block_connected(&self, block: &bitcoin::Block, height: u32) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() + if let Some(( + onchain_wallet, + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + )) = self.upgrade() { onchain_wallet.block_connected(block, height); channel_manager.block_connected(block, height); + offers_message_handler.block_connected(block, height); chain_monitor.block_connected(block, height); output_sweeper.block_connected(block, height); } } fn blocks_disconnected(&self, fork_point_block: lightning::chain::BlockLocator) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() + if let Some(( + onchain_wallet, + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + )) = self.upgrade() { onchain_wallet.blocks_disconnected(fork_point_block); channel_manager.blocks_disconnected(fork_point_block); + offers_message_handler.blocks_disconnected(fork_point_block); chain_monitor.blocks_disconnected(fork_point_block); output_sweeper.blocks_disconnected(fork_point_block); } diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 59fa23a6ca..4eefa80bdf 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -37,6 +37,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::runtime::Runtime; use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; @@ -208,7 +209,8 @@ impl ElectrumChainSource { } pub(crate) async fn sync_lightning_wallet( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let receiver_res = { @@ -224,8 +226,14 @@ impl ElectrumChainSource { })?; } - let res = - self.sync_lightning_wallet_inner(channel_manager, chain_monitor, output_sweeper).await; + let res = self + .sync_lightning_wallet_inner( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await; self.lightning_wallet_sync_status .lock() @@ -236,14 +244,17 @@ impl ElectrumChainSource { } async fn sync_lightning_wallet_inner( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let sync_cman = Arc::clone(&channel_manager); + let sync_offers = Arc::clone(&offers_message_handler); let sync_cmon = Arc::clone(&chain_monitor); let sync_sweeper = Arc::clone(&output_sweeper); let confirmables = vec![ sync_cman as Arc, + sync_offers as Arc, sync_cmon as Arc, sync_sweeper as Arc, ]; diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index f4aaea4c9b..020454abb2 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -29,6 +29,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -268,7 +269,8 @@ impl EsploraChainSource { } pub(super) async fn sync_lightning_wallet( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let receiver_res = { @@ -284,8 +286,14 @@ impl EsploraChainSource { })?; } - let res = - self.sync_lightning_wallet_inner(channel_manager, chain_monitor, output_sweeper).await; + let res = self + .sync_lightning_wallet_inner( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await; self.lightning_wallet_sync_status .lock() @@ -296,14 +304,17 @@ impl EsploraChainSource { } async fn sync_lightning_wallet_inner( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let sync_cman = Arc::clone(&channel_manager); + let sync_offers = Arc::clone(&offers_message_handler); let sync_cmon = Arc::clone(&chain_monitor); let sync_sweeper = Arc::clone(&output_sweeper); let confirmables = vec![ &*sync_cman as &(dyn Confirm + Sync + Send), + &*sync_offers as &(dyn Confirm + Sync + Send), &*sync_cmon as &(dyn Confirm + Sync + Send), &*sync_sweeper as &(dyn Confirm + Sync + Send), ]; diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 0f96c409f8..5ddb35230d 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -25,6 +25,7 @@ use crate::config::{ }; use crate::fee_estimator::OnchainFeeEstimator; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -259,7 +260,8 @@ impl ChainSource { pub(crate) async fn continuously_sync_wallets( &self, stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, - channel_manager: Arc, chain_monitor: Arc, + channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) { match &self.kind { @@ -271,6 +273,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, background_sync_config, @@ -294,6 +297,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, background_sync_config, @@ -315,6 +319,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) @@ -326,8 +331,9 @@ impl ChainSource { async fn start_tx_based_sync_loop( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, - background_sync_config: &BackgroundSyncConfig, logger: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, background_sync_config: &BackgroundSyncConfig, + logger: Arc, ) { // Setup syncing intervals let onchain_wallet_sync_interval_secs = background_sync_config @@ -374,6 +380,7 @@ impl ChainSource { _ = lightning_wallet_sync_interval.tick() => { let _ = self.sync_lightning_wallet( Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper), ).await; @@ -405,18 +412,29 @@ impl ChainSource { // Synchronize the Lightning wallet via transaction-based protocols (i.e., Esplora, Electrum, // etc.) pub(crate) async fn sync_lightning_wallet( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { esplora_chain_source - .sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper) + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) .await }, ChainSourceKind::Electrum(electrum_chain_source) => { electrum_chain_source - .sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper) + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) .await }, ChainSourceKind::Bitcoind { .. } => { @@ -429,7 +447,8 @@ impl ChainSource { pub(crate) async fn poll_and_update_listeners( &self, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> Result<(), Error> { match &self.kind { ChainSourceKind::Esplora { .. } => { @@ -447,6 +466,7 @@ impl ChainSource { .poll_and_update_listeners( onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) diff --git a/src/lib.rs b/src/lib.rs index 7a1c7e72be..21c3b2b218 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -172,8 +172,8 @@ use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; use payment::{ - Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment, - UnifiedPayment, + Bolt11Payment, Bolt12Payment, NodeOffersMessageHandler, OnchainPayment, PaymentDetails, + SpontaneousPayment, UnifiedPayment, }; use peer_store::{PeerInfo, PeerStore}; #[cfg(feature = "uniffi")] @@ -253,6 +253,7 @@ pub struct Node { output_sweeper: Arc, peer_manager: Arc, onion_messenger: Arc, + offers_message_handler: Arc, connection_manager: Arc>>, keys_manager: Arc, network_graph: Arc, @@ -360,6 +361,7 @@ impl Node { let chain_source = Arc::clone(&self.chain_source); let sync_wallet = Arc::clone(&self.wallet); let sync_cman = Arc::clone(&self.channel_manager); + let sync_offers = Arc::clone(&self.offers_message_handler); let sync_cmon = Arc::clone(&self.chain_monitor); let sync_sweeper = Arc::clone(&self.output_sweeper); self.runtime.spawn_background_task(async move { @@ -368,6 +370,7 @@ impl Node { stop_sync_receiver, sync_wallet, sync_cman, + sync_offers, sync_cmon, sync_sweeper, ) @@ -2038,13 +2041,19 @@ impl Node { let chain_source = Arc::clone(&self.chain_source); let sync_wallet = Arc::clone(&self.wallet); let sync_cman = Arc::clone(&self.channel_manager); + let sync_offers = Arc::clone(&self.offers_message_handler); let sync_cmon = Arc::clone(&self.chain_monitor); let sync_sweeper = Arc::clone(&self.output_sweeper); self.runtime.block_on(async move { if chain_source.is_transaction_based() { chain_source.update_fee_rate_estimates().await?; chain_source - .sync_lightning_wallet(sync_cman, sync_cmon, Arc::clone(&sync_sweeper)) + .sync_lightning_wallet( + sync_cman, + sync_offers, + sync_cmon, + Arc::clone(&sync_sweeper), + ) .await?; chain_source.sync_onchain_wallet(sync_wallet).await?; } else { @@ -2053,6 +2062,7 @@ impl Node { .poll_and_update_listeners( sync_wallet, sync_cman, + sync_offers, sync_cmon, Arc::clone(&sync_sweeper), ) diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index d5f21746b4..db95db6a04 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,24 +5,56 @@ // 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::sync::Arc; +use std::sync::{Arc, RwLock}; +use bitcoin::block::Header; +use bitcoin::constants::ChainHash; +use bitcoin::secp256k1::Secp256k1; +use bitcoin::{Block, BlockHash, Network, Txid}; use lightning::blinded_path::message::OffersContext; +use lightning::chain::transaction::TransactionData; +use lightning::chain::{BlockLocator, Confirm, Listen}; +use lightning::offers::flow::OffersMessageFlow; use lightning::onion_message::messenger::{ MessageSendInstructions, Responder, ResponseInstruction, }; use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; +use lightning::sign::{EntropySource, NodeSigner, Recipient}; -use crate::types::ChannelManager; +use crate::logger::Logger; +use crate::types::{ChannelManager, KeysManager, MessageRouter}; /// Routes offers messages through node-local handling before falling back to the channel manager. pub(crate) struct NodeOffersMessageHandler { channel_manager: Arc, + flow: OffersMessageFlow, Arc>, + best_block: RwLock, } impl NodeOffersMessageHandler { - pub(crate) fn new(channel_manager: Arc) -> Self { - Self { channel_manager } + pub(crate) fn new( + network: Network, current_timestamp: u32, channel_manager: Arc, + keys_manager: Arc, message_router: Arc, logger: Arc, + ) -> Self { + let best_block = channel_manager.current_best_block(); + let mut secp_ctx = Secp256k1::new(); + secp_ctx.seeded_randomize(&keys_manager.get_secure_random_bytes()); + let flow = OffersMessageFlow::new( + ChainHash::using_genesis_block(network), + best_block, + keys_manager.get_node_id(Recipient::Node).expect("node ID"), + current_timestamp, + keys_manager.get_expanded_key(), + keys_manager.get_receive_auth_key(), + secp_ctx, + message_router, + logger, + ); + Self { channel_manager, flow, best_block: RwLock::new(best_block) } + } + + pub(crate) fn current_best_block(&self) -> BlockLocator { + *self.best_block.read().expect("lock") } } @@ -37,3 +69,33 @@ impl OffersMessageHandler for NodeOffersMessageHandler { self.channel_manager.release_pending_messages() } } + +impl Confirm for NodeOffersMessageHandler { + fn transactions_confirmed(&self, _header: &Header, _txdata: &TransactionData, _height: u32) {} + + fn transaction_unconfirmed(&self, _txid: &Txid) {} + + fn best_block_updated(&self, header: &Header, height: u32) { + let best_block = BlockLocator::new(header.block_hash(), height); + *self.best_block.write().expect("lock") = best_block; + self.flow.best_block_updated(header, height); + } + + fn get_relevant_txids(&self) -> Vec<(Txid, u32, Option)> { + Vec::new() + } +} + +impl Listen for NodeOffersMessageHandler { + fn filtered_block_connected(&self, header: &Header, _txdata: &TransactionData, height: u32) { + self.best_block_updated(header, height); + } + + fn block_connected(&self, block: &Block, height: u32) { + self.best_block_updated(&block.header, height); + } + + fn blocks_disconnected(&self, fork_point_block: BlockLocator) { + *self.best_block.write().expect("lock") = fork_point_block; + } +} From c9082b9d86762976b937e7a5855bf46956e80fc0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:10:30 +0200 Subject: [PATCH 18/41] bolt12: Install LSPS2 router Install the node-local LSPS2-aware router during node construction so BOLT12 responses can append negotiated JIT paths. Co-Authored-By: HAL 9000 --- src/builder.rs | 27 +++++++++++++++++---------- src/types.rs | 4 +++- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 12665bc2fc..9fe5e8caec 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -69,6 +69,7 @@ use crate::io::{ PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; +use crate::liquidity::client::lsps2::router::LSPS2Router; use crate::liquidity::{LSPS2ServiceConfig, LiquiditySourceBuilder, LspConfig}; use crate::lnurl_auth::LnurlAuth; use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; @@ -1901,12 +1902,15 @@ fn build_with_store_internal( } let scoring_fee_params = ProbabilisticScoringFeeParameters::default(); - let router = Arc::new(DefaultRouter::new( - Arc::clone(&network_graph), - Arc::clone(&logger), + let router = Arc::new(LSPS2Router::new( + DefaultRouter::new( + Arc::clone(&network_graph), + Arc::clone(&logger), + Arc::clone(&keys_manager), + Arc::clone(&scorer), + scoring_fee_params, + ), Arc::clone(&keys_manager), - Arc::clone(&scorer), - scoring_fee_params, )); let mut user_config = default_user_config(&config); @@ -2267,12 +2271,15 @@ fn build_with_store_internal( if let Some(penalty) = probing_cfg.diversity_penalty_msat { probing_fee_params.probing_diversity_penalty_msat = penalty; } - let probing_router = Arc::new(DefaultRouter::new( - Arc::clone(&network_graph), - Arc::clone(&logger), + let probing_router = Arc::new(LSPS2Router::new( + DefaultRouter::new( + Arc::clone(&network_graph), + Arc::clone(&logger), + Arc::clone(&keys_manager), + Arc::clone(&scorer), + probing_fee_params, + ), Arc::clone(&keys_manager), - Arc::clone(&scorer), - probing_fee_params, )); Arc::new(HighDegreeStrategy::new( Arc::clone(&network_graph), diff --git a/src/types.rs b/src/types.rs index e9c11a37c8..59cc6fe93d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -48,6 +48,7 @@ use crate::config::{AnchorChannelsConfig, ChannelConfig}; use crate::data_store::DataStore; use crate::fee_estimator::OnchainFeeEstimator; use crate::ffi::maybe_wrap; +use crate::liquidity::client::lsps2::router::LSPS2Router; use crate::logger::Logger; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::{NodeOffersMessageHandler, PaymentDetails, PendingPaymentDetails}; @@ -277,7 +278,8 @@ pub(crate) type Broadcaster = crate::tx_broadcaster::TransactionBroadcaster>; +pub(crate) type BaseRouter = DefaultRouter< Arc, Arc, Arc, From bf6b7e5c4cefd52f3230d022f765044e73ef3b57 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:15:22 +0200 Subject: [PATCH 19/41] lsps2: Share payment lease selection Reuse one cached-or-negotiated lease path across invoice formats. Keep BOLT11 selection and fee-limit behavior unchanged. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 140 ++++++++++++++---------------- 1 file changed, 65 insertions(+), 75 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index df0470764f..ef34d89167 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -113,26 +113,69 @@ where expiry_secs: u32, payment_hash: Option, connection_manager: Arc>, ) -> Result<(Bolt11Invoice, LspConfig), Error> { + let (lease, total_fee_msat, lsp, was_negotiated) = + self.acquire_fixed_lease(amount_msat, &connection_manager).await?; + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }; + + let invoice = self.lsps2_create_jit_invoice( + LSPS2BuyResponse::from(&lease), + Some(amount_msat), + description, + expiry_secs, + payment_hash, + lsps2_parameters, + Some(&lsp.node_id), + )?; + + if was_negotiated { + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + } + Ok((invoice, lsp)) + } + + pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( + self: Arc, description: &Bolt11InvoiceDescription, expiry_secs: u32, + payment_hash: Option, connection_manager: Arc>, + ) -> Result<(Bolt11Invoice, LspConfig), Error> { + let (lease, proportional_fee, lsp, was_negotiated) = + self.acquire_variable_lease(&connection_manager).await?; + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, + max_proportional_opening_fee_ppm_msat: self + .config + .lsps2_max_total_lsp_fee_limit_msat + .is_none() + .then_some(proportional_fee), + }; + let invoice = self.lsps2_create_jit_invoice( + LSPS2BuyResponse::from(&lease), + None, + description, + expiry_secs, + payment_hash, + lsps2_parameters, + Some(&lsp.node_id), + )?; + + if was_negotiated { + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + } + Ok((invoice, lsp)) + } + + async fn acquire_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { if let Some((lease, total_fee_msat, lsp)) = self.take_cached_fixed_lease(amount_msat).await? { - let invoice = self.lsps2_create_jit_invoice( - LSPS2BuyResponse::from(&lease), - Some(amount_msat), - description, - expiry_secs, - payment_hash, - LSPS2Parameters { - max_total_opening_fee_msat: Some(total_fee_msat), - max_proportional_opening_fee_ppm_msat: None, - }, - Some(&lsp.node_id), - )?; - return Ok((invoice, lsp)); + return Ok((lease, total_fee_msat, lsp, false)); } - // Connect to all candidate LSPs before querying fees. - let all_offers = self.gather_lsps2_offers(&connection_manager).await?; + let all_offers = self.gather_lsps2_offers(connection_manager).await?; let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers .into_iter() .flat_map(|(lsp, resp)| { @@ -188,51 +231,17 @@ where ) .await?; let lease = self.consume_lease(&negotiated_lease.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( - LSPS2BuyResponse::from(&lease), - Some(amount_msat), - description, - expiry_secs, - payment_hash, - lsps2_parameters, - Some(&cheapest_lsp.node_id), - )?; - - log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok((invoice, cheapest_lsp)) + Ok((lease, min_total_fee_msat, cheapest_lsp, true)) } - pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( - self: Arc, description: &Bolt11InvoiceDescription, expiry_secs: u32, - payment_hash: Option, connection_manager: Arc>, - ) -> Result<(Bolt11Invoice, LspConfig), Error> { + async fn acquire_variable_lease( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { - let invoice = self.lsps2_create_jit_invoice( - LSPS2BuyResponse::from(&lease), - None, - description, - expiry_secs, - payment_hash, - LSPS2Parameters { - max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, - max_proportional_opening_fee_ppm_msat: self - .config - .lsps2_max_total_lsp_fee_limit_msat - .is_none() - .then_some(proportional_fee), - }, - Some(&lsp.node_id), - )?; - return Ok((invoice, lsp)); + return Ok((lease, proportional_fee, lsp, false)); } - // Connect to all candidate LSPs before querying fees. - let all_offers = self.gather_lsps2_offers(&connection_manager).await?; + let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; let (cheapest_lsp, min_prop_fee_ppm_msat, min_opening_params) = all_offers .into_iter() @@ -274,26 +283,7 @@ where .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) .await?; let lease = self.consume_lease(&negotiated_lease.id).await?; - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, - max_proportional_opening_fee_ppm_msat: self - .config - .lsps2_max_total_lsp_fee_limit_msat - .is_none() - .then_some(min_prop_fee_ppm_msat), - }; - let invoice = self.lsps2_create_jit_invoice( - LSPS2BuyResponse::from(&lease), - None, - description, - expiry_secs, - payment_hash, - lsps2_parameters, - Some(&cheapest_lsp.node_id), - )?; - - log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok((invoice, cheapest_lsp)) + Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true)) } async fn gather_lsps2_offers( From 90621fc3df624c6f5fcaf6d977ae1547e358ba7c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 29 Jul 2026 13:36:33 +0200 Subject: [PATCH 20/41] f Prevent duplicate lease consumption Reserve each cached lease while selecting it so concurrent receive requests cannot advertise the same intercept SCID. Restore the lease when durable removal fails because no invoice has used it yet. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 31 +++++++++++-------- src/liquidity/client/lsps2/state.rs | 47 ++++++++++++++++++----------- 2 files changed, 49 insertions(+), 29 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index ef34d89167..d9d151b32c 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -42,17 +42,21 @@ use self::state::{ }; async fn consume_after_persisted_removal( - value: T, persist_removal: RF, consume: CF, + value: T, persist_removal: RF, restore: CF, ) -> Result where T: Clone, RF: FnOnce(T) -> Fut, - CF: FnOnce(&T), + CF: FnOnce(T), Fut: Future>, { - persist_removal(value.clone()).await?; - consume(&value); - Ok(value) + match persist_removal(value.clone()).await { + Ok(()) => Ok(value), + Err(error) => { + restore(value); + Err(error) + }, + } } pub(crate) struct LSPS2Client @@ -440,18 +444,21 @@ where .lease_state .lock() .expect("lock") - .valid(id) + .take_valid(id) .ok_or(Error::LiquidityRequestFailed)?; self.consume_selected_lease(lease).await } async fn consume_selected_lease(&self, lease: PaymentLease) -> Result { let lease_store = Arc::clone(&self.lease_store); + // Selection has already removed the lease from the shared state so no other payment can + // reuse its intercept SCID. Restore it only if the durable removal fails, before an invoice + // containing the lease can be returned. consume_after_persisted_removal( lease, move |lease| async move { lease_store.remove(&lease.id).await }, |lease| { - self.lease_state.lock().expect("lock").remove(&lease.id); + self.lease_state.lock().expect("lock").insert(lease); }, ) .await @@ -733,18 +740,18 @@ mod tests { use super::*; #[tokio::test] - async fn failed_persisted_removal_does_not_consume() { - let consumed = Arc::new(Mutex::new(false)); - let consumed_ref = Arc::clone(&consumed); + async fn failed_persisted_removal_restores_value() { + let restored = Arc::new(Mutex::new(None)); + let restored_ref = Arc::clone(&restored); let result = consume_after_persisted_removal( 42, |_| async { Err(()) }, - move |_| *consumed_ref.lock().unwrap() = true, + move |value| *restored_ref.lock().unwrap() = Some(value), ) .await; assert_eq!(result, Err(())); - assert!(!*consumed.lock().unwrap()); + assert_eq!(*restored.lock().unwrap(), Some(42)); } } diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index e1e5b16dbb..f22f512993 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -348,16 +348,17 @@ impl LSPS2LeaseState { self.leases.insert(lease.id, lease); } - pub(crate) fn valid(&self, id: &PaymentLeaseId) -> Option { - self.leases.get(id).filter(|lease| is_lease_usable(lease)).cloned() - } - - pub(crate) fn remove(&mut self, id: &PaymentLeaseId) -> Option { - self.leases.remove(id) + pub(crate) fn take_valid(&mut self, id: &PaymentLeaseId) -> Option { + if self.leases.get(id).map_or(false, is_lease_usable) { + self.leases.remove(id) + } else { + None + } } + /// Selects and removes a fixed-amount lease in one operation. pub(crate) fn fixed_amount( - &self, amount_msat: u64, max_fee_msat: Option, available_lsps: &[PublicKey], + &mut self, amount_msat: u64, max_fee_msat: Option, available_lsps: &[PublicKey], ) -> Option<(PaymentLease, u64)> { let (id, fee_msat) = self .leases @@ -375,11 +376,12 @@ impl LSPS2LeaseState { }) .filter(|(_, fee_msat)| max_fee_msat.map_or(true, |max| *fee_msat <= max)) .min_by_key(|(_, fee_msat)| *fee_msat)?; - self.leases.get(&id).cloned().map(|lease| (lease, fee_msat)) + self.leases.remove(&id).map(|lease| (lease, fee_msat)) } + /// Selects and removes a variable-amount lease in one operation. pub(crate) fn variable_amount( - &self, max_total_fee_msat: Option, available_lsps: &[PublicKey], + &mut self, max_total_fee_msat: Option, available_lsps: &[PublicKey], ) -> Option<(PaymentLease, u64)> { let (id, proportional_fee) = self .leases @@ -392,7 +394,7 @@ impl LSPS2LeaseState { }) .map(|(id, lease)| (*id, lease.params.proportional as u64)) .min_by_key(|(_, fee)| *fee)?; - self.leases.get(&id).cloned().map(|lease| (lease, proportional_fee)) + self.leases.remove(&id).map(|lease| (lease, proportional_fee)) } pub(crate) fn prune(&mut self) { @@ -498,9 +500,21 @@ mod tests { let id = lease.id; let mut state = LSPS2LeaseState::default(); state.insert(lease); - assert!(state.valid(&id).is_some()); - assert!(state.remove(&id).is_some()); - assert!(state.valid(&id).is_none()); + assert!(state.take_valid(&id).is_some()); + assert!(state.take_valid(&id).is_none()); + } + + #[test] + fn selected_leases_cannot_be_consumed_twice() { + let lease = lease(2, 52, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); + let available_lsps = [lease.id.lsp_node_id]; + let mut state = LSPS2LeaseState::from_leases(vec![lease]); + + assert!(state.fixed_amount(1_000, None, &available_lsps).is_some()); + assert!( + state.fixed_amount(1_000, None, &available_lsps).is_none(), + "single-use lease was handed out twice" + ); } #[test] @@ -510,7 +524,7 @@ mod tests { let mut state = LSPS2LeaseState::default(); state.insert(lease); state.prune(); - assert!(state.valid(&id).is_none()); + assert!(state.take_valid(&id).is_none()); } #[test] @@ -523,7 +537,6 @@ mod tests { let available_lsps = [expensive.id.lsp_node_id, cheap.id.lsp_node_id]; let (selected, _) = state.fixed_amount(1_000, None, &available_lsps).unwrap(); assert_eq!(selected.id, cheap.id); - state.remove(&selected.id); let (remaining, _) = state.fixed_amount(1_000, None, &available_lsps).unwrap(); assert_eq!(remaining.id, expensive.id); } @@ -533,7 +546,7 @@ mod tests { let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; let unavailable = lease(2, 46, 1, Some(1_000), valid_until); let available = lease(3, 47, 2, Some(1_000), valid_until); - let state = LSPS2LeaseState::from_leases(vec![unavailable, available.clone()]); + let mut state = LSPS2LeaseState::from_leases(vec![unavailable, available.clone()]); let (selected, _) = state.fixed_amount(1_000, None, &[available.id.lsp_node_id]).unwrap(); assert_eq!(selected.id, available.id, "lease selection ignored the set of available LSPs"); @@ -543,7 +556,7 @@ mod tests { fn variable_lease_honors_total_fee_limit() { let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; let variable = lease(2, 46, 50, None, valid_until); - let state = LSPS2LeaseState::from_leases(vec![variable.clone()]); + let mut state = LSPS2LeaseState::from_leases(vec![variable.clone()]); let available_lsps = [variable.id.lsp_node_id]; assert!(state.variable_amount(Some(49), &available_lsps).is_none()); From 536eb7c737bb5d14c317a14f0416d73c20de869d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:53:51 +0200 Subject: [PATCH 21/41] bolt12: Handle invoice requests locally Verify and answer ordinary invoice requests with the node-owned offers flow while retaining ChannelManager handling for all other messages. Co-Authored-By: HAL 9000 --- src/builder.rs | 1 + src/payment/bolt12/offers.rs | 106 +++++++++++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 9fe5e8caec..691ee6c644 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1990,6 +1990,7 @@ fn build_with_store_internal( cur_time.as_secs().try_into().map_err(|_| BuildError::InvalidSystemTime)?, Arc::clone(&channel_manager), Arc::clone(&keys_manager), + Arc::clone(&router), Arc::clone(&message_router), Arc::clone(&logger), )); diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index db95db6a04..5df1c90c05 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -14,7 +14,11 @@ use bitcoin::{Block, BlockHash, Network, Txid}; use lightning::blinded_path::message::OffersContext; use lightning::chain::transaction::TransactionData; use lightning::chain::{BlockLocator, Confirm, Listen}; -use lightning::offers::flow::OffersMessageFlow; +use lightning::offers::flow::{InvreqResponseInstructions, OffersMessageFlow}; +use lightning::offers::invoice::UnsignedBolt12Invoice; +use lightning::offers::invoice_error::InvoiceError; +use lightning::offers::invoice_request::InvoiceRequestVerifiedFromOffer; +use lightning::offers::parse::Bolt12SemanticError; use lightning::onion_message::messenger::{ MessageSendInstructions, Responder, ResponseInstruction, }; @@ -22,19 +26,23 @@ use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; use lightning::sign::{EntropySource, NodeSigner, Recipient}; use crate::logger::Logger; -use crate::types::{ChannelManager, KeysManager, MessageRouter}; +use crate::types::{ChannelManager, KeysManager, MessageRouter, Router}; /// Routes offers messages through node-local handling before falling back to the channel manager. pub(crate) struct NodeOffersMessageHandler { channel_manager: Arc, + keys_manager: Arc, + router: Arc, flow: OffersMessageFlow, Arc>, + secp_ctx: Secp256k1, best_block: RwLock, } impl NodeOffersMessageHandler { pub(crate) fn new( network: Network, current_timestamp: u32, channel_manager: Arc, - keys_manager: Arc, message_router: Arc, logger: Arc, + keys_manager: Arc, router: Arc, message_router: Arc, + logger: Arc, ) -> Self { let best_block = channel_manager.current_best_block(); let mut secp_ctx = Secp256k1::new(); @@ -46,11 +54,18 @@ impl NodeOffersMessageHandler { current_timestamp, keys_manager.get_expanded_key(), keys_manager.get_receive_auth_key(), - secp_ctx, + secp_ctx.clone(), message_router, logger, ); - Self { channel_manager, flow, best_block: RwLock::new(best_block) } + Self { + channel_manager, + keys_manager, + router, + flow, + secp_ctx, + best_block: RwLock::new(best_block), + } } pub(crate) fn current_best_block(&self) -> BlockLocator { @@ -62,7 +77,86 @@ impl OffersMessageHandler for NodeOffersMessageHandler { fn handle_message( &self, message: OffersMessage, context: Option, responder: Option, ) -> Option<(OffersMessage, ResponseInstruction)> { - self.channel_manager.handle_message(message, context, responder) + let invoice_request = match message { + OffersMessage::InvoiceRequest(invoice_request) => invoice_request, + message => return self.channel_manager.handle_message(message, context, responder), + }; + + if matches!(context, Some(OffersContext::StaticInvoiceRequested { .. })) { + return self.channel_manager.handle_message( + OffersMessage::InvoiceRequest(invoice_request), + context, + responder, + ); + } + + let responder = responder?; + let payment_metadata = match context.as_ref() { + Some(OffersContext::InvoiceRequest { payment_metadata, .. }) => { + payment_metadata.clone() + }, + _ => None, + }; + let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { + Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request, + Ok(InvreqResponseInstructions::SendStaticInvoice { .. }) | Err(()) => return None, + }; + + let get_payment_info = |amount_msats, relative_expiry| { + self.channel_manager + .create_inbound_payment(Some(amount_msats), relative_expiry, None, None) + .map_err(|_| Bolt12SemanticError::InvalidAmount) + .map(|(payment_hash, payment_secret, _)| (payment_hash, payment_secret)) + }; + + let result = match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => self + .flow + .create_invoice_builder_from_invoice_request_with_keys( + self.router.as_ref(), + &request, + self.channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceError::from) + .and_then(|(builder, context)| { + builder + .build_and_sign(&self.secp_ctx) + .map_err(InvoiceError::from) + .map(|invoice| (invoice, context)) + }), + InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => self + .flow + .create_invoice_builder_from_invoice_request_without_keys( + self.router.as_ref(), + &request, + self.channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceError::from) + .and_then(|(builder, context)| { + builder + .build() + .map_err(InvoiceError::from) + .and_then(|invoice| { + invoice + .sign(|invoice: &UnsignedBolt12Invoice| { + self.keys_manager.sign_bolt12_invoice(invoice) + }) + .map_err(InvoiceError::from) + }) + .map(|invoice| (invoice, context)) + }), + }; + + Some(match result { + Ok((invoice, context)) => { + (OffersMessage::Invoice(invoice), responder.respond_with_reply_path(context)) + }, + Err(error) => (OffersMessage::InvoiceError(error), responder.respond()), + }) } fn release_pending_messages(&self) -> Vec<(OffersMessage, MessageSendInstructions)> { From 4a746dc1cbfa46c73a8a324aa3f12418456e615e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:26:11 +0200 Subject: [PATCH 22/41] bolt12: Respond to requests from leases Answer verified JIT invoice requests after asynchronously acquiring a single-use LSPS2 lease. Send the result through the onion messenger so negotiation does not depend on the ChannelManager event queue. Co-Authored-By: HAL 9000 --- src/builder.rs | 6 + src/liquidity/client/lsps2/mod.rs | 99 +++++++- src/payment/bolt12/offers.rs | 407 +++++++++++++++++++++++++----- 3 files changed, 448 insertions(+), 64 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 691ee6c644..803edbf18f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2203,6 +2203,12 @@ fn build_with_store_internal( Arc::clone(&keys_manager), Arc::clone(&logger), )); + offers_message_handler.initialize_jit_handling( + Arc::clone(&runtime), + liquidity_source.lsps2_client(), + Arc::downgrade(&connection_manager), + Arc::downgrade(&onion_messenger), + ); let output_sweeper = match sweeper_bytes_res { Ok(output_sweeper) => Arc::new(output_sweeper), diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index d9d151b32c..6f1f5fa875 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -5,7 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::future::Future; use std::ops::Deref; use std::sync::{Arc, Mutex, RwLock}; @@ -36,6 +36,7 @@ use crate::payment::PaymentMetadata; use crate::types::{ChannelManager, KeysManager, LiquidityManager}; use crate::{Config, Error}; +use self::router::LSPS2LeaseParameters; use self::state::{ now_secs, LSPS2LeaseState, LeaseCacheTarget, LeaseCacheTargetId, LeaseCacheTargetStore, PaymentLease, PaymentLeaseId, PaymentLeaseStore, @@ -59,6 +60,36 @@ where } } +const DEFAULT_BOLT12_INVOICE_EXPIRY_SECS: u32 = 2 * 60 * 60; + +pub(crate) struct JitInvoiceResponse { + pub(crate) payment_metadata: BTreeMap>, + pub(crate) allow_mpp: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum JitInvoiceRequest { + Fixed { amount_msat: u64, absolute_expiry: Option }, + Variable { amount_msat: u64, absolute_expiry: Option }, +} + +impl JitInvoiceRequest { + pub(crate) fn allow_mpp(&self) -> bool { + matches!(self, Self::Fixed { .. }) + } + + fn cache_target(&self) -> (LeaseCacheTargetId, Option) { + match *self { + Self::Fixed { amount_msat, absolute_expiry } => { + (LeaseCacheTargetId::Fixed { amount_msat }, absolute_expiry) + }, + Self::Variable { absolute_expiry, .. } => { + (LeaseCacheTargetId::Variable, absolute_expiry) + }, + } + } +} + pub(crate) struct LSPS2Client where L::Target: LdkLogger, @@ -112,6 +143,72 @@ where self.cache_target_store.targets() } + pub(crate) async fn prepare_invoice_response( + self: Arc, request: JitInvoiceRequest, connection_manager: Arc>, + ) -> Result { + // Cache targets are only an optimization. Recording an older, evicted offer again makes it + // eligible for startup pre-caching, but a persistence failure must not make the current + // invoice request unusable. + let (target_id, absolute_expiry) = request.cache_target(); + if let Err(error) = self.register_cache_target(target_id, absolute_expiry).await { + log_warn!(self.logger, "Failed recording LSPS2 lease cache target: {}", error); + } + + let allow_mpp = request.allow_mpp(); + let (lease, fee_parameters) = match request { + JitInvoiceRequest::Fixed { amount_msat, .. } => { + let (lease, total_fee_msat, _, _) = + self.acquire_fixed_lease(amount_msat, &connection_manager).await?; + ( + lease, + LSPS2Parameters { + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }, + ) + }, + JitInvoiceRequest::Variable { .. } => { + let (lease, proportional_fee, _, _) = + self.acquire_variable_lease(&connection_manager).await?; + ( + lease, + LSPS2Parameters { + max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, + max_proportional_opening_fee_ppm_msat: self + .config + .lsps2_max_total_lsp_fee_limit_msat + .is_none() + .then_some(proportional_fee), + }, + ) + }, + }; + + // The offers flow currently uses its two-hour default invoice expiry. Never publish a JIT + // path whose intercept SCID may expire before the invoice does. + if lease.valid_until.saturating_sub(now_secs()) < DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64 + { + return Err(Error::LiquidityRequestFailed); + } + let lease_parameters = LSPS2LeaseParameters { + lsp_node_id: lease.id.lsp_node_id, + intercept_scid: lease.id.intercept_scid, + cltv_expiry_delta: lease + .cltv_expiry_delta + .try_into() + .map_err(|_| Error::LiquidityRequestFailed)?, + payment_size_msat: lease.payment_size_msat, + valid_until: lease.valid_until, + }; + let payment_metadata = PaymentMetadata { + lsps2_parameters: Some(fee_parameters), + lsps2_lease_parameters: Some(lease_parameters), + } + .encode_as_bolt12_payment_metadata(); + + Ok(JitInvoiceResponse { payment_metadata, allow_mpp }) + } + pub(crate) async fn lsps2_receive_to_jit_channel( self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: Option, diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index 5df1c90c05..c3bd610123 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,37 +5,70 @@ // 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::sync::{Arc, RwLock}; +use std::collections::BTreeMap; +use std::sync::{Arc, OnceLock, RwLock, Weak}; use bitcoin::block::Header; use bitcoin::constants::ChainHash; use bitcoin::secp256k1::Secp256k1; use bitcoin::{Block, BlockHash, Network, Txid}; -use lightning::blinded_path::message::OffersContext; +use lightning::blinded_path::message::{MessageContext, OffersContext}; use lightning::chain::transaction::TransactionData; use lightning::chain::{BlockLocator, Confirm, Listen}; use lightning::offers::flow::{InvreqResponseInstructions, OffersMessageFlow}; -use lightning::offers::invoice::UnsignedBolt12Invoice; +use lightning::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use lightning::offers::invoice_error::InvoiceError; use lightning::offers::invoice_request::InvoiceRequestVerifiedFromOffer; +use lightning::offers::offer::Amount; use lightning::offers::parse::Bolt12SemanticError; use lightning::onion_message::messenger::{ MessageSendInstructions, Responder, ResponseInstruction, }; use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; use lightning::sign::{EntropySource, NodeSigner, Recipient}; +use lightning::types::payment::{PaymentHash, PaymentSecret}; +use lightning::util::logger::Logger as _; -use crate::logger::Logger; -use crate::types::{ChannelManager, KeysManager, MessageRouter, Router}; +use crate::connection::ConnectionManager; +use crate::liquidity::client::lsps2::{JitInvoiceRequest, JitInvoiceResponse, LSPS2Client}; +use crate::logger::{log_error, Logger}; +use crate::runtime::Runtime; +use crate::types::{ChannelManager, KeysManager, MessageRouter, OnionMessenger, Router}; + +type NodeOffersFlow = OffersMessageFlow, Arc>; +type InvoicePaymentInfo = OnceLock<(u64, PaymentHash, PaymentSecret)>; + +enum InvoiceBuildError { + Semantic(Bolt12SemanticError), + Response(InvoiceError), +} + +impl InvoiceBuildError { + fn into_invoice_error(self) -> InvoiceError { + match self { + Self::Semantic(error) => InvoiceError::from(error), + Self::Response(error) => error, + } + } +} + +struct JitInvoiceRequestDependencies { + runtime: Arc, + lsps2_client: Arc>>, + connection_manager: Weak>>, + onion_messenger: Weak, +} /// Routes offers messages through node-local handling before falling back to the channel manager. pub(crate) struct NodeOffersMessageHandler { channel_manager: Arc, keys_manager: Arc, router: Arc, - flow: OffersMessageFlow, Arc>, - secp_ctx: Secp256k1, + flow: Arc, + secp_ctx: Arc>, best_block: RwLock, + jit_dependencies: OnceLock, + logger: Arc, } impl NodeOffersMessageHandler { @@ -47,7 +80,7 @@ impl NodeOffersMessageHandler { let best_block = channel_manager.current_best_block(); let mut secp_ctx = Secp256k1::new(); secp_ctx.seeded_randomize(&keys_manager.get_secure_random_bytes()); - let flow = OffersMessageFlow::new( + let flow = Arc::new(OffersMessageFlow::new( ChainHash::using_genesis_block(network), best_block, keys_manager.get_node_id(Recipient::Node).expect("node ID"), @@ -56,23 +89,124 @@ impl NodeOffersMessageHandler { keys_manager.get_receive_auth_key(), secp_ctx.clone(), message_router, - logger, - ); + Arc::clone(&logger), + )); Self { channel_manager, keys_manager, router, flow, - secp_ctx, + secp_ctx: Arc::new(secp_ctx), best_block: RwLock::new(best_block), + jit_dependencies: OnceLock::new(), + logger, } } + pub(crate) fn initialize_jit_handling( + &self, runtime: Arc, lsps2_client: Arc>>, + connection_manager: Weak>>, + onion_messenger: Weak, + ) { + let dependencies = JitInvoiceRequestDependencies { + runtime, + lsps2_client, + connection_manager, + onion_messenger, + }; + assert!( + self.jit_dependencies.set(dependencies).is_ok(), + "JIT invoice handling must only be initialized once" + ); + } + pub(crate) fn current_best_block(&self) -> BlockLocator { *self.best_block.read().expect("lock") } } +fn build_invoice( + flow: &NodeOffersFlow, channel_manager: &ChannelManager, keys_manager: &KeysManager, + router: &Router, secp_ctx: &Secp256k1, + invoice_request: &InvoiceRequestVerifiedFromOffer, + payment_metadata: Option>>, allow_mpp: bool, + payment_info: &InvoicePaymentInfo, +) -> Result<(Bolt12Invoice, MessageContext), InvoiceBuildError> { + let get_payment_info = |amount_msats, relative_expiry| { + if let Some((cached_amount_msats, payment_hash, payment_secret)) = payment_info.get() { + return (*cached_amount_msats == amount_msats) + .then_some((*payment_hash, *payment_secret)) + .ok_or(Bolt12SemanticError::InvalidAmount); + } + let (payment_hash, payment_secret, _) = channel_manager + .create_inbound_payment(Some(amount_msats), relative_expiry, None, None) + .map_err(|_| Bolt12SemanticError::InvalidAmount)?; + let _ = payment_info.set((amount_msats, payment_hash, payment_secret)); + Ok((payment_hash, payment_secret)) + }; + + match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { + let (builder, context) = flow + .create_invoice_builder_from_invoice_request_with_keys( + router, + request, + channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceBuildError::Semantic)?; + let builder = if allow_mpp { builder } else { builder.disallow_mpp() }; + builder + .build_and_sign(secp_ctx) + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error))) + .map(|invoice| (invoice, context)) + }, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => { + let (builder, context) = flow + .create_invoice_builder_from_invoice_request_without_keys( + router, + request, + channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceBuildError::Semantic)?; + let builder = if allow_mpp { builder } else { builder.disallow_mpp() }; + let invoice = builder + .build() + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error)))?; + invoice + .sign(|invoice: &UnsignedBolt12Invoice| keys_manager.sign_bolt12_invoice(invoice)) + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error))) + .map(|invoice| (invoice, context)) + }, + } +} + +fn jit_invoice_request( + invoice_request: &InvoiceRequestVerifiedFromOffer, +) -> Result { + jit_invoice_request_from_fields( + invoice_request.amount(), + invoice_request.amount_msats(), + invoice_request.absolute_expiry().map(|expiry| expiry.as_secs()), + ) +} + +fn jit_invoice_request_from_fields( + offer_amount: Option, amount_msat: Option, absolute_expiry: Option, +) -> Result { + let amount_msat = amount_msat.ok_or(Bolt12SemanticError::MissingAmount)?; + match offer_amount { + Some(Amount::Bitcoin { .. }) => { + Ok(JitInvoiceRequest::Fixed { amount_msat, absolute_expiry }) + }, + Some(Amount::Currency { .. }) => Err(Bolt12SemanticError::UnsupportedCurrency), + None => Ok(JitInvoiceRequest::Variable { amount_msat, absolute_expiry }), + } +} + impl OffersMessageHandler for NodeOffersMessageHandler { fn handle_message( &self, message: OffersMessage, context: Option, responder: Option, @@ -102,61 +236,134 @@ impl OffersMessageHandler for NodeOffersMessageHandler { Ok(InvreqResponseInstructions::SendStaticInvoice { .. }) | Err(()) => return None, }; - let get_payment_info = |amount_msats, relative_expiry| { - self.channel_manager - .create_inbound_payment(Some(amount_msats), relative_expiry, None, None) - .map_err(|_| Bolt12SemanticError::InvalidAmount) - .map(|(payment_hash, payment_secret, _)| (payment_hash, payment_secret)) - }; - - let result = match invoice_request { - InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => self - .flow - .create_invoice_builder_from_invoice_request_with_keys( - self.router.as_ref(), - &request, - self.channel_manager.list_usable_channels(), - get_payment_info, - payment_metadata, - ) - .map_err(InvoiceError::from) - .and_then(|(builder, context)| { - builder - .build_and_sign(&self.secp_ctx) - .map_err(InvoiceError::from) - .map(|invoice| (invoice, context)) - }), - InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => self - .flow - .create_invoice_builder_from_invoice_request_without_keys( - self.router.as_ref(), - &request, - self.channel_manager.list_usable_channels(), - get_payment_info, - payment_metadata, - ) - .map_err(InvoiceError::from) - .and_then(|(builder, context)| { - builder - .build() - .map_err(InvoiceError::from) - .and_then(|invoice| { - invoice - .sign(|invoice: &UnsignedBolt12Invoice| { - self.keys_manager.sign_bolt12_invoice(invoice) - }) - .map_err(InvoiceError::from) - }) - .map(|invoice| (invoice, context)) - }), - }; + let allow_mpp = invoice_request.amount().is_some(); + let payment_info = Arc::new(InvoicePaymentInfo::new()); + let result = build_invoice( + self.flow.as_ref(), + self.channel_manager.as_ref(), + self.keys_manager.as_ref(), + self.router.as_ref(), + self.secp_ctx.as_ref(), + &invoice_request, + payment_metadata.clone(), + allow_mpp, + payment_info.as_ref(), + ); - Some(match result { + match result { Ok((invoice, context)) => { - (OffersMessage::Invoice(invoice), responder.respond_with_reply_path(context)) + return Some(( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + )); }, - Err(error) => (OffersMessage::InvoiceError(error), responder.respond()), - }) + Err(InvoiceBuildError::Semantic(Bolt12SemanticError::MissingPaths)) => {}, + Err(error) => { + return Some(( + OffersMessage::InvoiceError(error.into_invoice_error()), + responder.respond(), + )); + }, + } + + // Only an actual lack of ordinary blinded paths triggers LSPS2. Negotiation is kept out of + // the synchronous message-handler and router APIs; the verified request and reply path are + // moved into a future which sends the response directly when its single-use lease is ready. + let dependencies = match self.jit_dependencies.get() { + Some(dependencies) => dependencies, + None => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from( + Bolt12SemanticError::MissingPaths, + )), + responder.respond(), + )); + }, + }; + let jit_request = match jit_invoice_request(&invoice_request) { + Ok(request) => request, + Err(error) => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from(error)), + responder.respond(), + )); + }, + }; + let connection_manager = match dependencies.connection_manager.upgrade() { + Some(connection_manager) => connection_manager, + None => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "JIT invoice handling is unavailable".to_owned(), + )), + responder.respond(), + )); + }, + }; + let onion_messenger = match dependencies.onion_messenger.upgrade() { + Some(onion_messenger) => onion_messenger, + None => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "JIT invoice handling is unavailable".to_owned(), + )), + responder.respond(), + )); + }, + }; + + let lsps2_client = Arc::clone(&dependencies.lsps2_client); + let flow = Arc::clone(&self.flow); + let channel_manager = Arc::clone(&self.channel_manager); + let keys_manager = Arc::clone(&self.keys_manager); + let router = Arc::clone(&self.router); + let secp_ctx = Arc::clone(&self.secp_ctx); + let logger = Arc::clone(&self.logger); + dependencies.runtime.spawn_cancellable_background_task(async move { + let response = + lsps2_client.prepare_invoice_response(jit_request, connection_manager).await; + let (message, instructions) = match response { + Ok(JitInvoiceResponse { payment_metadata: jit_metadata, allow_mpp }) => { + debug_assert_eq!(allow_mpp, jit_request.allow_mpp()); + let mut merged_metadata = payment_metadata.unwrap_or_default(); + merged_metadata.extend(jit_metadata); + match build_invoice( + flow.as_ref(), + channel_manager.as_ref(), + keys_manager.as_ref(), + router.as_ref(), + secp_ctx.as_ref(), + &invoice_request, + Some(merged_metadata), + allow_mpp, + payment_info.as_ref(), + ) { + Ok((invoice, context)) => ( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + ), + Err(error) => ( + OffersMessage::InvoiceError(error.into_invoice_error()), + responder.respond(), + ), + } + }, + Err(error) => { + log_error!(logger, "Failed preparing LSPS2 invoice response: {}", error); + ( + OffersMessage::InvoiceError(InvoiceError::from_string( + "Failed preparing JIT invoice".to_owned(), + )), + responder.respond(), + ) + }, + }; + if let Err(error) = onion_messenger.handle_onion_message_response(message, instructions) + { + log_error!(logger, "Failed sending LSPS2 invoice response: {:?}", error); + } + }); + None } fn release_pending_messages(&self) -> Vec<(OffersMessage, MessageSendInstructions)> { @@ -193,3 +400,77 @@ impl Listen for NodeOffersMessageHandler { *self.best_block.write().expect("lock") = fork_point_block; } } + +#[cfg(test)] +mod tests { + use std::num::NonZeroU64; + + use bitcoin::secp256k1::{PublicKey, SecretKey}; + use lightning::ln::channelmanager::PaymentId; + use lightning::ln::inbound_payment::ExpandedKey; + use lightning::offers::nonce::Nonce; + use lightning::offers::offer::{OfferBuilder, Quantity}; + + use super::*; + + struct FixedEntropy; + + impl EntropySource for FixedEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } + + fn recipient_pubkey() -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[43; 32]).unwrap()) + } + + #[test] + fn fixed_offer_request_uses_quantity_resolved_amount() { + let expanded_key = ExpandedKey::new([44; 32]); + let nonce = Nonce::from_entropy_source(&FixedEntropy); + let secp_ctx = Secp256k1::new(); + let offer = OfferBuilder::new(recipient_pubkey()) + .amount_msats(1_000) + .supported_quantity(Quantity::Bounded(NonZeroU64::new(10).unwrap())) + .build() + .unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, PaymentId([45; 32])) + .unwrap() + .quantity(3) + .unwrap() + .build_and_sign() + .unwrap(); + + assert_eq!( + jit_invoice_request_from_fields(offer.amount(), invoice_request.amount_msats(), None,) + .unwrap(), + JitInvoiceRequest::Fixed { amount_msat: 3_000, absolute_expiry: None } + ); + } + + #[test] + fn variable_offer_request_disables_mpp() { + let expanded_key = ExpandedKey::new([46; 32]); + let nonce = Nonce::from_entropy_source(&FixedEntropy); + let secp_ctx = Secp256k1::new(); + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, PaymentId([47; 32])) + .unwrap() + .amount_msats(2_500) + .unwrap() + .build_and_sign() + .unwrap(); + let request = + jit_invoice_request_from_fields(offer.amount(), invoice_request.amount_msats(), None) + .unwrap(); + + assert_eq!( + request, + JitInvoiceRequest::Variable { amount_msat: 2_500, absolute_expiry: None } + ); + assert!(!request.allow_mpp()); + } +} From f832bade7b0d16bed9fe34ef476c0cb58c8766b5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 28 Jul 2026 15:20:42 +0200 Subject: [PATCH 23/41] f Preserve MPP for ordinary offer paths Keep MPP enabled while probing ordinary payment paths, including for variable-amount offers. Only apply the fixed-versus-variable policy when an LSPS2 JIT path is included. Co-Authored-By: HAL 9000 --- src/payment/bolt12/offers.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index c3bd610123..e233442d7d 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -236,7 +236,10 @@ impl OffersMessageHandler for NodeOffersMessageHandler { Ok(InvreqResponseInstructions::SendStaticInvoice { .. }) | Err(()) => return None, }; - let allow_mpp = invoice_request.amount().is_some(); + // The initial response only contains ordinary payment paths, for which MPP remains useful + // regardless of whether the offer fixed the amount. Only disable MPP below when rebuilding a + // variable-amount response with an LSPS2 JIT path. + let allow_mpp = true; let payment_info = Arc::new(InvoicePaymentInfo::new()); let result = build_invoice( self.flow.as_ref(), From 896f74c648a9cb0d1deb8a2766e50b208181439e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 7 Aug 2026 11:25:55 +0200 Subject: [PATCH 24/41] f Reject unsupported LSPS2 CLTV deltas Validate the LSP-provided CLTV delta before storing a negotiated lease and exclude incompatible restored leases from selection. This prevents BOLT12 from consuming a lease before rejecting it and avoids truncating the value in BOLT11 route hints. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 21 +++++++++++++++------ src/liquidity/client/lsps2/state.rs | 20 +++++++++++++++++++- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 6f1f5fa875..43790eb863 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -193,10 +193,8 @@ where let lease_parameters = LSPS2LeaseParameters { lsp_node_id: lease.id.lsp_node_id, intercept_scid: lease.id.intercept_scid, - cltv_expiry_delta: lease - .cltv_expiry_delta - .try_into() - .map_err(|_| Error::LiquidityRequestFailed)?, + cltv_expiry_delta: state::checked_cltv_expiry_delta(lease.cltv_expiry_delta) + .expect("LSPS2 lease CLTV delta was validated before selection"), payment_size_msat: lease.payment_size_msat, valid_until: lease.valid_until, }; @@ -514,6 +512,16 @@ where log_error!(self.logger, "Failed to handle response from liquidity service: {:?}", e); Error::LiquidityRequestFailed })?; + let cltv_expiry_delta = state::checked_cltv_expiry_delta(buy_response.cltv_expiry_delta) + .ok_or_else(|| { + log_error!( + self.logger, + "LSP {} returned an unsupported CLTV expiry delta of {}", + lsps2_node.node_id, + buy_response.cltv_expiry_delta + ); + Error::LiquidityRequestFailed + })?; let valid_until = opening_fee_params .valid_until @@ -527,7 +535,7 @@ where intercept_scid: buy_response.intercept_scid, }, params: opening_fee_params, - cltv_expiry_delta: buy_response.cltv_expiry_delta, + cltv_expiry_delta: cltv_expiry_delta.into(), payment_size_msat: amount_msat, valid_until, }; @@ -653,7 +661,8 @@ where 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, + cltv_expiry_delta: state::checked_cltv_expiry_delta(buy_response.cltv_expiry_delta) + .expect("LSPS2 lease CLTV delta was validated before invoice creation"), htlc_minimum_msat: None, htlc_maximum_msat: None, }]); diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index f22f512993..32c705e3c4 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -403,7 +403,12 @@ impl LSPS2LeaseState { } pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { - lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS + checked_cltv_expiry_delta(lease.cltv_expiry_delta).is_some() + && lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS +} + +pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { + cltv_expiry_delta.try_into().ok() } pub(crate) fn now_secs() -> u64 { @@ -527,6 +532,19 @@ mod tests { assert!(state.take_valid(&id).is_none()); } + #[test] + fn rejects_unsupported_cltv_delta() { + let mut lease = lease(2, 53, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); + lease.cltv_expiry_delta = u16::MAX as u32 + 1; + let id = lease.id; + let mut state = LSPS2LeaseState::from_leases(vec![lease]); + + assert!( + state.take_valid(&id).is_none(), + "lease with an unsupported CLTV delta remained usable" + ); + } + #[test] fn selects_cheapest_matching_lease_across_lsps() { let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; From f95552cddc539df57090a3a2ca58627b1ae3da97 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 15:04:33 +0200 Subject: [PATCH 25/41] bolt12: Bound pending JIT requests Limit concurrently pending JIT invoice requests so an onion-message storm cannot create an unbounded number of lease negotiations. Hold a permit while requests wait for an offer lock or an LSP response. Co-Authored-By: HAL 9000 --- src/payment/bolt12/offers.rs | 61 ++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index e233442d7d..ba1b0f1be0 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -6,6 +6,7 @@ // accordance with one or both of these licenses. use std::collections::BTreeMap; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock, RwLock, Weak}; use bitcoin::block::Header; @@ -52,6 +53,38 @@ impl InvoiceBuildError { } } +const MAX_PENDING_JIT_INVOICE_REQUESTS: usize = 100; + +struct PendingJitInvoiceRequests { + in_flight: Arc, + limit: usize, +} + +impl PendingJitInvoiceRequests { + fn new(limit: usize) -> Self { + Self { in_flight: Arc::new(AtomicUsize::new(0)), limit } + } + + fn try_acquire(&self) -> Option { + self.in_flight + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |in_flight| { + (in_flight < self.limit).then_some(in_flight + 1) + }) + .ok() + .map(|_| PendingJitInvoiceRequest { in_flight: Arc::clone(&self.in_flight) }) + } +} + +struct PendingJitInvoiceRequest { + in_flight: Arc, +} + +impl Drop for PendingJitInvoiceRequest { + fn drop(&mut self) { + self.in_flight.fetch_sub(1, Ordering::AcqRel); + } +} + struct JitInvoiceRequestDependencies { runtime: Arc, lsps2_client: Arc>>, @@ -68,6 +101,7 @@ pub(crate) struct NodeOffersMessageHandler { secp_ctx: Arc>, best_block: RwLock, jit_dependencies: OnceLock, + pending_jit_invoice_requests: PendingJitInvoiceRequests, logger: Arc, } @@ -99,6 +133,9 @@ impl NodeOffersMessageHandler { secp_ctx: Arc::new(secp_ctx), best_block: RwLock::new(best_block), jit_dependencies: OnceLock::new(), + pending_jit_invoice_requests: PendingJitInvoiceRequests::new( + MAX_PENDING_JIT_INVOICE_REQUESTS, + ), logger, } } @@ -292,6 +329,17 @@ impl OffersMessageHandler for NodeOffersMessageHandler { )); }, }; + let pending_request = match self.pending_jit_invoice_requests.try_acquire() { + Some(pending_request) => pending_request, + None => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "Too many pending JIT invoice requests".to_owned(), + )), + responder.respond(), + )); + }, + }; let connection_manager = match dependencies.connection_manager.upgrade() { Some(connection_manager) => connection_manager, None => { @@ -323,6 +371,7 @@ impl OffersMessageHandler for NodeOffersMessageHandler { let secp_ctx = Arc::clone(&self.secp_ctx); let logger = Arc::clone(&self.logger); dependencies.runtime.spawn_cancellable_background_task(async move { + let _pending_request = pending_request; let response = lsps2_client.prepare_invoice_response(jit_request, connection_manager).await; let (message, instructions) = match response { @@ -476,4 +525,16 @@ mod tests { ); assert!(!request.allow_mpp()); } + + #[test] + fn pending_jit_invoice_requests_are_bounded() { + let pending_requests = PendingJitInvoiceRequests::new(2); + let first = pending_requests.try_acquire().unwrap(); + let _second = pending_requests.try_acquire().unwrap(); + + assert!(pending_requests.try_acquire().is_none()); + + drop(first); + assert!(pending_requests.try_acquire().is_some()); + } } From 795951b8669dc543c8e64df5cffae07b43c83d95 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 7 Aug 2026 11:28:41 +0200 Subject: [PATCH 26/41] f Reject in-flight invoice request replays Track the hashes of signed JIT invoice requests while their response futures are pending. This prevents a replay from negotiating another single-use lease without persisting replay state beyond the short-lived request flow. Co-Authored-By: HAL 9000 --- src/payment/bolt12/offers.rs | 93 +++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 29 deletions(-) diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index ba1b0f1be0..6f4dc2af9e 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,12 +5,12 @@ // 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::BTreeMap; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, OnceLock, RwLock, Weak}; +use std::collections::{BTreeMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak}; use bitcoin::block::Header; use bitcoin::constants::ChainHash; +use bitcoin::hashes::{sha256, Hash}; use bitcoin::secp256k1::Secp256k1; use bitcoin::{Block, BlockHash, Network, Txid}; use lightning::blinded_path::message::{MessageContext, OffersContext}; @@ -29,6 +29,7 @@ use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; use lightning::sign::{EntropySource, NodeSigner, Recipient}; use lightning::types::payment::{PaymentHash, PaymentSecret}; use lightning::util::logger::Logger as _; +use lightning::util::ser::Writeable; use crate::connection::ConnectionManager; use crate::liquidity::client::lsps2::{JitInvoiceRequest, JitInvoiceResponse, LSPS2Client}; @@ -56,32 +57,44 @@ impl InvoiceBuildError { const MAX_PENDING_JIT_INVOICE_REQUESTS: usize = 100; struct PendingJitInvoiceRequests { - in_flight: Arc, + in_flight: Arc>>, limit: usize, } impl PendingJitInvoiceRequests { fn new(limit: usize) -> Self { - Self { in_flight: Arc::new(AtomicUsize::new(0)), limit } + Self { in_flight: Arc::new(Mutex::new(HashSet::new())), limit } } - fn try_acquire(&self) -> Option { - self.in_flight - .fetch_update(Ordering::AcqRel, Ordering::Acquire, |in_flight| { - (in_flight < self.limit).then_some(in_flight + 1) - }) - .ok() - .map(|_| PendingJitInvoiceRequest { in_flight: Arc::clone(&self.in_flight) }) + fn try_acquire( + &self, request_id: sha256::Hash, + ) -> Result { + let mut in_flight = self.in_flight.lock().expect("lock"); + if in_flight.contains(&request_id) { + return Err(PendingJitInvoiceRequestError::AlreadyPending); + } + if in_flight.len() == self.limit { + return Err(PendingJitInvoiceRequestError::AtCapacity); + } + in_flight.insert(request_id); + Ok(PendingJitInvoiceRequest { request_id, in_flight: Arc::clone(&self.in_flight) }) } } +#[derive(Debug, PartialEq, Eq)] +enum PendingJitInvoiceRequestError { + AlreadyPending, + AtCapacity, +} + struct PendingJitInvoiceRequest { - in_flight: Arc, + request_id: sha256::Hash, + in_flight: Arc>>, } impl Drop for PendingJitInvoiceRequest { fn drop(&mut self) { - self.in_flight.fetch_sub(1, Ordering::AcqRel); + self.in_flight.lock().expect("lock").remove(&self.request_id); } } @@ -260,6 +273,7 @@ impl OffersMessageHandler for NodeOffersMessageHandler { responder, ); } + let invoice_request_id = sha256::Hash::hash(&invoice_request.encode()); let responder = responder?; let payment_metadata = match context.as_ref() { @@ -329,17 +343,19 @@ impl OffersMessageHandler for NodeOffersMessageHandler { )); }, }; - let pending_request = match self.pending_jit_invoice_requests.try_acquire() { - Some(pending_request) => pending_request, - None => { - return Some(( - OffersMessage::InvoiceError(InvoiceError::from_string( - "Too many pending JIT invoice requests".to_owned(), - )), - responder.respond(), - )); - }, - }; + let pending_request = + match self.pending_jit_invoice_requests.try_acquire(invoice_request_id) { + Ok(pending_request) => pending_request, + Err(PendingJitInvoiceRequestError::AlreadyPending) => return None, + Err(PendingJitInvoiceRequestError::AtCapacity) => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "Too many pending JIT invoice requests".to_owned(), + )), + responder.respond(), + )); + }, + }; let connection_manager = match dependencies.connection_manager.upgrade() { Some(connection_manager) => connection_manager, None => { @@ -529,12 +545,31 @@ mod tests { #[test] fn pending_jit_invoice_requests_are_bounded() { let pending_requests = PendingJitInvoiceRequests::new(2); - let first = pending_requests.try_acquire().unwrap(); - let _second = pending_requests.try_acquire().unwrap(); + let first = pending_requests.try_acquire(sha256::Hash::from_byte_array([1; 32])).unwrap(); + let _second = pending_requests.try_acquire(sha256::Hash::from_byte_array([2; 32])).unwrap(); - assert!(pending_requests.try_acquire().is_none()); + assert!(matches!( + pending_requests.try_acquire(sha256::Hash::from_byte_array([3; 32])), + Err(PendingJitInvoiceRequestError::AtCapacity) + )); drop(first); - assert!(pending_requests.try_acquire().is_some()); + assert!(pending_requests.try_acquire(sha256::Hash::from_byte_array([3; 32])).is_ok()); + } + + #[test] + fn pending_jit_invoice_requests_reject_replays() { + let pending_requests = PendingJitInvoiceRequests::new(2); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let _first = pending_requests.try_acquire(request_id).unwrap(); + + assert!( + matches!( + pending_requests.try_acquire(request_id), + Err(PendingJitInvoiceRequestError::AlreadyPending) + ), + "an in-flight invoice request replay was accepted" + ); + assert!(pending_requests.try_acquire(sha256::Hash::from_byte_array([2; 32])).is_ok()); } } From 5d4ed425af564f8d632756cec2fc979f69bda576 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:39:17 +0200 Subject: [PATCH 27/41] lsps2: Extend opening parameter validity Keep built-in service parameters usable beyond the client cache safety margin so freshly negotiated leases are not rejected due to timing. Co-Authored-By: HAL 9000 --- src/liquidity/service/lsps2.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index 946511c5d9..4732e6f92c 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -26,7 +26,7 @@ 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_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 48); const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; pub(crate) struct LSPS2Service { From 3ac1281ac752128ae4fc03fa74efe8c04f784dac Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:43:24 +0200 Subject: [PATCH 28/41] bolt12: Validate LSPS2 opening fees Read fee limits from BOLT12 payment context metadata and reject unsupported withholding. Record accepted fees on inbound offer payments. Co-Authored-By: HAL 9000 --- src/event.rs | 127 +++++++++++++++++++++++++++++--- src/payment/bolt12/mod.rs | 4 + src/payment/metadata.rs | 15 ++++ src/payment/store.rs | 52 ++++++++++++- tests/integration_tests_rust.rs | 4 + 5 files changed, 187 insertions(+), 15 deletions(-) diff --git a/src/event.rs b/src/event.rs index a8b30ed32b..99469ca8ff 100644 --- a/src/event.rs +++ b/src/event.rs @@ -7,7 +7,7 @@ use core::future::Future; use core::task::{Poll, Waker}; -use std::collections::VecDeque; +use std::collections::{BTreeMap, VecDeque}; use std::ops::Deref; use std::sync::{Arc, Mutex}; @@ -674,13 +674,40 @@ where } } - fn lsps2_max_total_opening_fee_msat(payment_metadata: &[u8], amount_msat: u64) -> Option { + fn lsps2_max_total_opening_fee_msat( + payment_metadata: &[u8], amount_msat: u64, counterparty_skimmed_fee_msat: u64, + ) -> Option { let metadata = PaymentMetadata::read(&mut &payment_metadata[..]).ok()?; + Self::lsps2_max_total_opening_fee_msat_from_metadata( + metadata, + amount_msat, + counterparty_skimmed_fee_msat, + ) + } + + fn lsps2_bolt12_max_total_opening_fee_msat( + payment_metadata: &BTreeMap>, amount_msat: u64, + counterparty_skimmed_fee_msat: u64, + ) -> Option { + let metadata = PaymentMetadata::decode_from_bolt12_payment_metadata(payment_metadata)?; + Self::lsps2_max_total_opening_fee_msat_from_metadata( + metadata, + amount_msat, + counterparty_skimmed_fee_msat, + ) + } + + fn lsps2_max_total_opening_fee_msat_from_metadata( + metadata: PaymentMetadata, amount_msat: u64, counterparty_skimmed_fee_msat: u64, + ) -> Option { let lsps2_parameters = metadata.lsps2_parameters?; lsps2_parameters.max_total_opening_fee_msat.or_else(|| { lsps2_parameters.max_proportional_opening_fee_ppm_msat.and_then(|max_prop_fee| { - // If it's a variable amount payment, compute the actual fee. - compute_opening_fee(amount_msat, 0, max_prop_fee) + // `PaymentClaimable::amount_msat` excludes the fee withheld by the LSP. LSPS2 + // proportional fees are calculated from the amount sent by the payer, so add the + // withheld fee back before computing the permitted fee. + let payment_size_msat = amount_msat.checked_add(counterparty_skimmed_fee_msat)?; + compute_opening_fee(payment_size_msat, 0, max_prop_fee) }) }) } @@ -874,15 +901,28 @@ where .as_ref() .and_then(|fields| fields.payment_metadata.as_ref()) .and_then(|metadata| { - Self::lsps2_max_total_opening_fee_msat(metadata, amount_msat) + Self::lsps2_max_total_opening_fee_msat( + metadata, + amount_msat, + counterparty_skimmed_fee_msat, + ) }), + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { + payment_context.payment_metadata.as_ref().and_then(|metadata| { + Self::lsps2_bolt12_max_total_opening_fee_msat( + metadata, + amount_msat, + counterparty_skimmed_fee_msat, + ) + }) + }, _ => None, }; let Some(max_total_opening_fee_msat) = max_total_opening_fee_msat else { log_info!( self.logger, - "Refusing inbound payment with hash {} as the counterparty withheld {}msat without valid BOLT11 LSPS2 payment metadata", + "Refusing inbound payment with hash {} as the counterparty withheld {}msat without valid LSPS2 payment metadata", hex_utils::to_string(&payment_hash.0), counterparty_skimmed_fee_msat, ); @@ -904,7 +944,7 @@ where if let Some(info) = payment_info.as_ref() { match &info.kind { - PaymentKind::Bolt11 { .. } => { + PaymentKind::Bolt11 { .. } | PaymentKind::Bolt12Offer { .. } => { let update = PaymentDetailsUpdate { counterparty_skimmed_fee_msat: Some(Some(counterparty_skimmed_fee_msat)), ..PaymentDetailsUpdate::new(payment_id) @@ -917,7 +957,7 @@ where }, }; }, - _ => debug_assert!(false, "We only expect the counterparty to get away with withholding fees for BOLT11 payments."), + _ => debug_assert!(false, "We only expect the counterparty to withhold fees for LSPS2 invoice payments."), } } } @@ -986,6 +1026,8 @@ where hash: Some(payment_hash), preimage: payment_preimage, secret: Some(payment_secret), + counterparty_skimmed_fee_msat: (counterparty_skimmed_fee_msat > 0) + .then_some(counterparty_skimmed_fee_msat), offer_id, payer_note, quantity, @@ -2048,12 +2090,33 @@ mod tests { assert_eq!( EventHandler::>::lsps2_max_total_opening_fee_msat( &metadata.encode(), - 100_000 + 100_000, + 0, ), Some(42_000) ); } + #[test] + fn lsps2_proportional_fee_uses_gross_payment_amount() { + let metadata = PaymentMetadata { + lsps2_parameters: Some(LSPS2Parameters { + max_total_opening_fee_msat: None, + max_proportional_opening_fee_ppm_msat: Some(10_000), + }), + lsps2_lease_parameters: None, + }; + + assert_eq!( + EventHandler::>::lsps2_max_total_opening_fee_msat( + &metadata.encode(), + 198_000, + 2_000, + ), + Some(2_000) + ); + } + #[test] fn lsps2_payment_metadata_missing_or_malformed_limit_is_rejected() { let empty_metadata = @@ -2070,23 +2133,63 @@ mod tests { assert_eq!( EventHandler::>::lsps2_max_total_opening_fee_msat( &empty_metadata, - 100_000 + 100_000, + 0, ), None ); assert_eq!( - EventHandler::>::lsps2_max_total_opening_fee_msat(&[0xff], 100_000), + EventHandler::>::lsps2_max_total_opening_fee_msat(&[0xff], 100_000, 0,), None ); assert_eq!( EventHandler::>::lsps2_max_total_opening_fee_msat( &metadata_without_fee_limit, - 100_000 + 100_000, + 0, ), None ); } + #[test] + fn bolt12_lsps2_payment_metadata_decodes_fee_limit() { + let metadata = PaymentMetadata { + lsps2_parameters: Some(LSPS2Parameters { + max_total_opening_fee_msat: None, + max_proportional_opening_fee_ppm_msat: Some(10_000), + }), + lsps2_lease_parameters: None, + } + .encode_as_bolt12_payment_metadata(); + + assert_eq!( + EventHandler::>::lsps2_bolt12_max_total_opening_fee_msat( + &metadata, 100_000, 0, + ), + Some(1_000) + ); + } + + #[test] + fn bolt12_lsps2_payment_metadata_requires_fee_limit() { + let empty_metadata = BTreeMap::new(); + let metadata_without_fee_limit = + PaymentMetadata { lsps2_parameters: None, lsps2_lease_parameters: None } + .encode_as_bolt12_payment_metadata(); + let mut malformed_metadata = metadata_without_fee_limit.clone(); + *malformed_metadata.values_mut().next().unwrap() = vec![0xff]; + + for metadata in [empty_metadata, malformed_metadata, metadata_without_fee_limit] { + assert_eq!( + EventHandler::>::lsps2_bolt12_max_total_opening_fee_msat( + &metadata, 100_000, 0, + ), + None + ); + } + } + #[tokio::test] async fn event_queue_persistence() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/payment/bolt12/mod.rs b/src/payment/bolt12/mod.rs index e542dfbdce..2e057860f1 100644 --- a/src/payment/bolt12/mod.rs +++ b/src/payment/bolt12/mod.rs @@ -163,6 +163,7 @@ impl Bolt12Payment { hash: None, preimage: None, secret: None, + counterparty_skimmed_fee_msat: None, offer_id: offer.id(), payer_note: payer_note.map(UntrustedString), quantity, @@ -188,6 +189,7 @@ impl Bolt12Payment { hash: None, preimage: None, secret: None, + counterparty_skimmed_fee_msat: None, offer_id: offer.id(), payer_note: payer_note.map(UntrustedString), quantity, @@ -344,6 +346,7 @@ impl Bolt12Payment { hash: None, preimage: None, secret: None, + counterparty_skimmed_fee_msat: None, offer_id: offer.id(), payer_note: payer_note.map(UntrustedString), quantity, @@ -369,6 +372,7 @@ impl Bolt12Payment { hash: None, preimage: None, secret: None, + counterparty_skimmed_fee_msat: None, offer_id: offer.id(), payer_note: payer_note.map(UntrustedString), quantity, diff --git a/src/payment/metadata.rs b/src/payment/metadata.rs index 1d8ec15e59..5ba990e4ed 100644 --- a/src/payment/metadata.rs +++ b/src/payment/metadata.rs @@ -77,4 +77,19 @@ mod tests { assert_eq!(metadata, decoded); } + + #[test] + fn bolt12_metadata_roundtrips() { + let metadata = PaymentMetadata { + lsps2_parameters: Some(LSPS2Parameters { + max_total_opening_fee_msat: Some(42_000), + max_proportional_opening_fee_ppm_msat: None, + }), + lsps2_lease_parameters: None, + }; + + let encoded = metadata.encode_as_bolt12_payment_metadata(); + + assert_eq!(PaymentMetadata::decode_from_bolt12_payment_metadata(&encoded), Some(metadata)); + } } diff --git a/src/payment/store.rs b/src/payment/store.rs index d2b92747a2..ae7ab206b3 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -253,12 +253,13 @@ impl StorableObject for PaymentDetails { if let Some(skimmed_fee_msat) = update.counterparty_skimmed_fee_msat { match self.kind { - PaymentKind::Bolt11 { ref mut counterparty_skimmed_fee_msat, .. } => { + PaymentKind::Bolt11 { ref mut counterparty_skimmed_fee_msat, .. } + | PaymentKind::Bolt12Offer { ref mut counterparty_skimmed_fee_msat, .. } => { update_if_necessary!(*counterparty_skimmed_fee_msat, skimmed_fee_msat); }, _ => debug_assert!( false, - "We should only ever override counterparty_skimmed_fee_msat for BOLT11 payments" + "We should only override counterparty_skimmed_fee_msat for invoice payments" ), } } @@ -545,6 +546,17 @@ pub enum PaymentKind { preimage: Option, /// The secret used by the payment. secret: Option, + /// The value, in thousands of a satoshi, that was deducted from this payment as an extra + /// fee taken by our channel counterparty. + /// + /// Will only ever be `Some` for inbound payments received via an [bLIP-52 / LSPS 2] + /// just-in-time channel, and only after the payment is observed; `None` otherwise. + /// + /// This will always be `None` for payments serialized by versions that did not record + /// BOLT12 LSPS2 fees. + /// + /// [bLIP-52 / LSPS 2]: https://github.com/lightning/blips/blob/master/blip-0052.md + counterparty_skimmed_fee_msat: Option, /// The ID of the offer this payment is for. offer_id: OfferId, /// The payer note for the payment. @@ -617,6 +629,7 @@ impl_writeable_tlv_based_enum!(PaymentKind, (2, preimage, option), (3, quantity, option), (4, secret, option), + (5, counterparty_skimmed_fee_msat, option), (6, offer_id, required), }, (8, Spontaneous) => { @@ -732,7 +745,8 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate { }; let counterparty_skimmed_fee_msat = match value.kind { - PaymentKind::Bolt11 { counterparty_skimmed_fee_msat, .. } => { + PaymentKind::Bolt11 { counterparty_skimmed_fee_msat, .. } + | PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { Some(counterparty_skimmed_fee_msat) }, _ => None, @@ -1022,6 +1036,38 @@ mod tests { } } + #[test] + fn bolt12_offer_records_counterparty_skimmed_fee() { + let payment_id = PaymentId([41; 32]); + let mut payment = PaymentDetails::new( + payment_id, + PaymentKind::Bolt12Offer { + hash: Some(PaymentHash([42; 32])), + preimage: None, + secret: None, + counterparty_skimmed_fee_msat: None, + offer_id: OfferId([43; 32]), + payer_note: None, + quantity: None, + }, + Some(100_000), + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + + assert!(payment.update(PaymentDetailsUpdate { + counterparty_skimmed_fee_msat: Some(Some(1_000)), + ..PaymentDetailsUpdate::new(payment_id) + })); + assert!(matches!( + payment.kind, + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat: Some(1_000), .. } + )); + let encoded = payment.encode(); + assert_eq!(payment, PaymentDetails::read(&mut &*encoded).unwrap()); + } + #[derive(Clone, Debug, PartialEq, Eq)] struct LegacyBolt11JitKind { hash: PaymentHash, diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 09a5adbc0e..57e0296dad 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2328,12 +2328,14 @@ async fn simple_bolt12_send_receive() { hash, preimage, secret: _, + counterparty_skimmed_fee_msat, offer_id, quantity: ref qty, payer_note: ref note, } => { assert!(hash.is_some()); assert!(preimage.is_some()); + assert_eq!(counterparty_skimmed_fee_msat, None); assert_eq!(offer_id, offer.id()); assert_eq!(&expected_quantity, qty); assert_eq!(expected_payer_note.unwrap(), note.clone().unwrap().0); @@ -2395,12 +2397,14 @@ async fn simple_bolt12_send_receive() { hash, preimage, secret: _, + counterparty_skimmed_fee_msat, offer_id, quantity: ref qty, payer_note: ref note, } => { assert!(hash.is_some()); assert!(preimage.is_some()); + assert_eq!(counterparty_skimmed_fee_msat, None); assert_eq!(offer_id, offer.id()); assert_eq!(&expected_quantity, qty); assert_eq!(expected_payer_note.unwrap(), note.clone().unwrap().0); From 148a597f22eb6698e2e699499cec91b0d5cf09e0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 29 Jul 2026 17:10:37 +0200 Subject: [PATCH 29/41] f Allow idempotent inbound BOLT12 hashes Permit existing inbound BOLT12 records to accept an unchanged hash when failing a claimable payment. The remaining assertion still prevents changing an established hash. Co-Authored-By: HAL 9000 --- src/payment/store.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/payment/store.rs b/src/payment/store.rs index ae7ab206b3..aa76b66a7e 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -181,11 +181,6 @@ impl StorableObject for PaymentDetails { if let Some(hash_opt) = update.hash { match self.kind { PaymentKind::Bolt12Offer { ref mut hash, .. } => { - debug_assert_eq!( - self.direction, - PaymentDirection::Outbound, - "We should only ever override payment hash for outbound BOLT 12 payments" - ); debug_assert!( hash.is_none() || *hash == hash_opt, "We should never change a payment hash after being initially set" From a3b6d850e187dfb1986e34f210a65592496c18c3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:47:36 +0200 Subject: [PATCH 30/41] lsps2: Serialize lease negotiation Make same-amount and variable callers wait for one in-flight LSPS2 request, then recheck the shared cache before negotiating. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 23 ++++++++++++++- src/liquidity/client/lsps2/state.rs | 44 ++++++++++++++++++++++++++++- src/liquidity/mod.rs | 3 +- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 43790eb863..6d4f3f1ded 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -39,7 +39,7 @@ use crate::{Config, Error}; use self::router::LSPS2LeaseParameters; use self::state::{ now_secs, LSPS2LeaseState, LeaseCacheTarget, LeaseCacheTargetId, LeaseCacheTargetStore, - PaymentLease, PaymentLeaseId, PaymentLeaseStore, + LeaseRequestKey, PaymentLease, PaymentLeaseId, PaymentLeaseStore, PendingLeaseRequestState, }; async fn consume_after_persisted_removal( @@ -102,6 +102,7 @@ where pub(crate) lease_store: Arc>, pub(crate) lease_state: Mutex, pub(crate) cache_target_store: Arc>, + pub(crate) pending_lease_request_state: Mutex, pub(crate) channel_manager: Arc, pub(crate) keys_manager: Arc, pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, @@ -273,6 +274,17 @@ where { return Ok((lease, total_fee_msat, lsp, false)); } + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Fixed(amount_msat)); + let _request_guard = request_lock.lock().await; + if let Some((lease, total_fee_msat, lsp)) = + self.take_cached_fixed_lease(amount_msat).await? + { + return Ok((lease, total_fee_msat, lsp, false)); + } let all_offers = self.gather_lsps2_offers(connection_manager).await?; let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers @@ -339,6 +351,15 @@ where if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { return Ok((lease, proportional_fee, lsp, false)); } + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Variable); + let _request_guard = request_lock.lock().await; + if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { + return Ok((lease, proportional_fee, lsp, false)); + } let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 32c705e3c4..d22d86ac70 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::ops::Deref; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Weak}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; @@ -9,6 +9,7 @@ use lightning::util::ser::{Readable, Writeable}; use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; use lightning_liquidity::lsps2::utils::compute_opening_fee; +use tokio::sync::Mutex as AsyncMutex; use crate::data_store::{DataStore, StorableObject, StorableObjectId, StorableObjectUpdate}; use crate::hex_utils; @@ -261,6 +262,33 @@ fn merge_absolute_expiry(current: Option, new: Option) -> Option } } +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub(crate) enum LeaseRequestKey { + Fixed(u64), + Variable, +} + +#[derive(Default)] +pub(crate) struct PendingLeaseRequestState { + locks: HashMap>>, +} + +impl PendingLeaseRequestState { + pub(crate) fn request_lock(&mut self, key: LeaseRequestKey) -> Arc> { + self.prune(); + if let Some(lock) = self.locks.get(&key).and_then(Weak::upgrade) { + return lock; + } + let lock = Arc::new(AsyncMutex::new(())); + self.locks.insert(key, Arc::downgrade(&lock)); + lock + } + + pub(crate) fn prune(&mut self) { + self.locks.retain(|_, lock| lock.strong_count() > 0); + } +} + fn is_cache_target_expired(target: &LeaseCacheTarget, now: u64) -> bool { target.absolute_expiry.is_some_and(|expiry| expiry <= now) } @@ -686,4 +714,18 @@ mod tests { state.register(cache_target(id, None, 3)); assert_eq!(state.targets().first().unwrap().absolute_expiry, None); } + + #[test] + fn lease_requests_are_serialized_per_key() { + let mut state = PendingLeaseRequestState::default(); + + let fixed = state.request_lock(LeaseRequestKey::Fixed(1_000)); + let same_fixed = state.request_lock(LeaseRequestKey::Fixed(1_000)); + let other_fixed = state.request_lock(LeaseRequestKey::Fixed(2_000)); + let variable = state.request_lock(LeaseRequestKey::Variable); + + assert!(Arc::ptr_eq(&fixed, &same_fixed)); + assert!(!Arc::ptr_eq(&fixed, &other_fixed)); + assert!(!Arc::ptr_eq(&fixed, &variable)); + } } diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index f91f56a1c7..f919829640 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -38,7 +38,7 @@ use crate::io::{ use crate::liquidity::client::lsps1::LSPS1Client; use crate::liquidity::client::lsps2::state::{ is_lease_usable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, - LeaseCacheTargetStore, PaymentLeaseStore, + LeaseCacheTargetStore, PaymentLeaseStore, PendingLeaseRequestState, }; use crate::liquidity::client::lsps2::LSPS2Client; use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; @@ -361,6 +361,7 @@ where lease_store, lease_state: Mutex::new(lease_state), cache_target_store, + pending_lease_request_state: Mutex::new(PendingLeaseRequestState::default()), channel_manager: self.channel_manager.clone(), keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), From 209f4bc5f268a55fcc6843b391c0e28406a7fffd Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:51:14 +0200 Subject: [PATCH 31/41] lsps2: Refill consumed payment leases Keep one usable lease ready after fixed or variable receive flows consume cached parameters. Foreground callers share the refill lock and reuse its result when it completes. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 96 +++++++++++++++++++++++++++-- src/liquidity/client/lsps2/state.rs | 37 +++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 6d4f3f1ded..650dc3a488 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -272,6 +272,7 @@ where if let Some((lease, total_fee_msat, lsp)) = self.take_cached_fixed_lease(amount_msat).await? { + self.schedule_fixed_lease_refill(amount_msat, connection_manager); return Ok((lease, total_fee_msat, lsp, false)); } let request_lock = self @@ -283,9 +284,20 @@ where if let Some((lease, total_fee_msat, lsp)) = self.take_cached_fixed_lease(amount_msat).await? { + self.schedule_fixed_lease_refill(amount_msat, connection_manager); return Ok((lease, total_fee_msat, lsp, false)); } + let (negotiated_lease, min_total_fee_msat, cheapest_lsp) = + self.negotiate_fixed_lease(amount_msat, connection_manager).await?; + let lease = self.consume_lease(&negotiated_lease.id).await?; + self.schedule_fixed_lease_refill(amount_msat, connection_manager); + Ok((lease, min_total_fee_msat, cheapest_lsp, true)) + } + + async fn negotiate_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers .into_iter() @@ -341,14 +353,14 @@ where Some(&cheapest_lsp.node_id), ) .await?; - let lease = self.consume_lease(&negotiated_lease.id).await?; - Ok((lease, min_total_fee_msat, cheapest_lsp, true)) + Ok((negotiated_lease, min_total_fee_msat, cheapest_lsp)) } async fn acquire_variable_lease( self: &Arc, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { + self.schedule_variable_lease_refill(connection_manager); return Ok((lease, proportional_fee, lsp, false)); } let request_lock = self @@ -358,9 +370,20 @@ where .request_lock(LeaseRequestKey::Variable); let _request_guard = request_lock.lock().await; if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { + self.schedule_variable_lease_refill(connection_manager); return Ok((lease, proportional_fee, lsp, false)); } + let (negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp) = + self.negotiate_variable_lease(connection_manager).await?; + let lease = self.consume_lease(&negotiated_lease.id).await?; + self.schedule_variable_lease_refill(connection_manager); + Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true)) + } + + async fn negotiate_variable_lease( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; let (cheapest_lsp, min_prop_fee_ppm_msat, min_opening_params) = all_offers @@ -402,8 +425,73 @@ where let negotiated_lease = self .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) .await?; - let lease = self.consume_lease(&negotiated_lease.id).await?; - Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true)) + Ok((negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp)) + } + + fn schedule_fixed_lease_refill( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) { + let client = Arc::clone(self); + let connection_manager = Arc::clone(connection_manager); + tokio::spawn(async move { + if let Err(error) = client.cache_fixed_lease(amount_msat, &connection_manager).await { + log_warn!(client.logger, "Failed refilling LSPS2 payment lease: {}", error); + } + }); + } + + fn schedule_variable_lease_refill( + self: &Arc, connection_manager: &Arc>, + ) { + let client = Arc::clone(self); + let connection_manager = Arc::clone(connection_manager); + tokio::spawn(async move { + if let Err(error) = client.cache_variable_lease(&connection_manager).await { + log_warn!(client.logger, "Failed refilling LSPS2 payment lease: {}", error); + } + }); + } + + async fn cache_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(), Error> { + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Fixed(amount_msat)); + let _request_guard = request_lock.lock().await; + if self + .lease_state + .lock() + .expect("lock") + .has_fixed_amount(amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat) + { + return Ok(()); + } + self.negotiate_fixed_lease(amount_msat, connection_manager).await?; + Ok(()) + } + + async fn cache_variable_lease( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(), Error> { + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Variable); + let _request_guard = request_lock.lock().await; + if self + .lease_state + .lock() + .expect("lock") + .has_variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat) + { + return Ok(()); + } + self.negotiate_variable_lease(connection_manager).await?; + Ok(()) } async fn gather_lsps2_offers( diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index d22d86ac70..5b314bc119 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -407,6 +407,21 @@ impl LSPS2LeaseState { self.leases.remove(&id).map(|lease| (lease, fee_msat)) } + pub(crate) fn has_fixed_amount(&self, amount_msat: u64, max_fee_msat: Option) -> bool { + self.leases + .values() + .filter(|lease| lease.payment_size_msat == Some(amount_msat)) + .filter(|lease| is_lease_usable(lease)) + .filter_map(|lease| { + compute_opening_fee( + amount_msat, + lease.params.min_fee_msat, + lease.params.proportional as u64, + ) + }) + .any(|fee_msat| max_fee_msat.map_or(true, |max| fee_msat <= max)) + } + /// Selects and removes a variable-amount lease in one operation. pub(crate) fn variable_amount( &mut self, max_total_fee_msat: Option, available_lsps: &[PublicKey], @@ -425,6 +440,14 @@ impl LSPS2LeaseState { self.leases.remove(&id).map(|lease| (lease, proportional_fee)) } + pub(crate) fn has_variable_amount(&self, max_total_fee_msat: Option) -> bool { + self.leases + .values() + .filter(|lease| lease.payment_size_msat.is_none()) + .filter(|lease| is_lease_usable(lease)) + .any(|lease| max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max)) + } + pub(crate) fn prune(&mut self) { self.leases.retain(|_, lease| is_lease_usable(lease)); } @@ -609,6 +632,20 @@ mod tests { assert_eq!(state.variable_amount(Some(50), &available_lsps).unwrap().0.id, variable.id); } + #[test] + fn detects_cached_leases_for_refill() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let fixed = lease(2, 46, 100, Some(1_000), valid_until); + let variable = lease(3, 47, 50, None, valid_until); + let state = LSPS2LeaseState::from_leases(vec![fixed, variable]); + + assert!(state.has_fixed_amount(1_000, Some(100))); + assert!(!state.has_fixed_amount(1_000, Some(99))); + assert!(!state.has_fixed_amount(2_000, None)); + assert!(state.has_variable_amount(Some(50))); + assert!(!state.has_variable_amount(Some(49))); + } + fn cache_target( id: LeaseCacheTargetId, absolute_expiry: Option, last_accessed: u64, ) -> LeaseCacheTarget { From 0dceaa0ae62ccf705e95f9433fba92caaedf7de2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 7 Aug 2026 11:30:46 +0200 Subject: [PATCH 32/41] f Track LSPS2 lease refill tasks Register payment-lease refills as cancellable node tasks so shutdown can reject late work and abort active negotiations. Keep only a weak runtime reference in the liquidity client to avoid extending its lifetime. Co-Authored-By: HAL 9000 --- src/builder.rs | 1 + src/liquidity/client/lsps2/mod.rs | 11 ++++++++--- src/liquidity/mod.rs | 8 ++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 803edbf18f..bd7c6561bb 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2119,6 +2119,7 @@ fn build_with_store_internal( Arc::clone(&keys_manager), Arc::clone(&tx_broadcaster), Arc::clone(&kv_store), + Arc::downgrade(&runtime), Arc::clone(&config), Arc::clone(&logger), ); diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 650dc3a488..f825339cae 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -8,7 +8,7 @@ use std::collections::{BTreeMap, HashMap}; use std::future::Future; use std::ops::Deref; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock, Weak}; use std::time::Duration; use bitcoin::secp256k1::{PublicKey, Secp256k1}; @@ -33,6 +33,7 @@ use crate::liquidity::{ 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::{ChannelManager, KeysManager, LiquidityManager}; use crate::{Config, Error}; @@ -107,6 +108,8 @@ where pub(crate) keys_manager: Arc, pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, pub(crate) liquidity_manager: Arc, + // Refill tasks are runtime-owned; keeping this weak avoids extending the runtime's lifetime. + pub(crate) runtime: Weak, pub(crate) config: Arc, pub(crate) logger: L, } @@ -431,9 +434,10 @@ where fn schedule_fixed_lease_refill( self: &Arc, amount_msat: u64, connection_manager: &Arc>, ) { + let Some(runtime) = self.runtime.upgrade() else { return }; let client = Arc::clone(self); let connection_manager = Arc::clone(connection_manager); - tokio::spawn(async move { + runtime.spawn_cancellable_background_task(async move { if let Err(error) = client.cache_fixed_lease(amount_msat, &connection_manager).await { log_warn!(client.logger, "Failed refilling LSPS2 payment lease: {}", error); } @@ -443,9 +447,10 @@ where fn schedule_variable_lease_refill( self: &Arc, connection_manager: &Arc>, ) { + let Some(runtime) = self.runtime.upgrade() else { return }; let client = Arc::clone(self); let connection_manager = Arc::clone(connection_manager); - tokio::spawn(async move { + runtime.spawn_cancellable_background_task(async move { if let Err(error) = client.cache_variable_lease(&connection_manager).await { log_warn!(client.logger, "Failed refilling LSPS2 payment lease: {}", error); } diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index f919829640..e4b312b4bd 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -13,7 +13,7 @@ pub(crate) mod service; use std::collections::hash_map::Entry; use std::collections::HashMap; use std::ops::Deref; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock, Weak}; use std::time::Duration; use bitcoin::secp256k1::PublicKey; @@ -217,6 +217,7 @@ where keys_manager: Arc, tx_broadcaster: Arc, kv_store: Arc, + runtime: Weak, config: Arc, logger: L, } @@ -227,7 +228,8 @@ where { pub(crate) fn new( wallet: Arc, channel_manager: Arc, keys_manager: Arc, - tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: L, + tx_broadcaster: Arc, kv_store: Arc, runtime: Weak, + config: Arc, logger: L, ) -> Self { let lsp_nodes = Vec::new(); let lsps2_service = None; @@ -239,6 +241,7 @@ where keys_manager, tx_broadcaster, kv_store, + runtime, config, logger, } @@ -366,6 +369,7 @@ where keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), liquidity_manager: Arc::clone(&liquidity_manager), + runtime: self.runtime, config: self.config.clone(), logger: self.logger.clone(), }), From fac14ef583d3cb9999151a1e00a3aed2661284c0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 15:18:47 +0200 Subject: [PATCH 33/41] lsps2: Restore cached payment leases After startup discovery, refill persisted fixed-amount and variable lease cache targets. Reuse valid leases and renegotiate only missing or stale entries so receiving can resume promptly after restart. Co-Authored-By: HAL 9000 --- src/lib.rs | 11 +++++++++++ src/liquidity/client/lsps2/mod.rs | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 21c3b2b218..07b909f1d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -815,6 +815,17 @@ impl Node { liquidity_logger, "LSP protocols discovery complete.", ); + if let Err(error) = liquidity_handler + .lsps2_client() + .refill_cached_leases(&discovery_cm) + .await + { + log_error!( + liquidity_logger, + "Failed scheduling LSPS2 lease cache refills: {}", + error + ); + } } } } diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index f825339cae..d8d23a7326 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -147,6 +147,23 @@ where self.cache_target_store.targets() } + pub(crate) async fn refill_cached_leases( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(), Error> { + self.prune_stale_cache_targets().await?; + for target in self.cache_targets() { + match target.id { + LeaseCacheTargetId::Fixed { amount_msat } => { + self.schedule_fixed_lease_refill(amount_msat, connection_manager); + }, + LeaseCacheTargetId::Variable => { + self.schedule_variable_lease_refill(connection_manager); + }, + } + } + Ok(()) + } + pub(crate) async fn prepare_invoice_response( self: Arc, request: JitInvoiceRequest, connection_manager: Arc>, ) -> Result { From eb468367c88ed2fa28b2353b93850770c996ce47 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:57:04 +0200 Subject: [PATCH 34/41] lsps2: Retry failed lease negotiation Retry transient LSPS2 request failures for foreground acquisition and background cache refills. Preserve immediate errors for fee limits and unavailable liquidity sources. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index d8d23a7326..a5c38d20e5 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -62,6 +62,11 @@ where } const DEFAULT_BOLT12_INVOICE_EXPIRY_SECS: u32 = 2 * 60 * 60; +const LEASE_NEGOTIATION_MAX_ATTEMPTS: usize = 3; + +fn should_retry_lease_negotiation(error: Error, attempt: usize) -> bool { + error == Error::LiquidityRequestFailed && attempt < LEASE_NEGOTIATION_MAX_ATTEMPTS +} pub(crate) struct JitInvoiceResponse { pub(crate) payment_metadata: BTreeMap>, @@ -317,6 +322,27 @@ where async fn negotiate_fixed_lease( self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { + let mut attempt = 1; + loop { + let result = self.negotiate_fixed_lease_once(amount_msat, connection_manager).await; + match result { + Err(error) if should_retry_lease_negotiation(error, attempt) => { + log_warn!( + self.logger, + "LSPS2 lease negotiation attempt {} failed, retrying: {}", + attempt, + error + ); + attempt += 1; + }, + result => return result, + } + } + } + + async fn negotiate_fixed_lease_once( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers @@ -403,6 +429,27 @@ where async fn negotiate_variable_lease( self: &Arc, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { + let mut attempt = 1; + loop { + let result = self.negotiate_variable_lease_once(connection_manager).await; + match result { + Err(error) if should_retry_lease_negotiation(error, attempt) => { + log_warn!( + self.logger, + "LSPS2 lease negotiation attempt {} failed, retrying: {}", + attempt, + error + ); + attempt += 1; + }, + result => return result, + } + } + } + + async fn negotiate_variable_lease_once( + self: &Arc, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; @@ -990,6 +1037,15 @@ mod tests { assert_eq!(result, Err(())); assert_eq!(*restored.lock().unwrap(), Some(42)); } + + #[test] + fn lease_negotiation_retries_only_transient_failures() { + assert!(should_retry_lease_negotiation(Error::LiquidityRequestFailed, 1)); + assert!(should_retry_lease_negotiation(Error::LiquidityRequestFailed, 2)); + assert!(!should_retry_lease_negotiation(Error::LiquidityRequestFailed, 3)); + assert!(!should_retry_lease_negotiation(Error::LiquidityFeeTooHigh, 1)); + assert!(!should_retry_lease_negotiation(Error::LiquiditySourceUnavailable, 1)); + } } pub(crate) mod router; From 766083badd2bc4e676ac2addb83c2a6052813ceb Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:31:07 +0200 Subject: [PATCH 35/41] lsps2: Fail over lease negotiation Try the next eligible provider when a selected LSP rejects or times out during the buy request. Skip remaining fee-menu entries from a failed provider while preserving bounded whole-round retries. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 187 +++++++++++++++++++++++------- 1 file changed, 143 insertions(+), 44 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index a5c38d20e5..c481775d9d 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -5,8 +5,9 @@ // 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::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::future::Future; +use std::hash::Hash; use std::ops::Deref; use std::sync::{Arc, Mutex, RwLock, Weak}; use std::time::Duration; @@ -73,6 +74,33 @@ pub(crate) struct JitInvoiceResponse { pub(crate) allow_mpp: bool, } +async fn try_lease_candidates( + candidates: Vec, candidate_key: KF, mut attempt: AF, +) -> Result +where + K: Copy + Eq + Hash, + KF: Fn(&T) -> K, + AF: FnMut(T) -> Fut, + Fut: Future>, +{ + let mut failed_candidates = HashSet::new(); + let mut last_error = None; + for candidate in candidates { + let key = candidate_key(&candidate); + if failed_candidates.contains(&key) { + continue; + } + match attempt(candidate).await { + Ok(result) => return Ok(result), + Err(error) => { + failed_candidates.insert(key); + last_error = Some(error); + }, + } + } + Err(last_error.expect("lease candidates are non-empty")) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum JitInvoiceRequest { Fixed { amount_msat: u64, absolute_expiry: Option }, @@ -345,7 +373,7 @@ where self: &Arc, amount_msat: u64, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; - let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers + let mut candidates = all_offers .into_iter() .flat_map(|(lsp, resp)| { resp.opening_fee_params_menu @@ -369,11 +397,12 @@ where .map(|fee| (lsp, fee, params)) } }) - .min_by_key(|(_, fee, _)| *fee) - .ok_or_else(|| { - log_error!(self.logger, "Failed to handle response from liquidity service",); - Error::LiquidityRequestFailed - })?; + .collect::>(); + candidates.sort_unstable_by_key(|(_, fee, _)| *fee); + let min_total_fee_msat = candidates.first().map(|(_, fee, _)| *fee).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) = self.config.lsps2_max_total_lsp_fee_limit_msat { if min_total_fee_msat > max_total_lsp_fee_limit_msat { @@ -383,23 +412,44 @@ where ); return Err(Error::LiquidityFeeTooHigh); } + candidates.retain(|(_, fee, _)| *fee <= max_total_lsp_fee_limit_msat); } - log_debug!( - self.logger, - "Choosing cheapest liquidity offer from LSP {}, will pay {}msat in total LSP fees", - cheapest_lsp.node_id, - min_total_fee_msat - ); - - let negotiated_lease = self - .lsps2_send_buy_request( - Some(amount_msat), - min_opening_params, - Some(&cheapest_lsp.node_id), - ) - .await?; - Ok((negotiated_lease, min_total_fee_msat, cheapest_lsp)) + try_lease_candidates( + candidates, + |(lsp, _, _)| lsp.node_id, + |(lsp, total_fee_msat, opening_params)| { + let client = Arc::clone(self); + async move { + log_debug!( + client.logger, + "Choosing liquidity offer from LSP {}, will pay {}msat in total LSP fees", + lsp.node_id, + total_fee_msat + ); + match client + .lsps2_send_buy_request( + Some(amount_msat), + opening_params, + Some(&lsp.node_id), + ) + .await + { + Ok(lease) => Ok((lease, total_fee_msat, lsp)), + Err(error) => { + log_warn!( + client.logger, + "Failed negotiating LSPS2 payment lease with LSP {}, trying the next candidate: {}", + lsp.node_id, + error + ); + Err(error) + }, + } + } + }, + ) + .await } async fn acquire_variable_lease( @@ -453,7 +503,7 @@ where ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; - let (cheapest_lsp, min_prop_fee_ppm_msat, min_opening_params) = all_offers + let mut candidates = all_offers .into_iter() .flat_map(|(lsp, resp)| { resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params)) @@ -470,29 +520,52 @@ where rejected_for_fee |= !allowed; allowed }) - .min_by_key(|(_, ppm, _)| *ppm) - .ok_or_else(|| { - if rejected_for_fee { - log_error!( - self.logger, - "Failed to request inbound JIT channel as all LSP offers exceed our configured fee limit" - ); - return Error::LiquidityFeeTooHigh; - } + .collect::>(); + candidates.sort_unstable_by_key(|(_, ppm, _)| *ppm); + if candidates.is_empty() { + return Err(if rejected_for_fee { + log_error!( + self.logger, + "Failed to request inbound JIT channel as all LSP offers exceed our configured fee limit" + ); + Error::LiquidityFeeTooHigh + } else { log_error!(self.logger, "Failed to handle response from liquidity service",); Error::LiquidityRequestFailed - })?; - log_debug!( - self.logger, - "Choosing cheapest liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees", - cheapest_lsp.node_id, - min_prop_fee_ppm_msat - ); - - let negotiated_lease = self - .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) - .await?; - Ok((negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp)) + }); + } + + try_lease_candidates( + candidates, + |(lsp, _, _)| lsp.node_id, + |(lsp, proportional_fee_ppm_msat, opening_params)| { + let client = Arc::clone(self); + async move { + log_debug!( + client.logger, + "Choosing liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees", + lsp.node_id, + proportional_fee_ppm_msat + ); + match client + .lsps2_send_buy_request(None, opening_params, Some(&lsp.node_id)) + .await + { + Ok(lease) => Ok((lease, proportional_fee_ppm_msat, lsp)), + Err(error) => { + log_warn!( + client.logger, + "Failed negotiating LSPS2 payment lease with LSP {}, trying the next candidate: {}", + lsp.node_id, + error + ); + Err(error) + }, + } + } + }, + ) + .await } fn schedule_fixed_lease_refill( @@ -1046,6 +1119,32 @@ mod tests { assert!(!should_retry_lease_negotiation(Error::LiquidityFeeTooHigh, 1)); assert!(!should_retry_lease_negotiation(Error::LiquiditySourceUnavailable, 1)); } + + #[tokio::test] + async fn lease_negotiation_fails_over_between_lsps() { + let candidates = vec![(1, 10), (1, 20), (2, 30)]; + let attempted_lsps = Arc::new(Mutex::new(Vec::new())); + let attempted_lsps_ref = Arc::clone(&attempted_lsps); + let result = try_lease_candidates( + candidates, + |candidate| candidate.0, + move |candidate| { + let attempted_lsps = Arc::clone(&attempted_lsps_ref); + async move { + attempted_lsps.lock().unwrap().push(candidate.0); + if candidate.0 == 1 { + Err(()) + } else { + Ok(candidate.1) + } + } + }, + ) + .await; + + assert_eq!(result, Ok(30)); + assert_eq!(*attempted_lsps.lock().unwrap(), vec![1, 2]); + } } pub(crate) mod router; From 13d61a1cfd82aa81c09e7e9314e42deb7108bf65 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 15:46:02 +0200 Subject: [PATCH 36/41] lsps2: Match variable leases to payments Use resolved BOLT12 amounts to reject variable leases outside the payment range or total fee policy. Keep BOLT11 selection amountless until payment, while recording the exact BOLT12 fee limit. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 119 ++++++++++++++++------------ src/liquidity/client/lsps2/state.rs | 85 +++++++++++++++++--- 2 files changed, 142 insertions(+), 62 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index c481775d9d..f9baaa66d9 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -221,18 +221,17 @@ where }, ) }, - JitInvoiceRequest::Variable { .. } => { - let (lease, proportional_fee, _, _) = - self.acquire_variable_lease(&connection_manager).await?; + JitInvoiceRequest::Variable { amount_msat, .. } => { + // A BOLT12 invoice request has already resolved the payment amount. Use it to avoid + // consuming a cached variable lease outside its advertised range, and record the exact + // fee for that amount so payment validation need not accept the broader node-wide cap. + let (lease, total_fee_msat, _, _) = + self.acquire_variable_lease(Some(amount_msat), &connection_manager).await?; ( lease, LSPS2Parameters { - max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, - max_proportional_opening_fee_ppm_msat: self - .config - .lsps2_max_total_lsp_fee_limit_msat - .is_none() - .then_some(proportional_fee), + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, }, ) }, @@ -294,7 +293,7 @@ where payment_hash: Option, connection_manager: Arc>, ) -> Result<(Bolt11Invoice, LspConfig), Error> { let (lease, proportional_fee, lsp, was_negotiated) = - self.acquire_variable_lease(&connection_manager).await?; + self.acquire_variable_lease(None, &connection_manager).await?; let lsps2_parameters = LSPS2Parameters { max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, max_proportional_opening_fee_ppm_msat: self @@ -453,11 +452,11 @@ where } async fn acquire_variable_lease( - self: &Arc, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { - if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { + if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? { self.schedule_variable_lease_refill(connection_manager); - return Ok((lease, proportional_fee, lsp, false)); + return Ok((lease, fee, lsp, false)); } let request_lock = self .pending_lease_request_state @@ -465,24 +464,24 @@ where .expect("lock") .request_lock(LeaseRequestKey::Variable); let _request_guard = request_lock.lock().await; - if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { + if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? { self.schedule_variable_lease_refill(connection_manager); - return Ok((lease, proportional_fee, lsp, false)); + return Ok((lease, fee, lsp, false)); } - let (negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp) = - self.negotiate_variable_lease(connection_manager).await?; + let (negotiated_lease, fee, cheapest_lsp) = + self.negotiate_variable_lease(amount_msat, connection_manager).await?; let lease = self.consume_lease(&negotiated_lease.id).await?; self.schedule_variable_lease_refill(connection_manager); - Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true)) + Ok((lease, fee, cheapest_lsp, true)) } async fn negotiate_variable_lease( - self: &Arc, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let mut attempt = 1; loop { - let result = self.negotiate_variable_lease_once(connection_manager).await; + let result = self.negotiate_variable_lease_once(amount_msat, connection_manager).await; match result { Err(error) if should_retry_lease_negotiation(error, attempt) => { log_warn!( @@ -499,7 +498,7 @@ where } async fn negotiate_variable_lease_once( - self: &Arc, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; @@ -508,20 +507,34 @@ where .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) - }) - .filter(|(_, _, params)| { - let allowed = self + .filter_map(|(lsp, params)| { + // BOLT12 supplies a resolved amount here, while a BOLT11 zero-amount invoice does not. + // In the former case, only negotiate parameters that can carry that exact payment and + // compare providers by the total fee the payment would actually incur. + let selection_fee = if let Some(amount_msat) = amount_msat { + if amount_msat < params.min_payment_size_msat + || amount_msat > params.max_payment_size_msat + { + return None; + } + compute_opening_fee( + amount_msat, + params.min_fee_msat, + params.proportional as u64, + )? + } else { + params.proportional as u64 + }; + let fee_for_limit = amount_msat.map_or(params.min_fee_msat, |_| selection_fee); + let fee_allowed = self .config .lsps2_max_total_lsp_fee_limit_msat - .map_or(true, |limit| params.min_fee_msat <= limit); - rejected_for_fee |= !allowed; - allowed + .map_or(true, |limit| fee_for_limit <= limit); + rejected_for_fee |= !fee_allowed; + fee_allowed.then_some((lsp, selection_fee, params)) }) .collect::>(); - candidates.sort_unstable_by_key(|(_, ppm, _)| *ppm); + candidates.sort_unstable_by_key(|(_, fee, _)| *fee); if candidates.is_empty() { return Err(if rejected_for_fee { log_error!( @@ -538,20 +551,29 @@ where try_lease_candidates( candidates, |(lsp, _, _)| lsp.node_id, - |(lsp, proportional_fee_ppm_msat, opening_params)| { + |(lsp, fee, opening_params)| { let client = Arc::clone(self); async move { - log_debug!( - client.logger, - "Choosing liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees", - lsp.node_id, - proportional_fee_ppm_msat - ); + if amount_msat.is_some() { + log_debug!( + client.logger, + "Choosing liquidity offer from LSP {}, will pay {}msat in total LSP fees", + lsp.node_id, + fee + ); + } else { + log_debug!( + client.logger, + "Choosing liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees", + lsp.node_id, + fee + ); + } match client .lsps2_send_buy_request(None, opening_params, Some(&lsp.node_id)) .await { - Ok(lease) => Ok((lease, proportional_fee_ppm_msat, lsp)), + Ok(lease) => Ok((lease, fee, lsp)), Err(error) => { log_warn!( client.logger, @@ -628,11 +650,11 @@ where .lease_state .lock() .expect("lock") - .has_variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat) + .has_variable_amount(None, self.config.lsps2_max_total_lsp_fee_limit_msat) { return Ok(()); } - self.negotiate_variable_lease(connection_manager).await?; + self.negotiate_variable_lease(None, connection_manager).await?; Ok(()) } @@ -841,16 +863,15 @@ where } async fn take_cached_variable_lease( - &self, + &self, amount_msat: Option, ) -> Result, Error> { let lsps2_nodes = self.get_lsps2_nodes().await?; let available_lsps = lsps2_nodes.iter().map(|lsp| lsp.node_id).collect::>(); - let Some((lease, proportional_fee)) = self - .lease_state - .lock() - .expect("lock") - .variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat, &available_lsps) - else { + let Some((lease, fee)) = self.lease_state.lock().expect("lock").variable_amount( + amount_msat, + self.config.lsps2_max_total_lsp_fee_limit_msat, + &available_lsps, + ) else { return Ok(None); }; let lsp = lsps2_nodes @@ -858,7 +879,7 @@ where .find(|lsp| lsp.node_id == lease.id.lsp_node_id) .expect("lease was selected from available LSPs"); let lease = self.consume_selected_lease(lease).await?; - Ok(Some((lease, proportional_fee, lsp))) + Ok(Some((lease, fee, lsp))) } fn lsps2_create_jit_invoice( diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 5b314bc119..988a4dd40b 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -424,28 +424,64 @@ impl LSPS2LeaseState { /// Selects and removes a variable-amount lease in one operation. pub(crate) fn variable_amount( - &mut self, max_total_fee_msat: Option, available_lsps: &[PublicKey], + &mut self, amount_msat: Option, max_total_fee_msat: Option, + available_lsps: &[PublicKey], ) -> Option<(PaymentLease, u64)> { - let (id, proportional_fee) = self + let (id, selection_fee) = self .leases .iter() .filter(|(id, _)| available_lsps.contains(&id.lsp_node_id)) .filter(|(_, lease)| lease.payment_size_msat.is_none()) .filter(|(_, lease)| is_lease_usable(lease)) - .filter(|(_, lease)| { - max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max) + .filter_map(|(id, lease)| { + let selection_fee = if let Some(amount_msat) = amount_msat { + if amount_msat < lease.params.min_payment_size_msat + || amount_msat > lease.params.max_payment_size_msat + { + return None; + } + compute_opening_fee( + amount_msat, + lease.params.min_fee_msat, + lease.params.proportional as u64, + )? + } else { + lease.params.proportional as u64 + }; + let fee_for_limit = + amount_msat.map_or(lease.params.min_fee_msat, |_| selection_fee); + max_total_fee_msat + .map_or(true, |max| fee_for_limit <= max) + .then_some((*id, selection_fee)) }) - .map(|(id, lease)| (*id, lease.params.proportional as u64)) .min_by_key(|(_, fee)| *fee)?; - self.leases.remove(&id).map(|lease| (lease, proportional_fee)) + self.leases.remove(&id).map(|lease| (lease, selection_fee)) } - pub(crate) fn has_variable_amount(&self, max_total_fee_msat: Option) -> bool { + pub(crate) fn has_variable_amount( + &self, amount_msat: Option, max_total_fee_msat: Option, + ) -> bool { self.leases .values() .filter(|lease| lease.payment_size_msat.is_none()) .filter(|lease| is_lease_usable(lease)) - .any(|lease| max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max)) + .filter_map(|lease| { + if let Some(amount_msat) = amount_msat { + if amount_msat < lease.params.min_payment_size_msat + || amount_msat > lease.params.max_payment_size_msat + { + return None; + } + compute_opening_fee( + amount_msat, + lease.params.min_fee_msat, + lease.params.proportional as u64, + ) + } else { + Some(lease.params.min_fee_msat) + } + }) + .any(|fee_msat| max_total_fee_msat.map_or(true, |max| fee_msat <= max)) } pub(crate) fn prune(&mut self) { @@ -626,10 +662,33 @@ mod tests { let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; let variable = lease(2, 46, 50, None, valid_until); let mut state = LSPS2LeaseState::from_leases(vec![variable.clone()]); - let available_lsps = [variable.id.lsp_node_id]; - assert!(state.variable_amount(Some(49), &available_lsps).is_none()); - assert_eq!(state.variable_amount(Some(50), &available_lsps).unwrap().0.id, variable.id); + assert!(state.variable_amount(None, Some(49), &available_lsps).is_none()); + assert_eq!( + state.variable_amount(None, Some(50), &available_lsps).unwrap().0.id, + variable.id + ); + } + + #[test] + fn variable_lease_matches_resolved_payment_amount() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let mut incompatible = lease(2, 47, 1, None, valid_until); + incompatible.params.max_payment_size_msat = 1_000; + let mut compatible = lease(3, 48, 2, None, valid_until); + compatible.params.proportional = 500_000; + compatible.params.max_payment_size_msat = 3_000; + let available_lsps = [incompatible.id.lsp_node_id, compatible.id.lsp_node_id]; + let mut state = LSPS2LeaseState::from_leases(vec![incompatible, compatible.clone()]); + + assert!(state.variable_amount(Some(2_000), Some(999), &available_lsps).is_none()); + let (selected, total_fee_msat) = + state.variable_amount(Some(2_000), None, &available_lsps).unwrap(); + assert_eq!( + selected.id, compatible.id, + "selected variable lease must accept the resolved payment amount" + ); + assert_eq!(total_fee_msat, 1_000); } #[test] @@ -642,8 +701,8 @@ mod tests { assert!(state.has_fixed_amount(1_000, Some(100))); assert!(!state.has_fixed_amount(1_000, Some(99))); assert!(!state.has_fixed_amount(2_000, None)); - assert!(state.has_variable_amount(Some(50))); - assert!(!state.has_variable_amount(Some(49))); + assert!(state.has_variable_amount(None, Some(50))); + assert!(!state.has_variable_amount(None, Some(49))); } fn cache_target( From 87f38a431bb9d27c13cd354396ed8aa816c15d9f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 29 Jul 2026 14:37:54 +0200 Subject: [PATCH 37/41] f Separate cache and invoice validity Treat the one-day margin as a cache policy while allowing freshly negotiated leases to cover only the invoice being built. Keep on-demand leases exclusively owned and persist only leases intended for reuse. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 178 ++++++++++++++++++++-------- src/liquidity/client/lsps2/state.rs | 135 ++++++++++++++------- src/liquidity/mod.rs | 6 +- 3 files changed, 223 insertions(+), 96 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index f9baaa66d9..ab980013ee 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -40,8 +40,9 @@ use crate::{Config, Error}; use self::router::LSPS2LeaseParameters; use self::state::{ - now_secs, LSPS2LeaseState, LeaseCacheTarget, LeaseCacheTargetId, LeaseCacheTargetStore, - LeaseRequestKey, PaymentLease, PaymentLeaseId, PaymentLeaseStore, PendingLeaseRequestState, + is_lease_cacheable, is_lease_valid_for, now_secs, LSPS2LeaseState, LeaseCacheTarget, + LeaseCacheTargetId, LeaseCacheTargetStore, LeaseRequestKey, PaymentLease, PaymentLeaseId, + PaymentLeaseStore, PendingLeaseRequestState, MIN_CACHED_LEASE_REMAINING_SECS, }; async fn consume_after_persisted_removal( @@ -69,6 +70,14 @@ fn should_retry_lease_negotiation(error: Error, attempt: usize) -> bool { error == Error::LiquidityRequestFailed && attempt < LEASE_NEGOTIATION_MAX_ATTEMPTS } +fn opening_fee_params_valid_for(params: &LSPS2OpeningFeeParams, min_remaining_secs: u64) -> bool { + let Ok(valid_until) = u64::try_from(params.valid_until.0.timestamp()) else { + return false; + }; + let now = now_secs(); + valid_until > now && valid_until.saturating_sub(now) >= min_remaining_secs +} + pub(crate) struct JitInvoiceResponse { pub(crate) payment_metadata: BTreeMap>, pub(crate) allow_mpp: bool, @@ -154,7 +163,7 @@ where pub(crate) async fn prune_stale_leases(&self) -> Result<(), Error> { let stale_ids = self .lease_store - .list_filter(|lease| !state::is_lease_usable(lease)) + .list_filter(|lease| !is_lease_cacheable(lease)) .into_iter() .map(|lease| lease.id) .collect::>(); @@ -211,8 +220,13 @@ where let allow_mpp = request.allow_mpp(); let (lease, fee_parameters) = match request { JitInvoiceRequest::Fixed { amount_msat, .. } => { - let (lease, total_fee_msat, _, _) = - self.acquire_fixed_lease(amount_msat, &connection_manager).await?; + let (lease, total_fee_msat, _, _) = self + .acquire_fixed_lease( + amount_msat, + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, + &connection_manager, + ) + .await?; ( lease, LSPS2Parameters { @@ -225,8 +239,13 @@ where // A BOLT12 invoice request has already resolved the payment amount. Use it to avoid // consuming a cached variable lease outside its advertised range, and record the exact // fee for that amount so payment validation need not accept the broader node-wide cap. - let (lease, total_fee_msat, _, _) = - self.acquire_variable_lease(Some(amount_msat), &connection_manager).await?; + let (lease, total_fee_msat, _, _) = self + .acquire_variable_lease( + Some(amount_msat), + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, + &connection_manager, + ) + .await?; ( lease, LSPS2Parameters { @@ -239,8 +258,7 @@ where // The offers flow currently uses its two-hour default invoice expiry. Never publish a JIT // path whose intercept SCID may expire before the invoice does. - if lease.valid_until.saturating_sub(now_secs()) < DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64 - { + if !is_lease_valid_for(&lease, DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64) { return Err(Error::LiquidityRequestFailed); } let lease_parameters = LSPS2LeaseParameters { @@ -266,7 +284,7 @@ where connection_manager: Arc>, ) -> Result<(Bolt11Invoice, LspConfig), Error> { let (lease, total_fee_msat, lsp, was_negotiated) = - self.acquire_fixed_lease(amount_msat, &connection_manager).await?; + self.acquire_fixed_lease(amount_msat, expiry_secs.into(), &connection_manager).await?; let lsps2_parameters = LSPS2Parameters { max_total_opening_fee_msat: Some(total_fee_msat), max_proportional_opening_fee_ppm_msat: None, @@ -293,7 +311,7 @@ where payment_hash: Option, connection_manager: Arc>, ) -> Result<(Bolt11Invoice, LspConfig), Error> { let (lease, proportional_fee, lsp, was_negotiated) = - self.acquire_variable_lease(None, &connection_manager).await?; + self.acquire_variable_lease(None, expiry_secs.into(), &connection_manager).await?; let lsps2_parameters = LSPS2Parameters { max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, max_proportional_opening_fee_ppm_msat: self @@ -319,10 +337,11 @@ where } async fn acquire_fixed_lease( - self: &Arc, amount_msat: u64, connection_manager: &Arc>, + self: &Arc, amount_msat: u64, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { if let Some((lease, total_fee_msat, lsp)) = - self.take_cached_fixed_lease(amount_msat).await? + self.take_cached_fixed_lease(amount_msat, min_remaining_secs).await? { self.schedule_fixed_lease_refill(amount_msat, connection_manager); return Ok((lease, total_fee_msat, lsp, false)); @@ -334,25 +353,27 @@ where .request_lock(LeaseRequestKey::Fixed(amount_msat)); let _request_guard = request_lock.lock().await; if let Some((lease, total_fee_msat, lsp)) = - self.take_cached_fixed_lease(amount_msat).await? + self.take_cached_fixed_lease(amount_msat, min_remaining_secs).await? { self.schedule_fixed_lease_refill(amount_msat, connection_manager); return Ok((lease, total_fee_msat, lsp, false)); } - let (negotiated_lease, min_total_fee_msat, cheapest_lsp) = - self.negotiate_fixed_lease(amount_msat, connection_manager).await?; - let lease = self.consume_lease(&negotiated_lease.id).await?; + let (lease, min_total_fee_msat, cheapest_lsp) = + self.negotiate_fixed_lease(amount_msat, min_remaining_secs, connection_manager).await?; self.schedule_fixed_lease_refill(amount_msat, connection_manager); Ok((lease, min_total_fee_msat, cheapest_lsp, true)) } async fn negotiate_fixed_lease( - self: &Arc, amount_msat: u64, connection_manager: &Arc>, + self: &Arc, amount_msat: u64, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let mut attempt = 1; loop { - let result = self.negotiate_fixed_lease_once(amount_msat, connection_manager).await; + let result = self + .negotiate_fixed_lease_once(amount_msat, min_remaining_secs, connection_manager) + .await; match result { Err(error) if should_retry_lease_negotiation(error, attempt) => { log_warn!( @@ -369,7 +390,8 @@ where } async fn negotiate_fixed_lease_once( - self: &Arc, amount_msat: u64, connection_manager: &Arc>, + self: &Arc, amount_msat: u64, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut candidates = all_offers @@ -380,6 +402,14 @@ where .map(move |params| (lsp.clone(), params)) }) .filter_map(|(lsp, params)| { + if !opening_fee_params_valid_for(¶ms, min_remaining_secs) { + log_debug!( + self.logger, + "Skipping LSP {}'s JIT offer as it expires before the requested invoice", + lsp.node_id + ); + return None; + } if amount_msat < params.min_payment_size_msat || amount_msat > params.max_payment_size_msat { @@ -431,6 +461,7 @@ where Some(amount_msat), opening_params, Some(&lsp.node_id), + min_remaining_secs, ) .await { @@ -452,9 +483,12 @@ where } async fn acquire_variable_lease( - self: &Arc, amount_msat: Option, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { - if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? { + if let Some((lease, fee, lsp)) = + self.take_cached_variable_lease(amount_msat, min_remaining_secs).await? + { self.schedule_variable_lease_refill(connection_manager); return Ok((lease, fee, lsp, false)); } @@ -464,24 +498,29 @@ where .expect("lock") .request_lock(LeaseRequestKey::Variable); let _request_guard = request_lock.lock().await; - if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? { + if let Some((lease, fee, lsp)) = + self.take_cached_variable_lease(amount_msat, min_remaining_secs).await? + { self.schedule_variable_lease_refill(connection_manager); return Ok((lease, fee, lsp, false)); } - let (negotiated_lease, fee, cheapest_lsp) = - self.negotiate_variable_lease(amount_msat, connection_manager).await?; - let lease = self.consume_lease(&negotiated_lease.id).await?; + let (lease, fee, cheapest_lsp) = self + .negotiate_variable_lease(amount_msat, min_remaining_secs, connection_manager) + .await?; self.schedule_variable_lease_refill(connection_manager); Ok((lease, fee, cheapest_lsp, true)) } async fn negotiate_variable_lease( - self: &Arc, amount_msat: Option, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let mut attempt = 1; loop { - let result = self.negotiate_variable_lease_once(amount_msat, connection_manager).await; + let result = self + .negotiate_variable_lease_once(amount_msat, min_remaining_secs, connection_manager) + .await; match result { Err(error) if should_retry_lease_negotiation(error, attempt) => { log_warn!( @@ -498,7 +537,8 @@ where } async fn negotiate_variable_lease_once( - self: &Arc, amount_msat: Option, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; @@ -508,6 +548,14 @@ where resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params)) }) .filter_map(|(lsp, params)| { + if !opening_fee_params_valid_for(¶ms, min_remaining_secs) { + log_debug!( + self.logger, + "Skipping LSP {}'s JIT offer as it expires before the requested invoice", + lsp.node_id + ); + return None; + } // BOLT12 supplies a resolved amount here, while a BOLT11 zero-amount invoice does not. // In the former case, only negotiate parameters that can carry that exact payment and // compare providers by the total fee the payment would actually incur. @@ -570,7 +618,12 @@ where ); } match client - .lsps2_send_buy_request(None, opening_params, Some(&lsp.node_id)) + .lsps2_send_buy_request( + None, + opening_params, + Some(&lsp.node_id), + min_remaining_secs, + ) .await { Ok(lease) => Ok((lease, fee, lsp)), @@ -633,8 +686,10 @@ where { return Ok(()); } - self.negotiate_fixed_lease(amount_msat, connection_manager).await?; - Ok(()) + let (lease, _, _) = self + .negotiate_fixed_lease(amount_msat, MIN_CACHED_LEASE_REMAINING_SECS, connection_manager) + .await?; + self.store_cached_lease(lease).await } async fn cache_variable_lease( @@ -654,7 +709,17 @@ where { return Ok(()); } - self.negotiate_variable_lease(None, connection_manager).await?; + let (lease, _, _) = self + .negotiate_variable_lease(None, MIN_CACHED_LEASE_REMAINING_SECS, connection_manager) + .await?; + self.store_cached_lease(lease).await + } + + async fn store_cached_lease(&self, lease: PaymentLease) -> Result<(), Error> { + // Only leases intended for reuse are persisted and made visible to concurrent receive + // requests. A freshly negotiated on-demand lease remains exclusively owned by its caller. + self.lease_store.insert(lease.clone()).await?; + self.lease_state.lock().expect("lock").insert(lease); Ok(()) } @@ -746,7 +811,7 @@ where async fn lsps2_send_buy_request( &self, amount_msat: Option, opening_fee_params: LSPS2OpeningFeeParams, - node_id: Option<&PublicKey>, + node_id: Option<&PublicKey>, min_remaining_secs: u64, ) -> Result { let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) .ok_or(Error::LiquiditySourceUnavailable)?; @@ -812,21 +877,16 @@ where payment_size_msat: amount_msat, valid_until, }; - self.lease_store.insert(lease.clone()).await?; - self.lease_state.lock().expect("lock").insert(lease.clone()); + if !is_lease_valid_for(&lease, min_remaining_secs) { + log_warn!( + self.logger, + "LSPS2 payment lease expired before it could cover the requested invoice" + ); + return Err(Error::LiquidityRequestFailed); + } Ok(lease) } - async fn consume_lease(&self, id: &PaymentLeaseId) -> Result { - let lease = self - .lease_state - .lock() - .expect("lock") - .take_valid(id) - .ok_or(Error::LiquidityRequestFailed)?; - self.consume_selected_lease(lease).await - } - async fn consume_selected_lease(&self, lease: PaymentLease) -> Result { let lease_store = Arc::clone(&self.lease_store); // Selection has already removed the lease from the shared state so no other payment can @@ -843,13 +903,15 @@ where } async fn take_cached_fixed_lease( - &self, amount_msat: u64, + &self, amount_msat: u64, min_remaining_secs: u64, ) -> Result, Error> { + let min_remaining_secs = min_remaining_secs.max(MIN_CACHED_LEASE_REMAINING_SECS); let lsps2_nodes = self.get_lsps2_nodes().await?; let available_lsps = lsps2_nodes.iter().map(|lsp| lsp.node_id).collect::>(); let Some((lease, fee_msat)) = self.lease_state.lock().expect("lock").fixed_amount( amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat, + min_remaining_secs, &available_lsps, ) else { return Ok(None); @@ -863,13 +925,15 @@ where } async fn take_cached_variable_lease( - &self, amount_msat: Option, + &self, amount_msat: Option, min_remaining_secs: u64, ) -> Result, Error> { + let min_remaining_secs = min_remaining_secs.max(MIN_CACHED_LEASE_REMAINING_SECS); let lsps2_nodes = self.get_lsps2_nodes().await?; let available_lsps = lsps2_nodes.iter().map(|lsp| lsp.node_id).collect::>(); let Some((lease, fee)) = self.lease_state.lock().expect("lock").variable_amount( amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat, + min_remaining_secs, &available_lsps, ) else { return Ok(None); @@ -1141,6 +1205,24 @@ mod tests { assert!(!should_retry_lease_negotiation(Error::LiquiditySourceUnavailable, 1)); } + #[test] + fn opening_params_must_cover_invoice_lifetime() { + let valid_until = chrono::Utc::now() + chrono::Duration::hours(3); + let params = LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: valid_until.to_rfc3339().parse().unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 2_000, + promise: String::new(), + }; + + assert!(opening_fee_params_valid_for(¶ms, 2 * 60 * 60)); + assert!(!opening_fee_params_valid_for(¶ms, 4 * 60 * 60)); + } + #[tokio::test] async fn lease_negotiation_fails_over_between_lsps() { let candidates = vec![(1, 10), (1, 20), (2, 30)]; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 988a4dd40b..66f3688ed8 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -21,7 +21,7 @@ use crate::logger::{log_error, LdkLogger}; use crate::types::DynStore; use crate::Error; -pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; +pub(crate) const MIN_CACHED_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; pub(crate) type PaymentLeaseStore = DataStore; @@ -376,24 +376,17 @@ impl LSPS2LeaseState { self.leases.insert(lease.id, lease); } - pub(crate) fn take_valid(&mut self, id: &PaymentLeaseId) -> Option { - if self.leases.get(id).map_or(false, is_lease_usable) { - self.leases.remove(id) - } else { - None - } - } - /// Selects and removes a fixed-amount lease in one operation. pub(crate) fn fixed_amount( - &mut self, amount_msat: u64, max_fee_msat: Option, available_lsps: &[PublicKey], + &mut self, amount_msat: u64, max_fee_msat: Option, min_remaining_secs: u64, + available_lsps: &[PublicKey], ) -> Option<(PaymentLease, u64)> { let (id, fee_msat) = self .leases .iter() .filter(|(id, _)| available_lsps.contains(&id.lsp_node_id)) .filter(|(_, lease)| lease.payment_size_msat == Some(amount_msat)) - .filter(|(_, lease)| is_lease_usable(lease)) + .filter(|(_, lease)| is_lease_valid_for(lease, min_remaining_secs)) .filter_map(|(id, lease)| { compute_opening_fee( amount_msat, @@ -411,7 +404,7 @@ impl LSPS2LeaseState { self.leases .values() .filter(|lease| lease.payment_size_msat == Some(amount_msat)) - .filter(|lease| is_lease_usable(lease)) + .filter(|lease| is_lease_cacheable(lease)) .filter_map(|lease| { compute_opening_fee( amount_msat, @@ -425,14 +418,14 @@ impl LSPS2LeaseState { /// Selects and removes a variable-amount lease in one operation. pub(crate) fn variable_amount( &mut self, amount_msat: Option, max_total_fee_msat: Option, - available_lsps: &[PublicKey], + min_remaining_secs: u64, available_lsps: &[PublicKey], ) -> Option<(PaymentLease, u64)> { let (id, selection_fee) = self .leases .iter() .filter(|(id, _)| available_lsps.contains(&id.lsp_node_id)) .filter(|(_, lease)| lease.payment_size_msat.is_none()) - .filter(|(_, lease)| is_lease_usable(lease)) + .filter(|(_, lease)| is_lease_valid_for(lease, min_remaining_secs)) .filter_map(|(id, lease)| { let selection_fee = if let Some(amount_msat) = amount_msat { if amount_msat < lease.params.min_payment_size_msat @@ -464,7 +457,7 @@ impl LSPS2LeaseState { self.leases .values() .filter(|lease| lease.payment_size_msat.is_none()) - .filter(|lease| is_lease_usable(lease)) + .filter(|lease| is_lease_cacheable(lease)) .filter_map(|lease| { if let Some(amount_msat) = amount_msat { if amount_msat < lease.params.min_payment_size_msat @@ -485,19 +478,26 @@ impl LSPS2LeaseState { } pub(crate) fn prune(&mut self) { - self.leases.retain(|_, lease| is_lease_usable(lease)); + self.leases.retain(|_, lease| is_lease_cacheable(lease)); } } pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { - checked_cltv_expiry_delta(lease.cltv_expiry_delta).is_some() - && lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS + checked_cltv_expiry_delta(lease.cltv_expiry_delta).is_some() && lease.valid_until > now_secs() } pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { cltv_expiry_delta.try_into().ok() } +pub(crate) fn is_lease_valid_for(lease: &PaymentLease, min_remaining_secs: u64) -> bool { + is_lease_usable(lease) && lease.valid_until.saturating_sub(now_secs()) >= min_remaining_secs +} + +pub(crate) fn is_lease_cacheable(lease: &PaymentLease) -> bool { + is_lease_valid_for(lease, MIN_CACHED_LEASE_REMAINING_SECS) +} + pub(crate) fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_secs() } @@ -588,91 +588,137 @@ mod tests { #[test] fn leases_are_consumed_once() { - let lease = lease(2, 42, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); - let id = lease.id; + let lease = lease(2, 42, 1, Some(1_000), now_secs() + MIN_CACHED_LEASE_REMAINING_SECS + 60); + let available_lsps = [lease.id.lsp_node_id]; let mut state = LSPS2LeaseState::default(); state.insert(lease); - assert!(state.take_valid(&id).is_some()); - assert!(state.take_valid(&id).is_none()); + assert!(state.fixed_amount(1_000, None, 1, &available_lsps).is_some()); + assert!(state.fixed_amount(1_000, None, 1, &available_lsps).is_none()); + } + + #[test] + fn lease_can_be_usable_without_being_cacheable() { + let invoice_lifetime_secs = 2 * 60 * 60; + let lease = lease(2, 53, 1, Some(1_000), now_secs() + invoice_lifetime_secs + 60); + + assert!( + is_lease_usable(&lease), + "a live lease covering the invoice lifetime should be usable" + ); + assert!(is_lease_valid_for(&lease, invoice_lifetime_secs)); + assert!(!is_lease_cacheable(&lease)); + } + + #[test] + fn lease_selection_honors_invoice_lifetime() { + let invoice_lifetime_secs = 2 * 60 * 60; + let short_validity = now_secs() + invoice_lifetime_secs - 1; + let sufficient_validity = now_secs() + invoice_lifetime_secs + 60; + let cheap_fixed = lease(2, 54, 1, Some(1_000), short_validity); + let fixed = lease(3, 55, 2, Some(1_000), sufficient_validity); + let cheap_variable = lease(4, 56, 1, None, short_validity); + let variable = lease(5, 57, 2, None, sufficient_validity); + let available_lsps = [ + cheap_fixed.id.lsp_node_id, + fixed.id.lsp_node_id, + cheap_variable.id.lsp_node_id, + variable.id.lsp_node_id, + ]; + let mut state = LSPS2LeaseState::from_leases(vec![ + cheap_fixed, + fixed.clone(), + cheap_variable, + variable.clone(), + ]); + + assert_eq!( + state.fixed_amount(1_000, None, invoice_lifetime_secs, &available_lsps).unwrap().0.id, + fixed.id + ); + assert_eq!( + state.variable_amount(None, None, invoice_lifetime_secs, &available_lsps).unwrap().0.id, + variable.id + ); } #[test] fn selected_leases_cannot_be_consumed_twice() { - let lease = lease(2, 52, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); + let lease = lease(2, 52, 1, Some(1_000), now_secs() + MIN_CACHED_LEASE_REMAINING_SECS + 60); let available_lsps = [lease.id.lsp_node_id]; let mut state = LSPS2LeaseState::from_leases(vec![lease]); - assert!(state.fixed_amount(1_000, None, &available_lsps).is_some()); + assert!(state.fixed_amount(1_000, None, 1, &available_lsps).is_some()); assert!( - state.fixed_amount(1_000, None, &available_lsps).is_none(), + state.fixed_amount(1_000, None, 1, &available_lsps).is_none(), "single-use lease was handed out twice" ); } #[test] fn prunes_leases_close_to_expiry() { - let lease = lease(2, 43, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS - 1); - let id = lease.id; + let lease = lease(2, 43, 1, Some(1_000), now_secs() + MIN_CACHED_LEASE_REMAINING_SECS - 1); + let available_lsps = [lease.id.lsp_node_id]; let mut state = LSPS2LeaseState::default(); state.insert(lease); state.prune(); - assert!(state.take_valid(&id).is_none()); + assert!(state.fixed_amount(1_000, None, 1, &available_lsps).is_none()); } #[test] fn rejects_unsupported_cltv_delta() { - let mut lease = lease(2, 53, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); + let mut lease = + lease(2, 53, 1, Some(1_000), now_secs() + MIN_CACHED_LEASE_REMAINING_SECS + 60); lease.cltv_expiry_delta = u16::MAX as u32 + 1; - let id = lease.id; + let available_lsps = [lease.id.lsp_node_id]; let mut state = LSPS2LeaseState::from_leases(vec![lease]); assert!( - state.take_valid(&id).is_none(), + state.fixed_amount(1_000, None, 1, &available_lsps).is_none(), "lease with an unsupported CLTV delta remained usable" ); } #[test] fn selects_cheapest_matching_lease_across_lsps() { - let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let valid_until = now_secs() + MIN_CACHED_LEASE_REMAINING_SECS + 60; let expensive = lease(2, 44, 100, Some(1_000), valid_until); let cheap = lease(3, 45, 50, Some(1_000), valid_until); let mut state = LSPS2LeaseState::from_leases(vec![expensive.clone(), cheap.clone()]); - let available_lsps = [expensive.id.lsp_node_id, cheap.id.lsp_node_id]; - let (selected, _) = state.fixed_amount(1_000, None, &available_lsps).unwrap(); + let (selected, _) = state.fixed_amount(1_000, None, 1, &available_lsps).unwrap(); assert_eq!(selected.id, cheap.id); - let (remaining, _) = state.fixed_amount(1_000, None, &available_lsps).unwrap(); + let (remaining, _) = state.fixed_amount(1_000, None, 1, &available_lsps).unwrap(); assert_eq!(remaining.id, expensive.id); } #[test] fn ignores_leases_from_unavailable_lsps() { - let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let valid_until = now_secs() + MIN_CACHED_LEASE_REMAINING_SECS + 60; let unavailable = lease(2, 46, 1, Some(1_000), valid_until); let available = lease(3, 47, 2, Some(1_000), valid_until); let mut state = LSPS2LeaseState::from_leases(vec![unavailable, available.clone()]); - let (selected, _) = state.fixed_amount(1_000, None, &[available.id.lsp_node_id]).unwrap(); + let (selected, _) = + state.fixed_amount(1_000, None, 1, &[available.id.lsp_node_id]).unwrap(); assert_eq!(selected.id, available.id, "lease selection ignored the set of available LSPs"); } #[test] fn variable_lease_honors_total_fee_limit() { - let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let valid_until = now_secs() + MIN_CACHED_LEASE_REMAINING_SECS + 60; let variable = lease(2, 46, 50, None, valid_until); let mut state = LSPS2LeaseState::from_leases(vec![variable.clone()]); let available_lsps = [variable.id.lsp_node_id]; - assert!(state.variable_amount(None, Some(49), &available_lsps).is_none()); + assert!(state.variable_amount(None, Some(49), 1, &available_lsps).is_none()); assert_eq!( - state.variable_amount(None, Some(50), &available_lsps).unwrap().0.id, + state.variable_amount(None, Some(50), 1, &available_lsps).unwrap().0.id, variable.id ); } #[test] fn variable_lease_matches_resolved_payment_amount() { - let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let valid_until = now_secs() + MIN_CACHED_LEASE_REMAINING_SECS + 60; let mut incompatible = lease(2, 47, 1, None, valid_until); incompatible.params.max_payment_size_msat = 1_000; let mut compatible = lease(3, 48, 2, None, valid_until); @@ -680,10 +726,9 @@ mod tests { compatible.params.max_payment_size_msat = 3_000; let available_lsps = [incompatible.id.lsp_node_id, compatible.id.lsp_node_id]; let mut state = LSPS2LeaseState::from_leases(vec![incompatible, compatible.clone()]); - - assert!(state.variable_amount(Some(2_000), Some(999), &available_lsps).is_none()); + assert!(state.variable_amount(Some(2_000), Some(999), 1, &available_lsps).is_none()); let (selected, total_fee_msat) = - state.variable_amount(Some(2_000), None, &available_lsps).unwrap(); + state.variable_amount(Some(2_000), None, 1, &available_lsps).unwrap(); assert_eq!( selected.id, compatible.id, "selected variable lease must accept the resolved payment amount" @@ -693,7 +738,7 @@ mod tests { #[test] fn detects_cached_leases_for_refill() { - let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let valid_until = now_secs() + MIN_CACHED_LEASE_REMAINING_SECS + 60; let fixed = lease(2, 46, 100, Some(1_000), valid_until); let variable = lease(3, 47, 50, None, valid_until); let state = LSPS2LeaseState::from_leases(vec![fixed, variable]); diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index e4b312b4bd..d510f595a2 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -37,7 +37,7 @@ use crate::io::{ }; use crate::liquidity::client::lsps1::LSPS1Client; use crate::liquidity::client::lsps2::state::{ - is_lease_usable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, + is_lease_cacheable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, LeaseCacheTargetStore, PaymentLeaseStore, PendingLeaseRequestState, }; use crate::liquidity::client::lsps2::LSPS2Client; @@ -279,11 +279,11 @@ where Arc::clone(&self.kv_store), self.logger.clone(), )); - for lease in leases.iter().filter(|lease| !is_lease_usable(lease)) { + for lease in leases.iter().filter(|lease| !is_lease_cacheable(lease)) { lease_store.remove(&lease.id).await.map_err(|_| BuildError::WriteFailed)?; } let lease_state = - LSPS2LeaseState::from_leases(leases.into_iter().filter(is_lease_usable).collect()); + LSPS2LeaseState::from_leases(leases.into_iter().filter(is_lease_cacheable).collect()); let (cache_target_state, removed_target_ids) = LeaseCacheTargetState::from_targets( cache_targets, LSPS2_LEASE_CACHE_TARGET_SIZE, From a691b7726463ff21c77d5241de119901b8acf44c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:12:34 +0200 Subject: [PATCH 38/41] lsps2: Test BOLT12 JIT payments Cover fixed and variable BOLT12 offers through a real LSPS2 service. Verify node-ID offer addressing, fee withholding, fresh JIT channels, and the variable-amount single-path behavior. Co-Authored-By: HAL 9000 --- tests/integration_tests_rust.rs | 157 ++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 57e0296dad..b51d3c6ba4 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3143,6 +3143,163 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { assert_eq!(client_node.payment(&payment_id).unwrap().status, PaymentStatus::Failed); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn bolt12_lsps2_client_service_integration() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + + let mut sync_config = EsploraSyncConfig::default(); + sync_config.background_sync_config = None; + + let channel_opening_fee_ppm = 10_000; + let lsps2_service_config = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm, + 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: 0, + max_client_to_self_delay: 1024, + client_trusts_lsp: true, + disable_client_reserve: false, + }; + + let service_config = random_config(); + setup_builder!(service_builder, service_config.node_config); + service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + service_builder.enable_liquidity_provider(lsps2_service_config); + let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); + service_node.start().unwrap(); + + let service_node_id = service_node.node_id(); + let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); + + let client_config = random_config(); + setup_builder!(client_builder, client_config.node_config); + client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + client_builder.add_liquidity_source(service_node_id, service_addr, None, true); + let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); + client_node.start().unwrap(); + + let payer_config = random_config(); + setup_builder!(payer_builder, payer_config.node_config); + payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); + payer_node.start().unwrap(); + + let client_addr = client_node.listening_addresses().unwrap().first().unwrap().clone(); + payer_node.connect(client_node.node_id(), client_addr, false).unwrap(); + + let service_onchain_addr = service_node.onchain_payment().new_address().unwrap(); + let client_onchain_addr = client_node.onchain_payment().new_address().unwrap(); + let payer_onchain_addr = payer_node.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![service_onchain_addr, client_onchain_addr, payer_onchain_addr], + Amount::from_sat(10_000_000), + ) + .await; + service_node.sync_wallets().unwrap(); + client_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + + open_channel(&payer_node, &service_node, 5_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + service_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + expect_channel_ready_event!(payer_node, service_node.node_id()); + expect_channel_ready_event!(service_node, payer_node.node_id()); + while payer_node.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let fixed_amount_msat = 100_000_000; + // The recipient has no channel yet, but the connected payer gives the default message router a + // peer through which it can construct an offer path. This keeps the long-lived offer addressed + // by a blinded node path; the single-use intercept SCID is introduced only in the invoice. + assert!(client_node.list_channels().is_empty()); + let fixed_offer = + client_node.bolt12_payment().receive(fixed_amount_msat, "fixed", None, None).unwrap(); + assert!(!fixed_offer.paths().is_empty()); + assert_ne!(fixed_offer.issuer_signing_pubkey(), Some(client_node.node_id())); + let fixed_payment_id = + payer_node.bolt12_payment().send(&fixed_offer, None, None, None).unwrap(); + + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_event!(service_node, PaymentForwarded); + expect_channel_pending_event!(client_node, service_node.node_id()); + expect_channel_ready_event!(client_node, service_node.node_id()); + expect_payment_successful_event!(payer_node, Some(fixed_payment_id), None); + let fixed_fee_msat = fixed_amount_msat * channel_opening_fee_ppm as u64 / 1_000_000; + let fixed_received_msat = fixed_amount_msat - fixed_fee_msat; + let fixed_receiver_payment_id = + expect_payment_received_event!(client_node, fixed_received_msat).unwrap(); + match client_node.payment(&fixed_receiver_payment_id).unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(fixed_fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } + + // The over-provisioned part of the first JIT channel can receive this payment in full. The + // offers flow must therefore keep the ordinary blinded path and avoid negotiating or exposing a + // second intercept SCID. + let client_channel_count = client_node.list_channels().len(); + let service_channel_count = service_node.list_channels().len(); + let ordinary_amount_msat = 5_000_000; + let ordinary_offer = client_node + .bolt12_payment() + .receive(ordinary_amount_msat, "existing inbound", None, None) + .unwrap(); + let ordinary_payment_id = + payer_node.bolt12_payment().send(&ordinary_offer, None, None, None).unwrap(); + + expect_event!(service_node, PaymentForwarded); + expect_payment_successful_event!(payer_node, Some(ordinary_payment_id), None); + let ordinary_receiver_payment_id = + expect_payment_received_event!(client_node, ordinary_amount_msat).unwrap(); + match client_node.payment(&ordinary_receiver_payment_id).unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, None); + }, + _ => panic!("Unexpected payment kind"), + } + assert_eq!(client_node.list_channels().len(), client_channel_count); + assert_eq!(service_node.list_channels().len(), service_channel_count); + + // The first JIT channel cannot carry this larger variable payment. The variable offer disables + // MPP, so the payer must use the fresh JIT path rather than splitting across both paths. + let variable_amount_msat = 200_000_000; + let variable_offer = + client_node.bolt12_payment().receive_variable_amount("variable", None).unwrap(); + let variable_payment_id = payer_node + .bolt12_payment() + .send_using_amount(&variable_offer, variable_amount_msat, None, None, None) + .unwrap(); + + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_event!(service_node, PaymentForwarded); + expect_channel_pending_event!(client_node, service_node.node_id()); + expect_channel_ready_event!(client_node, service_node.node_id()); + expect_payment_successful_event!(payer_node, Some(variable_payment_id), None); + let variable_fee_msat = variable_amount_msat * channel_opening_fee_ppm as u64 / 1_000_000; + let variable_received_msat = variable_amount_msat - variable_fee_msat; + let variable_receiver_payment_id = + expect_payment_received_event!(client_node, variable_received_msat).unwrap(); + match client_node.payment(&variable_receiver_payment_id).unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(variable_fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn facade_logging() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 191da766dc5444f6c9ac5e99624343972db7fec7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:16:21 +0200 Subject: [PATCH 39/41] lsps2: Test multi-LSP BOLT12 selection Extend the order-independent fee-selection scenario through a real BOLT12 payment. Also prove that BOLT11 consumption and BOLT12 response handling share the same replenished lease pool. Co-Authored-By: HAL 9000 --- tests/integration_tests_rust.rs | 64 +++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index b51d3c6ba4..8b67e1839e 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -4482,17 +4482,18 @@ async fn lsps2_multi_lsp_rejects_fees_above_limit() { async fn do_lsps2_multi_lsp_picks_cheapest( reverse_order: bool, max_total_lsp_fee_limit_msat: Option, ) { - let (_bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + 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_opening_fee_ppm = 10_000; let cheap_cfg = LSPS2ServiceConfig { require_token: None, advertise_service: false, - channel_opening_fee_ppm: 10_000, + channel_opening_fee_ppm: cheap_opening_fee_ppm, channel_over_provisioning_ppm: 100_000, max_payment_size_msat: 1_000_000_000, min_payment_size_msat: 0, @@ -4549,12 +4550,47 @@ async fn do_lsps2_multi_lsp_picks_cheapest( let client = client_builder.build(client_config.node_entropy.into()).unwrap(); client.start().unwrap(); + let payer_config = random_config(); + setup_builder!(payer_builder, payer_config.node_config); + payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + let payer = payer_builder.build(payer_config.node_entropy.into()).unwrap(); + payer.start().unwrap(); + + let client_addr = client.listening_addresses().unwrap().first().unwrap().clone(); + payer.connect(client.node_id(), client_addr, false).unwrap(); + + let cheap_onchain_addr = cheap.onchain_payment().new_address().unwrap(); + let client_onchain_addr = client.onchain_payment().new_address().unwrap(); + let payer_onchain_addr = payer.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![cheap_onchain_addr, client_onchain_addr, payer_onchain_addr], + Amount::from_sat(10_000_000), + ) + .await; + cheap.sync_wallets().unwrap(); + client.sync_wallets().unwrap(); + payer.sync_wallets().unwrap(); + + open_channel(&payer, &cheap, 5_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + cheap.sync_wallets().unwrap(); + payer.sync_wallets().unwrap(); + expect_channel_ready_event!(payer, cheap.node_id()); + expect_channel_ready_event!(cheap, payer.node_id()); + while payer.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let invoice_description = Bolt11InvoiceDescription::Direct(Description::new(String::from("asdf")).unwrap()).into(); let jit_invoice_result = client.bolt11_payment().receive_via_jit_channel(100_000_000, &invoice_description, 1024); if max_total_lsp_fee_limit_msat.is_some() { assert!(matches!(jit_invoice_result, Err(NodeError::LiquidityFeeTooHigh))); + payer.stop().unwrap(); client.stop().unwrap(); cheap.stop().unwrap(); expensive.stop().unwrap(); @@ -4572,6 +4608,30 @@ async fn do_lsps2_multi_lsp_picks_cheapest( 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."); + // Consuming the BOLT11 lease schedules a replacement. The BOLT12 flow shares that cache and + // must retain the same cheapest-LSP selection regardless of registration order. + let payment_amount_msat = 100_000_000; + let offer = + client.bolt12_payment().receive(payment_amount_msat, "multi LSP", None, None).unwrap(); + let payment_id = payer.bolt12_payment().send(&offer, None, None, None).unwrap(); + + expect_channel_pending_event!(cheap, client.node_id()); + expect_channel_ready_event!(cheap, client.node_id()); + expect_event!(cheap, PaymentForwarded); + expect_channel_pending_event!(client, cheap.node_id()); + expect_channel_ready_event!(client, cheap.node_id()); + expect_payment_successful_event!(payer, Some(payment_id), None); + let fee_msat = payment_amount_msat * cheap_opening_fee_ppm as u64 / 1_000_000; + let receiver_payment_id = + expect_payment_received_event!(client, payment_amount_msat - fee_msat).unwrap(); + match client.payment(&receiver_payment_id).unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } + + payer.stop().unwrap(); client.stop().unwrap(); cheap.stop().unwrap(); expensive.stop().unwrap(); From e56492d5e5a462c7c42672fe9cbfc0584f28c4b7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:24:29 +0200 Subject: [PATCH 40/41] lsps2: Test restored BOLT12 leases Rebuild the receiver from its persisted store and pay the same long-lived offer again. Verify the pending offer survives and the exact cached lease is consumed before its replacement is negotiated. Co-Authored-By: HAL 9000 --- tests/integration_tests_rust.rs | 97 +++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 4 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 8b67e1839e..529ad8150d 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3177,10 +3177,14 @@ async fn bolt12_lsps2_client_service_integration() { let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); let client_config = random_config(); + let client_store = + TestSyncStore::new(client_config.node_config.storage_dir_path.clone().into()); setup_builder!(client_builder, client_config.node_config); client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); - client_builder.add_liquidity_source(service_node_id, service_addr, None, true); - let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); + client_builder.add_liquidity_source(service_node_id, service_addr.clone(), None, true); + let client_node = client_builder + .build_with_store(client_config.node_entropy.into(), client_store.clone()) + .unwrap(); client_node.start().unwrap(); let payer_config = random_config(); @@ -3272,8 +3276,93 @@ async fn bolt12_lsps2_client_service_integration() { assert_eq!(client_node.list_channels().len(), client_channel_count); assert_eq!(service_node.list_channels().len(), service_channel_count); - // The first JIT channel cannot carry this larger variable payment. The variable offer disables - // MPP, so the payer must use the fresh JIT path rather than splitting across both paths. + let persisted_lease_key = + tokio::time::timeout(std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), async { + loop { + let keys = KVStore::list(&client_store, "lsps2_leases", "").await.unwrap(); + if let Some(key) = keys.first() { + return key.clone(); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("replacement lease should be persisted"); + assert!(KVStore::read(&client_store, "lsps2_leases", "", &persisted_lease_key).await.is_ok()); + + let client_node_id = client_node.node_id(); + client_node.stop().unwrap(); + drop(client_node); + tokio::time::timeout(std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), async { + while payer_node.list_peers().iter().any(|peer| peer.node_id == client_node_id) + || service_node.list_peers().iter().any(|peer| peer.node_id == client_node_id) + { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("peers should observe the stopped client"); + + setup_builder!(restarted_client_builder, client_config.node_config); + restarted_client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + restarted_client_builder.add_liquidity_source( + service_node_id, + service_addr.clone(), + None, + true, + ); + let client_node = restarted_client_builder + .build_with_store(client_config.node_entropy.into(), client_store.clone()) + .unwrap(); + assert_eq!(client_node.node_id(), client_node_id); + assert!(KVStore::read(&client_store, "lsps2_leases", "", &persisted_lease_key).await.is_ok()); + client_node.start().unwrap(); + let client_addr = client_node.listening_addresses().unwrap().first().unwrap().clone(); + payer_node.connect(client_node.node_id(), client_addr, false).unwrap(); + + let restored_payment_id = + payer_node.bolt12_payment().send(&fixed_offer, None, None, None).unwrap(); + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_event!(service_node, PaymentForwarded); + loop { + let event = tokio::time::timeout( + std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), + client_node.next_event_async(), + ) + .await + .expect("restored client should receive a payment event"); + match event { + Event::ChannelPending { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, service_node.node_id()); + client_node.event_handled().unwrap(); + break; + }, + Event::PaymentReceived { payment_id, amount_msat, .. } => { + assert_eq!(payment_id, Some(ordinary_receiver_payment_id)); + assert_eq!(amount_msat, ordinary_amount_msat); + client_node.event_handled().unwrap(); + }, + unexpected => panic!("Unexpected event after restoring client: {unexpected:?}"), + } + } + expect_channel_ready_event!(client_node, service_node.node_id()); + expect_payment_successful_event!(payer_node, Some(restored_payment_id), None); + let restored_receiver_payment_id = + expect_payment_received_event!(client_node, fixed_received_msat).unwrap(); + match client_node.payment(&restored_receiver_payment_id).unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(fixed_fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } + assert!( + KVStore::read(&client_store, "lsps2_leases", "", &persisted_lease_key).await.is_err(), + "the restored lease must be consumed instead of renegotiating" + ); + + // The fixed JIT channels cannot carry this larger variable payment. The variable offer disables + // MPP, so the payer must use the fresh JIT path rather than splitting across the existing paths. let variable_amount_msat = 200_000_000; let variable_offer = client_node.bolt12_payment().receive_variable_amount("variable", None).unwrap(); From f56fa41b413638ab16421998337744ee0f087568 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:45:04 +0200 Subject: [PATCH 41/41] lsps2: Test payment lease state Pin the direct lease serialization format and ensure fixed-amount and variable-amount caches remain isolated. These focused checks complement the end-to-end BOLT11 and BOLT12 coverage. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/state.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 66f3688ed8..0d440a8159 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -654,6 +654,33 @@ mod tests { ); } + #[test] + fn payment_leases_roundtrip() { + let lease = + lease(2, 48, 100, Some(1_000), now_secs() + MIN_CACHED_LEASE_REMAINING_SECS + 60); + + let encoded = lease.encode(); + let decoded = PaymentLease::read(&mut &encoded[..]).unwrap(); + + assert_eq!(decoded, lease); + } + + #[test] + fn fixed_and_variable_lease_pools_are_isolated() { + let valid_until = now_secs() + MIN_CACHED_LEASE_REMAINING_SECS + 60; + let fixed = lease(2, 49, 100, Some(1_000), valid_until); + let variable = lease(3, 50, 50, None, valid_until); + let available_lsps = [fixed.id.lsp_node_id, variable.id.lsp_node_id]; + let mut state = LSPS2LeaseState::from_leases(vec![fixed.clone(), variable.clone()]); + + let (selected_fixed, _) = state.fixed_amount(1_000, None, 1, &available_lsps).unwrap(); + assert_eq!(selected_fixed.id, fixed.id); + assert!(state.fixed_amount(1_000, None, 1, &available_lsps).is_none()); + + let (selected_variable, _) = state.variable_amount(None, None, 1, &available_lsps).unwrap(); + assert_eq!(selected_variable.id, variable.id); + } + #[test] fn prunes_leases_close_to_expiry() { let lease = lease(2, 43, 1, Some(1_000), now_secs() + MIN_CACHED_LEASE_REMAINING_SECS - 1);