Skip to content

Add BOLT12 support to LSPS2 via custom Router implementation#4463

Open
tnull wants to merge 7 commits intolightningdevkit:mainfrom
tnull:2026-03-lsps2-bolt12-alt
Open

Add BOLT12 support to LSPS2 via custom Router implementation#4463
tnull wants to merge 7 commits intolightningdevkit:mainfrom
tnull:2026-03-lsps2-bolt12-alt

Conversation

@tnull
Copy link
Contributor

@tnull tnull commented Mar 5, 2026

Closes #4272.

This is an alternative approach to #4394 which leverages a custom Router implementation on the client side to inject the respective.

LDK Node integration PR over at lightningdevkit/ldk-node#817

@tnull tnull requested review from TheBlueMatt and jkczyz March 5, 2026 13:36
@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Mar 5, 2026

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 2cb0546 to 25ab3bc Compare March 5, 2026 14:05
&self, payment_context: &PaymentContext,
) -> Option<LSPS2Bolt12InvoiceParameters> {
// We intentionally only match `Bolt12Offer` here and not `AsyncBolt12Offer`, as LSPS2
// JIT channels are not applicable to async (always-online) BOLT12 offer flows.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is true? We need to support JIT opening for async offers as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, should have formulated that better, but IMO that is a next/follow-up step somewhat orthogonal to this PR?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can do it in a separate PR indeed, but I'm not really sure LSPS2 support for BOLT12 only for always-online nodes is nearly as useful has for async recipients. ISTM the second part is the more important usecase.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The big difference is that there are other LSPS2 (client and service) implementations out there that LSPs are running, while async payments isn't deployed at all yet, and will require both sides to be LDK for the time being.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean that's fair but are there other LSPS servers that support intercepting blinded paths and doing a JIT channel? I imagine we'll in practice require LDK for both ends for that as well.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In any case my point is that both sides are a similar priority, not that they have to happen in one PR.

Copy link
Contributor Author

@tnull tnull Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explored this further, but it seems there might be a conflict between approaches here:

No receive_async_via_jit_channel() API — receive_async() creates offers via ChannelManager::get_async_receive_offer() which bypasses the LSPS2 router entirely. The static invoice's payment paths don't use the intercept SCID.

So to add BOLT12-async-payments-via-LSPS2-JIT support we might need to reconsider how we could inject the respective data into the blinded paths. Not sure if @valentinewallace would have an opinion here.

Also, to quote Claude:

The simplest approach: the LSPS2 buy dance happens on the client side, before the static invoice is created. The client:

  1. Calls an LSPS2 buy request to get intercept_scid + cltv_expiry_delta
  2. Calls router.register_offer_nonce(offer_nonce, params)
  3. Then triggers the static invoice creation flow

Since the LSPS2BOLT12Router is both the payment router and the message router, when create_static_invoice_for_server() calls router.create_blinded_payment_paths() with AsyncBolt12OfferContext { offer_nonce }, the router finds the registered nonce and injects the intercept SCID.

But there's a problem: the static invoice is created on the server side (LSP), not the client side. The server calls create_static_invoice_for_server() which calls its own router. The client's router registration is irrelevant — it's the server's router that builds the payment paths.

So either:

  • (A) The server (LSP) needs to know about the LSPS2 intercept SCID for this client and register it on its own router before creating the static invoice. This means the LSPS2 buy flow needs to complete before static invoice creation, and the server must register the result on its router.
  • (B) The client creates the static invoice itself (not the server), but that's not how async payments work.
  • (C) Add a callback/hook in create_static_invoice_for_server() that lets the server inject custom payment paths.

Option (A) seems most natural: the LSP (as LSPS2 service) already knows about the client's intercept SCIDs. When the server creates the static invoice for a client, it could register the intercept SCID on its router so the payment paths go through the JIT channel. But this requires the LSP
to proactively register offer nonces for each client's async offers.

.entry(next_node_id)
.or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new()));

