From ba1fd79e577e5ed0ea200627117319b6c1203b98 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 11 Jan 2024 12:02:13 +0100 Subject: [PATCH] Allow for variable amount payments --- bindings/ldk_node.udl | 3 ++ src/event.rs | 18 +++++++- src/lib.rs | 62 ++++++++++++++++++++------ src/liquidity.rs | 100 ++++++++++++++++++++++++++++-------------- src/payment_store.rs | 12 +++++ 5 files changed, 146 insertions(+), 49 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 8f39b7ea4..535df15ba 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -86,6 +86,8 @@ interface LDKNode { Bolt11Invoice receive_variable_amount_payment([ByRef]string description, u32 expiry_secs); [Throws=NodeError] Bolt11Invoice receive_payment_via_jit_channel(u64 amount_msat, [ByRef]string description, u32 expiry_secs, u64? max_lsp_fee_limit_msat); + [Throws=NodeError] + Bolt11Invoice receive_variable_amount_payment_via_jit_channel([ByRef]string description, u32 expiry_secs, u64? max_proportional_lsp_fee_limit_ppm_msat); PaymentDetails? payment([ByRef]PaymentHash payment_hash); [Throws=NodeError] void remove_payment([ByRef]PaymentHash payment_hash); @@ -186,6 +188,7 @@ dictionary PaymentDetails { PaymentDirection direction; PaymentStatus status; u64? max_total_lsp_fee_limit_msat; + u64? max_proportional_lsp_fee_limit_ppm_msat; }; dictionary OutPoint { diff --git a/src/event.rs b/src/event.rs index 5272c2ce4..1fee92e51 100644 --- a/src/event.rs +++ b/src/event.rs @@ -26,6 +26,7 @@ use lightning::util::ser::{Readable, ReadableArgs, Writeable, Writer}; use bitcoin::blockdata::locktime::absolute::LockTime; use bitcoin::secp256k1::PublicKey; use bitcoin::OutPoint; +use lightning_liquidity::lsps2::utils::compute_opening_fee; use rand::{thread_rng, Rng}; use std::collections::VecDeque; use std::ops::Deref; @@ -381,8 +382,20 @@ where return; } - let max_total_lsp_fee_limit_msat = - info.max_total_lsp_fee_limit_msat.unwrap_or(0); + let max_total_lsp_fee_limit_msat = if let Some(max_total_lsp_fee_limit_msat) = + info.max_total_lsp_fee_limit_msat + { + max_total_lsp_fee_limit_msat + } else if let Some(max_proportional_lsp_fee_limit_ppm_msat) = + info.max_proportional_lsp_fee_limit_ppm_msat + { + // If it's a variable amount payment, compute the actual total opening fee. + compute_opening_fee(amount_msat, 0, max_proportional_lsp_fee_limit_ppm_msat) + .unwrap_or(0) + } else { + 0 + }; + if counterparty_skimmed_fee_msat > max_total_lsp_fee_limit_msat { log_info!( self.logger, @@ -497,6 +510,7 @@ where direction: PaymentDirection::Inbound, status: PaymentStatus::Succeeded, max_total_lsp_fee_limit_msat: None, + max_proportional_lsp_fee_limit_ppm_msat: None, }; match self.payment_store.insert(payment) { diff --git a/src/lib.rs b/src/lib.rs index b3bf5cdcd..0c734e0df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1198,6 +1198,7 @@ impl Node { direction: PaymentDirection::Outbound, status: PaymentStatus::Pending, max_total_lsp_fee_limit_msat: None, + max_proportional_lsp_fee_limit_ppm_msat: None, }; self.payment_store.insert(payment)?; @@ -1218,6 +1219,7 @@ impl Node { direction: PaymentDirection::Outbound, status: PaymentStatus::Failed, max_total_lsp_fee_limit_msat: None, + max_proportional_lsp_fee_limit_ppm_msat: None, }; self.payment_store.insert(payment)?; @@ -1306,6 +1308,7 @@ impl Node { direction: PaymentDirection::Outbound, status: PaymentStatus::Pending, max_total_lsp_fee_limit_msat: None, + max_proportional_lsp_fee_limit_ppm_msat: None, }; self.payment_store.insert(payment)?; @@ -1327,6 +1330,7 @@ impl Node { direction: PaymentDirection::Outbound, status: PaymentStatus::Failed, max_total_lsp_fee_limit_msat: None, + max_proportional_lsp_fee_limit_ppm_msat: None, }; self.payment_store.insert(payment)?; @@ -1382,6 +1386,7 @@ impl Node { direction: PaymentDirection::Outbound, amount_msat: Some(amount_msat), max_total_lsp_fee_limit_msat: None, + max_proportional_lsp_fee_limit_ppm_msat: None, }; self.payment_store.insert(payment)?; @@ -1403,6 +1408,7 @@ impl Node { direction: PaymentDirection::Outbound, amount_msat: Some(amount_msat), max_total_lsp_fee_limit_msat: None, + max_proportional_lsp_fee_limit_ppm_msat: None, }; self.payment_store.insert(payment)?; @@ -1577,6 +1583,7 @@ impl Node { direction: PaymentDirection::Inbound, status: PaymentStatus::Pending, max_total_lsp_fee_limit_msat: None, + max_proportional_lsp_fee_limit_ppm_msat: None, }; self.payment_store.insert(payment)?; @@ -1603,12 +1610,38 @@ impl Node { description, expiry_secs, max_total_lsp_fee_limit_msat, + None, + ) + } + + /// 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. + /// + /// When the returned invoice is paid, the configured [LSPS2]-compliant LSP will open a channel + /// to us, supplying just-in-time inbound liquidity. + /// + /// 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. + /// + /// [LSPS2]: https://github.com/BitcoinAndLightningLayerSpecs/lsp/blob/main/LSPS2/README.md + pub fn receive_variable_amount_payment_via_jit_channel( + &self, description: &str, expiry_secs: u32, + max_proportional_lsp_fee_limit_ppm_msat: Option, + ) -> Result { + self.receive_payment_via_jit_channel_inner( + None, + description, + expiry_secs, + None, + max_proportional_lsp_fee_limit_ppm_msat, ) } fn receive_payment_via_jit_channel_inner( &self, amount_msat: Option, description: &str, expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, + max_proportional_lsp_fee_limit_ppm_msat: Option, ) -> Result { let liquidity_source = self.liquidity_source.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; @@ -1638,18 +1671,20 @@ impl Node { log_info!(self.logger, "Connected to LSP {}@{}. ", peer_info.node_id, peer_info.address); let liquidity_source = Arc::clone(&liquidity_source); - let (invoice, lsp_opening_fee) = tokio::task::block_in_place(move || { - runtime.block_on(async move { - liquidity_source - .lsps2_receive_to_jit_channel( - amount_msat, - description, - expiry_secs, - max_total_lsp_fee_limit_msat, - ) - .await - }) - })?; + let (invoice, lsp_total_opening_fee, lsp_prop_opening_fee) = + tokio::task::block_in_place(move || { + runtime.block_on(async move { + liquidity_source + .lsps2_receive_to_jit_channel( + amount_msat, + description, + expiry_secs, + max_total_lsp_fee_limit_msat, + max_proportional_lsp_fee_limit_ppm_msat, + ) + .await + }) + })?; // Register payment in payment store. let payment_hash = PaymentHash(invoice.payment_hash().to_byte_array()); @@ -1660,7 +1695,8 @@ impl Node { amount_msat, direction: PaymentDirection::Inbound, status: PaymentStatus::Pending, - max_total_lsp_fee_limit_msat: Some(lsp_opening_fee), + max_total_lsp_fee_limit_msat: lsp_total_opening_fee, + max_proportional_lsp_fee_limit_ppm_msat: lsp_prop_opening_fee, }; self.payment_store.insert(payment)?; diff --git a/src/liquidity.rs b/src/liquidity.rs index 5c4db4018..294afb7ae 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -191,54 +191,86 @@ where pub(crate) async fn lsps2_receive_to_jit_channel( &self, amount_msat: Option, description: &str, expiry_secs: u32, max_total_lsp_fee_limit_msat: Option, - ) -> Result<(Bolt11Invoice, u64), Error> { + max_proportional_lsp_fee_limit_ppm_msat: Option, + ) -> Result<(Bolt11Invoice, Option, Option), Error> { let lsps2_service = self.lsps2_service.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; let fee_response = self.request_opening_fee_params().await?; - if let Some(amount_msat) = amount_msat { + let (min_total_fee_msat, min_prop_fee_ppm_msat, min_opening_params) = if let Some( + amount_msat, + ) = amount_msat + { + // `MPP+fixed-invoice` mode if amount_msat < fee_response.min_payment_size_msat || amount_msat > fee_response.max_payment_size_msat { log_error!(self.logger, "Failed to request inbound JIT channel as the payment of {}msat doesn't meet LSP limits (min: {}msat, max: {}msat)", amount_msat, fee_response.min_payment_size_msat, fee_response.max_payment_size_msat); return Err(Error::LiquidityRequestFailed); } - } - // If it's variable amount, we pick the cheapest opening fee with a dummy value. - let fee_computation_amount = amount_msat.unwrap_or(1_000_000); - let (min_opening_fee_msat, min_opening_params) = fee_response - .opening_fee_params_menu - .iter() - .flat_map(|params| { - if let Some(fee) = compute_opening_fee( - fee_computation_amount, - params.min_fee_msat, - params.proportional as u64, - ) { - Some((fee, params)) - } else { - None + let (min_total_fee_msat, min_params) = fee_response + .opening_fee_params_menu + .iter() + .flat_map(|params| { + if let Some(fee) = compute_opening_fee( + amount_msat, + params.min_fee_msat, + params.proportional as u64, + ) { + Some((fee, params)) + } else { + None + } + }) + .min_by_key(|p| p.0) + .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); } - }) - .min_by_key(|p| p.0) - .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_opening_fee_msat > max_total_lsp_fee_limit_msat { - log_error!(self.logger, "Failed to request inbound JIT channel as LSP's requested opening fee of {}msat exceeds our fee limit of {}msat", min_opening_fee_msat, max_total_lsp_fee_limit_msat); - return Err(Error::LiquidityFeeTooHigh); + log_debug!( + self.logger, + "Choosing cheapest liquidity offer, will pay {}msat in total LSP fees", + min_total_fee_msat + ); + + (Some(min_total_fee_msat), None, min_params) + } else { + // `no-MPP+var-invoice` mode + let (min_prop_fee_ppm_msat, min_params) = fee_response + .opening_fee_params_menu + .iter() + .map(|params| (params.proportional as u64, params)) + .min_by_key(|p| p.0) + .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, will pay {}msat in LSP fees", - min_opening_fee_msat - ); + log_debug!( + self.logger, + "Choosing cheapest liquidity offer, will pay {}ppm msat in proportional LSP fees", + min_prop_fee_ppm_msat + ); + (None, Some(min_prop_fee_ppm_msat), min_params) + }; let buy_response = self.send_buy_request(amount_msat, min_opening_params.clone()).await?; @@ -291,7 +323,7 @@ where })?; log_info!(self.logger, "JIT-channel invoice created: {}", invoice); - Ok((invoice, min_opening_fee_msat)) + Ok((invoice, min_total_fee_msat, min_prop_fee_ppm_msat)) } async fn request_opening_fee_params(&self) -> Result { diff --git a/src/payment_store.rs b/src/payment_store.rs index 586430889..3f491f395 100644 --- a/src/payment_store.rs +++ b/src/payment_store.rs @@ -40,12 +40,23 @@ pub struct PaymentDetails { /// /// [`LdkChannelConfig::accept_underpaying_htlcs`]: lightning::util::config::ChannelConfig::accept_underpaying_htlcs pub max_total_lsp_fee_limit_msat: Option, + /// The maximal proportional fee, in parts-per-million millisatoshi, we allow any configured + /// LSP withhold from us when forwarding the payment. + /// + /// This is usually only `Some` for payments received via a JIT-channel, in which case the first + /// inbound payment will pay for the LSP's channel opening fees. + /// + /// See [`LdkChannelConfig::accept_underpaying_htlcs`] for more information. + /// + /// [`LdkChannelConfig::accept_underpaying_htlcs`]: lightning::util::config::ChannelConfig::accept_underpaying_htlcs + pub max_proportional_lsp_fee_limit_ppm_msat: Option, } impl_writeable_tlv_based!(PaymentDetails, { (0, hash, required), (1, max_total_lsp_fee_limit_msat, option), (2, preimage, required), + (3, max_proportional_lsp_fee_limit_ppm_msat, option), (4, secret, required), (6, amount_msat, required), (8, direction, required), @@ -265,6 +276,7 @@ mod tests { direction: PaymentDirection::Inbound, status: PaymentStatus::Pending, max_total_lsp_fee_limit_msat: None, + max_proportional_lsp_fee_limit_ppm_msat: None, }; assert_eq!(Ok(false), payment_store.insert(payment.clone()));