From 8ae542ed3ea8b51e616e30b9fcd526d3273db630 Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 12 Feb 2026 19:12:53 +0530 Subject: [PATCH 1/6] Introduce CurrencyConversion trait Adds a `CurrencyConversion` trait allowing users to provide logic for converting currency-denominated amounts into millisatoshis. LDK itself cannot perform such conversions as exchange rates are external, time-dependent, and application-specific. Instead, the conversion logic must be supplied by the user. This trait forms the foundation for supporting Offers denominated in fiat currencies while keeping exchange-rate handling outside the core protocol logic. --- fuzz/src/invoice_request_deser.rs | 12 ++++++ lightning/src/offers/currency.rs | 63 +++++++++++++++++++++++++++++++ lightning/src/offers/mod.rs | 2 + lightning/src/offers/offer.rs | 16 ++++++++ 4 files changed, 93 insertions(+) create mode 100644 lightning/src/offers/currency.rs diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index a21303debd7..c23b6270c1a 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -61,6 +61,18 @@ pub fn do_test(data: &[u8], _out: Out) { } } +struct FuzzCurrencyConversion; + +impl CurrencyConversion for FuzzCurrencyConversion { + fn msats_per_minor_unit(&self, _iso4217_code: CurrencyCode) -> Result { + unreachable!() + } + + fn tolerance_percent(&self) -> u8 { + unreachable!() + } +} + struct Randomness; impl EntropySource for Randomness { diff --git a/lightning/src/offers/currency.rs b/lightning/src/offers/currency.rs new file mode 100644 index 00000000000..8e0214eff6f --- /dev/null +++ b/lightning/src/offers/currency.rs @@ -0,0 +1,63 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Data structures and encoding for currency conversion support. + +use crate::offers::offer::CurrencyCode; + +#[allow(unused_imports)] +use crate::prelude::*; +use core::ops::Deref; + +/// A trait for converting fiat currencies into millisatoshis (msats). +/// +/// Implementations must return the conversion rate in **msats per minor unit** +/// of the currency. For example: +/// +/// USD (exponent 2) → per **cent** (0.01 USD), not per dollar. +/// +/// This convention ensures amounts remain precise and purely integer-based when parsing and +/// validating BOLT12 invoice requests. +pub trait CurrencyConversion { + /// Returns the conversion rate in **msats per minor unit** for the given + /// ISO-4217 currency code. + fn msats_per_minor_unit(&self, iso4217_code: CurrencyCode) -> Result; + + /// Returns the acceptable tolerance, expressed as a percentage, used when + /// deriving conversion ranges. + /// + /// This represents a user-level policy (e.g., allowance for exchange-rate + /// drift or cached data) and does not directly affect fiat-to-msat conversion + /// outside of range computation. + fn tolerance_percent(&self) -> u8; +} + +impl> CurrencyConversion for CC { + fn msats_per_minor_unit(&self, iso4217_code: CurrencyCode) -> Result { + self.deref().msats_per_minor_unit(iso4217_code) + } + + fn tolerance_percent(&self) -> u8 { + self.deref().tolerance_percent() + } +} + +/// A [`CurrencyConversion`] implementation that does not support +/// any fiat currency conversions. +pub struct DefaultCurrencyConversion; + +impl CurrencyConversion for DefaultCurrencyConversion { + fn msats_per_minor_unit(&self, _iso4217_code: CurrencyCode) -> Result { + Err(()) + } + + fn tolerance_percent(&self) -> u8 { + 0 + } +} diff --git a/lightning/src/offers/mod.rs b/lightning/src/offers/mod.rs index 5b5cf6cdc78..608c017446f 100644 --- a/lightning/src/offers/mod.rs +++ b/lightning/src/offers/mod.rs @@ -16,6 +16,8 @@ pub mod offer; pub mod flow; +pub mod currency; + pub mod async_receive_offer_cache; pub mod invoice; pub mod invoice_error; diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 5592c50a264..a75a25c464b 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::currency::CurrencyConversion; use crate::offers::merkle::{TaggedHash, TlvRecord, TlvStream}; use crate::offers::nonce::Nonce; use crate::offers::parse::{Bech32Encode, Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; @@ -1125,6 +1126,21 @@ pub enum Amount { }, } +impl Amount { + pub(crate) fn into_msats( + self, currency_conversion: &CC, + ) -> Result { + match self { + Amount::Bitcoin { amount_msats } => Ok(amount_msats), + Amount::Currency { iso4217_code, amount } => currency_conversion + .msats_per_minor_unit(iso4217_code) + .map_err(|_| Bolt12SemanticError::UnsupportedCurrency)? + .checked_mul(amount) + .ok_or(Bolt12SemanticError::InvalidAmount), + } + } +} + /// An ISO 4217 three-letter currency code (e.g., USD). /// /// Currency codes must be exactly 3 ASCII uppercase letters. From 71a5ea4eb6e6af5fc6c64d3bfeb4499991515364 Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 6 Mar 2026 20:40:44 +0530 Subject: [PATCH 2/6] Parameterize OffersMessageFlow over CurrencyConversion Makes OffersMessageFlow generic over a CurrencyConversion implementation, propagating the parameter through to ChannelManager. Upcoming changes will introduce currency conversion support in BOLT 12 message handling, which requires access to conversion logic from both ChannelManager and OffersMessageFlow. By threading the conversion abstraction through OffersMessageFlow now, subsequent commits can use it directly without introducing temporary plumbing or refactoring the type hierarchy later. --- lightning-background-processor/src/lib.rs | 8 ++ lightning-block-sync/src/init.rs | 6 +- lightning/src/ln/channelmanager.rs | 113 +++++++++++++++------- lightning/src/ln/functional_test_utils.rs | 13 +++ lightning/src/ln/functional_tests.rs | 2 + lightning/src/ln/reload_tests.rs | 6 +- lightning/src/offers/flow.rs | 15 ++- lightning/src/util/test_utils.rs | 18 ++++ 8 files changed, 136 insertions(+), 45 deletions(-) diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index da415c70a32..3d480ec7402 100644 --- a/lightning-background-processor/src/lib.rs +++ b/lightning-background-processor/src/lib.rs @@ -378,6 +378,9 @@ type DynMessageRouter = lightning::onion_message::messenger::DefaultMessageRoute &'static (dyn EntropySource + Send + Sync), >; +#[cfg(not(c_bindings))] +type DynCurrencyConversion = lightning::offers::currency::DefaultCurrencyConversion; + #[cfg(all(not(c_bindings), not(taproot)))] type DynSignerProvider = dyn lightning::sign::SignerProvider + Send @@ -400,6 +403,7 @@ type DynChannelManager = lightning::ln::channelmanager::ChannelManager< &'static (dyn FeeEstimator + Send + Sync), &'static DynRouter, &'static DynMessageRouter, + &'static DynCurrencyConversion, &'static (dyn Logger + Send + Sync), >; @@ -1910,6 +1914,7 @@ mod tests { IgnoringMessageHandler, MessageHandler, PeerManager, SocketDescriptor, }; use lightning::ln::types::ChannelId; + use lightning::offers::currency::DefaultCurrencyConversion; use lightning::onion_message::messenger::{DefaultMessageRouter, OnionMessenger}; use lightning::routing::gossip::{NetworkGraph, P2PGossipSync}; use lightning::routing::router::{CandidateRouteHop, DefaultRouter, Path, RouteHop}; @@ -2005,6 +2010,7 @@ mod tests { Arc, >, >, + Arc, Arc, >; @@ -2430,6 +2436,7 @@ mod tests { Arc::clone(&network_graph), Arc::clone(&keys_manager), )); + let conversion = Arc::new(DefaultCurrencyConversion); let chain_source = Arc::new(test_utils::TestChainSource::new(Network::Bitcoin)); let kv_store = Arc::new(Persister::new(format!("{}_persister_{}", &persist_dir, i).into())); @@ -2455,6 +2462,7 @@ mod tests { Arc::clone(&tx_broadcaster), Arc::clone(&router), Arc::clone(&msg_router), + Arc::clone(&conversion), Arc::clone(&logger), Arc::clone(&keys_manager), Arc::clone(&keys_manager), diff --git a/lightning-block-sync/src/init.rs b/lightning-block-sync/src/init.rs index a870f8ca88c..f5afab7ad47 100644 --- a/lightning-block-sync/src/init.rs +++ b/lightning-block-sync/src/init.rs @@ -51,6 +51,7 @@ where /// use lightning::chain::chaininterface::BroadcasterInterface; /// use lightning::chain::chaininterface::FeeEstimator; /// use lightning::ln::channelmanager::{ChannelManager, ChannelManagerReadArgs}; +/// use lightning::offers::currency::CurrencyConversion; /// use lightning::onion_message::messenger::MessageRouter; /// use lightning::routing::router::Router; /// use lightning::sign; @@ -72,6 +73,7 @@ where /// F: FeeEstimator, /// R: Router, /// MR: MessageRouter, +/// CC: CurrencyConversion, /// L: Logger, /// C: chain::Filter, /// P: chainmonitor::Persist, @@ -86,6 +88,7 @@ where /// fee_estimator: &F, /// router: &R, /// message_router: &MR, +/// currency_conversion: &CC, /// logger: &L, /// persister: &P, /// ) { @@ -106,11 +109,12 @@ where /// tx_broadcaster, /// router, /// message_router, +/// currency_conversion, /// logger, /// config, /// vec![&mut monitor], /// ); -/// <(BlockHash, ChannelManager<&ChainMonitor, &T, &ES, &NS, &SP, &F, &R, &MR, &L>)>::read( +/// <(BlockHash, ChannelManager<&ChainMonitor, &T, &ES, &NS, &SP, &F, &R, &MR, &CC, &L>)>::read( /// &mut Cursor::new(&serialized_manager), read_args).unwrap() /// }; /// diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index ada27af749f..9bee21a1608 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -95,6 +95,9 @@ use crate::ln::outbound_payment::{ }; use crate::ln::types::ChannelId; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; +use crate::offers::currency::CurrencyConversion; +#[cfg(not(c_bindings))] +use crate::offers::currency::DefaultCurrencyConversion; use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow}; use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use crate::offers::invoice_error::InvoiceError; @@ -1817,6 +1820,7 @@ pub type SimpleArcChannelManager = ChannelManager< >, >, Arc>>, Arc, Arc>>, + Arc, Arc, >; @@ -1848,6 +1852,7 @@ pub type SimpleRefChannelManager<'a, 'b, 'c, 'd, 'e, 'f, 'g, 'h, 'i, M, T, F, L> ProbabilisticScorer<&'f NetworkGraph<&'g L>, &'g L>, >, &'i DefaultMessageRouter<&'f NetworkGraph<&'g L>, &'g L, &'c KeysManager>, + &'i DefaultCurrencyConversion, &'g L, >; @@ -1874,6 +1879,8 @@ pub trait AChannelManager { type Router: Router; /// A type implementing [`MessageRouter`]. type MessageRouter: MessageRouter; + /// A type implementing [`CurrencyConversion`]. + type CurrencyConversion: CurrencyConversion; /// A type implementing [`Logger`]. type Logger: Logger; /// Returns a reference to the actual [`ChannelManager`] object. @@ -1888,6 +1895,7 @@ pub trait AChannelManager { Self::FeeEstimator, Self::Router, Self::MessageRouter, + Self::CurrencyConversion, Self::Logger, >; } @@ -1901,8 +1909,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > AChannelManager for ChannelManager + > AChannelManager for ChannelManager { type Watch = M; type Broadcaster = T; @@ -1913,8 +1922,9 @@ impl< type FeeEstimator = F; type Router = R; type MessageRouter = MR; + type CurrencyConversion = CC; type Logger = L; - fn get_cm(&self) -> &ChannelManager { + fn get_cm(&self) -> &ChannelManager { self } } @@ -1995,6 +2005,7 @@ impl< /// # tx_broadcaster: &dyn lightning::chain::chaininterface::BroadcasterInterface, /// # router: &lightning::routing::router::DefaultRouter<&NetworkGraph<&'a L>, &'a L, &ES, &S, SP, SL>, /// # message_router: &lightning::onion_message::messenger::DefaultMessageRouter<&NetworkGraph<&'a L>, &'a L, &ES>, +/// # currency_conversion: &lightning::offers::currency::DefaultCurrencyConversion, /// # logger: &L, /// # entropy_source: &ES, /// # node_signer: &dyn lightning::sign::NodeSigner, @@ -2010,18 +2021,18 @@ impl< /// }; /// let config = UserConfig::default(); /// let channel_manager = ChannelManager::new( -/// fee_estimator, chain_monitor, tx_broadcaster, router, message_router, logger, -/// entropy_source, node_signer, signer_provider, config.clone(), params, current_timestamp, +/// fee_estimator, chain_monitor, tx_broadcaster, router, message_router, currency_conversion, +/// logger, entropy_source, node_signer, signer_provider, config.clone(), params, current_timestamp, /// ); /// /// // Restart from deserialized data /// let mut channel_monitors = read_channel_monitors(); /// let args = ChannelManagerReadArgs::new( /// entropy_source, node_signer, signer_provider, fee_estimator, chain_monitor, tx_broadcaster, -/// router, message_router, logger, config, channel_monitors.iter().collect(), +/// router, message_router, currency_conversion, logger, config, channel_monitors.iter().collect(), /// ); /// let (block_hash, channel_manager) = -/// <(BlockHash, ChannelManager<_, _, _, _, _, _, _, _, _>)>::read(&mut reader, args)?; +/// <(BlockHash, ChannelManager<_, _, _, _, _, _, _, _, _, _>)>::read(&mut reader, args)?; /// /// // Update the ChannelManager and ChannelMonitors with the latest chain data /// // ... @@ -2668,6 +2679,7 @@ pub struct ChannelManager< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, > { config: RwLock, @@ -2678,9 +2690,9 @@ pub struct ChannelManager< router: R, #[cfg(test)] - pub(super) flow: OffersMessageFlow, + pub(super) flow: OffersMessageFlow, #[cfg(not(test))] - flow: OffersMessageFlow, + flow: OffersMessageFlow, #[cfg(any(test, feature = "_test_utils"))] pub(super) best_block: RwLock, @@ -3468,8 +3480,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > ChannelManager + > ChannelManager { /// Constructs a new `ChannelManager` to hold several channels and route between them. /// @@ -3490,9 +3503,9 @@ impl< /// [`params.best_block.block_hash`]: chain::BestBlock::block_hash #[rustfmt::skip] pub fn new( - fee_est: F, chain_monitor: M, tx_broadcaster: T, router: R, message_router: MR, logger: L, - entropy_source: ES, node_signer: NS, signer_provider: SP, config: UserConfig, - params: ChainParameters, current_timestamp: u32, + fee_est: F, chain_monitor: M, tx_broadcaster: T, router: R, message_router: MR, + currency_conversion: CC, logger: L, entropy_source: ES, node_signer: NS, + signer_provider: SP, config: UserConfig, params: ChainParameters, current_timestamp: u32, ) -> Self where L: Clone, @@ -3506,7 +3519,8 @@ impl< 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, + currency_conversion, logger.clone(), ); ChannelManager { @@ -13976,8 +13990,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > ChannelManager + > ChannelManager { #[cfg(not(c_bindings))] create_offer_builder!(self, OfferBuilder<'_, DerivedMetadata, secp256k1::All>); @@ -14883,8 +14898,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > BaseMessageHandler for ChannelManager + > BaseMessageHandler for ChannelManager { fn provided_node_features(&self) -> NodeFeatures { provided_node_features(&self.config.read().unwrap()) @@ -15249,8 +15265,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > EventsProvider for ChannelManager + > EventsProvider for ChannelManager { /// Processes events that must be periodically handled. /// @@ -15274,8 +15291,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > chain::Listen for ChannelManager + > chain::Listen for ChannelManager { fn filtered_block_connected(&self, header: &Header, txdata: &TransactionData, height: u32) { { @@ -15325,8 +15343,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > chain::Confirm for ChannelManager + > chain::Confirm for ChannelManager { #[rustfmt::skip] fn transactions_confirmed(&self, header: &Header, txdata: &TransactionData, height: u32) { @@ -15488,8 +15507,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > ChannelManager + > ChannelManager { /// Calls a function which handles an on-chain event (blocks dis/connected, transactions /// un/confirmed, etc) on each channel, handling any resulting errors or messages generated by @@ -15840,8 +15860,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > ChannelMessageHandler for ChannelManager + > ChannelMessageHandler for ChannelManager { fn handle_open_channel(&self, counterparty_node_id: PublicKey, message: &msgs::OpenChannel) { // Note that we never need to persist the updated ChannelManager for an inbound @@ -16390,8 +16411,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > OffersMessageHandler for ChannelManager + > OffersMessageHandler for ChannelManager { #[rustfmt::skip] fn handle_message( @@ -16598,8 +16620,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > AsyncPaymentsMessageHandler for ChannelManager + > AsyncPaymentsMessageHandler for ChannelManager { fn handle_offer_paths_request( &self, message: OfferPathsRequest, context: AsyncPaymentsContext, @@ -16845,8 +16868,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > DNSResolverMessageHandler for ChannelManager + > DNSResolverMessageHandler for ChannelManager { fn handle_dnssec_query( &self, _message: DNSSECQuery, _responder: Option, @@ -16903,8 +16927,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > NodeIdLookUp for ChannelManager + > NodeIdLookUp for ChannelManager { fn next_node_id(&self, short_channel_id: u64) -> Option { self.short_to_chan_info.read().unwrap().get(&short_channel_id).map(|(pubkey, _)| *pubkey) @@ -17408,8 +17433,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > Writeable for ChannelManager + > Writeable for ChannelManager { #[rustfmt::skip] fn write(&self, writer: &mut W) -> Result<(), io::Error> { @@ -18138,6 +18164,7 @@ pub struct ChannelManagerReadArgs< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger + Clone, > { /// A cryptographically secure source of entropy. @@ -18176,6 +18203,11 @@ pub struct ChannelManagerReadArgs< /// /// [`BlindedMessagePath`]: crate::blinded_path::message::BlindedMessagePath pub message_router: MR, + /// The [`CurrencyConversion`] used for supporting and interpreting [`Offer`] amount + /// denoted in [`Amount::Currency`]. + /// + /// [`Amount::Currency`]: crate::offers::offer::Amount::Currency + pub currency_conversion: CC, /// The Logger for use in the ChannelManager and which may be used to log information during /// deserialization. pub logger: L, @@ -18216,16 +18248,18 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger + Clone, - > ChannelManagerReadArgs<'a, M, T, ES, NS, SP, F, R, MR, L> + > ChannelManagerReadArgs<'a, M, T, ES, NS, SP, F, R, MR, CC, L> { /// Simple utility function to create a ChannelManagerReadArgs which creates the monitor /// HashMap for you. This is primarily useful for C bindings where it is not practical to /// populate a HashMap directly from C. pub fn new( entropy_source: ES, node_signer: NS, signer_provider: SP, fee_estimator: F, - chain_monitor: M, tx_broadcaster: T, router: R, message_router: MR, logger: L, - config: UserConfig, mut channel_monitors: Vec<&'a ChannelMonitor>, + chain_monitor: M, tx_broadcaster: T, router: R, message_router: MR, + currency_conversion: CC, logger: L, config: UserConfig, + mut channel_monitors: Vec<&'a ChannelMonitor>, ) -> Self { Self { entropy_source, @@ -18236,6 +18270,7 @@ impl< tx_broadcaster, router, message_router, + currency_conversion, logger, config, channel_monitors: hash_map_from_iter( @@ -18293,15 +18328,16 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger + Clone, - > ReadableArgs> - for (BlockHash, Arc>) + > ReadableArgs> + for (BlockHash, Arc>) { fn read( - reader: &mut Reader, args: ChannelManagerReadArgs<'a, M, T, ES, NS, SP, F, R, MR, L>, + reader: &mut Reader, args: ChannelManagerReadArgs<'a, M, T, ES, NS, SP, F, R, MR, CC, L>, ) -> Result { let (blockhash, chan_manager) = - <(BlockHash, ChannelManager)>::read(reader, args)?; + <(BlockHash, ChannelManager)>::read(reader, args)?; Ok((blockhash, Arc::new(chan_manager))) } } @@ -18316,12 +18352,13 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger + Clone, - > ReadableArgs> - for (BlockHash, ChannelManager) + > ReadableArgs> + for (BlockHash, ChannelManager) { fn read( - reader: &mut Reader, args: ChannelManagerReadArgs<'a, M, T, ES, NS, SP, F, R, MR, L>, + reader: &mut Reader, args: ChannelManagerReadArgs<'a, M, T, ES, NS, SP, F, R, MR, CC, L>, ) -> Result { // Stage 1: Pure deserialization into DTO let data: ChannelManagerData = ChannelManagerData::read( @@ -18348,8 +18385,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger + Clone, - > ChannelManager + > ChannelManager { /// Constructs a `ChannelManager` from deserialized data and runtime dependencies. /// @@ -18361,7 +18399,7 @@ impl< /// [`ChannelMonitorUpdate`]s. pub(super) fn from_channel_manager_data( data: ChannelManagerData, - mut args: ChannelManagerReadArgs<'_, M, T, ES, NS, SP, F, R, MR, L>, + mut args: ChannelManagerReadArgs<'_, M, T, ES, NS, SP, F, R, MR, CC, L>, ) -> Result<(BlockHash, Self), DecodeError> { let ChannelManagerData { chain_hash, @@ -19669,6 +19707,7 @@ impl< args.node_signer.get_receive_auth_key(), secp_ctx.clone(), args.message_router, + args.currency_conversion, args.logger.clone(), ) .with_async_payments_offers_cache(async_receive_offer_cache); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 2d971c3a100..0f367dd651d 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -501,6 +501,7 @@ pub struct NodeCfg<'a> { pub fee_estimator: &'a test_utils::TestFeeEstimator, pub router: test_utils::TestRouter<'a>, pub message_router: test_utils::TestMessageRouter<'a>, + pub currency_conversion: test_utils::TestCurrencyConversion, pub chain_monitor: test_utils::TestChainMonitor<'a>, pub keys_manager: &'a test_utils::TestKeysInterface, pub logger: &'a test_utils::TestLogger, @@ -518,6 +519,7 @@ pub type TestChannelManager<'node_cfg, 'chan_mon_cfg> = ChannelManager< &'chan_mon_cfg test_utils::TestFeeEstimator, &'node_cfg test_utils::TestRouter<'chan_mon_cfg>, &'node_cfg test_utils::TestMessageRouter<'chan_mon_cfg>, + &'node_cfg test_utils::TestCurrencyConversion, &'chan_mon_cfg test_utils::TestLogger, >; @@ -566,6 +568,7 @@ pub struct Node<'chan_man, 'node_cfg: 'chan_man, 'chan_mon_cfg: 'node_cfg> { pub fee_estimator: &'chan_mon_cfg test_utils::TestFeeEstimator, pub router: &'node_cfg test_utils::TestRouter<'chan_mon_cfg>, pub message_router: &'node_cfg test_utils::TestMessageRouter<'chan_mon_cfg>, + pub currency_conversion: &'node_cfg test_utils::TestCurrencyConversion, pub chain_monitor: &'node_cfg test_utils::TestChainMonitor<'chan_mon_cfg>, pub keys_manager: &'chan_mon_cfg test_utils::TestKeysInterface, pub node: &'chan_man TestChannelManager<'node_cfg, 'chan_mon_cfg>, @@ -743,6 +746,7 @@ pub trait NodeHolder { ::FeeEstimator, ::Router, ::MessageRouter, + ::CurrencyConversion, ::Logger, >; fn chain_monitor(&self) -> Option<&test_utils::TestChainMonitor<'_>>; @@ -760,6 +764,7 @@ impl NodeHolder for &H { ::FeeEstimator, ::Router, ::MessageRouter, + ::CurrencyConversion, ::Logger, > { (*self).node() @@ -890,6 +895,7 @@ impl<'a, 'b, 'c> Drop for Node<'a, 'b, 'c> { &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, + &test_utils::TestCurrencyConversion, &test_utils::TestLogger, >, )>::read( @@ -909,6 +915,7 @@ impl<'a, 'b, 'c> Drop for Node<'a, 'b, 'c> { network_graph, self.keys_manager, ), + currency_conversion: &test_utils::TestCurrencyConversion, chain_monitor: self.chain_monitor, tx_broadcaster: &broadcaster, logger: &self.logger, @@ -1344,6 +1351,7 @@ pub fn _reload_node<'a, 'b, 'c>( fee_estimator: node.fee_estimator, router: node.router, message_router: node.message_router, + currency_conversion: node.currency_conversion, chain_monitor: node.chain_monitor, tx_broadcaster: node.tx_broadcaster, logger: node.logger, @@ -4600,6 +4608,7 @@ where Arc::clone(&network_graph), &cfg.keys_manager, ), + currency_conversion: test_utils::TestCurrencyConversion, chain_monitor, keys_manager: &cfg.keys_manager, node_seed: seed, @@ -4685,6 +4694,7 @@ pub fn create_node_chanmgrs<'a, 'b>( &'b test_utils::TestFeeEstimator, &'a test_utils::TestRouter<'b>, &'a test_utils::TestMessageRouter<'b>, + &'a test_utils::TestCurrencyConversion, &'b test_utils::TestLogger, >, > { @@ -4699,6 +4709,7 @@ pub fn create_node_chanmgrs<'a, 'b>( cfgs[i].tx_broadcaster, &cfgs[i].router, &cfgs[i].message_router, + &cfgs[i].currency_conversion, cfgs[i].logger, cfgs[i].keys_manager, cfgs[i].keys_manager, @@ -4729,6 +4740,7 @@ pub fn create_network<'a, 'b: 'a, 'c: 'b>( &'c test_utils::TestFeeEstimator, &'c test_utils::TestRouter, &'c test_utils::TestMessageRouter, + &'c test_utils::TestCurrencyConversion, &'c test_utils::TestLogger, >, >, @@ -4797,6 +4809,7 @@ pub fn create_network<'a, 'b: 'a, 'c: 'b>( fee_estimator: cfgs[i].fee_estimator, router: &cfgs[i].router, message_router: &cfgs[i].message_router, + currency_conversion: &cfgs[i].currency_conversion, chain_monitor: &cfgs[i].chain_monitor, keys_manager: &cfgs[i].keys_manager, node: &chan_mgrs[i], diff --git a/lightning/src/ln/functional_tests.rs b/lightning/src/ln/functional_tests.rs index 09a87d93156..95ea758d9f0 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -4867,6 +4867,7 @@ pub fn test_key_derivation_params() { test_utils::TestRouter::new(Arc::clone(&network_graph), &chanmon_cfgs[0].logger, &scorer); let message_router = test_utils::TestMessageRouter::new_default(Arc::clone(&network_graph), &keys_manager); + let currency_conversion = test_utils::TestCurrencyConversion {}; let node = NodeCfg { chain_source: &chanmon_cfgs[0].chain_source, logger: &chanmon_cfgs[0].logger, @@ -4874,6 +4875,7 @@ pub fn test_key_derivation_params() { fee_estimator: &chanmon_cfgs[0].fee_estimator, router, message_router, + currency_conversion, chain_monitor, keys_manager: &keys_manager, network_graph, diff --git a/lightning/src/ln/reload_tests.rs b/lightning/src/ln/reload_tests.rs index bb730f8fba8..0c62bde34d5 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -427,7 +427,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut nodes_0_read = &nodes_0_serialized[..]; if let Err(msgs::DecodeError::DangerousValue) = - <(BlockHash, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { + <(BlockHash, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestCurrencyConversion, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { config: UserConfig::default(), entropy_source: keys_manager, node_signer: keys_manager, @@ -435,6 +435,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { fee_estimator: &fee_estimator, router: &nodes[0].router, message_router: &nodes[0].message_router, + currency_conversion: &nodes[0].currency_conversion, chain_monitor: nodes[0].chain_monitor, tx_broadcaster: nodes[0].tx_broadcaster, logger: &logger, @@ -446,7 +447,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut nodes_0_read = &nodes_0_serialized[..]; let (_, nodes_0_deserialized_tmp) = - <(BlockHash, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { + <(BlockHash, ChannelManager<&test_utils::TestChainMonitor, &test_utils::TestBroadcaster, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestKeysInterface, &test_utils::TestFeeEstimator, &test_utils::TestRouter, &test_utils::TestMessageRouter, &test_utils::TestCurrencyConversion, &test_utils::TestLogger>)>::read(&mut nodes_0_read, ChannelManagerReadArgs { config: UserConfig::default(), entropy_source: keys_manager, node_signer: keys_manager, @@ -454,6 +455,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { fee_estimator: &fee_estimator, router: nodes[0].router, message_router: &nodes[0].message_router, + currency_conversion: &nodes[0].currency_conversion, chain_monitor: nodes[0].chain_monitor, tx_broadcaster: nodes[0].tx_broadcaster, logger: &logger, diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 6e7293cee6b..d904b45db2f 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -26,6 +26,7 @@ use crate::blinded_path::payment::{ }; use crate::chain::channelmonitor::LATENCY_GRACE_PERIOD_BLOCKS; +use crate::offers::currency::CurrencyConversion; #[allow(unused_imports)] use crate::prelude::*; @@ -73,7 +74,7 @@ use { /// /// [`OffersMessageFlow`] is parameterized by a [`MessageRouter`], which is responsible /// for finding message paths when initiating and retrying onion messages. -pub struct OffersMessageFlow { +pub struct OffersMessageFlow { chain_hash: ChainHash, best_block: RwLock, @@ -86,6 +87,8 @@ pub struct OffersMessageFlow { secp_ctx: Secp256k1, message_router: MR, + pub(crate) currency_conversion: CC, + #[cfg(not(any(test, feature = "_test_utils")))] pending_offers_messages: Mutex>, #[cfg(any(test, feature = "_test_utils"))] @@ -102,13 +105,13 @@ pub struct OffersMessageFlow { logger: L, } -impl OffersMessageFlow { +impl OffersMessageFlow { /// Creates a new [`OffersMessageFlow`] pub fn new( 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, + currency_conversion: CC, logger: L, ) -> Self { Self { chain_hash, @@ -123,6 +126,8 @@ impl OffersMessageFlow { secp_ctx, message_router, + currency_conversion, + pending_offers_messages: Mutex::new(Vec::new()), pending_async_payments_messages: Mutex::new(Vec::new()), @@ -257,7 +262,7 @@ const DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY: Duration = Duration::from_secs(365 * 2 pub(crate) const TEST_DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY: Duration = DEFAULT_ASYNC_RECEIVE_OFFER_EXPIRY; -impl OffersMessageFlow { +impl OffersMessageFlow { /// [`BlindedMessagePath`]s for an async recipient to communicate with this node and interactively /// build [`Offer`]s and [`StaticInvoice`]s for receiving async payments. /// @@ -450,7 +455,7 @@ pub enum HeldHtlcReplyPath { }, } -impl OffersMessageFlow { +impl OffersMessageFlow { /// Verifies an [`InvoiceRequest`] using the provided [`OffersContext`] or the [`InvoiceRequest::metadata`]. /// /// - If an [`OffersContext::InvoiceRequest`] with a `nonce` is provided, verification is performed using recipient context data. diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 22be4367c7a..f80dde0392a 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -30,7 +30,9 @@ use crate::ln::msgs::{BaseMessageHandler, MessageSendEvent}; use crate::ln::script::ShutdownScript; use crate::ln::types::ChannelId; use crate::ln::{msgs, wire}; +use crate::offers::currency::CurrencyConversion; use crate::offers::invoice::UnsignedBolt12Invoice; +use crate::offers::offer::CurrencyCode; use crate::onion_message::messenger::{ DefaultMessageRouter, Destination, MessageRouter, NodeIdMessageRouter, OnionMessagePath, }; @@ -447,6 +449,22 @@ impl<'a> MessageRouter for TestMessageRouter<'a> { } } +pub struct TestCurrencyConversion; + +impl CurrencyConversion for TestCurrencyConversion { + fn msats_per_minor_unit(&self, iso4217_code: CurrencyCode) -> Result { + if iso4217_code.as_str() == "USD" { + Ok(1_000) // 1 cent = 1000 msats (test-only fixed rate) + } else { + Err(()) + } + } + + fn tolerance_percent(&self) -> u8 { + 0 + } +} + pub struct OnlyReadsKeysInterface {} impl EntropySource for OnlyReadsKeysInterface { From 884e0583565f950191066d504516047881782528 Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 2 Mar 2026 16:12:58 +0530 Subject: [PATCH 3/6] Support setting fiat-denominated amounts in Offers Extends `OfferBuilder` to allow creating Offers whose amount is denominated in a fiat currency instead of millisatoshis. To ensure such Offers can later be processed correctly, currency amounts may only be set when the caller provides a `CurrencyConversion` implementation capable of resolving the amount into millisatoshis. Since amount correctness checks are now performed directly in the amount setters, they can be removed from the `build()` method. This introduces the first layer of currency support in Offers, allowing them to be created with currency-denominated amounts. --- lightning/src/offers/invoice_request.rs | 25 ++++++--- lightning/src/offers/merkle.rs | 11 ++-- lightning/src/offers/offer.rs | 75 ++++++++++++++++--------- 3 files changed, 73 insertions(+), 38 deletions(-) diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 4311d194dca..1e3a0869aea 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::currency::CurrencyConversion; use crate::offers::invoice::{DerivedSigningPubkey, ExplicitSigningPubkey, SigningPubkeyStrategy}; use crate::offers::merkle::{ self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, @@ -1573,6 +1574,7 @@ mod tests { use crate::types::features::{InvoiceRequestFeatures, OfferFeatures}; use crate::types::string::{PrintableString, UntrustedString}; use crate::util::ser::{BigSize, Readable, Writeable}; + use crate::util::test_utils::TestCurrencyConversion; use bitcoin::constants::ChainHash; use bitcoin::network::Network; use bitcoin::secp256k1::{self, Keypair, Secp256k1, SecretKey}; @@ -2063,6 +2065,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let supported_conversion = TestCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2094,10 +2097,11 @@ mod tests { assert_eq!(tlv_stream.amount, None); let invoice_request = OfferBuilder::new(recipient_pubkey()) - .amount(Amount::Currency { - iso4217_code: CurrencyCode::new(*b"USD").unwrap(), - amount: 10, - }) + .amount( + Amount::Currency { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 10 }, + &supported_conversion, + ) + .unwrap() .build_unchecked() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() @@ -2398,6 +2402,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let supported_conversion = TestCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2472,10 +2477,14 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .description("foo".to_string()) - .amount(Amount::Currency { - iso4217_code: CurrencyCode::new(*b"USD").unwrap(), - amount: 1000, - }) + .amount( + Amount::Currency { + iso4217_code: CurrencyCode::new(*b"USD").unwrap(), + amount: 1000, + }, + &supported_conversion, + ) + .unwrap() .build_unchecked() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() diff --git a/lightning/src/offers/merkle.rs b/lightning/src/offers/merkle.rs index 1a38fe5441f..2de117cb402 100644 --- a/lightning/src/offers/merkle.rs +++ b/lightning/src/offers/merkle.rs @@ -293,6 +293,7 @@ mod tests { use crate::offers::signer::Metadata; use crate::offers::test_utils::recipient_pubkey; use crate::util::ser::Writeable; + use crate::util::test_utils::TestCurrencyConversion; use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::FromHex; use bitcoin::secp256k1::schnorr::Signature; @@ -335,6 +336,7 @@ mod tests { let nonce = Nonce([0u8; 16]); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let supported_conversion = TestCurrencyConversion; let recipient_pubkey = { let secret_bytes = >::from_hex( @@ -356,10 +358,11 @@ mod tests { // BOLT 12 test vectors let invoice_request = OfferBuilder::new(recipient_pubkey) .description("A Mathematical Treatise".into()) - .amount(Amount::Currency { - iso4217_code: CurrencyCode::new(*b"USD").unwrap(), - amount: 100, - }) + .amount( + Amount::Currency { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 100 }, + &supported_conversion, + ) + .unwrap() .build_unchecked() // Override the payer metadata and signing pubkey to match the test vectors .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index a75a25c464b..2f92affa3f4 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -82,7 +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::currency::CurrencyConversion; +use crate::offers::currency::{CurrencyConversion, DefaultCurrencyConversion}; use crate::offers::merkle::{TaggedHash, TlvRecord, TlvStream}; use crate::offers::nonce::Nonce; use crate::offers::parse::{Bech32Encode, Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; @@ -340,19 +340,27 @@ macro_rules! offer_builder_methods { ( $return_value } - /// Sets the [`Offer::amount`] as an [`Amount::Bitcoin`]. + /// Sets the [`Offer::amount`] in millisatoshis. /// - /// Successive calls to this method will override the previous setting. + /// Internally this sets the amount as [`Amount::Bitcoin`]. + /// + /// Successive calls to this method override the previously set amount. pub fn amount_msats($self: $self_type, amount_msats: u64) -> $return_type { - $self.amount(Amount::Bitcoin { amount_msats }) + $self.amount(Amount::Bitcoin { amount_msats }, &DefaultCurrencyConversion) + .expect("Setting amount in msats cannot fail") } /// Sets the [`Offer::amount`]. /// /// Successive calls to this method will override the previous setting. - pub(super) fn amount($($self_mut)* $self: $self_type, amount: Amount) -> $return_type { + pub fn amount($($self_mut)* $self: $self_type, amount: Amount, currency_conversion: &CC) -> Result<$return_type, Bolt12SemanticError> + { + if amount.into_msats(currency_conversion)? > MAX_VALUE_MSAT { + return Err(Bolt12SemanticError::InvalidAmount); + } + $self.offer.amount = Some(amount); - $return_value + Ok($return_value) } /// Sets the [`Offer::absolute_expiry`] as seconds since the Unix epoch. @@ -401,16 +409,6 @@ macro_rules! offer_builder_methods { ( /// Builds an [`Offer`] from the builder's settings. pub fn build($($self_mut)* $self: $self_type) -> Result { - match $self.offer.amount { - Some(Amount::Bitcoin { amount_msats }) => { - if amount_msats > MAX_VALUE_MSAT { - return Err(Bolt12SemanticError::InvalidAmount); - } - }, - Some(Amount::Currency { .. }) => return Err(Bolt12SemanticError::UnsupportedCurrency), - None => {}, - } - if $self.offer.amount.is_some() && $self.offer.description.is_none() { $self.offer.description = Some(String::new()); } @@ -709,6 +707,20 @@ macro_rules! offer_accessors { ($self: ident, $contents: expr) => { pub fn issuer_signing_pubkey(&$self) -> Option { $contents.issuer_signing_pubkey() } + + /// Resolves the [`Offer::amount`] into millisatoshis. + /// + /// If the offer amount is denominated in a fiat currency, the provided + /// [`CurrencyConversion`] implementation is used to convert it into msats. + /// + /// Returns: + /// - `Ok(Some(msats))` if the offer specifies an amount and it can be resolved. + /// - `Ok(None)` if the offer does not specify an amount. + /// - `Err(_)` if the amount cannot be resolved (e.g., unsupported currency). + pub fn resolve_offer_amount(&$self, currency_conversion: &CC) -> Result, Bolt12SemanticError> + { + $contents.resolve_offer_amount(currency_conversion) + } } } impl Offer { @@ -994,6 +1006,12 @@ impl OfferContents { self.issuer_signing_pubkey } + pub(super) fn resolve_offer_amount( + &self, currency_conversion: &CC, + ) -> Result, Bolt12SemanticError> { + self.amount().map(|amt| amt.into_msats(currency_conversion)).transpose() + } + pub(super) fn verify_using_metadata( &self, bytes: &[u8], key: &ExpandedKey, secp_ctx: &Secp256k1, ) -> Result<(OfferId, Option), ()> { @@ -1417,6 +1435,7 @@ mod tests { use crate::types::features::OfferFeatures; use crate::types::string::PrintableString; use crate::util::ser::{BigSize, Writeable}; + use crate::util::test_utils::TestCurrencyConversion; use bitcoin::constants::ChainHash; use bitcoin::network::Network; use bitcoin::secp256k1::Secp256k1; @@ -1689,24 +1708,26 @@ mod tests { assert_eq!(tlv_stream.0.amount, Some(1000)); assert_eq!(tlv_stream.0.currency, None); + let conversion = TestCurrencyConversion; + #[cfg(not(c_bindings))] - let builder = OfferBuilder::new(pubkey(42)).amount(currency_amount.clone()); + let builder = OfferBuilder::new(pubkey(42)).amount(currency_amount.clone(), &conversion).unwrap(); #[cfg(c_bindings)] let mut builder = OfferBuilder::new(pubkey(42)); #[cfg(c_bindings)] - builder.amount(currency_amount.clone()); + let _ = builder.amount(currency_amount.clone(), &conversion); + + // Currency-denominated amounts are now supported, so setting the amount should succeed. let tlv_stream = builder.offer.as_tlv_stream(); assert_eq!(builder.offer.amount, Some(currency_amount.clone())); assert_eq!(tlv_stream.0.amount, Some(10)); assert_eq!(tlv_stream.0.currency, Some(b"USD")); - match builder.build() { - Ok(_) => panic!("expected error"), - Err(e) => assert_eq!(e, Bolt12SemanticError::UnsupportedCurrency), - } let offer = OfferBuilder::new(pubkey(42)) - .amount(currency_amount.clone()) - .amount(bitcoin_amount.clone()) + .amount(currency_amount.clone(), &conversion) + .unwrap() + .amount(bitcoin_amount.clone(), &conversion) + .unwrap() .build() .unwrap(); let tlv_stream = offer.as_tlv_stream(); @@ -1714,7 +1735,7 @@ mod tests { assert_eq!(tlv_stream.0.currency, None); let invalid_amount = Amount::Bitcoin { amount_msats: MAX_VALUE_MSAT + 1 }; - match OfferBuilder::new(pubkey(42)).amount(invalid_amount).build() { + match OfferBuilder::new(pubkey(42)).amount(invalid_amount, &conversion) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidAmount), } @@ -1912,8 +1933,10 @@ mod tests { #[test] fn parses_offer_with_amount() { + let conversion = TestCurrencyConversion; let offer = OfferBuilder::new(pubkey(42)) - .amount(Amount::Bitcoin { amount_msats: 1000 }) + .amount(Amount::Bitcoin { amount_msats: 1000 }, &conversion) + .unwrap() .build() .unwrap(); if let Err(e) = offer.to_string().parse::() { From f1d249a5c7f4f777fc9f36477849d331fbf42bf9 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 28 Feb 2026 18:39:14 +0530 Subject: [PATCH 4/6] Introduce CurrencyConversion in InvoiceRequest builder To support currency-denominated Offers, the InvoiceRequest builder needs to resolve the Offer amount at multiple points during construction. This occurs when explicitly setting `amount_msats` and again when the InvoiceRequest is finalized via `build()`. To avoid repeatedly passing a `CurrencyConversion` implementation into these checks, the builder now stores a reference to it at creation time. This allows the builder to resolve currency-denominated Offer amounts whenever validation requires it. As part of this change, `InvoiceRequest::amount_msats()` is updated to use the provided `CurrencyConversion` to resolve the underlying Offer amount when necessary. --- fuzz/src/offer_deser.rs | 5 +- lightning/src/ln/async_payments_tests.rs | 4 + lightning/src/ln/channelmanager.rs | 25 +- lightning/src/ln/offers_tests.rs | 28 ++- lightning/src/ln/outbound_payment.rs | 14 +- lightning/src/offers/flow.rs | 13 +- lightning/src/offers/invoice.rs | 124 +++++++--- lightning/src/offers/invoice_request.rs | 291 ++++++++++++++--------- lightning/src/offers/merkle.rs | 15 +- lightning/src/offers/offer.rs | 97 +++++--- lightning/src/offers/refund.rs | 7 +- 11 files changed, 402 insertions(+), 221 deletions(-) diff --git a/fuzz/src/offer_deser.rs b/fuzz/src/offer_deser.rs index 68902ab3150..df356ff7c67 100644 --- a/fuzz/src/offer_deser.rs +++ b/fuzz/src/offer_deser.rs @@ -12,6 +12,7 @@ use bitcoin::secp256k1::Secp256k1; use core::convert::TryFrom; use lightning::ln::channelmanager::PaymentId; use lightning::ln::inbound_payment::ExpandedKey; +use lightning::offers::currency::DefaultCurrencyConversion; use lightning::offers::invoice_request::InvoiceRequest; use lightning::offers::nonce::Nonce; use lightning::offers::offer::{Amount, Offer, Quantity}; @@ -48,13 +49,13 @@ fn build_request(offer: &Offer) -> Result { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = DefaultCurrencyConversion; let mut builder = offer.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)?; builder = match offer.amount() { None => builder.amount_msats(1000).unwrap(), - Some(Amount::Bitcoin { amount_msats }) => builder.amount_msats(amount_msats + 1)?, - Some(Amount::Currency { .. }) => return Err(Bolt12SemanticError::UnsupportedCurrency), + Some(amount) => builder.amount_msats(amount.to_msats(&conversion)?)?, }; builder = match offer.supported_quantity() { diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 25522346d9c..e2ca746d3d4 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -60,6 +60,7 @@ use crate::types::features::Bolt12InvoiceFeatures; use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use crate::util::config::{HTLCInterceptionFlags, UserConfig}; use crate::util::ser::Writeable; +use crate::util::test_utils::TestCurrencyConversion; use bitcoin::constants::ChainHash; use bitcoin::network::Network; use bitcoin::secp256k1; @@ -1448,6 +1449,8 @@ fn amount_doesnt_match_invreq() { let amt_msat = 5000; let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; + nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); let release_held_htlc_om_3_0 = pass_async_payments_oms( static_invoice, @@ -1471,6 +1474,7 @@ fn amount_doesnt_match_invreq() { Nonce::from_entropy_source(nodes[0].keys_manager), &secp_ctx, payment_id, + &conversion, ) .unwrap() .amount_msats(amt_msat + 1) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 9bee21a1608..185223fe3c1 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8373,12 +8373,25 @@ impl< }); let verified_invreq = match verify_opt { Some(verified_invreq) => { - if let Some(invreq_amt_msat) = - verified_invreq.amount_msats() + match verified_invreq + .amount_msats(&self.flow.currency_conversion) { - if payment_data.total_msat < invreq_amt_msat { - fail_htlc!(claimable_htlc, payment_hash); - } + Ok(invreq_amt_msat) => { + if payment_data.total_msat < invreq_amt_msat { + fail_htlc!(claimable_htlc, payment_hash); + } + }, + Err(_) => { + // `amount_msats()` can only fail if the invoice request does not specify an amount + // and the underlying offer's amount cannot be resolved. + // + // This invoice request corresponds to an offer we constructed, and we only allow + // creating offers with currency amounts that the node explicitly supports. + // + // Therefore, amount resolution must succeed here. Reaching this branch indicates + // an internal logic error. + debug_assert!(false); + }, } verified_invreq }, @@ -14193,10 +14206,12 @@ impl< None => builder, Some(quantity) => builder.quantity(quantity)?, }; + let builder = match amount_msats { None => builder, Some(amount_msats) => builder.amount_msats(amount_msats)?, }; + let builder = match payer_note { None => builder, Some(payer_note) => builder.payer_note(payer_note), diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index de08af5d276..bed47610a2d 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -73,6 +73,7 @@ use crate::util::ser::Writeable; const MAX_SHORT_LIVED_RELATIVE_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24); use crate::prelude::*; +use crate::util::test_utils::TestCurrencyConversion; macro_rules! expect_recent_payment { ($node: expr, $payment_state: path, $payment_id: expr) => {{ @@ -517,12 +518,14 @@ fn check_dummy_hop_pattern_in_offer() { } let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; + bob.node.pay_for_offer(&compact_offer, None, payment_id, Default::default()).unwrap(); let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); - assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); assert!(check_dummy_hopped_path_length(&reply_path, alice, bob_id, DUMMY_HOPS_PATH_LENGTH)); @@ -544,7 +547,7 @@ fn check_dummy_hop_pattern_in_offer() { let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); - assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); assert!(check_dummy_hopped_path_length(&reply_path, alice, bob_id, DUMMY_HOPS_PATH_LENGTH)); } @@ -706,6 +709,8 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { } let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; + david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); @@ -729,7 +734,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { human_readable_name: None, }, }); - assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), david_id); assert!(check_dummy_hopped_path_length(&reply_path, bob, charlie_id, DUMMY_HOPS_PATH_LENGTH)); @@ -871,6 +876,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { } let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); @@ -887,7 +893,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { human_readable_name: None, }, }); - assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); assert!(check_dummy_hopped_path_length(&reply_path, alice, bob_id, DUMMY_HOPS_PATH_LENGTH)); @@ -1253,6 +1259,7 @@ fn creates_and_pays_for_offer_with_retry() { assert!(check_compact_path_introduction_node(&path, bob, alice_id)); } let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); @@ -1276,7 +1283,7 @@ fn creates_and_pays_for_offer_with_retry() { human_readable_name: None, }, }); - assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); assert!(check_dummy_hopped_path_length(&reply_path, alice, bob_id, DUMMY_HOPS_PATH_LENGTH)); let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); @@ -1576,6 +1583,8 @@ fn fails_authentication_when_handling_invoice_request() { // Send the invoice request directly to Alice instead of using a blinded path. let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; + david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); @@ -1590,7 +1599,7 @@ fn fails_authentication_when_handling_invoice_request() { alice.onion_messenger.handle_onion_message(david_id, &onion_message); let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); - assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), david_id); assert!(check_dummy_hopped_path_length(&reply_path, david, charlie_id, DUMMY_HOPS_PATH_LENGTH)); @@ -1619,7 +1628,7 @@ fn fails_authentication_when_handling_invoice_request() { alice.onion_messenger.handle_onion_message(bob_id, &onion_message); let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); - assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), david_id); assert!(check_dummy_hopped_path_length(&reply_path, david, charlie_id, DUMMY_HOPS_PATH_LENGTH)); @@ -1693,6 +1702,8 @@ fn fails_authentication_when_handling_invoice_for_offer() { }; let payment_id = PaymentId([2; 32]); + let conversion = TestCurrencyConversion; + david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); @@ -1719,7 +1730,7 @@ fn fails_authentication_when_handling_invoice_for_offer() { alice.onion_messenger.handle_onion_message(bob_id, &onion_message); let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); - assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), david_id); assert!(check_dummy_hopped_path_length(&reply_path, david, charlie_id, DUMMY_HOPS_PATH_LENGTH)); @@ -1973,6 +1984,7 @@ fn fails_creating_invoice_request_for_unsupported_chain() { .create_offer_builder().unwrap() .clear_chains() .chain(Network::Signet) + .amount_msats(1_000) .build().unwrap(); match bob.node.pay_for_offer(&offer, None, PaymentId([1; 32]), Default::default()) { diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index b08b0f5a886..2bccd7d4b0b 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -2932,7 +2932,7 @@ mod tests { use crate::util::errors::APIError; use crate::util::hash_tables::new_hash_map; use crate::util::logger::WithContext; - use crate::util::test_utils; + use crate::util::test_utils::{self, TestCurrencyConversion}; use alloc::collections::VecDeque; @@ -3295,6 +3295,7 @@ mod tests { let pending_events = Mutex::new(VecDeque::new()); let outbound_payments = OutboundPayments::new(new_hash_map()); let payment_id = PaymentId([0; 32]); + let conversion = TestCurrencyConversion; let expiration = StaleExpiration::AbsoluteTimeout(Duration::from_secs(100)); assert!( @@ -3308,7 +3309,7 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build().unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion).unwrap() .build_and_sign().unwrap() .respond_with_no_std(payment_paths(), payment_hash(), created_at).unwrap() .build().unwrap() @@ -3352,12 +3353,13 @@ mod tests { let expanded_key = ExpandedKey::new([42; 32]); let nonce = Nonce([0; 16]); let payment_id = PaymentId([0; 32]); + let conversion = TestCurrencyConversion; let expiration = StaleExpiration::AbsoluteTimeout(Duration::from_secs(100)); let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build().unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion).unwrap() .build_and_sign().unwrap() .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() .build().unwrap() @@ -3417,12 +3419,13 @@ mod tests { let expanded_key = ExpandedKey::new([42; 32]); let nonce = Nonce([0; 16]); let payment_id = PaymentId([0; 32]); + let conversion = TestCurrencyConversion; let expiration = StaleExpiration::AbsoluteTimeout(Duration::from_secs(100)); let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build().unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion).unwrap() .build_and_sign().unwrap() .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() .build().unwrap() @@ -3507,11 +3510,12 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build().unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index d904b45db2f..9be156be1b3 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -833,12 +833,19 @@ impl OffersMessageFlow( &'a self, offer: &'a Offer, nonce: Nonce, payment_id: PaymentId, - ) -> Result, Bolt12SemanticError> { + ) -> Result, Bolt12SemanticError> { let expanded_key = &self.inbound_payment_key; let secp_ctx = &self.secp_ctx; + let conversion = &self.currency_conversion; + + let builder: InvoiceRequestBuilder = + offer.request_invoice(expanded_key, nonce, secp_ctx, payment_id, conversion)?.into(); + + let builder = match offer.resolve_offer_amount(conversion)? { + None => builder, + Some(amount_msats) => builder.amount_msats(amount_msats)?, + }; - let builder: InvoiceRequestBuilder = - offer.request_invoice(expanded_key, nonce, secp_ctx, payment_id)?.into(); let builder = builder.chain_hash(self.chain_hash)?; Ok(builder) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..51f6b17fbab 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -121,6 +121,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::currency::DefaultCurrencyConversion; #[cfg(test)] use crate::offers::invoice_macros::invoice_builder_methods_test_common; use crate::offers::invoice_macros::{invoice_accessors_common, invoice_builder_methods_common}; @@ -1769,10 +1770,29 @@ impl TryFrom for InvoiceContents { experimental_invoice_request_tlv_stream, ))?; - if let Some(requested_amount_msats) = invoice_request.amount_msats() { + // Note: + // It is safe to use `DefaultCurrencyConversion` here. + // + // `payable_amount_msats` can fail in two cases: + // 1. The computed payable amount is semantically invalid. + // 2. The invoice request does not specify an amount and the original offer + // is currency-denominated. + // + // Both cases are impossible at this point: + // - If the payable amount were invalid, it would have been rejected earlier. + // - If the offer amount were in currency, we always set an explicit amount + // on the invoice request. + // + // Since the invoice corresponds to the invoice request we constructed and sent, + // `payable_amount_msats` must succeed here. + if let Ok(requested_amount_msats) = + invoice_request.amount_msats(&DefaultCurrencyConversion) + { if amount_msats != requested_amount_msats { return Err(Bolt12SemanticError::InvalidAmount); } + } else { + debug_assert!(false); } Ok(InvoiceContents::ForOffer { invoice_request, fields }) @@ -1860,6 +1880,7 @@ mod tests { use crate::types::features::{Bolt12InvoiceFeatures, InvoiceRequestFeatures, OfferFeatures}; use crate::types::string::PrintableString; use crate::util::ser::{BigSize, Iterable, Writeable}; + use crate::util::test_utils::TestCurrencyConversion; #[cfg(not(c_bindings))] use {crate::offers::offer::OfferBuilder, crate::offers::refund::RefundBuilder}; #[cfg(c_bindings)] @@ -1891,6 +1912,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let encrypted_payment_id = expanded_key.crypt_for_offer(payment_id.0, nonce); let payment_paths = payment_paths(); @@ -1900,7 +1922,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2162,6 +2184,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let future_expiry = Duration::from_secs(u64::max_value()); let past_expiry = Duration::from_secs(0); @@ -2171,7 +2194,7 @@ mod tests { .absolute_expiry(future_expiry) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2187,7 +2210,7 @@ mod tests { .absolute_expiry(past_expiry) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_unchecked_and_sign() .respond_with(payment_paths(), payment_hash()) @@ -2239,6 +2262,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let blinded_path = BlindedMessagePath::from_blinded_path( pubkey(40), @@ -2258,7 +2282,7 @@ mod tests { .experimental_foo(42) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -2361,6 +2385,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let now = now(); let one_hour = Duration::from_secs(3600); @@ -2369,7 +2394,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2390,7 +2415,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2415,12 +2440,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(1001) .unwrap() @@ -2444,13 +2470,14 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .supported_quantity(Quantity::Unbounded) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .quantity(2) .unwrap() @@ -2471,7 +2498,7 @@ mod tests { .supported_quantity(Quantity::Unbounded) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .quantity(u64::max_value()) .unwrap() @@ -2490,6 +2517,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let script = ScriptBuf::new(); let pubkey = bitcoin::key::PublicKey::new(recipient_pubkey()); @@ -2500,7 +2528,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2548,6 +2576,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let mut features = Bolt12InvoiceFeatures::empty(); features.set_basic_mpp_optional(); @@ -2556,7 +2585,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2579,12 +2608,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2602,7 +2632,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2624,12 +2654,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2701,12 +2732,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2745,12 +2777,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2778,12 +2811,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2822,12 +2856,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2864,12 +2899,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -2901,6 +2937,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let script = ScriptBuf::new(); let pubkey = bitcoin::key::PublicKey::new(recipient_pubkey()); @@ -2911,7 +2948,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -2970,12 +3007,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3028,6 +3066,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let paths = [ BlindedMessagePath::from_blinded_path( @@ -3062,7 +3101,7 @@ mod tests { .path(paths[1].clone()) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3092,7 +3131,7 @@ mod tests { .path(paths[1].clone()) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3129,12 +3168,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3161,7 +3201,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(1000) .unwrap() @@ -3221,13 +3261,14 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let mut buffer = Vec::new(); OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3255,12 +3296,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let mut invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3293,6 +3335,7 @@ mod tests { let entropy = FixedEntropy {}; let nonce = Nonce::from_entropy_source(&entropy); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; const UNKNOWN_ODD_TYPE: u64 = INVOICE_TYPES.end - 1; assert!(UNKNOWN_ODD_TYPE % 2 == 1); @@ -3303,7 +3346,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3342,7 +3385,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3381,6 +3424,7 @@ mod tests { let entropy = FixedEntropy {}; let nonce = Nonce::from_entropy_source(&entropy); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let secp_ctx = Secp256k1::new(); let keys = Keypair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); @@ -3388,7 +3432,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3414,7 +3458,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3455,7 +3499,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3493,7 +3537,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3529,12 +3573,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3564,12 +3609,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap() @@ -3609,13 +3655,14 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let offer = OfferBuilder::new(recipient_pubkey()).amount_msats(1000).build().unwrap(); let offer_id = offer.id(); let invoice_request = offer - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -3657,11 +3704,12 @@ mod tests { let payment_paths = payment_paths(); let now = Duration::from_secs(123456); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let offer = OfferBuilder::new(node_id).amount_msats(1000).build().unwrap(); let invoice_request = offer - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 1e3a0869aea..51a025e001d 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -28,6 +28,7 @@ //! use lightning::ln::channelmanager::PaymentId; //! use lightning::ln::inbound_payment::ExpandedKey; //! use lightning::types::features::OfferFeatures; +//! use lightning::offers::currency::DefaultCurrencyConversion; //! use lightning::offers::invoice_request::UnsignedInvoiceRequest; //! # use lightning::offers::nonce::Nonce; //! use lightning::offers::offer::Offer; @@ -46,13 +47,14 @@ //! # let nonce = Nonce::from_entropy_source(&entropy); //! let secp_ctx = Secp256k1::new(); //! let payment_id = PaymentId([1; 32]); +//! let conversion = DefaultCurrencyConversion; //! let mut buffer = Vec::new(); //! //! # use lightning::offers::invoice_request::InvoiceRequestBuilder; -//! # >::from( +//! # >::from( //! "lno1qcp4256ypq" //! .parse::()? -//! .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)? +//! .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion)? //! # ) //! .chain(Network::Testnet)? //! .amount_msats(1000)? @@ -71,15 +73,15 @@ use crate::io; use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::{ExpandedKey, IV_LEN}; use crate::ln::msgs::DecodeError; -use crate::offers::currency::CurrencyConversion; +use crate::offers::currency::{CurrencyConversion, DefaultCurrencyConversion}; use crate::offers::invoice::{DerivedSigningPubkey, ExplicitSigningPubkey, SigningPubkeyStrategy}; use crate::offers::merkle::{ self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, }; use crate::offers::nonce::Nonce; use crate::offers::offer::{ - Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, Offer, OfferContents, - OfferId, OfferTlvStream, OfferTlvStreamRef, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, + 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}; @@ -119,11 +121,12 @@ pub(super) const IV_BYTES: &[u8; IV_LEN] = b"LDK Invreq ~~~~~"; /// This is not exported to bindings users as builder patterns don't map outside of move semantics. /// /// [module-level documentation]: self -pub struct InvoiceRequestBuilder<'a, 'b, T: secp256k1::Signing> { +pub struct InvoiceRequestBuilder<'a, 'b, T: secp256k1::Signing, CC: CurrencyConversion> { offer: &'a Offer, invoice_request: InvoiceRequestContentsWithoutPayerSigningPubkey, payer_signing_pubkey: Option, secp_ctx: Option<&'b Secp256k1>, + currency_conversion: &'a CC, } /// Builds an [`InvoiceRequest`] from an [`Offer`] for the "offer to be paid" flow. @@ -132,11 +135,12 @@ pub struct InvoiceRequestBuilder<'a, 'b, T: secp256k1::Signing> { /// /// [module-level documentation]: self #[cfg(c_bindings)] -pub struct InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b> { +pub struct InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b, CC: CurrencyConversion> { offer: &'a Offer, invoice_request: InvoiceRequestContentsWithoutPayerSigningPubkey, payer_signing_pubkey: Option, secp_ctx: Option<&'b Secp256k1>, + currency_conversion: &'a CC, } macro_rules! invoice_request_derived_payer_signing_pubkey_builder_methods { @@ -147,6 +151,7 @@ macro_rules! invoice_request_derived_payer_signing_pubkey_builder_methods { pub(super) fn deriving_signing_pubkey( offer: &'a Offer, expanded_key: &ExpandedKey, nonce: Nonce, secp_ctx: &'b Secp256k1<$secp_context>, payment_id: PaymentId, + currency_conversion: &'a CC, ) -> Self { let payment_id = Some(payment_id); let derivation_material = MetadataMaterial::new(nonce, expanded_key, payment_id); @@ -156,6 +161,7 @@ macro_rules! invoice_request_derived_payer_signing_pubkey_builder_methods { invoice_request: Self::create_contents(offer, metadata), payer_signing_pubkey: None, secp_ctx: Some(secp_ctx), + currency_conversion, } } @@ -224,7 +230,7 @@ macro_rules! invoice_request_builder_methods { ( /// [`quantity`]: Self::quantity pub fn amount_msats($($self_mut)* $self: $self_type, amount_msats: u64) -> Result<$return_type, Bolt12SemanticError> { $self.invoice_request.offer.check_amount_msats_for_quantity( - Some(amount_msats), $self.invoice_request.quantity + $self.currency_conversion, Some(amount_msats), $self.invoice_request.quantity )?; $self.invoice_request.amount_msats = Some(amount_msats); Ok($return_value) @@ -281,7 +287,7 @@ macro_rules! invoice_request_builder_methods { ( $self.invoice_request.offer.check_quantity($self.invoice_request.quantity)?; $self.invoice_request.offer.check_amount_msats_for_quantity( - $self.invoice_request.amount_msats, $self.invoice_request.quantity + $self.currency_conversion, $self.invoice_request.amount_msats, $self.invoice_request.quantity )?; Ok($self.build_without_checks()) @@ -401,7 +407,7 @@ macro_rules! invoice_request_builder_test_methods { ( } } } -impl<'a, 'b, T: secp256k1::Signing> InvoiceRequestBuilder<'a, 'b, T> { +impl<'a, 'b, T: secp256k1::Signing, CC: CurrencyConversion> InvoiceRequestBuilder<'a, 'b, T, CC> { invoice_request_derived_payer_signing_pubkey_builder_methods!(self, Self, T); invoice_request_builder_methods!(self, Self, Self, self, T, mut); @@ -410,31 +416,37 @@ impl<'a, 'b, T: secp256k1::Signing> InvoiceRequestBuilder<'a, 'b, T> { } #[cfg(all(c_bindings, not(test)))] -impl<'a, 'b> InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b> { +impl<'a, 'b, CC: CurrencyConversion> + InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b, CC> +{ invoice_request_derived_payer_signing_pubkey_builder_methods!(self, &mut Self, secp256k1::All); invoice_request_builder_methods!(self, &mut Self, (), (), secp256k1::All); } #[cfg(all(c_bindings, test))] -impl<'a, 'b> InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b> { +impl<'a, 'b, CC: CurrencyConversion> + InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b, CC> +{ invoice_request_derived_payer_signing_pubkey_builder_methods!(self, &mut Self, secp256k1::All); invoice_request_builder_methods!(self, &mut Self, &mut Self, self, secp256k1::All); invoice_request_builder_test_methods!(self, &mut Self, &mut Self, self); } #[cfg(c_bindings)] -impl<'a, 'b> From> - for InvoiceRequestBuilder<'a, 'b, secp256k1::All> +impl<'a, 'b, CC: CurrencyConversion> + From> + for InvoiceRequestBuilder<'a, 'b, secp256k1::All, CC> { - fn from(builder: InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b>) -> Self { + fn from(builder: InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b, CC>) -> Self { let InvoiceRequestWithDerivedPayerSigningPubkeyBuilder { offer, invoice_request, payer_signing_pubkey, secp_ctx, + currency_conversion, } = builder; - Self { offer, invoice_request, payer_signing_pubkey, secp_ctx } + Self { offer, invoice_request, payer_signing_pubkey, secp_ctx, currency_conversion } } } @@ -705,12 +717,21 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { $contents.chain() } - /// The amount to pay in msats (i.e., the minimum lightning-payable unit for [`chain`]), which - /// must be greater than or equal to [`Offer::amount`], converted if necessary. + /// Returns the total amount requested by this invoice request, in millisatoshis. + /// + /// If the invoice request explicitly sets an amount, that value is returned. + /// Otherwise, the amount is derived from [`Offer::amount`], multiplied by the + /// requested [`quantity`], and converted to millisatoshis if the offer amount + /// is currency-denominated. + /// + /// This returns an error if the effective amount is semantically invalid + /// (for example due to unsupported currency conversion or arithmetic overflow). /// - /// [`chain`]: Self::chain - pub fn amount_msats(&$self) -> Option { - $contents.amount_msats() + /// [`amount_msats`]: Self::amount_msats + /// [`quantity`]: Self::quantity + pub fn amount_msats(&$self, currency_conversion: &CC) -> Result + { + $contents.amount_msats(currency_conversion) } /// Returns whether an amount was set in the request; otherwise, if [`amount_msats`] is `Some` @@ -949,7 +970,7 @@ impl InvoiceRequest { invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self, - InvoiceWithExplicitSigningPubkeyBuilder + InvoiceWithExplicitSigningPubkeyBuilder<'_> ); invoice_request_verify_method!(self, &Self); @@ -1084,7 +1105,7 @@ impl VerifiedInvoiceRequest { invoice_request_respond_with_derived_signing_pubkey_methods!( self, self.inner, - InvoiceWithDerivedSigningPubkeyBuilder + InvoiceWithDerivedSigningPubkeyBuilder<'_> ); } @@ -1103,7 +1124,7 @@ impl VerifiedInvoiceRequest { invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self.inner, - InvoiceWithExplicitSigningPubkeyBuilder + InvoiceWithExplicitSigningPubkeyBuilder<'_> ); } @@ -1143,17 +1164,23 @@ impl InvoiceRequestContents { self.inner.chain() } - pub(super) fn amount_msats(&self) -> Option { - self.inner.amount_msats().or_else(|| match self.inner.offer.amount() { - Some(Amount::Bitcoin { amount_msats }) => { - Some(amount_msats.saturating_mul(self.quantity().unwrap_or(1))) - }, - Some(Amount::Currency { .. }) => None, + pub(super) fn amount_msats( + &self, currency_conversion: &CC, + ) -> Result { + match self.inner.amount_msats() { + Some(msats) => Ok(msats), None => { - debug_assert!(false); - None + let unit_msats = self + .inner + .offer + .resolve_offer_amount(currency_conversion)? + .ok_or(Bolt12SemanticError::MissingAmount)?; + + let quantity = self.quantity().unwrap_or(1); + + unit_msats.checked_mul(quantity).ok_or(Bolt12SemanticError::InvalidAmount) }, - }) + } } pub(super) fn has_amount_msats(&self) -> bool { @@ -1458,7 +1485,15 @@ impl TryFrom for InvoiceRequestContents { } offer.check_quantity(quantity)?; - offer.check_amount_msats_for_quantity(amount, quantity)?; + + match offer.check_amount_msats_for_quantity(&DefaultCurrencyConversion, amount, quantity) { + // If the offer amount is currency-denominated, we intentionally skip the + // amount check here, as currency conversion is not available at this stage. + // The corresponding validation is performed when handling the Invoice Request, + // i.e., during InvoiceBuilder creation. + Ok(()) | Err(Bolt12SemanticError::UnsupportedCurrency) => (), + Err(err) => return Err(err), + } let features = features.unwrap_or_else(InvoiceRequestFeatures::empty); @@ -1557,6 +1592,7 @@ mod tests { use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT}; + use crate::offers::currency::DefaultCurrencyConversion; use crate::offers::invoice::{Bolt12Invoice, SIGNATURE_TAG as INVOICE_SIGNATURE_TAG}; use crate::offers::invoice_request::string_truncate_safe; use crate::offers::merkle::{self, SignatureTlvStreamRef, TaggedHash, TlvStream}; @@ -1589,13 +1625,14 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let encrypted_payment_id = expanded_key.crypt_for_offer(payment_id.0, nonce); let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -1619,7 +1656,7 @@ mod tests { assert_eq!(invoice_request.supported_quantity(), Quantity::One); assert_eq!(invoice_request.issuer_signing_pubkey(), Some(recipient_pubkey())); assert_eq!(invoice_request.chain(), ChainHash::using_genesis_block(Network::Bitcoin)); - assert_eq!(invoice_request.amount_msats(), Some(1000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(1000)); assert_eq!(invoice_request.invoice_request_features(), &InvoiceRequestFeatures::empty()); assert_eq!(invoice_request.quantity(), None); assert_eq!(invoice_request.payer_note(), None); @@ -1679,6 +1716,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let future_expiry = Duration::from_secs(u64::max_value()); let past_expiry = Duration::from_secs(0); @@ -1688,7 +1726,7 @@ mod tests { .absolute_expiry(future_expiry) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() { @@ -1700,7 +1738,7 @@ mod tests { .absolute_expiry(past_expiry) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() { @@ -1716,6 +1754,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let offer = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -1723,7 +1762,7 @@ mod tests { .build() .unwrap(); let invoice_request = offer - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .experimental_bar(42) .build_and_sign() @@ -1826,6 +1865,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let mainnet = ChainHash::using_genesis_block(Network::Bitcoin); let testnet = ChainHash::using_genesis_block(Network::Testnet); @@ -1834,7 +1874,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .chain(Network::Bitcoin) .unwrap() @@ -1849,7 +1889,7 @@ mod tests { .chain(Network::Testnet) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .chain(Network::Testnet) .unwrap() @@ -1865,7 +1905,7 @@ mod tests { .chain(Network::Testnet) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .chain(Network::Bitcoin) .unwrap() @@ -1881,7 +1921,7 @@ mod tests { .chain(Network::Testnet) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .chain(Network::Bitcoin) .unwrap() @@ -1898,7 +1938,7 @@ mod tests { .chain(Network::Testnet) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .chain(Network::Bitcoin) { @@ -1911,7 +1951,7 @@ mod tests { .chain(Network::Testnet) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() { @@ -1927,12 +1967,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(1000) .unwrap() @@ -1940,14 +1981,14 @@ mod tests { .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert!(invoice_request.has_amount_msats()); - assert_eq!(invoice_request.amount_msats(), Some(1000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(1000)); assert_eq!(tlv_stream.amount, Some(1000)); let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(1001) .unwrap() @@ -1957,14 +1998,14 @@ mod tests { .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert!(invoice_request.has_amount_msats()); - assert_eq!(invoice_request.amount_msats(), Some(1000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(1000)); assert_eq!(tlv_stream.amount, Some(1000)); let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(1001) .unwrap() @@ -1972,14 +2013,14 @@ mod tests { .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert!(invoice_request.has_amount_msats()); - assert_eq!(invoice_request.amount_msats(), Some(1001)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(1001)); assert_eq!(tlv_stream.amount, Some(1001)); match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(999) { @@ -1992,7 +2033,7 @@ mod tests { .supported_quantity(Quantity::Unbounded) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .quantity(2) .unwrap() @@ -2006,7 +2047,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(MAX_VALUE_MSAT + 1) { @@ -2019,7 +2060,7 @@ mod tests { .supported_quantity(Quantity::Unbounded) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(1000) .unwrap() @@ -2034,7 +2075,7 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() { @@ -2047,7 +2088,7 @@ mod tests { .supported_quantity(Quantity::Unbounded) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .quantity(u64::max_value()) .unwrap() @@ -2065,19 +2106,20 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); - let supported_conversion = TestCurrencyConversion; + let conversion = TestCurrencyConversion; + let unsupported_conversion = DefaultCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert!(!invoice_request.has_amount_msats()); - assert_eq!(invoice_request.amount_msats(), Some(1000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(1000)); assert_eq!(tlv_stream.amount, None); let invoice_request = OfferBuilder::new(recipient_pubkey()) @@ -2085,7 +2127,7 @@ mod tests { .supported_quantity(Quantity::Unbounded) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .quantity(2) .unwrap() @@ -2093,22 +2135,26 @@ mod tests { .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert!(!invoice_request.has_amount_msats()); - assert_eq!(invoice_request.amount_msats(), Some(2000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(2000)); assert_eq!(tlv_stream.amount, None); let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount( Amount::Currency { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 10 }, - &supported_conversion, + &conversion, ) .unwrap() .build_unchecked() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_unchecked_and_sign(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert!(!invoice_request.has_amount_msats()); - assert_eq!(invoice_request.amount_msats(), None); + assert!(matches!( + invoice_request.amount_msats(&unsupported_conversion), + Err(Bolt12SemanticError::UnsupportedCurrency) + )); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(10_000)); assert_eq!(tlv_stream.amount, None); } @@ -2119,12 +2165,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .features_unchecked(InvoiceRequestFeatures::unknown()) .build_and_sign() @@ -2137,7 +2184,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .features_unchecked(InvoiceRequestFeatures::unknown()) .features_unchecked(InvoiceRequestFeatures::empty()) @@ -2155,6 +2202,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let one = NonZeroU64::new(1).unwrap(); let ten = NonZeroU64::new(10).unwrap(); @@ -2164,7 +2212,7 @@ mod tests { .supported_quantity(Quantity::One) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -2177,7 +2225,7 @@ mod tests { .supported_quantity(Quantity::One) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(2_000) .unwrap() @@ -2192,7 +2240,7 @@ mod tests { .supported_quantity(Quantity::Bounded(ten)) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(10_000) .unwrap() @@ -2201,7 +2249,7 @@ mod tests { .build_and_sign() .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); - assert_eq!(invoice_request.amount_msats(), Some(10_000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(10_000)); assert_eq!(tlv_stream.amount, Some(10_000)); match OfferBuilder::new(recipient_pubkey()) @@ -2209,7 +2257,7 @@ mod tests { .supported_quantity(Quantity::Bounded(ten)) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(11_000) .unwrap() @@ -2224,7 +2272,7 @@ mod tests { .supported_quantity(Quantity::Unbounded) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(2_000) .unwrap() @@ -2233,7 +2281,7 @@ mod tests { .build_and_sign() .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); - assert_eq!(invoice_request.amount_msats(), Some(2_000)); + assert_eq!(invoice_request.amount_msats(&conversion), Ok(2_000)); assert_eq!(tlv_stream.amount, Some(2_000)); match OfferBuilder::new(recipient_pubkey()) @@ -2241,7 +2289,7 @@ mod tests { .supported_quantity(Quantity::Unbounded) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() { @@ -2254,7 +2302,7 @@ mod tests { .supported_quantity(Quantity::Bounded(one)) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() { @@ -2270,12 +2318,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .payer_note("bar".into()) .build_and_sign() @@ -2288,7 +2337,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .payer_note("bar".into()) .payer_note("baz".into()) @@ -2306,12 +2355,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .features_unchecked(InvoiceRequestFeatures::unknown()) .build_and_sign() @@ -2330,12 +2380,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -2355,12 +2406,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .chain(Network::Bitcoin) .unwrap() @@ -2378,7 +2430,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .chain_unchecked(Network::Testnet) .build_unchecked_and_sign(); @@ -2402,13 +2454,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); - let supported_conversion = TestCurrencyConversion; + let conversion = TestCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -2423,7 +2475,7 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(1000) .unwrap() @@ -2440,7 +2492,7 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_unchecked_and_sign(); @@ -2459,7 +2511,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats_unchecked(999) .build_unchecked_and_sign(); @@ -2482,25 +2534,20 @@ mod tests { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 1000, }, - &supported_conversion, + &conversion, ) .unwrap() .build_unchecked() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_unchecked_and_sign(); let mut buffer = Vec::new(); invoice_request.write(&mut buffer).unwrap(); - match InvoiceRequest::try_from(buffer) { - Ok(_) => panic!("expected error"), - Err(e) => { - assert_eq!( - e, - Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::UnsupportedCurrency) - ); - }, + // Parsing must succeed now that LDK supports Offers with currency-denominated amounts. + if let Err(e) = InvoiceRequest::try_from(buffer) { + panic!("error parsing invoice_request: {:?}", e); } let invoice_request = OfferBuilder::new(recipient_pubkey()) @@ -2508,7 +2555,7 @@ mod tests { .supported_quantity(Quantity::Unbounded) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .quantity(u64::max_value()) .unwrap() @@ -2533,6 +2580,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let one = NonZeroU64::new(1).unwrap(); let ten = NonZeroU64::new(10).unwrap(); @@ -2542,7 +2590,7 @@ mod tests { .supported_quantity(Quantity::One) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -2559,7 +2607,7 @@ mod tests { .supported_quantity(Quantity::One) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(2_000) .unwrap() @@ -2584,7 +2632,7 @@ mod tests { .supported_quantity(Quantity::Bounded(ten)) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(10_000) .unwrap() @@ -2605,7 +2653,7 @@ mod tests { .supported_quantity(Quantity::Bounded(ten)) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(11_000) .unwrap() @@ -2628,7 +2676,7 @@ mod tests { .supported_quantity(Quantity::Unbounded) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .amount_msats(2_000) .unwrap() @@ -2649,7 +2697,7 @@ mod tests { .supported_quantity(Quantity::Unbounded) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_unchecked_and_sign(); @@ -2669,7 +2717,7 @@ mod tests { .supported_quantity(Quantity::Bounded(one)) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_unchecked_and_sign(); @@ -2692,12 +2740,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let unsigned_invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_unchecked(); let mut tlv_stream = unsigned_invoice_request.contents.as_tlv_stream(); @@ -2724,12 +2773,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let unsigned_invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_unchecked(); let mut tlv_stream = unsigned_invoice_request.contents.as_tlv_stream(); @@ -2754,12 +2804,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let unsigned_invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_unchecked(); let mut tlv_stream = unsigned_invoice_request.contents.as_tlv_stream(); @@ -2788,13 +2839,14 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let mut buffer = Vec::new(); OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_unchecked() .contents @@ -2817,12 +2869,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let mut invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -2849,6 +2902,7 @@ mod tests { let entropy = FixedEntropy {}; let nonce = Nonce::from_entropy_source(&entropy); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; const UNKNOWN_ODD_TYPE: u64 = INVOICE_REQUEST_TYPES.end - 1; assert!(UNKNOWN_ODD_TYPE % 2 == 1); @@ -2859,7 +2913,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_without_checks(); @@ -2894,7 +2948,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_without_checks(); @@ -2929,6 +2983,7 @@ mod tests { let entropy = FixedEntropy {}; let nonce = Nonce::from_entropy_source(&entropy); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; const UNKNOWN_ODD_TYPE: u64 = EXPERIMENTAL_INVOICE_REQUEST_TYPES.start + 1; assert!(UNKNOWN_ODD_TYPE % 2 == 1); @@ -2939,7 +2994,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_without_checks(); @@ -2977,7 +3032,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_without_checks(); @@ -3012,7 +3067,7 @@ mod tests { .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -3040,12 +3095,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -3083,6 +3139,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; #[cfg(c_bindings)] use crate::offers::offer::OfferWithDerivedMetadataBuilder as OfferBuilder; @@ -3101,7 +3158,7 @@ mod tests { let expected_payer_note = "❤️".repeat(85); let invoice_request = offer - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .chain(Network::Testnet) .unwrap() diff --git a/lightning/src/offers/merkle.rs b/lightning/src/offers/merkle.rs index 2de117cb402..12f2809630f 100644 --- a/lightning/src/offers/merkle.rs +++ b/lightning/src/offers/merkle.rs @@ -336,7 +336,7 @@ mod tests { let nonce = Nonce([0u8; 16]); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); - let supported_conversion = TestCurrencyConversion; + let conversion = TestCurrencyConversion; let recipient_pubkey = { let secret_bytes = >::from_hex( @@ -360,12 +360,12 @@ mod tests { .description("A Mathematical Treatise".into()) .amount( Amount::Currency { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 100 }, - &supported_conversion, + &conversion, ) .unwrap() .build_unchecked() // Override the payer metadata and signing pubkey to match the test vectors - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .payer_metadata(Metadata::Bytes(vec![0; 8])) .payer_signing_pubkey(payer_keys.public_key()) @@ -397,12 +397,13 @@ mod tests { let nonce = Nonce([0u8; 16]); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let unsigned_invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .payer_note("bar".into()) .build_unchecked(); @@ -424,6 +425,7 @@ mod tests { let nonce = Nonce([0u8; 16]); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let recipient_pubkey = { let secret_key = SecretKey::from_slice(&[41; 32]).unwrap(); @@ -433,7 +435,7 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey) .amount_msats(100) .build_unchecked() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -458,6 +460,7 @@ mod tests { let nonce = Nonce([0u8; 16]); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let recipient_pubkey = { let secret_key = SecretKey::from_slice(&[41; 32]).unwrap(); @@ -467,7 +470,7 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey) .amount_msats(100) .build_unchecked() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 2f92affa3f4..f50b1ed4c60 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -798,20 +798,23 @@ macro_rules! request_invoice_derived_signing_pubkey { ($self: ident, $offer: exp pub fn request_invoice< 'a, 'b, #[cfg(not(c_bindings))] - T: secp256k1::Signing + T: secp256k1::Signing, + CC: CurrencyConversion, >( &'a $self, expanded_key: &ExpandedKey, nonce: Nonce, #[cfg(not(c_bindings))] secp_ctx: &'b Secp256k1, #[cfg(c_bindings)] secp_ctx: &'b Secp256k1, - payment_id: PaymentId - ) -> Result<$builder, Bolt12SemanticError> { + payment_id: PaymentId, + currency_conversion: &'a CC, + ) -> Result<$builder, Bolt12SemanticError> + { if $offer.offer_features().requires_unknown_bits() { return Err(Bolt12SemanticError::UnknownRequiredFeatures); } - let mut builder = <$builder>::deriving_signing_pubkey(&$offer, expanded_key, nonce, secp_ctx, payment_id); + let mut builder = <$builder>::deriving_signing_pubkey(&$offer, expanded_key, nonce, secp_ctx, payment_id, currency_conversion); if let Some(hrn) = $hrn { #[cfg(c_bindings)] { @@ -828,7 +831,7 @@ macro_rules! request_invoice_derived_signing_pubkey { ($self: ident, $offer: exp #[cfg(not(c_bindings))] impl Offer { - request_invoice_derived_signing_pubkey!(self, self, InvoiceRequestBuilder<'a, 'b, T>, None); + request_invoice_derived_signing_pubkey!(self, self, InvoiceRequestBuilder<'a, 'b, T, CC>, None); } #[cfg(not(c_bindings))] @@ -836,7 +839,7 @@ impl OfferFromHrn { request_invoice_derived_signing_pubkey!( self, self.offer, - InvoiceRequestBuilder<'a, 'b, T>, + InvoiceRequestBuilder<'a, 'b, T, CC>, Some(self.hrn) ); } @@ -846,7 +849,7 @@ impl Offer { request_invoice_derived_signing_pubkey!( self, self, - InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b>, + InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b, CC>, None ); } @@ -856,7 +859,7 @@ impl OfferFromHrn { request_invoice_derived_signing_pubkey!( self, self.offer, - InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b>, + InvoiceRequestWithDerivedPayerSigningPubkeyBuilder<'a, 'b, CC>, Some(self.hrn) ); } @@ -943,28 +946,49 @@ impl OfferContents { self.paths.as_ref().map(|paths| paths.as_slice()).unwrap_or(&[]) } - pub(super) fn check_amount_msats_for_quantity( - &self, amount_msats: Option, quantity: Option, + pub(super) fn check_amount_msats_for_quantity( + &self, currency_conversion: &CC, requested_amount_msats: Option, + requested_quantity: Option, ) -> Result<(), Bolt12SemanticError> { - let offer_amount_msats = match self.amount { - None => 0, - Some(Amount::Bitcoin { amount_msats }) => amount_msats, - Some(Amount::Currency { .. }) => return Err(Bolt12SemanticError::UnsupportedCurrency), - }; + // If the offer expects a quantity but none has been provided yet, + // the implied total amount cannot be determined. Defer amount + // validation until the quantity is known. + if self.expects_quantity() && requested_quantity.is_none() { + return Ok(()); + } - if !self.expects_quantity() || quantity.is_some() { - let expected_amount_msats = offer_amount_msats - .checked_mul(quantity.unwrap_or(1)) - .ok_or(Bolt12SemanticError::InvalidAmount)?; - let amount_msats = amount_msats.unwrap_or(expected_amount_msats); + let quantity = requested_quantity.unwrap_or(1); + + // Expected offer amount defaults to zero if unspecified + let expected_amount_msats = self + .resolve_offer_amount(currency_conversion)? + .map(|unit_msats| { + unit_msats.checked_mul(quantity).ok_or(Bolt12SemanticError::InvalidAmount) + }) + .transpose()?; + + let total_amount_msats = match (requested_amount_msats, expected_amount_msats) { + // The payer specified an amount and the offer defines a minimum. + // Enforce that the requested amount satisfies the minimum. + (Some(requested), Some(minimum)) if requested < minimum => { + Err(Bolt12SemanticError::InsufficientAmount) + }, - if amount_msats < expected_amount_msats { - return Err(Bolt12SemanticError::InsufficientAmount); - } + // The payer specified a valid amount which satisfies the offer minimum + // (or the offer does not define one). + (Some(requested), _) => Ok(requested), - if amount_msats > MAX_VALUE_MSAT { - return Err(Bolt12SemanticError::InvalidAmount); - } + // The payer did not specify an amount but the offer defines one. + // Use the offer-implied amount. + (None, Some(amount_msats)) => Ok(amount_msats), + + // Neither the payer nor the offer defines an amount. + (None, None) => Err(Bolt12SemanticError::MissingAmount), + }?; + + // Sanity check: + if total_amount_msats > MAX_VALUE_MSAT { + return Err(Bolt12SemanticError::InvalidAmount); } Ok(()) @@ -1550,6 +1574,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; #[cfg(c_bindings)] use super::OfferWithDerivedMetadataBuilder as OfferBuilder; @@ -1562,7 +1587,7 @@ mod tests { assert_eq!(offer.issuer_signing_pubkey(), Some(node_id)); let invoice_request = offer - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -1573,7 +1598,7 @@ mod tests { // Fails verification when using the wrong method let invoice_request = offer - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -1590,7 +1615,7 @@ mod tests { let invoice_request = Offer::try_from(encoded_offer) .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -1606,7 +1631,7 @@ mod tests { let invoice_request = Offer::try_from(encoded_offer) .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -1621,6 +1646,7 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; let blinded_path = BlindedMessagePath::from_blinded_path( pubkey(40), @@ -1643,7 +1669,7 @@ mod tests { assert_ne!(offer.issuer_signing_pubkey(), Some(node_id)); let invoice_request = offer - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -1654,7 +1680,7 @@ mod tests { // Fails verification when using the wrong method let invoice_request = offer - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -1669,7 +1695,7 @@ mod tests { let invoice_request = Offer::try_from(encoded_offer) .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -1687,7 +1713,7 @@ mod tests { let invoice_request = Offer::try_from(encoded_offer) .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_and_sign() .unwrap(); @@ -1907,12 +1933,13 @@ mod tests { let nonce = Nonce::from_entropy_source(&entropy); let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; match OfferBuilder::new(pubkey(42)) .features_unchecked(OfferFeatures::unknown()) .build() .unwrap() - .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::UnknownRequiredFeatures), diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index c0fd9dfdd3e..f5abd4a17dd 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -666,8 +666,11 @@ impl Refund { #[cfg(c_bindings)] impl Refund { - respond_with_explicit_signing_pubkey_methods!(self, InvoiceWithExplicitSigningPubkeyBuilder); - respond_with_derived_signing_pubkey_methods!(self, InvoiceWithDerivedSigningPubkeyBuilder); + respond_with_explicit_signing_pubkey_methods!( + self, + InvoiceWithExplicitSigningPubkeyBuilder<'_> + ); + respond_with_derived_signing_pubkey_methods!(self, InvoiceWithDerivedSigningPubkeyBuilder<'_>); } #[cfg(test)] From fbf86a0007b8853d356b6f43ae16d0feb03cbf2f Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 28 Feb 2026 19:39:56 +0530 Subject: [PATCH 5/6] Support currency-denominated Offers in InvoiceBuilder Adds currency conversion support when responding to an `InvoiceRequest` and constructing the `InvoiceBuilder`. When the underlying Offer specifies its amount in a currency denomination, the `CurrencyConversion` implementation is used to resolve the payable amount into millisatoshis and ensure the invoice amount satisfies the Offer's requirements. This reintroduces the currency validation intentionally skipped during `InvoiceRequest` parsing, keeping parsing focused on structural validation while enforcing amount correctness at the time the Invoice is constructed. --- lightning/src/ln/channelmanager.rs | 1 + lightning/src/ln/offers_tests.rs | 3 +- lightning/src/ln/outbound_payment.rs | 15 ++- lightning/src/offers/flow.rs | 20 ++- lightning/src/offers/invoice.rs | 169 ++++++++++++++---------- lightning/src/offers/invoice_request.rs | 49 ++++--- 6 files changed, 152 insertions(+), 105 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 185223fe3c1..ad75202cee7 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5715,6 +5715,7 @@ impl< let features = self.bolt12_invoice_features(); let outbound_pmts_res = self.pending_outbound_payments.static_invoice_received( invoice, + &self.flow.currency_conversion, payment_id, features, best_block_height, diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index bed47610a2d..91be8e9e97c 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2367,6 +2367,7 @@ fn fails_paying_invoice_with_unknown_required_features() { .build().unwrap(); let payment_id = PaymentId([1; 32]); + let conversion = TestCurrencyConversion; david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); connect_peers(david, bob); @@ -2401,7 +2402,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(&conversion, 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 2bccd7d4b0b..4fd65d1c336 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -24,6 +24,7 @@ use crate::ln::channelmanager::{ use crate::ln::msgs::DecodeError; use crate::ln::onion_utils; use crate::ln::onion_utils::{DecodedOnionFailure, HTLCFailReason}; +use crate::offers::currency::CurrencyConversion; use crate::offers::invoice::{Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder}; use crate::offers::invoice_request::InvoiceRequest; use crate::offers::nonce::Nonce; @@ -1230,9 +1231,10 @@ impl OutboundPayments { 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<'a, ES: EntropySource, CC: CurrencyConversion>( + &'a self, invoice: &StaticInvoice, currency_conversion: &'a CC, payment_id: PaymentId, + features: Bolt12InvoiceFeatures, best_block_height: u32, duration_since_epoch: Duration, + entropy_source: ES, pending_events: &Mutex)>>, ) -> Result<(), Bolt12PaymentError> { macro_rules! abandon_with_entry { @@ -1280,6 +1282,7 @@ impl OutboundPayments { let amount_msat = match InvoiceBuilder::::amount_msats( invreq, + currency_conversion, ) { Ok(amt) => amt, Err(_) => { @@ -3311,7 +3314,7 @@ mod tests { .build().unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), created_at).unwrap() + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), created_at).unwrap() .build().unwrap() .sign(recipient_sign).unwrap(); @@ -3361,7 +3364,7 @@ mod tests { .build().unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()).unwrap() .build().unwrap() .sign(recipient_sign).unwrap(); @@ -3427,7 +3430,7 @@ mod tests { .build().unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() + .respond_with_no_std(&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 9be156be1b3..c10ab312d6e 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -998,9 +998,12 @@ impl OffersMessageFlow Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, { let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; + let conversion = &self.currency_conversion; - 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)?; @@ -1021,9 +1024,10 @@ impl OffersMessageFlow OffersMessageFlow Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, { let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; + let conversion = &self.currency_conversion; - 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)?; @@ -1080,9 +1087,10 @@ impl OffersMessageFlow PaymentHash { unimplemented!() } //! # //! # fn parse_invoice_request(bytes: Vec) -> Result<(), lightning::offers::parse::Bolt12ParseError> { +//! let conversion = DefaultCurrencyConversion; //! let payment_paths = create_payment_paths(); //! let payment_hash = create_payment_hash(); //! let secp_ctx = Secp256k1::new(); @@ -50,13 +52,13 @@ #![cfg_attr( feature = "std", doc = " - .respond_with(payment_paths, payment_hash)? + .respond_with(&conversion, 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(&conversion, payment_paths, payment_hash, core::time::Duration::from_secs(0))? " )] //! # ) @@ -120,8 +122,8 @@ use crate::blinded_path::BlindedPath; use crate::io; use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::{ExpandedKey, IV_LEN}; -use crate::ln::msgs::DecodeError; -use crate::offers::currency::DefaultCurrencyConversion; +use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT}; +use crate::offers::currency::{CurrencyConversion, DefaultCurrencyConversion}; #[cfg(test)] use crate::offers::invoice_macros::invoice_builder_methods_test_common; use crate::offers::invoice_macros::{invoice_accessors_common, invoice_builder_methods_common}; @@ -242,11 +244,12 @@ 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, + 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 { - let amount_msats = Self::amount_msats(invoice_request)?; + let amount_msats = Self::amount_msats(invoice_request, currency_conversion)?; let contents = InvoiceContents::ForOffer { invoice_request: invoice_request.contents.clone(), fields: Self::fields( @@ -314,11 +317,12 @@ 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, + 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 { - let amount_msats = Self::amount_msats(invoice_request)?; + 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(), @@ -394,19 +398,32 @@ 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, + pub(crate) fn amount_msats( + invoice_request: &InvoiceRequest, currency_conversion: &CC, ) -> 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), - }, + let quantity = invoice_request.quantity().unwrap_or(1); + let requested_msats = invoice_request.amount_msats(currency_conversion)?; + + let minimum_offer_msats = match invoice_request + .resolve_offer_amount(currency_conversion)? + { + Some(unit_msats) => Some( + unit_msats.checked_mul(quantity).ok_or(Bolt12SemanticError::InvalidAmount)?, + ), + None => None, + }; + + if let Some(minimum) = minimum_offer_msats { + if requested_msats < minimum { + return Err(Bolt12SemanticError::InsufficientAmount); + } } + + if requested_msats > MAX_VALUE_MSAT { + return Err(Bolt12SemanticError::InvalidAmount); + } + + Ok(requested_msats) } #[cfg_attr(c_bindings, allow(dead_code))] @@ -1773,7 +1790,7 @@ impl TryFrom for InvoiceContents { // Note: // It is safe to use `DefaultCurrencyConversion` here. // - // `payable_amount_msats` can fail in two cases: + // The call to `amount_msats()` can fail in two cases: // 1. The computed payable amount is semantically invalid. // 2. The invoice request does not specify an amount and the original offer // is currency-denominated. @@ -1785,14 +1802,17 @@ impl TryFrom for InvoiceContents { // // Since the invoice corresponds to the invoice request we constructed and sent, // `payable_amount_msats` must succeed here. - if let Ok(requested_amount_msats) = - invoice_request.amount_msats(&DefaultCurrencyConversion) - { - if amount_msats != requested_amount_msats { - return Err(Bolt12SemanticError::InvalidAmount); - } - } else { - debug_assert!(false); + let requested_amount_msats = + match invoice_request.amount_msats(&DefaultCurrencyConversion) { + Ok(msats) => msats, + Err(_) => { + debug_assert!(false); + return Err(Bolt12SemanticError::InvalidAmount); + }, + }; + + if amount_msats != requested_amount_msats { + return Err(Bolt12SemanticError::InvalidAmount); } Ok(InvoiceContents::ForOffer { invoice_request, fields }) @@ -1926,7 +1946,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths.clone(), payment_hash, now) + .respond_with_no_std(&conversion, payment_paths.clone(), payment_hash, now) .unwrap() .build() .unwrap(); @@ -2198,7 +2218,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with(payment_paths(), payment_hash()) + .respond_with(&conversion, payment_paths(), payment_hash()) .unwrap() .build() { @@ -2213,7 +2233,7 @@ mod tests { .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id, &conversion) .unwrap() .build_unchecked_and_sign() - .respond_with(payment_paths(), payment_hash()) + .respond_with(&conversion, payment_paths(), payment_hash()) .unwrap() .build() { @@ -2295,7 +2315,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( + &conversion, + payment_paths(), + payment_hash(), + now(), + ) .unwrap() .build_and_sign(&secp_ctx); @@ -2398,7 +2423,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now) .unwrap() .relative_expiry(one_hour.as_secs() as u32) .build() @@ -2419,7 +2444,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now - one_hour) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now - one_hour) .unwrap() .relative_expiry(one_hour.as_secs() as u32 - 1) .build() @@ -2452,7 +2477,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2483,7 +2508,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2503,7 +2528,7 @@ mod tests { .quantity(u64::max_value()) .unwrap() .build_unchecked_and_sign() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidAmount), @@ -2532,7 +2557,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .fallback_v0_p2wsh(&script.wscript_hash()) .fallback_v0_p2wpkh(&pubkey.wpubkey_hash().unwrap()) @@ -2589,7 +2614,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .allow_mpp() .build() @@ -2618,7 +2643,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2636,7 +2661,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2664,7 +2689,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2742,7 +2767,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2787,7 +2812,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .relative_expiry(3600) .build() @@ -2821,7 +2846,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2866,7 +2891,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2909,7 +2934,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .allow_mpp() .build() @@ -2953,11 +2978,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_std(&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_std(&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()) @@ -3017,7 +3044,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3106,6 +3133,7 @@ mod tests { .build_and_sign() .unwrap() .respond_with_no_std_using_signing_pubkey( + &conversion, payment_paths(), payment_hash(), now(), @@ -3136,6 +3164,7 @@ mod tests { .build_and_sign() .unwrap() .respond_with_no_std_using_signing_pubkey( + &conversion, payment_paths(), payment_hash(), now(), @@ -3178,7 +3207,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .amount_msats_unchecked(2000) .build() @@ -3207,7 +3236,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .amount_msats_unchecked(2000) .build() @@ -3272,7 +3301,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3306,7 +3335,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3350,7 +3379,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3389,7 +3418,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3436,7 +3465,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .experimental_baz(42) .build() @@ -3462,7 +3491,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3503,7 +3532,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3541,7 +3570,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3583,7 +3612,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3619,7 +3648,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3668,7 +3697,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3715,7 +3744,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths, payment_hash(), now) + .respond_with_no_std(&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 51a025e001d..aa3b8c6b49b 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -787,14 +787,15 @@ 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> + { 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. @@ -822,10 +823,11 @@ 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> + { if $contents.invoice_request_features().requires_unknown_bits() { return Err(Bolt12SemanticError::UnknownRequiredFeatures); } @@ -835,22 +837,23 @@ 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, + 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> { + ) -> Result<$builder, Bolt12SemanticError> + { 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) } } } @@ -1019,14 +1022,15 @@ 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> + { 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 @@ -1036,10 +1040,11 @@ 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> + { if $self.inner.invoice_request_features().requires_unknown_bits() { return Err(Bolt12SemanticError::UnknownRequiredFeatures); } @@ -1052,7 +1057,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 ) } } } @@ -1769,7 +1774,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) .unwrap() .experimental_baz(42) .build() @@ -2366,7 +2371,7 @@ mod tests { .features_unchecked(InvoiceRequestFeatures::unknown()) .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::UnknownRequiredFeatures), From 16687493be09aa2d63a060d5eb1e1d42ab34022e Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 28 Feb 2026 21:01:07 +0530 Subject: [PATCH 6/6] Add tests for currency-denominated Offer flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests covering Offers whose amounts are denominated in fiat currencies. These tests verify that: * currency-denominated Offer amounts can be created * InvoiceRequests correctly resolve amounts using CurrencyConversion * Invoice construction validates and enforces the payable amount This ensures the full Offer → InvoiceRequest → Invoice flow works correctly when the original Offer amount is specified in currency. --- lightning/src/ln/offers_tests.rs | 73 ++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 91be8e9e97c..22b267b4a68 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -52,6 +52,7 @@ use crate::blinded_path::message::OffersContext; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose}; use crate::ln::channelmanager::{PaymentId, RecentPaymentDetails, self}; use crate::ln::outbound_payment::{Bolt12PaymentError, RecipientOnionFields, Retry}; +use crate::offers::offer::{Amount, CurrencyCode}; 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}; @@ -916,6 +917,78 @@ 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_pays_for_offer_with_fiat_amount_using_one_hop_blinded_path() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + 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 amount = Amount::Currency { + iso4217_code: CurrencyCode::new(*b"USD").unwrap(), + amount: 1000, + }; + + let offer = alice.node + .create_offer_builder().unwrap() + .amount(amount, &alice.node.flow.currency_conversion).unwrap() + .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 (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }); + assert_eq!(invoice_request.amount_msats(&alice.node.flow.currency_conversion), Ok(1_000_000)); + assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); + assert!(check_dummy_hopped_path_length(&reply_path, alice, bob_id, DUMMY_HOPS_PATH_LENGTH)); + + 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(), 1_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_dummy_hopped_path_length(&reply_path, bob, alice_id, DUMMY_HOPS_PATH_LENGTH)); + + 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.