From d64e8d54fab36515adf5d553574901c687b409b7 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 27 Mar 2025 16:58:40 -0300 Subject: [PATCH 01/18] Separate time functionality into dedicated feature flag - Add 'time' feature flag to allow disabling time-dependent functionality - Include 'time' in default features - Allow users to disable SystemTime::now without disabling all std features - Follows pattern established in other crates (e.g., lightning-transaction-sync) - Improves compatibility with WASM environments --- 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..876cd202854 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 = [] backtrace = ["dep:backtrace"] [dependencies] From b43d53ddf48b5f8679fcc5a254b8d4bc2fb08f3b Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 27 Mar 2025 17:24:17 -0300 Subject: [PATCH 02/18] Prefactor - Improvements on LSPSDateTime - do new_from_duration_since_epoch (instead of From) - Avoid doing ambiguous timestamp types - Add abs_diff function to use on client / service --- lightning-liquidity/src/lsps0/ser.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lightning-liquidity/src/lsps0/ser.rs b/lightning-liquidity/src/lsps0/ser.rs index c916cf13e09..0a865935b2e 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -23,12 +23,14 @@ use crate::lsps2::msgs::{ }; use crate::prelude::HashMap; +use chrono::DateTime; use lightning::ln::msgs::{DecodeError, LightningError}; use lightning::ln::wire; use lightning::util::ser::{LengthLimitedRead, LengthReadable, WithoutLength}; use bitcoin::secp256k1::PublicKey; +use core::time::Duration; #[cfg(feature = "std")] use std::time::{SystemTime, UNIX_EPOCH}; @@ -214,6 +216,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(DateTime::UNIX_EPOCH + duration) + } } impl FromStr for LSPSDateTime { From 07cfade3a8dbd02745591dac3facd435c59e52d3 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 20 Mar 2025 17:56:31 -0300 Subject: [PATCH 03/18] Add custom URL parser for LSPS5. Adds a new url_utils.rs module that provides: - A lightweight URL parser specialized for LSPS5 webhook validation - An implementation focusing on scheme and host extraction - RFC-compliant scheme validation - Tests for various URL scenarios This implementation allows validating webhook URLs without depending on the external url crate --- lightning-liquidity/src/lsps5/url_utils.rs | 438 +++++++++++++++++++++ 1 file changed, 438 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..dc98f7cd344 --- /dev/null +++ b/lightning-liquidity/src/lsps5/url_utils.rs @@ -0,0 +1,438 @@ +// 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 crate::alloc::string::ToString; +use alloc::string::String; + +/// A URL implementation for scheme and host extraction +/// Simplified representation of a URL with just scheme and host components. +/// This struct provides parsing and access to these core parts of a URL string. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LSPSUrl { + scheme: String, + host: String, + url: String, +} + +/// Implementation of methods for the Url struct +impl LSPSUrl { + /// Parses a URL string into a URL instance + /// Extracts the scheme and host from any standard URL + /// + /// # 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: &str) -> Result { + let (scheme, remainder) = url_str + .split_once("://") + .ok_or_else(|| "URL must contain scheme separator '://'".to_string())?; + + if !is_valid_scheme(scheme) { + return Err(format!("Invalid URL scheme: {}", scheme)); + } + + if remainder.is_empty() { + return Err("URL host cannot be empty".to_string()); + } + + let host_section = remainder + .split(|c| c == '/' || c == '?' || c == '#') + .next() + .ok_or_else(|| "Failed to extract host section from URL".to_string())?; + + let clean_host = host_section + .split('@') + .last() + .filter(|s| !s.is_empty()) + .ok_or_else(|| "URL host cannot be empty".to_string())?; + + let final_host = if let Some((hostname, port)) = clean_host.rsplit_once(':') { + if hostname.is_empty() { + return Err("Hostname cannot be empty".to_string()); + } + if !port.is_empty() && port.parse::().is_err() { + return Err(format!("Invalid port: {}", port)); + } + format!("{}:{}", hostname, port) + } else { + clean_host.to_string() + }; + + Ok(LSPSUrl { scheme: scheme.to_string(), host: final_host, url: url_str.to_string() }) + } + + /// Returns the scheme part of the URL (http, https, etc.) + pub fn scheme(&self) -> &str { + &self.scheme + } + + /// Returns the host as an Option, None if empty + pub fn host(&self) -> Option<&str> { + (!self.host.is_empty()).then(|| self.host.as_str()) + } + + /// Returns the full URL string + pub fn url(&self) -> &str { + &self.url + } +} + +/// Validates a URL scheme according to RFC specifications +/// +/// According to RFC 1738, a scheme must: +/// 1. Start with a letter (a-z, A-Z) +/// 2. Contain only letters, digits, plus (+), period (.), or hyphen (-) +fn is_valid_scheme(scheme: &str) -> bool { + if scheme.is_empty() { + return false; + } + + let mut chars = scheme.chars(); + + if !chars.next().map_or(false, |c| c.is_ascii_alphabetic()) { + return false; + } + + chars.all(|c| c.is_ascii_alphabetic() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_parse_url_with_query_params() { + let url_str = "https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("www.example.org")); + } + + #[test] + fn test_parse_https_url() { + let url_str = "https://example.com/path"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_http_url() { + let url_str = "http://example.com/path"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "http"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_url_with_no_path() { + let url_str = "https://example.com"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_url_with_port() { + let url_str = "https://example.com:8080/path"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("example.com:8080")); + } + + #[test] + fn test_parse_url_with_subdomain_and_path() { + let url_str = "https://api.example.com/v1/resources"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("api.example.com")); + } + + #[test] + fn test_invalid_url_no_scheme() { + let url_str = "example.com/path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_invalid_url_empty_host() { + let url_str = "https:///path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_parse_protocol_with_path() { + let url_str = "ftp://ftp.example.org/pub/files/document.pdf"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "ftp"); + assert_eq!(url.host(), Some("ftp.example.org")); + } + + #[test] + fn test_parse_protocol_with_auth() { + let url_str = "sftp://user:password@sftp.example.com:22/uploads/"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "sftp"); + assert_eq!(url.host(), Some("sftp.example.com:22")); + } + + #[test] + fn test_parse_ssh_url() { + let url_str = "ssh://username@host.com:2222"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "ssh"); + assert_eq!(url.host(), Some("host.com:2222")); + } + + #[test] + fn test_parse_custom_protocol() { + let url_str = "lightning://03a.example.com/invoice?amount=10000"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "lightning"); + assert_eq!(url.host(), Some("03a.example.com")); + } + + #[test] + fn test_parse_url_with_fragment() { + let url_str = "https://example.com/page#section1"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_url_with_query_and_fragment() { + let url_str = "https://example.com/search?q=test#results"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_url_with_username_only() { + let url_str = "ftp://user@ftp.example.com/files/"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "ftp"); + assert_eq!(url.host(), Some("ftp.example.com")); + } + + #[test] + fn test_parse_url_with_credentials() { + let url_str = "http://user:pass@example.com/"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "http"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_url_with_ipv4_host() { + let url_str = "http://192.168.1.1/admin"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "http"); + assert_eq!(url.host(), Some("192.168.1.1")); + } + + #[test] + fn test_check_https_scheme() { + let url_str = "https://example.com/path"; + let url = LSPSUrl::parse(url_str).unwrap(); + assert_eq!(url.scheme(), "https"); + + let url_str = "http://example.com/path"; + let url = LSPSUrl::parse(url_str).unwrap(); + assert_ne!(url.scheme(), "https"); + } + + #[test] + fn test_empty_remainder_error() { + let url_str = "https://"; + let result = LSPSUrl::parse(url_str); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "URL host cannot be empty"); + } + + #[test] + fn test_malformed_scheme_chars() { + let url_str = "ht@ps://example.com"; + let result = LSPSUrl::parse(url_str); + assert!(result.is_err()); + + let url_str = "http!://example.com"; + let result = LSPSUrl::parse(url_str); + assert!(result.is_err()); + } + + // Update this test since the RFC requires schemes to start with a letter + #[test] + fn test_scheme_starting_with_digit() { + let url_str = "1https://example.com"; + let result = LSPSUrl::parse(url_str); + + // According to RFC, schemes must start with a letter + assert!(result.is_err()); + } + + #[test] + fn test_valid_scheme_chars() { + let valid_schemes = vec![ + "http", + "https", + "ftp", + "sftp", + "ssh", + "h123", + "scheme-with-dash", + "scheme.with.dots", + "scheme+plus", + ]; + + for scheme in valid_schemes { + let url_str = format!("{}://example.com", scheme); + let result = LSPSUrl::parse(&url_str); + assert!(result.is_ok(), "Valid scheme '{}' was rejected", scheme); + assert_eq!(result.unwrap().scheme(), scheme); + } + } + + #[test] + fn test_extremely_long_url() { + let host = "a".repeat(10000); + let url_str = format!("https://{}/path", host); + let result = LSPSUrl::parse(&url_str); + + assert!(result.is_ok()); + let url = result.unwrap(); + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host().unwrap().len(), 10000); + } + + #[test] + fn test_unicode_characters() { + let url_str = "https://例子.测试/path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + let url = result.unwrap(); + assert_eq!(url.host(), Some("例子.测试")); + } + + #[test] + fn test_weird_but_valid_scheme() { + let url_str = "a123+-.://example.com"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().scheme(), "a123+-."); + } + + #[test] + fn test_url_with_spaces() { + let url_str = "https://example.com/path with spaces"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().host(), Some("example.com")); + + let url_str = "https://bad domain.com/"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().host(), Some("bad domain.com")); + } + + #[test] + fn test_multiple_scheme_separators() { + let url_str = "https://example.com://path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + + let url_str = "https://://example.com"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_invalid_port() { + let url_str = "https://example.com:port/path"; + let result = LSPSUrl::parse(url_str); + + assert!(!result.is_ok()); + + let url_str = "https://example.com:65536/path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().host(), Some("example.com:65536")); + } + + #[test] + fn test_missing_host_domain() { + let url_str = "https://:8080/path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_scheme_only() { + let url_str = "https:"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_null_characters() { + let url_str = "https://example.com\0/path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().host(), Some("example.com\0")); + } + + #[test] + fn test_url_with_backslashes() { + let url_str = "https:\\\\example.com\\path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_just_scheme_and_authority_markers() { + let url_str = "://"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } +} From 69abc99347161a3240689e9274b9e21a8fdf9784 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 17 Apr 2025 19:07:31 -0300 Subject: [PATCH 04/18] fixup: Use UntrustedString and simplify LSPSUrl API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace String fields with UntrustedString wrapper - Change parse to take owned String and return Err(()) - Simplify error handling, drop free‑form error messages - Add is_https(), url_length(), is_public() helpers - Make url() public, remove scheme()/host() getters - Clean up parsing logic to avoid extra allocations - Update tests to use .to_string() and new API - Add proptest for robust property‑based testing - Remove redundant comments and dead code --- lightning-liquidity/src/lsps5/url_utils.rs | 371 +++++++++++++-------- 1 file changed, 234 insertions(+), 137 deletions(-) diff --git a/lightning-liquidity/src/lsps5/url_utils.rs b/lightning-liquidity/src/lsps5/url_utils.rs index dc98f7cd344..bdefb9fc7d8 100644 --- a/lightning-liquidity/src/lsps5/url_utils.rs +++ b/lightning-liquidity/src/lsps5/url_utils.rs @@ -7,96 +7,120 @@ // You may not use this file except in accordance with one or both of these // licenses. -//! URL utilities for LSPS5 webhook notifications +//! URL utilities for LSPS5 webhook notifications. use crate::alloc::string::ToString; use alloc::string::String; +use lightning_types::string::UntrustedString; -/// A URL implementation for scheme and host extraction +/// A URL implementation for scheme and host extraction. /// Simplified representation of a URL with just scheme and host components. /// This struct provides parsing and access to these core parts of a URL string. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct LSPSUrl { - scheme: String, - host: String, - url: String, + scheme: UntrustedString, + host: UntrustedString, + /// The full URL string. + pub url: UntrustedString, } -/// Implementation of methods for the Url struct impl LSPSUrl { - /// Parses a URL string into a URL instance - /// Extracts the scheme and host from any standard URL + /// Parses a URL string into a URL instance. + /// Extracts the scheme and host from any standard URL. /// /// # 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: &str) -> Result { - let (scheme, remainder) = url_str - .split_once("://") - .ok_or_else(|| "URL must contain scheme separator '://'".to_string())?; + /// A Result containing either the parsed URL or an error message. + pub fn parse(url_str: String) -> Result { + if !url_str.is_ascii() { + return Err(()); + } + + let (scheme, remainder) = url_str.split_once("://").ok_or_else(|| ())?; if !is_valid_scheme(scheme) { - return Err(format!("Invalid URL scheme: {}", scheme)); + return Err(()); } - if remainder.is_empty() { - return Err("URL host cannot be empty".to_string()); - } + let host_section = remainder.split(['/', '?', '#']).next().ok_or_else(|| ())?; - let host_section = remainder - .split(|c| c == '/' || c == '?' || c == '#') - .next() - .ok_or_else(|| "Failed to extract host section from URL".to_string())?; + let host_without_auth = + host_section.split('@').next_back().filter(|s| !s.is_empty()).ok_or_else(|| ())?; - let clean_host = host_section - .split('@') - .last() - .filter(|s| !s.is_empty()) - .ok_or_else(|| "URL host cannot be empty".to_string())?; + if host_without_auth.is_empty() { + return Err(()); + } - let final_host = if let Some((hostname, port)) = clean_host.rsplit_once(':') { - if hostname.is_empty() { - return Err("Hostname cannot be empty".to_string()); - } - if !port.is_empty() && port.parse::().is_err() { - return Err(format!("Invalid port: {}", port)); - } - format!("{}:{}", hostname, port) - } else { - clean_host.to_string() + match host_without_auth.rsplit_once(':') { + Some((hostname, _port)) if hostname.is_empty() => return Err(()), + Some((_hostname, port)) if !port.is_empty() && port.parse::().is_err() => { + return Err(()) + }, + _ => (), }; - Ok(LSPSUrl { scheme: scheme.to_string(), host: final_host, url: url_str.to_string() }) + Ok(LSPSUrl { + scheme: UntrustedString(scheme.to_string()), + host: UntrustedString(host_without_auth.to_string()), + url: UntrustedString(url_str.to_string()), + }) + } + + /// Returns if the URL scheme is "https". + pub fn is_https(&self) -> bool { + self.scheme.0 == "https" } - /// Returns the scheme part of the URL (http, https, etc.) - pub fn scheme(&self) -> &str { - &self.scheme + /// Returns URL length. + pub fn url_length(&self) -> usize { + self.url.0.chars().count() } - /// Returns the host as an Option, None if empty - pub fn host(&self) -> Option<&str> { - (!self.host.is_empty()).then(|| self.host.as_str()) + /// Returns whether the URL points to a public host. + /// + /// A host is considered non-public if it is: + /// - "localhost", or loopback addresses ("127.*", "::1") + /// - in the private range 10.* + /// - in the private range 192.168.* + /// - in the private range 172.16.0.0 to 172.31.255.255 + pub fn is_public(&self) -> bool { + let host = self.host.0.clone(); + + if host == "localhost" || host.starts_with("127.") || host == "::1" { + return false; + } + + if host.starts_with("10.") || host.starts_with("192.168.") { + return false; + } + + if host.starts_with("172.") { + if let Some(second_octet) = host.split('.').nth(1) { + if let Ok(num) = second_octet.parse::() { + if (16..=31).contains(&num) { + return false; + } + } + } + } + + true } - /// Returns the full URL string + /// Returns the full URL string. pub fn url(&self) -> &str { - &self.url + self.url.0.as_str() } } -/// Validates a URL scheme according to RFC specifications +/// Validates a URL scheme according to RFC specifications. /// /// According to RFC 1738, a scheme must: /// 1. Start with a letter (a-z, A-Z) /// 2. Contain only letters, digits, plus (+), period (.), or hyphen (-) fn is_valid_scheme(scheme: &str) -> bool { - if scheme.is_empty() { - return false; - } - let mut chars = scheme.chars(); if !chars.next().map_or(false, |c| c.is_ascii_alphabetic()) { @@ -109,63 +133,67 @@ fn is_valid_scheme(scheme: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use alloc::vec::Vec; + use proptest::prelude::*; + #[test] fn test_parse_url_with_query_params() { - let url_str = "https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best"; + let url_str = + "https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - - assert_eq!(url.scheme(), "https"); - assert_eq!(url.host(), Some("www.example.org")); + assert!(url.is_https()); + assert_eq!(url.scheme.0, "https"); + assert_eq!(url.host.0, "www.example.org"); } #[test] fn test_parse_https_url() { - let url_str = "https://example.com/path"; + let url_str = "https://example.com/path".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - - assert_eq!(url.scheme(), "https"); - assert_eq!(url.host(), Some("example.com")); + assert!(url.is_https()); + assert_eq!(url.scheme.0, "https"); + assert_eq!(url.host.0, "example.com"); } #[test] fn test_parse_http_url() { - let url_str = "http://example.com/path"; + let url_str = "http://example.com/path".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - - assert_eq!(url.scheme(), "http"); - assert_eq!(url.host(), Some("example.com")); + assert!(!url.is_https()); + assert_eq!(url.scheme.0, "http"); + assert_eq!(url.host.0, "example.com"); } #[test] fn test_parse_url_with_no_path() { - let url_str = "https://example.com"; + let url_str = "https://example.com".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - - assert_eq!(url.scheme(), "https"); - assert_eq!(url.host(), Some("example.com")); + assert!(url.is_https()); + assert_eq!(url.scheme.0, "https"); + assert_eq!(url.host.0, "example.com"); } #[test] fn test_parse_url_with_port() { - let url_str = "https://example.com:8080/path"; + let url_str = "https://example.com:8080/path".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "https"); - assert_eq!(url.host(), Some("example.com:8080")); + assert_eq!(url.scheme.0, "https"); + assert_eq!(url.host.0, "example.com:8080"); } #[test] fn test_parse_url_with_subdomain_and_path() { - let url_str = "https://api.example.com/v1/resources"; + let url_str = "https://api.example.com/v1/resources".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "https"); - assert_eq!(url.host(), Some("api.example.com")); + assert_eq!(url.scheme.0, "https"); + assert_eq!(url.host.0, "api.example.com"); } #[test] fn test_invalid_url_no_scheme() { - let url_str = "example.com/path"; + let url_str = "example.com/path".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_err()); @@ -173,7 +201,7 @@ mod tests { #[test] fn test_invalid_url_empty_host() { - let url_str = "https:///path"; + let url_str = "https:///path".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_err()); @@ -181,111 +209,110 @@ mod tests { #[test] fn test_parse_protocol_with_path() { - let url_str = "ftp://ftp.example.org/pub/files/document.pdf"; + let url_str = "ftp://ftp.example.org/pub/files/document.pdf".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "ftp"); - assert_eq!(url.host(), Some("ftp.example.org")); + assert_eq!(url.scheme.0, "ftp"); + assert_eq!(url.host.0, "ftp.example.org"); } #[test] fn test_parse_protocol_with_auth() { - let url_str = "sftp://user:password@sftp.example.com:22/uploads/"; + let url_str = "sftp://user:password@sftp.example.com:22/uploads/".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "sftp"); - assert_eq!(url.host(), Some("sftp.example.com:22")); + assert_eq!(url.scheme.0, "sftp"); + assert_eq!(url.host.0, "sftp.example.com:22"); } #[test] fn test_parse_ssh_url() { - let url_str = "ssh://username@host.com:2222"; + let url_str = "ssh://username@host.com:2222".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "ssh"); - assert_eq!(url.host(), Some("host.com:2222")); + assert_eq!(url.scheme.0, "ssh"); + assert_eq!(url.host.0, "host.com:2222"); } #[test] fn test_parse_custom_protocol() { - let url_str = "lightning://03a.example.com/invoice?amount=10000"; + let url_str = "lightning://03a.example.com/invoice?amount=10000".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "lightning"); - assert_eq!(url.host(), Some("03a.example.com")); + assert_eq!(url.scheme.0, "lightning"); + assert_eq!(url.host.0, "03a.example.com"); } #[test] fn test_parse_url_with_fragment() { - let url_str = "https://example.com/page#section1"; + let url_str = "https://example.com/page#section1".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "https"); - assert_eq!(url.host(), Some("example.com")); + assert_eq!(url.scheme.0, "https"); + assert_eq!(url.host.0, "example.com"); } #[test] fn test_parse_url_with_query_and_fragment() { - let url_str = "https://example.com/search?q=test#results"; + let url_str = "https://example.com/search?q=test#results".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "https"); - assert_eq!(url.host(), Some("example.com")); + assert_eq!(url.scheme.0, "https"); + assert_eq!(url.host.0, "example.com"); } #[test] fn test_parse_url_with_username_only() { - let url_str = "ftp://user@ftp.example.com/files/"; + let url_str = "ftp://user@ftp.example.com/files/".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "ftp"); - assert_eq!(url.host(), Some("ftp.example.com")); + assert_eq!(url.scheme.0, "ftp"); + assert_eq!(url.host.0, "ftp.example.com"); } #[test] fn test_parse_url_with_credentials() { - let url_str = "http://user:pass@example.com/"; + let url_str = "http://user:pass@example.com/".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "http"); - assert_eq!(url.host(), Some("example.com")); + assert_eq!(url.scheme.0, "http"); + assert_eq!(url.host.0, "example.com"); } #[test] fn test_parse_url_with_ipv4_host() { - let url_str = "http://192.168.1.1/admin"; + let url_str = "http://192.168.1.1/admin".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "http"); - assert_eq!(url.host(), Some("192.168.1.1")); + assert_eq!(url.scheme.0, "http"); + assert_eq!(url.host.0, "192.168.1.1"); } #[test] fn test_check_https_scheme() { - let url_str = "https://example.com/path"; + let url_str = "https://example.com/path".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_eq!(url.scheme(), "https"); + assert_eq!(url.scheme.0, "https"); - let url_str = "http://example.com/path"; + let url_str = "http://example.com/path".to_string(); let url = LSPSUrl::parse(url_str).unwrap(); - assert_ne!(url.scheme(), "https"); + assert_ne!(url.scheme.0, "https"); } #[test] fn test_empty_remainder_error() { - let url_str = "https://"; + let url_str = "https://".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_err()); - assert_eq!(result.unwrap_err(), "URL host cannot be empty"); } #[test] fn test_malformed_scheme_chars() { - let url_str = "ht@ps://example.com"; + let url_str = "ht@ps://example.com".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_err()); - let url_str = "http!://example.com"; + let url_str = "http!://example.com".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_err()); } @@ -293,7 +320,7 @@ mod tests { // Update this test since the RFC requires schemes to start with a letter #[test] fn test_scheme_starting_with_digit() { - let url_str = "1https://example.com"; + let url_str = "1https://example.com".to_string(); let result = LSPSUrl::parse(url_str); // According to RFC, schemes must start with a letter @@ -316,66 +343,64 @@ mod tests { for scheme in valid_schemes { let url_str = format!("{}://example.com", scheme); - let result = LSPSUrl::parse(&url_str); + let result = LSPSUrl::parse(UntrustedString(url_str).to_string()); assert!(result.is_ok(), "Valid scheme '{}' was rejected", scheme); - assert_eq!(result.unwrap().scheme(), scheme); + assert_eq!(result.unwrap().scheme.0, scheme); } } #[test] fn test_extremely_long_url() { let host = "a".repeat(10000); - let url_str = format!("https://{}/path", host); - let result = LSPSUrl::parse(&url_str); + let url_str = format!("https://{}/path", host).to_string(); + let result = LSPSUrl::parse(url_str); assert!(result.is_ok()); let url = result.unwrap(); - assert_eq!(url.scheme(), "https"); - assert_eq!(url.host().unwrap().len(), 10000); + assert_eq!(url.scheme.0, "https"); + assert_eq!(url.host.0.chars().count(), 10000); } #[test] fn test_unicode_characters() { - let url_str = "https://例子.测试/path"; + let url_str = "https://例子.测试/path".to_string(); let result = LSPSUrl::parse(url_str); - assert!(result.is_ok()); - let url = result.unwrap(); - assert_eq!(url.host(), Some("例子.测试")); + assert!(result.is_err()); } #[test] fn test_weird_but_valid_scheme() { - let url_str = "a123+-.://example.com"; + let url_str = "a123+-.://example.com".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_ok()); - assert_eq!(result.unwrap().scheme(), "a123+-."); + assert_eq!(result.unwrap().scheme.0, "a123+-."); } #[test] fn test_url_with_spaces() { - let url_str = "https://example.com/path with spaces"; + let url_str = "https://example.com/path with spaces".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_ok()); - assert_eq!(result.unwrap().host(), Some("example.com")); + assert_eq!(result.unwrap().host.0, "example.com"); - let url_str = "https://bad domain.com/"; + let url_str = "https://bad domain.com/".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_ok()); - assert_eq!(result.unwrap().host(), Some("bad domain.com")); + assert_eq!(result.unwrap().host.0, "bad domain.com"); } #[test] fn test_multiple_scheme_separators() { - let url_str = "https://example.com://path"; + let url_str = "https://example.com://path".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_ok()); - let url_str = "https://://example.com"; + let url_str = "https://://example.com".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_err()); @@ -383,21 +408,21 @@ mod tests { #[test] fn test_invalid_port() { - let url_str = "https://example.com:port/path"; + let url_str = "https://example.com:port/path".to_string(); let result = LSPSUrl::parse(url_str); assert!(!result.is_ok()); - let url_str = "https://example.com:65536/path"; + let url_str = "https://example.com:65536/path".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_ok()); - assert_eq!(result.unwrap().host(), Some("example.com:65536")); + assert_eq!(result.unwrap().host.0, "example.com:65536"); } #[test] fn test_missing_host_domain() { - let url_str = "https://:8080/path"; + let url_str = "https://:8080/path".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_err()); @@ -405,7 +430,7 @@ mod tests { #[test] fn test_scheme_only() { - let url_str = "https:"; + let url_str = "https:".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_err()); @@ -413,16 +438,16 @@ mod tests { #[test] fn test_null_characters() { - let url_str = "https://example.com\0/path"; + let url_str = "https://example.com\0/path".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_ok()); - assert_eq!(result.unwrap().host(), Some("example.com\0")); + assert_eq!(result.unwrap().host.0, "example.com\0"); } #[test] fn test_url_with_backslashes() { - let url_str = "https:\\\\example.com\\path"; + let url_str = "https:\\\\example.com\\path".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_err()); @@ -430,9 +455,81 @@ mod tests { #[test] fn test_just_scheme_and_authority_markers() { - let url_str = "://"; + let url_str = "://".to_string(); let result = LSPSUrl::parse(url_str); assert!(result.is_err()); } + + proptest! { + /// For any valid URL matching the regex: if it parses, then + /// - round-trip .url() == original, + /// - url_length() == .chars().count() + /// - original starts with "{scheme}://", + /// - host and scheme are non-empty and substrings of the original, + /// - host never empty, + /// - port (if present) is numeric, + /// - IPv4 hosts match expected pattern, + /// - is_public() is correct for localhost/private IPs, + /// - is_https() is correct for https scheme. + #[test] + fn test_url_properties( + url_str in proptest::string::string_regex( + r"([a-z][a-z0-9+.-]*)://((?:[a-z0-9._~%!$&()*+,;=-]+@)?(?:localhost|\d{1,3}(?:\.\d{1,3}){3}|\[[a-fA-F0-9:.]+\]|[a-z0-9._~%+-]+(?:\.[a-z0-9._~%+-]+)*))(?::\d{1,5})?(/[a-z0-9._~%!$&()*+,;=:@/-]*)?(\?[a-z0-9._~%!$&()*+,;=:@/?-]*)?(\#[a-z0-9._~%!$&()*+,;=:@/?-]*)?" + ).unwrap() + ) { + if let Ok(u) = LSPSUrl::parse(url_str.to_string()) { + prop_assert_eq!(u.url(), url_str.clone()); + prop_assert_eq!(u.url_length(), url_str.chars().count()); + let scheme_prefix = format!("{}://", u.scheme); + prop_assert!(url_str.starts_with(&scheme_prefix)); + + prop_assert!(!u.scheme.0.is_empty()); + prop_assert!(!u.host.0.is_empty()); + prop_assert!(url_str.contains(&u.scheme.0)); + prop_assert!(url_str.contains(&u.host.0)); + + prop_assert!(!u.host.0.is_empty()); + + if let Some(idx) = u.host.0.rfind(':') { + let (host_part, port_part) = u.host.0.split_at(idx); + if !host_part.is_empty() && port_part.len() > 1 { + let port_str = &port_part[1..]; + prop_assert!(port_str.chars().all(|c| c.is_ascii_digit())); + // Port must be in 0..=u32::MAX (parseable as u32) + prop_assert!(port_str.parse::().is_ok()); + } + } + + if u.host.0.chars().all(|c| c.is_ascii_digit() || c == '.') && u.host.0.matches('.').count() == 3 { + let octets: Vec<_> = u.host.0.split('.').collect(); + prop_assert_eq!(octets.len(), 4); + for octet in octets { + prop_assert!(!octet.is_empty()); + } + } + if u.host.0 == "localhost" || u.host.0.starts_with("127.") || u.host.0 == "::1" { + prop_assert!(!u.is_public()); + } + if u.host.0.starts_with("10.") || u.host.0.starts_with("192.168.") { + prop_assert!(!u.is_public()); + } + if u.host.0.starts_with("172.") { + if let Some(second_octet) = u.host.0.split('.').nth(1) { + if let Ok(num) = second_octet.parse::() { + if (16..=31).contains(&num) { + prop_assert!(!u.is_public()); + } + } + } + } + + if u.scheme.0 == "https" { + prop_assert!(u.is_https()); + } else { + prop_assert_eq!(u.is_https(), u.scheme.0 == "https"); + } + } + } + } } From 31969ed383f8fb4d717cd59e29e16dc90003c766 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Tue, 11 Mar 2025 16:56:33 -0300 Subject: [PATCH 05/18] Add messaging types for webhook operations - Define LSPS5Request and LSPS5Response enums for webhook registration, listing, and removal. - Implement WebhookNotification and associated helper constructors for different notification types. - Implement serialization/deserialization support with comprehensive tests. - Improve LSPS5 message types, validation, and testing - Replace generic String types with strongly-typed Lsps5AppName and Lsps5WebhookUrl with built-in length and format validation - Restructure imports to follow one-per-line convention - Add constants for notification method strings - Make WebhookNotificationMethod enum more consistent with LSPS5 prefix - Use explicit serde_json::json and serde_json::Value instead of imports - Improve code documentation with proper ticks and references - Add comprehensive test vectors from the BLIP-0055 specification --- lightning-liquidity/src/lsps5/msgs.rs | 709 ++++++++++++++++++++++++++ 1 file changed, 709 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..1d91d767198 --- /dev/null +++ b/lightning-liquidity/src/lsps5/msgs.rs @@ -0,0 +1,709 @@ +// 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 core::fmt; +use core::fmt::Display; + +use crate::alloc::string::ToString; +use crate::lsps0::ser::LSPSMessage; +use crate::lsps0::ser::LSPSRequestId; +use crate::lsps0::ser::LSPSResponseError; +use alloc::string::String; +use alloc::vec::Vec; +use serde::ser::SerializeMap; +use serde::{Deserialize, Serialize}; + +use super::url_utils::LSPSUrl; + +/// 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; + +pub(crate) const LSPS5_TOO_LONG_ERROR_CODE: i32 = 500; +pub(crate) const LSPS5_URL_PARSE_ERROR_CODE: i32 = 501; +pub(crate) const LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE: i32 = 502; +pub(crate) const LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE: i32 = 503; +pub(crate) const LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE: i32 = 1010; + +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"; + +/// App name for LSPS5 webhooks (max 64 bytes UTF-8) +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LSPS5AppName(String); + +impl LSPS5AppName { + /// Create a new LSPS5 app name + pub fn new(app_name: String) -> Result { + let lsps5_app_name = Self(app_name); + + match lsps5_app_name.validate() { + Ok(()) => Ok(lsps5_app_name), + Err(e) => Err(e), + } + } + + /// Validate the app name + pub fn validate(&self) -> Result<(), LSPSResponseError> { + if self.0.len() > MAX_APP_NAME_LENGTH { + return Err(LSPSResponseError { + code: LSPS5_TOO_LONG_ERROR_CODE, + message: format!( + "App name exceeds maximum length of {} bytes", + MAX_APP_NAME_LENGTH + ), + data: None, + }); + } + + Ok(()) + } +} + +impl Display for LSPS5AppName { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for LSPS5AppName { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for LSPS5AppName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::new(s).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) + } +} + +impl AsRef for LSPS5AppName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for String { + fn from(app_name: LSPS5AppName) -> Self { + app_name.0 + } +} + +/// URL for LSPS5 webhooks (max 1024 ASCII chars) +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LSPS5WebhookUrl(LSPSUrl); + +impl LSPS5WebhookUrl { + /// Create a new LSPS5 webhook URL + pub fn new(url: String) -> Result { + let parsed_url = match LSPSUrl::parse(&url) { + Ok(url) => url, + Err(e) => { + return Err(LSPSResponseError { + code: LSPS5_URL_PARSE_ERROR_CODE, + message: format!("Error parsing URL: {:?}", e), + data: None, + }); + }, + }; + + let lsps5_webhook_url = Self(parsed_url); + + match lsps5_webhook_url.validate() { + Ok(()) => Ok(lsps5_webhook_url), + Err(e) => Err(e), + } + } + + /// Validate the URL + pub fn validate(&self) -> Result<(), LSPSResponseError> { + let url_str = self.0.url(); + + if url_str.len() > MAX_WEBHOOK_URL_LENGTH { + return Err(LSPSResponseError { + code: LSPS5_TOO_LONG_ERROR_CODE, + message: format!( + "Webhook URL exceeds maximum length of {} bytes", + MAX_WEBHOOK_URL_LENGTH + ), + data: None, + }); + } + + if !url_str.is_ascii() { + return Err(LSPSResponseError { + code: LSPS5_URL_PARSE_ERROR_CODE, + message: "Webhook URL must be ASCII".to_string(), + data: None, + }); + } + + if self.0.scheme() != "https" { + return Err(LSPSResponseError { + code: LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE, + message: format!("Unsupported protocol: {}. HTTPS is required.", self.0.scheme()), + data: None, + }); + } + + // Check that URL has a host + if self.0.host().is_none() { + return Err(LSPSResponseError { + code: LSPS5_URL_PARSE_ERROR_CODE, + message: "URL must have a host".to_string(), + data: None, + }); + } + + // Check for localhost and private IPs + if let Some(host) = self.0.host() { + if host == "localhost" || host.starts_with("127.") || host == "::1" { + return Err(LSPSResponseError { + code: LSPS5_URL_PARSE_ERROR_CODE, + message: "URL must not point to localhost".to_string(), + data: None, + }); + } + + if host.starts_with("10.") + || host.starts_with("192.168.") + || (host.starts_with("172.") && { + if let Some(second_octet) = host.split('.').nth(1) { + if let Ok(num) = second_octet.parse::() { + (16..=31).contains(&num) + } else { + false + } + } else { + false + } + }) { + return Err(LSPSResponseError { + code: LSPS5_URL_PARSE_ERROR_CODE, + message: "URL must not point to private IP ranges".to_string(), + data: None, + }); + } + } + + Ok(()) + } + + /// Get the URL as a string + pub fn as_str(&self) -> &str { + self.0.url() + } +} + +impl Serialize for LSPS5WebhookUrl { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.0.url()) + } +} + +impl<'de> Deserialize<'de> for LSPS5WebhookUrl { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::new(s).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) + } +} + +impl AsRef for LSPS5WebhookUrl { + fn as_ref(&self) -> &str { + self.0.url() + } +} + +impl From for String { + fn from(url: LSPS5WebhookUrl) -> Self { + url.0.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 (max 64 bytes) + pub app_name: LSPS5AppName, + /// URL of the webhook (max 1024 ASCII chars) + 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 (empty) +#[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` (empty) +#[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 { + /// JSON-RPC version (must be "2.0") + pub jsonrpc: String, + /// Notification method with parameters + pub method: WebhookNotificationMethod, +} + +impl WebhookNotification { + /// Create a new webhook notification + pub fn new(method: WebhookNotificationMethod) -> Self { + Self { jsonrpc: "2.0".to_string(), 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", &self.jsonrpc)?; + + 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: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Helper { + jsonrpc: String, + method: String, + params: serde_json::Value, + } + + let helper = Helper::deserialize(deserializer)?; + + let method = match helper.method.as_str() { + LSPS5_WEBHOOK_REGISTERED_NOTIFICATION => { + WebhookNotificationMethod::LSPS5WebhookRegistered + }, + LSPS5_PAYMENT_INCOMING_NOTIFICATION => WebhookNotificationMethod::LSPS5PaymentIncoming, + LSPS5_EXPIRY_SOON_NOTIFICATION => { + // Directly extract timeout from params + if let Some(timeout) = helper.params.get("timeout").and_then(|t| t.as_u64()) { + WebhookNotificationMethod::LSPS5ExpirySoon { timeout: timeout as u32 } + } else { + return Err(serde::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(serde::de::Error::custom(format!("Unknown method: {}", helper.method))) + }, + }; + + Ok(WebhookNotification { jsonrpc: helper.jsonrpc, method }) + } +} + +/// 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), + /// Error response to [`ListWebhooks`](ListWebhooksRequest) request + ListWebhooksError(LSPSResponseError), + /// 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 = [ + "https://10.0.0.1/webhook", + "https://192.168.1.1/webhook", + "https://172.16.0.1/webhook", + "https://172.31.255.255/webhook", + "https://localhost/webhook", + "test-app", + "http://example.com/webhook", + ]; + + for url_str in urls_that_should_throw.iter() { + match LSPS5WebhookUrl::new(url_str.to_string()) { + Ok(_) => panic!("Expected error"), + Err(e) => { + // error is not null + assert!(e.code != 0); + }, + } + } + } + + #[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"); + } + } + } + } +} From adb190359a41f491a1800e9a3c6c9ce45a2fd846 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 17 Apr 2025 18:58:13 -0300 Subject: [PATCH 06/18] fixup: use UntrustedString for app/webhook, Visitor for deserialization - Use UntrustedString for LSPS5AppName and LSPS5WebhookUrl - Move URL validation to url_utils for consistency - Use chars().count() for app name length check - Remove jsonrpc field from WebhookNotification struct - Always serialize jsonrpc as 2.0 and Visitor for deserialization - Avoid heap allocations in WebhookNotification deserialization - Update tests for new constructors and error types --- lightning-liquidity/src/lsps5/msgs.rs | 509 +++++++++++++++----------- 1 file changed, 291 insertions(+), 218 deletions(-) diff --git a/lightning-liquidity/src/lsps5/msgs.rs b/lightning-liquidity/src/lsps5/msgs.rs index 1d91d767198..7654c34ad1e 100644 --- a/lightning-liquidity/src/lsps5/msgs.rs +++ b/lightning-liquidity/src/lsps5/msgs.rs @@ -11,6 +11,7 @@ use core::fmt; use core::fmt::Display; +use core::ops::Deref; use crate::alloc::string::ToString; use crate::lsps0::ser::LSPSMessage; @@ -18,15 +19,19 @@ use crate::lsps0::ser::LSPSRequestId; use crate::lsps0::ser::LSPSResponseError; use alloc::string::String; use alloc::vec::Vec; +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 super::url_utils::LSPSUrl; -/// Maximum allowed length for an `app_name` (in bytes) +/// 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) +/// Maximum allowed length for a webhook URL (in characters). pub const MAX_WEBHOOK_URL_LENGTH: usize = 1024; pub(crate) const LSPS5_TOO_LONG_ERROR_CODE: i32 = 500; @@ -46,41 +51,125 @@ 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"; -/// App name for LSPS5 webhooks (max 64 bytes UTF-8) -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct LSPS5AppName(String); +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +/// Structured LSPS5 error +pub enum LSPS5Error { + /// The provided input was too long. + TooLong(String), + /// The provided URL could not be parsed. + UrlParse(String), + /// The provided URL used an unsupported protocol. + UnsupportedProtocol(String), + /// The provided URL contained too many webhooks. + TooManyWebhooks(String), + /// The provided URL did not contain an app name. + AppNameNotFound(String), + /// The provided URL contained an app name that was not found. + Other { + /// Numeric code for matching legacy behaviors. + code: i32, + /// Human‐readable message. + message: String, + }, +} -impl LSPS5AppName { - /// Create a new LSPS5 app name - pub fn new(app_name: String) -> Result { - let lsps5_app_name = Self(app_name); +impl Serialize for LSPS5Error { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut m = serializer.serialize_struct("error", 3)?; + m.serialize_field("code", &self.code())?; + m.serialize_field("message", &self.message())?; + m.serialize_field("data", &Option::::None)?; + m.end() + } +} - match lsps5_app_name.validate() { - Ok(()) => Ok(lsps5_app_name), - Err(e) => Err(e), +impl LSPS5Error { + /// Numeric code for matching legacy behaviors + pub fn code(&self) -> i32 { + match self { + LSPS5Error::TooLong(_) => LSPS5_TOO_LONG_ERROR_CODE, + LSPS5Error::UrlParse(_) => LSPS5_URL_PARSE_ERROR_CODE, + LSPS5Error::UnsupportedProtocol(_) => LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE, + LSPS5Error::TooManyWebhooks(_) => LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE, + LSPS5Error::AppNameNotFound(_) => LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE, + LSPS5Error::Other { code, .. } => *code, + } + } + /// Human‐readable message + pub fn message(&self) -> String { + match self { + LSPS5Error::TooLong(m) + | LSPS5Error::UrlParse(m) + | LSPS5Error::UnsupportedProtocol(m) + | LSPS5Error::TooManyWebhooks(m) + | LSPS5Error::AppNameNotFound(m) => m.clone(), + LSPS5Error::Other { message, .. } => message.clone(), } } +} - /// Validate the app name - pub fn validate(&self) -> Result<(), LSPSResponseError> { - if self.0.len() > MAX_APP_NAME_LENGTH { - return Err(LSPSResponseError { - code: LSPS5_TOO_LONG_ERROR_CODE, - message: format!( - "App name exceeds maximum length of {} bytes", - MAX_APP_NAME_LENGTH - ), - data: None, - }); +/// Convert LSPSResponseError to LSPS5Error +impl From for LSPS5Error { + fn from(err: LSPSResponseError) -> Self { + match err.code { + LSPS5_TOO_LONG_ERROR_CODE => LSPS5Error::TooLong(err.message), + LSPS5_URL_PARSE_ERROR_CODE => LSPS5Error::UrlParse(err.message), + LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE => LSPS5Error::UnsupportedProtocol(err.message), + LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE => LSPS5Error::TooManyWebhooks(err.message), + LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE => LSPS5Error::AppNameNotFound(err.message), + code => LSPS5Error::Other { code, message: err.message }, } + } +} - Ok(()) +/// Convert LSPS5Error to LSPSResponseError. +impl From for LSPSResponseError { + fn from(err: LSPS5Error) -> Self { + LSPSResponseError { code: err.code(), message: err.message(), 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: UntrustedString) -> Result { + if app_name.to_string().chars().count() > MAX_APP_NAME_LENGTH { + return Err(LSPS5Error::TooLong(format!( + "App name exceeds maximum length of {} bytes", + MAX_APP_NAME_LENGTH + ))); + } + Ok(Self(app_name)) + } + + /// Create a new LSPS5 app name from a regular String. + pub fn from_string(app_name: String) -> Result { + Self::new(UntrustedString(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 { - write!(f, "{}", self.0) + f.write_str(self) } } @@ -89,7 +178,7 @@ impl Serialize for LSPS5AppName { where S: serde::Serializer, { - self.0.serialize(serializer) + serializer.serialize_str(self) } } @@ -99,125 +188,70 @@ impl<'de> Deserialize<'de> for LSPS5AppName { D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; - Self::new(s).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) + Self::new(UntrustedString(s)).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) } } impl AsRef for LSPS5AppName { fn as_ref(&self) -> &str { - &self.0 + self } } impl From for String { fn from(app_name: LSPS5AppName) -> Self { - app_name.0 + app_name.to_string() } } -/// URL for LSPS5 webhooks (max 1024 ASCII chars) +/// 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 { - let parsed_url = match LSPSUrl::parse(&url) { - Ok(url) => url, - Err(e) => { - return Err(LSPSResponseError { - code: LSPS5_URL_PARSE_ERROR_CODE, - message: format!("Error parsing URL: {:?}", e), - data: None, - }); - }, - }; - - let lsps5_webhook_url = Self(parsed_url); - - match lsps5_webhook_url.validate() { - Ok(()) => Ok(lsps5_webhook_url), - Err(e) => Err(e), - } - } - - /// Validate the URL - pub fn validate(&self) -> Result<(), LSPSResponseError> { - let url_str = self.0.url(); - - if url_str.len() > MAX_WEBHOOK_URL_LENGTH { - return Err(LSPSResponseError { - code: LSPS5_TOO_LONG_ERROR_CODE, - message: format!( - "Webhook URL exceeds maximum length of {} bytes", - MAX_WEBHOOK_URL_LENGTH - ), - data: None, - }); + /// Create a new LSPS5 webhook URL. + pub fn new(url: UntrustedString) -> Result { + let parsed_url = LSPSUrl::parse(url.0.clone()) + .map_err(|_e| LSPS5Error::UrlParse(format!("Error parsing URL: {:?}", url)))?; + if parsed_url.url_length() > MAX_WEBHOOK_URL_LENGTH { + return Err(LSPS5Error::TooLong(format!( + "Webhook URL exceeds maximum length of {} bytes", + MAX_WEBHOOK_URL_LENGTH + ))); } - - if !url_str.is_ascii() { - return Err(LSPSResponseError { - code: LSPS5_URL_PARSE_ERROR_CODE, - message: "Webhook URL must be ASCII".to_string(), - data: None, - }); + if !parsed_url.is_https() { + return Err(LSPS5Error::UnsupportedProtocol( + "Unsupported protocol: HTTPS is required".to_string(), + )); } - - if self.0.scheme() != "https" { - return Err(LSPSResponseError { - code: LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE, - message: format!("Unsupported protocol: {}. HTTPS is required.", self.0.scheme()), - data: None, - }); + if !parsed_url.is_public() { + return Err(LSPS5Error::UrlParse("Webhook URL must be a public URL".to_string())); } + Ok(Self(parsed_url)) + } - // Check that URL has a host - if self.0.host().is_none() { - return Err(LSPSResponseError { - code: LSPS5_URL_PARSE_ERROR_CODE, - message: "URL must have a host".to_string(), - data: None, - }); - } + /// Create a new LSPS5 webhook URL from a regular String. + pub fn from_string(url: String) -> Result { + Self::new(UntrustedString(url)) + } - // Check for localhost and private IPs - if let Some(host) = self.0.host() { - if host == "localhost" || host.starts_with("127.") || host == "::1" { - return Err(LSPSResponseError { - code: LSPS5_URL_PARSE_ERROR_CODE, - message: "URL must not point to localhost".to_string(), - data: None, - }); - } + /// Get the webhook URL as a string. + pub fn as_str(&self) -> &str { + self + } +} - if host.starts_with("10.") - || host.starts_with("192.168.") - || (host.starts_with("172.") && { - if let Some(second_octet) = host.split('.').nth(1) { - if let Ok(num) = second_octet.parse::() { - (16..=31).contains(&num) - } else { - false - } - } else { - false - } - }) { - return Err(LSPSResponseError { - code: LSPS5_URL_PARSE_ERROR_CODE, - message: "URL must not point to private IP ranges".to_string(), - data: None, - }); - } - } +impl Deref for LSPS5WebhookUrl { + type Target = str; - Ok(()) + fn deref(&self) -> &Self::Target { + self.0.url() } +} - /// Get the URL as a string - pub fn as_str(&self) -> &str { - self.0.url() +impl Display for LSPS5WebhookUrl { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self) // Using Deref } } @@ -226,7 +260,7 @@ impl Serialize for LSPS5WebhookUrl { where S: serde::Serializer, { - serializer.serialize_str(self.0.url()) + serializer.serialize_str(self) } } @@ -236,120 +270,118 @@ impl<'de> Deserialize<'de> for LSPS5WebhookUrl { D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; - Self::new(s).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) + Self::new(UntrustedString(s)).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) } } impl AsRef for LSPS5WebhookUrl { fn as_ref(&self) -> &str { - self.0.url() + self } } impl From for String { fn from(url: LSPS5WebhookUrl) -> Self { - url.0.url().to_string() + url.to_string() } } -/// Parameters for `lsps5.set_webhook` request +/// Parameters for `lsps5.set_webhook` request. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SetWebhookRequest { - /// Human-readable name for the webhook (max 64 bytes) + /// Human-readable name for the webhook. pub app_name: LSPS5AppName, - /// URL of the webhook (max 1024 ASCII chars) + /// URL of the webhook. pub webhook: LSPS5WebhookUrl, } -/// Response for `lsps5.set_webhook` +/// Response for `lsps5.set_webhook`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SetWebhookResponse { - /// Current number of webhooks registered for this client + /// Current number of webhooks registered for this client. pub num_webhooks: u32, - /// Maximum number of webhooks allowed by LSP + /// Maximum number of webhooks allowed by LSP. pub max_webhooks: u32, - /// Whether this is an unchanged registration + /// Whether this is an unchanged registration. pub no_change: bool, } -/// Parameters for `lsps5.list_webhooks` request (empty) +/// Parameters for `lsps5.list_webhooks` request. #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct ListWebhooksRequest {} -/// Response for `lsps5.list_webhooks` +/// Response for `lsps5.list_webhooks`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ListWebhooksResponse { - /// List of app_names with registered webhooks + /// List of app_names with registered webhooks. pub app_names: Vec, - /// Maximum number of webhooks allowed by LSP + /// Maximum number of webhooks allowed by LSP. pub max_webhooks: u32, } -/// Parameters for `lsps5.remove_webhook` request +/// Parameters for `lsps5.remove_webhook` request. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RemoveWebhookRequest { - /// App name identifying the webhook to remove + /// App name identifying the webhook to remove. pub app_name: LSPS5AppName, } -/// Response for `lsps5.remove_webhook` (empty) +/// Response for `lsps5.remove_webhook`. #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct RemoveWebhookResponse {} -/// Webhook notification methods defined in LSPS5 +/// Webhook notification methods defined in LSPS5. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum WebhookNotificationMethod { - /// Webhook has been successfully registered + /// Webhook has been successfully registered. LSPS5WebhookRegistered, - /// Client has payments pending to be received + /// Client has payments pending to be received. LSPS5PaymentIncoming, - /// HTLC or time-bound contract is about to expire + /// 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 + /// LSP wants to take back some liquidity. LSPS5LiquidityManagementRequest, - /// Client has onion messages pending + /// Client has onion messages pending. LSPS5OnionMessageIncoming, } -/// Webhook notification payload +/// Webhook notification payload. #[derive(Debug, Clone, PartialEq, Eq)] pub struct WebhookNotification { - /// JSON-RPC version (must be "2.0") - pub jsonrpc: String, - /// Notification method with parameters + /// Notification method with parameters. pub method: WebhookNotificationMethod, } impl WebhookNotification { - /// Create a new webhook notification + /// Create a new webhook notification. pub fn new(method: WebhookNotificationMethod) -> Self { - Self { jsonrpc: "2.0".to_string(), method } + Self { method } } - /// Create a webhook_registered notification + /// Create a webhook_registered notification. pub fn webhook_registered() -> Self { Self::new(WebhookNotificationMethod::LSPS5WebhookRegistered) } - /// Create a payment_incoming notification + /// Create a payment_incoming notification. pub fn payment_incoming() -> Self { Self::new(WebhookNotificationMethod::LSPS5PaymentIncoming) } - /// Create an expiry_soon notification + /// Create an expiry_soon notification. pub fn expiry_soon(timeout: u32) -> Self { Self::new(WebhookNotificationMethod::LSPS5ExpirySoon { timeout }) } - /// Create a liquidity_management_request notification + /// Create a liquidity_management_request notification. pub fn liquidity_management_request() -> Self { Self::new(WebhookNotificationMethod::LSPS5LiquidityManagementRequest) } - /// Create an onion_message_incoming notification + /// Create an onion_message_incoming notification. pub fn onion_message_incoming() -> Self { Self::new(WebhookNotificationMethod::LSPS5OnionMessageIncoming) } @@ -361,7 +393,7 @@ impl Serialize for WebhookNotification { S: serde::Serializer, { let mut map = serializer.serialize_map(Some(3))?; - map.serialize_entry("jsonrpc", &self.jsonrpc)?; + map.serialize_entry("jsonrpc", "2.0")?; let method_name = match &self.method { WebhookNotificationMethod::LSPS5WebhookRegistered => { @@ -396,81 +428,110 @@ impl Serialize for WebhookNotification { impl<'de> Deserialize<'de> for WebhookNotification { fn deserialize(deserializer: D) -> Result where - D: serde::Deserializer<'de>, + D: Deserializer<'de>, { - #[derive(Deserialize)] - struct Helper { - jsonrpc: String, - method: String, - params: serde_json::Value, - } + struct WebhookNotificationVisitor; - let helper = Helper::deserialize(deserializer)?; + impl<'de> Visitor<'de> for WebhookNotificationVisitor { + type Value = WebhookNotification; - let method = match helper.method.as_str() { - LSPS5_WEBHOOK_REGISTERED_NOTIFICATION => { - WebhookNotificationMethod::LSPS5WebhookRegistered - }, - LSPS5_PAYMENT_INCOMING_NOTIFICATION => WebhookNotificationMethod::LSPS5PaymentIncoming, - LSPS5_EXPIRY_SOON_NOTIFICATION => { - // Directly extract timeout from params - if let Some(timeout) = helper.params.get("timeout").and_then(|t| t.as_u64()) { - WebhookNotificationMethod::LSPS5ExpirySoon { timeout: timeout as u32 } - } else { - return Err(serde::de::Error::custom( - "Missing or invalid timeout parameter for expiry_soon notification", - )); + 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()?; + }, + } } - }, - LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_NOTIFICATION => { - WebhookNotificationMethod::LSPS5LiquidityManagementRequest - }, - LSPS5_ONION_MESSAGE_INCOMING_NOTIFICATION => { - WebhookNotificationMethod::LSPS5OnionMessageIncoming - }, - _ => { - return Err(serde::de::Error::custom(format!("Unknown method: {}", helper.method))) - }, - }; - Ok(WebhookNotification { jsonrpc: helper.jsonrpc, method }) + 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 +/// An LSPS5 protocol request. #[derive(Clone, Debug, PartialEq, Eq)] pub enum LSPS5Request { - /// Register or update a webhook + /// Register or update a webhook. SetWebhook(SetWebhookRequest), - /// List all registered webhooks + /// List all registered webhooks. ListWebhooks(ListWebhooksRequest), - /// Remove a webhook + /// Remove a webhook. RemoveWebhook(RemoveWebhookRequest), } -/// An LSPS5 protocol response +/// An LSPS5 protocol response. #[derive(Clone, Debug, PartialEq, Eq)] pub enum LSPS5Response { - /// Response to [`SetWebhook`](SetWebhookRequest) request + /// Response to [`SetWebhook`](SetWebhookRequest) request. SetWebhook(SetWebhookResponse), - /// Error response to [`SetWebhook`](SetWebhookRequest) request - SetWebhookError(LSPSResponseError), - /// Response to [`ListWebhooks`](ListWebhooksRequest) request + /// Error response to [`SetWebhook`](SetWebhookRequest) request. + SetWebhookError(LSPS5Error), + /// Response to [`ListWebhooks`](ListWebhooksRequest) request. ListWebhooks(ListWebhooksResponse), - /// Error response to [`ListWebhooks`](ListWebhooksRequest) request - ListWebhooksError(LSPSResponseError), - /// Response to [`RemoveWebhook`](RemoveWebhookRequest) request + /// Error response to [`ListWebhooks`](ListWebhooksRequest) request. + ListWebhooksError(LSPS5Error), + /// Response to [`RemoveWebhook`](RemoveWebhookRequest) request. RemoveWebhook(RemoveWebhookResponse), - /// Error response to [`RemoveWebhook`](RemoveWebhookRequest) request - RemoveWebhookError(LSPSResponseError), + /// Error response to [`RemoveWebhook`](RemoveWebhookRequest) request. + RemoveWebhookError(LSPS5Error), } #[derive(Clone, Debug, PartialEq, Eq)] -/// An LSPS5 protocol message +/// An LSPS5 protocol message. pub enum LSPS5Message { - /// A request variant + /// A request variant. Request(LSPSRequestId, LSPS5Request), - /// A response variant + /// A response variant. Response(LSPSRequestId, LSPS5Response), } @@ -511,10 +572,14 @@ mod tests { 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.app_name, + LSPS5AppName::new(UntrustedString("my_app".to_string())).unwrap() + ); assert_eq!( request.webhook, - LSPS5WebhookUrl::new("https://example.com/webhook".to_string()).unwrap() + LSPS5WebhookUrl::new(UntrustedString("https://example.com/webhook".to_string())) + .unwrap() ); } @@ -531,8 +596,8 @@ mod tests { 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(); + let app1 = LSPS5AppName::new(UntrustedString("app1".to_string())).unwrap(); + let app2 = LSPS5AppName::new(UntrustedString("app2".to_string())).unwrap(); assert_eq!(response.app_names, vec![app1, app2]); assert_eq!(response.max_webhooks, 5); } @@ -550,14 +615,15 @@ mod tests { 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() + LSPS5AppName::new(UntrustedString("My LSPS-Compliant Lightning Client".to_string())) + .unwrap() ); assert_eq!( request.webhook, - LSPS5WebhookUrl::new( + LSPS5WebhookUrl::new(UntrustedString( "https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best" .to_string() - ) + )) .unwrap() ); } @@ -575,9 +641,13 @@ mod tests { 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(); + let app1 = + LSPS5AppName::new(UntrustedString("My LSPS-Compliant Lightning Wallet".to_string())) + .unwrap(); + let app2 = LSPS5AppName::new(UntrustedString( + "Another Wallet With The Same Signing Device".to_string(), + )) + .unwrap(); assert_eq!(response.app_names, vec![app1, app2]); assert_eq!(response.max_webhooks, 42); } @@ -588,7 +658,10 @@ mod tests { 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() + LSPS5AppName::new(UntrustedString( + "Another Wallet With The Same Signig Device".to_string() + )) + .unwrap() ); } @@ -629,11 +702,11 @@ mod tests { ]; for url_str in urls_that_should_throw.iter() { - match LSPS5WebhookUrl::new(url_str.to_string()) { + match LSPS5WebhookUrl::new(UntrustedString(url_str.to_string())) { Ok(_) => panic!("Expected error"), Err(e) => { // error is not null - assert!(e.code != 0); + assert!(e.code() != 0); }, } } From 1be2c9f33b518ae82e6511b321e3cfad4f5a372a Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Tue, 11 Mar 2025 16:52:14 -0300 Subject: [PATCH 07/18] Add LSPS5 event enums for webhook operations - Introduce LSPS5ServiceEvent for LSPS-side webhook events including registration, listing, removal, and notification. - Define LSPS5ClientEvent for handling webhook outcomes on the client (Lightning node) side. - Outline WebhookNotificationParams enum to support notification-specific parameters. - Improve LSPS5 event documentation and field naming - Rename client/lsp fields to counterparty_node_id for consistent terminology - Replace generic String types with more specific Lsps5AppName and Lsps5WebhookUrl - Add comprehensive documentation for all events and fields - Include format specifications (UTF-8, ISO8601) and size constraints - Add request_id field to all relevant events for consistent request tracking - Provide detailed descriptions of error codes and their meanings - Use complete sentences in documentation comments --- lightning-liquidity/src/events/event_queue.rs | 1 - lightning-liquidity/src/lsps5/event.rs | 233 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 lightning-liquidity/src/lsps5/event.rs diff --git a/lightning-liquidity/src/events/event_queue.rs b/lightning-liquidity/src/events/event_queue.rs index a2589beb4e2..9bf94cf550f 100644 --- a/lightning-liquidity/src/events/event_queue.rs +++ b/lightning-liquidity/src/events/event_queue.rs @@ -1,6 +1,5 @@ use super::LiquidityEvent; use crate::sync::{Arc, Mutex}; - use alloc::collections::VecDeque; use alloc::vec::Vec; diff --git a/lightning-liquidity/src/lsps5/event.rs b/lightning-liquidity/src/lsps5/event.rs new file mode 100644 index 00000000000..44e3e224e8a --- /dev/null +++ b/lightning-liquidity/src/lsps5/event.rs @@ -0,0 +1,233 @@ +// 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. + +//! Events generated by the LSPS5 service and client + +use crate::lsps0::ser::LSPSRequestId; +use alloc::string::String; +use alloc::vec::Vec; +use bitcoin::secp256k1::PublicKey; + +use super::msgs::LSPS5AppName; +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 webhook was registered by a client + /// + /// This event occurs when a client successfully registers a webhook via `lsps5.set_webhook`. + /// You should store this information to be able to contact the client when they are offline. + WebhookRegistered { + /// Client node ID that registered the webhook + counterparty_node_id: PublicKey, + /// App name provided by the client (up to 64 bytes in UTF-8 format) + app_name: LSPS5AppName, + /// Webhook URL (HTTPS) that should be contacted to notify the client (up to 1024 ASCII characters) + 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, + /// Whether this was a new registration or an update to existing one with no changes + /// If false, a notification should be sent to the registered webhook + no_change: bool, + }, + + /// Webhooks were listed for a client + /// + /// This event occurs when a client requests their registered webhooks via `lsps5.list_webhooks`. + WebhooksListed { + /// Client node ID that requested their webhooks + counterparty_node_id: PublicKey, + /// App names with registered webhooks for this client + app_names: Vec, + /// The identifier of the issued bLIP-55 / LSPS5 webhook listing request + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + /// Maximum number of webhooks allowed by LSP per client + max_webhooks: u32, + }, + + /// A webhook was removed by a client + /// + /// This event occurs when a client successfully removes a webhook via `lsps5.remove_webhook`. + WebhookRemoved { + /// Client node ID that removed the webhook + counterparty_node_id: PublicKey, + /// App name that was removed + app_name: LSPS5AppName, + /// The identifier of the issued bLIP-55 / LSPS5 webhook removal request + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// A notification needs to be sent to a client's webhook + /// + /// This event occurs when the LSP needs to send a notification to a client's webhook. + /// When this event is received, the LSP should: + /// 1. Serialize the notification to JSON + /// 2. Make an HTTP POST request to the provided URL with the given headers and the serialized notification + /// + /// When the client receives this notification, they will process it and generate a + /// `WebhookNotificationReceived` event on their side. The client will validate the + /// signature using the LSP's node ID to ensure the notification is authentic. + SendWebhookNotifications { + /// Client node ID to be notified + counterparty_node_id: PublicKey, + /// App name to be notified + app_name: LSPS5AppName, + /// URL that to be contacted + url: LSPS5WebhookUrl, + /// Notification method with its parameters + notification: WebhookNotification, + /// ISO8601 timestamp of the notification (YYYY-MM-DDThh:mm:ss.uuuZ format) + timestamp: String, + /// Signature of the notification using the LSP's node ID + signature: String, + /// Headers to be included in the HTTP POST request + headers: Vec<(String, String)>, + }, +} + +/// 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`. + 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 (up to 64 bytes in UTF-8 format) + app_name: LSPS5AppName, + /// The webhook URL that was registered (HTTPS protocol) + 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`. This can happen if the app_name or URL is too long, + /// the URL uses an unsupported protocol, or the maximum number of webhooks is reached. + WebhookRegistrationFailed { + /// The node id of the LSP that rejected the registration + counterparty_node_id: PublicKey, + /// Error code from the LSP (500: too_long, 501: url_parse_error, + /// 502: unsupported_protocol, 503: too_many_webhooks) + error_code: i32, + /// Error message from the LSP + error_message: String, + /// 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. + 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, + }, + + /// The attempt to list webhooks failed + /// + /// This event is triggered when the LSP rejects a `lsps5.list_webhooks` request. + WebhooksListFailed { + /// The node id of the LSP that rejected the request + counterparty_node_id: PublicKey, + /// Error code from the LSP + error_code: i32, + /// Error message from the LSP + error_message: String, + /// 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`. + 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 app_name_not_found (1010). + WebhookRemovalFailed { + /// The node id of the LSP that rejected the removal + counterparty_node_id: PublicKey, + /// Error code from the LSP (1010: app_name_not_found) + error_code: i32, + /// Error message from the LSP + error_message: String, + /// 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, + }, + + /// A webhook notification was received from the LSP + /// + /// This event is triggered when the client receives a webhook notification + /// from the LSP. This can happen for various reasons such as incoming payment, + /// expiring HTLCs, liquidity management requests, or incoming onion messages. + WebhookNotificationReceived { + /// LSP node ID that sent the notification + counterparty_node_id: PublicKey, + /// The notification with its method and parameters + notification: WebhookNotification, + /// Timestamp of the notification in ISO8601 format (YYYY-MM-DDThh:mm:ss.uuuZ) + timestamp: String, + /// Whether the LSP's signature was successfully verified + signature_valid: bool, + }, +} From 0c0dac54d416978de0227228e3ad4fccbd94068b Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 17 Apr 2025 18:37:08 -0300 Subject: [PATCH 08/18] fixup: LSPS5 events - Improve type safety and documentation - Replaces raw error code/message pairs with a structured LSPS5Error type - Changes String timestamp fields to use the proper LSPSDateTime type - Improves documentation with references to MAX_APP_NAME_LENGTH constant --- lightning-liquidity/src/lsps5/event.rs | 162 +++++++++++++------------ 1 file changed, 87 insertions(+), 75 deletions(-) diff --git a/lightning-liquidity/src/lsps5/event.rs b/lightning-liquidity/src/lsps5/event.rs index 44e3e224e8a..5fef29093ef 100644 --- a/lightning-liquidity/src/lsps5/event.rs +++ b/lightning-liquidity/src/lsps5/event.rs @@ -9,15 +9,16 @@ //! Events generated by the LSPS5 service and client +use crate::lsps0::ser::LSPSDateTime; use crate::lsps0::ser::LSPSRequestId; use alloc::string::String; use alloc::vec::Vec; use bitcoin::secp256k1::PublicKey; 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 { @@ -26,75 +27,93 @@ pub enum LSPS5ServiceEvent { /// This event occurs when a client successfully registers a webhook via `lsps5.set_webhook`. /// You should store this information to be able to contact the client when they are offline. WebhookRegistered { - /// Client node ID that registered the webhook + /// Client node ID that registered the webhook. counterparty_node_id: PublicKey, - /// App name provided by the client (up to 64 bytes in UTF-8 format) + /// App name provided by the client. + /// + /// This app name is used to identify the webhook registration. + /// + /// **Note**: Ensure the app name is valid and its length does not exceed [`MAX_APP_NAME_LENGTH`]. + /// + /// [`MAX_APP_NAME_LENGTH`]: super::msgs::MAX_APP_NAME_LENGTH app_name: LSPS5AppName, - /// Webhook URL (HTTPS) that should be contacted to notify the client (up to 1024 ASCII characters) + /// Webhook URL (HTTPS) to be contacted for notifying the client. + /// + /// This URL is used by the LSP to send notifications. + /// + /// **Note**: Ensure the URL is valid and its length does not exceed [`MAX_WEBHOOK_URL_LENGTH`]. + /// Also ensure that the URL points to a public host. + /// + /// [`MAX_WEBHOOK_URL_LENGTH`]: super::msgs::MAX_WEBHOOK_URL_LENGTH url: LSPS5WebhookUrl, - /// The identifier of the issued bLIP-55 / LSPS5 webhook registration request + /// 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, - /// Whether this was a new registration or an update to existing one with no changes - /// If false, a notification should be sent to the registered webhook + /// Whether this was a new registration or an update to existing one with no changes. + /// If false, a notification should be sent to the registered webhook. no_change: bool, }, - /// Webhooks were listed for a client + /// Webhooks were listed for a client. /// /// This event occurs when a client requests their registered webhooks via `lsps5.list_webhooks`. WebhooksListed { - /// Client node ID that requested their webhooks + /// Client node ID that requested their webhooks. counterparty_node_id: PublicKey, - /// App names with registered webhooks for this client + /// App names with registered webhooks for this client. app_names: Vec, - /// The identifier of the issued bLIP-55 / LSPS5 webhook listing request + /// The identifier of the issued bLIP-55 / LSPS5 webhook listing request. /// /// This can be used to track which request this event corresponds to. request_id: LSPSRequestId, - /// Maximum number of webhooks allowed by LSP per client + /// Maximum number of webhooks allowed by LSP per client. max_webhooks: u32, }, - /// A webhook was removed by a client + /// A webhook was removed by a client. /// /// This event occurs when a client successfully removes a webhook via `lsps5.remove_webhook`. WebhookRemoved { - /// Client node ID that removed the webhook + /// Client node ID that removed the webhook. counterparty_node_id: PublicKey, - /// App name that was removed + /// App name that was removed. app_name: LSPS5AppName, - /// The identifier of the issued bLIP-55 / LSPS5 webhook removal request + /// The identifier of the issued bLIP-55 / LSPS5 webhook removal request. /// /// This can be used to track which request this event corresponds to. request_id: LSPSRequestId, }, - /// A notification needs to be sent to a client's webhook + /// A notification needs to be sent to a client's webhook. /// /// This event occurs when the LSP needs to send a notification to a client's webhook. /// When this event is received, the LSP should: - /// 1. Serialize the notification to JSON - /// 2. Make an HTTP POST request to the provided URL with the given headers and the serialized notification + /// 1. Serialize the notification to JSON. + /// 2. Make an HTTP POST request to the provided + /// URL with the given headers and the serialized notification. /// /// When the client receives this notification, they will process it and generate a /// `WebhookNotificationReceived` event on their side. The client will validate the /// signature using the LSP's node ID to ensure the notification is authentic. SendWebhookNotifications { - /// Client node ID to be notified + /// Client node ID to be notified. counterparty_node_id: PublicKey, - /// App name to be notified + /// App name to be notified. app_name: LSPS5AppName, - /// URL that to be contacted + /// URL that to be contacted. url: LSPS5WebhookUrl, - /// Notification method with its parameters + /// Notification method with its parameters. notification: WebhookNotification, - /// ISO8601 timestamp of the notification (YYYY-MM-DDThh:mm:ss.uuuZ format) - timestamp: String, - /// Signature of the notification using the LSP's node ID + /// Timestamp of the notification. + timestamp: LSPSDateTime, + /// Signature of the notification using the LSP's node ID. signature: String, - /// Headers to be included in the HTTP POST request + /// Headers to be included in the HTTP POST request. + /// + /// Content-Type (application/json). + /// x-lsps5-timestamp (timestamp in RFC3339 (YYYY-MM-DDThh:mm:ss.uuuZ) format). + /// x-lsps5-signature (signature of the notification using the LSP's node ID). headers: Vec<(String, String)>, }, } @@ -102,25 +121,25 @@ pub enum LSPS5ServiceEvent { /// 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 + /// 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`. WebhookRegistered { - /// The node id of the LSP that confirmed the registration + /// The node id of the LSP that confirmed the registration. counterparty_node_id: PublicKey, - /// Current number of webhooks registered for this client + /// Current number of webhooks registered for this client. num_webhooks: u32, - /// Maximum number of webhooks allowed by LSP + /// 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 + /// 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 (up to 64 bytes in UTF-8 format) + /// The app name that was registered. app_name: LSPS5AppName, - /// The webhook URL that was registered (HTTPS protocol) + /// The webhook URL that was registered. url: LSPS5WebhookUrl, - /// The identifier of the issued bLIP-55 / LSPS5 webhook registration request + /// 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, @@ -132,102 +151,95 @@ pub enum LSPS5ClientEvent { /// via `lsps5.set_webhook`. This can happen if the app_name or URL is too long, /// the URL uses an unsupported protocol, or the maximum number of webhooks is reached. WebhookRegistrationFailed { - /// The node id of the LSP that rejected the registration + /// The node id of the LSP that rejected the registration. counterparty_node_id: PublicKey, - /// Error code from the LSP (500: too_long, 501: url_parse_error, - /// 502: unsupported_protocol, 503: too_many_webhooks) - error_code: i32, - /// Error message from the LSP - error_message: String, - /// The app name that was attempted + /// Error from the LSP. + error: LSPS5Error, + /// The app name that was attempted. app_name: LSPS5AppName, - /// The webhook URL that was attempted + /// The webhook URL that was attempted. url: LSPS5WebhookUrl, - /// The identifier of the issued bLIP-55 / LSPS5 webhook registration request + /// 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 + /// The list of registered webhooks was successfully retrieved. /// /// This event is triggered when the LSP responds to a `lsps5.list_webhooks` request. WebhooksListed { - /// The node id of the LSP that provided the list + /// The node id of the LSP that provided the list. counterparty_node_id: PublicKey, - /// List of app names with registered webhooks + /// List of app names with registered webhooks. app_names: Vec, - /// Maximum number of webhooks allowed by LSP + /// Maximum number of webhooks allowed by LSP. max_webhooks: u32, - /// The identifier of the issued bLIP-55 / LSPS5 list webhooks request + /// 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, }, - /// The attempt to list webhooks failed + /// The attempt to list webhooks failed. /// /// This event is triggered when the LSP rejects a `lsps5.list_webhooks` request. WebhooksListFailed { - /// The node id of the LSP that rejected the request + /// The node id of the LSP that rejected the request. counterparty_node_id: PublicKey, - /// Error code from the LSP - error_code: i32, - /// Error message from the LSP - error_message: String, - /// The identifier of the issued bLIP-55 / LSPS5 list webhooks request + /// Error from the LSP. + error: LSPS5Error, + /// 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 + /// A webhook was successfully removed. /// /// This event is triggered when the LSP confirms successful removal /// of a webhook via `lsps5.remove_webhook`. WebhookRemoved { - /// The node id of the LSP that confirmed the removal + /// The node id of the LSP that confirmed the removal. counterparty_node_id: PublicKey, - /// The app name that was removed + /// The app name that was removed. app_name: LSPS5AppName, - /// The identifier of the issued bLIP-55 / LSPS5 remove webhook request + /// 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 + /// 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 app_name_not_found (1010). WebhookRemovalFailed { - /// The node id of the LSP that rejected the removal + /// The node id of the LSP that rejected the removal. counterparty_node_id: PublicKey, - /// Error code from the LSP (1010: app_name_not_found) - error_code: i32, - /// Error message from the LSP - error_message: String, - /// The app name that was attempted to be removed + /// 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 + /// 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 notification was received from the LSP + /// A webhook notification was received from the LSP. /// /// This event is triggered when the client receives a webhook notification /// from the LSP. This can happen for various reasons such as incoming payment, /// expiring HTLCs, liquidity management requests, or incoming onion messages. WebhookNotificationReceived { - /// LSP node ID that sent the notification + /// LSP node ID that sent the notification. counterparty_node_id: PublicKey, - /// The notification with its method and parameters + /// The notification with its method and parameters. notification: WebhookNotification, - /// Timestamp of the notification in ISO8601 format (YYYY-MM-DDThh:mm:ss.uuuZ) - timestamp: String, - /// Whether the LSP's signature was successfully verified + /// Timestamp of the notification. + timestamp: LSPSDateTime, + /// Whether the LSP's signature was successfully verified. signature_valid: bool, }, } From e31be89ed1e4628808c2cee502bcb0c4a0ed1341 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 24 Mar 2025 16:57:34 -0300 Subject: [PATCH 09/18] 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. Some details: - A generic HttpClient trait is defined so users can provide their own HTTP implementation - A generic TimeProvider trait is defined with a DefaultTimeProvider that uses std functionality - Uses URL utils to validate webhook URLs according to LSPS5 requirements - Uses secure message signing logic from the lightning::util::message_signing module - Works with the events and messages defined in earlier commits - Tests will be provided in a future commit --- lightning-liquidity/src/lsps5/service.rs | 724 +++++++++++++++++++++++ 1 file changed, 724 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..69785f60951 --- /dev/null +++ b/lightning-liquidity/src/lsps5/service.rs @@ -0,0 +1,724 @@ +// 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::events::EventQueue; +use crate::lsps0::ser::{ + LSPSDateTime, LSPSProtocolMessageHandler, LSPSRequestId, LSPSResponseError, +}; +use crate::lsps5::msgs::{ + ListWebhooksRequest, ListWebhooksResponse, RemoveWebhookRequest, RemoveWebhookResponse, + SetWebhookRequest, SetWebhookResponse, WebhookNotification, WebhookNotificationMethod, +}; +use crate::message_queue::MessageQueue; +use crate::prelude::*; +use core::time::Duration; + +use bitcoin::secp256k1::{PublicKey, SecretKey}; +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::util::logger::Level; +use lightning::util::message_signing; + +use crate::alloc::string::ToString; +use crate::sync::{Arc, Mutex}; +use alloc::boxed::Box; +use alloc::string::String; +use alloc::vec::Vec; +use serde_json::json; + +use super::event::LSPS5ServiceEvent; +use super::msgs::{ + LSPS5AppName, LSPS5Message, LSPS5Request, LSPS5Response, LSPS5WebhookUrl, + LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE, LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE, +}; + +/// Minimum number of days to retain webhooks after a client's last channel is closed +pub const MIN_WEBHOOK_RETENTION_DAYS: u32 = 30; + +/// A stored webhook +#[derive(Debug, Clone)] +struct StoredWebhook { + /// App name identifier for this webhook + _app_name: LSPS5AppName, + /// The webhook URL + url: LSPS5WebhookUrl, + /// Client node ID + _counterparty_node_id: PublicKey, + /// Last time this webhook was used + last_used: Duration, + /// Map of notification methods to last time they were sent + 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") + } +} + +/// Configuration for LSPS5 service +#[derive(Clone)] +pub struct LSPS5ServiceConfig { + /// Maximum number of webhooks allowed per client (default: 10) + pub max_webhooks_per_client: u32, + /// Signing key for LSP notifications + pub signing_key: SecretKey, + /// Minimum time between sending the same notification type in hours (default: 24) + pub notification_cooldown_hours: u64, +} + +impl Default for LSPS5ServiceConfig { + fn default() -> Self { + Self { + max_webhooks_per_client: 10, + signing_key: SecretKey::from_slice(&[1; 32]).expect("Static key should be valid"), + notification_cooldown_hours: 24, + } + } +} + +/// Service for handling LSPS5 webhook registration +pub struct LSPS5ServiceHandler { + /// Configuration parameters + config: LSPS5ServiceConfig, + /// Map of client node IDs to their registered webhooks + webhooks: Arc>>>, + /// Event queue for emitting events + event_queue: Arc, + /// Message queue for sending responses + pending_messages: Arc, + /// Time provider for LSPS5 service + time_provider: Arc, + /// Function for checking if a client has an open channel + client_has_open_channel: Box bool>, + /// Last time the stale webhooks were pruned + last_pruning: Arc>>, +} + +impl LSPS5ServiceHandler { + /// Create a new LSPS5 service handler + /// + /// # Arguments + /// * `event_queue` - Event queue for emitting events + /// * `pending_messages` - Message queue for sending responses + /// * `client_has_open_channel` - Function that checks if a client has an open channel + /// * `config` - Configuration for the LSPS5 service + /// + /// # Panics + /// Will panic if no HTTP client is provided and a default one cannot be created + #[cfg(feature = "time")] + pub(crate) fn new( + event_queue: Arc, pending_messages: Arc, + client_has_open_channel: Box bool>, config: LSPS5ServiceConfig, + ) -> Option { + let time_provider = Arc::new(DefaultTimeProvider); + Some(Self { + config, + webhooks: Arc::new(Mutex::new(new_hash_map())), + event_queue, + pending_messages, + time_provider, + client_has_open_channel, + last_pruning: Arc::new(Mutex::new(None)), + }) + } + + pub(crate) fn _new_with_custom_time_provider( + event_queue: Arc, pending_messages: Arc, + client_has_open_channel: Box bool>, config: LSPS5ServiceConfig, + time_provider: Option>, + ) -> Option { + let time_provider = match time_provider { + Some(provider) => provider, + None => return None, + }; + Some(Self { + config, + webhooks: Arc::new(Mutex::new(new_hash_map())), + event_queue, + pending_messages, + time_provider, + client_has_open_channel, + last_pruning: Arc::new(Mutex::new(None)), + }) + } + + fn check_prune_stale_webhooks(&self) -> Result<(), LightningError> { + let now = self.time_provider.duration_since_epoch(); + let should_prune = { + let mut last_pruning = self.last_pruning.lock().map_err(|_| LightningError { + err: "Failed to lock last_pruning mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let should_run = match *last_pruning { + Some(last_time) => now + .checked_sub(last_time) + .map_or(false, |elapsed| elapsed > Duration::from_secs(24 * 60 * 60)), + None => true, + }; + + if should_run { + *last_pruning = Some(now); + } + + should_run + }; + + if should_prune { + self.prune_stale_webhooks(); + } + + Ok(()) + } + + /// Handle a set_webhook request + pub fn handle_set_webhook( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + params: SetWebhookRequest, + ) -> Result<(), LightningError> { + let event_queue_notifier = self.event_queue.notifier(); + self.check_prune_stale_webhooks()?; + + if let Err(e) = params.app_name.validate() { + let msg = LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(e.clone())) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + return Err(LightningError { + err: e.message, + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + if let Err(e) = params.webhook.validate() { + let msg = LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(e.clone())) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + return Err(LightningError { + err: format!("Error handling SetWebhook request: {}", e.message), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + let mut webhooks = self.webhooks.lock().map_err(|_| LightningError { + err: "Failed to lock webhooks mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let client_webhooks = webhooks.entry(counterparty_node_id).or_insert_with(new_hash_map); + let now = self.time_provider.duration_since_epoch(); + + let no_change = client_webhooks + .get(¶ms.app_name) + .map_or(false, |webhook| webhook.url == params.webhook); + + if !client_webhooks.contains_key(¶ms.app_name) + && client_webhooks.len() >= self.config.max_webhooks_per_client as usize + { + let message = format!( + "Maximum of {} webhooks allowed per client", + self.config.max_webhooks_per_client + ); + let error_response = LSPSResponseError { + code: LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE, + message: message.clone(), + data: Some( + json!({ "max_webhooks": self.config.max_webhooks_per_client }).to_string(), + ), + }; + let msg = + LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(error_response)) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + return Err(LightningError { + err: message, + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + // Add or replace the webhook + let stored_webhook = 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(), + }; + + client_webhooks.insert(params.app_name.clone(), stored_webhook); + + let response = SetWebhookResponse { + num_webhooks: client_webhooks.len() as u32, + max_webhooks: self.config.max_webhooks_per_client, + no_change, + }; + event_queue_notifier.enqueue(LSPS5ServiceEvent::WebhookRegistered { + counterparty_node_id, + app_name: params.app_name.clone(), + url: params.webhook.clone(), + request_id: request_id.clone(), + no_change, + }); + + // Send webhook_registered notification if needed + // According to spec: + // "The LSP MUST send this notification to this webhook before sending any other notifications to this webhook." + if !no_change { + self.send_webhook_registered_notification( + counterparty_node_id, + params.app_name.clone(), + params.webhook.clone(), + )?; + } + + let msg = LSPS5Message::Response(request_id, LSPS5Response::SetWebhook(response)).into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + Ok(()) + } + + /// Handle a list_webhooks request + pub fn handle_list_webhooks( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + _params: ListWebhooksRequest, + ) -> Result<(), LightningError> { + let event_queue_notifier = self.event_queue.notifier(); + self.check_prune_stale_webhooks()?; + + let webhooks = self.webhooks.lock().map_err(|_| LightningError { + err: "Failed to lock webhooks mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + 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; + + event_queue_notifier.enqueue(LSPS5ServiceEvent::WebhooksListed { + counterparty_node_id, + app_names: app_names.clone(), + max_webhooks, + request_id: request_id.clone(), + }); + + 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(()) + } + + /// Handle a remove_webhook request + pub fn handle_remove_webhook( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + params: RemoveWebhookRequest, + ) -> Result<(), LightningError> { + let event_queue_notifier = self.event_queue.notifier(); + // Check if we need to prune stale webhooks + self.check_prune_stale_webhooks()?; + + let mut webhooks = self.webhooks.lock().map_err(|_| LightningError { + err: "Failed to lock webhooks mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + 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.clone(), + LSPS5Response::RemoveWebhook(response), + ) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + event_queue_notifier.enqueue(LSPS5ServiceEvent::WebhookRemoved { + counterparty_node_id, + app_name: params.app_name, + request_id, + }); + + return Ok(()); + } + } + + let error_message = format!("App name not found: {}", params.app_name); + let error_response = LSPSResponseError { + code: LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE, + message: error_message.clone(), + data: None, + }; + + let msg = + LSPS5Message::Response(request_id, LSPS5Response::RemoveWebhookError(error_response)) + .into(); + + self.pending_messages.enqueue(&counterparty_node_id, msg); + return Err(LightningError { + err: error_message, + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + /// Send a webhook_registered notification to a newly registered webhook + /// + /// According to spec: + /// "Only the newly-registered webhook is notified. + /// Only the newly-registered webhook is contacted for this notification" + fn send_webhook_registered_notification( + &self, client_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl, + ) -> Result<(), LightningError> { + let notification = WebhookNotification::webhook_registered(); + self.send_notification(client_node_id, app_name.clone(), url.clone(), notification) + } + + /// Send an incoming_payment notification to all of a client's webhooks + pub fn notify_payment_incoming(&self, client_id: PublicKey) -> Result<(), LightningError> { + let notification = WebhookNotification::payment_incoming(); + self.broadcast_notification(client_id, notification) + } + + /// Send an expiry_soon notification to all of a client's webhooks + pub fn notify_expiry_soon( + &self, client_id: PublicKey, timeout: u32, + ) -> Result<(), LightningError> { + let notification = WebhookNotification::expiry_soon(timeout); + self.broadcast_notification(client_id, notification) + } + + /// Send a liquidity_management_request notification to all of a client's webhooks + pub fn notify_liquidity_management_request( + &self, client_id: PublicKey, + ) -> Result<(), LightningError> { + let notification = WebhookNotification::liquidity_management_request(); + self.broadcast_notification(client_id, notification) + } + + /// Send an onion_message_incoming notification to all of a client's webhooks + pub fn notify_onion_message_incoming( + &self, client_id: PublicKey, + ) -> Result<(), LightningError> { + let notification = WebhookNotification::onion_message_incoming(); + self.broadcast_notification(client_id, notification) + } + + /// Broadcast a notification to all registered webhooks for a client + /// + /// According to spec: + /// "The LSP SHOULD contact all registered webhook URIs, if: + /// * The client has registered at least one via `lsps5.set_webhook`. + /// * *and* the client currently does not have a BOLT8 tunnel with the LSP. + /// * *and* one of the specified events has occurred." + fn broadcast_notification( + &self, client_id: PublicKey, notification: WebhookNotification, + ) -> Result<(), LightningError> { + let mut webhooks = self.webhooks.lock().map_err(|_| LightningError { + err: "Failed to lock webhooks mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let client_webhooks = match webhooks.get_mut(&client_id) { + Some(webhooks) if !webhooks.is_empty() => webhooks, + _ => return Ok(()), + }; + + let now = self.time_provider.duration_since_epoch(); + let cooldown_duration = Duration::from_secs(self.config.notification_cooldown_hours * 3600); + + for (app_name, webhook) in client_webhooks.iter_mut() { + if webhook + .last_notification_sent + .get(¬ification.method) + .and_then(|last_sent| now.checked_sub(*last_sent)) + .map_or(true, |duration| duration >= cooldown_duration) + { + webhook.last_notification_sent.insert(notification.method.clone(), now); + webhook.last_used = now; + + self.send_notification( + client_id, + app_name.clone(), + webhook.url.clone(), + notification.clone(), + )?; + } + } + + Ok(()) + } + + /// Send a notification to a webhook URL + fn send_notification( + &self, counterparty_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl, + notification: WebhookNotification, + ) -> Result<(), LightningError> { + let event_queue_notifier = self.event_queue.notifier(); + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()) + .to_rfc3339(); + + let notification_json = + serde_json::to_string(¬ification).map_err(|e| LightningError { + err: format!("Failed to serialize notification: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let signature_hex = self.sign_notification(¬ification_json, ×tamp)?; + + let headers = vec![ + ("Content-Type".to_string(), "application/json".to_string()), + ("x-lsps5-timestamp".to_string(), timestamp.clone()), + ("x-lsps5-signature".to_string(), signature_hex.clone()), + ]; + + event_queue_notifier.enqueue(LSPS5ServiceEvent::SendWebhookNotifications { + counterparty_node_id, + app_name, + url, + notification, + timestamp, + signature: signature_hex, + headers, + }); + + Ok(()) + } + + /// Sign a webhook notification with an LSP's signing key + /// + /// This function takes a notification body and timestamp and returns a signature + /// in the format required by the LSPS5 specification. + /// + /// # Arguments + /// + /// * `body` - The serialized notification JSON + /// * `timestamp` - The ISO8601 timestamp string + /// * `signing_key` - The LSP private key used for signing + /// + /// # Returns + /// + /// * The zbase32 encoded signature as specified in LSPS0, or an error if signing fails + pub fn sign_notification(&self, body: &str, timestamp: &str) -> Result { + // Create the message to sign + // According to spec: + // The message to be signed is: "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + let message = format!( + "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + timestamp, body + ); + + Ok(message_signing::sign(message.as_bytes(), &self.config.signing_key)) + } + + /// Clean up webhooks for clients with no channels that haven't been used in a while + /// According to spec: "MUST remember all webhooks for at least 7 days after the last channel is closed" + fn prune_stale_webhooks(&self) { + let now = self.time_provider.duration_since_epoch(); + let webhooks_lock = match self.webhooks.lock() { + Ok(guard) => guard, + Err(_) => return, + }; + let mut webhooks = webhooks_lock; + let retention_period = + Duration::from_secs(MIN_WEBHOOK_RETENTION_DAYS as u64 * 24 * 60 * 60); + + webhooks.retain(|client_id, client_webhooks| { + if !(self.client_has_open_channel)(client_id) { + client_webhooks.retain(|_, webhook| { + now.checked_sub(webhook.last_used) + .map_or(true, |duration| duration < retention_period) + }); + !client_webhooks.is_empty() + } else { + true + } + }); + } +} + +impl LSPSProtocolMessageHandler for LSPS5ServiceHandler { + type ProtocolMessage = LSPS5Message; + const PROTOCOL_NUMBER: Option = Some(2); + + 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.clone(), params) + }, + LSPS5Request::ListWebhooks(params) => { + self.handle_list_webhooks(*counterparty_node_id, request_id.clone(), params) + }, + LSPS5Request::RemoveWebhook(params) => self.handle_remove_webhook( + *counterparty_node_id, + request_id.clone(), + params, + ), + }; + res + }, + _ => { + debug_assert!( + false, + "Service handler received LSPS5 response message. This should never happen." + ); + Err(LightningError { + err: format!("Service handler received LSPS5 response message from node {:?}. This should never happen.", counterparty_node_id), + action: ErrorAction::IgnoreAndLog(Level::Info) + }) + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::cell::RefCell; + + // Mock time provider for testing + struct MockTimeProvider { + current_time: RefCell, + } + + impl MockTimeProvider { + fn new(seconds_since_epoch: u64) -> Self { + Self { current_time: RefCell::new(Duration::from_secs(seconds_since_epoch)) } + } + + fn advance_time(&self, seconds: u64) { + let mut time = self.current_time.borrow_mut(); + *time += Duration::from_secs(seconds); + } + } + + impl TimeProvider for MockTimeProvider { + fn duration_since_epoch(&self) -> Duration { + *self.current_time.borrow() + } + } + + // Test for prune_stale_webhooks + #[test] + fn test_prune_stale_webhooks() { + let event_queue = Arc::new(EventQueue::new()); + let pending_messages = Arc::new(MessageQueue::new()); + let config = LSPS5ServiceConfig::default(); + let time_provider = Arc::new(MockTimeProvider::new(1000)); // Starting time + + let mut client_keys = Vec::new(); + for i in 0..3 { + let key = SecretKey::from_slice(&[i + 1; 32]).expect("Valid key slice"); + let pubkey = PublicKey::from_secret_key(&bitcoin::secp256k1::Secp256k1::new(), &key); + client_keys.push(pubkey); + } + + let nodes_with_channels = Arc::new(Mutex::new(new_hash_set())); + + let channels_for_closure = nodes_with_channels.clone(); + + let client_has_open_channel = Box::new(move |pubkey: &PublicKey| -> bool { + channels_for_closure.lock().unwrap().contains(pubkey) + }); + + let handler = LSPS5ServiceHandler::_new_with_custom_time_provider( + event_queue, + pending_messages, + client_has_open_channel, + config, + Some(time_provider.clone()), + ) + .unwrap(); + + { + let mut webhooks = handler.webhooks.lock().unwrap(); + for (i, pubkey) in client_keys.iter().enumerate() { + let client_webhooks = webhooks.entry(*pubkey).or_insert_with(new_hash_map); + for j in 0..2 { + let app_name = LSPS5AppName::new(format!("app_{}_{}", i, j)).unwrap(); + let url = + LSPS5WebhookUrl::new(format!("https://example.com/webhook_{}_{}", i, j)) + .unwrap(); + client_webhooks.insert( + app_name.clone(), + StoredWebhook { + _app_name: app_name, + url, + _counterparty_node_id: *pubkey, + last_used: time_provider.duration_since_epoch(), + last_notification_sent: new_hash_map(), + }, + ); + } + } + } + + for i in 0..2 { + nodes_with_channels.lock().unwrap().insert(client_keys[i]); + } + + time_provider.advance_time(15 * 24 * 60 * 60); + + handler.prune_stale_webhooks(); + + { + let webhooks = handler.webhooks.lock().unwrap(); + assert_eq!(webhooks.len(), 3); + } + + time_provider.advance_time(20 * 24 * 60 * 60); + + handler.prune_stale_webhooks(); + + { + let webhooks = handler.webhooks.lock().unwrap(); + assert_eq!(webhooks.len(), 2); + assert!(webhooks.contains_key(&client_keys[0])); + assert!(webhooks.contains_key(&client_keys[1])); + assert!(!webhooks.contains_key(&client_keys[2])); + } + + { + let mut channels = nodes_with_channels.lock().unwrap(); + channels.remove(&client_keys[1]); + } + + time_provider.advance_time(40 * 24 * 60 * 60); + + handler.prune_stale_webhooks(); + + { + let webhooks = handler.webhooks.lock().unwrap(); + assert_eq!(webhooks.len(), 1); + assert!(webhooks.contains_key(&client_keys[0])); + assert!(!webhooks.contains_key(&client_keys[1])); + assert!(!webhooks.contains_key(&client_keys[2])); + } + } +} From 2e11ed7398748c5b7da669e0bbf77b191e27c3ad Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 17 Apr 2025 19:02:08 -0300 Subject: [PATCH 10/18] fixup: Refactor LSPS5 service: use LSPSDateTime, cleanups, fixes - Use LSPSDateTime for webhook timestamps for future LDK serialization - Make MIN_WEBHOOK_RETENTION_DAYS and PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS Durations - Remove unnecessary Arc from Mutex fields (webhooks, last_pruning) - Use unwrap() for Mutex locks as per project convention - Move pruning interval to a const - Set last_pruning in prune_stale_webhooks after pruning - Remove unnecessary reassignments and error handling on locks - Use new_with_custom_time_provider for handler construction - Set PROTOCOL_NUMBER to 5 for LSPS5 - Rename _new_with_custom_time_provider to new_with_custom_time_provider - Make notification_cooldown_hours a Duration - Minor doc and formatting improvements --- lightning-liquidity/src/lsps5/service.rs | 448 +++++++---------------- 1 file changed, 139 insertions(+), 309 deletions(-) diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index 69785f60951..1272c1d850d 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -7,65 +7,65 @@ // You may not use this file except in accordance with one or both of these // licenses. -//! Service implementation for LSPS5 webhook registration +//! Service implementation for LSPS5 webhook registration. use crate::events::EventQueue; -use crate::lsps0::ser::{ - LSPSDateTime, LSPSProtocolMessageHandler, LSPSRequestId, LSPSResponseError, -}; +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::*; +use core::ops::Deref; use core::time::Duration; use bitcoin::secp256k1::{PublicKey, SecretKey}; +use lightning::ln::channelmanager::AChannelManager; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::util::logger::Level; use lightning::util::message_signing; use crate::alloc::string::ToString; use crate::sync::{Arc, Mutex}; -use alloc::boxed::Box; use alloc::string::String; use alloc::vec::Vec; -use serde_json::json; use super::event::LSPS5ServiceEvent; use super::msgs::{ - LSPS5AppName, LSPS5Message, LSPS5Request, LSPS5Response, LSPS5WebhookUrl, - LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE, LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE, + LSPS5AppName, LSPS5Error, LSPS5Message, LSPS5Request, LSPS5Response, LSPS5WebhookUrl, }; -/// Minimum number of days to retain webhooks after a client's last channel is closed -pub const MIN_WEBHOOK_RETENTION_DAYS: u32 = 30; +/// 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 +/// A stored webhook. #[derive(Debug, Clone)] struct StoredWebhook { - /// App name identifier for this webhook + /// App name identifier for this webhook. _app_name: LSPS5AppName, - /// The webhook URL + /// The webhook URL. url: LSPS5WebhookUrl, - /// Client node ID + /// Client node ID. _counterparty_node_id: PublicKey, - /// Last time this webhook was used - last_used: Duration, - /// Map of notification methods to last time they were sent - last_notification_sent: HashMap, + /// Last time this webhook was used. + last_used: LSPSDateTime, + /// Map of notification methods to last time they were sent. + last_notification_sent: HashMap, } -/// Trait defining a time provider for LSPS5 service +/// 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 +/// and to convert between timestamps and durations. +pub trait TimeProvider: Send + Sync { + /// Get the current time as a duration since the Unix epoch. fn duration_since_epoch(&self) -> Duration; } -/// Default time provider using the system clock +/// Default time provider using the system clock. #[derive(Clone, Debug)] #[cfg(feature = "time")] pub struct DefaultTimeProvider; @@ -78,113 +78,103 @@ impl TimeProvider for DefaultTimeProvider { } } -/// Configuration for LSPS5 service +/// Configuration for LSPS5 service. #[derive(Clone)] pub struct LSPS5ServiceConfig { - /// Maximum number of webhooks allowed per client (default: 10) + /// Maximum number of webhooks allowed per client. pub max_webhooks_per_client: u32, - /// Signing key for LSP notifications + /// Signing key for LSP notifications. pub signing_key: SecretKey, /// Minimum time between sending the same notification type in hours (default: 24) - pub notification_cooldown_hours: u64, + pub notification_cooldown_hours: Duration, } +/// Default maximum number of webhooks allowed per client. +pub const DEFAULT_MAX_WEBHOOKS_PER_CLIENT: u32 = 10; +/// Default notification cooldown time in hours. +pub const DEFAULT_NOTIFICATION_COOLDOWN_HOURS: Duration = Duration::from_secs(24 * 60 * 60); + impl Default for LSPS5ServiceConfig { fn default() -> Self { Self { - max_webhooks_per_client: 10, + max_webhooks_per_client: DEFAULT_MAX_WEBHOOKS_PER_CLIENT, signing_key: SecretKey::from_slice(&[1; 32]).expect("Static key should be valid"), - notification_cooldown_hours: 24, + notification_cooldown_hours: DEFAULT_NOTIFICATION_COOLDOWN_HOURS, } } } /// Service for handling LSPS5 webhook registration -pub struct LSPS5ServiceHandler { - /// Configuration parameters +pub struct LSPS5ServiceHandler +where + CM::Target: AChannelManager, +{ config: LSPS5ServiceConfig, - /// Map of client node IDs to their registered webhooks - webhooks: Arc>>>, - /// Event queue for emitting events + webhooks: Mutex>>, event_queue: Arc, - /// Message queue for sending responses pending_messages: Arc, - /// Time provider for LSPS5 service time_provider: Arc, - /// Function for checking if a client has an open channel - client_has_open_channel: Box bool>, - /// Last time the stale webhooks were pruned - last_pruning: Arc>>, + channel_manager: CM, + last_pruning: Mutex>, } -impl LSPS5ServiceHandler { - /// Create a new LSPS5 service handler +impl LSPS5ServiceHandler +where + CM::Target: AChannelManager, +{ + /// Create a new LSPS5 service handler. /// /// # Arguments - /// * `event_queue` - Event queue for emitting events - /// * `pending_messages` - Message queue for sending responses - /// * `client_has_open_channel` - Function that checks if a client has an open channel - /// * `config` - Configuration for the LSPS5 service - /// - /// # Panics - /// Will panic if no HTTP client is provided and a default one cannot be created + /// * `event_queue` - Event queue for emitting events. + /// * `pending_messages` - Message queue for sending responses. + /// * `client_has_open_channel` - Function that checks if a client has an open channel. + /// * `config` - Configuration for the LSPS5 service. #[cfg(feature = "time")] pub(crate) fn new( - event_queue: Arc, pending_messages: Arc, - client_has_open_channel: Box bool>, config: LSPS5ServiceConfig, - ) -> Option { + event_queue: Arc, pending_messages: Arc, channel_manager: CM, + config: LSPS5ServiceConfig, + ) -> Self { let time_provider = Arc::new(DefaultTimeProvider); - Some(Self { - config, - webhooks: Arc::new(Mutex::new(new_hash_map())), + Self::new_with_custom_time_provider( event_queue, pending_messages, + channel_manager, + config, time_provider, - client_has_open_channel, - last_pruning: Arc::new(Mutex::new(None)), - }) + ) } - pub(crate) fn _new_with_custom_time_provider( - event_queue: Arc, pending_messages: Arc, - client_has_open_channel: Box bool>, config: LSPS5ServiceConfig, - time_provider: Option>, - ) -> Option { - let time_provider = match time_provider { - Some(provider) => provider, - None => return None, - }; - Some(Self { + /// Create a new LSPS5 service handler with a custom time provider. + /// + /// # Arguments + /// * `event_queue` - Event queue for emitting events. + /// * `pending_messages` - Message queue for sending responses. + /// * `client_has_open_channel` - Function that checks if a client has an open channel. + /// * `config` - Configuration for the LSPS5 service. + /// * `time_provider` - Custom time provider. + pub(crate) fn new_with_custom_time_provider( + event_queue: Arc, pending_messages: Arc, channel_manager: CM, + config: LSPS5ServiceConfig, time_provider: Arc, + ) -> Self { + Self { config, - webhooks: Arc::new(Mutex::new(new_hash_map())), + webhooks: Mutex::new(new_hash_map()), event_queue, pending_messages, time_provider, - client_has_open_channel, - last_pruning: Arc::new(Mutex::new(None)), - }) + channel_manager, + last_pruning: Mutex::new(None), + } } fn check_prune_stale_webhooks(&self) -> Result<(), LightningError> { - let now = self.time_provider.duration_since_epoch(); + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); let should_prune = { - let mut last_pruning = self.last_pruning.lock().map_err(|_| LightningError { - err: "Failed to lock last_pruning mutex".to_string(), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; - - let should_run = match *last_pruning { - Some(last_time) => now - .checked_sub(last_time) - .map_or(false, |elapsed| elapsed > Duration::from_secs(24 * 60 * 60)), - None => true, - }; - - if should_run { - *last_pruning = Some(now); - } - - should_run + let last_pruning = self.last_pruning.lock().unwrap(); + last_pruning.as_ref().map_or(true, |last_time| { + now.abs_diff(last_time.clone()) > PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS.as_secs() + }) }; if should_prune { @@ -194,7 +184,7 @@ impl LSPS5ServiceHandler { Ok(()) } - /// Handle a set_webhook request + /// Handle a set_webhook request. pub fn handle_set_webhook( &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, params: SetWebhookRequest, @@ -202,33 +192,11 @@ impl LSPS5ServiceHandler { let event_queue_notifier = self.event_queue.notifier(); self.check_prune_stale_webhooks()?; - if let Err(e) = params.app_name.validate() { - let msg = LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(e.clone())) - .into(); - self.pending_messages.enqueue(&counterparty_node_id, msg); - return Err(LightningError { - err: e.message, - action: ErrorAction::IgnoreAndLog(Level::Info), - }); - } - - if let Err(e) = params.webhook.validate() { - let msg = LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(e.clone())) - .into(); - self.pending_messages.enqueue(&counterparty_node_id, msg); - return Err(LightningError { - err: format!("Error handling SetWebhook request: {}", e.message), - action: ErrorAction::IgnoreAndLog(Level::Info), - }); - } - - let mut webhooks = self.webhooks.lock().map_err(|_| LightningError { - err: "Failed to lock webhooks mutex".to_string(), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; + let mut webhooks = self.webhooks.lock().unwrap(); let client_webhooks = webhooks.entry(counterparty_node_id).or_insert_with(new_hash_map); - let now = self.time_provider.duration_since_epoch(); + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); let no_change = client_webhooks .get(¶ms.app_name) @@ -241,16 +209,9 @@ impl LSPS5ServiceHandler { "Maximum of {} webhooks allowed per client", self.config.max_webhooks_per_client ); - let error_response = LSPSResponseError { - code: LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE, - message: message.clone(), - data: Some( - json!({ "max_webhooks": self.config.max_webhooks_per_client }).to_string(), - ), - }; + let error = LSPS5Error::TooManyWebhooks(message.clone()); let msg = - LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(error_response)) - .into(); + LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(error)).into(); self.pending_messages.enqueue(&counterparty_node_id, msg); return Err(LightningError { err: message, @@ -298,7 +259,7 @@ impl LSPS5ServiceHandler { Ok(()) } - /// Handle a list_webhooks request + /// Handle a list_webhooks request. pub fn handle_list_webhooks( &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, _params: ListWebhooksRequest, @@ -306,10 +267,7 @@ impl LSPS5ServiceHandler { let event_queue_notifier = self.event_queue.notifier(); self.check_prune_stale_webhooks()?; - let webhooks = self.webhooks.lock().map_err(|_| LightningError { - err: "Failed to lock webhooks mutex".to_string(), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; + let webhooks = self.webhooks.lock().unwrap(); let app_names = webhooks .get(&counterparty_node_id) @@ -332,7 +290,7 @@ impl LSPS5ServiceHandler { Ok(()) } - /// Handle a remove_webhook request + /// Handle a remove_webhook request. pub fn handle_remove_webhook( &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, params: RemoveWebhookRequest, @@ -341,10 +299,7 @@ impl LSPS5ServiceHandler { // Check if we need to prune stale webhooks self.check_prune_stale_webhooks()?; - let mut webhooks = self.webhooks.lock().map_err(|_| LightningError { - err: "Failed to lock webhooks mutex".to_string(), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; + 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() { @@ -366,15 +321,9 @@ impl LSPS5ServiceHandler { } let error_message = format!("App name not found: {}", params.app_name); - let error_response = LSPSResponseError { - code: LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE, - message: error_message.clone(), - data: None, - }; - + let error = LSPS5Error::AppNameNotFound(error_message.clone()); let msg = - LSPS5Message::Response(request_id, LSPS5Response::RemoveWebhookError(error_response)) - .into(); + LSPS5Message::Response(request_id, LSPS5Response::RemoveWebhookError(error)).into(); self.pending_messages.enqueue(&counterparty_node_id, msg); return Err(LightningError { @@ -383,11 +332,11 @@ impl LSPS5ServiceHandler { }); } - /// Send a webhook_registered notification to a newly registered webhook + /// Send a webhook_registered notification to a newly registered webhook. /// /// According to spec: /// "Only the newly-registered webhook is notified. - /// Only the newly-registered webhook is contacted for this notification" + /// Only the newly-registered webhook is contacted for this notification". fn send_webhook_registered_notification( &self, client_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl, ) -> Result<(), LightningError> { @@ -395,13 +344,13 @@ impl LSPS5ServiceHandler { self.send_notification(client_node_id, app_name.clone(), url.clone(), notification) } - /// Send an incoming_payment notification to all of a client's webhooks + /// Send an incoming_payment notification to all of a client's webhooks. pub fn notify_payment_incoming(&self, client_id: PublicKey) -> Result<(), LightningError> { let notification = WebhookNotification::payment_incoming(); self.broadcast_notification(client_id, notification) } - /// Send an expiry_soon notification to all of a client's webhooks + /// Send an expiry_soon notification to all of a client's webhooks. pub fn notify_expiry_soon( &self, client_id: PublicKey, timeout: u32, ) -> Result<(), LightningError> { @@ -409,7 +358,7 @@ impl LSPS5ServiceHandler { self.broadcast_notification(client_id, notification) } - /// Send a liquidity_management_request notification to all of a client's webhooks + /// Send a liquidity_management_request notification to all of a client's webhooks. pub fn notify_liquidity_management_request( &self, client_id: PublicKey, ) -> Result<(), LightningError> { @@ -417,7 +366,7 @@ impl LSPS5ServiceHandler { self.broadcast_notification(client_id, notification) } - /// Send an onion_message_incoming notification to all of a client's webhooks + /// Send an onion_message_incoming notification to all of a client's webhooks. pub fn notify_onion_message_incoming( &self, client_id: PublicKey, ) -> Result<(), LightningError> { @@ -425,7 +374,7 @@ impl LSPS5ServiceHandler { self.broadcast_notification(client_id, notification) } - /// Broadcast a notification to all registered webhooks for a client + /// Broadcast a notification to all registered webhooks for a client. /// /// According to spec: /// "The LSP SHOULD contact all registered webhook URIs, if: @@ -435,29 +384,26 @@ impl LSPS5ServiceHandler { fn broadcast_notification( &self, client_id: PublicKey, notification: WebhookNotification, ) -> Result<(), LightningError> { - let mut webhooks = self.webhooks.lock().map_err(|_| LightningError { - err: "Failed to lock webhooks mutex".to_string(), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; + 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 = self.time_provider.duration_since_epoch(); - let cooldown_duration = Duration::from_secs(self.config.notification_cooldown_hours * 3600); + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + let cooldown_duration = self.config.notification_cooldown_hours * 3600; for (app_name, webhook) in client_webhooks.iter_mut() { if webhook .last_notification_sent .get(¬ification.method) - .and_then(|last_sent| now.checked_sub(*last_sent)) - .map_or(true, |duration| duration >= cooldown_duration) + .map(|last_sent| now.clone().abs_diff(last_sent.clone())) + .map_or(true, |duration| duration >= cooldown_duration.as_secs()) { - webhook.last_notification_sent.insert(notification.method.clone(), now); - webhook.last_used = now; - + webhook.last_notification_sent.insert(notification.method.clone(), now.clone()); + webhook.last_used = now.clone(); self.send_notification( client_id, app_name.clone(), @@ -470,15 +416,14 @@ impl LSPS5ServiceHandler { Ok(()) } - /// Send a notification to a webhook URL + /// Send a notification to a webhook URL. fn send_notification( &self, counterparty_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl, notification: WebhookNotification, ) -> Result<(), LightningError> { let event_queue_notifier = self.event_queue.notifier(); let timestamp = - LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()) - .to_rfc3339(); + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); let notification_json = serde_json::to_string(¬ification).map_err(|e| LightningError { @@ -490,7 +435,7 @@ impl LSPS5ServiceHandler { let headers = vec![ ("Content-Type".to_string(), "application/json".to_string()), - ("x-lsps5-timestamp".to_string(), timestamp.clone()), + ("x-lsps5-timestamp".to_string(), timestamp.to_rfc3339()), ("x-lsps5-signature".to_string(), signature_hex.clone()), ]; @@ -507,7 +452,7 @@ impl LSPS5ServiceHandler { Ok(()) } - /// Sign a webhook notification with an LSP's signing key + /// Sign a webhook notification with an LSP's signing key. /// /// This function takes a notification body and timestamp and returns a signature /// in the format required by the LSPS5 specification. @@ -521,47 +466,58 @@ impl LSPS5ServiceHandler { /// # Returns /// /// * The zbase32 encoded signature as specified in LSPS0, or an error if signing fails - pub fn sign_notification(&self, body: &str, timestamp: &str) -> Result { + pub fn sign_notification( + &self, body: &str, timestamp: &LSPSDateTime, + ) -> Result { // Create the message to sign // According to spec: // The message to be signed is: "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", let message = format!( "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", - timestamp, body + timestamp.to_rfc3339(), + body ); Ok(message_signing::sign(message.as_bytes(), &self.config.signing_key)) } - /// Clean up webhooks for clients with no channels that haven't been used in a while - /// According to spec: "MUST remember all webhooks for at least 7 days after the last channel is closed" + /// Clean up webhooks for clients with no channels that haven't been used in a while. + /// According to spec: "MUST remember all webhooks for at least 7 days after the last channel is closed". fn prune_stale_webhooks(&self) { - let now = self.time_provider.duration_since_epoch(); - let webhooks_lock = match self.webhooks.lock() { - Ok(guard) => guard, - Err(_) => return, - }; - let mut webhooks = webhooks_lock; - let retention_period = - Duration::from_secs(MIN_WEBHOOK_RETENTION_DAYS as u64 * 24 * 60 * 60); + 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) { + if !self.client_has_open_channel(client_id) { client_webhooks.retain(|_, webhook| { - now.checked_sub(webhook.last_used) - .map_or(true, |duration| duration < retention_period) + now.abs_diff(webhook.last_used.clone()) < 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 { +impl LSPSProtocolMessageHandler for LSPS5ServiceHandler +where + CM::Target: AChannelManager, +{ type ProtocolMessage = LSPS5Message; - const PROTOCOL_NUMBER: Option = Some(2); + const PROTOCOL_NUMBER: Option = Some(5); fn handle_message( &self, message: Self::ProtocolMessage, counterparty_node_id: &PublicKey, @@ -596,129 +552,3 @@ impl LSPSProtocolMessageHandler for LSPS5ServiceHandler { } } } - -#[cfg(test)] -mod tests { - use super::*; - use core::cell::RefCell; - - // Mock time provider for testing - struct MockTimeProvider { - current_time: RefCell, - } - - impl MockTimeProvider { - fn new(seconds_since_epoch: u64) -> Self { - Self { current_time: RefCell::new(Duration::from_secs(seconds_since_epoch)) } - } - - fn advance_time(&self, seconds: u64) { - let mut time = self.current_time.borrow_mut(); - *time += Duration::from_secs(seconds); - } - } - - impl TimeProvider for MockTimeProvider { - fn duration_since_epoch(&self) -> Duration { - *self.current_time.borrow() - } - } - - // Test for prune_stale_webhooks - #[test] - fn test_prune_stale_webhooks() { - let event_queue = Arc::new(EventQueue::new()); - let pending_messages = Arc::new(MessageQueue::new()); - let config = LSPS5ServiceConfig::default(); - let time_provider = Arc::new(MockTimeProvider::new(1000)); // Starting time - - let mut client_keys = Vec::new(); - for i in 0..3 { - let key = SecretKey::from_slice(&[i + 1; 32]).expect("Valid key slice"); - let pubkey = PublicKey::from_secret_key(&bitcoin::secp256k1::Secp256k1::new(), &key); - client_keys.push(pubkey); - } - - let nodes_with_channels = Arc::new(Mutex::new(new_hash_set())); - - let channels_for_closure = nodes_with_channels.clone(); - - let client_has_open_channel = Box::new(move |pubkey: &PublicKey| -> bool { - channels_for_closure.lock().unwrap().contains(pubkey) - }); - - let handler = LSPS5ServiceHandler::_new_with_custom_time_provider( - event_queue, - pending_messages, - client_has_open_channel, - config, - Some(time_provider.clone()), - ) - .unwrap(); - - { - let mut webhooks = handler.webhooks.lock().unwrap(); - for (i, pubkey) in client_keys.iter().enumerate() { - let client_webhooks = webhooks.entry(*pubkey).or_insert_with(new_hash_map); - for j in 0..2 { - let app_name = LSPS5AppName::new(format!("app_{}_{}", i, j)).unwrap(); - let url = - LSPS5WebhookUrl::new(format!("https://example.com/webhook_{}_{}", i, j)) - .unwrap(); - client_webhooks.insert( - app_name.clone(), - StoredWebhook { - _app_name: app_name, - url, - _counterparty_node_id: *pubkey, - last_used: time_provider.duration_since_epoch(), - last_notification_sent: new_hash_map(), - }, - ); - } - } - } - - for i in 0..2 { - nodes_with_channels.lock().unwrap().insert(client_keys[i]); - } - - time_provider.advance_time(15 * 24 * 60 * 60); - - handler.prune_stale_webhooks(); - - { - let webhooks = handler.webhooks.lock().unwrap(); - assert_eq!(webhooks.len(), 3); - } - - time_provider.advance_time(20 * 24 * 60 * 60); - - handler.prune_stale_webhooks(); - - { - let webhooks = handler.webhooks.lock().unwrap(); - assert_eq!(webhooks.len(), 2); - assert!(webhooks.contains_key(&client_keys[0])); - assert!(webhooks.contains_key(&client_keys[1])); - assert!(!webhooks.contains_key(&client_keys[2])); - } - - { - let mut channels = nodes_with_channels.lock().unwrap(); - channels.remove(&client_keys[1]); - } - - time_provider.advance_time(40 * 24 * 60 * 60); - - handler.prune_stale_webhooks(); - - { - let webhooks = handler.webhooks.lock().unwrap(); - assert_eq!(webhooks.len(), 1); - assert!(webhooks.contains_key(&client_keys[0])); - assert!(!webhooks.contains_key(&client_keys[1])); - assert!(!webhooks.contains_key(&client_keys[2])); - } - } -} From eef8b7624cb6a74b21f19bd1b4ad1e0366fdb7f0 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 24 Mar 2025 17:00:09 -0300 Subject: [PATCH 11/18] Add LSPS5 webhook client implementation Implements the client-side functionality for LSPS5 webhook registration, allowing Lightning clients to register, list, and remove webhooks with LSPs. This client handler processes responses and verifies webhook notification signatures. Key features: - Full client API for webhook registration operations - Per-peer state tracking for pending requests - Automatic request timeout and cleanup - Security validation for webhook URLs - Notification signature verification - Add store_signature and check_signature to prevent replay attacks - Some tests are provided but more will come in a future commit This implementation pairs with the service-side LSPS5 webhook handler to complete the webhook registration protocol according to the LSPS5 specification. --- lightning-liquidity/src/lsps5/client.rs | 845 ++++++++++++++++++++++++ 1 file changed, 845 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..b5cc99beb6b --- /dev/null +++ b/lightning-liquidity/src/lsps5/client.rs @@ -0,0 +1,845 @@ +// 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::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, WebhookNotification, +}; +use crate::message_queue::MessageQueue; + +use bitcoin::secp256k1::PublicKey; +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::util::message_signing; + +use crate::sync::{Arc, Mutex, RwLock}; +use core::ops::Deref; + +use crate::alloc::string::ToString; +use crate::prelude::{new_hash_map, HashMap}; +use alloc::string::String; + +use super::msgs::{LSPS5AppName, LSPS5WebhookUrl}; +#[cfg(feature = "time")] +use super::service::DefaultTimeProvider; +use super::service::TimeProvider; +use crate::utils::generate_request_id; +use alloc::collections::VecDeque; +use core::str::FromStr; +use core::time::Duration; +use lightning::sign::EntropySource; +use lightning::util::logger::Level; + +/// Default maximum age in seconds for cached responses (1 hour) +pub const DEFAULT_RESPONSE_MAX_AGE_SECS: u64 = 3600; + +/// 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; + +/// 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: u64, +} + +impl Default for SignatureStorageConfig { + fn default() -> Self { + Self { + max_signatures: DEFAULT_MAX_SIGNATURES, + retention_minutes: DEFAULT_SIGNATURE_RETENTION_MINUTES, + } + } +} + +#[derive(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: u64, + /// Configuration for signature storage + pub signature_config: SignatureStorageConfig, +} + +impl Default for LSPS5ClientConfig { + fn default() -> Self { + Self { + response_max_age_secs: DEFAULT_RESPONSE_MAX_AGE_SECS, + signature_config: SignatureStorageConfig::default(), + } + } +} + +struct PeerState { + pending_set_webhook_requests: HashMap, // RequestId -> (app_name, webhook_url, timestamp) + pending_list_webhooks_requests: HashMap, // RequestId -> timestamp + pending_remove_webhook_requests: HashMap, // RequestId -> (app_name, timestamp) + last_cleanup: Option, // Seconds since epoch +} + +impl PeerState { + fn new() -> 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, + } + } + fn cleanup_expired_responses( + &mut self, max_age_secs: u64, time_provider: Arc, + ) { + let now = time_provider.duration_since_epoch(); + + // Only run cleanup once per minute to avoid excessive processing + if let Some(last_cleanup) = self.last_cleanup { + match now.checked_sub(last_cleanup) { + Some(elapsed) if elapsed < Duration::from_secs(60) => return, + None => return, + _ => {}, + } + } + + self.last_cleanup = Some(now); + + let cutoff = match u64::try_into(max_age_secs) { + Ok(secs) => match now.checked_sub(Duration::from_secs(secs)) { + Some(time) => time, + None => return, + }, + Err(_) => { + // If conversion fails, use a safe default + match now.checked_sub(Duration::from_secs(3600)) { + Some(time) => time, + None => return, + } + }, + }; + + self.pending_set_webhook_requests.retain(|_, (_, _, timestamp)| *timestamp > cutoff); + self.pending_list_webhooks_requests.retain(|_, timestamp| *timestamp > cutoff); + self.pending_remove_webhook_requests.retain(|_, (_, timestamp)| *timestamp > cutoff); + } +} + +/// LSPS5 client handler +pub struct LSPS5ClientHandler +where + ES::Target: EntropySource, +{ + /// Pending messages to be sent + pending_messages: Arc, + /// Event queue for emitting events + pending_events: Arc, + /// Entropy source + entropy_source: ES, + /// Per peer state for tracking requests + per_peer_state: RwLock>>, + /// Client configuration + config: LSPS5ClientConfig, + /// Time provider for LSPS5 service + time_provider: Arc, + /// Map of recently used signatures to prevent replay attacks + recent_signatures: Arc>>, +} + +impl LSPS5ClientHandler +where + ES::Target: EntropySource, +{ + /// Creates a new LSPS5 client handler with the provided entropy source, message queue, + /// event queue, and LSPS5ClientConfig + #[cfg(feature = "time")] + pub(crate) fn new( + entropy_source: ES, pending_messages: Arc, pending_events: Arc, + config: LSPS5ClientConfig, + ) -> Option { + let max_signatures = config.signature_config.max_signatures.clone(); + Some(Self { + pending_messages, + pending_events, + entropy_source, + per_peer_state: RwLock::new(new_hash_map()), + config, + time_provider: Arc::new(DefaultTimeProvider), + recent_signatures: Arc::new(Mutex::new(VecDeque::with_capacity(max_signatures))), + }) + } + + pub(crate) fn _new_with_custom_time_provider( + entropy_source: ES, pending_messages: Arc, pending_events: Arc, + config: LSPS5ClientConfig, time_provider: Option>, + ) -> Option { + let max_signatures = config.signature_config.max_signatures.clone(); + let time_provider = match time_provider { + Some(provider) => provider, + None => return None, + }; + Some(Self { + pending_messages, + pending_events, + entropy_source, + per_peer_state: RwLock::new(new_hash_map()), + config, + time_provider, + recent_signatures: Arc::new(Mutex::new(VecDeque::with_capacity(max_signatures))), + }) + } + + fn with_peer_state( + &self, counterparty_node_id: PublicKey, f: F, + ) -> Result + where + F: FnOnce(&mut PeerState) -> R, + { + let mut outer_state_lock = self.per_peer_state.write().map_err(|_| LightningError { + err: "Failed to execute write on per_peer_state".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + let inner_state_lock = + outer_state_lock.entry(counterparty_node_id).or_insert(Mutex::new(PeerState::new())); + let mut peer_state_lock = inner_state_lock.lock().map_err(|_| LightningError { + err: "Failed to lock inner_state_lock mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + peer_state_lock.cleanup_expired_responses( + self.config.response_max_age_secs, + Arc::clone(&self.time_provider), + ); + + Ok(f(&mut *peer_state_lock)) + } + + /// Register a webhook with the LSP + /// + /// Implements the `lsps5.set_webhook` method from bLIP-55. + /// + /// # Parameters + /// * `app_name` - A human-readable UTF-8 string that gives a name to the webhook (max 64 bytes) + /// * `webhook` - The URL of the webhook that the LSP can use to push notifications (max 1024 chars) + /// + /// # Returns + /// * Success - the request ID that was used + /// * Error - validation error or error sending the request + /// + /// Response will be provided asynchronously through the event queue as a + /// WebhookRegistered or WebhookRegistrationFailed event. + pub fn set_webhook( + &self, counterparty_node_id: PublicKey, app_name: String, webhook_url: String, + ) -> Result { + let app_name = LSPS5AppName::new(app_name).map_err(|e| LightningError { + err: e.message, + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let lsps_webhook_url = LSPS5WebhookUrl::new(webhook_url).map_err(|e| LightningError { + err: e.message, + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + 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(), + 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 registered webhooks + /// + /// Implements the `lsps5.list_webhooks` method from bLIP-55. + /// + /// # Returns + /// * Success - the request ID that was used + /// * Error - error sending the request + /// + /// Response will be provided asynchronously through the event queue as a + /// WebhooksListed or WebhooksListFailed event. + pub fn list_webhooks( + &self, counterparty_node_id: PublicKey, + ) -> Result { + let request_id = generate_request_id(&self.entropy_source); + let now = 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)); + + Ok(request_id) + } + + /// Remove a webhook by app_name + /// + /// Implements the `lsps5.remove_webhook` method from bLIP-55. + /// + /// # Parameters + /// * `app_name` - The name of the webhook to remove + /// + /// # Returns + /// * Success - the request ID that was used + /// * Error - error sending the request + /// + /// Response will be provided asynchronously through the event queue as a + /// WebhookRemoved or WebhookRemovalFailed event. + pub fn remove_webhook( + &self, counterparty_node_id: PublicKey, app_name: String, + ) -> Result { + let app_name = LSPS5AppName::new(app_name).map_err(|e| LightningError { + err: e.message, + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let request_id = generate_request_id(&self.entropy_source); + let now = 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) + } + + /// Handle received messages from the LSP + pub fn handle_message( + &self, message: LSPS5Message, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + let event_queue_notifier = self.pending_events.notifier(); + match message { + LSPS5Message::Response(request_id, response) => { + let mut result = Err(LightningError { + err: format!( + "Received LSPS5 response from unknown peer: {}", + counterparty_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Error), + }); + + self.with_peer_state(*counterparty_node_id, |peer_state| { + if let Some((app_name, webhook_url, _)) = + peer_state.pending_set_webhook_requests.remove(&request_id) + { + match response { + LSPS5Response::SetWebhook(response) => { + event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookRegistered { + counterparty_node_id: *counterparty_node_id, + num_webhooks: response.num_webhooks, + max_webhooks: response.max_webhooks, + no_change: response.no_change, + app_name, + url: webhook_url, + request_id, + }); + result = Ok(()); + }, + LSPS5Response::SetWebhookError(error) => { + event_queue_notifier.enqueue( + LSPS5ClientEvent::WebhookRegistrationFailed { + counterparty_node_id: *counterparty_node_id, + error_code: error.code, + error_message: error.message, + app_name, + url: webhook_url, + request_id, + }, + ); + result = Ok(()); + }, + _ => { + result = Err(LightningError { + err: "Unexpected response type for SetWebhook request" + .to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + }); + }, + } + } else if peer_state + .pending_list_webhooks_requests + .remove(&request_id) + .is_some() + { + match response { + LSPS5Response::ListWebhooks(response) => { + event_queue_notifier.enqueue(LSPS5ClientEvent::WebhooksListed { + counterparty_node_id: *counterparty_node_id, + app_names: response.app_names, + max_webhooks: response.max_webhooks, + request_id, + }); + result = Ok(()); + }, + LSPS5Response::ListWebhooksError(error) => { + event_queue_notifier.enqueue( + LSPS5ClientEvent::WebhooksListFailed { + counterparty_node_id: *counterparty_node_id, + error_code: error.code, + error_message: error.message, + request_id, + }, + ); + result = Ok(()); + }, + _ => { + result = Err(LightningError { + err: "Unexpected response type for ListWebhooks request" + .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(error) => { + event_queue_notifier.enqueue( + LSPS5ClientEvent::WebhookRemovalFailed { + counterparty_node_id: *counterparty_node_id, + error_code: error.code, + error_message: error.message, + app_name, + request_id, + }, + ); + result = Ok(()); + }, + _ => { + result = Err(LightningError { + err: "Unexpected response type for RemoveWebhook request" + .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), + }); + } + })?; + + result + }, + LSPS5Message::Request(_, _) => { + // We're a client, so we don't expect to receive requests + Err(LightningError { + err: format!( + "Received unexpected request message from {}", + counterparty_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }) + }, + } + } + + /// Verify a webhook notification signature from an LSP + /// + /// This can be used by a notification delivery service to verify + /// the authenticity of notifications received from an LSP. + /// + /// # Parameters + /// * `timestamp` - The ISO8601 timestamp from the notification + /// * `signature` - The signature string from the notification + /// * `notification` - The webhook notification object + /// + /// # Returns + /// * On success: `true` if the signature is valid + /// * On error: LightningError with error description + pub fn verify_notification_signature( + &self, counterparty_node_id: PublicKey, timestamp: &str, signature: &str, + notification: &WebhookNotification, + ) -> Result { + LSPSDateTime::from_str(timestamp) + .map_err(|_| LightningError { + err: format!("Invalid timestamp format: {}", timestamp), + action: ErrorAction::IgnoreAndLog(Level::Error), + }) + .and_then(|signature_timestamp| { + let now = LSPSDateTime::new_from_duration_since_epoch( + self.time_provider.duration_since_epoch(), + ); + let diff = signature_timestamp.abs_diff(&now); + + if diff > 600 { + return Err(LightningError { + err: format!("Timestamp too old: {}", timestamp), + action: ErrorAction::IgnoreAndLog(Level::Error), + }); + } + + Ok(()) + })?; + + let notification_json = + serde_json::to_string(notification).map_err(|e| LightningError { + err: format!("Failed to serialize notification: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let message = format!( + "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + timestamp, notification_json + ); + + if message_signing::verify(message.as_bytes(), signature, &counterparty_node_id) { + Ok(true) + } else { + Err(LightningError { + err: "Invalid signature".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + }) + } + } + + /// Check if a signature has been used before + fn check_signature_exists(&self, signature: &str) -> Result<(), LightningError> { + let recent_signatures = self.recent_signatures.lock().map_err(|_| LightningError { + err: "Failed to lock recent_signatures mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + for (stored_sig, _) in recent_signatures.iter() { + if stored_sig == signature { + return Err(LightningError { + err: "Replay attack detected: signature has been used before".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Warn), + }); + } + } + + Ok(()) + } + + /// Store a signature with timestamp for replay attack prevention + fn store_signature(&self, signature: String) -> Result<(), LightningError> { + let now = self.time_provider.duration_since_epoch(); + let mut recent_signatures = self.recent_signatures.lock().map_err(|_| LightningError { + err: "Failed to lock recent_signatures mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + recent_signatures.push_back((signature, now)); + + let retention_duration = + Duration::from_secs(self.config.signature_config.retention_minutes * 60); + while let Some((_, time)) = recent_signatures.front() { + if now.checked_sub(*time).map_or(false, |duration| duration > retention_duration) { + recent_signatures.pop_front(); + } else { + break; + } + } + + while recent_signatures.len() > self.config.signature_config.max_signatures { + recent_signatures.pop_front(); + } + + Ok(()) + } + + /// Parse a webhook notification received from an LSP + /// + /// This can be used by a client implementation to handle webhook + /// notifications after they're delivered through a push notification + /// system. + /// + /// # Parameters + /// * `timestamp` - The ISO8601 timestamp from the notification + /// * `signature` - The signature from the notification + /// * `notification_json` - The JSON string of the notification object + /// + /// # Returns + /// * On success: The parsed webhook notification + /// * On error: LightningError with error description + pub fn parse_webhook_notification( + &self, counterparty_node_id: PublicKey, timestamp: &str, signature: &str, + notification_json: &str, + ) -> Result { + let event_queue_notifier = self.pending_events.notifier(); + let notification: WebhookNotification = + serde_json::from_str(notification_json).map_err(|e| LightningError { + err: format!("Failed to parse notification: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + self.check_signature_exists(signature)?; + + self.store_signature(signature.to_string())?; + + match self.verify_notification_signature( + counterparty_node_id, + timestamp, + signature, + ¬ification, + ) { + Ok(signature_valid) => { + event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookNotificationReceived { + counterparty_node_id, + notification: notification.clone(), + timestamp: timestamp.to_string(), + signature_valid, + }); + Ok(notification) + }, + Err(e) => Err(e), + } + } +} + +impl LSPSProtocolMessageHandler for LSPS5ClientHandler +where + ES::Target: EntropySource, +{ + 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(test)] +mod tests { + #![cfg(all(test, feature = "time"))] + use super::*; + use crate::{ + lsps0::ser::LSPSRequestId, lsps5::msgs::SetWebhookResponse, tests::utils::TestEntropy, + }; + use bitcoin::{key::Secp256k1, secp256k1::SecretKey}; + + fn setup_test_client() -> ( + LSPS5ClientHandler>, + 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( + test_entropy_source, + message_queue.clone(), + event_queue.clone(), + LSPS5ClientConfig::default(), + ) + .unwrap(); + + 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::new(APP_NAME.to_string()).unwrap(); + let lsps5_webhook_url = LSPS5WebhookUrl::new(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).unwrap(); + 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::new(OLD_APP_NAME.to_string()).unwrap(); + let lsps5_new_app_name = LSPS5AppName::new(NEW_APP_NAME.to_string()).unwrap(); + let lsps5_webhook_url = LSPS5WebhookUrl::new(WEBHOOK_URL.to_string()).unwrap(); + let now = time_provider.duration_since_epoch(); + let mut peer_state = PeerState::new(); + peer_state.last_cleanup = Some(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(), + 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, + now.checked_sub(Duration::from_secs(600)).unwrap(), + ), // 10 minutes old + ); + + peer_state.cleanup_expired_responses(1800, time_provider.clone()); + + assert!(!peer_state.pending_set_webhook_requests.contains_key(&old_request_id)); + let cleanup_age = if let Some(last_cleanup) = peer_state.last_cleanup { + time_provider.duration_since_epoch().checked_sub(last_cleanup).unwrap() + } else { + Duration::from_secs(0) + }; + assert!(cleanup_age < Duration::from_secs(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 70d9bf2d86a8dab2de6fc6e569da7dc351636ba9 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 17 Apr 2025 18:42:43 -0300 Subject: [PATCH 12/18] fixup: Refactors in Client - Use Duration type consistently for time-related configurations instead of u64 - Replace raw durations with LSPSDateTime for timestamp tracking and comparison - Do unwrap directly on mutex locks - Clean up code organization (imports, visibility modifiers) - Use the new LSPS5Error --- lightning-liquidity/src/lsps5/client.rs | 368 +++++++++++------------- 1 file changed, 171 insertions(+), 197 deletions(-) diff --git a/lightning-liquidity/src/lsps5/client.rs b/lightning-liquidity/src/lsps5/client.rs index b5cc99beb6b..5a750916262 100644 --- a/lightning-liquidity/src/lsps5/client.rs +++ b/lightning-liquidity/src/lsps5/client.rs @@ -3,11 +3,13 @@ // // 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 +// , 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 +//! 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; @@ -16,52 +18,49 @@ use crate::lsps5::msgs::{ SetWebhookRequest, WebhookNotification, }; use crate::message_queue::MessageQueue; - -use bitcoin::secp256k1::PublicKey; -use lightning::ln::msgs::{ErrorAction, LightningError}; -use lightning::util::message_signing; - -use crate::sync::{Arc, Mutex, RwLock}; -use core::ops::Deref; - -use crate::alloc::string::ToString; use crate::prelude::{new_hash_map, HashMap}; -use alloc::string::String; +use crate::sync::{Arc, Mutex, RwLock}; +use crate::utils::generate_request_id; use super::msgs::{LSPS5AppName, LSPS5WebhookUrl}; #[cfg(feature = "time")] use super::service::DefaultTimeProvider; use super::service::TimeProvider; -use crate::utils::generate_request_id; + use alloc::collections::VecDeque; -use core::str::FromStr; -use core::time::Duration; +use alloc::string::String; +use bitcoin::secp256k1::PublicKey; +use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::sign::EntropySource; use lightning::util::logger::Level; +use lightning::util::message_signing; -/// Default maximum age in seconds for cached responses (1 hour) +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; -/// Default retention time for signatures in minutes (LSPS5 spec requires min 20 minutes) +/// 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 +/// Default maximum number of stored signatures. pub const DEFAULT_MAX_SIGNATURES: usize = 1000; -/// Configuration for signature storage +/// Configuration for signature storage. #[derive(Clone, Copy, Debug)] pub struct SignatureStorageConfig { - /// Maximum number of signatures to store + /// Maximum number of signatures to store. pub max_signatures: usize, - /// Retention time for signatures in minutes - pub retention_minutes: u64, + /// Retention time for signatures in minutes. + pub retention_minutes: Duration, } impl Default for SignatureStorageConfig { fn default() -> Self { Self { max_signatures: DEFAULT_MAX_SIGNATURES, - retention_minutes: DEFAULT_SIGNATURE_RETENTION_MINUTES, + retention_minutes: Duration::from_secs(DEFAULT_SIGNATURE_RETENTION_MINUTES * 60), } } } @@ -69,92 +68,86 @@ impl Default for SignatureStorageConfig { #[derive(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: u64, - /// Configuration for signature storage + /// Maximum age in seconds for cached responses (default: 3600 - 1 hour). + pub response_max_age_secs: Duration, + /// Configuration for signature storage. pub signature_config: SignatureStorageConfig, } impl Default for LSPS5ClientConfig { fn default() -> Self { Self { - response_max_age_secs: DEFAULT_RESPONSE_MAX_AGE_SECS, + response_max_age_secs: Duration::from_secs(DEFAULT_RESPONSE_MAX_AGE_SECS), signature_config: SignatureStorageConfig::default(), } } } struct PeerState { - pending_set_webhook_requests: HashMap, // RequestId -> (app_name, webhook_url, timestamp) - pending_list_webhooks_requests: HashMap, // RequestId -> timestamp - pending_remove_webhook_requests: HashMap, // RequestId -> (app_name, timestamp) - last_cleanup: Option, // Seconds since epoch + pending_set_webhook_requests: + HashMap, // RequestId -> (app_name, webhook_url, timestamp) + pending_list_webhooks_requests: HashMap, // RequestId -> timestamp + pending_remove_webhook_requests: HashMap, // RequestId -> (app_name, timestamp) + last_cleanup: Option, + max_age_secs: Duration, + time_provider: Arc, } impl PeerState { - fn new() -> Self { + fn new(max_age_secs: Duration, time_provider: Arc) -> 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, max_age_secs: u64, time_provider: Arc, - ) { - let now = time_provider.duration_since_epoch(); + 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 - if let Some(last_cleanup) = self.last_cleanup { - match now.checked_sub(last_cleanup) { - Some(elapsed) if elapsed < Duration::from_secs(60) => return, - None => return, - _ => {}, + let minute = 60; + if let Some(last_cleanup) = &self.last_cleanup { + if now.abs_diff(last_cleanup.clone()) < minute { + return; } } - self.last_cleanup = Some(now); - - let cutoff = match u64::try_into(max_age_secs) { - Ok(secs) => match now.checked_sub(Duration::from_secs(secs)) { - Some(time) => time, - None => return, - }, - Err(_) => { - // If conversion fails, use a safe default - match now.checked_sub(Duration::from_secs(3600)) { - Some(time) => time, - None => return, - } - }, - }; + self.last_cleanup = Some(now.clone()); - self.pending_set_webhook_requests.retain(|_, (_, _, timestamp)| *timestamp > cutoff); - self.pending_list_webhooks_requests.retain(|_, timestamp| *timestamp > cutoff); - self.pending_remove_webhook_requests.retain(|_, (_, timestamp)| *timestamp > cutoff); + self.pending_set_webhook_requests.retain(|_, (_, _, timestamp)| { + timestamp.abs_diff(now.clone()) < self.max_age_secs.as_secs() + }); + self.pending_list_webhooks_requests + .retain(|_, timestamp| timestamp.abs_diff(now.clone()) < self.max_age_secs.as_secs()); + self.pending_remove_webhook_requests.retain(|_, (_, timestamp)| { + timestamp.abs_diff(now.clone()) < self.max_age_secs.as_secs() + }); } } -/// LSPS5 client handler +/// LSPS5 client handler. pub struct LSPS5ClientHandler where ES::Target: EntropySource, { - /// Pending messages to be sent + /// Pending messages to be sent. pending_messages: Arc, - /// Event queue for emitting events + /// Event queue for emitting events. pending_events: Arc, - /// Entropy source + /// Entropy source. entropy_source: ES, - /// Per peer state for tracking requests + /// Per peer state for tracking requests. per_peer_state: RwLock>>, - /// Client configuration + /// Client configuration. config: LSPS5ClientConfig, - /// Time provider for LSPS5 service + /// Time provider for LSPS5 service. time_provider: Arc, - /// Map of recently used signatures to prevent replay attacks - recent_signatures: Arc>>, + /// Map of recently used signatures to prevent replay attacks. + recent_signatures: Mutex>, } impl LSPS5ClientHandler @@ -162,42 +155,36 @@ where ES::Target: EntropySource, { /// Creates a new LSPS5 client handler with the provided entropy source, message queue, - /// event queue, and LSPS5ClientConfig + /// event queue, and LSPS5ClientConfig. #[cfg(feature = "time")] pub(crate) fn new( entropy_source: ES, pending_messages: Arc, pending_events: Arc, config: LSPS5ClientConfig, - ) -> Option { - let max_signatures = config.signature_config.max_signatures.clone(); - Some(Self { + ) -> Self { + let time_provider = Arc::new(DefaultTimeProvider); + Self::new_with_custom_time_provider( + entropy_source, pending_messages, pending_events, - entropy_source, - per_peer_state: RwLock::new(new_hash_map()), config, - time_provider: Arc::new(DefaultTimeProvider), - recent_signatures: Arc::new(Mutex::new(VecDeque::with_capacity(max_signatures))), - }) + time_provider, + ) } - pub(crate) fn _new_with_custom_time_provider( + pub(crate) fn new_with_custom_time_provider( entropy_source: ES, pending_messages: Arc, pending_events: Arc, - config: LSPS5ClientConfig, time_provider: Option>, - ) -> Option { + config: LSPS5ClientConfig, time_provider: Arc, + ) -> Self { let max_signatures = config.signature_config.max_signatures.clone(); - let time_provider = match time_provider { - Some(provider) => provider, - None => return None, - }; - Some(Self { + Self { pending_messages, pending_events, entropy_source, per_peer_state: RwLock::new(new_hash_map()), config, time_provider, - recent_signatures: Arc::new(Mutex::new(VecDeque::with_capacity(max_signatures))), - }) + recent_signatures: Mutex::new(VecDeque::with_capacity(max_signatures)), + } } fn with_peer_state( @@ -206,50 +193,41 @@ where where F: FnOnce(&mut PeerState) -> R, { - let mut outer_state_lock = self.per_peer_state.write().map_err(|_| LightningError { - err: "Failed to execute write on per_peer_state".to_string(), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; - let inner_state_lock = - outer_state_lock.entry(counterparty_node_id).or_insert(Mutex::new(PeerState::new())); - let mut peer_state_lock = inner_state_lock.lock().map_err(|_| LightningError { - err: "Failed to lock inner_state_lock mutex".to_string(), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; + 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, Arc::clone(&self.time_provider)), + )); + let mut peer_state_lock = inner_state_lock.lock().unwrap(); - peer_state_lock.cleanup_expired_responses( - self.config.response_max_age_secs, - Arc::clone(&self.time_provider), - ); + peer_state_lock.cleanup_expired_responses(); Ok(f(&mut *peer_state_lock)) } - /// Register a webhook with the LSP + /// Register a webhook with the LSP. /// /// Implements the `lsps5.set_webhook` method from bLIP-55. /// /// # Parameters - /// * `app_name` - A human-readable UTF-8 string that gives a name to the webhook (max 64 bytes) - /// * `webhook` - The URL of the webhook that the LSP can use to push notifications (max 1024 chars) + /// * `app_name` - A human-readable UTF-8 string that gives a name to the webhook (max 64 bytes). + /// * `webhook` - The URL of the webhook that the LSP can use to push notifications (max 1024 chars). /// /// # Returns - /// * Success - the request ID that was used - /// * Error - validation error or error sending the request + /// * Success - the request ID that was used. + /// * Error - validation error or error sending the request. /// /// Response will be provided asynchronously through the event queue as a /// WebhookRegistered or WebhookRegistrationFailed event. pub fn set_webhook( &self, counterparty_node_id: PublicKey, app_name: String, webhook_url: String, ) -> Result { - let app_name = LSPS5AppName::new(app_name).map_err(|e| LightningError { - err: e.message, + let app_name = LSPS5AppName::from_string(app_name).map_err(|e| LightningError { + err: e.message(), action: ErrorAction::IgnoreAndLog(Level::Error), })?; - let lsps_webhook_url = LSPS5WebhookUrl::new(webhook_url).map_err(|e| LightningError { - err: e.message, - action: ErrorAction::IgnoreAndLog(Level::Error), + let lsps_webhook_url = LSPS5WebhookUrl::from_string(webhook_url).map_err(|e| { + LightningError { err: e.message(), action: ErrorAction::IgnoreAndLog(Level::Error) } })?; let request_id = generate_request_id(&self.entropy_source); @@ -260,7 +238,9 @@ where ( app_name.clone(), lsps_webhook_url.clone(), - self.time_provider.duration_since_epoch(), + LSPSDateTime::new_from_duration_since_epoch( + self.time_provider.duration_since_epoch(), + ), ), ); })?; @@ -274,13 +254,13 @@ where Ok(request_id) } - /// List all registered webhooks + /// List all registered webhooks. /// /// Implements the `lsps5.list_webhooks` method from bLIP-55. /// /// # Returns - /// * Success - the request ID that was used - /// * Error - error sending the request + /// * Success - the request ID that was used. + /// * Error - error sending the request. /// /// Response will be provided asynchronously through the event queue as a /// WebhooksListed or WebhooksListFailed event. @@ -288,7 +268,8 @@ where &self, counterparty_node_id: PublicKey, ) -> Result { let request_id = generate_request_id(&self.entropy_source); - let now = self.time_provider.duration_since_epoch(); + 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); @@ -301,29 +282,30 @@ where Ok(request_id) } - /// Remove a webhook by app_name + /// Remove a webhook by app_name. /// /// Implements the `lsps5.remove_webhook` method from bLIP-55. /// /// # Parameters - /// * `app_name` - The name of the webhook to remove + /// * `app_name` - The name of the webhook to remove. /// /// # Returns - /// * Success - the request ID that was used - /// * Error - error sending the request + /// * Success - the request ID that was used. + /// * Error - error sending the request. /// /// Response will be provided asynchronously through the event queue as a /// WebhookRemoved or WebhookRemovalFailed event. pub fn remove_webhook( &self, counterparty_node_id: PublicKey, app_name: String, ) -> Result { - let app_name = LSPS5AppName::new(app_name).map_err(|e| LightningError { - err: e.message, + let app_name = LSPS5AppName::from_string(app_name).map_err(|e| LightningError { + err: e.message(), action: ErrorAction::IgnoreAndLog(Level::Error), })?; let request_id = generate_request_id(&self.entropy_source); - let now = self.time_provider.duration_since_epoch(); + 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 @@ -338,8 +320,8 @@ where Ok(request_id) } - /// Handle received messages from the LSP - pub fn handle_message( + /// Handle received messages from the LSP. + fn handle_message( &self, message: LSPS5Message, counterparty_node_id: &PublicKey, ) -> Result<(), LightningError> { let event_queue_notifier = self.pending_events.notifier(); @@ -374,8 +356,7 @@ where event_queue_notifier.enqueue( LSPS5ClientEvent::WebhookRegistrationFailed { counterparty_node_id: *counterparty_node_id, - error_code: error.code, - error_message: error.message, + error, app_name, url: webhook_url, request_id, @@ -410,8 +391,7 @@ where event_queue_notifier.enqueue( LSPS5ClientEvent::WebhooksListFailed { counterparty_node_id: *counterparty_node_id, - error_code: error.code, - error_message: error.message, + error, request_id, }, ); @@ -441,8 +421,7 @@ where event_queue_notifier.enqueue( LSPS5ClientEvent::WebhookRemovalFailed { counterparty_node_id: *counterparty_node_id, - error_code: error.code, - error_message: error.message, + error, app_name, request_id, }, @@ -483,43 +462,33 @@ where } } - /// Verify a webhook notification signature from an LSP + /// Verify a webhook notification signature from an LSP. /// /// This can be used by a notification delivery service to verify /// the authenticity of notifications received from an LSP. /// /// # Parameters - /// * `timestamp` - The ISO8601 timestamp from the notification - /// * `signature` - The signature string from the notification - /// * `notification` - The webhook notification object + /// * `timestamp` - The ISO8601 timestamp from the notification. + /// * `signature` - The signature string from the notification. + /// * `notification` - The webhook notification object. /// /// # Returns - /// * On success: `true` if the signature is valid - /// * On error: LightningError with error description + /// * On success: `true` if the signature is valid. + /// * On error: LightningError with error description. pub fn verify_notification_signature( - &self, counterparty_node_id: PublicKey, timestamp: &str, signature: &str, - notification: &WebhookNotification, + &self, counterparty_node_id: PublicKey, signature_timestamp: &LSPSDateTime, + signature: &str, notification: &WebhookNotification, ) -> Result { - LSPSDateTime::from_str(timestamp) - .map_err(|_| LightningError { - err: format!("Invalid timestamp format: {}", timestamp), + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + let diff = signature_timestamp.abs_diff(now); + + if diff > 600 { + return Err(LightningError { + err: format!("Timestamp too old: {}", signature_timestamp), action: ErrorAction::IgnoreAndLog(Level::Error), - }) - .and_then(|signature_timestamp| { - let now = LSPSDateTime::new_from_duration_since_epoch( - self.time_provider.duration_since_epoch(), - ); - let diff = signature_timestamp.abs_diff(&now); - - if diff > 600 { - return Err(LightningError { - err: format!("Timestamp too old: {}", timestamp), - action: ErrorAction::IgnoreAndLog(Level::Error), - }); - } - - Ok(()) - })?; + }); + } let notification_json = serde_json::to_string(notification).map_err(|e| LightningError { @@ -529,7 +498,8 @@ where let message = format!( "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", - timestamp, notification_json + signature_timestamp.to_rfc3339(), + notification_json ); if message_signing::verify(message.as_bytes(), signature, &counterparty_node_id) { @@ -542,12 +512,9 @@ where } } - /// Check if a signature has been used before + /// Check if a signature has been used before. fn check_signature_exists(&self, signature: &str) -> Result<(), LightningError> { - let recent_signatures = self.recent_signatures.lock().map_err(|_| LightningError { - err: "Failed to lock recent_signatures mutex".to_string(), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; + let recent_signatures = self.recent_signatures.lock().unwrap(); for (stored_sig, _) in recent_signatures.iter() { if stored_sig == signature { @@ -561,20 +528,17 @@ where Ok(()) } - /// Store a signature with timestamp for replay attack prevention + /// Store a signature with timestamp for replay attack prevention. fn store_signature(&self, signature: String) -> Result<(), LightningError> { - let now = self.time_provider.duration_since_epoch(); - let mut recent_signatures = self.recent_signatures.lock().map_err(|_| LightningError { - err: "Failed to lock recent_signatures mutex".to_string(), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; + 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, now)); + recent_signatures.push_back((signature, now.clone())); - let retention_duration = - Duration::from_secs(self.config.signature_config.retention_minutes * 60); + let retention_duration = self.config.signature_config.retention_minutes * 60; while let Some((_, time)) = recent_signatures.front() { - if now.checked_sub(*time).map_or(false, |duration| duration > retention_duration) { + if now.abs_diff(time.clone()) > retention_duration.as_secs() { recent_signatures.pop_front(); } else { break; @@ -588,22 +552,22 @@ where Ok(()) } - /// Parse a webhook notification received from an LSP + /// Parse a webhook notification received from an LSP. /// /// This can be used by a client implementation to handle webhook /// notifications after they're delivered through a push notification /// system. /// /// # Parameters - /// * `timestamp` - The ISO8601 timestamp from the notification - /// * `signature` - The signature from the notification - /// * `notification_json` - The JSON string of the notification object + /// * `timestamp` - The ISO8601 timestamp from the notification. + /// * `signature` - The signature from the notification. + /// * `notification_json` - The JSON string of the notification object. /// /// # Returns - /// * On success: The parsed webhook notification - /// * On error: LightningError with error description + /// * On success: The parsed webhook notification. + /// * On error: LightningError with error description. pub fn parse_webhook_notification( - &self, counterparty_node_id: PublicKey, timestamp: &str, signature: &str, + &self, counterparty_node_id: PublicKey, timestamp: &LSPSDateTime, signature: &str, notification_json: &str, ) -> Result { let event_queue_notifier = self.pending_events.notifier(); @@ -627,7 +591,7 @@ where event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookNotificationReceived { counterparty_node_id, notification: notification.clone(), - timestamp: timestamp.to_string(), + timestamp: timestamp.clone(), signature_valid, }); Ok(notification) @@ -654,6 +618,8 @@ where #[cfg(test)] mod tests { #![cfg(all(test, feature = "time"))] + use core::time::Duration; + use super::*; use crate::{ lsps0::ser::LSPSRequestId, lsps5::msgs::SetWebhookResponse, tests::utils::TestEntropy, @@ -676,8 +642,7 @@ mod tests { message_queue.clone(), event_queue.clone(), LSPS5ClientConfig::default(), - ) - .unwrap(); + ); let secp = Secp256k1::new(); let secret_key_1 = SecretKey::from_slice(&[42u8; 32]).unwrap(); @@ -715,8 +680,8 @@ mod tests { 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::new(APP_NAME.to_string()).unwrap(); - let lsps5_webhook_url = LSPS5WebhookUrl::new(WEBHOOK_URL.to_string()).unwrap(); + 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).unwrap(); @@ -780,12 +745,14 @@ mod tests { 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::new(OLD_APP_NAME.to_string()).unwrap(); - let lsps5_new_app_name = LSPS5AppName::new(NEW_APP_NAME.to_string()).unwrap(); - let lsps5_webhook_url = LSPS5WebhookUrl::new(WEBHOOK_URL.to_string()).unwrap(); + 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(); - peer_state.last_cleanup = Some(now.checked_sub(Duration::from_secs(120)).unwrap()); + let mut peer_state = PeerState::new(Duration::from_secs(1800), time_provider.clone()); + 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()); @@ -796,7 +763,9 @@ mod tests { ( lsps5_old_app_name, lsps5_webhook_url.clone(), - now.checked_sub(Duration::from_secs(7200)).unwrap(), + LSPSDateTime::new_from_duration_since_epoch( + now.checked_sub(Duration::from_secs(7200)).unwrap(), + ), ), // 2 hours old ); @@ -806,19 +775,24 @@ mod tests { ( lsps5_new_app_name, lsps5_webhook_url, - now.checked_sub(Duration::from_secs(600)).unwrap(), + LSPSDateTime::new_from_duration_since_epoch( + now.checked_sub(Duration::from_secs(600)).unwrap(), + ), ), // 10 minutes old ); - peer_state.cleanup_expired_responses(1800, time_provider.clone()); + 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 { - time_provider.duration_since_epoch().checked_sub(last_cleanup).unwrap() + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()) + .abs_diff(last_cleanup) } else { - Duration::from_secs(0) + 0 }; - assert!(cleanup_age < Duration::from_secs(10)); + assert!(cleanup_age < 10); } #[test] From 2a909d171cf10df5f698154d3efcc023cb79a28f Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 24 Mar 2025 17:01:18 -0300 Subject: [PATCH 13/18] Add LSPS5 module structure --- lightning-liquidity/src/lsps5/mod.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 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..6ce23296cf5 --- /dev/null +++ b/lightning-liquidity/src/lsps5/mod.rs @@ -0,0 +1,22 @@ +// 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; From 90e1e8e022afd8bfbce21eb26c6d8f6af4e5547e Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 24 Mar 2025 17:03:46 -0300 Subject: [PATCH 14/18] Integrate LSPS5 with liquidity manager Fully integrates the LSPS5 webhook components into the lightning-liquidity framework, enabling usage through the LiquidityManager. It includes - Registering LSPS5 events in the event system - Adding LSPS5 module to the main library exports - Updating LSPS0 serialization to handle LSPS5 messages - Adding LSPS5 configuration options to client and service config structures - Implementing message handling for LSPS5 requests and responses - Adding accessor methods for LSPS5 client and service handlers With this change, LSPS5 webhook functionality can now be accessed through the standard LiquidityManager interface, following the same pattern as other LSPS protocols. --- lightning-background-processor/src/lib.rs | 1 + lightning-liquidity/src/events/event_queue.rs | 1 + 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 | 151 +++++++++++++++++- lightning-liquidity/src/manager.rs | 111 ++++++++++++- lightning-liquidity/tests/common/mod.rs | 1 + 8 files changed, 282 insertions(+), 5 deletions(-) diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index 1f7147f3203..f22d9fad2d8 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -1683,6 +1683,7 @@ mod tests { None, None, None, + None, )); let node = Node { node: manager, diff --git a/lightning-liquidity/src/events/event_queue.rs b/lightning-liquidity/src/events/event_queue.rs index 9bf94cf550f..a2589beb4e2 100644 --- a/lightning-liquidity/src/events/event_queue.rs +++ b/lightning-liquidity/src/events/event_queue.rs @@ -1,5 +1,6 @@ use super::LiquidityEvent; use crate::sync::{Arc, Mutex}; + use alloc::collections::VecDeque; use alloc::vec::Vec; 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 5fb59c319c8..275d101ca37 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 0a865935b2e..14a6e383c08 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -21,9 +21,13 @@ 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 chrono::DateTime; use lightning::ln::msgs::{DecodeError, LightningError}; use lightning::ln::wire; use lightning::util::ser::{LengthLimitedRead, LengthReadable, WithoutLength}; @@ -62,6 +66,9 @@ pub(crate) enum LSPSMethod { LSPS1CreateOrder, LSPS2GetInfo, LSPS2Buy, + LSPS5SetWebhook, + LSPS5ListWebhooks, + LSPS5RemoveWebhook, } impl LSPSMethod { @@ -73,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, } } } @@ -87,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"), } } @@ -119,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 @@ -218,13 +241,13 @@ impl LSPSDateTime { } /// Returns the time in seconds since the unix epoch. - pub fn abs_diff(&self, other: &Self) -> u64 { + 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(DateTime::UNIX_EPOCH + duration) + Self(chrono::DateTime::UNIX_EPOCH + duration) } } @@ -267,6 +290,8 @@ pub enum LSPSMessage { LSPS1(LSPS1Message), /// An LSPS2 message. LSPS2(LSPS2Message), + /// An LSPS5 message. + LSPS5(LSPS5Message), } impl LSPSMessage { @@ -294,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, } } @@ -410,6 +438,47 @@ 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::ListWebhooksError(error) => { + jsonrpc_object.serialize_field(JSONRPC_ERROR_FIELD_KEY, error)? + }, + 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() @@ -523,6 +592,31 @@ impl<'de, 'a> Visitor<'de> for LSPSMessageVisitor<'a> { .map_err(de::Error::custom)?; Ok(LSPSMessage::LSPS2(LSPS2Message::Request(id, LSPS2Request::Buy(request)))) }, + // Add LSPS5 methods + 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 { @@ -628,6 +722,57 @@ 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(error) = error { + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::ListWebhooksError(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::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 651cb4b74a6..364c8a077d6 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -10,6 +10,9 @@ use crate::lsps0::ser::{ LSPS_MESSAGE_TYPE_ID, }; use crate::lsps0::service::LSPS0ServiceHandler; +use crate::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler}; +use crate::lsps5::msgs::LSPS5Message; +use crate::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler, TimeProvider}; use crate::message_queue::MessageQueue; use crate::lsps1::client::{LSPS1ClientConfig, LSPS1ClientHandler}; @@ -52,6 +55,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 +71,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`]. @@ -144,6 +151,8 @@ 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>, @@ -163,7 +172,7 @@ where pub fn new( entropy_source: ES, channel_manager: CM, chain_source: Option, chain_params: Option, service_config: Option, - client_config: Option, + client_config: Option, time_provider: Option>, ) -> Self where { let pending_messages = Arc::new(MessageQueue::new()); @@ -173,7 +182,7 @@ where { let mut supported_protocols = Vec::new(); let lsps2_client_handler = client_config.as_ref().and_then(|config| { - config.lsps2_client_config.map(|config| { + config.lsps2_client_config.as_ref().map(|config| { LSPS2ClientHandler::new( entropy_source.clone(), Arc::clone(&pending_messages), @@ -198,6 +207,68 @@ where { }) }); + let lsps5_client_handler = client_config.as_ref().and_then(|config| { + config.lsps5_client_config.as_ref().map(|config| { + if time_provider.is_some() { + LSPS5ClientHandler::new_with_custom_time_provider( + entropy_source.clone(), + Arc::clone(&pending_messages), + Arc::clone(&pending_events), + config.clone(), + time_provider.clone().unwrap(), + ) + } else { + #[cfg(feature = "time")] + { + LSPS5ClientHandler::new( + entropy_source.clone(), + Arc::clone(&pending_messages), + Arc::clone(&pending_events), + config.clone(), + ) + } + #[cfg(not(feature = "time"))] + { + panic!("A custom time_provider must be provided if the 'time' feature is not enabled."); + } + } + }) + }); + + 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); + } + + if time_provider.is_some() { + return LSPS5ServiceHandler::new_with_custom_time_provider( + Arc::clone(&pending_events), + Arc::clone(&pending_messages), + channel_manager.clone(), + config.clone(), + time_provider.unwrap(), + ); + } else { + #[cfg(feature = "time")] + { + return LSPS5ServiceHandler::new( + Arc::clone(&pending_events), + Arc::clone(&pending_messages), + channel_manager.clone(), + config.clone(), + ); + } + #[cfg(not(feature = "time"))] + { + panic!("A custom time_provider must be provided if the 'time' feature is not enabled."); + } + } + }) + }); + let lsps1_client_handler = client_config.as_ref().and_then(|config| { config.lsps1_client_config.as_ref().map(|config| { LSPS1ClientHandler::new( @@ -252,6 +323,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 +372,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,6 +511,26 @@ 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(()) } diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index 2259d1eae06..f67089a4b81 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -459,6 +459,7 @@ pub(crate) fn create_liquidity_node( Some(chain_params), service_config, client_config, + None, )); let msg_handler = MessageHandler { chan_handler: Arc::new(test_utils::TestChannelMessageHandler::new( From 3fbb8fbdc369a524e370e701e7a55f81564bd399 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 24 Mar 2025 17:04:46 -0300 Subject: [PATCH 15/18] Add tests for LSPS5 client and service. Also tests for checking the correctness of the signing logic --- lightning-liquidity/tests/common/mod.rs | 71 +- .../tests/lsps2_integration_tests.rs | 10 +- .../tests/lsps5_integration_tests.rs | 929 ++++++++++++++++++ lightning-liquidity/tests/signing_tests.rs | 504 ++++++++++ 4 files changed, 1507 insertions(+), 7 deletions(-) create mode 100644 lightning-liquidity/tests/lsps5_integration_tests.rs create mode 100644 lightning-liquidity/tests/signing_tests.rs diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index f67089a4b81..4c5d85a2447 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -4,6 +4,7 @@ #![allow(unused_imports)] #![allow(unused_macros)] +use bitcoin::secp256k1::SecretKey; use lightning::chain::Filter; use lightning::sign::EntropySource; @@ -34,6 +35,8 @@ use lightning::util::persist::{ SCORER_PERSISTENCE_SECONDARY_NAMESPACE, }; use lightning::util::test_utils; +use lightning_liquidity::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler}; +use lightning_liquidity::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler, TimeProvider}; use lightning_liquidity::{LiquidityClientConfig, LiquidityManager, LiquidityServiceConfig}; use lightning_persister::fs_store::FilesystemStore; @@ -400,7 +403,7 @@ fn get_full_filepath(filepath: String, filename: String) -> String { pub(crate) fn create_liquidity_node( i: usize, persist_dir: &str, network: Network, service_config: Option, - client_config: Option, + client_config: Option, time_provider: Option>, ) -> Node { let tx_broadcaster = Arc::new(test_utils::TestBroadcaster::new(network)); let fee_estimator = Arc::new(test_utils::TestFeeEstimator::new(253)); @@ -459,7 +462,7 @@ pub(crate) fn create_liquidity_node( Some(chain_params), service_config, client_config, - None, + time_provider, )); let msg_handler = MessageHandler { chan_handler: Arc::new(test_utils::TestChannelMessageHandler::new( @@ -489,14 +492,23 @@ pub(crate) fn create_liquidity_node( } pub(crate) fn create_service_and_client_nodes( - persist_dir: &str, service_config: LiquidityServiceConfig, client_config: LiquidityClientConfig, + persist_dir: &str, service_config: LiquidityServiceConfig, + client_config: LiquidityClientConfig, time_provider: Option>, ) -> (Node, Node) { let persist_temp_path = env::temp_dir().join(persist_dir); let persist_dir = persist_temp_path.to_string_lossy().to_string(); let network = Network::Bitcoin; - let service_node = create_liquidity_node(1, &persist_dir, network, Some(service_config), None); - let client_node = create_liquidity_node(2, &persist_dir, network, None, Some(client_config)); + let service_node = create_liquidity_node( + 1, + &persist_dir, + network, + Some(service_config), + None, + time_provider.clone(), + ); + let client_node = + create_liquidity_node(2, &persist_dir, network, None, Some(client_config), time_provider); service_node .channel_manager @@ -672,3 +684,52 @@ fn advance_chain(node: &mut Node, num_blocks: u32) { } } } + +pub(crate) fn get_client_and_service( + time_provider: Option>, +) -> ( + &'static LSPS5ClientHandler>, + &'static LSPS5ServiceHandler>, + bitcoin::secp256k1::PublicKey, + bitcoin::secp256k1::PublicKey, + &'static Node, + &'static Node, +) { + let signing_key = SecretKey::from_slice(&[42; 32]).unwrap(); + let mut lsps5_service_config = LSPS5ServiceConfig::default(); + lsps5_service_config.signing_key = signing_key; + 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 (service_node, client_node) = create_service_and_client_nodes( + "webhook_registration_flow", + service_config, + client_config, + time_provider, + ); + + // Leak the nodes to extend their lifetime to 'static since this is test code + let service_node = Box::leak(Box::new(service_node)); + let client_node = Box::leak(Box::new(client_node)); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + + let secp = bitcoin::secp256k1::Secp256k1::new(); + let service_node_id = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &signing_key); + let client_node_id = client_node.channel_manager.get_our_node_id(); + + (client_handler, service_handler, service_node_id, client_node_id, service_node, client_node) +} diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 5a3f88dacac..a40129577d7 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -88,6 +88,7 @@ fn invoice_generation_flow() { #[cfg(lsps1_service)] lsps1_service_config: None, lsps2_service_config: Some(lsps2_service_config), + lsps5_service_config: None, advertise_service: true, }; @@ -95,10 +96,15 @@ fn invoice_generation_flow() { let client_config = LiquidityClientConfig { lsps1_client_config: None, lsps2_client_config: Some(lsps2_client_config), + lsps5_client_config: None, }; - let (service_node, client_node) = - create_service_and_client_nodes("invoice_generation_flow", service_config, client_config); + let (service_node, client_node) = create_service_and_client_nodes( + "invoice_generation_flow", + service_config, + client_config, + None, + ); let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); let service_node_id = service_node.channel_manager.get_our_node_id(); diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs new file mode 100644 index 00000000000..477ef3da1f9 --- /dev/null +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -0,0 +1,929 @@ +#![cfg(all(test, feature = "time"))] + +mod common; + +use common::{get_client_and_service, get_lsps_message}; +use lightning::ln::msgs::LightningError; +use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::util::hash_tables::HashSet; +use lightning_liquidity::events::LiquidityEvent; +use lightning_liquidity::lsps5::event::{LSPS5ClientEvent, LSPS5ServiceEvent}; +use lightning_liquidity::lsps5::msgs::{ + LSPS5AppName, LSPS5Error, LSPS5WebhookUrl, WebhookNotification, WebhookNotificationMethod, +}; +use lightning_liquidity::lsps5::service::{LSPS5ServiceConfig, TimeProvider}; +use lightning_liquidity::lsps5::service::{ + MIN_WEBHOOK_RETENTION_DAYS, PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS, +}; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +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); + } +} + +impl TimeProvider for MockTimeProvider { + fn duration_since_epoch(&self) -> Duration { + *self.current_time.read().unwrap() + } +} + +#[test] +fn webhook_registration_flow() { + let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = + get_client_and_service(None); + + 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 set_webhook_event = service_node.liquidity_manager.next_event().unwrap(); + + match set_webhook_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::WebhookRegistered { + counterparty_node_id, + app_name: an, + url: wu, + no_change, + request_id: req_id, + }) => { + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(an, app_name.clone()); + assert_eq!(wu, webhook_url); + assert_eq!(no_change, false); + assert_eq!(req_id, request_id); + }, + _ => panic!("Unexpected event"), + } + + let webhook_notification_event = service_node.liquidity_manager.next_event().unwrap(); + match webhook_notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + counterparty_node_id, + app_name: an, + url, + notification, + timestamp, + signature, + headers, + }) => { + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(an, app_name.clone()); + assert_eq!(url, webhook_url); + 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 SendWebhookNotifications 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, LSPS5ServiceConfig::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) + .expect("Failed to send list_webhooks request"); + 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_event = service_node.liquidity_manager.next_event().unwrap(); + + match list_webhooks_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::WebhooksListed { + app_names, + counterparty_node_id, + max_webhooks, + request_id: req_id, + }) => { + assert_eq!(app_names, vec![app_name.clone()]); + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(max_webhooks, LSPS5ServiceConfig::default().max_webhooks_per_client); + assert_eq!(req_id, list_request_id); + }, + _ => panic!("Unexpected event"), + } + + 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, LSPS5ServiceConfig::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 update_request_id = 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 set_webhook_update_event = service_node.liquidity_manager.next_event().unwrap(); + match set_webhook_update_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::WebhookRegistered { + counterparty_node_id, + app_name: an, + url: wu, + no_change, + request_id: req_id, + }) => { + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(an, app_name); + assert_eq!(wu, updated_webhook_url); + assert_eq!(no_change, false); + assert_eq!(req_id, update_request_id); + }, + _ => panic!("Unexpected event"), + } + + let webhook_notification_event = service_node.liquidity_manager.next_event().unwrap(); + match webhook_notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + url, .. + }) => { + assert_eq!(url, updated_webhook_url); + }, + _ => panic!("Expected SendWebhookNotifications 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_event = service_node.liquidity_manager.next_event().unwrap(); + match remove_webhook_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::WebhookRemoved { + counterparty_node_id, + app_name: an, + request_id: req_id, + }) => { + assert_eq!(counterparty_node_id, client_node_id); + assert_eq!(an, app_name); + assert_eq!(req_id, remove_request_id); + }, + _ => panic!("Unexpected event"), + } + + 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 (client_handler, _, service_node_id, client_node_id, service_node, client_node) = + get_client_and_service(None); + + // 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 err_message = result.unwrap_err().err; + assert!( + err_message.contains("exceeds maximum length"), + "Error message should mention length: {}", + err_message + ); + + // 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 err_message = result.unwrap_err().err; + assert!( + err_message.contains("Error parsing URL"), + "Error message should mention parse failure: {}", + err_message + ); + + // 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 err_message = result.unwrap_err().err; + assert!( + err_message.contains("Unsupported protocol"), + "Error message should mention protocol: {}", + err_message + ); + + // 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 err_message = result.unwrap_err().err; + assert!( + err_message.contains("exceeds maximum length"), + "Error message should mention length: {}", + err_message + ); + + // 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..LSPS5ServiceConfig::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, + LSPS5ServiceConfig::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 = LSPS5Error::TooManyWebhooks(format!( + "Maximum of {} webhooks allowed per client", + LSPS5ServiceConfig::default().max_webhooks_per_client + )); + assert_eq!(error, error_to_check); + 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, + .. + }) => { + let error_to_check = + LSPS5Error::AppNameNotFound(format!("App name not found: {}", raw_nonexistent_app)); + assert_eq!(error, error_to_check); + assert_eq!(app_name, nonexistent_app); + }, + _ => panic!("Expected WebhookRemovalFailed event, got {:?}", event), + } + + // TEST 7: URL with security issues (localhost) + let localhost_url = "https://localhost/webhook"; + let result = client_handler.set_webhook( + service_node_id, + "Localhost App".to_string(), + localhost_url.to_string(), + ); + assert!(result.is_err(), "Expected error due to localhost URL"); + + // TEST 8: URL with security issues (private IP) + let private_ip_url = "https://192.168.1.1/webhook"; + let result = client_handler.set_webhook( + service_node_id, + "Private IP App".to_string(), + private_ip_url.to_string(), + ); + assert!(result.is_err(), "Expected error due to private IP URL"); +} + +#[test] +fn webhook_notification_delivery_test() { + let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = + get_client_and_service(None); + + 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 _ = service_node.liquidity_manager.next_event().unwrap(); + + let notification_event = service_node.liquidity_manager.next_event().unwrap(); + let (timestamp_value, signature_value, notification_json) = match notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + url, + timestamp, + signature, + headers: _, + notification, + .. + }) => { + assert_eq!(url.as_str(), webhook_url); + assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered); + (timestamp, signature, serde_json::to_string(¬ification).unwrap()) + }, + _ => panic!("Expected SendWebhookNotifications 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 = client_handler.parse_webhook_notification( + service_node_id, + ×tamp_value, + &signature_value, + ¬ification_json, + ); + assert!( + result.is_ok(), + "Client should be able to parse and validate the webhook_registered notification" + ); + + assert!(service_node + .liquidity_manager + .lsps5_service_handler() + .unwrap() + .notify_payment_incoming(client_node_id) + .is_ok()); + + let payment_notification_event = service_node.liquidity_manager.next_event().unwrap(); + let (payment_timestamp, payment_signature, payment_json) = match payment_notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + url, + timestamp, + signature, + notification, + .. + }) => { + assert_eq!(url.as_str(), webhook_url); + assert_eq!(notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming); + (timestamp, signature, serde_json::to_string(¬ification).unwrap()) + }, + _ => panic!("Expected SendWebhookNotifications event for payment_incoming"), + }; + + let result = client_handler.parse_webhook_notification( + service_node_id, + &payment_timestamp, + &payment_signature, + &payment_json, + ); + assert!( + result.is_ok(), + "Client should be able to parse and validate the payment_incoming notification" + ); + + let notification = result.unwrap(); + assert_eq!( + notification.method, + WebhookNotificationMethod::LSPS5PaymentIncoming, + "Parsed notification should be payment_incoming" + ); + + assert!(service_node + .liquidity_manager + .lsps5_service_handler() + .unwrap() + .notify_payment_incoming(client_node_id) + .is_ok()); + + 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 + assert!(service_node + .liquidity_manager + .lsps5_service_handler() + .unwrap() + .notify_expiry_soon(client_node_id, timeout_block) + .is_ok()); + + let expiry_notification_event = service_node.liquidity_manager.next_event().unwrap(); + match expiry_notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + notification, + .. + }) => { + assert!(matches!( + notification.method, + WebhookNotificationMethod::LSPS5ExpirySoon { timeout } if timeout == timeout_block + )); + }, + _ => panic!("Expected SendWebhookNotifications event for expiry_soon"), + }; +} + +#[test] +fn multiple_webhooks_notification_test() { + let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = + get_client_and_service(None); + + 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 WebhookRegistered event + let _ = service_node.liquidity_manager.next_event().unwrap(); + // Consume SendWebhookNotifications 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(); + } + + assert!(service_node + .liquidity_manager + .lsps5_service_handler() + .unwrap() + .notify_liquidity_management_request(client_node_id) + .is_ok()); + + 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::SendWebhookNotifications { + url, + notification, + .. + }) => { + seen_webhooks.insert(url.as_str().to_string()); + + assert_eq!( + notification.method, + WebhookNotificationMethod::LSPS5LiquidityManagementRequest + ); + }, + _ => panic!("Expected SendWebhookNotifications 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 _ = service_node.liquidity_manager.next_event().unwrap(); + + let notification_event = service_node.liquidity_manager.next_event().unwrap(); + match notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + url, + notification, + .. + }) => { + assert_eq!(url.as_str(), new_webhook); + assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered); + }, + _ => panic!("Expected SendWebhookNotifications event"), + } +} + +#[test] +fn idempotency_set_webhook_test() { + let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = + get_client_and_service(None); + + 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 _ = service_node.liquidity_manager.next_event().unwrap(); + + let notification_event = service_node.liquidity_manager.next_event().unwrap(); + match notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { .. }) => {}, + _ => panic!("Expected SendWebhookNotifications 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(); + + let webhook_registered_again_event = service_node.liquidity_manager.next_event().unwrap(); + match webhook_registered_again_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::WebhookRegistered { + no_change, .. + }) => { + // Second registration with same parameters should be a no_change + assert_eq!(no_change, true, "Second identical registration should have no_change=true"); + }, + _ => panic!("Unexpected event"), + } + + 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(); + + let webhook_updated_event = service_node.liquidity_manager.next_event().unwrap(); + match webhook_updated_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::WebhookRegistered { + no_change, .. + }) => { + assert_eq!(no_change, false, "Update with different URL should have no_change=false"); + }, + _ => panic!("Expected WebhookRegistered event for update"), + } + + // For an update, a SendWebhookNotifications event SHOULD be emitted + let notification_update_event = service_node.liquidity_manager.next_event().unwrap(); + match notification_update_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + url, .. + }) => { + assert_eq!(url.as_str(), updated_webhook_url); + }, + _ => panic!("Expected SendWebhookNotifications event for update"), + } +} + +#[test] +fn replay_prevention_test() { + let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = + get_client_and_service(None); + + 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(); + + let _ = service_node.liquidity_manager.next_event().unwrap(); + 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(); + + assert!(service_node + .liquidity_manager + .lsps5_service_handler() + .unwrap() + .notify_payment_incoming(client_node_id) + .is_ok()); + + let notification_event = service_node.liquidity_manager.next_event().unwrap(); + let (timestamp, signature, body) = match notification_event { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + timestamp, + signature, + notification, + .. + }) => (timestamp, signature, serde_json::to_string(¬ification).unwrap()), + _ => panic!("Expected SendWebhookNotifications event"), + }; + + let result: Result = + client_handler.parse_webhook_notification(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: Result = + client_handler.parse_webhook_notification(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!( + err.err.contains("Replay attack detected") + || err.err.contains("signature has been used before"), + "Error should mention replay detection: {}", + err.err + ); +} + +#[test] +fn stale_webhooks() { + let mock_time_provider = Arc::new(MockTimeProvider::new(1000)); + let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = + get_client_and_service(Some(mock_time_provider.clone())); + + 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(); + let _ = service_node.liquidity_manager.next_event().unwrap(); + 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).unwrap(); + 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_evt = service_node.liquidity_manager.next_event().unwrap(); + match list_evt { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::WebhooksListed { app_names, .. }) => { + assert_eq!(app_names, vec![app_name.clone()]); + }, + _ => panic!("Expected WebhooksListed before prune"), + } + 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).unwrap(); + 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_evt2 = service_node.liquidity_manager.next_event().unwrap(); + match list_evt2 { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::WebhooksListed { app_names, .. }) => { + assert!(app_names.is_empty(), "Expected no webhooks after prune"); + }, + _ => panic!("Expected WebhooksListed after prune"), + } + 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, .. }) => { + assert!(app_names.is_empty(), "Expected no webhooks after prune (client)"); + }, + _ => panic!("Expected WebhooksListed after prune (client)"), + } +} diff --git a/lightning-liquidity/tests/signing_tests.rs b/lightning-liquidity/tests/signing_tests.rs new file mode 100644 index 00000000000..efb8f68424a --- /dev/null +++ b/lightning-liquidity/tests/signing_tests.rs @@ -0,0 +1,504 @@ +// 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. + +//! Tests for LSPS5 webhook notification signing and verification + +#![cfg(all(test, feature = "std"))] + +mod common; +use common::get_client_and_service; +use core::time::Duration; +use lightning_liquidity::lsps0::ser::LSPSDateTime; +use lightning_liquidity::lsps5::msgs::{WebhookNotification, WebhookNotificationMethod}; +use lightning_liquidity::lsps5::service::{DefaultTimeProvider, TimeProvider}; +use std::str::FromStr; +use std::sync::Arc; + +#[test] +fn test_basic_sign_and_verify() { + let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + let notification = WebhookNotification::webhook_registered(); + let notification_json = serde_json::to_string(¬ification).unwrap(); + + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); + + let result = client_handler.verify_notification_signature( + service_node_id, + ×tamp, + &signature, + ¬ification, + ); + + assert!(result.is_ok()); + assert!(result.unwrap()); +} + +#[test] +fn test_parse_webhook_notification() { + let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + let notification = WebhookNotification::payment_incoming(); + let notification_json = serde_json::to_string(¬ification).unwrap(); + + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); + + let parsed_notification = client_handler + .parse_webhook_notification(service_node_id, ×tamp, &signature, ¬ification_json) + .unwrap(); + + assert_eq!(parsed_notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming); +} + +#[test] +fn test_invalid_signature() { + let (client_handler, _, service_node_id, _, _, _) = get_client_and_service(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + let notification = WebhookNotification::webhook_registered(); + + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let invalid_signature = "xdtk1zf63sfn81r6qteymy73mb1b7dspj5kwx46uxwd6c3pu7y3bto"; + + let result = client_handler.verify_notification_signature( + service_node_id, + ×tamp, + invalid_signature, + ¬ification, + ); + + assert!(result.is_err()); +} + +#[test] +fn test_all_notification_types() { + let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + let notifications = vec![ + WebhookNotification::webhook_registered(), + WebhookNotification::payment_incoming(), + WebhookNotification::expiry_soon(144), + WebhookNotification::liquidity_management_request(), + WebhookNotification::onion_message_incoming(), + ]; + + for notification in notifications { + let notification_json = serde_json::to_string(¬ification).unwrap(); + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); + + let result = client_handler.verify_notification_signature( + service_node_id, + ×tamp, + &signature, + ¬ification, + ); + + assert!(result.is_ok()); + assert!(result.unwrap()); + + let parsed = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + &signature, + ¬ification_json, + ); + + assert!(parsed.is_ok()); + } +} + +#[test] +fn test_timestamp_out_of_range() { + let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); + + let notification = WebhookNotification::webhook_registered(); + let notification_json = serde_json::to_string(¬ification).unwrap(); + + let too_old_timestamp = LSPSDateTime::from_str("2020-01-01T00:00:00.000Z").unwrap(); + + let signature = + service_handler.sign_notification(¬ification_json, &too_old_timestamp).unwrap(); + + let result = client_handler.verify_notification_signature( + service_node_id, + &too_old_timestamp, + &signature, + ¬ification, + ); + + assert!(result.is_err()); +} + +#[test] +fn test_exact_bytes_from_spec_example() { + let timestamp = "2023-05-04T10:52:58.395Z"; + + let notification_json = r#"{"jsonrpc":"2.0","method":"lsps5.webhook_registered","params":{}}"#; + + let message = format!( + "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + timestamp, notification_json + ); + + let bytes = message.as_bytes(); + + assert_eq!(bytes[0], 0x4c); // 'L' + assert_eq!(bytes[1], 0x53); // 'S' + assert_eq!(bytes[2], 0x50); // 'P' + assert_eq!(bytes[3], 0x53); // 'S' + assert_eq!(bytes[4], 0x35); // '5' + assert_eq!(bytes[5], 0x3a); // ':' + + let expected_prefix = "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At"; + assert!(message.starts_with(expected_prefix)); + + assert!(message.contains(timestamp)); + assert!(message.contains(notification_json)); +} + +#[test] +fn test_expiry_soon_notification_with_timeout() { + let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + let timeout_value = 720000; + let notification = WebhookNotification::expiry_soon(timeout_value); + let notification_json = serde_json::to_string(¬ification).unwrap(); + + assert!(notification_json.contains(&format!("\"timeout\":{}", timeout_value))); + + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); + + let result = client_handler.verify_notification_signature( + service_node_id, + ×tamp, + &signature, + ¬ification, + ); + + assert!(result.is_ok()); + assert!(result.unwrap()); + + let parsed = client_handler + .parse_webhook_notification(service_node_id, ×tamp, &signature, ¬ification_json) + .unwrap(); + + assert_eq!( + parsed.method, + WebhookNotificationMethod::LSPS5ExpirySoon { timeout: timeout_value } + ); + + let binding: WebhookNotificationMethod = parsed.method; + assert_eq!(binding, WebhookNotificationMethod::LSPS5ExpirySoon { timeout: timeout_value }); + // println!("Parsed method: {:?}", binding); + // let params_obj = binding.as_object().unwrap(); + // // assert!(params_obj.contains_key("timeout")); + // // assert_eq!(params_obj["timeout"], timeout_value); +} + +#[test] +fn test_spec_example_header_format() { + let (_, service_handler, _, _, _, _) = get_client_and_service(None); + + let notification = WebhookNotification::payment_incoming(); + let notification_json = serde_json::to_string(¬ification).unwrap(); + + let timestamp = LSPSDateTime::from_str("2023-05-04T10:14:23.853Z").unwrap(); + + let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); + + let headers = vec![ + ("Content-Type".to_string(), "application/json".to_string()), + ("x-lsps5-timestamp".to_string(), timestamp.to_string()), + ("x-lsps5-signature".to_string(), signature.clone()), + ]; + + let timestamp_header = headers.iter().find(|(name, _)| name == "x-lsps5-timestamp").unwrap(); + let _ = headers.iter().find(|(name, _)| name == "x-lsps5-signature").unwrap(); + + assert_eq!(timestamp_header.1, timestamp.to_rfc3339()); + + for c in signature.chars() { + assert!( + (c >= 'a' && c <= 'z') || (c >= '1' && c <= '9') || c == 'y' || c == 'z', + "Invalid character in zbase32 signature: {}", + c + ); + } + + assert!(headers.iter().any(|(name, _)| name == "x-lsps5-timestamp")); + assert!(headers.iter().any(|(name, _)| name == "x-lsps5-signature")); +} + +#[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")); + } + } +} + +#[test] +fn test_tampered_notification_details() { + let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + + let notification = WebhookNotification::expiry_soon(700000); + let notification_json = serde_json::to_string(¬ification).unwrap(); + + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); + + let original_result = client_handler.verify_notification_signature( + service_node_id, + ×tamp, + &signature, + ¬ification, + ); + assert!(original_result.is_ok(), "Original notification should be valid"); + assert!(original_result.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 tampered_result = client_handler.verify_notification_signature( + service_node_id, + ×tamp, + &signature, + &tampered_notification, + ); + assert!(tampered_result.is_err(), "Tampered notification should fail verification"); + + let mut json_value: serde_json::Value = serde_json::from_str(¬ification_json).unwrap(); + json_value["method"] = serde_json::json!("lsps5.payment_incoming"); + let tampered_method_json = json_value.to_string(); + + let tampered_method_notification: WebhookNotification = + serde_json::from_str(&tampered_method_json).unwrap(); + + let tampered_method_result = client_handler.verify_notification_signature( + service_node_id, + ×tamp, + &signature, + &tampered_method_notification, + ); + assert!( + tampered_method_result.is_err(), + "Notification with tampered method should fail verification" + ); +} + +#[test] +fn test_timestamp_window_validation() { + let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + + let notification = WebhookNotification::onion_message_incoming(); + let notification_json = serde_json::to_string(¬ification).unwrap(); + + let current_time = time_provider.duration_since_epoch(); + let valid_timestamp = LSPSDateTime::new_from_duration_since_epoch(current_time); + println!("Current time: {:?}", current_time); + println!("Valid timestamp: {:?}", valid_timestamp.to_rfc3339()); + + let signature: String = + service_handler.sign_notification(¬ification_json, &valid_timestamp).unwrap(); + + let valid_result = client_handler.verify_notification_signature( + service_node_id, + &valid_timestamp, + &signature, + ¬ification, + ); + assert!(valid_result.is_ok()); + assert!(valid_result.unwrap()); + + let past_timestamp = LSPSDateTime::new_from_duration_since_epoch( + current_time.checked_sub(Duration::from_secs(20 * 60)).unwrap(), + ); + + let past_result = client_handler.verify_notification_signature( + service_node_id, + &past_timestamp, + &signature, + ¬ification, + ); + assert!(past_result.is_err(), "Notification with past timestamp should be rejected"); + + let future_timestamp = LSPSDateTime::new_from_duration_since_epoch( + current_time.checked_add(Duration::from_secs(15 * 60)).unwrap(), + ); + + let future_result = client_handler.verify_notification_signature( + service_node_id, + &future_timestamp, + &signature, + ¬ification, + ); + assert!(future_result.is_err(), "Notification with future timestamp should be rejected"); +} + +#[test] +fn test_unknown_method_and_malformed_notifications() { + let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let create_notification = |method: &str, params: serde_json::Value| -> serde_json::Value { + serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + "params": params + }) + }; + + let unknown_notification = + create_notification("lsps5.unknown_method", serde_json::json!({"some": "data"})); + let unknown_json = unknown_notification.to_string(); + let unknown_signature = service_handler.sign_notification(&unknown_json, ×tamp).unwrap(); + + let unknown_result = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + &unknown_signature, + &unknown_json, + ); + assert!(unknown_result.is_err(), "Unknown method should be rejected even with valid signature"); + + let invalid_jsonrpc = serde_json::json!({ + "method": "lsps5.payment_incoming", + "params": {} + }) + .to_string(); + let invalid_jsonrpc_signature = + service_handler.sign_notification(&invalid_jsonrpc, ×tamp).unwrap(); + + let invalid_jsonrpc_result = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + &invalid_jsonrpc_signature, + &invalid_jsonrpc, + ); + assert!(invalid_jsonrpc_result.is_err(), "Missing jsonrpc field should be rejected"); + + let missing_params = serde_json::json!({ + "jsonrpc": "2.0", + "method": "lsps5.payment_incoming" + }) + .to_string(); + let missing_params_signature = + service_handler.sign_notification(&missing_params, ×tamp).unwrap(); + + let missing_params_result = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + &missing_params_signature, + &missing_params, + ); + assert!(missing_params_result.is_err(), "Missing params field should be rejected"); + + let invalid_json = "{not valid json"; + let invalid_json_signature_result = service_handler.sign_notification(invalid_json, ×tamp); + + if let Ok(invalid_signature) = invalid_json_signature_result { + let invalid_json_result = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + &invalid_signature, + invalid_json, + ); + assert!(invalid_json_result.is_err(), "Invalid JSON should be rejected"); + } else { + assert!( + invalid_json_signature_result.is_err(), + "Invalid JSON should be rejected at signing" + ); + } + + let notification = WebhookNotification::payment_incoming(); + let notification_json = serde_json::to_string(¬ification).unwrap(); + + let edge_past_timestamp = LSPSDateTime::new_from_duration_since_epoch( + time_provider.duration_since_epoch().checked_sub(Duration::from_secs(9 * 60)).unwrap(), + ); + let edge_future_timestamp = LSPSDateTime::new_from_duration_since_epoch( + time_provider.duration_since_epoch().checked_add(Duration::from_secs(9 * 60)).unwrap(), + ); + + let past_edge_signature = + service_handler.sign_notification(¬ification_json, &edge_past_timestamp).unwrap(); + let future_edge_signature = + service_handler.sign_notification(¬ification_json, &edge_future_timestamp).unwrap(); + + let past_edge_result = client_handler.verify_notification_signature( + service_node_id, + &edge_past_timestamp, + &past_edge_signature, + ¬ification, + ); + let future_edge_result = client_handler.verify_notification_signature( + service_node_id, + &edge_future_timestamp, + &future_edge_signature, + ¬ification, + ); + + assert!(past_edge_result.is_ok(), "Timestamp just within past range should be accepted"); + assert!(future_edge_result.is_ok(), "Timestamp just within future range should be accepted"); +} From 0e3ea878bcac76cbbfb0c1dbcb71cd1eebbaea9a Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 28 Apr 2025 12:44:19 -0300 Subject: [PATCH 16/18] ldk-node needs both LSPS5ClientConfig and LSPS5ServiceConfig to have derive(Debug) --- lightning-liquidity/src/lsps5/client.rs | 2 +- lightning-liquidity/src/lsps5/service.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps5/client.rs b/lightning-liquidity/src/lsps5/client.rs index 5a750916262..73259f73e73 100644 --- a/lightning-liquidity/src/lsps5/client.rs +++ b/lightning-liquidity/src/lsps5/client.rs @@ -65,7 +65,7 @@ impl Default for SignatureStorageConfig { } } -#[derive(Clone)] +#[derive(Debug, Clone)] /// Configuration for the LSPS5 client pub struct LSPS5ClientConfig { /// Maximum age in seconds for cached responses (default: 3600 - 1 hour). diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index 1272c1d850d..90ebc15fb2c 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -79,7 +79,7 @@ impl TimeProvider for DefaultTimeProvider { } /// Configuration for LSPS5 service. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct LSPS5ServiceConfig { /// Maximum number of webhooks allowed per client. pub max_webhooks_per_client: u32, From 004c5c34d388c3f2886c709a7f5ee7f235175afd Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 5 May 2025 13:03:16 -0300 Subject: [PATCH 17/18] WIP --- lightning-liquidity/Cargo.toml | 2 +- lightning-liquidity/src/lsps5/service.rs | 24 +++---------------- lightning-liquidity/tests/common/mod.rs | 11 ++++++++- .../tests/lsps5_integration_tests.rs | 22 +++++++---------- 4 files changed, 22 insertions(+), 37 deletions(-) diff --git a/lightning-liquidity/Cargo.toml b/lightning-liquidity/Cargo.toml index 876cd202854..f301e4fe34c 100644 --- a/lightning-liquidity/Cargo.toml +++ b/lightning-liquidity/Cargo.toml @@ -16,7 +16,7 @@ categories = ["cryptography::cryptocurrencies"] [features] default = ["std", "time"] std = ["lightning/std"] -time = [] +time = ["std"] backtrace = ["dep:backtrace"] [dependencies] diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index 90ebc15fb2c..631d629f63e 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -89,21 +89,6 @@ pub struct LSPS5ServiceConfig { pub notification_cooldown_hours: Duration, } -/// Default maximum number of webhooks allowed per client. -pub const DEFAULT_MAX_WEBHOOKS_PER_CLIENT: u32 = 10; -/// Default notification cooldown time in hours. -pub const DEFAULT_NOTIFICATION_COOLDOWN_HOURS: Duration = Duration::from_secs(24 * 60 * 60); - -impl Default for LSPS5ServiceConfig { - fn default() -> Self { - Self { - max_webhooks_per_client: DEFAULT_MAX_WEBHOOKS_PER_CLIENT, - signing_key: SecretKey::from_slice(&[1; 32]).expect("Static key should be valid"), - notification_cooldown_hours: DEFAULT_NOTIFICATION_COOLDOWN_HOURS, - } - } -} - /// Service for handling LSPS5 webhook registration pub struct LSPS5ServiceHandler where @@ -184,8 +169,7 @@ where Ok(()) } - /// Handle a set_webhook request. - pub fn handle_set_webhook( + fn handle_set_webhook( &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, params: SetWebhookRequest, ) -> Result<(), LightningError> { @@ -259,8 +243,7 @@ where Ok(()) } - /// Handle a list_webhooks request. - pub fn handle_list_webhooks( + fn handle_list_webhooks( &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, _params: ListWebhooksRequest, ) -> Result<(), LightningError> { @@ -290,8 +273,7 @@ where Ok(()) } - /// Handle a remove_webhook request. - pub fn handle_remove_webhook( + fn handle_remove_webhook( &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, params: RemoveWebhookRequest, ) -> Result<(), LightningError> { diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index 4c5d85a2447..5912a13454b 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -685,6 +685,11 @@ fn advance_chain(node: &mut Node, num_blocks: u32) { } } +/// 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); + pub(crate) fn get_client_and_service( time_provider: Option>, ) -> ( @@ -696,7 +701,11 @@ pub(crate) fn get_client_and_service( &'static Node, ) { let signing_key = SecretKey::from_slice(&[42; 32]).unwrap(); - let mut lsps5_service_config = LSPS5ServiceConfig::default(); + let mut lsps5_service_config = LSPS5ServiceConfig { + max_webhooks_per_client: DEFAULT_MAX_WEBHOOKS_PER_CLIENT, + signing_key, + notification_cooldown_hours: DEFAULT_NOTIFICATION_COOLDOWN_HOURS, + }; lsps5_service_config.signing_key = signing_key; let service_config = LiquidityServiceConfig { #[cfg(lsps1_service)] diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 477ef3da1f9..36cee30c2ee 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -2,7 +2,7 @@ mod common; -use common::{get_client_and_service, get_lsps_message}; +use common::{get_client_and_service, get_lsps_message, DEFAULT_MAX_WEBHOOKS_PER_CLIENT}; use lightning::ln::msgs::LightningError; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::util::hash_tables::HashSet; @@ -11,7 +11,7 @@ use lightning_liquidity::lsps5::event::{LSPS5ClientEvent, LSPS5ServiceEvent}; use lightning_liquidity::lsps5::msgs::{ LSPS5AppName, LSPS5Error, LSPS5WebhookUrl, WebhookNotification, WebhookNotificationMethod, }; -use lightning_liquidity::lsps5::service::{LSPS5ServiceConfig, TimeProvider}; +use lightning_liquidity::lsps5::service::TimeProvider; use lightning_liquidity::lsps5::service::{ MIN_WEBHOOK_RETENTION_DAYS, PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS, }; @@ -103,7 +103,6 @@ fn webhook_registration_flow() { }, _ => panic!("Expected SendWebhookNotifications event"), } - let set_webhook_response = get_lsps_message!(service_node, client_node_id); client_node @@ -123,7 +122,7 @@ fn webhook_registration_flow() { request_id: req_id, }) => { assert_eq!(num_webhooks, 1); - assert_eq!(max_webhooks, LSPS5ServiceConfig::default().max_webhooks_per_client); + 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()); @@ -154,7 +153,7 @@ fn webhook_registration_flow() { }) => { assert_eq!(app_names, vec![app_name.clone()]); assert_eq!(counterparty_node_id, client_node_id); - assert_eq!(max_webhooks, LSPS5ServiceConfig::default().max_webhooks_per_client); + assert_eq!(max_webhooks, DEFAULT_MAX_WEBHOOKS_PER_CLIENT); assert_eq!(req_id, list_request_id); }, _ => panic!("Unexpected event"), @@ -177,7 +176,7 @@ fn webhook_registration_flow() { }) => { assert_eq!(lsp, service_node_id); assert_eq!(app_names, vec![app_name.clone()]); - assert_eq!(max_webhooks, LSPS5ServiceConfig::default().max_webhooks_per_client); + assert_eq!(max_webhooks, DEFAULT_MAX_WEBHOOKS_PER_CLIENT); assert_eq!(request_id, list_request_id); }, _ => panic!("Unexpected event"), @@ -352,8 +351,7 @@ fn webhook_error_handling_test() { // 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..LSPS5ServiceConfig::default().max_webhooks_per_client { + 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()) @@ -369,11 +367,7 @@ fn webhook_error_handling_test() { } // Now try to add one more webhook - should fail with too many webhooks error - let raw_one_too_many = format!( - "{} {}", - valid_app_name_base, - LSPS5ServiceConfig::default().max_webhooks_per_client - ); + 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()) @@ -396,7 +390,7 @@ fn webhook_error_handling_test() { }) => { let error_to_check = LSPS5Error::TooManyWebhooks(format!( "Maximum of {} webhooks allowed per client", - LSPS5ServiceConfig::default().max_webhooks_per_client + DEFAULT_MAX_WEBHOOKS_PER_CLIENT )); assert_eq!(error, error_to_check); assert_eq!(app_name, one_too_many); From e499084040e13b348ca85bf56efc95255d82c09d Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 5 May 2025 15:13:57 -0300 Subject: [PATCH 18/18] WIP --- lightning-background-processor/Cargo.toml | 4 +- lightning-background-processor/src/lib.rs | 16 +- lightning-liquidity/src/lsps0/ser.rs | 1 - lightning-liquidity/src/lsps5/client.rs | 513 ++++++------ lightning-liquidity/src/lsps5/event.rs | 254 +++++- lightning-liquidity/src/lsps5/msgs.rs | 326 ++++++-- lightning-liquidity/src/lsps5/service.rs | 750 ++++++++++++++---- lightning-liquidity/src/manager.rs | 90 +-- .../{ => src}/tests/common/mod.rs | 106 +-- .../tests/lsps2_integration_tests.rs | 22 +- .../tests/lsps5_integration_tests.rs | 228 +++--- lightning-liquidity/src/tests/mod.rs | 3 + lightning-liquidity/tests/signing_tests.rs | 504 ------------ 13 files changed, 1551 insertions(+), 1266 deletions(-) rename lightning-liquidity/{ => src}/tests/common/mod.rs (87%) rename lightning-liquidity/{ => src}/tests/lsps2_integration_tests.rs (92%) rename lightning-liquidity/{ => src}/tests/lsps5_integration_tests.rs (84%) delete mode 100644 lightning-liquidity/tests/signing_tests.rs diff --git a/lightning-background-processor/Cargo.toml b/lightning-background-processor/Cargo.toml index aa91378129f..7140ff88296 100644 --- a/lightning-background-processor/Cargo.toml +++ b/lightning-background-processor/Cargo.toml @@ -16,8 +16,8 @@ rustdoc-args = ["--cfg", "docsrs"] [features] futures = [ ] std = ["lightning/std", "lightning-liquidity/std", "bitcoin-io/std", "bitcoin_hashes/std"] - -default = ["std"] +time = ["std"] +default = ["std", "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 f22d9fad2d8..3d5ecfc0427 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -1142,6 +1142,7 @@ mod tests { use lightning::util::sweep::{OutputSpendStatus, OutputSweeper, PRUNE_DELAY_BLOCKS}; use lightning::util::test_utils; use lightning::{get_event, get_event_msg}; + use lightning_liquidity::lsps5::service::TimeProvider; use lightning_liquidity::LiquidityManager; use lightning_persister::fs_store::FilesystemStore; use lightning_rapid_gossip_sync::RapidGossipSync; @@ -1578,6 +1579,16 @@ mod tests { path.to_str().unwrap().to_string() } + pub struct DefaultTimeProvider; + + #[cfg(feature = "std")] + 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") + } + } + fn create_nodes(num_nodes: usize, persist_dir: &str) -> (String, Vec) { let persist_temp_path = env::temp_dir().join(persist_dir); let persist_dir = persist_temp_path.to_string_lossy().to_string(); @@ -1676,14 +1687,15 @@ mod tests { logger.clone(), keys_manager.clone(), )); - let liquidity_manager = Arc::new(LiquidityManager::new( + let time_provider = Arc::new(DefaultTimeProvider); + let liquidity_manager = Arc::new(LiquidityManager::new_with_custom_time_provider( Arc::clone(&keys_manager), Arc::clone(&manager), None, None, None, None, - None, + time_provider, )); let node = Node { node: manager, diff --git a/lightning-liquidity/src/lsps0/ser.rs b/lightning-liquidity/src/lsps0/ser.rs index 14a6e383c08..18525ea056c 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -592,7 +592,6 @@ impl<'de, 'a> Visitor<'de> for LSPSMessageVisitor<'a> { .map_err(de::Error::custom)?; Ok(LSPSMessage::LSPS2(LSPS2Message::Request(id, LSPS2Request::Buy(request)))) }, - // Add LSPS5 methods LSPSMethod::LSPS5SetWebhook => { let request = serde_json::from_value(params.unwrap_or(json!({}))) .map_err(de::Error::custom)?; diff --git a/lightning-liquidity/src/lsps5/client.rs b/lightning-liquidity/src/lsps5/client.rs index 73259f73e73..55ff5dc70fb 100644 --- a/lightning-liquidity/src/lsps5/client.rs +++ b/lightning-liquidity/src/lsps5/client.rs @@ -22,19 +22,19 @@ use crate::prelude::{new_hash_map, HashMap}; use crate::sync::{Arc, Mutex, RwLock}; use crate::utils::generate_request_id; -use super::msgs::{LSPS5AppName, LSPS5WebhookUrl}; -#[cfg(feature = "time")] -use super::service::DefaultTimeProvider; +use super::msgs::{LSPS5AppName, LSPS5ClientError, LSPS5Error, LSPS5WebhookUrl}; use super::service::TimeProvider; -use alloc::collections::VecDeque; -use alloc::string::String; use bitcoin::secp256k1::PublicKey; + use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::sign::EntropySource; use lightning::util::logger::Level; use lightning::util::message_signing; +use alloc::collections::VecDeque; +use alloc::string::String; + use core::ops::Deref; use core::time::Duration; @@ -85,9 +85,9 @@ impl Default for LSPS5ClientConfig { struct PeerState { pending_set_webhook_requests: - HashMap, // RequestId -> (app_name, webhook_url, timestamp) - pending_list_webhooks_requests: HashMap, // RequestId -> timestamp - pending_remove_webhook_requests: HashMap, // RequestId -> (app_name, timestamp) + HashMap, + pending_list_webhooks_requests: HashMap, + pending_remove_webhook_requests: HashMap, last_cleanup: Option, max_age_secs: Duration, time_provider: Arc, @@ -129,24 +129,33 @@ impl PeerState { } } -/// LSPS5 client handler. +/// 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, and to parse +/// and validate incoming signed notifications. +/// +/// # 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`] +/// - `parse_webhook_notification(...)` -> verify signature, timestamp, replay, and emit event +/// +/// [`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 pub struct LSPS5ClientHandler where ES::Target: EntropySource, { - /// Pending messages to be sent. pending_messages: Arc, - /// Event queue for emitting events. pending_events: Arc, - /// Entropy source. entropy_source: ES, - /// Per peer state for tracking requests. per_peer_state: RwLock>>, - /// Client configuration. config: LSPS5ClientConfig, - /// Time provider for LSPS5 service. time_provider: Arc, - /// Map of recently used signatures to prevent replay attacks. recent_signatures: Mutex>, } @@ -154,24 +163,8 @@ impl LSPS5ClientHandler where ES::Target: EntropySource, { - /// Creates a new LSPS5 client handler with the provided entropy source, message queue, - /// event queue, and LSPS5ClientConfig. - #[cfg(feature = "time")] + /// Constructs an `LSPS5ClientHandler`. pub(crate) fn new( - entropy_source: ES, pending_messages: Arc, pending_events: Arc, - config: LSPS5ClientConfig, - ) -> Self { - let time_provider = Arc::new(DefaultTimeProvider); - Self::new_with_custom_time_provider( - entropy_source, - pending_messages, - pending_events, - config, - time_provider, - ) - } - - pub(crate) fn new_with_custom_time_provider( entropy_source: ES, pending_messages: Arc, pending_events: Arc, config: LSPS5ClientConfig, time_provider: Arc, ) -> Self { @@ -187,9 +180,7 @@ where } } - fn with_peer_state( - &self, counterparty_node_id: PublicKey, f: F, - ) -> Result + fn with_peer_state(&self, counterparty_node_id: PublicKey, f: F) -> R where F: FnOnce(&mut PeerState) -> R, { @@ -201,34 +192,41 @@ where peer_state_lock.cleanup_expired_responses(); - Ok(f(&mut *peer_state_lock)) + f(&mut *peer_state_lock) } - /// Register a webhook with the LSP. + /// Register or update a webhook endpoint under a human-readable name. /// - /// Implements the `lsps5.set_webhook` method from bLIP-55. + /// Sends a `lsps5.set_webhook` JSON-RPC request to the given LSP peer. /// /// # Parameters - /// * `app_name` - A human-readable UTF-8 string that gives a name to the webhook (max 64 bytes). - /// * `webhook` - The URL of the webhook that the LSP can use to push notifications (max 1024 chars). + /// - `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 - /// * Success - the request ID that was used. - /// * Error - validation error or error sending the request. + /// 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. /// - /// Response will be provided asynchronously through the event queue as a - /// WebhookRegistered or WebhookRegistrationFailed event. + /// [`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).map_err(|e| LightningError { - err: e.message(), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; + ) -> Result { + let app_name = LSPS5AppName::from_string(app_name)?; - let lsps_webhook_url = LSPS5WebhookUrl::from_string(webhook_url).map_err(|e| { - LightningError { err: e.message(), action: ErrorAction::IgnoreAndLog(Level::Error) } - })?; + let lsps_webhook_url = LSPS5WebhookUrl::from_string(webhook_url)?; let request_id = generate_request_id(&self.entropy_source); @@ -243,7 +241,7 @@ where ), ), ); - })?; + }); let request = LSPS5Request::SetWebhook(SetWebhookRequest { app_name, webhook: lsps_webhook_url }); @@ -254,54 +252,63 @@ where Ok(request_id) } - /// List all registered webhooks. + /// List all webhook names currently registered with the LSP. /// - /// Implements the `lsps5.list_webhooks` method from bLIP-55. + /// Sends a `lsps5.list_webhooks` JSON-RPC request to the peer. + /// + /// # Parameters + /// - `counterparty_node_id`: The LSP node ID to query. /// /// # Returns - /// * Success - the request ID that was used. - /// * Error - error sending the request. + /// A unique `LSPSRequestId` for correlating the asynchronous response. + /// + /// Response from the LSP peer will be provided asynchronously through a + /// [`LSPS5Response::ListWebhooks`] or [`LSPS5Response::ListWebhooksError`] message, and this client + /// will then enqueue either a [`WebhooksListed`] or [`WebhooksListFailed`] event. /// - /// Response will be provided asynchronously through the event queue as a - /// WebhooksListed or WebhooksListFailed event. - pub fn list_webhooks( - &self, counterparty_node_id: PublicKey, - ) -> Result { + /// [`WebhooksListed`]: super::event::LSPS5ClientEvent::WebhooksListed + /// [`WebhooksListFailed`]: super::event::LSPS5ClientEvent::WebhooksListFailed + /// [`LSPS5Response::ListWebhooks`]: super::msgs::LSPS5Response::ListWebhooks + /// [`LSPS5Response::ListWebhooksError`]: super::msgs::LSPS5Response::ListWebhooksError + 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)); - Ok(request_id) + request_id } - /// Remove a webhook by app_name. + /// Remove a previously registered webhook by its name. /// - /// Implements the `lsps5.remove_webhook` method from bLIP-55. + /// Sends a `lsps5.remove_webhook` JSON-RPC request to the peer. /// /// # Parameters - /// * `app_name` - The name of the webhook to remove. + /// - `counterparty_node_id`: The LSP node ID to contact. + /// - `app_name`: The name of the webhook to remove. /// /// # Returns - /// * Success - the request ID that was used. - /// * Error - error sending the request. + /// A unique `LSPSRequestId` for correlating the asynchronous response. /// - /// Response will be provided asynchronously through the event queue as a - /// WebhookRemoved or WebhookRemovalFailed event. + /// 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).map_err(|e| LightningError { - err: e.message(), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; + ) -> Result { + let app_name = LSPS5AppName::from_string(app_name)?; let request_id = generate_request_id(&self.entropy_source); let now = @@ -311,7 +318,7 @@ where 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); @@ -320,216 +327,163 @@ where Ok(request_id) } - /// Handle received messages from the LSP. fn handle_message( &self, message: LSPS5Message, counterparty_node_id: &PublicKey, ) -> Result<(), LightningError> { - let event_queue_notifier = self.pending_events.notifier(); - match message { - LSPS5Message::Response(request_id, response) => { - let mut result = Err(LightningError { - err: format!( - "Received LSPS5 response from unknown peer: {}", - counterparty_node_id - ), - action: ErrorAction::IgnoreAndLog(Level::Error), - }); - - self.with_peer_state(*counterparty_node_id, |peer_state| { - if let Some((app_name, webhook_url, _)) = - peer_state.pending_set_webhook_requests.remove(&request_id) - { - match response { - LSPS5Response::SetWebhook(response) => { - event_queue_notifier.enqueue(LSPS5ClientEvent::WebhookRegistered { - counterparty_node_id: *counterparty_node_id, - num_webhooks: response.num_webhooks, - max_webhooks: response.max_webhooks, - no_change: response.no_change, - app_name, - url: webhook_url, - request_id, - }); - result = Ok(()); - }, - LSPS5Response::SetWebhookError(error) => { - event_queue_notifier.enqueue( - LSPS5ClientEvent::WebhookRegistrationFailed { - counterparty_node_id: *counterparty_node_id, - error, - app_name, - url: webhook_url, - request_id, - }, - ); - result = Ok(()); - }, - _ => { - result = Err(LightningError { - err: "Unexpected response type for SetWebhook request" - .to_string(), - action: ErrorAction::IgnoreAndLog(Level::Error), - }); - }, - } - } else if peer_state - .pending_list_webhooks_requests - .remove(&request_id) - .is_some() - { - match response { - LSPS5Response::ListWebhooks(response) => { - event_queue_notifier.enqueue(LSPS5ClientEvent::WebhooksListed { - counterparty_node_id: *counterparty_node_id, - app_names: response.app_names, - max_webhooks: response.max_webhooks, - request_id, - }); - result = Ok(()); - }, - LSPS5Response::ListWebhooksError(error) => { - event_queue_notifier.enqueue( - LSPS5ClientEvent::WebhooksListFailed { - counterparty_node_id: *counterparty_node_id, - error, - request_id, - }, - ); - result = Ok(()); - }, - _ => { - result = Err(LightningError { - err: "Unexpected response type for ListWebhooks request" - .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(error) => { - event_queue_notifier.enqueue( - LSPS5ClientEvent::WebhookRemovalFailed { - counterparty_node_id: *counterparty_node_id, - error, - app_name, - request_id, - }, - ); - result = Ok(()); - }, - _ => { - result = Err(LightningError { - err: "Unexpected response type for RemoveWebhook request" - .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), - }); - } - })?; - - result - }, + let (request_id, response) = match message { LSPS5Message::Request(_, _) => { - // We're a client, so we don't expect to receive requests - Err(LightningError { + 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(()); + }, + LSPS5Response::ListWebhooksError(e) => { + event_queue_notifier.enqueue(LSPS5ClientEvent::WebhooksListFailed { + counterparty_node_id: *counterparty_node_id, + error: e.clone().into(), + 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 } - /// Verify a webhook notification signature from an LSP. - /// - /// This can be used by a notification delivery service to verify - /// the authenticity of notifications received from an LSP. - /// - /// # Parameters - /// * `timestamp` - The ISO8601 timestamp from the notification. - /// * `signature` - The signature string from the notification. - /// * `notification` - The webhook notification object. - /// - /// # Returns - /// * On success: `true` if the signature is valid. - /// * On error: LightningError with error description. - pub fn verify_notification_signature( + fn verify_notification_signature( &self, counterparty_node_id: PublicKey, signature_timestamp: &LSPSDateTime, signature: &str, notification: &WebhookNotification, - ) -> Result { + ) -> Result { let now = LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); let diff = signature_timestamp.abs_diff(now); - - if diff > 600 { - return Err(LightningError { - err: format!("Timestamp too old: {}", signature_timestamp), - action: ErrorAction::IgnoreAndLog(Level::Error), - }); + let ten_minutes = 600; + if diff > ten_minutes { + return Err(LSPS5ClientError::InvalidTimestamp(signature_timestamp.to_rfc3339())); } - let notification_json = - serde_json::to_string(notification).map_err(|e| LightningError { - err: format!("Failed to serialize notification: {}", e), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; - let message = format!( - "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {:?}", signature_timestamp.to_rfc3339(), - notification_json + notification ); if message_signing::verify(message.as_bytes(), signature, &counterparty_node_id) { Ok(true) } else { - Err(LightningError { - err: "Invalid signature".to_string(), - action: ErrorAction::IgnoreAndLog(Level::Error), - }) + Err(LSPS5ClientError::InvalidSignature) } } - /// Check if a signature has been used before. - fn check_signature_exists(&self, signature: &str) -> Result<(), LightningError> { + fn check_signature_exists(&self, signature: &str) -> Result<(), LSPS5ClientError> { let recent_signatures = self.recent_signatures.lock().unwrap(); for (stored_sig, _) in recent_signatures.iter() { if stored_sig == signature { - return Err(LightningError { - err: "Replay attack detected: signature has been used before".to_string(), - action: ErrorAction::IgnoreAndLog(Level::Warn), - }); + return Err(LSPS5ClientError::ReplayAttack); } } Ok(()) } - /// Store a signature with timestamp for replay attack prevention. - fn store_signature(&self, signature: String) -> Result<(), LightningError> { + fn store_signature(&self, signature: String) { let now = LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); let mut recent_signatures = self.recent_signatures.lock().unwrap(); @@ -548,38 +502,52 @@ where while recent_signatures.len() > self.config.signature_config.max_signatures { recent_signatures.pop_front(); } - - Ok(()) } - /// Parse a webhook notification received from an LSP. + /// Parse and validate a webhook notification received from an LSP. /// - /// This can be used by a client implementation to handle webhook - /// notifications after they're delivered through a push notification - /// system. + /// Implements the bLIP-55 / LSPS5 webhook delivery rules: + /// 1. Parses `notification_json` into a `WebhookNotification` (JSON-RPC 2.0). + /// 2. Checks that `timestamp` (from `x-lsps5-timestamp`) is within ±10 minutes of local time. + /// 3. Ensures `signature` (from `x-lsps5-signature`) has not been replayed within the + /// configured retention window. + /// 4. Reconstructs the exact string + /// `"LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {timestamp} I notify {body}"` + /// and verifies the zbase32 LN-style signature against the LSP’s node ID. /// /// # Parameters - /// * `timestamp` - The ISO8601 timestamp from the notification. - /// * `signature` - The signature from the notification. - /// * `notification_json` - The JSON string of the notification object. + /// - `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_json`: the JSON string of the JSON-RPC notification object. /// - /// # Returns - /// * On success: The parsed webhook notification. - /// * On error: LightningError with error description. + /// On success, emits [`LSPS5ClientEvent::WebhookNotificationReceived`] + /// and returns the parsed [`WebhookNotification`]. + /// + /// Failure reasons include: + /// - Timestamp too old (drift > 10 minutes) + /// - Replay attack detected (signature reused) + /// - Invalid signature (crypto check fails) + /// - JSON parse errors for malformed `notification_json` + /// + /// Clients should call this method upon receiving a [`LSPS5ServiceEvent::SendWebhookNotification`] + /// event, before taking action on the notification. This guarantees that only authentic, + /// non-replayed notifications reach your application. + /// + /// [`LSPS5ClientEvent::WebhookNotificationReceived`]: super::event::LSPS5ClientEvent::WebhookNotificationReceived + /// [`LSPS5ServiceEvent::SendWebhookNotification`]: super::event::LSPS5ServiceEvent::SendWebhookNotification + /// [`WebhookNotification`]: super::msgs::WebhookNotification pub fn parse_webhook_notification( &self, counterparty_node_id: PublicKey, timestamp: &LSPSDateTime, signature: &str, notification_json: &str, - ) -> Result { + ) -> Result { let event_queue_notifier = self.pending_events.notifier(); - let notification: WebhookNotification = - serde_json::from_str(notification_json).map_err(|e| LightningError { - err: format!("Failed to parse notification: {}", e), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; + let notification: WebhookNotification = serde_json::from_str(notification_json) + .map_err(|e| LSPS5ClientError::DeserializeError(e.to_string()))?; self.check_signature_exists(signature)?; - self.store_signature(signature.to_string())?; + self.store_signature(signature.to_string()); match self.verify_notification_signature( counterparty_node_id, @@ -622,7 +590,9 @@ mod tests { use super::*; use crate::{ - lsps0::ser::LSPSRequestId, lsps5::msgs::SetWebhookResponse, tests::utils::TestEntropy, + lsps0::ser::LSPSRequestId, + lsps5::{msgs::SetWebhookResponse, service::DefaultTimeProvider}, + tests::utils::TestEntropy, }; use bitcoin::{key::Secp256k1, secp256k1::SecretKey}; @@ -636,12 +606,13 @@ mod tests { let test_entropy_source = Arc::new(TestEntropy {}); let message_queue = Arc::new(MessageQueue::new()); let event_queue = Arc::new(EventQueue::new()); - + let time_provider = Arc::new(DefaultTimeProvider); let client = LSPS5ClientHandler::new( test_entropy_source, message_queue.clone(), event_queue.clone(), LSPS5ClientConfig::default(), + time_provider, ); let secp = Secp256k1::new(); @@ -684,7 +655,7 @@ mod tests { 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).unwrap(); + let list_req_id = client.list_webhooks(peer); let remove_req_id = client.remove_webhook(peer, "test-app".to_string()).unwrap(); { diff --git a/lightning-liquidity/src/lsps5/event.rs b/lightning-liquidity/src/lsps5/event.rs index 5fef29093ef..132ea97f624 100644 --- a/lightning-liquidity/src/lsps5/event.rs +++ b/lightning-liquidity/src/lsps5/event.rs @@ -7,7 +7,7 @@ // You may not use this file except in accordance with one or both of these // licenses. -//! Events generated by the LSPS5 service and client +//! Contains bLIP-55 / LSPS5 event types use crate::lsps0::ser::LSPSDateTime; use crate::lsps0::ser::LSPSRequestId; @@ -19,13 +19,23 @@ 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 webhook was registered by a client + /// A webhook was registered by a client. + /// + /// This event is triggered when a client successfully registers or updates a webhook + /// via [`lsps5.set_webhook`]. The LSP should store this webhook information for future + /// notifications to this client. /// - /// This event occurs when a client successfully registers a webhook via `lsps5.set_webhook`. - /// You should store this information to be able to contact the client when they are offline. + /// When this event occurs, the LSP should: + /// 1. Store the webhook information for the client + /// 2. If `no_change` is `false` (i.e., this is a new registration or an update with changes), + /// send an initial [`lsps5.webhook_registered`] notification to verify the webhook works. + /// + /// [`lsps5.set_webhook`]: super::msgs::LSPS5Request::SetWebhook + /// [`lsps5.webhook_registered`]: super::msgs::WebhookNotificationMethod::LSPS5WebhookRegistered WebhookRegistered { /// Client node ID that registered the webhook. counterparty_node_id: PublicKey, @@ -57,27 +67,67 @@ pub enum LSPS5ServiceEvent { /// Webhooks were listed for a client. /// - /// This event occurs when a client requests their registered webhooks via `lsps5.list_webhooks`. + /// This event is triggered when a client successfully requests their registered webhooks + /// via [`lsps5.list_webhooks`]. The LSP has responded with the list of app names that + /// have registered webhooks for this client. + /// + /// When this event occurs, it indicates that: + /// 1. The LSP has successfully fetched all registered webhook names for this client + /// 2. The LSP has sent a response containing the list and maximum allowed webhooks + /// 3. The client is now aware of all their currently registered webhooks + /// + /// This event is primarily informational and doesn't typically require further action. + /// + /// [`lsps5.list_webhooks`]: super::msgs::LSPS5Request::ListWebhooks WebhooksListed { /// Client node ID that requested their webhooks. counterparty_node_id: PublicKey, /// App names with registered webhooks for this client. + /// + /// Each [`app_name`] in this list corresponds to a registered webhook. + /// + /// [`app_name`]: super::msgs::LSPS5AppName app_names: Vec, /// The identifier of the issued bLIP-55 / LSPS5 webhook listing request. /// /// This can be used to track which request this event corresponds to. request_id: LSPSRequestId, /// Maximum number of webhooks allowed by LSP per client. + /// + /// This is the value defined in [`max_webhooks_per_client`] within the service configuration. + /// + /// [`max_webhooks_per_client`]: super::service::LSPS5ServiceConfig::max_webhooks_per_client max_webhooks: u32, }, /// A webhook was removed by a client. /// - /// This event occurs when a client successfully removes a webhook via `lsps5.remove_webhook`. + /// This event is triggered when a client successfully removes a webhook via + /// [`lsps5.remove_webhook`]. The LSP has deleted the specified webhook registration from + /// its storage and will no longer send notifications to this webhook URL. + /// + /// When this event occurs, the LSP should: + /// 1. Confirm the webhook has been completely removed from all internal data structures + /// 2. If there are any pending notifications for this webhook, cancel them + /// 3. Update any related metrics or logs to reflect the webhook removal + /// + /// Note that if a client attempts to remove a webhook that doesn't exist, a + /// [`LSPS5ProtocolError::AppNameNotFound`] error is returned instead, and this event + /// will not be triggered. + /// + /// [`lsps5.remove_webhook`]: super::msgs::LSPS5Request::RemoveWebhook + /// [`LSPS5ProtocolError::AppNameNotFound`]: super::msgs::LSPS5ProtocolError::AppNameNotFound WebhookRemoved { /// Client node ID that removed the webhook. counterparty_node_id: PublicKey, /// App name that was removed. + /// + /// This identifies the webhook that was removed. + /// + /// **Note**: The [`app_name`] must have been previously registered via [`lsps5.set_webhook`]. + /// + /// [`app_name`]: super::msgs::LSPS5AppName + /// [`lsps5.set_webhook`]: super::msgs::LSPS5Request::SetWebhook app_name: LSPS5AppName, /// The identifier of the issued bLIP-55 / LSPS5 webhook removal request. /// @@ -87,33 +137,65 @@ pub enum LSPS5ServiceEvent { /// A notification needs to be sent to a client's webhook. /// - /// This event occurs when the LSP needs to send a notification to a client's webhook. - /// When this event is received, the LSP should: - /// 1. Serialize the notification to JSON. - /// 2. Make an HTTP POST request to the provided - /// URL with the given headers and the serialized notification. + /// This event is triggered when the LSP needs to notify a client about an event + /// via their registered webhook. The LSP must make an HTTP POST request to the + /// provided URL with the specified headers and notification content. + /// + /// When this event occurs, the LSP should: + /// 1. Send an HTTP POST request to the specified webhook URL + /// 2. Include all provided headers, especially the timestamp and signature headers + /// 3. Send the JSON-serialized notification as the request body + /// 4. Handle any HTTP errors according to the LSP's retry policy + /// + /// The notification is signed using the LSP's node ID to ensure authenticity + /// when received by the client. The client verifies this signature using + /// [`parse_webhook_notification`], which guards against replay attacks and tampering. + /// + /// 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`]. /// - /// When the client receives this notification, they will process it and generate a - /// `WebhookNotificationReceived` event on their side. The client will validate the - /// signature using the LSP's node ID to ensure the notification is authentic. - SendWebhookNotifications { + /// [`parse_webhook_notification`]: super::client::LSPS5ClientHandler::parse_webhook_notification + /// [`notification_cooldown_hours`]: super::service::LSPS5ServiceConfig::notification_cooldown_hours + SendWebhookNotification { /// Client node ID to be notified. counterparty_node_id: PublicKey, /// App name to be notified. + /// + /// This identifies which webhook registration should be notified. + /// + /// **Note**: The [`app_name`] must have been previously registered via [`lsps5.set_webhook`]. + /// + /// [`app_name`]: super::msgs::LSPS5AppName + /// [`lsps5.set_webhook`]: super::msgs::LSPS5Request::SetWebhook app_name: LSPS5AppName, /// URL that to be contacted. + /// + /// This is the webhook URL (HTTPS) provided by the client during registration. + /// + /// **Note**: The URL must be a valid HTTPS URL that points to a public host. + /// + /// [`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, /// Timestamp of the notification. + /// + /// This timestamp is used for signing the notification and must be within 10 minutes + /// of the client's local time for the signature to be accepted. timestamp: LSPSDateTime, /// Signature of the notification using the LSP's node ID. + /// + /// This signature helps the client verify that the notification was sent by the LSP. signature: String, /// Headers to be included in the HTTP POST request. /// - /// Content-Type (application/json). - /// x-lsps5-timestamp (timestamp in RFC3339 (YYYY-MM-DDThh:mm:ss.uuuZ) format). - /// x-lsps5-signature (signature of the notification using the LSP's node ID). + /// Headers should include: + /// - Content-Type (application/json) + /// - x-lsps5-timestamp (timestamp in RFC3339 format, e.g., "YYYY-MM-DDThh:mm:ss.uuuZ") + /// - x-lsps5-signature (signature of the notification using the LSP's node ID) headers: Vec<(String, String)>, }, } @@ -124,7 +206,24 @@ 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`. + /// of a webhook via [`lsps5.set_webhook`]. The client has received a successful + /// response with information about the total number of webhooks registered and limits. + /// + /// When this event occurs, the client should: + /// 1. Update any UI to reflect the successful registration + /// 2. Store the webhook registration details if needed locally + /// 3. Prepare to receive notifications at the registered webhook URL + /// 4. Note that if `no_change` is `true`, the LSP did not send a test notification + /// + /// The [`app_name`] and [`url`] both must respect maximum lengths of + /// [`MAX_APP_NAME_LENGTH`] and [`MAX_WEBHOOK_URL_LENGTH`] respectively, and the + /// [`url`] must use HTTPS. + /// + /// [`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 WebhookRegistered { /// The node id of the LSP that confirmed the registration. counterparty_node_id: PublicKey, @@ -145,11 +244,32 @@ pub enum LSPS5ClientEvent { request_id: LSPSRequestId, }, - /// A webhook registration attempt failed + /// A webhook registration attempt failed. /// /// This event is triggered when the LSP rejects a webhook registration - /// via `lsps5.set_webhook`. This can happen if the app_name or URL is too long, - /// the URL uses an unsupported protocol, or the maximum number of webhooks is reached. + /// via [`lsps5.set_webhook`]. This failure can occur for several reasons: + /// + /// When this event occurs, the client should: + /// 1. Present an appropriate error message to the user + /// 2. Consider retry strategies based on the specific error + /// 3. If the error is due to reaching webhook limits, prompt the user to remove + /// unused webhooks before trying again + /// + /// Common error cases include: + /// - 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`]) + /// + /// [`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, @@ -167,7 +287,19 @@ pub enum LSPS5ClientEvent { /// The list of registered webhooks was successfully retrieved. /// - /// This event is triggered when the LSP responds to a `lsps5.list_webhooks` request. + /// This event is triggered when the LSP responds to a + /// [`lsps5.list_webhooks`] request. The client now has an up-to-date + /// list of all registered webhook app names. + /// + /// When this event occurs, the client should: + /// 1. Update any UI to display the list of registered webhooks + /// 2. Update any local cache or state about registered webhooks + /// 3. Check if the number of webhooks approaches the maximum allowed limit + /// + /// This listing only provides the app names; to get the URLs, the client would + /// need to maintain its own records from registration events. + /// + /// [`lsps5.list_webhooks`]: super::msgs::LSPS5Request::ListWebhooks WebhooksListed { /// The node id of the LSP that provided the list. counterparty_node_id: PublicKey, @@ -183,7 +315,18 @@ pub enum LSPS5ClientEvent { /// The attempt to list webhooks failed. /// - /// This event is triggered when the LSP rejects a `lsps5.list_webhooks` request. + /// This event is triggered when the LSP rejects a + /// [`lsps5.list_webhooks`] request. This is uncommon but might occur + /// due to temporary server issues or authentication problems. + /// + /// When this event occurs, the client should: + /// 1. Present an appropriate error message to the user + /// 2. Consider implementing a retry mechanism with backoff + /// 3. If persistent, check connectivity to the LSP node + /// + /// The error details provided can help diagnose the specific issue. + /// + /// [`lsps5.list_webhooks`]: super::msgs::LSPS5Request::ListWebhooks WebhooksListFailed { /// The node id of the LSP that rejected the request. counterparty_node_id: PublicKey, @@ -198,7 +341,20 @@ pub enum LSPS5ClientEvent { /// A webhook was successfully removed. /// /// This event is triggered when the LSP confirms successful removal - /// of a webhook via `lsps5.remove_webhook`. + /// of a webhook via [`lsps5.remove_webhook`]. The webhook registration + /// has been deleted from the LSP's system and will no longer receive + /// notifications. + /// + /// When this event occurs, the client should: + /// 1. Update any UI to reflect the webhook removal + /// 2. Remove the webhook from any local storage or cache + /// 3. Update counters or indicators showing the number of registered webhooks + /// 4. Take any application-specific cleanup actions for the removed webhook + /// + /// 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, @@ -213,7 +369,25 @@ pub enum LSPS5ClientEvent { /// 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 app_name_not_found (1010). + /// via [`lsps5.remove_webhook`]. The most common scenario is attempting + /// to remove a webhook that doesn't exist or was already removed. + /// + /// When this event occurs, the client should: + /// 1. Present an appropriate error message to the user + /// 2. If the error is [`AppNameNotFound`], update any local state to + /// reflect that the webhook does not exist on the server + /// 3. Consider refreshing the webhook list to ensure local state + /// matches server state + /// + /// 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, @@ -229,9 +403,31 @@ pub enum LSPS5ClientEvent { /// A webhook notification was received from the LSP. /// - /// This event is triggered when the client receives a webhook notification - /// from the LSP. This can happen for various reasons such as incoming payment, - /// expiring HTLCs, liquidity management requests, or incoming onion messages. + /// This event is triggered when the client receives and successfully + /// verifies a webhook notification from the LSP. This represents an + /// asynchronous event that the LSP is notifying the client about. + /// + /// When this event occurs, the client should: + /// 1. Check `signature_valid` to confirm the notification is authentic + /// 2. Examine the `notification.method` to determine the type of notification + /// 3. Process the notification according to its method and parameters + /// 4. Update any UI or take actions based on the notification type + /// + /// Common notification methods include: + /// - [`LSPS5WebhookRegistered`] - Initial test notification after registration + /// - [`LSPS5PaymentIncoming`] - Client has pending incoming payments + /// - [`LSPS5ExpirySoon`] - An HTLC or contract will expire soon + /// - [`LSPS5LiquidityManagementRequest`] - LSP needs to manage liquidity + /// - [`LSPS5OnionMessageIncoming`] - Client has pending onion messages + /// + /// The client should reject notifications with invalid signatures or timestamps + /// more than 10 minutes from the current time. + /// + /// [`LSPS5WebhookRegistered`]: super::msgs::WebhookNotificationMethod::LSPS5WebhookRegistered + /// [`LSPS5PaymentIncoming`]: super::msgs::WebhookNotificationMethod::LSPS5PaymentIncoming + /// [`LSPS5ExpirySoon`]: super::msgs::WebhookNotificationMethod::LSPS5ExpirySoon + /// [`LSPS5LiquidityManagementRequest`]: super::msgs::WebhookNotificationMethod::LSPS5LiquidityManagementRequest + /// [`LSPS5OnionMessageIncoming`]: super::msgs::WebhookNotificationMethod::LSPS5OnionMessageIncoming WebhookNotificationReceived { /// LSP node ID that sent the notification. counterparty_node_id: PublicKey, diff --git a/lightning-liquidity/src/lsps5/msgs.rs b/lightning-liquidity/src/lsps5/msgs.rs index 7654c34ad1e..c887d73c393 100644 --- a/lightning-liquidity/src/lsps5/msgs.rs +++ b/lightning-liquidity/src/lsps5/msgs.rs @@ -9,24 +9,27 @@ //! LSPS5 message formats for webhook registration -use core::fmt; -use core::fmt::Display; -use core::ops::Deref; - use crate::alloc::string::ToString; use crate::lsps0::ser::LSPSMessage; use crate::lsps0::ser::LSPSRequestId; use crate::lsps0::ser::LSPSResponseError; -use alloc::string::String; -use alloc::vec::Vec; + +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 super::url_utils::LSPSUrl; +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; @@ -34,11 +37,18 @@ 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; -pub(crate) const LSPS5_TOO_LONG_ERROR_CODE: i32 = 500; -pub(crate) const LSPS5_URL_PARSE_ERROR_CODE: i32 = 501; -pub(crate) const LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE: i32 = 502; -pub(crate) const LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE: i32 = 503; -pub(crate) const LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE: i32 = 1010; +/// 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; pub(crate) const LSPS5_SET_WEBHOOK_METHOD_NAME: &str = "lsps5.set_webhook"; pub(crate) const LSPS5_LIST_WEBHOOKS_METHOD_NAME: &str = "lsps5.list_webhooks"; @@ -51,84 +61,234 @@ 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)] -/// Structured LSPS5 error -pub enum LSPS5Error { - /// The provided input was too long. - TooLong(String), - /// The provided URL could not be parsed. +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(String), - /// The provided URL used an unsupported protocol. - UnsupportedProtocol(String), - /// The provided URL contained too many webhooks. + + /// 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. + /// + /// The string contains the maximum number of webhooks allowed. TooManyWebhooks(String), - /// The provided URL did not contain an app name. - AppNameNotFound(String), - /// The provided URL contained an app name that was not found. - Other { - /// Numeric code for matching legacy behaviors. - code: i32, - /// Human‐readable message. - message: String, - }, + + /// 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, } -impl Serialize for LSPS5Error { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - let mut m = serializer.serialize_struct("error", 3)?; - m.serialize_field("code", &self.code())?; - m.serialize_field("message", &self.message())?; - m.serialize_field("data", &Option::::None)?; - m.end() +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, + } + } + /// The error message for the LSPS5 protocol error. + pub fn message(&self) -> String { + match self { + LSPS5ProtocolError::AppNameTooLong => { + format!("App name exceeds maximum length of {} bytes", MAX_APP_NAME_LENGTH) + }, + LSPS5ProtocolError::WebhookUrlTooLong => { + format!("Webhook URL exceeds maximum length of {} bytes", MAX_WEBHOOK_URL_LENGTH) + }, + LSPS5ProtocolError::UrlParse(m) => m.clone(), + LSPS5ProtocolError::UnsupportedProtocol => { + "Unsupported protocol: HTTPS is required".to_string() + }, + LSPS5ProtocolError::TooManyWebhooks(max) => { + format!("Maximum of {} webhooks allowed per client", max) + }, + LSPS5ProtocolError::AppNameNotFound => "App name not found".to_string(), + LSPS5ProtocolError::UnknownError => "Unknown error".to_string(), + } } } -impl LSPS5Error { - /// Numeric code for matching legacy behaviors +impl Serialize for LSPS5ProtocolError { + fn serialize(&self, ser: S) -> Result + where + S: Serializer, + { + let mut s = ser.serialize_struct("error", 3)?; + s.serialize_field("code", &self.code())?; + s.serialize_field("message", &self.message())?; + let data = match self { + LSPS5ProtocolError::TooManyWebhooks(max) => Some(max), + _ => None, + }; + s.serialize_field("data", &data)?; + 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. + /// The string contains the problematic timestamp. + InvalidTimestamp(String), + + /// Failed to serialize an object to JSON. + /// + /// The string contains the detailed error message. + SerializeError(String), + + /// Failed to deserialize JSON into an expected object. + /// + /// The string contains the detailed error message. + DeserializeError(String), + + /// Detected a reused notification signature. + /// + /// Indicates a potential replay attack where a previously seen + /// notification signature was reused. + ReplayAttack, +} + +impl LSPS5ClientError { + const BASE: i32 = 100_000; + /// The error code for the client error. pub fn code(&self) -> i32 { + use LSPS5ClientError::*; match self { - LSPS5Error::TooLong(_) => LSPS5_TOO_LONG_ERROR_CODE, - LSPS5Error::UrlParse(_) => LSPS5_URL_PARSE_ERROR_CODE, - LSPS5Error::UnsupportedProtocol(_) => LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE, - LSPS5Error::TooManyWebhooks(_) => LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE, - LSPS5Error::AppNameNotFound(_) => LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE, - LSPS5Error::Other { code, .. } => *code, + InvalidSignature => Self::BASE + 1, + InvalidTimestamp(_) => Self::BASE + 2, + SerializeError(_) => Self::BASE + 3, + DeserializeError(_) => Self::BASE + 4, + ReplayAttack => Self::BASE + 5, } } - /// Human‐readable message + /// The error message for the client error. pub fn message(&self) -> String { + use LSPS5ClientError::*; match self { - LSPS5Error::TooLong(m) - | LSPS5Error::UrlParse(m) - | LSPS5Error::UnsupportedProtocol(m) - | LSPS5Error::TooManyWebhooks(m) - | LSPS5Error::AppNameNotFound(m) => m.clone(), - LSPS5Error::Other { message, .. } => message.clone(), + InvalidSignature => "Invalid signature".into(), + InvalidTimestamp(m) => format!("Timestamp out of range: {}", m), + SerializeError(m) => format!("Serialization error: {}", m), + DeserializeError(m) => format!("Deserialization error: {}", m), + ReplayAttack => "Replay attack detected".into(), } } } -/// Convert LSPSResponseError to LSPS5Error +#[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 => LSPS5Error::TooLong(err.message), - LSPS5_URL_PARSE_ERROR_CODE => LSPS5Error::UrlParse(err.message), - LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE => LSPS5Error::UnsupportedProtocol(err.message), - LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE => LSPS5Error::TooManyWebhooks(err.message), - LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE => LSPS5Error::AppNameNotFound(err.message), - code => LSPS5Error::Other { code, message: err.message }, + LSPS5_TOO_LONG_ERROR_CODE => LSPS5ProtocolError::AppNameTooLong, + LSPS5_URL_PARSE_ERROR_CODE => LSPS5ProtocolError::UrlParse(err.message), + LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE => LSPS5ProtocolError::UnsupportedProtocol, + LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE => match err.data { + Some(d) => LSPS5ProtocolError::TooManyWebhooks(d), + None => LSPS5ProtocolError::UnknownError, + }, + 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(), + data: match e { + LSPS5ProtocolError::TooManyWebhooks(max) => Some(max.to_string()), + _ => None, + }, } } } -/// Convert LSPS5Error to LSPSResponseError. impl From for LSPSResponseError { - fn from(err: LSPS5Error) -> Self { - LSPSResponseError { code: err.code(), message: err.message(), data: None } + fn from(e: LSPS5Error) -> Self { + match e { + LSPS5Error::Protocol(p) => p.into(), + LSPS5Error::Client(c) => { + LSPSResponseError { code: c.code(), message: c.message(), data: None } + }, + } } } @@ -140,10 +300,7 @@ impl LSPS5AppName { /// Create a new LSPS5 app name. pub fn new(app_name: UntrustedString) -> Result { if app_name.to_string().chars().count() > MAX_APP_NAME_LENGTH { - return Err(LSPS5Error::TooLong(format!( - "App name exceeds maximum length of {} bytes", - MAX_APP_NAME_LENGTH - ))); + return Err(LSPS5ProtocolError::AppNameTooLong.into()); } Ok(Self(app_name)) } @@ -212,20 +369,18 @@ impl LSPS5WebhookUrl { /// Create a new LSPS5 webhook URL. pub fn new(url: UntrustedString) -> Result { let parsed_url = LSPSUrl::parse(url.0.clone()) - .map_err(|_e| LSPS5Error::UrlParse(format!("Error parsing URL: {:?}", url)))?; + .map_err(|_e| LSPS5ProtocolError::UrlParse("Error parsing URL".to_string()))?; if parsed_url.url_length() > MAX_WEBHOOK_URL_LENGTH { - return Err(LSPS5Error::TooLong(format!( - "Webhook URL exceeds maximum length of {} bytes", - MAX_WEBHOOK_URL_LENGTH - ))); + return Err(LSPS5ProtocolError::WebhookUrlTooLong.into()); } if !parsed_url.is_https() { - return Err(LSPS5Error::UnsupportedProtocol( - "Unsupported protocol: HTTPS is required".to_string(), - )); + return Err(LSPS5ProtocolError::UnsupportedProtocol.into()); } if !parsed_url.is_public() { - return Err(LSPS5Error::UrlParse("Webhook URL must be a public URL".to_string())); + return Err(LSPS5ProtocolError::UrlParse( + "Webhook URL must be a public URL".to_string(), + ) + .into()); } Ok(Self(parsed_url)) } @@ -515,15 +670,15 @@ pub enum LSPS5Response { /// Response to [`SetWebhook`](SetWebhookRequest) request. SetWebhook(SetWebhookResponse), /// Error response to [`SetWebhook`](SetWebhookRequest) request. - SetWebhookError(LSPS5Error), + SetWebhookError(LSPSResponseError), /// Response to [`ListWebhooks`](ListWebhooksRequest) request. ListWebhooks(ListWebhooksResponse), /// Error response to [`ListWebhooks`](ListWebhooksRequest) request. - ListWebhooksError(LSPS5Error), + ListWebhooksError(LSPSResponseError), /// Response to [`RemoveWebhook`](RemoveWebhookRequest) request. RemoveWebhook(RemoveWebhookResponse), /// Error response to [`RemoveWebhook`](RemoveWebhookRequest) request. - RemoveWebhookError(LSPS5Error), + RemoveWebhookError(LSPSResponseError), } #[derive(Clone, Debug, PartialEq, Eq)] @@ -705,8 +860,15 @@ mod tests { match LSPS5WebhookUrl::new(UntrustedString(url_str.to_string())) { Ok(_) => panic!("Expected error"), Err(e) => { - // error is not null - assert!(e.code() != 0); + 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 + ); }, } } diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index 631d629f63e..494dc9f635c 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -9,6 +9,7 @@ //! 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::{ @@ -17,23 +18,24 @@ use crate::lsps5::msgs::{ }; use crate::message_queue::MessageQueue; use crate::prelude::*; -use core::ops::Deref; -use core::time::Duration; +use crate::sync::{Arc, Mutex}; use bitcoin::secp256k1::{PublicKey, SecretKey}; + use lightning::ln::channelmanager::AChannelManager; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::util::logger::Level; use lightning::util::message_signing; -use crate::alloc::string::ToString; -use crate::sync::{Arc, Mutex}; +use core::ops::Deref; +use core::time::Duration; + use alloc::string::String; use alloc::vec::Vec; use super::event::LSPS5ServiceEvent; use super::msgs::{ - LSPS5AppName, LSPS5Error, LSPS5Message, LSPS5Request, LSPS5Response, LSPS5WebhookUrl, + LSPS5AppName, LSPS5Message, LSPS5ProtocolError, LSPS5Request, LSPS5Response, LSPS5WebhookUrl, }; /// Minimum number of days to retain webhooks after a client's last channel is closed. @@ -44,15 +46,10 @@ pub const PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS: Duration = Duration::from_secs(24 /// A stored webhook. #[derive(Debug, Clone)] struct StoredWebhook { - /// App name identifier for this webhook. _app_name: LSPS5AppName, - /// The webhook URL. url: LSPS5WebhookUrl, - /// Client node ID. _counterparty_node_id: PublicKey, - /// Last time this webhook was used. last_used: LSPSDateTime, - /// Map of notification methods to last time they were sent. last_notification_sent: HashMap, } @@ -78,7 +75,7 @@ impl TimeProvider for DefaultTimeProvider { } } -/// Configuration for LSPS5 service. +/// Server-side configuration options for LSPS5 Webhook Registration. #[derive(Clone, Debug)] pub struct LSPS5ServiceConfig { /// Maximum number of webhooks allowed per client. @@ -89,7 +86,48 @@ pub struct LSPS5ServiceConfig { pub notification_cooldown_hours: Duration, } -/// Service for handling LSPS5 webhook registration +/// Service‐side handler for the LSPS5 (bLIP-55) 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`], +/// emit [`LSPS5ServiceEvent::WebhookRegistered`], and send an initial +/// [`lsps5.webhook_registered`] notification if new or changed. +/// - `lsps5.list_webhooks` → return all registered [`app_name`]s via response and +/// [`LSPS5ServiceEvent::WebhooksListed`]. +/// - `lsps5.remove_webhook` → delete a named webhook or return [`app_name_not_found`] +/// error, emitting [`LSPS5ServiceEvent::WebhookRemoved`]. +/// - 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 spec`]: https://github.com/lightning/blips/pull/55/files +/// +/// [`max_webhooks_per_client`]: super::service::LSPS5ServiceConfig::max_webhooks_per_client +/// [`LSPS5ServiceEvent::WebhookRegistered`]: super::event::LSPS5ServiceEvent::WebhookRegistered +/// [`LSPS5ServiceEvent::WebhooksListed`]: super::event::LSPS5ServiceEvent::WebhooksListed +/// [`LSPS5ServiceEvent::WebhookRemoved`]: super::event::LSPS5ServiceEvent::WebhookRemoved +/// [`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, @@ -107,37 +145,8 @@ impl LSPS5ServiceHandler where CM::Target: AChannelManager, { - /// Create a new LSPS5 service handler. - /// - /// # Arguments - /// * `event_queue` - Event queue for emitting events. - /// * `pending_messages` - Message queue for sending responses. - /// * `client_has_open_channel` - Function that checks if a client has an open channel. - /// * `config` - Configuration for the LSPS5 service. - #[cfg(feature = "time")] + /// Constructs a `LSPS5ServiceHandler`. pub(crate) fn new( - event_queue: Arc, pending_messages: Arc, channel_manager: CM, - config: LSPS5ServiceConfig, - ) -> Self { - let time_provider = Arc::new(DefaultTimeProvider); - Self::new_with_custom_time_provider( - event_queue, - pending_messages, - channel_manager, - config, - time_provider, - ) - } - - /// Create a new LSPS5 service handler with a custom time provider. - /// - /// # Arguments - /// * `event_queue` - Event queue for emitting events. - /// * `pending_messages` - Message queue for sending responses. - /// * `client_has_open_channel` - Function that checks if a client has an open channel. - /// * `config` - Configuration for the LSPS5 service. - /// * `time_provider` - Custom time provider. - pub(crate) fn new_with_custom_time_provider( event_queue: Arc, pending_messages: Arc, channel_manager: CM, config: LSPS5ServiceConfig, time_provider: Arc, ) -> Self { @@ -152,7 +161,7 @@ where } } - fn check_prune_stale_webhooks(&self) -> Result<(), LightningError> { + fn check_prune_stale_webhooks(&self) { let now = LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); let should_prune = { @@ -165,8 +174,6 @@ where if should_prune { self.prune_stale_webhooks(); } - - Ok(()) } fn handle_set_webhook( @@ -174,7 +181,7 @@ where params: SetWebhookRequest, ) -> Result<(), LightningError> { let event_queue_notifier = self.event_queue.notifier(); - self.check_prune_stale_webhooks()?; + self.check_prune_stale_webhooks(); let mut webhooks = self.webhooks.lock().unwrap(); @@ -189,21 +196,21 @@ where if !client_webhooks.contains_key(¶ms.app_name) && client_webhooks.len() >= self.config.max_webhooks_per_client as usize { - let message = format!( - "Maximum of {} webhooks allowed per client", - self.config.max_webhooks_per_client + let error = LSPS5ProtocolError::TooManyWebhooks( + self.config.max_webhooks_per_client.to_string(), ); - let error = LSPS5Error::TooManyWebhooks(message.clone()); - let msg = - LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(error)).into(); + let msg = LSPS5Message::Response( + request_id, + LSPS5Response::SetWebhookError(error.clone().into()), + ) + .into(); self.pending_messages.enqueue(&counterparty_node_id, msg); return Err(LightningError { - err: message, + err: error.message(), action: ErrorAction::IgnoreAndLog(Level::Info), }); } - // Add or replace the webhook let stored_webhook = StoredWebhook { _app_name: params.app_name.clone(), url: params.webhook.clone(), @@ -235,7 +242,7 @@ where counterparty_node_id, params.app_name.clone(), params.webhook.clone(), - )?; + ); } let msg = LSPS5Message::Response(request_id, LSPS5Response::SetWebhook(response)).into(); @@ -248,7 +255,7 @@ where _params: ListWebhooksRequest, ) -> Result<(), LightningError> { let event_queue_notifier = self.event_queue.notifier(); - self.check_prune_stale_webhooks()?; + self.check_prune_stale_webhooks(); let webhooks = self.webhooks.lock().unwrap(); @@ -278,8 +285,7 @@ where params: RemoveWebhookRequest, ) -> Result<(), LightningError> { let event_queue_notifier = self.event_queue.notifier(); - // Check if we need to prune stale webhooks - self.check_prune_stale_webhooks()?; + self.check_prune_stale_webhooks(); let mut webhooks = self.webhooks.lock().unwrap(); @@ -302,75 +308,97 @@ where } } - let error_message = format!("App name not found: {}", params.app_name); - let error = LSPS5Error::AppNameNotFound(error_message.clone()); - let msg = - LSPS5Message::Response(request_id, LSPS5Response::RemoveWebhookError(error)).into(); + 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, + err: error.message(), action: ErrorAction::IgnoreAndLog(Level::Info), }); } - /// Send a webhook_registered notification to a newly registered webhook. - /// - /// According to spec: - /// "Only the newly-registered webhook is notified. - /// Only the newly-registered webhook is contacted for this notification". fn send_webhook_registered_notification( &self, client_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl, - ) -> Result<(), LightningError> { + ) { let notification = WebhookNotification::webhook_registered(); self.send_notification(client_node_id, app_name.clone(), url.clone(), notification) } - /// Send an incoming_payment notification to all of a client's webhooks. - pub fn notify_payment_incoming(&self, client_id: PublicKey) -> Result<(), LightningError> { + /// 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) { let notification = WebhookNotification::payment_incoming(); self.broadcast_notification(client_id, notification) } - /// Send an expiry_soon notification to all of a client's webhooks. - pub fn notify_expiry_soon( - &self, client_id: PublicKey, timeout: u32, - ) -> Result<(), LightningError> { + /// 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) { let notification = WebhookNotification::expiry_soon(timeout); self.broadcast_notification(client_id, notification) } - /// Send a liquidity_management_request notification to all of a client's webhooks. - pub fn notify_liquidity_management_request( - &self, client_id: PublicKey, - ) -> Result<(), LightningError> { + /// 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) { let notification = WebhookNotification::liquidity_management_request(); self.broadcast_notification(client_id, notification) } - /// Send an onion_message_incoming notification to all of a client's webhooks. - pub fn notify_onion_message_incoming( - &self, client_id: PublicKey, - ) -> Result<(), LightningError> { + /// 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::LSPS5LiquidityManagementRequest`] + /// 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) { let notification = WebhookNotification::onion_message_incoming(); self.broadcast_notification(client_id, notification) } - /// Broadcast a notification to all registered webhooks for a client. - /// - /// According to spec: - /// "The LSP SHOULD contact all registered webhook URIs, if: - /// * The client has registered at least one via `lsps5.set_webhook`. - /// * *and* the client currently does not have a BOLT8 tunnel with the LSP. - /// * *and* one of the specified events has occurred." - fn broadcast_notification( - &self, client_id: PublicKey, notification: WebhookNotification, - ) -> Result<(), LightningError> { + fn broadcast_notification(&self, client_id: PublicKey, notification: WebhookNotification) { 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(()), + _ => return, }; let now = @@ -391,29 +419,20 @@ where app_name.clone(), webhook.url.clone(), notification.clone(), - )?; + ); } } - - Ok(()) } - /// Send a notification to a webhook URL. fn send_notification( &self, counterparty_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl, notification: WebhookNotification, - ) -> Result<(), LightningError> { + ) { let event_queue_notifier = self.event_queue.notifier(); let timestamp = LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); - let notification_json = - serde_json::to_string(¬ification).map_err(|e| LightningError { - err: format!("Failed to serialize notification: {}", e), - action: ErrorAction::IgnoreAndLog(Level::Error), - })?; - - let signature_hex = self.sign_notification(¬ification_json, ×tamp)?; + let signature_hex = self.sign_notification(¬ification, ×tamp); let headers = vec![ ("Content-Type".to_string(), "application/json".to_string()), @@ -421,7 +440,7 @@ where ("x-lsps5-signature".to_string(), signature_hex.clone()), ]; - event_queue_notifier.enqueue(LSPS5ServiceEvent::SendWebhookNotifications { + event_queue_notifier.enqueue(LSPS5ServiceEvent::SendWebhookNotification { counterparty_node_id, app_name, url, @@ -430,41 +449,18 @@ where signature: signature_hex, headers, }); - - Ok(()) } - /// Sign a webhook notification with an LSP's signing key. - /// - /// This function takes a notification body and timestamp and returns a signature - /// in the format required by the LSPS5 specification. - /// - /// # Arguments - /// - /// * `body` - The serialized notification JSON - /// * `timestamp` - The ISO8601 timestamp string - /// * `signing_key` - The LSP private key used for signing - /// - /// # Returns - /// - /// * The zbase32 encoded signature as specified in LSPS0, or an error if signing fails - pub fn sign_notification( - &self, body: &str, timestamp: &LSPSDateTime, - ) -> Result { - // Create the message to sign - // According to spec: - // The message to be signed is: "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + fn sign_notification(&self, body: &WebhookNotification, timestamp: &LSPSDateTime) -> String { let message = format!( - "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {:?}", timestamp.to_rfc3339(), body ); - Ok(message_signing::sign(message.as_bytes(), &self.config.signing_key)) + message_signing::sign(message.as_bytes(), &self.config.signing_key) } - /// Clean up webhooks for clients with no channels that haven't been used in a while. - /// According to spec: "MUST remember all webhooks for at least 7 days after the last channel is closed". fn prune_stale_webhooks(&self) { let now = LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); @@ -526,11 +522,491 @@ where false, "Service handler received LSPS5 response message. This should never happen." ); - Err(LightningError { - err: format!("Service handler received LSPS5 response message from node {:?}. This should never happen.", counterparty_node_id), - action: ErrorAction::IgnoreAndLog(Level::Info) - }) + 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) }) }, } } } + +#[cfg(test)] +mod tests { + #![cfg(all(test, feature = "time"))] + use crate::tests::lsps5_integration_tests::lsps5_test_setup; + + use super::*; + use crate::lsps0::ser::LSPSDateTime; + use core::str::FromStr; + use core::time::Duration; + use std::sync::Arc; + + #[test] + fn test_basic_sign_and_verify() { + let (client_handler, service_handler, service_node_id, _, _, _) = lsps5_test_setup(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + let notification = WebhookNotification::webhook_registered(); + // let notification_json = serde_json::to_string(¬ification).unwrap(); + + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let signature = service_handler.sign_notification(¬ification, ×tamp); + let notification_json = serde_json::to_string(¬ification).unwrap(); + let result = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + &signature, + ¬ification_json, + ); + + assert!(result.is_ok()); + assert!(result.unwrap().method == WebhookNotificationMethod::LSPS5WebhookRegistered); + } + + #[test] + fn test_parse_webhook_notification() { + let (client_handler, service_handler, service_node_id, _, _, _) = lsps5_test_setup(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + let notification = WebhookNotification::payment_incoming(); + + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let signature = service_handler.sign_notification(¬ification, ×tamp); + let notification_json = serde_json::to_string(¬ification).unwrap(); + + let parsed_notification = client_handler + .parse_webhook_notification(service_node_id, ×tamp, &signature, ¬ification_json) + .unwrap(); + + assert_eq!(parsed_notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming); + } + + #[test] + fn test_invalid_signature() { + let (client_handler, _, service_node_id, _, _, _) = lsps5_test_setup(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + let notification = WebhookNotification::webhook_registered(); + + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let invalid_signature = "xdtk1zf63sfn81r6qteymy73mb1b7dspj5kwx46uxwd6c3pu7y3bto"; + let notification_json = serde_json::to_string(¬ification).unwrap(); + let result = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + invalid_signature, + ¬ification_json, + ); + + assert!(result.is_err()); + } + + #[test] + fn test_all_notification_types() { + let (client_handler, service_handler, service_node_id, _, _, _) = lsps5_test_setup(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + let notifications = vec![ + WebhookNotification::webhook_registered(), + WebhookNotification::payment_incoming(), + WebhookNotification::expiry_soon(144), + WebhookNotification::liquidity_management_request(), + WebhookNotification::onion_message_incoming(), + ]; + + for notification in notifications { + // let notification_json = serde_json::to_string(¬ification).unwrap(); + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let signature = service_handler.sign_notification(¬ification, ×tamp); + let notification_json = serde_json::to_string(¬ification).unwrap(); + let result = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + &signature, + ¬ification_json, + ); + + assert!(result.is_ok()); + assert!(result.unwrap().method == notification.method); + } + } + + #[test] + fn test_timestamp_out_of_range() { + let (client_handler, service_handler, service_node_id, _, _, _) = lsps5_test_setup(None); + + let notification = WebhookNotification::webhook_registered(); + // let notification_json = serde_json::to_string(¬ification).unwrap(); + + let too_old_timestamp = LSPSDateTime::from_str("2020-01-01T00:00:00.000Z").unwrap(); + + let signature = service_handler.sign_notification(¬ification, &too_old_timestamp); + let notification_json = serde_json::to_string(¬ification).unwrap(); + let result = client_handler.parse_webhook_notification( + service_node_id, + &too_old_timestamp, + &signature, + ¬ification_json, + ); + + assert!(result.is_err()); + } + + #[test] + fn test_exact_bytes_from_spec_example() { + let timestamp = "2023-05-04T10:52:58.395Z"; + + let notification_json = + r#"{"jsonrpc":"2.0","method":"lsps5.webhook_registered","params":{}}"#; + + let message = format!( + "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + timestamp, notification_json + ); + + let bytes = message.as_bytes(); + + assert_eq!(bytes[0], 0x4c); // 'L' + assert_eq!(bytes[1], 0x53); // 'S' + assert_eq!(bytes[2], 0x50); // 'P' + assert_eq!(bytes[3], 0x53); // 'S' + assert_eq!(bytes[4], 0x35); // '5' + assert_eq!(bytes[5], 0x3a); // ':' + + let expected_prefix = "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At"; + assert!(message.starts_with(expected_prefix)); + + assert!(message.contains(timestamp)); + assert!(message.contains(notification_json)); + } + + #[test] + fn test_expiry_soon_notification_with_timeout() { + let (client_handler, service_handler, service_node_id, _, _, _) = lsps5_test_setup(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + let timeout_value = 720000; + let notification = WebhookNotification::expiry_soon(timeout_value); + // let notification_json = serde_json::to_string(¬ification).unwrap(); + + // assert!(notification_json.contains(&format!("\"timeout\":{}", timeout_value))); + + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let signature = service_handler.sign_notification(¬ification, ×tamp); + let notification_json = serde_json::to_string(¬ification).unwrap(); + let result = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + &signature, + ¬ification_json, + ); + + assert!(result.is_ok()); + assert!( + result.unwrap().method + == WebhookNotificationMethod::LSPS5ExpirySoon { timeout: timeout_value } + ); + } + + #[test] + fn test_spec_example_header_format() { + let (_, service_handler, _, _, _, _) = lsps5_test_setup(None); + + let notification = WebhookNotification::payment_incoming(); + + let timestamp = LSPSDateTime::from_str("2023-05-04T10:14:23.853Z").unwrap(); + + let signature = service_handler.sign_notification(¬ification, ×tamp); + + let headers = vec![ + ("Content-Type".to_string(), "application/json".to_string()), + ("x-lsps5-timestamp".to_string(), timestamp.to_string()), + ("x-lsps5-signature".to_string(), signature.clone()), + ]; + + let timestamp_header = + headers.iter().find(|(name, _)| name == "x-lsps5-timestamp").unwrap(); + let _ = headers.iter().find(|(name, _)| name == "x-lsps5-signature").unwrap(); + + assert_eq!(timestamp_header.1, timestamp.to_rfc3339()); + + for c in signature.chars() { + assert!( + (c >= 'a' && c <= 'z') || (c >= '1' && c <= '9') || c == 'y' || c == 'z', + "Invalid character in zbase32 signature: {}", + c + ); + } + + assert!(headers.iter().any(|(name, _)| name == "x-lsps5-timestamp")); + assert!(headers.iter().any(|(name, _)| name == "x-lsps5-signature")); + } + + #[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")); + } + } + } + + #[test] + fn test_tampered_notification_details() { + let (client_handler, service_handler, service_node_id, _, _, _) = lsps5_test_setup(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + + let notification = WebhookNotification::expiry_soon(700000); + // let notification_json = serde_json::to_string(¬ification).unwrap(); + + let timestamp = + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + let signature = service_handler.sign_notification(¬ification, ×tamp); + let notification_json = serde_json::to_string(¬ification).unwrap(); + let original_result = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + &signature, + ¬ification_json, + ); + assert!(original_result.is_ok(), "Original notification should be valid"); + assert!( + original_result.unwrap().method + == WebhookNotificationMethod::LSPS5ExpirySoon { timeout: 700000 } + ); + + 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 notification_json = serde_json::to_string(&tampered_notification).unwrap(); + let tampered_result = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + &signature, + ¬ification_json, + ); + assert!(tampered_result.is_err(), "Tampered notification should fail verification"); + + let mut json_value: serde_json::Value = serde_json::from_str(¬ification_json).unwrap(); + json_value["method"] = serde_json::json!("lsps5.payment_incoming"); + let tampered_method_json = json_value.to_string(); + + let tampered_method_notification: WebhookNotification = + serde_json::from_str(&tampered_method_json).unwrap(); + let notification_json = serde_json::to_string(&tampered_method_notification).unwrap(); + let tampered_method_result = client_handler.parse_webhook_notification( + service_node_id, + ×tamp, + &signature, + ¬ification_json, + ); + assert!( + tampered_method_result.is_err(), + "Notification with tampered method should fail verification" + ); + } + + #[test] + fn test_timestamp_window_validation() { + let (client_handler, service_handler, service_node_id, _, _, _) = lsps5_test_setup(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + + let notification = WebhookNotification::onion_message_incoming(); + // let notification_json = serde_json::to_string(¬ification).unwrap(); + + let current_time = time_provider.duration_since_epoch(); + let valid_timestamp = LSPSDateTime::new_from_duration_since_epoch(current_time); + + let signature: String = service_handler.sign_notification(¬ification, &valid_timestamp); + let notification_json = serde_json::to_string(¬ification).unwrap(); + let valid_result = client_handler.parse_webhook_notification( + service_node_id, + &valid_timestamp, + &signature, + ¬ification_json, + ); + assert!(valid_result.is_ok()); + assert!( + valid_result.unwrap().method == WebhookNotificationMethod::LSPS5OnionMessageIncoming + ); + + let past_timestamp = LSPSDateTime::new_from_duration_since_epoch( + current_time.checked_sub(Duration::from_secs(20 * 60)).unwrap(), + ); + + let past_result = client_handler.parse_webhook_notification( + service_node_id, + &past_timestamp, + &signature, + ¬ification_json, + ); + assert!(past_result.is_err(), "Notification with past timestamp should be rejected"); + + let future_timestamp = LSPSDateTime::new_from_duration_since_epoch( + current_time.checked_add(Duration::from_secs(15 * 60)).unwrap(), + ); + + let future_result = client_handler.parse_webhook_notification( + service_node_id, + &future_timestamp, + &signature, + ¬ification_json, + ); + assert!(future_result.is_err(), "Notification with future timestamp should be rejected"); + } + + #[test] + fn test_unknown_method_and_malformed_notifications() { + let (client_handler, service_handler, service_node_id, _, _, _) = lsps5_test_setup(None); + let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); + // let timestamp = + // LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); + + // let create_notification = |method: &str, params: serde_json::Value| -> serde_json::Value { + // serde_json::json!({ + // "jsonrpc": "2.0", + // "method": method, + // "params": params + // }) + // }; + + // let unknown_notification = + // create_notification("lsps5.unknown_method", serde_json::json!({"some": "data"})); + // let unknown_json = unknown_notification.to_string(); + // let unknown_signature = + // service_handler.sign_notification(&unknown_notification, ×tamp); + + // let unknown_result = client_handler.parse_webhook_notification( + // service_node_id, + // ×tamp, + // &unknown_signature, + // &unknown_json, + // ); + // assert!( + // unknown_result.is_err(), + // "Unknown method should be rejected even with valid signature" + // ); + + // let invalid_jsonrpc = serde_json::json!({ + // "method": "lsps5.payment_incoming", + // "params": {} + // }) + // .to_string(); + // let invalid_jsonrpc_signature = + // service_handler.sign_notification(&invalid_jsonrpc, ×tamp); + + // let invalid_jsonrpc_result = client_handler.parse_webhook_notification( + // service_node_id, + // ×tamp, + // &invalid_jsonrpc_signature, + // &invalid_jsonrpc, + // ); + // assert!(invalid_jsonrpc_result.is_err(), "Missing jsonrpc field should be rejected"); + + // let missing_params = serde_json::json!({ + // "jsonrpc": "2.0", + // "method": "lsps5.payment_incoming" + // }) + // .to_string(); + // let missing_params_signature = + // service_handler.sign_notification(&missing_params, ×tamp); + + // let missing_params_result = client_handler.parse_webhook_notification( + // service_node_id, + // ×tamp, + // &missing_params_signature, + // &missing_params, + // ); + // assert!(missing_params_result.is_err(), "Missing params field should be rejected"); + + // let invalid_json = "{not valid json"; + // let invalid_json_signature_result = + // service_handler.sign_notification(invalid_json, ×tamp); + + // let invalid_json_result = client_handler.parse_webhook_notification( + // service_node_id, + // ×tamp, + // &invalid_json_signature_result, + // invalid_json, + // ); + // assert!(invalid_json_result.is_err(), "Invalid JSON should be rejected"); + + let notification = WebhookNotification::payment_incoming(); + // let notification_json = serde_json::to_string(¬ification).unwrap(); + + let edge_past_timestamp = LSPSDateTime::new_from_duration_since_epoch( + time_provider.duration_since_epoch().checked_sub(Duration::from_secs(9 * 60)).unwrap(), + ); + let edge_future_timestamp = LSPSDateTime::new_from_duration_since_epoch( + time_provider.duration_since_epoch().checked_add(Duration::from_secs(9 * 60)).unwrap(), + ); + + let past_edge_signature = + service_handler.sign_notification(¬ification, &edge_past_timestamp); + let future_edge_signature = + service_handler.sign_notification(¬ification, &edge_future_timestamp); + let notification_json = serde_json::to_string(¬ification).unwrap(); + let past_edge_result = client_handler.parse_webhook_notification( + service_node_id, + &edge_past_timestamp, + &past_edge_signature, + ¬ification_json, + ); + let future_edge_result = client_handler.parse_webhook_notification( + service_node_id, + &edge_future_timestamp, + &future_edge_signature, + ¬ification_json, + ); + + assert!(past_edge_result.is_ok(), "Timestamp just within past range should be accepted"); + assert!( + future_edge_result.is_ok(), + "Timestamp just within future range should be accepted" + ); + } +} diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 364c8a077d6..63f933d786d 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -12,6 +12,8 @@ use crate::lsps0::ser::{ 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; @@ -169,10 +171,34 @@ where /// /// Sets up the required protocol message handlers based on the given /// [`LiquidityClientConfig`] and [`LiquidityServiceConfig`]. + #[cfg(feature = "time")] pub fn new( entropy_source: ES, channel_manager: CM, chain_source: Option, chain_params: Option, service_config: Option, - client_config: Option, time_provider: Option>, + client_config: Option, + ) -> Self { + let time_provider = Arc::new(DefaultTimeProvider); + Self::new_with_custom_time_provider( + entropy_source, + channel_manager, + chain_source, + chain_params, + service_config, + client_config, + time_provider, + ) + } + + /// 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_with_custom_time_provider( + entropy_source: ES, channel_manager: CM, chain_source: Option, + chain_params: Option, service_config: Option, + client_config: Option, time_provider: Arc, ) -> Self where { let pending_messages = Arc::new(MessageQueue::new()); @@ -182,7 +208,7 @@ where { let mut supported_protocols = Vec::new(); let lsps2_client_handler = client_config.as_ref().and_then(|config| { - config.lsps2_client_config.as_ref().map(|config| { + config.lsps2_client_config.map(|config| { LSPS2ClientHandler::new( entropy_source.clone(), Arc::clone(&pending_messages), @@ -209,29 +235,13 @@ where { let lsps5_client_handler = client_config.as_ref().and_then(|config| { config.lsps5_client_config.as_ref().map(|config| { - if time_provider.is_some() { - LSPS5ClientHandler::new_with_custom_time_provider( - entropy_source.clone(), - Arc::clone(&pending_messages), - Arc::clone(&pending_events), - config.clone(), - time_provider.clone().unwrap(), - ) - } else { - #[cfg(feature = "time")] - { - LSPS5ClientHandler::new( - entropy_source.clone(), - Arc::clone(&pending_messages), - Arc::clone(&pending_events), - config.clone(), - ) - } - #[cfg(not(feature = "time"))] - { - panic!("A custom time_provider must be provided if the 'time' feature is not enabled."); - } - } + LSPS5ClientHandler::new( + entropy_source.clone(), + Arc::clone(&pending_messages), + Arc::clone(&pending_events), + config.clone(), + time_provider.clone(), + ) }) }); @@ -243,29 +253,13 @@ where { supported_protocols.push(number); } - if time_provider.is_some() { - return LSPS5ServiceHandler::new_with_custom_time_provider( - Arc::clone(&pending_events), - Arc::clone(&pending_messages), - channel_manager.clone(), - config.clone(), - time_provider.unwrap(), - ); - } else { - #[cfg(feature = "time")] - { - return LSPS5ServiceHandler::new( - Arc::clone(&pending_events), - Arc::clone(&pending_messages), - channel_manager.clone(), - config.clone(), - ); - } - #[cfg(not(feature = "time"))] - { - panic!("A custom time_provider must be provided if the 'time' feature is not enabled."); - } - } + return LSPS5ServiceHandler::new( + Arc::clone(&pending_events), + Arc::clone(&pending_messages), + channel_manager.clone(), + config.clone(), + time_provider, + ); }) }); diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/src/tests/common/mod.rs similarity index 87% rename from lightning-liquidity/tests/common/mod.rs rename to lightning-liquidity/src/tests/common/mod.rs index 5912a13454b..47e3f536100 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/src/tests/common/mod.rs @@ -1,17 +1,20 @@ -#![cfg(test)] +#![cfg(all(test, feature = "time"))] // TODO: remove these flags and unused code once we know what we'll need. #![allow(dead_code)] #![allow(unused_imports)] #![allow(unused_macros)] -use bitcoin::secp256k1::SecretKey; -use lightning::chain::Filter; -use lightning::sign::EntropySource; +use crate::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler}; +use crate::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler, TimeProvider}; +use crate::{LiquidityClientConfig, LiquidityManager, LiquidityServiceConfig}; use bitcoin::blockdata::constants::{genesis_block, ChainHash}; use bitcoin::blockdata::transaction::Transaction; +use bitcoin::secp256k1::SecretKey; use bitcoin::Network; + use lightning::chain::channelmonitor::ANTI_REORG_DELAY; +use lightning::chain::Filter; use lightning::chain::{chainmonitor, BestBlock, Confirm}; use lightning::ln::channelmanager; use lightning::ln::channelmanager::ChainParameters; @@ -20,11 +23,11 @@ use lightning::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init}; use lightning::ln::peer_handler::{ IgnoringMessageHandler, MessageHandler, PeerManager, SocketDescriptor, }; - use lightning::onion_message::messenger::DefaultMessageRouter; use lightning::routing::gossip::{NetworkGraph, P2PGossipSync}; use lightning::routing::router::{CandidateRouteHop, DefaultRouter, Path}; use lightning::routing::scoring::{ChannelUsage, ScoreLookUp, ScoreUpdate}; +use lightning::sign::EntropySource; use lightning::sign::{InMemorySigner, KeysManager}; use lightning::util::config::UserConfig; use lightning::util::persist::{ @@ -35,9 +38,7 @@ use lightning::util::persist::{ SCORER_PERSISTENCE_SECONDARY_NAMESPACE, }; use lightning::util::test_utils; -use lightning_liquidity::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler}; -use lightning_liquidity::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler, TimeProvider}; -use lightning_liquidity::{LiquidityClientConfig, LiquidityManager, LiquidityServiceConfig}; + use lightning_persister::fs_store::FilesystemStore; use std::collections::{HashMap, VecDeque}; @@ -70,7 +71,7 @@ type LockingWrapper = lightning::routing::scoring::MultiThreadedLockableScore #[cfg(not(c_bindings))] type LockingWrapper = std::sync::Mutex; -type ChannelManager = channelmanager::ChannelManager< +pub(crate) type ChannelManager = channelmanager::ChannelManager< Arc, Arc, Arc, @@ -454,16 +455,27 @@ pub(crate) fn create_liquidity_node( Some(chain_source.clone()), logger.clone(), )); + let liquidity_manager = Arc::new(if let Some(tp) = time_provider.clone() { + LiquidityManager::new_with_custom_time_provider( + keys_manager.clone(), + channel_manager.clone(), + None::>, + Some(chain_params.clone()), + service_config, + client_config, + tp, + ) + } else { + LiquidityManager::new( + keys_manager.clone(), + channel_manager.clone(), + None, + Some(chain_params), + service_config, + client_config, + ) + }); - let liquidity_manager = Arc::new(LiquidityManager::new( - Arc::clone(&keys_manager), - Arc::clone(&channel_manager), - None::>, - Some(chain_params), - service_config, - client_config, - time_provider, - )); let msg_handler = MessageHandler { chan_handler: Arc::new(test_utils::TestChannelMessageHandler::new( ChainHash::using_genesis_block(Network::Testnet), @@ -684,61 +696,3 @@ fn advance_chain(node: &mut Node, num_blocks: u32) { } } } - -/// 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); - -pub(crate) fn get_client_and_service( - time_provider: Option>, -) -> ( - &'static LSPS5ClientHandler>, - &'static LSPS5ServiceHandler>, - bitcoin::secp256k1::PublicKey, - bitcoin::secp256k1::PublicKey, - &'static Node, - &'static Node, -) { - let signing_key = SecretKey::from_slice(&[42; 32]).unwrap(); - let mut lsps5_service_config = LSPS5ServiceConfig { - max_webhooks_per_client: DEFAULT_MAX_WEBHOOKS_PER_CLIENT, - signing_key, - notification_cooldown_hours: DEFAULT_NOTIFICATION_COOLDOWN_HOURS, - }; - lsps5_service_config.signing_key = signing_key; - 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 (service_node, client_node) = create_service_and_client_nodes( - "webhook_registration_flow", - service_config, - client_config, - time_provider, - ); - - // Leak the nodes to extend their lifetime to 'static since this is test code - let service_node = Box::leak(Box::new(service_node)); - let client_node = Box::leak(Box::new(client_node)); - - let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); - let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); - - let secp = bitcoin::secp256k1::Secp256k1::new(); - let service_node_id = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &signing_key); - let client_node_id = client_node.channel_manager.get_our_node_id(); - - (client_handler, service_handler, service_node_id, client_node_id, service_node, client_node) -} diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/src/tests/lsps2_integration_tests.rs similarity index 92% rename from lightning-liquidity/tests/lsps2_integration_tests.rs rename to lightning-liquidity/src/tests/lsps2_integration_tests.rs index a40129577d7..0e0a2014880 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/src/tests/lsps2_integration_tests.rs @@ -1,17 +1,13 @@ #![cfg(all(test, feature = "std"))] -mod common; - -use common::{create_service_and_client_nodes, get_lsps_message, Node}; - -use lightning_liquidity::events::LiquidityEvent; -use lightning_liquidity::lsps0::ser::LSPSDateTime; -use lightning_liquidity::lsps2::client::LSPS2ClientConfig; -use lightning_liquidity::lsps2::event::{LSPS2ClientEvent, 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::{LiquidityClientConfig, LiquidityServiceConfig}; +use crate::events::LiquidityEvent; +use crate::lsps0::ser::LSPSDateTime; +use crate::lsps2::client::LSPS2ClientConfig; +use crate::lsps2::event::{LSPS2ClientEvent, LSPS2ServiceEvent}; +use crate::lsps2::msgs::LSPS2RawOpeningFeeParams; +use crate::lsps2::service::LSPS2ServiceConfig; +use crate::lsps2::utils::is_valid_opening_fee_params; +use crate::{LiquidityClientConfig, LiquidityServiceConfig}; use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning::ln::peer_handler::CustomMessageHandler; @@ -28,6 +24,8 @@ use bitcoin::Network; use std::str::FromStr; use std::time::Duration; +use super::common::{create_service_and_client_nodes, get_lsps_message, Node}; + fn create_jit_invoice( node: &Node, service_node_id: PublicKey, intercept_scid: u64, cltv_expiry_delta: u32, payment_size_msat: Option, description: &str, expiry_secs: u32, diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/src/tests/lsps5_integration_tests.rs similarity index 84% rename from lightning-liquidity/tests/lsps5_integration_tests.rs rename to lightning-liquidity/src/tests/lsps5_integration_tests.rs index 36cee30c2ee..7e57e25e0bb 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/src/tests/lsps5_integration_tests.rs @@ -1,23 +1,79 @@ #![cfg(all(test, feature = "time"))] -mod common; - -use common::{get_client_and_service, get_lsps_message, DEFAULT_MAX_WEBHOOKS_PER_CLIENT}; -use lightning::ln::msgs::LightningError; +use super::common::{create_service_and_client_nodes, get_lsps_message, ChannelManager, Node}; +use crate::events::LiquidityEvent; +use crate::lsps5::client::{LSPS5ClientConfig, LSPS5ClientHandler}; +use crate::lsps5::event::{LSPS5ClientEvent, LSPS5ServiceEvent}; +use crate::lsps5::msgs::{ + LSPS5AppName, LSPS5ClientError, LSPS5ProtocolError, LSPS5WebhookUrl, WebhookNotificationMethod, +}; +use crate::lsps5::service::{LSPS5ServiceConfig, LSPS5ServiceHandler, TimeProvider}; +use crate::lsps5::service::{MIN_WEBHOOK_RETENTION_DAYS, PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS}; +use crate::{LiquidityClientConfig, LiquidityServiceConfig}; +use bitcoin::secp256k1::SecretKey; use lightning::ln::peer_handler::CustomMessageHandler; +use lightning::sign::KeysManager; use lightning::util::hash_tables::HashSet; -use lightning_liquidity::events::LiquidityEvent; -use lightning_liquidity::lsps5::event::{LSPS5ClientEvent, LSPS5ServiceEvent}; -use lightning_liquidity::lsps5::msgs::{ - LSPS5AppName, LSPS5Error, LSPS5WebhookUrl, WebhookNotification, WebhookNotificationMethod, -}; -use lightning_liquidity::lsps5::service::TimeProvider; -use lightning_liquidity::lsps5::service::{ - MIN_WEBHOOK_RETENTION_DAYS, PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS, -}; use std::sync::{Arc, RwLock}; use std::time::Duration; +/// 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); + +pub(crate) fn lsps5_test_setup( + time_provider: Option>, +) -> ( + &'static LSPS5ClientHandler>, + &'static LSPS5ServiceHandler>, + bitcoin::secp256k1::PublicKey, + bitcoin::secp256k1::PublicKey, + &'static Node, + &'static Node, +) { + let signing_key = SecretKey::from_slice(&[42; 32]).unwrap(); + let mut lsps5_service_config = LSPS5ServiceConfig { + max_webhooks_per_client: DEFAULT_MAX_WEBHOOKS_PER_CLIENT, + signing_key, + notification_cooldown_hours: DEFAULT_NOTIFICATION_COOLDOWN_HOURS, + }; + lsps5_service_config.signing_key = signing_key; + 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 (service_node, client_node) = create_service_and_client_nodes( + "webhook_registration_flow", + service_config, + client_config, + time_provider, + ); + + let service_node = Box::leak(Box::new(service_node)); + let client_node = Box::leak(Box::new(client_node)); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); + + let secp = bitcoin::secp256k1::Secp256k1::new(); + let service_node_id = bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &signing_key); + let client_node_id = client_node.channel_manager.get_our_node_id(); + + (client_handler, service_handler, service_node_id, client_node_id, service_node, client_node) +} + struct MockTimeProvider { current_time: RwLock, } @@ -42,7 +98,7 @@ impl TimeProvider for MockTimeProvider { #[test] fn webhook_registration_flow() { let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = - get_client_and_service(None); + lsps5_test_setup(None); let raw_app_name = "My LSPS-Compliant Lightning Client"; let app_name = LSPS5AppName::from_string(raw_app_name.to_string()).unwrap(); @@ -80,7 +136,7 @@ fn webhook_registration_flow() { let webhook_notification_event = service_node.liquidity_manager.next_event().unwrap(); match webhook_notification_event { - LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { counterparty_node_id, app_name: an, url, @@ -101,7 +157,7 @@ fn webhook_registration_flow() { ); assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered); }, - _ => panic!("Expected SendWebhookNotifications event"), + _ => panic!("Expected SendWebhookNotification event"), } let set_webhook_response = get_lsps_message!(service_node, client_node_id); @@ -132,9 +188,7 @@ fn webhook_registration_flow() { _ => panic!("Unexpected event"), } - let list_request_id = client_handler - .list_webhooks(service_node_id) - .expect("Failed to send list_webhooks request"); + 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 @@ -216,12 +270,12 @@ fn webhook_registration_flow() { let webhook_notification_event = service_node.liquidity_manager.next_event().unwrap(); match webhook_notification_event { - LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { url, .. }) => { assert_eq!(url, updated_webhook_url); }, - _ => panic!("Expected SendWebhookNotifications event"), + _ => panic!("Expected SendWebhookNotification event"), } let set_webhook_update_response = get_lsps_message!(service_node, client_node_id); @@ -295,7 +349,7 @@ fn webhook_registration_flow() { #[test] fn webhook_error_handling_test() { let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = - get_client_and_service(None); + lsps5_test_setup(None); // TEST 1: URL too long error let app_name = "Error Test App"; @@ -305,48 +359,32 @@ fn webhook_error_handling_test() { 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 err_message = result.unwrap_err().err; - assert!( - err_message.contains("exceeds maximum length"), - "Error message should mention length: {}", - err_message - ); + 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 err_message = result.unwrap_err().err; - assert!( - err_message.contains("Error parsing URL"), - "Error message should mention parse failure: {}", - err_message - ); + let error = result.unwrap_err(); + assert_eq!(error, LSPS5ProtocolError::UrlParse("Error parsing URL".to_string()).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 err_message = result.unwrap_err().err; - assert!( - err_message.contains("Unsupported protocol"), - "Error message should mention protocol: {}", - err_message - ); + 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 err_message = result.unwrap_err().err; - assert!( - err_message.contains("exceeds maximum length"), - "Error message should mention length: {}", - err_message - ); + 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"; @@ -388,11 +426,9 @@ fn webhook_error_handling_test() { app_name, .. }) => { - let error_to_check = LSPS5Error::TooManyWebhooks(format!( - "Maximum of {} webhooks allowed per client", - DEFAULT_MAX_WEBHOOKS_PER_CLIENT - )); - assert_eq!(error, error_to_check); + let error_to_check = + LSPS5ProtocolError::TooManyWebhooks(DEFAULT_MAX_WEBHOOKS_PER_CLIENT.to_string()); + assert_eq!(error, error_to_check.into()); assert_eq!(app_name, one_too_many); }, _ => panic!("Expected WebhookRegistrationFailed event, got {:?}", event), @@ -420,9 +456,7 @@ fn webhook_error_handling_test() { app_name, .. }) => { - let error_to_check = - LSPS5Error::AppNameNotFound(format!("App name not found: {}", raw_nonexistent_app)); - assert_eq!(error, error_to_check); + assert_eq!(error, LSPS5ProtocolError::AppNameNotFound.into()); assert_eq!(app_name, nonexistent_app); }, _ => panic!("Expected WebhookRemovalFailed event, got {:?}", event), @@ -450,7 +484,7 @@ fn webhook_error_handling_test() { #[test] fn webhook_notification_delivery_test() { let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = - get_client_and_service(None); + lsps5_test_setup(None); let app_name = "Webhook Test App"; let webhook_url = "https://www.example.org/push?token=test123"; @@ -469,7 +503,7 @@ fn webhook_notification_delivery_test() { let notification_event = service_node.liquidity_manager.next_event().unwrap(); let (timestamp_value, signature_value, notification_json) = match notification_event { - LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { url, timestamp, signature, @@ -481,7 +515,7 @@ fn webhook_notification_delivery_test() { assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered); (timestamp, signature, serde_json::to_string(¬ification).unwrap()) }, - _ => panic!("Expected SendWebhookNotifications event"), + _ => panic!("Expected SendWebhookNotification event"), }; let set_webhook_response = get_lsps_message!(service_node, client_node_id); @@ -503,16 +537,15 @@ fn webhook_notification_delivery_test() { "Client should be able to parse and validate the webhook_registered notification" ); - assert!(service_node + service_node .liquidity_manager .lsps5_service_handler() .unwrap() - .notify_payment_incoming(client_node_id) - .is_ok()); + .notify_payment_incoming(client_node_id); let payment_notification_event = service_node.liquidity_manager.next_event().unwrap(); let (payment_timestamp, payment_signature, payment_json) = match payment_notification_event { - LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { url, timestamp, signature, @@ -523,7 +556,7 @@ fn webhook_notification_delivery_test() { assert_eq!(notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming); (timestamp, signature, serde_json::to_string(¬ification).unwrap()) }, - _ => panic!("Expected SendWebhookNotifications event for payment_incoming"), + _ => panic!("Expected SendWebhookNotification event for payment_incoming"), }; let result = client_handler.parse_webhook_notification( @@ -544,12 +577,11 @@ fn webhook_notification_delivery_test() { "Parsed notification should be payment_incoming" ); - assert!(service_node + service_node .liquidity_manager .lsps5_service_handler() .unwrap() - .notify_payment_incoming(client_node_id) - .is_ok()); + .notify_payment_incoming(client_node_id); assert!( service_node.liquidity_manager.next_event().is_none(), @@ -557,16 +589,15 @@ fn webhook_notification_delivery_test() { ); let timeout_block = 700000; // Some future block height - assert!(service_node + service_node .liquidity_manager .lsps5_service_handler() .unwrap() - .notify_expiry_soon(client_node_id, timeout_block) - .is_ok()); + .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::SendWebhookNotifications { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { notification, .. }) => { @@ -575,14 +606,14 @@ fn webhook_notification_delivery_test() { WebhookNotificationMethod::LSPS5ExpirySoon { timeout } if timeout == timeout_block )); }, - _ => panic!("Expected SendWebhookNotifications event for expiry_soon"), + _ => panic!("Expected SendWebhookNotification event for expiry_soon"), }; } #[test] fn multiple_webhooks_notification_test() { let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = - get_client_and_service(None); + lsps5_test_setup(None); let webhooks = vec![ ("Mobile App", "https://www.example.org/mobile-push?token=abc123"), @@ -603,7 +634,7 @@ fn multiple_webhooks_notification_test() { // Consume WebhookRegistered event let _ = service_node.liquidity_manager.next_event().unwrap(); - // Consume SendWebhookNotifications event for webhook_registered + // 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); @@ -615,19 +646,18 @@ fn multiple_webhooks_notification_test() { let _ = client_node.liquidity_manager.next_event().unwrap(); } - assert!(service_node + service_node .liquidity_manager .lsps5_service_handler() .unwrap() - .notify_liquidity_management_request(client_node_id) - .is_ok()); + .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::SendWebhookNotifications { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { url, notification, .. @@ -639,7 +669,7 @@ fn multiple_webhooks_notification_test() { WebhookNotificationMethod::LSPS5LiquidityManagementRequest ); }, - _ => panic!("Expected SendWebhookNotifications event"), + _ => panic!("Expected SendWebhookNotification event"), } } @@ -667,7 +697,7 @@ fn multiple_webhooks_notification_test() { let notification_event = service_node.liquidity_manager.next_event().unwrap(); match notification_event { - LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { url, notification, .. @@ -675,14 +705,14 @@ fn multiple_webhooks_notification_test() { assert_eq!(url.as_str(), new_webhook); assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered); }, - _ => panic!("Expected SendWebhookNotifications event"), + _ => panic!("Expected SendWebhookNotification event"), } } #[test] fn idempotency_set_webhook_test() { let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = - get_client_and_service(None); + lsps5_test_setup(None); let app_name = "Idempotency Test App"; let webhook_url = "https://www.example.org/webhook?token=test123"; @@ -701,8 +731,8 @@ fn idempotency_set_webhook_test() { let notification_event = service_node.liquidity_manager.next_event().unwrap(); match notification_event { - LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotifications { .. }) => {}, - _ => panic!("Expected SendWebhookNotifications event"), + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { .. }) => {}, + _ => panic!("Expected SendWebhookNotification event"), } let set_webhook_response = get_lsps_message!(service_node, client_node_id); @@ -782,22 +812,22 @@ fn idempotency_set_webhook_test() { _ => panic!("Expected WebhookRegistered event for update"), } - // For an update, a SendWebhookNotifications event SHOULD be emitted + // 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::SendWebhookNotifications { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { url, .. }) => { assert_eq!(url.as_str(), updated_webhook_url); }, - _ => panic!("Expected SendWebhookNotifications event for update"), + _ => panic!("Expected SendWebhookNotification event for update"), } } #[test] fn replay_prevention_test() { let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = - get_client_and_service(None); + lsps5_test_setup(None); let app_name = "Replay Prevention Test App"; let webhook_url = "https://www.example.org/webhook?token=replay123"; @@ -816,49 +846,43 @@ fn replay_prevention_test() { let _ = client_node.liquidity_manager.next_event().unwrap(); - assert!(service_node + service_node .liquidity_manager .lsps5_service_handler() .unwrap() - .notify_payment_incoming(client_node_id) - .is_ok()); + .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::SendWebhookNotifications { + LiquidityEvent::LSPS5Service(LSPS5ServiceEvent::SendWebhookNotification { timestamp, signature, notification, .. }) => (timestamp, signature, serde_json::to_string(¬ification).unwrap()), - _ => panic!("Expected SendWebhookNotifications event"), + _ => panic!("Expected SendWebhookNotification event"), }; - let result: Result = + let result = client_handler.parse_webhook_notification(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: Result = + let replay_result = client_handler.parse_webhook_notification(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!( - err.err.contains("Replay attack detected") - || err.err.contains("signature has been used before"), - "Error should mention replay detection: {}", - err.err - ); + assert_eq!(err, LSPS5ClientError::ReplayAttack); } #[test] fn stale_webhooks() { let mock_time_provider = Arc::new(MockTimeProvider::new(1000)); let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = - get_client_and_service(Some(mock_time_provider.clone())); + lsps5_test_setup(Some(mock_time_provider.clone())); let raw_app_name = "StaleApp"; let app_name = LSPS5AppName::from_string(raw_app_name.to_string()).unwrap(); @@ -876,7 +900,7 @@ fn stale_webhooks() { let _ = client_node.liquidity_manager.next_event().unwrap(); // LIST before prune -> should contain our webhook - let _ = client_handler.list_webhooks(service_node_id).unwrap(); + 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_evt = service_node.liquidity_manager.next_event().unwrap(); @@ -901,7 +925,7 @@ fn stale_webhooks() { ); // LIST calls prune before executing -> should be empty after advancing time - let _ = client_handler.list_webhooks(service_node_id).unwrap(); + 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_evt2 = service_node.liquidity_manager.next_event().unwrap(); diff --git a/lightning-liquidity/src/tests/mod.rs b/lightning-liquidity/src/tests/mod.rs index b5614dd8233..7f803168a75 100644 --- a/lightning-liquidity/src/tests/mod.rs +++ b/lightning-liquidity/src/tests/mod.rs @@ -1 +1,4 @@ +pub mod common; +pub mod lsps2_integration_tests; +pub mod lsps5_integration_tests; pub mod utils; diff --git a/lightning-liquidity/tests/signing_tests.rs b/lightning-liquidity/tests/signing_tests.rs deleted file mode 100644 index efb8f68424a..00000000000 --- a/lightning-liquidity/tests/signing_tests.rs +++ /dev/null @@ -1,504 +0,0 @@ -// 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. - -//! Tests for LSPS5 webhook notification signing and verification - -#![cfg(all(test, feature = "std"))] - -mod common; -use common::get_client_and_service; -use core::time::Duration; -use lightning_liquidity::lsps0::ser::LSPSDateTime; -use lightning_liquidity::lsps5::msgs::{WebhookNotification, WebhookNotificationMethod}; -use lightning_liquidity::lsps5::service::{DefaultTimeProvider, TimeProvider}; -use std::str::FromStr; -use std::sync::Arc; - -#[test] -fn test_basic_sign_and_verify() { - let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); - let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); - let notification = WebhookNotification::webhook_registered(); - let notification_json = serde_json::to_string(¬ification).unwrap(); - - let timestamp = - LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); - - let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); - - let result = client_handler.verify_notification_signature( - service_node_id, - ×tamp, - &signature, - ¬ification, - ); - - assert!(result.is_ok()); - assert!(result.unwrap()); -} - -#[test] -fn test_parse_webhook_notification() { - let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); - let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); - let notification = WebhookNotification::payment_incoming(); - let notification_json = serde_json::to_string(¬ification).unwrap(); - - let timestamp = - LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); - - let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); - - let parsed_notification = client_handler - .parse_webhook_notification(service_node_id, ×tamp, &signature, ¬ification_json) - .unwrap(); - - assert_eq!(parsed_notification.method, WebhookNotificationMethod::LSPS5PaymentIncoming); -} - -#[test] -fn test_invalid_signature() { - let (client_handler, _, service_node_id, _, _, _) = get_client_and_service(None); - let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); - let notification = WebhookNotification::webhook_registered(); - - let timestamp = - LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); - - let invalid_signature = "xdtk1zf63sfn81r6qteymy73mb1b7dspj5kwx46uxwd6c3pu7y3bto"; - - let result = client_handler.verify_notification_signature( - service_node_id, - ×tamp, - invalid_signature, - ¬ification, - ); - - assert!(result.is_err()); -} - -#[test] -fn test_all_notification_types() { - let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); - let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); - let notifications = vec![ - WebhookNotification::webhook_registered(), - WebhookNotification::payment_incoming(), - WebhookNotification::expiry_soon(144), - WebhookNotification::liquidity_management_request(), - WebhookNotification::onion_message_incoming(), - ]; - - for notification in notifications { - let notification_json = serde_json::to_string(¬ification).unwrap(); - let timestamp = - LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); - - let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); - - let result = client_handler.verify_notification_signature( - service_node_id, - ×tamp, - &signature, - ¬ification, - ); - - assert!(result.is_ok()); - assert!(result.unwrap()); - - let parsed = client_handler.parse_webhook_notification( - service_node_id, - ×tamp, - &signature, - ¬ification_json, - ); - - assert!(parsed.is_ok()); - } -} - -#[test] -fn test_timestamp_out_of_range() { - let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); - - let notification = WebhookNotification::webhook_registered(); - let notification_json = serde_json::to_string(¬ification).unwrap(); - - let too_old_timestamp = LSPSDateTime::from_str("2020-01-01T00:00:00.000Z").unwrap(); - - let signature = - service_handler.sign_notification(¬ification_json, &too_old_timestamp).unwrap(); - - let result = client_handler.verify_notification_signature( - service_node_id, - &too_old_timestamp, - &signature, - ¬ification, - ); - - assert!(result.is_err()); -} - -#[test] -fn test_exact_bytes_from_spec_example() { - let timestamp = "2023-05-04T10:52:58.395Z"; - - let notification_json = r#"{"jsonrpc":"2.0","method":"lsps5.webhook_registered","params":{}}"#; - - let message = format!( - "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", - timestamp, notification_json - ); - - let bytes = message.as_bytes(); - - assert_eq!(bytes[0], 0x4c); // 'L' - assert_eq!(bytes[1], 0x53); // 'S' - assert_eq!(bytes[2], 0x50); // 'P' - assert_eq!(bytes[3], 0x53); // 'S' - assert_eq!(bytes[4], 0x35); // '5' - assert_eq!(bytes[5], 0x3a); // ':' - - let expected_prefix = "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At"; - assert!(message.starts_with(expected_prefix)); - - assert!(message.contains(timestamp)); - assert!(message.contains(notification_json)); -} - -#[test] -fn test_expiry_soon_notification_with_timeout() { - let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); - let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); - let timeout_value = 720000; - let notification = WebhookNotification::expiry_soon(timeout_value); - let notification_json = serde_json::to_string(¬ification).unwrap(); - - assert!(notification_json.contains(&format!("\"timeout\":{}", timeout_value))); - - let timestamp = - LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); - - let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); - - let result = client_handler.verify_notification_signature( - service_node_id, - ×tamp, - &signature, - ¬ification, - ); - - assert!(result.is_ok()); - assert!(result.unwrap()); - - let parsed = client_handler - .parse_webhook_notification(service_node_id, ×tamp, &signature, ¬ification_json) - .unwrap(); - - assert_eq!( - parsed.method, - WebhookNotificationMethod::LSPS5ExpirySoon { timeout: timeout_value } - ); - - let binding: WebhookNotificationMethod = parsed.method; - assert_eq!(binding, WebhookNotificationMethod::LSPS5ExpirySoon { timeout: timeout_value }); - // println!("Parsed method: {:?}", binding); - // let params_obj = binding.as_object().unwrap(); - // // assert!(params_obj.contains_key("timeout")); - // // assert_eq!(params_obj["timeout"], timeout_value); -} - -#[test] -fn test_spec_example_header_format() { - let (_, service_handler, _, _, _, _) = get_client_and_service(None); - - let notification = WebhookNotification::payment_incoming(); - let notification_json = serde_json::to_string(¬ification).unwrap(); - - let timestamp = LSPSDateTime::from_str("2023-05-04T10:14:23.853Z").unwrap(); - - let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); - - let headers = vec![ - ("Content-Type".to_string(), "application/json".to_string()), - ("x-lsps5-timestamp".to_string(), timestamp.to_string()), - ("x-lsps5-signature".to_string(), signature.clone()), - ]; - - let timestamp_header = headers.iter().find(|(name, _)| name == "x-lsps5-timestamp").unwrap(); - let _ = headers.iter().find(|(name, _)| name == "x-lsps5-signature").unwrap(); - - assert_eq!(timestamp_header.1, timestamp.to_rfc3339()); - - for c in signature.chars() { - assert!( - (c >= 'a' && c <= 'z') || (c >= '1' && c <= '9') || c == 'y' || c == 'z', - "Invalid character in zbase32 signature: {}", - c - ); - } - - assert!(headers.iter().any(|(name, _)| name == "x-lsps5-timestamp")); - assert!(headers.iter().any(|(name, _)| name == "x-lsps5-signature")); -} - -#[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")); - } - } -} - -#[test] -fn test_tampered_notification_details() { - let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); - let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); - - let notification = WebhookNotification::expiry_soon(700000); - let notification_json = serde_json::to_string(¬ification).unwrap(); - - let timestamp = - LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); - - let signature = service_handler.sign_notification(¬ification_json, ×tamp).unwrap(); - - let original_result = client_handler.verify_notification_signature( - service_node_id, - ×tamp, - &signature, - ¬ification, - ); - assert!(original_result.is_ok(), "Original notification should be valid"); - assert!(original_result.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 tampered_result = client_handler.verify_notification_signature( - service_node_id, - ×tamp, - &signature, - &tampered_notification, - ); - assert!(tampered_result.is_err(), "Tampered notification should fail verification"); - - let mut json_value: serde_json::Value = serde_json::from_str(¬ification_json).unwrap(); - json_value["method"] = serde_json::json!("lsps5.payment_incoming"); - let tampered_method_json = json_value.to_string(); - - let tampered_method_notification: WebhookNotification = - serde_json::from_str(&tampered_method_json).unwrap(); - - let tampered_method_result = client_handler.verify_notification_signature( - service_node_id, - ×tamp, - &signature, - &tampered_method_notification, - ); - assert!( - tampered_method_result.is_err(), - "Notification with tampered method should fail verification" - ); -} - -#[test] -fn test_timestamp_window_validation() { - let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); - let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); - - let notification = WebhookNotification::onion_message_incoming(); - let notification_json = serde_json::to_string(¬ification).unwrap(); - - let current_time = time_provider.duration_since_epoch(); - let valid_timestamp = LSPSDateTime::new_from_duration_since_epoch(current_time); - println!("Current time: {:?}", current_time); - println!("Valid timestamp: {:?}", valid_timestamp.to_rfc3339()); - - let signature: String = - service_handler.sign_notification(¬ification_json, &valid_timestamp).unwrap(); - - let valid_result = client_handler.verify_notification_signature( - service_node_id, - &valid_timestamp, - &signature, - ¬ification, - ); - assert!(valid_result.is_ok()); - assert!(valid_result.unwrap()); - - let past_timestamp = LSPSDateTime::new_from_duration_since_epoch( - current_time.checked_sub(Duration::from_secs(20 * 60)).unwrap(), - ); - - let past_result = client_handler.verify_notification_signature( - service_node_id, - &past_timestamp, - &signature, - ¬ification, - ); - assert!(past_result.is_err(), "Notification with past timestamp should be rejected"); - - let future_timestamp = LSPSDateTime::new_from_duration_since_epoch( - current_time.checked_add(Duration::from_secs(15 * 60)).unwrap(), - ); - - let future_result = client_handler.verify_notification_signature( - service_node_id, - &future_timestamp, - &signature, - ¬ification, - ); - assert!(future_result.is_err(), "Notification with future timestamp should be rejected"); -} - -#[test] -fn test_unknown_method_and_malformed_notifications() { - let (client_handler, service_handler, service_node_id, _, _, _) = get_client_and_service(None); - let time_provider: Arc<(dyn TimeProvider + 'static)> = Arc::new(DefaultTimeProvider); - let timestamp = - LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()); - - let create_notification = |method: &str, params: serde_json::Value| -> serde_json::Value { - serde_json::json!({ - "jsonrpc": "2.0", - "method": method, - "params": params - }) - }; - - let unknown_notification = - create_notification("lsps5.unknown_method", serde_json::json!({"some": "data"})); - let unknown_json = unknown_notification.to_string(); - let unknown_signature = service_handler.sign_notification(&unknown_json, ×tamp).unwrap(); - - let unknown_result = client_handler.parse_webhook_notification( - service_node_id, - ×tamp, - &unknown_signature, - &unknown_json, - ); - assert!(unknown_result.is_err(), "Unknown method should be rejected even with valid signature"); - - let invalid_jsonrpc = serde_json::json!({ - "method": "lsps5.payment_incoming", - "params": {} - }) - .to_string(); - let invalid_jsonrpc_signature = - service_handler.sign_notification(&invalid_jsonrpc, ×tamp).unwrap(); - - let invalid_jsonrpc_result = client_handler.parse_webhook_notification( - service_node_id, - ×tamp, - &invalid_jsonrpc_signature, - &invalid_jsonrpc, - ); - assert!(invalid_jsonrpc_result.is_err(), "Missing jsonrpc field should be rejected"); - - let missing_params = serde_json::json!({ - "jsonrpc": "2.0", - "method": "lsps5.payment_incoming" - }) - .to_string(); - let missing_params_signature = - service_handler.sign_notification(&missing_params, ×tamp).unwrap(); - - let missing_params_result = client_handler.parse_webhook_notification( - service_node_id, - ×tamp, - &missing_params_signature, - &missing_params, - ); - assert!(missing_params_result.is_err(), "Missing params field should be rejected"); - - let invalid_json = "{not valid json"; - let invalid_json_signature_result = service_handler.sign_notification(invalid_json, ×tamp); - - if let Ok(invalid_signature) = invalid_json_signature_result { - let invalid_json_result = client_handler.parse_webhook_notification( - service_node_id, - ×tamp, - &invalid_signature, - invalid_json, - ); - assert!(invalid_json_result.is_err(), "Invalid JSON should be rejected"); - } else { - assert!( - invalid_json_signature_result.is_err(), - "Invalid JSON should be rejected at signing" - ); - } - - let notification = WebhookNotification::payment_incoming(); - let notification_json = serde_json::to_string(¬ification).unwrap(); - - let edge_past_timestamp = LSPSDateTime::new_from_duration_since_epoch( - time_provider.duration_since_epoch().checked_sub(Duration::from_secs(9 * 60)).unwrap(), - ); - let edge_future_timestamp = LSPSDateTime::new_from_duration_since_epoch( - time_provider.duration_since_epoch().checked_add(Duration::from_secs(9 * 60)).unwrap(), - ); - - let past_edge_signature = - service_handler.sign_notification(¬ification_json, &edge_past_timestamp).unwrap(); - let future_edge_signature = - service_handler.sign_notification(¬ification_json, &edge_future_timestamp).unwrap(); - - let past_edge_result = client_handler.verify_notification_signature( - service_node_id, - &edge_past_timestamp, - &past_edge_signature, - ¬ification, - ); - let future_edge_result = client_handler.verify_notification_signature( - service_node_id, - &edge_future_timestamp, - &future_edge_signature, - ¬ification, - ); - - assert!(past_edge_result.is_ok(), "Timestamp just within past range should be accepted"); - assert!(future_edge_result.is_ok(), "Timestamp just within future range should be accepted"); -}