From 3ed36a7b5293d27e067ed7f2b8c9f0d894efc4ea Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 14 Jul 2025 19:56:34 +0530 Subject: [PATCH 1/7] Introduce CurrencyConversion trait Adds the `CurrencyConversion` trait to allow users to define custom logic for converting fiat amounts into millisatoshis (msat). This abstraction lays the groundwork for supporting Offers denominated in fiat currencies, where conversion is inherently context-dependent. --- lightning/src/offers/invoice_request.rs | 33 +++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 4311d194dca..401dd8b63d5 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -77,8 +77,9 @@ use crate::offers::merkle::{ }; use crate::offers::nonce::Nonce; use crate::offers::offer::{ - Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, Offer, OfferContents, - OfferId, OfferTlvStream, OfferTlvStreamRef, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, + Amount, CurrencyCode, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, Offer, + OfferContents, OfferId, OfferTlvStream, OfferTlvStreamRef, EXPERIMENTAL_OFFER_TYPES, + OFFER_TYPES, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; use crate::offers::payer::{PayerContents, PayerTlvStream, PayerTlvStreamRef}; @@ -574,6 +575,34 @@ impl AsRef for UnsignedInvoiceRequest { } } +/// A trait for converting fiat currencies into millisatoshis (msats). +/// +/// Implementations must return the conversion rate in **msats per minor unit** of the currency, +/// where the minor unit is determined by its ISO-4217 exponent: +/// - USD (exponent 2) → per **cent** (0.01 USD), not per dollar. +/// - JPY (exponent 0) → per **yen**. +/// - KWD (exponent 3) → per **fils** (0.001 KWD). +/// +/// # Caution +/// +/// Returning msats per major unit will be off by a factor of 10^exponent (e.g. 100× for USD). +/// +/// This convention ensures amounts remain precise and purely integer-based when parsing and +/// validating BOLT12 invoice requests. +pub trait CurrencyConversion { + /// Converts a fiat currency specified by its ISO-4217 code into **msats per minor unit**. + fn fiat_to_msats(&self, iso4217_code: CurrencyCode) -> Result; +} + +/// A default implementation of the `CurrencyConversion` trait that does not support any currency conversions. +pub struct DefaultCurrencyConversion; + +impl CurrencyConversion for DefaultCurrencyConversion { + fn fiat_to_msats(&self, _iso4217_code: CurrencyCode) -> Result { + Err(()) + } +} + /// An `InvoiceRequest` is a request for a [`Bolt12Invoice`] formulated from an [`Offer`]. /// /// An offer may provide choices such as quantity, amount, chain, features, etc. An invoice request From 8d45f0af7f6b6ef942edfeb9ab4b1f96339368fd Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 14 Jul 2025 20:07:07 +0530 Subject: [PATCH 2/7] Integrate CurrencyConversion into Bolt12Invoice amount handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit updates the Bolt12Invoice amount creation logic to utilize the `CurrencyConversion` trait, enabling more flexible and customizable handling of fiat-to-msat conversions. Reasoning The `CurrencyConversion` trait is passed upstream into the invoice's amount creation flow, where it is used to interpret the Offer’s currency amount (if present) into millisatoshis. This change establishes a unified mechanism for amount handling—regardless of whether the Offer’s amount is denominated in Bitcoin or fiat, or whether the InvoiceRequest specifies an amount or not. --- fuzz/src/invoice_request_deser.rs | 16 ++- lightning/src/ln/channelmanager.rs | 7 +- lightning/src/ln/offers_tests.rs | 4 +- lightning/src/ln/outbound_payment.rs | 16 ++- lightning/src/offers/flow.rs | 35 +++-- lightning/src/offers/invoice.rs | 176 ++++++++++++++---------- lightning/src/offers/invoice_request.rs | 99 ++++++++++--- lightning/src/offers/offer.rs | 20 +++ 8 files changed, 259 insertions(+), 114 deletions(-) diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index a21303debd7..73875d4493e 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -17,8 +17,10 @@ use lightning::blinded_path::payment::{ use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning::ln::inbound_payment::ExpandedKey; use lightning::offers::invoice::UnsignedBolt12Invoice; -use lightning::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields}; -use lightning::offers::offer::OfferId; +use lightning::offers::invoice_request::{ + CurrencyConversion, InvoiceRequest, InvoiceRequestFields, +}; +use lightning::offers::offer::{CurrencyCode, OfferId}; use lightning::offers::parse::Bolt12SemanticError; use lightning::sign::{EntropySource, ReceiveAuthKey}; use lightning::types::features::BlindedHopFeatures; @@ -78,6 +80,14 @@ fn privkey(byte: u8) -> SecretKey { SecretKey::from_slice(&[byte; 32]).unwrap() } +struct FuzzCurrencyConversion; + +impl CurrencyConversion for FuzzCurrencyConversion { + fn fiat_to_msats(&self, _iso4217_code: CurrencyCode) -> Result { + unreachable!() + } +} + fn build_response( invoice_request: &InvoiceRequest, secp_ctx: &Secp256k1, ) -> Result { @@ -144,7 +154,7 @@ fn build_response( .unwrap(); let payment_hash = PaymentHash([42; 32]); - invoice_request.respond_with(vec![payment_path], payment_hash)?.build() + invoice_request.respond_with(&FuzzCurrencyConversion, vec![payment_path], payment_hash)?.build() } pub fn invoice_request_deser_test(data: &[u8], out: Out) { diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 8196d886e65..72a1774468b 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -93,7 +93,9 @@ use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow}; use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use crate::offers::invoice_error::InvoiceError; -use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromOffer}; +use crate::offers::invoice_request::{ + DefaultCurrencyConversion, InvoiceRequest, InvoiceRequestVerifiedFromOffer, +}; use crate::offers::nonce::Nonce; use crate::offers::offer::{Offer, OfferFromHrn}; use crate::offers::parse::Bolt12SemanticError; @@ -5666,6 +5668,7 @@ where let features = self.bolt12_invoice_features(); let outbound_pmts_res = self.pending_outbound_payments.static_invoice_received( invoice, + &DefaultCurrencyConversion, payment_id, features, best_block_height, @@ -15320,6 +15323,7 @@ where InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { let result = self.flow.create_invoice_builder_from_invoice_request_with_keys( &self.router, + &DefaultCurrencyConversion, &request, self.list_usable_channels(), get_payment_info, @@ -15344,6 +15348,7 @@ where InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => { let result = self.flow.create_invoice_builder_from_invoice_request_without_keys( &self.router, + &DefaultCurrencyConversion, &request, self.list_usable_channels(), get_payment_info, diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 4c53aefe58d..fe79f842d22 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -57,7 +57,7 @@ use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnou use crate::ln::outbound_payment::IDEMPOTENCY_TIMEOUT_TICKS; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_error::InvoiceError; -use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; +use crate::offers::invoice_request::{DefaultCurrencyConversion, InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; use crate::offers::parse::Bolt12SemanticError; use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, PADDED_PATH_LENGTH}; @@ -2331,7 +2331,7 @@ fn fails_paying_invoice_with_unknown_required_features() { let invoice = match verified_invoice_request { InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { - request.respond_using_derived_keys_no_std(payment_paths, payment_hash, created_at).unwrap() + request.respond_using_derived_keys_no_std(&DefaultCurrencyConversion, payment_paths, payment_hash, created_at).unwrap() .features_unchecked(Bolt12InvoiceFeatures::unknown()) .build_and_sign(&secp_ctx).unwrap() }, diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 75fe55bfeac..7e506a8913b 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -23,6 +23,7 @@ use crate::ln::channelmanager::{ use crate::ln::onion_utils; use crate::ln::onion_utils::{DecodedOnionFailure, HTLCFailReason}; use crate::offers::invoice::{Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder}; +use crate::offers::invoice_request::CurrencyConversion; use crate::offers::invoice_request::InvoiceRequest; use crate::offers::nonce::Nonce; use crate::offers::static_invoice::StaticInvoice; @@ -1115,13 +1116,15 @@ where Ok(()) } - pub(super) fn static_invoice_received( - &self, invoice: &StaticInvoice, payment_id: PaymentId, features: Bolt12InvoiceFeatures, - best_block_height: u32, duration_since_epoch: Duration, entropy_source: ES, + pub(super) fn static_invoice_received( + &self, invoice: &StaticInvoice, currency_conversion: CC, payment_id: PaymentId, + features: Bolt12InvoiceFeatures, best_block_height: u32, duration_since_epoch: Duration, + entropy_source: ES, pending_events: &Mutex)>>, ) -> Result<(), Bolt12PaymentError> where ES::Target: EntropySource, + CC::Target: CurrencyConversion, { macro_rules! abandon_with_entry { ($payment: expr, $reason: expr) => { @@ -1168,6 +1171,7 @@ where let amount_msat = match InvoiceBuilder::::amount_msats( invreq, + currency_conversion, ) { Ok(amt) => amt, Err(_) => { @@ -3206,7 +3210,7 @@ mod tests { .build().unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), created_at).unwrap() + .respond_with_no_conversion(payment_paths(), payment_hash(), created_at).unwrap() .build().unwrap() .sign(recipient_sign).unwrap(); @@ -3253,7 +3257,7 @@ mod tests { .build().unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() + .respond_with_no_conversion(payment_paths(), payment_hash(), now()).unwrap() .build().unwrap() .sign(recipient_sign).unwrap(); @@ -3316,7 +3320,7 @@ mod tests { .build().unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() + .respond_with_no_conversion(payment_paths(), payment_hash(), now()).unwrap() .build().unwrap() .sign(recipient_sign).unwrap(); diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 94a4534c61a..b4498a316fd 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -40,7 +40,8 @@ use crate::offers::invoice::{ DEFAULT_RELATIVE_EXPIRY, }; use crate::offers::invoice_request::{ - InvoiceRequest, InvoiceRequestBuilder, InvoiceRequestVerifiedFromOffer, VerifiedInvoiceRequest, + CurrencyConversion, InvoiceRequest, InvoiceRequestBuilder, InvoiceRequestVerifiedFromOffer, + VerifiedInvoiceRequest, }; use crate::offers::nonce::Nonce; use crate::offers::offer::{Amount, DerivedMetadata, Offer, OfferBuilder}; @@ -961,18 +962,23 @@ where /// Returns a [`Bolt12SemanticError`] if: /// - Valid blinded payment paths could not be generated for the [`Bolt12Invoice`]. /// - The [`InvoiceBuilder`] could not be created from the [`InvoiceRequest`]. - pub fn create_invoice_builder_from_invoice_request_with_keys<'a, R: Deref, F>( - &self, router: &R, invoice_request: &'a VerifiedInvoiceRequest, + pub fn create_invoice_builder_from_invoice_request_with_keys<'a, R: Deref, F, CC: Deref>( + &self, router: &R, currency_conversion: CC, + invoice_request: &'a VerifiedInvoiceRequest, usable_channels: Vec, get_payment_info: F, ) -> Result<(InvoiceBuilder<'a, DerivedSigningPubkey>, MessageContext), Bolt12SemanticError> where R::Target: Router, + CC::Target: CurrencyConversion, F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, { + let conversion = &*currency_conversion; let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; - let amount_msats = - InvoiceBuilder::::amount_msats(&invoice_request.inner)?; + let amount_msats = InvoiceBuilder::::amount_msats( + &invoice_request.inner, + conversion, + )?; let (payment_hash, payment_secret) = get_payment_info(amount_msats, relative_expiry)?; @@ -993,9 +999,10 @@ where .map_err(|_| Bolt12SemanticError::MissingPaths)?; #[cfg(feature = "std")] - let builder = invoice_request.respond_using_derived_keys(payment_paths, payment_hash); + let builder = invoice_request.respond_using_derived_keys(conversion, payment_paths, payment_hash); #[cfg(not(feature = "std"))] let builder = invoice_request.respond_using_derived_keys_no_std( + conversion, payment_paths, payment_hash, Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), @@ -1021,18 +1028,23 @@ where /// Returns a [`Bolt12SemanticError`] if: /// - Valid blinded payment paths could not be generated for the [`Bolt12Invoice`]. /// - The [`InvoiceBuilder`] could not be created from the [`InvoiceRequest`]. - pub fn create_invoice_builder_from_invoice_request_without_keys<'a, R: Deref, F>( - &self, router: &R, invoice_request: &'a VerifiedInvoiceRequest, + pub fn create_invoice_builder_from_invoice_request_without_keys<'a, R: Deref, F, CC: Deref>( + &self, router: &R, currency_conversion: CC, + invoice_request: &'a VerifiedInvoiceRequest, usable_channels: Vec, get_payment_info: F, ) -> Result<(InvoiceBuilder<'a, ExplicitSigningPubkey>, MessageContext), Bolt12SemanticError> where R::Target: Router, + CC::Target: CurrencyConversion, F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, { + let conversion = &*currency_conversion; let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; - let amount_msats = - InvoiceBuilder::::amount_msats(&invoice_request.inner)?; + let amount_msats = InvoiceBuilder::::amount_msats( + &invoice_request.inner, + conversion, + )?; let (payment_hash, payment_secret) = get_payment_info(amount_msats, relative_expiry)?; @@ -1053,9 +1065,10 @@ where .map_err(|_| Bolt12SemanticError::MissingPaths)?; #[cfg(feature = "std")] - let builder = invoice_request.respond_with(payment_paths, payment_hash); + let builder = invoice_request.respond_with(conversion, payment_paths, payment_hash); #[cfg(not(feature = "std"))] let builder = invoice_request.respond_with_no_std( + conversion, payment_paths, payment_hash, Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 6dfd6eac508..def386eec02 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -24,7 +24,7 @@ //! use bitcoin::secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey}; //! use core::convert::TryFrom; //! use lightning::offers::invoice::UnsignedBolt12Invoice; -//! use lightning::offers::invoice_request::InvoiceRequest; +//! use lightning::offers::invoice_request::{DefaultCurrencyConversion, InvoiceRequest}; //! use lightning::offers::refund::Refund; //! use lightning::util::ser::Writeable; //! @@ -50,13 +50,13 @@ #![cfg_attr( feature = "std", doc = " - .respond_with(payment_paths, payment_hash)? + .respond_with(&DefaultCurrencyConversion, payment_paths, payment_hash)? " )] #![cfg_attr( not(feature = "std"), doc = " - .respond_with_no_std(payment_paths, payment_hash, core::time::Duration::from_secs(0))? + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths, payment_hash, core::time::Duration::from_secs(0))? " )] //! # ) @@ -125,10 +125,10 @@ use crate::ln::msgs::DecodeError; use crate::offers::invoice_macros::invoice_builder_methods_test_common; use crate::offers::invoice_macros::{invoice_accessors_common, invoice_builder_methods_common}; use crate::offers::invoice_request::{ - ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceRequestTlvStreamRef, InvoiceRequest, - InvoiceRequestContents, InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef, - EXPERIMENTAL_INVOICE_REQUEST_TYPES, INVOICE_REQUEST_PAYER_ID_TYPE, INVOICE_REQUEST_TYPES, - IV_BYTES as INVOICE_REQUEST_IV_BYTES, + CurrencyConversion, ExperimentalInvoiceRequestTlvStream, + ExperimentalInvoiceRequestTlvStreamRef, InvoiceRequest, InvoiceRequestContents, + InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef, EXPERIMENTAL_INVOICE_REQUEST_TYPES, + INVOICE_REQUEST_PAYER_ID_TYPE, INVOICE_REQUEST_TYPES, IV_BYTES as INVOICE_REQUEST_IV_BYTES, }; use crate::offers::merkle::{ self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, @@ -158,6 +158,7 @@ use bitcoin::secp256k1::schnorr::Signature; use bitcoin::secp256k1::{self, Keypair, PublicKey, Secp256k1}; use bitcoin::{Network, WitnessProgram, WitnessVersion}; use core::hash::{Hash, Hasher}; +use core::ops::Deref; use core::time::Duration; #[allow(unused_imports)] @@ -241,11 +242,15 @@ impl SigningPubkeyStrategy for DerivedSigningPubkey {} macro_rules! invoice_explicit_signing_pubkey_builder_methods { ($self: ident, $self_type: ty) => { #[cfg_attr(c_bindings, allow(dead_code))] - pub(super) fn for_offer( - invoice_request: &'a InvoiceRequest, payment_paths: Vec, - created_at: Duration, payment_hash: PaymentHash, signing_pubkey: PublicKey, - ) -> Result { - let amount_msats = Self::amount_msats(invoice_request)?; + pub(super) fn for_offer( + invoice_request: &'a InvoiceRequest, currency_conversion: CC, + payment_paths: Vec, created_at: Duration, + payment_hash: PaymentHash, signing_pubkey: PublicKey, + ) -> Result + where + CC::Target: CurrencyConversion, + { + let amount_msats = Self::amount_msats(invoice_request, currency_conversion)?; let contents = InvoiceContents::ForOffer { invoice_request: invoice_request.contents.clone(), fields: Self::fields( @@ -313,11 +318,15 @@ macro_rules! invoice_explicit_signing_pubkey_builder_methods { macro_rules! invoice_derived_signing_pubkey_builder_methods { ($self: ident, $self_type: ty) => { #[cfg_attr(c_bindings, allow(dead_code))] - pub(super) fn for_offer_using_keys( - invoice_request: &'a InvoiceRequest, payment_paths: Vec, - created_at: Duration, payment_hash: PaymentHash, keys: Keypair, - ) -> Result { - let amount_msats = Self::amount_msats(invoice_request)?; + pub(super) fn for_offer_using_keys( + invoice_request: &'a InvoiceRequest, currency_conversion: CC, + payment_paths: Vec, created_at: Duration, + payment_hash: PaymentHash, keys: Keypair, + ) -> Result + where + CC::Target: CurrencyConversion, + { + let amount_msats = Self::amount_msats(invoice_request, currency_conversion)?; let signing_pubkey = keys.public_key(); let contents = InvoiceContents::ForOffer { invoice_request: invoice_request.contents.clone(), @@ -393,18 +402,38 @@ macro_rules! invoice_builder_methods { ( $self: ident, $self_type: ty, $return_type: ty, $return_value: expr, $type_param: ty $(, $self_mut: tt)? ) => { - pub(crate) fn amount_msats( - invoice_request: &InvoiceRequest, - ) -> Result { - match invoice_request.contents.inner.amount_msats() { - Some(amount_msats) => Ok(amount_msats), - None => match invoice_request.contents.inner.offer.amount() { - Some(Amount::Bitcoin { amount_msats }) => amount_msats - .checked_mul(invoice_request.quantity().unwrap_or(1)) - .ok_or(Bolt12SemanticError::InvalidAmount), - Some(Amount::Currency { .. }) => Err(Bolt12SemanticError::UnsupportedCurrency), - None => Err(Bolt12SemanticError::MissingAmount), - }, + pub(crate) fn amount_msats( + invoice_request: &InvoiceRequest, currency_conversion: CC, + ) -> Result + where + CC::Target: CurrencyConversion, + { + let min_amount = invoice_request.min_invoice_request_amount(currency_conversion)?; + + match (invoice_request.amount_msats(), min_amount) { + // Case 1: The InvoiceRequest specifies an explicit amount AND the Offer + // specifies a required minimum. The request amount is valid only if it + // is >= the minimum required msats. + (Some(ir_amount), Some(min_msats)) if ir_amount >= min_msats => Ok(ir_amount), + + // Case 1b: The InvoiceRequest specifies an explicit amount, but it is + // *below* the minimum required by the Offer. This is semantically invalid. + (Some(_), Some(_)) => Err(Bolt12SemanticError::InsufficientAmount), + + // Case 2: The InvoiceRequest specifies an explicit amount, and the Offer + // does *not* specify any minimum (donation Offer). In this case, any + // InvoiceRequest amount is acceptable. + (Some(ir_amount), None) => Ok(ir_amount), + + // Case 3: The InvoiceRequest does not specify an amount, but the Offer + // does specify a required minimum. We must use the Offer-implied amount + // as the Invoice's amount. + (None, Some(min_msats)) => Ok(min_msats), + + // Case 4: Neither the InvoiceRequest nor the Offer specify any amount. + // With no explicit or implied amount available, we cannot construct a + // valid Invoice amount. + (None, None) => Err(Bolt12SemanticError::MissingAmount), } } @@ -1818,8 +1847,8 @@ mod tests { use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::DecodeError; use crate::offers::invoice_request::{ - ExperimentalInvoiceRequestTlvStreamRef, InvoiceRequestTlvStreamRef, - InvoiceRequestVerifiedFromOffer, + DefaultCurrencyConversion, ExperimentalInvoiceRequestTlvStreamRef, + InvoiceRequestTlvStreamRef, InvoiceRequestVerifiedFromOffer, }; use crate::offers::merkle::{self, SignError, SignatureTlvStreamRef, TaggedHash, TlvStream}; use crate::offers::nonce::Nonce; @@ -1877,7 +1906,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths.clone(), payment_hash, now) + .respond_with_no_conversion(payment_paths.clone(), payment_hash, now) .unwrap() .build() .unwrap(); @@ -2148,7 +2177,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with(payment_paths(), payment_hash()) + .respond_with(&DefaultCurrencyConversion, payment_paths(), payment_hash()) .unwrap() .build() { @@ -2163,7 +2192,7 @@ mod tests { .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_unchecked_and_sign() - .respond_with(payment_paths(), payment_hash()) + .respond_with(&DefaultCurrencyConversion, payment_paths(), payment_hash()) .unwrap() .build() { @@ -2244,7 +2273,12 @@ mod tests { match verified_request { InvoiceRequestVerifiedFromOffer::DerivedKeys(req) => { let invoice = req - .respond_using_derived_keys_no_std(payment_paths(), payment_hash(), now()) + .respond_using_derived_keys_no_std( + &DefaultCurrencyConversion, + payment_paths(), + payment_hash(), + now(), + ) .unwrap() .build_and_sign(&secp_ctx); @@ -2346,7 +2380,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now) + .respond_with_no_conversion(payment_paths(), payment_hash(), now) .unwrap() .relative_expiry(one_hour.as_secs() as u32) .build() @@ -2367,7 +2401,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now - one_hour) + .respond_with_no_conversion(payment_paths(), payment_hash(), now - one_hour) .unwrap() .relative_expiry(one_hour.as_secs() as u32 - 1) .build() @@ -2399,7 +2433,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2429,7 +2463,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2449,7 +2483,7 @@ mod tests { .quantity(u64::max_value()) .unwrap() .build_unchecked_and_sign() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidAmount), @@ -2477,7 +2511,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .fallback_v0_p2wsh(&script.wscript_hash()) .fallback_v0_p2wpkh(&pubkey.wpubkey_hash().unwrap()) @@ -2533,7 +2567,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .allow_mpp() .build() @@ -2561,7 +2595,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2579,7 +2613,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2606,7 +2640,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2683,7 +2717,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2727,7 +2761,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .relative_expiry(3600) .build() @@ -2760,7 +2794,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2804,7 +2838,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2846,7 +2880,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .allow_mpp() .build() @@ -2889,11 +2923,13 @@ mod tests { .build_and_sign() .unwrap(); #[cfg(not(c_bindings))] - let invoice_builder = - invoice_request.respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap(); + let invoice_builder = invoice_request + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) + .unwrap(); #[cfg(c_bindings)] - let mut invoice_builder = - invoice_request.respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap(); + let mut invoice_builder = invoice_request + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) + .unwrap(); let invoice_builder = invoice_builder .fallback_v0_p2wsh(&script.wscript_hash()) .fallback_v0_p2wpkh(&pubkey.wpubkey_hash().unwrap()) @@ -2952,7 +2988,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3040,6 +3076,7 @@ mod tests { .build_and_sign() .unwrap() .respond_with_no_std_using_signing_pubkey( + &DefaultCurrencyConversion, payment_paths(), payment_hash(), now(), @@ -3070,6 +3107,7 @@ mod tests { .build_and_sign() .unwrap() .respond_with_no_std_using_signing_pubkey( + &DefaultCurrencyConversion, payment_paths(), payment_hash(), now(), @@ -3111,7 +3149,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .amount_msats_unchecked(2000) .build() @@ -3140,7 +3178,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .amount_msats_unchecked(2000) .build() @@ -3204,7 +3242,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3237,7 +3275,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3280,7 +3318,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3319,7 +3357,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3365,7 +3403,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .experimental_baz(42) .build() @@ -3391,7 +3429,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3432,7 +3470,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3470,7 +3508,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3511,7 +3549,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3546,7 +3584,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3594,7 +3632,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3640,7 +3678,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths, payment_hash(), now) + .respond_with_no_conversion(payment_paths, payment_hash(), now) .unwrap() .build() .unwrap() diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 401dd8b63d5..ca0b3bbd1d7 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -106,6 +106,7 @@ use crate::offers::invoice::{ #[allow(unused_imports)] use crate::prelude::*; +use core::ops::Deref; /// Tag for the hash function used when signing an [`InvoiceRequest`]'s merkle root. pub const SIGNATURE_TAG: &'static str = concat!("lightning", "invoice_request", "signature"); @@ -794,14 +795,17 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( /// /// [`Duration`]: core::time::Duration #[cfg(feature = "std")] - pub fn respond_with( - &$self, payment_paths: Vec, payment_hash: PaymentHash - ) -> Result<$builder, Bolt12SemanticError> { + pub fn respond_with( + &$self, currency_conversion: CC, payment_paths: Vec, payment_hash: PaymentHash + ) -> Result<$builder, Bolt12SemanticError> + where + CC::Target: CurrencyConversion + { let created_at = std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); - $contents.respond_with_no_std(payment_paths, payment_hash, created_at) + $contents.respond_with_no_std(currency_conversion, payment_paths, payment_hash, created_at) } /// Creates an [`InvoiceBuilder`] for the request with the given required fields. @@ -829,10 +833,13 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( /// /// [`Bolt12Invoice::created_at`]: crate::offers::invoice::Bolt12Invoice::created_at /// [`OfferBuilder::deriving_signing_pubkey`]: crate::offers::offer::OfferBuilder::deriving_signing_pubkey - pub fn respond_with_no_std( - &$self, payment_paths: Vec, payment_hash: PaymentHash, + pub fn respond_with_no_std( + &$self, currency_conversion: CC, payment_paths: Vec, payment_hash: PaymentHash, created_at: core::time::Duration - ) -> Result<$builder, Bolt12SemanticError> { + ) -> Result<$builder, Bolt12SemanticError> + where + CC::Target: CurrencyConversion + { if $contents.invoice_request_features().requires_unknown_bits() { return Err(Bolt12SemanticError::UnknownRequiredFeatures); } @@ -842,22 +849,33 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( None => return Err(Bolt12SemanticError::MissingIssuerSigningPubkey), }; - <$builder>::for_offer(&$contents, payment_paths, created_at, payment_hash, signing_pubkey) + <$builder>::for_offer(&$contents, currency_conversion, payment_paths, created_at, payment_hash, signing_pubkey) } #[cfg(test)] #[allow(dead_code)] - pub(super) fn respond_with_no_std_using_signing_pubkey( - &$self, payment_paths: Vec, payment_hash: PaymentHash, - created_at: core::time::Duration, signing_pubkey: PublicKey + pub(crate) fn respond_with_no_conversion( + &$self, payment_paths: Vec, payment_hash: PaymentHash, created_at: core::time::Duration ) -> Result<$builder, Bolt12SemanticError> { + $contents.respond_with_no_std(&DefaultCurrencyConversion, payment_paths, payment_hash, created_at) + } + + #[cfg(test)] + #[allow(dead_code)] + pub(super) fn respond_with_no_std_using_signing_pubkey( + &$self, currency_conversion: CC, payment_paths: Vec, payment_hash: PaymentHash, + created_at: core::time::Duration, signing_pubkey: PublicKey + ) -> Result<$builder, Bolt12SemanticError> + where + CC::Target: CurrencyConversion + { debug_assert!($contents.contents.inner.offer.issuer_signing_pubkey().is_none()); if $contents.invoice_request_features().requires_unknown_bits() { return Err(Bolt12SemanticError::UnknownRequiredFeatures); } - <$builder>::for_offer(&$contents, payment_paths, created_at, payment_hash, signing_pubkey) + <$builder>::for_offer(&$contents, currency_conversion, payment_paths, created_at, payment_hash, signing_pubkey) } } } @@ -1013,6 +1031,37 @@ impl InvoiceRequest { experimental_invoice_request_tlv_stream, ) } + + /// Computes the minimum invoice amount (in msats) required for this + /// `InvoiceRequest` to be valid, based on the Offer's amount and quantity. + /// + /// Returns: + /// - `Ok(Some(x))` if the Offer specifies an amount. Any explicit + /// `InvoiceRequest` amount must be at least `x` msats. + /// - `Ok(None)` if the Offer does not specify an amount (donation). In this + /// case, any explicit InvoiceRequest amount is acceptable. + /// - `Err(_)` if converting the Offer amount to msats or scaling by quantity + /// results in an overflow or invalid value. + /// + /// This does *not* inspect the amount explicitly given in the InvoiceRequest. + /// Instead, it provides the minimum amount the InvoiceRequest should specify + /// (if it specifies any) to satisfy the Offer. + pub(crate) fn min_invoice_request_amount( + &self, currency_conversion: CC, + ) -> Result, Bolt12SemanticError> + where + CC::Target: CurrencyConversion, + { + let quantity = self.quantity().unwrap_or(1); + + self.amount() + .map(|amt| { + amt.to_msats(currency_conversion).and_then(|unit_msats| { + unit_msats.checked_mul(quantity).ok_or(Bolt12SemanticError::InvalidAmount) + }) + }) + .transpose() + } } macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( @@ -1026,14 +1075,17 @@ macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( /// /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice #[cfg(feature = "std")] - pub fn respond_using_derived_keys( - &$self, payment_paths: Vec, payment_hash: PaymentHash - ) -> Result<$builder, Bolt12SemanticError> { + pub fn respond_using_derived_keys( + &$self, currency_conversion: CC, payment_paths: Vec, payment_hash: PaymentHash + ) -> Result<$builder, Bolt12SemanticError> + where + CC::Target: CurrencyConversion + { let created_at = std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH"); - $self.respond_using_derived_keys_no_std(payment_paths, payment_hash, created_at) + $self.respond_using_derived_keys_no_std(currency_conversion, payment_paths, payment_hash, created_at) } /// Creates an [`InvoiceBuilder`] for the request using the given required fields and that uses @@ -1043,10 +1095,13 @@ macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( /// See [`InvoiceRequest::respond_with_no_std`] for further details. /// /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice - pub fn respond_using_derived_keys_no_std( - &$self, payment_paths: Vec, payment_hash: PaymentHash, + pub fn respond_using_derived_keys_no_std( + &$self, currency_conversion: CC, payment_paths: Vec, payment_hash: PaymentHash, created_at: core::time::Duration - ) -> Result<$builder, Bolt12SemanticError> { + ) -> Result<$builder, Bolt12SemanticError> + where + CC::Target: CurrencyConversion + { if $self.inner.invoice_request_features().requires_unknown_bits() { return Err(Bolt12SemanticError::UnknownRequiredFeatures); } @@ -1059,7 +1114,7 @@ macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( } <$builder>::for_offer_using_keys( - &$self.inner, payment_paths, created_at, payment_hash, keys + &$self.inner, currency_conversion, payment_paths, created_at, payment_hash, keys ) } } } @@ -1757,7 +1812,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) .unwrap() .experimental_baz(42) .build() @@ -2341,7 +2396,7 @@ mod tests { .features_unchecked(InvoiceRequestFeatures::unknown()) .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_conversion(payment_paths(), payment_hash(), now()) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::UnknownRequiredFeatures), diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 7ad3c282c77..a741d8f6085 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -82,6 +82,7 @@ use crate::io; use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::{ExpandedKey, IV_LEN}; use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT}; +use crate::offers::invoice_request::CurrencyConversion; use crate::offers::merkle::{TaggedHash, TlvRecord, TlvStream}; use crate::offers::nonce::Nonce; use crate::offers::parse::{Bech32Encode, Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; @@ -99,6 +100,7 @@ use bitcoin::secp256k1::{self, Keypair, PublicKey, Secp256k1}; use core::borrow::Borrow; use core::hash::{Hash, Hasher}; use core::num::NonZeroU64; +use core::ops::Deref; use core::str::FromStr; use core::time::Duration; @@ -1125,6 +1127,24 @@ pub enum Amount { }, } +impl Amount { + pub(crate) fn to_msats( + self, currency_conversion: CC, + ) -> Result + where + CC::Target: CurrencyConversion, + { + match self { + Amount::Bitcoin { amount_msats } => Ok(amount_msats), + Amount::Currency { iso4217_code, amount } => currency_conversion + .fiat_to_msats(iso4217_code) + .map_err(|_| Bolt12SemanticError::UnsupportedCurrency)? + .checked_mul(amount) + .ok_or(Bolt12SemanticError::InvalidAmount), + } + } +} + /// An ISO 4217 three-letter currency code (e.g., USD). /// /// Currency codes must be exactly 3 ASCII uppercase letters. From 3aa33700d181e68011083356cd0fee5b328ce534 Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 18 Aug 2025 21:56:19 +0530 Subject: [PATCH 3/7] Introduce amount check in pay_for_offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We introduce this check in pay_for_offer, to ensure that if the offer amount is specified in currency, a corresponding amount to be used in invoice request must be provided. **Reasoning:** When responding to an offer with currency, we enforce that the invoice request must always include an amount. This ensures we never receive an invoice tied to a currency-denominated offer without a corresponding request amount. By moving currency conversion upfront into the invoice request creation where the user can supply their own conversion logic — we avoid pushing conversion concerns into invoice parsing. This significantly reduces complexity during invoice verification. --- lightning/src/ln/channelmanager.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 72a1774468b..d03dd2ede57 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -97,7 +97,7 @@ use crate::offers::invoice_request::{ DefaultCurrencyConversion, InvoiceRequest, InvoiceRequestVerifiedFromOffer, }; use crate::offers::nonce::Nonce; -use crate::offers::offer::{Offer, OfferFromHrn}; +use crate::offers::offer::{Amount, Offer, OfferFromHrn}; use crate::offers::parse::Bolt12SemanticError; use crate::offers::refund::Refund; use crate::offers::static_invoice::StaticInvoice; @@ -12938,12 +12938,21 @@ where /// Uses [`InvoiceRequestBuilder`] such that the [`InvoiceRequest`] it builds is recognized by /// the [`ChannelManager`] when handling a [`Bolt12Invoice`] message in response to the request. /// - /// `amount_msats` allows you to overpay what is required to satisfy the offer, or may be - /// required if the offer does not require a specific amount. - /// /// If the [`Offer`] was built from a human readable name resolved using BIP 353, you *must* /// instead call [`Self::pay_for_offer_from_hrn`]. /// + /// # Amount + /// `amount_msats` allows you to overpay what is required to satisfy the offer, or may be + /// required if the offer does not require a specific amount. + /// + /// # Currency + /// + /// If the [`Offer`] specifies its amount in a currency (that is, [`Amount::Currency`]), callers + /// must provide the `amount_msats`. [`ChannelManager`] enforces only that an amount is present + /// in this case. It does not verify here that the provided `amount_msats` is sufficient once + /// converted from the currency amount. The recipient may reject the resulting [`InvoiceRequest`] + /// if the amount is insufficient after conversion. + /// /// # Payment /// /// The provided `payment_id` is used to ensure that only one invoice is paid for the request @@ -13081,6 +13090,13 @@ where let entropy = &*self.entropy_source; let nonce = Nonce::from_entropy_source(entropy); + // If the offer is for a specific currency, ensure the amount is provided. + if let Some(Amount::Currency { iso4217_code: _, amount: _ }) = offer.amount() { + if amount_msats.is_none() { + return Err(Bolt12SemanticError::MissingAmount); + } + } + let builder = self.flow.create_invoice_request_builder( offer, nonce, payment_id, )?; From c0b0817fdd9a7b95d32716e750798fb663dcd2b2 Mon Sep 17 00:00:00 2001 From: shaavan Date: Wed, 30 Jul 2025 15:58:38 +0530 Subject: [PATCH 4/7] Split `enqueue_invoice` into destination-specific variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the `enqueue_invoice` function in the `Flow` component accepted a `Refund` as input and dispatched the invoice either directly to a known `PublicKey` or via `BlindedMessagePath`s, depending on what was available within the `Refund`. While this worked for the refund-based flow, it tightly coupled invoice dispatch logic to the `Refund` abstraction, limiting its general usability outside of that context. The upcoming commits will introduce support for constructing and enqueuing invoices from manually handled `InvoiceRequest`s—decoupled from the `Refund` flow. To enable this, we are preemptively introducing more flexible, destination-specific variants of the enqueue function. Specifically, the `Flow` now exposes two dedicated methods: - `enqueue_invoice_using_node_id`: For sending an invoice directly to a known `PublicKey`. - `enqueue_invoice_using_reply_paths`: For sending an invoice over a set of explicitly provided `BlindedMessagePath`s. This separation improves clarity, enables reuse in broader contexts, and lays the groundwork for more composable invoice handling across the Offers/Refund flow. --- lightning/src/ln/channelmanager.rs | 15 +++++- lightning/src/offers/flow.rs | 75 +++++++++++++++++++----------- 2 files changed, 62 insertions(+), 28 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d03dd2ede57..ac1fc320521 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13179,7 +13179,20 @@ where let invoice = builder.allow_mpp().build_and_sign(secp_ctx)?; - self.flow.enqueue_invoice(invoice.clone(), refund, self.get_peers_for_blinded_path())?; + if refund.paths().is_empty() { + self.flow.enqueue_invoice_using_node_id( + invoice.clone(), + refund.payer_signing_pubkey(), + self.get_peers_for_blinded_path(), + )?; + } else { + self.flow.enqueue_invoice_using_reply_paths( + invoice.clone(), + refund.paths(), + self.get_peers_for_blinded_path(), + )?; + } + Ok(invoice) } diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index b4498a316fd..a7b411c85fa 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1142,22 +1142,23 @@ where Ok(()) } - /// Enqueues the created [`Bolt12Invoice`] corresponding to a [`Refund`] to be sent - /// to the counterparty. + /// Enqueues the provided [`Bolt12Invoice`] to be sent directly to the specified + /// [`PublicKey`] `destination`. /// - /// # Peers + /// This method should be used when there are no available [`BlindedMessagePath`]s + /// for routing the [`Bolt12Invoice`] and the counterparty’s node ID is known. + /// + /// # Reply Path Requirement /// - /// The user must provide a list of [`MessageForwardNode`] that will be used to generate valid - /// reply paths for the counterparty to send back the corresponding [`InvoiceError`] if we fail - /// to create blinded reply paths + /// Reply paths are generated from the given `peers` to allow the counterparty to return + /// an [`InvoiceError`] in case they fail to process the invoice. If valid reply paths + /// cannot be constructed, this method returns a [`Bolt12SemanticError::MissingPaths`]. /// /// [`InvoiceError`]: crate::offers::invoice_error::InvoiceError - /// [`supports_onion_messages`]: crate::types::features::Features::supports_onion_messages - pub fn enqueue_invoice( - &self, invoice: Bolt12Invoice, refund: &Refund, peers: Vec, + pub fn enqueue_invoice_using_node_id( + &self, invoice: Bolt12Invoice, destination: PublicKey, peers: Vec, ) -> Result<(), Bolt12SemanticError> { let payment_hash = invoice.payment_hash(); - let context = MessageContext::Offers(OffersContext::InboundPayment { payment_hash }); let reply_paths = self @@ -1166,28 +1167,48 @@ where let mut pending_offers_messages = self.pending_offers_messages.lock().unwrap(); - if refund.paths().is_empty() { - for reply_path in reply_paths { - let instructions = MessageSendInstructions::WithSpecifiedReplyPath { - destination: Destination::Node(refund.payer_signing_pubkey()), - reply_path, - }; - let message = OffersMessage::Invoice(invoice.clone()); - pending_offers_messages.push((message, instructions)); - } - } else { - let message = OffersMessage::Invoice(invoice); - enqueue_onion_message_with_reply_paths( - message, - refund.paths(), - reply_paths, - &mut pending_offers_messages, - ); + for reply_path in reply_paths { + let instructions = MessageSendInstructions::WithSpecifiedReplyPath { + destination: Destination::Node(destination), + reply_path, + }; + let message = OffersMessage::Invoice(invoice.clone()); + pending_offers_messages.push((message, instructions)); } Ok(()) } + /// Similar to [`Self::enqueue_invoice_using_node_id`], but uses [`BlindedMessagePath`]s + /// for routing the [`Bolt12Invoice`] instead of a direct node ID. + /// + /// Useful when the counterparty expects to receive invoices through onion-routed paths + /// for privacy or anonymity. + /// + /// For reply path requirements see [`Self::enqueue_invoice_using_node_id`]. + pub fn enqueue_invoice_using_reply_paths( + &self, invoice: Bolt12Invoice, paths: &[BlindedMessagePath], peers: Vec, + ) -> Result<(), Bolt12SemanticError> { + let payment_hash = invoice.payment_hash(); + let context = MessageContext::Offers(OffersContext::InboundPayment { payment_hash }); + + let reply_paths = self + .create_blinded_paths(peers, context) + .map_err(|_| Bolt12SemanticError::MissingPaths)?; + + let mut pending_offers_messages = self.pending_offers_messages.lock().unwrap(); + + let message = OffersMessage::Invoice(invoice); + enqueue_onion_message_with_reply_paths( + message, + paths, + reply_paths, + &mut pending_offers_messages, + ); + + Ok(()) + } + /// Forwards a [`StaticInvoice`] over the provided [`Responder`] in response to an /// [`InvoiceRequest`] that we as a static invoice server received on behalf of an often-offline /// recipient. From 856ee8fcb810d96bbd5e441d8111198b714fd445 Mon Sep 17 00:00:00 2001 From: shaavan Date: Wed, 30 Jul 2025 16:26:37 +0530 Subject: [PATCH 5/7] Introduce `enqueue_invoice_error` API Adds an API to send an `InvoiceError` to the counterparty via the flow. This becomes useful with the introduction of Flow events in upcoming commits, where the user can choose to either respond to Offers Messages or return an `InvoiceError`. Note: Given the small scope of changes in this commit, we also take the opportunity to perform minor documentation cleanups in `flow.rs`. --- lightning/src/offers/flow.rs | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index a7b411c85fa..2d7492afe6d 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -39,6 +39,7 @@ use crate::offers::invoice::{ Bolt12Invoice, DerivedSigningPubkey, ExplicitSigningPubkey, InvoiceBuilder, DEFAULT_RELATIVE_EXPIRY, }; +use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{ CurrencyConversion, InvoiceRequest, InvoiceRequestBuilder, InvoiceRequestVerifiedFromOffer, VerifiedInvoiceRequest, @@ -1104,9 +1105,6 @@ where /// The user must provide a list of [`MessageForwardNode`] that will be used to generate /// valid reply paths for the counterparty to send back the corresponding [`Bolt12Invoice`] /// or [`InvoiceError`]. - /// - /// [`InvoiceError`]: crate::offers::invoice_error::InvoiceError - /// [`supports_onion_messages`]: crate::types::features::Features::supports_onion_messages pub fn enqueue_invoice_request( &self, invoice_request: InvoiceRequest, payment_id: PaymentId, nonce: Nonce, peers: Vec, @@ -1153,8 +1151,6 @@ where /// Reply paths are generated from the given `peers` to allow the counterparty to return /// an [`InvoiceError`] in case they fail to process the invoice. If valid reply paths /// cannot be constructed, this method returns a [`Bolt12SemanticError::MissingPaths`]. - /// - /// [`InvoiceError`]: crate::offers::invoice_error::InvoiceError pub fn enqueue_invoice_using_node_id( &self, invoice: Bolt12Invoice, destination: PublicKey, peers: Vec, ) -> Result<(), Bolt12SemanticError> { @@ -1209,6 +1205,26 @@ where Ok(()) } + /// Enqueues an [`InvoiceError`] to be sent to the counterparty via a specified + /// [`BlindedMessagePath`]. + /// + /// Since this method returns the invoice error to the counterparty without + /// expecting back a response, we enqueue it without a reply path. + pub fn enqueue_invoice_error( + &self, invoice_error: InvoiceError, path: BlindedMessagePath, + ) -> Result<(), Bolt12SemanticError> { + let mut pending_offers_messages = self.pending_offers_messages.lock().unwrap(); + + let instructions = MessageSendInstructions::WithoutReplyPath { + destination: Destination::BlindedPath(path), + }; + + let message = OffersMessage::InvoiceError(invoice_error); + pending_offers_messages.push((message, instructions)); + + Ok(()) + } + /// Forwards a [`StaticInvoice`] over the provided [`Responder`] in response to an /// [`InvoiceRequest`] that we as a static invoice server received on behalf of an often-offline /// recipient. @@ -1256,7 +1272,6 @@ where /// contained within the provided [`StaticInvoice`]. /// /// [`ReleaseHeldHtlc`]: crate::onion_message::async_payments::ReleaseHeldHtlc - /// [`supports_onion_messages`]: crate::types::features::Features::supports_onion_messages pub fn enqueue_held_htlc_available( &self, invoice: &StaticInvoice, reply_path_params: HeldHtlcReplyPath, ) -> Result<(), Bolt12SemanticError> { @@ -1333,8 +1348,6 @@ where /// The user must provide a list of [`MessageForwardNode`] that will be used to generate /// valid reply paths for the counterparty to send back the corresponding response for /// the [`DNSSECQuery`] message. - /// - /// [`supports_onion_messages`]: crate::types::features::Features::supports_onion_messages #[cfg(feature = "dnssec")] pub fn enqueue_dns_onion_message( &self, message: DNSSECQuery, context: DNSResolverContext, dns_resolvers: Vec, From 5ff719847ec38441053d70ac8f017518af87cdee Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 18 Jul 2025 17:30:17 +0530 Subject: [PATCH 6/7] Introduce FlowEvents for manual handling of offers messages Until now, offers messages were processed internally without exposing intermediate steps. This made it harder for callers to intercept or analyse offer messages before deciding how to respond to them. `FlowEvents` provide an optional mechanism to surface these events back to the user. With events enabled, the caller can manually inspect an incoming message, choose to construct and sign an invoice, or send back an InvoiceError. This shifts control to the user where needed, while keeping the default automatic flow unchanged. --- lightning/src/ln/channelmanager.rs | 7 ++- lightning/src/offers/flow.rs | 75 +++++++++++++++++++++++++++++- 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ac1fc320521..5e4b3c1f82a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3947,7 +3947,8 @@ where let flow = OffersMessageFlow::new( ChainHash::using_genesis_block(params.network), params.best_block, our_network_pubkey, current_timestamp, expanded_inbound_key, - node_signer.get_receive_auth_key(), secp_ctx.clone(), message_router, logger.clone(), + node_signer.get_receive_auth_key(), secp_ctx.clone(), message_router, false, + logger.clone(), ); ChannelManager { @@ -15328,7 +15329,7 @@ where None => return None, }; - let invoice_request = match self.flow.verify_invoice_request(invoice_request, context) { + let invoice_request = match self.flow.verify_invoice_request(invoice_request, context, responder.clone()) { Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) => invoice_request, Ok(InvreqResponseInstructions::SendStaticInvoice { recipient_id, invoice_slot, invoice_request }) => { self.pending_events.lock().unwrap().push_back((Event::StaticInvoiceRequested { @@ -15337,6 +15338,7 @@ where return None }, + Ok(InvreqResponseInstructions::AsynchronouslyHandleResponse) => return None, Err(_) => return None, }; @@ -18135,6 +18137,7 @@ where args.node_signer.get_receive_auth_key(), secp_ctx.clone(), args.message_router, + false, args.logger.clone(), ) .with_async_payments_offers_cache(async_receive_offer_cache); diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 2d7492afe6d..f942f899c73 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -71,6 +71,32 @@ use { crate::onion_message::dns_resolution::{DNSResolverMessage, DNSSECQuery, OMNameResolver}, }; +/// Defines the events that can be optionally triggered when processing offers messages. +/// +/// Once generated, these events are stored in the [`OffersMessageFlow`], where they can be +/// manually inspected and responded to. +pub enum OfferMessageFlowEvent { + /// Notifies that an [`InvoiceRequest`] has been received. + /// + /// To respond to this message: + /// - Based on the variant of [`InvoiceRequestVerifiedFromOffer`], create the appropriate invoice builder: + /// - [`InvoiceRequestVerifiedFromOffer::DerivedKeys`] → use + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_with_keys`] + /// - [`InvoiceRequestVerifiedFromOffer::ExplicitKeys`] → use + /// [`OffersMessageFlow::create_invoice_builder_from_invoice_request_without_keys`] + /// - After building the invoice, sign it and send it back using the provided reply path via + /// [`OffersMessageFlow::enqueue_invoice_using_reply_paths`]. + /// + /// If the invoice request is invalid, respond with an [`InvoiceError`] using + /// [`OffersMessageFlow::enqueue_invoice_error`]. + InvoiceRequestReceived { + /// The received, verified invoice request. + invoice_request: InvoiceRequestVerifiedFromOffer, + /// The reply path to use when responding to the invoice request. + reply_path: BlindedMessagePath, + }, +} + /// A BOLT12 offers code and flow utility provider, which facilitates /// BOLT12 builder generation and onion message handling. /// @@ -93,6 +119,8 @@ where secp_ctx: Secp256k1, message_router: MR, + pub(crate) enable_events: bool, + #[cfg(not(any(test, feature = "_test_utils")))] pending_offers_messages: Mutex>, #[cfg(any(test, feature = "_test_utils"))] @@ -106,6 +134,8 @@ where #[cfg(feature = "dnssec")] pending_dns_onion_messages: Mutex>, + pending_flow_events: Mutex>, + logger: L, } @@ -119,7 +149,7 @@ where chain_hash: ChainHash, best_block: BestBlock, our_network_pubkey: PublicKey, current_timestamp: u32, inbound_payment_key: inbound_payment::ExpandedKey, receive_auth_key: ReceiveAuthKey, secp_ctx: Secp256k1, message_router: MR, - logger: L, + enable_events: bool, logger: L, ) -> Self { Self { chain_hash, @@ -134,6 +164,8 @@ where secp_ctx, message_router, + enable_events, + pending_offers_messages: Mutex::new(Vec::new()), pending_async_payments_messages: Mutex::new(Vec::new()), @@ -144,6 +176,8 @@ where async_receive_offer_cache: Mutex::new(AsyncReceiveOfferCache::new()), + pending_flow_events: Mutex::new(Vec::new()), + logger, } } @@ -160,6 +194,18 @@ where self } + /// Enables [`OfferMessageFlowEvent`] for this flow. + /// + /// By default, events are not emitted when processing offers messages. Calling this method + /// sets the internal `enable_events` flag to `true`, allowing you to receive [`OfferMessageFlowEvent`] + /// such as [`OfferMessageFlowEvent::InvoiceRequestReceived`]. + /// + /// This is useful when you want to manually inspect, handle, or respond to incoming + /// offers messages rather than having them processed automatically. + pub fn enable_events(&mut self) { + self.enable_events = true; + } + /// Sets the [`BlindedMessagePath`]s that we will use as an async recipient to interactively build /// [`Offer`]s with a static invoice server, so the server can serve [`StaticInvoice`]s to payers /// on our behalf when we're offline. @@ -416,6 +462,8 @@ pub enum InvreqResponseInstructions { /// [`OffersMessageFlow::enqueue_invoice_request_to_forward`]. invoice_request: InvoiceRequest, }, + /// We are recipient of this payment, and should handle the response asynchronously. + AsynchronouslyHandleResponse, } /// Parameters for the reply path to a [`HeldHtlcAvailable`] onion message. @@ -444,6 +492,7 @@ where L::Target: Logger, { /// Verifies an [`InvoiceRequest`] using the provided [`OffersContext`] or the [`InvoiceRequest::metadata`]. + /// It also helps determine the response instructions, corresponding to the verified invoice request must be taken. /// /// - If an [`OffersContext::InvoiceRequest`] with a `nonce` is provided, verification is performed using recipient context data. /// - If no context is provided but the [`InvoiceRequest`] contains [`Offer`] metadata, verification is performed using that metadata. @@ -456,6 +505,7 @@ where /// - The verification process (via recipient context data or metadata) fails. pub fn verify_invoice_request( &self, invoice_request: InvoiceRequest, context: Option, + responder: Responder, ) -> Result { let secp_ctx = &self.secp_ctx; let expanded_key = &self.inbound_payment_key; @@ -489,7 +539,18 @@ where None => invoice_request.verify_using_metadata(expanded_key, secp_ctx), }?; - Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) + if self.enable_events { + self.pending_flow_events.lock().unwrap().push( + OfferMessageFlowEvent::InvoiceRequestReceived { + invoice_request, + reply_path: responder.into_blinded_path(), + }, + ); + + Ok(InvreqResponseInstructions::AsynchronouslyHandleResponse) + } else { + Ok(InvreqResponseInstructions::SendInvoice(invoice_request)) + } } /// Verifies a [`Bolt12Invoice`] using the provided [`OffersContext`] or the invoice's payer metadata, @@ -1374,6 +1435,11 @@ where Ok(()) } + /// Enqueues the generated [`OfferMessageFlowEvent`] to be processed. + pub fn enqueue_flow_event(&self, flow_event: OfferMessageFlowEvent) { + self.pending_flow_events.lock().unwrap().push(flow_event); + } + /// Gets the enqueued [`OffersMessage`] with their corresponding [`MessageSendInstructions`]. pub fn release_pending_offers_messages(&self) -> Vec<(OffersMessage, MessageSendInstructions)> { core::mem::take(&mut self.pending_offers_messages.lock().unwrap()) @@ -1386,6 +1452,11 @@ where core::mem::take(&mut self.pending_async_payments_messages.lock().unwrap()) } + /// Gets the enqueued [`OfferMessageFlowEvent`] to be processed. + pub fn release_pending_flow_events(&self) -> Vec { + core::mem::take(&mut self.pending_flow_events.lock().unwrap()) + } + /// Gets the enqueued [`DNSResolverMessage`] with their corresponding [`MessageSendInstructions`]. #[cfg(feature = "dnssec")] pub fn release_pending_dns_messages( From a4742bd120ebc06be659c4f7674ac403d43be2ce Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 14 Aug 2025 16:36:27 +0530 Subject: [PATCH 7/7] Introduce OfferMessageFlowEvent test with manual offer response --- lightning/src/ln/channelmanager.rs | 8 +- lightning/src/ln/offers_tests.rs | 114 +++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 5e4b3c1f82a..da096b9aad9 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -2666,6 +2666,9 @@ pub struct ChannelManager< fee_estimator: LowerBoundedFeeEstimator, chain_monitor: M, tx_broadcaster: T, + #[cfg(test)] + pub(super) router: R, + #[cfg(not(test))] router: R, #[cfg(test)] @@ -2892,6 +2895,9 @@ pub struct ChannelManager< pub(super) entropy_source: ES, #[cfg(not(test))] entropy_source: ES, + #[cfg(test)] + pub(super) node_signer: NS, + #[cfg(not(test))] node_signer: NS, #[cfg(test)] pub(super) signer_provider: SP, @@ -13416,7 +13422,7 @@ where now } - fn get_peers_for_blinded_path(&self) -> Vec { + pub(crate) fn get_peers_for_blinded_path(&self) -> Vec { let per_peer_state = self.per_peer_state.read().unwrap(); per_peer_state .iter() diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index fe79f842d22..d9174ebcdde 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -51,6 +51,7 @@ use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, Paym use crate::blinded_path::message::OffersContext; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose}; use crate::ln::channelmanager::{Bolt12PaymentError, PaymentId, RecentPaymentDetails, RecipientOnionFields, Retry, self}; +use crate::offers::flow::OfferMessageFlowEvent; use crate::types::features::Bolt12InvoiceFeatures; use crate::ln::functional_test_utils::*; use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnouncement, OnionMessage, OnionMessageHandler, RoutingMessageHandler, SocketAddress, UnsignedGossipMessage, UnsignedNodeAnnouncement}; @@ -866,6 +867,119 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } +/// Checks that an offer can be paid through a one-hop blinded path and that ephemeral pubkeys are +/// used rather than exposing a node's pubkey. However, the node's pubkey is still used as the +/// introduction node of the blinded path. +#[test] +fn creates_and_manually_respond_to_ir_then_pays_for_offer_using_one_hop_blinded_path() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let mut node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + node_chanmgrs[0].flow.enable_events(); + 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(); + assert_ne!(offer.issuer_signing_pubkey(), Some(alice_id)); + assert!(!offer.paths().is_empty()); + for path in offer.paths() { + 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(); + 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 flow_events = alice.node.flow.release_pending_flow_events(); + assert_eq!(flow_events.len(), 1, "expected exactly one flow event"); + + let (invoice_request, reply_path) = match flow_events.into_iter().next().unwrap() { + OfferMessageFlowEvent::InvoiceRequestReceived { + invoice_request: InvoiceRequestVerifiedFromOffer::DerivedKeys(req), + reply_path + } => (req, reply_path), + _ => panic!("Unexpected flow event"), + }; + + 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, + }, + }); + assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); + assert!(check_compact_path_introduction_node(&reply_path, alice, bob_id)); + + // Create response for invoice request manually. + let get_payment_info = |amount_msats, relative_expiry| { + alice + .node + .create_inbound_payment(Some(amount_msats), relative_expiry, None) + .map_err(|_| Bolt12SemanticError::InvalidAmount) + }; + + let router = &alice.node.router; + let (builder, _) = alice + .node + .flow + .create_invoice_builder_from_invoice_request_with_keys( + router, + &DefaultCurrencyConversion {}, + &invoice_request, + alice.node.list_usable_channels(), + get_payment_info, + ) + .expect("failed to create builder with derived keys"); + + let invoice = builder + .build_and_sign(&alice.node.secp_ctx) + .expect("failed to build and sign invoice"); + + alice + .node + .flow + .enqueue_invoice_using_reply_paths( + invoice, + &[reply_path], + alice.node.get_peers_for_blinded_path(), + ) + .expect("failed to enqueue invoice"); + + 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, reply_path) = extract_invoice(bob, &onion_message); + 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!(check_compact_path_introduction_node(&reply_path, bob, alice_id)); + + route_bolt12_payment(bob, &[alice], &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); +} + /// Checks that a refund can be paid through a one-hop blinded path and that ephemeral pubkeys are /// used rather than exposing a node's pubkey. However, the node's pubkey is still used as the /// introduction node of the blinded path.