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 644920557d2..8d3053cec1f 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -95,9 +95,11 @@ 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::offer::{Amount, Offer, OfferFromHrn}; use crate::offers::parse::Bolt12SemanticError; use crate::offers::refund::Refund; use crate::offers::signer; @@ -2694,6 +2696,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)] @@ -2920,6 +2925,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, @@ -3983,7 +3991,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 { @@ -5698,6 +5707,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, @@ -13027,6 +13037,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, )?; @@ -13109,7 +13126,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) } @@ -13332,7 +13362,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() @@ -15248,7 +15278,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 { @@ -15257,6 +15287,7 @@ where return None }, + Ok(InvreqResponseInstructions::AsynchronouslyHandleResponse) => return None, Err(_) => return None, }; @@ -15273,6 +15304,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, @@ -15298,6 +15330,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, @@ -18056,6 +18089,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/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 3a6965c6646..fa3ef8e0ceb 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -51,13 +51,14 @@ 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}; 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}; @@ -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. @@ -2331,7 +2448,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 88f0cc5079c..873a8864135 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -39,8 +39,10 @@ use crate::offers::invoice::{ Bolt12Invoice, DerivedSigningPubkey, ExplicitSigningPubkey, InvoiceBuilder, DEFAULT_RELATIVE_EXPIRY, }; +use crate::offers::invoice_error::InvoiceError; 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}; @@ -69,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. /// @@ -91,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"))] @@ -104,6 +134,8 @@ where #[cfg(feature = "dnssec")] pending_dns_onion_messages: Mutex>, + pending_flow_events: Mutex>, + logger: L, } @@ -117,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, @@ -132,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()), @@ -142,6 +176,8 @@ where async_receive_offer_cache: Mutex::new(AsyncReceiveOfferCache::new()), + pending_flow_events: Mutex::new(Vec::new()), + logger, } } @@ -158,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. @@ -419,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. @@ -447,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. @@ -459,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; @@ -492,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, @@ -970,22 +1033,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)?; @@ -1007,9 +1079,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), @@ -1035,21 +1108,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)?; @@ -1071,9 +1154,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), @@ -1109,9 +1193,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, @@ -1147,22 +1228,21 @@ 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. /// - /// 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 Path Requirement /// - /// [`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, + /// 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`]. + 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 @@ -1171,28 +1251,68 @@ 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(()) + } + + /// 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. @@ -1240,7 +1360,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> { @@ -1317,8 +1436,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, @@ -1345,6 +1462,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()) @@ -1357,6 +1479,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( diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 6dfd6eac508..14dba7de412 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 4311d194dca..3ec142db0b6 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}; @@ -105,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"); @@ -574,6 +576,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 @@ -765,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. @@ -800,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); } @@ -813,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) } } } @@ -997,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 @@ -1014,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); } @@ -1030,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 ) } } } @@ -1728,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() @@ -2312,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 7ad3c282c77..bf7a17903db 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.