From ae13c0ae0c45c6b278612c28587e2ca2986c08a0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:13:16 +0200 Subject: [PATCH 01/38] 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 2056c0f878032e65513492036951da93cea8271d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:13:41 +0200 Subject: [PATCH 02/38] 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 | 7 ++ src/liquidity/client/lsps2/state.rs | 164 ++++++++++++++++++++++++++++ 2 files changed, 171 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 8f98e152cb..ede263f773 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -5,6 +5,12 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. +//! The client side of the LSPS2 just-in-time channel protocol. +//! +//! Receiving over a just-in-time channel requires negotiating a payment lease with an LSP, i.e., +//! obtaining an intercept SCID for accepted opening fee parameters. See the [`state`] module for +//! how leases are tracked. + use std::collections::HashMap; use std::ops::Deref; use std::sync::{Arc, Mutex, RwLock}; @@ -554,3 +560,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..aeb95e8fec --- /dev/null +++ b/src/liquidity/client/lsps2/state.rs @@ -0,0 +1,164 @@ +// 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. + +//! Single-use LSPS2 payment leases. +//! +//! A payment lease is the outcome of a successful LSPS2 `buy` request: the opening fee parameters +//! we accepted, together with the intercept SCID and CLTV expiry delta the LSP assigned to them. +//! An invoice that routes payers through the intercept SCID lets the LSP open a just-in-time +//! channel to us once the payment arrives. +//! +//! The LSP opens a single channel per intercept SCID, so each lease may back at most one invoice. +//! Leases are therefore consumed when an invoice is built from them, and become unusable once the +//! opening fee parameters they were negotiated for expire. + +use std::collections::HashMap; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use bitcoin::secp256k1::PublicKey; +use lightning::impl_writeable_tlv_based; +use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; + +use crate::data_store::{DataStore, StorableObject, StorableObjectId}; +use crate::hex_utils; + +pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; + +pub(crate) type PaymentLeaseStore = DataStore; + +/// Identifies a [`PaymentLease`] by the LSP that granted it and the intercept SCID it assigned. +#[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, { + (1, lsp_node_id, required), + (3, intercept_scid, required), +}); + +impl StorableObjectId for PaymentLeaseId { + fn encode_to_hex_str(&self) -> String { + format!( + "{}{:016x}", + hex_utils::to_string(&self.lsp_node_id.serialize()), + self.intercept_scid + ) + } + + fn decode_from_hex_str(s: &str) -> Option { + let bytes = hex_utils::to_vec(s)?; + if bytes.len() != 41 { + return None; + } + let lsp_node_id = PublicKey::from_slice(&bytes[..33]).ok()?; + let intercept_scid = u64::from_be_bytes(bytes[33..].try_into().ok()?); + Some(Self { lsp_node_id, intercept_scid }) + } +} + +/// A negotiated, single-use right to receive a payment over a just-in-time channel. +/// +/// See the module-level documentation for details. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PaymentLease { + pub(crate) id: PaymentLeaseId, + /// The opening fee parameters the LSP agreed to when granting this lease. + pub(crate) params: LSPS2OpeningFeeParams, + /// The CLTV expiry delta the LSP requires for payments to the intercept SCID. + pub(crate) cltv_expiry_delta: u32, + /// The exact payment amount for a fixed-amount lease, or `None` for a variable-amount lease. + pub(crate) payment_size_msat: Option, + /// The UNIX timestamp, in seconds, at which the opening fee parameters expire. + pub(crate) valid_until: u64, +} + +impl_writeable_tlv_based!(PaymentLease, { + (1, id, required), + (3, params, required), + (5, valid_until, required), + (7, cltv_expiry_delta, required), + (9, payment_size_msat, option), +}); + +impl StorableObject for PaymentLease { + type Id = PaymentLeaseId; + + fn id(&self) -> Self::Id { + self.id + } +} + +/// The set of [`PaymentLease`]s that are available to be consumed. +#[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 f6af368b7b51979df5fed63acf1d58a8b9243117 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:27:14 +0200 Subject: [PATCH 03/38] lsps2: Persist payment leases Load cached leases in parallel at startup and remove entries that are expired or too close to expiry for safe reuse. Co-Authored-By: HAL 9000 --- src/io/mod.rs | 4 ++++ src/liquidity/client/lsps2/mod.rs | 4 ++++ src/liquidity/mod.rs | 29 +++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/io/mod.rs b/src/io/mod.rs index 4d229e8b0d..f94c9c6bf9 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -50,6 +50,10 @@ pub(crate) const CHANNEL_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE: &str pub(crate) const CHANNEL_PAIR_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE: &str = "channel_pair_stats"; +/// Cached LSPS2 payment leases are persisted under this prefix. +pub(crate) const LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE: &str = "lsps2_leases"; +pub(crate) const LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + /// The node metrics will be persisted under this key. pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = ""; pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = ""; diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index ede263f773..51b7ccd424 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -41,6 +41,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, @@ -50,6 +52,8 @@ where Mutex>>, pub(crate) pending_buy_requests: Mutex>>, + pub(crate) lease_store: Arc>, + pub(crate) lease_state: Mutex, pub(crate) channel_manager: Arc, pub(crate) keys_manager: Arc, pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index c1442b0fc9..c460387317 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -31,7 +31,13 @@ use tokio::sync::oneshot; use crate::builder::BuildError; use crate::connection::ConnectionManager; +use crate::data_store::KeepAllEntries; +use crate::io::utils::read_all_objects; +use crate::io::{ + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, +}; use crate::liquidity::client::lsps1::LSPS1Client; +use crate::liquidity::client::lsps2::state::{is_lease_usable, LSPS2LeaseState, PaymentLeaseStore}; use crate::liquidity::client::lsps2::LSPS2Client; use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; @@ -293,6 +299,27 @@ where } pub(crate) async fn build(self) -> Result, BuildError> { + let leases = read_all_objects( + &*self.kv_store, + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + self.logger.clone(), + ) + .await + .map_err(|_| BuildError::ReadFailed)?; + let lease_store = Arc::new(PaymentLeaseStore::new( + leases.clone(), + KeepAllEntries, + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&self.kv_store), + self.logger.clone(), + )); + for lease in leases.iter().filter(|lease| !is_lease_usable(lease)) { + lease_store.remove(&lease.id).await.map_err(|_| BuildError::WriteFailed)?; + } + let lease_state = + LSPS2LeaseState::from_leases(leases.into_iter().filter(is_lease_usable).collect()); let liquidity_service_config = self.lsps2_service.as_ref().map(|s| { let lsps2_service_config = Some(s.ldk_service_config.clone()); let lsps5_service_config = None; @@ -357,6 +384,8 @@ where lsp_nodes: Arc::clone(&lsp_nodes), pending_lsps2_fee_requests: Mutex::new(HashMap::new()), pending_buy_requests: Mutex::new(HashMap::new()), + lease_store, + lease_state: Mutex::new(lease_state), channel_manager: self.channel_manager.clone(), keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), From 82469b71aa1efaee9c3021a0c26ce7a3dfc127a4 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:28:41 +0200 Subject: [PATCH 04/38] lsps2: Prune stale payment leases Remove unusable leases hourly so persisted cache data cannot grow without bound. Co-Authored-By: HAL 9000 --- src/lib.rs | 19 +++++++++++++++++++ src/liquidity/client/lsps2/mod.rs | 15 +++++++++++++++ src/liquidity/client/lsps2/state.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index f47c62e538..e50b6307f9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -598,6 +598,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 51b7ccd424..16759f5ea5 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -66,6 +66,21 @@ impl LSPS2Client where L::Target: LdkLogger, { + pub(crate) async fn prune_stale_leases(&self) -> Result<(), Error> { + let stale_ids = self + .lease_store + .list_filter(|lease| !state::is_lease_usable(lease)) + .await + .into_iter() + .map(|lease| lease.id) + .collect::>(); + for id in stale_ids { + self.lease_store.remove(&id).await?; + } + self.lease_state.lock().expect("lock").prune(); + Ok(()) + } + pub(crate) async fn lsps2_receive_to_jit_channel( self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index aeb95e8fec..45fefce102 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -161,4 +161,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 1299d90dd5555a065f61fafda9daeef062b3d454 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 14:38:11 +0200 Subject: [PATCH 05/38] lsps2: Configure a global fee limit Move LSPS2 fee policy out of individual BOLT11 receive calls and into node configuration. A shared total limit gives every receive flow one policy across all configured liquidity sources. Co-Authored-By: HAL 9000 --- CHANGELOG.md | 3 ++ src/config.rs | 10 ++++ src/event.rs | 49 +++++++++++++----- src/liquidity/client/lsps2/mod.rs | 41 +++++++-------- src/payment/bolt11.rs | 85 ++++++++++++------------------- src/payment/store.rs | 2 +- tests/integration_tests_rust.rs | 54 +++++++++++--------- 7 files changed, 133 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2682bb7137..0f9bc0091c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ This replaces the previous unpaginated `Node::list_payments`, and `Node::list_payments_with_filter` has been removed; filter the returned pages instead. - `Node::payment` now returns a `Result`, as retrieving a payment may fail. +- LSPS2 just-in-time receive fee limits are now configured globally through + `Config::lsps2_max_total_lsp_fee_limit_msat`. The corresponding per-call fee-limit arguments + have been removed from the BOLT 11 receive APIs. - The Bitcoin Core RPC and REST chain-source builder methods now accept an optional `wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known birthday block instead of checkpointing at the current tip, which is useful when restoring a diff --git a/src/config.rs b/src/config.rs index 5365f0e27d..54c582d961 100644 --- a/src/config.rs +++ b/src/config.rs @@ -264,6 +264,7 @@ impl Default for ForwardedPaymentTrackingMode { /// | `node_alias` | None | /// | `trusted_peers_0conf` | [] | /// | `probing_liquidity_limit_multiplier` | 3 | +/// | `lsps2_max_total_lsp_fee_limit_msat` | None | /// | `anchor_channels_config` | AnchorChannelsConfig::default() | /// | `route_parameters` | None | /// | `tor_config` | None | @@ -314,6 +315,14 @@ pub struct Config { /// Channels with available liquidity less than the required amount times this value won't be /// used to send pre-flight probes. pub probing_liquidity_limit_multiplier: u64, + /// The maximum total fee in millisatoshis that an LSPS2 liquidity provider may charge for a + /// just-in-time channel. + /// + /// This limit applies to all configured LSPS2 liquidity sources in addition to the fee negotiated + /// for a payment. Fixed-amount payments never accept a fee exceeding the payment amount, whether + /// or not this limit is set. If unset, 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. @@ -364,6 +373,7 @@ impl Default for Config { announcement_addresses: None, trusted_peers_0conf: Vec::new(), probing_liquidity_limit_multiplier: DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER, + lsps2_max_total_lsp_fee_limit_msat: None, anchor_channels_config: AnchorChannelsConfig::default(), tor_config: None, route_parameters: None, diff --git a/src/event.rs b/src/event.rs index 88501ae6ef..c8235fe4c2 100644 --- a/src/event.rs +++ b/src/event.rs @@ -898,19 +898,23 @@ where ) -> 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( - gross_amount_msat, - lsps2_parameters.min_opening_fee_msat, - max_prop_fee, - ) - }) - }) - .filter(|fee| *fee <= gross_amount_msat) + let proportional_limit = + 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( + gross_amount_msat, + lsps2_parameters.min_opening_fee_msat, + 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, + } + .filter(|fee| *fee <= gross_amount_msat) } async fn resolve_inbound_payment_id( @@ -3115,6 +3119,25 @@ 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), + min_opening_fee_msat: 0, + }), + }; + + 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 16759f5ea5..c35a40f753 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -83,8 +83,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?; @@ -119,7 +119,7 @@ where })?; let max_total_lsp_fee_limit_msat = - max_total_lsp_fee_limit_msat.unwrap_or(amount_msat).min(amount_msat); + self.config.lsps2_max_total_lsp_fee_limit_msat.unwrap_or(amount_msat).min(amount_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", @@ -163,11 +163,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)| { @@ -177,25 +177,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", @@ -204,7 +205,7 @@ where ); let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: None, + max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), min_opening_fee_msat: min_opening_params.min_fee_msat, }; diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index e9996e9cc2..4a69580846 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -127,8 +127,7 @@ impl Bolt11Payment { fn receive_via_jit_channel_inner( &self, amount_msat: Option, description: &LdkBolt11InvoiceDescription, - expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, - max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, + expiry_secs: u32, payment_hash: Option, ) -> Result { let connection_manager = Arc::clone(&self.connection_manager); let res = self.runtime.block_on(async move { @@ -139,7 +138,6 @@ impl Bolt11Payment { amount_msat, description, expiry_secs, - max_total_lsp_fee_limit_msat, payment_hash, connection_manager, ) @@ -150,7 +148,6 @@ impl Bolt11Payment { .lsps2_receive_variable_amount_to_jit_channel( description, expiry_secs, - max_proportional_lsp_fee_limit_ppm_msat, payment_hash, connection_manager, ) @@ -647,43 +644,38 @@ impl Bolt11Payment { Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a payment of the amount given and - /// receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a payment of + /// the amount given when additional inbound liquidity is needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The invoice only advertises the configured [LSPS2]-compliant LSP's intercept route. The payment + /// may instead use pre-existing channels if the payer independently knows a route to the + /// recipient, in which case no new channel is opened. /// /// We choose the cheapest offer and return [`Error::LiquidityFeeTooHigh`] if its opening fee - /// exceeds `amount_msat` or the optional `max_total_lsp_fee_limit_msat`. + /// exceeds `amount_msat` or the configured [`Config::lsps2_max_total_lsp_fee_limit_msat`]. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md pub fn receive_via_jit_channel( &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_total_lsp_fee_limit_msat: Option, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } let description = maybe_try_convert_enum(description)?; - let invoice = self.receive_via_jit_channel_inner( - Some(amount_msat), - &description, - expiry_secs, - max_total_lsp_fee_limit_msat, - None, - None, - )?; + let invoice = + self.receive_via_jit_channel_inner(Some(amount_msat), &description, expiry_secs, None)?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a payment of the amount given and - /// receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a payment of + /// the amount given when additional inbound liquidity is needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The invoice only advertises the configured [LSPS2]-compliant LSP's intercept route. The payment + /// may instead use pre-existing channels if the payer independently knows a route to the + /// recipient, in which case no new channel is opened. /// /// We choose the cheapest offer and return [`Error::LiquidityFeeTooHigh`] if its opening fee - /// exceeds `amount_msat` or the optional `max_total_lsp_fee_limit_msat`. + /// exceeds `amount_msat` or the configured [`Config::lsps2_max_total_lsp_fee_limit_msat`]. /// /// The inbound payment will only be accepted if /// [`Config::manually_handle_unknown_bolt11_payments`] is enabled. We will then emit a @@ -707,7 +699,7 @@ impl Bolt11Payment { /// [`counterparty_skimmed_fee_msat`]: crate::payment::PaymentKind::Bolt11::counterparty_skimmed_fee_msat pub fn receive_via_jit_channel_for_hash( &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_total_lsp_fee_limit_msat: Option, payment_hash: PaymentHash, + payment_hash: PaymentHash, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); @@ -717,52 +709,42 @@ impl Bolt11Payment { Some(amount_msat), &description, expiry_secs, - max_total_lsp_fee_limit_msat, - None, Some(payment_hash), )?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a variable amount payment (also known - /// as "zero-amount" invoice) and receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a variable + /// amount payment, also known as a "zero-amount" invoice, when additional inbound liquidity is + /// needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The invoice only advertises the configured [LSPS2]-compliant LSP's intercept route. The payment + /// may instead use pre-existing channels if the payer independently knows a route to the + /// recipient, in which case no new channel is opened. /// - /// If set, `max_proportional_lsp_fee_limit_ppm_msat` will limit how much proportional fee, in - /// parts-per-million millisatoshis, we allow the LSP to take for opening the channel to us. - /// We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced when the payment arrives. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md pub fn receive_variable_amount_via_jit_channel( &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_proportional_lsp_fee_limit_ppm_msat: Option, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); } let description = maybe_try_convert_enum(description)?; - let invoice = self.receive_via_jit_channel_inner( - None, - &description, - expiry_secs, - None, - max_proportional_lsp_fee_limit_ppm_msat, - None, - )?; + let invoice = self.receive_via_jit_channel_inner(None, &description, expiry_secs, None)?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a variable amount payment (also known - /// as "zero-amount" invoice) and receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a variable + /// amount payment, also known as a "zero-amount" invoice, when additional inbound liquidity is + /// needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The invoice only advertises the configured [LSPS2]-compliant LSP's intercept route. The payment + /// may instead use pre-existing channels if the payer independently knows a route to the + /// recipient, in which case no new channel is opened. /// - /// If set, `max_proportional_lsp_fee_limit_ppm_msat` will limit how much proportional fee, in - /// parts-per-million millisatoshis, we allow the LSP to take for opening the channel to us. - /// We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced when the payment arrives. /// /// The inbound payment will only be accepted if /// [`Config::manually_handle_unknown_bolt11_payments`] is enabled. We will then emit a @@ -785,8 +767,7 @@ impl Bolt11Payment { /// [`fail_for_id`]: Self::fail_for_id /// [`counterparty_skimmed_fee_msat`]: crate::payment::PaymentKind::Bolt11::counterparty_skimmed_fee_msat pub fn receive_variable_amount_via_jit_channel_for_hash( - &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: PaymentHash, + &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: PaymentHash, ) -> Result { if !*self.is_running.read().expect("lock") { return Err(Error::NotRunning); @@ -796,8 +777,6 @@ impl Bolt11Payment { None, &description, expiry_secs, - None, - max_proportional_lsp_fee_limit_ppm_msat, Some(payment_hash), )?; Ok(maybe_wrap(invoice)) diff --git a/src/payment/store.rs b/src/payment/store.rs index d5ed722e2c..8c8908f909 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -553,7 +553,7 @@ pub enum PaymentKind { preimage: Option, /// The secret used by the payment. secret: Option, - /// The value, in thousands of a satoshi, that was deducted from this payment as an extra + /// The value, in thousandths of a satoshi, that was deducted from this payment as an extra /// fee taken by our channel counterparty. /// /// Will only ever be `Some` for inbound payments received via an [bLIP-52 / LSPS 2] diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 691668c56a..64575c2408 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -6895,17 +6895,15 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { Bolt11InvoiceDescription::Direct(Description::new(String::from("fee limit")).unwrap()) .into(); // Distinguish client fee rejection from the LSP's LiquidityRequestFailed. - for (amount, fee_limit, expected) in [ - (999, None, Err(NodeError::LiquidityFeeTooHigh)), - (999, Some(2_000), Err(NodeError::LiquidityFeeTooHigh)), - (1_001, Some(999), Err(NodeError::LiquidityFeeTooHigh)), - (1_000, None, Err(NodeError::LiquidityRequestFailed)), - (1_001, None, Ok(())), + for (amount, expected) in [ + (999, Err(NodeError::LiquidityFeeTooHigh)), + (1_000, Err(NodeError::LiquidityRequestFailed)), + (1_001, Ok(())), ] { assert_eq!( client_node .bolt11_payment() - .receive_via_jit_channel(amount, &fee_test_description, 1024, fee_limit) + .receive_via_jit_channel(amount, &fee_test_description, 1024) .map(|_| ()), expected ); @@ -6951,7 +6949,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. @@ -7007,13 +7005,11 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, ), Some(hash) => client_node.bolt11_payment().receive_via_jit_channel_for_hash( jit_amount_msat, &invoice_description, 1024, - None, hash, ), } @@ -7061,7 +7057,6 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, manual_payment_hash, ) .unwrap(); @@ -7116,7 +7111,6 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, manual_payment_hash, ) .unwrap(); @@ -7149,7 +7143,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { // Variable-amount fees must be computed from the gross payment. let variable_invoice = client_node .bolt11_payment() - .receive_variable_amount_via_jit_channel(&invoice_description, 1024, None) + .receive_variable_amount_via_jit_channel(&invoice_description, 1024) .unwrap(); let variable_amount_msat = 20_000_000; let variable_fee_msat = (variable_amount_msat * channel_opening_fee_ppm as u64) / 1_000_000; @@ -7333,7 +7327,7 @@ async fn live_node_apis_return_not_running_after_stop() { ); assert_eq!(node_a.bolt11_payment().fail_for_id(payment_id), Err(NodeError::NotRunning)); assert_eq!( - node_a.bolt11_payment().receive_via_jit_channel(1_000_000, &description.into(), 3600, None), + node_a.bolt11_payment().receive_via_jit_channel(1_000_000, &description.into(), 3600), Err(NodeError::NotRunning) ); assert_eq!( @@ -7458,7 +7452,6 @@ async fn lsps2_client_trusts_lsp() { jit_amount_msat, &invoice_description.into(), 1024, - None, manual_payment_hash, ) .unwrap(); @@ -7634,7 +7627,6 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { jit_amount_msat, &invoice_description.into(), 1024, - None, manual_payment_hash, ) .unwrap(); @@ -8498,11 +8490,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; } -async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { +#[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, 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()); @@ -8556,7 +8555,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 { @@ -8571,10 +8571,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 072c421d77d1fc5a472e81fc2158381b799e13d8 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 13:29:52 +0200 Subject: [PATCH 06/38] 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 | 271 ++++++++++++++++++++++------ src/liquidity/client/lsps2/state.rs | 164 +++++++++++++---- 2 files changed, 337 insertions(+), 98 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index c35a40f753..c1d3fe7413 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -12,6 +12,7 @@ //! how leases are tracked. use std::collections::HashMap; +use std::future::Future; use std::ops::Deref; use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; @@ -41,7 +42,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 @@ -86,8 +105,63 @@ 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, + min_opening_fee_msat: lease.params.min_fee_msat, + }; + + let invoice = self.lsps2_create_jit_invoice( + &lease, + description, + expiry_secs, + payment_hash, + lsps2_parameters, + )?; + + if was_negotiated { + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + } + Ok((invoice, lsp)) + } + + pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( + self: Arc, description: &Bolt11InvoiceDescription, expiry_secs: u32, + payment_hash: Option, connection_manager: Arc>, + ) -> Result<(Bolt11Invoice, LspConfig), Error> { + let (lease, proportional_fee, lsp, was_negotiated) = + self.acquire_variable_lease(&connection_manager).await?; + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, + max_proportional_opening_fee_ppm_msat: Some(proportional_fee), + min_opening_fee_msat: lease.params.min_fee_msat, + }; + let invoice = self.lsps2_create_jit_invoice( + &lease, + description, + expiry_secs, + payment_hash, + lsps2_parameters, + )?; + + if was_negotiated { + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + } + Ok((invoice, lsp)) + } + + async fn acquire_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { + if let Some((lease, total_fee_msat, lsp)) = + self.take_cached_fixed_lease(amount_msat).await? + { + return Ok((lease, total_fee_msat, lsp, false)); + } + + let all_offers = self.gather_lsps2_offers(connection_manager).await?; let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers .into_iter() .flat_map(|(lsp, resp)| { @@ -118,8 +192,7 @@ where Error::LiquidityRequestFailed })?; - let max_total_lsp_fee_limit_msat = - self.config.lsps2_max_total_lsp_fee_limit_msat.unwrap_or(amount_msat).min(amount_msat); + let max_total_lsp_fee_limit_msat = self.max_fixed_amount_fee_msat(amount_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", @@ -135,38 +208,25 @@ where min_total_fee_msat ); - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: Some(min_total_fee_msat), - max_proportional_opening_fee_ppm_msat: None, - min_opening_fee_msat: min_opening_params.min_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 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() @@ -204,26 +264,11 @@ where min_prop_fee_ppm_msat ); - 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), - min_opening_fee_msat: min_opening_params.min_fee_msat, - }; - let buy_response = self + let negotiated_lease = self .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) .await?; - 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( @@ -320,7 +365,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)?; @@ -333,7 +378,7 @@ where let _pending_request = { let mut pending_buy_requests_lock = self.pending_buy_requests.lock().expect("lock"); let request_id = client_handler - .select_opening_params(lsps2_node.node_id, amount_msat, opening_fee_params) + .select_opening_params(lsps2_node.node_id, amount_msat, opening_fee_params.clone()) .map_err(|e| { log_error!( self.logger, @@ -364,16 +409,103 @@ 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 + } + + /// Fixed-amount opening fees are bounded by the payment amount even without a configured limit. + fn max_fixed_amount_fee_msat(&self, amount_msat: u64) -> u64 { + self.config.lsps2_max_total_lsp_fee_limit_msat.unwrap_or(amount_msat).min(amount_msat) + } + + 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, + Some(self.max_fixed_amount_fee_msat(amount_msat)), + &available_lsps, + ) else { + return Ok(None); + }; + let lsp = lsps2_nodes + .into_iter() + .find(|lsp| lsp.node_id == lease.id.lsp_node_id) + .expect("lease was selected from available LSPs"); + let lease = self.consume_selected_lease(lease).await?; + Ok(Some((lease, fee_msat, lsp))) + } + + async fn take_cached_variable_lease( + &self, + ) -> Result, Error> { + let lsps2_nodes = self.get_lsps2_nodes().await?; + let available_lsps = lsps2_nodes.iter().map(|lsp| lsp.node_id).collect::>(); + let Some((lease, proportional_fee)) = self + .lease_state + .lock() + .expect("lock") + .variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat, &available_lsps) + else { + return Ok(None); + }; + let lsp = lsps2_nodes + .into_iter() + .find(|lsp| lsp.node_id == lease.id.lsp_node_id) + .expect("lease was selected from available LSPs"); + let lease = self.consume_selected_lease(lease).await?; + Ok(Some((lease, proportional_fee, lsp))) } fn lsps2_create_jit_invoice( - &self, buy_response: LSPS2BuyResponse, amount_msat: Option, - description: &Bolt11InvoiceDescription, expiry_secs: u32, + &self, lease: &PaymentLease, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: Option, lsps2_parameters: LSPS2Parameters, - node_id: Option<&PublicKey>, ) -> Result { - let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) + let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id)) .ok_or(Error::LiquiditySourceUnavailable)?; // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. @@ -386,7 +518,7 @@ where .channel_manager .create_inbound_payment_for_hash( payment_hash, - amount_msat, + lease.payment_size_msat, expiry_secs, Some(min_final_cltv_expiry_delta), Some(encoded_payment_metadata), @@ -400,7 +532,7 @@ where None => self .channel_manager .create_inbound_payment( - amount_msat, + lease.payment_size_msat, expiry_secs, Some(min_final_cltv_expiry_delta), Some(encoded_payment_metadata), @@ -413,9 +545,9 @@ where let route_hint = RouteHint(vec![RouteHintHop { src_node_id: lsps2_node.node_id, - short_channel_id: buy_response.intercept_scid, + short_channel_id: lease.id.intercept_scid, fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, - cltv_expiry_delta: buy_response.cltv_expiry_delta as u16, + cltv_expiry_delta: lease.cltv_expiry_delta as u16, htlc_minimum_msat: None, htlc_maximum_msat: None, }]); @@ -430,7 +562,7 @@ where .expiry_time(Duration::from_secs(expiry_secs.into())) .private_route(route_hint); - if let Some(amount_msat) = amount_msat { + if let Some(amount_msat) = lease.payment_size_msat { invoice_builder = invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); } @@ -580,4 +712,25 @@ pub(crate) struct LSPS2BuyResponse { intercept_scid: u64, cltv_expiry_delta: u32, } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn failed_persisted_removal_restores_value() { + let restored = Arc::new(Mutex::new(None)); + let restored_ref = Arc::clone(&restored); + let result = consume_after_persisted_removal( + 42, + |_| async { Err(()) }, + move |value| *restored_ref.lock().unwrap() = Some(value), + ) + .await; + + assert_eq!(result, Err(())); + assert_eq!(*restored.lock().unwrap(), Some(42)); + } +} + pub(crate) mod state; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 45fefce102..3e594a7462 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -13,8 +13,9 @@ //! channel to us once the payment arrives. //! //! The LSP opens a single channel per intercept SCID, so each lease may back at most one invoice. -//! Leases are therefore consumed when an invoice is built from them, and become unusable once the -//! opening fee parameters they were negotiated for expire. +//! Leases are therefore consumed as soon as they are selected for an invoice, even if building that +//! invoice fails afterwards, and become unusable once the opening fee parameters they were +//! negotiated for expire. use std::collections::HashMap; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -22,6 +23,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; use lightning::impl_writeable_tlv_based; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; +use lightning_liquidity::lsps2::utils::compute_opening_fee; use crate::data_store::{DataStore, StorableObject, StorableObjectId}; use crate::hex_utils; @@ -110,14 +112,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)); } @@ -134,58 +176,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 8808e35b479fce0bb60b00a3bebc7efb360bbd59 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 10:01:50 +0200 Subject: [PATCH 07/38] lsps2: Reject oversized CLTV deltas Reject leases whose CLTV delta cannot be represented in route hints. This prevents invalid leases from surviving restoration or reaching invoice creation. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 15 +++++++++++-- src/liquidity/client/lsps2/state.rs | 35 ++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index c1d3fe7413..fab02538a9 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -408,6 +408,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 @@ -421,7 +431,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, }; @@ -547,7 +557,8 @@ where src_node_id: lsps2_node.node_id, short_channel_id: lease.id.intercept_scid, fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, - cltv_expiry_delta: lease.cltv_expiry_delta as u16, + cltv_expiry_delta: state::checked_cltv_expiry_delta(lease.cltv_expiry_delta) + .expect("LSPS2 lease CLTV delta was validated before invoice creation"), htlc_minimum_msat: None, htlc_maximum_msat: None, }]); diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 3e594a7462..818fbcdbd1 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -22,6 +22,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; @@ -29,6 +30,7 @@ use crate::data_store::{DataStore, StorableObject, StorableObjectId}; use crate::hex_utils; pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; +const BOLT12_FINAL_CLTV_EXPIRY_DELTA: u16 = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; pub(crate) type PaymentLeaseStore = DataStore; @@ -166,7 +168,14 @@ impl LSPS2LeaseState { } pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { - lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS + checked_cltv_expiry_delta(lease.cltv_expiry_delta).is_some() + && lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS +} + +pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { + u16::try_from(cltv_expiry_delta) + .ok() + .filter(|delta| *delta <= u16::MAX - BOLT12_FINAL_CLTV_EXPIRY_DELTA) } fn now_secs() -> u64 { @@ -239,6 +248,30 @@ mod tests { assert!(state.take_valid(&id).is_none()); } + #[test] + fn rejects_unsupported_cltv_delta() { + let mut lease = lease(2, 53, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); + lease.cltv_expiry_delta = u16::MAX as u32 + 1; + let id = lease.id; + let mut state = LSPS2LeaseState::from_leases(vec![lease]); + + assert!( + state.take_valid(&id).is_none(), + "lease with an unsupported CLTV delta remained usable" + ); + } + + #[test] + fn rejects_cltv_delta_without_bolt12_headroom() { + let mut lease = lease(2, 54, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); + lease.cltv_expiry_delta = u16::MAX as u32 - (MIN_FINAL_CLTV_EXPIRY_DELTA + 2) as u32 + 1; + + assert!( + !is_lease_usable(&lease), + "lease CLTV delta cannot absorb the BOLT12 final CLTV delta" + ); + } + #[test] fn selects_cheapest_matching_lease_across_lsps() { let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; From bad907c8a3ea292e389e0b8e5ee2eb586632e2d0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:36:48 +0200 Subject: [PATCH 08/38] 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 ac36c97936f0660ac35cdcfd2eaa7561aa052d98 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 14:50:26 +0200 Subject: [PATCH 09/38] 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 | 424 +++++++++++++++++++++++++++- src/liquidity/mod.rs | 41 ++- 5 files changed, 483 insertions(+), 13 deletions(-) diff --git a/src/io/mod.rs b/src/io/mod.rs index f94c9c6bf9..b6098a2f2d 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -54,6 +54,11 @@ pub(crate) const CHANNEL_PAIR_FORWARDING_STATS_PERSISTENCE_SECONDARY_NAMESPACE: pub(crate) const LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE: &str = "lsps2_leases"; pub(crate) const LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// LSPS2 lease cache targets are persisted under this key. +pub(crate) const LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE: &str = "lsps2_cache_targets"; +pub(crate) const LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +pub(crate) const LSPS2_CACHE_TARGET_PERSISTENCE_KEY: &str = "targets"; + /// The node metrics will be persisted under this key. pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = ""; pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = ""; diff --git a/src/lib.rs b/src/lib.rs index e50b6307f9..2cb7a3415a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -598,7 +598,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(); @@ -612,6 +612,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 fab02538a9..da9e8ad4a6 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -42,7 +42,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, @@ -73,6 +76,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, @@ -100,6 +104,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 818fbcdbd1..5ed07e47f7 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -16,24 +16,287 @@ //! Leases are therefore consumed as soon as they are selected for an invoice, even if building that //! invoice fails afterwards, and become unusable once the opening fee parameters they were //! negotiated for expire. +//! +//! Negotiating a lease takes several round trips to the LSP. To avoid that latency when a payer +//! is waiting, we record [`LeaseCacheTarget`]s for the amounts we expect to receive, so that +//! matching leases can be negotiated ahead of time. use std::collections::HashMap; +use std::ops::Deref; +use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; -use lightning::impl_writeable_tlv_based; use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; +use lightning::util::persist::KVStore; +use lightning::util::ser::{Readable, Writeable}; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; use lightning_liquidity::lsps2::utils::compute_opening_fee; use crate::data_store::{DataStore, StorableObject, StorableObjectId}; use crate::hex_utils; +use crate::io::{ + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::logger::{log_error, LdkLogger}; +use crate::types::DynStore; +use crate::Error; pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; const BOLT12_FINAL_CLTV_EXPIRY_DELTA: u16 = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; pub(crate) type PaymentLeaseStore = DataStore; +/// Identifies the kind of [`PaymentLease`] a [`LeaseCacheTarget`] asks us to keep available. +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum LeaseCacheTargetId { + /// A lease for receiving exactly `amount_msat`. + Fixed { amount_msat: u64 }, + /// A lease for receiving an amount that is only determined by the payer. + Variable, +} + +impl_writeable_tlv_based_enum!(LeaseCacheTargetId, + (0, Fixed) => { + (1, amount_msat, required), + }, + (2, Variable) => {}, +); + +/// A kind of [`PaymentLease`] we expect to need and therefore try to negotiate ahead of time. +/// +/// Targets are retained in least-recently-used order up to a fixed capacity, and are dropped once +/// their `absolute_expiry` passes. +#[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, { + (1, id, required), + (3, absolute_expiry, option), + (5, last_accessed, required), +}); + +#[derive(Clone, Debug, PartialEq, Eq)] +struct LeaseCacheTargets { + targets: Vec, +} + +impl_writeable_tlv_based!(LeaseCacheTargets, { + (1, 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) +} + /// Identifies a [`PaymentLease`] by the LSP that granted it and the intercept SCID it assigned. #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub(crate) struct PaymentLeaseId { @@ -178,14 +441,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, @@ -307,4 +621,110 @@ mod tests { assert!(state.variable_amount(Some(49), &available_lsps).is_none()); assert_eq!(state.variable_amount(Some(50), &available_lsps).unwrap().0.id, variable.id); } + + fn cache_target( + id: LeaseCacheTargetId, absolute_expiry: Option, last_accessed: u64, + ) -> LeaseCacheTarget { + LeaseCacheTarget { id, absolute_expiry, last_accessed } + } + + #[test] + fn cache_targets_roundtrip() { + let target = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, Some(42), 7); + + let encoded = target.encode(); + let decoded = LeaseCacheTarget::read(&mut &encoded[..]).unwrap(); + assert_eq!(decoded, target); + } + + #[tokio::test] + async fn cache_targets_persist_as_single_blob() { + let kv_store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let (state, removed) = LeaseCacheTargetState::from_targets(Vec::new(), 2, 0); + assert!(removed.is_empty()); + let store = + LeaseCacheTargetStore::new(state, Arc::clone(&kv_store), Arc::new(TestLogger::new())); + store + .register(cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, None, 1)) + .await + .unwrap(); + store.register(cache_target(LeaseCacheTargetId::Variable, None, 2)).await.unwrap(); + + let keys = KVStore::list( + &*kv_store, + LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, + ) + .await + .unwrap(); + assert_eq!(keys, vec![LSPS2_CACHE_TARGET_PERSISTENCE_KEY.to_string()]); + + let restored = + read_lease_cache_targets(&*kv_store, Arc::new(TestLogger::new())).await.unwrap(); + assert_eq!(restored, store.targets()); + } + + #[tokio::test] + async fn cache_target_store_keeps_memory_on_persist_failure() { + let initial = cache_target(LeaseCacheTargetId::Variable, None, 1); + let (state, removed) = LeaseCacheTargetState::from_targets(vec![initial.clone()], 2, 0); + assert!(removed.is_empty()); + let kv_store: Arc = Arc::new(DynStoreWrapper(FailingStore)); + let store = LeaseCacheTargetStore::new(state, kv_store, Arc::new(TestLogger::new())); + + let result = store + .register(cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, None, 2)) + .await; + + assert_eq!(result, Err(Error::PersistenceFailed)); + assert_eq!(store.targets(), vec![initial]); + } + + #[test] + fn cache_target_registry_evicts_least_recently_used() { + let first = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, None, 1); + let second = cache_target(LeaseCacheTargetId::Variable, None, 2); + let third = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 3_000 }, None, 3); + let first_id = first.id; + let second_id = second.id; + let third_id = third.id; + let (mut state, removed) = LeaseCacheTargetState::from_targets(vec![first, second], 2, 10); + assert!(removed.is_empty()); + + assert_eq!(state.register(cache_target(first_id, None, 11)), None); + assert_eq!(state.register(third), Some(second_id)); + assert!(state.contains(&first_id)); + assert!(!state.contains(&second_id)); + assert!(state.contains(&third_id)); + } + + #[test] + fn cache_target_registry_prunes_only_expired_targets() { + let expired = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, Some(9), 1); + let unexpired = cache_target(LeaseCacheTargetId::Fixed { amount_msat: 2_000 }, Some(11), 2); + let long_lived = cache_target(LeaseCacheTargetId::Variable, None, 3); + let expired_id = expired.id; + let unexpired_id = unexpired.id; + let long_lived_id = long_lived.id; + + let (state, removed) = + LeaseCacheTargetState::from_targets(vec![expired, unexpired, long_lived], 3, 10); + + assert_eq!(removed, vec![expired_id]); + assert!(state.contains(&unexpired_id)); + assert!(state.contains(&long_lived_id)); + } + + #[test] + fn cache_target_registry_keeps_longest_expiry() { + let id = LeaseCacheTargetId::Fixed { amount_msat: 1_000 }; + let (mut state, removed) = + LeaseCacheTargetState::from_targets(vec![cache_target(id, Some(20), 1)], 2, 10); + assert!(removed.is_empty()); + + state.register(cache_target(id, Some(30), 2)); + assert_eq!(state.targets().first().unwrap().absolute_expiry, Some(30)); + state.register(cache_target(id, None, 3)); + assert_eq!(state.targets().first().unwrap().absolute_expiry, None); + } } diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index c460387317..c85820aae0 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -37,7 +37,10 @@ use crate::io::{ LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::liquidity::client::lsps1::LSPS1Client; -use crate::liquidity::client::lsps2::state::{is_lease_usable, LSPS2LeaseState, PaymentLeaseStore}; +use crate::liquidity::client::lsps2::state::{ + is_lease_usable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, + LeaseCacheTargetStore, PaymentLeaseStore, +}; use crate::liquidity::client::lsps2::LSPS2Client; use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; @@ -47,6 +50,9 @@ use crate::{Config, Error}; const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; const LSPS_DISCOVERY_WAIT_TIMEOUT_SECS: u64 = 10; +// Keep the proactively refreshed target set bounded. Evicted requirements still fall back to +// on-demand lease negotiation, so this limits durable state without limiting offer validity. +const LSPS2_LEASE_CACHE_TARGET_SIZE: usize = 100; pub(crate) struct PendingRequest { token: Arc<()>, @@ -299,14 +305,17 @@ where } pub(crate) async fn build(self) -> Result, BuildError> { - let leases = read_all_objects( - &*self.kv_store, - LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, - LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, - self.logger.clone(), - ) - .await - .map_err(|_| BuildError::ReadFailed)?; + let (leases, cache_targets) = tokio::join!( + read_all_objects( + &*self.kv_store, + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + self.logger.clone(), + ), + read_lease_cache_targets(&*self.kv_store, self.logger.clone()), + ); + let leases = leases.map_err(|_| BuildError::ReadFailed)?; + let cache_targets = cache_targets.map_err(|_| BuildError::ReadFailed)?; let lease_store = Arc::new(PaymentLeaseStore::new( leases.clone(), KeepAllEntries, @@ -320,6 +329,19 @@ where } let lease_state = LSPS2LeaseState::from_leases(leases.into_iter().filter(is_lease_usable).collect()); + let (cache_target_state, removed_target_ids) = LeaseCacheTargetState::from_targets( + cache_targets, + LSPS2_LEASE_CACHE_TARGET_SIZE, + now_secs(), + ); + let cache_target_store = Arc::new(LeaseCacheTargetStore::new( + cache_target_state, + Arc::clone(&self.kv_store), + self.logger.clone(), + )); + if !removed_target_ids.is_empty() { + cache_target_store.persist().await.map_err(|_| BuildError::WriteFailed)?; + } let liquidity_service_config = self.lsps2_service.as_ref().map(|s| { let lsps2_service_config = Some(s.ldk_service_config.clone()); let lsps5_service_config = None; @@ -386,6 +408,7 @@ where pending_buy_requests: Mutex::new(HashMap::new()), lease_store, lease_state: Mutex::new(lease_state), + cache_target_store, channel_manager: self.channel_manager.clone(), keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), From b8efc079abd3082a133ef935765394db38641bff Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 14:34:47 +0200 Subject: [PATCH 10/38] 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/liquidity/client/lsps2/mod.rs | 58 +++++++++++++++++++++++++++++++ src/payment/bolt12/mod.rs | 43 ++++++++++++++++++++--- 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2cb7a3415a..cc009aeaa4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1134,6 +1134,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), @@ -1151,6 +1152,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/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index da9e8ad4a6..09a6778013 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -111,10 +111,22 @@ where pub(crate) async fn register_cache_target( &self, id: LeaseCacheTargetId, absolute_expiry: Option, ) -> Result<(), Error> { + // Without an LSPS2 liquidity source, no lease could ever be negotiated for the target. + if !self.may_have_lsps2_nodes() { + return Ok(()); + } let target = LeaseCacheTarget::new(id, absolute_expiry); self.cache_target_store.register(target).await } + /// Returns whether any configured LSP supports LSPS2, or may support it once protocol + /// discovery completes. + fn may_have_lsps2_nodes(&self) -> bool { + self.lsp_nodes.read().expect("lock").iter().any(|lsp_node| { + lsp_node.supported_protocols.as_ref().map_or(true, |protocols| protocols.contains(&2)) + }) + } + pub(crate) fn cache_targets(&self) -> Vec { self.cache_target_store.targets() } @@ -761,6 +773,52 @@ mod tests { assert_eq!(result, Err(())); assert_eq!(*restored.lock().unwrap(), Some(42)); } + + #[test] + fn cache_targets_require_lsps2_liquidity_source() { + use crate::builder::NodeBuilder; + use crate::entropy::NodeEntropy; + use crate::io::test_utils::InMemoryStore; + + let lsp_node_id: PublicKey = + "0276607124ebe6a6c9338517b6f485825b27c2dcc0b9fc2aa6a4c0df91194e5993".parse().unwrap(); + // Protocol discovery may not have completed yet, in which case the LSP may support LSPS2. + for (lsp_protocols, expect_registered) in [ + (None, false), + (Some(Some(vec![1])), false), + (Some(None), true), + (Some(Some(vec![1, 2])), true), + ] { + let mut builder = NodeBuilder::new(); + builder.set_log_facade_logger(); + if lsp_protocols.is_some() { + builder.add_liquidity_source( + lsp_node_id, + lightning::ln::msgs::SocketAddress::OnionV2([0; 12]), + None, + false, + ); + } + #[cfg(not(feature = "uniffi"))] + let entropy = NodeEntropy::from_seed_bytes([42; 64]); + #[cfg(feature = "uniffi")] + let entropy = NodeEntropy::from_seed_bytes(vec![42; 64]).unwrap(); + let node = builder.build_with_store(entropy, InMemoryStore::new()).unwrap(); + let client = node.liquidity_source.lsps2_client(); + if let Some(protocols) = lsp_protocols.clone() { + client.lsp_nodes.write().unwrap()[0].supported_protocols = protocols; + } + + node.runtime + .block_on(client.register_cache_target(LeaseCacheTargetId::Variable, None)) + .unwrap(); + assert_eq!( + !client.cache_targets().is_empty(), + expect_registered, + "unexpected cache target registration with LSP protocols {lsp_protocols:?}" + ); + } + } } pub(crate) mod state; diff --git a/src/payment/bolt12/mod.rs b/src/payment/bolt12/mod.rs index 3fe2024e71..23d97c4a7f 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, @@ -267,9 +271,32 @@ impl Bolt12Payment { Error::OfferCreationFailed })?; + if !self.has_sufficient_inbound_liquidity(amount_msat) { + self.register_cache_target(&finalized_offer, LeaseCacheTargetId::Fixed { amount_msat }); + } + 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> { @@ -525,6 +552,10 @@ 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 { @@ -534,6 +565,10 @@ impl Bolt12Payment { /// 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 { @@ -553,7 +588,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 cd0ba518e19b2db6e457b65647aa650777a81643 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 14:54:56 +0200 Subject: [PATCH 11/38] payment: Move payment metadata Move shared payment metadata out of the BOLT11 module. This lets other protocols reuse it without a BOLT11 dependency. Co-Authored-By: HAL 9000 --- src/payment/bolt11.rs | 46 +----------------------------------- src/payment/metadata.rs | 52 +++++++++++++++++++++++++++++++++++++++++ src/payment/mod.rs | 3 ++- 3 files changed, 55 insertions(+), 46 deletions(-) create mode 100644 src/payment/metadata.rs diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 4a69580846..562739827e 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -13,7 +13,6 @@ use std::sync::{Arc, RwLock}; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; -use lightning::impl_writeable_tlv_based; use lightning::ln::channelmanager::{ Bolt11InvoiceParameters, OptionalBolt11PaymentParams, PaymentId, }; @@ -32,8 +31,7 @@ use crate::ffi::{maybe_deref, maybe_try_convert_enum, maybe_wrap}; use crate::liquidity::LiquiditySource; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::store::{ - LSPS2Parameters, PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, - PaymentStatus, + PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; use crate::peer_store::{PeerInfo, PeerStore}; use crate::runtime::Runtime; @@ -49,16 +47,6 @@ type Bolt11InvoiceDescription = LdkBolt11InvoiceDescription; #[cfg(feature = "uniffi")] type Bolt11InvoiceDescription = crate::ffi::Bolt11InvoiceDescription; -/// Metadata carried in BOLT11 invoice `payment_metadata`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct PaymentMetadata { - pub(crate) lsps2_parameters: Option, -} - -impl_writeable_tlv_based!(PaymentMetadata, { - (0, lsps2_parameters, option), -}); - /// A payment handler allowing to create and pay [BOLT 11] invoices. /// /// Should be retrieved by calling [`Node::bolt11_payment`]. @@ -164,38 +152,6 @@ impl Bolt11Payment { } } -#[cfg(test)] -mod tests { - use lightning::util::ser::{Readable, Writeable}; - - use super::*; - - #[test] - fn empty_metadata_roundtrips() { - let metadata = PaymentMetadata { lsps2_parameters: None }; - - let encoded = metadata.encode(); - let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); - - assert_eq!(metadata, decoded); - } - - #[test] - fn lsps2_parameters_roundtrip() { - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: Some(42_000), - max_proportional_opening_fee_ppm_msat: Some(17_000), - min_opening_fee_msat: 1_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..13ab737c08 --- /dev/null +++ b/src/payment/metadata.rs @@ -0,0 +1,52 @@ +// 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), + min_opening_fee_msat: 1_000, + }; + let metadata = PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }; + + let encoded = metadata.encode(); + let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); + + assert_eq!(metadata, decoded); + } +} diff --git a/src/payment/mod.rs b/src/payment/mod.rs index e1c60da79a..3d5ea37ffb 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -14,6 +14,7 @@ mod forwarding; pub(crate) mod forwarding_store; #[cfg(feature = "unified-payments")] mod hrn; +mod metadata; mod onchain; pub(crate) mod pending_payment_store; mod spontaneous; @@ -22,7 +23,6 @@ pub(crate) mod store; mod unified; pub use bolt11::Bolt11Payment; -pub(crate) use bolt11::PaymentMetadata; pub use bolt12::{Bolt12Payment, PayerProofOptions}; pub use forwarding::{ ChannelForwardingStats, ChannelForwardingStatsPage, ChannelPairForwardingStats, @@ -31,6 +31,7 @@ pub use forwarding::{ }; #[cfg(feature = "unified-payments")] pub(crate) use hrn::HRNResolver; +pub(crate) use metadata::PaymentMetadata; pub use onchain::OnchainPayment; pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails}; pub use spontaneous::SpontaneousPayment; From edc831f0ee86ecb129d74a66df6a4bba98c43c6d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 10:24:44 +0200 Subject: [PATCH 12/38] 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 | 7 +- src/liquidity/client/lsps2/mod.rs | 19 +- src/liquidity/client/lsps2/router.rs | 613 +++++++++++++++++++++++++++ src/liquidity/client/lsps2/state.rs | 7 +- src/payment/metadata.rs | 35 +- 5 files changed, 667 insertions(+), 14 deletions(-) create mode 100644 src/liquidity/client/lsps2/router.rs diff --git a/src/event.rs b/src/event.rs index c8235fe4c2..9e50217e0e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -3058,6 +3058,7 @@ mod tests { max_proportional_opening_fee_ppm_msat: None, min_opening_fee_msat: 1_000, }), + lsps2_lease_parameters: None, }; for (gross_amount_msat, expected) in @@ -3108,6 +3109,7 @@ mod tests { max_proportional_opening_fee_ppm_msat: Some(proportional), min_opening_fee_msat: 0, }), + lsps2_lease_parameters: None, }; assert_eq!( EventHandler::>::lsps2_max_total_opening_fee_msat( @@ -3127,6 +3129,7 @@ mod tests { max_proportional_opening_fee_ppm_msat: Some(10_000), min_opening_fee_msat: 0, }), + lsps2_lease_parameters: None, }; assert_eq!( @@ -3140,13 +3143,15 @@ 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, min_opening_fee_msat: 1_000, }), + lsps2_lease_parameters: None, } .encode(); diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 09a6778013..2fb1c638f6 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -47,6 +47,9 @@ use self::state::{ PaymentLease, PaymentLeaseId, PaymentLeaseStore, }; +// LSPS2 requires two blocks more than the usual final CLTV delta. +const LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA: u16 = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; + async fn consume_after_persisted_removal( value: T, persist_removal: RF, restore: CF, ) -> Result @@ -549,10 +552,11 @@ where let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id)) .ok_or(Error::LiquiditySourceUnavailable)?; - // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. - let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; - let encoded_payment_metadata = - PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }.encode(); + let encoded_payment_metadata = PaymentMetadata { + lsps2_parameters: Some(lsps2_parameters), + lsps2_lease_parameters: None, + } + .encode(); let (payment_hash, payment_secret, payment_metadata) = match payment_hash { Some(payment_hash) => { let (payment_secret, payment_metadata) = self @@ -561,7 +565,7 @@ where payment_hash, lease.payment_size_msat, expiry_secs, - Some(min_final_cltv_expiry_delta), + Some(LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA), Some(encoded_payment_metadata), ) .map_err(|e| { @@ -575,7 +579,7 @@ where .create_inbound_payment( lease.payment_size_msat, expiry_secs, - Some(min_final_cltv_expiry_delta), + Some(LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA), Some(encoded_payment_metadata), ) .map_err(|e| { @@ -600,7 +604,7 @@ where .payment_hash(payment_hash) .payment_secret(payment_secret) .current_timestamp() - .min_final_cltv_expiry_delta(min_final_cltv_expiry_delta.into()) + .min_final_cltv_expiry_delta(LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA.into()) .expiry_time(Duration::from_secs(expiry_secs.into())) .private_route(route_hint); @@ -821,4 +825,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..01949bc4a0 --- /dev/null +++ b/src/liquidity/client/lsps2/router.rs @@ -0,0 +1,613 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// . + +//! Router helpers for injecting LSPS2 blinded payment paths. + +use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; + +use lightning::blinded_path::payment::{ + BlindedPaymentPath, ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, + PaymentRelay, ReceiveTlvs, +}; +use lightning::blinded_path::IntroductionNode; +use lightning::impl_writeable_tlv_based; +use lightning::ln::channel_state::ChannelDetails; +use lightning::ln::channelmanager::PaymentId; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::{EntropySource, ReceiveAuthKey}; +use lightning::types::features::BlindedHopFeatures; +use lightning::types::payment::PaymentHash; + +use crate::payment::PaymentMetadata; + +use super::LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA; + +/// Parameters needed to construct an LSPS2 blinded payment path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LSPS2LeaseParameters { + /// The LSP serving as the blinded path introduction node. + pub lsp_node_id: PublicKey, + /// The single-use intercept short channel id negotiated with the LSP. + pub intercept_scid: u64, + /// The CLTV delta required by the LSP. + pub cltv_expiry_delta: u16, + /// The fixed amount negotiated for this intercept SCID, or `None` for a variable amount. + pub payment_size_msat: Option, + /// Unix timestamp at which these parameters expire. + pub valid_until: u64, +} + +impl_writeable_tlv_based!(LSPS2LeaseParameters, { + (1, lsp_node_id, required), + (3, intercept_scid, required), + (5, cltv_expiry_delta, required), + (7, payment_size_msat, option), + (9, valid_until, required), +}); + +/// A router wrapper that uses ordinary payment paths when available and LSPS2 JIT paths otherwise. +pub struct LSPS2Router { + inner_router: R, + entropy_source: ES, +} + +impl LSPS2Router { + /// Constructs an LSPS2-aware wrapper around `inner_router`. + pub fn new(inner_router: R, entropy_source: ES) -> Self { + Self { inner_router, entropy_source } + } + + fn payment_parameters(&self, payment_context: &PaymentContext) -> Option { + match payment_context { + PaymentContext::Bolt12Offer(_) | PaymentContext::AsyncBolt12Offer(_) => {}, + _ => return None, + } + payment_context + .payment_metadata() + .and_then(PaymentMetadata::decode_from_bolt12_payment_metadata) + .and_then(|metadata| metadata.lsps2_lease_parameters) + } +} + +impl Router for LSPS2Router { + fn find_route( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + ) -> Result { + self.inner_router.find_route(payer, route_params, first_hops, inflight_htlcs) + } + + fn find_route_with_id( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + payment_hash: PaymentHash, payment_id: PaymentId, + ) -> Result { + self.inner_router.find_route_with_id( + payer, + route_params, + first_hops, + inflight_htlcs, + payment_hash, + payment_id, + ) + } + + fn create_blinded_payment_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, + secp_ctx: &Secp256k1, + ) -> Result, ()> { + let parameters = self.payment_parameters(&tlvs.payment_context); + let allow_mpp = parameters.map_or(true, |params| params.payment_size_msat.is_some()); + let direct_path_has_sufficient_liquidity = amount_msats.map_or(true, |amount_msats| { + if allow_mpp { + first_hops + .iter() + .map(|channel| channel.inbound_capacity_msat) + .fold(0u64, u64::saturating_add) + >= amount_msats + } else { + first_hops.iter().any(|channel| { + channel.inbound_capacity_msat >= amount_msats + && channel.inbound_htlc_minimum_msat.unwrap_or(0) <= amount_msats + && channel.inbound_htlc_maximum_msat.unwrap_or(u64::MAX) >= amount_msats + }) + } + }); + let inner_paths = self.inner_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs.clone(), + amount_msats, + secp_ctx, + ); + let is_direct_recipient_path = matches!( + &inner_paths, + Ok(paths) if paths.len() == 1 && matches!( + paths[0].introduction_node(), + IntroductionNode::NodeId(node_id) if *node_id == recipient + ) + ); + // The default router may fall back to a direct path for an announced recipient even when no + // channel-backed paths have enough inbound liquidity. Only accept that fallback when the + // locally known channels can receive the resolved amount: across channels when MPP is allowed, + // or on one channel, including its HTLC bounds, when MPP is disabled. Otherwise it would hide + // the need for a JIT path and leave an invoice that cannot be paid. + // + // Always prefer usable ordinary paths. Besides avoiding an unnecessary channel open, this + // prevents an MPP payer from splitting one payment across regular and JIT paths. The LSP only + // opens its channel after receiving the complete negotiated amount on the intercept SCID, so a + // partial JIT shard could otherwise remain stuck indefinitely. + if matches!(&inner_paths, Ok(paths) if !paths.is_empty()) + && (!is_direct_recipient_path || direct_path_has_sufficient_liquidity) + { + return inner_paths; + } + + let Some(params) = parameters else { + // Without a lease we cannot offer a JIT path either. Rather than returning a direct path + // that cannot receive the amount, fail invoice creation. This intentionally applies to any + // invoice built with an amount, including refunds and nodes without a configured LSP. + return if is_direct_recipient_path { Err(()) } else { inner_paths }; + }; + let Some(amount_msats) = amount_msats else { + // Invoice construction supplies the resolved amount even for a variable-amount offer. Without + // it, we cannot constrain the JIT path to the full channel-open trigger amount. + return inner_paths; + }; + + // A fixed lease is valid only for the exact amount negotiated with the LSP. A mismatch means + // these parameters were selected for a different response and must never be exposed. + if params.payment_size_msat.is_some_and(|fixed_amount| fixed_amount != amount_msats) { + return Err(()); + } + // Both fixed and variable invoices require the entire resolved payment on the JIT path. + // Fixed invoices may advertise MPP across regular paths, while variable invoices disable + // MPP at response construction. Both bounds keep the JIT payment indivisible. + let payment_constraints = PaymentConstraints { + max_cltv_expiry: tlvs + .payment_constraints + .max_cltv_expiry + .saturating_add(params.cltv_expiry_delta as u32), + htlc_minimum_msat: amount_msats, + }; + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: params.intercept_scid, + payment_relay: PaymentRelay { + cltv_expiry_delta: params.cltv_expiry_delta, + fee_proportional_millionths: 0, + fee_base_msat: 0, + }, + payment_constraints, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: params.lsp_node_id, + htlc_maximum_msat: amount_msats, + }; + BlindedPaymentPath::new( + &[forward_node], + recipient, + local_node_receive_key, + tlvs, + amount_msats, + LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA, + &self.entropy_source, + secp_ctx, + ) + .map(|path| vec![path]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use bitcoin::secp256k1::SecretKey; + use core::sync::atomic::{AtomicUsize, Ordering}; + use lightning::blinded_path::payment::{Bolt12OfferContext, PaymentConstraints}; + use lightning::ln::channel_state::{ChannelCounterparty, ChannelShutdownState}; + use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; + use lightning::ln::types::ChannelId; + use lightning::offers::invoice_request::InvoiceRequestFields; + use lightning::offers::offer::OfferId; + use lightning::types::features::InitFeatures; + use lightning::types::payment::PaymentSecret; + use std::collections::BTreeMap; + + #[derive(Clone)] + struct TestEntropy; + + impl EntropySource for TestEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } + + struct MockRouter { + calls: AtomicUsize, + path_kind: MockPathKind, + } + + #[derive(Clone, Copy)] + enum MockPathKind { + None, + ChannelBacked, + DirectRecipient, + } + + impl Router for MockRouter { + fn find_route( + &self, _payer: &PublicKey, _route_params: &RouteParameters, + _first_hops: Option<&[&ChannelDetails]>, _inflight_htlcs: InFlightHtlcs, + ) -> Result { + Err("mock router") + } + + fn create_blinded_payment_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + _first_hops: Vec, tlvs: ReceiveTlvs, _amount_msats: Option, + secp_ctx: &Secp256k1, + ) -> Result, ()> { + self.calls.fetch_add(1, Ordering::AcqRel); + match self.path_kind { + MockPathKind::None => Err(()), + MockPathKind::DirectRecipient => BlindedPaymentPath::one_hop( + recipient, + local_node_receive_key, + tlvs, + MIN_FINAL_CLTV_EXPIRY_DELTA, + TestEntropy, + secp_ctx, + ) + .map(|path| vec![path]), + MockPathKind::ChannelBacked => { + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: 43, + payment_relay: PaymentRelay { + cltv_expiry_delta: 18, + fee_proportional_millionths: 0, + fee_base_msat: 0, + }, + payment_constraints: PaymentConstraints { + max_cltv_expiry: 118, + htlc_minimum_msat: 0, + }, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: pubkey(12), + htlc_maximum_msat: u64::MAX, + }; + BlindedPaymentPath::new( + &[forward_node], + recipient, + local_node_receive_key, + tlvs, + u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, + TestEntropy, + secp_ctx, + ) + .map(|path| vec![path]) + }, + } + } + } + + fn pubkey(byte: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[byte; 32]).unwrap()) + } + + fn first_hop( + inbound_capacity_msat: u64, inbound_htlc_minimum_msat: Option, + inbound_htlc_maximum_msat: Option, + ) -> ChannelDetails { + ChannelDetails { + channel_id: ChannelId::new_zero(), + counterparty: ChannelCounterparty { + node_id: pubkey(13), + features: InitFeatures::empty(), + unspendable_punishment_reserve: 0, + forwarding_info: None, + outbound_htlc_minimum_msat: None, + outbound_htlc_maximum_msat: None, + }, + funding_txo: None, + channel_type: None, + short_channel_id: None, + outbound_scid_alias: None, + inbound_scid_alias: None, + channel_value_satoshis: 0, + unspendable_punishment_reserve: None, + user_channel_id: 0, + feerate_sat_per_1000_weight: None, + outbound_capacity_msat: 0, + next_outbound_htlc_limit_msat: 0, + next_outbound_htlc_minimum_msat: 0, + next_splice_out_maximum_sat: 0, + inbound_capacity_msat, + confirmations_required: None, + confirmations: None, + force_close_spend_delay: None, + is_outbound: false, + is_channel_ready: true, + channel_shutdown_state: Some(ChannelShutdownState::NotShuttingDown), + is_usable: true, + is_announced: false, + inbound_htlc_minimum_msat, + inbound_htlc_maximum_msat, + config: None, + pending_inbound_htlcs: Vec::new(), + pending_outbound_htlcs: Vec::new(), + funding_redeem_script: None, + current_dust_exposure_msat: None, + splice_details: None, + } + } + + fn payment_tlvs(metadata: BTreeMap>) -> ReceiveTlvs { + ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: OfferId([8; 32]), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: pubkey(9), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + payment_metadata: Some(metadata), + }), + } + } + + fn payment_metadata(parameters: LSPS2LeaseParameters) -> BTreeMap> { + PaymentMetadata { lsps2_parameters: None, lsps2_lease_parameters: Some(parameters) } + .encode_as_bolt12_payment_metadata() + } + + #[test] + fn constrains_fixed_jit_path_to_negotiated_amount() { + let lsp_node_id = pubkey(11); + let parameters = LSPS2LeaseParameters { + lsp_node_id, + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::None }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + let paths = router + .create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(router.inner_router.calls.load(Ordering::Acquire), 1); + assert_eq!(paths.len(), 1); + assert_eq!( + paths[0].introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id) + ); + assert_eq!(paths[0].payinfo.htlc_minimum_msat, 3_000); + assert_eq!(paths[0].payinfo.htlc_maximum_msat, 3_000); + assert_eq!(paths[0].payinfo.cltv_expiry_delta, 48 + MIN_FINAL_CLTV_EXPIRY_DELTA + 2); + } + + #[test] + fn rejects_fixed_jit_path_for_another_amount() { + let parameters = LSPS2LeaseParameters { + lsp_node_id: pubkey(11), + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::None }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + assert!(router + .create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(5_000), + &Secp256k1::new(), + ) + .is_err()); + } + + #[test] + fn constrains_variable_jit_path_to_invoice_amount() { + let parameters = LSPS2LeaseParameters { + lsp_node_id: pubkey(11), + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: None, + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::None }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + let paths = router + .create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(5_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(paths[0].payinfo.htlc_minimum_msat, 5_000); + assert_eq!(paths[0].payinfo.htlc_maximum_msat, 5_000); + } + + #[test] + fn omits_jit_path_when_regular_paths_are_available() { + let lsp_node_id = pubkey(11); + let parameters = LSPS2LeaseParameters { + lsp_node_id, + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = + MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::ChannelBacked }; + let router = LSPS2Router::new(inner_router, TestEntropy); + let recipient = pubkey(10); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(router.inner_router.calls.load(Ordering::Acquire), 1); + assert_eq!(paths.len(), 1); + assert_ne!( + paths[0].introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id) + ); + } + + #[test] + fn rejects_insufficient_direct_recipient_fallback() { + let recipient = pubkey(10); + let inner_router = + MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + assert!( + router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(BTreeMap::new()), + Some(3_000), + &Secp256k1::new(), + ) + .is_err(), + "announced-recipient fallback hid missing inbound liquidity" + ); + } + + #[test] + fn accepts_sufficient_mpp_direct_recipient_fallback() { + let recipient = pubkey(10); + let parameters = LSPS2LeaseParameters { + lsp_node_id: pubkey(11), + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let inner_router = + MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + vec![first_hop(1_500, None, None), first_hop(1_500, None, None)], + payment_tlvs(payment_metadata(parameters)), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(paths[0].introduction_node(), &IntroductionNode::NodeId(recipient)); + } + + #[test] + fn requires_one_sufficient_non_mpp_channel() { + let recipient = pubkey(10); + let lsp_node_id = pubkey(11); + let parameters = LSPS2LeaseParameters { + lsp_node_id, + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: None, + valid_until: u64::MAX, + }; + let inner_router = + MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + vec![first_hop(2_000, None, None), first_hop(4_000, None, Some(2_500))], + payment_tlvs(payment_metadata(parameters)), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(paths[0].introduction_node(), &IntroductionNode::NodeId(lsp_node_id)); + } + + #[test] + fn replaces_insufficient_direct_fallback_with_jit_path() { + let recipient = pubkey(10); + let lsp_node_id = pubkey(11); + let parameters = LSPS2LeaseParameters { + lsp_node_id, + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let inner_router = + MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(payment_metadata(parameters)), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!( + paths[0].introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id), + "insufficient direct fallback hid the JIT path" + ); + } +} diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 5ed07e47f7..dc4eab0bdd 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -27,7 +27,6 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; -use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, Writeable}; use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; @@ -44,8 +43,9 @@ use crate::logger::{log_error, LdkLogger}; use crate::types::DynStore; use crate::Error; +use super::LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA; + pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; -const BOLT12_FINAL_CLTV_EXPIRY_DELTA: u16 = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; pub(crate) type PaymentLeaseStore = DataStore; @@ -438,7 +438,7 @@ pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { u16::try_from(cltv_expiry_delta) .ok() - .filter(|delta| *delta <= u16::MAX - BOLT12_FINAL_CLTV_EXPIRY_DELTA) + .filter(|delta| *delta <= u16::MAX - LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA) } pub(crate) fn now_secs() -> u64 { @@ -452,6 +452,7 @@ mod tests { use super::*; use bitcoin::secp256k1::{Secp256k1, SecretKey}; use lightning::io; + use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning::util::ser::{Readable, Writeable}; use lightning::util::test_utils::TestLogger; diff --git a/src/payment/metadata.rs b/src/payment/metadata.rs index 13ab737c08..f0b8c95068 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), + (1, 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(); @@ -42,7 +68,10 @@ mod tests { max_proportional_opening_fee_ppm_msat: Some(17_000), min_opening_fee_msat: 1_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 32d6e1388537210b0962afa80ac5458fd06047ff Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:42:26 +0200 Subject: [PATCH 13/38] bolt12: Wrap offers message handling Install a node-local handler while preserving ChannelManager behavior. This creates an isolated integration point for asynchronous JIT replies. Co-Authored-By: HAL 9000 --- src/builder.rs | 7 +++++-- src/payment/bolt12/mod.rs | 4 ++++ src/payment/bolt12/offers.rs | 39 ++++++++++++++++++++++++++++++++++++ src/payment/mod.rs | 1 + src/types.rs | 3 ++- 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 src/payment/bolt12/offers.rs diff --git a/src/builder.rs b/src/builder.rs index 21f15508b4..140da6fc1c 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -100,6 +100,7 @@ use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::payment::forwarding_store::ForwardingStore; #[cfg(feature = "unified-payments")] use crate::payment::HRNResolver; +use crate::payment::NodeOffersMessageHandler; use crate::peer_store::PeerStore; use crate::probing::{ HighDegreeStrategy, Prober, ProbingConfig, ProbingStrategy, ProbingStrategyKind, @@ -2232,6 +2233,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() { @@ -2311,7 +2314,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 {}, @@ -2324,7 +2327,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 23d97c4a7f..06caf7a8b7 100644 --- a/src/payment/bolt12/mod.rs +++ b/src/payment/bolt12/mod.rs @@ -9,6 +9,10 @@ //! //! [BOLT 12]: https://github.com/lightning/bolts/blob/master/12-offer-encoding.md +mod offers; + +pub(crate) use offers::NodeOffersMessageHandler; + use std::num::NonZeroU64; use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs new file mode 100644 index 0000000000..d5f21746b4 --- /dev/null +++ b/src/payment/bolt12/offers.rs @@ -0,0 +1,39 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::sync::Arc; + +use lightning::blinded_path::message::OffersContext; +use lightning::onion_message::messenger::{ + MessageSendInstructions, Responder, ResponseInstruction, +}; +use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; + +use crate::types::ChannelManager; + +/// Routes offers messages through node-local handling before falling back to the channel manager. +pub(crate) struct NodeOffersMessageHandler { + channel_manager: Arc, +} + +impl NodeOffersMessageHandler { + pub(crate) fn new(channel_manager: Arc) -> Self { + Self { channel_manager } + } +} + +impl OffersMessageHandler for NodeOffersMessageHandler { + fn handle_message( + &self, message: OffersMessage, context: Option, responder: Option, + ) -> Option<(OffersMessage, ResponseInstruction)> { + self.channel_manager.handle_message(message, context, responder) + } + + fn release_pending_messages(&self) -> Vec<(OffersMessage, MessageSendInstructions)> { + self.channel_manager.release_pending_messages() + } +} diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 3d5ea37ffb..64bc7b526e 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -23,6 +23,7 @@ pub(crate) mod store; mod unified; pub use bolt11::Bolt11Payment; +pub(crate) use bolt12::NodeOffersMessageHandler; pub use bolt12::{Bolt12Payment, PayerProofOptions}; pub use forwarding::{ ChannelForwardingStats, ChannelForwardingStatsPage, ChannelPairForwardingStats, diff --git a/src/types.rs b/src/types.rs index 9eb3fdaa8c..7a6d7e3f87 100644 --- a/src/types.rs +++ b/src/types.rs @@ -43,6 +43,7 @@ use crate::fee_estimator::OnchainFeeEstimator; use crate::ffi::maybe_wrap; use crate::logger::Logger; use crate::message_handler::NodeCustomMessageHandler; +use crate::payment::NodeOffersMessageHandler; use crate::payment::{ ChannelPairForwardingStats, ForwardedPaymentDetails, PaymentDetails, PendingPaymentDetails, }; @@ -306,7 +307,7 @@ pub(crate) type OnionMessenger = lightning::onion_message::messenger::OnionMesse Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, IgnoringMessageHandler, From 0778b7a4dedb34be25d3d23e2af59e01ea807e15 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:48:01 +0200 Subject: [PATCH 14/38] bolt12: Sync offers flow block state Feed every supported chain source into the node-local offers flow so invoice expiry and blinded-path CLTV limits use the current tip. Co-Authored-By: HAL 9000 --- src/builder.rs | 11 +++- src/chain/bitcoind.rs | 24 ++++++-- src/chain/electrum.rs | 19 +++++-- src/chain/esplora.rs | 19 +++++-- src/chain/mod.rs | 104 +++++++++++++++++++++++++++++------ src/lib.rs | 17 +++++- src/payment/bolt12/offers.rs | 71 ++++++++++++++++++++++-- 7 files changed, 224 insertions(+), 41 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 140da6fc1c..2a49572ba6 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2233,8 +2233,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() { @@ -2605,6 +2611,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 4aed20218a..879a9f5743 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -44,6 +44,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::tx_broadcaster::SortedTransactions; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -257,8 +258,8 @@ impl BitcoindChainSource { pub(super) async fn initialize_wallets( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, - tx_broadcaster: &Broadcaster, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, tx_broadcaster: &Broadcaster, ) -> Result<(), Error> { // First register for the wallet polling status to make sure `Node::sync_wallets` calls // wait on the result before proceeding. @@ -283,11 +284,13 @@ impl BitcoindChainSource { let onchain_wallet_best_block = onchain_wallet.current_best_block(); let channel_manager_best_block = channel_manager.current_best_block(); + let offers_best_block = offers_message_handler.current_best_block(); let sweeper_best_block = output_sweeper.current_best_block(); let chain_listeners = vec![ (onchain_wallet_best_block, &*onchain_wallet as &(dyn Listen + Send + Sync)), (channel_manager_best_block, &*channel_manager as &(dyn Listen + Send + Sync)), + (offers_best_block, &*offers_message_handler as &(dyn Listen + Send + Sync)), (sweeper_best_block, &*output_sweeper as &(dyn Listen + Send + Sync)), ]; @@ -309,6 +312,7 @@ impl BitcoindChainSource { header_cache, Arc::clone(&onchain_wallet), Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper), ); @@ -350,7 +354,8 @@ impl BitcoindChainSource { pub(super) async fn continuously_sync_wallets( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) { let mut chain_polling_interval = tokio::time::interval(Duration::from_secs(CHAIN_POLLING_INTERVAL_SECS)); @@ -389,6 +394,7 @@ impl BitcoindChainSource { _ = self.poll_and_update_listeners( Arc::clone(&onchain_wallet), Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper) ) => {} @@ -446,7 +452,8 @@ impl BitcoindChainSource { pub(super) async fn poll_and_update_listeners( &self, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> Result<(), Error> { let receiver_res = { let mut status_lock = self.wallet_polling_status.lock().expect("lock"); @@ -468,6 +475,7 @@ impl BitcoindChainSource { .poll_and_update_listeners_inner( onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) @@ -480,7 +488,8 @@ impl BitcoindChainSource { async fn poll_and_update_listeners_inner( &self, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> Result<(), Error> { let mut spv_client_lock = self.spv_client.lock().await; if spv_client_lock.is_none() { @@ -495,6 +504,7 @@ impl BitcoindChainSource { HeaderCache::new(), Arc::clone(&onchain_wallet), Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), chain_monitor, output_sweeper, )); @@ -561,12 +571,14 @@ impl BitcoindChainSource { fn new_spv_client( &self, chain_tip: ValidatedBlockHeader, header_cache: HeaderCache, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> BitcoindSpvClient { let chain_poller = ChainPoller::new(Arc::clone(&self.api_client), self.config.network); let chain_listener = Arc::new(ChainListener { onchain_wallet: Arc::downgrade(&onchain_wallet), channel_manager: Arc::downgrade(&channel_manager), + offers_message_handler: Arc::downgrade(&offers_message_handler), chain_monitor: Arc::downgrade(&chain_monitor), output_sweeper: Arc::downgrade(&output_sweeper), }); diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 07e252fc64..7be8656b58 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}; @@ -229,7 +230,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 = { @@ -247,8 +249,14 @@ impl ElectrumChainSource { let sync_guard = WalletSyncGuard::new(&self.lightning_wallet_sync_status, Error::TxSyncFailed); - let res = - self.sync_lightning_wallet_inner(channel_manager, chain_monitor, output_sweeper).await; + let res = self + .sync_lightning_wallet_inner( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await; sync_guard.complete(res); @@ -256,14 +264,17 @@ impl ElectrumChainSource { } async fn sync_lightning_wallet_inner( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let sync_cman = Arc::clone(&channel_manager); + let sync_offers = Arc::clone(&offers_message_handler); let sync_cmon = Arc::clone(&chain_monitor); let sync_sweeper = Arc::clone(&output_sweeper); let confirmables: Vec> = vec![ sync_cman as Arc, + sync_offers as Arc, sync_cmon as Arc, sync_sweeper as Arc, ]; diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 1c13f141fb..e87d075d1f 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -29,6 +29,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -270,7 +271,8 @@ impl EsploraChainSource { } pub(super) async fn sync_lightning_wallet( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let receiver_res = { @@ -288,8 +290,14 @@ impl EsploraChainSource { let sync_guard = WalletSyncGuard::new(&self.lightning_wallet_sync_status, Error::WalletOperationFailed); - let res = - self.sync_lightning_wallet_inner(channel_manager, chain_monitor, output_sweeper).await; + let res = self + .sync_lightning_wallet_inner( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await; sync_guard.complete(res); @@ -297,14 +305,17 @@ impl EsploraChainSource { } async fn sync_lightning_wallet_inner( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let sync_cman = Arc::clone(&channel_manager); + let sync_offers = Arc::clone(&offers_message_handler); let sync_cmon = Arc::clone(&chain_monitor); let sync_sweeper = Arc::clone(&output_sweeper); let confirmables = vec![ &*sync_cman as &(dyn Confirm + Sync + Send), + &*sync_offers as &(dyn Confirm + Sync + Send), &*sync_cmon as &(dyn Confirm + Sync + Send), &*sync_sweeper as &(dyn Confirm + Sync + Send), ]; diff --git a/src/chain/mod.rs b/src/chain/mod.rs index e1f7e2ad41..36325cf3c5 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -38,6 +38,7 @@ use crate::config::EsploraSyncConfig; use crate::config::{BackgroundSyncConfig, Config, WALLET_SYNC_INTERVAL_MINIMUM_SECS}; use crate::fee_estimator::OnchainFeeEstimator; use crate::logger::{log_debug, log_info, log_trace, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::runtime::Runtime; use crate::tx_broadcaster::BroadcastPackage; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; @@ -166,6 +167,7 @@ enum ChainSourceKind { pub(crate) struct ChainListener { pub(crate) onchain_wallet: std::sync::Weak, pub(crate) channel_manager: std::sync::Weak, + pub(crate) offers_message_handler: std::sync::Weak, pub(crate) chain_monitor: std::sync::Weak, pub(crate) output_sweeper: std::sync::Weak, } @@ -174,10 +176,17 @@ pub(crate) struct ChainListener { impl ChainListener { fn upgrade( &self, - ) -> Option<(Arc, Arc, Arc, Arc)> { + ) -> Option<( + Arc, + Arc, + Arc, + Arc, + Arc, + )> { Some(( self.onchain_wallet.upgrade()?, self.channel_manager.upgrade()?, + self.offers_message_handler.upgrade()?, self.chain_monitor.upgrade()?, self.output_sweeper.upgrade()?, )) @@ -190,32 +199,50 @@ impl Listen for ChainListener { &self, header: &bitcoin::block::Header, txdata: &lightning::chain::transaction::TransactionData, height: u32, ) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() + if let Some(( + onchain_wallet, + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + )) = self.upgrade() { onchain_wallet.filtered_block_connected(header, txdata, height); channel_manager.filtered_block_connected(header, txdata, height); + offers_message_handler.filtered_block_connected(header, txdata, height); chain_monitor.filtered_block_connected(header, txdata, height); output_sweeper.filtered_block_connected(header, txdata, height); } } fn block_connected(&self, block: &bitcoin::Block, height: u32) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() + if let Some(( + onchain_wallet, + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + )) = self.upgrade() { onchain_wallet.block_connected(block, height); channel_manager.block_connected(block, height); + offers_message_handler.block_connected(block, height); chain_monitor.block_connected(block, height); output_sweeper.block_connected(block, height); } } fn blocks_disconnected(&self, fork_point_block: lightning::chain::BlockLocator) { - if let Some((onchain_wallet, channel_manager, chain_monitor, output_sweeper)) = - self.upgrade() + if let Some(( + onchain_wallet, + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + )) = self.upgrade() { onchain_wallet.blocks_disconnected(fork_point_block); channel_manager.blocks_disconnected(fork_point_block); + offers_message_handler.blocks_disconnected(fork_point_block); chain_monitor.blocks_disconnected(fork_point_block); output_sweeper.blocks_disconnected(fork_point_block); } @@ -374,7 +401,8 @@ impl ChainSource { pub(crate) async fn initialize_wallets( &self, stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, - channel_manager: Arc, chain_monitor: Arc, + channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, zero_fee_commitments_support_required: bool, ) -> Result<(), Error> { // Replay can broadcast claims, so refresh fees and validate the backend before syncing. @@ -396,6 +424,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, &self.tx_broadcaster, @@ -404,18 +433,33 @@ impl ChainSource { }, #[cfg(feature = "chain-esplora")] ChainSourceKind::Esplora(source) => { - source.sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper).await + source + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await }, #[cfg(feature = "chain-electrum")] ChainSourceKind::Electrum(source) => { - source.sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper).await + source + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await }, } } pub(crate) async fn continuously_sync_wallets( &self, stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, - channel_manager: Arc, chain_monitor: Arc, + channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) { match &self.kind { @@ -428,6 +472,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, background_sync_config, @@ -452,6 +497,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, background_sync_config, @@ -474,6 +520,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) @@ -485,8 +532,9 @@ impl ChainSource { async fn start_tx_based_sync_loop( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, - background_sync_config: &BackgroundSyncConfig, logger: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, background_sync_config: &BackgroundSyncConfig, + logger: Arc, ) { // Setup syncing intervals let onchain_wallet_sync_interval_secs = background_sync_config @@ -533,6 +581,7 @@ impl ChainSource { _ = lightning_wallet_sync_interval.tick() => { let _ = self.sync_lightning_wallet( Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper), ).await; @@ -567,20 +616,31 @@ impl ChainSource { // Synchronize the Lightning wallet via transaction-based protocols (i.e., Esplora, Electrum, // etc.) pub(crate) async fn sync_lightning_wallet( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { match &self.kind { #[cfg(feature = "chain-esplora")] ChainSourceKind::Esplora(esplora_chain_source) => { esplora_chain_source - .sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper) + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) .await }, #[cfg(feature = "chain-electrum")] ChainSourceKind::Electrum(electrum_chain_source) => { electrum_chain_source - .sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper) + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) .await }, #[cfg(feature = "chain-bitcoind")] @@ -594,10 +654,17 @@ impl ChainSource { pub(crate) async fn poll_and_update_listeners( &self, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> Result<(), Error> { #[cfg(not(feature = "chain-bitcoind"))] - let _ = (&onchain_wallet, &channel_manager, &chain_monitor, &output_sweeper); + let _ = ( + &onchain_wallet, + &channel_manager, + &offers_message_handler, + &chain_monitor, + &output_sweeper, + ); match &self.kind { #[cfg(feature = "chain-esplora")] @@ -618,6 +685,7 @@ impl ChainSource { .poll_and_update_listeners( onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) diff --git a/src/lib.rs b/src/lib.rs index cc009aeaa4..26b45d9286 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -184,8 +184,8 @@ use payment::asynchronous::static_invoice_store::StaticInvoiceStore; pub use payment::forwarding_store::aggregate_channel_pair_stats; use payment::forwarding_store::{run_forwarded_payment_aggregation, ForwardingStore}; use payment::{ - Bolt11Payment, Bolt12Payment, ForwardingAnalytics, OnchainPayment, PaymentDetails, - PaymentDetailsPage, SpontaneousPayment, + Bolt11Payment, Bolt12Payment, ForwardingAnalytics, NodeOffersMessageHandler, OnchainPayment, + PaymentDetails, PaymentDetailsPage, SpontaneousPayment, }; #[cfg(feature = "unified-payments")] use payment::{HRNResolver, UnifiedPayment}; @@ -268,6 +268,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, @@ -393,6 +394,7 @@ impl Node { self.stop_sender.subscribe(), Arc::clone(&self.wallet), Arc::clone(&self.channel_manager), + Arc::clone(&self.offers_message_handler), Arc::clone(&self.chain_monitor), Arc::clone(&self.output_sweeper), zero_fee_commitments_support_required, @@ -403,6 +405,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 { @@ -411,6 +414,7 @@ impl Node { stop_sync_receiver, sync_wallet, sync_cman, + sync_offers, sync_cmon, sync_sweeper, ) @@ -2442,13 +2446,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 { @@ -2457,6 +2467,7 @@ impl Node { .poll_and_update_listeners( sync_wallet, sync_cman, + sync_offers, sync_cmon, Arc::clone(&sync_sweeper), ) diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index d5f21746b4..7013e607f1 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,24 +5,57 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::sync::Arc; +use std::sync::{Arc, RwLock}; +use bitcoin::block::Header; +use bitcoin::constants::ChainHash; +use bitcoin::secp256k1::Secp256k1; +use bitcoin::{Block, BlockHash, Network, Txid}; use lightning::blinded_path::message::OffersContext; +use lightning::chain::transaction::TransactionData; +use lightning::chain::{BlockLocator, Confirm, Listen}; +use lightning::offers::flow::OffersMessageFlow; use lightning::onion_message::messenger::{ MessageSendInstructions, Responder, ResponseInstruction, }; use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; +use lightning::sign::{EntropySource, NodeSigner, Recipient}; -use crate::types::ChannelManager; +use crate::logger::Logger; +use crate::types::{ChannelManager, KeysManager, MessageRouter}; /// Routes offers messages through node-local handling before falling back to the channel manager. pub(crate) struct NodeOffersMessageHandler { channel_manager: Arc, + flow: OffersMessageFlow, Arc>, + best_block: RwLock, } impl NodeOffersMessageHandler { - pub(crate) fn new(channel_manager: Arc) -> Self { - Self { channel_manager } + pub(crate) fn new( + network: Network, current_timestamp: u32, channel_manager: Arc, + keys_manager: Arc, message_router: Arc, logger: Arc, + ) -> Self { + let best_block = channel_manager.current_best_block(); + let mut secp_ctx = Secp256k1::new(); + secp_ctx.seeded_randomize(&keys_manager.get_secure_random_bytes()); + let flow = OffersMessageFlow::new( + ChainHash::using_genesis_block(network), + best_block, + keys_manager.get_node_id(Recipient::Node).expect("node ID"), + current_timestamp, + keys_manager.get_expanded_key(), + keys_manager.get_receive_auth_key(), + secp_ctx, + message_router, + logger, + ); + Self { channel_manager, flow, best_block: RwLock::new(best_block) } + } + + #[cfg(feature = "chain-bitcoind")] + pub(crate) fn current_best_block(&self) -> BlockLocator { + *self.best_block.read().expect("lock") } } @@ -37,3 +70,33 @@ impl OffersMessageHandler for NodeOffersMessageHandler { self.channel_manager.release_pending_messages() } } + +impl Confirm for NodeOffersMessageHandler { + fn transactions_confirmed(&self, _header: &Header, _txdata: &TransactionData, _height: u32) {} + + fn transaction_unconfirmed(&self, _txid: &Txid) {} + + fn best_block_updated(&self, header: &Header, height: u32) { + let best_block = BlockLocator::new(header.block_hash(), height); + *self.best_block.write().expect("lock") = best_block; + self.flow.best_block_updated(header, height); + } + + fn get_relevant_txids(&self) -> Vec<(Txid, u32, Option)> { + Vec::new() + } +} + +impl Listen for NodeOffersMessageHandler { + fn filtered_block_connected(&self, header: &Header, _txdata: &TransactionData, height: u32) { + self.best_block_updated(header, height); + } + + fn block_connected(&self, block: &Block, height: u32) { + self.best_block_updated(&block.header, height); + } + + fn blocks_disconnected(&self, fork_point_block: BlockLocator) { + *self.best_block.write().expect("lock") = fork_point_block; + } +} From a2cd3a1b7276271df3ea23de88c271fe98d5a55d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:10:30 +0200 Subject: [PATCH 15/38] 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 --- CHANGELOG.md | 5 +++++ src/builder.rs | 27 +++++++++++++++++---------- src/payment/bolt12/mod.rs | 9 +++++++++ src/types.rs | 4 +++- 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f9bc0091c..af30c9ddb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ change once after such a migration. Payment contents and completeness are unaffected. - Pending JIT-channel payments created before upgrading may fail after upgrade because the prior LSPS2 fee-limit state stored in `PaymentKind::Bolt11Jit` is not migrated. +- Creating a BOLT 12 invoice for a known amount now fails instead of falling back to a direct + path to the node if its channels cannot receive that amount and, when responding to an + offer's invoice request, no LSPS2 just-in-time channel is available. Such invoices could not + be paid. This also applies to refunds, which are never received through just-in-time + channels, and to nodes without a configured LSP. - Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains a persisted `ChannelClosed` event. - Users of the VSS storage backend must upgrade their VSS server to at least version diff --git a/src/builder.rs b/src/builder.rs index 2a49572ba6..90cc0154d2 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -92,6 +92,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}; @@ -2144,12 +2145,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); @@ -2538,12 +2542,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/payment/bolt12/mod.rs b/src/payment/bolt12/mod.rs index 06caf7a8b7..2b0f7ddd54 100644 --- a/src/payment/bolt12/mod.rs +++ b/src/payment/bolt12/mod.rs @@ -560,6 +560,9 @@ 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. + /// + /// If neither can receive the requested amount, the invoice request is rejected rather than + /// answered with an invoice that cannot be paid. pub fn receive( &self, amount_msat: u64, description: &str, expiry_secs: Option, quantity: Option, ) -> Result { @@ -573,6 +576,9 @@ 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. + /// + /// If neither can receive the requested amount, the invoice request is rejected rather than + /// answered with an invoice that cannot be paid. pub fn receive_variable_amount( &self, description: &str, expiry_secs: Option, ) -> Result { @@ -601,6 +607,9 @@ impl Bolt12Payment { /// The returned [`Bolt12Invoice`] is for informational purposes only (i.e., isn't needed to /// retrieve the refund). /// + /// Returns an error if our channels cannot receive the refund amount, as such an invoice could + /// not be paid. Refunds are never received through LSPS2 just-in-time channels. + /// /// [`Refund`]: lightning::offers::refund::Refund /// [`Bolt12Invoice`]: lightning::offers::invoice::Bolt12Invoice pub fn request_refund_payment(&self, refund: &Refund) -> Result { diff --git a/src/types.rs b/src/types.rs index 7a6d7e3f87..39935bd2ba 100644 --- a/src/types.rs +++ b/src/types.rs @@ -41,6 +41,7 @@ use crate::config::{AnchorChannelsConfig, ChannelConfig}; use crate::data_store::{DataStore, KeepAllEntries, KeepLeastRecentlyUsed, KeepNoEntries}; use crate::fee_estimator::OnchainFeeEstimator; use crate::ffi::maybe_wrap; +use crate::liquidity::client::lsps2::router::LSPS2Router; use crate::logger::Logger; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::NodeOffersMessageHandler; @@ -274,7 +275,8 @@ pub(crate) type Broadcaster = crate::tx_broadcaster::TransactionBroadcaster>; +pub(crate) type BaseRouter = DefaultRouter< Arc, Arc, Arc, From 575c644a62cf78bf151f46c3b3e2432f04448139 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 14:53:51 +0200 Subject: [PATCH 16/38] 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 90cc0154d2..83d3072766 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2242,6 +2242,7 @@ fn build_with_store_internal( cur_time.as_secs().try_into().map_err(|_| BuildError::InvalidSystemTime)?, Arc::clone(&channel_manager), Arc::clone(&keys_manager), + Arc::clone(&router), Arc::clone(&message_router), Arc::clone(&logger), )); diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index 7013e607f1..d03ba2ad05 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -14,7 +14,11 @@ use bitcoin::{Block, BlockHash, Network, Txid}; use lightning::blinded_path::message::OffersContext; use lightning::chain::transaction::TransactionData; use lightning::chain::{BlockLocator, Confirm, Listen}; -use lightning::offers::flow::OffersMessageFlow; +use lightning::offers::flow::{InvreqResponseInstructions, OffersMessageFlow}; +use lightning::offers::invoice::UnsignedBolt12Invoice; +use lightning::offers::invoice_error::InvoiceError; +use lightning::offers::invoice_request::InvoiceRequestVerifiedFromOffer; +use lightning::offers::parse::Bolt12SemanticError; use lightning::onion_message::messenger::{ MessageSendInstructions, Responder, ResponseInstruction, }; @@ -22,19 +26,23 @@ use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; use lightning::sign::{EntropySource, NodeSigner, Recipient}; use crate::logger::Logger; -use crate::types::{ChannelManager, KeysManager, MessageRouter}; +use crate::types::{ChannelManager, KeysManager, MessageRouter, Router}; /// Routes offers messages through node-local handling before falling back to the channel manager. pub(crate) struct NodeOffersMessageHandler { channel_manager: Arc, + keys_manager: Arc, + router: Arc, flow: OffersMessageFlow, Arc>, + secp_ctx: Secp256k1, best_block: RwLock, } impl NodeOffersMessageHandler { pub(crate) fn new( network: Network, current_timestamp: u32, channel_manager: Arc, - keys_manager: Arc, message_router: Arc, logger: Arc, + keys_manager: Arc, router: Arc, message_router: Arc, + logger: Arc, ) -> Self { let best_block = channel_manager.current_best_block(); let mut secp_ctx = Secp256k1::new(); @@ -46,11 +54,18 @@ impl NodeOffersMessageHandler { current_timestamp, keys_manager.get_expanded_key(), keys_manager.get_receive_auth_key(), - secp_ctx, + secp_ctx.clone(), message_router, logger, ); - Self { channel_manager, flow, best_block: RwLock::new(best_block) } + Self { + channel_manager, + keys_manager, + router, + flow, + secp_ctx, + best_block: RwLock::new(best_block), + } } #[cfg(feature = "chain-bitcoind")] @@ -63,7 +78,86 @@ impl OffersMessageHandler for NodeOffersMessageHandler { fn handle_message( &self, message: OffersMessage, context: Option, responder: Option, ) -> Option<(OffersMessage, ResponseInstruction)> { - self.channel_manager.handle_message(message, context, responder) + let invoice_request = match message { + OffersMessage::InvoiceRequest(invoice_request) => invoice_request, + message => return self.channel_manager.handle_message(message, context, responder), + }; + + if matches!(context, Some(OffersContext::StaticInvoiceRequested { .. })) { + return self.channel_manager.handle_message( + OffersMessage::InvoiceRequest(invoice_request), + context, + responder, + ); + } + + let responder = responder?; + let payment_metadata = match context.as_ref() { + Some(OffersContext::InvoiceRequest { payment_metadata, .. }) => { + payment_metadata.clone() + }, + _ => None, + }; + let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { + Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request, + Ok(InvreqResponseInstructions::SendStaticInvoice { .. }) | Err(()) => return None, + }; + + let get_payment_info = |amount_msats, relative_expiry| { + self.channel_manager + .create_inbound_payment(Some(amount_msats), relative_expiry, None, None) + .map_err(|_| Bolt12SemanticError::InvalidAmount) + .map(|(payment_hash, payment_secret, _)| (payment_hash, payment_secret)) + }; + + let result = match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => self + .flow + .create_invoice_builder_from_invoice_request_with_keys( + self.router.as_ref(), + &request, + self.channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceError::from) + .and_then(|(builder, context)| { + builder + .build_and_sign(&self.secp_ctx) + .map_err(InvoiceError::from) + .map(|invoice| (invoice, context)) + }), + InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => self + .flow + .create_invoice_builder_from_invoice_request_without_keys( + self.router.as_ref(), + &request, + self.channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceError::from) + .and_then(|(builder, context)| { + builder + .build() + .map_err(InvoiceError::from) + .and_then(|invoice| { + invoice + .sign(|invoice: &UnsignedBolt12Invoice| { + self.keys_manager.sign_bolt12_invoice(invoice) + }) + .map_err(InvoiceError::from) + }) + .map(|invoice| (invoice, context)) + }), + }; + + Some(match result { + Ok((invoice, context)) => { + (OffersMessage::Invoice(invoice), responder.respond_with_reply_path(context)) + }, + Err(error) => (OffersMessage::InvoiceError(error), responder.respond()), + }) } fn release_pending_messages(&self) -> Vec<(OffersMessage, MessageSendInstructions)> { From b31c195dd26824efdc2780df8a12f41d683ff2e8 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:26:11 +0200 Subject: [PATCH 17/38] 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 | 96 ++++++- src/payment/bolt12/offers.rs | 446 +++++++++++++++++++++++++----- 3 files changed, 484 insertions(+), 64 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 83d3072766..0a03b9bf85 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2465,6 +2465,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 2fb1c638f6..0a6bcc6b70 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -11,7 +11,7 @@ //! obtaining an intercept SCID for accepted opening fee parameters. See the [`state`] module for //! how leases are tracked. -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::future::Future; use std::ops::Deref; use std::sync::{Arc, Mutex, RwLock}; @@ -42,6 +42,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, @@ -68,6 +69,31 @@ where } } +const DEFAULT_BOLT12_INVOICE_EXPIRY_SECS: u32 = 2 * 60 * 60; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum JitInvoiceRequest { + Fixed { amount_msat: u64, absolute_expiry: Option }, + Variable { amount_msat: u64, absolute_expiry: Option }, +} + +impl JitInvoiceRequest { + pub(crate) fn allow_mpp(&self) -> bool { + matches!(self, Self::Fixed { .. }) + } + + fn cache_target(&self) -> (LeaseCacheTargetId, Option) { + match *self { + Self::Fixed { amount_msat, absolute_expiry } => { + (LeaseCacheTargetId::Fixed { amount_msat }, absolute_expiry) + }, + Self::Variable { absolute_expiry, .. } => { + (LeaseCacheTargetId::Variable, absolute_expiry) + }, + } + } +} + pub(crate) struct LSPS2Client where L::Target: LdkLogger, @@ -134,6 +160,74 @@ where self.cache_target_store.targets() } + pub(crate) async fn commit_invoice_cache_target(&self, request: JitInvoiceRequest) { + // Cache targets are only an optimization. A persistence failure must not make the current + // invoice request unusable. + let (target_id, absolute_expiry) = request.cache_target(); + if let Err(error) = self.register_cache_target(target_id, absolute_expiry).await { + log_warn!(self.logger, "Failed recording LSPS2 lease cache target: {}", error); + } + } + + pub(crate) async fn prepare_invoice_response( + self: Arc, request: JitInvoiceRequest, connection_manager: Arc>, + ) -> Result>, Error> { + let (lease, fee_parameters) = match request { + JitInvoiceRequest::Fixed { amount_msat, .. } => { + let (lease, total_fee_msat, _, _) = + self.acquire_fixed_lease(amount_msat, &connection_manager).await?; + let min_opening_fee_msat = lease.params.min_fee_msat; + ( + lease, + LSPS2Parameters { + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + min_opening_fee_msat, + }, + ) + }, + JitInvoiceRequest::Variable { .. } => { + let (lease, proportional_fee, _, _) = + self.acquire_variable_lease(&connection_manager).await?; + let min_opening_fee_msat = lease.params.min_fee_msat; + ( + 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), + min_opening_fee_msat, + }, + ) + }, + }; + + // The offers flow currently uses its two-hour default invoice expiry. Never publish a JIT + // path whose intercept SCID may expire before the invoice does. + if lease.valid_until.saturating_sub(now_secs()) < DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64 + { + return Err(Error::LiquidityRequestFailed); + } + let lease_parameters = LSPS2LeaseParameters { + lsp_node_id: lease.id.lsp_node_id, + intercept_scid: lease.id.intercept_scid, + cltv_expiry_delta: state::checked_cltv_expiry_delta(lease.cltv_expiry_delta) + .expect("LSPS2 lease CLTV delta was validated before selection"), + payment_size_msat: lease.payment_size_msat, + valid_until: lease.valid_until, + }; + let payment_metadata = PaymentMetadata { + lsps2_parameters: Some(fee_parameters), + lsps2_lease_parameters: Some(lease_parameters), + } + .encode_as_bolt12_payment_metadata(); + + Ok(payment_metadata) + } + pub(crate) async fn lsps2_receive_to_jit_channel( self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: Option, diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index d03ba2ad05..61150bdf45 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,37 +5,84 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::sync::{Arc, RwLock}; +use std::collections::BTreeMap; +use std::future::Future; +use std::sync::{Arc, OnceLock, RwLock, Weak}; use bitcoin::block::Header; use bitcoin::constants::ChainHash; use bitcoin::secp256k1::Secp256k1; use bitcoin::{Block, BlockHash, Network, Txid}; -use lightning::blinded_path::message::OffersContext; +use lightning::blinded_path::message::{MessageContext, OffersContext}; use lightning::chain::transaction::TransactionData; use lightning::chain::{BlockLocator, Confirm, Listen}; use lightning::offers::flow::{InvreqResponseInstructions, OffersMessageFlow}; -use lightning::offers::invoice::UnsignedBolt12Invoice; +use lightning::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use lightning::offers::invoice_error::InvoiceError; use lightning::offers::invoice_request::InvoiceRequestVerifiedFromOffer; +use lightning::offers::offer::Amount; use lightning::offers::parse::Bolt12SemanticError; use lightning::onion_message::messenger::{ MessageSendInstructions, Responder, ResponseInstruction, }; use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; use lightning::sign::{EntropySource, NodeSigner, Recipient}; +use lightning::types::payment::{PaymentHash, PaymentSecret}; +use lightning::util::logger::Logger as _; -use crate::logger::Logger; -use crate::types::{ChannelManager, KeysManager, MessageRouter, Router}; +use crate::connection::ConnectionManager; +use crate::liquidity::client::lsps2::{JitInvoiceRequest, LSPS2Client}; +use crate::logger::{log_error, Logger}; +use crate::runtime::Runtime; +use crate::types::{ChannelManager, KeysManager, MessageRouter, OnionMessenger, Router}; + +type NodeOffersFlow = OffersMessageFlow, Arc>; +type InvoicePaymentInfo = OnceLock<(u64, PaymentHash, PaymentSecret)>; + +enum InvoiceBuildError { + Semantic(Bolt12SemanticError), + Response(InvoiceError), +} + +impl InvoiceBuildError { + fn into_invoice_error(self) -> InvoiceError { + match self { + Self::Semantic(error) => InvoiceError::from(error), + Self::Response(error) => error, + } + } +} + +async fn commit_cache_target_on_success( + result: Result, commit: C, +) -> Result +where + C: FnOnce() -> Fut, + Fut: Future, +{ + if result.is_ok() { + commit().await; + } + result +} + +struct JitInvoiceRequestDependencies { + runtime: Arc, + lsps2_client: Arc>>, + connection_manager: Weak>>, + onion_messenger: Weak, +} /// Routes offers messages through node-local handling before falling back to the channel manager. pub(crate) struct NodeOffersMessageHandler { channel_manager: Arc, keys_manager: Arc, router: Arc, - flow: OffersMessageFlow, Arc>, - secp_ctx: Secp256k1, + flow: Arc, + secp_ctx: Arc>, best_block: RwLock, + jit_dependencies: OnceLock, + logger: Arc, } impl NodeOffersMessageHandler { @@ -47,7 +94,7 @@ impl NodeOffersMessageHandler { let best_block = channel_manager.current_best_block(); let mut secp_ctx = Secp256k1::new(); secp_ctx.seeded_randomize(&keys_manager.get_secure_random_bytes()); - let flow = OffersMessageFlow::new( + let flow = Arc::new(OffersMessageFlow::new( ChainHash::using_genesis_block(network), best_block, keys_manager.get_node_id(Recipient::Node).expect("node ID"), @@ -56,24 +103,125 @@ impl NodeOffersMessageHandler { keys_manager.get_receive_auth_key(), secp_ctx.clone(), message_router, - logger, - ); + Arc::clone(&logger), + )); Self { channel_manager, keys_manager, router, flow, - secp_ctx, + secp_ctx: Arc::new(secp_ctx), best_block: RwLock::new(best_block), + jit_dependencies: OnceLock::new(), + logger, } } + pub(crate) fn initialize_jit_handling( + &self, runtime: Arc, lsps2_client: Arc>>, + connection_manager: Weak>>, + onion_messenger: Weak, + ) { + let dependencies = JitInvoiceRequestDependencies { + runtime, + lsps2_client, + connection_manager, + onion_messenger, + }; + assert!( + self.jit_dependencies.set(dependencies).is_ok(), + "JIT invoice handling must only be initialized once" + ); + } + #[cfg(feature = "chain-bitcoind")] pub(crate) fn current_best_block(&self) -> BlockLocator { *self.best_block.read().expect("lock") } } +fn build_invoice( + flow: &NodeOffersFlow, channel_manager: &ChannelManager, keys_manager: &KeysManager, + router: &Router, secp_ctx: &Secp256k1, + invoice_request: &InvoiceRequestVerifiedFromOffer, + payment_metadata: Option>>, allow_mpp: bool, + payment_info: &InvoicePaymentInfo, +) -> Result<(Bolt12Invoice, MessageContext), InvoiceBuildError> { + let get_payment_info = |amount_msats, relative_expiry| { + if let Some((cached_amount_msats, payment_hash, payment_secret)) = payment_info.get() { + return (*cached_amount_msats == amount_msats) + .then_some((*payment_hash, *payment_secret)) + .ok_or(Bolt12SemanticError::InvalidAmount); + } + let (payment_hash, payment_secret, _) = channel_manager + .create_inbound_payment(Some(amount_msats), relative_expiry, None, None) + .map_err(|_| Bolt12SemanticError::InvalidAmount)?; + let _ = payment_info.set((amount_msats, payment_hash, payment_secret)); + Ok((payment_hash, payment_secret)) + }; + + match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { + let (builder, context) = flow + .create_invoice_builder_from_invoice_request_with_keys( + router, + request, + channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceBuildError::Semantic)?; + let builder = if allow_mpp { builder } else { builder.disallow_mpp() }; + builder + .build_and_sign(secp_ctx) + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error))) + .map(|invoice| (invoice, context)) + }, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => { + let (builder, context) = flow + .create_invoice_builder_from_invoice_request_without_keys( + router, + request, + channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceBuildError::Semantic)?; + let builder = if allow_mpp { builder } else { builder.disallow_mpp() }; + let invoice = builder + .build() + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error)))?; + invoice + .sign(|invoice: &UnsignedBolt12Invoice| keys_manager.sign_bolt12_invoice(invoice)) + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error))) + .map(|invoice| (invoice, context)) + }, + } +} + +fn jit_invoice_request( + invoice_request: &InvoiceRequestVerifiedFromOffer, +) -> Result { + jit_invoice_request_from_fields( + invoice_request.amount(), + invoice_request.amount_msats(), + invoice_request.absolute_expiry().map(|expiry| expiry.as_secs()), + ) +} + +fn jit_invoice_request_from_fields( + offer_amount: Option, amount_msat: Option, absolute_expiry: Option, +) -> Result { + let amount_msat = amount_msat.ok_or(Bolt12SemanticError::MissingAmount)?; + match offer_amount { + Some(Amount::Bitcoin { .. }) => { + Ok(JitInvoiceRequest::Fixed { amount_msat, absolute_expiry }) + }, + Some(Amount::Currency { .. }) => Err(Bolt12SemanticError::UnsupportedCurrency), + None => Ok(JitInvoiceRequest::Variable { amount_msat, absolute_expiry }), + } +} + impl OffersMessageHandler for NodeOffersMessageHandler { fn handle_message( &self, message: OffersMessage, context: Option, responder: Option, @@ -103,61 +251,131 @@ impl OffersMessageHandler for NodeOffersMessageHandler { Ok(InvreqResponseInstructions::SendStaticInvoice { .. }) | Err(()) => return None, }; - let get_payment_info = |amount_msats, relative_expiry| { - self.channel_manager - .create_inbound_payment(Some(amount_msats), relative_expiry, None, None) - .map_err(|_| Bolt12SemanticError::InvalidAmount) - .map(|(payment_hash, payment_secret, _)| (payment_hash, payment_secret)) - }; - - let result = match invoice_request { - InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => self - .flow - .create_invoice_builder_from_invoice_request_with_keys( - self.router.as_ref(), - &request, - self.channel_manager.list_usable_channels(), - get_payment_info, - payment_metadata, - ) - .map_err(InvoiceError::from) - .and_then(|(builder, context)| { - builder - .build_and_sign(&self.secp_ctx) - .map_err(InvoiceError::from) - .map(|invoice| (invoice, context)) - }), - InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => self - .flow - .create_invoice_builder_from_invoice_request_without_keys( - self.router.as_ref(), - &request, - self.channel_manager.list_usable_channels(), - get_payment_info, - payment_metadata, - ) - .map_err(InvoiceError::from) - .and_then(|(builder, context)| { - builder - .build() - .map_err(InvoiceError::from) - .and_then(|invoice| { - invoice - .sign(|invoice: &UnsignedBolt12Invoice| { - self.keys_manager.sign_bolt12_invoice(invoice) - }) - .map_err(InvoiceError::from) - }) - .map(|invoice| (invoice, context)) - }), - }; + // The initial response only contains ordinary payment paths, for which MPP remains useful + // regardless of whether the offer fixed the amount. Only disable MPP below when rebuilding a + // variable-amount response with an LSPS2 JIT path. + let allow_mpp = true; + let payment_info = InvoicePaymentInfo::new(); + let result = build_invoice( + self.flow.as_ref(), + self.channel_manager.as_ref(), + self.keys_manager.as_ref(), + self.router.as_ref(), + self.secp_ctx.as_ref(), + &invoice_request, + payment_metadata.clone(), + allow_mpp, + &payment_info, + ); - Some(match result { + match result { Ok((invoice, context)) => { - (OffersMessage::Invoice(invoice), responder.respond_with_reply_path(context)) + return Some(( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + )); }, - Err(error) => (OffersMessage::InvoiceError(error), responder.respond()), - }) + Err(InvoiceBuildError::Semantic(Bolt12SemanticError::MissingPaths)) => {}, + Err(error) => { + return Some(( + OffersMessage::InvoiceError(error.into_invoice_error()), + responder.respond(), + )); + }, + } + + // Only an actual lack of ordinary blinded paths triggers LSPS2. Negotiation is kept out of + // the synchronous message-handler and router APIs; the verified request and reply path are + // moved into a future which sends the response directly when its single-use lease is ready. + let dependencies = match self.jit_dependencies.get() { + Some(dependencies) => dependencies, + None => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from( + Bolt12SemanticError::MissingPaths, + )), + responder.respond(), + )); + }, + }; + let jit_request = match jit_invoice_request(&invoice_request) { + Ok(request) => request, + Err(error) => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from(error)), + responder.respond(), + )); + }, + }; + let (Some(connection_manager), Some(onion_messenger)) = + (dependencies.connection_manager.upgrade(), dependencies.onion_messenger.upgrade()) + else { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "JIT invoice handling is unavailable".to_owned(), + )), + responder.respond(), + )); + }; + + let lsps2_client = Arc::clone(&dependencies.lsps2_client); + let flow = Arc::clone(&self.flow); + let channel_manager = Arc::clone(&self.channel_manager); + let keys_manager = Arc::clone(&self.keys_manager); + let router = Arc::clone(&self.router); + let secp_ctx = Arc::clone(&self.secp_ctx); + let logger = Arc::clone(&self.logger); + dependencies.runtime.spawn_cancellable_background_task(async move { + let response = Arc::clone(&lsps2_client) + .prepare_invoice_response(jit_request, connection_manager) + .await; + let (message, instructions) = match response { + Ok(jit_metadata) => { + let mut merged_metadata = payment_metadata.unwrap_or_default(); + merged_metadata.extend(jit_metadata); + let invoice_result = build_invoice( + flow.as_ref(), + channel_manager.as_ref(), + keys_manager.as_ref(), + router.as_ref(), + secp_ctx.as_ref(), + &invoice_request, + Some(merged_metadata), + jit_request.allow_mpp(), + &payment_info, + ); + let invoice_result = commit_cache_target_on_success(invoice_result, || { + let lsps2_client = Arc::clone(&lsps2_client); + async move { lsps2_client.commit_invoice_cache_target(jit_request).await } + }) + .await; + match invoice_result { + Ok((invoice, context)) => ( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + ), + Err(error) => ( + OffersMessage::InvoiceError(error.into_invoice_error()), + responder.respond(), + ), + } + }, + Err(error) => { + log_error!(logger, "Failed preparing LSPS2 invoice response: {}", error); + ( + OffersMessage::InvoiceError(InvoiceError::from_string( + "Failed preparing JIT invoice".to_owned(), + )), + responder.respond(), + ) + }, + }; + if let Err(error) = onion_messenger.handle_onion_message_response(message, instructions) + { + log_error!(logger, "Failed sending LSPS2 invoice response: {:?}", error); + } + }); + None } fn release_pending_messages(&self) -> Vec<(OffersMessage, MessageSendInstructions)> { @@ -194,3 +412,105 @@ impl Listen for NodeOffersMessageHandler { *self.best_block.write().expect("lock") = fork_point_block; } } + +#[cfg(test)] +mod tests { + use core::sync::atomic::{AtomicBool, Ordering}; + use std::num::NonZeroU64; + + use bitcoin::secp256k1::{PublicKey, SecretKey}; + use lightning::ln::channelmanager::PaymentId; + use lightning::ln::inbound_payment::ExpandedKey; + use lightning::offers::nonce::Nonce; + use lightning::offers::offer::{OfferBuilder, Quantity}; + + use super::*; + + struct FixedEntropy; + + impl EntropySource for FixedEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } + + fn recipient_pubkey() -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[43; 32]).unwrap()) + } + + #[tokio::test] + async fn failed_invoice_does_not_commit_cache_target() { + let committed = AtomicBool::new(false); + let result = commit_cache_target_on_success(Err::<(), _>(()), || async { + committed.store(true, Ordering::Release); + }) + .await; + + assert!(result.is_err()); + assert!( + !committed.load(Ordering::Acquire), + "failed invoice construction committed a lease cache target" + ); + } + + #[tokio::test] + async fn successful_invoice_commits_cache_target() { + let committed = AtomicBool::new(false); + let result = commit_cache_target_on_success(Ok::<_, ()>(()), || async { + committed.store(true, Ordering::Release); + }) + .await; + + assert!(result.is_ok()); + assert!(committed.load(Ordering::Acquire)); + } + + #[test] + fn fixed_offer_request_uses_quantity_resolved_amount() { + let expanded_key = ExpandedKey::new([44; 32]); + let nonce = Nonce::from_entropy_source(&FixedEntropy); + let secp_ctx = Secp256k1::new(); + let offer = OfferBuilder::new(recipient_pubkey()) + .amount_msats(1_000) + .supported_quantity(Quantity::Bounded(NonZeroU64::new(10).unwrap())) + .build() + .unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, PaymentId([45; 32])) + .unwrap() + .quantity(3) + .unwrap() + .build_and_sign() + .unwrap(); + + assert_eq!( + jit_invoice_request_from_fields(offer.amount(), invoice_request.amount_msats(), None,) + .unwrap(), + JitInvoiceRequest::Fixed { amount_msat: 3_000, absolute_expiry: None } + ); + } + + #[test] + fn variable_offer_request_disables_mpp() { + let expanded_key = ExpandedKey::new([46; 32]); + let nonce = Nonce::from_entropy_source(&FixedEntropy); + let secp_ctx = Secp256k1::new(); + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, PaymentId([47; 32])) + .unwrap() + .amount_msats(2_500) + .unwrap() + .build_and_sign() + .unwrap(); + let request = + jit_invoice_request_from_fields(offer.amount(), invoice_request.amount_msats(), None) + .unwrap(); + + assert_eq!( + request, + JitInvoiceRequest::Variable { amount_msat: 2_500, absolute_expiry: None } + ); + assert!(!request.allow_mpp()); + } +} From eabefbaff1c835bd348d83d8d0bef59bfeb60d75 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 12 Nov 2026 08:19:44 +0100 Subject: [PATCH 18/38] f Expire targets of offers without expiry A payer may request any amount at or above the offer's, so each distinct amount requested from an offer that never expires committed its own permanent lease cache target, refilled at every startup. Let targets committed for invoice requests lapse after a day unless the offer sets an expiry. Further requests for the same amount extend them, and targets we registered ourselves remain permanent. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 36 +++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 0a6bcc6b70..2c50b1daed 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -71,6 +71,13 @@ where const DEFAULT_BOLT12_INVOICE_EXPIRY_SECS: u32 = 2 * 60 * 60; +/// The time for which we keep a lease cache target committed for an invoice request if its offer +/// never expires. +/// +/// A payer may request any amount at or above the offer's, so each such amount would otherwise leave +/// behind a target that is refilled indefinitely. +const INVOICE_REQUEST_CACHE_TARGET_EXPIRY_SECS: u64 = 24 * 60 * 60; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum JitInvoiceRequest { Fixed { amount_msat: u64, absolute_expiry: Option }, @@ -83,14 +90,17 @@ impl JitInvoiceRequest { } fn cache_target(&self) -> (LeaseCacheTargetId, Option) { - match *self { + let (id, absolute_expiry) = match *self { Self::Fixed { amount_msat, absolute_expiry } => { (LeaseCacheTargetId::Fixed { amount_msat }, absolute_expiry) }, Self::Variable { absolute_expiry, .. } => { (LeaseCacheTargetId::Variable, absolute_expiry) }, - } + }; + let absolute_expiry = absolute_expiry + .unwrap_or_else(|| now_secs().saturating_add(INVOICE_REQUEST_CACHE_TARGET_EXPIRY_SECS)); + (id, Some(absolute_expiry)) } } @@ -872,6 +882,28 @@ mod tests { assert_eq!(*restored.lock().unwrap(), Some(42)); } + #[test] + fn invoice_request_cache_targets_expire_by_default() { + let before = now_secs(); + for request in [ + JitInvoiceRequest::Fixed { amount_msat: 1_000, absolute_expiry: None }, + JitInvoiceRequest::Variable { amount_msat: 1_000, absolute_expiry: None }, + ] { + let (_, absolute_expiry) = request.cache_target(); + let absolute_expiry = + absolute_expiry.expect("a target for an offer without expiry must still expire"); + assert!(absolute_expiry >= before + 24 * 60 * 60); + assert!(absolute_expiry <= now_secs() + 24 * 60 * 60); + } + + for request in [ + JitInvoiceRequest::Fixed { amount_msat: 1_000, absolute_expiry: Some(42) }, + JitInvoiceRequest::Variable { amount_msat: 1_000, absolute_expiry: Some(42) }, + ] { + assert_eq!(request.cache_target().1, Some(42), "the offer's expiry must be kept"); + } + } + #[test] fn cache_targets_require_lsps2_liquidity_source() { use crate::builder::NodeBuilder; From 477b7f52c9ddf447f20d38729bb4f590436b4205 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 20 Jul 2026 15:04:33 +0200 Subject: [PATCH 19/38] 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 | 475 +++++++++++++++++++++++++++++++++-- 1 file changed, 459 insertions(+), 16 deletions(-) diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index 61150bdf45..2c419bb521 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -5,12 +5,14 @@ // http://opensource.org/licenses/MIT>, at your option. You may not use this file except in // accordance with one or both of these licenses. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::future::Future; -use std::sync::{Arc, OnceLock, RwLock, Weak}; +use std::sync::{Arc, Mutex, OnceLock, RwLock, Weak}; +use std::time::Instant; use bitcoin::block::Header; use bitcoin::constants::ChainHash; +use bitcoin::hashes::{sha256, Hash}; use bitcoin::secp256k1::Secp256k1; use bitcoin::{Block, BlockHash, Network, Txid}; use lightning::blinded_path::message::{MessageContext, OffersContext}; @@ -29,6 +31,7 @@ use lightning::onion_message::offers::{OffersMessage, OffersMessageHandler}; use lightning::sign::{EntropySource, NodeSigner, Recipient}; use lightning::types::payment::{PaymentHash, PaymentSecret}; use lightning::util::logger::Logger as _; +use lightning::util::ser::Writeable; use crate::connection::ConnectionManager; use crate::liquidity::client::lsps2::{JitInvoiceRequest, LSPS2Client}; @@ -66,6 +69,135 @@ where result } +const MAX_PENDING_JIT_INVOICE_REQUESTS: usize = 100; +const MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST: usize = 10; +const MAX_PENDING_JIT_INVOICE_RESPONDERS: usize = + MAX_PENDING_JIT_INVOICE_REQUESTS * MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST; +const MAX_COMPLETED_JIT_INVOICE_RESPONSES: usize = 100; + +struct PendingJitInvoiceRequests { + state: Arc>>, + request_limit: usize, + responder_limit: usize, + per_request_limit: usize, +} + +struct PendingJitInvoiceRequestState { + requests: HashMap>, + completed_responses: HashMap, + responder_count: usize, +} + +impl PendingJitInvoiceRequestState { + fn expire_completed_responses(&mut self) { + self.completed_responses.retain(|_, (_, invoice, _)| !invoice.is_expired()); + } +} + +impl PendingJitInvoiceRequests { + fn new(request_limit: usize, responder_limit: usize, per_request_limit: usize) -> Self { + Self { + state: Arc::new(Mutex::new(PendingJitInvoiceRequestState { + requests: HashMap::new(), + completed_responses: HashMap::new(), + responder_count: 0, + })), + request_limit, + responder_limit, + per_request_limit, + } + } + + fn cached_response( + &self, request_id: &sha256::Hash, + ) -> Option<(Bolt12Invoice, MessageContext)> { + let mut state = self.state.lock().expect("lock"); + state.expire_completed_responses(); + state + .completed_responses + .get(request_id) + .map(|(_, invoice, context)| (invoice.clone(), context.clone())) + } + + fn register( + &self, request_id: sha256::Hash, responder: T, + ) -> PendingJitInvoiceRequestRegistration { + let mut state = self.state.lock().expect("lock"); + state.expire_completed_responses(); + if let Some((_, invoice, context)) = state.completed_responses.get(&request_id) { + return PendingJitInvoiceRequestRegistration::Completed( + invoice.clone(), + context.clone(), + responder, + ); + } + let responder_limit_reached = state.responder_count >= self.responder_limit; + if let Some(responders) = state.requests.get_mut(&request_id) { + if responder_limit_reached || responders.len() >= self.per_request_limit { + return PendingJitInvoiceRequestRegistration::ReplayLimitReached; + } + responders.push(responder); + state.responder_count += 1; + return PendingJitInvoiceRequestRegistration::Joined; + } + if state.requests.len() >= self.request_limit || responder_limit_reached { + return PendingJitInvoiceRequestRegistration::AtCapacity(responder); + } + state.requests.insert(request_id, vec![responder]); + state.responder_count += 1; + PendingJitInvoiceRequestRegistration::Started(PendingJitInvoiceRequest { + request_id: Some(request_id), + state: Arc::clone(&self.state), + }) + } +} + +enum PendingJitInvoiceRequestRegistration { + Started(PendingJitInvoiceRequest), + Completed(Bolt12Invoice, MessageContext, T), + Joined, + ReplayLimitReached, + AtCapacity(T), +} + +struct PendingJitInvoiceRequest { + request_id: Option, + state: Arc>>, +} + +impl PendingJitInvoiceRequest { + fn complete(mut self, response: Option<(Bolt12Invoice, MessageContext)>) -> Vec { + self.finish(response) + } + + fn finish(&mut self, response: Option<(Bolt12Invoice, MessageContext)>) -> Vec { + let Some(request_id) = self.request_id.take() else { return Vec::new() }; + let mut state = self.state.lock().expect("lock"); + let responders = state.requests.remove(&request_id).unwrap_or_default(); + state.responder_count -= responders.len(); + if let Some((invoice, context)) = response { + state.expire_completed_responses(); + if state.completed_responses.len() >= MAX_COMPLETED_JIT_INVOICE_RESPONSES { + let oldest_request_id = *state + .completed_responses + .iter() + .min_by_key(|(_, (completed_at, _, _))| *completed_at) + .expect("non-empty cache") + .0; + state.completed_responses.remove(&oldest_request_id); + } + state.completed_responses.insert(request_id, (Instant::now(), invoice, context)); + } + responders + } +} + +impl Drop for PendingJitInvoiceRequest { + fn drop(&mut self) { + self.finish(None); + } +} + struct JitInvoiceRequestDependencies { runtime: Arc, lsps2_client: Arc>>, @@ -82,6 +214,7 @@ pub(crate) struct NodeOffersMessageHandler { secp_ctx: Arc>, best_block: RwLock, jit_dependencies: OnceLock, + pending_jit_invoice_requests: PendingJitInvoiceRequests, logger: Arc, } @@ -113,6 +246,11 @@ impl NodeOffersMessageHandler { secp_ctx: Arc::new(secp_ctx), best_block: RwLock::new(best_block), jit_dependencies: OnceLock::new(), + pending_jit_invoice_requests: PendingJitInvoiceRequests::new( + MAX_PENDING_JIT_INVOICE_REQUESTS, + MAX_PENDING_JIT_INVOICE_RESPONDERS, + MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST, + ), logger, } } @@ -238,6 +376,7 @@ impl OffersMessageHandler for NodeOffersMessageHandler { responder, ); } + let invoice_request_id = sha256::Hash::hash(&invoice_request.encode()); let responder = responder?; let payment_metadata = match context.as_ref() { @@ -251,6 +390,17 @@ impl OffersMessageHandler for NodeOffersMessageHandler { Ok(InvreqResponseInstructions::SendStaticInvoice { .. }) | Err(()) => return None, }; + // Replays must reuse the issued invoice even if ordinary payment paths became available + // after its JIT lease was negotiated. Verification above still authenticates every request. + if let Some((invoice, context)) = + self.pending_jit_invoice_requests.cached_response(&invoice_request_id) + { + return Some(( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + )); + } + // The initial response only contains ordinary payment paths, for which MPP remains useful // regardless of whether the offer fixed the amount. Only disable MPP below when rebuilding a // variable-amount response with an LSPS2 JIT path. @@ -317,6 +467,26 @@ impl OffersMessageHandler for NodeOffersMessageHandler { responder.respond(), )); }; + let pending_request = + match self.pending_jit_invoice_requests.register(invoice_request_id, responder) { + PendingJitInvoiceRequestRegistration::Started(pending_request) => pending_request, + PendingJitInvoiceRequestRegistration::Completed(invoice, context, responder) => { + return Some(( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + )); + }, + PendingJitInvoiceRequestRegistration::Joined => return None, + PendingJitInvoiceRequestRegistration::ReplayLimitReached => return None, + PendingJitInvoiceRequestRegistration::AtCapacity(responder) => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "Too many pending JIT invoice requests".to_owned(), + )), + responder.respond(), + )); + }, + }; let lsps2_client = Arc::clone(&dependencies.lsps2_client); let flow = Arc::clone(&self.flow); @@ -329,7 +499,7 @@ impl OffersMessageHandler for NodeOffersMessageHandler { let response = Arc::clone(&lsps2_client) .prepare_invoice_response(jit_request, connection_manager) .await; - let (message, instructions) = match response { + let (message, reply_context) = match response { Ok(jit_metadata) => { let mut merged_metadata = payment_metadata.unwrap_or_default(); merged_metadata.extend(jit_metadata); @@ -350,14 +520,10 @@ impl OffersMessageHandler for NodeOffersMessageHandler { }) .await; match invoice_result { - Ok((invoice, context)) => ( - OffersMessage::Invoice(invoice), - responder.respond_with_reply_path(context), - ), - Err(error) => ( - OffersMessage::InvoiceError(error.into_invoice_error()), - responder.respond(), - ), + Ok((invoice, context)) => (OffersMessage::Invoice(invoice), Some(context)), + Err(error) => { + (OffersMessage::InvoiceError(error.into_invoice_error()), None) + }, } }, Err(error) => { @@ -366,13 +532,26 @@ impl OffersMessageHandler for NodeOffersMessageHandler { OffersMessage::InvoiceError(InvoiceError::from_string( "Failed preparing JIT invoice".to_owned(), )), - responder.respond(), + None, ) }, }; - if let Err(error) = onion_messenger.handle_onion_message_response(message, instructions) - { - log_error!(logger, "Failed sending LSPS2 invoice response: {:?}", error); + let cached_response = match (&message, &reply_context) { + (OffersMessage::Invoice(invoice), Some(context)) => { + Some((invoice.clone(), context.clone())) + }, + _ => None, + }; + for responder in pending_request.complete(cached_response) { + let instructions = match reply_context.as_ref() { + Some(context) => responder.respond_with_reply_path(context.clone()), + None => responder.respond(), + }; + if let Err(error) = + onion_messenger.handle_onion_message_response(message.clone(), instructions) + { + log_error!(logger, "Failed sending LSPS2 invoice response: {:?}", error); + } } }); None @@ -417,12 +596,16 @@ impl Listen for NodeOffersMessageHandler { mod tests { use core::sync::atomic::{AtomicBool, Ordering}; use std::num::NonZeroU64; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; - use bitcoin::secp256k1::{PublicKey, SecretKey}; + use bitcoin::secp256k1::{Keypair, PublicKey, SecretKey}; + use lightning::blinded_path::payment::{BlindedPayInfo, BlindedPaymentPath}; + use lightning::blinded_path::BlindedHop; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; use lightning::offers::nonce::Nonce; use lightning::offers::offer::{OfferBuilder, Quantity}; + use lightning::types::features::BlindedHopFeatures; use super::*; @@ -438,6 +621,48 @@ mod tests { PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[43; 32]).unwrap()) } + fn invoice_response(created_at: Duration) -> (Bolt12Invoice, MessageContext) { + let expanded_key = ExpandedKey::new([44; 32]); + let nonce = Nonce::from_entropy_source(&FixedEntropy); + let secp_ctx = Secp256k1::new(); + let payment_hash = PaymentHash([45; 32]); + let path = BlindedPaymentPath::from_blinded_path_and_payinfo( + recipient_pubkey(), + recipient_pubkey(), + vec![BlindedHop { + blinded_node_id: recipient_pubkey(), + encrypted_payload: vec![0; 43], + }], + BlindedPayInfo { + fee_base_msat: 0, + fee_proportional_millionths: 0, + cltv_expiry_delta: 18, + htlc_minimum_msat: 1, + htlc_maximum_msat: 100_000, + features: BlindedHopFeatures::empty(), + }, + ); + let invoice = OfferBuilder::new(recipient_pubkey()) + .amount_msats(1_000) + .build() + .unwrap() + .request_invoice(&expanded_key, nonce, &secp_ctx, PaymentId([46; 32])) + .unwrap() + .build_and_sign() + .unwrap() + .respond_with_no_std(vec![path], payment_hash, created_at) + .unwrap() + .build() + .unwrap() + .sign(|invoice: &UnsignedBolt12Invoice| { + let keypair = + Keypair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[43; 32]).unwrap()); + Ok(secp_ctx.sign_schnorr_no_aux_rand(invoice.tagged_hash().as_digest(), &keypair)) + }) + .unwrap(); + (invoice, MessageContext::Offers(OffersContext::InboundPayment { payment_hash })) + } + #[tokio::test] async fn failed_invoice_does_not_commit_cache_target() { let committed = AtomicBool::new(false); @@ -513,4 +738,222 @@ mod tests { ); assert!(!request.allow_mpp()); } + + fn started_request( + registration: PendingJitInvoiceRequestRegistration, + ) -> PendingJitInvoiceRequest { + match registration { + PendingJitInvoiceRequestRegistration::Started(request) => request, + _ => panic!("expected a new pending JIT invoice request"), + } + } + + #[test] + fn completed_jit_invoice_request_reuses_response() { + let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let first = started_request(pending_requests.register(request_id, 1)); + let response = invoice_response(SystemTime::now().duration_since(UNIX_EPOCH).unwrap()); + assert_eq!(first.complete(Some(response.clone())), vec![1]); + + assert!( + !matches!( + pending_requests.register(request_id, 2), + PendingJitInvoiceRequestRegistration::Started(_) + ), + "a completed invoice request replay started a new lease and invoice" + ); + let (invoice, context) = pending_requests.cached_response(&request_id).unwrap(); + assert_eq!(invoice.encode(), response.0.encode()); + assert_eq!(context.encode(), response.1.encode()); + match pending_requests.register(request_id, 3) { + PendingJitInvoiceRequestRegistration::Completed(invoice, context, responder) => { + assert_eq!(invoice.encode(), response.0.encode()); + assert_eq!(context.encode(), response.1.encode()); + assert_eq!(responder, 3); + }, + _ => panic!("expected the same response with the replay's responder"), + } + } + + #[test] + fn completed_jit_invoice_responses_are_bounded() { + let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); + let response = invoice_response(SystemTime::now().duration_since(UNIX_EPOCH).unwrap()); + let oldest_id = sha256::Hash::from_byte_array([0; 32]); + let first = started_request(pending_requests.register(oldest_id, 0)); + assert_eq!(first.complete(Some(response.clone())), vec![0]); + pending_requests + .state + .lock() + .unwrap() + .completed_responses + .get_mut(&oldest_id) + .unwrap() + .0 -= Duration::from_secs(1); + for index in 1..=MAX_COMPLETED_JIT_INVOICE_RESPONSES { + let request_id = sha256::Hash::hash(&index.to_be_bytes()); + let request = started_request(pending_requests.register(request_id, index)); + assert_eq!(request.complete(Some(response.clone())), vec![index]); + } + assert!(pending_requests.cached_response(&oldest_id).is_none()); + assert_eq!( + pending_requests.state.lock().unwrap().completed_responses.len(), + MAX_COMPLETED_JIT_INVOICE_RESPONSES + ); + } + + #[test] + fn completed_jit_invoice_responses_follow_invoice_expiry() { + let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let first = started_request(pending_requests.register(request_id, 1)); + let elapsed = Duration::from_secs(60 * 60); + let response = + invoice_response(SystemTime::now().duration_since(UNIX_EPOCH).unwrap() - elapsed); + assert!(!response.0.is_expired()); + assert_eq!(first.complete(Some(response.clone())), vec![1]); + pending_requests + .state + .lock() + .unwrap() + .completed_responses + .get_mut(&request_id) + .unwrap() + .0 -= elapsed; + assert!( + pending_requests.cached_response(&request_id).is_some(), + "an unexpired invoice response was discarded before a delayed replay" + ); + match pending_requests.register(request_id, 2) { + PendingJitInvoiceRequestRegistration::Completed(invoice, context, responder) => { + assert_eq!(invoice.encode(), response.0.encode()); + assert_eq!(context.encode(), response.1.encode()); + assert_eq!(responder, 2); + }, + _ => panic!("a delayed replay did not reuse its unexpired invoice"), + } + + let expired_request_id = sha256::Hash::from_byte_array([2; 32]); + let expired = started_request(pending_requests.register(expired_request_id, 3)); + let expired_response = invoice_response(Duration::ZERO); + assert!(expired_response.0.is_expired()); + assert_eq!(expired.complete(Some(expired_response)), vec![3]); + assert!(pending_requests.cached_response(&expired_request_id).is_none()); + assert!(pending_requests.cached_response(&request_id).is_some()); + assert!(matches!( + pending_requests.register(expired_request_id, 4), + PendingJitInvoiceRequestRegistration::Started(_) + )); + } + + #[test] + fn failed_jit_invoice_requests_can_retry() { + let pending_requests = PendingJitInvoiceRequests::new(1, 1, 1); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let first = started_request(pending_requests.register(request_id, 1)); + assert_eq!(first.complete(None), vec![1]); + assert!(matches!( + pending_requests.register(request_id, 2), + PendingJitInvoiceRequestRegistration::Started(_) + )); + } + + #[test] + fn pending_jit_invoice_requests_are_bounded() { + let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); + let first = + started_request(pending_requests.register(sha256::Hash::from_byte_array([1; 32]), 1)); + let _second = + started_request(pending_requests.register(sha256::Hash::from_byte_array([2; 32]), 2)); + + assert!(matches!( + pending_requests.register(sha256::Hash::from_byte_array([3; 32]), 3), + PendingJitInvoiceRequestRegistration::AtCapacity(3) + )); + + drop(first); + assert!(matches!( + pending_requests.register(sha256::Hash::from_byte_array([3; 32]), 3), + PendingJitInvoiceRequestRegistration::Started(_) + )); + } + + #[test] + fn replay_responders_leave_room_for_other_requests() { + let pending_requests = PendingJitInvoiceRequests::new( + MAX_PENDING_JIT_INVOICE_REQUESTS, + MAX_PENDING_JIT_INVOICE_RESPONDERS, + MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST, + ); + let mut requests = Vec::new(); + for index in 0..MAX_PENDING_JIT_INVOICE_REQUESTS { + let request_id = sha256::Hash::hash(&index.to_be_bytes()); + let registration = pending_requests.register(request_id, 0); + assert!( + matches!(registration, PendingJitInvoiceRequestRegistration::Started(_)), + "replay responders exhausted capacity before all request slots were used" + ); + requests.push(started_request(registration)); + for responder in 1..MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST { + assert!(matches!( + pending_requests.register(request_id, responder), + PendingJitInvoiceRequestRegistration::Joined + )); + } + assert!(matches!( + pending_requests.register(request_id, MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST), + PendingJitInvoiceRequestRegistration::ReplayLimitReached + )); + } + let next_id = sha256::Hash::hash(&MAX_PENDING_JIT_INVOICE_REQUESTS.to_be_bytes()); + assert!(matches!( + pending_requests.register(next_id, 0), + PendingJitInvoiceRequestRegistration::AtCapacity(0) + )); + + let responders = requests.pop().unwrap().complete(None); + assert_eq!(responders, (0..MAX_RESPONDERS_PER_JIT_INVOICE_REQUEST).collect::>()); + assert!(matches!( + pending_requests.register(next_id, 0), + PendingJitInvoiceRequestRegistration::Started(_) + )); + } + + #[test] + fn pending_jit_invoice_requests_fan_out_replays() { + let pending_requests = PendingJitInvoiceRequests::new(2, 2, 2); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let first = started_request(pending_requests.register(request_id, 1)); + + assert!( + matches!( + pending_requests.register(request_id, 2), + PendingJitInvoiceRequestRegistration::Joined + ), + "an in-flight invoice request replay's responder was discarded" + ); + assert!(matches!( + pending_requests.register(sha256::Hash::from_byte_array([2; 32]), 3), + PendingJitInvoiceRequestRegistration::AtCapacity(3) + )); + assert_eq!(first.complete(None), vec![1, 2]); + } + + #[test] + fn pending_jit_invoice_requests_bound_replay_responders() { + let pending_requests = PendingJitInvoiceRequests::new(3, 3, 2); + let request_id = sha256::Hash::from_byte_array([1; 32]); + let first = started_request(pending_requests.register(request_id, 1)); + + assert!(matches!( + pending_requests.register(request_id, 2), + PendingJitInvoiceRequestRegistration::Joined + )); + assert!(matches!( + pending_requests.register(request_id, 3), + PendingJitInvoiceRequestRegistration::ReplayLimitReached + )); + assert_eq!(first.complete(None), vec![1, 2]); + } } From ded5170965a615235fa371e2993be9b686df6f73 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:39:17 +0200 Subject: [PATCH 20/38] 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 14fff4b624..2ed0846869 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 edacea2312f92af3721ff79794116040e1ad72eb Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:43:24 +0200 Subject: [PATCH 21/38] bolt12: Validate LSPS2 opening fees Read fee limits from BOLT12 payment context metadata and reject unsupported withholding. Record accepted fees on inbound offer payments. Co-Authored-By: HAL 9000 --- CHANGELOG.md | 6 ++ src/event.rs | 135 ++++++++++++++++++++++++++++---- src/payment/bolt12/mod.rs | 10 ++- src/payment/metadata.rs | 16 ++++ src/payment/store.rs | 120 +++++++++++++++++++++++++--- tests/integration_tests_rust.rs | 4 + 6 files changed, 261 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af30c9ddb0..2be1a28b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,12 @@ - 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. +- BOLT 12 payments can now be received through LSPS2 just-in-time channels. If an LSPS2 + liquidity source is configured and existing inbound liquidity is insufficient when an invoice + is requested for an offer, the payment is received through a just-in-time channel and the + LSP's opening fee is deducted from the amount received. +- `PaymentKind::Bolt12Offer` now exposes `counterparty_skimmed_fee_msat` for the fee deducted by + an LSP when an inbound payment is received through an LSPS2 just-in-time channel. - The Bitcoin Core RPC and REST chain-source builder methods now accept an optional `wallet_rescan_from_height` argument. Passing a height lets fresh wallets rescan from a known birthday block instead of checkpointing at the current tip, which is useful when restoring a diff --git a/src/event.rs b/src/event.rs index 9e50217e0e..b84306e389 100644 --- a/src/event.rs +++ b/src/event.rs @@ -7,7 +7,7 @@ use core::future::Future; use core::task::{Poll, Waker}; -use std::collections::{HashMap, VecDeque}; +use std::collections::{BTreeMap, HashMap, VecDeque}; use std::ops::Deref; use std::sync::{Arc, Mutex}; @@ -894,15 +894,40 @@ where } fn lsps2_max_total_opening_fee_msat( - payment_metadata: &[u8], gross_amount_msat: u64, + 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| { - // If it's a variable amount payment, compute the actual fee. compute_opening_fee( - gross_amount_msat, + payment_size_msat, lsps2_parameters.min_opening_fee_msat, max_prop_fee, ) @@ -914,7 +939,7 @@ where (Some(total_limit), None) => Some(total_limit), (None, proportional_limit) => proportional_limit, } - .filter(|fee| *fee <= gross_amount_msat) + .filter(|fee| *fee <= payment_size_msat) } async fn resolve_inbound_payment_id( @@ -1285,17 +1310,28 @@ where .as_ref() .and_then(|fields| fields.payment_metadata.as_ref()) .and_then(|metadata| { - let gross_amount_msat = - amount_msat.checked_add(counterparty_skimmed_fee_msat)?; - Self::lsps2_max_total_opening_fee_msat(metadata, gross_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, ); @@ -1318,7 +1354,7 @@ where if let Some(info) = payment_info.as_ref() { match &info.kind { - PaymentKind::Bolt11 { .. } => { + PaymentKind::Bolt11 { .. } | PaymentKind::Bolt12Offer { .. } => { let update = PaymentDetailsUpdate { amount_msat: Some(Some(amount_msat)), counterparty_skimmed_fee_msat: Some( @@ -1332,7 +1368,7 @@ where return Err(ReplayEvent()); } }, - _ => debug_assert_eq!(counterparty_skimmed_fee_msat, 0, "We only expect the counterparty to get away with withholding fees for BOLT11 payments."), + _ => debug_assert_eq!(counterparty_skimmed_fee_msat, 0, "We only expect the counterparty to withhold fees for LSPS2 invoice payments."), } } @@ -1488,6 +1524,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, @@ -1658,6 +1696,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, @@ -3068,6 +3107,7 @@ mod tests { EventHandler::>::lsps2_max_total_opening_fee_msat( &metadata.encode(), gross_amount_msat, + 0, ), expected, "The total fee limit must not exceed the gross payment amount of {gross_amount_msat} msat" @@ -3088,6 +3128,7 @@ mod tests { EventHandler::>::lsps2_max_total_opening_fee_msat( &metadata, amount_msat, + 0, ), Some(expected_fee_msat), "The fee allowance must include the negotiated minimum and round up proportional fees" @@ -3115,6 +3156,7 @@ mod tests { EventHandler::>::lsps2_max_total_opening_fee_msat( &metadata.encode(), gross_amount_msat, + 0, ), expected ); @@ -3135,12 +3177,34 @@ 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), + min_opening_fee_msat: 0, + }), + 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 = @@ -3158,23 +3222,64 @@ 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), + min_opening_fee_msat: 0, + }), + 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 2b0f7ddd54..bc52277b02 100644 --- a/src/payment/bolt12/mod.rs +++ b/src/payment/bolt12/mod.rs @@ -193,6 +193,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, @@ -221,6 +222,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, @@ -394,6 +396,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, @@ -422,6 +425,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, @@ -559,7 +563,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. /// /// If neither can receive the requested amount, the invoice request is rejected rather than /// answered with an invoice that cannot be paid. @@ -575,7 +580,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. /// /// If neither can receive the requested amount, the invoice request is rejected rather than /// answered with an invoice that cannot be paid. diff --git a/src/payment/metadata.rs b/src/payment/metadata.rs index f0b8c95068..e7f30d32b8 100644 --- a/src/payment/metadata.rs +++ b/src/payment/metadata.rs @@ -78,4 +78,20 @@ 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, + min_opening_fee_msat: 0, + }), + 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 8c8908f909..99ac6a7725 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -250,11 +250,6 @@ impl UpdatableObject for PaymentDetails { if let Some(hash_opt) = update.hash { match self.kind { PaymentKind::Bolt12Offer { ref mut hash, .. } => { - debug_assert_eq!( - self.direction, - PaymentDirection::Outbound, - "We should only ever override payment hash for outbound BOLT 12 payments" - ); debug_assert!( hash.is_none() || *hash == hash_opt, "We should never change a payment hash after being initially set" @@ -262,11 +257,6 @@ impl UpdatableObject for PaymentDetails { update_if_necessary!(*hash, hash_opt); }, PaymentKind::Bolt12Refund { ref mut hash, .. } => { - debug_assert_eq!( - self.direction, - PaymentDirection::Outbound, - "We should only ever override payment hash for outbound BOLT 12 payments" - ); debug_assert!( hash.is_none() || *hash == hash_opt, "We should never change a payment hash after being initially set" @@ -322,12 +312,13 @@ impl UpdatableObject for PaymentDetails { if let Some(skimmed_fee_msat) = update.counterparty_skimmed_fee_msat { match self.kind { - PaymentKind::Bolt11 { ref mut counterparty_skimmed_fee_msat, .. } => { + PaymentKind::Bolt11 { ref mut counterparty_skimmed_fee_msat, .. } + | PaymentKind::Bolt12Offer { ref mut counterparty_skimmed_fee_msat, .. } => { update_if_necessary!(*counterparty_skimmed_fee_msat, skimmed_fee_msat); }, _ => debug_assert!( false, - "We should only ever override counterparty_skimmed_fee_msat for BOLT11 payments" + "We should only override counterparty_skimmed_fee_msat for invoice payments" ), } } @@ -573,6 +564,17 @@ pub enum PaymentKind { preimage: Option, /// The secret used by the payment. secret: Option, + /// The value, in thousandths of a satoshi, that was deducted from this payment as an extra + /// fee taken by our channel counterparty. + /// + /// Will only ever be `Some` for inbound payments received via an [bLIP-52 / LSPS 2] + /// just-in-time channel, and only after the payment is observed; `None` otherwise. + /// + /// This will always be `None` for payments serialized by versions that did not record + /// BOLT12 LSPS2 fees. + /// + /// [bLIP-52 / LSPS 2]: https://github.com/lightning/blips/blob/master/blip-0052.md + counterparty_skimmed_fee_msat: Option, /// The ID of the offer this payment is for. offer_id: OfferId, /// The payer note for the payment. @@ -664,6 +666,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) => { @@ -783,7 +786,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, @@ -1111,6 +1115,96 @@ mod tests { } } + #[test] + fn inbound_bolt12_offer_payments_can_be_failed() { + let payment_id = PaymentId([41; 32]); + let hash = PaymentHash([42; 32]); + let mut payment = PaymentDetails::new( + payment_id, + PaymentKind::Bolt12Offer { + hash: Some(hash), + preimage: None, + secret: None, + counterparty_skimmed_fee_msat: None, + offer_id: OfferId([43; 32]), + payer_note: None, + quantity: None, + }, + Some(100_000), + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + + payment.update(PaymentDetailsUpdate { + hash: Some(Some(hash)), + status: Some(PaymentStatus::Failed), + ..PaymentDetailsUpdate::new(payment_id) + }); + + assert_eq!(payment.status, PaymentStatus::Failed); + } + + #[test] + fn inbound_bolt12_refunds_can_be_failed() { + let payment_id = PaymentId([41; 32]); + let hash = PaymentHash([42; 32]); + let mut payment = PaymentDetails::new( + payment_id, + PaymentKind::Bolt12Refund { + hash: Some(hash), + preimage: None, + secret: None, + payer_note: None, + quantity: None, + }, + Some(100_000), + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + + payment.update(PaymentDetailsUpdate { + hash: Some(Some(hash)), + status: Some(PaymentStatus::Failed), + ..PaymentDetailsUpdate::new(payment_id) + }); + + assert_eq!(payment.status, PaymentStatus::Failed); + } + + #[test] + fn bolt12_offer_records_counterparty_skimmed_fee() { + let payment_id = PaymentId([41; 32]); + let mut payment = PaymentDetails::new( + payment_id, + PaymentKind::Bolt12Offer { + hash: Some(PaymentHash([42; 32])), + preimage: None, + secret: None, + counterparty_skimmed_fee_msat: None, + offer_id: OfferId([43; 32]), + payer_note: None, + quantity: None, + }, + Some(100_000), + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + + assert!(payment.update(PaymentDetailsUpdate { + counterparty_skimmed_fee_msat: Some(Some(1_000)), + ..PaymentDetailsUpdate::new(payment_id) + })); + assert!(matches!( + payment.kind, + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat: Some(1_000), .. } + )); + let encoded = payment.encode(); + assert_eq!(payment, PaymentDetails::read(&mut &*encoded).unwrap()); + } + #[derive(Clone, Debug, PartialEq, Eq)] struct LegacyLSPS2Parameters { max_total_opening_fee_msat: Option, diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 64575c2408..6aacb4aff1 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -6030,12 +6030,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); @@ -6097,12 +6099,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 8e9759c178a172431e2b5f3ec92bd0c962ff2478 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:47:36 +0200 Subject: [PATCH 22/38] lsps2: Serialize lease negotiation Make same-amount and variable callers wait for one in-flight LSPS2 request, then recheck the shared cache before negotiating. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 23 ++++++++++++++++- src/liquidity/client/lsps2/state.rs | 38 ++++++++++++++++++++++++++++- src/liquidity/mod.rs | 3 ++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 2c50b1daed..db2bca554a 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -45,7 +45,7 @@ use crate::{Config, Error}; use self::router::LSPS2LeaseParameters; use self::state::{ now_secs, LSPS2LeaseState, LeaseCacheTarget, LeaseCacheTargetId, LeaseCacheTargetStore, - PaymentLease, PaymentLeaseId, PaymentLeaseStore, + PaymentLease, PaymentLeaseId, PaymentLeaseStore, PendingLeaseRequestState, }; // LSPS2 requires two blocks more than the usual final CLTV delta. @@ -116,6 +116,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, @@ -298,6 +299,17 @@ where { return Ok((lease, total_fee_msat, lsp, false)); } + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseCacheTargetId::Fixed { amount_msat }); + let _request_guard = request_lock.lock().await; + if let Some((lease, total_fee_msat, lsp)) = + self.take_cached_fixed_lease(amount_msat).await? + { + return Ok((lease, total_fee_msat, lsp, false)); + } let all_offers = self.gather_lsps2_offers(connection_manager).await?; let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers @@ -363,6 +375,15 @@ where if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { return Ok((lease, proportional_fee, lsp, false)); } + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseCacheTargetId::Variable); + let _request_guard = request_lock.lock().await; + if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { + return Ok((lease, proportional_fee, lsp, false)); + } let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index dc4eab0bdd..4693ea32c1 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -23,7 +23,7 @@ use std::collections::HashMap; use std::ops::Deref; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, Weak}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use bitcoin::secp256k1::PublicKey; @@ -32,6 +32,7 @@ use lightning::util::ser::{Readable, Writeable}; use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; use lightning_liquidity::lsps2::utils::compute_opening_fee; +use tokio::sync::Mutex as AsyncMutex; use crate::data_store::{DataStore, StorableObject, StorableObjectId}; use crate::hex_utils; @@ -293,6 +294,27 @@ fn merge_absolute_expiry(current: Option, new: Option) -> Option } } +#[derive(Default)] +pub(crate) struct PendingLeaseRequestState { + locks: HashMap>>, +} + +impl PendingLeaseRequestState { + pub(crate) fn request_lock(&mut self, key: LeaseCacheTargetId) -> Arc> { + self.prune(); + if let Some(lock) = self.locks.get(&key).and_then(Weak::upgrade) { + return lock; + } + let lock = Arc::new(AsyncMutex::new(())); + self.locks.insert(key, Arc::downgrade(&lock)); + lock + } + + pub(crate) fn prune(&mut self) { + self.locks.retain(|_, lock| lock.strong_count() > 0); + } +} + fn is_cache_target_expired(target: &LeaseCacheTarget, now: u64) -> bool { target.absolute_expiry.is_some_and(|expiry| expiry <= now) } @@ -728,4 +750,18 @@ mod tests { state.register(cache_target(id, None, 3)); assert_eq!(state.targets().first().unwrap().absolute_expiry, None); } + + #[test] + fn lease_requests_are_serialized_per_key() { + let mut state = PendingLeaseRequestState::default(); + + let fixed = state.request_lock(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }); + let same_fixed = state.request_lock(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }); + let other_fixed = state.request_lock(LeaseCacheTargetId::Fixed { amount_msat: 2_000 }); + let variable = state.request_lock(LeaseCacheTargetId::Variable); + + assert!(Arc::ptr_eq(&fixed, &same_fixed)); + assert!(!Arc::ptr_eq(&fixed, &other_fixed)); + assert!(!Arc::ptr_eq(&fixed, &variable)); + } } diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index c85820aae0..1fc7bb1d7d 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -39,7 +39,7 @@ use crate::io::{ use crate::liquidity::client::lsps1::LSPS1Client; use crate::liquidity::client::lsps2::state::{ is_lease_usable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, - LeaseCacheTargetStore, PaymentLeaseStore, + LeaseCacheTargetStore, PaymentLeaseStore, PendingLeaseRequestState, }; use crate::liquidity::client::lsps2::LSPS2Client; use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; @@ -409,6 +409,7 @@ where lease_store, lease_state: Mutex::new(lease_state), cache_target_store, + pending_lease_request_state: Mutex::new(PendingLeaseRequestState::default()), channel_manager: self.channel_manager.clone(), keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), From 58afbdd2c1374c8fbf2559df6ef67a20e6b8b3bc Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:51:14 +0200 Subject: [PATCH 23/38] 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/lib.rs | 2 + src/liquidity/client/lsps2/mod.rs | 226 +++++++++++++++++++++++++++- src/liquidity/client/lsps2/state.rs | 194 +++++++++++++++++++++++- src/liquidity/mod.rs | 16 +- src/payment/bolt12/mod.rs | 21 ++- src/payment/bolt12/offers.rs | 9 +- 7 files changed, 445 insertions(+), 24 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 0a03b9bf85..8a19aa6efc 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2380,6 +2380,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/lib.rs b/src/lib.rs index 26b45d9286..36ce588100 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1139,6 +1139,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.liquidity_source), + Arc::clone(&self.connection_manager), Arc::clone(&self.keys_manager), Arc::clone(&self.payment_store), Arc::clone(&self.config), @@ -1157,6 +1158,7 @@ impl Node { Arc::clone(&self.runtime), Arc::clone(&self.channel_manager), Arc::clone(&self.liquidity_source), + Arc::clone(&self.connection_manager), Arc::clone(&self.keys_manager), Arc::clone(&self.payment_store), Arc::clone(&self.config), diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index db2bca554a..ecbe14f393 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -14,7 +14,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}; @@ -39,6 +39,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}; @@ -121,6 +122,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, } @@ -145,7 +148,22 @@ where } pub(crate) async fn prune_stale_cache_targets(&self) -> Result<(), Error> { - self.cache_target_store.prune(now_secs()).await + let removed_target_ids = self.cache_target_store.prune(now_secs()).await?; + self.drop_cached_leases(&removed_target_ids).await + } + + /// Drops the cached leases of targets we no longer track, as otherwise every lease negotiated + /// for a since-removed target would remain stored until it expires. + async fn drop_cached_leases(&self, target_ids: &[LeaseCacheTargetId]) -> Result<(), Error> { + if target_ids.is_empty() { + return Ok(()); + } + let lease_ids = self.lease_state.lock().expect("lock").lease_ids_for_targets(target_ids); + for id in lease_ids { + self.lease_store.remove(&id).await?; + self.lease_state.lock().expect("lock").remove(&id); + } + Ok(()) } pub(crate) async fn register_cache_target( @@ -156,7 +174,10 @@ where return Ok(()); } let target = LeaseCacheTarget::new(id, absolute_expiry); - self.cache_target_store.register(target).await + if let Some(evicted_id) = self.cache_target_store.register(target).await? { + self.drop_cached_leases(&[evicted_id]).await?; + } + Ok(()) } /// Returns whether any configured LSP supports LSPS2, or may support it once protocol @@ -171,13 +192,31 @@ where self.cache_target_store.targets() } - pub(crate) async fn commit_invoice_cache_target(&self, request: JitInvoiceRequest) { + /// Records a cache target and starts negotiating a matching lease in the background, so that + /// the first payer does not have to wait for the negotiation. + pub(crate) async fn register_cache_target_and_refill( + self: &Arc, id: LeaseCacheTargetId, absolute_expiry: Option, + connection_manager: &Arc>, + ) -> Result<(), Error> { + if !self.may_have_lsps2_nodes() { + return Ok(()); + } + let res = self.register_cache_target(id, absolute_expiry).await; + self.schedule_lease_refill(id, connection_manager); + res + } + + pub(crate) async fn commit_invoice_cache_target( + self: &Arc, request: JitInvoiceRequest, + connection_manager: &Arc>, + ) { // Cache targets are only an optimization. A persistence failure must not make the current // invoice request unusable. let (target_id, absolute_expiry) = request.cache_target(); if let Err(error) = self.register_cache_target(target_id, absolute_expiry).await { log_warn!(self.logger, "Failed recording LSPS2 lease cache target: {}", error); } + self.schedule_lease_refill(target_id, connection_manager); } pub(crate) async fn prepare_invoice_response( @@ -259,6 +298,7 @@ where payment_hash, lsps2_parameters, )?; + self.schedule_lease_refill(LeaseCacheTargetId::Fixed { amount_msat }, &connection_manager); if was_negotiated { log_info!(self.logger, "JIT-channel invoice created: {}", invoice); @@ -284,6 +324,7 @@ where payment_hash, lsps2_parameters, )?; + self.schedule_lease_refill(LeaseCacheTargetId::Variable, &connection_manager); if was_negotiated { log_info!(self.logger, "JIT-channel invoice created: {}", invoice); @@ -311,6 +352,15 @@ where return Ok((lease, total_fee_msat, lsp, false)); } + let (negotiated_lease, min_total_fee_msat, cheapest_lsp) = + self.negotiate_fixed_lease(amount_msat, connection_manager).await?; + let lease = self.consume_lease(&negotiated_lease.id).await?; + Ok((lease, min_total_fee_msat, cheapest_lsp, true)) + } + + async fn negotiate_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers .into_iter() @@ -365,8 +415,7 @@ where Some(&cheapest_lsp.node_id), ) .await?; - let lease = self.consume_lease(&negotiated_lease.id).await?; - Ok((lease, min_total_fee_msat, cheapest_lsp, true)) + Ok((negotiated_lease, min_total_fee_msat, cheapest_lsp)) } async fn acquire_variable_lease( @@ -385,6 +434,15 @@ where return Ok((lease, proportional_fee, lsp, false)); } + let (negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp) = + self.negotiate_variable_lease(connection_manager).await?; + let lease = self.consume_lease(&negotiated_lease.id).await?; + Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true)) + } + + async fn negotiate_variable_lease( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; let (cheapest_lsp, min_prop_fee_ppm_msat, min_opening_params) = all_offers @@ -426,8 +484,74 @@ where let negotiated_lease = self .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) .await?; - let lease = self.consume_lease(&negotiated_lease.id).await?; - Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true)) + Ok((negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp)) + } + + fn schedule_lease_refill( + self: &Arc, target_id: LeaseCacheTargetId, + connection_manager: &Arc>, + ) { + let Some(runtime) = self.runtime.upgrade() else { return }; + let client = Arc::clone(self); + let connection_manager = Arc::clone(connection_manager); + runtime.spawn_cancellable_background_task(async move { + let result = match target_id { + LeaseCacheTargetId::Fixed { amount_msat } => { + client.cache_fixed_lease(amount_msat, &connection_manager).await + }, + LeaseCacheTargetId::Variable => { + client.cache_variable_lease(&connection_manager).await + }, + }; + if let Err(error) = result { + log_warn!(client.logger, "Failed refilling LSPS2 payment lease: {}", error); + } + }); + } + + async fn cache_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(), Error> { + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseCacheTargetId::Fixed { amount_msat }); + let _request_guard = request_lock.lock().await; + let available_lsps = + self.get_lsps2_nodes().await?.into_iter().map(|lsp| lsp.node_id).collect::>(); + if self.lease_state.lock().expect("lock").has_fixed_amount( + amount_msat, + self.config.lsps2_max_total_lsp_fee_limit_msat, + &available_lsps, + ) { + return Ok(()); + } + self.negotiate_fixed_lease(amount_msat, connection_manager).await?; + Ok(()) + } + + async fn cache_variable_lease( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(), Error> { + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseCacheTargetId::Variable); + let _request_guard = request_lock.lock().await; + let available_lsps = + self.get_lsps2_nodes().await?.into_iter().map(|lsp| lsp.node_id).collect::>(); + if self + .lease_state + .lock() + .expect("lock") + .has_variable_amount(self.config.lsps2_max_total_lsp_fee_limit_msat, &available_lsps) + { + return Ok(()); + } + self.negotiate_variable_lease(connection_manager).await?; + Ok(()) } async fn gather_lsps2_offers( @@ -925,6 +1049,92 @@ mod tests { } } + #[test] + fn removed_cache_targets_drop_their_leases() { + use crate::builder::NodeBuilder; + use crate::entropy::NodeEntropy; + use crate::io::test_utils::InMemoryStore; + use crate::liquidity::LSPS2_LEASE_CACHE_TARGET_SIZE; + + let lsp_node_id: PublicKey = + "0276607124ebe6a6c9338517b6f485825b27c2dcc0b9fc2aa6a4c0df91194e5993".parse().unwrap(); + let mut builder = NodeBuilder::new(); + builder.set_log_facade_logger(); + builder.add_liquidity_source( + lsp_node_id, + lightning::ln::msgs::SocketAddress::OnionV2([0; 12]), + None, + false, + ); + #[cfg(not(feature = "uniffi"))] + let entropy = NodeEntropy::from_seed_bytes([42; 64]); + #[cfg(feature = "uniffi")] + let entropy = NodeEntropy::from_seed_bytes(vec![42; 64]).unwrap(); + let node = builder.build_with_store(entropy, InMemoryStore::new()).unwrap(); + let client = node.liquidity_source.lsps2_client(); + client.lsp_nodes.write().unwrap()[0].supported_protocols = Some(vec![2]); + + let valid_until = now_secs() + 2 * state::MIN_LEASE_REMAINING_SECS; + let lease = |intercept_scid, amount_msat| PaymentLease { + id: PaymentLeaseId { lsp_node_id, intercept_scid }, + params: LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: chrono::DateTime::from_timestamp(valid_until as i64, 0) + .unwrap() + .to_rfc3339() + .parse() + .unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 1_000_000, + promise: String::new(), + }, + cltv_expiry_delta: 144, + payment_size_msat: Some(amount_msat), + valid_until, + }; + let (evicted, expired, retained) = (lease(1, 1_000), lease(2, 2_000), lease(3, 3_000)); + + node.runtime.block_on(async { + for lease in [&evicted, &expired, &retained] { + client.lease_store.insert(lease.clone()).await.unwrap(); + client.lease_state.lock().unwrap().insert(lease.clone()); + } + let fixed = |amount_msat| LeaseCacheTargetId::Fixed { amount_msat }; + client.register_cache_target(fixed(1_000), None).await.unwrap(); + client.register_cache_target(fixed(2_000), Some(now_secs())).await.unwrap(); + client.register_cache_target(fixed(3_000), None).await.unwrap(); + // Fill the cache so that registering one more target evicts the least recently used one. + for amount_msat in 0..(LSPS2_LEASE_CACHE_TARGET_SIZE as u64 - 3) { + client.register_cache_target(fixed(10_000 + amount_msat), None).await.unwrap(); + } + client.register_cache_target(fixed(999_999), None).await.unwrap(); + client.prune_stale_cache_targets().await.unwrap(); + + for (lease, expect_retained) in + [(&evicted, false), (&expired, false), (&retained, true)] + { + let amount_msat = lease.payment_size_msat.unwrap(); + assert_eq!( + client.lease_state.lock().unwrap().has_fixed_amount( + amount_msat, + None, + &[lsp_node_id] + ), + expect_retained, + "unexpected cached lease state for {amount_msat} msat" + ); + assert_eq!( + client.lease_store.get(&lease.id).await.unwrap().is_some(), + expect_retained, + "unexpected persisted lease state for {amount_msat} msat" + ); + } + }); + } + #[test] fn cache_targets_require_lsps2_liquidity_source() { use crate::builder::NodeBuilder; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 4693ea32c1..6ee22b1912 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -59,6 +59,16 @@ pub(crate) enum LeaseCacheTargetId { Variable, } +impl LeaseCacheTargetId { + /// Returns whether the given lease may be used to serve this target. + pub(crate) fn matches(&self, lease: &PaymentLease) -> bool { + match self { + Self::Fixed { amount_msat } => lease.payment_size_msat == Some(*amount_msat), + Self::Variable => lease.payment_size_msat.is_none(), + } + } +} + impl_writeable_tlv_based_enum!(LeaseCacheTargetId, (0, Fixed) => { (1, amount_msat, required), @@ -206,24 +216,29 @@ where } } - pub(crate) async fn register(&self, target: LeaseCacheTarget) -> Result<(), Error> { + /// Registers the given target, returning the target evicted to make room for it, if any. + 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); + let evicted = updated_state.register(target); self.persist_state(&updated_state).await?; *self.state.lock().expect("lock") = updated_state; - Ok(()) + Ok(evicted) } - pub(crate) async fn prune(&self, now: u64) -> Result<(), Error> { + /// Removes expired targets, returning their identifiers. + 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(()); + let removed = updated_state.prune(now); + if removed.is_empty() { + return Ok(removed); } self.persist_state(&updated_state).await?; *self.state.lock().expect("lock") = updated_state; - Ok(()) + Ok(removed) } pub(crate) async fn persist(&self) -> Result<(), Error> { @@ -398,6 +413,21 @@ impl LSPS2LeaseState { self.leases.insert(lease.id, lease); } + pub(crate) fn remove(&mut self, id: &PaymentLeaseId) { + self.leases.remove(id); + } + + /// Returns the identifiers of all leases that may serve any of the given targets. + pub(crate) fn lease_ids_for_targets( + &self, target_ids: &[LeaseCacheTargetId], + ) -> Vec { + self.leases + .values() + .filter(|lease| target_ids.iter().any(|target_id| target_id.matches(lease))) + .map(|lease| lease.id) + .collect() + } + 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) @@ -429,6 +459,24 @@ impl LSPS2LeaseState { self.leases.remove(&id).map(|lease| (lease, fee_msat)) } + pub(crate) fn has_fixed_amount( + &self, amount_msat: u64, max_fee_msat: Option, available_lsps: &[PublicKey], + ) -> bool { + self.leases + .values() + .filter(|lease| available_lsps.contains(&lease.id.lsp_node_id)) + .filter(|lease| lease.payment_size_msat == Some(amount_msat)) + .filter(|lease| is_lease_usable(lease)) + .filter_map(|lease| { + compute_opening_fee( + amount_msat, + lease.params.min_fee_msat, + lease.params.proportional as u64, + ) + }) + .any(|fee_msat| max_fee_msat.map_or(true, |max| fee_msat <= max)) + } + /// Selects and removes a variable-amount lease in one operation. pub(crate) fn variable_amount( &mut self, max_total_fee_msat: Option, available_lsps: &[PublicKey], @@ -447,6 +495,17 @@ impl LSPS2LeaseState { self.leases.remove(&id).map(|lease| (lease, proportional_fee)) } + pub(crate) fn has_variable_amount( + &self, max_total_fee_msat: Option, available_lsps: &[PublicKey], + ) -> bool { + self.leases + .values() + .filter(|lease| available_lsps.contains(&lease.id.lsp_node_id)) + .filter(|lease| lease.payment_size_msat.is_none()) + .filter(|lease| is_lease_usable(lease)) + .any(|lease| max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max)) + } + pub(crate) fn prune(&mut self) { self.leases.retain(|_, lease| is_lease_usable(lease)); } @@ -645,6 +704,47 @@ mod tests { assert_eq!(state.variable_amount(Some(50), &available_lsps).unwrap().0.id, variable.id); } + #[test] + fn detects_cached_leases_for_refill() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let fixed = lease(2, 46, 100, Some(1_000), valid_until); + let variable = lease(3, 47, 50, None, valid_until); + let available_lsps = [fixed.id.lsp_node_id, variable.id.lsp_node_id]; + let state = LSPS2LeaseState::from_leases(vec![fixed, variable]); + + assert!(state.has_fixed_amount(1_000, Some(100), &available_lsps)); + assert!(!state.has_fixed_amount(1_000, Some(99), &available_lsps)); + assert!(!state.has_fixed_amount(2_000, None, &available_lsps)); + assert!(state.has_variable_amount(Some(50), &available_lsps)); + assert!(!state.has_variable_amount(Some(49), &available_lsps)); + } + + #[test] + fn unavailable_fixed_lease_does_not_suppress_refill() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let unavailable = lease(2, 48, 100, Some(1_000), valid_until); + let available_lsp = lease(3, 49, 100, Some(1_000), valid_until).id.lsp_node_id; + let state = LSPS2LeaseState::from_leases(vec![unavailable]); + + assert!( + !state.has_fixed_amount(1_000, Some(100), &[available_lsp]), + "a lease from an unavailable LSP suppressed fixed-amount refill" + ); + } + + #[test] + fn unavailable_variable_lease_does_not_suppress_refill() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let unavailable = lease(2, 50, 50, None, valid_until); + let available_lsp = lease(3, 51, 50, None, valid_until).id.lsp_node_id; + let state = LSPS2LeaseState::from_leases(vec![unavailable]); + + assert!( + !state.has_variable_amount(Some(50), &[available_lsp]), + "a lease from an unavailable LSP suppressed variable-amount refill" + ); + } + fn cache_target( id: LeaseCacheTargetId, absolute_expiry: Option, last_accessed: u64, ) -> LeaseCacheTarget { @@ -764,4 +864,84 @@ mod tests { assert!(!Arc::ptr_eq(&fixed, &other_fixed)); assert!(!Arc::ptr_eq(&fixed, &variable)); } + + #[test] + fn startup_drops_leases_of_expired_cache_targets() { + use crate::builder::NodeBuilder; + use crate::entropy::NodeEntropy; + use crate::io::{ + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + }; + + let now = now_secs(); + let valid_until = now + 7 * 24 * 60 * 60; + let expired_target_lease = lease(2, 46, 1, Some(1_000), valid_until); + let live_target_lease = lease(3, 47, 1, Some(2_000), valid_until); + let untargeted_lease = lease(4, 48, 1, None, valid_until); + let targets = vec![ + LeaseCacheTarget::new(LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, Some(now - 1)), + LeaseCacheTarget::new( + LeaseCacheTargetId::Fixed { amount_msat: 2_000 }, + Some(now + 3_600), + ), + ]; + + let store = InMemoryStore::new(); + tokio::runtime::Builder::new_current_thread().build().unwrap().block_on(async { + KVStore::write( + &store, + LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, + LeaseCacheTargets { targets }.encode(), + ) + .await + .unwrap(); + for lease in [&expired_target_lease, &live_target_lease, &untargeted_lease] { + KVStore::write( + &store, + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + &lease.id.encode_to_hex_str(), + lease.encode(), + ) + .await + .unwrap(); + } + }); + + let mut builder = NodeBuilder::new(); + builder.set_log_facade_logger(); + #[cfg(not(feature = "uniffi"))] + let entropy = NodeEntropy::from_seed_bytes([42; 64]); + #[cfg(feature = "uniffi")] + let entropy = NodeEntropy::from_seed_bytes(vec![42; 64]).unwrap(); + let node = builder.build_with_store(entropy, store).unwrap(); + + let mut remaining = node + .runtime + .block_on(KVStore::list( + &*node.kv_store, + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + )) + .unwrap(); + remaining.sort(); + let mut expected = + vec![live_target_lease.id.encode_to_hex_str(), untargeted_lease.id.encode_to_hex_str()]; + expected.sort(); + assert_eq!( + remaining, expected, + "only the leases of cache targets dropped at startup must be removed" + ); + let client = node.liquidity_source.lsps2_client(); + let lease_state = client.lease_state.lock().unwrap(); + assert!(lease_state + .lease_ids_for_targets(&[LeaseCacheTargetId::Fixed { amount_msat: 1_000 }]) + .is_empty()); + assert_eq!( + lease_state.lease_ids_for_targets(&[LeaseCacheTargetId::Fixed { amount_msat: 2_000 }]), + vec![live_target_lease.id] + ); + } } diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 1fc7bb1d7d..c86b2a84bb 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -13,7 +13,7 @@ pub(crate) mod service; use std::collections::HashMap; use std::hash::Hash; use std::ops::Deref; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{Arc, Mutex, RwLock, Weak}; use std::time::Duration; use bitcoin::secp256k1::PublicKey; @@ -264,6 +264,7 @@ where keys_manager: Arc, tx_broadcaster: Arc, kv_store: Arc, + runtime: Weak, config: Arc, logger: L, } @@ -274,7 +275,8 @@ where { pub(crate) fn new( wallet: Arc, channel_manager: Arc, keys_manager: Arc, - tx_broadcaster: Arc, kv_store: Arc, config: Arc, logger: L, + tx_broadcaster: Arc, kv_store: Arc, runtime: Weak, + config: Arc, logger: L, ) -> Self { let lsp_nodes = Vec::new(); let lsps2_service = None; @@ -286,6 +288,7 @@ where keys_manager, tx_broadcaster, kv_store, + runtime, config, logger, } @@ -327,7 +330,7 @@ where for lease in leases.iter().filter(|lease| !is_lease_usable(lease)) { lease_store.remove(&lease.id).await.map_err(|_| BuildError::WriteFailed)?; } - let lease_state = + let mut 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, @@ -341,6 +344,12 @@ where )); if !removed_target_ids.is_empty() { cache_target_store.persist().await.map_err(|_| BuildError::WriteFailed)?; + // The runtime prune only sees tracked targets, so leases of targets dropped here would + // otherwise linger until they expire. + for lease_id in lease_state.lease_ids_for_targets(&removed_target_ids) { + lease_store.remove(&lease_id).await.map_err(|_| BuildError::WriteFailed)?; + lease_state.remove(&lease_id); + } } let liquidity_service_config = self.lsps2_service.as_ref().map(|s| { let lsps2_service_config = Some(s.ldk_service_config.clone()); @@ -414,6 +423,7 @@ where keys_manager: self.keys_manager.clone(), discovery_done_rx: discovery_done_rx.clone(), liquidity_manager: Arc::clone(&liquidity_manager), + runtime: self.runtime, config: self.config.clone(), logger: self.logger.clone(), }), diff --git a/src/payment/bolt12/mod.rs b/src/payment/bolt12/mod.rs index bc52277b02..639e5eb87e 100644 --- a/src/payment/bolt12/mod.rs +++ b/src/payment/bolt12/mod.rs @@ -33,6 +33,7 @@ use lightning_types::payment::PaymentPreimage; use lightning_types::string::UntrustedString; use crate::config::{AsyncPaymentsRole, Config, LDK_PAYMENT_RETRY_TIMEOUT}; +use crate::connection::ConnectionManager; use crate::error::Error; use crate::ffi::{maybe_deref, maybe_wrap}; use crate::liquidity::client::lsps2::state::LeaseCacheTargetId; @@ -103,6 +104,7 @@ pub struct Bolt12Payment { runtime: Arc, channel_manager: Arc, liquidity_source: Arc>>, + connection_manager: Arc>>, keys_manager: Arc, payment_store: Arc, config: Arc, @@ -114,7 +116,8 @@ pub struct Bolt12Payment { impl Bolt12Payment { pub(crate) fn new( runtime: Arc, channel_manager: Arc, - liquidity_source: Arc>>, keys_manager: Arc, + liquidity_source: Arc>>, + connection_manager: Arc>>, keys_manager: Arc, payment_store: Arc, config: Arc, is_running: Arc>, logger: Arc, async_payments_role: Option, ) -> Self { @@ -122,6 +125,7 @@ impl Bolt12Payment { runtime, channel_manager, liquidity_source, + connection_manager, keys_manager, payment_store, config, @@ -286,9 +290,18 @@ impl Bolt12Payment { 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), - ) { + let lsps2_client = self.liquidity_source.lsps2_client(); + let res = if *self.is_running.read().expect("lock") { + self.runtime.block_on(lsps2_client.register_cache_target_and_refill( + id, + absolute_expiry, + &self.connection_manager, + )) + } else { + // We cannot negotiate leases while stopped. Invoice requests still negotiate on demand. + self.runtime.block_on(lsps2_client.register_cache_target(id, absolute_expiry)) + }; + if let Err(error) = res { // 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); diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs index 2c419bb521..503b524a4f 100644 --- a/src/payment/bolt12/offers.rs +++ b/src/payment/bolt12/offers.rs @@ -497,7 +497,7 @@ impl OffersMessageHandler for NodeOffersMessageHandler { let logger = Arc::clone(&self.logger); dependencies.runtime.spawn_cancellable_background_task(async move { let response = Arc::clone(&lsps2_client) - .prepare_invoice_response(jit_request, connection_manager) + .prepare_invoice_response(jit_request, Arc::clone(&connection_manager)) .await; let (message, reply_context) = match response { Ok(jit_metadata) => { @@ -516,7 +516,12 @@ impl OffersMessageHandler for NodeOffersMessageHandler { ); let invoice_result = commit_cache_target_on_success(invoice_result, || { let lsps2_client = Arc::clone(&lsps2_client); - async move { lsps2_client.commit_invoice_cache_target(jit_request).await } + let connection_manager = Arc::clone(&connection_manager); + async move { + lsps2_client + .commit_invoice_cache_target(jit_request, &connection_manager) + .await + } }) .await; match invoice_result { From 4e6f0daa029602ff83807a9a7d2af490417635c1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Nov 2026 14:09:46 +0100 Subject: [PATCH 24/38] f Persist dropped cache targets after their leases Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/state.rs | 117 ++++++++++++++++++++++++++++ src/liquidity/mod.rs | 4 +- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 6ee22b1912..bce5a18fa0 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -944,4 +944,121 @@ mod tests { vec![live_target_lease.id] ); } + + /// Fails removing persisted leases while passing everything else through. + struct LeaseRemovalFailingStore(Arc); + + impl KVStore for LeaseRemovalFailingStore { + fn read( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, + ) -> impl std::future::Future, io::Error>> + 'static + Send { + KVStore::read(&*self.0, primary_namespace, secondary_namespace, key) + } + + fn write( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec, + ) -> impl std::future::Future> + 'static + Send { + KVStore::write(&*self.0, primary_namespace, secondary_namespace, key, buf) + } + + fn remove( + &self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool, + ) -> impl std::future::Future> + 'static + Send { + let removal = + (primary_namespace != crate::io::LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE).then( + || KVStore::remove(&*self.0, primary_namespace, secondary_namespace, key, lazy), + ); + async move { + match removal { + Some(removal) => removal.await, + None => 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 { + KVStore::list(&*self.0, primary_namespace, secondary_namespace) + } + } + + impl PaginatedKVStore for LeaseRemovalFailingStore { + fn list_paginated( + &self, primary_namespace: &str, secondary_namespace: &str, + page_token: Option, + ) -> impl std::future::Future> + 'static + Send + { + PaginatedKVStore::list_paginated( + &*self.0, + primary_namespace, + secondary_namespace, + page_token, + ) + } + } + + #[test] + fn startup_keeps_dropped_cache_targets_until_their_leases_are_removed() { + use crate::builder::{BuildError, NodeBuilder}; + use crate::entropy::NodeEntropy; + use crate::io::{ + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + }; + + let now = now_secs(); + let expired_target_lease = lease(2, 46, 1, Some(1_000), now + 7 * 24 * 60 * 60); + let targets = vec![LeaseCacheTarget::new( + LeaseCacheTargetId::Fixed { amount_msat: 1_000 }, + Some(now - 1), + )]; + + let store = Arc::new(InMemoryStore::new()); + let runtime = tokio::runtime::Builder::new_current_thread().build().unwrap(); + runtime.block_on(async { + KVStore::write( + &*store, + LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, + LeaseCacheTargets { targets: targets.clone() }.encode(), + ) + .await + .unwrap(); + KVStore::write( + &*store, + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + &expired_target_lease.id.encode_to_hex_str(), + expired_target_lease.encode(), + ) + .await + .unwrap(); + }); + + let mut builder = NodeBuilder::new(); + builder.set_log_facade_logger(); + #[cfg(not(feature = "uniffi"))] + let entropy = NodeEntropy::from_seed_bytes([42; 64]); + #[cfg(feature = "uniffi")] + let entropy = NodeEntropy::from_seed_bytes(vec![42; 64]).unwrap(); + let res = builder.build_with_store(entropy, LeaseRemovalFailingStore(Arc::clone(&store))); + assert!(matches!(res, Err(BuildError::WriteFailed))); + + // The removed target identifiers are derived from the persisted targets. They must hence + // stay persisted until their leases are gone, as a retry could otherwise not find the leases. + let data = runtime + .block_on(KVStore::read( + &*store, + LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, + )) + .unwrap(); + assert_eq!( + LeaseCacheTargets::read(&mut &data[..]).unwrap().targets, + targets, + "a dropped cache target was persisted as removed before its leases were removed" + ); + } } diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index c86b2a84bb..079548a0d6 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -343,13 +343,15 @@ where self.logger.clone(), )); if !removed_target_ids.is_empty() { - cache_target_store.persist().await.map_err(|_| BuildError::WriteFailed)?; // The runtime prune only sees tracked targets, so leases of targets dropped here would // otherwise linger until they expire. for lease_id in lease_state.lease_ids_for_targets(&removed_target_ids) { lease_store.remove(&lease_id).await.map_err(|_| BuildError::WriteFailed)?; lease_state.remove(&lease_id); } + // Persist the shrunk target set last: the removed identifiers are derived from the + // persisted targets, so a retry after a failure above can still find the leases to drop. + 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()); From a3d2484269dc309a4ae80c7d0d8857d952626b91 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 15:18:47 +0200 Subject: [PATCH 25/38] lsps2: Restore cached payment leases After startup discovery, refill persisted fixed-amount and variable lease cache targets. Reuse valid leases and renegotiate only missing or stale entries so receiving can resume promptly after restart. Co-Authored-By: HAL 9000 --- src/lib.rs | 11 +++++++++++ src/liquidity/client/lsps2/mod.rs | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 36ce588100..0f4eda9ae5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -870,6 +870,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 ecbe14f393..f39109c9df 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -206,6 +206,16 @@ where res } + pub(crate) async fn refill_cached_leases( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(), Error> { + self.prune_stale_cache_targets().await?; + for target in self.cache_targets() { + self.schedule_lease_refill(target.id, connection_manager); + } + Ok(()) + } + pub(crate) async fn commit_invoice_cache_target( self: &Arc, request: JitInvoiceRequest, connection_manager: &Arc>, From d2631c23a6edaf617c082a5803aa3cf223ab5fb1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 15:57:04 +0200 Subject: [PATCH 26/38] 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 | 544 ++++++++++++++++++++++++++---- 1 file changed, 485 insertions(+), 59 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index f39109c9df..98d5672df3 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -11,8 +11,9 @@ //! obtaining an intercept SCID for accepted opening fee parameters. See the [`state`] module for //! how leases are tracked. -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; @@ -23,7 +24,7 @@ use lightning::log_warn; use lightning::routing::router::{RouteHint, RouteHintHop}; use lightning::util::ser::Writeable; use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, RoutingFees}; -use lightning_liquidity::lsps0::ser::LSPSRequestId; +use lightning_liquidity::lsps0::ser::{LSPSRequestId, LSPSResponseError}; use lightning_liquidity::lsps2::event::LSPS2ClientEvent; use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; use lightning_liquidity::lsps2::utils::compute_opening_fee; @@ -71,6 +72,89 @@ where } const DEFAULT_BOLT12_INVOICE_EXPIRY_SECS: u32 = 2 * 60 * 60; +const LEASE_NEGOTIATION_MAX_ATTEMPTS: usize = 3; + +#[derive(Debug, PartialEq, Eq)] +enum LeaseNegotiationError { + Local(Error), + Rejected(LSPSResponseError), +} + +impl LeaseNegotiationError { + fn is_retryable(&self) -> bool { + match self { + Self::Local(error) => *error == Error::LiquidityRequestFailed, + // bLIP-52: INVALID_OPENING_FEE_PARAMS. Fetch fresh parameters before buying again. + Self::Rejected(error) => error.code == 201, + } + } +} + +impl From for LeaseNegotiationError { + fn from(error: Error) -> Self { + Self::Local(error) + } +} + +impl From for Error { + fn from(error: LeaseNegotiationError) -> Self { + match error { + LeaseNegotiationError::Local(error) => error, + LeaseNegotiationError::Rejected(_) => Self::LiquidityRequestFailed, + } + } +} + +impl std::fmt::Display for LeaseNegotiationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Local(error) => error.fmt(f), + Self::Rejected(error) => { + write!(f, "LSPS2 request rejected ({}): {}", error.code, error.message) + }, + } + } +} + +fn should_retry_lease_negotiation(error: &LeaseNegotiationError, attempt: usize) -> bool { + error.is_retryable() && attempt < LEASE_NEGOTIATION_MAX_ATTEMPTS +} + +fn record_negotiation_failure( + previous: &mut Option, error: LeaseNegotiationError, +) { + // One LSP's permanent rejection must not suppress retries for another LSP's transient failure. + if previous.as_ref().map_or(true, |error| !error.is_retryable()) { + *previous = Some(error); + } +} + +async fn try_lease_candidates( + candidates: Vec, candidate_key: KF, mut attempt: AF, +) -> Result +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); + record_negotiation_failure(&mut last_error, error); + }, + } + } + Err(last_error.expect("lease candidates are non-empty")) +} /// The time for which we keep a lease cache target committed for an invoice request if its offer /// never expires. @@ -111,9 +195,9 @@ where { pub(crate) lsp_nodes: Arc>>, pub(crate) pending_lsps2_fee_requests: - Mutex>>, + Mutex>>>, pub(crate) pending_buy_requests: - Mutex>>, + Mutex>>>, pub(crate) lease_store: Arc>, pub(crate) lease_state: Mutex, pub(crate) cache_target_store: Arc>, @@ -371,8 +455,29 @@ 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.map_err(Error::from), + } + } + } + + async fn negotiate_fixed_lease_once( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), LeaseNegotiationError> { 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 @@ -396,11 +501,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 + })?; let max_total_lsp_fee_limit_msat = self.max_fixed_amount_fee_msat(amount_msat); if min_total_fee_msat > max_total_lsp_fee_limit_msat { @@ -408,24 +514,45 @@ where "Failed to request inbound JIT channel as LSP's requested total opening fee of {}msat exceeds our fee limit of {}msat", min_total_fee_msat, max_total_lsp_fee_limit_msat ); - return Err(Error::LiquidityFeeTooHigh); + return Err(Error::LiquidityFeeTooHigh.into()); } - - 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)) + candidates.retain(|(_, fee, _)| *fee <= max_total_lsp_fee_limit_msat); + + try_lease_candidates( + candidates, + |(lsp, _, _)| lsp.node_id, + |(lsp, total_fee_msat, opening_params)| { + let client = Arc::clone(self); + async move { + log_debug!( + client.logger, + "Choosing liquidity offer from LSP {}, will pay {}msat in total LSP fees", + lsp.node_id, + total_fee_msat + ); + match client + .lsps2_send_buy_request( + Some(amount_msat), + opening_params, + Some(&lsp.node_id), + ) + .await + { + Ok(lease) => Ok((lease, total_fee_msat, lsp)), + Err(error) => { + log_warn!( + client.logger, + "Failed negotiating LSPS2 payment lease with LSP {}, trying the next candidate: {}", + lsp.node_id, + error + ); + Err(error) + }, + } + } + }, + ) + .await } async fn acquire_variable_lease( @@ -453,9 +580,30 @@ 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.map_err(Error::from), + } + } + } + + async fn negotiate_variable_lease_once( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), LeaseNegotiationError> { 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)) @@ -472,29 +620,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.into() + } 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 - ); + Error::LiquidityRequestFailed.into() + }); + } - let negotiated_lease = self - .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) - .await?; - Ok((negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp)) + try_lease_candidates( + candidates, + |(lsp, _, _)| lsp.node_id, + |(lsp, proportional_fee_ppm_msat, opening_params)| { + let client = Arc::clone(self); + async move { + log_debug!( + client.logger, + "Choosing liquidity offer from LSP {}, will pay {}ppm msat in proportional LSP fees", + lsp.node_id, + proportional_fee_ppm_msat + ); + match client + .lsps2_send_buy_request(None, opening_params, Some(&lsp.node_id)) + .await + { + Ok(lease) => Ok((lease, proportional_fee_ppm_msat, lsp)), + Err(error) => { + log_warn!( + client.logger, + "Failed negotiating LSPS2 payment lease with LSP {}, trying the next candidate: {}", + lsp.node_id, + error + ); + Err(error) + }, + } + } + }, + ) + .await } fn schedule_lease_refill( @@ -566,7 +737,7 @@ where async fn gather_lsps2_offers( self: &Arc, connection_manager: &Arc>, - ) -> Result, Error> { + ) -> Result, LeaseNegotiationError> { let lsps2_nodes = self.get_lsps2_nodes().await?; // Connect to all candidate LSPs in parallel. @@ -586,7 +757,8 @@ where let mut all_offers: Vec<(LspConfig, LSPS2FeeResponse)> = Vec::with_capacity(lsps2_nodes.len()); - let mut fee_set: JoinSet<(LspConfig, Result)> = JoinSet::new(); + let mut failure = None; + let mut fee_set = JoinSet::new(); for lsp_node in &lsps2_nodes { let lsp = lsp_node.clone(); let client = Arc::clone(self); @@ -600,13 +772,20 @@ where Ok((lsp, Ok(fees))) => all_offers.push((lsp, fees)), Ok((lsp, Err(e))) => { log_warn!(self.logger, "Failed to get fees from LSP {}: {}", lsp.node_id, e); + record_negotiation_failure(&mut failure, e); }, Err(e) => { log_warn!(self.logger, "Failed to get fees from LSP: {}", e); + record_negotiation_failure(&mut failure, Error::LiquidityRequestFailed.into()); }, } } + if all_offers.is_empty() { + if let Some(error) = failure { + return Err(error); + } + } Ok(all_offers) } } @@ -617,7 +796,7 @@ where { async fn lsps2_request_opening_fee_params( &self, node_id: Option<&PublicKey>, - ) -> Result { + ) -> Result { let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) .ok_or(Error::LiquiditySourceUnavailable)?; @@ -652,13 +831,14 @@ where .map_err(|e| { log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); Error::LiquidityRequestFailed - }) + })? + .map_err(LeaseNegotiationError::Rejected) } async fn lsps2_send_buy_request( &self, amount_msat: Option, opening_fee_params: LSPS2OpeningFeeParams, node_id: Option<&PublicKey>, - ) -> Result { + ) -> Result { let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) .ok_or(Error::LiquiditySourceUnavailable)?; @@ -700,7 +880,8 @@ where .map_err(|e| { log_error!(self.logger, "Failed to handle response from liquidity service: {:?}", e); Error::LiquidityRequestFailed - })?; + })? + .map_err(LeaseNegotiationError::Rejected)?; let cltv_expiry_delta = state::checked_cltv_expiry_delta(buy_response.cltv_expiry_delta) .ok_or_else(|| { log_error!( @@ -907,7 +1088,7 @@ where { let response = LSPS2FeeResponse { opening_fee_params_menu }; - match request.sender.send(response) { + match request.sender.send(Ok(response)) { Ok(()) => (), Err(_) => { log_error!( @@ -949,7 +1130,7 @@ where { let response = LSPS2BuyResponse { intercept_scid, cltv_expiry_delta }; - match request.sender.send(response) { + match request.sender.send(Ok(response)) { Ok(()) => (), Err(_) => { log_error!( @@ -972,9 +1153,51 @@ where ); } }, - _ => { - log_error!(self.logger, "Received unexpected LSPS2Client liquidity event!"); + LSPS2ClientEvent::GetInfoFailed { request_id, counterparty_node_id, error } => { + self.handle_request_rejection( + &self.pending_lsps2_fee_requests, + request_id, + counterparty_node_id, + error, + ); }, + LSPS2ClientEvent::BuyRequestFailed { request_id, counterparty_node_id, error } => { + self.handle_request_rejection( + &self.pending_buy_requests, + request_id, + counterparty_node_id, + error, + ); + }, + } + } + + fn handle_request_rejection( + &self, + pending: &Mutex>>>, + request_id: LSPSRequestId, counterparty_node_id: PublicKey, error: LSPSResponseError, + ) { + if !self.lsp_nodes.read().expect("lock").iter().any(|n| n.node_id == counterparty_node_id) { + log_error!( + self.logger, + "Received LSPS2 rejection from unexpected peer {}", + counterparty_node_id + ); + return; + } + if let Some(request) = pending.lock().expect("lock").remove(&request_id) { + if request.sender.send(Err(error)).is_err() { + log_error!( + self.logger, + "Failed to handle rejection for liquidity request {:?}", + request_id + ); + } + } else { + log_error!( + self.logger, + "Received rejection from liquidity service for unknown request." + ); } } @@ -1021,6 +1244,156 @@ pub(crate) struct LSPS2BuyResponse { #[cfg(test)] mod tests { use super::*; + use std::future::poll_fn; + use std::task::Poll; + + use crate::builder::NodeBuilder; + use crate::entropy::NodeEntropy; + use crate::io::test_utils::InMemoryStore; + + fn rejection_test_node() -> (crate::Node, PublicKey) { + let node_id = + "0276607124ebe6a6c9338517b6f485825b27c2dcc0b9fc2aa6a4c0df91194e5993".parse().unwrap(); + let mut builder = NodeBuilder::new(); + builder.set_log_facade_logger(); + // An unsupported address makes connection attempts fail without network I/O. + builder.add_liquidity_source( + node_id, + lightning::ln::msgs::SocketAddress::OnionV2([0; 12]), + None, + false, + ); + #[cfg(not(feature = "uniffi"))] + let entropy = NodeEntropy::from_seed_bytes([42; 64]); + #[cfg(feature = "uniffi")] + let entropy = NodeEntropy::from_seed_bytes(vec![42; 64]).unwrap(); + let node = builder.build_with_store(entropy, InMemoryStore::new()).unwrap(); + let client = node.liquidity_source.lsps2_client(); + client.lsp_nodes.write().unwrap()[0].supported_protocols = Some(vec![2]); + (node, node_id) + } + + fn check_rejection_completes_request(is_buy: bool, code: i32) { + let (node, node_id) = rejection_test_node(); + let client = node.liquidity_source.lsps2_client(); + node.runtime.block_on(async { + let params = LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: (chrono::Utc::now() + chrono::Duration::days(2)) + .to_rfc3339() + .parse() + .unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 2_000, + promise: String::new(), + }; + let request = async { + if is_buy { + client + .lsps2_send_buy_request(Some(1_000), params, Some(&node_id)) + .await + .map(|_| ()) + } else { + client.lsps2_request_opening_fee_params(Some(&node_id)).await.map(|_| ()) + } + }; + tokio::pin!(request); + poll_fn(|cx| { + assert!(request.as_mut().poll(cx).is_pending()); + Poll::Ready(()) + }) + .await; + let error = LSPSResponseError { code, message: "request rejected".into(), data: None }; + let expected_error = error.clone(); + let event = if is_buy { + let request_id = + client.pending_buy_requests.lock().unwrap().keys().next().unwrap().clone(); + LSPS2ClientEvent::BuyRequestFailed { + request_id, + counterparty_node_id: node_id, + error, + } + } else { + let request_id = client + .pending_lsps2_fee_requests + .lock() + .unwrap() + .keys() + .next() + .unwrap() + .clone(); + LSPS2ClientEvent::GetInfoFailed { request_id, counterparty_node_id: node_id, error } + }; + client.handle_event(event).await; + let result = poll_fn(|cx| Poll::Ready(request.as_mut().poll(cx))).await; + let Poll::Ready(Err(error)) = result else { + panic!("rejection must complete the request immediately: {result:?}"); + }; + assert_eq!(error, LeaseNegotiationError::Rejected(expected_error)); + assert_eq!(should_retry_lease_negotiation(&error, 1), code == 201); + assert!(!should_retry_lease_negotiation(&error, 3)); + assert!(client.pending_buy_requests.lock().unwrap().is_empty()); + assert!(client.pending_lsps2_fee_requests.lock().unwrap().is_empty()); + }); + } + + #[test] + fn get_info_rejection_completes_request() { + for code in [1, 200, -32602] { + check_rejection_completes_request(false, code); + } + } + + #[test] + fn buy_rejection_completes_request() { + for code in [1, 201, 202, 203, -32602] { + check_rejection_completes_request(true, code); + } + } + + #[test] + fn rejected_fee_queries_stop_lease_negotiation() { + let (node, node_id) = rejection_test_node(); + let client = node.liquidity_source.lsps2_client(); + node.runtime.block_on(async { + for fixed_amount in [true, false] { + let mut rejection_count = 0; + tokio::time::timeout(Duration::from_secs(1), async { + let negotiation = async { + if fixed_amount { + client.negotiate_fixed_lease(1_000, &node.connection_manager).await + } else { + client.negotiate_variable_lease(&node.connection_manager).await + } + }; + let rejections = async { + loop { + let request_id = client.pending_lsps2_fee_requests.lock().unwrap().keys().next().cloned(); + if let Some(request_id) = request_id { + rejection_count += 1; + assert_eq!(rejection_count, 1, "a permanent fee query rejection must not trigger another negotiation round"); + client.handle_event(LSPS2ClientEvent::GetInfoFailed { + request_id, counterparty_node_id: node_id, + error: LSPSResponseError { code: 200, message: "unrecognized token".into(), data: None }, + }).await; + } else { + tokio::task::yield_now().await; + } + } + }; + tokio::select! { + result = negotiation => assert!(matches!(result, Err(Error::LiquidityRequestFailed))), + _ = rejections => unreachable!(), + } + }).await.expect("rejected fee queries must finish without waiting for request timeouts"); + assert_eq!(rejection_count, 1); + assert!(client.pending_lsps2_fee_requests.lock().unwrap().is_empty()); + } + }); + } #[tokio::test] async fn failed_persisted_removal_restores_value() { @@ -1190,6 +1563,59 @@ mod tests { ); } } + + #[test] + fn lease_negotiation_retries_only_transient_failures() { + assert!(should_retry_lease_negotiation(&Error::LiquidityRequestFailed.into(), 1)); + assert!(should_retry_lease_negotiation(&Error::LiquidityRequestFailed.into(), 2)); + assert!(!should_retry_lease_negotiation(&Error::LiquidityRequestFailed.into(), 3)); + assert!(!should_retry_lease_negotiation(&Error::LiquidityFeeTooHigh.into(), 1)); + assert!(!should_retry_lease_negotiation(&Error::LiquiditySourceUnavailable.into(), 1)); + } + + #[tokio::test] + async fn lease_negotiation_preserves_retryable_failures() { + let candidates = + vec![(1, Error::LiquidityRequestFailed), (2, Error::LiquiditySourceUnavailable)]; + for candidates in [candidates.clone(), candidates.into_iter().rev().collect()] { + let result: Result<(), _> = try_lease_candidates( + candidates, + |candidate| candidate.0, + |(_, error)| async move { Err(error.into()) }, + ) + .await; + assert!( + should_retry_lease_negotiation(&result.unwrap_err(), 1), + "a permanent failure must not suppress another LSP's retryable failure" + ); + } + } + + #[tokio::test] + 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(Error::LiquiditySourceUnavailable.into()) + } else { + Ok(candidate.1) + } + } + }, + ) + .await; + + assert_eq!(result, Ok(30)); + assert_eq!(*attempted_lsps.lock().unwrap(), vec![1, 2]); + } } pub(crate) mod router; From e1ab36cddff55daadd04b0e7f04f23329640c539 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 21 Jul 2026 15:46:02 +0200 Subject: [PATCH 27/38] 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/config.rs | 6 +- src/liquidity/client/lsps2/mod.rs | 169 ++++++++++++++++------------ src/liquidity/client/lsps2/state.rs | 64 +++++++++-- 3 files changed, 157 insertions(+), 82 deletions(-) diff --git a/src/config.rs b/src/config.rs index 54c582d961..52684bf0cb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -319,9 +319,9 @@ pub struct Config { /// just-in-time channel. /// /// This limit applies to all configured LSPS2 liquidity sources in addition to the fee negotiated - /// for a payment. Fixed-amount payments never accept a fee exceeding the payment amount, whether - /// or not this limit is set. If unset, variable-amount payments use the negotiated proportional - /// fee as their limit. + /// for a payment. Once the payment amount is known, fees exceeding it are never accepted, whether + /// or not this limit is set. If unset, payments of a yet unknown amount 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/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 98d5672df3..fc830620cc 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -316,38 +316,22 @@ where pub(crate) async fn prepare_invoice_response( self: Arc, request: JitInvoiceRequest, connection_manager: Arc>, ) -> Result>, Error> { - let (lease, fee_parameters) = match request { + let (lease, total_fee_msat, _, _) = match request { JitInvoiceRequest::Fixed { amount_msat, .. } => { - let (lease, total_fee_msat, _, _) = - self.acquire_fixed_lease(amount_msat, &connection_manager).await?; - let min_opening_fee_msat = lease.params.min_fee_msat; - ( - lease, - LSPS2Parameters { - max_total_opening_fee_msat: Some(total_fee_msat), - max_proportional_opening_fee_ppm_msat: None, - min_opening_fee_msat, - }, - ) + self.acquire_fixed_lease(amount_msat, &connection_manager).await? }, - JitInvoiceRequest::Variable { .. } => { - let (lease, proportional_fee, _, _) = - self.acquire_variable_lease(&connection_manager).await?; - let min_opening_fee_msat = lease.params.min_fee_msat; - ( - 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), - min_opening_fee_msat, - }, - ) + JitInvoiceRequest::Variable { amount_msat, .. } => { + // A BOLT12 invoice request has already resolved the payment amount. Use it to avoid + // consuming a cached variable lease outside its advertised range, and record the exact + // fee for that amount so payment validation need not accept the broader node-wide cap. + self.acquire_variable_lease(Some(amount_msat), &connection_manager).await? }, }; + let fee_parameters = LSPS2Parameters { + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + min_opening_fee_msat: lease.params.min_fee_msat, + }; // 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. @@ -405,7 +389,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), @@ -556,10 +540,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 @@ -567,22 +551,22 @@ where .expect("lock") .request_lock(LeaseCacheTargetId::Variable); let _request_guard = request_lock.lock().await; - if let Some((lease, proportional_fee, lsp)) = self.take_cached_variable_lease().await? { - return Ok((lease, proportional_fee, lsp, false)); + if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? { + return Ok((lease, fee, lsp, false)); } - let (negotiated_lease, min_prop_fee_ppm_msat, cheapest_lsp) = - self.negotiate_variable_lease(connection_manager).await?; + let (negotiated_lease, fee, cheapest_lsp) = + self.negotiate_variable_lease(amount_msat, connection_manager).await?; let lease = self.consume_lease(&negotiated_lease.id).await?; - Ok((lease, min_prop_fee_ppm_msat, cheapest_lsp, true)) + Ok((lease, fee, cheapest_lsp, true)) } async fn negotiate_variable_lease( - self: &Arc, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let mut attempt = 1; loop { - let result = self.negotiate_variable_lease_once(connection_manager).await; + let result = self.negotiate_variable_lease_once(amount_msat, connection_manager).await; match result { Err(error) if should_retry_lease_negotiation(&error, attempt) => { log_warn!( @@ -599,7 +583,7 @@ where } async fn negotiate_variable_lease_once( - self: &Arc, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), LeaseNegotiationError> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; @@ -608,20 +592,17 @@ 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 - .config - .lsps2_max_total_lsp_fee_limit_msat - .map_or(true, |limit| params.min_fee_msat <= limit); - rejected_for_fee |= !allowed; - allowed + .filter_map(|(lsp, params)| { + let (selection_fee, fee_for_limit) = + state::variable_lease_fees(¶ms, amount_msat)?; + let fee_allowed = self + .max_variable_amount_fee_msat(amount_msat) + .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!( @@ -638,20 +619,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, @@ -731,7 +721,7 @@ where { return Ok(()); } - self.negotiate_variable_lease(connection_manager).await?; + self.negotiate_variable_lease(None, connection_manager).await?; Ok(()) } @@ -944,6 +934,14 @@ where self.config.lsps2_max_total_lsp_fee_limit_msat.unwrap_or(amount_msat).min(amount_msat) } + /// Variable-amount opening fees are bounded likewise once the payment amount has been resolved. + fn max_variable_amount_fee_msat(&self, amount_msat: Option) -> Option { + match amount_msat { + Some(amount_msat) => Some(self.max_fixed_amount_fee_msat(amount_msat)), + None => self.config.lsps2_max_total_lsp_fee_limit_msat, + } + } + async fn take_cached_fixed_lease( &self, amount_msat: u64, ) -> Result, Error> { @@ -965,16 +963,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.max_variable_amount_fee_msat(amount_msat), + &available_lsps, + ) else { return Ok(None); }; let lsp = lsps2_nodes @@ -982,7 +979,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( @@ -1366,7 +1363,7 @@ mod tests { if fixed_amount { client.negotiate_fixed_lease(1_000, &node.connection_manager).await } else { - client.negotiate_variable_lease(&node.connection_manager).await + client.negotiate_variable_lease(Some(1_000), &node.connection_manager).await } }; let rejections = async { @@ -1616,6 +1613,38 @@ mod tests { assert_eq!(result, Ok(30)); assert_eq!(*attempted_lsps.lock().unwrap(), vec![1, 2]); } + #[test] + fn cached_variable_lease_must_cover_resolved_amount() { + let (node, lsp_node_id) = rejection_test_node(); + let client = node.liquidity_source.lsps2_client(); + let lease = PaymentLease { + id: PaymentLeaseId { lsp_node_id, intercept_scid: 7 }, + params: LSPS2OpeningFeeParams { + min_fee_msat: 1_500, + proportional: 0, + valid_until: (chrono::Utc::now() + chrono::Duration::days(2)) + .to_rfc3339() + .parse() + .unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 10_000, + promise: String::new(), + }, + cltv_expiry_delta: 18, + payment_size_msat: None, + valid_until: now_secs() + 7 * 24 * 60 * 60, + }; + node.runtime.block_on(client.lease_store.insert(lease.clone())).unwrap(); + client.lease_state.lock().unwrap().insert(lease.clone()); + + // No fee limit is configured, so only the resolved amount bounds the acceptable fee. + let taken = node.runtime.block_on(client.take_cached_variable_lease(Some(1_000))).unwrap(); + assert!(taken.is_none(), "a lease fee exceeding the payment amount must not be accepted"); + let taken = node.runtime.block_on(client.take_cached_variable_lease(Some(2_000))).unwrap(); + assert_eq!(taken.map(|(lease, fee_msat, _)| (lease.id, fee_msat)), Some((lease.id, 1_500))); + } } pub(crate) mod router; diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index bce5a18fa0..1a3f9fb7ce 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -479,20 +479,24 @@ impl LSPS2LeaseState { /// Selects and removes a variable-amount lease in one operation. pub(crate) fn variable_amount( - &mut self, max_total_fee_msat: Option, available_lsps: &[PublicKey], + &mut self, amount_msat: Option, max_total_fee_msat: Option, + available_lsps: &[PublicKey], ) -> Option<(PaymentLease, u64)> { - let (id, proportional_fee) = self + let (id, selection_fee) = self .leases .iter() .filter(|(id, _)| available_lsps.contains(&id.lsp_node_id)) .filter(|(_, lease)| lease.payment_size_msat.is_none()) .filter(|(_, lease)| is_lease_usable(lease)) - .filter(|(_, lease)| { - max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max) + .filter_map(|(id, lease)| { + let (selection_fee, fee_for_limit) = + variable_lease_fees(&lease.params, amount_msat)?; + max_total_fee_msat + .map_or(true, |max| fee_for_limit <= max) + .then_some((*id, selection_fee)) }) - .map(|(id, lease)| (*id, lease.params.proportional as u64)) .min_by_key(|(_, fee)| *fee)?; - self.leases.remove(&id).map(|lease| (lease, proportional_fee)) + self.leases.remove(&id).map(|lease| (lease, selection_fee)) } pub(crate) fn has_variable_amount( @@ -511,6 +515,25 @@ impl LSPS2LeaseState { } } +/// Returns the variable lease's (selection fee, fee for the configured limit). +/// Resolved BOLT12 amounts must fit the advertised range and use the exact total fee for both. +/// Unknown BOLT11 amounts rank by proportional fee and check the minimum fee against the limit. +pub(crate) fn variable_lease_fees( + params: &LSPS2OpeningFeeParams, amount_msat: Option, +) -> Option<(u64, u64)> { + if let Some(amount_msat) = amount_msat { + if amount_msat < params.min_payment_size_msat || amount_msat > params.max_payment_size_msat + { + return None; + } + let total_fee = + compute_opening_fee(amount_msat, params.min_fee_msat, params.proportional as u64)?; + Some((total_fee, total_fee)) + } else { + Some((params.proportional as u64, params.min_fee_msat)) + } +} + pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { checked_cltv_expiry_delta(lease.cltv_expiry_delta).is_some() && lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS @@ -698,10 +721,33 @@ mod tests { let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; let variable = lease(2, 46, 50, None, valid_until); let mut state = LSPS2LeaseState::from_leases(vec![variable.clone()]); - let available_lsps = [variable.id.lsp_node_id]; - assert!(state.variable_amount(Some(49), &available_lsps).is_none()); - assert_eq!(state.variable_amount(Some(50), &available_lsps).unwrap().0.id, variable.id); + assert!(state.variable_amount(None, Some(49), &available_lsps).is_none()); + assert_eq!( + state.variable_amount(None, Some(50), &available_lsps).unwrap().0.id, + variable.id + ); + } + + #[test] + fn variable_lease_matches_resolved_payment_amount() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let mut incompatible = lease(2, 47, 1, None, valid_until); + incompatible.params.max_payment_size_msat = 1_000; + let mut compatible = lease(3, 48, 2, None, valid_until); + compatible.params.proportional = 500_000; + compatible.params.max_payment_size_msat = 3_000; + let available_lsps = [incompatible.id.lsp_node_id, compatible.id.lsp_node_id]; + let mut state = LSPS2LeaseState::from_leases(vec![incompatible, compatible.clone()]); + + assert!(state.variable_amount(Some(2_000), Some(999), &available_lsps).is_none()); + let (selected, total_fee_msat) = + state.variable_amount(Some(2_000), None, &available_lsps).unwrap(); + assert_eq!( + selected.id, compatible.id, + "selected variable lease must accept the resolved payment amount" + ); + assert_eq!(total_fee_msat, 1_000); } #[test] From 2084c4a0f380d945c92671137f37c79329adf777 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Nov 2026 14:31:15 +0100 Subject: [PATCH 28/38] f Test fee caps of negotiated leases Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 169 ++++++++++++++++++++++++------ 1 file changed, 138 insertions(+), 31 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index fc830620cc..ab89fd0917 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -607,7 +607,7 @@ where 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" + "Failed to request inbound JIT channel as all LSP offers exceed our fee limit" ); Error::LiquidityFeeTooHigh.into() } else { @@ -1249,9 +1249,16 @@ mod tests { use crate::io::test_utils::InMemoryStore; fn rejection_test_node() -> (crate::Node, PublicKey) { + test_node_with_fee_limit(None) + } + + fn test_node_with_fee_limit(fee_limit_msat: Option) -> (crate::Node, PublicKey) { let node_id = "0276607124ebe6a6c9338517b6f485825b27c2dcc0b9fc2aa6a4c0df91194e5993".parse().unwrap(); - let mut builder = NodeBuilder::new(); + let mut builder = NodeBuilder::from_config(Config { + lsps2_max_total_lsp_fee_limit_msat: fee_limit_msat, + ..Default::default() + }); builder.set_log_facade_logger(); // An unsupported address makes connection attempts fail without network I/O. builder.add_liquidity_source( @@ -1613,37 +1620,137 @@ mod tests { assert_eq!(result, Ok(30)); assert_eq!(*attempted_lsps.lock().unwrap(), vec![1, 2]); } + fn fee_cap_test_params() -> LSPS2OpeningFeeParams { + LSPS2OpeningFeeParams { + min_fee_msat: 1_500, + proportional: 0, + valid_until: (chrono::Utc::now() + chrono::Duration::days(2)) + .to_rfc3339() + .parse() + .unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 10_000, + promise: String::new(), + } + } + #[test] fn cached_variable_lease_must_cover_resolved_amount() { - let (node, lsp_node_id) = rejection_test_node(); - let client = node.liquidity_source.lsps2_client(); - let lease = PaymentLease { - id: PaymentLeaseId { lsp_node_id, intercept_scid: 7 }, - params: LSPS2OpeningFeeParams { - min_fee_msat: 1_500, - proportional: 0, - valid_until: (chrono::Utc::now() + chrono::Duration::days(2)) - .to_rfc3339() - .parse() - .unwrap(), - min_lifetime: 1, - max_client_to_self_delay: 1, - min_payment_size_msat: 1, - max_payment_size_msat: 10_000, - promise: String::new(), - }, - cltv_expiry_delta: 18, - payment_size_msat: None, - valid_until: now_secs() + 7 * 24 * 60 * 60, - }; - node.runtime.block_on(client.lease_store.insert(lease.clone())).unwrap(); - client.lease_state.lock().unwrap().insert(lease.clone()); - - // No fee limit is configured, so only the resolved amount bounds the acceptable fee. - let taken = node.runtime.block_on(client.take_cached_variable_lease(Some(1_000))).unwrap(); - assert!(taken.is_none(), "a lease fee exceeding the payment amount must not be accepted"); - let taken = node.runtime.block_on(client.take_cached_variable_lease(Some(2_000))).unwrap(); - assert_eq!(taken.map(|(lease, fee_msat, _)| (lease.id, fee_msat)), Some((lease.id, 1_500))); + // The resolved amount bounds the acceptable fee, both without a configured fee limit and + // with a configured limit exceeding the amount. + for fee_limit_msat in [None, Some(2_000)] { + let (node, lsp_node_id) = test_node_with_fee_limit(fee_limit_msat); + let client = node.liquidity_source.lsps2_client(); + let lease = PaymentLease { + id: PaymentLeaseId { lsp_node_id, intercept_scid: 7 }, + params: fee_cap_test_params(), + cltv_expiry_delta: 18, + payment_size_msat: None, + valid_until: now_secs() + 7 * 24 * 60 * 60, + }; + node.runtime.block_on(client.lease_store.insert(lease.clone())).unwrap(); + client.lease_state.lock().unwrap().insert(lease.clone()); + + let taken = + node.runtime.block_on(client.take_cached_variable_lease(Some(1_499))).unwrap(); + assert!( + taken.is_none(), + "a lease fee exceeding the payment amount must not be accepted" + ); + let taken = + node.runtime.block_on(client.take_cached_variable_lease(Some(1_500))).unwrap(); + assert_eq!( + taken.map(|(lease, fee_msat, _)| (lease.id, fee_msat)), + Some((lease.id, 1_500)), + "a lease fee equal to the payment amount must be accepted" + ); + } + } + + #[test] + fn negotiated_lease_fees_must_not_exceed_payment_amount() { + // As for cached leases, the amount bounds the fee of freshly negotiated leases, both + // without a configured fee limit and with a configured limit exceeding the amount. + for fee_limit_msat in [None, Some(2_000)] { + let (node, node_id) = test_node_with_fee_limit(fee_limit_msat); + let client = node.liquidity_source.lsps2_client(); + node.runtime.block_on(async { + for (fixed_amount, amount_msat) in + [(true, 1_499), (true, 1_500), (false, 1_499), (false, 1_500)] + { + let negotiation = async { + if fixed_amount { + client + .negotiate_fixed_lease(amount_msat, &node.connection_manager) + .await + } else { + client + .negotiate_variable_lease( + Some(amount_msat), + &node.connection_manager, + ) + .await + } + }; + let responses = async { + loop { + let fee_request_id = client + .pending_lsps2_fee_requests + .lock() + .unwrap() + .keys() + .next() + .cloned(); + if let Some(request_id) = fee_request_id { + client + .handle_event(LSPS2ClientEvent::OpeningParametersReady { + request_id, + counterparty_node_id: node_id, + opening_fee_params_menu: vec![fee_cap_test_params()], + }) + .await; + } + let buy_request_id = + client.pending_buy_requests.lock().unwrap().keys().next().cloned(); + if let Some(request_id) = buy_request_id { + client + .handle_event(LSPS2ClientEvent::InvoiceParametersReady { + request_id, + counterparty_node_id: node_id, + intercept_scid: 42, + cltv_expiry_delta: 144, + payment_size_msat: fixed_amount.then_some(amount_msat), + }) + .await; + } + tokio::task::yield_now().await; + } + }; + let result = tokio::time::timeout(Duration::from_secs(1), async { + tokio::select! { + result = negotiation => result, + _ = responses => unreachable!(), + } + }) + .await + .expect("lease negotiation must finish without waiting for request timeouts"); + if amount_msat < 1_500 { + assert!( + matches!(result, Err(Error::LiquidityFeeTooHigh)), + "a negotiated fee exceeding the payment amount must not be accepted" + ); + } else { + assert_eq!( + result.map(|(_, fee_msat, _)| fee_msat), + Ok(1_500), + "a negotiated fee equal to the payment amount must be accepted" + ); + } + } + }); + } } } From 5d065609becc118a4156c952725155ccfb3d10d5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 10:02:19 +0200 Subject: [PATCH 29/38] 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 | 284 ++++++++++++++++++++++------ src/liquidity/client/lsps2/state.rs | 156 ++++++++++----- src/payment/bolt11.rs | 12 ++ 3 files changed, 347 insertions(+), 105 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index ab89fd0917..2dbf28e6a0 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -46,8 +46,9 @@ use crate::{Config, Error}; use self::router::LSPS2LeaseParameters; use self::state::{ - now_secs, LSPS2LeaseState, LeaseCacheTarget, LeaseCacheTargetId, LeaseCacheTargetStore, - PaymentLease, PaymentLeaseId, PaymentLeaseStore, PendingLeaseRequestState, + is_lease_usable, is_lease_valid_for, now_secs, LSPS2LeaseState, LeaseCacheTarget, + LeaseCacheTargetId, LeaseCacheTargetStore, PaymentLease, PaymentLeaseId, PaymentLeaseStore, + PendingLeaseRequestState, MIN_CACHED_LEASE_REMAINING_SECS, }; // LSPS2 requires two blocks more than the usual final CLTV delta. @@ -129,6 +130,19 @@ fn record_negotiation_failure( } } +fn opening_fee_params_valid_for(params: &LSPS2OpeningFeeParams, min_remaining_secs: u64) -> bool { + let Ok(valid_until) = u64::try_from(params.valid_until.0.timestamp()) else { + return false; + }; + // Leave time for a full buy round trip before the invoice's lifetime begins. + let Some(min_remaining_secs) = min_remaining_secs.checked_add(LIQUIDITY_REQUEST_TIMEOUT_SECS) + else { + return false; + }; + let now = now_secs(); + valid_until > now && valid_until.saturating_sub(now) >= min_remaining_secs +} + async fn try_lease_candidates( candidates: Vec, candidate_key: KF, mut attempt: AF, ) -> Result @@ -219,7 +233,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_usable(lease)) .await .into_iter() .map(|lease| lease.id) @@ -318,13 +332,23 @@ where ) -> Result>, Error> { let (lease, total_fee_msat, _, _) = match request { JitInvoiceRequest::Fixed { amount_msat, .. } => { - self.acquire_fixed_lease(amount_msat, &connection_manager).await? + self.acquire_fixed_lease( + amount_msat, + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, + &connection_manager, + ) + .await? }, JitInvoiceRequest::Variable { amount_msat, .. } => { // A BOLT12 invoice request has already resolved the payment amount. Use it to avoid // consuming a cached variable lease outside its advertised range, and record the exact // fee for that amount so payment validation need not accept the broader node-wide cap. - self.acquire_variable_lease(Some(amount_msat), &connection_manager).await? + self.acquire_variable_lease( + Some(amount_msat), + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, + &connection_manager, + ) + .await? }, }; let fee_parameters = LSPS2Parameters { @@ -335,8 +359,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 { @@ -362,7 +385,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, @@ -389,7 +412,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), @@ -411,10 +434,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)); } @@ -425,23 +449,25 @@ where .request_lock(LeaseCacheTargetId::Fixed { amount_msat }); let _request_guard = request_lock.lock().await; if let Some((lease, total_fee_msat, lsp)) = - self.take_cached_fixed_lease(amount_msat).await? + self.take_cached_fixed_lease(amount_msat, min_remaining_secs).await? { return Ok((lease, total_fee_msat, lsp, false)); } - let (negotiated_lease, min_total_fee_msat, cheapest_lsp) = - self.negotiate_fixed_lease(amount_msat, connection_manager).await?; - let lease = self.consume_lease(&negotiated_lease.id).await?; + let (lease, min_total_fee_msat, cheapest_lsp) = + self.negotiate_fixed_lease(amount_msat, min_remaining_secs, connection_manager).await?; Ok((lease, min_total_fee_msat, cheapest_lsp, true)) } async fn negotiate_fixed_lease( - self: &Arc, amount_msat: u64, connection_manager: &Arc>, + self: &Arc, amount_msat: u64, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let mut attempt = 1; loop { - let result = self.negotiate_fixed_lease_once(amount_msat, connection_manager).await; + let result = self + .negotiate_fixed_lease_once(amount_msat, min_remaining_secs, connection_manager) + .await; match result { Err(error) if should_retry_lease_negotiation(&error, attempt) => { log_warn!( @@ -458,7 +484,8 @@ where } async fn negotiate_fixed_lease_once( - self: &Arc, amount_msat: u64, connection_manager: &Arc>, + self: &Arc, amount_msat: u64, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), LeaseNegotiationError> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut candidates = all_offers @@ -469,6 +496,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 { @@ -519,6 +554,7 @@ where Some(amount_msat), opening_params, Some(&lsp.node_id), + min_remaining_secs, ) .await { @@ -540,9 +576,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 @@ -551,22 +590,27 @@ where .expect("lock") .request_lock(LeaseCacheTargetId::Variable); let _request_guard = request_lock.lock().await; - if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? { + if let Some((lease, fee, lsp)) = + self.take_cached_variable_lease(amount_msat, min_remaining_secs).await? + { return Ok((lease, fee, lsp, false)); } - let (negotiated_lease, fee, cheapest_lsp) = - self.negotiate_variable_lease(amount_msat, connection_manager).await?; - let lease = self.consume_lease(&negotiated_lease.id).await?; + let (lease, fee, cheapest_lsp) = self + .negotiate_variable_lease(amount_msat, min_remaining_secs, connection_manager) + .await?; Ok((lease, fee, cheapest_lsp, true)) } async fn negotiate_variable_lease( - self: &Arc, amount_msat: Option, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), Error> { let mut attempt = 1; loop { - let result = self.negotiate_variable_lease_once(amount_msat, connection_manager).await; + let result = self + .negotiate_variable_lease_once(amount_msat, min_remaining_secs, connection_manager) + .await; match result { Err(error) if should_retry_lease_negotiation(&error, attempt) => { log_warn!( @@ -583,7 +627,8 @@ where } async fn negotiate_variable_lease_once( - self: &Arc, amount_msat: Option, connection_manager: &Arc>, + self: &Arc, amount_msat: Option, min_remaining_secs: u64, + connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig), LeaseNegotiationError> { let all_offers = self.gather_lsps2_offers(connection_manager).await?; let mut rejected_for_fee = false; @@ -593,6 +638,14 @@ where resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params)) }) .filter_map(|(lsp, params)| { + if !opening_fee_params_valid_for(¶ms, min_remaining_secs) { + log_debug!( + self.logger, + "Skipping LSP {}'s JIT offer as it expires before the requested invoice", + lsp.node_id + ); + return None; + } let (selection_fee, fee_for_limit) = state::variable_lease_fees(¶ms, amount_msat)?; let fee_allowed = self @@ -638,7 +691,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)), @@ -698,8 +756,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( @@ -721,7 +781,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(()) } @@ -827,7 +897,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)?; @@ -840,6 +910,14 @@ where let (buy_request_sender, buy_request_receiver) = oneshot::channel(); let _pending_request = { let mut pending_buy_requests_lock = self.pending_buy_requests.lock().expect("lock"); + // Earlier buy attempts may have exhausted this candidate's negotiation margin. + if !opening_fee_params_valid_for(&opening_fee_params, min_remaining_secs) { + log_debug!( + self.logger, + "Skipping LSPS2 buy as opening parameters no longer cover the requested invoice and buy timeout" + ); + return Err(Error::LiquidityRequestFailed.into()); + } let request_id = client_handler .select_opening_params(lsps2_node.node_id, amount_msat, opening_fee_params.clone()) .map_err(|e| { @@ -899,21 +977,16 @@ where payment_size_msat: amount_msat, valid_until, }; - self.lease_store.insert(lease.clone()).await?; - self.lease_state.lock().expect("lock").insert(lease.clone()); + if !is_lease_valid_for(&lease, min_remaining_secs) { + log_warn!( + self.logger, + "LSPS2 payment lease expired before it could cover the requested invoice" + ); + return Err(Error::LiquidityRequestFailed.into()); + } Ok(lease) } - async fn consume_lease(&self, id: &PaymentLeaseId) -> Result { - let lease = self - .lease_state - .lock() - .expect("lock") - .take_valid(id) - .ok_or(Error::LiquidityRequestFailed)?; - self.consume_selected_lease(lease).await - } - async fn consume_selected_lease(&self, lease: PaymentLease) -> Result { let lease_store = Arc::clone(&self.lease_store); // Selection has already removed the lease from the shared state so no other payment can @@ -943,13 +1016,14 @@ where } async fn take_cached_fixed_lease( - &self, amount_msat: u64, + &self, amount_msat: u64, min_remaining_secs: 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, Some(self.max_fixed_amount_fee_msat(amount_msat)), + min_remaining_secs, &available_lsps, ) else { return Ok(None); @@ -963,13 +1037,14 @@ where } async fn take_cached_variable_lease( - &self, amount_msat: Option, + &self, amount_msat: Option, min_remaining_secs: 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)) = self.lease_state.lock().expect("lock").variable_amount( amount_msat, self.max_variable_amount_fee_msat(amount_msat), + min_remaining_secs, &available_lsps, ) else { return Ok(None); @@ -1297,7 +1372,7 @@ mod tests { let request = async { if is_buy { client - .lsps2_send_buy_request(Some(1_000), params, Some(&node_id)) + .lsps2_send_buy_request(Some(1_000), params, Some(&node_id), 3_600) .await .map(|_| ()) } else { @@ -1344,6 +1419,62 @@ mod tests { }); } + #[test] + fn cached_leases_only_need_to_outlive_the_invoice() { + let (node, node_id) = rejection_test_node(); + let client = node.liquidity_source.lsps2_client(); + // Shorter than the margin we require when refilling the cache, but long enough to back an + // invoice that expires within two hours. + let valid_until = now_secs() + MIN_CACHED_LEASE_REMAINING_SECS - 3_600; + let lease = |intercept_scid, payment_size_msat| PaymentLease { + id: PaymentLeaseId { lsp_node_id: node_id, intercept_scid }, + params: LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: chrono::DateTime::from_timestamp(valid_until as i64, 0) + .unwrap() + .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(), + }, + cltv_expiry_delta: 144, + payment_size_msat, + valid_until, + }; + node.runtime.block_on(async { + client.store_cached_lease(lease(1, Some(1_000))).await.unwrap(); + client.store_cached_lease(lease(2, None)).await.unwrap(); + client.prune_stale_leases().await.unwrap(); + + let invoice_expiry_secs = 2 * 3_600; + assert!( + client + .take_cached_fixed_lease(1_000, MIN_CACHED_LEASE_REMAINING_SECS) + .await + .unwrap() + .is_none(), + "a lease expiring before the invoice must not be used" + ); + assert!( + client.take_cached_fixed_lease(1_000, invoice_expiry_secs).await.unwrap().is_some(), + "a cached fixed-amount lease outliving the invoice must be usable" + ); + assert!( + client + .take_cached_variable_lease(Some(1_000), invoice_expiry_secs) + .await + .unwrap() + .is_some(), + "a cached variable-amount lease outliving the invoice must be usable" + ); + }); + } + #[test] fn get_info_rejection_completes_request() { for code in [1, 200, -32602] { @@ -1368,9 +1499,9 @@ mod tests { tokio::time::timeout(Duration::from_secs(1), async { let negotiation = async { if fixed_amount { - client.negotiate_fixed_lease(1_000, &node.connection_manager).await + client.negotiate_fixed_lease(1_000, 3_600, &node.connection_manager).await } else { - client.negotiate_variable_lease(Some(1_000), &node.connection_manager).await + client.negotiate_variable_lease(Some(1_000), 3_600, &node.connection_manager).await } }; let rejections = async { @@ -1461,7 +1592,7 @@ mod tests { let client = node.liquidity_source.lsps2_client(); client.lsp_nodes.write().unwrap()[0].supported_protocols = Some(vec![2]); - let valid_until = now_secs() + 2 * state::MIN_LEASE_REMAINING_SECS; + let valid_until = now_secs() + 2 * MIN_CACHED_LEASE_REMAINING_SECS; let lease = |intercept_scid, amount_msat| PaymentLease { id: PaymentLeaseId { lsp_node_id, intercept_scid }, params: LSPS2OpeningFeeParams { @@ -1595,6 +1726,34 @@ mod tests { } } + #[test] + fn opening_params_must_cover_invoice_lifetime() { + let valid_until = chrono::Utc::now() + chrono::Duration::hours(3); + let mut params = LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: valid_until.to_rfc3339().parse().unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 2_000, + promise: String::new(), + }; + + assert!(opening_fee_params_valid_for(¶ms, 2 * 60 * 60)); + assert!(!opening_fee_params_valid_for(¶ms, 4 * 60 * 60)); + assert!(!opening_fee_params_valid_for(¶ms, u64::MAX)); + + let invoice_lifetime_secs = 2 * 60 * 60; + let valid_until = + chrono::Utc::now() + chrono::Duration::seconds(invoice_lifetime_secs as i64 + 1); + params.valid_until = valid_until.to_rfc3339().parse().unwrap(); + assert!( + !opening_fee_params_valid_for(¶ms, invoice_lifetime_secs), + "opening parameters leave no time for the buy round trip" + ); + } + #[tokio::test] async fn lease_negotiation_fails_over_between_lsps() { let candidates = vec![(1, 10), (1, 20), (2, 30)]; @@ -1653,14 +1812,24 @@ mod tests { node.runtime.block_on(client.lease_store.insert(lease.clone())).unwrap(); client.lease_state.lock().unwrap().insert(lease.clone()); - let taken = - node.runtime.block_on(client.take_cached_variable_lease(Some(1_499))).unwrap(); + let taken = node + .runtime + .block_on(client.take_cached_variable_lease( + Some(1_499), + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, + )) + .unwrap(); assert!( taken.is_none(), "a lease fee exceeding the payment amount must not be accepted" ); - let taken = - node.runtime.block_on(client.take_cached_variable_lease(Some(1_500))).unwrap(); + let taken = node + .runtime + .block_on(client.take_cached_variable_lease( + Some(1_500), + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, + )) + .unwrap(); assert_eq!( taken.map(|(lease, fee_msat, _)| (lease.id, fee_msat)), Some((lease.id, 1_500)), @@ -1683,12 +1852,17 @@ mod tests { let negotiation = async { if fixed_amount { client - .negotiate_fixed_lease(amount_msat, &node.connection_manager) + .negotiate_fixed_lease( + amount_msat, + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, + &node.connection_manager, + ) .await } else { client .negotiate_variable_lease( Some(amount_msat), + DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64, &node.connection_manager, ) .await diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs index 1a3f9fb7ce..f4786ba438 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -19,7 +19,9 @@ //! //! Negotiating a lease takes several round trips to the LSP. To avoid that latency when a payer //! is waiting, we record [`LeaseCacheTarget`]s for the amounts we expect to receive, so that -//! matching leases can be negotiated ahead of time. +//! matching leases can be negotiated ahead of time. When no cached lease matches an invoice +//! request, one is negotiated on demand and handed to that request alone, without ever entering +//! the shared pool. use std::collections::HashMap; use std::ops::Deref; @@ -46,7 +48,7 @@ use crate::Error; use super::LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA; -pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; +pub(crate) const MIN_CACHED_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; pub(crate) type PaymentLeaseStore = DataStore; @@ -428,24 +430,17 @@ impl LSPS2LeaseState { .collect() } - 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, @@ -466,7 +461,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, @@ -480,14 +475,14 @@ impl LSPS2LeaseState { /// Selects and removes a variable-amount lease in one operation. pub(crate) fn variable_amount( &mut self, amount_msat: Option, max_total_fee_msat: Option, - available_lsps: &[PublicKey], + min_remaining_secs: u64, available_lsps: &[PublicKey], ) -> Option<(PaymentLease, u64)> { let (id, selection_fee) = self .leases .iter() .filter(|(id, _)| available_lsps.contains(&id.lsp_node_id)) .filter(|(_, lease)| lease.payment_size_msat.is_none()) - .filter(|(_, lease)| is_lease_usable(lease)) + .filter(|(_, lease)| is_lease_valid_for(lease, min_remaining_secs)) .filter_map(|(id, lease)| { let (selection_fee, fee_for_limit) = variable_lease_fees(&lease.params, amount_msat)?; @@ -506,7 +501,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)) .any(|lease| max_total_fee_msat.map_or(true, |max| lease.params.min_fee_msat <= max)) } @@ -535,8 +530,7 @@ pub(crate) fn variable_lease_fees( } pub(crate) fn is_lease_usable(lease: &PaymentLease) -> bool { - checked_cltv_expiry_delta(lease.cltv_expiry_delta).is_some() - && lease.valid_until.saturating_sub(now_secs()) >= MIN_LEASE_REMAINING_SECS + checked_cltv_expiry_delta(lease.cltv_expiry_delta).is_some() && lease.valid_until > now_secs() } pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { @@ -545,6 +539,14 @@ pub(crate) fn checked_cltv_expiry_delta(cltv_expiry_delta: u32) -> Option { .filter(|delta| *delta <= u16::MAX - LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA) } +pub(crate) fn is_lease_valid_for(lease: &PaymentLease, min_remaining_secs: u64) -> bool { + is_lease_usable(lease) && lease.valid_until.saturating_sub(now_secs()) >= min_remaining_secs +} + +pub(crate) fn is_lease_cacheable(lease: &PaymentLease) -> bool { + is_lease_valid_for(lease, MIN_CACHED_LEASE_REMAINING_SECS) +} + pub(crate) fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or(Duration::ZERO).as_secs() } @@ -636,53 +638,108 @@ 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; + fn prunes_only_expired_leases() { + let expired = lease(2, 43, 1, Some(1_000), now_secs()); + // Too close to expiry to be worth caching, but still usable for a short-lived invoice. + let expiring = + lease(2, 44, 1, Some(2_000), now_secs() + MIN_CACHED_LEASE_REMAINING_SECS - 1); + let available_lsps = [expired.id.lsp_node_id]; let mut state = LSPS2LeaseState::default(); - state.insert(lease); + state.insert(expired); + state.insert(expiring.clone()); state.prune(); - assert!(state.take_valid(&id).is_none()); + assert_eq!(state.leases.len(), 1, "only the expired lease must be pruned"); + assert_eq!( + state.fixed_amount(2_000, None, 1, &available_lsps).map(|(lease, _)| lease), + Some(expiring) + ); } #[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!( @@ -693,45 +750,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); @@ -739,10 +796,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" @@ -752,7 +808,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]; @@ -767,7 +823,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]); @@ -780,7 +836,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/payment/bolt11.rs b/src/payment/bolt11.rs index 562739827e..ccb6c5dacd 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -607,6 +607,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`. + /// /// We choose the cheapest offer and return [`Error::LiquidityFeeTooHigh`] if its opening fee /// exceeds `amount_msat` or the configured [`Config::lsps2_max_total_lsp_fee_limit_msat`]. /// @@ -630,6 +633,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`. + /// /// We choose the cheapest offer and return [`Error::LiquidityFeeTooHigh`] if its opening fee /// exceeds `amount_msat` or the configured [`Config::lsps2_max_total_lsp_fee_limit_msat`]. /// @@ -678,6 +684,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 @@ -700,6 +709,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 b9f8cf3c851c5a0a9ee8fe0c67c8c33bff831d0c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Nov 2026 14:38:42 +0100 Subject: [PATCH 30/38] f Leave a margin between invoice and lease expiry Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 123 +++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index 2dbf28e6a0..db8423e310 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -130,6 +130,17 @@ fn record_negotiation_failure( } } +/// The time a lease backing an invoice must remain valid beyond the invoice's expiry. +/// +/// Payers may attempt a payment until the invoice expires, while the LSP drops an unpaid intercept +/// SCID as soon as its lease does. The margin leaves time for such a late payment to arrive. +const LEASE_EXPIRY_MARGIN_SECS: u64 = 10 * 60; + +/// Returns how long a lease must remain valid to back an invoice expiring in `invoice_expiry_secs`. +fn min_lease_validity_secs(invoice_expiry_secs: u64) -> u64 { + invoice_expiry_secs.saturating_add(LEASE_EXPIRY_MARGIN_SECS) +} + 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; @@ -359,7 +370,8 @@ 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 !is_lease_valid_for(&lease, DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64) { + let min_remaining_secs = min_lease_validity_secs(DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64); + if !is_lease_valid_for(&lease, min_remaining_secs) { return Err(Error::LiquidityRequestFailed); } let lease_parameters = LSPS2LeaseParameters { @@ -434,9 +446,10 @@ where } async fn acquire_fixed_lease( - self: &Arc, amount_msat: u64, min_remaining_secs: u64, + self: &Arc, amount_msat: u64, invoice_expiry_secs: u64, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { + let min_remaining_secs = min_lease_validity_secs(invoice_expiry_secs); if let Some((lease, total_fee_msat, lsp)) = self.take_cached_fixed_lease(amount_msat, min_remaining_secs).await? { @@ -576,9 +589,10 @@ where } async fn acquire_variable_lease( - self: &Arc, amount_msat: Option, min_remaining_secs: u64, + self: &Arc, amount_msat: Option, invoice_expiry_secs: u64, connection_manager: &Arc>, ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { + let min_remaining_secs = min_lease_validity_secs(invoice_expiry_secs); if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat, min_remaining_secs).await? { @@ -1419,6 +1433,109 @@ mod tests { }); } + #[test] + fn invoice_leases_must_outlive_the_invoice_by_a_margin() { + let (node, node_id) = rejection_test_node(); + let client = node.liquidity_source.lsps2_client(); + let invoice_expiry_secs = DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64; + let lease = |intercept_scid, payment_size_msat, valid_until: u64| PaymentLease { + id: PaymentLeaseId { lsp_node_id: node_id, intercept_scid }, + params: LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: chrono::DateTime::from_timestamp(valid_until as i64, 0) + .unwrap() + .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(), + }, + cltv_expiry_delta: 144, + payment_size_msat, + valid_until, + }; + node.runtime.block_on(async { + for (fixed_amount, intercept_scid) in [(true, 1), (false, 3)] { + let payment_size_msat = fixed_amount.then_some(1_000); + let acquire = || async { + let acquisition = async { + if fixed_amount { + client + .acquire_fixed_lease( + 1_000, + invoice_expiry_secs, + &node.connection_manager, + ) + .await + } else { + client + .acquire_variable_lease( + Some(1_000), + invoice_expiry_secs, + &node.connection_manager, + ) + .await + } + }; + // Reject any fee query so that a lease can only be acquired from the cache. + let rejections = async { + loop { + let request_id = client + .pending_lsps2_fee_requests + .lock() + .unwrap() + .keys() + .next() + .cloned(); + if let Some(request_id) = request_id { + client + .handle_event(LSPS2ClientEvent::GetInfoFailed { + request_id, + counterparty_node_id: node_id, + error: LSPSResponseError { + code: 200, + message: "unrecognized token".into(), + data: None, + }, + }) + .await; + } else { + tokio::task::yield_now().await; + } + } + }; + tokio::time::timeout(Duration::from_secs(1), async { + tokio::select! { + result = acquisition => result, + _ = rejections => unreachable!(), + } + }) + .await + .expect("lease acquisition must finish without waiting for request timeouts") + }; + + let valid_until = now_secs() + invoice_expiry_secs + LEASE_EXPIRY_MARGIN_SECS; + let short_lease = lease(intercept_scid, payment_size_msat, valid_until - 60); + client.store_cached_lease(short_lease).await.unwrap(); + assert!( + matches!(acquire().await, Err(Error::LiquidityRequestFailed)), + "a cached lease expiring just after the invoice must not back it" + ); + + let sufficient_lease = + lease(intercept_scid + 1, payment_size_msat, valid_until + 60); + client.store_cached_lease(sufficient_lease.clone()).await.unwrap(); + let (acquired, _, _, is_fresh) = acquire().await.unwrap(); + assert_eq!(acquired.id, sufficient_lease.id); + assert!(!is_fresh); + } + }); + } + #[test] fn cached_leases_only_need_to_outlive_the_invoice() { let (node, node_id) = rejection_test_node(); From efa68f7ec5a8d13cef7422c27fff7da34c4cca30 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 12 Nov 2026 08:22:20 +0100 Subject: [PATCH 31/38] f Test the lease margin on negotiation The margin test derived its boundary from the margin constant and only acquired cached leases, so it kept passing without any margin and with negotiation ignoring it. Use fixed offsets around the margin and also cover freshly negotiated fixed- and variable-amount leases. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/mod.rs | 118 +++++++++++++++++++----------- 1 file changed, 75 insertions(+), 43 deletions(-) diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs index db8423e310..2f80a2bbaa 100644 --- a/src/liquidity/client/lsps2/mod.rs +++ b/src/liquidity/client/lsps2/mod.rs @@ -1436,24 +1436,26 @@ mod tests { #[test] fn invoice_leases_must_outlive_the_invoice_by_a_margin() { let (node, node_id) = rejection_test_node(); - let client = node.liquidity_source.lsps2_client(); + let client = &node.liquidity_source.lsps2_client(); + let connection_manager = &node.connection_manager; let invoice_expiry_secs = DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64; + let params = |valid_until: u64| LSPS2OpeningFeeParams { + min_fee_msat: 1, + proportional: 1, + valid_until: chrono::DateTime::from_timestamp(valid_until as i64, 0) + .unwrap() + .to_rfc3339() + .parse() + .unwrap(), + min_lifetime: 1, + max_client_to_self_delay: 1, + min_payment_size_msat: 1, + max_payment_size_msat: 2_000, + promise: String::new(), + }; let lease = |intercept_scid, payment_size_msat, valid_until: u64| PaymentLease { id: PaymentLeaseId { lsp_node_id: node_id, intercept_scid }, - params: LSPS2OpeningFeeParams { - min_fee_msat: 1, - proportional: 1, - valid_until: chrono::DateTime::from_timestamp(valid_until as i64, 0) - .unwrap() - .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(), - }, + params: params(valid_until), cltv_expiry_delta: 144, payment_size_msat, valid_until, @@ -1461,39 +1463,42 @@ mod tests { node.runtime.block_on(async { for (fixed_amount, intercept_scid) in [(true, 1), (false, 3)] { let payment_size_msat = fixed_amount.then_some(1_000); - let acquire = || async { + // Acquires a lease while the LSP offers opening parameters valid until the given time + // and accepts any buy request for them. Without an offer, the LSP rejects fee queries so + // that a lease can only be acquired from the cache. + let acquire = |offered_valid_until: Option| async move { let acquisition = async { if fixed_amount { client - .acquire_fixed_lease( - 1_000, - invoice_expiry_secs, - &node.connection_manager, - ) + .acquire_fixed_lease(1_000, invoice_expiry_secs, connection_manager) .await } else { client .acquire_variable_lease( Some(1_000), invoice_expiry_secs, - &node.connection_manager, + connection_manager, ) .await } }; - // Reject any fee query so that a lease can only be acquired from the cache. - let rejections = async { + let responses = async { loop { - let request_id = client + let fee_request_id = client .pending_lsps2_fee_requests .lock() .unwrap() .keys() .next() .cloned(); - if let Some(request_id) = request_id { - client - .handle_event(LSPS2ClientEvent::GetInfoFailed { + if let Some(request_id) = fee_request_id { + let event = match offered_valid_until { + Some(valid_until) => LSPS2ClientEvent::OpeningParametersReady { + request_id, + counterparty_node_id: node_id, + opening_fee_params_menu: vec![params(valid_until)], + }, + None => LSPS2ClientEvent::GetInfoFailed { request_id, counterparty_node_id: node_id, error: LSPSResponseError { @@ -1501,35 +1506,62 @@ mod tests { message: "unrecognized token".into(), data: None, }, + }, + }; + client.handle_event(event).await; + } + let buy_request_id = + client.pending_buy_requests.lock().unwrap().keys().next().cloned(); + if let Some(request_id) = buy_request_id { + client + .handle_event(LSPS2ClientEvent::InvoiceParametersReady { + request_id, + counterparty_node_id: node_id, + intercept_scid: 100 + intercept_scid, + cltv_expiry_delta: 144, + payment_size_msat, }) .await; - } else { - tokio::task::yield_now().await; } + tokio::task::yield_now().await; } }; tokio::time::timeout(Duration::from_secs(1), async { tokio::select! { result = acquisition => result, - _ = rejections => unreachable!(), + _ = responses => unreachable!(), } }) .await .expect("lease acquisition must finish without waiting for request timeouts") }; - let valid_until = now_secs() + invoice_expiry_secs + LEASE_EXPIRY_MARGIN_SECS; - let short_lease = lease(intercept_scid, payment_size_msat, valid_until - 60); + let invoice_expires_at = now_secs() + invoice_expiry_secs; + let (within_margin, beyond_margin) = + (invoice_expires_at + 5 * 60, invoice_expires_at + 15 * 60); + + let short_lease = lease(intercept_scid, payment_size_msat, within_margin); client.store_cached_lease(short_lease).await.unwrap(); assert!( - matches!(acquire().await, Err(Error::LiquidityRequestFailed)), + matches!(acquire(None).await, Err(Error::LiquidityRequestFailed)), "a cached lease expiring just after the invoice must not back it" ); + assert!( + matches!( + acquire(Some(within_margin)).await, + Err(Error::LiquidityRequestFailed) + ), + "a lease expiring just after the invoice must not be negotiated to back it" + ); + + let (negotiated, _, _, is_fresh) = acquire(Some(beyond_margin)).await.unwrap(); + assert_eq!(negotiated.id.intercept_scid, 100 + intercept_scid); + assert_eq!(negotiated.valid_until, beyond_margin); + assert!(is_fresh); - let sufficient_lease = - lease(intercept_scid + 1, payment_size_msat, valid_until + 60); + let sufficient_lease = lease(intercept_scid + 1, payment_size_msat, beyond_margin); client.store_cached_lease(sufficient_lease.clone()).await.unwrap(); - let (acquired, _, _, is_fresh) = acquire().await.unwrap(); + let (acquired, _, _, is_fresh) = acquire(None).await.unwrap(); assert_eq!(acquired.id, sufficient_lease.id); assert!(!is_fresh); } @@ -1537,7 +1569,7 @@ mod tests { } #[test] - fn cached_leases_only_need_to_outlive_the_invoice() { + fn cached_leases_only_need_to_outlive_the_invoice_by_the_margin() { let (node, node_id) = rejection_test_node(); let client = node.liquidity_source.lsps2_client(); // Shorter than the margin we require when refilling the cache, but long enough to back an @@ -1568,7 +1600,7 @@ mod tests { client.store_cached_lease(lease(2, None)).await.unwrap(); client.prune_stale_leases().await.unwrap(); - let invoice_expiry_secs = 2 * 3_600; + let min_remaining_secs = min_lease_validity_secs(2 * 3_600); assert!( client .take_cached_fixed_lease(1_000, MIN_CACHED_LEASE_REMAINING_SECS) @@ -1578,16 +1610,16 @@ mod tests { "a lease expiring before the invoice must not be used" ); assert!( - client.take_cached_fixed_lease(1_000, invoice_expiry_secs).await.unwrap().is_some(), - "a cached fixed-amount lease outliving the invoice must be usable" + client.take_cached_fixed_lease(1_000, min_remaining_secs).await.unwrap().is_some(), + "a cached fixed-amount lease outliving the invoice by the margin must be usable" ); assert!( client - .take_cached_variable_lease(Some(1_000), invoice_expiry_secs) + .take_cached_variable_lease(Some(1_000), min_remaining_secs) .await .unwrap() .is_some(), - "a cached variable-amount lease outliving the invoice must be usable" + "a cached variable-amount lease outliving the invoice by the margin must be usable" ); }); } From c612cb7a7d470c4557d6a16332cc5c55625c58b3 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:12:34 +0200 Subject: [PATCH 32/38] 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 6aacb4aff1..a5cdea8770 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -7164,6 +7164,163 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { expect_payment_successful_event!(payer_node, variable_payment_id, None); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn bolt12_lsps2_client_service_integration() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + + let mut sync_config = EsploraSyncConfig::default(); + sync_config.background_sync_config = None; + + let channel_opening_fee_ppm = 10_000; + let lsps2_service_config = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm, + channel_over_provisioning_ppm: 100_000, + max_payment_size_msat: 1_000_000_000, + min_payment_size_msat: 0, + min_channel_lifetime: 100, + min_channel_opening_fee_msat: 0, + max_client_to_self_delay: 1024, + client_trusts_lsp: true, + disable_client_reserve: false, + }; + + let service_config = random_config(); + setup_builder!(service_builder, service_config.node_config); + service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + service_builder.enable_liquidity_provider(lsps2_service_config); + let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); + service_node.start().unwrap(); + + let service_node_id = service_node.node_id(); + let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); + + let client_config = random_config(); + setup_builder!(client_builder, client_config.node_config); + client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + client_builder.add_liquidity_source(service_node_id, service_addr, None, true); + let client_node = client_builder.build(client_config.node_entropy.into()).unwrap(); + client_node.start().unwrap(); + + let payer_config = random_config(); + setup_builder!(payer_builder, payer_config.node_config); + payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); + payer_node.start().unwrap(); + + let client_addr = client_node.listening_addresses().unwrap().first().unwrap().clone(); + payer_node.connect(client_node.node_id(), client_addr, false).unwrap(); + + let service_onchain_addr = service_node.onchain_payment().new_address().unwrap(); + let client_onchain_addr = client_node.onchain_payment().new_address().unwrap(); + let payer_onchain_addr = payer_node.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![service_onchain_addr, client_onchain_addr, payer_onchain_addr], + Amount::from_sat(10_000_000), + ) + .await; + service_node.sync_wallets().unwrap(); + client_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + + open_channel(&payer_node, &service_node, 5_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + service_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + expect_channel_ready_event!(payer_node, service_node.node_id()); + expect_channel_ready_event!(service_node, payer_node.node_id()); + while payer_node.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let fixed_amount_msat = 100_000_000; + // The recipient has no channel yet, but the connected payer gives the default message router a + // peer through which it can construct an offer path. This keeps the long-lived offer addressed + // by a blinded node path; the single-use intercept SCID is introduced only in the invoice. + assert!(client_node.list_channels().is_empty()); + let fixed_offer = + client_node.bolt12_payment().receive(fixed_amount_msat, "fixed", None, None).unwrap(); + assert!(!fixed_offer.paths().is_empty()); + assert_ne!(fixed_offer.issuer_signing_pubkey(), Some(client_node.node_id())); + let fixed_payment_id = + payer_node.bolt12_payment().send(&fixed_offer, None, None, None).unwrap(); + + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_event!(service_node, PaymentForwarded); + expect_channel_pending_event!(client_node, service_node.node_id()); + expect_channel_ready_event!(client_node, service_node.node_id()); + expect_payment_successful_event!(payer_node, fixed_payment_id, None); + let fixed_fee_msat = fixed_amount_msat * channel_opening_fee_ppm as u64 / 1_000_000; + let fixed_received_msat = fixed_amount_msat - fixed_fee_msat; + let fixed_receiver_payment_id = + expect_payment_received_event!(client_node, fixed_received_msat); + match client_node.payment(&fixed_receiver_payment_id).unwrap().unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(fixed_fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } + + // The over-provisioned part of the first JIT channel can receive this payment in full. The + // offers flow must therefore keep the ordinary blinded path and avoid negotiating or exposing a + // second intercept SCID. + let client_channel_count = client_node.list_channels().len(); + let service_channel_count = service_node.list_channels().len(); + let ordinary_amount_msat = 5_000_000; + let ordinary_offer = client_node + .bolt12_payment() + .receive(ordinary_amount_msat, "existing inbound", None, None) + .unwrap(); + let ordinary_payment_id = + payer_node.bolt12_payment().send(&ordinary_offer, None, None, None).unwrap(); + + expect_event!(service_node, PaymentForwarded); + expect_payment_successful_event!(payer_node, ordinary_payment_id, None); + let ordinary_receiver_payment_id = + expect_payment_received_event!(client_node, ordinary_amount_msat); + match client_node.payment(&ordinary_receiver_payment_id).unwrap().unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, None); + }, + _ => panic!("Unexpected payment kind"), + } + assert_eq!(client_node.list_channels().len(), client_channel_count); + assert_eq!(service_node.list_channels().len(), service_channel_count); + + // The first JIT channel cannot carry this larger variable payment. The variable offer disables + // MPP, so the payer must use the fresh JIT path rather than splitting across both paths. + let variable_amount_msat = 200_000_000; + let variable_offer = + client_node.bolt12_payment().receive_variable_amount("variable", None).unwrap(); + let variable_payment_id = payer_node + .bolt12_payment() + .send_using_amount(&variable_offer, variable_amount_msat, None, None, None) + .unwrap(); + + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_event!(service_node, PaymentForwarded); + expect_channel_pending_event!(client_node, service_node.node_id()); + expect_channel_ready_event!(client_node, service_node.node_id()); + expect_payment_successful_event!(payer_node, variable_payment_id, None); + let variable_fee_msat = variable_amount_msat * channel_opening_fee_ppm as u64 / 1_000_000; + let variable_received_msat = variable_amount_msat - variable_fee_msat; + let variable_receiver_payment_id = + expect_payment_received_event!(client_node, variable_received_msat); + match client_node.payment(&variable_receiver_payment_id).unwrap().unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(variable_fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn facade_logging() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); From 593f1da76cc878c213f548e4e954b2b27392094e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:16:21 +0200 Subject: [PATCH 33/38] lsps2: Test multi-LSP BOLT12 selection Extend the order-independent fee-selection scenario through a real BOLT12 payment. Also prove that BOLT11 consumption and BOLT12 response handling share the same replenished lease pool. Co-Authored-By: HAL 9000 --- tests/integration_tests_rust.rs | 69 ++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 2 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index a5cdea8770..cebca144a4 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -8663,17 +8663,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, @@ -8730,12 +8731,52 @@ async fn do_lsps2_multi_lsp_picks_cheapest( let client = client_builder.build(client_config.node_entropy.into()).unwrap(); client.start().unwrap(); + let payer_config = random_config(); + setup_builder!(payer_builder, payer_config.node_config); + payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + let payer = payer_builder.build(payer_config.node_entropy.into()).unwrap(); + payer.start().unwrap(); + + let client_addr = client.listening_addresses().unwrap().first().unwrap().clone(); + payer.connect(client.node_id(), client_addr, false).unwrap(); + + let cheap_onchain_addr = cheap.onchain_payment().new_address().unwrap(); + let client_onchain_addr = client.onchain_payment().new_address().unwrap(); + let payer_onchain_addr = payer.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![cheap_onchain_addr, client_onchain_addr, payer_onchain_addr], + Amount::from_sat(10_000_000), + ) + .await; + cheap.sync_wallets().unwrap(); + client.sync_wallets().unwrap(); + payer.sync_wallets().unwrap(); + + open_channel(&payer, &cheap, 5_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + cheap.sync_wallets().unwrap(); + payer.sync_wallets().unwrap(); + expect_channel_ready_event!(payer, cheap.node_id()); + expect_channel_ready_event!(cheap, payer.node_id()); + while payer.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let invoice_description = Bolt11InvoiceDescription::Direct(Description::new(String::from("asdf")).unwrap()).into(); let jit_invoice_result = client.bolt11_payment().receive_via_jit_channel(100_000_000, &invoice_description, 1024); if max_total_lsp_fee_limit_msat.is_some() { assert!(matches!(jit_invoice_result, Err(NodeError::LiquidityFeeTooHigh))); + let offer = client.bolt12_payment().receive(100_000_000, "multi LSP", None, None).unwrap(); + let payment_id = payer.bolt12_payment().send(&offer, None, None, None).unwrap(); + expect_event!(payer, PaymentFailed); + assert_eq!(payer.payment(&payment_id).unwrap().unwrap().status, PaymentStatus::Failed); + assert!(client.list_channels().is_empty()); + payer.stop().unwrap(); client.stop().unwrap(); cheap.stop().unwrap(); expensive.stop().unwrap(); @@ -8753,6 +8794,30 @@ async fn do_lsps2_multi_lsp_picks_cheapest( let route_hint_src = first_hop.expect("route hint should have at least one hop").src_node_id; assert_eq!(route_hint_src, cheap_id, "expected cheaper LSP to be selected."); + // Consuming the BOLT11 lease schedules a replacement. The BOLT12 flow shares that cache and + // must retain the same cheapest-LSP selection regardless of registration order. + let payment_amount_msat = 100_000_000; + let offer = + client.bolt12_payment().receive(payment_amount_msat, "multi LSP", None, None).unwrap(); + let payment_id = payer.bolt12_payment().send(&offer, None, None, None).unwrap(); + + expect_channel_pending_event!(cheap, client.node_id()); + expect_channel_ready_event!(cheap, client.node_id()); + expect_event!(cheap, PaymentForwarded); + expect_channel_pending_event!(client, cheap.node_id()); + expect_channel_ready_event!(client, cheap.node_id()); + expect_payment_successful_event!(payer, payment_id, None); + let fee_msat = payment_amount_msat * cheap_opening_fee_ppm as u64 / 1_000_000; + let receiver_payment_id = + expect_payment_received_event!(client, payment_amount_msat - fee_msat); + match client.payment(&receiver_payment_id).unwrap().unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } + + payer.stop().unwrap(); client.stop().unwrap(); cheap.stop().unwrap(); expensive.stop().unwrap(); From ab353b59852a2215f5b04c1aeac5728562ef9863 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:24:29 +0200 Subject: [PATCH 34/38] 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 | 113 ++++++++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 4 deletions(-) diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index cebca144a4..f8211c4ee2 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -7198,10 +7198,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(); @@ -7239,6 +7243,22 @@ async fn bolt12_lsps2_client_service_integration() { tokio::time::sleep(std::time::Duration::from_secs(1)).await; let fixed_amount_msat = 100_000_000; + // A unified URI's offer must register a lease target as well, so that its first payer need not + // wait for an on-demand negotiation. The client has no channel yet, so a lease is negotiated + // in the background right away. + assert!(client_node.list_channels().is_empty()); + assert!(KVStore::list(&client_store, "lsps2_leases", "").await.unwrap().is_empty()); + let unified_uri = + client_node.unified_payment().receive(fixed_amount_msat / 1_000, "unified", 3_600).unwrap(); + assert!(unified_uri.to_lowercase().contains("lno="), "unified URI should carry an offer"); + tokio::time::timeout(std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), async { + while KVStore::list(&client_store, "lsps2_leases", "").await.unwrap().is_empty() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("unified offer should pre-negotiate a lease"); + // 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. @@ -7293,8 +7313,93 @@ async fn bolt12_lsps2_client_service_integration() { assert_eq!(client_node.list_channels().len(), client_channel_count); assert_eq!(service_node.list_channels().len(), service_channel_count); - // The first JIT channel cannot carry this larger variable payment. The variable offer disables - // MPP, so the payer must use the fresh JIT path rather than splitting across both paths. + let persisted_lease_key = + tokio::time::timeout(std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), async { + loop { + let keys = KVStore::list(&client_store, "lsps2_leases", "").await.unwrap(); + if let Some(key) = keys.first() { + return key.clone(); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("replacement lease should be persisted"); + assert!(KVStore::read(&client_store, "lsps2_leases", "", &persisted_lease_key).await.is_ok()); + + let client_node_id = client_node.node_id(); + client_node.stop().unwrap(); + drop(client_node); + tokio::time::timeout(std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), async { + while payer_node.list_peers().iter().any(|peer| peer.node_id == client_node_id) + || service_node.list_peers().iter().any(|peer| peer.node_id == client_node_id) + { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("peers should observe the stopped client"); + + setup_builder!(restarted_client_builder, client_config.node_config); + restarted_client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + restarted_client_builder.add_liquidity_source( + service_node_id, + service_addr.clone(), + None, + true, + ); + let client_node = restarted_client_builder + .build_with_store(client_config.node_entropy.into(), client_store.clone()) + .unwrap(); + assert_eq!(client_node.node_id(), client_node_id); + assert!(KVStore::read(&client_store, "lsps2_leases", "", &persisted_lease_key).await.is_ok()); + client_node.start().unwrap(); + let client_addr = client_node.listening_addresses().unwrap().first().unwrap().clone(); + payer_node.connect(client_node.node_id(), client_addr, false).unwrap(); + + let restored_payment_id = + payer_node.bolt12_payment().send(&fixed_offer, None, None, None).unwrap(); + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_event!(service_node, PaymentForwarded); + loop { + let event = tokio::time::timeout( + std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), + client_node.next_event_async(), + ) + .await + .expect("restored client should receive a payment event"); + match event { + Event::ChannelPending { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, service_node.node_id()); + client_node.event_handled().unwrap(); + break; + }, + Event::PaymentReceived { payment_id, amount_msat, .. } => { + assert_eq!(payment_id, ordinary_receiver_payment_id); + assert_eq!(amount_msat, ordinary_amount_msat); + client_node.event_handled().unwrap(); + }, + unexpected => panic!("Unexpected event after restoring client: {unexpected:?}"), + } + } + expect_channel_ready_event!(client_node, service_node.node_id()); + expect_payment_successful_event!(payer_node, restored_payment_id, None); + let restored_receiver_payment_id = + expect_payment_received_event!(client_node, fixed_received_msat); + match client_node.payment(&restored_receiver_payment_id).unwrap().unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(fixed_fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } + assert!( + KVStore::read(&client_store, "lsps2_leases", "", &persisted_lease_key).await.is_err(), + "the restored lease must be consumed instead of renegotiating" + ); + + // The fixed JIT channels cannot carry this larger variable payment. The variable offer disables + // MPP, so the payer must use the fresh JIT path rather than splitting across the existing paths. let variable_amount_msat = 200_000_000; let variable_offer = client_node.bolt12_payment().receive_variable_amount("variable", None).unwrap(); From f79e3df875d57fb936764bc464f44da1a76b9326 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 16 Jul 2026 16:45:04 +0200 Subject: [PATCH 35/38] 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 f4786ba438..4030059047 100644 --- a/src/liquidity/client/lsps2/state.rs +++ b/src/liquidity/client/lsps2/state.rs @@ -704,6 +704,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_only_expired_leases() { let expired = lease(2, 43, 1, Some(1_000), now_secs()); From 4c9fff9d5c3ce4ecd30cc02210d937ad3eb790f0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 14 Aug 2026 13:01:41 +0200 Subject: [PATCH 36/38] 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 | 190 ++++++++++++++++++++++++--- 2 files changed, 174 insertions(+), 18 deletions(-) diff --git a/src/builder.rs b/src/builder.rs index 8a19aa6efc..037bb88737 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -2153,6 +2153,7 @@ fn build_with_store_internal( Arc::clone(&scorer), scoring_fee_params, ), + Arc::clone(&network_graph), Arc::clone(&keys_manager), )); @@ -2558,6 +2559,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 01949bc4a0..8b2f0d6640 100644 --- a/src/liquidity/client/lsps2/router.rs +++ b/src/liquidity/client/lsps2/router.rs @@ -17,12 +17,15 @@ use lightning::blinded_path::IntroductionNode; use lightning::impl_writeable_tlv_based; use lightning::ln::channel_state::ChannelDetails; use lightning::ln::channelmanager::PaymentId; +use lightning::routing::gossip::NodeId; use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; use lightning::sign::{EntropySource, ReceiveAuthKey}; use lightning::types::features::BlindedHopFeatures; use lightning::types::payment::PaymentHash; +use std::sync::Arc; use crate::payment::PaymentMetadata; +use crate::types::Graph; use super::LSPS2_MIN_FINAL_CLTV_EXPIRY_DELTA; @@ -52,13 +55,14 @@ impl_writeable_tlv_based!(LSPS2LeaseParameters, { /// A router wrapper that uses ordinary payment paths when available and LSPS2 JIT paths otherwise. pub struct LSPS2Router { inner_router: R, + network_graph: Arc, entropy_source: ES, } impl LSPS2Router { /// Constructs an LSPS2-aware wrapper around `inner_router`. - pub fn new(inner_router: R, entropy_source: ES) -> Self { - Self { inner_router, entropy_source } + pub fn new(inner_router: R, network_graph: Arc, entropy_source: ES) -> Self { + Self { inner_router, network_graph, entropy_source } } fn payment_parameters(&self, payment_context: &PaymentContext) -> Option { @@ -103,15 +107,28 @@ impl Router for LSPS2Router { ) -> Result, ()> { let parameters = self.payment_parameters(&tlvs.payment_context); let allow_mpp = parameters.map_or(true, |params| params.payment_size_msat.is_some()); + let is_eligible_first_hop = |channel: &&ChannelDetails| { + channel.is_announced + && 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 +152,10 @@ 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. + // public, blinded-path-eligible channels can receive the resolved amount: across channels when + // MPP is allowed, or on one channel, including its HTLC bounds, when MPP is disabled. A direct + // recipient path provides no hint for payers to discover private channels. + // Otherwise it would hide the need for a JIT path and leave an invoice that cannot be paid. // // Always prefer usable ordinary paths. Besides avoiding an unnecessary channel open, this // prevents an MPP payer from splitting one payment across regular and JIT paths. The LSP only @@ -210,17 +228,22 @@ mod tests { use super::*; use bitcoin::secp256k1::SecretKey; + use bitcoin::Network; use core::sync::atomic::{AtomicUsize, Ordering}; use lightning::blinded_path::payment::{Bolt12OfferContext, PaymentConstraints}; - use lightning::ln::channel_state::{ChannelCounterparty, ChannelShutdownState}; + use lightning::ln::channel_state::{ + ChannelCounterparty, ChannelShutdownState, CounterpartyForwardingInfo, + }; use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning::ln::types::ChannelId; use lightning::offers::invoice_request::InvoiceRequestFields; use lightning::offers::offer::OfferId; - use lightning::types::features::InitFeatures; + use lightning::types::features::{ChannelFeatures, InitFeatures}; use lightning::types::payment::PaymentSecret; use std::collections::BTreeMap; + use crate::logger::Logger; + #[derive(Clone)] struct TestEntropy; @@ -316,7 +339,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, }, @@ -341,7 +368,7 @@ mod tests { is_channel_ready: true, channel_shutdown_state: Some(ChannelShutdownState::NotShuttingDown), is_usable: true, - is_announced: false, + is_announced: true, inbound_htlc_minimum_msat, inbound_htlc_maximum_msat, config: None, @@ -353,6 +380,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]), @@ -387,7 +434,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( @@ -422,7 +469,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( @@ -447,7 +494,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( @@ -477,7 +524,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 @@ -504,7 +551,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 @@ -533,7 +580,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( @@ -549,6 +596,113 @@ mod tests { assert_eq!(paths[0].introduction_node(), &IntroductionNode::NodeId(recipient)); } + #[test] + fn ignores_private_channel_capacity_for_direct_fallback() { + let recipient = pubkey(10); + let lsp_node_id = pubkey(11); + let inner_router = + MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; + let router = LSPS2Router::new(inner_router, announced_graph(recipient, true), TestEntropy); + + for payment_size_msat in [Some(3_000), None] { + let parameters = LSPS2LeaseParameters { + lsp_node_id, + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat, + valid_until: u64::MAX, + }; + for is_announced in [false, true] { + let mut channel = first_hop(3_000, None, None); + channel.is_announced = is_announced; + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + vec![first_hop(1_500, None, None), channel], + payment_tlvs(payment_metadata(parameters)), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + let introduction_node = if is_announced { recipient } else { lsp_node_id }; + assert_eq!( + paths[0].introduction_node(), + &IntroductionNode::NodeId(introduction_node), + "direct fallback must only count public channel capacity" + ); + } + } + } + + #[test] + fn ignores_capacity_without_forwarding_info() { + let recipient = pubkey(10); + 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); @@ -562,7 +716,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( @@ -591,7 +745,7 @@ mod tests { }; let inner_router = MockRouter { calls: AtomicUsize::new(0), path_kind: MockPathKind::DirectRecipient }; - let router = LSPS2Router::new(inner_router, TestEntropy); + let router = LSPS2Router::new(inner_router, empty_graph(), TestEntropy); let paths = router .create_blinded_payment_paths( From 32697f4dff82ab335f2209de645d8630aa6d0e15 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 12 Nov 2026 08:23:22 +0100 Subject: [PATCH 37/38] f Count capacity with large CLTV deltas Inbound capacity only counted towards a direct recipient path if the counterparty's forwarding parameters also fit a blinded hop. Payers reach a direct path over the public graph though, where a large CLTV expiry delta is no obstacle, so such capacity was ignored and invoice creation failed without a lease despite sufficient public inbound. Only require the counterparty to advertise the channel. Co-Authored-By: HAL 9000 --- src/liquidity/client/lsps2/router.rs | 46 +++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/liquidity/client/lsps2/router.rs b/src/liquidity/client/lsps2/router.rs index 8b2f0d6640..f1b87cfd9c 100644 --- a/src/liquidity/client/lsps2/router.rs +++ b/src/liquidity/client/lsps2/router.rs @@ -113,11 +113,7 @@ impl Router for LSPS2Router { .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()) + .is_some() && channel.counterparty.forwarding_info.is_some() }; let direct_path_has_sufficient_liquidity = amount_msats.map_or(true, |amount_msats| { if allow_mpp { @@ -152,9 +148,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 - // public, blinded-path-eligible channels can receive the resolved amount: across channels when - // MPP is allowed, or on one channel, including its HTLC bounds, when MPP is disabled. A direct - // recipient path provides no hint for payers to discover private channels. + // public channels advertised by their counterparty can receive the resolved amount: across + // channels when MPP is allowed, or on one channel, including its HTLC bounds, when MPP is + // disabled. A direct recipient path provides no hint for payers to discover private channels. // Otherwise it would hide the need for a JIT path and leave an invoice that cannot be paid. // // Always prefer usable ordinary paths. Besides avoiding an unnecessary channel open, this @@ -670,6 +666,40 @@ mod tests { ); } + #[test] + fn counts_capacity_with_large_cltv_delta_for_direct_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, + }; + // Too large for a blinded hop, but payers route to a direct path over the public graph. + let mut large_cltv_delta = first_hop(3_000, None, None); + large_cltv_delta.counterparty.forwarding_info.as_mut().unwrap().cltv_expiry_delta = 288; + 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![large_cltv_delta], + payment_tlvs(payment_metadata(parameters)), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!( + paths[0].introduction_node(), + &IntroductionNode::NodeId(recipient), + "a large CLTV expiry delta must not exclude publicly routable capacity" + ); + } + #[test] fn ignores_capacity_from_unannounced_peer() { let recipient = pubkey(10); From 68ee68484a4b04d09e692babfdaecbd93e5c3030 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 11 Nov 2026 12:09:00 +0100 Subject: [PATCH 38/38] Expire unified offers with their URI `UnifiedPayment::receive` applied `expiry_sec` only to the BOLT 11 invoice, while the BOLT 12 offer in the same URI never expired. Besides contradicting the documented meaning of the parameter, this left every unified receive needing a JIT channel with a permanent LSPS2 lease cache target, for which leases were re-negotiated indefinitely. Apply the expiry to the offer as well, so that all Lightning payment methods of a URI lapse together and lease cache targets registered for unified offers are pruned once the URI can no longer be paid. Co-Authored-By: HAL 9000 --- CHANGELOG.md | 2 ++ src/payment/unified.rs | 23 ++++++++++++++--------- tests/integration_tests_rust.rs | 9 +++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2be1a28b21..0198e86876 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ offer's invoice request, no LSPS2 just-in-time channel is available. Such invoices could not be paid. This also applies to refunds, which are never received through just-in-time channels, and to nodes without a configured LSP. +- The BOLT 12 offer in URIs generated by `UnifiedPayment::receive` now expires after + `expiry_sec`, like the BOLT 11 invoice. Previously, the offer never expired. - Upgrading from LDK Node v0.1 is no longer supported if the event queue still contains a persisted `ChannelClosed` event. - Users of the VSS storage backend must upgrade their VSS server to at least version diff --git a/src/payment/unified.rs b/src/payment/unified.rs index f7db4a7f11..78cbc23a72 100644 --- a/src/payment/unified.rs +++ b/src/payment/unified.rs @@ -121,7 +121,8 @@ impl UnifiedPayment { /// - `amount_sats`: The amount to be received, specified in satoshis. /// - `description`: A description or note associated with the payment. /// This message is visible to the payer and can provide context or details about the payment. - /// - `expiry_sec`: The expiration time for the payment, specified in seconds. + /// - `expiry_sec`: The expiration time for the payment, specified in seconds. It applies + /// to both the BOLT11 invoice and the BOLT12 offer. /// /// Returns a payable URI that can be used to request and receive a payment of the amount /// given. Failure to generate the on-chain address will result in an error return @@ -139,14 +140,18 @@ impl UnifiedPayment { let onchain_address = self.onchain_payment.new_address()?; - let bolt12_offer = - match self.bolt12_payment.receive_inner(amount_msats, description, None, None) { - Ok(offer) => Some(maybe_wrap(offer)), - Err(e) => { - log_error!(self.logger, "Failed to create offer: {}", e); - None - }, - }; + let bolt12_offer = match self.bolt12_payment.receive_inner( + amount_msats, + description, + Some(expiry_sec), + None, + ) { + Ok(offer) => Some(maybe_wrap(offer)), + Err(e) => { + log_error!(self.logger, "Failed to create offer: {}", e); + None + }, + }; let invoice_description = Bolt11InvoiceDescription::Direct( Description::new(description.to_string()).map_err(|_| Error::InvoiceCreationFailed)?, diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index f8211c4ee2..06157a1ebb 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -6713,6 +6713,15 @@ async fn unified_send_receive_bip21_uri() { let uni_payment = node_b.unified_payment().receive(expected_amount_sats, "asdf", expiry_sec); let uri_str = uni_payment.clone().unwrap(); + + // The offer must expire together with the rest of the URI. + let offer_str = uri_str.split("&lno=").nth(1).unwrap().split('&').next().unwrap(); + let offer = offer_str.to_lowercase().parse::().unwrap(); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap(); + let offer_expiry = offer.absolute_expiry().expect("unified offer must honor the URI expiry"); + assert!(offer_expiry > now); + assert!(offer_expiry <= now + Duration::from_secs(expiry_sec as u64)); + let offer_payment_id: PaymentId = match node_a.unified_payment().send(&uri_str, None, None).await { Ok(UnifiedPaymentResult::Bolt12 { payment_id }) => {