From 0a67061a13a2bc50fd5c3864d1c1c23be5fed268 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 01/25] Add onion-message interception for unknown SCIDs to `OnionMessenger` We extend the `OnionMessenger` capabilities to also intercept onion messages if they are for unknown SCIDs. Co-Authored-By: HAL 9000 --- .../src/onion_message/functional_tests.rs | 52 +++++++ lightning/src/onion_message/messenger.rs | 142 ++++++++++++++++-- 2 files changed, 184 insertions(+), 10 deletions(-) diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 75e2aaf3c5f..26e1c5b102f 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -1173,6 +1173,58 @@ fn intercept_offline_peer_oms() { pass_along_path(&vec![nodes.remove(1), final_node_vec.remove(0)]); } +#[test] +fn intercept_offline_peer_oms_registered_by_scid() { + let mut nodes = create_nodes(3); + let fake_scid = 42; + + nodes[1].messenger.register_scid_for_interception(fake_scid, nodes[2].node_id); + + let message = TestCustomMessage::Pong; + let intermediate_nodes = + [MessageForwardNode { node_id: nodes[1].node_id, short_channel_id: Some(fake_scid) }]; + let blinded_path = BlindedMessagePath::new( + &intermediate_nodes, + nodes[2].node_id, + nodes[2].messenger.node_signer.get_receive_auth_key(), + MessageContext::Custom(Vec::new()), + false, + &*nodes[2].entropy_source, + &Secp256k1::new(), + ); + let destination = Destination::BlindedPath(blinded_path); + let instructions = MessageSendInstructions::WithoutReplyPath { destination }; + + disconnect_peers(&nodes[1], &nodes[2]); + nodes[0].messenger.send_onion_message(message, instructions).unwrap(); + let mut final_node_vec = nodes.split_off(2); + pass_along_path(&nodes); + + let mut events = release_events(&nodes[1]); + assert_eq!(events.len(), 1); + let onion_message = match events.remove(0) { + Event::OnionMessageIntercepted { peer_node_id, message } => { + assert_eq!(peer_node_id, final_node_vec[0].node_id); + message + }, + _ => panic!(), + }; + + connect_peers(&nodes[1], &final_node_vec[0]); + let peer_conn_ev = release_events(&nodes[1]); + assert_eq!(peer_conn_ev.len(), 1); + match peer_conn_ev[0] { + Event::OnionMessagePeerConnected { peer_node_id } => { + assert_eq!(peer_node_id, final_node_vec[0].node_id); + }, + _ => panic!(), + } + + nodes[1].messenger.forward_onion_message(onion_message, &final_node_vec[0].node_id).unwrap(); + final_node_vec[0].custom_message_handler.expect_message(TestCustomMessage::Pong); + pass_along_path(&vec![nodes.remove(1), final_node_vec.remove(0)]); +} + #[test] fn spec_test_vector() { let node_cfgs = [ diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index f94eb7877f5..9a5d3072ee0 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -125,6 +125,72 @@ 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 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_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) + } +} + +impl> OnionMessageInterceptor for B { + fn register_scid_for_interception(&self, scid: u64, peer_node_id: PublicKey) { + self.deref().register_scid_for_interception(scid, peer_node_id); + } + + fn deregister_scid_for_interception(&self, scid: u64) -> bool { + self.deref().deregister_scid_for_interception(scid) + } +} + /// A sender, receiver and forwarder of [`OnionMessage`]s. /// /// # Handling Messages @@ -273,6 +339,7 @@ pub struct OnionMessenger< dns_resolver_handler: DRH, custom_handler: CMH, intercept_messages_for_offline_peers: bool, + scids_registered_for_interception: Mutex>, pending_intercepted_msgs_events: Mutex>, pending_peer_connected_events: Mutex>, pending_events_processor: AtomicBool, @@ -1453,6 +1520,7 @@ impl< dns_resolver_handler: dns_resolver, custom_handler, intercept_messages_for_offline_peers, + 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 +1538,34 @@ impl< self.async_payments_handler = async_payments_handler; } + /// 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, @@ -1659,15 +1755,32 @@ impl< fn enqueue_forwarded_onion_message( &self, next_hop: NextMessageHop, onion_message: OnionMessage, log_suffix: fmt::Arguments, ) -> Result<(), SendError> { - let next_node_id = match next_hop { - NextMessageHop::NodeId(pubkey) => pubkey, - 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); + let (next_node_id, is_registered_for_interception) = { + let scids_registered_for_interception = + self.scids_registered_for_interception.lock().unwrap(); + match next_hop { + NextMessageHop::NodeId(pubkey) => { + let is_registered = + scids_registered_for_interception.values().any(|nid| *nid == pubkey); + (pubkey, is_registered) }, - }, + NextMessageHop::ShortChannelId(scid) => { + match self.node_id_lookup.next_node_id(scid) { + Some(pubkey) => (pubkey, false), + None => { + // The SCID is unknown to NodeIdLookUp (not a real channel). Check + // if it's registered for SCID-based interception before dropping. + match scids_registered_for_interception.get(&scid).copied() { + Some(peer_node_id) => (peer_node_id, true), + None => { + log_trace!(self.logger, "Dropping forwarded onion message: unable to resolve next hop using SCID {} {}", scid, log_suffix); + return Err(SendError::GetNodeIdFailed); + }, + } + }, + } + }, + } }; let mut message_recipients = self.message_recipients.lock().unwrap(); @@ -1686,6 +1799,9 @@ impl< .entry(next_node_id) .or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new())); + let should_intercept = + self.intercept_messages_for_offline_peers || is_registered_for_interception; + match message_recipients.entry(next_node_id) { hash_map::Entry::Occupied(mut e) if matches!(e.get(), OnionMessageRecipient::ConnectedPeer(..)) => @@ -1699,7 +1815,7 @@ impl< ); Ok(()) }, - _ if self.intercept_messages_for_offline_peers => { + _ if should_intercept => { log_trace!( self.logger, "Generating OnionMessageIntercepted event for peer {} {}", @@ -2142,7 +2258,13 @@ impl< .or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new())) .mark_connected(); } - if self.intercept_messages_for_offline_peers { + let is_registered_for_interception = self + .scids_registered_for_interception + .lock() + .unwrap() + .values() + .any(|nid| *nid == their_node_id); + if self.intercept_messages_for_offline_peers || is_registered_for_interception { let mut pending_peer_connected_events = self.pending_peer_connected_events.lock().unwrap(); pending_peer_connected_events From 686ad19dd90beb49a60fe1d8b1b1aeb670e98b5d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 14:51:03 +0100 Subject: [PATCH 02/25] f Intercept OMs for all unknown SCIDs --- lightning/src/blinded_path/message.rs | 5 + lightning/src/events/mod.rs | 49 ++++-- .../src/onion_message/functional_tests.rs | 64 +------- lightning/src/onion_message/messenger.rs | 155 +++--------------- 4 files changed, 72 insertions(+), 201 deletions(-) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 7bcbe80a965..89eaa232fa4 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -271,6 +271,11 @@ pub enum NextMessageHop { ShortChannelId(u64), } +impl_writeable_tlv_based_enum!(NextMessageHop, + {0, NodeId} => (), + {2, ShortChannelId} => (), +); + /// An intermediate node, and possibly a short channel id leading to the next node. /// /// Note: diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 011b7f595bc..d204609979b 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -18,7 +18,7 @@ pub mod bump_transaction; pub use bump_transaction::BumpTransactionEvent; -use crate::blinded_path::message::{BlindedMessagePath, OffersContext}; +use crate::blinded_path::message::{BlindedMessagePath, NextMessageHop, OffersContext}; use crate::blinded_path::payment::{ Bolt12OfferContext, Bolt12RefundContext, PaymentContext, PaymentContextRef, }; @@ -1721,8 +1721,8 @@ pub enum Event { /// /// [`OnionMessenger::new_with_offline_peer_interception`]: crate::onion_message::messenger::OnionMessenger::new_with_offline_peer_interception OnionMessageIntercepted { - /// The node id of the offline peer. - peer_node_id: PublicKey, + /// The next hop (offline peer or unkown SCID). + next_hop: NextMessageHop, /// The onion message intended to be forwarded to `peer_node_id`. message: msgs::OnionMessage, }, @@ -2303,12 +2303,25 @@ impl Writeable for Event { 35u8.write(writer)?; // Never write ConnectionNeeded events as buffered onion messages aren't serialized. }, - &Event::OnionMessageIntercepted { ref peer_node_id, ref message } => { + &Event::OnionMessageIntercepted { ref next_hop, ref message } => { 37u8.write(writer)?; - write_tlv_fields!(writer, { - (0, peer_node_id, required), - (2, message, required), - }); + match next_hop { + NextMessageHop::NodeId(peer_node_id) => { + // If we have the node_id, we keep writing it for backwards compatibility. + write_tlv_fields!(writer, { + (0, peer_node_id, required), + (1, next_hop, required), + (2, message, required), + }); + }, + NextMessageHop::ShortChannelId(_) => { + write_tlv_fields!(writer, { + // 0 used to be peer_node_id in LDK v0.2 and prior. + (1, next_hop, required), + (2, message, required), + }); + }, + } }, &Event::OnionMessagePeerConnected { ref peer_node_id } => { 39u8.write(writer)?; @@ -2936,13 +2949,23 @@ impl MaybeReadable for Event { 37u8 => { let mut f = || { _init_and_read_len_prefixed_tlv_fields!(reader, { - (0, peer_node_id, required), + (0, peer_node_id, option), + (1, next_hop, option), (2, message, required), }); - Ok(Some(Event::OnionMessageIntercepted { - peer_node_id: peer_node_id.0.unwrap(), - message: message.0.unwrap(), - })) + + if let Some(next_hop) = next_hop.or(peer_node_id.map(NextMessageHop::NodeId)) { + Ok(Some(Event::OnionMessageIntercepted { + next_hop, + message: message.0.unwrap(), + })) + } else { + debug_assert!( + false, + "Either next_hop or peer_node_id should always been set" + ); + Ok(None) + } }; f() }, diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 26e1c5b102f..b3cafcbe9b0 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -24,7 +24,7 @@ use super::offers::{OffersMessage, OffersMessageHandler}; use super::packet::{OnionMessageContents, Packet}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, DNSResolverContext, MessageContext, - MessageForwardNode, OffersContext, MESSAGE_PADDING_ROUND_OFF, + MessageForwardNode, NextMessageHop, OffersContext, MESSAGE_PADDING_ROUND_OFF, }; use crate::blinded_path::utils::is_padded; use crate::blinded_path::EmptyNodeIdLookUp; @@ -1144,9 +1144,13 @@ fn intercept_offline_peer_oms() { let mut events = release_events(&nodes[1]); assert_eq!(events.len(), 1); let onion_message = match events.remove(0) { - Event::OnionMessageIntercepted { peer_node_id, message } => { - assert_eq!(peer_node_id, final_node_vec[0].node_id); - message + Event::OnionMessageIntercepted { next_hop, message } => { + if let NextMessageHop::NodeId(peer_node_id) = next_hop { + assert_eq!(peer_node_id, final_node_vec[0].node_id); + message + } else { + panic!(); + } }, _ => panic!(), }; @@ -1173,58 +1177,6 @@ fn intercept_offline_peer_oms() { pass_along_path(&vec![nodes.remove(1), final_node_vec.remove(0)]); } -#[test] -fn intercept_offline_peer_oms_registered_by_scid() { - let mut nodes = create_nodes(3); - let fake_scid = 42; - - nodes[1].messenger.register_scid_for_interception(fake_scid, nodes[2].node_id); - - let message = TestCustomMessage::Pong; - let intermediate_nodes = - [MessageForwardNode { node_id: nodes[1].node_id, short_channel_id: Some(fake_scid) }]; - let blinded_path = BlindedMessagePath::new( - &intermediate_nodes, - nodes[2].node_id, - nodes[2].messenger.node_signer.get_receive_auth_key(), - MessageContext::Custom(Vec::new()), - false, - &*nodes[2].entropy_source, - &Secp256k1::new(), - ); - let destination = Destination::BlindedPath(blinded_path); - let instructions = MessageSendInstructions::WithoutReplyPath { destination }; - - disconnect_peers(&nodes[1], &nodes[2]); - nodes[0].messenger.send_onion_message(message, instructions).unwrap(); - let mut final_node_vec = nodes.split_off(2); - pass_along_path(&nodes); - - let mut events = release_events(&nodes[1]); - assert_eq!(events.len(), 1); - let onion_message = match events.remove(0) { - Event::OnionMessageIntercepted { peer_node_id, message } => { - assert_eq!(peer_node_id, final_node_vec[0].node_id); - message - }, - _ => panic!(), - }; - - connect_peers(&nodes[1], &final_node_vec[0]); - let peer_conn_ev = release_events(&nodes[1]); - assert_eq!(peer_conn_ev.len(), 1); - match peer_conn_ev[0] { - Event::OnionMessagePeerConnected { peer_node_id } => { - assert_eq!(peer_node_id, final_node_vec[0].node_id); - }, - _ => panic!(), - } - - nodes[1].messenger.forward_onion_message(onion_message, &final_node_vec[0].node_id).unwrap(); - final_node_vec[0].custom_message_handler.expect_message(TestCustomMessage::Pong); - pass_along_path(&vec![nodes.remove(1), final_node_vec.remove(0)]); -} - #[test] fn spec_test_vector() { let node_cfgs = [ diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 9a5d3072ee0..27ad8e9a8f0 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -125,72 +125,6 @@ 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 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_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) - } -} - -impl> OnionMessageInterceptor for B { - fn register_scid_for_interception(&self, scid: u64, peer_node_id: PublicKey) { - self.deref().register_scid_for_interception(scid, peer_node_id); - } - - fn deregister_scid_for_interception(&self, scid: u64) -> bool { - self.deref().deregister_scid_for_interception(scid) - } -} - /// A sender, receiver and forwarder of [`OnionMessage`]s. /// /// # Handling Messages @@ -339,7 +273,6 @@ pub struct OnionMessenger< dns_resolver_handler: DRH, custom_handler: CMH, intercept_messages_for_offline_peers: bool, - scids_registered_for_interception: Mutex>, pending_intercepted_msgs_events: Mutex>, pending_peer_connected_events: Mutex>, pending_events_processor: AtomicBool, @@ -1520,7 +1453,6 @@ impl< dns_resolver_handler: dns_resolver, custom_handler, intercept_messages_for_offline_peers, - 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), @@ -1538,34 +1470,6 @@ impl< self.async_payments_handler = async_payments_handler; } - /// 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, @@ -1755,32 +1659,28 @@ impl< fn enqueue_forwarded_onion_message( &self, next_hop: NextMessageHop, onion_message: OnionMessage, log_suffix: fmt::Arguments, ) -> Result<(), SendError> { - let (next_node_id, is_registered_for_interception) = { - let scids_registered_for_interception = - self.scids_registered_for_interception.lock().unwrap(); - match next_hop { - NextMessageHop::NodeId(pubkey) => { - let is_registered = - scids_registered_for_interception.values().any(|nid| *nid == pubkey); - (pubkey, is_registered) - }, - NextMessageHop::ShortChannelId(scid) => { - match self.node_id_lookup.next_node_id(scid) { - Some(pubkey) => (pubkey, false), - None => { - // The SCID is unknown to NodeIdLookUp (not a real channel). Check - // if it's registered for SCID-based interception before dropping. - match scids_registered_for_interception.get(&scid).copied() { - Some(peer_node_id) => (peer_node_id, true), - None => { - log_trace!(self.logger, "Dropping forwarded onion message: unable to resolve next hop using SCID {} {}", scid, log_suffix); - return Err(SendError::GetNodeIdFailed); - }, - } - }, + let next_node_id = match next_hop { + NextMessageHop::NodeId(pubkey) => pubkey, + NextMessageHop::ShortChannelId(scid) => match self.node_id_lookup.next_node_id(scid) { + Some(pubkey) => pubkey, + None => { + if self.intercept_messages_for_offline_peers { + log_trace!( + self.logger, + "Generating OnionMessageIntercepted event for SCID {} {}", + scid, + log_suffix + ); + self.enqueue_intercepted_event(Event::OnionMessageIntercepted { + next_hop, + message: onion_message, + }); + return Ok(()); } + log_trace!(self.logger, "Dropping forwarded onion messager: unable to resolve next hop using SCID {} {}", scid, log_suffix); + return Err(SendError::GetNodeIdFailed); }, - } + }, }; let mut message_recipients = self.message_recipients.lock().unwrap(); @@ -1799,9 +1699,6 @@ impl< .entry(next_node_id) .or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new())); - let should_intercept = - self.intercept_messages_for_offline_peers || is_registered_for_interception; - match message_recipients.entry(next_node_id) { hash_map::Entry::Occupied(mut e) if matches!(e.get(), OnionMessageRecipient::ConnectedPeer(..)) => @@ -1815,7 +1712,7 @@ impl< ); Ok(()) }, - _ if should_intercept => { + _ if self.intercept_messages_for_offline_peers => { log_trace!( self.logger, "Generating OnionMessageIntercepted event for peer {} {}", @@ -1823,7 +1720,7 @@ impl< log_suffix ); self.enqueue_intercepted_event(Event::OnionMessageIntercepted { - peer_node_id: next_node_id, + next_hop, message: onion_message, }); Ok(()) @@ -2258,13 +2155,7 @@ impl< .or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new())) .mark_connected(); } - let is_registered_for_interception = self - .scids_registered_for_interception - .lock() - .unwrap() - .values() - .any(|nid| *nid == their_node_id); - if self.intercept_messages_for_offline_peers || is_registered_for_interception { + if self.intercept_messages_for_offline_peers { let mut pending_peer_connected_events = self.pending_peer_connected_events.lock().unwrap(); pending_peer_connected_events From dda51b83c5986c14d047d196adca9e0025a17578 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 15:35:45 +0100 Subject: [PATCH 03/25] f Some more docs --- lightning/src/events/mod.rs | 6 ++++-- lightning/src/onion_message/messenger.rs | 7 +++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index d204609979b..87467540a2c 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1705,8 +1705,10 @@ pub enum Event { /// [`ChannelHandshakeConfig::negotiate_anchor_zero_fee_commitments`]: crate::util::config::ChannelHandshakeConfig::negotiate_anchor_zero_fee_commitments BumpTransaction(BumpTransactionEvent), /// We received an onion message that is intended to be forwarded to a peer - /// that is currently offline. This event will only be generated if the - /// `OnionMessenger` was initialized with + /// that is currently offline *or* that is intended to be forwarded along a channel with an + /// SCID unknown to us. + /// + /// This event will only be generated if the `OnionMessenger` was initialized with /// [`OnionMessenger::new_with_offline_peer_interception`], see its docs. /// /// The offline peer should be awoken if possible on receipt of this event, such as via the LSPS5 diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 27ad8e9a8f0..fd4bd71ec36 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -1399,10 +1399,9 @@ impl< /// later forwarding. /// /// Interception flow: - /// 1. If an onion message for an offline peer is received, `OnionMessenger` will - /// generate an [`Event::OnionMessageIntercepted`]. Event handlers can - /// then choose to persist this onion message for later forwarding, or drop - /// it. + /// 1. If an onion message for an offline peer or unknown SCIDs is received, `OnionMessenger` + /// will generate an [`Event::OnionMessageIntercepted`]. Event handlers can then choose + /// to persist this onion message for later forwarding, or drop it. /// 2. When the offline peer later comes back online, `OnionMessenger` will /// generate an [`Event::OnionMessagePeerConnected`]. Event handlers will /// then fetch all previously intercepted onion messages for this peer. From a65dec4b34c996e478ad1f52c8015b0d5dcdf647 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 15:54:42 +0100 Subject: [PATCH 04/25] f Fix docs --- lightning/src/events/mod.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 87467540a2c..69d709a0e4a 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1723,9 +1723,10 @@ pub enum Event { /// /// [`OnionMessenger::new_with_offline_peer_interception`]: crate::onion_message::messenger::OnionMessenger::new_with_offline_peer_interception OnionMessageIntercepted { - /// The next hop (offline peer or unkown SCID). + /// The next hop (offline peer or unknown SCID). next_hop: NextMessageHop, - /// The onion message intended to be forwarded to `peer_node_id`. + /// The onion message intended to be forwarded to the offline peer or via the unknown + /// channel once established. message: msgs::OnionMessage, }, /// Indicates that an onion message supporting peer has come online and any messages previously @@ -2964,7 +2965,7 @@ impl MaybeReadable for Event { } else { debug_assert!( false, - "Either next_hop or peer_node_id should always been set" + "Either next_hop or peer_node_id should always be set" ); Ok(None) } From ab33c61e56699b8273287bcccee0aaf5c1f75f60 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 30 Mar 2026 15:34:47 +0200 Subject: [PATCH 05/25] f typo --- lightning/src/onion_message/messenger.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index fd4bd71ec36..f1cff1719a6 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -1676,7 +1676,7 @@ impl< }); return Ok(()); } - log_trace!(self.logger, "Dropping forwarded onion messager: unable to resolve next hop using SCID {} {}", scid, log_suffix); + log_trace!(self.logger, "Dropping forwarded onion message: unable to resolve next hop using SCID {} {}", scid, log_suffix); return Err(SendError::GetNodeIdFailed); }, }, From 768055fe86b4b7ae8a27e2c9c1748420b683722c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 15:40:45 +0100 Subject: [PATCH 06/25] Test `OnionMessageIntercepted` for unknown SCID next hops Add `intercept_unknown_scid_oms` test that verifies the `OnionMessenger` correctly generates `OnionMessageIntercepted` events with a `ShortChannelId` next hop when a blinded path uses an unresolvable SCID. This complements the existing `intercept_offline_peer_oms` test which only covers the `NodeId` variant (offline peer case). Co-Authored-By: HAL 9000 --- .../src/onion_message/functional_tests.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index b3cafcbe9b0..ec700fd3120 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -1177,6 +1177,77 @@ fn intercept_offline_peer_oms() { pass_along_path(&vec![nodes.remove(1), final_node_vec.remove(0)]); } +#[test] +fn intercept_unknown_scid_oms() { + // Ensure that if OnionMessenger is initialized with + // new_with_offline_peer_interception, we will intercept OMs that use an unknown SCID as the + // next hop, generate the right events, and forward OMs when they are re-injected by the + // user. + let node_cfgs = vec![ + MessengerCfg::new(), + MessengerCfg::new().with_offline_peer_interception(), + MessengerCfg::new(), + ]; + let mut nodes = create_nodes_using_cfgs(node_cfgs); + + let peer_conn_evs = release_events(&nodes[1]); + assert_eq!(peer_conn_evs.len(), 2); + for (i, ev) in peer_conn_evs.iter().enumerate() { + match ev { + Event::OnionMessagePeerConnected { peer_node_id } => { + let node_idx = if i == 0 { 0 } else { 2 }; + assert_eq!(peer_node_id, &nodes[node_idx].node_id); + }, + _ => panic!(), + } + } + + // Use a SCID-based intermediate hop to trigger the unknown SCID interception path. Since + // we use `EmptyNodeIdLookUp`, the SCID cannot be resolved, so the OnionMessenger will + // generate an `OnionMessageIntercepted` event with a `ShortChannelId` next hop. + let scid = 42; + let message = TestCustomMessage::Pong; + let intermediate_nodes = + [MessageForwardNode { node_id: nodes[1].node_id, short_channel_id: Some(scid) }]; + let blinded_path = BlindedMessagePath::new( + &intermediate_nodes, + nodes[2].node_id, + nodes[2].messenger.node_signer.get_receive_auth_key(), + MessageContext::Custom(Vec::new()), + false, + &*nodes[2].entropy_source, + &Secp256k1::new(), + ); + let destination = Destination::BlindedPath(blinded_path); + let instructions = MessageSendInstructions::WithoutReplyPath { destination }; + + nodes[0].messenger.send_onion_message(message, instructions).unwrap(); + let mut final_node_vec = nodes.split_off(2); + pass_along_path(&nodes); + + // We expect an `OnionMessageIntercepted` event with a `ShortChannelId` next hop since the + // SCID is not resolvable via the `EmptyNodeIdLookUp`. + let mut events = release_events(&nodes[1]); + assert_eq!(events.len(), 1); + let onion_message = match events.remove(0) { + Event::OnionMessageIntercepted { next_hop, message } => { + if let NextMessageHop::ShortChannelId(intercepted_scid) = next_hop { + assert_eq!(intercepted_scid, scid); + message + } else { + panic!("Expected ShortChannelId next hop, got NodeId"); + } + }, + _ => panic!(), + }; + + // The user resolves the SCID externally and forwards the intercepted message to the + // correct peer. + nodes[1].messenger.forward_onion_message(onion_message, &final_node_vec[0].node_id).unwrap(); + final_node_vec[0].custom_message_handler.expect_message(TestCustomMessage::Pong); + pass_along_path(&vec![nodes.remove(1), final_node_vec.remove(0)]); +} + #[test] fn spec_test_vector() { let node_cfgs = [ From 40e83004749576876b147b9e5432460597147fc7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 15:49:58 +0100 Subject: [PATCH 07/25] Test `OnionMessageIntercepted` upgrade/downgrade with LDK 0.2 Add backwards compatibility tests for `Event::OnionMessageIntercepted` serialization to verify that: - Events serialized by LDK 0.2 (with `peer_node_id` in TLV field 0) can be deserialized by the current version as `NextMessageHop::NodeId`. - Events with `NodeId` next hop serialized by the current version can be deserialized by LDK 0.2 (which reads `peer_node_id` from field 0). - Events with `ShortChannelId` next hop (which omit TLV field 0) correctly fail to deserialize in LDK 0.2, since the `peer_node_id` field is required there. Co-Authored-By: HAL 9000 --- .../src/upgrade_downgrade_tests.rs | 115 +++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 7f607bba848..634d17dcd90 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -17,7 +17,10 @@ use lightning_0_2::ln::channelmanager::PaymentId as PaymentId_0_2; use lightning_0_2::ln::channelmanager::RecipientOnionFields as RecipientOnionFields_0_2; use lightning_0_2::ln::functional_test_utils as lightning_0_2_utils; use lightning_0_2::ln::msgs::ChannelMessageHandler as _; +use lightning_0_2::ln::msgs::OnionMessage as OnionMessage_0_2; +use lightning_0_2::onion_message::packet::Packet as Packet_0_2; use lightning_0_2::routing::router as router_0_2; +use lightning_0_2::util::ser::MaybeReadable as MaybeReadable_0_2; use lightning_0_2::util::ser::Writeable as _; use lightning_0_1::commitment_signed_dance as commitment_signed_dance_0_1; @@ -45,23 +48,29 @@ use lightning_0_0_125::ln::msgs::ChannelMessageHandler as _; use lightning_0_0_125::routing::router as router_0_0_125; use lightning_0_0_125::util::ser::Writeable as _; +use lightning::blinded_path::message::NextMessageHop; use lightning::chain::channelmonitor::{ANTI_REORG_DELAY, HTLC_FAIL_BACK_BUFFER}; use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType}; use lightning::ln::functional_test_utils::*; +use lightning::ln::msgs; use lightning::ln::msgs::BaseMessageHandler as _; use lightning::ln::msgs::ChannelMessageHandler as _; use lightning::ln::msgs::MessageSendEvent; use lightning::ln::splicing_tests::*; use lightning::ln::types::ChannelId; +use lightning::onion_message::packet::Packet; use lightning::sign::OutputSpender; +use lightning::util::ser::{MaybeReadable, Writeable}; use lightning::util::wallet_utils::WalletSourceSync; use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use bitcoin::script::Builder; -use bitcoin::secp256k1::Secp256k1; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::{opcodes, Amount, TxOut}; +use lightning::io::Cursor; + use std::sync::Arc; #[test] @@ -701,3 +710,107 @@ fn do_upgrade_mid_htlc_forward(test: MidHtlcForwardCase) { expect_payment_claimable!(nodes[2], pay_hash, pay_secret, 1_000_000); claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], pay_preimage); } + +/// Constructs a dummy `OnionMessage` (current version) for use in serialization tests. +fn dummy_onion_message() -> msgs::OnionMessage { + let pubkey = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + msgs::OnionMessage { + blinding_point: pubkey, + onion_routing_packet: Packet { + version: 0, + public_key: pubkey, + hop_data: vec![1; 64], + hmac: [2; 32], + }, + } +} + +/// Constructs a dummy `OnionMessage` (0.2 version) for use in serialization tests. +fn dummy_onion_message_0_2() -> OnionMessage_0_2 { + let pubkey = bitcoin::secp256k1::PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[42; 32]).unwrap(), + ); + OnionMessage_0_2 { + blinding_point: pubkey, + onion_routing_packet: Packet_0_2 { + version: 0, + public_key: pubkey, + hop_data: vec![1; 64], + hmac: [2; 32], + }, + } +} + +#[test] +fn test_onion_message_intercepted_upgrade_from_0_2() { + // Ensure that an `Event::OnionMessageIntercepted` serialized by LDK 0.2 (which uses + // `peer_node_id: PublicKey` in TLV field 0) can be deserialized by the current version, + // producing `NextMessageHop::NodeId`. + let pubkey = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + + let event_0_2 = Event_0_2::OnionMessageIntercepted { + peer_node_id: pubkey, + message: dummy_onion_message_0_2(), + }; + + let serialized = lightning_0_2::util::ser::Writeable::encode(&event_0_2); + + let mut reader = Cursor::new(&serialized); + let deserialized = ::read(&mut reader).unwrap().unwrap(); + + match deserialized { + Event::OnionMessageIntercepted { next_hop, message } => { + assert_eq!(next_hop, NextMessageHop::NodeId(pubkey)); + assert_eq!(message, dummy_onion_message()); + }, + _ => panic!("Expected OnionMessageIntercepted event"), + } +} + +#[test] +fn test_onion_message_intercepted_node_id_downgrade_to_0_2() { + // Ensure that an `Event::OnionMessageIntercepted` with a `NodeId` next hop serialized by + // the current version can be deserialized by LDK 0.2 (which expects `peer_node_id` in TLV + // field 0). + let pubkey = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + + let event = Event::OnionMessageIntercepted { + next_hop: NextMessageHop::NodeId(pubkey), + message: dummy_onion_message(), + }; + + let serialized = event.encode(); + + let mut reader = Cursor::new(&serialized); + let deserialized = ::read(&mut reader).unwrap().unwrap(); + + match deserialized { + Event_0_2::OnionMessageIntercepted { peer_node_id, message } => { + assert_eq!(peer_node_id, pubkey); + assert_eq!(message, dummy_onion_message_0_2()); + }, + _ => panic!("Expected OnionMessageIntercepted event"), + } +} + +#[test] +fn test_onion_message_intercepted_scid_downgrade_to_0_2() { + // Ensure that an `Event::OnionMessageIntercepted` with a `ShortChannelId` next hop + // serialized by the current version cannot be deserialized by LDK 0.2, since the + // `peer_node_id` field (0) is not written for SCID variants and LDK 0.2 requires it. + let event = Event::OnionMessageIntercepted { + next_hop: NextMessageHop::ShortChannelId(42), + message: dummy_onion_message(), + }; + + let serialized = event.encode(); + + // LDK 0.2 will try to read field 0 as required. Since it's absent, the read will fail. + let mut reader = Cursor::new(&serialized); + let result = ::read(&mut reader); + assert!(result.is_err(), "LDK 0.2 should fail to decode a ShortChannelId variant"); +} From 4381fdf176ce415c626acd43a5f2c69b73dcedf9 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 08/25] Add an LSPS2-aware `BOLT12` router wrapper Introduce `LSPS2BOLT12Router` to map registered offers to LSPS2 invoice parameters and build blinded payment paths through the negotiated intercept `SCID`. All other routing behavior still delegates to the wrapped 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 e17bfb43437dfdf9299a1a21c51d62df23146843 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 16:09:32 +0100 Subject: [PATCH 09/25] f Docs --- lightning-liquidity/src/lsps2/router.rs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index 74832739f04..e807f240395 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -50,12 +50,16 @@ pub struct LSPS2Bolt12InvoiceParameters { /// 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. +/// [`MessageForwardNode::short_channel_id`] so that [`Event::HTLCIntercepted`] is emitted when the +/// HTLC arrives, prompting the LSP to open the channel just-in-time. /// -/// [`OnionMessageInterceptor::register_scid_for_interception`]: lightning::onion_message::messenger::OnionMessageInterceptor::register_scid_for_interception +/// The LSP must use an [`OnionMessenger`] that is setup via +/// [`OnionMessenger::new_with_offline_peer_interception`] so that forwarded messages are +/// intercepted rather than dropped. +/// +/// [`OnionMessenger`]: lightning::onion_message::messenger::OnionMessenger +/// [`OnionMessenger::new_with_offline_peer_interception`]: lightning::onion_message::messenger::OnionMessenger::new_with_offline_peer_interception +/// [`Event::HTLCIntercepted`]: lightning::events::Event::HTLCIntercepted pub struct LSPS2BOLT12Router { inner_router: R, inner_message_router: MR, @@ -206,10 +210,8 @@ impl MessageRoute &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. + // Inject intercept SCIDs to have the payer use them when sending HTLCs, prompting the LSP + // node to emit Event::HTLCIntercepted and hence trigger channel open let peers = match &context { MessageContext::Offers(OffersContext::InvoiceRequest { .. }) => { let params = self.offer_to_invoice_params.lock().unwrap(); From af9b735d8e8739eb2152a8a99563902a02ad98dd Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 16:27:30 +0100 Subject: [PATCH 10/25] f User offerids in tracking map Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps2/router.rs | 18 +++++++++--------- lightning/src/offers/offer.rs | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index e807f240395..4e96c576c5c 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -64,7 +64,7 @@ pub struct LSPS2BOLT12Router>, + offer_to_invoice_params: Mutex>, } impl LSPS2BOLT12Router { @@ -82,12 +82,12 @@ impl LSPS2BOLT12R 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) + self.offer_to_invoice_params.lock().unwrap().insert(offer_id, 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) + pub fn unregister_offer(&self, offer_id: OfferId) -> Option { + self.offer_to_invoice_params.lock().unwrap().remove(&offer_id) } /// Clears all LSPS2 parameters previously registered via [`Self::register_offer`]. @@ -105,7 +105,7 @@ impl LSPS2BOLT12R _ => return None, }; - self.offer_to_invoice_params.lock().unwrap().get(&offer_id.0).copied() + self.offer_to_invoice_params.lock().unwrap().get(offer_id).copied() } } @@ -508,8 +508,8 @@ mod tests { 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); + assert_eq!(router.unregister_offer(offer_id), Some(params)); + assert_eq!(router.unregister_offer(offer_id), None); } #[test] @@ -536,7 +536,7 @@ mod tests { ); router.clear_registered_offers(); - assert_eq!(router.unregister_offer(&OfferId([1; 32])), None); - assert_eq!(router.unregister_offer(&OfferId([2; 32])), None); + assert_eq!(router.unregister_offer(OfferId([1; 32])), None); + assert_eq!(router.unregister_offer(OfferId([2; 32])), None); } } diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index b2703454169..a3200eb52c3 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -118,7 +118,7 @@ pub(super) const IV_BYTES_WITH_METADATA: &[u8; IV_LEN] = b"LDK Offer ~~~~~~"; pub(super) const IV_BYTES_WITHOUT_METADATA: &[u8; IV_LEN] = b"LDK Offer v2~~~~"; /// An identifier for an [`Offer`] built using [`DerivedMetadata`]. -#[derive(Clone, Copy, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, Hash, PartialEq)] pub struct OfferId(pub [u8; 32]); impl OfferId { From 132b81085c49d6d6589bb8ded99ae44736f31004 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Mon, 30 Mar 2026 14:53:17 +0200 Subject: [PATCH 11/25] f Comments --- lightning-liquidity/src/lsps2/router.rs | 35 +++++++++---------------- 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index 4e96c576c5c..47dc3234dfa 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -44,9 +44,9 @@ pub struct LSPS2Bolt12InvoiceParameters { } /// A router wrapper that injects LSPS2-specific BOLT12 blinded paths for registered offer ids -/// while delegating all other routing behavior to the inner routers. +/// while delegating all other blinded path creation behaviors to the inner routers. /// -/// For **payment** blinded paths (in invoices), it injects the intercept SCID as the forwarding +/// For **payment** blinded paths (in invoices), it returns 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 @@ -208,27 +208,18 @@ impl MessageRoute fn create_blinded_paths( &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, - context: MessageContext, peers: Vec, secp_ctx: &Secp256k1, + context: MessageContext, mut peers: Vec, secp_ctx: &Secp256k1, ) -> Result, ()> { - // Inject intercept SCIDs to have the payer use them when sending HTLCs, prompting the LSP - // node to emit Event::HTLCIntercepted and hence trigger channel open - 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, - }; + // Override with intercept SCIDs to have the payer use them when sending HTLCs, prompting + // the LSP node to emit Event::HTLCIntercepted and hence trigger channel open + if matches!(&context, MessageContext::Offers(OffersContext::InvoiceRequest { .. })) { + let params = self.offer_to_invoice_params.lock().unwrap(); + for peer in &mut peers { + if let Some(p) = params.values().find(|p| p.counterparty_node_id == peer.node_id) { + peer.short_channel_id = Some(p.intercept_scid); + } + } + } self.inner_message_router.create_blinded_paths( recipient, From 5340c70fe47c15a84fd7e7e4e227dee975d5c1e2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 31 Mar 2026 10:16:41 +0200 Subject: [PATCH 12/25] f Docs --- lightning-liquidity/src/lsps2/router.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index 47dc3234dfa..c619fc5a8b5 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -139,6 +139,8 @@ impl Router first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, secp_ctx: &Secp256k1, ) -> Result, ()> { + // Override with intercept SCIDs to have the payer use them when sending payments, + // prompting the LSP node to emit Event::HTLCIntercepted, hence triggering channel open. let lsps2_invoice_params = match self.registered_lsps2_params(&tlvs.payment_context) { Some(params) => params, None => { @@ -210,8 +212,10 @@ impl MessageRoute &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, context: MessageContext, mut peers: Vec, secp_ctx: &Secp256k1, ) -> Result, ()> { - // Override with intercept SCIDs to have the payer use them when sending HTLCs, prompting - // the LSP node to emit Event::HTLCIntercepted and hence trigger channel open + // Override with intercept SCIDs to have the payer use them when sending invoice requests, + // prompting the LSP node to emit Event::OnionMessageIntercepted, allowing it to then use + // the Router implementation above to also override the blinded payment paths with the + // intercept SCID, hence triggering channel open. if matches!(&context, MessageContext::Offers(OffersContext::InvoiceRequest { .. })) { let params = self.offer_to_invoice_params.lock().unwrap(); for peer in &mut peers { From c691d009bd55e66b1631b8a03e250037808c49d0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 13/25] Document the LSPS2 `BOLT12` router flow Describe how `InvoiceParametersReady` feeds both the existing `BOLT11` route-hint flow and the new `LSPS2BOLT12Router` registration path for `BOLT12` offers. 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 f13b39ddfaa33e961d241bf3dbaef909cd941041 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 14/25] Test LSPS2 router payment-path generation for `BOLT12` 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`. Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- .../tests/lsps2_integration_tests.rs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index b8a4a5adebb..23278cf70a0 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -15,6 +15,11 @@ 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::onion_message::messenger::NullMessageRouter; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::ReceiveAuthKey; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::LSPSDateTime; @@ -22,11 +27,16 @@ 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::blinded_path::payment::{ + Bolt12OfferContext, PaymentConstraints, PaymentContext, ReceiveTlvs, +}; +use lightning::blinded_path::NodeIdLookUp; 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, @@ -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 }; @@ -1476,6 +1526,90 @@ 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, + NullMessageRouter {}, + 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 087f12a4ff3545b718aa13b76bd74e18bf876b2f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 15/25] Add a blinded-payment-path override to test utilities Allow tests to inject a custom `create_blinded_payment_paths` hook while preserving the normal `ReceiveTlvs` bindings. This makes it possible to exercise LSPS2-specific `BOLT12` path construction in integration tests. 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 c1ff9ea899392ec212fe7ad28fb9714e2bbaa185 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 16/25] Add an LSPS2 `BOLT12` end-to-end integration test Cover the full offer-payment flow from onion-message invoice exchange through HTLC interception, JIT channel opening, and settlement. This confirms the LSPS2 router and service handler work together in the integrated path. Co-Authored-By: HAL 9000 --- .../tests/lsps2_integration_tests.rs | 472 +++++++++++++++++- 1 file changed, 470 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 23278cf70a0..2522892b6df 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -7,20 +7,22 @@ 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::onion_message::messenger::NullMessageRouter; 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; @@ -1610,6 +1612,472 @@ 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); + + // Disconnect service from client so the service must intercept the compact SCID-based + // InvoiceRequest instead of forwarding it immediately after resolving the registered SCID. + service_node.node.peer_disconnected(client_node_id); + client_node.node.peer_disconnected(service_node_id); + service_node.onion_messenger.peer_disconnected(client_node_id); + client_node.onion_messenger.peer_disconnected(service_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"); + + // Reconnect the service and client, then forward the intercepted message. + reconnect_nodes(ReconnectArgs::new(&service_node.inner, &client_node.inner)); + + // Forward the intercepted message to the reconnected client. + service_node + .onion_messenger + .forward_onion_message(intercepted_msg, &client_node_id) + .expect("Should succeed since client reconnected"); + + 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, From 833313a5a09ddf9bf66a64d0e57681d2d136448a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 15:19:53 +0100 Subject: [PATCH 17/25] f Intercept OMs for all unknown SCIDs --- lightning-liquidity/tests/lsps2_integration_tests.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 2522892b6df..c92a11ef7b9 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -7,6 +7,7 @@ use common::{ get_lsps_message, LSPSNodes, LSPSNodesWithPayer, LiquidityNode, }; +use lightning::blinded_path::message::NextMessageHop; use lightning::events::{ClosureReason, Event, EventsProvider}; use lightning::get_event_msg; use lightning::ln::channelmanager::{OptionalBolt11PaymentParams, PaymentId}; @@ -22,7 +23,6 @@ use lightning::onion_message::messenger::NullMessageRouter; use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; 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; @@ -1924,11 +1924,6 @@ fn bolt12_lsps2_compact_message_path_test() { 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); @@ -2021,8 +2016,8 @@ fn bolt12_lsps2_compact_message_path_test() { 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); + Event::OnionMessageIntercepted { next_hop, message } => { + assert_eq!(next_hop, NextMessageHop::ShortChannelId(intercept_scid)); Some(message) }, _ => None, From 11624f1bfaf77cf8ba906193550393ea3b182a05 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 31 Mar 2026 11:09:41 +0200 Subject: [PATCH 18/25] DRAFT: Add opaque `context_data` to `OffersContext::InvoiceRequest` Allow embedding application-specific opaque bytes in the offer's blinded message path context. When an InvoiceRequest is received, the context data is extracted alongside the nonce, enabling stateless round-tripping of parameters (e.g., LSP channel parameters for JIT channels) without requiring client-side storage. Co-Authored-By: HAL 9000 --- lightning/src/blinded_path/message.rs | 8 ++++++++ lightning/src/ln/async_payments_tests.rs | 15 ++++++++++++--- lightning/src/ln/offers_tests.rs | 2 +- lightning/src/offers/flow.rs | 10 +++++++--- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 89eaa232fa4..e7f2f18b7e8 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -396,6 +396,13 @@ pub enum OffersContext { /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest /// [`Offer`]: crate::offers::offer::Offer nonce: Nonce, + /// Opaque bytes to be passed back when handling an [`InvoiceRequest`]. + /// + /// Can be used to carry application-specific context needed for invoice construction + /// (e.g., LSP channel parameters for JIT channels). + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + context_data: Option>, }, /// Context used by a [`BlindedMessagePath`] within the [`Offer`] of an async recipient. /// @@ -653,6 +660,7 @@ impl_writeable_tlv_based_enum!(MessageContext, impl_writeable_tlv_based_enum!(OffersContext, (0, InvoiceRequest) => { (0, nonce, required), + (1, context_data, option), }, (1, OutboundPaymentForRefund) => { (0, payment_id, required), diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 25522346d9c..2f33f9e9ced 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -314,7 +314,10 @@ fn create_static_invoice( .create_blinded_paths( always_online_counterparty.node.get_our_node_id(), always_online_counterparty.keys_manager.get_receive_auth_key(), - MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }), + MessageContext::Offers(OffersContext::InvoiceRequest { + nonce: Nonce([42; 16]), + context_data: None, + }), Vec::new(), &secp_ctx, ) @@ -682,7 +685,10 @@ fn static_invoice_unknown_required_features() { .create_blinded_paths( nodes[1].node.get_our_node_id(), nodes[1].keys_manager.get_receive_auth_key(), - MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }), + MessageContext::Offers(OffersContext::InvoiceRequest { + nonce: Nonce([42; 16]), + context_data: None, + }), Vec::new(), &secp_ctx, ) @@ -1662,7 +1668,10 @@ fn invalid_async_receive_with_retry( .create_blinded_paths( nodes[1].node.get_our_node_id(), nodes[1].keys_manager.get_receive_auth_key(), - MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }), + MessageContext::Offers(OffersContext::InvoiceRequest { + nonce: Nonce([42; 16]), + context_data: None, + }), Vec::new(), &secp_ctx, ) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index de08af5d276..c9197eae011 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -256,7 +256,7 @@ fn claim_bolt12_payment_with_extra_fees<'a, 'b, 'c>( fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> Nonce { match node.onion_messenger.peel_onion_message(message) { - Ok(PeeledOnion::Offers(_, Some(OffersContext::InvoiceRequest { nonce }), _)) => nonce, + Ok(PeeledOnion::Offers(_, Some(OffersContext::InvoiceRequest { nonce, .. }), _)) => nonce, Ok(PeeledOnion::Offers(_, context, _)) => panic!("Unexpected onion message context: {:?}", context), Ok(PeeledOnion::Forward(_, _)) => panic!("Unexpected onion message forward"), Ok(_) => panic!("Unexpected onion message"), diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 6e7293cee6b..2e49e034324 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -470,7 +470,7 @@ impl OffersMessageFlow { let nonce = match context { None if invoice_request.metadata().is_some() => None, - Some(OffersContext::InvoiceRequest { nonce }) => Some(nonce), + Some(OffersContext::InvoiceRequest { nonce, .. }) => Some(nonce), Some(OffersContext::StaticInvoiceRequested { recipient_id, invoice_slot, @@ -577,7 +577,8 @@ impl OffersMessageFlow { let secp_ctx = &self.secp_ctx; let nonce = Nonce::from_entropy_source(entropy); - let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce }); + let context = + MessageContext::Offers(OffersContext::InvoiceRequest { nonce, context_data: None }); let mut builder = OfferBuilder::deriving_signing_pubkey(node_id, expanded_key, nonce, secp_ctx) @@ -1708,7 +1709,10 @@ impl OffersMessageFlow { .and_then(|builder| builder.build_and_sign(secp_ctx)) .map_err(|_| ())?; - let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce: offer_nonce }); + let context = MessageContext::Offers(OffersContext::InvoiceRequest { + nonce: offer_nonce, + context_data: None, + }); let forward_invoice_request_path = self .create_blinded_paths(peers, context) .and_then(|paths| paths.into_iter().next().ok_or(()))?; From cccb464957aac3312cfad0a83d11a368464d51f5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 31 Mar 2026 11:10:20 +0200 Subject: [PATCH 19/25] DRAFT: Thread `context_data` through `verify_invoice_request` Extend `InvreqResponseInstructions::SendInvoice` to carry the opaque `context_data` from the offer's blinded message path. This allows callers to access application-specific context (e.g., LSP parameters) after invoice request verification, without requiring external state. Co-Authored-By: HAL 9000 --- lightning/src/ln/channelmanager.rs | 2 +- lightning/src/offers/flow.rs | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f9772bb120b..a52f5002578 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -16969,7 +16969,7 @@ impl< }; let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { - Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request, + Ok(InvreqResponseInstructions::SendInvoice { invoice_request, context_data: _ }) => invoice_request, Ok(InvreqResponseInstructions::SendStaticInvoice { recipient_id, invoice_slot, invoice_request }) => { self.pending_events.lock().unwrap().push_back((Event::StaticInvoiceRequested { recipient_id, invoice_slot, reply_path: responder, invoice_request, diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 2e49e034324..69c3d7ac178 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -410,7 +410,13 @@ fn enqueue_onion_message_with_reply_paths( pub enum InvreqResponseInstructions { /// We are the recipient of this payment, and a [`Bolt12Invoice`] should be sent in response to /// the invoice request since it is now verified. - SendInvoice(InvoiceRequestVerifiedFromOffer), + SendInvoice { + /// The verified invoice request to respond to. + invoice_request: InvoiceRequestVerifiedFromOffer, + /// Opaque context data from the offer's blinded message path, if any was provided + /// when creating the offer. + context_data: Option>, + }, /// We are a static invoice server and should respond to this invoice request by retrieving the /// [`StaticInvoice`] corresponding to the `recipient_id` and `invoice_slot` and calling /// [`OffersMessageFlow::enqueue_static_invoice`]. @@ -468,9 +474,11 @@ impl OffersMessageFlow { let secp_ctx = &self.secp_ctx; let expanded_key = &self.inbound_payment_key; - let nonce = match context { - None if invoice_request.metadata().is_some() => None, - Some(OffersContext::InvoiceRequest { nonce, .. }) => Some(nonce), + let (nonce, context_data) = match context { + None if invoice_request.metadata().is_some() => (None, None), + Some(OffersContext::InvoiceRequest { nonce, context_data }) => { + (Some(nonce), context_data) + }, Some(OffersContext::StaticInvoiceRequested { recipient_id, invoice_slot, @@ -497,7 +505,7 @@ impl OffersMessageFlow { None => invoice_request.verify_using_metadata(expanded_key, secp_ctx), }?; - Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) + Ok(InvreqResponseInstructions::SendInvoice { invoice_request, context_data }) } /// Verifies a [`Bolt12Invoice`] using the provided [`OffersContext`] or the invoice's payer From 69b396234a6a2975f5a52878a0831ec676b377ae Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 31 Mar 2026 11:10:42 +0200 Subject: [PATCH 20/25] DRAFT: Add invoice builder methods with pre-built payment paths Add `create_invoice_builder_with_custom_payment_paths_derived_psk` and `create_invoice_builder_with_custom_payment_paths_explicit_psk` to `OffersMessageFlow`. These accept pre-built `BlindedPaymentPath`s instead of constructing them via a `Router`, enabling external code (e.g., LSP integration) to build payment paths directly. Co-Authored-By: HAL 9000 --- lightning/src/offers/flow.rs | 91 ++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 69c3d7ac178..09e9a6c57b1 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1092,6 +1092,97 @@ impl OffersMessageFlow { Ok((builder, context)) } + /// Creates an [`InvoiceBuilder`] for the + /// provided [`VerifiedInvoiceRequest`] using pre-built payment paths. + /// + /// This is similar to [`Self::create_invoice_builder_from_invoice_request_with_keys`] but + /// accepts pre-built [`BlindedPaymentPath`]s instead of constructing them via a [`Router`]. + /// This is useful when payment paths need to be constructed externally, e.g., to route + /// through an LSP for JIT channel opens. + /// + /// Returns the invoice builder along with a [`MessageContext`] that can + /// later be used to respond to the counterparty. + /// + /// # Errors + /// + /// Returns a [`Bolt12SemanticError`] if the [`InvoiceBuilder`] could not be created from the + /// [`InvoiceRequest`]. + pub fn create_invoice_builder_with_custom_payment_paths_derived_psk<'a, F>( + &self, invoice_request: &'a VerifiedInvoiceRequest, + payment_paths: Vec, get_payment_info: F, + ) -> Result<(InvoiceBuilder<'a, DerivedSigningPubkey>, MessageContext), Bolt12SemanticError> + where + F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, + { + let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; + + let amount_msats = + InvoiceBuilder::::amount_msats(&invoice_request.inner)?; + + let (_payment_hash, _payment_secret) = get_payment_info(amount_msats, relative_expiry)?; + + #[cfg(feature = "std")] + let builder = invoice_request.respond_using_derived_keys(payment_paths, _payment_hash); + #[cfg(not(feature = "std"))] + let builder = invoice_request.respond_using_derived_keys_no_std( + payment_paths, + _payment_hash, + Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), + ); + let builder = builder.map(|b| InvoiceBuilder::from(b).allow_mpp())?; + + let context = + MessageContext::Offers(OffersContext::InboundPayment { payment_hash: _payment_hash }); + + Ok((builder, context)) + } + + /// Creates an [`InvoiceBuilder`] for the + /// provided [`VerifiedInvoiceRequest`] using pre-built payment paths. + /// + /// This is similar to [`Self::create_invoice_builder_from_invoice_request_without_keys`] but + /// accepts pre-built [`BlindedPaymentPath`]s instead of constructing them via a [`Router`]. + /// This is useful when payment paths need to be constructed externally, e.g., to route + /// through an LSP for JIT channel opens. + /// + /// Returns the invoice builder along with a [`MessageContext`] that can + /// later be used to respond to the counterparty. + /// + /// # Errors + /// + /// Returns a [`Bolt12SemanticError`] if the [`InvoiceBuilder`] could not be created from the + /// [`InvoiceRequest`]. + pub fn create_invoice_builder_with_custom_payment_paths_explicit_psk<'a, F>( + &self, invoice_request: &'a VerifiedInvoiceRequest, + payment_paths: Vec, get_payment_info: F, + ) -> Result<(InvoiceBuilder<'a, ExplicitSigningPubkey>, MessageContext), Bolt12SemanticError> + where + F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, + { + let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; + + let amount_msats = + InvoiceBuilder::::amount_msats(&invoice_request.inner)?; + + let (_payment_hash, _payment_secret) = get_payment_info(amount_msats, relative_expiry)?; + + #[cfg(feature = "std")] + let builder = invoice_request.respond_with(payment_paths, _payment_hash); + #[cfg(not(feature = "std"))] + let builder = invoice_request.respond_with_no_std( + payment_paths, + _payment_hash, + Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), + ); + + let builder = builder.map(|b| InvoiceBuilder::from(b).allow_mpp())?; + + let context = + MessageContext::Offers(OffersContext::InboundPayment { payment_hash: _payment_hash }); + + Ok((builder, context)) + } + /// Enqueues the created [`InvoiceRequest`] to be sent to the counterparty. /// /// # Payment From 81a0ab74bd20c625ed819652af5d10e4559dc233 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 31 Mar 2026 11:13:37 +0200 Subject: [PATCH 21/25] DRAFT: Add `Event::InvoiceRequestReceived` for externally-handled invoices When an InvoiceRequest arrives with `context_data` in its OffersContext, emit an `Event::InvoiceRequestReceived` instead of auto-building the invoice. This allows external code (e.g., LSP integration) to build custom payment paths and create the invoice response using `OffersMessageFlow::create_invoice_builder_with_custom_payment_paths_*`. Co-Authored-By: HAL 9000 --- lightning/src/events/mod.rs | 30 ++++++++++++++++++++++++- lightning/src/ln/channelmanager.rs | 9 +++++++- lightning/src/offers/invoice.rs | 2 ++ lightning/src/offers/invoice_request.rs | 9 ++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 69d709a0e4a..d2da082f564 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -30,7 +30,7 @@ use crate::ln::onion_utils::LocalHTLCFailureReason; use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::types::ChannelId; use crate::offers::invoice::Bolt12Invoice; -use crate::offers::invoice_request::InvoiceRequest; +use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromOffer}; use crate::offers::static_invoice::StaticInvoice; use crate::onion_message::messenger::Responder; use crate::routing::gossip::NetworkUpdate; @@ -1835,6 +1835,28 @@ pub enum Event { /// [`ChannelManager::respond_to_static_invoice_request`]: crate::ln::channelmanager::ChannelManager::respond_to_static_invoice_request invoice_request: InvoiceRequest, }, + /// An [`InvoiceRequest`] was received for an [`Offer`] that had application-specific + /// context data embedded in its blinded message path. + /// + /// The handler should use the `context_data` to determine how to build the invoice response + /// (e.g., constructing custom blinded payment paths for JIT channel opens via an LSP), + /// then use [`OffersMessageFlow::create_invoice_builder_with_custom_payment_paths_derived_psk`] + /// or [`OffersMessageFlow::create_invoice_builder_with_custom_payment_paths_explicit_psk`] + /// to build the invoice, and send the response via the `responder`. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`Offer`]: crate::offers::offer::Offer + /// [`OffersMessageFlow::create_invoice_builder_with_custom_payment_paths_derived_psk`]: crate::offers::flow::OffersMessageFlow::create_invoice_builder_with_custom_payment_paths_derived_psk + /// [`OffersMessageFlow::create_invoice_builder_with_custom_payment_paths_explicit_psk`]: crate::offers::flow::OffersMessageFlow::create_invoice_builder_with_custom_payment_paths_explicit_psk + InvoiceRequestReceived { + /// The verified invoice request to respond to. + invoice_request: InvoiceRequestVerifiedFromOffer, + /// The opaque context data that was embedded in the offer's blinded message path + /// when the offer was created. + context_data: Vec, + /// The responder to use when sending the invoice back to the payer. + responder: Responder, + }, /// Indicates that a channel funding transaction constructed interactively is ready to be /// signed. This event will only be triggered if at least one input was contributed. /// @@ -2366,6 +2388,10 @@ impl Writeable for Event { 47u8.write(writer)?; // Never write StaticInvoiceRequested events as buffered onion messages aren't serialized. }, + &Event::InvoiceRequestReceived { .. } => { + 48u8.write(writer)?; + // Never write InvoiceRequestReceived events as buffered onion messages aren't serialized. + }, &Event::FundingTransactionReadyForSigning { .. } => { 49u8.write(writer)?; // We never write out FundingTransactionReadyForSigning events as they will be regenerated when @@ -3025,6 +3051,8 @@ impl MaybeReadable for Event { 45u8 => Ok(None), // Note that we do not write a length-prefixed TLV for StaticInvoiceRequested events. 47u8 => Ok(None), + // Note that we do not write a length-prefixed TLV for InvoiceRequestReceived events. + 48u8 => Ok(None), // Note that we do not write a length-prefixed TLV for FundingTransactionReadyForSigning events. 49u8 => Ok(None), 50u8 => { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index a52f5002578..8a11d407482 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -16969,7 +16969,14 @@ impl< }; let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { - Ok(InvreqResponseInstructions::SendInvoice { invoice_request, context_data: _ }) => invoice_request, + Ok(InvreqResponseInstructions::SendInvoice { invoice_request, context_data: Some(context_data) }) => { + self.pending_events.lock().unwrap().push_back((Event::InvoiceRequestReceived { + invoice_request, context_data, responder, + }, None)); + + return None + }, + Ok(InvreqResponseInstructions::SendInvoice { invoice_request, context_data: None }) => invoice_request, Ok(InvreqResponseInstructions::SendStaticInvoice { recipient_id, invoice_slot, invoice_request }) => { self.pending_events.lock().unwrap().push_back((Event::StaticInvoiceRequested { recipient_id, invoice_slot, reply_path: responder, invoice_request, diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..cbbd4e495e6 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -228,11 +228,13 @@ pub trait SigningPubkeyStrategy {} /// [`Bolt12Invoice::signing_pubkey`] was explicitly set. /// /// This is not exported to bindings users as builder patterns don't map outside of move semantics. +#[derive(Clone, Debug)] pub struct ExplicitSigningPubkey {} /// [`Bolt12Invoice::signing_pubkey`] was derived. /// /// This is not exported to bindings users as builder patterns don't map outside of move semantics. +#[derive(Clone, Debug)] pub struct DerivedSigningPubkey(pub(super) Keypair); impl SigningPubkeyStrategy for ExplicitSigningPubkey {} diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 7805882ef73..f04429bf43a 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -638,6 +638,7 @@ pub struct VerifiedInvoiceRequest { /// [`InvoiceRequest::verify_using_metadata`] and [`InvoiceRequest::verify_using_recipient_data`]. /// /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice +#[derive(Clone, Debug)] pub enum InvoiceRequestVerifiedFromOffer { /// A verified invoice request that uses signing keys derived from the originating [`Offer`]’s metadata or recipient_data. DerivedKeys(VerifiedInvoiceRequest), @@ -647,6 +648,14 @@ pub enum InvoiceRequestVerifiedFromOffer { ExplicitKeys(VerifiedInvoiceRequest), } +impl PartialEq for InvoiceRequestVerifiedFromOffer { + fn eq(&self, other: &Self) -> bool { + self.inner().eq(other.inner()) + } +} + +impl Eq for InvoiceRequestVerifiedFromOffer {} + impl InvoiceRequestVerifiedFromOffer { /// Returns a reference to the underlying `InvoiceRequest`. pub(crate) fn inner(&self) -> &InvoiceRequest { From a6610acab7e1d18841c89dab5a6907ea1d98850d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 31 Mar 2026 12:32:11 +0200 Subject: [PATCH 22/25] f - Use odd event discriminant for InvoiceRequestReceived Use 53u8 (odd) instead of 48u8 (even) so that old readers can gracefully skip the unknown event type. Co-Authored-By: HAL 9000 --- lightning/src/events/mod.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index d2da082f564..23e96f74d64 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -2388,10 +2388,6 @@ impl Writeable for Event { 47u8.write(writer)?; // Never write StaticInvoiceRequested events as buffered onion messages aren't serialized. }, - &Event::InvoiceRequestReceived { .. } => { - 48u8.write(writer)?; - // Never write InvoiceRequestReceived events as buffered onion messages aren't serialized. - }, &Event::FundingTransactionReadyForSigning { .. } => { 49u8.write(writer)?; // We never write out FundingTransactionReadyForSigning events as they will be regenerated when @@ -2431,6 +2427,10 @@ impl Writeable for Event { (9, abandoned_funding_txo, option), }); }, + &Event::InvoiceRequestReceived { .. } => { + 53u8.write(writer)?; + // Never write InvoiceRequestReceived events as buffered onion messages aren't serialized. + }, // Note that, going forward, all new events must only write data inside of // `write_tlv_fields`. Versions 0.0.101+ will ignore odd-numbered events that write // data via `write_tlv_fields`. @@ -3051,8 +3051,6 @@ impl MaybeReadable for Event { 45u8 => Ok(None), // Note that we do not write a length-prefixed TLV for StaticInvoiceRequested events. 47u8 => Ok(None), - // Note that we do not write a length-prefixed TLV for InvoiceRequestReceived events. - 48u8 => Ok(None), // Note that we do not write a length-prefixed TLV for FundingTransactionReadyForSigning events. 49u8 => Ok(None), 50u8 => { @@ -3097,6 +3095,8 @@ impl MaybeReadable for Event { }; f() }, + // Note that we do not write a length-prefixed TLV for InvoiceRequestReceived events. + 53u8 => Ok(None), // Versions prior to 0.0.100 did not ignore odd types, instead returning InvalidValue. // Version 0.0.100 failed to properly ignore odd types, possibly resulting in corrupt // reads. From 5fac712068915c8434a9daa4b89f7f6400dd4d0c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 31 Mar 2026 11:14:09 +0200 Subject: [PATCH 23/25] DRAFT: Add offer builder variant with embedded context data Add `create_offer_builder_with_context_data` to `OffersMessageFlow`, which embeds opaque bytes in the offer's `OffersContext::InvoiceRequest`. When an `InvoiceRequest` arrives for such an offer, `ChannelManager` emits `Event::InvoiceRequestReceived` instead of auto-responding, allowing external code to construct custom payment paths. Co-Authored-By: HAL 9000 --- lightning/src/offers/flow.rs | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 09e9a6c57b1..efaac7ed390 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -569,7 +569,7 @@ impl OffersMessageFlow { } fn create_offer_builder_intern( - &self, entropy_source: ES, make_paths: PF, + &self, entropy_source: ES, context_data: Option>, make_paths: PF, ) -> Result<(OfferBuilder<'_, DerivedMetadata, secp256k1::All>, Nonce), Bolt12SemanticError> where PF: FnOnce( @@ -585,8 +585,7 @@ impl OffersMessageFlow { let secp_ctx = &self.secp_ctx; let nonce = Nonce::from_entropy_source(entropy); - let context = - MessageContext::Offers(OffersContext::InvoiceRequest { nonce, context_data: None }); + let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce, context_data }); let mut builder = OfferBuilder::deriving_signing_pubkey(node_id, expanded_key, nonce, secp_ctx) @@ -627,7 +626,7 @@ impl OffersMessageFlow { pub fn create_offer_builder( &self, entropy_source: ES, peers: Vec, ) -> Result, Bolt12SemanticError> { - self.create_offer_builder_intern(&entropy_source, |_, context, _| { + self.create_offer_builder_intern(&entropy_source, None, |_, context, _| { self.create_blinded_paths(peers, context) .map(|paths| paths.into_iter().take(1)) .map_err(|_| Bolt12SemanticError::MissingPaths) @@ -648,7 +647,7 @@ impl OffersMessageFlow { &self, router: ME, entropy_source: ES, peers: Vec, ) -> Result, Bolt12SemanticError> { let receive_key = self.get_receive_auth_key(); - self.create_offer_builder_intern(&entropy_source, |node_id, context, secp_ctx| { + self.create_offer_builder_intern(&entropy_source, None, |node_id, context, secp_ctx| { router .create_blinded_paths(node_id, receive_key, context, peers, secp_ctx) .map(|paths| paths.into_iter().take(1)) @@ -657,6 +656,28 @@ impl OffersMessageFlow { .map(|(builder, _)| builder) } + /// Same as [`Self::create_offer_builder`], but embeds opaque `context_data` in the offer's + /// blinded message path. When an [`InvoiceRequest`] is received for this offer, the + /// `context_data` will be surfaced via [`Event::InvoiceRequestReceived`] instead of + /// auto-generating an invoice, allowing external code to build custom payment paths. + /// + /// See [`Self::create_offer_builder`] for more details on privacy and limitations. + /// + /// This is not exported to bindings users as builder patterns don't map outside of move semantics. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`Event::InvoiceRequestReceived`]: crate::events::Event::InvoiceRequestReceived + pub fn create_offer_builder_with_context_data( + &self, entropy_source: ES, peers: Vec, context_data: Vec, + ) -> Result, Bolt12SemanticError> { + self.create_offer_builder_intern(&entropy_source, Some(context_data), |_, context, _| { + self.create_blinded_paths(peers, context) + .map(|paths| paths.into_iter().take(1)) + .map_err(|_| Bolt12SemanticError::MissingPaths) + }) + .map(|(builder, _)| builder) + } + /// Create an offer for receiving async payments as an often-offline recipient. /// /// Because we may be offline when the payer attempts to request an invoice, you MUST: @@ -670,7 +691,7 @@ impl OffersMessageFlow { pub fn create_async_receive_offer_builder( &self, entropy_source: ES, message_paths_to_always_online_node: Vec, ) -> Result<(OfferBuilder<'_, DerivedMetadata, secp256k1::All>, Nonce), Bolt12SemanticError> { - self.create_offer_builder_intern(&entropy_source, |_, _, _| { + self.create_offer_builder_intern(&entropy_source, None, |_, _, _| { Ok(message_paths_to_always_online_node) }) } @@ -687,7 +708,7 @@ impl OffersMessageFlow { &self, entropy_source: ES, per_node_peers: Vec<(PublicKey, Vec)>, path_count_limit: usize, ) -> Result, Bolt12SemanticError> { - self.create_offer_builder_intern(entropy_source, |_, context, _| { + self.create_offer_builder_intern(entropy_source, None, |_, context, _| { self.blinded_paths_for_phantom_offer(per_node_peers, path_count_limit, context) .map_err(|_| Bolt12SemanticError::MissingPaths) }) From 18c31708b52c09fc3dd3c22adad1a50c95192fb0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 31 Mar 2026 11:17:18 +0200 Subject: [PATCH 24/25] DRAFT: Replace `LSPS2BOLT12Router` wrapper with helper functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the Router/MessageRouter wrapper pattern in favor of: - `LSPS2Bolt12InvoiceParameters::to_context_data()` / `from_context_data()` for serializing params to/from opaque bytes embedded in the offer's MessageContext. - `build_lsps2_payment_paths()` standalone function for constructing blinded payment paths with the LSP as introduction node. This eliminates the need for client-side HashMap storage and makes the LSPS2 BOLT12 flow stateless — the offer itself carries everything needed to build JIT channel payment paths. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/event.rs | 16 +- lightning-liquidity/src/lsps2/router.rs | 575 ++++++------------ .../tests/lsps2_integration_tests.rs | 139 ++--- 3 files changed, 229 insertions(+), 501 deletions(-) diff --git a/lightning-liquidity/src/lsps2/event.rs b/lightning-liquidity/src/lsps2/event.rs index 9ca20863387..af3d301cd65 100644 --- a/lightning-liquidity/src/lsps2/event.rs +++ b/lightning-liquidity/src/lsps2/event.rs @@ -52,14 +52,20 @@ pub enum LSPS2ClientEvent { /// 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. + /// For BOLT12 JIT flows, use [`LSPS2Bolt12InvoiceParameters::to_context_data`] to serialize + /// these parameters and pass them to + /// [`OffersMessageFlow::create_offer_builder_with_context_data`] when creating the offer. When + /// an [`InvoiceRequest`] is received, the parameters will be surfaced via + /// [`Event::InvoiceRequestReceived`], where they can be deserialized and used to build payment + /// paths via [`build_lsps2_payment_paths`]. /// /// **Note: ** This event will *not* be persisted across restarts. /// - /// [`LSPS2BOLT12Router`]: crate::lsps2::router::LSPS2BOLT12Router + /// [`LSPS2Bolt12InvoiceParameters::to_context_data`]: crate::lsps2::router::LSPS2Bolt12InvoiceParameters::to_context_data + /// [`OffersMessageFlow::create_offer_builder_with_context_data`]: lightning::offers::flow::OffersMessageFlow::create_offer_builder_with_context_data + /// [`InvoiceRequest`]: lightning::offers::invoice_request::InvoiceRequest + /// [`Event::InvoiceRequestReceived`]: lightning::events::Event::InvoiceRequestReceived + /// [`build_lsps2_payment_paths`]: crate::lsps2::router::build_lsps2_payment_paths InvoiceParametersReady { /// The identifier of the issued bLIP-52 / LSPS2 `buy` request, as returned by /// [`LSPS2ClientHandler::select_opening_params`]. diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index c619fc5a8b5..f2037a4640f 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -7,32 +7,53 @@ // 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. +//! Helpers for combining LSPS2 with BOLT12 offer flows. +//! +//! Provides [`LSPS2Bolt12InvoiceParameters`] for embedding LSPS2 channel parameters into a +//! BOLT12 offer's [`MessageContext`], and [`build_lsps2_payment_paths`] for constructing blinded +//! payment paths that route through the LSP for JIT channel opens. +//! +//! # Usage +//! +//! 1. Create an offer with [`OffersMessageFlow::create_offer_builder_with_context_data`], +//! passing serialized [`LSPS2Bolt12InvoiceParameters`] as the context data. +//! 2. When an [`InvoiceRequest`] arrives, [`Event::InvoiceRequestReceived`] is emitted with the +//! context data. Deserialize the parameters and call [`build_lsps2_payment_paths`] to construct +//! the blinded payment paths. +//! 3. Use [`OffersMessageFlow::create_invoice_builder_with_custom_payment_paths_derived_psk`] (or +//! the `_explicit_psk` variant) with the constructed paths to build and send the invoice. +//! +//! [`MessageContext`]: lightning::blinded_path::message::MessageContext +//! [`InvoiceRequest`]: lightning::offers::invoice_request::InvoiceRequest +//! [`OffersMessageFlow::create_offer_builder_with_context_data`]: lightning::offers::flow::OffersMessageFlow::create_offer_builder_with_context_data +//! [`OffersMessageFlow::create_invoice_builder_with_custom_payment_paths_derived_psk`]: lightning::offers::flow::OffersMessageFlow::create_invoice_builder_with_custom_payment_paths_derived_psk +//! [`Event::InvoiceRequestReceived`]: lightning::events::Event::InvoiceRequestReceived 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, + BlindedPaymentPath, ForwardTlvs, PaymentConstraints, 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::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning::sign::{EntropySource, ReceiveAuthKey}; use lightning::types::features::BlindedHopFeatures; -use lightning::types::payment::PaymentHash; +use lightning::util::ser::{Readable, Writeable, Writer}; /// LSPS2 invoice parameters required to construct BOLT12 blinded payment paths through an LSP. +/// +/// These parameters are serialized to opaque bytes and embedded in the offer's +/// [`MessageContext`] via [`OffersMessageFlow::create_offer_builder_with_context_data`]. +/// When an [`InvoiceRequest`] is received, the parameters are deserialized from +/// [`Event::InvoiceRequestReceived::context_data`] and used to build payment paths via +/// [`build_lsps2_payment_paths`]. +/// +/// [`MessageContext`]: lightning::blinded_path::message::MessageContext +/// [`InvoiceRequest`]: lightning::offers::invoice_request::InvoiceRequest +/// [`OffersMessageFlow::create_offer_builder_with_context_data`]: lightning::offers::flow::OffersMessageFlow::create_offer_builder_with_context_data +/// [`Event::InvoiceRequestReceived`]: lightning::events::Event::InvoiceRequestReceived #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct LSPS2Bolt12InvoiceParameters { /// The LSP node id to use as the blinded path introduction node. @@ -43,299 +64,130 @@ pub struct LSPS2Bolt12InvoiceParameters { pub cltv_expiry_delta: u32, } -/// A router wrapper that injects LSPS2-specific BOLT12 blinded paths for registered offer ids -/// while delegating all other blinded path creation behaviors to the inner routers. -/// -/// For **payment** blinded paths (in invoices), it returns 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`] so that [`Event::HTLCIntercepted`] is emitted when the -/// HTLC arrives, prompting the LSP to open the channel just-in-time. -/// -/// The LSP must use an [`OnionMessenger`] that is setup via -/// [`OnionMessenger::new_with_offline_peer_interception`] so that forwarded messages are -/// intercepted rather than dropped. -/// -/// [`OnionMessenger`]: lightning::onion_message::messenger::OnionMessenger -/// [`OnionMessenger::new_with_offline_peer_interception`]: lightning::onion_message::messenger::OnionMessenger::new_with_offline_peer_interception -/// [`Event::HTLCIntercepted`]: lightning::events::Event::HTLCIntercepted -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, 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) +impl LSPS2Bolt12InvoiceParameters { + /// Serializes these parameters to opaque bytes for embedding in an offer's context data. + pub fn to_context_data(&self) -> Vec { + self.encode() } - /// 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).copied() + /// Deserializes parameters from opaque context data bytes. + /// + /// Returns `None` if the data cannot be parsed. + pub fn from_context_data(data: &[u8]) -> Option { + let mut cursor = lightning::io::Cursor::new(data); + Self::read(&mut cursor).ok() } } -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) +impl Writeable for LSPS2Bolt12InvoiceParameters { + fn write(&self, writer: &mut W) -> Result<(), lightning::io::Error> { + self.counterparty_node_id.write(writer)?; + self.intercept_scid.write(writer)?; + self.cltv_expiry_delta.write(writer)?; + Ok(()) } +} - 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, ()> { - // Override with intercept SCIDs to have the payer use them when sending payments, - // prompting the LSP node to emit Event::HTLCIntercepted, hence triggering channel open. - 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 Readable for LSPS2Bolt12InvoiceParameters { + fn read( + reader: &mut R, + ) -> Result { + let counterparty_node_id = PublicKey::read(reader)?; + let intercept_scid = u64::read(reader)?; + let cltv_expiry_delta = u32::read(reader)?; + Ok(Self { counterparty_node_id, intercept_scid, cltv_expiry_delta }) } } -impl MessageRouter - for LSPS2BOLT12Router -{ - fn find_path( - &self, sender: PublicKey, peers: Vec, destination: Destination, - ) -> Result { - self.inner_message_router.find_path(sender, peers, destination) - } +/// Builds LSPS2 blinded payment paths using the given parameters. +/// +/// Constructs a single-hop [`BlindedPaymentPath`] with the LSP as the introduction node and the +/// intercept SCID as the forwarding hop, so that the LSP can intercept the HTLC and open a JIT +/// channel. +/// +/// # Errors +/// +/// Returns `Err(())` if the `cltv_expiry_delta` exceeds `u16::MAX` or if the blinded path +/// cannot be constructed. +pub fn build_lsps2_payment_paths< + ES: EntropySource, + T: secp256k1::Signing + secp256k1::Verification, +>( + params: &LSPS2Bolt12InvoiceParameters, recipient: PublicKey, + local_node_receive_key: ReceiveAuthKey, tlvs: ReceiveTlvs, entropy_source: &ES, + secp_ctx: &Secp256k1, +) -> Result, ()> { + let payment_relay = PaymentRelay { + cltv_expiry_delta: u16::try_from(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(params.cltv_expiry_delta), + htlc_minimum_msat: 0, + }; - fn create_blinded_paths( - &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, - context: MessageContext, mut peers: Vec, secp_ctx: &Secp256k1, - ) -> Result, ()> { - // Override with intercept SCIDs to have the payer use them when sending invoice requests, - // prompting the LSP node to emit Event::OnionMessageIntercepted, allowing it to then use - // the Router implementation above to also override the blinded payment paths with the - // intercept SCID, hence triggering channel open. - if matches!(&context, MessageContext::Offers(OffersContext::InvoiceRequest { .. })) { - let params = self.offer_to_invoice_params.lock().unwrap(); - for peer in &mut peers { - if let Some(p) = params.values().find(|p| p.counterparty_node_id == peer.node_id) { - peer.short_channel_id = Some(p.intercept_scid); - } - } - } + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: params.intercept_scid, + payment_relay, + payment_constraints, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: params.counterparty_node_id, + htlc_maximum_msat: u64::MAX, + }; - self.inner_message_router.create_blinded_paths( - recipient, - local_node_receive_key, - context, - peers, - secp_ctx, - ) - } + // 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, + entropy_source, + secp_ctx, + )?; + + Ok(vec![path]) } #[cfg(test)] mod tests { - use super::{LSPS2BOLT12Router, LSPS2Bolt12InvoiceParameters}; + use super::{build_lsps2_payment_paths, LSPS2Bolt12InvoiceParameters}; use bitcoin::network::Network; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use lightning::blinded_path::payment::{ - Bolt12OfferContext, Bolt12RefundContext, PaymentConstraints, PaymentContext, ReceiveTlvs, + Bolt12OfferContext, 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::sign::{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 { + impl lightning::sign::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) @@ -357,21 +209,39 @@ mod tests { } } - 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 {}), + 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) } } #[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); + fn serialization_round_trip() { + let params = LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(2), + intercept_scid: 42, + cltv_expiry_delta: 48, + }; + + let data = params.to_context_data(); + let deserialized = LSPS2Bolt12InvoiceParameters::from_context_data(&data).unwrap(); + assert_eq!(params, deserialized); + } - let offer_id = OfferId([8; 32]); + #[test] + fn from_context_data_rejects_invalid_data() { + assert!(LSPS2Bolt12InvoiceParameters::from_context_data(&[]).is_none()); + assert!(LSPS2Bolt12InvoiceParameters::from_context_data(&[1, 2, 3]).is_none()); + } + + #[test] + fn builds_lsps2_blinded_path() { let lsp_keys = TestKeysInterface::new(&[43; 32], Network::Testnet); let lsp_node_id = lsp_keys.get_node_id(Recipient::Node).unwrap(); @@ -379,26 +249,23 @@ mod tests { 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 params = 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(); + let entropy_source = TestEntropy; + let mut paths = build_lsps2_payment_paths( + ¶ms, + recipient, + ReceiveAuthKey([3; 32]), + bolt12_offer_tlvs(OfferId([8; 32])), + &entropy_source, + &secp_ctx, + ) + .unwrap(); assert_eq!(paths.len(), 1); let mut path = paths.pop().unwrap(); @@ -419,119 +286,25 @@ mod tests { 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 params = 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( + let entropy_source = TestEntropy; + let result = build_lsps2_payment_paths( + ¶ms, pubkey(13), ReceiveAuthKey([3; 32]), - Vec::new(), - bolt12_offer_tlvs(offer_id), - Some(1_000), + bolt12_offer_tlvs(OfferId([11; 32])), + &entropy_source, &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); - } } diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index c92a11ef7b9..993465268a0 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -19,8 +19,6 @@ use lightning::ln::msgs::OnionMessageHandler; use lightning::ln::types::ChannelId; use lightning::offers::invoice_request::InvoiceRequestFields; use lightning::offers::offer::OfferId; -use lightning::onion_message::messenger::NullMessageRouter; -use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; use lightning::sign::{RandomBytes, ReceiveAuthKey}; use lightning_liquidity::events::LiquidityEvent; @@ -29,7 +27,7 @@ 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::router::{build_lsps2_payment_paths, LSPS2Bolt12InvoiceParameters}; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; use lightning_liquidity::lsps2::utils::is_valid_opening_fee_params; use lightning_liquidity::utils::time::{DefaultTimeProvider, TimeProvider}; @@ -80,34 +78,6 @@ impl NodeIdLookUp for RecordingLookup { } } -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 }; @@ -1552,22 +1522,13 @@ fn bolt12_custom_router_uses_lsps2_intercept_scid() { 1_000, ); - let inner_router = FailingRouter::new(); - let router = LSPS2BOLT12Router::new( - inner_router, - NullMessageRouter {}, - 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 params = LSPS2Bolt12InvoiceParameters { + counterparty_node_id: service_node_id, + intercept_scid, + cltv_expiry_delta, + }; let tlvs = ReceiveTlvs { payment_secret: lightning_types::payment::PaymentSecret([7; 32]), @@ -1584,16 +1545,16 @@ fn bolt12_custom_router_uses_lsps2_intercept_scid() { }; 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(); + let entropy_source = lsps_nodes.client_node.keys_manager; + let mut paths = build_lsps2_payment_paths( + ¶ms, + client_node_id, + ReceiveAuthKey([3; 32]), + tlvs, + entropy_source, + &secp_ctx, + ) + .unwrap(); assert_eq!(paths.len(), 1); let mut path = paths.pop().unwrap(); @@ -1680,33 +1641,27 @@ fn bolt12_lsps2_end_to_end_test() { .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_params = 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 entropy = Arc::new(RandomBytes::new([43; 32])); + let entropy_clone = Arc::clone(&entropy); + *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( + build_lsps2_payment_paths( + &lsps2_params, recipient, local_node_receive_key, - first_hops, tlvs, - amount_msats, + &*entropy_clone, &secp_ctx, ) - })); + }, + )); let payment_id = PaymentId([1; 32]); payer_node.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); @@ -1962,33 +1917,27 @@ fn bolt12_lsps2_compact_message_path_test() { .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_params = 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 entropy = Arc::new(RandomBytes::new([43; 32])); + let entropy_clone = Arc::clone(&entropy); + *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( + build_lsps2_payment_paths( + &lsps2_params, recipient, local_node_receive_key, - first_hops, tlvs, - amount_msats, + &*entropy_clone, &secp_ctx, ) - })); + }, + )); // Payer sends InvoiceRequest toward the service node. let payment_id = PaymentId([1; 32]); From 3ada94e965accf628390ebe0cb3d0e76287e7f8b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 31 Mar 2026 11:18:09 +0200 Subject: [PATCH 25/25] DRAFT: Add async payments (AsyncBolt12Offer) test for LSPS2 paths Verify that `build_lsps2_payment_paths` works with `PaymentContext::AsyncBolt12Offer`, which was previously unsupported by the old `LSPS2BOLT12Router` wrapper approach. With the new context-based design, LSPS2 payment path construction is agnostic to the payment context type, naturally supporting both sync and async BOLT12 flows. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/router.rs | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index f2037a4640f..20ea63687b9 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -286,6 +286,67 @@ mod tests { assert_eq!(*lookup.short_channel_id.lock().unwrap(), Some(expected_scid)); } + #[test] + fn builds_lsps2_blinded_path_for_async_offer() { + // Verify that build_lsps2_payment_paths works with AsyncBolt12Offer payment context, + // which was previously unsupported by the old LSPS2BOLT12Router wrapper. + use lightning::blinded_path::payment::AsyncBolt12OfferContext; + use lightning::offers::nonce::Nonce; + use lightning::sign::EntropySource; + + 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); + + let params = LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id, + intercept_scid: expected_scid, + cltv_expiry_delta: expected_cltv_delta, + }; + + let offer_nonce = Nonce::from_entropy_source(&TestEntropy); + let async_tlvs = ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext { + offer_nonce, + }), + }; + + let secp_ctx = Secp256k1::new(); + let entropy_source = TestEntropy; + let mut paths = build_lsps2_payment_paths( + ¶ms, + recipient, + ReceiveAuthKey([3; 32]), + async_tlvs, + &entropy_source, + &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 rejects_out_of_range_cltv_delta() { let params = LSPS2Bolt12InvoiceParameters {