Skip to content

Add BOLT12 support to LSPS2 via custom Router implementation#4463

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

Add BOLT12 support to LSPS2 via custom Router implementation#4463
tnull wants to merge 18 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
@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.

@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

No new issues found beyond what was already flagged in prior review passes.

Review Summary

All significant issues were identified in previous review passes. The current revision (c37392e) has addressed several of them:

Fixed since last review:

  • MessageRouter implementation removed from LSPS2BOLT12Router, eliminating the values().find() non-determinism and HashMap collapse bugs
  • ? operator in the LSPS2 path construction loop replaced with continue, preserving valid paths
  • BestBlock unused import removed
  • Various typos and doc references fixed

Prior issues still open (not re-posted, as instructed):

  • lightning-liquidity/src/lsps2/router.rs:209Ok(paths) can return Ok(vec![]) when all LSPS2 constructions fail and inner router also fails, silently producing an invoice with zero payment paths
  • lightning-liquidity/src/lsps2/router.rs:70-73register_intercept_scid key parameter is redundant with invoice_params.intercept_scid; mismatched values cause silent key/path inconsistency
  • lightning-liquidity/src/lsps2/router.rs:55 — Stale [Event::HTLCIntercepted] rustdoc link definition is unused after MessageRouter removal

No new issues found in this review pass. The new code (router.rs, integration tests, upgrade/downgrade tests, event serialization changes) is correct and well-tested.

@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 2 commits April 2, 2026 18:43
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
The intercept SCID is the natural key for LSPS2 invoice parameters
since it directly identifies the JIT channel negotiation, whereas
offer ids are a higher-level concept that may not always be available.

Co-Authored-By: HAL 9000
@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 3999f75 to 711a50b Compare April 2, 2026 16:48
tnull added 2 commits April 2, 2026 19:00
With multiple concurrent LSPS2 flows, each registration should produce
its own blinded payment path so that each JIT channel can be opened
independently.

Co-Authored-By: HAL 9000
When LSPS2 intercept SCIDs are registered, also query the inner router
for paths through pre-existing channels. This allows payers to use
existing inbound liquidity when available rather than always triggering
a JIT channel open.

Co-Authored-By: HAL 9000
@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 711a50b to 718988d Compare April 2, 2026 17:15
Comment on lines +173 to +215
for lsps2_invoice_params in all_params {
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.clone(),
u64::MAX,
MIN_FINAL_CLTV_EXPIRY_DELTA,
&self.entropy_source,
secp_ctx,
)?;
paths.push(path);
}
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: ? in loop discards all previously accumulated paths on partial failure.

If multiple LSPS2 params are registered and one has an invalid CLTV delta (line 176) or BlindedPaymentPath::new fails (line 213), the ? operator returns Err(()) for the entire function. This discards:

  1. Valid paths from the inner router (collected at lines 161-171)
  2. Valid LSPS2 paths from earlier loop iterations

For example, with two registered SCIDs where the second has cltv_expiry_delta > u16::MAX, the first SCID's valid path and any inner router paths are all lost.

Consider skipping invalid registrations with continue instead of ?:

Suggested change
for lsps2_invoice_params in all_params {
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.clone(),
u64::MAX,
MIN_FINAL_CLTV_EXPIRY_DELTA,
&self.entropy_source,
secp_ctx,
)?;
paths.push(path);
}
for lsps2_invoice_params in all_params {
let payment_relay = match u16::try_from(lsps2_invoice_params.cltv_expiry_delta) {
Ok(cltv_expiry_delta) => PaymentRelay {
cltv_expiry_delta,
fee_proportional_millionths: 0,
fee_base_msat: 0,
},
Err(_) => continue,
};
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 = match BlindedPaymentPath::new(
&[forward_node],
recipient,
local_node_receive_key,
tlvs.clone(),
u64::MAX,
MIN_FINAL_CLTV_EXPIRY_DELTA,
&self.entropy_source,
secp_ctx,
) {
Ok(path) => path,
Err(()) => continue,
};
paths.push(path);
}

