Skip to content

Add BOLT12 support to LSPS2 via custom Router implementation#4463

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

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

Conversation

@tnull
Copy link
Copy Markdown
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
Copy Markdown

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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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.

Copy link
Copy Markdown
Collaborator

@TheBlueMatt TheBlueMatt Mar 31, 2026

Choose a reason for hiding this comment

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

I think claude is confused. Looking at BOLT12 async payments, for example the path where we build a static invoice when handling an offer path (<ChannelManager as AsyncPaymentsMessageHandler>::handle_offer_paths) we call self.flow.handle_offer_paths(... &*self.router) which calls create_static_invoice_for_server(...router) which calls create_static_invoice_builder(&router...) which calls the same create_blinded_payments_path that is used for every other bolt12 invoice. As long as the router passed through that recognizes the PaymentContext::AsyncBolt12Offer I don't see why it wouldn't work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It indeed just admitted to me being wrong here. Now have it write a test case for async payments to see what would be necessary to actually get it to work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Now explored in tnull@b484c6a

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

🔔 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
Copy Markdown

🔔 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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
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
Copy Markdown
Contributor

@jkczyz jkczyz Mar 26, 2026

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmm, I'm still a bit skeptical regarding the size constraint, especially since we might end up mixing BOLT11, BOLT12 and onchain data in a single QR-encoded BIP21.

