From f7c9d04463c1880cd37267c6d85976e2a41f1f71 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 27 Mar 2025 16:58:40 -0300 Subject: [PATCH 01/12] Introduce 'time' feature in lightning-liquidity. This will be used to allow disabling time-dependent functionality. 'time' is included in the features default. --- lightning-liquidity/Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lightning-liquidity/Cargo.toml b/lightning-liquidity/Cargo.toml index 0733d387b15..f301e4fe34c 100644 --- a/lightning-liquidity/Cargo.toml +++ b/lightning-liquidity/Cargo.toml @@ -14,8 +14,9 @@ categories = ["cryptography::cryptocurrencies"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["std"] +default = ["std", "time"] std = ["lightning/std"] +time = ["std"] backtrace = ["dep:backtrace"] [dependencies] From b105bc5a896885bff3089334f8562b333eb1e6e2 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 27 Mar 2025 17:24:17 -0300 Subject: [PATCH 02/12] Prefactor - Improvements on LSPSDateTime Introduce new_from_duration_since_epoch constructor from Duration. Also add abs_diff function to use on client / service. Also do feature='time' instead of feature='std' on time related functionality --- lightning-liquidity/src/lsps0/ser.rs | 15 +++++++++++++-- lightning-liquidity/src/lsps2/utils.rs | 6 +++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/lightning-liquidity/src/lsps0/ser.rs b/lightning-liquidity/src/lsps0/ser.rs index 9fb27713892..74b40cd9d60 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -29,7 +29,8 @@ use lightning::util::ser::{LengthLimitedRead, LengthReadable, WithoutLength}; use bitcoin::secp256k1::PublicKey; -#[cfg(feature = "std")] +use core::time::Duration; +#[cfg(feature = "time")] use std::time::{SystemTime, UNIX_EPOCH}; use serde::de::{self, MapAccess, Visitor}; @@ -204,7 +205,7 @@ impl LSPSDateTime { } /// Returns if the given time is in the past. - #[cfg(feature = "std")] + #[cfg(feature = "time")] pub fn is_past(&self) -> bool { let now_seconds_since_epoch = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -214,6 +215,16 @@ impl LSPSDateTime { self.0.timestamp().try_into().expect("expiration to be ahead of unix epoch"); now_seconds_since_epoch > datetime_seconds_since_epoch } + + /// Returns the time in seconds since the unix epoch. + pub fn abs_diff(&self, other: &Self) -> u64 { + self.0.timestamp().abs_diff(other.0.timestamp()) + } + + /// Returns the time in seconds since the unix epoch. + pub fn new_from_duration_since_epoch(duration: Duration) -> Self { + Self(chrono::DateTime::UNIX_EPOCH + duration) + } } impl FromStr for LSPSDateTime { diff --git a/lightning-liquidity/src/lsps2/utils.rs b/lightning-liquidity/src/lsps2/utils.rs index 76ceeb8f60b..a2c4d65936d 100644 --- a/lightning-liquidity/src/lsps2/utils.rs +++ b/lightning-liquidity/src/lsps2/utils.rs @@ -28,13 +28,13 @@ pub fn is_valid_opening_fee_params( } /// Determines if the given parameters are expired, or still valid. -#[cfg_attr(not(feature = "std"), allow(unused_variables))] +#[cfg_attr(not(feature = "time"), allow(unused_variables))] pub fn is_expired_opening_fee_params(fee_params: &LSPS2OpeningFeeParams) -> bool { - #[cfg(feature = "std")] + #[cfg(feature = "time")] { fee_params.valid_until.is_past() } - #[cfg(not(feature = "std"))] + #[cfg(not(feature = "time"))] { // TODO: We need to find a way to check expiry times in no-std builds. false From 8ac732ccedb69a4b2e01fcd43ee8315f444c4f05 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Fri, 18 Jul 2025 15:02:51 -0300 Subject: [PATCH 03/12] Prefactor -> Introduce sign_message on NodeSigner, to be used by LSPS5/service when signing notifications --- fuzz/src/chanmon_consistency.rs | 4 ++++ fuzz/src/full_stack.rs | 4 ++++ fuzz/src/onion_message.rs | 4 ++++ lightning/src/ln/blinded_payment_tests.rs | 3 +++ lightning/src/sign/mod.rs | 19 +++++++++++++++++++ lightning/src/util/dyn_signer.rs | 2 ++ lightning/src/util/test_utils.rs | 8 ++++++++ 7 files changed, 44 insertions(+) diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index 3acaa2fa323..5d376a9d842 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -365,6 +365,10 @@ impl NodeSigner for KeyProvider { let secp_ctx = Secp256k1::signing_only(); Ok(secp_ctx.sign_ecdsa(&msg_hash, &self.node_secret)) } + + fn sign_message(&self, msg: &[u8]) -> Result { + Ok(lightning::util::message_signing::sign(msg, &self.node_secret)) + } } impl SignerProvider for KeyProvider { diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index 970726e7005..1de5cf949f7 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -436,6 +436,10 @@ impl NodeSigner for KeyProvider { Ok(secp_ctx.sign_ecdsa(&msg_hash, &self.node_secret)) } + fn sign_message(&self, msg: &[u8]) -> Result { + Ok(lightning::util::message_signing::sign(msg, &self.node_secret)) + } + fn get_peer_storage_key(&self) -> PeerStorageKey { PeerStorageKey { inner: [42; 32] } } diff --git a/fuzz/src/onion_message.rs b/fuzz/src/onion_message.rs index 85fb5a9f513..a653b35a126 100644 --- a/fuzz/src/onion_message.rs +++ b/fuzz/src/onion_message.rs @@ -277,6 +277,10 @@ impl NodeSigner for KeyProvider { unreachable!() } + fn sign_message(&self, msg: &[u8]) -> Result { + Ok(lightning::util::message_signing::sign(msg, &self.node_secret)) + } + fn get_peer_storage_key(&self) -> PeerStorageKey { unreachable!() } diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index ba600624a01..675d628ef2c 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -1633,6 +1633,8 @@ fn route_blinding_spec_test_vector() { &self, _invoice: &UnsignedBolt12Invoice, ) -> Result { unreachable!() } fn sign_gossip_message(&self, _msg: UnsignedGossipMessage) -> Result { unreachable!() } + + fn sign_message(&self, msg: &[u8]) -> Result { Ok(crate::util::message_signing::sign(msg, &self.node_secret)) } } let logger = test_utils::TestLogger::with_id("".to_owned()); @@ -1944,6 +1946,7 @@ fn test_trampoline_inbound_payment_decoding() { &self, _invoice: &UnsignedBolt12Invoice, ) -> Result { unreachable!() } fn sign_gossip_message(&self, _msg: UnsignedGossipMessage) -> Result { unreachable!() } + fn sign_message(&self, msg: &[u8]) -> Result { Ok(crate::util::message_signing::sign(msg, &self.node_secret)) } } let logger = test_utils::TestLogger::with_id("".to_owned()); diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index a1ba428ded6..057a7406a5a 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -930,6 +930,17 @@ pub trait NodeSigner { /// message to be broadcast, as otherwise it may prevent one from receiving funds over the /// corresponding channel. fn sign_gossip_message(&self, msg: UnsignedGossipMessage) -> Result; + + /// Sign an arbitrary message with the node's secret key. + /// + /// Creates a digital signature of a message given the node's secret. The message is prefixed + /// with "Lightning Signed Message:" before signing. See [this description of the format](https://web.archive.org/web/20191010011846/https://twitter.com/rusty_twit/status/1182102005914800128) + /// for more details. + /// + /// A receiver knowing the node's id and the message can be sure that the signature was generated by the caller. + /// An `Err` can be returned to signal that the signer is unavailable / cannot produce a valid + /// signature. + fn sign_message(&self, msg: &[u8]) -> Result; } /// A trait that describes a wallet capable of creating a spending [`Transaction`] from a set of @@ -2209,6 +2220,10 @@ impl NodeSigner for KeysManager { let msg_hash = hash_to_message!(&Sha256dHash::hash(&msg.encode()[..])[..]); Ok(self.secp_ctx.sign_ecdsa(&msg_hash, &self.node_secret)) } + + fn sign_message(&self, msg: &[u8]) -> Result { + Ok(crate::util::message_signing::sign(msg, &self.node_secret)) + } } impl OutputSpender for KeysManager { @@ -2374,6 +2389,10 @@ impl NodeSigner for PhantomKeysManager { fn sign_gossip_message(&self, msg: UnsignedGossipMessage) -> Result { self.inner.sign_gossip_message(msg) } + + fn sign_message(&self, msg: &[u8]) -> Result { + self.inner.sign_message(msg) + } } impl OutputSpender for PhantomKeysManager { diff --git a/lightning/src/util/dyn_signer.rs b/lightning/src/util/dyn_signer.rs index ea062de5c76..fc2f6329aeb 100644 --- a/lightning/src/util/dyn_signer.rs +++ b/lightning/src/util/dyn_signer.rs @@ -211,6 +211,7 @@ delegate!(DynKeysInterface, NodeSigner, inner, fn get_node_id(, recipient: Recipient) -> Result, fn sign_gossip_message(, msg: UnsignedGossipMessage) -> Result, + fn sign_message(, msg: &[u8]) -> Result, fn ecdh(, recipient: Recipient, other_key: &PublicKey, tweak: Option<&Scalar>) -> Result, fn sign_invoice(, invoice: &RawBolt11Invoice, recipient: Recipient) -> Result, fn sign_bolt12_invoice(, @@ -278,6 +279,7 @@ delegate!(DynPhantomKeysInterface, NodeSigner, inner, fn get_node_id(, recipient: Recipient) -> Result, fn sign_gossip_message(, msg: UnsignedGossipMessage) -> Result, + fn sign_message(, msg: &[u8]) -> Result, fn ecdh(, recipient: Recipient, other_key: &PublicKey, tweak: Option<&Scalar>) -> Result, fn sign_invoice(, invoice: &RawBolt11Invoice, recipient: Recipient) -> Result, fn sign_bolt12_invoice(, invoice: &crate::offers::invoice::UnsignedBolt12Invoice diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 4e8af1157ec..e5b02b34824 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -1569,6 +1569,10 @@ impl NodeSigner for TestNodeSigner { fn sign_gossip_message(&self, _msg: msgs::UnsignedGossipMessage) -> Result { unreachable!() } + + fn sign_message(&self, msg: &[u8]) -> Result { + Ok(crate::util::message_signing::sign(msg, &self.node_secret)) + } } pub struct TestKeysInterface { @@ -1631,6 +1635,10 @@ impl NodeSigner for TestKeysInterface { fn sign_gossip_message(&self, msg: msgs::UnsignedGossipMessage) -> Result { self.backing.sign_gossip_message(msg) } + + fn sign_message(&self, msg: &[u8]) -> Result { + self.backing.sign_message(msg) + } } impl SignerProvider for TestKeysInterface { From c0b88342f13bcc936d4b2b62a2e81b715de2770a Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 20 Mar 2025 17:56:31 -0300 Subject: [PATCH 04/12] Add custom URL parser for LSPS5. Allows validating webhook URLs without depending on the external url crate. --- lightning-liquidity/src/lsps5/url_utils.rs | 227 +++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 lightning-liquidity/src/lsps5/url_utils.rs diff --git a/lightning-liquidity/src/lsps5/url_utils.rs b/lightning-liquidity/src/lsps5/url_utils.rs new file mode 100644 index 00000000000..58a8bbe371d --- /dev/null +++ b/lightning-liquidity/src/lsps5/url_utils.rs @@ -0,0 +1,227 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! URL utilities for LSPS5 webhook notifications. + +use super::msgs::LSPS5ProtocolError; + +use lightning_types::string::UntrustedString; + +use alloc::string::String; + +/// Represents a parsed URL for LSPS5 webhook notifications. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LSPSUrl { + url: UntrustedString, +} + +impl LSPSUrl { + /// Parses a URL string into a URL instance. + /// + /// # Arguments + /// * `url_str` - The URL string to parse + /// + /// # Returns + /// A Result containing either the parsed URL or an error message. + pub fn parse(url_str: String) -> Result { + if url_str.chars().any(|c| !Self::is_valid_url_char(c)) { + return Err(LSPS5ProtocolError::UrlParse); + } + + let (scheme, remainder) = + url_str.split_once("://").ok_or_else(|| (LSPS5ProtocolError::UrlParse))?; + + if !scheme.eq_ignore_ascii_case("https") { + return Err(LSPS5ProtocolError::UnsupportedProtocol); + } + + let host_section = remainder + .split(['/', '?', '#']) + .next() + .ok_or_else(|| (LSPS5ProtocolError::UrlParse))?; + + let host_without_auth = host_section + .split('@') + .next_back() + .filter(|s| !s.is_empty()) + .ok_or_else(|| (LSPS5ProtocolError::UrlParse))?; + + if host_without_auth.is_empty() + || host_without_auth.chars().any(|c| !Self::is_valid_host_char(c)) + { + return Err(LSPS5ProtocolError::UrlParse); + } + + match host_without_auth.rsplit_once(':') { + Some((hostname, _)) if hostname.is_empty() => return Err(LSPS5ProtocolError::UrlParse), + Some((_, port)) => { + if !port.is_empty() && port.parse::().is_err() { + return Err(LSPS5ProtocolError::UrlParse); + } + }, + None => {}, + }; + + Ok(LSPSUrl { url: UntrustedString(url_str) }) + } + + /// Returns URL length. + pub fn url_length(&self) -> usize { + self.url.0.chars().count() + } + + /// Returns the full URL string. + pub fn url(&self) -> &str { + self.url.0.as_str() + } + + fn is_valid_url_char(c: char) -> bool { + c.is_ascii_alphanumeric() + || matches!(c, ':' | '/' | '.' | '@' | '?' | '#' | '%' | '-' | '_' | '&' | '=') + } + + fn is_valid_host_char(c: char) -> bool { + c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':' | '_') + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::alloc::string::ToString; + use alloc::vec::Vec; + use proptest::prelude::*; + + #[test] + fn test_extremely_long_url() { + let url_str = format!("https://{}/path", "a".repeat(1000)).to_string(); + let url_chars = url_str.chars().count(); + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + let url = result.unwrap(); + assert_eq!(url.url.0.chars().count(), url_chars); + } + + #[test] + fn test_parse_http_url() { + let url_str = "http://example.com/path".to_string(); + let url = LSPSUrl::parse(url_str).unwrap_err(); + assert_eq!(url, LSPS5ProtocolError::UnsupportedProtocol); + } + + #[test] + fn valid_lsps_url() { + let test_vec: Vec<&'static str> = vec![ + "https://www.example.org/push?l=1234567890abcopqrstuv&c=best", + "https://www.example.com/path", + "https://example.org", + "https://example.com:8080/path", + "https://api.example.com/v1/resources", + "https://example.com/page#section1", + "https://example.com/search?q=test#results", + "https://user:pass@example.com/", + "https://192.168.1.1/admin", + "https://example.com://path", + "https://example.com/path%20with%20spaces", + "https://example_example.com/path?query=with&spaces=true", + ]; + for url_str in test_vec { + let url = LSPSUrl::parse(url_str.to_string()); + assert!(url.is_ok(), "Failed to parse URL: {}", url_str); + } + } + + #[test] + fn invalid_lsps_url() { + let test_vec = vec![ + "ftp://ftp.example.org/pub/files/document.pdf", + "sftp://user:password@sftp.example.com:22/uploads/", + "ssh://username@host.com:2222", + "lightning://03a.example.com/invoice?amount=10000", + "ftp://user@ftp.example.com/files/", + "https://例子.测试/path", + "a123+-.://example.com", + "a123+-.://example.com", + "https:\\\\example.com\\path", + "https:///whatever", + "https://example.com/path with spaces", + ]; + for url_str in test_vec { + let url = LSPSUrl::parse(url_str.to_string()); + assert!(url.is_err(), "Expected error for URL: {}", url_str); + } + } + + #[test] + fn parsing_errors() { + let test_vec = vec![ + "example.com/path", + "https://bad domain.com/", + "https://example.com\0/path", + "https://", + "ht@ps://example.com", + "http!://example.com", + "1https://example.com", + "https://://example.com", + "https://example.com:port/path", + "https://:8080/path", + "https:", + "://", + "https://example.com\0/path", + ]; + for url_str in test_vec { + let url = LSPSUrl::parse(url_str.to_string()); + assert!(url.is_err(), "Expected error for URL: {}", url_str); + } + } + + fn host_strategy() -> impl Strategy { + prop_oneof![ + proptest::string::string_regex( + "[a-z0-9]+(?:-[a-z0-9]+)*(?:\\.[a-z0-9]+(?:-[a-z0-9]+)*)*" + ) + .unwrap(), + (0u8..=255u8, 0u8..=255u8, 0u8..=255u8, 0u8..=255u8) + .prop_map(|(a, b, c, d)| format!("{}.{}.{}.{}", a, b, c, d)) + ] + } + + proptest! { + #[test] + fn proptest_parse_round_trip( + host in host_strategy(), + port in proptest::option::of(0u16..=65535u16), + path in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap()), + query in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap()), + fragment in proptest::option::of(proptest::string::string_regex("[a-zA-Z0-9._%&=:@/-]{0,20}").unwrap()) + ) { + let mut url = format!("https://{}", host); + if let Some(p) = port { + url.push_str(&format!(":{}", p)); + } + if let Some(pth) = &path { + url.push('/'); + url.push_str(pth); + } + if let Some(q) = &query { + url.push('?'); + url.push_str(q); + } + if let Some(f) = &fragment { + url.push('#'); + url.push_str(f); + } + + let parsed = LSPSUrl::parse(url.clone()).expect("should parse"); + prop_assert_eq!(parsed.url(), url.as_str()); + prop_assert_eq!(parsed.url_length(), url.chars().count()); + } + } +} From 82ae71057f6fec9f43c4b36fd11d3d928a45724a Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Tue, 11 Mar 2025 16:56:33 -0300 Subject: [PATCH 05/12] Add LSPS5 message formats for webhook registration Define LSPSMessage request, response and error types. Also introduce LSPS5AppName, LSPS5WebhookUrl, WebhookNotification types --- lightning-liquidity/src/lsps5/msgs.rs | 943 ++++++++++++++++++++++++++ 1 file changed, 943 insertions(+) create mode 100644 lightning-liquidity/src/lsps5/msgs.rs diff --git a/lightning-liquidity/src/lsps5/msgs.rs b/lightning-liquidity/src/lsps5/msgs.rs new file mode 100644 index 00000000000..6d3743a9a5a --- /dev/null +++ b/lightning-liquidity/src/lsps5/msgs.rs @@ -0,0 +1,943 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! LSPS5 message formats for webhook registration + +use crate::alloc::string::ToString; +use crate::lsps0::ser::LSPSMessage; +use crate::lsps0::ser::LSPSRequestId; +use crate::lsps0::ser::LSPSResponseError; + +use super::url_utils::LSPSUrl; + +use lightning_types::string::UntrustedString; + +use serde::de::{self, Deserializer, MapAccess, Visitor}; +use serde::ser::SerializeMap; +use serde::ser::SerializeStruct; +use serde::Serializer; +use serde::{Deserialize, Serialize}; + +use alloc::string::String; +use alloc::vec::Vec; + +use core::fmt; +use core::fmt::Display; +use core::ops::Deref; + +/// Maximum allowed length for an `app_name` (in bytes). +pub const MAX_APP_NAME_LENGTH: usize = 64; + +/// Maximum allowed length for a webhook URL (in characters). +pub const MAX_WEBHOOK_URL_LENGTH: usize = 1024; + +/// Either the app name or the webhook URL is too long. +pub const LSPS5_TOO_LONG_ERROR_CODE: i32 = 500; +/// The provided URL could not be parsed. +pub const LSPS5_URL_PARSE_ERROR_CODE: i32 = 501; +/// The provided URL is not HTTPS. +pub const LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE: i32 = 502; +/// The client has too many webhooks registered. +pub const LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE: i32 = 503; +/// The app name was not found. +pub const LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE: i32 = 1010; +/// An unknown error occurred. +pub const LSPS5_UNKNOWN_ERROR_CODE: i32 = 1000; +/// An error occurred during serialization of LSPS5 webhook notification. +pub const LSPS5_SERIALIZATION_ERROR_CODE: i32 = 1001; + +pub(crate) const LSPS5_SET_WEBHOOK_METHOD_NAME: &str = "lsps5.set_webhook"; +pub(crate) const LSPS5_LIST_WEBHOOKS_METHOD_NAME: &str = "lsps5.list_webhooks"; +pub(crate) const LSPS5_REMOVE_WEBHOOK_METHOD_NAME: &str = "lsps5.remove_webhook"; + +pub(crate) const LSPS5_WEBHOOK_REGISTERED_NOTIFICATION: &str = "lsps5.webhook_registered"; +pub(crate) const LSPS5_PAYMENT_INCOMING_NOTIFICATION: &str = "lsps5.payment_incoming"; +pub(crate) const LSPS5_EXPIRY_SOON_NOTIFICATION: &str = "lsps5.expiry_soon"; +pub(crate) const LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_NOTIFICATION: &str = + "lsps5.liquidity_management_request"; +pub(crate) const LSPS5_ONION_MESSAGE_INCOMING_NOTIFICATION: &str = "lsps5.onion_message_incoming"; + +/// Protocol errors defined in the LSPS5/bLIP-55 specification. +/// +/// These errors are sent over JSON-RPC when protocol-level validation fails +/// and correspond directly to error codes defined in the LSPS5 specification. +/// LSPs must use these errors when rejecting client requests. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub enum LSPS5ProtocolError { + /// App name exceeds the maximum allowed length of 64 bytes. + /// + /// Sent when registering a webhook with an app name longer than MAX_APP_NAME_LENGTH. + AppNameTooLong, + + /// Webhook URL exceeds the maximum allowed length of 1024 bytes. + /// + /// Sent when registering a webhook with a URL longer than MAX_WEBHOOK_URL_LENGTH. + WebhookUrlTooLong, + + /// Webhook URL is not a valid URL. + /// + /// Sent when the provided webhook URL cannot be parsed or is syntactically invalid. + UrlParse, + + /// Webhook URL does not use HTTPS. + /// + /// The LSPS5 specification requires all webhook URLs to use HTTPS. + UnsupportedProtocol, + + /// Client has reached their maximum allowed number of webhooks. + TooManyWebhooks, + + /// The specified app name was not found in the registered webhooks. + /// + /// Sent when trying to update or remove a webhook that doesn't exist. + AppNameNotFound, + + /// An unspecified or unexpected error occurred. + UnknownError, + + /// Error during serialization of LSPS5 webhook notification. + SerializationError, +} + +impl LSPS5ProtocolError { + /// private code range so we never collide with the spec's codes + pub fn code(&self) -> i32 { + match self { + LSPS5ProtocolError::AppNameTooLong | LSPS5ProtocolError::WebhookUrlTooLong => { + LSPS5_TOO_LONG_ERROR_CODE + }, + LSPS5ProtocolError::UrlParse => LSPS5_URL_PARSE_ERROR_CODE, + LSPS5ProtocolError::UnsupportedProtocol => LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE, + LSPS5ProtocolError::TooManyWebhooks => LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE, + LSPS5ProtocolError::AppNameNotFound => LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE, + LSPS5ProtocolError::UnknownError => LSPS5_UNKNOWN_ERROR_CODE, + LSPS5ProtocolError::SerializationError => LSPS5_SERIALIZATION_ERROR_CODE, + } + } + /// The error message for the LSPS5 protocol error. + pub fn message(&self) -> &'static str { + match self { + LSPS5ProtocolError::AppNameTooLong => "App name exceeds maximum length", + LSPS5ProtocolError::WebhookUrlTooLong => "Webhook URL exceeds maximum length", + LSPS5ProtocolError::UrlParse => "Error parsing URL", + LSPS5ProtocolError::UnsupportedProtocol => "Unsupported protocol: HTTPS is required", + LSPS5ProtocolError::TooManyWebhooks => "Maximum number of webhooks allowed per client", + LSPS5ProtocolError::AppNameNotFound => "App name not found", + LSPS5ProtocolError::UnknownError => "Unknown error", + LSPS5ProtocolError::SerializationError => { + "Error serializing LSPS5 webhook notification" + }, + } + } +} + +impl Serialize for LSPS5ProtocolError { + fn serialize(&self, ser: S) -> Result + where + S: Serializer, + { + let mut s = ser.serialize_struct("error", 2)?; + s.serialize_field("code", &self.code())?; + s.serialize_field("message", &self.message())?; + s.end() + } +} + +/// Client-side validation and processing errors. +/// +/// Unlike LSPS5ProtocolError, these errors are not part of the LSPS5 specification +/// and are meant for internal use in the client implementation. They represent +/// failures when parsing, validating, or processing webhook notifications. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LSPS5ClientError { + /// Signature verification failed. + /// + /// The cryptographic signature from the LSP node doesn't validate. + InvalidSignature, + + /// Notification timestamp is too old or too far in the future. + /// + /// LSPS5 requires timestamps to be within ±10 minutes of current time. + InvalidTimestamp, + + /// Detected a reused notification signature. + /// + /// Indicates a potential replay attack where a previously seen + /// notification signature was reused. + ReplayAttack, + + /// Error during serialization of LSPS5 webhook notification. + SerializationError, +} + +impl LSPS5ClientError { + const BASE: i32 = 100_000; + /// The error code for the client error. + pub fn code(&self) -> i32 { + use LSPS5ClientError::*; + match self { + InvalidSignature => Self::BASE + 1, + InvalidTimestamp => Self::BASE + 2, + ReplayAttack => Self::BASE + 3, + SerializationError => LSPS5_SERIALIZATION_ERROR_CODE, + } + } + /// The error message for the client error. + pub fn message(&self) -> &'static str { + use LSPS5ClientError::*; + match self { + InvalidSignature => "Invalid signature", + InvalidTimestamp => "Timestamp out of range", + ReplayAttack => "Replay attack detected", + SerializationError => "Error serializing LSPS5 webhook notification", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// Combined error type for LSPS5 client and protocol errors. +/// +/// This enum wraps both specification-defined protocol errors and +/// client-side processing errors into a single error type for use +/// throughout the LSPS5 implementation. +pub enum LSPS5Error { + /// An error defined in the LSPS5 specification. + /// + /// This represents errors that are part of the formal protocol. + Protocol(LSPS5ProtocolError), + + /// A client-side processing error. + /// + /// This represents errors that occur during client-side handling + /// of notifications or other validation. + Client(LSPS5ClientError), +} + +impl From for LSPS5Error { + fn from(e: LSPS5ProtocolError) -> Self { + LSPS5Error::Protocol(e) + } +} +impl From for LSPS5Error { + fn from(e: LSPS5ClientError) -> Self { + LSPS5Error::Client(e) + } +} + +impl From for LSPS5Error { + fn from(err: LSPSResponseError) -> Self { + LSPS5ProtocolError::from(err).into() + } +} + +impl From for LSPS5ProtocolError { + fn from(err: LSPSResponseError) -> Self { + match err.code { + LSPS5_TOO_LONG_ERROR_CODE => LSPS5ProtocolError::AppNameTooLong, + LSPS5_URL_PARSE_ERROR_CODE => LSPS5ProtocolError::UrlParse, + LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE => LSPS5ProtocolError::UnsupportedProtocol, + LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE => LSPS5ProtocolError::TooManyWebhooks, + LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE => LSPS5ProtocolError::AppNameNotFound, + _ => LSPS5ProtocolError::UnknownError, + } + } +} + +impl From for LSPSResponseError { + fn from(e: LSPS5ProtocolError) -> Self { + LSPSResponseError { code: e.code(), message: e.message().into(), data: None } + } +} + +impl From for LSPSResponseError { + fn from(e: LSPS5Error) -> Self { + match e { + LSPS5Error::Protocol(p) => p.into(), + LSPS5Error::Client(c) => { + LSPSResponseError { code: c.code(), message: c.message().into(), data: None } + }, + } + } +} + +/// App name for LSPS5 webhooks. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LSPS5AppName(UntrustedString); + +impl LSPS5AppName { + /// Create a new LSPS5 app name. + pub fn new(app_name: String) -> Result { + if app_name.len() > MAX_APP_NAME_LENGTH { + return Err(LSPS5ProtocolError::AppNameTooLong.into()); + } + Ok(Self(UntrustedString(app_name))) + } + + /// Create a new LSPS5 app name from a regular String. + pub fn from_string(app_name: String) -> Result { + Self::new(app_name) + } + + /// Get the app name as a string. + pub fn as_str(&self) -> &str { + self + } +} + +impl Deref for LSPS5AppName { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 .0 + } +} + +impl Display for LSPS5AppName { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self) + } +} + +impl Serialize for LSPS5AppName { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self) + } +} + +impl<'de> Deserialize<'de> for LSPS5AppName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + if s.len() > MAX_APP_NAME_LENGTH { + return Err(serde::de::Error::custom("App name exceeds maximum length")); + } + Self::new(s).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) + } +} + +impl AsRef for LSPS5AppName { + fn as_ref(&self) -> &str { + self + } +} + +impl From for String { + fn from(app_name: LSPS5AppName) -> Self { + app_name.to_string() + } +} + +/// URL for LSPS5 webhooks. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LSPS5WebhookUrl(LSPSUrl); + +impl LSPS5WebhookUrl { + /// Create a new LSPS5 webhook URL. + pub fn new(url: String) -> Result { + if url.len() > MAX_WEBHOOK_URL_LENGTH { + return Err(LSPS5ProtocolError::WebhookUrlTooLong.into()); + } + let parsed_url = LSPSUrl::parse(url)?; + + Ok(Self(parsed_url)) + } + + /// Create a new LSPS5 webhook URL from a regular String. + pub fn from_string(url: String) -> Result { + Self::new(url) + } + + /// Get the webhook URL as a string. + pub fn as_str(&self) -> &str { + self + } +} + +impl Deref for LSPS5WebhookUrl { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0.url() + } +} + +impl Display for LSPS5WebhookUrl { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self) // Using Deref + } +} + +impl Serialize for LSPS5WebhookUrl { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self) + } +} + +impl<'de> Deserialize<'de> for LSPS5WebhookUrl { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + if s.len() > MAX_WEBHOOK_URL_LENGTH { + return Err(serde::de::Error::custom("Webhook URL exceeds maximum length")); + } + Self::new(s).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) + } +} + +impl AsRef for LSPS5WebhookUrl { + fn as_ref(&self) -> &str { + self + } +} + +impl From for String { + fn from(url: LSPS5WebhookUrl) -> Self { + url.to_string() + } +} + +/// Parameters for `lsps5.set_webhook` request. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SetWebhookRequest { + /// Human-readable name for the webhook. + pub app_name: LSPS5AppName, + /// URL of the webhook. + pub webhook: LSPS5WebhookUrl, +} + +/// Response for `lsps5.set_webhook`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SetWebhookResponse { + /// Current number of webhooks registered for this client. + pub num_webhooks: u32, + /// Maximum number of webhooks allowed by LSP. + pub max_webhooks: u32, + /// Whether this is an unchanged registration. + pub no_change: bool, +} + +/// Parameters for `lsps5.list_webhooks` request. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct ListWebhooksRequest {} + +/// Response for `lsps5.list_webhooks`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ListWebhooksResponse { + /// List of app_names with registered webhooks. + pub app_names: Vec, + /// Maximum number of webhooks allowed by LSP. + pub max_webhooks: u32, +} + +/// Parameters for `lsps5.remove_webhook` request. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RemoveWebhookRequest { + /// App name identifying the webhook to remove. + pub app_name: LSPS5AppName, +} + +/// Response for `lsps5.remove_webhook`. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct RemoveWebhookResponse {} + +/// Webhook notification methods defined in LSPS5. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum WebhookNotificationMethod { + /// Webhook has been successfully registered. + LSPS5WebhookRegistered, + /// Client has payments pending to be received. + LSPS5PaymentIncoming, + /// HTLC or time-bound contract is about to expire. + LSPS5ExpirySoon { + /// Block height when timeout occurs and the LSP would be forced to close the channel + timeout: u32, + }, + /// LSP wants to take back some liquidity. + LSPS5LiquidityManagementRequest, + /// Client has onion messages pending. + LSPS5OnionMessageIncoming, +} + +/// Webhook notification payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebhookNotification { + /// Notification method with parameters. + pub method: WebhookNotificationMethod, +} + +impl WebhookNotification { + /// Create a new webhook notification. + pub fn new(method: WebhookNotificationMethod) -> Self { + Self { method } + } + + /// Create a webhook_registered notification. + pub fn webhook_registered() -> Self { + Self::new(WebhookNotificationMethod::LSPS5WebhookRegistered) + } + + /// Create a payment_incoming notification. + pub fn payment_incoming() -> Self { + Self::new(WebhookNotificationMethod::LSPS5PaymentIncoming) + } + + /// Create an expiry_soon notification. + pub fn expiry_soon(timeout: u32) -> Self { + Self::new(WebhookNotificationMethod::LSPS5ExpirySoon { timeout }) + } + + /// Create a liquidity_management_request notification. + pub fn liquidity_management_request() -> Self { + Self::new(WebhookNotificationMethod::LSPS5LiquidityManagementRequest) + } + + /// Create an onion_message_incoming notification. + pub fn onion_message_incoming() -> Self { + Self::new(WebhookNotificationMethod::LSPS5OnionMessageIncoming) + } +} + +impl Serialize for WebhookNotification { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(Some(3))?; + map.serialize_entry("jsonrpc", "2.0")?; + + let method_name = match &self.method { + WebhookNotificationMethod::LSPS5WebhookRegistered => { + LSPS5_WEBHOOK_REGISTERED_NOTIFICATION + }, + WebhookNotificationMethod::LSPS5PaymentIncoming => LSPS5_PAYMENT_INCOMING_NOTIFICATION, + WebhookNotificationMethod::LSPS5ExpirySoon { .. } => LSPS5_EXPIRY_SOON_NOTIFICATION, + WebhookNotificationMethod::LSPS5LiquidityManagementRequest => { + LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_NOTIFICATION + }, + WebhookNotificationMethod::LSPS5OnionMessageIncoming => { + LSPS5_ONION_MESSAGE_INCOMING_NOTIFICATION + }, + }; + map.serialize_entry("method", &method_name)?; + + let params = match &self.method { + WebhookNotificationMethod::LSPS5WebhookRegistered => serde_json::json!({}), + WebhookNotificationMethod::LSPS5PaymentIncoming => serde_json::json!({}), + WebhookNotificationMethod::LSPS5ExpirySoon { timeout } => { + serde_json::json!({ "timeout": timeout }) + }, + WebhookNotificationMethod::LSPS5LiquidityManagementRequest => serde_json::json!({}), + WebhookNotificationMethod::LSPS5OnionMessageIncoming => serde_json::json!({}), + }; + map.serialize_entry("params", ¶ms)?; + + map.end() + } +} + +impl<'de> Deserialize<'de> for WebhookNotification { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct WebhookNotificationVisitor; + + impl<'de> Visitor<'de> for WebhookNotificationVisitor { + type Value = WebhookNotification; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid LSPS5 WebhookNotification object") + } + + fn visit_map(self, mut map: V) -> Result + where + V: MapAccess<'de>, + { + let mut jsonrpc: Option = None; + let mut method: Option = None; + let mut params: Option = None; + + while let Some(key) = map.next_key::<&str>()? { + match key { + "jsonrpc" => jsonrpc = Some(map.next_value()?), + "method" => method = Some(map.next_value()?), + "params" => params = Some(map.next_value()?), + _ => { + let _: serde::de::IgnoredAny = map.next_value()?; + }, + } + } + + let jsonrpc = jsonrpc.ok_or_else(|| de::Error::missing_field("jsonrpc"))?; + if jsonrpc != "2.0" { + return Err(de::Error::custom("Invalid jsonrpc version")); + } + let method = method.ok_or_else(|| de::Error::missing_field("method"))?; + let params = params.ok_or_else(|| de::Error::missing_field("params"))?; + + let method = match method.as_str() { + LSPS5_WEBHOOK_REGISTERED_NOTIFICATION => { + WebhookNotificationMethod::LSPS5WebhookRegistered + }, + LSPS5_PAYMENT_INCOMING_NOTIFICATION => { + WebhookNotificationMethod::LSPS5PaymentIncoming + }, + LSPS5_EXPIRY_SOON_NOTIFICATION => { + if let Some(timeout) = params.get("timeout").and_then(|t| t.as_u64()) { + WebhookNotificationMethod::LSPS5ExpirySoon { timeout: timeout as u32 } + } else { + return Err(de::Error::custom( + "Missing or invalid timeout parameter for expiry_soon notification", + )); + } + }, + LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_NOTIFICATION => { + WebhookNotificationMethod::LSPS5LiquidityManagementRequest + }, + LSPS5_ONION_MESSAGE_INCOMING_NOTIFICATION => { + WebhookNotificationMethod::LSPS5OnionMessageIncoming + }, + _ => return Err(de::Error::custom(format!("Unknown method: {}", method))), + }; + + Ok(WebhookNotification { method }) + } + } + + deserializer.deserialize_map(WebhookNotificationVisitor) + } +} + +/// An LSPS5 protocol request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LSPS5Request { + /// Register or update a webhook. + SetWebhook(SetWebhookRequest), + /// List all registered webhooks. + ListWebhooks(ListWebhooksRequest), + /// Remove a webhook. + RemoveWebhook(RemoveWebhookRequest), +} + +/// An LSPS5 protocol response. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LSPS5Response { + /// Response to [`SetWebhook`](SetWebhookRequest) request. + SetWebhook(SetWebhookResponse), + /// Error response to [`SetWebhook`](SetWebhookRequest) request. + SetWebhookError(LSPSResponseError), + /// Response to [`ListWebhooks`](ListWebhooksRequest) request. + ListWebhooks(ListWebhooksResponse), + /// Response to [`RemoveWebhook`](RemoveWebhookRequest) request. + RemoveWebhook(RemoveWebhookResponse), + /// Error response to [`RemoveWebhook`](RemoveWebhookRequest) request. + RemoveWebhookError(LSPSResponseError), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +/// An LSPS5 protocol message. +pub enum LSPS5Message { + /// A request variant. + Request(LSPSRequestId, LSPS5Request), + /// A response variant. + Response(LSPSRequestId, LSPS5Response), +} + +impl TryFrom for LSPS5Message { + type Error = (); + + fn try_from(message: LSPSMessage) -> Result { + match message { + LSPSMessage::LSPS5(message) => Ok(message), + _ => Err(()), + } + } +} + +impl From for LSPSMessage { + fn from(message: LSPS5Message) -> Self { + LSPSMessage::LSPS5(message) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::alloc::string::ToString; + + #[test] + fn webhook_notification_serialization() { + let notification = WebhookNotification::webhook_registered(); + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.webhook_registered","params":{}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + + let notification = WebhookNotification::expiry_soon(144); + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.expiry_soon","params":{"timeout":144}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + } + + #[test] + fn parse_set_webhook_request() { + let json_str = r#"{"app_name":"my_app","webhook":"https://example.com/webhook"}"#; + let request: SetWebhookRequest = serde_json::from_str(json_str).unwrap(); + assert_eq!(request.app_name, LSPS5AppName::new("my_app".to_string()).unwrap()); + assert_eq!( + request.webhook, + LSPS5WebhookUrl::new("https://example.com/webhook".to_string()).unwrap() + ); + } + + #[test] + fn parse_set_webhook_response() { + let json_str = r#"{"num_webhooks":1,"max_webhooks":5,"no_change":false}"#; + let response: SetWebhookResponse = serde_json::from_str(json_str).unwrap(); + assert_eq!(response.num_webhooks, 1); + assert_eq!(response.max_webhooks, 5); + assert_eq!(response.no_change, false); + } + + #[test] + fn parse_list_webhooks_response() { + let json_str = r#"{"app_names":["app1","app2"],"max_webhooks":5}"#; + let response: ListWebhooksResponse = serde_json::from_str(json_str).unwrap(); + let app1 = LSPS5AppName::new("app1".to_string()).unwrap(); + let app2 = LSPS5AppName::new("app2".to_string()).unwrap(); + assert_eq!(response.app_names, vec![app1, app2]); + assert_eq!(response.max_webhooks, 5); + } + + #[test] + fn parse_empty_requests_responses() { + let json_str = r#"{}"#; + let _list_req: ListWebhooksRequest = serde_json::from_str(json_str).unwrap(); + let _remove_resp: RemoveWebhookResponse = serde_json::from_str(json_str).unwrap(); + } + + #[test] + fn spec_example_set_webhook_request() { + let json_str = r#"{"app_name":"My LSPS-Compliant Lightning Client","webhook":"https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best"}"#; + let request: SetWebhookRequest = serde_json::from_str(json_str).unwrap(); + assert_eq!( + request.app_name, + LSPS5AppName::new("My LSPS-Compliant Lightning Client".to_string()).unwrap() + ); + assert_eq!( + request.webhook, + LSPS5WebhookUrl::new( + "https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best" + .to_string() + ) + .unwrap() + ); + } + + #[test] + fn spec_example_set_webhook_response() { + let json_str = r#"{"num_webhooks":2,"max_webhooks":4,"no_change":false}"#; + let response: SetWebhookResponse = serde_json::from_str(json_str).unwrap(); + assert_eq!(response.num_webhooks, 2); + assert_eq!(response.max_webhooks, 4); + assert_eq!(response.no_change, false); + } + + #[test] + fn spec_example_list_webhooks_response() { + let json_str = r#"{"app_names":["My LSPS-Compliant Lightning Wallet","Another Wallet With The Same Signing Device"],"max_webhooks":42}"#; + let response: ListWebhooksResponse = serde_json::from_str(json_str).unwrap(); + let app1 = LSPS5AppName::new("My LSPS-Compliant Lightning Wallet".to_string()).unwrap(); + let app2 = + LSPS5AppName::new("Another Wallet With The Same Signing Device".to_string()).unwrap(); + assert_eq!(response.app_names, vec![app1, app2]); + assert_eq!(response.max_webhooks, 42); + } + + #[test] + fn spec_example_remove_webhook_request() { + let json_str = r#"{"app_name":"Another Wallet With The Same Signig Device"}"#; + let request: RemoveWebhookRequest = serde_json::from_str(json_str).unwrap(); + assert_eq!( + request.app_name, + LSPS5AppName::new("Another Wallet With The Same Signig Device".to_string()).unwrap() + ); + } + + #[test] + fn spec_example_webhook_notifications() { + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.webhook_registered","params":{}}"#; + let notification: WebhookNotification = serde_json::from_str(json_str).unwrap(); + assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered); + + let notification = WebhookNotification::payment_incoming(); + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.payment_incoming","params":{}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + + let notification = WebhookNotification::expiry_soon(144); + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.expiry_soon","params":{"timeout":144}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + + let notification = WebhookNotification::liquidity_management_request(); + let json_str = + r#"{"jsonrpc":"2.0","method":"lsps5.liquidity_management_request","params":{}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + + let notification = WebhookNotification::onion_message_incoming(); + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.onion_message_incoming","params":{}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + } + + #[test] + fn test_url_security_validation() { + let urls_that_should_throw = [ + "test-app", + "http://example.com/webhook", + "ftp://example.com/webhook", + "ws://example.com/webhook", + "ws+unix://example.com/webhook", + "ws+unix:/example.com/webhook", + "ws+unix://example.com/webhook?param=value", + "ws+unix:/example.com/webhook?param=value", + ]; + + for url_str in urls_that_should_throw.iter() { + match LSPS5WebhookUrl::new(url_str.to_string()) { + Ok(_) => panic!("Expected error"), + Err(e) => { + let protocol_error = match e { + LSPS5Error::Protocol(err) => err, + _ => panic!("Expected protocol error"), + }; + let code = protocol_error.code(); + assert!( + code == LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE + || code == LSPS5_URL_PARSE_ERROR_CODE + ); + }, + } + } + } + + #[test] + fn test_webhook_notification_parameter_binding() { + let notification = WebhookNotification::expiry_soon(144); + if let WebhookNotificationMethod::LSPS5ExpirySoon { timeout } = notification.method { + assert_eq!(timeout, 144); + } else { + panic!("Expected LSPS5ExpirySoon variant"); + } + + let json = serde_json::to_string(¬ification).unwrap(); + assert_eq!( + json, + r#"{"jsonrpc":"2.0","method":"lsps5.expiry_soon","params":{"timeout":144}}"# + ); + let deserialized: WebhookNotification = serde_json::from_str(&json).unwrap(); + if let WebhookNotificationMethod::LSPS5ExpirySoon { timeout } = deserialized.method { + assert_eq!(timeout, 144); + } else { + panic!("Expected LSPS5ExpirySoon variant after deserialization"); + } + } + + #[test] + fn test_missing_parameter_error() { + let json_without_timeout = r#"{"jsonrpc":"2.0","method":"lsps5.expiry_soon","params":{}}"#; + + let result: Result = serde_json::from_str(json_without_timeout); + assert!(result.is_err(), "Should fail when timeout parameter is missing"); + + let err = result.unwrap_err().to_string(); + assert!( + err.contains("Missing or invalid timeout parameter"), + "Error should mention missing parameter: {}", + err + ); + } + + #[test] + fn test_notification_round_trip_all_types() { + let notifications = vec![ + WebhookNotification::webhook_registered(), + WebhookNotification::payment_incoming(), + WebhookNotification::expiry_soon(123), + WebhookNotification::liquidity_management_request(), + WebhookNotification::onion_message_incoming(), + ]; + + for original in notifications { + let json = serde_json::to_string(&original).unwrap(); + let deserialized: WebhookNotification = serde_json::from_str(&json).unwrap(); + + assert_eq!(original, deserialized); + + if let WebhookNotificationMethod::LSPS5ExpirySoon { timeout: original_timeout } = + original.method + { + if let WebhookNotificationMethod::LSPS5ExpirySoon { + timeout: deserialized_timeout, + } = deserialized.method + { + assert_eq!(original_timeout, deserialized_timeout); + } else { + panic!("Expected LSPS5ExpirySoon after deserialization"); + } + } + } + } + + #[test] + fn test_all_notification_methods_from_spec() { + let methods = [ + ("lsps5.webhook_registered", WebhookNotificationMethod::LSPS5WebhookRegistered, "{}"), + ("lsps5.payment_incoming", WebhookNotificationMethod::LSPS5PaymentIncoming, "{}"), + ( + "lsps5.expiry_soon", + WebhookNotificationMethod::LSPS5ExpirySoon { timeout: 144 }, + "{\"timeout\":144}", + ), + ( + "lsps5.liquidity_management_request", + WebhookNotificationMethod::LSPS5LiquidityManagementRequest, + "{}", + ), + ( + "lsps5.onion_message_incoming", + WebhookNotificationMethod::LSPS5OnionMessageIncoming, + "{}", + ), + ]; + + for (method_name, method_enum, params_json) in methods { + let json = format!( + r#"{{"jsonrpc":"2.0","method":"{}","params":{}}}"#, + method_name, params_json + ); + + let notification: WebhookNotification = serde_json::from_str(&json).unwrap(); + + assert_eq!(notification.method, method_enum); + + let serialized = serde_json::to_string(¬ification).unwrap(); + assert!(serialized.contains(&format!("\"method\":\"{}\"", method_name))); + + if method_name == "lsps5.expiry_soon" { + assert!(serialized.contains("\"timeout\":144")); + } + } + } +} From 229dfb0cc9f641e93d20516faf8be7cb8c0eee3d Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Tue, 11 Mar 2025 16:52:14 -0300 Subject: [PATCH 06/12] Add LSPS5 service and client events Introduce SendWebhookNotification event for LSPS5/service, and also WebhookRegistered, WebhookRegistrationFailed, WebhooksListed, WebhookRemoved and WebhookRemovalFailed events for LSPS5/client. --- lightning-liquidity/src/lsps5/event.rs | 213 +++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 lightning-liquidity/src/lsps5/event.rs diff --git a/lightning-liquidity/src/lsps5/event.rs b/lightning-liquidity/src/lsps5/event.rs new file mode 100644 index 00000000000..7730428e5ce --- /dev/null +++ b/lightning-liquidity/src/lsps5/event.rs @@ -0,0 +1,213 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Contains bLIP-55 / LSPS5 event types + +use crate::lsps0::ser::LSPSRequestId; +use alloc::string::String; +use alloc::vec::Vec; +use bitcoin::secp256k1::PublicKey; +use lightning::util::hash_tables::HashMap; + +use super::msgs::LSPS5AppName; +use super::msgs::LSPS5Error; +use super::msgs::LSPS5WebhookUrl; +use super::msgs::WebhookNotification; + +/// An event which an bLIP-55 / LSPS5 server should take some action in response to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LSPS5ServiceEvent { + /// A notification needs to be sent to a client. + /// + /// This event is triggered when the LSP needs to notify a client about an event + /// via their registered webhook. + /// + /// The LSP should send an HTTP POST to the [`url`], using the + /// JSON-serialized [`notification`] as the body and including the `headers`. + /// If the HTTP request fails, the LSP may implement a retry policy according to its + /// implementation preferences, but must respect rate-limiting as defined in + /// [`notification_cooldown_hours`]. + /// + /// The notification is signed using the LSP's node ID to ensure authenticity + /// when received by the client. The client verifies this signature using + /// [`validate`], which guards against replay attacks and tampering. + /// + /// [`validate`]: super::validator::LSPS5Validator::validate + /// [`notification_cooldown_hours`]: super::service::LSPS5ServiceConfig::notification_cooldown_hours + /// [`url`]: super::msgs::LSPS5WebhookUrl + /// [`notification`]: super::msgs::WebhookNotification + SendWebhookNotification { + /// Client node ID to be notified. + counterparty_node_id: PublicKey, + /// [`App name`] to be notified. + /// + /// This identifies which webhook registration should be notified. + /// + /// [`App name`]: super::msgs::LSPS5AppName + app_name: LSPS5AppName, + /// URL to be called. + /// + /// This is the [`webhook URL`] provided by the client during registration. + /// + /// [`webhook URL`]: super::msgs::LSPS5WebhookUrl + url: LSPS5WebhookUrl, + /// Notification method with its parameters. + /// + /// This contains the type of notification and any associated data to be sent to the client. + notification: WebhookNotification, + /// Headers to be included in the HTTP POST request. + /// + /// This is a map of HTTP header key-value pairs. It will include: + /// - `"Content-Type"`: with a value like `"application/json"`. + /// - `"x-lsps5-timestamp"`: with the timestamp in RFC3339 format (`"YYYY-MM-DDThh:mm:ss.uuuZ"`). + /// - `"x-lsps5-signature"`: with the signature of the notification payload, signed using the LSP's node ID. + /// Other custom headers may also be included as needed. + headers: HashMap, + }, +} + +/// An event which an LSPS5 client should take some action in response to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LSPS5ClientEvent { + /// A webhook was successfully registered with the LSP. + /// + /// This event is triggered when the LSP confirms successful registration + /// of a webhook via [`lsps5.set_webhook`]. + /// + /// If `no_change` is `false` (indicating the registered webhook is a new registration), + /// the LSP will also emit a [`SendWebhookNotification`] event with a [`webhook_registered`] notification + /// to notify the client about this registration. + /// + /// [`lsps5.set_webhook`]: super::msgs::LSPS5Request::SetWebhook + /// [`SendWebhookNotification`]: super::event::LSPS5ServiceEvent::SendWebhookNotification + /// [`webhook_registered`]: super::msgs::WebhookNotificationMethod::LSPS5WebhookRegistered + WebhookRegistered { + /// The node id of the LSP that confirmed the registration. + counterparty_node_id: PublicKey, + /// Current number of webhooks registered for this client. + num_webhooks: u32, + /// Maximum number of webhooks allowed by LSP. + max_webhooks: u32, + /// Whether this was an unchanged registration (same app_name and URL). + /// If true, the LSP didn't send a webhook notification for this registration. + no_change: bool, + /// The app name that was registered. + app_name: LSPS5AppName, + /// The webhook URL that was registered. + url: LSPS5WebhookUrl, + /// The identifier of the issued bLIP-55 / LSPS5 webhook registration request. + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// A webhook registration attempt failed. + /// + /// This event is triggered when the LSP rejects a webhook registration + /// via [`lsps5.set_webhook`]. + /// + /// Possible errors: + /// - The [`app_name`] exceeds [`MAX_APP_NAME_LENGTH`] (error [`AppNameTooLong`]). + /// - The [`url`] exceeds [`MAX_WEBHOOK_URL_LENGTH`] (error [`WebhookUrlTooLong`]). + /// - The [`url`] uses an unsupported protocol. HTTPS is required (error [`UnsupportedProtocol`]). + /// - Maximum number of webhooks per client has been reached (error [`TooManyWebhooks`]). Remove a webhook before + /// registering a new one. + /// + /// [`lsps5.set_webhook`]: super::msgs::LSPS5Request::SetWebhook + /// [`app_name`]: super::msgs::LSPS5AppName + /// [`url`]: super::msgs::LSPS5WebhookUrl + /// [`MAX_APP_NAME_LENGTH`]: super::msgs::MAX_APP_NAME_LENGTH + /// [`MAX_WEBHOOK_URL_LENGTH`]: super::msgs::MAX_WEBHOOK_URL_LENGTH + /// [`AppNameTooLong`]: super::msgs::LSPS5ProtocolError::AppNameTooLong + /// [`WebhookUrlTooLong`]: super::msgs::LSPS5ProtocolError::WebhookUrlTooLong + /// [`UnsupportedProtocol`]: super::msgs::LSPS5ProtocolError::UnsupportedProtocol + /// [`TooManyWebhooks`]: super::msgs::LSPS5ProtocolError::TooManyWebhooks + WebhookRegistrationFailed { + /// The node id of the LSP that rejected the registration. + counterparty_node_id: PublicKey, + /// Error from the LSP. + error: LSPS5Error, + /// The app name that was attempted. + app_name: LSPS5AppName, + /// The webhook URL that was attempted. + url: LSPS5WebhookUrl, + /// The identifier of the issued bLIP-55 / LSPS5 webhook registration request. + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// The list of registered webhooks was successfully retrieved. + /// + /// This event is triggered when the LSP responds to a + /// [`lsps5.list_webhooks`] request. + /// + /// [`lsps5.list_webhooks`]: super::msgs::LSPS5Request::ListWebhooks + WebhooksListed { + /// The node id of the LSP that provided the list. + counterparty_node_id: PublicKey, + /// List of app names with registered webhooks. + app_names: Vec, + /// Maximum number of webhooks allowed by LSP. + max_webhooks: u32, + /// The identifier of the issued bLIP-55 / LSPS5 list webhooks request. + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// A webhook was successfully removed. + /// + /// This event is triggered when the LSP confirms successful removal + /// of a webhook via [`lsps5.remove_webhook`]. The webhook registration + /// has been deleted from the LSP's system and will no longer receive + /// notifications. + /// + /// After this event, the app_name is free to be reused for a new webhook + /// registration if desired. + /// + /// [`lsps5.remove_webhook`]: super::msgs::LSPS5Request::RemoveWebhook + WebhookRemoved { + /// The node id of the LSP that confirmed the removal. + counterparty_node_id: PublicKey, + /// The app name that was removed. + app_name: LSPS5AppName, + /// The identifier of the issued bLIP-55 / LSPS5 remove webhook request. + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// A webhook removal attempt failed. + /// + /// This event is triggered when the LSP rejects a webhook removal + /// via [`lsps5.remove_webhook`]. + /// + /// The most common error is [`LSPS5ProtocolError::AppNameNotFound`] + /// (error code [`LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE`]), which indicates + /// the given [`app_name`] was not found in the LSP's registration database. + /// + /// [`lsps5.remove_webhook`]: super::msgs::LSPS5Request::RemoveWebhook + /// [`AppNameNotFound`]: super::msgs::LSPS5ProtocolError::AppNameNotFound + /// [`LSPS5ProtocolError::AppNameNotFound`]: super::msgs::LSPS5ProtocolError::AppNameNotFound + /// [`LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE`]: super::msgs::LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE + /// [`app_name`]: super::msgs::LSPS5AppName + WebhookRemovalFailed { + /// The node id of the LSP that rejected the removal. + counterparty_node_id: PublicKey, + /// Error from the LSP. + error: LSPS5Error, + /// The app name that was attempted to be removed. + app_name: LSPS5AppName, + /// The identifier of the issued bLIP-55 / LSPS5 remove webhook request. + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, +} From bc2f99027f42d7910964772208dcee93037f3495 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 24 Mar 2025 16:57:34 -0300 Subject: [PATCH 07/12] Add LSPS5 webhook service implementation Implements the LSPS5 webhook registration service that allows LSPs to notify clients of important events via webhooks. This service handles webhook registration, listing, removal, and notification delivery according to the LSPS5 specification. --- lightning-liquidity/src/lsps5/service.rs | 547 +++++++++++++++++++++++ 1 file changed, 547 insertions(+) create mode 100644 lightning-liquidity/src/lsps5/service.rs diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs new file mode 100644 index 00000000000..ef9828eca80 --- /dev/null +++ b/lightning-liquidity/src/lsps5/service.rs @@ -0,0 +1,547 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Service implementation for LSPS5 webhook registration. + +use crate::alloc::string::ToString; +use crate::events::EventQueue; +use crate::lsps0::ser::{LSPSDateTime, LSPSProtocolMessageHandler, LSPSRequestId}; +use crate::lsps5::msgs::{ + ListWebhooksRequest, ListWebhooksResponse, RemoveWebhookRequest, RemoveWebhookResponse, + SetWebhookRequest, SetWebhookResponse, WebhookNotification, WebhookNotificationMethod, +}; +use crate::message_queue::MessageQueue; +use crate::prelude::hash_map::Entry; +use crate::prelude::*; +use crate::sync::{Arc, Mutex}; + +use bitcoin::secp256k1::PublicKey; + +use lightning::ln::channelmanager::AChannelManager; +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::sign::NodeSigner; +use lightning::util::logger::Level; + +use core::ops::Deref; +use core::time::Duration; + +use alloc::string::String; +use alloc::vec::Vec; + +use super::event::LSPS5ServiceEvent; +use super::msgs::{ + LSPS5AppName, LSPS5Message, LSPS5ProtocolError, LSPS5Request, LSPS5Response, LSPS5WebhookUrl, +}; + +/// Minimum number of days to retain webhooks after a client's last channel is closed. +pub const MIN_WEBHOOK_RETENTION_DAYS: Duration = Duration::from_secs(30 * 24 * 60 * 60); +/// Interval for pruning stale webhooks. +pub const PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS: Duration = Duration::from_secs(24 * 60 * 60); + +/// A stored webhook. +#[derive(Debug, Clone)] +struct StoredWebhook { + _app_name: LSPS5AppName, + url: LSPS5WebhookUrl, + _counterparty_node_id: PublicKey, + last_used: LSPSDateTime, + last_notification_sent: HashMap, +} + +/// Trait defining a time provider for LSPS5 service. +/// +/// This trait is used to provide the current time for LSPS5 service operations +/// and to convert between timestamps and durations. +pub trait TimeProvider { + /// Get the current time as a duration since the Unix epoch. + fn duration_since_epoch(&self) -> Duration; +} + +/// Default time provider using the system clock. +#[derive(Clone, Debug)] +#[cfg(feature = "time")] +pub struct DefaultTimeProvider; + +#[cfg(feature = "time")] +impl TimeProvider for DefaultTimeProvider { + fn duration_since_epoch(&self) -> Duration { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).expect("system time before Unix epoch") + } +} + +/// Server-side configuration options for LSPS5 Webhook Registration. +#[derive(Clone, Debug)] +pub struct LSPS5ServiceConfig { + /// Maximum number of webhooks allowed per client. + pub max_webhooks_per_client: u32, + /// Minimum time between sending the same notification type in hours (default: 24) + pub notification_cooldown_hours: Duration, +} + +/// Service-side handler for the [`bLIP-55 / LSPS5`] webhook registration protocol. +/// +/// Runs on the LSP (server) side. Stores and manages client-registered webhooks, +/// enforces per-client limits and retention policies, and emits signed JSON-RPC +/// notifications to each webhook endpoint when events occur. +/// +/// # Core Responsibilities +/// - Handle incoming JSON-RPC requests: +/// - `lsps5.set_webhook` -> insert or replace a webhook, enforce [`max_webhooks_per_client`], +/// and send an initial [`lsps5.webhook_registered`] notification if new or changed. +/// - `lsps5.list_webhooks` -> return all registered [`app_name`]s via response. +/// - `lsps5.remove_webhook` -> delete a named webhook or return [`app_name_not_found`] error. +/// - Prune stale webhooks after a client has no open channels and no activity for at least +/// [`MIN_WEBHOOK_RETENTION_DAYS`]. +/// - Rate-limit repeat notifications of the same method to a client by +/// [`notification_cooldown_hours`]. +/// - Sign and enqueue outgoing webhook notifications: +/// - Construct JSON-RPC 2.0 Notification objects [`WebhookNotification`], +/// - Timestamp and LN-style zbase32-sign each payload, +/// - Emit [`LSPS5ServiceEvent::SendWebhookNotification`] with HTTP headers. +/// +/// # Security & Spec Compliance +/// - All notifications are signed with the LSP's node key according to bLIP-50/LSPS0. +/// - Clients must validate signature, timestamp (±10 min), and replay protection via +/// `LSPS5ClientHandler::parse_webhook_notification`. +/// - Webhook endpoints use only HTTPS and must guard against unauthorized calls. +/// +/// [`bLIP-55 / LSPS5`]: https://github.com/lightning/blips/pull/55/files +/// [`max_webhooks_per_client`]: super::service::LSPS5ServiceConfig::max_webhooks_per_client +/// [`app_name_not_found`]: super::msgs::LSPS5ProtocolError::AppNameNotFound +/// [`notification_cooldown_hours`]: super::service::LSPS5ServiceConfig::notification_cooldown_hours +/// [`WebhookNotification`]: super::msgs::WebhookNotification +/// [`LSPS5ServiceEvent::SendWebhookNotification`]: super::event::LSPS5ServiceEvent::SendWebhookNotification +/// [`app_name`]: super::msgs::LSPS5AppName +/// [`lsps5.webhook_registered`]: super::msgs::WebhookNotificationMethod::LSPS5WebhookRegistered +pub struct LSPS5ServiceHandler +where + CM::Target: AChannelManager, + NS::Target: NodeSigner, + TP::Target: TimeProvider, +{ + config: LSPS5ServiceConfig, + webhooks: Mutex>>, + event_queue: Arc, + pending_messages: Arc, + time_provider: TP, + channel_manager: CM, + node_signer: NS, + last_pruning: Mutex>, +} + +impl LSPS5ServiceHandler +where + CM::Target: AChannelManager, + NS::Target: NodeSigner, + TP::Target: TimeProvider, +{ + /// Constructs a `LSPS5ServiceHandler` using the given time provider. + pub(crate) fn new_with_time_provider( + event_queue: Arc, pending_messages: Arc, channel_manager: CM, + node_signer: NS, config: LSPS5ServiceConfig, time_provider: TP, + ) -> Self { + assert!(config.max_webhooks_per_client > 0, "`max_webhooks_per_client` must be > 0"); + Self { + config, + webhooks: Mutex::new(new_hash_map()), + event_queue, + pending_messages, + time_provider, + channel_manager, + node_signer, + last_pruning: Mutex::new(None), + } + } + + fn check_prune_stale_webhooks(&self) { + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + let should_prune = { + let last_pruning = self.last_pruning.lock().unwrap(); + last_pruning.as_ref().map_or(true, |last_time| { + now.abs_diff(&last_time) > PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS.as_secs() + }) + }; + + if should_prune { + self.prune_stale_webhooks(); + } + } + + fn handle_set_webhook( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + params: SetWebhookRequest, + ) -> Result<(), LightningError> { + self.check_prune_stale_webhooks(); + + let mut webhooks = self.webhooks.lock().unwrap(); + + let client_webhooks = webhooks.entry(counterparty_node_id).or_insert_with(new_hash_map); + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + + let num_webhooks = client_webhooks.len(); + let mut no_change = false; + match client_webhooks.entry(params.app_name.clone()) { + Entry::Occupied(mut entry) => { + no_change = entry.get().url == params.webhook; + let (last_used, last_notification_sent) = if no_change { + (entry.get().last_used.clone(), entry.get().last_notification_sent.clone()) + } else { + (now, new_hash_map()) + }; + entry.insert(StoredWebhook { + _app_name: params.app_name.clone(), + url: params.webhook.clone(), + _counterparty_node_id: counterparty_node_id, + last_used, + last_notification_sent, + }); + }, + Entry::Vacant(entry) => { + if num_webhooks >= self.config.max_webhooks_per_client as usize { + let error = LSPS5ProtocolError::TooManyWebhooks; + let msg = LSPS5Message::Response( + request_id, + LSPS5Response::SetWebhookError(error.clone().into()), + ) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + return Err(LightningError { + err: error.message().into(), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + entry.insert(StoredWebhook { + _app_name: params.app_name.clone(), + url: params.webhook.clone(), + _counterparty_node_id: counterparty_node_id, + last_used: now, + last_notification_sent: new_hash_map(), + }); + }, + } + + if !no_change { + self.send_webhook_registered_notification( + counterparty_node_id, + params.app_name, + params.webhook, + ) + .map_err(|e| { + let msg = LSPS5Message::Response( + request_id.clone(), + LSPS5Response::SetWebhookError(e.clone().into()), + ) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + LightningError { + err: e.message().into(), + action: ErrorAction::IgnoreAndLog(Level::Info), + } + })?; + } + + let msg = LSPS5Message::Response( + request_id, + LSPS5Response::SetWebhook(SetWebhookResponse { + num_webhooks: client_webhooks.len() as u32, + max_webhooks: self.config.max_webhooks_per_client, + no_change, + }), + ) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + Ok(()) + } + + fn handle_list_webhooks( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + _params: ListWebhooksRequest, + ) -> Result<(), LightningError> { + self.check_prune_stale_webhooks(); + + let webhooks = self.webhooks.lock().unwrap(); + + let app_names = webhooks + .get(&counterparty_node_id) + .map(|client_webhooks| client_webhooks.keys().cloned().collect::>()) + .unwrap_or_else(Vec::new); + + let max_webhooks = self.config.max_webhooks_per_client; + + let response = ListWebhooksResponse { app_names, max_webhooks }; + let msg = LSPS5Message::Response(request_id, LSPS5Response::ListWebhooks(response)).into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + + Ok(()) + } + + fn handle_remove_webhook( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + params: RemoveWebhookRequest, + ) -> Result<(), LightningError> { + self.check_prune_stale_webhooks(); + + let mut webhooks = self.webhooks.lock().unwrap(); + + if let Some(client_webhooks) = webhooks.get_mut(&counterparty_node_id) { + if client_webhooks.remove(¶ms.app_name).is_some() { + let response = RemoveWebhookResponse {}; + let msg = + LSPS5Message::Response(request_id, LSPS5Response::RemoveWebhook(response)) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + + return Ok(()); + } + } + + let error = LSPS5ProtocolError::AppNameNotFound; + let msg = LSPS5Message::Response( + request_id, + LSPS5Response::RemoveWebhookError(error.clone().into()), + ) + .into(); + + self.pending_messages.enqueue(&counterparty_node_id, msg); + return Err(LightningError { + err: error.message().into(), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + fn send_webhook_registered_notification( + &self, client_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl, + ) -> Result<(), LSPS5ProtocolError> { + let notification = WebhookNotification::webhook_registered(); + self.send_notification(client_node_id, app_name, url, notification) + } + + /// Notify the LSP service that the client has one or more incoming payments pending. + /// + /// SHOULD be called by your LSP application logic as soon as you detect an incoming + /// payment (HTLC or future mechanism) for `client_id`. + /// This builds a [`WebhookNotificationMethod::LSPS5PaymentIncoming`] webhook notification, signs it with your + /// node key, and enqueues HTTP POSTs to all registered webhook URLs for that client. + /// + /// # Parameters + /// - `client_id`: the client's node-ID whose webhooks should be invoked. + /// + /// [`WebhookNotificationMethod::LSPS5PaymentIncoming`]: super::msgs::WebhookNotificationMethod::LSPS5PaymentIncoming + pub fn notify_payment_incoming(&self, client_id: PublicKey) -> Result<(), LSPS5ProtocolError> { + let notification = WebhookNotification::payment_incoming(); + self.broadcast_notification(client_id, notification) + } + + /// Notify that an HTLC or other time-bound contract is expiring soon. + /// + /// SHOULD be called by your LSP application logic when a channel contract for `client_id` + /// is within 24 blocks of timeout, and the timeout would cause a channel closure. + /// Builds a [`WebhookNotificationMethod::LSPS5ExpirySoon`] notification including + /// the `timeout` block height, signs it, and enqueues HTTP POSTs to the client's + /// registered webhooks. + /// + /// # Parameters + /// - `client_id`: the client's node-ID whose webhooks should be invoked. + /// - `timeout`: the block height at which the channel contract will expire. + /// + /// [`WebhookNotificationMethod::LSPS5ExpirySoon`]: super::msgs::WebhookNotificationMethod::LSPS5ExpirySoon + pub fn notify_expiry_soon( + &self, client_id: PublicKey, timeout: u32, + ) -> Result<(), LSPS5ProtocolError> { + let notification = WebhookNotification::expiry_soon(timeout); + self.broadcast_notification(client_id, notification) + } + + /// Notify that the LSP intends to manage liquidity (e.g. close or splice) on client channels. + /// + /// SHOULD be called by your LSP application logic when you decide to reclaim or adjust + /// liquidity for `client_id`. Builds a [`WebhookNotificationMethod::LSPS5LiquidityManagementRequest`] notification, + /// signs it, and sends it to all of the client's registered webhook URLs. + /// + /// # Parameters + /// - `client_id`: the client's node-ID whose webhooks should be invoked. + /// + /// [`WebhookNotificationMethod::LSPS5LiquidityManagementRequest`]: super::msgs::WebhookNotificationMethod::LSPS5LiquidityManagementRequest + pub fn notify_liquidity_management_request( + &self, client_id: PublicKey, + ) -> Result<(), LSPS5ProtocolError> { + let notification = WebhookNotification::liquidity_management_request(); + self.broadcast_notification(client_id, notification) + } + + /// Notify that the client has one or more pending BOLT Onion Messages. + /// + /// SHOULD be called by your LSP application logic when you receive Onion Messages + /// for `client_id` while the client is offline. Builds a [`WebhookNotificationMethod::LSPS5OnionMessageIncoming`] + /// notification, signs it, and enqueues HTTP POSTs to each registered webhook. + /// + /// # Parameters + /// - `client_id`: the client's node-ID whose webhooks should be invoked. + /// + /// [`WebhookNotificationMethod::LSPS5OnionMessageIncoming`]: super::msgs::WebhookNotificationMethod::LSPS5OnionMessageIncoming + pub fn notify_onion_message_incoming( + &self, client_id: PublicKey, + ) -> Result<(), LSPS5ProtocolError> { + let notification = WebhookNotification::onion_message_incoming(); + self.broadcast_notification(client_id, notification) + } + + fn broadcast_notification( + &self, client_id: PublicKey, notification: WebhookNotification, + ) -> Result<(), LSPS5ProtocolError> { + let mut webhooks = self.webhooks.lock().unwrap(); + + let client_webhooks = match webhooks.get_mut(&client_id) { + Some(webhooks) if !webhooks.is_empty() => webhooks, + _ => return Ok(()), + }; + + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + + for (app_name, webhook) in client_webhooks.iter_mut() { + if webhook + .last_notification_sent + .get(¬ification.method) + .map(|last_sent| now.clone().abs_diff(&last_sent)) + .map_or(true, |duration| { + duration >= self.config.notification_cooldown_hours.as_secs() + }) { + webhook.last_notification_sent.insert(notification.method.clone(), now.clone()); + webhook.last_used = now.clone(); + self.send_notification( + client_id, + app_name.clone(), + webhook.url.clone(), + notification.clone(), + )?; + } + } + Ok(()) + } + + fn send_notification( + &self, counterparty_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl, + notification: WebhookNotification, + ) -> Result<(), LSPS5ProtocolError> { + let event_queue_notifier = self.event_queue.notifier(); + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + + let signature_hex = self.sign_notification(¬ification, ×tamp)?; + + let mut headers: HashMap = [("Content-Type", "application/json")] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + headers.insert("x-lsps5-timestamp".into(), timestamp.to_rfc3339()); + headers.insert("x-lsps5-signature".into(), signature_hex); + + event_queue_notifier.enqueue(LSPS5ServiceEvent::SendWebhookNotification { + counterparty_node_id, + app_name, + url, + notification, + headers, + }); + + Ok(()) + } + + fn sign_notification( + &self, body: &WebhookNotification, timestamp: &LSPSDateTime, + ) -> Result { + let notification_json = + serde_json::to_string(body).map_err(|_| LSPS5ProtocolError::SerializationError)?; + + let message = format!( + "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + timestamp.to_rfc3339(), + notification_json + ); + + self.node_signer + .sign_message(message.as_bytes()) + .map_err(|_| LSPS5ProtocolError::UnknownError) + } + + fn prune_stale_webhooks(&self) { + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + let mut webhooks = self.webhooks.lock().unwrap(); + + webhooks.retain(|client_id, client_webhooks| { + if !self.client_has_open_channel(client_id) { + client_webhooks.retain(|_, webhook| { + now.abs_diff(&webhook.last_used) < MIN_WEBHOOK_RETENTION_DAYS.as_secs() + }); + !client_webhooks.is_empty() + } else { + true + } + }); + + let mut last_pruning = self.last_pruning.lock().unwrap(); + *last_pruning = Some(now); + } + + fn client_has_open_channel(&self, client_id: &PublicKey) -> bool { + self.channel_manager + .get_cm() + .list_channels() + .iter() + .any(|c| c.is_usable && c.counterparty.node_id == *client_id) + } +} + +impl LSPSProtocolMessageHandler for LSPS5ServiceHandler +where + CM::Target: AChannelManager, + NS::Target: NodeSigner, + TP::Target: TimeProvider, +{ + type ProtocolMessage = LSPS5Message; + const PROTOCOL_NUMBER: Option = Some(5); + + fn handle_message( + &self, message: Self::ProtocolMessage, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + match message { + LSPS5Message::Request(request_id, request) => { + let res = match request { + LSPS5Request::SetWebhook(params) => { + self.handle_set_webhook(*counterparty_node_id, request_id, params) + }, + LSPS5Request::ListWebhooks(params) => { + self.handle_list_webhooks(*counterparty_node_id, request_id, params) + }, + LSPS5Request::RemoveWebhook(params) => { + self.handle_remove_webhook(*counterparty_node_id, request_id, params) + }, + }; + res + }, + _ => { + debug_assert!( + false, + "Service handler received LSPS5 response message. This should never happen." + ); + let err = format!( + "Service handler received LSPS5 response message from node {:?}. This should never happen.", + counterparty_node_id + ); + Err(LightningError { err, action: ErrorAction::IgnoreAndLog(Level::Info) }) + }, + } + } +} From 867f12b219f8867449f479624b21f02ac316cb72 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 24 Mar 2025 17:00:09 -0300 Subject: [PATCH 08/12] Add LSPS5 webhook client implementation Implements the client-side functionality for LSPS5 webhook registration, allowing clients to register, list, and remove webhooks with LSPs. --- lightning-liquidity/src/lsps5/client.rs | 641 ++++++++++++++++++++++++ 1 file changed, 641 insertions(+) create mode 100644 lightning-liquidity/src/lsps5/client.rs diff --git a/lightning-liquidity/src/lsps5/client.rs b/lightning-liquidity/src/lsps5/client.rs new file mode 100644 index 00000000000..1f6c1a9be3c --- /dev/null +++ b/lightning-liquidity/src/lsps5/client.rs @@ -0,0 +1,641 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Client implementation for LSPS5 webhook registration. + +use crate::alloc::string::ToString; +use crate::events::EventQueue; +use crate::lsps0::ser::{LSPSDateTime, LSPSMessage, LSPSProtocolMessageHandler, LSPSRequestId}; +use crate::lsps5::event::LSPS5ClientEvent; +use crate::lsps5::msgs::{ + LSPS5Message, LSPS5Request, LSPS5Response, ListWebhooksRequest, RemoveWebhookRequest, + SetWebhookRequest, +}; + +use crate::message_queue::MessageQueue; +use crate::prelude::{new_hash_map, HashMap}; +use crate::sync::{Arc, Mutex, RwLock}; +use crate::utils::generate_request_id; + +use super::msgs::{LSPS5AppName, LSPS5Error, LSPS5WebhookUrl}; +use super::service::TimeProvider; + +use bitcoin::secp256k1::PublicKey; + +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::sign::EntropySource; +use lightning::util::logger::Level; + +use alloc::string::String; + +use core::ops::Deref; +use core::time::Duration; + +/// Default maximum age in seconds for cached responses (1 hour). +pub const DEFAULT_RESPONSE_MAX_AGE_SECS: u64 = 3600; + +#[derive(Debug, Clone)] +/// Configuration for the LSPS5 client +pub struct LSPS5ClientConfig { + /// Maximum age in seconds for cached responses (default: 3600 - 1 hour). + pub response_max_age_secs: Duration, +} + +impl Default for LSPS5ClientConfig { + fn default() -> Self { + Self { response_max_age_secs: Duration::from_secs(DEFAULT_RESPONSE_MAX_AGE_SECS) } + } +} + +struct PeerState +where + TP::Target: TimeProvider, +{ + pending_set_webhook_requests: + HashMap, + pending_list_webhooks_requests: HashMap, + pending_remove_webhook_requests: HashMap, + last_cleanup: Option, + max_age_secs: Duration, + time_provider: TP, +} + +impl PeerState +where + TP::Target: TimeProvider, +{ + fn new(max_age_secs: Duration, time_provider: TP) -> Self { + Self { + pending_set_webhook_requests: new_hash_map(), + pending_list_webhooks_requests: new_hash_map(), + pending_remove_webhook_requests: new_hash_map(), + last_cleanup: None, + max_age_secs, + time_provider, + } + } + + fn cleanup_expired_responses(&mut self) { + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + // Only run cleanup once per minute to avoid excessive processing + const CLEANUP_INTERVAL: Duration = Duration::from_secs(60); + if let Some(last_cleanup) = &self.last_cleanup { + let time_since_last_cleanup = Duration::from_secs(now.abs_diff(&last_cleanup)); + if time_since_last_cleanup < CLEANUP_INTERVAL { + return; + } + } + + self.last_cleanup = Some(now.clone()); + + self.pending_set_webhook_requests.retain(|_, (_, _, timestamp)| { + Duration::from_secs(timestamp.abs_diff(&now)) < self.max_age_secs + }); + self.pending_list_webhooks_requests.retain(|_, timestamp| { + Duration::from_secs(timestamp.abs_diff(&now)) < self.max_age_secs + }); + self.pending_remove_webhook_requests.retain(|_, (_, timestamp)| { + Duration::from_secs(timestamp.abs_diff(&now)) < self.max_age_secs + }); + } +} + +/// Client-side handler for the LSPS5 (bLIP-55) webhook registration protocol. +/// +/// `LSPS5ClientHandler` is the primary interface for LSP clients +/// to register, list, and remove webhook endpoints with an LSP. +/// +/// This handler is intended for use on the client-side (e.g., a mobile app) +/// which has access to the node's keys and can send/receive peer messages. +/// +/// For validating incoming webhook notifications on a server, see [`LSPS5Validator`]. +/// +/// # Core Capabilities +/// +/// - `set_webhook(peer, app_name, url)` -> register or update a webhook [`lsps5.set_webhook`] +/// - `list_webhooks(peer)` -> retrieve all registered webhooks [`lsps5.list_webhooks`] +/// - `remove_webhook(peer, name)` -> delete a webhook [`lsps5.remove_webhook`] +/// +/// [`bLIP-55 / LSPS5 specification`]: https://github.com/lightning/blips/pull/55/files +/// [`lsps5.set_webhook`]: super::msgs::LSPS5Request::SetWebhook +/// [`lsps5.list_webhooks`]: super::msgs::LSPS5Request::ListWebhooks +/// [`lsps5.remove_webhook`]: super::msgs::LSPS5Request::RemoveWebhook +/// [`LSPS5Validator`]: super::validator::LSPS5Validator +pub struct LSPS5ClientHandler +where + ES::Target: EntropySource, + TP::Target: TimeProvider, +{ + pending_messages: Arc, + pending_events: Arc, + entropy_source: ES, + per_peer_state: RwLock>>>, + config: LSPS5ClientConfig, + time_provider: TP, +} + +impl LSPS5ClientHandler +where + ES::Target: EntropySource, + TP::Target: TimeProvider, +{ + /// Constructs an `LSPS5ClientHandler`. + pub(crate) fn new_with_time_provider( + entropy_source: ES, pending_messages: Arc, pending_events: Arc, + config: LSPS5ClientConfig, time_provider: TP, + ) -> Self { + Self { + pending_messages, + pending_events, + entropy_source, + per_peer_state: RwLock::new(new_hash_map()), + config, + time_provider, + } + } + + fn with_peer_state(&self, counterparty_node_id: PublicKey, f: F) -> R + where + F: FnOnce(&mut PeerState) -> R, + { + let mut outer_state_lock = self.per_peer_state.write().unwrap(); + let inner_state_lock = outer_state_lock.entry(counterparty_node_id).or_insert(Mutex::new( + PeerState::new(self.config.response_max_age_secs, self.time_provider.clone()), + )); + let mut peer_state_lock = inner_state_lock.lock().unwrap(); + + peer_state_lock.cleanup_expired_responses(); + + f(&mut *peer_state_lock) + } + + /// Register or update a webhook endpoint under a human-readable name. + /// + /// Sends a `lsps5.set_webhook` JSON-RPC request to the given LSP peer. + /// + /// # Parameters + /// - `counterparty_node_id`: The LSP node ID to contact. + /// - `app_name`: A UTF-8 name for this webhook. + /// - `webhook_url`: HTTPS URL for push notifications. + /// + /// # Returns + /// A unique `LSPSRequestId` for correlating the asynchronous response. + /// + /// Response from the LSP peer will be provided asynchronously through a + /// [`LSPS5Response::SetWebhook`] or [`LSPS5Response::SetWebhookError`] message, and this client + /// will then enqueue either a [`WebhookRegistered`] or [`WebhookRegistrationFailed`] event. + /// + /// **Note**: Ensure the app name is valid and its length does not exceed [`MAX_APP_NAME_LENGTH`]. + /// Also ensure the URL is valid, has HTTPS protocol, its length does not exceed [`MAX_WEBHOOK_URL_LENGTH`] + /// and that the URL points to a public host. + /// + /// [`MAX_WEBHOOK_URL_LENGTH`]: super::msgs::MAX_WEBHOOK_URL_LENGTH + /// [`MAX_APP_NAME_LENGTH`]: super::msgs::MAX_APP_NAME_LENGTH + /// [`WebhookRegistered`]: super::event::LSPS5ClientEvent::WebhookRegistered + /// [`WebhookRegistrationFailed`]: super::event::LSPS5ClientEvent::WebhookRegistrationFailed + /// [`LSPS5Response::SetWebhook`]: super::msgs::LSPS5Response::SetWebhook + /// [`LSPS5Response::SetWebhookError`]: super::msgs::LSPS5Response::SetWebhookError + pub fn set_webhook( + &self, counterparty_node_id: PublicKey, app_name: String, webhook_url: String, + ) -> Result { + let app_name = LSPS5AppName::from_string(app_name)?; + + let lsps_webhook_url = LSPS5WebhookUrl::from_string(webhook_url)?; + + let request_id = generate_request_id(&self.entropy_source); + + self.with_peer_state(counterparty_node_id, |peer_state| { + peer_state.pending_set_webhook_requests.insert( + request_id.clone(), + ( + app_name.clone(), + lsps_webhook_url.clone(), + LSPSDateTime::new_from_duration_since_epoch( + self.time_provider.duration_since_epoch(), + ), + ), + ); + }); + + let request = + LSPS5Request::SetWebhook(SetWebhookRequest { app_name, webhook: lsps_webhook_url }); + + let message = LSPS5Message::Request(request_id.clone(), request); + self.pending_messages.enqueue(&counterparty_node_id, LSPSMessage::LSPS5(message)); + + Ok(request_id) + } + + /// List all webhook names currently registered with the LSP. + /// + /// Sends a `lsps5.list_webhooks` JSON-RPC request to the peer. + /// + /// # Parameters + /// - `counterparty_node_id`: The LSP node ID to query. + /// + /// # Returns + /// A unique `LSPSRequestId` for correlating the asynchronous response. + /// + /// Response from the LSP peer will be provided asynchronously through a + /// [`LSPS5Response::ListWebhooks`] message, and this client + /// will then enqueue a [`WebhooksListed`] event. + /// + /// [`WebhooksListed`]: super::event::LSPS5ClientEvent::WebhooksListed + /// [`LSPS5Response::ListWebhooks`]: super::msgs::LSPS5Response::ListWebhooks + pub fn list_webhooks(&self, counterparty_node_id: PublicKey) -> LSPSRequestId { + let request_id = generate_request_id(&self.entropy_source); + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + + self.with_peer_state(counterparty_node_id, |peer_state| { + peer_state.pending_list_webhooks_requests.insert(request_id.clone(), now); + }); + + let request = LSPS5Request::ListWebhooks(ListWebhooksRequest {}); + let message = LSPS5Message::Request(request_id.clone(), request); + self.pending_messages.enqueue(&counterparty_node_id, LSPSMessage::LSPS5(message)); + + request_id + } + + /// Remove a previously registered webhook by its name. + /// + /// Sends a `lsps5.remove_webhook` JSON-RPC request to the peer. + /// + /// # Parameters + /// - `counterparty_node_id`: The LSP node ID to contact. + /// - `app_name`: The name of the webhook to remove. + /// + /// # Returns + /// A unique `LSPSRequestId` for correlating the asynchronous response. + /// + /// Response from the LSP peer will be provided asynchronously through a + /// [`LSPS5Response::RemoveWebhook`] or [`LSPS5Response::RemoveWebhookError`] message, and this client + /// will then enqueue either a [`WebhookRemoved`] or [`WebhookRemovalFailed`] event. + /// + /// [`WebhookRemoved`]: super::event::LSPS5ClientEvent::WebhookRemoved + /// [`WebhookRemovalFailed`]: super::event::LSPS5ClientEvent::WebhookRemovalFailed + /// [`LSPS5Response::RemoveWebhook`]: super::msgs::LSPS5Response::RemoveWebhook + /// [`LSPS5Response::RemoveWebhookError`]: super::msgs::LSPS5Response::RemoveWebhookError + pub fn remove_webhook( + &self, counterparty_node_id: PublicKey, app_name: String, + ) -> Result { + let app_name = LSPS5AppName::from_string(app_name)?; + + let request_id = generate_request_id(&self.entropy_source); + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + + self.with_peer_state(counterparty_node_id, |peer_state| { + peer_state + .pending_remove_webhook_requests + .insert(request_id.clone(), (app_name.clone(), now)); + }); + + let request = LSPS5Request::RemoveWebhook(RemoveWebhookRequest { app_name }); + let message = LSPS5Message::Request(request_id.clone(), request); + self.pending_messages.enqueue(&counterparty_node_id, LSPSMessage::LSPS5(message)); + + Ok(request_id) + } + + fn handle_message( + &self, message: LSPS5Message, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + let (request_id, response) = match message { + LSPS5Message::Request(_, _) => { + return Err(LightningError { + err: format!( + "Received unexpected request message from {}", + counterparty_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + }, + LSPS5Message::Response(rid, resp) => (rid, resp), + }; + let mut result: Result<(), LightningError> = Err(LightningError { + err: format!("Received LSPS5 response from unknown peer: {}", counterparty_node_id), + action: ErrorAction::IgnoreAndLog(Level::Error), + }); + let event_queue_notifier = self.pending_events.notifier(); + let handle_response = |peer_state: &mut PeerState| { + if let Some((app_name, webhook_url, _)) = + peer_state.pending_set_webhook_requests.remove(&request_id) + { + match &response { + LSPS5Response::SetWebhook(r) => { + event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookRegistered { + counterparty_node_id: *counterparty_node_id, + num_webhooks: r.num_webhooks, + max_webhooks: r.max_webhooks, + no_change: r.no_change, + app_name, + url: webhook_url, + request_id, + }); + result = Ok(()); + }, + LSPS5Response::SetWebhookError(e) => { + event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookRegistrationFailed { + counterparty_node_id: *counterparty_node_id, + error: e.clone().into(), + app_name, + url: webhook_url, + request_id, + }); + result = Ok(()); + }, + _ => { + result = Err(LightningError { + err: "Unexpected response type for SetWebhook".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + }); + }, + } + } else if peer_state.pending_list_webhooks_requests.remove(&request_id).is_some() { + match &response { + LSPS5Response::ListWebhooks(r) => { + event_queue_notifier.enqueue(LSPS5ClientEvent::WebhooksListed { + counterparty_node_id: *counterparty_node_id, + app_names: r.app_names.clone(), + max_webhooks: r.max_webhooks, + request_id, + }); + result = Ok(()); + }, + _ => { + result = Err(LightningError { + err: "Unexpected response type for ListWebhooks".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + }); + }, + } + } else if let Some((app_name, _)) = + peer_state.pending_remove_webhook_requests.remove(&request_id) + { + match &response { + LSPS5Response::RemoveWebhook(_) => { + event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookRemoved { + counterparty_node_id: *counterparty_node_id, + app_name, + request_id, + }); + result = Ok(()); + }, + LSPS5Response::RemoveWebhookError(e) => { + event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookRemovalFailed { + counterparty_node_id: *counterparty_node_id, + error: e.clone().into(), + app_name, + request_id, + }); + result = Ok(()); + }, + _ => { + result = Err(LightningError { + err: "Unexpected response type for RemoveWebhook".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + }); + }, + } + } else { + result = Err(LightningError { + err: format!("Received response for unknown request ID: {}", request_id.0), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + }; + self.with_peer_state(*counterparty_node_id, handle_response); + result + } +} + +impl LSPSProtocolMessageHandler for LSPS5ClientHandler +where + ES::Target: EntropySource, + TP::Target: TimeProvider, +{ + type ProtocolMessage = LSPS5Message; + const PROTOCOL_NUMBER: Option = Some(5); + + fn handle_message( + &self, message: Self::ProtocolMessage, lsp_node_id: &PublicKey, + ) -> Result<(), LightningError> { + self.handle_message(message, lsp_node_id) + } +} + +#[cfg(all(test, feature = "time"))] +mod tests { + use core::time::Duration; + + use super::*; + use crate::{ + lsps0::ser::LSPSRequestId, + lsps5::{msgs::SetWebhookResponse, service::DefaultTimeProvider}, + tests::utils::TestEntropy, + }; + use bitcoin::{key::Secp256k1, secp256k1::SecretKey}; + + fn setup_test_client() -> ( + LSPS5ClientHandler, Arc>, + Arc, + Arc, + PublicKey, + PublicKey, + ) { + let test_entropy_source = Arc::new(TestEntropy {}); + let message_queue = Arc::new(MessageQueue::new()); + let event_queue = Arc::new(EventQueue::new()); + let client = LSPS5ClientHandler::new_with_time_provider( + test_entropy_source, + Arc::clone(&message_queue), + Arc::clone(&event_queue), + LSPS5ClientConfig::default(), + Arc::new(DefaultTimeProvider), + ); + + let secp = Secp256k1::new(); + let secret_key_1 = SecretKey::from_slice(&[42u8; 32]).unwrap(); + let secret_key_2 = SecretKey::from_slice(&[43u8; 32]).unwrap(); + let peer_1 = PublicKey::from_secret_key(&secp, &secret_key_1); + let peer_2 = PublicKey::from_secret_key(&secp, &secret_key_2); + + (client, message_queue, event_queue, peer_1, peer_2) + } + + #[test] + fn test_per_peer_state_isolation() { + let (client, _, _, peer_1, peer_2) = setup_test_client(); + + let req_id_1 = client + .set_webhook(peer_1, "test-app-1".to_string(), "https://example.com/hook1".to_string()) + .unwrap(); + let req_id_2 = client + .set_webhook(peer_2, "test-app-2".to_string(), "https://example.com/hook2".to_string()) + .unwrap(); + + { + let outer_state_lock = client.per_peer_state.read().unwrap(); + + let peer_1_state = outer_state_lock.get(&peer_1).unwrap().lock().unwrap(); + assert!(peer_1_state.pending_set_webhook_requests.contains_key(&req_id_1)); + + let peer_2_state = outer_state_lock.get(&peer_2).unwrap().lock().unwrap(); + assert!(peer_2_state.pending_set_webhook_requests.contains_key(&req_id_2)); + } + } + + #[test] + fn test_pending_request_tracking() { + let (client, _, _, peer, _) = setup_test_client(); + const APP_NAME: &str = "test-app"; + const WEBHOOK_URL: &str = "https://example.com/hook"; + let lsps5_app_name = LSPS5AppName::from_string(APP_NAME.to_string()).unwrap(); + let lsps5_webhook_url = LSPS5WebhookUrl::from_string(WEBHOOK_URL.to_string()).unwrap(); + let set_req_id = + client.set_webhook(peer, APP_NAME.to_string(), WEBHOOK_URL.to_string()).unwrap(); + let list_req_id = client.list_webhooks(peer); + let remove_req_id = client.remove_webhook(peer, "test-app".to_string()).unwrap(); + + { + let outer_state_lock = client.per_peer_state.read().unwrap(); + let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap(); + assert_eq!( + peer_state.pending_set_webhook_requests.get(&set_req_id).unwrap(), + &( + lsps5_app_name.clone(), + lsps5_webhook_url, + peer_state.pending_set_webhook_requests.get(&set_req_id).unwrap().2.clone() + ) + ); + + assert!(peer_state.pending_list_webhooks_requests.contains_key(&list_req_id)); + + assert_eq!( + peer_state.pending_remove_webhook_requests.get(&remove_req_id).unwrap().0, + lsps5_app_name + ); + } + } + + #[test] + fn test_handle_response_clears_pending_state() { + let (client, _, _, peer, _) = setup_test_client(); + + let req_id = client + .set_webhook(peer, "test-app".to_string(), "https://example.com/hook".to_string()) + .unwrap(); + + let response = LSPS5Response::SetWebhook(SetWebhookResponse { + num_webhooks: 1, + max_webhooks: 5, + no_change: false, + }); + let response_msg = LSPS5Message::Response(req_id.clone(), response); + + { + let outer_state_lock = client.per_peer_state.read().unwrap(); + let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap(); + assert!(peer_state.pending_set_webhook_requests.contains_key(&req_id)); + } + + client.handle_message(response_msg, &peer).unwrap(); + + { + let outer_state_lock = client.per_peer_state.read().unwrap(); + let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap(); + assert!(!peer_state.pending_set_webhook_requests.contains_key(&req_id)); + } + } + + #[test] + fn test_cleanup_expired_responses() { + let (client, _, _, _, _) = setup_test_client(); + let time_provider = &client.time_provider; + const OLD_APP_NAME: &str = "test-app-old"; + const NEW_APP_NAME: &str = "test-app-new"; + const WEBHOOK_URL: &str = "https://example.com/hook"; + let lsps5_old_app_name = LSPS5AppName::from_string(OLD_APP_NAME.to_string()).unwrap(); + let lsps5_new_app_name = LSPS5AppName::from_string(NEW_APP_NAME.to_string()).unwrap(); + let lsps5_webhook_url = LSPS5WebhookUrl::from_string(WEBHOOK_URL.to_string()).unwrap(); + let now = time_provider.duration_since_epoch(); + let mut peer_state = PeerState::>::new( + Duration::from_secs(1800), + Arc::clone(time_provider), + ); + peer_state.last_cleanup = Some(LSPSDateTime::new_from_duration_since_epoch( + now.checked_sub(Duration::from_secs(120)).unwrap(), + )); + + let old_request_id = LSPSRequestId("test:request:old".to_string()); + let new_request_id = LSPSRequestId("test:request:new".to_string()); + + // Add an old request (should be removed during cleanup) + peer_state.pending_set_webhook_requests.insert( + old_request_id.clone(), + ( + lsps5_old_app_name, + lsps5_webhook_url.clone(), + LSPSDateTime::new_from_duration_since_epoch( + now.checked_sub(Duration::from_secs(7200)).unwrap(), + ), + ), // 2 hours old + ); + + // Add a recent request (should be kept) + peer_state.pending_set_webhook_requests.insert( + new_request_id.clone(), + ( + lsps5_new_app_name, + lsps5_webhook_url, + LSPSDateTime::new_from_duration_since_epoch( + now.checked_sub(Duration::from_secs(600)).unwrap(), + ), + ), // 10 minutes old + ); + + peer_state.cleanup_expired_responses(); + + assert!(!peer_state.pending_set_webhook_requests.contains_key(&old_request_id)); + assert!(peer_state.pending_set_webhook_requests.contains_key(&new_request_id)); + + let cleanup_age = if let Some(last_cleanup) = peer_state.last_cleanup { + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()) + .abs_diff(&last_cleanup) + } else { + 0 + }; + assert!(cleanup_age < 10); + } + + #[test] + fn test_unknown_request_id_handling() { + let (client, _message_queue, _, peer, _) = setup_test_client(); + + let _valid_req = client + .set_webhook(peer, "test-app".to_string(), "https://example.com/hook".to_string()) + .unwrap(); + + let unknown_req_id = LSPSRequestId("unknown:request:id".to_string()); + let response = LSPS5Response::SetWebhook(SetWebhookResponse { + num_webhooks: 1, + max_webhooks: 5, + no_change: false, + }); + let response_msg = LSPS5Message::Response(unknown_req_id, response); + + let result = client.handle_message(response_msg, &peer); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.err.to_lowercase().contains("unknown request id")); + } +} From a744797858823125ad59f45f0ecf2114565c2870 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Fri, 18 Jul 2025 14:45:48 -0300 Subject: [PATCH 09/12] Introduce LSPS5/Validator, a utility for validating webhook notifications from an LSP. As context, this utility started as part of the client, but was extracted in favor of having it separated, so it's clear that this functions should not be used by the client, but by the proxy server that has to forward the notifications --- lightning-liquidity/src/lsps5/validator.rs | 220 +++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 lightning-liquidity/src/lsps5/validator.rs diff --git a/lightning-liquidity/src/lsps5/validator.rs b/lightning-liquidity/src/lsps5/validator.rs new file mode 100644 index 00000000000..e5a690404e7 --- /dev/null +++ b/lightning-liquidity/src/lsps5/validator.rs @@ -0,0 +1,220 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! LSPS5 Validator + +use super::msgs::LSPS5ClientError; +use super::service::TimeProvider; + +use crate::alloc::string::ToString; +use crate::lsps0::ser::LSPSDateTime; +use crate::lsps5::msgs::WebhookNotification; +use crate::sync::Mutex; + +use lightning::util::message_signing; + +use bitcoin::secp256k1::PublicKey; + +use alloc::collections::VecDeque; +use alloc::string::String; + +use core::ops::Deref; +use core::time::Duration; + +/// Configuration for signature storage. +#[derive(Clone, Copy, Debug)] +pub struct SignatureStorageConfig { + /// Maximum number of signatures to store. + pub max_signatures: usize, + /// Retention time for signatures in minutes. + pub retention_minutes: Duration, +} + +/// Default retention time for signatures in minutes (LSPS5 spec requires min 20 minutes). +pub const DEFAULT_SIGNATURE_RETENTION_MINUTES: u64 = 20; + +/// Default maximum number of stored signatures. +pub const DEFAULT_MAX_SIGNATURES: usize = 1000; + +impl Default for SignatureStorageConfig { + fn default() -> Self { + Self { + max_signatures: DEFAULT_MAX_SIGNATURES, + retention_minutes: Duration::from_secs(DEFAULT_SIGNATURE_RETENTION_MINUTES * 60), + } + } +} + +/// A utility for validating webhook notifications from an LSP. +/// +/// In a typical setup, a proxy server receives webhook notifications from the LSP +/// and then forwards them to the client (e.g., via mobile push notifications). +/// This validator should be used by the proxy to verify the authenticity and +/// integrity of the notification before processing or forwarding it. +/// +/// # Core Capabilities +/// +/// - `validate(...)` -> Verifies signature, timestamp, and protects against replay attacks. +/// +/// # Usage +/// +/// The validator requires a `SignatureStore` to track recently seen signatures +/// to prevent replay attacks. You should create a single `LSPS5Validator` instance +/// and share it across all requests. +/// +/// [`bLIP-55 / LSPS5 specification`]: https://github.com/lightning/blips/pull/55/files +pub struct LSPS5Validator +where + TP::Target: TimeProvider, + SS::Target: SignatureStore, +{ + time_provider: TP, + signature_store: SS, +} + +impl LSPS5Validator +where + TP::Target: TimeProvider, + SS::Target: SignatureStore, +{ + /// Creates a new `LSPS5Validator`. + pub fn new(time_provider: TP, signature_store: SS) -> Self { + Self { time_provider, signature_store } + } + + fn verify_notification_signature( + &self, counterparty_node_id: PublicKey, signature_timestamp: &LSPSDateTime, + signature: &str, notification: &WebhookNotification, + ) -> Result<(), LSPS5ClientError> { + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + let diff = signature_timestamp.abs_diff(&now); + const MAX_TIMESTAMP_DRIFT_SECS: u64 = 600; + if diff > MAX_TIMESTAMP_DRIFT_SECS { + return Err(LSPS5ClientError::InvalidTimestamp); + } + + let notification_json = serde_json::to_string(notification) + .map_err(|_| LSPS5ClientError::SerializationError)?; + let message = format!( + "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + signature_timestamp.to_rfc3339(), + notification_json + ); + + if message_signing::verify(message.as_bytes(), signature, &counterparty_node_id) { + Ok(()) + } else { + Err(LSPS5ClientError::InvalidSignature) + } + } + + /// Parse and validate a webhook notification received from an LSP. + /// + /// Verifies the webhook delivery by checking the timestamp is within ±10 minutes, + /// ensuring no signature replay within the retention window, and verifying the + /// zbase32 LN-style signature against the LSP's node ID. + /// + /// Call this method on your proxy/server before processing any webhook notification + /// to ensure its authenticity. + /// + /// # Parameters + /// - `counterparty_node_id`: The LSP's public key, used to verify the signature. + /// - `timestamp`: ISO8601 time when the LSP created the notification. + /// - `signature`: The zbase32-encoded LN signature over timestamp+body. + /// - `notification`: The [`WebhookNotification`] received from the LSP. + /// + /// Returns the validated [`WebhookNotification`] or an error for invalid timestamp, + /// replay attack, or signature verification failure. + /// + /// [`WebhookNotification`]: super::msgs::WebhookNotification + pub fn validate( + &self, counterparty_node_id: PublicKey, timestamp: &LSPSDateTime, signature: &str, + notification: &WebhookNotification, + ) -> Result { + self.verify_notification_signature( + counterparty_node_id, + timestamp, + signature, + notification, + )?; + + if self.signature_store.exists(signature)? { + return Err(LSPS5ClientError::ReplayAttack); + } + + self.signature_store.store(signature)?; + + Ok(notification.clone()) + } +} + +/// Trait for storing and checking webhook notification signatures to prevent replay attacks. +pub trait SignatureStore { + /// Checks if a signature already exists in the store. + fn exists(&self, signature: &str) -> Result; + /// Stores a new signature. + fn store(&self, signature: &str) -> Result<(), LSPS5ClientError>; +} + +/// An in-memory store for webhook notification signatures. +pub struct InMemorySignatureStore +where + TP::Target: TimeProvider, +{ + recent_signatures: Mutex>, + config: SignatureStorageConfig, + time_provider: TP, +} + +impl InMemorySignatureStore +where + TP::Target: TimeProvider, +{ + /// Creates a new `InMemorySignatureStore`. + pub fn new(config: SignatureStorageConfig, time_provider: TP) -> Self { + Self { + recent_signatures: Mutex::new(VecDeque::with_capacity(config.max_signatures)), + config, + time_provider, + } + } +} + +impl SignatureStore for InMemorySignatureStore +where + TP::Target: TimeProvider, +{ + fn exists(&self, signature: &str) -> Result { + let recent_signatures = self.recent_signatures.lock().unwrap(); + for (stored_sig, _) in recent_signatures.iter() { + if stored_sig == signature { + return Ok(true); + } + } + Ok(false) + } + + fn store(&self, signature: &str) -> Result<(), LSPS5ClientError> { + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + let mut recent_signatures = self.recent_signatures.lock().unwrap(); + + recent_signatures.push_back((signature.to_string(), now.clone())); + + let retention_secs = self.config.retention_minutes.as_secs(); + recent_signatures.retain(|(_, ts)| now.abs_diff(ts) <= retention_secs); + + if recent_signatures.len() > self.config.max_signatures { + let excess = recent_signatures.len() - self.config.max_signatures; + recent_signatures.drain(0..excess); + } + Ok(()) + } +} From f135feec075455314f27648fbbc2d36ca3069e74 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 24 Mar 2025 17:01:18 -0300 Subject: [PATCH 10/12] Add LSPS5 module structure --- lightning-liquidity/src/lsps5/mod.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 lightning-liquidity/src/lsps5/mod.rs diff --git a/lightning-liquidity/src/lsps5/mod.rs b/lightning-liquidity/src/lsps5/mod.rs new file mode 100644 index 00000000000..62d64ddda39 --- /dev/null +++ b/lightning-liquidity/src/lsps5/mod.rs @@ -0,0 +1,23 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! LSPS5 Webhook Registration Protocol Implementation +//! +//! Implements bLIP-55: LSP Protocol for Notification Webhook Registration +//! +//! This module provides functionality for Lightning Service Providers to send +//! webhook notifications to their clients, and for clients to register webhooks +//! with LSPs. + +pub mod client; +pub mod event; +pub mod msgs; +pub mod service; +pub mod url_utils; +pub mod validator; From 2c870fb4bb33c81734c48d518feee0d773e6257c Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 24 Mar 2025 17:03:46 -0300 Subject: [PATCH 11/12] Integrate LSPS5 with liquidity manager Fully integrates the LSPS5 webhook components into the lightning-liquidity framework, enabling usage through the LiquidityManager. --- fuzz/src/lsps_message.rs | 1 + lightning-background-processor/Cargo.toml | 4 +- lightning-background-processor/src/lib.rs | 23 +- lightning-liquidity/src/events/mod.rs | 17 ++ lightning-liquidity/src/lib.rs | 4 + lightning-liquidity/src/lsps0/msgs.rs | 1 + lightning-liquidity/src/lsps0/ser.rs | 137 +++++++++++ lightning-liquidity/src/manager.rs | 212 ++++++++++++++++-- lightning-liquidity/tests/common/mod.rs | 15 +- .../tests/lsps0_integration_tests.rs | 13 +- .../tests/lsps2_integration_tests.rs | 11 +- 11 files changed, 407 insertions(+), 31 deletions(-) diff --git a/fuzz/src/lsps_message.rs b/fuzz/src/lsps_message.rs index 01856e2eae8..299b9f07955 100644 --- a/fuzz/src/lsps_message.rs +++ b/fuzz/src/lsps_message.rs @@ -78,6 +78,7 @@ pub fn do_test(data: &[u8]) { )); let liquidity_manager = Arc::new(LiquidityManager::new( + Arc::clone(&keys_manager), Arc::clone(&keys_manager), Arc::clone(&manager), None::>, diff --git a/lightning-background-processor/Cargo.toml b/lightning-background-processor/Cargo.toml index 17f00600171..47d3211344b 100644 --- a/lightning-background-processor/Cargo.toml +++ b/lightning-background-processor/Cargo.toml @@ -14,9 +14,9 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -std = ["lightning/std", "lightning-liquidity/std", "bitcoin-io/std", "bitcoin_hashes/std"] - default = ["std"] +std = ["lightning/std", "lightning-liquidity/std", "bitcoin-io/std", "bitcoin_hashes/std", "lightning-liquidity/time"] + [dependencies] bitcoin = { version = "0.32.2", default-features = false } diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index 4badf6b0ab0..ad1592b7722 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -735,6 +735,7 @@ use futures_util::{dummy_waker, OptionalSelector, Selector, SelectorOutput}; /// # use lightning_background_processor::{process_events_async, GossipSync}; /// # use core::future::Future; /// # use core::pin::Pin; +/// # use lightning_liquidity::lsps5::service::TimeProvider; /// # struct Logger {} /// # impl lightning::util::logger::Logger for Logger { /// # fn log(&self, _record: lightning::util::logger::Record) {} @@ -753,6 +754,15 @@ use futures_util::{dummy_waker, OptionalSelector, Selector, SelectorOutput}; /// # fn remove(&self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool) -> Pin> + 'static + Send>> { todo!() } /// # fn list(&self, primary_namespace: &str, secondary_namespace: &str) -> Pin, io::Error>> + 'static + Send>> { todo!() } /// # } +/// # use core::time::Duration; +/// # struct DefaultTimeProvider; +/// # +/// # impl TimeProvider for DefaultTimeProvider { +/// # fn duration_since_epoch(&self) -> Duration { +/// # use std::time::{SystemTime, UNIX_EPOCH}; +/// # SystemTime::now().duration_since(UNIX_EPOCH).expect("system time before Unix epoch") +/// # } +/// # } /// # struct EventHandler {} /// # impl EventHandler { /// # async fn handle_event(&self, _: lightning::events::Event) -> Result<(), ReplayEvent> { Ok(()) } @@ -768,7 +778,7 @@ use futures_util::{dummy_waker, OptionalSelector, Selector, SelectorOutput}; /// # type P2PGossipSync
    = lightning::routing::gossip::P2PGossipSync, Arc
      , Arc>; /// # type ChannelManager = lightning::ln::channelmanager::SimpleArcChannelManager, B, FE, Logger>; /// # type OnionMessenger = lightning::onion_message::messenger::OnionMessenger, Arc, Arc, Arc>, Arc, Arc, Arc>>, Arc>, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler>; -/// # type LiquidityManager = lightning_liquidity::LiquidityManager, Arc>, Arc>; +/// # type LiquidityManager = lightning_liquidity::LiquidityManager, Arc, Arc>, Arc, Arc>; /// # type Scorer = RwLock, Arc>>; /// # type PeerManager = lightning::ln::peer_handler::SimpleArcPeerManager, B, FE, Arc
        , Logger, F, StoreSync>; /// # type OutputSweeper = lightning::util::sweep::OutputSweeper, Arc, Arc, Arc, Arc, Arc, Arc>; @@ -1391,6 +1401,7 @@ mod tests { use lightning::util::sweep::{OutputSpendStatus, OutputSweeperSync, PRUNE_DELAY_BLOCKS}; use lightning::util::test_utils; use lightning::{get_event, get_event_msg}; + use lightning_liquidity::lsps5::service::DefaultTimeProvider; use lightning_liquidity::LiquidityManager; use lightning_persister::fs_store::FilesystemStore; use lightning_rapid_gossip_sync::RapidGossipSync; @@ -1488,8 +1499,13 @@ mod tests { IgnoringMessageHandler, >; - type LM = - LiquidityManager, Arc, Arc>; + type LM = LiquidityManager< + Arc, + Arc, + Arc, + Arc, + Arc, + >; struct Node { node: Arc, @@ -1936,6 +1952,7 @@ mod tests { Arc::clone(&keys_manager), )); let liquidity_manager = Arc::new(LiquidityManager::new( + Arc::clone(&keys_manager), Arc::clone(&keys_manager), Arc::clone(&manager), None, diff --git a/lightning-liquidity/src/events/mod.rs b/lightning-liquidity/src/events/mod.rs index 506b91494c3..82e480a454c 100644 --- a/lightning-liquidity/src/events/mod.rs +++ b/lightning-liquidity/src/events/mod.rs @@ -23,6 +23,7 @@ pub use event_queue::MAX_EVENT_QUEUE_SIZE; use crate::lsps0; use crate::lsps1; use crate::lsps2; +use crate::lsps5; /// An event which you should probably take some action in response to. #[derive(Debug, Clone, PartialEq, Eq)] @@ -38,6 +39,10 @@ pub enum LiquidityEvent { LSPS2Client(lsps2::event::LSPS2ClientEvent), /// An LSPS2 (JIT Channel) server event. LSPS2Service(lsps2::event::LSPS2ServiceEvent), + /// An LSPS5 (Webhook) client event. + LSPS5Client(lsps5::event::LSPS5ClientEvent), + /// An LSPS5 (Webhook) server event. + LSPS5Service(lsps5::event::LSPS5ServiceEvent), } impl From for LiquidityEvent { @@ -70,3 +75,15 @@ impl From for LiquidityEvent { Self::LSPS2Service(event) } } + +impl From for LiquidityEvent { + fn from(event: lsps5::event::LSPS5ClientEvent) -> Self { + Self::LSPS5Client(event) + } +} + +impl From for LiquidityEvent { + fn from(event: lsps5::event::LSPS5ServiceEvent) -> Self { + Self::LSPS5Service(event) + } +} diff --git a/lightning-liquidity/src/lib.rs b/lightning-liquidity/src/lib.rs index 646a6e43015..34fc307f55a 100644 --- a/lightning-liquidity/src/lib.rs +++ b/lightning-liquidity/src/lib.rs @@ -23,6 +23,8 @@ //! an LSP will open a "just-in-time" channel. This is useful for the initial on-boarding of //! clients as the channel opening fees are deducted from the incoming payment, i.e., no funds are //! required client-side to initiate this flow. +//! - [bLIP-55 / LSPS5] defines a protocol for sending webhook notifications to clients. This is +//! useful for notifying clients about incoming payments, channel expiries, etc. //! //! To get started, you'll want to setup a [`LiquidityManager`] and configure it to be the //! [`CustomMessageHandler`] of your LDK node. You can then for example call @@ -37,6 +39,7 @@ //! [bLIP-50 / LSPS0]: https://github.com/lightning/blips/blob/master/blip-0050.md //! [bLIP-51 / LSPS1]: https://github.com/lightning/blips/blob/master/blip-0051.md //! [bLIP-52 / LSPS2]: https://github.com/lightning/blips/blob/master/blip-0052.md +//! [bLIP-55 / LSPS5]: https://github.com/lightning/blips/pull/55/files //! [`CustomMessageHandler`]: lightning::ln::peer_handler::CustomMessageHandler //! [`LiquidityManager::next_event`]: crate::LiquidityManager::next_event #![deny(missing_docs)] @@ -59,6 +62,7 @@ pub mod events; pub mod lsps0; pub mod lsps1; pub mod lsps2; +pub mod lsps5; mod manager; pub mod message_queue; #[allow(dead_code)] diff --git a/lightning-liquidity/src/lsps0/msgs.rs b/lightning-liquidity/src/lsps0/msgs.rs index 24df03a1481..6fb885659b5 100644 --- a/lightning-liquidity/src/lsps0/msgs.rs +++ b/lightning-liquidity/src/lsps0/msgs.rs @@ -83,6 +83,7 @@ impl TryFrom for LSPS0Message { LSPSMessage::LSPS0(message) => Ok(message), LSPSMessage::LSPS1(_) => Err(()), LSPSMessage::LSPS2(_) => Err(()), + LSPSMessage::LSPS5(_) => Err(()), } } } diff --git a/lightning-liquidity/src/lsps0/ser.rs b/lightning-liquidity/src/lsps0/ser.rs index 74b40cd9d60..aeb29422678 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -21,6 +21,11 @@ use crate::lsps1::msgs::{ use crate::lsps2::msgs::{ LSPS2Message, LSPS2Request, LSPS2Response, LSPS2_BUY_METHOD_NAME, LSPS2_GET_INFO_METHOD_NAME, }; +use crate::lsps5::msgs::{ + LSPS5Message, LSPS5Request, LSPS5Response, LSPS5_LIST_WEBHOOKS_METHOD_NAME, + LSPS5_REMOVE_WEBHOOK_METHOD_NAME, LSPS5_SET_WEBHOOK_METHOD_NAME, +}; + use crate::prelude::HashMap; use lightning::ln::msgs::{DecodeError, LightningError}; @@ -61,6 +66,9 @@ pub(crate) enum LSPSMethod { LSPS1CreateOrder, LSPS2GetInfo, LSPS2Buy, + LSPS5SetWebhook, + LSPS5ListWebhooks, + LSPS5RemoveWebhook, } impl LSPSMethod { @@ -72,6 +80,9 @@ impl LSPSMethod { Self::LSPS1GetOrder => LSPS1_GET_ORDER_METHOD_NAME, Self::LSPS2GetInfo => LSPS2_GET_INFO_METHOD_NAME, Self::LSPS2Buy => LSPS2_BUY_METHOD_NAME, + Self::LSPS5SetWebhook => LSPS5_SET_WEBHOOK_METHOD_NAME, + Self::LSPS5ListWebhooks => LSPS5_LIST_WEBHOOKS_METHOD_NAME, + Self::LSPS5RemoveWebhook => LSPS5_REMOVE_WEBHOOK_METHOD_NAME, } } } @@ -86,6 +97,9 @@ impl FromStr for LSPSMethod { LSPS1_GET_ORDER_METHOD_NAME => Ok(Self::LSPS1GetOrder), LSPS2_GET_INFO_METHOD_NAME => Ok(Self::LSPS2GetInfo), LSPS2_BUY_METHOD_NAME => Ok(Self::LSPS2Buy), + LSPS5_SET_WEBHOOK_METHOD_NAME => Ok(Self::LSPS5SetWebhook), + LSPS5_LIST_WEBHOOKS_METHOD_NAME => Ok(Self::LSPS5ListWebhooks), + LSPS5_REMOVE_WEBHOOK_METHOD_NAME => Ok(Self::LSPS5RemoveWebhook), _ => Err(&"Unknown method name"), } } @@ -118,6 +132,16 @@ impl From<&LSPS2Request> for LSPSMethod { } } +impl From<&LSPS5Request> for LSPSMethod { + fn from(value: &LSPS5Request) -> Self { + match value { + LSPS5Request::SetWebhook(_) => Self::LSPS5SetWebhook, + LSPS5Request::ListWebhooks(_) => Self::LSPS5ListWebhooks, + LSPS5Request::RemoveWebhook(_) => Self::LSPS5RemoveWebhook, + } + } +} + impl<'de> Deserialize<'de> for LSPSMethod { fn deserialize(deserializer: D) -> Result where @@ -266,6 +290,8 @@ pub enum LSPSMessage { LSPS1(LSPS1Message), /// An LSPS2 message. LSPS2(LSPS2Message), + /// An LSPS5 message. + LSPS5(LSPS5Message), } impl LSPSMessage { @@ -293,6 +319,9 @@ impl LSPSMessage { LSPSMessage::LSPS2(LSPS2Message::Request(request_id, request)) => { Some((LSPSRequestId(request_id.0.clone()), request.into())) }, + LSPSMessage::LSPS5(LSPS5Message::Request(request_id, request)) => { + Some((LSPSRequestId(request_id.0.clone()), request.into())) + }, _ => None, } } @@ -409,6 +438,44 @@ impl Serialize for LSPSMessage { jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &serde_json::Value::Null)?; jsonrpc_object.serialize_field(JSONRPC_ERROR_FIELD_KEY, &error)?; }, + LSPSMessage::LSPS5(LSPS5Message::Request(request_id, request)) => { + jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &request_id.0)?; + jsonrpc_object + .serialize_field(JSONRPC_METHOD_FIELD_KEY, &LSPSMethod::from(request))?; + + match request { + LSPS5Request::SetWebhook(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + LSPS5Request::ListWebhooks(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + LSPS5Request::RemoveWebhook(params) => { + jsonrpc_object.serialize_field(JSONRPC_PARAMS_FIELD_KEY, params)? + }, + } + }, + LSPSMessage::LSPS5(LSPS5Message::Response(request_id, response)) => { + jsonrpc_object.serialize_field(JSONRPC_ID_FIELD_KEY, &request_id.0)?; + + match response { + LSPS5Response::SetWebhook(result) => { + jsonrpc_object.serialize_field(JSONRPC_RESULT_FIELD_KEY, result)? + }, + LSPS5Response::SetWebhookError(error) => { + jsonrpc_object.serialize_field(JSONRPC_ERROR_FIELD_KEY, error)? + }, + LSPS5Response::ListWebhooks(result) => { + jsonrpc_object.serialize_field(JSONRPC_RESULT_FIELD_KEY, result)? + }, + LSPS5Response::RemoveWebhook(result) => { + jsonrpc_object.serialize_field(JSONRPC_RESULT_FIELD_KEY, result)? + }, + LSPS5Response::RemoveWebhookError(error) => { + jsonrpc_object.serialize_field(JSONRPC_ERROR_FIELD_KEY, error)? + }, + } + }, } jsonrpc_object.end() @@ -522,6 +589,30 @@ impl<'de, 'a> Visitor<'de> for LSPSMessageVisitor<'a> { .map_err(de::Error::custom)?; Ok(LSPSMessage::LSPS2(LSPS2Message::Request(id, LSPS2Request::Buy(request)))) }, + LSPSMethod::LSPS5SetWebhook => { + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Request( + id, + LSPS5Request::SetWebhook(request), + ))) + }, + LSPSMethod::LSPS5ListWebhooks => { + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Request( + id, + LSPS5Request::ListWebhooks(request), + ))) + }, + LSPSMethod::LSPS5RemoveWebhook => { + let request = serde_json::from_value(params.unwrap_or(json!({}))) + .map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Request( + id, + LSPS5Request::RemoveWebhook(request), + ))) + }, }, None => match self.request_id_to_method_map.remove(&id) { Some(method) => match method { @@ -627,6 +718,52 @@ impl<'de, 'a> Visitor<'de> for LSPSMessageVisitor<'a> { Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) } }, + LSPSMethod::LSPS5SetWebhook => { + if let Some(error) = error { + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::SetWebhookError(error.into()), + ))) + } else if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::SetWebhook(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, + LSPSMethod::LSPS5ListWebhooks => { + if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::ListWebhooks(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, + LSPSMethod::LSPS5RemoveWebhook => { + if let Some(error) = error { + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::RemoveWebhookError(error.into()), + ))) + } else if let Some(result) = result { + let response = + serde_json::from_value(result).map_err(de::Error::custom)?; + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::RemoveWebhook(response), + ))) + } else { + Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) + } + }, }, None => Err(de::Error::custom(format!( "Received response for unknown request id: {}", diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index f4cce6855cd..f0d24197701 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -10,6 +10,11 @@ use crate::lsps0::ser::{ LSPS_MESSAGE_TYPE_ID, }; use crate::lsps0::service::LSPS0ServiceHandler; +use crate::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler}; +use crate::lsps5::msgs::LSPS5Message; +#[cfg(feature = "time")] +use crate::lsps5::service::DefaultTimeProvider; +use crate::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler, TimeProvider}; use crate::message_queue::MessageQueue; use crate::lsps1::client::{LSPS1ClientConfig, LSPS1ClientHandler}; @@ -28,7 +33,7 @@ use lightning::ln::channelmanager::{AChannelManager, ChainParameters}; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::CustomMessageReader; -use lightning::sign::EntropySource; +use lightning::sign::{EntropySource, NodeSigner}; use lightning::util::logger::Level; use lightning::util::ser::{LengthLimitedRead, LengthReadable}; use lightning::util::wakers::Future; @@ -52,6 +57,8 @@ pub struct LiquidityServiceConfig { /// Optional server-side configuration for JIT channels /// should you want to support them. pub lsps2_service_config: Option, + /// Optional server-side configuration for LSPS5 webhook service. + pub lsps5_service_config: Option, /// Controls whether the liquidity service should be advertised via setting the feature bit in /// node announcment and the init message. pub advertise_service: bool, @@ -66,6 +73,8 @@ pub struct LiquidityClientConfig { pub lsps1_client_config: Option, /// Optional client-side configuration for JIT channels. pub lsps2_client_config: Option, + /// Optional client-side configuration for LSPS5 webhook service. + pub lsps5_client_config: Option, } /// A trivial trait which describes any [`LiquidityManager`]. @@ -77,6 +86,10 @@ pub trait ALiquidityManager { type EntropySource: EntropySource + ?Sized; /// A type that may be dereferenced to [`Self::EntropySource`]. type ES: Deref + Clone; + /// A type implementing [`NodeSigner`] + type NodeSigner: NodeSigner + ?Sized; + /// A type that may be dereferenced to [`Self::NodeSigner`]. + type NS: Deref + Clone; /// A type implementing [`AChannelManager`] type AChannelManager: AChannelManager + ?Sized; /// A type that may be dereferenced to [`Self::AChannelManager`]. @@ -85,24 +98,39 @@ pub trait ALiquidityManager { type Filter: Filter + ?Sized; /// A type that may be dereferenced to [`Self::Filter`]. type C: Deref + Clone; + /// A type implementing [`TimeProvider`]. + type TimeProvider: TimeProvider + ?Sized; + /// A type that may be dereferenced to [`Self::TimeProvider`]. + type TP: Deref + Clone; /// Returns a reference to the actual [`LiquidityManager`] object. - fn get_lm(&self) -> &LiquidityManager; + fn get_lm(&self) -> &LiquidityManager; } -impl ALiquidityManager - for LiquidityManager +impl< + ES: Deref + Clone, + NS: Deref + Clone, + CM: Deref + Clone, + C: Deref + Clone, + TP: Deref + Clone, + > ALiquidityManager for LiquidityManager where ES::Target: EntropySource, + NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, + TP::Target: TimeProvider, { type EntropySource = ES::Target; type ES = ES; + type NodeSigner = NS::Target; + type NS = NS; type AChannelManager = CM::Target; type CM = CM; type Filter = C::Target; type C = C; - fn get_lm(&self) -> &LiquidityManager { + type TimeProvider = TP::Target; + type TP = TP; + fn get_lm(&self) -> &LiquidityManager { self } } @@ -126,11 +154,18 @@ where /// [`Event::ChannelReady`]: lightning::events::Event::ChannelReady /// [`Event::HTLCHandlingFailed`]: lightning::events::Event::HTLCHandlingFailed /// [`Event::PaymentForwarded`]: lightning::events::Event::PaymentForwarded -pub struct LiquidityManager -where +pub struct LiquidityManager< + ES: Deref + Clone, + NS: Deref + Clone, + CM: Deref + Clone, + C: Deref + Clone, + TP: Deref + Clone, +> where ES::Target: EntropySource, + NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, + TP::Target: TimeProvider, { pending_messages: Arc, pending_events: Arc, @@ -144,28 +179,68 @@ where lsps1_client_handler: Option>, lsps2_service_handler: Option>, lsps2_client_handler: Option>, + lsps5_service_handler: Option>, + lsps5_client_handler: Option>, service_config: Option, _client_config: Option, best_block: RwLock>, _chain_source: Option, } -impl LiquidityManager +#[cfg(feature = "time")] +impl + LiquidityManager> +where + ES::Target: EntropySource, + NS::Target: NodeSigner, + CM::Target: AChannelManager, + C::Target: Filter, +{ + /// Constructor for the [`LiquidityManager`] using the default system clock + pub fn new( + entropy_source: ES, node_signer: NS, channel_manager: CM, chain_source: Option, + chain_params: Option, service_config: Option, + client_config: Option, + ) -> Self { + let time_provider = Arc::new(DefaultTimeProvider); + Self::new_with_custom_time_provider( + entropy_source, + node_signer, + channel_manager, + chain_source, + chain_params, + service_config, + client_config, + time_provider, + ) + } +} + +impl< + ES: Deref + Clone, + NS: Deref + Clone, + CM: Deref + Clone, + C: Deref + Clone, + TP: Deref + Clone, + > LiquidityManager where ES::Target: EntropySource, + NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, + TP::Target: TimeProvider, { - /// Constructor for the [`LiquidityManager`]. + /// Constructor for the [`LiquidityManager`] with a custom time provider. /// + /// This should be used on non-std platforms where access to the system time is not + /// available. /// Sets up the required protocol message handlers based on the given /// [`LiquidityClientConfig`] and [`LiquidityServiceConfig`]. - pub fn new( - entropy_source: ES, channel_manager: CM, chain_source: Option, + pub fn new_with_custom_time_provider( + entropy_source: ES, node_signer: NS, channel_manager: CM, chain_source: Option, chain_params: Option, service_config: Option, - client_config: Option, - ) -> Self -where { + client_config: Option, time_provider: TP, + ) -> Self { let pending_messages = Arc::new(MessageQueue::new()); let pending_events = Arc::new(EventQueue::new()); let ignored_peers = RwLock::new(new_hash_set()); @@ -198,6 +273,37 @@ where { }) }); + let lsps5_client_handler = client_config.as_ref().and_then(|config| { + config.lsps5_client_config.as_ref().map(|config| { + LSPS5ClientHandler::new_with_time_provider( + entropy_source.clone(), + Arc::clone(&pending_messages), + Arc::clone(&pending_events), + config.clone(), + time_provider.clone(), + ) + }) + }); + + let lsps5_service_handler = service_config.as_ref().and_then(|config| { + config.lsps5_service_config.as_ref().map(|config| { + if let Some(number) = + as LSPSProtocolMessageHandler>::PROTOCOL_NUMBER + { + supported_protocols.push(number); + } + + LSPS5ServiceHandler::new_with_time_provider( + Arc::clone(&pending_events), + Arc::clone(&pending_messages), + channel_manager.clone(), + node_signer, + config.clone(), + time_provider.clone(), + ) + }) + }); + let lsps1_client_handler = client_config.as_ref().and_then(|config| { config.lsps1_client_config.as_ref().map(|config| { LSPS1ClientHandler::new( @@ -252,6 +358,8 @@ where { lsps1_service_handler, lsps2_client_handler, lsps2_service_handler, + lsps5_client_handler, + lsps5_service_handler, service_config, _client_config: client_config, best_block: RwLock::new(chain_params.map(|chain_params| chain_params.best_block)), @@ -299,6 +407,20 @@ where { self.lsps2_service_handler.as_ref() } + /// Returns a reference to the LSPS5 client-side handler. + /// + /// The returned hendler allows to initiate the LSPS5 client-side flow. That is, it allows to + pub fn lsps5_client_handler(&self) -> Option<&LSPS5ClientHandler> { + self.lsps5_client_handler.as_ref() + } + + /// Returns a reference to the LSPS5 server-side handler. + /// + /// The returned hendler allows to initiate the LSPS5 service-side flow. + pub fn lsps5_service_handler(&self) -> Option<&LSPS5ServiceHandler> { + self.lsps5_service_handler.as_ref() + } + /// Returns a [`Future`] that will complete when the next batch of pending messages is ready to /// be processed. /// @@ -424,17 +546,44 @@ where { }, } }, + LSPSMessage::LSPS5(msg @ LSPS5Message::Response(..)) => { + match &self.lsps5_client_handler { + Some(lsps5_client_handler) => { + lsps5_client_handler.handle_message(msg, sender_node_id)?; + }, + None => { + return Err(LightningError { err: format!("Received LSPS5 response message without LSPS5 client handler configured. From node = {:?}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}); + }, + } + }, + LSPSMessage::LSPS5(msg @ LSPS5Message::Request(..)) => { + match &self.lsps5_service_handler { + Some(lsps5_service_handler) => { + lsps5_service_handler.handle_message(msg, sender_node_id)?; + }, + None => { + return Err(LightningError { err: format!("Received LSPS5 request message without LSPS5 service handler configured. From node = {:?}", sender_node_id), action: ErrorAction::IgnoreAndLog(Level::Info)}); + }, + } + }, } Ok(()) } } -impl CustomMessageReader - for LiquidityManager +impl< + ES: Deref + Clone, + NS: Deref + Clone, + CM: Deref + Clone, + C: Deref + Clone, + TP: Deref + Clone, + > CustomMessageReader for LiquidityManager where ES::Target: EntropySource, + NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, + TP::Target: TimeProvider, { type CustomMessage = RawLSPSMessage; @@ -450,12 +599,19 @@ where } } -impl CustomMessageHandler - for LiquidityManager +impl< + ES: Deref + Clone, + NS: Deref + Clone, + CM: Deref + Clone, + C: Deref + Clone, + TP: Deref + Clone, + > CustomMessageHandler for LiquidityManager where ES::Target: EntropySource, + NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, + TP::Target: TimeProvider, { fn handle_custom_message( &self, msg: Self::CustomMessage, sender_node_id: PublicKey, @@ -562,11 +718,19 @@ where } } -impl Listen for LiquidityManager +impl< + ES: Deref + Clone, + NS: Deref + Clone, + CM: Deref + Clone, + C: Deref + Clone, + TP: Deref + Clone, + > Listen for LiquidityManager where ES::Target: EntropySource, + NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, + TP::Target: TimeProvider, { fn filtered_block_connected( &self, header: &bitcoin::block::Header, txdata: &chain::transaction::TransactionData, @@ -599,11 +763,19 @@ where } } -impl Confirm for LiquidityManager +impl< + ES: Deref + Clone, + NS: Deref + Clone, + CM: Deref + Clone, + C: Deref + Clone, + TP: Deref + Clone, + > Confirm for LiquidityManager where ES::Target: EntropySource, + NS::Target: NodeSigner, CM::Target: AChannelManager, C::Target: Filter, + TP::Target: TimeProvider, { fn transactions_confirmed( &self, _header: &bitcoin::block::Header, _txdata: &chain::transaction::TransactionData, diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index 3a31ab99ea7..344b8bfc725 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -2,6 +2,7 @@ use bitcoin::Network; use lightning::ln::channelmanager::ChainParameters; +use lightning_liquidity::lsps5::service::TimeProvider; use lightning_liquidity::{LiquidityClientConfig, LiquidityManager, LiquidityServiceConfig}; use lightning::chain::{BestBlock, Filter}; @@ -19,28 +20,32 @@ pub(crate) struct LSPSNodes<'a, 'b, 'c> { pub(crate) fn create_service_and_client_nodes<'a, 'b, 'c>( nodes: Vec>, service_config: LiquidityServiceConfig, - client_config: LiquidityClientConfig, + client_config: LiquidityClientConfig, time_provider: Arc, ) -> LSPSNodes<'a, 'b, 'c> { let chain_params = ChainParameters { network: Network::Testnet, best_block: BestBlock::from_network(Network::Testnet), }; - let service_lm = LiquidityManager::new( + let service_lm = LiquidityManager::new_with_custom_time_provider( + nodes[0].keys_manager, nodes[0].keys_manager, nodes[0].node, None::>, Some(chain_params.clone()), Some(service_config), None, + Arc::clone(&time_provider), ); - let client_lm = LiquidityManager::new( + let client_lm = LiquidityManager::new_with_custom_time_provider( + nodes[1].keys_manager, nodes[1].keys_manager, nodes[1].node, None::>, Some(chain_params), None, Some(client_config), + time_provider, ); let mut iter = nodes.into_iter(); @@ -53,9 +58,11 @@ pub(crate) fn create_service_and_client_nodes<'a, 'b, 'c>( pub(crate) struct LiquidityNode<'a, 'b, 'c> { pub inner: Node<'a, 'b, 'c>, pub liquidity_manager: LiquidityManager< + &'c TestKeysInterface, &'c TestKeysInterface, &'a TestChannelManager<'b, 'c>, Arc, + Arc, >, } @@ -63,9 +70,11 @@ impl<'a, 'b, 'c> LiquidityNode<'a, 'b, 'c> { pub fn new( node: Node<'a, 'b, 'c>, liquidity_manager: LiquidityManager< + &'c TestKeysInterface, &'c TestKeysInterface, &'a TestChannelManager<'b, 'c>, Arc, + Arc, >, ) -> Self { Self { inner: node, liquidity_manager } diff --git a/lightning-liquidity/tests/lsps0_integration_tests.rs b/lightning-liquidity/tests/lsps0_integration_tests.rs index 465ab029d53..912eccb842e 100644 --- a/lightning-liquidity/tests/lsps0_integration_tests.rs +++ b/lightning-liquidity/tests/lsps0_integration_tests.rs @@ -12,6 +12,7 @@ use lightning_liquidity::lsps1::client::LSPS1ClientConfig; use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; use lightning_liquidity::lsps2::client::LSPS2ClientConfig; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; +use lightning_liquidity::lsps5::service::DefaultTimeProvider; use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; use lightning::ln::functional_test_utils::{ @@ -19,6 +20,8 @@ use lightning::ln::functional_test_utils::{ }; use lightning::ln::peer_handler::CustomMessageHandler; +use std::sync::Arc; + #[test] fn list_protocols_integration_test() { let chanmon_cfgs = create_chanmon_cfgs(2); @@ -34,6 +37,7 @@ fn list_protocols_integration_test() { #[cfg(lsps1_service)] lsps1_service_config: Some(lsps1_service_config), lsps2_service_config: Some(lsps2_service_config), + lsps5_service_config: None, advertise_service: true, }; @@ -46,13 +50,18 @@ fn list_protocols_integration_test() { #[cfg(not(lsps1_service))] lsps1_client_config: None, lsps2_client_config: Some(lsps2_client_config), + lsps5_client_config: None, }; let service_node_id = nodes[0].node.get_our_node_id(); let client_node_id = nodes[1].node.get_our_node_id(); - let LSPSNodes { service_node, client_node } = - create_service_and_client_nodes(nodes, service_config, client_config); + let LSPSNodes { service_node, client_node } = create_service_and_client_nodes( + nodes, + service_config, + client_config, + Arc::new(DefaultTimeProvider), + ); let client_handler = client_node.liquidity_manager.lsps0_client_handler(); diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 02266807992..efa38a0f3c6 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -12,6 +12,7 @@ use lightning_liquidity::lsps2::event::LSPS2ServiceEvent; use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; use lightning_liquidity::lsps2::utils::is_valid_opening_fee_params; +use lightning_liquidity::lsps5::service::DefaultTimeProvider; use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; use lightning::ln::channelmanager::{InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; @@ -35,6 +36,7 @@ use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::Network; use std::str::FromStr; +use std::sync::Arc; use std::time::Duration; const MAX_PENDING_REQUESTS_PER_PEER: usize = 10; @@ -49,6 +51,7 @@ fn setup_test_lsps2_nodes<'a, 'b, 'c>( #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(lsps2_service_config), + lsps5_service_config: None, advertise_service: true, }; @@ -56,8 +59,14 @@ fn setup_test_lsps2_nodes<'a, 'b, 'c>( let client_config = LiquidityClientConfig { lsps1_client_config: None, lsps2_client_config: Some(lsps2_client_config), + lsps5_client_config: None, }; - let lsps_nodes = create_service_and_client_nodes(nodes, service_config, client_config); + let lsps_nodes = create_service_and_client_nodes( + nodes, + service_config, + client_config, + Arc::new(DefaultTimeProvider), + ); (lsps_nodes, promise_secret) } From 18fe1641abe5ed94ffa637e747fb4587dad92367 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 24 Mar 2025 17:04:46 -0300 Subject: [PATCH 12/12] Add tests for LSPS5 client and service. --- .../tests/lsps0_integration_tests.rs | 20 +- .../tests/lsps2_integration_tests.rs | 2 +- .../tests/lsps5_integration_tests.rs | 1177 +++++++++++++++++ 3 files changed, 1192 insertions(+), 7 deletions(-) create mode 100644 lightning-liquidity/tests/lsps5_integration_tests.rs diff --git a/lightning-liquidity/tests/lsps0_integration_tests.rs b/lightning-liquidity/tests/lsps0_integration_tests.rs index 912eccb842e..8a67561d8c1 100644 --- a/lightning-liquidity/tests/lsps0_integration_tests.rs +++ b/lightning-liquidity/tests/lsps0_integration_tests.rs @@ -1,4 +1,4 @@ -#![cfg(all(test, feature = "std"))] +#![cfg(all(test, feature = "time"))] mod common; @@ -12,7 +12,8 @@ use lightning_liquidity::lsps1::client::LSPS1ClientConfig; use lightning_liquidity::lsps1::service::LSPS1ServiceConfig; use lightning_liquidity::lsps2::client::LSPS2ClientConfig; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; -use lightning_liquidity::lsps5::service::DefaultTimeProvider; +use lightning_liquidity::lsps5::client::LSPS5ClientConfig; +use lightning_liquidity::lsps5::service::{DefaultTimeProvider, LSPS5ServiceConfig}; use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; use lightning::ln::functional_test_utils::{ @@ -21,6 +22,7 @@ use lightning::ln::functional_test_utils::{ use lightning::ln::peer_handler::CustomMessageHandler; use std::sync::Arc; +use std::time::Duration; #[test] fn list_protocols_integration_test() { @@ -33,24 +35,29 @@ fn list_protocols_integration_test() { let lsps2_service_config = LSPS2ServiceConfig { promise_secret }; #[cfg(lsps1_service)] let lsps1_service_config = LSPS1ServiceConfig { supported_options: None, token: None }; + let lsps5_service_config = LSPS5ServiceConfig { + max_webhooks_per_client: 10, + notification_cooldown_hours: Duration::from_secs(3600), + }; let service_config = LiquidityServiceConfig { #[cfg(lsps1_service)] lsps1_service_config: Some(lsps1_service_config), lsps2_service_config: Some(lsps2_service_config), - lsps5_service_config: None, + lsps5_service_config: Some(lsps5_service_config), advertise_service: true, }; let lsps2_client_config = LSPS2ClientConfig::default(); #[cfg(lsps1_service)] let lsps1_client_config: LSPS1ClientConfig = LSPS1ClientConfig { max_channel_fees_msat: None }; + let lsps5_client_config = LSPS5ClientConfig::default(); let client_config = LiquidityClientConfig { #[cfg(lsps1_service)] lsps1_client_config: Some(lsps1_client_config), #[cfg(not(lsps1_service))] lsps1_client_config: None, lsps2_client_config: Some(lsps2_client_config), - lsps5_client_config: None, + lsps5_client_config: Some(lsps5_client_config), }; let service_node_id = nodes[0].node.get_our_node_id(); @@ -91,11 +98,12 @@ fn list_protocols_integration_test() { { assert!(protocols.contains(&1)); assert!(protocols.contains(&2)); - assert_eq!(protocols.len(), 2); + assert!(protocols.contains(&5)); + assert_eq!(protocols.len(), 3); } #[cfg(not(lsps1_service))] - assert_eq!(protocols, vec![2]); + assert_eq!(protocols, vec![2, 5]); }, _ => panic!("Unexpected event"), } diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index efa38a0f3c6..e83f74da798 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -1,4 +1,4 @@ -#![cfg(all(test, feature = "std"))] +#![cfg(all(test, feature = "std", feature = "time"))] mod common; diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs new file mode 100644 index 00000000000..c0bbe4622d8 --- /dev/null +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -0,0 +1,1177 @@ +#![cfg(all(test, feature = "time"))] + +mod common; + +use common::{create_service_and_client_nodes, get_lsps_message, LSPSNodes}; + +use lightning::ln::functional_test_utils::{ + create_chanmon_cfgs, create_network, create_node_cfgs, create_node_chanmgrs, Node, +}; +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::util::hash_tables::{HashMap, HashSet}; +use lightning_liquidity::events::LiquidityEvent; +use lightning_liquidity::lsps0::ser::LSPSDateTime; +use lightning_liquidity::lsps5::client::LSPS5ClientConfig; +use lightning_liquidity::lsps5::event::{LSPS5ClientEvent, LSPS5ServiceEvent}; +use lightning_liquidity::lsps5::msgs::{ + LSPS5AppName, LSPS5ClientError, LSPS5ProtocolError, LSPS5WebhookUrl, WebhookNotification, + WebhookNotificationMethod, +}; +use lightning_liquidity::lsps5::service::{DefaultTimeProvider, LSPS5ServiceConfig, TimeProvider}; +use lightning_liquidity::lsps5::service::{ + MIN_WEBHOOK_RETENTION_DAYS, PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS, +}; +use lightning_liquidity::lsps5::validator::{ + InMemorySignatureStore, LSPS5Validator, SignatureStorageConfig, +}; +use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// Default maximum number of webhooks allowed per client. +pub(crate) const DEFAULT_MAX_WEBHOOKS_PER_CLIENT: u32 = 10; +/// Default notification cooldown time in hours. +pub(crate) const DEFAULT_NOTIFICATION_COOLDOWN_HOURS: Duration = Duration::from_secs(24 * 60 * 60); + +type TestValidator = LSPS5Validator< + Arc, + Arc>>, +>; + +pub(crate) fn lsps5_test_setup<'a, 'b, 'c>( + nodes: Vec>, time_provider: Arc, + max_signatures: Option, +) -> (LSPSNodes<'a, 'b, 'c>, TestValidator) { + let lsps5_service_config = LSPS5ServiceConfig { + max_webhooks_per_client: DEFAULT_MAX_WEBHOOKS_PER_CLIENT, + notification_cooldown_hours: DEFAULT_NOTIFICATION_COOLDOWN_HOURS, + }; + let service_config = LiquidityServiceConfig { + #[cfg(lsps1_service)] + lsps1_service_config: None, + lsps2_service_config: None, + lsps5_service_config: Some(lsps5_service_config), + advertise_service: true, + }; + + let lsps5_client_config = LSPS5ClientConfig::default(); + + let client_config = LiquidityClientConfig { + lsps1_client_config: None, + lsps2_client_config: None, + lsps5_client_config: Some(lsps5_client_config), + }; + + let lsps_nodes = create_service_and_client_nodes( + nodes, + service_config, + client_config, + Arc::clone(&time_provider), + ); + + let mut signature_config = SignatureStorageConfig::default(); + if let Some(max_signatures) = max_signatures { + signature_config.max_signatures = max_signatures; + } + + let signature_store = + Arc::new(InMemorySignatureStore::new(signature_config, Arc::clone(&time_provider))); + let validator = LSPS5Validator::new(time_provider, signature_store); + + (lsps_nodes, validator) +} + +struct MockTimeProvider { + current_time: RwLock, +} + +impl MockTimeProvider { + fn new(seconds_since_epoch: u64) -> Self { + Self { current_time: RwLock::new(Duration::from_secs(seconds_since_epoch)) } + } + + fn advance_time(&self, seconds: u64) { + let mut time = self.current_time.write().unwrap(); + *time += Duration::from_secs(seconds); + } + + fn rewind_time(&self, seconds: u64) { + let mut time = self.current_time.write().unwrap(); + *time = time.checked_sub(Duration::from_secs(seconds)).unwrap_or_default(); + } +} + +impl TimeProvider for MockTimeProvider { + fn duration_since_epoch(&self) -> Duration { + *self.current_time.read().unwrap() + } +} + +fn extract_ts_sig(headers: &HashMap) -> (LSPSDateTime, String) { + let timestamp = headers + .get("x-lsps5-timestamp") + .expect("missing x-lsps5-timestamp header") + .parse::() + .expect("failed to parse x-lsps5-timestamp header"); + + let signature = + headers.get("x-lsps5-signature").expect("missing x-lsps5-signature header").to_owned(); + (timestamp, signature) +} + +#[test] +fn webhook_registration_flow() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, _) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider), None); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + + let raw_app_name = "My LSPS-Compliant Lightning Client"; + let app_name = LSPS5AppName::from_string(raw_app_name.to_string()).unwrap(); + let raw_webhook_url = "https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best"; + let webhook_url = LSPS5WebhookUrl::from_string(raw_webhook_url.to_string()).unwrap(); + + let request_id = client_handler + .set_webhook(service_node_id, raw_app_name.to_string(), raw_webhook_url.to_string()) + .expect("Failed to send set_webhook request"); + let set_webhook_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(set_webhook_request, client_node_id) + .unwrap(); + + let webhook_notification_event = service_node.liquidity_manager.next_event().unwrap(); + match webhook_notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + counterparty_node_id, + app_name: an, + url, + notification, + headers, + }) => { + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(an, app_name.clone()); + assert_eq!(url, webhook_url); + let (timestamp, signature) = extract_ts_sig(&headers); + + assert!(timestamp.to_rfc3339().len() > 0, "Timestamp should not be empty"); + assert!(signature.len() > 0, "Signature should not be empty"); + assert_eq!( + headers.len(), + 3, + "Should have 3 headers (Content-Type, timestamp, signature)" + ); + assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered); + }, + _ => panic!("Expected SendWebhookNotification event"), + } + let set_webhook_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(set_webhook_response, service_node_id) + .unwrap(); + + let webhook_registered_event = client_node.liquidity_manager.next_event().unwrap(); + match webhook_registered_event { + LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRegistered { + num_webhooks, + max_webhooks, + no_change, + counterparty_node_id: lsp, + app_name: an, + url, + request_id: req_id, + }) => { + assert_eq!(num_webhooks, 1); + assert_eq!(max_webhooks, DEFAULT_MAX_WEBHOOKS_PER_CLIENT); + assert_eq!(no_change, false); + assert_eq!(lsp, service_node_id); + assert_eq!(an, app_name.clone()); + assert_eq!(url, webhook_url); + assert_eq!(req_id, request_id); + }, + _ => panic!("Unexpected event"), + } + + let list_request_id = client_handler.list_webhooks(service_node_id); + let list_webhooks_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(list_webhooks_request, client_node_id) + .unwrap(); + + let list_webhooks_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(list_webhooks_response, service_node_id) + .unwrap(); + + let webhooks_list_event = client_node.liquidity_manager.next_event().unwrap(); + match webhooks_list_event { + LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhooksListed { + counterparty_node_id: lsp, + app_names, + max_webhooks, + request_id, + }) => { + assert_eq!(lsp, service_node_id); + assert_eq!(app_names, vec![app_name.clone()]); + assert_eq!(max_webhooks, DEFAULT_MAX_WEBHOOKS_PER_CLIENT); + assert_eq!(request_id, list_request_id); + }, + _ => panic!("Unexpected event"), + } + + let raw_updated_webhook_url = "https://www.example.org/push?l=updatedtoken&c=best"; + let updated_webhook_url = + LSPS5WebhookUrl::from_string(raw_updated_webhook_url.to_string()).unwrap(); + let _ = client_handler + .set_webhook(service_node_id, raw_app_name.to_string(), raw_updated_webhook_url.to_string()) + .expect("Failed to send update webhook request"); + + let set_webhook_update_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(set_webhook_update_request, client_node_id) + .unwrap(); + + let webhook_notification_event = service_node.liquidity_manager.next_event().unwrap(); + match webhook_notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + url, .. + }) => { + assert_eq!(url, updated_webhook_url); + }, + _ => panic!("Expected SendWebhookNotification event"), + } + + let set_webhook_update_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(set_webhook_update_response, service_node_id) + .unwrap(); + + let webhook_update_event = client_node.liquidity_manager.next_event().unwrap(); + match webhook_update_event { + LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRegistered { + counterparty_node_id, + app_name: an, + url, + .. + }) => { + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(an, app_name); + assert_eq!(url, updated_webhook_url); + }, + _ => panic!("Unexpected event"), + } + + let remove_request_id = client_handler + .remove_webhook(service_node_id, app_name.to_string()) + .expect("Failed to send remove_webhook request"); + let remove_webhook_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(remove_webhook_request, client_node_id) + .unwrap(); + + let remove_webhook_response = get_lsps_message!(service_node, client_node_id); + + client_node + .liquidity_manager + .handle_custom_message(remove_webhook_response, service_node_id) + .unwrap(); + + let webhook_removed_event = client_node.liquidity_manager.next_event().unwrap(); + match webhook_removed_event { + LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRemoved { + counterparty_node_id, + app_name: an, + request_id, + }) => { + assert_eq!(counterparty_node_id, service_node_id); + assert_eq!(an, app_name); + assert_eq!(request_id, remove_request_id); + }, + _ => panic!("Unexpected event"), + } +} + +#[test] +fn webhook_error_handling_test() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, _) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider), None); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + + // TEST 1: URL too long error + let app_name = "Error Test App"; + + let long_url = format!("https://example.org/{}", "a".repeat(1024)); + + let result = client_handler.set_webhook(service_node_id, app_name.to_string(), long_url); + + assert!(result.is_err(), "Expected error due to URL length"); + let error = result.unwrap_err(); + assert!(error == LSPS5ProtocolError::WebhookUrlTooLong.into()); + + // TEST 2: Invalid URL format error + let invalid_url = "not-a-valid-url"; + let result = + client_handler.set_webhook(service_node_id, app_name.to_string(), invalid_url.to_string()); + assert!(result.is_err(), "Expected error due to invalid URL format"); + let error = result.unwrap_err(); + assert_eq!(error, LSPS5ProtocolError::UrlParse.into()); + + // TEST 3: Unsupported protocol error (not HTTPS) + let http_url = "http://example.org/webhook"; + let result = + client_handler.set_webhook(service_node_id, app_name.to_string(), http_url.to_string()); + assert!(result.is_err(), "Expected error due to non-HTTPS protocol"); + let error = result.unwrap_err(); + assert_eq!(error, LSPS5ProtocolError::UnsupportedProtocol.into()); + + // TEST 4: App name too long + let long_app_name = "A".repeat(65); + let valid_url = "https://example.org/webhook"; + let result = client_handler.set_webhook(service_node_id, long_app_name, valid_url.to_string()); + assert!(result.is_err(), "Expected error due to app name too long"); + let error = result.unwrap_err(); + assert!(error == LSPS5ProtocolError::AppNameTooLong.into()); + + // TEST 5: Too many webhooks - register the max number and then try one more + let valid_app_name_base = "Valid App"; + let valid_url = "https://example.org/webhook"; + for i in 0..DEFAULT_MAX_WEBHOOKS_PER_CLIENT { + let app_name = format!("{} {}", valid_app_name_base, i); + let _ = client_handler + .set_webhook(service_node_id, app_name, valid_url.to_string()) + .expect("Should be able to register webhook"); + + let request = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(request, client_node_id).unwrap(); + + let response = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(response, service_node_id).unwrap(); + + let _ = client_node.liquidity_manager.next_event().unwrap(); + } + + // Now try to add one more webhook - should fail with too many webhooks error + let raw_one_too_many = format!("{} {}", valid_app_name_base, DEFAULT_MAX_WEBHOOKS_PER_CLIENT); + let one_too_many = LSPS5AppName::from_string(raw_one_too_many.to_string()).unwrap(); + let _ = client_handler + .set_webhook(service_node_id, raw_one_too_many.clone(), valid_url.to_string()) + .expect("Request should send but will receive error response"); + + let request = get_lsps_message!(client_node, service_node_id); + let result = service_node.liquidity_manager.handle_custom_message(request, client_node_id); + assert!(result.is_err(), "Server should return an error for too many webhooks"); + + let response = get_lsps_message!(service_node, client_node_id); + + client_node.liquidity_manager.handle_custom_message(response, service_node_id).unwrap(); + + let event = client_node.liquidity_manager.next_event().unwrap(); + match event { + LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRegistrationFailed { + error, + app_name, + .. + }) => { + let error_to_check = LSPS5ProtocolError::TooManyWebhooks; + assert_eq!(error, error_to_check.into()); + assert_eq!(app_name, one_too_many); + }, + _ => panic!("Expected WebhookRegistrationFailed event, got {:?}", event), + } + + // TEST 6: Remove a non-existent webhook + let raw_nonexistent_app = "NonexistentApp"; + let nonexistent_app = LSPS5AppName::from_string(raw_nonexistent_app.to_string()).unwrap(); + let _ = client_handler + .remove_webhook(service_node_id, raw_nonexistent_app.to_string()) + .expect("Remove webhook request should send successfully"); + + let request = get_lsps_message!(client_node, service_node_id); + let result = service_node.liquidity_manager.handle_custom_message(request, client_node_id); + assert!(result.is_err(), "Server should return an error for non-existent webhook"); + + let response = get_lsps_message!(service_node, client_node_id); + + client_node.liquidity_manager.handle_custom_message(response, service_node_id).unwrap(); + + let event = client_node.liquidity_manager.next_event().unwrap(); + match event { + LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRemovalFailed { + error, + app_name, + .. + }) => { + assert_eq!(error, LSPS5ProtocolError::AppNameNotFound.into()); + assert_eq!(app_name, nonexistent_app); + }, + _ => panic!("Expected WebhookRemovalFailed event, got {:?}", event), + } +} + +#[test] +fn webhook_notification_delivery_test() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, validator) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider), None); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + + let app_name = "Webhook Test App"; + let webhook_url = "https://www.example.org/push?token=test123"; + + let _ = client_handler + .set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string()) + .expect("Register webhook request should succeed"); + let set_webhook_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(set_webhook_request, client_node_id) + .unwrap(); + + let notification_event = service_node.liquidity_manager.next_event().unwrap(); + let (timestamp_value, signature_value, notification) = match notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + url, + headers, + notification, + .. + }) => { + let (timestamp, signature) = extract_ts_sig(&headers); + assert_eq!(url.as_str(), webhook_url); + assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered); + (timestamp, signature, notification) + }, + _ => panic!("Expected SendWebhookNotification event"), + }; + + let set_webhook_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(set_webhook_response, service_node_id) + .unwrap(); + + let _ = client_node.liquidity_manager.next_event().unwrap(); + + let result = + validator.validate(service_node_id, ×tamp_value, &signature_value, ¬ification); + assert!( + result.is_ok(), + "Client should be able to parse and validate the webhook_registered notification" + ); + + let _ = service_handler.notify_payment_incoming(client_node_id); + + let payment_notification_event = service_node.liquidity_manager.next_event().unwrap(); + let (payment_timestamp, payment_signature, notification) = match payment_notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + url, + headers, + notification, + .. + }) => { + let (timestamp, signature) = extract_ts_sig(&headers); + assert_eq!(url.as_str(), webhook_url); + assert_eq!(notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming); + (timestamp, signature, notification) + }, + _ => panic!("Expected SendWebhookNotification event for payment_incoming"), + }; + + let result = + validator.validate(service_node_id, &payment_timestamp, &payment_signature, ¬ification); + assert!( + result.is_ok(), + "Client should be able to parse and validate the payment_incoming notification" + ); + + let _ = service_handler.notify_payment_incoming(client_node_id); + + assert!( + service_node.liquidity_manager.next_event().is_none(), + "No event should be emitted due to cooldown" + ); + + let timeout_block = 700000; // Some future block height + let _ = service_handler.notify_expiry_soon(client_node_id, timeout_block); + + let expiry_notification_event = service_node.liquidity_manager.next_event().unwrap(); + match expiry_notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + notification, + .. + }) => { + assert!(matches!( + notification.method, + WebhookNotificationMethod::LSPS5ExpirySoon { timeout } if timeout == timeout_block + )); + }, + _ => panic!("Expected SendWebhookNotification event for expiry_soon"), + }; +} + +#[test] +fn multiple_webhooks_notification_test() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, _) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider), None); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + + let webhooks = vec![ + ("Mobile App", "https://www.example.org/mobile-push?token=abc123"), + ("Desktop App", "https://www.example.org/desktop-push?token=def456"), + ("Web App", "https://www.example.org/web-push?token=ghi789"), + ]; + + for (app_name, webhook_url) in &webhooks { + let _ = client_handler + .set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string()) + .expect("Register webhook request should succeed"); + let set_webhook_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(set_webhook_request, client_node_id) + .unwrap(); + + // Consume SendWebhookNotification event for webhook_registered + let _ = service_node.liquidity_manager.next_event().unwrap(); + + let set_webhook_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(set_webhook_response, service_node_id) + .unwrap(); + + let _ = client_node.liquidity_manager.next_event().unwrap(); + } + + let _ = service_handler.notify_liquidity_management_request(client_node_id); + + let mut seen_webhooks = HashSet::default(); + + for _ in 0..3 { + let notification_event = service_node.liquidity_manager.next_event().unwrap(); + match notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + url, + notification, + .. + }) => { + seen_webhooks.insert(url.as_str().to_string()); + + assert_eq!( + notification.method, + WebhookNotificationMethod::LSPS5LiquidityManagementRequest + ); + }, + _ => panic!("Expected SendWebhookNotification event"), + } + } + + for (_, webhook_url) in &webhooks { + assert!( + seen_webhooks.contains(*webhook_url), + "Webhook URL {} should have been called", + webhook_url + ); + } + + let new_app = "New App"; + let new_webhook = "https://www.example.org/new-push?token=xyz789"; + + let _ = client_handler + .set_webhook(service_node_id, new_app.to_string(), new_webhook.to_string()) + .expect("Register new webhook request should succeed"); + let set_webhook_request = get_lsps_message!(client_node, service_node_id); + service_node + .liquidity_manager + .handle_custom_message(set_webhook_request, client_node_id) + .unwrap(); + + let notification_event = service_node.liquidity_manager.next_event().unwrap(); + match notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + url, + notification, + .. + }) => { + assert_eq!(url.as_str(), new_webhook); + assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered); + }, + _ => panic!("Expected SendWebhookNotification event"), + } +} + +#[test] +fn idempotency_set_webhook_test() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, _) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider), None); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + + let app_name = "Idempotency Test App"; + let webhook_url = "https://www.example.org/webhook?token=test123"; + + let _ = client_handler + .set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string()) + .expect("First webhook registration should succeed"); + let set_webhook_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(set_webhook_request, client_node_id) + .unwrap(); + + let notification_event = service_node.liquidity_manager.next_event().unwrap(); + match notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { .. }) => {}, + _ => panic!("Expected SendWebhookNotification event"), + } + + let set_webhook_response = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(set_webhook_response, service_node_id) + .unwrap(); + + let webhook_registered_event = client_node.liquidity_manager.next_event().unwrap(); + match webhook_registered_event { + LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRegistered { no_change, .. }) => { + assert_eq!(no_change, false, "First registration should have no_change=false"); + }, + _ => panic!("Unexpected event"), + } + + // Now register the SAME webhook AGAIN (should be idempotent) + let _ = client_handler + .set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string()) + .expect("Second identical webhook registration should succeed"); + let set_webhook_request_again = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(set_webhook_request_again, client_node_id) + .unwrap(); + + assert!( + service_node.liquidity_manager.next_event().is_none(), + "No notification should be sent for idempotent operation" + ); + + let set_webhook_response_again = get_lsps_message!(service_node, client_node_id); + client_node + .liquidity_manager + .handle_custom_message(set_webhook_response_again, service_node_id) + .unwrap(); + + let webhook_registered_again_client_event = client_node.liquidity_manager.next_event().unwrap(); + match webhook_registered_again_client_event { + LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhookRegistered { no_change, .. }) => { + assert_eq!(no_change, true, "Second identical registration should have no_change=true"); + }, + _ => panic!("Expected WebhookRegistered event for second registration"), + } + + let updated_webhook_url = "https://www.example.org/webhook?token=updated456"; + + let _ = client_handler + .set_webhook(service_node_id, app_name.to_string(), updated_webhook_url.to_string()) + .expect("Update webhook request should succeed"); + let update_webhook_request = get_lsps_message!(client_node, service_node_id); + + service_node + .liquidity_manager + .handle_custom_message(update_webhook_request, client_node_id) + .unwrap(); + + // For an update, a SendWebhookNotification event SHOULD be emitted + let notification_update_event = service_node.liquidity_manager.next_event().unwrap(); + match notification_update_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + url, .. + }) => { + assert_eq!(url.as_str(), updated_webhook_url); + }, + _ => panic!("Expected SendWebhookNotification event for update"), + } +} + +#[test] +fn replay_prevention_test() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, validator) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider), None); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + + let app_name = "Replay Prevention Test App"; + let webhook_url = "https://www.example.org/webhook?token=replay123"; + + let _ = client_handler + .set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string()) + .expect("Register webhook request should succeed"); + let request = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(request, client_node_id).unwrap(); + + // Consume initial SendWebhookNotification event + let _ = service_node.liquidity_manager.next_event().unwrap(); + + let response = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(response, service_node_id).unwrap(); + + let _ = client_node.liquidity_manager.next_event().unwrap(); + + let _ = service_handler.notify_payment_incoming(client_node_id); + + let notification_event = service_node.liquidity_manager.next_event().unwrap(); + let (timestamp, signature, body) = match notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + headers, + notification, + .. + }) => { + let (timestamp, signature) = extract_ts_sig(&headers); + (timestamp, signature, notification) + }, + _ => panic!("Expected SendWebhookNotification event"), + }; + + let result = validator.validate(service_node_id, ×tamp, &signature, &body); + assert!(result.is_ok(), "First verification should succeed"); + + // Try again with same timestamp and signature (simulate replay attack) + let replay_result = validator.validate(service_node_id, ×tamp, &signature, &body); + + // This should now fail since we've implemented replay prevention + assert!(replay_result.is_err(), "Replay attack should be detected and rejected"); + + let err = replay_result.unwrap_err(); + assert_eq!(err, LSPS5ClientError::ReplayAttack); +} + +#[test] +fn stale_webhooks() { + let mock_time_provider = Arc::new(MockTimeProvider::new(1000)); + let time_provider = Arc::::clone(&mock_time_provider); + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, _) = lsps5_test_setup(nodes, time_provider, None); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + + let raw_app_name = "StaleApp"; + let app_name = LSPS5AppName::from_string(raw_app_name.to_string()).unwrap(); + let raw_webhook_url = "https://example.org/stale"; + let _ = LSPS5WebhookUrl::from_string(raw_webhook_url.to_string()).unwrap(); + let _ = client_handler + .set_webhook(service_node_id, raw_app_name.to_string(), raw_webhook_url.to_string()) + .unwrap(); + let req = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(req, client_node_id).unwrap(); + + // consume initial SendWebhookNotification + let _ = service_node.liquidity_manager.next_event().unwrap(); + + let resp = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(resp, service_node_id).unwrap(); + let _ = client_node.liquidity_manager.next_event().unwrap(); + + // LIST before prune -> should contain our webhook + let _ = client_handler.list_webhooks(service_node_id); + let list_req = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(list_req, client_node_id).unwrap(); + + let list_resp = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(list_resp, service_node_id).unwrap(); + let list_cli = client_node.liquidity_manager.next_event().unwrap(); + match list_cli { + LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhooksListed { app_names, .. }) => { + assert_eq!(app_names, vec![app_name.clone()]); + }, + _ => panic!("Expected WebhooksListed before prune (client)"), + } + + mock_time_provider.advance_time( + MIN_WEBHOOK_RETENTION_DAYS.as_secs() + PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS.as_secs(), + ); + + // LIST calls prune before executing -> should be empty after advancing time + let _ = client_handler.list_webhooks(service_node_id); + let list_req2 = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(list_req2, client_node_id).unwrap(); + + let list_resp2 = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(list_resp2, service_node_id).unwrap(); + let list_cli2 = client_node.liquidity_manager.next_event().unwrap(); + match list_cli2 { + LiquidityEvent::LSPS5Client(LSPS5ClientEvent::WebhooksListed { app_names, .. }) => { + println!("App names after prune: {:?}", app_names); + assert!(app_names.is_empty(), "Expected no webhooks after prune (client)"); + }, + _ => panic!("Expected WebhooksListed after prune (client)"), + } +} + +#[test] +fn test_all_notifications() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, validator) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider), None); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + + let app_name = "OnionApp"; + let webhook_url = "https://www.example.org/onion"; + let _ = client_handler + .set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string()) + .expect("Register webhook request should succeed"); + let set_req = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(set_req, client_node_id).unwrap(); + + // consume initial SendWebhookNotification + let _ = service_node.liquidity_manager.next_event().unwrap(); + + let _ = service_handler.notify_onion_message_incoming(client_node_id); + let _ = service_handler.notify_payment_incoming(client_node_id); + let _ = service_handler.notify_expiry_soon(client_node_id, 1000); + let _ = service_handler.notify_liquidity_management_request(client_node_id); + + let expected_notifications = vec![ + WebhookNotificationMethod::LSPS5OnionMessageIncoming, + WebhookNotificationMethod::LSPS5PaymentIncoming, + WebhookNotificationMethod::LSPS5ExpirySoon { timeout: 1000 }, + WebhookNotificationMethod::LSPS5LiquidityManagementRequest, + ]; + + for expected_method in expected_notifications { + let event = service_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + url, + headers, + notification, + .. + }) = event + { + assert_eq!(url.as_str(), webhook_url); + assert_eq!(notification.method, expected_method); + let (timestamp, signature) = extract_ts_sig(&headers); + + let parse_result = + validator.validate(service_node_id, ×tamp, &signature, ¬ification); + assert!(parse_result.is_ok(), "Failed to parse {:?} notification", expected_method); + } else { + panic!("Unexpected event: {:?}", event); + } + } +} + +#[test] +fn test_tampered_notification() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, validator) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider), None); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + + let app_name = "OnionApp"; + let webhook_url = "https://www.example.org/onion"; + let _ = client_handler + .set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string()) + .expect("Register webhook request should succeed"); + let set_req = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(set_req, client_node_id).unwrap(); + + // consume initial SendWebhookNotification + let _ = service_node.liquidity_manager.next_event().unwrap(); + + let _ = service_handler.notify_expiry_soon(client_node_id, 700000); + + let event = service_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + url: _, + headers, + notification, + .. + }) = event + { + let notification_json = serde_json::to_string(¬ification).unwrap(); + let mut json_value: serde_json::Value = serde_json::from_str(¬ification_json).unwrap(); + json_value["params"]["timeout"] = serde_json::json!(800000); + let tampered_timeout_json = json_value.to_string(); + + let tampered_notification: WebhookNotification = + serde_json::from_str(&tampered_timeout_json).unwrap(); + let (timestamp, signature) = extract_ts_sig(&headers); + let tampered_result = + validator.validate(service_node_id, ×tamp, &signature, &tampered_notification); + assert_eq!(tampered_result.unwrap_err(), LSPS5ClientError::InvalidSignature); + } else { + panic!("Unexpected event: {:?}", event); + } + + assert!(client_node.liquidity_manager.next_event().is_none()); +} + +#[test] +fn test_bad_signature_notification() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, validator) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider), None); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + + let app_name = "OnionApp"; + let webhook_url = "https://www.example.org/onion"; + let _ = client_handler + .set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string()) + .unwrap(); + let set_req = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(set_req, client_node_id).unwrap(); + + // consume initial SendWebhookNotification + let _ = service_node.liquidity_manager.next_event().unwrap(); + + let _ = service_handler.notify_onion_message_incoming(client_node_id); + + let event = service_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + url: _, + headers, + notification, + .. + }) = event + { + let (timestamp, _) = extract_ts_sig(&headers); + + let invalid_signature = "xdtk1zf63sfn81r6qteymy73mb1b7dspj5kwx46uxwd6c3pu7y3bto"; + let bad_signature_result = + validator.validate(service_node_id, ×tamp, &invalid_signature, ¬ification); + assert!(bad_signature_result.unwrap_err() == LSPS5ClientError::InvalidSignature); + } else { + panic!("Unexpected event: {:?}", event); + } + + assert!(client_node.liquidity_manager.next_event().is_none()); +} + +#[test] +fn test_timestamp_notification_window_validation() { + let mock_time_provider = Arc::new(MockTimeProvider::new( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time before Unix epoch") + .as_secs(), + )); + let time_provider = Arc::::clone(&mock_time_provider); + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, validator) = lsps5_test_setup(nodes, time_provider, None); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + + let app_name = "OnionApp"; + let webhook_url = "https://www.example.org/onion"; + let _ = client_handler + .set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string()) + .expect("Register webhook request should succeed"); + let set_req = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(set_req, client_node_id).unwrap(); + + // consume initial SendWebhookNotification + let _ = service_node.liquidity_manager.next_event().unwrap(); + + let _ = service_handler.notify_onion_message_incoming(client_node_id); + + let expected_method = WebhookNotificationMethod::LSPS5OnionMessageIncoming; + + let event = service_node.liquidity_manager.next_event().unwrap(); + if let LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + url, + notification, + headers, + .. + }) = event + { + assert_eq!(url.as_str(), webhook_url); + assert_eq!(notification.method, expected_method); + let (timestamp, signature) = extract_ts_sig(&headers); + + // 1) past timestamp (current time advanced) + mock_time_provider.advance_time(60 * 60); + let err_past = + validator.validate(service_node_id, ×tamp, &signature, ¬ification).unwrap_err(); + assert!( + matches!(err_past, LSPS5ClientError::InvalidTimestamp), + "Expected InvalidTimestamp error variant, got {:?}", + err_past + ); + + // 2) future timestamp + mock_time_provider.rewind_time(60 * 60 * 2); + let err_future = + validator.validate(service_node_id, ×tamp, &signature, ¬ification).unwrap_err(); + assert!( + matches!(err_future, LSPS5ClientError::InvalidTimestamp), + "Expected InvalidTimestamp error variant, got {:?}", + err_future + ); + } else { + panic!("Unexpected event: {:?}", event); + } + + assert!(client_node.liquidity_manager.next_event().is_none()); +} + +#[test] +fn test_notify_without_webhooks_does_nothing() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, _) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider), Some(0)); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let client_node_id = client_node.inner.node.get_our_node_id(); + + let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + + // without ever registering a webhook -> both notifiers should early-return + let _ = service_handler.notify_payment_incoming(client_node_id); + assert!(service_node.liquidity_manager.next_event().is_none()); + + let _ = service_handler.notify_onion_message_incoming(client_node_id); + assert!(service_node.liquidity_manager.next_event().is_none()); +} + +#[test] +fn no_replay_error_when_signature_storage_is_disabled() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, validator) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider), Some(0)); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + + let app_name = "test app"; + let webhook_url = "https://www.example.org/webhook?token=replay123"; + + let _ = client_handler + .set_webhook(service_node_id, app_name.to_string(), webhook_url.to_string()) + .expect("Register webhook request should succeed"); + let request = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(request, client_node_id).unwrap(); + + // consume initial SendWebhookNotification + let _ = service_node.liquidity_manager.next_event().unwrap(); + + let response = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(response, service_node_id).unwrap(); + + let _ = client_node.liquidity_manager.next_event().unwrap(); + + let _ = service_handler.notify_payment_incoming(client_node_id); + + let notification_event = service_node.liquidity_manager.next_event().unwrap(); + let (timestamp, signature, body) = match notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { + headers, + notification, + .. + }) => { + let (timestamp, signature) = extract_ts_sig(&headers); + (timestamp, signature, notification) + }, + _ => panic!("Expected SendWebhookNotification event"), + }; + + // max_signatures is set to 0, so there is no replay attack prevention + // and the same notification can be parsed multiple times without error + for _ in 0..4 { + let result = validator.validate(service_node_id, ×tamp, &signature, &body); + assert!(result.is_ok(), "Verification should succeed because storage is disabled"); + } +}