From afcc599afec068d1dfbbddf455467dfd5cdf3037 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:12:45 +0200 Subject: [PATCH 01/11] Add an LSPS2-aware BOLT12 router wrapper Let BOLT12 blinded payment paths include LSPS2 parameters decoded from payment metadata, so JIT-channel paths work without router state for the intercepted SCID. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/mod.rs | 1 + lightning-liquidity/src/lsps2/router.rs | 666 ++++++++++++++++++++++++ 2 files changed, 667 insertions(+) create mode 100644 lightning-liquidity/src/lsps2/router.rs diff --git a/lightning-liquidity/src/lsps2/mod.rs b/lightning-liquidity/src/lsps2/mod.rs index 1d5fb76d3b4..684ad9b26f7 100644 --- a/lightning-liquidity/src/lsps2/mod.rs +++ b/lightning-liquidity/src/lsps2/mod.rs @@ -13,5 +13,6 @@ pub mod client; pub mod event; pub mod msgs; pub(crate) mod payment_queue; +pub mod router; pub mod service; pub mod utils; diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs new file mode 100644 index 00000000000..758f7344aff --- /dev/null +++ b/lightning-liquidity/src/lsps2/router.rs @@ -0,0 +1,666 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Router helpers for combining LSPS2 with BOLT12 offer flows. + +use alloc::collections::BTreeMap; +use alloc::vec::Vec; + +use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; + +use lightning::blinded_path::payment::{ + BlindedPaymentPath, ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, + PaymentRelay, ReceiveTlvs, +}; +use lightning::impl_ser_tlv_based; +use lightning::ln::channel_state::ChannelDetails; +use lightning::ln::channelmanager::{PaymentId, MIN_FINAL_CLTV_EXPIRY_DELTA}; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::{EntropySource, ReceiveAuthKey}; +use lightning::types::features::BlindedHopFeatures; +use lightning::types::payment::PaymentHash; + +/// LSPS2 invoice parameters required to construct BOLT12 blinded payment paths through an LSP. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +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: u16, +} + +impl_ser_tlv_based!(LSPS2Bolt12InvoiceParameters, { + (0, counterparty_node_id, required), + (2, intercept_scid, required), + (4, cltv_expiry_delta, required), +}); + +/// Decodes LSPS2 BOLT12 invoice parameters from BOLT12 payment metadata. +/// +/// LDK does not assign a key for LSPS2 metadata. Integrations should choose an application-specific +/// key, encode their data there, and provide a decoder that extracts any LSPS2 parameters which +/// should be converted into blinded payment paths. +pub trait LSPS2Bolt12PaymentMetadataDecoder { + /// Returns all LSPS2 invoice parameters encoded in `payment_metadata`. + fn decode_lsps2_invoice_parameters( + &self, payment_metadata: &BTreeMap>, + ) -> Vec; +} + +impl LSPS2Bolt12PaymentMetadataDecoder for () { + fn decode_lsps2_invoice_parameters( + &self, _payment_metadata: &BTreeMap>, + ) -> Vec { + Vec::new() + } +} + +impl LSPS2Bolt12PaymentMetadataDecoder for F +where + F: Fn(&BTreeMap>) -> Vec, +{ + fn decode_lsps2_invoice_parameters( + &self, payment_metadata: &BTreeMap>, + ) -> Vec { + self(payment_metadata) + } +} + +/// A router wrapper that injects LSPS2-specific BOLT12 blinded payment paths based on the +/// payment metadata attached to BOLT12 offer contexts while delegating all other blinded path +/// creation behaviors to the inner router. +/// +/// For **payment** blinded paths (in invoices), it appends paths using the intercept SCID as the +/// forwarding hop so that the LSP can intercept the HTLC and open a JIT channel. Paths from the +/// inner router (e.g., through pre-existing channels) are included as well, allowing payers to +/// use existing inbound liquidity when available. +/// +/// This wrapper does **not** modify blinded onion-message paths. Async static-invoice and LSPS5 +/// users should rely on their normal [`MessageRouter`] integration and any out-of-band SCID to +/// node-id resolution they maintain when handling [`Event::OnionMessageIntercepted`]. +/// +/// [`MessageRouter`]: lightning::onion_message::messenger::MessageRouter +/// [`Event::OnionMessageIntercepted`]: lightning::events::Event::OnionMessageIntercepted +/// [`Event::HTLCIntercepted`]: lightning::events::Event::HTLCIntercepted +pub struct LSPS2BOLT12Router< + R: Router, + ES: EntropySource, + MD: LSPS2Bolt12PaymentMetadataDecoder = (), +> { + inner_router: R, + entropy_source: ES, + payment_metadata_decoder: MD, +} + +impl LSPS2BOLT12Router { + /// Constructs a new wrapper around `inner_router`. + pub fn new(inner_router: R, entropy_source: ES) -> Self { + Self { inner_router, entropy_source, payment_metadata_decoder: () } + } +} + +impl + LSPS2BOLT12Router +{ + /// Constructs a new wrapper around `inner_router` which decodes LSPS2 parameters from BOLT12 + /// payment metadata using `payment_metadata_decoder`. + pub fn new_with_payment_metadata_decoder( + inner_router: R, entropy_source: ES, payment_metadata_decoder: MD, + ) -> Self { + Self { inner_router, entropy_source, payment_metadata_decoder } + } + + fn metadata_lsps2_params( + &self, payment_context: &PaymentContext, + ) -> Vec { + // LSPS2 paths are applicable both to normal offers and async offers that resolve via a + // static invoice server. In both cases the intercept SCID lets the LSP intercept the HTLC + // and open the JIT channel before forwarding the payment. + match payment_context { + PaymentContext::Bolt12Offer(_) | PaymentContext::AsyncBolt12Offer(_) => {}, + _ => return Vec::new(), + }; + + match payment_context.payment_metadata() { + Some(metadata) => { + self.payment_metadata_decoder.decode_lsps2_invoice_parameters(metadata) + }, + None => Vec::new(), + } + } +} + +impl Router + for LSPS2BOLT12Router +{ + fn find_route( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + ) -> Result { + self.inner_router.find_route(payer, route_params, first_hops, inflight_htlcs) + } + + fn find_route_with_id( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + payment_hash: PaymentHash, payment_id: PaymentId, + ) -> Result { + self.inner_router.find_route_with_id( + payer, + route_params, + first_hops, + inflight_htlcs, + payment_hash, + payment_id, + ) + } + + fn create_blinded_payment_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, + secp_ctx: &Secp256k1, + ) -> Result, ()> { + // Retrieve paths through existing channels from the inner router. + let inner_res = self.inner_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs.clone(), + amount_msats, + secp_ctx, + ); + + // If no LSPS2 parameters were present in the payment metadata, just fallback to the inner + // router's paths. + let all_params = self.metadata_lsps2_params(&tlvs.payment_context); + if all_params.is_empty() { + return inner_res; + } + + // For metadata-derived parameters, add paths with intercept SCIDs to have the payer use + // them when sending payments, prompting the LSP node to emit Event::HTLCIntercepted, hence + // triggering channel open. We however also keep the inner paths so the payer can use + // pre-existing inbound liquidity when available rather than always triggering a JIT channel + // open. As BOLT12 specifies that paths should be ordered by preference, adding JIT-paths to + // the end of the list *should* have the payer prefer pre-existing channels. However, there + // of course is no guarantee that the payer's router will actually process the paths in this + // exact order. + let mut paths = inner_res.unwrap_or_default(); + for lsps2_invoice_params in all_params { + let payment_relay = PaymentRelay { + cltv_expiry_delta: lsps2_invoice_params.cltv_expiry_delta, + 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 as u32), + 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 a publicly-exposed 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); + } + + if paths.is_empty() { + return Err(()); + } + + Ok(paths) + } +} + +#[cfg(test)] +mod tests { + use super::{ + LSPS2BOLT12Router, LSPS2Bolt12InvoiceParameters, LSPS2Bolt12PaymentMetadataDecoder, + }; + + use alloc::collections::BTreeMap; + use bitcoin::network::Network; + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + use lightning::blinded_path::payment::{ + Bolt12OfferContext, Bolt12RefundContext, PaymentConstraints, PaymentContext, ReceiveTlvs, + }; + use lightning::blinded_path::NodeIdLookUp; + use lightning::ln::channel_state::ChannelDetails; + use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; + use lightning::offers::invoice_request::InvoiceRequestFields; + use lightning::offers::offer::OfferId; + use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; + use lightning::sign::{EntropySource, NodeSigner, ReceiveAuthKey, Recipient}; + use lightning::types::payment::PaymentSecret; + use lightning::util::ser::{Readable, Writeable}; + use lightning::util::test_utils::TestKeysInterface; + + use crate::sync::Mutex; + + use core::sync::atomic::{AtomicUsize, Ordering}; + + struct RecordingLookup { + next_node_id: PublicKey, + short_channel_id: Mutex>, + } + + impl NodeIdLookUp for RecordingLookup { + fn next_node_id(&self, short_channel_id: u64) -> Option { + *self.short_channel_id.lock().unwrap() = Some(short_channel_id); + Some(self.next_node_id) + } + } + + #[derive(Clone)] + struct TestEntropy; + + impl EntropySource for TestEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } + + #[derive(Clone, Copy)] + struct TestMetadataDecoder; + + impl LSPS2Bolt12PaymentMetadataDecoder for TestMetadataDecoder { + fn decode_lsps2_invoice_parameters( + &self, payment_metadata: &BTreeMap>, + ) -> Vec { + payment_metadata + .values() + .filter_map(|encoded| { + let mut reader = &encoded[..]; + LSPS2Bolt12InvoiceParameters::read(&mut reader).ok() + }) + .collect() + } + } + + struct MockRouter { + create_blinded_payment_paths_calls: AtomicUsize, + paths_to_return: Mutex>>, + } + + impl MockRouter { + fn new() -> Self { + Self { + create_blinded_payment_paths_calls: AtomicUsize::new(0), + paths_to_return: Mutex::new(None), + } + } + + fn create_blinded_payment_paths_calls(&self) -> usize { + self.create_blinded_payment_paths_calls.load(Ordering::Acquire) + } + } + + impl Router for MockRouter { + fn find_route( + &self, _payer: &PublicKey, _route_params: &RouteParameters, + _first_hops: Option<&[&ChannelDetails]>, _inflight_htlcs: InFlightHtlcs, + ) -> Result { + Err("mock router") + } + + fn create_blinded_payment_paths< + T: bitcoin::secp256k1::Signing + bitcoin::secp256k1::Verification, + >( + &self, _recipient: PublicKey, _local_node_receive_key: ReceiveAuthKey, + _first_hops: Vec, _tlvs: ReceiveTlvs, _amount_msats: Option, + _secp_ctx: &Secp256k1, + ) -> Result, ()> { + self.create_blinded_payment_paths_calls.fetch_add(1, Ordering::AcqRel); + match self.paths_to_return.lock().unwrap().take() { + Some(paths) => Ok(paths), + None => Err(()), + } + } + } + + fn pubkey(byte: u8) -> PublicKey { + let secret_key = SecretKey::from_slice(&[byte; 32]).unwrap(); + PublicKey::from_secret_key(&Secp256k1::new(), &secret_key) + } + + fn bolt12_offer_tlvs(offer_id: OfferId) -> ReceiveTlvs { + bolt12_offer_tlvs_with_metadata(offer_id, None) + } + + fn bolt12_offer_tlvs_with_metadata( + offer_id: OfferId, payment_metadata: Option>>, + ) -> ReceiveTlvs { + ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id, + payment_metadata, + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: pubkey(9), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }), + } + } + + fn bolt12_refund_tlvs() -> ReceiveTlvs { + ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), + } + } + + fn metadata_for_params(params: LSPS2Bolt12InvoiceParameters) -> BTreeMap> { + let mut encoded_params = Vec::new(); + params.write(&mut encoded_params).unwrap(); + let mut metadata = BTreeMap::new(); + metadata.insert(42, encoded_params); + metadata + } + + #[test] + fn creates_lsps2_blinded_path_from_payment_metadata() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + entropy_source, + TestMetadataDecoder, + ); + + let offer_id = OfferId([8; 32]); + let lsp_keys = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id = lsp_keys.get_node_id(Recipient::Node).unwrap(); + + let expected_scid = 42; + let expected_cltv_delta = 48; + let recipient = pubkey(10); + + let metadata = metadata_for_params(LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id, + intercept_scid: expected_scid, + cltv_expiry_delta: expected_cltv_delta, + }); + + let secp_ctx = Secp256k1::new(); + let mut paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs_with_metadata(offer_id, Some(metadata)), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + assert_eq!(paths.len(), 1); + let mut path = paths.pop().unwrap(); + assert_eq!( + path.introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id) + ); + assert_eq!(path.payinfo.fee_base_msat, 0); + assert_eq!(path.payinfo.fee_proportional_millionths, 0); + assert_eq!( + path.payinfo.cltv_expiry_delta, + expected_cltv_delta + MIN_FINAL_CLTV_EXPIRY_DELTA + ); + + let lookup = + RecordingLookup { next_node_id: recipient, short_channel_id: Mutex::new(None) }; + path.advance_path_by_one(&lsp_keys, &lookup, &secp_ctx).unwrap(); + assert_eq!(*lookup.short_channel_id.lock().unwrap(), Some(expected_scid)); + } + + #[test] + fn delegates_when_context_is_not_bolt12_offer() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, entropy_source); + let secp_ctx = Secp256k1::new(); + + let result = router.create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_refund_tlvs(), + Some(10_000), + &secp_ctx, + ); + + assert!(result.is_err()); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn delegates_when_no_intercept_scid_is_registered() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, entropy_source); + let secp_ctx = Secp256k1::new(); + + // Use a Bolt12Offer context without any registered intercept SCIDs. + let offer_id = OfferId([99; 32]); + let result = router.create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs(offer_id), + Some(10_000), + &secp_ctx, + ); + + assert!(result.is_err()); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn creates_paths_for_all_metadata_intercept_scids() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + entropy_source, + TestMetadataDecoder, + ); + + let lsp_keys_a = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id_a = lsp_keys_a.get_node_id(Recipient::Node).unwrap(); + let scid_a = 100; + + let lsp_keys_b = TestKeysInterface::new(&[44; 32], Network::Testnet); + let lsp_node_id_b = lsp_keys_b.get_node_id(Recipient::Node).unwrap(); + let scid_b = 200; + + let mut metadata = metadata_for_params(LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id_a, + intercept_scid: scid_a, + cltv_expiry_delta: 48, + }); + let mut encoded_b = Vec::new(); + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id_b, + intercept_scid: scid_b, + cltv_expiry_delta: 72, + } + .write(&mut encoded_b) + .unwrap(); + metadata.insert(43, encoded_b); + + let recipient = pubkey(10); + let secp_ctx = Secp256k1::new(); + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs_with_metadata(OfferId([8; 32]), Some(metadata)), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + assert_eq!(paths.len(), 2); + + // Verify each path uses a distinct intercept SCID by advancing through the LSP hop. + let mut seen_scids = std::collections::HashSet::new(); + for mut path in paths { + let (keys, node_id) = if path.introduction_node() + == &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id_a) + { + (&lsp_keys_a, lsp_node_id_a) + } else { + (&lsp_keys_b, lsp_node_id_b) + }; + let _ = node_id; + + let lookup = + RecordingLookup { next_node_id: recipient, short_channel_id: Mutex::new(None) }; + path.advance_path_by_one(keys, &lookup, &secp_ctx).unwrap(); + let scid = lookup.short_channel_id.lock().unwrap().unwrap(); + seen_scids.insert(scid); + } + + assert!(seen_scids.contains(&scid_a), "Path for SCID {} missing", scid_a); + assert!(seen_scids.contains(&scid_b), "Path for SCID {} missing", scid_b); + + // Inner router is always called to include paths through existing channels. + // It returned Err here, so only the LSPS2 paths are present. + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn includes_inner_router_paths_alongside_lsps2_paths() { + let inner_router = MockRouter::new(); + let lsp_keys = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id = lsp_keys.get_node_id(Recipient::Node).unwrap(); + let recipient = pubkey(10); + let secp_ctx = Secp256k1::new(); + + // Pre-create a blinded path as if the inner router built it from an existing channel. + let existing_tlvs = bolt12_offer_tlvs(OfferId([8; 32])); + let existing_path = lightning::blinded_path::payment::BlindedPaymentPath::new( + &[], + recipient, + ReceiveAuthKey([3; 32]), + existing_tlvs, + u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, + &TestEntropy, + &secp_ctx, + ) + .unwrap(); + *inner_router.paths_to_return.lock().unwrap() = Some(vec![existing_path]); + + let router = LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + TestEntropy, + TestMetadataDecoder, + ); + + let intercept_scid = 42; + let metadata = metadata_for_params(LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id, + intercept_scid, + cltv_expiry_delta: 48, + }); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs_with_metadata(OfferId([8; 32]), Some(metadata)), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + // Should contain both the LSPS2 intercept path and the inner router's existing + // channel path. + assert_eq!(paths.len(), 2); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn lsps2_paths_returned_even_when_inner_router_fails() { + let inner_router = MockRouter::new(); + // paths_to_return is None, so inner router returns Err(()) + let lsp_keys = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id = lsp_keys.get_node_id(Recipient::Node).unwrap(); + let recipient = pubkey(10); + let secp_ctx = Secp256k1::new(); + + let router = LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + TestEntropy, + TestMetadataDecoder, + ); + + let intercept_scid = 42; + let metadata = metadata_for_params(LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id, + intercept_scid, + cltv_expiry_delta: 48, + }); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs_with_metadata(OfferId([8; 32]), Some(metadata)), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + // Only the LSPS2 path, since the inner router failed. + assert_eq!(paths.len(), 1); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } +} From 92c14f432cf9b26ca534222badbf338acc1ba57e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 17 Jun 2026 10:28:34 +0200 Subject: [PATCH 02/11] f - Reject invalid async LSPS2 static invoices Fail async static-invoice path creation when no LSPS2 metadata exists. This prevents automatic refreshes from uploading unusable invoices. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/router.rs | 63 ++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index 758f7344aff..26cdb58be5d 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -168,6 +168,14 @@ impl Router first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, secp_ctx: &Secp256k1, ) -> Result, ()> { + let all_params = self.metadata_lsps2_params(&tlvs.payment_context); + if all_params.is_empty() + && first_hops.is_empty() + && matches!(&tlvs.payment_context, PaymentContext::AsyncBolt12Offer(_)) + { + return Err(()); + } + // Retrieve paths through existing channels from the inner router. let inner_res = self.inner_router.create_blinded_payment_paths( recipient, @@ -180,7 +188,6 @@ impl Router // If no LSPS2 parameters were present in the payment metadata, just fallback to the inner // router's paths. - let all_params = self.metadata_lsps2_params(&tlvs.payment_context); if all_params.is_empty() { return inner_res; } @@ -259,12 +266,14 @@ mod tests { use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use lightning::blinded_path::payment::{ - Bolt12OfferContext, Bolt12RefundContext, PaymentConstraints, PaymentContext, ReceiveTlvs, + AsyncBolt12OfferContext, BlindedPaymentPath, Bolt12OfferContext, Bolt12RefundContext, + PaymentConstraints, PaymentContext, ReceiveTlvs, }; use lightning::blinded_path::NodeIdLookUp; use lightning::ln::channel_state::ChannelDetails; use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning::offers::invoice_request::InvoiceRequestFields; + use lightning::offers::nonce::Nonce; use lightning::offers::offer::OfferId; use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; use lightning::sign::{EntropySource, NodeSigner, ReceiveAuthKey, Recipient}; @@ -383,6 +392,19 @@ mod tests { } } + fn async_bolt12_offer_tlvs( + offer_nonce: Nonce, payment_metadata: Option>>, + ) -> ReceiveTlvs { + ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::AsyncBolt12Offer(AsyncBolt12OfferContext { + offer_nonce, + payment_metadata, + }), + } + } + fn bolt12_refund_tlvs() -> ReceiveTlvs { ReceiveTlvs { payment_secret: PaymentSecret([2; 32]), @@ -498,6 +520,43 @@ mod tests { assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); } + #[test] + fn errors_for_async_offer_without_metadata_or_first_hops() { + let inner_router = MockRouter::new(); + let recipient = pubkey(10); + let secp_ctx = Secp256k1::new(); + let tlvs = async_bolt12_offer_tlvs(Nonce::from_entropy_source(TestEntropy), None); + let inner_path = BlindedPaymentPath::new( + &[], + recipient, + ReceiveAuthKey([3; 32]), + tlvs.clone(), + u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, + &TestEntropy, + &secp_ctx, + ) + .unwrap(); + *inner_router.paths_to_return.lock().unwrap() = Some(vec![inner_path]); + + let router = LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + TestEntropy, + TestMetadataDecoder, + ); + let result = router.create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + tlvs, + Some(10_000), + &secp_ctx, + ); + + assert!(result.is_err()); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 0); + } + #[test] fn creates_paths_for_all_metadata_intercept_scids() { let inner_router = MockRouter::new(); From fd5bbd66d2b0f98676ddec03615584d1e5c1fe73 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:15:07 +0200 Subject: [PATCH 03/11] Derive Hash for OfferId Allow offer metadata maps to key entries directly by offer id without wrapping the identifier. Co-Authored-By: HAL 9000 --- lightning/src/offers/offer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 8bafb004aaf..5855d976c1c 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -118,7 +118,7 @@ pub(super) const IV_BYTES_WITH_METADATA: &[u8; IV_LEN] = b"LDK Offer ~~~~~~"; pub(super) const IV_BYTES_WITHOUT_METADATA: &[u8; IV_LEN] = b"LDK Offer v2~~~~"; /// An identifier for an [`Offer`] built using [`DerivedMetadata`]. -#[derive(Clone, Copy, Eq, PartialEq)] +#[derive(Clone, Copy, Eq, Hash, PartialEq)] pub struct OfferId(pub [u8; 32]); impl OfferId { From 1bbfd6f315c7175e6e80b89db5c1da3870330ea5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:16:57 +0200 Subject: [PATCH 04/11] Add async offer refresh readiness APIs Let async recipients refresh receive offers explicitly, wait for readiness, and preserve payment metadata across static-invoice refreshes. Co-Authored-By: HAL 9000 --- lightning/src/ln/channelmanager.rs | 105 +++++++++++++++--- .../src/offers/async_receive_offer_cache.rs | 59 +++++++++- lightning/src/offers/flow.rs | 61 ++++++++-- 3 files changed, 196 insertions(+), 29 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2d00b1d1098..014df559b2e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5884,30 +5884,95 @@ impl< ) } - fn check_refresh_async_receive_offer_cache(&self, timer_tick_occurred: bool) { + fn check_refresh_async_receive_offer_cache(&self, timer_tick_occurred: bool) -> Result<(), ()> { + self.check_refresh_async_receive_offer_cache_with_payment_metadata( + timer_tick_occurred, + None, + ) + } + + fn check_refresh_async_receive_offer_cache_with_payment_metadata( + &self, timer_tick_occurred: bool, payment_metadata: Option>>, + ) -> Result<(), ()> { let peers = self.get_peers_for_blinded_path(); let channels = self.list_usable_channels(); let router = &self.router; - let refresh_res = self.flow.check_refresh_async_receive_offer_cache( + self.flow.check_refresh_async_receive_offer_cache_with_payment_metadata( peers, channels, router, timer_tick_occurred, - ); - match refresh_res { - Err(()) => { - log_error!( - self.logger, - "Failed to create blinded paths when requesting async receive offer paths" - ); - }, - Ok(()) => {}, - } + payment_metadata, + ) } #[cfg(test)] pub(crate) fn test_check_refresh_async_receive_offers(&self) { - self.check_refresh_async_receive_offer_cache(false); + self.check_refresh_async_receive_offer_cache(false).unwrap(); + } + + /// Requests fresh async receive offer paths from the configured static invoice server, if any. + pub fn refresh_async_receive_offers(&self) -> Result<(), ()> { + self.check_refresh_async_receive_offer_cache(false).map_err(|()| { + log_error!( + self.logger, + "Failed to create blinded paths when requesting async receive offer paths" + ); + }) + } + + /// Requests fresh async receive offer paths from the configured static invoice server, if any, + /// and attaches `payment_metadata` to the resulting BOLT 12 payment contexts. + /// + /// The metadata is persisted with the async receive offer cache so future static-invoice + /// refreshes for the same offer continue to include it. + pub fn refresh_async_receive_offers_with_payment_metadata( + &self, payment_metadata: BTreeMap>, + ) -> Result<(), ()> { + self.check_refresh_async_receive_offer_cache_with_payment_metadata( + false, + Some(payment_metadata), + ) + .map_err(|()| { + log_error!( + self.logger, + "Failed to create blinded paths when requesting async receive offer paths" + ); + }) + } + + /// Returns once an async receive offer is ready after the interactive static-invoice + /// protocol completes, or immediately if one is already available. + /// + /// Callers that need a timeout can combine this future with their runtime's timeout + /// primitive. + #[cfg_attr( + feature = "std", + doc = "Synchronous callers should instead fetch the underlying [`Future`] via [`Self::get_async_receive_offer_ready_future`] and call [`Future::wait_timeout`] on it." + )] + /// + /// [`Future`]: crate::util::wakers::Future + #[cfg_attr( + feature = "std", + doc = "[`Future::wait_timeout`]: crate::util::wakers::Future::wait_timeout" + )] + pub async fn await_async_receive_offer(&self) -> Result { + if let Ok(offer) = self.get_async_receive_offer() { + return Ok(offer); + } + + self.flow.get_async_receive_offer_ready_future().await; + self.get_async_receive_offer() + } + + /// Returns a [`Future`] that completes when an async receive offer is ready. + /// + /// See [`OffersMessageFlow::get_async_receive_offer_ready_future`] for details. + /// + /// [`Future`]: crate::util::wakers::Future + /// [`OffersMessageFlow::get_async_receive_offer_ready_future`]: crate::offers::flow::OffersMessageFlow::get_async_receive_offer_ready_future + pub fn get_async_receive_offer_ready_future(&self) -> crate::util::wakers::Future { + self.flow.get_async_receive_offer_ready_future() } /// Should be called after handling an [`Event::PersistStaticInvoice`], where the `Responder` @@ -9129,7 +9194,12 @@ impl< self.pending_outbound_payments .remove_stale_payments(duration_since_epoch, &self.pending_events); - self.check_refresh_async_receive_offer_cache(true); + let _ = self.check_refresh_async_receive_offer_cache(true).map_err(|()| { + log_error!( + self.logger, + "Failed to create blinded paths when requesting async receive offer paths" + ); + }); if self.check_free_holding_cells() { // While we try to ensure we clear holding cells immediately, its possible we miss @@ -16055,7 +16125,12 @@ impl< // interactively building offers as soon as we can after startup. We can't start building offers // until we have some peer connection(s) to receive onion messages over, so as a minor optimization // refresh the cache when a peer connects. - self.check_refresh_async_receive_offer_cache(false); + let _ = self.check_refresh_async_receive_offer_cache(false).map_err(|()| { + log_error!( + self.logger, + "Failed to create blinded paths when requesting async receive offer paths" + ); + }); res } diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index 367cdb68fc8..cbb5d1ef2da 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -11,6 +11,8 @@ //! server as an async recipient. The static invoice server will serve the resulting invoices to //! payers on our behalf when we're offline. +use alloc::collections::BTreeMap; + use crate::blinded_path::message::{AsyncPaymentsContext, BlindedMessagePath}; use crate::io; use crate::io::Read; @@ -19,7 +21,7 @@ use crate::offers::nonce::Nonce; use crate::offers::offer::Offer; use crate::onion_message::messenger::Responder; use crate::prelude::*; -use crate::util::ser::{Readable, Writeable, Writer}; +use crate::util::ser::{BigSizeKeyedMap, Readable, Writeable, Writer}; use core::time::Duration; /// The status of this offer in the cache. @@ -62,6 +64,7 @@ struct AsyncReceiveOffer { /// payment paths become otherwise outdated. offer_nonce: Nonce, update_static_invoice_path: Responder, + payment_metadata: Option>>, } impl AsyncReceiveOffer { @@ -92,6 +95,7 @@ impl_ser_tlv_based!(AsyncReceiveOffer, { (4, status, required), (6, update_static_invoice_path, required), (8, created_at, required), + (10, payment_metadata, (option, encoding: (BTreeMap>, BigSizeKeyedMap))), }); /// If we are an often-offline recipient, we'll want to interactively build offers and static @@ -147,6 +151,8 @@ pub struct AsyncReceiveOfferCache { /// Blinded paths used to request offer paths from the static invoice server. #[allow(unused)] // TODO: remove when we get rid of async payments cfg flag paths_to_static_invoice_server: Vec, + /// Payment metadata associated with offer-path requests in flight. + pending_offer_payment_metadata: BTreeMap>>>, } impl AsyncReceiveOfferCache { @@ -158,6 +164,7 @@ impl AsyncReceiveOfferCache { offers: Vec::new(), offer_paths_request_attempts: 0, paths_to_static_invoice_server: Vec::new(), + pending_offer_payment_metadata: BTreeMap::new(), } } @@ -320,6 +327,7 @@ impl AsyncReceiveOfferCache { pub(super) fn cache_pending_offer( &mut self, offer: Offer, offer_paths_absolute_expiry_secs: Option, offer_nonce: Nonce, update_static_invoice_path: Responder, duration_since_epoch: Duration, slot: u16, + payment_metadata: Option>>, ) -> Result<(), ()> { self.prune_expired_offers(duration_since_epoch, false); @@ -340,6 +348,7 @@ impl AsyncReceiveOfferCache { offer_nonce, status: OfferStatus::Pending, update_static_invoice_path, + payment_metadata, }) }, None => { @@ -347,6 +356,7 @@ impl AsyncReceiveOfferCache { return Err(()); }, } + self.pending_offer_payment_metadata.remove(&slot); Ok(()) } @@ -433,8 +443,11 @@ impl AsyncReceiveOfferCache { // Indicates that onion messages requesting new offer paths have been sent to the static invoice // server. Calling this method allows the cache to self-limit how many requests are sent. - pub(super) fn new_offers_requested(&mut self) { + pub(super) fn new_offers_requested( + &mut self, slot: u16, payment_metadata: Option>>, + ) { self.offer_paths_request_attempts += 1; + self.pending_offer_payment_metadata.insert(slot, payment_metadata); } /// Called on timer tick (roughly once per minute) to allow another [`MAX_UPDATE_ATTEMPTS`] offer @@ -447,7 +460,7 @@ impl AsyncReceiveOfferCache { /// the static invoice server. pub(super) fn offers_needing_invoice_refresh( &self, duration_since_epoch: Duration, - ) -> impl Iterator { + ) -> impl Iterator>>)> { // For any offers which are either in use or pending confirmation by the server, we should send // them a fresh invoice on each timer tick. self.offers_with_idx().filter_map(move |(_, offer)| { @@ -462,13 +475,44 @@ impl AsyncReceiveOfferCache { OfferStatus::Ready { .. } => false, }; if needs_invoice_update { - Some((&offer.offer, offer.offer_nonce, &offer.update_static_invoice_path)) + Some(( + &offer.offer, + offer.offer_nonce, + &offer.update_static_invoice_path, + offer.payment_metadata.clone(), + )) } else { None } }) } + /// Returns the payment metadata that should be attached to a replacement offer in `slot`. + pub(super) fn payment_metadata_for_new_offer( + &self, slot: u16, payment_metadata: Option>>, + ) -> Option>> { + payment_metadata + .or_else(|| self.pending_offer_payment_metadata.get(&slot).cloned().flatten()) + .or_else(|| { + self.offers + .get(slot as usize) + .and_then(|offer| offer.as_ref()) + .and_then(|offer| offer.payment_metadata.clone()) + }) + } + + /// Returns the payment metadata associated with an incoming offer-path response for `slot`. + pub(super) fn payment_metadata_for_offer_slot( + &self, slot: u16, + ) -> Option>> { + self.pending_offer_payment_metadata.get(&slot).cloned().flatten().or_else(|| { + self.offers + .get(slot as usize) + .and_then(|offer| offer.as_ref()) + .and_then(|offer| offer.payment_metadata.clone()) + }) + } + /// Should be called when we receive a [`StaticInvoicePersisted`] message from the static invoice /// server, which indicates that a new offer was persisted by the server and they are ready to /// serve the corresponding static invoice to payers on our behalf. @@ -536,6 +580,11 @@ impl Readable for AsyncReceiveOfferCache { (2, paths_to_static_invoice_server, required_vec), }); let offers: Vec> = offers; - Ok(Self { offers, offer_paths_request_attempts: 0, paths_to_static_invoice_server }) + Ok(Self { + offers, + offer_paths_request_attempts: 0, + paths_to_static_invoice_server, + pending_offer_payment_metadata: BTreeMap::new(), + }) } } diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index ade684e5be1..f24c2711574 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -63,6 +63,7 @@ use crate::sync::{Mutex, RwLock}; use crate::types::payment::{PaymentHash, PaymentSecret}; use crate::util::logger::Logger; use crate::util::ser::Writeable; +use crate::util::wakers::{Future, Notifier}; /// A BOLT12 offers code and flow utility provider, which facilitates /// BOLT12 builder generation and onion message handling. @@ -89,6 +90,7 @@ pub struct OffersMessageFlow { pending_async_payments_messages: Mutex>, async_receive_offer_cache: Mutex, + async_receive_offer_ready_notifier: Notifier, logger: L, } @@ -118,6 +120,7 @@ impl OffersMessageFlow { pending_async_payments_messages: Mutex::new(Vec::new()), async_receive_offer_cache: Mutex::new(AsyncReceiveOfferCache::new()), + async_receive_offer_ready_notifier: Notifier::new(), logger, } @@ -154,7 +157,7 @@ impl OffersMessageFlow { // We'll only fail here if no peers are connected yet for us to create reply paths to outbound // offer_paths_requests, so ignore the error. - let _ = self.check_refresh_async_offers(peers, false); + let _ = self.check_refresh_async_offers(peers, false, None); Ok(()) } @@ -1337,6 +1340,19 @@ impl OffersMessageFlow { pub fn check_refresh_async_receive_offer_cache( &self, peers: Vec, usable_channels: Vec, router: R, timer_tick_occurred: bool, + ) -> Result<(), ()> { + self.check_refresh_async_receive_offer_cache_with_payment_metadata( + peers, + usable_channels, + router, + timer_tick_occurred, + None, + ) + } + + pub(crate) fn check_refresh_async_receive_offer_cache_with_payment_metadata( + &self, peers: Vec, usable_channels: Vec, router: R, + timer_tick_occurred: bool, payment_metadata: Option>>, ) -> Result<(), ()> { // Terminate early if this node does not intend to receive async payments. { @@ -1346,7 +1362,7 @@ impl OffersMessageFlow { } } - self.check_refresh_async_offers(peers.clone(), timer_tick_occurred)?; + self.check_refresh_async_offers(peers.clone(), timer_tick_occurred, payment_metadata)?; if timer_tick_occurred { self.check_refresh_static_invoices(peers, usable_channels, router); @@ -1357,6 +1373,7 @@ impl OffersMessageFlow { fn check_refresh_async_offers( &self, peers: Vec, timer_tick_occurred: bool, + payment_metadata: Option>>, ) -> Result<(), ()> { let duration_since_epoch = self.duration_since_epoch(); let mut cache = self.async_receive_offer_cache.lock().unwrap(); @@ -1368,6 +1385,8 @@ impl OffersMessageFlow { Some(idx) => idx, None => return Ok(()), }; + let payment_metadata = + cache.payment_metadata_for_new_offer(needs_new_offer_slot, payment_metadata); // If we need new offers, send out offer paths request messages to the static invoice server. let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths { @@ -1387,7 +1406,7 @@ impl OffersMessageFlow { }; // We can't fail past this point, so indicate to the cache that we've requested new offers. - cache.new_offers_requested(); + cache.new_offers_requested(needs_new_offer_slot, payment_metadata); let mut pending_async_payments_messages = self.pending_async_payments_messages.lock().unwrap(); @@ -1414,7 +1433,8 @@ impl OffersMessageFlow { let duration_since_epoch = self.duration_since_epoch(); let cache = self.async_receive_offer_cache.lock().unwrap(); for offer_and_metadata in cache.offers_needing_invoice_refresh(duration_since_epoch) { - let (offer, offer_nonce, update_static_invoice_path) = offer_and_metadata; + let (offer, offer_nonce, update_static_invoice_path, payment_metadata) = + offer_and_metadata; let (invoice, forward_invreq_path) = match self.create_static_invoice_for_server( offer, @@ -1422,6 +1442,7 @@ impl OffersMessageFlow { peers.clone(), usable_channels.clone(), &router, + payment_metadata, ) { Ok((invoice, path)) => (invoice, path), Err(()) => continue, @@ -1545,7 +1566,7 @@ impl OffersMessageFlow { _ => return None, }; - { + let payment_metadata = { // Only respond with `ServeStaticInvoice` if we actually need a new offer built. let mut cache = self.async_receive_offer_cache.lock().unwrap(); cache.prune_expired_offers(duration_since_epoch, false); @@ -1557,7 +1578,8 @@ impl OffersMessageFlow { ) { return None; } - } + cache.payment_metadata_for_offer_slot(invoice_slot) + }; let (mut offer_builder, offer_nonce) = match self.create_async_receive_offer_builder(&entropy, message.paths) { @@ -1583,6 +1605,7 @@ impl OffersMessageFlow { peers, usable_channels, router, + payment_metadata.clone(), ) { Ok(res) => res, Err(()) => { @@ -1598,6 +1621,7 @@ impl OffersMessageFlow { responder, duration_since_epoch, invoice_slot, + payment_metadata, ) { log_error!(self.logger, "Failed to cache pending offer"); return None; @@ -1619,6 +1643,7 @@ impl OffersMessageFlow { fn create_static_invoice_for_server( &self, offer: &Offer, offer_nonce: Nonce, peers: Vec, usable_channels: Vec, router: R, + payment_metadata: Option>>, ) -> Result<(StaticInvoice, BlindedMessagePath), ()> { let expanded_key = &self.inbound_payment_key; let duration_since_epoch = self.duration_since_epoch(); @@ -1650,14 +1675,14 @@ impl OffersMessageFlow { offer_relative_expiry, usable_channels, peers.clone(), - None, + payment_metadata.clone(), ) .and_then(|builder| builder.build_and_sign(secp_ctx)) .map_err(|_| ())?; let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce: offer_nonce, - payment_metadata: None, + payment_metadata, }); let forward_invoice_request_path = self .create_blinded_paths(peers, context) @@ -1722,7 +1747,25 @@ impl OffersMessageFlow { /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted pub fn handle_static_invoice_persisted(&self, context: AsyncPaymentsContext) -> bool { let mut cache = self.async_receive_offer_cache.lock().unwrap(); - cache.static_invoice_persisted(context) + let updated = cache.static_invoice_persisted(context); + if updated { + self.async_receive_offer_ready_notifier.notify(); + } + updated + } + + /// Returns a [`Future`] that completes when an async receive offer is ready, i.e., after the + /// interactive static-invoice protocol completes. + /// + /// Callers can `.await` the returned [`Future`] in an async context. + #[cfg_attr( + feature = "std", + doc = "Synchronous callers can instead call [`Future::wait_timeout`] on it." + )] + /// + /// After it completes, use [`Self::get_async_receive_offer`] to retrieve the offer. + pub fn get_async_receive_offer_ready_future(&self) -> Future { + self.async_receive_offer_ready_notifier.get_future() } /// Get the encoded [`AsyncReceiveOfferCache`] for persistence. From ab50d44f663f322e48e645e7490a6bd70e801cd8 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 17 Jun 2026 10:35:19 +0200 Subject: [PATCH 05/11] f - Make async offer readiness race-free Return a completed future when an async receive offer is already ready. Drop redundant public wrappers now covered by lower-level APIs. Co-Authored-By: HAL 9000 --- lightning/src/ln/async_payments_tests.rs | 26 ++++++++++++++ lightning/src/ln/channelmanager.rs | 34 ------------------- .../src/offers/async_receive_offer_cache.rs | 9 +++++ lightning/src/offers/flow.rs | 10 ++++-- lightning/src/util/wakers.rs | 12 +++++++ 5 files changed, 55 insertions(+), 36 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 6e8f38f847a..cfd439b1e4a 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -1093,6 +1093,32 @@ fn ignore_duplicate_invoice() { assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); } +#[test] +fn async_receive_offer_ready_future_completes_when_offer_already_ready() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + + let mut allow_priv_chan_fwds_cfg = test_default_channel_config(); + allow_priv_chan_fwds_cfg.accept_forwards_to_priv_channels = true; + let node_chanmgrs = + create_node_chanmgrs(3, &node_cfgs, &[None, Some(allow_priv_chan_fwds_cfg), None]); + + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + + let recipient_id = vec![42; 32]; + let inv_server_paths = + nodes[1].node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap(); + nodes[2].node.set_paths_to_static_invoice_server(inv_server_paths).unwrap(); + expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1]]); + + pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id.clone()); + + assert!(nodes[2].node.get_async_receive_offer_ready_future().poll_is_complete()); + assert!(nodes[2].node.get_async_receive_offer_ready_future().poll_is_complete()); +} + #[test] fn async_receive_flow_success() { // Test that an always-online sender can successfully pay an async receiver. diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 014df559b2e..eb52bb80dd0 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5911,16 +5911,6 @@ impl< self.check_refresh_async_receive_offer_cache(false).unwrap(); } - /// Requests fresh async receive offer paths from the configured static invoice server, if any. - pub fn refresh_async_receive_offers(&self) -> Result<(), ()> { - self.check_refresh_async_receive_offer_cache(false).map_err(|()| { - log_error!( - self.logger, - "Failed to create blinded paths when requesting async receive offer paths" - ); - }) - } - /// Requests fresh async receive offer paths from the configured static invoice server, if any, /// and attaches `payment_metadata` to the resulting BOLT 12 payment contexts. /// @@ -5941,30 +5931,6 @@ impl< }) } - /// Returns once an async receive offer is ready after the interactive static-invoice - /// protocol completes, or immediately if one is already available. - /// - /// Callers that need a timeout can combine this future with their runtime's timeout - /// primitive. - #[cfg_attr( - feature = "std", - doc = "Synchronous callers should instead fetch the underlying [`Future`] via [`Self::get_async_receive_offer_ready_future`] and call [`Future::wait_timeout`] on it." - )] - /// - /// [`Future`]: crate::util::wakers::Future - #[cfg_attr( - feature = "std", - doc = "[`Future::wait_timeout`]: crate::util::wakers::Future::wait_timeout" - )] - pub async fn await_async_receive_offer(&self) -> Result { - if let Ok(offer) = self.get_async_receive_offer() { - return Ok(offer); - } - - self.flow.get_async_receive_offer_ready_future().await; - self.get_async_receive_offer() - } - /// Returns a [`Future`] that completes when an async receive offer is ready. /// /// See [`OffersMessageFlow::get_async_receive_offer_ready_future`] for details. diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index cbb5d1ef2da..9e48851f9d1 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -265,6 +265,15 @@ impl AsyncReceiveOfferCache { .ok_or(()) } + /// Returns whether [`Self::get_async_receive_offer`] would return an offer without marking an + /// unused offer as used. + pub(super) fn has_async_receive_offer(&self, duration_since_epoch: Duration) -> bool { + self.offers_with_idx().any(|(_, offer)| { + !offer.offer.is_expired_no_std(duration_since_epoch) + && matches!(offer.status, OfferStatus::Ready { .. } | OfferStatus::Used { .. }) + }) + } + /// Remove expired offers from the cache, returning the first slot number in the cache that needs /// a new offer, if any exist. pub(super) fn prune_expired_offers( diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index f24c2711574..38ce579af62 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1755,7 +1755,8 @@ impl OffersMessageFlow { } /// Returns a [`Future`] that completes when an async receive offer is ready, i.e., after the - /// interactive static-invoice protocol completes. + /// interactive static-invoice protocol completes. If an offer is already ready, the returned + /// [`Future`] will already be complete. /// /// Callers can `.await` the returned [`Future`] in an async context. #[cfg_attr( @@ -1765,7 +1766,12 @@ impl OffersMessageFlow { /// /// After it completes, use [`Self::get_async_receive_offer`] to retrieve the offer. pub fn get_async_receive_offer_ready_future(&self) -> Future { - self.async_receive_offer_ready_notifier.get_future() + let cache = self.async_receive_offer_cache.lock().unwrap(); + if cache.has_async_receive_offer(self.duration_since_epoch()) { + Future::completed() + } else { + self.async_receive_offer_ready_notifier.get_future() + } } /// Get the encoded [`AsyncReceiveOfferCache`] for persistence. diff --git a/lightning/src/util/wakers.rs b/lightning/src/util/wakers.rs index 1a0f08b5e66..eedd4df8368 100644 --- a/lightning/src/util/wakers.rs +++ b/lightning/src/util/wakers.rs @@ -162,6 +162,18 @@ pub struct Future { } impl Future { + pub(crate) fn completed() -> Self { + let state = Arc::new(Mutex::new(FutureState { + callbacks: Vec::new(), + std_future_callbacks: Vec::new(), + callbacks_with_state: Vec::new(), + complete: true, + callbacks_made: false, + next_idx: 1, + })); + Future { state, self_idx: 0 } + } + /// Registers a callback to be called upon completion of this future. If the future has already /// completed, the callback will be called immediately. /// From 348ff2981d1332f62ca99f26f8331451711541e2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:19:46 +0200 Subject: [PATCH 06/11] Make cltv_expiry_delta u16 Align LSPS2 CLTV deltas with the wire format and LDK routing types. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/event.rs | 2 +- lightning-liquidity/src/lsps2/msgs.rs | 2 +- lightning-liquidity/src/lsps2/service.rs | 4 ++-- .../tests/lsps2_integration_tests.rs | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lightning-liquidity/src/lsps2/event.rs b/lightning-liquidity/src/lsps2/event.rs index 956da403e11..0575b52c26a 100644 --- a/lightning-liquidity/src/lsps2/event.rs +++ b/lightning-liquidity/src/lsps2/event.rs @@ -63,7 +63,7 @@ pub enum LSPS2ClientEvent { /// The intercept short channel id to use in the route hint. intercept_scid: u64, /// The `cltv_expiry_delta` to use in the route hint. - cltv_expiry_delta: u32, + cltv_expiry_delta: u16, /// The initial payment size you specified. payment_size_msat: Option, }, diff --git a/lightning-liquidity/src/lsps2/msgs.rs b/lightning-liquidity/src/lsps2/msgs.rs index 9375069ca0a..72bf91d9228 100644 --- a/lightning-liquidity/src/lsps2/msgs.rs +++ b/lightning-liquidity/src/lsps2/msgs.rs @@ -182,7 +182,7 @@ pub struct LSPS2BuyResponse { /// The intercept short channel id used by LSP to identify need to open channel. pub jit_channel_scid: LSPS2InterceptScid, /// The locktime expiry delta the lsp requires. - pub lsp_cltv_expiry_delta: u32, + pub lsp_cltv_expiry_delta: u16, /// Trust model flag (default: false). /// /// false => "LSP trusts client": LSP immediately (or as soon as safe) broadcasts the diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index b52d12e5168..331f09672a4 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -904,7 +904,7 @@ where /// [`LSPS2ServiceEvent::BuyRequest`]: crate::lsps2::event::LSPS2ServiceEvent::BuyRequest pub async fn invoice_parameters_generated( &self, counterparty_node_id: &PublicKey, request_id: LSPSRequestId, intercept_scid: u64, - cltv_expiry_delta: u32, client_trusts_lsp: bool, user_channel_id: u128, + cltv_expiry_delta: u16, client_trusts_lsp: bool, user_channel_id: u128, ) -> Result<(), APIError> { let mut message_queue_notifier = self.pending_messages.notifier(); let mut should_persist = false; @@ -2177,7 +2177,7 @@ where /// Wraps [`LSPS2ServiceHandler::invoice_parameters_generated`]. pub fn invoice_parameters_generated( &self, counterparty_node_id: &PublicKey, request_id: LSPSRequestId, intercept_scid: u64, - cltv_expiry_delta: u32, client_trusts_lsp: bool, user_channel_id: u128, + cltv_expiry_delta: u16, client_trusts_lsp: bool, user_channel_id: u128, ) -> Result<(), APIError> { let mut fut = pin!(self.inner.invoice_parameters_generated( counterparty_node_id, diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 6ebf176e12d..4822839193b 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -116,7 +116,7 @@ fn setup_test_lsps2_nodes_with_payer<'a, 'b, 'c>( fn create_jit_invoice( node: &LiquidityNode<'_, '_, '_>, service_node_id: PublicKey, intercept_scid: u64, - cltv_expiry_delta: u32, payment_size_msat: Option, description: &str, expiry_secs: u32, + cltv_expiry_delta: u16, payment_size_msat: Option, description: &str, expiry_secs: u32, ) -> Result { // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; @@ -131,7 +131,7 @@ fn create_jit_invoice( src_node_id: service_node_id, short_channel_id: intercept_scid, fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, - cltv_expiry_delta: cltv_expiry_delta as u16, + cltv_expiry_delta, htlc_minimum_msat: None, htlc_maximum_msat: None, }]); @@ -1286,7 +1286,7 @@ fn client_trusts_lsp_end_to_end_test() { let intercept_scid = service_node.node.get_intercept_scid(); let user_channel_id = 42; - let cltv_expiry_delta: u32 = 144; + let cltv_expiry_delta: u16 = 144; let payment_size_msat = Some(1_000_000); let fee_base_msat = 1000; @@ -1464,7 +1464,7 @@ fn client_trusts_lsp_end_to_end_test() { fn execute_lsps2_dance( lsps_nodes: &LSPSNodesWithPayer, intercept_scid: u64, user_channel_id: u128, - cltv_expiry_delta: u32, promise_secret: [u8; 32], payment_size_msat: Option, + cltv_expiry_delta: u16, promise_secret: [u8; 32], payment_size_msat: Option, fee_base_msat: u64, ) { let service_node = &lsps_nodes.service_node; @@ -1759,7 +1759,7 @@ fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { let intercept_scid = service_node.node.get_intercept_scid(); let user_channel_id = 43u128; - let cltv_expiry_delta: u32 = 144; + let cltv_expiry_delta: u16 = 144; let payment_size_msat = Some(1_000_000); let fee_base_msat: u64 = 10_000; @@ -1948,7 +1948,7 @@ fn htlc_timeout_before_client_claim_results_in_handling_failed() { let intercept_scid = service_node.node.get_intercept_scid(); let user_channel_id = 44u128; - let cltv_expiry_delta: u32 = 144; + let cltv_expiry_delta: u16 = 144; let payment_size_msat = Some(1_000_000); let fee_base_msat: u64 = 10_000; @@ -2282,7 +2282,7 @@ fn client_trusts_lsp_partial_fee_does_not_trigger_broadcast() { let intercept_scid = service_node.node.get_intercept_scid(); let user_channel_id = 42; - let cltv_expiry_delta: u32 = 144; + let cltv_expiry_delta: u16 = 144; let payment_size_msat = Some(1_000_000); let fee_base_msat: u64 = 10_000; From 7d46ea6f1f2e023195123395a39f3059080956ac Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:20:21 +0200 Subject: [PATCH 07/11] Document the LSPS2 BOLT12 router flow Clarify how LSPS2 invoice parameters relate to BOLT11 route hints and BOLT12 blinded payment path creation. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/event.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lightning-liquidity/src/lsps2/event.rs b/lightning-liquidity/src/lsps2/event.rs index 0575b52c26a..eb9f25b0098 100644 --- a/lightning-liquidity/src/lsps2/event.rs +++ b/lightning-liquidity/src/lsps2/event.rs @@ -49,7 +49,17 @@ pub enum LSPS2ClientEvent { /// When the invoice is paid, the LSP will open a channel with the previously agreed upon /// parameters to you. /// + /// For BOLT11 JIT invoices, `intercept_scid` and `cltv_expiry_delta` can be used in a route + /// hint. + /// + /// For BOLT12 JIT flows, register these parameters for your offer id on an + /// [`LSPS2BOLT12Router`] and then proceed with the regular BOLT12 offer + /// flow. The router will inject the LSPS2-specific blinded payment path when creating the + /// invoice. + /// /// **Note: ** This event will *not* be persisted across restarts. + /// + /// [`LSPS2BOLT12Router`]: crate::lsps2::router::LSPS2BOLT12Router InvoiceParametersReady { /// The identifier of the issued bLIP-52 / LSPS2 `buy` request, as returned by /// [`LSPS2ClientHandler::select_opening_params`]. From b14aa207f7e6a4407add93d50aefd003eb34e476 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:21:18 +0200 Subject: [PATCH 08/11] Add blinded path override to test utilities Let integration tests force specific blinded payment paths, so LSPS2 BOLT12 routing behavior can be exercised deterministically. Co-Authored-By: HAL 9000 --- lightning/src/util/test_utils.rs | 35 +++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 892c9f4169d..6985e21c5a3 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -168,6 +168,23 @@ impl chaininterface::FeeEstimator for TestFeeEstimator { } } +/// Override closure type for [`TestRouter::override_create_blinded_payment_paths`]. +/// +/// This closure is called instead of the default [`Router::create_blinded_payment_paths`] +/// implementation when set, receiving the actual [`ReceiveTlvs`] so tests can construct custom +/// blinded payment paths using the same TLVs the caller generated. +pub type BlindedPaymentPathOverrideFn = Box< + dyn Fn( + PublicKey, + ReceiveAuthKey, + Vec, + ReceiveTlvs, + Option, + ) -> Result, ()> + + Send + + Sync, +>; + pub struct TestRouter<'a> { pub router: DefaultRouter< Arc>, @@ -181,6 +198,7 @@ pub struct TestRouter<'a> { pub next_routes: Mutex>)>>, pub next_blinded_payment_paths: Mutex>, pub next_payment_context_metadata: Mutex>>>, + pub override_create_blinded_payment_paths: Mutex>, pub scorer: &'a RwLock, } @@ -193,6 +211,7 @@ impl<'a> TestRouter<'a> { let next_routes = Mutex::new(VecDeque::new()); let next_blinded_payment_paths = Mutex::new(Vec::new()); let next_payment_context_metadata = Mutex::new(None); + let override_create_blinded_payment_paths = Mutex::new(None); Self { router: DefaultRouter::new( Arc::clone(&network_graph), @@ -205,6 +224,7 @@ impl<'a> TestRouter<'a> { next_routes, next_blinded_payment_paths, next_payment_context_metadata, + override_create_blinded_payment_paths, scorer, } } @@ -338,6 +358,12 @@ impl<'a> Router for TestRouter<'a> { PaymentContext::Bolt12Refund(ctx) => ctx.payment_metadata = Some(metadata), } } + if let Some(override_fn) = + self.override_create_blinded_payment_paths.lock().unwrap().as_ref() + { + return override_fn(recipient, local_node_receive_key, first_hops, tlvs, amount_msats); + } + let mut expected_paths = self.next_blinded_payment_paths.lock().unwrap(); if expected_paths.is_empty() { self.router.create_blinded_payment_paths( @@ -383,6 +409,7 @@ pub enum TestMessageRouterInternal<'a> { pub struct TestMessageRouter<'a> { pub inner: TestMessageRouterInternal<'a>, pub peers_override: Mutex>, + pub forward_node_scid_override: Mutex>, } impl<'a> TestMessageRouter<'a> { @@ -395,6 +422,7 @@ impl<'a> TestMessageRouter<'a> { entropy_source, )), peers_override: Mutex::new(Vec::new()), + forward_node_scid_override: Mutex::new(new_hash_map()), } } @@ -407,6 +435,7 @@ impl<'a> TestMessageRouter<'a> { entropy_source, )), peers_override: Mutex::new(Vec::new()), + forward_node_scid_override: Mutex::new(new_hash_map()), } } } @@ -438,9 +467,13 @@ impl<'a> MessageRouter for TestMessageRouter<'a> { { let peers_override = self.peers_override.lock().unwrap(); if !peers_override.is_empty() { + let scid_override = self.forward_node_scid_override.lock().unwrap(); let peer_override_nodes: Vec<_> = peers_override .iter() - .map(|pk| MessageForwardNode { node_id: *pk, short_channel_id: None }) + .map(|pk| MessageForwardNode { + node_id: *pk, + short_channel_id: scid_override.get(pk).copied(), + }) .collect(); peers = peer_override_nodes; } From e50eec0889d48af8757237e6bb285c9805f90ec6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:22:49 +0200 Subject: [PATCH 09/11] Cover LSPS2 BOLT12 JIT-channel flows Exercise decoder-provided LSPS2 parameters across custom-router, end-to-end, compact message path, and async-payment BOLT12 flows. Co-Authored-By: HAL 9000 --- .../tests/lsps2_integration_tests.rs | 1387 +++++++++++++++-- 1 file changed, 1228 insertions(+), 159 deletions(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 4822839193b..1997744d1b0 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -7,7 +7,10 @@ use common::{ get_lsps_message, LSPSNodes, LSPSNodesWithPayer, LiquidityNode, }; -use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType}; +use lightning::blinded_path::message::{ + BlindedMessagePath, MessageContext, MessageForwardNode, NextMessageHop, OffersContext, +}; +use lightning::events::{ClosureReason, Event, EventsProvider, HTLCHandlingFailureType}; use lightning::get_event_msg; use lightning::ln::channelmanager::{ OptionalBolt11PaymentParams, PaymentId, TrustedChannelFeatures, @@ -16,7 +19,13 @@ use lightning::ln::functional_test_utils::*; use lightning::ln::msgs::BaseMessageHandler; use lightning::ln::msgs::ChannelMessageHandler; use lightning::ln::msgs::MessageSendEvent; +use lightning::ln::msgs::OnionMessageHandler; use lightning::ln::types::ChannelId; +use lightning::offers::invoice_request::InvoiceRequestFields; +use lightning::offers::offer::OfferId; +use lightning::onion_message::messenger::{Destination, MessageRouter, OnionMessagePath}; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::{RandomBytes, ReceiveAuthKey}; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::LSPSDateTime; @@ -24,11 +33,18 @@ use lightning_liquidity::lsps2::client::LSPS2ClientConfig; use lightning_liquidity::lsps2::event::LSPS2ClientEvent; use lightning_liquidity::lsps2::event::LSPS2ServiceEvent; use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; +use lightning_liquidity::lsps2::router::{ + LSPS2BOLT12Router, LSPS2Bolt12InvoiceParameters, LSPS2Bolt12PaymentMetadataDecoder, +}; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; use lightning_liquidity::lsps2::utils::is_valid_opening_fee_params; use lightning_liquidity::utils::time::{DefaultTimeProvider, TimeProvider}; use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; +use lightning::blinded_path::payment::{ + Bolt12OfferContext, PaymentConstraints, PaymentContext, ReceiveTlvs, +}; +use lightning::blinded_path::NodeIdLookUp; use lightning::ln::channelmanager::{InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; use lightning::ln::functional_test_utils::{ create_chanmon_cfgs, create_node_cfgs, create_node_chanmgrs, @@ -41,6 +57,7 @@ use lightning::sign::NodeSigner; use lightning::util::config::HTLCInterceptionFlags; use lightning::util::errors::APIError; use lightning::util::logger::Logger; +use lightning::util::ser::{Readable, Writeable}; use lightning::util::test_utils::{TestBroadcaster, TestStore}; use lightning_invoice::{Bolt11Invoice, InvoiceBuilder, RoutingFees}; @@ -51,6 +68,7 @@ use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::Network; use lightning_types::payment::PaymentPreimage; +use std::collections::BTreeMap; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; @@ -58,6 +76,100 @@ use std::time::Duration; const MAX_PENDING_REQUESTS_PER_PEER: usize = 10; const MAX_TOTAL_PENDING_REQUESTS: usize = 1000; +struct RecordingLookup { + next_node_id: PublicKey, + short_channel_id: std::sync::Mutex>, +} + +impl NodeIdLookUp for RecordingLookup { + fn next_node_id(&self, short_channel_id: u64) -> Option { + *self.short_channel_id.lock().unwrap() = Some(short_channel_id); + Some(self.next_node_id) + } +} + +struct FailingRouter; + +impl FailingRouter { + fn new() -> Self { + Self + } +} + +impl Router for FailingRouter { + fn find_route( + &self, _payer: &PublicKey, _route_params: &RouteParameters, + _first_hops: Option<&[&lightning::ln::channel_state::ChannelDetails]>, + _inflight_htlcs: InFlightHtlcs, + ) -> Result { + Err("failing test router") + } + + fn create_blinded_payment_paths< + T: bitcoin::secp256k1::Signing + bitcoin::secp256k1::Verification, + >( + &self, _recipient: PublicKey, _local_node_receive_key: ReceiveAuthKey, + _first_hops: Vec, _tlvs: ReceiveTlvs, + _amount_msats: Option, _secp_ctx: &Secp256k1, + ) -> Result, ()> { + Err(()) + } +} + +#[derive(Clone, Copy)] +struct TestBolt12PaymentMetadataDecoder; + +impl LSPS2Bolt12PaymentMetadataDecoder for TestBolt12PaymentMetadataDecoder { + fn decode_lsps2_invoice_parameters( + &self, payment_metadata: &BTreeMap>, + ) -> Vec { + payment_metadata + .values() + .filter_map(|encoded| { + let mut reader = &encoded[..]; + LSPS2Bolt12InvoiceParameters::read(&mut reader).ok() + }) + .collect() + } +} + +fn lsps2_bolt12_payment_metadata( + counterparty_node_id: PublicKey, intercept_scid: u64, cltv_expiry_delta: u16, +) -> BTreeMap> { + let params = + LSPS2Bolt12InvoiceParameters { counterparty_node_id, intercept_scid, cltv_expiry_delta }; + let mut encoded_params = Vec::new(); + params.write(&mut encoded_params).unwrap(); + let mut metadata = BTreeMap::new(); + metadata.insert(42, encoded_params); + metadata +} + +struct PaymentMetadataMessageRouter { + inner: MR, + payment_metadata: BTreeMap>, +} + +impl MessageRouter for PaymentMetadataMessageRouter { + fn find_path( + &self, sender: PublicKey, peers: Vec, destination: Destination, + ) -> Result { + self.inner.find_path(sender, peers, destination) + } + + fn create_blinded_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + mut context: MessageContext, peers: Vec, secp_ctx: &Secp256k1, + ) -> Result, ()> { + if let MessageContext::Offers(OffersContext::InvoiceRequest { payment_metadata, .. }) = + &mut context + { + *payment_metadata = Some(self.payment_metadata.clone()); + } + self.inner.create_blinded_paths(recipient, local_node_receive_key, context, peers, secp_ctx) + } +} + fn build_lsps2_configs() -> ([u8; 32], LiquidityServiceConfig, LiquidityClientConfig) { let promise_secret = [42; 32]; let lsps2_service_config = LSPS2ServiceConfig { promise_secret }; @@ -1597,147 +1709,95 @@ fn execute_lsps2_dance( } } -fn create_channel_with_manual_broadcast( - service_node_id: &PublicKey, client_node_id: &PublicKey, service_node: &LiquidityNode, - client_node: &LiquidityNode, user_channel_id: u128, expected_outbound_amount_msat: &u64, - mark_broadcast_safe: bool, -) -> (ChannelId, bitcoin::Transaction) { - assert!(service_node - .node - .create_channel( - *client_node_id, - *expected_outbound_amount_msat, - 0, - user_channel_id, - None, - None - ) - .is_ok()); - let open_channel = - get_event_msg!(service_node, MessageSendEvent::SendOpenChannel, *client_node_id); - - client_node.node.handle_open_channel(*service_node_id, &open_channel); +#[test] +fn bolt12_custom_router_uses_lsps2_intercept_scid() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); - let events = client_node.node.get_and_clear_pending_events(); - assert_eq!(events.len(), 1); - match events[0] { - Event::OpenChannelRequest { temporary_channel_id, .. } => { - client_node - .node - .accept_inbound_channel_from_trusted_peer( - &temporary_channel_id, - &service_node_id, - user_channel_id, - TrustedChannelFeatures::ZeroConf, - None, - ) - .unwrap(); - }, - _ => panic!("Unexpected event"), - }; + let service_node_id = lsps_nodes.service_node.inner.node.get_our_node_id(); + let client_node_id = lsps_nodes.client_node.inner.node.get_our_node_id(); - let accept_channel = - get_event_msg!(client_node, MessageSendEvent::SendAcceptChannel, *service_node_id); - assert_eq!(accept_channel.common_fields.minimum_depth, 0); + let intercept_scid = lsps_nodes.service_node.node.get_intercept_scid(); + let cltv_expiry_delta = 72; - service_node.node.handle_accept_channel(*client_node_id, &accept_channel); - let (temp_channel_id, funding_tx, funding_outpoint) = create_funding_transaction( - &service_node, - &client_node_id, - *expected_outbound_amount_msat, - user_channel_id, + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + 42, + cltv_expiry_delta, + promise_secret, + Some(250_000), + 1_000, ); - let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); - service_handler - .store_funding_transaction(user_channel_id, &client_node_id, funding_tx.clone()) - .unwrap(); - service_node - .node - .funding_transaction_generated_manual_broadcast( - temp_channel_id, - *client_node_id, - funding_tx.clone(), - ) - .unwrap(); - - let funding_created = - get_event_msg!(service_node, MessageSendEvent::SendFundingCreated, *client_node_id); - client_node.node.handle_funding_created(*service_node_id, &funding_created); - check_added_monitors(&client_node.inner, 1); - - let bs_signed_locked = client_node.node.get_and_clear_pending_msg_events(); - assert_eq!(bs_signed_locked.len(), 2); - - let as_channel_ready; - match &bs_signed_locked[0] { - MessageSendEvent::SendFundingSigned { node_id, msg } => { - assert_eq!(*node_id, *service_node_id); - service_node.node.handle_funding_signed(*client_node_id, &msg); - let events = &service_node.node.get_and_clear_pending_events(); - assert_eq!(events.len(), 2); - match &events[0] { - Event::FundingTxBroadcastSafe { - funding_txo, - user_channel_id, - counterparty_node_id, - .. - } => { - assert_eq!(funding_txo.txid, funding_outpoint.txid); - assert_eq!(funding_txo.vout, funding_outpoint.index as u32); - if mark_broadcast_safe { - service_handler - .set_funding_tx_broadcast_safe(*user_channel_id, counterparty_node_id) - .unwrap(); - } - }, - _ => panic!("Unexpected event"), - }; - match &events[1] { - Event::ChannelPending { counterparty_node_id, .. } => { - assert_eq!(counterparty_node_id, client_node_id); - }, - _ => panic!("Unexpected event"), - } - expect_channel_pending_event(&client_node, &service_node_id); - check_added_monitors(&service_node.inner, 1); - - as_channel_ready = - get_event_msg!(service_node, MessageSendEvent::SendChannelReady, *client_node_id); - }, - _ => panic!("Unexpected event"), - } - match &bs_signed_locked[1] { - MessageSendEvent::SendChannelReady { node_id, msg } => { - assert_eq!(*node_id, *service_node_id); - service_node.node.handle_channel_ready(*client_node_id, &msg); - expect_channel_ready_event(&service_node, &client_node_id); - }, - _ => panic!("Unexpected event"), - } + let payment_metadata = + lsps2_bolt12_payment_metadata(service_node_id, intercept_scid, cltv_expiry_delta); + let inner_router = FailingRouter::new(); + let router = LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + lsps_nodes.client_node.keys_manager, + TestBolt12PaymentMetadataDecoder, + ); - client_node.node.handle_channel_ready(*service_node_id, &as_channel_ready); - expect_channel_ready_event(&client_node, &service_node_id); + let tlvs = ReceiveTlvs { + payment_secret: lightning_types::payment::PaymentSecret([7; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 50, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: OfferId([42; 32]), + payment_metadata: Some(payment_metadata), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: lsps_nodes.payer_node.node.get_our_node_id(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }), + }; - let as_channel_update = - get_event_msg!(service_node, MessageSendEvent::SendChannelUpdate, *client_node_id); - let bs_channel_update = - get_event_msg!(client_node, MessageSendEvent::SendChannelUpdate, *service_node_id); + let secp_ctx = Secp256k1::new(); + let mut paths = router + .create_blinded_payment_paths( + client_node_id, + ReceiveAuthKey([3; 32]), + Vec::new(), + tlvs, + Some(100_000), + &secp_ctx, + ) + .unwrap(); - service_node.node.handle_channel_update(*client_node_id, &bs_channel_update); - client_node.node.handle_channel_update(*service_node_id, &as_channel_update); + assert_eq!(paths.len(), 1); + let mut path = paths.pop().unwrap(); + assert_eq!( + path.introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(service_node_id) + ); + assert_eq!(path.payinfo.fee_base_msat, 0); + assert_eq!(path.payinfo.fee_proportional_millionths, 0); - (as_channel_ready.channel_id, funding_tx) + let lookup = RecordingLookup { + next_node_id: client_node_id, + short_channel_id: std::sync::Mutex::new(None), + }; + path.advance_path_by_one(lsps_nodes.service_node.keys_manager, &lookup, &secp_ctx).unwrap(); + assert_eq!(*lookup.short_channel_id.lock().unwrap(), Some(intercept_scid)); } #[test] -fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { +fn bolt12_lsps2_end_to_end_test() { + // End-to-end test of the BOLT12 + LSPS2 JIT channel flow. Three nodes: payer, service, client. + // client_trusts_lsp=true; funding transaction broadcast happens after client claims the HTLC. let chanmon_cfgs = create_chanmon_cfgs(3); let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let mut service_node_config = test_default_channel_config(); service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; let mut client_node_config = test_default_channel_config(); + client_node_config.accept_inbound_channels = true; client_node_config.channel_config.accept_underpaying_htlcs = true; let node_chanmgrs = create_node_chanmgrs( @@ -1758,10 +1818,10 @@ fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); let intercept_scid = service_node.node.get_intercept_scid(); - let user_channel_id = 43u128; + let user_channel_id = 42; let cltv_expiry_delta: u16 = 144; let payment_size_msat = Some(1_000_000); - let fee_base_msat: u64 = 10_000; + let fee_base_msat = 1_000; execute_lsps2_dance( &lsps_nodes, @@ -1773,72 +1833,157 @@ fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { fee_base_msat, ); - let invoice = create_jit_invoice( - &client_node, - service_node_id, - intercept_scid, - cltv_expiry_delta, - payment_size_msat, - "late-safe", - 3600, - ) - .unwrap(); + // Disconnect payer from client to ensure deterministic onion message routing through service. + payer_node.node.peer_disconnected(client_node_id); + client_node.node.peer_disconnected(payer_node_id); + payer_node.onion_messenger.peer_disconnected(client_node_id); + client_node.onion_messenger.peer_disconnected(payer_node_id); - payer_node + let payment_metadata = + lsps2_bolt12_payment_metadata(service_node_id, intercept_scid, cltv_expiry_delta); + + #[cfg(c_bindings)] + let offer = { + let mut offer_builder = client_node + .node + .create_offer_builder_using_router(PaymentMetadataMessageRouter { + inner: client_node.message_router, + payment_metadata: payment_metadata.clone(), + }) + .unwrap(); + offer_builder.amount_msats(payment_size_msat.unwrap()); + offer_builder.build().unwrap() + }; + #[cfg(not(c_bindings))] + let offer = client_node .node - .pay_for_bolt11_invoice( - &invoice, - PaymentId(invoice.payment_hash().0), - None, - OptionalBolt11PaymentParams::default(), - ) + .create_offer_builder_using_router(PaymentMetadataMessageRouter { + inner: client_node.message_router, + payment_metadata: payment_metadata.clone(), + }) + .unwrap() + .amount_msats(payment_size_msat.unwrap()) + .build() .unwrap(); + let lsps2_router = Arc::new(LSPS2BOLT12Router::new_with_payment_metadata_decoder( + FailingRouter::new(), + Arc::new(RandomBytes::new([43; 32])), + TestBolt12PaymentMetadataDecoder, + )); + + let lsps2_router = Arc::clone(&lsps2_router); + *client_node.router.override_create_blinded_payment_paths.lock().unwrap() = + Some(Box::new(move |recipient, local_node_receive_key, first_hops, tlvs, amount_msats| { + let secp_ctx = Secp256k1::new(); + lsps2_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs, + amount_msats, + &secp_ctx, + ) + })); + + let payment_id = PaymentId([1; 32]); + payer_node.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + + let onion_msg = payer_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Payer should send InvoiceRequest toward service"); + service_node.onion_messenger.handle_onion_message(payer_node_id, &onion_msg); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(client_node_id) + .expect("Service should forward InvoiceRequest to client"); + client_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + + let onion_msg = client_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Client should send Invoice toward service"); + service_node.onion_messenger.handle_onion_message(client_node_id, &onion_msg); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(payer_node_id) + .expect("Service should forward Invoice to payer"); + payer_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + check_added_monitors(&payer_node, 1); let events = payer_node.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); let ev = SendEvent::from_event(events[0].clone()); + service_node.inner.node.handle_update_add_htlc(payer_node_id, &ev.msgs[0]); do_commitment_signed_dance(&service_node.inner, &payer_node, &ev.commitment_msg, false, true); service_node.inner.node.process_pending_htlc_forwards(); let events = service_node.inner.node.get_and_clear_pending_events(); assert_eq!(events.len(), 1); - match &events[0] { + let (payment_hash, expected_outbound_amount_msat) = match &events[0] { Event::HTLCIntercepted { intercept_id, requested_next_hop_scid, - payment_hash: _, + payment_hash, expected_outbound_amount_msat, .. } => { assert_eq!(*requested_next_hop_scid, intercept_scid); + service_handler .htlc_intercepted( *requested_next_hop_scid, *intercept_id, *expected_outbound_amount_msat, - invoice.payment_hash(), + *payment_hash, ) .unwrap(); + (*payment_hash, expected_outbound_amount_msat) }, - other => panic!("Expected HTLCIntercepted, got {:?}", other), - } + other => panic!("Expected HTLCIntercepted event, got: {:?}", other), + }; + + let open_channel_event = service_node.liquidity_manager.next_event().unwrap(); + + match open_channel_event { + LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::OpenChannel { + their_network_key, + amt_to_forward_msat, + opening_fee_msat, + user_channel_id: uc_id, + intercept_scid: iscd, + }) => { + assert_eq!(their_network_key, client_node_id); + assert_eq!(amt_to_forward_msat, payment_size_msat.unwrap() - fee_base_msat); + assert_eq!(opening_fee_msat, fee_base_msat); + assert_eq!(uc_id, user_channel_id); + assert_eq!(iscd, intercept_scid); + }, + other => panic!("Expected OpenChannel event, got: {:?}", other), + }; + + let result = + service_handler.channel_needs_manual_broadcast(user_channel_id, &client_node_id).unwrap(); + assert!(result, "Channel should require manual broadcast"); - // Create channel but DO NOT mark broadcast safe yet let (channel_id, funding_tx) = create_channel_with_manual_broadcast( &service_node_id, &client_node_id, &service_node, &client_node, user_channel_id, - &(payment_size_msat.unwrap() - fee_base_msat), - false, + expected_outbound_amount_msat, + true, ); service_handler.channel_ready(user_channel_id, &channel_id, &client_node_id).unwrap(); + service_node.inner.node.process_pending_htlc_forwards(); - // Run forward to client and let client claim. do not notify service handler yet. let pay_event = { { let mut added_monitors = @@ -1846,9 +1991,9 @@ fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { assert_eq!(added_monitors.len(), 1); added_monitors.clear(); } - let mut msg_events = service_node.inner.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - SendEvent::from_event(msg_events.remove(0)) + let mut events = service_node.inner.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + SendEvent::from_event(events.remove(0)) }; client_node.inner.node.handle_update_add_htlc(service_node_id, &pay_event.msgs[0]); @@ -1864,12 +2009,525 @@ fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { let client_events = client_node.inner.node.get_and_clear_pending_events(); assert_eq!(client_events.len(), 1); let preimage = match &client_events[0] { - Event::PaymentClaimable { purpose, .. } => purpose.preimage().unwrap(), - other => panic!("Expected PaymentClaimable, got {:?}", other), + Event::PaymentClaimable { payment_hash: ph, purpose, .. } => { + assert_eq!(*ph, payment_hash); + purpose.preimage() + }, + other => panic!("Expected PaymentClaimable event on client, got: {:?}", other), }; - client_node.inner.node.claim_funds(preimage); - claim_and_assert_forwarded_only(&payer_node, &service_node.inner, &client_node.inner, preimage); + let broadcasted = service_node.inner.tx_broadcaster.txn_broadcasted.lock().unwrap(); + assert!(broadcasted.is_empty(), "There should be no broadcasted txs yet"); + drop(broadcasted); + + client_node.inner.node.claim_funds(preimage.unwrap()); + + claim_and_assert_forwarded_only( + &payer_node, + &service_node.inner, + &client_node.inner, + preimage.unwrap(), + ); + + let service_events = service_node.node.get_and_clear_pending_events(); + assert_eq!(service_events.len(), 1); + + let total_fee_msat = match service_events[0].clone() { + Event::PaymentForwarded { + prev_htlcs, + next_htlcs, + skimmed_fee_msat, + total_fee_earned_msat, + .. + } => { + assert_eq!(prev_htlcs[0].node_id, Some(payer_node_id)); + assert_eq!(next_htlcs[0].node_id, Some(client_node_id)); + service_handler.payment_forwarded(channel_id, skimmed_fee_msat.unwrap_or(0)).unwrap(); + Some(total_fee_earned_msat.unwrap() - skimmed_fee_msat.unwrap()) + }, + _ => panic!("Expected PaymentForwarded event, got: {:?}", service_events[0]), + }; + + let broadcasted = service_node.inner.tx_broadcaster.txn_broadcasted.lock().unwrap(); + assert!(broadcasted.iter().any(|b| b.compute_txid() == funding_tx.compute_txid())); + + expect_payment_sent(&payer_node, preimage.unwrap(), Some(total_fee_msat), true, true); +} + +#[test] +fn bolt12_lsps2_compact_message_path_test() { + // Tests that LSPS2 BOLT12 offers work with compact SCID-based message blinded paths. + // The client's offer uses an intercept SCID instead of the full pubkey for the next hop + // in the message blinded path. When the service node receives a forwarded InvoiceRequest + // with the unresolvable intercept SCID, it emits OnionMessageIntercepted instead of + // dropping the message. The test then forwards the message to the connected client. + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + + let mut service_node_config = test_default_channel_config(); + service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; + + let mut client_node_config = test_default_channel_config(); + client_node_config.accept_inbound_channels = true; + client_node_config.channel_config.accept_underpaying_htlcs = true; + + let node_chanmgrs = create_node_chanmgrs( + 3, + &node_cfgs, + &[Some(service_node_config), Some(client_node_config), None], + ); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); + let LSPSNodesWithPayer { ref service_node, ref client_node, ref payer_node } = lsps_nodes; + + let payer_node_id = payer_node.node.get_our_node_id(); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + let intercept_scid = service_node.node.get_intercept_scid(); + let user_channel_id = 42; + let cltv_expiry_delta: u16 = 144; + let payment_size_msat = Some(1_000_000); + let fee_base_msat = 1_000; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + user_channel_id, + cltv_expiry_delta, + promise_secret, + payment_size_msat, + fee_base_msat, + ); + + // Configure the client's message router to use compact SCID encoding for message + // blinded paths through the service node. + client_node.message_router.peers_override.lock().unwrap().push(service_node_id); + client_node + .message_router + .forward_node_scid_override + .lock() + .unwrap() + .insert(service_node_id, intercept_scid); + + // Disconnect payer from client so messages route through service. + payer_node.node.peer_disconnected(client_node_id); + client_node.node.peer_disconnected(payer_node_id); + payer_node.onion_messenger.peer_disconnected(client_node_id); + client_node.onion_messenger.peer_disconnected(payer_node_id); + + // Disconnect service from client so the service must intercept the compact SCID-based + // InvoiceRequest instead of forwarding it immediately after resolving the registered SCID. + service_node.node.peer_disconnected(client_node_id); + client_node.node.peer_disconnected(service_node_id); + service_node.onion_messenger.peer_disconnected(client_node_id); + client_node.onion_messenger.peer_disconnected(service_node_id); + + let payment_metadata = + lsps2_bolt12_payment_metadata(service_node_id, intercept_scid, cltv_expiry_delta); + + #[cfg(c_bindings)] + let offer = { + let mut offer_builder = client_node + .node + .create_offer_builder_using_router(PaymentMetadataMessageRouter { + inner: client_node.message_router, + payment_metadata: payment_metadata.clone(), + }) + .unwrap(); + offer_builder.amount_msats(payment_size_msat.unwrap()); + offer_builder.build().unwrap() + }; + #[cfg(not(c_bindings))] + let offer = client_node + .node + .create_offer_builder_using_router(PaymentMetadataMessageRouter { + inner: client_node.message_router, + payment_metadata: payment_metadata.clone(), + }) + .unwrap() + .amount_msats(payment_size_msat.unwrap()) + .build() + .unwrap(); + + let lsps2_router = Arc::new(LSPS2BOLT12Router::new_with_payment_metadata_decoder( + FailingRouter::new(), + Arc::new(RandomBytes::new([43; 32])), + TestBolt12PaymentMetadataDecoder, + )); + + let lsps2_router = Arc::clone(&lsps2_router); + *client_node.router.override_create_blinded_payment_paths.lock().unwrap() = + Some(Box::new(move |recipient, local_node_receive_key, first_hops, tlvs, amount_msats| { + let secp_ctx = Secp256k1::new(); + lsps2_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs, + amount_msats, + &secp_ctx, + ) + })); + + // Payer sends InvoiceRequest toward the service node. + let payment_id = PaymentId([1; 32]); + payer_node.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + + let onion_msg = payer_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Payer should send InvoiceRequest toward service"); + service_node.onion_messenger.handle_onion_message(payer_node_id, &onion_msg); + + // The service node can't resolve the intercept SCID via NodeIdLookUp (no real channel), + // so the message is intercepted via SCID-based interception. + // It should NOT be available as a normal forwarded message. + assert!( + service_node.onion_messenger.next_onion_message_for_peer(client_node_id).is_none(), + "Message should be intercepted, not forwarded directly" + ); + + // Process the OnionMessageIntercepted event and forward the message. + let events = core::cell::RefCell::new(Vec::new()); + service_node.onion_messenger.process_pending_events(&|e| Ok(events.borrow_mut().push(e))); + let events = events.into_inner(); + + let intercepted_msg = events + .into_iter() + .find_map(|e| match e { + Event::OnionMessageIntercepted { next_hop, message } => { + assert_eq!(next_hop, NextMessageHop::ShortChannelId(intercept_scid)); + Some(message) + }, + _ => None, + }) + .expect("Service should emit OnionMessageIntercepted for SCID-based forward"); + + // Reconnect the service and client, then forward the intercepted message. + reconnect_nodes(ReconnectArgs::new(&service_node.inner, &client_node.inner)); + + // Forward the intercepted message to the reconnected client. + service_node + .onion_messenger + .forward_onion_message(intercepted_msg, &client_node_id) + .expect("Should succeed since client reconnected"); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(client_node_id) + .expect("Service should have forwarded message to client"); + client_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + + // Client should respond with an Invoice back through the service to the payer. + let onion_msg = client_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Client should send Invoice toward service"); + service_node.onion_messenger.handle_onion_message(client_node_id, &onion_msg); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(payer_node_id) + .expect("Service should forward Invoice to payer"); + payer_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + + // Payer should have queued an HTLC payment. + check_added_monitors(&payer_node, 1); + let events = payer_node.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = SendEvent::from_event(events[0].clone()); + + // Verify the payment gets intercepted at the service node on the intercept SCID. + service_node.inner.node.handle_update_add_htlc(payer_node_id, &ev.msgs[0]); + do_commitment_signed_dance(&service_node.inner, &payer_node, &ev.commitment_msg, false, true); + service_node.inner.node.process_pending_htlc_forwards(); + + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::HTLCIntercepted { requested_next_hop_scid, .. } => { + assert_eq!(*requested_next_hop_scid, intercept_scid); + }, + other => panic!("Expected HTLCIntercepted event, got: {:?}", other), + }; +} + +fn create_channel_with_manual_broadcast( + service_node_id: &PublicKey, client_node_id: &PublicKey, service_node: &LiquidityNode, + client_node: &LiquidityNode, user_channel_id: u128, expected_outbound_amount_msat: &u64, + mark_broadcast_safe: bool, +) -> (ChannelId, bitcoin::Transaction) { + assert!(service_node + .node + .create_channel( + *client_node_id, + *expected_outbound_amount_msat, + 0, + user_channel_id, + None, + None + ) + .is_ok()); + let open_channel = + get_event_msg!(service_node, MessageSendEvent::SendOpenChannel, *client_node_id); + + client_node.node.handle_open_channel(*service_node_id, &open_channel); + + let events = client_node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match events[0] { + Event::OpenChannelRequest { temporary_channel_id, .. } => { + client_node + .node + .accept_inbound_channel_from_trusted_peer( + &temporary_channel_id, + &service_node_id, + user_channel_id, + TrustedChannelFeatures::ZeroConf, + None, + ) + .unwrap(); + }, + _ => panic!("Unexpected event"), + }; + + let accept_channel = + get_event_msg!(client_node, MessageSendEvent::SendAcceptChannel, *service_node_id); + assert_eq!(accept_channel.common_fields.minimum_depth, 0); + + service_node.node.handle_accept_channel(*client_node_id, &accept_channel); + let (temp_channel_id, funding_tx, funding_outpoint) = create_funding_transaction( + &service_node, + &client_node_id, + *expected_outbound_amount_msat, + user_channel_id, + ); + let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); + service_handler + .store_funding_transaction(user_channel_id, &client_node_id, funding_tx.clone()) + .unwrap(); + service_node + .node + .funding_transaction_generated_manual_broadcast( + temp_channel_id, + *client_node_id, + funding_tx.clone(), + ) + .unwrap(); + + let funding_created = + get_event_msg!(service_node, MessageSendEvent::SendFundingCreated, *client_node_id); + client_node.node.handle_funding_created(*service_node_id, &funding_created); + check_added_monitors(&client_node.inner, 1); + + let bs_signed_locked = client_node.node.get_and_clear_pending_msg_events(); + assert_eq!(bs_signed_locked.len(), 2); + + let as_channel_ready; + match &bs_signed_locked[0] { + MessageSendEvent::SendFundingSigned { node_id, msg } => { + assert_eq!(*node_id, *service_node_id); + service_node.node.handle_funding_signed(*client_node_id, &msg); + let events = &service_node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2); + match &events[0] { + Event::FundingTxBroadcastSafe { + funding_txo, + user_channel_id, + counterparty_node_id, + .. + } => { + assert_eq!(funding_txo.txid, funding_outpoint.txid); + assert_eq!(funding_txo.vout, funding_outpoint.index as u32); + if mark_broadcast_safe { + service_handler + .set_funding_tx_broadcast_safe(*user_channel_id, counterparty_node_id) + .unwrap(); + } + }, + _ => panic!("Unexpected event"), + }; + match &events[1] { + Event::ChannelPending { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, client_node_id); + }, + _ => panic!("Unexpected event"), + } + expect_channel_pending_event(&client_node, &service_node_id); + check_added_monitors(&service_node.inner, 1); + + as_channel_ready = + get_event_msg!(service_node, MessageSendEvent::SendChannelReady, *client_node_id); + }, + _ => panic!("Unexpected event"), + } + + match &bs_signed_locked[1] { + MessageSendEvent::SendChannelReady { node_id, msg } => { + assert_eq!(*node_id, *service_node_id); + service_node.node.handle_channel_ready(*client_node_id, &msg); + expect_channel_ready_event(&service_node, &client_node_id); + }, + _ => panic!("Unexpected event"), + } + + client_node.node.handle_channel_ready(*service_node_id, &as_channel_ready); + expect_channel_ready_event(&client_node, &service_node_id); + + let as_channel_update = + get_event_msg!(service_node, MessageSendEvent::SendChannelUpdate, *client_node_id); + let bs_channel_update = + get_event_msg!(client_node, MessageSendEvent::SendChannelUpdate, *service_node_id); + + service_node.node.handle_channel_update(*client_node_id, &bs_channel_update); + client_node.node.handle_channel_update(*service_node_id, &as_channel_update); + + (as_channel_ready.channel_id, funding_tx) +} + +#[test] +fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let mut service_node_config = test_default_channel_config(); + service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; + + let mut client_node_config = test_default_channel_config(); + client_node_config.channel_config.accept_underpaying_htlcs = true; + + let node_chanmgrs = create_node_chanmgrs( + 3, + &node_cfgs, + &[Some(service_node_config), Some(client_node_config), None], + ); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); + let LSPSNodesWithPayer { ref service_node, ref client_node, ref payer_node } = lsps_nodes; + + let payer_node_id = payer_node.node.get_our_node_id(); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); + + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + let intercept_scid = service_node.node.get_intercept_scid(); + let user_channel_id = 43u128; + let cltv_expiry_delta: u16 = 144; + let payment_size_msat = Some(1_000_000); + let fee_base_msat: u64 = 10_000; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + user_channel_id, + cltv_expiry_delta, + promise_secret, + payment_size_msat, + fee_base_msat, + ); + + let invoice = create_jit_invoice( + &client_node, + service_node_id, + intercept_scid, + cltv_expiry_delta, + payment_size_msat, + "late-safe", + 3600, + ) + .unwrap(); + + payer_node + .node + .pay_for_bolt11_invoice( + &invoice, + PaymentId(invoice.payment_hash().0), + None, + OptionalBolt11PaymentParams::default(), + ) + .unwrap(); + + check_added_monitors(&payer_node, 1); + let events = payer_node.node.get_and_clear_pending_msg_events(); + let ev = SendEvent::from_event(events[0].clone()); + service_node.inner.node.handle_update_add_htlc(payer_node_id, &ev.msgs[0]); + do_commitment_signed_dance(&service_node.inner, &payer_node, &ev.commitment_msg, false, true); + service_node.inner.node.process_pending_htlc_forwards(); + + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::HTLCIntercepted { + intercept_id, + requested_next_hop_scid, + payment_hash: _, + expected_outbound_amount_msat, + .. + } => { + assert_eq!(*requested_next_hop_scid, intercept_scid); + service_handler + .htlc_intercepted( + *requested_next_hop_scid, + *intercept_id, + *expected_outbound_amount_msat, + invoice.payment_hash(), + ) + .unwrap(); + }, + other => panic!("Expected HTLCIntercepted, got {:?}", other), + } + + // Create channel but DO NOT mark broadcast safe yet + let (channel_id, funding_tx) = create_channel_with_manual_broadcast( + &service_node_id, + &client_node_id, + &service_node, + &client_node, + user_channel_id, + &(payment_size_msat.unwrap() - fee_base_msat), + false, + ); + + service_handler.channel_ready(user_channel_id, &channel_id, &client_node_id).unwrap(); + service_node.inner.node.process_pending_htlc_forwards(); + + // Run forward to client and let client claim. do not notify service handler yet. + let pay_event = { + { + let mut added_monitors = + service_node.inner.chain_monitor.added_monitors.lock().unwrap(); + assert_eq!(added_monitors.len(), 1); + added_monitors.clear(); + } + let mut msg_events = service_node.inner.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + SendEvent::from_event(msg_events.remove(0)) + }; + + client_node.inner.node.handle_update_add_htlc(service_node_id, &pay_event.msgs[0]); + do_commitment_signed_dance( + &client_node.inner, + &service_node.inner, + &pay_event.commitment_msg, + false, + true, + ); + client_node.inner.node.process_pending_htlc_forwards(); + + let client_events = client_node.inner.node.get_and_clear_pending_events(); + assert_eq!(client_events.len(), 1); + let preimage = match &client_events[0] { + Event::PaymentClaimable { purpose, .. } => purpose.preimage().unwrap(), + other => panic!("Expected PaymentClaimable, got {:?}", other), + }; + + client_node.inner.node.claim_funds(preimage); + claim_and_assert_forwarded_only(&payer_node, &service_node.inner, &client_node.inner, preimage); // Service now has PaymentForwarded. Record in JIT state but still not safe to broadcast. let events = service_node.node.get_and_clear_pending_events(); @@ -2471,3 +3129,414 @@ fn client_trusts_lsp_partial_fee_does_not_trigger_broadcast() { client_node.inner.chain_monitor.added_monitors.lock().unwrap().clear(); payer_node.chain_monitor.added_monitors.lock().unwrap().clear(); } + +#[test] +fn async_payment_via_lsps2_jit_channel() { + // Test async payments through an LSPS2 JIT channel. Three nodes: payer, service (LSP + + // static invoice server), client (often-offline async recipient). The client has no channel + // with the service and relies on LSPS2 to open a JIT channel when the payment arrives. + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + + let mut service_node_config = test_default_channel_config(); + service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; + service_node_config.accept_forwards_to_priv_channels = true; + + let mut client_node_config = test_default_channel_config(); + client_node_config.accept_inbound_channels = true; + client_node_config.channel_config.accept_underpaying_htlcs = true; + + let node_chanmgrs = create_node_chanmgrs( + 3, + &node_cfgs, + &[Some(service_node_config), Some(client_node_config), None], + ); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); + let LSPSNodesWithPayer { ref service_node, ref client_node, ref payer_node } = lsps_nodes; + + let payer_node_id = payer_node.node.get_our_node_id(); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); + + // Create channel: payer ↔ service. + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + // Run the LSPS2 dance to get an intercept SCID and fee parameters. + let intercept_scid = service_node.node.get_intercept_scid(); + let user_channel_id = 42; + let cltv_expiry_delta: u16 = 144; + let payment_size_msat = None; + let fee_base_msat = 1_000; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + user_channel_id, + cltv_expiry_delta, + promise_secret, + payment_size_msat, + fee_base_msat, + ); + + // Set up the LSPS2BOLT12Router on the client BEFORE the static invoice is created, so + // the invoice's blinded payment paths route through the LSP's intercept SCID. + let payment_metadata = + lsps2_bolt12_payment_metadata(service_node_id, intercept_scid, cltv_expiry_delta); + let lsps2_router = Arc::new(LSPS2BOLT12Router::new_with_payment_metadata_decoder( + FailingRouter::new(), + Arc::new(RandomBytes::new([43; 32])), + TestBolt12PaymentMetadataDecoder, + )); + + let lsps2_router_clone = Arc::clone(&lsps2_router); + *client_node.router.override_create_blinded_payment_paths.lock().unwrap() = + Some(Box::new(move |recipient, local_node_receive_key, first_hops, tlvs, amount_msats| { + let secp_ctx = Secp256k1::new(); + lsps2_router_clone.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs, + amount_msats, + &secp_ctx, + ) + })); + + // --- Static invoice server setup --- + // The service needs the client as a peer for blinded path creation, since they don't share + // a channel yet. + service_node.inner.message_router.peers_override.lock().unwrap().push(client_node_id); + client_node.inner.message_router.peers_override.lock().unwrap().push(service_node_id); + + // The service node acts as the always-online static invoice server for the client. + let recipient_id = vec![42; 32]; + let inv_server_paths: Vec = service_node + .inner + .node + .blinded_paths_for_async_recipient(recipient_id.clone(), None) + .unwrap(); + client_node.inner.node.set_paths_to_static_invoice_server(inv_server_paths).unwrap(); + client_node + .inner + .node + .refresh_async_receive_offers_with_payment_metadata(payment_metadata) + .unwrap(); + + // Forward all OfferPathsRequest messages from client to service. + while let Some(msg) = + client_node.inner.onion_messenger.next_onion_message_for_peer(service_node_id) + { + service_node.inner.onion_messenger.handle_onion_message(client_node_id, &msg); + } + + // Service responds with OfferPaths. Forward all to the client. + while let Some(msg) = + service_node.inner.onion_messenger.next_onion_message_for_peer(client_node_id) + { + client_node.inner.onion_messenger.handle_onion_message(service_node_id, &msg); + } + + // Client constructs the static invoice and sends ServeStaticInvoice (plus possibly more + // OfferPathsRequests). Forward all messages from client to service. + while let Some(msg) = + client_node.inner.onion_messenger.next_onion_message_for_peer(service_node_id) + { + service_node.inner.onion_messenger.handle_onion_message(client_node_id, &msg); + } + + // Clear overrides — all blinded paths have been created. + service_node.inner.message_router.peers_override.lock().unwrap().clear(); + client_node.inner.message_router.peers_override.lock().unwrap().clear(); + + // Drain any remaining service → client messages (additional OfferPaths responses). + while let Some(msg) = + service_node.inner.onion_messenger.next_onion_message_for_peer(client_node_id) + { + client_node.inner.onion_messenger.handle_onion_message(service_node_id, &msg); + } + + // Service should have emitted at least one PersistStaticInvoice event. + let events = service_node.inner.node.get_and_clear_pending_events(); + assert!(!events.is_empty(), "Expected PersistStaticInvoice event(s), got none"); + let (static_invoice, invoice_request_path, ack_path) = events + .into_iter() + .find_map(|e| match e { + Event::PersistStaticInvoice { + invoice, + invoice_persisted_path, + invoice_request_path, + .. + } => Some((invoice, invoice_request_path, invoice_persisted_path)), + _ => None, + }) + .expect("Expected a PersistStaticInvoice event"); + + // Service calls static_invoice_persisted to acknowledge. + service_node.inner.node.static_invoice_persisted(ack_path); + while let Some(msg) = + service_node.inner.onion_messenger.next_onion_message_for_peer(client_node_id) + { + client_node.inner.onion_messenger.handle_onion_message(service_node_id, &msg); + } + + // Get the async receive offer from the client. + let offer = client_node.inner.node.get_async_receive_offer().unwrap(); + + // --- Payer initiates async payment --- + // The payer also needs an explicit peer for creating blinded reply paths. + payer_node.message_router.peers_override.lock().unwrap().push(service_node_id); + + let amt_msat = 100_000; + let payment_id = PaymentId([1; 32]); + payer_node.node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + + // InvoiceRequest: payer → client (the offer issuer). The client forwards it to the service + // (static invoice server) via the offer's blinded path. + let invreq_om = payer_node + .onion_messenger + .next_onion_message_for_peer(client_node_id) + .expect("Payer should send InvoiceRequest toward client"); + client_node.inner.onion_messenger.handle_onion_message(payer_node_id, &invreq_om); + + // Client forwards InvoiceRequest to service (static invoice server). + let invreq_fwd = client_node + .inner + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Client should forward InvoiceRequest to service"); + service_node.inner.onion_messenger.handle_onion_message(client_node_id, &invreq_fwd); + + // Service emits StaticInvoiceRequested — respond with the persisted static invoice. + let events = service_node.inner.node.get_and_clear_pending_events(); + assert!(!events.is_empty(), "Expected StaticInvoiceRequested event"); + let (reply_path, invoice_request) = events + .into_iter() + .find_map(|e| match e { + Event::StaticInvoiceRequested { reply_path, invoice_request, .. } => { + Some((reply_path, invoice_request)) + }, + _ => None, + }) + .expect("Expected StaticInvoiceRequested event"); + service_node + .inner + .node + .respond_to_static_invoice_request( + static_invoice, + reply_path, + invoice_request, + invoice_request_path, + ) + .unwrap(); + + // Service sends InvoiceRequest forward (to client) and StaticInvoice response (to payer). + // Drain service → client messages first. + while let Some(msg) = + service_node.inner.onion_messenger.next_onion_message_for_peer(client_node_id) + { + client_node.inner.onion_messenger.handle_onion_message(service_node_id, &msg); + } + let static_invoice_om = service_node + .inner + .onion_messenger + .next_onion_message_for_peer(payer_node_id) + .expect("Service should send StaticInvoice to payer"); + payer_node.onion_messenger.handle_onion_message(service_node_id, &static_invoice_om); + + // Sender should NOT lock in HTLCs yet — it waits for ReleaseHeldHtlc. + payer_node.node.process_pending_htlc_forwards(); + assert!(payer_node.node.get_and_clear_pending_msg_events().is_empty()); + + // HeldHtlcAvailable: payer → service → client. Simulate the client being offline when the + // service receives the message: disconnect client, let the service handle the payer's + // message (triggering OnionMessageIntercepted), then reconnect and forward. + let held_htlc_om = payer_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Payer should send HeldHtlcAvailable toward service"); + + service_node.inner.node.peer_disconnected(client_node_id); + client_node.inner.node.peer_disconnected(service_node_id); + service_node.inner.onion_messenger.peer_disconnected(client_node_id); + client_node.inner.onion_messenger.peer_disconnected(service_node_id); + + service_node.inner.onion_messenger.handle_onion_message(payer_node_id, &held_htlc_om); + + let events = core::cell::RefCell::new(Vec::new()); + service_node.inner.onion_messenger.process_pending_events(&|e| Ok(events.borrow_mut().push(e))); + let intercepted: Vec<_> = events + .into_inner() + .into_iter() + .filter_map(|e| match e { + Event::OnionMessageIntercepted { next_hop, message } => { + assert_eq!(next_hop, NextMessageHop::NodeId(client_node_id)); + Some(message) + }, + _ => None, + }) + .collect(); + assert!(!intercepted.is_empty(), "Expected OnionMessageIntercepted for HeldHtlcAvailable"); + + reconnect_nodes(ReconnectArgs::new(&service_node.inner, &client_node.inner)); + for message in intercepted { + service_node.inner.onion_messenger.forward_onion_message(message, &client_node_id).unwrap(); + } + while let Some(msg) = + service_node.inner.onion_messenger.next_onion_message_for_peer(client_node_id) + { + client_node.inner.onion_messenger.handle_onion_message(service_node_id, &msg); + } + + // ReleaseHeldHtlc: client → service → payer (reply path goes through service). + let release_om = client_node + .inner + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Client should send ReleaseHeldHtlc toward service"); + service_node.inner.onion_messenger.handle_onion_message(client_node_id, &release_om); + + let release_fwd = service_node + .inner + .onion_messenger + .next_onion_message_for_peer(payer_node_id) + .expect("Service should forward ReleaseHeldHtlc to payer"); + payer_node.onion_messenger.handle_onion_message(service_node_id, &release_fwd); + + // --- Payer creates the HTLC --- + let mut events = payer_node.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = remove_first_msg_event_to_node(&service_node_id, &mut events); + check_added_monitors(&payer_node, 1); + + let send_ev = SendEvent::from_event(ev); + let payment_hash = send_ev.msgs[0].payment_hash; + service_node.inner.node.handle_update_add_htlc(payer_node_id, &send_ev.msgs[0]); + do_commitment_signed_dance( + &service_node.inner, + &payer_node, + &send_ev.commitment_msg, + false, + true, + ); + service_node.inner.node.process_pending_htlc_forwards(); + + // Service intercepts the HTLC on the LSPS2 intercept SCID. + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let expected_outbound_amount_msat = match &events[0] { + Event::HTLCIntercepted { + intercept_id, + requested_next_hop_scid, + payment_hash: ph, + expected_outbound_amount_msat, + .. + } => { + assert_eq!(*requested_next_hop_scid, intercept_scid); + assert_eq!(*ph, payment_hash); + service_handler + .htlc_intercepted( + *requested_next_hop_scid, + *intercept_id, + *expected_outbound_amount_msat, + *ph, + ) + .unwrap(); + expected_outbound_amount_msat + }, + other => panic!("Expected HTLCIntercepted event, got: {:?}", other), + }; + + // Service emits OpenChannel event for the JIT channel. + let open_channel_event = service_node.liquidity_manager.next_event().unwrap(); + match open_channel_event { + LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::OpenChannel { + their_network_key, + user_channel_id: uc_id, + intercept_scid: iscd, + .. + }) => { + assert_eq!(their_network_key, client_node_id); + assert_eq!(uc_id, user_channel_id); + assert_eq!(iscd, intercept_scid); + }, + other => panic!("Expected OpenChannel event, got: {:?}", other), + }; + + let result = + service_handler.channel_needs_manual_broadcast(user_channel_id, &client_node_id).unwrap(); + assert!(result, "Channel should require manual broadcast"); + + // Open the JIT channel between service and client. + let (channel_id, _funding_tx) = create_channel_with_manual_broadcast( + &service_node_id, + &client_node_id, + &service_node, + &client_node, + user_channel_id, + expected_outbound_amount_msat, + true, + ); + + service_handler.channel_ready(user_channel_id, &channel_id, &client_node_id).unwrap(); + service_node.inner.node.process_pending_htlc_forwards(); + + // Service forwards the payment to the client through the new JIT channel. + let pay_event = { + { + let mut added_monitors = + service_node.inner.chain_monitor.added_monitors.lock().unwrap(); + assert_eq!(added_monitors.len(), 1); + added_monitors.clear(); + } + let mut events = service_node.inner.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + SendEvent::from_event(events.remove(0)) + }; + + client_node.inner.node.handle_update_add_htlc(service_node_id, &pay_event.msgs[0]); + do_commitment_signed_dance( + &client_node.inner, + &service_node.inner, + &pay_event.commitment_msg, + false, + true, + ); + client_node.inner.node.process_pending_htlc_forwards(); + + // Client receives the payment. + let client_events = client_node.inner.node.get_and_clear_pending_events(); + assert_eq!(client_events.len(), 1); + let preimage = match &client_events[0] { + Event::PaymentClaimable { payment_hash: ph, purpose, .. } => { + assert_eq!(*ph, payment_hash); + purpose.preimage() + }, + other => panic!("Expected PaymentClaimable event on client, got: {:?}", other), + }; + + // Client claims the payment. + client_node.inner.node.claim_funds(preimage.unwrap()); + + claim_and_assert_forwarded_only( + &payer_node, + &service_node.inner, + &client_node.inner, + preimage.unwrap(), + ); + + let service_events = service_node.node.get_and_clear_pending_events(); + assert_eq!(service_events.len(), 1); + match service_events[0].clone() { + Event::PaymentForwarded { prev_htlcs, next_htlcs, skimmed_fee_msat, .. } => { + assert_eq!(prev_htlcs[0].node_id, Some(payer_node_id)); + assert_eq!(next_htlcs[0].node_id, Some(client_node_id)); + service_handler.payment_forwarded(channel_id, skimmed_fee_msat.unwrap_or(0)).unwrap(); + }, + other => panic!("Expected PaymentForwarded event, got: {:?}", other), + }; + + expect_payment_sent(&payer_node, preimage.unwrap(), None, true, true); +} From eeff1633f707a1b95b3b3f531f627973c8e90371 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 17 Jun 2026 10:41:59 +0200 Subject: [PATCH 10/11] f - Cover LSPS2 async invoice gating Assert no static invoice is uploaded before LSPS2 metadata is ready. Keep LSPS2 integration tests compiling with current upstream types. Co-Authored-By: HAL 9000 --- .../tests/lsps2_integration_tests.rs | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 1997744d1b0..3101e4c61b2 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -42,7 +42,7 @@ use lightning_liquidity::utils::time::{DefaultTimeProvider, TimeProvider}; use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; use lightning::blinded_path::payment::{ - Bolt12OfferContext, PaymentConstraints, PaymentContext, ReceiveTlvs, + BlindedPaymentPath, Bolt12OfferContext, PaymentConstraints, PaymentContext, ReceiveTlvs, }; use lightning::blinded_path::NodeIdLookUp; use lightning::ln::channelmanager::{InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; @@ -116,6 +116,42 @@ impl Router for FailingRouter { } } +struct FallbackBeforeMetadataRouter; + +impl Router for FallbackBeforeMetadataRouter { + fn find_route( + &self, _payer: &PublicKey, _route_params: &RouteParameters, + _first_hops: Option<&[&lightning::ln::channel_state::ChannelDetails]>, + _inflight_htlcs: InFlightHtlcs, + ) -> Result { + Err("fallback-before-metadata test router") + } + + fn create_blinded_payment_paths< + T: bitcoin::secp256k1::Signing + bitcoin::secp256k1::Verification, + >( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + _first_hops: Vec, tlvs: ReceiveTlvs, + _amount_msats: Option, secp_ctx: &Secp256k1, + ) -> Result, ()> { + if tlvs.payment_context.payment_metadata().is_some() { + return Err(()); + } + + BlindedPaymentPath::new( + &[], + recipient, + local_node_receive_key, + tlvs, + u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, + &RandomBytes::new([44; 32]), + secp_ctx, + ) + .map(|path| vec![path]) + } +} + #[derive(Clone, Copy)] struct TestBolt12PaymentMetadataDecoder; @@ -593,7 +629,7 @@ fn channel_open_failed_releases_intercepted_htlcs() { let intercept_scid = service_node.node.get_intercept_scid(); let user_channel_id = 42u128; - let cltv_expiry_delta: u32 = 144; + let cltv_expiry_delta: u16 = 144; let payment_size_msat = Some(1_000_000); let fee_base_msat: u64 = 1_000; @@ -2198,7 +2234,7 @@ fn bolt12_lsps2_compact_message_path_test() { let intercepted_msg = events .into_iter() .find_map(|e| match e { - Event::OnionMessageIntercepted { next_hop, message } => { + Event::OnionMessageIntercepted { next_hop, message, .. } => { assert_eq!(next_hop, NextMessageHop::ShortChannelId(intercept_scid)); Some(message) }, @@ -3186,7 +3222,7 @@ fn async_payment_via_lsps2_jit_channel() { let payment_metadata = lsps2_bolt12_payment_metadata(service_node_id, intercept_scid, cltv_expiry_delta); let lsps2_router = Arc::new(LSPS2BOLT12Router::new_with_payment_metadata_decoder( - FailingRouter::new(), + FallbackBeforeMetadataRouter, Arc::new(RandomBytes::new([43; 32])), TestBolt12PaymentMetadataDecoder, )); @@ -3219,6 +3255,30 @@ fn async_payment_via_lsps2_jit_channel() { .blinded_paths_for_async_recipient(recipient_id.clone(), None) .unwrap(); client_node.inner.node.set_paths_to_static_invoice_server(inv_server_paths).unwrap(); + + // The path setup above triggers an automatic async-offer refresh before the LSPS2 metadata + // has been attached. Even if the inner router could build a fallback path, we must not upload + // a static invoice for the LSPS2 JIT flow until the metadata-derived intercept-SCID path is + // available. + while let Some(msg) = + client_node.inner.onion_messenger.next_onion_message_for_peer(service_node_id) + { + service_node.inner.onion_messenger.handle_onion_message(client_node_id, &msg); + } + while let Some(msg) = + service_node.inner.onion_messenger.next_onion_message_for_peer(client_node_id) + { + client_node.inner.onion_messenger.handle_onion_message(service_node_id, &msg); + } + assert!( + client_node.inner.onion_messenger.next_onion_message_for_peer(service_node_id).is_none(), + "client must not upload a static invoice before LSPS2 metadata is available" + ); + assert!( + service_node.inner.node.get_and_clear_pending_events().is_empty(), + "service must not receive a static invoice before LSPS2 metadata is available" + ); + client_node .inner .node @@ -3371,7 +3431,7 @@ fn async_payment_via_lsps2_jit_channel() { .into_inner() .into_iter() .filter_map(|e| match e { - Event::OnionMessageIntercepted { next_hop, message } => { + Event::OnionMessageIntercepted { next_hop, message, .. } => { assert_eq!(next_hop, NextMessageHop::NodeId(client_node_id)); Some(message) }, From abdecc2b9ebe42b940b8404d4f024f42e265eb6f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:23:22 +0200 Subject: [PATCH 11/11] Add LSPS2 BOLT12 pending changelog entry Record the new LSPS2 BOLT12 routing support and its compatibility note for the next release notes. Co-Authored-By: HAL 9000 --- pending_changelog/4463-LSPS2-BOLT12.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 pending_changelog/4463-LSPS2-BOLT12.txt diff --git a/pending_changelog/4463-LSPS2-BOLT12.txt b/pending_changelog/4463-LSPS2-BOLT12.txt new file mode 100644 index 00000000000..51cacb17995 --- /dev/null +++ b/pending_changelog/4463-LSPS2-BOLT12.txt @@ -0,0 +1,12 @@ +## Backwards Compatibility + +If you manually persist `Event::OnionMessageIntercepted` events and construct +your `OnionMessenger` via `OnionMessenger::new_with_offline_peer_interception` +with `intercept_for_unknown_scids` set to `true`, you may not be able to +downgrade to LDK v0.2 or prior: persisted events carrying the new +`NextMessageHop::ShortChannelId` next hop will fail to deserialize on the +older version. + +LDK does not persist `OnionMessageIntercepted` events itself. Users who do +not persist these events manually, or who leave `intercept_for_unknown_scids` +disabled, are unaffected.