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
dns_resolver_handler: DRH,
custom_handler: CMH,
intercept_messages_for_offline_peers: bool,
peers_registered_for_interception: Mutex<HashSet<PublicKey>>,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why not just ignore the events for peers that are offline? Not quite sure I get why we need to move the filtering logic into OnionMessenger.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wouldn't we need this for example for LSPS5? I.e., we'd need to intercept the messages, then wake up the the peer via notification, then forward the messages?

&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.

@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?

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
@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

Review Summary

Previously flagged issues — status update

  • Deadlock in htlc_interceptedFIXED. The read lock on peer_by_intercept_scid is now properly dropped before the if let block body by using .copied() at lines 1043-1044.
  • Unused BestBlock importFIXED. Removed from the imports.
  • values().find() ambiguity in create_blinded_paths MessageRouter impl (router.rs:220) — Still applicable. When multiple offers share the same LSP but different intercept SCIDs, values().find() picks an arbitrary one.

New finding (cross-cutting, not on a diff line)

Missing cleanup_intercept_scids call in channel_open_abandonedservice.rs:1373 removes the intercept SCID from per-peer outbound_channels_by_intercept_scid but doesn't call cleanup_intercept_scids. This means:

  1. The peer_by_intercept_scid handler-level map entry leaks (pre-existing issue).
  2. The onion message interceptor registration for that SCID leaks (new issue introduced by this PR, since the PR registers SCIDs on the interceptor at line 966-972 in buy_response but doesn't deregister them here).

The fix would be to add a cleanup_intercept_scids call after line 1375, similar to the error path in htlc_intercepted at line 1106. Since this code isn't in the diff, I couldn't post an inline comment.

A similar gap exists in prune_expired_request_state (line 636), though that case is partially mitigated by full peer cleanup in do_persist when the peer eventually becomes prunable.

@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 5786409 to 98a9e9d Compare March 24, 2026 14:50
Comment on lines +1104 to +1110
let has_remaining =
!peer_state.outbound_channels_by_intercept_scid.is_empty();
self.cleanup_intercept_scids(
counterparty_node_id,
&[intercept_scid],
has_remaining,
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Bug: Deadlock — The RwLockReadGuard from self.peer_by_intercept_scid.read() at the enclosing if let (line 1043-1044) has its lifetime extended to the end of the if let block because counterparty_node_id is a &PublicKey borrowed from it. When this error path calls cleanup_intercept_scids, that method attempts to acquire self.peer_by_intercept_scid.write() (line 804). Since the read lock is still held on the same thread, this will deadlock.

Fix: copy the PublicKey out of the read guard so the guard can be dropped before entering the block body. Change the if let at line 1043-1044 to:

let counterparty_node_id =
    self.peer_by_intercept_scid.read().unwrap().get(&intercept_scid).copied();
if let Some(counterparty_node_id) = counterparty_node_id
{

Then update all *counterparty_node_id dereferences to just counterparty_node_id inside the block (it's already a PublicKey rather than &PublicKey).

Bolt12OfferContext, PaymentConstraints, PaymentContext, ReceiveTlvs,
};
use lightning::blinded_path::NodeIdLookUp;
use lightning::chain::BestBlock;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Unused import — BestBlock is imported but never used in this file. This will produce a compiler warning (and may fail CI depending on configuration).

tnull added 6 commits March 24, 2026 16:09
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<dyn OnionMessageInterceptor>`. 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
Introduce a router wrapper that maps BOLT12 offer ids to LSPS2 invoice parameters and injects intercept-SCID blinded payment paths while delegating all other routing logic to an inner router.

Co-Authored-By: HAL 9000
Clarify that InvoiceParametersReady supports BOLT11 route hints and BOLT12 offer flows via LSPS2BOLT12Router registration.

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`. This validates the custom-router wiring used for LSPS2 + `BOLT12`.

Co-Authored-By: HAL 9000
Allow tests to provide a  override that receives the caller's , enabling custom blinded-path generation while preserving valid  bindings.

Co-Authored-By: HAL 9000
Exercise the full  flow through onion-message invoice exchange, , JIT channel opening, and settlement to confirm  paths integrate with LSPS2 service handling.

Co-Authored-By: HAL 9000
@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
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