From 2ea54b87cc9eb15bff4e43160a71e5e5c44b2310 Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 5 Dec 2025 18:10:19 +0530 Subject: [PATCH 1/5] Introduce custom TLVs in `pay_for_bolt11_invoice` Custom TLVs let the payer attach arbitrary data to the onion packet, enabling everything from richer metadata to custom authentication on the payee's side. Until now, this flexibility existed only through `send_payment`. The simpler `pay_for_bolt11_invoice` API offered no way to pass custom TLVs, limiting its usefulness in flows that rely on additional context. This commit adds custom TLV support to `pay_for_bolt11_invoice`, bringing it to feature parity. --- lightning-liquidity/tests/lsps2_integration_tests.rs | 4 ++++ lightning/src/ln/bolt11_payment_tests.rs | 4 ++++ lightning/src/ln/channelmanager.rs | 9 +++++++-- lightning/src/ln/invoice_utils.rs | 9 ++++++++- lightning/src/ln/outbound_payment.rs | 8 +++++++- lightning/src/ln/payment_tests.rs | 5 ++++- 6 files changed, 34 insertions(+), 5 deletions(-) diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 82f93b5990c..41e3e6df963 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -1221,6 +1221,7 @@ fn client_trusts_lsp_end_to_end_test() { &invoice, PaymentId(invoice.payment_hash().to_byte_array()), None, + vec![], Default::default(), Retry::Attempts(3), ) @@ -1694,6 +1695,7 @@ fn late_payment_forwarded_and_safe_after_force_close_does_not_broadcast() { &invoice, PaymentId(invoice.payment_hash().to_byte_array()), None, + vec![], Default::default(), Retry::Attempts(3), ) @@ -1885,6 +1887,7 @@ fn htlc_timeout_before_client_claim_results_in_handling_failed() { &invoice, PaymentId(invoice.payment_hash().to_byte_array()), None, + vec![], Default::default(), Retry::Attempts(3), ) @@ -2222,6 +2225,7 @@ fn client_trusts_lsp_partial_fee_does_not_trigger_broadcast() { &invoice, PaymentId(invoice.payment_hash().to_byte_array()), None, + vec![], Default::default(), Retry::Attempts(3), ) diff --git a/lightning/src/ln/bolt11_payment_tests.rs b/lightning/src/ln/bolt11_payment_tests.rs index 63c5576e333..7e33cd4ab65 100644 --- a/lightning/src/ln/bolt11_payment_tests.rs +++ b/lightning/src/ln/bolt11_payment_tests.rs @@ -55,6 +55,7 @@ fn payment_metadata_end_to_end_for_invoice_with_amount() { &invoice, PaymentId(payment_hash.0), Some(100), + vec![], RouteParametersConfig::default(), Retry::Attempts(0), ) { @@ -68,6 +69,7 @@ fn payment_metadata_end_to_end_for_invoice_with_amount() { &invoice, PaymentId(payment_hash.0), None, + vec![], RouteParametersConfig::default(), Retry::Attempts(0), ) @@ -123,6 +125,7 @@ fn payment_metadata_end_to_end_for_invoice_with_no_amount() { &invoice, PaymentId(payment_hash.0), None, + vec![], RouteParametersConfig::default(), Retry::Attempts(0), ) { @@ -136,6 +139,7 @@ fn payment_metadata_end_to_end_for_invoice_with_no_amount() { &invoice, PaymentId(payment_hash.0), Some(50_000), + vec![], RouteParametersConfig::default(), Retry::Attempts(0), ) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 72585d69f80..ad6249d272f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -2272,8 +2272,11 @@ where /// # let channel_manager = channel_manager.get_cm(); /// # let payment_id = PaymentId([42; 32]); /// # let payment_hash = PaymentHash((*invoice.payment_hash()).to_byte_array()); +/// # let custom_tlvs = vec![ +/// # (343493u64, b"hello".to_vec()), +/// # ]; /// match channel_manager.pay_for_bolt11_invoice( -/// invoice, payment_id, None, route_params_config, retry +/// invoice, payment_id, None, custom_tlvs, route_params_config, retry /// ) { /// Ok(()) => println!("Sending payment with hash {}", payment_hash), /// Err(e) => println!("Failed sending payment with hash {}: {:?}", payment_hash, e), @@ -5542,7 +5545,8 @@ where /// To use default settings, call the function with [`RouteParametersConfig::default`]. pub fn pay_for_bolt11_invoice( &self, invoice: &Bolt11Invoice, payment_id: PaymentId, amount_msats: Option, - route_params_config: RouteParametersConfig, retry_strategy: Retry, + custom_tlvs: Vec<(u64, Vec)>, route_params_config: RouteParametersConfig, + retry_strategy: Retry, ) -> Result<(), Bolt11PaymentError> { let best_block_height = self.best_block.read().unwrap().height; let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); @@ -5550,6 +5554,7 @@ where invoice, payment_id, amount_msats, + custom_tlvs, route_params_config, retry_strategy, &self.router, diff --git a/lightning/src/ln/invoice_utils.rs b/lightning/src/ln/invoice_utils.rs index 425cc4d7eb6..07a024ecf3d 100644 --- a/lightning/src/ln/invoice_utils.rs +++ b/lightning/src/ln/invoice_utils.rs @@ -710,7 +710,14 @@ mod test { let retry = Retry::Attempts(0); nodes[0] .node - .pay_for_bolt11_invoice(&invoice, PaymentId([42; 32]), None, Default::default(), retry) + .pay_for_bolt11_invoice( + &invoice, + PaymentId([42; 32]), + None, + vec![], + Default::default(), + retry, + ) .unwrap(); check_added_monitors(&nodes[0], 1); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 75fe55bfeac..d03c31448ac 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -543,6 +543,8 @@ pub enum RetryableSendFailure { /// /// [`BlindedPaymentPath`]: crate::blinded_path::payment::BlindedPaymentPath OnionPacketSizeExceeded, + /// The provided [`RecipientOnionFields::custom_tlvs`] are of invalid range + InvalidCustomTlvs, } /// If a payment fails to send to a route, it can be in one of several states. This enum is returned @@ -919,6 +921,7 @@ where pub(super) fn pay_for_bolt11_invoice( &self, invoice: &Bolt11Invoice, payment_id: PaymentId, amount_msats: Option, + custom_tlvs: Vec<(u64, Vec)>, route_params_config: RouteParametersConfig, retry_strategy: Retry, router: &R, @@ -942,7 +945,9 @@ where (None, None) => return Err(Bolt11PaymentError::InvalidAmount), }; - let mut recipient_onion = RecipientOnionFields::secret_only(*invoice.payment_secret()); + let mut recipient_onion = RecipientOnionFields::secret_only(*invoice.payment_secret()) + .with_custom_tlvs(custom_tlvs) + .map_err(|_| Bolt11PaymentError::SendingFailed(RetryableSendFailure::InvalidCustomTlvs))?; recipient_onion.payment_metadata = invoice.payment_metadata().map(|v| v.clone()); let payment_params = PaymentParameters::from_bolt11_invoice(invoice) @@ -1061,6 +1066,7 @@ where RetryableSendFailure::RouteNotFound => PaymentFailureReason::RouteNotFound, RetryableSendFailure::DuplicatePayment => PaymentFailureReason::UnexpectedError, RetryableSendFailure::OnionPacketSizeExceeded => PaymentFailureReason::UnexpectedError, + RetryableSendFailure::InvalidCustomTlvs => PaymentFailureReason::UnexpectedError, }; self.abandon_payment(payment_id, reason, pending_events); return Err(Bolt12PaymentError::SendingFailed(e)); diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 6c982738a52..508cab663f9 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -5400,7 +5400,10 @@ fn max_out_mpp_path() { let id = PaymentId([42; 32]); let retry = Retry::Attempts(0); - nodes[0].node.pay_for_bolt11_invoice(&invoice, id, None, route_params_cfg, retry).unwrap(); + nodes[0] + .node + .pay_for_bolt11_invoice(&invoice, id, None, vec![], route_params_cfg, retry) + .unwrap(); assert!(nodes[0].node.list_recent_payments().len() == 1); check_added_monitors(&nodes[0], 2); // one monitor update per MPP part From ce467a4190695bc08606cae4b62f01ac18cfb563 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 6 Dec 2025 18:40:30 +0530 Subject: [PATCH 2/5] Expand test to cover Bolt11 custom TLVs Extends the payment flow test to assert that custom TLVs passed to `pay_for_bolt11_invoice` are preserved and delivered correctly. --- lightning/src/ln/invoice_utils.rs | 40 ++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/lightning/src/ln/invoice_utils.rs b/lightning/src/ln/invoice_utils.rs index 07a024ecf3d..a706d928b2d 100644 --- a/lightning/src/ln/invoice_utils.rs +++ b/lightning/src/ln/invoice_utils.rs @@ -614,6 +614,7 @@ where mod test { use super::*; use crate::chain::channelmonitor::HTLC_FAIL_BACK_BUFFER; + use crate::events::Event; use crate::ln::channelmanager::{ Bolt11InvoiceParameters, PaymentId, PhantomRouteHints, RecipientOnionFields, Retry, MIN_FINAL_CLTV_EXPIRY_DELTA, @@ -663,7 +664,7 @@ mod test { } #[test] - fn create_and_pay_for_bolt11_invoice() { + fn create_and_pay_for_bolt11_invoice_with_custom_tlvs() { 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, None]); @@ -694,6 +695,11 @@ mod test { Duration::from_secs(non_default_invoice_expiry_secs.into()) ); + let (payment_hash, payment_secret) = + (PaymentHash(invoice.payment_hash().to_byte_array()), *invoice.payment_secret()); + + let preimage = nodes[1].node.get_payment_preimage(payment_hash, payment_secret).unwrap(); + // Invoice SCIDs should always use inbound SCID aliases over the real channel ID, if one is // available. let chan = &nodes[1].node.list_usable_channels()[0]; @@ -708,13 +714,15 @@ mod test { assert_eq!(invoice.route_hints()[0].0[0].htlc_maximum_msat, chan.inbound_htlc_maximum_msat); let retry = Retry::Attempts(0); + let custom_tlvs = vec![(65537, vec![42; 42])]; + nodes[0] .node .pay_for_bolt11_invoice( &invoice, PaymentId([42; 32]), None, - vec![], + custom_tlvs.clone(), Default::default(), retry, ) @@ -725,10 +733,30 @@ mod test { assert_eq!(events.len(), 1); let payment_event = SendEvent::from_event(events.remove(0)); nodes[1].node.handle_update_add_htlc(node_a_id, &payment_event.msgs[0]); - nodes[1].node.handle_commitment_signed_batch_test(node_a_id, &payment_event.commitment_msg); - check_added_monitors(&nodes[1], 1); - let events = nodes[1].node.get_and_clear_pending_msg_events(); - assert_eq!(events.len(), 2); + check_added_monitors!(&nodes[1], 0); + do_commitment_signed_dance( + &nodes[1], + &nodes[0], + &payment_event.commitment_msg, + false, + false, + ); + expect_and_process_pending_htlcs(&nodes[1], false); + + let events = nodes[1].node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + + match events[0] { + Event::PaymentClaimable { ref onion_fields, .. } => { + assert_eq!(onion_fields.clone().unwrap().custom_tlvs().clone(), custom_tlvs); + }, + _ => panic!("Unexpected event"), + } + + claim_payment_along_route( + ClaimAlongRouteArgs::new(&nodes[0], &[&[&nodes[1]]], preimage) + .with_custom_tlvs(custom_tlvs), + ); } fn do_create_invoice_min_final_cltv_delta(with_custom_delta: bool) { From 7ce7d5e5661dcaa86b844c7b2b67628ff658bd26 Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 4 Dec 2025 19:16:33 +0530 Subject: [PATCH 3/5] Add custom TLV support to `PendingOutboundPayment` for Bolt12 flows Bolt11 payments now allow attaching custom TLVs to the onion packet, giving payers a way to pass metadata ranging from descriptions to custom authentication. Bolt12 lacked an equivalent path, leaving its outbound flow less extensible. This commit adds a `custom_tlvs` field to the Bolt12-related `PendingOutboundPayment` variants, bringing the internal Bolt12 payment state in line with the Bolt11 changes and preparing the ground for user-facing API support. A follow-up commit will expose a way for users to set these TLVs. --- lightning/src/ln/channelmanager.rs | 14 +++-- lightning/src/ln/outbound_payment.rs | 76 ++++++++++++++++------------ 2 files changed, 54 insertions(+), 36 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ad6249d272f..f7127fbd840 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12874,7 +12874,8 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { /// [Avoiding Duplicate Payments]: #avoiding-duplicate-payments pub fn create_refund_builder( &$self, amount_msats: u64, absolute_expiry: Duration, payment_id: PaymentId, - retry_strategy: Retry, route_params_config: RouteParametersConfig + custom_tlvs: Vec<(u64, Vec)>, retry_strategy: Retry, + route_params_config: RouteParametersConfig ) -> Result<$builder, Bolt12SemanticError> { let entropy = &*$self.entropy_source; @@ -12888,7 +12889,7 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { let expiration = StaleExpiration::AbsoluteTimeout(absolute_expiry); $self.pending_outbound_payments .add_new_awaiting_invoice( - payment_id, expiration, retry_strategy, route_params_config, None, + payment_id, custom_tlvs, expiration, retry_strategy, route_params_config, None, ) .map_err(|_| Bolt12SemanticError::DuplicatePaymentId)?; @@ -12915,7 +12916,7 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice pub fn create_refund_builder_using_router( &$self, router: ME, amount_msats: u64, absolute_expiry: Duration, payment_id: PaymentId, - retry_strategy: Retry, route_params_config: RouteParametersConfig + custom_tlvs: Vec<(u64, Vec)>, retry_strategy: Retry, route_params_config: RouteParametersConfig ) -> Result<$builder, Bolt12SemanticError> where ME::Target: MessageRouter, @@ -12932,7 +12933,7 @@ macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { let expiration = StaleExpiration::AbsoluteTimeout(absolute_expiry); $self.pending_outbound_payments .add_new_awaiting_invoice( - payment_id, expiration, retry_strategy, route_params_config, None, + payment_id, custom_tlvs, expiration, retry_strategy, route_params_config, None, ) .map_err(|_| Bolt12SemanticError::DuplicatePaymentId)?; @@ -13054,6 +13055,7 @@ where self.pending_outbound_payments .add_new_awaiting_invoice( payment_id, + vec![], StaleExpiration::TimerTicks(1), optional_params.retry_strategy, optional_params.route_params_config, @@ -13083,6 +13085,7 @@ where self.pending_outbound_payments .add_new_awaiting_invoice( payment_id, + vec![], StaleExpiration::TimerTicks(1), optional_params.retry_strategy, optional_params.route_params_config, @@ -13125,6 +13128,7 @@ where self.pending_outbound_payments .add_new_awaiting_invoice( payment_id, + vec![], StaleExpiration::TimerTicks(1), optional_params.retry_strategy, optional_params.route_params_config, @@ -15778,7 +15782,7 @@ where self.pay_for_offer_intern(&offer, None, Some(amt_msats), payer_note, payment_id, Some(name), |retryable_invoice_request| { self.pending_outbound_payments - .received_offer(payment_id, Some(retryable_invoice_request)) + .received_offer(payment_id, vec![], Some(retryable_invoice_request)) .map_err(|_| Bolt12SemanticError::DuplicatePaymentId) }); if offer_pay_res.is_err() { diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index d03c31448ac..72801b102f1 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -82,6 +82,7 @@ pub(crate) enum PendingOutboundPayment { retry_strategy: Retry, route_params_config: RouteParametersConfig, retryable_invoice_request: Option, + custom_tlvs: Vec<(u64, Vec)>, }, // Represents the state after the invoice has been received, transitioning from the corresponding // `AwaitingInvoice` state. @@ -93,6 +94,7 @@ pub(crate) enum PendingOutboundPayment { // race conditions where this field might be missing upon reload. It may be required // for future retries. route_params_config: RouteParametersConfig, + custom_tlvs: Vec<(u64, Vec)>, }, // This state applies when we are paying an often-offline recipient and another node on the // network served us a static invoice on the recipient's behalf in response to our invoice @@ -986,7 +988,7 @@ where SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, { - let (payment_hash, retry_strategy, params_config, _) = self + let (payment_hash, retry_strategy, params_config, custom_tlvs, _) = self .mark_invoice_received_and_get_details(invoice, payment_id)?; if invoice.invoice_features().requires_unknown_bits_from(&features) { @@ -1004,10 +1006,16 @@ where route_params.max_total_routing_fee_msat = Some(max_fee_msat); } let invoice = PaidBolt12Invoice::Bolt12Invoice(invoice.clone()); + + let recipient_onion = RecipientOnionFields::spontaneous_empty() + .with_custom_tlvs(custom_tlvs) + .map_err(|_| Bolt12PaymentError::SendingFailed(RetryableSendFailure::InvalidCustomTlvs))?; + 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, - best_block_height, pending_events, send_payment_along_path + payment_id, payment_hash, None, None, invoice, recipient_onion, + route_params, retry_strategy, false, router, first_hops, inflight_htlcs, + entropy_source, node_signer, node_id_lookup, secp_ctx, best_block_height, pending_events, + send_payment_along_path ) } @@ -1017,7 +1025,7 @@ where >( &self, payment_id: PaymentId, payment_hash: PaymentHash, keysend_preimage: Option, invoice_request: Option<&InvoiceRequest>, - bolt12_invoice: PaidBolt12Invoice, + bolt12_invoice: PaidBolt12Invoice, recipient_onion: RecipientOnionFields, mut route_params: RouteParameters, retry_strategy: Retry, hold_htlcs_at_next_hop: bool, 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, @@ -1050,11 +1058,6 @@ where } } - let recipient_onion = RecipientOnionFields { - payment_secret: None, - payment_metadata: None, - custom_tlvs: vec![], - }; let route = match self.find_initial_route( payment_id, payment_hash, &recipient_onion, keysend_preimage, invoice_request, &mut route_params, router, &first_hops, &inflight_htlcs, node_signer, best_block_height, @@ -1289,6 +1292,8 @@ where retry_strategy = Retry::Attempts(0); } + let recipient_onion = RecipientOnionFields::spontaneous_empty(); + let invoice = PaidBolt12Invoice::StaticInvoice(invoice); self.send_payment_for_bolt12_invoice_internal( payment_id, @@ -1296,6 +1301,7 @@ where Some(keysend_preimage), Some(&invoice_request), invoice, + recipient_onion, route_params, retry_strategy, hold_htlcs_at_next_hop, @@ -1980,7 +1986,8 @@ where #[cfg(feature = "dnssec")] #[rustfmt::skip] pub(super) fn received_offer( - &self, payment_id: PaymentId, retryable_invoice_request: Option, + &self, payment_id: PaymentId, custom_tlvs: Vec<(u64, Vec)>, + retryable_invoice_request: Option, ) -> Result<(), ()> { match self.pending_outbound_payments.lock().unwrap().entry(payment_id) { hash_map::Entry::Occupied(entry) => match entry.get() { @@ -1992,6 +1999,7 @@ where retry_strategy: *retry_strategy, route_params_config: *route_params_config, retryable_invoice_request, + custom_tlvs, }; core::mem::swap(&mut new_val, entry.into_mut()); Ok(()) @@ -2003,7 +2011,8 @@ where } pub(super) fn add_new_awaiting_invoice( - &self, payment_id: PaymentId, expiration: StaleExpiration, retry_strategy: Retry, + &self, payment_id: PaymentId, custom_tlvs: Vec<(u64, Vec)>, + expiration: StaleExpiration, retry_strategy: Retry, route_params_config: RouteParametersConfig, retryable_invoice_request: Option, ) -> Result<(), ()> { @@ -2019,6 +2028,7 @@ where retry_strategy, route_params_config, retryable_invoice_request, + custom_tlvs, }); Ok(()) @@ -2031,7 +2041,7 @@ where &self, invoice: &Bolt12Invoice, payment_id: PaymentId ) -> Result<(), Bolt12PaymentError> { self.mark_invoice_received_and_get_details(invoice, payment_id) - .and_then(|(_, _, _, is_newly_marked)| { + .and_then(|(_, _, _, _, is_newly_marked)| { is_newly_marked .then_some(()) .ok_or(Bolt12PaymentError::DuplicateInvoice) @@ -2040,32 +2050,34 @@ where #[rustfmt::skip] fn mark_invoice_received_and_get_details( - &self, invoice: &Bolt12Invoice, payment_id: PaymentId - ) -> Result<(PaymentHash, Retry, RouteParametersConfig, bool), Bolt12PaymentError> { + &self, invoice: &Bolt12Invoice, payment_id: PaymentId, + ) -> Result<(PaymentHash, Retry, RouteParametersConfig, Vec<(u64, Vec)>, bool), Bolt12PaymentError> { match self.pending_outbound_payments.lock().unwrap().entry(payment_id) { - hash_map::Entry::Occupied(entry) => match entry.get() { + hash_map::Entry::Occupied(mut entry) => match entry.get_mut() { PendingOutboundPayment::AwaitingInvoice { - retry_strategy: retry, route_params_config, .. + retry_strategy: retry, route_params_config, custom_tlvs, .. } => { let payment_hash = invoice.payment_hash(); let retry = *retry; let config = *route_params_config; - *entry.into_mut() = PendingOutboundPayment::InvoiceReceived { + let custom = core::mem::take(custom_tlvs); + *entry.get_mut() = PendingOutboundPayment::InvoiceReceived { payment_hash, retry_strategy: retry, route_params_config: config, + custom_tlvs: custom.clone(), }; - Ok((payment_hash, retry, config, true)) + Ok((payment_hash, retry, config, custom, true)) }, // When manual invoice handling is enabled, the corresponding `PendingOutboundPayment` entry // is already updated at the time the invoice is received. This ensures that `InvoiceReceived` // event generation remains idempotent, even if the same invoice is received again before the // event is handled by the user. PendingOutboundPayment::InvoiceReceived { - retry_strategy, route_params_config, .. + retry_strategy, route_params_config, custom_tlvs, .. } => { - Ok((invoice.payment_hash(), *retry_strategy, *route_params_config, false)) + Ok((invoice.payment_hash(), *retry_strategy, *route_params_config, custom_tlvs.clone(), false)) }, _ => Err(Bolt12PaymentError::DuplicateInvoice), }, @@ -2757,6 +2769,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, |fee_msat| RouteParametersConfig::default().with_max_total_routing_fee_msat(fee_msat) ) ))), + (9, custom_tlvs, optional_vec), }, (7, InvoiceReceived) => { (0, payment_hash, required), @@ -2773,6 +2786,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, |fee_msat| RouteParametersConfig::default().with_max_total_routing_fee_msat(fee_msat) ) ))), + (7, custom_tlvs, optional_vec), }, // Added in 0.1. Prior versions will drop these outbounds on downgrade, which is safe because no // HTLCs are in-flight. @@ -3053,7 +3067,7 @@ mod tests { assert!(!outbound_payments.has_pending_payments()); assert!( outbound_payments.add_new_awaiting_invoice( - payment_id, expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, + payment_id, vec![], expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, ).is_ok() ); assert!(outbound_payments.has_pending_payments()); @@ -3083,14 +3097,14 @@ mod tests { assert!( outbound_payments.add_new_awaiting_invoice( - payment_id, expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, + payment_id, vec![], expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, ).is_ok() ); assert!(outbound_payments.has_pending_payments()); assert!( outbound_payments.add_new_awaiting_invoice( - payment_id, expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, + payment_id, vec![], expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, ).is_err() ); } @@ -3108,7 +3122,7 @@ mod tests { assert!(!outbound_payments.has_pending_payments()); assert!( outbound_payments.add_new_awaiting_invoice( - payment_id, expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, + payment_id, vec![], expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, ).is_ok() ); assert!(outbound_payments.has_pending_payments()); @@ -3138,14 +3152,14 @@ mod tests { assert!( outbound_payments.add_new_awaiting_invoice( - payment_id, expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, + payment_id, vec![], expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, ).is_ok() ); assert!(outbound_payments.has_pending_payments()); assert!( outbound_payments.add_new_awaiting_invoice( - payment_id, expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, + payment_id, vec![], expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, ).is_err() ); } @@ -3162,7 +3176,7 @@ mod tests { assert!(!outbound_payments.has_pending_payments()); assert!( outbound_payments.add_new_awaiting_invoice( - payment_id, expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, + payment_id, vec![], expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, ).is_ok() ); assert!(outbound_payments.has_pending_payments()); @@ -3201,7 +3215,7 @@ mod tests { assert!( outbound_payments.add_new_awaiting_invoice( - payment_id, expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, + payment_id, vec![], expiration, Retry::Attempts(0), RouteParametersConfig::default(), None, ).is_ok() ); assert!(outbound_payments.has_pending_payments()); @@ -3267,7 +3281,7 @@ mod tests { assert!( outbound_payments.add_new_awaiting_invoice( - payment_id, expiration, Retry::Attempts(0), + payment_id, vec![], expiration, Retry::Attempts(0), route_params_config, None, ).is_ok() ); @@ -3370,7 +3384,7 @@ mod tests { assert!( outbound_payments.add_new_awaiting_invoice( - payment_id, expiration, Retry::Attempts(0), route_params_config, None, + payment_id, vec![], expiration, Retry::Attempts(0), route_params_config, None, ).is_ok() ); assert!(outbound_payments.has_pending_payments()); From 5c505d4859e745fd50076ad242e69944c73d9b51 Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 5 Dec 2025 23:14:53 +0530 Subject: [PATCH 4/5] Introduce `custom_tlvs` field in payer-facing functions With internal support for custom TLVs now in place, this commit extends that capability to all payer-facing entry points, from `create_refund_builder` to the various `pay_for_offer` functions. This brings the full Bolt12 payer API in line with the Bolt11 flow and ensures that callers can attach custom metadata consistently across all payment flows. --- lightning/src/ln/async_payments_tests.rs | 82 +++++++++++++++---- lightning/src/ln/channelmanager.rs | 27 +++--- .../src/ln/max_payment_path_len_tests.rs | 8 +- lightning/src/ln/offers_tests.rs | 80 +++++++++--------- 4 files changed, 129 insertions(+), 68 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 8e7fbdf94fd..01c3e0c7ef1 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -547,7 +547,10 @@ fn build_async_offer_and_init_payment( let offer = recipient.node.get_async_receive_offer().unwrap(); let payment_id = PaymentId([1; 32]); - sender.node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + sender + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); // Forward invreq to server, pass static invoice back let (peer_id, invreq_om) = extract_invoice_request_om(sender, &[sender_lsp, invoice_server]); @@ -705,7 +708,10 @@ fn static_invoice_unknown_required_features() { // unknown required features. let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + nodes[0] + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); // Don't forward the invreq since the invoice was created outside of the normal flow, instead // manually construct the response. @@ -777,7 +783,10 @@ fn ignore_unexpected_static_invoice() { let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + nodes[0] + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); let invreq_om = nodes[0] .onion_messenger @@ -906,7 +915,10 @@ fn ignore_duplicate_invoice() { let offer = async_recipient.node.get_async_receive_offer().unwrap(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - sender.node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + sender + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); let sender_node_id = sender.node.get_our_node_id(); let always_online_node_id = always_online_node.node.get_our_node_id(); @@ -1004,7 +1016,10 @@ fn ignore_duplicate_invoice() { // Now handle case where the sender pays regular invoice and ignores static invoice. let payment_id = PaymentId([2; 32]); - sender.node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + sender + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); let invreq_om = sender.onion_messenger.next_onion_message_for_peer(always_online_node_id).unwrap(); @@ -1110,7 +1125,10 @@ fn async_receive_flow_success() { let offer = nodes[2].node.get_async_receive_offer().unwrap(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + nodes[0] + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); let release_held_htlc_om = pass_async_payments_oms( static_invoice.clone(), &nodes[0], @@ -1170,7 +1188,10 @@ fn expired_static_invoice_fail() { let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + nodes[0] + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); let invreq_om = nodes[0] .onion_messenger @@ -1253,7 +1274,10 @@ fn timeout_unreleased_payment() { let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - sender.node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + sender + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); let invreq_om = sender.onion_messenger.next_onion_message_for_peer(server.node.get_our_node_id()).unwrap(); @@ -1347,7 +1371,10 @@ fn async_receive_mpp() { let amt_msat = 15_000_000; let payment_id = PaymentId([1; 32]); - nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + nodes[0] + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); let release_held_htlc_om_3_0 = pass_async_payments_oms( static_invoice, &nodes[0], @@ -1445,7 +1472,10 @@ fn amount_doesnt_match_invreq() { let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + nodes[0] + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); let release_held_htlc_om_3_0 = pass_async_payments_oms( static_invoice, &nodes[0], @@ -1688,7 +1718,10 @@ fn invalid_async_receive_with_retry( let static_invoice = invoice_flow_res.invoice; let offer = nodes[2].node.get_async_receive_offer().unwrap(); - nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + nodes[0] + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); let release_held_htlc_om_2_0 = pass_async_payments_oms( static_invoice, &nodes[0], @@ -1782,7 +1815,10 @@ fn expired_static_invoice_message_path() { let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + nodes[0] + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); // While the invoice is unexpired, respond with release_held_htlc. let (held_htlc_available_om, _release_held_htlc_om) = pass_async_payments_oms( @@ -1897,7 +1933,7 @@ fn expired_static_invoice_payment_path() { let payment_id = PaymentId([1; 32]); let mut params: OptionalOfferPaymentParams = Default::default(); params.retry_strategy = Retry::Attempts(0); - nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, params).unwrap(); + nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], params).unwrap(); let release_held_htlc_om = pass_async_payments_oms( static_invoice, &nodes[0], @@ -2341,7 +2377,10 @@ fn refresh_static_invoices_for_used_offers() { let offer = recipient.node.get_async_receive_offer().unwrap(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - sender.node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + sender + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); let release_held_htlc_om = pass_async_payments_oms( updated_invoice.clone(), @@ -2673,7 +2712,10 @@ fn invoice_server_is_not_channel_peer() { let offer = recipient.node.get_async_receive_offer().unwrap(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - sender.node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + sender + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); // Do the held_htlc_available --> release_held_htlc dance. let release_held_htlc_om = pass_async_payments_oms( @@ -2735,7 +2777,10 @@ fn invoice_request_forwarded_to_async_recipient() { let offer = async_recipient.node.get_async_receive_offer().unwrap(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - sender.node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + sender + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); let sender_node_id = sender.node.get_our_node_id(); @@ -2836,7 +2881,10 @@ fn async_payment_e2e() { let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - sender.node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + sender + .node + .pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()) + .unwrap(); // Forward invreq to server, pass static invoice back, check that htlc was locked in/monitor was // added diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f7127fbd840..0b0c553a73e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -2374,9 +2374,12 @@ where /// # channel_manager: T, offer: &Offer, amount_msats: Option, /// # ) { /// # let channel_manager = channel_manager.get_cm(); -/// let payment_id = PaymentId([42; 32]); +/// # let payment_id = PaymentId([42; 32]); +/// # let custom_tlvs = vec![ +/// # (343493u64, b"hello".to_vec()), +/// # ]; /// match channel_manager.pay_for_offer( -/// offer, amount_msats, payment_id, Default::default(), +/// offer, amount_msats, payment_id, custom_tlvs, Default::default(), /// ) { /// Ok(()) => println!("Requesting invoice for offer"), /// Err(e) => println!("Unable to request invoice for offer: {:?}", e), @@ -2431,10 +2434,13 @@ where /// # route_params_config: RouteParametersConfig /// # ) -> Result<(), Bolt12SemanticError> { /// # let channel_manager = channel_manager.get_cm(); -/// let payment_id = PaymentId([42; 32]); +/// # let payment_id = PaymentId([42; 32]); +/// # let custom_tlvs = vec![ +/// # (343493u64, b"hello".to_vec()), +/// # ]; /// let refund = channel_manager /// .create_refund_builder( -/// amount_msats, absolute_expiry, payment_id, retry, route_params_config +/// amount_msats, absolute_expiry, payment_id, custom_tlvs, retry, route_params_config /// )? /// # ; /// # // Needed for compiling for c_bindings @@ -13049,13 +13055,13 @@ where /// [Avoiding Duplicate Payments]: #avoiding-duplicate-payments pub fn pay_for_offer( &self, offer: &Offer, amount_msats: Option, payment_id: PaymentId, - optional_params: OptionalOfferPaymentParams, + custom_tlvs: Vec<(u64, Vec)>, optional_params: OptionalOfferPaymentParams, ) -> Result<(), Bolt12SemanticError> { let create_pending_payment_fn = |retryable_invoice_request: RetryableInvoiceRequest| { self.pending_outbound_payments .add_new_awaiting_invoice( payment_id, - vec![], + custom_tlvs, StaleExpiration::TimerTicks(1), optional_params.retry_strategy, optional_params.route_params_config, @@ -13079,13 +13085,13 @@ where /// identical to [`Self::pay_for_offer`]. pub fn pay_for_offer_from_hrn( &self, offer: &OfferFromHrn, amount_msats: u64, payment_id: PaymentId, - optional_params: OptionalOfferPaymentParams, + custom_tlvs: Vec<(u64, Vec)>, optional_params: OptionalOfferPaymentParams, ) -> Result<(), Bolt12SemanticError> { let create_pending_payment_fn = |retryable_invoice_request: RetryableInvoiceRequest| { self.pending_outbound_payments .add_new_awaiting_invoice( payment_id, - vec![], + custom_tlvs, StaleExpiration::TimerTicks(1), optional_params.retry_strategy, optional_params.route_params_config, @@ -13122,13 +13128,14 @@ where /// [`InvoiceRequest::quantity`]: crate::offers::invoice_request::InvoiceRequest::quantity pub fn pay_for_offer_with_quantity( &self, offer: &Offer, amount_msats: Option, payment_id: PaymentId, - optional_params: OptionalOfferPaymentParams, quantity: u64, + custom_tlvs: Vec<(u64, Vec)>, optional_params: OptionalOfferPaymentParams, + quantity: u64, ) -> Result<(), Bolt12SemanticError> { let create_pending_payment_fn = |retryable_invoice_request: RetryableInvoiceRequest| { self.pending_outbound_payments .add_new_awaiting_invoice( payment_id, - vec![], + custom_tlvs, StaleExpiration::TimerTicks(1), optional_params.retry_strategy, optional_params.route_params_config, diff --git a/lightning/src/ln/max_payment_path_len_tests.rs b/lightning/src/ln/max_payment_path_len_tests.rs index f67ad442c29..c49f0c636c7 100644 --- a/lightning/src/ln/max_payment_path_len_tests.rs +++ b/lightning/src/ln/max_payment_path_len_tests.rs @@ -519,7 +519,13 @@ fn bolt12_invoice_too_large_blinded_paths() { let payment_id = PaymentId([1; 32]); nodes[0] .node - .pay_for_offer(&offer, Some(5000), payment_id, OptionalOfferPaymentParams::default()) + .pay_for_offer( + &offer, + Some(5000), + payment_id, + vec![], + OptionalOfferPaymentParams::default(), + ) .unwrap(); let invreq_om = nodes[0] .onion_messenger diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 4c53aefe58d..e7b2b4b453c 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -306,7 +306,7 @@ fn create_refund_with_no_blinded_path() { let router = NullMessageRouter {}; let refund = alice.node - .create_refund_builder_using_router(&router, 10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder_using_router(&router, 10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); assert_eq!(refund.amount_msats(), 10_000_000); @@ -473,7 +473,7 @@ fn check_dummy_hop_pattern_in_offer() { } let payment_id = PaymentId([1; 32]); - bob.node.pay_for_offer(&compact_offer, None, payment_id, Default::default()).unwrap(); + bob.node.pay_for_offer(&compact_offer, None, payment_id, vec![], Default::default()).unwrap(); let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); @@ -495,7 +495,7 @@ fn check_dummy_hop_pattern_in_offer() { assert!(padded_offer.paths().iter().all(|path| path.blinded_hops().len() == PADDED_PATH_LENGTH)); let payment_id = PaymentId([2; 32]); - bob.node.pay_for_offer(&padded_offer, None, payment_id, Default::default()).unwrap(); + bob.node.pay_for_offer(&padded_offer, None, payment_id, vec![], Default::default()).unwrap(); let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); @@ -571,7 +571,7 @@ fn creates_short_lived_refund() { let absolute_expiry = bob.node.duration_since_epoch() + MAX_SHORT_LIVED_RELATIVE_EXPIRY; let payment_id = PaymentId([1; 32]); let refund = bob.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); assert_eq!(refund.absolute_expiry(), Some(absolute_expiry)); @@ -602,7 +602,7 @@ fn creates_long_lived_refund() { let router = NodeIdMessageRouter::new(bob.network_graph, bob.keys_manager); let refund = bob.node - .create_refund_builder_using_router(&router, 10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder_using_router(&router, 10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); assert_eq!(refund.absolute_expiry(), Some(absolute_expiry)); @@ -662,7 +662,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { } let payment_id = PaymentId([1; 32]); - david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + david.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()).unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); connect_peers(david, bob); @@ -758,7 +758,7 @@ fn creates_and_pays_for_refund_using_two_hop_blinded_path() { let absolute_expiry = Duration::from_secs(u64::MAX); let payment_id = PaymentId([1; 32]); let refund = david.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); assert_eq!(refund.amount_msats(), 10_000_000); @@ -827,7 +827,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { } let payment_id = PaymentId([1; 32]); - bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + bob.node.pay_for_offer(&offer, None, payment_id, vec![], 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(); @@ -886,7 +886,7 @@ fn creates_and_pays_for_refund_using_one_hop_blinded_path() { let absolute_expiry = Duration::from_secs(u64::MAX); let payment_id = PaymentId([1; 32]); let refund = bob.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); assert_eq!(refund.amount_msats(), 10_000_000); @@ -948,7 +948,7 @@ fn pays_for_offer_without_blinded_paths() { assert!(offer.paths().is_empty()); let payment_id = PaymentId([1; 32]); - bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + bob.node.pay_for_offer(&offer, None, payment_id, vec![], 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(); @@ -995,7 +995,7 @@ fn pays_for_refund_without_blinded_paths() { let absolute_expiry = Duration::from_secs(u64::MAX); let payment_id = PaymentId([1; 32]); let refund = bob.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .clear_paths() .build().unwrap(); @@ -1075,7 +1075,7 @@ fn send_invoice_requests_with_distinct_reply_path() { } let payment_id = PaymentId([1; 32]); - david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + david.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()).unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); connect_peers(david, bob); @@ -1149,7 +1149,7 @@ fn send_invoice_for_refund_with_distinct_reply_path() { let absolute_expiry = Duration::from_secs(u64::MAX); let payment_id = PaymentId([1; 32]); let refund = alice.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); assert_ne!(refund.payer_signing_pubkey(), alice_id); @@ -1208,7 +1208,7 @@ fn creates_and_pays_for_offer_with_retry() { assert!(check_compact_path_introduction_node(&path, bob, alice_id)); } let payment_id = PaymentId([1; 32]); - bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + bob.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()).unwrap(); expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); let _lost_onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); @@ -1280,7 +1280,7 @@ fn pays_bolt12_invoice_asynchronously() { .build().unwrap(); let payment_id = PaymentId([1; 32]); - bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + bob.node.pay_for_offer(&offer, None, payment_id, vec![], 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(); @@ -1377,7 +1377,7 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { } let payment_id = PaymentId([1; 32]); - bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + bob.node.pay_for_offer(&offer, None, payment_id, vec![], 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(); @@ -1435,7 +1435,7 @@ fn creates_refund_with_blinded_path_using_unannounced_introduction_node() { let absolute_expiry = Duration::from_secs(u64::MAX); let payment_id = PaymentId([1; 32]); let refund = bob.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); assert_ne!(refund.payer_signing_pubkey(), bob_id); @@ -1518,7 +1518,7 @@ fn fails_authentication_when_handling_invoice_request() { // Send the invoice request directly to Alice instead of using a blinded path. let payment_id = PaymentId([1; 32]); - david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + david.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()).unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); connect_peers(david, alice); @@ -1543,7 +1543,7 @@ fn fails_authentication_when_handling_invoice_request() { // Send the invoice request to Alice using an invalid blinded path. let payment_id = PaymentId([2; 32]); - david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + david.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()).unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); match &mut david.node.flow.pending_offers_messages.lock().unwrap().first_mut().unwrap().1 { @@ -1619,7 +1619,7 @@ fn fails_authentication_when_handling_invoice_for_offer() { // Initiate an invoice request, but abandon tracking it. let payment_id = PaymentId([1; 32]); - david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + david.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()).unwrap(); david.node.abandon_payment(payment_id); get_event!(david, Event::PaymentFailed); @@ -1635,7 +1635,7 @@ fn fails_authentication_when_handling_invoice_for_offer() { }; let payment_id = PaymentId([2; 32]); - david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + david.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()).unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); // Swap out the reply path to force authentication to fail when handling the invoice since it @@ -1714,7 +1714,7 @@ fn fails_authentication_when_handling_invoice_for_refund() { let absolute_expiry = Duration::from_secs(u64::MAX); let payment_id = PaymentId([1; 32]); let refund = david.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); assert_ne!(refund.payer_signing_pubkey(), david_id); @@ -1748,7 +1748,7 @@ fn fails_authentication_when_handling_invoice_for_refund() { let invalid_path = refund.paths().first().unwrap().clone(); let payment_id = PaymentId([2; 32]); let refund = david.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); assert_ne!(refund.payer_signing_pubkey(), david_id); @@ -1819,7 +1819,7 @@ fn fails_creating_or_paying_for_offer_without_connected_peers() { let payment_id = PaymentId([1; 32]); - match david.node.pay_for_offer(&offer, None, payment_id, Default::default()) { + match david.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()) { Ok(_) => panic!("Expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::MissingPaths), } @@ -1830,7 +1830,7 @@ fn fails_creating_or_paying_for_offer_without_connected_peers() { args.send_channel_ready = (true, true); reconnect_nodes(args); - assert!(david.node.pay_for_offer(&offer, None, payment_id, Default::default()).is_ok()); + assert!(david.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()).is_ok()); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); } @@ -1871,7 +1871,7 @@ fn fails_creating_refund_or_sending_invoice_without_connected_peers() { let absolute_expiry = david.node.duration_since_epoch() + MAX_SHORT_LIVED_RELATIVE_EXPIRY; let payment_id = PaymentId([1; 32]); match david.node.create_refund_builder( - 10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default() + 10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default() ) { Ok(_) => panic!("Expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::MissingPaths), @@ -1882,7 +1882,7 @@ fn fails_creating_refund_or_sending_invoice_without_connected_peers() { reconnect_nodes(args); let refund = david.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); @@ -1917,7 +1917,7 @@ fn fails_creating_invoice_request_for_unsupported_chain() { .chain(Network::Signet) .build().unwrap(); - match bob.node.pay_for_offer(&offer, None, PaymentId([1; 32]), Default::default()) { + match bob.node.pay_for_offer(&offer, None, PaymentId([1; 32]), vec![], Default::default()) { Ok(_) => panic!("Expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::UnsupportedChain), } @@ -1939,7 +1939,7 @@ fn fails_sending_invoice_with_unsupported_chain_for_refund() { let absolute_expiry = Duration::from_secs(u64::MAX); let payment_id = PaymentId([1; 32]); let refund = bob.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .chain(Network::Signet) .build().unwrap(); @@ -1974,7 +1974,7 @@ fn fails_creating_invoice_request_without_blinded_reply_path() { .amount_msats(10_000_000) .build().unwrap(); - match david.node.pay_for_offer(&offer, None, PaymentId([1; 32]), Default::default()) { + match david.node.pay_for_offer(&offer, None, PaymentId([1; 32]), vec![], Default::default()) { Ok(_) => panic!("Expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::MissingPaths), } @@ -2007,10 +2007,10 @@ fn fails_creating_invoice_request_with_duplicate_payment_id() { .build().unwrap(); let payment_id = PaymentId([1; 32]); - assert!(david.node.pay_for_offer( &offer, None, payment_id, Default::default()).is_ok()); + assert!(david.node.pay_for_offer( &offer, None, payment_id, vec![], Default::default()).is_ok()); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); - match david.node.pay_for_offer(&offer, None, payment_id, Default::default()) { + match david.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()) { Ok(_) => panic!("Expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::DuplicatePaymentId), } @@ -2031,13 +2031,13 @@ fn fails_creating_refund_with_duplicate_payment_id() { let payment_id = PaymentId([1; 32]); assert!( nodes[0].node.create_refund_builder( - 10_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default() + 10_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default() ).is_ok() ); expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); match nodes[0].node.create_refund_builder( - 10_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default() + 10_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default() ) { Ok(_) => panic!("Expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::DuplicatePaymentId), @@ -2089,7 +2089,7 @@ fn fails_sending_invoice_without_blinded_payment_paths_for_offer() { .build().unwrap(); let payment_id = PaymentId([1; 32]); - david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + david.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()).unwrap(); connect_peers(david, bob); @@ -2156,7 +2156,7 @@ fn fails_sending_invoice_without_blinded_payment_paths_for_refund() { let absolute_expiry = Duration::from_secs(u64::MAX); let payment_id = PaymentId([1; 32]); let refund = david.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); @@ -2205,7 +2205,7 @@ fn fails_paying_invoice_more_than_once() { let absolute_expiry = Duration::from_secs(u64::MAX); let payment_id = PaymentId([1; 32]); let refund = david.node - .create_refund_builder(10_000_000, absolute_expiry, payment_id, Retry::Attempts(0), RouteParametersConfig::default()) + .create_refund_builder(10_000_000, absolute_expiry, payment_id, vec![], Retry::Attempts(0), RouteParametersConfig::default()) .unwrap() .build().unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); @@ -2297,7 +2297,7 @@ fn fails_paying_invoice_with_unknown_required_features() { .build().unwrap(); let payment_id = PaymentId([1; 32]); - david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + david.node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()).unwrap(); connect_peers(david, bob); @@ -2381,7 +2381,7 @@ fn rejects_keysend_to_non_static_invoice_path() { let offer = nodes[1].node.create_offer_builder().unwrap().build().unwrap(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); - nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); + nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, vec![], Default::default()).unwrap(); let invreq_om = nodes[0].onion_messenger.next_onion_message_for_peer(nodes[1].node.get_our_node_id()).unwrap(); nodes[1].onion_messenger.handle_onion_message(nodes[0].node.get_our_node_id(), &invreq_om); let invoice_om = nodes[1].onion_messenger.next_onion_message_for_peer(nodes[0].node.get_our_node_id()).unwrap(); @@ -2466,7 +2466,7 @@ fn no_double_pay_with_stale_channelmanager() { assert!(offer.paths().is_empty()); let payment_id = PaymentId([1; 32]); - nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + nodes[0].node.pay_for_offer(&offer, None, payment_id, vec![], Default::default()).unwrap(); expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); let invreq_om = nodes[0].onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); From ef6edeccbc0cfc413290e72dc6067df19ffd12c6 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 6 Dec 2025 18:20:41 +0530 Subject: [PATCH 5/5] Introduce Bolt12 custom TLVs test Adds an end-to-end test validating that custom TLVs propagate correctly through the Bolt12 payer flow. --- lightning/src/ln/offers_tests.rs | 131 ++++++++++++++++++++++++++++++- 1 file changed, 130 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index e7b2b4b453c..5d8f9a7e387 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -165,6 +165,12 @@ fn check_compact_path_introduction_node<'a, 'b, 'c>( fn route_bolt12_payment<'a, 'b, 'c>( node: &Node<'a, 'b, 'c>, path: &[&Node<'a, 'b, 'c>], invoice: &Bolt12Invoice +) { + route_bolt12_payment_with_custom_tlvs(node, path, invoice, Vec::new()); +} + +fn route_bolt12_payment_with_custom_tlvs<'a, 'b, 'c>( + node: &Node<'a, 'b, 'c>, path: &[&Node<'a, 'b, 'c>], invoice: &Bolt12Invoice, custom_tlvs: Vec<(u64, Vec)> ) { // Monitor added when handling the invoice onion message. check_added_monitors(node, 1); @@ -178,7 +184,8 @@ fn route_bolt12_payment<'a, 'b, 'c>( let amount_msats = invoice.amount_msats(); let payment_hash = invoice.payment_hash(); let args = PassAlongPathArgs::new(node, path, amount_msats, payment_hash, ev) - .without_clearing_recipient_events(); + .without_clearing_recipient_events() + .with_custom_tlvs(custom_tlvs); do_pass_along_path(args); } @@ -1348,6 +1355,128 @@ fn pays_bolt12_invoice_asynchronously() { ); } +/// Checks that a deferred invoice can be paid asynchronously from an Event::InvoiceReceived. +#[test] +fn pays_bolt12_invoice_asynchronously_with_custom_tlvs() { + 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 bob = &nodes[1]; + let alice_id = alice.node.get_our_node_id(); + let bob_id = bob.node.get_our_node_id(); + + let offer = alice.node + .create_offer_builder().unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + const CUSTOM_TLV_TYPE: u64 = 65537; + let custom_tlvs = vec![(CUSTOM_TLV_TYPE, vec![42; 42])]; + let payment_id = PaymentId([1; 32]); + + bob.node + .pay_for_offer(&offer, None, payment_id, custom_tlvs.clone(), 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); + + let (invoice_request, _) = extract_invoice_request(alice, &onion_message); + let expected_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); + + // Re-process the same onion message to ensure idempotency — + // we should not generate a duplicate `InvoiceReceived` event. + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let mut events = bob.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + + let (invoice, context) = match events.pop().unwrap() { + Event::InvoiceReceived { payment_id: actual, invoice, context, .. } => { + assert_eq!(actual, payment_id); + (invoice, context) + }, + _ => panic!("No Event::InvoiceReceived"), + }; + + assert_eq!(invoice.amount_msats(), 10_000_000); + assert_ne!(invoice.signing_pubkey(), alice_id); + assert!(!invoice.payment_paths().is_empty()); + for path in invoice.payment_paths() { + assert_eq!(path.introduction_node(), &IntroductionNode::NodeId(alice_id)); + } + + assert!(bob.node.send_payment_for_bolt12_invoice(&invoice, context.as_ref()).is_ok()); + assert_eq!( + bob.node.send_payment_for_bolt12_invoice(&invoice, context.as_ref()), + Err(Bolt12PaymentError::DuplicateInvoice), + ); + + route_bolt12_payment_with_custom_tlvs(bob, &[alice], &invoice, custom_tlvs.clone()); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + let purpose = match get_event!(&alice, Event::PaymentClaimable) { + Event::PaymentClaimable { purpose, .. } => purpose, + _ => panic!("No Event::PaymentClaimable"), + }; + let payment_preimage = purpose.preimage().expect("No preimage in Event::PaymentClaimable"); + + match purpose { + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { + assert_eq!(PaymentContext::Bolt12Offer(payment_context), expected_payment_context); + }, + _ => panic!("Unexpected payment purpose: {:?}", purpose), + } + + let route = &[&[alice] as &[&Node]]; + + let claim_payment_args = + ClaimAlongRouteArgs::new(bob, route, payment_preimage) + .with_custom_tlvs(custom_tlvs); + + if let Some(inv) = claim_payment_along_route(claim_payment_args).0 { + assert_eq!(inv, PaidBolt12Invoice::Bolt12Invoice(invoice.clone())); + } else { + panic!("Expected PaidBolt12Invoice::Bolt12Invoice"); + } + + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); + + assert_eq!( + bob.node.send_payment_for_bolt12_invoice(&invoice, context.as_ref()), + Err(Bolt12PaymentError::DuplicateInvoice), + ); + + for _ in 0..=IDEMPOTENCY_TIMEOUT_TICKS { + bob.node.timer_tick_occurred(); + } + + assert_eq!( + bob.node.send_payment_for_bolt12_invoice(&invoice, context.as_ref()), + Err(Bolt12PaymentError::UnexpectedInvoice), + ); +} + /// Checks that an offer can be created using an unannounced node as a blinded path's introduction /// node. This is only preferred if there are no other options which may indicated either the offer /// is intended for the unannounced node or that the node is actually announced (e.g., an LSP) but