From ecbc5ebfba1965918057cd1996721dd0c7f39d89 Mon Sep 17 00:00:00 2001 From: Alkamal01 Date: Sat, 2 May 2026 17:09:53 +0100 Subject: [PATCH 1/4] outbound_payment: add pay_for_bolt12_invoice for externally-sourced invoices Adds ChannelManager::pay_for_bolt12_invoice which pays a Bolt12Invoice without requiring a prior LDK-managed request. The caller provides their own payment_id and is responsible for verification, enabling partial MPP payments from multiple senders. The onion total is always set to the full invoice amount so recipients can validate correctly regardless of each sender's contribution. --- lightning/src/ln/channelmanager.rs | 75 ++++++++++++++ lightning/src/ln/offers_tests.rs | 141 +++++++++++++++++++++++++++ lightning/src/ln/outbound_payment.rs | 81 +++++++++++++-- 3 files changed, 291 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index a3c33b8320f..e2934fa6b88 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -758,6 +758,33 @@ impl Default for OptionalBolt11PaymentParams { } } +/// Optional arguments to [`ChannelManager::pay_for_bolt12_invoice`]. +/// +/// These fields will often not need to be set, and the provided [`Self::default`] can be used. +pub struct OptionalBolt12PaymentParams { + /// Pathfinding options which tweak how the path is constructed to the recipient. + pub route_params_config: RouteParametersConfig, + /// The number of tries or time during which we'll retry this payment if some paths to the + /// recipient fail. + /// + /// Once the retry limit is reached, further path failures will not be retried and the payment + /// will ultimately fail once all pending paths have failed (generating an + /// [`Event::PaymentFailed`]). + pub retry_strategy: Retry, +} + +impl Default for OptionalBolt12PaymentParams { + fn default() -> Self { + Self { + route_params_config: Default::default(), + #[cfg(feature = "std")] + retry_strategy: Retry::Timeout(core::time::Duration::from_secs(2)), + #[cfg(not(feature = "std"))] + retry_strategy: Retry::Attempts(3), + } + } +} + /// Optional arguments to [`ChannelManager::pay_for_offer`]. /// /// These fields will often not need to be set, and the provided [`Self::default`] can be used. @@ -5857,6 +5884,50 @@ impl< ) } + /// Pays a [`Bolt12Invoice`] without requiring it to have been requested through LDK. + /// + /// Unlike [`ChannelManager::send_payment_for_bolt12_invoice`], this method does not verify + /// that the invoice was previously requested. The caller is responsible for invoice + /// verification and for providing a unique `payment_id`. + /// + /// `amount_msats` controls how much this node contributes to the payment. Set to `None` to pay + /// the full invoice amount. For payments split across multiple senders, provide a partial + /// `amount_msats` here — the onion total is always set to the full invoice amount so that the + /// recipient can correctly validate the payment. + /// + /// Returns [`Bolt12PaymentError::DuplicateInvoice`] if a payment with the given `payment_id` + /// is already pending, or [`Bolt12PaymentError::InvalidAmount`] if `amount_msats` exceeds the + /// invoice amount. + /// + /// Either [`Event::PaymentSent`] or [`Event::PaymentFailed`] will be generated once the + /// payment completes. + pub fn pay_for_bolt12_invoice( + &self, invoice: &Bolt12Invoice, payment_id: PaymentId, amount_msats: Option, + optional_params: OptionalBolt12PaymentParams, + ) -> Result<(), Bolt12PaymentError> { + let best_block_height = self.best_block.read().unwrap().height; + let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); + let features = self.bolt12_invoice_features(); + self.pending_outbound_payments.pay_for_bolt12_invoice( + invoice, + payment_id, + amount_msats, + optional_params, + &self.router, + self.list_usable_channels(), + features, + || self.compute_inflight_htlcs(), + &self.entropy_source, + &self.node_signer, + &self, + &self.secp_ctx, + best_block_height, + &self.pending_events, + |args| self.send_payment_along_path(args), + &WithContext::for_payment(&self.logger, None, None, None, payment_id), + ) + } + fn check_refresh_async_receive_offer_cache(&self, timer_tick_occurred: bool) { let peers = self.get_peers_for_blinded_path(); let channels = self.list_usable_channels(); @@ -17059,6 +17130,10 @@ impl< log_trace!($logger, "{}", err_msg); InvoiceError::from_string(err_msg.to_string()) }, + Err(Bolt12PaymentError::InvalidAmount) => { + log_error!($logger, "Got InvalidAmount paying internally-sourced invoice; this shouldn't happen"); + return None + }, Err(Bolt12PaymentError::UnexpectedInvoice) | Err(Bolt12PaymentError::DuplicateInvoice) | Ok(()) => return None, diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index de08af5d276..a3bf6e6d137 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2667,3 +2667,144 @@ fn creates_and_pays_for_phantom_offer() { assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none()); } } + +/// Checks that a BOLT 12 invoice can be paid via [`ChannelManager::pay_for_bolt12_invoice`] +/// without requiring a prior LDK-managed payment request. +#[test] +fn pay_for_bolt12_invoice_with_fresh_payment_id() { + let mut manually_pay_cfg = test_default_channel_config(); + manually_pay_cfg.manually_handle_bolt12_invoices = 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, &[None, Some(manually_pay_cfg)]); + 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(); + + let offer = alice.node + .create_offer_builder().unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + // Use the standard offer flow to obtain an invoice, but pay it via the new API with a + // fresh payment_id rather than the one from the original request. + let orig_payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&offer, None, orig_payment_id, Default::default()).unwrap(); + + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(alice, &onion_message); + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }); + + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let invoice = match get_event!(bob, Event::InvoiceReceived) { + Event::InvoiceReceived { invoice, .. } => invoice, + _ => panic!("Expected InvoiceReceived"), + }; + + // Abandon the original payment since we're paying via a fresh payment_id below. + bob.node.abandon_payment(orig_payment_id); + get_event!(bob, Event::PaymentFailed); + + let payment_id = PaymentId([2; 32]); + bob.node.pay_for_bolt12_invoice(&invoice, payment_id, None, Default::default()).unwrap(); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + route_bolt12_payment(bob, &[alice], &invoice); + claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); +} + +/// Checks error cases for [`ChannelManager::pay_for_bolt12_invoice`]: +/// overpaying returns [`Bolt12PaymentError::InvalidAmount`] and re-using a payment_id +/// returns [`Bolt12PaymentError::DuplicateInvoice`]. +#[test] +fn pay_for_bolt12_invoice_error_cases() { + let mut manually_pay_cfg = test_default_channel_config(); + manually_pay_cfg.manually_handle_bolt12_invoices = 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, &[None, Some(manually_pay_cfg)]); + 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(); + + let offer = alice.node + .create_offer_builder().unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + let orig_payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&offer, None, orig_payment_id, Default::default()).unwrap(); + + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(alice, &onion_message); + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }); + + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let invoice = match get_event!(bob, Event::InvoiceReceived) { + Event::InvoiceReceived { invoice, .. } => invoice, + _ => panic!("Expected InvoiceReceived"), + }; + + bob.node.abandon_payment(orig_payment_id); + get_event!(bob, Event::PaymentFailed); + + let payment_id = PaymentId([2; 32]); + + // Overpaying is rejected before any state is inserted. + assert_eq!( + bob.node.pay_for_bolt12_invoice( + &invoice, payment_id, Some(invoice.amount_msats() + 1), Default::default() + ), + Err(Bolt12PaymentError::InvalidAmount), + ); + + // First call succeeds and starts the payment. + bob.node.pay_for_bolt12_invoice(&invoice, payment_id, None, Default::default()).unwrap(); + + // Re-using the same payment_id is rejected. + assert_eq!( + bob.node.pay_for_bolt12_invoice(&invoice, payment_id, None, Default::default()), + Err(Bolt12PaymentError::DuplicateInvoice), + ); + + route_bolt12_payment(bob, &[alice], &invoice); + claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); +} diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 7259f60796f..111df68b062 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -18,8 +18,8 @@ use crate::blinded_path::{IntroductionNode, NodeIdLookUp}; use crate::events::{self, PaidBolt12Invoice, PaymentFailureReason}; use crate::ln::channel_state::ChannelDetails; use crate::ln::channelmanager::{ - EventCompletionAction, HTLCSource, OptionalBolt11PaymentParams, PaymentCompleteUpdate, - PaymentId, + EventCompletionAction, HTLCSource, OptionalBolt11PaymentParams, OptionalBolt12PaymentParams, + PaymentCompleteUpdate, PaymentId, }; use crate::ln::msgs::DecodeError; use crate::ln::onion_utils; @@ -657,6 +657,12 @@ pub enum Bolt12PaymentError { DuplicateInvoice, /// The invoice was valid for the corresponding [`PaymentId`], but required unknown features. UnknownRequiredFeatures, + /// Incorrect amount was provided to [`ChannelManager::pay_for_bolt12_invoice`]. + /// + /// This occurs when `amount_msats` exceeds the invoice amount. + /// + /// [`ChannelManager::pay_for_bolt12_invoice`]: crate::ln::channelmanager::ChannelManager::pay_for_bolt12_invoice + InvalidAmount, /// The invoice was valid for the corresponding [`PaymentId`], but sending the payment failed. SendingFailed(RetryableSendFailure), /// Failed to create a blinded path back to ourselves. @@ -1124,12 +1130,73 @@ impl OutboundPayments { } let invoice = PaidBolt12Invoice::Bolt12Invoice(invoice.clone()); self.send_payment_for_bolt12_invoice_internal( - payment_id, payment_hash, None, None, invoice, route_params, retry_strategy, false, router, - first_hops, inflight_htlcs, entropy_source, node_signer, node_id_lookup, secp_ctx, + payment_id, payment_hash, None, None, invoice, route_params, retry_strategy, false, None, + router, first_hops, inflight_htlcs, entropy_source, node_signer, node_id_lookup, secp_ctx, best_block_height, pending_events, send_payment_along_path, logger, ) } + #[rustfmt::skip] + pub(super) fn pay_for_bolt12_invoice< + R: Router, ES: EntropySource, NS: NodeSigner, NL: NodeIdLookUp, IH, SP, L: Logger, + >( + &self, invoice: &Bolt12Invoice, payment_id: PaymentId, amount_msats: Option, + optional_params: OptionalBolt12PaymentParams, + router: &R, first_hops: Vec, features: Bolt12InvoiceFeatures, inflight_htlcs: IH, + entropy_source: &ES, node_signer: &NS, node_id_lookup: &NL, + secp_ctx: &Secp256k1, best_block_height: u32, + pending_events: &Mutex)>>, + send_payment_along_path: SP, logger: &WithContext, + ) -> Result<(), Bolt12PaymentError> + where + IH: Fn() -> InFlightHtlcs, + SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, + { + let OptionalBolt12PaymentParams { retry_strategy, route_params_config } = optional_params; + + let invoice_amount = invoice.amount_msats(); + let send_amount = amount_msats.unwrap_or(invoice_amount); + + if send_amount > invoice_amount { + return Err(Bolt12PaymentError::InvalidAmount); + } + + if invoice.invoice_features().requires_unknown_bits_from(&features) { + return Err(Bolt12PaymentError::UnknownRequiredFeatures); + } + + let payment_hash = invoice.payment_hash(); + + match self.pending_outbound_payments.lock().unwrap().entry(payment_id) { + hash_map::Entry::Occupied(_) => return Err(Bolt12PaymentError::DuplicateInvoice), + hash_map::Entry::Vacant(entry) => { + entry.insert(PendingOutboundPayment::InvoiceReceived { + payment_hash, + retry_strategy, + route_params_config, + }); + }, + } + + let mut route_params = RouteParameters::from_payment_params_and_value( + PaymentParameters::from_bolt12_invoice(invoice) + .with_user_config_ignoring_fee_limit(route_params_config), + send_amount, + ); + if let Some(max_fee_msat) = route_params_config.max_total_routing_fee_msat { + route_params.max_total_routing_fee_msat = Some(max_fee_msat); + } + // The onion total must always reflect the full invoice amount so that the recipient can + // correctly validate MPP payments, including when this node pays only a partial amount. + let invoice = PaidBolt12Invoice::Bolt12Invoice(invoice.clone()); + self.send_payment_for_bolt12_invoice_internal( + payment_id, payment_hash, None, None, invoice, route_params, retry_strategy, + false, Some(invoice_amount), router, first_hops, inflight_htlcs, + entropy_source, node_signer, node_id_lookup, secp_ctx, best_block_height, + pending_events, send_payment_along_path, logger, + ) + } + #[rustfmt::skip] fn send_payment_for_bolt12_invoice_internal< R: Router, ES: EntropySource, NS: NodeSigner, NL: NodeIdLookUp, IH, SP, L: Logger, @@ -1137,7 +1204,8 @@ impl OutboundPayments { &self, payment_id: PaymentId, payment_hash: PaymentHash, keysend_preimage: Option, invoice_request: Option<&InvoiceRequest>, bolt12_invoice: PaidBolt12Invoice, - mut route_params: RouteParameters, retry_strategy: Retry, hold_htlcs_at_next_hop: bool, router: &R, + mut route_params: RouteParameters, retry_strategy: Retry, hold_htlcs_at_next_hop: bool, + total_mpp_amount_msat_override: Option, router: &R, first_hops: Vec, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, node_id_lookup: &NL, secp_ctx: &Secp256k1, best_block_height: u32, pending_events: &Mutex)>>, @@ -1169,7 +1237,7 @@ impl OutboundPayments { payment_secret: None, payment_metadata: None, custom_tlvs: vec![], - total_mpp_amount_msat: route_params.final_value_msat, + total_mpp_amount_msat: total_mpp_amount_msat_override.unwrap_or(route_params.final_value_msat), }; let route = match self.find_initial_route( payment_id, payment_hash, &recipient_onion, keysend_preimage, invoice_request, @@ -1405,6 +1473,7 @@ impl OutboundPayments { route_params, retry_strategy, hold_htlcs_at_next_hop, + None, router, first_hops, inflight_htlcs, From 06737db352dc22f4764942f06ebbb0af6c641e58 Mon Sep 17 00:00:00 2001 From: Alkamal01 Date: Sun, 3 May 2026 06:09:55 +0100 Subject: [PATCH 2/4] bolt12: validate non-zero amount, assert unreachable path, add tests - Reject `send_amount == 0` in `pay_for_bolt12_invoice` - Add `debug_assert!(false)` in `InvalidAmount` arm of `handle_pay_invoice_res!` (unreachable for internally-sourced invoices) - Add `pay_for_bolt12_invoice_partial_amount` test verifying: - HTLC `amount_msat` equals `partial_amount` - `total_mpp_amount_msat` in the onion equals full invoice amount - recipient holds HTLC rather than settling - Add zero-amount assertion to `pay_for_bolt12_invoice_error_cases` --- lightning/src/ln/channelmanager.rs | 1 + lightning/src/ln/offers_tests.rs | 89 +++++++++++++++++++++++++++- lightning/src/ln/outbound_payment.rs | 2 +- 3 files changed, 89 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index e2934fa6b88..abdb7f757a3 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -17131,6 +17131,7 @@ impl< InvoiceError::from_string(err_msg.to_string()) }, Err(Bolt12PaymentError::InvalidAmount) => { + debug_assert!(false, "Got InvalidAmount paying internally-sourced invoice; this shouldn't happen"); log_error!($logger, "Got InvalidAmount paying internally-sourced invoice; this shouldn't happen"); return None }, diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index a3bf6e6d137..027fdd9dab5 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2733,8 +2733,8 @@ fn pay_for_bolt12_invoice_with_fresh_payment_id() { } /// Checks error cases for [`ChannelManager::pay_for_bolt12_invoice`]: -/// overpaying returns [`Bolt12PaymentError::InvalidAmount`] and re-using a payment_id -/// returns [`Bolt12PaymentError::DuplicateInvoice`]. +/// zero amount and overpaying return [`Bolt12PaymentError::InvalidAmount`], re-using a +/// payment_id returns [`Bolt12PaymentError::DuplicateInvoice`]. #[test] fn pay_for_bolt12_invoice_error_cases() { let mut manually_pay_cfg = test_default_channel_config(); @@ -2787,6 +2787,12 @@ fn pay_for_bolt12_invoice_error_cases() { let payment_id = PaymentId([2; 32]); + // Zero amount is rejected. + assert_eq!( + bob.node.pay_for_bolt12_invoice(&invoice, payment_id, Some(0), Default::default()), + Err(Bolt12PaymentError::InvalidAmount), + ); + // Overpaying is rejected before any state is inserted. assert_eq!( bob.node.pay_for_bolt12_invoice( @@ -2808,3 +2814,82 @@ fn pay_for_bolt12_invoice_error_cases() { claim_bolt12_payment(bob, &[alice], payment_context, &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } + +/// Checks that pay_for_bolt12_invoice with a partial amount routes an HTLC for the partial +/// amount while setting total_mpp_amount_msat to the full invoice amount in the onion, so the +/// recipient holds the HTLC awaiting additional parts until the full amount arrives. +#[test] +fn pay_for_bolt12_invoice_partial_amount() { + let mut manually_pay_cfg = test_default_channel_config(); + manually_pay_cfg.manually_handle_bolt12_invoices = 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, &[None, Some(manually_pay_cfg)]); + 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(); + + let invoice_amount = 10_000_000u64; + let partial_amount = 5_000_000u64; + + let offer = alice.node + .create_offer_builder().unwrap() + .amount_msats(invoice_amount) + .build().unwrap(); + + let orig_payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&offer, None, orig_payment_id, Default::default()).unwrap(); + + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let invoice = match get_event!(bob, Event::InvoiceReceived) { + Event::InvoiceReceived { invoice, .. } => invoice, + _ => panic!("Expected InvoiceReceived"), + }; + + bob.node.abandon_payment(orig_payment_id); + get_event!(bob, Event::PaymentFailed); + + let payment_hash = invoice.payment_hash(); + let payment_id = PaymentId([2; 32]); + + let params = channelmanager::OptionalBolt12PaymentParams { + retry_strategy: Retry::Attempts(0), + ..Default::default() + }; + bob.node.pay_for_bolt12_invoice(&invoice, payment_id, Some(partial_amount), params).unwrap(); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + check_added_monitors(bob, 1); + let mut events = bob.node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let ev = remove_first_msg_event_to_node(&alice_id, &mut events); + + // The HTLC carries the partial amount, not the full invoice amount. + if let crate::ln::msgs::MessageSendEvent::UpdateHTLCs { ref updates, .. } = ev { + assert_eq!(updates.update_add_htlcs[0].amount_msat, partial_amount); + } else { + panic!("Expected UpdateHTLCs"); + } + + do_pass_along_path( + PassAlongPathArgs::new(bob, &[alice], partial_amount, payment_hash, ev) + .without_clearing_recipient_events() + .without_claimable_event() + .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]) + ); + + // Alice has not emitted PaymentClaimable: total_mpp_amount_msat in the onion equals the + // full invoice amount (10M), so she waits for the remaining 5M before settling. + assert!(alice.node.get_and_clear_pending_events().is_empty()); +} diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 111df68b062..135905318c0 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -1157,7 +1157,7 @@ impl OutboundPayments { let invoice_amount = invoice.amount_msats(); let send_amount = amount_msats.unwrap_or(invoice_amount); - if send_amount > invoice_amount { + if send_amount == 0 || send_amount > invoice_amount { return Err(Bolt12PaymentError::InvalidAmount); } From dbc0169d079540c9dbdddc93d0fef974fed9a568 Mon Sep 17 00:00:00 2001 From: Alkamal01 Date: Sun, 3 May 2026 06:30:06 +0100 Subject: [PATCH 3/4] Address review feedback on BOLT 12 payment errors Update InvalidAmount docs to explicitly mention zero amount rejection, and add missing test coverage for the UnknownRequiredFeatures error during BOLT 12 invoice payments. --- lightning/src/ln/channelmanager.rs | 2 +- lightning/src/ln/offers_tests.rs | 31 +++++++++++++++++++++++++--- lightning/src/ln/outbound_payment.rs | 2 +- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index abdb7f757a3..b89292e3a8f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5896,7 +5896,7 @@ impl< /// recipient can correctly validate the payment. /// /// Returns [`Bolt12PaymentError::DuplicateInvoice`] if a payment with the given `payment_id` - /// is already pending, or [`Bolt12PaymentError::InvalidAmount`] if `amount_msats` exceeds the + /// is already pending, or [`Bolt12PaymentError::InvalidAmount`] if `amount_msats` is zero or exceeds the /// invoice amount. /// /// Either [`Event::PaymentSent`] or [`Event::PaymentFailed`] will be generated once the diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 027fdd9dab5..7350942317b 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2760,10 +2760,10 @@ fn pay_for_bolt12_invoice_error_cases() { let orig_payment_id = PaymentId([1; 32]); bob.node.pay_for_offer(&offer, None, orig_payment_id, Default::default()).unwrap(); - let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); - alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + let invoice_request_onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &invoice_request_onion_message); - let (invoice_request, _) = extract_invoice_request(alice, &onion_message); + let (invoice_request, _) = extract_invoice_request(alice, &invoice_request_onion_message); let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { offer_id: offer.id(), invoice_request: InvoiceRequestFields { @@ -2810,6 +2810,31 @@ fn pay_for_bolt12_invoice_error_cases() { Err(Bolt12PaymentError::DuplicateInvoice), ); + // Creating an invoice with unknown required features should be rejected. + let expanded_key = alice.keys_manager.get_expanded_key(); + let secp_ctx = Secp256k1::new(); + let created_at = alice.node.duration_since_epoch(); + let nonce = extract_offer_nonce(alice, &invoice_request_onion_message); + let verified_invoice_request = invoice_request + .verify_using_recipient_data(nonce, &expanded_key, &secp_ctx).unwrap(); + + let unknown_features_invoice = match verified_invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { + request.respond_using_derived_keys_no_std(invoice.payment_paths().to_vec(), invoice.payment_hash(), created_at).unwrap() + .features_unchecked(Bolt12InvoiceFeatures::unknown()) + .build_and_sign(&secp_ctx).unwrap() + }, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(_) => { + panic!("Expected invoice request with keys"); + }, + }; + + let unknown_features_payment_id = PaymentId([3; 32]); + assert_eq!( + bob.node.pay_for_bolt12_invoice(&unknown_features_invoice, unknown_features_payment_id, None, Default::default()), + Err(Bolt12PaymentError::UnknownRequiredFeatures), + ); + route_bolt12_payment(bob, &[alice], &invoice); claim_bolt12_payment(bob, &[alice], payment_context, &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 135905318c0..e836e10433b 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -659,7 +659,7 @@ pub enum Bolt12PaymentError { UnknownRequiredFeatures, /// Incorrect amount was provided to [`ChannelManager::pay_for_bolt12_invoice`]. /// - /// This occurs when `amount_msats` exceeds the invoice amount. + /// This occurs when `amount_msats` is zero or exceeds the invoice amount. /// /// [`ChannelManager::pay_for_bolt12_invoice`]: crate::ln::channelmanager::ChannelManager::pay_for_bolt12_invoice InvalidAmount, From 095030bf19d00b13d4e3a95340ae4013e992bb36 Mon Sep 17 00:00:00 2001 From: Alkamal01 Date: Tue, 9 Jun 2026 10:03:53 +0100 Subject: [PATCH 4/4] Address review feedback: optional amount_msats and deprecate old API Move `amount_msats` from a positional parameter of `pay_for_bolt12_invoice` into `OptionalBolt12PaymentParams` as requested, and deprecate `send_payment_for_bolt12_invoice` in favor of `pay_for_bolt12_invoice`. --- lightning/src/ln/async_payments_tests.rs | 1 + lightning/src/ln/channelmanager.rs | 25 +++++++++++++++-------- lightning/src/ln/offers_tests.rs | 26 ++++++++++++++++-------- lightning/src/ln/outbound_payment.rs | 4 ++-- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index bd07d13c13d..194215b2aed 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -868,6 +868,7 @@ fn ignore_unexpected_static_invoice() { } #[test] +#[allow(deprecated)] // Tests the deprecated send_payment_for_bolt12_invoice. fn ignore_duplicate_invoice() { // When a sender tries to pay an async recipient it could potentially end up receiving two // invoices: one static invoice that it received from always-online node and a fresh invoice diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index b89292e3a8f..5f12784b5ba 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -762,6 +762,13 @@ impl Default for OptionalBolt11PaymentParams { /// /// These fields will often not need to be set, and the provided [`Self::default`] can be used. pub struct OptionalBolt12PaymentParams { + /// The amount this node contributes to the payment. Set to `None` to pay the full invoice + /// amount. + /// + /// For payments split across multiple senders, provide a partial amount here — the onion total + /// is always set to the full invoice amount so that the recipient can correctly validate the + /// payment. + pub amount_msats: Option, /// Pathfinding options which tweak how the path is constructed to the recipient. pub route_params_config: RouteParametersConfig, /// The number of tries or time during which we'll retry this payment if some paths to the @@ -776,6 +783,7 @@ pub struct OptionalBolt12PaymentParams { impl Default for OptionalBolt12PaymentParams { fn default() -> Self { Self { + amount_msats: None, route_params_config: Default::default(), #[cfg(feature = "std")] retry_strategy: Retry::Timeout(core::time::Duration::from_secs(2)), @@ -5851,6 +5859,10 @@ impl< /// whether or not the payment was successful. /// /// [timer tick]: Self::timer_tick_occurred + #[deprecated( + since = "0.3.0", + note = "Use ChannelManager::pay_for_bolt12_invoice instead, providing a fresh payment_id and verifying the invoice yourself." + )] pub fn send_payment_for_bolt12_invoice( &self, invoice: &Bolt12Invoice, context: Option<&OffersContext>, ) -> Result<(), Bolt12PaymentError> { @@ -5890,19 +5902,17 @@ impl< /// that the invoice was previously requested. The caller is responsible for invoice /// verification and for providing a unique `payment_id`. /// - /// `amount_msats` controls how much this node contributes to the payment. Set to `None` to pay - /// the full invoice amount. For payments split across multiple senders, provide a partial - /// `amount_msats` here — the onion total is always set to the full invoice amount so that the - /// recipient can correctly validate the payment. + /// The amount this node contributes to the payment can be set via + /// [`OptionalBolt12PaymentParams::amount_msats`], which defaults to the full invoice amount. /// /// Returns [`Bolt12PaymentError::DuplicateInvoice`] if a payment with the given `payment_id` - /// is already pending, or [`Bolt12PaymentError::InvalidAmount`] if `amount_msats` is zero or exceeds the - /// invoice amount. + /// is already pending, or [`Bolt12PaymentError::InvalidAmount`] if the requested amount is zero + /// or exceeds the invoice amount. /// /// Either [`Event::PaymentSent`] or [`Event::PaymentFailed`] will be generated once the /// payment completes. pub fn pay_for_bolt12_invoice( - &self, invoice: &Bolt12Invoice, payment_id: PaymentId, amount_msats: Option, + &self, invoice: &Bolt12Invoice, payment_id: PaymentId, optional_params: OptionalBolt12PaymentParams, ) -> Result<(), Bolt12PaymentError> { let best_block_height = self.best_block.read().unwrap().height; @@ -5911,7 +5921,6 @@ impl< self.pending_outbound_payments.pay_for_bolt12_invoice( invoice, payment_id, - amount_msats, optional_params, &self.router, self.list_usable_channels(), diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 7350942317b..fa332a9fff3 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -1303,6 +1303,7 @@ fn creates_and_pays_for_offer_with_retry() { /// Checks that a deferred invoice can be paid asynchronously from an Event::InvoiceReceived. #[test] +#[allow(deprecated)] // Tests the deprecated send_payment_for_bolt12_invoice. fn pays_bolt12_invoice_asynchronously() { let mut manually_pay_cfg = test_default_channel_config(); manually_pay_cfg.manually_handle_bolt12_invoices = true; @@ -2724,7 +2725,7 @@ fn pay_for_bolt12_invoice_with_fresh_payment_id() { get_event!(bob, Event::PaymentFailed); let payment_id = PaymentId([2; 32]); - bob.node.pay_for_bolt12_invoice(&invoice, payment_id, None, Default::default()).unwrap(); + bob.node.pay_for_bolt12_invoice(&invoice, payment_id, Default::default()).unwrap(); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); route_bolt12_payment(bob, &[alice], &invoice); @@ -2788,25 +2789,31 @@ fn pay_for_bolt12_invoice_error_cases() { let payment_id = PaymentId([2; 32]); // Zero amount is rejected. + let zero_amount_params = channelmanager::OptionalBolt12PaymentParams { + amount_msats: Some(0), + ..Default::default() + }; assert_eq!( - bob.node.pay_for_bolt12_invoice(&invoice, payment_id, Some(0), Default::default()), + bob.node.pay_for_bolt12_invoice(&invoice, payment_id, zero_amount_params), Err(Bolt12PaymentError::InvalidAmount), ); // Overpaying is rejected before any state is inserted. + let overpay_params = channelmanager::OptionalBolt12PaymentParams { + amount_msats: Some(invoice.amount_msats() + 1), + ..Default::default() + }; assert_eq!( - bob.node.pay_for_bolt12_invoice( - &invoice, payment_id, Some(invoice.amount_msats() + 1), Default::default() - ), + bob.node.pay_for_bolt12_invoice(&invoice, payment_id, overpay_params), Err(Bolt12PaymentError::InvalidAmount), ); // First call succeeds and starts the payment. - bob.node.pay_for_bolt12_invoice(&invoice, payment_id, None, Default::default()).unwrap(); + bob.node.pay_for_bolt12_invoice(&invoice, payment_id, Default::default()).unwrap(); // Re-using the same payment_id is rejected. assert_eq!( - bob.node.pay_for_bolt12_invoice(&invoice, payment_id, None, Default::default()), + bob.node.pay_for_bolt12_invoice(&invoice, payment_id, Default::default()), Err(Bolt12PaymentError::DuplicateInvoice), ); @@ -2831,7 +2838,7 @@ fn pay_for_bolt12_invoice_error_cases() { let unknown_features_payment_id = PaymentId([3; 32]); assert_eq!( - bob.node.pay_for_bolt12_invoice(&unknown_features_invoice, unknown_features_payment_id, None, Default::default()), + bob.node.pay_for_bolt12_invoice(&unknown_features_invoice, unknown_features_payment_id, Default::default()), Err(Bolt12PaymentError::UnknownRequiredFeatures), ); @@ -2889,10 +2896,11 @@ fn pay_for_bolt12_invoice_partial_amount() { let payment_id = PaymentId([2; 32]); let params = channelmanager::OptionalBolt12PaymentParams { + amount_msats: Some(partial_amount), retry_strategy: Retry::Attempts(0), ..Default::default() }; - bob.node.pay_for_bolt12_invoice(&invoice, payment_id, Some(partial_amount), params).unwrap(); + bob.node.pay_for_bolt12_invoice(&invoice, payment_id, params).unwrap(); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); check_added_monitors(bob, 1); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index e836e10433b..0294161b069 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -1140,7 +1140,7 @@ impl OutboundPayments { pub(super) fn pay_for_bolt12_invoice< R: Router, ES: EntropySource, NS: NodeSigner, NL: NodeIdLookUp, IH, SP, L: Logger, >( - &self, invoice: &Bolt12Invoice, payment_id: PaymentId, amount_msats: Option, + &self, invoice: &Bolt12Invoice, payment_id: PaymentId, optional_params: OptionalBolt12PaymentParams, router: &R, first_hops: Vec, features: Bolt12InvoiceFeatures, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, node_id_lookup: &NL, @@ -1152,7 +1152,7 @@ impl OutboundPayments { IH: Fn() -> InFlightHtlcs, SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, { - let OptionalBolt12PaymentParams { retry_strategy, route_params_config } = optional_params; + let OptionalBolt12PaymentParams { amount_msats, retry_strategy, route_params_config } = optional_params; let invoice_amount = invoice.amount_msats(); let send_amount = amount_msats.unwrap_or(invoice_amount);