From ea5635c4c816b263c81ce5f838dd823205240a8a Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Fri, 14 Mar 2025 18:49:56 +0100 Subject: [PATCH] lsps: Implement JSON-RPC message structure Signed-off-by: Peter Neuroth --- Cargo.lock | 8 + Cargo.toml | 11 +- plugins/.gitignore | 1 + plugins/Makefile | 3 +- plugins/lsps-plugin/Cargo.toml | 8 + plugins/lsps-plugin/src/jsonrpc/mod.rs | 449 +++++++++++++++++++++++++ plugins/lsps-plugin/src/lib.rs | 1 + plugins/lsps-plugin/src/main.rs | 3 + 8 files changed, 478 insertions(+), 6 deletions(-) create mode 100644 plugins/lsps-plugin/Cargo.toml create mode 100644 plugins/lsps-plugin/src/jsonrpc/mod.rs create mode 100644 plugins/lsps-plugin/src/lib.rs create mode 100644 plugins/lsps-plugin/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 70a0b3799..81a8393c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -415,6 +415,14 @@ dependencies = [ "tonic", ] +[[package]] +name = "cln-lsps" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "cln-plugin" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index ae9afb7bc..a24609f9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,10 @@ strip = "debuginfo" [workspace] resolver = "2" members = [ - "cln-rpc", - "cln-grpc", - "plugins", - "plugins/grpc-plugin", - "plugins/rest-plugin" + "cln-rpc", + "cln-grpc", + "plugins", + "plugins/grpc-plugin", + "plugins/rest-plugin", + "plugins/lsps-plugin", ] diff --git a/plugins/.gitignore b/plugins/.gitignore index ae3fd1fb9..669c440ce 100644 --- a/plugins/.gitignore +++ b/plugins/.gitignore @@ -20,3 +20,4 @@ cln-askrene recklessrpc exposesecret cln-xpay +cln-lsps diff --git a/plugins/Makefile b/plugins/Makefile index 747f629e1..1110452fa 100644 --- a/plugins/Makefile +++ b/plugins/Makefile @@ -148,7 +148,7 @@ plugins/cln-grpc: target/${RUST_PROFILE}/cln-grpc plugins/clnrest: target/${RUST_PROFILE}/clnrest @cp $< $@ -PLUGINS += plugins/cln-grpc plugins/clnrest +PLUGINS += plugins/cln-grpc plugins/clnrest plugins/cln-lsps endif PLUGIN_COMMON_OBJS := \ @@ -300,6 +300,7 @@ CLN_PLUGIN_EXAMPLES := \ CLN_PLUGIN_SRC = $(shell find plugins/src -name "*.rs") CLN_GRPC_PLUGIN_SRC = $(shell find plugins/grpc-plugin/src -name "*.rs") CLN_REST_PLUGIN_SRC = $(shell find plugins/rest-plugin/src -name "*.rs") +CLN_LSPS_PLUGIN_SRC = $(shell find plugins/lsps-plugin/src -name "*.rs") target/${RUST_PROFILE}/cln-grpc: ${CLN_PLUGIN_SRC} ${CLN_GRPC_PLUGIN_SRC} $(MSGGEN_GENALL) $(MSGGEN_GEN_ALL) cargo build ${CARGO_OPTS} --bin cln-grpc diff --git a/plugins/lsps-plugin/Cargo.toml b/plugins/lsps-plugin/Cargo.toml new file mode 100644 index 000000000..f265571ba --- /dev/null +++ b/plugins/lsps-plugin/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "cln-lsps" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/plugins/lsps-plugin/src/jsonrpc/mod.rs b/plugins/lsps-plugin/src/jsonrpc/mod.rs new file mode 100644 index 000000000..354f9b34d --- /dev/null +++ b/plugins/lsps-plugin/src/jsonrpc/mod.rs @@ -0,0 +1,449 @@ +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use serde_json::{self, Value}; +use std::fmt; + +// Constants for JSON-RPC error codes +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; +const INTERNAL_ERROR: i64 = -32603; + +/// Trait to convert a struct into a JSON-RPC RequestObject. +pub trait JsonRpcRequest: Serialize { + const METHOD: &'static str; + fn into_request(self, id: impl Into>) -> RequestObject + where + Self: Sized, + { + RequestObject { + jsonrpc: "2.0".into(), + method: Self::METHOD.into(), + params: Some(self), + id: id.into(), + } + } +} + +/// Trait for converting JSON-RPC responses into typed results. +pub trait JsonRpcResponse +where + T: DeserializeOwned, +{ + fn into_response(self, id: String) -> ResponseObject + where + Self: Sized + DeserializeOwned, + { + ResponseObject { + jsonrpc: "2.0".into(), + id: id.into(), + result: Some(self), + error: None, + } + } + + fn from_response(resp: ResponseObject) -> Result { + match (resp.result, resp.error) { + (Some(result), None) => Ok(result), + (None, Some(error)) => Err(error), + _ => Err(RpcError { + code: -32603, + message: "Invalid response format".into(), + data: None, + }), + } + } +} + +/// Automatically implements the `JsonRpcResponse` trait for all types that +/// implement `DeserializeOwned`. This simplifies creating JSON-RPC services, +/// as you only need to define data structures that can be deserialized. +impl JsonRpcResponse for T where T: DeserializeOwned {} + +/// # RequestObject +/// +/// Represents a JSON-RPC 2.0 Request object, as defined in section 4 of the +/// specification. This structure encapsulates all necessary information for +/// a remote procedure call. +/// +/// # Type Parameters +/// +/// * `T`: The type of the `params` field. This *MUST* implement `Serialize` +/// to allow it to be encoded as JSON. Typically this will be a struct +/// implementing the `JsonRpcRequest` trait. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RequestObject +where + T: Serialize, +{ + /// **REQUIRED**. MUST be `"2.0"`. + pub jsonrpc: String, + /// **REQUIRED**. The method to be invoked. + pub method: String, + /// A struct containing the method parameters. + #[serde(skip_serializing_if = "is_none_or_null")] + pub params: Option, + /// An identifier established by the Client that MUST contain a String. + /// # Note: this is special to LSPS0, might change to match the more general + /// JSON-RPC 2.0 sepec if needed. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +impl RequestObject +where + T: Serialize, +{ + /// Returns the inner data object contained by params for handling or future + /// processing. + pub fn into_inner(self) -> Option { + self.params + } +} + +/// Helper function to check if params is None or would serialize to null. +fn is_none_or_null(opt: &Option) -> bool { + match opt { + None => true, + Some(val) => match serde_json::to_value(&val) { + Ok(Value::Null) => true, + _ => false, + }, + } +} + +/// # ResponseObject +/// +/// Represents a JSON-RPC 2.0 Response object, as defined in section 5.0 of the +/// specification. This structure encapsulates either a successful result or +/// an error. +/// +/// # Type Parameters +/// +/// * `T`: The type of the `result` field, which will be returned upon a +/// succesful execution of the procedure. *MUST* implement both `Serialize` +/// (to allow construction of responses) and `DeserializeOwned` (to allow +/// receipt and parsing of responses). +#[derive(Debug, Serialize, Deserialize)] +#[serde(bound = "T: Serialize + DeserializeOwned")] +pub struct ResponseObject +where + T: DeserializeOwned, +{ + /// **REQUIRED**. MUST be `"2.0"`. + jsonrpc: String, + /// **REQUIRED**. The identifier of the original request this is a response. + id: String, + /// **REQUIRED on success**. The data if there is a request and non-errored. + /// MUST be `null` if there was an error. + result: Option, + /// **REQUIRED on error** An error type if there was a failure. + error: Option, +} + +impl ResponseObject +where + T: DeserializeOwned + Serialize, +{ + /// Returns a potential data (result) if the code execution passed else it + /// returns with RPC error, data (error details) if there was + pub fn into_inner(self) -> Result { + T::from_response(self) + } +} + +/// # RpcError +/// +/// Represents an error object in a JSON-RPC 2.0 Response object (section 5.1). +/// Provides structured information about an error that occurred during the +/// method invocation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RpcError { + /// **REQUIRED**. An integer indicating the type of error. + pub code: i64, + /// **REQUIRED**. A string containing a short description of the error. + pub message: String, + /// A primitive that can be either Primitive or Structured type if there + /// were. + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl RpcError { + pub fn into_response(self, id: String) -> ResponseObject { + ResponseObject { + jsonrpc: "2.0".into(), + id: id.into(), + result: None, + error: Some(self), + } + } +} + +impl RpcError { + /// Reserved for implementation-defined server-errors. + pub fn custom_error(code: i64, message: T) -> Self { + RpcError { + code, + message: message.to_string(), + data: None, + } + } + + /// Reserved for implementation-defined server-errors. + pub fn custom_error_with_data( + code: i64, + message: T, + data: serde_json::Value, + ) -> Self { + RpcError { + code, + message: message.to_string(), + data: Some(data), + } + } + + /// Invalid JSON was received by the server. + /// An error occurred on the server while parsing the JSON text. + pub fn parse_error(message: T) -> Self { + Self::custom_error(PARSE_ERROR, message) + } + + /// Invalid JSON was received by the server. + /// An error occurred on the server while parsing the JSON text. + pub fn parse_error_with_data( + message: T, + data: serde_json::Value, + ) -> Self { + Self::custom_error_with_data(PARSE_ERROR, message, data) + } + + /// The JSON sent is not a valid Request object. + pub fn invalid_request(message: T) -> Self { + Self::custom_error(INVALID_REQUEST, message) + } + + /// The JSON sent is not a valid Request object. + pub fn invalid_request_with_data( + message: T, + data: serde_json::Value, + ) -> Self { + Self::custom_error_with_data(INVALID_REQUEST, message, data) + } + + /// The method does not exist / is not available. + pub fn method_not_found(message: T) -> Self { + Self::custom_error(METHOD_NOT_FOUND, message) + } + + /// The method does not exist / is not available. + pub fn method_not_found_with_data( + message: T, + data: serde_json::Value, + ) -> Self { + Self::custom_error_with_data(METHOD_NOT_FOUND, message, data) + } + + /// Invalid method parameter(s). + pub fn invalid_params(message: T) -> Self { + Self::custom_error(INVALID_PARAMS, message) + } + + /// Invalid method parameter(s). + pub fn invalid_params_with_data( + message: T, + data: serde_json::Value, + ) -> Self { + Self::custom_error_with_data(INVALID_PARAMS, message, data) + } + + /// Internal JSON-RPC error. + pub fn internal_error(message: T) -> Self { + Self::custom_error(INTERNAL_ERROR, message) + } + + /// Internal JSON-RPC error. + pub fn internal_error_with_data( + message: T, + data: serde_json::Value, + ) -> Self { + Self::custom_error_with_data(INTERNAL_ERROR, message, data) + } +} + +impl fmt::Display for RpcError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "JSON-RPC Error (code: {}, message: {}, data: {:?})", + self.code, self.message, self.data + ) + } +} + +impl std::error::Error for RpcError {} + +#[cfg(test)] +mod test_message_serialization { + use super::*; + use serde_json::json; + + #[test] + fn test_empty_params_serialization() { + // Empty params should serialize to `"params":{}` instead of + // `"params":null`. + #[derive(Debug, Serialize, Deserialize)] + pub struct SayHelloRequest; + impl JsonRpcRequest for SayHelloRequest { + const METHOD: &'static str = "say_hello"; + } + let rpc_request = SayHelloRequest.into_request(Some("unique-id-123".into())); + assert!(!serde_json::to_string(&rpc_request) + .expect("could not convert to json") + .contains("\"params\"")); + } + + #[test] + fn test_request_serialization_and_deserialization() { + // Ensure that we correctly serialize to a valid JSON-RPC 2.0 request. + #[derive(Default, Debug, Serialize, Deserialize)] + pub struct SayNameRequest { + name: String, + age: i32, + } + impl JsonRpcRequest for SayNameRequest { + const METHOD: &'static str = "say_name"; + } + let rpc_request = SayNameRequest { + name: "Satoshi".to_string(), + age: 99, + } + .into_request(Some("unique-id-123".into())); + + let json_value: serde_json::Value = serde_json::to_value(&rpc_request).unwrap(); + let expected_value: serde_json::Value = serde_json::json!({ + "jsonrpc": "2.0", + "method": "say_name", + "params": { + "name": "Satoshi", + "age": 99 + }, + "id": "unique-id-123" + }); + assert_eq!(json_value, expected_value); + + let request: RequestObject = serde_json::from_value(json_value).unwrap(); + assert_eq!(request.method, "say_name"); + assert_eq!(request.jsonrpc, "2.0"); + + let request: RequestObject = + serde_json::from_value(expected_value).unwrap(); + let inner = request.into_inner(); + assert_eq!(inner.unwrap().name, rpc_request.params.unwrap().name); + } + + #[test] + fn test_response_deserialization() { + // Check that we can convert a JSON-RPC response into a typed result. + #[derive(Debug, Serialize, Deserialize, PartialEq)] + pub struct SayNameResponse { + name: String, + age: i32, + message: String, + } + + let json_response = r#" + { + "jsonrpc": "2.0", + "result": { + "age": 99, + "message": "Hello Satoshi!", + "name": "Satoshi" + }, + "id": "unique-id-123" + }"#; + + let response_object: ResponseObject = + serde_json::from_str(json_response).unwrap(); + + let response: SayNameResponse = response_object.into_inner().unwrap(); + let expected_response = SayNameResponse { + name: "Satoshi".into(), + age: 99, + message: "Hello Satoshi!".into(), + }; + + assert_eq!(response, expected_response); + } + + #[test] + fn test_empty_result() { + // Check that we correctly deserialize an empty result. + #[derive(Debug, Serialize, Deserialize, PartialEq)] + pub struct DummyResponse {} + + let json_response = r#" + { + "jsonrpc": "2.0", + "result": {}, + "id": "unique-id-123" + }"#; + + let response_object: ResponseObject = + serde_json::from_str(json_response).unwrap(); + + let response: DummyResponse = response_object.into_inner().unwrap(); + let expected_response = DummyResponse {}; + + assert_eq!(response, expected_response); + } + #[test] + fn test_error_deserialization() { + // Check that we deserialize an error if we got one. + #[derive(Debug, Serialize, Deserialize, PartialEq)] + pub struct DummyResponse {} + + let json_response = r#" + { + "jsonrpc": "2.0", + "id": "unique-id-123", + "error": { + "code": -32099, + "message": "something bad happened", + "data": { + "f1": "v1", + "f2": 2 + } + } + }"#; + + let response_object: ResponseObject = + serde_json::from_str(json_response).unwrap(); + + let response = response_object.into_inner(); + let err = response.unwrap_err(); + assert_eq!(err.code, -32099); + assert_eq!(err.message, "something bad happened"); + assert_eq!( + err.data, + serde_json::from_str("{\"f1\":\"v1\",\"f2\":2}").unwrap() + ); + } + + #[test] + fn test_error_serialization() { + let error = RpcError::invalid_request("Invalid request"); + let serialized = serde_json::to_string(&error).unwrap(); + assert_eq!(serialized, r#"{"code":-32600,"message":"Invalid request"}"#); + + let error_with_data = RpcError::internal_error_with_data( + "Internal server error", + json!({"details": "Something went wrong"}), + ); + let serialized_with_data = serde_json::to_string(&error_with_data).unwrap(); + assert_eq!( + serialized_with_data, + r#"{"code":-32603,"message":"Internal server error","data":{"details":"Something went wrong"}}"# + ); + } +} diff --git a/plugins/lsps-plugin/src/lib.rs b/plugins/lsps-plugin/src/lib.rs new file mode 100644 index 000000000..0910ab014 --- /dev/null +++ b/plugins/lsps-plugin/src/lib.rs @@ -0,0 +1 @@ +mod jsonrpc; diff --git a/plugins/lsps-plugin/src/main.rs b/plugins/lsps-plugin/src/main.rs new file mode 100644 index 000000000..e7a11a969 --- /dev/null +++ b/plugins/lsps-plugin/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +}