diff --git a/Cargo.toml b/Cargo.toml index 7fc945833e..ae2141963c 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -185,16 +185,16 @@ harness = false #vss-client-ng = { path = "../vss-client" } #vss-client-ng = { git = "https://github.com/lightningdevkit/vss-client", branch = "main" } # -#[patch."https://github.com/lightningdevkit/rust-lightning"] -#lightning = { path = "../rust-lightning/lightning" } -#lightning-types = { path = "../rust-lightning/lightning-types" } -#lightning-invoice = { path = "../rust-lightning/lightning-invoice" } +[patch."https://github.com/lightningdevkit/rust-lightning"] +lightning = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "5df6db764b88f57913f18923124838cc66bf6400" } +lightning-types = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "5df6db764b88f57913f18923124838cc66bf6400" } +lightning-invoice = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "5df6db764b88f57913f18923124838cc66bf6400" } #lightning-net-tokio = { path = "../rust-lightning/lightning-net-tokio" } #lightning-persister = { path = "../rust-lightning/lightning-persister" } #lightning-background-processor = { path = "../rust-lightning/lightning-background-processor" } #lightning-rapid-gossip-sync = { path = "../rust-lightning/lightning-rapid-gossip-sync" } #lightning-block-sync = { path = "../rust-lightning/lightning-block-sync" } #lightning-transaction-sync = { path = "../rust-lightning/lightning-transaction-sync" } -#lightning-liquidity = { path = "../rust-lightning/lightning-liquidity" } -#lightning-macros = { path = "../rust-lightning/lightning-macros" } +lightning-liquidity = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "5df6db764b88f57913f18923124838cc66bf6400" } +lightning-macros = { git = "https://git.rust-bitcoin.org/tnull/rust-lightning", rev = "5df6db764b88f57913f18923124838cc66bf6400" } #lightning-dns-resolver = { path = "../rust-lightning/lightning-dns-resolver" } diff --git a/src/builder.rs b/src/builder.rs index a70b04b2ab..c5aa49663f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -69,11 +69,13 @@ 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}; use crate::message_handler::NodeCustomMessageHandler; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; +use crate::payment::NodeOffersMessageHandler; use crate::peer_store::PeerStore; use crate::probing::{ HighDegreeStrategy, Prober, ProbingConfig, ProbingStrategy, ProbingStrategyKind, @@ -1900,12 +1902,15 @@ fn build_with_store_internal( } let scoring_fee_params = ProbabilisticScoringFeeParameters::default(); - let router = Arc::new(DefaultRouter::new( - Arc::clone(&network_graph), - Arc::clone(&logger), + let router = Arc::new(LSPS2Router::new( + DefaultRouter::new( + Arc::clone(&network_graph), + Arc::clone(&logger), + Arc::clone(&keys_manager), + Arc::clone(&scorer), + scoring_fee_params, + ), Arc::clone(&keys_manager), - Arc::clone(&scorer), - scoring_fee_params, )); let mut user_config = default_user_config(&config); @@ -1980,6 +1985,15 @@ fn build_with_store_internal( }; let channel_manager = Arc::new(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(&router), + Arc::clone(&message_router), + Arc::clone(&logger), + )); // Give ChannelMonitors to ChainMonitor for (_blockhash, channel_monitor) in channel_monitors.into_iter() { @@ -2050,7 +2064,7 @@ fn build_with_store_internal( Arc::clone(&logger), Arc::clone(&channel_manager), message_router, - Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&channel_manager), Arc::clone(&om_resolver), IgnoringMessageHandler {}, @@ -2063,7 +2077,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 {}, @@ -2189,6 +2203,12 @@ fn build_with_store_internal( Arc::clone(&keys_manager), Arc::clone(&logger), )); + offers_message_handler.initialize_jit_handling( + Arc::clone(&runtime), + liquidity_source.lsps2_client(), + Arc::downgrade(&connection_manager), + Arc::downgrade(&onion_messenger), + ); let output_sweeper = match sweeper_bytes_res { Ok(output_sweeper) => Arc::new(output_sweeper), @@ -2270,12 +2290,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), @@ -2321,6 +2344,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 f857ef5333..061c8132ca 100644 --- a/src/chain/bitcoind.rs +++ b/src/chain/bitcoind.rs @@ -42,6 +42,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -148,7 +149,8 @@ impl BitcoindChainSource { pub(super) async fn continuously_sync_wallets( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) { // First register for the wallet polling status to make sure `Node::sync_wallets` calls // wait on the result before proceeding. @@ -176,11 +178,13 @@ impl BitcoindChainSource { let onchain_wallet_best_block = onchain_wallet.current_best_block(); let channel_manager_best_block = channel_manager.current_best_block(); + let offers_best_block = offers_message_handler.current_best_block(); let sweeper_best_block = output_sweeper.current_best_block(); let mut chain_listeners = vec![ (onchain_wallet_best_block, &*onchain_wallet as &(dyn Listen + Send + Sync)), (channel_manager_best_block, &*channel_manager as &(dyn Listen + Send + Sync)), + (offers_best_block, &*offers_message_handler as &(dyn Listen + Send + Sync)), (sweeper_best_block, &*output_sweeper as &(dyn Listen + Send + Sync)), ]; @@ -324,6 +328,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) ) => {} @@ -381,7 +386,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"); @@ -401,6 +407,7 @@ impl BitcoindChainSource { .poll_and_update_listeners_inner( onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) @@ -413,7 +420,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 latest_chain_tip_opt = self.latest_chain_tip.read().expect("lock").clone(); let chain_tip = @@ -423,6 +431,7 @@ impl BitcoindChainSource { let chain_listener = ChainListener { onchain_wallet: Arc::clone(&onchain_wallet), channel_manager: Arc::clone(&channel_manager), + offers_message_handler: Arc::clone(&offers_message_handler), chain_monitor: Arc::clone(&chain_monitor), output_sweeper, }; @@ -1461,6 +1470,7 @@ pub(crate) enum FeeRateEstimationMode { pub(crate) struct ChainListener { pub(crate) onchain_wallet: Arc, pub(crate) channel_manager: Arc, + pub(crate) offers_message_handler: Arc, pub(crate) chain_monitor: Arc, pub(crate) output_sweeper: Arc, } @@ -1472,12 +1482,14 @@ impl Listen for ChainListener { ) { self.onchain_wallet.filtered_block_connected(header, txdata, height); self.channel_manager.filtered_block_connected(header, txdata, height); + self.offers_message_handler.filtered_block_connected(header, txdata, height); self.chain_monitor.filtered_block_connected(header, txdata, height); self.output_sweeper.filtered_block_connected(header, txdata, height); } fn block_connected(&self, block: &bitcoin::Block, height: u32) { self.onchain_wallet.block_connected(block, height); self.channel_manager.block_connected(block, height); + self.offers_message_handler.block_connected(block, height); self.chain_monitor.block_connected(block, height); self.output_sweeper.block_connected(block, height); } @@ -1485,6 +1497,7 @@ impl Listen for ChainListener { fn blocks_disconnected(&self, fork_point_block: lightning::chain::BlockLocator) { self.onchain_wallet.blocks_disconnected(fork_point_block); self.channel_manager.blocks_disconnected(fork_point_block); + self.offers_message_handler.blocks_disconnected(fork_point_block); self.chain_monitor.blocks_disconnected(fork_point_block); self.output_sweeper.blocks_disconnected(fork_point_block); } diff --git a/src/chain/electrum.rs b/src/chain/electrum.rs index 59fa23a6ca..4eefa80bdf 100644 --- a/src/chain/electrum.rs +++ b/src/chain/electrum.rs @@ -37,6 +37,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::update_and_persist_node_metrics; use crate::logger::{log_bytes, log_debug, log_error, log_trace, log_warn, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::runtime::Runtime; use crate::tx_broadcaster::SortedTransactions; use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; @@ -208,7 +209,8 @@ impl ElectrumChainSource { } pub(crate) async fn sync_lightning_wallet( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let receiver_res = { @@ -224,8 +226,14 @@ impl ElectrumChainSource { })?; } - let res = - self.sync_lightning_wallet_inner(channel_manager, chain_monitor, output_sweeper).await; + let res = self + .sync_lightning_wallet_inner( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await; self.lightning_wallet_sync_status .lock() @@ -236,14 +244,17 @@ impl ElectrumChainSource { } async fn sync_lightning_wallet_inner( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let sync_cman = Arc::clone(&channel_manager); + let sync_offers = Arc::clone(&offers_message_handler); let sync_cmon = Arc::clone(&chain_monitor); let sync_sweeper = Arc::clone(&output_sweeper); let confirmables = vec![ sync_cman as Arc, + sync_offers as Arc, sync_cmon as Arc, sync_sweeper as Arc, ]; diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 21205bd252..eea2ce2bf4 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}; @@ -260,7 +261,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 = { @@ -276,8 +278,14 @@ impl EsploraChainSource { })?; } - let res = - self.sync_lightning_wallet_inner(channel_manager, chain_monitor, output_sweeper).await; + let res = self + .sync_lightning_wallet_inner( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) + .await; self.lightning_wallet_sync_status .lock() @@ -288,14 +296,17 @@ impl EsploraChainSource { } async fn sync_lightning_wallet_inner( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { let sync_cman = Arc::clone(&channel_manager); + let sync_offers = Arc::clone(&offers_message_handler); let sync_cmon = Arc::clone(&chain_monitor); let sync_sweeper = Arc::clone(&output_sweeper); let confirmables = vec![ &*sync_cman as &(dyn Confirm + Sync + Send), + &*sync_offers as &(dyn Confirm + Sync + Send), &*sync_cmon as &(dyn Confirm + Sync + Send), &*sync_sweeper as &(dyn Confirm + Sync + Send), ]; diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 0f96c409f8..5ddb35230d 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -25,6 +25,7 @@ use crate::config::{ }; use crate::fee_estimator::OnchainFeeEstimator; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; +use crate::payment::NodeOffersMessageHandler; use crate::runtime::Runtime; use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; use crate::{Error, PersistedNodeMetrics}; @@ -259,7 +260,8 @@ impl ChainSource { pub(crate) async fn continuously_sync_wallets( &self, stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, - channel_manager: Arc, chain_monitor: Arc, + channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) { match &self.kind { @@ -271,6 +273,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, background_sync_config, @@ -294,6 +297,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, background_sync_config, @@ -315,6 +319,7 @@ impl ChainSource { stop_sync_receiver, onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) @@ -326,8 +331,9 @@ impl ChainSource { async fn start_tx_based_sync_loop( &self, mut stop_sync_receiver: tokio::sync::watch::Receiver<()>, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, - background_sync_config: &BackgroundSyncConfig, logger: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, background_sync_config: &BackgroundSyncConfig, + logger: Arc, ) { // Setup syncing intervals let onchain_wallet_sync_interval_secs = background_sync_config @@ -374,6 +380,7 @@ impl ChainSource { _ = lightning_wallet_sync_interval.tick() => { let _ = self.sync_lightning_wallet( Arc::clone(&channel_manager), + Arc::clone(&offers_message_handler), Arc::clone(&chain_monitor), Arc::clone(&output_sweeper), ).await; @@ -405,18 +412,29 @@ impl ChainSource { // Synchronize the Lightning wallet via transaction-based protocols (i.e., Esplora, Electrum, // etc.) pub(crate) async fn sync_lightning_wallet( - &self, channel_manager: Arc, chain_monitor: Arc, + &self, channel_manager: Arc, + offers_message_handler: Arc, chain_monitor: Arc, output_sweeper: Arc, ) -> Result<(), Error> { match &self.kind { ChainSourceKind::Esplora(esplora_chain_source) => { esplora_chain_source - .sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper) + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) .await }, ChainSourceKind::Electrum(electrum_chain_source) => { electrum_chain_source - .sync_lightning_wallet(channel_manager, chain_monitor, output_sweeper) + .sync_lightning_wallet( + channel_manager, + offers_message_handler, + chain_monitor, + output_sweeper, + ) .await }, ChainSourceKind::Bitcoind { .. } => { @@ -429,7 +447,8 @@ impl ChainSource { pub(crate) async fn poll_and_update_listeners( &self, onchain_wallet: Arc, channel_manager: Arc, - chain_monitor: Arc, output_sweeper: Arc, + offers_message_handler: Arc, chain_monitor: Arc, + output_sweeper: Arc, ) -> Result<(), Error> { match &self.kind { ChainSourceKind::Esplora { .. } => { @@ -447,6 +466,7 @@ impl ChainSource { .poll_and_update_listeners( onchain_wallet, channel_manager, + offers_message_handler, chain_monitor, output_sweeper, ) diff --git a/src/config.rs b/src/config.rs index 772c4bd806..d85f9bbc34 100644 --- a/src/config.rs +++ b/src/config.rs @@ -151,6 +151,7 @@ pub(crate) const LIQUIDITY_DISCOVERY_RETRY_MAX_DELAY: Duration = Duration::from_ /// | `node_alias` | None | /// | `trusted_peers_0conf` | [] | /// | `probing_liquidity_limit_multiplier` | 3 | +/// | `lsps2_max_total_lsp_fee_limit_msat` | None | /// | `anchor_channels_config` | AnchorChannelsConfig::default() | /// | `route_parameters` | None | /// | `tor_config` | None | @@ -194,6 +195,13 @@ pub struct Config { /// Channels with available liquidity less than the required amount times this value won't be /// used to send pre-flight probes. pub probing_liquidity_limit_multiplier: u64, + /// The maximum total fee in millisatoshis that an LSPS2 liquidity provider may charge for a + /// just-in-time channel. + /// + /// This limit applies to all configured LSPS2 liquidity sources. If unset, fixed-amount + /// payments accept the fee negotiated for that payment, while variable-amount payments use the + /// negotiated proportional fee as their limit. + pub lsps2_max_total_lsp_fee_limit_msat: Option, /// Configuration options pertaining to Anchor channels, i.e., channels for which the /// `option_zero_fee_commitments` or `option_anchors_zero_fee_htlc_tx` channel type is /// negotiated. @@ -230,6 +238,7 @@ impl Default for Config { announcement_addresses: None, trusted_peers_0conf: Vec::new(), probing_liquidity_limit_multiplier: DEFAULT_PROBING_LIQUIDITY_LIMIT_MULTIPLIER, + lsps2_max_total_lsp_fee_limit_msat: None, anchor_channels_config: AnchorChannelsConfig::default(), tor_config: None, route_parameters: None, diff --git a/src/event.rs b/src/event.rs index b8ca735198..fe1f9b46ea 100644 --- a/src/event.rs +++ b/src/event.rs @@ -7,7 +7,7 @@ use core::future::Future; use core::task::{Poll, Waker}; -use std::collections::VecDeque; +use std::collections::{BTreeMap, VecDeque}; use std::ops::Deref; use std::sync::{Arc, Mutex}; @@ -664,13 +664,40 @@ where } } - fn lsps2_max_total_opening_fee_msat(payment_metadata: &[u8], amount_msat: u64) -> Option { + fn lsps2_max_total_opening_fee_msat( + payment_metadata: &[u8], amount_msat: u64, counterparty_skimmed_fee_msat: u64, + ) -> Option { let metadata = PaymentMetadata::read(&mut &payment_metadata[..]).ok()?; + Self::lsps2_max_total_opening_fee_msat_from_metadata( + metadata, + amount_msat, + counterparty_skimmed_fee_msat, + ) + } + + fn lsps2_bolt12_max_total_opening_fee_msat( + payment_metadata: &BTreeMap>, amount_msat: u64, + counterparty_skimmed_fee_msat: u64, + ) -> Option { + let metadata = PaymentMetadata::decode_from_bolt12_payment_metadata(payment_metadata)?; + Self::lsps2_max_total_opening_fee_msat_from_metadata( + metadata, + amount_msat, + counterparty_skimmed_fee_msat, + ) + } + + fn lsps2_max_total_opening_fee_msat_from_metadata( + metadata: PaymentMetadata, amount_msat: u64, counterparty_skimmed_fee_msat: u64, + ) -> Option { let lsps2_parameters = metadata.lsps2_parameters?; lsps2_parameters.max_total_opening_fee_msat.or_else(|| { lsps2_parameters.max_proportional_opening_fee_ppm_msat.and_then(|max_prop_fee| { - // If it's a variable amount payment, compute the actual fee. - compute_opening_fee(amount_msat, 0, max_prop_fee) + // `PaymentClaimable::amount_msat` excludes the fee withheld by the LSP. LSPS2 + // proportional fees are calculated from the amount sent by the payer, so add the + // withheld fee back before computing the permitted fee. + let payment_size_msat = amount_msat.checked_add(counterparty_skimmed_fee_msat)?; + compute_opening_fee(payment_size_msat, 0, max_prop_fee) }) }) } @@ -864,15 +891,28 @@ where .as_ref() .and_then(|fields| fields.payment_metadata.as_ref()) .and_then(|metadata| { - Self::lsps2_max_total_opening_fee_msat(metadata, amount_msat) + Self::lsps2_max_total_opening_fee_msat( + metadata, + amount_msat, + counterparty_skimmed_fee_msat, + ) }), + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { + payment_context.payment_metadata.as_ref().and_then(|metadata| { + Self::lsps2_bolt12_max_total_opening_fee_msat( + metadata, + amount_msat, + counterparty_skimmed_fee_msat, + ) + }) + }, _ => None, }; let Some(max_total_opening_fee_msat) = max_total_opening_fee_msat else { log_info!( self.logger, - "Refusing inbound payment with hash {} as the counterparty withheld {}msat without valid BOLT11 LSPS2 payment metadata", + "Refusing inbound payment with hash {} as the counterparty withheld {}msat without valid LSPS2 payment metadata", hex_utils::to_string(&payment_hash.0), counterparty_skimmed_fee_msat, ); @@ -894,7 +934,7 @@ where if let Some(info) = payment_info.as_ref() { match &info.kind { - PaymentKind::Bolt11 { .. } => { + PaymentKind::Bolt11 { .. } | PaymentKind::Bolt12Offer { .. } => { let update = PaymentDetailsUpdate { counterparty_skimmed_fee_msat: Some(Some(counterparty_skimmed_fee_msat)), ..PaymentDetailsUpdate::new(payment_id) @@ -907,7 +947,7 @@ where }, }; }, - _ => debug_assert!(false, "We only expect the counterparty to get away with withholding fees for BOLT11 payments."), + _ => debug_assert!(false, "We only expect the counterparty to withhold fees for LSPS2 invoice payments."), } } } @@ -976,6 +1016,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, @@ -2032,48 +2074,112 @@ mod tests { max_total_opening_fee_msat: Some(42_000), max_proportional_opening_fee_ppm_msat: None, }), + lsps2_lease_parameters: None, }; assert_eq!( EventHandler::>::lsps2_max_total_opening_fee_msat( &metadata.encode(), - 100_000 + 100_000, + 0, ), Some(42_000) ); } + #[test] + fn lsps2_proportional_fee_uses_gross_payment_amount() { + let metadata = PaymentMetadata { + lsps2_parameters: Some(LSPS2Parameters { + max_total_opening_fee_msat: None, + max_proportional_opening_fee_ppm_msat: Some(10_000), + }), + lsps2_lease_parameters: None, + }; + + assert_eq!( + EventHandler::>::lsps2_max_total_opening_fee_msat( + &metadata.encode(), + 198_000, + 2_000, + ), + Some(2_000) + ); + } + #[test] fn lsps2_payment_metadata_missing_or_malformed_limit_is_rejected() { - let empty_metadata = PaymentMetadata { lsps2_parameters: None }.encode(); + let empty_metadata = + PaymentMetadata { lsps2_parameters: None, lsps2_lease_parameters: None }.encode(); let metadata_without_fee_limit = PaymentMetadata { lsps2_parameters: Some(LSPS2Parameters { max_total_opening_fee_msat: None, max_proportional_opening_fee_ppm_msat: None, }), + lsps2_lease_parameters: None, } .encode(); assert_eq!( EventHandler::>::lsps2_max_total_opening_fee_msat( &empty_metadata, - 100_000 + 100_000, + 0, ), None ); assert_eq!( - EventHandler::>::lsps2_max_total_opening_fee_msat(&[0xff], 100_000), + EventHandler::>::lsps2_max_total_opening_fee_msat(&[0xff], 100_000, 0,), None ); assert_eq!( EventHandler::>::lsps2_max_total_opening_fee_msat( &metadata_without_fee_limit, - 100_000 + 100_000, + 0, ), None ); } + #[test] + fn bolt12_lsps2_payment_metadata_decodes_fee_limit() { + let metadata = PaymentMetadata { + lsps2_parameters: Some(LSPS2Parameters { + max_total_opening_fee_msat: None, + max_proportional_opening_fee_ppm_msat: Some(10_000), + }), + lsps2_lease_parameters: None, + } + .encode_as_bolt12_payment_metadata(); + + assert_eq!( + EventHandler::>::lsps2_bolt12_max_total_opening_fee_msat( + &metadata, 100_000, 0, + ), + Some(1_000) + ); + } + + #[test] + fn bolt12_lsps2_payment_metadata_requires_fee_limit() { + let empty_metadata = BTreeMap::new(); + let metadata_without_fee_limit = + PaymentMetadata { lsps2_parameters: None, lsps2_lease_parameters: None } + .encode_as_bolt12_payment_metadata(); + let mut malformed_metadata = metadata_without_fee_limit.clone(); + *malformed_metadata.values_mut().next().unwrap() = vec![0xff]; + + for metadata in [empty_metadata, malformed_metadata, metadata_without_fee_limit] { + assert_eq!( + EventHandler::>::lsps2_bolt12_max_total_opening_fee_msat( + &metadata, 100_000, 0, + ), + None + ); + } + } + #[tokio::test] async fn event_queue_persistence() { let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); diff --git a/src/io/mod.rs b/src/io/mod.rs index a01aa59a83..470d4c391e 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -33,6 +33,15 @@ pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// Cached LSPS2 payment leases are persisted under this prefix. +pub(crate) const LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE: &str = "lsps2_leases"; +pub(crate) const LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + +/// 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 b22c1538cc..e3c0fd6a84 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -172,8 +172,8 @@ use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; use payment::{ - Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, SpontaneousPayment, - UnifiedPayment, + Bolt11Payment, Bolt12Payment, NodeOffersMessageHandler, OnchainPayment, PaymentDetails, + SpontaneousPayment, UnifiedPayment, }; use peer_store::{PeerInfo, PeerStore}; #[cfg(feature = "uniffi")] @@ -253,6 +253,7 @@ pub struct Node { output_sweeper: Arc, peer_manager: Arc, onion_messenger: Arc, + offers_message_handler: Arc, connection_manager: Arc>>, keys_manager: Arc, network_graph: Arc, @@ -350,6 +351,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 { @@ -358,6 +360,7 @@ impl Node { stop_sync_receiver, sync_wallet, sync_cman, + sync_offers, sync_cmon, sync_sweeper, ) @@ -545,6 +548,28 @@ impl Node { } }); + // 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(); + 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); + } + if let Err(error) = lsps2_client.prune_stale_cache_targets().await { + log_error!(prune_logger, "Failed pruning stale LSPS2 cache targets: {}", error); + } + }, + } + } + }); + // Regularly broadcast node announcements. let bcast_cm = Arc::clone(&self.channel_manager); let bcast_pm = Arc::clone(&self.peer_manager); @@ -780,6 +805,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 + ); + } } } } @@ -1052,6 +1088,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), @@ -1069,6 +1106,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), @@ -2004,13 +2042,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 { @@ -2019,6 +2063,7 @@ impl Node { .poll_and_update_listeners( sync_wallet, sync_cman, + sync_offers, sync_cmon, Arc::clone(&sync_sweeper), ) diff --git a/src/liquidity/client/lsps2.rs b/src/liquidity/client/lsps2.rs deleted file mode 100644 index 3033f8d827..0000000000 --- a/src/liquidity/client/lsps2.rs +++ /dev/null @@ -1,553 +0,0 @@ -// This file is Copyright its original authors, visible in version control history. -// -// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in -// accordance with one or both of these licenses. - -use std::collections::HashMap; -use std::ops::Deref; -use std::sync::{Arc, Mutex, RwLock}; -use std::time::Duration; - -use bitcoin::secp256k1::{PublicKey, Secp256k1}; -use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; -use lightning::log_warn; -use lightning::routing::router::{RouteHint, RouteHintHop}; -use lightning::util::ser::Writeable; -use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, RoutingFees}; -use lightning_liquidity::lsps0::ser::LSPSRequestId; -use lightning_liquidity::lsps2::event::LSPS2ClientEvent; -use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; -use lightning_liquidity::lsps2::utils::compute_opening_fee; -use lightning_types::payment::PaymentHash; -use tokio::sync::oneshot; -use tokio::task::JoinSet; - -use crate::connection::ConnectionManager; -use crate::liquidity::{ - select_all_lsps_for_protocol, select_lsps_for_protocol, LspConfig, LspNode, - LIQUIDITY_REQUEST_TIMEOUT_SECS, LSPS_DISCOVERY_WAIT_TIMEOUT_SECS, -}; -use crate::logger::{log_debug, log_error, log_info, LdkLogger}; -use crate::payment::store::LSPS2Parameters; -use crate::payment::PaymentMetadata; -use crate::types::{ChannelManager, KeysManager, LiquidityManager}; -use crate::{Config, Error}; - -pub(crate) struct LSPS2Client -where - L::Target: LdkLogger, -{ - pub(crate) lsp_nodes: Arc>>, - pub(crate) pending_lsps2_fee_requests: - Mutex>>, - pub(crate) pending_buy_requests: - Mutex>>, - pub(crate) channel_manager: Arc, - pub(crate) keys_manager: Arc, - pub(crate) discovery_done_rx: tokio::sync::watch::Receiver, - pub(crate) liquidity_manager: Arc, - pub(crate) config: Arc, - pub(crate) logger: L, -} - -impl LSPS2Client -where - L::Target: LdkLogger, -{ - 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>, - ) -> Result<(Bolt11Invoice, LspConfig), Error> { - // Connect to all candidate LSPs before querying fees. - let all_offers = self.gather_lsps2_offers(&connection_manager).await?; - let (cheapest_lsp, min_total_fee_msat, min_opening_params) = all_offers - .into_iter() - .flat_map(|(lsp, resp)| { - resp.opening_fee_params_menu - .into_iter() - .map(move |params| (lsp.clone(), params)) - }) - .filter_map(|(lsp, params)| { - if amount_msat < params.min_payment_size_msat - || amount_msat > params.max_payment_size_msat - { - log_debug!(self.logger, - "Skipping LSP {}'s JIT offer as the payment of {}msat doesn't meet LSP limits (min: {}msat, max: {}msat)", - lsp.node_id, - amount_msat, - params.min_payment_size_msat, - params.max_payment_size_msat - ); - None - } else { - compute_opening_fee(amount_msat, params.min_fee_msat, params.proportional as u64) - .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 - })?; - - if let Some(max_total_lsp_fee_limit_msat) = max_total_lsp_fee_limit_msat { - if min_total_fee_msat > max_total_lsp_fee_limit_msat { - log_error!(self.logger, - "Failed to request inbound JIT channel as LSP's requested total opening fee of {}msat exceeds our fee limit of {}msat", - min_total_fee_msat, max_total_lsp_fee_limit_msat - ); - return Err(Error::LiquidityFeeTooHigh); - } - } - - log_debug!( - self.logger, - "Choosing cheapest liquidity offer from LSP {}, will pay {}msat in total LSP fees", - cheapest_lsp.node_id, - min_total_fee_msat - ); - - let buy_response = self - .lsps2_send_buy_request( - Some(amount_msat), - min_opening_params, - Some(&cheapest_lsp.node_id), - ) - .await?; - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: Some(min_total_fee_msat), - max_proportional_opening_fee_ppm_msat: None, - }; - - let invoice = self.lsps2_create_jit_invoice( - buy_response, - Some(amount_msat), - description, - expiry_secs, - payment_hash, - lsps2_parameters, - Some(&cheapest_lsp.node_id), - )?; - - log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok((invoice, cheapest_lsp)) - } - - 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>, - ) -> Result<(Bolt11Invoice, LspConfig), Error> { - // Connect to all candidate LSPs before querying fees. - let all_offers = self.gather_lsps2_offers(&connection_manager).await?; - let (cheapest_lsp, min_prop_fee_ppm_msat, min_opening_params) = all_offers - .into_iter() - .flat_map(|(lsp, resp)| { - resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params)) - }) - .map(|(lsp, params)| { - let ppm = params.proportional as u64; - (lsp, ppm, params) - }) - .min_by_key(|(_, ppm, _)| *ppm) - .ok_or_else(|| { - log_error!(self.logger, "Failed to handle response from liquidity service",); - Error::LiquidityRequestFailed - })?; - - 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", - cheapest_lsp.node_id, - min_prop_fee_ppm_msat - ); - - let buy_response = self - .lsps2_send_buy_request(None, min_opening_params, Some(&cheapest_lsp.node_id)) - .await?; - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: None, - max_proportional_opening_fee_ppm_msat: Some(min_prop_fee_ppm_msat), - }; - 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)) - } - - async fn gather_lsps2_offers( - self: &Arc, connection_manager: &Arc>, - ) -> Result, Error> { - let lsps2_nodes = self.get_lsps2_nodes().await?; - - // Connect to all candidate LSPs in parallel. - let mut connect_set = JoinSet::new(); - for lsp_node in &lsps2_nodes { - let cm = Arc::clone(connection_manager); - let node_id = lsp_node.node_id; - let addr = lsp_node.address.clone(); - let logger = self.logger.clone(); - connect_set.spawn(async move { - if let Err(e) = cm.connect_peer_if_necessary(node_id, addr).await { - log_warn!(logger, "Failed to connect to LSP {} for fee query: {}", node_id, e); - } - }); - } - while connect_set.join_next().await.is_some() {} - - let mut all_offers: Vec<(LspConfig, LSPS2FeeResponse)> = - Vec::with_capacity(lsps2_nodes.len()); - let mut fee_set: JoinSet<(LspConfig, Result)> = JoinSet::new(); - for lsp_node in &lsps2_nodes { - let lsp = lsp_node.clone(); - let client = Arc::clone(self); - fee_set.spawn(async move { - let res = client.lsps2_request_opening_fee_params(Some(&lsp.node_id)).await; - (lsp, res) - }); - } - while let Some(join_result) = fee_set.join_next().await { - match join_result { - Ok((lsp, Ok(fees))) => all_offers.push((lsp, fees)), - Ok((lsp, Err(e))) => { - log_warn!(self.logger, "Failed to get fees from LSP {}: {}", lsp.node_id, e) - }, - Err(e) => { - log_warn!(self.logger, "Failed to get fees from LSP: {}", e) - }, - } - } - - Ok(all_offers) - } -} - -impl LSPS2Client -where - L::Target: LdkLogger, -{ - async fn lsps2_request_opening_fee_params( - &self, node_id: Option<&PublicKey>, - ) -> Result { - let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) - .ok_or(Error::LiquiditySourceUnavailable)?; - - let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { - log_error!(self.logger, "Liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let (fee_request_sender, fee_request_receiver) = oneshot::channel(); - { - let mut pending_fee_requests_lock = - self.pending_lsps2_fee_requests.lock().expect("lock"); - let request_id = - client_handler.request_opening_params(lsps2_node.node_id, lsps2_node.token.clone()); - pending_fee_requests_lock.insert(request_id, fee_request_sender); - } - - tokio::time::timeout( - Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), - fee_request_receiver, - ) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request timed out: {}", e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); - Error::LiquidityRequestFailed - }) - } - - async fn lsps2_send_buy_request( - &self, amount_msat: Option, opening_fee_params: LSPS2OpeningFeeParams, - node_id: Option<&PublicKey>, - ) -> Result { - let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) - .ok_or(Error::LiquiditySourceUnavailable)?; - - let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { - log_error!(self.logger, "Liquidity client was not configured.",); - Error::LiquiditySourceUnavailable - })?; - - let (buy_request_sender, buy_request_receiver) = oneshot::channel(); - { - 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) - .map_err(|e| { - log_error!( - self.logger, - "Failed to send buy request to liquidity service: {:?}", - e - ); - Error::LiquidityRequestFailed - })?; - pending_buy_requests_lock.insert(request_id, buy_request_sender); - } - - let buy_response = tokio::time::timeout( - Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), - buy_request_receiver, - ) - .await - .map_err(|e| { - log_error!(self.logger, "Liquidity request timed out: {}", e); - Error::LiquidityRequestFailed - })? - .map_err(|e| { - log_error!(self.logger, "Failed to handle response from liquidity service: {:?}", e); - Error::LiquidityRequestFailed - })?; - - Ok(buy_response) - } - - fn lsps2_create_jit_invoice( - &self, buy_response: LSPS2BuyResponse, amount_msat: Option, - description: &Bolt11InvoiceDescription, expiry_secs: u32, - payment_hash: Option, lsps2_parameters: LSPS2Parameters, - node_id: Option<&PublicKey>, - ) -> Result { - let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) - .ok_or(Error::LiquiditySourceUnavailable)?; - - // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. - let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; - let encoded_payment_metadata = - PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }.encode(); - let (payment_hash, payment_secret, payment_metadata) = match payment_hash { - Some(payment_hash) => { - let (payment_secret, payment_metadata) = self - .channel_manager - .create_inbound_payment_for_hash( - payment_hash, - None, - expiry_secs, - Some(min_final_cltv_expiry_delta), - Some(encoded_payment_metadata), - ) - .map_err(|e| { - log_error!(self.logger, "Failed to register inbound payment: {:?}", e); - Error::InvoiceCreationFailed - })?; - (payment_hash, payment_secret, payment_metadata) - }, - None => self - .channel_manager - .create_inbound_payment( - None, - expiry_secs, - Some(min_final_cltv_expiry_delta), - Some(encoded_payment_metadata), - ) - .map_err(|e| { - log_error!(self.logger, "Failed to register inbound payment: {:?}", e); - Error::InvoiceCreationFailed - })?, - }; - - let route_hint = RouteHint(vec![RouteHintHop { - src_node_id: lsps2_node.node_id, - short_channel_id: buy_response.intercept_scid, - fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, - cltv_expiry_delta: buy_response.cltv_expiry_delta as u16, - htlc_minimum_msat: None, - htlc_maximum_msat: None, - }]); - - let currency = self.config.network.into(); - let mut invoice_builder = InvoiceBuilder::new(currency) - .invoice_description(description.clone()) - .payment_hash(payment_hash) - .payment_secret(payment_secret) - .current_timestamp() - .min_final_cltv_expiry_delta(min_final_cltv_expiry_delta.into()) - .expiry_time(Duration::from_secs(expiry_secs.into())) - .private_route(route_hint); - - if let Some(amount_msat) = amount_msat { - invoice_builder = invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); - } - - let invoice = if let Some(payment_metadata) = payment_metadata { - invoice_builder.payment_metadata(payment_metadata).build_signed(|hash| { - Secp256k1::new() - .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) - }) - } else { - invoice_builder.build_signed(|hash| { - Secp256k1::new() - .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) - }) - }; - invoice.map_err(|e| { - log_error!(self.logger, "Failed to build and sign invoice: {}", e); - Error::InvoiceCreationFailed - }) - } - - pub(crate) async fn handle_event(&self, event: LSPS2ClientEvent) { - match event { - LSPS2ClientEvent::OpeningParametersReady { - request_id, - counterparty_node_id, - opening_fee_params_menu, - } => { - if self - .lsp_nodes - .read() - .expect("lock") - .iter() - .any(|n| n.node_id == counterparty_node_id) - { - if let Some(sender) = - self.pending_lsps2_fee_requests.lock().expect("lock").remove(&request_id) - { - let response = LSPS2FeeResponse { opening_fee_params_menu }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } - } else { - log_error!( - self.logger, - "Received unexpected LSPS2Client::OpeningParametersReady event!" - ); - } - }, - LSPS2ClientEvent::InvoiceParametersReady { - request_id, - counterparty_node_id, - intercept_scid, - cltv_expiry_delta, - .. - } => { - if self - .lsp_nodes - .read() - .expect("lock") - .iter() - .any(|n| n.node_id == counterparty_node_id) - { - if let Some(sender) = - self.pending_buy_requests.lock().expect("lock").remove(&request_id) - { - let response = LSPS2BuyResponse { intercept_scid, cltv_expiry_delta }; - - match sender.send(response) { - Ok(()) => (), - Err(_) => { - log_error!( - self.logger, - "Failed to handle response for request {:?} from liquidity service", - request_id - ); - }, - } - } else { - debug_assert!( - false, - "Received response from liquidity service for unknown request." - ); - log_error!( - self.logger, - "Received response from liquidity service for unknown request." - ); - } - } else { - log_error!( - self.logger, - "Received unexpected LSPS2Client::InvoiceParametersReady event!" - ); - } - }, - _ => { - log_error!(self.logger, "Received unexpected LSPS2Client liquidity event!"); - }, - } - } - - async fn get_lsps2_nodes(&self) -> Result, Error> { - let has_undiscovered_protocol = - self.lsp_nodes.read().expect("lock").iter().any(|n| n.supported_protocols.is_none()); - - if has_undiscovered_protocol { - // LSP protocol discovery is still in flight, we wait briefly for it to finish, then re-check. - let mut rx = self.discovery_done_rx.clone(); - if !*rx.borrow() { - log_debug!( - self.logger, - "Waiting for LSP protocol discovery to complete before selecting LSPS2 nodes." - ); - let _ = tokio::time::timeout( - Duration::from_secs(LSPS_DISCOVERY_WAIT_TIMEOUT_SECS), - rx.wait_for(|done| *done), - ) - .await; - } - } - - let lsps2_nodes = select_all_lsps_for_protocol(&self.lsp_nodes, 2); - if lsps2_nodes.is_empty() { - log_error!(self.logger, "No LSPs available for LSPS2 protocol."); - return Err(Error::LiquiditySourceUnavailable); - }; - Ok(lsps2_nodes) - } -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS2FeeResponse { - opening_fee_params_menu: Vec, -} - -#[derive(Debug, Clone)] -pub(crate) struct LSPS2BuyResponse { - intercept_scid: u64, - cltv_expiry_delta: u32, -} diff --git a/src/liquidity/client/lsps2/mod.rs b/src/liquidity/client/lsps2/mod.rs new file mode 100644 index 0000000000..8e11676d6f --- /dev/null +++ b/src/liquidity/client/lsps2/mod.rs @@ -0,0 +1,1153 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::future::Future; +use std::hash::Hash; +use std::ops::Deref; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +use bitcoin::secp256k1::{PublicKey, Secp256k1}; +use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; +use lightning::log_warn; +use lightning::routing::router::{RouteHint, RouteHintHop}; +use lightning::util::ser::Writeable; +use lightning_invoice::{Bolt11Invoice, Bolt11InvoiceDescription, InvoiceBuilder, RoutingFees}; +use lightning_liquidity::lsps0::ser::LSPSRequestId; +use lightning_liquidity::lsps2::event::LSPS2ClientEvent; +use lightning_liquidity::lsps2::msgs::LSPS2OpeningFeeParams; +use lightning_liquidity::lsps2::utils::compute_opening_fee; +use lightning_types::payment::PaymentHash; +use tokio::sync::oneshot; +use tokio::task::JoinSet; + +use crate::connection::ConnectionManager; +use crate::liquidity::{ + select_all_lsps_for_protocol, select_lsps_for_protocol, LspConfig, LspNode, + LIQUIDITY_REQUEST_TIMEOUT_SECS, LSPS_DISCOVERY_WAIT_TIMEOUT_SECS, +}; +use crate::logger::{log_debug, log_error, log_info, LdkLogger}; +use crate::payment::store::LSPS2Parameters; +use crate::payment::PaymentMetadata; +use crate::types::{ChannelManager, KeysManager, LiquidityManager}; +use crate::{Config, Error}; + +use self::router::LSPS2LeaseParameters; +use self::state::{ + now_secs, LSPS2LeaseState, LeaseCacheTarget, LeaseCacheTargetId, LeaseCacheTargetStore, + LeaseRequestKey, PaymentLease, PaymentLeaseId, PaymentLeaseStore, PendingLeaseRequestState, +}; + +async fn consume_after_persisted_removal( + value: T, persist_removal: RF, consume: CF, +) -> Result +where + T: Clone, + RF: FnOnce(T) -> Fut, + CF: FnOnce(&T), + Fut: Future>, +{ + persist_removal(value.clone()).await?; + consume(&value); + Ok(value) +} + +const DEFAULT_BOLT12_INVOICE_EXPIRY_SECS: u32 = 2 * 60 * 60; +const LEASE_NEGOTIATION_MAX_ATTEMPTS: usize = 3; + +fn should_retry_lease_negotiation(error: Error, attempt: usize) -> bool { + error == Error::LiquidityRequestFailed && attempt < LEASE_NEGOTIATION_MAX_ATTEMPTS +} + +pub(crate) struct JitInvoiceResponse { + pub(crate) payment_metadata: BTreeMap>, + pub(crate) allow_mpp: bool, +} + +async fn try_lease_candidates( + candidates: Vec, candidate_key: KF, mut attempt: AF, +) -> Result +where + K: Copy + Eq + Hash, + KF: Fn(&T) -> K, + AF: FnMut(T) -> Fut, + Fut: Future>, +{ + let mut failed_candidates = HashSet::new(); + let mut last_error = None; + for candidate in candidates { + let key = candidate_key(&candidate); + if failed_candidates.contains(&key) { + continue; + } + match attempt(candidate).await { + Ok(result) => return Ok(result), + Err(error) => { + failed_candidates.insert(key); + last_error = Some(error); + }, + } + } + Err(last_error.expect("lease candidates are non-empty")) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum JitInvoiceRequest { + Fixed { amount_msat: u64, absolute_expiry: Option }, + 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, +{ + pub(crate) lsp_nodes: Arc>>, + pub(crate) pending_lsps2_fee_requests: + Mutex>>, + pub(crate) pending_buy_requests: + Mutex>>, + 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, + pub(crate) liquidity_manager: Arc, + pub(crate) config: Arc, + pub(crate) logger: L, +} + +impl LSPS2Client +where + L::Target: LdkLogger, +{ + pub(crate) async fn prune_stale_leases(&self) -> Result<(), Error> { + let stale_ids = self + .lease_store + .list_filter(|lease| !state::is_lease_usable(lease)) + .into_iter() + .map(|lease| lease.id) + .collect::>(); + for id in stale_ids { + self.lease_store.remove(&id).await?; + } + self.lease_state.lock().expect("lock").prune(); + Ok(()) + } + + pub(crate) async fn 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 refill_cached_leases( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(), Error> { + self.prune_stale_cache_targets().await?; + for target in self.cache_targets() { + match target.id { + LeaseCacheTargetId::Fixed { amount_msat } => { + self.schedule_fixed_lease_refill(amount_msat, connection_manager); + }, + LeaseCacheTargetId::Variable => { + self.schedule_variable_lease_refill(connection_manager); + }, + } + } + Ok(()) + } + + pub(crate) async fn prepare_invoice_response( + self: Arc, request: JitInvoiceRequest, connection_manager: Arc>, + ) -> Result { + // Cache targets are only an optimization. Recording an older, evicted offer again makes it + // eligible for startup pre-caching, but a persistence failure must not make the current + // invoice request unusable. + let (target_id, absolute_expiry) = request.cache_target(); + if let Err(error) = self.register_cache_target(target_id, absolute_expiry).await { + log_warn!(self.logger, "Failed recording LSPS2 lease cache target: {}", error); + } + + let allow_mpp = request.allow_mpp(); + let (lease, fee_parameters) = match request { + JitInvoiceRequest::Fixed { amount_msat, .. } => { + let (lease, total_fee_msat, _, _) = + self.acquire_fixed_lease(amount_msat, &connection_manager).await?; + ( + lease, + LSPS2Parameters { + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }, + ) + }, + JitInvoiceRequest::Variable { amount_msat, .. } => { + // A BOLT12 invoice request has already resolved the payment amount. Use it to avoid + // consuming a cached variable lease outside its advertised range, and record the exact + // fee for that amount so payment validation need not accept the broader node-wide cap. + let (lease, total_fee_msat, _, _) = + self.acquire_variable_lease(Some(amount_msat), &connection_manager).await?; + ( + lease, + LSPS2Parameters { + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }, + ) + }, + }; + + // The offers flow currently uses its two-hour default invoice expiry. Never publish a JIT + // path whose intercept SCID may expire before the invoice does. + if lease.valid_until.saturating_sub(now_secs()) < DEFAULT_BOLT12_INVOICE_EXPIRY_SECS as u64 + { + return Err(Error::LiquidityRequestFailed); + } + let lease_parameters = LSPS2LeaseParameters { + lsp_node_id: lease.id.lsp_node_id, + intercept_scid: lease.id.intercept_scid, + cltv_expiry_delta: lease + .cltv_expiry_delta + .try_into() + .map_err(|_| Error::LiquidityRequestFailed)?, + payment_size_msat: lease.payment_size_msat, + valid_until: lease.valid_until, + }; + let payment_metadata = PaymentMetadata { + lsps2_parameters: Some(fee_parameters), + lsps2_lease_parameters: Some(lease_parameters), + } + .encode_as_bolt12_payment_metadata(); + + Ok(JitInvoiceResponse { payment_metadata, allow_mpp }) + } + + pub(crate) async fn lsps2_receive_to_jit_channel( + self: Arc, amount_msat: u64, description: &Bolt11InvoiceDescription, + expiry_secs: u32, payment_hash: Option, + connection_manager: Arc>, + ) -> Result<(Bolt11Invoice, LspConfig), Error> { + let (lease, total_fee_msat, lsp, was_negotiated) = + self.acquire_fixed_lease(amount_msat, &connection_manager).await?; + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: Some(total_fee_msat), + max_proportional_opening_fee_ppm_msat: None, + }; + + let invoice = self.lsps2_create_jit_invoice( + LSPS2BuyResponse::from(&lease), + Some(amount_msat), + description, + expiry_secs, + payment_hash, + lsps2_parameters, + Some(&lsp.node_id), + )?; + + if was_negotiated { + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + } + Ok((invoice, lsp)) + } + + pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( + self: Arc, description: &Bolt11InvoiceDescription, expiry_secs: u32, + payment_hash: Option, connection_manager: Arc>, + ) -> Result<(Bolt11Invoice, LspConfig), Error> { + let (lease, proportional_fee, lsp, was_negotiated) = + self.acquire_variable_lease(None, &connection_manager).await?; + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: self.config.lsps2_max_total_lsp_fee_limit_msat, + max_proportional_opening_fee_ppm_msat: self + .config + .lsps2_max_total_lsp_fee_limit_msat + .is_none() + .then_some(proportional_fee), + }; + let invoice = self.lsps2_create_jit_invoice( + LSPS2BuyResponse::from(&lease), + None, + description, + expiry_secs, + payment_hash, + lsps2_parameters, + Some(&lsp.node_id), + )?; + + if was_negotiated { + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + } + Ok((invoice, lsp)) + } + + async fn acquire_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { + if let Some((lease, total_fee_msat, lsp)) = + self.take_cached_fixed_lease(amount_msat).await? + { + self.schedule_fixed_lease_refill(amount_msat, connection_manager); + return Ok((lease, total_fee_msat, lsp, false)); + } + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Fixed(amount_msat)); + let _request_guard = request_lock.lock().await; + if let Some((lease, total_fee_msat, lsp)) = + self.take_cached_fixed_lease(amount_msat).await? + { + self.schedule_fixed_lease_refill(amount_msat, connection_manager); + return Ok((lease, total_fee_msat, lsp, false)); + } + + let (negotiated_lease, min_total_fee_msat, cheapest_lsp) = + self.negotiate_fixed_lease(amount_msat, connection_manager).await?; + let lease = self.consume_lease(&negotiated_lease.id).await?; + self.schedule_fixed_lease_refill(amount_msat, connection_manager); + Ok((lease, min_total_fee_msat, cheapest_lsp, true)) + } + + async fn negotiate_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { + let mut attempt = 1; + loop { + let result = self.negotiate_fixed_lease_once(amount_msat, connection_manager).await; + match result { + Err(error) if should_retry_lease_negotiation(error, attempt) => { + log_warn!( + self.logger, + "LSPS2 lease negotiation attempt {} failed, retrying: {}", + attempt, + error + ); + attempt += 1; + }, + result => return result, + } + } + } + + async fn negotiate_fixed_lease_once( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { + let all_offers = self.gather_lsps2_offers(connection_manager).await?; + let mut candidates = all_offers + .into_iter() + .flat_map(|(lsp, resp)| { + resp.opening_fee_params_menu + .into_iter() + .map(move |params| (lsp.clone(), params)) + }) + .filter_map(|(lsp, params)| { + if amount_msat < params.min_payment_size_msat + || amount_msat > params.max_payment_size_msat + { + log_debug!(self.logger, + "Skipping LSP {}'s JIT offer as the payment of {}msat doesn't meet LSP limits (min: {}msat, max: {}msat)", + lsp.node_id, + amount_msat, + params.min_payment_size_msat, + params.max_payment_size_msat + ); + None + } else { + compute_opening_fee(amount_msat, params.min_fee_msat, params.proportional as u64) + .map(|fee| (lsp, fee, params)) + } + }) + .collect::>(); + candidates.sort_unstable_by_key(|(_, fee, _)| *fee); + let min_total_fee_msat = candidates.first().map(|(_, fee, _)| *fee).ok_or_else(|| { + log_error!(self.logger, "Failed to handle response from liquidity service",); + Error::LiquidityRequestFailed + })?; + + if let Some(max_total_lsp_fee_limit_msat) = self.config.lsps2_max_total_lsp_fee_limit_msat { + if min_total_fee_msat > max_total_lsp_fee_limit_msat { + log_error!(self.logger, + "Failed to request inbound JIT channel as LSP's requested total opening fee of {}msat exceeds our configured fee limit of {}msat", + min_total_fee_msat, max_total_lsp_fee_limit_msat + ); + return Err(Error::LiquidityFeeTooHigh); + } + 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( + self: &Arc, amount_msat: Option, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig, bool), Error> { + if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? { + self.schedule_variable_lease_refill(connection_manager); + return Ok((lease, fee, lsp, false)); + } + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Variable); + let _request_guard = request_lock.lock().await; + if let Some((lease, fee, lsp)) = self.take_cached_variable_lease(amount_msat).await? { + self.schedule_variable_lease_refill(connection_manager); + return Ok((lease, fee, lsp, false)); + } + + let (negotiated_lease, fee, cheapest_lsp) = + self.negotiate_variable_lease(amount_msat, connection_manager).await?; + let lease = self.consume_lease(&negotiated_lease.id).await?; + self.schedule_variable_lease_refill(connection_manager); + Ok((lease, fee, cheapest_lsp, true)) + } + + async fn negotiate_variable_lease( + self: &Arc, amount_msat: Option, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { + let mut attempt = 1; + loop { + 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!( + self.logger, + "LSPS2 lease negotiation attempt {} failed, retrying: {}", + attempt, + error + ); + attempt += 1; + }, + result => return result, + } + } + } + + async fn negotiate_variable_lease_once( + self: &Arc, amount_msat: Option, connection_manager: &Arc>, + ) -> Result<(PaymentLease, u64, LspConfig), Error> { + let all_offers = self.gather_lsps2_offers(connection_manager).await?; + let mut rejected_for_fee = false; + let mut candidates = all_offers + .into_iter() + .flat_map(|(lsp, resp)| { + resp.opening_fee_params_menu.into_iter().map(move |params| (lsp.clone(), params)) + }) + .filter_map(|(lsp, params)| { + // BOLT12 supplies a resolved amount here, while a BOLT11 zero-amount invoice does not. + // In the former case, only negotiate parameters that can carry that exact payment and + // compare providers by the total fee the payment would actually incur. + let selection_fee = if let Some(amount_msat) = amount_msat { + if amount_msat < params.min_payment_size_msat + || amount_msat > params.max_payment_size_msat + { + return None; + } + compute_opening_fee( + amount_msat, + params.min_fee_msat, + params.proportional as u64, + )? + } else { + params.proportional as u64 + }; + let fee_for_limit = amount_msat.map_or(params.min_fee_msat, |_| selection_fee); + let fee_allowed = self + .config + .lsps2_max_total_lsp_fee_limit_msat + .map_or(true, |limit| fee_for_limit <= limit); + rejected_for_fee |= !fee_allowed; + fee_allowed.then_some((lsp, selection_fee, params)) + }) + .collect::>(); + candidates.sort_unstable_by_key(|(_, fee, _)| *fee); + if candidates.is_empty() { + return Err(if rejected_for_fee { + log_error!( + self.logger, + "Failed to request inbound JIT channel as all LSP offers exceed our configured fee limit" + ); + Error::LiquidityFeeTooHigh + } else { + log_error!(self.logger, "Failed to handle response from liquidity service",); + Error::LiquidityRequestFailed + }); + } + + try_lease_candidates( + candidates, + |(lsp, _, _)| lsp.node_id, + |(lsp, fee, opening_params)| { + let client = Arc::clone(self); + async move { + 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, fee, lsp)), + Err(error) => { + log_warn!( + client.logger, + "Failed negotiating LSPS2 payment lease with LSP {}, trying the next candidate: {}", + lsp.node_id, + error + ); + Err(error) + }, + } + } + }, + ) + .await + } + + fn schedule_fixed_lease_refill( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) { + let client = Arc::clone(self); + let connection_manager = Arc::clone(connection_manager); + tokio::spawn(async move { + if let Err(error) = client.cache_fixed_lease(amount_msat, &connection_manager).await { + log_warn!(client.logger, "Failed refilling LSPS2 payment lease: {}", error); + } + }); + } + + fn schedule_variable_lease_refill( + self: &Arc, connection_manager: &Arc>, + ) { + let client = Arc::clone(self); + let connection_manager = Arc::clone(connection_manager); + tokio::spawn(async move { + if let Err(error) = client.cache_variable_lease(&connection_manager).await { + log_warn!(client.logger, "Failed refilling LSPS2 payment lease: {}", error); + } + }); + } + + async fn cache_fixed_lease( + self: &Arc, amount_msat: u64, connection_manager: &Arc>, + ) -> Result<(), Error> { + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Fixed(amount_msat)); + let _request_guard = request_lock.lock().await; + if self + .lease_state + .lock() + .expect("lock") + .has_fixed_amount(amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat) + { + return Ok(()); + } + self.negotiate_fixed_lease(amount_msat, connection_manager).await?; + Ok(()) + } + + async fn cache_variable_lease( + self: &Arc, connection_manager: &Arc>, + ) -> Result<(), Error> { + let request_lock = self + .pending_lease_request_state + .lock() + .expect("lock") + .request_lock(LeaseRequestKey::Variable); + let _request_guard = request_lock.lock().await; + if self + .lease_state + .lock() + .expect("lock") + .has_variable_amount(None, self.config.lsps2_max_total_lsp_fee_limit_msat) + { + return Ok(()); + } + self.negotiate_variable_lease(None, connection_manager).await?; + Ok(()) + } + + async fn gather_lsps2_offers( + self: &Arc, connection_manager: &Arc>, + ) -> Result, Error> { + let lsps2_nodes = self.get_lsps2_nodes().await?; + + // Connect to all candidate LSPs in parallel. + let mut connect_set = JoinSet::new(); + for lsp_node in &lsps2_nodes { + let cm = Arc::clone(connection_manager); + let node_id = lsp_node.node_id; + let addr = lsp_node.address.clone(); + let logger = self.logger.clone(); + connect_set.spawn(async move { + if let Err(e) = cm.connect_peer_if_necessary(node_id, addr).await { + log_warn!(logger, "Failed to connect to LSP {} for fee query: {}", node_id, e); + } + }); + } + while connect_set.join_next().await.is_some() {} + + let mut all_offers: Vec<(LspConfig, LSPS2FeeResponse)> = + Vec::with_capacity(lsps2_nodes.len()); + let mut fee_set: JoinSet<(LspConfig, Result)> = JoinSet::new(); + for lsp_node in &lsps2_nodes { + let lsp = lsp_node.clone(); + let client = Arc::clone(self); + fee_set.spawn(async move { + let res = client.lsps2_request_opening_fee_params(Some(&lsp.node_id)).await; + (lsp, res) + }); + } + while let Some(join_result) = fee_set.join_next().await { + match join_result { + Ok((lsp, Ok(fees))) => all_offers.push((lsp, fees)), + Ok((lsp, Err(e))) => { + log_warn!(self.logger, "Failed to get fees from LSP {}: {}", lsp.node_id, e) + }, + Err(e) => { + log_warn!(self.logger, "Failed to get fees from LSP: {}", e) + }, + } + } + + Ok(all_offers) + } +} + +impl LSPS2Client +where + L::Target: LdkLogger, +{ + async fn lsps2_request_opening_fee_params( + &self, node_id: Option<&PublicKey>, + ) -> Result { + let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) + .ok_or(Error::LiquiditySourceUnavailable)?; + + let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { + log_error!(self.logger, "Liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let (fee_request_sender, fee_request_receiver) = oneshot::channel(); + { + let mut pending_fee_requests_lock = + self.pending_lsps2_fee_requests.lock().expect("lock"); + let request_id = + client_handler.request_opening_params(lsps2_node.node_id, lsps2_node.token.clone()); + pending_fee_requests_lock.insert(request_id, fee_request_sender); + } + + tokio::time::timeout( + Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), + fee_request_receiver, + ) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request timed out: {}", e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {}", e); + Error::LiquidityRequestFailed + }) + } + + async fn lsps2_send_buy_request( + &self, amount_msat: Option, opening_fee_params: LSPS2OpeningFeeParams, + node_id: Option<&PublicKey>, + ) -> Result { + let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) + .ok_or(Error::LiquiditySourceUnavailable)?; + + let client_handler = self.liquidity_manager.lsps2_client_handler().ok_or_else(|| { + log_error!(self.logger, "Liquidity client was not configured.",); + Error::LiquiditySourceUnavailable + })?; + + let (buy_request_sender, buy_request_receiver) = oneshot::channel(); + { + 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.clone()) + .map_err(|e| { + log_error!( + self.logger, + "Failed to send buy request to liquidity service: {:?}", + e + ); + Error::LiquidityRequestFailed + })?; + pending_buy_requests_lock.insert(request_id, buy_request_sender); + } + + let buy_response = tokio::time::timeout( + Duration::from_secs(LIQUIDITY_REQUEST_TIMEOUT_SECS), + buy_request_receiver, + ) + .await + .map_err(|e| { + log_error!(self.logger, "Liquidity request timed out: {}", e); + Error::LiquidityRequestFailed + })? + .map_err(|e| { + log_error!(self.logger, "Failed to handle response from liquidity service: {:?}", e); + Error::LiquidityRequestFailed + })?; + + 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") + .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); + consume_after_persisted_removal( + lease, + move |lease| async move { lease_store.remove(&lease.id).await }, + |lease| { + self.lease_state.lock().expect("lock").remove(&lease.id); + }, + ) + .await + } + + async fn take_cached_fixed_lease( + &self, amount_msat: u64, + ) -> Result, Error> { + loop { + let Some((lease, fee_msat)) = self + .lease_state + .lock() + .expect("lock") + .fixed_amount(amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat) + else { + return Ok(None); + }; + let lease = self.consume_selected_lease(lease).await?; + if let Some(lsp) = + select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id)) + { + return Ok(Some((lease, fee_msat, lsp))); + } + } + } + + async fn take_cached_variable_lease( + &self, amount_msat: Option, + ) -> Result, Error> { + loop { + let Some((lease, fee)) = self + .lease_state + .lock() + .expect("lock") + .variable_amount(amount_msat, self.config.lsps2_max_total_lsp_fee_limit_msat) + else { + return Ok(None); + }; + let lease = self.consume_selected_lease(lease).await?; + if let Some(lsp) = + select_lsps_for_protocol(&self.lsp_nodes, 2, Some(&lease.id.lsp_node_id)) + { + return Ok(Some((lease, fee, lsp))); + } + } + } + + fn lsps2_create_jit_invoice( + &self, buy_response: LSPS2BuyResponse, amount_msat: Option, + description: &Bolt11InvoiceDescription, expiry_secs: u32, + payment_hash: Option, lsps2_parameters: LSPS2Parameters, + node_id: Option<&PublicKey>, + ) -> Result { + let lsps2_node = select_lsps_for_protocol(&self.lsp_nodes, 2, node_id) + .ok_or(Error::LiquiditySourceUnavailable)?; + + // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. + let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; + 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 + .channel_manager + .create_inbound_payment_for_hash( + payment_hash, + None, + expiry_secs, + Some(min_final_cltv_expiry_delta), + Some(encoded_payment_metadata), + ) + .map_err(|e| { + log_error!(self.logger, "Failed to register inbound payment: {:?}", e); + Error::InvoiceCreationFailed + })?; + (payment_hash, payment_secret, payment_metadata) + }, + None => self + .channel_manager + .create_inbound_payment( + None, + expiry_secs, + Some(min_final_cltv_expiry_delta), + Some(encoded_payment_metadata), + ) + .map_err(|e| { + log_error!(self.logger, "Failed to register inbound payment: {:?}", e); + Error::InvoiceCreationFailed + })?, + }; + + let route_hint = RouteHint(vec![RouteHintHop { + src_node_id: lsps2_node.node_id, + short_channel_id: buy_response.intercept_scid, + fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, + cltv_expiry_delta: buy_response.cltv_expiry_delta as u16, + htlc_minimum_msat: None, + htlc_maximum_msat: None, + }]); + + let currency = self.config.network.into(); + let mut invoice_builder = InvoiceBuilder::new(currency) + .invoice_description(description.clone()) + .payment_hash(payment_hash) + .payment_secret(payment_secret) + .current_timestamp() + .min_final_cltv_expiry_delta(min_final_cltv_expiry_delta.into()) + .expiry_time(Duration::from_secs(expiry_secs.into())) + .private_route(route_hint); + + if let Some(amount_msat) = amount_msat { + invoice_builder = invoice_builder.amount_milli_satoshis(amount_msat).basic_mpp(); + } + + let invoice = if let Some(payment_metadata) = payment_metadata { + invoice_builder.payment_metadata(payment_metadata).build_signed(|hash| { + Secp256k1::new() + .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) + }) + } else { + invoice_builder.build_signed(|hash| { + Secp256k1::new() + .sign_ecdsa_recoverable(hash, &self.keys_manager.get_node_secret_key()) + }) + }; + invoice.map_err(|e| { + log_error!(self.logger, "Failed to build and sign invoice: {}", e); + Error::InvoiceCreationFailed + }) + } + + pub(crate) async fn handle_event(&self, event: LSPS2ClientEvent) { + match event { + LSPS2ClientEvent::OpeningParametersReady { + request_id, + counterparty_node_id, + opening_fee_params_menu, + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = + self.pending_lsps2_fee_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS2FeeResponse { opening_fee_params_menu }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!( + self.logger, + "Received unexpected LSPS2Client::OpeningParametersReady event!" + ); + } + }, + LSPS2ClientEvent::InvoiceParametersReady { + request_id, + counterparty_node_id, + intercept_scid, + cltv_expiry_delta, + .. + } => { + if self + .lsp_nodes + .read() + .expect("lock") + .iter() + .any(|n| n.node_id == counterparty_node_id) + { + if let Some(sender) = + self.pending_buy_requests.lock().expect("lock").remove(&request_id) + { + let response = LSPS2BuyResponse { intercept_scid, cltv_expiry_delta }; + + match sender.send(response) { + Ok(()) => (), + Err(_) => { + log_error!( + self.logger, + "Failed to handle response for request {:?} from liquidity service", + request_id + ); + }, + } + } else { + debug_assert!( + false, + "Received response from liquidity service for unknown request." + ); + log_error!( + self.logger, + "Received response from liquidity service for unknown request." + ); + } + } else { + log_error!( + self.logger, + "Received unexpected LSPS2Client::InvoiceParametersReady event!" + ); + } + }, + _ => { + log_error!(self.logger, "Received unexpected LSPS2Client liquidity event!"); + }, + } + } + + async fn get_lsps2_nodes(&self) -> Result, Error> { + let has_undiscovered_protocol = + self.lsp_nodes.read().expect("lock").iter().any(|n| n.supported_protocols.is_none()); + + if has_undiscovered_protocol { + // LSP protocol discovery is still in flight, we wait briefly for it to finish, then re-check. + let mut rx = self.discovery_done_rx.clone(); + if !*rx.borrow() { + log_debug!( + self.logger, + "Waiting for LSP protocol discovery to complete before selecting LSPS2 nodes." + ); + let _ = tokio::time::timeout( + Duration::from_secs(LSPS_DISCOVERY_WAIT_TIMEOUT_SECS), + rx.wait_for(|done| *done), + ) + .await; + } + } + + let lsps2_nodes = select_all_lsps_for_protocol(&self.lsp_nodes, 2); + if lsps2_nodes.is_empty() { + log_error!(self.logger, "No LSPs available for LSPS2 protocol."); + return Err(Error::LiquiditySourceUnavailable); + }; + Ok(lsps2_nodes) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS2FeeResponse { + opening_fee_params_menu: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct LSPS2BuyResponse { + intercept_scid: u64, + cltv_expiry_delta: u32, +} + +impl From<&PaymentLease> for LSPS2BuyResponse { + fn from(lease: &PaymentLease) -> Self { + Self { intercept_scid: lease.id.intercept_scid, cltv_expiry_delta: lease.cltv_expiry_delta } + } +} +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn failed_persisted_removal_does_not_consume() { + let consumed = Arc::new(Mutex::new(false)); + let consumed_ref = Arc::clone(&consumed); + let result = consume_after_persisted_removal( + 42, + |_| async { Err(()) }, + move |_| *consumed_ref.lock().unwrap() = true, + ) + .await; + + assert_eq!(result, Err(())); + assert!(!*consumed.lock().unwrap()); + } + + #[test] + fn lease_negotiation_retries_only_transient_failures() { + assert!(should_retry_lease_negotiation(Error::LiquidityRequestFailed, 1)); + assert!(should_retry_lease_negotiation(Error::LiquidityRequestFailed, 2)); + assert!(!should_retry_lease_negotiation(Error::LiquidityRequestFailed, 3)); + assert!(!should_retry_lease_negotiation(Error::LiquidityFeeTooHigh, 1)); + assert!(!should_retry_lease_negotiation(Error::LiquiditySourceUnavailable, 1)); + } + + #[tokio::test] + async fn lease_negotiation_fails_over_between_lsps() { + let candidates = vec![(1, 10), (1, 20), (2, 30)]; + let attempted_lsps = Arc::new(Mutex::new(Vec::new())); + let attempted_lsps_ref = Arc::clone(&attempted_lsps); + let result = try_lease_candidates( + candidates, + |candidate| candidate.0, + move |candidate| { + let attempted_lsps = Arc::clone(&attempted_lsps_ref); + async move { + attempted_lsps.lock().unwrap().push(candidate.0); + if candidate.0 == 1 { + Err(()) + } else { + Ok(candidate.1) + } + } + }, + ) + .await; + + assert_eq!(result, Ok(30)); + assert_eq!(*attempted_lsps.lock().unwrap(), vec![1, 2]); + } +} + +pub(crate) mod router; +pub(crate) mod state; diff --git a/src/liquidity/client/lsps2/router.rs b/src/liquidity/client/lsps2/router.rs new file mode 100644 index 0000000000..92d0aa816b --- /dev/null +++ b/src/liquidity/client/lsps2/router.rs @@ -0,0 +1,392 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// . + +//! Router helpers for injecting LSPS2 blinded payment paths. + +use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; + +use lightning::blinded_path::payment::{ + BlindedPaymentPath, ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, + PaymentRelay, ReceiveTlvs, +}; +use lightning::impl_writeable_tlv_based; +use lightning::ln::channel_state::ChannelDetails; +use lightning::ln::channelmanager::{PaymentId, MIN_FINAL_CLTV_EXPIRY_DELTA}; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::{EntropySource, ReceiveAuthKey}; +use lightning::types::features::BlindedHopFeatures; +use lightning::types::payment::PaymentHash; + +use crate::payment::PaymentMetadata; + +/// Parameters needed to construct an LSPS2 blinded payment path. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LSPS2LeaseParameters { + /// The LSP serving as the blinded path introduction node. + pub lsp_node_id: PublicKey, + /// The single-use intercept short channel id negotiated with the LSP. + pub intercept_scid: u64, + /// The CLTV delta required by the LSP. + pub cltv_expiry_delta: u16, + /// The fixed amount negotiated for this intercept SCID, or `None` for a variable amount. + pub payment_size_msat: Option, + /// Unix timestamp at which these parameters expire. + pub valid_until: u64, +} + +impl_writeable_tlv_based!(LSPS2LeaseParameters, { + (0, lsp_node_id, required), + (2, intercept_scid, required), + (4, cltv_expiry_delta, required), + (6, payment_size_msat, option), + (8, valid_until, required), +}); + +/// A router wrapper that uses ordinary payment paths when available and LSPS2 JIT paths otherwise. +pub struct LSPS2Router { + inner_router: R, + entropy_source: ES, +} + +impl LSPS2Router { + /// Constructs an LSPS2-aware wrapper around `inner_router`. + pub fn new(inner_router: R, entropy_source: ES) -> Self { + Self { inner_router, entropy_source } + } + + fn payment_parameters(&self, payment_context: &PaymentContext) -> Vec { + match payment_context { + PaymentContext::Bolt12Offer(_) | PaymentContext::AsyncBolt12Offer(_) => {}, + _ => return Vec::new(), + } + payment_context + .payment_metadata() + .and_then(PaymentMetadata::decode_from_bolt12_payment_metadata) + .and_then(|metadata| metadata.lsps2_lease_parameters) + .into_iter() + .collect() + } +} + +impl Router for LSPS2Router { + fn find_route( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + ) -> Result { + self.inner_router.find_route(payer, route_params, first_hops, inflight_htlcs) + } + + fn find_route_with_id( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + payment_hash: PaymentHash, payment_id: PaymentId, + ) -> Result { + self.inner_router.find_route_with_id( + payer, + route_params, + first_hops, + inflight_htlcs, + payment_hash, + payment_id, + ) + } + + fn create_blinded_payment_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, + secp_ctx: &Secp256k1, + ) -> Result, ()> { + let inner_paths = self.inner_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs.clone(), + amount_msats, + secp_ctx, + ); + // The inner router was given the resolved payment amount and only returns ordinary paths when + // the existing inbound liquidity can receive all of it. Always prefer those paths. Besides + // avoiding an unnecessary channel open, this prevents an MPP payer from splitting one payment + // across regular and JIT paths. The LSP only opens its channel after receiving the complete + // negotiated amount on the intercept SCID, so a partial JIT shard could otherwise remain stuck + // indefinitely. + if matches!(&inner_paths, Ok(paths) if !paths.is_empty()) { + return inner_paths; + } + + let parameters = self.payment_parameters(&tlvs.payment_context); + if parameters.is_empty() { + return inner_paths; + } + let Some(amount_msats) = amount_msats else { + // Invoice construction supplies the resolved amount even for a variable-amount offer. Without + // it, we cannot constrain the JIT path to the full channel-open trigger amount. + return inner_paths; + }; + + let mut paths = Vec::new(); + for params in parameters { + // A fixed lease is valid only for the exact amount negotiated with the LSP. A mismatch means + // these parameters were selected for a different response and must never be exposed. + if params.payment_size_msat.is_some_and(|fixed_amount| fixed_amount != amount_msats) { + continue; + } + // Both fixed and variable invoices require the entire resolved payment on any selected JIT + // path. Fixed invoices may advertise MPP across multiple regular paths, while variable + // invoices disable MPP at response construction. Setting both bounds here additionally makes + // every JIT candidate indivisible and lets us include several candidates for robustness. + let htlc_amount_msat = amount_msats; + let payment_constraints = PaymentConstraints { + max_cltv_expiry: tlvs + .payment_constraints + .max_cltv_expiry + .saturating_add(params.cltv_expiry_delta as u32), + htlc_minimum_msat: htlc_amount_msat, + }; + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: params.intercept_scid, + payment_relay: PaymentRelay { + cltv_expiry_delta: params.cltv_expiry_delta, + fee_proportional_millionths: 0, + fee_base_msat: 0, + }, + payment_constraints, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: params.lsp_node_id, + htlc_maximum_msat: htlc_amount_msat, + }; + if let Ok(path) = BlindedPaymentPath::new( + &[forward_node], + recipient, + local_node_receive_key, + tlvs.clone(), + htlc_amount_msat, + MIN_FINAL_CLTV_EXPIRY_DELTA, + &self.entropy_source, + secp_ctx, + ) { + paths.push(path); + } + } + if paths.is_empty() { + // A capacity race may make ordinary paths available after a lease was negotiated. That case + // returned above and intentionally discards the single-use lease. Reaching here means neither + // ordinary nor valid JIT paths can receive the payment. + Err(()) + } else { + Ok(paths) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use bitcoin::secp256k1::SecretKey; + use core::sync::atomic::{AtomicUsize, Ordering}; + use lightning::blinded_path::payment::{Bolt12OfferContext, PaymentConstraints}; + use lightning::offers::invoice_request::InvoiceRequestFields; + use lightning::offers::offer::OfferId; + use lightning::types::payment::PaymentSecret; + use std::collections::BTreeMap; + + #[derive(Clone)] + struct TestEntropy; + + impl EntropySource for TestEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } + + struct MockRouter { + calls: AtomicUsize, + return_regular_path: bool, + } + + impl Router for MockRouter { + fn find_route( + &self, _payer: &PublicKey, _route_params: &RouteParameters, + _first_hops: Option<&[&ChannelDetails]>, _inflight_htlcs: InFlightHtlcs, + ) -> Result { + Err("mock router") + } + + fn create_blinded_payment_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + _first_hops: Vec, tlvs: ReceiveTlvs, _amount_msats: Option, + secp_ctx: &Secp256k1, + ) -> Result, ()> { + self.calls.fetch_add(1, Ordering::AcqRel); + if self.return_regular_path { + BlindedPaymentPath::one_hop( + recipient, + local_node_receive_key, + tlvs, + MIN_FINAL_CLTV_EXPIRY_DELTA, + TestEntropy, + secp_ctx, + ) + .map(|path| vec![path]) + } else { + Err(()) + } + } + } + + fn pubkey(byte: u8) -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[byte; 32]).unwrap()) + } + + fn payment_tlvs(metadata: BTreeMap>) -> ReceiveTlvs { + ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: OfferId([8; 32]), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: pubkey(9), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + payment_metadata: Some(metadata), + }), + } + } + + fn payment_metadata(parameters: LSPS2LeaseParameters) -> BTreeMap> { + PaymentMetadata { lsps2_parameters: None, lsps2_lease_parameters: Some(parameters) } + .encode_as_bolt12_payment_metadata() + } + + #[test] + fn constrains_fixed_jit_path_to_negotiated_amount() { + let lsp_node_id = pubkey(11); + let parameters = LSPS2LeaseParameters { + lsp_node_id, + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: false }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + let paths = router + .create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(router.inner_router.calls.load(Ordering::Acquire), 1); + assert_eq!(paths.len(), 1); + assert_eq!( + paths[0].introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id) + ); + assert_eq!(paths[0].payinfo.htlc_minimum_msat, 3_000); + assert_eq!(paths[0].payinfo.htlc_maximum_msat, 3_000); + } + + #[test] + fn rejects_fixed_jit_path_for_another_amount() { + let parameters = LSPS2LeaseParameters { + lsp_node_id: pubkey(11), + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: false }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + assert!(router + .create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(5_000), + &Secp256k1::new(), + ) + .is_err()); + } + + #[test] + fn constrains_variable_jit_path_to_invoice_amount() { + let parameters = LSPS2LeaseParameters { + lsp_node_id: pubkey(11), + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: None, + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: false }; + let router = LSPS2Router::new(inner_router, TestEntropy); + + let paths = router + .create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(5_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(paths[0].payinfo.htlc_minimum_msat, 5_000); + assert_eq!(paths[0].payinfo.htlc_maximum_msat, 5_000); + } + + #[test] + fn omits_jit_path_when_regular_paths_are_available() { + let lsp_node_id = pubkey(11); + let parameters = LSPS2LeaseParameters { + lsp_node_id, + intercept_scid: 42, + cltv_expiry_delta: 48, + payment_size_msat: Some(3_000), + valid_until: u64::MAX, + }; + let metadata = payment_metadata(parameters); + let inner_router = MockRouter { calls: AtomicUsize::new(0), return_regular_path: true }; + let router = LSPS2Router::new(inner_router, TestEntropy); + let recipient = pubkey(10); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + payment_tlvs(metadata), + Some(3_000), + &Secp256k1::new(), + ) + .unwrap(); + + assert_eq!(router.inner_router.calls.load(Ordering::Acquire), 1); + assert_eq!(paths.len(), 1); + assert_ne!( + paths[0].introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id) + ); + } +} diff --git a/src/liquidity/client/lsps2/state.rs b/src/liquidity/client/lsps2/state.rs new file mode 100644 index 0000000000..c77cd47d80 --- /dev/null +++ b/src/liquidity/client/lsps2/state.rs @@ -0,0 +1,782 @@ +use std::collections::HashMap; +use std::ops::Deref; +use std::sync::{Arc, Mutex, Weak}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use bitcoin::secp256k1::PublicKey; +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 tokio::sync::Mutex as AsyncMutex; + +use crate::data_store::{DataStore, StorableObject, StorableObjectId, StorableObjectUpdate}; +use crate::hex_utils; +use crate::io::{ + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, +}; +use crate::logger::{log_error, LdkLogger}; +use crate::types::DynStore; +use crate::Error; + +pub(crate) const MIN_LEASE_REMAINING_SECS: u64 = 24 * 60 * 60; + +pub(crate) type PaymentLeaseStore = DataStore; + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum LeaseCacheTargetId { + Fixed { amount_msat: u64 }, + Variable, +} + +impl_writeable_tlv_based_enum!(LeaseCacheTargetId, + (0, Fixed) => { + (0, amount_msat, required), + }, + (2, Variable) => {}, +); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct LeaseCacheTarget { + pub(crate) id: LeaseCacheTargetId, + pub(crate) absolute_expiry: Option, + last_accessed: u64, +} + +impl LeaseCacheTarget { + pub(crate) fn new(id: LeaseCacheTargetId, absolute_expiry: Option) -> Self { + Self { id, absolute_expiry, last_accessed: now_secs() } + } +} + +impl_writeable_tlv_based!(LeaseCacheTarget, { + (0, id, required), + (2, absolute_expiry, option), + (4, last_accessed, required), +}); + +#[derive(Clone, Debug, PartialEq, Eq)] +struct LeaseCacheTargets { + targets: Vec, +} + +impl_writeable_tlv_based!(LeaseCacheTargets, { + (0, targets, required_vec), +}); + +#[derive(Clone)] +pub(crate) struct LeaseCacheTargetState { + targets: HashMap, + capacity: usize, +} + +impl LeaseCacheTargetState { + pub(crate) fn from_targets( + targets: Vec, capacity: usize, now: u64, + ) -> (Self, Vec) { + let mut removed = Vec::new(); + let mut targets = targets + .into_iter() + .filter_map(|target| { + if is_cache_target_expired(&target, now) { + removed.push(target.id); + None + } else { + Some((target.id, target)) + } + }) + .collect::>(); + let capacity = capacity.max(1); + while targets.len() > capacity { + let id = least_recently_used(&targets).expect("targets are non-empty"); + targets.remove(&id); + removed.push(id); + } + (Self { targets, capacity }, removed) + } + + pub(crate) fn register(&mut self, mut target: LeaseCacheTarget) -> Option { + let last_accessed = self.next_access_timestamp(target.last_accessed); + if let Some(existing) = self.targets.get_mut(&target.id) { + existing.absolute_expiry = + merge_absolute_expiry(existing.absolute_expiry, target.absolute_expiry); + existing.last_accessed = last_accessed; + return None; + } + + let evicted = if self.targets.len() == self.capacity { + let id = least_recently_used(&self.targets).expect("targets are non-empty"); + self.targets.remove(&id); + Some(id) + } else { + None + }; + target.last_accessed = last_accessed; + self.targets.insert(target.id, target); + evicted + } + + pub(crate) fn targets(&self) -> Vec { + let mut targets = self.targets.values().cloned().collect::>(); + targets.sort_unstable_by_key(|target| target.id); + targets + } + + #[cfg(test)] + pub(crate) fn contains(&self, id: &LeaseCacheTargetId) -> bool { + self.targets.contains_key(id) + } + + pub(crate) fn prune(&mut self, now: u64) -> Vec { + let removed = self + .targets + .iter() + .filter(|(_, target)| is_cache_target_expired(target, now)) + .map(|(id, _)| *id) + .collect::>(); + for id in &removed { + self.targets.remove(id); + } + removed + } + + fn next_access_timestamp(&self, now: u64) -> u64 { + self.targets + .values() + .map(|target| target.last_accessed) + .max() + .map_or(now, |last_accessed| now.max(last_accessed.saturating_add(1))) + } +} + +pub(crate) struct LeaseCacheTargetStore +where + L::Target: LdkLogger, +{ + state: Mutex, + mutation_lock: tokio::sync::Mutex<()>, + kv_store: Arc, + logger: L, +} + +impl LeaseCacheTargetStore +where + L::Target: LdkLogger, +{ + pub(crate) fn new(state: LeaseCacheTargetState, kv_store: Arc, logger: L) -> Self { + Self { + state: Mutex::new(state), + mutation_lock: tokio::sync::Mutex::new(()), + kv_store, + logger, + } + } + + pub(crate) async fn register(&self, target: LeaseCacheTarget) -> Result<(), Error> { + let _guard = self.mutation_lock.lock().await; + let mut updated_state = self.state.lock().expect("lock").clone(); + updated_state.register(target); + self.persist_state(&updated_state).await?; + *self.state.lock().expect("lock") = updated_state; + Ok(()) + } + + pub(crate) async fn prune(&self, now: u64) -> Result<(), Error> { + let _guard = self.mutation_lock.lock().await; + let mut updated_state = self.state.lock().expect("lock").clone(); + if updated_state.prune(now).is_empty() { + return Ok(()); + } + self.persist_state(&updated_state).await?; + *self.state.lock().expect("lock") = updated_state; + Ok(()) + } + + pub(crate) async fn persist(&self) -> Result<(), Error> { + let _guard = self.mutation_lock.lock().await; + let state = self.state.lock().expect("lock").clone(); + self.persist_state(&state).await + } + + pub(crate) fn targets(&self) -> Vec { + self.state.lock().expect("lock").targets() + } + + async fn persist_state(&self, state: &LeaseCacheTargetState) -> Result<(), Error> { + let data = LeaseCacheTargets { targets: state.targets() }.encode(); + KVStore::write( + &*self.kv_store, + LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, + data, + ) + .await + .map_err(|error| { + log_error!(self.logger, "Writing LSPS2 lease cache targets failed due to: {}", error); + Error::PersistenceFailed + }) + } +} + +pub(crate) async fn read_lease_cache_targets( + kv_store: &DynStore, logger: L, +) -> Result, lightning::io::Error> +where + L::Target: LdkLogger, +{ + match KVStore::read( + kv_store, + LSPS2_CACHE_TARGET_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_SECONDARY_NAMESPACE, + LSPS2_CACHE_TARGET_PERSISTENCE_KEY, + ) + .await + { + Ok(data) => LeaseCacheTargets::read(&mut &data[..]).map(|targets| targets.targets).map_err( + |error| { + log_error!(logger, "Failed to deserialize LSPS2 lease cache targets: {}", error); + lightning::io::Error::new( + lightning::io::ErrorKind::InvalidData, + "Failed to deserialize LSPS2 lease cache targets", + ) + }, + ), + Err(error) if error.kind() == lightning::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(error) => Err(error), + } +} + +fn least_recently_used( + targets: &HashMap, +) -> Option { + targets.values().min_by_key(|target| (target.last_accessed, target.id)).map(|target| target.id) +} + +fn merge_absolute_expiry(current: Option, new: Option) -> Option { + match (current, new) { + (None, _) | (_, None) => None, + (Some(current), Some(new)) => Some(current.max(new)), + } +} + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub(crate) enum LeaseRequestKey { + Fixed(u64), + Variable, +} + +#[derive(Default)] +pub(crate) struct PendingLeaseRequestState { + locks: HashMap>>, +} + +impl PendingLeaseRequestState { + pub(crate) fn request_lock(&mut self, key: LeaseRequestKey) -> Arc> { + self.prune(); + if let Some(lock) = self.locks.get(&key).and_then(Weak::upgrade) { + return lock; + } + let lock = Arc::new(AsyncMutex::new(())); + self.locks.insert(key, Arc::downgrade(&lock)); + lock + } + + pub(crate) fn prune(&mut self) { + self.locks.retain(|_, lock| lock.strong_count() > 0); + } +} + +fn is_cache_target_expired(target: &LeaseCacheTarget, now: u64) -> bool { + target.absolute_expiry.is_some_and(|expiry| expiry <= now) +} + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub(crate) struct PaymentLeaseId { + pub(crate) lsp_node_id: PublicKey, + pub(crate) intercept_scid: u64, +} + +impl_writeable_tlv_based!(PaymentLeaseId, { + (0, lsp_node_id, required), + (2, intercept_scid, required), +}); + +impl StorableObjectId for PaymentLeaseId { + fn encode_to_hex_str(&self) -> String { + format!( + "{}{:016x}", + hex_utils::to_string(&self.lsp_node_id.serialize()), + self.intercept_scid + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PaymentLease { + pub(crate) id: PaymentLeaseId, + pub(crate) params: LSPS2OpeningFeeParams, + pub(crate) cltv_expiry_delta: u32, + pub(crate) payment_size_msat: Option, + pub(crate) valid_until: u64, +} + +impl_writeable_tlv_based!(PaymentLease, { + (0, id, required), + (2, params, required), + (4, valid_until, required), + (6, cltv_expiry_delta, required), + (8, payment_size_msat, option), +}); + +#[derive(Clone, Debug)] +pub(crate) struct PaymentLeaseUpdate(PaymentLease); + +impl StorableObjectUpdate for PaymentLeaseUpdate { + fn id(&self) -> PaymentLeaseId { + self.0.id + } +} + +impl StorableObject for PaymentLease { + type Id = PaymentLeaseId; + type Update = PaymentLeaseUpdate; + + fn id(&self) -> Self::Id { + self.id + } + + fn update(&mut self, update: Self::Update) -> bool { + if *self == update.0 { + false + } else { + *self = update.0; + true + } + } + + fn to_update(&self) -> Self::Update { + PaymentLeaseUpdate(self.clone()) + } +} + +#[derive(Default)] +pub(crate) struct LSPS2LeaseState { + leases: HashMap, +} + +impl LSPS2LeaseState { + pub(crate) fn from_leases(leases: Vec) -> Self { + Self { leases: leases.into_iter().map(|lease| (lease.id, lease)).collect() } + } + + pub(crate) fn insert(&mut self, lease: PaymentLease) { + self.leases.insert(lease.id, lease); + } + + pub(crate) fn valid(&self, id: &PaymentLeaseId) -> Option { + self.leases.get(id).filter(|lease| is_lease_usable(lease)).cloned() + } + + pub(crate) fn remove(&mut self, id: &PaymentLeaseId) -> Option { + self.leases.remove(id) + } + + pub(crate) fn fixed_amount( + &self, amount_msat: u64, max_fee_msat: Option, + ) -> Option<(PaymentLease, u64)> { + let (id, fee_msat) = self + .leases + .iter() + .filter(|(_, lease)| lease.payment_size_msat == Some(amount_msat)) + .filter(|(_, lease)| is_lease_usable(lease)) + .filter_map(|(id, lease)| { + compute_opening_fee( + amount_msat, + lease.params.min_fee_msat, + lease.params.proportional as u64, + ) + .map(|fee_msat| (*id, fee_msat)) + }) + .filter(|(_, fee_msat)| max_fee_msat.map_or(true, |max| *fee_msat <= max)) + .min_by_key(|(_, fee_msat)| *fee_msat)?; + self.leases.get(&id).cloned().map(|lease| (lease, fee_msat)) + } + + pub(crate) fn has_fixed_amount(&self, amount_msat: u64, max_fee_msat: Option) -> bool { + self.leases + .values() + .filter(|lease| lease.payment_size_msat == Some(amount_msat)) + .filter(|lease| is_lease_usable(lease)) + .filter_map(|lease| { + compute_opening_fee( + amount_msat, + lease.params.min_fee_msat, + lease.params.proportional as u64, + ) + }) + .any(|fee_msat| max_fee_msat.map_or(true, |max| fee_msat <= max)) + } + + pub(crate) fn variable_amount( + &self, amount_msat: Option, max_total_fee_msat: Option, + ) -> Option<(PaymentLease, u64)> { + let (id, selection_fee) = self + .leases + .iter() + .filter(|(_, lease)| lease.payment_size_msat.is_none()) + .filter(|(_, lease)| is_lease_usable(lease)) + .filter_map(|(id, lease)| { + let selection_fee = if let Some(amount_msat) = amount_msat { + if amount_msat < lease.params.min_payment_size_msat + || amount_msat > lease.params.max_payment_size_msat + { + return None; + } + compute_opening_fee( + amount_msat, + lease.params.min_fee_msat, + lease.params.proportional as u64, + )? + } else { + lease.params.proportional as u64 + }; + let fee_for_limit = + amount_msat.map_or(lease.params.min_fee_msat, |_| selection_fee); + max_total_fee_msat + .map_or(true, |max| fee_for_limit <= max) + .then_some((*id, selection_fee)) + }) + .min_by_key(|(_, fee)| *fee)?; + self.leases.get(&id).cloned().map(|lease| (lease, selection_fee)) + } + + pub(crate) fn has_variable_amount( + &self, amount_msat: Option, max_total_fee_msat: Option, + ) -> bool { + self.variable_amount(amount_msat, max_total_fee_msat).is_some() + } + + 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 +} + +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, + 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, + 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_000, + promise: String::new(), + }, + cltv_expiry_delta: 18, + payment_size_msat, + valid_until, + } + } + + #[test] + fn leases_are_consumed_once() { + let lease = lease(2, 42, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS + 60); + let id = lease.id; + let mut state = LSPS2LeaseState::default(); + state.insert(lease); + assert!(state.valid(&id).is_some()); + assert!(state.remove(&id).is_some()); + assert!(state.valid(&id).is_none()); + } + + #[test] + fn payment_leases_roundtrip() { + let lease = lease(2, 48, 100, Some(1_000), now_secs() + MIN_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_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 mut state = LSPS2LeaseState::from_leases(vec![fixed.clone(), variable.clone()]); + + let (selected_fixed, _) = state.fixed_amount(1_000, None).unwrap(); + assert_eq!(selected_fixed.id, fixed.id); + state.remove(&selected_fixed.id); + assert!(state.fixed_amount(1_000, None).is_none()); + + let (selected_variable, _) = state.variable_amount(None, None).unwrap(); + assert_eq!(selected_variable.id, variable.id); + } + + #[test] + fn prunes_leases_close_to_expiry() { + let lease = lease(2, 43, 1, Some(1_000), now_secs() + MIN_LEASE_REMAINING_SECS - 1); + let id = lease.id; + let mut state = LSPS2LeaseState::default(); + state.insert(lease); + state.prune(); + assert!(state.valid(&id).is_none()); + } + + #[test] + fn selects_cheapest_matching_lease_across_lsps() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let expensive = lease(2, 44, 100, Some(1_000), valid_until); + let cheap = lease(3, 45, 50, Some(1_000), valid_until); + let mut state = LSPS2LeaseState::from_leases(vec![expensive.clone(), cheap.clone()]); + + let (selected, _) = state.fixed_amount(1_000, None).unwrap(); + assert_eq!(selected.id, cheap.id); + state.remove(&selected.id); + let (remaining, _) = state.fixed_amount(1_000, None).unwrap(); + assert_eq!(remaining.id, expensive.id); + } + + #[test] + fn variable_lease_honors_total_fee_limit() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let variable = lease(2, 46, 50, None, valid_until); + let state = LSPS2LeaseState::from_leases(vec![variable.clone()]); + + assert!(state.variable_amount(None, Some(49)).is_none()); + assert_eq!(state.variable_amount(None, Some(50)).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 state = LSPS2LeaseState::from_leases(vec![incompatible, compatible.clone()]); + + let (selected, total_fee_msat) = state.variable_amount(Some(2_000), None).unwrap(); + assert_eq!( + selected.id, compatible.id, + "selected variable lease must accept the resolved payment amount" + ); + assert_eq!(total_fee_msat, 1_000); + assert!(state.variable_amount(Some(2_000), Some(999)).is_none()); + } + + #[test] + fn detects_cached_leases_for_refill() { + let valid_until = now_secs() + MIN_LEASE_REMAINING_SECS + 60; + let fixed = lease(2, 46, 100, Some(1_000), valid_until); + let variable = lease(3, 47, 50, None, valid_until); + let state = LSPS2LeaseState::from_leases(vec![fixed, variable]); + + assert!(state.has_fixed_amount(1_000, Some(100))); + assert!(!state.has_fixed_amount(1_000, Some(99))); + assert!(!state.has_fixed_amount(2_000, None)); + assert!(state.has_variable_amount(None, Some(50))); + assert!(!state.has_variable_amount(None, Some(49))); + } + + 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); + } + + #[test] + fn lease_requests_are_serialized_per_key() { + let mut state = PendingLeaseRequestState::default(); + + let fixed = state.request_lock(LeaseRequestKey::Fixed(1_000)); + let same_fixed = state.request_lock(LeaseRequestKey::Fixed(1_000)); + let other_fixed = state.request_lock(LeaseRequestKey::Fixed(2_000)); + let variable = state.request_lock(LeaseRequestKey::Variable); + + assert!(Arc::ptr_eq(&fixed, &same_fixed)); + assert!(!Arc::ptr_eq(&fixed, &other_fixed)); + assert!(!Arc::ptr_eq(&fixed, &variable)); + } +} diff --git a/src/liquidity/mod.rs b/src/liquidity/mod.rs index 0eddde1ae8..f919829640 100644 --- a/src/liquidity/mod.rs +++ b/src/liquidity/mod.rs @@ -31,7 +31,15 @@ use tokio::sync::oneshot; use crate::builder::BuildError; use crate::connection::ConnectionManager; +use crate::io::utils::read_all_objects; +use crate::io::{ + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, +}; use crate::liquidity::client::lsps1::LSPS1Client; +use crate::liquidity::client::lsps2::state::{ + is_lease_usable, now_secs, read_lease_cache_targets, LSPS2LeaseState, LeaseCacheTargetState, + LeaseCacheTargetStore, PaymentLeaseStore, PendingLeaseRequestState, +}; use crate::liquidity::client::lsps2::LSPS2Client; use crate::liquidity::service::lsps2::{LSPS2Service, LSPS2ServiceLiquiditySource}; use crate::logger::{log_debug, log_error, log_info, LdkLogger, Logger}; @@ -41,6 +49,9 @@ use crate::{Config, Error}; const LIQUIDITY_REQUEST_TIMEOUT_SECS: u64 = 5; const LSPS_DISCOVERY_WAIT_TIMEOUT_SECS: u64 = 10; +// Keep the proactively refreshed target set bounded. Evicted requirements still fall back to +// on-demand lease negotiation, so this limits durable state without limiting offer validity. +const LSPS2_LEASE_CACHE_TARGET_SIZE: usize = 100; fn select_lsps_for_protocol( lsp_nodes: &Arc>>, protocol: u16, override_node_id: Option<&PublicKey>, @@ -247,6 +258,42 @@ where } pub(crate) async fn build(self) -> Result, BuildError> { + let (leases, cache_targets) = tokio::join!( + read_all_objects( + &*self.kv_store, + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE, + LSPS2_LEASE_PERSISTENCE_SECONDARY_NAMESPACE, + self.logger.clone(), + ), + read_lease_cache_targets(&*self.kv_store, self.logger.clone()), + ); + let leases = leases.map_err(|_| BuildError::ReadFailed)?; + let cache_targets = cache_targets.map_err(|_| BuildError::ReadFailed)?; + let lease_store = Arc::new(PaymentLeaseStore::new( + leases.clone(), + LSPS2_LEASE_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + 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 (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; @@ -311,6 +358,10 @@ 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), + 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(), diff --git a/src/liquidity/service/lsps2.rs b/src/liquidity/service/lsps2.rs index 946511c5d9..4732e6f92c 100644 --- a/src/liquidity/service/lsps2.rs +++ b/src/liquidity/service/lsps2.rs @@ -26,7 +26,7 @@ use crate::logger::{log_error, LdkLogger}; use crate::types::{ChannelManager, KeysManager, LiquidityManager, PeerManager, Wallet}; use crate::{total_anchor_channels_reserve_sats, Config}; -const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); +const LSPS2_GETINFO_REQUEST_EXPIRY: Duration = Duration::from_secs(60 * 60 * 48); const LSPS2_CHANNEL_CLTV_EXPIRY_DELTA: u32 = 72; pub(crate) struct LSPS2Service { diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 4503dfa061..651da77b2a 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -13,7 +13,6 @@ use std::sync::{Arc, RwLock}; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; -use lightning::impl_writeable_tlv_based; use lightning::ln::channelmanager::{ Bolt11InvoiceParameters, OptionalBolt11PaymentParams, PaymentId, }; @@ -32,8 +31,7 @@ use crate::ffi::{maybe_deref, maybe_try_convert_enum, maybe_wrap}; use crate::liquidity::LiquiditySource; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::store::{ - LSPS2Parameters, PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, - PaymentStatus, + PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; use crate::peer_store::{PeerInfo, PeerStore}; use crate::runtime::Runtime; @@ -49,16 +47,6 @@ type Bolt11InvoiceDescription = LdkBolt11InvoiceDescription; #[cfg(feature = "uniffi")] type Bolt11InvoiceDescription = crate::ffi::Bolt11InvoiceDescription; -/// Metadata carried in BOLT11 invoice `payment_metadata`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct PaymentMetadata { - pub(crate) lsps2_parameters: Option, -} - -impl_writeable_tlv_based!(PaymentMetadata, { - (0, lsps2_parameters, option), -}); - /// A payment handler allowing to create and pay [BOLT 11] invoices. /// /// Should be retrieved by calling [`Node::bolt11_payment`]. @@ -165,8 +153,7 @@ impl Bolt11Payment { fn receive_via_jit_channel_inner( &self, amount_msat: Option, description: &LdkBolt11InvoiceDescription, - expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, - max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: Option, + expiry_secs: u32, payment_hash: Option, ) -> Result { let connection_manager = Arc::clone(&self.connection_manager); let (invoice, chosen_lsp) = self.runtime.block_on(async move { @@ -177,7 +164,6 @@ impl Bolt11Payment { amount_msat, description, expiry_secs, - max_total_lsp_fee_limit_msat, payment_hash, connection_manager, ) @@ -188,7 +174,6 @@ impl Bolt11Payment { .lsps2_receive_variable_amount_to_jit_channel( description, expiry_secs, - max_proportional_lsp_fee_limit_ppm_msat, payment_hash, connection_manager, ) @@ -233,37 +218,6 @@ impl Bolt11Payment { } } -#[cfg(test)] -mod tests { - use lightning::util::ser::{Readable, Writeable}; - - use super::*; - - #[test] - fn empty_metadata_roundtrips() { - let metadata = PaymentMetadata { lsps2_parameters: None }; - - let encoded = metadata.encode(); - let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); - - assert_eq!(metadata, decoded); - } - - #[test] - fn lsps2_parameters_roundtrip() { - let lsps2_parameters = LSPS2Parameters { - max_total_opening_fee_msat: Some(42_000), - max_proportional_opening_fee_ppm_msat: Some(17_000), - }; - let metadata = PaymentMetadata { lsps2_parameters: Some(lsps2_parameters) }; - - let encoded = metadata.encode(); - let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); - - assert_eq!(metadata, decoded); - } -} - impl Bolt11Payment { fn send_internal( &self, invoice: &LdkBolt11Invoice, amount_msat: Option, @@ -655,40 +609,33 @@ impl Bolt11Payment { Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a payment of the amount given and - /// receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a payment of + /// the amount given when additional inbound liquidity is needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_total_lsp_fee_limit_msat` will limit how much fee we allow the LSP to take for opening the - /// channel to us. We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced before creating the invoice. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md pub fn receive_via_jit_channel( &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_total_lsp_fee_limit_msat: Option, ) -> Result { let description = maybe_try_convert_enum(description)?; - let invoice = self.receive_via_jit_channel_inner( - Some(amount_msat), - &description, - expiry_secs, - max_total_lsp_fee_limit_msat, - None, - None, - )?; + let invoice = + self.receive_via_jit_channel_inner(Some(amount_msat), &description, expiry_secs, None)?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a payment of the amount given and - /// receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a payment of + /// the amount given when additional inbound liquidity is needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_total_lsp_fee_limit_msat` will limit how much fee we allow the LSP to take for opening the - /// channel to us. We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced before creating the invoice. /// /// We will register the given payment hash and emit a [`PaymentClaimable`] event once /// the inbound payment arrives. The check that [`counterparty_skimmed_fee_msat`] is within the limits @@ -706,56 +653,46 @@ impl Bolt11Payment { /// [`counterparty_skimmed_fee_msat`]: crate::payment::PaymentKind::Bolt11::counterparty_skimmed_fee_msat pub fn receive_via_jit_channel_for_hash( &self, amount_msat: u64, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_total_lsp_fee_limit_msat: Option, payment_hash: PaymentHash, + payment_hash: PaymentHash, ) -> Result { let description = maybe_try_convert_enum(description)?; let invoice = self.receive_via_jit_channel_inner( Some(amount_msat), &description, expiry_secs, - max_total_lsp_fee_limit_msat, - None, Some(payment_hash), )?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a variable amount payment (also known - /// as "zero-amount" invoice) and receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a variable + /// amount payment, also known as a "zero-amount" invoice, when additional inbound liquidity is + /// needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_proportional_lsp_fee_limit_ppm_msat` will limit how much proportional fee, in - /// parts-per-million millisatoshis, we allow the LSP to take for opening the channel to us. - /// We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced when the payment arrives. /// /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md pub fn receive_variable_amount_via_jit_channel( &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_proportional_lsp_fee_limit_ppm_msat: Option, ) -> Result { let description = maybe_try_convert_enum(description)?; - let invoice = self.receive_via_jit_channel_inner( - None, - &description, - expiry_secs, - None, - max_proportional_lsp_fee_limit_ppm_msat, - None, - )?; + let invoice = self.receive_via_jit_channel_inner(None, &description, expiry_secs, None)?; Ok(maybe_wrap(invoice)) } - /// Returns a payable invoice that can be used to request a variable amount payment (also known - /// as "zero-amount" invoice) and receive it via a newly created just-in-time (JIT) channel. + /// Returns a payable invoice that can use a just-in-time (JIT) channel to receive a variable + /// amount payment, also known as a "zero-amount" invoice, when additional inbound liquidity is + /// needed. /// - /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel - /// to us, supplying just-in-time inbound liquidity. + /// The configured [LSPS2]-compliant LSP may open a channel when the invoice is paid. The payment + /// may instead arrive over pre-existing channels when they provide sufficient inbound liquidity, + /// in which case no new channel is opened. /// - /// If set, `max_proportional_lsp_fee_limit_ppm_msat` will limit how much proportional fee, in - /// parts-per-million millisatoshis, we allow the LSP to take for opening the channel to us. - /// We'll use its cheapest offer otherwise. + /// The configured LSPS2 fee limit is enforced when the payment arrives. /// /// We will register the given payment hash and emit a [`PaymentClaimable`] event once /// the inbound payment arrives. The check that [`counterparty_skimmed_fee_msat`] is within the limits @@ -772,16 +709,13 @@ impl Bolt11Payment { /// [`fail_for_hash`]: Self::fail_for_hash /// [`counterparty_skimmed_fee_msat`]: crate::payment::PaymentKind::Bolt11::counterparty_skimmed_fee_msat pub fn receive_variable_amount_via_jit_channel_for_hash( - &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, - max_proportional_lsp_fee_limit_ppm_msat: Option, payment_hash: PaymentHash, + &self, description: &Bolt11InvoiceDescription, expiry_secs: u32, payment_hash: PaymentHash, ) -> Result { let description = maybe_try_convert_enum(description)?; let invoice = self.receive_via_jit_channel_inner( None, &description, expiry_secs, - None, - max_proportional_lsp_fee_limit_ppm_msat, Some(payment_hash), )?; Ok(maybe_wrap(invoice)) diff --git a/src/payment/bolt12.rs b/src/payment/bolt12/mod.rs similarity index 90% rename from src/payment/bolt12.rs rename to src/payment/bolt12/mod.rs index d79aca6c24..2e057860f1 100644 --- a/src/payment/bolt12.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}; @@ -27,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; @@ -62,6 +68,7 @@ type HumanReadableName = Arc; pub struct Bolt12Payment { runtime: Arc, channel_manager: Arc, + liquidity_source: Arc>>, keys_manager: Arc, payment_store: Arc, config: Arc, @@ -73,13 +80,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, @@ -155,6 +163,7 @@ impl Bolt12Payment { hash: None, preimage: None, secret: None, + counterparty_skimmed_fee_msat: None, offer_id: offer.id(), payer_note: payer_note.map(UntrustedString), quantity, @@ -180,6 +189,7 @@ impl Bolt12Payment { hash: None, preimage: None, secret: None, + counterparty_skimmed_fee_msat: None, offer_id: offer.id(), payer_note: payer_note.map(UntrustedString), quantity, @@ -237,6 +247,25 @@ impl Bolt12Payment { Ok(finalized_offer) } + fn register_cache_target(&self, offer: &LdkOffer, id: LeaseCacheTargetId) { + let absolute_expiry = offer.absolute_expiry().map(|expiry| expiry.as_secs()); + if let Err(error) = self.runtime.block_on( + self.liquidity_source.lsps2_client().register_cache_target(id, absolute_expiry), + ) { + // Cache targets only reduce invoice-request latency. Failing to persist one must not make a + // long-lived offer unusable because the request path can negotiate a lease on demand. + log_error!(self.logger, "Failed to cache LSPS2 lease target: {}", error); + } + } + + fn has_sufficient_inbound_liquidity(&self, amount_msat: u64) -> bool { + self.channel_manager + .list_usable_channels() + .into_iter() + .fold(0u64, |total, channel| total.saturating_add(channel.inbound_capacity_msat)) + >= amount_msat + } + fn blinded_paths_for_async_recipient_internal( &self, recipient_id: Vec, ) -> Result, Error> { @@ -317,6 +346,7 @@ impl Bolt12Payment { hash: None, preimage: None, secret: None, + counterparty_skimmed_fee_msat: None, offer_id: offer.id(), payer_note: payer_note.map(UntrustedString), quantity, @@ -342,6 +372,7 @@ impl Bolt12Payment { hash: None, preimage: None, secret: None, + counterparty_skimmed_fee_msat: None, offer_id: offer.id(), payer_note: payer_note.map(UntrustedString), quantity, @@ -391,15 +422,26 @@ impl Bolt12Payment { /// Returns a payable offer that can be used to request and receive a payment of the amount /// given. + /// + /// If LSPS2 is configured and existing inbound liquidity is insufficient when an invoice is + /// requested, the payment may be received through a just-in-time channel. Otherwise, it may be + /// received entirely over pre-existing channels. pub fn receive( &self, amount_msat: u64, description: &str, expiry_secs: Option, quantity: Option, ) -> Result { let offer = self.receive_inner(amount_msat, description, expiry_secs, quantity)?; + if !self.has_sufficient_inbound_liquidity(amount_msat) { + self.register_cache_target(&offer, LeaseCacheTargetId::Fixed { amount_msat }); + } Ok(maybe_wrap(offer)) } /// Returns a payable offer that can be used to request and receive a payment for which the /// amount is to be determined by the user, also known as a "zero-amount" offer. + /// + /// If LSPS2 is configured and existing inbound liquidity is insufficient when an invoice is + /// requested, the payment may be received through a just-in-time channel. Otherwise, it may be + /// received entirely over pre-existing channels. pub fn receive_variable_amount( &self, description: &str, expiry_secs: Option, ) -> Result { @@ -419,7 +461,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)) } diff --git a/src/payment/bolt12/offers.rs b/src/payment/bolt12/offers.rs new file mode 100644 index 0000000000..4a59f7d763 --- /dev/null +++ b/src/payment/bolt12/offers.rs @@ -0,0 +1,537 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +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::{MessageContext, OffersContext}; +use lightning::chain::transaction::TransactionData; +use lightning::chain::{BlockLocator, Confirm, Listen}; +use lightning::offers::flow::{InvreqResponseInstructions, OffersMessageFlow}; +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::connection::ConnectionManager; +use crate::liquidity::client::lsps2::{JitInvoiceRequest, JitInvoiceResponse, LSPS2Client}; +use crate::logger::{log_error, Logger}; +use crate::runtime::Runtime; +use crate::types::{ChannelManager, KeysManager, MessageRouter, OnionMessenger, Router}; + +type NodeOffersFlow = OffersMessageFlow, Arc>; +type InvoicePaymentInfo = OnceLock<(u64, PaymentHash, PaymentSecret)>; + +enum InvoiceBuildError { + Semantic(Bolt12SemanticError), + Response(InvoiceError), +} + +impl InvoiceBuildError { + fn into_invoice_error(self) -> InvoiceError { + match self { + Self::Semantic(error) => InvoiceError::from(error), + Self::Response(error) => error, + } + } +} + +const MAX_PENDING_JIT_INVOICE_REQUESTS: usize = 100; + +struct PendingJitInvoiceRequests { + in_flight: Arc, + limit: usize, +} + +impl PendingJitInvoiceRequests { + fn new(limit: usize) -> Self { + Self { in_flight: Arc::new(AtomicUsize::new(0)), limit } + } + + fn try_acquire(&self) -> Option { + self.in_flight + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |in_flight| { + (in_flight < self.limit).then_some(in_flight + 1) + }) + .ok() + .map(|_| PendingJitInvoiceRequest { in_flight: Arc::clone(&self.in_flight) }) + } +} + +struct PendingJitInvoiceRequest { + in_flight: Arc, +} + +impl Drop for PendingJitInvoiceRequest { + fn drop(&mut self) { + self.in_flight.fetch_sub(1, Ordering::AcqRel); + } +} + +struct JitInvoiceRequestDependencies { + runtime: Arc, + lsps2_client: Arc>>, + 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: Arc, + secp_ctx: Arc>, + best_block: RwLock, + jit_dependencies: OnceLock, + pending_jit_invoice_requests: PendingJitInvoiceRequests, + logger: Arc, +} + +impl NodeOffersMessageHandler { + pub(crate) fn new( + network: Network, current_timestamp: u32, channel_manager: 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(); + secp_ctx.seeded_randomize(&keys_manager.get_secure_random_bytes()); + let flow = Arc::new(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.clone(), + message_router, + Arc::clone(&logger), + )); + Self { + channel_manager, + keys_manager, + router, + flow, + secp_ctx: Arc::new(secp_ctx), + best_block: RwLock::new(best_block), + jit_dependencies: OnceLock::new(), + pending_jit_invoice_requests: PendingJitInvoiceRequests::new( + MAX_PENDING_JIT_INVOICE_REQUESTS, + ), + logger, + } + } + + pub(crate) fn initialize_jit_handling( + &self, runtime: Arc, lsps2_client: Arc>>, + connection_manager: Weak>>, + onion_messenger: Weak, + ) { + let dependencies = JitInvoiceRequestDependencies { + runtime, + lsps2_client, + connection_manager, + onion_messenger, + }; + assert!( + self.jit_dependencies.set(dependencies).is_ok(), + "JIT invoice handling must only be initialized once" + ); + } + + pub(crate) fn current_best_block(&self) -> BlockLocator { + *self.best_block.read().expect("lock") + } +} + +fn build_invoice( + flow: &NodeOffersFlow, channel_manager: &ChannelManager, keys_manager: &KeysManager, + router: &Router, secp_ctx: &Secp256k1, + invoice_request: &InvoiceRequestVerifiedFromOffer, + payment_metadata: Option>>, allow_mpp: bool, + payment_info: &InvoicePaymentInfo, +) -> Result<(Bolt12Invoice, MessageContext), InvoiceBuildError> { + let get_payment_info = |amount_msats, relative_expiry| { + if let Some((cached_amount_msats, payment_hash, payment_secret)) = payment_info.get() { + return (*cached_amount_msats == amount_msats) + .then_some((*payment_hash, *payment_secret)) + .ok_or(Bolt12SemanticError::InvalidAmount); + } + let (payment_hash, payment_secret, _) = channel_manager + .create_inbound_payment(Some(amount_msats), relative_expiry, None, None) + .map_err(|_| Bolt12SemanticError::InvalidAmount)?; + let _ = payment_info.set((amount_msats, payment_hash, payment_secret)); + Ok((payment_hash, payment_secret)) + }; + + match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { + let (builder, context) = flow + .create_invoice_builder_from_invoice_request_with_keys( + router, + request, + channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceBuildError::Semantic)?; + let builder = if allow_mpp { builder } else { builder.disallow_mpp() }; + builder + .build_and_sign(secp_ctx) + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error))) + .map(|invoice| (invoice, context)) + }, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => { + let (builder, context) = flow + .create_invoice_builder_from_invoice_request_without_keys( + router, + request, + channel_manager.list_usable_channels(), + get_payment_info, + payment_metadata, + ) + .map_err(InvoiceBuildError::Semantic)?; + let builder = if allow_mpp { builder } else { builder.disallow_mpp() }; + let invoice = builder + .build() + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error)))?; + invoice + .sign(|invoice: &UnsignedBolt12Invoice| keys_manager.sign_bolt12_invoice(invoice)) + .map_err(|error| InvoiceBuildError::Response(InvoiceError::from(error))) + .map(|invoice| (invoice, context)) + }, + } +} + +fn jit_invoice_request( + invoice_request: &InvoiceRequestVerifiedFromOffer, +) -> Result { + jit_invoice_request_from_fields( + invoice_request.amount(), + invoice_request.amount_msats(), + invoice_request.absolute_expiry().map(|expiry| expiry.as_secs()), + ) +} + +fn jit_invoice_request_from_fields( + offer_amount: Option, amount_msat: Option, absolute_expiry: Option, +) -> Result { + let amount_msat = amount_msat.ok_or(Bolt12SemanticError::MissingAmount)?; + match offer_amount { + Some(Amount::Bitcoin { .. }) => { + Ok(JitInvoiceRequest::Fixed { amount_msat, absolute_expiry }) + }, + Some(Amount::Currency { .. }) => Err(Bolt12SemanticError::UnsupportedCurrency), + None => Ok(JitInvoiceRequest::Variable { amount_msat, absolute_expiry }), + } +} + +impl OffersMessageHandler for NodeOffersMessageHandler { + fn handle_message( + &self, message: OffersMessage, context: Option, responder: Option, + ) -> Option<(OffersMessage, ResponseInstruction)> { + 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 allow_mpp = invoice_request.amount().is_some(); + let payment_info = Arc::new(InvoicePaymentInfo::new()); + let result = build_invoice( + self.flow.as_ref(), + self.channel_manager.as_ref(), + self.keys_manager.as_ref(), + self.router.as_ref(), + self.secp_ctx.as_ref(), + &invoice_request, + payment_metadata.clone(), + allow_mpp, + payment_info.as_ref(), + ); + + match result { + Ok((invoice, context)) => { + return Some(( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + )); + }, + 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 pending_request = match self.pending_jit_invoice_requests.try_acquire() { + Some(pending_request) => pending_request, + None => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "Too many pending JIT invoice requests".to_owned(), + )), + responder.respond(), + )); + }, + }; + let connection_manager = match dependencies.connection_manager.upgrade() { + Some(connection_manager) => connection_manager, + None => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "JIT invoice handling is unavailable".to_owned(), + )), + responder.respond(), + )); + }, + }; + let onion_messenger = match dependencies.onion_messenger.upgrade() { + Some(onion_messenger) => onion_messenger, + None => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from_string( + "JIT invoice handling is unavailable".to_owned(), + )), + responder.respond(), + )); + }, + }; + + let lsps2_client = Arc::clone(&dependencies.lsps2_client); + let flow = Arc::clone(&self.flow); + let channel_manager = Arc::clone(&self.channel_manager); + let keys_manager = Arc::clone(&self.keys_manager); + let router = Arc::clone(&self.router); + let secp_ctx = Arc::clone(&self.secp_ctx); + let logger = Arc::clone(&self.logger); + dependencies.runtime.spawn_cancellable_background_task(async move { + let _pending_request = pending_request; + let response = + lsps2_client.prepare_invoice_response(jit_request, connection_manager).await; + let (message, instructions) = match response { + Ok(JitInvoiceResponse { payment_metadata: jit_metadata, allow_mpp }) => { + debug_assert_eq!(allow_mpp, jit_request.allow_mpp()); + let mut merged_metadata = payment_metadata.unwrap_or_default(); + merged_metadata.extend(jit_metadata); + match build_invoice( + flow.as_ref(), + channel_manager.as_ref(), + keys_manager.as_ref(), + router.as_ref(), + secp_ctx.as_ref(), + &invoice_request, + Some(merged_metadata), + allow_mpp, + payment_info.as_ref(), + ) { + Ok((invoice, context)) => ( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + ), + Err(error) => ( + OffersMessage::InvoiceError(error.into_invoice_error()), + responder.respond(), + ), + } + }, + Err(error) => { + log_error!(logger, "Failed preparing LSPS2 invoice response: {}", error); + ( + OffersMessage::InvoiceError(InvoiceError::from_string( + "Failed preparing JIT invoice".to_owned(), + )), + responder.respond(), + ) + }, + }; + if let Err(error) = onion_messenger.handle_onion_message_response(message, instructions) + { + log_error!(logger, "Failed sending LSPS2 invoice response: {:?}", error); + } + }); + None + } + + fn release_pending_messages(&self) -> Vec<(OffersMessage, MessageSendInstructions)> { + 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; + } +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU64; + + use bitcoin::secp256k1::{PublicKey, SecretKey}; + use lightning::ln::channelmanager::PaymentId; + use lightning::ln::inbound_payment::ExpandedKey; + use lightning::offers::nonce::Nonce; + use lightning::offers::offer::{OfferBuilder, Quantity}; + + use super::*; + + struct FixedEntropy; + + impl EntropySource for FixedEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } + + fn recipient_pubkey() -> PublicKey { + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[43; 32]).unwrap()) + } + + #[test] + fn fixed_offer_request_uses_quantity_resolved_amount() { + let expanded_key = ExpandedKey::new([44; 32]); + let nonce = Nonce::from_entropy_source(&FixedEntropy); + let secp_ctx = Secp256k1::new(); + let offer = OfferBuilder::new(recipient_pubkey()) + .amount_msats(1_000) + .supported_quantity(Quantity::Bounded(NonZeroU64::new(10).unwrap())) + .build() + .unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, PaymentId([45; 32])) + .unwrap() + .quantity(3) + .unwrap() + .build_and_sign() + .unwrap(); + + assert_eq!( + jit_invoice_request_from_fields(offer.amount(), invoice_request.amount_msats(), None,) + .unwrap(), + JitInvoiceRequest::Fixed { amount_msat: 3_000, absolute_expiry: None } + ); + } + + #[test] + fn variable_offer_request_disables_mpp() { + let expanded_key = ExpandedKey::new([46; 32]); + let nonce = Nonce::from_entropy_source(&FixedEntropy); + let secp_ctx = Secp256k1::new(); + let offer = OfferBuilder::new(recipient_pubkey()).build().unwrap(); + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, PaymentId([47; 32])) + .unwrap() + .amount_msats(2_500) + .unwrap() + .build_and_sign() + .unwrap(); + let request = + jit_invoice_request_from_fields(offer.amount(), invoice_request.amount_msats(), None) + .unwrap(); + + assert_eq!( + request, + JitInvoiceRequest::Variable { amount_msat: 2_500, absolute_expiry: None } + ); + assert!(!request.allow_mpp()); + } + + #[test] + fn pending_jit_invoice_requests_are_bounded() { + let pending_requests = PendingJitInvoiceRequests::new(2); + let first = pending_requests.try_acquire().unwrap(); + let _second = pending_requests.try_acquire().unwrap(); + + assert!(pending_requests.try_acquire().is_none()); + + drop(first); + assert!(pending_requests.try_acquire().is_some()); + } +} diff --git a/src/payment/metadata.rs b/src/payment/metadata.rs new file mode 100644 index 0000000000..5ba990e4ed --- /dev/null +++ b/src/payment/metadata.rs @@ -0,0 +1,95 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::collections::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; + +pub(crate) const LDK_NODE_BOLT12_PAYMENT_METADATA_KEY: u64 = 0; + +/// Metadata carried in BOLT11 invoice metadata or BOLT12 payment-context metadata. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PaymentMetadata { + /// Fee limits retained until payment claim to validate the LSP's withholding. + pub(crate) lsps2_parameters: Option, + /// Single-use routing parameters consumed while constructing blinded payment paths. + pub(crate) lsps2_lease_parameters: Option, +} + +impl PaymentMetadata { + pub(crate) fn encode_as_bolt12_payment_metadata(&self) -> BTreeMap> { + let mut metadata = BTreeMap::new(); + metadata.insert(LDK_NODE_BOLT12_PAYMENT_METADATA_KEY, self.encode()); + metadata + } + + pub(crate) fn decode_from_bolt12_payment_metadata( + payment_metadata: &BTreeMap>, + ) -> Option { + payment_metadata + .get(&LDK_NODE_BOLT12_PAYMENT_METADATA_KEY) + .and_then(|encoded| Self::read(&mut &encoded[..]).ok()) + } +} + +impl_writeable_tlv_based!(PaymentMetadata, { + (0, lsps2_parameters, option), + (2, lsps2_lease_parameters, option), +}); + +#[cfg(test)] +mod tests { + use lightning::util::ser::{Readable, Writeable}; + + use super::*; + + #[test] + fn empty_metadata_roundtrips() { + let metadata = PaymentMetadata { lsps2_parameters: None, lsps2_lease_parameters: None }; + + let encoded = metadata.encode(); + let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); + + assert_eq!(metadata, decoded); + } + + #[test] + fn lsps2_parameters_roundtrip() { + let lsps2_parameters = LSPS2Parameters { + max_total_opening_fee_msat: Some(42_000), + max_proportional_opening_fee_ppm_msat: Some(17_000), + }; + let metadata = PaymentMetadata { + lsps2_parameters: Some(lsps2_parameters), + lsps2_lease_parameters: None, + }; + + let encoded = metadata.encode(); + let decoded = PaymentMetadata::read(&mut &*encoded).unwrap(); + + assert_eq!(metadata, decoded); + } + + #[test] + fn bolt12_metadata_roundtrips() { + let metadata = PaymentMetadata { + lsps2_parameters: Some(LSPS2Parameters { + max_total_opening_fee_msat: Some(42_000), + max_proportional_opening_fee_ppm_msat: None, + }), + lsps2_lease_parameters: None, + }; + + let encoded = metadata.encode_as_bolt12_payment_metadata(); + + assert_eq!(PaymentMetadata::decode_from_bolt12_payment_metadata(&encoded), Some(metadata)); + } +} diff --git a/src/payment/mod.rs b/src/payment/mod.rs index fd75322ceb..4b80711289 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod asynchronous; mod bolt11; mod bolt12; +mod metadata; mod onchain; pub(crate) mod pending_payment_store; mod spontaneous; @@ -17,8 +18,9 @@ pub(crate) mod store; mod unified; pub use bolt11::Bolt11Payment; -pub(crate) use bolt11::PaymentMetadata; pub use bolt12::Bolt12Payment; +pub(crate) use bolt12::NodeOffersMessageHandler; +pub(crate) use metadata::PaymentMetadata; pub use onchain::OnchainPayment; pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails}; pub use spontaneous::SpontaneousPayment; diff --git a/src/payment/store.rs b/src/payment/store.rs index d2b92747a2..ae7ab206b3 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -253,12 +253,13 @@ impl StorableObject for PaymentDetails { if let Some(skimmed_fee_msat) = update.counterparty_skimmed_fee_msat { match self.kind { - PaymentKind::Bolt11 { ref mut counterparty_skimmed_fee_msat, .. } => { + PaymentKind::Bolt11 { ref mut counterparty_skimmed_fee_msat, .. } + | PaymentKind::Bolt12Offer { ref mut counterparty_skimmed_fee_msat, .. } => { update_if_necessary!(*counterparty_skimmed_fee_msat, skimmed_fee_msat); }, _ => debug_assert!( false, - "We should only ever override counterparty_skimmed_fee_msat for BOLT11 payments" + "We should only override counterparty_skimmed_fee_msat for invoice payments" ), } } @@ -545,6 +546,17 @@ pub enum PaymentKind { preimage: Option, /// The secret used by the payment. secret: Option, + /// The value, in thousands of a satoshi, that was deducted from this payment as an extra + /// fee taken by our channel counterparty. + /// + /// Will only ever be `Some` for inbound payments received via an [bLIP-52 / LSPS 2] + /// just-in-time channel, and only after the payment is observed; `None` otherwise. + /// + /// This will always be `None` for payments serialized by versions that did not record + /// BOLT12 LSPS2 fees. + /// + /// [bLIP-52 / LSPS 2]: https://github.com/lightning/blips/blob/master/blip-0052.md + counterparty_skimmed_fee_msat: Option, /// The ID of the offer this payment is for. offer_id: OfferId, /// The payer note for the payment. @@ -617,6 +629,7 @@ impl_writeable_tlv_based_enum!(PaymentKind, (2, preimage, option), (3, quantity, option), (4, secret, option), + (5, counterparty_skimmed_fee_msat, option), (6, offer_id, required), }, (8, Spontaneous) => { @@ -732,7 +745,8 @@ impl From<&PaymentDetails> for PaymentDetailsUpdate { }; let counterparty_skimmed_fee_msat = match value.kind { - PaymentKind::Bolt11 { counterparty_skimmed_fee_msat, .. } => { + PaymentKind::Bolt11 { counterparty_skimmed_fee_msat, .. } + | PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { Some(counterparty_skimmed_fee_msat) }, _ => None, @@ -1022,6 +1036,38 @@ mod tests { } } + #[test] + fn bolt12_offer_records_counterparty_skimmed_fee() { + let payment_id = PaymentId([41; 32]); + let mut payment = PaymentDetails::new( + payment_id, + PaymentKind::Bolt12Offer { + hash: Some(PaymentHash([42; 32])), + preimage: None, + secret: None, + counterparty_skimmed_fee_msat: None, + offer_id: OfferId([43; 32]), + payer_note: None, + quantity: None, + }, + Some(100_000), + None, + PaymentDirection::Inbound, + PaymentStatus::Pending, + ); + + assert!(payment.update(PaymentDetailsUpdate { + counterparty_skimmed_fee_msat: Some(Some(1_000)), + ..PaymentDetailsUpdate::new(payment_id) + })); + assert!(matches!( + payment.kind, + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat: Some(1_000), .. } + )); + let encoded = payment.encode(); + assert_eq!(payment, PaymentDetails::read(&mut &*encoded).unwrap()); + } + #[derive(Clone, Debug, PartialEq, Eq)] struct LegacyBolt11JitKind { hash: PaymentHash, diff --git a/src/types.rs b/src/types.rs index 5552877ef8..202dc071f1 100644 --- a/src/types.rs +++ b/src/types.rs @@ -46,9 +46,10 @@ use crate::config::{AnchorChannelsConfig, ChannelConfig}; use crate::data_store::DataStore; use crate::fee_estimator::OnchainFeeEstimator; use crate::ffi::maybe_wrap; +use crate::liquidity::client::lsps2::router::LSPS2Router; use crate::logger::Logger; use crate::message_handler::NodeCustomMessageHandler; -use crate::payment::{PaymentDetails, PendingPaymentDetails}; +use crate::payment::{NodeOffersMessageHandler, PaymentDetails, PendingPaymentDetails}; use crate::runtime::RuntimeSpawner; #[cfg(not(feature = "uniffi"))] @@ -273,7 +274,8 @@ pub(crate) type Broadcaster = crate::tx_broadcaster::TransactionBroadcaster>; +pub(crate) type BaseRouter = DefaultRouter< Arc, Arc, Arc, @@ -306,7 +308,7 @@ pub(crate) type OnionMessenger = lightning::onion_message::messenger::OnionMesse Arc, Arc, Arc, - Arc, + Arc, Arc, Arc, IgnoringMessageHandler, diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index e401c82189..0184ba3e50 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -2332,12 +2332,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); @@ -2399,12 +2401,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); @@ -3003,7 +3007,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. @@ -3064,7 +3068,6 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, manual_payment_hash, ) .unwrap(); @@ -3117,7 +3120,6 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { jit_amount_msat, &invoice_description, 1024, - None, manual_payment_hash, ) .unwrap(); @@ -3145,6 +3147,252 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { assert_eq!(client_node.payment(&payment_id).unwrap().status, PaymentStatus::Failed); } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn bolt12_lsps2_client_service_integration() { + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + + let mut sync_config = EsploraSyncConfig::default(); + sync_config.background_sync_config = None; + + let channel_opening_fee_ppm = 10_000; + let lsps2_service_config = LSPS2ServiceConfig { + require_token: None, + advertise_service: false, + channel_opening_fee_ppm, + channel_over_provisioning_ppm: 100_000, + max_payment_size_msat: 1_000_000_000, + min_payment_size_msat: 0, + min_channel_lifetime: 100, + min_channel_opening_fee_msat: 0, + max_client_to_self_delay: 1024, + client_trusts_lsp: true, + disable_client_reserve: false, + }; + + let service_config = random_config(); + setup_builder!(service_builder, service_config.node_config); + service_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + service_builder.enable_liquidity_provider(lsps2_service_config); + let service_node = service_builder.build(service_config.node_entropy.into()).unwrap(); + service_node.start().unwrap(); + + let service_node_id = service_node.node_id(); + let service_addr = service_node.listening_addresses().unwrap().first().unwrap().clone(); + + let client_config = random_config(); + 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.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(); + setup_builder!(payer_builder, payer_config.node_config); + payer_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + let payer_node = payer_builder.build(payer_config.node_entropy.into()).unwrap(); + payer_node.start().unwrap(); + + let client_addr = client_node.listening_addresses().unwrap().first().unwrap().clone(); + payer_node.connect(client_node.node_id(), client_addr, false).unwrap(); + + let service_onchain_addr = service_node.onchain_payment().new_address().unwrap(); + let client_onchain_addr = client_node.onchain_payment().new_address().unwrap(); + let payer_onchain_addr = payer_node.onchain_payment().new_address().unwrap(); + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![service_onchain_addr, client_onchain_addr, payer_onchain_addr], + Amount::from_sat(10_000_000), + ) + .await; + service_node.sync_wallets().unwrap(); + client_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + + open_channel(&payer_node, &service_node, 5_000_000, true, &electrsd).await; + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + service_node.sync_wallets().unwrap(); + payer_node.sync_wallets().unwrap(); + expect_channel_ready_event!(payer_node, service_node.node_id()); + expect_channel_ready_event!(service_node, payer_node.node_id()); + while payer_node.status().latest_node_announcement_broadcast_timestamp.is_none() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + let fixed_amount_msat = 100_000_000; + // The recipient has no channel yet, but the connected payer gives the default message router a + // peer through which it can construct an offer path. This keeps the long-lived offer addressed + // by a blinded node path; the single-use intercept SCID is introduced only in the invoice. + assert!(client_node.list_channels().is_empty()); + let fixed_offer = + client_node.bolt12_payment().receive(fixed_amount_msat, "fixed", None, None).unwrap(); + assert!(!fixed_offer.paths().is_empty()); + assert_ne!(fixed_offer.issuer_signing_pubkey(), Some(client_node.node_id())); + let fixed_payment_id = + payer_node.bolt12_payment().send(&fixed_offer, None, None, None).unwrap(); + + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_event!(service_node, PaymentForwarded); + expect_channel_pending_event!(client_node, service_node.node_id()); + expect_channel_ready_event!(client_node, service_node.node_id()); + expect_payment_successful_event!(payer_node, Some(fixed_payment_id), None); + let fixed_fee_msat = fixed_amount_msat * channel_opening_fee_ppm as u64 / 1_000_000; + let fixed_received_msat = fixed_amount_msat - fixed_fee_msat; + let fixed_receiver_payment_id = + expect_payment_received_event!(client_node, fixed_received_msat).unwrap(); + match client_node.payment(&fixed_receiver_payment_id).unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(fixed_fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } + + // The over-provisioned part of the first JIT channel can receive this payment in full. The + // offers flow must therefore keep the ordinary blinded path and avoid negotiating or exposing a + // second intercept SCID. + let client_channel_count = client_node.list_channels().len(); + let service_channel_count = service_node.list_channels().len(); + let ordinary_amount_msat = 5_000_000; + let ordinary_offer = client_node + .bolt12_payment() + .receive(ordinary_amount_msat, "existing inbound", None, None) + .unwrap(); + let ordinary_payment_id = + payer_node.bolt12_payment().send(&ordinary_offer, None, None, None).unwrap(); + + expect_event!(service_node, PaymentForwarded); + expect_payment_successful_event!(payer_node, Some(ordinary_payment_id), None); + let ordinary_receiver_payment_id = + expect_payment_received_event!(client_node, ordinary_amount_msat).unwrap(); + match client_node.payment(&ordinary_receiver_payment_id).unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, None); + }, + _ => panic!("Unexpected payment kind"), + } + assert_eq!(client_node.list_channels().len(), client_channel_count); + assert_eq!(service_node.list_channels().len(), service_channel_count); + + let persisted_lease_key = + tokio::time::timeout(std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), async { + loop { + let keys = KVStore::list(&client_store, "lsps2_leases", "").await.unwrap(); + if let Some(key) = keys.first() { + return key.clone(); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("replacement lease should be persisted"); + assert!(KVStore::read(&client_store, "lsps2_leases", "", &persisted_lease_key).await.is_ok()); + + let client_node_id = client_node.node_id(); + client_node.stop().unwrap(); + drop(client_node); + tokio::time::timeout(std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), async { + while payer_node.list_peers().iter().any(|peer| peer.node_id == client_node_id) + || service_node.list_peers().iter().any(|peer| peer.node_id == client_node_id) + { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("peers should observe the stopped client"); + + setup_builder!(restarted_client_builder, client_config.node_config); + restarted_client_builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + restarted_client_builder.add_liquidity_source( + service_node_id, + service_addr.clone(), + None, + true, + ); + let client_node = restarted_client_builder + .build_with_store(client_config.node_entropy.into(), client_store.clone()) + .unwrap(); + assert_eq!(client_node.node_id(), client_node_id); + assert!(KVStore::read(&client_store, "lsps2_leases", "", &persisted_lease_key).await.is_ok()); + client_node.start().unwrap(); + let client_addr = client_node.listening_addresses().unwrap().first().unwrap().clone(); + payer_node.connect(client_node.node_id(), client_addr, false).unwrap(); + + let restored_payment_id = + payer_node.bolt12_payment().send(&fixed_offer, None, None, None).unwrap(); + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_event!(service_node, PaymentForwarded); + loop { + let event = tokio::time::timeout( + std::time::Duration::from_secs(common::INTEROP_TIMEOUT_SECS), + client_node.next_event_async(), + ) + .await + .expect("restored client should receive a payment event"); + match event { + Event::ChannelPending { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, service_node.node_id()); + client_node.event_handled().unwrap(); + break; + }, + Event::PaymentReceived { payment_id, amount_msat, .. } => { + assert_eq!(payment_id, Some(ordinary_receiver_payment_id)); + assert_eq!(amount_msat, ordinary_amount_msat); + client_node.event_handled().unwrap(); + }, + unexpected => panic!("Unexpected event after restoring client: {unexpected:?}"), + } + } + expect_channel_ready_event!(client_node, service_node.node_id()); + expect_payment_successful_event!(payer_node, Some(restored_payment_id), None); + let restored_receiver_payment_id = + expect_payment_received_event!(client_node, fixed_received_msat).unwrap(); + match client_node.payment(&restored_receiver_payment_id).unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(fixed_fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } + assert!( + KVStore::read(&client_store, "lsps2_leases", "", &persisted_lease_key).await.is_err(), + "the restored lease must be consumed instead of renegotiating" + ); + + // The fixed JIT channels cannot carry this larger variable payment. The variable offer disables + // MPP, so the payer must use the fresh JIT path rather than splitting across the existing paths. + let variable_amount_msat = 200_000_000; + let variable_offer = + client_node.bolt12_payment().receive_variable_amount("variable", None).unwrap(); + let variable_payment_id = payer_node + .bolt12_payment() + .send_using_amount(&variable_offer, variable_amount_msat, None, None, None) + .unwrap(); + + expect_channel_pending_event!(service_node, client_node.node_id()); + expect_channel_ready_event!(service_node, client_node.node_id()); + expect_event!(service_node, PaymentForwarded); + expect_channel_pending_event!(client_node, service_node.node_id()); + expect_channel_ready_event!(client_node, service_node.node_id()); + expect_payment_successful_event!(payer_node, Some(variable_payment_id), None); + let variable_fee_msat = variable_amount_msat * channel_opening_fee_ppm as u64 / 1_000_000; + let variable_received_msat = variable_amount_msat - variable_fee_msat; + let variable_receiver_payment_id = + expect_payment_received_event!(client_node, variable_received_msat).unwrap(); + match client_node.payment(&variable_receiver_payment_id).unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(variable_fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn facade_logging() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -3327,7 +3575,6 @@ async fn lsps2_client_trusts_lsp() { jit_amount_msat, &invoice_description.into(), 1024, - None, manual_payment_hash, ) .unwrap(); @@ -3504,7 +3751,6 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { jit_amount_msat, &invoice_description.into(), 1024, - None, manual_payment_hash, ) .unwrap(); @@ -4317,22 +4563,30 @@ 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) { - let (_bitcoind, electrsd) = setup_bitcoind_and_electrsd(); +#[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()); 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, @@ -4375,7 +4629,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 { @@ -4388,12 +4643,53 @@ async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { 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 = 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))); + payer.stop().unwrap(); + 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(); @@ -4405,6 +4701,30 @@ async fn do_lsps2_multi_lsp_picks_cheapest(reverse_order: bool) { let route_hint_src = first_hop.expect("route hint should have at least one hop").src_node_id; assert_eq!(route_hint_src, cheap_id, "expected cheaper LSP to be selected."); + // Consuming the BOLT11 lease schedules a replacement. The BOLT12 flow shares that cache and + // must retain the same cheapest-LSP selection regardless of registration order. + let payment_amount_msat = 100_000_000; + let offer = + client.bolt12_payment().receive(payment_amount_msat, "multi LSP", None, None).unwrap(); + let payment_id = payer.bolt12_payment().send(&offer, None, None, None).unwrap(); + + expect_channel_pending_event!(cheap, client.node_id()); + expect_channel_ready_event!(cheap, client.node_id()); + expect_event!(cheap, PaymentForwarded); + expect_channel_pending_event!(client, cheap.node_id()); + expect_channel_ready_event!(client, cheap.node_id()); + expect_payment_successful_event!(payer, Some(payment_id), None); + let fee_msat = payment_amount_msat * cheap_opening_fee_ppm as u64 / 1_000_000; + let receiver_payment_id = + expect_payment_received_event!(client, payment_amount_msat - fee_msat).unwrap(); + match client.payment(&receiver_payment_id).unwrap().kind { + PaymentKind::Bolt12Offer { counterparty_skimmed_fee_msat, .. } => { + assert_eq!(counterparty_skimmed_fee_msat, Some(fee_msat)); + }, + _ => panic!("Unexpected payment kind"), + } + + payer.stop().unwrap(); client.stop().unwrap(); cheap.stop().unwrap(); expensive.stop().unwrap();