It seems this would mostly be useful to avoid the client having to store anything about the JIT-request? But note they already do (i.e., the negotiated LSP fees, so they can check the LSP didn't withhold more when the payment arrives), so it should be fine to also store an additional OfferId? Is there another benefit I'm not seeing?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, I'm still a bit skeptical regarding the size constraint, especially since we might end up mixing BOLT11, BOLT12 and onchain data in a single QR-encoded BIP21.

The alternative approach limits it to 10 bytes per blinded path, plus encoding overhead, FWIW.

It seems this would mostly be useful to avoid the client having to store anything about the JIT-request? But note they already do (i.e., the negotiated LSP fees, so they can check the LSP didn't withhold more when the payment arrives), so it should be fine to also store an additional OfferId? Is there another benefit I'm not seeing?

Is there any reason that data can't be store in the BlindedPaymentPath's ReceiveTlvs / PaymentContext? That data should have been authenticated.

This approach also avoids the behavior around wrapping a MessageRouter / Router, which isn't obvious.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Is there any reason that data can't be store in the BlindedPaymentPath's ReceiveTlvs / PaymentContext? That data should have been authenticated.

Well, yes, for one we also store it in the BOLT11 case which doesn't have those options available?

This approach also avoids the behavior around wrapping a MessageRouter / Router, which isn't obvious.

I guess it would also allow us to more easily resolve the current conflict with async payments, as noted above? #4463 (comment).

It seems this approach would then in parts go back to the approach of #4394, i.e., if we want to get rid of the Router/MessageRouter wrapper we'd need to surface an InvoiceRequestReceived event that the LSPS2-specific logic could act upon?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Now had Claude whip up some commits switching to that approach so we can decide whether we want to go there. Indeed it seems that we're able to drop the custom router wrapper and get async payments to work. Let me know what you think

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems this would mostly be useful to avoid the client having to store anything about the JIT-request? But note they already do (i.e., the negotiated LSP fees, so they can check the LSP didn't withhold more when the payment arrives), so it should be fine to also store an additional OfferId? Is there another benefit I'm not seeing?

Regarding the negotiated LSP fees, where do we check the LSP didn't hold too much? To make it stateless, would we need to store the expected fees in the opaque data such that it could be included in the BlindedPaymentPath?

@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
Copy Markdown
Collaborator

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

Review Summary

All previously posted inline comments have been addressed in this revision:

  • Event discriminant changed from 48 (even) to 53 (odd) -- resolved
  • All doc typos ("unkown", "implentation", "should always been set", stale "peer_node_id" reference) -- resolved
  • Broken rustdoc link to OnionMessageInterceptor::register_scid_for_interception -- resolved
  • Dead code (cleanup_intercept_scids) and associated deadlock/SCID leak bugs in service.rs -- resolved (method removed)
  • Unused BestBlock import -- resolved
  • Wrong event references in router.rs comments -- resolved

Still-valid prior comments (not re-posted per instructions)

  1. lightning-liquidity/src/lsps2/router.rs:76-79from_context_data doesn't verify all bytes were consumed; trailing bytes silently ignored.

Cross-cutting concerns (unchanged from prior review)

  • Zero test coverage for the InvoiceRequestReceived event flow. The documented approach 2 (using create_offer_builder_with_context_data to embed LSPS2 params, receiving them back via Event::InvoiceRequestReceived, then building custom payment paths with create_invoice_builder_with_custom_payment_paths_*) is not exercised by any test. Both integration tests use the Router override approach instead. While the Router override approach is well-tested (both basic path construction and full end-to-end payment flow), the context_data path through OffersContext::InvoiceRequestverify_invoice_requestInvoiceRequestReceived event emission remains untested.

No new issues found in this review pass.

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

codecov bot commented Mar 24, 2026

Codecov Report

❌ Patch coverage is 77.89116% with 65 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.10%. Comparing base (12edb7d) to head (3ada94e).
⚠️ Report is 44 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/offers/flow.rs 26.31% 42 Missing ⚠️
lightning/src/events/mod.rs 20.00% 11 Missing and 1 partial ⚠️
lightning-liquidity/src/lsps2/router.rs 96.90% 1 Missing and 5 partials ⚠️
lightning/src/offers/invoice_request.rs 0.00% 3 Missing ⚠️
lightning/src/onion_message/messenger.rs 81.81% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4463      +/-   ##
==========================================
+ Coverage   86.20%   87.10%   +0.90%     
==========================================
  Files         160      164       +4     
  Lines      107545   109018    +1473     
  Branches   107545   109018    +1473     
==========================================
+ Hits        92707    94958    +2251     
+ Misses      12214    11570     -644     
+ Partials     2624     2490     -134     
Flag Coverage Δ
fuzzing 40.11% <0.00%> (?)
tests 86.19% <77.89%> (-0.01%) ⬇️

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 added 6 commits March 30, 2026 15:34
Add `intercept_unknown_scid_oms` test that verifies the
`OnionMessenger` correctly generates `OnionMessageIntercepted` events
with a `ShortChannelId` next hop when a blinded path uses an
unresolvable SCID. This complements the existing
`intercept_offline_peer_oms` test which only covers the `NodeId`
variant (offline peer case).

Co-Authored-By: HAL 9000
Add backwards compatibility tests for `Event::OnionMessageIntercepted`
serialization to verify that:

- Events serialized by LDK 0.2 (with `peer_node_id` in TLV field 0)
  can be deserialized by the current version as
  `NextMessageHop::NodeId`.
- Events with `NodeId` next hop serialized by the current version can
  be deserialized by LDK 0.2 (which reads `peer_node_id` from field
  0).
- Events with `ShortChannelId` next hop (which omit TLV field 0)
  correctly fail to deserialize in LDK 0.2, since the `peer_node_id`
  field is required there.

Co-Authored-By: HAL 9000
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
Signed-off-by: Elias Rohrer <dev@tnull.de>
@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 1210727 to aa40c59 Compare March 30, 2026 13:35
Comment on lines +231 to +237
self.inner_message_router.create_blinded_paths(
recipient,
local_node_receive_key,
context,
peers,
secp_ctx,
)
Copy link
Copy Markdown
Contributor

@jkczyz jkczyz Mar 30, 2026

Choose a reason for hiding this comment

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

Couldn't the same point me made for the Rotuer implementation?

I don't have a strong opinion on the approach yet, though I do wonder if instead of wrapping we have the user explicitly pass a custom MessageRouter when creating the Offer. They can't easily do the same for payment paths without overriding OffersMessageHandler behavior (i.e., they would need to implement it with custom logic for OffersMessageFlow). But that could be avoided with the alternative mentioned in https://github.com/lightningdevkit/rust-lightning/pull/4463/changes#r2997527775.

/// 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
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Partly, yes, but also because we later cast it to u16 using u16::try_from.

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
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Hmm, I'm still a bit skeptical regarding the size constraint, especially since we might end up mixing BOLT11, BOLT12 and onchain data in a single QR-encoded BIP21.

The alternative approach limits it to 10 bytes per blinded path, plus encoding overhead, FWIW.

It seems this would mostly be useful to avoid the client having to store anything about the JIT-request? But note they already do (i.e., the negotiated LSP fees, so they can check the LSP didn't withhold more when the payment arrives), so it should be fine to also store an additional OfferId? Is there another benefit I'm not seeing?

Is there any reason that data can't be store in the BlindedPaymentPath's ReceiveTlvs / PaymentContext? That data should have been authenticated.

This approach also avoids the behavior around wrapping a MessageRouter / Router, which isn't obvious.

///
/// [`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
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ah, sorry meant to say NodeIdMessageRouter.

if let Some(p) =
params.values().find(|p| p.counterparty_node_id == peer.node_id)
{
peer.short_channel_id = Some(p.intercept_scid);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note that when wrapping NodeIdMessageRouter this will be overridden with None. But why do we inject the intercept_scid?

Hmm, that's fine, assuming it still works when we do the override here?

We are delegating to the wrapped router after this override, so no it will be cleared.

@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from aa40c59 to 73f07bf Compare March 31, 2026 08:17
tnull added 6 commits March 31, 2026 10:30
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
Signed-off-by: Elias Rohrer <dev@tnull.de>
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 73f07bf to 833313a Compare March 31, 2026 08:31
tnull added 4 commits March 31, 2026 11:32
Allow embedding application-specific opaque bytes in the offer's blinded
message path context. When an InvoiceRequest is received, the context
data is extracted alongside the nonce, enabling stateless round-tripping
of parameters (e.g., LSP channel parameters for JIT channels) without
requiring client-side storage.

Co-Authored-By: HAL 9000
Extend `InvreqResponseInstructions::SendInvoice` to carry the opaque
`context_data` from the offer's blinded message path. This allows
callers to access application-specific context (e.g., LSP parameters)
after invoice request verification, without requiring external state.

Co-Authored-By: HAL 9000
Add `create_invoice_builder_with_custom_payment_paths_derived_psk` and
`create_invoice_builder_with_custom_payment_paths_explicit_psk` to
`OffersMessageFlow`. These accept pre-built `BlindedPaymentPath`s
instead of constructing them via a `Router`, enabling external code
(e.g., LSP integration) to build payment paths directly.

Co-Authored-By: HAL 9000
…oices

When an InvoiceRequest arrives with `context_data` in its
OffersContext, emit an `Event::InvoiceRequestReceived` instead of
auto-building the invoice. This allows external code (e.g., LSP
integration) to build custom payment paths and create the invoice
response using `OffersMessageFlow::create_invoice_builder_with_custom_payment_paths_*`.

Co-Authored-By: HAL 9000
Comment on lines +2391 to +2394
&Event::InvoiceRequestReceived { .. } => {
48u8.write(writer)?;
// Never write InvoiceRequestReceived events as buffered onion messages aren't serialized.
},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bug: Event number 48 is even, breaking backward compatibility if serialized.

All other "never persisted, no body" events use odd numbers (ConnectionNeeded=35, PersistStaticInvoice=45, StaticInvoiceRequested=47, FundingTransactionReadyForSigning=49). Odd numbers are important because the deserialization catch-all at line 3103 skips unknown odd events, while unknown even events at line 3115 return Err(DecodeError::InvalidValue).

InvoiceRequestReceived is pushed to self.pending_events in channelmanager.rs:16973, which IS serialized during ChannelManager persistence (lines 18126-18129). If the ChannelManager is persisted while this event is pending (between push and handler consumption), 48u8 is written to the byte stream. An older version that doesn't recognize event 48 will fail the entire ChannelManager deserialization.

Should use an odd number (e.g., 53 or the next available odd) for consistency and forward safety. Note that even with an odd number, the missing TLV body is a pre-existing pattern issue (same as events 45, 47), but at least the older reader would attempt to skip rather than hard-fail.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes, we should change that if we really move forward with the DRAFT commits.

Comment on lines +76 to +79
pub fn from_context_data(data: &[u8]) -> Option<Self> {
let mut cursor = lightning::io::Cursor::new(data);
Self::read(&mut cursor).ok()
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Minor: from_context_data does not check that all bytes were consumed. If data has trailing bytes beyond the expected 45 (33 pubkey + 8 u64 + 4 u32), they are silently ignored. This means slightly-corrupt or version-mismatched data could parse successfully into potentially wrong parameters.

Consider adding:

Suggested change
pub fn from_context_data(data: &[u8]) -> Option<Self> {
let mut cursor = lightning::io::Cursor::new(data);
Self::read(&mut cursor).ok()
}
pub fn from_context_data(data: &[u8]) -> Option<Self> {
let mut cursor = lightning::io::Cursor::new(data);
let result = Self::read(&mut cursor).ok()?;
if cursor.position() as usize != data.len() {
return None;
}
Some(result)
}

If forward extensibility is intentional (future versions may append fields), then this is fine as-is but should be documented.

tnull added 4 commits March 31, 2026 12:32
Use 53u8 (odd) instead of 48u8 (even) so that old readers can
gracefully skip the unknown event type.

Co-Authored-By: HAL 9000
Add `create_offer_builder_with_context_data` to `OffersMessageFlow`,
which embeds opaque bytes in the offer's `OffersContext::InvoiceRequest`.
When an `InvoiceRequest` arrives for such an offer, `ChannelManager`
emits `Event::InvoiceRequestReceived` instead of auto-responding,
allowing external code to construct custom payment paths.

Co-Authored-By: HAL 9000
Remove the Router/MessageRouter wrapper pattern in favor of:

- `LSPS2Bolt12InvoiceParameters::to_context_data()` / `from_context_data()`
  for serializing params to/from opaque bytes embedded in the offer's
  MessageContext.
- `build_lsps2_payment_paths()` standalone function for constructing
  blinded payment paths with the LSP as introduction node.

This eliminates the need for client-side HashMap storage and makes the
LSPS2 BOLT12 flow stateless — the offer itself carries everything
needed to build JIT channel payment paths.

Co-Authored-By: HAL 9000
Verify that `build_lsps2_payment_paths` works with
`PaymentContext::AsyncBolt12Offer`, which was previously unsupported
by the old `LSPS2BOLT12Router` wrapper approach. With the new
context-based design, LSPS2 payment path construction is agnostic to
the payment context type, naturally supporting both sync and async
BOLT12 flows.

Co-Authored-By: HAL 9000
@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 3a8b1c4 to 3ada94e Compare March 31, 2026 10:32
///
/// Returns a [`Bolt12SemanticError`] if the [`InvoiceBuilder`] could not be created from the
/// [`InvoiceRequest`].
pub fn create_invoice_builder_with_custom_payment_paths_derived_psk<'a, F>(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We could probably use the existing methods, having the caller pass a custom Router.

self.inner_router.find_route(payer, route_params, first_hops, inflight_htlcs)
impl Writeable for LSPS2Bolt12InvoiceParameters {
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), lightning::io::Error> {
self.counterparty_node_id.write(writer)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We could also take this from Offer::paths if they are expected to use the LSP as the introduction node.

That said, I'm not sure how much savings we care about in terms of QR size. Would these offers typically be behind a HBA (or hopefully would be in the future)?

///
/// [`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
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actually I did mean NullMessageRouter. It produces no paths, so requires that the offer's issuer_signing_pubkey is its node id.

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
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It seems this would mostly be useful to avoid the client having to store anything about the JIT-request? But note they already do (i.e., the negotiated LSP fees, so they can check the LSP didn't withhold more when the payment arrives), so it should be fine to also store an additional OfferId? Is there another benefit I'm not seeing?

Regarding the negotiated LSP fees, where do we check the LSP didn't hold too much? To make it stateless, would we need to store the expected fees in the opaque data such that it could be included in the BlindedPaymentPath?

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 2nd Reminder

Hey @TheBlueMatt! 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.

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