Alternatively, validate cltv_expiry_delta in register_intercept_scid so the error can't happen here.

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.

Done.

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.

DefaultRouter errors if we can't produce any paths. We should do that here or add some protection in the offers module for this.

@tnull
Copy link
Copy Markdown
Contributor Author

tnull commented Apr 2, 2026

Now updated to not register for onion message interception based on OfferId, but rather intercept scid. Plus we now merge the blinded paths with the ones returned from the inner router, so, in case we have pre-existing channels, only if the payer happens to choose the JIT path the channel will be opened. Will play around with this more on the LDK Node side, and play some more Claude-ping-pong here tomorrow. But would love some conceptual review at least (cc @TheBlueMatt @jkczyz)

Skip invalid LSPS2 registrations when building blinded payment paths so one bad entry doesn't discard otherwise usable paths.

Co-Authored-By: HAL 9000
@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 7124215 to 0a40b71 Compare April 3, 2026 09:24
Comment on lines +84 to +88
pub fn register_intercept_scid(
&self, intercept_scid: u64, invoice_params: LSPS2Bolt12InvoiceParameters,
) -> Option<LSPS2Bolt12InvoiceParameters> {
self.scid_to_invoice_params.lock().unwrap().insert(intercept_scid, invoice_params)
}
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.

Nit: The intercept_scid parameter (used as the HashMap key) is redundant with invoice_params.intercept_scid (used in the actual payment path at line 194). If a caller accidentally passes mismatched values, the payment path will use invoice_params.intercept_scid while deregister_intercept_scid requires the key value — a silent correctness bug.

Consider either:

  1. Removing the separate parameter and using invoice_params.intercept_scid as the key:
Suggested change
pub fn register_intercept_scid(
&self, intercept_scid: u64, invoice_params: LSPS2Bolt12InvoiceParameters,
) -> Option<LSPS2Bolt12InvoiceParameters> {
self.scid_to_invoice_params.lock().unwrap().insert(intercept_scid, invoice_params)
}
pub fn register_intercept_scid(
&self, invoice_params: LSPS2Bolt12InvoiceParameters,
) -> Option<LSPS2Bolt12InvoiceParameters> {
self.scid_to_invoice_params.lock().unwrap().insert(invoice_params.intercept_scid, invoice_params)
}
  1. Or adding a debug assertion that they match.

Comment on lines +173 to +215
for lsps2_invoice_params in all_params {
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.clone(),
u64::MAX,
MIN_FINAL_CLTV_EXPIRY_DELTA,
&self.entropy_source,
secp_ctx,
)?;
paths.push(path);
}
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.

DefaultRouter errors if we can't produce any paths. We should do that here or add some protection in the offers module for this.

Comment on lines +146 to +147
// Override with intercept SCIDs to have the payer use them when sending payments,
// prompting the LSP node to emit Event::HTLCIntercepted, hence triggering channel open.
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.

Comment would be better placed where the actual overriding happens. This is where it doesn't happen.

.unwrap_or_default();

for lsps2_invoice_params in all_params {
let payment_relay = match u16::try_from(lsps2_invoice_params.cltv_expiry_delta) {
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.

Probably should just make cltv_expiry_delta a u16 to avoid this case.

amount_msats,
secp_ctx,
)
.unwrap_or_default();
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.

Might be worth noting that erroring here would have prevented us from adding any JIT paths.

Comment on lines +160 to +162
// Also try the inner router for paths through existing channels, so the payer
// can use pre-existing inbound liquidity when available rather than always
// triggering a JIT channel open.
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.

May want to note that BOLT12 specifies to order the paths with the most preferred first, so this should come first. Though routers may pick based on other criteria (or use mpp).

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 3rd 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.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 4th 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.

paths.push(path);
}

Ok(paths)
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.

Nit: If every LSPS2 param hits continue (e.g., all have cltv_expiry_delta > u16::MAX) and the inner router returns Err, this returns Ok(vec![]). The caller (e.g., ChannelManager) will then build an invoice with zero payment paths — an unpayable invoice — with no error signal.

