Add BOLT12 support to LSPS2 via custom Router implementation#4463
Add BOLT12 support to LSPS2 via custom Router implementation#4463tnull wants to merge 18 commits intolightningdevkit:mainfrom
Router implementation#4463Conversation
|
👋 Thanks for assigning @jkczyz as a reviewer! |
2cb0546 to
25ab3bc
Compare
|
🔔 1st Reminder Hey @jkczyz! This PR has been waiting for your review. |
|
🔔 2nd Reminder Hey @jkczyz! This PR has been waiting for your review. |
25ab3bc to
5786409
Compare
|
No new issues found beyond what was already flagged in prior review passes. Review SummaryAll significant issues were identified in previous review passes. The current revision (c37392e) has addressed several of them: Fixed since last review:
Prior issues still open (not re-posted, as instructed):
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. |
5786409 to
98a9e9d
Compare
8800d48 to
7ca886d
Compare
Codecov Report❌ Patch coverage is 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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
7ca886d to
2ff16d7
Compare
bcc4e10 to
5602e07
Compare
ea05389 to
3acf915
Compare
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
3999f75 to
711a50b
Compare
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
711a50b to
718988d
Compare
| 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); | ||
| } |
There was a problem hiding this comment.
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:
- Valid paths from the inner router (collected at lines 161-171)
- 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 ?:
| 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.
There was a problem hiding this comment.
DefaultRouter errors if we can't produce any paths. We should do that here or add some protection in the offers module for this.
|
Now updated to not register for onion message interception based on |
Skip invalid LSPS2 registrations when building blinded payment paths so one bad entry doesn't discard otherwise usable paths. Co-Authored-By: HAL 9000
7124215 to
0a40b71
Compare
| 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) | ||
| } |
There was a problem hiding this comment.
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:
- Removing the separate parameter and using
invoice_params.intercept_scidas the key:
| 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) | |
| } |
- Or adding a debug assertion that they match.
| 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); | ||
| } |
There was a problem hiding this comment.
DefaultRouter errors if we can't produce any paths. We should do that here or add some protection in the offers module for this.
| // 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. |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Probably should just make cltv_expiry_delta a u16 to avoid this case.
| amount_msats, | ||
| secp_ctx, | ||
| ) | ||
| .unwrap_or_default(); |
There was a problem hiding this comment.
Might be worth noting that erroring here would have prevented us from adding any JIT paths.
| // 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. |
There was a problem hiding this comment.
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).
|
🔔 3rd Reminder Hey @TheBlueMatt! This PR has been waiting for your review. |
|
🔔 4th Reminder Hey @TheBlueMatt! This PR has been waiting for your review. |
| paths.push(path); | ||
| } | ||
|
|
||
| Ok(paths) |
There was a problem hiding this comment.
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.
|
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 |
TheBlueMatt
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?)
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
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
Co-Authored-By: HAL 9000
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]>
071aa74 to
c37392e
Compare
| client_node.message_router.peers_override.lock().unwrap().push(service_node_id); | ||
| client_node | ||
| .message_router | ||
| .forward_node_scid_override |
There was a problem hiding this comment.
Okay but somehow we need to do this in prod too? How is that going to work?
There was a problem hiding this comment.
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.
Closes #4272.
This is an alternative approach to #4394 which leverages a custom
Routerimplementation on the client side to inject the respective.LDK Node integration PR over at lightningdevkit/ldk-node#817