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