lsp_plugin: add lsps2_getinfo handler and call
What changed, and why it matters
This commit adds a new feature to Core Lightning's experimental LSPS (Lightning Service Provider Specification) plugin. It lets a client ask a service provider for its current fee menu, and the service plugin fetches that menu from a separate policy plugin. The change is mostly feature work, but it introduces a few places where malformed input or a misbehaving policy plugin could cause errors or unexpected behavior. There is no indication this fixes a known security bug or is being treated as a security patch by the project.
Treat as a normal feature commit. Reviewers should verify that the policy-plugin RPC is only exposed to the local node (it is prefixed `dev-`), that error responses do not leak sensitive internal paths to remote peers, and that the promise HMAC covers all fields intended to be tamper-evident per BLIP-052. No urgent security action is indicated from this diff alone.
Security signals we found
New network-exposed RPC method `lsps-lsps2-getinfo` added to client plugin
New JSON-RPC handler `lsps2.get_info` added to service plugin
Service plugin delegates fee policy to external plugin via `dev-lsps2-getpolicy` RPC
Error message from policy plugin is forwarded to remote peer with code 200, potentially leaking internal error details
Promise HMAC is computed over policy-provided fee parameters using a configured 32-byte secret
Evidence from the diff
The patch implements the BLIP-052 lsps2.get_info flow: a client-side RPC lsps-lsps2-getinfo, a service-side JSON-RPC handler, and a test policy plugin (dev-lsps2-getpolicy). The handler validates the request has an id, calls the policy plugin, computes HMAC promises over the returned fee menu, and returns the menu. Notable code-quality/security observations: (1) the handler returns Ok(vec![]) for notifications without any logging, which is acceptable but could hide unexpected traffic; (2) policy-plugin errors are wrapped in a generic JSON-RPC error code 200 with the full formatted error chain, which may leak internal policy-plugin paths or details; (3) the client-side ensure_lsp_connected check and Bolt8 transport creation are new network-facing RPC paths; (4) the test policy plugin uses datetime.now(timezone.utc) and string replacement to format valid_until, which is test-only code. No memory-unsafe operations, no authentication bypass, no cryptographic misuse, and no explicit security fix are visible in the diff.
Changed components
plugins/lsps-plugin/src/client.rsplugins/lsps-plugin/src/lsps2/handler.rsplugins/lsps-plugin/src/lsps2/mod.rsplugins/lsps-plugin/src/service.rstests/plugins/lsps2_policy.pytests/test_cln_lsps.pyInspect captured patch +378 / −10
diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs
index 52f1da61..b861ca54 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<State>,
+ v: serde_json::Value,
+) -> Result<serde_json::Value, anyhow::Error> {
+ 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<State>,
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<String>,
+}
diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs
new file mode 100644
index 00000000..2f9162ad
--- /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<Lsps2PolicyGetInfoResponse>;
+}
+
+#[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> {
+ ClnRpc::new(&self.rpc_path).await
+ }
+}
+
+#[async_trait]
+impl ClnApi for ClnApiRpc {
+ async fn lsps2_getpolicy(
+ &self,
+ params: &Lsps2PolicyGetInfoRequest,
+ ) -> AnyResult<Lsps2PolicyGetInfoResponse> {
+ 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<A: ClnApi> {
+ pub api: A,
+ pub promise_secret: [u8; 32],
+}
+
+impl<A: ClnApi> Lsps2GetInfoHandler<A> {
+ 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<T: ClnApi + 'static> RequestHandler for Lsps2GetInfoHandler<T> {
+ async fn handle(&self, payload: &[u8]) -> core::result::Result<Vec<u8>, RpcError> {
+ let (payload, _) = unwrap_payload_with_peer_id(payload);
+
+ let req: RequestObject<Lsps2GetInfoRequest> = 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::<Result<Vec<_>, 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<Lsps2GetInfoRequest>) -> Vec<u8> {
+ 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<Mutex<Option<Lsps2PolicyGetInfoResponse>>>,
+ lsps2_getpolicy_error: Arc<Mutex<Option<ClnRpcError>>>,
+ }
+
+ #[async_trait]
+ impl ClnApi for FakeCln {
+ async fn lsps2_getpolicy(
+ &self,
+ _params: &Lsps2PolicyGetInfoRequest,
+ ) -> Result<Lsps2PolicyGetInfoResponse, anyhow::Error> {
+ 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<Lsps2GetInfoResponse> =
+ 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 0d0c0b35..b217f987 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 aff0fb6b..11905dbf 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 00000000..e16eb4ae
--- /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 40a8ae68..53dcf386 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"]
Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.