From e55f4967923b5ce1be70edc0d20f21ab3023fb4d Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Thu, 18 Sep 2025 13:32:12 +0200 Subject: [PATCH 01/22] lsp_plugin: change id type in jsonrpc Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/jsonrpc/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/lsps-plugin/src/jsonrpc/mod.rs b/plugins/lsps-plugin/src/jsonrpc/mod.rs index 78a4fa1a8514..b7c871ee0932 100644 --- a/plugins/lsps-plugin/src/jsonrpc/mod.rs +++ b/plugins/lsps-plugin/src/jsonrpc/mod.rs @@ -56,7 +56,7 @@ pub type Result = std::result::Result; /// request format. pub trait JsonRpcRequest: Serialize { const METHOD: &'static str; - fn into_request(self, id: impl Into>) -> RequestObject + fn into_request(self, id: Option) -> RequestObject where Self: Sized, { @@ -64,7 +64,7 @@ pub trait JsonRpcRequest: Serialize { jsonrpc: "2.0".into(), method: Self::METHOD.into(), params: Some(self), - id: id.into(), + id, } } } From 0760fe90ef8e8b5fa89b20659b31cbec28f185ff Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Thu, 18 Sep 2025 14:13:14 +0200 Subject: [PATCH 02/22] lsp_plugin: add fn to wrap payload with peer id We need to somehow access the peer id in the jrpc server to know where the response should go. This seems to be the most convenient way for now. We may unclutter this in the future if this results in performance issues. Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/lib.rs | 1 + plugins/lsps-plugin/src/service.rs | 7 +- plugins/lsps-plugin/src/util.rs | 134 +++++++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 plugins/lsps-plugin/src/util.rs diff --git a/plugins/lsps-plugin/src/lib.rs b/plugins/lsps-plugin/src/lib.rs index 8d4044c193e0..2eb605d92af9 100644 --- a/plugins/lsps-plugin/src/lib.rs +++ b/plugins/lsps-plugin/src/lib.rs @@ -1,2 +1,3 @@ pub mod jsonrpc; pub mod lsps0; +pub mod util; diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index 8078f00430ab..c8907ae99611 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -6,6 +6,7 @@ use cln_lsps::jsonrpc::{JsonRpcResponse, RequestObject, RpcError, TransportError use cln_lsps::lsps0; use cln_lsps::lsps0::model::{Lsps0listProtocolsRequest, Lsps0listProtocolsResponse}; use cln_lsps::lsps0::transport::{self, CustomMsg}; +use cln_lsps::util::wrap_payload_with_peer_id; use cln_plugin::options::ConfigOption; use cln_plugin::{options, Plugin}; use cln_rpc::notifications::CustomMsgNotification; @@ -84,8 +85,12 @@ async fn on_custommsg( 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(&req.payload, &mut writer).await { + match service.handle_message(&payload, &mut writer).await { Ok(_) => continue_response, Err(e) => { debug!("failed to handle lsps message: {}", e); diff --git a/plugins/lsps-plugin/src/util.rs b/plugins/lsps-plugin/src/util.rs new file mode 100644 index 000000000000..89f308aee1c3 --- /dev/null +++ b/plugins/lsps-plugin/src/util.rs @@ -0,0 +1,134 @@ +use anyhow::anyhow; +use anyhow::Result; +use cln_rpc::primitives::PublicKey; +use core::fmt; +use serde_json::Value; +use std::str::FromStr; + +/// Errors that can occur when unwrapping payload data +#[derive(Debug, Clone, PartialEq)] +pub enum UnwrapError { + /// The public key bytes are invalid + InvalidPublicKey(String), + /// Failed to deserialize json value, + SerdeFailure(String), +} + +impl fmt::Display for UnwrapError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UnwrapError::InvalidPublicKey(e) => { + write!(f, "Invalid public key: {}", e) + } + UnwrapError::SerdeFailure(e) => { + write!(f, "Failed to serialize or deserialize json value: {}", e) + } + } + } +} + +impl std::error::Error for UnwrapError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + _ => None, + } + } +} + +/// Wraps a payload with a peer ID for internal LSPS message transmission. +pub fn try_wrap_payload_with_peer_id(payload: &[u8], peer_id: PublicKey) -> Result> { + // We expect the payload to be valid json, so no empty payload allowed, also + // checks that we have curly braces at start and end. + if payload.is_empty() || payload[0] != b'{' || payload[payload.len() - 1] != b'}' { + return Err(anyhow!("payload no valid json")); + } + + let pubkey_hex = peer_id.to_string(); + let mut result = Vec::with_capacity(pubkey_hex.len() + payload.len() + 13); + + result.extend_from_slice(&payload[..payload.len() - 1]); + result.extend_from_slice(b",\"peer_id\":\""); + result.extend_from_slice(pubkey_hex.as_bytes()); + result.extend_from_slice(b"\"}"); + Ok(result) +} + +/// Safely unwraps payload data and a peer ID +pub fn try_unwrap_payload_with_peer_id(data: &[u8]) -> Result<(Vec, PublicKey)> { + let mut json: Value = + serde_json::from_slice(data).map_err(|e| UnwrapError::SerdeFailure(e.to_string()))?; + + if let Value::Object(ref mut map) = json { + if let Some(Value::String(peer_id)) = map.remove("peer_id") { + let modified_json = serde_json::to_string(&json) + .map_err(|e| UnwrapError::SerdeFailure(e.to_string()))?; + return Ok(( + modified_json.into_bytes(), + PublicKey::from_str(&peer_id) + .map_err(|e| UnwrapError::InvalidPublicKey(e.to_string()))?, + )); + } + } + Err(UnwrapError::InvalidPublicKey(String::from( + "public key missing", + )))? +} + +/// Unwraps payload data and peer ID, panicking on error +/// +/// This is a convenience function for cases where one knows the data is valid. +pub fn unwrap_payload_with_peer_id(data: &[u8]) -> (Vec, PublicKey) { + try_unwrap_payload_with_peer_id(data).expect("Failed to unwrap payload with peer_id") +} + +/// Wraps payload data and peer ID, panicking on error +/// +/// This is a convenience function for cases where one knows that the payload is +/// valid. +pub fn wrap_payload_with_peer_id(payload: &[u8], peer_id: PublicKey) -> Vec { + try_wrap_payload_with_peer_id(payload, peer_id).expect("Failed to wrap payload with peer_id") +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + // Valid test public key + 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, + ]; + + #[test] + fn test_wrap_and_unwrap_roundtrip() { + let peer_id = PublicKey::from_slice(&PUBKEY).unwrap(); + let payload = + json!({"jsonrpc": "2.0","method": "some-method","params": {},"id": "some-id"}); + let wrapped = wrap_payload_with_peer_id(payload.to_string().as_bytes(), peer_id); + + let (unwrapped_payload, unwrapped_peer_id) = unwrap_payload_with_peer_id(&wrapped); + let value: serde_json::Value = serde_json::from_slice(&unwrapped_payload).unwrap(); + + assert_eq!(value, payload); + assert_eq!(unwrapped_peer_id, peer_id); + } + + #[test] + fn test_invalid_pubkey() { + let mut invalid_data = vec![0u8; 40]; + // Set an invalid public key (all zeros) + invalid_data[0] = 0x02; // Valid prefix + // But rest remains zeros which is invalid + let payload = json!({"jsonrpc": "2.0","method": "some-method","params": {},"id": "some-id","peer_id": hex::encode(&invalid_data)}); + + let result = try_unwrap_payload_with_peer_id(payload.to_string().as_bytes()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err().downcast_ref::(), + Some(UnwrapError::InvalidPublicKey(_)) + )); + } +} From 11bafd71dee57771d290ce612222337577758401 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Thu, 18 Sep 2025 15:09:07 +0200 Subject: [PATCH 03/22] lsp_plugin: add sane error to listprotocols Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index c54699c67e2b..92b5a9e1c180 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -1,8 +1,10 @@ +use anyhow::Context; use cln_lsps::jsonrpc::client::JsonRpcClient; use cln_lsps::lsps0::{ self, transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager}, }; +use log::debug; use serde::Deserialize; use std::path::Path; @@ -38,6 +40,7 @@ async fn main() -> Result<(), anyhow::Error> { } } +/// RPC Method handler for `lsps-listprotocols`. async fn on_lsps_listprotocols( p: cln_plugin::Plugin, v: serde_json::Value, @@ -49,16 +52,26 @@ async fn on_lsps_listprotocols( let dir = p.configuration().lightning_dir; let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); - let req: Request = serde_json::from_value(v).unwrap(); + let req: Request = serde_json::from_value(v).context("Failed to parse request JSON")?; - let client = JsonRpcClient::new(Bolt8Transport::new( + // Create the transport first and handle potential errors + let transport = Bolt8Transport::new( &req.peer, rpc_path, p.state().hook_manager.clone(), - None, - )?); + None, // Use default timeout + ) + .context("Failed to create Bolt8Transport")?; + + // Now create the client using the transport + let client = JsonRpcClient::new(transport); + + let request = lsps0::model::Lsps0listProtocolsRequest {}; let res: lsps0::model::Lsps0listProtocolsResponse = client - .call_typed(lsps0::model::Lsps0listProtocolsRequest {}) - .await?; + .call_typed(request) + .await + .context("lsps0.list_protocols call failed")?; + + debug!("Received lsps0.list_protocols response: {:?}", res); Ok(serde_json::to_value(res)?) } From 3f03931a1dd84da78cb48d24f1caa8504e469d62 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Thu, 18 Sep 2025 15:05:29 +0200 Subject: [PATCH 04/22] lsp_plugin: add dev-eneabled flag for client While this is still experimental, we only want to enable the client when explicitly defined! Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 17 ++++++++++++++++- plugins/lsps-plugin/src/service.rs | 26 ++++++++++---------------- tests/test_cln_lsps.py | 2 +- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index 92b5a9e1c180..ce39d655c156 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -4,10 +4,17 @@ use cln_lsps::lsps0::{ self, transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager}, }; +use cln_plugin::options; use log::debug; use serde::Deserialize; use std::path::Path; +/// An option to enable this service. +const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag( + "dev-lsps-client-enabled", + "Enables an LSPS client on the node.", +); + #[derive(Clone)] struct State { hook_manager: CustomMessageHookManager, @@ -26,14 +33,22 @@ async fn main() -> Result<(), anyhow::Error> { if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout()) .hook("custommsg", CustomMessageHookManager::on_custommsg::) + .option(OPTION_ENABLED) .rpcmethod( "lsps-listprotocols", "list protocols supported by lsp", on_lsps_listprotocols, ) - .start(state) + .configure() .await? { + if !plugin.option(&OPTION_ENABLED)? { + return plugin + .disable(&format!("`{}` not enabled", OPTION_ENABLED.name)) + .await; + } + + let plugin = plugin.start(state).await?; plugin.join().await } else { Ok(()) diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index c8907ae99611..a350a3d94fcc 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -16,14 +16,9 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; -/// An option to enable this service. It defaults to `false` as we don't want a -/// node to be an LSP per default. -/// If a user want's to run an LSP service on their node this has to explicitly -/// set to true. We keep this as a dev option for now until it actually does -/// something. -const OPTION_ENABLED: options::DefaultBooleanConfigOption = ConfigOption::new_bool_with_default( - "dev-lsps-service", - false, +/// An option to enable this service. +const OPTION_ENABLED: options::FlagConfigOption = ConfigOption::new_flag( + "dev-lsps-service-enabled", "Enables an LSPS service on the node.", ); @@ -34,14 +29,6 @@ struct State { #[tokio::main] async fn main() -> Result<(), anyhow::Error> { - let lsps_service = JsonRpcServer::builder() - .with_handler( - Lsps0listProtocolsRequest::METHOD.to_string(), - Arc::new(Lsps0ListProtocolsHandler), - ) - .build(); - let state = State { lsps_service }; - if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout()) .option(OPTION_ENABLED) .hook("custommsg", on_custommsg) @@ -54,6 +41,13 @@ async fn main() -> Result<(), anyhow::Error> { .await; } + let lsps_builder = JsonRpcServer::builder().with_handler( + Lsps0listProtocolsRequest::METHOD.to_string(), + Arc::new(Lsps0ListProtocolsHandler {}), + ); + let lsps_service = lsps_builder.build(); + + let state = State { lsps_service }; let plugin = plugin.start(state).await?; plugin.join().await } else { diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 28f09d64d750..5803d93456e3 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -21,7 +21,7 @@ def test_lsps_service_disabled(node_factory): @unittest.skipUnless(RUST, 'RUST is not enabled') def test_lsps0_listprotocols(node_factory): l1, l2 = node_factory.get_nodes(2, opts=[ - {}, {"dev-lsps-service": True} + {"dev-lsps-client-enabled": None}, {"dev-lsps-service-enabled": None} ]) # We don't need a channel to query for lsps services From 9fc278c24398e905c5810393e05439e54b75ca9a Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Fri, 19 Sep 2025 16:44:51 +0200 Subject: [PATCH 05/22] lsp_plugin: add primitives for messages Adds some primitives defined in lsps0 for other protocol messages. Signed-off-by: Peter Neuroth --- Cargo.lock | 106 +++++++++++ plugins/lsps-plugin/Cargo.toml | 1 + plugins/lsps-plugin/src/lsps0/mod.rs | 1 + plugins/lsps-plugin/src/lsps0/primitives.rs | 199 ++++++++++++++++++++ 4 files changed, 307 insertions(+) create mode 100644 plugins/lsps-plugin/src/lsps0/primitives.rs diff --git a/Cargo.lock b/Cargo.lock index 75f85c53a514..1ba793c90362 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.98" @@ -464,6 +473,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cln-bip353" version = "0.1.0" @@ -524,6 +546,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "chrono", "cln-plugin", "cln-rpc", "hex", @@ -1209,6 +1232,30 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -3336,6 +3383,65 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + +[[package]] +name = "windows-result" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/plugins/lsps-plugin/Cargo.toml b/plugins/lsps-plugin/Cargo.toml index 13279da12c17..592fdbba1b96 100644 --- a/plugins/lsps-plugin/Cargo.toml +++ b/plugins/lsps-plugin/Cargo.toml @@ -14,6 +14,7 @@ path = "src/service.rs" [dependencies] anyhow = "1.0" async-trait = "0.1" +chrono = "0.4.42" cln-plugin = { version = "0.5", path = "../" } cln-rpc = { version = "0.5", path = "../../cln-rpc" } hex = "0.4" diff --git a/plugins/lsps-plugin/src/lsps0/mod.rs b/plugins/lsps-plugin/src/lsps0/mod.rs index e7716c921b42..df78189cdb51 100644 --- a/plugins/lsps-plugin/src/lsps0/mod.rs +++ b/plugins/lsps-plugin/src/lsps0/mod.rs @@ -1,2 +1,3 @@ pub mod model; +pub mod primitives; pub mod transport; diff --git a/plugins/lsps-plugin/src/lsps0/primitives.rs b/plugins/lsps-plugin/src/lsps0/primitives.rs new file mode 100644 index 000000000000..3b2ff52d1e8c --- /dev/null +++ b/plugins/lsps-plugin/src/lsps0/primitives.rs @@ -0,0 +1,199 @@ +use core::fmt; +use serde::{ + de::{self}, + Deserialize, Deserializer, Serialize, Serializer, +}; + +const MSAT_PER_SAT: u64 = 1_000; + +/// Represents a monetary amount as defined in LSPS0.msat. Is converted to a +/// `String` in json messages with a suffix `_msat` or `_sat` and internally +/// represented as Millisatoshi `u64`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Msat(pub u64); + +impl Msat { + /// Constructs a new `Msat` struct from a `u64` msat value. + pub fn from_msat(msat: u64) -> Self { + Msat(msat) + } + + /// Construct a new `Msat` struct from a `u64` sat value. + pub fn from_sat(sat: u64) -> Self { + Msat(sat * MSAT_PER_SAT) + } + + /// Returns the sat amount of the field. Is a floored integer division e.g + /// 100678 becomes 100. + pub fn to_sats_floor(&self) -> u64 { + self.0 / 1000 + } + + /// Returns the msat value as `u64`. Is the inner value of `Msat`. + pub fn msat(&self) -> u64 { + self.0 + } +} + +impl core::fmt::Display for Msat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}_msat", self.0) + } +} + +impl Serialize for Msat { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl<'de> Deserialize<'de> for Msat { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct MsatVisitor; + + impl<'de> de::Visitor<'de> for MsatVisitor { + type Value = Msat; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a string representing a number") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + value + .parse::() + .map(Msat::from_msat) + .map_err(|_| E::custom(format!("Invalid number string: {}", value))) + } + + // Also handle if JSON mistakenly has a number instead of string + fn visit_u64(self, value: u64) -> Result + where + E: de::Error, + { + Ok(Msat::from_msat(value)) + } + + fn visit_i64(self, value: i64) -> Result + where + E: de::Error, + { + if value < 0 { + Err(E::custom("Msat cannot be negative")) + } else { + Ok(Msat::from_msat(value as u64)) + } + } + } + + deserializer.deserialize_any(MsatVisitor) + } +} + +/// Represents parts-per-million as defined in LSPS0.ppm. Gets it's own type +/// from the rationals: "This is its own type so that fractions can be expressed +/// using this type, instead of as a floating-point type which might lose +/// accuracy when serialized into text.". Having it as a separate type also +/// provides more clarity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] // Key attribute! Serialize/Deserialize as the inner u32 +pub struct Ppm(pub u32); // u32 is sufficient as 1,000,000 fits easily + +impl Ppm { + /// Constructs a new `Ppm` from a u32. + pub const fn from_ppm(value: u32) -> Self { + Ppm(value) + } + + /// Applies the proportion to a base amount (e.g., in msats). + pub fn apply_to(&self, base_msat: u64) -> u64 { + // Careful about integer division order and potential overflow + (base_msat as u128 * self.0 as u128 / 1_000_000) as u64 + } + + /// Returns the ppm. + pub fn ppm(&self) -> u32 { + self.0 + } +} + +impl core::fmt::Display for Ppm { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}ppm", self.0) + } +} + +/// Represents a short channel id as defined in LSPS0.scid. Matches with the +/// implementation in cln_rpc. +pub type ShortChannelId = cln_rpc::primitives::ShortChannelId; + +/// Represents a datetime as defined in LSPS0.datetime. Uses ISO8601 in UTC +/// timezone. +pub type DateTime = chrono::DateTime; + +#[cfg(test)] +mod tests { + use super::*; + use serde_json; + + #[derive(Debug, Serialize, Deserialize)] + struct TestMessage { + amount: Msat, + } + + /// Test serialization of a struct containing Msat. + #[test] + fn test_msat_serialization() { + let msg = TestMessage { + amount: Msat(12345000), + }; + + let expected_amount_json = r#""amount":"12345000""#; + + // Assert that the field gets serialized as string. + let json_string = serde_json::to_string(&msg).expect("Serialization failed"); + assert!( + json_string.contains(expected_amount_json), + "Serialized JSON should contain '{}'", + expected_amount_json + ); + + // Parse back to generic json value and check field. + let json_value: serde_json::Value = + serde_json::from_str(&json_string).expect("Failed to parse JSON back"); + assert_eq!( + json_value + .get("amount") + .expect("JSON should have 'amount' field"), + &serde_json::Value::String("12345000".to_string()), + "JSON 'amount' field should have the correct string value" + ); + } + + /// Test deserialization into a struct containing Msat. + #[test] + fn test_msat_deserialization_and_errors() { + // Case 1: Input string uses "_msat" suffix + let json_ok = r#"{"amount":"987654321"}"#; + let expected_value_msat = Msat(987654321); + let message1: TestMessage = + serde_json::from_str(json_ok).expect("Deserialization from string failed"); + assert_eq!(message1.amount, expected_value_msat); + + // Case 2: Non-numeric Value before suffix + let json_non_numeric = r#"{"amount":"abc"}"#; + let result_non_numeric = serde_json::from_str::(json_non_numeric); + assert!( + result_non_numeric.is_err(), + "Deserialization should fail for non-numeric value" + ); + } +} From 21056f6c0b55db083e0957076174f4b799b61334 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Fri, 19 Sep 2025 17:05:25 +0200 Subject: [PATCH 06/22] lsp_plugin: change listprotocols request Using lsp_id instead of peer as identifier Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 4 ++-- tests/test_cln_lsps.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index ce39d655c156..b100e5f4828e 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -62,7 +62,7 @@ async fn on_lsps_listprotocols( ) -> Result { #[derive(Deserialize)] struct Request { - peer: String, + lsp_id: String, } let dir = p.configuration().lightning_dir; let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); @@ -71,7 +71,7 @@ async fn on_lsps_listprotocols( // Create the transport first and handle potential errors let transport = Bolt8Transport::new( - &req.peer, + &req.lsp_id, rpc_path, p.state().hook_manager.clone(), None, // Use default timeout diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 5803d93456e3..512d432d5244 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -27,5 +27,5 @@ def test_lsps0_listprotocols(node_factory): # We don't need a channel to query for lsps services node_factory.join_nodes([l1, l2], fundchannel=False) - res = l1.rpc.lsps_listprotocols(peer=l2.info['id']) + res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id']) assert res From 4987d954b91ca981617375cea4531c5c6668b3fb Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Fri, 19 Sep 2025 17:10:38 +0200 Subject: [PATCH 07/22] lsp_plugin: check that featurebit is set and that the client is connected to the lsp before sending a request Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 53 +++++++++++++- plugins/lsps-plugin/src/lib.rs | 2 + plugins/lsps-plugin/src/service.rs | 10 ++- plugins/lsps-plugin/src/util.rs | 113 +++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 2 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index b100e5f4828e..52f1da613e14 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -1,13 +1,19 @@ -use anyhow::Context; +use anyhow::{anyhow, Context}; use cln_lsps::jsonrpc::client::JsonRpcClient; use cln_lsps::lsps0::{ self, transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager}, }; +use cln_lsps::util; +use cln_lsps::LSP_FEATURE_BIT; use cln_plugin::options; +use cln_rpc::model::requests::ListpeersRequest; +use cln_rpc::primitives::PublicKey; +use cln_rpc::ClnRpc; use log::debug; use serde::Deserialize; use std::path::Path; +use std::str::FromStr as _; /// An option to enable this service. const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag( @@ -66,9 +72,14 @@ async fn on_lsps_listprotocols( } let dir = p.configuration().lightning_dir; let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; let req: Request = serde_json::from_value(v).context("Failed to parse request JSON")?; + // Fail early: Check that we are connected to the peer and that it has the + // LSP feature bit set. + ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?; + // Create the transport first and handle potential errors let transport = Bolt8Transport::new( &req.lsp_id, @@ -90,3 +101,43 @@ async fn on_lsps_listprotocols( debug!("Received lsps0.list_protocols response: {:?}", res); Ok(serde_json::to_value(res)?) } + +/// Checks that the node is connected to the peer and that it has the LSP +/// feature bit set. +async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<(), anyhow::Error> { + let res = cln_client + .call_typed(&ListpeersRequest { + id: Some(PublicKey::from_str(lsp_id)?), + level: None, + }) + .await?; + + // unwrap in next line is safe as we checked that an item exists before. + if res.peers.is_empty() || !res.peers.first().unwrap().connected { + debug!("Node isn't connected to lsp {lsp_id}"); + return Err(anyhow!("not connected to lsp")); + } + + res.peers + .first() + .filter(|peer| { + // Check that feature bit is set + peer.features.as_deref().map_or(false, |f_str| { + if let Some(feature_bits) = hex::decode(f_str).ok() { + let mut fb = feature_bits.clone(); + fb.reverse(); + util::is_feature_bit_set(&fb, LSP_FEATURE_BIT) + } else { + false + } + }) + }) + .ok_or_else(|| { + anyhow!( + "peer is not an lsp, feature bit {} is missing", + LSP_FEATURE_BIT, + ) + })?; + + Ok(()) +} diff --git a/plugins/lsps-plugin/src/lib.rs b/plugins/lsps-plugin/src/lib.rs index 2eb605d92af9..aa93d0acb6bd 100644 --- a/plugins/lsps-plugin/src/lib.rs +++ b/plugins/lsps-plugin/src/lib.rs @@ -1,3 +1,5 @@ pub mod jsonrpc; pub mod lsps0; pub mod util; + +pub const LSP_FEATURE_BIT: usize = 729; diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index a350a3d94fcc..6e3a578f5f8c 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -3,10 +3,10 @@ use async_trait::async_trait; use cln_lsps::jsonrpc::server::{JsonRpcResponseWriter, RequestHandler}; use cln_lsps::jsonrpc::{server::JsonRpcServer, JsonRpcRequest}; use cln_lsps::jsonrpc::{JsonRpcResponse, RequestObject, RpcError, TransportError}; -use cln_lsps::lsps0; use cln_lsps::lsps0::model::{Lsps0listProtocolsRequest, Lsps0listProtocolsResponse}; use cln_lsps::lsps0::transport::{self, CustomMsg}; use cln_lsps::util::wrap_payload_with_peer_id; +use cln_lsps::{lsps0, util, LSP_FEATURE_BIT}; use cln_plugin::options::ConfigOption; use cln_plugin::{options, Plugin}; use cln_rpc::notifications::CustomMsgNotification; @@ -31,6 +31,14 @@ struct State { async fn main() -> Result<(), anyhow::Error> { if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout()) .option(OPTION_ENABLED) + .featurebits( + cln_plugin::FeatureBitsKind::Node, + util::feature_bit_to_hex(LSP_FEATURE_BIT), + ) + .featurebits( + cln_plugin::FeatureBitsKind::Init, + util::feature_bit_to_hex(LSP_FEATURE_BIT), + ) .hook("custommsg", on_custommsg) .configure() .await? diff --git a/plugins/lsps-plugin/src/util.rs b/plugins/lsps-plugin/src/util.rs index 89f308aee1c3..fe61bb37641f 100644 --- a/plugins/lsps-plugin/src/util.rs +++ b/plugins/lsps-plugin/src/util.rs @@ -5,6 +5,42 @@ use core::fmt; use serde_json::Value; use std::str::FromStr; +/// Checks if the feature bit is set in the provided bitmap. +/// Returns true if the `feature_bit` is set in the `bitmap`. Returns false if +/// the `feature_bit` is unset or our ouf bounds. +/// +/// # Arguments +/// +/// * `bitmap`: A slice of bytes representing the feature bitmap. +/// * `feature_bit`: The 0-based index of the bit to check across the bitmap. +/// +pub fn is_feature_bit_set(bitmap: &[u8], feature_bit: usize) -> bool { + let byte_index = feature_bit >> 3; // Equivalent to feature_bit / 8 + let bit_index = feature_bit & 7; // Equivalent to feature_bit % 8 + + if let Some(&target_byte) = bitmap.get(byte_index) { + let mask = 1 << bit_index; + (target_byte & mask) != 0 + } else { + false + } +} + +/// Returns a single feature_bit in hex representation, least-significant bit +/// first. +/// +/// # Arguments +/// +/// * `feature_bit`: The 0-based index of the bit to check across the bitmap. +/// +pub fn feature_bit_to_hex(feature_bit: usize) -> String { + let byte_index = feature_bit >> 3; // Equivalent to feature_bit / 8 + let mask = 1 << (feature_bit & 7); // Equivalent to feature_bit % 8 + let mut map = vec![0u8; byte_index + 1]; + map[0] |= mask; // least-significant bit first ordering. + hex::encode(&map) +} + /// Errors that can occur when unwrapping payload data #[derive(Debug, Clone, PartialEq)] pub enum UnwrapError { @@ -131,4 +167,81 @@ mod tests { Some(UnwrapError::InvalidPublicKey(_)) )); } + + #[test] + fn test_basic_bit_checks() { + // Example bitmap: + // Byte 0: 0b10100101 (165) -> Bits 0, 2, 5, 7 set + // Byte 1: 0b01101010 (106) -> Bits 1, 3, 5, 6 set (indices 9, 11, 13, 14) + let bitmap: &[u8] = &[0b10100101, 0b01101010]; + + // Check bits in byte 0 (indices 0-7) + assert_eq!(is_feature_bit_set(bitmap, 0), true); // Bit 0 + assert_eq!(is_feature_bit_set(bitmap, 1), false); // Bit 1 + assert_eq!(is_feature_bit_set(bitmap, 2), true); // Bit 2 + assert_eq!(is_feature_bit_set(bitmap, 3), false); // Bit 3 + assert_eq!(is_feature_bit_set(bitmap, 4), false); // Bit 4 + assert_eq!(is_feature_bit_set(bitmap, 5), true); // Bit 5 + assert_eq!(is_feature_bit_set(bitmap, 6), false); // Bit 6 + assert_eq!(is_feature_bit_set(bitmap, 7), true); // Bit 7 + + // Check bits in byte 1 (indices 8-15) + assert_eq!(is_feature_bit_set(bitmap, 8), false); // Bit 8 (Byte 1, bit 0) + assert_eq!(is_feature_bit_set(bitmap, 9), true); // Bit 9 (Byte 1, bit 1) + assert_eq!(is_feature_bit_set(bitmap, 10), false); // Bit 10 (Byte 1, bit 2) + assert_eq!(is_feature_bit_set(bitmap, 11), true); // Bit 11 (Byte 1, bit 3) + assert_eq!(is_feature_bit_set(bitmap, 12), false); // Bit 12 (Byte 1, bit 4) + assert_eq!(is_feature_bit_set(bitmap, 13), true); // Bit 13 (Byte 1, bit 5) + assert_eq!(is_feature_bit_set(bitmap, 14), true); // Bit 14 (Byte 1, bit 6) + assert_eq!(is_feature_bit_set(bitmap, 15), false); // Bit 15 (Byte 1, bit 7) + } + + #[test] + fn test_out_of_bounds() { + let bitmap: &[u8] = &[0b11111111, 0b00000000]; // 16 bits total + + assert_eq!(is_feature_bit_set(bitmap, 15), false); // Last valid bit (is 0) + assert_eq!(is_feature_bit_set(bitmap, 16), false); // Out of bounds + assert_eq!(is_feature_bit_set(bitmap, 100), false); // Way out of bounds + } + + #[test] + fn test_empty_bitmap() { + let bitmap: &[u8] = &[]; + assert_eq!(is_feature_bit_set(bitmap, 0), false); + assert_eq!(is_feature_bit_set(bitmap, 8), false); + } + + #[test] + fn test_feature_to_hex_bit_0_be() { + // Bit 0 is in Byte 0 (LE index). num_bytes=1. BE index = 1-1-0=0. + // Expected map: [0x01] + let feature_hex = feature_bit_to_hex(0); + assert_eq!(feature_hex, "01"); + assert!(is_feature_bit_set(&hex::decode(feature_hex).unwrap(), 0)); + } + + #[test] + fn test_feature_to_hex_bit_8_be() { + // Bit 8 is in Byte 1 (LE index). num_bytes=2. BE index = 2-1-1=0. + // Mask is 0x01 for bit 0 within its byte. + // Expected map: [0x01, 0x00] (Byte for 8-15 first, then 0-7) + let feature_hex = feature_bit_to_hex(8); + let mut decoded = hex::decode(&feature_hex).unwrap(); + decoded.reverse(); + assert_eq!(feature_hex, "0100"); + assert!(is_feature_bit_set(&decoded, 8)); + } + + #[test] + fn test_feature_to_hex_bit_27_be() { + // Bit 27 is in Byte 3 (LE index). num_bytes=4. BE index = 4-1-3=0. + // Mask is 0x08 for bit 3 within its byte. + // Expected map: [0x08, 0x00, 0x00, 0x00] (Byte for 24-31 first) + let feature_hex = feature_bit_to_hex(27); + let mut decoded = hex::decode(&feature_hex).unwrap(); + decoded.reverse(); + assert_eq!(feature_hex, "08000000"); + assert!(is_feature_bit_set(&decoded, 27)); + } } From 3a48ca5bf043bdac48ca3ec3d01e58c28ebb30c6 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Fri, 19 Sep 2025 17:16:58 +0200 Subject: [PATCH 08/22] lsp_plugin: refactor lsps0listprotocols handler Move the handler to a separate file, and add lsps2_enabled flag to the handler. Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/lsps0/handler.rs | 90 ++++++++++++++++++++++++ plugins/lsps-plugin/src/lsps0/mod.rs | 1 + plugins/lsps-plugin/src/service.rs | 27 ++----- 3 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 plugins/lsps-plugin/src/lsps0/handler.rs diff --git a/plugins/lsps-plugin/src/lsps0/handler.rs b/plugins/lsps-plugin/src/lsps0/handler.rs new file mode 100644 index 000000000000..6b552f477cd9 --- /dev/null +++ b/plugins/lsps-plugin/src/lsps0/handler.rs @@ -0,0 +1,90 @@ +use crate::{ + jsonrpc::{server::RequestHandler, JsonRpcResponse, RequestObject, RpcError}, + lsps0::model::{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, RpcError> { + let (payload, _) = unwrap_payload_with_peer_id(payload); + + let req: RequestObject = + 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 = Lsps0listProtocolsResponse { protocols }.into_response(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::{ + jsonrpc::{JsonRpcRequest, ResponseObject}, + 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) -> Vec { + 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(Some("test-id".to_string())); + let payload = create_wrapped_request(&request); + + let result = handler.handle(&payload).await.unwrap(); + let response: ResponseObject = + serde_json::from_slice(&result).unwrap(); + + let data = response.into_inner().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(Some("test-id".to_string())); + let payload = create_wrapped_request(&request); + + let result = handler.handle(&payload).await.unwrap(); + let response: ResponseObject = + serde_json::from_slice(&result).unwrap(); + + let data = response.into_inner().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 df78189cdb51..f32b0a55819b 100644 --- a/plugins/lsps-plugin/src/lsps0/mod.rs +++ b/plugins/lsps-plugin/src/lsps0/mod.rs @@ -1,3 +1,4 @@ +pub mod handler; pub mod model; pub mod primitives; pub mod transport; diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index 6e3a578f5f8c..116e13f2a744 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -1,9 +1,10 @@ use anyhow::anyhow; use async_trait::async_trait; -use cln_lsps::jsonrpc::server::{JsonRpcResponseWriter, RequestHandler}; +use cln_lsps::jsonrpc::server::JsonRpcResponseWriter; +use cln_lsps::jsonrpc::TransportError; use cln_lsps::jsonrpc::{server::JsonRpcServer, JsonRpcRequest}; -use cln_lsps::jsonrpc::{JsonRpcResponse, RequestObject, RpcError, TransportError}; -use cln_lsps::lsps0::model::{Lsps0listProtocolsRequest, Lsps0listProtocolsResponse}; +use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler; +use cln_lsps::lsps0::model::Lsps0listProtocolsRequest; use cln_lsps::lsps0::transport::{self, CustomMsg}; use cln_lsps::util::wrap_payload_with_peer_id; use cln_lsps::{lsps0, util, LSP_FEATURE_BIT}; @@ -51,7 +52,9 @@ async fn main() -> Result<(), anyhow::Error> { let lsps_builder = JsonRpcServer::builder().with_handler( Lsps0listProtocolsRequest::METHOD.to_string(), - Arc::new(Lsps0ListProtocolsHandler {}), + Arc::new(Lsps0ListProtocolsHandler { + lsps2_enabled: false, + }), ); let lsps_service = lsps_builder.build(); @@ -115,19 +118,3 @@ impl JsonRpcResponseWriter for LspsResponseWriter { transport::send_custommsg(&mut client, payload.to_vec(), self.peer_id).await } } - -pub struct Lsps0ListProtocolsHandler; - -#[async_trait] -impl RequestHandler for Lsps0ListProtocolsHandler { - async fn handle(&self, payload: &[u8]) -> core::result::Result, RpcError> { - let req: RequestObject = - serde_json::from_slice(payload).unwrap(); - if let Some(id) = req.id { - let res = Lsps0listProtocolsResponse { protocols: vec![] }.into_response(id); - let res_vec = serde_json::to_vec(&res).unwrap(); - return Ok(res_vec); - } - Ok(vec![]) - } -} From 2eb7c32dfda1f45f6a410dba56e577e4634fc209 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Fri, 19 Sep 2025 18:12:51 +0200 Subject: [PATCH 09/22] lsp_plugin: add lsps2 models Add models and options to enable lsps2 on the lsp Signed-off-by: Peter Neuroth --- Cargo.lock | 16 +- plugins/lsps-plugin/Cargo.toml | 3 +- plugins/lsps-plugin/src/lib.rs | 1 + plugins/lsps-plugin/src/lsps2/mod.rs | 14 + plugins/lsps-plugin/src/lsps2/model.rs | 640 +++++++++++++++++++++++++ plugins/lsps-plugin/src/service.rs | 39 +- tests/test_cln_lsps.py | 15 + 7 files changed, 718 insertions(+), 10 deletions(-) create mode 100644 plugins/lsps-plugin/src/lsps2/mod.rs create mode 100644 plugins/lsps-plugin/src/lsps2/model.rs diff --git a/Cargo.lock b/Cargo.lock index 1ba793c90362..b7a62211d873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,9 +334,9 @@ dependencies = [ [[package]] name = "bitcoin" -version = "0.32.6" +version = "0.32.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8929a18b8e33ea6b3c09297b687baaa71fb1b97353243a3f1029fad5c59c5b" +checksum = "0fda569d741b895131a88ee5589a467e73e9c4718e958ac9308e4f7dc44b6945" dependencies = [ "base58ck", "bech32 0.11.0", @@ -376,7 +376,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f00d509810205bfef492f1d6cefe1e2ac35b5e66675d51642315ddc5cee0e78" dependencies = [ - "bitcoin 0.32.6", + "bitcoin 0.32.7", "dnssec-prover", "getrandom 0.3.3", "lightning", @@ -482,6 +482,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -491,7 +492,7 @@ name = "cln-bip353" version = "0.1.0" dependencies = [ "anyhow", - "bitcoin 0.32.6", + "bitcoin 0.32.7", "bitcoin-payment-instructions", "bytes", "cln-plugin", @@ -546,6 +547,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "bitcoin 0.31.2", "chrono", "cln-plugin", "cln-rpc", @@ -1513,7 +1515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e540fcb289a76826c9c0b078d3dd1f05691972c5a53fb4d3120540862040a147" dependencies = [ "bech32 0.11.0", - "bitcoin 0.32.6", + "bitcoin 0.32.7", "dnssec-prover", "hashbrown 0.13.2", "libm", @@ -1529,7 +1531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11209f386879b97198b2bfc9e9c1e5d42870825c6bd4376f17f95357244d6600" dependencies = [ "bech32 0.11.0", - "bitcoin 0.32.6", + "bitcoin 0.32.7", "lightning-types", ] @@ -1539,7 +1541,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2cd84d4e71472035903e43caded8ecc123066ce466329ccd5ae537a8d5488c7" dependencies = [ - "bitcoin 0.32.6", + "bitcoin 0.32.7", ] [[package]] diff --git a/plugins/lsps-plugin/Cargo.toml b/plugins/lsps-plugin/Cargo.toml index 592fdbba1b96..60b3b57eda20 100644 --- a/plugins/lsps-plugin/Cargo.toml +++ b/plugins/lsps-plugin/Cargo.toml @@ -14,7 +14,8 @@ path = "src/service.rs" [dependencies] anyhow = "1.0" async-trait = "0.1" -chrono = "0.4.42" +bitcoin = "0.31" +chrono = { version= "0.4.42", features = ["serde"] } cln-plugin = { version = "0.5", path = "../" } cln-rpc = { version = "0.5", path = "../../cln-rpc" } hex = "0.4" diff --git a/plugins/lsps-plugin/src/lib.rs b/plugins/lsps-plugin/src/lib.rs index aa93d0acb6bd..f14b96c7de90 100644 --- a/plugins/lsps-plugin/src/lib.rs +++ b/plugins/lsps-plugin/src/lib.rs @@ -1,5 +1,6 @@ pub mod jsonrpc; pub mod lsps0; +pub mod lsps2; pub mod util; pub const LSP_FEATURE_BIT: usize = 729; diff --git a/plugins/lsps-plugin/src/lsps2/mod.rs b/plugins/lsps-plugin/src/lsps2/mod.rs new file mode 100644 index 000000000000..0d0c0b35e8bd --- /dev/null +++ b/plugins/lsps-plugin/src/lsps2/mod.rs @@ -0,0 +1,14 @@ +use cln_plugin::options; + +pub mod model; + +pub const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag( + "dev-lsps2-service-enabled", + "Enables lsps2 for the LSP service", +); + +pub const OPTION_PROMISE_SECRET: options::StringConfigOption = + options::ConfigOption::new_str_no_default( + "dev-lsps2-promise-secret", + "A 64-character hex string that is the secret for promises", + ); diff --git a/plugins/lsps-plugin/src/lsps2/model.rs b/plugins/lsps-plugin/src/lsps2/model.rs new file mode 100644 index 000000000000..7a186db0d6c6 --- /dev/null +++ b/plugins/lsps-plugin/src/lsps2/model.rs @@ -0,0 +1,640 @@ +use crate::{ + jsonrpc::{JsonRpcRequest, RpcError}, + lsps0::primitives::{DateTime, Msat, Ppm, ShortChannelId}, +}; +use bitcoin::hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine}; +use chrono::Utc; +use log::debug; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq)] +pub enum Error { + InvalidOpeningFeeParams, + PaymentSizeTooSmall, + PaymentSizeTooLarge, + ClientRejected, +} + +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let err_str = match self { + Error::InvalidOpeningFeeParams => "invalid opening fee params", + Error::PaymentSizeTooSmall => "payment size too small", + Error::PaymentSizeTooLarge => "payment size too large", + Error::ClientRejected => "client rejected", + }; + write!(f, "{}", &err_str) + } +} + +impl From for RpcError { + fn from(value: Error) -> Self { + match value { + Error::InvalidOpeningFeeParams => RpcError { + code: 201, + message: "invalid opening fee params".to_string(), + data: None, + }, + Error::PaymentSizeTooSmall => RpcError { + code: 202, + message: "payment size too small".to_string(), + data: None, + }, + Error::PaymentSizeTooLarge => RpcError { + code: 203, + message: "payment size too large".to_string(), + data: None, + }, + Error::ClientRejected => RpcError { + code: 001, + message: "client rejected".to_string(), + data: None, + }, + } + } +} + +impl core::error::Error for Error {} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Lsps2GetInfoRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +impl JsonRpcRequest for Lsps2GetInfoRequest { + const METHOD: &'static str = "lsps2.get_info"; +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Lsps2GetInfoResponse { + pub opening_fee_params_menu: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PromiseError { + TooLong { length: usize, max: usize }, +} + +impl core::fmt::Display for PromiseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PromiseError::TooLong { length, max } => { + write!( + f, + "promise string is too long: {} bytes (max allowed {})", + length, max + ) + } + } + } +} + +impl core::error::Error for PromiseError {} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(try_from = "String")] +pub struct Promise(String); + +impl Promise { + pub const MAX_BYTES: usize = 512; +} + +impl TryFrom for Promise { + type Error = PromiseError; + + fn try_from(s: String) -> Result { + let len = s.len(); + if len <= Promise::MAX_BYTES { + Ok(Promise(s)) + } else { + Err(PromiseError::TooLong { + length: len, + max: Promise::MAX_BYTES, + }) + } + } +} + +impl TryFrom<&str> for Promise { + type Error = PromiseError; + + fn try_from(s: &str) -> Result { + let len = s.len(); + if len <= Promise::MAX_BYTES { + Ok(Promise(s.to_owned())) + } else { + Err(PromiseError::TooLong { + length: len, + max: Promise::MAX_BYTES, + }) + } + } +} + +impl core::fmt::Display for Promise { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Represents a set of parameters for calculating the opening fee for a JIT +/// channel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] // LSPS2 requires the client to fail if a field is unrecognized. +pub struct OpeningFeeParams { + pub min_fee_msat: Msat, + pub proportional: Ppm, + pub valid_until: DateTime, + pub min_lifetime: u32, + pub max_client_to_self_delay: u32, + pub min_payment_size_msat: Msat, + pub max_payment_size_msat: Msat, + pub promise: Promise, // Max 512 bytes +} + +impl OpeningFeeParams { + pub fn validate( + &self, + secret: &[u8], + payment_size_msat: Option, + receivable: Option, + ) -> Result<(), Error> { + // LSPs MUST check that the opening_fee_params.promise does in fact + // prove that it previously promised the specified opening_fee_params. + let mut hmac = HmacEngine::::new(&secret); + hmac.input(&self.min_fee_msat.msat().to_be_bytes()); + hmac.input(&self.proportional.ppm().to_be_bytes()); + hmac.input(self.valid_until.to_rfc3339().as_bytes()); + hmac.input(&self.min_lifetime.to_be_bytes()); + hmac.input(&self.max_client_to_self_delay.to_be_bytes()); + hmac.input(&self.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(&self.max_payment_size_msat.msat().to_be_bytes()); + let promise: String = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + if self.promise != Promise(promise) { + return Err(Error::InvalidOpeningFeeParams); + } + + // LSPs MUST check that the opening_fee_params.valid_until is not a past + // datetime. + let now = Utc::now(); + if now > self.valid_until { + debug!("Got invalid opening fee params: timeout, {:?}", self); + return Err(Error::InvalidOpeningFeeParams); + } + + // If the payment_size_msat is specified in the request, the LSP: + // - MUST compute the opening_fee and check that the computation did + // not hit an overflow failure. + // - MUST check that the resulting opening_fee is strictly less than + // the payment_size_msat. + // - SHOULD check that it has sufficient incoming liquidity from the + // public network to be able to receive at least + // payment_size_msat. + if let Some(payment_size_msat) = payment_size_msat { + let opening_fee = compute_opening_fee( + payment_size_msat.msat(), + self.min_fee_msat.msat(), + self.proportional.ppm() as u64, + ) + .ok_or(Error::PaymentSizeTooLarge)?; + if opening_fee >= payment_size_msat.msat() { + return Err(Error::PaymentSizeTooSmall); + } + + if let Some(rec) = receivable { + if opening_fee >= rec.msat() { + return Err(Error::PaymentSizeTooLarge); + } + } + } + + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Lsps2BuyRequest { + pub opening_fee_params: OpeningFeeParams, + #[serde(skip_serializing_if = "Option::is_none")] + pub payment_size_msat: Option, +} + +impl JsonRpcRequest for Lsps2BuyRequest { + const METHOD: &'static str = "lsps2.buy"; +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Lsps2BuyResponse { + pub jit_channel_scid: ShortChannelId, + pub lsp_cltv_expiry_delta: u32, + // is an optional Boolean. If not specified, it defaults to false. If + // specified and true, the client MUST trust the LSP to actually create and + // confirm a valid channel funding transaction. + #[serde(default)] + pub client_trusts_lsp: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Lsps2PolicyGetInfoRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, +} + +impl From for Lsps2PolicyGetInfoRequest { + fn from(value: Lsps2GetInfoRequest) -> Self { + Self { token: value.token } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Lsps2PolicyGetInfoResponse { + pub policy_opening_fee_params_menu: Vec, +} + +/// An internal representation of a policy of parameters for calculating the +/// opening fee for a JIT channel. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PolicyOpeningFeeParams { + pub min_fee_msat: Msat, + pub proportional: Ppm, + pub valid_until: DateTime, + pub min_lifetime: u32, + pub max_client_to_self_delay: u32, + pub min_payment_size_msat: Msat, + pub max_payment_size_msat: Msat, +} + +impl PolicyOpeningFeeParams { + pub fn get_hmac_hex(&self, secret: &[u8]) -> String { + let mut hmac = HmacEngine::::new(&secret); + hmac.input(&self.min_fee_msat.msat().to_be_bytes()); + hmac.input(&self.proportional.ppm().to_be_bytes()); + hmac.input(self.valid_until.to_rfc3339().as_bytes()); + hmac.input(&self.min_lifetime.to_be_bytes()); + hmac.input(&self.max_client_to_self_delay.to_be_bytes()); + hmac.input(&self.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(&self.max_payment_size_msat.msat().to_be_bytes()); + let promise = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + promise + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DatastoreEntry { + pub peer_id: cln_rpc::primitives::PublicKey, + pub opening_fee_params: OpeningFeeParams, + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_payment_size: Option, +} + +/// Computes the opening fee in millisatoshis as described in LSPS2. +/// Returns None if an arithmetic overflow occurs during calculation. +/// +/// # Arguments +/// * `payment_size_msat` - The size of the payment for which the channel is +/// being opened. +/// * `opening_fee_min_fee_msat` - The minimum fee to be paid by the client to +/// the LSP +/// * `opening_fee_proportional` - The proportional fee charged by the LSP +pub fn compute_opening_fee( + payment_size_msat: u64, + opening_fee_min_fee_msat: u64, + opening_fee_proportional: u64, +) -> Option { + payment_size_msat + .checked_mul(opening_fee_proportional) + .and_then(|f| f.checked_add(999999)) + .and_then(|f| f.checked_div(1000000)) + .map(|f| std::cmp::max(f, opening_fee_min_fee_msat)) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + + use super::*; + + // Helper struct for testing Serde + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct TestData { + label: String, + value: Promise, + } + + // Helper function to create valid opening fee params + fn create_valid_opening_fee_params(secret: &[u8]) -> OpeningFeeParams { + let params = OpeningFeeParams { + min_fee_msat: Msat::from_msat(1000), + proportional: Ppm::from_ppm(1000), // 0.1% + valid_until: Utc::now() + Duration::hours(1), // Valid for 1 hour + min_lifetime: 144, // blocks + max_client_to_self_delay: 2016, // blocks + min_payment_size_msat: Msat::from_msat(1000), // 1 Sat + max_payment_size_msat: Msat::from_msat(100_000_000_000), // 1 BTC + promise: Promise("placeholder".to_string()), // Will be replaced + }; + + // Compute the correct promise + let mut hmac = HmacEngine::::new(secret); + hmac.input(¶ms.min_fee_msat.msat().to_be_bytes()); + hmac.input(¶ms.proportional.ppm().to_be_bytes()); + hmac.input(params.valid_until.to_rfc3339().as_bytes()); + hmac.input(¶ms.min_lifetime.to_be_bytes()); + hmac.input(¶ms.max_client_to_self_delay.to_be_bytes()); + hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes()); + let promise: String = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + + OpeningFeeParams { + promise: Promise(promise), + ..params + } + } + + #[test] + fn test_serde_promise_ok() { + let json = r#"{"label": "short", "value": "This is valid"}"#; + let result = serde_json::from_str::(json); + assert!(result.is_ok()); + let data = result.unwrap(); + assert_eq!(data.value.0, "This is valid"); + } + + #[test] + fn test_serde_promise_too_long() { + let long_value = "a".repeat(513); // Exceeds 512 bytes + let json = format!(r#"{{"label": "long", "value": "{}"}}"#, long_value); + let result = serde_json::from_str::(&json); + assert!(result.is_err()); + // Check the error message relates to our PromiseError + assert!(result + .unwrap_err() + .to_string() + .contains("promise string is too long")); + } + + #[test] + fn test_serde_promise_wrong_type() { + // Input JSON has a number where a string is expected for 'value' + let json = r#"{"label": "wrong_type", "value": 123}"#; + let result = serde_json::from_str::(json); + assert!(result.is_err()); + // This error occurs when Serde tries to deserialize 123 as the String + // required by `try_from = "String"`. + assert!(result + .unwrap_err() + .to_string() + .contains("invalid type: integer")); + } + + #[test] + fn test_validate_success_minimal() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + let result = params.validate(secret, None, None); + assert!( + result.is_ok(), + "Valid params with no payment_size should succeed" + ); + } + + #[test] + fn test_validate_success_with_payment_size() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + let payment_size = Msat::from_msat(10_000_000); // 10M msat + + let result = params.validate(secret, Some(payment_size), None); + assert!( + result.is_ok(), + "Valid params with valid payment_size should succeed" + ); + } + + #[test] + fn test_validate_success_with_payment_size_and_receivable() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + let payment_size = Msat::from_msat(10_000_000); // 10M msat + let receivable = Msat::from_msat(50_000_000); // 50M msat + + let result = params.validate(secret, Some(payment_size), Some(receivable)); + assert!( + result.is_ok(), + "Valid params with payment_size and receivable should succeed" + ); + } + + #[test] + fn test_validate_invalid_promise() { + let secret = b"test_secret_key_32_bytes_long___"; + let mut params = create_valid_opening_fee_params(secret); + params.min_fee_msat = Msat(10); + + let result = params.validate(secret, None, None); + assert!( + matches!(result, Err(Error::InvalidOpeningFeeParams)), + "Invalid promise should fail validation" + ); + } + + #[test] + fn test_validate_wrong_secret() { + let secret1 = b"test_secret_key_32_bytes_long___"; + let secret2 = b"different_secret_key_32_bytes___"; + let params = create_valid_opening_fee_params(secret1); + + let result = params.validate(secret2, None, None); + assert!( + matches!(result, Err(Error::InvalidOpeningFeeParams)), + "Wrong secret should fail validation" + ); + } + + #[test] + fn test_validate_expired_timestamp() { + let secret = b"test_secret_key_32_bytes_long___"; + let mut params = create_valid_opening_fee_params(secret); + params.valid_until = Utc::now() - Duration::hours(1); // Expired 1 hour ago + + // Recompute promise with expired timestamp + let mut hmac = HmacEngine::::new(secret); + hmac.input(¶ms.min_fee_msat.msat().to_be_bytes()); + hmac.input(¶ms.proportional.ppm().to_be_bytes()); + hmac.input(params.valid_until.to_rfc3339().as_bytes()); + hmac.input(¶ms.min_lifetime.to_be_bytes()); + hmac.input(¶ms.max_client_to_self_delay.to_be_bytes()); + hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes()); + let promise: String = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + params.promise = Promise(promise); + + let result = params.validate(secret, None, None); + assert!( + matches!(result, Err(Error::InvalidOpeningFeeParams)), + "Expired timestamp should fail validation" + ); + } + + #[test] + fn test_validate_payment_size_overflow() { + let secret = b"test_secret_key_32_bytes_long___"; + let mut params = create_valid_opening_fee_params(secret); + // Set proportional fee high enough to cause overflow + params.proportional = Ppm::from_ppm(u32::MAX); + + // Recompute promise + let mut hmac = HmacEngine::::new(secret); + hmac.input(¶ms.min_fee_msat.msat().to_be_bytes()); + hmac.input(¶ms.proportional.ppm().to_be_bytes()); + hmac.input(params.valid_until.to_rfc3339().as_bytes()); + hmac.input(¶ms.min_lifetime.to_be_bytes()); + hmac.input(¶ms.max_client_to_self_delay.to_be_bytes()); + hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes()); + let promise: String = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + params.promise = Promise(promise); + + let payment_size = Msat::from_msat(u64::MAX); + let result = params.validate(secret, Some(payment_size), None); + assert!( + matches!(result, Err(Error::PaymentSizeTooLarge)), + "Overflow in fee calculation should return PaymentSizeTooLarge" + ); + } + + #[test] + fn test_validate_opening_fee_equals_payment_size() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + // Find a payment size where opening fee equals payment size + // With min_fee_msat = 1000 and proportional = 1000 (0.1%) + // The opening fee will be max(1000, payment * 1000 / 1_000_000) + // So for small payments, fee = 1000 + let payment_size = Msat::from_msat(1000); // Same as min_fee_msat + + let result = params.validate(secret, Some(payment_size), None); + assert!( + matches!(result, Err(Error::PaymentSizeTooSmall)), + "Opening fee equal to payment size should fail" + ); + } + + #[test] + fn test_validate_opening_fee_greater_than_payment_size() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + // Payment size smaller than minimum fee + let payment_size = Msat::from_msat(500); // Less than min_fee_msat (1000) + + let result = params.validate(secret, Some(payment_size), None); + assert!( + matches!(result, Err(Error::PaymentSizeTooSmall)), + "Opening fee greater than payment size should fail" + ); + } + + #[test] + fn test_validate_opening_fee_equals_receivable() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + let payment_size = Msat::from_msat(10_000_000); // 10M msat + let receivable = Msat::from_msat(1000); // Same as min_fee_msat + + let result = params.validate(secret, Some(payment_size), Some(receivable)); + assert!( + matches!(result, Err(Error::PaymentSizeTooLarge)), + "Opening fee equal to receivable should fail" + ); + } + + #[test] + fn test_validate_opening_fee_greater_than_receivable() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + let payment_size = Msat::from_msat(10_000_000); // 10M msat + let receivable = Msat::from_msat(500); // Less than min_fee_msat (1000) + + let result = params.validate(secret, Some(payment_size), Some(receivable)); + assert!( + matches!(result, Err(Error::PaymentSizeTooLarge)), + "Opening fee greater than receivable should fail" + ); + } + + #[test] + fn test_validate_large_payment_proportional_fee() { + let secret = b"test_secret_key_32_bytes_long___"; + let params = create_valid_opening_fee_params(secret); + + // Large payment where proportional fee dominates + // Opening fee = max(1000, 1_000_000_000 * 1000 / 1_000_000) = max(1000, 1_000_000) = 1_000_000 + let payment_size = Msat::from_msat(1_000_000_000); + + let result = params.validate(secret, Some(payment_size), None); + assert!( + result.is_ok(), + "Large payment with proportional fee should succeed" + ); + } + + #[test] + fn test_validate_max_values() { + let secret = b"test_secret_key_32_bytes_long___"; + let mut params = OpeningFeeParams { + min_fee_msat: Msat::from_msat(u64::MAX / 1000), // Avoid overflow + proportional: Ppm::from_ppm(100), // Small proportional to avoid overflow + valid_until: Utc::now() + Duration::hours(1), + min_lifetime: u32::MAX, + max_client_to_self_delay: u32::MAX, + min_payment_size_msat: Msat::from_msat(1), + max_payment_size_msat: Msat::from_msat(u64::MAX), + promise: Promise("placeholder".to_string()), + }; + + // Compute promise + let mut hmac = HmacEngine::::new(secret); + hmac.input(¶ms.min_fee_msat.msat().to_be_bytes()); + hmac.input(¶ms.proportional.ppm().to_be_bytes()); + hmac.input(params.valid_until.to_rfc3339().as_bytes()); + hmac.input(¶ms.min_lifetime.to_be_bytes()); + hmac.input(¶ms.max_client_to_self_delay.to_be_bytes()); + hmac.input(¶ms.min_payment_size_msat.msat().to_be_bytes()); + hmac.input(¶ms.max_payment_size_msat.msat().to_be_bytes()); + let promise: String = Hmac::from_engine(hmac) + .to_byte_array() + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + params.promise = Promise(promise); + + let result = params.validate(secret, None, None); + assert!(result.is_ok(), "Maximum safe values should be valid"); + } +} diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index 116e13f2a744..aff0fb6bb58b 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -7,7 +7,7 @@ use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler; use cln_lsps::lsps0::model::Lsps0listProtocolsRequest; use cln_lsps::lsps0::transport::{self, CustomMsg}; use cln_lsps::util::wrap_payload_with_peer_id; -use cln_lsps::{lsps0, util, LSP_FEATURE_BIT}; +use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT}; use cln_plugin::options::ConfigOption; use cln_plugin::{options, Plugin}; use cln_rpc::notifications::CustomMsgNotification; @@ -32,6 +32,8 @@ struct State { async fn main() -> Result<(), anyhow::Error> { if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout()) .option(OPTION_ENABLED) + .option(lsps2::OPTION_ENABLED) + .option(lsps2::OPTION_PROMISE_SECRET) .featurebits( cln_plugin::FeatureBitsKind::Node, util::feature_bit_to_hex(LSP_FEATURE_BIT), @@ -50,12 +52,45 @@ async fn main() -> Result<(), anyhow::Error> { .await; } + if plugin.option(&lsps2::OPTION_ENABLED)? { + log::debug!("lsps2 enabled"); + let secret_hex = plugin.option(&lsps2::OPTION_PROMISE_SECRET)?; + if let Some(secret_hex) = secret_hex { + let secret_hex = secret_hex.trim().to_lowercase(); + + let decoded_bytes = match hex::decode(&secret_hex) { + Ok(bytes) => bytes, + Err(_) => { + return plugin + .disable(&format!( + "Invalid hex string for promise secret: {}", + secret_hex + )) + .await; + } + }; + + let _: [u8; 32] = match decoded_bytes.try_into() { + Ok(array) => array, + Err(vec) => { + return plugin + .disable(&format!( + "Promise secret must be exactly 32 bytes, got {}", + vec.len() + )) + .await; + } + }; + } + } + let lsps_builder = JsonRpcServer::builder().with_handler( Lsps0listProtocolsRequest::METHOD.to_string(), Arc::new(Lsps0ListProtocolsHandler { - lsps2_enabled: false, + lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?, }), ); + let lsps_service = lsps_builder.build(); let state = State { lsps_service }; diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 512d432d5244..40a8ae68b8d2 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -29,3 +29,18 @@ def test_lsps0_listprotocols(node_factory): res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id']) assert res + +def test_lsps2_enabled(node_factory): + l1, l2 = node_factory.get_nodes(2, opts=[ + {"dev-lsps-client-enabled": None}, + { + "dev-lsps-service-enabled": None, + "dev-lsps2-service-enabled": None, + "dev-lsps2-promise-secret": "0" * 64 + } + ]) + + node_factory.join_nodes([l1, l2], fundchannel=False) + + res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id']) + assert res['protocols'] == [2] From 6d26facf2b78336b3aab94995d417eb585bca468 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Sun, 21 Sep 2025 20:09:46 +0200 Subject: [PATCH 10/22] lsp_plugin: add lsps2_getinfo handler and call This commit adds the lsps2_get_info call defined by BLIP052. It also adds a test policy plugin that the LSP service plugin uses to fetch the actual fee menu from to separate the concerns of providing a spec compliant implementation of an LSP and making business decisions about fee prices. Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 56 +++++- plugins/lsps-plugin/src/lsps2/handler.rs | 239 +++++++++++++++++++++++ plugins/lsps-plugin/src/lsps2/mod.rs | 1 + plugins/lsps-plugin/src/service.rs | 27 ++- tests/plugins/lsps2_policy.py | 45 +++++ tests/test_cln_lsps.py | 20 ++ 6 files changed, 378 insertions(+), 10 deletions(-) create mode 100644 plugins/lsps-plugin/src/lsps2/handler.rs create mode 100755 tests/plugins/lsps2_policy.py diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index 52f1da613e14..b861ca544c12 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -4,6 +4,7 @@ use cln_lsps::lsps0::{ self, transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager}, }; +use cln_lsps::lsps2::model::{Lsps2GetInfoRequest, Lsps2GetInfoResponse}; use cln_lsps::util; use cln_lsps::LSP_FEATURE_BIT; use cln_plugin::options; @@ -11,7 +12,7 @@ use cln_rpc::model::requests::ListpeersRequest; use cln_rpc::primitives::PublicKey; use cln_rpc::ClnRpc; use log::debug; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::path::Path; use std::str::FromStr as _; @@ -45,6 +46,11 @@ async fn main() -> Result<(), anyhow::Error> { "list protocols supported by lsp", on_lsps_listprotocols, ) + .rpcmethod( + "lsps-lsps2-getinfo", + "Low-level command to request the opening fee menu of an LSP", + on_lsps_lsps2_getinfo, + ) .configure() .await? { @@ -61,7 +67,47 @@ async fn main() -> Result<(), anyhow::Error> { } } -/// RPC Method handler for `lsps-listprotocols`. +/// Rpc Method handler for `lsps-lsps2-getinfo`. +async fn on_lsps_lsps2_getinfo( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let req: ClnRpcLsps2GetinfoRequest = + serde_json::from_value(v).context("Failed to parse request JSON")?; + debug!( + "Requesting opening fee menu from lsp {} with token {:?}", + req.lsp_id, req.token + ); + + let dir = p.configuration().lightning_dir; + let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + // Fail early: Check that we are connected to the peer and that it has the + // LSP feature bit set. + ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?; + + // Create Transport and Client + let transport = Bolt8Transport::new( + &req.lsp_id, + rpc_path.clone(), // Clone path for potential reuse + p.state().hook_manager.clone(), + None, // Use default timeout + ) + .context("Failed to create Bolt8Transport")?; + let client = JsonRpcClient::new(transport); + + // 1. Call lsps2.get_info. + let info_req = Lsps2GetInfoRequest { token: req.token }; + let info_res: Lsps2GetInfoResponse = client + .call_typed(info_req) + .await + .context("lsps2.get_info call failed")?; + debug!("received lsps2.get_info response: {:?}", info_res); + + Ok(serde_json::to_value(info_res)?) +} + async fn on_lsps_listprotocols( p: cln_plugin::Plugin, v: serde_json::Value, @@ -141,3 +187,9 @@ async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<( Ok(()) } + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClnRpcLsps2GetinfoRequest { + lsp_id: String, + token: Option, +} diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs new file mode 100644 index 000000000000..2f9162ad83c9 --- /dev/null +++ b/plugins/lsps-plugin/src/lsps2/handler.rs @@ -0,0 +1,239 @@ +use crate::{ + jsonrpc::{server::RequestHandler, JsonRpcResponse as _, RequestObject, RpcError}, + lsps2::model::{ + Lsps2GetInfoRequest, Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest, + Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise, + }, + util::unwrap_payload_with_peer_id, +}; +use anyhow::{Context, Result as AnyResult}; +use async_trait::async_trait; +use cln_rpc::ClnRpc; +use std::path::PathBuf; + +#[async_trait] +pub trait ClnApi: Send + Sync { + async fn lsps2_getpolicy( + &self, + params: &Lsps2PolicyGetInfoRequest, + ) -> AnyResult; +} + +#[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::new(&self.rpc_path).await + } +} + +#[async_trait] +impl ClnApi for ClnApiRpc { + async fn lsps2_getpolicy( + &self, + params: &Lsps2PolicyGetInfoRequest, + ) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_raw("dev-lsps2-getpolicy", params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling dev-lsps2-getpolicy") + } +} + +/// Handler for the `lsps2.get_info` method. +pub struct Lsps2GetInfoHandler { + pub api: A, + pub promise_secret: [u8; 32], +} + +impl Lsps2GetInfoHandler { + pub fn new(api: A, promise_secret: [u8; 32]) -> Self { + Self { + api, + promise_secret, + } + } +} + +/// The RequestHandler calls the internal rpc command `dev-lsps2-getinfo`. It +/// expects a plugin has registered this command and manages policies for the +/// LSPS2 service. +#[async_trait] +impl RequestHandler for Lsps2GetInfoHandler { + async fn handle(&self, payload: &[u8]) -> core::result::Result, RpcError> { + let (payload, _) = unwrap_payload_with_peer_id(payload); + + let req: RequestObject = 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::, RpcError>>()?; + + let res = Lsps2GetInfoResponse { + opening_fee_params_menu, + } + .into_response(req.id.unwrap()); // We checked that we got an id before. + + serde_json::to_vec(&res) + .map_err(|e| RpcError::internal_error(format!("Failed to serialize response: {}", e))) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + use crate::{ + jsonrpc::{JsonRpcRequest, ResponseObject}, + lsps0::primitives::{Msat, Ppm}, + lsps2::model::PolicyOpeningFeeParams, + util::wrap_payload_with_peer_id, + }; + use chrono::{TimeZone, Utc}; + use cln_rpc::primitives::PublicKey; + use cln_rpc::RpcError as ClnRpcError; + + const PUBKEY: [u8; 33] = [ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, + 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, 0x5b, 0x16, + 0xf8, 0x17, 0x98, + ]; + + fn create_peer_id() -> PublicKey { + PublicKey::from_slice(&PUBKEY).expect("Valid pubkey") + } + + fn create_wrapped_request(request: &RequestObject) -> Vec { + let payload = serde_json::to_vec(request).expect("Failed to serialize request"); + wrap_payload_with_peer_id(&payload, create_peer_id()) + } + + #[derive(Clone, Default)] + struct FakeCln { + lsps2_getpolicy_response: Arc>>, + lsps2_getpolicy_error: Arc>>, + } + + #[async_trait] + impl ClnApi for FakeCln { + async fn lsps2_getpolicy( + &self, + _params: &Lsps2PolicyGetInfoRequest, + ) -> Result { + if let Some(err) = self.lsps2_getpolicy_error.lock().unwrap().take() { + return Err(anyhow::Error::new(err).context("from fake api")); + }; + if let Some(res) = self.lsps2_getpolicy_response.lock().unwrap().take() { + return Ok(res); + }; + panic!("No lsps2 response defined"); + } + } + + #[tokio::test] + async fn test_successful_get_info() { + let promise_secret = [0u8; 32]; + let params = Lsps2PolicyGetInfoResponse { + policy_opening_fee_params_menu: vec![PolicyOpeningFeeParams { + min_fee_msat: Msat(2000), + proportional: Ppm(10000), + valid_until: Utc.with_ymd_and_hms(1970, 1, 1, 0, 0, 0).unwrap(), + min_lifetime: 1000, + max_client_to_self_delay: 42, + min_payment_size_msat: Msat(1000000), + max_payment_size_msat: Msat(100000000), + }], + }; + let promise = params.policy_opening_fee_params_menu[0].get_hmac_hex(&promise_secret); + let fake = FakeCln::default(); + *fake.lsps2_getpolicy_response.lock().unwrap() = Some(params); + let handler = Lsps2GetInfoHandler::new(fake, promise_secret); + + let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string())); + let payload = create_wrapped_request(&request); + + let result = handler.handle(&payload).await.unwrap(); + let response: ResponseObject = + serde_json::from_slice(&result).unwrap(); + let response = response.into_inner().unwrap(); + + assert_eq!( + response.opening_fee_params_menu[0].min_payment_size_msat, + Msat(1000000) + ); + assert_eq!( + response.opening_fee_params_menu[0].max_payment_size_msat, + Msat(100000000) + ); + assert_eq!( + response.opening_fee_params_menu[0].promise, + promise.try_into().unwrap() + ); + } + + #[tokio::test] + async fn test_get_info_rpc_error_handling() { + let fake = FakeCln::default(); + *fake.lsps2_getpolicy_error.lock().unwrap() = Some(ClnRpcError { + code: Some(-1), + message: "not found".to_string(), + data: None, + }); + let handler = Lsps2GetInfoHandler::new(fake, [0; 32]); + let request = Lsps2GetInfoRequest { token: None }.into_request(Some("test-id".to_string())); + let payload = create_wrapped_request(&request); + + let result = handler.handle(&payload).await; + + assert!(result.is_err()); + let error = result.unwrap_err(); + assert_eq!(error.code, 200); + assert!(error.message.contains("failed to fetch policy")); + } +} diff --git a/plugins/lsps-plugin/src/lsps2/mod.rs b/plugins/lsps-plugin/src/lsps2/mod.rs index 0d0c0b35e8bd..b217f987154d 100644 --- a/plugins/lsps-plugin/src/lsps2/mod.rs +++ b/plugins/lsps-plugin/src/lsps2/mod.rs @@ -1,5 +1,6 @@ use cln_plugin::options; +pub mod handler; pub mod model; pub const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag( diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index aff0fb6bb58b..11905dbfd544 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -6,6 +6,7 @@ use cln_lsps::jsonrpc::{server::JsonRpcServer, JsonRpcRequest}; use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler; use cln_lsps::lsps0::model::Lsps0listProtocolsRequest; use cln_lsps::lsps0::transport::{self, CustomMsg}; +use cln_lsps::lsps2::model::Lsps2GetInfoRequest; use cln_lsps::util::wrap_payload_with_peer_id; use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT}; use cln_plugin::options::ConfigOption; @@ -46,12 +47,22 @@ async fn main() -> Result<(), anyhow::Error> { .configure() .await? { + let rpc_path = + Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file); + if !plugin.option(&OPTION_ENABLED)? { return plugin .disable(&format!("`{}` not enabled", OPTION_ENABLED.name)) .await; } + let mut lsps_builder = JsonRpcServer::builder().with_handler( + Lsps0listProtocolsRequest::METHOD.to_string(), + Arc::new(Lsps0ListProtocolsHandler { + lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?, + }), + ); + if plugin.option(&lsps2::OPTION_ENABLED)? { log::debug!("lsps2 enabled"); let secret_hex = plugin.option(&lsps2::OPTION_PROMISE_SECRET)?; @@ -70,7 +81,7 @@ async fn main() -> Result<(), anyhow::Error> { } }; - let _: [u8; 32] = match decoded_bytes.try_into() { + let secret: [u8; 32] = match decoded_bytes.try_into() { Ok(array) => array, Err(vec) => { return plugin @@ -81,16 +92,16 @@ async fn main() -> Result<(), anyhow::Error> { .await; } }; + + let cln_api_rpc = lsps2::handler::ClnApiRpc::new(rpc_path); + let getinfo_handler = lsps2::handler::Lsps2GetInfoHandler::new(cln_api_rpc, secret); + lsps_builder = lsps_builder.with_handler( + Lsps2GetInfoRequest::METHOD.to_string(), + Arc::new(getinfo_handler), + ); } } - let lsps_builder = JsonRpcServer::builder().with_handler( - Lsps0listProtocolsRequest::METHOD.to_string(), - Arc::new(Lsps0ListProtocolsHandler { - lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?, - }), - ); - let lsps_service = lsps_builder.build(); let state = State { lsps_service }; diff --git a/tests/plugins/lsps2_policy.py b/tests/plugins/lsps2_policy.py new file mode 100755 index 000000000000..e16eb4ae159d --- /dev/null +++ b/tests/plugins/lsps2_policy.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +""" A simple implementation of a LSPS2 compatible policy plugin. It is the job +of this plugin to deliver a fee options menu to the LSPS2 service plugin. +""" + +from pyln.client import Plugin +from datetime import datetime, timedelta, timezone + + +plugin = Plugin() + + +@plugin.method("dev-lsps2-getpolicy") +def lsps2_getpolicy(request): + """ Returns an opening fee menu for the LSPS2 plugin. + """ + now = datetime.now(timezone.utc) + + # Is ISO 8601 format "YYYY-MM-DDThh:mm:ss.uuuZ" + valid_until = (now + timedelta(hours=1)).isoformat().replace('+00:00', 'Z') + + return { "policy_opening_fee_params_menu": [ + { + "min_fee_msat": "1000", + "proportional": 1000, + "valid_until": valid_until, + "min_lifetime": 2000, + "max_client_to_self_delay": 2016, + "min_payment_size_msat": "1000", + "max_payment_size_msat": "100000000", + }, + { + "min_fee_msat": "1092000", + "proportional": 2400, + "valid_until": valid_until, + "min_lifetime": 1008, + "max_client_to_self_delay": 2016, + "min_payment_size_msat": "1000", + "max_payment_size_msat": "1000000", + } + ] +} + + +plugin.run() diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 40a8ae68b8d2..53dcf386df31 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -30,6 +30,7 @@ def test_lsps0_listprotocols(node_factory): res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id']) assert res + def test_lsps2_enabled(node_factory): l1, l2 = node_factory.get_nodes(2, opts=[ {"dev-lsps-client-enabled": None}, @@ -44,3 +45,22 @@ def test_lsps2_enabled(node_factory): res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id']) assert res['protocols'] == [2] + + +def test_lsps2_getinfo(node_factory): + plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py') + + l1, l2 = node_factory.get_nodes(2, opts=[ + {"dev-lsps-client-enabled": None}, + { + "dev-lsps-service-enabled": None, + "dev-lsps2-service-enabled": None, + "dev-lsps2-promise-secret": "0" * 64, + "plugin": plugin + } + ]) + + node_factory.join_nodes([l1, l2], fundchannel=False) + + res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id']) + assert res["opening_fee_params_menu"] From 7ed03d99f0f010953b8f58631dfdda0df1127000 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Sun, 21 Sep 2025 23:24:13 +0200 Subject: [PATCH 11/22] lsp_plugin: add lsps2_buy request and handler Adds the lsps2.buy request to the client and the lsps2.buy handler to the LSP service. Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 120 ++++++- plugins/lsps-plugin/src/lsps2/handler.rs | 403 ++++++++++++++++++++++- plugins/lsps-plugin/src/lsps2/mod.rs | 3 + plugins/lsps-plugin/src/service.rs | 16 +- tests/test_cln_lsps.py | 24 ++ 5 files changed, 551 insertions(+), 15 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index b861ca544c12..99bae34732ca 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -1,17 +1,21 @@ use anyhow::{anyhow, Context}; use cln_lsps::jsonrpc::client::JsonRpcClient; +use cln_lsps::lsps0::primitives::Msat; use cln_lsps::lsps0::{ self, transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager}, }; -use cln_lsps::lsps2::model::{Lsps2GetInfoRequest, Lsps2GetInfoResponse}; +use cln_lsps::lsps2::model::{ + compute_opening_fee, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest, + Lsps2GetInfoResponse, OpeningFeeParams, +}; use cln_lsps::util; use cln_lsps::LSP_FEATURE_BIT; use cln_plugin::options; use cln_rpc::model::requests::ListpeersRequest; -use cln_rpc::primitives::PublicKey; +use cln_rpc::primitives::{AmountOrAny, PublicKey}; use cln_rpc::ClnRpc; -use log::debug; +use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use std::path::Path; use std::str::FromStr as _; @@ -51,6 +55,11 @@ async fn main() -> Result<(), anyhow::Error> { "Low-level command to request the opening fee menu of an LSP", on_lsps_lsps2_getinfo, ) + .rpcmethod( + "lsps-lsps2-buy", + "Low-level command to return the lsps2.buy result from an ", + on_lsps_lsps2_buy, + ) .configure() .await? { @@ -108,6 +117,103 @@ async fn on_lsps_lsps2_getinfo( Ok(serde_json::to_value(info_res)?) } +/// Rpc Method handler for `lsps-lsps2-buy`. +async fn on_lsps_lsps2_buy( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let req: ClnRpcLsps2BuyRequest = + serde_json::from_value(v).context("Failed to parse request JSON")?; + debug!( + "Asking for a channel from lsp {} with opening fee params {:?} and payment size {:?}", + req.lsp_id, req.opening_fee_params, req.payment_size_msat + ); + + let dir = p.configuration().lightning_dir; + let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + // Fail early: Check that we are connected to the peer and that it has the + // LSP feature bit set. + ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?; + + // Create Transport and Client + let transport = Bolt8Transport::new( + &req.lsp_id, + rpc_path.clone(), // Clone path for potential reuse + p.state().hook_manager.clone(), + None, // Use default timeout + ) + .context("Failed to create Bolt8Transport")?; + let client = JsonRpcClient::new(transport); + + // Convert from AmountOrAny to Msat. + let payment_size_msat = if let Some(payment_size) = req.payment_size_msat { + match payment_size { + AmountOrAny::Amount(amount) => Some(Msat::from_msat(amount.msat())), + AmountOrAny::Any => None, + } + } else { + None + }; + + let selected_params = req.opening_fee_params; + + if let Some(payment_size) = payment_size_msat { + if payment_size < selected_params.min_payment_size_msat { + return Err(anyhow!( + "Requested payment size {}msat is below minimum {}msat required by LSP", + payment_size, + selected_params.min_payment_size_msat + )); + } + if payment_size > selected_params.max_payment_size_msat { + return Err(anyhow!( + "Requested payment size {}msat is above maximum {}msat allowed by LSP", + payment_size, + selected_params.max_payment_size_msat + )); + } + + let opening_fee = compute_opening_fee( + payment_size.msat(), + selected_params.min_fee_msat.msat(), + selected_params.proportional.ppm() as u64, + ) + .ok_or_else(|| { + warn!( + "Opening fee calculation overflowed for payment size {}", + payment_size + ); + anyhow!("failed to calculate opening fee") + })?; + + info!( + "Calculated opening fee: {}msat for payment size {}msat", + opening_fee, payment_size + ); + } else { + info!("No payment size specified, requesting JIT channel for a variable-amount invoice."); + // Check if the selected params allow for variable amount (implicitly they do if max > min) + if selected_params.min_payment_size_msat >= selected_params.max_payment_size_msat { + // This shouldn't happen if LSP follows spec, but good to check. + warn!("Selected fee params seem unsuitable for variable amount: min >= max"); + } + } + + debug!("Calling lsps2.buy for peer {}", req.lsp_id); + let buy_req = Lsps2BuyRequest { + opening_fee_params: selected_params, // Pass the chosen params back + payment_size_msat, + }; + let buy_res: Lsps2BuyResponse = client + .call_typed(buy_req) + .await + .context("lsps2.buy call failed")?; + + Ok(serde_json::to_value(buy_res)?) +} + async fn on_lsps_listprotocols( p: cln_plugin::Plugin, v: serde_json::Value, @@ -188,6 +294,14 @@ async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<( Ok(()) } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClnRpcLsps2BuyRequest { + lsp_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + payment_size_msat: Option, + opening_fee_params: OpeningFeeParams, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct ClnRpcLsps2GetinfoRequest { lsp_id: String, diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs index 2f9162ad83c9..a28587951ed3 100644 --- a/plugins/lsps-plugin/src/lsps2/handler.rs +++ b/plugins/lsps-plugin/src/lsps2/handler.rs @@ -1,14 +1,27 @@ use crate::{ jsonrpc::{server::RequestHandler, JsonRpcResponse as _, RequestObject, RpcError}, - lsps2::model::{ - Lsps2GetInfoRequest, Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest, - Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise, + lsps0::primitives::ShortChannelId, + lsps2::{ + model::{ + DatastoreEntry, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest, + Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest, Lsps2PolicyGetInfoResponse, + OpeningFeeParams, Promise, + }, + DS_MAIN_KEY, DS_SUB_KEY, }, util::unwrap_payload_with_peer_id, }; use anyhow::{Context, Result as AnyResult}; use async_trait::async_trait; -use cln_rpc::ClnRpc; +use cln_rpc::{ + model::{ + requests::{DatastoreMode, DatastoreRequest, GetinfoRequest}, + responses::{DatastoreResponse, GetinfoResponse}, + }, + ClnRpc, +}; +use log::warn; +use rand::{rng, Rng as _}; use std::path::PathBuf; #[async_trait] @@ -17,8 +30,14 @@ pub trait ClnApi: Send + Sync { &self, params: &Lsps2PolicyGetInfoRequest, ) -> AnyResult; + + async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult; + + async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult; } +const DEFAULT_CLTV_EXPIRY_DELTA: u32 = 144; + #[derive(Clone)] pub struct ClnApiRpc { rpc_path: PathBuf, @@ -46,6 +65,22 @@ impl ClnApi for ClnApiRpc { .map_err(anyhow::Error::new) .with_context(|| "calling dev-lsps2-getpolicy") } + + async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult { + 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 { + let mut rpc = self.create_rpc().await?; + rpc.call_typed(params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling datastore") + } } /// Handler for the `lsps2.get_info` method. @@ -124,6 +159,106 @@ impl RequestHandler for Lsps2GetInfoHandler { } } +pub struct Lsps2BuyHandler { + pub api: A, + pub promise_secret: [u8; 32], +} + +impl Lsps2BuyHandler { + pub fn new(api: A, promise_secret: [u8; 32]) -> Self { + Self { + api, + promise_secret, + } + } +} + +#[async_trait] +impl RequestHandler for Lsps2BuyHandler { + async fn handle(&self, payload: &[u8]) -> core::result::Result, RpcError> { + let (payload, peer_id) = unwrap_payload_with_peer_id(payload); + + let req: RequestObject = 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 = 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, + } + .into_response(req.id.unwrap()); // We checked that we got an id before. + + serde_json::to_vec(&res) + .map_err(|e| RpcError::internal_error(format!("Failed to serialize response: {}", e))) + } +} + +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) +} + #[cfg(test)] mod tests { use std::sync::{Arc, Mutex}; @@ -136,8 +271,9 @@ mod tests { util::wrap_payload_with_peer_id, }; use chrono::{TimeZone, Utc}; - use cln_rpc::primitives::PublicKey; + use cln_rpc::primitives::{Amount, PublicKey}; use cln_rpc::RpcError as ClnRpcError; + use serde::Serialize; const PUBKEY: [u8; 33] = [ 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, 0x87, @@ -149,15 +285,45 @@ mod tests { PublicKey::from_slice(&PUBKEY).expect("Valid pubkey") } - fn create_wrapped_request(request: &RequestObject) -> Vec { + fn create_wrapped_request(request: &RequestObject) -> Vec { 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>>, lsps2_getpolicy_error: Arc>>, + cln_getinfo_response: Arc>>, + cln_getinfo_error: Arc>>, + cln_datastore_response: Arc>>, + cln_datastore_error: Arc>>, } #[async_trait] @@ -174,6 +340,54 @@ mod tests { }; panic!("No lsps2 response defined"); } + + async fn cln_getinfo( + &self, + _params: &GetinfoRequest, + ) -> Result { + 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 { + 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"); + } + } + + 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] @@ -236,4 +450,181 @@ mod tests { 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(Some("ok-fixed".into())); + let payload = create_wrapped_request(&req); + + let out = handler.handle(&payload).await.unwrap(); + let resp: ResponseObject = serde_json::from_slice(&out).unwrap(); + let resp = resp.into_inner().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(Some("ok-var".into())); + let payload = create_wrapped_request(&req); + + let out = handler.handle(&payload).await.unwrap(); + let resp: ResponseObject = serde_json::from_slice(&out).unwrap(); + assert!(resp.into_inner().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(Some("bad-promise".into())); + 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(Some("past-valid".into())); + 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(Some("too-small".into())); + + 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(Some("overflow".into())); + + let err = handler + .handle(&create_wrapped_request(&req)) + .await + .unwrap_err(); + assert_eq!(err.code, 203); + } } diff --git a/plugins/lsps-plugin/src/lsps2/mod.rs b/plugins/lsps-plugin/src/lsps2/mod.rs index b217f987154d..7ed8e74462a6 100644 --- a/plugins/lsps-plugin/src/lsps2/mod.rs +++ b/plugins/lsps-plugin/src/lsps2/mod.rs @@ -13,3 +13,6 @@ pub const OPTION_PROMISE_SECRET: options::StringConfigOption = "dev-lsps2-promise-secret", "A 64-character hex string that is the secret for promises", ); + +pub const DS_MAIN_KEY: &'static str = "lsps"; +pub const DS_SUB_KEY: &'static str = "lsps2"; diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index 11905dbfd544..ca620f9e5649 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -6,7 +6,7 @@ use cln_lsps::jsonrpc::{server::JsonRpcServer, JsonRpcRequest}; use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler; use cln_lsps::lsps0::model::Lsps0listProtocolsRequest; use cln_lsps::lsps0::transport::{self, CustomMsg}; -use cln_lsps::lsps2::model::Lsps2GetInfoRequest; +use cln_lsps::lsps2::model::{Lsps2BuyRequest, Lsps2GetInfoRequest}; use cln_lsps::util::wrap_payload_with_peer_id; use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT}; use cln_plugin::options::ConfigOption; @@ -94,11 +94,15 @@ async fn main() -> Result<(), anyhow::Error> { }; let cln_api_rpc = lsps2::handler::ClnApiRpc::new(rpc_path); - let getinfo_handler = lsps2::handler::Lsps2GetInfoHandler::new(cln_api_rpc, secret); - lsps_builder = lsps_builder.with_handler( - Lsps2GetInfoRequest::METHOD.to_string(), - Arc::new(getinfo_handler), - ); + let 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)); } } diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 53dcf386df31..1d1a1886b926 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -64,3 +64,27 @@ def test_lsps2_getinfo(node_factory): res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id']) assert res["opening_fee_params_menu"] + + +def test_lsps2_buy(node_factory): + # We need a policy service to fetch from. + plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py') + + l1, l2 = node_factory.get_nodes(2, opts=[ + {"dev-lsps-client-enabled": None}, + { + "dev-lsps-service-enabled": None, + "dev-lsps2-service-enabled": None, + "dev-lsps2-promise-secret": "0" * 64, + "plugin": plugin + } + ]) + + # We don't need a channel to query for lsps services + node_factory.join_nodes([l1, l2], fundchannel=False) + + res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id']) + params = res["opening_fee_params_menu"][0] + + res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], payment_size_msat=None, opening_fee_params=params) + assert res From 563827bccd470cf17c7d13a6524fd9ad7810d5e9 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Mon, 22 Sep 2025 15:43:16 +0200 Subject: [PATCH 12/22] lsp_plugin: add htlc_accepted handler for no-mpp Adds the service side (LSP) for a simple no-mpp trusted jit channel opening. This is only an intermediate step, we are going to add support for multiple htlcs. This is experimental and can drain on-chain fees from the LSP if used in public. Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/lsps2/cln.rs | 727 +++++++++++++++++ plugins/lsps-plugin/src/lsps2/handler.rs | 981 ++++++++++++++++++++++- plugins/lsps-plugin/src/lsps2/mod.rs | 1 + plugins/lsps-plugin/src/lsps2/model.rs | 17 + plugins/lsps-plugin/src/service.rs | 37 +- tests/plugins/lsps2_policy.py | 11 + 6 files changed, 1761 insertions(+), 13 deletions(-) create mode 100644 plugins/lsps-plugin/src/lsps2/cln.rs diff --git a/plugins/lsps-plugin/src/lsps2/cln.rs b/plugins/lsps-plugin/src/lsps2/cln.rs new file mode 100644 index 000000000000..6e3d6d232c2a --- /dev/null +++ b/plugins/lsps-plugin/src/lsps2/cln.rs @@ -0,0 +1,727 @@ +//! Backfill structs for missing or incomplete Core Lightning types. +//! +//! This module provides struct implementations that are not available or +//! fully accessible in the core-lightning crate, enabling better compatibility +//! and interoperability with Core Lightning's RPC interface. +use cln_rpc::primitives::{Amount, ShortChannelId}; +use hex::FromHex; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::lsps2::cln::tlv::TlvStream; + +pub const TLV_FORWARD_AMT: u64 = 2; +pub const TLV_OUTGOING_CLTV: u64 = 4; +pub const TLV_SHORT_CHANNEL_ID: u64 = 6; +pub const TLV_PAYMENT_SECRET: u64 = 8; + +#[derive(Debug, Deserialize)] +#[allow(unused)] +pub struct Onion { + pub forward_msat: Option, + #[serde(deserialize_with = "from_hex")] + pub next_onion: Vec, + pub outgoing_cltv_value: Option, + pub payload: TlvStream, + // pub payload: TlvStream, + #[serde(deserialize_with = "from_hex")] + pub shared_secret: Vec, + pub short_channel_id: Option, + pub total_msat: Option, + #[serde(rename = "type")] + pub type_: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(unused)] +pub struct Htlc { + pub amount_msat: Amount, + pub cltv_expiry: u32, + pub cltv_expiry_relative: u16, + pub id: u64, + #[serde(deserialize_with = "from_hex")] + pub payment_hash: Vec, + pub short_channel_id: ShortChannelId, + pub extra_tlvs: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum HtlcAcceptedResult { + Continue, + Fail, + Resolve, +} + +impl std::fmt::Display for HtlcAcceptedResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + HtlcAcceptedResult::Continue => "continue", + HtlcAcceptedResult::Fail => "fail", + HtlcAcceptedResult::Resolve => "resolve", + }; + write!(f, "{s}") + } +} + +#[derive(Debug, Deserialize)] +pub struct HtlcAcceptedRequest { + pub htlc: Htlc, + pub onion: Onion, + pub forward_to: Option, +} + +#[derive(Debug, Serialize)] +pub struct HtlcAcceptedResponse { + pub result: HtlcAcceptedResult, + #[serde(skip_serializing_if = "Option::is_none")] + pub payment_key: Option, + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "to_hex")] + pub payload: Option>, + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "to_hex")] + pub forward_to: Option>, + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "to_hex")] + pub extra_tlvs: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub failure_message: Option, + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "to_hex")] + pub failure_onion: Option>, +} + +impl HtlcAcceptedResponse { + pub fn continue_( + payload: Option>, + forward_to: Option>, + extra_tlvs: Option>, + ) -> Self { + Self { + result: HtlcAcceptedResult::Continue, + payment_key: None, + payload, + forward_to, + extra_tlvs, + failure_message: None, + failure_onion: None, + } + } + + pub fn fail(failure_message: Option, failure_onion: Option>) -> Self { + Self { + result: HtlcAcceptedResult::Fail, + payment_key: None, + payload: None, + forward_to: None, + extra_tlvs: None, + failure_message, + failure_onion, + } + } +} + +/// Deserializes a lowercase hex string to a `Vec`. +pub fn from_hex<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + use serde::de::Error; + String::deserialize(deserializer) + .and_then(|string| Vec::from_hex(string).map_err(|err| Error::custom(err.to_string()))) +} + +pub fn to_hex(bytes: &Option>, serializer: S) -> Result +where + S: Serializer, +{ + match bytes { + Some(data) => serializer.serialize_str(&hex::encode(data)), + None => serializer.serialize_none(), + } +} + +pub mod tlv { + use anyhow::Result; + use serde::{de::Error as DeError, Deserialize, Deserializer, Serialize, Serializer}; + use std::{convert::TryFrom, fmt}; + + #[derive(Clone, Debug)] + pub struct TlvRecord { + pub type_: u64, + pub value: Vec, + } + + #[derive(Clone, Debug, Default)] + pub struct TlvStream(pub Vec); + + #[derive(Debug)] + pub enum TlvError { + DuplicateType(u64), + NotSorted, + LengthMismatch(u64, usize, usize), + Truncated, + NonCanonicalBigSize, + TrailingBytes, + Hex(hex::FromHexError), + Other(String), + } + + impl fmt::Display for TlvError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TlvError::DuplicateType(t) => write!(f, "duplicate tlv type {}", t), + TlvError::NotSorted => write!(f, "tlv types must be strictly increasing"), + TlvError::LengthMismatch(t, e, g) => { + write!(f, "length mismatch type {}: expected {}, got {}", t, e, g) + } + TlvError::Truncated => write!(f, "truncated input"), + TlvError::NonCanonicalBigSize => write!(f, "non-canonical bigsize encoding"), + TlvError::TrailingBytes => write!(f, "leftover bytes after parsing"), + TlvError::Hex(e) => write!(f, "hex error: {}", e), + TlvError::Other(s) => write!(f, "{}", s), + } + } + } + + impl std::error::Error for TlvError {} + impl From for TlvError { + fn from(e: hex::FromHexError) -> Self { + TlvError::Hex(e) + } + } + + impl TlvStream { + pub fn to_bytes(&mut self) -> Result> { + self.0.sort_by_key(|r| r.type_); + for w in self.0.windows(2) { + if w[0].type_ == w[1].type_ { + return Err(TlvError::DuplicateType(w[0].type_).into()); + } + if w[0].type_ > w[1].type_ { + return Err(TlvError::NotSorted.into()); + } + } + let mut out = Vec::new(); + for rec in &self.0 { + out.extend(encode_bigsize(rec.type_)); + out.extend(encode_bigsize(rec.value.len() as u64)); + out.extend(&rec.value); + } + Ok(out) + } + + pub fn from_bytes(mut bytes: &[u8]) -> Result { + let mut recs = Vec::new(); + let mut last_type: Option = None; + + while !bytes.is_empty() { + let (t, n1) = decode_bigsize(bytes)?; + bytes = &bytes[n1..]; + let (len, n2) = decode_bigsize(bytes)?; + bytes = &bytes[n2..]; + + let l = + usize::try_from(len).map_err(|_| TlvError::Other("length too large".into()))?; + if bytes.len() < l { + return Err(TlvError::Truncated.into()); + } + let v = bytes[..l].to_vec(); + bytes = &bytes[l..]; + + if let Some(prev) = last_type { + if t == prev { + return Err(TlvError::DuplicateType(t).into()); + } + if t < prev { + return Err(TlvError::NotSorted.into()); + } + } + last_type = Some(t); + recs.push(TlvRecord { type_: t, value: v }); + } + Ok(TlvStream(recs)) + } + + pub fn from_bytes_with_length_prefix(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Err(TlvError::Truncated.into()); + } + + let (length, length_bytes) = decode_bigsize(bytes)?; + let remaining = &bytes[length_bytes..]; + + let length_usize = usize::try_from(length) + .map_err(|_| TlvError::Other("length prefix too large".into()))?; + + if remaining.len() != length_usize { + return Err(TlvError::LengthMismatch(0, length_usize, remaining.len()).into()); + } + + Self::from_bytes(remaining) + } + + /// Attempt to auto-detect whether the input has a length prefix or not + /// First tries to parse as length-prefixed, then falls back to raw TLV + /// parsing. + pub fn from_bytes_auto(bytes: &[u8]) -> Result { + // Try length-prefixed first + if let Ok(stream) = Self::from_bytes_with_length_prefix(bytes) { + return Ok(stream); + } + + // Fall back to raw TLV parsing + Self::from_bytes(bytes) + } + + /// Get a reference to the value of a TLV record by type. + pub fn get(&self, type_: u64) -> Option<&[u8]> { + self.0 + .iter() + .find(|rec| rec.type_ == type_) + .map(|rec| rec.value.as_slice()) + } + + /// Insert a TLV record (replaces if type already exists). + pub fn insert(&mut self, type_: u64, value: Vec) { + // If the type already exists, replace its value. + if let Some(rec) = self.0.iter_mut().find(|rec| rec.type_ == type_) { + rec.value = value; + return; + } + // Otherwise push and re-sort to maintain canonical order. + self.0.push(TlvRecord { type_, value }); + self.0.sort_by_key(|r| r.type_); + } + + /// Remove a record by type. + pub fn remove(&mut self, type_: u64) -> Option> { + if let Some(pos) = self.0.iter().position(|rec| rec.type_ == type_) { + Some(self.0.remove(pos).value) + } else { + None + } + } + + /// Check if a type exists. + pub fn contains(&self, type_: u64) -> bool { + self.0.iter().any(|rec| rec.type_ == type_) + } + + /// Insert or override a `tu64` value for `type_` (keeps canonical TLV order). + pub fn set_tu64(&mut self, type_: u64, value: u64) { + let enc = encode_tu64(value); + if let Some(rec) = self.0.iter_mut().find(|r| r.type_ == type_) { + rec.value = enc; + } else { + self.0.push(TlvRecord { type_, value: enc }); + self.0.sort_by_key(|r| r.type_); + } + } + + /// Read a `tu64` if present, validating minimal encoding. + /// Returns Ok(None) if the type isn't present. + pub fn get_tu64(&self, type_: u64) -> Result, TlvError> { + if let Some(rec) = self.0.iter().find(|r| r.type_ == type_) { + Ok(Some(decode_tu64(&rec.value)?)) + } else { + Ok(None) + } + } + } + + impl Serialize for TlvStream { + fn serialize(&self, serializer: S) -> Result { + let mut tmp = self.clone(); + let bytes = tmp.to_bytes().map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&hex::encode(bytes)) + } + } + + impl<'de> Deserialize<'de> for TlvStream { + fn deserialize>(deserializer: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = TlvStream; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "a hex string representing a Lightning TLV stream") + } + fn visit_str(self, s: &str) -> Result { + let bytes = hex::decode(s).map_err(E::custom)?; + TlvStream::from_bytes_auto(&bytes).map_err(E::custom) + } + } + deserializer.deserialize_str(V) + } + } + + impl TryFrom<&[u8]> for TlvStream { + type Error = anyhow::Error; + fn try_from(value: &[u8]) -> Result { + TlvStream::from_bytes(value) + } + } + + impl From> for TlvStream { + fn from(v: Vec) -> Self { + TlvStream(v) + } + } + + /// BOLT #1 BigSize encoding + fn encode_bigsize(x: u64) -> Vec { + let mut out = Vec::new(); + if x < 0xfd { + out.push(x as u8); + } else if x <= 0xffff { + out.push(0xfd); + out.extend_from_slice(&(x as u16).to_be_bytes()); + } else if x <= 0xffff_ffff { + out.push(0xfe); + out.extend_from_slice(&(x as u32).to_be_bytes()); + } else { + out.push(0xff); + out.extend_from_slice(&x.to_be_bytes()); + } + out + } + + fn decode_bigsize(input: &[u8]) -> Result<(u64, usize)> { + if input.is_empty() { + return Err(TlvError::Truncated.into()); + } + match input[0] { + n @ 0x00..=0xfc => Ok((n as u64, 1)), + 0xfd => { + if input.len() < 3 { + return Err(TlvError::Truncated.into()); + } + let v = u16::from_be_bytes([input[1], input[2]]) as u64; + if v < 0xfd { + return Err(TlvError::NonCanonicalBigSize.into()); + } + Ok((v, 3)) + } + 0xfe => { + if input.len() < 5 { + return Err(TlvError::Truncated.into()); + } + let v = u32::from_be_bytes([input[1], input[2], input[3], input[4]]) as u64; + if v <= 0xffff { + return Err(TlvError::NonCanonicalBigSize.into()); + } + Ok((v, 5)) + } + 0xff => { + if input.len() < 9 { + return Err(TlvError::Truncated.into()); + } + let v = u64::from_be_bytes([ + input[1], input[2], input[3], input[4], input[5], input[6], input[7], input[8], + ]); + if v <= 0xffff_ffff { + return Err(TlvError::NonCanonicalBigSize.into()); + } + Ok((v, 9)) + } + } + } + + /// Encode a BOLT #1 `tu64`: big-endian, minimal length (no leading 0x00). + /// Value 0 is encoded as zero-length. + pub fn encode_tu64(v: u64) -> Vec { + if v == 0 { + return Vec::new(); + } + let bytes = v.to_be_bytes(); + let first = bytes.iter().position(|&b| b != 0).unwrap(); // safe: v != 0 + bytes[first..].to_vec() + } + + /// Decode a BOLT #1 `tu64`, enforcing minimal form. + /// Empty slice -> 0. Leading 0x00 or >8 bytes is invalid. + fn decode_tu64(raw: &[u8]) -> Result { + if raw.is_empty() { + return Ok(0); + } + if raw.len() > 8 { + return Err(TlvError::Other("tu64 too long".into())); + } + if raw[0] == 0 { + return Err(TlvError::Other("non-minimal tu64 (leading zero)".into())); + } + let mut buf = [0u8; 8]; + buf[8 - raw.len()..].copy_from_slice(raw); + Ok(u64::from_be_bytes(buf)) + } + + #[cfg(test)] + mod tests { + use super::*; + use anyhow::Result; + + // Small helpers to keep tests readable + fn rec(type_: u64, value: &[u8]) -> TlvRecord { + TlvRecord { + type_, + value: value.to_vec(), + } + } + + fn build_bytes(type_: u64, value: &[u8]) -> Vec { + let mut v = Vec::new(); + v.extend(super::encode_bigsize(type_)); + v.extend(super::encode_bigsize(value.len() as u64)); + v.extend(value); + v + } + + #[test] + fn encode_then_decode_roundtrip() -> Result<()> { + let mut stream = TlvStream(vec![rec(1, &[0x01, 0x02]), rec(5, &[0xaa])]); + + // Encode + let bytes = stream.to_bytes()?; + // Expect exact TLV sequence: + // type=1 -> 0x01, len=2 -> 0x02, value=0x01 0x02 + // type=5 -> 0x05, len=1 -> 0x01, value=0xaa + assert_eq!(hex::encode(&bytes), "010201020501aa"); + + // Decode back + let decoded = TlvStream::from_bytes(&bytes)?; + assert_eq!(decoded.0.len(), 2); + assert_eq!(decoded.0[0].type_, 1); + assert_eq!(decoded.0[0].value, vec![0x01, 0x02]); + assert_eq!(decoded.0[1].type_, 5); + assert_eq!(decoded.0[1].value, vec![0xaa]); + + Ok(()) + } + + #[test] + fn json_hex_roundtrip() -> Result<()> { + let stream = TlvStream(vec![rec(1, &[0x01, 0x02]), rec(5, &[0xaa])]); + + // Serialize to hex string in JSON + let json = serde_json::to_string(&stream)?; + // It's a quoted hex string; check inner value + let s: String = serde_json::from_str(&json)?; + assert_eq!(s, "010201020501aa"); + + // And back from JSON hex + let back: TlvStream = serde_json::from_str(&json)?; + assert_eq!(back.0.len(), 2); + assert_eq!(back.0[0].type_, 1); + assert_eq!(back.0[0].value, vec![0x01, 0x02]); + assert_eq!(back.0[1].type_, 5); + assert_eq!(back.0[1].value, vec![0xaa]); + + Ok(()) + } + + #[test] + fn decode_with_len_prefix() -> Result<()> { + let payload = "1202039896800401760608000073000f2c0007"; + let stream = TlvStream::from_bytes_with_length_prefix(&hex::decode(payload).unwrap())?; + // let stream: TlvStream = serde_json::from_str(payload)?; + println!("TLV {:?}", stream.0); + + Ok(()) + } + + #[test] + fn bigsize_boundary_minimal_encodings() -> Result<()> { + // Types at 0xfc, 0xfd, 0x10000 to exercise size switches + let mut stream = TlvStream(vec![ + rec(0x00fc, &[0x11]), // single-byte bigsize + rec(0x00fd, &[0x22]), // 0xfd prefix + u16 + rec(0x0001_0000, &[0x33]), // 0xfe prefix + u32 + ]); + + let bytes = stream.to_bytes()?; // just ensure it encodes + // Decode back to confirm roundtrip/canonical encodings accepted + let back = TlvStream::from_bytes(&bytes)?; + assert_eq!(back.0[0].type_, 0x00fc); + assert_eq!(back.0[1].type_, 0x00fd); + assert_eq!(back.0[2].type_, 0x0001_0000); + Ok(()) + } + + #[test] + fn decode_rejects_non_canonical_bigsize() { + // (1) Non-canonical: 0xfd 00 fc encodes 0xfc but should be a single byte + let mut bytes = Vec::new(); + bytes.extend([0xfd, 0x00, 0xfc]); // non-canonical type + bytes.extend([0x01]); // len = 1 + bytes.extend([0x00]); // value + let err = TlvStream::from_bytes(&bytes).unwrap_err(); + assert!(format!("{}", err).contains("non-canonical")); + + // (2) Non-canonical: 0xfe 00 00 00 ff encodes 0xff but should be 0xfd-form + let mut bytes = Vec::new(); + bytes.extend([0xfe, 0x00, 0x00, 0x00, 0xff]); + bytes.extend([0x01]); + bytes.extend([0x00]); + let err = TlvStream::from_bytes(&bytes).unwrap_err(); + assert!(format!("{}", err).contains("non-canonical")); + + // (3) Non-canonical: 0xff 00..01 encodes 1, which should be single byte + let mut bytes = Vec::new(); + bytes.extend([0xff, 0, 0, 0, 0, 0, 0, 0, 1]); + bytes.extend([0x01]); + bytes.extend([0x00]); + let err = TlvStream::from_bytes(&bytes).unwrap_err(); + assert!(format!("{}", err).contains("non-canonical")); + } + + #[test] + fn decode_rejects_out_of_order_types() { + // Build two TLVs but put type 5 before type 1 + let mut bad = Vec::new(); + bad.extend(build_bytes(5, &[0xaa])); + bad.extend(build_bytes(1, &[0x00])); + + let err = TlvStream::from_bytes(&bad).unwrap_err(); + assert!( + format!("{}", err).contains("increasing") || format!("{}", err).contains("sorted"), + "expected ordering error, got: {err}" + ); + } + + #[test] + fn decode_rejects_duplicate_types() { + // Two records with same type=1 + let mut bad = Vec::new(); + bad.extend(build_bytes(1, &[0x01])); + bad.extend(build_bytes(1, &[0x02])); + let err = TlvStream::from_bytes(&bad).unwrap_err(); + assert!( + format!("{}", err).contains("duplicate"), + "expected duplicate error, got: {err}" + ); + } + + #[test] + fn encode_rejects_duplicate_types() { + // insert duplicate types and expect encode to fail + let mut s = TlvStream(vec![rec(1, &[0x01]), rec(1, &[0x02])]); + let err = s.to_bytes().unwrap_err(); + assert!( + format!("{}", err).contains("duplicate"), + "expected duplicate error, got: {err}" + ); + } + + #[test] + fn decode_truncated_value() { + // type=1, len=2 but only 1 byte of value provided + let mut bytes = Vec::new(); + bytes.extend(encode_bigsize(1)); + bytes.extend(encode_bigsize(2)); + bytes.push(0x00); // missing one more byte + let err = TlvStream::from_bytes(&bytes).unwrap_err(); + assert!( + format!("{}", err).contains("truncated"), + "expected truncated error, got: {err}" + ); + } + + #[test] + fn set_and_get_tu64_basic() -> Result<()> { + let mut s = TlvStream::default(); + s.set_tu64(42, 123456789); + assert_eq!(s.get_tu64(42)?, Some(123456789)); + Ok(()) + } + + #[test] + fn set_tu64_overwrite_keeps_order() -> Result<()> { + let mut s = TlvStream(vec![ + TlvRecord { + type_: 1, + value: vec![0xaa], + }, + TlvRecord { + type_: 10, + value: vec![0xbb], + }, + ]); + + // insert between 1 and 10 + s.set_tu64(5, 7); + assert_eq!( + s.0.iter().map(|r| r.type_).collect::>(), + vec![1, 5, 10] + ); + assert_eq!(s.get_tu64(5)?, Some(7)); + + // overwrite existing 5 (no duplicate, order preserved) + s.set_tu64(5, 9); + let types: Vec = s.0.iter().map(|r| r.type_).collect(); + assert_eq!(types, vec![1, 5, 10]); + assert_eq!(s.0.iter().filter(|r| r.type_ == 5).count(), 1); + assert_eq!(s.get_tu64(5)?, Some(9)); + Ok(()) + } + + #[test] + fn tu64_zero_encodes_empty_and_roundtrips() -> Result<()> { + let mut s = TlvStream::default(); + s.set_tu64(3, 0); + + // stored value is zero-length + let rec = s.0.iter().find(|r| r.type_ == 3).unwrap(); + assert!(rec.value.is_empty()); + + // wire round-trip + let mut sc = s.clone(); + let bytes = sc.to_bytes()?; + let s2 = TlvStream::from_bytes(&bytes)?; + assert_eq!(s2.get_tu64(3)?, Some(0)); + Ok(()) + } + + #[test] + fn get_tu64_missing_returns_none() -> Result<()> { + let s = TlvStream::default(); + assert_eq!(s.get_tu64(999)?, None); + Ok(()) + } + + #[test] + fn get_tu64_rejects_non_minimal_and_too_long() { + // non-minimal: leading zero + let mut s = TlvStream::default(); + s.0.push(TlvRecord { + type_: 9, + value: vec![0x00, 0x01], + }); + assert!(s.get_tu64(9).is_err()); + + // too long: 9 bytes + let mut s2 = TlvStream::default(); + s2.0.push(TlvRecord { + type_: 9, + value: vec![0; 9], + }); + assert!(s2.get_tu64(9).is_err()); + } + + #[test] + fn tu64_multi_roundtrip_bytes_and_json() -> Result<()> { + let mut s = TlvStream::default(); + s.set_tu64(42, 0); + s.set_tu64(7, 256); + + // wire roundtrip + let mut sc = s.clone(); + let bytes = sc.to_bytes()?; + let s2 = TlvStream::from_bytes(&bytes)?; + assert_eq!(s2.get_tu64(42)?, Some(0)); + assert_eq!(s2.get_tu64(7)?, Some(256)); + + // json hex roundtrip (custom Serialize/Deserialize) + let json = serde_json::to_string(&s)?; + let s3: TlvStream = serde_json::from_str(&json)?; + assert_eq!(s3.get_tu64(42)?, Some(0)); + assert_eq!(s3.get_tu64(7)?, Some(256)); + Ok(()) + } + } +} diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs index a28587951ed3..9bcda54e806e 100644 --- a/plugins/lsps-plugin/src/lsps2/handler.rs +++ b/plugins/lsps-plugin/src/lsps2/handler.rs @@ -1,11 +1,15 @@ use crate::{ jsonrpc::{server::RequestHandler, JsonRpcResponse as _, RequestObject, RpcError}, - lsps0::primitives::ShortChannelId, + lsps0::primitives::{Msat, ShortChannelId}, lsps2::{ + cln::{HtlcAcceptedRequest, HtlcAcceptedResponse, TLV_FORWARD_AMT}, model::{ + compute_opening_fee, + failure_codes::{TEMPORARY_CHANNEL_FAILURE, UNKNOWN_NEXT_PEER}, DatastoreEntry, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest, - Lsps2GetInfoResponse, Lsps2PolicyGetInfoRequest, Lsps2PolicyGetInfoResponse, - OpeningFeeParams, Promise, + Lsps2GetInfoResponse, Lsps2PolicyGetChannelCapacityRequest, + Lsps2PolicyGetChannelCapacityResponse, Lsps2PolicyGetInfoRequest, + Lsps2PolicyGetInfoResponse, OpeningFeeParams, Promise, }, DS_MAIN_KEY, DS_SUB_KEY, }, @@ -13,16 +17,25 @@ use crate::{ }; 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, GetinfoRequest}, - responses::{DatastoreResponse, GetinfoResponse}, + requests::{ + DatastoreMode, DatastoreRequest, DeldatastoreRequest, FundchannelRequest, + GetinfoRequest, ListdatastoreRequest, ListpeerchannelsRequest, + }, + responses::{ + DatastoreResponse, DeldatastoreResponse, FundchannelResponse, GetinfoResponse, + ListdatastoreResponse, ListpeerchannelsResponse, + }, }, + primitives::{Amount, AmountOrAll, ChannelState}, ClnRpc, }; -use log::warn; +use log::{debug, warn}; use rand::{rng, Rng as _}; -use std::path::PathBuf; +use std::{fmt, path::PathBuf, time::Duration}; #[async_trait] pub trait ClnApi: Send + Sync { @@ -31,9 +44,31 @@ pub trait ClnApi: Send + Sync { params: &Lsps2PolicyGetInfoRequest, ) -> AnyResult; + async fn lsps2_getchannelcapacity( + &self, + params: &Lsps2PolicyGetChannelCapacityRequest, + ) -> AnyResult; + async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult; async fn cln_datastore(&self, params: &DatastoreRequest) -> AnyResult; + + async fn cln_listdatastore( + &self, + params: &ListdatastoreRequest, + ) -> AnyResult; + + async fn cln_deldatastore( + &self, + params: &DeldatastoreRequest, + ) -> AnyResult; + + async fn cln_fundchannel(&self, params: &FundchannelRequest) -> AnyResult; + + async fn cln_listpeerchannels( + &self, + params: &ListpeerchannelsRequest, + ) -> AnyResult; } const DEFAULT_CLTV_EXPIRY_DELTA: u32 = 144; @@ -66,6 +101,17 @@ impl ClnApi for ClnApiRpc { .with_context(|| "calling dev-lsps2-getpolicy") } + async fn lsps2_getchannelcapacity( + &self, + params: &Lsps2PolicyGetChannelCapacityRequest, + ) -> AnyResult { + let mut rpc = self.create_rpc().await?; + rpc.call_raw("dev-lsps2-getchannelcapacity", params) + .await + .map_err(anyhow::Error::new) + .with_context(|| "calling dev-lsps2-getchannelcapacity") + } + async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult { let mut rpc = self.create_rpc().await?; rpc.call_typed(params) @@ -81,6 +127,47 @@ impl ClnApi for ClnApiRpc { .map_err(anyhow::Error::new) .with_context(|| "calling datastore") } + + async fn cln_listdatastore( + &self, + params: &ListdatastoreRequest, + ) -> AnyResult { + 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 { + 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 { + 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 { + 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. @@ -259,6 +346,330 @@ fn generate_jit_scid(best_blockheigt: u32) -> u64 { ((block as u64) << 40) | ((tx_idx as u64) << 16) | (output_idx as u64) } +pub struct HtlcAcceptedHookHandler { + api: A, + htlc_minimum_msat: u64, + backoff_listpeerchannels: Duration, +} + +impl HtlcAcceptedHookHandler { + 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 { + 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 + if ds_rec.expected_payment_size.is_some() { + warn!("mpp payments are not implemented yet"); + 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: None, // Fimxe: Core-Lightning is complaining that it doesn't support these channel_types + // channel_type: Some(vec![46, 50]), // Sets `option_zeroconf` and `option_scid_alias` + 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: {:0x?}", &payload_bytes); + eprintln!("ABOUT TO SEND PAYLOAD: {:0x?}", &payload_bytes); + + let mut extra_tlvs = req.htlc.extra_tlvs.unwrap_or_default().clone(); + extra_tlvs.set_tu64(65537, opening_fee); + let extra_tlvs_bytes = extra_tlvs.to_bytes().unwrap(); + + 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 }, + /// Entry existed but had neither `string` nor `hex`. + MissingValue { key: Vec }, + /// JSON parse failed (from `string` or decoded `hex`). + JsonParse { + key: Vec, + source: serde_json::Error, + }, + /// Hex decode failed. + HexDecode { + key: Vec, + 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 { + vec![ + DS_MAIN_KEY.to_string(), + DS_SUB_KEY.to_string(), + scid.to_string(), + ] +} + +pub fn deserialize_by_key( + resp: &ListdatastoreResponse, + key: K, +) -> std::result::Result<(DatastoreEntry, Option), 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::(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::(&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}; @@ -267,12 +678,18 @@ mod tests { use crate::{ jsonrpc::{JsonRpcRequest, ResponseObject}, lsps0::primitives::{Msat, Ppm}, - lsps2::model::PolicyOpeningFeeParams, + lsps2::{ + cln::{tlv::TlvStream, HtlcAcceptedResult}, + model::PolicyOpeningFeeParams, + }, util::wrap_payload_with_peer_id, }; use chrono::{TimeZone, Utc}; - use cln_rpc::primitives::{Amount, PublicKey}; - use cln_rpc::RpcError as ClnRpcError; + 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] = [ @@ -324,6 +741,17 @@ mod tests { cln_getinfo_error: Arc>>, cln_datastore_response: Arc>>, cln_datastore_error: Arc>>, + cln_listdatastore_response: Arc>>, + cln_listdatastore_error: Arc>>, + cln_deldatastore_response: Arc>>, + cln_deldatastore_error: Arc>>, + cln_fundchannel_response: Arc>>, + cln_fundchannel_error: Arc>>, + cln_listpeerchannels_response: Arc>>, + cln_listpeerchannels_error: Arc>>, + lsps2_getchannelcapacity_response: + Arc>>, + lsps2_getchannelcapacity_error: Arc>>, } #[async_trait] @@ -341,6 +769,24 @@ mod tests { panic!("No lsps2 response defined"); } + async fn lsps2_getchannelcapacity( + &self, + _params: &Lsps2PolicyGetChannelCapacityRequest, + ) -> AnyResult { + 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, @@ -366,6 +812,168 @@ mod tests { }; panic!("No cln datastore response defined"); } + + async fn cln_listdatastore( + &self, + _params: &ListdatastoreRequest, + ) -> AnyResult { + 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 { + 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 { + 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 { + 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, + 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, + ) -> DatastoreEntry { + let (_, policy) = params_with_promise(&[0u8; 32]); + DatastoreEntry { + peer_id, + opening_fee_params: policy, + expected_payment_size, + } } fn minimal_getinfo(height: u32) -> GetinfoResponse { @@ -627,4 +1235,357 @@ mod tests { .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] + 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 7ed8e74462a6..2b98aa1dfb15 100644 --- a/plugins/lsps-plugin/src/lsps2/mod.rs +++ b/plugins/lsps-plugin/src/lsps2/mod.rs @@ -1,5 +1,6 @@ use cln_plugin::options; +pub mod cln; pub mod handler; pub mod model; diff --git a/plugins/lsps-plugin/src/lsps2/model.rs b/plugins/lsps-plugin/src/lsps2/model.rs index 7a186db0d6c6..bcf8de079d05 100644 --- a/plugins/lsps-plugin/src/lsps2/model.rs +++ b/plugins/lsps-plugin/src/lsps2/model.rs @@ -7,6 +7,11 @@ use chrono::Utc; use log::debug; use serde::{Deserialize, Serialize}; +pub mod failure_codes { + pub const TEMPORARY_CHANNEL_FAILURE: &'static str = "1007"; + pub const UNKNOWN_NEXT_PEER: &'static str = "4010"; +} + #[derive(Clone, Debug, PartialEq)] pub enum Error { InvalidOpeningFeeParams, @@ -256,6 +261,18 @@ pub struct Lsps2PolicyGetInfoResponse { pub policy_opening_fee_params_menu: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Lsps2PolicyGetChannelCapacityRequest { + pub opening_fee_params: OpeningFeeParams, + pub init_payment_size: Msat, + pub scid: ShortChannelId, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Lsps2PolicyGetChannelCapacityResponse { + pub channel_capacity_msat: Option, +} + /// An internal representation of a policy of parameters for calculating the /// opening fee for a JIT channel. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index ca620f9e5649..b17c8e83b0bd 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -6,6 +6,8 @@ use cln_lsps::jsonrpc::{server::JsonRpcServer, JsonRpcRequest}; use cln_lsps::lsps0::handler::Lsps0ListProtocolsHandler; use cln_lsps::lsps0::model::Lsps0listProtocolsRequest; use cln_lsps::lsps0::transport::{self, CustomMsg}; +use cln_lsps::lsps2::cln::{HtlcAcceptedRequest, HtlcAcceptedResponse}; +use cln_lsps::lsps2::handler::{ClnApiRpc, HtlcAcceptedHookHandler}; use cln_lsps::lsps2::model::{Lsps2BuyRequest, Lsps2GetInfoRequest}; use cln_lsps::util::wrap_payload_with_peer_id; use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT}; @@ -27,6 +29,7 @@ const OPTION_ENABLED: options::FlagConfigOption = ConfigOption::new_flag( #[derive(Clone)] struct State { lsps_service: JsonRpcServer, + lsps2_enabled: bool, } #[tokio::main] @@ -44,6 +47,7 @@ async fn main() -> Result<(), anyhow::Error> { util::feature_bit_to_hex(LSP_FEATURE_BIT), ) .hook("custommsg", on_custommsg) + .hook("htlc_accepted", on_htlc_accepted) .configure() .await? { @@ -63,7 +67,7 @@ async fn main() -> Result<(), anyhow::Error> { }), ); - if plugin.option(&lsps2::OPTION_ENABLED)? { + let lsps2_enabled = if plugin.option(&lsps2::OPTION_ENABLED)? { log::debug!("lsps2 enabled"); let secret_hex = plugin.option(&lsps2::OPTION_PROMISE_SECRET)?; if let Some(secret_hex) = secret_hex { @@ -104,11 +108,17 @@ async fn main() -> Result<(), anyhow::Error> { ) .with_handler(Lsps2BuyRequest::METHOD.to_string(), Arc::new(buy_handler)); } - } + true + } else { + false + }; let lsps_service = lsps_builder.build(); - let state = State { lsps_service }; + let state = State { + lsps_service, + lsps2_enabled, + }; let plugin = plugin.start(state).await?; plugin.join().await } else { @@ -116,6 +126,27 @@ async fn main() -> Result<(), anyhow::Error> { } } +async fn on_htlc_accepted( + p: Plugin, + v: serde_json::Value, +) -> Result { + if !p.state().lsps2_enabled { + // just continue. + // Fixme: Add forward and extra tlvs from incoming. + let res = serde_json::to_value(&HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(res); + } + + let req: HtlcAcceptedRequest = serde_json::from_value(v)?; + let rpc_path = Path::new(&p.configuration().lightning_dir).join(&p.configuration().rpc_file); + let api = ClnApiRpc::new(rpc_path); + // Fixme: Use real htlc_minimum_amount. + let handler = HtlcAcceptedHookHandler::new(api, 1000); + let res = handler.handle(req).await?; + let res_val = serde_json::to_value(&res)?; + Ok(res_val) +} + async fn on_custommsg( p: Plugin, v: serde_json::Value, diff --git a/tests/plugins/lsps2_policy.py b/tests/plugins/lsps2_policy.py index e16eb4ae159d..7588712df476 100755 --- a/tests/plugins/lsps2_policy.py +++ b/tests/plugins/lsps2_policy.py @@ -41,5 +41,16 @@ def lsps2_getpolicy(request): ] } +@plugin.method("dev-lsps2-getchannelcapacity") +def lsps2_getchannelcapacity(request, init_payment_size, scid, opening_fee_params): + """ Returns an opening fee menu for the LSPS2 plugin. + """ + now = datetime.now(timezone.utc) + + # Is ISO 8601 format "YYYY-MM-DDThh:mm:ss.uuuZ" + valid_until = (now + timedelta(hours=1)).isoformat().replace('+00:00', 'Z') + + return { "channel_capacity_msat": 100000000 } + plugin.run() From c28abb53ae73f86bdb0a5ae37ee73633808c9f15 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Mon, 22 Sep 2025 17:20:51 +0200 Subject: [PATCH 13/22] lsp_plugin: add lsps-jitchannel handler Adds the full roundtrip to request a jit channel from the LSP. It approves the jit scid returned by the LSP and returns the invoice with the corresponding route-hint. Changelog-Added Experimental support for LSPS2 no-MPP, Lsps-trusts-client mode. See https://github.com/lightning/blips/blob/master/blip-0052.md for further details. Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 360 +++++++++++++++++++++++++++++- tests/test_cln_lsps.py | 75 ++++++- 2 files changed, 432 insertions(+), 3 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index 99bae34732ca..578a5b5c8c0b 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -1,10 +1,15 @@ use anyhow::{anyhow, Context}; +use chrono::{Duration, Utc}; use cln_lsps::jsonrpc::client::JsonRpcClient; use cln_lsps::lsps0::primitives::Msat; use cln_lsps::lsps0::{ self, transport::{Bolt8Transport, CustomMessageHookManager, WithCustomMessageHookManager}, }; +use cln_lsps::lsps2::cln::tlv::encode_tu64; +use cln_lsps::lsps2::cln::{ + HtlcAcceptedRequest, HtlcAcceptedResponse, TLV_FORWARD_AMT, TLV_PAYMENT_SECRET, +}; use cln_lsps::lsps2::model::{ compute_opening_fee, Lsps2BuyRequest, Lsps2BuyResponse, Lsps2GetInfoRequest, Lsps2GetInfoResponse, OpeningFeeParams, @@ -12,8 +17,9 @@ use cln_lsps::lsps2::model::{ use cln_lsps::util; use cln_lsps::LSP_FEATURE_BIT; use cln_plugin::options; -use cln_rpc::model::requests::ListpeersRequest; -use cln_rpc::primitives::{AmountOrAny, PublicKey}; +use cln_rpc::model::requests::{DatastoreMode, DatastoreRequest, ListpeersRequest}; +use cln_rpc::model::responses::InvoiceResponse; +use cln_rpc::primitives::{AmountOrAny, PublicKey, ShortChannelId}; use cln_rpc::ClnRpc; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; @@ -60,6 +66,18 @@ async fn main() -> Result<(), anyhow::Error> { "Low-level command to return the lsps2.buy result from an ", on_lsps_lsps2_buy, ) + .rpcmethod( + "lsps-lsps2-approve", + "Low-level command to approve a jit channel opening for the given scid", + on_lsps_lsps2_approve, + ) + .rpcmethod( + "lsps-jitchannel", + "Requests a new jit channel from LSP and returns the matching invoice", + on_lsps_jitchannel, + ) + .hook("htlc_accepted", on_htlc_accepted) + .hook("openchannel", on_openchannel) .configure() .await? { @@ -214,6 +232,283 @@ async fn on_lsps_lsps2_buy( Ok(serde_json::to_value(buy_res)?) } +async fn on_lsps_lsps2_approve( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let req: ClnRpcLsps2Approve = serde_json::from_value(v)?; + let ds_rec = DatastoreRecord { + lsp_id: req.lsp_id, + jit_channel_scid: req.jit_channel_scid, + client_trusts_lsp: req.client_trusts_lsp.unwrap_or_default(), + }; + let ds_rec_json = serde_json::to_string(&ds_rec)?; + + let dir = p.configuration().lightning_dir; + let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + let ds_req = DatastoreRequest { + generation: None, + hex: None, + mode: Some(DatastoreMode::CREATE_OR_REPLACE), + string: Some(ds_rec_json), + key: vec![ + "lsps".to_string(), + "client".to_string(), + req.jit_channel_scid.to_string(), + ], + }; + let _ds_res = cln_client.call_typed(&ds_req).await?; + Ok(serde_json::Value::default()) +} + +/// RPC Method handler for `lsps-jitchannel`. +/// Calls lsps2.get_info, selects parameters, calculates fee, calls lsps2.buy, +/// creates invoice. +async fn on_lsps_jitchannel( + p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + #[derive(Deserialize)] + struct Request { + lsp_id: String, + // Optional: for fixed-amount invoices + payment_size_msat: Option, + // Optional: for discounts/API keys + token: Option, + } + + let req: Request = serde_json::from_value(v).context("Failed to parse request JSON")?; + debug!( + "Handling lsps-buy-jit-channel request for peer {} with payment_size {:?} and token {:?}", + req.lsp_id, req.payment_size_msat, req.token + ); + + let dir = p.configuration().lightning_dir; + let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + // 1. Get LSP's opening fee menu. + let info_res: Lsps2GetInfoResponse = cln_client + .call_raw( + "lsps-lsps2-getinfo", + &ClnRpcLsps2GetinfoRequest { + lsp_id: req.lsp_id.clone(), + token: req.token, + }, + ) + .await?; + + // 2. Select Fee Parameters. + // Simple strategy for now: choose the first valid option as LSPS2 requires + // this to be the cheapest. Could be more sophisticated (e.g., user choice). + let selected_params = info_res + .opening_fee_params_menu + .iter() + .find(|params| { + // Basic validation on client side: check expiry and promise length + let fut_now = Utc::now() + Duration::minutes(1); // Add some extra time for network delay + let expiry_valid = params.valid_until > fut_now; + if !expiry_valid { + warn!("Ignoring expired fee params from LSP {:?}", params); + } + expiry_valid + }) + .cloned() // Clone the selected params + .ok_or_else(|| { + anyhow!( + "No valid/unexpired fee parameters offered by LSP {}", + req.lsp_id + ) + })?; + + info!("Selected fee parameters: {:?}", selected_params); + + // 3. Request channel from LSP. + let buy_res: Lsps2BuyResponse = cln_client + .call_raw( + "lsps-lsps2-buy", + &ClnRpcLsps2BuyRequest { + lsp_id: req.lsp_id.clone(), + payment_size_msat: req.payment_size_msat, + opening_fee_params: selected_params.clone(), + }, + ) + .await?; + + debug!("Received lsps2.buy response: {:?}", buy_res); + + // We define the invoice expiry here to avoid cloning `selected_params` + // as they are about to be moved to the `Lsps2BuyRequest`. + let expiry = (selected_params.valid_until - Utc::now()).num_seconds(); + if expiry <= 10 { + return Err(anyhow!( + "Invoice lifetime is too short, options are valid until: {}", + selected_params.valid_until, + )); + } + + // 4. Create and invoice with a route hint pointing to the LSP, using + // the scid we got from the LSP. + let hint = RoutehintHopDev { + id: req.lsp_id.clone(), + short_channel_id: buy_res.jit_channel_scid.to_string(), + fee_base_msat: Some(0), + fee_proportional_millionths: 0, + cltv_expiry_delta: u16::try_from(buy_res.lsp_cltv_expiry_delta)?, + }; + + let amount_msat = if let Some(payment_size) = req.payment_size_msat { + payment_size + } else { + AmountOrAny::Any + }; + + let inv: cln_rpc::model::responses::InvoiceResponse = cln_client + .call_raw( + "invoice", + &InvoiceRequest { + amount_msat, + dev_routes: Some(vec![vec![hint]]), + description: String::from("TODO"), // TODO: Pass down description from rpc call + label: gen_label(None), // TODO: Pass down label from rpc call + expiry: Some(expiry as u64), + cltv: Some(u32::try_from(6 + 2)?), // TODO: FETCH REAL VALUE! + deschashonly: None, + preimage: None, + exposeprivatechannels: None, + fallbacks: None, + }, + ) + .await?; + + // 5. Approve jit_channel_scid for a jit channel opening. + let appr_req = ClnRpcLsps2Approve { + lsp_id: req.lsp_id, + jit_channel_scid: buy_res.jit_channel_scid, + client_trusts_lsp: Some(buy_res.client_trusts_lsp), + }; + let _: serde_json::Value = cln_client.call_raw("lsps-lsps2-approve", &appr_req).await?; + + // 6. Return invoice. + let out = InvoiceResponse { + bolt11: inv.bolt11, + created_index: inv.created_index, + warning_capacity: inv.warning_capacity, + warning_deadends: inv.warning_deadends, + warning_mpp: inv.warning_mpp, + warning_offline: inv.warning_offline, + warning_private_unused: inv.warning_private_unused, + expires_at: inv.expires_at, + payment_hash: inv.payment_hash, + payment_secret: inv.payment_secret, + }; + Ok(serde_json::to_value(out)?) +} + +async fn on_htlc_accepted( + _p: cln_plugin::Plugin, + v: serde_json::Value, +) -> Result { + let req: HtlcAcceptedRequest = serde_json::from_value(v)?; + + let htlc_amt = req.htlc.amount_msat; + let onion_amt = match req.onion.forward_msat { + Some(a) => a, + None => { + debug!("onion is missing forward_msat"); + let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(value); + } + }; + + let is_lsp_payment = req + .htlc + .extra_tlvs + .as_ref() + .map_or(false, |tlv| tlv.contains(65537)); + + if !is_lsp_payment || htlc_amt.msat() >= onion_amt.msat() { + // Not an Lsp payment. + let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(value); + } + debug!("incoming jit-channel htlc"); + + // Safe unwrap(): we already checked that `extra_tlvs` exists. + let extra_tlvs = req.htlc.extra_tlvs.unwrap(); + let deducted_amt = match extra_tlvs.get_tu64(65537)? { + Some(amt) => amt, + None => { + warn!("htlc is missing the extra_fee amount"); + let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(value); + } + }; + debug!("lsp htlc is deducted by an extra_fee={}", deducted_amt); + + // Fixme: Check that it is not a forward (has payment_secret) before rpc_calls. + + // Fixme: Check that we did not already pay for this channel. + // - via datastore or invoice label. + + // Fixme: Check the if MPP or No-MPP, assuming No-MPP for now. + // - check that extra_fee + htlc is the total_amount_msat of the onion. + + let mut payload = req.onion.payload.clone(); + payload.set_tu64(TLV_FORWARD_AMT, htlc_amt.msat()); + let payment_secret = match payload.get(TLV_PAYMENT_SECRET) { + Some(s) => s, + None => { + debug!("can't decode tlv payment_secret {:?}", payload); + let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(value); + } + }; + + let total_amt = htlc_amt.msat(); + let mut ps = Vec::new(); + ps.extend_from_slice(&payment_secret[0..32]); + ps.extend(encode_tu64(total_amt)); + payload.insert(TLV_PAYMENT_SECRET, ps); + let payload_bytes = match payload.to_bytes() { + Ok(b) => b, + Err(e) => { + warn!("can't encode payload to bytes {}", e); + let value = serde_json::to_value(HtlcAcceptedResponse::continue_(None, None, None))?; + return Ok(value); + } + }; + + info!( + "Amended onion payload with forward_amt={} and total_msat={}", + htlc_amt.msat(), + total_amt + ); + let value = serde_json::to_value(HtlcAcceptedResponse::continue_( + Some(payload_bytes), + None, + None, + ))?; + Ok(value) +} + +async fn on_openchannel( + _p: cln_plugin::Plugin, + _v: serde_json::Value, +) -> Result { + // Fixme: Register a list of trusted LSPs and check if LSP is allowlisted. + // And if we expect a channel to be opened. + // - either datastore or invoice label possible. + info!("Allowing zero-conf channel from LSP"); + Ok(serde_json::json!({ + "result": "continue", + "reserve": "0msat", + "mindepth": 0, + })) +} + async fn on_lsps_listprotocols( p: cln_plugin::Plugin, v: serde_json::Value, @@ -294,6 +589,52 @@ async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<( Ok(()) } +/// Generates a unique label from an optional `String`. The given label is +/// appended by a timestamp (now). +fn gen_label(label: Option<&str>) -> String { + let now = Utc::now(); + let millis = now.timestamp_millis(); + let l = label.unwrap_or_else(|| "lsps2.buy"); + format!("{}_{}", l, millis) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct LspsBuyJitChannelResponse { + bolt11: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InvoiceRequest { + pub amount_msat: cln_rpc::primitives::AmountOrAny, + pub description: String, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expiry: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fallbacks: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub preimage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cltv: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deschashonly: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exposeprivatechannels: Option>, + #[serde(rename = "dev-routes", skip_serializing_if = "Option::is_none")] + pub dev_routes: Option>>, +} + +// This variant is used by dev-routes, using slightly different key names. +// TODO Remove once we have consolidated the routehint format. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RoutehintHopDev { + pub id: String, + pub short_channel_id: String, + pub fee_base_msat: Option, + pub fee_proportional_millionths: u32, + pub cltv_expiry_delta: u16, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct ClnRpcLsps2BuyRequest { lsp_id: String, @@ -307,3 +648,18 @@ struct ClnRpcLsps2GetinfoRequest { lsp_id: String, token: Option, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ClnRpcLsps2Approve { + lsp_id: String, + jit_channel_scid: ShortChannelId, + #[serde(default)] + client_trusts_lsp: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct DatastoreRecord { + lsp_id: String, + jit_channel_scid: ShortChannelId, + client_trusts_lsp: bool, +} diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 1d1a1886b926..776c1a28129a 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -1,6 +1,6 @@ from fixtures import * # noqa: F401,F403 from pyln.testing.utils import RUST - +from utils import only_one import os import unittest @@ -88,3 +88,76 @@ def test_lsps2_buy(node_factory): res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], payment_size_msat=None, opening_fee_params=params) assert res + + +def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind): + """ Tests the creation of a "Just-In-Time-Channel" (jit-channel). + + At the beginning we have the following situation where l2 acts as the LSP + (LSP) + l1 l2----l3 + + l1 now wants to get a channel from l2 via the lsps2 jit-channel protocol: + - l1 requests a new jit channel form l2 + - l1 creates an invoice based on the opening fee parameters it got from l2 + - l3 pays the invoice + - l2 opens a channel to l1 and forwards the payment (deducted by a fee) + + eventualy this will result in the following situation + (LSP) + l1----l2----l3 + """ + # We need a policy service to fetch from. + plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py') + + l1, l2, l3= node_factory.get_nodes(3, opts=[ + {"dev-lsps-client-enabled": None}, + { + "dev-lsps-service-enabled": None, + "dev-lsps2-service-enabled": None, + "dev-lsps2-promise-secret": "00" * 32, + "plugin": plugin, + "fee-base": 0, # We are going to deduct our fee anyways, + "fee-per-satoshi": 0, # We are going to deduct our fee anyways, + }, + {}, + ]) + + # Give the LSP some funds to open jit-channels + addr = l2.rpc.newaddr()['bech32'] + bitcoind.rpc.sendtoaddress(addr, 1) + bitcoind.generate_block(1) + + node_factory.join_nodes([l3, l2], fundchannel=True, wait_for_announce=True) + node_factory.join_nodes([l1, l2], fundchannel=False) + + chanid = only_one(l3.rpc.listpeerchannels(l2.info['id'])['channels'])['short_channel_id'] + + inv = l1.rpc.lsps_jitchannel(lsp_id=l2.info['id']) + assert inv + + dec = l3.rpc.decode(inv['bolt11']) + assert dec + + routehint = only_one(only_one(dec['routes'])) + + amt = 10000000 + fee = amt * 10 // 1000000 + 1 + + route = [{'amount_msat': amt, + 'id': l2.info['id'], + 'delay': 14, + 'channel': chanid}, + {'amount_msat': amt, + 'id': l1.info['id'], + 'delay': 8, + 'channel': routehint['short_channel_id']}] + + l3.rpc.sendpay(route, dec['payment_hash'], payment_secret=inv['payment_secret'], bolt11=inv['bolt11'], partid=0) + + res = l3.rpc.waitsendpay(dec['payment_hash']) + assert res['payment_preimage'] + + # l1 should have gotten a jit-channel. + chs = l1.rpc.listpeerchannels()['channels'] + assert len(chs) == 1 From 55c0132996ae312bac705f533f76267d8f80ad8a Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Thu, 25 Sep 2025 17:49:56 +0200 Subject: [PATCH 14/22] lsp_plugin: add client side check for zero_conf We only allow zero_conf channels if we approved the a jit-channel from the LSP in advance. Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 67 +++++++++++++++++------- plugins/lsps-plugin/src/lsps2/handler.rs | 3 +- tests/test_cln_lsps.py | 56 ++++++++++++++++++++ 3 files changed, 105 insertions(+), 21 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index 578a5b5c8c0b..484facc23bd4 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -17,7 +17,9 @@ use cln_lsps::lsps2::model::{ use cln_lsps::util; use cln_lsps::LSP_FEATURE_BIT; use cln_plugin::options; -use cln_rpc::model::requests::{DatastoreMode, DatastoreRequest, ListpeersRequest}; +use cln_rpc::model::requests::{ + DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest, ListpeersRequest, +}; use cln_rpc::model::responses::InvoiceResponse; use cln_rpc::primitives::{AmountOrAny, PublicKey, ShortChannelId}; use cln_rpc::ClnRpc; @@ -238,7 +240,6 @@ async fn on_lsps_lsps2_approve( ) -> Result { let req: ClnRpcLsps2Approve = serde_json::from_value(v)?; let ds_rec = DatastoreRecord { - lsp_id: req.lsp_id, jit_channel_scid: req.jit_channel_scid, client_trusts_lsp: req.client_trusts_lsp.unwrap_or_default(), }; @@ -253,11 +254,7 @@ async fn on_lsps_lsps2_approve( hex: None, mode: Some(DatastoreMode::CREATE_OR_REPLACE), string: Some(ds_rec_json), - key: vec![ - "lsps".to_string(), - "client".to_string(), - req.jit_channel_scid.to_string(), - ], + key: vec!["lsps".to_string(), "client".to_string(), req.lsp_id], }; let _ds_res = cln_client.call_typed(&ds_req).await?; Ok(serde_json::Value::default()) @@ -494,19 +491,52 @@ async fn on_htlc_accepted( Ok(value) } +/// Allows `zero_conf` channels to the client if the LSP is on the allowlist. async fn on_openchannel( - _p: cln_plugin::Plugin, - _v: serde_json::Value, + p: cln_plugin::Plugin, + v: serde_json::Value, ) -> Result { - // Fixme: Register a list of trusted LSPs and check if LSP is allowlisted. - // And if we expect a channel to be opened. - // - either datastore or invoice label possible. - info!("Allowing zero-conf channel from LSP"); - Ok(serde_json::json!({ - "result": "continue", - "reserve": "0msat", - "mindepth": 0, - })) + #[derive(Deserialize)] + struct Request { + id: String, + } + + let req: Request = serde_json::from_value(v.get("openchannel").unwrap().clone()) + .context("Failed to parse request JSON")?; + let dir = p.configuration().lightning_dir; + let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file); + let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; + + let ds_req = ListdatastoreRequest { + key: Some(vec![ + "lsps".to_string(), + "client".to_string(), + req.id.clone(), + ]), + }; + let ds_res = cln_client.call_typed(&ds_req).await?; + if let Some(_rec) = ds_res.datastore.iter().next() { + info!("Allowing zero-conf channel from LSP {}", &req.id); + let ds_req = DeldatastoreRequest { + generation: None, + key: vec!["lsps".to_string(), "client".to_string(), req.id.clone()], + }; + if let Some(err) = cln_client.call_typed(&ds_req).await.err() { + // We can do nothing but report that there was an issue deleting the + // datastore record. + warn!("Failed to delete LSP record from datastore: {}", err); + } + // Fixme: Check that we actually use client-trusts-LSP mode - can be + // found in the ds record. + return Ok(serde_json::json!({ + "result": "continue", + "reserve": "0msat", + "mindepth": 0, + })); + } else { + // Not a requested JIT-channel opening, continue. + Ok(serde_json::json!({"result": "continue"})) + } } async fn on_lsps_listprotocols( @@ -659,7 +689,6 @@ struct ClnRpcLsps2Approve { #[derive(Debug, Clone, Serialize, Deserialize)] struct DatastoreRecord { - lsp_id: String, jit_channel_scid: ShortChannelId, client_trusts_lsp: bool, } diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs index 9bcda54e806e..f3f5c6ed781b 100644 --- a/plugins/lsps-plugin/src/lsps2/handler.rs +++ b/plugins/lsps-plugin/src/lsps2/handler.rs @@ -507,8 +507,7 @@ impl HtlcAcceptedHookHandler { push_msat: None, request_amt: None, reserve: None, - channel_type: None, // Fimxe: Core-Lightning is complaining that it doesn't support these channel_types - // channel_type: Some(vec![46, 50]), // Sets `option_zeroconf` and `option_scid_alias` + channel_type: Some(vec![12, 22, 50]), utxos: None, amount: AmountOrAll::Amount(Amount::from_msat(cap)), id: ds_rec.peer_id, diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 776c1a28129a..15f1df33151d 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -161,3 +161,59 @@ def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind): # l1 should have gotten a jit-channel. chs = l1.rpc.listpeerchannels()['channels'] assert len(chs) == 1 + + +def test_lsps2_non_approved_zero_conf(node_factory, bitcoind): + """ Checks that we don't allow zerof_conf channels from an LSP if we did + not approve it first. + """ + # We need a policy service to fetch from. + plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py') + + l1, l2, l3= node_factory.get_nodes(3, opts=[ + {"dev-lsps-client-enabled": None}, + { + "dev-lsps-service-enabled": None, + "dev-lsps2-service-enabled": None, + "dev-lsps2-promise-secret": "00" * 32, + "plugin": plugin, + "fee-base": 0, # We are going to deduct our fee anyways, + "fee-per-satoshi": 0, # We are going to deduct our fee anyways, + }, + {"disable-mpp": None}, + ]) + + # Give the LSP some funds to open jit-channels + addr = l2.rpc.newaddr()['bech32'] + bitcoind.rpc.sendtoaddress(addr, 1) + bitcoind.generate_block(1) + + node_factory.join_nodes([l3, l2], fundchannel=True, wait_for_announce=True) + node_factory.join_nodes([l1, l2], fundchannel=False) + + chanid = only_one(l3.rpc.listpeerchannels(l2.info['id'])['channels'])['short_channel_id'] + + fee_opt = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id'])['opening_fee_params_menu'][0] + buy_res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], opening_fee_params=fee_opt) + + hint = [[{ + "id": l2.info['id'], + "short_channel_id": buy_res['jit_channel_scid'], + "fee_base_msat": 0, + "fee_proportional_millionths": 0, + "cltv_expiry_delta": buy_res['lsp_cltv_expiry_delta'], + }]] + + bolt11 = l1.dev_invoice( + amount_msat="any", + description="lsp-invoice-1", + label="lsp-invoice-1", + dev_routes=hint, + )['bolt11'] + + with pytest.raises(ValueError): + l3.rpc.pay(bolt11, amount_msat=10000000) + + # l1 shouldn't have a new channel. + chs = l1.rpc.listpeerchannels()['channels'] + assert len(chs) == 0 From fbdd460bf01e94159ced4a39a0e3a1a72ed76ad3 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Mon, 29 Sep 2025 16:39:41 +0200 Subject: [PATCH 15/22] lsp_plugin: pass-through invoice params Calling lsps_jitchannel we want to pass through the label and description parameters used to call `invoice` to keep the api close to Core-Lightning Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 54 ++++++++++--------------------- tests/test_cln_lsps.py | 9 ++++-- 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index 484facc23bd4..1037bf550b44 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -167,19 +167,8 @@ async fn on_lsps_lsps2_buy( .context("Failed to create Bolt8Transport")?; let client = JsonRpcClient::new(transport); - // Convert from AmountOrAny to Msat. - let payment_size_msat = if let Some(payment_size) = req.payment_size_msat { - match payment_size { - AmountOrAny::Amount(amount) => Some(Msat::from_msat(amount.msat())), - AmountOrAny::Any => None, - } - } else { - None - }; - let selected_params = req.opening_fee_params; - - if let Some(payment_size) = payment_size_msat { + if let Some(payment_size) = req.payment_size_msat { if payment_size < selected_params.min_payment_size_msat { return Err(anyhow!( "Requested payment size {}msat is below minimum {}msat required by LSP", @@ -224,7 +213,7 @@ async fn on_lsps_lsps2_buy( debug!("Calling lsps2.buy for peer {}", req.lsp_id); let buy_req = Lsps2BuyRequest { opening_fee_params: selected_params, // Pass the chosen params back - payment_size_msat, + payment_size_msat: req.payment_size_msat, }; let buy_res: Lsps2BuyResponse = client .call_typed(buy_req) @@ -270,16 +259,18 @@ async fn on_lsps_jitchannel( #[derive(Deserialize)] struct Request { lsp_id: String, - // Optional: for fixed-amount invoices - payment_size_msat: Option, // Optional: for discounts/API keys token: Option, + // Pass-through of cln invoice rpc params + pub amount_msat: cln_rpc::primitives::AmountOrAny, + pub description: String, + pub label: String, } let req: Request = serde_json::from_value(v).context("Failed to parse request JSON")?; debug!( "Handling lsps-buy-jit-channel request for peer {} with payment_size {:?} and token {:?}", - req.lsp_id, req.payment_size_msat, req.token + req.lsp_id, req.amount_msat, req.token ); let dir = p.configuration().lightning_dir; @@ -322,13 +313,18 @@ async fn on_lsps_jitchannel( info!("Selected fee parameters: {:?}", selected_params); + let payment_size_msat = match req.amount_msat { + AmountOrAny::Amount(amount) => Some(Msat::from_msat(amount.msat())), + AmountOrAny::Any => None, + }; + // 3. Request channel from LSP. let buy_res: Lsps2BuyResponse = cln_client .call_raw( "lsps-lsps2-buy", &ClnRpcLsps2BuyRequest { lsp_id: req.lsp_id.clone(), - payment_size_msat: req.payment_size_msat, + payment_size_msat, opening_fee_params: selected_params.clone(), }, ) @@ -356,20 +352,14 @@ async fn on_lsps_jitchannel( cltv_expiry_delta: u16::try_from(buy_res.lsp_cltv_expiry_delta)?, }; - let amount_msat = if let Some(payment_size) = req.payment_size_msat { - payment_size - } else { - AmountOrAny::Any - }; - let inv: cln_rpc::model::responses::InvoiceResponse = cln_client .call_raw( "invoice", &InvoiceRequest { - amount_msat, + amount_msat: req.amount_msat, dev_routes: Some(vec![vec![hint]]), - description: String::from("TODO"), // TODO: Pass down description from rpc call - label: gen_label(None), // TODO: Pass down label from rpc call + description: req.description, + label: req.label, expiry: Some(expiry as u64), cltv: Some(u32::try_from(6 + 2)?), // TODO: FETCH REAL VALUE! deschashonly: None, @@ -619,15 +609,6 @@ async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<( Ok(()) } -/// Generates a unique label from an optional `String`. The given label is -/// appended by a timestamp (now). -fn gen_label(label: Option<&str>) -> String { - let now = Utc::now(); - let millis = now.timestamp_millis(); - let l = label.unwrap_or_else(|| "lsps2.buy"); - format!("{}_{}", l, millis) -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] struct LspsBuyJitChannelResponse { bolt11: String, @@ -668,8 +649,7 @@ pub struct RoutehintHopDev { #[derive(Debug, Clone, Serialize, Deserialize)] struct ClnRpcLsps2BuyRequest { lsp_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - payment_size_msat: Option, + payment_size_msat: Option, opening_fee_params: OpeningFeeParams, } diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 15f1df33151d..a7b66eb43c71 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -86,7 +86,7 @@ def test_lsps2_buy(node_factory): res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id']) params = res["opening_fee_params_menu"][0] - res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], payment_size_msat=None, opening_fee_params=params) + res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], opening_fee_params=params) assert res @@ -133,7 +133,12 @@ def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind): chanid = only_one(l3.rpc.listpeerchannels(l2.info['id'])['channels'])['short_channel_id'] - inv = l1.rpc.lsps_jitchannel(lsp_id=l2.info['id']) + inv = l1.rpc.lsps_jitchannel( + lsp_id=l2.info['id'], + amount_msat="any", + description="lsp-jit-channel-0", + label="lsp-jit-channel-0" + ) assert inv dec = l3.rpc.decode(inv['bolt11']) From 2fb5b982f4d01ccd0f8b5e1553802bd34c6d87db Mon Sep 17 00:00:00 2001 From: Christian Decker Date: Mon, 29 Sep 2025 17:56:21 +0200 Subject: [PATCH 16/22] chore(fmt): Fix formatting of new python files. --- tests/plugins/lsps2_policy.py | 63 +++++---- tests/test_cln_lsps.py | 238 +++++++++++++++++++--------------- 2 files changed, 162 insertions(+), 139 deletions(-) diff --git a/tests/plugins/lsps2_policy.py b/tests/plugins/lsps2_policy.py index 7588712df476..9294bbec75fc 100755 --- a/tests/plugins/lsps2_policy.py +++ b/tests/plugins/lsps2_policy.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -""" A simple implementation of a LSPS2 compatible policy plugin. It is the job +"""A simple implementation of a LSPS2 compatible policy plugin. It is the job of this plugin to deliver a fee options menu to the LSPS2 service plugin. """ @@ -12,45 +12,40 @@ @plugin.method("dev-lsps2-getpolicy") def lsps2_getpolicy(request): - """ Returns an opening fee menu for the LSPS2 plugin. - """ + """Returns an opening fee menu for the LSPS2 plugin.""" now = datetime.now(timezone.utc) # Is ISO 8601 format "YYYY-MM-DDThh:mm:ss.uuuZ" - valid_until = (now + timedelta(hours=1)).isoformat().replace('+00:00', 'Z') - - return { "policy_opening_fee_params_menu": [ - { - "min_fee_msat": "1000", - "proportional": 1000, - "valid_until": valid_until, - "min_lifetime": 2000, - "max_client_to_self_delay": 2016, - "min_payment_size_msat": "1000", - "max_payment_size_msat": "100000000", - }, - { - "min_fee_msat": "1092000", - "proportional": 2400, - "valid_until": valid_until, - "min_lifetime": 1008, - "max_client_to_self_delay": 2016, - "min_payment_size_msat": "1000", - "max_payment_size_msat": "1000000", - } - ] -} + valid_until = (now + timedelta(hours=1)).isoformat().replace("+00:00", "Z") + + return { + "policy_opening_fee_params_menu": [ + { + "min_fee_msat": "1000", + "proportional": 1000, + "valid_until": valid_until, + "min_lifetime": 2000, + "max_client_to_self_delay": 2016, + "min_payment_size_msat": "1000", + "max_payment_size_msat": "100000000", + }, + { + "min_fee_msat": "1092000", + "proportional": 2400, + "valid_until": valid_until, + "min_lifetime": 1008, + "max_client_to_self_delay": 2016, + "min_payment_size_msat": "1000", + "max_payment_size_msat": "1000000", + }, + ] + } + @plugin.method("dev-lsps2-getchannelcapacity") def lsps2_getchannelcapacity(request, init_payment_size, scid, opening_fee_params): - """ Returns an opening fee menu for the LSPS2 plugin. - """ - now = datetime.now(timezone.utc) - - # Is ISO 8601 format "YYYY-MM-DDThh:mm:ss.uuuZ" - valid_until = (now + timedelta(hours=1)).isoformat().replace('+00:00', 'Z') - - return { "channel_capacity_msat": 100000000 } + """Returns an opening fee menu for the LSPS2 plugin.""" + return {"channel_capacity_msat": 100000000} plugin.run() diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index a7b66eb43c71..290a3e2946b0 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -2,6 +2,7 @@ from pyln.testing.utils import RUST from utils import only_one import os +import pytest import unittest RUST_PROFILE = os.environ.get("RUST_PROFILE", "debug") @@ -20,78 +21,87 @@ def test_lsps_service_disabled(node_factory): @unittest.skipUnless(RUST, 'RUST is not enabled') def test_lsps0_listprotocols(node_factory): - l1, l2 = node_factory.get_nodes(2, opts=[ - {"dev-lsps-client-enabled": None}, {"dev-lsps-service-enabled": None} - ]) + l1, l2 = node_factory.get_nodes( + 2, opts=[{"dev-lsps-client-enabled": None}, {"dev-lsps-service-enabled": None}] + ) # We don't need a channel to query for lsps services node_factory.join_nodes([l1, l2], fundchannel=False) - res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id']) + res = l1.rpc.lsps_listprotocols(lsp_id=l2.info["id"]) assert res def test_lsps2_enabled(node_factory): - l1, l2 = node_factory.get_nodes(2, opts=[ - {"dev-lsps-client-enabled": None}, - { - "dev-lsps-service-enabled": None, - "dev-lsps2-service-enabled": None, - "dev-lsps2-promise-secret": "0" * 64 - } - ]) + l1, l2 = node_factory.get_nodes( + 2, + opts=[ + {"dev-lsps-client-enabled": None}, + { + "dev-lsps-service-enabled": None, + "dev-lsps2-service-enabled": None, + "dev-lsps2-promise-secret": "0" * 64, + }, + ], + ) node_factory.join_nodes([l1, l2], fundchannel=False) - res = l1.rpc.lsps_listprotocols(lsp_id=l2.info['id']) - assert res['protocols'] == [2] + res = l1.rpc.lsps_listprotocols(lsp_id=l2.info["id"]) + assert res["protocols"] == [2] def test_lsps2_getinfo(node_factory): - plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py') - - l1, l2 = node_factory.get_nodes(2, opts=[ - {"dev-lsps-client-enabled": None}, - { - "dev-lsps-service-enabled": None, - "dev-lsps2-service-enabled": None, - "dev-lsps2-promise-secret": "0" * 64, - "plugin": plugin - } - ]) + plugin = os.path.join(os.path.dirname(__file__), "plugins/lsps2_policy.py") + + l1, l2 = node_factory.get_nodes( + 2, + opts=[ + {"dev-lsps-client-enabled": None}, + { + "dev-lsps-service-enabled": None, + "dev-lsps2-service-enabled": None, + "dev-lsps2-promise-secret": "0" * 64, + "plugin": plugin, + }, + ], + ) node_factory.join_nodes([l1, l2], fundchannel=False) - res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id']) + res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info["id"]) assert res["opening_fee_params_menu"] def test_lsps2_buy(node_factory): # We need a policy service to fetch from. - plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py') - - l1, l2 = node_factory.get_nodes(2, opts=[ - {"dev-lsps-client-enabled": None}, - { - "dev-lsps-service-enabled": None, - "dev-lsps2-service-enabled": None, - "dev-lsps2-promise-secret": "0" * 64, - "plugin": plugin - } - ]) + plugin = os.path.join(os.path.dirname(__file__), "plugins/lsps2_policy.py") + + l1, l2 = node_factory.get_nodes( + 2, + opts=[ + {"dev-lsps-client-enabled": None}, + { + "dev-lsps-service-enabled": None, + "dev-lsps2-service-enabled": None, + "dev-lsps2-promise-secret": "0" * 64, + "plugin": plugin, + }, + ], + ) # We don't need a channel to query for lsps services node_factory.join_nodes([l1, l2], fundchannel=False) - res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id']) + res = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info["id"]) params = res["opening_fee_params_menu"][0] - res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], opening_fee_params=params) + res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info["id"], opening_fee_params=params) assert res def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind): - """ Tests the creation of a "Just-In-Time-Channel" (jit-channel). + """Tests the creation of a "Just-In-Time-Channel" (jit-channel). At the beginning we have the following situation where l2 acts as the LSP (LSP) @@ -108,117 +118,135 @@ def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind): l1----l2----l3 """ # We need a policy service to fetch from. - plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py') - - l1, l2, l3= node_factory.get_nodes(3, opts=[ - {"dev-lsps-client-enabled": None}, - { - "dev-lsps-service-enabled": None, - "dev-lsps2-service-enabled": None, - "dev-lsps2-promise-secret": "00" * 32, - "plugin": plugin, - "fee-base": 0, # We are going to deduct our fee anyways, - "fee-per-satoshi": 0, # We are going to deduct our fee anyways, - }, - {}, - ]) + plugin = os.path.join(os.path.dirname(__file__), "plugins/lsps2_policy.py") + + l1, l2, l3 = node_factory.get_nodes( + 3, + opts=[ + {"dev-lsps-client-enabled": None}, + { + "dev-lsps-service-enabled": None, + "dev-lsps2-service-enabled": None, + "dev-lsps2-promise-secret": "00" * 32, + "plugin": plugin, + "fee-base": 0, # We are going to deduct our fee anyways, + "fee-per-satoshi": 0, # We are going to deduct our fee anyways, + }, + {}, + ], + ) # Give the LSP some funds to open jit-channels - addr = l2.rpc.newaddr()['bech32'] + addr = l2.rpc.newaddr()["bech32"] bitcoind.rpc.sendtoaddress(addr, 1) bitcoind.generate_block(1) node_factory.join_nodes([l3, l2], fundchannel=True, wait_for_announce=True) node_factory.join_nodes([l1, l2], fundchannel=False) - chanid = only_one(l3.rpc.listpeerchannels(l2.info['id'])['channels'])['short_channel_id'] + chanid = only_one(l3.rpc.listpeerchannels(l2.info["id"])["channels"])[ + "short_channel_id" + ] inv = l1.rpc.lsps_jitchannel( - lsp_id=l2.info['id'], + lsp_id=l2.info["id"], amount_msat="any", description="lsp-jit-channel-0", - label="lsp-jit-channel-0" + label="lsp-jit-channel-0", ) assert inv - dec = l3.rpc.decode(inv['bolt11']) + dec = l3.rpc.decode(inv["bolt11"]) assert dec - routehint = only_one(only_one(dec['routes'])) + routehint = only_one(only_one(dec["routes"])) amt = 10000000 - fee = amt * 10 // 1000000 + 1 - - route = [{'amount_msat': amt, - 'id': l2.info['id'], - 'delay': 14, - 'channel': chanid}, - {'amount_msat': amt, - 'id': l1.info['id'], - 'delay': 8, - 'channel': routehint['short_channel_id']}] - l3.rpc.sendpay(route, dec['payment_hash'], payment_secret=inv['payment_secret'], bolt11=inv['bolt11'], partid=0) + route = [ + {"amount_msat": amt, "id": l2.info["id"], "delay": 14, "channel": chanid}, + { + "amount_msat": amt, + "id": l1.info["id"], + "delay": 8, + "channel": routehint["short_channel_id"], + }, + ] + + l3.rpc.sendpay( + route, + dec["payment_hash"], + payment_secret=inv["payment_secret"], + bolt11=inv["bolt11"], + partid=0, + ) - res = l3.rpc.waitsendpay(dec['payment_hash']) - assert res['payment_preimage'] + res = l3.rpc.waitsendpay(dec["payment_hash"]) + assert res["payment_preimage"] # l1 should have gotten a jit-channel. - chs = l1.rpc.listpeerchannels()['channels'] + chs = l1.rpc.listpeerchannels()["channels"] assert len(chs) == 1 def test_lsps2_non_approved_zero_conf(node_factory, bitcoind): - """ Checks that we don't allow zerof_conf channels from an LSP if we did - not approve it first. + """Checks that we don't allow zerof_conf channels from an LSP if we did + not approve it first. """ # We need a policy service to fetch from. - plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py') - - l1, l2, l3= node_factory.get_nodes(3, opts=[ - {"dev-lsps-client-enabled": None}, - { - "dev-lsps-service-enabled": None, - "dev-lsps2-service-enabled": None, - "dev-lsps2-promise-secret": "00" * 32, - "plugin": plugin, - "fee-base": 0, # We are going to deduct our fee anyways, - "fee-per-satoshi": 0, # We are going to deduct our fee anyways, - }, - {"disable-mpp": None}, - ]) + plugin = os.path.join(os.path.dirname(__file__), "plugins/lsps2_policy.py") + + l1, l2, l3 = node_factory.get_nodes( + 3, + opts=[ + {"dev-lsps-client-enabled": None}, + { + "dev-lsps-service-enabled": None, + "dev-lsps2-service-enabled": None, + "dev-lsps2-promise-secret": "00" * 32, + "plugin": plugin, + "fee-base": 0, # We are going to deduct our fee anyways, + "fee-per-satoshi": 0, # We are going to deduct our fee anyways, + }, + {"disable-mpp": None}, + ], + ) # Give the LSP some funds to open jit-channels - addr = l2.rpc.newaddr()['bech32'] + addr = l2.rpc.newaddr()["bech32"] bitcoind.rpc.sendtoaddress(addr, 1) bitcoind.generate_block(1) node_factory.join_nodes([l3, l2], fundchannel=True, wait_for_announce=True) node_factory.join_nodes([l1, l2], fundchannel=False) - chanid = only_one(l3.rpc.listpeerchannels(l2.info['id'])['channels'])['short_channel_id'] - - fee_opt = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id'])['opening_fee_params_menu'][0] - buy_res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], opening_fee_params=fee_opt) - - hint = [[{ - "id": l2.info['id'], - "short_channel_id": buy_res['jit_channel_scid'], - "fee_base_msat": 0, - "fee_proportional_millionths": 0, - "cltv_expiry_delta": buy_res['lsp_cltv_expiry_delta'], - }]] + fee_opt = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info["id"])[ + "opening_fee_params_menu" + ][0] + buy_res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info["id"], opening_fee_params=fee_opt) + + hint = [ + [ + { + "id": l2.info["id"], + "short_channel_id": buy_res["jit_channel_scid"], + "fee_base_msat": 0, + "fee_proportional_millionths": 0, + "cltv_expiry_delta": buy_res["lsp_cltv_expiry_delta"], + } + ] + ] bolt11 = l1.dev_invoice( amount_msat="any", description="lsp-invoice-1", label="lsp-invoice-1", dev_routes=hint, - )['bolt11'] + )["bolt11"] with pytest.raises(ValueError): l3.rpc.pay(bolt11, amount_msat=10000000) # l1 shouldn't have a new channel. - chs = l1.rpc.listpeerchannels()['channels'] + chs = l1.rpc.listpeerchannels()["channels"] assert len(chs) == 0 From 9b4f165a8f5ce806a18dfd3d99f73a9d0beff1eb Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Tue, 7 Oct 2025 12:41:54 +0200 Subject: [PATCH 17/22] lsp_plugin: remove redundant config option We don't need to separately enable lsp and lsps2 services. If lsps2 is not enabled what can we do with just the messaging layer? Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/service.rs | 66 ++++++++++++------------------ 1 file changed, 27 insertions(+), 39 deletions(-) diff --git a/plugins/lsps-plugin/src/service.rs b/plugins/lsps-plugin/src/service.rs index b17c8e83b0bd..60607754b36e 100644 --- a/plugins/lsps-plugin/src/service.rs +++ b/plugins/lsps-plugin/src/service.rs @@ -1,4 +1,4 @@ -use anyhow::anyhow; +use anyhow::{anyhow, bail}; use async_trait::async_trait; use cln_lsps::jsonrpc::server::JsonRpcResponseWriter; use cln_lsps::jsonrpc::TransportError; @@ -11,8 +11,7 @@ use cln_lsps::lsps2::handler::{ClnApiRpc, HtlcAcceptedHookHandler}; use cln_lsps::lsps2::model::{Lsps2BuyRequest, Lsps2GetInfoRequest}; use cln_lsps::util::wrap_payload_with_peer_id; use cln_lsps::{lsps0, lsps2, util, LSP_FEATURE_BIT}; -use cln_plugin::options::ConfigOption; -use cln_plugin::{options, Plugin}; +use cln_plugin::Plugin; use cln_rpc::notifications::CustomMsgNotification; use cln_rpc::primitives::PublicKey; use log::debug; @@ -20,12 +19,6 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; -/// An option to enable this service. -const OPTION_ENABLED: options::FlagConfigOption = ConfigOption::new_flag( - "dev-lsps-service-enabled", - "Enables an LSPS service on the node.", -); - #[derive(Clone)] struct State { lsps_service: JsonRpcServer, @@ -35,7 +28,6 @@ struct State { #[tokio::main] async fn main() -> Result<(), anyhow::Error> { if let Some(plugin) = cln_plugin::Builder::new(tokio::io::stdin(), tokio::io::stdout()) - .option(OPTION_ENABLED) .option(lsps2::OPTION_ENABLED) .option(lsps2::OPTION_PROMISE_SECRET) .featurebits( @@ -54,23 +46,9 @@ async fn main() -> Result<(), anyhow::Error> { let rpc_path = Path::new(&plugin.configuration().lightning_dir).join(&plugin.configuration().rpc_file); - if !plugin.option(&OPTION_ENABLED)? { - return plugin - .disable(&format!("`{}` not enabled", OPTION_ENABLED.name)) - .await; - } - - let mut lsps_builder = JsonRpcServer::builder().with_handler( - Lsps0listProtocolsRequest::METHOD.to_string(), - Arc::new(Lsps0ListProtocolsHandler { - lsps2_enabled: plugin.option(&lsps2::OPTION_ENABLED)?, - }), - ); - - let lsps2_enabled = if plugin.option(&lsps2::OPTION_ENABLED)? { - log::debug!("lsps2 enabled"); - let secret_hex = plugin.option(&lsps2::OPTION_PROMISE_SECRET)?; - if let Some(secret_hex) = secret_hex { + if plugin.option(&lsps2::OPTION_ENABLED)? { + log::debug!("lsps2-service enabled"); + if let Some(secret_hex) = plugin.option(&lsps2::OPTION_PROMISE_SECRET)? { let secret_hex = secret_hex.trim().to_lowercase(); let decoded_bytes = match hex::decode(&secret_hex) { @@ -97,6 +75,13 @@ 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 = lsps2::handler::ClnApiRpc::new(rpc_path); let getinfo_handler = lsps2::handler::Lsps2GetInfoHandler::new(cln_api_rpc.clone(), secret); @@ -107,20 +92,23 @@ async fn main() -> Result<(), anyhow::Error> { Arc::new(getinfo_handler), ) .with_handler(Lsps2BuyRequest::METHOD.to_string(), Arc::new(buy_handler)); - } - true - } else { - false - }; - let lsps_service = lsps_builder.build(); + let lsps_service = lsps_builder.build(); - let state = State { - lsps_service, - lsps2_enabled, - }; - let plugin = plugin.start(state).await?; - plugin.join().await + let state = State { + lsps_service, + lsps2_enabled: true, + }; + let plugin = plugin.start(state).await?; + plugin.join().await + } else { + bail!("lsps2 enabled but no promise-secret set."); + } + } else { + return plugin + .disable(&format!("`{}` not enabled", &lsps2::OPTION_ENABLED.name)) + .await; + } } else { Ok(()) } From 9f2841ae2ae104387567e469cef930cd9e1b92b8 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Tue, 7 Oct 2025 12:44:11 +0200 Subject: [PATCH 18/22] lsp_plugin: rename cmds and opts to fit convention We use `experimental-*` for documented commands instead of `dev-` which are undocumented commands. Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 4 +- plugins/lsps-plugin/src/lsps2/handler.rs | 10 ++--- plugins/lsps-plugin/src/lsps2/mod.rs | 4 +- tests/plugins/lsps2_policy.py | 8 ++-- tests/test_cln_lsps.py | 50 ++++++++++++------------ 5 files changed, 39 insertions(+), 37 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index 1037bf550b44..e407efeb0fa1 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -30,7 +30,7 @@ use std::str::FromStr as _; /// An option to enable this service. const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag( - "dev-lsps-client-enabled", + "experimental-lsps-client", "Enables an LSPS client on the node.", ); @@ -74,7 +74,7 @@ async fn main() -> Result<(), anyhow::Error> { on_lsps_lsps2_approve, ) .rpcmethod( - "lsps-jitchannel", + "lsps-lsps2-invoice", "Requests a new jit channel from LSP and returns the matching invoice", on_lsps_jitchannel, ) diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs index f3f5c6ed781b..1bac89b9d1bd 100644 --- a/plugins/lsps-plugin/src/lsps2/handler.rs +++ b/plugins/lsps-plugin/src/lsps2/handler.rs @@ -95,10 +95,10 @@ impl ClnApi for ClnApiRpc { params: &Lsps2PolicyGetInfoRequest, ) -> AnyResult { let mut rpc = self.create_rpc().await?; - rpc.call_raw("dev-lsps2-getpolicy", params) + rpc.call_raw("lsps2-policy-getpolicy", params) .await .map_err(anyhow::Error::new) - .with_context(|| "calling dev-lsps2-getpolicy") + .with_context(|| "calling lsps2-policy-getpolicy") } async fn lsps2_getchannelcapacity( @@ -106,10 +106,10 @@ impl ClnApi for ClnApiRpc { params: &Lsps2PolicyGetChannelCapacityRequest, ) -> AnyResult { let mut rpc = self.create_rpc().await?; - rpc.call_raw("dev-lsps2-getchannelcapacity", params) + rpc.call_raw("lsps2-policy-getchannelcapacity", params) .await .map_err(anyhow::Error::new) - .with_context(|| "calling dev-lsps2-getchannelcapacity") + .with_context(|| "calling lsps2-policy-getchannelcapacity") } async fn cln_getinfo(&self, params: &GetinfoRequest) -> AnyResult { @@ -185,7 +185,7 @@ impl Lsps2GetInfoHandler { } } -/// The RequestHandler calls the internal rpc command `dev-lsps2-getinfo`. It +/// 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] diff --git a/plugins/lsps-plugin/src/lsps2/mod.rs b/plugins/lsps-plugin/src/lsps2/mod.rs index 2b98aa1dfb15..60d8ebf5f101 100644 --- a/plugins/lsps-plugin/src/lsps2/mod.rs +++ b/plugins/lsps-plugin/src/lsps2/mod.rs @@ -5,13 +5,13 @@ pub mod handler; pub mod model; pub const OPTION_ENABLED: options::FlagConfigOption = options::ConfigOption::new_flag( - "dev-lsps2-service-enabled", + "experimental-lsps2-service", "Enables lsps2 for the LSP service", ); pub const OPTION_PROMISE_SECRET: options::StringConfigOption = options::ConfigOption::new_str_no_default( - "dev-lsps2-promise-secret", + "experimental-lsps2-promise-secret", "A 64-character hex string that is the secret for promises", ); diff --git a/tests/plugins/lsps2_policy.py b/tests/plugins/lsps2_policy.py index 9294bbec75fc..d71fc67035d9 100755 --- a/tests/plugins/lsps2_policy.py +++ b/tests/plugins/lsps2_policy.py @@ -10,8 +10,8 @@ plugin = Plugin() -@plugin.method("dev-lsps2-getpolicy") -def lsps2_getpolicy(request): +@plugin.method("lsps2-policy-getpolicy") +def lsps2_policy_getpolicy(request): """Returns an opening fee menu for the LSPS2 plugin.""" now = datetime.now(timezone.utc) @@ -42,8 +42,8 @@ def lsps2_getpolicy(request): } -@plugin.method("dev-lsps2-getchannelcapacity") -def lsps2_getchannelcapacity(request, init_payment_size, scid, opening_fee_params): +@plugin.method("lsps2-policy-getchannelcapacity") +def lsps2_policy_getchannelcapacity(request, init_payment_size, scid, opening_fee_params): """Returns an opening fee menu for the LSPS2 plugin.""" return {"channel_capacity_msat": 100000000} diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py index 290a3e2946b0..c45e4739a0d0 100644 --- a/tests/test_cln_lsps.py +++ b/tests/test_cln_lsps.py @@ -22,7 +22,14 @@ def test_lsps_service_disabled(node_factory): @unittest.skipUnless(RUST, 'RUST is not enabled') def test_lsps0_listprotocols(node_factory): l1, l2 = node_factory.get_nodes( - 2, opts=[{"dev-lsps-client-enabled": None}, {"dev-lsps-service-enabled": None}] + 2, + opts=[ + {"experimental-lsps-client": None}, + { + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, + }, + ], ) # We don't need a channel to query for lsps services @@ -36,11 +43,10 @@ def test_lsps2_enabled(node_factory): l1, l2 = node_factory.get_nodes( 2, opts=[ - {"dev-lsps-client-enabled": None}, + {"experimental-lsps-client": None}, { - "dev-lsps-service-enabled": None, - "dev-lsps2-service-enabled": None, - "dev-lsps2-promise-secret": "0" * 64, + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, }, ], ) @@ -57,14 +63,13 @@ def test_lsps2_getinfo(node_factory): l1, l2 = node_factory.get_nodes( 2, opts=[ - {"dev-lsps-client-enabled": None}, + {"experimental-lsps-client": None}, { - "dev-lsps-service-enabled": None, - "dev-lsps2-service-enabled": None, - "dev-lsps2-promise-secret": "0" * 64, + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, "plugin": plugin, }, - ], + ] ) node_factory.join_nodes([l1, l2], fundchannel=False) @@ -80,14 +85,13 @@ def test_lsps2_buy(node_factory): l1, l2 = node_factory.get_nodes( 2, opts=[ - {"dev-lsps-client-enabled": None}, + {"experimental-lsps-client": None}, { - "dev-lsps-service-enabled": None, - "dev-lsps2-service-enabled": None, - "dev-lsps2-promise-secret": "0" * 64, + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, "plugin": plugin, }, - ], + ] ) # We don't need a channel to query for lsps services @@ -123,11 +127,10 @@ def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind): l1, l2, l3 = node_factory.get_nodes( 3, opts=[ - {"dev-lsps-client-enabled": None}, + {"experimental-lsps-client": None}, { - "dev-lsps-service-enabled": None, - "dev-lsps2-service-enabled": None, - "dev-lsps2-promise-secret": "00" * 32, + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, "plugin": plugin, "fee-base": 0, # We are going to deduct our fee anyways, "fee-per-satoshi": 0, # We are going to deduct our fee anyways, @@ -148,7 +151,7 @@ def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind): "short_channel_id" ] - inv = l1.rpc.lsps_jitchannel( + inv = l1.rpc.lsps_lsps2_invoice( lsp_id=l2.info["id"], amount_msat="any", description="lsp-jit-channel-0", @@ -199,11 +202,10 @@ def test_lsps2_non_approved_zero_conf(node_factory, bitcoind): l1, l2, l3 = node_factory.get_nodes( 3, opts=[ - {"dev-lsps-client-enabled": None}, + {"experimental-lsps-client": None}, { - "dev-lsps-service-enabled": None, - "dev-lsps2-service-enabled": None, - "dev-lsps2-promise-secret": "00" * 32, + "experimental-lsps2-service": None, + "experimental-lsps2-promise-secret": "0" * 64, "plugin": plugin, "fee-base": 0, # We are going to deduct our fee anyways, "fee-per-satoshi": 0, # We are going to deduct our fee anyways, From 602c3080a5286488c0697a45006c3bf1cf462de4 Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Tue, 7 Oct 2025 16:27:55 +0200 Subject: [PATCH 19/22] lsp_plugin: add documentation for options Signed-off-by: Peter Neuroth --- doc/lightningd-config.5.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/doc/lightningd-config.5.md b/doc/lightningd-config.5.md index cc5fa08ba5a0..0a9d5826137d 100644 --- a/doc/lightningd-config.5.md +++ b/doc/lightningd-config.5.md @@ -575,7 +575,7 @@ the address is announced. IPv4 or IPv6 address of the Tor control port (default port 9051), and this will be used to configure a Tor hidden service for port 9735 in case of mainnet (bitcoin) network whereas other networks (testnet, -testnet4, signet, regtest) will set the same default ports they use for +testnet4, signet, regtest) will set the same default ports they use for non-Tor addresses (see above). The Tor hidden service will be configured to point to the first IPv4 or IPv6 address we bind to and is by default unique to @@ -804,6 +804,24 @@ The operations will be bundled into a single transaction. The channel will remai active while awaiting splice confirmation, however you can only spend the smaller of the prior channel balance and the new one. +* **experimental-lsps-client** + + Specifying this enables client side support for the lsps protocol +([blip][blip] #50). Core-Lightning only supports the lsps2 ([blip][blip] #52) +subprotocol describing the creation of just-in-time-channel (JIT-channels) +between a LSP and this client. + +* **experimental-lsps2-service** + + Specifying this enables a LSP JIT-Channel service according to the lsps +protocol ([blip][blip] #52). It requires a LSP-Policy plugin to be available and +a *experimental-lsps2-promise-secret* to be set. + +* **experimental-lsps2-promise-secret**=*promise_secret* + + Sets a `promise_secret` for the LSP JIT-Channel service. Is a 64-character hex + string that acts as the secret for promises according to ([blip][blip] #52). + Is required if *experimental-lsps2-service* is set. BUGS ---- @@ -838,3 +856,4 @@ the rest of the code is covered by the BSD-style MIT license. [bolt]: https://github.com/lightning/bolts [bolt12]: https://github.com/rustyrussell/lightning-rfc/blob/guilt/offers/12-offer-encoding.md [pr4421]: https://github.com/ElementsProject/lightning/pull/4421 +[blip]: https://github.com/lightning/blips From bd319716fb57795a35e5092abe41984257e48cce Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Tue, 7 Oct 2025 17:31:45 +0200 Subject: [PATCH 20/22] lsp_plugin: add reversed feature-bit check Core-Lightning returns the feature-bits in reversed order but we don't want to rely on the caller to reverse the u8 slice themselfs. This commit adds a convenience function that reverses the bitmap to avoid hard to debug mistakes. Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 4 +--- plugins/lsps-plugin/src/util.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index e407efeb0fa1..dbc00926917e 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -591,9 +591,7 @@ async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<( // Check that feature bit is set peer.features.as_deref().map_or(false, |f_str| { if let Some(feature_bits) = hex::decode(f_str).ok() { - let mut fb = feature_bits.clone(); - fb.reverse(); - util::is_feature_bit_set(&fb, LSP_FEATURE_BIT) + util::is_feature_bit_set_reversed(&feature_bits, LSP_FEATURE_BIT) } else { false } diff --git a/plugins/lsps-plugin/src/util.rs b/plugins/lsps-plugin/src/util.rs index fe61bb37641f..06784911dfc2 100644 --- a/plugins/lsps-plugin/src/util.rs +++ b/plugins/lsps-plugin/src/util.rs @@ -5,6 +5,32 @@ use core::fmt; use serde_json::Value; use std::str::FromStr; +/// Checks whether a feature bit is set in a bitmap interpreted as +/// **big-endian across bytes**, while keeping **LSB-first within each byte**. +/// +/// This function creates a reversed copy of `bitmap` (so the least-significant +/// byte becomes last), then calls the simple LSB-first `is_feature_bit_set` on it. +/// No mutation of the caller’s slice occurs. +/// +/// In other words: +/// - byte order: **reversed** (big-endian across the slice) +/// - bit order within a byte: **LSB-first** (unchanged) +/// +/// If you need *full* MSB-first (also within a byte), don’t use this helper— +/// rewrite the mask as `1u8 << (7 - bit_index)` instead. +/// +/// # Arguments +/// * `bitmap` – byte slice containing the bitfield (original order, not modified) +/// * `feature_bit` – zero-based bit index across the entire bitmap +/// +/// # Returns +/// `true` if the bit is set; `false` if the bit is unset or out of bounds +pub fn is_feature_bit_set_reversed(bitmap: &[u8], feature_bit: usize) -> bool { + let mut reversed = bitmap.to_vec(); + reversed.reverse(); + is_feature_bit_set(&reversed, feature_bit) +} + /// Checks if the feature bit is set in the provided bitmap. /// Returns true if the `feature_bit` is set in the `bitmap`. Returns false if /// the `feature_bit` is unset or our ouf bounds. From 52db3a91d6e120b884fede0fe41c6fb1c57426ca Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Fri, 10 Oct 2025 16:36:07 +0200 Subject: [PATCH 21/22] lsp_plugin: relax LSP feateture bit handling Replace ensure_lsp_connected() by check_peer_lsp_status() which only returns the status of the peer (connected, has_lsp_feature). This allows us to be more tolearant about the LSP feature bit since it is only optional according to the spec. We still check for the feature but only return a warning in the logs. Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 78 ++++++++++++++++++------------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index dbc00926917e..6ab8a01086cc 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -542,10 +542,19 @@ async fn on_lsps_listprotocols( let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?; let req: Request = serde_json::from_value(v).context("Failed to parse request JSON")?; + let lsp_status = check_peer_lsp_status(&mut cln_client, &req.lsp_id).await?; - // Fail early: Check that we are connected to the peer and that it has the - // LSP feature bit set. - ensure_lsp_connected(&mut cln_client, &req.lsp_id).await?; + // Fail early: Check that we are connected to the peer. + if !lsp_status.connected { + bail!("Not connected to peer {}", &req.lsp_id); + }; + + // From Blip52: LSPs MAY set the features bit numbered 729 + // (option_supports_lsps)... + // We only log that it is not set but don't fail. + if !lsp_status.has_lsp_feature { + debug!("Peer {} doesn't have the LSP feature bit set.", &req.lsp_id); + } // Create the transport first and handle potential errors let transport = Bolt8Transport::new( @@ -563,48 +572,53 @@ async fn on_lsps_listprotocols( let res: lsps0::model::Lsps0listProtocolsResponse = client .call_typed(request) .await - .context("lsps0.list_protocols call failed")?; + .map_err(|e| anyhow!("lsps0.list_protocols call failed: {}", e))?; debug!("Received lsps0.list_protocols response: {:?}", res); Ok(serde_json::to_value(res)?) } -/// Checks that the node is connected to the peer and that it has the LSP -/// feature bit set. -async fn ensure_lsp_connected(cln_client: &mut ClnRpc, lsp_id: &str) -> Result<(), anyhow::Error> { +struct PeerLspStatus { + connected: bool, + has_lsp_feature: bool, +} + +/// Returns the `PeerLspStatus`, containing information about the connectivity +/// and the LSP feature bit. +async fn check_peer_lsp_status( + cln_client: &mut ClnRpc, + peer_id: &str, +) -> Result { let res = cln_client .call_typed(&ListpeersRequest { - id: Some(PublicKey::from_str(lsp_id)?), + id: Some(PublicKey::from_str(peer_id)?), level: None, }) .await?; - // unwrap in next line is safe as we checked that an item exists before. - if res.peers.is_empty() || !res.peers.first().unwrap().connected { - debug!("Node isn't connected to lsp {lsp_id}"); - return Err(anyhow!("not connected to lsp")); - } - - res.peers - .first() - .filter(|peer| { - // Check that feature bit is set - peer.features.as_deref().map_or(false, |f_str| { - if let Some(feature_bits) = hex::decode(f_str).ok() { - util::is_feature_bit_set_reversed(&feature_bits, LSP_FEATURE_BIT) - } else { - false - } + let peer = match res.peers.first() { + None => { + return Ok(PeerLspStatus { + connected: false, + has_lsp_feature: false, }) - }) - .ok_or_else(|| { - anyhow!( - "peer is not an lsp, feature bit {} is missing", - LSP_FEATURE_BIT, - ) - })?; + } + Some(p) => p, + }; + + let connected = peer.connected; + let has_lsp_feature = if let Some(f_str) = &peer.features { + let feature_bits = hex::decode(f_str) + .map_err(|e| anyhow!("Invalid feature bits hex for peer {peer_id}, {f_str}: {e}"))?; + util::is_feature_bit_set_reversed(&feature_bits, LSP_FEATURE_BIT) + } else { + false + }; - Ok(()) + Ok(PeerLspStatus { + connected, + has_lsp_feature, + }) } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] From 1a2d522d90bc6c60dcfd721f9051e2df1a9dc55b Mon Sep 17 00:00:00 2001 From: Peter Neuroth Date: Fri, 10 Oct 2025 16:40:45 +0200 Subject: [PATCH 22/22] lsp_plugin: remove reserve from hook response This slipped in during development but actually, we don't want to mess with the channel reservere here. Signed-off-by: Peter Neuroth --- plugins/lsps-plugin/src/client.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs index 6ab8a01086cc..9f02f861df19 100644 --- a/plugins/lsps-plugin/src/client.rs +++ b/plugins/lsps-plugin/src/client.rs @@ -520,7 +520,6 @@ async fn on_openchannel( // found in the ds record. return Ok(serde_json::json!({ "result": "continue", - "reserve": "0msat", "mindepth": 0, })); } else {