From 3849e23954e54e8b2d549c1153f6ca7117d5207f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 6 Feb 2026 11:41:14 +0100 Subject: [PATCH 1/7] Add BOLT12 support for LSPS2 JIT channels Add building blocks for creating BOLT12 invoices with blinded payment paths through an LSP using intercept SCIDs, enabling LSPS2 JIT channel opens for BOLT12 payments. - Add `create_blinded_payment_paths_for_intercept_scid()` on `OffersMessageFlow` to construct blinded payment paths through the LSP using LSPS2 parameters (intercept SCID, CLTV delta, zero fees) - Add `create_invoice_builder_from_invoice_request_with_custom_payment_paths()` on `OffersMessageFlow` to build BOLT12 invoices with pre-built payment paths - Add `manually_handle_bolt12_invoice_requests` config option and `Event::InvoiceRequestReceived` to allow external handling of invoice requests (needed for LSPS2 clients that must construct custom paths) - Add `send_invoice_for_request()` and `offers_handler()` on `ChannelManager` for sending back manually-constructed invoices - Update `InvoiceParametersReady` docs for BOLT12 usage Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/event.rs | 30 +++++- lightning/src/events/mod.rs | 38 +++++++ lightning/src/ln/channelmanager.rs | 55 ++++++++++ lightning/src/offers/flow.rs | 141 ++++++++++++++++++++++++- lightning/src/util/config.rs | 24 +++++ 5 files changed, 283 insertions(+), 5 deletions(-) diff --git a/lightning-liquidity/src/lsps2/event.rs b/lightning-liquidity/src/lsps2/event.rs index 502429b79ec..ba60150a9fc 100644 --- a/lightning-liquidity/src/lsps2/event.rs +++ b/lightning-liquidity/src/lsps2/event.rs @@ -49,7 +49,27 @@ pub enum LSPS2ClientEvent { /// When the invoice is paid, the LSP will open a channel with the previously agreed upon /// parameters to you. /// - /// **Note: ** This event will *not* be persisted across restarts. + /// ## BOLT11 + /// For BOLT11 invoices, use `intercept_scid` and `cltv_expiry_delta` in a route hint + /// pointing to the LSP (`counterparty_node_id`). + /// + /// ## BOLT12 + /// For BOLT12 invoices, the same parameters are used to construct blinded payment paths + /// through the LSP: + /// - `counterparty_node_id` is the introduction node (LSP) of the blinded payment path + /// - `intercept_scid` is used as `ForwardTlvs::short_channel_id` in the blinded path + /// - `cltv_expiry_delta` is used as `PaymentRelay::cltv_expiry_delta` in the blinded path + /// - Fee parameters should be set to zero (fees are taken via fee skimming in LSPS2) + /// + /// Use [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`] to construct + /// the blinded payment paths, and + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`] + /// to build the BOLT12 invoice with those paths. + /// + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`]: lightning::offers::flow::OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]: lightning::offers::flow::OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths + /// + /// **Note:** This event will *not* be persisted across restarts. InvoiceParametersReady { /// The identifier of the issued bLIP-52 / LSPS2 `buy` request, as returned by /// [`LSPS2ClientHandler::select_opening_params`]. @@ -59,10 +79,14 @@ pub enum LSPS2ClientEvent { /// [`LSPS2ClientHandler::select_opening_params`]: crate::lsps2::client::LSPS2ClientHandler::select_opening_params request_id: LSPSRequestId, /// The node id of the LSP. + /// + /// For BOLT12, this is used as the introduction node of the blinded payment path. counterparty_node_id: PublicKey, - /// The intercept short channel id to use in the route hint. + /// The intercept short channel id to use in the route hint (BOLT11) or as the + /// `ForwardTlvs::short_channel_id` in a blinded payment path (BOLT12). intercept_scid: u64, - /// The `cltv_expiry_delta` to use in the route hint. + /// The `cltv_expiry_delta` to use in the route hint (BOLT11) or as the + /// `PaymentRelay::cltv_expiry_delta` in a blinded payment path (BOLT12). cltv_expiry_delta: u32, /// The initial payment size you specified. payment_size_msat: Option, diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 3d860e9f363..8b4d533a5fa 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1827,6 +1827,38 @@ pub enum Event { /// [`ChannelManager::respond_to_static_invoice_request`]: crate::ln::channelmanager::ChannelManager::respond_to_static_invoice_request invoice_request: InvoiceRequest, }, + /// We received a [`Bolt12InvoiceRequest`] and the user has opted to manually handle it via + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]. + /// + /// The user should construct a [`Bolt12Invoice`] (e.g., using custom blinded payment paths + /// for LSPS2 JIT channels via + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`] and + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]) + /// and send it back via [`ChannelManager::send_invoice_for_request`]. + /// + /// # Failure Behavior and Persistence + /// This event will eventually be replayed after failures-to-handle (i.e., the event handler + /// returning `Err(ReplayEvent ())`), but won't be persisted across restarts. + /// + /// [`Bolt12InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]: crate::util::config::UserConfig::manually_handle_bolt12_invoice_requests + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`]: crate::offers::flow::OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]: crate::offers::flow::OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths + /// [`ChannelManager::send_invoice_for_request`]: crate::ln::channelmanager::ChannelManager::send_invoice_for_request + InvoiceRequestReceived { + /// The invoice request received from the payer. + invoice_request: InvoiceRequest, + /// The context from the incoming onion message, needed for verification via + /// [`OffersMessageFlow::verify_invoice_request`]. + /// + /// [`OffersMessageFlow::verify_invoice_request`]: crate::offers::flow::OffersMessageFlow::verify_invoice_request + context: Option, + /// Used to send the [`Bolt12Invoice`] response back to the payer. + /// + /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice + responder: Responder, + }, /// Indicates that a channel funding transaction constructed interactively is ready to be /// signed. This event will only be triggered if at least one input was contributed. /// @@ -2317,6 +2349,10 @@ impl Writeable for Event { 47u8.write(writer)?; // Never write StaticInvoiceRequested events as buffered onion messages aren't serialized. }, + &Event::InvoiceRequestReceived { .. } => { + 51u8.write(writer)?; + // Never write InvoiceRequestReceived events as the responder isn't serialized. + }, &Event::FundingTransactionReadyForSigning { .. } => { 49u8.write(writer)?; // We never write out FundingTransactionReadyForSigning events as they will be regenerated when @@ -2948,6 +2984,8 @@ impl MaybeReadable for Event { 47u8 => Ok(None), // Note that we do not write a length-prefixed TLV for FundingTransactionReadyForSigning events. 49u8 => Ok(None), + // Note that we do not write a length-prefixed TLV for InvoiceRequestReceived events. + 51u8 => Ok(None), 50u8 => { let mut f = || { _init_and_read_len_prefixed_tlv_fields!(reader, { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 665a79a9610..3ca420146a0 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -32,6 +32,7 @@ use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::{secp256k1, Sequence, SignedAmount}; +use crate::blinded_path::message::MessageContext; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, MessageForwardNode, OffersContext, }; @@ -5579,6 +5580,41 @@ impl< self.flow.enqueue_static_invoice(invoice, responder) } + /// Sends a [`Bolt12Invoice`] in response to an [`Event::InvoiceRequestReceived`]. + /// + /// This is used when [`UserConfig::manually_handle_bolt12_invoice_requests`] is set to `true`, + /// allowing the user to construct a custom invoice (e.g., with blinded payment paths through + /// an LSP's intercept SCID for LSPS2 JIT channels) and send it back to the payer. + /// + /// The `responder` and `context` should come from the [`Event::InvoiceRequestReceived`] + /// event and the invoice builder (e.g., via + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]), + /// respectively. + /// + /// [`Event::InvoiceRequestReceived`]: crate::events::Event::InvoiceRequestReceived + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]: crate::util::config::UserConfig::manually_handle_bolt12_invoice_requests + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]: crate::offers::flow::OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths + pub fn send_invoice_for_request( + &self, invoice: Bolt12Invoice, context: MessageContext, responder: Responder, + ) { + self.flow.enqueue_invoice_for_request(invoice, context, responder); + } + + /// Returns a reference to the [`OffersMessageFlow`], which provides methods for creating + /// BOLT12 offers, invoices, and related blinded paths. + /// + /// This is useful when manually handling invoice requests (see + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]) to access methods like + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`] and + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]. + /// + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]: crate::util::config::UserConfig::manually_handle_bolt12_invoice_requests + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`]: crate::offers::flow::OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]: crate::offers::flow::OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths + pub fn offers_handler(&self) -> &OffersMessageFlow { + &self.flow + } + fn initiate_async_payment( &self, invoice: &StaticInvoice, payment_id: PaymentId, ) -> Result<(), Bolt12PaymentError> { @@ -15992,6 +16028,9 @@ impl< None => return None, }; + let manually_handle = self.config.read().unwrap().manually_handle_bolt12_invoice_requests; + let saved_context = if manually_handle { context.clone() } else { None }; + let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request, Ok(InvreqResponseInstructions::SendStaticInvoice { recipient_id, invoice_slot, invoice_request }) => { @@ -16004,6 +16043,22 @@ impl< Err(_) => return None, }; + if manually_handle { + let inner_request = match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(ref request) => request.inner.clone(), + InvoiceRequestVerifiedFromOffer::ExplicitKeys(ref request) => request.inner.clone(), + }; + self.pending_events.lock().unwrap().push_back(( + Event::InvoiceRequestReceived { + invoice_request: inner_request, + context: saved_context, + responder, + }, + None, + )); + return None; + } + let get_payment_info = |amount_msats, relative_expiry| { self.create_inbound_payment( Some(amount_msats), diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 0bb98777227..e683ab8e202 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -22,7 +22,7 @@ use crate::blinded_path::message::{ }; use crate::blinded_path::payment::{ AsyncBolt12OfferContext, BlindedPaymentPath, Bolt12OfferContext, Bolt12RefundContext, - PaymentConstraints, PaymentContext, ReceiveTlvs, + ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, }; use crate::chain::channelmonitor::LATENCY_GRACE_PERIOD_BLOCKS; @@ -31,7 +31,9 @@ use crate::prelude::*; use crate::chain::BestBlock; use crate::ln::channel_state::ChannelDetails; -use crate::ln::channelmanager::{InterceptId, PaymentId, CLTV_FAR_FAR_AWAY}; +use crate::ln::channelmanager::{ + InterceptId, PaymentId, CLTV_FAR_FAR_AWAY, MIN_FINAL_CLTV_EXPIRY_DELTA, +}; use crate::ln::inbound_payment; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::invoice::{ @@ -58,6 +60,7 @@ use crate::onion_message::packet::OnionMessageContents; use crate::routing::router::Router; use crate::sign::{EntropySource, ReceiveAuthKey}; use crate::sync::{Mutex, RwLock}; +use crate::types::features::BlindedHopFeatures; use crate::types::payment::{PaymentHash, PaymentSecret}; use crate::util::logger::Logger; use crate::util::ser::Writeable; @@ -337,6 +340,79 @@ impl OffersMessageFlow { ) } + /// Creates a [`BlindedPaymentPath`] that goes through an LSP's intercept SCID. + /// + /// This is intended for use with LSPS2 (JIT channels) and BOLT12 invoices. The resulting + /// blinded payment path has the LSP as the introduction node, with the `intercept_scid` + /// encoded in the [`ForwardTlvs`] so that the LSP can intercept the HTLC and open a JIT + /// channel to the client. + /// + /// Fee parameters in the blinded path are set to zero since LSPS2 takes fees via fee + /// skimming rather than through relay fees. + /// + /// The caller is expected to obtain `payment_secret` from + /// [`ChannelManager::create_inbound_payment`] and pass the corresponding `payment_hash` + /// when building the invoice via + /// [`Self::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]. + /// + /// [`ChannelManager::create_inbound_payment`]: crate::ln::channelmanager::ChannelManager::create_inbound_payment + pub fn create_blinded_payment_paths_for_intercept_scid( + &self, entropy_source: ES, lsp_node_id: PublicKey, intercept_scid: u64, + cltv_expiry_delta: u16, payment_secret: PaymentSecret, payment_context: PaymentContext, + amount_msats: u64, relative_expiry_seconds: u32, + ) -> Result, ()> { + let secp_ctx = &self.secp_ctx; + let receive_auth_key = self.receive_auth_key; + let payee_node_id = self.get_our_node_id(); + + // Assume shorter than usual block times to avoid spuriously failing payments too early. + const SECONDS_PER_BLOCK: u32 = 9 * 60; + let relative_expiry_blocks = relative_expiry_seconds / SECONDS_PER_BLOCK; + let max_cltv_expiry = core::cmp::max(relative_expiry_blocks, CLTV_FAR_FAR_AWAY) + .saturating_add(LATENCY_GRACE_PERIOD_BLOCKS) + .saturating_add(self.best_block.read().unwrap().height); + + let payee_tlvs = ReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { max_cltv_expiry, htlc_minimum_msat: 1 }, + payment_context, + }; + + // Build the forwarding node representing the LSP. The payment constraints for the + // forwarding hop extend the max CLTV expiry by the hop's delta. + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: intercept_scid, + payment_relay: PaymentRelay { + cltv_expiry_delta, + fee_base_msat: 0, + fee_proportional_millionths: 0, + }, + payment_constraints: PaymentConstraints { + max_cltv_expiry: max_cltv_expiry.saturating_add(cltv_expiry_delta as u32), + htlc_minimum_msat: 0, + }, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: lsp_node_id, + htlc_maximum_msat: amount_msats, + }; + + let path = BlindedPaymentPath::new( + &[forward_node], + payee_node_id, + receive_auth_key, + payee_tlvs, + amount_msats, + MIN_FINAL_CLTV_EXPIRY_DELTA, + entropy_source, + secp_ctx, + )?; + + Ok(vec![path]) + } + #[cfg(test)] /// Creates multi-hop blinded payment paths for the given `amount_msats` by delegating to /// [`Router::create_blinded_payment_paths`]. @@ -1032,6 +1108,43 @@ impl OffersMessageFlow { Ok((builder, context)) } + /// Creates an [`InvoiceBuilder`] for the provided + /// [`VerifiedInvoiceRequest`] using pre-built blinded payment paths. + /// + /// This is intended for use with LSPS2 (JIT channels) where the blinded payment paths are + /// constructed via [`Self::create_blinded_payment_paths_for_intercept_scid`] rather than + /// through the [`Router`]. + /// + /// The caller is expected to: + /// 1. Call [`ChannelManager::create_inbound_payment`] to obtain both a `payment_hash` and + /// `payment_secret`. + /// 2. Use the `payment_secret` when constructing blinded payment paths via + /// [`Self::create_blinded_payment_paths_for_intercept_scid`]. + /// 3. Pass the resulting `payment_paths` and `payment_hash` to this method. + /// + /// Returns the invoice builder along with a [`MessageContext`] that can later be used to + /// respond to the counterparty. + /// + /// [`ChannelManager::create_inbound_payment`]: crate::ln::channelmanager::ChannelManager::create_inbound_payment + pub fn create_invoice_builder_from_invoice_request_with_custom_payment_paths<'a>( + &self, invoice_request: &'a VerifiedInvoiceRequest, + payment_paths: Vec, payment_hash: PaymentHash, + ) -> Result<(InvoiceBuilder<'a, DerivedSigningPubkey>, MessageContext), Bolt12SemanticError> { + #[cfg(feature = "std")] + let builder = invoice_request.respond_using_derived_keys(payment_paths, payment_hash); + #[cfg(not(feature = "std"))] + let builder = invoice_request.respond_using_derived_keys_no_std( + payment_paths, + payment_hash, + Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), + ); + let builder = builder.map(|b| InvoiceBuilder::from(b).allow_mpp())?; + + let context = MessageContext::Offers(OffersContext::InboundPayment { payment_hash }); + + Ok((builder, context)) + } + /// Enqueues the created [`InvoiceRequest`] to be sent to the counterparty. /// /// # Payment @@ -1183,6 +1296,30 @@ impl OffersMessageFlow { pending_offers_messages.push((message, instructions)); } + /// Enqueues a [`Bolt12Invoice`] to be sent back to a payer in response to an + /// [`InvoiceRequest`] that was manually handled. + /// + /// This is used when [`UserConfig::manually_handle_bolt12_invoice_requests`] is set to `true`, + /// typically for LSPS2 JIT channels where custom blinded payment paths are needed. + /// + /// The `context` should be obtained from + /// [`Self::create_invoice_builder_from_invoice_request_with_custom_payment_paths`], and the + /// `responder` should come from the [`Event::InvoiceRequestReceived`] event. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]: crate::util::config::UserConfig::manually_handle_bolt12_invoice_requests + /// [`Event::InvoiceRequestReceived`]: crate::events::Event::InvoiceRequestReceived + pub fn enqueue_invoice_for_request( + &self, invoice: Bolt12Invoice, context: MessageContext, responder: Responder, + ) { + let message = OffersMessage::Invoice(invoice); + let instructions = responder.respond_with_reply_path(context); + self.pending_offers_messages + .lock() + .unwrap() + .push((message, instructions.into_instructions())); + } + /// Enqueues `held_htlc_available` onion messages to be sent to the payee via the reply paths /// contained within the provided [`StaticInvoice`]. /// diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index feb326cfad6..f0d5ad8b398 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -1041,6 +1041,28 @@ pub struct UserConfig { /// [`ChannelManager::send_payment_for_bolt12_invoice`]: crate::ln::channelmanager::ChannelManager::send_payment_for_bolt12_invoice /// [`ChannelManager::abandon_payment`]: crate::ln::channelmanager::ChannelManager::abandon_payment pub manually_handle_bolt12_invoices: bool, + /// If this is set to `true`, the user needs to manually handle incoming BOLT12 + /// [`InvoiceRequest`]s. + /// + /// When set to `true`, [`Event::InvoiceRequestReceived`] will be generated for each received + /// [`InvoiceRequest`] instead of automatically creating and sending a [`Bolt12Invoice`]. + /// + /// This is useful for LSP clients using LSPS2 (JIT channels) with BOLT12, where the invoice + /// needs to include blinded payment paths through the LSP's intercept SCID. The user can + /// construct custom blinded payment paths via + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`], build the invoice + /// via [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`], + /// and send it back via [`ChannelManager::send_invoice_for_request`]. + /// + /// Default value: `false` + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice + /// [`Event::InvoiceRequestReceived`]: crate::events::Event::InvoiceRequestReceived + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`]: crate::offers::flow::OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]: crate::offers::flow::OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths + /// [`ChannelManager::send_invoice_for_request`]: crate::ln::channelmanager::ChannelManager::send_invoice_for_request + pub manually_handle_bolt12_invoice_requests: bool, /// If this is set to `true`, dual-funded channels will be enabled. /// /// Default value: `false` @@ -1095,6 +1117,7 @@ impl Default for UserConfig { manually_accept_inbound_channels: false, htlc_interception_flags: 0, manually_handle_bolt12_invoices: false, + manually_handle_bolt12_invoice_requests: false, enable_dual_funded_channels: false, enable_htlc_hold: false, hold_outbound_htlcs_at_next_hop: false, @@ -1118,6 +1141,7 @@ impl Readable for UserConfig { manually_accept_inbound_channels: Readable::read(reader)?, htlc_interception_flags: Readable::read(reader)?, manually_handle_bolt12_invoices: Readable::read(reader)?, + manually_handle_bolt12_invoice_requests: Readable::read(reader)?, enable_dual_funded_channels: Readable::read(reader)?, hold_outbound_htlcs_at_next_hop: Readable::read(reader)?, enable_htlc_hold: Readable::read(reader)?, From 2272b2fe2d49ef91ff72a1b8709fb2ed455bde3b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 6 Feb 2026 11:46:16 +0100 Subject: [PATCH 2/7] Add per-peer onion message interception API to `OnionMessenger` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `register_peer_for_interception()` and `deregister_peer_for_interception()` methods to `OnionMessenger`, allowing specific peers to be registered for onion message interception without enabling blanket interception for all offline peers. When a registered peer is offline and an onion message needs to be forwarded to them, `Event::OnionMessageIntercepted` is emitted. When a registered peer connects, `Event::OnionMessagePeerConnected` is emitted. This works alongside the existing global `new_with_offline_peer_interception()` flag — if either the global flag is set or the peer is specifically registered, interception occurs. This enables LSPS2 services to intercept onion messages only for peers with active JIT channel sessions, rather than intercepting messages for all offline peers. Co-Authored-By: HAL 9000 --- lightning/src/onion_message/messenger.rs | 42 ++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index e688c020ac6..cfc6a60e9af 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -273,6 +273,7 @@ pub struct OnionMessenger< dns_resolver_handler: DRH, custom_handler: CMH, intercept_messages_for_offline_peers: bool, + peers_registered_for_interception: Mutex>, pending_intercepted_msgs_events: Mutex>, pending_peer_connected_events: Mutex>, pending_events_processor: AtomicBool, @@ -1449,6 +1450,7 @@ impl< dns_resolver_handler: dns_resolver, custom_handler, intercept_messages_for_offline_peers, + peers_registered_for_interception: Mutex::new(new_hash_set()), pending_intercepted_msgs_events: Mutex::new(Vec::new()), pending_peer_connected_events: Mutex::new(Vec::new()), pending_events_processor: AtomicBool::new(false), @@ -1466,6 +1468,37 @@ impl< self.async_payments_handler = async_payments_handler; } + /// Registers a peer for onion message interception. + /// + /// When an onion message needs to be forwarded to a registered peer that is currently offline, + /// an [`Event::OnionMessageIntercepted`] will be generated, allowing the message to be stored + /// and forwarded later when the peer reconnects. + /// + /// Similarly, when a registered peer connects, an [`Event::OnionMessagePeerConnected`] will + /// be generated. + /// + /// This is useful for services like LSPS2 that need to intercept onion messages for specific + /// peers (e.g., those with active JIT channel sessions) without enabling blanket interception + /// for all offline peers via [`Self::new_with_offline_peer_interception`]. + /// + /// Use [`Self::deregister_peer_for_interception`] to stop intercepting messages for this peer. + /// + /// [`Event::OnionMessageIntercepted`]: crate::events::Event::OnionMessageIntercepted + /// [`Event::OnionMessagePeerConnected`]: crate::events::Event::OnionMessagePeerConnected + pub fn register_peer_for_interception(&self, peer_node_id: PublicKey) { + self.peers_registered_for_interception.lock().unwrap().insert(peer_node_id); + } + + /// Deregisters a peer from onion message interception. + /// + /// After this call, onion messages for this peer will no longer be intercepted (unless + /// blanket interception is enabled via [`Self::new_with_offline_peer_interception`]). + /// + /// Returns whether the peer was previously registered. + pub fn deregister_peer_for_interception(&self, peer_node_id: &PublicKey) -> bool { + self.peers_registered_for_interception.lock().unwrap().remove(peer_node_id) + } + /// Sends an [`OnionMessage`] based on its [`MessageSendInstructions`]. pub fn send_onion_message( &self, contents: T, instructions: MessageSendInstructions, @@ -1682,6 +1715,9 @@ impl< .entry(next_node_id) .or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new())); + let should_intercept = self.intercept_messages_for_offline_peers + || self.peers_registered_for_interception.lock().unwrap().contains(&next_node_id); + match message_recipients.entry(next_node_id) { hash_map::Entry::Occupied(mut e) if matches!(e.get(), OnionMessageRecipient::ConnectedPeer(..)) => @@ -1695,7 +1731,7 @@ impl< ); Ok(()) }, - _ if self.intercept_messages_for_offline_peers => { + _ if should_intercept => { log_trace!( self.logger, "Generating OnionMessageIntercepted event for peer {} {}", @@ -2138,7 +2174,9 @@ impl< .or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new())) .mark_connected(); } - if self.intercept_messages_for_offline_peers { + let is_registered = + self.peers_registered_for_interception.lock().unwrap().contains(&their_node_id); + if self.intercept_messages_for_offline_peers || is_registered { let mut pending_peer_connected_events = self.pending_peer_connected_events.lock().unwrap(); pending_peer_connected_events From 7e20374ba96aa86935b7fb2a69263e2b5da382a7 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 6 Feb 2026 12:07:20 +0100 Subject: [PATCH 3/7] Wire `OnionMessageInterceptor` into LSPS2 service handler Define the `OnionMessageInterceptor` trait with `register_peer_for_interception()` and `deregister_peer_for_interception()` methods, and implement it for `OnionMessenger`. This allows external components to register peers for onion message interception via a trait object, without needing to know the concrete `OnionMessenger` type. Wire the trait into `LSPS2ServiceHandler` as an optional `Arc`. When provided: - On init, all peers with active intercept SCIDs are registered - In `invoice_parameters_generated()`, the counterparty is registered when a new intercept SCID is assigned This ensures that onion messages for LSPS2 clients with active JIT channel sessions are intercepted when those clients are offline, enabling the LSP to store and forward messages when the client reconnects. Co-Authored-By: HAL 9000 --- lightning-background-processor/src/lib.rs | 1 + lightning-liquidity/src/lsps2/service.rs | 16 +++++++ lightning-liquidity/src/manager.rs | 9 ++++ lightning-liquidity/tests/common/mod.rs | 2 + .../tests/lsps2_integration_tests.rs | 1 + .../tests/lsps5_integration_tests.rs | 1 + lightning/src/onion_message/messenger.rs | 48 +++++++++++++++++++ 7 files changed, 78 insertions(+) diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index bb99d65e6b5..8e61297474c 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -2517,6 +2517,7 @@ mod tests { Arc::clone(&tx_broadcaster), None, None, + None, ) .unwrap(), ); diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 4c688d39eef..befc66e569f 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -45,6 +45,7 @@ use lightning::events::HTLCHandlingFailureType; use lightning::ln::channelmanager::{AChannelManager, FailureCode, InterceptId}; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::types::ChannelId; +use lightning::onion_message::messenger::OnionMessageInterceptor; use lightning::util::errors::APIError; use lightning::util::logger::Level; use lightning::util::ser::Writeable; @@ -717,6 +718,7 @@ where total_pending_requests: AtomicUsize, config: LSPS2ServiceConfig, persistence_in_flight: AtomicUsize, + onion_message_interceptor: Option>, } impl LSPS2ServiceHandler @@ -728,6 +730,7 @@ where per_peer_state: HashMap>, pending_messages: Arc, pending_events: Arc>, channel_manager: CM, kv_store: K, tx_broadcaster: T, config: LSPS2ServiceConfig, + onion_message_interceptor: Option>, ) -> Result { let mut peer_by_intercept_scid = new_hash_map(); let mut peer_by_channel_id = new_hash_map(); @@ -756,6 +759,14 @@ where } } + // Register all peers with active intercept SCIDs for onion message interception, + // so that messages for offline peers are held rather than dropped. + if let Some(ref interceptor) = onion_message_interceptor { + for node_id in peer_by_intercept_scid.values() { + interceptor.register_peer_for_interception(*node_id); + } + } + Ok(Self { pending_messages, pending_events, @@ -768,6 +779,7 @@ where kv_store, tx_broadcaster, config, + onion_message_interceptor, }) } @@ -921,6 +933,10 @@ where peer_by_intercept_scid.insert(intercept_scid, *counterparty_node_id); } + if let Some(ref interceptor) = self.onion_message_interceptor { + interceptor.register_peer_for_interception(*counterparty_node_id); + } + let outbound_jit_channel = OutboundJITChannel::new( buy_request.payment_size_msat, buy_request.opening_fee_params, diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 1f11fc8add7..0d71cb2f6bd 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -48,6 +48,7 @@ use lightning::ln::channelmanager::{AChannelManager, ChainParameters}; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::CustomMessageReader; +use lightning::onion_message::messenger::OnionMessageInterceptor; use lightning::sign::{EntropySource, NodeSigner}; use lightning::util::logger::Level; use lightning::util::persist::{KVStore, KVStoreSync, KVStoreSyncWrapper}; @@ -330,6 +331,7 @@ where chain_params: Option, kv_store: K, transaction_broadcaster: T, service_config: Option, client_config: Option, + onion_message_interceptor: Option>, ) -> Result { Self::new_with_custom_time_provider( entropy_source, @@ -342,6 +344,7 @@ where service_config, client_config, DefaultTimeProvider, + onion_message_interceptor, ) .await } @@ -373,6 +376,7 @@ where chain_source: Option, chain_params: Option, kv_store: K, service_config: Option, client_config: Option, time_provider: TP, + onion_message_interceptor: Option>, ) -> Result { let pending_msgs_or_needs_persist_notifier = Arc::new(Notifier::new()); let pending_messages = @@ -415,6 +419,7 @@ where kv_store.clone(), transaction_broadcaster.clone(), lsps2_service_config.clone(), + onion_message_interceptor.clone(), )?) } else { None @@ -1044,6 +1049,7 @@ where chain_params: Option, kv_store_sync: KS, transaction_broadcaster: T, service_config: Option, client_config: Option, + onion_message_interceptor: Option>, ) -> Result { let kv_store = KVStoreSyncWrapper(kv_store_sync); @@ -1057,6 +1063,7 @@ where transaction_broadcaster, service_config, client_config, + onion_message_interceptor, )); let mut waker = dummy_waker(); @@ -1094,6 +1101,7 @@ where chain_params: Option, kv_store_sync: KS, transaction_broadcaster: T, service_config: Option, client_config: Option, time_provider: TP, + onion_message_interceptor: Option>, ) -> Result { let kv_store = KVStoreSyncWrapper(kv_store_sync); let mut fut = pin!(LiquidityManager::new_with_custom_time_provider( @@ -1107,6 +1115,7 @@ where service_config, client_config, time_provider, + onion_message_interceptor, )); let mut waker = dummy_waker(); diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index dea987527ad..7bd3adf7043 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -47,6 +47,7 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( Some(service_config), None, Arc::clone(&time_provider), + None, ) .unwrap(); @@ -61,6 +62,7 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( None, Some(client_config), time_provider, + None, ) .unwrap(); diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 312199e19ec..55a72e4bbc5 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -1089,6 +1089,7 @@ fn lsps2_service_handler_persistence_across_restarts() { Some(service_config), None, time_provider, + None, ) .unwrap(); diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 16f20fd095f..623bf42a88f 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -1618,6 +1618,7 @@ fn lsps5_service_handler_persistence_across_restarts() { Some(service_config), None, Arc::clone(&time_provider), + None, ) .unwrap(); diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index cfc6a60e9af..ca68e209abb 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -125,6 +125,54 @@ impl< } } +/// A trait for registering specific peers for onion message interception. +/// +/// When a peer is registered for interception and is currently offline, any onion messages +/// intended to be forwarded to them will generate an [`Event::OnionMessageIntercepted`] instead +/// of being dropped. When a registered peer connects, an [`Event::OnionMessagePeerConnected`] +/// will be generated. +/// +/// [`OnionMessenger`] implements this trait, but it is also useful as a trait object to allow +/// external components (e.g., an LSPS2 service) to register peers for interception without +/// needing to know the concrete [`OnionMessenger`] type. +/// +/// [`Event::OnionMessageIntercepted`]: crate::events::Event::OnionMessageIntercepted +/// [`Event::OnionMessagePeerConnected`]: crate::events::Event::OnionMessagePeerConnected +pub trait OnionMessageInterceptor { + /// Registers a peer for onion message interception. + /// + /// See [`OnionMessenger::register_peer_for_interception`] for more details. + fn register_peer_for_interception(&self, peer_node_id: PublicKey); + + /// Deregisters a peer from onion message interception. + /// + /// See [`OnionMessenger::deregister_peer_for_interception`] for more details. + /// + /// Returns whether the peer was previously registered. + fn deregister_peer_for_interception(&self, peer_node_id: &PublicKey) -> bool; +} + +impl< + ES: EntropySource, + NS: NodeSigner, + L: Logger, + NL: NodeIdLookUp, + MR: MessageRouter, + OMH: OffersMessageHandler, + APH: AsyncPaymentsMessageHandler, + DRH: DNSResolverMessageHandler, + CMH: CustomOnionMessageHandler, + > OnionMessageInterceptor for OnionMessenger +{ + fn register_peer_for_interception(&self, peer_node_id: PublicKey) { + OnionMessenger::register_peer_for_interception(self, peer_node_id) + } + + fn deregister_peer_for_interception(&self, peer_node_id: &PublicKey) -> bool { + OnionMessenger::deregister_peer_for_interception(self, peer_node_id) + } +} + /// A sender, receiver and forwarder of [`OnionMessage`]s. /// /// # Handling Messages From 3803986e6e2bdd2ff3df43b99f00135e8ac302bd Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 6 Feb 2026 12:18:59 +0100 Subject: [PATCH 4/7] Add deregistration cleanup for `OnionMessageInterceptor` in LSPS2 When intercept SCIDs are removed during cleanup, also clean up the handler-level `peer_by_intercept_scid` map and deregister the peer from onion message interception if they have no remaining active SCIDs. Cleanup is added in all relevant paths: - `prune_expired_request_state()` now returns pruned SCIDs - `peer_disconnected()` cleans up after pruning - `htlc_intercepted()` error path (fixes existing TODO) - `channel_open_abandoned()` cleans up after removing the SCID - `persist()` cleans up both `peer_by_intercept_scid` and `peer_by_channel_id` when removing a peer entry entirely Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/service.rs | 85 ++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 6 deletions(-) diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index befc66e569f..2a3b0e1155e 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -632,17 +632,20 @@ impl PeerState { }); } - fn prune_expired_request_state(&mut self) { + fn prune_expired_request_state(&mut self) -> Vec { + let mut pruned_scids = Vec::new(); self.outbound_channels_by_intercept_scid.retain(|intercept_scid, entry| { if entry.is_prunable() { // We abort the flow, and prune any data kept. self.intercept_scid_by_channel_id.retain(|_, iscid| intercept_scid != iscid); self.intercept_scid_by_user_channel_id.retain(|_, iscid| intercept_scid != iscid); self.needs_persist |= true; + pruned_scids.push(*intercept_scid); return false; } true }); + pruned_scids } fn pending_requests_and_channels(&self) -> usize { @@ -788,6 +791,29 @@ where &self.config } + /// Cleans up `peer_by_intercept_scid` entries for the given SCIDs, and deregisters the peer + /// from onion message interception if they have no remaining active intercept SCIDs. + fn cleanup_intercept_scids( + &self, counterparty_node_id: &PublicKey, pruned_scids: &[u64], has_remaining_channels: bool, + ) { + if pruned_scids.is_empty() { + return; + } + + { + let mut peer_by_intercept_scid = self.peer_by_intercept_scid.write().unwrap(); + for scid in pruned_scids { + peer_by_intercept_scid.remove(scid); + } + } + + if !has_remaining_channels { + if let Some(ref interceptor) = self.onion_message_interceptor { + interceptor.deregister_peer_for_interception(counterparty_node_id); + } + } + } + /// Returns whether the peer has any active LSPS2 requests. pub(crate) fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool { let outer_state_lock = self.per_peer_state.read().unwrap(); @@ -1067,7 +1093,15 @@ where peer_state .outbound_channels_by_intercept_scid .remove(&intercept_scid); - // TODO: cleanup peer_by_intercept_scid + let has_remaining = + !peer_state.outbound_channels_by_intercept_scid.is_empty(); + drop(peer_state); + drop(outer_state_lock); + self.cleanup_intercept_scids( + counterparty_node_id, + &[intercept_scid], + has_remaining, + ); return Err(APIError::APIMisuseError { err: e.err }); }, } @@ -1286,7 +1320,7 @@ where pub async fn channel_open_abandoned( &self, counterparty_node_id: &PublicKey, user_channel_id: u128, ) -> Result<(), APIError> { - { + let (intercept_scid, has_remaining) = { let outer_state_lock = self.per_peer_state.read().unwrap(); let inner_state_lock = outer_state_lock.get(counterparty_node_id).ok_or_else(|| { APIError::APIMisuseError { @@ -1333,7 +1367,11 @@ where peer_state.outbound_channels_by_intercept_scid.remove(&intercept_scid); peer_state.intercept_scid_by_channel_id.retain(|_, &mut scid| scid != intercept_scid); peer_state.needs_persist |= true; - } + let has_remaining = !peer_state.outbound_channels_by_intercept_scid.is_empty(); + (intercept_scid, has_remaining) + }; + + self.cleanup_intercept_scids(counterparty_node_id, &[intercept_scid], has_remaining); self.persist_peer_state(*counterparty_node_id).await.map_err(|e| { APIError::APIMisuseError { @@ -1817,10 +1855,16 @@ where { // First build a list of peers to persist and prune with the read lock. This allows // us to avoid the write lock unless we actually need to remove a node. + let mut all_pruned_scids = Vec::new(); let outer_state_lock = self.per_peer_state.read().unwrap(); for (counterparty_node_id, inner_state_lock) in outer_state_lock.iter() { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - peer_state_lock.prune_expired_request_state(); + let pruned_scids = peer_state_lock.prune_expired_request_state(); + if !pruned_scids.is_empty() { + let has_remaining = + !peer_state_lock.outbound_channels_by_intercept_scid.is_empty(); + all_pruned_scids.push((*counterparty_node_id, pruned_scids, has_remaining)); + } let is_prunable = peer_state_lock.is_prunable(); if is_prunable { need_remove.push(*counterparty_node_id); @@ -1828,6 +1872,15 @@ where need_persist.push(*counterparty_node_id); } } + drop(outer_state_lock); + + for (counterparty_node_id, pruned_scids, has_remaining) in all_pruned_scids { + self.cleanup_intercept_scids( + &counterparty_node_id, + &pruned_scids, + has_remaining, + ); + } } for counterparty_node_id in need_persist.into_iter() { @@ -1838,6 +1891,7 @@ where for counterparty_node_id in need_remove { let mut future_opt = None; + let mut was_removed = false; { // We need to take the `per_peer_state` write lock to remove an entry, but also // have to hold it until after the `remove` call returns (but not through @@ -1849,6 +1903,7 @@ where let state = entry.get_mut().get_mut().unwrap(); if state.is_prunable() { entry.remove(); + was_removed = true; let key = counterparty_node_id.to_string(); future_opt = Some(self.kv_store.remove( LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, @@ -1866,6 +1921,20 @@ where debug_assert!(false); } } + if was_removed { + // Clean up handler-level maps for the removed peer. + self.peer_by_intercept_scid + .write() + .unwrap() + .retain(|_, node_id| *node_id != counterparty_node_id); + self.peer_by_channel_id + .write() + .unwrap() + .retain(|_, node_id| *node_id != counterparty_node_id); + if let Some(ref interceptor) = self.onion_message_interceptor { + interceptor.deregister_peer_for_interception(&counterparty_node_id); + } + } if let Some(future) = future_opt { future.await?; did_persist = true; @@ -1893,7 +1962,11 @@ where // We clean up the peer state, but leave removing the peer entry to the prune logic in // `persist` which removes it from the store. peer_state_lock.prune_pending_requests(); - peer_state_lock.prune_expired_request_state(); + let pruned_scids = peer_state_lock.prune_expired_request_state(); + let has_remaining = !peer_state_lock.outbound_channels_by_intercept_scid.is_empty(); + drop(peer_state_lock); + drop(outer_state_lock); + self.cleanup_intercept_scids(&counterparty_node_id, &pruned_scids, has_remaining); } } From e703e60530211b33f10b7d72fe7f94f953c8d1d2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 6 Feb 2026 12:24:58 +0100 Subject: [PATCH 5/7] Add `send_bolt12_invoice_for_intercept_scid` convenience method Add a convenience method on `ChannelManager` that combines all the steps needed to create and send a BOLT12 invoice using LSPS2 JIT channel parameters: 1. Verify the invoice request 2. Create an inbound payment (payment hash + secret) 3. Build blinded payment paths through the LSP using the intercept SCID 4. Construct the BOLT12 invoice with those paths 5. Send the invoice back via the responder This simplifies the LSPS2 client flow: instead of manually calling five separate methods across `OffersMessageFlow` and `ChannelManager`, the caller can handle `Event::InvoiceRequestReceived` with a single call, passing the LSPS2 parameters from `InvoiceParametersReady`. Co-Authored-By: HAL 9000 --- lightning/src/ln/channelmanager.rs | 79 +++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 3ca420146a0..2efdc269d31 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -98,7 +98,9 @@ use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow}; use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use crate::offers::invoice_error::InvoiceError; -use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromOffer}; +use crate::offers::invoice_request::{ + InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer, +}; use crate::offers::nonce::Nonce; use crate::offers::offer::{Offer, OfferFromHrn}; use crate::offers::parse::Bolt12SemanticError; @@ -5600,6 +5602,81 @@ impl< self.flow.enqueue_invoice_for_request(invoice, context, responder); } + /// Creates and sends a BOLT12 invoice in response to an [`Event::InvoiceRequestReceived`], + /// allowing to construct blinded payment paths for a specific [`InterceptId`] through an LSP. + /// + /// This is a convenience method that combines the steps of verifying the invoice request, + /// creating an inbound payment, building blinded payment paths with the given intercept SCID, + /// constructing the invoice, and sending it back via the responder. + /// + /// Returns the [`Bolt12Invoice`] that was sent on success, or a [`Bolt12SemanticError`] on + /// failure. + /// + /// [`Event::InvoiceRequestReceived`]: crate::events::Event::InvoiceRequestReceived + pub fn send_bolt12_invoice_for_intercept_scid( + &self, invoice_request: InvoiceRequest, context: Option, + responder: Responder, lsp_node_id: PublicKey, intercept_scid: u64, cltv_expiry_delta: u16, + invoice_expiry_secs: u32, + ) -> Result { + let verified = self + .flow + .verify_invoice_request(invoice_request, context) + .map_err(|_| Bolt12SemanticError::InvalidMetadata)?; + + let invoice_request = match verified { + InvreqResponseInstructions::SendInvoice( + InvoiceRequestVerifiedFromOffer::DerivedKeys(request), + ) => request, + _ => return Err(Bolt12SemanticError::InvalidMetadata), + }; + + let amount_msats = + invoice_request.amount_msats().ok_or(Bolt12SemanticError::MissingAmount)?; + + let (payment_hash, payment_secret) = self + .create_inbound_payment(Some(amount_msats), invoice_expiry_secs, None) + .map_err(|_| Bolt12SemanticError::InvalidAmount)?; + + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: invoice_request.offer_id, + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: invoice_request.quantity(), + payer_note_truncated: invoice_request + .payer_note() + .map(|note| UntrustedString(note.to_string())), + human_readable_name: invoice_request.offer_from_hrn().clone(), + }, + }); + + let payment_paths = self + .flow + .create_blinded_payment_paths_for_intercept_scid( + &self.entropy_source, + lsp_node_id, + intercept_scid, + cltv_expiry_delta, + payment_secret, + payment_context, + amount_msats, + invoice_expiry_secs, + ) + .map_err(|_| Bolt12SemanticError::MissingPaths)?; + + let (builder, reply_context) = + self.flow.create_invoice_builder_from_invoice_request_with_custom_payment_paths( + &invoice_request, + payment_paths, + payment_hash, + )?; + + let invoice = builder.build_and_sign(&self.secp_ctx)?; + + self.flow.enqueue_invoice_for_request(invoice.clone(), reply_context, responder); + + Ok(invoice) + } + /// Returns a reference to the [`OffersMessageFlow`], which provides methods for creating /// BOLT12 offers, invoices, and related blinded paths. /// From b0914626d8eaefc9aa0f95d0648ab3d77f233c7d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 6 Feb 2026 12:35:39 +0100 Subject: [PATCH 6/7] Add integration test for BOLT12 invoice with intercept SCID blinded paths Add a test that verifies the `manually_handle_bolt12_invoice_requests` config flag and `send_bolt12_invoice_for_intercept_scid` convenience method work correctly together. The test exercises the full LSPS2+BOLT12 flow: creating an offer, receiving an `InvoiceRequest` via the `Event::InvoiceRequestReceived` event, building a BOLT12 invoice with blinded payment paths using an intercept SCID, and sending the invoice back to the payer via onion message. Co-Authored-By: HAL 9000 --- lightning/src/ln/offers_tests.rs | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 12e631b4042..646a8dd5b95 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2572,3 +2572,88 @@ fn no_double_pay_with_stale_channelmanager() { // generated in response to the duplicate invoice. assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); } + +/// Checks that when `manually_handle_bolt12_invoice_requests` is enabled, an +/// `Event::InvoiceRequestReceived` is emitted instead of automatically creating an invoice, and +/// that `send_bolt12_invoice_for_intercept_scid` can be used to create and send a BOLT12 invoice +/// with blinded payment paths using an intercept SCID (as used in LSPS2 JIT channels). +#[test] +fn creates_bolt12_invoice_with_intercept_scid_blinded_paths() { + let mut manually_handle_cfg = test_default_channel_config(); + manually_handle_cfg.manually_handle_bolt12_invoice_requests = true; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(manually_handle_cfg), None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; + let alice_id = alice.node.get_our_node_id(); + let bob = &nodes[1]; + let bob_id = bob.node.get_our_node_id(); + + // Alice creates an offer with manual invoice request handling enabled. + let offer = alice.node + .create_offer_builder().unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + // Bob pays for the offer, which sends an InvoiceRequest to Alice. + let payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); + + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + + // Since manually_handle_bolt12_invoice_requests is true, Alice should NOT auto-create an + // invoice. + assert!(alice.onion_messenger.next_onion_message_for_peer(bob_id).is_none()); + + // Instead, Alice should get an InvoiceRequestReceived event. + let mut events = alice.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + + let (invoice_request, context, responder) = match events.pop().unwrap() { + Event::InvoiceRequestReceived { invoice_request, context, responder } => { + (invoice_request, context, responder) + }, + _ => panic!("Expected Event::InvoiceRequestReceived"), + }; + + // Alice creates a BOLT12 invoice with blinded payment paths using an intercept SCID, + // simulating the LSPS2 JIT channel flow where Bob acts as the LSP introduction node. + let intercept_scid = 42u64; + let cltv_expiry_delta = 144u16; + let invoice_expiry_secs = 3600u32; + + let invoice = alice.node.send_bolt12_invoice_for_intercept_scid( + invoice_request, + context, + responder, + bob_id, + intercept_scid, + cltv_expiry_delta, + invoice_expiry_secs, + ).unwrap(); + + // Verify the invoice has the correct structure. + assert_eq!(invoice.amount_msats(), 10_000_000); + assert!(!invoice.payment_paths().is_empty()); + for path in invoice.payment_paths() { + assert_eq!(path.introduction_node(), &IntroductionNode::NodeId(bob_id)); + } + + // Verify the invoice is sent back to Bob as an onion message. + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + + // Extract and verify the invoice Bob would receive. + let (received_invoice, _) = extract_invoice(bob, &onion_message); + assert_eq!(received_invoice.amount_msats(), 10_000_000); + assert!(!received_invoice.payment_paths().is_empty()); + for path in received_invoice.payment_paths() { + assert_eq!(path.introduction_node(), &IntroductionNode::NodeId(bob_id)); + } +} From c847f08f1f588e2fc20987f92bda6bc1bfc621d9 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 6 Feb 2026 13:13:35 +0100 Subject: [PATCH 7/7] Add end-to-end LSPS2 integration test for BOLT12 JIT channel flow Add `bolt12_lsps2_end_to_end_test` that exercises the full BOLT12 + LSPS2 JIT channel lifecycle across three nodes (payer, service/LSP, client): - LSPS2 ceremony to negotiate JIT channel parameters - Client creates BOLT12 offer with `manually_handle_bolt12_invoice_requests` - Payer sends `InvoiceRequest` routed through the service via onion messages - Client handles `InvoiceRequestReceived` and calls `send_bolt12_invoice_for_intercept_scid` to create an invoice with blinded payment paths through the service's intercept SCID - Payer pays the invoice through the blinded path - Service intercepts the HTLC and opens a JIT channel to the client - Client claims, service broadcasts the funding transaction Co-Authored-By: HAL 9000 --- .../tests/lsps2_integration_tests.rs | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 55a72e4bbc5..61dfb456996 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -7,6 +7,7 @@ use common::{ get_lsps_message, LSPSNodes, LSPSNodesWithPayer, LiquidityNode, }; +use lightning::blinded_path::IntroductionNode; use lightning::events::{ClosureReason, Event}; use lightning::get_event_msg; use lightning::ln::channelmanager::{OptionalBolt11PaymentParams, PaymentId}; @@ -14,6 +15,7 @@ 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_liquidity::events::LiquidityEvent; @@ -2000,6 +2002,304 @@ fn htlc_timeout_before_client_claim_results_in_handling_failed() { payer_node.chain_monitor.added_monitors.lock().unwrap().clear(); } +#[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. + // + // 1. Create a channel between payer and service + // 2. Do the LSPS2 ceremony between client and service to get an intercept SCID + // 3. Client creates a BOLT12 offer (with manually_handle_bolt12_invoice_requests = true) + // 4. Payer pays for the offer (sends InvoiceRequest via onion message) + // 5. Client receives InvoiceRequestReceived event and creates a BOLT12 invoice with blinded + // payment paths through the service's intercept SCID + // 6. Payer receives the invoice and sends payment through the blinded path + // 7. Service intercepts the HTLC and creates a JIT channel to the client + // 8. Service forwards the HTLC to the client via the JIT channel + // 9. Client claims the payment, service broadcasts the funding tx + 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.manually_accept_inbound_channels = true; + client_node_config.channel_config.accept_underpaying_htlcs = true; + client_node_config.manually_handle_bolt12_invoice_requests = 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 channel between payer and service + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + // LSPS2 ceremony: client negotiates JIT channel parameters with service + 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 = 1000; + + 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. + // This guarantees that both the offer's blinded message paths and the payer's reply paths + // route through the service node. + 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); + + // Client creates a BOLT12 offer. Since client's only remaining peer is service, + // the blinded message paths will use service as the introduction node. + let offer = client_node + .node + .create_offer_builder() + .unwrap() + .amount_msats(payment_size_msat.unwrap()) + .build() + .unwrap(); + + // Payer initiates payment for the offer + let payment_id = PaymentId([1; 32]); + payer_node.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + + // Route InvoiceRequest: payer -> service -> client + 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); + + // Client should NOT auto-create an invoice (manually_handle_bolt12_invoice_requests = true) + assert!(client_node.onion_messenger.next_onion_message_for_peer(service_node_id).is_none()); + + // Client receives InvoiceRequestReceived event + let mut events = client_node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + + let (invoice_request, context, responder) = match events.pop().unwrap() { + Event::InvoiceRequestReceived { invoice_request, context, responder } => { + (invoice_request, context, responder) + }, + other => panic!("Expected Event::InvoiceRequestReceived, got: {:?}", other), + }; + + // Client creates a BOLT12 invoice with blinded payment paths through the service's + // intercept SCID, simulating the LSPS2 JIT channel flow. + let invoice = client_node + .node + .send_bolt12_invoice_for_intercept_scid( + invoice_request, + context, + responder, + service_node_id, + intercept_scid, + cltv_expiry_delta as u16, + 3600, + ) + .unwrap(); + + // Verify the invoice has the correct structure: blinded payment paths with service + // as the introduction node (the LSP that will open the JIT channel). + assert_eq!(invoice.amount_msats(), payment_size_msat.unwrap()); + assert!(!invoice.payment_paths().is_empty()); + for path in invoice.payment_paths() { + assert_eq!(path.introduction_node(), &IntroductionNode::NodeId(service_node_id)); + } + + // Route Invoice: client -> service -> 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 processes the invoice and starts the payment automatically + 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()); + + // Payment goes to service via the blinded payment path (through 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(); + + // Service intercepts the HTLC (destined for intercept SCID) + 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), + }; + + // Service emits OpenChannel event with the correct fee deduction + 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), + }; + + // Create JIT channel with manual broadcast (client_trusts_lsp = true) + 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 marks channel as ready and forwards the intercepted HTLC + service_handler.channel_ready(user_channel_id, &channel_id, &client_node_id).unwrap(); + service_node.inner.node.process_pending_htlc_forwards(); + + // Forward the HTLC to client on the JIT channel + 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(); + + // Client sees PaymentClaimable + 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), + }; + + // Before client claims, service should not have broadcasted the funding tx + 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 claims the payment + client_node.inner.node.claim_funds(preimage.unwrap()); + + claim_and_assert_forwarded_only( + &payer_node, + &service_node.inner, + &client_node.inner, + preimage.unwrap(), + ); + + // Service should emit PaymentForwarded + 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_node_id, + next_node_id, + skimmed_fee_msat, + total_fee_earned_msat, + .. + } => { + assert_eq!(prev_node_id, Some(payer_node_id)); + assert_eq!(next_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]), + }; + + // Service should have broadcasted the funding tx after client claimed + let broadcasted = service_node.inner.tx_broadcaster.txn_broadcasted.lock().unwrap(); + assert!(broadcasted.iter().any(|b| b.compute_txid() == funding_tx.compute_txid())); + + // Payer should have the PaymentSent event + expect_payment_sent(&payer_node, preimage.unwrap(), Some(total_fee_msat), true, true); +} + fn claim_and_assert_forwarded_only<'a, 'b, 'c>( payer_node: &lightning::ln::functional_test_utils::Node<'a, 'b, 'c>, service_node: &lightning::ln::functional_test_utils::Node<'a, 'b, 'c>,