let should_intercept = self.intercept_messages_for_offline_peers
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shoulnd't we also expand interception to unknown SCIDs for blinded message path creation prior to channel open? I guess its not critical for this to work but it would make the generated offers much smaller as we'd be able to use the SCID encoding rather than pubkey encoding.

Copy link
Contributor Author

@tnull tnull Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't go this way for now, but we do allow registering intercept SCIDs now. Note that a wildcard intercept without having the user pre-register a publickey won't work without breaking the Event::OnionMessageIntercepted API.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'd rather break the OnionMessageIntercepted API? If we're woried about too much event generation and not wanting to use the event path fine, but we currently will intercept for any next-hop that is by public key, it seems perfectly reasonable to just extend that to scid nexthops, which would simplify this patch a lot.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't want to pre-register a (node_id,scid) tuple, the API will be very odd, as the user will still need to track that elsewhere. Then, OnionMessageIntercepted would bubble up a SCID that the user then would need to know how to translate to a node id, before calling forward_onion_message.

Plus, I fear that this opens the door for anybody to easily DoS us, even without knowing a specific SCID or node id? Arguably, we might want to switch to a pre-registration mode for async payments, too, so that an attacker would at least need to know a specific peer or SCID to be able to DoS us?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't want to pre-register a (node_id,scid) tuple, the API will be very odd, as the user will still need to track that elsewhere.

I don't see how this is more odd than doing it inside the OnionMessenger, which feels like entirely the wrong place.

Plus, I fear that this opens the door for anybody to easily DoS us, even without knowing a specific SCID or node id? Arguably, we might want to switch to a pre-registration mode for async payments, too, so that an attacker would at least need to know a specific peer or SCID to be able to DoS us?

I don't see how this is materially different. It presumably doesn't take much effort to get a single offer through an LSP, so requiring that just seems like security theater for most setups.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline, landed on moving to intercept everything for unknown SCIDs, but also to apply more OM DoS protection very soon.

@tnull tnull moved this to Goal: Merge in Weekly Goals Mar 5, 2026
@tnull tnull self-assigned this Mar 5, 2026
@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @jkczyz! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

Comment on lines +33 to +40
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,
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be too expensive to store this in the Offer's blinded path? Though I suppose the Router doesn't have access to that, so we'd have to provide it the MessageContext.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine it would be. Adding yet another 45 bytes might be a bit much w.r.t. to QR encoding?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, that would be and additional 72 bytes more when encoded as bech32.

Maybe a compact representation (SCID and direction) could be used similar to what we do in blinded paths? That would use 9 bytes instead of 33 for the pubkey, so 21 bytes instead of 45. Encoded that would be 33/34 more bytes instead of 72.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you expand on what exactly you imagine we store? And is this mostly around not requiring the client to remember anything outside the offer locally?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was thinking we wouldn't need to make a custom Router or use any additional storage for Offer registration. Instead, it would be something like:

  • Include LSPS2Bolt12InvoiceParameters in an Offer's blinded paths using MessageContext when building the Offer.
  • When InvoiceRequest is received, extract the parameters from the MessageContext if set.
  • Use them to determine how to build the BlindedPaymentPaths.

(Alternatively, given the InvoiceRequest contains the Offer's message paths, if the LSP is the introduction node, we can use that directly instead of storing it in the MessageContext. Then, we'd just need the intercept_scid and cltv_expiry_delta as additional data.)

For the last step, we could be either (a) bypass the Router entirely and directly build the BlindedPaymentPath from the parameters, (b) pass Option<MessageContext> to Router and implement DefaultRouter to recognize it, or (c) something similar but with a different interface (e.g., passing Option instead).

We could use the IntroductionNode::DirectedShortChannelId directly in the BlindedPaymentPath, too, or look it up and use IntroductionNode::NodeId. I believe we currently support routing over the former but don't yet support creating them unlike for BlindedMessagePath.

