diff --git a/lightning-liquidity/Cargo.toml b/lightning-liquidity/Cargo.toml index eebb0e8a076..9518e75a74e 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 a2c18417f9c..6af40a12661 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::prelude::{Vec, VecDeque}; use crate::sync::{Arc, Mutex}; @@ -116,6 +117,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 { @@ -149,6 +154,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 3930efa51a2..6d234b6b10f 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)] @@ -65,6 +68,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 91ec28ca119..d36f0d7ea5c 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 af21b30a783..ea691b97a4d 100644 --- a/lightning-liquidity/src/lsps0/ser.rs +++ b/lightning-liquidity/src/lsps0/ser.rs @@ -16,8 +16,13 @@ use crate::lsps1::msgs::{ use crate::lsps2::msgs::{ LSPS2Message, LSPS2Request, LSPS2Response, LSPS2_BUY_METHOD_NAME, LSPS2_GET_INFO_METHOD_NAME, }; +use crate::lsps5::msgs::{ + LSPS5Message, LSPS5Request, LSPS5Response, LSPS5_LIST_WEBHOOKS_METHOD_NAME, + LSPS5_REMOVE_WEBHOOK_METHOD_NAME, LSPS5_SET_WEBHOOK_METHOD_NAME, +}; use crate::prelude::{HashMap, String}; +use chrono::DateTime; use lightning::ln::msgs::{DecodeError, LightningError}; use lightning::ln::wire; use lightning::util::ser::{LengthLimitedRead, LengthReadable, WithoutLength}; @@ -27,6 +32,7 @@ use bitcoin::secp256k1::PublicKey; use core::fmt::{self, Display}; use core::str::FromStr; +use core::time::Duration; #[cfg(feature = "std")] use std::time::{SystemTime, UNIX_EPOCH}; @@ -58,6 +64,9 @@ pub(crate) enum LSPSMethod { LSPS1CreateOrder, LSPS2GetInfo, LSPS2Buy, + LSPS5SetWebhook, + LSPS5ListWebhooks, + LSPS5RemoveWebhook, } impl LSPSMethod { @@ -69,6 +78,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, } } } @@ -83,6 +95,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"), } } @@ -115,6 +130,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 @@ -212,6 +237,17 @@ 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()) + } +} + +impl From for LSPSDateTime { + fn from(duration: Duration) -> Self { + Self(DateTime::UNIX_EPOCH + duration) + } } impl FromStr for LSPSDateTime { @@ -253,6 +289,8 @@ pub enum LSPSMessage { LSPS1(LSPS1Message), /// An LSPS2 message. LSPS2(LSPS2Message), + /// An LSPS5 message. + LSPS5(LSPS5Message), } impl LSPSMessage { @@ -280,6 +318,10 @@ impl LSPSMessage { LSPSMessage::LSPS2(LSPS2Message::Request(request_id, request)) => { Some((LSPSRequestId(request_id.0.clone()), request.into())) }, + // Add LSPS5 + LSPSMessage::LSPS5(LSPS5Message::Request(request_id, request)) => { + Some((LSPSRequestId(request_id.0.clone()), request.into())) + }, _ => None, } } @@ -396,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() @@ -509,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 { @@ -614,6 +722,58 @@ impl<'de, 'a> Visitor<'de> for LSPSMessageVisitor<'a> { Err(de::Error::custom("Received invalid JSON-RPC object: one of method, result, or error required")) } }, + // Add LSPS5 methods + LSPSMethod::LSPS5SetWebhook => { + if let Some(error) = error { + Ok(LSPSMessage::LSPS5(LSPS5Message::Response( + id, + LSPS5Response::SetWebhookError(error), + ))) + } 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), + ))) + } 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), + ))) + } 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..0c834752c63 --- /dev/null +++ b/lightning-liquidity/src/lsps5/client.rs @@ -0,0 +1,837 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. You may not use this file except in accordance with one or both of these +// licenses. + +//! Client implementation for LSPS5 webhook registration + +use crate::events::EventQueue; +use crate::lsps0::ser::{LSPSDateTime, LSPSMessage, LSPSProtocolMessageHandler, LSPSRequestId}; +use crate::lsps5::event::LSPS5ClientEvent; +use crate::lsps5::msgs::{ + LSPS5Message, LSPS5Request, LSPS5Response, ListWebhooksRequest, RemoveWebhookRequest, + SetWebhookRequest, WebhookNotification, +}; +use crate::message_queue::MessageQueue; +use crate::prelude::*; + +use bitcoin::secp256k1::PublicKey; +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::util::message_signing; + +use crate::sync::{Arc, Mutex, RwLock}; +use core::ops::Deref; + +use crate::prelude::{new_hash_map, HashMap, String}; + +use super::msgs::{LSPS5AppName, LSPS5WebhookUrl}; +#[cfg(feature = "time")] +use super::service::DefaultTimeProvider; +use super::service::TimeProvider; +use crate::utils::generate_request_id; +use core::str::FromStr; +use core::time::Duration; +use lightning::sign::EntropySource; +use lightning::util::logger::Level; + +/// Default maximum age in seconds for cached responses (1 hour) +pub const DEFAULT_RESPONSE_MAX_AGE_SECS: u64 = 3600; + +/// Default retention time for signatures in minutes (LSPS5 spec requires min 20 minutes) +pub const DEFAULT_SIGNATURE_RETENTION_MINUTES: u64 = 20; + +/// Default maximum number of stored signatures +pub const DEFAULT_MAX_SIGNATURES: usize = 1000; + +/// Configuration for signature storage +#[derive(Clone, Copy, Debug)] +pub struct SignatureStorageConfig { + /// Maximum number of signatures to store + pub max_signatures: usize, + /// Retention time for signatures in minutes + pub retention_minutes: u64, +} + +impl Default for SignatureStorageConfig { + fn default() -> Self { + Self { + max_signatures: DEFAULT_MAX_SIGNATURES, + retention_minutes: DEFAULT_SIGNATURE_RETENTION_MINUTES, + } + } +} + +#[derive(Clone)] +/// Configuration for the LSPS5 client +pub struct LSPS5ClientConfig { + /// Maximum age in seconds for cached responses (default: 3600 - 1 hour) + pub response_max_age_secs: u64, + /// Configuration for signature storage + pub signature_config: SignatureStorageConfig, +} + +impl Default for LSPS5ClientConfig { + fn default() -> Self { + Self { + response_max_age_secs: DEFAULT_RESPONSE_MAX_AGE_SECS, + signature_config: SignatureStorageConfig::default(), + } + } +} + +struct PeerState { + pending_set_webhook_requests: HashMap, // RequestId -> (app_name, webhook_url, timestamp) + pending_list_webhooks_requests: HashMap, // RequestId -> timestamp + pending_remove_webhook_requests: HashMap, // RequestId -> (app_name, timestamp) + last_cleanup: Option, // Seconds since epoch +} + +impl PeerState { + fn new() -> Self { + Self { + pending_set_webhook_requests: new_hash_map(), + pending_list_webhooks_requests: new_hash_map(), + pending_remove_webhook_requests: new_hash_map(), + last_cleanup: None, + } + } + fn cleanup_expired_responses( + &mut self, max_age_secs: u64, time_provider: Arc, + ) { + let now = time_provider.duration_since_epoch(); + + // Only run cleanup once per minute to avoid excessive processing + if let Some(last_cleanup) = self.last_cleanup { + match now.checked_sub(last_cleanup) { + Some(elapsed) if elapsed < Duration::from_secs(60) => return, + None => return, + _ => {}, + } + } + + self.last_cleanup = Some(now); + + let cutoff = match u64::try_into(max_age_secs) { + Ok(secs) => match now.checked_sub(Duration::from_secs(secs)) { + Some(time) => time, + None => return, + }, + Err(_) => { + // If conversion fails, use a safe default + match now.checked_sub(Duration::from_secs(3600)) { + Some(time) => time, + None => return, + } + }, + }; + + self.pending_set_webhook_requests.retain(|_, (_, _, timestamp)| *timestamp > cutoff); + self.pending_list_webhooks_requests.retain(|_, timestamp| *timestamp > cutoff); + self.pending_remove_webhook_requests.retain(|_, (_, timestamp)| *timestamp > cutoff); + } +} + +/// LSPS5 client handler +pub struct LSPS5ClientHandler +where + ES::Target: EntropySource, +{ + /// Pending messages to be sent + pending_messages: Arc, + /// Event queue for emitting events + pending_events: Arc, + /// Entropy source + entropy_source: ES, + /// Per peer state for tracking requests + per_peer_state: RwLock>>, + /// Client configuration + config: LSPS5ClientConfig, + /// Time provider for LSPS5 service + time_provider: Arc, + /// Map of recently used signatures to prevent replay attacks + recent_signatures: Arc>>, +} + +impl LSPS5ClientHandler +where + ES::Target: EntropySource, +{ + /// Creates a new LSPS5 client handler with the provided entropy source, message queue, + /// event queue, and LSPS5ClientConfig + #[cfg(feature = "time")] + pub(crate) fn new( + entropy_source: ES, pending_messages: Arc, pending_events: Arc, + config: LSPS5ClientConfig, + ) -> Option { + let max_signatures = config.signature_config.max_signatures.clone(); + Some(Self { + pending_messages, + pending_events, + entropy_source, + per_peer_state: RwLock::new(new_hash_map()), + config, + time_provider: Arc::new(DefaultTimeProvider), + recent_signatures: Arc::new(Mutex::new(VecDeque::with_capacity(max_signatures))), + }) + } + + pub(crate) fn _new_with_custom_time_provider( + entropy_source: ES, pending_messages: Arc, pending_events: Arc, + config: LSPS5ClientConfig, time_provider: Option>, + ) -> Option { + let max_signatures = config.signature_config.max_signatures.clone(); + let time_provider = match time_provider { + Some(provider) => provider, + None => return None, + }; + Some(Self { + pending_messages, + pending_events, + entropy_source, + per_peer_state: RwLock::new(new_hash_map()), + config, + time_provider, + recent_signatures: Arc::new(Mutex::new(VecDeque::with_capacity(max_signatures))), + }) + } + + fn with_peer_state( + &self, counterparty_node_id: PublicKey, f: F, + ) -> Result + where + F: FnOnce(&mut PeerState) -> R, + { + let mut outer_state_lock = self.per_peer_state.write().map_err(|_| LightningError { + err: "Failed to execute write on per_peer_state".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + let inner_state_lock = + outer_state_lock.entry(counterparty_node_id).or_insert(Mutex::new(PeerState::new())); + let mut peer_state_lock = inner_state_lock.lock().map_err(|_| LightningError { + err: "Failed to lock inner_state_lock mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + peer_state_lock.cleanup_expired_responses( + self.config.response_max_age_secs, + Arc::clone(&self.time_provider), + ); + + Ok(f(&mut *peer_state_lock)) + } + + /// Register a webhook with the LSP + /// + /// Implements the `lsps5.set_webhook` method from bLIP-55. + /// + /// # Parameters + /// * `app_name` - A human-readable UTF-8 string that gives a name to the webhook (max 64 bytes) + /// * `webhook` - The URL of the webhook that the LSP can use to push notifications (max 1024 chars) + /// + /// # Returns + /// * Success - the request ID that was used + /// * Error - validation error or error sending the request + /// + /// Response will be provided asynchronously through the event queue as a + /// WebhookRegistered or WebhookRegistrationFailed event. + pub fn set_webhook( + &self, counterparty_node_id: PublicKey, app_name: String, webhook_url: String, + ) -> Result { + let app_name = LSPS5AppName::new(app_name).map_err(|e| LightningError { + err: e.message, + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let lsps_webhook_url = LSPS5WebhookUrl::new(webhook_url).map_err(|e| LightningError { + err: e.message, + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let request_id = generate_request_id(&self.entropy_source); + + self.with_peer_state(counterparty_node_id, |peer_state| { + peer_state.pending_set_webhook_requests.insert( + request_id.clone(), + ( + app_name.clone(), + lsps_webhook_url.clone(), + self.time_provider.duration_since_epoch(), + ), + ); + })?; + + let request = + LSPS5Request::SetWebhook(SetWebhookRequest { app_name, webhook: lsps_webhook_url }); + + let message = LSPS5Message::Request(request_id.clone(), request); + self.pending_messages.enqueue(&counterparty_node_id, LSPSMessage::LSPS5(message)); + + Ok(request_id) + } + + /// List all registered webhooks + /// + /// Implements the `lsps5.list_webhooks` method from bLIP-55. + /// + /// # Returns + /// * Success - the request ID that was used + /// * Error - error sending the request + /// + /// Response will be provided asynchronously through the event queue as a + /// WebhooksListed or WebhooksListFailed event. + pub fn list_webhooks( + &self, counterparty_node_id: PublicKey, + ) -> Result { + let request_id = generate_request_id(&self.entropy_source); + let now = self.time_provider.duration_since_epoch(); + + self.with_peer_state(counterparty_node_id, |peer_state| { + peer_state.pending_list_webhooks_requests.insert(request_id.clone(), now); + })?; + + let request = LSPS5Request::ListWebhooks(ListWebhooksRequest {}); + let message = LSPS5Message::Request(request_id.clone(), request); + self.pending_messages.enqueue(&counterparty_node_id, LSPSMessage::LSPS5(message)); + + Ok(request_id) + } + + /// Remove a webhook by app_name + /// + /// Implements the `lsps5.remove_webhook` method from bLIP-55. + /// + /// # Parameters + /// * `app_name` - The name of the webhook to remove + /// + /// # Returns + /// * Success - the request ID that was used + /// * Error - error sending the request + /// + /// Response will be provided asynchronously through the event queue as a + /// WebhookRemoved or WebhookRemovalFailed event. + pub fn remove_webhook( + &self, counterparty_node_id: PublicKey, app_name: String, + ) -> Result { + let app_name = LSPS5AppName::new(app_name).map_err(|e| LightningError { + err: e.message, + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let request_id = generate_request_id(&self.entropy_source); + let now = self.time_provider.duration_since_epoch(); + + self.with_peer_state(counterparty_node_id, |peer_state| { + peer_state + .pending_remove_webhook_requests + .insert(request_id.clone(), (app_name.clone(), now)); + })?; + + let request = LSPS5Request::RemoveWebhook(RemoveWebhookRequest { app_name }); + let message = LSPS5Message::Request(request_id.clone(), request); + self.pending_messages.enqueue(&counterparty_node_id, LSPSMessage::LSPS5(message)); + + Ok(request_id) + } + + /// Handle received messages from the LSP + pub fn handle_message( + &self, message: LSPS5Message, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + 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_code: error.code, + error_message: error.message, + app_name, + url: webhook_url, + request_id, + }, + ); + result = Ok(()); + }, + _ => { + result = Err(LightningError { + err: "Unexpected response type for SetWebhook request" + .to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + }); + }, + } + } else if peer_state + .pending_list_webhooks_requests + .remove(&request_id) + .is_some() + { + match response { + LSPS5Response::ListWebhooks(response) => { + 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_code: error.code, + error_message: error.message, + request_id, + }); + result = Ok(()); + }, + _ => { + result = Err(LightningError { + err: "Unexpected response type for ListWebhooks request" + .to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + }); + }, + } + } else if let Some((app_name, _)) = + peer_state.pending_remove_webhook_requests.remove(&request_id) + { + match response { + LSPS5Response::RemoveWebhook(_) => { + 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_code: error.code, + error_message: error.message, + app_name, + request_id, + }, + ); + result = Ok(()); + }, + _ => { + result = Err(LightningError { + err: "Unexpected response type for RemoveWebhook request" + .to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + }); + }, + } + } else { + result = Err(LightningError { + err: format!( + "Received response for unknown request ID: {}", + request_id.0 + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + })?; + + result + }, + LSPS5Message::Request(_, _) => { + // We're a client, so we don't expect to receive requests + Err(LightningError { + err: format!( + "Received unexpected request message from {}", + counterparty_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Info), + }) + }, + } + } + + /// Verify a webhook notification signature from an LSP + /// + /// This can be used by a notification delivery service to verify + /// the authenticity of notifications received from an LSP. + /// + /// # Parameters + /// * `timestamp` - The ISO8601 timestamp from the notification + /// * `signature` - The signature string from the notification + /// * `notification` - The webhook notification object + /// + /// # Returns + /// * On success: `true` if the signature is valid + /// * On error: LightningError with error description + pub fn verify_notification_signature( + &self, counterparty_node_id: PublicKey, timestamp: &str, signature: &str, + notification: &WebhookNotification, + ) -> Result { + LSPSDateTime::from_str(timestamp) + .map_err(|_| LightningError { + err: format!("Invalid timestamp format: {}", timestamp), + action: ErrorAction::IgnoreAndLog(Level::Error), + }) + .and_then(|signature_timestamp| { + let now = LSPSDateTime::from(self.time_provider.duration_since_epoch()); + let diff = signature_timestamp.abs_diff(&now); + + if diff > 600 { + return Err(LightningError { + err: format!("Timestamp too old: {}", timestamp), + action: ErrorAction::IgnoreAndLog(Level::Error), + }); + } + + Ok(()) + })?; + + let notification_json = + serde_json::to_string(notification).map_err(|e| LightningError { + err: format!("Failed to serialize notification: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let message = format!( + "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + timestamp, notification_json + ); + + if message_signing::verify(message.as_bytes(), signature, &counterparty_node_id) { + Ok(true) + } else { + Err(LightningError { + err: "Invalid signature".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + }) + } + } + + /// Check if a signature has been used before + fn check_signature_exists(&self, signature: &str) -> Result<(), LightningError> { + let recent_signatures = self.recent_signatures.lock().map_err(|_| LightningError { + err: "Failed to lock recent_signatures mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + for (stored_sig, _) in recent_signatures.iter() { + if stored_sig == signature { + return Err(LightningError { + err: "Replay attack detected: signature has been used before".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Warn), + }); + } + } + + Ok(()) + } + + /// Store a signature with timestamp for replay attack prevention + fn store_signature(&self, signature: String) -> Result<(), LightningError> { + let now = self.time_provider.duration_since_epoch(); + let mut recent_signatures = self.recent_signatures.lock().map_err(|_| LightningError { + err: "Failed to lock recent_signatures mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + recent_signatures.push_back((signature, now)); + + let retention_duration = + Duration::from_secs(self.config.signature_config.retention_minutes * 60); + while let Some((_, time)) = recent_signatures.front() { + if now.checked_sub(*time).map_or(false, |duration| duration > retention_duration) { + recent_signatures.pop_front(); + } else { + break; + } + } + + while recent_signatures.len() > self.config.signature_config.max_signatures { + recent_signatures.pop_front(); + } + + Ok(()) + } + + /// Parse a webhook notification received from an LSP + /// + /// This can be used by a client implementation to handle webhook + /// notifications after they're delivered through a push notification + /// system. + /// + /// # Parameters + /// * `timestamp` - The ISO8601 timestamp from the notification + /// * `signature` - The signature from the notification + /// * `notification_json` - The JSON string of the notification object + /// + /// # Returns + /// * On success: The parsed webhook notification + /// * On error: LightningError with error description + pub fn parse_webhook_notification( + &self, counterparty_node_id: PublicKey, timestamp: &str, signature: &str, + notification_json: &str, + ) -> Result { + let 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.to_string(), + signature_valid, + }); + Ok(notification) + }, + Err(e) => Err(e), + } + } +} + +impl LSPSProtocolMessageHandler for LSPS5ClientHandler +where + ES::Target: EntropySource, +{ + type ProtocolMessage = LSPS5Message; + const PROTOCOL_NUMBER: Option = Some(5); + + fn handle_message( + &self, message: Self::ProtocolMessage, lsp_node_id: &PublicKey, + ) -> Result<(), LightningError> { + self.handle_message(message, lsp_node_id) + } +} + +#[cfg(test)] +mod tests { + #![cfg(all(test, feature = "time"))] + use super::*; + use crate::{ + lsps0::ser::LSPSRequestId, lsps5::msgs::SetWebhookResponse, tests::utils::TestEntropy, + }; + use bitcoin::{key::Secp256k1, secp256k1::SecretKey}; + + fn setup_test_client() -> ( + LSPS5ClientHandler>, + Arc, + Arc, + PublicKey, + PublicKey, + ) { + let test_entropy_source = Arc::new(TestEntropy {}); + let message_queue = Arc::new(MessageQueue::new()); + let event_queue = Arc::new(EventQueue::new()); + + let client = LSPS5ClientHandler::new( + test_entropy_source, + message_queue.clone(), + event_queue.clone(), + LSPS5ClientConfig::default(), + ) + .unwrap(); + + let secp = Secp256k1::new(); + let secret_key_1 = SecretKey::from_slice(&[42u8; 32]).unwrap(); + let secret_key_2 = SecretKey::from_slice(&[43u8; 32]).unwrap(); + let peer_1 = PublicKey::from_secret_key(&secp, &secret_key_1); + let peer_2 = PublicKey::from_secret_key(&secp, &secret_key_2); + + (client, message_queue, event_queue, peer_1, peer_2) + } + + #[test] + fn test_per_peer_state_isolation() { + let (client, _, _, peer_1, peer_2) = setup_test_client(); + + let req_id_1 = client + .set_webhook(peer_1, "test-app-1".to_string(), "https://example.com/hook1".to_string()) + .unwrap(); + let req_id_2 = client + .set_webhook(peer_2, "test-app-2".to_string(), "https://example.com/hook2".to_string()) + .unwrap(); + + { + let outer_state_lock = client.per_peer_state.read().unwrap(); + + let peer_1_state = outer_state_lock.get(&peer_1).unwrap().lock().unwrap(); + assert!(peer_1_state.pending_set_webhook_requests.contains_key(&req_id_1)); + + let peer_2_state = outer_state_lock.get(&peer_2).unwrap().lock().unwrap(); + assert!(peer_2_state.pending_set_webhook_requests.contains_key(&req_id_2)); + } + } + + #[test] + fn test_pending_request_tracking() { + let (client, _, _, peer, _) = setup_test_client(); + const APP_NAME: &str = "test-app"; + const WEBHOOK_URL: &str = "https://example.com/hook"; + let lsps5_app_name = LSPS5AppName::new(APP_NAME.to_string()).unwrap(); + let lsps5_webhook_url = LSPS5WebhookUrl::new(WEBHOOK_URL.to_string()).unwrap(); + let set_req_id = + client.set_webhook(peer, APP_NAME.to_string(), WEBHOOK_URL.to_string()).unwrap(); + let list_req_id = client.list_webhooks(peer).unwrap(); + let remove_req_id = client.remove_webhook(peer, "test-app".to_string()).unwrap(); + + { + let outer_state_lock = client.per_peer_state.read().unwrap(); + let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap(); + assert_eq!( + peer_state.pending_set_webhook_requests.get(&set_req_id).unwrap(), + &( + lsps5_app_name.clone(), + lsps5_webhook_url, + peer_state.pending_set_webhook_requests.get(&set_req_id).unwrap().2.clone() + ) + ); + + assert!(peer_state.pending_list_webhooks_requests.contains_key(&list_req_id)); + + assert_eq!( + peer_state.pending_remove_webhook_requests.get(&remove_req_id).unwrap().0, + lsps5_app_name + ); + } + } + + #[test] + fn test_handle_response_clears_pending_state() { + let (client, _, _, peer, _) = setup_test_client(); + + let req_id = client + .set_webhook(peer, "test-app".to_string(), "https://example.com/hook".to_string()) + .unwrap(); + + let response = LSPS5Response::SetWebhook(SetWebhookResponse { + num_webhooks: 1, + max_webhooks: 5, + no_change: false, + }); + let response_msg = LSPS5Message::Response(req_id.clone(), response); + + { + let outer_state_lock = client.per_peer_state.read().unwrap(); + let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap(); + assert!(peer_state.pending_set_webhook_requests.contains_key(&req_id)); + } + + client.handle_message(response_msg, &peer).unwrap(); + + { + let outer_state_lock = client.per_peer_state.read().unwrap(); + let peer_state = outer_state_lock.get(&peer).unwrap().lock().unwrap(); + assert!(!peer_state.pending_set_webhook_requests.contains_key(&req_id)); + } + } + + #[test] + fn test_cleanup_expired_responses() { + let (client, _, _, _, _) = setup_test_client(); + let time_provider = &client.time_provider; + const OLD_APP_NAME: &str = "test-app-old"; + const NEW_APP_NAME: &str = "test-app-new"; + const WEBHOOK_URL: &str = "https://example.com/hook"; + let lsps5_old_app_name = LSPS5AppName::new(OLD_APP_NAME.to_string()).unwrap(); + let lsps5_new_app_name = LSPS5AppName::new(NEW_APP_NAME.to_string()).unwrap(); + let lsps5_webhook_url = LSPS5WebhookUrl::new(WEBHOOK_URL.to_string()).unwrap(); + let now = time_provider.duration_since_epoch(); + let mut peer_state = PeerState::new(); + peer_state.last_cleanup = Some(now.checked_sub(Duration::from_secs(120)).unwrap()); + + let old_request_id = LSPSRequestId("test:request:old".to_string()); + let new_request_id = LSPSRequestId("test:request:new".to_string()); + + // Add an old request (should be removed during cleanup) + peer_state.pending_set_webhook_requests.insert( + old_request_id.clone(), + ( + lsps5_old_app_name, + lsps5_webhook_url.clone(), + now.checked_sub(Duration::from_secs(7200)).unwrap(), + ), // 2 hours old + ); + + // Add a recent request (should be kept) + peer_state.pending_set_webhook_requests.insert( + new_request_id.clone(), + ( + lsps5_new_app_name, + lsps5_webhook_url, + now.checked_sub(Duration::from_secs(600)).unwrap(), + ), // 10 minutes old + ); + + peer_state.cleanup_expired_responses(1800, time_provider.clone()); + + assert!(!peer_state.pending_set_webhook_requests.contains_key(&old_request_id)); + let cleanup_age = if let Some(last_cleanup) = peer_state.last_cleanup { + time_provider.duration_since_epoch().checked_sub(last_cleanup).unwrap() + } else { + Duration::from_secs(0) + }; + assert!(cleanup_age < Duration::from_secs(10)); + } + + #[test] + fn test_unknown_request_id_handling() { + let (client, _message_queue, _, peer, _) = setup_test_client(); + + let _valid_req = client + .set_webhook(peer, "test-app".to_string(), "https://example.com/hook".to_string()) + .unwrap(); + + let unknown_req_id = LSPSRequestId("unknown:request:id".to_string()); + let response = LSPS5Response::SetWebhook(SetWebhookResponse { + num_webhooks: 1, + max_webhooks: 5, + no_change: false, + }); + let response_msg = LSPS5Message::Response(unknown_req_id, response); + + let result = client.handle_message(response_msg, &peer); + assert!(result.is_err()); + let error = result.unwrap_err(); + assert!(error.err.to_lowercase().contains("unknown request id")); + } +} diff --git a/lightning-liquidity/src/lsps5/event.rs b/lightning-liquidity/src/lsps5/event.rs new file mode 100644 index 00000000000..54eca659697 --- /dev/null +++ b/lightning-liquidity/src/lsps5/event.rs @@ -0,0 +1,232 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Events generated by the LSPS5 service and client + +use crate::lsps0::ser::LSPSRequestId; +use crate::prelude::String; +use crate::prelude::Vec; +use bitcoin::secp256k1::PublicKey; + +use super::msgs::LSPS5AppName; +use super::msgs::LSPS5WebhookUrl; +use super::msgs::WebhookNotification; + +/// An event which an bLIP-55 / LSPS5 server should take some action in response to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LSPS5ServiceEvent { + /// A webhook was registered by a client + /// + /// This event occurs when a client successfully registers a webhook via `lsps5.set_webhook`. + /// You should store this information to be able to contact the client when they are offline. + WebhookRegistered { + /// Client node ID that registered the webhook + counterparty_node_id: PublicKey, + /// App name provided by the client (up to 64 bytes in UTF-8 format) + app_name: LSPS5AppName, + /// Webhook URL (HTTPS) that should be contacted to notify the client (up to 1024 ASCII characters) + url: LSPS5WebhookUrl, + /// The identifier of the issued bLIP-55 / LSPS5 webhook registration request + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + /// Whether this was a new registration or an update to existing one with no changes + /// If false, a notification should be sent to the registered webhook + no_change: bool, + }, + + /// Webhooks were listed for a client + /// + /// This event occurs when a client requests their registered webhooks via `lsps5.list_webhooks`. + WebhooksListed { + /// Client node ID that requested their webhooks + counterparty_node_id: PublicKey, + /// App names with registered webhooks for this client + app_names: Vec, + /// The identifier of the issued bLIP-55 / LSPS5 webhook listing request + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + /// Maximum number of webhooks allowed by LSP per client + max_webhooks: u32, + }, + + /// A webhook was removed by a client + /// + /// This event occurs when a client successfully removes a webhook via `lsps5.remove_webhook`. + WebhookRemoved { + /// Client node ID that removed the webhook + counterparty_node_id: PublicKey, + /// App name that was removed + app_name: LSPS5AppName, + /// The identifier of the issued bLIP-55 / LSPS5 webhook removal request + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// This event occurs when the LSP needs to send a notification to a client's webhook. + /// + /// When this event is received, the LSP should: + /// 1. Serialize the notification to JSON + /// 2. Make an HTTP POST request to the provided URL with the given headers and the serialized notification + /// + /// When the client receives this notification, they will process it and generate a + /// `WebhookNotificationReceived` event on their side. The client will validate the + /// signature using the LSP's node ID to ensure the notification is authentic. + SendWebhookNotifications { + /// Client node ID to be notified + counterparty_node_id: PublicKey, + /// App name to be notified + app_name: LSPS5AppName, + /// URL that to be contacted + url: LSPS5WebhookUrl, + /// Notification method with its parameters + notification: WebhookNotification, + /// ISO8601 timestamp of the notification (YYYY-MM-DDThh:mm:ss.uuuZ format) + timestamp: String, + /// Signature of the notification using the LSP's node ID + signature: String, + /// Headers to be included in the HTTP POST request + headers: Vec<(String, String)>, + }, +} + +/// An event which an LSPS5 client should take some action in response to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LSPS5ClientEvent { + /// A webhook was successfully registered with the LSP + /// + /// This event is triggered when the LSP confirms successful registration + /// of a webhook via `lsps5.set_webhook`. + WebhookRegistered { + /// The node id of the LSP that confirmed the registration + counterparty_node_id: PublicKey, + /// Current number of webhooks registered for this client + num_webhooks: u32, + /// Maximum number of webhooks allowed by LSP + max_webhooks: u32, + /// Whether this was an unchanged registration (same app_name and URL) + /// If true, the LSP didn't send a webhook notification for this registration + no_change: bool, + /// The app name that was registered (up to 64 bytes in UTF-8 format) + app_name: LSPS5AppName, + /// The webhook URL that was registered (HTTPS protocol) + url: LSPS5WebhookUrl, + /// The identifier of the issued bLIP-55 / LSPS5 webhook registration request + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// A webhook registration attempt failed + /// + /// This event is triggered when the LSP rejects a webhook registration + /// via `lsps5.set_webhook`. This can happen if the app_name or URL is too long, + /// the URL uses an unsupported protocol, or the maximum number of webhooks is reached. + WebhookRegistrationFailed { + /// The node id of the LSP that rejected the registration + counterparty_node_id: PublicKey, + /// Error code from the LSP (500: too_long, 501: url_parse_error, + /// 502: unsupported_protocol, 503: too_many_webhooks) + error_code: i32, + /// Error message from the LSP + error_message: String, + /// The app name that was attempted + app_name: LSPS5AppName, + /// The webhook URL that was attempted + url: LSPS5WebhookUrl, + /// The identifier of the issued bLIP-55 / LSPS5 webhook registration request + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// The list of registered webhooks was successfully retrieved + /// + /// This event is triggered when the LSP responds to a `lsps5.list_webhooks` request. + WebhooksListed { + /// The node id of the LSP that provided the list + counterparty_node_id: PublicKey, + /// List of app names with registered webhooks + app_names: Vec, + /// Maximum number of webhooks allowed by LSP + max_webhooks: u32, + /// The identifier of the issued bLIP-55 / LSPS5 list webhooks request + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// The attempt to list webhooks failed + /// + /// This event is triggered when the LSP rejects a `lsps5.list_webhooks` request. + WebhooksListFailed { + /// The node id of the LSP that rejected the request + counterparty_node_id: PublicKey, + /// Error code from the LSP + error_code: i32, + /// Error message from the LSP + error_message: String, + /// The identifier of the issued bLIP-55 / LSPS5 list webhooks request + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// A webhook was successfully removed + /// + /// This event is triggered when the LSP confirms successful removal + /// of a webhook via `lsps5.remove_webhook`. + WebhookRemoved { + /// The node id of the LSP that confirmed the removal + counterparty_node_id: PublicKey, + /// The app name that was removed + app_name: LSPS5AppName, + /// The identifier of the issued bLIP-55 / LSPS5 remove webhook request + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// A webhook removal attempt failed + /// + /// This event is triggered when the LSP rejects a webhook removal + /// via `lsps5.remove_webhook`. The most common error is app_name_not_found (1010). + WebhookRemovalFailed { + /// The node id of the LSP that rejected the removal + counterparty_node_id: PublicKey, + /// Error code from the LSP (1010: app_name_not_found) + error_code: i32, + /// Error message from the LSP + error_message: String, + /// The app name that was attempted to be removed + app_name: LSPS5AppName, + /// The identifier of the issued bLIP-55 / LSPS5 remove webhook request + /// + /// This can be used to track which request this event corresponds to. + request_id: LSPSRequestId, + }, + + /// A webhook notification was received from the LSP + /// + /// This event is triggered when the client receives a webhook notification + /// from the LSP. This can happen for various reasons such as incoming payment, + /// expiring HTLCs, liquidity management requests, or incoming onion messages. + WebhookNotificationReceived { + /// LSP node ID that sent the notification + counterparty_node_id: PublicKey, + /// The notification with its method and parameters + notification: WebhookNotification, + /// Timestamp of the notification in ISO8601 format (YYYY-MM-DDThh:mm:ss.uuuZ) + timestamp: String, + /// Whether the LSP's signature was successfully verified + signature_valid: bool, + }, +} 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..df44fd84ea9 --- /dev/null +++ b/lightning-liquidity/src/lsps5/msgs.rs @@ -0,0 +1,733 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! LSPS5 message formats for webhook registration + +use core::fmt; +use core::fmt::Display; + +use crate::lsps0::ser::LSPSMessage; +use crate::lsps0::ser::LSPSRequestId; +use crate::lsps0::ser::LSPSResponseError; +use crate::prelude::*; +use serde::ser::SerializeMap; +use serde::{Deserialize, Serialize}; + +use super::url_utils::LSPSUrl; + +/// Maximum allowed length for an app_name (in bytes) +pub const MAX_APP_NAME_LENGTH: usize = 64; + +/// Maximum allowed length for a webhook URL (in characters) +pub const MAX_WEBHOOK_URL_LENGTH: usize = 1024; + +pub(crate) const LSPS5_TOO_LONG_ERROR_CODE: i32 = 500; +pub(crate) const LSPS5_URL_PARSE_ERROR_CODE: i32 = 501; +pub(crate) const LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE: i32 = 502; +pub(crate) const LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE: i32 = 503; +pub(crate) const LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE: i32 = 1010; + +pub(crate) const LSPS5_SET_WEBHOOK_METHOD_NAME: &str = "lsps5.set_webhook"; +pub(crate) const LSPS5_LIST_WEBHOOKS_METHOD_NAME: &str = "lsps5.list_webhooks"; +pub(crate) const LSPS5_REMOVE_WEBHOOK_METHOD_NAME: &str = "lsps5.remove_webhook"; + +pub(crate) const LSPS5_WEBHOOK_REGISTERED: &str = "lsps5.webhook_registered"; +pub(crate) const LSPS5_PAYMENT_INCOMING: &str = "lsps5.payment_incoming"; +pub(crate) const LSPS5_EXPIRY_SOON: &str = "lsps5.expiry_soon"; +pub(crate) const LSPS5_LIQUIDITY_MANAGEMENT_REQUEST: &str = "lsps5.liquidity_management_request"; +pub(crate) const LSPS5_ONION_MESSAGE_INCOMING: &str = "lsps5.onion_message_incoming"; + +/// 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, +} + +impl WebhookNotificationMethod { + /// Extract parameters for JSON serialization + pub fn parameters_json_value(&self) -> serde_json::Value { + match self { + Self::LSPS5WebhookRegistered => serde_json::json!({}), + Self::LSPS5PaymentIncoming => serde_json::json!({}), + Self::LSPS5ExpirySoon { timeout } => serde_json::json!({ "timeout": timeout }), + Self::LSPS5LiquidityManagementRequest => serde_json::json!({}), + Self::LSPS5OnionMessageIncoming => serde_json::json!({}), + } + } +} + +impl Serialize for WebhookNotificationMethod { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::LSPS5WebhookRegistered => serializer.serialize_str(LSPS5_WEBHOOK_REGISTERED), + Self::LSPS5PaymentIncoming => serializer.serialize_str(LSPS5_PAYMENT_INCOMING), + Self::LSPS5ExpirySoon { .. } => serializer.serialize_str(LSPS5_EXPIRY_SOON), + Self::LSPS5LiquidityManagementRequest => { + serializer.serialize_str(LSPS5_LIQUIDITY_MANAGEMENT_REQUEST) + }, + Self::LSPS5OnionMessageIncoming => { + serializer.serialize_str(LSPS5_ONION_MESSAGE_INCOMING) + }, + } + } +} + +impl<'de> Deserialize<'de> for WebhookNotificationMethod { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + LSPS5_WEBHOOK_REGISTERED => Ok(Self::LSPS5WebhookRegistered), + LSPS5_PAYMENT_INCOMING => Ok(Self::LSPS5PaymentIncoming), + LSPS5_EXPIRY_SOON => { + // Default timeout when deserializing without params + // The actual timeout will be set from the params field later + Ok(Self::LSPS5ExpirySoon { timeout: 0 }) + }, + LSPS5_LIQUIDITY_MANAGEMENT_REQUEST => Ok(Self::LSPS5LiquidityManagementRequest), + LSPS5_ONION_MESSAGE_INCOMING => Ok(Self::LSPS5OnionMessageIncoming), + _ => { + Err(serde::de::Error::custom(format!("Unknown webhook notification method: {}", s))) + }, + } + } +} + +/// App name for LSPS5 webhooks (max 64 bytes UTF-8) +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LSPS5AppName(String); + +impl LSPS5AppName { + /// Create a new LSPS5 app name + pub fn new(app_name: String) -> Result { + let lsps5_app_name = Self(app_name); + + match lsps5_app_name.validate() { + Ok(()) => Ok(lsps5_app_name), + Err(e) => Err(e), + } + } + + /// Validate the app name + pub fn validate(&self) -> Result<(), LSPSResponseError> { + if self.0.len() > MAX_APP_NAME_LENGTH { + return Err(LSPSResponseError { + code: LSPS5_TOO_LONG_ERROR_CODE, + message: format!( + "App name exceeds maximum length of {} bytes", + MAX_APP_NAME_LENGTH + ), + data: None, + }); + } + + Ok(()) + } +} + +impl Display for LSPS5AppName { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Serialize for LSPS5AppName { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for LSPS5AppName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::new(s).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) + } +} + +impl AsRef for LSPS5AppName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for String { + fn from(app_name: LSPS5AppName) -> Self { + app_name.0 + } +} + +/// URL for LSPS5 webhooks (max 1024 ASCII chars) +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LSPS5WebhookUrl(LSPSUrl); + +impl LSPS5WebhookUrl { + /// Create a new LSPS5 webhook URL + pub fn new(url: String) -> Result { + let parsed_url = match LSPSUrl::parse(&url) { + Ok(url) => url, + Err(e) => { + return Err(LSPSResponseError { + code: LSPS5_URL_PARSE_ERROR_CODE, + message: format!("Error parsing URL: {:?}", e), + data: None, + }); + }, + }; + + let lsps5_webhook_url = Self(parsed_url); + + match lsps5_webhook_url.validate() { + Ok(()) => Ok(lsps5_webhook_url), + Err(e) => Err(e), + } + } + + /// Validate the URL + pub fn validate(&self) -> Result<(), LSPSResponseError> { + let url_str = self.0.url(); + + if url_str.len() > MAX_WEBHOOK_URL_LENGTH { + return Err(LSPSResponseError { + code: LSPS5_TOO_LONG_ERROR_CODE, + message: format!( + "Webhook URL exceeds maximum length of {} bytes", + MAX_WEBHOOK_URL_LENGTH + ), + data: None, + }); + } + + if !url_str.is_ascii() { + return Err(LSPSResponseError { + code: LSPS5_URL_PARSE_ERROR_CODE, + message: "Webhook URL must be ASCII".to_string(), + data: None, + }); + } + + if self.0.scheme() != "https" { + return Err(LSPSResponseError { + code: LSPS5_UNSUPPORTED_PROTOCOL_ERROR_CODE, + message: format!("Unsupported protocol: {}. HTTPS is required.", self.0.scheme()), + data: None, + }); + } + + // Check that URL has a host + if self.0.host().is_none() { + return Err(LSPSResponseError { + code: LSPS5_URL_PARSE_ERROR_CODE, + message: "URL must have a host".to_string(), + data: None, + }); + } + + // Check for localhost and private IPs + if let Some(host) = self.0.host_str() { + if host == "localhost" || host.starts_with("127.") || host == "::1" { + return Err(LSPSResponseError { + code: LSPS5_URL_PARSE_ERROR_CODE, + message: "URL must not point to localhost".to_string(), + data: None, + }); + } + + if host.starts_with("10.") + || host.starts_with("192.168.") + || (host.starts_with("172.") && { + if let Some(second_octet) = host.split('.').nth(1) { + if let Ok(num) = second_octet.parse::() { + (16..=31).contains(&num) + } else { + false + } + } else { + false + } + }) { + return Err(LSPSResponseError { + code: LSPS5_URL_PARSE_ERROR_CODE, + message: "URL must not point to private IP ranges".to_string(), + data: None, + }); + } + } + + Ok(()) + } + + /// Get the URL as a string + pub fn as_str(&self) -> &str { + self.0.url() + } +} + +impl Serialize for LSPS5WebhookUrl { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.0.url()) + } +} + +impl<'de> Deserialize<'de> for LSPS5WebhookUrl { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::new(s).map_err(|e| serde::de::Error::custom(format!("{:?}", e))) + } +} + +impl AsRef for LSPS5WebhookUrl { + fn as_ref(&self) -> &str { + self.0.url() + } +} + +impl From for String { + fn from(url: LSPS5WebhookUrl) -> Self { + url.0.url().to_string() + } +} + +/// Parameters for `lsps5.set_webhook` request +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SetWebhookRequest { + /// Human-readable name for the webhook (max 64 bytes) + pub app_name: LSPS5AppName, + /// URL of the webhook (max 1024 ASCII chars) + pub webhook: LSPS5WebhookUrl, +} + +/// Response for `lsps5.set_webhook` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SetWebhookResponse { + /// Current number of webhooks registered for this client + pub num_webhooks: u32, + /// Maximum number of webhooks allowed by LSP + pub max_webhooks: u32, + /// Whether this is an unchanged registration + pub no_change: bool, +} + +/// Parameters for `lsps5.list_webhooks` request (empty) +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct ListWebhooksRequest {} + +/// Response for `lsps5.list_webhooks` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ListWebhooksResponse { + /// List of app_names with registered webhooks + pub app_names: Vec, + /// Maximum number of webhooks allowed by LSP + pub max_webhooks: u32, +} + +/// Parameters for `lsps5.remove_webhook` request +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RemoveWebhookRequest { + /// App name identifying the webhook to remove + pub app_name: LSPS5AppName, +} + +/// Response for `lsps5.remove_webhook` (empty) +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct RemoveWebhookResponse {} + +/// Webhook notification payload +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WebhookNotification { + /// JSON-RPC version (must be "2.0") + pub jsonrpc: String, + /// Notification method with parameters + pub method: WebhookNotificationMethod, +} + +impl WebhookNotification { + /// Create a new webhook notification + pub fn new(method: WebhookNotificationMethod) -> Self { + Self { jsonrpc: "2.0".to_string(), method } + } + + /// Create a webhook_registered notification + pub fn webhook_registered() -> Self { + Self::new(WebhookNotificationMethod::LSPS5WebhookRegistered) + } + + /// Create a payment_incoming notification + pub fn payment_incoming() -> Self { + Self::new(WebhookNotificationMethod::LSPS5PaymentIncoming) + } + + /// Create an expiry_soon notification + pub fn expiry_soon(timeout: u32) -> Self { + Self::new(WebhookNotificationMethod::LSPS5ExpirySoon { timeout }) + } + + /// Create a liquidity_management_request notification + pub fn liquidity_management_request() -> Self { + Self::new(WebhookNotificationMethod::LSPS5LiquidityManagementRequest) + } + + /// Create an onion_message_incoming notification + pub fn onion_message_incoming() -> Self { + Self::new(WebhookNotificationMethod::LSPS5OnionMessageIncoming) + } +} + +impl Serialize for WebhookNotification { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let mut map = serializer.serialize_map(Some(3))?; + map.serialize_entry("jsonrpc", &self.jsonrpc)?; + map.serialize_entry("method", &self.method)?; + map.serialize_entry("params", &self.method.parameters_json_value())?; + map.end() + } +} + +impl<'de> Deserialize<'de> for WebhookNotification { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Helper { + jsonrpc: String, + method: WebhookNotificationMethod, + params: serde_json::Value, + } + + let helper = Helper::deserialize(deserializer)?; + + // Now update the method with parameters from the params field + let method = match helper.method { + WebhookNotificationMethod::LSPS5ExpirySoon { .. } => { + if let Some(timeout) = helper.params.get("timeout").and_then(|t| t.as_u64()) { + WebhookNotificationMethod::LSPS5ExpirySoon { timeout: timeout as u32 } + } else { + return Err(serde::de::Error::custom( + "Missing or invalid timeout parameter for expiry_soon notification", + )); + } + }, + other => other, + }; + + Ok(WebhookNotification { jsonrpc: helper.jsonrpc, method }) + } +} + +/// An LSPS5 protocol request +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LSPS5Request { + /// Register or update a webhook + SetWebhook(SetWebhookRequest), + /// List all registered webhooks + ListWebhooks(ListWebhooksRequest), + /// Remove a webhook + RemoveWebhook(RemoveWebhookRequest), +} + +/// An LSPS5 protocol response +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LSPS5Response { + /// Response to [`SetWebhook`](SetWebhookRequest) request + SetWebhook(SetWebhookResponse), + /// Error response to [`SetWebhook`](SetWebhookRequest) request + SetWebhookError(LSPSResponseError), + /// Response to [`ListWebhooks`](ListWebhooksRequest) request + ListWebhooks(ListWebhooksResponse), + /// Error response to [`ListWebhooks`](ListWebhooksRequest) request + ListWebhooksError(LSPSResponseError), + /// Response to [`RemoveWebhook`](RemoveWebhookRequest) request + RemoveWebhook(RemoveWebhookResponse), + /// Error response to [`RemoveWebhook`](RemoveWebhookRequest) request + RemoveWebhookError(LSPSResponseError), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +/// An LSPS5 protocol message +pub enum LSPS5Message { + /// A request variant + Request(LSPSRequestId, LSPS5Request), + /// A response variant + Response(LSPSRequestId, LSPS5Response), +} + +impl TryFrom for LSPS5Message { + type Error = (); + + fn try_from(message: LSPSMessage) -> Result { + match message { + LSPSMessage::LSPS5(message) => Ok(message), + _ => Err(()), + } + } +} + +impl From for LSPSMessage { + fn from(message: LSPS5Message) -> Self { + LSPSMessage::LSPS5(message) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::alloc::string::ToString; + + #[test] + fn webhook_notification_serialization() { + let notification = WebhookNotification::webhook_registered(); + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.webhook_registered","params":{}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + + let notification = WebhookNotification::expiry_soon(144); + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.expiry_soon","params":{"timeout":144}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + } + + #[test] + fn parse_set_webhook_request() { + let json_str = r#"{"app_name":"my_app","webhook":"https://example.com/webhook"}"#; + let request: SetWebhookRequest = serde_json::from_str(json_str).unwrap(); + assert_eq!(request.app_name, LSPS5AppName::new("my_app".to_string()).unwrap()); + assert_eq!( + request.webhook, + LSPS5WebhookUrl::new("https://example.com/webhook".to_string()).unwrap() + ); + } + + #[test] + fn parse_set_webhook_response() { + let json_str = r#"{"num_webhooks":1,"max_webhooks":5,"no_change":false}"#; + let response: SetWebhookResponse = serde_json::from_str(json_str).unwrap(); + assert_eq!(response.num_webhooks, 1); + assert_eq!(response.max_webhooks, 5); + assert_eq!(response.no_change, false); + } + + #[test] + fn parse_list_webhooks_response() { + let json_str = r#"{"app_names":["app1","app2"],"max_webhooks":5}"#; + let response: ListWebhooksResponse = serde_json::from_str(json_str).unwrap(); + let app1 = LSPS5AppName::new("app1".to_string()).unwrap(); + let app2 = LSPS5AppName::new("app2".to_string()).unwrap(); + assert_eq!(response.app_names, vec![app1, app2]); + assert_eq!(response.max_webhooks, 5); + } + + #[test] + fn parse_empty_requests_responses() { + let json_str = r#"{}"#; + let _list_req: ListWebhooksRequest = serde_json::from_str(json_str).unwrap(); + let _remove_resp: RemoveWebhookResponse = serde_json::from_str(json_str).unwrap(); + } + + #[test] + fn spec_example_set_webhook_request() { + let json_str = r#"{"app_name":"My LSPS-Compliant Lightning Client","webhook":"https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best"}"#; + let request: SetWebhookRequest = serde_json::from_str(json_str).unwrap(); + assert_eq!( + request.app_name, + LSPS5AppName::new("My LSPS-Compliant Lightning Client".to_string()).unwrap() + ); + assert_eq!( + request.webhook, + LSPS5WebhookUrl::new( + "https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best" + .to_string() + ) + .unwrap() + ); + } + + #[test] + fn spec_example_set_webhook_response() { + let json_str = r#"{"num_webhooks":2,"max_webhooks":4,"no_change":false}"#; + let response: SetWebhookResponse = serde_json::from_str(json_str).unwrap(); + assert_eq!(response.num_webhooks, 2); + assert_eq!(response.max_webhooks, 4); + assert_eq!(response.no_change, false); + } + + #[test] + fn spec_example_list_webhooks_response() { + let json_str = r#"{"app_names":["My LSPS-Compliant Lightning Wallet","Another Wallet With The Same Signing Device"],"max_webhooks":42}"#; + let response: ListWebhooksResponse = serde_json::from_str(json_str).unwrap(); + let app1 = LSPS5AppName::new("My LSPS-Compliant Lightning Wallet".to_string()).unwrap(); + let app2 = + LSPS5AppName::new("Another Wallet With The Same Signing Device".to_string()).unwrap(); + assert_eq!(response.app_names, vec![app1, app2]); + assert_eq!(response.max_webhooks, 42); + } + + #[test] + fn spec_example_remove_webhook_request() { + let json_str = r#"{"app_name":"Another Wallet With The Same Signig Device"}"#; + let request: RemoveWebhookRequest = serde_json::from_str(json_str).unwrap(); + assert_eq!( + request.app_name, + LSPS5AppName::new("Another Wallet With The Same Signig Device".to_string()).unwrap() + ); + } + + #[test] + fn spec_example_webhook_notifications() { + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.webhook_registered","params":{}}"#; + let notification: WebhookNotification = serde_json::from_str(json_str).unwrap(); + assert_eq!(notification.method, WebhookNotificationMethod::LSPS5WebhookRegistered); + + let notification = WebhookNotification::payment_incoming(); + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.payment_incoming","params":{}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + + let notification = WebhookNotification::expiry_soon(144); + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.expiry_soon","params":{"timeout":144}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + + let notification = WebhookNotification::liquidity_management_request(); + let json_str = + r#"{"jsonrpc":"2.0","method":"lsps5.liquidity_management_request","params":{}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + + let notification = WebhookNotification::onion_message_incoming(); + let json_str = r#"{"jsonrpc":"2.0","method":"lsps5.onion_message_incoming","params":{}}"#; + assert_eq!(json_str, serde_json::json!(notification).to_string()); + } + + #[test] + fn test_url_security_validation() { + let urls_that_should_throw = [ + "https://10.0.0.1/webhook", + "https://192.168.1.1/webhook", + "https://172.16.0.1/webhook", + "https://172.31.255.255/webhook", + "https://localhost/webhook", + "test-app", + "http://example.com/webhook", + ]; + + for url_str in urls_that_should_throw.iter() { + match LSPS5WebhookUrl::new(url_str.to_string()) { + Ok(_) => panic!("Expected error"), + Err(e) => { + // error is not null + assert!(e.code != 0); + }, + } + } + } + + #[test] + fn test_webhook_notification_parameter_binding() { + let notification = WebhookNotification::expiry_soon(144); + if let WebhookNotificationMethod::LSPS5ExpirySoon { timeout } = notification.method { + assert_eq!(timeout, 144); + } else { + panic!("Expected LSPS5ExpirySoon variant"); + } + + let json = serde_json::to_string(¬ification).unwrap(); + assert_eq!( + json, + r#"{"jsonrpc":"2.0","method":"lsps5.expiry_soon","params":{"timeout":144}}"# + ); + let deserialized: WebhookNotification = serde_json::from_str(&json).unwrap(); + if let WebhookNotificationMethod::LSPS5ExpirySoon { timeout } = deserialized.method { + assert_eq!(timeout, 144); + } else { + panic!("Expected LSPS5ExpirySoon variant after deserialization"); + } + } + + #[test] + fn test_notification_method_parameter_extraction() { + let method1 = WebhookNotificationMethod::LSPS5WebhookRegistered; + let method2 = WebhookNotificationMethod::LSPS5ExpirySoon { timeout: 500 }; + + assert_eq!(method1.parameters_json_value(), serde_json::json!({})); + assert_eq!(method2.parameters_json_value(), serde_json::json!({"timeout": 500})); + } + + #[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..a7208ffffe1 --- /dev/null +++ b/lightning-liquidity/src/lsps5/service.rs @@ -0,0 +1,714 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Service implementation for LSPS5 webhook registration + +use crate::events::EventQueue; +use crate::lsps0::ser::{ + LSPSDateTime, LSPSProtocolMessageHandler, LSPSRequestId, LSPSResponseError, +}; +use crate::lsps5::msgs::{ + ListWebhooksRequest, ListWebhooksResponse, RemoveWebhookRequest, RemoveWebhookResponse, + SetWebhookRequest, SetWebhookResponse, WebhookNotification, WebhookNotificationMethod, +}; +use crate::message_queue::MessageQueue; +use crate::prelude::*; +use core::time::Duration; + +use bitcoin::secp256k1::{PublicKey, SecretKey}; +use lightning::ln::msgs::{ErrorAction, LightningError}; +use lightning::util::logger::Level; +use lightning::util::message_signing; + +use crate::sync::{Arc, Mutex}; +use serde_json::json; + +use super::event::LSPS5ServiceEvent; +use super::msgs::{ + LSPS5AppName, LSPS5Message, LSPS5Request, LSPS5Response, LSPS5WebhookUrl, + LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE, LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE, +}; + +/// Minimum number of days to retain webhooks after a client's last channel is closed +pub const MIN_WEBHOOK_RETENTION_DAYS: u32 = 30; + +/// A stored webhook +#[derive(Debug, Clone)] +struct StoredWebhook { + /// App name identifier for this webhook + _app_name: LSPS5AppName, + /// The webhook URL + url: LSPS5WebhookUrl, + /// Client node ID + _counterparty_node_id: PublicKey, + /// Last time this webhook was used + last_used: Duration, + /// Map of notification methods to last time they were sent + last_notification_sent: HashMap, +} + +/// Trait defining a time provider for LSPS5 service +/// This trait is used to provide the current time for LSPS5 service operations +/// and to convert between timestamps and durations +pub trait TimeProvider { + /// Get the current time as a duration since the Unix epoch + fn duration_since_epoch(&self) -> Duration; +} + +/// Default time provider using the system clock +#[derive(Clone, Debug)] +#[cfg(feature = "time")] +pub struct DefaultTimeProvider; + +#[cfg(feature = "time")] +impl TimeProvider for DefaultTimeProvider { + fn duration_since_epoch(&self) -> Duration { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now().duration_since(UNIX_EPOCH).expect("system time before Unix epoch") + } +} + +/// Configuration for LSPS5 service +#[derive(Clone)] +pub struct LSPS5ServiceConfig { + /// Maximum number of webhooks allowed per client (default: 10) + pub max_webhooks_per_client: u32, + /// Signing key for LSP notifications + pub signing_key: SecretKey, + /// Minimum time between sending the same notification type in hours (default: 24) + pub notification_cooldown_hours: u64, +} + +impl Default for LSPS5ServiceConfig { + fn default() -> Self { + Self { + max_webhooks_per_client: 10, + signing_key: SecretKey::from_slice(&[1; 32]).expect("Static key should be valid"), + notification_cooldown_hours: 24, + } + } +} + +/// Service for handling LSPS5 webhook registration +pub struct LSPS5ServiceHandler { + /// Configuration parameters + config: LSPS5ServiceConfig, + /// Map of client node IDs to their registered webhooks + webhooks: Arc>>>, + /// Event queue for emitting events + event_queue: Arc, + /// Message queue for sending responses + pending_messages: Arc, + /// Time provider for LSPS5 service + time_provider: Arc, + /// Function for checking if a client has an open channel + client_has_open_channel: Box bool>, + /// Last time the stale webhooks were pruned + last_pruning: Arc>>, +} + +impl LSPS5ServiceHandler { + /// Create a new LSPS5 service handler + /// + /// # Arguments + /// * `event_queue` - Event queue for emitting events + /// * `pending_messages` - Message queue for sending responses + /// * `client_has_open_channel` - Function that checks if a client has an open channel + /// * `config` - Configuration for the LSPS5 service + /// + /// # Panics + /// Will panic if no HTTP client is provided and a default one cannot be created + #[cfg(feature = "time")] + pub(crate) fn new( + event_queue: Arc, pending_messages: Arc, + client_has_open_channel: Box bool>, config: LSPS5ServiceConfig, + ) -> Option { + let time_provider = Arc::new(DefaultTimeProvider); + Some(Self { + config, + webhooks: Arc::new(Mutex::new(new_hash_map())), + event_queue, + pending_messages, + time_provider, + client_has_open_channel, + last_pruning: Arc::new(Mutex::new(None)), + }) + } + + pub(crate) fn _new_with_custom_time_provider( + event_queue: Arc, pending_messages: Arc, + client_has_open_channel: Box bool>, config: LSPS5ServiceConfig, + time_provider: Option>, + ) -> Option { + let time_provider = match time_provider { + Some(provider) => provider, + None => return None, + }; + Some(Self { + config, + webhooks: Arc::new(Mutex::new(new_hash_map())), + event_queue, + pending_messages, + time_provider, + client_has_open_channel, + last_pruning: Arc::new(Mutex::new(None)), + }) + } + + fn check_prune_stale_webhooks(&self) -> Result<(), LightningError> { + let now = self.time_provider.duration_since_epoch(); + let should_prune = { + let mut last_pruning = self.last_pruning.lock().map_err(|_| LightningError { + err: "Failed to lock last_pruning mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let should_run = match *last_pruning { + Some(last_time) => now + .checked_sub(last_time) + .map_or(false, |elapsed| elapsed > Duration::from_secs(24 * 60 * 60)), + None => true, + }; + + if should_run { + *last_pruning = Some(now); + } + + should_run + }; + + if should_prune { + self.prune_stale_webhooks(); + } + + Ok(()) + } + + /// Handle a set_webhook request + pub fn handle_set_webhook( + &self, counterparty_node_id: PublicKey, request_id: LSPSRequestId, + params: SetWebhookRequest, + ) -> Result<(), LightningError> { + self.check_prune_stale_webhooks()?; + + if let Err(e) = params.app_name.validate() { + let msg = LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(e.clone())) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + return Err(LightningError { + err: e.message, + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + if let Err(e) = params.webhook.validate() { + let msg = LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(e.clone())) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + return Err(LightningError { + err: format!("Error handling SetWebhook request: {}", e.message), + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + let mut webhooks = self.webhooks.lock().map_err(|_| LightningError { + err: "Failed to lock webhooks mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let client_webhooks = webhooks.entry(counterparty_node_id).or_insert_with(new_hash_map); + let now = self.time_provider.duration_since_epoch(); + + let no_change = client_webhooks + .get(¶ms.app_name) + .map_or(false, |webhook| webhook.url == params.webhook); + + if !client_webhooks.contains_key(¶ms.app_name) + && client_webhooks.len() >= self.config.max_webhooks_per_client as usize + { + let message = format!( + "Maximum of {} webhooks allowed per client", + self.config.max_webhooks_per_client + ); + let error_response = LSPSResponseError { + code: LSPS5_TOO_MANY_WEBHOOKS_ERROR_CODE, + message: message.clone(), + data: Some( + json!({ "max_webhooks": self.config.max_webhooks_per_client }).to_string(), + ), + }; + let msg = + LSPS5Message::Response(request_id, LSPS5Response::SetWebhookError(error_response)) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + return Err(LightningError { + err: message, + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + // Add or replace the webhook + let stored_webhook = StoredWebhook { + _app_name: params.app_name.clone(), + url: params.webhook.clone(), + _counterparty_node_id: counterparty_node_id, + last_used: now, + last_notification_sent: new_hash_map(), + }; + + client_webhooks.insert(params.app_name.clone(), stored_webhook); + + let response = SetWebhookResponse { + num_webhooks: client_webhooks.len() as u32, + max_webhooks: self.config.max_webhooks_per_client, + no_change, + }; + 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().map_err(|_| LightningError { + err: "Failed to lock webhooks mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let app_names = webhooks + .get(&counterparty_node_id) + .map(|client_webhooks| client_webhooks.keys().cloned().collect::>()) + .unwrap_or_else(Vec::new); + + let max_webhooks = self.config.max_webhooks_per_client; + + 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().map_err(|_| LightningError { + err: "Failed to lock webhooks mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + if let Some(client_webhooks) = webhooks.get_mut(&counterparty_node_id) { + if client_webhooks.remove(¶ms.app_name).is_some() { + let response = RemoveWebhookResponse {}; + let msg = LSPS5Message::Response( + request_id.clone(), + LSPS5Response::RemoveWebhook(response), + ) + .into(); + self.pending_messages.enqueue(&counterparty_node_id, msg); + 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_response = LSPSResponseError { + code: LSPS5_APP_NAME_NOT_FOUND_ERROR_CODE, + message: error_message.clone(), + data: None, + }; + + let msg = + LSPS5Message::Response(request_id, LSPS5Response::RemoveWebhookError(error_response)) + .into(); + + self.pending_messages.enqueue(&counterparty_node_id, msg); + return Err(LightningError { + err: error_message, + action: ErrorAction::IgnoreAndLog(Level::Info), + }); + } + + /// Send a webhook_registered notification to a newly registered webhook + /// + /// According to spec: + /// "Only the newly-registered webhook is notified. + /// Only the newly-registered webhook is contacted for this notification" + fn send_webhook_registered_notification( + &self, client_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl, + ) -> Result<(), LightningError> { + let notification = WebhookNotification::webhook_registered(); + self.send_notification(client_node_id, app_name.clone(), url.clone(), notification) + } + + /// Send an incoming_payment notification to all of a client's webhooks + pub fn notify_payment_incoming(&self, client_id: PublicKey) -> Result<(), LightningError> { + let notification = WebhookNotification::payment_incoming(); + self.broadcast_notification(client_id, notification) + } + + /// Send an expiry_soon notification to all of a client's webhooks + pub fn notify_expiry_soon( + &self, client_id: PublicKey, timeout: u32, + ) -> Result<(), LightningError> { + let notification = WebhookNotification::expiry_soon(timeout); + self.broadcast_notification(client_id, notification) + } + + /// Send a liquidity_management_request notification to all of a client's webhooks + pub fn notify_liquidity_management_request( + &self, client_id: PublicKey, + ) -> Result<(), LightningError> { + let notification = WebhookNotification::liquidity_management_request(); + self.broadcast_notification(client_id, notification) + } + + /// Send an onion_message_incoming notification to all of a client's webhooks + pub fn notify_onion_message_incoming( + &self, client_id: PublicKey, + ) -> Result<(), LightningError> { + let notification = WebhookNotification::onion_message_incoming(); + self.broadcast_notification(client_id, notification) + } + + /// Broadcast a notification to all registered webhooks for a client + /// + /// According to spec: + /// "The LSP SHOULD contact all registered webhook URIs, if: + /// * The client has registered at least one via `lsps5.set_webhook`. + /// * *and* the client currently does not have a BOLT8 tunnel with the LSP. + /// * *and* one of the specified events has occurred." + fn broadcast_notification( + &self, client_id: PublicKey, notification: WebhookNotification, + ) -> Result<(), LightningError> { + let mut webhooks = self.webhooks.lock().map_err(|_| LightningError { + err: "Failed to lock webhooks mutex".to_string(), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let client_webhooks = match webhooks.get_mut(&client_id) { + Some(webhooks) if !webhooks.is_empty() => webhooks, + _ => return Ok(()), + }; + + let now = self.time_provider.duration_since_epoch(); + let cooldown_duration = Duration::from_secs(self.config.notification_cooldown_hours * 3600); + + for (app_name, webhook) in client_webhooks.iter_mut() { + if webhook + .last_notification_sent + .get(¬ification.method) + .and_then(|last_sent| now.checked_sub(*last_sent)) + .map_or(true, |duration| duration >= cooldown_duration) + { + webhook.last_notification_sent.insert(notification.method.clone(), now); + webhook.last_used = now; + + self.send_notification( + client_id, + app_name.clone(), + webhook.url.clone(), + notification.clone(), + )?; + } + } + + Ok(()) + } + + /// Send a notification to a webhook URL + fn send_notification( + &self, counterparty_node_id: PublicKey, app_name: LSPS5AppName, url: LSPS5WebhookUrl, + notification: WebhookNotification, + ) -> Result<(), LightningError> { + let timestamp = LSPSDateTime::from(self.time_provider.duration_since_epoch()).to_rfc3339(); + + let notification_json = + serde_json::to_string(¬ification).map_err(|e| LightningError { + err: format!("Failed to serialize notification: {}", e), + action: ErrorAction::IgnoreAndLog(Level::Error), + })?; + + let signature_hex = self.sign_notification(¬ification_json, ×tamp)?; + + let headers = vec![ + ("Content-Type".to_string(), "application/json".to_string()), + ("x-lsps5-timestamp".to_string(), timestamp.clone()), + ("x-lsps5-signature".to_string(), signature_hex.clone()), + ]; + + 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: &str) -> Result { + // Create the message to sign + // According to spec: + // The message to be signed is: "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + let message = format!( + "LSPS5: DO NOT SIGN THIS MESSAGE MANUALLY: LSP: At {} I notify {}", + timestamp, body + ); + + Ok(message_signing::sign(message.as_bytes(), &self.config.signing_key)) + } + + /// Clean up webhooks for clients with no channels that haven't been used in a while + /// According to spec: "MUST remember all webhooks for at least 7 days after the last channel is closed" + fn prune_stale_webhooks(&self) { + let now = self.time_provider.duration_since_epoch(); + let webhooks_lock = match self.webhooks.lock() { + Ok(guard) => guard, + Err(_) => return, + }; + let mut webhooks = webhooks_lock; + let retention_period = + Duration::from_secs(MIN_WEBHOOK_RETENTION_DAYS as u64 * 24 * 60 * 60); + + webhooks.retain(|client_id, client_webhooks| { + if !(self.client_has_open_channel)(client_id) { + client_webhooks.retain(|_, webhook| { + now.checked_sub(webhook.last_used) + .map_or(true, |duration| duration < retention_period) + }); + !client_webhooks.is_empty() + } else { + true + } + }); + } +} + +impl LSPSProtocolMessageHandler for LSPS5ServiceHandler { + type ProtocolMessage = LSPS5Message; + const PROTOCOL_NUMBER: Option = Some(2); + + fn handle_message( + &self, message: Self::ProtocolMessage, counterparty_node_id: &PublicKey, + ) -> Result<(), LightningError> { + match message { + LSPS5Message::Request(request_id, request) => { + let res = match request { + LSPS5Request::SetWebhook(params) => { + self.handle_set_webhook(*counterparty_node_id, request_id.clone(), params) + }, + LSPS5Request::ListWebhooks(params) => { + self.handle_list_webhooks(*counterparty_node_id, request_id.clone(), params) + }, + LSPS5Request::RemoveWebhook(params) => self.handle_remove_webhook( + *counterparty_node_id, + request_id.clone(), + params, + ), + }; + res + }, + _ => { + debug_assert!( + false, + "Service handler received LSPS5 response message. This should never happen." + ); + Err(LightningError { + err: format!("Service handler received LSPS5 response message from node {:?}. This should never happen.", counterparty_node_id), + action: ErrorAction::IgnoreAndLog(Level::Info) + }) + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::cell::RefCell; + + // Mock time provider for testing + struct MockTimeProvider { + current_time: RefCell, + } + + impl MockTimeProvider { + fn new(seconds_since_epoch: u64) -> Self { + Self { current_time: RefCell::new(Duration::from_secs(seconds_since_epoch)) } + } + + fn advance_time(&self, seconds: u64) { + let mut time = self.current_time.borrow_mut(); + *time += Duration::from_secs(seconds); + } + } + + impl TimeProvider for MockTimeProvider { + fn duration_since_epoch(&self) -> Duration { + *self.current_time.borrow() + } + } + + // Test for prune_stale_webhooks + #[test] + fn test_prune_stale_webhooks() { + let event_queue = Arc::new(EventQueue::new()); + let pending_messages = Arc::new(MessageQueue::new()); + let config = LSPS5ServiceConfig::default(); + let time_provider = Arc::new(MockTimeProvider::new(1000)); // Starting time + + let mut client_keys = Vec::new(); + for i in 0..3 { + let key = SecretKey::from_slice(&[i + 1; 32]).expect("Valid key slice"); + let pubkey = PublicKey::from_secret_key(&bitcoin::secp256k1::Secp256k1::new(), &key); + client_keys.push(pubkey); + } + + let nodes_with_channels = Arc::new(Mutex::new(new_hash_set())); + + let channels_for_closure = nodes_with_channels.clone(); + + let client_has_open_channel = Box::new(move |pubkey: &PublicKey| -> bool { + channels_for_closure.lock().unwrap().contains(pubkey) + }); + + let handler = LSPS5ServiceHandler::_new_with_custom_time_provider( + event_queue, + pending_messages, + client_has_open_channel, + config, + Some(time_provider.clone()), + ) + .unwrap(); + + { + let mut webhooks = handler.webhooks.lock().unwrap(); + for (i, pubkey) in client_keys.iter().enumerate() { + let client_webhooks = webhooks.entry(*pubkey).or_insert_with(new_hash_map); + for j in 0..2 { + let app_name = LSPS5AppName::new(format!("app_{}_{}", i, j)).unwrap(); + let url = + LSPS5WebhookUrl::new(format!("https://example.com/webhook_{}_{}", i, j)) + .unwrap(); + client_webhooks.insert( + app_name.clone(), + StoredWebhook { + _app_name: app_name, + url, + _counterparty_node_id: *pubkey, + last_used: time_provider.duration_since_epoch(), + last_notification_sent: new_hash_map(), + }, + ); + } + } + } + + for i in 0..2 { + nodes_with_channels.lock().unwrap().insert(client_keys[i]); + } + + time_provider.advance_time(15 * 24 * 60 * 60); + + handler.prune_stale_webhooks(); + + { + let webhooks = handler.webhooks.lock().unwrap(); + assert_eq!(webhooks.len(), 3); + } + + time_provider.advance_time(20 * 24 * 60 * 60); + + handler.prune_stale_webhooks(); + + { + let webhooks = handler.webhooks.lock().unwrap(); + assert_eq!(webhooks.len(), 2); + assert!(webhooks.contains_key(&client_keys[0])); + assert!(webhooks.contains_key(&client_keys[1])); + assert!(!webhooks.contains_key(&client_keys[2])); + } + + { + let mut channels = nodes_with_channels.lock().unwrap(); + channels.remove(&client_keys[1]); + } + + time_provider.advance_time(40 * 24 * 60 * 60); + + handler.prune_stale_webhooks(); + + { + let webhooks = handler.webhooks.lock().unwrap(); + assert_eq!(webhooks.len(), 1); + assert!(webhooks.contains_key(&client_keys[0])); + assert!(!webhooks.contains_key(&client_keys[1])); + assert!(!webhooks.contains_key(&client_keys[2])); + } + } +} diff --git a/lightning-liquidity/src/lsps5/url_utils.rs b/lightning-liquidity/src/lsps5/url_utils.rs new file mode 100644 index 00000000000..6ea22dfa656 --- /dev/null +++ b/lightning-liquidity/src/lsps5/url_utils.rs @@ -0,0 +1,464 @@ +// 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 crate::prelude::String; +use crate::prelude::Vec; + +/// A URL implementation for scheme and host extraction +/// Simplified representation of a URL with just scheme and host components. +/// This struct provides parsing and access to these core parts of a URL string. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct LSPSUrl { + scheme: String, + host: String, + url: String, +} + +/// Implementation of methods for the Url struct +impl LSPSUrl { + /// Parses a URL string into a URL instance + /// Extracts the scheme and host from any standard URL + /// + /// # Arguments + /// * `url_str` - The URL string to parse + /// + /// # Returns + /// A Result containing either the parsed URL or an error message + pub fn parse(url_str: &str) -> Result { + let parts: Vec<&str> = url_str.splitn(2, "://").collect(); + if parts.len() != 2 { + return Err("URL must contain scheme separator '://'".to_string()); + } + + let scheme = parts[0].to_string(); + if scheme.is_empty() { + return Err("URL scheme cannot be empty".to_string()); + } + + if !validate_scheme(&scheme) { + return Err(format!("Invalid URL scheme: {}", scheme)); + } + + let remainder = parts[1]; + if remainder.is_empty() { + return Err("URL host cannot be empty".to_string()); + } + + let host = match remainder.find('/') { + Some(idx) => &remainder[0..idx], + None => match remainder.find('?') { + Some(idx) => &remainder[0..idx], + None => match remainder.find('#') { + Some(idx) => &remainder[0..idx], + None => remainder, + }, + }, + }; + + let mut clean_host = host; + if let Some(auth_idx) = host.rfind('@') { + clean_host = &host[auth_idx + 1..]; + } + + let mut final_host = clean_host; + if let Some(port_idx) = clean_host.rfind(':') { + final_host = &clean_host[0..port_idx]; + } + + if final_host.is_empty() { + return Err("URL host cannot be empty".to_string()); + } + + Ok(LSPSUrl { scheme, host: final_host.to_string(), url: url_str.to_string() }) + } + + /// Returns the scheme part of the URL (http, https, etc.) + pub fn scheme(&self) -> &str { + &self.scheme + } + + /// Returns the host as an Option, None if empty + pub fn host(&self) -> Option<&str> { + if self.host.is_empty() { + None + } else { + Some(&self.host) + } + } + + /// Returns the host string directly if available + pub fn host_str(&self) -> Option<&str> { + self.host() + } + + /// Returns the full URL string + pub fn url(&self) -> &str { + &self.url + } +} + +/// Validates a URL scheme according to RFC specifications +/// +/// According to RFC 1738, a scheme must: +/// 1. Start with a letter (a-z, A-Z) +/// 2. Contain only letters, digits, plus (+), period (.), or hyphen (-) +fn validate_scheme(scheme: &str) -> bool { + if scheme.is_empty() { + return false; + } + + let mut chars = scheme.chars(); + + let first = match chars.next() { + Some(c) => c, + None => return false, // No characters (empty string) + }; + + if !first.is_ascii_alphabetic() { + return false; + } + + chars.all(|c| c.is_ascii_alphabetic() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_parse_url_with_query_params() { + let url_str = "https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("www.example.org")); + } + + #[test] + fn test_parse_https_url() { + let url_str = "https://example.com/path"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_http_url() { + let url_str = "http://example.com/path"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "http"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_url_with_no_path() { + let url_str = "https://example.com"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_url_with_port() { + let url_str = "https://example.com:8080/path"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_url_with_subdomain_and_path() { + let url_str = "https://api.example.com/v1/resources"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("api.example.com")); + } + + #[test] + fn test_invalid_url_no_scheme() { + let url_str = "example.com/path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_invalid_url_empty_host() { + let url_str = "https:///path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_parse_protocol_with_path() { + let url_str = "ftp://ftp.example.org/pub/files/document.pdf"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "ftp"); + assert_eq!(url.host(), Some("ftp.example.org")); + } + + #[test] + fn test_parse_protocol_with_auth() { + let url_str = "sftp://user:password@sftp.example.com:22/uploads/"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "sftp"); + assert_eq!(url.host(), Some("sftp.example.com")); + } + + #[test] + fn test_parse_ssh_url() { + let url_str = "ssh://username@host.com:2222"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "ssh"); + assert_eq!(url.host(), Some("host.com")); + } + + #[test] + fn test_parse_custom_protocol() { + let url_str = "lightning://03a.example.com/invoice?amount=10000"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "lightning"); + assert_eq!(url.host(), Some("03a.example.com")); + } + + #[test] + fn test_parse_url_with_fragment() { + let url_str = "https://example.com/page#section1"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_url_with_query_and_fragment() { + let url_str = "https://example.com/search?q=test#results"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_url_with_username_only() { + let url_str = "ftp://user@ftp.example.com/files/"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "ftp"); + assert_eq!(url.host(), Some("ftp.example.com")); + } + + #[test] + fn test_parse_url_with_credentials() { + let url_str = "http://user:pass@example.com/"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "http"); + assert_eq!(url.host(), Some("example.com")); + } + + #[test] + fn test_parse_url_with_ipv4_host() { + let url_str = "http://192.168.1.1/admin"; + let url = LSPSUrl::parse(url_str).unwrap(); + + assert_eq!(url.scheme(), "http"); + assert_eq!(url.host(), Some("192.168.1.1")); + } + + #[test] + fn test_check_https_scheme() { + let url_str = "https://example.com/path"; + let url = LSPSUrl::parse(url_str).unwrap(); + assert_eq!(url.scheme(), "https"); + + let url_str = "http://example.com/path"; + let url = LSPSUrl::parse(url_str).unwrap(); + assert_ne!(url.scheme(), "https"); + } + + #[test] + fn test_empty_remainder_error() { + let url_str = "https://"; + let result = LSPSUrl::parse(url_str); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "URL host cannot be empty"); + } + + #[test] + fn test_malformed_scheme_chars() { + let url_str = "ht@ps://example.com"; + let result = LSPSUrl::parse(url_str); + assert!(result.is_err()); + + let url_str = "http!://example.com"; + let result = LSPSUrl::parse(url_str); + assert!(result.is_err()); + } + + // Update this test since the RFC requires schemes to start with a letter + #[test] + fn test_scheme_starting_with_digit() { + let url_str = "1https://example.com"; + let result = LSPSUrl::parse(url_str); + + // According to RFC, schemes must start with a letter + assert!(result.is_err()); + } + + #[test] + fn test_valid_scheme_chars() { + let valid_schemes = vec![ + "http", + "https", + "ftp", + "sftp", + "ssh", + "h123", + "scheme-with-dash", + "scheme.with.dots", + "scheme+plus", + ]; + + for scheme in valid_schemes { + let url_str = format!("{}://example.com", scheme); + let result = LSPSUrl::parse(&url_str); + assert!(result.is_ok(), "Valid scheme '{}' was rejected", scheme); + assert_eq!(result.unwrap().scheme(), scheme); + } + } + + #[test] + fn test_extremely_long_url() { + let host = "a".repeat(10000); + let url_str = format!("https://{}/path", host); + let result = LSPSUrl::parse(&url_str); + + assert!(result.is_ok()); + let url = result.unwrap(); + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host().unwrap().len(), 10000); + } + + #[test] + fn test_unicode_characters() { + let url_str = "https://例子.测试/path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + let url = result.unwrap(); + assert_eq!(url.host(), Some("例子.测试")); + } + + #[test] + fn test_weird_but_valid_scheme() { + let url_str = "a123+-.://example.com"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().scheme(), "a123+-."); + } + + #[test] + fn test_url_with_spaces() { + let url_str = "https://example.com/path with spaces"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().host(), Some("example.com")); + + let url_str = "https://bad domain.com/"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().host(), Some("bad domain.com")); + } + + #[test] + fn test_multiple_scheme_separators() { + let url_str = "https://example.com://path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().host(), Some("example.com")); + + let url_str = "https://://example.com"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_invalid_port() { + let url_str = "https://example.com:port/path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().host(), Some("example.com")); + + let url_str = "https://example.com:65536/path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().host(), Some("example.com")); + } + + #[test] + fn test_missing_host_domain() { + let url_str = "https://:8080/path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_scheme_only() { + let url_str = "https:"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_null_characters() { + let url_str = "https://example.com\0/path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_ok()); + assert_eq!(result.unwrap().host(), Some("example.com\0")); + } + + #[test] + fn test_url_with_backslashes() { + let url_str = "https:\\\\example.com\\path"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } + + #[test] + fn test_just_scheme_and_authority_markers() { + let url_str = "://"; + let result = LSPSUrl::parse(url_str); + + assert!(result.is_err()); + } +} diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 63726cc1f77..65f60ee9508 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -7,6 +7,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}; @@ -48,6 +51,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, @@ -62,6 +67,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. @@ -105,6 +112,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>, @@ -125,6 +134,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()); @@ -159,6 +169,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( @@ -213,6 +292,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)), @@ -260,6 +341,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. /// @@ -435,6 +530,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..64755426997 --- /dev/null +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -0,0 +1,853 @@ +#![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, 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::new(raw_app_name.to_string()).unwrap(); + let raw_webhook_url = "https://www.example.org/push?l=1234567890abcdefghijklmnopqrstuv&c=best"; + let webhook_url = LSPS5WebhookUrl::new(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.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::new(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::new(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_code, + error_message, + app_name, + .. + }) => { + // TOO_MANY_WEBHOOKS error code from spec + assert_eq!(error_code, 503); + assert_eq!(app_name, one_too_many); + assert!( + error_message.contains("Maximum of"), + "Error message should mention max webhooks: {}", + error_message + ); + }, + _ => panic!("Expected WebhookRegistrationFailed event, got {:?}", event), + } + + // TEST 6: Remove a non-existent webhook + let raw_nonexistent_app = "NonexistentApp"; + let nonexistent_app = LSPS5AppName::new(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_code, + error_message, + app_name, + .. + }) => { + assert_eq!(error_code, 1010); + assert_eq!(app_name, nonexistent_app); + assert!( + error_message.contains("App name not found"), + "Error message should mention app name not found: {}", + error_message + ); + }, + _ => 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"); + let err_message = result.unwrap_err().err; + assert!( + err_message.contains("localhost"), + "Error message should mention localhost: {}", + err_message + ); + + // 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"); + let err_message = result.unwrap_err().err; + assert!( + err_message.contains("private IP"), + "Error message should mention private IP: {}", + err_message + ); +} + +#[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..33d72b079da --- /dev/null +++ b/lightning-liquidity/tests/signing_tests.rs @@ -0,0 +1,527 @@ +// 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::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::from(time_provider.duration_since_epoch()).to_rfc3339(); + + 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::from(time_provider.duration_since_epoch()).to_rfc3339(); + + 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::from(time_provider.duration_since_epoch()).to_rfc3339(); + + 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_invalid_timestamp() { + 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 invalid_timestamp = "2023/05/04 10:52:58"; + + let signature = + service_handler.sign_notification(¬ification_json, invalid_timestamp).unwrap(); + + let result = client_handler.verify_notification_signature( + service_node_id, + invalid_timestamp, + &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::from(time_provider.duration_since_epoch()).to_rfc3339(); + + 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 = "2020-01-01T00:00:00.000Z"; + + 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::from(time_provider.duration_since_epoch()).to_rfc3339(); + + 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 = parsed.method.parameters_json_value(); + 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 = "2023-05-04T10:14:23.853Z"; + + let signature = service_handler.sign_notification(¬ification_json, timestamp).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); + + 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::from(time_provider.duration_since_epoch()).to_rfc3339(); + + 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::from(current_time).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::from(current_time.checked_sub(Duration::from_secs(20 * 60)).unwrap()) + .to_rfc3339(); + + 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::from(current_time.checked_add(Duration::from_secs(15 * 60)).unwrap()) + .to_rfc3339(); + + 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"); + + let invalid_timestamp = "2023-13-42T25:61:99Z"; + let invalid_format_result = client_handler.verify_notification_signature( + service_node_id, + invalid_timestamp, + &signature, + ¬ification, + ); + assert!( + invalid_format_result.is_err(), + "Notification with invalid timestamp format 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::from(time_provider.duration_since_epoch()).to_rfc3339(); + + 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::from( + time_provider.duration_since_epoch().checked_sub(Duration::from_secs(9 * 60)).unwrap(), + ) + .to_rfc3339(); + let edge_future_timestamp = LSPSDateTime::from( + time_provider.duration_since_epoch().checked_add(Duration::from_secs(9 * 60)).unwrap(), + ) + .to_rfc3339(); + + 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"); +}