diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index b861ca544..99bae3473 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -1,17 +1,21 @@ use anyhow::{anyhow, Context}; use cln_lsps::jsonrpc::client::JsonRpcClient; +use cln_lsps::lsps0::primitives::Msat; use cln_lsps::lsps0::{ self, transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager}, }; -use cln_lsps::lsps2::model::{Lsps2GetInfoRequest, Lsps2GetInfoResponse}; +use cln_lsps::lsps2::model::{ + compute_opening_fee, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest, + Lsps2GetInfoResponse, OpeningFeeParams, +}; use cln_lsps::util; use cln_lsps::LSP_FEATURE_BIT; use cln_plugin::options; use cln_rpc::model::requests::ListpeersRequest; -use cln_rpc::primitives::PublicKey; +use cln_rpc::primitives::{AmountOrAny, PublicKey}; use cln_rpc::ClnRpc; -use log::debug; +use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use std::path::Path; use std::str::FromStr as _; @@ -51,6 +55,11 @@ async fn main() -> Result<(), anyhow::Error> { "Low-level command to request the opening fee menu of an LSP", on_lsps_lsps2_getinfo, ) + .rpcmethod( + "lsps-lsps2-buy", + "Low-level command to return the lsps2.buy result from an ", + on_lsps_lsps2_buy, + ) .configure() .await? { @@ -108,6 +117,103 @@ async fn on_lsps_lsps2_getinfo( Ok(serde_json::to_value(info_res)?) } +/// Rpc Method handler for `lsps-lsps2-buy`. +async fn on_lsps_lsps2_buy( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let req: ClnRpcLsps2BuyRequest = + serde_json::from_value(v).context("Failed to parse request JSON")?; + debug!( + "Asking for a channel from lsp {} with opening fee params {:?} and payment size {:?}", + req.lsp_id, req.opening_fee_params, req.payment_size_msat + ); + + let dir = p.configuration().lightning_dir; + let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + // Fail early: Check that we are connected to the peer and that it has the + // LSP feature bit set. + ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?; + + // Create Transport and Client + let transport = Bolt8Transport::new( + &req.lsp_id, + rpc_path.clone(), // Clone path for potential reuse + p.state().hook_manager.clone(), + None, // Use default timeout + ) + .context("Failed to create Bolt8Transport")?; + let client = JsonRpcClient::new(transport); + + // Convert from AmountOrAny to Msat. + let payment_size_msat = if let Some(payment_size) = req.payment_size_msat { + match payment_size { + AmountOrAny::Amount(amount) => Some(Msat::from_msat(amount.msat())), + AmountOrAny::Any => None, + } + } else { + None + }; + + let selected_params = req.opening_fee_params; + + if let Some(payment_size) = payment_size_msat { + if payment_size < selected_params.min_payment_size_msat { + return Err(anyhow!( + "Requested payment size {}msat is below minimum {}msat required by LSP", + payment_size, + selected_params.min_payment_size_msat + )); + } + if payment_size > selected_params.max_payment_size_msat { + return Err(anyhow!( + "Requested payment size {}msat is above maximum {}msat allowed by LSP", + payment_size, + selected_params.max_payment_size_msat + )); + } + + let opening_fee = compute_opening_fee( + payment_size.msat(), + selected_params.min_fee_msat.msat(), + selected_params.proportional.ppm() as u64, + ) + .ok_or_else(|| { + warn!( + "Opening fee calculation overflowed for payment size {}", + payment_size + ); + anyhow!("failed to calculate opening fee") + })?; + + info!( + "Calculated opening fee: {}msat for payment size {}msat", + opening_fee, payment_size + ); + } else { + info!("No payment size specified, requesting JIT channel for a variable-amount invoice."); + // Check if the selected params allow for variable amount (implicitly they do if max > min) + if selected_params.min_payment_size_msat >= selected_params.max_payment_size_msat { + // This shouldn't happen if LSP follows spec, but good to check. + warn!("Selected fee params seem unsuitable for variable amount: min >= max"); + } + } + + debug!("Calling lsps2.buy for peer {}", req.lsp_id); + let buy_req = Lsps2BuyRequest { + opening_fee_params: selected_params, // Pass the chosen params back + payment_size_msat, + }; + let buy_res: Lsps2BuyResponse = client + .call_typed(buy_req) + .await + .context("lsps2.buy call failed")?; + + Ok(serde_json::to_value(buy_res)?) +} + async fn on_lsps_listprotocols( p: cln_plugin::Plugin, v: serde_json::Value, @@ -188,6 +294,14 @@ async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<( Ok(()) } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClnRpcLsps2BuyRequest { + lsp_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + payment_size_msat: Option, + opening_fee_params: OpeningFeeParams, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct ClnRpcLsps2GetinfoRequest { lsp_id: String, diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs index 2f9162ad8..a28587951 100644 --- a/plugins/lsps-plugin/src/lsps2/handler.rs +++ b/plugins/lsps-plugin/src/lsps2/handler.rs @@ -1,14 +1,27 @@ use crate::{ jsonrpc::{server::RequestHandler, JsonRpcResponse as _, RequestObject, RpcError}, - lsps2::model::{ - Lsps2GetInfoRequest, Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest, - Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise, + lsps0::primitives::ShortChannelId, + lsps2::{ + model::{ + DatastoreEntry, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest, + Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest, Lsps2PolicyGetInfoResponse, + OpeningFeeParams, Promise, + }, + DS_MAIN_KEY, DS_SUB_KEY, }, util::unwrap_payload_with_peer_id, }; use anyhow::{Context, Result as AnyResult}; use async_trait::async_trait; -use cln_rpc::ClnRpc; +use cln_rpc::{ + model::{ + requests::{DatastoreMode, DatastoreRequest, GetinfoRequest}, + responses::{DatastoreResponse, GetinfoResponse}, + }, + ClnRpc, +}; +use log::warn; +use rand::{rng, Rng as _}; use std::path::PathBuf; #[async_trait] @@ -17,8 +30,14 @@ pub trait ClnApi: Send + Sync { &self, params: &Lsps2PolicyGetInfoRequest, ) -> AnyResult; + + async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult; + + async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult; } +const DEFAULT_CLTV_EXPIRY_DELTA: u32 = 144; + #[derive(Clone)] pub struct ClnApiRpc { rpc_path: PathBuf, @@ -46,6 +65,22 @@ impl ClnApi for ClnApiRpc { .map_err(anyhow::Error::new) .with_context(|| "calling dev-lsps2-getpolicy") } + + async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_typed(params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling getinfo") + } + + async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_typed(params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling datastore") + } } /// Handler for the `lsps2.get_info` method. @@ -124,6 +159,106 @@ impl RequestHandler for Lsps2GetInfoHandler { } } +pub struct Lsps2BuyHandler { + pub api: A, + pub promise_secret: [u8; 32], +} + +impl Lsps2BuyHandler { + pub fn new(api: A, promise_secret: [u8; 32]) -> Self { + Self { + api, + promise_secret, + } + } +} + +#[async_trait] +impl RequestHandler for Lsps2BuyHandler { + async fn handle(&self, payload: &[u8]) -> core::result::Result, RpcError> { + let (payload, peer_id) = unwrap_payload_with_peer_id(payload); + + let req: RequestObject = serde_json::from_slice(&payload) + .map_err(|e| RpcError::parse_error(format!("Failed to parse request: {}", e)))?; + + if req.id.is_none() { + // Is a notification we can not reply so we just return + return Ok(vec![]); + } + + let req_params = req + .params + .ok_or_else(|| RpcError::invalid_request("Missing params field"))?; + + let fee_params = req_params.opening_fee_params; + + // FIXME: In the future we should replace the \`None\` with a meaningful + // value that reflects the inbound capacity for this node from the + // public network for a better pre-condition check on the payment_size. + fee_params.validate(&self.promise_secret, req_params.payment_size_msat, None)?; + + // Generate a tmp scid to identify jit channel request in htlc. + let get_info_req = GetinfoRequest {}; + let info = self.api.cln_getinfo(&get_info_req).await.map_err(|e| { + warn!("Failed to call getinfo via rpc {}", e); + RpcError::internal_error("Internal error") + })?; + + // FIXME: Future task: Check that we don't conflict with any jit scid we + // already handed out -> Check datastore entries. + let jit_scid_u64 = generate_jit_scid(info.blockheight); + let jit_scid = ShortChannelId::from(jit_scid_u64); + let ds_data = DatastoreEntry { + peer_id, + opening_fee_params: fee_params, + expected_payment_size: req_params.payment_size_msat, + }; + let ds_json = serde_json::to_string(&ds_data).map_err(|e| { + warn!("Failed to serialize opening fee params to string {}", e); + RpcError::internal_error("Internal error") + })?; + + let ds_req = DatastoreRequest { + generation: None, + hex: None, + mode: Some(DatastoreMode::MUST_CREATE), + string: Some(ds_json), + key: vec![ + DS_MAIN_KEY.to_string(), + DS_SUB_KEY.to_string(), + jit_scid.to_string(), + ], + }; + + let _ds_res = self.api.cln_datastore(&ds_req).await.map_err(|e| { + warn!("Failed to store jit request in ds via rpc {}", e); + RpcError::internal_error("Internal error") + })?; + + let res = Lsps2BuyResponse { + jit_channel_scid: jit_scid, + // We can make this configurable if necessary. + lsp_cltv_expiry_delta: DEFAULT_CLTV_EXPIRY_DELTA, + // We can implement the other mode later on as we might have to do + // some additional work on core-lightning to enable this. + client_trusts_lsp: false, + } + .into_response(req.id.unwrap()); // We checked that we got an id before. + + serde_json::to_vec(&res) + .map_err(|e| RpcError::internal_error(format!("Failed to serialize response: {}", e))) + } +} + +fn generate_jit_scid(best_blockheigt: u32) -> u64 { + let mut rng = rng(); + let block = best_blockheigt + 6; // Approx 1 hour in the future and should avoid collision with confirmed channels + let tx_idx: u32 = rng.random_range(0..5000); + let output_idx: u16 = rng.random_range(0..10); + + ((block as u64) << 40) | ((tx_idx as u64) << 16) | (output_idx as u64) +} + #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; @@ -136,8 +271,9 @@ mod tests { util::wrap_payload_with_peer_id, }; use chrono::{TimeZone, Utc}; - use cln_rpc::primitives::PublicKey; + use cln_rpc::primitives::{Amount, PublicKey}; use cln_rpc::RpcError as ClnRpcError; + use serde::Serialize; const PUBKEY: [u8; 33] = [ 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, @@ -149,15 +285,45 @@ mod tests { PublicKey::from_slice(&PUBKEY).expect("Valid pubkey") } - fn create_wrapped_request(request: &RequestObject) -> Vec { + fn create_wrapped_request(request: &RequestObject) -> Vec { let payload = serde_json::to_vec(request).expect("Failed to serialize request"); wrap_payload_with_peer_id(&payload, create_peer_id()) } + /// Build a pair: policy params + buy params with a Promise derived from `secret` + fn params_with_promise(secret: &[u8; 32]) -> (PolicyOpeningFeeParams, OpeningFeeParams) { + let policy = PolicyOpeningFeeParams { + min_fee_msat: Msat(2_000), + proportional: Ppm(10_000), + valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(), + min_lifetime: 1000, + max_client_to_self_delay: 42, + min_payment_size_msat: Msat(1_000_000), + max_payment_size_msat: Msat(100_000_000), + }; + let hex = policy.get_hmac_hex(secret); + let promise: Promise = hex.try_into().expect("hex->Promise"); + let buy = OpeningFeeParams { + min_fee_msat: policy.min_fee_msat, + proportional: policy.proportional, + valid_until: policy.valid_until, + min_lifetime: policy.min_lifetime, + max_client_to_self_delay: policy.max_client_to_self_delay, + min_payment_size_msat: policy.min_payment_size_msat, + max_payment_size_msat: policy.max_payment_size_msat, + promise, + }; + (policy, buy) + } + #[derive(Clone, Default)] struct FakeCln { lsps2_getpolicy_response: Arc>>, lsps2_getpolicy_error: Arc>>, + cln_getinfo_response: Arc>>, + cln_getinfo_error: Arc>>, + cln_datastore_response: Arc>>, + cln_datastore_error: Arc>>, } #[async_trait] @@ -174,6 +340,54 @@ mod tests { }; panic!("No lsps2 response defined"); } + + async fn cln_getinfo( + &self, + _params: &GetinfoRequest, + ) -> Result { + if let Some(err) = self.cln_getinfo_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + }; + if let Some(res) = self.cln_getinfo_response.lock().unwrap().take() { + return Ok(res); + }; + panic!("No cln getinfo response defined"); + } + + async fn cln_datastore( + &self, + _params: &DatastoreRequest, + ) -> Result { + if let Some(err) = self.cln_datastore_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + }; + if let Some(res) = self.cln_datastore_response.lock().unwrap().take() { + return Ok(res); + }; + panic!("No cln datastore response defined"); + } + } + + fn minimal_getinfo(height: u32) -> GetinfoResponse { + GetinfoResponse { + lightning_dir: String::default(), + alias: None, + our_features: None, + warning_bitcoind_sync: None, + warning_lightningd_sync: None, + address: None, + binding: None, + blockheight: height, + color: String::default(), + fees_collected_msat: Amount::from_msat(0), + id: PublicKey::from_slice(&PUBKEY).expect("pubkey from slice"), + network: String::default(), + num_active_channels: u32::default(), + num_inactive_channels: u32::default(), + num_peers: u32::default(), + num_pending_channels: u32::default(), + version: String::default(), + } } #[tokio::test] @@ -236,4 +450,181 @@ mod tests { assert_eq!(error.code, 200); assert!(error.message.contains("failed to fetch policy")); } + + #[tokio::test] + async fn buy_ok_fixed_amount() { + let secret = [0u8; 32]; + let fake = FakeCln::default(); + *fake.cln_getinfo_response.lock().unwrap() = Some(minimal_getinfo(900_000)); + *fake.cln_datastore_response.lock().unwrap() = Some(DatastoreResponse { + generation: Some(0), + hex: None, + string: None, + key: vec![], + }); + + let handler = Lsps2BuyHandler::new(fake, secret); + let (_policy, buy) = params_with_promise(&secret); + + // Set payment_size_msat => "MPP+fixed-invoice" mode. + let req = Lsps2BuyRequest { + opening_fee_params: buy, + payment_size_msat: Some(Msat(2_000_000)), + } + .into_request(Some("ok-fixed".into())); + let payload = create_wrapped_request(&req); + + let out = handler.handle(&payload).await.unwrap(); + let resp: ResponseObject = serde_json::from_slice(&out).unwrap(); + let resp = resp.into_inner().unwrap(); + + assert_eq!(resp.lsp_cltv_expiry_delta, DEFAULT_CLTV_EXPIRY_DELTA); + assert!(!resp.client_trusts_lsp); + assert!(resp.jit_channel_scid.to_u64() > 0); + } + + #[tokio::test] + async fn buy_ok_variable_amount_no_payment_size() { + let secret = [2u8; 32]; + let fake = FakeCln::default(); + *fake.cln_getinfo_response.lock().unwrap() = Some(minimal_getinfo(900_100)); + *fake.cln_datastore_response.lock().unwrap() = Some(DatastoreResponse { + generation: Some(0), + hex: None, + string: None, + key: vec![], + }); + + let handler = Lsps2BuyHandler::new(fake, secret); + let (_policy, buy) = params_with_promise(&secret); + + // No payment_size_msat => "no-MPP+var-invoice" mode. + let req = Lsps2BuyRequest { + opening_fee_params: buy, + payment_size_msat: None, + } + .into_request(Some("ok-var".into())); + let payload = create_wrapped_request(&req); + + let out = handler.handle(&payload).await.unwrap(); + let resp: ResponseObject = serde_json::from_slice(&out).unwrap(); + assert!(resp.into_inner().is_ok()); + } + + #[tokio::test] + async fn buy_rejects_invalid_promise_or_past_valid_until_with_201() { + let secret = [3u8; 32]; + let handler = Lsps2BuyHandler::new(FakeCln::default(), secret); + + // Case A: wrong promise (derive with different secret) + let (_policy_wrong, mut buy_wrong) = params_with_promise(&[9u8; 32]); + buy_wrong.valid_until = Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(); // future, so only promise is wrong + let req_wrong = Lsps2BuyRequest { + opening_fee_params: buy_wrong, + payment_size_msat: Some(Msat(2_000_000)), + } + .into_request(Some("bad-promise".into())); + let err1 = handler + .handle(&create_wrapped_request(&req_wrong)) + .await + .unwrap_err(); + assert_eq!(err1.code, 201); + + // Case B: past valid_until + let (_policy, mut buy_past) = params_with_promise(&secret); + buy_past.valid_until = Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(); // past + let req_past = Lsps2BuyRequest { + opening_fee_params: buy_past, + payment_size_msat: Some(Msat(2_000_000)), + } + .into_request(Some("past-valid".into())); + let err2 = handler + .handle(&create_wrapped_request(&req_past)) + .await + .unwrap_err(); + assert_eq!(err2.code, 201); + } + + #[tokio::test] + async fn buy_rejects_when_opening_fee_ge_payment_size_with_202() { + let secret = [4u8; 32]; + let handler = Lsps2BuyHandler::new(FakeCln::default(), secret); + + // Make min_fee already >= payment_size to trigger 202 + let policy = PolicyOpeningFeeParams { + min_fee_msat: Msat(10_000), + proportional: Ppm(0), // no extra percentage + valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(), + min_lifetime: 1000, + max_client_to_self_delay: 42, + min_payment_size_msat: Msat(1), + max_payment_size_msat: Msat(u64::MAX / 2), + }; + let hex = policy.get_hmac_hex(&secret); + let promise: Promise = hex.try_into().unwrap(); + let buy = OpeningFeeParams { + min_fee_msat: policy.min_fee_msat, + proportional: policy.proportional, + valid_until: policy.valid_until, + min_lifetime: policy.min_lifetime, + max_client_to_self_delay: policy.max_client_to_self_delay, + min_payment_size_msat: policy.min_payment_size_msat, + max_payment_size_msat: policy.max_payment_size_msat, + promise, + }; + + let req = Lsps2BuyRequest { + opening_fee_params: buy, + payment_size_msat: Some(Msat(9_999)), // strictly less than min_fee => opening_fee >= payment_size + } + .into_request(Some("too-small".into())); + + let err = handler + .handle(&create_wrapped_request(&req)) + .await + .unwrap_err(); + assert_eq!(err.code, 202); + } + + #[tokio::test] + async fn buy_rejects_on_fee_overflow_with_203() { + let secret = [5u8; 32]; + let handler = Lsps2BuyHandler::new(FakeCln::default(), secret); + + // Choose values likely to overflow if multiplication isn't checked: + // opening_fee = min_fee + payment_size * proportional / 1_000_000 + let policy = PolicyOpeningFeeParams { + min_fee_msat: Msat(u64::MAX / 2), + proportional: Ppm(u32::MAX), // 4_294_967_295 ppm + valid_until: Utc.with_ymd_and_hms(2100, 1, 1, 0, 0, 0).unwrap(), + min_lifetime: 1000, + max_client_to_self_delay: 42, + min_payment_size_msat: Msat(1), + max_payment_size_msat: Msat(u64::MAX), + }; + let hex = policy.get_hmac_hex(&secret); + let promise: Promise = hex.try_into().unwrap(); + let buy = OpeningFeeParams { + min_fee_msat: policy.min_fee_msat, + proportional: policy.proportional, + valid_until: policy.valid_until, + min_lifetime: policy.min_lifetime, + max_client_to_self_delay: policy.max_client_to_self_delay, + min_payment_size_msat: policy.min_payment_size_msat, + max_payment_size_msat: policy.max_payment_size_msat, + promise, + }; + + let req = Lsps2BuyRequest { + opening_fee_params: buy, + payment_size_msat: Some(Msat(u64::MAX / 2)), + } + .into_request(Some("overflow".into())); + + let err = handler + .handle(&create_wrapped_request(&req)) + .await + .unwrap_err(); + assert_eq!(err.code, 203); + } } diff --git a/plugins/lsps-plugin/src/lsps2/mod.rs b/plugins/lsps-plugin/src/lsps2/mod.rs index b217f9871..7ed8e7446 100644 --- a/plugins/lsps-plugin/src/lsps2/mod.rs +++ b/plugins/lsps-plugin/src/lsps2/mod.rs @@ -13,3 +13,6 @@ pub const OPTION_PROMISE_SECRET: options::StringConfigOption = "dev-lsps2-promise-secret", "A 64-character hex string that is the secret for promises", ); + +pub const DS_MAIN_KEY: &'static str = "lsps"; +pub const DS_SUB_KEY: &'static str = "lsps2"; diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index 11905dbfd..ca620f9e5 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -6,7 +6,7 @@ use cln_lsps::jsonrpc::{server::JsonRpcServer, JsonRpcRequest}; use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler; use cln_lsps::lsps0::model::Lsps0listProtocolsRequest; use cln_lsps::lsps0::transport::{self, CustomMsg}; -use cln_lsps::lsps2::model::Lsps2GetInfoRequest; +use cln_lsps::lsps2::model::{Lsps2BuyRequest, Lsps2GetInfoRequest}; use cln_lsps::util::wrap_payload_with_peer_id; use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT}; use cln_plugin::options::ConfigOption; @@ -94,11 +94,15 @@ async fn main() -> Result<(), anyhow::Error> { }; let cln_api_rpc = lsps2::handler::ClnApiRpc::new(rpc_path); - let getinfo_handler = lsps2::handler::Lsps2GetInfoHandler::new(cln_api_rpc, secret); - lsps_builder = lsps_builder.with_handler( - Lsps2GetInfoRequest::METHOD.to_string(), - Arc::new(getinfo_handler), - ); + let getinfo_handler = + lsps2::handler::Lsps2GetInfoHandler::new(cln_api_rpc.clone(), secret); + let buy_handler = lsps2::handler::Lsps2BuyHandler::new(cln_api_rpc, secret); + lsps_builder = lsps_builder + .with_handler( + Lsps2GetInfoRequest::METHOD.to_string(), + Arc::new(getinfo_handler), + ) + .with_handler(Lsps2BuyRequest::METHOD.to_string(), Arc::new(buy_handler)); } } diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 53dcf386d..1d1a1886b 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -64,3 +64,27 @@ def test_lsps2_getinfo(node_factory): res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id']) assert res["opening_fee_params_menu"] + + +def test_lsps2_buy(node_factory): + # We need a policy service to fetch from. + plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py') + + l1, l2 = node_factory.get_nodes(2, opts=[ + {"dev-lsps-client-enabled": None}, + { + "dev-lsps-service-enabled": None, + "dev-lsps2-service-enabled": None, + "dev-lsps2-promise-secret": "0" * 64, + "plugin": plugin + } + ]) + + # We don't need a channel to query for lsps services + node_factory.join_nodes([l1, l2], fundchannel=False) + + res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id']) + params = res["opening_fee_params_menu"][0] + + res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], payment_size_msat=None, opening_fee_params=params) + assert res