plugins: lsps: switch to typed transport
What changed, and why it matters
This commit is a routine internal code cleanup in Core Lightning's experimental LSPS (Lightning Service Provider Specification) plugin. It replaces a generic JSON-RPC helper with a more specific, typed client. There is no indication this fixes a security bug or introduces a vulnerability; it is a refactoring change.
No security action required. Treat as normal refactoring; standard code review and regression testing are sufficient.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The patch removes the generic JsonRpcClient and its raw send/notify methods from core/transport.rs, adds a new typed LspsClient in core/client.rs, and updates Bolt8Transport to implement a single typed request() method. Call sites in client.rs now use LspsClient::list_protocols, get_info, and buy. Error handling is adjusted to serialize both success and error variants of JsonRpcResponse. No security-relevant behavior changes are visible in the diff.
Changed components
plugins/lsps-plugin/src/core/transport.rsplugins/lsps-plugin/src/core/client.rsplugins/lsps-plugin/src/core/mod.rsplugins/lsps-plugin/src/lsps0/transport.rsplugins/lsps-plugin/src/client.rsInspect captured patch +118 / −338
diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs
index a22d141e..9b6f003b 100644
--- a/plugins/lsps-plugin/src/client.rs
+++ b/plugins/lsps-plugin/src/client.rs
@@ -1,7 +1,7 @@
use anyhow::{anyhow, bail, Context};
use bitcoin::hashes::{hex::FromHex, sha256, Hash};
use chrono::{Duration, Utc};
-use cln_lsps::core::transport::JsonRpcClient;
+use cln_lsps::core::client::LspsClient;
use cln_lsps::lsps0::transport::{
Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager,
};
@@ -10,12 +10,9 @@ use cln_lsps::lsps2::cln::{
HtlcAcceptedRequest, HtlcAcceptedResponse, InvoicePaymentRequest, OpenChannelRequest,
TLV_FORWARD_AMT, TLV_PAYMENT_SECRET,
};
-use cln_lsps::proto::lsps0::{
- Lsps0listProtocolsRequest, Lsps0listProtocolsResponse, Msat, LSP_FEATURE_BIT,
-};
+use cln_lsps::proto::lsps0::{Msat, LSP_FEATURE_BIT};
use cln_lsps::proto::lsps2::{
- compute_opening_fee, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest,
- Lsps2GetInfoResponse, OpeningFeeParams,
+ compute_opening_fee, Lsps2BuyResponse, Lsps2GetInfoResponse, OpeningFeeParams,
};
use cln_lsps::util;
use cln_plugin::options;
@@ -140,18 +137,13 @@ async fn on_lsps_lsps2_getinfo(
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(&lsp_id, info_req)
- .await
- .context("lsps2.get_info call failed")?
- .into_result()?;
- debug!("received lsps2.get_info response: {:?}", info_res);
-
- Ok(serde_json::to_value(info_res)?)
+ let client = LspsClient::new(transport);
+ match client.get_info(&lsp_id, req.token).await?.as_result() {
+ Ok(i) => Ok(serde_json::to_value(i)?),
+ Err(e) => Ok(serde_json::to_value(e)?),
+ }
}
/// Rpc Method handler for `lsps-lsps2-buy`.
@@ -193,7 +185,7 @@ async fn on_lsps_lsps2_buy(
None, // Use default timeout
)
.context("Failed to create Bolt8Transport")?;
- let client = JsonRpcClient::new(transport);
+ let client = LspsClient::new(transport);
let selected_params = req.opening_fee_params;
if let Some(payment_size) = req.payment_size_msat {
@@ -239,17 +231,14 @@ async fn on_lsps_lsps2_buy(
}
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: req.payment_size_msat,
- };
- let buy_res: Lsps2BuyResponse = client
- .call_typed(&lsp_id, buy_req)
- .await
- .context("lsps2.buy call failed")?
- .into_result()?;
-
- Ok(serde_json::to_value(buy_res)?)
+ match client
+ .buy(&lsp_id, selected_params, req.payment_size_msat)
+ .await?
+ .as_result()
+ {
+ Ok(i) => Ok(serde_json::to_value(i)?),
+ Err(e) => Ok(serde_json::to_value(e)?),
+ }
}
async fn on_lsps_lsps2_approve(
@@ -731,17 +720,14 @@ async fn on_lsps_listprotocols(
.context("Failed to create Bolt8Transport")?;
// Now create the client using the transport
- let client = JsonRpcClient::new(transport);
-
- let request = Lsps0listProtocolsRequest {};
- let res: Lsps0listProtocolsResponse = client
- .call_typed(&lsp_id, request)
- .await
- .context("lsps0.list_protocols call failed")?
- .into_result()?;
-
- debug!("Received lsps0.list_protocols response: {:?}", res);
- Ok(serde_json::to_value(res)?)
+ let client = LspsClient::new(transport);
+ match client.list_protocols(&lsp_id).await?.as_result() {
+ Ok(i) => {
+ debug!("Received lsps0.list_protocols response: {:?}", i);
+ Ok(serde_json::to_value(i)?)
+ }
+ Err(e) => Ok(serde_json::to_value(e)?),
+ }
}
struct PeerLspStatus {
diff --git a/plugins/lsps-plugin/src/core/client.rs b/plugins/lsps-plugin/src/core/client.rs
new file mode 100644
index 00000000..0c494e9f
--- /dev/null
+++ b/plugins/lsps-plugin/src/core/client.rs
@@ -0,0 +1,66 @@
+use bitcoin::secp256k1::PublicKey;
+
+use crate::{
+ core::transport::{self, Transport},
+ proto::{
+ jsonrpc::{JsonRpcRequest, JsonRpcResponse},
+ lsps0::{Lsps0listProtocolsRequest, Lsps0listProtocolsResponse, Msat},
+ lsps2::{
+ Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest, Lsps2GetInfoResponse,
+ OpeningFeeParams,
+ },
+ },
+};
+
+pub struct LspsClient<T: Transport> {
+ transport: T,
+}
+
+impl<T: Transport> LspsClient<T> {
+ pub fn new(transport: T) -> Self {
+ Self { transport }
+ }
+}
+
+// LSPS0 Implementation
+impl<T: Transport> LspsClient<T> {
+ pub async fn list_protocols(
+ &self,
+ peer: &PublicKey,
+ ) -> Result<JsonRpcResponse<Lsps0listProtocolsResponse>, transport::Error> {
+ self.transport
+ .request(peer, &Lsps0listProtocolsRequest {}.into_request())
+ .await
+ }
+}
+
+// LSPS2 Implementation
+impl<T: Transport> LspsClient<T> {
+ pub async fn get_info(
+ &self,
+ peer: &PublicKey,
+ token: Option<String>,
+ ) -> Result<JsonRpcResponse<Lsps2GetInfoResponse>, transport::Error> {
+ self.transport
+ .request(peer, &Lsps2GetInfoRequest { token }.into_request())
+ .await
+ }
+
+ pub async fn buy(
+ &self,
+ peer: &PublicKey,
+ opening_fee_params: OpeningFeeParams,
+ payment_size_msat: Option<Msat>,
+ ) -> Result<JsonRpcResponse<Lsps2BuyResponse>, transport::Error> {
+ self.transport
+ .request(
+ peer,
+ &Lsps2BuyRequest {
+ opening_fee_params,
+ payment_size_msat,
+ }
+ .into_request(),
+ )
+ .await
+ }
+}
diff --git a/plugins/lsps-plugin/src/core/mod.rs b/plugins/lsps-plugin/src/core/mod.rs
index bfc7a330..ca0e0203 100644
--- a/plugins/lsps-plugin/src/core/mod.rs
+++ b/plugins/lsps-plugin/src/core/mod.rs
@@ -1 +1,2 @@
+pub mod client;
pub mod transport;
diff --git a/plugins/lsps-plugin/src/core/transport.rs b/plugins/lsps-plugin/src/core/transport.rs
index a1dc9d69..47f6ef2a 100644
--- a/plugins/lsps-plugin/src/core/transport.rs
+++ b/plugins/lsps-plugin/src/core/transport.rs
@@ -1,9 +1,8 @@
-use crate::proto::jsonrpc::{JsonRpcRequest, JsonRpcResponse, RequestObject};
+use crate::proto::jsonrpc::{JsonRpcResponse, RequestObject};
use async_trait::async_trait;
use bitcoin::secp256k1::PublicKey;
use core::fmt::Debug;
use serde::{de::DeserializeOwned, Serialize};
-use serde_json::Value;
use thiserror::Error;
/// Transport-specific errors that may occur when sending or receiving JSON-RPC
@@ -37,8 +36,6 @@ pub type Result<T> = std::result::Result<T, Error>;
/// request over some transport mechanism (RPC, Bolt8, etc.)
#[async_trait]
pub trait Transport: Send + Sync {
- async fn send(&self, peer: &PublicKey, request: &str) -> Result<String>;
- async fn notify(&self, peer: &PublicKey, request: &str) -> Result<()>;
async fn request<P, R>(
&self,
_peer_id: &PublicKey,
@@ -46,276 +43,5 @@ pub trait Transport: Send + Sync {
) -> Result<JsonRpcResponse<R>>
where
P: Serialize + Send + Sync,
- R: DeserializeOwned + Send,
- {
- unimplemented!();
- }
-}
-
-/// A typed JSON-RPC client that works with any transport implementation.
-///
-/// This client handles the JSON-RPC protocol details including message
-/// formatting, request ID generation, and response parsing.
-#[derive(Clone)]
-pub struct JsonRpcClient<T: Transport> {
- transport: T,
-}
-
-impl<T: Transport> JsonRpcClient<T> {
- pub fn new(transport: T) -> Self {
- Self { transport }
- }
-
- /// Makes a JSON-RPC method call with raw JSON parameters and returns a raw
- /// JSON result.
- pub async fn call_raw(
- &self,
- peer_id: &PublicKey,
- method: &str,
- params: Option<Value>,
- id: Option<String>,
- ) -> Result<JsonRpcResponse<Value>> {
- let request = RequestObject {
- jsonrpc: "2.0".into(),
- method: method.into(),
- params,
- id,
- };
- self.send_request(peer_id, &request).await
- }
-
- /// Makes a typed JSON-RPC method call with a request object and returns a
- /// typed response.
- ///
- /// This method provides type safety by using request and response types
- /// that implement the necessary traits.
- pub async fn call_typed<RQ, RS>(
- &self,
- peer_id: &PublicKey,
- request: RQ,
- ) -> Result<JsonRpcResponse<RS>>
- where
- RQ: JsonRpcRequest + Send + Sync,
- RS: DeserializeOwned + Serialize + Send,
- {
- let request = request.into_request();
- self.send_request(peer_id, &request).await
- }
-
- async fn send_request<RP, RS>(
- &self,
- peer: &PublicKey,
- request: &RequestObject<RP>,
- ) -> Result<JsonRpcResponse<RS>>
- where
- RP: Serialize + Send + Sync,
- RS: DeserializeOwned + Serialize + Send,
- {
- self.transport.request(peer, request).await
- }
-}
-
-#[cfg(test)]
-mod test_json_rpc {
- use super::*;
- use crate::proto::jsonrpc::RpcError;
- use serde::Deserialize;
- use std::{str::FromStr as _, sync::Arc};
- use tokio::sync::OnceCell;
-
- #[derive(Clone)]
- struct TestTransport {
- req: Arc<OnceCell<String>>,
- res: Arc<Option<String>>,
- err: Arc<Option<String>>,
- }
-
- impl TestTransport {
- // Get the last request as parsed JSON
- fn last_request_json(&self) -> Option<Value> {
- self.req
- .get()
- .and_then(|req_str| serde_json::from_str(req_str).ok())
- }
- }
-
- #[async_trait]
- impl Transport for TestTransport {
- async fn request<P, R>(
- &self,
- _peer_id: &PublicKey,
- request: &RequestObject<P>,
- ) -> Result<JsonRpcResponse<R>>
- where
- P: Serialize + Send + Sync,
- R: DeserializeOwned + Send,
- {
- // Store the request
- let req = serde_json::to_string(request).unwrap();
- let _ = self.req.set(req);
-
- // Check for error first
- if let Some(err) = &*self.err {
- return Err(Error::Internal(err.into()));
- }
-
- // Then check for response
- if let Some(res) = &*self.res {
- let res: JsonRpcResponse<R> = match serde_json::from_str(&res) {
- Ok(v) => v,
- Err(e) => {
- println!("GOT ERROR {}", e);
- panic!();
- }
- };
- return Ok(res);
- }
- panic!("TestTransport: neither result nor error is set.");
- }
- async fn send(
- &self,
- _peer_id: &PublicKey,
- _req: &str,
- ) -> core::result::Result<String, Error> {
- unimplemented!();
- }
-
- async fn notify(
- &self,
- _peer_id: &PublicKey,
- _req: &str,
- ) -> core::result::Result<(), Error> {
- unimplemented!();
- }
- }
-
- #[derive(Default, Clone, Serialize, Deserialize, Debug)]
- struct DummyCall {
- foo: String,
- bar: i32,
- }
-
- impl JsonRpcRequest for DummyCall {
- const METHOD: &'static str = "dummy_call";
- }
-
- #[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
- struct DummyResponse {
- foo: String,
- bar: i32,
- }
-
- #[tokio::test]
- async fn test_typed_call_w_response() {
- let peer_id = PublicKey::from_str(
- "02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc",
- )
- .unwrap();
-
- let req = DummyCall {
- foo: String::from("hello world!"),
- bar: 13,
- };
-
- let expected_res = DummyResponse {
- foo: String::from("hello client!"),
- bar: 10,
- };
-
- let res_obj = JsonRpcResponse::success(&expected_res, "my-id-123");
- let res_str = serde_json::to_string(&res_obj).unwrap();
-
- let transport = TestTransport {
- req: Arc::new(OnceCell::const_new()),
- res: Arc::new(Some(res_str)),
- err: Arc::new(None),
- };
-
- let client_1 = JsonRpcClient::new(transport.clone());
- let res = client_1
- .call_typed::<_, DummyResponse>(&peer_id, req.clone())
- .await
- .expect("Should have an OK result")
- .expect("Should not be a JSON-RPC error");
- assert_eq!(res, expected_res);
- let transport_req = transport
- .last_request_json()
- .expect("Transport should have gotten a request");
- assert_eq!(
- transport_req
- .get("jsonrpc")
- .and_then(|v| v.as_str())
- .unwrap(),
- "2.0"
- );
- assert_eq!(
- transport_req
- .get("params")
- .and_then(|v| v.as_object())
- .unwrap(),
- serde_json::to_value(&req).unwrap().as_object().unwrap()
- );
- }
-
- #[tokio::test]
- async fn test_typed_call_w_rpc_error() {
- let peer_id = PublicKey::from_str(
- "02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc",
- )
- .unwrap();
-
- let req = DummyCall {
- foo: "hello world!".into(),
- bar: 13,
- };
-
- let err_res = RpcError::custom_error_with_data(
- -32099,
- "got a custom error",
- serde_json::json!({"got": "some"}),
- );
-
- let res_obj = JsonRpcResponse::error(err_res.clone(), "unique-id-123");
- let res_str = serde_json::to_string(&res_obj).unwrap();
-
- let transport = TestTransport {
- req: Arc::new(OnceCell::const_new()),
- res: Arc::new(Some(res_str)),
- err: Arc::new(None),
- };
-
- let client_1 = JsonRpcClient::new(transport);
- let res = client_1
- .call_typed::<_, DummyResponse>(&peer_id, req)
- .await
- .expect("only inner rpc error")
- .expect_err("expect rpc error");
- assert_eq!(res, err_res);
- }
-
- #[tokio::test]
- async fn test_typed_call_w_internal_error() {
- let peer_id = PublicKey::from_str(
- "02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc",
- )
- .unwrap();
-
- let req = DummyCall {
- foo: "hello world!".into(),
- bar: 13,
- };
-
- let transport = TestTransport {
- req: Arc::new(OnceCell::const_new()),
- res: Arc::new(None),
- err: Arc::new(Some(String::from("transport error"))),
- };
-
- let client_1 = JsonRpcClient::new(transport);
- let res = client_1
- .call_typed::<_, DummyResponse>(&peer_id, req)
- .await
- .expect_err("Expected error response");
- assert!(matches!(res, Error::Internal(..)));
- }
+ R: DeserializeOwned + Send;
}
diff --git a/plugins/lsps-plugin/src/lsps0/transport.rs b/plugins/lsps-plugin/src/lsps0/transport.rs
index ffc98e92..07270635 100644
--- a/plugins/lsps-plugin/src/lsps0/transport.rs
+++ b/plugins/lsps-plugin/src/lsps0/transport.rs
@@ -1,12 +1,18 @@
use crate::{
core::transport::{Error, Transport},
- proto::lsps0::LSPS0_MESSAGE_TYPE,
+ proto::{
+ jsonrpc::{JsonRpcResponse, RequestObject},
+ lsps0::LSPS0_MESSAGE_TYPE,
+ },
};
use async_trait::async_trait;
use cln_plugin::Plugin;
use cln_rpc::{primitives::PublicKey, ClnRpc};
use log::{debug, error, trace};
-use serde::{de::Visitor, Deserialize, Serialize};
+use serde::{
+ de::{DeserializeOwned, Visitor},
+ Deserialize, Serialize,
+};
use std::{
array::TryFromSliceError,
collections::HashMap,
@@ -253,13 +259,22 @@ pub async fn send_custommsg(
#[async_trait]
impl Transport for Bolt8Transport {
- /// Sends a JSON-RPC request and waits for a response.
- async fn send(
+ async fn request<P, R>(
&self,
peer_id: &PublicKey,
- request: &str,
- ) -> core::result::Result<String, Error> {
- let id = extract_message_id(request)?;
+ request: &RequestObject<P>,
+ ) -> Result<JsonRpcResponse<R>, Error>
+ where
+ P: Serialize + Send + Sync,
+ R: DeserializeOwned + Send,
+ {
+ let id = if let Some(id) = request.id.as_ref() {
+ id
+ } else {
+ return Err(Error::MissingId);
+ };
+ let request_bytes = serde_json::to_vec(request)?;
+
let mut client = self.connect_to_node().await?;
let (tx, rx) = mpsc::channel(1);
@@ -274,7 +289,7 @@ impl Transport for Bolt8Transport {
self.hook_watcher
.subscribe_hook_once(id, Arc::downgrade(&tx_arc))
.await;
- self.send_custom_msg(&mut client, peer_id, request.as_bytes())
+ self.send_custom_msg(&mut client, peer_id, &request_bytes)
.await?;
let res = self.wait_for_response(rx).await?;
@@ -286,21 +301,7 @@ impl Transport for Bolt8Transport {
)));
}
- core::str::from_utf8(&res.payload)
- .map_err(|e| {
- Error::Internal(format!(
- "failed to decode msg payload {:?}: {}",
- res.payload, e
- ))
- })
- .map(|s| s.into())
- }
-
- /// Sends a notification without waiting for a response.
- async fn notify(&self, peer_id: &PublicKey, request: &str) -> core::result::Result<(), Error> {
- let mut client = self.connect_to_node().await?;
- self.send_custom_msg(&mut client, peer_id, request.as_bytes())
- .await
+ Ok(serde_json::from_slice(&res.payload)?)
}
}
Why this scored 11/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.