diff --git a/fuzz/src/lsps_message.rs b/fuzz/src/lsps_message.rs
index 8ff85d0fc24..5124b74b8bd 100644
--- a/fuzz/src/lsps_message.rs
+++ b/fuzz/src/lsps_message.rs
@@ -21,7 +21,7 @@ use lightning::util::test_utils::{
};
use lightning_liquidity::lsps0::ser::LSPS_MESSAGE_TYPE_ID;
-use lightning_liquidity::LiquidityManagerSync;
+use lightning_liquidity::{DummyOnionMessageInterceptor, LiquidityManagerSync};
use core::time::Duration;
@@ -87,6 +87,7 @@ pub fn do_test(data: &[u8]) {
Arc::clone(&tx_broadcaster),
None,
None,
+ DummyOnionMessageInterceptor,
)
.unwrap(),
);
diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs
index 4d6e770c099..15d67a2de98 100644
--- a/lightning-background-processor/src/lib.rs
+++ b/lightning-background-processor/src/lib.rs
@@ -74,6 +74,8 @@ use lightning_rapid_gossip_sync::RapidGossipSync;
use lightning_liquidity::ALiquidityManager;
#[cfg(feature = "std")]
use lightning_liquidity::ALiquidityManagerSync;
+#[cfg(not(c_bindings))]
+use lightning_liquidity::DummyOnionMessageInterceptor;
use core::ops::Deref;
use core::time::Duration;
@@ -463,6 +465,7 @@ pub const NO_LIQUIDITY_MANAGER: Option<
BroadcasterInterface = &(dyn lightning::chain::chaininterface::BroadcasterInterface
+ Send
+ Sync),
+ OMI = DummyOnionMessageInterceptor,
> + Send
+ Sync,
>,
@@ -485,6 +488,7 @@ pub const NO_LIQUIDITY_MANAGER_SYNC: Option<
BroadcasterInterface = &(dyn lightning::chain::chaininterface::BroadcasterInterface
+ Send
+ Sync),
+ OMI = DummyOnionMessageInterceptor,
> + Send
+ Sync,
>,
@@ -789,6 +793,7 @@ use futures_util::{dummy_waker, Joiner, OptionalSelector, Selector, SelectorOutp
/// # use core::future::Future;
/// # use core::pin::Pin;
/// # use lightning_liquidity::utils::time::TimeProvider;
+/// # use lightning_liquidity::DummyOnionMessageInterceptor;
/// # struct Logger {}
/// # impl lightning::util::logger::Logger for Logger {
/// # fn log(&self, _record: lightning::util::logger::Record) {}
@@ -831,7 +836,7 @@ use futures_util::{dummy_waker, Joiner, OptionalSelector, Selector, SelectorOutp
/// # type P2PGossipSync
= lightning::routing::gossip::P2PGossipSync, Arc, Arc>;
/// # type ChannelManager = lightning::ln::channelmanager::SimpleArcChannelManager, B, FE, Logger>;
/// # type OnionMessenger = lightning::onion_message::messenger::OnionMessenger, Arc, Arc, Arc>, Arc, Arc, Arc>>, Arc>, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler, lightning::ln::peer_handler::IgnoringMessageHandler>;
-/// # type LiquidityManager = lightning_liquidity::LiquidityManager, Arc, Arc>, Arc, Arc, Arc>;
+/// # type LiquidityManager = lightning_liquidity::LiquidityManager, Arc, Arc>, Arc, Arc, Arc, DummyOnionMessageInterceptor>;
/// # type Scorer = RwLock, Arc>>;
/// # type PeerManager = lightning::ln::peer_handler::SimpleArcPeerManager, B, FE, Arc, Logger, F, StoreSync>;
/// # type OutputSweeper = lightning::util::sweep::OutputSweeper, Arc, Arc, Arc, Arc, Arc, Arc>;
@@ -1972,7 +1977,9 @@ mod tests {
use lightning::util::test_utils;
use lightning::{get_event, get_event_msg};
use lightning_liquidity::utils::time::DefaultTimeProvider;
- use lightning_liquidity::{ALiquidityManagerSync, LiquidityManager, LiquidityManagerSync};
+ use lightning_liquidity::{
+ ALiquidityManagerSync, DummyOnionMessageInterceptor, LiquidityManager, LiquidityManagerSync,
+ };
use lightning_persister::fs_store::v1::FilesystemStore;
use lightning_rapid_gossip_sync::RapidGossipSync;
use std::collections::VecDeque;
@@ -2556,6 +2563,7 @@ mod tests {
Arc::clone(&tx_broadcaster),
None,
None,
+ DummyOnionMessageInterceptor,
)
.unwrap(),
);
diff --git a/lightning-liquidity/src/lib.rs b/lightning-liquidity/src/lib.rs
index 2f1d5bd01e7..e2de3bb1655 100644
--- a/lightning-liquidity/src/lib.rs
+++ b/lightning-liquidity/src/lib.rs
@@ -74,6 +74,6 @@ mod tests;
pub mod utils;
pub use manager::{
- ALiquidityManager, ALiquidityManagerSync, LiquidityClientConfig, LiquidityManager,
- LiquidityManagerSync, LiquidityServiceConfig,
+ ALiquidityManager, ALiquidityManagerSync, DummyOnionMessageInterceptor, LiquidityClientConfig,
+ LiquidityManager, LiquidityManagerSync, LiquidityServiceConfig,
};
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`].
diff --git a/lightning-liquidity/src/lsps2/mod.rs b/lightning-liquidity/src/lsps2/mod.rs
index 1d5fb76d3b4..684ad9b26f7 100644
--- a/lightning-liquidity/src/lsps2/mod.rs
+++ b/lightning-liquidity/src/lsps2/mod.rs
@@ -13,5 +13,6 @@ pub mod client;
pub mod event;
pub mod msgs;
pub(crate) mod payment_queue;
+pub mod router;
pub mod service;
pub mod utils;
diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs
new file mode 100644
index 00000000000..74832739f04
--- /dev/null
+++ b/lightning-liquidity/src/lsps2/router.rs
@@ -0,0 +1,540 @@
+// This file is Copyright its original authors, visible in version control
+// history.
+//
+// This file is licensed under the Apache License, Version 2.0 or the MIT license
+// , at your option.
+// You may not use this file except in accordance with one or both of these
+// licenses.
+
+//! Router helpers for combining LSPS2 with BOLT12 offer flows.
+
+use alloc::vec::Vec;
+
+use crate::prelude::{new_hash_map, HashMap};
+use crate::sync::Mutex;
+
+use bitcoin::secp256k1::{self, PublicKey, Secp256k1};
+
+use lightning::blinded_path::message::{
+ BlindedMessagePath, MessageContext, MessageForwardNode, OffersContext,
+};
+use lightning::blinded_path::payment::{
+ BlindedPaymentPath, Bolt12OfferContext, ForwardTlvs, PaymentConstraints, PaymentContext,
+ PaymentForwardNode, PaymentRelay, ReceiveTlvs,
+};
+use lightning::ln::channel_state::ChannelDetails;
+use lightning::ln::channelmanager::{PaymentId, MIN_FINAL_CLTV_EXPIRY_DELTA};
+use lightning::offers::offer::OfferId;
+use lightning::onion_message::messenger::{Destination, MessageRouter, OnionMessagePath};
+use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router};
+use lightning::sign::{EntropySource, ReceiveAuthKey};
+use lightning::types::features::BlindedHopFeatures;
+use lightning::types::payment::PaymentHash;
+
+/// LSPS2 invoice parameters required to construct BOLT12 blinded payment paths through an LSP.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct LSPS2Bolt12InvoiceParameters {
+ /// The LSP node id to use as the blinded path introduction node.
+ pub counterparty_node_id: PublicKey,
+ /// The LSPS2 intercept short channel id.
+ pub intercept_scid: u64,
+ /// The CLTV expiry delta the LSP requires for forwarding over `intercept_scid`.
+ pub cltv_expiry_delta: u32,
+}
+
+/// A router wrapper that injects LSPS2-specific BOLT12 blinded paths for registered offer ids
+/// while delegating all other routing behavior to the inner routers.
+///
+/// For **payment** blinded paths (in invoices), it injects the intercept SCID as the forwarding
+/// hop so that the LSP can intercept the HTLC and open a JIT channel.
+///
+/// For **message** blinded paths (in offers), it injects the intercept SCID as the
+/// [`MessageForwardNode::short_channel_id`] for compact encoding, resulting in significantly
+/// smaller offers when bech32-encoded (e.g., for QR codes). The LSP must register the intercept
+/// SCID for interception via [`OnionMessageInterceptor::register_scid_for_interception`] so that
+/// forwarded messages using the compact encoding are intercepted rather than dropped.
+///
+/// [`OnionMessageInterceptor::register_scid_for_interception`]: lightning::onion_message::messenger::OnionMessageInterceptor::register_scid_for_interception
+pub struct LSPS2BOLT12Router {
+ inner_router: R,
+ inner_message_router: MR,
+ entropy_source: ES,
+ offer_to_invoice_params: Mutex>,
+}
+
+impl LSPS2BOLT12Router {
+ /// Constructs a new wrapper around `inner_router` and `inner_message_router`.
+ pub fn new(inner_router: R, inner_message_router: MR, entropy_source: ES) -> Self {
+ Self {
+ inner_router,
+ inner_message_router,
+ entropy_source,
+ offer_to_invoice_params: Mutex::new(new_hash_map()),
+ }
+ }
+
+ /// Registers LSPS2 parameters to be used when generating blinded payment paths for `offer_id`.
+ pub fn register_offer(
+ &self, offer_id: OfferId, invoice_params: LSPS2Bolt12InvoiceParameters,
+ ) -> Option {
+ self.offer_to_invoice_params.lock().unwrap().insert(offer_id.0, invoice_params)
+ }
+
+ /// Removes any previously registered LSPS2 parameters for `offer_id`.
+ pub fn unregister_offer(&self, offer_id: &OfferId) -> Option {
+ self.offer_to_invoice_params.lock().unwrap().remove(&offer_id.0)
+ }
+
+ /// Clears all LSPS2 parameters previously registered via [`Self::register_offer`].
+ pub fn clear_registered_offers(&self) {
+ self.offer_to_invoice_params.lock().unwrap().clear();
+ }
+
+ fn registered_lsps2_params(
+ &self, payment_context: &PaymentContext,
+ ) -> Option {
+ // We intentionally only match `Bolt12Offer` here and not `AsyncBolt12Offer`, as LSPS2
+ // JIT channels are not applicable to async (always-online) BOLT12 offer flows.
+ let Bolt12OfferContext { offer_id, .. } = match payment_context {
+ PaymentContext::Bolt12Offer(context) => context,
+ _ => return None,
+ };
+
+ self.offer_to_invoice_params.lock().unwrap().get(&offer_id.0).copied()
+ }
+}
+
+impl Router
+ for LSPS2BOLT12Router
+{
+ fn find_route(
+ &self, payer: &PublicKey, route_params: &RouteParameters,
+ first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs,
+ ) -> Result {
+ self.inner_router.find_route(payer, route_params, first_hops, inflight_htlcs)
+ }
+
+ fn find_route_with_id(
+ &self, payer: &PublicKey, route_params: &RouteParameters,
+ first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs,
+ payment_hash: PaymentHash, payment_id: PaymentId,
+ ) -> Result {
+ self.inner_router.find_route_with_id(
+ payer,
+ route_params,
+ first_hops,
+ inflight_htlcs,
+ payment_hash,
+ payment_id,
+ )
+ }
+
+ fn create_blinded_payment_paths(
+ &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey,
+ first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option,
+ secp_ctx: &Secp256k1,
+ ) -> Result, ()> {
+ let lsps2_invoice_params = match self.registered_lsps2_params(&tlvs.payment_context) {
+ Some(params) => params,
+ None => {
+ return self.inner_router.create_blinded_payment_paths(
+ recipient,
+ local_node_receive_key,
+ first_hops,
+ tlvs,
+ amount_msats,
+ secp_ctx,
+ )
+ },
+ };
+
+ let payment_relay = PaymentRelay {
+ cltv_expiry_delta: u16::try_from(lsps2_invoice_params.cltv_expiry_delta)
+ .map_err(|_| ())?,
+ fee_proportional_millionths: 0,
+ fee_base_msat: 0,
+ };
+ let payment_constraints = PaymentConstraints {
+ max_cltv_expiry: tlvs
+ .payment_constraints
+ .max_cltv_expiry
+ .saturating_add(lsps2_invoice_params.cltv_expiry_delta),
+ htlc_minimum_msat: 0,
+ };
+
+ let forward_node = PaymentForwardNode {
+ tlvs: ForwardTlvs {
+ short_channel_id: lsps2_invoice_params.intercept_scid,
+ payment_relay,
+ payment_constraints,
+ features: BlindedHopFeatures::empty(),
+ next_blinding_override: None,
+ },
+ node_id: lsps2_invoice_params.counterparty_node_id,
+ htlc_maximum_msat: u64::MAX,
+ };
+
+ // We deliberately use `BlindedPaymentPath::new` without dummy hops here. Since the LSP
+ // is the introduction node and already knows the recipient, adding dummy hops would not
+ // provide meaningful privacy benefits in the LSPS2 JIT channel context.
+ let path = BlindedPaymentPath::new(
+ &[forward_node],
+ recipient,
+ local_node_receive_key,
+ tlvs,
+ u64::MAX,
+ MIN_FINAL_CLTV_EXPIRY_DELTA,
+ &self.entropy_source,
+ secp_ctx,
+ )?;
+
+ Ok(vec![path])
+ }
+}
+
+impl MessageRouter
+ for LSPS2BOLT12Router
+{
+ fn find_path(
+ &self, sender: PublicKey, peers: Vec, destination: Destination,
+ ) -> Result {
+ self.inner_message_router.find_path(sender, peers, destination)
+ }
+
+ fn create_blinded_paths(
+ &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey,
+ context: MessageContext, peers: Vec, secp_ctx: &Secp256k1,
+ ) -> Result, ()> {
+ // Inject intercept SCIDs for size-constrained contexts (offer QR codes) so that
+ // the message blinded path uses compact SCID encoding instead of full pubkeys.
+ // We use the first matching intercept SCID for each peer since the message path
+ // is only used for routing InvoiceRequests, not for payment interception.
+ let peers = match &context {
+ MessageContext::Offers(OffersContext::InvoiceRequest { .. }) => {
+ let params = self.offer_to_invoice_params.lock().unwrap();
+ peers
+ .into_iter()
+ .map(|mut peer| {
+ if let Some(p) =
+ params.values().find(|p| p.counterparty_node_id == peer.node_id)
+ {
+ peer.short_channel_id = Some(p.intercept_scid);
+ }
+ peer
+ })
+ .collect()
+ },
+ _ => peers,
+ };
+
+ self.inner_message_router.create_blinded_paths(
+ recipient,
+ local_node_receive_key,
+ context,
+ peers,
+ secp_ctx,
+ )
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{LSPS2BOLT12Router, LSPS2Bolt12InvoiceParameters};
+
+ use bitcoin::network::Network;
+ use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey};
+
+ use lightning::blinded_path::payment::{
+ Bolt12OfferContext, Bolt12RefundContext, PaymentConstraints, PaymentContext, ReceiveTlvs,
+ };
+ use lightning::blinded_path::NodeIdLookUp;
+ use lightning::ln::channel_state::ChannelDetails;
+ use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA;
+ use lightning::offers::invoice_request::InvoiceRequestFields;
+ use lightning::offers::offer::OfferId;
+ use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router};
+ use lightning::sign::{EntropySource, NodeSigner, ReceiveAuthKey, Recipient};
+ use lightning::types::payment::PaymentSecret;
+ use lightning::util::test_utils::TestKeysInterface;
+
+ use crate::sync::Mutex;
+
+ use core::sync::atomic::{AtomicUsize, Ordering};
+
+ struct RecordingLookup {
+ next_node_id: PublicKey,
+ short_channel_id: Mutex