diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index bb99d65e6b5..8e61297474c 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -2517,6 +2517,7 @@ mod tests { Arc::clone(&tx_broadcaster), None, None, + None, ) .unwrap(), ); diff --git a/lightning-liquidity/src/lsps2/event.rs b/lightning-liquidity/src/lsps2/event.rs index 502429b79ec..ba60150a9fc 100644 --- a/lightning-liquidity/src/lsps2/event.rs +++ b/lightning-liquidity/src/lsps2/event.rs @@ -49,7 +49,27 @@ pub enum LSPS2ClientEvent { /// When the invoice is paid, the LSP will open a channel with the previously agreed upon /// parameters to you. /// - /// **Note: ** This event will *not* be persisted across restarts. + /// ## BOLT11 + /// For BOLT11 invoices, use `intercept_scid` and `cltv_expiry_delta` in a route hint + /// pointing to the LSP (`counterparty_node_id`). + /// + /// ## BOLT12 + /// For BOLT12 invoices, the same parameters are used to construct blinded payment paths + /// through the LSP: + /// - `counterparty_node_id` is the introduction node (LSP) of the blinded payment path + /// - `intercept_scid` is used as `ForwardTlvs::short_channel_id` in the blinded path + /// - `cltv_expiry_delta` is used as `PaymentRelay::cltv_expiry_delta` in the blinded path + /// - Fee parameters should be set to zero (fees are taken via fee skimming in LSPS2) + /// + /// Use [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`] to construct + /// the blinded payment paths, and + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`] + /// to build the BOLT12 invoice with those paths. + /// + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`]: lightning::offers::flow::OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]: lightning::offers::flow::OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths + /// + /// **Note:** This event will *not* be persisted across restarts. InvoiceParametersReady { /// The identifier of the issued bLIP-52 / LSPS2 `buy` request, as returned by /// [`LSPS2ClientHandler::select_opening_params`]. @@ -59,10 +79,14 @@ pub enum LSPS2ClientEvent { /// [`LSPS2ClientHandler::select_opening_params`]: crate::lsps2::client::LSPS2ClientHandler::select_opening_params request_id: LSPSRequestId, /// The node id of the LSP. + /// + /// For BOLT12, this is used as the introduction node of the blinded payment path. counterparty_node_id: PublicKey, - /// The intercept short channel id to use in the route hint. + /// The intercept short channel id to use in the route hint (BOLT11) or as the + /// `ForwardTlvs::short_channel_id` in a blinded payment path (BOLT12). intercept_scid: u64, - /// The `cltv_expiry_delta` to use in the route hint. + /// The `cltv_expiry_delta` to use in the route hint (BOLT11) or as the + /// `PaymentRelay::cltv_expiry_delta` in a blinded payment path (BOLT12). cltv_expiry_delta: u32, /// The initial payment size you specified. payment_size_msat: Option, diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 4c688d39eef..2a3b0e1155e 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -45,6 +45,7 @@ use lightning::events::HTLCHandlingFailureType; use lightning::ln::channelmanager::{AChannelManager, FailureCode, InterceptId}; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::types::ChannelId; +use lightning::onion_message::messenger::OnionMessageInterceptor; use lightning::util::errors::APIError; use lightning::util::logger::Level; use lightning::util::ser::Writeable; @@ -631,17 +632,20 @@ impl PeerState { }); } - fn prune_expired_request_state(&mut self) { + fn prune_expired_request_state(&mut self) -> Vec { + let mut pruned_scids = Vec::new(); self.outbound_channels_by_intercept_scid.retain(|intercept_scid, entry| { if entry.is_prunable() { // We abort the flow, and prune any data kept. self.intercept_scid_by_channel_id.retain(|_, iscid| intercept_scid != iscid); self.intercept_scid_by_user_channel_id.retain(|_, iscid| intercept_scid != iscid); self.needs_persist |= true; + pruned_scids.push(*intercept_scid); return false; } true }); + pruned_scids } fn pending_requests_and_channels(&self) -> usize { @@ -717,6 +721,7 @@ where total_pending_requests: AtomicUsize, config: LSPS2ServiceConfig, persistence_in_flight: AtomicUsize, + onion_message_interceptor: Option>, } impl LSPS2ServiceHandler @@ -728,6 +733,7 @@ where per_peer_state: HashMap>, pending_messages: Arc, pending_events: Arc>, channel_manager: CM, kv_store: K, tx_broadcaster: T, config: LSPS2ServiceConfig, + onion_message_interceptor: Option>, ) -> Result { let mut peer_by_intercept_scid = new_hash_map(); let mut peer_by_channel_id = new_hash_map(); @@ -756,6 +762,14 @@ where } } + // Register all peers with active intercept SCIDs for onion message interception, + // so that messages for offline peers are held rather than dropped. + if let Some(ref interceptor) = onion_message_interceptor { + for node_id in peer_by_intercept_scid.values() { + interceptor.register_peer_for_interception(*node_id); + } + } + Ok(Self { pending_messages, pending_events, @@ -768,6 +782,7 @@ where kv_store, tx_broadcaster, config, + onion_message_interceptor, }) } @@ -776,6 +791,29 @@ where &self.config } + /// Cleans up `peer_by_intercept_scid` entries for the given SCIDs, and deregisters the peer + /// from onion message interception if they have no remaining active intercept SCIDs. + fn cleanup_intercept_scids( + &self, counterparty_node_id: &PublicKey, pruned_scids: &[u64], has_remaining_channels: bool, + ) { + if pruned_scids.is_empty() { + return; + } + + { + let mut peer_by_intercept_scid = self.peer_by_intercept_scid.write().unwrap(); + for scid in pruned_scids { + peer_by_intercept_scid.remove(scid); + } + } + + if !has_remaining_channels { + if let Some(ref interceptor) = self.onion_message_interceptor { + interceptor.deregister_peer_for_interception(counterparty_node_id); + } + } + } + /// Returns whether the peer has any active LSPS2 requests. pub(crate) fn has_active_requests(&self, counterparty_node_id: &PublicKey) -> bool { let outer_state_lock = self.per_peer_state.read().unwrap(); @@ -921,6 +959,10 @@ where peer_by_intercept_scid.insert(intercept_scid, *counterparty_node_id); } + if let Some(ref interceptor) = self.onion_message_interceptor { + interceptor.register_peer_for_interception(*counterparty_node_id); + } + let outbound_jit_channel = OutboundJITChannel::new( buy_request.payment_size_msat, buy_request.opening_fee_params, @@ -1051,7 +1093,15 @@ where peer_state .outbound_channels_by_intercept_scid .remove(&intercept_scid); - // TODO: cleanup peer_by_intercept_scid + let has_remaining = + !peer_state.outbound_channels_by_intercept_scid.is_empty(); + drop(peer_state); + drop(outer_state_lock); + self.cleanup_intercept_scids( + counterparty_node_id, + &[intercept_scid], + has_remaining, + ); return Err(APIError::APIMisuseError { err: e.err }); }, } @@ -1270,7 +1320,7 @@ where pub async fn channel_open_abandoned( &self, counterparty_node_id: &PublicKey, user_channel_id: u128, ) -> Result<(), APIError> { - { + let (intercept_scid, has_remaining) = { let outer_state_lock = self.per_peer_state.read().unwrap(); let inner_state_lock = outer_state_lock.get(counterparty_node_id).ok_or_else(|| { APIError::APIMisuseError { @@ -1317,7 +1367,11 @@ where peer_state.outbound_channels_by_intercept_scid.remove(&intercept_scid); peer_state.intercept_scid_by_channel_id.retain(|_, &mut scid| scid != intercept_scid); peer_state.needs_persist |= true; - } + let has_remaining = !peer_state.outbound_channels_by_intercept_scid.is_empty(); + (intercept_scid, has_remaining) + }; + + self.cleanup_intercept_scids(counterparty_node_id, &[intercept_scid], has_remaining); self.persist_peer_state(*counterparty_node_id).await.map_err(|e| { APIError::APIMisuseError { @@ -1801,10 +1855,16 @@ where { // First build a list of peers to persist and prune with the read lock. This allows // us to avoid the write lock unless we actually need to remove a node. + let mut all_pruned_scids = Vec::new(); let outer_state_lock = self.per_peer_state.read().unwrap(); for (counterparty_node_id, inner_state_lock) in outer_state_lock.iter() { let mut peer_state_lock = inner_state_lock.lock().unwrap(); - peer_state_lock.prune_expired_request_state(); + let pruned_scids = peer_state_lock.prune_expired_request_state(); + if !pruned_scids.is_empty() { + let has_remaining = + !peer_state_lock.outbound_channels_by_intercept_scid.is_empty(); + all_pruned_scids.push((*counterparty_node_id, pruned_scids, has_remaining)); + } let is_prunable = peer_state_lock.is_prunable(); if is_prunable { need_remove.push(*counterparty_node_id); @@ -1812,6 +1872,15 @@ where need_persist.push(*counterparty_node_id); } } + drop(outer_state_lock); + + for (counterparty_node_id, pruned_scids, has_remaining) in all_pruned_scids { + self.cleanup_intercept_scids( + &counterparty_node_id, + &pruned_scids, + has_remaining, + ); + } } for counterparty_node_id in need_persist.into_iter() { @@ -1822,6 +1891,7 @@ where for counterparty_node_id in need_remove { let mut future_opt = None; + let mut was_removed = false; { // We need to take the `per_peer_state` write lock to remove an entry, but also // have to hold it until after the `remove` call returns (but not through @@ -1833,6 +1903,7 @@ where let state = entry.get_mut().get_mut().unwrap(); if state.is_prunable() { entry.remove(); + was_removed = true; let key = counterparty_node_id.to_string(); future_opt = Some(self.kv_store.remove( LIQUIDITY_MANAGER_PERSISTENCE_PRIMARY_NAMESPACE, @@ -1850,6 +1921,20 @@ where debug_assert!(false); } } + if was_removed { + // Clean up handler-level maps for the removed peer. + self.peer_by_intercept_scid + .write() + .unwrap() + .retain(|_, node_id| *node_id != counterparty_node_id); + self.peer_by_channel_id + .write() + .unwrap() + .retain(|_, node_id| *node_id != counterparty_node_id); + if let Some(ref interceptor) = self.onion_message_interceptor { + interceptor.deregister_peer_for_interception(&counterparty_node_id); + } + } if let Some(future) = future_opt { future.await?; did_persist = true; @@ -1877,7 +1962,11 @@ where // We clean up the peer state, but leave removing the peer entry to the prune logic in // `persist` which removes it from the store. peer_state_lock.prune_pending_requests(); - peer_state_lock.prune_expired_request_state(); + let pruned_scids = peer_state_lock.prune_expired_request_state(); + let has_remaining = !peer_state_lock.outbound_channels_by_intercept_scid.is_empty(); + drop(peer_state_lock); + drop(outer_state_lock); + self.cleanup_intercept_scids(&counterparty_node_id, &pruned_scids, has_remaining); } } diff --git a/lightning-liquidity/src/manager.rs b/lightning-liquidity/src/manager.rs index 1f11fc8add7..0d71cb2f6bd 100644 --- a/lightning-liquidity/src/manager.rs +++ b/lightning-liquidity/src/manager.rs @@ -48,6 +48,7 @@ use lightning::ln::channelmanager::{AChannelManager, ChainParameters}; use lightning::ln::msgs::{ErrorAction, LightningError}; use lightning::ln::peer_handler::CustomMessageHandler; use lightning::ln::wire::CustomMessageReader; +use lightning::onion_message::messenger::OnionMessageInterceptor; use lightning::sign::{EntropySource, NodeSigner}; use lightning::util::logger::Level; use lightning::util::persist::{KVStore, KVStoreSync, KVStoreSyncWrapper}; @@ -330,6 +331,7 @@ where chain_params: Option, kv_store: K, transaction_broadcaster: T, service_config: Option, client_config: Option, + onion_message_interceptor: Option>, ) -> Result { Self::new_with_custom_time_provider( entropy_source, @@ -342,6 +344,7 @@ where service_config, client_config, DefaultTimeProvider, + onion_message_interceptor, ) .await } @@ -373,6 +376,7 @@ where chain_source: Option, chain_params: Option, kv_store: K, service_config: Option, client_config: Option, time_provider: TP, + onion_message_interceptor: Option>, ) -> Result { let pending_msgs_or_needs_persist_notifier = Arc::new(Notifier::new()); let pending_messages = @@ -415,6 +419,7 @@ where kv_store.clone(), transaction_broadcaster.clone(), lsps2_service_config.clone(), + onion_message_interceptor.clone(), )?) } else { None @@ -1044,6 +1049,7 @@ where chain_params: Option, kv_store_sync: KS, transaction_broadcaster: T, service_config: Option, client_config: Option, + onion_message_interceptor: Option>, ) -> Result { let kv_store = KVStoreSyncWrapper(kv_store_sync); @@ -1057,6 +1063,7 @@ where transaction_broadcaster, service_config, client_config, + onion_message_interceptor, )); let mut waker = dummy_waker(); @@ -1094,6 +1101,7 @@ where chain_params: Option, kv_store_sync: KS, transaction_broadcaster: T, service_config: Option, client_config: Option, time_provider: TP, + onion_message_interceptor: Option>, ) -> Result { let kv_store = KVStoreSyncWrapper(kv_store_sync); let mut fut = pin!(LiquidityManager::new_with_custom_time_provider( @@ -1107,6 +1115,7 @@ where service_config, client_config, time_provider, + onion_message_interceptor, )); let mut waker = dummy_waker(); diff --git a/lightning-liquidity/tests/common/mod.rs b/lightning-liquidity/tests/common/mod.rs index dea987527ad..7bd3adf7043 100644 --- a/lightning-liquidity/tests/common/mod.rs +++ b/lightning-liquidity/tests/common/mod.rs @@ -47,6 +47,7 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( Some(service_config), None, Arc::clone(&time_provider), + None, ) .unwrap(); @@ -61,6 +62,7 @@ fn build_service_and_client_nodes<'a, 'b, 'c>( None, Some(client_config), time_provider, + None, ) .unwrap(); diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 312199e19ec..61dfb456996 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -7,6 +7,7 @@ use common::{ get_lsps_message, LSPSNodes, LSPSNodesWithPayer, LiquidityNode, }; +use lightning::blinded_path::IntroductionNode; use lightning::events::{ClosureReason, Event}; use lightning::get_event_msg; use lightning::ln::channelmanager::{OptionalBolt11PaymentParams, PaymentId}; @@ -14,6 +15,7 @@ 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_liquidity::events::LiquidityEvent; @@ -1089,6 +1091,7 @@ fn lsps2_service_handler_persistence_across_restarts() { Some(service_config), None, time_provider, + None, ) .unwrap(); @@ -1999,6 +2002,304 @@ fn htlc_timeout_before_client_claim_results_in_handling_failed() { payer_node.chain_monitor.added_monitors.lock().unwrap().clear(); } +#[test] +fn bolt12_lsps2_end_to_end_test() { + // End-to-end test of the BOLT12 + LSPS2 JIT channel flow. Three nodes: payer, service, client. + // client_trusts_lsp=true; funding transaction broadcast happens after client claims the HTLC. + // + // 1. Create a channel between payer and service + // 2. Do the LSPS2 ceremony between client and service to get an intercept SCID + // 3. Client creates a BOLT12 offer (with manually_handle_bolt12_invoice_requests = true) + // 4. Payer pays for the offer (sends InvoiceRequest via onion message) + // 5. Client receives InvoiceRequestReceived event and creates a BOLT12 invoice with blinded + // payment paths through the service's intercept SCID + // 6. Payer receives the invoice and sends payment through the blinded path + // 7. Service intercepts the HTLC and creates a JIT channel to the client + // 8. Service forwards the HTLC to the client via the JIT channel + // 9. Client claims the payment, service broadcasts the funding tx + 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.manually_accept_inbound_channels = true; + client_node_config.channel_config.accept_underpaying_htlcs = true; + client_node_config.manually_handle_bolt12_invoice_requests = 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 between payer and service + create_chan_between_nodes_with_value(&payer_node, &service_node.inner, 2_000_000, 100_000); + + // LSPS2 ceremony: client negotiates JIT channel parameters with service + let intercept_scid = service_node.node.get_intercept_scid(); + let user_channel_id = 42; + let cltv_expiry_delta: u32 = 144; + let payment_size_msat = Some(1_000_000); + let fee_base_msat = 1000; + + execute_lsps2_dance( + &lsps_nodes, + intercept_scid, + user_channel_id, + cltv_expiry_delta, + promise_secret, + payment_size_msat, + fee_base_msat, + ); + + // Disconnect payer from client to ensure deterministic onion message routing through service. + // This guarantees that both the offer's blinded message paths and the payer's reply paths + // route through the service node. + 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); + + // Client creates a BOLT12 offer. Since client's only remaining peer is service, + // the blinded message paths will use service as the introduction node. + let offer = client_node + .node + .create_offer_builder() + .unwrap() + .amount_msats(payment_size_msat.unwrap()) + .build() + .unwrap(); + + // Payer initiates payment for the offer + let payment_id = PaymentId([1; 32]); + payer_node.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + + // Route InvoiceRequest: payer -> service -> client + 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); + + // Client should NOT auto-create an invoice (manually_handle_bolt12_invoice_requests = true) + assert!(client_node.onion_messenger.next_onion_message_for_peer(service_node_id).is_none()); + + // Client receives InvoiceRequestReceived event + let mut events = client_node.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + + let (invoice_request, context, responder) = match events.pop().unwrap() { + Event::InvoiceRequestReceived { invoice_request, context, responder } => { + (invoice_request, context, responder) + }, + other => panic!("Expected Event::InvoiceRequestReceived, got: {:?}", other), + }; + + // Client creates a BOLT12 invoice with blinded payment paths through the service's + // intercept SCID, simulating the LSPS2 JIT channel flow. + let invoice = client_node + .node + .send_bolt12_invoice_for_intercept_scid( + invoice_request, + context, + responder, + service_node_id, + intercept_scid, + cltv_expiry_delta as u16, + 3600, + ) + .unwrap(); + + // Verify the invoice has the correct structure: blinded payment paths with service + // as the introduction node (the LSP that will open the JIT channel). + assert_eq!(invoice.amount_msats(), payment_size_msat.unwrap()); + assert!(!invoice.payment_paths().is_empty()); + for path in invoice.payment_paths() { + assert_eq!(path.introduction_node(), &IntroductionNode::NodeId(service_node_id)); + } + + // Route Invoice: client -> service -> 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 processes the invoice and starts the payment automatically + 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()); + + // Payment goes to service via the blinded payment path (through 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(); + + // Service intercepts the HTLC (destined for intercept SCID) + let events = service_node.inner.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let (payment_hash, expected_outbound_amount_msat) = match &events[0] { + Event::HTLCIntercepted { + intercept_id, + requested_next_hop_scid, + payment_hash, + expected_outbound_amount_msat, + .. + } => { + assert_eq!(*requested_next_hop_scid, intercept_scid); + + service_handler + .htlc_intercepted( + *requested_next_hop_scid, + *intercept_id, + *expected_outbound_amount_msat, + *payment_hash, + ) + .unwrap(); + (*payment_hash, expected_outbound_amount_msat) + }, + other => panic!("Expected HTLCIntercepted event, got: {:?}", other), + }; + + // Service emits OpenChannel event with the correct fee deduction + 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), + }; + + // Create JIT channel with manual broadcast (client_trusts_lsp = true) + let result = + service_handler.channel_needs_manual_broadcast(user_channel_id, &client_node_id).unwrap(); + assert!(result, "Channel should require manual broadcast"); + + let (channel_id, funding_tx) = create_channel_with_manual_broadcast( + &service_node_id, + &client_node_id, + &service_node, + &client_node, + user_channel_id, + expected_outbound_amount_msat, + true, + ); + + // Service marks channel as ready and forwards the intercepted HTLC + service_handler.channel_ready(user_channel_id, &channel_id, &client_node_id).unwrap(); + service_node.inner.node.process_pending_htlc_forwards(); + + // Forward the HTLC to client on the 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 sees PaymentClaimable + 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), + }; + + // Before client claims, service should not have broadcasted the funding tx + 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 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(), + ); + + // Service should emit PaymentForwarded + 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_node_id, + next_node_id, + skimmed_fee_msat, + total_fee_earned_msat, + .. + } => { + assert_eq!(prev_node_id, Some(payer_node_id)); + assert_eq!(next_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]), + }; + + // Service should have broadcasted the funding tx after client claimed + let broadcasted = service_node.inner.tx_broadcaster.txn_broadcasted.lock().unwrap(); + assert!(broadcasted.iter().any(|b| b.compute_txid() == funding_tx.compute_txid())); + + // Payer should have the PaymentSent event + expect_payment_sent(&payer_node, preimage.unwrap(), Some(total_fee_msat), true, true); +} + fn claim_and_assert_forwarded_only<'a, 'b, 'c>( payer_node: &lightning::ln::functional_test_utils::Node<'a, 'b, 'c>, service_node: &lightning::ln::functional_test_utils::Node<'a, 'b, 'c>, diff --git a/lightning-liquidity/tests/lsps5_integration_tests.rs b/lightning-liquidity/tests/lsps5_integration_tests.rs index 16f20fd095f..623bf42a88f 100644 --- a/lightning-liquidity/tests/lsps5_integration_tests.rs +++ b/lightning-liquidity/tests/lsps5_integration_tests.rs @@ -1618,6 +1618,7 @@ fn lsps5_service_handler_persistence_across_restarts() { Some(service_config), None, Arc::clone(&time_provider), + None, ) .unwrap(); diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 3d860e9f363..8b4d533a5fa 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -1827,6 +1827,38 @@ pub enum Event { /// [`ChannelManager::respond_to_static_invoice_request`]: crate::ln::channelmanager::ChannelManager::respond_to_static_invoice_request invoice_request: InvoiceRequest, }, + /// We received a [`Bolt12InvoiceRequest`] and the user has opted to manually handle it via + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]. + /// + /// The user should construct a [`Bolt12Invoice`] (e.g., using custom blinded payment paths + /// for LSPS2 JIT channels via + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`] and + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]) + /// and send it back via [`ChannelManager::send_invoice_for_request`]. + /// + /// # Failure Behavior and Persistence + /// This event will eventually be replayed after failures-to-handle (i.e., the event handler + /// returning `Err(ReplayEvent ())`), but won't be persisted across restarts. + /// + /// [`Bolt12InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]: crate::util::config::UserConfig::manually_handle_bolt12_invoice_requests + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`]: crate::offers::flow::OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]: crate::offers::flow::OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths + /// [`ChannelManager::send_invoice_for_request`]: crate::ln::channelmanager::ChannelManager::send_invoice_for_request + InvoiceRequestReceived { + /// The invoice request received from the payer. + invoice_request: InvoiceRequest, + /// The context from the incoming onion message, needed for verification via + /// [`OffersMessageFlow::verify_invoice_request`]. + /// + /// [`OffersMessageFlow::verify_invoice_request`]: crate::offers::flow::OffersMessageFlow::verify_invoice_request + context: Option, + /// Used to send the [`Bolt12Invoice`] response back to the payer. + /// + /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice + responder: Responder, + }, /// Indicates that a channel funding transaction constructed interactively is ready to be /// signed. This event will only be triggered if at least one input was contributed. /// @@ -2317,6 +2349,10 @@ impl Writeable for Event { 47u8.write(writer)?; // Never write StaticInvoiceRequested events as buffered onion messages aren't serialized. }, + &Event::InvoiceRequestReceived { .. } => { + 51u8.write(writer)?; + // Never write InvoiceRequestReceived events as the responder isn't serialized. + }, &Event::FundingTransactionReadyForSigning { .. } => { 49u8.write(writer)?; // We never write out FundingTransactionReadyForSigning events as they will be regenerated when @@ -2948,6 +2984,8 @@ impl MaybeReadable for Event { 47u8 => Ok(None), // Note that we do not write a length-prefixed TLV for FundingTransactionReadyForSigning events. 49u8 => Ok(None), + // Note that we do not write a length-prefixed TLV for InvoiceRequestReceived events. + 51u8 => Ok(None), 50u8 => { let mut f = || { _init_and_read_len_prefixed_tlv_fields!(reader, { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 665a79a9610..2efdc269d31 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -32,6 +32,7 @@ use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; use bitcoin::{secp256k1, Sequence, SignedAmount}; +use crate::blinded_path::message::MessageContext; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, MessageForwardNode, OffersContext, }; @@ -97,7 +98,9 @@ use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow}; use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use crate::offers::invoice_error::InvoiceError; -use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromOffer}; +use crate::offers::invoice_request::{ + InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer, +}; use crate::offers::nonce::Nonce; use crate::offers::offer::{Offer, OfferFromHrn}; use crate::offers::parse::Bolt12SemanticError; @@ -5579,6 +5582,116 @@ impl< self.flow.enqueue_static_invoice(invoice, responder) } + /// Sends a [`Bolt12Invoice`] in response to an [`Event::InvoiceRequestReceived`]. + /// + /// This is used when [`UserConfig::manually_handle_bolt12_invoice_requests`] is set to `true`, + /// allowing the user to construct a custom invoice (e.g., with blinded payment paths through + /// an LSP's intercept SCID for LSPS2 JIT channels) and send it back to the payer. + /// + /// The `responder` and `context` should come from the [`Event::InvoiceRequestReceived`] + /// event and the invoice builder (e.g., via + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]), + /// respectively. + /// + /// [`Event::InvoiceRequestReceived`]: crate::events::Event::InvoiceRequestReceived + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]: crate::util::config::UserConfig::manually_handle_bolt12_invoice_requests + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]: crate::offers::flow::OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths + pub fn send_invoice_for_request( + &self, invoice: Bolt12Invoice, context: MessageContext, responder: Responder, + ) { + self.flow.enqueue_invoice_for_request(invoice, context, responder); + } + + /// Creates and sends a BOLT12 invoice in response to an [`Event::InvoiceRequestReceived`], + /// allowing to construct blinded payment paths for a specific [`InterceptId`] through an LSP. + /// + /// This is a convenience method that combines the steps of verifying the invoice request, + /// creating an inbound payment, building blinded payment paths with the given intercept SCID, + /// constructing the invoice, and sending it back via the responder. + /// + /// Returns the [`Bolt12Invoice`] that was sent on success, or a [`Bolt12SemanticError`] on + /// failure. + /// + /// [`Event::InvoiceRequestReceived`]: crate::events::Event::InvoiceRequestReceived + pub fn send_bolt12_invoice_for_intercept_scid( + &self, invoice_request: InvoiceRequest, context: Option, + responder: Responder, lsp_node_id: PublicKey, intercept_scid: u64, cltv_expiry_delta: u16, + invoice_expiry_secs: u32, + ) -> Result { + let verified = self + .flow + .verify_invoice_request(invoice_request, context) + .map_err(|_| Bolt12SemanticError::InvalidMetadata)?; + + let invoice_request = match verified { + InvreqResponseInstructions::SendInvoice( + InvoiceRequestVerifiedFromOffer::DerivedKeys(request), + ) => request, + _ => return Err(Bolt12SemanticError::InvalidMetadata), + }; + + let amount_msats = + invoice_request.amount_msats().ok_or(Bolt12SemanticError::MissingAmount)?; + + let (payment_hash, payment_secret) = self + .create_inbound_payment(Some(amount_msats), invoice_expiry_secs, None) + .map_err(|_| Bolt12SemanticError::InvalidAmount)?; + + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: invoice_request.offer_id, + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: invoice_request.quantity(), + payer_note_truncated: invoice_request + .payer_note() + .map(|note| UntrustedString(note.to_string())), + human_readable_name: invoice_request.offer_from_hrn().clone(), + }, + }); + + let payment_paths = self + .flow + .create_blinded_payment_paths_for_intercept_scid( + &self.entropy_source, + lsp_node_id, + intercept_scid, + cltv_expiry_delta, + payment_secret, + payment_context, + amount_msats, + invoice_expiry_secs, + ) + .map_err(|_| Bolt12SemanticError::MissingPaths)?; + + let (builder, reply_context) = + self.flow.create_invoice_builder_from_invoice_request_with_custom_payment_paths( + &invoice_request, + payment_paths, + payment_hash, + )?; + + let invoice = builder.build_and_sign(&self.secp_ctx)?; + + self.flow.enqueue_invoice_for_request(invoice.clone(), reply_context, responder); + + Ok(invoice) + } + + /// Returns a reference to the [`OffersMessageFlow`], which provides methods for creating + /// BOLT12 offers, invoices, and related blinded paths. + /// + /// This is useful when manually handling invoice requests (see + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]) to access methods like + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`] and + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]. + /// + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]: crate::util::config::UserConfig::manually_handle_bolt12_invoice_requests + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`]: crate::offers::flow::OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]: crate::offers::flow::OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths + pub fn offers_handler(&self) -> &OffersMessageFlow { + &self.flow + } + fn initiate_async_payment( &self, invoice: &StaticInvoice, payment_id: PaymentId, ) -> Result<(), Bolt12PaymentError> { @@ -15992,6 +16105,9 @@ impl< None => return None, }; + let manually_handle = self.config.read().unwrap().manually_handle_bolt12_invoice_requests; + let saved_context = if manually_handle { context.clone() } else { None }; + let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request, Ok(InvreqResponseInstructions::SendStaticInvoice { recipient_id, invoice_slot, invoice_request }) => { @@ -16004,6 +16120,22 @@ impl< Err(_) => return None, }; + if manually_handle { + let inner_request = match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(ref request) => request.inner.clone(), + InvoiceRequestVerifiedFromOffer::ExplicitKeys(ref request) => request.inner.clone(), + }; + self.pending_events.lock().unwrap().push_back(( + Event::InvoiceRequestReceived { + invoice_request: inner_request, + context: saved_context, + responder, + }, + None, + )); + return None; + } + let get_payment_info = |amount_msats, relative_expiry| { self.create_inbound_payment( Some(amount_msats), diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 12e631b4042..646a8dd5b95 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2572,3 +2572,88 @@ fn no_double_pay_with_stale_channelmanager() { // generated in response to the duplicate invoice. assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); } + +/// Checks that when `manually_handle_bolt12_invoice_requests` is enabled, an +/// `Event::InvoiceRequestReceived` is emitted instead of automatically creating an invoice, and +/// that `send_bolt12_invoice_for_intercept_scid` can be used to create and send a BOLT12 invoice +/// with blinded payment paths using an intercept SCID (as used in LSPS2 JIT channels). +#[test] +fn creates_bolt12_invoice_with_intercept_scid_blinded_paths() { + let mut manually_handle_cfg = test_default_channel_config(); + manually_handle_cfg.manually_handle_bolt12_invoice_requests = true; + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[Some(manually_handle_cfg), None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; + let alice_id = alice.node.get_our_node_id(); + let bob = &nodes[1]; + let bob_id = bob.node.get_our_node_id(); + + // Alice creates an offer with manual invoice request handling enabled. + let offer = alice.node + .create_offer_builder().unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + // Bob pays for the offer, which sends an InvoiceRequest to Alice. + let payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); + + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + + // Since manually_handle_bolt12_invoice_requests is true, Alice should NOT auto-create an + // invoice. + assert!(alice.onion_messenger.next_onion_message_for_peer(bob_id).is_none()); + + // Instead, Alice should get an InvoiceRequestReceived event. + let mut events = alice.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + + let (invoice_request, context, responder) = match events.pop().unwrap() { + Event::InvoiceRequestReceived { invoice_request, context, responder } => { + (invoice_request, context, responder) + }, + _ => panic!("Expected Event::InvoiceRequestReceived"), + }; + + // Alice creates a BOLT12 invoice with blinded payment paths using an intercept SCID, + // simulating the LSPS2 JIT channel flow where Bob acts as the LSP introduction node. + let intercept_scid = 42u64; + let cltv_expiry_delta = 144u16; + let invoice_expiry_secs = 3600u32; + + let invoice = alice.node.send_bolt12_invoice_for_intercept_scid( + invoice_request, + context, + responder, + bob_id, + intercept_scid, + cltv_expiry_delta, + invoice_expiry_secs, + ).unwrap(); + + // Verify the invoice has the correct structure. + assert_eq!(invoice.amount_msats(), 10_000_000); + assert!(!invoice.payment_paths().is_empty()); + for path in invoice.payment_paths() { + assert_eq!(path.introduction_node(), &IntroductionNode::NodeId(bob_id)); + } + + // Verify the invoice is sent back to Bob as an onion message. + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + + // Extract and verify the invoice Bob would receive. + let (received_invoice, _) = extract_invoice(bob, &onion_message); + assert_eq!(received_invoice.amount_msats(), 10_000_000); + assert!(!received_invoice.payment_paths().is_empty()); + for path in received_invoice.payment_paths() { + assert_eq!(path.introduction_node(), &IntroductionNode::NodeId(bob_id)); + } +} diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 0bb98777227..e683ab8e202 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -22,7 +22,7 @@ use crate::blinded_path::message::{ }; use crate::blinded_path::payment::{ AsyncBolt12OfferContext, BlindedPaymentPath, Bolt12OfferContext, Bolt12RefundContext, - PaymentConstraints, PaymentContext, ReceiveTlvs, + ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, }; use crate::chain::channelmonitor::LATENCY_GRACE_PERIOD_BLOCKS; @@ -31,7 +31,9 @@ use crate::prelude::*; use crate::chain::BestBlock; use crate::ln::channel_state::ChannelDetails; -use crate::ln::channelmanager::{InterceptId, PaymentId, CLTV_FAR_FAR_AWAY}; +use crate::ln::channelmanager::{ + InterceptId, PaymentId, CLTV_FAR_FAR_AWAY, MIN_FINAL_CLTV_EXPIRY_DELTA, +}; use crate::ln::inbound_payment; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::invoice::{ @@ -58,6 +60,7 @@ use crate::onion_message::packet::OnionMessageContents; use crate::routing::router::Router; use crate::sign::{EntropySource, ReceiveAuthKey}; use crate::sync::{Mutex, RwLock}; +use crate::types::features::BlindedHopFeatures; use crate::types::payment::{PaymentHash, PaymentSecret}; use crate::util::logger::Logger; use crate::util::ser::Writeable; @@ -337,6 +340,79 @@ impl OffersMessageFlow { ) } + /// Creates a [`BlindedPaymentPath`] that goes through an LSP's intercept SCID. + /// + /// This is intended for use with LSPS2 (JIT channels) and BOLT12 invoices. The resulting + /// blinded payment path has the LSP as the introduction node, with the `intercept_scid` + /// encoded in the [`ForwardTlvs`] so that the LSP can intercept the HTLC and open a JIT + /// channel to the client. + /// + /// Fee parameters in the blinded path are set to zero since LSPS2 takes fees via fee + /// skimming rather than through relay fees. + /// + /// The caller is expected to obtain `payment_secret` from + /// [`ChannelManager::create_inbound_payment`] and pass the corresponding `payment_hash` + /// when building the invoice via + /// [`Self::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]. + /// + /// [`ChannelManager::create_inbound_payment`]: crate::ln::channelmanager::ChannelManager::create_inbound_payment + pub fn create_blinded_payment_paths_for_intercept_scid( + &self, entropy_source: ES, lsp_node_id: PublicKey, intercept_scid: u64, + cltv_expiry_delta: u16, payment_secret: PaymentSecret, payment_context: PaymentContext, + amount_msats: u64, relative_expiry_seconds: u32, + ) -> Result, ()> { + let secp_ctx = &self.secp_ctx; + let receive_auth_key = self.receive_auth_key; + let payee_node_id = self.get_our_node_id(); + + // Assume shorter than usual block times to avoid spuriously failing payments too early. + const SECONDS_PER_BLOCK: u32 = 9 * 60; + let relative_expiry_blocks = relative_expiry_seconds / SECONDS_PER_BLOCK; + let max_cltv_expiry = core::cmp::max(relative_expiry_blocks, CLTV_FAR_FAR_AWAY) + .saturating_add(LATENCY_GRACE_PERIOD_BLOCKS) + .saturating_add(self.best_block.read().unwrap().height); + + let payee_tlvs = ReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { max_cltv_expiry, htlc_minimum_msat: 1 }, + payment_context, + }; + + // Build the forwarding node representing the LSP. The payment constraints for the + // forwarding hop extend the max CLTV expiry by the hop's delta. + let forward_node = PaymentForwardNode { + tlvs: ForwardTlvs { + short_channel_id: intercept_scid, + payment_relay: PaymentRelay { + cltv_expiry_delta, + fee_base_msat: 0, + fee_proportional_millionths: 0, + }, + payment_constraints: PaymentConstraints { + max_cltv_expiry: max_cltv_expiry.saturating_add(cltv_expiry_delta as u32), + htlc_minimum_msat: 0, + }, + features: BlindedHopFeatures::empty(), + next_blinding_override: None, + }, + node_id: lsp_node_id, + htlc_maximum_msat: amount_msats, + }; + + let path = BlindedPaymentPath::new( + &[forward_node], + payee_node_id, + receive_auth_key, + payee_tlvs, + amount_msats, + MIN_FINAL_CLTV_EXPIRY_DELTA, + entropy_source, + secp_ctx, + )?; + + Ok(vec![path]) + } + #[cfg(test)] /// Creates multi-hop blinded payment paths for the given `amount_msats` by delegating to /// [`Router::create_blinded_payment_paths`]. @@ -1032,6 +1108,43 @@ impl OffersMessageFlow { Ok((builder, context)) } + /// Creates an [`InvoiceBuilder`] for the provided + /// [`VerifiedInvoiceRequest`] using pre-built blinded payment paths. + /// + /// This is intended for use with LSPS2 (JIT channels) where the blinded payment paths are + /// constructed via [`Self::create_blinded_payment_paths_for_intercept_scid`] rather than + /// through the [`Router`]. + /// + /// The caller is expected to: + /// 1. Call [`ChannelManager::create_inbound_payment`] to obtain both a `payment_hash` and + /// `payment_secret`. + /// 2. Use the `payment_secret` when constructing blinded payment paths via + /// [`Self::create_blinded_payment_paths_for_intercept_scid`]. + /// 3. Pass the resulting `payment_paths` and `payment_hash` to this method. + /// + /// Returns the invoice builder along with a [`MessageContext`] that can later be used to + /// respond to the counterparty. + /// + /// [`ChannelManager::create_inbound_payment`]: crate::ln::channelmanager::ChannelManager::create_inbound_payment + pub fn create_invoice_builder_from_invoice_request_with_custom_payment_paths<'a>( + &self, invoice_request: &'a VerifiedInvoiceRequest, + payment_paths: Vec, payment_hash: PaymentHash, + ) -> Result<(InvoiceBuilder<'a, DerivedSigningPubkey>, MessageContext), Bolt12SemanticError> { + #[cfg(feature = "std")] + let builder = invoice_request.respond_using_derived_keys(payment_paths, payment_hash); + #[cfg(not(feature = "std"))] + let builder = invoice_request.respond_using_derived_keys_no_std( + payment_paths, + payment_hash, + Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), + ); + let builder = builder.map(|b| InvoiceBuilder::from(b).allow_mpp())?; + + let context = MessageContext::Offers(OffersContext::InboundPayment { payment_hash }); + + Ok((builder, context)) + } + /// Enqueues the created [`InvoiceRequest`] to be sent to the counterparty. /// /// # Payment @@ -1183,6 +1296,30 @@ impl OffersMessageFlow { pending_offers_messages.push((message, instructions)); } + /// Enqueues a [`Bolt12Invoice`] to be sent back to a payer in response to an + /// [`InvoiceRequest`] that was manually handled. + /// + /// This is used when [`UserConfig::manually_handle_bolt12_invoice_requests`] is set to `true`, + /// typically for LSPS2 JIT channels where custom blinded payment paths are needed. + /// + /// The `context` should be obtained from + /// [`Self::create_invoice_builder_from_invoice_request_with_custom_payment_paths`], and the + /// `responder` should come from the [`Event::InvoiceRequestReceived`] event. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`UserConfig::manually_handle_bolt12_invoice_requests`]: crate::util::config::UserConfig::manually_handle_bolt12_invoice_requests + /// [`Event::InvoiceRequestReceived`]: crate::events::Event::InvoiceRequestReceived + pub fn enqueue_invoice_for_request( + &self, invoice: Bolt12Invoice, context: MessageContext, responder: Responder, + ) { + let message = OffersMessage::Invoice(invoice); + let instructions = responder.respond_with_reply_path(context); + self.pending_offers_messages + .lock() + .unwrap() + .push((message, instructions.into_instructions())); + } + /// Enqueues `held_htlc_available` onion messages to be sent to the payee via the reply paths /// contained within the provided [`StaticInvoice`]. /// diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index e688c020ac6..ca68e209abb 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -125,6 +125,54 @@ impl< } } +/// A trait for registering specific peers for onion message interception. +/// +/// When a peer is registered for interception and is currently offline, any onion messages +/// intended to be forwarded to them will generate an [`Event::OnionMessageIntercepted`] instead +/// of being dropped. When a registered peer connects, an [`Event::OnionMessagePeerConnected`] +/// will be generated. +/// +/// [`OnionMessenger`] implements this trait, but it is also useful as a trait object to allow +/// external components (e.g., an LSPS2 service) to register peers for interception without +/// needing to know the concrete [`OnionMessenger`] type. +/// +/// [`Event::OnionMessageIntercepted`]: crate::events::Event::OnionMessageIntercepted +/// [`Event::OnionMessagePeerConnected`]: crate::events::Event::OnionMessagePeerConnected +pub trait OnionMessageInterceptor { + /// Registers a peer for onion message interception. + /// + /// See [`OnionMessenger::register_peer_for_interception`] for more details. + fn register_peer_for_interception(&self, peer_node_id: PublicKey); + + /// Deregisters a peer from onion message interception. + /// + /// See [`OnionMessenger::deregister_peer_for_interception`] for more details. + /// + /// Returns whether the peer was previously registered. + fn deregister_peer_for_interception(&self, peer_node_id: &PublicKey) -> bool; +} + +impl< + ES: EntropySource, + NS: NodeSigner, + L: Logger, + NL: NodeIdLookUp, + MR: MessageRouter, + OMH: OffersMessageHandler, + APH: AsyncPaymentsMessageHandler, + DRH: DNSResolverMessageHandler, + CMH: CustomOnionMessageHandler, + > OnionMessageInterceptor for OnionMessenger +{ + fn register_peer_for_interception(&self, peer_node_id: PublicKey) { + OnionMessenger::register_peer_for_interception(self, peer_node_id) + } + + fn deregister_peer_for_interception(&self, peer_node_id: &PublicKey) -> bool { + OnionMessenger::deregister_peer_for_interception(self, peer_node_id) + } +} + /// A sender, receiver and forwarder of [`OnionMessage`]s. /// /// # Handling Messages @@ -273,6 +321,7 @@ pub struct OnionMessenger< dns_resolver_handler: DRH, custom_handler: CMH, intercept_messages_for_offline_peers: bool, + peers_registered_for_interception: Mutex>, pending_intercepted_msgs_events: Mutex>, pending_peer_connected_events: Mutex>, pending_events_processor: AtomicBool, @@ -1449,6 +1498,7 @@ impl< dns_resolver_handler: dns_resolver, custom_handler, intercept_messages_for_offline_peers, + peers_registered_for_interception: Mutex::new(new_hash_set()), pending_intercepted_msgs_events: Mutex::new(Vec::new()), pending_peer_connected_events: Mutex::new(Vec::new()), pending_events_processor: AtomicBool::new(false), @@ -1466,6 +1516,37 @@ impl< self.async_payments_handler = async_payments_handler; } + /// Registers a peer for onion message interception. + /// + /// When an onion message needs to be forwarded to a registered peer that is currently offline, + /// an [`Event::OnionMessageIntercepted`] will be generated, allowing the message to be stored + /// and forwarded later when the peer reconnects. + /// + /// Similarly, when a registered peer connects, an [`Event::OnionMessagePeerConnected`] will + /// be generated. + /// + /// This is useful for services like LSPS2 that need to intercept onion messages for specific + /// peers (e.g., those with active JIT channel sessions) without enabling blanket interception + /// for all offline peers via [`Self::new_with_offline_peer_interception`]. + /// + /// Use [`Self::deregister_peer_for_interception`] to stop intercepting messages for this peer. + /// + /// [`Event::OnionMessageIntercepted`]: crate::events::Event::OnionMessageIntercepted + /// [`Event::OnionMessagePeerConnected`]: crate::events::Event::OnionMessagePeerConnected + pub fn register_peer_for_interception(&self, peer_node_id: PublicKey) { + self.peers_registered_for_interception.lock().unwrap().insert(peer_node_id); + } + + /// Deregisters a peer from onion message interception. + /// + /// After this call, onion messages for this peer will no longer be intercepted (unless + /// blanket interception is enabled via [`Self::new_with_offline_peer_interception`]). + /// + /// Returns whether the peer was previously registered. + pub fn deregister_peer_for_interception(&self, peer_node_id: &PublicKey) -> bool { + self.peers_registered_for_interception.lock().unwrap().remove(peer_node_id) + } + /// Sends an [`OnionMessage`] based on its [`MessageSendInstructions`]. pub fn send_onion_message( &self, contents: T, instructions: MessageSendInstructions, @@ -1682,6 +1763,9 @@ impl< .entry(next_node_id) .or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new())); + let should_intercept = self.intercept_messages_for_offline_peers + || self.peers_registered_for_interception.lock().unwrap().contains(&next_node_id); + match message_recipients.entry(next_node_id) { hash_map::Entry::Occupied(mut e) if matches!(e.get(), OnionMessageRecipient::ConnectedPeer(..)) => @@ -1695,7 +1779,7 @@ impl< ); Ok(()) }, - _ if self.intercept_messages_for_offline_peers => { + _ if should_intercept => { log_trace!( self.logger, "Generating OnionMessageIntercepted event for peer {} {}", @@ -2138,7 +2222,9 @@ impl< .or_insert_with(|| OnionMessageRecipient::ConnectedPeer(VecDeque::new())) .mark_connected(); } - if self.intercept_messages_for_offline_peers { + let is_registered = + self.peers_registered_for_interception.lock().unwrap().contains(&their_node_id); + if self.intercept_messages_for_offline_peers || is_registered { let mut pending_peer_connected_events = self.pending_peer_connected_events.lock().unwrap(); pending_peer_connected_events diff --git a/lightning/src/util/config.rs b/lightning/src/util/config.rs index feb326cfad6..f0d5ad8b398 100644 --- a/lightning/src/util/config.rs +++ b/lightning/src/util/config.rs @@ -1041,6 +1041,28 @@ pub struct UserConfig { /// [`ChannelManager::send_payment_for_bolt12_invoice`]: crate::ln::channelmanager::ChannelManager::send_payment_for_bolt12_invoice /// [`ChannelManager::abandon_payment`]: crate::ln::channelmanager::ChannelManager::abandon_payment pub manually_handle_bolt12_invoices: bool, + /// If this is set to `true`, the user needs to manually handle incoming BOLT12 + /// [`InvoiceRequest`]s. + /// + /// When set to `true`, [`Event::InvoiceRequestReceived`] will be generated for each received + /// [`InvoiceRequest`] instead of automatically creating and sending a [`Bolt12Invoice`]. + /// + /// This is useful for LSP clients using LSPS2 (JIT channels) with BOLT12, where the invoice + /// needs to include blinded payment paths through the LSP's intercept SCID. The user can + /// construct custom blinded payment paths via + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`], build the invoice + /// via [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`], + /// and send it back via [`ChannelManager::send_invoice_for_request`]. + /// + /// Default value: `false` + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice + /// [`Event::InvoiceRequestReceived`]: crate::events::Event::InvoiceRequestReceived + /// [`OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid`]: crate::offers::flow::OffersMessageFlow::create_blinded_payment_paths_for_intercept_scid + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths`]: crate::offers::flow::OffersMessageFlow::create_invoice_builder_from_invoice_request_with_custom_payment_paths + /// [`ChannelManager::send_invoice_for_request`]: crate::ln::channelmanager::ChannelManager::send_invoice_for_request + pub manually_handle_bolt12_invoice_requests: bool, /// If this is set to `true`, dual-funded channels will be enabled. /// /// Default value: `false` @@ -1095,6 +1117,7 @@ impl Default for UserConfig { manually_accept_inbound_channels: false, htlc_interception_flags: 0, manually_handle_bolt12_invoices: false, + manually_handle_bolt12_invoice_requests: false, enable_dual_funded_channels: false, enable_htlc_hold: false, hold_outbound_htlcs_at_next_hop: false, @@ -1118,6 +1141,7 @@ impl Readable for UserConfig { manually_accept_inbound_channels: Readable::read(reader)?, htlc_interception_flags: Readable::read(reader)?, manually_handle_bolt12_invoices: Readable::read(reader)?, + manually_handle_bolt12_invoice_requests: Readable::read(reader)?, enable_dual_funded_channels: Readable::read(reader)?, hold_outbound_htlcs_at_next_hop: Readable::read(reader)?, enable_htlc_hold: Readable::read(reader)?,