plugins: lsps: slim down lsps service
What changed, and why it matters
This commit is a large internal refactoring of the experimental LSPS (Lightning Service Provider Specification) plugin in Core Lightning. It removes a lot of old code and replaces it with a slimmer service adapter. There is no direct evidence in the commit that this fixes a security vulnerability; it appears to be a code cleanup and architectural rewrite. Because it removes working LSPS2 logic (buy/get_info handlers and HTLC hook handling) and replaces it with a much smaller framework, the main risk is that functionality is incomplete or behavior may change, not that a known exploit was patched.
Treat this as a normal refactoring commit. If running the experimental LSPS plugin, verify that LSPS2 service functionality still works as intended after the rewrite, since the previous buy/get_info and HTLC-accepted handlers were removed. No immediate security patch action is indicated by the commit itself.
Security signals we found
Large negative diff (-2028 lines) removing prior LSPS2 request handlers and HTLC-accepted hook logic
New custom-message adapter decodes untrusted hex payloads and routes them; decode errors are logged and ignored
No input validation changes beyond moving existing decode_frame/encode_frame calls into a new module
No mention of security, CVE, bug, vulnerability, or exploit in commit title or message
No verified external references supplied
Evidence from the diff
The commit deletes the previous JsonRpcServer, Lsps0ListProtocolsHandler, Lsps2GetInfoHandler, Lsps2BuyHandler, HtlcAcceptedHookHandler, and related tests, and introduces a new cln_adapters module plus a lightweight LspsService wrapping a JsonRpcRouter. The new on_custommsg_service decodes hex LSPS0 frames and dispatches them through the router, then re-encodes and sends responses via sendcustommsg. The service builder auto-registers lsps0.list_protocols and filters out protocol 0. The diff is almost entirely structural removal/addition; no explicit vulnerability, CVE, advisory, or security fix language is present.
Changed components
plugins/lsps-plugin/src/service.rsplugins/lsps-plugin/src/core/server.rsplugins/lsps-plugin/src/cln_adapters/service.rsplugins/lsps-plugin/src/lsps0/handler.rs (deleted)plugins/lsps-plugin/src/lsps2/handler.rs (deleted)LSPS0/LSPS2 custom-message handling in Core Lightning experimental pluginInspect captured patch +238 / −2028
diff --git a/plugins/lsps-plugin/src/cln_adapters/mod.rs b/plugins/lsps-plugin/src/cln_adapters/mod.rs
new file mode 100644
index 00000000..1f278a4d
--- /dev/null
+++ b/plugins/lsps-plugin/src/cln_adapters/mod.rs
@@ -0,0 +1 @@
+pub mod service;
diff --git a/plugins/lsps-plugin/src/cln_adapters/service.rs b/plugins/lsps-plugin/src/cln_adapters/service.rs
new file mode 100644
index 00000000..36f9f504
--- /dev/null
+++ b/plugins/lsps-plugin/src/cln_adapters/service.rs
@@ -0,0 +1,96 @@
+use crate::{
+ core::{router::RequestContext, server::LspsService},
+ proto::lsps0::{decode_frame, encode_frame},
+};
+use anyhow::Result;
+use bitcoin::secp256k1::PublicKey;
+use cln_plugin::Plugin;
+use cln_rpc::model::requests::SendcustommsgRequest;
+use serde::Deserialize;
+use serde_json::Value;
+use std::{
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+
+pub trait ServiceStore {
+ fn service(&self) -> Arc<LspsService>;
+}
+
+#[derive(Debug, Clone, Deserialize)]
+struct CustomMsg {
+ peer_id: PublicKey,
+ payload: String,
+}
+
+fn rpc_path<S>(p: &Plugin<S>) -> PathBuf
+where
+ S: Clone + Sync + Send + 'static,
+{
+ let dir = p.configuration().lightning_dir;
+ Path::new(&dir).join(&p.configuration().rpc_file)
+}
+
+async fn send_custommsg<P>(rpc_path: P, peer: &PublicKey, msg: &str) -> Result<()>
+where
+ P: AsRef<Path>,
+{
+ let mut client = cln_rpc::ClnRpc::new(rpc_path).await?;
+ let _ = client
+ .call_typed(&SendcustommsgRequest {
+ msg: msg.to_owned(),
+ node_id: peer.to_owned(),
+ })
+ .await?;
+ Ok(())
+}
+
+pub async fn on_custommsg_service<S>(plugin: Plugin<S>, v: Value) -> Result<Value>
+where
+ S: Clone + Sync + Send + 'static + ServiceStore,
+{
+ let msg: CustomMsg = serde_json::from_value(v)?;
+ let req = match decode_lsps0_frame_hex(&msg.payload) {
+ Some(d) => d,
+ None => {
+ return Ok(serde_json::json!({
+ "result": "continue"
+ }))
+ }
+ };
+ let service = plugin.state().service();
+ let rpc_path = rpc_path(&plugin);
+ let ctx = RequestContext {
+ peer_id: msg.peer_id,
+ };
+ let res = service.handle(&ctx, &req).await;
+ if let Some(payload) = res {
+ let payload = encode_lsps0_frame_hex(&payload);
+ if let Err(e) = send_custommsg(&rpc_path, &msg.peer_id, &payload).await {
+ log::error!("Failed to send LSPS response to {}: {}", &msg.peer_id, e);
+ };
+ }
+
+ Ok(serde_json::json!({
+ "result": "continue"
+ }))
+}
+
+fn decode_lsps0_frame_hex(hex_str: &str) -> Option<Vec<u8>> {
+ let frame = match hex::decode(hex_str) {
+ Ok(f) => f,
+ Err(e) => {
+ log::error!(
+ "Failed to decode hex string payload from custom message: {}",
+ e
+ );
+ return None;
+ }
+ };
+ decode_frame(&frame).ok().map(|d| d.to_owned())
+}
+
+fn encode_lsps0_frame_hex(payload: &[u8]) -> String {
+ let frame = encode_frame(payload);
+ hex::encode(&frame)
+}
diff --git a/plugins/lsps-plugin/src/core/server.rs b/plugins/lsps-plugin/src/core/server.rs
index 6b998f4b..936c01fa 100644
--- a/plugins/lsps-plugin/src/core/server.rs
+++ b/plugins/lsps-plugin/src/core/server.rs
@@ -1,303 +1,172 @@
-use crate::proto::jsonrpc::RpcError;
-use crate::{core::transport::Result, proto::jsonrpc::JsonRpcResponse};
-use async_trait::async_trait;
-use log::{debug, trace};
-use std::{collections::HashMap, sync::Arc};
+use crate::core::router::{JsonRpcRouter, JsonRpcRouterBuilder, RequestContext};
+use crate::proto::lsps0::{Lsps0listProtocolsRequest, Lsps0listProtocolsResponse};
-/// Responsible for writing JSON-RPC responses back to clients.
-///
-/// This trait abstracts the mechanism for sending responses back to the client,
-/// allowing handlers to remain transport-agnostic. Implementations of this
-/// trait handle the actual transmission of response data over the underlying
-/// transport.
-#[async_trait]
-pub trait JsonRpcResponseWriter: Send + 'static {
- /// Writes the provided payload as a response.
- async fn write(&mut self, payload: &[u8]) -> Result<()>;
+pub trait LspsProtocol: Send + Sync + 'static {
+ fn register_handler(&self, router: &mut JsonRpcRouterBuilder);
+ fn protocol(&self) -> u8;
}
-/// Processes JSON-RPC requests and produces responses.
-///
-/// This trait defines the interface for handling specific JSON-RPC methods.
-/// Each method supported by the server should have a corresponding handler
-/// that implements this trait.
-#[async_trait]
-pub trait RequestHandler: Send + Sync + 'static {
- /// Handles a JSON-RPC request.
- async fn handle(&self, payload: &[u8]) -> core::result::Result<Vec<u8>, RpcError>;
+pub struct LspsService {
+ router: JsonRpcRouter,
+ supported_protocols: Vec<u8>,
}
-/// Builder for creating JSON-RPC servers.
-pub struct JsonRpcServerBuilder {
- handlers: HashMap<String, Arc<dyn RequestHandler>>,
-}
-
-impl JsonRpcServerBuilder {
- pub fn new() -> Self {
- Self {
- handlers: HashMap::new(),
- }
+impl LspsService {
+ pub fn builder() -> LspsServiceBuilder {
+ LspsServiceBuilder::new()
}
- /// Registers a handler for a specific JSON-RPC method.
- pub fn with_handler(mut self, method: String, handler: Arc<dyn RequestHandler>) -> Self {
- self.handlers.insert(method, handler);
- self
+ pub async fn handle(&self, ctx: &RequestContext, request: &[u8]) -> Option<Vec<u8>> {
+ self.router.handle(ctx, request).await
}
- /// Builds a JSON-RPC server with the configured handlers.
- pub fn build(self) -> JsonRpcServer {
- JsonRpcServer {
- handlers: Arc::new(self.handlers),
- }
+ pub fn protocols(&self) -> &[u8] {
+ &self.supported_protocols
}
}
-/// Server for handling JSON-RPC 2.0 requests.
-///
-/// Dispatches incoming JSON-RPC requests to the appropriate handlers based on
-/// the method name, and manages the response lifecycle.
-#[derive(Clone)]
-pub struct JsonRpcServer {
- handlers: Arc<HashMap<String, Arc<dyn RequestHandler>>>,
+pub struct LspsServiceBuilder {
+ router_builder: JsonRpcRouterBuilder,
+ supported_protocols: Vec<u8>,
}
-impl JsonRpcServer {
- pub fn builder() -> JsonRpcServerBuilder {
- JsonRpcServerBuilder::new()
+impl LspsServiceBuilder {
+ pub fn new() -> Self {
+ Self {
+ router_builder: JsonRpcRouterBuilder::new(),
+ supported_protocols: vec![],
+ }
}
- // Processes a JSON-RPC message and writes the response.
- ///
- /// This is the main entry point for handling JSON-RPC requests. It:
- /// 1. Parses and validates the incoming request
- /// 2. Routes the request to the appropriate handler
- /// 3. Writes the response back to the client (if needed)
- pub async fn handle_message(
- &self,
- payload: &[u8],
- writer: &mut dyn JsonRpcResponseWriter,
- ) -> Result<()> {
- trace!("Handle request with payload: {:?}", payload);
- let value: serde_json::Value = serde_json::from_slice(payload)?;
- let id = value.get("id").and_then(|id| id.as_str());
- let method = value.get("method").and_then(|method| method.as_str());
- let jsonrpc = value.get("jsonrpc").and_then(|jrpc| jrpc.as_str());
+ pub fn with_protocol<M>(mut self, method: M) -> Self
+ where
+ M: LspsProtocol,
+ {
+ let proto = method.protocol();
+ self.supported_protocols.push(proto);
+ method.register_handler(&mut self.router_builder);
+ self
+ }
- trace!(
- "Validate request: id={:?}, method={:?}, jsonrpc={:?}",
- id,
- method,
- jsonrpc
+ pub fn build(mut self) -> LspsService {
+ self.supported_protocols.sort();
+ self.supported_protocols.dedup();
+ let supported_protocols: Vec<u8> = self
+ .supported_protocols
+ .into_iter()
+ .filter(|&p| p != 0)
+ .collect();
+
+ let protocols_for_rpc = supported_protocols.clone();
+ self.router_builder.register(
+ "lsps0.list_protocols",
+ move |_p: Lsps0listProtocolsRequest| {
+ let protocols = protocols_for_rpc.clone();
+ async move { Ok(Lsps0listProtocolsResponse { protocols }) }
+ },
);
- let method = match (jsonrpc, method) {
- (Some(jrpc), Some(method)) if jrpc == "2.0" => method,
- (_, _) => {
- debug!("Got invalid request {}", value);
- let err = RpcError {
- code: -32600,
- message: "Invalid request".into(),
- data: None,
- };
- return self.maybe_write_error(id, err, writer).await;
- }
- };
-
- trace!("Get handler for id={:?}, method={:?}", id, method);
- if let Some(handler) = self.handlers.get(method) {
- trace!(
- "Call handler for id={:?}, method={:?}, with payload={:?}",
- id,
- method,
- payload
- );
- match handler.handle(payload).await {
- Ok(res) => return self.maybe_write(id, &res, writer).await,
- Err(e) => {
- debug!("Handler returned with error: {}", e);
- return self.maybe_write_error(id, e, writer).await;
- }
- };
- } else {
- debug!("No handler found for method: {}", method);
- let err = RpcError {
- code: -32601,
- message: "Method not found".into(),
- data: None,
- };
- return self.maybe_write_error(id, err, writer).await;
- }
- }
- /// Writes a response if the request has an ID.
- ///
- /// For notifications (requests without an ID), no response is written.
- async fn maybe_write(
- &self,
- id: Option<&str>,
- payload: &[u8],
- writer: &mut dyn JsonRpcResponseWriter,
- ) -> Result<()> {
- // No need to respond when we don't have an id - it's a notification
- if id.is_some() {
- return writer.write(payload).await;
- }
- Ok(())
- }
+ let router = self.router_builder.build();
- /// Writes an error response if the request has an ID.
- ///
- /// For notifications (requests without an ID), no response is written.
- async fn maybe_write_error(
- &self,
- id: Option<&str>,
- err: RpcError,
- writer: &mut dyn JsonRpcResponseWriter,
- ) -> Result<()> {
- // No need to respond when we don't have an id - it's a notification
- if let Some(id) = id {
- let err_res = JsonRpcResponse::error(err, id);
- let err_vec = serde_json::to_vec(&err_res)?;
- return writer.write(&err_vec).await;
+ LspsService {
+ router,
+ supported_protocols,
}
- Ok(())
}
}
#[cfg(test)]
-mod test_json_rpc_server {
+mod tests {
use super::*;
- #[derive(Default)]
- struct MockWriter {
- log_content: String,
- }
-
- #[async_trait]
- impl JsonRpcResponseWriter for MockWriter {
- async fn write(&mut self, payload: &[u8]) -> Result<()> {
- println!("Write payload={:?}", &payload);
- let byte_str = String::from_utf8(payload.to_vec()).unwrap();
- self.log_content = byte_str;
- Ok(())
+ fn test_context() -> RequestContext {
+ RequestContext {
+ peer_id: "0279BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"
+ .parse()
+ .unwrap(),
}
}
- // Echo handler
- pub struct Echo;
+ // Minimal mock - just tracks protocol number
+ struct MockProtocol(u8);
- #[async_trait]
- impl RequestHandler for Echo {
- async fn handle(&self, payload: &[u8]) -> core::result::Result<Vec<u8>, RpcError> {
- println!("Called handler with payload: {:?}", &payload);
- Ok(payload.to_vec())
+ impl LspsProtocol for MockProtocol {
+ fn register_handler(&self, _router: &mut JsonRpcRouterBuilder) {
+ // No-op, we just care about protocol number
}
- }
- #[tokio::test]
- async fn test_notification() {
- // A notification should not respond to the client so there is no need
- // to write payload to the writer;
- let server = JsonRpcServer::builder()
- .with_handler("echo".to_string(), Arc::new(Echo))
- .build();
-
- let mut writer = MockWriter {
- log_content: String::default(),
- };
-
- let msg = r#"{"jsonrpc":"2.0","method":"echo","params":{"age":99,"name":"Satoshi"}}"#; // No id signals a notification.
- let res = server.handle_message(msg.as_bytes(), &mut writer).await;
- assert!(res.is_ok());
- assert!(writer.log_content.is_empty()); // Was a notification we don't expect a response;
+ fn protocol(&self) -> u8 {
+ self.0
+ }
}
- #[tokio::test]
- async fn missing_method_field() {
- // We verify the request data, check that we return an error when we
- // don't understand the request.
- let server = JsonRpcServer::builder()
- .with_handler("echo".to_string(), Arc::new(Echo))
+ #[test]
+ fn test_protocols_sorted() {
+ let service = LspsService::builder()
+ .with_protocol(MockProtocol(5))
+ .with_protocol(MockProtocol(1))
+ .with_protocol(MockProtocol(2))
.build();
- let mut writer = MockWriter {
- log_content: String::default(),
- };
-
- let msg = r#"{"jsonrpc":"2.0","params":{"age":99,"name":"Satoshi"},"id":"unique-id-123"}"#;
- let res = server.handle_message(msg.as_bytes(), &mut writer).await;
- assert!(res.is_ok());
- let expected = r#"{"jsonrpc":"2.0","id":"unique-id-123","error":{"code":-32600,"message":"Invalid request"}}"#; // Unknown method say_hello
- assert_eq!(writer.log_content, expected);
+ assert_eq!(service.protocols(), &[1, 2, 5]);
}
- #[tokio::test]
- async fn wrong_version() {
- // We only accept requests that have jsonrpc version 2.0.
- let server = JsonRpcServer::builder()
- .with_handler("echo".to_string(), Arc::new(Echo))
+ #[test]
+ fn test_protocols_deduped() {
+ let service = LspsService::builder()
+ .with_protocol(MockProtocol(2))
+ .with_protocol(MockProtocol(2))
.build();
- let mut writer = MockWriter {
- log_content: String::default(),
- };
-
- let msg = r#"{"jsonrpc":"1.0","method":"echo","params":{"age":99,"name":"Satoshi"},"id":"unique-id-123"}"#;
- let res = server.handle_message(msg.as_bytes(), &mut writer).await;
- assert!(res.is_ok());
- let expected = r#"{"jsonrpc":"2.0","id":"unique-id-123","error":{"code":-32600,"message":"Invalid request"}}"#; // Unknown method say_hello
- assert_eq!(writer.log_content, expected);
+ assert_eq!(service.protocols(), &[2]);
}
- #[tokio::test]
- async fn propper_request() {
- // Check that we call the handler and write back to the writer when
- // processing a well-formed request.
- let server = JsonRpcServer::builder()
- .with_handler("echo".to_string(), Arc::new(Echo))
+ #[test]
+ fn test_protocol_zero_filtered() {
+ let service = LspsService::builder()
+ .with_protocol(MockProtocol(0))
+ .with_protocol(MockProtocol(2))
.build();
- let mut writer = MockWriter {
- log_content: String::default(),
- };
-
- let msg = r#"{"jsonrpc":"2.0","method":"echo","params":{"age":99,"name":"Satoshi"},"id":"unique-id-123"}"#;
- let res = server.handle_message(msg.as_bytes(), &mut writer).await;
- assert!(res.is_ok());
- assert_eq!(writer.log_content, msg.to_string());
+ assert_eq!(service.protocols(), &[2]);
}
#[tokio::test]
- async fn unknown_method() {
- // We don't know the method and need to send back an error to the client.
- let server = JsonRpcServer::builder()
- .with_handler("echo".to_string(), Arc::new(Echo))
+ async fn test_list_protocols_returns_registered() {
+ let service = LspsService::builder()
+ .with_protocol(MockProtocol(2))
+ .with_protocol(MockProtocol(1))
.build();
- let mut writer = MockWriter {
- log_content: String::default(),
- };
+ let request = serde_json::to_vec(&serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": "1",
+ "method": "lsps0.list_protocols",
+ "params": {}
+ }))
+ .unwrap();
+
+ let response = service.handle(&test_context(), &request).await.unwrap();
+ let parsed: serde_json::Value = serde_json::from_slice(&response).unwrap();
- let msg = r#"{"jsonrpc":"2.0","method":"say_hello","params":{"age":99,"name":"Satoshi"},"id":"unique-id-123"}"#; // Unknown method say_hello
- let res = server.handle_message(msg.as_bytes(), &mut writer).await;
- assert!(res.is_ok());
- let expected = r#"{"jsonrpc":"2.0","id":"unique-id-123","error":{"code":-32601,"message":"Method not found"}}"#; // Unknown method say_hello
- assert_eq!(writer.log_content, expected);
+ assert_eq!(parsed["result"]["protocols"], serde_json::json!([1, 2]));
}
#[tokio::test]
- async fn test_handler() {
- let server = JsonRpcServer::builder()
- .with_handler("echo".to_string(), Arc::new(Echo))
- .build();
+ async fn test_list_protocols_empty() {
+ let service = LspsService::builder().build();
+
+ let request = serde_json::to_vec(&serde_json::json!({
+ "jsonrpc": "2.0",
+ "id": "1",
+ "method": "lsps0.list_protocols",
+ "params": {}
+ }))
+ .unwrap();
- let mut writer = MockWriter {
- log_content: String::default(),
- };
+ let response = service.handle(&test_context(), &request).await.unwrap();
+ let parsed: serde_json::Value = serde_json::from_slice(&response).unwrap();
- let msg = r#"{"jsonrpc":"2.0","method":"echo","params":{"age":99,"name":"Satoshi"},"id":"unique-id-123"}"#;
- let res = server.handle_message(msg.as_bytes(), &mut writer).await;
- assert!(res.is_ok());
- assert_eq!(writer.log_content, msg.to_string());
+ assert_eq!(parsed["result"]["protocols"], serde_json::json!([]));
}
}
diff --git a/plugins/lsps-plugin/src/lib.rs b/plugins/lsps-plugin/src/lib.rs
index 549f657a..f4793aaf 100644
--- a/plugins/lsps-plugin/src/lib.rs
+++ b/plugins/lsps-plugin/src/lib.rs
@@ -1,3 +1,4 @@
+pub mod cln_adapters;
pub mod core;
pub mod lsps0;
pub mod lsps2;
diff --git a/plugins/lsps-plugin/src/lsps0/handler.rs b/plugins/lsps-plugin/src/lsps0/handler.rs
deleted file mode 100644
index 01510ba9..00000000
--- a/plugins/lsps-plugin/src/lsps0/handler.rs
+++ /dev/null
@@ -1,90 +0,0 @@
-use crate::{
- core::server::RequestHandler,
- proto::{
- jsonrpc::{JsonRpcResponse, RequestObject, RpcError},
- lsps0::{Lsps0listProtocolsRequest, Lsps0listProtocolsResponse},
- },
- util::unwrap_payload_with_peer_id,
-};
-use async_trait::async_trait;
-
-pub struct Lsps0ListProtocolsHandler {
- pub lsps2_enabled: bool,
-}
-
-#[async_trait]
-impl RequestHandler for Lsps0ListProtocolsHandler {
- async fn handle(&self, payload: &[u8]) -> core::result::Result<Vec<u8>, RpcError> {
- let (payload, _) = unwrap_payload_with_peer_id(payload);
-
- let req: RequestObject<Lsps0listProtocolsRequest> =
- serde_json::from_slice(&payload).unwrap();
- if let Some(id) = req.id {
- let mut protocols = vec![];
- if self.lsps2_enabled {
- protocols.push(2);
- }
- let res = JsonRpcResponse::success(Lsps0listProtocolsResponse { protocols }, id);
- let res_vec = serde_json::to_vec(&res).unwrap();
- return Ok(res_vec);
- }
- // If request has no ID (notification), return empty Ok result.
- Ok(vec![])
- }
-}
-
-#[cfg(test)]
-mod test {
- use super::*;
- use crate::{proto::jsonrpc::JsonRpcRequest as _, util::wrap_payload_with_peer_id};
- use cln_rpc::primitives::PublicKey;
-
- 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<Lsps0listProtocolsRequest>) -> Vec<u8> {
- let payload = serde_json::to_vec(request).expect("Failed to serialize request");
- wrap_payload_with_peer_id(&payload, create_peer_id())
- }
-
- #[tokio::test]
- async fn test_lsps2_disabled_returns_empty_protocols() {
- let handler = Lsps0ListProtocolsHandler {
- lsps2_enabled: false,
- };
-
- let request = Lsps0listProtocolsRequest {}.into_request();
- let payload = create_wrapped_request(&request);
-
- let result = handler.handle(&payload).await.unwrap();
- let response: JsonRpcResponse<Lsps0listProtocolsResponse> =
- serde_json::from_slice(&result).unwrap();
-
- let data = response.into_result().expect("Should have result data");
- assert!(data.protocols.is_empty());
- }
-
- #[tokio::test]
- async fn test_lsps2_enabled_returns_protocol_2() {
- let handler = Lsps0ListProtocolsHandler {
- lsps2_enabled: true,
- };
-
- let request = Lsps0listProtocolsRequest {}.into_request();
- let payload = create_wrapped_request(&request);
-
- let result = handler.handle(&payload).await.unwrap();
- let response: JsonRpcResponse<Lsps0listProtocolsResponse> =
- serde_json::from_slice(&result).unwrap();
-
- let data = response.into_result().expect("Should have result data");
- assert_eq!(data.protocols, vec![2]);
- }
-}
diff --git a/plugins/lsps-plugin/src/lsps0/mod.rs b/plugins/lsps-plugin/src/lsps0/mod.rs
index 91bf9cfe..bfc7a330 100644
--- a/plugins/lsps-plugin/src/lsps0/mod.rs
+++ b/plugins/lsps-plugin/src/lsps0/mod.rs
@@ -1,2 +1 @@
-pub mod handler;
pub mod transport;
diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs
deleted file mode 100644
index 7e0e4322..00000000
--- a/plugins/lsps-plugin/src/lsps2/handler.rs
+++ /dev/null
@@ -1,1598 +0,0 @@
-use crate::{
- core::server::RequestHandler,
- lsps2::{
- cln::{HtlcAcceptedRequest, HtlcAcceptedResponse, TLV_FORWARD_AMT},
- DS_MAIN_KEY, DS_SUB_KEY,
- },
- proto::{
- jsonrpc::{JsonRpcResponse, RequestObject, RpcError, RpcErrorExt as _},
- lsps0::{Msat, ShortChannelId},
- lsps2::{
- compute_opening_fee,
- failure_codes::{TEMPORARY_CHANNEL_FAILURE, UNKNOWN_NEXT_PEER},
- DatastoreEntry, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest,
- Lsps2GetInfoResponse, Lsps2PolicyGetChannelCapacityRequest,
- Lsps2PolicyGetChannelCapacityResponse, Lsps2PolicyGetInfoRequest,
- Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise,
- },
- },
- util::unwrap_payload_with_peer_id,
-};
-use anyhow::{Context, Result as AnyResult};
-use async_trait::async_trait;
-use bitcoin::hashes::Hash as _;
-use chrono::Utc;
-use cln_rpc::{
- model::{
- requests::{
- DatastoreMode, DatastoreRequest, DeldatastoreRequest, FundchannelRequest,
- GetinfoRequest, ListdatastoreRequest, ListpeerchannelsRequest,
- },
- responses::{
- DatastoreResponse, DeldatastoreResponse, FundchannelResponse, GetinfoResponse,
- ListdatastoreResponse, ListpeerchannelsResponse,
- },
- },
- primitives::{Amount, AmountOrAll, ChannelState},
- ClnRpc,
-};
-use log::{debug, warn};
-use rand::{rng, Rng as _};
-use std::{fmt, path::PathBuf, time::Duration};
-
-#[async_trait]
-pub trait ClnApi: Send + Sync {
- async fn lsps2_getpolicy(
- &self,
- params: &Lsps2PolicyGetInfoRequest,
- ) -> AnyResult<Lsps2PolicyGetInfoResponse>;
-
- async fn lsps2_getchannelcapacity(
- &self,
- params: &Lsps2PolicyGetChannelCapacityRequest,
- ) -> AnyResult<Lsps2PolicyGetChannelCapacityResponse>;
-
- async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult<GetinfoResponse>;
-
- async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult<DatastoreResponse>;
-
- async fn cln_listdatastore(
- &self,
- params: &ListdatastoreRequest,
- ) -> AnyResult<ListdatastoreResponse>;
-
- async fn cln_deldatastore(
- &self,
- params: &DeldatastoreRequest,
- ) -> AnyResult<DeldatastoreResponse>;
-
- async fn cln_fundchannel(&self, params: &FundchannelRequest) -> AnyResult<FundchannelResponse>;
-
- async fn cln_listpeerchannels(
- &self,
- params: &ListpeerchannelsRequest,
- ) -> AnyResult<ListpeerchannelsResponse>;
-}
-
-const DEFAULT_CLTV_EXPIRY_DELTA: u32 = 144;
-
-#[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("lsps2-policy-getpolicy", params)
- .await
- .map_err(anyhow::Error::new)
- .with_context(|| "calling lsps2-policy-getpolicy")
- }
-
- async fn lsps2_getchannelcapacity(
- &self,
- params: &Lsps2PolicyGetChannelCapacityRequest,
- ) -> AnyResult<Lsps2PolicyGetChannelCapacityResponse> {
- let mut rpc = self.create_rpc().await?;
- rpc.call_raw("lsps2-policy-getchannelcapacity", params)
- .await
- .map_err(anyhow::Error::new)
- .with_context(|| "calling lsps2-policy-getchannelcapacity")
- }
-
- async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult<GetinfoResponse> {
- 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<DatastoreResponse> {
- let mut rpc = self.create_rpc().await?;
- rpc.call_typed(params)
- .await
- .map_err(anyhow::Error::new)
- .with_context(|| "calling datastore")
- }
-
- async fn cln_listdatastore(
- &self,
- params: &ListdatastoreRequest,
- ) -> AnyResult<ListdatastoreResponse> {
- let mut rpc = self.create_rpc().await?;
- rpc.call_typed(params)
- .await
- .map_err(anyhow::Error::new)
- .with_context(|| "calling listdatastore")
- }
-
- async fn cln_deldatastore(
- &self,
- params: &DeldatastoreRequest,
- ) -> AnyResult<DeldatastoreResponse> {
- let mut rpc = self.create_rpc().await?;
- rpc.call_typed(params)
- .await
- .map_err(anyhow::Error::new)
- .with_context(|| "calling deldatastore")
- }
-
- async fn cln_fundchannel(&self, params: &FundchannelRequest) -> AnyResult<FundchannelResponse> {
- let mut rpc = self.create_rpc().await?;
- rpc.call_typed(params)
- .await
- .map_err(anyhow::Error::new)
- .with_context(|| "calling fundchannel")
- }
-
- async fn cln_listpeerchannels(
- &self,
- params: &ListpeerchannelsRequest,
- ) -> AnyResult<ListpeerchannelsResponse> {
- let mut rpc = self.create_rpc().await?;
- rpc.call_typed(params)
- .await
- .map_err(anyhow::Error::new)
- .with_context(|| "calling listpeerchannels")
- }
-}
-
-/// 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 `lsps2-policy-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 = JsonRpcResponse::success(
- Lsps2GetInfoResponse {
- opening_fee_params_menu,
- },
- 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)))
- }
-}
-
-pub struct Lsps2BuyHandler<A: ClnApi> {
- pub api: A,
- pub promise_secret: [u8; 32],
-}
-
-impl<A: ClnApi> Lsps2BuyHandler<A> {
- pub fn new(api: A, promise_secret: [u8; 32]) -> Self {
- Self {
- api,
- promise_secret,
- }
- }
-}
-
-#[async_trait]
-impl<A: ClnApi + 'static> RequestHandler for Lsps2BuyHandler<A> {
- async fn handle(&self, payload: &[u8]) -> core::result::Result<Vec<u8>, RpcError> {
- let (payload, peer_id) = unwrap_payload_with_peer_id(payload);
-
- let req: RequestObject<Lsps2BuyRequest> = 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 = JsonRpcResponse::success(
- 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,
- },
- 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)
-}
-
-pub struct HtlcAcceptedHookHandler<A: ClnApi> {
- api: A,
- htlc_minimum_msat: u64,
- backoff_listpeerchannels: Duration,
-}
-
-impl<A: ClnApi> HtlcAcceptedHookHandler<A> {
- pub fn new(api: A, htlc_minimum_msat: u64) -> Self {
- Self {
- api,
- htlc_minimum_msat,
- backoff_listpeerchannels: Duration::from_secs(10),
- }
- }
-
- pub async fn handle(&self, req: HtlcAcceptedRequest) -> AnyResult<HtlcAcceptedResponse> {
- let scid = match req.onion.short_channel_id {
- Some(scid) => scid,
- None => {
- // We are the final destination of this htlc.
- return Ok(HtlcAcceptedResponse::continue_(None, None, None));
- }
- };
-
- // A) Is this SCID one that we care about?
- let ds_req = ListdatastoreRequest {
- key: Some(scid_ds_key(scid)),
- };
- let ds_res = self.api.cln_listdatastore(&ds_req).await.map_err(|e| {
- warn!("Failed to listpeerchannels via rpc {}", e);
- RpcError::internal_error("Internal error")
- })?;
-
- let (ds_rec, ds_gen) = match deserialize_by_key(&ds_res, scid_ds_key(scid)) {
- Ok(r) => r,
- Err(DsError::NotFound { .. }) => {
- // We don't know the scid, continue.
- return Ok(HtlcAcceptedResponse::continue_(None, None, None));
- }
- Err(e @ DsError::MissingValue { .. })
- | Err(e @ DsError::HexDecode { .. })
- | Err(e @ DsError::JsonParse { .. }) => {
- // We have a data issue, log and continue.
- // Note: We may want to actually reject the htlc here or throw
- // an error alltogether but we will try to fulfill this htlc for
- // now.
- warn!("datastore issue: {}", e);
- return Ok(HtlcAcceptedResponse::continue_(None, None, None));
- }
- };
-
- // Fixme: Check that we don't have a channel yet with the peer that we await to
- // become READY to use.
- // ---
-
- // Fixme: We only accept no-mpp for now, mpp and other flows will be added later on
- // Fixme: We continue mpp for now to let the test mock handle the htlc, as we need
- // to test the client implementation for mpp payments.
- if ds_rec.expected_payment_size.is_some() {
- warn!("mpp payments are not implemented yet");
- return Ok(HtlcAcceptedResponse::continue_(None, None, None));
- // return Ok(HtlcAcceptedResponse::fail(
- // Some(UNKNOWN_NEXT_PEER.to_string()),
- // None,
- // ));
- }
-
- // B) Is the fee option menu still valid?
- let now = Utc::now();
- if now >= ds_rec.opening_fee_params.valid_until {
- // Not valid anymore, remove from DS and fail HTLC.
- let ds_req = DeldatastoreRequest {
- generation: ds_gen,
- key: scid_ds_key(scid),
- };
- match self.api.cln_deldatastore(&ds_req).await {
- Ok(_) => debug!("removed datastore for scid: {}, wasn't valid anymore", scid),
- Err(e) => warn!("could not remove datastore for scid: {}: {}", scid, e),
- };
- return Ok(HtlcAcceptedResponse::fail(
- Some(TEMPORARY_CHANNEL_FAILURE.to_string()),
- None,
- ));
- }
-
- // C) Is the amount in the boundaries of the fee menu?
- if req.htlc.amount_msat.msat() < ds_rec.opening_fee_params.min_fee_msat.msat()
- || req.htlc.amount_msat.msat() > ds_rec.opening_fee_params.max_payment_size_msat.msat()
- {
- // No! reject the HTLC.
- debug!("amount_msat for scid: {}, was too low or to high", scid);
- return Ok(HtlcAcceptedResponse::fail(
- Some(UNKNOWN_NEXT_PEER.to_string()),
- None,
- ));
- }
-
- // D) Check that the amount_msat covers the opening fee (only for non-mpp right now)
- let opening_fee = if let Some(opening_fee) = compute_opening_fee(
- req.htlc.amount_msat.msat(),
- ds_rec.opening_fee_params.min_fee_msat.msat(),
- ds_rec.opening_fee_params.proportional.ppm() as u64,
- ) {
- if opening_fee + self.htlc_minimum_msat >= req.htlc.amount_msat.msat() {
- debug!("amount_msat for scid: {}, does not cover opening fee", scid);
- return Ok(HtlcAcceptedResponse::fail(
- Some(UNKNOWN_NEXT_PEER.to_string()),
- None,
- ));
- }
- opening_fee
- } else {
- // The computation overflowed.
- debug!("amount_msat for scid: {}, was too low or to high", scid);
- return Ok(HtlcAcceptedResponse::fail(
- Some(UNKNOWN_NEXT_PEER.to_string()),
- None,
- ));
- };
-
- // E) We made it, open a channel to the peer.
- let ch_cap_req = Lsps2PolicyGetChannelCapacityRequest {
- opening_fee_params: ds_rec.opening_fee_params,
- init_payment_size: Msat::from_msat(req.htlc.amount_msat.msat()),
- scid,
- };
- let ch_cap_res = match self.api.lsps2_getchannelcapacity(&ch_cap_req).await {
- Ok(r) => r,
- Err(e) => {
- warn!("failed to get channel capacity for scid {}: {}", scid, e);
- return Ok(HtlcAcceptedResponse::fail(
- Some(UNKNOWN_NEXT_PEER.to_string()),
- None,
- ));
- }
- };
-
- let cap = match ch_cap_res.channel_capacity_msat {
- Some(c) => c,
- None => {
- debug!("policy giver does not allow channel for scid {}", scid);
- return Ok(HtlcAcceptedResponse::fail(
- Some(UNKNOWN_NEXT_PEER.to_string()),
- None,
- ));
- }
- };
-
- // We take the policy-giver seriously, if the capacity is too low, we
- // still try to open the channel.
- // Fixme: We may check that the capacity is ge than the
- // (amount_msat - opening fee) in the future.
- // Fixme: Make this configurable, maybe return the whole request from
- // the policy giver?
- let fund_ch_req = FundchannelRequest {
- announce: Some(false),
- close_to: None,
- compact_lease: None,
- feerate: None,
- minconf: None,
- mindepth: Some(0),
- push_msat: None,
- request_amt: None,
- reserve: None,
- channel_type: Some(vec![12, 46, 50]),
- utxos: None,
- amount: AmountOrAll::Amount(Amount::from_msat(cap)),
- id: ds_rec.peer_id,
- };
-
- let fund_ch_res = match self.api.cln_fundchannel(&fund_ch_req).await {
- Ok(r) => r,
- Err(e) => {
- // Fixme: Retry to fund the channel.
- warn!("could not fund jit channel for scid {}: {}", scid, e);
- return Ok(HtlcAcceptedResponse::fail(
- Some(UNKNOWN_NEXT_PEER.to_string()),
- None,
- ));
- }
- };
-
- // F) Wait for the peer to send `channel_ready`.
- // Fixme: Use event to check for channel ready,
- // Fixme: Check for htlc timeout if peer refuses to send "ready".
- // Fixme: handle unexpected channel states.
- let mut is_active = false;
- while !is_active {
- let ls_ch_req = ListpeerchannelsRequest {
- id: Some(ds_rec.peer_id),
- short_channel_id: None,
- };
- let ls_ch_res = match self.api.cln_listpeerchannels(&ls_ch_req).await {
- Ok(r) => r,
- Err(e) => {
- warn!("failed to fetch peer channels for scid {}: {}", scid, e);
- tokio::time::sleep(self.backoff_listpeerchannels).await;
- continue;
- }
- };
- let chs = ls_ch_res
- .channels
- .iter()
- .find(|&ch| ch.channel_id.is_some_and(|id| id == fund_ch_res.channel_id));
- if let Some(ch) = chs {
- debug!("jit channel for scid {} has state {:?}", scid, ch.state);
- if ch.state == ChannelState::CHANNELD_NORMAL {
- is_active = true;
- }
- }
- tokio::time::sleep(self.backoff_listpeerchannels).await;
- }
-
- // G) We got a working channel, deduct fee and forward htlc.
- let deducted_amt_msat = req.htlc.amount_msat.msat() - opening_fee;
- let mut payload = req.onion.payload.clone();
- payload.set_tu64(TLV_FORWARD_AMT, deducted_amt_msat);
-
- // It is okay to unwrap the next line as we do not have duplicate entries.
- let payload_bytes = payload.to_bytes().unwrap();
- debug!("about to send payload: {:02x?}", &payload_bytes);
-
- let mut extra_tlvs = req.htlc.extra_tlvs.unwrap_or_default().clone();
- extra_tlvs.set_u64(65537, opening_fee);
- let extra_tlvs_bytes = extra_tlvs.to_bytes().unwrap();
- debug!("extra_tlv: {:02x?}", extra_tlvs_bytes);
-
- Ok(HtlcAcceptedResponse::continue_(
- Some(payload_bytes),
- Some(fund_ch_res.channel_id.as_byte_array().to_vec()),
- Some(extra_tlvs_bytes),
- ))
- }
-}
-
-#[derive(Debug)]
-pub enum DsError {
- /// No datastore entry with this exact key.
- NotFound { key: Vec<String> },
- /// Entry existed but had neither `string` nor `hex`.
- MissingValue { key: Vec<String> },
- /// JSON parse failed (from `string` or decoded `hex`).
- JsonParse {
- key: Vec<String>,
- source: serde_json::Error,
- },
- /// Hex decode failed.
- HexDecode {
- key: Vec<String>,
- source: hex::FromHexError,
- },
-}
-
-impl fmt::Display for DsError {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- DsError::NotFound { key } => write!(f, "no datastore entry for key {:?}", key),
- DsError::MissingValue { key } => write!(
- f,
- "datastore entry had neither `string` nor `hex` for key {:?}",
- key
- ),
- DsError::JsonParse { key, source } => {
- write!(f, "failed to parse JSON at key {:?}: {}", key, source)
- }
- DsError::HexDecode { key, source } => {
- write!(f, "failed to decode hex at key {:?}: {}", key, source)
- }
- }
- }
-}
-
-impl std::error::Error for DsError {}
-
-fn scid_ds_key(scid: ShortChannelId) -> Vec<String> {
- vec![
- DS_MAIN_KEY.to_string(),
- DS_SUB_KEY.to_string(),
- scid.to_string(),
- ]
-}
-
-pub fn deserialize_by_key<K>(
- resp: &ListdatastoreResponse,
- key: K,
-) -> std::result::Result<(DatastoreEntry, Option<u64>), DsError>
-where
- K: AsRef<[String]>,
-{
- let wanted: &[String] = key.as_ref();
-
- let ds = resp
- .datastore
- .iter()
- .find(|d| d.key.as_slice() == wanted)
- .ok_or_else(|| DsError::NotFound {
- key: wanted.to_vec(),
- })?;
-
- // Prefer `string`, fall back to `hex`
- if let Some(s) = &ds.string {
- let value = serde_json::from_str::<DatastoreEntry>(s).map_err(|e| DsError::JsonParse {
- key: ds.key.clone(),
- source: e,
- })?;
- return Ok((value, ds.generation));
- }
-
- if let Some(hx) = &ds.hex {
- let bytes = hex::decode(hx).map_err(|e| DsError::HexDecode {
- key: ds.key.clone(),
- source: e,
- })?;
- let value =
- serde_json::from_slice::<DatastoreEntry>(&bytes).map_err(|e| DsError::JsonParse {
- key: ds.key.clone(),
- source: e,
- })?;
- return Ok((value, ds.generation));
- }
-
- Err(DsError::MissingValue {
- key: ds.key.clone(),
- })
-}
-
-#[cfg(test)]
-mod tests {
- use std::sync::{Arc, Mutex};
-
- use super::*;
- use crate::{
- lsps2::cln::{tlv::TlvStream, HtlcAcceptedResult},
- proto::{jsonrpc::JsonRpcRequest, lsps0::Ppm, lsps2::PolicyOpeningFeeParams},
- util::wrap_payload_with_peer_id,
- };
- use chrono::{TimeZone, Utc};
- use cln_rpc::{model::responses::ListdatastoreDatastore, RpcError as ClnRpcError};
- use cln_rpc::{
- model::responses::ListpeerchannelsChannels,
- primitives::{Amount, PublicKey, Sha256},
- };
- use serde::Serialize;
-
- 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<T: Serialize>(request: &RequestObject<T>) -> Vec<u8> {
- 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<Mutex<Option<Lsps2PolicyGetInfoResponse>>>,
- lsps2_getpolicy_error: Arc<Mutex<Option<ClnRpcError>>>,
- cln_getinfo_response: Arc<Mutex<Option<GetinfoResponse>>>,
- cln_getinfo_error: Arc<Mutex<Option<ClnRpcError>>>,
- cln_datastore_response: Arc<Mutex<Option<DatastoreResponse>>>,
- cln_datastore_error: Arc<Mutex<Option<ClnRpcError>>>,
- cln_listdatastore_response: Arc<Mutex<Option<ListdatastoreResponse>>>,
- cln_listdatastore_error: Arc<Mutex<Option<ClnRpcError>>>,
- cln_deldatastore_response: Arc<Mutex<Option<DeldatastoreResponse>>>,
- cln_deldatastore_error: Arc<Mutex<Option<ClnRpcError>>>,
- cln_fundchannel_response: Arc<Mutex<Option<FundchannelResponse>>>,
- cln_fundchannel_error: Arc<Mutex<Option<ClnRpcError>>>,
- cln_listpeerchannels_response: Arc<Mutex<Option<ListpeerchannelsResponse>>>,
- cln_listpeerchannels_error: Arc<Mutex<Option<ClnRpcError>>>,
- lsps2_getchannelcapacity_response:
- Arc<Mutex<Option<Lsps2PolicyGetChannelCapacityResponse>>>,
- lsps2_getchannelcapacity_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");
- }
-
- async fn lsps2_getchannelcapacity(
- &self,
- _params: &Lsps2PolicyGetChannelCapacityRequest,
- ) -> AnyResult<Lsps2PolicyGetChannelCapacityResponse> {
- if let Some(err) = self.lsps2_getchannelcapacity_error.lock().unwrap().take() {
- return Err(anyhow::Error::new(err).context("from fake api"));
- }
- if let Some(res) = self
- .lsps2_getchannelcapacity_response
- .lock()
- .unwrap()
- .take()
- {
- return Ok(res);
- }
- panic!("No lsps2 getchannelcapacity response defined");
- }
-
- async fn cln_getinfo(
- &self,
- _params: &GetinfoRequest,
- ) -> Result<GetinfoResponse, anyhow::Error> {
- 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<DatastoreResponse, anyhow::Error> {
- 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");
- }
-
- async fn cln_listdatastore(
- &self,
- _params: &ListdatastoreRequest,
- ) -> AnyResult<ListdatastoreResponse> {
- if let Some(err) = self.cln_listdatastore_error.lock().unwrap().take() {
- return Err(anyhow::Error::new(err).context("from fake api"));
- }
- if let Some(res) = self.cln_listdatastore_response.lock().unwrap().take() {
- return Ok(res);
- }
- panic!("No cln listdatastore response defined");
- }
-
- async fn cln_deldatastore(
- &self,
- _params: &DeldatastoreRequest,
- ) -> AnyResult<DeldatastoreResponse> {
- if let Some(err) = self.cln_deldatastore_error.lock().unwrap().take() {
- return Err(anyhow::Error::new(err).context("from fake api"));
- }
- if let Some(res) = self.cln_deldatastore_response.lock().unwrap().take() {
- return Ok(res);
- }
- panic!("No cln deldatastore response defined");
- }
-
- async fn cln_fundchannel(
- &self,
- _params: &FundchannelRequest,
- ) -> AnyResult<FundchannelResponse> {
- if let Some(err) = self.cln_fundchannel_error.lock().unwrap().take() {
- return Err(anyhow::Error::new(err).context("from fake api"));
- }
- if let Some(res) = self.cln_fundchannel_response.lock().unwrap().take() {
- return Ok(res);
- }
- panic!("No cln fundchannel response defined");
- }
-
- async fn cln_listpeerchannels(
- &self,
- _params: &ListpeerchannelsRequest,
- ) -> AnyResult<ListpeerchannelsResponse> {
- if let Some(err) = self.cln_listpeerchannels_error.lock().unwrap().take() {
- return Err(anyhow::Error::new(err).context("from fake api"));
- }
-
- if let Some(res) = self.cln_listpeerchannels_response.lock().unwrap().take() {
- return Ok(res);
- }
-
- // Default: return a ready channel
- let channel = ListpeerchannelsChannels {
- channel_id: Some(*Sha256::from_bytes_ref(&[1u8; 32])),
- state: ChannelState::CHANNELD_NORMAL,
- peer_id: create_peer_id(),
- peer_connected: true,
- alias: None,
- closer: None,
- funding: None,
- funding_outnum: None,
- funding_txid: None,
- htlcs: None,
- in_offered_msat: None,
- initial_feerate: None,
- last_feerate: None,
- last_stable_connection: None,
- last_tx_fee_msat: None,
- lost_state: None,
- max_accepted_htlcs: None,
- minimum_htlc_in_msat: None,
- next_feerate: None,
- next_fee_step: None,
- out_fulfilled_msat: None,
- out_offered_msat: None,
- owner: None,
- private: None,
- receivable_msat: None,
- reestablished: None,
- scratch_txid: None,
- short_channel_id: None,
- spendable_msat: None,
- status: None,
- their_reserve_msat: None,
- to_us_msat: None,
- total_msat: None,
- close_to: None,
- close_to_addr: None,
- direction: None,
- dust_limit_msat: None,
- fee_base_msat: None,
- fee_proportional_millionths: None,
- feerate: None,
- ignore_fee_limits: None,
- in_fulfilled_msat: None,
- in_payments_fulfilled: None,
- in_payments_offered: None,
- max_to_us_msat: None,
- maximum_htlc_out_msat: None,
- min_to_us_msat: None,
- minimum_htlc_out_msat: None,
- our_max_htlc_value_in_flight_msat: None,
- our_reserve_msat: None,
- our_to_self_delay: None,
- out_payments_fulfilled: None,
- out_payments_offered: None,
- their_max_htlc_value_in_flight_msat: None,
- their_to_self_delay: None,
- updates: None,
- inflight: None,
- #[allow(deprecated)]
- max_total_htlc_in_msat: None,
- opener: cln_rpc::primitives::ChannelSide::LOCAL,
- };
-
- Ok(ListpeerchannelsResponse {
- channels: vec![channel],
- })
- }
- }
-
- fn create_test_htlc_request(
- scid: Option<ShortChannelId>,
- amount_msat: u64,
- ) -> HtlcAcceptedRequest {
- let payload = TlvStream::default();
-
- HtlcAcceptedRequest {
- onion: crate::lsps2::cln::Onion {
- short_channel_id: scid,
- payload,
- next_onion: vec![],
- forward_msat: None,
- outgoing_cltv_value: None,
- shared_secret: vec![],
- total_msat: None,
- type_: None,
- },
- htlc: crate::lsps2::cln::Htlc {
- amount_msat: Amount::from_msat(amount_msat),
- cltv_expiry: 100,
- cltv_expiry_relative: 10,
- payment_hash: vec![],
- extra_tlvs: None,
- short_channel_id: ShortChannelId::from(123456789u64),
- id: 0,
- },
- forward_to: None,
- }
- }
-
- fn create_test_datastore_entry(
- peer_id: PublicKey,
- expected_payment_size: Option<Msat>,
- ) -> DatastoreEntry {
- let (_, policy) = params_with_promise(&[0u8; 32]);
- DatastoreEntry {
- peer_id,
- opening_fee_params: policy,
- expected_payment_size,
- }
- }
-
- 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]
- 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();
- let payload = create_wrapped_request(&request);
-
- let result = handler.handle(&payload).await.unwrap();
- let response: JsonRpcResponse<Lsps2GetInfoResponse> =
- serde_json::from_slice(&result).unwrap();
- let response = response.into_result().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();
- 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"));
- }
-
- #[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();
- let payload = create_wrapped_request(&req);
-
- let out = handler.handle(&payload).await.unwrap();
- let resp: JsonRpcResponse<Lsps2BuyResponse> = serde_json::from_slice(&out).unwrap();
- let resp = resp.into_result().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();
- let payload = create_wrapped_request(&req);
-
- let out = handler.handle(&payload).await.unwrap();
- let resp: JsonRpcResponse<Lsps2BuyResponse> = serde_json::from_slice(&out).unwrap();
- assert!(resp.into_result().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();
- 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();
- 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();
-
- 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();
-
- let err = handler
- .handle(&create_wrapped_request(&req))
- .await
- .unwrap_err();
- assert_eq!(err.code, 203);
- }
- #[tokio::test]
- async fn test_htlc_no_scid_continues() {
- let fake = FakeCln::default();
- let handler = HtlcAcceptedHookHandler::new(fake, 1000);
-
- // HTLC with no short_channel_id (final destination)
- let req = create_test_htlc_request(None, 1000000);
-
- let result = handler.handle(req).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Continue);
- }
-
- #[tokio::test]
- async fn test_htlc_unknown_scid_continues() {
- let fake = FakeCln::default();
- let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
- let scid = ShortChannelId::from(123456789u64);
-
- // Return empty datastore response (SCID not found)
- *fake.cln_listdatastore_response.lock().unwrap() =
- Some(ListdatastoreResponse { datastore: vec![] });
-
- let req = create_test_htlc_request(Some(scid), 1000000);
-
- let result = handler.handle(req).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Continue);
- }
-
- #[tokio::test]
- async fn test_htlc_expired_fee_menu_fails() {
- let fake = FakeCln::default();
- let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
- let peer_id = create_peer_id();
- let scid = ShortChannelId::from(123456789u64);
-
- // Create datastore entry with expired fee menu
- let mut ds_entry = create_test_datastore_entry(peer_id, None);
- ds_entry.opening_fee_params.valid_until =
- Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(); // expired
-
- let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
- *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
- datastore: vec![ListdatastoreDatastore {
- key: scid_ds_key(scid),
- generation: Some(1),
- hex: None,
- string: Some(ds_entry_json),
- }],
- });
-
- // Mock successful deletion
- *fake.cln_deldatastore_response.lock().unwrap() = Some(DeldatastoreResponse {
- generation: Some(1),
- hex: None,
- string: None,
- key: scid_ds_key(scid),
- });
-
- let req = create_test_htlc_request(Some(scid), 1000000);
-
- let result = handler.handle(req).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- TEMPORARY_CHANNEL_FAILURE.to_string()
- );
- }
-
- #[tokio::test]
- async fn test_htlc_amount_too_low_fails() {
- let fake = FakeCln::default();
- let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
- let peer_id = create_peer_id();
- let scid = ShortChannelId::from(123456789u64);
-
- let ds_entry = create_test_datastore_entry(peer_id, None);
- let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
-
- *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
- datastore: vec![ListdatastoreDatastore {
- key: scid_ds_key(scid),
- generation: Some(1),
- hex: None,
- string: Some(ds_entry_json),
- }],
- });
-
- // HTLC amount below minimum
- let req = create_test_htlc_request(Some(scid), 100);
-
- let result = handler.handle(req).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- UNKNOWN_NEXT_PEER.to_string()
- );
- }
-
- #[tokio::test]
- async fn test_htlc_amount_too_high_fails() {
- let fake = FakeCln::default();
- let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
- let peer_id = create_peer_id();
- let scid = ShortChannelId::from(123456789u64);
-
- let ds_entry = create_test_datastore_entry(peer_id, None);
- let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
-
- *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
- datastore: vec![ListdatastoreDatastore {
- key: scid_ds_key(scid),
- generation: Some(1),
- hex: None,
- string: Some(ds_entry_json),
- }],
- });
-
- // HTLC amount above maximum
- let req = create_test_htlc_request(Some(scid), 200_000_000);
-
- let result = handler.handle(req).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- UNKNOWN_NEXT_PEER.to_string()
- );
- }
-
- #[tokio::test]
- async fn test_htlc_amount_doesnt_cover_fee_fails() {
- let fake = FakeCln::default();
- let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
- let peer_id = create_peer_id();
- let scid = ShortChannelId::from(123456789u64);
-
- let ds_entry = create_test_datastore_entry(peer_id, None);
- let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
-
- *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
- datastore: vec![ListdatastoreDatastore {
- key: scid_ds_key(scid),
- generation: Some(1),
- hex: None,
- string: Some(ds_entry_json),
- }],
- });
-
- // HTLC amount just barely covers minimum fee but not minimum HTLC
- let req = create_test_htlc_request(Some(scid), 2500); // min_fee is 2000, htlc_minimum is 1000
-
- let result = handler.handle(req).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- UNKNOWN_NEXT_PEER.to_string()
- );
- }
-
- #[tokio::test]
- async fn test_htlc_channel_capacity_request_fails() {
- let fake = FakeCln::default();
- let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
- let peer_id = create_peer_id();
- let scid = ShortChannelId::from(123456789u64);
-
- let ds_entry = create_test_datastore_entry(peer_id, None);
- let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
-
- *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
- datastore: vec![ListdatastoreDatastore {
- key: scid_ds_key(scid),
- generation: Some(1),
- hex: None,
- string: Some(ds_entry_json),
- }],
- });
-
- *fake.lsps2_getchannelcapacity_error.lock().unwrap() = Some(ClnRpcError {
- code: Some(-1),
- message: "capacity check failed".to_string(),
- data: None,
- });
-
- let req = create_test_htlc_request(Some(scid), 10_000_000);
-
- let result = handler.handle(req).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- UNKNOWN_NEXT_PEER.to_string()
- );
- }
-
- #[tokio::test]
- async fn test_htlc_policy_denies_channel() {
- let fake = FakeCln::default();
- let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
- let peer_id = create_peer_id();
- let scid = ShortChannelId::from(123456789u64);
-
- let ds_entry = create_test_datastore_entry(peer_id, None);
- let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
-
- *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
- datastore: vec![ListdatastoreDatastore {
- key: scid_ds_key(scid),
- generation: Some(1),
- hex: None,
- string: Some(ds_entry_json),
- }],
- });
-
- // Policy response with no channel capacity (denied)
- *fake.lsps2_getchannelcapacity_response.lock().unwrap() =
- Some(Lsps2PolicyGetChannelCapacityResponse {
- channel_capacity_msat: None,
- });
-
- let req = create_test_htlc_request(Some(scid), 10_000_000);
-
- let result = handler.handle(req).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- UNKNOWN_NEXT_PEER.to_string()
- );
- }
-
- #[tokio::test]
- async fn test_htlc_fund_channel_fails() {
- let fake = FakeCln::default();
- let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
- let peer_id = create_peer_id();
- let scid = ShortChannelId::from(123456789u64);
-
- let ds_entry = create_test_datastore_entry(peer_id, None);
- let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
-
- *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
- datastore: vec![ListdatastoreDatastore {
- key: scid_ds_key(scid),
- generation: Some(1),
- hex: None,
- string: Some(ds_entry_json),
- }],
- });
-
- *fake.lsps2_getchannelcapacity_response.lock().unwrap() =
- Some(Lsps2PolicyGetChannelCapacityResponse {
- channel_capacity_msat: Some(50_000_000),
- });
-
- *fake.cln_fundchannel_error.lock().unwrap() = Some(ClnRpcError {
- code: Some(-1),
- message: "insufficient funds".to_string(),
- data: None,
- });
-
- let req = create_test_htlc_request(Some(scid), 10_000_000);
-
- let result = handler.handle(req).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- UNKNOWN_NEXT_PEER.to_string()
- );
- }
-
- #[tokio::test]
- async fn test_htlc_successful_flow() {
- let fake = FakeCln::default();
- let handler = HtlcAcceptedHookHandler {
- api: fake.clone(),
- htlc_minimum_msat: 1000,
- backoff_listpeerchannels: Duration::from_millis(10),
- };
- let peer_id = create_peer_id();
- let scid = ShortChannelId::from(123456789u64);
-
- let ds_entry = create_test_datastore_entry(peer_id, None);
- let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
-
- *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
- datastore: vec![ListdatastoreDatastore {
- key: scid_ds_key(scid),
- generation: Some(1),
- hex: None,
- string: Some(ds_entry_json),
- }],
- });
-
- *fake.lsps2_getchannelcapacity_response.lock().unwrap() =
- Some(Lsps2PolicyGetChannelCapacityResponse {
- channel_capacity_msat: Some(50_000_000),
- });
-
- *fake.cln_fundchannel_response.lock().unwrap() = Some(FundchannelResponse {
- channel_id: *Sha256::from_bytes_ref(&[1u8; 32]),
- outnum: 0,
- txid: String::default(),
- channel_type: None,
- close_to: None,
- mindepth: None,
- tx: String::default(),
- });
-
- let req = create_test_htlc_request(Some(scid), 10_000_000);
-
- let result = handler.handle(req).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Continue);
-
- assert!(result.payload.is_some());
- assert!(result.extra_tlvs.is_some());
- assert!(result.forward_to.is_some());
-
- // The payload should have the deducted amount
- let payload_bytes = result.payload.unwrap();
- let payload_tlv = TlvStream::from_bytes(&payload_bytes).unwrap();
-
- // Should contain forward amount.
- assert!(payload_tlv.get(TLV_FORWARD_AMT).is_some());
- }
-
- #[tokio::test]
- #[ignore] // We deactivate the mpp check on the experimental server for
- // client side checks.
- async fn test_htlc_mpp_not_implemented() {
- let fake = FakeCln::default();
- let handler = HtlcAcceptedHookHandler::new(fake.clone(), 1000);
- let peer_id = create_peer_id();
- let scid = ShortChannelId::from(123456789u64);
-
- // Create entry with expected_payment_size (MPP mode)
- let mut ds_entry = create_test_datastore_entry(peer_id, None);
- ds_entry.expected_payment_size = Some(Msat::from_msat(1000000));
- let ds_entry_json = serde_json::to_string(&ds_entry).unwrap();
-
- *fake.cln_listdatastore_response.lock().unwrap() = Some(ListdatastoreResponse {
- datastore: vec![ListdatastoreDatastore {
- key: scid_ds_key(scid),
- generation: Some(1),
- hex: None,
- string: Some(ds_entry_json),
- }],
- });
-
- let req = create_test_htlc_request(Some(scid), 10_000_000);
-
- let result = handler.handle(req).await.unwrap();
- assert_eq!(result.result, HtlcAcceptedResult::Fail);
- assert_eq!(
- result.failure_message.unwrap(),
- UNKNOWN_NEXT_PEER.to_string()
- );
- }
-}
diff --git a/plugins/lsps-plugin/src/lsps2/mod.rs b/plugins/lsps-plugin/src/lsps2/mod.rs
index 9d06dbec..7cb16532 100644
--- a/plugins/lsps-plugin/src/lsps2/mod.rs
+++ b/plugins/lsps-plugin/src/lsps2/mod.rs
@@ -1,7 +1,6 @@
use cln_plugin::options;
pub mod cln;
-pub mod handler;
pub const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag(
"experimental-lsps2-service",
diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs
index 1153efa1..c87cddc5 100644
--- a/plugins/lsps-plugin/src/service.rs
+++ b/plugins/lsps-plugin/src/service.rs
@@ -1,32 +1,31 @@
-use anyhow::{anyhow, bail};
-use async_trait::async_trait;
-use cln_lsps::core::{
- server::{JsonRpcResponseWriter, JsonRpcServer},
- transport::{Error, Result as TransportResult},
+use anyhow::bail;
+use cln_lsps::{
+ cln_adapters::service::{on_custommsg_service, ServiceStore},
+ core::{
+ lsps2::handler::{ClnApiRpc, HtlcAcceptedHookHandler, Lsps2ServiceHandler},
+ server::LspsService,
+ },
+ lsps2::{
+ self,
+ cln::{HtlcAcceptedRequest, HtlcAcceptedResponse},
+ },
};
-use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler;
-use cln_lsps::lsps0::transport::{self, CustomMsg};
-use cln_lsps::lsps2;
-use cln_lsps::lsps2::cln::{HtlcAcceptedRequest, HtlcAcceptedResponse};
-use cln_lsps::lsps2::handler::{ClnApiRpc, HtlcAcceptedHookHandler};
-use cln_lsps::proto::jsonrpc::JsonRpcRequest as _;
-use cln_lsps::proto::lsps0::{Lsps0listProtocolsRequest, LSPS0_MESSAGE_TYPE};
-use cln_lsps::proto::lsps2::{Lsps2BuyRequest, Lsps2GetInfoRequest};
-use cln_lsps::util::wrap_payload_with_peer_id;
use cln_plugin::Plugin;
-use cln_rpc::notifications::CustomMsgNotification;
-use cln_rpc::primitives::PublicKey;
-use log::debug;
-use std::path::{Path, PathBuf};
-use std::str::FromStr;
+use std::path::Path;
use std::sync::Arc;
#[derive(Clone)]
struct State {
- lsps_service: JsonRpcServer,
+ lsps_service: Arc<LspsService>,
lsps2_enabled: bool,
}
+impl ServiceStore for State {
+ fn service(&self) -> Arc<LspsService> {
+ self.lsps_service.clone()
+ }
+}
+
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout())
@@ -44,7 +43,7 @@ async fn main() -> Result<(), anyhow::Error> {
// cln_plugin::FeatureBitsKind::Init,
// util::feature_bit_to_hex(LSP_FEATURE_BIT),
// )
- .hook("custommsg", on_custommsg)
+ .hook("custommsg", on_custommsg_service)
.hook("htlc_accepted", on_htlc_accepted)
.configure()
.await?
@@ -81,28 +80,15 @@ async fn main() -> Result<(), anyhow::Error> {
}
};
- let mut lsps_builder = JsonRpcServer::builder().with_handler(
- Lsps0listProtocolsRequest::METHOD.to_string(),
- Arc::new(Lsps0ListProtocolsHandler {
- lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?,
- }),
- );
+ let cln_api_rpc = ClnApiRpc::new(rpc_path);
+ let lsps2_handler =
+ Arc::new(Lsps2ServiceHandler::new(Arc::new(cln_api_rpc), &secret));
- let cln_api_rpc = lsps2::handler::ClnApiRpc::new(rpc_path);
- 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));
-
- let lsps_service = lsps_builder.build();
+ let lsps_service_builder = LspsService::builder();
+ let lsps_service = lsps_service_builder.with_protocol(lsps2_handler).build();
let state = State {
- lsps_service,
+ lsps_service: Arc::new(lsps_service),
lsps2_enabled: true,
};
let plugin = plugin.start(state).await?;
@@ -140,56 +126,3 @@ async fn on_htlc_accepted(
let res_val = serde_json::to_value(&res)?;
Ok(res_val)
}
-
-async fn on_custommsg(
- p: Plugin<State>,
- v: serde_json::Value,
-) -> Result<serde_json::Value, anyhow::Error> {
- // All of this could be done async if needed.
- let continue_response = Ok(serde_json::json!({
- "result": "continue"
- }));
- let msg: CustomMsgNotification =
- serde_json::from_value(v).map_err(|e| anyhow!("invalid custommsg: {e}"))?;
-
- let req = CustomMsg::from_str(&msg.payload).map_err(|e| anyhow!("invalid payload {e}"))?;
- if req.message_type != LSPS0_MESSAGE_TYPE {
- // We don't care if this is not for us!
- return continue_response;
- }
-
- let dir = p.configuration().lightning_dir;
- let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file);
- let mut writer = LspsResponseWriter {
- peer_id: msg.peer_id,
- rpc_path: rpc_path.try_into()?,
- };
-
- // The payload inside CustomMsg is the actual JSON-RPC
- // request/notification, we wrap it to attach the peer_id as well.
- let payload = wrap_payload_with_peer_id(&req.payload, msg.peer_id);
-
- let service = p.state().lsps_service.clone();
- match service.handle_message(&payload, &mut writer).await {
- Ok(_) => continue_response,
- Err(e) => {
- debug!("failed to handle lsps message: {}", e);
- continue_response
- }
- }
-}
-
-pub struct LspsResponseWriter {
- peer_id: PublicKey,
- rpc_path: PathBuf,
-}
-
-#[async_trait]
-impl JsonRpcResponseWriter for LspsResponseWriter {
- async fn write(&mut self, payload: &[u8]) -> TransportResult<()> {
- let mut client = cln_rpc::ClnRpc::new(&self.rpc_path)
- .await
- .map_err(|e| Error::Internal(e.to_string()))?;
- transport::send_custommsg(&mut client, payload, &self.peer_id).await
- }
-}
Why this scored 14/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.