diff --git a/libs/Cargo.lock b/libs/Cargo.lock index 18eb82172..3682080eb 100644 --- a/libs/Cargo.lock +++ b/libs/Cargo.lock @@ -1135,6 +1135,7 @@ dependencies = [ "once_cell", "prost 0.11.9", "pyo3", + "serde_json", "tokio 1.30.0", "tonic 0.8.3", ] diff --git a/libs/gl-client-py/Cargo.toml b/libs/gl-client-py/Cargo.toml index 664529cd9..3f0021da1 100644 --- a/libs/gl-client-py/Cargo.toml +++ b/libs/gl-client-py/Cargo.toml @@ -20,3 +20,4 @@ prost = "0.11" pyo3 = {version = "0.18", features = ["extension-module", "serde", "abi3-py37"]} tokio = { version = "1", features = ["full"] } tonic = { version = "^0.8", features = ["tls", "transport"] } +serde_json = "^1.0" \ No newline at end of file diff --git a/libs/gl-client-py/glclient/__init__.py b/libs/gl-client-py/glclient/__init__.py index 130a90e71..28408a849 100644 --- a/libs/gl-client-py/glclient/__init__.py +++ b/libs/gl-client-py/glclient/__init__.py @@ -9,6 +9,7 @@ from binascii import hexlify, unhexlify from typing import Optional, List, Union, Iterable, Any, Type, TypeVar import logging +from glclient.lsps import LspClient # Keep in sync with the libhsmd version, this is tested in unit tests. @@ -551,6 +552,13 @@ def list_datastore( bytes(self.inner.call(uri, bytes(req))) ) + def get_lsp_client( + self, + peer_id : bytes + ) -> LspClient: + native_lsps = self.inner.get_lsp_client() + return LspClient(native_lsps, peer_id) + def normalize_node_id(node_id, string=False): if len(node_id) == 66: diff --git a/libs/gl-client-py/glclient/glclient.pyi b/libs/gl-client-py/glclient/glclient.pyi index d16c2b9b3..9354d3557 100644 --- a/libs/gl-client-py/glclient/glclient.pyi +++ b/libs/gl-client-py/glclient/glclient.pyi @@ -1,3 +1,5 @@ +import typing as t + class TlsConfig: def __init__(self) -> None: ... def with_ca_certificate(self, ca: bytes) -> "TlsConfig": ... @@ -24,6 +26,19 @@ class Scheduler: def schedule(self) -> bytes: ... class Node: - def __init__(self, node_id: bytes, network: str, tls: TlsConfig, grpc_uri: str) -> None: ... + def __init__( + self, node_id: bytes, network: str, tls: TlsConfig, grpc_uri: str + ) -> None: ... def stop(self) -> None: ... def call(self, method: str, request: bytes) -> bytes: ... + def get_lsp_client(self) -> LspClient: ... + +class LspClient: + def rpc_call(self, peer_id: bytes, method: str, params: bytes) -> bytes: ... + def rpc_call_with_json_rpc_id( + self, + peer_id: bytes, + method: str, + params: bytes, + json_rpc_id: t.Optional[str] = None, + ) -> bytes: ... diff --git a/libs/gl-client-py/glclient/lsps.py b/libs/gl-client-py/glclient/lsps.py new file mode 100644 index 000000000..8c0063dcf --- /dev/null +++ b/libs/gl-client-py/glclient/lsps.py @@ -0,0 +1,119 @@ +from dataclasses import dataclass, is_dataclass, asdict, field + +import typing as t +import json +import time +import binascii + +import glclient.glclient as native + +import logging + +logger = logging.getLogger(__name__) + + +def parse_and_validate_peer_id(data: t.Union[str, bytes]) -> bytes: + if isinstance(data, bytes): + if len(data) == 33: + return data + else: + raise ValueError( + f"Invalid peer_id. Expected a byte-array of length 33 but received {len(data)} instead" + ) + if isinstance(data, str): + if len(data) != 66: + raise ValueError( + f"Invalid peer_id. Must be a length 66 hex-string but received {len(data)}" + ) + try: + return bytes.fromhex(data) + except Exception as e: + raise ValueError("Invalid peer_id. Failed to parse hex-string") from e + + +class EnhancedJSONEncoder(json.JSONEncoder): + def default(self, o): + if is_dataclass(o): + return asdict(o) + elif isinstance(o, NoParams): + return dict() + elif isinstance(o, type) and o.__name__ == "NoParams": + return dict() + return super().default(o) + + +class AsDataClassDescriptor: + """Descriptor that allows to initialize a nested dataclass from a nested directory""" + + def __init__(self, *, cls): + self._cls = cls + + def __set_name__(self, owner, name): + self._name = f"_{name}" + + def __get__(self, obj, type): + return getattr(obj, self._name, None) + + def __set__(self, obj, value): + if isinstance(value, self._cls): + setattr(obj, self._name, value) + else: + setattr(obj, self._name, self._cls(**value)) + + +def _dump_json_bytes(object: t.Any) -> bytes: + json_str: str = json.dumps(object, cls=EnhancedJSONEncoder) + json_bytes: bytes = json_str.encode("utf-8") + return json_bytes + + +@dataclass +class ProtocolList: + protocols: t.List[int] + + +@dataclass +class Lsps1Options: + minimum_channel_confirmations: t.Optional[int] + minimum_onchain_payment_confirmations: t.Optional[int] + supports_zero_channel_reserve: t.Optional[bool] + min_onchain_payment_size_sat: t.Optional[int] + max_channel_expiry_blocks: t.Optional[int] + min_initial_client_balance_sat: t.Optional[int] + min_initial_lsp_balance_sat: t.Optional[int] + max_initial_client_balance_sat: t.Optional[int] + min_channel_balance_sat: t.Optional[int] + max_channel_balance_sat: t.Optional[int] + + +class NoParams: + pass + + +class LspClient: + def __init__(self, native: native.LspClient, peer_id: t.Union[bytes, str]): + self._native = native + self._peer_id: bytes = parse_and_validate_peer_id(peer_id) + + def _rpc_call( + self, + peer_id: bytes, + method_name: str, + param_json: bytes, + json_rpc_id: t.Optional[str] = None, + ) -> bytes: + logger.debug("Request lsp to peer %s and method %s", peer_id, method_name) + if json_rpc_id is None: + return self._native.rpc_call(peer_id, method_name, param_json) + else: + return self._native.rpc_call_with_json_rpc_id( + peer_id, method_name, param_json, json_rpc_id=json_rpc_id + ) + + def list_protocols(self, json_rpc_id: t.Optional[str] = None) -> ProtocolList: + json_bytes = _dump_json_bytes(NoParams) + result = self._rpc_call( + self._peer_id, "lsps0.listprotocols", json_bytes, json_rpc_id=json_rpc_id + ) + response_dict = json.loads(result) + return ProtocolList(**response_dict) diff --git a/libs/gl-client-py/src/lib.rs b/libs/gl-client-py/src/lib.rs index 06a8e1092..2b10d5b9c 100644 --- a/libs/gl-client-py/src/lib.rs +++ b/libs/gl-client-py/src/lib.rs @@ -9,11 +9,14 @@ mod runtime; mod scheduler; mod signer; mod tls; +mod lsps; pub use node::Node; pub use scheduler::Scheduler; pub use signer::Signer; pub use tls::TlsConfig; +pub use lsps::LspClient; + #[pyfunction] pub fn backup_decrypt_with_seed(encrypted: Vec, seed: Vec) -> PyResult> { @@ -36,6 +39,7 @@ fn glclient(_py: Python, m: &PyModule) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_function(wrap_pyfunction!(backup_decrypt_with_seed, m)?)?; diff --git a/libs/gl-client-py/src/lsps.rs b/libs/gl-client-py/src/lsps.rs new file mode 100644 index 000000000..2d5d916ca --- /dev/null +++ b/libs/gl-client-py/src/lsps.rs @@ -0,0 +1,93 @@ +use crate::runtime::exec; +use gl_client::lsps::error::LspsError; +use gl_client::lsps::json_rpc::{JsonRpcResponse, generate_random_rpc_id}; +use gl_client::lsps::message as lsps_message; +use gl_client::lsps::transport::JsonRpcTransport; +use gl_client::node::{Client, ClnClient}; +use pyo3::exceptions::{PyBaseException, PyConnectionError, PyTimeoutError, PyValueError}; +use pyo3::prelude::*; +use pyo3::PyErr; +use pyo3::types::PyBytes; + +#[pyclass] +pub struct LspClient { + transport: JsonRpcTransport, +} + +impl LspClient { + pub fn new(client: Client, cln_client: ClnClient) -> Self { + LspClient { + transport: JsonRpcTransport::new(client, cln_client), + } + } +} + +fn lsps_err_to_py_err(err: &LspsError) -> PyErr { + match err { + LspsError::MethodUnknown(method_name) => { + PyValueError::new_err(format!("Unknown method {:?}", method_name)) + } + LspsError::ConnectionClosed => PyConnectionError::new_err("Failed to connect"), + LspsError::GrpcError(status) => PyConnectionError::new_err(String::from(status.message())), + LspsError::Timeout => PyTimeoutError::new_err("Did not receive a response from the LSPS"), + LspsError::JsonParseRequestError(error) => { + PyValueError::new_err(format!("Failed to parse json-request, {:}", error)) + } + LspsError::JsonParseResponseError(error) => { + PyValueError::new_err(format!("Failed to parse json-response, {:}", error)) + } + LspsError::Other(error_message) => PyBaseException::new_err(String::from(error_message)), + } +} + +#[pymethods] +impl LspClient { + // When doing ffi with python we'de like to keep the interface as small as possible. + // + // We already have JSON-serialization and deserialization working because the underlying protocol uses JSON-rpc + // + // When one of the JSON-rpc method is called from python the user can just specify the peer-id and the serialized parameter they want to send + // The serialized result will be returned + pub fn rpc_call( + &mut self, + py : Python, + peer_id: &[u8], + method_name: &str, + value: &[u8], + ) -> PyResult { + let json_rpc_id = generate_random_rpc_id(); + self.rpc_call_with_json_rpc_id(py, peer_id, method_name, value, json_rpc_id) + } + + + pub fn rpc_call_with_json_rpc_id( + &mut self, + py : Python, + peer_id: &[u8], + method_name: &str, + value: &[u8], + json_rpc_id : String + ) -> PyResult { + // Parse the method-name and call the rpc-request + let rpc_response: JsonRpcResponse, Vec> = + lsps_message::JsonRpcMethodEnum::from_method_name(method_name) + .and_then(|method| exec(self.transport.request_with_json_rpc_id(peer_id, &method, value.to_vec(), json_rpc_id))) + .map_err(|err| lsps_err_to_py_err(&err))?; + + match rpc_response { + JsonRpcResponse::Ok(ok) => { + let response = ok.result; // response as byte-array + let py_object : PyObject = PyBytes::new(py, &response).into(); + return Ok(py_object) + } + JsonRpcResponse::Error(err) => { + // We should be able to put the error-data in here + // Replace this by a custom exception type + return Err(PyBaseException::new_err(format!( + "{:?} - {:?}", + err.error.code, err.error.message + ))); + } + } + } +} diff --git a/libs/gl-client-py/src/node.rs b/libs/gl-client-py/src/node.rs index b49d5c181..cae5ee0d4 100644 --- a/libs/gl-client-py/src/node.rs +++ b/libs/gl-client-py/src/node.rs @@ -1,5 +1,6 @@ use crate::runtime::exec; use crate::tls::TlsConfig; +use crate::lsps::LspClient; use gl_client as gl; use gl_client::bitcoin::Network; use gl_client::pb; @@ -12,6 +13,7 @@ use tonic::{Code, Status}; pub struct Node { client: gl::node::Client, gclient: gl::node::GClient, + cln_client : gl::node::ClnClient } #[pymethods] @@ -29,21 +31,24 @@ impl Node { // TODO: Could be massively simplified by using a scoped task // from tokio_scoped to a - let (client, gclient) = exec(async { + let (client, gclient, cln_client, ) = exec(async { let i = inner.clone(); let u = grpc_uri.clone(); let h1 = tokio::spawn(async move { i.connect(u).await }); let i = inner.clone(); let u = grpc_uri.clone(); let h2 = tokio::spawn(async move { i.connect(u).await }); + let i = inner.clone(); + let u = grpc_uri.clone(); + let h3 = tokio::spawn(async move { i.connect(u).await }); - Ok::<(gl::node::Client, gl::node::GClient), anyhow::Error>((h1.await??, h2.await??)) + Ok::<(gl::node::Client, gl::node::GClient, gl::node::ClnClient), anyhow::Error>((h1.await??, h2.await??, h3.await??)) }) .map_err(|e| { pyo3::exceptions::PyValueError::new_err(format!("could not connect to node: {}", e)) })?; - Ok(Node { client, gclient }) + Ok(Node { client, gclient, cln_client }) } fn call(&self, method: &str, payload: Vec) -> PyResult> { @@ -77,6 +82,13 @@ impl Node { .map_err(error_starting_stream)?; Ok(CustommsgStream { inner: stream }) } + + fn get_lsp_client(&self) -> LspClient { + LspClient::new( + self.client.clone(), + self.cln_client.clone() + ) + } } fn error_decoding_request(e: D) -> PyErr { diff --git a/libs/gl-client-py/tests/test_lsps.py b/libs/gl-client-py/tests/test_lsps.py new file mode 100644 index 000000000..9ed5ac2be --- /dev/null +++ b/libs/gl-client-py/tests/test_lsps.py @@ -0,0 +1,44 @@ +from glclient.lsps import AsDataClassDescriptor, EnhancedJSONEncoder, NoParams +import json + +from dataclasses import dataclass + +@dataclass +class Nested: + value : str + +@dataclass +class Nester: + nested_field : Nested = AsDataClassDescriptor(cls=Nested) + + +def test_nested_serialization(): + nester = Nester(nested_field=Nested(value=0)) + json_str = json.dumps(nester, cls=EnhancedJSONEncoder) + + assert json_str == """{"nested_field": {"value": 0}}""" + +def test_nested_deserialization(): + + nested_dict = { + "nested_field" : {"value" : 0} + } + result = Nester(**nested_dict) + + assert isinstance(result.nested_field, Nested) + assert result.nested_field.value == 0 + +def test_serialize_no_params(): + no_params_1 = NoParams + no_params_2 = NoParams() + + assert json.dumps(no_params_1, cls=EnhancedJSONEncoder) == "{}" + assert json.dumps(no_params_2, cls=EnhancedJSONEncoder) == "{}" + +def test_deserialize_no_params(): + json_str = "{}" + + # Should not raise + # this behavior should be the same as for a dataclass + + NoParams(**json.loads(json_str)) diff --git a/libs/gl-client/src/lsps/error.rs b/libs/gl-client/src/lsps/error.rs new file mode 100644 index 000000000..15847e232 --- /dev/null +++ b/libs/gl-client/src/lsps/error.rs @@ -0,0 +1,25 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum LspsError { + #[error("Unknown method")] + MethodUnknown(String), + #[error("Failed to parse json-request")] + JsonParseRequestError(serde_json::Error), + #[error("Failed to parse json-response")] + JsonParseResponseError(serde_json::Error), + #[error("Error while calling lightning grpc-method")] + GrpcError(#[from] tonic::Status), + #[error("Connection closed")] + ConnectionClosed, + #[error("Timeout")] + Timeout, + #[error("Something unexpected happened")] + Other(String), +} + +impl From for LspsError { + fn from(value: std::io::Error) -> Self { + return Self::Other(value.to_string()); + } +} diff --git a/libs/gl-client/src/lsps/json_rpc.rs b/libs/gl-client/src/lsps/json_rpc.rs index 190ba2b78..06f34231e 100644 --- a/libs/gl-client/src/lsps/json_rpc.rs +++ b/libs/gl-client/src/lsps/json_rpc.rs @@ -1,10 +1,13 @@ use base64::Engine as _; +use serde::de::DeserializeOwned; use serde::ser::SerializeMap; use serde::{Deserialize, Serialize}; -fn generate_random_rpc_id() -> String { - // TODO: verify that rand::random is a CSRNG - +/// Generate a random json_rpc_id string that follows the requirements of LSPS0 +/// +/// - Should be a String +/// - Should be at generated using at least 80 bits of randomness +pub fn generate_random_rpc_id() -> String { // The specification requires an id using least 80 random bits of randomness let seed: [u8; 10] = rand::random(); let result = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(seed); @@ -36,10 +39,10 @@ impl JsonRpcMethod { self.method } - pub fn create_request(&self, params: I) -> JsonRpcRequest { + pub fn create_request(&self, params: I, json_rpc_id: String) -> JsonRpcRequest { JsonRpcRequest:: { - json_rpc: String::from("2.0"), - id: generate_random_rpc_id(), + jsonrpc: String::from("2.0"), + id: String::from(json_rpc_id), method: self.method.into(), params: params, } @@ -47,8 +50,8 @@ impl JsonRpcMethod { } impl JsonRpcMethod { - pub fn create_request_no_params(&self) -> JsonRpcRequest { - self.create_request(NoParams::default()) + pub fn create_request_no_params(&self, json_rpc_id: String) -> JsonRpcRequest { + self.create_request(NoParams::default(), json_rpc_id) } } @@ -58,12 +61,12 @@ impl<'a, I, O, E> std::convert::From<&JsonRpcMethod> for String { } } -impl<'de, 'a, I, O, E> JsonRpcMethod +impl<'de, I, O, E> JsonRpcMethod where O: Deserialize<'de>, E: Deserialize<'de>, { - pub fn parse_json_response( + pub fn parse_json_response_str( &self, json_str: &'de str, ) -> Result, serde_json::Error> { @@ -71,6 +74,19 @@ where } } +impl JsonRpcMethod +where + O: DeserializeOwned, + E: DeserializeOwned, +{ + pub fn parse_json_response_value( + &self, + json_value: serde_json::Value, + ) -> Result, serde_json::Error> { + serde_json::from_value(json_value) + } +} + // We only intend to implement to implement an LSP-client and only intend on sending requests // Therefore, we only implement the serialization of requests // @@ -78,7 +94,7 @@ where // R is the data-type of the result if the query is successful #[derive(Serialize, Deserialize, Debug)] pub struct JsonRpcRequest { - pub json_rpc: String, + pub jsonrpc: String, pub id: String, pub method: String, pub params: I, @@ -105,7 +121,7 @@ impl Serialize for NoParams { impl JsonRpcRequest { pub fn new(method: JsonRpcMethod, params: I) -> Self { return Self { - json_rpc: String::from("2.0"), + jsonrpc: String::from("2.0"), id: generate_random_rpc_id(), method: method.method.into(), params: params, @@ -116,7 +132,7 @@ impl JsonRpcRequest { impl JsonRpcRequest { pub fn new_no_params(method: JsonRpcMethod) -> Self { return Self { - json_rpc: String::from("2.0"), + jsonrpc: String::from("2.0"), id: generate_random_rpc_id(), method: method.method.into(), params: NoParams::default(), @@ -128,14 +144,14 @@ impl JsonRpcRequest { pub struct JsonRpcResponseSuccess { pub id: String, pub result: O, - pub json_rpc: String, + pub jsonrpc: String, } #[derive(Debug, Serialize, Deserialize)] pub struct JsonRpcResponseFailure { pub id: String, pub error: ErrorData, - pub json_rpc: String, + pub jsonrpc: String, } #[derive(Debug, Serialize, Deserialize)] @@ -174,7 +190,7 @@ mod test { fn serialize_json_rpc_request() { let rpc_request = JsonRpcRequest { id: "abcefg".into(), - json_rpc: "2.0".into(), + jsonrpc: "2.0".into(), params: NoParams::default(), method: "test.method".into(), }; @@ -182,7 +198,7 @@ mod test { let json_str = serde_json::to_string(&rpc_request).unwrap(); let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); - assert_eq!(value.get("json_rpc").unwrap(), "2.0"); + assert_eq!(value.get("jsonrpc").unwrap(), "2.0"); assert_eq!(value.get("id").unwrap(), &rpc_request.id); assert_eq!(value.get("method").unwrap(), "test.method"); assert!(value.get("params").unwrap().as_object().unwrap().is_empty()) @@ -193,7 +209,7 @@ mod test { let rpc_response_ok: JsonRpcResponseSuccess = JsonRpcResponseSuccess { id: String::from("abc"), result: String::from("result_data"), - json_rpc: String::from("2.0"), + jsonrpc: String::from("2.0"), }; let rpc_response: JsonRpcResponse = JsonRpcResponse::Ok(rpc_response_ok); @@ -201,7 +217,7 @@ mod test { let json_str: String = serde_json::to_string(&rpc_response).unwrap(); let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); - assert_eq!(value.get("json_rpc").unwrap(), "2.0"); + assert_eq!(value.get("jsonrpc").unwrap(), "2.0"); assert_eq!(value.get("id").unwrap(), "abc"); assert_eq!(value.get("result").unwrap(), "result_data") } @@ -210,7 +226,7 @@ mod test { fn serialize_json_rpc_response_error() { let rpc_response: JsonRpcResponse = JsonRpcResponse::Error(JsonRpcResponseFailure { - json_rpc: String::from("2.0"), + jsonrpc: String::from("2.0"), id: String::from("abc"), error: ErrorData { code: -32700, @@ -222,7 +238,7 @@ mod test { let json_str: String = serde_json::to_string(&rpc_response).unwrap(); let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); - assert_eq!(value.get("json_rpc").unwrap(), "2.0"); + assert_eq!(value.get("jsonrpc").unwrap(), "2.0"); assert_eq!(value.get("id").unwrap(), "abc"); assert_eq!(value.get("error").unwrap().get("code").unwrap(), -32700); assert_eq!( @@ -234,10 +250,11 @@ mod test { #[test] fn create_rpc_request_from_call() { let rpc_method = JsonRpcMethod::::new("test.method"); - let rpc_request = rpc_method.create_request_no_params(); + let json_rpc_id = generate_random_rpc_id(); + let rpc_request = rpc_method.create_request_no_params(json_rpc_id); assert_eq!(rpc_request.method, "test.method"); - assert_eq!(rpc_request.json_rpc, "2.0"); + assert_eq!(rpc_request.jsonrpc, "2.0"); assert_eq!(rpc_request.params, NoParams::default()); } @@ -246,19 +263,19 @@ mod test { let rpc_method = JsonRpcMethod::::new("test.return_string"); let json_value = serde_json::json!({ - "json_rpc" : "2.0", + "jsonrpc" : "2.0", "result" : "result_data", "id" : "request_id" }); let json_str = serde_json::to_string(&json_value).unwrap(); - let result = rpc_method.parse_json_response(&json_str).unwrap(); + let result = rpc_method.parse_json_response_str(&json_str).unwrap(); match result { JsonRpcResponse::Error(_) => panic!("Deserialized a good response but got panic"), JsonRpcResponse::Ok(ok) => { - assert_eq!(ok.json_rpc, "2.0"); + assert_eq!(ok.jsonrpc, "2.0"); assert_eq!(ok.id, "request_id"); assert_eq!(ok.result, "result_data") } @@ -271,18 +288,18 @@ mod test { JsonRpcMethod::::new("test.return_string"); let json_value = serde_json::json!({ - "json_rpc" : "2.0", + "jsonrpc" : "2.0", "error" : { "code" : -32700, "message" : "Failed to parse response"}, "id" : "request_id" }); let json_str = serde_json::to_string(&json_value).unwrap(); - let result = rpc_method.parse_json_response(&json_str).unwrap(); + let result = rpc_method.parse_json_response_str(&json_str).unwrap(); match result { JsonRpcResponse::Error(err) => { - assert_eq!(err.json_rpc, "2.0"); + assert_eq!(err.jsonrpc, "2.0"); assert_eq!(err.error.code, -32700); assert_eq!(err.error.message, "Failed to parse response"); diff --git a/libs/gl-client/src/lsps/json_rpc_erased.rs b/libs/gl-client/src/lsps/json_rpc_erased.rs index 65495a312..1f3dd6432 100644 --- a/libs/gl-client/src/lsps/json_rpc_erased.rs +++ b/libs/gl-client/src/lsps/json_rpc_erased.rs @@ -36,15 +36,21 @@ pub trait JsonRpcMethodErased { fn create_request( &self, params: Vec, + json_rpc_id: String, ) -> Result; - fn parse_json_response( + fn parse_json_response_str( &self, json_str: &str, ) -> Result; + + fn parse_json_response_value( + &self, + json_str: serde_json::Value, + ) -> Result; } -impl JsonRpcMethodErased for JsonRpcMethod< I, O, E> +impl JsonRpcMethodErased for JsonRpcMethod where I: serde::de::DeserializeOwned + Serialize, O: serde::de::DeserializeOwned + Serialize, @@ -54,24 +60,36 @@ where &self.method } - fn create_request(&self, params: Vec) -> Result { + fn create_request( + &self, + params: Vec, + json_rpc_id: String, + ) -> Result { let typed_params: I = serde_json::from_slice(¶ms)?; let typed_json_rpc_request: JsonRpcRequest = - JsonRpcMethod::create_request(&self, typed_params); + JsonRpcMethod::create_request(&self, typed_params, json_rpc_id); typed_json_rpc_request.erase() } - fn parse_json_response( + fn parse_json_response_str( &self, json_str: &str, ) -> Result { // Check if the json-struct matches the expected type let result: JsonRpcResponse = - JsonRpcMethod::::parse_json_response(&self, json_str)?; + JsonRpcMethod::::parse_json_response_str(&self, json_str)?; return result.erase(); } -} + fn parse_json_response_value( + &self, + json_value: serde_json::Value, + ) -> Result { + let result: JsonRpcResponse = + JsonRpcMethod::::parse_json_response_value(&self, json_value)?; + return result.erase(); + } +} impl JsonRpcMethod where @@ -84,13 +102,10 @@ where } pub fn ref_erase<'a>(&'a self) -> &'a dyn JsonRpcMethodErased { - return self + return self; } - } - - // The trait JsonRpcUnerased is only intended to be used by library developers // // The user of this library might want to use the strongly typed generic version @@ -105,15 +120,23 @@ where // By using this trait, functionality will work for both type of users pub trait JsonRpcMethodUnerased<'a, I, O, E> { - fn name(&self) -> &str; - fn create_request(&self, params: I) -> Result, serde_json::Error>; + fn create_request( + &self, + params: I, + json_rpc_id: String, + ) -> Result, serde_json::Error>; - fn parse_json_response( + fn parse_json_response_str( &self, json_str: &str, ) -> Result, serde_json::Error>; + + fn parse_json_response_value( + &self, + json_value: serde_json::Value, + ) -> Result, serde_json::Error>; } // Dummy implementation for when the user uses the generic api @@ -126,43 +149,62 @@ where JsonRpcMethod::name(self) } - fn create_request(&self, params: I) -> Result, serde_json::Error> { - Ok(JsonRpcMethod::create_request(&self, params)) + fn create_request( + &self, + params: I, + json_rpc_id: String, + ) -> Result, serde_json::Error> { + Ok(JsonRpcMethod::create_request(&self, params, json_rpc_id)) } - fn parse_json_response( + fn parse_json_response_str( &self, json_str: &str, ) -> Result, serde_json::Error> { - JsonRpcMethod::parse_json_response(&self, json_str) + JsonRpcMethod::parse_json_response_str(&self, json_str) + } + + fn parse_json_response_value( + &self, + json_value: serde_json::Value, + ) -> Result, serde_json::Error> { + JsonRpcMethod::parse_json_response_value(&self, json_value) } } struct UneraseWrapper<'a> { - inner : &'a dyn JsonRpcMethodErased + inner: &'a dyn JsonRpcMethodErased, } impl<'a> JsonRpcMethodUnerased<'a, Vec, Vec, Vec> for UneraseWrapper<'a> { - fn name(&self) -> &str { self.inner.name() } - fn create_request(&self, params: Vec) -> Result>, serde_json::Error> { - self.inner.create_request(params) + fn create_request( + &self, + params: Vec, + json_rpc_id: String, + ) -> Result>, serde_json::Error> { + self.inner.create_request(params, json_rpc_id) } - fn parse_json_response( - &self, - json_str: &str, - ) -> Result, Vec>, serde_json::Error> { - self.inner.parse_json_response(json_str) + fn parse_json_response_str( + &self, + json_str: &str, + ) -> Result, Vec>, serde_json::Error> { + self.inner.parse_json_response_str(json_str) } + fn parse_json_response_value( + &self, + json_value: serde_json::Value, + ) -> Result, Vec>, serde_json::Error> { + self.inner.parse_json_response_value(json_value) + } } impl dyn JsonRpcMethodErased { - // The impl promises here we return a concrete type // However, we'd rather keep the implementation details private in this module and don't want users messing with it pub fn unerase<'a>(&'a self) -> impl JsonRpcMethodUnerased<'a, Vec, Vec, Vec> { @@ -177,7 +219,7 @@ where fn erase(self) -> Result { let value = serde_json::to_vec(&self.params)?; Ok(JsonRpcRequest { - json_rpc: self.json_rpc, + jsonrpc: self.jsonrpc, id: self.id, method: self.method, params: value, @@ -193,7 +235,7 @@ where Ok(JsonRpcResponseSuccessErased { id: self.id, result: serde_json::to_vec(&self.result)?, - json_rpc: self.json_rpc, + jsonrpc: self.jsonrpc, }) } } @@ -206,7 +248,7 @@ where Ok(JsonRpcResponseFailureErased { id: self.id, error: self.error.erase()?, - json_rpc: self.json_rpc, + jsonrpc: self.jsonrpc, }) } } @@ -250,37 +292,83 @@ where #[cfg(test)] mod test { use super::*; - use crate::lsps::json_rpc::JsonRpcMethod; + use crate::lsps::json_rpc::{generate_random_rpc_id, JsonRpcMethod}; #[derive(Serialize, serde::Deserialize)] - struct TestStruct { + struct TestRequestStruct { test: String, } + #[derive(Serialize, serde::Deserialize)] + struct TestResponseStruct { + response: String, + } + #[test] fn create_rpc_request_from_method_erased() { - let rpc_method = JsonRpcMethod::::new("test.method"); + let rpc_method = JsonRpcMethod::::new("test.method"); let rpc_method_erased = rpc_method.erase_box(); // This rpc-request should work becasue the parameters match the schema - let data = serde_json::json!({"test" : "This should work"}); - let rpc_request = rpc_method_erased.create_request(data).unwrap(); + let json_data = serde_json::json!({"test" : "This should work"}); + let vec_data: Vec = serde_json::to_vec(&json_data).unwrap(); + + let json_rpc_id = generate_random_rpc_id(); + let rpc_request: JsonRpcRequest> = rpc_method_erased + .create_request(vec_data, json_rpc_id) + .unwrap(); assert_eq!(rpc_request.method, "test.method"); - assert_eq!( - rpc_request.params.get("test").unwrap().as_str().unwrap(), - "This should work" - ); } #[test] fn create_rpc_request_from_method_erased_checks_types() { - let rpc_method = JsonRpcMethod::::new("test.method"); + let rpc_method = JsonRpcMethod::::new("test.method"); let rpc_method_erased = rpc_method.erase_box(); // This rpc-request should fail because the parameters do not match the schema // The test field is missing - let rpc_request = rpc_method_erased.create_request(serde_json::json!({})); - assert!(rpc_request.is_err()) + let param_vec = serde_json::to_vec(&serde_json::json!({})).unwrap(); + let json_rpc_id = generate_random_rpc_id(); + let rpc_request = rpc_method_erased.create_request(param_vec, json_rpc_id); + assert!(rpc_request.is_err()); + } + + #[test] + fn parse_rpc_request_from_method_erased() { + let rpc_method = + JsonRpcMethod::::new("test.method"); + let rpc_method_erased = rpc_method.erase_box(); + + let json_value = serde_json::json!({ + "jsonrpc" : "2.0", + "id" : "abcdef", + "result" : {"response" : "content"} + }); + + rpc_method_erased + .parse_json_response_value(json_value) + .unwrap(); } + #[test] + fn parse_rpc_request_from_method_erased_fails() { + let rpc_method = + JsonRpcMethod::::new("test.method"); + let rpc_method_erased = rpc_method.erase_box(); + + let json_value = serde_json::json!({ + "jsonrpd" : "2.0", // See the typo-here + "id" : "abcdef", + "result" : {"response" : "content"} + }); + + let result: Result = + rpc_method_erased.parse_json_response_value(json_value); + assert!(result.is_err()); + + // TODO: improve the error-message here + // It currently gives a vague error-message about not matching one of the enum scenarios in JsonRpcResponse + // It should at least mention that the field jsonrpc is missing + //assert!(format!("{:?}", result).contains("jsonrpc")); + } } diff --git a/libs/gl-client/src/lsps/message.rs b/libs/gl-client/src/lsps/message.rs index 0291ba788..c93789f43 100644 --- a/libs/gl-client/src/lsps/message.rs +++ b/libs/gl-client/src/lsps/message.rs @@ -1,6 +1,8 @@ +use crate::lsps::error::LspsError; pub use crate::lsps::json_rpc::{JsonRpcMethod, NoParams}; use crate::lsps::json_rpc_erased::{JsonRpcMethodErased, JsonRpcMethodUnerased}; use serde::{Deserialize, Serialize}; +use serde_with::serde_as; use time::format_description::well_known::Rfc3339; use uuid::Uuid; @@ -14,9 +16,10 @@ type OnchainFeeRate = u64; // - O represents the result data // - E represents the error if present // -// When creating a new type you must +// To create language bindings for a new rpc-call you must // 1. Add it to the JsonRpcMethodEnum // 2. Add it to the from_method_name function +// 3. Add it to the ref_erase function pub type Lsps0ListProtocols = JsonRpcMethod; pub type Lsps1Info = JsonRpcMethod; pub type Lsps1Order = JsonRpcMethod; @@ -28,20 +31,21 @@ pub const LSPS1_GETORDER: Lsps1Order = Lsps1Order::new("lsps1.order"); pub enum JsonRpcMethodEnum { Lsps0ListProtocols(Lsps0ListProtocols), Lsps1Info(Lsps1Info), - Lsps1Order(Lsps1Order) + Lsps1Order(Lsps1Order), } impl JsonRpcMethodEnum { - - pub fn from_method_name(value : &str) -> Result { + pub fn from_method_name(value: &str) -> Result { match value { - "lsps0.list_protocols" => Ok(Self::Lsps0ListProtocols(LSPS0_LISTPROTOCOLS)), + "lsps0.listprotocols" => Ok(Self::Lsps0ListProtocols(LSPS0_LISTPROTOCOLS)), "lsps1.info" => Ok(Self::Lsps1Info(LSPS1_GETINFO)), "lsps1.order" => Ok(Self::Lsps1Order(LSPS1_GETORDER)), - _ => Err(()), + default => Err(LspsError::MethodUnknown(String::from(default))), } } + // Useful for language bindings. + // The python code can pub fn ref_erase<'a>(&'a self) -> &'a dyn JsonRpcMethodErased { match self { Self::Lsps0ListProtocols(list_protocol) => list_protocol.ref_erase(), @@ -52,20 +56,30 @@ impl JsonRpcMethodEnum { } impl<'a> JsonRpcMethodUnerased<'a, Vec, Vec, Vec> for JsonRpcMethodEnum { - fn name(&self) -> &str { self.ref_erase().name() } - fn create_request(&self, params: Vec) -> Result>, serde_json::Error> { - self.ref_erase().create_request(params) + fn create_request( + &self, + params: Vec, + json_rpc_id: String, + ) -> Result>, serde_json::Error> { + self.ref_erase().create_request(params, json_rpc_id) } - fn parse_json_response( - &self, - json_str: &str, - ) -> Result, Vec>, serde_json::Error> { - self.ref_erase().parse_json_response(json_str) + fn parse_json_response_str( + &self, + json_str: &str, + ) -> Result, Vec>, serde_json::Error> { + self.ref_erase().parse_json_response_str(json_str) + } + + fn parse_json_response_value( + &self, + json_value: serde_json::Value, + ) -> Result, Vec>, serde_json::Error> { + self.ref_erase().parse_json_response_value(json_value) } } @@ -108,8 +122,6 @@ pub struct Lsps1GetOrderRequest { pub announce_channel: String, } -use serde_with::serde_as; - #[serde_as] #[derive(Debug, Serialize, Deserialize)] pub struct Lsps1GetOrderResponse { @@ -176,24 +188,37 @@ struct Payment { #[cfg(test)] mod test { + use crate::lsps::json_rpc::generate_random_rpc_id; + use super::*; use serde_json::{from_str, to_string, Value}; #[test] fn serialize_request_with_no_params() { let method = LSPS0_LISTPROTOCOLS; - let rpc_request = method.create_request_no_params(); + let json_rpc_id = generate_random_rpc_id(); + let rpc_request = method.create_request_no_params(json_rpc_id); let json_str = to_string(&rpc_request).unwrap(); // Test that params is an empty dict // // LSPS-0 spec demands that a parameter-by-name scheme is always followed let v: Value = from_str(&json_str).unwrap(); - assert_eq!(v.get("json_rpc").unwrap(), "2.0"); + assert_eq!(v.get("jsonrpc").unwrap(), "2.0"); assert_eq!( v.get("method").unwrap().as_str().unwrap(), "lsps0.listprotocols" ); assert!(v.get("params").unwrap().as_object().unwrap().is_empty()) } + + #[test] + fn serialize_protocol_list() { + let protocols = ProtocolList { + protocols: vec![1, 3], + }; + + let json_str = serde_json::to_string(&protocols).unwrap(); + assert_eq!(json_str, "{\"protocols\":[1,3]}") + } } diff --git a/libs/gl-client/src/lsps/mod.rs b/libs/gl-client/src/lsps/mod.rs index 28b7784cc..1af1fdc84 100644 --- a/libs/gl-client/src/lsps/mod.rs +++ b/libs/gl-client/src/lsps/mod.rs @@ -2,3 +2,4 @@ pub mod json_rpc; pub mod json_rpc_erased; pub mod message; pub mod transport; +pub mod error; diff --git a/libs/gl-client/src/lsps/transport.rs b/libs/gl-client/src/lsps/transport.rs index 6567b7844..af1919646 100644 --- a/libs/gl-client/src/lsps/transport.rs +++ b/libs/gl-client/src/lsps/transport.rs @@ -8,13 +8,13 @@ // are used as the transport layer. All messages related to LSPS will start // with the LSPS_MESSAGE_ID. // -use super::json_rpc::{JsonRpcResponse}; +use super::json_rpc::{generate_random_rpc_id, JsonRpcResponse}; use super::json_rpc_erased::JsonRpcMethodUnerased; +use crate::lsps::error::LspsError; use crate::node::{Client, ClnClient}; use crate::pb::{Custommsg, StreamCustommsgRequest}; use cln_grpc::pb::SendcustommsgRequest; use std::io::{Cursor, Read, Write}; -use thiserror::Error; // BOLT8 message ID 37913 const LSPS_MESSAGE_ID: [u8; 2] = [0x94, 0x19]; @@ -25,53 +25,65 @@ pub struct JsonRpcTransport { cln_client: ClnClient, // USed for sending custom message } -#[derive(Error, Debug)] -pub enum TransportError { - #[error("Failed to parse json")] - JsonParseError(#[from] serde_json::Error), - #[error("Error while calling lightning grpc-method")] - GrpcError(#[from] tonic::Status), - #[error("Connection closed")] - ConnectionClosed, - #[error("Timeout")] - Timeout, - #[error("Something unexpected happened")] - Other(String), -} - -impl From for TransportError { - fn from(value: std::io::Error) -> Self { - return Self::Other(value.to_string()); - } -} - impl JsonRpcTransport { - - pub fn new(client : Client, cln_client : ClnClient) -> Self { + pub fn new(client: Client, cln_client: ClnClient) -> Self { Self { - client : client, - cln_client : cln_client, + client: client, + cln_client: cln_client, } } + /// Create a JSON-rpc request to a LSPS + /// + /// # Arguments: + /// - peer_id: the node_id of the lsps + /// - method: the request method as defined in the LSPS + /// - param: the request parameter + /// pub async fn request<'a, I, O, E>( &mut self, peer_id: &[u8], - method : &impl JsonRpcMethodUnerased<'a, I, O, E>, - param : I - ) -> Result, TransportError> + method: &impl JsonRpcMethodUnerased<'a, I, O, E>, + param: I, + ) -> Result, LspsError> + where + I: serde::Serialize, + E: serde::de::DeserializeOwned, + O: serde::de::DeserializeOwned, + { + let json_rpc_id = generate_random_rpc_id(); + return self + .request_with_json_rpc_id(peer_id, method, param, json_rpc_id) + .await; + } + + /// Makes the jsonrpc request and returns the response + /// + /// This method allows the user to specify a custom json_rpc_id. + /// To ensure compliance with LSPS0 the use of `[request`] is recommended. + /// For some testing scenario's it is useful to have a reproducable json_rpc_id + pub async fn request_with_json_rpc_id<'a, I, O, E>( + &mut self, + peer_id: &[u8], + method: &impl JsonRpcMethodUnerased<'a, I, O, E>, + param: I, + json_rpc_id: String, + ) -> Result, LspsError> where I: serde::Serialize, E: serde::de::DeserializeOwned, O: serde::de::DeserializeOwned, { // Constructs the JsonRpcRequest - let request = method.create_request(param)?; + let request = method + .create_request(param, json_rpc_id) + .map_err(|x| LspsError::JsonParseRequestError(x))?; // Core-lightning uses the convention that the first two bytes are the BOLT-8 message id let mut cursor: Cursor> = std::io::Cursor::new(Vec::new()); cursor.write(&LSPS_MESSAGE_ID)?; - serde_json::to_writer(&mut cursor, &request)?; + serde_json::to_writer(&mut cursor, &request) + .map_err(|x| LspsError::JsonParseRequestError(x))?; let custom_message_request = SendcustommsgRequest { node_id: peer_id.to_vec(), @@ -91,7 +103,7 @@ impl JsonRpcTransport { // Sends the JsonRpcRequest // Once the await has completed our greenlight node has successfully send the message - // It doesn't mean that the LSPS has already responded to it + // The LSPS did probably not respond yet self.cln_client .send_custom_msg(custom_message_request) .await?; @@ -116,9 +128,13 @@ impl JsonRpcTransport { // Deserialize the JSON compare the json_rpc_id // If it matches we return a typed JsonRpcRequest - let value: serde_json::Value = serde_json::from_reader(&mut msg_cursor)?; + let value: serde_json::Value = serde_json::from_reader(&mut msg_cursor) + .map_err(|x| LspsError::JsonParseResponseError(x))?; if value.get("id").and_then(|x| x.as_str()) == Some(&request.id) { - let rpc_response: JsonRpcResponse = serde_json::from_value(value)?; + // There is a bug here. We need to do the parsing in the underlying trait + let rpc_response = method + .parse_json_response_value(value) + .map_err(|x| LspsError::JsonParseResponseError(x))?; return Ok(rpc_response); } @@ -130,13 +146,11 @@ impl JsonRpcTransport { // I might have to add a built-in time-out mechanism in StreamCustomMsg or come up with // a better solution. if loop_start_instant.elapsed().as_millis() >= TIMEOUT_MILLIS { - return Err(TransportError::Timeout); + return Err(LspsError::Timeout); } } // If the stream was closed - return Err(TransportError::ConnectionClosed); + return Err(LspsError::ConnectionClosed); } } - - diff --git a/libs/gl-testing/pyproject.toml b/libs/gl-testing/pyproject.toml index 162b759c0..0849627aa 100644 --- a/libs/gl-testing/pyproject.toml +++ b/libs/gl-testing/pyproject.toml @@ -17,7 +17,7 @@ sh = "^1.14.2" pytest-timeout = "^2.1.0" # `pip` doesn't like relative-path dependencies. Should be ok once we # publish this on PyPI so pip can find it too. -#gl-client-py = {path = "../gl-client-py"} +# gl-client-py = {path = "../gl-client-py"} pytest-xdist = "^2.5.0" pytest = "^7.1.2" pytest-cov = "^3.0.0" diff --git a/libs/gl-testing/tests/test_lsps.py b/libs/gl-testing/tests/test_lsps.py new file mode 100644 index 000000000..fe8384815 --- /dev/null +++ b/libs/gl-testing/tests/test_lsps.py @@ -0,0 +1,88 @@ +from gltesting.fixtures import * +from pyln.testing.utils import NodeFactory, BitcoinD, LightningNode +import json + +from glclient.lsps import ProtocolList + +import threading + +logger = logging.getLogger(__name__) + +class AwaitResult: + """ A very poor implementation of an awaitable in python + + It is inefficient and uses Threads under the hood. + But it gives something like the `await` syntax which + makes it easier to write tests. + """ + def __init__(self, function, args=None, kwargs=None): + self._result = None + self._thread = None + self._exception = None + + if args is None: + args = [] + if kwargs is None: + kwargs = dict() + + def wrap_function(*args, **kwargs): + try: + self._result = function(*args, **kwargs) + except Exception as e: + self._exception = e + + self._thread = threading.Thread(target=wrap_function, args=args, kwargs=kwargs) + self._thread.start() + + def await_result(self, timeout_seconds : float=30.0): + self._thread.join(timeout=timeout_seconds) + if self._thread.is_alive(): + raise TimeoutError() + + if self._exception: + raise self._exception + + return self._result + + +def test_lsps_list_protocol(clients : Clients, node_factory : NodeFactory, bitcoind : BitcoinD): + # Create the LSP + n1 : LightningNode = node_factory.get_node() + + # Create and configure the greenlight client and connect it to the LSP + c = clients.new() + c.register(configure=True) + gl1 = c.node() + s = c.signer().run_in_thread() + + # Connect our greenlight node (client ot the LSP) + lsp_ip = n1.info["binding"][0]["address"] + lsp_port = n1.info["binding"][0]["port"] + gl1.connect_peer(n1.info['id'], addr=f"{lsp_ip}:{lsp_port}") + + + # Get the lsp-client and do list-protocols + lsp_client = gl1.get_lsp_client(n1.info['id']) + + # The client sends a message + json_rpc_id = "abcdef" + protocol_fut = AwaitResult(lambda: lsp_client.list_protocols(json_rpc_id=json_rpc_id)) + + # The n1.rpc.sendcustommsg expects that both the node_id and msg are hex encoded strings + msg_content = { + "jsonrpc" : "2.0", + "id" : json_rpc_id, + "result" : {"protocols" : [1,2]} + } + + json_str = json.dumps(msg_content) + json_bytes = json_str.encode("utf-8") + msg_str = "9419" + json_bytes.hex() + + n1.rpc.sendcustommsg( + node_id = gl1.get_info().id.hex(), + msg = msg_str + ) + + result = protocol_fut.await_result() + assert result == ProtocolList([1,2]) \ No newline at end of file