@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 25ab3bc to 5786409 Compare March 24, 2026 14:34
@ldk-claude-review-bot
Copy link
Collaborator

ldk-claude-review-bot commented Mar 24, 2026

No new issues found.

All five major bugs identified in prior review passes have been resolved in the current version:

  1. Deadlock in htlc_intercepted (service.rs:1039) — Fixed via .copied() to drop the read guard before entering the block
  2. cleanup_intercept_scids dead code (service.rs:805) — Now wired in at all SCID lifecycle points (htlc_intercepted error, channel_open_abandoned, do_persist, peer_disconnected)
  3. Unused BestBlock import — Removed
  4. SCID leak race in do_persist (service.rs:1882) — Fixed by calling cleanup for all pruned SCIDs before the peer removal loop, plus full peer cleanup after removal
  5. Missing cleanup in channel_open_abandoned (service.rs:1376) — Fixed

Previously noted low-severity observations (unchanged, not re-posted as inline comments):

  • lightning-liquidity/src/lsps2/router.rs:220params.values().find() nondeterminism when multiple offers share the same LSP peer
  • lightning-liquidity/src/lsps2/service.rs:803-804 — Minor doc comment could be more precise

@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 5786409 to 98a9e9d Compare March 24, 2026 14:50
@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch 2 times, most recently from 8800d48 to 7ca886d Compare March 24, 2026 15:14
@codecov
Copy link

codecov bot commented Mar 24, 2026

Codecov Report

❌ Patch coverage is 79.22078% with 112 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.17%. Comparing base (12edb7d) to head (3acf915).

Files with missing lines Patch % Lines
lightning-liquidity/src/lsps2/router.rs 77.63% 72 Missing ⚠️
lightning-liquidity/src/lsps2/service.rs 80.95% 26 Missing and 2 partials ⚠️
lightning/src/onion_message/messenger.rs 71.05% 11 Missing ⚠️
lightning-liquidity/src/manager.rs 94.44% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4463      +/-   ##
==========================================
- Coverage   86.20%   86.17%   -0.04%     
==========================================
  Files         160      161       +1     
  Lines      107545   108041     +496     
  Branches   107545   108041     +496     
==========================================
+ Hits        92707    93101     +394     
- Misses      12214    12308      +94     
- Partials     2624     2632       +8     
Flag Coverage Δ
tests 86.17% <79.22%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 7ca886d to 2ff16d7 Compare March 25, 2026 08:23
@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch 4 times, most recently from bcc4e10 to 5602e07 Compare March 25, 2026 12:27
@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch 2 times, most recently from ea05389 to 3acf915 Compare March 25, 2026 13:24
@tnull
Copy link
Contributor Author

tnull commented Mar 25, 2026

Alright, this is now rebased, and I also switched to defaulting to inject the SCID to the blinded path, not the pubkey, while the service part of it should still allow for either to be used. Also played some rounds of AI ping-pong on both ends, so this should be good for another look.

@tnull tnull requested review from TheBlueMatt and jkczyz March 25, 2026 13:26
@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 3acf915 to c4511d5 Compare March 26, 2026 07:48
tnull added 7 commits March 26, 2026 09:01
Let callers register specific peers for offline onion-message
interception and emit dedicated events when a message is intercepted or
a tracked peer reconnects. This gives LSPS2 service flows a narrower
alternative to blanket offline-peer interception.

Co-Authored-By: HAL 9000
Expose `OnionMessageInterceptor` through `LiquidityManager` and use it
from `LSPS2ServiceHandler` to register peers that have active JIT
sessions. This lets the LSP store and forward onion messages only for
LSPS2 clients that currently need interception.

Co-Authored-By: HAL 9000
Signed-off-by: Elias Rohrer <dev@tnull.de>
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
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
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
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
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
@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from c4511d5 to e7047c5 Compare March 26, 2026 08:01
@TheBlueMatt TheBlueMatt added this to the 0.3 milestone Mar 26, 2026
.entry(next_node_id)
.or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new()));

