From c3cbfb2265cdf3c4f327ce9d01f2db45ca54f95f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:13:16 +0200 Subject: [PATCH 01/35] 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 23ccf7ed4f3ea89347dc7de5ca1b00b20dc4f445 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:13:41 +0200 Subject: [PATCH 02/35] 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 | 144 ++++++++++++++++++++++++++++ 2 files changed, 145 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 8084cace78..1e3fb90eae 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -553,3 +553,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..b2a82d9cb3 --- /dev/null +++ b/src/liquidity/client/lsps2/state.rs @@ -0,0 +1,144 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::collections::HashMap; +use std::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}; +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 + ) + } + + fn decode_from_hex_str(s: &str) -> Option { + let bytes = hex_utils::to_vec(s)?; + if bytes.len() != 41 { + return None; + } + let lsp_node_id = PublicKey::from_slice(&bytes[..33]).ok()?; + let intercept_scid = u64::from_be_bytes(bytes[33..].try_into().ok()?); + Some(Self { lsp_node_id, 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), +}); + +impl StorableObject for PaymentLease { + type Id = PaymentLeaseId; + + fn id(&self) -> Self::Id { + self.id + } +} + +#[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 2dbbd53913ac1c7b87980ff3e554547b149017eb Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:27:14 +0200 Subject: [PATCH 03/35] 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 | 29 +++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/io/mod.rs b/src/io/mod.rs index 4d229e8b0d..f94c9c6bf9 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -50,6 +50,10 @@ pub(crate) const CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE: &str pub(crate) const CHANNEL_PAIR_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE: &str = "channel_pair_stats"; +/// 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 1e3fb90eae..b35f52da7a 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 c1442b0fc9..c460387317 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -31,7 +31,13 @@ use tokio::sync::oneshot; use crate::builder::BuildError; use crate::connection::ConnectionManager; +use crate::data_store::KeepAllEntries; +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}; @@ -293,6 +299,27 @@ 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(), + KeepAllEntries, + 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; @@ -357,6 +384,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 ae99533bde5a91904fca9832ed01aa1f38710f65 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:28:41 +0200 Subject: [PATCH 04/35] 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 | 15 +++++++++++++++ src/liquidity/client/lsps2/state.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index a5f2a72e18..db631e5e74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -591,6 +591,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 b35f52da7a..275be11dfb 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -60,6 +60,21 @@ 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)) + .await + .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 b2a82d9cb3..4fc02f8aac 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -141,4 +141,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 32f59def2a716bd1c834f26129244b0b08d85000 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 14:38:11 +0200 Subject: [PATCH 05/35] 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 --- CHANGELOG.md | 3 ++ src/config.rs | 9 ++++ src/event.rs | 34 +++++++++--- src/liquidity/client/lsps2/mod.rs | 43 +++++++-------- src/payment/bolt11.rs | 87 ++++++++++++------------------- src/payment/store.rs | 2 +- tests/integration_tests_rust.rs | 40 ++++++++------ 7 files changed, 119 insertions(+), 99 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d7c631bb3..e655375909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ This replaces the previous unpaginated `Node::list_payments`, and `Node::list_payments_with_filter` has been removed; filter the returned pages instead. - `Node::payment` now returns a `Result`, as retrieving a payment may fail. +- LSPS2 just-in-time receive fee limits are now configured globally through + `Config::lsps2_max_total_lsp_fee_limit_msat`. The corresponding per-call fee-limit arguments + have been removed from the BOLT 11 receive APIs. - The Bitcoin Core RPC and REST chain-source builder methods now accept an optional `wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known birthday block instead of checkpointing at the current tip, which is useful when restoring a diff --git a/src/config.rs b/src/config.rs index d524eac58e..5344b32a38 100644 --- a/src/config.rs +++ b/src/config.rs @@ -261,6 +261,7 @@ impl Default for ForwardedPaymentTrackingMode { /// | `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 | @@ -311,6 +312,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 in addition to the fee negotiated + /// for a payment. 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. @@ -361,6 +369,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/event.rs b/src/event.rs index 657c938ed6..e4bb3fa348 100644 --- a/src/event.rs +++ b/src/event.rs @@ -710,12 +710,16 @@ where fn lsps2_max_total_opening_fee_msat(payment_metadata: &[u8], amount_msat: u64) -> Option { let metadata = PaymentMetadata::read(&mut &payment_metadata[..]).ok()?; 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) - }) - }) + let proportional_limit = lsps2_parameters + .max_proportional_opening_fee_ppm_msat + .and_then(|max_prop_fee| compute_opening_fee(amount_msat, 0, max_prop_fee)); + match (lsps2_parameters.max_total_opening_fee_msat, proportional_limit) { + (Some(total_limit), Some(proportional_limit)) => { + Some(total_limit.min(proportional_limit)) + }, + (Some(total_limit), None) => Some(total_limit), + (None, proportional_limit) => proportional_limit, + } } async fn resolve_inbound_payment_id( @@ -2815,6 +2819,24 @@ mod tests { ); } + #[test] + fn lsps2_payment_metadata_applies_stricter_fee_limit() { + let metadata = PaymentMetadata { + lsps2_parameters: Some(LSPS2Parameters { + max_total_opening_fee_msat: Some(42_000), + max_proportional_opening_fee_ppm_msat: Some(10_000), + }), + }; + + assert_eq!( + EventHandler::>::lsps2_max_total_opening_fee_msat( + &metadata.encode(), + 100_000 + ), + Some(1_000) + ); + } + #[test] fn lsps2_payment_metadata_missing_or_malformed_limit_is_rejected() { let empty_metadata = PaymentMetadata { lsps2_parameters: None }.encode(); diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 275be11dfb..9554db509f 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -77,8 +77,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?; @@ -112,10 +112,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", @@ -201,7 +202,7 @@ where .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) .await?; let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: None, + max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), }; let invoice = self.lsps2_create_jit_invoice( diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index e2a34828cf..d4e4281315 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -127,8 +127,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 res = self.runtime.block_on(async move { @@ -139,7 +138,6 @@ impl Bolt11Payment { amount_msat, description, expiry_secs, - max_total_lsp_fee_limit_msat, payment_hash, connection_manager, ) @@ -150,7 +148,6 @@ impl Bolt11Payment { .lsps2_receive_variable_amount_to_jit_channel( description, expiry_secs, - max_proportional_lsp_fee_limit_ppm_msat, payment_hash, connection_manager, ) @@ -666,43 +663,36 @@ 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 invoice only advertises the configured [LSPS2]-compliant LSP's intercept route. The payment + /// may instead use pre-existing channels if the payer independently knows a route to the + /// recipient, 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 { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } 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 invoice only advertises the configured [LSPS2]-compliant LSP's intercept route. The payment + /// may instead use pre-existing channels if the payer independently knows a route to the + /// recipient, 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. /// /// The inbound payment will only be accepted if /// [`Config::manually_handle_unknown_bolt11_payments`] is enabled. We will then emit a @@ -726,7 +716,7 @@ 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 { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); @@ -736,52 +726,42 @@ impl Bolt11Payment { 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 invoice only advertises the configured [LSPS2]-compliant LSP's intercept route. The payment + /// may instead use pre-existing channels if the payer independently knows a route to the + /// recipient, 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 { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } 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 invoice only advertises the configured [LSPS2]-compliant LSP's intercept route. The payment + /// may instead use pre-existing channels if the payer independently knows a route to the + /// recipient, 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. /// /// The inbound payment will only be accepted if /// [`Config::manually_handle_unknown_bolt11_payments`] is enabled. We will then emit a @@ -804,8 +784,7 @@ impl Bolt11Payment { /// [`fail_for_id`]: Self::fail_for_id /// [`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 { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); @@ -815,8 +794,6 @@ impl Bolt11Payment { None, &description, expiry_secs, - None, - max_proportional_lsp_fee_limit_ppm_msat, Some(payment_hash), )?; Ok(maybe_wrap(invoice)) diff --git a/src/payment/store.rs b/src/payment/store.rs index 12d360134a..921d8041ba 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -523,7 +523,7 @@ 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 + /// The value, in thousandths 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] diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 080fb8c359..086ae9fde2 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -5378,7 +5378,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. @@ -5434,13 +5434,11 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, ), Some(hash) => client_node.bolt11_payment().receive_via_jit_channel_for_hash( jit_amount_msat, &invoice_description, 1024, - None, hash, ), } @@ -5488,7 +5486,6 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, manual_payment_hash, ) .unwrap(); @@ -5546,7 +5543,6 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, manual_payment_hash, ) .unwrap(); @@ -5740,7 +5736,7 @@ async fn live_node_apis_return_not_running_after_stop() { ); assert_eq!(node_a.bolt11_payment().fail_for_id(payment_id), Err(NodeError::NotRunning)); assert_eq!( - node_a.bolt11_payment().receive_via_jit_channel(1_000_000, &description.into(), 3600, None), + node_a.bolt11_payment().receive_via_jit_channel(1_000_000, &description.into(), 3600), Err(NodeError::NotRunning) ); assert_eq!( @@ -5865,7 +5861,6 @@ async fn lsps2_client_trusts_lsp() { jit_amount_msat, &invoice_description.into(), 1024, - None, manual_payment_hash, ) .unwrap(); @@ -6044,7 +6039,6 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { jit_amount_msat, &invoice_description.into(), 1024, - None, manual_payment_hash, ) .unwrap(); @@ -6870,11 +6864,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()); @@ -6928,7 +6929,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 { @@ -6943,10 +6945,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 007cd8b6d3697ec032fd8c6a25f9e20fa8115b33 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:29:52 +0200 Subject: [PATCH 06/35] bolt11: Consume LSPS2 payment leases Represent negotiated LSPS2 parameters as single-use payment leases and allow BOLT11 invoice creation to reuse compatible cached leases. Remove leases from shared state before persisted deletion so concurrent calls cannot consume the same lease. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 260 ++++++++++++++++++++++------ src/liquidity/client/lsps2/state.rs | 159 +++++++++++++---- 2 files changed, 326 insertions(+), 93 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 9554db509f..920b82fcfb 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; @@ -35,7 +36,25 @@ 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}; + +async fn consume_after_persisted_removal( + value: T, persist_removal: RF, restore: CF, +) -> Result +where + T: Clone, + RF: FnOnce(T) -> Fut, + CF: FnOnce(T), + Fut: Future>, +{ + match persist_removal(value.clone()).await { + Ok(()) => Ok(value), + Err(error) => { + restore(value); + Err(error) + }, + } +} pub(crate) struct LSPS2Client where @@ -80,8 +99,61 @@ where 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?; + 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( + &lease, + description, + expiry_secs, + payment_hash, + lsps2_parameters, + )?; + + 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: Some(proportional_fee), + }; + let invoice = self.lsps2_create_jit_invoice( + &lease, + description, + expiry_secs, + payment_hash, + lsps2_parameters, + )?; + + 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? + { + 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 .into_iter() .flat_map(|(lsp, resp)| { @@ -129,38 +201,25 @@ 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 lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: Some(min_total_fee_msat), - max_proportional_opening_fee_ppm_msat: None, - }; - - let invoice = self.lsps2_create_jit_invoice( - buy_response, - Some(amount_msat), - description, - expiry_secs, - payment_hash, - lsps2_parameters, - Some(&cheapest_lsp.node_id), - )?; - - log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok((invoice, cheapest_lsp)) + let lease = self.consume_lease(&negotiated_lease.id).await?; + 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> { - // Connect to all candidate LSPs before querying fees. - let all_offers = self.gather_lsps2_offers(&connection_manager).await?; + 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? { + return Ok((lease, proportional_fee, lsp, false)); + } + + 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() @@ -198,25 +257,11 @@ 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 lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, - max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), - }; - let invoice = self.lsps2_create_jit_invoice( - buy_response, - None, - description, - expiry_secs, - payment_hash, - lsps2_parameters, - Some(&cheapest_lsp.node_id), - )?; - - log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok((invoice, cheapest_lsp)) + let lease = self.consume_lease(&negotiated_lease.id).await?; + Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true)) } async fn gather_lsps2_offers( @@ -313,7 +358,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)?; @@ -326,7 +371,7 @@ where let _pending_request = { 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, @@ -357,16 +402,98 @@ 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.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").insert(lease); + }, + ) + .await + } + + async fn take_cached_fixed_lease( + &self, amount_msat: u64, + ) -> 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, 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> { + 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( - &self, buy_response: LSPS2BuyResponse, amount_msat: Option, - description: &Bolt11InvoiceDescription, expiry_secs: u32, + &self, lease: &PaymentLease, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: Option, lsps2_parameters: LSPS2Parameters, - node_id: Option<&PublicKey>, ) -> Result { - let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) + let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id)) .ok_or(Error::LiquiditySourceUnavailable)?; // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. @@ -379,7 +506,7 @@ where .channel_manager .create_inbound_payment_for_hash( payment_hash, - amount_msat, + lease.payment_size_msat, expiry_secs, Some(min_final_cltv_expiry_delta), Some(encoded_payment_metadata), @@ -393,7 +520,7 @@ where None => self .channel_manager .create_inbound_payment( - amount_msat, + lease.payment_size_msat, expiry_secs, Some(min_final_cltv_expiry_delta), Some(encoded_payment_metadata), @@ -406,9 +533,9 @@ where let route_hint = RouteHint(vec![RouteHintHop { src_node_id: lsps2_node.node_id, - short_channel_id: buy_response.intercept_scid, + short_channel_id: lease.id.intercept_scid, fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, - cltv_expiry_delta: buy_response.cltv_expiry_delta as u16, + cltv_expiry_delta: lease.cltv_expiry_delta as u16, htlc_minimum_msat: None, htlc_maximum_msat: None, }]); @@ -423,7 +550,7 @@ where .expiry_time(Duration::from_secs(expiry_secs.into())) .private_route(route_hint); - if let Some(amount_msat) = amount_msat { + if let Some(amount_msat) = lease.payment_size_msat { invoice_builder = invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); } @@ -573,4 +700,25 @@ pub(crate) struct LSPS2BuyResponse { intercept_scid: u64, cltv_expiry_delta: u32, } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + 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 |value| *restored_ref.lock().unwrap() = Some(value), + ) + .await; + + assert_eq!(result, Err(())); + assert_eq!(*restored.lock().unwrap(), Some(42)); + } +} + pub(crate) mod state; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 4fc02f8aac..d4bc9fb4e0 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -11,6 +11,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}; use crate::hex_utils; @@ -90,14 +91,54 @@ impl LSPS2LeaseState { } pub(crate) fn take_valid(&mut self, id: &PaymentLeaseId) -> Option { - let lease = self.leases.remove(id)?; - if is_lease_usable(&lease) { - Some(lease) + 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], + ) -> 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)| { + 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.remove(&id).map(|lease| (lease, fee_msat)) + } + + /// 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], + ) -> 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)| { + 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.remove(&id).map(|lease| (lease, proportional_fee)) + } + pub(crate) fn prune(&mut self) { self.leases.retain(|_, lease| is_lease_usable(lease)); } @@ -114,58 +155,102 @@ fn now_secs() -> u64 { #[cfg(test)] mod tests { use super::*; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; - #[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, + 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, - }); + 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.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] 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()); } + + #[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 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); + 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 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"); + } + + #[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 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); + } } From e9b07dede41f92bb937dc4842b6440281c5adaa2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 10:01:50 +0200 Subject: [PATCH 07/35] lsps2: Reject oversized CLTV deltas Reject leases whose CLTV delta cannot be represented in route hints. This prevents invalid leases from surviving restoration or reaching invoice creation. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 15 +++++++++++-- src/liquidity/client/lsps2/state.rs | 35 ++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 920b82fcfb..9d77f7df40 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -401,6 +401,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 @@ -414,7 +424,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, }; @@ -535,7 +545,8 @@ where src_node_id: lsps2_node.node_id, short_channel_id: lease.id.intercept_scid, fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, - cltv_expiry_delta: lease.cltv_expiry_delta as u16, + cltv_expiry_delta: state::checked_cltv_expiry_delta(lease.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 d4bc9fb4e0..ab045fdb8d 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -10,6 +10,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; use lightning::impl_writeable_tlv_based; +use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; use lightning_liquidity::lsps2::utils::compute_opening_fee; @@ -17,6 +18,7 @@ use crate::data_store::{DataStore, StorableObject, StorableObjectId}; use crate::hex_utils; pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; +const BOLT12_FINAL_CLTV_EXPIRY_DELTA: u16 = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; pub(crate) type PaymentLeaseStore = DataStore; @@ -145,7 +147,14 @@ 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 { + u16::try_from(cltv_expiry_delta) + .ok() + .filter(|delta| *delta <= u16::MAX - BOLT12_FINAL_CLTV_EXPIRY_DELTA) } fn now_secs() -> u64 { @@ -218,6 +227,30 @@ 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 rejects_cltv_delta_without_bolt12_headroom() { + let mut lease = lease(2, 54, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); + lease.cltv_expiry_delta = u16::MAX as u32 - (MIN_FINAL_CLTV_EXPIRY_DELTA + 2) as u32 + 1; + + assert!( + !is_lease_usable(&lease), + "lease CLTV delta cannot absorb the BOLT12 final CLTV delta" + ); + } + #[test] fn selects_cheapest_matching_lease_across_lsps() { let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; From 91e900545cc6882ea1865ca7744f74df94d44251 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:36:48 +0200 Subject: [PATCH 08/35] 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 79090ecd0cd01816ed696fd981bbc84259439e1f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 14:50:26 +0200 Subject: [PATCH 09/35] 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 f94c9c6bf9..b6098a2f2d 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -54,6 +54,11 @@ pub(crate) const CHANNEL_PAIR_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE: 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 db631e5e74..fd4459529b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -591,7 +591,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(); @@ -605,6 +605,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 9d77f7df40..87f255442d 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, restore: CF, @@ -67,6 +70,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, @@ -94,6 +98,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 ab045fdb8d..f620087d26 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -6,22 +6,274 @@ // accordance with one or both of these licenses. use std::collections::HashMap; +use std::ops::Deref; +use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; -use lightning::impl_writeable_tlv_based; use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; +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}; 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; const BOLT12_FINAL_CLTV_EXPIRY_DELTA: u16 = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; 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, @@ -157,14 +409,65 @@ pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { .filter(|delta| *delta <= u16::MAX - BOLT12_FINAL_CLTV_EXPIRY_DELTA) } -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, @@ -286,4 +589,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 c460387317..c85820aae0 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -37,7 +37,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}; @@ -47,6 +50,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; pub(crate) struct PendingRequest { token: Arc<()>, @@ -299,14 +305,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(), KeepAllEntries, @@ -320,6 +329,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; @@ -386,6 +408,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 5d2f0209e0dbe4618319a569cf7fb48feafe783f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 14:34:47 +0200 Subject: [PATCH 10/35] bolt12: Register LSPS2 lease targets Register 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 fd4459529b..9d0e5dcc38 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1127,6 +1127,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), @@ -1144,6 +1145,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 dcac54c172..4f8bc1eefd 100644 --- a/src/payment/bolt12/mod.rs +++ b/src/payment/bolt12/mod.rs @@ -31,6 +31,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; @@ -96,6 +98,7 @@ pub struct PayerProofOptions { pub struct Bolt12Payment { runtime: Arc, channel_manager: Arc, + liquidity_source: Arc>>, keys_manager: Arc, payment_store: Arc, config: Arc, @@ -107,13 +110,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, @@ -271,6 +275,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> { @@ -506,15 +529,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 { @@ -534,7 +568,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 5e6bbb8501b01bb86e078adc7f38483f6f8a2e44 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 14:54:56 +0200 Subject: [PATCH 11/35] 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 d4e4281315..021b050187 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`]. @@ -164,37 +152,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 e1c60da79a..3d5ea37ffb 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -14,6 +14,7 @@ mod forwarding; pub(crate) mod forwarding_store; #[cfg(feature = "unified-payments")] mod hrn; +mod metadata; mod onchain; pub(crate) mod pending_payment_store; mod spontaneous; @@ -22,7 +23,6 @@ pub(crate) mod store; mod unified; pub use bolt11::Bolt11Payment; -pub(crate) use bolt11::PaymentMetadata; pub use bolt12::{Bolt12Payment, PayerProofOptions}; pub use forwarding::{ ChannelForwardingStats, ChannelForwardingStatsPage, ChannelPairForwardingStats, @@ -31,6 +31,7 @@ pub use forwarding::{ }; #[cfg(feature = "unified-payments")] pub(crate) use hrn::HRNResolver; +pub(crate) use metadata::PaymentMetadata; pub use onchain::OnchainPayment; pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails}; pub use spontaneous::SpontaneousPayment; From e5ed186e8047615b0cbae5bb2935a7f67fcb9352 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 10:24:44 +0200 Subject: [PATCH 12/35] 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 | 6 +- src/liquidity/client/lsps2/mod.rs | 19 +- src/liquidity/client/lsps2/router.rs | 610 +++++++++++++++++++++++++++ src/liquidity/client/lsps2/state.rs | 7 +- src/payment/metadata.rs | 35 +- 5 files changed, 663 insertions(+), 14 deletions(-) create mode 100644 src/liquidity/client/lsps2/router.rs diff --git a/src/event.rs b/src/event.rs index e4bb3fa348..44f53042a6 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2808,6 +2808,7 @@ mod tests { max_total_opening_fee_msat: Some(42_000), max_proportional_opening_fee_ppm_msat: None, }), + lsps2_lease_parameters: None, }; assert_eq!( @@ -2826,6 +2827,7 @@ mod tests { max_total_opening_fee_msat: Some(42_000), max_proportional_opening_fee_ppm_msat: Some(10_000), }), + lsps2_lease_parameters: None, }; assert_eq!( @@ -2839,12 +2841,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 87f255442d..7f785527dd 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -41,6 +41,9 @@ use self::state::{ PaymentLease, PaymentLeaseId, PaymentLeaseStore, }; +// LSPS2 requires two blocks more than the usual final CLTV delta. +const LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA: u16 = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; + async fn consume_after_persisted_removal( value: T, persist_removal: RF, restore: CF, ) -> Result @@ -525,10 +528,11 @@ where let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id)) .ok_or(Error::LiquiditySourceUnavailable)?; - // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. - let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; - 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 @@ -537,7 +541,7 @@ where payment_hash, lease.payment_size_msat, expiry_secs, - Some(min_final_cltv_expiry_delta), + Some(LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA), Some(encoded_payment_metadata), ) .map_err(|e| { @@ -551,7 +555,7 @@ where .create_inbound_payment( lease.payment_size_msat, expiry_secs, - Some(min_final_cltv_expiry_delta), + Some(LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA), Some(encoded_payment_metadata), ) .map_err(|e| { @@ -576,7 +580,7 @@ where .payment_hash(payment_hash) .payment_secret(payment_secret) .current_timestamp() - .min_final_cltv_expiry_delta(min_final_cltv_expiry_delta.into()) + .min_final_cltv_expiry_delta(LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA.into()) .expiry_time(Duration::from_secs(expiry_secs.into())) .private_route(route_hint); @@ -751,4 +755,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..4791d80e99 --- /dev/null +++ b/src/liquidity/client/lsps2/router.rs @@ -0,0 +1,610 @@ +// 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::blinded_path::IntroductionNode; +use lightning::impl_writeable_tlv_based; +use lightning::ln::channel_state::ChannelDetails; +use lightning::ln::channelmanager::PaymentId; +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; + +use super::LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA; + +/// 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) -> Option { + match payment_context { + PaymentContext::Bolt12Offer(_) | PaymentContext::AsyncBolt12Offer(_) => {}, + _ => return None, + } + payment_context + .payment_metadata() + .and_then(PaymentMetadata::decode_from_bolt12_payment_metadata) + .and_then(|metadata| metadata.lsps2_lease_parameters) + } +} + +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 parameters = self.payment_parameters(&tlvs.payment_context); + let allow_mpp = parameters.map_or(true, |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, + first_hops, + tlvs.clone(), + amount_msats, + secp_ctx, + ); + 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 Some(params) = parameters else { + 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 + // it, we cannot constrain the JIT path to the full channel-open trigger amount. + return inner_paths; + }; + + // 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) { + return Err(()); + } + // Both fixed and variable invoices require the entire resolved payment on the JIT path. + // Fixed invoices may advertise MPP across regular paths, while variable invoices disable + // MPP at response construction. Both bounds keep the JIT payment indivisible. + let payment_constraints = PaymentConstraints { + max_cltv_expiry: tlvs + .payment_constraints + .max_cltv_expiry + .saturating_add(params.cltv_expiry_delta as u32), + htlc_minimum_msat: amount_msats, + }; + 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: amount_msats, + }; + BlindedPaymentPath::new( + &[forward_node], + recipient, + local_node_receive_key, + tlvs, + amount_msats, + LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA, + &self.entropy_source, + secp_ctx, + ) + .map(|path| vec![path]) + } +} + +#[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::ln::channel_state::{ChannelCounterparty, ChannelShutdownState}; + use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; + use lightning::ln::types::ChannelId; + use lightning::offers::invoice_request::InvoiceRequestFields; + use lightning::offers::offer::OfferId; + use lightning::types::features::InitFeatures; + 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, + path_kind: MockPathKind, + } + + #[derive(Clone, Copy)] + enum MockPathKind { + None, + ChannelBacked, + DirectRecipient, + } + + 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); + match self.path_kind { + MockPathKind::None => Err(()), + MockPathKind::DirectRecipient => BlindedPaymentPath::one_hop( + recipient, + local_node_receive_key, + tlvs, + MIN_FINAL_CLTV_EXPIRY_DELTA, + TestEntropy, + secp_ctx, + ) + .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]) + }, + } + } + } + + fn pubkey(byte: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[byte; 32]).unwrap()) + } + + fn first_hop( + inbound_capacity_msat: u64, inbound_htlc_minimum_msat: Option, + inbound_htlc_maximum_msat: Option, + ) -> ChannelDetails { + ChannelDetails { + channel_id: ChannelId::new_zero(), + counterparty: ChannelCounterparty { + node_id: pubkey(13), + features: InitFeatures::empty(), + unspendable_punishment_reserve: 0, + forwarding_info: None, + outbound_htlc_minimum_msat: None, + outbound_htlc_maximum_msat: None, + }, + funding_txo: None, + channel_type: None, + short_channel_id: None, + outbound_scid_alias: None, + inbound_scid_alias: None, + channel_value_satoshis: 0, + unspendable_punishment_reserve: None, + user_channel_id: 0, + feerate_sat_per_1000_weight: None, + outbound_capacity_msat: 0, + next_outbound_htlc_limit_msat: 0, + next_outbound_htlc_minimum_msat: 0, + next_splice_out_maximum_sat: 0, + inbound_capacity_msat, + confirmations_required: None, + confirmations: None, + force_close_spend_delay: None, + is_outbound: false, + is_channel_ready: true, + channel_shutdown_state: Some(ChannelShutdownState::NotShuttingDown), + is_usable: true, + is_announced: false, + inbound_htlc_minimum_msat, + inbound_htlc_maximum_msat, + config: None, + pending_inbound_htlcs: Vec::new(), + pending_outbound_htlcs: Vec::new(), + funding_redeem_script: None, + current_dust_exposure_msat: None, + splice_details: None, + } + } + + 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), path_kind: MockPathKind::None }; + 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); + assert_eq!(paths[0].payinfo.cltv_expiry_delta, 48 + MIN_FINAL_CLTV_EXPIRY_DELTA + 2); + } + + #[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), path_kind: MockPathKind::None }; + 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), path_kind: MockPathKind::None }; + 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), path_kind: MockPathKind::ChannelBacked }; + 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) + ); + } + + #[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 accepts_sufficient_mpp_direct_recipient_fallback() { + let recipient = pubkey(10); + 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 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![first_hop(1_500, None, None), first_hop(1_500, None, None)], + payment_tlvs(payment_metadata(parameters)), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(paths[0].introduction_node(), &IntroductionNode::NodeId(recipient)); + } + + #[test] + fn requires_one_sufficient_non_mpp_channel() { + 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: None, + 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![first_hop(2_000, None, None), first_hop(4_000, None, Some(2_500))], + payment_tlvs(payment_metadata(parameters)), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(paths[0].introduction_node(), &IntroductionNode::NodeId(lsp_node_id)); + } + + #[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" + ); + } +} diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index f620087d26..5bea4a8ae6 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -11,7 +11,6 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; -use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, Writeable}; use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; @@ -28,8 +27,9 @@ use crate::logger::{log_error, LdkLogger}; use crate::types::DynStore; use crate::Error; +use super::LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA; + pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; -const BOLT12_FINAL_CLTV_EXPIRY_DELTA: u16 = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; pub(crate) type PaymentLeaseStore = DataStore; @@ -406,7 +406,7 @@ pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { u16::try_from(cltv_expiry_delta) .ok() - .filter(|delta| *delta <= u16::MAX - BOLT12_FINAL_CLTV_EXPIRY_DELTA) + .filter(|delta| *delta <= u16::MAX - LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA) } pub(crate) fn now_secs() -> u64 { @@ -420,6 +420,7 @@ mod tests { use super::*; use bitcoin::secp256k1::{Secp256k1, SecretKey}; use lightning::io; + use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning::util::ser::{Readable, Writeable}; use lightning::util::test_utils::TestLogger; 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 0c049fac74245279a7f26bdf4b486326ecd66f61 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:42:26 +0200 Subject: [PATCH 13/35] 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 | 3 ++- 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 src/payment/bolt12/offers.rs diff --git a/src/builder.rs b/src/builder.rs index 54e79f4b3a..29811cf4e0 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -99,6 +99,7 @@ use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::payment::forwarding_store::ForwardingStore; #[cfg(feature = "unified-payments")] use crate::payment::HRNResolver; +use crate::payment::NodeOffersMessageHandler; use crate::peer_store::PeerStore; use crate::probing::{ HighDegreeStrategy, Prober, ProbingConfig, ProbingStrategy, ProbingStrategyKind, @@ -2217,6 +2218,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() { @@ -2296,7 +2299,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 {}, @@ -2309,7 +2312,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 4f8bc1eefd..1413ec911c 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 3d5ea37ffb..64bc7b526e 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -23,6 +23,7 @@ pub(crate) mod store; mod unified; pub use bolt11::Bolt11Payment; +pub(crate) use bolt12::NodeOffersMessageHandler; pub use bolt12::{Bolt12Payment, PayerProofOptions}; pub use forwarding::{ ChannelForwardingStats, ChannelForwardingStatsPage, ChannelPairForwardingStats, diff --git a/src/types.rs b/src/types.rs index 26049e8a92..94af5110cd 100644 --- a/src/types.rs +++ b/src/types.rs @@ -42,6 +42,7 @@ use crate::fee_estimator::OnchainFeeEstimator; use crate::ffi::maybe_wrap; use crate::logger::Logger; use crate::message_handler::NodeCustomMessageHandler; +use crate::payment::NodeOffersMessageHandler; use crate::payment::{ ChannelPairForwardingStats, ForwardedPaymentDetails, PaymentDetails, PendingPaymentDetails, }; @@ -305,7 +306,7 @@ pub(crate) type OnionMessenger = lightning::onion_message::messenger::OnionMesse Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, IgnoringMessageHandler, From 5a08e608c865d4cf60ef72a74f96efba58346200 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:48:01 +0200 Subject: [PATCH 14/35] 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 | 24 ++++++-- src/chain/electrum.rs | 19 +++++-- src/chain/esplora.rs | 19 +++++-- src/chain/mod.rs | 104 +++++++++++++++++++++++++++++------ src/lib.rs | 17 +++++- src/payment/bolt12/offers.rs | 71 ++++++++++++++++++++++-- 7 files changed, 224 insertions(+), 41 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 29811cf4e0..c22aeacced 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2218,8 +2218,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() { @@ -2590,6 +2596,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 2628294160..c0e6c4a932 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -44,6 +44,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::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -257,8 +258,8 @@ impl BitcoindChainSource { pub(super) async fn initialize_wallets( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, - tx_broadcaster: &Broadcaster, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, tx_broadcaster: &Broadcaster, ) -> Result<(), Error> { // First register for the wallet polling status to make sure `Node::sync_wallets` calls // wait on the result before proceeding. @@ -283,11 +284,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 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)), ]; @@ -309,6 +312,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), ); @@ -350,7 +354,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, ) { let mut chain_polling_interval = tokio::time::interval(Duration::from_secs(CHAIN_POLLING_INTERVAL_SECS)); @@ -389,6 +394,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) ) => {} @@ -446,7 +452,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"); @@ -468,6 +475,7 @@ impl BitcoindChainSource { .poll_and_update_listeners_inner( onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) @@ -480,7 +488,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() { @@ -495,6 +504,7 @@ impl BitcoindChainSource { HeaderCache::new(), Arc::clone(&onchain_wallet), Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), chain_monitor, output_sweeper, )); @@ -561,12 +571,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), }); diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 86025998e8..709f600c55 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}; @@ -220,7 +221,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 = { @@ -238,8 +240,14 @@ impl ElectrumChainSource { let sync_guard = WalletSyncGuard::new(&self.lightning_wallet_sync_status, Error::TxSyncFailed); - 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; sync_guard.complete(res); @@ -247,14 +255,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> = 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 1c13f141fb..e87d075d1f 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}; @@ -270,7 +271,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 = { @@ -288,8 +290,14 @@ impl EsploraChainSource { let sync_guard = WalletSyncGuard::new(&self.lightning_wallet_sync_status, Error::WalletOperationFailed); - 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; sync_guard.complete(res); @@ -297,14 +305,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 e1f7e2ad41..36325cf3c5 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -38,6 +38,7 @@ use crate::config::EsploraSyncConfig; use crate::config::{BackgroundSyncConfig, Config, WALLET_SYNC_INTERVAL_MINIMUM_SECS}; use crate::fee_estimator::OnchainFeeEstimator; use crate::logger::{log_debug, log_info, log_trace, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::runtime::Runtime; use crate::tx_broadcaster::BroadcastPackage; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; @@ -166,6 +167,7 @@ enum ChainSourceKind { 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, } @@ -174,10 +176,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()?, )) @@ -190,32 +199,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); } @@ -374,7 +401,8 @@ impl ChainSource { pub(crate) async fn initialize_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, zero_fee_commitments_support_required: bool, ) -> Result<(), Error> { // Replay can broadcast claims, so refresh fees and validate the backend before syncing. @@ -396,6 +424,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, &self.tx_broadcaster, @@ -404,18 +433,33 @@ impl ChainSource { }, #[cfg(feature = "chain-esplora")] ChainSourceKind::Esplora(source) => { - source.sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper).await + source + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await }, #[cfg(feature = "chain-electrum")] ChainSourceKind::Electrum(source) => { - source.sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper).await + source + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await }, } } 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 { @@ -428,6 +472,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, background_sync_config, @@ -452,6 +497,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, background_sync_config, @@ -474,6 +520,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) @@ -485,8 +532,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 @@ -533,6 +581,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; @@ -567,20 +616,31 @@ 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 { #[cfg(feature = "chain-esplora")] 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 }, #[cfg(feature = "chain-electrum")] 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 }, #[cfg(feature = "chain-bitcoind")] @@ -594,10 +654,17 @@ 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> { #[cfg(not(feature = "chain-bitcoind"))] - let _ = (&onchain_wallet, &channel_manager, &chain_monitor, &output_sweeper); + let _ = ( + &onchain_wallet, + &channel_manager, + &offers_message_handler, + &chain_monitor, + &output_sweeper, + ); match &self.kind { #[cfg(feature = "chain-esplora")] @@ -618,6 +685,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 9d0e5dcc38..2fdd4d00f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -178,8 +178,8 @@ use payment::asynchronous::static_invoice_store::StaticInvoiceStore; pub use payment::forwarding_store::aggregate_channel_pair_stats; use payment::forwarding_store::{run_forwarded_payment_aggregation, ForwardingStore}; use payment::{ - Bolt11Payment, Bolt12Payment, ForwardingAnalytics, OnchainPayment, PaymentDetails, - PaymentDetailsPage, SpontaneousPayment, + Bolt11Payment, Bolt12Payment, ForwardingAnalytics, NodeOffersMessageHandler, OnchainPayment, + PaymentDetails, PaymentDetailsPage, SpontaneousPayment, }; #[cfg(feature = "unified-payments")] use payment::{HRNResolver, UnifiedPayment}; @@ -261,6 +261,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, @@ -386,6 +387,7 @@ impl Node { self.stop_sender.subscribe(), Arc::clone(&self.wallet), Arc::clone(&self.channel_manager), + Arc::clone(&self.offers_message_handler), Arc::clone(&self.chain_monitor), Arc::clone(&self.output_sweeper), zero_fee_commitments_support_required, @@ -396,6 +398,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 { @@ -404,6 +407,7 @@ impl Node { stop_sync_receiver, sync_wallet, sync_cman, + sync_offers, sync_cmon, sync_sweeper, ) @@ -2231,13 +2235,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 { @@ -2246,6 +2256,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..7013e607f1 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,24 +5,57 @@ // 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) } + } + + #[cfg(feature = "chain-bitcoind")] + pub(crate) fn current_best_block(&self) -> BlockLocator { + *self.best_block.read().expect("lock") } } @@ -37,3 +70,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 b150098014cf2a09b579f83c43d26195a9661caf Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:10:30 +0200 Subject: [PATCH 15/35] 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 c22aeacced..8af6c0a307 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -91,6 +91,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}; @@ -2129,12 +2130,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); @@ -2523,12 +2527,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 94af5110cd..f3dddcb396 100644 --- a/src/types.rs +++ b/src/types.rs @@ -40,6 +40,7 @@ use crate::config::{AnchorChannelsConfig, ChannelConfig}; use crate::data_store::{DataStore, KeepAllEntries, KeepLeastRecentlyUsed, KeepNoEntries}; 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; @@ -273,7 +274,8 @@ pub(crate) type Broadcaster = crate::tx_broadcaster::TransactionBroadcaster>; +pub(crate) type BaseRouter = DefaultRouter< Arc, Arc, Arc, From a340a5bf019ea3cd44bbb1ac06244e5ea18db82c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:53:51 +0200 Subject: [PATCH 16/35] 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 8af6c0a307..47da82ab77 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2227,6 +2227,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 7013e607f1..d03ba2ad05 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), + } } #[cfg(feature = "chain-bitcoind")] @@ -63,7 +78,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 4b5f8175830af1340d863b5f4bf5c46ba3387aeb Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:26:11 +0200 Subject: [PATCH 17/35] 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 | 92 +++++- src/payment/bolt12/offers.rs | 446 +++++++++++++++++++++++++----- 3 files changed, 480 insertions(+), 64 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 47da82ab77..4f041d98d4 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2450,6 +2450,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 7f785527dd..1287065d2a 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, @@ -62,6 +63,31 @@ where } } +const DEFAULT_BOLT12_INVOICE_EXPIRY_SECS: u32 = 2 * 60 * 60; + +#[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, @@ -116,6 +142,70 @@ where self.cache_target_store.targets() } + pub(crate) async fn commit_invoice_cache_target(&self, request: JitInvoiceRequest) { + // Cache targets are only an optimization. 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); + } + } + + pub(crate) async fn prepare_invoice_response( + self: Arc, request: JitInvoiceRequest, connection_manager: Arc>, + ) -> Result>, Error> { + 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: 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, + }; + let payment_metadata = PaymentMetadata { + lsps2_parameters: Some(fee_parameters), + lsps2_lease_parameters: Some(lease_parameters), + } + .encode_as_bolt12_payment_metadata(); + + Ok(payment_metadata) + } + 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 d03ba2ad05..61150bdf45 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,37 +5,84 @@ // 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::future::Future; +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, 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, + } + } +} + +async fn commit_cache_target_on_success( + result: Result, commit: C, +) -> Result +where + C: FnOnce() -> Fut, + Fut: Future, +{ + if result.is_ok() { + commit().await; + } + result +} + +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 +94,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,24 +103,125 @@ 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" + ); + } + #[cfg(feature = "chain-bitcoind")] 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, @@ -103,61 +251,131 @@ 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)) - }), - }; + // 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 = 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, + ); - 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 (Some(connection_manager), Some(onion_messenger)) = + (dependencies.connection_manager.upgrade(), dependencies.onion_messenger.upgrade()) + else { + 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 = Arc::clone(&lsps2_client) + .prepare_invoice_response(jit_request, connection_manager) + .await; + let (message, instructions) = match response { + Ok(jit_metadata) => { + let mut merged_metadata = payment_metadata.unwrap_or_default(); + merged_metadata.extend(jit_metadata); + let invoice_result = 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), + jit_request.allow_mpp(), + &payment_info, + ); + let invoice_result = commit_cache_target_on_success(invoice_result, || { + let lsps2_client = Arc::clone(&lsps2_client); + async move { lsps2_client.commit_invoice_cache_target(jit_request).await } + }) + .await; + match invoice_result { + 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)> { @@ -194,3 +412,105 @@ impl Listen for NodeOffersMessageHandler { *self.best_block.write().expect("lock") = fork_point_block; } } + +#[cfg(test)] +mod tests { + use core::sync::atomic::{AtomicBool, Ordering}; + 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()) + } + + #[tokio::test] + async fn failed_invoice_does_not_commit_cache_target() { + let committed = AtomicBool::new(false); + let result = commit_cache_target_on_success(Err::<(), _>(()), || async { + committed.store(true, Ordering::Release); + }) + .await; + + assert!(result.is_err()); + assert!( + !committed.load(Ordering::Acquire), + "failed invoice construction committed a lease cache target" + ); + } + + #[tokio::test] + async fn successful_invoice_commits_cache_target() { + let committed = AtomicBool::new(false); + let result = commit_cache_target_on_success(Ok::<_, ()>(()), || async { + committed.store(true, Ordering::Release); + }) + .await; + + assert!(result.is_ok()); + assert!(committed.load(Ordering::Acquire)); + } + + #[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 8c0ceffdb44ce64f03648c481d3c8491330fa8a2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 15:04:33 +0200 Subject: [PATCH 18/35] 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 | 420 +++++++++++++++++++++++++++++++++-- 1 file changed, 404 insertions(+), 16 deletions(-) diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index 61150bdf45..e16d3370ec 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,12 +5,14 @@ // 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::collections::{BTreeMap, HashMap}; use std::future::Future; -use std::sync::{Arc, OnceLock, RwLock, Weak}; +use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak}; +use std::time::{Duration, Instant}; 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 +31,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, LSPS2Client}; @@ -66,6 +69,137 @@ where result } +const MAX_PENDING_JIT_INVOICE_REQUESTS: usize = 100; +const MAX_PENDING_JIT_INVOICE_RESPONDERS: usize = 100; +const MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST: usize = 10; +const MAX_COMPLETED_JIT_INVOICE_RESPONSES: usize = 100; +const JIT_INVOICE_RESPONSE_CACHE_LIFETIME: Duration = Duration::from_secs(60); + +struct PendingJitInvoiceRequests { + state: Arc>>, + request_limit: usize, + responder_limit: usize, + per_request_limit: usize, +} + +struct PendingJitInvoiceRequestState { + requests: HashMap>, + completed_responses: HashMap, + responder_count: usize, +} + +impl PendingJitInvoiceRequestState { + fn expire_completed_responses(&mut self) { + self.completed_responses.retain(|_, (completed_at, invoice, _)| { + completed_at.elapsed() < JIT_INVOICE_RESPONSE_CACHE_LIFETIME && !invoice.is_expired() + }); + } +} + +impl PendingJitInvoiceRequests { + fn new(request_limit: usize, responder_limit: usize, per_request_limit: usize) -> Self { + Self { + state: Arc::new(Mutex::new(PendingJitInvoiceRequestState { + requests: HashMap::new(), + completed_responses: HashMap::new(), + responder_count: 0, + })), + request_limit, + responder_limit, + per_request_limit, + } + } + + fn cached_response( + &self, request_id: &sha256::Hash, + ) -> Option<(Bolt12Invoice, MessageContext)> { + let mut state = self.state.lock().expect("lock"); + state.expire_completed_responses(); + state + .completed_responses + .get(request_id) + .map(|(_, invoice, context)| (invoice.clone(), context.clone())) + } + + fn register( + &self, request_id: sha256::Hash, responder: T, + ) -> PendingJitInvoiceRequestRegistration { + let mut state = self.state.lock().expect("lock"); + state.expire_completed_responses(); + if let Some((_, invoice, context)) = state.completed_responses.get(&request_id) { + return PendingJitInvoiceRequestRegistration::Completed( + invoice.clone(), + context.clone(), + responder, + ); + } + let responder_limit_reached = state.responder_count >= self.responder_limit; + if let Some(responders) = state.requests.get_mut(&request_id) { + if responder_limit_reached || responders.len() >= self.per_request_limit { + return PendingJitInvoiceRequestRegistration::ReplayLimitReached; + } + responders.push(responder); + state.responder_count += 1; + return PendingJitInvoiceRequestRegistration::Joined; + } + if state.requests.len() >= self.request_limit || responder_limit_reached { + return PendingJitInvoiceRequestRegistration::AtCapacity(responder); + } + state.requests.insert(request_id, vec![responder]); + state.responder_count += 1; + PendingJitInvoiceRequestRegistration::Started(PendingJitInvoiceRequest { + request_id: Some(request_id), + state: Arc::clone(&self.state), + }) + } +} + +enum PendingJitInvoiceRequestRegistration { + Started(PendingJitInvoiceRequest), + Completed(Bolt12Invoice, MessageContext, T), + Joined, + ReplayLimitReached, + AtCapacity(T), +} + +struct PendingJitInvoiceRequest { + request_id: Option, + state: Arc>>, +} + +impl PendingJitInvoiceRequest { + fn complete(mut self, response: Option<(Bolt12Invoice, MessageContext)>) -> Vec { + self.finish(response) + } + + fn finish(&mut self, response: Option<(Bolt12Invoice, MessageContext)>) -> Vec { + let Some(request_id) = self.request_id.take() else { return Vec::new() }; + let mut state = self.state.lock().expect("lock"); + let responders = state.requests.remove(&request_id).unwrap_or_default(); + state.responder_count -= responders.len(); + if let Some((invoice, context)) = response { + state.expire_completed_responses(); + if state.completed_responses.len() >= MAX_COMPLETED_JIT_INVOICE_RESPONSES { + let oldest_request_id = *state + .completed_responses + .iter() + .min_by_key(|(_, (completed_at, _, _))| *completed_at) + .expect("non-empty cache") + .0; + state.completed_responses.remove(&oldest_request_id); + } + state.completed_responses.insert(request_id, (Instant::now(), invoice, context)); + } + responders + } +} + +impl Drop for PendingJitInvoiceRequest { + fn drop(&mut self) { + self.finish(None); + } +} + struct JitInvoiceRequestDependencies { runtime: Arc, lsps2_client: Arc>>, @@ -82,6 +216,7 @@ pub(crate) struct NodeOffersMessageHandler { secp_ctx: Arc>, best_block: RwLock, jit_dependencies: OnceLock, + pending_jit_invoice_requests: PendingJitInvoiceRequests, logger: Arc, } @@ -113,6 +248,11 @@ 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, + MAX_PENDING_JIT_INVOICE_RESPONDERS, + MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST, + ), logger, } } @@ -238,6 +378,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() { @@ -251,6 +392,17 @@ impl OffersMessageHandler for NodeOffersMessageHandler { Ok(InvreqResponseInstructions::SendStaticInvoice { .. }) | Err(()) => return None, }; + // Replays must reuse the issued invoice even if ordinary payment paths became available + // after its JIT lease was negotiated. Verification above still authenticates every request. + if let Some((invoice, context)) = + self.pending_jit_invoice_requests.cached_response(&invoice_request_id) + { + return Some(( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + )); + } + // 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. @@ -317,6 +469,26 @@ impl OffersMessageHandler for NodeOffersMessageHandler { responder.respond(), )); }; + let pending_request = + match self.pending_jit_invoice_requests.register(invoice_request_id, responder) { + PendingJitInvoiceRequestRegistration::Started(pending_request) => pending_request, + PendingJitInvoiceRequestRegistration::Completed(invoice, context, responder) => { + return Some(( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + )); + }, + PendingJitInvoiceRequestRegistration::Joined => return None, + PendingJitInvoiceRequestRegistration::ReplayLimitReached => return None, + PendingJitInvoiceRequestRegistration::AtCapacity(responder) => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "Too many pending JIT invoice requests".to_owned(), + )), + responder.respond(), + )); + }, + }; let lsps2_client = Arc::clone(&dependencies.lsps2_client); let flow = Arc::clone(&self.flow); @@ -329,7 +501,7 @@ impl OffersMessageHandler for NodeOffersMessageHandler { let response = Arc::clone(&lsps2_client) .prepare_invoice_response(jit_request, connection_manager) .await; - let (message, instructions) = match response { + let (message, reply_context) = match response { Ok(jit_metadata) => { let mut merged_metadata = payment_metadata.unwrap_or_default(); merged_metadata.extend(jit_metadata); @@ -350,14 +522,10 @@ impl OffersMessageHandler for NodeOffersMessageHandler { }) .await; match invoice_result { - Ok((invoice, context)) => ( - OffersMessage::Invoice(invoice), - responder.respond_with_reply_path(context), - ), - Err(error) => ( - OffersMessage::InvoiceError(error.into_invoice_error()), - responder.respond(), - ), + Ok((invoice, context)) => (OffersMessage::Invoice(invoice), Some(context)), + Err(error) => { + (OffersMessage::InvoiceError(error.into_invoice_error()), None) + }, } }, Err(error) => { @@ -366,13 +534,26 @@ impl OffersMessageHandler for NodeOffersMessageHandler { OffersMessage::InvoiceError(InvoiceError::from_string( "Failed preparing JIT invoice".to_owned(), )), - responder.respond(), + None, ) }, }; - if let Err(error) = onion_messenger.handle_onion_message_response(message, instructions) - { - log_error!(logger, "Failed sending LSPS2 invoice response: {:?}", error); + let cached_response = match (&message, &reply_context) { + (OffersMessage::Invoice(invoice), Some(context)) => { + Some((invoice.clone(), context.clone())) + }, + _ => None, + }; + for responder in pending_request.complete(cached_response) { + let instructions = match reply_context.as_ref() { + Some(context) => responder.respond_with_reply_path(context.clone()), + None => responder.respond(), + }; + if let Err(error) = + onion_messenger.handle_onion_message_response(message.clone(), instructions) + { + log_error!(logger, "Failed sending LSPS2 invoice response: {:?}", error); + } } }); None @@ -417,12 +598,16 @@ impl Listen for NodeOffersMessageHandler { mod tests { use core::sync::atomic::{AtomicBool, Ordering}; use std::num::NonZeroU64; + use std::time::{SystemTime, UNIX_EPOCH}; - use bitcoin::secp256k1::{PublicKey, SecretKey}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + use lightning::blinded_path::payment::{BlindedPayInfo, BlindedPaymentPath}; + use lightning::blinded_path::BlindedHop; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::offers::nonce::Nonce; use lightning::offers::offer::{OfferBuilder, Quantity}; + use lightning::types::features::BlindedHopFeatures; use super::*; @@ -438,6 +623,48 @@ mod tests { PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[43; 32]).unwrap()) } + fn invoice_response(created_at: Duration) -> (Bolt12Invoice, MessageContext) { + let expanded_key = ExpandedKey::new([44; 32]); + let nonce = Nonce::from_entropy_source(&FixedEntropy); + let secp_ctx = Secp256k1::new(); + let payment_hash = PaymentHash([45; 32]); + let path = BlindedPaymentPath::from_blinded_path_and_payinfo( + recipient_pubkey(), + recipient_pubkey(), + vec![BlindedHop { + blinded_node_id: recipient_pubkey(), + encrypted_payload: vec![0; 43], + }], + BlindedPayInfo { + fee_base_msat: 0, + fee_proportional_millionths: 0, + cltv_expiry_delta: 18, + htlc_minimum_msat: 1, + htlc_maximum_msat: 100_000, + features: BlindedHopFeatures::empty(), + }, + ); + let invoice = OfferBuilder::new(recipient_pubkey()) + .amount_msats(1_000) + .build() + .unwrap() + .request_invoice(&expanded_key, nonce, &secp_ctx, PaymentId([46; 32])) + .unwrap() + .build_and_sign() + .unwrap() + .respond_with_no_std(vec![path], payment_hash, created_at) + .unwrap() + .build() + .unwrap() + .sign(|invoice: &UnsignedBolt12Invoice| { + let keypair = + Keypair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[43; 32]).unwrap()); + Ok(secp_ctx.sign_schnorr_no_aux_rand(invoice.tagged_hash().as_digest(), &keypair)) + }) + .unwrap(); + (invoice, MessageContext::Offers(OffersContext::InboundPayment { payment_hash })) + } + #[tokio::test] async fn failed_invoice_does_not_commit_cache_target() { let committed = AtomicBool::new(false); @@ -513,4 +740,165 @@ mod tests { ); assert!(!request.allow_mpp()); } + + fn started_request( + registration: PendingJitInvoiceRequestRegistration, + ) -> PendingJitInvoiceRequest { + match registration { + PendingJitInvoiceRequestRegistration::Started(request) => request, + _ => panic!("expected a new pending JIT invoice request"), + } + } + + #[test] + fn completed_jit_invoice_request_reuses_response() { + let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let first = started_request(pending_requests.register(request_id, 1)); + let response = invoice_response(SystemTime::now().duration_since(UNIX_EPOCH).unwrap()); + assert_eq!(first.complete(Some(response.clone())), vec![1]); + + assert!( + !matches!( + pending_requests.register(request_id, 2), + PendingJitInvoiceRequestRegistration::Started(_) + ), + "a completed invoice request replay started a new lease and invoice" + ); + let (invoice, context) = pending_requests.cached_response(&request_id).unwrap(); + assert_eq!(invoice.encode(), response.0.encode()); + assert_eq!(context.encode(), response.1.encode()); + match pending_requests.register(request_id, 3) { + PendingJitInvoiceRequestRegistration::Completed(invoice, context, responder) => { + assert_eq!(invoice.encode(), response.0.encode()); + assert_eq!(context.encode(), response.1.encode()); + assert_eq!(responder, 3); + }, + _ => panic!("expected the same response with the replay's responder"), + } + } + + #[test] + fn completed_jit_invoice_responses_are_bounded() { + let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); + let response = invoice_response(SystemTime::now().duration_since(UNIX_EPOCH).unwrap()); + let oldest_id = sha256::Hash::from_byte_array([0; 32]); + let first = started_request(pending_requests.register(oldest_id, 0)); + assert_eq!(first.complete(Some(response.clone())), vec![0]); + pending_requests + .state + .lock() + .unwrap() + .completed_responses + .get_mut(&oldest_id) + .unwrap() + .0 -= Duration::from_secs(1); + for index in 1..=MAX_COMPLETED_JIT_INVOICE_RESPONSES { + let request_id = sha256::Hash::hash(&index.to_be_bytes()); + let request = started_request(pending_requests.register(request_id, index)); + assert_eq!(request.complete(Some(response.clone())), vec![index]); + } + assert!(pending_requests.cached_response(&oldest_id).is_none()); + assert_eq!( + pending_requests.state.lock().unwrap().completed_responses.len(), + MAX_COMPLETED_JIT_INVOICE_RESPONSES + ); + } + + #[test] + fn completed_jit_invoice_responses_expire() { + let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let first = started_request(pending_requests.register(request_id, 1)); + let response = invoice_response(SystemTime::now().duration_since(UNIX_EPOCH).unwrap()); + assert_eq!(first.complete(Some(response)), vec![1]); + pending_requests + .state + .lock() + .unwrap() + .completed_responses + .get_mut(&request_id) + .unwrap() + .0 -= JIT_INVOICE_RESPONSE_CACHE_LIFETIME; + assert!(pending_requests.cached_response(&request_id).is_none()); + + let retry = started_request(pending_requests.register(request_id, 2)); + let expired_response = invoice_response(Duration::ZERO); + assert!(expired_response.0.is_expired()); + assert_eq!(retry.complete(Some(expired_response)), vec![2]); + assert!(pending_requests.cached_response(&request_id).is_none()); + assert!(matches!( + pending_requests.register(request_id, 3), + PendingJitInvoiceRequestRegistration::Started(_) + )); + } + + #[test] + fn failed_jit_invoice_requests_can_retry() { + let pending_requests = PendingJitInvoiceRequests::new(1, 1, 1); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let first = started_request(pending_requests.register(request_id, 1)); + assert_eq!(first.complete(None), vec![1]); + assert!(matches!( + pending_requests.register(request_id, 2), + PendingJitInvoiceRequestRegistration::Started(_) + )); + } + + #[test] + fn pending_jit_invoice_requests_are_bounded() { + let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); + let first = + started_request(pending_requests.register(sha256::Hash::from_byte_array([1; 32]), 1)); + let _second = + started_request(pending_requests.register(sha256::Hash::from_byte_array([2; 32]), 2)); + + assert!(matches!( + pending_requests.register(sha256::Hash::from_byte_array([3; 32]), 3), + PendingJitInvoiceRequestRegistration::AtCapacity(3) + )); + + drop(first); + assert!(matches!( + pending_requests.register(sha256::Hash::from_byte_array([3; 32]), 3), + PendingJitInvoiceRequestRegistration::Started(_) + )); + } + + #[test] + fn pending_jit_invoice_requests_fan_out_replays() { + let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let first = started_request(pending_requests.register(request_id, 1)); + + assert!( + matches!( + pending_requests.register(request_id, 2), + PendingJitInvoiceRequestRegistration::Joined + ), + "an in-flight invoice request replay's responder was discarded" + ); + assert!(matches!( + pending_requests.register(sha256::Hash::from_byte_array([2; 32]), 3), + PendingJitInvoiceRequestRegistration::AtCapacity(3) + )); + assert_eq!(first.complete(None), vec![1, 2]); + } + + #[test] + fn pending_jit_invoice_requests_bound_replay_responders() { + let pending_requests = PendingJitInvoiceRequests::new(3, 3, 2); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let first = started_request(pending_requests.register(request_id, 1)); + + assert!(matches!( + pending_requests.register(request_id, 2), + PendingJitInvoiceRequestRegistration::Joined + )); + assert!(matches!( + pending_requests.register(request_id, 3), + PendingJitInvoiceRequestRegistration::ReplayLimitReached + )); + assert_eq!(first.complete(None), vec![1, 2]); + } } From c44768eff01140696c2a94af0a7b8ee7e2aee2b8 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 16 Oct 2026 14:42:47 +0200 Subject: [PATCH 19/35] f Preserve slots for distinct JIT requests Replay responders shared a global limit equal to the request limit. Ten requests with ten reply paths could therefore block new payers while most request slots remained unused. Size the global responder budget for all pending requests so replay copies cannot consume capacity reserved for other payers. Retain the per-request bound to keep memory use predictable. Co-Authored-By: HAL 9000 --- src/payment/bolt12/offers.rs | 44 +++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index e16d3370ec..26a3896a33 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -70,8 +70,9 @@ where } const MAX_PENDING_JIT_INVOICE_REQUESTS: usize = 100; -const MAX_PENDING_JIT_INVOICE_RESPONDERS: usize = 100; const MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST: usize = 10; +const MAX_PENDING_JIT_INVOICE_RESPONDERS: usize = + MAX_PENDING_JIT_INVOICE_REQUESTS * MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST; const MAX_COMPLETED_JIT_INVOICE_RESPONSES: usize = 100; const JIT_INVOICE_RESPONSE_CACHE_LIFETIME: Duration = Duration::from_secs(60); @@ -865,6 +866,47 @@ mod tests { )); } + #[test] + fn replay_responders_leave_room_for_other_requests() { + let pending_requests = PendingJitInvoiceRequests::new( + MAX_PENDING_JIT_INVOICE_REQUESTS, + MAX_PENDING_JIT_INVOICE_RESPONDERS, + MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST, + ); + let mut requests = Vec::new(); + for index in 0..MAX_PENDING_JIT_INVOICE_REQUESTS { + let request_id = sha256::Hash::hash(&index.to_be_bytes()); + let registration = pending_requests.register(request_id, 0); + assert!( + matches!(registration, PendingJitInvoiceRequestRegistration::Started(_)), + "replay responders exhausted capacity before all request slots were used" + ); + requests.push(started_request(registration)); + for responder in 1..MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST { + assert!(matches!( + pending_requests.register(request_id, responder), + PendingJitInvoiceRequestRegistration::Joined + )); + } + assert!(matches!( + pending_requests.register(request_id, MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST), + PendingJitInvoiceRequestRegistration::ReplayLimitReached + )); + } + let next_id = sha256::Hash::hash(&MAX_PENDING_JIT_INVOICE_REQUESTS.to_be_bytes()); + assert!(matches!( + pending_requests.register(next_id, 0), + PendingJitInvoiceRequestRegistration::AtCapacity(0) + )); + + let responders = requests.pop().unwrap().complete(None); + assert_eq!(responders, (0..MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST).collect::>()); + assert!(matches!( + pending_requests.register(next_id, 0), + PendingJitInvoiceRequestRegistration::Started(_) + )); + } + #[test] fn pending_jit_invoice_requests_fan_out_replays() { let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); From 59ac345f42144a3cbb70c6652c18dd403ae256f2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 16 Oct 2026 14:45:00 +0200 Subject: [PATCH 20/35] f Keep JIT responses until invoice expiry Completed responses expired after one minute even when their invoices remained payable for two hours. A delayed replay could consume a fresh single-use lease and trigger another refill. Retain responses while their invoices remain valid so delayed copies can reuse the original invoice. Keep the entry limit and eviction of the oldest response to bound memory use. Co-Authored-By: HAL 9000 --- src/payment/bolt12/offers.rs | 43 +++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index 26a3896a33..2c419bb521 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -8,7 +8,7 @@ use std::collections::{BTreeMap, HashMap}; use std::future::Future; use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak}; -use std::time::{Duration, Instant}; +use std::time::Instant; use bitcoin::block::Header; use bitcoin::constants::ChainHash; @@ -74,7 +74,6 @@ const MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST: usize = 10; const MAX_PENDING_JIT_INVOICE_RESPONDERS: usize = MAX_PENDING_JIT_INVOICE_REQUESTS * MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST; const MAX_COMPLETED_JIT_INVOICE_RESPONSES: usize = 100; -const JIT_INVOICE_RESPONSE_CACHE_LIFETIME: Duration = Duration::from_secs(60); struct PendingJitInvoiceRequests { state: Arc>>, @@ -91,9 +90,7 @@ struct PendingJitInvoiceRequestState { impl PendingJitInvoiceRequestState { fn expire_completed_responses(&mut self) { - self.completed_responses.retain(|_, (completed_at, invoice, _)| { - completed_at.elapsed() < JIT_INVOICE_RESPONSE_CACHE_LIFETIME && !invoice.is_expired() - }); + self.completed_responses.retain(|_, (_, invoice, _)| !invoice.is_expired()); } } @@ -599,7 +596,7 @@ impl Listen for NodeOffersMessageHandler { mod tests { use core::sync::atomic::{AtomicBool, Ordering}; use std::num::NonZeroU64; - use std::time::{SystemTime, UNIX_EPOCH}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; use lightning::blinded_path::payment::{BlindedPayInfo, BlindedPaymentPath}; @@ -807,12 +804,15 @@ mod tests { } #[test] - fn completed_jit_invoice_responses_expire() { + fn completed_jit_invoice_responses_follow_invoice_expiry() { let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); let request_id = sha256::Hash::from_byte_array([1; 32]); let first = started_request(pending_requests.register(request_id, 1)); - let response = invoice_response(SystemTime::now().duration_since(UNIX_EPOCH).unwrap()); - assert_eq!(first.complete(Some(response)), vec![1]); + let elapsed = Duration::from_secs(60 * 60); + let response = + invoice_response(SystemTime::now().duration_since(UNIX_EPOCH).unwrap() - elapsed); + assert!(!response.0.is_expired()); + assert_eq!(first.complete(Some(response.clone())), vec![1]); pending_requests .state .lock() @@ -820,16 +820,29 @@ mod tests { .completed_responses .get_mut(&request_id) .unwrap() - .0 -= JIT_INVOICE_RESPONSE_CACHE_LIFETIME; - assert!(pending_requests.cached_response(&request_id).is_none()); + .0 -= elapsed; + assert!( + pending_requests.cached_response(&request_id).is_some(), + "an unexpired invoice response was discarded before a delayed replay" + ); + match pending_requests.register(request_id, 2) { + PendingJitInvoiceRequestRegistration::Completed(invoice, context, responder) => { + assert_eq!(invoice.encode(), response.0.encode()); + assert_eq!(context.encode(), response.1.encode()); + assert_eq!(responder, 2); + }, + _ => panic!("a delayed replay did not reuse its unexpired invoice"), + } - let retry = started_request(pending_requests.register(request_id, 2)); + let expired_request_id = sha256::Hash::from_byte_array([2; 32]); + let expired = started_request(pending_requests.register(expired_request_id, 3)); let expired_response = invoice_response(Duration::ZERO); assert!(expired_response.0.is_expired()); - assert_eq!(retry.complete(Some(expired_response)), vec![2]); - assert!(pending_requests.cached_response(&request_id).is_none()); + assert_eq!(expired.complete(Some(expired_response)), vec![3]); + assert!(pending_requests.cached_response(&expired_request_id).is_none()); + assert!(pending_requests.cached_response(&request_id).is_some()); assert!(matches!( - pending_requests.register(request_id, 3), + pending_requests.register(expired_request_id, 4), PendingJitInvoiceRequestRegistration::Started(_) )); } From 0917ef64c2bd4db879ce6609cedf8dbeb281cd51 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:39:17 +0200 Subject: [PATCH 21/35] 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 7f49428d29..c90717306d 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 c74bedb9d5b98616808e27a2262f0bfab905ce0b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:43:24 +0200 Subject: [PATCH 22/35] 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 --- CHANGELOG.md | 2 + src/event.rs | 130 +++++++++++++++++++++++++++++--- src/payment/bolt12/mod.rs | 10 ++- src/payment/metadata.rs | 15 ++++ src/payment/store.rs | 120 +++++++++++++++++++++++++---- tests/integration_tests_rust.rs | 4 + 6 files changed, 254 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e655375909..d1606d72c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ - LSPS2 just-in-time receive fee limits are now configured globally through `Config::lsps2_max_total_lsp_fee_limit_msat`. The corresponding per-call fee-limit arguments have been removed from the BOLT 11 receive APIs. +- `PaymentKind::Bolt12Offer` now exposes `counterparty_skimmed_fee_msat` for the fee deducted by + an LSP when an inbound payment is received through an LSPS2 just-in-time channel. - The Bitcoin Core RPC and REST chain-source builder methods now accept an optional `wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known birthday block instead of checkpointing at the current tip, which is useful when restoring a diff --git a/src/event.rs b/src/event.rs index 44f53042a6..4864c0fa20 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::{HashMap, VecDeque}; +use std::collections::{BTreeMap, HashMap, VecDeque}; use std::ops::Deref; use std::sync::{Arc, Mutex}; @@ -707,12 +707,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?; + // `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)?; let proportional_limit = lsps2_parameters .max_proportional_opening_fee_ppm_msat - .and_then(|max_prop_fee| compute_opening_fee(amount_msat, 0, max_prop_fee)); + .and_then(|max_prop_fee| compute_opening_fee(payment_size_msat, 0, max_prop_fee)); match (lsps2_parameters.max_total_opening_fee_msat, proportional_limit) { (Some(total_limit), Some(proportional_limit)) => { Some(total_limit.min(proportional_limit)) @@ -1062,15 +1090,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, ); @@ -1092,7 +1133,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) @@ -1105,7 +1146,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."), } } } @@ -1266,6 +1307,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, @@ -1436,6 +1479,7 @@ where hash: Some(payment_hash), preimage: payment_preimage, secret: Some(payment_secret), + counterparty_skimmed_fee_msat: None, offer_id: payment_context.offer_id, payer_note: payment_context.invoice_request.payer_note_truncated, quantity: payment_context.invoice_request.quantity, @@ -2814,7 +2858,8 @@ mod tests { assert_eq!( EventHandler::>::lsps2_max_total_opening_fee_msat( &metadata.encode(), - 100_000 + 100_000, + 0, ), Some(42_000) ); @@ -2833,12 +2878,33 @@ mod tests { assert_eq!( EventHandler::>::lsps2_max_total_opening_fee_msat( &metadata.encode(), - 100_000 + 100_000, + 0, ), Some(1_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 = @@ -2855,23 +2921,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 1413ec911c..14a8edaf5d 100644 --- a/src/payment/bolt12/mod.rs +++ b/src/payment/bolt12/mod.rs @@ -197,6 +197,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, @@ -222,6 +223,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, @@ -378,6 +380,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, @@ -403,6 +406,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, @@ -536,7 +540,8 @@ impl Bolt12Payment { /// /// 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. + /// received entirely over pre-existing channels. When a just-in-time channel is used, the LSP + /// fee is deducted from the amount received. pub fn receive( &self, amount_msat: u64, description: &str, expiry_secs: Option, quantity: Option, ) -> Result { @@ -552,7 +557,8 @@ impl Bolt12Payment { /// /// 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. + /// received entirely over pre-existing channels. When a just-in-time channel is used, the LSP + /// fee is deducted from the amount received. pub fn receive_variable_amount( &self, description: &str, expiry_secs: Option, ) -> Result { 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 921d8041ba..fdfa6af5a4 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -220,11 +220,6 @@ impl UpdatableObject 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" @@ -232,11 +227,6 @@ impl UpdatableObject for PaymentDetails { update_if_necessary!(*hash, hash_opt); }, PaymentKind::Bolt12Refund { 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" @@ -292,12 +282,13 @@ impl UpdatableObject 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" ), } } @@ -543,6 +534,17 @@ pub enum PaymentKind { preimage: Option, /// The secret used by the payment. secret: Option, + /// The value, in thousandths 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. @@ -634,6 +636,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) => { @@ -749,7 +752,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, @@ -996,6 +1000,96 @@ mod tests { } } + #[test] + fn inbound_bolt12_offer_payments_can_be_failed() { + let payment_id = PaymentId([41; 32]); + let hash = PaymentHash([42; 32]); + let mut payment = PaymentDetails::new( + payment_id, + PaymentKind::Bolt12Offer { + hash: Some(hash), + 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, + ); + + payment.update(PaymentDetailsUpdate { + hash: Some(Some(hash)), + status: Some(PaymentStatus::Failed), + ..PaymentDetailsUpdate::new(payment_id) + }); + + assert_eq!(payment.status, PaymentStatus::Failed); + } + + #[test] + fn inbound_bolt12_refunds_can_be_failed() { + let payment_id = PaymentId([41; 32]); + let hash = PaymentHash([42; 32]); + let mut payment = PaymentDetails::new( + payment_id, + PaymentKind::Bolt12Refund { + hash: Some(hash), + preimage: None, + secret: None, + payer_note: None, + quantity: None, + }, + Some(100_000), + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + + payment.update(PaymentDetailsUpdate { + hash: Some(Some(hash)), + status: Some(PaymentStatus::Failed), + ..PaymentDetailsUpdate::new(payment_id) + }); + + assert_eq!(payment.status, PaymentStatus::Failed); + } + + #[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 086ae9fde2..c9d6a3d804 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -4559,12 +4559,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); @@ -4626,12 +4628,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 b6961daff2f72eb61b700af3b2ecfb980521bfd0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:47:36 +0200 Subject: [PATCH 23/35] 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 | 38 ++++++++++++++++++++++++++++- src/liquidity/mod.rs | 3 ++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 1287065d2a..cb59aa86ea 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, + PaymentLease, PaymentLeaseId, PaymentLeaseStore, PendingLeaseRequestState, }; // LSPS2 requires two blocks more than the usual final CLTV delta. @@ -100,6 +100,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, @@ -264,6 +265,17 @@ where { return Ok((lease, total_fee_msat, lsp, false)); } + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseCacheTargetId::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 @@ -330,6 +342,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(LeaseCacheTargetId::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 5bea4a8ae6..dc7dd2ac16 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -7,7 +7,7 @@ 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; @@ -16,6 +16,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}; use crate::hex_utils; @@ -270,6 +271,27 @@ fn merge_absolute_expiry(current: Option, new: Option) -> Option } } +#[derive(Default)] +pub(crate) struct PendingLeaseRequestState { + locks: HashMap>>, +} + +impl PendingLeaseRequestState { + pub(crate) fn request_lock(&mut self, key: LeaseCacheTargetId) -> 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) } @@ -696,4 +718,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(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }); + let same_fixed = state.request_lock(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }); + let other_fixed = state.request_lock(LeaseCacheTargetId::Fixed { amount_msat: 2_000 }); + let variable = state.request_lock(LeaseCacheTargetId::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 c85820aae0..1fc7bb1d7d 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -39,7 +39,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}; @@ -409,6 +409,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 61bb764fa4b1f9756eb94065569366d73c247495 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:51:14 +0200 Subject: [PATCH 24/35] 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/builder.rs | 1 + src/liquidity/client/lsps2/mod.rs | 104 ++++++++++++++++++++++++++-- src/liquidity/client/lsps2/state.rs | 70 +++++++++++++++++++ src/liquidity/mod.rs | 8 ++- src/payment/bolt12/offers.rs | 9 ++- 5 files changed, 182 insertions(+), 10 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 4f041d98d4..a2d2df0bc8 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2365,6 +2365,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 cb59aa86ea..4331019959 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}; @@ -105,6 +106,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, } @@ -143,13 +146,17 @@ where self.cache_target_store.targets() } - pub(crate) async fn commit_invoice_cache_target(&self, request: JitInvoiceRequest) { + pub(crate) async fn commit_invoice_cache_target( + self: &Arc, request: JitInvoiceRequest, + connection_manager: &Arc>, + ) { // Cache targets are only an optimization. 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); } + self.schedule_lease_refill(target_id, connection_manager); } pub(crate) async fn prepare_invoice_response( @@ -226,6 +233,7 @@ where payment_hash, lsps2_parameters, )?; + self.schedule_lease_refill(LeaseCacheTargetId::Fixed { amount_msat }, &connection_manager); if was_negotiated { log_info!(self.logger, "JIT-channel invoice created: {}", invoice); @@ -250,6 +258,7 @@ where payment_hash, lsps2_parameters, )?; + self.schedule_lease_refill(LeaseCacheTargetId::Variable, &connection_manager); if was_negotiated { log_info!(self.logger, "JIT-channel invoice created: {}", invoice); @@ -277,6 +286,15 @@ where 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?; + 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() @@ -332,8 +350,7 @@ 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( @@ -352,6 +369,15 @@ where 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?; + 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 @@ -393,8 +419,74 @@ 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_lease_refill( + self: &Arc, target_id: LeaseCacheTargetId, + connection_manager: &Arc>, + ) { + let Some(runtime) = self.runtime.upgrade() else { return }; + let client = Arc::clone(self); + let connection_manager = Arc::clone(connection_manager); + runtime.spawn_cancellable_background_task(async move { + let result = match target_id { + LeaseCacheTargetId::Fixed { amount_msat } => { + client.cache_fixed_lease(amount_msat, &connection_manager).await + }, + LeaseCacheTargetId::Variable => { + client.cache_variable_lease(&connection_manager).await + }, + }; + if let Err(error) = result { + 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(LeaseCacheTargetId::Fixed { amount_msat }); + let _request_guard = request_lock.lock().await; + let available_lsps = + self.get_lsps2_nodes().await?.into_iter().map(|lsp| lsp.node_id).collect::>(); + if self.lease_state.lock().expect("lock").has_fixed_amount( + amount_msat, + self.config.lsps2_max_total_lsp_fee_limit_msat, + &available_lsps, + ) { + 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(LeaseCacheTargetId::Variable); + let _request_guard = request_lock.lock().await; + let available_lsps = + self.get_lsps2_nodes().await?.into_iter().map(|lsp| lsp.node_id).collect::>(); + if self + .lease_state + .lock() + .expect("lock") + .has_variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat, &available_lsps) + { + 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 dc7dd2ac16..4dd82b1de6 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -397,6 +397,24 @@ 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, available_lsps: &[PublicKey], + ) -> bool { + self.leases + .values() + .filter(|lease| available_lsps.contains(&lease.id.lsp_node_id)) + .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], @@ -415,6 +433,17 @@ impl LSPS2LeaseState { self.leases.remove(&id).map(|lease| (lease, proportional_fee)) } + pub(crate) fn has_variable_amount( + &self, max_total_fee_msat: Option, available_lsps: &[PublicKey], + ) -> bool { + self.leases + .values() + .filter(|lease| available_lsps.contains(&lease.id.lsp_node_id)) + .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)); } @@ -613,6 +642,47 @@ 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 available_lsps = [fixed.id.lsp_node_id, variable.id.lsp_node_id]; + let state = LSPS2LeaseState::from_leases(vec![fixed, variable]); + + assert!(state.has_fixed_amount(1_000, Some(100), &available_lsps)); + assert!(!state.has_fixed_amount(1_000, Some(99), &available_lsps)); + assert!(!state.has_fixed_amount(2_000, None, &available_lsps)); + assert!(state.has_variable_amount(Some(50), &available_lsps)); + assert!(!state.has_variable_amount(Some(49), &available_lsps)); + } + + #[test] + fn unavailable_fixed_lease_does_not_suppress_refill() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let unavailable = lease(2, 48, 100, Some(1_000), valid_until); + let available_lsp = lease(3, 49, 100, Some(1_000), valid_until).id.lsp_node_id; + let state = LSPS2LeaseState::from_leases(vec![unavailable]); + + assert!( + !state.has_fixed_amount(1_000, Some(100), &[available_lsp]), + "a lease from an unavailable LSP suppressed fixed-amount refill" + ); + } + + #[test] + fn unavailable_variable_lease_does_not_suppress_refill() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let unavailable = lease(2, 50, 50, None, valid_until); + let available_lsp = lease(3, 51, 50, None, valid_until).id.lsp_node_id; + let state = LSPS2LeaseState::from_leases(vec![unavailable]); + + assert!( + !state.has_variable_amount(Some(50), &[available_lsp]), + "a lease from an unavailable LSP suppressed variable-amount refill" + ); + } + fn cache_target( id: LeaseCacheTargetId, absolute_expiry: Option, last_accessed: u64, ) -> LeaseCacheTarget { diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 1fc7bb1d7d..3d55d9ddc2 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -13,7 +13,7 @@ pub(crate) mod service; use std::collections::HashMap; use std::hash::Hash; 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; @@ -264,6 +264,7 @@ where keys_manager: Arc, tx_broadcaster: Arc, kv_store: Arc, + runtime: Weak, config: Arc, logger: L, } @@ -274,7 +275,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; @@ -286,6 +288,7 @@ where keys_manager, tx_broadcaster, kv_store, + runtime, config, logger, } @@ -414,6 +417,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(), }), diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index 2c419bb521..503b524a4f 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -497,7 +497,7 @@ impl OffersMessageHandler for NodeOffersMessageHandler { let logger = Arc::clone(&self.logger); dependencies.runtime.spawn_cancellable_background_task(async move { let response = Arc::clone(&lsps2_client) - .prepare_invoice_response(jit_request, connection_manager) + .prepare_invoice_response(jit_request, Arc::clone(&connection_manager)) .await; let (message, reply_context) = match response { Ok(jit_metadata) => { @@ -516,7 +516,12 @@ impl OffersMessageHandler for NodeOffersMessageHandler { ); let invoice_result = commit_cache_target_on_success(invoice_result, || { let lsps2_client = Arc::clone(&lsps2_client); - async move { lsps2_client.commit_invoice_cache_target(jit_request).await } + let connection_manager = Arc::clone(&connection_manager); + async move { + lsps2_client + .commit_invoice_cache_target(jit_request, &connection_manager) + .await + } }) .await; match invoice_result { From f18feb0b82382591ebe2bd64ae007903a4378107 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 15:18:47 +0200 Subject: [PATCH 25/35] 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 | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 2fdd4d00f4..20e9e10d53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -863,6 +863,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 4331019959..f56c401d6e 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -146,6 +146,16 @@ 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() { + self.schedule_lease_refill(target.id, connection_manager); + } + Ok(()) + } + pub(crate) async fn commit_invoice_cache_target( self: &Arc, request: JitInvoiceRequest, connection_manager: &Arc>, From b8eade056dc1219373d7a5200e6d8102716867bf Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:57:04 +0200 Subject: [PATCH 26/35] lsps2: Retry lease negotiation failures Try the next eligible provider when a selected LSP rejects or times out, and retry bounded whole rounds for transient failures. Preserve immediate errors for fee limits and unavailable liquidity sources. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 241 ++++++++++++++++++++++++------ 1 file changed, 198 insertions(+), 43 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index f56c401d6e..abe31260dc 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; @@ -65,6 +66,38 @@ 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 +} + +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 { @@ -304,9 +337,30 @@ 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 + let mut candidates = all_offers .into_iter() .flat_map(|(lsp, resp)| { resp.opening_fee_params_menu @@ -330,11 +384,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 { @@ -344,23 +399,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( @@ -387,10 +463,31 @@ 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; - 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)) @@ -407,29 +504,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_lease_refill( @@ -966,6 +1086,41 @@ 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)); + } + + #[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 5a5c3e345d4e88500b2378f2c476065598b6b2db Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 16 Oct 2026 15:21:12 +0200 Subject: [PATCH 27/35] f Handle LSPS2 rejections without timeout Rejected LSPS2 requests waited for the timeout and were treated as transient failures. Fee collection also discarded rejection reasons, while a later permanent buy failure could mask a retryable failure. Complete rejected requests immediately and retain their error through negotiation. Retry invalid opening parameters with a fresh fee query, stop retrying permanent rejections, and preserve other LSPs' transient failures when deciding whether to retry a round. Addresses #999 (discussion_r4124545784). Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 339 +++++++++++++++++++++++++++--- 1 file changed, 305 insertions(+), 34 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index abe31260dc..a2652054c9 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -18,7 +18,7 @@ use lightning::log_warn; use lightning::routing::router::{RouteHint, RouteHintHop}; use lightning::util::ser::Writeable; use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, RoutingFees}; -use lightning_liquidity::lsps0::ser::LSPSRequestId; +use lightning_liquidity::lsps0::ser::{LSPSRequestId, LSPSResponseError}; use lightning_liquidity::lsps2::event::LSPS2ClientEvent; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; use lightning_liquidity::lsps2::utils::compute_opening_fee; @@ -68,18 +68,69 @@ 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 +#[derive(Debug, PartialEq, Eq)] +enum LeaseNegotiationError { + Local(Error), + Rejected(LSPSResponseError), } -async fn try_lease_candidates( +impl LeaseNegotiationError { + fn is_retryable(&self) -> bool { + match self { + Self::Local(error) => *error == Error::LiquidityRequestFailed, + // bLIP-52: INVALID_OPENING_FEE_PARAMS. Fetch fresh parameters before buying again. + Self::Rejected(error) => error.code == 201, + } + } +} + +impl From for LeaseNegotiationError { + fn from(error: Error) -> Self { + Self::Local(error) + } +} + +impl From for Error { + fn from(error: LeaseNegotiationError) -> Self { + match error { + LeaseNegotiationError::Local(error) => error, + LeaseNegotiationError::Rejected(_) => Self::LiquidityRequestFailed, + } + } +} + +impl std::fmt::Display for LeaseNegotiationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Local(error) => error.fmt(f), + Self::Rejected(error) => { + write!(f, "LSPS2 request rejected ({}): {}", error.code, error.message) + }, + } + } +} + +fn should_retry_lease_negotiation(error: &LeaseNegotiationError, attempt: usize) -> bool { + error.is_retryable() && attempt < LEASE_NEGOTIATION_MAX_ATTEMPTS +} + +fn record_negotiation_failure( + previous: &mut Option, error: LeaseNegotiationError, +) { + // One LSP's permanent rejection must not suppress retries for another LSP's transient failure. + if previous.as_ref().map_or(true, |error| !error.is_retryable()) { + *previous = Some(error); + } +} + +async fn try_lease_candidates( candidates: Vec, candidate_key: KF, mut attempt: AF, -) -> Result +) -> Result where K: Copy + Eq + Hash, KF: Fn(&T) -> K, AF: FnMut(T) -> Fut, - Fut: Future>, + Fut: Future>, { let mut failed_candidates = HashSet::new(); let mut last_error = None; @@ -92,7 +143,7 @@ where Ok(result) => return Ok(result), Err(error) => { failed_candidates.insert(key); - last_error = Some(error); + record_negotiation_failure(&mut last_error, error); }, } } @@ -128,9 +179,9 @@ where { pub(crate) lsp_nodes: Arc>>, pub(crate) pending_lsps2_fee_requests: - Mutex>>, + Mutex>>>, pub(crate) pending_buy_requests: - Mutex>>, + Mutex>>>, pub(crate) lease_store: Arc>, pub(crate) lease_state: Mutex, pub(crate) cache_target_store: Arc>, @@ -342,7 +393,7 @@ where loop { let result = self.negotiate_fixed_lease_once(amount_msat, connection_manager).await; match result { - Err(error) if should_retry_lease_negotiation(error, attempt) => { + Err(error) if should_retry_lease_negotiation(&error, attempt) => { log_warn!( self.logger, "LSPS2 lease negotiation attempt {} failed, retrying: {}", @@ -351,14 +402,14 @@ where ); attempt += 1; }, - result => return result, + result => return result.map_err(Error::from), } } } async fn negotiate_fixed_lease_once( self: &Arc, amount_msat: u64, connection_manager: &Arc>, - ) -> Result<(PaymentLease, u64, LspConfig), Error> { + ) -> Result<(PaymentLease, u64, LspConfig), LeaseNegotiationError> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut candidates = all_offers .into_iter() @@ -397,7 +448,7 @@ where "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); + return Err(Error::LiquidityFeeTooHigh.into()); } candidates.retain(|(_, fee, _)| *fee <= max_total_lsp_fee_limit_msat); } @@ -468,7 +519,7 @@ where loop { let result = self.negotiate_variable_lease_once(connection_manager).await; match result { - Err(error) if should_retry_lease_negotiation(error, attempt) => { + Err(error) if should_retry_lease_negotiation(&error, attempt) => { log_warn!( self.logger, "LSPS2 lease negotiation attempt {} failed, retrying: {}", @@ -477,14 +528,14 @@ where ); attempt += 1; }, - result => return result, + result => return result.map_err(Error::from), } } } async fn negotiate_variable_lease_once( self: &Arc, connection_manager: &Arc>, - ) -> Result<(PaymentLease, u64, LspConfig), Error> { + ) -> Result<(PaymentLease, u64, LspConfig), LeaseNegotiationError> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; let mut candidates = all_offers @@ -512,10 +563,10 @@ where self.logger, "Failed to request inbound JIT channel as all LSP offers exceed our configured fee limit" ); - Error::LiquidityFeeTooHigh + Error::LiquidityFeeTooHigh.into() } else { log_error!(self.logger, "Failed to handle response from liquidity service",); - Error::LiquidityRequestFailed + Error::LiquidityRequestFailed.into() }); } @@ -621,7 +672,7 @@ where async fn gather_lsps2_offers( self: &Arc, connection_manager: &Arc>, - ) -> Result, Error> { + ) -> Result, LeaseNegotiationError> { let lsps2_nodes = self.get_lsps2_nodes().await?; // Connect to all candidate LSPs in parallel. @@ -641,7 +692,8 @@ where let mut all_offers: Vec<(LspConfig, LSPS2FeeResponse)> = Vec::with_capacity(lsps2_nodes.len()); - let mut fee_set: JoinSet<(LspConfig, Result)> = JoinSet::new(); + let mut failure = None; + let mut fee_set = JoinSet::new(); for lsp_node in &lsps2_nodes { let lsp = lsp_node.clone(); let client = Arc::clone(self); @@ -655,13 +707,20 @@ where Ok((lsp, Ok(fees))) => all_offers.push((lsp, fees)), Ok((lsp, Err(e))) => { log_warn!(self.logger, "Failed to get fees from LSP {}: {}", lsp.node_id, e); + record_negotiation_failure(&mut failure, e); }, Err(e) => { log_warn!(self.logger, "Failed to get fees from LSP: {}", e); + record_negotiation_failure(&mut failure, Error::LiquidityRequestFailed.into()); }, } } + if all_offers.is_empty() { + if let Some(error) = failure { + return Err(error); + } + } Ok(all_offers) } } @@ -672,7 +731,7 @@ where { async fn lsps2_request_opening_fee_params( &self, node_id: Option<&PublicKey>, - ) -> Result { + ) -> Result { let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) .ok_or(Error::LiquiditySourceUnavailable)?; @@ -707,13 +766,14 @@ where .map_err(|e| { log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); Error::LiquidityRequestFailed - }) + })? + .map_err(LeaseNegotiationError::Rejected) } 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)?; @@ -755,7 +815,8 @@ where .map_err(|e| { log_error!(self.logger, "Failed to handle response from liquidity service: {:?}", e); Error::LiquidityRequestFailed - })?; + })? + .map_err(LeaseNegotiationError::Rejected)?; let cltv_expiry_delta = state::checked_cltv_expiry_delta(buy_response.cltv_expiry_delta) .ok_or_else(|| { log_error!( @@ -957,7 +1018,7 @@ where { let response = LSPS2FeeResponse { opening_fee_params_menu }; - match request.sender.send(response) { + match request.sender.send(Ok(response)) { Ok(()) => (), Err(_) => { log_error!( @@ -999,7 +1060,7 @@ where { let response = LSPS2BuyResponse { intercept_scid, cltv_expiry_delta }; - match request.sender.send(response) { + match request.sender.send(Ok(response)) { Ok(()) => (), Err(_) => { log_error!( @@ -1022,12 +1083,54 @@ where ); } }, - _ => { - log_error!(self.logger, "Received unexpected LSPS2Client liquidity event!"); + LSPS2ClientEvent::GetInfoFailed { request_id, counterparty_node_id, error } => { + self.handle_request_rejection( + &self.pending_lsps2_fee_requests, + request_id, + counterparty_node_id, + error, + ); + }, + LSPS2ClientEvent::BuyRequestFailed { request_id, counterparty_node_id, error } => { + self.handle_request_rejection( + &self.pending_buy_requests, + request_id, + counterparty_node_id, + error, + ); }, } } + fn handle_request_rejection( + &self, + pending: &Mutex>>>, + request_id: LSPSRequestId, counterparty_node_id: PublicKey, error: LSPSResponseError, + ) { + if !self.lsp_nodes.read().expect("lock").iter().any(|n| n.node_id == counterparty_node_id) { + log_error!( + self.logger, + "Received LSPS2 rejection from unexpected peer {}", + counterparty_node_id + ); + return; + } + if let Some(request) = pending.lock().expect("lock").remove(&request_id) { + if request.sender.send(Err(error)).is_err() { + log_error!( + self.logger, + "Failed to handle rejection for liquidity request {:?}", + request_id + ); + } + } else { + log_error!( + self.logger, + "Received rejection from liquidity service for unknown request." + ); + } + } + async fn get_lsps2_nodes(&self) -> Result, Error> { let has_undiscovered_protocol = self.lsp_nodes.read().expect("lock").iter().any(|n| n.supported_protocols.is_none()); @@ -1071,6 +1174,156 @@ pub(crate) struct LSPS2BuyResponse { #[cfg(test)] mod tests { use super::*; + use std::future::poll_fn; + use std::task::Poll; + + use crate::builder::NodeBuilder; + use crate::entropy::NodeEntropy; + use crate::io::test_utils::InMemoryStore; + + fn rejection_test_node() -> (crate::Node, PublicKey) { + let node_id = + "0276607124ebe6a6c9338517b6f485825b27c2dcc0b9fc2aa6a4c0df91194e5993".parse().unwrap(); + let mut builder = NodeBuilder::new(); + builder.set_log_facade_logger(); + // An unsupported address makes connection attempts fail without network I/O. + builder.add_liquidity_source( + node_id, + lightning::ln::msgs::SocketAddress::OnionV2([0; 12]), + None, + false, + ); + #[cfg(not(feature = "uniffi"))] + let entropy = NodeEntropy::from_seed_bytes([42; 64]); + #[cfg(feature = "uniffi")] + let entropy = NodeEntropy::from_seed_bytes(vec![42; 64]).unwrap(); + let node = builder.build_with_store(entropy, InMemoryStore::new()).unwrap(); + let client = node.liquidity_source.lsps2_client(); + client.lsp_nodes.write().unwrap()[0].supported_protocols = Some(vec![2]); + (node, node_id) + } + + fn check_rejection_completes_request(is_buy: bool, code: i32) { + let (node, node_id) = rejection_test_node(); + let client = node.liquidity_source.lsps2_client(); + node.runtime.block_on(async { + let params = LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: (chrono::Utc::now() + chrono::Duration::days(2)) + .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(), + }; + let request = async { + if is_buy { + client + .lsps2_send_buy_request(Some(1_000), params, Some(&node_id)) + .await + .map(|_| ()) + } else { + client.lsps2_request_opening_fee_params(Some(&node_id)).await.map(|_| ()) + } + }; + tokio::pin!(request); + poll_fn(|cx| { + assert!(request.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + let error = LSPSResponseError { code, message: "request rejected".into(), data: None }; + let expected_error = error.clone(); + let event = if is_buy { + let request_id = + client.pending_buy_requests.lock().unwrap().keys().next().unwrap().clone(); + LSPS2ClientEvent::BuyRequestFailed { + request_id, + counterparty_node_id: node_id, + error, + } + } else { + let request_id = client + .pending_lsps2_fee_requests + .lock() + .unwrap() + .keys() + .next() + .unwrap() + .clone(); + LSPS2ClientEvent::GetInfoFailed { request_id, counterparty_node_id: node_id, error } + }; + client.handle_event(event).await; + let result = poll_fn(|cx| Poll::Ready(request.as_mut().poll(cx))).await; + let Poll::Ready(Err(error)) = result else { + panic!("rejection must complete the request immediately: {result:?}"); + }; + assert_eq!(error, LeaseNegotiationError::Rejected(expected_error)); + assert_eq!(should_retry_lease_negotiation(&error, 1), code == 201); + assert!(!should_retry_lease_negotiation(&error, 3)); + assert!(client.pending_buy_requests.lock().unwrap().is_empty()); + assert!(client.pending_lsps2_fee_requests.lock().unwrap().is_empty()); + }); + } + + #[test] + fn get_info_rejection_completes_request() { + for code in [1, 200, -32602] { + check_rejection_completes_request(false, code); + } + } + + #[test] + fn buy_rejection_completes_request() { + for code in [1, 201, 202, 203, -32602] { + check_rejection_completes_request(true, code); + } + } + + #[test] + fn rejected_fee_queries_stop_lease_negotiation() { + let (node, node_id) = rejection_test_node(); + let client = node.liquidity_source.lsps2_client(); + node.runtime.block_on(async { + for fixed_amount in [true, false] { + let mut rejection_count = 0; + tokio::time::timeout(Duration::from_secs(1), async { + let negotiation = async { + if fixed_amount { + client.negotiate_fixed_lease(1_000, &node.connection_manager).await + } else { + client.negotiate_variable_lease(&node.connection_manager).await + } + }; + let rejections = async { + loop { + let request_id = client.pending_lsps2_fee_requests.lock().unwrap().keys().next().cloned(); + if let Some(request_id) = request_id { + rejection_count += 1; + assert_eq!(rejection_count, 1, "a permanent fee query rejection must not trigger another negotiation round"); + client.handle_event(LSPS2ClientEvent::GetInfoFailed { + request_id, counterparty_node_id: node_id, + error: LSPSResponseError { code: 200, message: "unrecognized token".into(), data: None }, + }).await; + } else { + tokio::task::yield_now().await; + } + } + }; + tokio::select! { + result = negotiation => assert!(matches!(result, Err(Error::LiquidityRequestFailed))), + _ = rejections => unreachable!(), + } + }).await.expect("rejected fee queries must finish without waiting for request timeouts"); + assert_eq!(rejection_count, 1); + assert!(client.pending_lsps2_fee_requests.lock().unwrap().is_empty()); + } + }); + } #[tokio::test] async fn failed_persisted_removal_restores_value() { @@ -1089,11 +1342,29 @@ mod tests { #[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)); + assert!(should_retry_lease_negotiation(&Error::LiquidityRequestFailed.into(), 1)); + assert!(should_retry_lease_negotiation(&Error::LiquidityRequestFailed.into(), 2)); + assert!(!should_retry_lease_negotiation(&Error::LiquidityRequestFailed.into(), 3)); + assert!(!should_retry_lease_negotiation(&Error::LiquidityFeeTooHigh.into(), 1)); + assert!(!should_retry_lease_negotiation(&Error::LiquiditySourceUnavailable.into(), 1)); + } + + #[tokio::test] + async fn lease_negotiation_preserves_retryable_failures() { + let candidates = + vec![(1, Error::LiquidityRequestFailed), (2, Error::LiquiditySourceUnavailable)]; + for candidates in [candidates.clone(), candidates.into_iter().rev().collect()] { + let result: Result<(), _> = try_lease_candidates( + candidates, + |candidate| candidate.0, + |(_, error)| async move { Err(error.into()) }, + ) + .await; + assert!( + should_retry_lease_negotiation(&result.unwrap_err(), 1), + "a permanent failure must not suppress another LSP's retryable failure" + ); + } } #[tokio::test] @@ -1109,7 +1380,7 @@ mod tests { async move { attempted_lsps.lock().unwrap().push(candidate.0); if candidate.0 == 1 { - Err(()) + Err(Error::LiquiditySourceUnavailable.into()) } else { Ok(candidate.1) } From 2e47c89dbbc1215bf2865ce6d8085c6c82b1bd74 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 15:46:02 +0200 Subject: [PATCH 28/35] 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 | 121 +++++++++++++--------------- src/liquidity/client/lsps2/state.rs | 64 ++++++++++++--- 2 files changed, 112 insertions(+), 73 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index a2652054c9..0b7ecb0c5d 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -256,34 +256,21 @@ where pub(crate) async fn prepare_invoice_response( self: Arc, request: JitInvoiceRequest, connection_manager: Arc>, ) -> Result>, Error> { - let (lease, fee_parameters) = match request { + let (lease, total_fee_msat, _, _) = 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, - }, - ) + self.acquire_fixed_lease(amount_msat, &connection_manager).await? }, - 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), - }, - ) + 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. + self.acquire_variable_lease(Some(amount_msat), &connection_manager).await? }, }; + let fee_parameters = LSPS2Parameters { + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }; // 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. @@ -340,7 +327,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: Some(proportional_fee), @@ -491,10 +478,10 @@ 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? { - return Ok((lease, proportional_fee, lsp, false)); + if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? { + return Ok((lease, fee, lsp, false)); } let request_lock = self .pending_lease_request_state @@ -502,22 +489,22 @@ where .expect("lock") .request_lock(LeaseCacheTargetId::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)); + if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? { + 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?; - 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!( @@ -534,7 +521,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), LeaseNegotiationError> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; @@ -543,20 +530,18 @@ 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)| { + let (selection_fee, fee_for_limit) = + state::variable_lease_fees(¶ms, amount_msat)?; + 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!( @@ -573,20 +558,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, @@ -666,7 +660,7 @@ where { return Ok(()); } - self.negotiate_variable_lease(connection_manager).await?; + self.negotiate_variable_lease(None, connection_manager).await?; Ok(()) } @@ -895,16 +889,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 @@ -912,7 +905,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( @@ -1296,7 +1289,7 @@ mod tests { if fixed_amount { client.negotiate_fixed_lease(1_000, &node.connection_manager).await } else { - client.negotiate_variable_lease(&node.connection_manager).await + client.negotiate_variable_lease(Some(1_000), &node.connection_manager).await } }; let rejections = async { diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 4dd82b1de6..a124c5a206 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -417,20 +417,24 @@ 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, fee_for_limit) = + variable_lease_fees(&lease.params, amount_msat)?; + 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( @@ -449,6 +453,25 @@ impl LSPS2LeaseState { } } +/// Returns the variable lease's (selection fee, fee for the configured limit). +/// Resolved BOLT12 amounts must fit the advertised range and use the exact total fee for both. +/// Unknown BOLT11 amounts rank by proportional fee and check the minimum fee against the limit. +pub(crate) fn variable_lease_fees( + params: &LSPS2OpeningFeeParams, amount_msat: Option, +) -> Option<(u64, u64)> { + if let Some(amount_msat) = amount_msat { + if amount_msat < params.min_payment_size_msat || amount_msat > params.max_payment_size_msat + { + return None; + } + let total_fee = + compute_opening_fee(amount_msat, params.min_fee_msat, params.proportional as u64)?; + Some((total_fee, total_fee)) + } else { + Some((params.proportional as u64, params.min_fee_msat)) + } +} + 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 @@ -636,10 +659,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] From 6871a6d4ecd3aa2ed24887b1d262938134016873 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 10:02:19 +0200 Subject: [PATCH 29/35] lsps2: Separate cache lease 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 caller-owned and persist only leases intended for reuse. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 203 +++++++++++++++++++++------- src/liquidity/client/lsps2/state.rs | 142 ++++++++++++------- src/liquidity/mod.rs | 6 +- src/payment/bolt11.rs | 12 ++ 4 files changed, 263 insertions(+), 100 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 0b7ecb0c5d..061971aa0f 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, - PaymentLease, PaymentLeaseId, PaymentLeaseStore, PendingLeaseRequestState, + is_lease_cacheable, is_lease_valid_for, now_secs, LSPS2LeaseState, LeaseCacheTarget, + LeaseCacheTargetId, LeaseCacheTargetStore, PaymentLease, PaymentLeaseId, PaymentLeaseStore, + PendingLeaseRequestState, MIN_CACHED_LEASE_REMAINING_SECS, }; // LSPS2 requires two blocks more than the usual final CLTV delta. @@ -123,6 +124,19 @@ fn record_negotiation_failure( } } +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; + }; + // Leave time for a full buy round trip before the invoice's lifetime begins. + let Some(min_remaining_secs) = min_remaining_secs.checked_add(LIQUIDITY_REQUEST_TIMEOUT_SECS) + else { + return false; + }; + let now = now_secs(); + valid_until > now && valid_until.saturating_sub(now) >= min_remaining_secs +} + async fn try_lease_candidates( candidates: Vec, candidate_key: KF, mut attempt: AF, ) -> Result @@ -203,7 +217,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)) .await .into_iter() .map(|lease| lease.id) @@ -258,13 +272,23 @@ where ) -> Result>, Error> { let (lease, total_fee_msat, _, _) = match request { JitInvoiceRequest::Fixed { amount_msat, .. } => { - self.acquire_fixed_lease(amount_msat, &connection_manager).await? + self.acquire_fixed_lease( + amount_msat, + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, + &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. - self.acquire_variable_lease(Some(amount_msat), &connection_manager).await? + self.acquire_variable_lease( + Some(amount_msat), + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, + &connection_manager, + ) + .await? }, }; let fee_parameters = LSPS2Parameters { @@ -274,8 +298,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 { @@ -301,7 +324,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, @@ -327,7 +350,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: Some(proportional_fee), @@ -348,10 +371,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? { return Ok((lease, total_fee_msat, lsp, false)); } @@ -362,23 +386,25 @@ where .request_lock(LeaseCacheTargetId::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? { 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?; 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!( @@ -395,7 +421,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), LeaseNegotiationError> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut candidates = all_offers @@ -406,6 +433,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 { @@ -457,6 +492,7 @@ where Some(amount_msat), opening_params, Some(&lsp.node_id), + min_remaining_secs, ) .await { @@ -478,9 +514,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? + { return Ok((lease, fee, lsp, false)); } let request_lock = self @@ -489,22 +528,27 @@ where .expect("lock") .request_lock(LeaseCacheTargetId::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? + { 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?; 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!( @@ -521,7 +565,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), LeaseNegotiationError> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; @@ -531,6 +576,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; + } let (selection_fee, fee_for_limit) = state::variable_lease_fees(¶ms, amount_msat)?; let fee_allowed = self @@ -577,7 +630,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)), @@ -637,8 +695,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( @@ -660,7 +720,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(()) } @@ -766,7 +836,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)?; @@ -779,6 +849,14 @@ where let (buy_request_sender, buy_request_receiver) = oneshot::channel(); let _pending_request = { let mut pending_buy_requests_lock = self.pending_buy_requests.lock().expect("lock"); + // Earlier buy attempts may have exhausted this candidate's negotiation margin. + if !opening_fee_params_valid_for(&opening_fee_params, min_remaining_secs) { + log_debug!( + self.logger, + "Skipping LSPS2 buy as opening parameters no longer cover the requested invoice and buy timeout" + ); + return Err(Error::LiquidityRequestFailed.into()); + } let request_id = client_handler .select_opening_params(lsps2_node.node_id, amount_msat, opening_fee_params.clone()) .map_err(|e| { @@ -838,21 +916,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.into()); + } 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 @@ -869,13 +942,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); @@ -889,13 +964,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); @@ -1216,7 +1293,7 @@ mod tests { let request = async { if is_buy { client - .lsps2_send_buy_request(Some(1_000), params, Some(&node_id)) + .lsps2_send_buy_request(Some(1_000), params, Some(&node_id), 3_600) .await .map(|_| ()) } else { @@ -1287,9 +1364,9 @@ mod tests { tokio::time::timeout(Duration::from_secs(1), async { let negotiation = async { if fixed_amount { - client.negotiate_fixed_lease(1_000, &node.connection_manager).await + client.negotiate_fixed_lease(1_000, 3_600, &node.connection_manager).await } else { - client.negotiate_variable_lease(Some(1_000), &node.connection_manager).await + client.negotiate_variable_lease(Some(1_000), 3_600, &node.connection_manager).await } }; let rejections = async { @@ -1360,6 +1437,34 @@ mod tests { } } + #[test] + fn opening_params_must_cover_invoice_lifetime() { + let valid_until = chrono::Utc::now() + chrono::Duration::hours(3); + let mut 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)); + assert!(!opening_fee_params_valid_for(¶ms, u64::MAX)); + + let invoice_lifetime_secs = 2 * 60 * 60; + let valid_until = + chrono::Utc::now() + chrono::Duration::seconds(invoice_lifetime_secs as i64 + 1); + params.valid_until = valid_until.to_rfc3339().parse().unwrap(); + assert!( + !opening_fee_params_valid_for(¶ms, invoice_lifetime_secs), + "opening parameters leave no time for the buy round trip" + ); + } + #[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 a124c5a206..7fbd5cec77 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -30,7 +30,7 @@ use crate::Error; use super::LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA; -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; @@ -366,24 +366,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, @@ -404,7 +397,7 @@ impl LSPS2LeaseState { .values() .filter(|lease| available_lsps.contains(&lease.id.lsp_node_id)) .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, @@ -418,14 +411,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, fee_for_limit) = variable_lease_fees(&lease.params, amount_msat)?; @@ -444,12 +437,12 @@ impl LSPS2LeaseState { .values() .filter(|lease| available_lsps.contains(&lease.id.lsp_node_id)) .filter(|lease| lease.payment_size_msat.is_none()) - .filter(|lease| is_lease_usable(lease)) + .filter(|lease| is_lease_cacheable(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)); + self.leases.retain(|_, lease| is_lease_cacheable(lease)); } } @@ -473,8 +466,7 @@ pub(crate) fn variable_lease_fees( } 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 { @@ -483,6 +475,14 @@ pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { .filter(|delta| *delta <= u16::MAX - LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA) } +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() } @@ -574,53 +574,100 @@ 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 rejects_cltv_delta_without_bolt12_headroom() { - let mut lease = lease(2, 54, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); + let mut lease = + lease(2, 54, 1, Some(1_000), now_secs() + MIN_CACHED_LEASE_REMAINING_SECS + 60); lease.cltv_expiry_delta = u16::MAX as u32 - (MIN_FINAL_CLTV_EXPIRY_DELTA + 2) as u32 + 1; assert!( @@ -631,45 +678,45 @@ mod tests { #[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); @@ -677,10 +724,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" @@ -690,7 +736,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 available_lsps = [fixed.id.lsp_node_id, variable.id.lsp_node_id]; @@ -705,7 +751,7 @@ mod tests { #[test] fn unavailable_fixed_lease_does_not_suppress_refill() { - 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, 48, 100, Some(1_000), valid_until); let available_lsp = lease(3, 49, 100, Some(1_000), valid_until).id.lsp_node_id; let state = LSPS2LeaseState::from_leases(vec![unavailable]); @@ -718,7 +764,7 @@ mod tests { #[test] fn unavailable_variable_lease_does_not_suppress_refill() { - 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, 50, 50, None, valid_until); let available_lsp = lease(3, 51, 50, None, valid_until).id.lsp_node_id; let state = LSPS2LeaseState::from_leases(vec![unavailable]); diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 3d55d9ddc2..cc02909ab4 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, + is_lease_cacheable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, LeaseCacheTargetStore, PaymentLeaseStore, PendingLeaseRequestState, }; use crate::liquidity::client::lsps2::LSPS2Client; @@ -327,11 +327,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, diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 021b050187..c4f7185f74 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -627,6 +627,9 @@ impl Bolt11Payment { /// may instead use pre-existing channels if the payer independently knows a route to the /// recipient, in which case no new channel is opened. /// + /// Invoice creation fails if no configured LSP advertises opening parameters that remain valid + /// for at least `expiry_secs`. + /// /// The configured LSPS2 fee limit is enforced before creating the invoice. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md @@ -649,6 +652,9 @@ impl Bolt11Payment { /// may instead use pre-existing channels if the payer independently knows a route to the /// recipient, in which case no new channel is opened. /// + /// Invoice creation fails if no configured LSP advertises opening parameters that remain valid + /// for at least `expiry_secs`. + /// /// The configured LSPS2 fee limit is enforced before creating the invoice. /// /// The inbound payment will only be accepted if @@ -696,6 +702,9 @@ impl Bolt11Payment { /// may instead use pre-existing channels if the payer independently knows a route to the /// recipient, in which case no new channel is opened. /// + /// Invoice creation fails if no configured LSP advertises opening parameters that remain valid + /// for at least `expiry_secs`. + /// /// The configured LSPS2 fee limit is enforced when the payment arrives. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md @@ -718,6 +727,9 @@ impl Bolt11Payment { /// may instead use pre-existing channels if the payer independently knows a route to the /// recipient, in which case no new channel is opened. /// + /// Invoice creation fails if no configured LSP advertises opening parameters that remain valid + /// for at least `expiry_secs`. + /// /// The configured LSPS2 fee limit is enforced when the payment arrives. /// /// The inbound payment will only be accepted if From 718d5f6831a81d625bb5647551bf3d68c46178b3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:12:34 +0200 Subject: [PATCH 30/35] 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 c9d6a3d804..bd8ad3d1b2 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -5577,6 +5577,163 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { ); } +#[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, 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); + match client_node.payment(&fixed_receiver_payment_id).unwrap().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, ordinary_payment_id, None); + let ordinary_receiver_payment_id = + expect_payment_received_event!(client_node, ordinary_amount_msat); + match client_node.payment(&ordinary_receiver_payment_id).unwrap().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, 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); + match client_node.payment(&variable_receiver_payment_id).unwrap().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 82fc0714a29e2926ed5f73dc430cdb5a2ff8dd3c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:16:21 +0200 Subject: [PATCH 31/35] 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 | 69 ++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index bd8ad3d1b2..57b09a2b2b 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -7037,17 +7037,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, @@ -7104,12 +7105,52 @@ 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))); + let offer = client.bolt12_payment().receive(100_000_000, "multi LSP", None, None).unwrap(); + let payment_id = payer.bolt12_payment().send(&offer, None, None, None).unwrap(); + expect_event!(payer, PaymentFailed); + assert_eq!(payer.payment(&payment_id).unwrap().unwrap().status, PaymentStatus::Failed); + assert!(client.list_channels().is_empty()); + payer.stop().unwrap(); client.stop().unwrap(); cheap.stop().unwrap(); expensive.stop().unwrap(); @@ -7127,6 +7168,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, 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); + match client.payment(&receiver_payment_id).unwrap().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 97df82a3a1b132b552a5a8ccad51e7d062ee73ae Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:24:29 +0200 Subject: [PATCH 32/35] 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 57b09a2b2b..9d356e440f 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -5611,10 +5611,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(); @@ -5706,8 +5710,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, 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, restored_payment_id, None); + let restored_receiver_payment_id = + expect_payment_received_event!(client_node, fixed_received_msat); + match client_node.payment(&restored_receiver_payment_id).unwrap().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 0ffce2a2525b55e035dd33e74cef01affe68e691 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:45:04 +0200 Subject: [PATCH 33/35] 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 7fbd5cec77..4c316dfaf2 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -640,6 +640,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); From 14cd63055c616a43f37c8aa3b8aae68d51d64292 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 13:01:41 +0200 Subject: [PATCH 34/35] lsps2: Filter direct fallback capacity Only count inbound capacity from graph-known counterparties with usable forwarding parameters. Otherwise a direct-recipient fallback can hide the JIT path even though the payer cannot use that capacity. Co-Authored-By: HAL 9000 --- src/builder.rs | 2 + src/liquidity/client/lsps2/router.rs | 145 +++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 17 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index a2d2df0bc8..3cff8b725f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2138,6 +2138,7 @@ fn build_with_store_internal( Arc::clone(&scorer), scoring_fee_params, ), + Arc::clone(&network_graph), Arc::clone(&keys_manager), )); @@ -2543,6 +2544,7 @@ fn build_with_store_internal( Arc::clone(&scorer), probing_fee_params, ), + Arc::clone(&network_graph), Arc::clone(&keys_manager), )); Arc::new(HighDegreeStrategy::new( diff --git a/src/liquidity/client/lsps2/router.rs b/src/liquidity/client/lsps2/router.rs index 4791d80e99..81d18d8f52 100644 --- a/src/liquidity/client/lsps2/router.rs +++ b/src/liquidity/client/lsps2/router.rs @@ -17,12 +17,15 @@ use lightning::blinded_path::IntroductionNode; use lightning::impl_writeable_tlv_based; use lightning::ln::channel_state::ChannelDetails; use lightning::ln::channelmanager::PaymentId; +use lightning::routing::gossip::NodeId; use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; use lightning::sign::{EntropySource, ReceiveAuthKey}; use lightning::types::features::BlindedHopFeatures; use lightning::types::payment::PaymentHash; +use std::sync::Arc; use crate::payment::PaymentMetadata; +use crate::types::Graph; use super::LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA; @@ -52,13 +55,14 @@ impl_writeable_tlv_based!(LSPS2LeaseParameters, { /// A router wrapper that uses ordinary payment paths when available and LSPS2 JIT paths otherwise. pub struct LSPS2Router { inner_router: R, + network_graph: Arc, 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 } + pub fn new(inner_router: R, network_graph: Arc, entropy_source: ES) -> Self { + Self { inner_router, network_graph, entropy_source } } fn payment_parameters(&self, payment_context: &PaymentContext) -> Option { @@ -103,15 +107,26 @@ impl Router for LSPS2Router { ) -> Result, ()> { let parameters = self.payment_parameters(&tlvs.payment_context); let allow_mpp = parameters.map_or(true, |params| params.payment_size_msat.is_some()); + let is_eligible_first_hop = |channel: &&ChannelDetails| { + self.network_graph + .read_only() + .node(&NodeId::from_pubkey(&channel.counterparty.node_id)) + .is_some() && channel + .counterparty + .forwarding_info + .clone() + .is_some_and(|info| PaymentRelay::try_from(info).is_ok()) + }; let direct_path_has_sufficient_liquidity = amount_msats.map_or(true, |amount_msats| { if allow_mpp { first_hops .iter() + .filter(is_eligible_first_hop) .map(|channel| channel.inbound_capacity_msat) .fold(0u64, u64::saturating_add) >= amount_msats } else { - first_hops.iter().any(|channel| { + first_hops.iter().filter(is_eligible_first_hop).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 @@ -135,9 +150,9 @@ impl Router for LSPS2Router { ); // 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. + // locally known, blinded-path-eligible 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 @@ -207,17 +222,22 @@ mod tests { use super::*; use bitcoin::secp256k1::SecretKey; + use bitcoin::Network; use core::sync::atomic::{AtomicUsize, Ordering}; use lightning::blinded_path::payment::{Bolt12OfferContext, PaymentConstraints}; - use lightning::ln::channel_state::{ChannelCounterparty, ChannelShutdownState}; + use lightning::ln::channel_state::{ + ChannelCounterparty, ChannelShutdownState, CounterpartyForwardingInfo, + }; use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning::ln::types::ChannelId; use lightning::offers::invoice_request::InvoiceRequestFields; use lightning::offers::offer::OfferId; - use lightning::types::features::InitFeatures; + use lightning::types::features::{ChannelFeatures, InitFeatures}; use lightning::types::payment::PaymentSecret; use std::collections::BTreeMap; + use crate::logger::Logger; + #[derive(Clone)] struct TestEntropy; @@ -313,7 +333,11 @@ mod tests { node_id: pubkey(13), features: InitFeatures::empty(), unspendable_punishment_reserve: 0, - forwarding_info: None, + forwarding_info: Some(CounterpartyForwardingInfo { + fee_base_msat: 0, + fee_proportional_millionths: 0, + cltv_expiry_delta: 18, + }), outbound_htlc_minimum_msat: None, outbound_htlc_maximum_msat: None, }, @@ -350,6 +374,26 @@ mod tests { } } + fn empty_graph() -> Arc { + Arc::new(Graph::new(Network::Regtest, Arc::new(Logger::new_log_facade()))) + } + + fn announced_graph(recipient: PublicKey, include_peer: bool) -> Arc { + let graph = empty_graph(); + let counterparty = if include_peer { pubkey(13) } else { pubkey(14) }; + graph + .add_channel_from_partial_announcement( + 42, + None, + 0, + ChannelFeatures::empty(), + NodeId::from_pubkey(&recipient), + NodeId::from_pubkey(&counterparty), + ) + .unwrap(); + graph + } + fn payment_tlvs(metadata: BTreeMap>) -> ReceiveTlvs { ReceiveTlvs { payment_secret: PaymentSecret([2; 32]), @@ -384,7 +428,7 @@ mod tests { }; let metadata = payment_metadata(parameters); let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::None }; - let router = LSPS2Router::new(inner_router, TestEntropy); + let router = LSPS2Router::new(inner_router, empty_graph(), TestEntropy); let paths = router .create_blinded_payment_paths( @@ -419,7 +463,7 @@ mod tests { }; let metadata = payment_metadata(parameters); let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::None }; - let router = LSPS2Router::new(inner_router, TestEntropy); + let router = LSPS2Router::new(inner_router, empty_graph(), TestEntropy); assert!(router .create_blinded_payment_paths( @@ -444,7 +488,7 @@ mod tests { }; let metadata = payment_metadata(parameters); let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::None }; - let router = LSPS2Router::new(inner_router, TestEntropy); + let router = LSPS2Router::new(inner_router, empty_graph(), TestEntropy); let paths = router .create_blinded_payment_paths( @@ -474,7 +518,7 @@ mod tests { let metadata = payment_metadata(parameters); let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::ChannelBacked }; - let router = LSPS2Router::new(inner_router, TestEntropy); + let router = LSPS2Router::new(inner_router, empty_graph(), TestEntropy); let recipient = pubkey(10); let paths = router @@ -501,7 +545,7 @@ mod tests { let recipient = pubkey(10); let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; - let router = LSPS2Router::new(inner_router, TestEntropy); + let router = LSPS2Router::new(inner_router, empty_graph(), TestEntropy); assert!( router @@ -530,7 +574,7 @@ mod tests { }; let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; - let router = LSPS2Router::new(inner_router, TestEntropy); + let router = LSPS2Router::new(inner_router, announced_graph(recipient, true), TestEntropy); let paths = router .create_blinded_payment_paths( @@ -546,6 +590,73 @@ mod tests { assert_eq!(paths[0].introduction_node(), &IntroductionNode::NodeId(recipient)); } + #[test] + fn ignores_capacity_without_forwarding_info() { + 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 mut missing_forwarding_info = first_hop(3_000, None, None); + missing_forwarding_info.counterparty.forwarding_info = None; + let inner_router = + MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; + let router = LSPS2Router::new(inner_router, announced_graph(recipient, true), TestEntropy); + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + vec![missing_forwarding_info], + payment_tlvs(payment_metadata(parameters)), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!( + paths[0].introduction_node(), + &IntroductionNode::NodeId(lsp_node_id), + "capacity without forwarding parameters hid the JIT path" + ); + } + + #[test] + fn ignores_capacity_from_unannounced_peer() { + 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, announced_graph(recipient, false), TestEntropy); + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + vec![first_hop(3_000, None, None)], + payment_tlvs(payment_metadata(parameters)), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!( + paths[0].introduction_node(), + &IntroductionNode::NodeId(lsp_node_id), + "capacity through an unannounced peer hid the JIT path" + ); + } + #[test] fn requires_one_sufficient_non_mpp_channel() { let recipient = pubkey(10); @@ -559,7 +670,7 @@ mod tests { }; let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; - let router = LSPS2Router::new(inner_router, TestEntropy); + let router = LSPS2Router::new(inner_router, announced_graph(recipient, true), TestEntropy); let paths = router .create_blinded_payment_paths( @@ -588,7 +699,7 @@ mod tests { }; let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; - let router = LSPS2Router::new(inner_router, TestEntropy); + let router = LSPS2Router::new(inner_router, empty_graph(), TestEntropy); let paths = router .create_blinded_payment_paths( From 3e06254b543addda661dd16f5485031fc3ea5a7b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 16 Oct 2026 14:50:03 +0200 Subject: [PATCH 35/35] f Exclude private capacity from direct fallback Private channels could satisfy the direct-recipient capacity check when their peers appeared in gossip. That fallback exposes no channel hint, so payers could get an unpayable invoice instead of a JIT path. Count only public channels toward direct-recipient capacity so hidden liquidity cannot suppress JIT fallback. Preserve ordinary blinded paths and cover both single-part and multipart capacity checks. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/router.rs | 57 ++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/src/liquidity/client/lsps2/router.rs b/src/liquidity/client/lsps2/router.rs index 81d18d8f52..da3a276c7b 100644 --- a/src/liquidity/client/lsps2/router.rs +++ b/src/liquidity/client/lsps2/router.rs @@ -108,10 +108,12 @@ impl Router for LSPS2Router { let parameters = self.payment_parameters(&tlvs.payment_context); let allow_mpp = parameters.map_or(true, |params| params.payment_size_msat.is_some()); let is_eligible_first_hop = |channel: &&ChannelDetails| { - self.network_graph - .read_only() - .node(&NodeId::from_pubkey(&channel.counterparty.node_id)) - .is_some() && channel + channel.is_announced + && self + .network_graph + .read_only() + .node(&NodeId::from_pubkey(&channel.counterparty.node_id)) + .is_some() && channel .counterparty .forwarding_info .clone() @@ -150,8 +152,9 @@ impl Router for LSPS2Router { ); // 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, blinded-path-eligible channels can receive the resolved amount: across channels - // when MPP is allowed, or on one channel, including its HTLC bounds, when MPP is disabled. + // public, blinded-path-eligible channels can receive the resolved amount: across channels when + // MPP is allowed, or on one channel, including its HTLC bounds, when MPP is disabled. A direct + // recipient path provides no hint for payers to discover private channels. // 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 @@ -362,7 +365,7 @@ mod tests { is_channel_ready: true, channel_shutdown_state: Some(ChannelShutdownState::NotShuttingDown), is_usable: true, - is_announced: false, + is_announced: true, inbound_htlc_minimum_msat, inbound_htlc_maximum_msat, config: None, @@ -590,6 +593,46 @@ mod tests { assert_eq!(paths[0].introduction_node(), &IntroductionNode::NodeId(recipient)); } + #[test] + fn ignores_private_channel_capacity_for_direct_fallback() { + let recipient = pubkey(10); + let lsp_node_id = pubkey(11); + let inner_router = + MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; + let router = LSPS2Router::new(inner_router, announced_graph(recipient, true), TestEntropy); + + for payment_size_msat in [Some(3_000), None] { + let parameters = LSPS2LeaseParameters { + lsp_node_id, + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat, + valid_until: u64::MAX, + }; + for is_announced in [false, true] { + let mut channel = first_hop(3_000, None, None); + channel.is_announced = is_announced; + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + vec![first_hop(1_500, None, None), channel], + payment_tlvs(payment_metadata(parameters)), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + let introduction_node = if is_announced { recipient } else { lsp_node_id }; + assert_eq!( + paths[0].introduction_node(), + &IntroductionNode::NodeId(introduction_node), + "direct fallback must only count public channel capacity" + ); + } + } + } + #[test] fn ignores_capacity_without_forwarding_info() { let recipient = pubkey(10);