From 6ed6a9d85746e54c0f6b1b64fa1d27a06c73d95d Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 12:46:36 +0200 Subject: [PATCH 1/9] Intercept onion messages for unknown SCID hops Allow integrations to intercept blinded onion-message hops that identify the next node by short channel id, so LSPS-style protocols can resolve those hops out of band instead of dropping the message. Co-Authored-By: HAL 9000 --- .../src/upgrade_downgrade_tests.rs | 115 +++++++++++++++++- lightning/src/blinded_path/message.rs | 5 + lightning/src/events/mod.rs | 51 +++++--- lightning/src/ln/functional_test_utils.rs | 1 + .../src/onion_message/functional_tests.rs | 97 ++++++++++++++- lightning/src/onion_message/messenger.rs | 39 ++++-- 6 files changed, 280 insertions(+), 28 deletions(-) diff --git a/lightning-tests/src/upgrade_downgrade_tests.rs b/lightning-tests/src/upgrade_downgrade_tests.rs index 7cc59227af4..75413ef14f6 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] @@ -700,3 +709,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"); +} diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 417c66374a9..65a3a4593b9 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -275,6 +275,11 @@ pub enum NextMessageHop { ShortChannelId(u64), } +impl_ser_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 b0947183384..06e41ecc93f 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, }; @@ -1835,9 +1835,13 @@ 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 - /// [`OnionMessenger::new_with_offline_peer_interception`], see its docs. + /// 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 + /// [`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. @@ -1851,9 +1855,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 @@ -2435,12 +2440,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)?; @@ -3068,11 +3086,16 @@ 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), }); + + let next_hop = next_hop + .or(peer_node_id.map(NextMessageHop::NodeId)) + .ok_or(msgs::DecodeError::InvalidValue)?; Ok(Some(Event::OnionMessageIntercepted { - peer_node_id: peer_node_id.0.unwrap(), + next_hop, message: message.0.unwrap(), })) }; diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index ac6f137d5bb..d23d2cc2d85 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -4799,6 +4799,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 75e2aaf3c5f..c1b45820e85 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; @@ -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); @@ -288,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 { @@ -311,7 +320,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 +331,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( @@ -1144,9 +1154,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!(), }; @@ -1173,6 +1187,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 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_unknown_scid_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 = [ diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 913a04637b9..b8dc7845bb7 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, @@ -1393,6 +1394,7 @@ impl< dns_resolver, custom_handler, false, + false, ) } @@ -1400,11 +1402,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 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. @@ -1420,6 +1429,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, @@ -1432,13 +1442,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()); @@ -1455,6 +1466,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), @@ -1666,7 +1678,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_for_unknown_scids { + 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); }, }, @@ -1709,7 +1734,7 @@ impl< log_suffix ); self.enqueue_intercepted_event(Event::OnionMessageIntercepted { - peer_node_id: next_node_id, + next_hop, message: onion_message, }); Ok(()) From e6a84bab30a822c18822f85fd5fdf7585cc57830 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:12:45 +0200 Subject: [PATCH 2/9] Add an LSPS2-aware BOLT12 router wrapper Allow BOLT12 blinded payment paths to be supplemented from LSPS2 parameters decoded out of payment metadata, so recipients can advertise JIT-channel paths without maintaining router-local SCID state. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/mod.rs | 1 + lightning-liquidity/src/lsps2/router.rs | 666 ++++++++++++++++++++++++ 2 files changed, 667 insertions(+) create mode 100644 lightning-liquidity/src/lsps2/router.rs diff --git a/lightning-liquidity/src/lsps2/mod.rs b/lightning-liquidity/src/lsps2/mod.rs index 1d5fb76d3b4..684ad9b26f7 100644 --- a/lightning-liquidity/src/lsps2/mod.rs +++ b/lightning-liquidity/src/lsps2/mod.rs @@ -13,5 +13,6 @@ pub mod client; pub mod event; pub mod msgs; pub(crate) mod payment_queue; +pub mod router; pub mod service; pub mod utils; diff --git a/lightning-liquidity/src/lsps2/router.rs b/lightning-liquidity/src/lsps2/router.rs new file mode 100644 index 00000000000..758f7344aff --- /dev/null +++ b/lightning-liquidity/src/lsps2/router.rs @@ -0,0 +1,666 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Router helpers for combining LSPS2 with BOLT12 offer flows. + +use alloc::collections::BTreeMap; +use alloc::vec::Vec; + +use bitcoin::secp256k1::{self, PublicKey, Secp256k1}; + +use lightning::blinded_path::payment::{ + BlindedPaymentPath, ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, + PaymentRelay, ReceiveTlvs, +}; +use lightning::impl_ser_tlv_based; +use lightning::ln::channel_state::ChannelDetails; +use lightning::ln::channelmanager::{PaymentId, MIN_FINAL_CLTV_EXPIRY_DELTA}; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::{EntropySource, ReceiveAuthKey}; +use lightning::types::features::BlindedHopFeatures; +use lightning::types::payment::PaymentHash; + +/// LSPS2 invoice parameters required to construct BOLT12 blinded payment paths through an LSP. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LSPS2Bolt12InvoiceParameters { + /// The LSP node id to use as the blinded path introduction node. + pub counterparty_node_id: PublicKey, + /// The LSPS2 intercept short channel id. + pub intercept_scid: u64, + /// The CLTV expiry delta the LSP requires for forwarding over `intercept_scid`. + pub cltv_expiry_delta: u16, +} + +impl_ser_tlv_based!(LSPS2Bolt12InvoiceParameters, { + (0, counterparty_node_id, required), + (2, intercept_scid, required), + (4, cltv_expiry_delta, required), +}); + +/// Decodes LSPS2 BOLT12 invoice parameters from BOLT12 payment metadata. +/// +/// LDK does not assign a key for LSPS2 metadata. Integrations should choose an application-specific +/// key, encode their data there, and provide a decoder that extracts any LSPS2 parameters which +/// should be converted into blinded payment paths. +pub trait LSPS2Bolt12PaymentMetadataDecoder { + /// Returns all LSPS2 invoice parameters encoded in `payment_metadata`. + fn decode_lsps2_invoice_parameters( + &self, payment_metadata: &BTreeMap>, + ) -> Vec; +} + +impl LSPS2Bolt12PaymentMetadataDecoder for () { + fn decode_lsps2_invoice_parameters( + &self, _payment_metadata: &BTreeMap>, + ) -> Vec { + Vec::new() + } +} + +impl LSPS2Bolt12PaymentMetadataDecoder for F +where + F: Fn(&BTreeMap>) -> Vec, +{ + fn decode_lsps2_invoice_parameters( + &self, payment_metadata: &BTreeMap>, + ) -> Vec { + self(payment_metadata) + } +} + +/// A router wrapper that injects LSPS2-specific BOLT12 blinded payment paths based on the +/// payment metadata attached to BOLT12 offer contexts while delegating all other blinded path +/// creation behaviors to the inner router. +/// +/// For **payment** blinded paths (in invoices), it appends paths using the intercept SCID as the +/// forwarding hop so that the LSP can intercept the HTLC and open a JIT channel. Paths from the +/// inner router (e.g., through pre-existing channels) are included as well, allowing payers to +/// use existing inbound liquidity when available. +/// +/// This wrapper does **not** modify blinded onion-message paths. Async static-invoice and LSPS5 +/// users should rely on their normal [`MessageRouter`] integration and any out-of-band SCID to +/// node-id resolution they maintain when handling [`Event::OnionMessageIntercepted`]. +/// +/// [`MessageRouter`]: lightning::onion_message::messenger::MessageRouter +/// [`Event::OnionMessageIntercepted`]: lightning::events::Event::OnionMessageIntercepted +/// [`Event::HTLCIntercepted`]: lightning::events::Event::HTLCIntercepted +pub struct LSPS2BOLT12Router< + R: Router, + ES: EntropySource, + MD: LSPS2Bolt12PaymentMetadataDecoder = (), +> { + inner_router: R, + entropy_source: ES, + payment_metadata_decoder: MD, +} + +impl LSPS2BOLT12Router { + /// Constructs a new wrapper around `inner_router`. + pub fn new(inner_router: R, entropy_source: ES) -> Self { + Self { inner_router, entropy_source, payment_metadata_decoder: () } + } +} + +impl + LSPS2BOLT12Router +{ + /// Constructs a new wrapper around `inner_router` which decodes LSPS2 parameters from BOLT12 + /// payment metadata using `payment_metadata_decoder`. + pub fn new_with_payment_metadata_decoder( + inner_router: R, entropy_source: ES, payment_metadata_decoder: MD, + ) -> Self { + Self { inner_router, entropy_source, payment_metadata_decoder } + } + + fn metadata_lsps2_params( + &self, payment_context: &PaymentContext, + ) -> Vec { + // LSPS2 paths are applicable both to normal offers and async offers that resolve via a + // static invoice server. In both cases the intercept SCID lets the LSP intercept the HTLC + // and open the JIT channel before forwarding the payment. + match payment_context { + PaymentContext::Bolt12Offer(_) | PaymentContext::AsyncBolt12Offer(_) => {}, + _ => return Vec::new(), + }; + + match payment_context.payment_metadata() { + Some(metadata) => { + self.payment_metadata_decoder.decode_lsps2_invoice_parameters(metadata) + }, + None => Vec::new(), + } + } +} + +impl Router + for LSPS2BOLT12Router +{ + fn find_route( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + ) -> Result { + self.inner_router.find_route(payer, route_params, first_hops, inflight_htlcs) + } + + fn find_route_with_id( + &self, payer: &PublicKey, route_params: &RouteParameters, + first_hops: Option<&[&ChannelDetails]>, inflight_htlcs: InFlightHtlcs, + payment_hash: PaymentHash, payment_id: PaymentId, + ) -> Result { + self.inner_router.find_route_with_id( + payer, + route_params, + first_hops, + inflight_htlcs, + payment_hash, + payment_id, + ) + } + + fn create_blinded_payment_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + first_hops: Vec, tlvs: ReceiveTlvs, amount_msats: Option, + secp_ctx: &Secp256k1, + ) -> Result, ()> { + // Retrieve paths through existing channels from the inner router. + let inner_res = self.inner_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs.clone(), + amount_msats, + secp_ctx, + ); + + // If no LSPS2 parameters were present in the payment metadata, just fallback to the inner + // router's paths. + let all_params = self.metadata_lsps2_params(&tlvs.payment_context); + if all_params.is_empty() { + return inner_res; + } + + // For metadata-derived parameters, add paths with intercept SCIDs to have the payer use + // them when sending payments, prompting the LSP node to emit Event::HTLCIntercepted, hence + // triggering channel open. We however also keep the inner paths so the payer can use + // pre-existing inbound liquidity when available rather than always triggering a JIT channel + // open. As BOLT12 specifies that paths should be ordered by preference, adding JIT-paths to + // the end of the list *should* have the payer prefer pre-existing channels. However, there + // of course is no guarantee that the payer's router will actually process the paths in this + // exact order. + let mut paths = inner_res.unwrap_or_default(); + for lsps2_invoice_params in all_params { + let payment_relay = PaymentRelay { + cltv_expiry_delta: lsps2_invoice_params.cltv_expiry_delta, + fee_proportional_millionths: 0, + fee_base_msat: 0, + }; + let payment_constraints = PaymentConstraints { + max_cltv_expiry: tlvs + .payment_constraints + .max_cltv_expiry + .saturating_add(lsps2_invoice_params.cltv_expiry_delta as u32), + htlc_minimum_msat: 0, + }; + + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: lsps2_invoice_params.intercept_scid, + payment_relay, + payment_constraints, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: lsps2_invoice_params.counterparty_node_id, + htlc_maximum_msat: u64::MAX, + }; + + // We deliberately use `BlindedPaymentPath::new` without dummy hops here. Since the LSP + // is a publicly-exposed introduction node and already knows the recipient, adding + // dummy hops would not provide meaningful privacy benefits in the LSPS2 JIT channel + // context. + let path = match BlindedPaymentPath::new( + &[forward_node], + recipient, + local_node_receive_key, + tlvs.clone(), + u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, + &self.entropy_source, + secp_ctx, + ) { + Ok(path) => path, + Err(()) => continue, + }; + paths.push(path); + } + + if paths.is_empty() { + return Err(()); + } + + Ok(paths) + } +} + +#[cfg(test)] +mod tests { + use super::{ + LSPS2BOLT12Router, LSPS2Bolt12InvoiceParameters, LSPS2Bolt12PaymentMetadataDecoder, + }; + + use alloc::collections::BTreeMap; + use bitcoin::network::Network; + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + + use lightning::blinded_path::payment::{ + Bolt12OfferContext, Bolt12RefundContext, PaymentConstraints, PaymentContext, ReceiveTlvs, + }; + use lightning::blinded_path::NodeIdLookUp; + use lightning::ln::channel_state::ChannelDetails; + use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; + use lightning::offers::invoice_request::InvoiceRequestFields; + use lightning::offers::offer::OfferId; + use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; + use lightning::sign::{EntropySource, NodeSigner, ReceiveAuthKey, Recipient}; + use lightning::types::payment::PaymentSecret; + use lightning::util::ser::{Readable, Writeable}; + use lightning::util::test_utils::TestKeysInterface; + + use crate::sync::Mutex; + + use core::sync::atomic::{AtomicUsize, Ordering}; + + struct RecordingLookup { + next_node_id: PublicKey, + short_channel_id: Mutex>, + } + + impl NodeIdLookUp for RecordingLookup { + fn next_node_id(&self, short_channel_id: u64) -> Option { + *self.short_channel_id.lock().unwrap() = Some(short_channel_id); + Some(self.next_node_id) + } + } + + #[derive(Clone)] + struct TestEntropy; + + impl EntropySource for TestEntropy { + fn get_secure_random_bytes(&self) -> [u8; 32] { + [42; 32] + } + } + + #[derive(Clone, Copy)] + struct TestMetadataDecoder; + + impl LSPS2Bolt12PaymentMetadataDecoder for TestMetadataDecoder { + fn decode_lsps2_invoice_parameters( + &self, payment_metadata: &BTreeMap>, + ) -> Vec { + payment_metadata + .values() + .filter_map(|encoded| { + let mut reader = &encoded[..]; + LSPS2Bolt12InvoiceParameters::read(&mut reader).ok() + }) + .collect() + } + } + + struct MockRouter { + create_blinded_payment_paths_calls: AtomicUsize, + paths_to_return: Mutex>>, + } + + impl MockRouter { + fn new() -> Self { + Self { + create_blinded_payment_paths_calls: AtomicUsize::new(0), + paths_to_return: Mutex::new(None), + } + } + + fn create_blinded_payment_paths_calls(&self) -> usize { + self.create_blinded_payment_paths_calls.load(Ordering::Acquire) + } + } + + impl Router for MockRouter { + fn find_route( + &self, _payer: &PublicKey, _route_params: &RouteParameters, + _first_hops: Option<&[&ChannelDetails]>, _inflight_htlcs: InFlightHtlcs, + ) -> Result { + Err("mock router") + } + + fn create_blinded_payment_paths< + T: bitcoin::secp256k1::Signing + bitcoin::secp256k1::Verification, + >( + &self, _recipient: PublicKey, _local_node_receive_key: ReceiveAuthKey, + _first_hops: Vec, _tlvs: ReceiveTlvs, _amount_msats: Option, + _secp_ctx: &Secp256k1, + ) -> Result, ()> { + self.create_blinded_payment_paths_calls.fetch_add(1, Ordering::AcqRel); + match self.paths_to_return.lock().unwrap().take() { + Some(paths) => Ok(paths), + None => Err(()), + } + } + } + + fn pubkey(byte: u8) -> PublicKey { + let secret_key = SecretKey::from_slice(&[byte; 32]).unwrap(); + PublicKey::from_secret_key(&Secp256k1::new(), &secret_key) + } + + fn bolt12_offer_tlvs(offer_id: OfferId) -> ReceiveTlvs { + bolt12_offer_tlvs_with_metadata(offer_id, None) + } + + fn bolt12_offer_tlvs_with_metadata( + offer_id: OfferId, payment_metadata: Option>>, + ) -> ReceiveTlvs { + ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id, + payment_metadata, + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: pubkey(9), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }), + } + } + + fn bolt12_refund_tlvs() -> ReceiveTlvs { + ReceiveTlvs { + payment_secret: PaymentSecret([2; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 100, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), + } + } + + fn metadata_for_params(params: LSPS2Bolt12InvoiceParameters) -> BTreeMap> { + let mut encoded_params = Vec::new(); + params.write(&mut encoded_params).unwrap(); + let mut metadata = BTreeMap::new(); + metadata.insert(42, encoded_params); + metadata + } + + #[test] + fn creates_lsps2_blinded_path_from_payment_metadata() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + entropy_source, + TestMetadataDecoder, + ); + + let offer_id = OfferId([8; 32]); + let lsp_keys = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id = lsp_keys.get_node_id(Recipient::Node).unwrap(); + + let expected_scid = 42; + let expected_cltv_delta = 48; + let recipient = pubkey(10); + + let metadata = metadata_for_params(LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id, + intercept_scid: expected_scid, + cltv_expiry_delta: expected_cltv_delta, + }); + + let secp_ctx = Secp256k1::new(); + let mut paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs_with_metadata(offer_id, Some(metadata)), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + assert_eq!(paths.len(), 1); + let mut path = paths.pop().unwrap(); + assert_eq!( + path.introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id) + ); + assert_eq!(path.payinfo.fee_base_msat, 0); + assert_eq!(path.payinfo.fee_proportional_millionths, 0); + assert_eq!( + path.payinfo.cltv_expiry_delta, + expected_cltv_delta + MIN_FINAL_CLTV_EXPIRY_DELTA + ); + + let lookup = + RecordingLookup { next_node_id: recipient, short_channel_id: Mutex::new(None) }; + path.advance_path_by_one(&lsp_keys, &lookup, &secp_ctx).unwrap(); + assert_eq!(*lookup.short_channel_id.lock().unwrap(), Some(expected_scid)); + } + + #[test] + fn delegates_when_context_is_not_bolt12_offer() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, entropy_source); + let secp_ctx = Secp256k1::new(); + + let result = router.create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_refund_tlvs(), + Some(10_000), + &secp_ctx, + ); + + assert!(result.is_err()); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn delegates_when_no_intercept_scid_is_registered() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new(inner_router, entropy_source); + let secp_ctx = Secp256k1::new(); + + // Use a Bolt12Offer context without any registered intercept SCIDs. + let offer_id = OfferId([99; 32]); + let result = router.create_blinded_payment_paths( + pubkey(10), + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs(offer_id), + Some(10_000), + &secp_ctx, + ); + + assert!(result.is_err()); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn creates_paths_for_all_metadata_intercept_scids() { + let inner_router = MockRouter::new(); + let entropy_source = TestEntropy; + let router = LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + entropy_source, + TestMetadataDecoder, + ); + + let lsp_keys_a = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id_a = lsp_keys_a.get_node_id(Recipient::Node).unwrap(); + let scid_a = 100; + + let lsp_keys_b = TestKeysInterface::new(&[44; 32], Network::Testnet); + let lsp_node_id_b = lsp_keys_b.get_node_id(Recipient::Node).unwrap(); + let scid_b = 200; + + let mut metadata = metadata_for_params(LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id_a, + intercept_scid: scid_a, + cltv_expiry_delta: 48, + }); + let mut encoded_b = Vec::new(); + LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id_b, + intercept_scid: scid_b, + cltv_expiry_delta: 72, + } + .write(&mut encoded_b) + .unwrap(); + metadata.insert(43, encoded_b); + + let recipient = pubkey(10); + let secp_ctx = Secp256k1::new(); + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs_with_metadata(OfferId([8; 32]), Some(metadata)), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + assert_eq!(paths.len(), 2); + + // Verify each path uses a distinct intercept SCID by advancing through the LSP hop. + let mut seen_scids = std::collections::HashSet::new(); + for mut path in paths { + let (keys, node_id) = if path.introduction_node() + == &lightning::blinded_path::IntroductionNode::NodeId(lsp_node_id_a) + { + (&lsp_keys_a, lsp_node_id_a) + } else { + (&lsp_keys_b, lsp_node_id_b) + }; + let _ = node_id; + + let lookup = + RecordingLookup { next_node_id: recipient, short_channel_id: Mutex::new(None) }; + path.advance_path_by_one(keys, &lookup, &secp_ctx).unwrap(); + let scid = lookup.short_channel_id.lock().unwrap().unwrap(); + seen_scids.insert(scid); + } + + assert!(seen_scids.contains(&scid_a), "Path for SCID {} missing", scid_a); + assert!(seen_scids.contains(&scid_b), "Path for SCID {} missing", scid_b); + + // Inner router is always called to include paths through existing channels. + // It returned Err here, so only the LSPS2 paths are present. + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn includes_inner_router_paths_alongside_lsps2_paths() { + let inner_router = MockRouter::new(); + let lsp_keys = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id = lsp_keys.get_node_id(Recipient::Node).unwrap(); + let recipient = pubkey(10); + let secp_ctx = Secp256k1::new(); + + // Pre-create a blinded path as if the inner router built it from an existing channel. + let existing_tlvs = bolt12_offer_tlvs(OfferId([8; 32])); + let existing_path = lightning::blinded_path::payment::BlindedPaymentPath::new( + &[], + recipient, + ReceiveAuthKey([3; 32]), + existing_tlvs, + u64::MAX, + MIN_FINAL_CLTV_EXPIRY_DELTA, + &TestEntropy, + &secp_ctx, + ) + .unwrap(); + *inner_router.paths_to_return.lock().unwrap() = Some(vec![existing_path]); + + let router = LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + TestEntropy, + TestMetadataDecoder, + ); + + let intercept_scid = 42; + let metadata = metadata_for_params(LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id, + intercept_scid, + cltv_expiry_delta: 48, + }); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs_with_metadata(OfferId([8; 32]), Some(metadata)), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + // Should contain both the LSPS2 intercept path and the inner router's existing + // channel path. + assert_eq!(paths.len(), 2); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } + + #[test] + fn lsps2_paths_returned_even_when_inner_router_fails() { + let inner_router = MockRouter::new(); + // paths_to_return is None, so inner router returns Err(()) + let lsp_keys = TestKeysInterface::new(&[43; 32], Network::Testnet); + let lsp_node_id = lsp_keys.get_node_id(Recipient::Node).unwrap(); + let recipient = pubkey(10); + let secp_ctx = Secp256k1::new(); + + let router = LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + TestEntropy, + TestMetadataDecoder, + ); + + let intercept_scid = 42; + let metadata = metadata_for_params(LSPS2Bolt12InvoiceParameters { + counterparty_node_id: lsp_node_id, + intercept_scid, + cltv_expiry_delta: 48, + }); + + let paths = router + .create_blinded_payment_paths( + recipient, + ReceiveAuthKey([3; 32]), + Vec::new(), + bolt12_offer_tlvs_with_metadata(OfferId([8; 32]), Some(metadata)), + Some(5_000), + &secp_ctx, + ) + .unwrap(); + + // Only the LSPS2 path, since the inner router failed. + assert_eq!(paths.len(), 1); + assert_eq!(router.inner_router.create_blinded_payment_paths_calls(), 1); + } +} From c9f73394ac40dedaaaf60cf82cce4ffea7c07b69 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:15:07 +0200 Subject: [PATCH 3/9] Derive Hash for OfferId Allow tests and callers to key offer metadata by offer id without wrapping the identifier. Co-Authored-By: HAL 9000 --- lightning/src/offers/offer.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 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 { From 88ac2b0e393b642e814ad3dda3ccb2225541c093 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:16:57 +0200 Subject: [PATCH 4/9] Add async offer refresh readiness APIs Let async recipients explicitly refresh receive offers, wait for readiness, and preserve payment metadata across static-invoice refreshes. Co-Authored-By: HAL 9000 --- lightning/src/ln/channelmanager.rs | 105 +++++++++++++++--- .../src/offers/async_receive_offer_cache.rs | 59 +++++++++- lightning/src/offers/flow.rs | 61 ++++++++-- 3 files changed, 196 insertions(+), 29 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 0ae4c87d511..7f73d3eec7d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5852,30 +5852,95 @@ impl< ) } - fn check_refresh_async_receive_offer_cache(&self, timer_tick_occurred: bool) { + fn check_refresh_async_receive_offer_cache(&self, timer_tick_occurred: bool) -> Result<(), ()> { + self.check_refresh_async_receive_offer_cache_with_payment_metadata( + timer_tick_occurred, + None, + ) + } + + fn check_refresh_async_receive_offer_cache_with_payment_metadata( + &self, timer_tick_occurred: bool, payment_metadata: Option>>, + ) -> Result<(), ()> { let peers = self.get_peers_for_blinded_path(); let channels = self.list_usable_channels(); let router = &self.router; - let refresh_res = self.flow.check_refresh_async_receive_offer_cache( + self.flow.check_refresh_async_receive_offer_cache_with_payment_metadata( peers, channels, router, timer_tick_occurred, - ); - match refresh_res { - Err(()) => { - log_error!( - self.logger, - "Failed to create blinded paths when requesting async receive offer paths" - ); - }, - Ok(()) => {}, - } + payment_metadata, + ) } #[cfg(test)] pub(crate) fn test_check_refresh_async_receive_offers(&self) { - self.check_refresh_async_receive_offer_cache(false); + self.check_refresh_async_receive_offer_cache(false).unwrap(); + } + + /// Requests fresh async receive offer paths from the configured static invoice server, if any. + pub fn refresh_async_receive_offers(&self) -> Result<(), ()> { + self.check_refresh_async_receive_offer_cache(false).map_err(|()| { + log_error!( + self.logger, + "Failed to create blinded paths when requesting async receive offer paths" + ); + }) + } + + /// Requests fresh async receive offer paths from the configured static invoice server, if any, + /// and attaches `payment_metadata` to the resulting BOLT 12 payment contexts. + /// + /// The metadata is persisted with the async receive offer cache so future static-invoice + /// refreshes for the same offer continue to include it. + pub fn refresh_async_receive_offers_with_payment_metadata( + &self, payment_metadata: BTreeMap>, + ) -> Result<(), ()> { + self.check_refresh_async_receive_offer_cache_with_payment_metadata( + false, + Some(payment_metadata), + ) + .map_err(|()| { + log_error!( + self.logger, + "Failed to create blinded paths when requesting async receive offer paths" + ); + }) + } + + /// Returns once an async receive offer is ready after the interactive static-invoice + /// protocol completes, or immediately if one is already available. + /// + /// Callers that need a timeout can combine this future with their runtime's timeout + /// primitive. + #[cfg_attr( + feature = "std", + doc = "Synchronous callers should instead fetch the underlying [`Future`] via [`Self::get_async_receive_offer_ready_future`] and call [`Future::wait_timeout`] on it." + )] + /// + /// [`Future`]: crate::util::wakers::Future + #[cfg_attr( + feature = "std", + doc = "[`Future::wait_timeout`]: crate::util::wakers::Future::wait_timeout" + )] + pub async fn await_async_receive_offer(&self) -> Result { + if let Ok(offer) = self.get_async_receive_offer() { + return Ok(offer); + } + + self.flow.get_async_receive_offer_ready_future().await; + self.get_async_receive_offer() + } + + /// Returns a [`Future`] that completes when an async receive offer is ready. + /// + /// See [`OffersMessageFlow::get_async_receive_offer_ready_future`] for details. + /// + /// [`Future`]: crate::util::wakers::Future + /// [`OffersMessageFlow::get_async_receive_offer_ready_future`]: crate::offers::flow::OffersMessageFlow::get_async_receive_offer_ready_future + pub fn get_async_receive_offer_ready_future(&self) -> crate::util::wakers::Future { + self.flow.get_async_receive_offer_ready_future() } /// Should be called after handling an [`Event::PersistStaticInvoice`], where the `Responder` @@ -9096,7 +9161,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 @@ -16022,7 +16092,12 @@ impl< // interactively building offers as soon as we can after startup. We can't start building offers // until we have some peer connection(s) to receive onion messages over, so as a minor optimization // refresh the cache when a peer connects. - self.check_refresh_async_receive_offer_cache(false); + let _ = self.check_refresh_async_receive_offer_cache(false).map_err(|()| { + log_error!( + self.logger, + "Failed to create blinded paths when requesting async receive offer paths" + ); + }); res } diff --git a/lightning/src/offers/async_receive_offer_cache.rs b/lightning/src/offers/async_receive_offer_cache.rs index dd96b5d1c42..b35e447c38b 100644 --- a/lightning/src/offers/async_receive_offer_cache.rs +++ b/lightning/src/offers/async_receive_offer_cache.rs @@ -11,6 +11,8 @@ //! server as an async recipient. The static invoice server will serve the resulting invoices to //! payers on our behalf when we're offline. +use alloc::collections::BTreeMap; + use crate::blinded_path::message::{AsyncPaymentsContext, BlindedMessagePath}; use crate::io; use crate::io::Read; @@ -19,7 +21,7 @@ use crate::offers::nonce::Nonce; use crate::offers::offer::Offer; use crate::onion_message::messenger::Responder; use crate::prelude::*; -use crate::util::ser::{Readable, Writeable, Writer}; +use crate::util::ser::{BigSizeKeyedMap, Readable, Writeable, Writer}; use core::time::Duration; /// The status of this offer in the cache. @@ -62,6 +64,7 @@ struct AsyncReceiveOffer { /// payment paths become otherwise outdated. offer_nonce: Nonce, update_static_invoice_path: Responder, + payment_metadata: Option>>, } impl AsyncReceiveOffer { @@ -92,6 +95,7 @@ impl_ser_tlv_based!(AsyncReceiveOffer, { (4, status, required), (6, update_static_invoice_path, required), (8, created_at, required), + (10, payment_metadata, (option, encoding: (BTreeMap>, BigSizeKeyedMap))), }); /// If we are an often-offline recipient, we'll want to interactively build offers and static @@ -147,6 +151,8 @@ pub struct AsyncReceiveOfferCache { /// Blinded paths used to request offer paths from the static invoice server. #[allow(unused)] // TODO: remove when we get rid of async payments cfg flag paths_to_static_invoice_server: Vec, + /// Payment metadata associated with offer-path requests in flight. + pending_offer_payment_metadata: BTreeMap>>>, } impl AsyncReceiveOfferCache { @@ -158,6 +164,7 @@ impl AsyncReceiveOfferCache { offers: Vec::new(), offer_paths_request_attempts: 0, paths_to_static_invoice_server: Vec::new(), + pending_offer_payment_metadata: BTreeMap::new(), } } @@ -320,6 +327,7 @@ impl AsyncReceiveOfferCache { pub(super) fn cache_pending_offer( &mut self, offer: Offer, offer_paths_absolute_expiry_secs: Option, offer_nonce: Nonce, update_static_invoice_path: Responder, duration_since_epoch: Duration, slot: u16, + payment_metadata: Option>>, ) -> Result<(), ()> { self.prune_expired_offers(duration_since_epoch, false); @@ -340,6 +348,7 @@ impl AsyncReceiveOfferCache { offer_nonce, status: OfferStatus::Pending, update_static_invoice_path, + payment_metadata, }) }, None => { @@ -347,6 +356,7 @@ impl AsyncReceiveOfferCache { return Err(()); }, } + self.pending_offer_payment_metadata.remove(&slot); Ok(()) } @@ -433,8 +443,11 @@ impl AsyncReceiveOfferCache { // Indicates that onion messages requesting new offer paths have been sent to the static invoice // server. Calling this method allows the cache to self-limit how many requests are sent. - pub(super) fn new_offers_requested(&mut self) { + pub(super) fn new_offers_requested( + &mut self, slot: u16, payment_metadata: Option>>, + ) { self.offer_paths_request_attempts += 1; + self.pending_offer_payment_metadata.insert(slot, payment_metadata); } /// Called on timer tick (roughly once per minute) to allow another [`MAX_UPDATE_ATTEMPTS`] offer @@ -447,7 +460,7 @@ impl AsyncReceiveOfferCache { /// the static invoice server. pub(super) fn offers_needing_invoice_refresh( &self, duration_since_epoch: Duration, - ) -> impl Iterator { + ) -> impl Iterator>>)> { // For any offers which are either in use or pending confirmation by the server, we should send // them a fresh invoice on each timer tick. self.offers_with_idx().filter_map(move |(_, offer)| { @@ -462,13 +475,44 @@ impl AsyncReceiveOfferCache { OfferStatus::Ready { .. } => false, }; if needs_invoice_update { - Some((&offer.offer, offer.offer_nonce, &offer.update_static_invoice_path)) + Some(( + &offer.offer, + offer.offer_nonce, + &offer.update_static_invoice_path, + offer.payment_metadata.clone(), + )) } else { None } }) } + /// Returns the payment metadata that should be attached to a replacement offer in `slot`. + pub(super) fn payment_metadata_for_new_offer( + &self, slot: u16, payment_metadata: Option>>, + ) -> Option>> { + payment_metadata + .or_else(|| self.pending_offer_payment_metadata.get(&slot).cloned().flatten()) + .or_else(|| { + self.offers + .get(slot as usize) + .and_then(|offer| offer.as_ref()) + .and_then(|offer| offer.payment_metadata.clone()) + }) + } + + /// Returns the payment metadata associated with an incoming offer-path response for `slot`. + pub(super) fn payment_metadata_for_offer_slot( + &self, slot: u16, + ) -> Option>> { + self.pending_offer_payment_metadata.get(&slot).cloned().flatten().or_else(|| { + self.offers + .get(slot as usize) + .and_then(|offer| offer.as_ref()) + .and_then(|offer| offer.payment_metadata.clone()) + }) + } + /// Should be called when we receive a [`StaticInvoicePersisted`] message from the static invoice /// server, which indicates that a new offer was persisted by the server and they are ready to /// serve the corresponding static invoice to payers on our behalf. @@ -536,6 +580,11 @@ impl Readable for AsyncReceiveOfferCache { (2, paths_to_static_invoice_server, required_vec), }); let offers: Vec> = offers; - Ok(Self { offers, offer_paths_request_attempts: 0, paths_to_static_invoice_server }) + Ok(Self { + offers, + offer_paths_request_attempts: 0, + paths_to_static_invoice_server, + pending_offer_payment_metadata: BTreeMap::new(), + }) } } diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index bdc3475b554..49f50f06322 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -63,6 +63,7 @@ use crate::sync::{Mutex, RwLock}; use crate::types::payment::{PaymentHash, PaymentSecret}; use crate::util::logger::Logger; use crate::util::ser::Writeable; +use crate::util::wakers::{Future, Notifier}; /// A BOLT12 offers code and flow utility provider, which facilitates /// BOLT12 builder generation and onion message handling. @@ -89,6 +90,7 @@ pub struct OffersMessageFlow { pending_async_payments_messages: Mutex>, async_receive_offer_cache: Mutex, + async_receive_offer_ready_notifier: Notifier, logger: L, } @@ -118,6 +120,7 @@ impl OffersMessageFlow { pending_async_payments_messages: Mutex::new(Vec::new()), async_receive_offer_cache: Mutex::new(AsyncReceiveOfferCache::new()), + async_receive_offer_ready_notifier: Notifier::new(), logger, } @@ -154,7 +157,7 @@ impl OffersMessageFlow { // We'll only fail here if no peers are connected yet for us to create reply paths to outbound // offer_paths_requests, so ignore the error. - let _ = self.check_refresh_async_offers(peers, false); + let _ = self.check_refresh_async_offers(peers, false, None); Ok(()) } @@ -1341,6 +1344,19 @@ impl OffersMessageFlow { pub fn check_refresh_async_receive_offer_cache( &self, peers: Vec, usable_channels: Vec, router: R, timer_tick_occurred: bool, + ) -> Result<(), ()> { + self.check_refresh_async_receive_offer_cache_with_payment_metadata( + peers, + usable_channels, + router, + timer_tick_occurred, + None, + ) + } + + pub(crate) fn check_refresh_async_receive_offer_cache_with_payment_metadata( + &self, peers: Vec, usable_channels: Vec, router: R, + timer_tick_occurred: bool, payment_metadata: Option>>, ) -> Result<(), ()> { // Terminate early if this node does not intend to receive async payments. { @@ -1350,7 +1366,7 @@ impl OffersMessageFlow { } } - self.check_refresh_async_offers(peers.clone(), timer_tick_occurred)?; + self.check_refresh_async_offers(peers.clone(), timer_tick_occurred, payment_metadata)?; if timer_tick_occurred { self.check_refresh_static_invoices(peers, usable_channels, router); @@ -1361,6 +1377,7 @@ impl OffersMessageFlow { fn check_refresh_async_offers( &self, peers: Vec, timer_tick_occurred: bool, + payment_metadata: Option>>, ) -> Result<(), ()> { let duration_since_epoch = self.duration_since_epoch(); let mut cache = self.async_receive_offer_cache.lock().unwrap(); @@ -1372,6 +1389,8 @@ impl OffersMessageFlow { Some(idx) => idx, None => return Ok(()), }; + let payment_metadata = + cache.payment_metadata_for_new_offer(needs_new_offer_slot, payment_metadata); // If we need new offers, send out offer paths request messages to the static invoice server. let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths { @@ -1391,7 +1410,7 @@ impl OffersMessageFlow { }; // We can't fail past this point, so indicate to the cache that we've requested new offers. - cache.new_offers_requested(); + cache.new_offers_requested(needs_new_offer_slot, payment_metadata); let mut pending_async_payments_messages = self.pending_async_payments_messages.lock().unwrap(); @@ -1418,7 +1437,8 @@ impl OffersMessageFlow { let duration_since_epoch = self.duration_since_epoch(); let cache = self.async_receive_offer_cache.lock().unwrap(); for offer_and_metadata in cache.offers_needing_invoice_refresh(duration_since_epoch) { - let (offer, offer_nonce, update_static_invoice_path) = offer_and_metadata; + let (offer, offer_nonce, update_static_invoice_path, payment_metadata) = + offer_and_metadata; let (invoice, forward_invreq_path) = match self.create_static_invoice_for_server( offer, @@ -1426,6 +1446,7 @@ impl OffersMessageFlow { peers.clone(), usable_channels.clone(), &router, + payment_metadata, ) { Ok((invoice, path)) => (invoice, path), Err(()) => continue, @@ -1549,7 +1570,7 @@ impl OffersMessageFlow { _ => return None, }; - { + let payment_metadata = { // Only respond with `ServeStaticInvoice` if we actually need a new offer built. let mut cache = self.async_receive_offer_cache.lock().unwrap(); cache.prune_expired_offers(duration_since_epoch, false); @@ -1561,7 +1582,8 @@ impl OffersMessageFlow { ) { return None; } - } + cache.payment_metadata_for_offer_slot(invoice_slot) + }; let (mut offer_builder, offer_nonce) = match self.create_async_receive_offer_builder(&entropy, message.paths) { @@ -1587,6 +1609,7 @@ impl OffersMessageFlow { peers, usable_channels, router, + payment_metadata.clone(), ) { Ok(res) => res, Err(()) => { @@ -1602,6 +1625,7 @@ impl OffersMessageFlow { responder, duration_since_epoch, invoice_slot, + payment_metadata, ) { log_error!(self.logger, "Failed to cache pending offer"); return None; @@ -1623,6 +1647,7 @@ impl OffersMessageFlow { fn create_static_invoice_for_server( &self, offer: &Offer, offer_nonce: Nonce, peers: Vec, usable_channels: Vec, router: R, + payment_metadata: Option>>, ) -> Result<(StaticInvoice, BlindedMessagePath), ()> { let expanded_key = &self.inbound_payment_key; let duration_since_epoch = self.duration_since_epoch(); @@ -1654,14 +1679,14 @@ impl OffersMessageFlow { offer_relative_expiry, usable_channels, peers.clone(), - None, + payment_metadata.clone(), ) .and_then(|builder| builder.build_and_sign(secp_ctx)) .map_err(|_| ())?; let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce: offer_nonce, - payment_metadata: None, + payment_metadata, }); let forward_invoice_request_path = self .create_blinded_paths(peers, context) @@ -1726,7 +1751,25 @@ impl OffersMessageFlow { /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted pub fn handle_static_invoice_persisted(&self, context: AsyncPaymentsContext) -> bool { let mut cache = self.async_receive_offer_cache.lock().unwrap(); - cache.static_invoice_persisted(context) + let updated = cache.static_invoice_persisted(context); + if updated { + self.async_receive_offer_ready_notifier.notify(); + } + updated + } + + /// Returns a [`Future`] that completes when an async receive offer is ready, i.e., after the + /// interactive static-invoice protocol completes. + /// + /// Callers can `.await` the returned [`Future`] in an async context. + #[cfg_attr( + feature = "std", + doc = "Synchronous callers can instead call [`Future::wait_timeout`] on it." + )] + /// + /// After it completes, use [`Self::get_async_receive_offer`] to retrieve the offer. + pub fn get_async_receive_offer_ready_future(&self) -> Future { + self.async_receive_offer_ready_notifier.get_future() } /// Get the encoded [`AsyncReceiveOfferCache`] for persistence. From f161f6f0458242facaafe711c5cfd5cee557739f Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:19:46 +0200 Subject: [PATCH 5/9] Make cltv_expiry_delta u16 Align LSPS2 CLTV deltas with the wire format and the rest of LDK's routing types. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/event.rs | 2 +- lightning-liquidity/src/lsps2/msgs.rs | 2 +- lightning-liquidity/src/lsps2/service.rs | 4 ++-- .../tests/lsps2_integration_tests.rs | 14 +++++++------- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lightning-liquidity/src/lsps2/event.rs b/lightning-liquidity/src/lsps2/event.rs index 956da403e11..0575b52c26a 100644 --- a/lightning-liquidity/src/lsps2/event.rs +++ b/lightning-liquidity/src/lsps2/event.rs @@ -63,7 +63,7 @@ pub enum LSPS2ClientEvent { /// The intercept short channel id to use in the route hint. intercept_scid: u64, /// The `cltv_expiry_delta` to use in the route hint. - cltv_expiry_delta: u32, + cltv_expiry_delta: u16, /// The initial payment size you specified. payment_size_msat: Option, }, diff --git a/lightning-liquidity/src/lsps2/msgs.rs b/lightning-liquidity/src/lsps2/msgs.rs index 9375069ca0a..72bf91d9228 100644 --- a/lightning-liquidity/src/lsps2/msgs.rs +++ b/lightning-liquidity/src/lsps2/msgs.rs @@ -182,7 +182,7 @@ pub struct LSPS2BuyResponse { /// The intercept short channel id used by LSP to identify need to open channel. pub jit_channel_scid: LSPS2InterceptScid, /// The locktime expiry delta the lsp requires. - pub lsp_cltv_expiry_delta: u32, + pub lsp_cltv_expiry_delta: u16, /// Trust model flag (default: false). /// /// false => "LSP trusts client": LSP immediately (or as soon as safe) broadcasts the diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 5f318fc077e..f40ff427dbc 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 d361215822c..863db1884de 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -116,7 +116,7 @@ fn setup_test_lsps2_nodes_with_payer<'a, 'b, 'c>( fn create_jit_invoice( node: &LiquidityNode<'_, '_, '_>, service_node_id: PublicKey, intercept_scid: u64, - cltv_expiry_delta: u32, payment_size_msat: Option, description: &str, expiry_secs: u32, + cltv_expiry_delta: u16, payment_size_msat: Option, description: &str, expiry_secs: u32, ) -> Result { // LSPS2 requires min_final_cltv_expiry_delta to be at least 2 more than usual. let min_final_cltv_expiry_delta = MIN_FINAL_CLTV_EXPIRY_DELTA + 2; @@ -131,7 +131,7 @@ fn create_jit_invoice( src_node_id: service_node_id, short_channel_id: intercept_scid, fees: RoutingFees { base_msat: 0, proportional_millionths: 0 }, - cltv_expiry_delta: cltv_expiry_delta as u16, + cltv_expiry_delta, htlc_minimum_msat: None, htlc_maximum_msat: None, }]); @@ -1167,7 +1167,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; @@ -1345,7 +1345,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 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; @@ -1829,7 +1829,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; @@ -2163,7 +2163,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 a34d0937a34d9f9ea229802429323567965a249e Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:20:21 +0200 Subject: [PATCH 6/9] Document the LSPS2 BOLT12 router flow Clarify how LSPS2 invoice parameters relate to BOLT11 route hints and BOLT12 blinded payment path creation. Co-Authored-By: HAL 9000 --- lightning-liquidity/src/lsps2/event.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lightning-liquidity/src/lsps2/event.rs b/lightning-liquidity/src/lsps2/event.rs index 0575b52c26a..eb9f25b0098 100644 --- a/lightning-liquidity/src/lsps2/event.rs +++ b/lightning-liquidity/src/lsps2/event.rs @@ -49,7 +49,17 @@ pub enum LSPS2ClientEvent { /// When the invoice is paid, the LSP will open a channel with the previously agreed upon /// parameters to you. /// + /// For BOLT11 JIT invoices, `intercept_scid` and `cltv_expiry_delta` can be used in a route + /// hint. + /// + /// For BOLT12 JIT flows, register these parameters for your offer id on an + /// [`LSPS2BOLT12Router`] and then proceed with the regular BOLT12 offer + /// flow. The router will inject the LSPS2-specific blinded payment path when creating the + /// invoice. + /// /// **Note: ** This event will *not* be persisted across restarts. + /// + /// [`LSPS2BOLT12Router`]: crate::lsps2::router::LSPS2BOLT12Router InvoiceParametersReady { /// The identifier of the issued bLIP-52 / LSPS2 `buy` request, as returned by /// [`LSPS2ClientHandler::select_opening_params`]. From 1b4a3595abf058a644adc9f477219f6c8806d5ec Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:21:18 +0200 Subject: [PATCH 7/9] Add a blinded-payment-path override to test utilities Let integration tests force specific blinded payment paths so LSPS2 BOLT12 routing behavior can be exercised deterministically. Co-Authored-By: HAL 9000 --- lightning/src/util/test_utils.rs | 35 +++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 892c9f4169d..6985e21c5a3 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -168,6 +168,23 @@ impl chaininterface::FeeEstimator for TestFeeEstimator { } } +/// Override closure type for [`TestRouter::override_create_blinded_payment_paths`]. +/// +/// This closure is called instead of the default [`Router::create_blinded_payment_paths`] +/// implementation when set, receiving the actual [`ReceiveTlvs`] so tests can construct custom +/// blinded payment paths using the same TLVs the caller generated. +pub type BlindedPaymentPathOverrideFn = Box< + dyn Fn( + PublicKey, + ReceiveAuthKey, + Vec, + ReceiveTlvs, + Option, + ) -> Result, ()> + + Send + + Sync, +>; + pub struct TestRouter<'a> { pub router: DefaultRouter< Arc>, @@ -181,6 +198,7 @@ pub struct TestRouter<'a> { pub next_routes: Mutex>)>>, pub next_blinded_payment_paths: Mutex>, pub next_payment_context_metadata: Mutex>>>, + pub override_create_blinded_payment_paths: Mutex>, pub scorer: &'a RwLock, } @@ -193,6 +211,7 @@ impl<'a> TestRouter<'a> { let next_routes = Mutex::new(VecDeque::new()); let next_blinded_payment_paths = Mutex::new(Vec::new()); let next_payment_context_metadata = Mutex::new(None); + let override_create_blinded_payment_paths = Mutex::new(None); Self { router: DefaultRouter::new( Arc::clone(&network_graph), @@ -205,6 +224,7 @@ impl<'a> TestRouter<'a> { next_routes, next_blinded_payment_paths, next_payment_context_metadata, + override_create_blinded_payment_paths, scorer, } } @@ -338,6 +358,12 @@ impl<'a> Router for TestRouter<'a> { PaymentContext::Bolt12Refund(ctx) => ctx.payment_metadata = Some(metadata), } } + if let Some(override_fn) = + self.override_create_blinded_payment_paths.lock().unwrap().as_ref() + { + return override_fn(recipient, local_node_receive_key, first_hops, tlvs, amount_msats); + } + let mut expected_paths = self.next_blinded_payment_paths.lock().unwrap(); if expected_paths.is_empty() { self.router.create_blinded_payment_paths( @@ -383,6 +409,7 @@ pub enum TestMessageRouterInternal<'a> { pub struct TestMessageRouter<'a> { pub inner: TestMessageRouterInternal<'a>, pub peers_override: Mutex>, + pub forward_node_scid_override: Mutex>, } impl<'a> TestMessageRouter<'a> { @@ -395,6 +422,7 @@ impl<'a> TestMessageRouter<'a> { entropy_source, )), peers_override: Mutex::new(Vec::new()), + forward_node_scid_override: Mutex::new(new_hash_map()), } } @@ -407,6 +435,7 @@ impl<'a> TestMessageRouter<'a> { entropy_source, )), peers_override: Mutex::new(Vec::new()), + forward_node_scid_override: Mutex::new(new_hash_map()), } } } @@ -438,9 +467,13 @@ impl<'a> MessageRouter for TestMessageRouter<'a> { { let peers_override = self.peers_override.lock().unwrap(); if !peers_override.is_empty() { + let scid_override = self.forward_node_scid_override.lock().unwrap(); let peer_override_nodes: Vec<_> = peers_override .iter() - .map(|pk| MessageForwardNode { node_id: *pk, short_channel_id: None }) + .map(|pk| MessageForwardNode { + node_id: *pk, + short_channel_id: scid_override.get(pk).copied(), + }) .collect(); peers = peer_override_nodes; } From 85fbbaec1f7f6d4a606760379e10ba22affecdac Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:22:49 +0200 Subject: [PATCH 8/9] Cover LSPS2 BOLT12 JIT-channel flows Exercise decoder-provided LSPS2 parameters across custom-router, end-to-end, compact message path, and async-payment BOLT12 flows. Co-Authored-By: HAL 9000 --- .../tests/lsps2_integration_tests.rs | 1387 +++++++++++++++-- 1 file changed, 1228 insertions(+), 159 deletions(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 863db1884de..09290ffa998 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -7,7 +7,10 @@ use common::{ get_lsps_message, LSPSNodes, LSPSNodesWithPayer, LiquidityNode, }; -use lightning::events::{ClosureReason, Event}; +use lightning::blinded_path::message::{ + BlindedMessagePath, MessageContext, MessageForwardNode, NextMessageHop, OffersContext, +}; +use lightning::events::{ClosureReason, Event, EventsProvider}; use lightning::get_event_msg; use lightning::ln::channelmanager::{ OptionalBolt11PaymentParams, PaymentId, TrustedChannelFeatures, @@ -16,7 +19,13 @@ use lightning::ln::functional_test_utils::*; use lightning::ln::msgs::BaseMessageHandler; use lightning::ln::msgs::ChannelMessageHandler; use lightning::ln::msgs::MessageSendEvent; +use lightning::ln::msgs::OnionMessageHandler; use lightning::ln::types::ChannelId; +use lightning::offers::invoice_request::InvoiceRequestFields; +use lightning::offers::offer::OfferId; +use lightning::onion_message::messenger::{Destination, MessageRouter, OnionMessagePath}; +use lightning::routing::router::{InFlightHtlcs, Route, RouteParameters, Router}; +use lightning::sign::{RandomBytes, ReceiveAuthKey}; use lightning_liquidity::events::LiquidityEvent; use lightning_liquidity::lsps0::ser::LSPSDateTime; @@ -24,11 +33,18 @@ use lightning_liquidity::lsps2::client::LSPS2ClientConfig; use lightning_liquidity::lsps2::event::LSPS2ClientEvent; use lightning_liquidity::lsps2::event::LSPS2ServiceEvent; use lightning_liquidity::lsps2::msgs::LSPS2RawOpeningFeeParams; +use lightning_liquidity::lsps2::router::{ + LSPS2BOLT12Router, LSPS2Bolt12InvoiceParameters, LSPS2Bolt12PaymentMetadataDecoder, +}; use lightning_liquidity::lsps2::service::LSPS2ServiceConfig; use lightning_liquidity::lsps2::utils::is_valid_opening_fee_params; use lightning_liquidity::utils::time::{DefaultTimeProvider, TimeProvider}; use lightning_liquidity::{LiquidityClientConfig, LiquidityManagerSync, LiquidityServiceConfig}; +use lightning::blinded_path::payment::{ + Bolt12OfferContext, PaymentConstraints, PaymentContext, ReceiveTlvs, +}; +use lightning::blinded_path::NodeIdLookUp; use lightning::ln::channelmanager::{InterceptId, MIN_FINAL_CLTV_EXPIRY_DELTA}; use lightning::ln::functional_test_utils::{ create_chanmon_cfgs, create_node_cfgs, create_node_chanmgrs, @@ -41,6 +57,7 @@ use lightning::sign::NodeSigner; use lightning::util::config::HTLCInterceptionFlags; use lightning::util::errors::APIError; use lightning::util::logger::Logger; +use lightning::util::ser::{Readable, Writeable}; use lightning::util::test_utils::{TestBroadcaster, TestStore}; use lightning_invoice::{Bolt11Invoice, InvoiceBuilder, RoutingFees}; @@ -51,6 +68,7 @@ use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; use bitcoin::Network; use lightning_types::payment::PaymentPreimage; +use std::collections::BTreeMap; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; @@ -58,6 +76,100 @@ use std::time::Duration; const MAX_PENDING_REQUESTS_PER_PEER: usize = 10; const MAX_TOTAL_PENDING_REQUESTS: usize = 1000; +struct RecordingLookup { + next_node_id: PublicKey, + short_channel_id: std::sync::Mutex>, +} + +impl NodeIdLookUp for RecordingLookup { + fn next_node_id(&self, short_channel_id: u64) -> Option { + *self.short_channel_id.lock().unwrap() = Some(short_channel_id); + Some(self.next_node_id) + } +} + +struct FailingRouter; + +impl FailingRouter { + fn new() -> Self { + Self + } +} + +impl Router for FailingRouter { + fn find_route( + &self, _payer: &PublicKey, _route_params: &RouteParameters, + _first_hops: Option<&[&lightning::ln::channel_state::ChannelDetails]>, + _inflight_htlcs: InFlightHtlcs, + ) -> Result { + Err("failing test router") + } + + fn create_blinded_payment_paths< + T: bitcoin::secp256k1::Signing + bitcoin::secp256k1::Verification, + >( + &self, _recipient: PublicKey, _local_node_receive_key: ReceiveAuthKey, + _first_hops: Vec, _tlvs: ReceiveTlvs, + _amount_msats: Option, _secp_ctx: &Secp256k1, + ) -> Result, ()> { + Err(()) + } +} + +#[derive(Clone, Copy)] +struct TestBolt12PaymentMetadataDecoder; + +impl LSPS2Bolt12PaymentMetadataDecoder for TestBolt12PaymentMetadataDecoder { + fn decode_lsps2_invoice_parameters( + &self, payment_metadata: &BTreeMap>, + ) -> Vec { + payment_metadata + .values() + .filter_map(|encoded| { + let mut reader = &encoded[..]; + LSPS2Bolt12InvoiceParameters::read(&mut reader).ok() + }) + .collect() + } +} + +fn lsps2_bolt12_payment_metadata( + counterparty_node_id: PublicKey, intercept_scid: u64, cltv_expiry_delta: u16, +) -> BTreeMap> { + let params = + LSPS2Bolt12InvoiceParameters { counterparty_node_id, intercept_scid, cltv_expiry_delta }; + let mut encoded_params = Vec::new(); + params.write(&mut encoded_params).unwrap(); + let mut metadata = BTreeMap::new(); + metadata.insert(42, encoded_params); + metadata +} + +struct PaymentMetadataMessageRouter { + inner: MR, + payment_metadata: BTreeMap>, +} + +impl MessageRouter for PaymentMetadataMessageRouter { + fn find_path( + &self, sender: PublicKey, peers: Vec, destination: Destination, + ) -> Result { + self.inner.find_path(sender, peers, destination) + } + + fn create_blinded_paths( + &self, recipient: PublicKey, local_node_receive_key: ReceiveAuthKey, + mut context: MessageContext, peers: Vec, secp_ctx: &Secp256k1, + ) -> Result, ()> { + if let MessageContext::Offers(OffersContext::InvoiceRequest { payment_metadata, .. }) = + &mut context + { + *payment_metadata = Some(self.payment_metadata.clone()); + } + self.inner.create_blinded_paths(recipient, local_node_receive_key, context, peers, secp_ctx) + } +} + fn build_lsps2_configs() -> ([u8; 32], LiquidityServiceConfig, LiquidityClientConfig) { let promise_secret = [42; 32]; let lsps2_service_config = LSPS2ServiceConfig { promise_secret }; @@ -1478,147 +1590,95 @@ fn execute_lsps2_dance( } } -fn create_channel_with_manual_broadcast( - service_node_id: &PublicKey, client_node_id: &PublicKey, service_node: &LiquidityNode, - client_node: &LiquidityNode, user_channel_id: u128, expected_outbound_amount_msat: &u64, - mark_broadcast_safe: bool, -) -> (ChannelId, bitcoin::Transaction) { - assert!(service_node - .node - .create_channel( - *client_node_id, - *expected_outbound_amount_msat, - 0, - user_channel_id, - None, - None - ) - .is_ok()); - let open_channel = - get_event_msg!(service_node, MessageSendEvent::SendOpenChannel, *client_node_id); - - client_node.node.handle_open_channel(*service_node_id, &open_channel); +#[test] +fn bolt12_custom_router_uses_lsps2_intercept_scid() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); - let events = client_node.node.get_and_clear_pending_events(); - assert_eq!(events.len(), 1); - match events[0] { - Event::OpenChannelRequest { temporary_channel_id, .. } => { - client_node - .node - .accept_inbound_channel_from_trusted_peer( - &temporary_channel_id, - &service_node_id, - user_channel_id, - TrustedChannelFeatures::ZeroConf, - None, - ) - .unwrap(); - }, - _ => panic!("Unexpected event"), - }; + let service_node_id = lsps_nodes.service_node.inner.node.get_our_node_id(); + let client_node_id = lsps_nodes.client_node.inner.node.get_our_node_id(); - let accept_channel = - get_event_msg!(client_node, MessageSendEvent::SendAcceptChannel, *service_node_id); - assert_eq!(accept_channel.common_fields.minimum_depth, 0); + let intercept_scid = lsps_nodes.service_node.node.get_intercept_scid(); + let cltv_expiry_delta = 72; - service_node.node.handle_accept_channel(*client_node_id, &accept_channel); - let (temp_channel_id, funding_tx, funding_outpoint) = create_funding_transaction( - &service_node, - &client_node_id, - *expected_outbound_amount_msat, - user_channel_id, + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + 42, + cltv_expiry_delta, + promise_secret, + Some(250_000), + 1_000, ); - let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); - service_handler - .store_funding_transaction(user_channel_id, &client_node_id, funding_tx.clone()) - .unwrap(); - service_node - .node - .funding_transaction_generated_manual_broadcast( - temp_channel_id, - *client_node_id, - funding_tx.clone(), - ) - .unwrap(); - - let funding_created = - get_event_msg!(service_node, MessageSendEvent::SendFundingCreated, *client_node_id); - client_node.node.handle_funding_created(*service_node_id, &funding_created); - check_added_monitors(&client_node.inner, 1); - - let bs_signed_locked = client_node.node.get_and_clear_pending_msg_events(); - assert_eq!(bs_signed_locked.len(), 2); - - let as_channel_ready; - match &bs_signed_locked[0] { - MessageSendEvent::SendFundingSigned { node_id, msg } => { - assert_eq!(*node_id, *service_node_id); - service_node.node.handle_funding_signed(*client_node_id, &msg); - let events = &service_node.node.get_and_clear_pending_events(); - assert_eq!(events.len(), 2); - match &events[0] { - Event::FundingTxBroadcastSafe { - funding_txo, - user_channel_id, - counterparty_node_id, - .. - } => { - assert_eq!(funding_txo.txid, funding_outpoint.txid); - assert_eq!(funding_txo.vout, funding_outpoint.index as u32); - if mark_broadcast_safe { - service_handler - .set_funding_tx_broadcast_safe(*user_channel_id, counterparty_node_id) - .unwrap(); - } - }, - _ => panic!("Unexpected event"), - }; - match &events[1] { - Event::ChannelPending { counterparty_node_id, .. } => { - assert_eq!(counterparty_node_id, client_node_id); - }, - _ => panic!("Unexpected event"), - } - expect_channel_pending_event(&client_node, &service_node_id); - check_added_monitors(&service_node.inner, 1); - - as_channel_ready = - get_event_msg!(service_node, MessageSendEvent::SendChannelReady, *client_node_id); - }, - _ => panic!("Unexpected event"), - } - match &bs_signed_locked[1] { - MessageSendEvent::SendChannelReady { node_id, msg } => { - assert_eq!(*node_id, *service_node_id); - service_node.node.handle_channel_ready(*client_node_id, &msg); - expect_channel_ready_event(&service_node, &client_node_id); - }, - _ => panic!("Unexpected event"), - } + let payment_metadata = + lsps2_bolt12_payment_metadata(service_node_id, intercept_scid, cltv_expiry_delta); + let inner_router = FailingRouter::new(); + let router = LSPS2BOLT12Router::new_with_payment_metadata_decoder( + inner_router, + lsps_nodes.client_node.keys_manager, + TestBolt12PaymentMetadataDecoder, + ); - client_node.node.handle_channel_ready(*service_node_id, &as_channel_ready); - expect_channel_ready_event(&client_node, &service_node_id); + let tlvs = ReceiveTlvs { + payment_secret: lightning_types::payment::PaymentSecret([7; 32]), + payment_constraints: PaymentConstraints { max_cltv_expiry: 50, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: OfferId([42; 32]), + payment_metadata: Some(payment_metadata), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: lsps_nodes.payer_node.node.get_our_node_id(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }), + }; - let as_channel_update = - get_event_msg!(service_node, MessageSendEvent::SendChannelUpdate, *client_node_id); - let bs_channel_update = - get_event_msg!(client_node, MessageSendEvent::SendChannelUpdate, *service_node_id); + let secp_ctx = Secp256k1::new(); + let mut paths = router + .create_blinded_payment_paths( + client_node_id, + ReceiveAuthKey([3; 32]), + Vec::new(), + tlvs, + Some(100_000), + &secp_ctx, + ) + .unwrap(); - service_node.node.handle_channel_update(*client_node_id, &bs_channel_update); - client_node.node.handle_channel_update(*service_node_id, &as_channel_update); + assert_eq!(paths.len(), 1); + let mut path = paths.pop().unwrap(); + assert_eq!( + path.introduction_node(), + &lightning::blinded_path::IntroductionNode::NodeId(service_node_id) + ); + assert_eq!(path.payinfo.fee_base_msat, 0); + assert_eq!(path.payinfo.fee_proportional_millionths, 0); - (as_channel_ready.channel_id, funding_tx) + let lookup = RecordingLookup { + next_node_id: client_node_id, + short_channel_id: std::sync::Mutex::new(None), + }; + path.advance_path_by_one(lsps_nodes.service_node.keys_manager, &lookup, &secp_ctx).unwrap(); + assert_eq!(*lookup.short_channel_id.lock().unwrap(), Some(intercept_scid)); } #[test] -fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { +fn bolt12_lsps2_end_to_end_test() { + // End-to-end test of the BOLT12 + LSPS2 JIT channel flow. Three nodes: payer, service, client. + // client_trusts_lsp=true; funding transaction broadcast happens after client claims the HTLC. let chanmon_cfgs = create_chanmon_cfgs(3); let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let mut service_node_config = test_default_channel_config(); service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; let mut client_node_config = test_default_channel_config(); + client_node_config.accept_inbound_channels = true; client_node_config.channel_config.accept_underpaying_htlcs = true; let node_chanmgrs = create_node_chanmgrs( @@ -1639,10 +1699,10 @@ fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); let intercept_scid = service_node.node.get_intercept_scid(); - let user_channel_id = 43u128; + let user_channel_id = 42; let cltv_expiry_delta: u16 = 144; let payment_size_msat = Some(1_000_000); - let fee_base_msat: u64 = 10_000; + let fee_base_msat = 1_000; execute_lsps2_dance( &lsps_nodes, @@ -1654,72 +1714,157 @@ fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { fee_base_msat, ); - let invoice = create_jit_invoice( - &client_node, - service_node_id, - intercept_scid, - cltv_expiry_delta, - payment_size_msat, - "late-safe", - 3600, - ) - .unwrap(); + // Disconnect payer from client to ensure deterministic onion message routing through service. + payer_node.node.peer_disconnected(client_node_id); + client_node.node.peer_disconnected(payer_node_id); + payer_node.onion_messenger.peer_disconnected(client_node_id); + client_node.onion_messenger.peer_disconnected(payer_node_id); - payer_node + let payment_metadata = + lsps2_bolt12_payment_metadata(service_node_id, intercept_scid, cltv_expiry_delta); + + #[cfg(c_bindings)] + let offer = { + let mut offer_builder = client_node + .node + .create_offer_builder_using_router(PaymentMetadataMessageRouter { + inner: client_node.message_router, + payment_metadata: payment_metadata.clone(), + }) + .unwrap(); + offer_builder.amount_msats(payment_size_msat.unwrap()); + offer_builder.build().unwrap() + }; + #[cfg(not(c_bindings))] + let offer = client_node .node - .pay_for_bolt11_invoice( - &invoice, - PaymentId(invoice.payment_hash().0), - None, - OptionalBolt11PaymentParams::default(), - ) + .create_offer_builder_using_router(PaymentMetadataMessageRouter { + inner: client_node.message_router, + payment_metadata: payment_metadata.clone(), + }) + .unwrap() + .amount_msats(payment_size_msat.unwrap()) + .build() .unwrap(); + let lsps2_router = Arc::new(LSPS2BOLT12Router::new_with_payment_metadata_decoder( + FailingRouter::new(), + Arc::new(RandomBytes::new([43; 32])), + TestBolt12PaymentMetadataDecoder, + )); + + let lsps2_router = Arc::clone(&lsps2_router); + *client_node.router.override_create_blinded_payment_paths.lock().unwrap() = + Some(Box::new(move |recipient, local_node_receive_key, first_hops, tlvs, amount_msats| { + let secp_ctx = Secp256k1::new(); + lsps2_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs, + amount_msats, + &secp_ctx, + ) + })); + + let payment_id = PaymentId([1; 32]); + payer_node.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + + let onion_msg = payer_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Payer should send InvoiceRequest toward service"); + service_node.onion_messenger.handle_onion_message(payer_node_id, &onion_msg); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(client_node_id) + .expect("Service should forward InvoiceRequest to client"); + client_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + + let onion_msg = client_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Client should send Invoice toward service"); + service_node.onion_messenger.handle_onion_message(client_node_id, &onion_msg); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(payer_node_id) + .expect("Service should forward Invoice to payer"); + payer_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + check_added_monitors(&payer_node, 1); let events = payer_node.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); let ev = SendEvent::from_event(events[0].clone()); + service_node.inner.node.handle_update_add_htlc(payer_node_id, &ev.msgs[0]); do_commitment_signed_dance(&service_node.inner, &payer_node, &ev.commitment_msg, false, true); service_node.inner.node.process_pending_htlc_forwards(); let events = service_node.inner.node.get_and_clear_pending_events(); assert_eq!(events.len(), 1); - match &events[0] { + let (payment_hash, expected_outbound_amount_msat) = match &events[0] { Event::HTLCIntercepted { intercept_id, requested_next_hop_scid, - payment_hash: _, + payment_hash, expected_outbound_amount_msat, .. } => { assert_eq!(*requested_next_hop_scid, intercept_scid); + service_handler .htlc_intercepted( *requested_next_hop_scid, *intercept_id, *expected_outbound_amount_msat, - invoice.payment_hash(), + *payment_hash, ) .unwrap(); + (*payment_hash, expected_outbound_amount_msat) }, - other => panic!("Expected HTLCIntercepted, got {:?}", other), - } + other => panic!("Expected HTLCIntercepted event, got: {:?}", other), + }; + + let open_channel_event = service_node.liquidity_manager.next_event().unwrap(); + + match open_channel_event { + LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::OpenChannel { + their_network_key, + amt_to_forward_msat, + opening_fee_msat, + user_channel_id: uc_id, + intercept_scid: iscd, + }) => { + assert_eq!(their_network_key, client_node_id); + assert_eq!(amt_to_forward_msat, payment_size_msat.unwrap() - fee_base_msat); + assert_eq!(opening_fee_msat, fee_base_msat); + assert_eq!(uc_id, user_channel_id); + assert_eq!(iscd, intercept_scid); + }, + other => panic!("Expected OpenChannel event, got: {:?}", other), + }; + + let result = + service_handler.channel_needs_manual_broadcast(user_channel_id, &client_node_id).unwrap(); + assert!(result, "Channel should require manual broadcast"); - // Create channel but DO NOT mark broadcast safe yet let (channel_id, funding_tx) = create_channel_with_manual_broadcast( &service_node_id, &client_node_id, &service_node, &client_node, user_channel_id, - &(payment_size_msat.unwrap() - fee_base_msat), - false, + expected_outbound_amount_msat, + true, ); service_handler.channel_ready(user_channel_id, &channel_id, &client_node_id).unwrap(); + service_node.inner.node.process_pending_htlc_forwards(); - // Run forward to client and let client claim. do not notify service handler yet. let pay_event = { { let mut added_monitors = @@ -1727,9 +1872,9 @@ fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { assert_eq!(added_monitors.len(), 1); added_monitors.clear(); } - let mut msg_events = service_node.inner.node.get_and_clear_pending_msg_events(); - assert_eq!(msg_events.len(), 1); - SendEvent::from_event(msg_events.remove(0)) + let mut events = service_node.inner.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + SendEvent::from_event(events.remove(0)) }; client_node.inner.node.handle_update_add_htlc(service_node_id, &pay_event.msgs[0]); @@ -1745,12 +1890,525 @@ fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { let client_events = client_node.inner.node.get_and_clear_pending_events(); assert_eq!(client_events.len(), 1); let preimage = match &client_events[0] { - Event::PaymentClaimable { purpose, .. } => purpose.preimage().unwrap(), - other => panic!("Expected PaymentClaimable, got {:?}", other), + Event::PaymentClaimable { payment_hash: ph, purpose, .. } => { + assert_eq!(*ph, payment_hash); + purpose.preimage() + }, + other => panic!("Expected PaymentClaimable event on client, got: {:?}", other), }; - client_node.inner.node.claim_funds(preimage); - claim_and_assert_forwarded_only(&payer_node, &service_node.inner, &client_node.inner, preimage); + let broadcasted = service_node.inner.tx_broadcaster.txn_broadcasted.lock().unwrap(); + assert!(broadcasted.is_empty(), "There should be no broadcasted txs yet"); + drop(broadcasted); + + client_node.inner.node.claim_funds(preimage.unwrap()); + + claim_and_assert_forwarded_only( + &payer_node, + &service_node.inner, + &client_node.inner, + preimage.unwrap(), + ); + + let service_events = service_node.node.get_and_clear_pending_events(); + assert_eq!(service_events.len(), 1); + + let total_fee_msat = match service_events[0].clone() { + Event::PaymentForwarded { + prev_htlcs, + next_htlcs, + skimmed_fee_msat, + total_fee_earned_msat, + .. + } => { + assert_eq!(prev_htlcs[0].node_id, Some(payer_node_id)); + assert_eq!(next_htlcs[0].node_id, Some(client_node_id)); + service_handler.payment_forwarded(channel_id, skimmed_fee_msat.unwrap_or(0)).unwrap(); + Some(total_fee_earned_msat.unwrap() - skimmed_fee_msat.unwrap()) + }, + _ => panic!("Expected PaymentForwarded event, got: {:?}", service_events[0]), + }; + + let broadcasted = service_node.inner.tx_broadcaster.txn_broadcasted.lock().unwrap(); + assert!(broadcasted.iter().any(|b| b.compute_txid() == funding_tx.compute_txid())); + + expect_payment_sent(&payer_node, preimage.unwrap(), Some(total_fee_msat), true, true); +} + +#[test] +fn bolt12_lsps2_compact_message_path_test() { + // Tests that LSPS2 BOLT12 offers work with compact SCID-based message blinded paths. + // The client's offer uses an intercept SCID instead of the full pubkey for the next hop + // in the message blinded path. When the service node receives a forwarded InvoiceRequest + // with the unresolvable intercept SCID, it emits OnionMessageIntercepted instead of + // dropping the message. The test then forwards the message to the connected client. + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + + let mut service_node_config = test_default_channel_config(); + service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; + + let mut client_node_config = test_default_channel_config(); + client_node_config.accept_inbound_channels = true; + client_node_config.channel_config.accept_underpaying_htlcs = true; + + let node_chanmgrs = create_node_chanmgrs( + 3, + &node_cfgs, + &[Some(service_node_config), Some(client_node_config), None], + ); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); + let LSPSNodesWithPayer { ref service_node, ref client_node, ref payer_node } = lsps_nodes; + + let payer_node_id = payer_node.node.get_our_node_id(); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + let intercept_scid = service_node.node.get_intercept_scid(); + let user_channel_id = 42; + let cltv_expiry_delta: u16 = 144; + let payment_size_msat = Some(1_000_000); + let fee_base_msat = 1_000; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + user_channel_id, + cltv_expiry_delta, + promise_secret, + payment_size_msat, + fee_base_msat, + ); + + // Configure the client's message router to use compact SCID encoding for message + // blinded paths through the service node. + client_node.message_router.peers_override.lock().unwrap().push(service_node_id); + client_node + .message_router + .forward_node_scid_override + .lock() + .unwrap() + .insert(service_node_id, intercept_scid); + + // Disconnect payer from client so messages route through service. + payer_node.node.peer_disconnected(client_node_id); + client_node.node.peer_disconnected(payer_node_id); + payer_node.onion_messenger.peer_disconnected(client_node_id); + client_node.onion_messenger.peer_disconnected(payer_node_id); + + // Disconnect service from client so the service must intercept the compact SCID-based + // InvoiceRequest instead of forwarding it immediately after resolving the registered SCID. + service_node.node.peer_disconnected(client_node_id); + client_node.node.peer_disconnected(service_node_id); + service_node.onion_messenger.peer_disconnected(client_node_id); + client_node.onion_messenger.peer_disconnected(service_node_id); + + let payment_metadata = + lsps2_bolt12_payment_metadata(service_node_id, intercept_scid, cltv_expiry_delta); + + #[cfg(c_bindings)] + let offer = { + let mut offer_builder = client_node + .node + .create_offer_builder_using_router(PaymentMetadataMessageRouter { + inner: client_node.message_router, + payment_metadata: payment_metadata.clone(), + }) + .unwrap(); + offer_builder.amount_msats(payment_size_msat.unwrap()); + offer_builder.build().unwrap() + }; + #[cfg(not(c_bindings))] + let offer = client_node + .node + .create_offer_builder_using_router(PaymentMetadataMessageRouter { + inner: client_node.message_router, + payment_metadata: payment_metadata.clone(), + }) + .unwrap() + .amount_msats(payment_size_msat.unwrap()) + .build() + .unwrap(); + + let lsps2_router = Arc::new(LSPS2BOLT12Router::new_with_payment_metadata_decoder( + FailingRouter::new(), + Arc::new(RandomBytes::new([43; 32])), + TestBolt12PaymentMetadataDecoder, + )); + + let lsps2_router = Arc::clone(&lsps2_router); + *client_node.router.override_create_blinded_payment_paths.lock().unwrap() = + Some(Box::new(move |recipient, local_node_receive_key, first_hops, tlvs, amount_msats| { + let secp_ctx = Secp256k1::new(); + lsps2_router.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs, + amount_msats, + &secp_ctx, + ) + })); + + // Payer sends InvoiceRequest toward the service node. + let payment_id = PaymentId([1; 32]); + payer_node.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + + let onion_msg = payer_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Payer should send InvoiceRequest toward service"); + service_node.onion_messenger.handle_onion_message(payer_node_id, &onion_msg); + + // The service node can't resolve the intercept SCID via NodeIdLookUp (no real channel), + // so the message is intercepted via SCID-based interception. + // It should NOT be available as a normal forwarded message. + assert!( + service_node.onion_messenger.next_onion_message_for_peer(client_node_id).is_none(), + "Message should be intercepted, not forwarded directly" + ); + + // Process the OnionMessageIntercepted event and forward the message. + let events = core::cell::RefCell::new(Vec::new()); + service_node.onion_messenger.process_pending_events(&|e| Ok(events.borrow_mut().push(e))); + let events = events.into_inner(); + + let intercepted_msg = events + .into_iter() + .find_map(|e| match e { + Event::OnionMessageIntercepted { next_hop, message } => { + assert_eq!(next_hop, NextMessageHop::ShortChannelId(intercept_scid)); + Some(message) + }, + _ => None, + }) + .expect("Service should emit OnionMessageIntercepted for SCID-based forward"); + + // Reconnect the service and client, then forward the intercepted message. + reconnect_nodes(ReconnectArgs::new(&service_node.inner, &client_node.inner)); + + // Forward the intercepted message to the reconnected client. + service_node + .onion_messenger + .forward_onion_message(intercepted_msg, &client_node_id) + .expect("Should succeed since client reconnected"); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(client_node_id) + .expect("Service should have forwarded message to client"); + client_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + + // Client should respond with an Invoice back through the service to the payer. + let onion_msg = client_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Client should send Invoice toward service"); + service_node.onion_messenger.handle_onion_message(client_node_id, &onion_msg); + + let fwd_msg = service_node + .onion_messenger + .next_onion_message_for_peer(payer_node_id) + .expect("Service should forward Invoice to payer"); + payer_node.onion_messenger.handle_onion_message(service_node_id, &fwd_msg); + + // Payer should have queued an HTLC payment. + check_added_monitors(&payer_node, 1); + let events = payer_node.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = SendEvent::from_event(events[0].clone()); + + // Verify the payment gets intercepted at the service node on the intercept SCID. + service_node.inner.node.handle_update_add_htlc(payer_node_id, &ev.msgs[0]); + do_commitment_signed_dance(&service_node.inner, &payer_node, &ev.commitment_msg, false, true); + service_node.inner.node.process_pending_htlc_forwards(); + + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::HTLCIntercepted { requested_next_hop_scid, .. } => { + assert_eq!(*requested_next_hop_scid, intercept_scid); + }, + other => panic!("Expected HTLCIntercepted event, got: {:?}", other), + }; +} + +fn create_channel_with_manual_broadcast( + service_node_id: &PublicKey, client_node_id: &PublicKey, service_node: &LiquidityNode, + client_node: &LiquidityNode, user_channel_id: u128, expected_outbound_amount_msat: &u64, + mark_broadcast_safe: bool, +) -> (ChannelId, bitcoin::Transaction) { + assert!(service_node + .node + .create_channel( + *client_node_id, + *expected_outbound_amount_msat, + 0, + user_channel_id, + None, + None + ) + .is_ok()); + let open_channel = + get_event_msg!(service_node, MessageSendEvent::SendOpenChannel, *client_node_id); + + client_node.node.handle_open_channel(*service_node_id, &open_channel); + + let events = client_node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match events[0] { + Event::OpenChannelRequest { temporary_channel_id, .. } => { + client_node + .node + .accept_inbound_channel_from_trusted_peer( + &temporary_channel_id, + &service_node_id, + user_channel_id, + TrustedChannelFeatures::ZeroConf, + None, + ) + .unwrap(); + }, + _ => panic!("Unexpected event"), + }; + + let accept_channel = + get_event_msg!(client_node, MessageSendEvent::SendAcceptChannel, *service_node_id); + assert_eq!(accept_channel.common_fields.minimum_depth, 0); + + service_node.node.handle_accept_channel(*client_node_id, &accept_channel); + let (temp_channel_id, funding_tx, funding_outpoint) = create_funding_transaction( + &service_node, + &client_node_id, + *expected_outbound_amount_msat, + user_channel_id, + ); + let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); + service_handler + .store_funding_transaction(user_channel_id, &client_node_id, funding_tx.clone()) + .unwrap(); + service_node + .node + .funding_transaction_generated_manual_broadcast( + temp_channel_id, + *client_node_id, + funding_tx.clone(), + ) + .unwrap(); + + let funding_created = + get_event_msg!(service_node, MessageSendEvent::SendFundingCreated, *client_node_id); + client_node.node.handle_funding_created(*service_node_id, &funding_created); + check_added_monitors(&client_node.inner, 1); + + let bs_signed_locked = client_node.node.get_and_clear_pending_msg_events(); + assert_eq!(bs_signed_locked.len(), 2); + + let as_channel_ready; + match &bs_signed_locked[0] { + MessageSendEvent::SendFundingSigned { node_id, msg } => { + assert_eq!(*node_id, *service_node_id); + service_node.node.handle_funding_signed(*client_node_id, &msg); + let events = &service_node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 2); + match &events[0] { + Event::FundingTxBroadcastSafe { + funding_txo, + user_channel_id, + counterparty_node_id, + .. + } => { + assert_eq!(funding_txo.txid, funding_outpoint.txid); + assert_eq!(funding_txo.vout, funding_outpoint.index as u32); + if mark_broadcast_safe { + service_handler + .set_funding_tx_broadcast_safe(*user_channel_id, counterparty_node_id) + .unwrap(); + } + }, + _ => panic!("Unexpected event"), + }; + match &events[1] { + Event::ChannelPending { counterparty_node_id, .. } => { + assert_eq!(counterparty_node_id, client_node_id); + }, + _ => panic!("Unexpected event"), + } + expect_channel_pending_event(&client_node, &service_node_id); + check_added_monitors(&service_node.inner, 1); + + as_channel_ready = + get_event_msg!(service_node, MessageSendEvent::SendChannelReady, *client_node_id); + }, + _ => panic!("Unexpected event"), + } + + match &bs_signed_locked[1] { + MessageSendEvent::SendChannelReady { node_id, msg } => { + assert_eq!(*node_id, *service_node_id); + service_node.node.handle_channel_ready(*client_node_id, &msg); + expect_channel_ready_event(&service_node, &client_node_id); + }, + _ => panic!("Unexpected event"), + } + + client_node.node.handle_channel_ready(*service_node_id, &as_channel_ready); + expect_channel_ready_event(&client_node, &service_node_id); + + let as_channel_update = + get_event_msg!(service_node, MessageSendEvent::SendChannelUpdate, *client_node_id); + let bs_channel_update = + get_event_msg!(client_node, MessageSendEvent::SendChannelUpdate, *service_node_id); + + service_node.node.handle_channel_update(*client_node_id, &bs_channel_update); + client_node.node.handle_channel_update(*service_node_id, &as_channel_update); + + (as_channel_ready.channel_id, funding_tx) +} + +#[test] +fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let mut service_node_config = test_default_channel_config(); + service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; + + let mut client_node_config = test_default_channel_config(); + client_node_config.channel_config.accept_underpaying_htlcs = true; + + let node_chanmgrs = create_node_chanmgrs( + 3, + &node_cfgs, + &[Some(service_node_config), Some(client_node_config), None], + ); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); + let LSPSNodesWithPayer { ref service_node, ref client_node, ref payer_node } = lsps_nodes; + + let payer_node_id = payer_node.node.get_our_node_id(); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); + + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + let intercept_scid = service_node.node.get_intercept_scid(); + let user_channel_id = 43u128; + let cltv_expiry_delta: u16 = 144; + let payment_size_msat = Some(1_000_000); + let fee_base_msat: u64 = 10_000; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + user_channel_id, + cltv_expiry_delta, + promise_secret, + payment_size_msat, + fee_base_msat, + ); + + let invoice = create_jit_invoice( + &client_node, + service_node_id, + intercept_scid, + cltv_expiry_delta, + payment_size_msat, + "late-safe", + 3600, + ) + .unwrap(); + + payer_node + .node + .pay_for_bolt11_invoice( + &invoice, + PaymentId(invoice.payment_hash().0), + None, + OptionalBolt11PaymentParams::default(), + ) + .unwrap(); + + check_added_monitors(&payer_node, 1); + let events = payer_node.node.get_and_clear_pending_msg_events(); + let ev = SendEvent::from_event(events[0].clone()); + service_node.inner.node.handle_update_add_htlc(payer_node_id, &ev.msgs[0]); + do_commitment_signed_dance(&service_node.inner, &payer_node, &ev.commitment_msg, false, true); + service_node.inner.node.process_pending_htlc_forwards(); + + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match &events[0] { + Event::HTLCIntercepted { + intercept_id, + requested_next_hop_scid, + payment_hash: _, + expected_outbound_amount_msat, + .. + } => { + assert_eq!(*requested_next_hop_scid, intercept_scid); + service_handler + .htlc_intercepted( + *requested_next_hop_scid, + *intercept_id, + *expected_outbound_amount_msat, + invoice.payment_hash(), + ) + .unwrap(); + }, + other => panic!("Expected HTLCIntercepted, got {:?}", other), + } + + // Create channel but DO NOT mark broadcast safe yet + let (channel_id, funding_tx) = create_channel_with_manual_broadcast( + &service_node_id, + &client_node_id, + &service_node, + &client_node, + user_channel_id, + &(payment_size_msat.unwrap() - fee_base_msat), + false, + ); + + service_handler.channel_ready(user_channel_id, &channel_id, &client_node_id).unwrap(); + service_node.inner.node.process_pending_htlc_forwards(); + + // Run forward to client and let client claim. do not notify service handler yet. + let pay_event = { + { + let mut added_monitors = + service_node.inner.chain_monitor.added_monitors.lock().unwrap(); + assert_eq!(added_monitors.len(), 1); + added_monitors.clear(); + } + let mut msg_events = service_node.inner.node.get_and_clear_pending_msg_events(); + assert_eq!(msg_events.len(), 1); + SendEvent::from_event(msg_events.remove(0)) + }; + + client_node.inner.node.handle_update_add_htlc(service_node_id, &pay_event.msgs[0]); + do_commitment_signed_dance( + &client_node.inner, + &service_node.inner, + &pay_event.commitment_msg, + false, + true, + ); + client_node.inner.node.process_pending_htlc_forwards(); + + let client_events = client_node.inner.node.get_and_clear_pending_events(); + assert_eq!(client_events.len(), 1); + let preimage = match &client_events[0] { + Event::PaymentClaimable { purpose, .. } => purpose.preimage().unwrap(), + other => panic!("Expected PaymentClaimable, got {:?}", other), + }; + + client_node.inner.node.claim_funds(preimage); + claim_and_assert_forwarded_only(&payer_node, &service_node.inner, &client_node.inner, preimage); // Service now has PaymentForwarded. Record in JIT state but still not safe to broadcast. let events = service_node.node.get_and_clear_pending_events(); @@ -2352,3 +3010,414 @@ fn client_trusts_lsp_partial_fee_does_not_trigger_broadcast() { client_node.inner.chain_monitor.added_monitors.lock().unwrap().clear(); payer_node.chain_monitor.added_monitors.lock().unwrap().clear(); } + +#[test] +fn async_payment_via_lsps2_jit_channel() { + // Test async payments through an LSPS2 JIT channel. Three nodes: payer, service (LSP + + // static invoice server), client (often-offline async recipient). The client has no channel + // with the service and relies on LSPS2 to open a JIT channel when the payment arrives. + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + + let mut service_node_config = test_default_channel_config(); + service_node_config.htlc_interception_flags = HTLCInterceptionFlags::ToInterceptSCIDs as u8; + service_node_config.accept_forwards_to_priv_channels = true; + + let mut client_node_config = test_default_channel_config(); + client_node_config.accept_inbound_channels = true; + client_node_config.channel_config.accept_underpaying_htlcs = true; + + let node_chanmgrs = create_node_chanmgrs( + 3, + &node_cfgs, + &[Some(service_node_config), Some(client_node_config), None], + ); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + let (lsps_nodes, promise_secret) = setup_test_lsps2_nodes_with_payer(nodes); + let LSPSNodesWithPayer { ref service_node, ref client_node, ref payer_node } = lsps_nodes; + + let payer_node_id = payer_node.node.get_our_node_id(); + let service_node_id = service_node.inner.node.get_our_node_id(); + let client_node_id = client_node.inner.node.get_our_node_id(); + + let service_handler = service_node.liquidity_manager.lsps2_service_handler().unwrap(); + + // Create channel: payer ↔ service. + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + // Run the LSPS2 dance to get an intercept SCID and fee parameters. + let intercept_scid = service_node.node.get_intercept_scid(); + let user_channel_id = 42; + let cltv_expiry_delta: u16 = 144; + let payment_size_msat = None; + let fee_base_msat = 1_000; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + user_channel_id, + cltv_expiry_delta, + promise_secret, + payment_size_msat, + fee_base_msat, + ); + + // Set up the LSPS2BOLT12Router on the client BEFORE the static invoice is created, so + // the invoice's blinded payment paths route through the LSP's intercept SCID. + let payment_metadata = + lsps2_bolt12_payment_metadata(service_node_id, intercept_scid, cltv_expiry_delta); + let lsps2_router = Arc::new(LSPS2BOLT12Router::new_with_payment_metadata_decoder( + FailingRouter::new(), + Arc::new(RandomBytes::new([43; 32])), + TestBolt12PaymentMetadataDecoder, + )); + + let lsps2_router_clone = Arc::clone(&lsps2_router); + *client_node.router.override_create_blinded_payment_paths.lock().unwrap() = + Some(Box::new(move |recipient, local_node_receive_key, first_hops, tlvs, amount_msats| { + let secp_ctx = Secp256k1::new(); + lsps2_router_clone.create_blinded_payment_paths( + recipient, + local_node_receive_key, + first_hops, + tlvs, + amount_msats, + &secp_ctx, + ) + })); + + // --- Static invoice server setup --- + // The service needs the client as a peer for blinded path creation, since they don't share + // a channel yet. + service_node.inner.message_router.peers_override.lock().unwrap().push(client_node_id); + client_node.inner.message_router.peers_override.lock().unwrap().push(service_node_id); + + // The service node acts as the always-online static invoice server for the client. + let recipient_id = vec![42; 32]; + let inv_server_paths: Vec = service_node + .inner + .node + .blinded_paths_for_async_recipient(recipient_id.clone(), None) + .unwrap(); + client_node.inner.node.set_paths_to_static_invoice_server(inv_server_paths).unwrap(); + client_node + .inner + .node + .refresh_async_receive_offers_with_payment_metadata(payment_metadata) + .unwrap(); + + // Forward all OfferPathsRequest messages from client to service. + while let Some(msg) = + client_node.inner.onion_messenger.next_onion_message_for_peer(service_node_id) + { + service_node.inner.onion_messenger.handle_onion_message(client_node_id, &msg); + } + + // Service responds with OfferPaths. Forward all to the client. + while let Some(msg) = + service_node.inner.onion_messenger.next_onion_message_for_peer(client_node_id) + { + client_node.inner.onion_messenger.handle_onion_message(service_node_id, &msg); + } + + // Client constructs the static invoice and sends ServeStaticInvoice (plus possibly more + // OfferPathsRequests). Forward all messages from client to service. + while let Some(msg) = + client_node.inner.onion_messenger.next_onion_message_for_peer(service_node_id) + { + service_node.inner.onion_messenger.handle_onion_message(client_node_id, &msg); + } + + // Clear overrides — all blinded paths have been created. + service_node.inner.message_router.peers_override.lock().unwrap().clear(); + client_node.inner.message_router.peers_override.lock().unwrap().clear(); + + // Drain any remaining service → client messages (additional OfferPaths responses). + while let Some(msg) = + service_node.inner.onion_messenger.next_onion_message_for_peer(client_node_id) + { + client_node.inner.onion_messenger.handle_onion_message(service_node_id, &msg); + } + + // Service should have emitted at least one PersistStaticInvoice event. + let events = service_node.inner.node.get_and_clear_pending_events(); + assert!(!events.is_empty(), "Expected PersistStaticInvoice event(s), got none"); + let (static_invoice, invoice_request_path, ack_path) = events + .into_iter() + .find_map(|e| match e { + Event::PersistStaticInvoice { + invoice, + invoice_persisted_path, + invoice_request_path, + .. + } => Some((invoice, invoice_request_path, invoice_persisted_path)), + _ => None, + }) + .expect("Expected a PersistStaticInvoice event"); + + // Service calls static_invoice_persisted to acknowledge. + service_node.inner.node.static_invoice_persisted(ack_path); + while let Some(msg) = + service_node.inner.onion_messenger.next_onion_message_for_peer(client_node_id) + { + client_node.inner.onion_messenger.handle_onion_message(service_node_id, &msg); + } + + // Get the async receive offer from the client. + let offer = client_node.inner.node.get_async_receive_offer().unwrap(); + + // --- Payer initiates async payment --- + // The payer also needs an explicit peer for creating blinded reply paths. + payer_node.message_router.peers_override.lock().unwrap().push(service_node_id); + + let amt_msat = 100_000; + let payment_id = PaymentId([1; 32]); + payer_node.node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + + // InvoiceRequest: payer → client (the offer issuer). The client forwards it to the service + // (static invoice server) via the offer's blinded path. + let invreq_om = payer_node + .onion_messenger + .next_onion_message_for_peer(client_node_id) + .expect("Payer should send InvoiceRequest toward client"); + client_node.inner.onion_messenger.handle_onion_message(payer_node_id, &invreq_om); + + // Client forwards InvoiceRequest to service (static invoice server). + let invreq_fwd = client_node + .inner + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Client should forward InvoiceRequest to service"); + service_node.inner.onion_messenger.handle_onion_message(client_node_id, &invreq_fwd); + + // Service emits StaticInvoiceRequested — respond with the persisted static invoice. + let events = service_node.inner.node.get_and_clear_pending_events(); + assert!(!events.is_empty(), "Expected StaticInvoiceRequested event"); + let (reply_path, invoice_request) = events + .into_iter() + .find_map(|e| match e { + Event::StaticInvoiceRequested { reply_path, invoice_request, .. } => { + Some((reply_path, invoice_request)) + }, + _ => None, + }) + .expect("Expected StaticInvoiceRequested event"); + service_node + .inner + .node + .respond_to_static_invoice_request( + static_invoice, + reply_path, + invoice_request, + invoice_request_path, + ) + .unwrap(); + + // Service sends InvoiceRequest forward (to client) and StaticInvoice response (to payer). + // Drain service → client messages first. + while let Some(msg) = + service_node.inner.onion_messenger.next_onion_message_for_peer(client_node_id) + { + client_node.inner.onion_messenger.handle_onion_message(service_node_id, &msg); + } + let static_invoice_om = service_node + .inner + .onion_messenger + .next_onion_message_for_peer(payer_node_id) + .expect("Service should send StaticInvoice to payer"); + payer_node.onion_messenger.handle_onion_message(service_node_id, &static_invoice_om); + + // Sender should NOT lock in HTLCs yet — it waits for ReleaseHeldHtlc. + payer_node.node.process_pending_htlc_forwards(); + assert!(payer_node.node.get_and_clear_pending_msg_events().is_empty()); + + // HeldHtlcAvailable: payer → service → client. Simulate the client being offline when the + // service receives the message: disconnect client, let the service handle the payer's + // message (triggering OnionMessageIntercepted), then reconnect and forward. + let held_htlc_om = payer_node + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Payer should send HeldHtlcAvailable toward service"); + + service_node.inner.node.peer_disconnected(client_node_id); + client_node.inner.node.peer_disconnected(service_node_id); + service_node.inner.onion_messenger.peer_disconnected(client_node_id); + client_node.inner.onion_messenger.peer_disconnected(service_node_id); + + service_node.inner.onion_messenger.handle_onion_message(payer_node_id, &held_htlc_om); + + let events = core::cell::RefCell::new(Vec::new()); + service_node.inner.onion_messenger.process_pending_events(&|e| Ok(events.borrow_mut().push(e))); + let intercepted: Vec<_> = events + .into_inner() + .into_iter() + .filter_map(|e| match e { + Event::OnionMessageIntercepted { next_hop, message } => { + assert_eq!(next_hop, NextMessageHop::NodeId(client_node_id)); + Some(message) + }, + _ => None, + }) + .collect(); + assert!(!intercepted.is_empty(), "Expected OnionMessageIntercepted for HeldHtlcAvailable"); + + reconnect_nodes(ReconnectArgs::new(&service_node.inner, &client_node.inner)); + for message in intercepted { + service_node.inner.onion_messenger.forward_onion_message(message, &client_node_id).unwrap(); + } + while let Some(msg) = + service_node.inner.onion_messenger.next_onion_message_for_peer(client_node_id) + { + client_node.inner.onion_messenger.handle_onion_message(service_node_id, &msg); + } + + // ReleaseHeldHtlc: client → service → payer (reply path goes through service). + let release_om = client_node + .inner + .onion_messenger + .next_onion_message_for_peer(service_node_id) + .expect("Client should send ReleaseHeldHtlc toward service"); + service_node.inner.onion_messenger.handle_onion_message(client_node_id, &release_om); + + let release_fwd = service_node + .inner + .onion_messenger + .next_onion_message_for_peer(payer_node_id) + .expect("Service should forward ReleaseHeldHtlc to payer"); + payer_node.onion_messenger.handle_onion_message(service_node_id, &release_fwd); + + // --- Payer creates the HTLC --- + let mut events = payer_node.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = remove_first_msg_event_to_node(&service_node_id, &mut events); + check_added_monitors(&payer_node, 1); + + let send_ev = SendEvent::from_event(ev); + let payment_hash = send_ev.msgs[0].payment_hash; + service_node.inner.node.handle_update_add_htlc(payer_node_id, &send_ev.msgs[0]); + do_commitment_signed_dance( + &service_node.inner, + &payer_node, + &send_ev.commitment_msg, + false, + true, + ); + service_node.inner.node.process_pending_htlc_forwards(); + + // Service intercepts the HTLC on the LSPS2 intercept SCID. + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let expected_outbound_amount_msat = match &events[0] { + Event::HTLCIntercepted { + intercept_id, + requested_next_hop_scid, + payment_hash: ph, + expected_outbound_amount_msat, + .. + } => { + assert_eq!(*requested_next_hop_scid, intercept_scid); + assert_eq!(*ph, payment_hash); + service_handler + .htlc_intercepted( + *requested_next_hop_scid, + *intercept_id, + *expected_outbound_amount_msat, + *ph, + ) + .unwrap(); + expected_outbound_amount_msat + }, + other => panic!("Expected HTLCIntercepted event, got: {:?}", other), + }; + + // Service emits OpenChannel event for the JIT channel. + let open_channel_event = service_node.liquidity_manager.next_event().unwrap(); + match open_channel_event { + LiquidityEvent::LSPS2Service(LSPS2ServiceEvent::OpenChannel { + their_network_key, + user_channel_id: uc_id, + intercept_scid: iscd, + .. + }) => { + assert_eq!(their_network_key, client_node_id); + assert_eq!(uc_id, user_channel_id); + assert_eq!(iscd, intercept_scid); + }, + other => panic!("Expected OpenChannel event, got: {:?}", other), + }; + + let result = + service_handler.channel_needs_manual_broadcast(user_channel_id, &client_node_id).unwrap(); + assert!(result, "Channel should require manual broadcast"); + + // Open the JIT channel between service and client. + let (channel_id, _funding_tx) = create_channel_with_manual_broadcast( + &service_node_id, + &client_node_id, + &service_node, + &client_node, + user_channel_id, + expected_outbound_amount_msat, + true, + ); + + service_handler.channel_ready(user_channel_id, &channel_id, &client_node_id).unwrap(); + service_node.inner.node.process_pending_htlc_forwards(); + + // Service forwards the payment to the client through the new JIT channel. + let pay_event = { + { + let mut added_monitors = + service_node.inner.chain_monitor.added_monitors.lock().unwrap(); + assert_eq!(added_monitors.len(), 1); + added_monitors.clear(); + } + let mut events = service_node.inner.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + SendEvent::from_event(events.remove(0)) + }; + + client_node.inner.node.handle_update_add_htlc(service_node_id, &pay_event.msgs[0]); + do_commitment_signed_dance( + &client_node.inner, + &service_node.inner, + &pay_event.commitment_msg, + false, + true, + ); + client_node.inner.node.process_pending_htlc_forwards(); + + // Client receives the payment. + let client_events = client_node.inner.node.get_and_clear_pending_events(); + assert_eq!(client_events.len(), 1); + let preimage = match &client_events[0] { + Event::PaymentClaimable { payment_hash: ph, purpose, .. } => { + assert_eq!(*ph, payment_hash); + purpose.preimage() + }, + other => panic!("Expected PaymentClaimable event on client, got: {:?}", other), + }; + + // Client claims the payment. + client_node.inner.node.claim_funds(preimage.unwrap()); + + claim_and_assert_forwarded_only( + &payer_node, + &service_node.inner, + &client_node.inner, + preimage.unwrap(), + ); + + let service_events = service_node.node.get_and_clear_pending_events(); + assert_eq!(service_events.len(), 1); + match service_events[0].clone() { + Event::PaymentForwarded { prev_htlcs, next_htlcs, skimmed_fee_msat, .. } => { + assert_eq!(prev_htlcs[0].node_id, Some(payer_node_id)); + assert_eq!(next_htlcs[0].node_id, Some(client_node_id)); + service_handler.payment_forwarded(channel_id, skimmed_fee_msat.unwrap_or(0)).unwrap(); + }, + other => panic!("Expected PaymentForwarded event, got: {:?}", other), + }; + + expect_payment_sent(&payer_node, preimage.unwrap(), None, true, true); +} From 26da1dde77c27563121245ea5f506093f88b1323 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 9 Jun 2026 13:23:22 +0200 Subject: [PATCH 9/9] Add LSPS2 BOLT12 pending changelog entry Record the new LSPS2 BOLT12 routing support and its related compatibility note for the next release notes. Co-Authored-By: HAL 9000 --- pending_changelog/4463-LSPS2-BOLT12.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 pending_changelog/4463-LSPS2-BOLT12.txt diff --git a/pending_changelog/4463-LSPS2-BOLT12.txt b/pending_changelog/4463-LSPS2-BOLT12.txt new file mode 100644 index 00000000000..51cacb17995 --- /dev/null +++ b/pending_changelog/4463-LSPS2-BOLT12.txt @@ -0,0 +1,12 @@ +## Backwards Compatibility + +If you manually persist `Event::OnionMessageIntercepted` events and construct +your `OnionMessenger` via `OnionMessenger::new_with_offline_peer_interception` +with `intercept_for_unknown_scids` set to `true`, you may not be able to +downgrade to LDK v0.2 or prior: persisted events carrying the new +`NextMessageHop::ShortChannelId` next hop will fail to deserialize on the +older version. + +LDK does not persist `OnionMessageIntercepted` events itself. Users who do +not persist these events manually, or who leave `intercept_for_unknown_scids` +disabled, are unaffected.