From 0f78f3a0bb93d696aa874d5268ac782a7af18c3e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:13:16 +0200 Subject: [PATCH 01/42] 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 541f2bb6ef9858cb59224f52ebd2dd59d8fb31de Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:13:41 +0200 Subject: [PATCH 02/42] lsps2: Add single-use payment leases Track negotiated parameters as expiring leases so intercept identifiers cannot be reused across payments. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 1 + src/liquidity/client/lsps2/state.rs | 150 ++++++++++++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 src/liquidity/client/lsps2/state.rs diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 3033f8d827..923db45e57 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -551,3 +551,4 @@ pub(crate) struct LSPS2BuyResponse { intercept_scid: u64, cltv_expiry_delta: u32, } +pub(crate) mod state; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs new file mode 100644 index 0000000000..0e479e2c43 --- /dev/null +++ b/src/liquidity/client/lsps2/state.rs @@ -0,0 +1,150 @@ +use std::collections::HashMap; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use bitcoin::secp256k1::PublicKey; +use lightning::impl_writeable_tlv_based; +use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; + +use crate::data_store::{DataStore, StorableObject, StorableObjectId, StorableObjectUpdate}; +use crate::hex_utils; + +pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; + +pub(crate) type PaymentLeaseStore = DataStore; + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub(crate) struct PaymentLeaseId { + pub(crate) lsp_node_id: PublicKey, + pub(crate) intercept_scid: u64, +} + +impl_writeable_tlv_based!(PaymentLeaseId, { + (0, lsp_node_id, required), + (2, intercept_scid, required), +}); + +impl StorableObjectId for PaymentLeaseId { + fn encode_to_hex_str(&self) -> String { + format!( + "{}{:016x}", + hex_utils::to_string(&self.lsp_node_id.serialize()), + self.intercept_scid + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PaymentLease { + pub(crate) id: PaymentLeaseId, + pub(crate) params: LSPS2OpeningFeeParams, + pub(crate) cltv_expiry_delta: u32, + pub(crate) payment_size_msat: Option, + pub(crate) valid_until: u64, +} + +impl_writeable_tlv_based!(PaymentLease, { + (0, id, required), + (2, params, required), + (4, valid_until, required), + (6, cltv_expiry_delta, required), + (8, payment_size_msat, option), +}); + +#[derive(Clone, Debug)] +pub(crate) struct PaymentLeaseUpdate(PaymentLease); + +impl StorableObjectUpdate for PaymentLeaseUpdate { + fn id(&self) -> PaymentLeaseId { + self.0.id + } +} + +impl StorableObject for PaymentLease { + type Id = PaymentLeaseId; + type Update = PaymentLeaseUpdate; + + fn id(&self) -> Self::Id { + self.id + } + + fn update(&mut self, update: Self::Update) -> bool { + if *self == update.0 { + false + } else { + *self = update.0; + true + } + } + + fn to_update(&self) -> Self::Update { + PaymentLeaseUpdate(self.clone()) + } +} + +#[derive(Default)] +pub(crate) struct LSPS2LeaseState { + leases: HashMap, +} + +impl LSPS2LeaseState { + pub(crate) fn from_leases(leases: Vec) -> Self { + Self { leases: leases.into_iter().map(|lease| (lease.id, lease)).collect() } + } + + pub(crate) fn insert(&mut self, lease: PaymentLease) { + self.leases.insert(lease.id, lease); + } + + pub(crate) fn take_valid(&mut self, id: &PaymentLeaseId) -> Option { + let lease = self.leases.remove(id)?; + if is_lease_usable(&lease) { + Some(lease) + } else { + None + } + } + + pub(crate) fn prune(&mut self) { + self.leases.retain(|_, lease| is_lease_usable(lease)); + } +} + +pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { + lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS +} + +fn now_secs() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn leases_are_consumed_once() { + let id = PaymentLeaseId { + lsp_node_id: PublicKey::from_slice(&[2; 33]).unwrap(), + intercept_scid: 42, + }; + let mut state = LSPS2LeaseState::default(); + state.insert(PaymentLease { + id, + params: LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: "2030-01-01T00:00:00Z".parse().unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 2, + promise: String::new(), + }, + cltv_expiry_delta: 18, + payment_size_msat: Some(1_000), + valid_until: now_secs() + MIN_LEASE_REMAINING_SECS + 60, + }); + assert!(state.take_valid(&id).is_some()); + assert!(state.take_valid(&id).is_none()); + } +} From b24e9104d00d37682178fdb2840898a648decacb Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:27:14 +0200 Subject: [PATCH 03/42] lsps2: Persist payment leases Load cached leases in parallel at startup and remove entries that are expired or too close to expiry for safe reuse. Co-Authored-By: HAL 9000 --- src/io/mod.rs | 4 ++++ src/liquidity/client/lsps2/mod.rs | 4 ++++ src/liquidity/mod.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/io/mod.rs b/src/io/mod.rs index c70c68d96e..c4feb0e2e9 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -33,6 +33,10 @@ pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// Cached LSPS2 payment leases are persisted under this prefix. +pub(crate) const LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE: &str = "lsps2_leases"; +pub(crate) const LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + /// The node metrics will be persisted under this key. pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = ""; pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = ""; diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 923db45e57..09baa422a5 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -35,6 +35,8 @@ use crate::payment::PaymentMetadata; use crate::types::{ChannelManager, KeysManager, LiquidityManager}; use crate::{Config, Error}; +use self::state::{LSPS2LeaseState, PaymentLeaseStore}; + pub(crate) struct LSPS2Client where L::Target: LdkLogger, @@ -44,6 +46,8 @@ where Mutex>>, pub(crate) pending_buy_requests: Mutex>>, + pub(crate) lease_store: Arc>, + pub(crate) lease_state: Mutex, pub(crate) channel_manager: Arc, pub(crate) keys_manager: Arc, pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 0eddde1ae8..428ba31f5c 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -31,7 +31,12 @@ use tokio::sync::oneshot; use crate::builder::BuildError; use crate::connection::ConnectionManager; +use crate::io::utils::read_all_objects; +use crate::io::{ + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, +}; use crate::liquidity::client::lsps1::LSPS1Client; +use crate::liquidity::client::lsps2::state::{is_lease_usable, LSPS2LeaseState, PaymentLeaseStore}; use crate::liquidity::client::lsps2::LSPS2Client; use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; @@ -247,6 +252,26 @@ where } pub(crate) async fn build(self) -> Result, BuildError> { + let leases = read_all_objects( + &*self.kv_store, + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + self.logger.clone(), + ) + .await + .map_err(|_| BuildError::ReadFailed)?; + let lease_store = Arc::new(PaymentLeaseStore::new( + leases.clone(), + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&self.kv_store), + self.logger.clone(), + )); + for lease in leases.iter().filter(|lease| !is_lease_usable(lease)) { + lease_store.remove(&lease.id).await.map_err(|_| BuildError::WriteFailed)?; + } + let lease_state = + LSPS2LeaseState::from_leases(leases.into_iter().filter(is_lease_usable).collect()); let liquidity_service_config = self.lsps2_service.as_ref().map(|s| { let lsps2_service_config = Some(s.ldk_service_config.clone()); let lsps5_service_config = None; @@ -311,6 +336,8 @@ where lsp_nodes: Arc::clone(&lsp_nodes), pending_lsps2_fee_requests: Mutex::new(HashMap::new()), pending_buy_requests: Mutex::new(HashMap::new()), + lease_store, + lease_state: Mutex::new(lease_state), channel_manager: self.channel_manager.clone(), keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), From a1c35dd33b1bcaf3cd537e6c2bf1259df13680ae Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:28:41 +0200 Subject: [PATCH 04/42] lsps2: Prune stale payment leases Remove unusable leases hourly so persisted cache data cannot grow without bound. Co-Authored-By: HAL 9000 --- src/lib.rs | 19 +++++++++++++++++++ src/liquidity/client/lsps2/mod.rs | 14 ++++++++++++++ src/liquidity/client/lsps2/state.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 14ee734a3d..a60f9fcd00 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -555,6 +555,25 @@ impl Node { } }); + // Periodically prune LSPS2 leases before they expire so stale parameters don't accumulate. + let lsps2_client = self.liquidity_source.lsps2_client(); + let prune_logger = Arc::clone(&self.logger); + let mut stop_pruning = self.stop_sender.subscribe(); + self.runtime.spawn_cancellable_background_task(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60 * 60)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = stop_pruning.changed() => return, + _ = interval.tick() => { + if let Err(error) = lsps2_client.prune_stale_leases().await { + log_error!(prune_logger, "Failed pruning stale LSPS2 leases: {}", error); + } + }, + } + } + }); + // Regularly broadcast node announcements. let bcast_cm = Arc::clone(&self.channel_manager); let bcast_pm = Arc::clone(&self.peer_manager); diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 09baa422a5..6ae95225ab 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -60,6 +60,20 @@ impl LSPS2Client where L::Target: LdkLogger, { + pub(crate) async fn prune_stale_leases(&self) -> Result<(), Error> { + let stale_ids = self + .lease_store + .list_filter(|lease| !state::is_lease_usable(lease)) + .into_iter() + .map(|lease| lease.id) + .collect::>(); + for id in stale_ids { + self.lease_store.remove(&id).await?; + } + self.lease_state.lock().expect("lock").prune(); + Ok(()) + } + pub(crate) async fn lsps2_receive_to_jit_channel( self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 0e479e2c43..73282a2c31 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -147,4 +147,31 @@ mod tests { assert!(state.take_valid(&id).is_some()); assert!(state.take_valid(&id).is_none()); } + + #[test] + fn prunes_leases_close_to_expiry() { + let id = PaymentLeaseId { + lsp_node_id: PublicKey::from_slice(&[2; 33]).unwrap(), + intercept_scid: 43, + }; + let mut state = LSPS2LeaseState::default(); + state.insert(PaymentLease { + id, + params: LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: "2030-01-01T00:00:00Z".parse().unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 2, + promise: String::new(), + }, + cltv_expiry_delta: 18, + payment_size_msat: Some(1_000), + valid_until: now_secs() + MIN_LEASE_REMAINING_SECS - 1, + }); + state.prune(); + assert!(state.take_valid(&id).is_none()); + } } From 8bda20de8d00e2177d37bbd6666cfea97e71c061 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 14:38:11 +0200 Subject: [PATCH 05/42] lsps2: Configure a global fee limit Move LSPS2 fee policy out of individual BOLT11 receive calls and into node configuration. A shared total limit gives every receive flow one policy across all configured liquidity sources. Co-Authored-By: HAL 9000 --- src/config.rs | 9 ++++ src/liquidity/client/lsps2/mod.rs | 49 +++++++++-------- src/payment/bolt11.rs | 87 ++++++++++++------------------- tests/integration_tests_rust.rs | 36 ++++++++----- 4 files changed, 91 insertions(+), 90 deletions(-) diff --git a/src/config.rs b/src/config.rs index aa8cc7e615..c3358c470a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -167,6 +167,7 @@ pub(crate) const LIQUIDITY_DISCOVERY_RETRY_MAX_DELAY: Duration = Duration::from_ /// | `node_alias` | None | /// | `trusted_peers_0conf` | [] | /// | `probing_liquidity_limit_multiplier` | 3 | +/// | `lsps2_max_total_lsp_fee_limit_msat` | None | /// | `anchor_channels_config` | AnchorChannelsConfig::default() | /// | `route_parameters` | None | /// | `tor_config` | None | @@ -211,6 +212,13 @@ pub struct Config { /// Channels with available liquidity less than the required amount times this value won't be /// used to send pre-flight probes. pub probing_liquidity_limit_multiplier: u64, + /// The maximum total fee in millisatoshis that an LSPS2 liquidity provider may charge for a + /// just-in-time channel. + /// + /// This limit applies to all configured LSPS2 liquidity sources. If unset, fixed-amount + /// payments accept the fee negotiated for that payment, while variable-amount payments use the + /// negotiated proportional fee as their limit. + pub lsps2_max_total_lsp_fee_limit_msat: Option, /// Configuration options pertaining to Anchor channels, i.e., channels for which the /// `option_zero_fee_commitments` or `option_anchors_zero_fee_htlc_tx` channel type is /// negotiated. @@ -258,6 +266,7 @@ impl Default for Config { announcement_addresses: None, trusted_peers_0conf: Vec::new(), probing_liquidity_limit_multiplier: DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER, + lsps2_max_total_lsp_fee_limit_msat: None, anchor_channels_config: AnchorChannelsConfig::default(), tor_config: None, route_parameters: None, diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 6ae95225ab..5319eef3ab 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -76,8 +76,8 @@ where pub(crate) async fn lsps2_receive_to_jit_channel( self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, - expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, - payment_hash: Option, connection_manager: Arc>, + expiry_secs: u32, payment_hash: Option, + connection_manager: Arc>, ) -> Result<(Bolt11Invoice, LspConfig), Error> { // Connect to all candidate LSPs before querying fees. let all_offers = self.gather_lsps2_offers(&connection_manager).await?; @@ -111,10 +111,10 @@ where Error::LiquidityRequestFailed })?; - if let Some(max_total_lsp_fee_limit_msat) = max_total_lsp_fee_limit_msat { + if let Some(max_total_lsp_fee_limit_msat) = self.config.lsps2_max_total_lsp_fee_limit_msat { if min_total_fee_msat > max_total_lsp_fee_limit_msat { log_error!(self.logger, - "Failed to request inbound JIT channel as LSP's requested total opening fee of {}msat exceeds our fee limit of {}msat", + "Failed to request inbound JIT channel as LSP's requested total opening fee of {}msat exceeds our configured fee limit of {}msat", min_total_fee_msat, max_total_lsp_fee_limit_msat ); return Err(Error::LiquidityFeeTooHigh); @@ -156,11 +156,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)| { @@ -170,25 +170,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", @@ -200,8 +201,12 @@ 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_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), + max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, + max_proportional_opening_fee_ppm_msat: self + .config + .lsps2_max_total_lsp_fee_limit_msat + .is_none() + .then_some(min_prop_fee_ppm_msat), }; let invoice = self.lsps2_create_jit_invoice( buy_response, diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index e33dd3950f..27e1046900 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -125,8 +125,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 { @@ -137,7 +136,6 @@ impl Bolt11Payment { amount_msat, description, expiry_secs, - max_total_lsp_fee_limit_msat, payment_hash, connection_manager, ) @@ -148,7 +146,6 @@ impl Bolt11Payment { .lsps2_receive_variable_amount_to_jit_channel( description, expiry_secs, - max_proportional_lsp_fee_limit_ppm_msat, payment_hash, connection_manager, ) @@ -621,40 +618,33 @@ impl Bolt11Payment { Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a payment of the amount given and - /// receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a payment of + /// the amount given when additional inbound liquidity is needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_total_lsp_fee_limit_msat` will limit how much fee we allow the LSP to take for opening the - /// channel to us. We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced before creating the invoice. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md pub fn receive_via_jit_channel( &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_total_lsp_fee_limit_msat: Option, ) -> Result { let description = maybe_try_convert_enum(description)?; - let invoice = self.receive_via_jit_channel_inner( - Some(amount_msat), - &description, - expiry_secs, - max_total_lsp_fee_limit_msat, - None, - None, - )?; + let invoice = + self.receive_via_jit_channel_inner(Some(amount_msat), &description, expiry_secs, None)?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a payment of the amount given and - /// receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a payment of + /// the amount given when additional inbound liquidity is needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_total_lsp_fee_limit_msat` will limit how much fee we allow the LSP to take for opening the - /// channel to us. We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced before creating the invoice. /// /// The inbound payment will only be accepted if /// [`Config::manually_handle_unknown_bolt11_payments`] is enabled. We will then emit a @@ -678,56 +668,46 @@ impl Bolt11Payment { /// [`counterparty_skimmed_fee_msat`]: crate::payment::PaymentKind::Bolt11::counterparty_skimmed_fee_msat pub fn receive_via_jit_channel_for_hash( &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_total_lsp_fee_limit_msat: Option, payment_hash: PaymentHash, + payment_hash: PaymentHash, ) -> Result { let description = maybe_try_convert_enum(description)?; let invoice = self.receive_via_jit_channel_inner( Some(amount_msat), &description, expiry_secs, - max_total_lsp_fee_limit_msat, - None, Some(payment_hash), )?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a variable amount payment (also known - /// as "zero-amount" invoice) and receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a variable + /// amount payment, also known as a "zero-amount" invoice, when additional inbound liquidity is + /// needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_proportional_lsp_fee_limit_ppm_msat` will limit how much proportional fee, in - /// parts-per-million millisatoshis, we allow the LSP to take for opening the channel to us. - /// We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced when the payment arrives. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md pub fn receive_variable_amount_via_jit_channel( &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_proportional_lsp_fee_limit_ppm_msat: Option, ) -> Result { let description = maybe_try_convert_enum(description)?; - let invoice = self.receive_via_jit_channel_inner( - None, - &description, - expiry_secs, - None, - max_proportional_lsp_fee_limit_ppm_msat, - None, - )?; + let invoice = self.receive_via_jit_channel_inner(None, &description, expiry_secs, None)?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a variable amount payment (also known - /// as "zero-amount" invoice) and receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a variable + /// amount payment, also known as a "zero-amount" invoice, when additional inbound liquidity is + /// needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_proportional_lsp_fee_limit_ppm_msat` will limit how much proportional fee, in - /// parts-per-million millisatoshis, we allow the LSP to take for opening the channel to us. - /// We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced when the payment arrives. /// /// The inbound payment will only be accepted if /// [`Config::manually_handle_unknown_bolt11_payments`] is enabled. We will then emit a @@ -750,16 +730,13 @@ 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 { let description = maybe_try_convert_enum(description)?; let invoice = self.receive_via_jit_channel_inner( None, &description, expiry_secs, - None, - max_proportional_lsp_fee_limit_ppm_msat, Some(payment_hash), )?; Ok(maybe_wrap(invoice)) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index c1e973091d..1184a2b4c6 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3504,7 +3504,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. @@ -3565,7 +3565,6 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, manual_payment_hash, ) .unwrap(); @@ -3620,7 +3619,6 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, manual_payment_hash, ) .unwrap(); @@ -3831,7 +3829,6 @@ async fn lsps2_client_trusts_lsp() { jit_amount_msat, &invoice_description.into(), 1024, - None, manual_payment_hash, ) .unwrap(); @@ -4010,7 +4007,6 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { jit_amount_msat, &invoice_description.into(), 1024, - None, manual_payment_hash, ) .unwrap(); @@ -4823,11 +4819,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()); @@ -4881,7 +4884,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 { @@ -4896,10 +4900,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 90ef9391f67fb2158a70b400179ec1ae38196466 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 11:15:18 +0200 Subject: [PATCH 06/42] f Enforce negotiated variable payment fees Apply the configured total cap in addition to the fee quoted by the selected LSP. This prevents a global policy from authorizing a higher proportional charge. Co-Authored-By: HAL 9000 --- src/event.rs | 34 +++++++++++++++++++++++++------ src/liquidity/client/lsps2/mod.rs | 6 +----- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/event.rs b/src/event.rs index be54969c7a..558c0f1055 100644 --- a/src/event.rs +++ b/src/event.rs @@ -683,12 +683,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, + } } fn resolve_inbound_payment_id( @@ -2290,6 +2294,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 5319eef3ab..7fcf665f30 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -202,11 +202,7 @@ where .await?; let lsps2_parameters = LSPS2Parameters { max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, - max_proportional_opening_fee_ppm_msat: self - .config - .lsps2_max_total_lsp_fee_limit_msat - .is_none() - .then_some(min_prop_fee_ppm_msat), + max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), }; let invoice = self.lsps2_create_jit_invoice( buy_response, From c2ec00bdc7fc8a8db6a04d4efbda9ea886249097 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 11:16:34 +0200 Subject: [PATCH 07/42] f Clarify global LSPS2 fee policy Document the breaking configuration change, the interaction with the negotiated fee, and which non-JIT routes a BOLT 11 payer can use. Co-Authored-By: HAL 9000 --- CHANGELOG.md | 3 +++ src/config.rs | 6 +++--- src/payment/bolt11.rs | 24 ++++++++++++------------ src/payment/store.rs | 2 +- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8853e63610..6d5e433019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ `Event::PaymentClaimable`. ## Feature and API updates +- 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 c3358c470a..cdcaed2cf2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -215,9 +215,9 @@ pub struct Config { /// The maximum total fee in millisatoshis that an LSPS2 liquidity provider may charge for a /// just-in-time channel. /// - /// This limit applies to all configured LSPS2 liquidity sources. If unset, fixed-amount - /// payments accept the fee negotiated for that payment, while variable-amount payments use the - /// negotiated proportional fee as their limit. + /// 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 diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 27e1046900..257c984dba 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -621,9 +621,9 @@ impl Bolt11Payment { /// 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. /// - /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment - /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, - /// in which case no new channel is opened. + /// 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. /// /// The configured LSPS2 fee limit is enforced before creating the invoice. /// @@ -640,9 +640,9 @@ impl Bolt11Payment { /// 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. /// - /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment - /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, - /// in which case no new channel is opened. + /// 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. /// /// The configured LSPS2 fee limit is enforced before creating the invoice. /// @@ -684,9 +684,9 @@ impl Bolt11Payment { /// amount payment, also known as a "zero-amount" invoice, when additional inbound liquidity is /// needed. /// - /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment - /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, - /// in which case no new channel is opened. + /// 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. /// /// The configured LSPS2 fee limit is enforced when the payment arrives. /// @@ -703,9 +703,9 @@ impl Bolt11Payment { /// amount payment, also known as a "zero-amount" invoice, when additional inbound liquidity is /// needed. /// - /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment - /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, - /// in which case no new channel is opened. + /// 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. /// /// The configured LSPS2 fee limit is enforced when the payment arrives. /// diff --git a/src/payment/store.rs b/src/payment/store.rs index cb76e78cc3..38440a460e 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -541,7 +541,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] From d2e80a31ebf47fe3371fcaf83d37eed31cfa1e36 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:29:52 +0200 Subject: [PATCH 08/42] 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 | 253 ++++++++++++++++++++++------ src/liquidity/client/lsps2/state.rs | 159 +++++++++++++---- 2 files changed, 328 insertions(+), 84 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 7fcf665f30..91eafb8f78 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 @@ -79,8 +98,65 @@ 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( + LSPS2BuyResponse::from(&lease), + Some(amount_msat), + description, + expiry_secs, + payment_hash, + lsps2_parameters, + Some(&lsp.node_id), + )?; + + if was_negotiated { + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + } + Ok((invoice, lsp)) + } + + pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( + self: Arc, description: &Bolt11InvoiceDescription, expiry_secs: u32, + payment_hash: Option, connection_manager: Arc>, + ) -> Result<(Bolt11Invoice, LspConfig), Error> { + let (lease, proportional_fee, lsp, was_negotiated) = + self.acquire_variable_lease(&connection_manager).await?; + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, + max_proportional_opening_fee_ppm_msat: Some(proportional_fee), + }; + let invoice = self.lsps2_create_jit_invoice( + LSPS2BuyResponse::from(&lease), + None, + description, + expiry_secs, + payment_hash, + lsps2_parameters, + Some(&lsp.node_id), + )?; + + if was_negotiated { + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + } + Ok((invoice, lsp)) + } + + async fn acquire_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { + if let Some((lease, total_fee_msat, lsp)) = + self.take_cached_fixed_lease(amount_msat).await? + { + 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)| { @@ -128,38 +204,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() @@ -197,25 +260,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( @@ -307,7 +356,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)?; @@ -320,7 +369,7 @@ where { let mut pending_buy_requests_lock = self.pending_buy_requests.lock().expect("lock"); let request_id = client_handler - .select_opening_params(lsps2_node.node_id, amount_msat, opening_fee_params) + .select_opening_params(lsps2_node.node_id, amount_msat, opening_fee_params.clone()) .map_err(|e| { log_error!( self.logger, @@ -346,7 +395,91 @@ 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( @@ -570,4 +703,30 @@ pub(crate) struct LSPS2BuyResponse { intercept_scid: u64, cltv_expiry_delta: u32, } + +impl From<&PaymentLease> for LSPS2BuyResponse { + fn from(lease: &PaymentLease) -> Self { + Self { intercept_scid: lease.id.intercept_scid, cltv_expiry_delta: lease.cltv_expiry_delta } + } +} +#[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 73282a2c31..7d2e634f18 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -4,6 +4,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; use lightning::impl_writeable_tlv_based; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; +use lightning_liquidity::lsps2::utils::compute_opening_fee; use crate::data_store::{DataStore, StorableObject, StorableObjectId, StorableObjectUpdate}; use crate::hex_utils; @@ -96,14 +97,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)); } @@ -120,58 +161,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 356386b464db16d89fd75d41fc5078616d92ef14 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 10:01:50 +0200 Subject: [PATCH 09/42] 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 | 20 +++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 91eafb8f78..e07caeb384 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -394,6 +394,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 @@ -407,7 +417,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, }; @@ -530,7 +540,8 @@ where src_node_id: lsps2_node.node_id, short_channel_id: buy_response.intercept_scid, fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, - cltv_expiry_delta: buy_response.cltv_expiry_delta as u16, + cltv_expiry_delta: state::checked_cltv_expiry_delta(buy_response.cltv_expiry_delta) + .expect("LSPS2 lease CLTV delta was validated before invoice creation"), htlc_minimum_msat: None, htlc_maximum_msat: None, }]); diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 7d2e634f18..629af15d33 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -151,7 +151,12 @@ impl LSPS2LeaseState { } pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { - lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS + checked_cltv_expiry_delta(lease.cltv_expiry_delta).is_some() + && lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS +} + +pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { + cltv_expiry_delta.try_into().ok() } fn now_secs() -> u64 { @@ -224,6 +229,19 @@ mod tests { assert!(state.take_valid(&id).is_none()); } + #[test] + fn rejects_unsupported_cltv_delta() { + let mut lease = lease(2, 53, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); + lease.cltv_expiry_delta = u16::MAX as u32 + 1; + let id = lease.id; + let mut state = LSPS2LeaseState::from_leases(vec![lease]); + + assert!( + state.take_valid(&id).is_none(), + "lease with an unsupported CLTV delta remained usable" + ); + } + #[test] fn selects_cheapest_matching_lease_across_lsps() { let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; From 2ec28f216d919fadf347a3ce74a5ea9759a2bf93 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 11:19:07 +0200 Subject: [PATCH 10/42] f Reserve BOLT12 final CLTV headroom Reject lease deltas that cannot be combined with the BOLT12 final CLTV delta so path construction cannot destroy an otherwise accepted lease. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/state.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 629af15d33..bd5e67e7fc 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -3,6 +3,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; @@ -10,6 +11,7 @@ use crate::data_store::{DataStore, StorableObject, StorableObjectId, StorableObj 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; @@ -156,7 +158,9 @@ pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { } pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { - cltv_expiry_delta.try_into().ok() + u16::try_from(cltv_expiry_delta) + .ok() + .filter(|delta| *delta <= u16::MAX - BOLT12_FINAL_CLTV_EXPIRY_DELTA) } fn now_secs() -> u64 { @@ -242,6 +246,17 @@ mod tests { ); } + #[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 abf2ea78f1f2f08b842cdac3b0c6f9b80d37143d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:36:48 +0200 Subject: [PATCH 11/42] 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 ff0e9222ddaba82a2882fc5ed91dd229515d9897 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 14:50:26 +0200 Subject: [PATCH 12/42] 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 c4feb0e2e9..ac09202983 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -37,6 +37,11 @@ pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = "" pub(crate) const LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE: &str = "lsps2_leases"; pub(crate) const LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// LSPS2 lease cache targets are persisted under this key. +pub(crate) const LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE: &str = "lsps2_cache_targets"; +pub(crate) const LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +pub(crate) const LSPS2_CACHE_TARGET_PERSISTENCE_KEY: &str = "targets"; + /// The node metrics will be persisted under this key. pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = ""; pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = ""; diff --git a/src/lib.rs b/src/lib.rs index a60f9fcd00..4385d32a10 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -555,7 +555,7 @@ impl Node { } }); - // Periodically prune LSPS2 leases before they expire so stale parameters don't accumulate. + // Periodically prune LSPS2 state so expired leases and cache targets don't accumulate. let lsps2_client = self.liquidity_source.lsps2_client(); let prune_logger = Arc::clone(&self.logger); let mut stop_pruning = self.stop_sender.subscribe(); @@ -569,6 +569,9 @@ impl Node { if let Err(error) = lsps2_client.prune_stale_leases().await { log_error!(prune_logger, "Failed pruning stale LSPS2 leases: {}", error); } + if let Err(error) = lsps2_client.prune_stale_cache_targets().await { + log_error!(prune_logger, "Failed pruning stale LSPS2 cache targets: {}", error); + } }, } } diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index e07caeb384..e7408e62ae 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, @@ -93,6 +97,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 bd5e67e7fc..72274b4002 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -1,20 +1,272 @@ 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, StorableObjectUpdate}; use crate::hex_utils; +use crate::io::{ + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::logger::{log_error, LdkLogger}; +use crate::types::DynStore; +use crate::Error; pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; 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, @@ -163,14 +415,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, @@ -292,4 +595,110 @@ mod tests { assert!(state.variable_amount(Some(49), &available_lsps).is_none()); assert_eq!(state.variable_amount(Some(50), &available_lsps).unwrap().0.id, variable.id); } + + fn cache_target( + id: LeaseCacheTargetId, absolute_expiry: Option, last_accessed: u64, + ) -> LeaseCacheTarget { + LeaseCacheTarget { id, absolute_expiry, last_accessed } + } + + #[test] + fn cache_targets_roundtrip() { + let target = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, Some(42), 7); + + let encoded = target.encode(); + let decoded = LeaseCacheTarget::read(&mut &encoded[..]).unwrap(); + assert_eq!(decoded, target); + } + + #[tokio::test] + async fn cache_targets_persist_as_single_blob() { + let kv_store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (state, removed) = LeaseCacheTargetState::from_targets(Vec::new(), 2, 0); + assert!(removed.is_empty()); + let store = + LeaseCacheTargetStore::new(state, Arc::clone(&kv_store), Arc::new(TestLogger::new())); + store + .register(cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, None, 1)) + .await + .unwrap(); + store.register(cache_target(LeaseCacheTargetId::Variable, None, 2)).await.unwrap(); + + let keys = KVStore::list( + &*kv_store, + LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, + ) + .await + .unwrap(); + assert_eq!(keys, vec![LSPS2_CACHE_TARGET_PERSISTENCE_KEY.to_string()]); + + let restored = + read_lease_cache_targets(&*kv_store, Arc::new(TestLogger::new())).await.unwrap(); + assert_eq!(restored, store.targets()); + } + + #[tokio::test] + async fn cache_target_store_keeps_memory_on_persist_failure() { + let initial = cache_target(LeaseCacheTargetId::Variable, None, 1); + let (state, removed) = LeaseCacheTargetState::from_targets(vec![initial.clone()], 2, 0); + assert!(removed.is_empty()); + let kv_store: Arc = Arc::new(DynStoreWrapper(FailingStore)); + let store = LeaseCacheTargetStore::new(state, kv_store, Arc::new(TestLogger::new())); + + let result = store + .register(cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, None, 2)) + .await; + + assert_eq!(result, Err(Error::PersistenceFailed)); + assert_eq!(store.targets(), vec![initial]); + } + + #[test] + fn cache_target_registry_evicts_least_recently_used() { + let first = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, None, 1); + let second = cache_target(LeaseCacheTargetId::Variable, None, 2); + let third = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 3_000 }, None, 3); + let first_id = first.id; + let second_id = second.id; + let third_id = third.id; + let (mut state, removed) = LeaseCacheTargetState::from_targets(vec![first, second], 2, 10); + assert!(removed.is_empty()); + + assert_eq!(state.register(cache_target(first_id, None, 11)), None); + assert_eq!(state.register(third), Some(second_id)); + assert!(state.contains(&first_id)); + assert!(!state.contains(&second_id)); + assert!(state.contains(&third_id)); + } + + #[test] + fn cache_target_registry_prunes_only_expired_targets() { + let expired = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, Some(9), 1); + let unexpired = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 2_000 }, Some(11), 2); + let long_lived = cache_target(LeaseCacheTargetId::Variable, None, 3); + let expired_id = expired.id; + let unexpired_id = unexpired.id; + let long_lived_id = long_lived.id; + + let (state, removed) = + LeaseCacheTargetState::from_targets(vec![expired, unexpired, long_lived], 3, 10); + + assert_eq!(removed, vec![expired_id]); + assert!(state.contains(&unexpired_id)); + assert!(state.contains(&long_lived_id)); + } + + #[test] + fn cache_target_registry_keeps_longest_expiry() { + let id = LeaseCacheTargetId::Fixed { amount_msat: 1_000 }; + let (mut state, removed) = + LeaseCacheTargetState::from_targets(vec![cache_target(id, Some(20), 1)], 2, 10); + assert!(removed.is_empty()); + + state.register(cache_target(id, Some(30), 2)); + assert_eq!(state.targets().first().unwrap().absolute_expiry, Some(30)); + state.register(cache_target(id, None, 3)); + assert_eq!(state.targets().first().unwrap().absolute_expiry, None); + } } diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 428ba31f5c..f91f56a1c7 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -36,7 +36,10 @@ use crate::io::{ LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::liquidity::client::lsps1::LSPS1Client; -use crate::liquidity::client::lsps2::state::{is_lease_usable, LSPS2LeaseState, PaymentLeaseStore}; +use crate::liquidity::client::lsps2::state::{ + is_lease_usable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, + LeaseCacheTargetStore, PaymentLeaseStore, +}; use crate::liquidity::client::lsps2::LSPS2Client; use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; @@ -46,6 +49,9 @@ use crate::{Config, Error}; const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; const LSPS_DISCOVERY_WAIT_TIMEOUT_SECS: u64 = 10; +// Keep the proactively refreshed target set bounded. Evicted requirements still fall back to +// on-demand lease negotiation, so this limits durable state without limiting offer validity. +const LSPS2_LEASE_CACHE_TARGET_SIZE: usize = 100; fn select_lsps_for_protocol( lsp_nodes: &Arc>>, protocol: u16, override_node_id: Option<&PublicKey>, @@ -252,14 +258,17 @@ where } pub(crate) async fn build(self) -> Result, BuildError> { - let leases = read_all_objects( - &*self.kv_store, - LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, - LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, - self.logger.clone(), - ) - .await - .map_err(|_| BuildError::ReadFailed)?; + let (leases, cache_targets) = tokio::join!( + read_all_objects( + &*self.kv_store, + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + self.logger.clone(), + ), + read_lease_cache_targets(&*self.kv_store, self.logger.clone()), + ); + let leases = leases.map_err(|_| BuildError::ReadFailed)?; + let cache_targets = cache_targets.map_err(|_| BuildError::ReadFailed)?; let lease_store = Arc::new(PaymentLeaseStore::new( leases.clone(), LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), @@ -272,6 +281,19 @@ where } let lease_state = LSPS2LeaseState::from_leases(leases.into_iter().filter(is_lease_usable).collect()); + let (cache_target_state, removed_target_ids) = LeaseCacheTargetState::from_targets( + cache_targets, + LSPS2_LEASE_CACHE_TARGET_SIZE, + now_secs(), + ); + let cache_target_store = Arc::new(LeaseCacheTargetStore::new( + cache_target_state, + Arc::clone(&self.kv_store), + self.logger.clone(), + )); + if !removed_target_ids.is_empty() { + cache_target_store.persist().await.map_err(|_| BuildError::WriteFailed)?; + } let liquidity_service_config = self.lsps2_service.as_ref().map(|s| { let lsps2_service_config = Some(s.ldk_service_config.clone()); let lsps5_service_config = None; @@ -338,6 +360,7 @@ where pending_buy_requests: Mutex::new(HashMap::new()), lease_store, lease_state: Mutex::new(lease_state), + cache_target_store, channel_manager: self.channel_manager.clone(), keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), From 1cd3537b41871d210cb741d6979fec3adc0f55f2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 14:34:47 +0200 Subject: [PATCH 13/42] 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 4385d32a10..7a1c7e72be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1084,6 +1084,7 @@ impl Node { Bolt12Payment::new( Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), + Arc::clone(&self.liquidity_source), Arc::clone(&self.keys_manager), Arc::clone(&self.payment_store), Arc::clone(&self.config), @@ -1101,6 +1102,7 @@ impl Node { Arc::new(Bolt12Payment::new( Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), + Arc::clone(&self.liquidity_source), Arc::clone(&self.keys_manager), Arc::clone(&self.payment_store), Arc::clone(&self.config), diff --git a/src/payment/bolt12/mod.rs b/src/payment/bolt12/mod.rs index 0e994d67ac..b156729c7b 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 074f8b4ea3cd94d04e2fea036bce6da3b10e7c04 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 14:54:56 +0200 Subject: [PATCH 14/42] 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/event.rs | 1 + src/payment/bolt11.rs | 45 +----------------------------------- src/payment/metadata.rs | 51 +++++++++++++++++++++++++++++++++++++++++ src/payment/mod.rs | 3 ++- 4 files changed, 55 insertions(+), 45 deletions(-) create mode 100644 src/payment/metadata.rs diff --git a/src/event.rs b/src/event.rs index 558c0f1055..6ce3fe42dd 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2301,6 +2301,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!( diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 257c984dba..c0c93706c3 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`]. @@ -162,37 +150,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 1ac6103bea..5795de9afe 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod asynchronous; mod bolt11; mod bolt12; +mod metadata; mod onchain; pub(crate) mod pending_payment_store; mod spontaneous; @@ -17,8 +18,8 @@ pub(crate) mod store; mod unified; pub use bolt11::Bolt11Payment; -pub(crate) use bolt11::PaymentMetadata; pub use bolt12::{Bolt12Payment, PayerProofOptions}; +pub(crate) use metadata::PaymentMetadata; pub use onchain::OnchainPayment; pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails}; pub use spontaneous::SpontaneousPayment; From c6340e3f5bbd3d6ab7ada9d239d31689f3573def Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 10:24:44 +0200 Subject: [PATCH 15/42] lsps2: Add JIT payment router Keep LSPS2 path construction in LDK Node and fall back to exact- amount JIT paths only when ordinary blinded paths are unavailable. This avoids unnecessary channel opens and unsafe MPP splits. Decode node-local single-use lease metadata directly in the router so the internal wrapper needs no pluggable metadata abstraction. Co-Authored-By: HAL 9000 --- src/event.rs | 5 +- src/liquidity/client/lsps2/mod.rs | 8 +- src/liquidity/client/lsps2/router.rs | 517 +++++++++++++++++++++++++++ src/payment/metadata.rs | 35 +- 4 files changed, 559 insertions(+), 6 deletions(-) create mode 100644 src/liquidity/client/lsps2/router.rs diff --git a/src/event.rs b/src/event.rs index 6ce3fe42dd..d31aaaa783 100644 --- a/src/event.rs +++ b/src/event.rs @@ -2283,6 +2283,7 @@ mod tests { max_total_opening_fee_msat: Some(42_000), max_proportional_opening_fee_ppm_msat: None, }), + lsps2_lease_parameters: None, }; assert_eq!( @@ -2315,12 +2316,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 e7408e62ae..232164da4d 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -522,8 +522,11 @@ where // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; - let encoded_payment_metadata = - PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }.encode(); + let encoded_payment_metadata = PaymentMetadata { + lsps2_parameters: Some(lsps2_parameters), + lsps2_lease_parameters: None, + } + .encode(); let (payment_hash, payment_secret, payment_metadata) = match payment_hash { Some(payment_hash) => { let (payment_secret, payment_metadata) = self @@ -759,4 +762,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..7318ef4394 --- /dev/null +++ b/src/liquidity/client/lsps2/router.rs @@ -0,0 +1,517 @@ +// 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, MIN_FINAL_CLTV_EXPIRY_DELTA}; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::{EntropySource, ReceiveAuthKey}; +use lightning::types::features::BlindedHopFeatures; +use lightning::types::payment::PaymentHash; + +use crate::payment::PaymentMetadata; + +/// Parameters needed to construct an LSPS2 blinded payment path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LSPS2LeaseParameters { + /// The LSP serving as the blinded path introduction node. + pub lsp_node_id: PublicKey, + /// The single-use intercept short channel id negotiated with the LSP. + pub intercept_scid: u64, + /// The CLTV delta required by the LSP. + pub cltv_expiry_delta: u16, + /// The fixed amount negotiated for this intercept SCID, or `None` for a variable amount. + pub payment_size_msat: Option, + /// Unix timestamp at which these parameters expire. + pub valid_until: u64, +} + +impl_writeable_tlv_based!(LSPS2LeaseParameters, { + (0, lsp_node_id, required), + (2, intercept_scid, required), + (4, cltv_expiry_delta, required), + (6, payment_size_msat, option), + (8, valid_until, required), +}); + +/// A router wrapper that uses ordinary payment paths when available and LSPS2 JIT paths otherwise. +pub struct LSPS2Router { + inner_router: R, + entropy_source: ES, +} + +impl LSPS2Router { + /// Constructs an LSPS2-aware wrapper around `inner_router`. + pub fn new(inner_router: R, entropy_source: ES) -> Self { + Self { inner_router, entropy_source } + } + + fn payment_parameters(&self, payment_context: &PaymentContext) -> Vec { + match payment_context { + PaymentContext::Bolt12Offer(_) | PaymentContext::AsyncBolt12Offer(_) => {}, + _ => return Vec::new(), + } + payment_context + .payment_metadata() + .and_then(PaymentMetadata::decode_from_bolt12_payment_metadata) + .and_then(|metadata| metadata.lsps2_lease_parameters) + .into_iter() + .collect() + } +} + +impl Router for LSPS2Router { + fn find_route( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + ) -> Result { + self.inner_router.find_route(payer, route_params, first_hops, inflight_htlcs) + } + + fn find_route_with_id( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + payment_hash: PaymentHash, payment_id: PaymentId, + ) -> Result { + self.inner_router.find_route_with_id( + payer, + route_params, + first_hops, + inflight_htlcs, + payment_hash, + payment_id, + ) + } + + fn create_blinded_payment_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, + secp_ctx: &Secp256k1, + ) -> Result, ()> { + let parameters = self.payment_parameters(&tlvs.payment_context); + let allow_mpp = parameters.iter().all(|params| params.payment_size_msat.is_some()); + let direct_path_has_sufficient_liquidity = amount_msats.map_or(true, |amount_msats| { + if allow_mpp { + first_hops + .iter() + .map(|channel| channel.inbound_capacity_msat) + .fold(0u64, u64::saturating_add) + >= amount_msats + } else { + first_hops.iter().any(|channel| { + channel.inbound_capacity_msat >= amount_msats + && channel.inbound_htlc_minimum_msat.unwrap_or(0) <= amount_msats + && channel.inbound_htlc_maximum_msat.unwrap_or(u64::MAX) >= amount_msats + }) + } + }); + let inner_paths = self.inner_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + 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; + } + + if parameters.is_empty() { + 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; + }; + + let mut paths = Vec::new(); + for params in parameters { + // A fixed lease is valid only for the exact amount negotiated with the LSP. A mismatch means + // these parameters were selected for a different response and must never be exposed. + if params.payment_size_msat.is_some_and(|fixed_amount| fixed_amount != amount_msats) { + continue; + } + // Both fixed and variable invoices require the entire resolved payment on any selected JIT + // path. Fixed invoices may advertise MPP across multiple regular paths, while variable + // invoices disable MPP at response construction. Setting both bounds here additionally makes + // every JIT candidate indivisible and lets us include several candidates for robustness. + let htlc_amount_msat = amount_msats; + let payment_constraints = PaymentConstraints { + max_cltv_expiry: tlvs + .payment_constraints + .max_cltv_expiry + .saturating_add(params.cltv_expiry_delta as u32), + htlc_minimum_msat: htlc_amount_msat, + }; + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: params.intercept_scid, + payment_relay: PaymentRelay { + cltv_expiry_delta: params.cltv_expiry_delta, + fee_proportional_millionths: 0, + fee_base_msat: 0, + }, + payment_constraints, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: params.lsp_node_id, + htlc_maximum_msat: htlc_amount_msat, + }; + if let Ok(path) = BlindedPaymentPath::new( + &[forward_node], + recipient, + local_node_receive_key, + tlvs.clone(), + htlc_amount_msat, + // LSPS2 requires two blocks more than the usual final CLTV delta. + MIN_FINAL_CLTV_EXPIRY_DELTA + 2, + &self.entropy_source, + secp_ctx, + ) { + paths.push(path); + } + } + if paths.is_empty() { + // A capacity race may make ordinary paths available after a lease was negotiated. That case + // returned above and intentionally discards the single-use lease. Reaching here means neither + // ordinary nor valid JIT paths can receive the payment. + Err(()) + } else { + Ok(paths) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use bitcoin::secp256k1::SecretKey; + use core::sync::atomic::{AtomicUsize, Ordering}; + use lightning::blinded_path::payment::{Bolt12OfferContext, PaymentConstraints}; + use lightning::offers::invoice_request::InvoiceRequestFields; + use lightning::offers::offer::OfferId; + use lightning::types::payment::PaymentSecret; + use std::collections::BTreeMap; + + #[derive(Clone)] + struct TestEntropy; + + impl EntropySource for TestEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } + + struct MockRouter { + calls: AtomicUsize, + 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 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 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/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 e4e79d92686c1a957b6d2f7ecc56bc780780e5a8 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 11:23:54 +0200 Subject: [PATCH 16/42] f Cover direct-path liquidity branches Exercise aggregate MPP capacity and the per-channel non-MPP check so ordinary fallback behavior remains explicit. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/router.rs | 107 +++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/liquidity/client/lsps2/router.rs b/src/liquidity/client/lsps2/router.rs index 7318ef4394..ed166f3944 100644 --- a/src/liquidity/client/lsps2/router.rs +++ b/src/liquidity/client/lsps2/router.rs @@ -224,8 +224,11 @@ mod tests { 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::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; @@ -314,6 +317,53 @@ mod tests { 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]), @@ -482,6 +532,63 @@ mod tests { ); } + #[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); From d9cc2ee23415a563e3537666e870631e9cabbb01 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:42:26 +0200 Subject: [PATCH 17/42] bolt12: Wrap offers message handling Install a node-local handler while preserving ChannelManager behavior. This creates an isolated integration point for asynchronous JIT replies. Co-Authored-By: HAL 9000 --- src/builder.rs | 7 +++++-- src/payment/bolt12/mod.rs | 4 ++++ src/payment/bolt12/offers.rs | 39 ++++++++++++++++++++++++++++++++++++ src/payment/mod.rs | 1 + src/types.rs | 4 ++-- 5 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 src/payment/bolt12/offers.rs diff --git a/src/builder.rs b/src/builder.rs index dc41aef1a2..a1da26b287 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -74,6 +74,7 @@ use crate::lnurl_auth::LnurlAuth; use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; +use crate::payment::NodeOffersMessageHandler; use crate::peer_store::PeerStore; use crate::probing::{ HighDegreeStrategy, Prober, ProbingConfig, ProbingStrategy, ProbingStrategyKind, @@ -2022,6 +2023,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() { @@ -2092,7 +2095,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 {}, @@ -2105,7 +2108,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 b156729c7b..914cd5f4dc 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 5795de9afe..87fa88d9a9 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -18,6 +18,7 @@ pub(crate) mod store; mod unified; pub use bolt11::Bolt11Payment; +pub(crate) use bolt12::NodeOffersMessageHandler; pub use bolt12::{Bolt12Payment, PayerProofOptions}; pub(crate) use metadata::PaymentMetadata; pub use onchain::OnchainPayment; diff --git a/src/types.rs b/src/types.rs index 22429d980b..e9c11a37c8 100644 --- a/src/types.rs +++ b/src/types.rs @@ -50,7 +50,7 @@ use crate::fee_estimator::OnchainFeeEstimator; use crate::ffi::maybe_wrap; use crate::logger::Logger; use crate::message_handler::NodeCustomMessageHandler; -use crate::payment::{PaymentDetails, PendingPaymentDetails}; +use crate::payment::{NodeOffersMessageHandler, PaymentDetails, PendingPaymentDetails}; use crate::runtime::RuntimeSpawner; #[cfg(feature = "uniffi")] @@ -310,7 +310,7 @@ pub(crate) type OnionMessenger = lightning::onion_message::messenger::OnionMesse Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, IgnoringMessageHandler, From e240a7c2f6e0d1603d74b8b447e3fcad26ba99aa Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:48:01 +0200 Subject: [PATCH 18/42] bolt12: Sync offers flow block state Feed every supported chain source into the node-local offers flow so invoice expiry and blinded-path CLTV limits use the current tip. Co-Authored-By: HAL 9000 --- src/builder.rs | 11 ++++-- src/chain/bitcoind.rs | 60 +++++++++++++++++++++++++------ src/chain/electrum.rs | 19 +++++++--- src/chain/esplora.rs | 19 +++++++--- src/chain/mod.rs | 34 ++++++++++++++---- src/lib.rs | 16 +++++++-- src/payment/bolt12/offers.rs | 70 +++++++++++++++++++++++++++++++++--- 7 files changed, 194 insertions(+), 35 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index a1da26b287..f5a1e53f01 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2023,8 +2023,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() { @@ -2366,6 +2372,7 @@ fn build_with_store_internal( output_sweeper, peer_manager, onion_messenger, + offers_message_handler, connection_manager, keys_manager, network_graph, diff --git a/src/chain/bitcoind.rs b/src/chain/bitcoind.rs index f7589671b8..dda881352a 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -42,6 +42,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -156,7 +157,8 @@ impl BitcoindChainSource { pub(super) async fn continuously_sync_wallets( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) { // First register for the wallet polling status to make sure `Node::sync_wallets` calls // wait on the result before proceeding. @@ -184,11 +186,13 @@ impl BitcoindChainSource { let onchain_wallet_best_block = onchain_wallet.current_best_block(); let channel_manager_best_block = channel_manager.current_best_block(); + let offers_best_block = offers_message_handler.current_best_block(); let sweeper_best_block = output_sweeper.current_best_block(); let mut chain_listeners = vec![ (onchain_wallet_best_block, &*onchain_wallet as &(dyn Listen + Send + Sync)), (channel_manager_best_block, &*channel_manager as &(dyn Listen + Send + Sync)), + (offers_best_block, &*offers_message_handler as &(dyn Listen + Send + Sync)), (sweeper_best_block, &*output_sweeper as &(dyn Listen + Send + Sync)), ]; @@ -224,6 +228,7 @@ impl BitcoindChainSource { header_cache, Arc::clone(&onchain_wallet), Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper), ); @@ -341,6 +346,7 @@ impl BitcoindChainSource { _ = self.poll_and_update_listeners( Arc::clone(&onchain_wallet), Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper) ) => {} @@ -398,7 +404,8 @@ impl BitcoindChainSource { pub(super) async fn poll_and_update_listeners( &self, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> Result<(), Error> { let receiver_res = { let mut status_lock = self.wallet_polling_status.lock().expect("lock"); @@ -418,6 +425,7 @@ impl BitcoindChainSource { .poll_and_update_listeners_inner( onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) @@ -430,7 +438,8 @@ impl BitcoindChainSource { async fn poll_and_update_listeners_inner( &self, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> Result<(), Error> { let mut spv_client_lock = self.spv_client.lock().await; if spv_client_lock.is_none() { @@ -445,6 +454,7 @@ impl BitcoindChainSource { HeaderCache::new(), Arc::clone(&onchain_wallet), Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), chain_monitor, output_sweeper, )); @@ -511,12 +521,14 @@ impl BitcoindChainSource { fn new_spv_client( &self, chain_tip: ValidatedBlockHeader, header_cache: HeaderCache, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> BitcoindSpvClient { let chain_poller = ChainPoller::new(Arc::clone(&self.api_client), self.config.network); let chain_listener = Arc::new(ChainListener { onchain_wallet: Arc::downgrade(&onchain_wallet), channel_manager: Arc::downgrade(&channel_manager), + offers_message_handler: Arc::downgrade(&offers_message_handler), chain_monitor: Arc::downgrade(&chain_monitor), output_sweeper: Arc::downgrade(&output_sweeper), }); @@ -1507,6 +1519,7 @@ pub(crate) enum FeeRateEstimationMode { pub(crate) struct ChainListener { pub(crate) onchain_wallet: std::sync::Weak, pub(crate) channel_manager: std::sync::Weak, + pub(crate) offers_message_handler: std::sync::Weak, pub(crate) chain_monitor: std::sync::Weak, pub(crate) output_sweeper: std::sync::Weak, } @@ -1514,10 +1527,17 @@ pub(crate) struct ChainListener { impl ChainListener { fn upgrade( &self, - ) -> Option<(Arc, Arc, Arc, Arc)> { + ) -> Option<( + Arc, + Arc, + Arc, + Arc, + Arc, + )> { Some(( self.onchain_wallet.upgrade()?, self.channel_manager.upgrade()?, + self.offers_message_handler.upgrade()?, self.chain_monitor.upgrade()?, self.output_sweeper.upgrade()?, )) @@ -1529,32 +1549,50 @@ impl Listen for ChainListener { &self, header: &bitcoin::block::Header, txdata: &lightning::chain::transaction::TransactionData, height: u32, ) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() + if let Some(( + onchain_wallet, + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + )) = self.upgrade() { onchain_wallet.filtered_block_connected(header, txdata, height); channel_manager.filtered_block_connected(header, txdata, height); + offers_message_handler.filtered_block_connected(header, txdata, height); chain_monitor.filtered_block_connected(header, txdata, height); output_sweeper.filtered_block_connected(header, txdata, height); } } fn block_connected(&self, block: &bitcoin::Block, height: u32) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() + if let Some(( + onchain_wallet, + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + )) = self.upgrade() { onchain_wallet.block_connected(block, height); channel_manager.block_connected(block, height); + offers_message_handler.block_connected(block, height); chain_monitor.block_connected(block, height); output_sweeper.block_connected(block, height); } } fn blocks_disconnected(&self, fork_point_block: lightning::chain::BlockLocator) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() + if let Some(( + onchain_wallet, + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + )) = self.upgrade() { onchain_wallet.blocks_disconnected(fork_point_block); channel_manager.blocks_disconnected(fork_point_block); + offers_message_handler.blocks_disconnected(fork_point_block); chain_monitor.blocks_disconnected(fork_point_block); output_sweeper.blocks_disconnected(fork_point_block); } diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 5bae426653..9255a0bdfb 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -37,6 +37,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::runtime::Runtime; use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; @@ -208,7 +209,8 @@ impl ElectrumChainSource { } pub(crate) async fn sync_lightning_wallet( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let receiver_res = { @@ -224,8 +226,14 @@ impl ElectrumChainSource { })?; } - let res = - self.sync_lightning_wallet_inner(channel_manager, chain_monitor, output_sweeper).await; + let res = self + .sync_lightning_wallet_inner( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await; self.lightning_wallet_sync_status .lock() @@ -236,14 +244,17 @@ impl ElectrumChainSource { } async fn sync_lightning_wallet_inner( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let sync_cman = Arc::clone(&channel_manager); + let sync_offers = Arc::clone(&offers_message_handler); let sync_cmon = Arc::clone(&chain_monitor); let sync_sweeper = Arc::clone(&output_sweeper); let confirmables = vec![ sync_cman as Arc, + sync_offers as Arc, sync_cmon as Arc, sync_sweeper as Arc, ]; diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index f4aaea4c9b..020454abb2 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -29,6 +29,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -268,7 +269,8 @@ impl EsploraChainSource { } pub(super) async fn sync_lightning_wallet( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let receiver_res = { @@ -284,8 +286,14 @@ impl EsploraChainSource { })?; } - let res = - self.sync_lightning_wallet_inner(channel_manager, chain_monitor, output_sweeper).await; + let res = self + .sync_lightning_wallet_inner( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await; self.lightning_wallet_sync_status .lock() @@ -296,14 +304,17 @@ impl EsploraChainSource { } async fn sync_lightning_wallet_inner( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let sync_cman = Arc::clone(&channel_manager); + let sync_offers = Arc::clone(&offers_message_handler); let sync_cmon = Arc::clone(&chain_monitor); let sync_sweeper = Arc::clone(&output_sweeper); let confirmables = vec![ &*sync_cman as &(dyn Confirm + Sync + Send), + &*sync_offers as &(dyn Confirm + Sync + Send), &*sync_cmon as &(dyn Confirm + Sync + Send), &*sync_sweeper as &(dyn Confirm + Sync + Send), ]; diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 0f96c409f8..5ddb35230d 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -25,6 +25,7 @@ use crate::config::{ }; use crate::fee_estimator::OnchainFeeEstimator; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -259,7 +260,8 @@ impl ChainSource { pub(crate) async fn continuously_sync_wallets( &self, stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, - channel_manager: Arc, chain_monitor: Arc, + channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) { match &self.kind { @@ -271,6 +273,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, background_sync_config, @@ -294,6 +297,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, background_sync_config, @@ -315,6 +319,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) @@ -326,8 +331,9 @@ impl ChainSource { async fn start_tx_based_sync_loop( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, - background_sync_config: &BackgroundSyncConfig, logger: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, background_sync_config: &BackgroundSyncConfig, + logger: Arc, ) { // Setup syncing intervals let onchain_wallet_sync_interval_secs = background_sync_config @@ -374,6 +380,7 @@ impl ChainSource { _ = lightning_wallet_sync_interval.tick() => { let _ = self.sync_lightning_wallet( Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper), ).await; @@ -405,18 +412,29 @@ impl ChainSource { // Synchronize the Lightning wallet via transaction-based protocols (i.e., Esplora, Electrum, // etc.) pub(crate) async fn sync_lightning_wallet( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { esplora_chain_source - .sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper) + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) .await }, ChainSourceKind::Electrum(electrum_chain_source) => { electrum_chain_source - .sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper) + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) .await }, ChainSourceKind::Bitcoind { .. } => { @@ -429,7 +447,8 @@ impl ChainSource { pub(crate) async fn poll_and_update_listeners( &self, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> Result<(), Error> { match &self.kind { ChainSourceKind::Esplora { .. } => { @@ -447,6 +466,7 @@ impl ChainSource { .poll_and_update_listeners( onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) diff --git a/src/lib.rs b/src/lib.rs index 7a1c7e72be..21c3b2b218 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -172,8 +172,8 @@ use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; use payment::{ - Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment, - UnifiedPayment, + Bolt11Payment, Bolt12Payment, NodeOffersMessageHandler, OnchainPayment, PaymentDetails, + SpontaneousPayment, UnifiedPayment, }; use peer_store::{PeerInfo, PeerStore}; #[cfg(feature = "uniffi")] @@ -253,6 +253,7 @@ pub struct Node { output_sweeper: Arc, peer_manager: Arc, onion_messenger: Arc, + offers_message_handler: Arc, connection_manager: Arc>>, keys_manager: Arc, network_graph: Arc, @@ -360,6 +361,7 @@ impl Node { let chain_source = Arc::clone(&self.chain_source); let sync_wallet = Arc::clone(&self.wallet); let sync_cman = Arc::clone(&self.channel_manager); + let sync_offers = Arc::clone(&self.offers_message_handler); let sync_cmon = Arc::clone(&self.chain_monitor); let sync_sweeper = Arc::clone(&self.output_sweeper); self.runtime.spawn_background_task(async move { @@ -368,6 +370,7 @@ impl Node { stop_sync_receiver, sync_wallet, sync_cman, + sync_offers, sync_cmon, sync_sweeper, ) @@ -2038,13 +2041,19 @@ impl Node { let chain_source = Arc::clone(&self.chain_source); let sync_wallet = Arc::clone(&self.wallet); let sync_cman = Arc::clone(&self.channel_manager); + let sync_offers = Arc::clone(&self.offers_message_handler); let sync_cmon = Arc::clone(&self.chain_monitor); let sync_sweeper = Arc::clone(&self.output_sweeper); self.runtime.block_on(async move { if chain_source.is_transaction_based() { chain_source.update_fee_rate_estimates().await?; chain_source - .sync_lightning_wallet(sync_cman, sync_cmon, Arc::clone(&sync_sweeper)) + .sync_lightning_wallet( + sync_cman, + sync_offers, + sync_cmon, + Arc::clone(&sync_sweeper), + ) .await?; chain_source.sync_onchain_wallet(sync_wallet).await?; } else { @@ -2053,6 +2062,7 @@ impl Node { .poll_and_update_listeners( sync_wallet, sync_cman, + sync_offers, sync_cmon, Arc::clone(&sync_sweeper), ) diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index d5f21746b4..db95db6a04 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,24 +5,56 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::sync::Arc; +use std::sync::{Arc, RwLock}; +use bitcoin::block::Header; +use bitcoin::constants::ChainHash; +use bitcoin::secp256k1::Secp256k1; +use bitcoin::{Block, BlockHash, Network, Txid}; use lightning::blinded_path::message::OffersContext; +use lightning::chain::transaction::TransactionData; +use lightning::chain::{BlockLocator, Confirm, Listen}; +use lightning::offers::flow::OffersMessageFlow; use lightning::onion_message::messenger::{ MessageSendInstructions, Responder, ResponseInstruction, }; use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; +use lightning::sign::{EntropySource, NodeSigner, Recipient}; -use crate::types::ChannelManager; +use crate::logger::Logger; +use crate::types::{ChannelManager, KeysManager, MessageRouter}; /// Routes offers messages through node-local handling before falling back to the channel manager. pub(crate) struct NodeOffersMessageHandler { channel_manager: Arc, + flow: OffersMessageFlow, Arc>, + best_block: RwLock, } impl NodeOffersMessageHandler { - pub(crate) fn new(channel_manager: Arc) -> Self { - Self { channel_manager } + pub(crate) fn new( + network: Network, current_timestamp: u32, channel_manager: Arc, + keys_manager: Arc, message_router: Arc, logger: Arc, + ) -> Self { + let best_block = channel_manager.current_best_block(); + let mut secp_ctx = Secp256k1::new(); + secp_ctx.seeded_randomize(&keys_manager.get_secure_random_bytes()); + let flow = OffersMessageFlow::new( + ChainHash::using_genesis_block(network), + best_block, + keys_manager.get_node_id(Recipient::Node).expect("node ID"), + current_timestamp, + keys_manager.get_expanded_key(), + keys_manager.get_receive_auth_key(), + secp_ctx, + message_router, + logger, + ); + Self { channel_manager, flow, best_block: RwLock::new(best_block) } + } + + pub(crate) fn current_best_block(&self) -> BlockLocator { + *self.best_block.read().expect("lock") } } @@ -37,3 +69,33 @@ impl OffersMessageHandler for NodeOffersMessageHandler { self.channel_manager.release_pending_messages() } } + +impl Confirm for NodeOffersMessageHandler { + fn transactions_confirmed(&self, _header: &Header, _txdata: &TransactionData, _height: u32) {} + + fn transaction_unconfirmed(&self, _txid: &Txid) {} + + fn best_block_updated(&self, header: &Header, height: u32) { + let best_block = BlockLocator::new(header.block_hash(), height); + *self.best_block.write().expect("lock") = best_block; + self.flow.best_block_updated(header, height); + } + + fn get_relevant_txids(&self) -> Vec<(Txid, u32, Option)> { + Vec::new() + } +} + +impl Listen for NodeOffersMessageHandler { + fn filtered_block_connected(&self, header: &Header, _txdata: &TransactionData, height: u32) { + self.best_block_updated(header, height); + } + + fn block_connected(&self, block: &Block, height: u32) { + self.best_block_updated(&block.header, height); + } + + fn blocks_disconnected(&self, fork_point_block: BlockLocator) { + *self.best_block.write().expect("lock") = fork_point_block; + } +} From df8ebe45721c4578462a46211fdf779891e691f6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:10:30 +0200 Subject: [PATCH 19/42] 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 f5a1e53f01..cfff333413 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -69,6 +69,7 @@ use crate::io::{ PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; +use crate::liquidity::client::lsps2::router::LSPS2Router; use crate::liquidity::{LSPS2ServiceConfig, LiquiditySourceBuilder, LspConfig}; use crate::lnurl_auth::LnurlAuth; use crate::logger::{log_error, LdkLogger, LogLevel, LogWriter, Logger}; @@ -1934,12 +1935,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); @@ -2309,12 +2313,15 @@ fn build_with_store_internal( if let Some(penalty) = probing_cfg.diversity_penalty_msat { probing_fee_params.probing_diversity_penalty_msat = penalty; } - let probing_router = Arc::new(DefaultRouter::new( - Arc::clone(&network_graph), - Arc::clone(&logger), + let probing_router = Arc::new(LSPS2Router::new( + DefaultRouter::new( + Arc::clone(&network_graph), + Arc::clone(&logger), + Arc::clone(&keys_manager), + Arc::clone(&scorer), + probing_fee_params, + ), Arc::clone(&keys_manager), - Arc::clone(&scorer), - probing_fee_params, )); Arc::new(HighDegreeStrategy::new( Arc::clone(&network_graph), diff --git a/src/types.rs b/src/types.rs index e9c11a37c8..59cc6fe93d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -48,6 +48,7 @@ use crate::config::{AnchorChannelsConfig, ChannelConfig}; use crate::data_store::DataStore; use crate::fee_estimator::OnchainFeeEstimator; use crate::ffi::maybe_wrap; +use crate::liquidity::client::lsps2::router::LSPS2Router; use crate::logger::Logger; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::{NodeOffersMessageHandler, PaymentDetails, PendingPaymentDetails}; @@ -277,7 +278,8 @@ pub(crate) type Broadcaster = crate::tx_broadcaster::TransactionBroadcaster>; +pub(crate) type BaseRouter = DefaultRouter< Arc, Arc, Arc, From 5a2985d9ec76b4bf7b5f829a47225d9d421d636b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:53:51 +0200 Subject: [PATCH 20/42] 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 cfff333413..c3cd0d956c 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2032,6 +2032,7 @@ fn build_with_store_internal( cur_time.as_secs().try_into().map_err(|_| BuildError::InvalidSystemTime)?, Arc::clone(&channel_manager), Arc::clone(&keys_manager), + Arc::clone(&router), Arc::clone(&message_router), Arc::clone(&logger), )); diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index db95db6a04..5df1c90c05 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -14,7 +14,11 @@ use bitcoin::{Block, BlockHash, Network, Txid}; use lightning::blinded_path::message::OffersContext; use lightning::chain::transaction::TransactionData; use lightning::chain::{BlockLocator, Confirm, Listen}; -use lightning::offers::flow::OffersMessageFlow; +use lightning::offers::flow::{InvreqResponseInstructions, OffersMessageFlow}; +use lightning::offers::invoice::UnsignedBolt12Invoice; +use lightning::offers::invoice_error::InvoiceError; +use lightning::offers::invoice_request::InvoiceRequestVerifiedFromOffer; +use lightning::offers::parse::Bolt12SemanticError; use lightning::onion_message::messenger::{ MessageSendInstructions, Responder, ResponseInstruction, }; @@ -22,19 +26,23 @@ use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; use lightning::sign::{EntropySource, NodeSigner, Recipient}; use crate::logger::Logger; -use crate::types::{ChannelManager, KeysManager, MessageRouter}; +use crate::types::{ChannelManager, KeysManager, MessageRouter, Router}; /// Routes offers messages through node-local handling before falling back to the channel manager. pub(crate) struct NodeOffersMessageHandler { channel_manager: Arc, + keys_manager: Arc, + router: Arc, flow: OffersMessageFlow, Arc>, + secp_ctx: Secp256k1, best_block: RwLock, } impl NodeOffersMessageHandler { pub(crate) fn new( network: Network, current_timestamp: u32, channel_manager: Arc, - keys_manager: Arc, message_router: Arc, logger: Arc, + keys_manager: Arc, router: Arc, message_router: Arc, + logger: Arc, ) -> Self { let best_block = channel_manager.current_best_block(); let mut secp_ctx = Secp256k1::new(); @@ -46,11 +54,18 @@ impl NodeOffersMessageHandler { current_timestamp, keys_manager.get_expanded_key(), keys_manager.get_receive_auth_key(), - secp_ctx, + secp_ctx.clone(), message_router, logger, ); - Self { channel_manager, flow, best_block: RwLock::new(best_block) } + Self { + channel_manager, + keys_manager, + router, + flow, + secp_ctx, + best_block: RwLock::new(best_block), + } } pub(crate) fn current_best_block(&self) -> BlockLocator { @@ -62,7 +77,86 @@ impl OffersMessageHandler for NodeOffersMessageHandler { fn handle_message( &self, message: OffersMessage, context: Option, responder: Option, ) -> Option<(OffersMessage, ResponseInstruction)> { - self.channel_manager.handle_message(message, context, responder) + let invoice_request = match message { + OffersMessage::InvoiceRequest(invoice_request) => invoice_request, + message => return self.channel_manager.handle_message(message, context, responder), + }; + + if matches!(context, Some(OffersContext::StaticInvoiceRequested { .. })) { + return self.channel_manager.handle_message( + OffersMessage::InvoiceRequest(invoice_request), + context, + responder, + ); + } + + let responder = responder?; + let payment_metadata = match context.as_ref() { + Some(OffersContext::InvoiceRequest { payment_metadata, .. }) => { + payment_metadata.clone() + }, + _ => None, + }; + let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { + Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request, + Ok(InvreqResponseInstructions::SendStaticInvoice { .. }) | Err(()) => return None, + }; + + let get_payment_info = |amount_msats, relative_expiry| { + self.channel_manager + .create_inbound_payment(Some(amount_msats), relative_expiry, None, None) + .map_err(|_| Bolt12SemanticError::InvalidAmount) + .map(|(payment_hash, payment_secret, _)| (payment_hash, payment_secret)) + }; + + let result = match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => self + .flow + .create_invoice_builder_from_invoice_request_with_keys( + self.router.as_ref(), + &request, + self.channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceError::from) + .and_then(|(builder, context)| { + builder + .build_and_sign(&self.secp_ctx) + .map_err(InvoiceError::from) + .map(|invoice| (invoice, context)) + }), + InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => self + .flow + .create_invoice_builder_from_invoice_request_without_keys( + self.router.as_ref(), + &request, + self.channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceError::from) + .and_then(|(builder, context)| { + builder + .build() + .map_err(InvoiceError::from) + .and_then(|invoice| { + invoice + .sign(|invoice: &UnsignedBolt12Invoice| { + self.keys_manager.sign_bolt12_invoice(invoice) + }) + .map_err(InvoiceError::from) + }) + .map(|invoice| (invoice, context)) + }), + }; + + Some(match result { + Ok((invoice, context)) => { + (OffersMessage::Invoice(invoice), responder.respond_with_reply_path(context)) + }, + Err(error) => (OffersMessage::InvoiceError(error), responder.respond()), + }) } fn release_pending_messages(&self) -> Vec<(OffersMessage, MessageSendInstructions)> { From 79aed690d30abcc7dfbe0f2bbc8c6c92ca6cd4d4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:26:11 +0200 Subject: [PATCH 21/42] 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 | 97 ++++++- src/payment/bolt12/offers.rs | 410 +++++++++++++++++++++++++----- 3 files changed, 449 insertions(+), 64 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index c3cd0d956c..f1eca2b282 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2245,6 +2245,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 232164da4d..a61cf2bf80 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -5,7 +5,7 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::future::Future; use std::ops::Deref; use std::sync::{Arc, Mutex, RwLock}; @@ -36,6 +36,7 @@ use crate::payment::PaymentMetadata; use crate::types::{ChannelManager, KeysManager, LiquidityManager}; use crate::{Config, Error}; +use self::router::LSPS2LeaseParameters; use self::state::{ now_secs, LSPS2LeaseState, LeaseCacheTarget, LeaseCacheTargetId, LeaseCacheTargetStore, PaymentLease, PaymentLeaseId, PaymentLeaseStore, @@ -59,6 +60,36 @@ where } } +const DEFAULT_BOLT12_INVOICE_EXPIRY_SECS: u32 = 2 * 60 * 60; + +pub(crate) struct JitInvoiceResponse { + pub(crate) payment_metadata: BTreeMap>, + pub(crate) allow_mpp: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum JitInvoiceRequest { + Fixed { amount_msat: u64, absolute_expiry: Option }, + Variable { amount_msat: u64, absolute_expiry: Option }, +} + +impl JitInvoiceRequest { + pub(crate) fn allow_mpp(&self) -> bool { + matches!(self, Self::Fixed { .. }) + } + + fn cache_target(&self) -> (LeaseCacheTargetId, Option) { + match *self { + Self::Fixed { amount_msat, absolute_expiry } => { + (LeaseCacheTargetId::Fixed { amount_msat }, absolute_expiry) + }, + Self::Variable { absolute_expiry, .. } => { + (LeaseCacheTargetId::Variable, absolute_expiry) + }, + } + } +} + pub(crate) struct LSPS2Client where L::Target: LdkLogger, @@ -112,6 +143,70 @@ where self.cache_target_store.targets() } + pub(crate) async fn prepare_invoice_response( + self: Arc, request: JitInvoiceRequest, connection_manager: Arc>, + ) -> Result { + // Cache targets are only an optimization. Recording an older, evicted offer again makes it + // eligible for startup pre-caching, but a persistence failure must not make the current + // invoice request unusable. + let (target_id, absolute_expiry) = request.cache_target(); + if let Err(error) = self.register_cache_target(target_id, absolute_expiry).await { + log_warn!(self.logger, "Failed recording LSPS2 lease cache target: {}", error); + } + + let allow_mpp = request.allow_mpp(); + let (lease, fee_parameters) = match request { + JitInvoiceRequest::Fixed { amount_msat, .. } => { + let (lease, total_fee_msat, _, _) = + self.acquire_fixed_lease(amount_msat, &connection_manager).await?; + ( + lease, + LSPS2Parameters { + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }, + ) + }, + JitInvoiceRequest::Variable { .. } => { + let (lease, proportional_fee, _, _) = + self.acquire_variable_lease(&connection_manager).await?; + ( + lease, + LSPS2Parameters { + max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, + max_proportional_opening_fee_ppm_msat: self + .config + .lsps2_max_total_lsp_fee_limit_msat + .is_none() + .then_some(proportional_fee), + }, + ) + }, + }; + + // The offers flow currently uses its two-hour default invoice expiry. Never publish a JIT + // path whose intercept SCID may expire before the invoice does. + if lease.valid_until.saturating_sub(now_secs()) < DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64 + { + return Err(Error::LiquidityRequestFailed); + } + let lease_parameters = LSPS2LeaseParameters { + lsp_node_id: lease.id.lsp_node_id, + intercept_scid: lease.id.intercept_scid, + cltv_expiry_delta: 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(JitInvoiceResponse { payment_metadata, allow_mpp }) + } + pub(crate) async fn lsps2_receive_to_jit_channel( self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: Option, diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index 5df1c90c05..e233442d7d 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,37 +5,70 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::sync::{Arc, RwLock}; +use std::collections::BTreeMap; +use std::sync::{Arc, OnceLock, RwLock, Weak}; use bitcoin::block::Header; use bitcoin::constants::ChainHash; use bitcoin::secp256k1::Secp256k1; use bitcoin::{Block, BlockHash, Network, Txid}; -use lightning::blinded_path::message::OffersContext; +use lightning::blinded_path::message::{MessageContext, OffersContext}; use lightning::chain::transaction::TransactionData; use lightning::chain::{BlockLocator, Confirm, Listen}; use lightning::offers::flow::{InvreqResponseInstructions, OffersMessageFlow}; -use lightning::offers::invoice::UnsignedBolt12Invoice; +use lightning::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use lightning::offers::invoice_error::InvoiceError; use lightning::offers::invoice_request::InvoiceRequestVerifiedFromOffer; +use lightning::offers::offer::Amount; use lightning::offers::parse::Bolt12SemanticError; use lightning::onion_message::messenger::{ MessageSendInstructions, Responder, ResponseInstruction, }; use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; use lightning::sign::{EntropySource, NodeSigner, Recipient}; +use lightning::types::payment::{PaymentHash, PaymentSecret}; +use lightning::util::logger::Logger as _; -use crate::logger::Logger; -use crate::types::{ChannelManager, KeysManager, MessageRouter, Router}; +use crate::connection::ConnectionManager; +use crate::liquidity::client::lsps2::{JitInvoiceRequest, JitInvoiceResponse, LSPS2Client}; +use crate::logger::{log_error, Logger}; +use crate::runtime::Runtime; +use crate::types::{ChannelManager, KeysManager, MessageRouter, OnionMessenger, Router}; + +type NodeOffersFlow = OffersMessageFlow, Arc>; +type InvoicePaymentInfo = OnceLock<(u64, PaymentHash, PaymentSecret)>; + +enum InvoiceBuildError { + Semantic(Bolt12SemanticError), + Response(InvoiceError), +} + +impl InvoiceBuildError { + fn into_invoice_error(self) -> InvoiceError { + match self { + Self::Semantic(error) => InvoiceError::from(error), + Self::Response(error) => error, + } + } +} + +struct JitInvoiceRequestDependencies { + runtime: Arc, + lsps2_client: Arc>>, + connection_manager: Weak>>, + onion_messenger: Weak, +} /// Routes offers messages through node-local handling before falling back to the channel manager. pub(crate) struct NodeOffersMessageHandler { channel_manager: Arc, keys_manager: Arc, router: Arc, - flow: OffersMessageFlow, Arc>, - secp_ctx: Secp256k1, + flow: Arc, + secp_ctx: Arc>, best_block: RwLock, + jit_dependencies: OnceLock, + logger: Arc, } impl NodeOffersMessageHandler { @@ -47,7 +80,7 @@ impl NodeOffersMessageHandler { let best_block = channel_manager.current_best_block(); let mut secp_ctx = Secp256k1::new(); secp_ctx.seeded_randomize(&keys_manager.get_secure_random_bytes()); - let flow = OffersMessageFlow::new( + let flow = Arc::new(OffersMessageFlow::new( ChainHash::using_genesis_block(network), best_block, keys_manager.get_node_id(Recipient::Node).expect("node ID"), @@ -56,23 +89,124 @@ impl NodeOffersMessageHandler { keys_manager.get_receive_auth_key(), secp_ctx.clone(), message_router, - logger, - ); + Arc::clone(&logger), + )); Self { channel_manager, keys_manager, router, flow, - secp_ctx, + secp_ctx: Arc::new(secp_ctx), best_block: RwLock::new(best_block), + jit_dependencies: OnceLock::new(), + logger, } } + pub(crate) fn initialize_jit_handling( + &self, runtime: Arc, lsps2_client: Arc>>, + connection_manager: Weak>>, + onion_messenger: Weak, + ) { + let dependencies = JitInvoiceRequestDependencies { + runtime, + lsps2_client, + connection_manager, + onion_messenger, + }; + assert!( + self.jit_dependencies.set(dependencies).is_ok(), + "JIT invoice handling must only be initialized once" + ); + } + pub(crate) fn current_best_block(&self) -> BlockLocator { *self.best_block.read().expect("lock") } } +fn build_invoice( + flow: &NodeOffersFlow, channel_manager: &ChannelManager, keys_manager: &KeysManager, + router: &Router, secp_ctx: &Secp256k1, + invoice_request: &InvoiceRequestVerifiedFromOffer, + payment_metadata: Option>>, allow_mpp: bool, + payment_info: &InvoicePaymentInfo, +) -> Result<(Bolt12Invoice, MessageContext), InvoiceBuildError> { + let get_payment_info = |amount_msats, relative_expiry| { + if let Some((cached_amount_msats, payment_hash, payment_secret)) = payment_info.get() { + return (*cached_amount_msats == amount_msats) + .then_some((*payment_hash, *payment_secret)) + .ok_or(Bolt12SemanticError::InvalidAmount); + } + let (payment_hash, payment_secret, _) = channel_manager + .create_inbound_payment(Some(amount_msats), relative_expiry, None, None) + .map_err(|_| Bolt12SemanticError::InvalidAmount)?; + let _ = payment_info.set((amount_msats, payment_hash, payment_secret)); + Ok((payment_hash, payment_secret)) + }; + + match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { + let (builder, context) = flow + .create_invoice_builder_from_invoice_request_with_keys( + router, + request, + channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceBuildError::Semantic)?; + let builder = if allow_mpp { builder } else { builder.disallow_mpp() }; + builder + .build_and_sign(secp_ctx) + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error))) + .map(|invoice| (invoice, context)) + }, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => { + let (builder, context) = flow + .create_invoice_builder_from_invoice_request_without_keys( + router, + request, + channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceBuildError::Semantic)?; + let builder = if allow_mpp { builder } else { builder.disallow_mpp() }; + let invoice = builder + .build() + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error)))?; + invoice + .sign(|invoice: &UnsignedBolt12Invoice| keys_manager.sign_bolt12_invoice(invoice)) + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error))) + .map(|invoice| (invoice, context)) + }, + } +} + +fn jit_invoice_request( + invoice_request: &InvoiceRequestVerifiedFromOffer, +) -> Result { + jit_invoice_request_from_fields( + invoice_request.amount(), + invoice_request.amount_msats(), + invoice_request.absolute_expiry().map(|expiry| expiry.as_secs()), + ) +} + +fn jit_invoice_request_from_fields( + offer_amount: Option, amount_msat: Option, absolute_expiry: Option, +) -> Result { + let amount_msat = amount_msat.ok_or(Bolt12SemanticError::MissingAmount)?; + match offer_amount { + Some(Amount::Bitcoin { .. }) => { + Ok(JitInvoiceRequest::Fixed { amount_msat, absolute_expiry }) + }, + Some(Amount::Currency { .. }) => Err(Bolt12SemanticError::UnsupportedCurrency), + None => Ok(JitInvoiceRequest::Variable { amount_msat, absolute_expiry }), + } +} + impl OffersMessageHandler for NodeOffersMessageHandler { fn handle_message( &self, message: OffersMessage, context: Option, responder: Option, @@ -102,61 +236,137 @@ 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 = Arc::new(InvoicePaymentInfo::new()); + let result = build_invoice( + self.flow.as_ref(), + self.channel_manager.as_ref(), + self.keys_manager.as_ref(), + self.router.as_ref(), + self.secp_ctx.as_ref(), + &invoice_request, + payment_metadata.clone(), + allow_mpp, + payment_info.as_ref(), + ); - Some(match result { + match result { Ok((invoice, context)) => { - (OffersMessage::Invoice(invoice), responder.respond_with_reply_path(context)) + return Some(( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + )); }, - Err(error) => (OffersMessage::InvoiceError(error), responder.respond()), - }) + Err(InvoiceBuildError::Semantic(Bolt12SemanticError::MissingPaths)) => {}, + Err(error) => { + return Some(( + OffersMessage::InvoiceError(error.into_invoice_error()), + responder.respond(), + )); + }, + } + + // Only an actual lack of ordinary blinded paths triggers LSPS2. Negotiation is kept out of + // the synchronous message-handler and router APIs; the verified request and reply path are + // moved into a future which sends the response directly when its single-use lease is ready. + let dependencies = match self.jit_dependencies.get() { + Some(dependencies) => dependencies, + None => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from( + Bolt12SemanticError::MissingPaths, + )), + responder.respond(), + )); + }, + }; + let jit_request = match jit_invoice_request(&invoice_request) { + Ok(request) => request, + Err(error) => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from(error)), + responder.respond(), + )); + }, + }; + let connection_manager = match dependencies.connection_manager.upgrade() { + Some(connection_manager) => connection_manager, + None => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "JIT invoice handling is unavailable".to_owned(), + )), + responder.respond(), + )); + }, + }; + let onion_messenger = match dependencies.onion_messenger.upgrade() { + Some(onion_messenger) => onion_messenger, + None => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "JIT invoice handling is unavailable".to_owned(), + )), + responder.respond(), + )); + }, + }; + + let lsps2_client = Arc::clone(&dependencies.lsps2_client); + let flow = Arc::clone(&self.flow); + let channel_manager = Arc::clone(&self.channel_manager); + let keys_manager = Arc::clone(&self.keys_manager); + let router = Arc::clone(&self.router); + let secp_ctx = Arc::clone(&self.secp_ctx); + let logger = Arc::clone(&self.logger); + dependencies.runtime.spawn_cancellable_background_task(async move { + let response = + lsps2_client.prepare_invoice_response(jit_request, connection_manager).await; + let (message, instructions) = match response { + Ok(JitInvoiceResponse { payment_metadata: jit_metadata, allow_mpp }) => { + debug_assert_eq!(allow_mpp, jit_request.allow_mpp()); + let mut merged_metadata = payment_metadata.unwrap_or_default(); + merged_metadata.extend(jit_metadata); + match build_invoice( + flow.as_ref(), + channel_manager.as_ref(), + keys_manager.as_ref(), + router.as_ref(), + secp_ctx.as_ref(), + &invoice_request, + Some(merged_metadata), + allow_mpp, + payment_info.as_ref(), + ) { + Ok((invoice, context)) => ( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + ), + Err(error) => ( + OffersMessage::InvoiceError(error.into_invoice_error()), + responder.respond(), + ), + } + }, + Err(error) => { + log_error!(logger, "Failed preparing LSPS2 invoice response: {}", error); + ( + OffersMessage::InvoiceError(InvoiceError::from_string( + "Failed preparing JIT invoice".to_owned(), + )), + responder.respond(), + ) + }, + }; + if let Err(error) = onion_messenger.handle_onion_message_response(message, instructions) + { + log_error!(logger, "Failed sending LSPS2 invoice response: {:?}", error); + } + }); + None } fn release_pending_messages(&self) -> Vec<(OffersMessage, MessageSendInstructions)> { @@ -193,3 +403,77 @@ impl Listen for NodeOffersMessageHandler { *self.best_block.write().expect("lock") = fork_point_block; } } + +#[cfg(test)] +mod tests { + use std::num::NonZeroU64; + + use bitcoin::secp256k1::{PublicKey, SecretKey}; + use lightning::ln::channelmanager::PaymentId; + use lightning::ln::inbound_payment::ExpandedKey; + use lightning::offers::nonce::Nonce; + use lightning::offers::offer::{OfferBuilder, Quantity}; + + use super::*; + + struct FixedEntropy; + + impl EntropySource for FixedEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } + + fn recipient_pubkey() -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[43; 32]).unwrap()) + } + + #[test] + fn fixed_offer_request_uses_quantity_resolved_amount() { + let expanded_key = ExpandedKey::new([44; 32]); + let nonce = Nonce::from_entropy_source(&FixedEntropy); + let secp_ctx = Secp256k1::new(); + let offer = OfferBuilder::new(recipient_pubkey()) + .amount_msats(1_000) + .supported_quantity(Quantity::Bounded(NonZeroU64::new(10).unwrap())) + .build() + .unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, PaymentId([45; 32])) + .unwrap() + .quantity(3) + .unwrap() + .build_and_sign() + .unwrap(); + + assert_eq!( + jit_invoice_request_from_fields(offer.amount(), invoice_request.amount_msats(), None,) + .unwrap(), + JitInvoiceRequest::Fixed { amount_msat: 3_000, absolute_expiry: None } + ); + } + + #[test] + fn variable_offer_request_disables_mpp() { + let expanded_key = ExpandedKey::new([46; 32]); + let nonce = Nonce::from_entropy_source(&FixedEntropy); + let secp_ctx = Secp256k1::new(); + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, PaymentId([47; 32])) + .unwrap() + .amount_msats(2_500) + .unwrap() + .build_and_sign() + .unwrap(); + let request = + jit_invoice_request_from_fields(offer.amount(), invoice_request.amount_msats(), None) + .unwrap(); + + assert_eq!( + request, + JitInvoiceRequest::Variable { amount_msat: 2_500, absolute_expiry: None } + ); + assert!(!request.allow_mpp()); + } +} From 38a4b299df05e77325bbb1d260aeb347ac121ee0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 13:28:49 +0200 Subject: [PATCH 22/42] f Register only completed invoice targets Cache targets drive persistent refill work and must reflect requests that produced usable invoices. Committing before lease validation let failed requests displace legitimate targets. Keep persistence best-effort so cache failures do not reject invoices. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 11 +++--- src/payment/bolt12/offers.rs | 57 ++++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index a61cf2bf80..23e33ee1f3 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -143,17 +143,18 @@ where self.cache_target_store.targets() } - pub(crate) async fn prepare_invoice_response( - self: Arc, request: JitInvoiceRequest, connection_manager: Arc>, - ) -> Result { - // Cache targets are only an optimization. Recording an older, evicted offer again makes it - // eligible for startup pre-caching, but a persistence failure must not make the current + 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 { let allow_mpp = request.allow_mpp(); let (lease, fee_parameters) = match request { JitInvoiceRequest::Fixed { amount_msat, .. } => { diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index e233442d7d..a7e40d3057 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -6,6 +6,7 @@ // accordance with one or both of these licenses. use std::collections::BTreeMap; +use std::future::Future; use std::sync::{Arc, OnceLock, RwLock, Weak}; use bitcoin::block::Header; @@ -52,6 +53,19 @@ impl InvoiceBuildError { } } +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>>, @@ -323,14 +337,15 @@ impl OffersMessageHandler for NodeOffersMessageHandler { let secp_ctx = Arc::clone(&self.secp_ctx); let logger = Arc::clone(&self.logger); dependencies.runtime.spawn_cancellable_background_task(async move { - let response = - lsps2_client.prepare_invoice_response(jit_request, connection_manager).await; + let response = Arc::clone(&lsps2_client) + .prepare_invoice_response(jit_request, connection_manager) + .await; let (message, instructions) = match response { Ok(JitInvoiceResponse { payment_metadata: jit_metadata, allow_mpp }) => { debug_assert_eq!(allow_mpp, jit_request.allow_mpp()); let mut merged_metadata = payment_metadata.unwrap_or_default(); merged_metadata.extend(jit_metadata); - match build_invoice( + let invoice_result = build_invoice( flow.as_ref(), channel_manager.as_ref(), keys_manager.as_ref(), @@ -340,7 +355,13 @@ impl OffersMessageHandler for NodeOffersMessageHandler { Some(merged_metadata), allow_mpp, payment_info.as_ref(), - ) { + ); + 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), @@ -406,6 +427,7 @@ impl Listen for NodeOffersMessageHandler { #[cfg(test)] mod tests { + use core::sync::atomic::{AtomicBool, Ordering}; use std::num::NonZeroU64; use bitcoin::secp256k1::{PublicKey, SecretKey}; @@ -428,6 +450,33 @@ mod tests { 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]); From db23c326a4d4a74c93c9704b09fcbff98bf5f4ce Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 15:04:33 +0200 Subject: [PATCH 23/42] 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 | 100 ++++++++++++++++++++++++++++++++++- 1 file changed, 98 insertions(+), 2 deletions(-) diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index a7e40d3057..3cd8cae268 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,12 +5,13 @@ // 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, HashSet}; use std::future::Future; -use std::sync::{Arc, OnceLock, RwLock, Weak}; +use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak}; use bitcoin::block::Header; use bitcoin::constants::ChainHash; +use bitcoin::hashes::{sha256, Hash}; use bitcoin::secp256k1::Secp256k1; use bitcoin::{Block, BlockHash, Network, Txid}; use lightning::blinded_path::message::{MessageContext, OffersContext}; @@ -29,6 +30,7 @@ use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; use lightning::sign::{EntropySource, NodeSigner, Recipient}; use lightning::types::payment::{PaymentHash, PaymentSecret}; use lightning::util::logger::Logger as _; +use lightning::util::ser::Writeable; use crate::connection::ConnectionManager; use crate::liquidity::client::lsps2::{JitInvoiceRequest, JitInvoiceResponse, LSPS2Client}; @@ -66,6 +68,50 @@ where result } +const MAX_PENDING_JIT_INVOICE_REQUESTS: usize = 100; + +struct PendingJitInvoiceRequests { + in_flight: Arc>>, + limit: usize, +} + +impl PendingJitInvoiceRequests { + fn new(limit: usize) -> Self { + Self { in_flight: Arc::new(Mutex::new(HashSet::new())), limit } + } + + fn try_acquire( + &self, request_id: sha256::Hash, + ) -> Result { + let mut in_flight = self.in_flight.lock().expect("lock"); + if in_flight.contains(&request_id) { + return Err(PendingJitInvoiceRequestError::AlreadyPending); + } + if in_flight.len() == self.limit { + return Err(PendingJitInvoiceRequestError::AtCapacity); + } + in_flight.insert(request_id); + Ok(PendingJitInvoiceRequest { request_id, in_flight: Arc::clone(&self.in_flight) }) + } +} + +#[derive(Debug, PartialEq, Eq)] +enum PendingJitInvoiceRequestError { + AlreadyPending, + AtCapacity, +} + +struct PendingJitInvoiceRequest { + request_id: sha256::Hash, + in_flight: Arc>>, +} + +impl Drop for PendingJitInvoiceRequest { + fn drop(&mut self) { + self.in_flight.lock().expect("lock").remove(&self.request_id); + } +} + struct JitInvoiceRequestDependencies { runtime: Arc, lsps2_client: Arc>>, @@ -82,6 +128,7 @@ pub(crate) struct NodeOffersMessageHandler { secp_ctx: Arc>, best_block: RwLock, jit_dependencies: OnceLock, + pending_jit_invoice_requests: PendingJitInvoiceRequests, logger: Arc, } @@ -113,6 +160,9 @@ impl NodeOffersMessageHandler { secp_ctx: Arc::new(secp_ctx), best_block: RwLock::new(best_block), jit_dependencies: OnceLock::new(), + pending_jit_invoice_requests: PendingJitInvoiceRequests::new( + MAX_PENDING_JIT_INVOICE_REQUESTS, + ), logger, } } @@ -237,6 +287,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() { @@ -306,6 +357,19 @@ impl OffersMessageHandler for NodeOffersMessageHandler { )); }, }; + let pending_request = + match self.pending_jit_invoice_requests.try_acquire(invoice_request_id) { + Ok(pending_request) => pending_request, + Err(PendingJitInvoiceRequestError::AlreadyPending) => return None, + Err(PendingJitInvoiceRequestError::AtCapacity) => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "Too many pending JIT invoice requests".to_owned(), + )), + responder.respond(), + )); + }, + }; let connection_manager = match dependencies.connection_manager.upgrade() { Some(connection_manager) => connection_manager, None => { @@ -337,6 +401,7 @@ impl OffersMessageHandler for NodeOffersMessageHandler { let secp_ctx = Arc::clone(&self.secp_ctx); let logger = Arc::clone(&self.logger); dependencies.runtime.spawn_cancellable_background_task(async move { + let _pending_request = pending_request; let response = Arc::clone(&lsps2_client) .prepare_invoice_response(jit_request, connection_manager) .await; @@ -525,4 +590,35 @@ mod tests { ); assert!(!request.allow_mpp()); } + + #[test] + fn pending_jit_invoice_requests_are_bounded() { + let pending_requests = PendingJitInvoiceRequests::new(2); + let first = pending_requests.try_acquire(sha256::Hash::from_byte_array([1; 32])).unwrap(); + let _second = pending_requests.try_acquire(sha256::Hash::from_byte_array([2; 32])).unwrap(); + + assert!(matches!( + pending_requests.try_acquire(sha256::Hash::from_byte_array([3; 32])), + Err(PendingJitInvoiceRequestError::AtCapacity) + )); + + drop(first); + assert!(pending_requests.try_acquire(sha256::Hash::from_byte_array([3; 32])).is_ok()); + } + + #[test] + fn pending_jit_invoice_requests_reject_replays() { + let pending_requests = PendingJitInvoiceRequests::new(2); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let _first = pending_requests.try_acquire(request_id).unwrap(); + + assert!( + matches!( + pending_requests.try_acquire(request_id), + Err(PendingJitInvoiceRequestError::AlreadyPending) + ), + "an in-flight invoice request replay was accepted" + ); + assert!(pending_requests.try_acquire(sha256::Hash::from_byte_array([2; 32])).is_ok()); + } } From 941d8938fd97a31c653e2a7c37fa91824036a96a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:39:17 +0200 Subject: [PATCH 24/42] lsps2: Extend opening parameter validity Keep built-in service parameters usable beyond the client cache safety margin so freshly negotiated leases are not rejected due to timing. Co-Authored-By: HAL 9000 --- src/liquidity/service/lsps2.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index 946511c5d9..4732e6f92c 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -26,7 +26,7 @@ use crate::logger::{log_error, LdkLogger}; use crate::types::{ChannelManager, KeysManager, LiquidityManager, PeerManager, Wallet}; use crate::{total_anchor_channels_reserve_sats, Config}; -const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); +const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 48); const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; pub(crate) struct LSPS2Service { From 1ec667e9d20710a3dc99d7c073f1f952dad369a5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:43:24 +0200 Subject: [PATCH 25/42] bolt12: Validate LSPS2 opening fees Read fee limits from BOLT12 payment context metadata and reject unsupported withholding. Record accepted fees on inbound offer payments. Co-Authored-By: HAL 9000 --- src/event.rs | 130 +++++++++++++++++++++++++++++--- src/payment/bolt12/mod.rs | 4 + src/payment/metadata.rs | 15 ++++ src/payment/store.rs | 57 ++++++++++++-- tests/integration_tests_rust.rs | 4 + 5 files changed, 190 insertions(+), 20 deletions(-) diff --git a/src/event.rs b/src/event.rs index d31aaaa783..634441ae05 100644 --- a/src/event.rs +++ b/src/event.rs @@ -7,7 +7,7 @@ use core::future::Future; use core::task::{Poll, Waker}; -use std::collections::VecDeque; +use std::collections::{BTreeMap, VecDeque}; use std::ops::Deref; use std::sync::{Arc, Mutex}; @@ -680,12 +680,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)) @@ -904,15 +932,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, ); @@ -934,7 +975,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) @@ -947,7 +988,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."), } } } @@ -1130,6 +1171,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, @@ -1332,6 +1375,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, @@ -2289,7 +2333,8 @@ mod tests { assert_eq!( EventHandler::>::lsps2_max_total_opening_fee_msat( &metadata.encode(), - 100_000 + 100_000, + 0, ), Some(42_000) ); @@ -2308,12 +2353,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 = @@ -2330,23 +2396,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 914cd5f4dc..a2b034eace 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, 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 38440a460e..b50d8c76dd 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -181,11 +181,6 @@ impl StorableObject for PaymentDetails { if let Some(hash_opt) = update.hash { match self.kind { PaymentKind::Bolt12Offer { ref mut hash, .. } => { - debug_assert_eq!( - self.direction, - PaymentDirection::Outbound, - "We should only ever override payment hash for outbound BOLT 12 payments" - ); debug_assert!( hash.is_none() || *hash == hash_opt, "We should never change a payment hash after being initially set" @@ -269,12 +264,13 @@ impl StorableObject for PaymentDetails { if let Some(skimmed_fee_msat) = update.counterparty_skimmed_fee_msat { match self.kind { - PaymentKind::Bolt11 { ref mut counterparty_skimmed_fee_msat, .. } => { + PaymentKind::Bolt11 { ref mut counterparty_skimmed_fee_msat, .. } + | PaymentKind::Bolt12Offer { ref mut counterparty_skimmed_fee_msat, .. } => { update_if_necessary!(*counterparty_skimmed_fee_msat, skimmed_fee_msat); }, _ => debug_assert!( false, - "We should only ever override counterparty_skimmed_fee_msat for BOLT11 payments" + "We should only override counterparty_skimmed_fee_msat for invoice payments" ), } } @@ -561,6 +557,17 @@ pub enum PaymentKind { preimage: Option, /// The secret used by the payment. secret: Option, + /// The value, in thousands of a satoshi, that was deducted from this payment as an extra + /// fee taken by our channel counterparty. + /// + /// Will only ever be `Some` for inbound payments received via an [bLIP-52 / LSPS 2] + /// just-in-time channel, and only after the payment is observed; `None` otherwise. + /// + /// This will always be `None` for payments serialized by versions that did not record + /// BOLT12 LSPS2 fees. + /// + /// [bLIP-52 / LSPS 2]: https://github.com/lightning/blips/blob/master/blip-0052.md + counterparty_skimmed_fee_msat: Option, /// The ID of the offer this payment is for. offer_id: OfferId, /// The payer note for the payment. @@ -640,6 +647,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) => { @@ -782,7 +790,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, @@ -1436,6 +1445,38 @@ mod tests { )); } + #[test] + fn bolt12_offer_records_counterparty_skimmed_fee() { + let payment_id = PaymentId([41; 32]); + let mut payment = PaymentDetails::new( + payment_id, + PaymentKind::Bolt12Offer { + hash: Some(PaymentHash([42; 32])), + preimage: None, + secret: None, + counterparty_skimmed_fee_msat: None, + offer_id: OfferId([43; 32]), + payer_note: None, + quantity: None, + }, + Some(100_000), + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + + assert!(payment.update(PaymentDetailsUpdate { + counterparty_skimmed_fee_msat: Some(Some(1_000)), + ..PaymentDetailsUpdate::new(payment_id) + })); + assert!(matches!( + payment.kind, + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat: Some(1_000), .. } + )); + let encoded = payment.encode(); + assert_eq!(payment, PaymentDetails::read(&mut &*encoded).unwrap()); + } + #[derive(Clone, Debug, PartialEq, Eq)] struct LegacyBolt11JitKind { hash: PaymentHash, diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 1184a2b4c6..9d2e360139 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2835,12 +2835,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); @@ -2902,12 +2904,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 5e4e85083cdebdc466ab7bb0f4d2963ac1d5a135 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 11:25:51 +0200 Subject: [PATCH 26/42] f Allow inbound BOLT12 refunds to fail Inbound refunds can be rejected before settlement just like inbound offer payments, so updating their terminal status must not assert. Co-Authored-By: HAL 9000 --- src/payment/store.rs | 63 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/payment/store.rs b/src/payment/store.rs index b50d8c76dd..eb7c99d934 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -188,11 +188,6 @@ impl StorableObject 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" @@ -1445,6 +1440,64 @@ 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]); From 4c608b05e488e8234f138cc652f87500e9472c60 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 11:26:51 +0200 Subject: [PATCH 27/42] f Document BOLT12 JIT fee deductions Describe the amount received after an LSP fee and record the new payment-kind field in the pending release notes. Co-Authored-By: HAL 9000 --- CHANGELOG.md | 2 ++ src/payment/bolt12/mod.rs | 6 ++++-- src/payment/store.rs | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5e433019..75eb392036 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,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/payment/bolt12/mod.rs b/src/payment/bolt12/mod.rs index a2b034eace..43ea5354dd 100644 --- a/src/payment/bolt12/mod.rs +++ b/src/payment/bolt12/mod.rs @@ -540,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 { @@ -556,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/store.rs b/src/payment/store.rs index eb7c99d934..38f6441890 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -552,7 +552,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] From 051e4fcced5a75b845b51a0195a0054316d193cd Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:47:36 +0200 Subject: [PATCH 28/42] lsps2: Serialize lease negotiation Make same-amount and variable callers wait for one in-flight LSPS2 request, then recheck the shared cache before negotiating. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 23 ++++++++++++++- src/liquidity/client/lsps2/state.rs | 44 ++++++++++++++++++++++++++++- src/liquidity/mod.rs | 3 +- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 23e33ee1f3..3b75e18ec7 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -39,7 +39,7 @@ use crate::{Config, Error}; use self::router::LSPS2LeaseParameters; use self::state::{ now_secs, LSPS2LeaseState, LeaseCacheTarget, LeaseCacheTargetId, LeaseCacheTargetStore, - PaymentLease, PaymentLeaseId, PaymentLeaseStore, + LeaseRequestKey, PaymentLease, PaymentLeaseId, PaymentLeaseStore, PendingLeaseRequestState, }; async fn consume_after_persisted_removal( @@ -102,6 +102,7 @@ where pub(crate) lease_store: Arc>, pub(crate) lease_state: Mutex, pub(crate) cache_target_store: Arc>, + pub(crate) pending_lease_request_state: Mutex, pub(crate) channel_manager: Arc, pub(crate) keys_manager: Arc, pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, @@ -270,6 +271,17 @@ where { return Ok((lease, total_fee_msat, lsp, false)); } + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Fixed(amount_msat)); + let _request_guard = request_lock.lock().await; + if let Some((lease, total_fee_msat, lsp)) = + self.take_cached_fixed_lease(amount_msat).await? + { + return Ok((lease, total_fee_msat, lsp, false)); + } let all_offers = self.gather_lsps2_offers(connection_manager).await?; let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers @@ -336,6 +348,15 @@ where if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { return Ok((lease, proportional_fee, lsp, false)); } + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Variable); + let _request_guard = request_lock.lock().await; + if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { + return Ok((lease, proportional_fee, lsp, false)); + } let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 72274b4002..a49531983d 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use std::ops::Deref; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Weak}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; @@ -10,6 +10,7 @@ use lightning::util::ser::{Readable, Writeable}; use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; use lightning_liquidity::lsps2::utils::compute_opening_fee; +use tokio::sync::Mutex as AsyncMutex; use crate::data_store::{DataStore, StorableObject, StorableObjectId, StorableObjectUpdate}; use crate::hex_utils; @@ -263,6 +264,33 @@ fn merge_absolute_expiry(current: Option, new: Option) -> Option } } +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub(crate) enum LeaseRequestKey { + Fixed(u64), + Variable, +} + +#[derive(Default)] +pub(crate) struct PendingLeaseRequestState { + locks: HashMap>>, +} + +impl PendingLeaseRequestState { + pub(crate) fn request_lock(&mut self, key: LeaseRequestKey) -> Arc> { + self.prune(); + if let Some(lock) = self.locks.get(&key).and_then(Weak::upgrade) { + return lock; + } + let lock = Arc::new(AsyncMutex::new(())); + self.locks.insert(key, Arc::downgrade(&lock)); + lock + } + + pub(crate) fn prune(&mut self) { + self.locks.retain(|_, lock| lock.strong_count() > 0); + } +} + fn is_cache_target_expired(target: &LeaseCacheTarget, now: u64) -> bool { target.absolute_expiry.is_some_and(|expiry| expiry <= now) } @@ -701,4 +729,18 @@ mod tests { state.register(cache_target(id, None, 3)); assert_eq!(state.targets().first().unwrap().absolute_expiry, None); } + + #[test] + fn lease_requests_are_serialized_per_key() { + let mut state = PendingLeaseRequestState::default(); + + let fixed = state.request_lock(LeaseRequestKey::Fixed(1_000)); + let same_fixed = state.request_lock(LeaseRequestKey::Fixed(1_000)); + let other_fixed = state.request_lock(LeaseRequestKey::Fixed(2_000)); + let variable = state.request_lock(LeaseRequestKey::Variable); + + assert!(Arc::ptr_eq(&fixed, &same_fixed)); + assert!(!Arc::ptr_eq(&fixed, &other_fixed)); + assert!(!Arc::ptr_eq(&fixed, &variable)); + } } diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index f91f56a1c7..f919829640 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -38,7 +38,7 @@ use crate::io::{ use crate::liquidity::client::lsps1::LSPS1Client; use crate::liquidity::client::lsps2::state::{ is_lease_usable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, - LeaseCacheTargetStore, PaymentLeaseStore, + LeaseCacheTargetStore, PaymentLeaseStore, PendingLeaseRequestState, }; use crate::liquidity::client::lsps2::LSPS2Client; use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; @@ -361,6 +361,7 @@ where lease_store, lease_state: Mutex::new(lease_state), cache_target_store, + pending_lease_request_state: Mutex::new(PendingLeaseRequestState::default()), channel_manager: self.channel_manager.clone(), keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), From 4b7fbe1c09d145b5e3460c54a39640efce35f87c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:51:14 +0200 Subject: [PATCH 29/42] 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 | 103 ++++++++++++++++++++++++++-- src/liquidity/client/lsps2/state.rs | 37 ++++++++++ src/liquidity/mod.rs | 8 ++- 4 files changed, 142 insertions(+), 7 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index f1eca2b282..ec0d275030 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2161,6 +2161,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 3b75e18ec7..7513acbcc0 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -8,7 +8,7 @@ use std::collections::{BTreeMap, HashMap}; use std::future::Future; use std::ops::Deref; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock, Weak}; use std::time::Duration; use bitcoin::secp256k1::{PublicKey, Secp256k1}; @@ -33,6 +33,7 @@ use crate::liquidity::{ use crate::logger::{log_debug, log_error, log_info, LdkLogger}; use crate::payment::store::LSPS2Parameters; use crate::payment::PaymentMetadata; +use crate::runtime::Runtime; use crate::types::{ChannelManager, KeysManager, LiquidityManager}; use crate::{Config, Error}; @@ -107,6 +108,8 @@ where pub(crate) keys_manager: Arc, pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, pub(crate) liquidity_manager: Arc, + // Refill tasks are runtime-owned; keeping this weak avoids extending the runtime's lifetime. + pub(crate) runtime: Weak, pub(crate) config: Arc, pub(crate) logger: L, } @@ -269,6 +272,7 @@ where if let Some((lease, total_fee_msat, lsp)) = self.take_cached_fixed_lease(amount_msat).await? { + self.schedule_fixed_lease_refill(amount_msat, connection_manager); return Ok((lease, total_fee_msat, lsp, false)); } let request_lock = self @@ -280,9 +284,20 @@ where if let Some((lease, total_fee_msat, lsp)) = self.take_cached_fixed_lease(amount_msat).await? { + self.schedule_fixed_lease_refill(amount_msat, connection_manager); return Ok((lease, total_fee_msat, lsp, false)); } + let (negotiated_lease, min_total_fee_msat, cheapest_lsp) = + self.negotiate_fixed_lease(amount_msat, connection_manager).await?; + let lease = self.consume_lease(&negotiated_lease.id).await?; + self.schedule_fixed_lease_refill(amount_msat, connection_manager); + Ok((lease, min_total_fee_msat, cheapest_lsp, true)) + } + + async fn negotiate_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers .into_iter() @@ -338,14 +353,14 @@ where Some(&cheapest_lsp.node_id), ) .await?; - let lease = self.consume_lease(&negotiated_lease.id).await?; - Ok((lease, min_total_fee_msat, cheapest_lsp, true)) + Ok((negotiated_lease, min_total_fee_msat, cheapest_lsp)) } async fn acquire_variable_lease( self: &Arc, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { + self.schedule_variable_lease_refill(connection_manager); return Ok((lease, proportional_fee, lsp, false)); } let request_lock = self @@ -355,9 +370,20 @@ where .request_lock(LeaseRequestKey::Variable); let _request_guard = request_lock.lock().await; if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { + self.schedule_variable_lease_refill(connection_manager); return Ok((lease, proportional_fee, lsp, false)); } + let (negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp) = + self.negotiate_variable_lease(connection_manager).await?; + let lease = self.consume_lease(&negotiated_lease.id).await?; + self.schedule_variable_lease_refill(connection_manager); + Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true)) + } + + async fn negotiate_variable_lease( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; let (cheapest_lsp, min_prop_fee_ppm_msat, min_opening_params) = all_offers @@ -399,8 +425,75 @@ where let negotiated_lease = self .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) .await?; - let lease = self.consume_lease(&negotiated_lease.id).await?; - Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true)) + Ok((negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp)) + } + + fn schedule_fixed_lease_refill( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) { + let 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 { + if let Err(error) = client.cache_fixed_lease(amount_msat, &connection_manager).await { + log_warn!(client.logger, "Failed refilling LSPS2 payment lease: {}", error); + } + }); + } + + fn schedule_variable_lease_refill( + self: &Arc, connection_manager: &Arc>, + ) { + let 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 { + if let Err(error) = client.cache_variable_lease(&connection_manager).await { + log_warn!(client.logger, "Failed refilling LSPS2 payment lease: {}", error); + } + }); + } + + async fn cache_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(), Error> { + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Fixed(amount_msat)); + let _request_guard = request_lock.lock().await; + if self + .lease_state + .lock() + .expect("lock") + .has_fixed_amount(amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat) + { + return Ok(()); + } + self.negotiate_fixed_lease(amount_msat, connection_manager).await?; + Ok(()) + } + + async fn cache_variable_lease( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(), Error> { + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Variable); + let _request_guard = request_lock.lock().await; + if self + .lease_state + .lock() + .expect("lock") + .has_variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat) + { + return Ok(()); + } + self.negotiate_variable_lease(connection_manager).await?; + Ok(()) } async fn gather_lsps2_offers( diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index a49531983d..3aa1c3b679 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -409,6 +409,21 @@ impl LSPS2LeaseState { self.leases.remove(&id).map(|lease| (lease, fee_msat)) } + pub(crate) fn has_fixed_amount(&self, amount_msat: u64, max_fee_msat: Option) -> bool { + self.leases + .values() + .filter(|lease| lease.payment_size_msat == Some(amount_msat)) + .filter(|lease| is_lease_usable(lease)) + .filter_map(|lease| { + compute_opening_fee( + amount_msat, + lease.params.min_fee_msat, + lease.params.proportional as u64, + ) + }) + .any(|fee_msat| max_fee_msat.map_or(true, |max| fee_msat <= max)) + } + /// Selects and removes a variable-amount lease in one operation. pub(crate) fn variable_amount( &mut self, max_total_fee_msat: Option, available_lsps: &[PublicKey], @@ -427,6 +442,14 @@ impl LSPS2LeaseState { self.leases.remove(&id).map(|lease| (lease, proportional_fee)) } + pub(crate) fn has_variable_amount(&self, max_total_fee_msat: Option) -> bool { + self.leases + .values() + .filter(|lease| lease.payment_size_msat.is_none()) + .filter(|lease| is_lease_usable(lease)) + .any(|lease| max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max)) + } + pub(crate) fn prune(&mut self) { self.leases.retain(|_, lease| is_lease_usable(lease)); } @@ -624,6 +647,20 @@ mod tests { assert_eq!(state.variable_amount(Some(50), &available_lsps).unwrap().0.id, variable.id); } + #[test] + fn detects_cached_leases_for_refill() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let fixed = lease(2, 46, 100, Some(1_000), valid_until); + let variable = lease(3, 47, 50, None, valid_until); + let state = LSPS2LeaseState::from_leases(vec![fixed, variable]); + + assert!(state.has_fixed_amount(1_000, Some(100))); + assert!(!state.has_fixed_amount(1_000, Some(99))); + assert!(!state.has_fixed_amount(2_000, None)); + assert!(state.has_variable_amount(Some(50))); + assert!(!state.has_variable_amount(Some(49))); + } + fn cache_target( id: LeaseCacheTargetId, absolute_expiry: Option, last_accessed: u64, ) -> LeaseCacheTarget { diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index f919829640..e4b312b4bd 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -13,7 +13,7 @@ pub(crate) mod service; use std::collections::hash_map::Entry; use std::collections::HashMap; use std::ops::Deref; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock, Weak}; use std::time::Duration; use bitcoin::secp256k1::PublicKey; @@ -217,6 +217,7 @@ where keys_manager: Arc, tx_broadcaster: Arc, kv_store: Arc, + runtime: Weak, config: Arc, logger: L, } @@ -227,7 +228,8 @@ where { pub(crate) fn new( wallet: Arc, channel_manager: Arc, keys_manager: Arc, - tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: L, + tx_broadcaster: Arc, kv_store: Arc, runtime: Weak, + config: Arc, logger: L, ) -> Self { let lsp_nodes = Vec::new(); let lsps2_service = None; @@ -239,6 +241,7 @@ where keys_manager, tx_broadcaster, kv_store, + runtime, config, logger, } @@ -366,6 +369,7 @@ where keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), liquidity_manager: Arc::clone(&liquidity_manager), + runtime: self.runtime, config: self.config.clone(), logger: self.logger.clone(), }), From de61699456fa4971312744bca0a1c5081a82c824 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 11:28:50 +0200 Subject: [PATCH 30/42] f Ignore leases from unavailable LSPs A cached lease must not suppress refill after its LSP is removed from the configured set. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 17 ++++++----- src/liquidity/client/lsps2/state.rs | 47 ++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 7513acbcc0..d82726b2c2 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -463,12 +463,13 @@ where .expect("lock") .request_lock(LeaseRequestKey::Fixed(amount_msat)); let _request_guard = request_lock.lock().await; - if self - .lease_state - .lock() - .expect("lock") - .has_fixed_amount(amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat) - { + 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?; @@ -484,11 +485,13 @@ where .expect("lock") .request_lock(LeaseRequestKey::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) + .has_variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat, &available_lsps) { return Ok(()); } diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 3aa1c3b679..bbacbd5fa0 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -409,9 +409,12 @@ impl LSPS2LeaseState { self.leases.remove(&id).map(|lease| (lease, fee_msat)) } - pub(crate) fn has_fixed_amount(&self, amount_msat: u64, max_fee_msat: Option) -> bool { + 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| { @@ -442,9 +445,12 @@ impl LSPS2LeaseState { self.leases.remove(&id).map(|lease| (lease, proportional_fee)) } - pub(crate) fn has_variable_amount(&self, max_total_fee_msat: Option) -> bool { + 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)) @@ -652,13 +658,40 @@ mod tests { 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))); - assert!(!state.has_fixed_amount(1_000, Some(99))); - assert!(!state.has_fixed_amount(2_000, None)); - assert!(state.has_variable_amount(Some(50))); - assert!(!state.has_variable_amount(Some(49))); + assert!(state.has_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( From fa10fd3dbfc282c36118bb990564c745660bf938 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 13:34:06 +0200 Subject: [PATCH 31/42] f Refill only completed invoice targets Lease acquisition can still be followed by invoice construction failure. Starting a replacement before that outcome recreates the same uncommitted cache work avoided by deferred target registration. Activate refills only once BOLT11 or BOLT12 invoice construction succeeds. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 21 ++++++++++++++------- src/payment/bolt12/offers.rs | 9 +++++++-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index d82726b2c2..96b54ecb7c 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -147,13 +147,24 @@ 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); } + match request { + JitInvoiceRequest::Fixed { amount_msat, .. } => { + self.schedule_fixed_lease_refill(amount_msat, connection_manager); + }, + JitInvoiceRequest::Variable { .. } => { + self.schedule_variable_lease_refill(connection_manager); + }, + } } pub(crate) async fn prepare_invoice_response( @@ -233,6 +244,7 @@ where lsps2_parameters, Some(&lsp.node_id), )?; + self.schedule_fixed_lease_refill(amount_msat, &connection_manager); if was_negotiated { log_info!(self.logger, "JIT-channel invoice created: {}", invoice); @@ -259,6 +271,7 @@ where lsps2_parameters, Some(&lsp.node_id), )?; + self.schedule_variable_lease_refill(&connection_manager); if was_negotiated { log_info!(self.logger, "JIT-channel invoice created: {}", invoice); @@ -272,7 +285,6 @@ where if let Some((lease, total_fee_msat, lsp)) = self.take_cached_fixed_lease(amount_msat).await? { - self.schedule_fixed_lease_refill(amount_msat, connection_manager); return Ok((lease, total_fee_msat, lsp, false)); } let request_lock = self @@ -284,14 +296,12 @@ where if let Some((lease, total_fee_msat, lsp)) = self.take_cached_fixed_lease(amount_msat).await? { - self.schedule_fixed_lease_refill(amount_msat, connection_manager); return Ok((lease, total_fee_msat, lsp, false)); } let (negotiated_lease, min_total_fee_msat, cheapest_lsp) = self.negotiate_fixed_lease(amount_msat, connection_manager).await?; let lease = self.consume_lease(&negotiated_lease.id).await?; - self.schedule_fixed_lease_refill(amount_msat, connection_manager); Ok((lease, min_total_fee_msat, cheapest_lsp, true)) } @@ -360,7 +370,6 @@ where self: &Arc, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { - self.schedule_variable_lease_refill(connection_manager); return Ok((lease, proportional_fee, lsp, false)); } let request_lock = self @@ -370,14 +379,12 @@ where .request_lock(LeaseRequestKey::Variable); let _request_guard = request_lock.lock().await; if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { - self.schedule_variable_lease_refill(connection_manager); return Ok((lease, proportional_fee, lsp, false)); } let (negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp) = self.negotiate_variable_lease(connection_manager).await?; let lease = self.consume_lease(&negotiated_lease.id).await?; - self.schedule_variable_lease_refill(connection_manager); Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true)) } diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index 3cd8cae268..fb88cce932 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -403,7 +403,7 @@ impl OffersMessageHandler for NodeOffersMessageHandler { dependencies.runtime.spawn_cancellable_background_task(async move { let _pending_request = pending_request; 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, instructions) = match response { Ok(JitInvoiceResponse { payment_metadata: jit_metadata, allow_mpp }) => { @@ -423,7 +423,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 47835096a2b2deb8018dd35580d0d85516ad15cb Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 15:18:47 +0200 Subject: [PATCH 32/42] lsps2: Restore cached payment leases After startup discovery, refill persisted fixed-amount and variable lease cache targets. Reuse valid leases and renegotiate only missing or stale entries so receiving can resume promptly after restart. Co-Authored-By: HAL 9000 --- src/lib.rs | 11 +++++++++++ src/liquidity/client/lsps2/mod.rs | 17 +++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 21c3b2b218..07b909f1d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -815,6 +815,17 @@ impl Node { liquidity_logger, "LSP protocols discovery complete.", ); + if let Err(error) = liquidity_handler + .lsps2_client() + .refill_cached_leases(&discovery_cm) + .await + { + log_error!( + liquidity_logger, + "Failed scheduling LSPS2 lease cache refills: {}", + error + ); + } } } } diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 96b54ecb7c..17959781d8 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -147,6 +147,23 @@ where self.cache_target_store.targets() } + pub(crate) async fn refill_cached_leases( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(), Error> { + self.prune_stale_cache_targets().await?; + for target in self.cache_targets() { + match target.id { + LeaseCacheTargetId::Fixed { amount_msat } => { + self.schedule_fixed_lease_refill(amount_msat, connection_manager); + }, + LeaseCacheTargetId::Variable => { + self.schedule_variable_lease_refill(connection_manager); + }, + } + } + Ok(()) + } + pub(crate) async fn commit_invoice_cache_target( self: &Arc, request: JitInvoiceRequest, connection_manager: &Arc>, From b2ba5a7468a6caeaaca8a9884498127f95e8a0e3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:57:04 +0200 Subject: [PATCH 33/42] 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 17959781d8..b9c192e09e 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; @@ -62,12 +63,44 @@ where } const DEFAULT_BOLT12_INVOICE_EXPIRY_SECS: u32 = 2 * 60 * 60; +const LEASE_NEGOTIATION_MAX_ATTEMPTS: usize = 3; + +fn should_retry_lease_negotiation(error: Error, attempt: usize) -> bool { + error == Error::LiquidityRequestFailed && attempt < LEASE_NEGOTIATION_MAX_ATTEMPTS +} pub(crate) struct JitInvoiceResponse { pub(crate) payment_metadata: BTreeMap>, pub(crate) allow_mpp: bool, } +async fn try_lease_candidates( + candidates: Vec, candidate_key: KF, mut attempt: AF, +) -> Result +where + K: Copy + Eq + Hash, + KF: Fn(&T) -> K, + AF: FnMut(T) -> Fut, + Fut: Future>, +{ + let mut failed_candidates = HashSet::new(); + let mut last_error = None; + for candidate in candidates { + let key = candidate_key(&candidate); + if failed_candidates.contains(&key) { + continue; + } + match attempt(candidate).await { + Ok(result) => return Ok(result), + Err(error) => { + failed_candidates.insert(key); + last_error = Some(error); + }, + } + } + Err(last_error.expect("lease candidates are non-empty")) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum JitInvoiceRequest { Fixed { amount_msat: u64, absolute_expiry: Option }, @@ -324,9 +357,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 @@ -350,11 +404,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 { @@ -364,23 +419,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( @@ -407,10 +483,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)) @@ -427,29 +524,52 @@ where rejected_for_fee |= !allowed; allowed }) - .min_by_key(|(_, ppm, _)| *ppm) - .ok_or_else(|| { - if rejected_for_fee { - log_error!( - self.logger, - "Failed to request inbound JIT channel as all LSP offers exceed our configured fee limit" - ); - return Error::LiquidityFeeTooHigh; - } + .collect::>(); + candidates.sort_unstable_by_key(|(_, ppm, _)| *ppm); + if candidates.is_empty() { + return Err(if rejected_for_fee { + log_error!( + self.logger, + "Failed to request inbound JIT channel as all LSP offers exceed our configured fee limit" + ); + Error::LiquidityFeeTooHigh + } else { log_error!(self.logger, "Failed to handle response from liquidity service",); Error::LiquidityRequestFailed - })?; - log_debug!( - self.logger, - "Choosing cheapest liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees", - cheapest_lsp.node_id, - min_prop_fee_ppm_msat - ); + }); + } - let negotiated_lease = self - .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) - .await?; - Ok((negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp)) + try_lease_candidates( + candidates, + |(lsp, _, _)| lsp.node_id, + |(lsp, proportional_fee_ppm_msat, opening_params)| { + let client = Arc::clone(self); + async move { + log_debug!( + client.logger, + "Choosing liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees", + lsp.node_id, + proportional_fee_ppm_msat + ); + match client + .lsps2_send_buy_request(None, opening_params, Some(&lsp.node_id)) + .await + { + Ok(lease) => Ok((lease, proportional_fee_ppm_msat, lsp)), + Err(error) => { + log_warn!( + client.logger, + "Failed negotiating LSPS2 payment lease with LSP {}, trying the next candidate: {}", + lsp.node_id, + error + ); + Err(error) + }, + } + } + }, + ) + .await } fn schedule_fixed_lease_refill( @@ -997,6 +1117,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 5c27973098749e260a5a71f89a54c69ec76bfffe Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 15:46:02 +0200 Subject: [PATCH 34/42] 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 | 128 ++++++++++++++++------------ src/liquidity/client/lsps2/state.rs | 86 ++++++++++++++++--- 2 files changed, 146 insertions(+), 68 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index b9c192e09e..743141ed39 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -233,18 +233,17 @@ where }, ) }, - JitInvoiceRequest::Variable { .. } => { - let (lease, proportional_fee, _, _) = - self.acquire_variable_lease(&connection_manager).await?; + JitInvoiceRequest::Variable { amount_msat, .. } => { + // A BOLT12 invoice request has already resolved the payment amount. Use it to avoid + // consuming a cached variable lease outside its advertised range, and record the exact + // fee for that amount so payment validation need not accept the broader node-wide cap. + let (lease, total_fee_msat, _, _) = + self.acquire_variable_lease(Some(amount_msat), &connection_manager).await?; ( lease, LSPS2Parameters { - max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, - max_proportional_opening_fee_ppm_msat: self - .config - .lsps2_max_total_lsp_fee_limit_msat - .is_none() - .then_some(proportional_fee), + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, }, ) }, @@ -307,7 +306,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), @@ -460,10 +459,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 @@ -471,22 +470,22 @@ where .expect("lock") .request_lock(LeaseRequestKey::Variable); let _request_guard = request_lock.lock().await; - if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { - 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!( @@ -503,7 +502,7 @@ where } async fn negotiate_variable_lease_once( - self: &Arc, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; @@ -512,20 +511,34 @@ where .flat_map(|(lsp, resp)| { resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params)) }) - .map(|(lsp, params)| { - let ppm = params.proportional as u64; - (lsp, ppm, params) - }) - .filter(|(_, _, params)| { - let allowed = self + .filter_map(|(lsp, params)| { + // BOLT12 supplies a resolved amount here, while a BOLT11 zero-amount invoice does not. + // In the former case, only negotiate parameters that can carry that exact payment and + // compare providers by the total fee the payment would actually incur. + let selection_fee = if let Some(amount_msat) = amount_msat { + if amount_msat < params.min_payment_size_msat + || amount_msat > params.max_payment_size_msat + { + return None; + } + compute_opening_fee( + amount_msat, + params.min_fee_msat, + params.proportional as u64, + )? + } else { + params.proportional as u64 + }; + let fee_for_limit = amount_msat.map_or(params.min_fee_msat, |_| selection_fee); + let fee_allowed = self .config .lsps2_max_total_lsp_fee_limit_msat - .map_or(true, |limit| params.min_fee_msat <= limit); - rejected_for_fee |= !allowed; - allowed + .map_or(true, |limit| fee_for_limit <= limit); + rejected_for_fee |= !fee_allowed; + fee_allowed.then_some((lsp, selection_fee, params)) }) .collect::>(); - candidates.sort_unstable_by_key(|(_, ppm, _)| *ppm); + candidates.sort_unstable_by_key(|(_, fee, _)| *fee); if candidates.is_empty() { return Err(if rejected_for_fee { log_error!( @@ -542,20 +555,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, @@ -631,15 +653,14 @@ where 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) - { + if self.lease_state.lock().expect("lock").has_variable_amount( + None, + self.config.lsps2_max_total_lsp_fee_limit_msat, + &available_lsps, + ) { return Ok(()); } - self.negotiate_variable_lease(connection_manager).await?; + self.negotiate_variable_lease(None, connection_manager).await?; Ok(()) } @@ -848,16 +869,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 @@ -865,7 +885,7 @@ where .find(|lsp| lsp.node_id == lease.id.lsp_node_id) .expect("lease was selected from available LSPs"); let lease = self.consume_selected_lease(lease).await?; - Ok(Some((lease, proportional_fee, lsp))) + Ok(Some((lease, fee, lsp))) } fn lsps2_create_jit_invoice( diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index bbacbd5fa0..fba6b82d54 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -429,31 +429,66 @@ impl LSPS2LeaseState { /// Selects and removes a variable-amount lease in one operation. pub(crate) fn variable_amount( - &mut self, max_total_fee_msat: Option, available_lsps: &[PublicKey], + &mut self, amount_msat: Option, max_total_fee_msat: Option, + available_lsps: &[PublicKey], ) -> Option<(PaymentLease, u64)> { - let (id, proportional_fee) = self + let (id, selection_fee) = self .leases .iter() .filter(|(id, _)| available_lsps.contains(&id.lsp_node_id)) .filter(|(_, lease)| lease.payment_size_msat.is_none()) .filter(|(_, lease)| is_lease_usable(lease)) - .filter(|(_, lease)| { - max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max) + .filter_map(|(id, lease)| { + let selection_fee = if let Some(amount_msat) = amount_msat { + if amount_msat < lease.params.min_payment_size_msat + || amount_msat > lease.params.max_payment_size_msat + { + return None; + } + compute_opening_fee( + amount_msat, + lease.params.min_fee_msat, + lease.params.proportional as u64, + )? + } else { + lease.params.proportional as u64 + }; + let fee_for_limit = + amount_msat.map_or(lease.params.min_fee_msat, |_| selection_fee); + max_total_fee_msat + .map_or(true, |max| fee_for_limit <= max) + .then_some((*id, selection_fee)) }) - .map(|(id, lease)| (*id, lease.params.proportional as u64)) .min_by_key(|(_, fee)| *fee)?; - self.leases.remove(&id).map(|lease| (lease, proportional_fee)) + self.leases.remove(&id).map(|lease| (lease, selection_fee)) } pub(crate) fn has_variable_amount( - &self, max_total_fee_msat: Option, available_lsps: &[PublicKey], + &self, amount_msat: Option, 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)) + .filter_map(|lease| { + if let Some(amount_msat) = amount_msat { + if amount_msat < lease.params.min_payment_size_msat + || amount_msat > lease.params.max_payment_size_msat + { + return None; + } + compute_opening_fee( + amount_msat, + lease.params.min_fee_msat, + lease.params.proportional as u64, + ) + } else { + Some(lease.params.min_fee_msat) + } + }) + .any(|fee_msat| max_total_fee_msat.map_or(true, |max| fee_msat <= max)) } pub(crate) fn prune(&mut self) { @@ -647,10 +682,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] @@ -664,8 +722,8 @@ mod tests { 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)); + assert!(state.has_variable_amount(None, Some(50), &available_lsps)); + assert!(!state.has_variable_amount(None, Some(49), &available_lsps)); } #[test] @@ -689,7 +747,7 @@ mod tests { let state = LSPS2LeaseState::from_leases(vec![unavailable]); assert!( - !state.has_variable_amount(Some(50), &[available_lsp]), + !state.has_variable_amount(None, Some(50), &[available_lsp]), "a lease from an unavailable LSP suppressed variable-amount refill" ); } From af176f78398d5fd9d31f65fe7ba5c6ac46878631 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 10:02:19 +0200 Subject: [PATCH 35/42] 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 | 178 ++++++++++++++++++++-------- src/liquidity/client/lsps2/state.rs | 142 ++++++++++++++-------- src/liquidity/mod.rs | 6 +- 3 files changed, 227 insertions(+), 99 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 743141ed39..c3731f4e89 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -40,8 +40,9 @@ use crate::{Config, Error}; use self::router::LSPS2LeaseParameters; use self::state::{ - now_secs, LSPS2LeaseState, LeaseCacheTarget, LeaseCacheTargetId, LeaseCacheTargetStore, - LeaseRequestKey, PaymentLease, PaymentLeaseId, PaymentLeaseStore, PendingLeaseRequestState, + is_lease_cacheable, is_lease_valid_for, now_secs, LSPS2LeaseState, LeaseCacheTarget, + LeaseCacheTargetId, LeaseCacheTargetStore, LeaseRequestKey, PaymentLease, PaymentLeaseId, + PaymentLeaseStore, PendingLeaseRequestState, MIN_CACHED_LEASE_REMAINING_SECS, }; async fn consume_after_persisted_removal( @@ -69,6 +70,14 @@ fn should_retry_lease_negotiation(error: Error, attempt: usize) -> bool { error == Error::LiquidityRequestFailed && attempt < LEASE_NEGOTIATION_MAX_ATTEMPTS } +fn opening_fee_params_valid_for(params: &LSPS2OpeningFeeParams, min_remaining_secs: u64) -> bool { + let Ok(valid_until) = u64::try_from(params.valid_until.0.timestamp()) else { + return false; + }; + let now = now_secs(); + valid_until > now && valid_until.saturating_sub(now) >= min_remaining_secs +} + pub(crate) struct JitInvoiceResponse { pub(crate) payment_metadata: BTreeMap>, pub(crate) allow_mpp: bool, @@ -154,7 +163,7 @@ where pub(crate) async fn prune_stale_leases(&self) -> Result<(), Error> { let stale_ids = self .lease_store - .list_filter(|lease| !state::is_lease_usable(lease)) + .list_filter(|lease| !is_lease_cacheable(lease)) .into_iter() .map(|lease| lease.id) .collect::>(); @@ -223,8 +232,13 @@ where let allow_mpp = request.allow_mpp(); let (lease, fee_parameters) = match request { JitInvoiceRequest::Fixed { amount_msat, .. } => { - let (lease, total_fee_msat, _, _) = - self.acquire_fixed_lease(amount_msat, &connection_manager).await?; + let (lease, total_fee_msat, _, _) = self + .acquire_fixed_lease( + amount_msat, + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, + &connection_manager, + ) + .await?; ( lease, LSPS2Parameters { @@ -237,8 +251,13 @@ where // A BOLT12 invoice request has already resolved the payment amount. Use it to avoid // consuming a cached variable lease outside its advertised range, and record the exact // fee for that amount so payment validation need not accept the broader node-wide cap. - let (lease, total_fee_msat, _, _) = - self.acquire_variable_lease(Some(amount_msat), &connection_manager).await?; + let (lease, total_fee_msat, _, _) = self + .acquire_variable_lease( + Some(amount_msat), + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, + &connection_manager, + ) + .await?; ( lease, LSPS2Parameters { @@ -251,8 +270,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 { @@ -278,7 +296,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, @@ -306,7 +324,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), @@ -329,10 +347,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)); } @@ -343,23 +362,25 @@ where .request_lock(LeaseRequestKey::Fixed(amount_msat)); let _request_guard = request_lock.lock().await; if let Some((lease, total_fee_msat, lsp)) = - self.take_cached_fixed_lease(amount_msat).await? + self.take_cached_fixed_lease(amount_msat, min_remaining_secs).await? { 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!( @@ -376,7 +397,8 @@ where } async fn negotiate_fixed_lease_once( - self: &Arc, amount_msat: u64, connection_manager: &Arc>, + self: &Arc, amount_msat: u64, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut candidates = all_offers @@ -387,6 +409,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 { @@ -438,6 +468,7 @@ where Some(amount_msat), opening_params, Some(&lsp.node_id), + min_remaining_secs, ) .await { @@ -459,9 +490,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 @@ -470,22 +504,27 @@ where .expect("lock") .request_lock(LeaseRequestKey::Variable); let _request_guard = request_lock.lock().await; - if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? { + if let Some((lease, fee, lsp)) = + self.take_cached_variable_lease(amount_msat, min_remaining_secs).await? + { 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!( @@ -502,7 +541,8 @@ where } async fn negotiate_variable_lease_once( - self: &Arc, amount_msat: Option, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; @@ -512,6 +552,14 @@ where resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params)) }) .filter_map(|(lsp, params)| { + if !opening_fee_params_valid_for(¶ms, min_remaining_secs) { + log_debug!( + self.logger, + "Skipping LSP {}'s JIT offer as it expires before the requested invoice", + lsp.node_id + ); + return None; + } // BOLT12 supplies a resolved amount here, while a BOLT11 zero-amount invoice does not. // In the former case, only negotiate parameters that can carry that exact payment and // compare providers by the total fee the payment would actually incur. @@ -574,7 +622,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)), @@ -638,8 +691,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 +715,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(()) } @@ -752,7 +817,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)?; @@ -818,21 +883,16 @@ where payment_size_msat: amount_msat, valid_until, }; - self.lease_store.insert(lease.clone()).await?; - self.lease_state.lock().expect("lock").insert(lease.clone()); + if !is_lease_valid_for(&lease, min_remaining_secs) { + log_warn!( + self.logger, + "LSPS2 payment lease expired before it could cover the requested invoice" + ); + return Err(Error::LiquidityRequestFailed); + } Ok(lease) } - async fn consume_lease(&self, id: &PaymentLeaseId) -> Result { - let lease = self - .lease_state - .lock() - .expect("lock") - .take_valid(id) - .ok_or(Error::LiquidityRequestFailed)?; - self.consume_selected_lease(lease).await - } - async fn consume_selected_lease(&self, lease: PaymentLease) -> Result { let lease_store = Arc::clone(&self.lease_store); // Selection has already removed the lease from the shared state so no other payment can @@ -849,13 +909,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); @@ -869,13 +931,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); @@ -1147,6 +1211,24 @@ mod tests { assert!(!should_retry_lease_negotiation(Error::LiquiditySourceUnavailable, 1)); } + #[test] + fn opening_params_must_cover_invoice_lifetime() { + let valid_until = chrono::Utc::now() + chrono::Duration::hours(3); + let params = LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: valid_until.to_rfc3339().parse().unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 2_000, + promise: String::new(), + }; + + assert!(opening_fee_params_valid_for(¶ms, 2 * 60 * 60)); + assert!(!opening_fee_params_valid_for(¶ms, 4 * 60 * 60)); + } + #[tokio::test] async fn lease_negotiation_fails_over_between_lsps() { let candidates = vec![(1, 10), (1, 20), (2, 30)]; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index fba6b82d54..e3e9de6eac 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -22,7 +22,7 @@ use crate::logger::{log_error, LdkLogger}; use crate::types::DynStore; use crate::Error; -pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; +pub(crate) const MIN_CACHED_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; const BOLT12_FINAL_CLTV_EXPIRY_DELTA: u16 = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; pub(crate) type PaymentLeaseStore = DataStore; @@ -378,24 +378,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, @@ -416,7 +409,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, @@ -430,14 +423,14 @@ impl LSPS2LeaseState { /// Selects and removes a variable-amount lease in one operation. pub(crate) fn variable_amount( &mut self, amount_msat: Option, max_total_fee_msat: Option, - available_lsps: &[PublicKey], + min_remaining_secs: u64, available_lsps: &[PublicKey], ) -> Option<(PaymentLease, u64)> { let (id, selection_fee) = self .leases .iter() .filter(|(id, _)| available_lsps.contains(&id.lsp_node_id)) .filter(|(_, lease)| lease.payment_size_msat.is_none()) - .filter(|(_, lease)| is_lease_usable(lease)) + .filter(|(_, lease)| is_lease_valid_for(lease, min_remaining_secs)) .filter_map(|(id, lease)| { let selection_fee = if let Some(amount_msat) = amount_msat { if amount_msat < lease.params.min_payment_size_msat @@ -471,7 +464,7 @@ 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)) .filter_map(|lease| { if let Some(amount_msat) = amount_msat { if amount_msat < lease.params.min_payment_size_msat @@ -492,13 +485,12 @@ impl LSPS2LeaseState { } pub(crate) fn prune(&mut self) { - self.leases.retain(|_, lease| is_lease_usable(lease)); + self.leases.retain(|_, lease| is_lease_cacheable(lease)); } } pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { - checked_cltv_expiry_delta(lease.cltv_expiry_delta).is_some() - && lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS + checked_cltv_expiry_delta(lease.cltv_expiry_delta).is_some() && lease.valid_until > now_secs() } pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { @@ -507,6 +499,14 @@ pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { .filter(|delta| *delta <= u16::MAX - BOLT12_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() } @@ -597,53 +597,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!( @@ -654,45 +701,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); @@ -700,10 +747,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" @@ -713,7 +759,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]; @@ -728,7 +774,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]); @@ -741,7 +787,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 e4b312b4bd..d510f595a2 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -37,7 +37,7 @@ use crate::io::{ }; use crate::liquidity::client::lsps1::LSPS1Client; use crate::liquidity::client::lsps2::state::{ - is_lease_usable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, + is_lease_cacheable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, LeaseCacheTargetStore, PaymentLeaseStore, PendingLeaseRequestState, }; use crate::liquidity::client::lsps2::LSPS2Client; @@ -279,11 +279,11 @@ where Arc::clone(&self.kv_store), self.logger.clone(), )); - for lease in leases.iter().filter(|lease| !is_lease_usable(lease)) { + for lease in leases.iter().filter(|lease| !is_lease_cacheable(lease)) { lease_store.remove(&lease.id).await.map_err(|_| BuildError::WriteFailed)?; } let lease_state = - LSPS2LeaseState::from_leases(leases.into_iter().filter(is_lease_usable).collect()); + LSPS2LeaseState::from_leases(leases.into_iter().filter(is_lease_cacheable).collect()); let (cache_target_state, removed_target_ids) = LeaseCacheTargetState::from_targets( cache_targets, LSPS2_LEASE_CACHE_TARGET_SIZE, From 43a2ee51a31926ac4d705e3093bb44c8f1f1b156 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 11:30:54 +0200 Subject: [PATCH 36/42] f Document JIT invoice validity requirement Callers need to know that long invoice expiries require an LSP offer whose validity covers the requested lifetime. Co-Authored-By: HAL 9000 --- src/payment/bolt11.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index c0c93706c3..2b613c404e 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -582,6 +582,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 @@ -601,6 +604,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 @@ -645,6 +651,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 @@ -664,6 +673,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 5b3189cae04d0321866647a69f00744a7d799167 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:12:34 +0200 Subject: [PATCH 37/42] 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 9d2e360139..ebbdae61e9 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3650,6 +3650,163 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { assert_eq!(client_node.payment(&client_payment_id).unwrap().status, PaymentStatus::Failed); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn bolt12_lsps2_client_service_integration() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + + let mut sync_config = EsploraSyncConfig::default(); + sync_config.background_sync_config = None; + + let channel_opening_fee_ppm = 10_000; + let lsps2_service_config = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm, + channel_over_provisioning_ppm: 100_000, + max_payment_size_msat: 1_000_000_000, + min_payment_size_msat: 0, + min_channel_lifetime: 100, + min_channel_opening_fee_msat: 0, + max_client_to_self_delay: 1024, + client_trusts_lsp: true, + disable_client_reserve: false, + }; + + let service_config = random_config(); + setup_builder!(service_builder, service_config.node_config); + service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + service_builder.enable_liquidity_provider(lsps2_service_config); + let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); + service_node.start().unwrap(); + + let service_node_id = service_node.node_id(); + let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); + + let client_config = random_config(); + setup_builder!(client_builder, client_config.node_config); + client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + client_builder.add_liquidity_source(service_node_id, service_addr, None, true); + let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); + client_node.start().unwrap(); + + let payer_config = random_config(); + setup_builder!(payer_builder, payer_config.node_config); + payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); + payer_node.start().unwrap(); + + let client_addr = client_node.listening_addresses().unwrap().first().unwrap().clone(); + payer_node.connect(client_node.node_id(), client_addr, false).unwrap(); + + let service_onchain_addr = service_node.onchain_payment().new_address().unwrap(); + let client_onchain_addr = client_node.onchain_payment().new_address().unwrap(); + let payer_onchain_addr = payer_node.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![service_onchain_addr, client_onchain_addr, payer_onchain_addr], + Amount::from_sat(10_000_000), + ) + .await; + service_node.sync_wallets().unwrap(); + client_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + + open_channel(&payer_node, &service_node, 5_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + service_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + expect_channel_ready_event!(payer_node, service_node.node_id()); + expect_channel_ready_event!(service_node, payer_node.node_id()); + while payer_node.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let fixed_amount_msat = 100_000_000; + // The recipient has no channel yet, but the connected payer gives the default message router a + // peer through which it can construct an offer path. This keeps the long-lived offer addressed + // by a blinded node path; the single-use intercept SCID is introduced only in the invoice. + assert!(client_node.list_channels().is_empty()); + let fixed_offer = + client_node.bolt12_payment().receive(fixed_amount_msat, "fixed", None, None).unwrap(); + assert!(!fixed_offer.paths().is_empty()); + assert_ne!(fixed_offer.issuer_signing_pubkey(), Some(client_node.node_id())); + let fixed_payment_id = + payer_node.bolt12_payment().send(&fixed_offer, None, None, None).unwrap(); + + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_event!(service_node, PaymentForwarded); + expect_channel_pending_event!(client_node, service_node.node_id()); + expect_channel_ready_event!(client_node, service_node.node_id()); + expect_payment_successful_event!(payer_node, 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().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().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().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 67c7fd535e50bba3420e272411fe3820914512d1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:16:21 +0200 Subject: [PATCH 38/42] lsps2: Test multi-LSP BOLT12 selection Extend the order-independent fee-selection scenario through a real BOLT12 payment. Also prove that BOLT11 consumption and BOLT12 response handling share the same replenished lease pool. Co-Authored-By: HAL 9000 --- tests/integration_tests_rust.rs | 64 +++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index ebbdae61e9..08a1770b8c 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -4992,17 +4992,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, @@ -5059,12 +5060,47 @@ async fn do_lsps2_multi_lsp_picks_cheapest( let client = client_builder.build(client_config.node_entropy.into()).unwrap(); client.start().unwrap(); + let payer_config = random_config(); + setup_builder!(payer_builder, payer_config.node_config); + payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + let payer = payer_builder.build(payer_config.node_entropy.into()).unwrap(); + payer.start().unwrap(); + + let client_addr = client.listening_addresses().unwrap().first().unwrap().clone(); + payer.connect(client.node_id(), client_addr, false).unwrap(); + + let cheap_onchain_addr = cheap.onchain_payment().new_address().unwrap(); + let client_onchain_addr = client.onchain_payment().new_address().unwrap(); + let payer_onchain_addr = payer.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![cheap_onchain_addr, client_onchain_addr, payer_onchain_addr], + Amount::from_sat(10_000_000), + ) + .await; + cheap.sync_wallets().unwrap(); + client.sync_wallets().unwrap(); + payer.sync_wallets().unwrap(); + + open_channel(&payer, &cheap, 5_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + cheap.sync_wallets().unwrap(); + payer.sync_wallets().unwrap(); + expect_channel_ready_event!(payer, cheap.node_id()); + expect_channel_ready_event!(cheap, payer.node_id()); + while payer.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let invoice_description = Bolt11InvoiceDescription::Direct(Description::new(String::from("asdf")).unwrap()).into(); let jit_invoice_result = client.bolt11_payment().receive_via_jit_channel(100_000_000, &invoice_description, 1024); if max_total_lsp_fee_limit_msat.is_some() { assert!(matches!(jit_invoice_result, Err(NodeError::LiquidityFeeTooHigh))); + payer.stop().unwrap(); client.stop().unwrap(); cheap.stop().unwrap(); expensive.stop().unwrap(); @@ -5082,6 +5118,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().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 eea73b285ca134bb9be387360c76cf3ff617c529 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 11:32:41 +0200 Subject: [PATCH 39/42] f Exercise BOLT12 fee rejection Keep the limited-fee multi-LSP case running through the BOLT12 invoice-request path before returning. Co-Authored-By: HAL 9000 --- tests/integration_tests_rust.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 08a1770b8c..0238cb840c 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -5100,6 +5100,11 @@ async fn do_lsps2_multi_lsp_picks_cheapest( 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().status, PaymentStatus::Failed); + assert!(client.list_channels().is_empty()); payer.stop().unwrap(); client.stop().unwrap(); cheap.stop().unwrap(); From ab58cf417723f71b460f631586353393b1e62553 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:24:29 +0200 Subject: [PATCH 40/42] 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 0238cb840c..d6d758b1ce 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -3684,10 +3684,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(); @@ -3779,8 +3783,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().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 515724321fd47efc7cd22ad07d485528ad07c4f3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:45:04 +0200 Subject: [PATCH 41/42] 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 e3e9de6eac..f30529e355 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -663,6 +663,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 d7375f6490b13bbe67208c4e8d59fba12a599458 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 13:01:41 +0200 Subject: [PATCH 42/42] 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 ec0d275030..e8b4034a81 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -1943,6 +1943,7 @@ fn build_with_store_internal( Arc::clone(&scorer), scoring_fee_params, ), + Arc::clone(&network_graph), Arc::clone(&keys_manager), )); @@ -2329,6 +2330,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 ed166f3944..ccd5b57114 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, MIN_FINAL_CLTV_EXPIRY_DELTA}; +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; /// Parameters needed to construct an LSPS2 blinded payment path. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -50,13 +53,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) -> Vec { @@ -103,15 +107,26 @@ impl Router for LSPS2Router { ) -> Result, ()> { let parameters = self.payment_parameters(&tlvs.payment_context); let allow_mpp = parameters.iter().all(|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 @@ -222,16 +237,21 @@ 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::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; @@ -327,7 +347,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, }, @@ -364,6 +388,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]), @@ -398,7 +442,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( @@ -433,7 +477,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( @@ -458,7 +502,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( @@ -488,7 +532,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 @@ -515,7 +559,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 @@ -544,7 +588,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( @@ -560,6 +604,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); @@ -573,7 +684,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( @@ -602,7 +713,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(