let should_intercept = self.intercept_messages_for_offline_peers
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I'd rather break the OnionMessageIntercepted API? If we're woried about too much event generation and not wanting to use the event path fine, but we currently will intercept for any next-hop that is by public key, it seems perfectly reasonable to just extend that to scid nexthops, which would simplify this patch a lot.

};

// 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads like dummy hops are only protecting the recipient from the introduction point de-anonymizing them. Maybe mention that we've already exposed who our LSP is so its not really surprising that we'd use them here?

Copy link
Contributor

@jkczyz jkczyz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, this could be a gap in my knowledge, but as mentioned in some of the comments, why do we need onion message interception for this? The rationale doesn't appear to be clearly spelled out anywhere.

/// 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,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be u16?

Comment on lines +33 to +40
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,
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was thinking we wouldn't need to make a custom Router or use any additional storage for Offer registration. Instead, it would be something like:

  • Include LSPS2Bolt12InvoiceParameters in an Offer's blinded paths using MessageContext when building the Offer.
  • When InvoiceRequest is received, extract the parameters from the MessageContext if set.
  • Use them to determine how to build the BlindedPaymentPaths.

(Alternatively, given the InvoiceRequest contains the Offer's message paths, if the LSP is the introduction node, we can use that directly instead of storing it in the MessageContext. Then, we'd just need the intercept_scid and cltv_expiry_delta as additional data.)

For the last step, we could be either (a) bypass the Router entirely and directly build the BlindedPaymentPath from the parameters, (b) pass Option<MessageContext> to Router and implement DefaultRouter to recognize it, or (c) something similar but with a different interface (e.g., passing Option instead).

We could use the IntroductionNode::DirectedShortChannelId directly in the BlindedPaymentPath, too, or look it up and use IntroductionNode::NodeId. I believe we currently support routing over the former but don't yet support creating them unlike for BlindedMessagePath.

inner_router: R,
inner_message_router: MR,
entropy_source: ES,
offer_to_invoice_params: Mutex<HashMap<[u8; 32], LSPS2Bolt12InvoiceParameters>>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use OfferId?

Comment on lines +46 to +47
/// A router wrapper that injects LSPS2-specific BOLT12 blinded paths for registered offer ids
/// while delegating all other routing behavior to the inner routers.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be more specific that this is for blinded path creation operations. Path finding is not affected.

Comment on lines +49 to +50
/// 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"injects" is probably not the right terminology. IIUC, it bypasses the wrapped router entirely, unlike the one for message paths.

///
/// [`OnionMessageInterceptor::register_scid_for_interception`]: lightning::onion_message::messenger::OnionMessageInterceptor::register_scid_for_interception
pub struct LSPS2BOLT12Router<R: Router, MR: MessageRouter, ES: EntropySource + Send + Sync> {
inner_router: R,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapping NullMessageRouter essentially makes the wrapper a no-opt. That's fine since we can't support the "no blinded path" use case, but may not be obvious.

}
peer
})
.collect()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to re-allocate. We can modify in-place instead.

Comment on lines +211 to +212
// We use the first matching intercept SCID for each peer since the message path
// is only used for routing InvoiceRequests, not for payment interception.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like a non sequitur.

Comment on lines +52 to +54
/// 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose of injecting isn't smaller QR code size. It's so that the intercept_scid can be used, IIUC.

Comment on lines +54 to +56
/// 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mentioned below: do InvoiceRequest's need to be intercepted?

@TheBlueMatt
Copy link
Collaborator

Ok, this could be a gap in my knowledge, but as mentioned in some of the comments, why do we need onion message interception for this?

If the peer is offline and we receive an invoice-request, we need to be able to use LSPS5 to send a notification to the client and wake them up to get them to respond.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Goal: Merge

Development

Successfully merging this pull request may close these issues.

BOLT 12 support for bLIP-52/LSPS2

5 participants