From 4c8b457651ea357d742ca95270a3873d5d9ecbe9 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 01/22] Add onion-message interception for unknown SCIDs to `OnionMessenger` We extend the `OnionMessenger` capabilities to also intercept onion messages if they are for unknown SCIDs. Co-Authored-By: HAL 9000 --- lightning/src/blinded_path/message.rs | 5 ++ lightning/src/events/mod.rs | 58 ++++++++++++++----- .../src/onion_message/functional_tests.rs | 12 ++-- lightning/src/onion_message/messenger.rs | 24 ++++++-- 4 files changed, 73 insertions(+), 26 deletions(-) diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 7bcbe80a965..89eaa232fa4 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -271,6 +271,11 @@ pub enum NextMessageHop { ShortChannelId(u64), } +impl_writeable_tlv_based_enum!(NextMessageHop, + {0, NodeId} => (), + {2, ShortChannelId} => (), +); + /// An intermediate node, and possibly a short channel id leading to the next node. /// /// Note: diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 73c4a39c76f..1ea195c51a3 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -18,7 +18,7 @@ pub mod bump_transaction; pub use bump_transaction::BumpTransactionEvent; -use crate::blinded_path::message::{BlindedMessagePath, OffersContext}; +use crate::blinded_path::message::{BlindedMessagePath, NextMessageHop, OffersContext}; use crate::blinded_path::payment::{ Bolt12OfferContext, Bolt12RefundContext, PaymentContext, PaymentContextRef, }; @@ -1705,8 +1705,10 @@ pub enum Event { /// [`ChannelHandshakeConfig::negotiate_anchor_zero_fee_commitments`]: crate::util::config::ChannelHandshakeConfig::negotiate_anchor_zero_fee_commitments BumpTransaction(BumpTransactionEvent), /// We received an onion message that is intended to be forwarded to a peer - /// that is currently offline. This event will only be generated if the - /// `OnionMessenger` was initialized with + /// that is currently offline *or* that is intended to be forwarded along a channel with an + /// SCID unknown to us. + /// + /// This event will only be generated if the `OnionMessenger` was initialized with /// [`OnionMessenger::new_with_offline_peer_interception`], see its docs. /// /// The offline peer should be awoken if possible on receipt of this event, such as via the LSPS5 @@ -1721,9 +1723,10 @@ pub enum Event { /// /// [`OnionMessenger::new_with_offline_peer_interception`]: crate::onion_message::messenger::OnionMessenger::new_with_offline_peer_interception OnionMessageIntercepted { - /// The node id of the offline peer. - peer_node_id: PublicKey, - /// The onion message intended to be forwarded to `peer_node_id`. + /// The next hop (offline peer or unknown SCID). + next_hop: NextMessageHop, + /// The onion message intended to be forwarded to the offline peer or via the unknown + /// channel once established. message: msgs::OnionMessage, }, /// Indicates that an onion message supporting peer has come online and any messages previously @@ -2303,12 +2306,25 @@ impl Writeable for Event { 35u8.write(writer)?; // Never write ConnectionNeeded events as buffered onion messages aren't serialized. }, - &Event::OnionMessageIntercepted { ref peer_node_id, ref message } => { + &Event::OnionMessageIntercepted { ref next_hop, ref message } => { 37u8.write(writer)?; - write_tlv_fields!(writer, { - (0, peer_node_id, required), - (2, message, required), - }); + match next_hop { + NextMessageHop::NodeId(peer_node_id) => { + // If we have the node_id, we keep writing it for backwards compatibility. + write_tlv_fields!(writer, { + (0, peer_node_id, required), + (1, next_hop, required), + (2, message, required), + }); + }, + NextMessageHop::ShortChannelId(_) => { + write_tlv_fields!(writer, { + // 0 used to be peer_node_id in LDK v0.2 and prior. + (1, next_hop, required), + (2, message, required), + }); + }, + } }, &Event::OnionMessagePeerConnected { ref peer_node_id } => { 39u8.write(writer)?; @@ -2936,13 +2952,23 @@ impl MaybeReadable for Event { 37u8 => { let mut f = || { _init_and_read_len_prefixed_tlv_fields!(reader, { - (0, peer_node_id, required), + (0, peer_node_id, option), + (1, next_hop, option), (2, message, required), }); - Ok(Some(Event::OnionMessageIntercepted { - peer_node_id: peer_node_id.0.unwrap(), - message: message.0.unwrap(), - })) + + if let Some(next_hop) = next_hop.or(peer_node_id.map(NextMessageHop::NodeId)) { + Ok(Some(Event::OnionMessageIntercepted { + next_hop, + message: message.0.unwrap(), + })) + } else { + debug_assert!( + false, + "Either next_hop or peer_node_id should always be set" + ); + Ok(None) + } }; f() }, diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 75e2aaf3c5f..b3cafcbe9b0 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -24,7 +24,7 @@ use super::offers::{OffersMessage, OffersMessageHandler}; use super::packet::{OnionMessageContents, Packet}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, DNSResolverContext, MessageContext, - MessageForwardNode, OffersContext, MESSAGE_PADDING_ROUND_OFF, + MessageForwardNode, NextMessageHop, OffersContext, MESSAGE_PADDING_ROUND_OFF, }; use crate::blinded_path::utils::is_padded; use crate::blinded_path::EmptyNodeIdLookUp; @@ -1144,9 +1144,13 @@ fn intercept_offline_peer_oms() { let mut events = release_events(&nodes[1]); assert_eq!(events.len(), 1); let onion_message = match events.remove(0) { - Event::OnionMessageIntercepted { peer_node_id, message } => { - assert_eq!(peer_node_id, final_node_vec[0].node_id); - message + Event::OnionMessageIntercepted { next_hop, message } => { + if let NextMessageHop::NodeId(peer_node_id) = next_hop { + assert_eq!(peer_node_id, final_node_vec[0].node_id); + message + } else { + panic!(); + } }, _ => panic!(), }; diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 7ef4e4a66a8..4a6e21bfa6c 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -1399,10 +1399,9 @@ impl< /// later forwarding. /// /// Interception flow: - /// 1. If an onion message for an offline peer is received, `OnionMessenger` will - /// generate an [`Event::OnionMessageIntercepted`]. Event handlers can - /// then choose to persist this onion message for later forwarding, or drop - /// it. + /// 1. If an onion message for an offline peer or unknown SCIDs is received, `OnionMessenger` + /// will generate an [`Event::OnionMessageIntercepted`]. Event handlers can then choose + /// to persist this onion message for later forwarding, or drop it. /// 2. When the offline peer later comes back online, `OnionMessenger` will /// generate an [`Event::OnionMessagePeerConnected`]. Event handlers will /// then fetch all previously intercepted onion messages for this peer. @@ -1664,7 +1663,20 @@ impl< NextMessageHop::ShortChannelId(scid) => match self.node_id_lookup.next_node_id(scid) { Some(pubkey) => pubkey, None => { - log_trace!(self.logger, "Dropping forwarded onion messager: unable to resolve next hop using SCID {} {}", scid, log_suffix); + if self.intercept_messages_for_offline_peers { + log_trace!( + self.logger, + "Generating OnionMessageIntercepted event for SCID {} {}", + scid, + log_suffix + ); + self.enqueue_intercepted_event(Event::OnionMessageIntercepted { + next_hop, + message: onion_message, + }); + return Ok(()); + } + log_trace!(self.logger, "Dropping forwarded onion message: unable to resolve next hop using SCID {} {}", scid, log_suffix); return Err(SendError::GetNodeIdFailed); }, }, @@ -1707,7 +1719,7 @@ impl< log_suffix ); self.enqueue_intercepted_event(Event::OnionMessageIntercepted { - peer_node_id: next_node_id, + next_hop, message: onion_message, }); Ok(()) From 2f52bc4854834d0d42069a6b593bff4bae395c95 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 11:59:31 +0200 Subject: [PATCH 02/22] f - Gate unknown-SCID interception behind a new flag Add an `intercept_for_unknown_scids: bool` parameter to `OnionMessenger::new_with_offline_peer_interception`. When false, we preserve the pre-0.3 behavior of dropping onion messages whose next hop is a `ShortChannelId` that cannot be resolved. When true, we generate an `Event::OnionMessageIntercepted` with a `NextMessageHop::ShortChannelId` next hop. This protects downgrade compatibility for users persisting `OnionMessageIntercepted` events who may need to roll back to LDK 0.2, since the `ShortChannelId` variant of the event is not deserializable there. Co-Authored-By: HAL 9000 --- lightning/src/events/mod.rs | 4 +++- lightning/src/ln/functional_test_utils.rs | 1 + .../src/onion_message/functional_tests.rs | 10 ++++++-- lightning/src/onion_message/messenger.rs | 23 +++++++++++++++---- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 1ea195c51a3..cae5b418af5 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1709,7 +1709,9 @@ pub enum Event { /// SCID unknown to us. /// /// This event will only be generated if the `OnionMessenger` was initialized with - /// [`OnionMessenger::new_with_offline_peer_interception`], see its docs. + /// [`OnionMessenger::new_with_offline_peer_interception`], see its docs. The + /// [`NextMessageHop::ShortChannelId`] variant is only generated if `intercept_for_unknown_scids` + /// was set when constructing the `OnionMessenger`. /// /// The offline peer should be awoken if possible on receipt of this event, such as via the LSPS5 /// protocol. diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index c1923730a3d..414cc6cc217 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -4796,6 +4796,7 @@ pub fn create_network<'a, 'b: 'a, 'c: 'b>( &chan_mgrs[i], IgnoringMessageHandler {}, IgnoringMessageHandler {}, + true, ); let gossip_sync = P2PGossipSync::new(cfgs[i].network_graph.as_ref(), None, cfgs[i].logger); let wallet_source = Arc::new(test_utils::TestWalletSource::new( diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index b3cafcbe9b0..288e86ac65a 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -275,10 +275,15 @@ fn create_nodes(num_messengers: u8) -> Vec { struct MessengerCfg { secret_override: Option, intercept_offline_peer_oms: bool, + intercept_unknown_scid_oms: bool, } impl MessengerCfg { fn new() -> Self { - Self { secret_override: None, intercept_offline_peer_oms: false } + Self { + secret_override: None, + intercept_offline_peer_oms: false, + intercept_unknown_scid_oms: false, + } } fn with_node_secret(mut self, secret: SecretKey) -> Self { self.secret_override = Some(secret); @@ -311,7 +316,7 @@ fn create_nodes_using_cfgs(cfgs: Vec) -> Vec { let async_payments_message_handler = Arc::new(TestAsyncPaymentsMessageHandler {}); let dns_resolver_message_handler = Arc::new(TestDNSResolverMessageHandler {}); let custom_message_handler = Arc::new(TestCustomMessageHandler::new()); - let messenger = if cfg.intercept_offline_peer_oms { + let messenger = if cfg.intercept_offline_peer_oms || cfg.intercept_unknown_scid_oms { OnionMessenger::new_with_offline_peer_interception( Arc::clone(&entropy_source), Arc::clone(&node_signer), @@ -322,6 +327,7 @@ fn create_nodes_using_cfgs(cfgs: Vec) -> Vec { async_payments_message_handler, dns_resolver_message_handler, Arc::clone(&custom_message_handler), + cfg.intercept_unknown_scid_oms, ) } else { OnionMessenger::new( diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 4a6e21bfa6c..cb90888aea9 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -273,6 +273,7 @@ pub struct OnionMessenger< dns_resolver_handler: DRH, custom_handler: CMH, intercept_messages_for_offline_peers: bool, + intercept_for_unknown_scids: bool, pending_intercepted_msgs_events: Mutex>, pending_peer_connected_events: Mutex>, pending_events_processor: AtomicBool, @@ -1391,6 +1392,7 @@ impl< dns_resolver, custom_handler, false, + false, ) } @@ -1398,10 +1400,18 @@ impl< /// intended to be forwarded to offline peers, we will intercept them for /// later forwarding. /// + /// If `intercept_for_unknown_scids` is set, we will additionally intercept onion messages whose + /// next hop is a [`NextMessageHop::ShortChannelId`] that cannot be resolved to a connected + /// peer, generating an [`Event::OnionMessageIntercepted`] with a + /// [`NextMessageHop::ShortChannelId`] next hop. This variant of the event was introduced in + /// LDK 0.3, so users who persist [`Event::OnionMessageIntercepted`] events and may need to + /// downgrade to LDK 0.2 must leave this disabled. + /// /// Interception flow: - /// 1. If an onion message for an offline peer or unknown SCIDs is received, `OnionMessenger` - /// will generate an [`Event::OnionMessageIntercepted`]. Event handlers can then choose - /// to persist this onion message for later forwarding, or drop it. + /// 1. If an onion message for an offline peer or (if `intercept_for_unknown_scids` is set) an + /// unknown SCID is received, `OnionMessenger` will generate an + /// [`Event::OnionMessageIntercepted`]. Event handlers can then choose to persist this + /// onion message for later forwarding, or drop it. /// 2. When the offline peer later comes back online, `OnionMessenger` will /// generate an [`Event::OnionMessagePeerConnected`]. Event handlers will /// then fetch all previously intercepted onion messages for this peer. @@ -1417,6 +1427,7 @@ impl< pub fn new_with_offline_peer_interception( entropy_source: ES, node_signer: NS, logger: L, node_id_lookup: NL, message_router: MR, offers_handler: OMH, async_payments_handler: APH, dns_resolver: DRH, custom_handler: CMH, + intercept_for_unknown_scids: bool, ) -> Self { Self::new_inner( entropy_source, @@ -1429,13 +1440,14 @@ impl< dns_resolver, custom_handler, true, + intercept_for_unknown_scids, ) } fn new_inner( entropy_source: ES, node_signer: NS, logger: L, node_id_lookup: NL, message_router: MR, offers_handler: OMH, async_payments_handler: APH, dns_resolver: DRH, custom_handler: CMH, - intercept_messages_for_offline_peers: bool, + intercept_messages_for_offline_peers: bool, intercept_for_unknown_scids: bool, ) -> Self { let mut secp_ctx = Secp256k1::new(); secp_ctx.seeded_randomize(&entropy_source.get_secure_random_bytes()); @@ -1452,6 +1464,7 @@ impl< dns_resolver_handler: dns_resolver, custom_handler, intercept_messages_for_offline_peers, + intercept_for_unknown_scids, pending_intercepted_msgs_events: Mutex::new(Vec::new()), pending_peer_connected_events: Mutex::new(Vec::new()), pending_events_processor: AtomicBool::new(false), @@ -1663,7 +1676,7 @@ impl< NextMessageHop::ShortChannelId(scid) => match self.node_id_lookup.next_node_id(scid) { Some(pubkey) => pubkey, None => { - if self.intercept_messages_for_offline_peers { + if self.intercept_for_unknown_scids { log_trace!( self.logger, "Generating OnionMessageIntercepted event for SCID {} {}", From 16784d576bb258e3489e4a23888b25acd28f85dc Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 15:32:01 +0200 Subject: [PATCH 03/22] f - Return DecodeError::InvalidValue for missing next hop Replace the `debug_assert!(false, ...)` + `Ok(None)` branch in `Event::OnionMessageIntercepted` deserialization with a plain `ok_or(DecodeError::InvalidValue)?`. Returning an error on malformed input matches the conventions used elsewhere in this file and avoids silently dropping events that were written with neither a `peer_node_id` nor a `next_hop` TLV. Co-Authored-By: HAL 9000 --- lightning/src/events/mod.rs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index cae5b418af5..850b0bacb28 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -2959,18 +2959,13 @@ impl MaybeReadable for Event { (2, message, required), }); - if let Some(next_hop) = next_hop.or(peer_node_id.map(NextMessageHop::NodeId)) { - Ok(Some(Event::OnionMessageIntercepted { - next_hop, - message: message.0.unwrap(), - })) - } else { - debug_assert!( - false, - "Either next_hop or peer_node_id should always be set" - ); - Ok(None) - } + let next_hop = next_hop + .or(peer_node_id.map(NextMessageHop::NodeId)) + .ok_or(msgs::DecodeError::InvalidValue)?; + Ok(Some(Event::OnionMessageIntercepted { + next_hop, + message: message.0.unwrap(), + })) }; f() }, From 1bfe002ba41e528e3c06c0455a3af54335ccb903 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 15:40:45 +0100 Subject: [PATCH 04/22] Test `OnionMessageIntercepted` for unknown SCID next hops Add `intercept_unknown_scid_oms` test that verifies the `OnionMessenger` correctly generates `OnionMessageIntercepted` events with a `ShortChannelId` next hop when a blinded path uses an unresolvable SCID. This complements the existing `intercept_offline_peer_oms` test which only covers the `NodeId` variant (offline peer case). Co-Authored-By: HAL 9000 --- .../src/onion_message/functional_tests.rs | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 288e86ac65a..e8f6be63904 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -1183,6 +1183,77 @@ fn intercept_offline_peer_oms() { pass_along_path(&vec![nodes.remove(1), final_node_vec.remove(0)]); } +#[test] +fn intercept_unknown_scid_oms() { + // Ensure that if OnionMessenger is initialized with + // new_with_offline_peer_interception, we will intercept OMs that use an unknown SCID as the + // next hop, generate the right events, and forward OMs when they are re-injected by the + // user. + let node_cfgs = vec![ + MessengerCfg::new(), + MessengerCfg::new().with_offline_peer_interception(), + MessengerCfg::new(), + ]; + let mut nodes = create_nodes_using_cfgs(node_cfgs); + + let peer_conn_evs = release_events(&nodes[1]); + assert_eq!(peer_conn_evs.len(), 2); + for (i, ev) in peer_conn_evs.iter().enumerate() { + match ev { + Event::OnionMessagePeerConnected { peer_node_id } => { + let node_idx = if i == 0 { 0 } else { 2 }; + assert_eq!(peer_node_id, &nodes[node_idx].node_id); + }, + _ => panic!(), + } + } + + // Use a SCID-based intermediate hop to trigger the unknown SCID interception path. Since + // we use `EmptyNodeIdLookUp`, the SCID cannot be resolved, so the OnionMessenger will + // generate an `OnionMessageIntercepted` event with a `ShortChannelId` next hop. + let scid = 42; + let message = TestCustomMessage::Pong; + let intermediate_nodes = + [MessageForwardNode { node_id: nodes[1].node_id, short_channel_id: Some(scid) }]; + let blinded_path = BlindedMessagePath::new( + &intermediate_nodes, + nodes[2].node_id, + nodes[2].messenger.node_signer.get_receive_auth_key(), + MessageContext::Custom(Vec::new()), + false, + &*nodes[2].entropy_source, + &Secp256k1::new(), + ); + let destination = Destination::BlindedPath(blinded_path); + let instructions = MessageSendInstructions::WithoutReplyPath { destination }; + + nodes[0].messenger.send_onion_message(message, instructions).unwrap(); + let mut final_node_vec = nodes.split_off(2); + pass_along_path(&nodes); + + // We expect an `OnionMessageIntercepted` event with a `ShortChannelId` next hop since the + // SCID is not resolvable via the `EmptyNodeIdLookUp`. + let mut events = release_events(&nodes[1]); + assert_eq!(events.len(), 1); + let onion_message = match events.remove(0) { + Event::OnionMessageIntercepted { next_hop, message } => { + if let NextMessageHop::ShortChannelId(intercepted_scid) = next_hop { + assert_eq!(intercepted_scid, scid); + message + } else { + panic!("Expected ShortChannelId next hop, got NodeId"); + } + }, + _ => panic!(), + }; + + // The user resolves the SCID externally and forwards the intercepted message to the + // correct peer. + nodes[1].messenger.forward_onion_message(onion_message, &final_node_vec[0].node_id).unwrap(); + final_node_vec[0].custom_message_handler.expect_message(TestCustomMessage::Pong); + pass_along_path(&vec![nodes.remove(1), final_node_vec.remove(0)]); +} + #[test] fn spec_test_vector() { let node_cfgs = [ From 86e9cda7999eeb59254ef7bb0efd19de713f6dc5 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 12:01:08 +0200 Subject: [PATCH 05/22] f - Enable intercept_for_unknown_scids in the unknown-SCID test The unknown-SCID interception is now gated on `intercept_for_unknown_scids`, so the test must opt in via a new `with_unknown_scid_interception` helper. Co-Authored-By: HAL 9000 --- lightning/src/onion_message/functional_tests.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index e8f6be63904..c1b45820e85 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -293,6 +293,10 @@ impl MessengerCfg { self.intercept_offline_peer_oms = true; self } + fn with_unknown_scid_interception(mut self) -> Self { + self.intercept_unknown_scid_oms = true; + self + } } fn create_nodes_using_cfgs(cfgs: Vec) -> Vec { @@ -1186,12 +1190,12 @@ fn intercept_offline_peer_oms() { #[test] fn intercept_unknown_scid_oms() { // Ensure that if OnionMessenger is initialized with - // new_with_offline_peer_interception, we will intercept OMs that use an unknown SCID as the - // next hop, generate the right events, and forward OMs when they are re-injected by the - // user. + // new_with_offline_peer_interception and `intercept_for_unknown_scids` set, we will + // intercept OMs that use an unknown SCID as the next hop, generate the right events, and + // forward OMs when they are re-injected by the user. let node_cfgs = vec![ MessengerCfg::new(), - MessengerCfg::new().with_offline_peer_interception(), + MessengerCfg::new().with_unknown_scid_interception(), MessengerCfg::new(), ]; let mut nodes = create_nodes_using_cfgs(node_cfgs); From d556a270be0152059399beae7f0c1193e817935a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 27 Mar 2026 15:49:58 +0100 Subject: [PATCH 06/22] Test `OnionMessageIntercepted` upgrade/downgrade with LDK 0.2 Add backwards compatibility tests for `Event::OnionMessageIntercepted` serialization to verify that: - Events serialized by LDK 0.2 (with `peer_node_id` in TLV field 0) can be deserialized by the current version as `NextMessageHop::NodeId`. - Events with `NodeId` next hop serialized by the current version can be deserialized by LDK 0.2 (which reads `peer_node_id` from field 0). - Events with `ShortChannelId` next hop (which omit TLV field 0) correctly fail to deserialize in LDK 0.2, since the `peer_node_id` field is required there. Co-Authored-By: HAL 9000 --- .../src/upgrade_downgrade_tests.rs | 115 +++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 7f607bba848..634d17dcd90 100644 --- a/lightning-tests/src/upgrade_downgrade_tests.rs +++ b/lightning-tests/src/upgrade_downgrade_tests.rs @@ -17,7 +17,10 @@ use lightning_0_2::ln::channelmanager::PaymentId as PaymentId_0_2; use lightning_0_2::ln::channelmanager::RecipientOnionFields as RecipientOnionFields_0_2; use lightning_0_2::ln::functional_test_utils as lightning_0_2_utils; use lightning_0_2::ln::msgs::ChannelMessageHandler as _; +use lightning_0_2::ln::msgs::OnionMessage as OnionMessage_0_2; +use lightning_0_2::onion_message::packet::Packet as Packet_0_2; use lightning_0_2::routing::router as router_0_2; +use lightning_0_2::util::ser::MaybeReadable as MaybeReadable_0_2; use lightning_0_2::util::ser::Writeable as _; use lightning_0_1::commitment_signed_dance as commitment_signed_dance_0_1; @@ -45,23 +48,29 @@ use lightning_0_0_125::ln::msgs::ChannelMessageHandler as _; use lightning_0_0_125::routing::router as router_0_0_125; use lightning_0_0_125::util::ser::Writeable as _; +use lightning::blinded_path::message::NextMessageHop; use lightning::chain::channelmonitor::{ANTI_REORG_DELAY, HTLC_FAIL_BACK_BUFFER}; use lightning::events::{ClosureReason, Event, HTLCHandlingFailureType}; use lightning::ln::functional_test_utils::*; +use lightning::ln::msgs; use lightning::ln::msgs::BaseMessageHandler as _; use lightning::ln::msgs::ChannelMessageHandler as _; use lightning::ln::msgs::MessageSendEvent; use lightning::ln::splicing_tests::*; use lightning::ln::types::ChannelId; +use lightning::onion_message::packet::Packet; use lightning::sign::OutputSpender; +use lightning::util::ser::{MaybeReadable, Writeable}; use lightning::util::wallet_utils::WalletSourceSync; use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use bitcoin::script::Builder; -use bitcoin::secp256k1::Secp256k1; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::{opcodes, Amount, TxOut}; +use lightning::io::Cursor; + use std::sync::Arc; #[test] @@ -701,3 +710,107 @@ fn do_upgrade_mid_htlc_forward(test: MidHtlcForwardCase) { expect_payment_claimable!(nodes[2], pay_hash, pay_secret, 1_000_000); claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], pay_preimage); } + +/// Constructs a dummy `OnionMessage` (current version) for use in serialization tests. +fn dummy_onion_message() -> msgs::OnionMessage { + let pubkey = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + msgs::OnionMessage { + blinding_point: pubkey, + onion_routing_packet: Packet { + version: 0, + public_key: pubkey, + hop_data: vec![1; 64], + hmac: [2; 32], + }, + } +} + +/// Constructs a dummy `OnionMessage` (0.2 version) for use in serialization tests. +fn dummy_onion_message_0_2() -> OnionMessage_0_2 { + let pubkey = bitcoin::secp256k1::PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[42; 32]).unwrap(), + ); + OnionMessage_0_2 { + blinding_point: pubkey, + onion_routing_packet: Packet_0_2 { + version: 0, + public_key: pubkey, + hop_data: vec![1; 64], + hmac: [2; 32], + }, + } +} + +#[test] +fn test_onion_message_intercepted_upgrade_from_0_2() { + // Ensure that an `Event::OnionMessageIntercepted` serialized by LDK 0.2 (which uses + // `peer_node_id: PublicKey` in TLV field 0) can be deserialized by the current version, + // producing `NextMessageHop::NodeId`. + let pubkey = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + + let event_0_2 = Event_0_2::OnionMessageIntercepted { + peer_node_id: pubkey, + message: dummy_onion_message_0_2(), + }; + + let serialized = lightning_0_2::util::ser::Writeable::encode(&event_0_2); + + let mut reader = Cursor::new(&serialized); + let deserialized = ::read(&mut reader).unwrap().unwrap(); + + match deserialized { + Event::OnionMessageIntercepted { next_hop, message } => { + assert_eq!(next_hop, NextMessageHop::NodeId(pubkey)); + assert_eq!(message, dummy_onion_message()); + }, + _ => panic!("Expected OnionMessageIntercepted event"), + } +} + +#[test] +fn test_onion_message_intercepted_node_id_downgrade_to_0_2() { + // Ensure that an `Event::OnionMessageIntercepted` with a `NodeId` next hop serialized by + // the current version can be deserialized by LDK 0.2 (which expects `peer_node_id` in TLV + // field 0). + let pubkey = + PublicKey::from_secret_key(&Secp256k1::new(), &SecretKey::from_slice(&[42; 32]).unwrap()); + + let event = Event::OnionMessageIntercepted { + next_hop: NextMessageHop::NodeId(pubkey), + message: dummy_onion_message(), + }; + + let serialized = event.encode(); + + let mut reader = Cursor::new(&serialized); + let deserialized = ::read(&mut reader).unwrap().unwrap(); + + match deserialized { + Event_0_2::OnionMessageIntercepted { peer_node_id, message } => { + assert_eq!(peer_node_id, pubkey); + assert_eq!(message, dummy_onion_message_0_2()); + }, + _ => panic!("Expected OnionMessageIntercepted event"), + } +} + +#[test] +fn test_onion_message_intercepted_scid_downgrade_to_0_2() { + // Ensure that an `Event::OnionMessageIntercepted` with a `ShortChannelId` next hop + // serialized by the current version cannot be deserialized by LDK 0.2, since the + // `peer_node_id` field (0) is not written for SCID variants and LDK 0.2 requires it. + let event = Event::OnionMessageIntercepted { + next_hop: NextMessageHop::ShortChannelId(42), + message: dummy_onion_message(), + }; + + let serialized = event.encode(); + + // LDK 0.2 will try to read field 0 as required. Since it's absent, the read will fail. + let mut reader = Cursor::new(&serialized); + let result = ::read(&mut reader); + assert!(result.is_err(), "LDK 0.2 should fail to decode a ShortChannelId variant"); +} From cdca1347aa8b3efbb9b8b6aaf9db6e510dc4c0a8 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 07/22] Add an LSPS2-aware `BOLT12` router wrapper Introduce `LSPS2BOLT12Router` to allow registering LSPS2 invoice parameters and build blinded payment paths through the negotiated intercept SCIDs. All other routing behavior still delegates to the wrapped router. Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps2/mod.rs | 1 + lightning-liquidity/src/lsps2/router.rs | 684 ++++++++++++++++++++++++ 2 files changed, 685 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..d414a55d468 --- /dev/null +++ b/lightning-liquidity/src/lsps2/router.rs @@ -0,0 +1,684 @@ +// 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::vec::Vec; + +use crate::prelude::{new_hash_map, HashMap}; +use crate::sync::Mutex; + +use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; + +use lightning::blinded_path::payment::{ + BlindedPaymentPath, ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, + PaymentRelay, ReceiveTlvs, +}; +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: u32, +} + +/// A router wrapper that injects LSPS2-specific BOLT12 blinded payment paths for registered +/// intercept SCIDs while delegating all other blinded path creation behaviors to the inner router. +/// +/// For **payment** blinded paths (in invoices), it returns the intercept SCID as the forwarding +/// hop so that the LSP can intercept the HTLC and open a JIT channel. +/// +/// 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 { + inner_router: R, + entropy_source: ES, + scid_to_invoice_params: Mutex>, +} + +impl LSPS2BOLT12Router { + /// Constructs a new wrapper around `inner_router`. + pub fn new(inner_router: R, entropy_source: ES) -> Self { + Self { inner_router, entropy_source, scid_to_invoice_params: Mutex::new(new_hash_map()) } + } + + /// Registers LSPS2 parameters to be used when generating blinded payment paths for + /// `intercept_scid`. + pub fn register_intercept_scid( + &self, intercept_scid: u64, invoice_params: LSPS2Bolt12InvoiceParameters, + ) -> Option { + debug_assert_eq!(intercept_scid, invoice_params.intercept_scid); + self.scid_to_invoice_params.lock().unwrap().insert(intercept_scid, invoice_params) + } + + /// Removes any previously registered LSPS2 parameters for `intercept_scid`. + pub fn deregister_intercept_scid( + &self, intercept_scid: u64, + ) -> Option { + self.scid_to_invoice_params.lock().unwrap().remove(&intercept_scid) + } + + /// Clears all LSPS2 parameters previously registered via [`Self::register_intercept_scid`]. + pub fn clear_registered_intercept_scids(&self) { + self.scid_to_invoice_params.lock().unwrap().clear(); + } + + fn registered_lsps2_params( + &self, payment_context: &PaymentContext, + ) -> Vec { + // We intentionally only match `Bolt12Offer` here and not `AsyncBolt12Offer`, as LSPS2 + // JIT channels are not applicable to async (always-online) BOLT12 offer flows. + match payment_context { + PaymentContext::Bolt12Offer(_) => {}, + _ => return Vec::new(), + }; + + self.scid_to_invoice_params.lock().unwrap().values().copied().collect() + } +} + +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 we have no LSPS2 parameters registered, just fallback to the inner router's paths. + let all_params = self.registered_lsps2_params(&tlvs.payment_context); + if all_params.is_empty() { + return inner_res; + } + + // For registered 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 = match u16::try_from(lsps2_invoice_params.cltv_expiry_delta) { + Ok(cltv_expiry_delta) => PaymentRelay { + cltv_expiry_delta, + fee_proportional_millionths: 0, + fee_base_msat: 0, + }, + Err(_) => continue, + }; + let payment_constraints = PaymentConstraints { + max_cltv_expiry: tlvs + .payment_constraints + .max_cltv_expiry + .saturating_add(lsps2_invoice_params.cltv_expiry_delta), + htlc_minimum_msat: 0, + }; + + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: lsps2_invoice_params.intercept_scid, + payment_relay, + payment_constraints, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: lsps2_invoice_params.counterparty_node_id, + htlc_maximum_msat: u64::MAX, + }; + + // We deliberately use `BlindedPaymentPath::new` without dummy hops here. Since the LSP + // is 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}; + + 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::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] + } + } + + 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 { + ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id, + 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 {}), + } + } + + #[test] + fn creates_lsps2_blinded_path_for_registered_intercept_scid() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, entropy_source); + + 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); + + router.register_intercept_scid( + expected_scid, + 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(offer_id), + 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 as u16 + 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 skips_out_of_range_cltv_delta_and_keeps_valid_paths() { + let inner_router = MockRouter::new(); + let recipient = pubkey(13); + let secp_ctx = Secp256k1::new(); + + let existing_tlvs = bolt12_offer_tlvs(OfferId([11; 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 entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, entropy_source); + + let valid_scid = 21; + router.register_intercept_scid( + valid_scid, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(12), + intercept_scid: valid_scid, + cltv_expiry_delta: 48, + }, + ); + router.register_intercept_scid( + 22, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(14), + intercept_scid: 22, + cltv_expiry_delta: u32::from(u16::MAX) + 1, + }, + ); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs(OfferId([11; 32])), + Some(1_000), + &secp_ctx, + ) + .unwrap(); + + assert_eq!(paths.len(), 2); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn can_deregister_intercept_scid() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, entropy_source); + + let intercept_scid = 7; + let params = LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(2), + intercept_scid, + cltv_expiry_delta: 40, + }; + assert_eq!(router.register_intercept_scid(intercept_scid, params), None); + assert_eq!(router.deregister_intercept_scid(intercept_scid), Some(params)); + assert_eq!(router.deregister_intercept_scid(intercept_scid), None); + } + + #[test] + fn can_clear_registered_intercept_scids() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, entropy_source); + + router.register_intercept_scid( + 7, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(2), + intercept_scid: 7, + cltv_expiry_delta: 40, + }, + ); + router.register_intercept_scid( + 8, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(3), + intercept_scid: 8, + cltv_expiry_delta: 41, + }, + ); + + router.clear_registered_intercept_scids(); + assert_eq!(router.deregister_intercept_scid(7), None); + assert_eq!(router.deregister_intercept_scid(8), None); + } + + #[test] + fn creates_paths_for_all_registered_intercept_scids() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, entropy_source); + + 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; + + router.register_intercept_scid( + scid_a, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id_a, + intercept_scid: scid_a, + cltv_expiry_delta: 48, + }, + ); + router.register_intercept_scid( + scid_b, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id_b, + intercept_scid: scid_b, + cltv_expiry_delta: 72, + }, + ); + + 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(OfferId([8; 32])), + 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(inner_router, TestEntropy); + + let intercept_scid = 42; + router.register_intercept_scid( + intercept_scid, + 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(OfferId([8; 32])), + 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(inner_router, TestEntropy); + + let intercept_scid = 42; + router.register_intercept_scid( + intercept_scid, + 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(OfferId([8; 32])), + 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 f4e53b9a4905be7a52678d075b68e2c8a4274b01 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 22 Apr 2026 10:42:28 +0200 Subject: [PATCH 08/22] f - Key LSPS2 router on (counterparty_node_id, intercept_scid) Intercept SCIDs are only unique on a per-node basis, so two different peers can legitimately hand out the same SCID. Keying the registration map on the SCID alone would let the second registration silently overwrite the first. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/router.rs | 57 +++++++++++++++++++------ 1 file changed, 45 insertions(+), 12 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index d414a55d468..4417f6e1a0c 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -54,7 +54,7 @@ pub struct LSPS2Bolt12InvoiceParameters { pub struct LSPS2BOLT12Router { inner_router: R, entropy_source: ES, - scid_to_invoice_params: Mutex>, + scid_to_invoice_params: Mutex>, } impl LSPS2BOLT12Router { @@ -65,18 +65,23 @@ impl LSPS2BOLT12Router { /// Registers LSPS2 parameters to be used when generating blinded payment paths for /// `intercept_scid`. + /// + /// Intercept SCIDs are only unique on a per-node basis, so registrations are keyed on the + /// `(counterparty_node_id, intercept_scid)` pair from `invoice_params`. pub fn register_intercept_scid( &self, intercept_scid: u64, invoice_params: LSPS2Bolt12InvoiceParameters, ) -> Option { debug_assert_eq!(intercept_scid, invoice_params.intercept_scid); - self.scid_to_invoice_params.lock().unwrap().insert(intercept_scid, invoice_params) + let key = (invoice_params.counterparty_node_id, intercept_scid); + self.scid_to_invoice_params.lock().unwrap().insert(key, invoice_params) } - /// Removes any previously registered LSPS2 parameters for `intercept_scid`. + /// Removes any previously registered LSPS2 parameters for + /// `(counterparty_node_id, intercept_scid)`. pub fn deregister_intercept_scid( - &self, intercept_scid: u64, + &self, counterparty_node_id: PublicKey, intercept_scid: u64, ) -> Option { - self.scid_to_invoice_params.lock().unwrap().remove(&intercept_scid) + self.scid_to_invoice_params.lock().unwrap().remove(&(counterparty_node_id, intercept_scid)) } /// Clears all LSPS2 parameters previously registered via [`Self::register_intercept_scid`]. @@ -482,14 +487,40 @@ mod tests { let router = LSPS2BOLT12Router::new(inner_router, entropy_source); let intercept_scid = 7; + let counterparty_node_id = pubkey(2); let params = LSPS2Bolt12InvoiceParameters { - counterparty_node_id: pubkey(2), + counterparty_node_id, intercept_scid, cltv_expiry_delta: 40, }; assert_eq!(router.register_intercept_scid(intercept_scid, params), None); - assert_eq!(router.deregister_intercept_scid(intercept_scid), Some(params)); - assert_eq!(router.deregister_intercept_scid(intercept_scid), None); + assert_eq!( + router.deregister_intercept_scid(counterparty_node_id, intercept_scid), + Some(params) + ); + assert_eq!(router.deregister_intercept_scid(counterparty_node_id, intercept_scid), None); + } + + #[test] + fn same_scid_from_different_peers_does_not_collide() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, entropy_source); + + let shared_scid = 42; + let params_a = LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(2), + intercept_scid: shared_scid, + cltv_expiry_delta: 40, + }; + let params_b = LSPS2Bolt12InvoiceParameters { + counterparty_node_id: pubkey(3), + intercept_scid: shared_scid, + cltv_expiry_delta: 41, + }; + + assert_eq!(router.register_intercept_scid(shared_scid, params_a), None); + assert_eq!(router.register_intercept_scid(shared_scid, params_b), None); } #[test] @@ -498,10 +529,12 @@ mod tests { let entropy_source = TestEntropy; let router = LSPS2BOLT12Router::new(inner_router, entropy_source); + let peer_a = pubkey(2); + let peer_b = pubkey(3); router.register_intercept_scid( 7, LSPS2Bolt12InvoiceParameters { - counterparty_node_id: pubkey(2), + counterparty_node_id: peer_a, intercept_scid: 7, cltv_expiry_delta: 40, }, @@ -509,15 +542,15 @@ mod tests { router.register_intercept_scid( 8, LSPS2Bolt12InvoiceParameters { - counterparty_node_id: pubkey(3), + counterparty_node_id: peer_b, intercept_scid: 8, cltv_expiry_delta: 41, }, ); router.clear_registered_intercept_scids(); - assert_eq!(router.deregister_intercept_scid(7), None); - assert_eq!(router.deregister_intercept_scid(8), None); + assert_eq!(router.deregister_intercept_scid(peer_a, 7), None); + assert_eq!(router.deregister_intercept_scid(peer_b, 8), None); } #[test] From cde093c966be265840206536c86a9634660900fe Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 22 Apr 2026 10:55:52 +0200 Subject: [PATCH 09/22] f - Drop unneeded Send + Sync bounds on EntropySource The bounds were never required by the struct or its Router impl. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/router.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index 4417f6e1a0c..f65273a324d 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -51,13 +51,13 @@ pub struct LSPS2Bolt12InvoiceParameters { /// [`MessageRouter`]: lightning::onion_message::messenger::MessageRouter /// [`Event::OnionMessageIntercepted`]: lightning::events::Event::OnionMessageIntercepted /// [`Event::HTLCIntercepted`]: lightning::events::Event::HTLCIntercepted -pub struct LSPS2BOLT12Router { +pub struct LSPS2BOLT12Router { inner_router: R, entropy_source: ES, scid_to_invoice_params: Mutex>, } -impl LSPS2BOLT12Router { +impl LSPS2BOLT12Router { /// Constructs a new wrapper around `inner_router`. pub fn new(inner_router: R, entropy_source: ES) -> Self { Self { inner_router, entropy_source, scid_to_invoice_params: Mutex::new(new_hash_map()) } @@ -103,7 +103,7 @@ impl LSPS2BOLT12Router { } } -impl Router for LSPS2BOLT12Router { +impl Router for LSPS2BOLT12Router { fn find_route( &self, payer: &PublicKey, route_params: &RouteParameters, first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, From 5917ad8da32a52f3df465bb3a6f46f6cec67585e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 10/22] Document the LSPS2 `BOLT12` router flow Describe how `InvoiceParametersReady` feeds both the existing `BOLT11` route-hint flow and the new `LSPS2BOLT12Router` registration path for `BOLT12` offers. Co-Authored-By: HAL 9000 --- 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 502429b79ec..9ca20863387 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 85d0aca802eee5c1ffb547e4f231ab3b74da67df Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 11/22] Test LSPS2 router payment-path generation for BOLT12 Exercise the LSPS2 buy flow and assert that registered LSPS2 parameters produce a blinded payment path whose first forwarding hop uses the negotiated intercept SCID. Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- .../tests/lsps2_integration_tests.rs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index fbff2eae4cd..2781b443f11 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -17,6 +17,11 @@ use lightning::ln::msgs::BaseMessageHandler; use lightning::ln::msgs::ChannelMessageHandler; use lightning::ln::msgs::MessageSendEvent; use lightning::ln::types::ChannelId; +use lightning::offers::invoice_request::InvoiceRequestFields; +use lightning::offers::offer::OfferId; +use lightning::onion_message::messenger::NullMessageRouter; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::ReceiveAuthKey; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::LSPSDateTime; @@ -24,11 +29,16 @@ 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}; 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, @@ -58,6 +68,46 @@ 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(()) + } +} + fn build_lsps2_configs() -> ([u8; 32], LiquidityServiceConfig, LiquidityClientConfig) { let promise_secret = [42; 32]; let lsps2_service_config = LSPS2ServiceConfig { promise_secret }; @@ -1478,6 +1528,90 @@ fn execute_lsps2_dance( } } +#[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 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 intercept_scid = lsps_nodes.service_node.node.get_intercept_scid(); + let cltv_expiry_delta = 72; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + 42, + cltv_expiry_delta, + promise_secret, + Some(250_000), + 1_000, + ); + + let inner_router = FailingRouter::new(); + let router = LSPS2BOLT12Router::new( + inner_router, + NullMessageRouter {}, + lsps_nodes.client_node.keys_manager, + ); + let offer_id = OfferId([42; 32]); + + router.register_offer( + offer_id, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: service_node_id, + intercept_scid, + cltv_expiry_delta, + }, + ); + + 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, + 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 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(); + + 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); + + 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)); +} + 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, From aa59457dd393ae278a0735cfc37dc3dd54f87389 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 12/22] Add a blinded-payment-path override to test utilities Allow tests to inject a custom `create_blinded_payment_paths` hook while preserving the normal `ReceiveTlvs` bindings. This makes it possible to exercise LSPS2-specific `BOLT12` path construction in integration tests. Co-Authored-By: HAL 9000 --- 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 57f9ba6b22f..25a3054afc4 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -166,6 +166,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>, @@ -178,6 +195,7 @@ pub struct TestRouter<'a> { pub network_graph: Arc>, pub next_routes: Mutex>)>>, pub next_blinded_payment_paths: Mutex>, + pub override_create_blinded_payment_paths: Mutex>, pub scorer: &'a RwLock, } @@ -189,6 +207,7 @@ impl<'a> TestRouter<'a> { let entropy_source = Arc::new(RandomBytes::new([42; 32])); let next_routes = Mutex::new(VecDeque::new()); let next_blinded_payment_paths = Mutex::new(Vec::new()); + let override_create_blinded_payment_paths = Mutex::new(None); Self { router: DefaultRouter::new( Arc::clone(&network_graph), @@ -200,6 +219,7 @@ impl<'a> TestRouter<'a> { network_graph, next_routes, next_blinded_payment_paths, + override_create_blinded_payment_paths, scorer, } } @@ -322,6 +342,12 @@ impl<'a> Router for TestRouter<'a> { first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, secp_ctx: &Secp256k1, ) -> Result, ()> { + 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( @@ -367,6 +393,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> { @@ -379,6 +406,7 @@ impl<'a> TestMessageRouter<'a> { entropy_source, )), peers_override: Mutex::new(Vec::new()), + forward_node_scid_override: Mutex::new(new_hash_map()), } } @@ -391,6 +419,7 @@ impl<'a> TestMessageRouter<'a> { entropy_source, )), peers_override: Mutex::new(Vec::new()), + forward_node_scid_override: Mutex::new(new_hash_map()), } } } @@ -422,9 +451,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 aa77493bceb45de931ff1c8138b4d2351650028f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 25 Mar 2026 09:19:41 +0100 Subject: [PATCH 13/22] Add an LSPS2-BOLT12 end-to-end integration test Cover the full offer-payment flow from onion-message invoice exchange through HTLC interception, JIT channel opening, and settlement. This confirms the LSPS2 router and service handler work together in the integrated path. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/router.rs | 6 +- .../tests/lsps2_integration_tests.rs | 480 +++++++++++++++++- 2 files changed, 471 insertions(+), 15 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index f65273a324d..7bcadb68477 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -41,8 +41,10 @@ pub struct LSPS2Bolt12InvoiceParameters { /// A router wrapper that injects LSPS2-specific BOLT12 blinded payment paths for registered /// intercept SCIDs while delegating all other blinded path creation behaviors to the inner router. /// -/// For **payment** blinded paths (in invoices), it returns the intercept SCID as the forwarding -/// hop so that the LSP can intercept the HTLC and open a JIT channel. +/// 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 diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 2781b443f11..698fe317c88 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -7,7 +7,8 @@ use common::{ get_lsps_message, LSPSNodes, LSPSNodesWithPayer, LiquidityNode, }; -use lightning::events::{ClosureReason, Event}; +use lightning::blinded_path::message::NextMessageHop; +use lightning::events::{ClosureReason, Event, EventsProvider}; use lightning::get_event_msg; use lightning::ln::channelmanager::{ OptionalBolt11PaymentParams, PaymentId, TrustedChannelFeatures, @@ -16,12 +17,12 @@ 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::NullMessageRouter; use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; -use lightning::sign::ReceiveAuthKey; +use lightning::sign::{RandomBytes, ReceiveAuthKey}; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::LSPSDateTime; @@ -1553,15 +1554,9 @@ fn bolt12_custom_router_uses_lsps2_intercept_scid() { ); let inner_router = FailingRouter::new(); - let router = LSPS2BOLT12Router::new( - inner_router, - NullMessageRouter {}, - lsps_nodes.client_node.keys_manager, - ); - let offer_id = OfferId([42; 32]); - - router.register_offer( - offer_id, + let router = LSPS2BOLT12Router::new(inner_router, lsps_nodes.client_node.keys_manager); + router.register_intercept_scid( + intercept_scid, LSPS2Bolt12InvoiceParameters { counterparty_node_id: service_node_id, intercept_scid, @@ -1573,7 +1568,7 @@ fn bolt12_custom_router_uses_lsps2_intercept_scid() { 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, + offer_id: OfferId([42; 32]), invoice_request: InvoiceRequestFields { payer_signing_pubkey: lsps_nodes.payer_node.node.get_our_node_id(), quantity: None, @@ -1612,6 +1607,465 @@ fn bolt12_custom_router_uses_lsps2_intercept_scid() { assert_eq!(*lookup.short_channel_id.lock().unwrap(), Some(intercept_scid)); } +#[test] +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( + 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 = 42; + let cltv_expiry_delta: u32 = 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, + ); + + // 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); + + #[cfg(c_bindings)] + let offer = { + let mut offer_builder = client_node.node.create_offer_builder().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() + .unwrap() + .amount_msats(payment_size_msat.unwrap()) + .build() + .unwrap(); + + let lsps2_router = Arc::new(LSPS2BOLT12Router::new( + FailingRouter::new(), + Arc::new(RandomBytes::new([43; 32])), + )); + lsps2_router.register_intercept_scid( + intercept_scid, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: service_node_id, + intercept_scid, + cltv_expiry_delta, + }, + ); + + 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); + let (payment_hash, expected_outbound_amount_msat) = 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, + *payment_hash, + ) + .unwrap(); + (*payment_hash, expected_outbound_amount_msat) + }, + 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"); + + 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(); + + 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(); + + 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), + }; + + 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: u32 = 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); + + #[cfg(c_bindings)] + let offer = { + let mut offer_builder = client_node.node.create_offer_builder().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() + .unwrap() + .amount_msats(payment_size_msat.unwrap()) + .build() + .unwrap(); + + let lsps2_router = Arc::new(LSPS2BOLT12Router::new( + FailingRouter::new(), + Arc::new(RandomBytes::new([43; 32])), + )); + lsps2_router.register_intercept_scid( + intercept_scid, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: service_node_id, + intercept_scid, + cltv_expiry_delta, + }, + ); + + 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, From b4d605526eaf4fbcf9ea68c7a947ef158d30f054 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 8 Apr 2026 15:51:44 +0200 Subject: [PATCH 14/22] Add an async-payment-via-LSPS2 integration test Test the full end-to-end flow of an async payment through an LSPS2 JIT channel: static invoice server setup, `LSPS2BOLT12Router` registration, async payment onion message exchange (`HeldHtlcAvailable`/`ReleaseHeldHtlc`), HTLC interception, JIT channel open, and payment claim. Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- .../tests/lsps2_integration_tests.rs | 415 +++++++++++++++++- 1 file changed, 414 insertions(+), 1 deletion(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 698fe317c88..49dc4e3f194 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -7,7 +7,7 @@ use common::{ get_lsps_message, LSPSNodes, LSPSNodesWithPayer, LiquidityNode, }; -use lightning::blinded_path::message::NextMessageHop; +use lightning::blinded_path::message::{BlindedMessagePath, NextMessageHop}; use lightning::events::{ClosureReason, Event, EventsProvider}; use lightning::get_event_msg; use lightning::ln::channelmanager::{ @@ -2940,3 +2940,416 @@ 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 lsps2_router = Arc::new(LSPS2BOLT12Router::new( + FailingRouter::new(), + Arc::new(RandomBytes::new([43; 32])), + )); + lsps2_router.register_intercept_scid( + intercept_scid, + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: service_node_id, + intercept_scid, + cltv_expiry_delta, + }, + ); + + 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(); + + // Trigger OfferPathsRequest from client to service. timer_tick_occurred may queue multiple + // OfferPathsRequest messages; forward all of them to the service. + client_node.inner.node.timer_tick_occurred(); + 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 2d7e835836a173edf236de771aebf37c69e9b377 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 3 Apr 2026 15:32:40 +0200 Subject: [PATCH 15/22] Add async offer refresh readiness APIs To enable suppoort for async payments via LSPS2 JIT channels, we expose explicit async receive-offer refresh and readiness waiting so integrators can sequence external setup before relying on a ready async offer, instead of polling timer ticks. Generated with AI assistance. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/router.rs | 7 ++-- lightning/src/ln/channelmanager.rs | 56 ++++++++++++++++++------- lightning/src/offers/flow.rs | 24 +++++++---- 3 files changed, 62 insertions(+), 25 deletions(-) diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs index 7bcadb68477..6b9922d6852 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -94,10 +94,11 @@ impl LSPS2BOLT12Router { fn registered_lsps2_params( &self, payment_context: &PaymentContext, ) -> Vec { - // We intentionally only match `Bolt12Offer` here and not `AsyncBolt12Offer`, as LSPS2 - // JIT channels are not applicable to async (always-online) BOLT12 offer flows. + // 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::Bolt12Offer(_) | PaymentContext::AsyncBolt12Offer(_) => {}, _ => return Vec::new(), }; diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 73d9a67f50f..f42738608f7 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5857,30 +5857,46 @@ 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<(), ()> { 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( 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(()) => {}, - } + ) } #[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" + ); + }) + } + + /// Waits for an async receive offer to become ready after the interactive static-invoice + /// protocol completes. + #[cfg(feature = "std")] + pub fn await_async_receive_offer(&self, max_wait: Duration) -> Result { + if let Ok(offer) = self.get_async_receive_offer() { + return Ok(offer); + } + + if !self.flow.wait_for_async_receive_offer_ready(max_wait) { + return Err(()); + } + + self.get_async_receive_offer() } /// Should be called after handling an [`Event::PersistStaticInvoice`], where the `Responder` @@ -9101,7 +9117,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 @@ -15807,7 +15828,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/flow.rs b/lightning/src/offers/flow.rs index 2edcbc8aba8..77f406eacc9 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -61,6 +61,8 @@ use crate::sync::{Mutex, RwLock}; use crate::types::payment::{PaymentHash, PaymentSecret}; use crate::util::logger::Logger; use crate::util::ser::Writeable; +#[cfg(feature = "std")] +use crate::util::wakers::Notifier; /// A BOLT12 offers code and flow utility provider, which facilitates /// BOLT12 builder generation and onion message handling. @@ -87,6 +89,8 @@ pub struct OffersMessageFlow { pending_async_payments_messages: Mutex>, async_receive_offer_cache: Mutex, + #[cfg(feature = "std")] + async_receive_offer_ready_notifier: Notifier, logger: L, } @@ -116,6 +120,8 @@ impl OffersMessageFlow { pending_async_payments_messages: Mutex::new(Vec::new()), async_receive_offer_cache: Mutex::new(AsyncReceiveOfferCache::new()), + #[cfg(feature = "std")] + async_receive_offer_ready_notifier: Notifier::new(), logger, } @@ -1361,7 +1367,6 @@ impl OffersMessageFlow { Some(idx) => idx, None => return Ok(()), }; - // If we need new offers, send out offer paths request messages to the static invoice server. let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths { path_absolute_expiry: duration_since_epoch @@ -1475,7 +1480,6 @@ impl OffersMessageFlow { }, _ => return None, }; - // Create the blinded paths that will be included in the async recipient's offer. let (offer_paths, paths_expiry) = { let path_absolute_expiry = @@ -1537,7 +1541,6 @@ impl OffersMessageFlow { }, _ => return None, }; - { // Only respond with `ServeStaticInvoice` if we actually need a new offer built. let mut cache = self.async_receive_offer_cache.lock().unwrap(); @@ -1569,7 +1572,6 @@ impl OffersMessageFlow { return None; }, }; - let (invoice, forward_invoice_request_path) = match self.create_static_invoice_for_server( &offer, offer_nonce, @@ -1583,7 +1585,6 @@ impl OffersMessageFlow { return None; }, }; - if let Err(()) = self.async_receive_offer_cache.lock().unwrap().cache_pending_offer( offer, message.paths_absolute_expiry, @@ -1595,7 +1596,6 @@ impl OffersMessageFlow { log_error!(self.logger, "Failed to cache pending offer"); return None; } - let reply_path_context = { MessageContext::AsyncPayments(AsyncPaymentsContext::StaticInvoicePersisted { offer_id, @@ -1711,7 +1711,17 @@ 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); + #[cfg(feature = "std")] + if updated { + self.async_receive_offer_ready_notifier.notify(); + } + updated + } + + #[cfg(feature = "std")] + pub(crate) fn wait_for_async_receive_offer_ready(&self, max_wait: Duration) -> bool { + self.async_receive_offer_ready_notifier.get_future().wait_timeout(max_wait) } /// Get the encoded [`AsyncReceiveOfferCache`] for persistence. From 7f69689b3c3a296755e9f55dd8bf740f1cd696f2 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 15:48:21 +0200 Subject: [PATCH 16/22] f - Expose an async / Future-based async receive offer API Replace the blocking `await_async_receive_offer(Duration)` with an `async fn await_async_receive_offer(&self) -> Result` and a new `get_async_receive_offer_ready_future() -> Future` helper. Async callers can `.await` the offer directly; synchronous callers can fetch the underlying [`Future`] and call `wait_timeout` on it, which lets them combine with whatever timing primitive their runtime provides. Co-Authored-By: HAL 9000 --- lightning/src/ln/channelmanager.rs | 29 ++++++++++++++++++++++------- lightning/src/offers/flow.rs | 12 ++++++++++-- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f42738608f7..38320786749 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5884,21 +5884,36 @@ impl< }) } - /// Waits for an async receive offer to become ready after the interactive static-invoice - /// protocol completes. + /// 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. 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 + /// [`Future::wait_timeout`]: crate::util::wakers::Future::wait_timeout #[cfg(feature = "std")] - pub fn await_async_receive_offer(&self, max_wait: Duration) -> Result { + pub async fn await_async_receive_offer(&self) -> Result { if let Ok(offer) = self.get_async_receive_offer() { return Ok(offer); } - if !self.flow.wait_for_async_receive_offer_ready(max_wait) { - return Err(()); - } - + 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 + #[cfg(feature = "std")] + 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` /// comes from [`Event::PersistStaticInvoice::invoice_persisted_path`]. pub fn static_invoice_persisted(&self, invoice_persisted_path: Responder) { diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 77f406eacc9..09b3b2b3ea1 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -62,6 +62,8 @@ use crate::types::payment::{PaymentHash, PaymentSecret}; use crate::util::logger::Logger; use crate::util::ser::Writeable; #[cfg(feature = "std")] +use crate::util::wakers::Future; +#[cfg(feature = "std")] use crate::util::wakers::Notifier; /// A BOLT12 offers code and flow utility provider, which facilitates @@ -1719,9 +1721,15 @@ impl OffersMessageFlow { updated } + /// Returns a [`Future`] that completes when an async receive offer is ready, i.e., after the + /// interactive static-invoice protocol completes. + /// + /// Callers can either `.await` the returned [`Future`] in an async context or call + /// [`Future::wait_timeout`] from a synchronous context. After it completes, use + /// [`Self::get_async_receive_offer`] to retrieve the offer. #[cfg(feature = "std")] - pub(crate) fn wait_for_async_receive_offer_ready(&self, max_wait: Duration) -> bool { - self.async_receive_offer_ready_notifier.get_future().wait_timeout(max_wait) + 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 21e8600469c851868a592538ca95a5cc8bba8c79 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 24 Apr 2026 11:01:10 +0200 Subject: [PATCH 17/22] f - Drop unnecessary std gates and restore unrelated whitespace `Notifier`, `Future`, and `.await` are all available in no-std; only `Future::wait_timeout` requires std. Stop gating the notifier field and callers on `std`, and also revert the incidental blank-line removals that crept into the async-receive-offer refresh changes. Co-Authored-By: HAL 9000 --- lightning/src/ln/channelmanager.rs | 2 -- lightning/src/offers/flow.rs | 15 +++++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 38320786749..18e9508f4eb 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5893,7 +5893,6 @@ impl< /// /// [`Future`]: crate::util::wakers::Future /// [`Future::wait_timeout`]: crate::util::wakers::Future::wait_timeout - #[cfg(feature = "std")] pub async fn await_async_receive_offer(&self) -> Result { if let Ok(offer) = self.get_async_receive_offer() { return Ok(offer); @@ -5909,7 +5908,6 @@ impl< /// /// [`Future`]: crate::util::wakers::Future /// [`OffersMessageFlow::get_async_receive_offer_ready_future`]: crate::offers::flow::OffersMessageFlow::get_async_receive_offer_ready_future - #[cfg(feature = "std")] pub fn get_async_receive_offer_ready_future(&self) -> crate::util::wakers::Future { self.flow.get_async_receive_offer_ready_future() } diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 09b3b2b3ea1..cbc68a8ebd6 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -61,10 +61,7 @@ use crate::sync::{Mutex, RwLock}; use crate::types::payment::{PaymentHash, PaymentSecret}; use crate::util::logger::Logger; use crate::util::ser::Writeable; -#[cfg(feature = "std")] -use crate::util::wakers::Future; -#[cfg(feature = "std")] -use crate::util::wakers::Notifier; +use crate::util::wakers::{Future, Notifier}; /// A BOLT12 offers code and flow utility provider, which facilitates /// BOLT12 builder generation and onion message handling. @@ -91,7 +88,6 @@ pub struct OffersMessageFlow { pending_async_payments_messages: Mutex>, async_receive_offer_cache: Mutex, - #[cfg(feature = "std")] async_receive_offer_ready_notifier: Notifier, logger: L, @@ -122,7 +118,6 @@ impl OffersMessageFlow { pending_async_payments_messages: Mutex::new(Vec::new()), async_receive_offer_cache: Mutex::new(AsyncReceiveOfferCache::new()), - #[cfg(feature = "std")] async_receive_offer_ready_notifier: Notifier::new(), logger, @@ -1369,6 +1364,7 @@ impl OffersMessageFlow { Some(idx) => idx, None => return Ok(()), }; + // If we need new offers, send out offer paths request messages to the static invoice server. let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths { path_absolute_expiry: duration_since_epoch @@ -1482,6 +1478,7 @@ impl OffersMessageFlow { }, _ => return None, }; + // Create the blinded paths that will be included in the async recipient's offer. let (offer_paths, paths_expiry) = { let path_absolute_expiry = @@ -1543,6 +1540,7 @@ impl OffersMessageFlow { }, _ => return None, }; + { // Only respond with `ServeStaticInvoice` if we actually need a new offer built. let mut cache = self.async_receive_offer_cache.lock().unwrap(); @@ -1574,6 +1572,7 @@ impl OffersMessageFlow { return None; }, }; + let (invoice, forward_invoice_request_path) = match self.create_static_invoice_for_server( &offer, offer_nonce, @@ -1587,6 +1586,7 @@ impl OffersMessageFlow { return None; }, }; + if let Err(()) = self.async_receive_offer_cache.lock().unwrap().cache_pending_offer( offer, message.paths_absolute_expiry, @@ -1598,6 +1598,7 @@ impl OffersMessageFlow { log_error!(self.logger, "Failed to cache pending offer"); return None; } + let reply_path_context = { MessageContext::AsyncPayments(AsyncPaymentsContext::StaticInvoicePersisted { offer_id, @@ -1714,7 +1715,6 @@ impl OffersMessageFlow { pub fn handle_static_invoice_persisted(&self, context: AsyncPaymentsContext) -> bool { let mut cache = self.async_receive_offer_cache.lock().unwrap(); let updated = cache.static_invoice_persisted(context); - #[cfg(feature = "std")] if updated { self.async_receive_offer_ready_notifier.notify(); } @@ -1727,7 +1727,6 @@ impl OffersMessageFlow { /// Callers can either `.await` the returned [`Future`] in an async context or call /// [`Future::wait_timeout`] from a synchronous context. After it completes, use /// [`Self::get_async_receive_offer`] to retrieve the offer. - #[cfg(feature = "std")] pub fn get_async_receive_offer_ready_future(&self) -> Future { self.async_receive_offer_ready_notifier.get_future() } From ecf232219d2f1e3289222e69d4fc3daed1cc8a95 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 24 Apr 2026 11:08:35 +0200 Subject: [PATCH 18/22] f - Gate Future::wait_timeout doc links on std Now that `get_async_receive_offer_ready_future` and `await_async_receive_offer` are available in no-std, their rustdoc intra-doc links to `Future::wait_timeout` (which is itself still std-only) break the no-std rustdoc build. Wrap the sentence and reference definition that mention `Future::wait_timeout` in `cfg_attr(feature = "std", doc = ...)` so the links only appear when they can resolve. Co-Authored-By: HAL 9000 --- lightning/src/ln/channelmanager.rs | 12 +++++++++--- lightning/src/offers/flow.rs | 10 +++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 18e9508f4eb..1b5991c1767 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5888,11 +5888,17 @@ impl< /// protocol completes, or immediately if one is already available. /// /// Callers that need a timeout can combine this future with their runtime's timeout - /// primitive. Synchronous callers should instead fetch the underlying [`Future`] via - /// [`Self::get_async_receive_offer_ready_future`] and call [`Future::wait_timeout`] on it. + /// 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 - /// [`Future::wait_timeout`]: crate::util::wakers::Future::wait_timeout + #[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); diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index cbc68a8ebd6..820d1f2618a 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1724,9 +1724,13 @@ impl OffersMessageFlow { /// Returns a [`Future`] that completes when an async receive offer is ready, i.e., after the /// interactive static-invoice protocol completes. /// - /// Callers can either `.await` the returned [`Future`] in an async context or call - /// [`Future::wait_timeout`] from a synchronous context. After it completes, use - /// [`Self::get_async_receive_offer`] to retrieve the offer. + /// 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() } From ba2f86de1c5d55f6f290604cc00fec9bc62c794c Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 8 Apr 2026 15:14:29 +0200 Subject: [PATCH 19/22] Make `cltv_expiry_delta` `u16` The BOLT 7 wire format defines `cltv_expiry_delta` as a 2-byte field, and LDK uses u16 for it everywhere (`RouteHintHop`, `ChannelUpdateInfo`, `UnsignedChannelUpdate`). Align the LSPS2 types accordingly. serde_json will reject values exceeding `u16::MAX` during deserialization, so a counterparty sending an out-of-range value is handled gracefully. Co-Authored-By: HAL 9000 Signed-off-by: Elias Rohrer --- lightning-liquidity/src/lsps2/event.rs | 2 +- lightning-liquidity/src/lsps2/msgs.rs | 2 +- lightning-liquidity/src/lsps2/router.rs | 73 ++----------------- lightning-liquidity/src/lsps2/service.rs | 4 +- .../tests/lsps2_integration_tests.rs | 18 ++--- 5 files changed, 20 insertions(+), 79 deletions(-) diff --git a/lightning-liquidity/src/lsps2/event.rs b/lightning-liquidity/src/lsps2/event.rs index 9ca20863387..54e6a195f7b 100644 --- a/lightning-liquidity/src/lsps2/event.rs +++ b/lightning-liquidity/src/lsps2/event.rs @@ -73,7 +73,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 ba4d0fea4cd..b0a86233279 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/router.rs b/lightning-liquidity/src/lsps2/router.rs index 6b9922d6852..b9433e1b33c 100644 --- a/lightning-liquidity/src/lsps2/router.rs +++ b/lightning-liquidity/src/lsps2/router.rs @@ -35,7 +35,7 @@ pub struct LSPS2Bolt12InvoiceParameters { /// The LSPS2 intercept short channel id. pub intercept_scid: u64, /// The CLTV expiry delta the LSP requires for forwarding over `intercept_scid`. - pub cltv_expiry_delta: u32, + pub cltv_expiry_delta: u16, } /// A router wrapper that injects LSPS2-specific BOLT12 blinded payment paths for registered @@ -160,19 +160,16 @@ impl Router for LSPS2BOLT12Router { // the paths in this exact order. let mut paths = inner_res.unwrap_or_default(); for lsps2_invoice_params in all_params { - let payment_relay = match u16::try_from(lsps2_invoice_params.cltv_expiry_delta) { - Ok(cltv_expiry_delta) => PaymentRelay { - cltv_expiry_delta, - fee_proportional_millionths: 0, - fee_base_msat: 0, - }, - Err(_) => continue, + let payment_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), + .saturating_add(lsps2_invoice_params.cltv_expiry_delta as u32), htlc_minimum_msat: 0, }; @@ -376,7 +373,7 @@ mod tests { assert_eq!(path.payinfo.fee_proportional_millionths, 0); assert_eq!( path.payinfo.cltv_expiry_delta, - expected_cltv_delta as u16 + MIN_FINAL_CLTV_EXPIRY_DELTA + expected_cltv_delta + MIN_FINAL_CLTV_EXPIRY_DELTA ); let lookup = @@ -427,62 +424,6 @@ mod tests { assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); } - #[test] - fn skips_out_of_range_cltv_delta_and_keeps_valid_paths() { - let inner_router = MockRouter::new(); - let recipient = pubkey(13); - let secp_ctx = Secp256k1::new(); - - let existing_tlvs = bolt12_offer_tlvs(OfferId([11; 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 entropy_source = TestEntropy; - let router = LSPS2BOLT12Router::new(inner_router, entropy_source); - - let valid_scid = 21; - router.register_intercept_scid( - valid_scid, - LSPS2Bolt12InvoiceParameters { - counterparty_node_id: pubkey(12), - intercept_scid: valid_scid, - cltv_expiry_delta: 48, - }, - ); - router.register_intercept_scid( - 22, - LSPS2Bolt12InvoiceParameters { - counterparty_node_id: pubkey(14), - intercept_scid: 22, - cltv_expiry_delta: u32::from(u16::MAX) + 1, - }, - ); - - let paths = router - .create_blinded_payment_paths( - recipient, - ReceiveAuthKey([3; 32]), - Vec::new(), - bolt12_offer_tlvs(OfferId([11; 32])), - Some(1_000), - &secp_ctx, - ) - .unwrap(); - - assert_eq!(paths.len(), 2); - assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); - } - #[test] fn can_deregister_intercept_scid() { let inner_router = MockRouter::new(); diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index b7f6f2fc64d..5a90d355384 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; @@ -2179,7 +2179,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 49dc4e3f194..a0f3595b249 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -167,7 +167,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; @@ -182,7 +182,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, }]); @@ -1218,7 +1218,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; @@ -1396,7 +1396,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; @@ -1640,7 +1640,7 @@ fn bolt12_lsps2_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 = 1_000; @@ -1904,7 +1904,7 @@ fn bolt12_lsps2_compact_message_path_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 = 1_000; @@ -2228,7 +2228,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; @@ -2417,7 +2417,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; @@ -2751,7 +2751,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 b9735855c57afda25ad47626159ef6ffc5ac1b3b Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Wed, 15 Apr 2026 10:05:23 +0200 Subject: [PATCH 20/22] Add pending_changelog entry --- pending_changelog/4463-LSPS2-BOLT12.txt | 5 +++++ 1 file changed, 5 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..fdbb3dede61 --- /dev/null +++ b/pending_changelog/4463-LSPS2-BOLT12.txt @@ -0,0 +1,5 @@ +## Backwards Compatibility + +When downgrading to LDK v0.2 and prior, `OnionMessageIntercepted` events that +hold the new `NextMessageHop::ShortChannelId` will fail to be deserialized and +hence will be discarded. From 0cef8dc83df8f52ec0d0c9f67bd620aa8d1150b9 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Thu, 23 Apr 2026 15:37:44 +0200 Subject: [PATCH 21/22] f - Clarify changelog downgrade note Reword the backwards-compatibility note to call out that the downgrade risk is limited to users who (1) manually persist `Event::OnionMessageIntercepted` events and (2) opt in to the new `intercept_for_unknown_scids` flag on `OnionMessenger::new_with_offline_peer_interception`. LDK itself does not persist these events, so anyone who doesn't persist them manually is unaffected regardless of the flag. Co-Authored-By: HAL 9000 --- pending_changelog/4463-LSPS2-BOLT12.txt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/pending_changelog/4463-LSPS2-BOLT12.txt b/pending_changelog/4463-LSPS2-BOLT12.txt index fdbb3dede61..51cacb17995 100644 --- a/pending_changelog/4463-LSPS2-BOLT12.txt +++ b/pending_changelog/4463-LSPS2-BOLT12.txt @@ -1,5 +1,12 @@ ## Backwards Compatibility -When downgrading to LDK v0.2 and prior, `OnionMessageIntercepted` events that -hold the new `NextMessageHop::ShortChannelId` will fail to be deserialized and -hence will be discarded. +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. From 434248352852a1a89af307f04e063082ad64319a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 28 Apr 2026 15:12:03 +0200 Subject: [PATCH 22/22] Derive `Hash` for `OfferId` .. which we'll need downstream. --- 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 b2703454169..a3200eb52c3 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 {