From b4508d91a428afca24adfe2335587839d70ae233 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 01/13] 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 --- lightning/src/blinded_path/message.rs | 5 ++ lightning/src/events/mod.rs | 58 ++++++++++++++----- .../src/onion_message/functional_tests.rs | 12 ++-- lightning/src/onion_message/messenger.rs | 24 ++++++-- 4 files changed, 73 insertions(+), 26 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..69d709a0e4a 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, }; @@ -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 @@ -1721,9 +1723,10 @@ 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 onion message intended to be forwarded to `peer_node_id`. + /// The next hop (offline peer or unknown SCID). + next_hop: NextMessageHop, + /// 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 @@ -2303,12 +2306,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 +2952,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 be set" + ); + Ok(None) + } }; f() }, diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 75e2aaf3c5f..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!(), }; diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index f94eb7877f5..f1cff1719a6 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. @@ -1664,7 +1663,20 @@ impl< NextMessageHop::ShortChannelId(scid) => match self.node_id_lookup.next_node_id(scid) { Some(pubkey) => pubkey, None => { - log_trace!(self.logger, "Dropping forwarded onion messager: unable to resolve next hop using SCID {} {}", scid, log_suffix); + 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 message: unable to resolve next hop using SCID {} {}", scid, log_suffix); return Err(SendError::GetNodeIdFailed); }, }, @@ -1707,7 +1719,7 @@ impl< log_suffix ); self.enqueue_intercepted_event(Event::OnionMessageIntercepted { - peer_node_id: next_node_id, + next_hop, message: onion_message, }); Ok(()) From 44b822d2594c00d416ee9432d3a2392cd6dc3987 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 15:40:45 +0100 Subject: [PATCH 02/13] 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 14b723ebebcebc3c4c2eb0a3031519e03eb3e0ab Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 15:49:58 +0100 Subject: [PATCH 03/13] 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 0472ddd9ffbdd6de0c0c4b0959b8af754b1fa93a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 04/13] 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 | 537 ++++++++++++++++++++++++ lightning/src/offers/offer.rs | 2 +- 3 files changed, 539 insertions(+), 1 deletion(-) 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..c619fc5a8b5 --- /dev/null +++ b/lightning-liquidity/src/lsps2/router.rs @@ -0,0 +1,537 @@ +// 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 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) + } + + /// 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() + } +} + +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, ()> { + // 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 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, 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); + } + } + } + + 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); + } +} 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 9d61ea5ee211550038727075044238ba97526034 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 2 Apr 2026 18:24:37 +0200 Subject: [PATCH 05/13] f - Register LSPS2 parameters by intercept SCID instead of offer id The intercept SCID is the natural key for LSPS2 invoice parameters since it directly identifies the JIT channel negotiation, whereas offer ids are a higher-level concept that may not always be available. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/router.rs | 98 +++++++++++++------------ 1 file changed, 50 insertions(+), 48 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index c619fc5a8b5..3cf744aeee7 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -20,12 +20,11 @@ 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, 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}; @@ -43,8 +42,8 @@ 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. +/// A router wrapper that injects LSPS2-specific BOLT12 blinded paths for registered intercept +/// SCIDs 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. @@ -64,7 +63,7 @@ pub struct LSPS2BOLT12Router>, + scid_to_invoice_params: Mutex>, } impl LSPS2BOLT12Router { @@ -74,25 +73,28 @@ impl LSPS2BOLT12R inner_router, inner_message_router, entropy_source, - offer_to_invoice_params: Mutex::new(new_hash_map()), + scid_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, + /// Registers LSPS2 parameters to be used when generating blinded payment paths for + /// `intercept_scid`. + pub fn register_intercept_scid( + &self, intercept_scid: u64, invoice_params: LSPS2Bolt12InvoiceParameters, ) -> Option { - self.offer_to_invoice_params.lock().unwrap().insert(offer_id, invoice_params) + self.scid_to_invoice_params.lock().unwrap().insert(intercept_scid, 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) + /// Removes any previously registered LSPS2 parameters for `intercept_scid`. + pub fn deregister_intercept_scid( + &self, intercept_scid: u64, + ) -> Option { + self.scid_to_invoice_params.lock().unwrap().remove(&intercept_scid) } - /// Clears all LSPS2 parameters previously registered via [`Self::register_offer`]. - pub fn clear_registered_offers(&self) { - self.offer_to_invoice_params.lock().unwrap().clear(); + /// Clears all LSPS2 parameters previously registered via [`Self::register_intercept_scid`]. + pub fn clear_registered_intercept_scids(&self) { + self.scid_to_invoice_params.lock().unwrap().clear(); } fn registered_lsps2_params( @@ -100,12 +102,12 @@ impl LSPS2BOLT12R ) -> 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, + match payment_context { + PaymentContext::Bolt12Offer(_) => {}, _ => return None, }; - self.offer_to_invoice_params.lock().unwrap().get(offer_id).copied() + self.scid_to_invoice_params.lock().unwrap().values().next().copied() } } @@ -217,7 +219,7 @@ impl MessageRoute // 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(); + let params = self.scid_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); @@ -366,7 +368,7 @@ mod tests { } #[test] - fn creates_lsps2_blinded_path_for_registered_offer() { + fn creates_lsps2_blinded_path_for_registered_intercept_scid() { let inner_router = MockRouter::new(); let entropy_source = TestEntropy; let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, entropy_source); @@ -379,8 +381,8 @@ mod tests { let expected_cltv_delta = 48; let recipient = pubkey(10); - router.register_offer( - offer_id, + router.register_intercept_scid( + expected_scid, LSPS2Bolt12InvoiceParameters { counterparty_node_id: lsp_node_id, intercept_scid: expected_scid, @@ -420,7 +422,7 @@ mod tests { } #[test] - fn delegates_when_offer_is_not_registered() { + fn delegates_when_context_is_not_bolt12_offer() { let inner_router = MockRouter::new(); let entropy_source = TestEntropy; let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, entropy_source); @@ -440,19 +442,19 @@ mod tests { } #[test] - fn delegates_when_offer_id_is_not_registered() { + fn delegates_when_no_intercept_scid_is_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]); + // Use a Bolt12Offer context without any registered intercept SCIDs. + let 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), + bolt12_offer_tlvs(offer_id), Some(10_000), &secp_ctx, ); @@ -467,12 +469,12 @@ mod tests { let entropy_source = TestEntropy; let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, entropy_source); - let offer_id = OfferId([11; 32]); - router.register_offer( - offer_id, + let intercept_scid = 21; + router.register_intercept_scid( + intercept_scid, LSPS2Bolt12InvoiceParameters { counterparty_node_id: pubkey(12), - intercept_scid: 21, + intercept_scid, cltv_expiry_delta: u32::from(u16::MAX) + 1, }, ); @@ -482,7 +484,7 @@ mod tests { pubkey(13), ReceiveAuthKey([3; 32]), Vec::new(), - bolt12_offer_tlvs(offer_id), + bolt12_offer_tlvs(OfferId([11; 32])), Some(1_000), &secp_ctx, ); @@ -491,38 +493,38 @@ mod tests { } #[test] - fn can_unregister_offer() { + fn can_deregister_intercept_scid() { 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 intercept_scid = 7; let params = LSPS2Bolt12InvoiceParameters { counterparty_node_id: pubkey(2), - intercept_scid: 7, + intercept_scid, 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.register_intercept_scid(intercept_scid, params), None); + assert_eq!(router.deregister_intercept_scid(intercept_scid), Some(params)); + assert_eq!(router.deregister_intercept_scid(intercept_scid), None); } #[test] - fn can_clear_registered_offers() { + fn can_clear_registered_intercept_scids() { let inner_router = MockRouter::new(); let entropy_source = TestEntropy; let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, entropy_source); - router.register_offer( - OfferId([1; 32]), + router.register_intercept_scid( + 7, LSPS2Bolt12InvoiceParameters { counterparty_node_id: pubkey(2), intercept_scid: 7, cltv_expiry_delta: 40, }, ); - router.register_offer( - OfferId([2; 32]), + router.register_intercept_scid( + 8, LSPS2Bolt12InvoiceParameters { counterparty_node_id: pubkey(3), intercept_scid: 8, @@ -530,8 +532,8 @@ mod tests { }, ); - router.clear_registered_offers(); - assert_eq!(router.unregister_offer(OfferId([1; 32])), None); - assert_eq!(router.unregister_offer(OfferId([2; 32])), None); + router.clear_registered_intercept_scids(); + assert_eq!(router.deregister_intercept_scid(7), None); + assert_eq!(router.deregister_intercept_scid(8), None); } } From ce20f9a5221afb1a6b876540c5610043505a2d8f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 2 Apr 2026 19:00:05 +0200 Subject: [PATCH 06/13] f - Return blinded paths for all registered intercept SCIDs With multiple concurrent LSPS2 flows, each registration should produce its own blinded payment path so that each JIT channel can be opened independently. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/router.rs | 187 +++++++++++++++++------- 1 file changed, 131 insertions(+), 56 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index 3cf744aeee7..b5292728b21 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -99,15 +99,15 @@ impl LSPS2BOLT12R fn registered_lsps2_params( &self, payment_context: &PaymentContext, - ) -> Option { + ) -> Vec { // We intentionally only match `Bolt12Offer` here and not `AsyncBolt12Offer`, as LSPS2 // JIT channels are not applicable to async (always-online) BOLT12 offer flows. match payment_context { PaymentContext::Bolt12Offer(_) => {}, - _ => return None, + _ => return Vec::new(), }; - self.scid_to_invoice_params.lock().unwrap().values().next().copied() + self.scid_to_invoice_params.lock().unwrap().values().copied().collect() } } @@ -143,61 +143,64 @@ impl Router ) -> 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, - }; + let all_params = self.registered_lsps2_params(&tlvs.payment_context); + if all_params.is_empty() { + return self.inner_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs, + amount_msats, + 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, - &self.entropy_source, - secp_ctx, - )?; + let mut paths = Vec::with_capacity(all_params.len()); + for lsps2_invoice_params in all_params { + 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.clone(), + u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, + &self.entropy_source, + secp_ctx, + )?; + paths.push(path); + } - Ok(vec![path]) + Ok(paths) } } @@ -536,4 +539,76 @@ mod tests { assert_eq!(router.deregister_intercept_scid(7), None); assert_eq!(router.deregister_intercept_scid(8), None); } + + #[test] + fn creates_paths_for_all_registered_intercept_scids() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, entropy_source); + + let lsp_keys_a = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id_a = lsp_keys_a.get_node_id(Recipient::Node).unwrap(); + let scid_a = 100; + + let lsp_keys_b = TestKeysInterface::new(&[44; 32], Network::Testnet); + let lsp_node_id_b = lsp_keys_b.get_node_id(Recipient::Node).unwrap(); + let scid_b = 200; + + router.register_intercept_scid( + scid_a, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id_a, + intercept_scid: scid_a, + cltv_expiry_delta: 48, + }, + ); + router.register_intercept_scid( + scid_b, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id_b, + intercept_scid: scid_b, + cltv_expiry_delta: 72, + }, + ); + + let recipient = pubkey(10); + let secp_ctx = Secp256k1::new(); + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs(OfferId([8; 32])), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + assert_eq!(paths.len(), 2); + + // Verify each path uses a distinct intercept SCID by advancing through the LSP hop. + let mut seen_scids = std::collections::HashSet::new(); + for mut path in paths { + let (keys, node_id) = if path.introduction_node() + == &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id_a) + { + (&lsp_keys_a, lsp_node_id_a) + } else { + (&lsp_keys_b, lsp_node_id_b) + }; + let _ = node_id; + + let lookup = + RecordingLookup { next_node_id: recipient, short_channel_id: Mutex::new(None) }; + path.advance_path_by_one(keys, &lookup, &secp_ctx).unwrap(); + let scid = lookup.short_channel_id.lock().unwrap().unwrap(); + seen_scids.insert(scid); + } + + assert!(seen_scids.contains(&scid_a), "Path for SCID {} missing", scid_a); + assert!(seen_scids.contains(&scid_b), "Path for SCID {} missing", scid_b); + + // Inner router should not have been called. + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 0); + } } From 110ba64ae3770fa5319f4ecb7f8b2f4d7189d259 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 2 Apr 2026 19:09:44 +0200 Subject: [PATCH 07/13] f - Include inner router paths alongside LSPS2 intercept paths When LSPS2 intercept SCIDs are registered, also query the inner router for paths through pre-existing channels. This allows payers to use existing inbound liquidity when available rather than always triggering a JIT channel open. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/router.rs | 121 +++++++++++++++++++++++- 1 file changed, 116 insertions(+), 5 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index b5292728b21..a3212d36e37 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -155,7 +155,21 @@ impl Router ); } - let mut paths = Vec::with_capacity(all_params.len()); + // Also try the inner router for paths through existing channels, so the payer + // can use pre-existing inbound liquidity when available rather than always + // triggering a JIT channel open. + let mut paths = self + .inner_router + .create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs.clone(), + amount_msats, + secp_ctx, + ) + .unwrap_or_default(); + for lsps2_invoice_params in all_params { let payment_relay = PaymentRelay { cltv_expiry_delta: u16::try_from(lsps2_invoice_params.cltv_expiry_delta) @@ -309,11 +323,15 @@ mod tests { struct MockRouter { create_blinded_payment_paths_calls: AtomicUsize, + paths_to_return: Mutex>>, } impl MockRouter { fn new() -> Self { - Self { create_blinded_payment_paths_calls: AtomicUsize::new(0) } + Self { + create_blinded_payment_paths_calls: AtomicUsize::new(0), + paths_to_return: Mutex::new(None), + } } fn create_blinded_payment_paths_calls(&self) -> usize { @@ -337,7 +355,10 @@ mod tests { _secp_ctx: &Secp256k1, ) -> Result, ()> { self.create_blinded_payment_paths_calls.fetch_add(1, Ordering::AcqRel); - Err(()) + match self.paths_to_return.lock().unwrap().take() { + Some(paths) => Ok(paths), + None => Err(()), + } } } @@ -608,7 +629,97 @@ mod tests { assert!(seen_scids.contains(&scid_a), "Path for SCID {} missing", scid_a); assert!(seen_scids.contains(&scid_b), "Path for SCID {} missing", scid_b); - // Inner router should not have been called. - assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 0); + // Inner router is always called to include paths through existing channels. + // It returned Err here, so only the LSPS2 paths are present. + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn includes_inner_router_paths_alongside_lsps2_paths() { + let inner_router = MockRouter::new(); + let lsp_keys = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id = lsp_keys.get_node_id(Recipient::Node).unwrap(); + let recipient = pubkey(10); + let secp_ctx = Secp256k1::new(); + + // Pre-create a blinded path as if the inner router built it from an existing channel. + let existing_tlvs = bolt12_offer_tlvs(OfferId([8; 32])); + let existing_path = lightning::blinded_path::payment::BlindedPaymentPath::new( + &[], + recipient, + ReceiveAuthKey([3; 32]), + existing_tlvs, + u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, + &TestEntropy, + &secp_ctx, + ) + .unwrap(); + *inner_router.paths_to_return.lock().unwrap() = Some(vec![existing_path]); + + let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, TestEntropy); + + let intercept_scid = 42; + router.register_intercept_scid( + intercept_scid, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id, + intercept_scid, + cltv_expiry_delta: 48, + }, + ); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs(OfferId([8; 32])), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + // Should contain both the LSPS2 intercept path and the inner router's existing + // channel path. + assert_eq!(paths.len(), 2); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn lsps2_paths_returned_even_when_inner_router_fails() { + let inner_router = MockRouter::new(); + // paths_to_return is None, so inner router returns Err(()) + let lsp_keys = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id = lsp_keys.get_node_id(Recipient::Node).unwrap(); + let recipient = pubkey(10); + let secp_ctx = Secp256k1::new(); + + let router = LSPS2BOLT12Router::new(inner_router, MockMessageRouter, TestEntropy); + + let intercept_scid = 42; + router.register_intercept_scid( + intercept_scid, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id, + intercept_scid, + cltv_expiry_delta: 48, + }, + ); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs(OfferId([8; 32])), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + // Only the LSPS2 path, since the inner router failed. + assert_eq!(paths.len(), 1); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); } } From 52500de78ca3d8bd373dc9a119ed81d0cfc4915a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 08/13] 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 54d234eeb0b85849771e933ebc58b3f07e4fd9d0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 09/13] 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 a8d568fe9004c5794649ab14fcdae9ab6a7ea796 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 10/13] 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 3541c823d08..c01935e98f6 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 3cfc08e40cbc8d38d67aec9d5909b50a67dfb18a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 11/13] 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 | 467 +++++++++++++++++- 1 file changed, 465 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 23278cf70a0..c92a11ef7b9 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -7,19 +7,21 @@ use common::{ get_lsps_message, LSPSNodes, LSPSNodesWithPayer, LiquidityNode, }; -use lightning::events::{ClosureReason, Event}; +use lightning::blinded_path::message::NextMessageHop; +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_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::LSPSDateTime; @@ -1610,6 +1612,467 @@ 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, + ); + + // 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 { next_hop, message } => { + assert_eq!(next_hop, NextMessageHop::ShortChannelId(intercept_scid)); + 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 718988de4c092eb77908ab38a2d39d4f0c55f8e1 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 2 Apr 2026 18:25:40 +0200 Subject: [PATCH 12/13] f - Update test call sites to use intercept SCID registration Co-Authored-By: HAL 9000 --- .../tests/lsps2_integration_tests.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index c92a11ef7b9..4cca0091b5c 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -1558,10 +1558,8 @@ fn bolt12_custom_router_uses_lsps2_intercept_scid() { NullMessageRouter {}, lsps_nodes.client_node.keys_manager, ); - let offer_id = OfferId([42; 32]); - - router.register_offer( - offer_id, + router.register_intercept_scid( + intercept_scid, LSPS2Bolt12InvoiceParameters { counterparty_node_id: service_node_id, intercept_scid, @@ -1573,7 +1571,7 @@ fn bolt12_custom_router_uses_lsps2_intercept_scid() { 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, + offer_id: OfferId([42; 32]), invoice_request: InvoiceRequestFields { payer_signing_pubkey: lsps_nodes.payer_node.node.get_our_node_id(), quantity: None, @@ -1685,8 +1683,8 @@ fn bolt12_lsps2_end_to_end_test() { NullMessageRouter {}, Arc::new(RandomBytes::new([43; 32])), )); - lsps2_router.register_offer( - offer.id(), + lsps2_router.register_intercept_scid( + intercept_scid, LSPS2Bolt12InvoiceParameters { counterparty_node_id: service_node_id, intercept_scid, @@ -1967,8 +1965,8 @@ fn bolt12_lsps2_compact_message_path_test() { NullMessageRouter {}, Arc::new(RandomBytes::new([43; 32])), )); - lsps2_router.register_offer( - offer.id(), + lsps2_router.register_intercept_scid( + intercept_scid, LSPS2Bolt12InvoiceParameters { counterparty_node_id: service_node_id, intercept_scid, From 71242155f2b90007e850be89daef4db78d8508f0 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 2 Apr 2026 19:43:52 +0200 Subject: [PATCH 13/13] f - Update LSPS2BOLT12Router docs to reflect combined path behavior Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/router.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index a3212d36e37..104f6b994ce 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -45,8 +45,10 @@ pub struct LSPS2Bolt12InvoiceParameters { /// A router wrapper that injects LSPS2-specific BOLT12 blinded paths for registered intercept /// SCIDs 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 **payment** blinded paths (in invoices), it appends paths using the intercept SCID as the +/// forwarding hop so that the LSP can intercept the HTLC and open a JIT channel. Paths from the +/// inner router (e.g., through pre-existing channels) are included as well, allowing payers to +/// use existing inbound liquidity when available. /// /// 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