From 706d90d8b62543da9e4389eb0e698938fa296fd0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 6 Feb 2026 11:46:16 +0100 Subject: [PATCH 1/7] Add per-peer onion message interception API to `OnionMessenger` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `register_peer_for_interception()` and `deregister_peer_for_interception()` methods to `OnionMessenger`, allowing specific peers to be registered for onion message interception without enabling blanket interception for all offline peers. When a registered peer is offline and an onion message needs to be forwarded to them, `Event::OnionMessageIntercepted` is emitted. When a registered peer connects, `Event::OnionMessagePeerConnected` is emitted. This works alongside the existing global `new_with_offline_peer_interception()` flag — if either the global flag is set or the peer is specifically registered, interception occurs. This enables LSPS2 services to intercept onion messages only for peers with active JIT channel sessions, rather than intercepting messages for all offline peers. Co-Authored-By: HAL 9000 --- lightning/src/onion_message/messenger.rs | 180 ++++++++++++++++++++++- 1 file changed, 176 insertions(+), 4 deletions(-) diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index f94eb7877f5..7789983140d 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -125,6 +125,82 @@ impl< } } +/// A trait for registering peers and SCIDs for onion message interception. +/// +/// When a peer is registered for interception and is currently offline, any onion messages +/// intended to be forwarded to them will generate an [`Event::OnionMessageIntercepted`] instead +/// of being dropped. When a registered peer connects, an [`Event::OnionMessagePeerConnected`] +/// will be generated. +/// +/// Additionally, SCIDs (short channel IDs) can be registered for interception. When an onion +/// message is forwarded with a [`NextMessageHop::ShortChannelId`] that cannot be resolved via +/// [`NodeIdLookUp`] but is registered here, an [`Event::OnionMessageIntercepted`] will be +/// generated using the associated peer's node ID. This enables compact SCID-based encoding in +/// blinded message paths for scenarios like LSPS2 JIT channels where the SCID is a fake +/// intercept SCID that does not correspond to a real channel. +/// +/// [`OnionMessenger`] implements this trait, but it is also useful as a trait object to allow +/// external components (e.g., an LSPS2 service) to register peers for interception without +/// needing to know the concrete [`OnionMessenger`] type. +/// +/// [`NextMessageHop::ShortChannelId`]: crate::blinded_path::message::NextMessageHop::ShortChannelId +/// [`Event::OnionMessageIntercepted`]: crate::events::Event::OnionMessageIntercepted +/// [`Event::OnionMessagePeerConnected`]: crate::events::Event::OnionMessagePeerConnected +pub trait OnionMessageInterceptor { + /// Registers a peer for onion message interception. + /// + /// See [`OnionMessenger::register_peer_for_interception`] for more details. + fn register_peer_for_interception(&self, peer_node_id: PublicKey); + + /// Deregisters a peer from onion message interception. + /// + /// See [`OnionMessenger::deregister_peer_for_interception`] for more details. + /// + /// Returns whether the peer was previously registered. + fn deregister_peer_for_interception(&self, peer_node_id: &PublicKey) -> bool; + + /// Registers a short channel ID for onion message interception. + /// + /// See [`OnionMessenger::register_scid_for_interception`] for more details. + fn register_scid_for_interception(&self, scid: u64, peer_node_id: PublicKey); + + /// Deregisters a short channel ID from onion message interception. + /// + /// See [`OnionMessenger::deregister_scid_for_interception`] for more details. + /// + /// Returns whether the SCID was previously registered. + fn deregister_scid_for_interception(&self, scid: u64) -> bool; +} + +impl< + ES: EntropySource, + NS: NodeSigner, + L: Logger, + NL: NodeIdLookUp, + MR: MessageRouter, + OMH: OffersMessageHandler, + APH: AsyncPaymentsMessageHandler, + DRH: DNSResolverMessageHandler, + CMH: CustomOnionMessageHandler, + > OnionMessageInterceptor for OnionMessenger +{ + fn register_peer_for_interception(&self, peer_node_id: PublicKey) { + OnionMessenger::register_peer_for_interception(self, peer_node_id) + } + + fn deregister_peer_for_interception(&self, peer_node_id: &PublicKey) -> bool { + OnionMessenger::deregister_peer_for_interception(self, peer_node_id) + } + + fn register_scid_for_interception(&self, scid: u64, peer_node_id: PublicKey) { + OnionMessenger::register_scid_for_interception(self, scid, peer_node_id) + } + + fn deregister_scid_for_interception(&self, scid: u64) -> bool { + OnionMessenger::deregister_scid_for_interception(self, scid) + } +} + /// A sender, receiver and forwarder of [`OnionMessage`]s. /// /// # Handling Messages @@ -273,6 +349,8 @@ pub struct OnionMessenger< dns_resolver_handler: DRH, custom_handler: CMH, intercept_messages_for_offline_peers: bool, + peers_registered_for_interception: Mutex>, + scids_registered_for_interception: Mutex>, pending_intercepted_msgs_events: Mutex>, pending_peer_connected_events: Mutex>, pending_events_processor: AtomicBool, @@ -1453,6 +1531,8 @@ impl< dns_resolver_handler: dns_resolver, custom_handler, intercept_messages_for_offline_peers, + peers_registered_for_interception: Mutex::new(new_hash_set()), + scids_registered_for_interception: Mutex::new(new_hash_map()), pending_intercepted_msgs_events: Mutex::new(Vec::new()), pending_peer_connected_events: Mutex::new(Vec::new()), pending_events_processor: AtomicBool::new(false), @@ -1470,6 +1550,65 @@ impl< self.async_payments_handler = async_payments_handler; } + /// Registers a peer for onion message interception. + /// + /// When an onion message needs to be forwarded to a registered peer that is currently offline, + /// an [`Event::OnionMessageIntercepted`] will be generated, allowing the message to be stored + /// and forwarded later when the peer reconnects. + /// + /// Similarly, when a registered peer connects, an [`Event::OnionMessagePeerConnected`] will + /// be generated. + /// + /// This is useful for services like LSPS2 that need to intercept onion messages for specific + /// peers (e.g., those with active JIT channel sessions) without enabling blanket interception + /// for all offline peers via [`Self::new_with_offline_peer_interception`]. + /// + /// Use [`Self::deregister_peer_for_interception`] to stop intercepting messages for this peer. + /// + /// [`Event::OnionMessageIntercepted`]: crate::events::Event::OnionMessageIntercepted + /// [`Event::OnionMessagePeerConnected`]: crate::events::Event::OnionMessagePeerConnected + pub fn register_peer_for_interception(&self, peer_node_id: PublicKey) { + self.peers_registered_for_interception.lock().unwrap().insert(peer_node_id); + } + + /// Deregisters a peer from onion message interception. + /// + /// After this call, onion messages for this peer will no longer be intercepted (unless + /// blanket interception is enabled via [`Self::new_with_offline_peer_interception`]). + /// + /// Returns whether the peer was previously registered. + pub fn deregister_peer_for_interception(&self, peer_node_id: &PublicKey) -> bool { + self.peers_registered_for_interception.lock().unwrap().remove(peer_node_id) + } + + /// Registers a short channel ID for onion message interception, associating it with + /// `peer_node_id`. + /// + /// When an onion message is forwarded with a [`NextMessageHop::ShortChannelId`] that cannot + /// be resolved via [`NodeIdLookUp`] but matches a registered SCID, an + /// [`Event::OnionMessageIntercepted`] will be generated using the associated `peer_node_id`. + /// + /// This is useful for services like LSPS2 where fake intercept SCIDs are used in compact + /// blinded message paths. The SCID does not correspond to a real channel, so + /// [`NodeIdLookUp`] cannot resolve it, but the message should still be intercepted rather + /// than dropped. + /// + /// Use [`Self::deregister_scid_for_interception`] to stop intercepting messages for this + /// SCID. + /// + /// [`NextMessageHop::ShortChannelId`]: crate::blinded_path::message::NextMessageHop::ShortChannelId + /// [`Event::OnionMessageIntercepted`]: crate::events::Event::OnionMessageIntercepted + pub fn register_scid_for_interception(&self, scid: u64, peer_node_id: PublicKey) { + self.scids_registered_for_interception.lock().unwrap().insert(scid, peer_node_id); + } + + /// Deregisters a short channel ID from onion message interception. + /// + /// Returns whether the SCID was previously registered. + pub fn deregister_scid_for_interception(&self, scid: u64) -> bool { + self.scids_registered_for_interception.lock().unwrap().remove(&scid).is_some() + } + /// Sends an [`OnionMessage`] based on its [`MessageSendInstructions`]. pub fn send_onion_message( &self, contents: T, instructions: MessageSendInstructions, @@ -1664,8 +1803,30 @@ impl< NextMessageHop::ShortChannelId(scid) => match self.node_id_lookup.next_node_id(scid) { Some(pubkey) => pubkey, None => { - log_trace!(self.logger, "Dropping forwarded onion messager: unable to resolve next hop using SCID {} {}", scid, log_suffix); - return Err(SendError::GetNodeIdFailed); + // The SCID is unknown to NodeIdLookUp (not a real channel). Check + // if it's registered for SCID-based interception before dropping. + match self.scids_registered_for_interception.lock().unwrap().get(&scid).copied() + { + Some(peer_node_id) => { + log_trace!( + self.logger, + "Generating OnionMessageIntercepted event for \ + SCID {} (peer {}) {}", + scid, + peer_node_id, + log_suffix + ); + self.enqueue_intercepted_event(Event::OnionMessageIntercepted { + peer_node_id, + message: onion_message, + }); + return Ok(()); + }, + None => { + log_trace!(self.logger, "Dropping forwarded onion message: unable to resolve next hop using SCID {} {}", scid, log_suffix); + return Err(SendError::GetNodeIdFailed); + }, + } }, }, }; @@ -1686,6 +1847,9 @@ impl< .entry(next_node_id) .or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new())); + let should_intercept = self.intercept_messages_for_offline_peers + || self.peers_registered_for_interception.lock().unwrap().contains(&next_node_id); + match message_recipients.entry(next_node_id) { hash_map::Entry::Occupied(mut e) if matches!(e.get(), OnionMessageRecipient::ConnectedPeer(..)) => @@ -1699,7 +1863,7 @@ impl< ); Ok(()) }, - _ if self.intercept_messages_for_offline_peers => { + _ if should_intercept => { log_trace!( self.logger, "Generating OnionMessageIntercepted event for peer {} {}", @@ -2142,7 +2306,15 @@ impl< .or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new())) .mark_connected(); } - if self.intercept_messages_for_offline_peers { + let is_registered = + self.peers_registered_for_interception.lock().unwrap().contains(&their_node_id); + let is_registered_by_scid = self + .scids_registered_for_interception + .lock() + .unwrap() + .values() + .any(|nid| *nid == their_node_id); + if self.intercept_messages_for_offline_peers || is_registered || is_registered_by_scid { let mut pending_peer_connected_events = self.pending_peer_connected_events.lock().unwrap(); pending_peer_connected_events From 2cf201a429b7cf29a1d4f8814ac16fe9a0628527 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 6 Feb 2026 12:07:20 +0100 Subject: [PATCH 2/7] Wire `OnionMessageInterceptor` into LSPS2 service handler Define the `OnionMessageInterceptor` trait with `register_peer_for_interception()` and `deregister_peer_for_interception()` methods, and implement it for `OnionMessenger`. This allows external components to register peers for onion message interception via a trait object, without needing to know the concrete `OnionMessenger` type. Wire the trait into `LSPS2ServiceHandler` as an optional `Arc`. When provided: - On init, all peers with active intercept SCIDs are registered - In `invoice_parameters_generated()`, the counterparty is registered when a new intercept SCID is assigned This ensures that onion messages for LSPS2 clients with active JIT channel sessions are intercepted when those clients are offline, enabling the LSP to store and forward messages when the client reconnects. Co-Authored-By: HAL 9000 --- fuzz/src/lsps_message.rs | 1 + lightning-background-processor/src/lib.rs | 1 + lightning-liquidity/src/lsps2/service.rs | 90 +++++++++++++++++-- lightning-liquidity/src/manager.rs | 9 ++ lightning-liquidity/tests/common/mod.rs | 2 + .../tests/lsps1_integration_tests.rs | 4 + .../tests/lsps2_integration_tests.rs | 1 + .../tests/lsps5_integration_tests.rs | 1 + 8 files changed, 100 insertions(+), 9 deletions(-) diff --git a/fuzz/src/lsps_message.rs b/fuzz/src/lsps_message.rs index 8ff85d0fc24..f054bacdb0c 100644 --- a/fuzz/src/lsps_message.rs +++ b/fuzz/src/lsps_message.rs @@ -87,6 +87,7 @@ pub fn do_test(data: &[u8]) { Arc::clone(&tx_broadcaster), None, None, + None, ) .unwrap(), ); diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index 4d6e770c099..0b15d8a67fb 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -2556,6 +2556,7 @@ mod tests { Arc::clone(&tx_broadcaster), None, None, + None, ) .unwrap(), ); diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index b7f6f2fc64d..6e137197e20 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -45,6 +45,7 @@ use lightning::events::HTLCHandlingFailureType; use lightning::ln::channelmanager::{AChannelManager, FailureCode, InterceptId}; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::types::ChannelId; +use lightning::onion_message::messenger::OnionMessageInterceptor; use lightning::util::errors::APIError; use lightning::util::logger::Level; use lightning::util::ser::Writeable; @@ -717,6 +718,7 @@ where total_pending_requests: AtomicUsize, config: LSPS2ServiceConfig, persistence_in_flight: AtomicUsize, + onion_message_interceptor: Option>, } impl LSPS2ServiceHandler @@ -728,6 +730,7 @@ where per_peer_state: HashMap>, pending_messages: Arc, pending_events: Arc>, channel_manager: CM, kv_store: K, tx_broadcaster: T, config: LSPS2ServiceConfig, + onion_message_interceptor: Option>, ) -> Result { let mut peer_by_intercept_scid = new_hash_map(); let mut peer_by_channel_id = new_hash_map(); @@ -756,6 +759,17 @@ where } } + // Register all peers and SCIDs with active intercept SCIDs for onion message + // interception, so that messages for offline peers are held rather than dropped. + // Both peer-based and SCID-based registration are needed to support clients using + // either pubkey or compact SCID encoding in their message blinded paths. + if let Some(ref interceptor) = onion_message_interceptor { + for (scid, node_id) in &peer_by_intercept_scid { + interceptor.register_peer_for_interception(*node_id); + interceptor.register_scid_for_interception(*scid, *node_id); + } + } + Ok(Self { pending_messages, pending_events, @@ -768,6 +782,7 @@ where kv_store, tx_broadcaster, config, + onion_message_interceptor, }) } @@ -776,6 +791,33 @@ where &self.config } + /// Cleans up `peer_by_intercept_scid` entries for the given SCIDs, and deregisters the peer + /// from onion message interception if they have no remaining active intercept SCIDs. + fn cleanup_intercept_scids( + &self, counterparty_node_id: &PublicKey, pruned_scids: &[u64], has_remaining_channels: bool, + ) { + if pruned_scids.is_empty() { + return; + } + + { + let mut peer_by_intercept_scid = self.peer_by_intercept_scid.write().unwrap(); + for scid in pruned_scids { + peer_by_intercept_scid.remove(scid); + } + } + + if let Some(ref interceptor) = self.onion_message_interceptor { + for scid in pruned_scids { + interceptor.deregister_scid_for_interception(*scid); + } + + if !has_remaining_channels { + interceptor.deregister_peer_for_interception(counterparty_node_id); + } + } + } + /// 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(); @@ -921,6 +963,14 @@ where peer_by_intercept_scid.insert(intercept_scid, *counterparty_node_id); } + if let Some(ref interceptor) = self.onion_message_interceptor { + interceptor.register_peer_for_interception(*counterparty_node_id); + interceptor.register_scid_for_interception( + intercept_scid, + *counterparty_node_id, + ); + } + let outbound_jit_channel = OutboundJITChannel::new( buy_request.payment_size_msat, buy_request.opening_fee_params, @@ -990,17 +1040,17 @@ where let event_queue_notifier = self.pending_events.notifier(); let mut should_persist = None; - if let Some(counterparty_node_id) = - self.peer_by_intercept_scid.read().unwrap().get(&intercept_scid) - { + let counterparty_node_id = + self.peer_by_intercept_scid.read().unwrap().get(&intercept_scid).copied(); + if let Some(counterparty_node_id) = counterparty_node_id { let outer_state_lock = self.per_peer_state.read().unwrap(); - match outer_state_lock.get(counterparty_node_id) { + match outer_state_lock.get(&counterparty_node_id) { Some(inner_state_lock) => { let mut peer_state = inner_state_lock.lock().unwrap(); if let Some(jit_channel) = peer_state.outbound_channels_by_intercept_scid.get_mut(&intercept_scid) { - should_persist = Some(*counterparty_node_id); + should_persist = Some(counterparty_node_id); let htlc = InterceptedHTLC { intercept_id, expected_outbound_amount_msat, @@ -1009,7 +1059,7 @@ where match jit_channel.htlc_intercepted(htlc) { Ok(Some(HTLCInterceptedAction::OpenChannel(open_channel_params))) => { let event = LSPS2ServiceEvent::OpenChannel { - their_network_key: counterparty_node_id.clone(), + their_network_key: counterparty_node_id, amt_to_forward_msat: open_channel_params.amt_to_forward_msat, opening_fee_msat: open_channel_params.opening_fee_msat, user_channel_id: jit_channel.user_channel_id, @@ -1021,7 +1071,7 @@ where self.channel_manager.get_cm().forward_intercepted_htlc( intercept_id, &channel_id, - *counterparty_node_id, + counterparty_node_id, expected_outbound_amount_msat, )?; }, @@ -1038,7 +1088,7 @@ where self.channel_manager.get_cm().forward_intercepted_htlc( intercept_id, &channel_id, - *counterparty_node_id, + counterparty_node_id, amount_to_forward_msat, )?; } @@ -1051,7 +1101,13 @@ where peer_state .outbound_channels_by_intercept_scid .remove(&intercept_scid); - // TODO: cleanup peer_by_intercept_scid + let has_remaining = + !peer_state.outbound_channels_by_intercept_scid.is_empty(); + self.cleanup_intercept_scids( + &counterparty_node_id, + &[intercept_scid], + has_remaining, + ); return Err(APIError::APIMisuseError { err: e.err }); }, } @@ -1858,6 +1914,22 @@ where debug_assert!(false); } } + if future_opt.is_some() { + // Clean up handler-level maps for the removed peer. + let removed_scids: Vec = self + .peer_by_intercept_scid + .read() + .unwrap() + .iter() + .filter(|(_, nid)| **nid == counterparty_node_id) + .map(|(scid, _)| *scid) + .collect(); + self.cleanup_intercept_scids(&counterparty_node_id, &removed_scids, false); + self.peer_by_channel_id + .write() + .unwrap() + .retain(|_, node_id| *node_id != counterparty_node_id); + } if let Some(future) = future_opt { future.await?; did_persist = true; diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index f1b098dbfaa..45d5cf1affd 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -47,6 +47,7 @@ use lightning::ln::channelmanager::AChannelManager; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::CustomMessageReader; +use lightning::onion_message::messenger::OnionMessageInterceptor; use lightning::sign::{EntropySource, NodeSigner}; use lightning::util::logger::Level; use lightning::util::persist::{KVStore, KVStoreSync, KVStoreSyncWrapper}; @@ -310,6 +311,7 @@ where entropy_source: ES, node_signer: NS, channel_manager: CM, kv_store: K, transaction_broadcaster: T, service_config: Option, client_config: Option, + onion_message_interceptor: Option>, ) -> Result { Self::new_with_custom_time_provider( entropy_source, @@ -320,6 +322,7 @@ where service_config, client_config, DefaultTimeProvider, + onion_message_interceptor, ) .await } @@ -349,6 +352,7 @@ where entropy_source: ES, node_signer: NS, channel_manager: CM, transaction_broadcaster: T, kv_store: K, service_config: Option, client_config: Option, time_provider: TP, + onion_message_interceptor: Option>, ) -> Result { let pending_msgs_or_needs_persist_notifier = Arc::new(Notifier::new()); let pending_messages = @@ -391,6 +395,7 @@ where kv_store.clone(), transaction_broadcaster.clone(), lsps2_service_config.clone(), + onion_message_interceptor.clone(), )?) } else { None @@ -940,6 +945,7 @@ where entropy_source: ES, node_signer: NS, channel_manager: CM, kv_store_sync: KS, transaction_broadcaster: T, service_config: Option, client_config: Option, + onion_message_interceptor: Option>, ) -> Result { let kv_store = KVStoreSyncWrapper(kv_store_sync); @@ -951,6 +957,7 @@ where transaction_broadcaster, service_config, client_config, + onion_message_interceptor, )); let mut waker = dummy_waker(); @@ -986,6 +993,7 @@ where entropy_source: ES, node_signer: NS, channel_manager: CM, kv_store_sync: KS, transaction_broadcaster: T, service_config: Option, client_config: Option, time_provider: TP, + onion_message_interceptor: Option>, ) -> Result { let kv_store = KVStoreSyncWrapper(kv_store_sync); let mut fut = pin!(LiquidityManager::new_with_custom_time_provider( @@ -997,6 +1005,7 @@ where service_config, client_config, time_provider, + onion_message_interceptor, )); let mut waker = dummy_waker(); diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index 2716df7c0a3..48f685127da 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -36,6 +36,7 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( Some(service_config), None, Arc::clone(&time_provider), + None, ) .unwrap(); @@ -48,6 +49,7 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( None, Some(client_config), time_provider, + None, ) .unwrap(); diff --git a/lightning-liquidity/tests/lsps1_integration_tests.rs b/lightning-liquidity/tests/lsps1_integration_tests.rs index a177b338ad7..6de6faa7095 100644 --- a/lightning-liquidity/tests/lsps1_integration_tests.rs +++ b/lightning-liquidity/tests/lsps1_integration_tests.rs @@ -434,6 +434,7 @@ fn lsps1_service_handler_persistence_across_restarts() { Some(service_config), None, Arc::clone(&time_provider), + None, ) .unwrap(); @@ -454,6 +455,7 @@ fn lsps1_service_handler_persistence_across_restarts() { None, Some(client_config), time_provider, + None, ) .unwrap(); @@ -1087,6 +1089,7 @@ fn lsps1_expired_orders_are_pruned_and_not_persisted() { Some(service_config), None, Arc::clone(&time_provider), + None, ) .unwrap(); @@ -1106,6 +1109,7 @@ fn lsps1_expired_orders_are_pruned_and_not_persisted() { None, Some(client_config), time_provider, + None, ) .unwrap(); diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index b8a4a5adebb..87e1490da6b 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -1079,6 +1079,7 @@ fn lsps2_service_handler_persistence_across_restarts() { Some(service_config), None, time_provider, + None, ) .unwrap(); diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 2b32b4dcbc6..653d417fe98 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -1604,6 +1604,7 @@ fn lsps5_service_handler_persistence_across_restarts() { Some(service_config), None, Arc::clone(&time_provider), + None, ) .unwrap(); From c80beb6306f1cc5ade522033aa84c70dd43061e7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Mar 2026 10:12:39 +0100 Subject: [PATCH 3/7] Add LSPS2 BOLT12 custom router Introduce a router wrapper that maps BOLT12 offer ids to LSPS2 invoice parameters and injects intercept-SCID blinded payment paths while delegating all other routing logic to an inner router. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/mod.rs | 1 + lightning-liquidity/src/lsps2/router.rs | 540 ++++++++++++++++++++++++ 2 files changed, 541 insertions(+) create mode 100644 lightning-liquidity/src/lsps2/router.rs diff --git a/lightning-liquidity/src/lsps2/mod.rs b/lightning-liquidity/src/lsps2/mod.rs index 1d5fb76d3b4..684ad9b26f7 100644 --- a/lightning-liquidity/src/lsps2/mod.rs +++ b/lightning-liquidity/src/lsps2/mod.rs @@ -13,5 +13,6 @@ pub mod client; pub mod event; pub mod msgs; pub(crate) mod payment_queue; +pub mod router; pub mod service; pub mod utils; diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs new file mode 100644 index 00000000000..74832739f04 --- /dev/null +++ b/lightning-liquidity/src/lsps2/router.rs @@ -0,0 +1,540 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Router helpers for combining LSPS2 with BOLT12 offer flows. + +use alloc::vec::Vec; + +use crate::prelude::{new_hash_map, HashMap}; +use crate::sync::Mutex; + +use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; + +use lightning::blinded_path::message::{ + BlindedMessagePath, MessageContext, MessageForwardNode, OffersContext, +}; +use lightning::blinded_path::payment::{ + BlindedPaymentPath, Bolt12OfferContext, ForwardTlvs, PaymentConstraints, PaymentContext, + PaymentForwardNode, PaymentRelay, ReceiveTlvs, +}; +use lightning::ln::channel_state::ChannelDetails; +use lightning::ln::channelmanager::{PaymentId, MIN_FINAL_CLTV_EXPIRY_DELTA}; +use lightning::offers::offer::OfferId; +use lightning::onion_message::messenger::{Destination, MessageRouter, OnionMessagePath}; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::{EntropySource, ReceiveAuthKey}; +use lightning::types::features::BlindedHopFeatures; +use lightning::types::payment::PaymentHash; + +/// LSPS2 invoice parameters required to construct BOLT12 blinded payment paths through an LSP. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LSPS2Bolt12InvoiceParameters { + /// The LSP node id to use as the blinded path introduction node. + pub counterparty_node_id: PublicKey, + /// The LSPS2 intercept short channel id. + pub intercept_scid: u64, + /// The CLTV expiry delta the LSP requires for forwarding over `intercept_scid`. + pub cltv_expiry_delta: u32, +} + +/// A router wrapper that injects LSPS2-specific BOLT12 blinded paths for registered offer ids +/// while delegating all other routing behavior to the inner routers. +/// +/// For **payment** blinded paths (in invoices), it injects the intercept SCID as the forwarding +/// hop so that the LSP can intercept the HTLC and open a JIT channel. +/// +/// For **message** blinded paths (in offers), it injects the intercept SCID as the +/// [`MessageForwardNode::short_channel_id`] for compact encoding, resulting in significantly +/// smaller offers when bech32-encoded (e.g., for QR codes). The LSP must register the intercept +/// SCID for interception via [`OnionMessageInterceptor::register_scid_for_interception`] so that +/// forwarded messages using the compact encoding are intercepted rather than dropped. +/// +/// [`OnionMessageInterceptor::register_scid_for_interception`]: lightning::onion_message::messenger::OnionMessageInterceptor::register_scid_for_interception +pub struct LSPS2BOLT12Router { + inner_router: R, + inner_message_router: MR, + entropy_source: ES, + offer_to_invoice_params: Mutex>, +} + +impl LSPS2BOLT12Router { + /// Constructs a new wrapper around `inner_router` and `inner_message_router`. + pub fn new(inner_router: R, inner_message_router: MR, entropy_source: ES) -> Self { + Self { + inner_router, + inner_message_router, + entropy_source, + offer_to_invoice_params: Mutex::new(new_hash_map()), + } + } + + /// Registers LSPS2 parameters to be used when generating blinded payment paths for `offer_id`. + pub fn register_offer( + &self, offer_id: OfferId, invoice_params: LSPS2Bolt12InvoiceParameters, + ) -> Option { + self.offer_to_invoice_params.lock().unwrap().insert(offer_id.0, invoice_params) + } + + /// Removes any previously registered LSPS2 parameters for `offer_id`. + pub fn unregister_offer(&self, offer_id: &OfferId) -> Option { + self.offer_to_invoice_params.lock().unwrap().remove(&offer_id.0) + } + + /// Clears all LSPS2 parameters previously registered via [`Self::register_offer`]. + pub fn clear_registered_offers(&self) { + self.offer_to_invoice_params.lock().unwrap().clear(); + } + + fn registered_lsps2_params( + &self, payment_context: &PaymentContext, + ) -> Option { + // We intentionally only match `Bolt12Offer` here and not `AsyncBolt12Offer`, as LSPS2 + // JIT channels are not applicable to async (always-online) BOLT12 offer flows. + let Bolt12OfferContext { offer_id, .. } = match payment_context { + PaymentContext::Bolt12Offer(context) => context, + _ => return None, + }; + + self.offer_to_invoice_params.lock().unwrap().get(&offer_id.0).copied() + } +} + +impl Router + for LSPS2BOLT12Router +{ + fn find_route( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + ) -> Result { + self.inner_router.find_route(payer, route_params, first_hops, inflight_htlcs) + } + + fn find_route_with_id( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + payment_hash: PaymentHash, payment_id: PaymentId, + ) -> Result { + self.inner_router.find_route_with_id( + payer, + route_params, + first_hops, + inflight_htlcs, + payment_hash, + payment_id, + ) + } + + fn create_blinded_payment_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, + secp_ctx: &Secp256k1, + ) -> Result, ()> { + let lsps2_invoice_params = match self.registered_lsps2_params(&tlvs.payment_context) { + Some(params) => params, + None => { + return self.inner_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs, + amount_msats, + secp_ctx, + ) + }, + }; + + let payment_relay = PaymentRelay { + cltv_expiry_delta: u16::try_from(lsps2_invoice_params.cltv_expiry_delta) + .map_err(|_| ())?, + fee_proportional_millionths: 0, + fee_base_msat: 0, + }; + let payment_constraints = PaymentConstraints { + max_cltv_expiry: tlvs + .payment_constraints + .max_cltv_expiry + .saturating_add(lsps2_invoice_params.cltv_expiry_delta), + htlc_minimum_msat: 0, + }; + + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: lsps2_invoice_params.intercept_scid, + payment_relay, + payment_constraints, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: lsps2_invoice_params.counterparty_node_id, + htlc_maximum_msat: u64::MAX, + }; + + // We deliberately use `BlindedPaymentPath::new` without dummy hops here. Since the LSP + // is the introduction node and already knows the recipient, adding dummy hops would not + // provide meaningful privacy benefits in the LSPS2 JIT channel context. + let path = BlindedPaymentPath::new( + &[forward_node], + recipient, + local_node_receive_key, + tlvs, + u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, + &self.entropy_source, + secp_ctx, + )?; + + Ok(vec![path]) + } +} + +impl MessageRouter + for LSPS2BOLT12Router +{ + fn find_path( + &self, sender: PublicKey, peers: Vec, destination: Destination, + ) -> Result { + self.inner_message_router.find_path(sender, peers, destination) + } + + fn create_blinded_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + context: MessageContext, peers: Vec, secp_ctx: &Secp256k1, + ) -> Result, ()> { + // Inject intercept SCIDs for size-constrained contexts (offer QR codes) so that + // the message blinded path uses compact SCID encoding instead of full pubkeys. + // We use the first matching intercept SCID for each peer since the message path + // is only used for routing InvoiceRequests, not for payment interception. + let peers = match &context { + MessageContext::Offers(OffersContext::InvoiceRequest { .. }) => { + let params = self.offer_to_invoice_params.lock().unwrap(); + peers + .into_iter() + .map(|mut peer| { + if let Some(p) = + params.values().find(|p| p.counterparty_node_id == peer.node_id) + { + peer.short_channel_id = Some(p.intercept_scid); + } + peer + }) + .collect() + }, + _ => peers, + }; + + self.inner_message_router.create_blinded_paths( + recipient, + local_node_receive_key, + context, + peers, + secp_ctx, + ) + } +} + +#[cfg(test)] +mod tests { + use super::{LSPS2BOLT12Router, LSPS2Bolt12InvoiceParameters}; + + use bitcoin::network::Network; + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + use lightning::blinded_path::payment::{ + Bolt12OfferContext, Bolt12RefundContext, PaymentConstraints, PaymentContext, ReceiveTlvs, + }; + use lightning::blinded_path::NodeIdLookUp; + use lightning::ln::channel_state::ChannelDetails; + use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; + use lightning::offers::invoice_request::InvoiceRequestFields; + use lightning::offers::offer::OfferId; + use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; + use lightning::sign::{EntropySource, NodeSigner, ReceiveAuthKey, Recipient}; + use lightning::types::payment::PaymentSecret; + use lightning::util::test_utils::TestKeysInterface; + + use crate::sync::Mutex; + + use core::sync::atomic::{AtomicUsize, Ordering}; + + struct RecordingLookup { + next_node_id: PublicKey, + short_channel_id: Mutex>, + } + + impl NodeIdLookUp for RecordingLookup { + fn next_node_id(&self, short_channel_id: u64) -> Option { + *self.short_channel_id.lock().unwrap() = Some(short_channel_id); + Some(self.next_node_id) + } + } + + #[derive(Clone)] + struct TestEntropy; + + impl EntropySource for TestEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } + + struct MockMessageRouter; + + impl lightning::onion_message::messenger::MessageRouter for MockMessageRouter { + fn find_path( + &self, _sender: PublicKey, _peers: Vec, + _destination: lightning::onion_message::messenger::Destination, + ) -> Result { + Err(()) + } + + fn create_blinded_paths< + T: bitcoin::secp256k1::Signing + bitcoin::secp256k1::Verification, + >( + &self, _recipient: PublicKey, _local_node_receive_key: lightning::sign::ReceiveAuthKey, + _context: lightning::blinded_path::message::MessageContext, + _peers: Vec, + _secp_ctx: &Secp256k1, + ) -> Result, ()> { + Err(()) + } + } + + struct MockRouter { + create_blinded_payment_paths_calls: AtomicUsize, + } + + impl MockRouter { + fn new() -> Self { + Self { create_blinded_payment_paths_calls: AtomicUsize::new(0) } + } + + fn create_blinded_payment_paths_calls(&self) -> usize { + self.create_blinded_payment_paths_calls.load(Ordering::Acquire) + } + } + + impl Router for MockRouter { + fn find_route( + &self, _payer: &PublicKey, _route_params: &RouteParameters, + _first_hops: Option<&[&ChannelDetails]>, _inflight_htlcs: InFlightHtlcs, + ) -> Result { + Err("mock router") + } + + fn create_blinded_payment_paths< + T: bitcoin::secp256k1::Signing + bitcoin::secp256k1::Verification, + >( + &self, _recipient: PublicKey, _local_node_receive_key: ReceiveAuthKey, + _first_hops: Vec, _tlvs: ReceiveTlvs, _amount_msats: Option, + _secp_ctx: &Secp256k1, + ) -> Result, ()> { + self.create_blinded_payment_paths_calls.fetch_add(1, Ordering::AcqRel); + Err(()) + } + } + + fn pubkey(byte: u8) -> PublicKey { + let secret_key = SecretKey::from_slice(&[byte; 32]).unwrap(); + PublicKey::from_secret_key(&Secp256k1::new(), &secret_key) + } + + fn bolt12_offer_tlvs(offer_id: OfferId) -> ReceiveTlvs { + ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id, + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: pubkey(9), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }), + } + } + + fn bolt12_refund_tlvs() -> ReceiveTlvs { + ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + } + } + + #[test] + fn creates_lsps2_blinded_path_for_registered_offer() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, entropy_source); + + let offer_id = OfferId([8; 32]); + let lsp_keys = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id = lsp_keys.get_node_id(Recipient::Node).unwrap(); + + let expected_scid = 42; + let expected_cltv_delta = 48; + let recipient = pubkey(10); + + router.register_offer( + offer_id, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id, + intercept_scid: expected_scid, + cltv_expiry_delta: expected_cltv_delta, + }, + ); + + let secp_ctx = Secp256k1::new(); + let mut paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs(offer_id), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + assert_eq!(paths.len(), 1); + let mut path = paths.pop().unwrap(); + assert_eq!( + path.introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id) + ); + assert_eq!(path.payinfo.fee_base_msat, 0); + assert_eq!(path.payinfo.fee_proportional_millionths, 0); + assert_eq!( + path.payinfo.cltv_expiry_delta, + expected_cltv_delta as u16 + MIN_FINAL_CLTV_EXPIRY_DELTA + ); + + let lookup = + RecordingLookup { next_node_id: recipient, short_channel_id: Mutex::new(None) }; + path.advance_path_by_one(&lsp_keys, &lookup, &secp_ctx).unwrap(); + assert_eq!(*lookup.short_channel_id.lock().unwrap(), Some(expected_scid)); + } + + #[test] + fn delegates_when_offer_is_not_registered() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, entropy_source); + let secp_ctx = Secp256k1::new(); + + let result = router.create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_refund_tlvs(), + Some(10_000), + &secp_ctx, + ); + + assert!(result.is_err()); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn delegates_when_offer_id_is_not_registered() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, entropy_source); + let secp_ctx = Secp256k1::new(); + + // Use a Bolt12Offer context with an OfferId that was never registered. + let unregistered_offer_id = OfferId([99; 32]); + let result = router.create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs(unregistered_offer_id), + Some(10_000), + &secp_ctx, + ); + + assert!(result.is_err()); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn rejects_out_of_range_cltv_delta() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, entropy_source); + + let offer_id = OfferId([11; 32]); + router.register_offer( + offer_id, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(12), + intercept_scid: 21, + cltv_expiry_delta: u32::from(u16::MAX) + 1, + }, + ); + + let secp_ctx = Secp256k1::new(); + let result = router.create_blinded_payment_paths( + pubkey(13), + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs(offer_id), + Some(1_000), + &secp_ctx, + ); + + assert!(result.is_err()); + } + + #[test] + fn can_unregister_offer() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, entropy_source); + + let offer_id = OfferId([1; 32]); + let params = LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(2), + intercept_scid: 7, + cltv_expiry_delta: 40, + }; + assert_eq!(router.register_offer(offer_id, params), None); + assert_eq!(router.unregister_offer(&offer_id), Some(params)); + assert_eq!(router.unregister_offer(&offer_id), None); + } + + #[test] + fn can_clear_registered_offers() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, entropy_source); + + router.register_offer( + OfferId([1; 32]), + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(2), + intercept_scid: 7, + cltv_expiry_delta: 40, + }, + ); + router.register_offer( + OfferId([2; 32]), + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(3), + intercept_scid: 8, + cltv_expiry_delta: 41, + }, + ); + + router.clear_registered_offers(); + assert_eq!(router.unregister_offer(&OfferId([1; 32])), None); + assert_eq!(router.unregister_offer(&OfferId([2; 32])), None); + } +} From f0e592fb0d9563bba7d19f236783aa1283429a1c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Mar 2026 10:12:44 +0100 Subject: [PATCH 4/7] Document LSPS2 BOLT12 router flow Clarify that InvoiceParametersReady supports BOLT11 route hints and BOLT12 offer flows via LSPS2BOLT12Router registration. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/event.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lightning-liquidity/src/lsps2/event.rs b/lightning-liquidity/src/lsps2/event.rs index 502429b79ec..9ca20863387 100644 --- a/lightning-liquidity/src/lsps2/event.rs +++ b/lightning-liquidity/src/lsps2/event.rs @@ -49,7 +49,17 @@ pub enum LSPS2ClientEvent { /// When the invoice is paid, the LSP will open a channel with the previously agreed upon /// parameters to you. /// + /// For BOLT11 JIT invoices, `intercept_scid` and `cltv_expiry_delta` can be used in a route + /// hint. + /// + /// For BOLT12 JIT flows, register these parameters for your offer id on an + /// [`LSPS2BOLT12Router`] and then proceed with the regular BOLT12 offer + /// flow. The router will inject the LSPS2-specific blinded payment path when creating the + /// invoice. + /// /// **Note: ** This event will *not* be persisted across restarts. + /// + /// [`LSPS2BOLT12Router`]: crate::lsps2::router::LSPS2BOLT12Router InvoiceParametersReady { /// The identifier of the issued bLIP-52 / LSPS2 `buy` request, as returned by /// [`LSPS2ClientHandler::select_opening_params`]. From fbdfe8f929d1c32b87411467187412e6a7d61641 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Mar 2026 13:42:36 +0100 Subject: [PATCH 5/7] Add integration coverage for LSPS2 `BOLT12` router path Exercise the LSPS2 buy flow and assert that a registered `OfferId` produces a blinded payment path whose first forwarding hop uses the negotiated intercept `SCID`. This validates the custom-router wiring used for LSPS2 + `BOLT12`. Co-Authored-By: HAL 9000 --- .../tests/lsps2_integration_tests.rs | 132 +++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 87e1490da6b..ede9dd88142 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -15,6 +15,10 @@ use lightning::ln::msgs::BaseMessageHandler; use lightning::ln::msgs::ChannelMessageHandler; use lightning::ln::msgs::MessageSendEvent; use lightning::ln::types::ChannelId; +use lightning::offers::invoice_request::InvoiceRequestFields; +use lightning::offers::offer::OfferId; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::ReceiveAuthKey; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::LSPSDateTime; @@ -22,12 +26,18 @@ use lightning_liquidity::lsps2::client::LSPS2ClientConfig; use lightning_liquidity::lsps2::event::LSPS2ClientEvent; use lightning_liquidity::lsps2::event::LSPS2ServiceEvent; use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; +use lightning_liquidity::lsps2::router::{LSPS2BOLT12Router, LSPS2Bolt12InvoiceParameters}; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; use lightning_liquidity::lsps2::utils::is_valid_opening_fee_params; use lightning_liquidity::utils::time::{DefaultTimeProvider, TimeProvider}; use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; -use lightning::ln::channelmanager::{InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; +use lightning::blinded_path::payment::{ + Bolt12OfferContext, PaymentConstraints, PaymentContext, ReceiveTlvs, +}; +use lightning::blinded_path::NodeIdLookUp; +use lightning::chain::{BestBlock, Filter}; +use lightning::ln::channelmanager::{ChainParameters, InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; use lightning::ln::functional_test_utils::{ create_chanmon_cfgs, create_node_cfgs, create_node_chanmgrs, }; @@ -56,6 +66,46 @@ use std::time::Duration; const MAX_PENDING_REQUESTS_PER_PEER: usize = 10; const MAX_TOTAL_PENDING_REQUESTS: usize = 1000; +struct RecordingLookup { + next_node_id: PublicKey, + short_channel_id: std::sync::Mutex>, +} + +impl NodeIdLookUp for RecordingLookup { + fn next_node_id(&self, short_channel_id: u64) -> Option { + *self.short_channel_id.lock().unwrap() = Some(short_channel_id); + Some(self.next_node_id) + } +} + +struct FailingRouter; + +impl FailingRouter { + fn new() -> Self { + Self + } +} + +impl Router for FailingRouter { + fn find_route( + &self, _payer: &PublicKey, _route_params: &RouteParameters, + _first_hops: Option<&[&lightning::ln::channel_state::ChannelDetails]>, + _inflight_htlcs: InFlightHtlcs, + ) -> Result { + Err("failing test router") + } + + fn create_blinded_payment_paths< + T: bitcoin::secp256k1::Signing + bitcoin::secp256k1::Verification, + >( + &self, _recipient: PublicKey, _local_node_receive_key: ReceiveAuthKey, + _first_hops: Vec, _tlvs: ReceiveTlvs, + _amount_msats: Option, _secp_ctx: &Secp256k1, + ) -> Result, ()> { + Err(()) + } +} + fn build_lsps2_configs() -> ([u8; 32], LiquidityServiceConfig, LiquidityClientConfig) { let promise_secret = [42; 32]; let lsps2_service_config = LSPS2ServiceConfig { promise_secret }; @@ -1477,6 +1527,86 @@ fn execute_lsps2_dance( } } +#[test] +fn bolt12_custom_router_uses_lsps2_intercept_scid() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); + + let service_node_id = lsps_nodes.service_node.inner.node.get_our_node_id(); + let client_node_id = lsps_nodes.client_node.inner.node.get_our_node_id(); + + let intercept_scid = lsps_nodes.service_node.node.get_intercept_scid(); + let cltv_expiry_delta = 72; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + 42, + cltv_expiry_delta, + promise_secret, + Some(250_000), + 1_000, + ); + + let inner_router = FailingRouter::new(); + let router = LSPS2BOLT12Router::new(inner_router, lsps_nodes.client_node.keys_manager); + let offer_id = OfferId([42; 32]); + + router.register_offer( + offer_id, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: service_node_id, + intercept_scid, + cltv_expiry_delta, + }, + ); + + let tlvs = ReceiveTlvs { + payment_secret: lightning_types::payment::PaymentSecret([7; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 50, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id, + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: lsps_nodes.payer_node.node.get_our_node_id(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }), + }; + + let secp_ctx = Secp256k1::new(); + let mut paths = router + .create_blinded_payment_paths( + client_node_id, + ReceiveAuthKey([3; 32]), + Vec::new(), + tlvs, + Some(100_000), + &secp_ctx, + ) + .unwrap(); + + assert_eq!(paths.len(), 1); + let mut path = paths.pop().unwrap(); + assert_eq!( + path.introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(service_node_id) + ); + assert_eq!(path.payinfo.fee_base_msat, 0); + assert_eq!(path.payinfo.fee_proportional_millionths, 0); + + let lookup = RecordingLookup { + next_node_id: client_node_id, + short_channel_id: std::sync::Mutex::new(None), + }; + path.advance_path_by_one(lsps_nodes.service_node.keys_manager, &lookup, &secp_ctx).unwrap(); + assert_eq!(*lookup.short_channel_id.lock().unwrap(), Some(intercept_scid)); +} + fn create_channel_with_manual_broadcast( service_node_id: &PublicKey, client_node_id: &PublicKey, service_node: &LiquidityNode, client_node: &LiquidityNode, user_channel_id: u128, expected_outbound_amount_msat: &u64, From 97b2f06ac815050accef52f9efa29e34ced376c0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Mar 2026 14:32:46 +0100 Subject: [PATCH 6/7] Add override for blinded path creation Allow tests to provide a override that receives the caller's , enabling custom blinded-path generation while preserving valid bindings. Co-Authored-By: HAL 9000 --- lightning/src/util/test_utils.rs | 35 +++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index abcc24adf8d..66e9dce0695 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -165,6 +165,23 @@ impl chaininterface::FeeEstimator for TestFeeEstimator { } } +/// Override closure type for [`TestRouter::override_create_blinded_payment_paths`]. +/// +/// This closure is called instead of the default [`Router::create_blinded_payment_paths`] +/// implementation when set, receiving the actual [`ReceiveTlvs`] so tests can construct custom +/// blinded payment paths using the same TLVs the caller generated. +pub type BlindedPaymentPathOverrideFn = Box< + dyn Fn( + PublicKey, + ReceiveAuthKey, + Vec, + ReceiveTlvs, + Option, + ) -> Result, ()> + + Send + + Sync, +>; + pub struct TestRouter<'a> { pub router: DefaultRouter< Arc>, @@ -177,6 +194,7 @@ pub struct TestRouter<'a> { pub network_graph: Arc>, pub next_routes: Mutex>)>>, pub next_blinded_payment_paths: Mutex>, + pub override_create_blinded_payment_paths: Mutex>, pub scorer: &'a RwLock, } @@ -188,6 +206,7 @@ impl<'a> TestRouter<'a> { let entropy_source = Arc::new(RandomBytes::new([42; 32])); let next_routes = Mutex::new(VecDeque::new()); let next_blinded_payment_paths = Mutex::new(Vec::new()); + let override_create_blinded_payment_paths = Mutex::new(None); Self { router: DefaultRouter::new( Arc::clone(&network_graph), @@ -199,6 +218,7 @@ impl<'a> TestRouter<'a> { network_graph, next_routes, next_blinded_payment_paths, + override_create_blinded_payment_paths, scorer, } } @@ -321,6 +341,12 @@ impl<'a> Router for TestRouter<'a> { first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, secp_ctx: &Secp256k1, ) -> Result, ()> { + if let Some(override_fn) = + self.override_create_blinded_payment_paths.lock().unwrap().as_ref() + { + return override_fn(recipient, local_node_receive_key, first_hops, tlvs, amount_msats); + } + let mut expected_paths = self.next_blinded_payment_paths.lock().unwrap(); if expected_paths.is_empty() { self.router.create_blinded_payment_paths( @@ -366,6 +392,7 @@ pub enum TestMessageRouterInternal<'a> { pub struct TestMessageRouter<'a> { pub inner: TestMessageRouterInternal<'a>, pub peers_override: Mutex>, + pub forward_node_scid_override: Mutex>, } impl<'a> TestMessageRouter<'a> { @@ -378,6 +405,7 @@ impl<'a> TestMessageRouter<'a> { entropy_source, )), peers_override: Mutex::new(Vec::new()), + forward_node_scid_override: Mutex::new(new_hash_map()), } } @@ -390,6 +418,7 @@ impl<'a> TestMessageRouter<'a> { entropy_source, )), peers_override: Mutex::new(Vec::new()), + forward_node_scid_override: Mutex::new(new_hash_map()), } } } @@ -421,9 +450,13 @@ impl<'a> MessageRouter for TestMessageRouter<'a> { { let peers_override = self.peers_override.lock().unwrap(); if !peers_override.is_empty() { + let scid_override = self.forward_node_scid_override.lock().unwrap(); let peer_override_nodes: Vec<_> = peers_override .iter() - .map(|pk| MessageForwardNode { node_id: *pk, short_channel_id: None }) + .map(|pk| MessageForwardNode { + node_id: *pk, + short_channel_id: scid_override.get(pk).copied(), + }) .collect(); peers = peer_override_nodes; } From 7ca886d8074222b45249795510413dcd78be8d96 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 5 Mar 2026 14:32:52 +0100 Subject: [PATCH 7/7] Add LSPS2 BOLT12 end-to-end integration test Exercise the full flow through onion-message invoice exchange, , JIT channel opening, and settlement to confirm paths integrate with LSPS2 service handling. Co-Authored-By: HAL 9000 --- .../tests/lsps2_integration_tests.rs | 471 +++++++++++++++++- 1 file changed, 466 insertions(+), 5 deletions(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index ede9dd88142..a93cd409977 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -7,19 +7,21 @@ use common::{ get_lsps_message, LSPSNodes, LSPSNodesWithPayer, LiquidityNode, }; -use lightning::events::{ClosureReason, Event}; +use lightning::events::{ClosureReason, Event, EventsProvider}; use lightning::get_event_msg; use lightning::ln::channelmanager::{OptionalBolt11PaymentParams, PaymentId}; use lightning::ln::functional_test_utils::*; use lightning::ln::msgs::BaseMessageHandler; use lightning::ln::msgs::ChannelMessageHandler; use lightning::ln::msgs::MessageSendEvent; +use lightning::ln::msgs::OnionMessageHandler; use lightning::ln::types::ChannelId; use lightning::offers::invoice_request::InvoiceRequestFields; use lightning::offers::offer::OfferId; use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; -use lightning::sign::ReceiveAuthKey; +use lightning::sign::{RandomBytes, ReceiveAuthKey}; +use lightning::onion_message::messenger::NullMessageRouter; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::LSPSDateTime; use lightning_liquidity::lsps2::client::LSPS2ClientConfig; @@ -36,8 +38,7 @@ use lightning::blinded_path::payment::{ Bolt12OfferContext, PaymentConstraints, PaymentContext, ReceiveTlvs, }; use lightning::blinded_path::NodeIdLookUp; -use lightning::chain::{BestBlock, Filter}; -use lightning::ln::channelmanager::{ChainParameters, InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; +use lightning::ln::channelmanager::{InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; use lightning::ln::functional_test_utils::{ create_chanmon_cfgs, create_node_cfgs, create_node_chanmgrs, }; @@ -1552,7 +1553,11 @@ fn bolt12_custom_router_uses_lsps2_intercept_scid() { ); let inner_router = FailingRouter::new(); - let router = LSPS2BOLT12Router::new(inner_router, lsps_nodes.client_node.keys_manager); + let router = LSPS2BOLT12Router::new( + inner_router, + NullMessageRouter {}, + lsps_nodes.client_node.keys_manager, + ); let offer_id = OfferId([42; 32]); router.register_offer( @@ -1607,6 +1612,462 @@ fn bolt12_custom_router_uses_lsps2_intercept_scid() { assert_eq!(*lookup.short_channel_id.lock().unwrap(), Some(intercept_scid)); } +#[test] +fn bolt12_lsps2_end_to_end_test() { + // End-to-end test of the BOLT12 + LSPS2 JIT channel flow. Three nodes: payer, service, client. + // client_trusts_lsp=true; funding transaction broadcast happens after client claims the HTLC. + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + + let mut service_node_config = test_default_channel_config(); + service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; + + let mut client_node_config = test_default_channel_config(); + client_node_config.accept_inbound_channels = true; + client_node_config.channel_config.accept_underpaying_htlcs = true; + + let node_chanmgrs = create_node_chanmgrs( + 3, + &node_cfgs, + &[Some(service_node_config), Some(client_node_config), None], + ); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); + let LSPSNodesWithPayer { ref service_node, ref client_node, ref payer_node } = lsps_nodes; + + let payer_node_id = payer_node.node.get_our_node_id(); + 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 service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); + + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + let intercept_scid = service_node.node.get_intercept_scid(); + let user_channel_id = 42; + let cltv_expiry_delta: u32 = 144; + let payment_size_msat = Some(1_000_000); + let fee_base_msat = 1_000; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + user_channel_id, + cltv_expiry_delta, + promise_secret, + payment_size_msat, + fee_base_msat, + ); + + // Disconnect payer from client to ensure deterministic onion message routing through service. + payer_node.node.peer_disconnected(client_node_id); + client_node.node.peer_disconnected(payer_node_id); + payer_node.onion_messenger.peer_disconnected(client_node_id); + client_node.onion_messenger.peer_disconnected(payer_node_id); + + #[cfg(c_bindings)] + let offer = { + let mut offer_builder = client_node.node.create_offer_builder().unwrap(); + offer_builder.amount_msats(payment_size_msat.unwrap()); + offer_builder.build().unwrap() + }; + #[cfg(not(c_bindings))] + let offer = client_node + .node + .create_offer_builder() + .unwrap() + .amount_msats(payment_size_msat.unwrap()) + .build() + .unwrap(); + + let lsps2_router = Arc::new(LSPS2BOLT12Router::new( + FailingRouter::new(), + NullMessageRouter {}, + Arc::new(RandomBytes::new([43; 32])), + )); + lsps2_router.register_offer( + offer.id(), + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: service_node_id, + intercept_scid, + cltv_expiry_delta, + }, + ); + + let lsps2_router = Arc::clone(&lsps2_router); + *client_node.router.override_create_blinded_payment_paths.lock().unwrap() = + Some(Box::new(move |recipient, local_node_receive_key, first_hops, tlvs, amount_msats| { + let secp_ctx = Secp256k1::new(); + lsps2_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs, + amount_msats, + &secp_ctx, + ) + })); + + let payment_id = PaymentId([1; 32]); + payer_node.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + + let onion_msg = payer_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Payer should send InvoiceRequest toward service"); + service_node.onion_messenger.handle_onion_message(payer_node_id, &onion_msg); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(client_node_id) + .expect("Service should forward InvoiceRequest to client"); + client_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + + let onion_msg = client_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Client should send Invoice toward service"); + service_node.onion_messenger.handle_onion_message(client_node_id, &onion_msg); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(payer_node_id) + .expect("Service should forward Invoice to payer"); + payer_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + + check_added_monitors(&payer_node, 1); + let events = payer_node.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = SendEvent::from_event(events[0].clone()); + + service_node.inner.node.handle_update_add_htlc(payer_node_id, &ev.msgs[0]); + do_commitment_signed_dance(&service_node.inner, &payer_node, &ev.commitment_msg, false, true); + service_node.inner.node.process_pending_htlc_forwards(); + + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let (payment_hash, expected_outbound_amount_msat) = match &events[0] { + Event::HTLCIntercepted { + intercept_id, + requested_next_hop_scid, + payment_hash, + expected_outbound_amount_msat, + .. + } => { + assert_eq!(*requested_next_hop_scid, intercept_scid); + + service_handler + .htlc_intercepted( + *requested_next_hop_scid, + *intercept_id, + *expected_outbound_amount_msat, + *payment_hash, + ) + .unwrap(); + (*payment_hash, expected_outbound_amount_msat) + }, + other => panic!("Expected HTLCIntercepted event, got: {:?}", other), + }; + + let open_channel_event = service_node.liquidity_manager.next_event().unwrap(); + + match open_channel_event { + LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::OpenChannel { + their_network_key, + amt_to_forward_msat, + opening_fee_msat, + user_channel_id: uc_id, + intercept_scid: iscd, + }) => { + assert_eq!(their_network_key, client_node_id); + assert_eq!(amt_to_forward_msat, payment_size_msat.unwrap() - fee_base_msat); + assert_eq!(opening_fee_msat, fee_base_msat); + assert_eq!(uc_id, user_channel_id); + assert_eq!(iscd, intercept_scid); + }, + other => panic!("Expected OpenChannel event, got: {:?}", other), + }; + + let result = + service_handler.channel_needs_manual_broadcast(user_channel_id, &client_node_id).unwrap(); + assert!(result, "Channel should require manual broadcast"); + + let (channel_id, funding_tx) = create_channel_with_manual_broadcast( + &service_node_id, + &client_node_id, + &service_node, + &client_node, + user_channel_id, + expected_outbound_amount_msat, + true, + ); + + service_handler.channel_ready(user_channel_id, &channel_id, &client_node_id).unwrap(); + + service_node.inner.node.process_pending_htlc_forwards(); + + let pay_event = { + { + let mut added_monitors = + service_node.inner.chain_monitor.added_monitors.lock().unwrap(); + assert_eq!(added_monitors.len(), 1); + added_monitors.clear(); + } + let mut events = service_node.inner.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + SendEvent::from_event(events.remove(0)) + }; + + client_node.inner.node.handle_update_add_htlc(service_node_id, &pay_event.msgs[0]); + do_commitment_signed_dance( + &client_node.inner, + &service_node.inner, + &pay_event.commitment_msg, + false, + true, + ); + client_node.inner.node.process_pending_htlc_forwards(); + + let client_events = client_node.inner.node.get_and_clear_pending_events(); + assert_eq!(client_events.len(), 1); + let preimage = match &client_events[0] { + Event::PaymentClaimable { payment_hash: ph, purpose, .. } => { + assert_eq!(*ph, payment_hash); + purpose.preimage() + }, + other => panic!("Expected PaymentClaimable event on client, got: {:?}", other), + }; + + let broadcasted = service_node.inner.tx_broadcaster.txn_broadcasted.lock().unwrap(); + assert!(broadcasted.is_empty(), "There should be no broadcasted txs yet"); + drop(broadcasted); + + client_node.inner.node.claim_funds(preimage.unwrap()); + + claim_and_assert_forwarded_only( + &payer_node, + &service_node.inner, + &client_node.inner, + preimage.unwrap(), + ); + + let service_events = service_node.node.get_and_clear_pending_events(); + assert_eq!(service_events.len(), 1); + + let total_fee_msat = match service_events[0].clone() { + Event::PaymentForwarded { + prev_htlcs, + next_htlcs, + skimmed_fee_msat, + total_fee_earned_msat, + .. + } => { + assert_eq!(prev_htlcs[0].node_id, Some(payer_node_id)); + assert_eq!(next_htlcs[0].node_id, Some(client_node_id)); + service_handler.payment_forwarded(channel_id, skimmed_fee_msat.unwrap_or(0)).unwrap(); + Some(total_fee_earned_msat.unwrap() - skimmed_fee_msat.unwrap()) + }, + _ => panic!("Expected PaymentForwarded event, got: {:?}", service_events[0]), + }; + + let broadcasted = service_node.inner.tx_broadcaster.txn_broadcasted.lock().unwrap(); + assert!(broadcasted.iter().any(|b| b.compute_txid() == funding_tx.compute_txid())); + + expect_payment_sent(&payer_node, preimage.unwrap(), Some(total_fee_msat), true, true); +} + +#[test] +fn bolt12_lsps2_compact_message_path_test() { + // Tests that LSPS2 BOLT12 offers work with compact SCID-based message blinded paths. + // The client's offer uses an intercept SCID instead of the full pubkey for the next hop + // in the message blinded path. When the service node receives a forwarded InvoiceRequest + // with the unresolvable intercept SCID, it emits OnionMessageIntercepted instead of + // dropping the message. The test then forwards the message to the connected client. + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + + let mut service_node_config = test_default_channel_config(); + service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; + + let mut client_node_config = test_default_channel_config(); + client_node_config.accept_inbound_channels = true; + client_node_config.channel_config.accept_underpaying_htlcs = true; + + let node_chanmgrs = create_node_chanmgrs( + 3, + &node_cfgs, + &[Some(service_node_config), Some(client_node_config), None], + ); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); + let LSPSNodesWithPayer { ref service_node, ref client_node, ref payer_node } = lsps_nodes; + + let payer_node_id = payer_node.node.get_our_node_id(); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + let intercept_scid = service_node.node.get_intercept_scid(); + let user_channel_id = 42; + let cltv_expiry_delta: u32 = 144; + let payment_size_msat = Some(1_000_000); + let fee_base_msat = 1_000; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + user_channel_id, + cltv_expiry_delta, + promise_secret, + payment_size_msat, + fee_base_msat, + ); + + // Register the intercept SCID for onion message interception on the service node. + // This enables the service to intercept forwarded messages addressed by SCID rather than + // dropping them when NodeIdLookUp can't resolve the fake intercept SCID. + service_node.onion_messenger.register_scid_for_interception(intercept_scid, client_node_id); + + // Configure the client's message router to use compact SCID encoding for message + // blinded paths through the service node. + client_node.message_router.peers_override.lock().unwrap().push(service_node_id); + client_node + .message_router + .forward_node_scid_override + .lock() + .unwrap() + .insert(service_node_id, intercept_scid); + + // Disconnect payer from client so messages route through service. + payer_node.node.peer_disconnected(client_node_id); + client_node.node.peer_disconnected(payer_node_id); + payer_node.onion_messenger.peer_disconnected(client_node_id); + client_node.onion_messenger.peer_disconnected(payer_node_id); + + #[cfg(c_bindings)] + let offer = { + let mut offer_builder = client_node.node.create_offer_builder().unwrap(); + offer_builder.amount_msats(payment_size_msat.unwrap()); + offer_builder.build().unwrap() + }; + #[cfg(not(c_bindings))] + let offer = client_node + .node + .create_offer_builder() + .unwrap() + .amount_msats(payment_size_msat.unwrap()) + .build() + .unwrap(); + + let lsps2_router = Arc::new(LSPS2BOLT12Router::new( + FailingRouter::new(), + NullMessageRouter {}, + Arc::new(RandomBytes::new([43; 32])), + )); + lsps2_router.register_offer( + offer.id(), + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: service_node_id, + intercept_scid, + cltv_expiry_delta, + }, + ); + + let lsps2_router = Arc::clone(&lsps2_router); + *client_node.router.override_create_blinded_payment_paths.lock().unwrap() = + Some(Box::new(move |recipient, local_node_receive_key, first_hops, tlvs, amount_msats| { + let secp_ctx = Secp256k1::new(); + lsps2_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs, + amount_msats, + &secp_ctx, + ) + })); + + // Payer sends InvoiceRequest toward the service node. + let payment_id = PaymentId([1; 32]); + payer_node.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + + let onion_msg = payer_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Payer should send InvoiceRequest toward service"); + service_node.onion_messenger.handle_onion_message(payer_node_id, &onion_msg); + + // The service node can't resolve the intercept SCID via NodeIdLookUp (no real channel), + // so the message is intercepted via SCID-based interception. + // It should NOT be available as a normal forwarded message. + assert!( + service_node.onion_messenger.next_onion_message_for_peer(client_node_id).is_none(), + "Message should be intercepted, not forwarded directly" + ); + + // Process the OnionMessageIntercepted event and forward the message. + let events = core::cell::RefCell::new(Vec::new()); + service_node.onion_messenger.process_pending_events(&|e| Ok(events.borrow_mut().push(e))); + let events = events.into_inner(); + + let intercepted_msg = events + .into_iter() + .find_map(|e| match e { + Event::OnionMessageIntercepted { peer_node_id, message } => { + assert_eq!(peer_node_id, client_node_id); + Some(message) + }, + _ => None, + }) + .expect("Service should emit OnionMessageIntercepted for SCID-based forward"); + + // Forward the intercepted message to the (connected) client. + service_node + .onion_messenger + .forward_onion_message(intercepted_msg, &client_node_id) + .expect("Should succeed since client is connected"); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(client_node_id) + .expect("Service should have forwarded message to client"); + client_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + + // Client should respond with an Invoice back through the service to the payer. + let onion_msg = client_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Client should send Invoice toward service"); + service_node.onion_messenger.handle_onion_message(client_node_id, &onion_msg); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(payer_node_id) + .expect("Service should forward Invoice to payer"); + payer_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + + // Payer should have queued an HTLC payment. + check_added_monitors(&payer_node, 1); + let events = payer_node.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = SendEvent::from_event(events[0].clone()); + + // Verify the payment gets intercepted at the service node on the intercept SCID. + service_node.inner.node.handle_update_add_htlc(payer_node_id, &ev.msgs[0]); + do_commitment_signed_dance(&service_node.inner, &payer_node, &ev.commitment_msg, false, true); + service_node.inner.node.process_pending_htlc_forwards(); + + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::HTLCIntercepted { requested_next_hop_scid, .. } => { + assert_eq!(*requested_next_hop_scid, intercept_scid); + }, + other => panic!("Expected HTLCIntercepted event, got: {:?}", other), + }; +} + fn create_channel_with_manual_broadcast( service_node_id: &PublicKey, client_node_id: &PublicKey, service_node: &LiquidityNode, client_node: &LiquidityNode, user_channel_id: u128, expected_outbound_amount_msat: &u64,