diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index 52f1da613..b861ca544 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -4,6 +4,7 @@ use cln_lsps::lsps0::{ self, transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager}, }; +use cln_lsps::lsps2::model::{Lsps2GetInfoRequest, Lsps2GetInfoResponse}; use cln_lsps::util; use cln_lsps::LSP_FEATURE_BIT; use cln_plugin::options; @@ -11,7 +12,7 @@ use cln_rpc::model::requests::ListpeersRequest; use cln_rpc::primitives::PublicKey; use cln_rpc::ClnRpc; use log::debug; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::path::Path; use std::str::FromStr as _; @@ -45,6 +46,11 @@ async fn main() -> Result<(), anyhow::Error> { "list protocols supported by lsp", on_lsps_listprotocols, ) + .rpcmethod( + "lsps-lsps2-getinfo", + "Low-level command to request the opening fee menu of an LSP", + on_lsps_lsps2_getinfo, + ) .configure() .await? { @@ -61,7 +67,47 @@ async fn main() -> Result<(), anyhow::Error> { } } -/// RPC Method handler for `lsps-listprotocols`. +/// Rpc Method handler for `lsps-lsps2-getinfo`. +async fn on_lsps_lsps2_getinfo( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let req: ClnRpcLsps2GetinfoRequest = + serde_json::from_value(v).context("Failed to parse request JSON")?; + debug!( + "Requesting opening fee menu from lsp {} with token {:?}", + req.lsp_id, req.token + ); + + 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); + + // 1. Call lsps2.get_info. + let info_req = Lsps2GetInfoRequest { token: req.token }; + let info_res: Lsps2GetInfoResponse = client + .call_typed(info_req) + .await + .context("lsps2.get_info call failed")?; + debug!("received lsps2.get_info response: {:?}", info_res); + + Ok(serde_json::to_value(info_res)?) +} + async fn on_lsps_listprotocols( p: cln_plugin::Plugin, v: serde_json::Value, @@ -141,3 +187,9 @@ async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<( Ok(()) } + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClnRpcLsps2GetinfoRequest { + lsp_id: String, + token: Option, +} diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs new file mode 100644 index 000000000..2f9162ad8 --- /dev/null +++ b/plugins/lsps-plugin/src/lsps2/handler.rs @@ -0,0 +1,239 @@ +use crate::{ + jsonrpc::{server::RequestHandler, JsonRpcResponse as _, RequestObject, RpcError}, + lsps2::model::{ + Lsps2GetInfoRequest, Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest, + Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise, + }, + util::unwrap_payload_with_peer_id, +}; +use anyhow::{Context, Result as AnyResult}; +use async_trait::async_trait; +use cln_rpc::ClnRpc; +use std::path::PathBuf; + +#[async_trait] +pub trait ClnApi: Send + Sync { + async fn lsps2_getpolicy( + &self, + params: &Lsps2PolicyGetInfoRequest, + ) -> AnyResult; +} + +#[derive(Clone)] +pub struct ClnApiRpc { + rpc_path: PathBuf, +} + +impl ClnApiRpc { + pub fn new(rpc_path: PathBuf) -> Self { + Self { rpc_path } + } + + async fn create_rpc(&self) -> AnyResult { + ClnRpc::new(&self.rpc_path).await + } +} + +#[async_trait] +impl ClnApi for ClnApiRpc { + async fn lsps2_getpolicy( + &self, + params: &Lsps2PolicyGetInfoRequest, + ) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_raw("dev-lsps2-getpolicy", params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling dev-lsps2-getpolicy") + } +} + +/// Handler for the `lsps2.get_info` method. +pub struct Lsps2GetInfoHandler { + pub api: A, + pub promise_secret: [u8; 32], +} + +impl Lsps2GetInfoHandler { + pub fn new(api: A, promise_secret: [u8; 32]) -> Self { + Self { + api, + promise_secret, + } + } +} + +/// The RequestHandler calls the internal rpc command `dev-lsps2-getinfo`. It +/// expects a plugin has registered this command and manages policies for the +/// LSPS2 service. +#[async_trait] +impl RequestHandler for Lsps2GetInfoHandler { + async fn handle(&self, payload: &[u8]) -> core::result::Result, RpcError> { + let (payload, _) = 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 params = req + .params + .ok_or(RpcError::invalid_params("expected params but was missing"))?; + + let policy_params: Lsps2PolicyGetInfoRequest = params.into(); + let res_data: Lsps2PolicyGetInfoResponse = self + .api + .lsps2_getpolicy(&policy_params) + .await + .map_err(|e| RpcError { + code: 200, + message: format!("failed to fetch policy {e:#}"), + data: None, + })?; + + let opening_fee_params_menu = res_data + .policy_opening_fee_params_menu + .iter() + .map(|v| { + let promise: Promise = v + .get_hmac_hex(&self.promise_secret) + .try_into() + .map_err(|e| RpcError::internal_error(format!("invalid promise: {e}")))?; + Ok(OpeningFeeParams { + min_fee_msat: v.min_fee_msat, + proportional: v.proportional, + valid_until: v.valid_until, + min_lifetime: v.min_lifetime, + max_client_to_self_delay: v.max_client_to_self_delay, + min_payment_size_msat: v.min_payment_size_msat, + max_payment_size_msat: v.max_payment_size_msat, + promise, + }) + }) + .collect::, RpcError>>()?; + + let res = Lsps2GetInfoResponse { + opening_fee_params_menu, + } + .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))) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + use crate::{ + jsonrpc::{JsonRpcRequest, ResponseObject}, + lsps0::primitives::{Msat, Ppm}, + lsps2::model::PolicyOpeningFeeParams, + util::wrap_payload_with_peer_id, + }; + use chrono::{TimeZone, Utc}; + use cln_rpc::primitives::PublicKey; + use cln_rpc::RpcError as ClnRpcError; + + const PUBKEY: [u8; 33] = [ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, + 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, + 0xf8, 0x17, 0x98, + ]; + + fn create_peer_id() -> PublicKey { + PublicKey::from_slice(&PUBKEY).expect("Valid pubkey") + } + + 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()) + } + + #[derive(Clone, Default)] + struct FakeCln { + lsps2_getpolicy_response: Arc>>, + lsps2_getpolicy_error: Arc>>, + } + + #[async_trait] + impl ClnApi for FakeCln { + async fn lsps2_getpolicy( + &self, + _params: &Lsps2PolicyGetInfoRequest, + ) -> Result { + if let Some(err) = self.lsps2_getpolicy_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + }; + if let Some(res) = self.lsps2_getpolicy_response.lock().unwrap().take() { + return Ok(res); + }; + panic!("No lsps2 response defined"); + } + } + + #[tokio::test] + async fn test_successful_get_info() { + let promise_secret = [0u8; 32]; + let params = Lsps2PolicyGetInfoResponse { + policy_opening_fee_params_menu: vec![PolicyOpeningFeeParams { + min_fee_msat: Msat(2000), + proportional: Ppm(10000), + valid_until: Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(), + min_lifetime: 1000, + max_client_to_self_delay: 42, + min_payment_size_msat: Msat(1000000), + max_payment_size_msat: Msat(100000000), + }], + }; + let promise = params.policy_opening_fee_params_menu[0].get_hmac_hex(&promise_secret); + let fake = FakeCln::default(); + *fake.lsps2_getpolicy_response.lock().unwrap() = Some(params); + let handler = Lsps2GetInfoHandler::new(fake, promise_secret); + + let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string())); + let payload = create_wrapped_request(&request); + + let result = handler.handle(&payload).await.unwrap(); + let response: ResponseObject = + serde_json::from_slice(&result).unwrap(); + let response = response.into_inner().unwrap(); + + assert_eq!( + response.opening_fee_params_menu[0].min_payment_size_msat, + Msat(1000000) + ); + assert_eq!( + response.opening_fee_params_menu[0].max_payment_size_msat, + Msat(100000000) + ); + assert_eq!( + response.opening_fee_params_menu[0].promise, + promise.try_into().unwrap() + ); + } + + #[tokio::test] + async fn test_get_info_rpc_error_handling() { + let fake = FakeCln::default(); + *fake.lsps2_getpolicy_error.lock().unwrap() = Some(ClnRpcError { + code: Some(-1), + message: "not found".to_string(), + data: None, + }); + let handler = Lsps2GetInfoHandler::new(fake, [0; 32]); + let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string())); + let payload = create_wrapped_request(&request); + + let result = handler.handle(&payload).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.code, 200); + assert!(error.message.contains("failed to fetch policy")); + } +} diff --git a/plugins/lsps-plugin/src/lsps2/mod.rs b/plugins/lsps-plugin/src/lsps2/mod.rs index 0d0c0b35e..b217f9871 100644 --- a/plugins/lsps-plugin/src/lsps2/mod.rs +++ b/plugins/lsps-plugin/src/lsps2/mod.rs @@ -1,5 +1,6 @@ use cln_plugin::options; +pub mod handler; pub mod model; pub const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag( diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index aff0fb6bb..11905dbfd 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -6,6 +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::util::wrap_payload_with_peer_id; use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT}; use cln_plugin::options::ConfigOption; @@ -46,12 +47,22 @@ async fn main() -> Result<(), anyhow::Error> { .configure() .await? { + let rpc_path = + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file); + if !plugin.option(&OPTION_ENABLED)? { return plugin .disable(&format!("`{}` not enabled", OPTION_ENABLED.name)) .await; } + let mut lsps_builder = JsonRpcServer::builder().with_handler( + Lsps0listProtocolsRequest::METHOD.to_string(), + Arc::new(Lsps0ListProtocolsHandler { + lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?, + }), + ); + if plugin.option(&lsps2::OPTION_ENABLED)? { log::debug!("lsps2 enabled"); let secret_hex = plugin.option(&lsps2::OPTION_PROMISE_SECRET)?; @@ -70,7 +81,7 @@ async fn main() -> Result<(), anyhow::Error> { } }; - let _: [u8; 32] = match decoded_bytes.try_into() { + let secret: [u8; 32] = match decoded_bytes.try_into() { Ok(array) => array, Err(vec) => { return plugin @@ -81,16 +92,16 @@ async fn main() -> Result<(), anyhow::Error> { .await; } }; + + 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 lsps_builder = JsonRpcServer::builder().with_handler( - Lsps0listProtocolsRequest::METHOD.to_string(), - Arc::new(Lsps0ListProtocolsHandler { - lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?, - }), - ); - let lsps_service = lsps_builder.build(); let state = State { lsps_service }; diff --git a/tests/plugins/lsps2_policy.py b/tests/plugins/lsps2_policy.py new file mode 100755 index 000000000..e16eb4ae1 --- /dev/null +++ b/tests/plugins/lsps2_policy.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +""" A simple implementation of a LSPS2 compatible policy plugin. It is the job +of this plugin to deliver a fee options menu to the LSPS2 service plugin. +""" + +from pyln.client import Plugin +from datetime import datetime, timedelta, timezone + + +plugin = Plugin() + + +@plugin.method("dev-lsps2-getpolicy") +def lsps2_getpolicy(request): + """ Returns an opening fee menu for the LSPS2 plugin. + """ + now = datetime.now(timezone.utc) + + # Is ISO 8601 format "YYYY-MM-DDThh:mm:ss.uuuZ" + valid_until = (now + timedelta(hours=1)).isoformat().replace('+00:00', 'Z') + + return { "policy_opening_fee_params_menu": [ + { + "min_fee_msat": "1000", + "proportional": 1000, + "valid_until": valid_until, + "min_lifetime": 2000, + "max_client_to_self_delay": 2016, + "min_payment_size_msat": "1000", + "max_payment_size_msat": "100000000", + }, + { + "min_fee_msat": "1092000", + "proportional": 2400, + "valid_until": valid_until, + "min_lifetime": 1008, + "max_client_to_self_delay": 2016, + "min_payment_size_msat": "1000", + "max_payment_size_msat": "1000000", + } + ] +} + + +plugin.run() diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 40a8ae68b..53dcf386d 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -30,6 +30,7 @@ def test_lsps0_listprotocols(node_factory): res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id']) assert res + def test_lsps2_enabled(node_factory): l1, l2 = node_factory.get_nodes(2, opts=[ {"dev-lsps-client-enabled": None}, @@ -44,3 +45,22 @@ def test_lsps2_enabled(node_factory): res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id']) assert res['protocols'] == [2] + + +def test_lsps2_getinfo(node_factory): + 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 + } + ]) + + node_factory.join_nodes([l1, l2], fundchannel=False) + + res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id']) + assert res["opening_fee_params_menu"]