diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 6aecd0772..abdc65bee 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -87,6 +87,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); @@ -174,6 +176,7 @@ enum PaymentStatus { dictionary LSPFeeLimits { u64? max_total_opening_fee_msat; + u64? max_proportional_opening_fee_ppm_msat; }; dictionary PaymentDetails { diff --git a/src/event.rs b/src/event.rs index b3a5b85d8..f53fd7f85 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,18 @@ where return; } - let max_total_opening_fee_msat = - info.lsp_fee_limits.and_then(|l| l.max_total_opening_fee_msat).unwrap_or(0); + let max_total_opening_fee_msat = info + .lsp_fee_limits + .and_then(|l| { + l.max_total_opening_fee_msat.or_else(|| { + l.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) + }) + }) + }) + .unwrap_or(0); + if counterparty_skimmed_fee_msat > max_total_opening_fee_msat { log_info!( self.logger, diff --git a/src/lib.rs b/src/lib.rs index 8842c84bb..2fb738173 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1614,12 +1614,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)?; @@ -1649,29 +1675,38 @@ 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_total_opening_fee) = tokio::task::block_in_place(move || { - runtime.block_on(async move { - if let Some(amount_msat) = amount_msat { - liquidity_source - .lsps2_receive_to_jit_channel( - amount_msat, - description, - expiry_secs, - max_total_lsp_fee_limit_msat, - ) - .await - .map(|(invoice, total_fee)| (invoice, Some(total_fee))) - } else { - // TODO: will be implemented in the next commit - Err(Error::LiquidityRequestFailed) - } - }) - })?; + let (invoice, lsp_total_opening_fee, lsp_prop_opening_fee) = + tokio::task::block_in_place(move || { + runtime.block_on(async move { + if let Some(amount_msat) = amount_msat { + liquidity_source + .lsps2_receive_to_jit_channel( + amount_msat, + description, + expiry_secs, + max_total_lsp_fee_limit_msat, + ) + .await + .map(|(invoice, total_fee)| (invoice, Some(total_fee), None)) + } else { + liquidity_source + .lsps2_receive_variable_amount_to_jit_channel( + description, + expiry_secs, + max_proportional_lsp_fee_limit_ppm_msat, + ) + .await + .map(|(invoice, prop_fee)| (invoice, None, Some(prop_fee))) + } + }) + })?; // Register payment in payment store. let payment_hash = PaymentHash(invoice.payment_hash().to_byte_array()); - let lsp_fee_limits = - Some(LSPFeeLimits { max_total_opening_fee_msat: lsp_total_opening_fee }); + let lsp_fee_limits = Some(LSPFeeLimits { + max_total_opening_fee_msat: lsp_total_opening_fee, + max_proportional_opening_fee_ppm_msat: lsp_prop_opening_fee, + }); let payment = PaymentDetails { hash: payment_hash, preimage: None, diff --git a/src/liquidity.rs b/src/liquidity.rs index 717cf4f33..965e25b77 100644 --- a/src/liquidity.rs +++ b/src/liquidity.rs @@ -249,6 +249,49 @@ where Ok((invoice, min_total_fee_msat)) } + pub(crate) async fn lsps2_receive_variable_amount_to_jit_channel( + &self, description: &str, expiry_secs: u32, + max_proportional_lsp_fee_limit_ppm_msat: Option, + ) -> Result<(Bolt11Invoice, u64), Error> { + let fee_response = self.lsps2_request_opening_fee_params().await?; + + let (min_prop_fee_ppm_msat, min_opening_params) = fee_response + .opening_fee_params_menu + .into_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 {}ppm msat in proportional LSP fees", + min_prop_fee_ppm_msat + ); + + let buy_response = self.lsps2_send_buy_request(None, min_opening_params).await?; + let invoice = + self.lsps2_create_jit_invoice(buy_response, None, description, expiry_secs)?; + + log_info!(self.logger, "JIT-channel invoice created: {}", invoice); + Ok((invoice, min_prop_fee_ppm_msat)) + } + async fn lsps2_request_opening_fee_params(&self) -> Result { let lsps2_service = self.lsps2_service.as_ref().ok_or(Error::LiquiditySourceUnavailable)?; diff --git a/src/payment_store.rs b/src/payment_store.rs index 12fa80d10..704966878 100644 --- a/src/payment_store.rs +++ b/src/payment_store.rs @@ -92,10 +92,14 @@ pub struct LSPFeeLimits { /// The maximal total amount we allow any configured LSP withhold from us when forwarding the /// payment. pub max_total_opening_fee_msat: Option, + /// The maximal proportional fee, in parts-per-million millisatoshi, we allow any configured + /// LSP withhold from us when forwarding the payment. + pub max_proportional_opening_fee_ppm_msat: Option, } impl_writeable_tlv_based!(LSPFeeLimits, { (0, max_total_opening_fee_msat, option), + (2, max_proportional_opening_fee_ppm_msat, option), }); #[derive(Clone, Debug, PartialEq, Eq)]