From 2a5f1682541b92d0661dfa039f03191e0e3baa5f Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 25 Jul 2025 22:51:40 +0530 Subject: [PATCH 01/12] Refactor: Convert fields function to macro In the following commits we will introduce `fields` function for other types as well, so to keep code DRY we convert the function to a macro. --- lightning/src/offers/invoice_request.rs | 59 ++++++++++++++----------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index d5d3c4d75a8..dc058c2039d 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -970,9 +970,43 @@ macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( } } } +macro_rules! fields_accessor { + ($self:ident, $inner:expr) => { + /// Fetch the [`InvoiceRequestFields`] for this verified invoice. + /// + /// These are fields which we expect to be useful when receiving a payment for this invoice + /// request, and include the returned [`InvoiceRequestFields`] in the + /// [`PaymentContext::Bolt12Offer`]. + /// + /// [`PaymentContext::Bolt12Offer`]: crate::blinded_path::payment::PaymentContext::Bolt12Offer + pub fn fields(&$self) -> InvoiceRequestFields { + let InvoiceRequestContents { + payer_signing_pubkey, + inner: InvoiceRequestContentsWithoutPayerSigningPubkey { + quantity, + payer_note, + .. + }, + } = &$inner; + + InvoiceRequestFields { + payer_signing_pubkey: *payer_signing_pubkey, + quantity: *quantity, + payer_note_truncated: payer_note + .clone() + // Truncate the payer note to `PAYER_NOTE_LIMIT` bytes, rounding + // down to the nearest valid UTF-8 code point boundary. + .map(|s| UntrustedString(string_truncate_safe(s, PAYER_NOTE_LIMIT))), + human_readable_name: $self.offer_from_hrn().clone(), + } + } + }; +} + impl VerifiedInvoiceRequest { offer_accessors!(self, self.inner.contents.inner.offer); invoice_request_accessors!(self, self.inner.contents); + fields_accessor!(self, self.inner.contents); #[cfg(not(c_bindings))] invoice_request_respond_with_explicit_signing_pubkey_methods!( self, @@ -997,31 +1031,6 @@ impl VerifiedInvoiceRequest { self.inner, InvoiceWithDerivedSigningPubkeyBuilder ); - - /// Fetch the [`InvoiceRequestFields`] for this verified invoice. - /// - /// These are fields which we expect to be useful when receiving a payment for this invoice - /// request, and include the returned [`InvoiceRequestFields`] in the - /// [`PaymentContext::Bolt12Offer`]. - /// - /// [`PaymentContext::Bolt12Offer`]: crate::blinded_path::payment::PaymentContext::Bolt12Offer - pub fn fields(&self) -> InvoiceRequestFields { - let InvoiceRequestContents { - payer_signing_pubkey, - inner: InvoiceRequestContentsWithoutPayerSigningPubkey { quantity, payer_note, .. }, - } = &self.inner.contents; - - InvoiceRequestFields { - payer_signing_pubkey: *payer_signing_pubkey, - quantity: *quantity, - payer_note_truncated: payer_note - .clone() - // Truncate the payer note to `PAYER_NOTE_LIMIT` bytes, rounding - // down to the nearest valid UTF-8 code point boundary. - .map(|s| UntrustedString(string_truncate_safe(s, PAYER_NOTE_LIMIT))), - human_readable_name: self.offer_from_hrn().clone(), - } - } } /// `String::truncate(new_len)` panics if you split inside a UTF-8 code point, From eeb5d24447b31c0fdebd1253b35d2a72f625cf9a Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 25 Jul 2025 22:50:24 +0530 Subject: [PATCH 02/12] Introduce VerifiedInvoiceRequest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit reintroduces `VerifiedInvoiceRequest`, now parameterized by `SigningPubkeyStrategy`. The key motivation is to restrict which functions can be called on a `VerifiedInvoiceRequest` based on its strategy type. This enables compile-time guarantees — ensuring that an incorrect `InvoiceBuilder` cannot be constructed for a given request, and misuses are caught early. --- lightning/src/ln/channelmanager.rs | 4 +- lightning/src/ln/offers_tests.rs | 20 +++- lightning/src/offers/flow.rs | 72 ++++++------ lightning/src/offers/invoice.rs | 46 +++----- lightning/src/offers/invoice_request.rs | 147 ++++++++++++++++++------ lightning/src/offers/offer.rs | 4 +- 6 files changed, 186 insertions(+), 107 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1d87eccfe66..ca106092bbf 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -7735,7 +7735,7 @@ where }; let payment_purpose_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { - offer_id: verified_invreq.offer_id, + offer_id: verified_invreq.offer_id(), invoice_request: verified_invreq.fields(), }); let from_parts_res = events::PaymentPurpose::from_parts( @@ -14876,7 +14876,7 @@ where }; let amount_msats = match InvoiceBuilder::::amount_msats( - &invoice_request.inner + &invoice_request.inner() ) { Ok(amount_msats) => amount_msats, Err(error) => return Some((OffersMessage::InvoiceError(error.into()), responder.respond())), diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index b7d64df4063..3a6965c6646 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}; +use crate::offers::invoice_request::{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}; @@ -2326,11 +2326,19 @@ fn fails_paying_invoice_with_unknown_required_features() { let secp_ctx = Secp256k1::new(); let created_at = alice.node.duration_since_epoch(); - let invoice = invoice_request - .verify_using_recipient_data(nonce, &expanded_key, &secp_ctx).unwrap() - .respond_using_derived_keys_no_std(payment_paths, payment_hash, created_at).unwrap() - .features_unchecked(Bolt12InvoiceFeatures::unknown()) - .build_and_sign(&secp_ctx).unwrap(); + let verified_invoice_request = invoice_request + .verify_using_recipient_data(nonce, &expanded_key, &secp_ctx).unwrap(); + + let invoice = match verified_invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { + request.respond_using_derived_keys_no_std(payment_paths, payment_hash, created_at).unwrap() + .features_unchecked(Bolt12InvoiceFeatures::unknown()) + .build_and_sign(&secp_ctx).unwrap() + }, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(_) => { + panic!("Expected invoice request with keys"); + }, + }; // Enqueue an onion message containing the new invoice. let instructions = MessageSendInstructions::WithoutReplyPath { diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 615b2991f17..507445934b5 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -41,7 +41,7 @@ use crate::offers::invoice::{ }; use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{ - InvoiceRequest, InvoiceRequestBuilder, VerifiedInvoiceRequest, + InvoiceRequest, InvoiceRequestBuilder, InvoiceRequestVerifiedFromOffer, }; use crate::offers::nonce::Nonce; use crate::offers::offer::{Amount, DerivedMetadata, Offer, OfferBuilder}; @@ -403,7 +403,7 @@ fn enqueue_onion_message_with_reply_paths( pub enum InvreqResponseInstructions { /// We are the recipient of this payment, and a [`Bolt12Invoice`] should be sent in response to /// the invoice request since it is now verified. - SendInvoice(VerifiedInvoiceRequest), + SendInvoice(InvoiceRequestVerifiedFromOffer), /// We are a static invoice server and should respond to this invoice request by retrieving the /// [`StaticInvoice`] corresponding to the `recipient_id` and `invoice_slot` and calling /// [`OffersMessageFlow::enqueue_static_invoice`]. @@ -939,7 +939,7 @@ where Ok(builder.into()) } - /// Creates a response for the provided [`VerifiedInvoiceRequest`]. + /// Creates a response for the provided [`InvoiceRequestVerifiedFromOffer`]. /// /// A response can be either an [`OffersMessage::Invoice`] with additional [`MessageContext`], /// or an [`OffersMessage::InvoiceError`], depending on the [`InvoiceRequest`]. @@ -949,8 +949,9 @@ where /// - We fail to generate a valid signed [`Bolt12Invoice`] for the [`InvoiceRequest`]. pub fn create_response_for_invoice_request( &self, signer: &NS, router: &R, entropy_source: ES, - invoice_request: VerifiedInvoiceRequest, amount_msats: u64, payment_hash: PaymentHash, - payment_secret: PaymentSecret, usable_channels: Vec, + invoice_request: InvoiceRequestVerifiedFromOffer, amount_msats: u64, + payment_hash: PaymentHash, payment_secret: PaymentSecret, + usable_channels: Vec, ) -> (OffersMessage, Option) where ES::Target: EntropySource, @@ -963,7 +964,7 @@ where let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; let context = PaymentContext::Bolt12Offer(Bolt12OfferContext { - offer_id: invoice_request.offer_id, + offer_id: invoice_request.offer_id(), invoice_request: invoice_request.fields(), }); @@ -986,35 +987,36 @@ where #[cfg(not(feature = "std"))] let created_at = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64); - let response = if invoice_request.keys.is_some() { - #[cfg(feature = "std")] - let builder = invoice_request.respond_using_derived_keys(payment_paths, payment_hash); - #[cfg(not(feature = "std"))] - let builder = invoice_request.respond_using_derived_keys_no_std( - payment_paths, - payment_hash, - created_at, - ); - builder - .map(InvoiceBuilder::::from) - .and_then(|builder| builder.allow_mpp().build_and_sign(secp_ctx)) - .map_err(InvoiceError::from) - } else { - #[cfg(feature = "std")] - let builder = invoice_request.respond_with(payment_paths, payment_hash); - #[cfg(not(feature = "std"))] - let builder = invoice_request.respond_with_no_std(payment_paths, payment_hash, created_at); - builder - .map(InvoiceBuilder::::from) - .and_then(|builder| builder.allow_mpp().build()) - .map_err(InvoiceError::from) - .and_then(|invoice| { - #[cfg(c_bindings)] - let mut invoice = invoice; - invoice - .sign(|invoice: &UnsignedBolt12Invoice| signer.sign_bolt12_invoice(invoice)) - .map_err(InvoiceError::from) - }) + let response = match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { + #[cfg(feature = "std")] + let builder = request.respond_using_derived_keys(payment_paths, payment_hash); + #[cfg(not(feature = "std"))] + let builder = request.respond_using_derived_keys_no_std(payment_paths, payment_hash, created_at); + builder + .map(InvoiceBuilder::::from) + .and_then(|builder| builder.allow_mpp().build_and_sign(secp_ctx)) + .map_err(InvoiceError::from) + }, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => { + #[cfg(feature = "std")] + let builder = request.respond_with(payment_paths, payment_hash); + #[cfg(not(feature = "std"))] + let builder = request.respond_with_no_std(payment_paths, payment_hash, created_at); + builder + .map(InvoiceBuilder::::from) + .and_then(|builder| builder.allow_mpp().build()) + .map_err(InvoiceError::from) + .and_then(|invoice| { + #[cfg(c_bindings)] + let mut invoice = invoice; + invoice + .sign(|invoice: &UnsignedBolt12Invoice| { + signer.sign_bolt12_invoice(invoice) + }) + .map_err(InvoiceError::from) + }) + }, }; match response { diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 9751f52b046..4e1c608dfdf 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1819,6 +1819,7 @@ mod tests { use crate::ln::msgs::DecodeError; use crate::offers::invoice_request::{ ExperimentalInvoiceRequestTlvStreamRef, InvoiceRequestTlvStreamRef, + InvoiceRequestVerifiedFromOffer, }; use crate::offers::merkle::{self, SignError, SignatureTlvStreamRef, TaggedHash, TlvStream}; use crate::offers::nonce::Nonce; @@ -2235,42 +2236,31 @@ mod tests { .build_and_sign() .unwrap(); - if let Err(e) = invoice_request + let verified_request = invoice_request .clone() .verify_using_recipient_data(nonce, &expanded_key, &secp_ctx) - .unwrap() - .respond_using_derived_keys_no_std(payment_paths(), payment_hash(), now()) - .unwrap() - .build_and_sign(&secp_ctx) - { - panic!("error building invoice: {:?}", e); + .unwrap(); + + match verified_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(req) => { + let invoice = req + .respond_using_derived_keys_no_std(payment_paths(), payment_hash(), now()) + .unwrap() + .build_and_sign(&secp_ctx); + + if let Err(e) = invoice { + panic!("error building invoice: {:?}", e); + } + }, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(_) => { + panic!("expected invoice request with keys"); + }, } let expanded_key = ExpandedKey::new([41; 32]); assert!(invoice_request .verify_using_recipient_data(nonce, &expanded_key, &secp_ctx) .is_err()); - - let invoice_request = - OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) - .amount_msats(1000) - // Omit the path so that node_id is used for the signing pubkey instead of deriving it - .experimental_foo(42) - .build() - .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) - .unwrap() - .build_and_sign() - .unwrap(); - - match invoice_request - .verify_using_metadata(&expanded_key, &secp_ctx) - .unwrap() - .respond_using_derived_keys_no_std(payment_paths(), payment_hash(), now()) - { - Ok(_) => panic!("expected error"), - Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidMetadata), - } } #[test] diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index dc058c2039d..4311d194dca 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -71,6 +71,7 @@ use crate::io; use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::{ExpandedKey, IV_LEN}; use crate::ln::msgs::DecodeError; +use crate::offers::invoice::{DerivedSigningPubkey, ExplicitSigningPubkey, SigningPubkeyStrategy}; use crate::offers::merkle::{ self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, }; @@ -96,7 +97,7 @@ use bitcoin::secp256k1::schnorr::Signature; use bitcoin::secp256k1::{self, Keypair, PublicKey, Secp256k1}; #[cfg(not(c_bindings))] -use crate::offers::invoice::{DerivedSigningPubkey, ExplicitSigningPubkey, InvoiceBuilder}; +use crate::offers::invoice::InvoiceBuilder; #[cfg(c_bindings)] use crate::offers::invoice::{ InvoiceWithDerivedSigningPubkeyBuilder, InvoiceWithExplicitSigningPubkeyBuilder, @@ -601,18 +602,18 @@ impl Eq for InvoiceRequest {} /// [`InvoiceRequest::verify_using_recipient_data`] and exposes different ways to respond depending /// on whether the signing keys were derived. #[derive(Clone, Debug)] -pub struct VerifiedInvoiceRequest { +pub struct VerifiedInvoiceRequest { /// The identifier of the [`Offer`] for which the [`InvoiceRequest`] was made. pub offer_id: OfferId, /// The verified request. pub(crate) inner: InvoiceRequest, - /// Keys used for signing a [`Bolt12Invoice`] if they can be derived. + /// Keys for signing a [`Bolt12Invoice`] for the request. /// #[cfg_attr( feature = "std", - doc = "If `Some`, must call [`respond_using_derived_keys`] when responding. Otherwise, call [`respond_with`]." + doc = "If `DerivedSigningPubkey`, must call [`respond_using_derived_keys`] when responding. Otherwise, call [`respond_with`]." )] #[cfg_attr(feature = "std", doc = "")] /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice @@ -621,7 +622,47 @@ pub struct VerifiedInvoiceRequest { doc = "[`respond_using_derived_keys`]: Self::respond_using_derived_keys" )] #[cfg_attr(feature = "std", doc = "[`respond_with`]: Self::respond_with")] - pub keys: Option, + pub keys: S, +} + +/// Represents a [`VerifiedInvoiceRequest`], along with information about how the resulting +/// [`Bolt12Invoice`] should be signed. +/// +/// The signing strategy determines whether the signing keys are: +/// - Derived either from the originating [`Offer`]’s metadata or recipient_data, or +/// - Explicitly provided. +/// +/// This distinction is required to produce a valid, signed [`Bolt12Invoice`] from a verified request. +/// +/// For more on key derivation strategies, see: +/// [`InvoiceRequest::verify_using_metadata`] and [`InvoiceRequest::verify_using_recipient_data`]. +/// +/// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice +pub enum InvoiceRequestVerifiedFromOffer { + /// A verified invoice request that uses signing keys derived from the originating [`Offer`]’s metadata or recipient_data. + DerivedKeys(VerifiedInvoiceRequest), + /// A verified invoice request that requires explicitly provided signing keys to sign the resulting [`Bolt12Invoice`]. + /// + /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice + ExplicitKeys(VerifiedInvoiceRequest), +} + +impl InvoiceRequestVerifiedFromOffer { + /// Returns a reference to the underlying `InvoiceRequest`. + pub(crate) fn inner(&self) -> &InvoiceRequest { + match self { + InvoiceRequestVerifiedFromOffer::DerivedKeys(req) => &req.inner, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(req) => &req.inner, + } + } + + /// Returns the `OfferId` of the offer this invoice request is for. + pub fn offer_id(&self) -> OfferId { + match self { + InvoiceRequestVerifiedFromOffer::DerivedKeys(req) => req.offer_id, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(req) => req.offer_id, + } + } } /// The contents of an [`InvoiceRequest`], which may be shared with an [`Bolt12Invoice`]. @@ -754,7 +795,7 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( /// /// If the originating [`Offer`] was created using [`OfferBuilder::deriving_signing_pubkey`], /// then first use [`InvoiceRequest::verify_using_metadata`] or - /// [`InvoiceRequest::verify_using_recipient_data`] and then [`VerifiedInvoiceRequest`] methods + /// [`InvoiceRequest::verify_using_recipient_data`] and then [`InvoiceRequestVerifiedFromOffer`] methods /// instead. /// /// [`Bolt12Invoice::created_at`]: crate::offers::invoice::Bolt12Invoice::created_at @@ -810,17 +851,30 @@ macro_rules! invoice_request_verify_method { secp_ctx: &Secp256k1, #[cfg(c_bindings)] secp_ctx: &Secp256k1, - ) -> Result { + ) -> Result { let (offer_id, keys) = $self.contents.inner.offer.verify_using_metadata(&$self.bytes, key, secp_ctx)?; - Ok(VerifiedInvoiceRequest { - offer_id, + let inner = { #[cfg(not(c_bindings))] - inner: $self, + { $self } #[cfg(c_bindings)] - inner: $self.clone(), - keys, - }) + { $self.clone() } + }; + + let verified = match keys { + None => InvoiceRequestVerifiedFromOffer::ExplicitKeys(VerifiedInvoiceRequest { + offer_id, + inner, + keys: ExplicitSigningPubkey {}, + }), + Some(keys) => InvoiceRequestVerifiedFromOffer::DerivedKeys(VerifiedInvoiceRequest { + offer_id, + inner, + keys: DerivedSigningPubkey(keys), + }), + }; + + Ok(verified) } /// Verifies that the request was for an offer created using the given key by checking a nonce @@ -840,18 +894,32 @@ macro_rules! invoice_request_verify_method { secp_ctx: &Secp256k1, #[cfg(c_bindings)] secp_ctx: &Secp256k1, - ) -> Result { + ) -> Result { let (offer_id, keys) = $self.contents.inner.offer.verify_using_recipient_data( &$self.bytes, nonce, key, secp_ctx )?; - Ok(VerifiedInvoiceRequest { - offer_id, + + let inner = { #[cfg(not(c_bindings))] - inner: $self, + { $self } #[cfg(c_bindings)] - inner: $self.clone(), - keys, - }) + { $self.clone() } + }; + + let verified = match keys { + None => InvoiceRequestVerifiedFromOffer::ExplicitKeys(VerifiedInvoiceRequest { + offer_id, + inner, + keys: ExplicitSigningPubkey {}, + }), + Some(keys) => InvoiceRequestVerifiedFromOffer::DerivedKeys(VerifiedInvoiceRequest { + offer_id, + inner, + keys: DerivedSigningPubkey(keys), + }), + }; + + Ok(verified) } }; } @@ -954,10 +1022,7 @@ macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( return Err(Bolt12SemanticError::UnknownRequiredFeatures); } - let keys = match $self.keys { - None => return Err(Bolt12SemanticError::InvalidMetadata), - Some(keys) => keys, - }; + let keys = $self.keys.0; match $contents.contents.inner.offer.issuer_signing_pubkey() { Some(signing_pubkey) => debug_assert_eq!(signing_pubkey, keys.public_key()), @@ -1003,36 +1068,50 @@ macro_rules! fields_accessor { }; } -impl VerifiedInvoiceRequest { +impl VerifiedInvoiceRequest { offer_accessors!(self, self.inner.contents.inner.offer); invoice_request_accessors!(self, self.inner.contents); fields_accessor!(self, self.inner.contents); + #[cfg(not(c_bindings))] - invoice_request_respond_with_explicit_signing_pubkey_methods!( + invoice_request_respond_with_derived_signing_pubkey_methods!( self, self.inner, - InvoiceBuilder<'_, ExplicitSigningPubkey> + InvoiceBuilder<'_, DerivedSigningPubkey> ); #[cfg(c_bindings)] - invoice_request_respond_with_explicit_signing_pubkey_methods!( + invoice_request_respond_with_derived_signing_pubkey_methods!( self, self.inner, - InvoiceWithExplicitSigningPubkeyBuilder + InvoiceWithDerivedSigningPubkeyBuilder ); +} + +impl VerifiedInvoiceRequest { + offer_accessors!(self, self.inner.contents.inner.offer); + invoice_request_accessors!(self, self.inner.contents); + fields_accessor!(self, self.inner.contents); + #[cfg(not(c_bindings))] - invoice_request_respond_with_derived_signing_pubkey_methods!( + invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self.inner, - InvoiceBuilder<'_, DerivedSigningPubkey> + InvoiceBuilder<'_, ExplicitSigningPubkey> ); #[cfg(c_bindings)] - invoice_request_respond_with_derived_signing_pubkey_methods!( + invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self.inner, - InvoiceWithDerivedSigningPubkeyBuilder + InvoiceWithExplicitSigningPubkeyBuilder ); } +impl InvoiceRequestVerifiedFromOffer { + offer_accessors!(self, self.inner().contents.inner.offer); + invoice_request_accessors!(self, self.inner().contents); + fields_accessor!(self, self.inner().contents); +} + /// `String::truncate(new_len)` panics if you split inside a UTF-8 code point, /// which would leave the `String` containing invalid UTF-8. This function will /// instead truncate the string to the next smaller code point boundary so the @@ -3025,7 +3104,7 @@ mod tests { match invoice_request.verify_using_metadata(&expanded_key, &secp_ctx) { Ok(invoice_request) => { let fields = invoice_request.fields(); - assert_eq!(invoice_request.offer_id, offer.id()); + assert_eq!(invoice_request.offer_id(), offer.id()); assert_eq!( fields, InvoiceRequestFields { diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 7eb719c104a..cbea8e34d08 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -1532,7 +1532,7 @@ mod tests { .build_and_sign() .unwrap(); match invoice_request.verify_using_metadata(&expanded_key, &secp_ctx) { - Ok(invoice_request) => assert_eq!(invoice_request.offer_id, offer.id()), + Ok(invoice_request) => assert_eq!(invoice_request.offer_id(), offer.id()), Err(_) => panic!("unexpected error"), } @@ -1613,7 +1613,7 @@ mod tests { .build_and_sign() .unwrap(); match invoice_request.verify_using_recipient_data(nonce, &expanded_key, &secp_ctx) { - Ok(invoice_request) => assert_eq!(invoice_request.offer_id, offer.id()), + Ok(invoice_request) => assert_eq!(invoice_request.offer_id(), offer.id()), Err(_) => panic!("unexpected error"), } From 685100175de052968f42ad33662180fa6efae488 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 26 Jul 2025 18:00:01 +0530 Subject: [PATCH 03/12] Introduce specific InvoiceBuilders in OffersMessageFlow This change improves type safety and architectural clarity by introducing dedicated `InvoiceBuilder` methods tied to each variant of `VerifiedInvoiceRequestEnum`. With this change, users are now required to match on the enum variant before calling the corresponding builder method. This pushes the responsibility of selecting the correct builder to the user and ensures that invalid builder usage is caught at compile time, rather than relying on runtime checks. The signing logic has also been moved from the builder to the `ChannelManager`. This shift simplifies the builder's role and aligns it with the rest of the API, where builder methods return a configurable object that can be extended before signing. The result is a more consistent and predictable interface that separates concerns cleanly and makes future maintenance easier. --- lightning/src/ln/channelmanager.rs | 86 ++++++++++++-- lightning/src/offers/flow.rs | 177 +++++++++++++++++------------ 2 files changed, 178 insertions(+), 85 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ca106092bbf..7f6e963420f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -93,10 +93,11 @@ use crate::ln::types::ChannelId; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow}; use crate::offers::invoice::{ - Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder, DEFAULT_RELATIVE_EXPIRY, + Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder, UnsignedBolt12Invoice, + DEFAULT_RELATIVE_EXPIRY, }; use crate::offers::invoice_error::InvoiceError; -use crate::offers::invoice_request::InvoiceRequest; +use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; use crate::offers::offer::{Offer, OfferFromHrn}; use crate::offers::parse::Bolt12SemanticError; @@ -14893,16 +14894,79 @@ where }, }; - let entropy = &*self.entropy_source; - let (response, context) = self.flow.create_response_for_invoice_request( - &self.node_signer, &self.router, entropy, invoice_request, amount_msats, - payment_hash, payment_secret, self.list_usable_channels() - ); + let (result, context) = match invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { + let result = self.flow.create_invoice_builder_from_invoice_request_with_keys( + &self.router, + &*self.entropy_source, + &request, + amount_msats, + payment_hash, + payment_secret, + self.list_usable_channels(), + ); - match context { - Some(context) => Some((response, responder.respond_with_reply_path(context))), - None => Some((response, responder.respond())) - } + match result { + Ok((builder, context)) => { + let res = builder + .build_and_sign(&self.secp_ctx) + .map_err(InvoiceError::from); + + (res, context) + }, + Err(error) => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from(error)), + responder.respond(), + )); + }, + } + }, + InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => { + let result = self.flow.create_invoice_builder_from_invoice_request_without_keys( + &self.router, + &*self.entropy_source, + &request, + amount_msats, + payment_hash, + payment_secret, + self.list_usable_channels(), + ); + + match result { + Ok((builder, context)) => { + let res = builder + .build() + .map_err(InvoiceError::from) + .and_then(|invoice| { + #[cfg(c_bindings)] + let mut invoice = invoice; + invoice + .sign(|invoice: &UnsignedBolt12Invoice| self.node_signer.sign_bolt12_invoice(invoice)) + .map_err(InvoiceError::from) + }); + (res, context) + }, + Err(error) => { + return Some(( + OffersMessage::InvoiceError(InvoiceError::from(error)), + responder.respond(), + )); + }, + } + } + }; + + Some(match result { + Ok(invoice) => ( + OffersMessage::Invoice(invoice), + responder.respond_with_reply_path(context), + ), + Err(error) => ( + OffersMessage::InvoiceError(error), + responder.respond(), + ), + }) }, OffersMessage::Invoice(invoice) => { let payment_id = match self.flow.verify_bolt12_invoice(&invoice, context.as_ref()) { diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 507445934b5..417a13c225c 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -37,16 +37,16 @@ use crate::ln::inbound_payment; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::invoice::{ Bolt12Invoice, DerivedSigningPubkey, ExplicitSigningPubkey, InvoiceBuilder, - UnsignedBolt12Invoice, DEFAULT_RELATIVE_EXPIRY, + DEFAULT_RELATIVE_EXPIRY, }; -use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{ - InvoiceRequest, InvoiceRequestBuilder, InvoiceRequestVerifiedFromOffer, + InvoiceRequest, InvoiceRequestBuilder, InvoiceRequestVerifiedFromOffer, VerifiedInvoiceRequest, }; use crate::offers::nonce::Nonce; use crate::offers::offer::{Amount, DerivedMetadata, Offer, OfferBuilder}; use crate::offers::parse::Bolt12SemanticError; use crate::offers::refund::{Refund, RefundBuilder}; +use crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder}; use crate::onion_message::async_payments::{ AsyncPaymentsMessage, HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ServeStaticInvoice, StaticInvoicePersisted, @@ -57,9 +57,7 @@ use crate::onion_message::messenger::{ use crate::onion_message::offers::OffersMessage; use crate::onion_message::packet::OnionMessageContents; use crate::routing::router::Router; -use crate::sign::{EntropySource, NodeSigner, ReceiveAuthKey}; - -use crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder}; +use crate::sign::{EntropySource, ReceiveAuthKey}; use crate::sync::{Mutex, RwLock}; use crate::types::payment::{PaymentHash, PaymentSecret}; use crate::util::logger::Logger; @@ -939,95 +937,124 @@ where Ok(builder.into()) } - /// Creates a response for the provided [`InvoiceRequestVerifiedFromOffer`]. + /// Creates an [`InvoiceBuilder`] for the + /// provided [`VerifiedInvoiceRequest`]. + /// + /// Returns the invoice builder along with a [`MessageContext`] that can + /// later be used to respond to the counterparty. + /// + /// Use this method when you want to inspect or modify the [`InvoiceBuilder`] + /// before signing and generating the final [`Bolt12Invoice`]. /// - /// A response can be either an [`OffersMessage::Invoice`] with additional [`MessageContext`], - /// or an [`OffersMessage::InvoiceError`], depending on the [`InvoiceRequest`]. + /// # Errors /// - /// An [`OffersMessage::InvoiceError`] will be generated if: - /// - We fail to generate valid payment paths to include in the [`Bolt12Invoice`]. - /// - We fail to generate a valid signed [`Bolt12Invoice`] for the [`InvoiceRequest`]. - pub fn create_response_for_invoice_request( - &self, signer: &NS, router: &R, entropy_source: ES, - invoice_request: InvoiceRequestVerifiedFromOffer, amount_msats: u64, + /// 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, ES: Deref, R: Deref>( + &self, router: &R, entropy_source: ES, + invoice_request: &'a VerifiedInvoiceRequest, amount_msats: u64, payment_hash: PaymentHash, payment_secret: PaymentSecret, usable_channels: Vec, - ) -> (OffersMessage, Option) + ) -> Result<(InvoiceBuilder<'a, DerivedSigningPubkey>, MessageContext), Bolt12SemanticError> where ES::Target: EntropySource, - NS::Target: NodeSigner, + R::Target: Router, { let entropy = &*entropy_source; - let secp_ctx = &self.secp_ctx; + let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; + + let context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: invoice_request.offer_id, + invoice_request: invoice_request.fields(), + }); + + let payment_paths = self + .create_blinded_payment_paths( + router, + entropy, + usable_channels, + Some(amount_msats), + payment_secret, + context, + relative_expiry, + ) + .map_err(|_| Bolt12SemanticError::MissingPaths)?; + + #[cfg(feature = "std")] + let builder = invoice_request.respond_using_derived_keys(payment_paths, payment_hash); + #[cfg(not(feature = "std"))] + let builder = invoice_request.respond_using_derived_keys_no_std( + payment_paths, + payment_hash, + Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), + ); + let builder = builder.map(|b| InvoiceBuilder::from(b).allow_mpp())?; + let context = MessageContext::Offers(OffersContext::InboundPayment { payment_hash }); + + Ok((builder, context)) + } + + /// Creates an [`InvoiceBuilder`] for the + /// provided [`VerifiedInvoiceRequest`]. + /// + /// Returns the invoice builder along with a [`MessageContext`] that can + /// later be used to respond to the counterparty. + /// + /// Use this method when you want to inspect or modify the [`InvoiceBuilder`] + /// before signing and generating the final [`Bolt12Invoice`]. + /// + /// # Errors + /// + /// 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, ES: Deref, R: Deref>( + &self, router: &R, entropy_source: ES, + invoice_request: &'a VerifiedInvoiceRequest, amount_msats: u64, + payment_hash: PaymentHash, payment_secret: PaymentSecret, + usable_channels: Vec, + ) -> Result<(InvoiceBuilder<'a, ExplicitSigningPubkey>, MessageContext), Bolt12SemanticError> + where + ES::Target: EntropySource, + R::Target: Router, + { + let entropy = &*entropy_source; let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; let context = PaymentContext::Bolt12Offer(Bolt12OfferContext { - offer_id: invoice_request.offer_id(), + offer_id: invoice_request.offer_id, invoice_request: invoice_request.fields(), }); - let payment_paths = match self.create_blinded_payment_paths( - router, - entropy, - usable_channels, - Some(amount_msats), - payment_secret, - context, - relative_expiry, - ) { - Ok(paths) => paths, - Err(_) => { - let error = InvoiceError::from(Bolt12SemanticError::MissingPaths); - return (OffersMessage::InvoiceError(error.into()), None); - }, - }; + let payment_paths = self + .create_blinded_payment_paths( + router, + entropy, + usable_channels, + Some(amount_msats), + payment_secret, + context, + relative_expiry, + ) + .map_err(|_| Bolt12SemanticError::MissingPaths)?; + #[cfg(feature = "std")] + let builder = invoice_request.respond_with(payment_paths, payment_hash); #[cfg(not(feature = "std"))] - let created_at = Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64); + let builder = invoice_request.respond_with_no_std( + payment_paths, + payment_hash, + Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), + ); - let response = match invoice_request { - InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => { - #[cfg(feature = "std")] - let builder = request.respond_using_derived_keys(payment_paths, payment_hash); - #[cfg(not(feature = "std"))] - let builder = request.respond_using_derived_keys_no_std(payment_paths, payment_hash, created_at); - builder - .map(InvoiceBuilder::::from) - .and_then(|builder| builder.allow_mpp().build_and_sign(secp_ctx)) - .map_err(InvoiceError::from) - }, - InvoiceRequestVerifiedFromOffer::ExplicitKeys(request) => { - #[cfg(feature = "std")] - let builder = request.respond_with(payment_paths, payment_hash); - #[cfg(not(feature = "std"))] - let builder = request.respond_with_no_std(payment_paths, payment_hash, created_at); - builder - .map(InvoiceBuilder::::from) - .and_then(|builder| builder.allow_mpp().build()) - .map_err(InvoiceError::from) - .and_then(|invoice| { - #[cfg(c_bindings)] - let mut invoice = invoice; - invoice - .sign(|invoice: &UnsignedBolt12Invoice| { - signer.sign_bolt12_invoice(invoice) - }) - .map_err(InvoiceError::from) - }) - }, - }; + let builder = builder.map(|b| InvoiceBuilder::from(b).allow_mpp())?; - match response { - Ok(invoice) => { - let context = - MessageContext::Offers(OffersContext::InboundPayment { payment_hash }); + let context = MessageContext::Offers(OffersContext::InboundPayment { payment_hash }); - (OffersMessage::Invoice(invoice), Some(context)) - }, - Err(error) => (OffersMessage::InvoiceError(error.into()), None), - } + Ok((builder, context)) } /// Enqueues the created [`InvoiceRequest`] to be sent to the counterparty. @@ -1054,6 +1081,7 @@ where /// 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, @@ -1099,6 +1127,7 @@ where /// reply paths for the counterparty to send back the corresponding [`InvoiceError`] if we fail /// to create blinded reply paths /// + /// [`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, From d2dd871c0449ccbc275b38e03234f39d50d90b65 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 26 Jul 2025 18:18:20 +0530 Subject: [PATCH 04/12] Refactor: Introduce `get_payment_info` closure for invoice creation To ensure correct Bolt12 payment flow behavior, the `amount_msats` used for generating the `payment_hash`, `payment_secret`, and payment path must remain consistent. Previously, these steps could inadvertently diverge due to separate sources of `amount_msats`. This commit refactors the interface to use a `get_payment_info` closure, which captures the required variables and provides a single source of truth for both payment info (payment_hash, payment_secret) and path generation. This ensures consistency and eliminates subtle bugs that could arise from mismatched amounts across the flow. --- lightning/src/ln/channelmanager.rs | 66 +++++++++++------------------- lightning/src/offers/flow.rs | 35 +++++++++++----- 2 files changed, 47 insertions(+), 54 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7f6e963420f..8b2bc1fcf79 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -92,10 +92,7 @@ use crate::ln::outbound_payment::{ use crate::ln::types::ChannelId; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow}; -use crate::offers::invoice::{ - Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder, UnsignedBolt12Invoice, - DEFAULT_RELATIVE_EXPIRY, -}; +use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; @@ -12737,27 +12734,24 @@ where ) -> Result { let secp_ctx = &self.secp_ctx; - let amount_msats = refund.amount_msats(); - let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; - let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); - match self.create_inbound_payment(Some(amount_msats), relative_expiry, None) { - Ok((payment_hash, payment_secret)) => { - let entropy = &*self.entropy_source; - let builder = self.flow.create_invoice_builder_from_refund( - &self.router, entropy, refund, payment_hash, - payment_secret, self.list_usable_channels() - )?; - - let invoice = builder.allow_mpp().build_and_sign(secp_ctx)?; + let entropy = &*self.entropy_source; + let builder = self.flow.create_invoice_builder_from_refund( + &self.router, entropy, refund, self.list_usable_channels(), + |amount_msats, relative_expiry| { + self.create_inbound_payment( + Some(amount_msats), + relative_expiry, + None + ).map_err(|()| Bolt12SemanticError::InvalidAmount) + } + )?; - self.flow.enqueue_invoice(invoice.clone(), refund, self.get_peers_for_blinded_path())?; + let invoice = builder.allow_mpp().build_and_sign(secp_ctx)?; - Ok(invoice) - }, - Err(()) => Err(Bolt12SemanticError::InvalidAmount), - } + self.flow.enqueue_invoice(invoice.clone(), refund, self.get_peers_for_blinded_path())?; + Ok(invoice) } /// Pays for an [`Offer`] looked up using [BIP 353] Human Readable Names resolved by the DNS @@ -14876,22 +14870,12 @@ where Err(_) => return None, }; - let amount_msats = match InvoiceBuilder::::amount_msats( - &invoice_request.inner() - ) { - Ok(amount_msats) => amount_msats, - Err(error) => return Some((OffersMessage::InvoiceError(error.into()), responder.respond())), - }; - - let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; - let (payment_hash, payment_secret) = match self.create_inbound_payment( - Some(amount_msats), relative_expiry, None - ) { - Ok((payment_hash, payment_secret)) => (payment_hash, payment_secret), - Err(()) => { - let error = Bolt12SemanticError::InvalidAmount; - return Some((OffersMessage::InvoiceError(error.into()), responder.respond())); - }, + let get_payment_info = |amount_msats, relative_expiry| { + self.create_inbound_payment( + Some(amount_msats), + relative_expiry, + None + ).map_err(|_| Bolt12SemanticError::InvalidAmount) }; let (result, context) = match invoice_request { @@ -14900,10 +14884,8 @@ where &self.router, &*self.entropy_source, &request, - amount_msats, - payment_hash, - payment_secret, self.list_usable_channels(), + get_payment_info, ); match result { @@ -14927,10 +14909,8 @@ where &self.router, &*self.entropy_source, &request, - amount_msats, - payment_hash, - payment_secret, self.list_usable_channels(), + get_payment_info, ); match result { diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 417a13c225c..74e5f029597 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -884,13 +884,14 @@ where /// /// Returns an error if the refund targets a different chain or if no valid /// blinded path can be constructed. - pub fn create_invoice_builder_from_refund<'a, ES: Deref, R: Deref>( - &'a self, router: &R, entropy_source: ES, refund: &'a Refund, payment_hash: PaymentHash, - payment_secret: PaymentSecret, usable_channels: Vec, + pub fn create_invoice_builder_from_refund<'a, ES: Deref, R: Deref, F>( + &'a self, router: &R, entropy_source: ES, refund: &'a Refund, + usable_channels: Vec, get_payment_info: F, ) -> Result, Bolt12SemanticError> where ES::Target: EntropySource, R::Target: Router, + F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, { if refund.chain() != self.chain_hash { return Err(Bolt12SemanticError::UnsupportedChain); @@ -902,6 +903,8 @@ where let amount_msats = refund.amount_msats(); let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; + let (payment_hash, payment_secret) = get_payment_info(amount_msats, relative_expiry)?; + let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {}); let payment_paths = self .create_blinded_payment_paths( @@ -951,20 +954,25 @@ 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, ES: Deref, R: Deref>( + pub fn create_invoice_builder_from_invoice_request_with_keys<'a, ES: Deref, R: Deref, F>( &self, router: &R, entropy_source: ES, - invoice_request: &'a VerifiedInvoiceRequest, amount_msats: u64, - payment_hash: PaymentHash, payment_secret: PaymentSecret, - usable_channels: Vec, + invoice_request: &'a VerifiedInvoiceRequest, + usable_channels: Vec, get_payment_info: F, ) -> Result<(InvoiceBuilder<'a, DerivedSigningPubkey>, MessageContext), Bolt12SemanticError> where ES::Target: EntropySource, R::Target: Router, + F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, { let entropy = &*entropy_source; let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; + let amount_msats = + InvoiceBuilder::::amount_msats(&invoice_request.inner)?; + + let (payment_hash, payment_secret) = get_payment_info(amount_msats, relative_expiry)?; + let context = PaymentContext::Bolt12Offer(Bolt12OfferContext { offer_id: invoice_request.offer_id, invoice_request: invoice_request.fields(), @@ -1011,19 +1019,24 @@ 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, ES: Deref, R: Deref>( + pub fn create_invoice_builder_from_invoice_request_without_keys<'a, ES: Deref, R: Deref, F>( &self, router: &R, entropy_source: ES, - invoice_request: &'a VerifiedInvoiceRequest, amount_msats: u64, - payment_hash: PaymentHash, payment_secret: PaymentSecret, - usable_channels: Vec, + invoice_request: &'a VerifiedInvoiceRequest, + usable_channels: Vec, get_payment_info: F, ) -> Result<(InvoiceBuilder<'a, ExplicitSigningPubkey>, MessageContext), Bolt12SemanticError> where ES::Target: EntropySource, R::Target: Router, + F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, { let entropy = &*entropy_source; let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; + let amount_msats = + InvoiceBuilder::::amount_msats(&invoice_request.inner)?; + + let (payment_hash, payment_secret) = get_payment_info(amount_msats, relative_expiry)?; + let context = PaymentContext::Bolt12Offer(Bolt12OfferContext { offer_id: invoice_request.offer_id, invoice_request: invoice_request.fields(), From 6e197de6aecb6b53823b54ae91b9b108ef18df1c Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 23 Aug 2025 21:12:23 +0530 Subject: [PATCH 05/12] rustfmt: Remove skip from request_refund_payment --- lightning/src/ln/channelmanager.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 8b2bc1fcf79..d8008b6d611 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12728,24 +12728,23 @@ where /// /// [`BlindedPaymentPath`]: crate::blinded_path::payment::BlindedPaymentPath /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice - #[rustfmt::skip] pub fn request_refund_payment( - &self, refund: &Refund + &self, refund: &Refund, ) -> Result { let secp_ctx = &self.secp_ctx; + let entropy = &*self.entropy_source; let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); - let entropy = &*self.entropy_source; let builder = self.flow.create_invoice_builder_from_refund( - &self.router, entropy, refund, self.list_usable_channels(), + &self.router, + entropy, + refund, + self.list_usable_channels(), |amount_msats, relative_expiry| { - self.create_inbound_payment( - Some(amount_msats), - relative_expiry, - None - ).map_err(|()| Bolt12SemanticError::InvalidAmount) - } + self.create_inbound_payment(Some(amount_msats), relative_expiry, None) + .map_err(|()| Bolt12SemanticError::InvalidAmount) + }, )?; let invoice = builder.allow_mpp().build_and_sign(secp_ctx)?; From 4a8f3a4425d51faa85bc811dfba61867419d3046 Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 14 Jul 2025 19:56:34 +0530 Subject: [PATCH 06/12] 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..eb97dc82c21 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(Bolt12SemanticError::UnsupportedCurrency) + } +} + /// 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 3c540d6e2b4d2aa5c69a376f0887333a612be3d5 Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 14 Jul 2025 20:07:07 +0530 Subject: [PATCH 07/12] 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 | 46 ++++-- lightning/src/offers/invoice.rs | 178 +++++++++++++++--------- lightning/src/offers/invoice_request.rs | 68 ++++++--- lightning/src/offers/offer.rs | 19 +++ 8 files changed, 239 insertions(+), 115 deletions(-) diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index 96d8515f0b5..24ad76594a0 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -17,9 +17,11 @@ 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::invoice_request::{ + CurrencyConversion, InvoiceRequest, InvoiceRequestFields, +}; use lightning::offers::nonce::Nonce; -use lightning::offers::offer::OfferId; +use lightning::offers::offer::{CurrencyCode, OfferId}; use lightning::offers::parse::Bolt12SemanticError; use lightning::sign::EntropySource; use lightning::types::features::BlindedHopFeatures; @@ -79,6 +81,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 { @@ -145,7 +155,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 d8008b6d611..987b464282e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -94,7 +94,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; @@ -5502,6 +5504,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, @@ -14882,6 +14885,7 @@ where let result = self.flow.create_invoice_builder_from_invoice_request_with_keys( &self.router, &*self.entropy_source, + &DefaultCurrencyConversion, &request, self.list_usable_channels(), get_payment_info, @@ -14907,6 +14911,7 @@ where let result = self.flow.create_invoice_builder_from_invoice_request_without_keys( &self.router, &*self.entropy_source, + &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 3a6965c6646..70773210374 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 74e5f029597..3623da0ca82 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}; @@ -954,22 +955,31 @@ 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, ES: Deref, R: Deref, F>( - &self, router: &R, entropy_source: ES, + pub fn create_invoice_builder_from_invoice_request_with_keys< + 'a, + ES: Deref, + R: Deref, + F, + CC: Deref, + >( + &self, router: &R, entropy_source: ES, currency_conversion: CC, invoice_request: &'a VerifiedInvoiceRequest, usable_channels: Vec, get_payment_info: F, ) -> Result<(InvoiceBuilder<'a, DerivedSigningPubkey>, MessageContext), Bolt12SemanticError> where ES::Target: EntropySource, - R::Target: Router, + CC::Target: CurrencyConversion, F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, { let entropy = &*entropy_source; + 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)?; @@ -991,9 +1001,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), @@ -1019,21 +1030,31 @@ 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, ES: Deref, R: Deref, F>( - &self, router: &R, entropy_source: ES, + pub fn create_invoice_builder_from_invoice_request_without_keys< + 'a, + ES: Deref, + R: Deref, + F, + CC: Deref, + >( + &self, router: &R, entropy_source: ES, currency_conversion: CC, invoice_request: &'a VerifiedInvoiceRequest, usable_channels: Vec, get_payment_info: F, ) -> Result<(InvoiceBuilder<'a, ExplicitSigningPubkey>, MessageContext), Bolt12SemanticError> where ES::Target: EntropySource, R::Target: Router, + CC::Target: CurrencyConversion, F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, { let entropy = &*entropy_source; + 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)?; @@ -1055,9 +1076,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 4e1c608dfdf..32f4955638e 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,19 +402,41 @@ 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 inner = &invoice_request.contents.inner; + let quantity = invoice_request.quantity().unwrap_or(1); + + // Compute the Offer-implied amount (if the Offer specifies one), + // converting from fiat if necessary and scaling by quantity. + let offer_msats_opt = inner + .offer + .amount() + .map(|amt| { + amt.to_msats(currency_conversion).and_then(|unit_msats| { + unit_msats.checked_mul(quantity).ok_or(Bolt12SemanticError::InvalidAmount) + }) + }) + .transpose()?; + + // Case 1: The InvoiceRequest provides an explicit amount. + // In this case we must enforce the Offer's minimum (if any): + // reject if the IR's amount is below the Offer-implied floor. + if let Some(ir_msats) = inner.amount_msats() { + if offer_msats_opt.map_or(false, |offer_msats| ir_msats < offer_msats) { + return Err(Bolt12SemanticError::InsufficientAmount); + } + return Ok(ir_msats); } + + // Case 2: The IR has no explicit amount. + // Fallback to the Offer-implied amount if available. + // If neither IR nor Offer specify an amount, this is invalid. + offer_msats_opt.ok_or(Bolt12SemanticError::MissingAmount) } #[cfg_attr(c_bindings, allow(dead_code))] @@ -1818,8 +1849,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 +1908,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 +2179,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 +2194,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 +2275,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 +2382,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 +2403,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 +2435,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 +2465,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 +2485,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 +2513,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 +2569,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 +2597,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 +2615,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 +2642,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 +2719,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 +2763,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 +2796,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 +2840,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 +2882,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 +2925,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 +2990,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 +3078,7 @@ mod tests { .build_and_sign() .unwrap() .respond_with_no_std_using_signing_pubkey( + &DefaultCurrencyConversion, payment_paths(), payment_hash(), now(), @@ -3070,6 +3109,7 @@ mod tests { .build_and_sign() .unwrap() .respond_with_no_std_using_signing_pubkey( + &DefaultCurrencyConversion, payment_paths(), payment_hash(), now(), @@ -3111,7 +3151,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 +3180,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 +3244,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 +3277,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 +3320,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 +3359,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 +3405,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 +3431,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 +3472,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 +3510,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 +3551,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 +3586,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 +3634,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 +3680,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 eb97dc82c21..3ec142db0b6 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) } } } @@ -1026,14 +1044,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 +1064,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 +1083,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 +1781,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 +2365,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 cbea8e34d08..61f120adc12 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,23 @@ 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)? + .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 c31d8b0b3d6aeef1615cdf388d52b870c8006fa5 Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 18 Aug 2025 21:56:19 +0530 Subject: [PATCH 08/12] 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 | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 987b464282e..772fe8adf1d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -98,7 +98,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::signer; @@ -12670,6 +12670,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 d9b9d5397cb8a465fe03f84b3882bbd961fc662e Mon Sep 17 00:00:00 2001 From: shaavan Date: Wed, 30 Jul 2025 15:58:38 +0530 Subject: [PATCH 09/12] 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 772fe8adf1d..0c9cc5a3289 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -12759,7 +12759,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 3623da0ca82..8fdee23085c 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1153,22 +1153,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 @@ -1177,28 +1178,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 46ca4f236195a7d0791669cf45b5372e69f1bd52 Mon Sep 17 00:00:00 2001 From: shaavan Date: Wed, 30 Jul 2025 16:26:37 +0530 Subject: [PATCH 10/12] 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 8fdee23085c..0b129a10455 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, @@ -1115,9 +1116,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, @@ -1164,8 +1162,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> { @@ -1220,6 +1216,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. @@ -1267,7 +1283,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> { @@ -1344,8 +1359,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 3f77b2901cceaee893bbdf4dfca8fe9d868347cd Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 18 Jul 2025 17:30:17 +0530 Subject: [PATCH 11/12] 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 0c9cc5a3289..d9dbd0a6d94 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -3900,7 +3900,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 { @@ -14880,7 +14881,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 { @@ -14889,6 +14890,7 @@ where return None }, + Ok(InvreqResponseInstructions::AsynchronouslyHandleResponse) => return None, Err(_) => return None, }; @@ -17657,6 +17659,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 0b129a10455..8cb8e1a7c7e 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. @@ -421,6 +467,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. @@ -449,6 +497,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. @@ -461,6 +510,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; @@ -494,7 +544,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, @@ -1385,6 +1446,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()) @@ -1397,6 +1463,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 1ea58db9c801e298eaa07c94ba116741c8e90e2d Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 14 Aug 2025 16:36:27 +0530 Subject: [PATCH 12/12] Introduce OfferMessageFlowEvent test with manual offer response --- lightning/src/ln/channelmanager.rs | 8 +- lightning/src/ln/offers_tests.rs | 117 +++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index d9dbd0a6d94..cb69065c574 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -2670,6 +2670,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)] @@ -2896,6 +2899,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, @@ -12996,7 +13002,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 70773210374..fa3ef8e0ceb 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,122 @@ 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 entropy = &*alice.node.entropy_source; + + let (builder, _) = alice + .node + .flow + .create_invoice_builder_from_invoice_request_with_keys( + router, + entropy, + &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.