From d2b86bf5aa34add273215c7940448088919c752c Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Thu, 7 Aug 2025 11:08:06 -0300 Subject: [PATCH 1/6] Add LSPS5 DOS protections. When handling an incoming LSPS5 request, the manager will check if the counterparty is 'engaged' in some way before responding. `Engaged` meaning = active channel | LSPS2 active operation | LSPS1 active operation. Logic: `If not engaged then reject request;` A single test is added only checking for the active channel condition, because it's not super easy to get LSPS1-2 on the correct state to check this (yet). Other tangential work is happening that will make this easier and more tests will come in the near future --- lightning-liquidity/src/lsps1/service.rs | 11 + lightning-liquidity/src/lsps2/service.rs | 91 ++++++- lightning-liquidity/src/lsps5/service.rs | 25 +- lightning-liquidity/src/manager.rs | 26 ++ .../tests/lsps5_integration_tests.rs | 241 +++++++++++++++++- 5 files changed, 388 insertions(+), 6 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index aa10e735565..40707d3c20c 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -174,6 +174,17 @@ where &self.config } + pub(crate) fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool { + let outer_state_lock = self.per_peer_state.read().unwrap(); + if let Some(inner_state_lock) = outer_state_lock.get(counterparty_node_id) { + let peer_state = inner_state_lock.lock().unwrap(); + !(peer_state.pending_requests.is_empty() + && peer_state.outbound_channels_by_order_id.is_empty()) + } else { + false + } + } + fn handle_get_info_request( &self, request_id: LSPSRequestId, counterparty_node_id: &PublicKey, ) -> Result<(), LightningError> { diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 114ed8b250d..315a7c4a6d5 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -108,8 +108,8 @@ struct ForwardPaymentAction(ChannelId, FeePayment); struct ForwardHTLCsAction(ChannelId, Vec); /// The different states a requested JIT channel can be in. -#[derive(Debug)] -enum OutboundJITChannelState { +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum OutboundJITChannelState { /// The JIT channel SCID was created after a buy request, and we are awaiting an initial payment /// of sufficient size to open the channel. PendingInitialPayment { payment_queue: PaymentQueue }, @@ -134,6 +134,30 @@ enum OutboundJITChannelState { PaymentForwarded { channel_id: ChannelId }, } +impl OutboundJITChannelState { + fn ord_index(&self) -> u8 { + match self { + OutboundJITChannelState::PendingInitialPayment { .. } => 0, + OutboundJITChannelState::PendingChannelOpen { .. } => 1, + OutboundJITChannelState::PendingPaymentForward { .. } => 2, + OutboundJITChannelState::PendingPayment { .. } => 3, + OutboundJITChannelState::PaymentForwarded { .. } => 4, + } + } +} + +impl PartialOrd for OutboundJITChannelState { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for OutboundJITChannelState { + fn cmp(&self, other: &Self) -> core::cmp::Ordering { + self.ord_index().cmp(&other.ord_index()) + } +} + impl OutboundJITChannelState { fn new() -> Self { OutboundJITChannelState::PendingInitialPayment { payment_queue: PaymentQueue::new() } @@ -566,6 +590,18 @@ where &self.config } + pub(crate) fn highest_state_for_peer( + &self, counterparty_node_id: &PublicKey, + ) -> Option { + let outer_state_lock = self.per_peer_state.read().unwrap(); + if let Some(inner_state_lock) = outer_state_lock.get(counterparty_node_id) { + let peer_state = inner_state_lock.lock().unwrap(); + peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max() + } else { + None + } + } + /// Used by LSP to inform a client requesting a JIT Channel the token they used is invalid. /// /// Should be called in response to receiving a [`LSPS2ServiceEvent::GetInfo`] event. @@ -1899,4 +1935,55 @@ mod tests { ); } } + + #[test] + fn highest_state_for_peer_orders() { + let opening_fee_params = LSPS2OpeningFeeParams { + min_fee_msat: 0, + proportional: 0, + valid_until: LSPSDateTime::from_str("1970-01-01T00:00:00Z").unwrap(), + min_lifetime: 0, + max_client_to_self_delay: 0, + min_payment_size_msat: 0, + max_payment_size_msat: 0, + promise: String::new(), + }; + + let mut map = new_hash_map(); + map.insert( + 0, + OutboundJITChannel { + state: OutboundJITChannelState::PendingInitialPayment { + payment_queue: PaymentQueue::new(), + }, + user_channel_id: 0, + opening_fee_params: opening_fee_params.clone(), + payment_size_msat: None, + }, + ); + map.insert( + 1, + OutboundJITChannel { + state: OutboundJITChannelState::PendingChannelOpen { + payment_queue: PaymentQueue::new(), + opening_fee_msat: 0, + }, + user_channel_id: 1, + opening_fee_params: opening_fee_params.clone(), + payment_size_msat: None, + }, + ); + map.insert( + 2, + OutboundJITChannel { + state: OutboundJITChannelState::PaymentForwarded { channel_id: ChannelId([0; 32]) }, + user_channel_id: 2, + opening_fee_params, + payment_size_msat: None, + }, + ); + + let max_state = map.values().map(|c| c.state.clone()).max().unwrap(); + assert!(matches!(max_state, OutboundJITChannelState::PaymentForwarded { .. })); + } } diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index 72c3d83b3fe..e03cd0a00a3 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -21,6 +21,7 @@ use crate::prelude::*; use crate::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; use crate::utils::time::TimeProvider; +use crate::lsps2::service::OutboundJITChannelState; use bitcoin::secp256k1::PublicKey; use lightning::ln::channelmanager::AChannelManager; @@ -149,6 +150,28 @@ where } } + /// Returns whether a request from the given client should be accepted. + /// + /// Prior activity includes an existing open channel, an active LSPS1 flow, + /// or an LSPS2 flow that has progressed to at least + /// [`OutboundJITChannelState::PendingChannelOpen`]. + pub(crate) fn can_accept_request( + &self, client_id: &PublicKey, lsps2_max_state: Option, + lsps1_has_activity: bool, + ) -> bool { + self.client_has_open_channel(client_id) + || lsps1_has_activity + || lsps2_max_state.map_or(false, |s| { + matches!( + s, + OutboundJITChannelState::PendingChannelOpen { .. } + | OutboundJITChannelState::PendingPaymentForward { .. } + | OutboundJITChannelState::PendingPayment { .. } + | OutboundJITChannelState::PaymentForwarded { .. } + ) + }) + } + fn check_prune_stale_webhooks<'a>( &self, outer_state_lock: &mut RwLockWriteGuard<'a, HashMap>, ) { @@ -487,7 +510,7 @@ where .map_err(|_| LSPS5ProtocolError::UnknownError) } - fn client_has_open_channel(&self, client_id: &PublicKey) -> bool { + pub(crate) fn client_has_open_channel(&self, client_id: &PublicKey) -> bool { self.channel_manager .get_cm() .list_channels() diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 4cf97786d02..afa147e292f 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -568,6 +568,32 @@ where LSPSMessage::LSPS5(msg @ LSPS5Message::Request(..)) => { match &self.lsps5_service_handler { Some(lsps5_service_handler) => { + let lsps2_max_state = self + .lsps2_service_handler + .as_ref() + .and_then(|h| h.highest_state_for_peer(sender_node_id)); + #[cfg(lsps1_service)] + let lsps1_has_active_requests = self + .lsps1_service_handler + .as_ref() + .map_or(false, |h| h.has_active_requests(sender_node_id)); + #[cfg(not(lsps1_service))] + let lsps1_has_active_requests = false; + + if !lsps5_service_handler.can_accept_request( + sender_node_id, + lsps2_max_state, + lsps1_has_active_requests, + ) { + return Err(LightningError { + err: format!( + "Rejecting LSPS5 request from {:?} without prior activity (requires open channel or active LSPS1 or LSPS2 flow)", + sender_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Debug), + }); + } + lsps5_service_handler.handle_message(msg, sender_node_id)?; }, None => { diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index e526d3eda5e..69e64a768dc 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -4,14 +4,22 @@ mod common; use common::{create_service_and_client_nodes, get_lsps_message, LSPSNodes}; +use lightning::check_closed_event; +use lightning::events::ClosureReason; +use lightning::ln::channelmanager::InterceptId; use lightning::ln::functional_test_utils::{ - create_chanmon_cfgs, create_network, create_node_cfgs, create_node_chanmgrs, Node, + close_channel, create_chan_between_nodes, create_chanmon_cfgs, create_network, + create_node_cfgs, create_node_chanmgrs, Node, }; use lightning::ln::msgs::Init; 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::lsps2::client::LSPS2ClientConfig; +use lightning_liquidity::lsps2::event::{LSPS2ClientEvent, LSPS2ServiceEvent}; +use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; +use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; use lightning_liquidity::lsps5::client::LSPS5ClientConfig; use lightning_liquidity::lsps5::event::{LSPS5ClientEvent, LSPS5ServiceEvent}; use lightning_liquidity::lsps5::msgs::{ @@ -27,6 +35,10 @@ use lightning_liquidity::lsps5::service::{ use lightning_liquidity::lsps5::validator::{LSPS5Validator, MAX_RECENT_SIGNATURES}; use lightning_liquidity::utils::time::{DefaultTimeProvider, TimeProvider}; use lightning_liquidity::{LiquidityClientConfig, LiquidityServiceConfig}; + +use lightning_types::payment::PaymentHash; + +use std::str::FromStr; use std::sync::{Arc, RwLock}; use std::time::Duration; @@ -62,6 +74,39 @@ pub(crate) fn lsps5_test_setup<'a, 'b, 'c>( (lsps_nodes, validator) } +pub(crate) fn lsps5_lsps2_test_setup<'a, 'b, 'c>( + nodes: Vec>, time_provider: Arc, +) -> (LSPSNodes<'a, 'b, 'c>, LSPS5Validator) { + let lsps5_service_config = LSPS5ServiceConfig::default(); + let lsps2_service_config = LSPS2ServiceConfig { promise_secret: [42; 32] }; + let service_config = LiquidityServiceConfig { + #[cfg(lsps1_service)] + lsps1_service_config: None, + lsps2_service_config: Some(lsps2_service_config), + lsps5_service_config: Some(lsps5_service_config), + advertise_service: true, + }; + + let lsps5_client_config = LSPS5ClientConfig::default(); + let lsps2_client_config = LSPS2ClientConfig::default(); + let client_config = LiquidityClientConfig { + lsps1_client_config: None, + lsps2_client_config: Some(lsps2_client_config), + lsps5_client_config: Some(lsps5_client_config), + }; + + let lsps_nodes = create_service_and_client_nodes( + nodes, + service_config, + client_config, + Arc::clone(&time_provider), + ); + + let validator = LSPS5Validator::new(); + + (lsps_nodes, validator) +} + struct MockTimeProvider { current_time: RwLock, } @@ -102,7 +147,8 @@ fn webhook_registration_flow() { let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, _) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider)); - let LSPSNodes { service_node, client_node } = lsps_nodes; + let LSPSNodes { service_node, client_node, .. } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); @@ -293,6 +339,7 @@ fn webhook_error_handling_test() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, _) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider)); let LSPSNodes { service_node, client_node } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); @@ -419,6 +466,7 @@ fn webhook_notification_delivery_test() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, validator) = lsps5_test_setup(nodes, time_provider); let LSPSNodes { service_node, client_node } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); @@ -529,6 +577,7 @@ fn multiple_webhooks_notification_test() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, _) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider)); let LSPSNodes { service_node, client_node } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); @@ -629,6 +678,7 @@ fn idempotency_set_webhook_test() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, _) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider)); let LSPSNodes { service_node, client_node } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); @@ -731,6 +781,7 @@ fn replay_prevention_test() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, validator) = lsps5_test_setup(nodes, time_provider); let LSPSNodes { service_node, client_node } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); @@ -818,7 +869,8 @@ fn stale_webhooks() { let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); let nodes = create_network(2, &node_cfgs, &node_chanmgrs); - let (lsps_nodes, _) = lsps5_test_setup(nodes, time_provider); + let (lsps_nodes, _) = lsps5_lsps2_test_setup(nodes, time_provider); + establish_lsps2_prior_interaction(&lsps_nodes); let LSPSNodes { service_node, client_node } = lsps_nodes; let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); @@ -896,6 +948,7 @@ fn test_all_notifications() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, validator) = lsps5_test_setup(nodes, time_provider); let LSPSNodes { service_node, client_node } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); @@ -962,6 +1015,7 @@ fn test_tampered_notification() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, validator) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider)); let LSPSNodes { service_node, client_node } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); @@ -1015,6 +1069,7 @@ fn test_bad_signature_notification() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, validator) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider)); let LSPSNodes { service_node, client_node } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); @@ -1063,6 +1118,7 @@ fn test_notify_without_webhooks_does_nothing() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, _) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider)); let LSPSNodes { service_node, client_node } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let client_node_id = client_node.inner.node.get_our_node_id(); let service_handler = service_node.liquidity_manager.lsps5_service_handler().unwrap(); @@ -1085,6 +1141,7 @@ fn test_notifications_and_peer_connected_resets_cooldown() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, _) = lsps5_test_setup(nodes, time_provider); let LSPSNodes { service_node, client_node } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); @@ -1183,6 +1240,7 @@ fn webhook_update_affects_future_notifications() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, _) = lsps5_test_setup(nodes, time_provider); let LSPSNodes { service_node, client_node } = lsps_nodes; + create_chan_between_nodes(&service_node.inner, &client_node.inner); let service_node_id = service_node.inner.node.get_our_node_id(); let client_node_id = client_node.inner.node.get_our_node_id(); let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); @@ -1234,3 +1292,180 @@ fn webhook_update_affects_future_notifications() { _ => panic!("Expected SendWebhookNotification after update"), } } + +#[test] +fn dos_protection() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, _) = lsps5_test_setup(nodes, Arc::new(DefaultTimeProvider)); + let LSPSNodes { service_node, client_node } = lsps_nodes; + let client_node_id = client_node.inner.node.get_our_node_id(); + let service_node_id = service_node.inner.node.get_our_node_id(); + + let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + + let assert_reject = || -> () { + let _ = client_handler + .set_webhook( + service_node_id, + "App".to_string(), + "https://example.org/webhook".to_string(), + ) + .expect("Request should send"); + 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(), "Service should reject request without prior interaction"); + + assert!(service_node.liquidity_manager.get_and_clear_pending_msg().is_empty()); + }; + + let assert_accept = || -> () { + let _ = client_handler + .set_webhook( + service_node_id, + "App".to_string(), + "https://example.org/webhook".to_string(), + ) + .expect("Request should send"); + 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_ok(), "Service should accept request after prior interaction"); + 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) + .expect("Client should handle response"); + let _ = client_node.liquidity_manager.next_event().unwrap(); + }; + + // no channel is open so far -> should reject + assert_reject(); + + let (_, _, _, channel_id, funding_tx) = + create_chan_between_nodes(&service_node.inner, &client_node.inner); + + // now that a channel is open, should accept + assert_accept(); + + close_channel(&service_node.inner, &client_node.inner, &channel_id, funding_tx, true); + let node_a_reason = ClosureReason::CounterpartyInitiatedCooperativeClosure; + check_closed_event!(service_node.inner, 1, node_a_reason, [client_node_id], 100000); + let node_b_reason = ClosureReason::LocallyInitiatedCooperativeClosure; + check_closed_event!(client_node.inner, 1, node_b_reason, [service_node_id], 100000); + + // channel is now closed again -> should reject + assert_reject(); +} + +#[test] +fn lsps2_state_allows_lsps5_request() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + let (lsps_nodes, _) = lsps5_lsps2_test_setup(nodes, Arc::new(DefaultTimeProvider)); + establish_lsps2_prior_interaction(&lsps_nodes); + + let LSPSNodes { service_node, client_node } = lsps_nodes; + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let lsps5_client = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + + let _ = lsps5_client + .set_webhook(service_node_id, "App".to_string(), "https://example.org/webhook".to_string()) + .expect("Request should send"); + 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_ok(), "Service should accept request based on LSPS2 state"); +} + +fn establish_lsps2_prior_interaction(lsps_nodes: &LSPSNodes) { + let service_node = &lsps_nodes.service_node; + let client_node = &lsps_nodes.client_node; + + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let lsps2_client = client_node.liquidity_manager.lsps2_client_handler().unwrap(); + let lsps2_service = service_node.liquidity_manager.lsps2_service_handler().unwrap(); + + let get_info_request_id = lsps2_client.request_opening_params(service_node_id, None); + let get_info_req = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(get_info_req, client_node_id).unwrap(); + + let get_info_event = service_node.liquidity_manager.next_event().unwrap(); + let opening_fee_params = match get_info_event { + LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::GetInfo { + request_id, + counterparty_node_id, + .. + }) => { + assert_eq!(request_id, get_info_request_id); + assert_eq!(counterparty_node_id, client_node_id); + let raw_opening_params = LSPS2RawOpeningFeeParams { + min_fee_msat: 1000, + proportional: 0, + valid_until: LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(), + min_lifetime: 144, + max_client_to_self_delay: 144, + min_payment_size_msat: 1, + max_payment_size_msat: 1_000_000_000, + }; + lsps2_service + .opening_fee_params_generated( + &client_node_id, + request_id.clone(), + vec![raw_opening_params], + ) + .unwrap(); + let response = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(response, service_node_id).unwrap(); + match client_node.liquidity_manager.next_event().unwrap() { + LiquidityEvent::LSPS2Client(LSPS2ClientEvent::OpeningParametersReady { + opening_fee_params_menu, + .. + }) => opening_fee_params_menu.first().unwrap().clone(), + _ => panic!("Unexpected event"), + } + }, + _ => panic!("Unexpected event"), + }; + + let payment_size_msat = Some(1_000_000); + let buy_request_id = lsps2_client + .select_opening_params(service_node_id, payment_size_msat, opening_fee_params.clone()) + .unwrap(); + let buy_req = get_lsps_message!(client_node, service_node_id); + service_node.liquidity_manager.handle_custom_message(buy_req, client_node_id).unwrap(); + let _ = service_node.liquidity_manager.next_event().unwrap(); + + let intercept_scid = service_node.inner.node.get_intercept_scid(); + let user_channel_id = 7; + let cltv_expiry_delta = 144; + lsps2_service + .invoice_parameters_generated( + &client_node_id, + buy_request_id.clone(), + intercept_scid, + cltv_expiry_delta, + true, + user_channel_id, + ) + .unwrap(); + let buy_resp = get_lsps_message!(service_node, client_node_id); + client_node.liquidity_manager.handle_custom_message(buy_resp, service_node_id).unwrap(); + let _ = client_node.liquidity_manager.next_event().unwrap(); + + let intercept_id = InterceptId([0; 32]); + let payment_hash = PaymentHash([1; 32]); + lsps2_service.htlc_intercepted(intercept_scid, intercept_id, 1_000_000, payment_hash).unwrap(); + + let _ = service_node.liquidity_manager.next_event().unwrap(); +} From 1eb505111df100485ab6493e9af640980eb58448 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Tue, 12 Aug 2025 12:17:15 -0300 Subject: [PATCH 2/6] fixup: address comments. changes: - refactor highest_state_for_peer and has_active_requests for better readabiliy - refactor ordering logic OutboundJITChannelState --- lightning-liquidity/src/lsps1/service.rs | 8 ++--- lightning-liquidity/src/lsps2/service.rs | 39 ++++++++++++++++-------- lightning-liquidity/src/lsps5/service.rs | 12 ++------ 3 files changed, 31 insertions(+), 28 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 40707d3c20c..4acfb81eb32 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -176,13 +176,11 @@ where pub(crate) fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool { let outer_state_lock = self.per_peer_state.read().unwrap(); - if let Some(inner_state_lock) = outer_state_lock.get(counterparty_node_id) { - let peer_state = inner_state_lock.lock().unwrap(); + outer_state_lock.get(counterparty_node_id).map_or(false, |inner| { + let peer_state = inner.lock().unwrap(); !(peer_state.pending_requests.is_empty() && peer_state.outbound_channels_by_order_id.is_empty()) - } else { - false - } + }) } fn handle_get_info_request( diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 315a7c4a6d5..e8ec5d8f23d 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -107,8 +107,17 @@ struct ForwardPaymentAction(ChannelId, FeePayment); #[derive(Debug, PartialEq)] struct ForwardHTLCsAction(ChannelId, Vec); +#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] +pub(crate) enum OutboundJITStage { + PendingInitialPayment, + PendingChannelOpen, + PendingPaymentForward, + PendingPayment, + PaymentForwarded, +} + /// The different states a requested JIT channel can be in. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone)] pub(crate) enum OutboundJITChannelState { /// The JIT channel SCID was created after a buy request, and we are awaiting an initial payment /// of sufficient size to open the channel. @@ -135,13 +144,19 @@ pub(crate) enum OutboundJITChannelState { } impl OutboundJITChannelState { - fn ord_index(&self) -> u8 { + pub(crate) fn stage(&self) -> OutboundJITStage { match self { - OutboundJITChannelState::PendingInitialPayment { .. } => 0, - OutboundJITChannelState::PendingChannelOpen { .. } => 1, - OutboundJITChannelState::PendingPaymentForward { .. } => 2, - OutboundJITChannelState::PendingPayment { .. } => 3, - OutboundJITChannelState::PaymentForwarded { .. } => 4, + OutboundJITChannelState::PendingInitialPayment { .. } => { + OutboundJITStage::PendingInitialPayment + }, + OutboundJITChannelState::PendingChannelOpen { .. } => { + OutboundJITStage::PendingChannelOpen + }, + OutboundJITChannelState::PendingPaymentForward { .. } => { + OutboundJITStage::PendingPaymentForward + }, + OutboundJITChannelState::PendingPayment { .. } => OutboundJITStage::PendingPayment, + OutboundJITChannelState::PaymentForwarded { .. } => OutboundJITStage::PaymentForwarded, } } } @@ -154,7 +169,7 @@ impl PartialOrd for OutboundJITChannelState { impl Ord for OutboundJITChannelState { fn cmp(&self, other: &Self) -> core::cmp::Ordering { - self.ord_index().cmp(&other.ord_index()) + self.stage().cmp(&other.stage()) } } @@ -594,12 +609,10 @@ where &self, counterparty_node_id: &PublicKey, ) -> Option { let outer_state_lock = self.per_peer_state.read().unwrap(); - if let Some(inner_state_lock) = outer_state_lock.get(counterparty_node_id) { - let peer_state = inner_state_lock.lock().unwrap(); + outer_state_lock.get(counterparty_node_id).and_then(|inner| { + let peer_state = inner.lock().unwrap(); peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max() - } else { - None - } + }) } /// Used by LSP to inform a client requesting a JIT Channel the token they used is invalid. diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index e03cd0a00a3..942f9d22f24 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -21,7 +21,7 @@ use crate::prelude::*; use crate::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; use crate::utils::time::TimeProvider; -use crate::lsps2::service::OutboundJITChannelState; +use crate::lsps2::service::{OutboundJITChannelState, OutboundJITStage}; use bitcoin::secp256k1::PublicKey; use lightning::ln::channelmanager::AChannelManager; @@ -161,15 +161,7 @@ where ) -> bool { self.client_has_open_channel(client_id) || lsps1_has_activity - || lsps2_max_state.map_or(false, |s| { - matches!( - s, - OutboundJITChannelState::PendingChannelOpen { .. } - | OutboundJITChannelState::PendingPaymentForward { .. } - | OutboundJITChannelState::PendingPayment { .. } - | OutboundJITChannelState::PaymentForwarded { .. } - ) - }) + || lsps2_max_state.map_or(false, |s| s.stage() >= OutboundJITStage::PendingChannelOpen) } fn check_prune_stale_webhooks<'a>( From cb66a95e0dde5d1a7a33b56aa97101eb1ca73221 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Wed, 13 Aug 2025 14:52:30 -0300 Subject: [PATCH 3/6] fixup: make OutboundJITChannelState private, and do bool helper for checking if lsps2 has any pendingChannelOpen or above --- lightning-liquidity/src/lsps2/service.rs | 111 ++------------- lightning-liquidity/src/lsps5/service.rs | 10 +- lightning-liquidity/src/manager.rs | 6 +- .../tests/lsps5_integration_tests.rs | 134 +++++++++++------- 4 files changed, 104 insertions(+), 157 deletions(-) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index e8ec5d8f23d..821d9be744c 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -107,18 +107,9 @@ struct ForwardPaymentAction(ChannelId, FeePayment); #[derive(Debug, PartialEq)] struct ForwardHTLCsAction(ChannelId, Vec); -#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd)] -pub(crate) enum OutboundJITStage { - PendingInitialPayment, - PendingChannelOpen, - PendingPaymentForward, - PendingPayment, - PaymentForwarded, -} - /// The different states a requested JIT channel can be in. -#[derive(Debug, PartialEq, Eq, Clone)] -pub(crate) enum OutboundJITChannelState { +#[derive(Debug)] +enum OutboundJITChannelState { /// The JIT channel SCID was created after a buy request, and we are awaiting an initial payment /// of sufficient size to open the channel. PendingInitialPayment { payment_queue: PaymentQueue }, @@ -143,36 +134,6 @@ pub(crate) enum OutboundJITChannelState { PaymentForwarded { channel_id: ChannelId }, } -impl OutboundJITChannelState { - pub(crate) fn stage(&self) -> OutboundJITStage { - match self { - OutboundJITChannelState::PendingInitialPayment { .. } => { - OutboundJITStage::PendingInitialPayment - }, - OutboundJITChannelState::PendingChannelOpen { .. } => { - OutboundJITStage::PendingChannelOpen - }, - OutboundJITChannelState::PendingPaymentForward { .. } => { - OutboundJITStage::PendingPaymentForward - }, - OutboundJITChannelState::PendingPayment { .. } => OutboundJITStage::PendingPayment, - OutboundJITChannelState::PaymentForwarded { .. } => OutboundJITStage::PaymentForwarded, - } - } -} - -impl PartialOrd for OutboundJITChannelState { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for OutboundJITChannelState { - fn cmp(&self, other: &Self) -> core::cmp::Ordering { - self.stage().cmp(&other.stage()) - } -} - impl OutboundJITChannelState { fn new() -> Self { OutboundJITChannelState::PendingInitialPayment { payment_queue: PaymentQueue::new() } @@ -605,13 +566,20 @@ where &self.config } - pub(crate) fn highest_state_for_peer( - &self, counterparty_node_id: &PublicKey, - ) -> Option { + /// Returns whether the peer has any opening or open JIT channels. + pub(crate) fn has_opening_or_open_jit_channel(&self, counterparty_node_id: &PublicKey) -> bool { let outer_state_lock = self.per_peer_state.read().unwrap(); - outer_state_lock.get(counterparty_node_id).and_then(|inner| { + outer_state_lock.get(counterparty_node_id).map_or(false, |inner| { let peer_state = inner.lock().unwrap(); - peer_state.outbound_channels_by_intercept_scid.values().map(|c| c.state.clone()).max() + peer_state.outbound_channels_by_intercept_scid.values().any(|chan| { + matches!( + chan.state, + OutboundJITChannelState::PendingChannelOpen { .. } + | OutboundJITChannelState::PendingPaymentForward { .. } + | OutboundJITChannelState::PendingPayment { .. } + | OutboundJITChannelState::PaymentForwarded { .. } + ) + }) }) } @@ -1948,55 +1916,4 @@ mod tests { ); } } - - #[test] - fn highest_state_for_peer_orders() { - let opening_fee_params = LSPS2OpeningFeeParams { - min_fee_msat: 0, - proportional: 0, - valid_until: LSPSDateTime::from_str("1970-01-01T00:00:00Z").unwrap(), - min_lifetime: 0, - max_client_to_self_delay: 0, - min_payment_size_msat: 0, - max_payment_size_msat: 0, - promise: String::new(), - }; - - let mut map = new_hash_map(); - map.insert( - 0, - OutboundJITChannel { - state: OutboundJITChannelState::PendingInitialPayment { - payment_queue: PaymentQueue::new(), - }, - user_channel_id: 0, - opening_fee_params: opening_fee_params.clone(), - payment_size_msat: None, - }, - ); - map.insert( - 1, - OutboundJITChannel { - state: OutboundJITChannelState::PendingChannelOpen { - payment_queue: PaymentQueue::new(), - opening_fee_msat: 0, - }, - user_channel_id: 1, - opening_fee_params: opening_fee_params.clone(), - payment_size_msat: None, - }, - ); - map.insert( - 2, - OutboundJITChannel { - state: OutboundJITChannelState::PaymentForwarded { channel_id: ChannelId([0; 32]) }, - user_channel_id: 2, - opening_fee_params, - payment_size_msat: None, - }, - ); - - let max_state = map.values().map(|c| c.state.clone()).max().unwrap(); - assert!(matches!(max_state, OutboundJITChannelState::PaymentForwarded { .. })); - } } diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index 942f9d22f24..ad84abb142e 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -21,7 +21,6 @@ use crate::prelude::*; use crate::sync::{Arc, Mutex, RwLock, RwLockWriteGuard}; use crate::utils::time::TimeProvider; -use crate::lsps2::service::{OutboundJITChannelState, OutboundJITStage}; use bitcoin::secp256k1::PublicKey; use lightning::ln::channelmanager::AChannelManager; @@ -153,15 +152,14 @@ where /// Returns whether a request from the given client should be accepted. /// /// Prior activity includes an existing open channel, an active LSPS1 flow, - /// or an LSPS2 flow that has progressed to at least - /// [`OutboundJITChannelState::PendingChannelOpen`]. + /// or an LSPS2 flow that has an opening or open JIT channel. pub(crate) fn can_accept_request( - &self, client_id: &PublicKey, lsps2_max_state: Option, + &self, client_id: &PublicKey, lsps2_has_opening_or_open_jit_channel: bool, lsps1_has_activity: bool, ) -> bool { self.client_has_open_channel(client_id) + || lsps2_has_opening_or_open_jit_channel || lsps1_has_activity - || lsps2_max_state.map_or(false, |s| s.stage() >= OutboundJITStage::PendingChannelOpen) } fn check_prune_stale_webhooks<'a>( @@ -502,7 +500,7 @@ where .map_err(|_| LSPS5ProtocolError::UnknownError) } - pub(crate) fn client_has_open_channel(&self, client_id: &PublicKey) -> bool { + fn client_has_open_channel(&self, client_id: &PublicKey) -> bool { self.channel_manager .get_cm() .list_channels() diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index afa147e292f..36723d16b26 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -568,10 +568,10 @@ where LSPSMessage::LSPS5(msg @ LSPS5Message::Request(..)) => { match &self.lsps5_service_handler { Some(lsps5_service_handler) => { - let lsps2_max_state = self + let lsps2_has_opening_or_open_jit_channel = self .lsps2_service_handler .as_ref() - .and_then(|h| h.highest_state_for_peer(sender_node_id)); + .map_or(false, |h| h.has_opening_or_open_jit_channel(sender_node_id)); #[cfg(lsps1_service)] let lsps1_has_active_requests = self .lsps1_service_handler @@ -582,7 +582,7 @@ where if !lsps5_service_handler.can_accept_request( sender_node_id, - lsps2_max_state, + lsps2_has_opening_or_open_jit_channel, lsps1_has_active_requests, ) { return Err(LightningError { diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 69e64a768dc..5ee47bcd2df 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -1230,6 +1230,49 @@ fn test_notifications_and_peer_connected_resets_cooldown() { } } +macro_rules! assert_lsps5_reject { + ($client_handler:expr, $service_node:expr, $client_node:expr, $service_node_id:expr, $client_node_id:expr) => {{ + let _ = $client_handler + .set_webhook( + $service_node_id, + "App".to_string(), + "https://example.org/webhook".to_string(), + ) + .expect("Request should send"); + 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(), "Service should reject request without prior interaction"); + + assert!($service_node.liquidity_manager.get_and_clear_pending_msg().is_empty()); + }}; +} + +macro_rules! assert_lsps5_accept { + ($client_handler:expr, $service_node:expr, $client_node:expr, $service_node_id:expr, $client_node_id:expr) => {{ + let _ = $client_handler + .set_webhook( + $service_node_id, + "App".to_string(), + "https://example.org/webhook".to_string(), + ) + .expect("Request should send"); + 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_ok(), "Service should accept request after prior interaction"); + 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) + .expect("Client should handle response"); + let _ = $client_node.liquidity_manager.next_event().unwrap(); + }}; +} + #[test] fn webhook_update_affects_future_notifications() { let mock_time_provider = Arc::new(MockTimeProvider::new(1000)); @@ -1306,51 +1349,26 @@ fn dos_protection() { let client_handler = client_node.liquidity_manager.lsps5_client_handler().unwrap(); - let assert_reject = || -> () { - let _ = client_handler - .set_webhook( - service_node_id, - "App".to_string(), - "https://example.org/webhook".to_string(), - ) - .expect("Request should send"); - 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(), "Service should reject request without prior interaction"); - - assert!(service_node.liquidity_manager.get_and_clear_pending_msg().is_empty()); - }; - - let assert_accept = || -> () { - let _ = client_handler - .set_webhook( - service_node_id, - "App".to_string(), - "https://example.org/webhook".to_string(), - ) - .expect("Request should send"); - 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_ok(), "Service should accept request after prior interaction"); - 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) - .expect("Client should handle response"); - let _ = client_node.liquidity_manager.next_event().unwrap(); - }; - // no channel is open so far -> should reject - assert_reject(); + assert_lsps5_reject!( + client_handler, + service_node, + client_node, + service_node_id, + client_node_id + ); let (_, _, _, channel_id, funding_tx) = create_chan_between_nodes(&service_node.inner, &client_node.inner); // now that a channel is open, should accept - assert_accept(); + assert_lsps5_accept!( + client_handler, + service_node, + client_node, + service_node_id, + client_node_id + ); close_channel(&service_node.inner, &client_node.inner, &channel_id, funding_tx, true); let node_a_reason = ClosureReason::CounterpartyInitiatedCooperativeClosure; @@ -1359,7 +1377,13 @@ fn dos_protection() { check_closed_event!(client_node.inner, 1, node_b_reason, [service_node_id], 100000); // channel is now closed again -> should reject - assert_reject(); + assert_lsps5_reject!( + client_handler, + service_node, + client_node, + service_node_id, + client_node_id + ); } #[test] @@ -1370,20 +1394,28 @@ fn lsps2_state_allows_lsps5_request() { let nodes = create_network(2, &node_cfgs, &node_chanmgrs); let (lsps_nodes, _) = lsps5_lsps2_test_setup(nodes, Arc::new(DefaultTimeProvider)); - establish_lsps2_prior_interaction(&lsps_nodes); - let LSPSNodes { service_node, client_node } = lsps_nodes; - let service_node_id = service_node.inner.node.get_our_node_id(); - let client_node_id = client_node.inner.node.get_our_node_id(); + let client_node_id = lsps_nodes.client_node.inner.node.get_our_node_id(); + let service_node_id = lsps_nodes.service_node.inner.node.get_our_node_id(); + let client_handler = lsps_nodes.client_node.liquidity_manager.lsps5_client_handler().unwrap(); - let lsps5_client = client_node.liquidity_manager.lsps5_client_handler().unwrap(); + assert_lsps5_reject!( + client_handler, + lsps_nodes.service_node, + lsps_nodes.client_node, + service_node_id, + client_node_id + ); - let _ = lsps5_client - .set_webhook(service_node_id, "App".to_string(), "https://example.org/webhook".to_string()) - .expect("Request should send"); - 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_ok(), "Service should accept request based on LSPS2 state"); + establish_lsps2_prior_interaction(&lsps_nodes); + + assert_lsps5_accept!( + client_handler, + lsps_nodes.service_node, + lsps_nodes.client_node, + service_node_id, + client_node_id + ); } fn establish_lsps2_prior_interaction(lsps_nodes: &LSPSNodes) { From b52219416b7fa873d63c3580931d4ba3ad250642 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Mon, 18 Aug 2025 12:01:46 -0300 Subject: [PATCH 4/6] fixup: accept lsps5 requests if lsps2 has an active request in any OutboundJITChannelState --- lightning-liquidity/src/lsps2/service.rs | 14 +++----------- lightning-liquidity/src/lsps5/service.rs | 7 ++----- lightning-liquidity/src/manager.rs | 6 +++--- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 821d9be744c..8e67140be0f 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -566,20 +566,12 @@ where &self.config } - /// Returns whether the peer has any opening or open JIT channels. - pub(crate) fn has_opening_or_open_jit_channel(&self, counterparty_node_id: &PublicKey) -> bool { + /// Returns whether the peer has any active LSPS2 requests. + pub(crate) fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool { let outer_state_lock = self.per_peer_state.read().unwrap(); outer_state_lock.get(counterparty_node_id).map_or(false, |inner| { let peer_state = inner.lock().unwrap(); - peer_state.outbound_channels_by_intercept_scid.values().any(|chan| { - matches!( - chan.state, - OutboundJITChannelState::PendingChannelOpen { .. } - | OutboundJITChannelState::PendingPaymentForward { .. } - | OutboundJITChannelState::PendingPayment { .. } - | OutboundJITChannelState::PaymentForwarded { .. } - ) - }) + !peer_state.outbound_channels_by_intercept_scid.is_empty() }) } diff --git a/lightning-liquidity/src/lsps5/service.rs b/lightning-liquidity/src/lsps5/service.rs index ad84abb142e..e14ce7a1c0a 100644 --- a/lightning-liquidity/src/lsps5/service.rs +++ b/lightning-liquidity/src/lsps5/service.rs @@ -154,12 +154,9 @@ where /// Prior activity includes an existing open channel, an active LSPS1 flow, /// or an LSPS2 flow that has an opening or open JIT channel. pub(crate) fn can_accept_request( - &self, client_id: &PublicKey, lsps2_has_opening_or_open_jit_channel: bool, - lsps1_has_activity: bool, + &self, client_id: &PublicKey, lsps2_has_active_requests: bool, lsps1_has_activity: bool, ) -> bool { - self.client_has_open_channel(client_id) - || lsps2_has_opening_or_open_jit_channel - || lsps1_has_activity + self.client_has_open_channel(client_id) || lsps2_has_active_requests || lsps1_has_activity } fn check_prune_stale_webhooks<'a>( diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 36723d16b26..c5fdc2aae97 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -568,10 +568,10 @@ where LSPSMessage::LSPS5(msg @ LSPS5Message::Request(..)) => { match &self.lsps5_service_handler { Some(lsps5_service_handler) => { - let lsps2_has_opening_or_open_jit_channel = self + let lsps2_has_active_requests = self .lsps2_service_handler .as_ref() - .map_or(false, |h| h.has_opening_or_open_jit_channel(sender_node_id)); + .map_or(false, |h| h.has_active_requests(sender_node_id)); #[cfg(lsps1_service)] let lsps1_has_active_requests = self .lsps1_service_handler @@ -582,7 +582,7 @@ where if !lsps5_service_handler.can_accept_request( sender_node_id, - lsps2_has_opening_or_open_jit_channel, + lsps2_has_active_requests, lsps1_has_active_requests, ) { return Err(LightningError { From 4c1b4a1f212ff0ad3c3bd80028194e48cfc54630 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Fri, 22 Aug 2025 12:55:00 -0300 Subject: [PATCH 5/6] fixup: on lsps1, don't count pending_requests as an active flow. the service has not responded yet, so don't count that for DoS protection purposes --- lightning-liquidity/src/lsps1/service.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps1/service.rs b/lightning-liquidity/src/lsps1/service.rs index 4acfb81eb32..1b4bdf5cf46 100644 --- a/lightning-liquidity/src/lsps1/service.rs +++ b/lightning-liquidity/src/lsps1/service.rs @@ -174,12 +174,17 @@ where &self.config } + /// Returns whether the peer currently has any active LSPS1 order flows. + /// + /// An order is considered active only after we have validated the client's + /// `CreateOrder` request and replied with a `CreateOrder` response containing + /// an `order_id`. + /// Pending requests that are still awaiting our response are deliberately NOT counted. pub(crate) fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool { let outer_state_lock = self.per_peer_state.read().unwrap(); outer_state_lock.get(counterparty_node_id).map_or(false, |inner| { let peer_state = inner.lock().unwrap(); - !(peer_state.pending_requests.is_empty() - && peer_state.outbound_channels_by_order_id.is_empty()) + !peer_state.outbound_channels_by_order_id.is_empty() }) } From e9e2add74a9504f1174238c569977673c3118648 Mon Sep 17 00:00:00 2001 From: Martin Saposnic Date: Fri, 22 Aug 2025 12:55:44 -0300 Subject: [PATCH 6/6] fixup: only apply DoS protections for state allocating operations. don't ignore all messages from the peer on lsps5. just the state allocating ones --- lightning-liquidity/src/lsps5/msgs.rs | 6 ++++ lightning-liquidity/src/manager.rs | 52 ++++++++++++++------------- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/lightning-liquidity/src/lsps5/msgs.rs b/lightning-liquidity/src/lsps5/msgs.rs index ada1f263e03..d4a7625de57 100644 --- a/lightning-liquidity/src/lsps5/msgs.rs +++ b/lightning-liquidity/src/lsps5/msgs.rs @@ -640,6 +640,12 @@ pub enum LSPS5Request { RemoveWebhook(RemoveWebhookRequest), } +impl LSPS5Request { + pub(crate) fn is_state_allocating(&self) -> bool { + matches!(self, LSPS5Request::SetWebhook(_)) + } +} + /// An LSPS5 protocol response. #[derive(Clone, Debug, PartialEq, Eq)] pub enum LSPS5Response { diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index c5fdc2aae97..d6b7a8afc6a 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -568,30 +568,34 @@ where LSPSMessage::LSPS5(msg @ LSPS5Message::Request(..)) => { match &self.lsps5_service_handler { Some(lsps5_service_handler) => { - let lsps2_has_active_requests = self - .lsps2_service_handler - .as_ref() - .map_or(false, |h| h.has_active_requests(sender_node_id)); - #[cfg(lsps1_service)] - let lsps1_has_active_requests = self - .lsps1_service_handler - .as_ref() - .map_or(false, |h| h.has_active_requests(sender_node_id)); - #[cfg(not(lsps1_service))] - let lsps1_has_active_requests = false; - - if !lsps5_service_handler.can_accept_request( - sender_node_id, - lsps2_has_active_requests, - lsps1_has_active_requests, - ) { - return Err(LightningError { - err: format!( - "Rejecting LSPS5 request from {:?} without prior activity (requires open channel or active LSPS1 or LSPS2 flow)", - sender_node_id - ), - action: ErrorAction::IgnoreAndLog(Level::Debug), - }); + if let LSPS5Message::Request(_, ref req) = msg { + if req.is_state_allocating() { + let lsps2_has_active_requests = self + .lsps2_service_handler + .as_ref() + .map_or(false, |h| h.has_active_requests(sender_node_id)); + #[cfg(lsps1_service)] + let lsps1_has_active_requests = self + .lsps1_service_handler + .as_ref() + .map_or(false, |h| h.has_active_requests(sender_node_id)); + #[cfg(not(lsps1_service))] + let lsps1_has_active_requests = false; + + if !lsps5_service_handler.can_accept_request( + sender_node_id, + lsps2_has_active_requests, + lsps1_has_active_requests, + ) { + return Err(LightningError { + err: format!( + "Rejecting LSPS5 request from {:?} without prior activity (requires open channel or active LSPS1 or LSPS2 flow)", + sender_node_id + ), + action: ErrorAction::IgnoreAndLog(Level::Debug), + }); + } + } } lsps5_service_handler.handle_message(msg, sender_node_id)?;