diff --git a/libs/gl-plugin/src/context.rs b/libs/gl-plugin/src/context.rs index 48692fc56..fc1cfec8d 100644 --- a/libs/gl-plugin/src/context.rs +++ b/libs/gl-plugin/src/context.rs @@ -10,9 +10,9 @@ //! sign off actually match the authentic commands by a valid //! caller. +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::Mutex; -use serde::{Serialize, Deserialize}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Request { diff --git a/libs/gl-plugin/src/messages.rs b/libs/gl-plugin/src/messages.rs index f5bec0843..571744964 100644 --- a/libs/gl-plugin/src/messages.rs +++ b/libs/gl-plugin/src/messages.rs @@ -280,7 +280,7 @@ where /// `peer_connected` hook. #[derive(Serialize, Deserialize, Debug)] pub struct PeerConnectedCall { - pub peer: Peer + pub peer: Peer, } #[derive(Serialize, Deserialize, Debug)] @@ -295,10 +295,9 @@ pub struct Peer { #[serde(rename_all = "snake_case")] pub enum Direction { In, - Out + Out, } - #[cfg(test)] mod test { use super::*; @@ -315,7 +314,10 @@ mod test { }); let call = serde_json::from_str::(&msg.to_string()).unwrap(); - assert_eq!(call.peer.id, "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f"); + assert_eq!( + call.peer.id, + "03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f" + ); assert_eq!(call.peer.direction, Direction::In); assert_eq!(call.peer.addr, "34.239.230.56:9735"); assert_eq!(call.peer.features, ""); diff --git a/libs/gl-plugin/src/node/mod.rs b/libs/gl-plugin/src/node/mod.rs index b6e68c465..56ca42194 100644 --- a/libs/gl-plugin/src/node/mod.rs +++ b/libs/gl-plugin/src/node/mod.rs @@ -7,10 +7,8 @@ use anyhow::{Context, Error, Result}; use base64::{engine::general_purpose, Engine as _}; use bytes::BufMut; use cln_rpc::Notification; +use gl_client::metrics::{savings_percent, signer_state_request_wire_bytes}; use gl_client::persist::{State, StateSketch}; -use gl_client::metrics::{ - signer_state_request_wire_bytes, savings_percent, -}; use governor::{ clock::MonotonicClock, state::direct::NotKeyed, state::InMemoryState, Quota, RateLimiter, }; @@ -230,14 +228,12 @@ impl Node for PluginNodeServer { // We require capacity + 5% buffer to account for fees and routing. // Only check for specific amounts (not "any" amount invoices). if req.amount_msat > 0 { - let receivable = self - .get_receivable_capacity(&mut rpc) - .await - .unwrap_or(0); + let receivable = self.get_receivable_capacity(&mut rpc).await.unwrap_or(0); // Add 5% buffer: capacity >= amount * 1.05 // Equivalent to: capacity * 100 >= amount * 105 - let has_sufficient_capacity = req.amount_msat + let has_sufficient_capacity = req + .amount_msat .saturating_mul(105) .checked_div(100) .map(|required| receivable >= required) @@ -274,7 +270,10 @@ impl Node for PluginNodeServer { bolt11: res.bolt11, created_index: res.created_index.unwrap_or(0) as u32, expires_at: res.expires_at as u32, - payment_hash: >::borrow(&res.payment_hash).to_vec(), + payment_hash: >::borrow( + &res.payment_hash, + ) + .to_vec(), payment_secret: res.payment_secret.to_vec(), opening_fee_msat: 0, })); @@ -300,35 +299,42 @@ impl Node for PluginNodeServer { Status::not_found("Could not retrieve LSPS peers for invoice negotiation.") })?; - if lsps.len() < 1 { + if lsps.is_empty() { return Err(Status::not_found( "Could not find an LSP peer to negotiate the LSPS2 channel for this invoice.", )); } - let lsp = &lsps[0]; - log::info!("Selecting {:?} for invoice negotiation", lsp); + let (lsp_id, param) = select_opening_params(lsps).ok_or_else(|| { + Status::not_found("No opening params returned by any LSP, cannot create invoice.") + })?; + + log::info!( + "Selecting LSP {} with params {:?} for invoice negotiation", + lsp_id, + param + ); // Compute the expected opening fee from the LSP's fee parameters. - let opening_fee_msat = lsp.params.first().map_or(0, |p| { - let min_fee: u64 = p.min_fee_msat.parse().unwrap_or(0); + let opening_fee_msat = { + let min_fee: u64 = param.min_fee_msat.parse().unwrap_or(0); let proportional_fee = req .amount_msat - .saturating_mul(p.proportional) + .saturating_mul(param.proportional) .div_ceil(1_000_000); std::cmp::max(min_fee, proportional_fee) - }); + }; // Use the new RPC method name for versions > v25.05gl1 let mut res = if *version > *"v25.05gl1" { let mut invreq: crate::requests::LspInvoiceRequestV2 = req.into(); - invreq.lsp_id = lsp.node_id.to_owned(); + invreq.lsp_id = lsp_id.to_owned(); rpc.call_typed(&invreq) .await .map_err(|e| Status::new(Code::Internal, e.to_string()))? } else { let mut invreq: crate::requests::LspInvoiceRequest = req.into(); - invreq.lsp_id = lsp.node_id.to_owned(); + invreq.lsp_id = lsp_id.to_owned(); rpc.call_typed(&invreq) .await .map_err(|e| Status::new(Code::Internal, e.to_string()))? @@ -446,9 +452,8 @@ impl Node for PluginNodeServer { // the large state with them. let state_snapshot = signer_state.lock().await.clone(); - let state_entries: Vec = state_snapshot - .omit_tombstones() - .into(); + let state_entries: Vec = + state_snapshot.omit_tombstones().into(); let state_wire_bytes = signer_state_request_wire_bytes(&state_entries); let state_entries: Vec = state_entries .into_iter() @@ -501,7 +506,6 @@ impl Node for PluginNodeServer { hsm_id ); - let state_snapshot = signer_state.lock().await.clone(); // Estimate the size of the full state to calculate the bandwidth savings of sending diffs let full_entries: Vec = @@ -754,10 +758,7 @@ impl Node for PluginNodeServer { if let Err(e) = address.require_network(network) { return Err(Status::new( Code::Unknown, - format!( - "Network validation failed: {}", - e - ), + format!("Network validation failed: {}", e), )); } } @@ -843,6 +844,22 @@ struct Lsps2Offer { params: Vec, } +/// Select the LSP and opening fee params to use for an LSPS2 invoice +/// negotiation. +/// +/// We flatten the params across all LSPs and pick the first available +/// pair. This way an LSP that got selected but returned an empty param +/// set is skipped in favor of the next LSP that actually returned +/// usable params, rather than ending up without valid opening params. +/// Returns `None` if no LSP returned any params at all. +fn select_opening_params( + lsps: Vec, +) -> Option<(String, crate::responses::OpeningFeeParams)> { + lsps.into_iter() + .flat_map(|l| l.params.into_iter().map(move |p| (l.node_id.clone(), p))) + .next() +} + impl PluginNodeServer { pub async fn run(self) -> Result<()> { let addr = self.grpc_binding.parse().unwrap(); @@ -1216,3 +1233,85 @@ where mod rpcwait; pub use rpcwait::RpcWaitService; + +#[cfg(test)] +mod test { + use super::*; + use crate::responses::OpeningFeeParams; + + fn param(min_fee_msat: &str) -> OpeningFeeParams { + OpeningFeeParams { + min_fee_msat: min_fee_msat.to_string(), + proportional: 0, + valid_until: "2100-01-01T00:00:00Z".to_string(), + min_lifetime: 144, + max_client_to_self_delay: 1024, + min_payment_size_msat: "0".to_string(), + max_payment_size_msat: "1000000000".to_string(), + promise: "promise".to_string(), + } + } + + #[test] + fn test_select_opening_params_empty() { + // No LSPs at all -> nothing to select. + assert!(select_opening_params(vec![]).is_none()); + } + + #[test] + fn test_select_opening_params_first_lsp_empty() { + // The first LSP got selected but returned an empty param set. + // We must skip it and fall back to the next LSP that actually + // returned usable params. + let lsps = vec![ + Lsps2Offer { + node_id: "lsp_empty".to_string(), + params: vec![], + }, + Lsps2Offer { + node_id: "lsp_good".to_string(), + params: vec![param("100")], + }, + ]; + + let (lsp_id, p) = select_opening_params(lsps).expect("should fall back to second LSP"); + assert_eq!(lsp_id, "lsp_good"); + assert_eq!(p.min_fee_msat, "100"); + } + + #[test] + fn test_select_opening_params_all_empty() { + // Every LSP returned an empty param set -> nothing valid to pick. + let lsps = vec![ + Lsps2Offer { + node_id: "lsp_empty_1".to_string(), + params: vec![], + }, + Lsps2Offer { + node_id: "lsp_empty_2".to_string(), + params: vec![], + }, + ]; + + assert!(select_opening_params(lsps).is_none()); + } + + #[test] + fn test_select_opening_params_prefers_first_nonempty() { + // When the first LSP does return params we keep using it. + let lsps = vec![ + Lsps2Offer { + node_id: "lsp_first".to_string(), + params: vec![param("1"), param("2")], + }, + Lsps2Offer { + node_id: "lsp_second".to_string(), + params: vec![param("3")], + }, + ]; + + let (lsp_id, p) = select_opening_params(lsps).expect("first LSP has params"); + assert_eq!(lsp_id, "lsp_first"); + assert_eq!(p.min_fee_msat, "1"); + } +} diff --git a/libs/gl-plugin/src/responses.rs b/libs/gl-plugin/src/responses.rs index 556f89e6f..913ac44ed 100644 --- a/libs/gl-plugin/src/responses.rs +++ b/libs/gl-plugin/src/responses.rs @@ -349,8 +349,7 @@ pub struct InvoiceResponse { #[derive(Debug, Clone, Deserialize)] pub struct LspGetinfoResponse { -pub opening_fee_params_menu: Vec, - + pub opening_fee_params_menu: Vec, } #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] // LSPS2 requires the client to fail if a field is unrecognized. @@ -360,8 +359,8 @@ pub struct OpeningFeeParams { pub valid_until: String, pub min_lifetime: u32, pub max_client_to_self_delay: u32, - pub min_payment_size_msat: String , - pub max_payment_size_msat: String , + pub min_payment_size_msat: String, + pub max_payment_size_msat: String, pub promise: String, // Max 512 bytes } diff --git a/libs/gl-plugin/src/stager.rs b/libs/gl-plugin/src/stager.rs index 8c21a87ef..05ecbc697 100644 --- a/libs/gl-plugin/src/stager.rs +++ b/libs/gl-plugin/src/stager.rs @@ -124,7 +124,11 @@ impl Stage { .filter_map(|r| { let head: [u16; 2] = [r.request.raw[0].into(), r.request.raw[1].into()]; let typ = head[0] << 8 | head[1]; - if sticky_types.contains(&typ) { Some(typ) } else { None } + if sticky_types.contains(&typ) { + Some(typ) + } else { + None + } }) .collect();