diff --git a/lightning-liquidity/Cargo.toml b/lightning-liquidity/Cargo.toml index 1cc0d988544..3c158b7dc1b 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] diff --git a/lightning-liquidity/src/events.rs b/lightning-liquidity/src/events.rs index 46308c7446c..714b0708d5f 100644 --- a/lightning-liquidity/src/events.rs +++ b/lightning-liquidity/src/events.rs @@ -18,6 +18,7 @@ use crate::lsps0; use crate::lsps1; use crate::lsps2; +use crate::lsps5; use crate::sync::{Arc, Mutex}; use alloc::collections::VecDeque; @@ -118,6 +119,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 { @@ -151,6 +156,18 @@ impl From for LiquidityEvent { } } +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) + } +} + struct EventFuture { event_queue: Arc>>, waker: Arc>>, diff --git a/lightning-liquidity/src/lib.rs b/lightning-liquidity/src/lib.rs index 909590eac96..2732afecec3 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 c916cf13e09..14a6e383c08 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -21,6 +21,11 @@ use crate::lsps1::msgs::{ use crate::lsps2::msgs::{ LSPS2Message, LSPS2Request, LSPS2Response, LSPS2_BUY_METHOD_NAME, LSPS2_GET_INFO_METHOD_NAME, }; +use crate::lsps5::msgs::{ + LSPS5Message, LSPS5Request, LSPS5Response, LSPS5_LIST_WEBHOOKS_METHOD_NAME, + LSPS5_REMOVE_WEBHOOK_METHOD_NAME, LSPS5_SET_WEBHOOK_METHOD_NAME, +}; + use crate::prelude::HashMap; use lightning::ln::msgs::{DecodeError, LightningError}; @@ -29,6 +34,7 @@ use lightning::util::ser::{LengthLimitedRead, LengthReadable, WithoutLength}; use bitcoin::secp256k1::PublicKey; +use core::time::Duration; #[cfg(feature = "std")] use std::time::{SystemTime, UNIX_EPOCH}; @@ -60,6 +66,9 @@ pub(crate) enum LSPSMethod { LSPS1CreateOrder, LSPS2GetInfo, LSPS2Buy, + LSPS5SetWebhook, + LSPS5ListWebhooks, + LSPS5RemoveWebhook, } impl LSPSMethod { @@ -71,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, } } } @@ -85,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"), } } @@ -117,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 @@ -214,6 +239,16 @@ impl LSPSDateTime { self.0.timestamp().try_into().expect("expiration to be ahead of unix epoch"); now_seconds_since_epoch > datetime_seconds_since_epoch } + + /// Returns the time in seconds since the unix epoch. + pub fn abs_diff(&self, other: Self) -> u64 { + self.0.timestamp().abs_diff(other.0.timestamp()) + } + + /// Returns the time in seconds since the unix epoch. + pub fn new_from_duration_since_epoch(duration: Duration) -> Self { + Self(chrono::DateTime::UNIX_EPOCH + duration) + } } impl FromStr for LSPSDateTime { @@ -255,6 +290,8 @@ pub enum LSPSMessage { LSPS1(LSPS1Message), /// An LSPS2 message. LSPS2(LSPS2Message), + /// An LSPS5 message. + LSPS5(LSPS5Message), } impl LSPSMessage { @@ -282,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, } } @@ -398,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() @@ -511,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 { @@ -616,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/lsps5/client.rs b/lightning-liquidity/src/lsps5/client.rs new file mode 100644 index 00000000000..8cda3a71289 --- /dev/null +++ b/lightning-liquidity/src/lsps5/client.rs @@ -0,0 +1,820 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Client implementation for LSPS5 webhook registration. + +use crate::alloc::string::ToString; +use crate::events::EventQueue; +use crate::lsps0::ser::{LSPSDateTime, LSPSMessage, LSPSProtocolMessageHandler, LSPSRequestId}; +use crate::lsps5::event::LSPS5ClientEvent; +use crate::lsps5::msgs::{ + LSPS5Message, LSPS5Request, LSPS5Response, ListWebhooksRequest, RemoveWebhookRequest, + SetWebhookRequest, WebhookNotification, +}; +use crate::message_queue::MessageQueue; +use crate::prelude::{new_hash_map, HashMap}; +use crate::sync::{Arc, Mutex, RwLock}; +use crate::utils::generate_request_id; + +use super::msgs::{LSPS5AppName, LSPS5WebhookUrl}; +#[cfg(feature = "time")] +use super::service::DefaultTimeProvider; +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 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). +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: Duration, +} + +impl Default for SignatureStorageConfig { + fn default() -> Self { + Self { + max_signatures: DEFAULT_MAX_SIGNATURES, + retention_minutes: Duration::from_secs(DEFAULT_SIGNATURE_RETENTION_MINUTES * 60), + } + } +} + +#[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: Duration, + /// Configuration for signature storage. + pub signature_config: SignatureStorageConfig, +} + +impl Default for LSPS5ClientConfig { + fn default() -> Self { + Self { + 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, + max_age_secs: Duration, + time_provider: Arc, +} + +impl PeerState { + 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) { + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + // Only run cleanup once per minute to avoid excessive processing + 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.clone()); + + 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. +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>, +} + +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 time_provider = Arc::new(DefaultTimeProvider); + Self::new_with_custom_time_provider( + entropy_source, + pending_messages, + pending_events, + config, + Some(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 { + 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: 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().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(); + + 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::from_string(app_name).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); + + self.with_peer_state(counterparty_node_id, |peer_state| { + peer_state.pending_set_webhook_requests.insert( + request_id.clone(), + ( + app_name.clone(), + lsps_webhook_url.clone(), + LSPSDateTime::new_from_duration_since_epoch( + self.time_provider.duration_since_epoch(), + ), + ), + ); + })?; + + let request = + LSPS5Request::SetWebhook(SetWebhookRequest { app_name, webhook: lsps_webhook_url }); + + let message = LSPS5Message::Request(request_id.clone(), request); + self.pending_messages.enqueue(&counterparty_node_id, LSPSMessage::LSPS5(message)); + + Ok(request_id) + } + + /// List all 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 = + 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) + } + + /// 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::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 = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + + self.with_peer_state(counterparty_node_id, |peer_state| { + peer_state + .pending_remove_webhook_requests + .insert(request_id.clone(), (app_name.clone(), now)); + })?; + + let request = LSPS5Request::RemoveWebhook(RemoveWebhookRequest { app_name }); + let message = LSPS5Message::Request(request_id.clone(), request); + self.pending_messages.enqueue(&counterparty_node_id, LSPSMessage::LSPS5(message)); + + Ok(request_id) + } + + /// Handle received messages from the LSP. + fn handle_message( + &self, message: LSPS5Message, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + 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) => { + self.pending_events.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) => { + self.pending_events.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) => { + self.pending_events.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) => { + self.pending_events.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(_) => { + self.pending_events.enqueue(LSPS5ClientEvent::WebhookRemoved { + counterparty_node_id: *counterparty_node_id, + app_name, + request_id, + }); + result = Ok(()); + }, + LSPS5Response::RemoveWebhookError(error) => { + self.pending_events.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 + }, + 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, signature_timestamp: &LSPSDateTime, + signature: &str, notification: &WebhookNotification, + ) -> 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 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 {}", + signature_timestamp.to_rfc3339(), + 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().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), + }); + } + } + + Ok(()) + } + + /// Store a signature with timestamp for replay attack prevention. + fn store_signature(&self, signature: String) -> Result<(), LightningError> { + 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.clone())); + + let retention_duration = self.config.signature_config.retention_minutes * 60; + while let Some((_, time)) = recent_signatures.front() { + if now.abs_diff(time.clone()) > retention_duration.as_secs() { + 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: &LSPSDateTime, signature: &str, + notification_json: &str, + ) -> Result { + 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) => { + self.pending_events.enqueue(LSPS5ClientEvent::WebhookNotificationReceived { + counterparty_node_id, + notification: notification.clone(), + timestamp: timestamp.clone(), + 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 core::time::Duration; + + 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::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(); + let remove_req_id = client.remove_webhook(peer, "test-app".to_string()).unwrap(); + + { + let outer_state_lock = client.per_peer_state.read().unwrap(); + let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap(); + assert_eq!( + peer_state.pending_set_webhook_requests.get(&set_req_id).unwrap(), + &( + lsps5_app_name.clone(), + lsps5_webhook_url, + peer_state.pending_set_webhook_requests.get(&set_req_id).unwrap().2.clone() + ) + ); + + assert!(peer_state.pending_list_webhooks_requests.contains_key(&list_req_id)); + + assert_eq!( + peer_state.pending_remove_webhook_requests.get(&remove_req_id).unwrap().0, + lsps5_app_name + ); + } + } + + #[test] + fn test_handle_response_clears_pending_state() { + let (client, _, _, peer, _) = setup_test_client(); + + let req_id = client + .set_webhook(peer, "test-app".to_string(), "https://example.com/hook".to_string()) + .unwrap(); + + let response = LSPS5Response::SetWebhook(SetWebhookResponse { + num_webhooks: 1, + max_webhooks: 5, + no_change: false, + }); + let response_msg = LSPS5Message::Response(req_id.clone(), response); + + { + let outer_state_lock = client.per_peer_state.read().unwrap(); + let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap(); + assert!(peer_state.pending_set_webhook_requests.contains_key(&req_id)); + } + + client.handle_message(response_msg, &peer).unwrap(); + + { + let outer_state_lock = client.per_peer_state.read().unwrap(); + let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap(); + assert!(!peer_state.pending_set_webhook_requests.contains_key(&req_id)); + } + } + + #[test] + fn test_cleanup_expired_responses() { + let (client, _, _, _, _) = setup_test_client(); + let time_provider = &client.time_provider; + const OLD_APP_NAME: &str = "test-app-old"; + const NEW_APP_NAME: &str = "test-app-new"; + const WEBHOOK_URL: &str = "https://example.com/hook"; + let lsps5_old_app_name = LSPS5AppName::from_string(OLD_APP_NAME.to_string()).unwrap(); + let lsps5_new_app_name = LSPS5AppName::from_string(NEW_APP_NAME.to_string()).unwrap(); + let lsps5_webhook_url = LSPS5WebhookUrl::from_string(WEBHOOK_URL.to_string()).unwrap(); + let now = time_provider.duration_since_epoch(); + let mut peer_state = PeerState::new(Duration::from_secs(1800), 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()); + + // Add an old request (should be removed during cleanup) + peer_state.pending_set_webhook_requests.insert( + old_request_id.clone(), + ( + lsps5_old_app_name, + lsps5_webhook_url.clone(), + LSPSDateTime::new_from_duration_since_epoch( + now.checked_sub(Duration::from_secs(7200)).unwrap(), + ), + ), // 2 hours old + ); + + // Add a recent request (should be kept) + peer_state.pending_set_webhook_requests.insert( + new_request_id.clone(), + ( + lsps5_new_app_name, + lsps5_webhook_url, + LSPSDateTime::new_from_duration_since_epoch( + now.checked_sub(Duration::from_secs(600)).unwrap(), + ), + ), // 10 minutes old + ); + + peer_state.cleanup_expired_responses(); + + assert!(!peer_state.pending_set_webhook_requests.contains_key(&old_request_id)); + assert!(peer_state.pending_set_webhook_requests.contains_key(&new_request_id)); + + let cleanup_age = if let Some(last_cleanup) = peer_state.last_cleanup { + LSPSDateTime::new_from_duration_since_epoch(time_provider.duration_since_epoch()) + .abs_diff(last_cleanup) + } else { + 0 + }; + assert!(cleanup_age < 10); + } + + #[test] + fn test_unknown_request_id_handling() { + let (client, _message_queue, _, peer, _) = setup_test_client(); + + let _valid_req = client + .set_webhook(peer, "test-app".to_string(), "https://example.com/hook".to_string()) + .unwrap(); + + let unknown_req_id = LSPSRequestId("unknown:request:id".to_string()); + let response = LSPS5Response::SetWebhook(SetWebhookResponse { + num_webhooks: 1, + max_webhooks: 5, + no_change: false, + }); + let response_msg = LSPS5Message::Response(unknown_req_id, response); + + let result = client.handle_message(response_msg, &peer); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.err.to_lowercase().contains("unknown request id")); + } +} diff --git a/lightning-liquidity/src/lsps5/event.rs b/lightning-liquidity/src/lsps5/event.rs new file mode 100644 index 00000000000..5fef29093ef --- /dev/null +++ b/lightning-liquidity/src/lsps5/event.rs @@ -0,0 +1,245 @@ +// 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::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 { + /// 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. + /// + /// 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) 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. + /// + /// 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, + /// 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. + /// + /// 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)>, + }, +} + +/// 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. + app_name: LSPS5AppName, + /// The webhook URL that was registered. + url: LSPS5WebhookUrl, + /// The identifier of the issued bLIP-55 / LSPS5 webhook registration request. + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// A webhook registration attempt failed + /// + /// This event is triggered when the LSP rejects a webhook registration + /// via `lsps5.set_webhook`. 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 from the LSP. + error: LSPS5Error, + /// The app name that was attempted. + app_name: LSPS5AppName, + /// The webhook URL that was attempted. + url: LSPS5WebhookUrl, + /// The identifier of the issued bLIP-55 / LSPS5 webhook registration request. + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// The list of registered webhooks was successfully retrieved. + /// + /// This event is triggered when the LSP responds to a `lsps5.list_webhooks` request. + 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 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. + /// + /// 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 from the LSP. + error: LSPS5Error, + /// The app name that was attempted to be removed. + app_name: LSPS5AppName, + /// The identifier of the issued bLIP-55 / LSPS5 remove webhook request. + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// 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. + timestamp: LSPSDateTime, + /// Whether the LSP's signature was successfully verified. + signature_valid: bool, + }, +} 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; diff --git a/lightning-liquidity/src/lsps5/msgs.rs b/lightning-liquidity/src/lsps5/msgs.rs new file mode 100644 index 00000000000..7654c34ad1e --- /dev/null +++ b/lightning-liquidity/src/lsps5/msgs.rs @@ -0,0 +1,782 @@ +// 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 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 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). +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"; + +#[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 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 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(), + } + } +} + +/// 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 }, + } + } +} + +/// 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 { + f.write_str(self) + } +} + +impl Serialize for LSPS5AppName { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self) + } +} + +impl<'de> Deserialize<'de> for LSPS5AppName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::new(UntrustedString(s)).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) + } +} + +impl AsRef for LSPS5AppName { + fn as_ref(&self) -> &str { + self + } +} + +impl From for String { + fn from(app_name: LSPS5AppName) -> Self { + app_name.to_string() + } +} + +/// URL for LSPS5 webhooks. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LSPS5WebhookUrl(LSPSUrl); + +impl LSPS5WebhookUrl { + /// Create a new LSPS5 webhook URL. + pub fn new(url: 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 !parsed_url.is_https() { + return Err(LSPS5Error::UnsupportedProtocol( + "Unsupported protocol: HTTPS is required".to_string(), + )); + } + if !parsed_url.is_public() { + return Err(LSPS5Error::UrlParse("Webhook URL must be a public URL".to_string())); + } + Ok(Self(parsed_url)) + } + + /// Create a new LSPS5 webhook URL from a regular String. + pub fn from_string(url: String) -> Result { + Self::new(UntrustedString(url)) + } + + /// Get the webhook URL as a string. + pub fn as_str(&self) -> &str { + self + } +} + +impl Deref for LSPS5WebhookUrl { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0.url() + } +} + +impl Display for LSPS5WebhookUrl { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self) // Using Deref + } +} + +impl Serialize for LSPS5WebhookUrl { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self) + } +} + +impl<'de> Deserialize<'de> for LSPS5WebhookUrl { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::new(UntrustedString(s)).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) + } +} + +impl AsRef for LSPS5WebhookUrl { + fn as_ref(&self) -> &str { + self + } +} + +impl From for String { + fn from(url: LSPS5WebhookUrl) -> Self { + url.to_string() + } +} + +/// Parameters for `lsps5.set_webhook` request. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SetWebhookRequest { + /// Human-readable name for the webhook. + pub app_name: LSPS5AppName, + /// URL of the webhook. + pub webhook: LSPS5WebhookUrl, +} + +/// Response for `lsps5.set_webhook`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SetWebhookResponse { + /// Current number of webhooks registered for this client. + pub num_webhooks: u32, + /// Maximum number of webhooks allowed by LSP. + pub max_webhooks: u32, + /// Whether this is an unchanged registration. + pub no_change: bool, +} + +/// Parameters for `lsps5.list_webhooks` request. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct ListWebhooksRequest {} + +/// Response for `lsps5.list_webhooks`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ListWebhooksResponse { + /// List of app_names with registered webhooks. + pub app_names: Vec, + /// Maximum number of webhooks allowed by LSP. + pub max_webhooks: u32, +} + +/// Parameters for `lsps5.remove_webhook` request. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RemoveWebhookRequest { + /// App name identifying the webhook to remove. + pub app_name: LSPS5AppName, +} + +/// Response for `lsps5.remove_webhook`. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct RemoveWebhookResponse {} + +/// Webhook notification methods defined in LSPS5. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum WebhookNotificationMethod { + /// Webhook has been successfully registered. + LSPS5WebhookRegistered, + /// Client has payments pending to be received. + LSPS5PaymentIncoming, + /// HTLC or time-bound contract is about to expire. + LSPS5ExpirySoon { + /// Block height when timeout occurs and the LSP would be forced to close the channel + timeout: u32, + }, + /// LSP wants to take back some liquidity. + LSPS5LiquidityManagementRequest, + /// Client has onion messages pending. + LSPS5OnionMessageIncoming, +} + +/// Webhook notification payload. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebhookNotification { + /// Notification method with parameters. + pub method: WebhookNotificationMethod, +} + +impl WebhookNotification { + /// Create a new webhook notification. + pub fn new(method: WebhookNotificationMethod) -> Self { + Self { method } + } + + /// Create a webhook_registered notification. + pub fn webhook_registered() -> Self { + Self::new(WebhookNotificationMethod::LSPS5WebhookRegistered) + } + + /// Create a payment_incoming notification. + pub fn payment_incoming() -> Self { + Self::new(WebhookNotificationMethod::LSPS5PaymentIncoming) + } + + /// Create an expiry_soon notification. + pub fn expiry_soon(timeout: u32) -> Self { + Self::new(WebhookNotificationMethod::LSPS5ExpirySoon { timeout }) + } + + /// Create a liquidity_management_request notification. + pub fn liquidity_management_request() -> Self { + Self::new(WebhookNotificationMethod::LSPS5LiquidityManagementRequest) + } + + /// Create an onion_message_incoming notification. + pub fn onion_message_incoming() -> Self { + Self::new(WebhookNotificationMethod::LSPS5OnionMessageIncoming) + } +} + +impl Serialize for WebhookNotification { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(Some(3))?; + map.serialize_entry("jsonrpc", "2.0")?; + + let method_name = match &self.method { + WebhookNotificationMethod::LSPS5WebhookRegistered => { + LSPS5_WEBHOOK_REGISTERED_NOTIFICATION + }, + WebhookNotificationMethod::LSPS5PaymentIncoming => LSPS5_PAYMENT_INCOMING_NOTIFICATION, + WebhookNotificationMethod::LSPS5ExpirySoon { .. } => LSPS5_EXPIRY_SOON_NOTIFICATION, + WebhookNotificationMethod::LSPS5LiquidityManagementRequest => { + LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_NOTIFICATION + }, + WebhookNotificationMethod::LSPS5OnionMessageIncoming => { + LSPS5_ONION_MESSAGE_INCOMING_NOTIFICATION + }, + }; + map.serialize_entry("method", &method_name)?; + + let params = match &self.method { + WebhookNotificationMethod::LSPS5WebhookRegistered => serde_json::json!({}), + WebhookNotificationMethod::LSPS5PaymentIncoming => serde_json::json!({}), + WebhookNotificationMethod::LSPS5ExpirySoon { timeout } => { + serde_json::json!({ "timeout": timeout }) + }, + WebhookNotificationMethod::LSPS5LiquidityManagementRequest => serde_json::json!({}), + WebhookNotificationMethod::LSPS5OnionMessageIncoming => serde_json::json!({}), + }; + map.serialize_entry("params", ¶ms)?; + + map.end() + } +} + +impl<'de> Deserialize<'de> for WebhookNotification { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct WebhookNotificationVisitor; + + impl<'de> Visitor<'de> for WebhookNotificationVisitor { + type Value = WebhookNotification; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a valid LSPS5 WebhookNotification object") + } + + fn visit_map(self, mut map: V) -> Result + where + V: MapAccess<'de>, + { + let mut jsonrpc: Option = None; + let mut method: Option = None; + let mut params: Option = None; + + while let Some(key) = map.next_key::<&str>()? { + match key { + "jsonrpc" => jsonrpc = Some(map.next_value()?), + "method" => method = Some(map.next_value()?), + "params" => params = Some(map.next_value()?), + _ => { + let _: serde::de::IgnoredAny = map.next_value()?; + }, + } + } + + let jsonrpc = jsonrpc.ok_or_else(|| de::Error::missing_field("jsonrpc"))?; + if jsonrpc != "2.0" { + return Err(de::Error::custom("Invalid jsonrpc version")); + } + let method = method.ok_or_else(|| de::Error::missing_field("method"))?; + let params = params.ok_or_else(|| de::Error::missing_field("params"))?; + + let method = match method.as_str() { + LSPS5_WEBHOOK_REGISTERED_NOTIFICATION => { + WebhookNotificationMethod::LSPS5WebhookRegistered + }, + LSPS5_PAYMENT_INCOMING_NOTIFICATION => { + WebhookNotificationMethod::LSPS5PaymentIncoming + }, + LSPS5_EXPIRY_SOON_NOTIFICATION => { + if let Some(timeout) = params.get("timeout").and_then(|t| t.as_u64()) { + WebhookNotificationMethod::LSPS5ExpirySoon { timeout: timeout as u32 } + } else { + return Err(de::Error::custom( + "Missing or invalid timeout parameter for expiry_soon notification", + )); + } + }, + LSPS5_LIQUIDITY_MANAGEMENT_REQUEST_NOTIFICATION => { + WebhookNotificationMethod::LSPS5LiquidityManagementRequest + }, + LSPS5_ONION_MESSAGE_INCOMING_NOTIFICATION => { + WebhookNotificationMethod::LSPS5OnionMessageIncoming + }, + _ => return Err(de::Error::custom(format!("Unknown method: {}", method))), + }; + + Ok(WebhookNotification { method }) + } + } + + deserializer.deserialize_map(WebhookNotificationVisitor) + } +} + +/// An LSPS5 protocol request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LSPS5Request { + /// Register or update a webhook. + SetWebhook(SetWebhookRequest), + /// List all registered webhooks. + ListWebhooks(ListWebhooksRequest), + /// Remove a webhook. + RemoveWebhook(RemoveWebhookRequest), +} + +/// An LSPS5 protocol response. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LSPS5Response { + /// Response to [`SetWebhook`](SetWebhookRequest) request. + SetWebhook(SetWebhookResponse), + /// Error response to [`SetWebhook`](SetWebhookRequest) request. + SetWebhookError(LSPS5Error), + /// Response to [`ListWebhooks`](ListWebhooksRequest) request. + ListWebhooks(ListWebhooksResponse), + /// Error response to [`ListWebhooks`](ListWebhooksRequest) request. + ListWebhooksError(LSPS5Error), + /// Response to [`RemoveWebhook`](RemoveWebhookRequest) request. + RemoveWebhook(RemoveWebhookResponse), + /// Error response to [`RemoveWebhook`](RemoveWebhookRequest) request. + RemoveWebhookError(LSPS5Error), +} + +#[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(UntrustedString("my_app".to_string())).unwrap() + ); + assert_eq!( + request.webhook, + LSPS5WebhookUrl::new(UntrustedString("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(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); + } + + #[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(UntrustedString("My LSPS-Compliant Lightning Client".to_string())) + .unwrap() + ); + assert_eq!( + request.webhook, + LSPS5WebhookUrl::new(UntrustedString( + "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(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); + } + + #[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(UntrustedString( + "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(UntrustedString(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"); + } + } + } + } +} diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs new file mode 100644 index 00000000000..1a04d3b9176 --- /dev/null +++ b/lightning-liquidity/src/lsps5/service.rs @@ -0,0 +1,672 @@ +// 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}; +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 super::event::LSPS5ServiceEvent; +use super::msgs::{ + 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: Duration = Duration::from_secs(30 * 24 * 60 * 60); +/// Interval for pruning stale webhooks. +pub const PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS: Duration = Duration::from_secs(24 * 60 * 60); + +/// A stored webhook. +#[derive(Debug, Clone)] +struct StoredWebhook { + /// App name 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, +} + +/// 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. + 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: 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 { + /// Configuration parameters + config: LSPS5ServiceConfig, + /// Map of client node IDs to their registered webhooks + webhooks: Mutex>>, + /// 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: Mutex>, +} + +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. + #[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); + Self::new_with_custom_time_provider( + event_queue, + pending_messages, + client_has_open_channel, + config, + Some(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, + 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: Mutex::new(new_hash_map()), + event_queue, + pending_messages, + time_provider, + client_has_open_channel, + last_pruning: Mutex::new(None), + }) + } + + fn check_prune_stale_webhooks(&self) -> Result<(), LightningError> { + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + let should_prune = { + let last_pruning = self.last_pruning.lock().unwrap(); + last_pruning.as_ref().map_or(true, |last_time| { + now.abs_diff(last_time.clone()) > PRUNE_STALE_WEBHOOKS_INTERVAL_DAYS.as_secs() + }) + }; + + 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> { + self.check_prune_stale_webhooks()?; + + let mut webhooks = self.webhooks.lock().unwrap(); + + let client_webhooks = webhooks.entry(counterparty_node_id).or_insert_with(new_hash_map); + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + + let 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 = LSPS5Error::TooManyWebhooks(message.clone()); + let msg = + LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(error)).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, + }; + self.event_queue.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> { + self.check_prune_stale_webhooks()?; + + let webhooks = self.webhooks.lock().unwrap(); + + let app_names = webhooks + .get(&counterparty_node_id) + .map(|client_webhooks| client_webhooks.keys().cloned().collect::>()) + .unwrap_or_else(Vec::new); + + let max_webhooks = self.config.max_webhooks_per_client; + + self.event_queue.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> { + // Check if we need to prune stale webhooks + self.check_prune_stale_webhooks()?; + + let mut webhooks = self.webhooks.lock().unwrap(); + + if let Some(client_webhooks) = webhooks.get_mut(&counterparty_node_id) { + if client_webhooks.remove(¶ms.app_name).is_some() { + let response = RemoveWebhookResponse {}; + let msg = LSPS5Message::Response( + request_id.clone(), + LSPS5Response::RemoveWebhook(response), + ) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + self.event_queue.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 = LSPS5Error::AppNameNotFound(error_message.clone()); + let msg = + LSPS5Message::Response(request_id, LSPS5Response::RemoveWebhookError(error)).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().unwrap(); + + let client_webhooks = match webhooks.get_mut(&client_id) { + Some(webhooks) if !webhooks.is_empty() => webhooks, + _ => return Ok(()), + }; + + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + 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) + .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.clone()); + webhook.last_used = now.clone(); + 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 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 headers = vec![ + ("Content-Type".to_string(), "application/json".to_string()), + ("x-lsps5-timestamp".to_string(), timestamp.to_rfc3339()), + ("x-lsps5-signature".to_string(), signature_hex.clone()), + ]; + + self.event_queue.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: &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.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". + fn prune_stale_webhooks(&self) { + let now = + LSPSDateTime::new_from_duration_since_epoch(self.time_provider.duration_since_epoch()); + let mut webhooks = self.webhooks.lock().unwrap(); + + webhooks.retain(|client_id, client_webhooks| { + if !(self.client_has_open_channel)(client_id) { + client_webhooks.retain(|_, webhook| { + now.abs_diff(webhook.last_used.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); + } +} + +impl LSPSProtocolMessageHandler for LSPS5ServiceHandler { + type ProtocolMessage = LSPS5Message; + const PROTOCOL_NUMBER: Option = Some(5); + + fn handle_message( + &self, message: Self::ProtocolMessage, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + match message { + LSPS5Message::Request(request_id, request) => { + let res = match request { + LSPS5Request::SetWebhook(params) => { + self.handle_set_webhook(*counterparty_node_id, request_id.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; + + 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] + 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::from_string(format!("app_{}_{}", i, j)).unwrap(); + let url = LSPS5WebhookUrl::from_string(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: LSPSDateTime::new_from_duration_since_epoch( + 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])); + } + } +} diff --git a/lightning-liquidity/src/lsps5/url_utils.rs b/lightning-liquidity/src/lsps5/url_utils.rs new file mode 100644 index 00000000000..bdefb9fc7d8 --- /dev/null +++ b/lightning-liquidity/src/lsps5/url_utils.rs @@ -0,0 +1,535 @@ +// 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; +use lightning_types::string::UntrustedString; + +/// 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: UntrustedString, + host: UntrustedString, + /// The full URL string. + pub url: UntrustedString, +} + +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: 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(()); + } + + let host_section = remainder.split(['/', '?', '#']).next().ok_or_else(|| ())?; + + let host_without_auth = + host_section.split('@').next_back().filter(|s| !s.is_empty()).ok_or_else(|| ())?; + + if host_without_auth.is_empty() { + return Err(()); + } + + 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: 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 URL length. + pub fn url_length(&self) -> usize { + self.url.0.chars().count() + } + + /// 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. + pub fn url(&self) -> &str { + self.url.0.as_str() + } +} + +/// 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 { + 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::*; + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + + 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".to_string(); + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_invalid_url_empty_host() { + let url_str = "https:///path".to_string(); + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + + 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/".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + + 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/".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + + 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/".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + + 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".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + assert_eq!(url.scheme.0, "https"); + + let url_str = "http://example.com/path".to_string(); + let url = LSPSUrl::parse(url_str).unwrap(); + assert_ne!(url.scheme.0, "https"); + } + + #[test] + fn test_empty_remainder_error() { + let url_str = "https://".to_string(); + let result = LSPSUrl::parse(url_str); + assert!(result.is_err()); + } + + #[test] + fn test_malformed_scheme_chars() { + 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".to_string(); + 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".to_string(); + 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(UntrustedString(url_str).to_string()); + assert!(result.is_ok(), "Valid scheme '{}' was rejected", 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).to_string(); + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + let url = result.unwrap(); + assert_eq!(url.scheme.0, "https"); + assert_eq!(url.host.0.chars().count(), 10000); + } + + #[test] + fn test_unicode_characters() { + let url_str = "https://例子.测试/path".to_string(); + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_weird_but_valid_scheme() { + let url_str = "a123+-.://example.com".to_string(); + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().scheme.0, "a123+-."); + } + + #[test] + fn test_url_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.0, "example.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.0, "bad domain.com"); + } + + #[test] + fn test_multiple_scheme_separators() { + 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".to_string(); + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_invalid_port() { + 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".to_string(); + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().host.0, "example.com:65536"); + } + + #[test] + fn test_missing_host_domain() { + let url_str = "https://:8080/path".to_string(); + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_scheme_only() { + let url_str = "https:".to_string(); + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_null_characters() { + 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.0, "example.com\0"); + } + + #[test] + fn test_url_with_backslashes() { + let url_str = "https:\\\\example.com\\path".to_string(); + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_just_scheme_and_authority_markers() { + 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"); + } + } + } + } +} diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index eec9a71d632..ceda3de7b74 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -11,6 +11,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, ProcessMessagesCallback}; 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, } /// The main interface into LSP functionality. @@ -109,6 +116,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>, @@ -129,6 +138,7 @@ where entropy_source: ES, channel_manager: CM, chain_source: Option, chain_params: Option, service_config: Option, client_config: Option, + _time_provider: Option>, ) -> Self where { let pending_messages = Arc::new(MessageQueue::new()); @@ -163,6 +173,75 @@ where { }) }); + let lsps5_client_handler = client_config + .as_ref() + .and_then(|config| { + config.lsps5_client_config.as_ref().map(|config| { + #[cfg(feature = "time")] + return LSPS5ClientHandler::new( + entropy_source.clone(), + Arc::clone(&pending_messages), + Arc::clone(&pending_events), + config.clone(), + ); + + #[cfg(not(feature = "time"))] + return LSPS5ClientHandler::new_with_custom_time_provider( + entropy_source.clone(), + Arc::clone(&pending_messages), + Arc::clone(&pending_events), + config.clone(), + _time_provider.clone(), + ); + }) + }) + .flatten(); + + let lsps5_service_handler = service_config + .as_ref() + .and_then(|config| { + config.lsps5_service_config.as_ref().map(|config| { + if let Some(number) = + ::PROTOCOL_NUMBER + { + supported_protocols.push(number); + } + + let client_has_open_channels = { + let mut usable_channels = new_hash_map(); + let channels = channel_manager.get_cm().list_channels(); + for channel in channels.iter() { + if channel.is_usable { + usable_channels.insert(channel.counterparty.node_id, true); + } + } + + let usable_channels_arc = Arc::new(usable_channels); + Box::new(move |client_node_id: &PublicKey| -> bool { + usable_channels_arc.contains_key(client_node_id) + }) as Box bool> + }; + + #[cfg(feature = "time")] + return LSPS5ServiceHandler::new( + Arc::clone(&pending_events), + Arc::clone(&pending_messages), + client_has_open_channels, + config.clone(), + ); + + #[cfg(not(feature = "time"))] + return LSPS5ServiceHandler::new_with_custom_time_provider( + Arc::clone(&pending_events), + Arc::clone(&pending_messages), + client_has_open_channels, + config.clone(), + _time_provider.clone(), + ); + }) + }) + .flatten(); + let lsps1_client_handler = client_config.as_ref().and_then(|config| { config.lsps1_client_config.as_ref().map(|config| { LSPS1ClientHandler::new( @@ -217,6 +296,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)), @@ -264,6 +345,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() + } + /// Allows to set a callback that will be called after new messages are pushed to the message /// queue. /// @@ -439,6 +534,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 f114f7b9c89..b89cfd1223e 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}; use lightning_liquidity::{LiquidityClientConfig, LiquidityManager, LiquidityServiceConfig}; use lightning_persister::fs_store::FilesystemStore; @@ -460,6 +463,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( @@ -683,3 +687,46 @@ fn advance_chain(node: &mut Node, num_blocks: u32) { } } } + +pub(crate) fn get_client_and_service() -> ( + &'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); + + // 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..7c58c52e53f 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,6 +96,7 @@ 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) = diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs new file mode 100644 index 00000000000..da2e8cab5cd --- /dev/null +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -0,0 +1,835 @@ +#![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; + +#[test] +fn webhook_registration_flow() { + let (client_handler, _, service_node_id, client_node_id, service_node, client_node) = + get_client_and_service(); + + 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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 + ); +} diff --git a/lightning-liquidity/tests/signing_tests.rs b/lightning-liquidity/tests/signing_tests.rs new file mode 100644 index 00000000000..d4e608a7e38 --- /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(); + 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(); + 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(); + 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(); + 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(); + + 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(); + 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(); + + 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(); + 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(); + 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(); + 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"); +}