In contrast, when no intercept SCIDs are registered and the inner router fails, Err(()) is returned (line 151), which lets the caller surface the failure.

Consider adding a guard after the loop:

if paths.is_empty() {
    return Err(());
}
Ok(paths)

This makes both "no paths" scenarios consistent and avoids silent generation of broken invoices.

@tnull
Copy link
Copy Markdown
Contributor Author

tnull commented Apr 6, 2026

Now included two commits that were necessary to make the async-payments-via-LSPS2-BOLT12-JIT-flow work. The latter commit is #4542 to make review independent/easier. Validated this works in async_payment_via_lsps2_jit_channel test over at lightningdevkit/ldk-node#817, though there we'll still have to handle persistence to actually allow peers to re-register the same intercept scids after restart.

Copy link
Copy Markdown
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

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

assuming it works on ldk node this seems like the right design to me.

/// use existing inbound liquidity when available.
///
/// For **message** blinded paths (in offers), it injects the intercept SCID as the
/// [`MessageForwardNode::short_channel_id`] so that [`Event::HTLCIntercepted`] is emitted when the
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.

No, this is true for payment blinded paths, not message blinded paths. Here we get OnionMessageIntercepted events and can use LSPS5 to wake the client (should we have an implementation of something in the LSPS5 service to handle that? maybe its too little code to bother with?)

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, well, as mentioned above this was mostly a shortcut/abbreviated version of the flow. Happy to make it more verbose here, too.

}
}

impl<R: Router, MR: MessageRouter, ES: EntropySource + Send + Sync> MessageRouter
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 should probably split this so that someone could configure a separate LSPS5 service provider from their LSPS2 service (or if their LSPS2 service doesn't offer LSPS5 we'll have to accept direct OMs).

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 now went ahead and dropped the MessageRouter part entirely as it's unnecessary for this PR. We can revisit whether we need an LSPS5-specific MessageRouter in a follow-up, but now that we simply emit OnionMessageIntercepted for all unknown SCIDs, we might even get away without one. And forcing the user's invoice request path over a specific connected LSPS5 provider seems out-of-scope for this LSPS2<>BOLT12 integration PR. Let me know if that's fine with you.

tnull added 10 commits April 7, 2026 15:22
Leave blinded onion-message routing to the application's normal MessageRouter integration so LSPS2 only overrides the HTLC path needed for JIT channels.

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
Signed-off-by: Elias Rohrer <[email protected]>
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
Keep exercising SCID-based InvoiceRequest interception while removing the now-unused message-router constructor wiring from the LSPS2 helper setup.

Co-Authored-By: HAL 9000
To enable suppoort for async payments via LSPS2 JIT channels, we expose
explicit async receive-offer refresh and readiness waiting so
integrators can sequence external setup before relying on a ready async
offer, instead of polling timer ticks.

Generated with AI assistance.

Co-Authored-By: HAL 9000
Handle `ReleaseHeldHtlc` messages that arrive before the sender-side LSP
has even queued the held HTLC for onion decoding. Unlike lightningdevkit#4106, which
covers releases arriving after the HTLC is in `decode_update_add_htlcs`
but before it reaches `pending_intercepted_htlcs`, this preserves
releases that arrive one step earlier and would otherwise be dropped as
HTLC not found.

Co-Authored-By: HAL 9000
Signed-off-by: Elias Rohrer <[email protected]>
@tnull tnull force-pushed the 2026-03-lsps2-bolt12-alt branch from 071aa74 to c37392e Compare April 7, 2026 13:22
client_node.message_router.peers_override.lock().unwrap().push(service_node_id);
client_node
.message_router
.forward_node_scid_override
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.

Okay but somehow we need to do this in prod too? How is that going to work?

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.

Or I guess it works for async because we already use the always-online node for the path? And we'll just simply not support non-async for LSP setups? That seems fine to me but we should add a test for async here, then.

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