From ed708ef3acd4ea5480706ff7ee79c9a095676d8f Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 6 Apr 2026 14:06:50 +0530 Subject: [PATCH 01/10] [refactor] Clarify InvoiceRequest amount accessors InvoiceRequest::amount_msats previously mixed two meanings: the explicit request field and the payable amount inferred from the offer. That made call sites harder to reason about and obscured whether code was validating the request itself or the amount that should ultimately be paid. Make amount_msats return only the amount set directly on the request and remove the redundant has_amount_msats helper. Introduce payable_amount_msats for callers that need the effective amount derived from the request or offer, preserving explicit error reporting for missing, unsupported, or overflowing payable amounts. Co-Authored-By: OpenAI Codex --- lightning/src/ln/channelmanager.rs | 15 ++++-- lightning/src/ln/offers_tests.rs | 16 +++--- lightning/src/offers/invoice.rs | 11 +--- lightning/src/offers/invoice_request.rs | 70 +++++++++++++++---------- 4 files changed, 61 insertions(+), 51 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 660875400c7..a61991b3b50 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8533,12 +8533,17 @@ impl< }); let verified_invreq = match verify_opt { Some(verified_invreq) => { - if let Some(invreq_amt_msat) = - verified_invreq.amount_msats() - { - if payment_data.total_msat < invreq_amt_msat { + match verified_invreq.payable_amount_msats() { + Ok(invreq_amt_msat) => { + if payment_data.total_msat < invreq_amt_msat { + fail_htlc!(claimable_htlc, payment_hash); + } + }, + Err(Bolt12SemanticError::UnsupportedCurrency) + | Err(Bolt12SemanticError::MissingAmount) => {}, + Err(_) => { fail_htlc!(claimable_htlc, payment_hash); - } + }, } verified_invreq }, diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index de08af5d276..6ca3bb4e742 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -522,7 +522,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.payable_amount_msats(), 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 +544,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.payable_amount_msats(), 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)); } @@ -729,7 +729,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.payable_amount_msats(), 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)); @@ -887,7 +887,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.payable_amount_msats(), 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)); @@ -1276,7 +1276,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.payable_amount_msats(), 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(); @@ -1590,7 +1590,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.payable_amount_msats(), 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 +1619,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.payable_amount_msats(), 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)); @@ -1719,7 +1719,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.payable_amount_msats(), 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)); diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..eb566a919ff 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -396,16 +396,7 @@ macro_rules! invoice_builder_methods { pub(crate) fn amount_msats( invoice_request: &InvoiceRequest, ) -> Result { - match invoice_request.contents.inner.amount_msats() { - Some(amount_msats) => Ok(amount_msats), - None => match invoice_request.contents.inner.offer.amount() { - Some(Amount::Bitcoin { amount_msats }) => amount_msats - .checked_mul(invoice_request.quantity().unwrap_or(1)) - .ok_or(Bolt12SemanticError::InvalidAmount), - Some(Amount::Currency { .. }) => Err(Bolt12SemanticError::UnsupportedCurrency), - None => Err(Bolt12SemanticError::MissingAmount), - }, - } + invoice_request.payable_amount_msats() } #[cfg_attr(c_bindings, allow(dead_code))] diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 7805882ef73..bf3c4a08dca 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -704,21 +704,24 @@ 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. + /// The amount set directly on the invoice request, in msats (i.e., the minimum + /// lightning-payable unit for [`chain`]). /// /// [`chain`]: Self::chain pub fn amount_msats(&$self) -> Option { $contents.amount_msats() } - /// Returns whether an amount was set in the request; otherwise, if [`amount_msats`] is `Some` - /// then it was inferred from the [`Offer::amount`] and [`quantity`]. + /// The amount payable for the request, in msats. /// - /// [`amount_msats`]: Self::amount_msats - /// [`quantity`]: Self::quantity - pub fn has_amount_msats(&$self) -> bool { - $contents.has_amount_msats() + /// If the invoice request explicitly sets an amount, that amount is returned. + /// Otherwise, the amount is inferred from [`Offer::amount`] and [`quantity`] + /// when the offer amount is already bitcoin-denominated. + /// + /// Returns an error if the payable amount is missing, unsupported, or + /// semantically invalid. + pub fn payable_amount_msats(&$self) -> Result { + $contents.payable_amount_msats() } /// Features pertaining to requesting an invoice. @@ -1143,20 +1146,24 @@ impl InvoiceRequestContents { } 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, + self.inner.amount_msats() + } + + pub(super) fn payable_amount_msats(&self) -> Result { + if let Some(amount_msats) = self.inner.amount_msats() { + return Ok(amount_msats); + } + + match self.inner.offer.amount() { + Some(Amount::Bitcoin { amount_msats }) => amount_msats + .checked_mul(self.quantity().unwrap_or(1)) + .ok_or(Bolt12SemanticError::InvalidAmount), + Some(Amount::Currency { .. }) => Err(Bolt12SemanticError::UnsupportedCurrency), None => { debug_assert!(false); - None + Err(Bolt12SemanticError::MissingAmount) }, - }) - } - - pub(super) fn has_amount_msats(&self) -> bool { - self.inner.amount_msats().is_some() + } } pub(super) fn features(&self) -> &InvoiceRequestFeatures { @@ -1617,7 +1624,8 @@ 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(), None); + assert_eq!(invoice_request.payable_amount_msats(), Ok(1000)); assert_eq!(invoice_request.invoice_request_features(), &InvoiceRequestFeatures::empty()); assert_eq!(invoice_request.quantity(), None); assert_eq!(invoice_request.payer_note(), None); @@ -1937,8 +1945,9 @@ mod tests { .build_and_sign() .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); - assert!(invoice_request.has_amount_msats()); + assert!(invoice_request.amount_msats().is_some()); assert_eq!(invoice_request.amount_msats(), Some(1000)); + assert_eq!(invoice_request.payable_amount_msats(), Ok(1000)); assert_eq!(tlv_stream.amount, Some(1000)); let invoice_request = OfferBuilder::new(recipient_pubkey()) @@ -1954,8 +1963,9 @@ mod tests { .build_and_sign() .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); - assert!(invoice_request.has_amount_msats()); + assert!(invoice_request.amount_msats().is_some()); assert_eq!(invoice_request.amount_msats(), Some(1000)); + assert_eq!(invoice_request.payable_amount_msats(), Ok(1000)); assert_eq!(tlv_stream.amount, Some(1000)); let invoice_request = OfferBuilder::new(recipient_pubkey()) @@ -1969,8 +1979,9 @@ mod tests { .build_and_sign() .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); - assert!(invoice_request.has_amount_msats()); + assert!(invoice_request.amount_msats().is_some()); assert_eq!(invoice_request.amount_msats(), Some(1001)); + assert_eq!(invoice_request.payable_amount_msats(), Ok(1001)); assert_eq!(tlv_stream.amount, Some(1001)); match OfferBuilder::new(recipient_pubkey()) @@ -2079,8 +2090,8 @@ mod tests { .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(), None); + assert_eq!(invoice_request.payable_amount_msats(), Ok(1000)); assert_eq!(tlv_stream.amount, None); let invoice_request = OfferBuilder::new(recipient_pubkey()) @@ -2095,8 +2106,8 @@ mod tests { .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(2000)); + assert_eq!(invoice_request.amount_msats(), None); + assert_eq!(invoice_request.payable_amount_msats(), Ok(2000)); assert_eq!(tlv_stream.amount, None); let invoice_request = OfferBuilder::new(recipient_pubkey()) @@ -2109,8 +2120,11 @@ mod tests { .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_eq!( + invoice_request.payable_amount_msats(), + Err(Bolt12SemanticError::UnsupportedCurrency) + ); assert_eq!(tlv_stream.amount, None); } From 6a96cc65ac309c5ba5ab8b191af64c8d0f9dabd4 Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 12 Feb 2026 19:12:53 +0530 Subject: [PATCH 02/10] [feat] Introduce the CurrencyConversion trait Add a `CurrencyConversion` trait for resolving currency-denominated amounts into millisatoshis. LDK cannot supply exchange rates itself, so applications provide this conversion logic as the foundation for fiat-denominated offer support. Co-Authored-By: OpenAI Codex --- fuzz/src/invoice_request_deser.rs | 11 ++++++- lightning/src/offers/currency.rs | 49 +++++++++++++++++++++++++++++++ lightning/src/offers/mod.rs | 2 ++ 3 files changed, 61 insertions(+), 1 deletion(-) 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..7519cf5310a 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -16,9 +16,10 @@ use lightning::blinded_path::payment::{ }; use lightning::ln::channelmanager::MIN_FINAL_CLTV_EXPIRY_DELTA; use lightning::ln::inbound_payment::ExpandedKey; +use lightning::offers::currency::CurrencyConversion; use lightning::offers::invoice::UnsignedBolt12Invoice; use lightning::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields}; -use lightning::offers::offer::OfferId; +use lightning::offers::offer::{CurrencyCode, OfferId}; use lightning::offers::parse::Bolt12SemanticError; use lightning::sign::{EntropySource, ReceiveAuthKey}; use lightning::types::features::BlindedHopFeatures; @@ -61,6 +62,14 @@ pub fn do_test(data: &[u8], _out: Out) { } } +struct FuzzCurrencyConversion; + +impl CurrencyConversion for FuzzCurrencyConversion { + fn msats_per_minor_unit(&self, _iso4217_code: CurrencyCode) -> Result<(f64, u8), ()> { + Err(()) + } +} + 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..e2b983732e8 --- /dev/null +++ b/lightning/src/offers/currency.rs @@ -0,0 +1,49 @@ +// 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. +/// +/// The returned tolerance percent is currently unused by LDK's offer flows, but +/// remains part of the interface for callers that want to surface or preserve +/// that metadata alongside the conversion factor. +pub trait CurrencyConversion { + /// Returns the conversion rate in **msats per minor unit** for the given + /// ISO-4217 currency code together with an application-defined tolerance, + /// expressed as a percentage. + fn msats_per_minor_unit(&self, iso4217_code: CurrencyCode) -> Result<(f64, u8), ()>; +} + +impl> CurrencyConversion for CC { + fn msats_per_minor_unit(&self, iso4217_code: CurrencyCode) -> Result<(f64, u8), ()> { + self.deref().msats_per_minor_unit(iso4217_code) + } +} + +/// 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<(f64, u8), ()> { + Err(()) + } +} 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; From f6d29ab2ee4af35de957fd75cd2367841ccd0b8f Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 6 Apr 2026 21:18:15 +0530 Subject: [PATCH 03/10] [feat] Introduce the MsatsRange abstraction Add an internal MsatsRange type that carries a nominal millisatoshi amount together with its tolerated conversion range. This creates a single canonical result for resolving currency-denominated offer amounts without forcing callers to immediately collapse back to a plain u64. Keeping the nominal amount and tolerance together makes the later quantity-scaling and tolerant-comparison logic easier to express in one place. The new Amount::resolve_msats helper is the first consumer of this abstraction. It keeps the current behavior for bitcoin amounts while capturing the tolerance returned by CurrencyConversion for fiat amounts. Co-Authored-By: OpenAI Codex --- lightning/src/offers/offer.rs | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index b2703454169..518e3c89177 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,93 @@ pub enum Amount { }, } +/// A resolved millisatoshi amount together with the tolerated range around it. +/// +/// Currency-denominated amounts may be independently resolved using different +/// conversion snapshots. The nominal amount is the value used locally, while +/// the tolerance defines the acceptable range for tolerant comparisons. +/// +/// Use the accessor methods to read the nominal amount or tolerated bounds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MsatsRange { + pub(crate) amount_msats: u64, + pub(crate) tolerance: u8, +} + +impl MsatsRange { + /// Returns the nominal amount in millisatoshis. + pub fn amount_msats(&self) -> u64 { + self.amount_msats + } + + /// Returns the smallest amount accepted within the configured tolerance. + pub fn minimum_msats(&self) -> u64 { + let amount_msats = self.amount_msats as u128; + let minimum_msats = if self.tolerance >= 100 { + 0 + } else { + amount_msats * (100 - self.tolerance as u128) / 100 + }; + + u64::try_from(minimum_msats).expect("tolerance range cannot underflow") + } + + /// Returns the largest amount accepted within the configured tolerance. + /// + /// The upper bound rounds up so integer division does not exclude boundary + /// values that should remain accepted. + pub fn maximum_msats(&self) -> u64 { + let amount_msats = self.amount_msats as u128; + let maximum_msats = (amount_msats * (100 + self.tolerance as u128)).div_ceil(100); + u64::try_from(maximum_msats).unwrap_or(u64::MAX) + } + + /// Returns whether `amount_msats` falls within the tolerated range. + pub fn contains(&self, amount_msats: u64) -> bool { + amount_msats >= self.minimum_msats() && amount_msats <= self.maximum_msats() + } + + /// Multiplies the nominal amount by `quantity`, preserving the tolerance. + /// + /// This keeps unit-amount tolerance semantics intact while scaling the + /// full tolerated range to the requested quantity. + pub fn checked_mul(self, quantity: u64) -> Result { + Ok(Self { + amount_msats: self + .amount_msats + .checked_mul(quantity) + .ok_or(Bolt12SemanticError::InvalidAmount)?, + tolerance: self.tolerance, + }) + } +} + +impl Amount { + /// Resolves an [`Amount`] into a nominal millisatoshi value together with the + /// tolerated range implied by the current conversion snapshot. + pub fn resolve_msats( + self, currency_conversion: &CC, + ) -> Result { + match self { + Amount::Bitcoin { amount_msats } => Ok(MsatsRange { amount_msats, tolerance: 0 }), + Amount::Currency { iso4217_code, amount } => { + let (msats_per_minor_unit, tolerance_percent) = currency_conversion + .msats_per_minor_unit(iso4217_code) + .map_err(|_| Bolt12SemanticError::UnsupportedCurrency)?; + let amount_msats = libm::round(msats_per_minor_unit * amount as f64); + + if !amount_msats.is_finite() { + return Err(Bolt12SemanticError::InvalidAmount); + } + + let exact_msats = u64::try_from(amount_msats as i128) + .map_err(|_| Bolt12SemanticError::InvalidAmount)?; + Ok(MsatsRange { amount_msats: exact_msats, tolerance: tolerance_percent }) + }, + } + } +} + /// An ISO 4217 three-letter currency code (e.g., USD). /// /// Currency codes must be exactly 3 ASCII uppercase letters. From 329faa44ecbdb6ee07a17d65d6ffaf17747a679c Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 6 Apr 2026 21:20:09 +0530 Subject: [PATCH 04/10] [test] Cover the MsatsRange helper Add focused coverage for the new MsatsRange abstraction. The feature commit introduces a canonical range representation for resolved currency amounts, so this commit exercises the two key behaviors that later callers will rely on: deriving tolerated bounds from a conversion snapshot and scaling that range by quantity. It also moves TestCurrencyConversion into shared test utilities so later currency-conversion tests can reuse the same fixed USD rate and tolerance. Co-Authored-By: OpenAI Codex --- lightning/src/offers/offer.rs | 57 +++++++++++++++++++++++++++++++- lightning/src/util/test_utils.rs | 14 ++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 518e3c89177..9e9a237f125 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -1474,7 +1474,7 @@ mod tests { #[cfg(c_bindings)] use super::OfferWithExplicitMetadataBuilder as OfferBuilder; use super::{ - Amount, ExperimentalOfferTlvStreamRef, Offer, OfferTlvStreamRef, Quantity, + Amount, ExperimentalOfferTlvStreamRef, MsatsRange, Offer, OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, }; @@ -1483,6 +1483,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::CurrencyConversion; use crate::offers::nonce::Nonce; use crate::offers::offer::CurrencyCode; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError}; @@ -1490,12 +1491,21 @@ 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; use core::num::NonZeroU64; use core::time::Duration; + struct InfiniteCurrencyConversion; + + impl CurrencyConversion for InfiniteCurrencyConversion { + fn msats_per_minor_unit(&self, _iso4217_code: CurrencyCode) -> Result<(f64, u8), ()> { + Ok((f64::INFINITY, 0)) + } + } + #[test] fn builds_offer_with_defaults() { let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); @@ -1799,6 +1809,51 @@ mod tests { } } + #[test] + fn resolves_amount_into_msats_range() { + let conversion = TestCurrencyConversion; + let invalid_conversion = InfiniteCurrencyConversion; + + let bitcoin_range = + Amount::Bitcoin { amount_msats: 1_000 }.resolve_msats(&conversion).unwrap(); + assert_eq!(bitcoin_range.amount_msats(), 1_000); + assert_eq!(bitcoin_range.minimum_msats(), 1_000); + assert_eq!(bitcoin_range.maximum_msats(), 1_000); + assert!(bitcoin_range.contains(1_000)); + + let currency_range = + Amount::Currency { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 10 } + .resolve_msats(&conversion) + .unwrap(); + assert_eq!(currency_range.amount_msats(), 10_000); + assert_eq!(currency_range.minimum_msats(), 9_000); + assert_eq!(currency_range.maximum_msats(), 11_000); + assert!(currency_range.contains(10_500)); + assert!(!currency_range.contains(11_001)); + + let unsupported_amount = + Amount::Currency { iso4217_code: CurrencyCode::new(*b"EUR").unwrap(), amount: 10 }; + assert_eq!( + unsupported_amount.resolve_msats(&conversion), + Err(Bolt12SemanticError::UnsupportedCurrency) + ); + assert_eq!( + Amount::Currency { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 10 } + .resolve_msats(&invalid_conversion), + Err(Bolt12SemanticError::InvalidAmount) + ); + } + + #[test] + fn multiplies_msats_range_by_quantity() { + let range = MsatsRange { amount_msats: 10_000, tolerance: 10 }.checked_mul(2).unwrap(); + assert_eq!(range.amount_msats(), 20_000); + assert_eq!(range.minimum_msats(), 18_000); + assert_eq!(range.maximum_msats(), 22_000); + assert!(range.contains(21_000)); + assert!(!range.contains(22_001)); + } + #[test] fn builds_offer_with_description() { let offer = OfferBuilder::new(pubkey(42)).description("foo".into()).build().unwrap(); diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 57f9ba6b22f..8f4e983c66b 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -31,7 +31,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, }; @@ -448,6 +450,18 @@ impl<'a> MessageRouter for TestMessageRouter<'a> { } } +pub struct TestCurrencyConversion; + +impl CurrencyConversion for TestCurrencyConversion { + fn msats_per_minor_unit(&self, iso4217_code: CurrencyCode) -> Result<(f64, u8), ()> { + if iso4217_code.as_str() == "USD" { + Ok((1_000.0, 10)) // 1 cent = 1000 msats (test-only fixed rate) + } else { + Err(()) + } + } +} + pub struct OnlyReadsKeysInterface {} impl EntropySource for OnlyReadsKeysInterface { From 1d3b9e958b692e89f2b21111a31aa2c44b25bea4 Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 6 Mar 2026 20:40:44 +0530 Subject: [PATCH 05/10] [feat] Keep CurrencyConversion in ChannelManager Store the CurrencyConversion implementation on ChannelManager and thread it through the ChannelManager constructor and read args. This keeps the conversion dependency with the higher-level payment state machine that owns offer payment state and persistence, while updating the background processor, block-sync setup, tests, and fuzz harnesses to construct ChannelManager with an explicit conversion implementation. Co-Authored-By: OpenAI Codex --- fuzz/src/full_stack.rs | 14 +++ lightning-background-processor/src/lib.rs | 8 ++ lightning-block-sync/src/init.rs | 6 +- lightning/src/ln/channelmanager.rs | 117 +++++++++++++++------- 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 | 1 - 8 files changed, 127 insertions(+), 40 deletions(-) diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index c1d7982e5e4..93c5ba8cfe6 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -51,7 +51,9 @@ use lightning::ln::peer_handler::{ }; use lightning::ln::script::ShutdownScript; use lightning::ln::types::ChannelId; +use lightning::offers::currency::CurrencyConversion; use lightning::offers::invoice::UnsignedBolt12Invoice; +use lightning::offers::offer::CurrencyCode; use lightning::onion_message::messenger::{Destination, MessageRouter, OnionMessagePath}; use lightning::routing::gossip::{NetworkGraph, P2PGossipSync}; use lightning::routing::router::{ @@ -184,6 +186,14 @@ impl MessageRouter for FuzzRouter { } } +struct FuzzCurrencyConversion; + +impl CurrencyConversion for FuzzCurrencyConversion { + fn msats_per_minor_unit(&self, _iso4217_code: CurrencyCode) -> Result<(f64, u8), ()> { + Err(()) + } +} + struct TestBroadcaster { txn_broadcasted: Mutex>, } @@ -239,6 +249,7 @@ type ChannelMan<'a> = ChannelManager< Arc, &'a FuzzRouter, &'a FuzzRouter, + &'a FuzzCurrencyConversion, Arc, >; type PeerMan<'a> = PeerManager< @@ -549,6 +560,8 @@ pub fn do_test(mut data: &[u8], logger: &Arc let fee_est = Arc::new(FuzzEstimator { input: input.clone() }); let router = FuzzRouter {}; + let conversion = FuzzCurrencyConversion; + macro_rules! get_slice { ($len: expr) => { match input.get_slice($len as usize) { @@ -613,6 +626,7 @@ pub fn do_test(mut data: &[u8], logger: &Arc broadcast.clone(), &router, &router, + &conversion, Arc::clone(&logger), keys_manager.clone(), keys_manager.clone(), diff --git a/lightning-background-processor/src/lib.rs b/lightning-background-processor/src/lib.rs index c796c53a031..e7bfeef3933 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(not(c_bindings))] type DynSignerProvider = dyn lightning::sign::SignerProvider + Send @@ -393,6 +396,7 @@ type DynChannelManager = lightning::ln::channelmanager::ChannelManager< &'static (dyn FeeEstimator + Send + Sync), &'static DynRouter, &'static DynMessageRouter, + &'static DynCurrencyConversion, &'static (dyn Logger + Send + Sync), >; @@ -1949,6 +1953,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}; @@ -2044,6 +2049,7 @@ mod tests { Arc, >, >, + Arc, Arc, >; @@ -2468,6 +2474,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())); @@ -2494,6 +2501,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 07c9f230be3..c8bda39889d 100644 --- a/lightning-block-sync/src/init.rs +++ b/lightning-block-sync/src/init.rs @@ -53,6 +53,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; @@ -74,6 +75,7 @@ where /// F: FeeEstimator, /// R: Router, /// MR: MessageRouter, +/// CC: CurrencyConversion, /// L: Logger, /// C: chain::Filter, /// P: chainmonitor::Persist, @@ -88,6 +90,7 @@ where /// fee_estimator: &F, /// router: &R, /// message_router: &MR, +/// currency_conversion: &CC, /// logger: &L, /// persister: &P, /// ) { @@ -108,11 +111,12 @@ where /// tx_broadcaster, /// router, /// message_router, +/// currency_conversion, /// logger, /// config, /// vec![&mut monitor], /// ); -/// <(BestBlock, ChannelManager<&ChainMonitor, &T, &ES, &NS, &SP, &F, &R, &MR, &L>)>::read( +/// <(BestBlock, 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 a61991b3b50..61848efe1bd 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; @@ -1880,6 +1883,7 @@ pub type SimpleArcChannelManager = ChannelManager< >, >, Arc>>, Arc, Arc>>, + Arc, Arc, >; @@ -1911,6 +1915,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, >; @@ -1937,6 +1942,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. @@ -1951,6 +1958,7 @@ pub trait AChannelManager { Self::FeeEstimator, Self::Router, Self::MessageRouter, + Self::CurrencyConversion, Self::Logger, >; } @@ -1964,8 +1972,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > AChannelManager for ChannelManager + > AChannelManager for ChannelManager { type Watch = M; type Broadcaster = T; @@ -1976,8 +1985,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 } } @@ -2057,6 +2067,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, @@ -2072,18 +2083,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 (best_block, channel_manager) = -/// <(BestBlock, ChannelManager<_, _, _, _, _, _, _, _, _>)>::read(&mut reader, args)?; +/// <(BestBlock, ChannelManager<_, _, _, _, _, _, _, _, _, _>)>::read(&mut reader, args)?; /// /// // Update the ChannelManager and ChannelMonitors with the latest chain data /// // ... @@ -2729,6 +2740,7 @@ pub struct ChannelManager< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, > { config: RwLock, @@ -2742,6 +2754,10 @@ pub struct ChannelManager< pub(super) flow: OffersMessageFlow, #[cfg(not(test))] flow: OffersMessageFlow, + #[cfg(test)] + pub(super) currency_conversion: CC, + #[cfg(not(test))] + currency_conversion: CC, #[cfg(any(test, feature = "_test_utils"))] pub(super) best_block: RwLock, @@ -3563,8 +3579,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. /// @@ -3585,9 +3602,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, @@ -3601,7 +3618,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, + logger.clone(), ); ChannelManager { @@ -3612,6 +3630,7 @@ impl< tx_broadcaster, router, flow, + currency_conversion, best_block: RwLock::new(params.best_block), @@ -14493,8 +14512,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>); @@ -15340,8 +15360,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()) @@ -15726,8 +15747,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. /// @@ -15751,8 +15773,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) { { @@ -15802,8 +15825,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) { @@ -15965,8 +15989,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 @@ -16319,8 +16344,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 @@ -16879,8 +16905,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger, - > OffersMessageHandler for ChannelManager + > OffersMessageHandler for ChannelManager { #[rustfmt::skip] fn handle_message( @@ -17087,8 +17114,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, @@ -17333,8 +17361,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) @@ -17858,8 +17887,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> { @@ -18593,6 +18623,7 @@ pub struct ChannelManagerReadArgs< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger + Clone, > { /// A cryptographically secure source of entropy. @@ -18631,6 +18662,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, @@ -18671,16 +18707,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, @@ -18691,6 +18729,7 @@ impl< tx_broadcaster, router, message_router, + currency_conversion, logger, config, channel_monitors: hash_map_from_iter( @@ -18748,15 +18787,16 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger + Clone, - > ReadableArgs> - for (BestBlock, Arc>) + > ReadableArgs> + for (BestBlock, 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 (best_block, chan_manager) = - <(BestBlock, ChannelManager)>::read(reader, args)?; + <(BestBlock, ChannelManager)>::read(reader, args)?; Ok((best_block, Arc::new(chan_manager))) } } @@ -18771,12 +18811,13 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger + Clone, - > ReadableArgs> - for (BestBlock, ChannelManager) + > ReadableArgs> + for (BestBlock, 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( @@ -18803,8 +18844,9 @@ impl< F: FeeEstimator, R: Router, MR: MessageRouter, + CC: CurrencyConversion, L: Logger + Clone, - > ChannelManager + > ChannelManager { /// Constructs a `ChannelManager` from deserialized data and runtime dependencies. /// @@ -18816,7 +18858,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<(BestBlock, Self), DecodeError> { let ChannelManagerData { chain_hash, @@ -20048,6 +20090,7 @@ impl< tx_broadcaster: args.tx_broadcaster, router: args.router, flow, + currency_conversion: args.currency_conversion, best_block: RwLock::new(best_block), @@ -21662,6 +21705,7 @@ pub mod bench { &'a test_utils::TestFeeEstimator, &'a test_utils::TestRouter<'a>, &'a test_utils::TestMessageRouter<'a>, + &'a test_utils::TestCurrencyConversion, &'a test_utils::TestLogger, >; @@ -21700,6 +21744,7 @@ pub mod bench { let entropy = test_utils::TestKeysInterface::new(&[0u8; 32], network); let router = test_utils::TestRouter::new(Arc::new(NetworkGraph::new(network, &logger_a)), &logger_a, &scorer); let message_router = test_utils::TestMessageRouter::new_default(Arc::new(NetworkGraph::new(network, &logger_a)), &entropy); + let currency_conversion = test_utils::TestCurrencyConversion; let mut config: UserConfig = Default::default(); config.channel_config.max_dust_htlc_exposure = MaxDustHTLCExposure::FeeRateMultiplier(5_000_000 / 253); @@ -21708,7 +21753,7 @@ pub mod bench { let seed_a = [1u8; 32]; let keys_manager_a = KeysManager::new(&seed_a, 42, 42, true); let chain_monitor_a = ChainMonitor::new(None, &tx_broadcaster, &logger_a, &fee_estimator, &persister_a, &keys_manager_a, keys_manager_a.get_peer_storage_key(), false); - let node_a = ChannelManager::new(&fee_estimator, &chain_monitor_a, &tx_broadcaster, &router, &message_router, &logger_a, &keys_manager_a, &keys_manager_a, &keys_manager_a, config.clone(), ChainParameters { + let node_a = ChannelManager::new(&fee_estimator, &chain_monitor_a, &tx_broadcaster, &router, &message_router, ¤cy_conversion, &logger_a, &keys_manager_a, &keys_manager_a, &keys_manager_a, config.clone(), ChainParameters { network, best_block: BestBlock::from_network(network), }, genesis_block.header.time); @@ -21718,7 +21763,7 @@ pub mod bench { let seed_b = [2u8; 32]; let keys_manager_b = KeysManager::new(&seed_b, 42, 42, true); let chain_monitor_b = ChainMonitor::new(None, &tx_broadcaster, &logger_a, &fee_estimator, &persister_b, &keys_manager_b, keys_manager_b.get_peer_storage_key(), false); - let node_b = ChannelManager::new(&fee_estimator, &chain_monitor_b, &tx_broadcaster, &router, &message_router, &logger_b, &keys_manager_b, &keys_manager_b, &keys_manager_b, config.clone(), ChainParameters { + let node_b = ChannelManager::new(&fee_estimator, &chain_monitor_b, &tx_broadcaster, &router, &message_router, ¤cy_conversion, &logger_b, &keys_manager_b, &keys_manager_b, &keys_manager_b, config.clone(), ChainParameters { network, best_block: BestBlock::from_network(network), }, genesis_block.header.time); diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index d39cee78b0f..67d5ace3d1d 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, >; @@ -552,6 +554,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>, @@ -737,6 +740,7 @@ pub trait NodeHolder { ::FeeEstimator, ::Router, ::MessageRouter, + ::CurrencyConversion, ::Logger, >; fn chain_monitor(&self) -> Option<&test_utils::TestChainMonitor<'_>>; @@ -754,6 +758,7 @@ impl NodeHolder for &H { ::FeeEstimator, ::Router, ::MessageRouter, + ::CurrencyConversion, ::Logger, > { (*self).node() @@ -884,6 +889,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( @@ -903,6 +909,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, @@ -1334,6 +1341,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, @@ -4605,6 +4613,7 @@ where Arc::clone(&network_graph), &cfg.keys_manager, ), + currency_conversion: test_utils::TestCurrencyConversion, chain_monitor, keys_manager: &cfg.keys_manager, node_seed: seed, @@ -4706,6 +4715,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, >, > { @@ -4720,6 +4730,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, @@ -4750,6 +4761,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, >, >, @@ -4805,6 +4817,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 32a07be4d2b..bfd42bbe571 100644 --- a/lightning/src/ln/functional_tests.rs +++ b/lightning/src/ln/functional_tests.rs @@ -4869,6 +4869,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, @@ -4876,6 +4877,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 9e992467ecd..91aaa911883 100644 --- a/lightning/src/ln/reload_tests.rs +++ b/lightning/src/ln/reload_tests.rs @@ -426,7 +426,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut nodes_0_read = &nodes_0_serialized[..]; if let Err(msgs::DecodeError::DangerousValue) = - <(BestBlock, 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 { + <(BestBlock, 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, @@ -434,6 +434,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, @@ -445,7 +446,7 @@ fn test_manager_serialize_deserialize_inconsistent_monitor() { let mut nodes_0_read = &nodes_0_serialized[..]; let (_, nodes_0_deserialized_tmp) = - <(BestBlock, 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 { + <(BestBlock, 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, @@ -453,6 +454,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 c1c3ce26aee..cc326d03ba7 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -25,7 +25,6 @@ use crate::blinded_path::payment::{ PaymentConstraints, PaymentContext, ReceiveTlvs, }; use crate::chain::channelmonitor::LATENCY_GRACE_PERIOD_BLOCKS; - #[allow(unused_imports)] use crate::prelude::*; From 66adad105fd854458c5f591177047f6e3fa7c844 Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 2 Mar 2026 16:12:58 +0530 Subject: [PATCH 06/10] [feat] Support fiat-denominated amounts in OfferBuilder Allow OfferBuilder to accept currency-denominated amounts when the caller provides a CurrencyConversion implementation. Validate configured amounts in the builder setters instead of deferring that work to build(). That lets unsupported or invalid fiat amounts fail at the point they are provided and keeps build() focused on assembling and signing already-validated offer state. Co-Authored-By: OpenAI Codex --- lightning/src/ln/async_payments_tests.rs | 6 +- lightning/src/ln/channelmanager.rs | 4 +- .../src/ln/max_payment_path_len_tests.rs | 2 +- lightning/src/ln/offers_tests.rs | 98 +++---- lightning/src/ln/outbound_payment.rs | 16 +- lightning/src/offers/flow.rs | 10 +- lightning/src/offers/invoice.rs | 74 +++--- lightning/src/offers/invoice_request.rs | 148 ++++++----- lightning/src/offers/merkle.rs | 15 +- lightning/src/offers/offer.rs | 244 +++++++++--------- lightning/src/offers/static_invoice.rs | 45 ++-- lightning/src/offers/test_utils.rs | 3 +- 12 files changed, 322 insertions(+), 343 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 341bd5d7269..8ca3b747ef4 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -324,7 +324,7 @@ fn create_static_invoice( .flow .create_async_receive_offer_builder(entropy_source, blinded_paths_to_always_online_node) .unwrap(); - let offer = offer_builder.build().unwrap(); + let offer = offer_builder.build(); let static_invoice = create_static_invoice_builder(recipient, &offer, offer_nonce, relative_expiry) .build_and_sign(&secp_ctx) @@ -695,7 +695,7 @@ fn static_invoice_unknown_required_features() { .flow .create_async_receive_offer_builder(entropy_source, blinded_paths_to_always_online_node) .unwrap(); - let offer = offer_builder.build().unwrap(); + let offer = offer_builder.build(); let static_invoice_unknown_req_features = create_static_invoice_builder(&nodes[2], &offer, nonce, None) .features_unchecked(Bolt12InvoiceFeatures::unknown()) @@ -1679,7 +1679,7 @@ fn invalid_async_receive_with_retry( .flow .create_async_receive_offer_builder(entropy_source, blinded_paths_to_always_online_node) .unwrap(); - let offer = offer_builder.build().unwrap(); + let offer = offer_builder.build(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 61848efe1bd..43dc0eda2dc 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -2454,8 +2454,8 @@ impl< /// # let builder: lightning::offers::offer::OfferBuilder<_, _> = offer.into(); /// # let offer = builder /// .description("coffee".to_string()) -/// .amount_msats(10_000_000) -/// .build()?; +/// .amount_msats(10_000_000).unwrap() +/// .build(); /// let bech32_offer = offer.to_string(); /// /// // On the event processing thread diff --git a/lightning/src/ln/max_payment_path_len_tests.rs b/lightning/src/ln/max_payment_path_len_tests.rs index 45640d3486d..e4455a13b52 100644 --- a/lightning/src/ln/max_payment_path_len_tests.rs +++ b/lightning/src/ln/max_payment_path_len_tests.rs @@ -519,7 +519,7 @@ fn bolt12_invoice_too_large_blinded_paths() { ), ]); - let offer = nodes[1].node.create_offer_builder().unwrap().build().unwrap(); + let offer = nodes[1].node.create_offer_builder().unwrap().build(); let payment_id = PaymentId([1; 32]); nodes[0] .node diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 6ca3bb4e742..50d668323ab 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -326,8 +326,8 @@ fn create_offer_with_no_blinded_path() { let router = NullMessageRouter {}; let offer = alice.node .create_offer_builder_using_router(&router).unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_eq!(offer.issuer_signing_pubkey(), Some(alice_id)); assert!(offer.paths().is_empty()); } @@ -402,8 +402,8 @@ fn prefers_non_tor_nodes_in_blinded_paths() { let offer = bob.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_ne!(offer.issuer_signing_pubkey(), Some(bob_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { @@ -418,8 +418,8 @@ fn prefers_non_tor_nodes_in_blinded_paths() { let offer = bob.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_ne!(offer.issuer_signing_pubkey(), Some(bob_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { @@ -469,8 +469,8 @@ fn prefers_more_connected_nodes_in_blinded_paths() { let offer = bob.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_ne!(offer.issuer_signing_pubkey(), Some(bob_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { @@ -504,8 +504,8 @@ fn check_dummy_hop_pattern_in_offer() { let compact_offer = alice.node .create_offer_builder_using_router(&default_router).unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert!(!compact_offer.paths().is_empty()); @@ -532,8 +532,8 @@ fn check_dummy_hop_pattern_in_offer() { let padded_offer = alice.node .create_offer_builder_using_router(&node_id_router).unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert!(!padded_offer.paths().is_empty()); assert!(padded_offer.paths().iter().all(|path| path.blinded_hops().len() == QR_CODED_DUMMY_HOPS_PATH_LENGTH)); @@ -565,7 +565,7 @@ fn creates_short_lived_offer() { let offer = alice.node .create_offer_builder().unwrap() - .build().unwrap(); + .build(); assert!(!offer.paths().is_empty()); for path in offer.paths() { let introduction_node_id = resolve_introduction_node(bob, &path); @@ -591,7 +591,7 @@ fn creates_long_lived_offer() { let offer = alice.node .create_offer_builder_using_router(&router) .unwrap() - .build().unwrap(); + .build(); assert!(!offer.paths().is_empty()); for path in offer.paths() { assert_eq!(path.introduction_node(), &IntroductionNode::NodeId(alice_id)); @@ -697,8 +697,8 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { let offer = alice.node .create_offer_builder() .unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_ne!(offer.issuer_signing_pubkey(), Some(alice_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { @@ -862,8 +862,8 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { let offer = alice.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_ne!(offer.issuer_signing_pubkey(), Some(alice_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { @@ -986,8 +986,8 @@ fn pays_for_offer_without_blinded_paths() { let offer = alice.node .create_offer_builder().unwrap() .clear_paths() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_eq!(offer.issuer_signing_pubkey(), Some(alice_id)); assert!(offer.paths().is_empty()); @@ -1111,8 +1111,8 @@ fn send_invoice_requests_with_distinct_reply_path() { let offer = alice.node .create_offer_builder() .unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_ne!(offer.issuer_signing_pubkey(), Some(alice_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { @@ -1245,8 +1245,8 @@ fn creates_and_pays_for_offer_with_retry() { let offer = alice.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_ne!(offer.issuer_signing_pubkey(), Some(alice_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { @@ -1321,8 +1321,8 @@ fn pays_bolt12_invoice_asynchronously() { let offer = alice.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); let payment_id = PaymentId([1; 32]); bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); @@ -1413,8 +1413,8 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { let offer = alice.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_ne!(offer.issuer_signing_pubkey(), Some(alice_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { @@ -1557,8 +1557,8 @@ fn fails_authentication_when_handling_invoice_request() { let offer = alice.node .create_offer_builder() .unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_eq!(offer.metadata(), None); assert_ne!(offer.issuer_signing_pubkey(), Some(alice_id)); assert!(!offer.paths().is_empty()); @@ -1569,7 +1569,7 @@ fn fails_authentication_when_handling_invoice_request() { let invalid_path = alice.node .create_offer_builder() .unwrap() - .build().unwrap() + .build() .paths().first().unwrap() .clone(); assert!(check_compact_path_introduction_node(&invalid_path, alice, bob_id)); @@ -1667,8 +1667,8 @@ fn fails_authentication_when_handling_invoice_for_offer() { let offer = alice.node .create_offer_builder() .unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); assert_ne!(offer.issuer_signing_pubkey(), Some(alice_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { @@ -1871,9 +1871,9 @@ fn fails_creating_or_paying_for_offer_without_connected_peers() { let absolute_expiry = alice.node.duration_since_epoch() + MAX_SHORT_LIVED_RELATIVE_EXPIRY; let offer = alice.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) + .amount_msats(10_000_000).unwrap() .absolute_expiry(absolute_expiry) - .build().unwrap(); + .build(); let payment_id = PaymentId([1; 32]); @@ -1973,7 +1973,7 @@ fn fails_creating_invoice_request_for_unsupported_chain() { .create_offer_builder().unwrap() .clear_chains() .chain(Network::Signet) - .build().unwrap(); + .build(); match bob.node.pay_for_offer(&offer, None, PaymentId([1; 32]), Default::default()) { Ok(_) => panic!("Expected error"), @@ -2029,8 +2029,8 @@ fn fails_creating_invoice_request_without_blinded_reply_path() { let offer = alice.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); match david.node.pay_for_offer(&offer, None, PaymentId([1; 32]), Default::default()) { Ok(_) => panic!("Expected error"), @@ -2061,8 +2061,8 @@ fn fails_creating_invoice_request_with_duplicate_payment_id() { let offer = alice.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); let payment_id = PaymentId([1; 32]); assert!(david.node.pay_for_offer( &offer, None, payment_id, Default::default()).is_ok()); @@ -2143,8 +2143,8 @@ fn fails_sending_invoice_without_blinded_payment_paths_for_offer() { let offer = alice.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); let payment_id = PaymentId([1; 32]); david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); @@ -2351,8 +2351,8 @@ fn fails_paying_invoice_with_unknown_required_features() { let offer = alice.node .create_offer_builder().unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); let payment_id = PaymentId([1; 32]); david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); @@ -2436,7 +2436,7 @@ fn rejects_keysend_to_non_static_invoice_path() { create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); // First pay the offer and save the payment preimage and invoice. - let offer = nodes[1].node.create_offer_builder().unwrap().build().unwrap(); + let offer = nodes[1].node.create_offer_builder().unwrap().build(); let amt_msat = 5000; let payment_id = PaymentId([1; 32]); nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap(); @@ -2519,8 +2519,8 @@ fn no_double_pay_with_stale_channelmanager() { let offer = nodes[1].node .create_offer_builder().unwrap() .clear_paths() - .amount_msats(amt_msat) - .build().unwrap(); + .amount_msats(amt_msat).unwrap() + .build(); assert_eq!(offer.issuer_signing_pubkey(), Some(bob_id)); assert!(offer.paths().is_empty()); @@ -2598,8 +2598,8 @@ fn creates_and_pays_for_phantom_offer() { let offer = nodes[1].node .create_phantom_offer_builder(vec![(node_c_id, nodes[2].node.list_channels())], 2) .unwrap() - .amount_msats(10_000_000) - .build().unwrap(); + .amount_msats(10_000_000).unwrap() + .build(); // The offer should be resolvable by either of node B or C but signed by a derived key assert!(offer.issuer_signing_pubkey().is_some()); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 7259f60796f..bf9132c665e 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -3256,8 +3256,8 @@ mod tests { let created_at = now() - DEFAULT_RELATIVE_EXPIRY; let invoice = OfferBuilder::new(recipient_pubkey()) - .amount_msats(1000) - .build().unwrap() + .amount_msats(1000).unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() .respond_with_no_std(payment_paths(), payment_hash(), created_at).unwrap() @@ -3305,8 +3305,8 @@ mod tests { let expiration = StaleExpiration::AbsoluteTimeout(Duration::from_secs(100)); let invoice = OfferBuilder::new(recipient_pubkey()) - .amount_msats(1000) - .build().unwrap() + .amount_msats(1000).unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() @@ -3370,8 +3370,8 @@ mod tests { let expiration = StaleExpiration::AbsoluteTimeout(Duration::from_secs(100)); let invoice = OfferBuilder::new(recipient_pubkey()) - .amount_msats(1000) - .build().unwrap() + .amount_msats(1000).unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() @@ -3459,8 +3459,8 @@ mod tests { let payment_id = PaymentId([1; 32]); OfferBuilder::new(recipient_pubkey()) - .amount_msats(1000) - .build().unwrap() + .amount_msats(1000).unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index cc326d03ba7..ebbfe02a029 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -1621,14 +1621,8 @@ impl OffersMessageFlow { offer_builder = offer_builder.absolute_expiry(Duration::from_secs(paths_absolute_expiry)); } - let (offer_id, offer) = match offer_builder.build() { - Ok(offer) => (offer.id(), offer), - Err(_) => { - log_error!(self.logger, "Failed to build async receive offer"); - debug_assert!(false); - return None; - }, - }; + let offer = offer_builder.build(); + let offer_id = offer.id(); let (invoice, forward_invoice_request_path) = match self.create_static_invoice_for_server( &offer, diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index eb566a919ff..f401d6b8594 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1889,8 +1889,8 @@ mod tests { let now = now(); let unsigned_invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2159,9 +2159,9 @@ mod tests { if let Err(e) = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .absolute_expiry(future_expiry) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2175,9 +2175,9 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .absolute_expiry(past_expiry) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_unchecked_and_sign() @@ -2245,10 +2245,10 @@ mod tests { let invoice_request = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .amount_msats(1000) + .unwrap() .path(blinded_path) .experimental_foo(42) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2358,8 +2358,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2379,8 +2379,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2409,8 +2409,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(1001) @@ -2438,9 +2438,9 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .quantity(2) @@ -2459,9 +2459,9 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .quantity(u64::max_value()) @@ -2489,8 +2489,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2545,8 +2545,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2573,8 +2573,8 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2591,8 +2591,8 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2618,8 +2618,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2695,8 +2695,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2739,8 +2739,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2772,8 +2772,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2816,8 +2816,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2858,8 +2858,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2900,8 +2900,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2964,8 +2964,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3049,10 +3049,10 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .clear_issuer_signing_pubkey() .amount_msats(1000) + .unwrap() .path(paths[0].clone()) .path(paths[1].clone()) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3079,10 +3079,10 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .clear_issuer_signing_pubkey() .amount_msats(1000) + .unwrap() .path(paths[0].clone()) .path(paths[1].clone()) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3123,8 +3123,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3150,8 +3150,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(1000) @@ -3216,8 +3216,8 @@ mod tests { let mut buffer = Vec::new(); OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3249,8 +3249,8 @@ mod tests { let mut invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3292,8 +3292,8 @@ mod tests { let keys = Keypair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let mut unsigned_invoice = OfferBuilder::new(keys.public_key()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3331,8 +3331,8 @@ mod tests { let mut unsigned_invoice = OfferBuilder::new(keys.public_key()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3377,8 +3377,8 @@ mod tests { let keys = Keypair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let invoice = OfferBuilder::new(keys.public_key()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3403,8 +3403,8 @@ mod tests { let mut unsigned_invoice = OfferBuilder::new(keys.public_key()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3444,8 +3444,8 @@ mod tests { let mut unsigned_invoice = OfferBuilder::new(keys.public_key()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3482,8 +3482,8 @@ mod tests { let invoice = OfferBuilder::new(keys.public_key()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3523,8 +3523,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3558,8 +3558,8 @@ mod tests { let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3601,7 +3601,7 @@ mod tests { let secp_ctx = Secp256k1::new(); let payment_id = PaymentId([1; 32]); - let offer = OfferBuilder::new(recipient_pubkey()).amount_msats(1000).build().unwrap(); + let offer = OfferBuilder::new(recipient_pubkey()).amount_msats(1000).unwrap().build(); let offer_id = offer.id(); @@ -3649,7 +3649,7 @@ mod tests { let now = Duration::from_secs(123456); let payment_id = PaymentId([1; 32]); - let offer = OfferBuilder::new(node_id).amount_msats(1000).build().unwrap(); + let offer = OfferBuilder::new(node_id).amount_msats(1000).unwrap().build(); let invoice_request = offer .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index bf3c4a08dca..8d88eb23e23 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, @@ -1580,6 +1581,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}; @@ -1598,8 +1600,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -1691,9 +1693,9 @@ mod tests { if let Err(e) = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .absolute_expiry(future_expiry) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -1703,9 +1705,9 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .absolute_expiry(past_expiry) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -1725,9 +1727,9 @@ mod tests { let offer = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .experimental_foo(42) - .build() - .unwrap(); + .build(); let invoice_request = offer .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() @@ -1838,8 +1840,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .chain(Network::Bitcoin) @@ -1852,9 +1854,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .chain(Network::Testnet) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .chain(Network::Testnet) @@ -1867,10 +1869,10 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .chain(Network::Bitcoin) .chain(Network::Testnet) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .chain(Network::Bitcoin) @@ -1883,10 +1885,10 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .chain(Network::Bitcoin) .chain(Network::Testnet) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .chain(Network::Bitcoin) @@ -1901,9 +1903,9 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .chain(Network::Testnet) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .chain(Network::Bitcoin) @@ -1914,9 +1916,9 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .chain(Network::Testnet) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -1936,8 +1938,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(1000) @@ -1952,8 +1954,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(1001) @@ -1970,8 +1972,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(1001) @@ -1986,8 +1988,8 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(999) @@ -1998,9 +2000,9 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .quantity(2) @@ -2013,8 +2015,8 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(MAX_VALUE_MSAT + 1) @@ -2025,9 +2027,9 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(1000) @@ -2042,7 +2044,6 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2052,16 +2053,16 @@ mod tests { } // An offer with amount_msats(0) must be rejected by the builder per BOLT 12. - match OfferBuilder::new(recipient_pubkey()).amount_msats(0).build() { + match OfferBuilder::new(recipient_pubkey()).amount_msats(0) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidAmount), } match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .quantity(u64::max_value()) @@ -2080,11 +2081,12 @@ 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) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2096,9 +2098,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .quantity(2) @@ -2111,10 +2113,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() @@ -2138,8 +2141,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .features_unchecked(InvoiceRequestFeatures::unknown()) @@ -2151,8 +2154,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .features_unchecked(InvoiceRequestFeatures::unknown()) @@ -2177,9 +2180,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::One) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2190,9 +2193,9 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::One) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(2_000) @@ -2205,9 +2208,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Bounded(ten)) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(10_000) @@ -2222,9 +2225,9 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Bounded(ten)) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(11_000) @@ -2237,9 +2240,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(2_000) @@ -2254,9 +2257,9 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2267,9 +2270,9 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Bounded(one)) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2289,8 +2292,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .payer_note("bar".into()) @@ -2302,8 +2305,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .payer_note("bar".into()) @@ -2325,8 +2328,8 @@ mod tests { match OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .features_unchecked(InvoiceRequestFeatures::unknown()) @@ -2349,8 +2352,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2374,8 +2377,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .chain(Network::Bitcoin) @@ -2392,8 +2395,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .chain_unchecked(Network::Testnet) @@ -2418,11 +2421,12 @@ 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) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2437,7 +2441,6 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(1000) @@ -2454,7 +2457,6 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_unchecked_and_sign(); @@ -2472,8 +2474,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats_unchecked(999) @@ -2492,10 +2494,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() @@ -2516,9 +2522,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .quantity(u64::max_value()) @@ -2550,9 +2556,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::One) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2567,9 +2573,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::One) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(2_000) @@ -2592,9 +2598,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Bounded(ten)) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(10_000) @@ -2613,9 +2619,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Bounded(ten)) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(11_000) @@ -2636,9 +2642,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .amount_msats(2_000) @@ -2657,9 +2663,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_unchecked_and_sign(); @@ -2677,9 +2683,9 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Bounded(one)) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_unchecked_and_sign(); @@ -2706,8 +2712,8 @@ mod tests { let unsigned_invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_unchecked(); @@ -2738,8 +2744,8 @@ mod tests { let unsigned_invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_unchecked(); @@ -2768,8 +2774,8 @@ mod tests { let unsigned_invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_unchecked(); @@ -2803,8 +2809,8 @@ mod tests { let mut buffer = Vec::new(); OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_unchecked() @@ -2831,8 +2837,8 @@ mod tests { let mut invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -2868,8 +2874,8 @@ mod tests { let keys = Keypair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let (mut unsigned_invoice_request, payer_keys, _) = OfferBuilder::new(keys.public_key()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_without_checks(); @@ -2903,8 +2909,8 @@ mod tests { let (mut unsigned_invoice_request, payer_keys, _) = OfferBuilder::new(keys.public_key()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_without_checks(); @@ -2948,8 +2954,8 @@ mod tests { let keys = Keypair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); let (mut unsigned_invoice_request, payer_keys, _) = OfferBuilder::new(keys.public_key()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_without_checks(); @@ -2986,8 +2992,8 @@ mod tests { let (mut unsigned_invoice_request, payer_keys, _) = OfferBuilder::new(keys.public_key()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_without_checks(); @@ -3021,8 +3027,8 @@ mod tests { let invoice_request = OfferBuilder::new(keys.public_key()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3054,8 +3060,8 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_and_sign() @@ -3100,9 +3106,9 @@ mod tests { let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .chain(Network::Testnet) .amount_msats(1000) + .unwrap() .supported_quantity(Quantity::Unbounded) - .build() - .unwrap(); + .build(); assert_eq!(offer.issuer_signing_pubkey(), Some(node_id)); // UTF-8 payer note that we can't naively `.truncate(PAYER_NOTE_LIMIT)` diff --git a/lightning/src/offers/merkle.rs b/lightning/src/offers/merkle.rs index 1a38fe5441f..4bf3161123d 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) @@ -397,8 +400,8 @@ mod tests { let unsigned_invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) - .build() .unwrap() + .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .payer_note("bar".into()) @@ -429,6 +432,7 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey) .amount_msats(100) + .unwrap() .build_unchecked() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() @@ -463,6 +467,7 @@ mod tests { let invoice_request = OfferBuilder::new(recipient_pubkey) .amount_msats(100) + .unwrap() .build_unchecked() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 9e9a237f125..9b7c6bb84a9 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -45,13 +45,13 @@ //! let expiration = SystemTime::now() + Duration::from_secs(24 * 60 * 60); //! let offer = OfferBuilder::new(pubkey) //! .description("coffee, large".to_string()) -//! .amount_msats(20_000) +//! .amount_msats(20_000).unwrap() //! .supported_quantity(Quantity::Unbounded) //! .absolute_expiry(expiration.duration_since(SystemTime::UNIX_EPOCH).unwrap()) //! .issuer("Foo Bar".to_string()) //! .path(create_blinded_path()) //! .path(create_another_blinded_path()) -//! .build()?; +//! .build(); //! //! // Encode as a bech32 string for use in a QR code. //! let encoded_offer = offer.to_string(); @@ -315,6 +315,14 @@ macro_rules! offer_derived_metadata_builder_methods { macro_rules! offer_builder_methods { ( $self: ident, $self_type: ty, $return_type: ty, $return_value: expr $(, $self_mut: tt)? ) => { + fn validate_amount_msats(amount_msats: u64) -> Result<(), Bolt12SemanticError> { + if amount_msats == 0 || amount_msats > MAX_VALUE_MSAT { + return Err(Bolt12SemanticError::InvalidAmount); + } + + Ok(()) + } + /// Adds the chain hash of the given [`Network`] to [`Offer::chains`]. If not called, /// the chain hash of [`Network::Bitcoin`] is assumed to be the only one supported. /// @@ -340,19 +348,28 @@ 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. - pub fn amount_msats($self: $self_type, amount_msats: u64) -> $return_type { - $self.amount(Amount::Bitcoin { amount_msats }) + /// Internally this sets the amount as [`Amount::Bitcoin`]. + /// + /// Successive calls to this method override the previously set amount. + pub fn amount_msats($($self_mut)* $self: $self_type, amount_msats: u64) -> Result<$return_type, Bolt12SemanticError> { + Self::validate_amount_msats(amount_msats)?; + + $self.offer.amount = Some(Amount::Bitcoin { amount_msats }); + Ok($return_value) } /// 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> + { + let amount_msats = amount.resolve_msats(currency_conversion)?.amount_msats(); + Self::validate_amount_msats(amount_msats)?; + $self.offer.amount = Some(amount); - $return_value + Ok($return_value) } /// Sets the [`Offer::absolute_expiry`] as seconds since the Unix epoch. @@ -400,17 +417,7 @@ 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 == 0 || amount_msats > MAX_VALUE_MSAT { - return Err(Bolt12SemanticError::InvalidAmount); - } - }, - Some(Amount::Currency { .. }) => return Err(Bolt12SemanticError::UnsupportedCurrency), - None => {}, - } - + pub fn build($($self_mut)* $self: $self_type) -> Offer { if $self.offer.amount.is_some() && $self.offer.description.is_none() { $self.offer.description = Some(String::new()); } @@ -421,10 +428,6 @@ macro_rules! offer_builder_methods { ( } } - Ok($self.build_without_checks()) - } - - fn build_without_checks($($self_mut)* $self: $self_type) -> Offer { if let Some(mut metadata) = $self.offer.metadata.take() { // Create the metadata for stateless verification of an InvoiceRequest. if metadata.has_derivation_material() { @@ -512,7 +515,7 @@ macro_rules! offer_builder_test_methods { ( #[cfg_attr(c_bindings, allow(dead_code))] pub(super) fn build_unchecked($self: $self_type) -> Offer { - $self.build_without_checks() + $self.build() } } } @@ -709,6 +712,21 @@ 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 and + /// derive the tolerated range implied by the current conversion snapshot. + /// + /// Returns: + /// - `Ok(Some(range))` 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 +1012,12 @@ impl OfferContents { self.issuer_signing_pubkey } + pub(super) fn resolve_offer_amount( + &self, currency_conversion: &CC, + ) -> Result, Bolt12SemanticError> { + self.amount().map(|amt| amt.resolve_msats(currency_conversion)).transpose() + } + pub(super) fn verify_using_metadata( &self, bytes: &[u8], key: &ExpandedKey, secp_ctx: &Secp256k1, ) -> Result<(OfferId, Option), ()> { @@ -1508,7 +1532,7 @@ mod tests { #[test] fn builds_offer_with_defaults() { - let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).build(); let mut buffer = Vec::new(); offer.write(&mut buffer).unwrap(); @@ -1559,30 +1583,24 @@ mod tests { let mainnet = ChainHash::using_genesis_block(Network::Bitcoin); let testnet = ChainHash::using_genesis_block(Network::Testnet); - let offer = OfferBuilder::new(pubkey(42)).chain(Network::Bitcoin).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).chain(Network::Bitcoin).build(); assert!(offer.supports_chain(mainnet)); assert_eq!(offer.chains(), vec![mainnet]); assert_eq!(offer.as_tlv_stream().0.chains, None); - let offer = OfferBuilder::new(pubkey(42)).chain(Network::Testnet).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).chain(Network::Testnet).build(); assert!(offer.supports_chain(testnet)); assert_eq!(offer.chains(), vec![testnet]); assert_eq!(offer.as_tlv_stream().0.chains, Some(&vec![testnet])); - let offer = OfferBuilder::new(pubkey(42)) - .chain(Network::Testnet) - .chain(Network::Testnet) - .build() - .unwrap(); + let offer = + OfferBuilder::new(pubkey(42)).chain(Network::Testnet).chain(Network::Testnet).build(); assert!(offer.supports_chain(testnet)); assert_eq!(offer.chains(), vec![testnet]); assert_eq!(offer.as_tlv_stream().0.chains, Some(&vec![testnet])); - let offer = OfferBuilder::new(pubkey(42)) - .chain(Network::Bitcoin) - .chain(Network::Testnet) - .build() - .unwrap(); + let offer = + OfferBuilder::new(pubkey(42)).chain(Network::Bitcoin).chain(Network::Testnet).build(); assert!(offer.supports_chain(mainnet)); assert!(offer.supports_chain(testnet)); assert_eq!(offer.chains(), vec![mainnet, testnet]); @@ -1591,7 +1609,7 @@ mod tests { #[test] fn builds_offer_with_metadata() { - let offer = OfferBuilder::new(pubkey(42)).metadata(vec![42; 32]).unwrap().build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).metadata(vec![42; 32]).unwrap().build(); assert_eq!(offer.metadata(), Some(&vec![42; 32])); assert_eq!(offer.as_tlv_stream().0.metadata, Some(&vec![42; 32])); @@ -1600,8 +1618,7 @@ mod tests { .unwrap() .metadata(vec![43; 32]) .unwrap() - .build() - .unwrap(); + .build(); assert_eq!(offer.metadata(), Some(&vec![43; 32])); assert_eq!(offer.as_tlv_stream().0.metadata, Some(&vec![43; 32])); } @@ -1619,9 +1636,9 @@ mod tests { use super::OfferWithDerivedMetadataBuilder as OfferBuilder; let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .amount_msats(1000) + .unwrap() .experimental_foo(42) - .build() - .unwrap(); + .build(); assert!(offer.metadata().is_some()); assert_eq!(offer.issuer_signing_pubkey(), Some(node_id)); @@ -1699,10 +1716,10 @@ mod tests { use super::OfferWithDerivedMetadataBuilder as OfferBuilder; let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .amount_msats(1000) + .unwrap() .path(blinded_path) .experimental_foo(42) - .build() - .unwrap(); + .build(); assert!(offer.metadata().is_none()); assert_ne!(offer.issuer_signing_pubkey(), Some(node_id)); @@ -1766,44 +1783,45 @@ mod tests { let currency_amount = Amount::Currency { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 10 }; - let offer = OfferBuilder::new(pubkey(42)).amount_msats(1000).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).amount_msats(1000).unwrap().build(); let tlv_stream = offer.as_tlv_stream(); assert_eq!(offer.amount(), Some(bitcoin_amount)); 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()) - .build() - .unwrap(); + .amount(currency_amount.clone(), &conversion) + .unwrap() + .amount(bitcoin_amount.clone(), &conversion) + .unwrap() + .build(); let tlv_stream = offer.as_tlv_stream(); assert_eq!(tlv_stream.0.amount, Some(1000)); 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), } // An amount of 0 must be rejected per BOLT 12. - match OfferBuilder::new(pubkey(42)).amount_msats(0).build() { + match OfferBuilder::new(pubkey(42)).amount_msats(0) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidAmount), } @@ -1856,37 +1874,33 @@ mod tests { #[test] fn builds_offer_with_description() { - let offer = OfferBuilder::new(pubkey(42)).description("foo".into()).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).description("foo".into()).build(); assert_eq!(offer.description(), Some(PrintableString("foo"))); assert_eq!(offer.as_tlv_stream().0.description, Some(&String::from("foo"))); let offer = OfferBuilder::new(pubkey(42)) .description("foo".into()) .description("bar".into()) - .build() - .unwrap(); + .build(); assert_eq!(offer.description(), Some(PrintableString("bar"))); assert_eq!(offer.as_tlv_stream().0.description, Some(&String::from("bar"))); - let offer = OfferBuilder::new(pubkey(42)).amount_msats(1000).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).amount_msats(1000).unwrap().build(); assert_eq!(offer.description(), Some(PrintableString(""))); assert_eq!(offer.as_tlv_stream().0.description, Some(&String::from(""))); } #[test] fn builds_offer_with_features() { - let offer = OfferBuilder::new(pubkey(42)) - .features_unchecked(OfferFeatures::unknown()) - .build() - .unwrap(); + let offer = + OfferBuilder::new(pubkey(42)).features_unchecked(OfferFeatures::unknown()).build(); assert_eq!(offer.offer_features(), &OfferFeatures::unknown()); assert_eq!(offer.as_tlv_stream().0.features, Some(&OfferFeatures::unknown())); let offer = OfferBuilder::new(pubkey(42)) .features_unchecked(OfferFeatures::unknown()) .features_unchecked(OfferFeatures::empty()) - .build() - .unwrap(); + .build(); assert_eq!(offer.offer_features(), &OfferFeatures::empty()); assert_eq!(offer.as_tlv_stream().0.features, None); } @@ -1897,7 +1911,7 @@ mod tests { let past_expiry = Duration::from_secs(0); let now = future_expiry - Duration::from_secs(1_000); - let offer = OfferBuilder::new(pubkey(42)).absolute_expiry(future_expiry).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).absolute_expiry(future_expiry).build(); #[cfg(feature = "std")] assert!(!offer.is_expired()); assert!(!offer.is_expired_no_std(now)); @@ -1907,8 +1921,7 @@ mod tests { let offer = OfferBuilder::new(pubkey(42)) .absolute_expiry(future_expiry) .absolute_expiry(past_expiry) - .build() - .unwrap(); + .build(); #[cfg(feature = "std")] assert!(offer.is_expired()); assert!(offer.is_expired_no_std(now)); @@ -1937,11 +1950,8 @@ mod tests { ), ]; - let offer = OfferBuilder::new(pubkey(42)) - .path(paths[0].clone()) - .path(paths[1].clone()) - .build() - .unwrap(); + let offer = + OfferBuilder::new(pubkey(42)).path(paths[0].clone()).path(paths[1].clone()).build(); let tlv_stream = offer.as_tlv_stream(); assert_eq!(offer.paths(), paths.as_slice()); assert_eq!(offer.issuer_signing_pubkey(), Some(pubkey(42))); @@ -1952,15 +1962,11 @@ mod tests { #[test] fn builds_offer_with_issuer() { - let offer = OfferBuilder::new(pubkey(42)).issuer("foo".into()).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).issuer("foo".into()).build(); assert_eq!(offer.issuer(), Some(PrintableString("foo"))); assert_eq!(offer.as_tlv_stream().0.issuer, Some(&String::from("foo"))); - let offer = OfferBuilder::new(pubkey(42)) - .issuer("foo".into()) - .issuer("bar".into()) - .build() - .unwrap(); + let offer = OfferBuilder::new(pubkey(42)).issuer("foo".into()).issuer("bar".into()).build(); assert_eq!(offer.issuer(), Some(PrintableString("bar"))); assert_eq!(offer.as_tlv_stream().0.issuer, Some(&String::from("bar"))); } @@ -1970,33 +1976,27 @@ mod tests { let one = NonZeroU64::new(1).unwrap(); let ten = NonZeroU64::new(10).unwrap(); - let offer = - OfferBuilder::new(pubkey(42)).supported_quantity(Quantity::One).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).supported_quantity(Quantity::One).build(); let tlv_stream = offer.as_tlv_stream(); assert!(!offer.expects_quantity()); assert_eq!(offer.supported_quantity(), Quantity::One); assert_eq!(tlv_stream.0.quantity_max, None); - let offer = - OfferBuilder::new(pubkey(42)).supported_quantity(Quantity::Unbounded).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).supported_quantity(Quantity::Unbounded).build(); let tlv_stream = offer.as_tlv_stream(); assert!(offer.expects_quantity()); assert_eq!(offer.supported_quantity(), Quantity::Unbounded); assert_eq!(tlv_stream.0.quantity_max, Some(0)); - let offer = OfferBuilder::new(pubkey(42)) - .supported_quantity(Quantity::Bounded(ten)) - .build() - .unwrap(); + let offer = + OfferBuilder::new(pubkey(42)).supported_quantity(Quantity::Bounded(ten)).build(); let tlv_stream = offer.as_tlv_stream(); assert!(offer.expects_quantity()); assert_eq!(offer.supported_quantity(), Quantity::Bounded(ten)); assert_eq!(tlv_stream.0.quantity_max, Some(10)); - let offer = OfferBuilder::new(pubkey(42)) - .supported_quantity(Quantity::Bounded(one)) - .build() - .unwrap(); + let offer = + OfferBuilder::new(pubkey(42)).supported_quantity(Quantity::Bounded(one)).build(); let tlv_stream = offer.as_tlv_stream(); assert!(offer.expects_quantity()); assert_eq!(offer.supported_quantity(), Quantity::Bounded(one)); @@ -2005,8 +2005,7 @@ mod tests { let offer = OfferBuilder::new(pubkey(42)) .supported_quantity(Quantity::Bounded(ten)) .supported_quantity(Quantity::One) - .build() - .unwrap(); + .build(); let tlv_stream = offer.as_tlv_stream(); assert!(!offer.expects_quantity()); assert_eq!(offer.supported_quantity(), Quantity::One); @@ -2024,7 +2023,6 @@ mod tests { match OfferBuilder::new(pubkey(42)) .features_unchecked(OfferFeatures::unknown()) .build() - .unwrap() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) { Ok(_) => panic!("expected error"), @@ -2034,11 +2032,8 @@ mod tests { #[test] fn parses_offer_with_chains() { - let offer = OfferBuilder::new(pubkey(42)) - .chain(Network::Bitcoin) - .chain(Network::Testnet) - .build() - .unwrap(); + let offer = + OfferBuilder::new(pubkey(42)).chain(Network::Bitcoin).chain(Network::Testnet).build(); if let Err(e) = offer.to_string().parse::() { panic!("error parsing offer: {:?}", e); } @@ -2046,10 +2041,11 @@ mod tests { #[test] fn parses_offer_with_amount() { + let conversion = TestCurrencyConversion; let offer = OfferBuilder::new(pubkey(42)) - .amount(Amount::Bitcoin { amount_msats: 1000 }) - .build() - .unwrap(); + .amount(Amount::Bitcoin { amount_msats: 1000 }, &conversion) + .unwrap() + .build(); if let Err(e) = offer.to_string().parse::() { panic!("error parsing offer: {:?}", e); } @@ -2181,7 +2177,7 @@ mod tests { #[test] fn parses_offer_with_description() { - let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).build(); if let Err(e) = offer.to_string().parse::() { panic!("error parsing offer: {:?}", e); } @@ -2189,8 +2185,8 @@ mod tests { let offer = OfferBuilder::new(pubkey(42)) .description("foo".to_string()) .amount_msats(1000) - .build() - .unwrap(); + .unwrap() + .build(); if let Err(e) = offer.to_string().parse::() { panic!("error parsing offer: {:?}", e); } @@ -2231,8 +2227,7 @@ mod tests { BlindedHop { blinded_node_id: pubkey(46), encrypted_payload: vec![0; 46] }, ], )) - .build() - .unwrap(); + .build(); if let Err(e) = offer.to_string().parse::() { panic!("error parsing offer: {:?}", e); } @@ -2247,8 +2242,7 @@ mod tests { ], )) .clear_issuer_signing_pubkey() - .build() - .unwrap(); + .build(); if let Err(e) = offer.to_string().parse::() { panic!("error parsing offer: {:?}", e); } @@ -2257,7 +2251,7 @@ mod tests { builder.offer.issuer_signing_pubkey = None; builder.offer.paths = Some(vec![]); - let offer = builder.build().unwrap(); + let offer = builder.build(); match offer.to_string().parse::() { Ok(_) => panic!("expected error"), Err(e) => { @@ -2271,30 +2265,26 @@ mod tests { #[test] fn parses_offer_with_quantity() { - let offer = - OfferBuilder::new(pubkey(42)).supported_quantity(Quantity::One).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).supported_quantity(Quantity::One).build(); if let Err(e) = offer.to_string().parse::() { panic!("error parsing offer: {:?}", e); } - let offer = - OfferBuilder::new(pubkey(42)).supported_quantity(Quantity::Unbounded).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).supported_quantity(Quantity::Unbounded).build(); if let Err(e) = offer.to_string().parse::() { panic!("error parsing offer: {:?}", e); } let offer = OfferBuilder::new(pubkey(42)) .supported_quantity(Quantity::Bounded(NonZeroU64::new(10).unwrap())) - .build() - .unwrap(); + .build(); if let Err(e) = offer.to_string().parse::() { panic!("error parsing offer: {:?}", e); } let offer = OfferBuilder::new(pubkey(42)) .supported_quantity(Quantity::Bounded(NonZeroU64::new(1).unwrap())) - .build() - .unwrap(); + .build(); if let Err(e) = offer.to_string().parse::() { panic!("error parsing offer: {:?}", e); } @@ -2302,7 +2292,7 @@ mod tests { #[test] fn parses_offer_with_issuer_id() { - let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).build(); if let Err(e) = offer.to_string().parse::() { panic!("error parsing offer: {:?}", e); } @@ -2331,7 +2321,7 @@ mod tests { const UNKNOWN_ODD_TYPE: u64 = OFFER_TYPES.end - 1; assert!(UNKNOWN_ODD_TYPE % 2 == 1); - let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).build(); let mut encoded_offer = Vec::new(); offer.write(&mut encoded_offer).unwrap(); @@ -2347,7 +2337,7 @@ mod tests { const UNKNOWN_EVEN_TYPE: u64 = OFFER_TYPES.end - 2; assert!(UNKNOWN_EVEN_TYPE % 2 == 0); - let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).build(); let mut encoded_offer = Vec::new(); offer.write(&mut encoded_offer).unwrap(); @@ -2363,7 +2353,7 @@ mod tests { #[test] fn parses_offer_with_experimental_tlv_records() { - let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).build(); let mut encoded_offer = Vec::new(); offer.write(&mut encoded_offer).unwrap(); @@ -2376,7 +2366,7 @@ mod tests { Err(e) => panic!("error parsing offer: {:?}", e), } - let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).build(); let mut encoded_offer = Vec::new(); offer.write(&mut encoded_offer).unwrap(); @@ -2392,7 +2382,7 @@ mod tests { #[test] fn fails_parsing_offer_with_out_of_range_tlv_records() { - let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).build(); let mut encoded_offer = Vec::new(); offer.write(&mut encoded_offer).unwrap(); @@ -2405,7 +2395,7 @@ mod tests { Err(e) => assert_eq!(e, Bolt12ParseError::Decode(DecodeError::InvalidValue)), } - let offer = OfferBuilder::new(pubkey(42)).build().unwrap(); + let offer = OfferBuilder::new(pubkey(42)).build(); let mut encoded_offer = Vec::new(); offer.write(&mut encoded_offer).unwrap(); diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index c8afb7cfc12..2f716a25750 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -819,8 +819,7 @@ mod tests { let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) - .build() - .unwrap(); + .build(); StaticInvoiceBuilder::for_offer_using_derived_keys( &offer, @@ -859,8 +858,7 @@ mod tests { let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) - .build() - .unwrap(); + .build(); let invoice = StaticInvoiceBuilder::for_offer_using_derived_keys( &offer, @@ -961,8 +959,7 @@ mod tests { OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) .absolute_expiry(future_expiry) - .build() - .unwrap(); + .build(); let invoice = StaticInvoiceBuilder::for_offer_using_derived_keys( &valid_offer, @@ -983,8 +980,7 @@ mod tests { OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) .absolute_expiry(past_expiry) - .build() - .unwrap(); + .build(); if let Err(e) = StaticInvoiceBuilder::for_offer_using_derived_keys( &expired_offer, payment_paths(), @@ -1015,8 +1011,7 @@ mod tests { let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) .experimental_foo(42) - .build() - .unwrap(); + .build(); if let Err(e) = StaticInvoiceBuilder::for_offer_using_derived_keys( &offer, @@ -1061,8 +1056,7 @@ mod tests { let valid_offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) - .build() - .unwrap(); + .build(); // Error if payment paths are missing. if let Err(e) = StaticInvoiceBuilder::for_offer_using_derived_keys( @@ -1128,8 +1122,7 @@ mod tests { let valid_offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) - .build() - .unwrap(); + .build(); let mut offer_missing_issuer_id = valid_offer.clone(); let (mut offer_tlv_stream, _) = offer_missing_issuer_id.as_tlv_stream(); @@ -1165,8 +1158,7 @@ mod tests { .path(blinded_path()) .metadata(vec![42; 32]) .unwrap() - .build() - .unwrap(); + .build(); if let Err(e) = StaticInvoiceBuilder::for_offer_using_derived_keys( &offer, payment_paths(), @@ -1196,8 +1188,7 @@ mod tests { .path(blinded_path()) .chain(Network::Bitcoin) .chain(Network::Testnet) - .build() - .unwrap(); + .build(); if let Err(e) = StaticInvoiceBuilder::for_offer_using_derived_keys( &offer_with_extra_chain, @@ -1226,8 +1217,7 @@ mod tests { let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) - .build() - .unwrap(); + .build(); const TEST_RELATIVE_EXPIRY: u32 = 3600; let invoice = StaticInvoiceBuilder::for_offer_using_derived_keys( @@ -1268,8 +1258,7 @@ mod tests { let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) - .build() - .unwrap(); + .build(); let invoice = StaticInvoiceBuilder::for_offer_using_derived_keys( &offer, @@ -1405,8 +1394,7 @@ mod tests { let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) - .build() - .unwrap(); + .build(); const UNKNOWN_ODD_TYPE: u64 = INVOICE_TYPES.end - 1; assert!(UNKNOWN_ODD_TYPE % 2 == 1); @@ -1499,8 +1487,7 @@ mod tests { let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) - .build() - .unwrap(); + .build(); let invoice = StaticInvoiceBuilder::for_offer_using_derived_keys( &offer, @@ -1605,8 +1592,7 @@ mod tests { let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) - .build() - .unwrap(); + .build(); let invoice = StaticInvoiceBuilder::for_offer_using_derived_keys( &offer, @@ -1699,8 +1685,7 @@ mod tests { let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) - .build() - .unwrap(); + .build(); let offer_id = offer.id(); diff --git a/lightning/src/offers/test_utils.rs b/lightning/src/offers/test_utils.rs index d0decdf2c38..23ec5c81b9c 100644 --- a/lightning/src/offers/test_utils.rs +++ b/lightning/src/offers/test_utils.rs @@ -149,8 +149,7 @@ pub fn dummy_static_invoice() -> StaticInvoice { let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) .path(blinded_path()) - .build() - .unwrap(); + .build(); StaticInvoiceBuilder::for_offer_using_derived_keys( &offer, From 7b67660e761bb8c74fa5641906e66d01b79d9c6c Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 28 Feb 2026 18:39:14 +0530 Subject: [PATCH 07/10] [feat] Defer currency amount checks in InvoiceRequest Keep currency-dependent amount validation out of invoice request construction. Requests that omit amount_msats now keep the amount omitted on the wire. Request construction and parsing still enforce every amount check that is knowable locally, including missing-amount, quantity, bitcoin-denominated minimums, and max-value checks, while deferring minimum validation that requires currency conversion. This also drops the extra CurrencyConversion plumbing and related API changes from this commit so it stays focused on the request-side behavior change. Follow-up commits resolve and verify deferred amounts when handling invoices. Co-Authored-By: OpenAI Codex --- fuzz/src/offer_deser.rs | 7 +- lightning/src/ln/async_payments_tests.rs | 3 + lightning/src/ln/channelmanager.rs | 24 +++-- lightning/src/ln/offers_tests.rs | 53 +++++++++-- lightning/src/ln/outbound_payment.rs | 12 ++- lightning/src/offers/flow.rs | 1 + lightning/src/offers/invoice.rs | 39 ++++++-- lightning/src/offers/invoice_request.rs | 115 ++++++++++++++++------- lightning/src/offers/merkle.rs | 7 +- lightning/src/offers/offer.rs | 16 +++- lightning/src/offers/refund.rs | 7 +- 11 files changed, 205 insertions(+), 79 deletions(-) diff --git a/fuzz/src/offer_deser.rs b/fuzz/src/offer_deser.rs index 68902ab3150..67ac1c4b138 100644 --- a/fuzz/src/offer_deser.rs +++ b/fuzz/src/offer_deser.rs @@ -12,9 +12,10 @@ 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}; +use lightning::offers::offer::{Offer, Quantity}; use lightning::offers::parse::Bolt12SemanticError; use lightning::sign::EntropySource; use lightning::util::ser::Writeable; @@ -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.resolve_msats(&conversion)?.amount_msats())?, }; builder = match offer.supported_quantity() { diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 8ca3b747ef4..728c175c4d8 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; @@ -1455,6 +1456,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, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 43dc0eda2dc..dd2d8a82df5 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8552,17 +8552,15 @@ impl< }); let verified_invreq = match verify_opt { Some(verified_invreq) => { - match verified_invreq.payable_amount_msats() { - Ok(invreq_amt_msat) => { - if payment_data.total_msat < invreq_amt_msat { - fail_htlc!(claimable_htlc, payment_hash); - } - }, - Err(Bolt12SemanticError::UnsupportedCurrency) - | Err(Bolt12SemanticError::MissingAmount) => {}, - Err(_) => { + if let Some(invreq_amt_msat) = + verified_invreq.amount_msats() + { + // Only explicit payer-provided amounts act as a lower + // bound here. Omitted amounts are resolved into the + // invoice amount when the payee creates the invoice. + if payment_data.total_msat < invreq_amt_msat { fail_htlc!(claimable_htlc, payment_hash); - }, + } } verified_invreq }, @@ -14713,18 +14711,18 @@ impl< let entropy = &self.entropy_source; let nonce = Nonce::from_entropy_source(entropy); - let builder = self.flow.create_invoice_request_builder( - offer, nonce, payment_id, - )?; + let builder = self.flow.create_invoice_request_builder(offer, nonce, payment_id)?; let builder = match quantity { 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 50d668323ab..f176443c800 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -59,6 +59,7 @@ use crate::ln::outbound_payment::IDEMPOTENCY_TIMEOUT_TICKS; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; +use crate::offers::currency::DefaultCurrencyConversion; use crate::offers::nonce::Nonce; use crate::offers::parse::Bolt12SemanticError; use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH}; @@ -517,12 +518,16 @@ fn check_dummy_hop_pattern_in_offer() { } let payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&compact_offer, None, payment_id, Default::default()).unwrap(); 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.payable_amount_msats(), Ok(10_000_000)); + assert_eq!( + invoice_request.payable_amount_msats(), + 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 +549,10 @@ 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.payable_amount_msats(), Ok(10_000_000)); + assert_eq!( + invoice_request.payable_amount_msats(), + 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 +714,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { } let payment_id = PaymentId([1; 32]); + david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); @@ -729,7 +738,10 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { human_readable_name: None, }, }); - assert_eq!(invoice_request.payable_amount_msats(), Ok(10_000_000)); + assert_eq!( + invoice_request.payable_amount_msats(), + 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)); @@ -887,7 +899,10 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { human_readable_name: None, }, }); - assert_eq!(invoice_request.payable_amount_msats(), Ok(10_000_000)); + assert_eq!( + invoice_request.payable_amount_msats(), + 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)); @@ -1276,7 +1291,10 @@ fn creates_and_pays_for_offer_with_retry() { human_readable_name: None, }, }); - assert_eq!(invoice_request.payable_amount_msats(), Ok(10_000_000)); + assert_eq!( + invoice_request.payable_amount_msats(), + 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 +1594,7 @@ fn fails_authentication_when_handling_invoice_request() { // Send the invoice request directly to Alice instead of using a blinded path. let payment_id = PaymentId([1; 32]); + david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); @@ -1590,7 +1609,10 @@ 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.payable_amount_msats(), Ok(10_000_000)); + assert_eq!( + invoice_request.payable_amount_msats(), + 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 +1641,10 @@ 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.payable_amount_msats(), Ok(10_000_000)); + assert_eq!( + invoice_request.payable_amount_msats(), + 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 +1718,7 @@ fn fails_authentication_when_handling_invoice_for_offer() { }; let payment_id = PaymentId([2; 32]); + david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); expect_recent_payment!(david, RecentPaymentDetails::AwaitingInvoice, payment_id); @@ -1719,7 +1745,10 @@ 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.payable_amount_msats(), Ok(10_000_000)); + assert_eq!( + invoice_request.payable_amount_msats(), + 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 +2002,7 @@ fn fails_creating_invoice_request_for_unsupported_chain() { .create_offer_builder().unwrap() .clear_chains() .chain(Network::Signet) + .amount_msats(1_000).unwrap() .build(); match bob.node.pay_for_offer(&offer, None, PaymentId([1; 32]), Default::default()) { @@ -2389,7 +2419,12 @@ 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(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 bf9132c665e..57b95e8a6cb 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -24,7 +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::invoice::{Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder}; +use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_request::InvoiceRequest; use crate::offers::nonce::Nonce; use crate::offers::static_invoice::StaticInvoice; @@ -1285,9 +1285,7 @@ impl OutboundPayments { )); } - let amount_msat = match InvoiceBuilder::::amount_msats( - invreq, - ) { + let amount_msat = match invreq.payable_amount_msats() { Ok(amt) => amt, Err(_) => { // We check this during invoice request parsing, when constructing the invreq's @@ -2882,7 +2880,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; @@ -3245,6 +3243,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!( @@ -3302,6 +3301,7 @@ 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()) @@ -3367,6 +3367,7 @@ 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()) @@ -3457,6 +3458,7 @@ 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).unwrap() diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index ebbfe02a029..5b0e62b5699 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -833,6 +833,7 @@ impl OffersMessageFlow { 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 f401d6b8594..bff3ed3109a 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1851,6 +1851,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)] @@ -1882,6 +1883,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(); @@ -2153,6 +2155,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); @@ -2230,6 +2233,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), @@ -2352,6 +2356,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); @@ -2406,6 +2411,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2435,6 +2441,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2481,6 +2488,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()); @@ -2539,6 +2547,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(); @@ -2570,6 +2579,7 @@ 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) @@ -2615,6 +2625,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2692,6 +2703,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2736,6 +2748,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2769,6 +2782,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2813,6 +2827,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2855,6 +2870,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2892,6 +2908,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()); @@ -2961,6 +2978,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -3019,6 +3037,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( @@ -3120,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; let invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -3140,12 +3160,11 @@ mod tests { let mut buffer = Vec::new(); invoice.write(&mut buffer).unwrap(); - match Bolt12Invoice::try_from(buffer) { - Ok(_) => panic!("expected error"), - Err(e) => assert_eq!( - e, - Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidAmount) - ), + // When the payer omits `invoice_request.amount_msats`, invoice parsing cannot validate the + // final amount yet. That check is deferred until a later stage where the offer amount can be + // resolved with currency conversion if necessary. + if let Err(e) = Bolt12Invoice::try_from(buffer) { + panic!("error parsing invoice: {:?}", e); } let invoice = OfferBuilder::new(recipient_pubkey()) @@ -3212,6 +3231,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 buffer = Vec::new(); OfferBuilder::new(recipient_pubkey()) @@ -3246,6 +3266,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -3284,6 +3305,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); @@ -3372,6 +3394,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()); @@ -3520,6 +3543,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -3555,6 +3579,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 invoice = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -3600,6 +3625,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).unwrap().build(); @@ -3648,6 +3674,7 @@ 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).unwrap().build(); diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 8d88eb23e23..e7af57ec747 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -223,9 +223,14 @@ 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 - )?; + match $self + .invoice_request + .offer + .check_amount_msats_for_quantity(Some(amount_msats), $self.invoice_request.quantity) + { + Ok(()) | Err(Bolt12SemanticError::UnsupportedCurrency) => (), + Err(err) => return Err(err), + } $self.invoice_request.amount_msats = Some(amount_msats); Ok($return_value) } @@ -280,9 +285,17 @@ 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 - )?; + // Keep the wire request amount omitted whenever the payer leaves it unspecified. We still + // enforce every amount check that can be derived locally, but if the offer amount is + // currency-denominated we defer the remaining minimum calculation until a later stage where + // currency conversion is available. + match $self.invoice_request.offer.check_amount_msats_for_quantity( + $self.invoice_request.amount_msats, + $self.invoice_request.quantity + ) { + Ok(()) | Err(Bolt12SemanticError::UnsupportedCurrency) => (), + Err(err) => return Err(err), + } Ok($self.build_without_checks()) } @@ -805,8 +818,9 @@ 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, - created_at: core::time::Duration + &$self, + payment_paths: Vec, payment_hash: PaymentHash, + created_at: core::time::Duration, ) -> Result<$builder, Bolt12SemanticError> { if $contents.invoice_request_features().requires_unknown_bits() { return Err(Bolt12SemanticError::UnknownRequiredFeatures); @@ -823,8 +837,9 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( #[cfg(test)] #[allow(dead_code)] pub(super) fn respond_with_no_std_using_signing_pubkey( - &$self, payment_paths: Vec, payment_hash: PaymentHash, - created_at: core::time::Duration, signing_pubkey: PublicKey + &$self, + payment_paths: Vec, payment_hash: PaymentHash, + created_at: core::time::Duration, signing_pubkey: PublicKey, ) -> Result<$builder, Bolt12SemanticError> { debug_assert!($contents.contents.inner.offer.issuer_signing_pubkey().is_none()); @@ -952,7 +967,7 @@ impl InvoiceRequest { invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self, - InvoiceWithExplicitSigningPubkeyBuilder + InvoiceWithExplicitSigningPubkeyBuilder<'_> ); invoice_request_verify_method!(self, &Self); @@ -1002,7 +1017,8 @@ 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 + &$self, + payment_paths: Vec, payment_hash: PaymentHash ) -> Result<$builder, Bolt12SemanticError> { let created_at = std::time::SystemTime::now() .duration_since(std::time::SystemTime::UNIX_EPOCH) @@ -1019,7 +1035,8 @@ macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( /// /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice pub fn respond_using_derived_keys_no_std( - &$self, payment_paths: Vec, payment_hash: PaymentHash, + &$self, + payment_paths: Vec, payment_hash: PaymentHash, created_at: core::time::Duration ) -> Result<$builder, Bolt12SemanticError> { if $self.inner.invoice_request_features().requires_unknown_bits() { @@ -1087,7 +1104,7 @@ impl VerifiedInvoiceRequest { invoice_request_respond_with_derived_signing_pubkey_methods!( self, self.inner, - InvoiceWithDerivedSigningPubkeyBuilder + InvoiceWithDerivedSigningPubkeyBuilder<'_> ); } @@ -1106,7 +1123,7 @@ impl VerifiedInvoiceRequest { invoice_request_respond_with_explicit_signing_pubkey_methods!( self, self.inner, - InvoiceWithExplicitSigningPubkeyBuilder + InvoiceWithExplicitSigningPubkeyBuilder<'_> ); } @@ -1160,10 +1177,7 @@ impl InvoiceRequestContents { .checked_mul(self.quantity().unwrap_or(1)) .ok_or(Bolt12SemanticError::InvalidAmount), Some(Amount::Currency { .. }) => Err(Bolt12SemanticError::UnsupportedCurrency), - None => { - debug_assert!(false); - Err(Bolt12SemanticError::MissingAmount) - }, + None => Err(Bolt12SemanticError::MissingAmount), } } @@ -1465,7 +1479,14 @@ impl TryFrom for InvoiceRequestContents { } offer.check_quantity(quantity)?; - offer.check_amount_msats_for_quantity(amount, quantity)?; + + match offer.check_amount_msats_for_quantity(amount, quantity) { + // When the offer amount is currency-denominated we can still parse the request and + // enforce every conversion-independent check above, but the amount minimum itself is + // deferred until a later stage where currency conversion is available. + Ok(()) | Err(Bolt12SemanticError::UnsupportedCurrency) => (), + Err(err) => return Err(err), + } let features = features.unwrap_or_else(InvoiceRequestFeatures::empty); @@ -1596,6 +1617,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 invoice_request = OfferBuilder::new(recipient_pubkey()) @@ -1687,6 +1709,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); @@ -1724,6 +1747,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) @@ -1834,6 +1858,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); @@ -1935,6 +1960,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 invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2081,7 +2107,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 conversion = TestCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2115,7 +2141,7 @@ mod tests { 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() @@ -2129,6 +2155,16 @@ mod tests { Err(Bolt12SemanticError::UnsupportedCurrency) ); assert_eq!(tlv_stream.amount, None); + + let invoice_request = OfferBuilder::new(recipient_pubkey()) + .build() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .build_unchecked_and_sign(); + let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); + assert_eq!(invoice_request.amount_msats(), None); + assert_eq!(invoice_request.payable_amount_msats(), Err(Bolt12SemanticError::MissingAmount)); + assert_eq!(tlv_stream.amount, None); } #[test] @@ -2138,6 +2174,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 invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2174,6 +2211,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(); @@ -2220,7 +2258,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.payable_amount_msats(), Ok(10_000)); assert_eq!(tlv_stream.amount, Some(10_000)); match OfferBuilder::new(recipient_pubkey()) @@ -2252,7 +2290,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.payable_amount_msats(), Ok(2_000)); assert_eq!(tlv_stream.amount, Some(2_000)); match OfferBuilder::new(recipient_pubkey()) @@ -2289,6 +2327,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 invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2325,6 +2364,7 @@ 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) @@ -2349,6 +2389,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 invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2374,6 +2415,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 invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2421,7 +2463,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 conversion = TestCurrencyConversion; let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2499,7 +2541,7 @@ mod tests { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 1000, }, - &supported_conversion, + &conversion, ) .unwrap() .build_unchecked() @@ -2510,14 +2552,9 @@ mod tests { 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()) @@ -2550,6 +2587,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(); @@ -2709,6 +2747,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 unsigned_invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2741,6 +2780,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 unsigned_invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2771,6 +2811,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 unsigned_invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2805,6 +2846,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 buffer = Vec::new(); OfferBuilder::new(recipient_pubkey()) @@ -2834,6 +2876,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 invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -2866,6 +2909,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); @@ -2946,6 +2990,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); @@ -3057,6 +3102,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 invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) @@ -3100,6 +3146,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; diff --git a/lightning/src/offers/merkle.rs b/lightning/src/offers/merkle.rs index 4bf3161123d..2be3d3e9403 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,7 +360,7 @@ 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() @@ -397,6 +397,7 @@ 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) @@ -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(); @@ -459,6 +461,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(); diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 9b7c6bb84a9..ccb24c76b35 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -817,7 +817,8 @@ macro_rules! request_invoice_derived_signing_pubkey { ($self: ident, $offer: exp 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); if let Some(hrn) = $hrn { #[cfg(c_bindings)] { @@ -952,6 +953,12 @@ impl OfferContents { pub(super) fn check_amount_msats_for_quantity( &self, amount_msats: Option, quantity: Option, ) -> Result<(), Bolt12SemanticError> { + if let Some(amount_msats) = amount_msats { + if amount_msats > MAX_VALUE_MSAT { + return Err(Bolt12SemanticError::InvalidAmount); + } + } + let offer_amount_msats = match self.amount { None => 0, Some(Amount::Bitcoin { amount_msats }) => amount_msats, @@ -967,10 +974,6 @@ impl OfferContents { if amount_msats < expected_amount_msats { return Err(Bolt12SemanticError::InsufficientAmount); } - - if amount_msats > MAX_VALUE_MSAT { - return Err(Bolt12SemanticError::InvalidAmount); - } } Ok(()) @@ -1631,6 +1634,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; @@ -1702,6 +1706,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), @@ -2019,6 +2024,7 @@ 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()) 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 5d052d6c8c17895bfa78a08c944dfce989a1a098 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 28 Mar 2026 19:40:50 +0530 Subject: [PATCH 08/10] [test] Add parsing coverage for explicit fiat request amounts Extend invoice request parsing tests for currency-denominated offers to cover explicit serialized msat amounts. Verify that well-formed explicit amounts still parse and out-of-range values are rejected. Co-Authored-By: OpenAI Codex --- lightning/src/offers/invoice_request.rs | 54 +++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index e7af57ec747..c697bbfb05b 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -2557,6 +2557,60 @@ mod tests { panic!("error parsing invoice_request: {:?}", e); } + let invoice_request = OfferBuilder::new(recipient_pubkey()) + .description("foo".to_string()) + .amount( + Amount::Currency { + iso4217_code: CurrencyCode::new(*b"USD").unwrap(), + amount: 1000, + }, + &conversion, + ) + .unwrap() + .build_unchecked() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .amount_msats(1_000_000) + .unwrap() + .build_unchecked_and_sign(); + + let mut buffer = Vec::new(); + invoice_request.write(&mut buffer).unwrap(); + + // Parsing still accepts explicit amounts for currency-denominated offers when the + // serialized msat amount is well-formed. Any conversion-dependent minimum check is + // deferred until a later stage where currency conversion is available. + if let Err(e) = InvoiceRequest::try_from(buffer) { + panic!("error parsing invoice_request with explicit amount: {:?}", e); + } + + let invoice_request = OfferBuilder::new(recipient_pubkey()) + .description("foo".to_string()) + .amount( + Amount::Currency { + iso4217_code: CurrencyCode::new(*b"USD").unwrap(), + amount: 1000, + }, + &conversion, + ) + .unwrap() + .build_unchecked() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .amount_msats_unchecked(MAX_VALUE_MSAT + 1) + .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::InvalidAmount) + ), + } + let invoice_request = OfferBuilder::new(recipient_pubkey()) .amount_msats(1000) .unwrap() From 26be16a5cbf45ffdb92d7bf589dc1068063e5c43 Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 9 Apr 2026 14:06:58 +0530 Subject: [PATCH 09/10] [feat] Quote currency-denominated offer amounts in invoices Use CurrencyConversion when turning an authenticated InvoiceRequest into a BOLT12 invoice and when validating the returned invoice on the payer. This keeps invoice_request.amount_msats omitted on the wire for currency-denominated offers while still letting the payee quote a concrete millisatoshi amount in the invoice. The same payable-amount logic is then reused to validate the authenticated invoice before it is recorded or paid. Payer-side amount validation failures are treated as local terminal errors. Invalid quoted amounts and unsupported local currency conversion now abandon the pending payment immediately instead of surfacing later as an invoice-request timeout, and they do not send InvoiceError back to the payee. Async and static invoice paths resolve offer amounts through the same conversion-aware builder so resolved amounts, payment secrets, and payment handling stay in sync. Co-Authored-By: OpenAI Codex --- fuzz/src/invoice_request_deser.rs | 5 +- lightning/src/ln/async_payments_tests.rs | 3 - lightning/src/ln/channelmanager.rs | 65 +++++-- lightning/src/ln/offers_tests.rs | 24 +-- lightning/src/ln/outbound_payment.rs | 25 ++- lightning/src/offers/flow.rs | 66 +++++-- lightning/src/offers/invoice.rs | 219 ++++++++++++++++------- lightning/src/offers/invoice_request.rs | 166 +++++++++++------ lightning/src/offers/offer.rs | 2 +- 9 files changed, 404 insertions(+), 171 deletions(-) diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index 7519cf5310a..52a76ba57a1 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -153,7 +153,10 @@ fn build_response( .unwrap(); let payment_hash = PaymentHash([42; 32]); - invoice_request.respond_with(vec![payment_path], payment_hash)?.build() + let conversion = FuzzCurrencyConversion; + invoice_request + .respond_with(&conversion, vec![payment_path], payment_hash)? + .build() } pub fn invoice_request_deser_test(data: &[u8], out: Out) { diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 728c175c4d8..8ca3b747ef4 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -60,7 +60,6 @@ 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; @@ -1456,8 +1455,6 @@ 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, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index dd2d8a82df5..9f7f965431d 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -5751,10 +5751,20 @@ impl< pub fn send_payment_for_bolt12_invoice( &self, invoice: &Bolt12Invoice, context: Option<&OffersContext>, ) -> Result<(), Bolt12PaymentError> { - match self.flow.verify_bolt12_invoice(invoice, context) { - Ok(payment_id) => self.send_payment_for_verified_bolt12_invoice(invoice, payment_id), - Err(()) => Err(Bolt12PaymentError::UnexpectedInvoice), + let payment_id = match self.flow.verify_bolt12_invoice(invoice, context) { + Ok(payment_id) => payment_id, + Err(()) => return Err(Bolt12PaymentError::UnexpectedInvoice), + }; + + match self.check_bolt12_invoice_amount(invoice) { + Ok(()) => {}, + Err(e) => { + self.abandon_payment_with_reason(payment_id, PaymentFailureReason::UnexpectedError); + return Err(e); + }, } + + self.send_payment_for_verified_bolt12_invoice(invoice, payment_id) } fn send_payment_for_verified_bolt12_invoice( @@ -5781,6 +5791,24 @@ impl< ) } + fn check_bolt12_invoice_amount( + &self, invoice: &Bolt12Invoice, + ) -> Result<(), Bolt12PaymentError> { + let requested_amount = + invoice.payable_amount(&self.currency_conversion).map_err(|e| match e { + Bolt12SemanticError::UnsupportedCurrency => Bolt12PaymentError::UnsupportedCurrency, + _ => Bolt12PaymentError::InvalidAmount, + })?; + // A returned invoice quotes the amount the payee expects to receive. Make + // sure it matches the payer's locally expected amount before recording the + // invoice as received or initiating payment. + if !requested_amount.contains(invoice.amount_msats()) { + return Err(Bolt12PaymentError::InvalidAmount); + } + + Ok(()) + } + fn check_refresh_async_receive_offer_cache(&self, timer_tick_occurred: bool) { let peers = self.get_peers_for_blinded_path(); let channels = self.list_usable_channels(); @@ -5789,6 +5817,7 @@ impl< peers, channels, router, + &self.currency_conversion, timer_tick_occurred, ); match refresh_res { @@ -16909,12 +16938,12 @@ impl< { #[rustfmt::skip] fn handle_message( - &self, message: OffersMessage, context: Option, responder: Option, - ) -> Option<(OffersMessage, ResponseInstruction)> { - macro_rules! handle_pay_invoice_res { - ($res: expr, $invoice: expr, $logger: expr) => {{ - let error = match $res { - Err(Bolt12PaymentError::UnknownRequiredFeatures) => { + &self, message: OffersMessage, context: Option, responder: Option, + ) -> Option<(OffersMessage, ResponseInstruction)> { + macro_rules! handle_pay_invoice_res { + ($res: expr, $invoice: expr, $payment_id: expr, $logger: expr) => {{ + let error = match $res { + Err(Bolt12PaymentError::UnknownRequiredFeatures) => { log_trace!( $logger, "Invoice requires unknown features: {:?}", $invoice.invoice_features() @@ -16930,6 +16959,13 @@ impl< log_trace!($logger, "{}", err_msg); InvoiceError::from_string(err_msg.to_string()) }, + Err(Bolt12PaymentError::InvalidAmount) + | Err(Bolt12PaymentError::UnsupportedCurrency) => { + self.abandon_payment_with_reason( + $payment_id, PaymentFailureReason::UnexpectedError + ); + return None; + }, Err(Bolt12PaymentError::UnexpectedInvoice) | Err(Bolt12PaymentError::DuplicateInvoice) | Ok(()) => return None, @@ -16978,6 +17014,7 @@ impl< &self.router, &request, self.list_usable_channels(), + &self.currency_conversion, get_payment_info, ); @@ -17002,6 +17039,7 @@ impl< &self.router, &request, self.list_usable_channels(), + &self.currency_conversion, get_payment_info, ); @@ -17050,6 +17088,10 @@ impl< &self.logger, None, None, Some(invoice.payment_hash()), payment_id, ); + if let Err(e) = self.check_bolt12_invoice_amount(&invoice) { + handle_pay_invoice_res!(Err(e), invoice, payment_id, logger); + } + if self.config.read().unwrap().manually_handle_bolt12_invoices { // Update the corresponding entry in `PendingOutboundPayment` for this invoice. // This ensures that event generation remains idempotent in case we receive @@ -17064,7 +17106,7 @@ impl< } let res = self.send_payment_for_verified_bolt12_invoice(&invoice, payment_id); - handle_pay_invoice_res!(res, invoice, logger); + handle_pay_invoice_res!(res, invoice, payment_id, logger); }, OffersMessage::StaticInvoice(invoice) => { let payment_id = match context { @@ -17072,7 +17114,7 @@ impl< _ => return None }; let res = self.initiate_async_payment(&invoice, payment_id); - handle_pay_invoice_res!(res, invoice, self.logger); + handle_pay_invoice_res!(res, invoice, payment_id, self.logger); }, OffersMessage::InvoiceError(invoice_error) => { let payment_hash = match context { @@ -17144,6 +17186,7 @@ impl< self.list_usable_channels(), &self.entropy_source, &self.router, + &self.currency_conversion, ) { Some((msg, ctx)) => (msg, ctx), None => return None, diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index f176443c800..9b4443f168d 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -56,10 +56,10 @@ use crate::types::features::Bolt12InvoiceFeatures; use crate::ln::functional_test_utils::*; use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnouncement, OnionMessage, OnionMessageHandler, RoutingMessageHandler, SocketAddress, UnsignedGossipMessage, UnsignedNodeAnnouncement}; use crate::ln::outbound_payment::IDEMPOTENCY_TIMEOUT_TICKS; +use crate::offers::currency::DefaultCurrencyConversion; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; -use crate::offers::currency::DefaultCurrencyConversion; use crate::offers::nonce::Nonce; use crate::offers::parse::Bolt12SemanticError; use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH}; @@ -68,6 +68,7 @@ use crate::routing::gossip::{NodeAlias, NodeId}; use crate::routing::router::{DEFAULT_PAYMENT_DUMMY_HOPS, PaymentParameters, RouteParameters, RouteParametersConfig}; use crate::sign::{NodeSigner, Recipient}; use crate::util::ser::Writeable; +use crate::util::test_utils::TestCurrencyConversion; /// This used to determine whether we built a compact path or not, but now its just a random /// constant we apply to blinded path expiry in these tests. @@ -525,7 +526,7 @@ fn check_dummy_hop_pattern_in_offer() { let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); assert_eq!( - invoice_request.payable_amount_msats(), + invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()), Ok(10_000_000) ); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); @@ -550,7 +551,7 @@ fn check_dummy_hop_pattern_in_offer() { let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); assert_eq!( - invoice_request.payable_amount_msats(), + invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()), Ok(10_000_000) ); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); @@ -739,7 +740,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { }, }); assert_eq!( - invoice_request.payable_amount_msats(), + invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()), Ok(10_000_000) ); assert_ne!(invoice_request.payer_signing_pubkey(), david_id); @@ -900,7 +901,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { }, }); assert_eq!( - invoice_request.payable_amount_msats(), + invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()), Ok(10_000_000) ); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); @@ -1292,7 +1293,7 @@ fn creates_and_pays_for_offer_with_retry() { }, }); assert_eq!( - invoice_request.payable_amount_msats(), + invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()), Ok(10_000_000) ); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); @@ -1610,7 +1611,7 @@ fn fails_authentication_when_handling_invoice_request() { let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); assert_eq!( - invoice_request.payable_amount_msats(), + invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()), Ok(10_000_000) ); assert_ne!(invoice_request.payer_signing_pubkey(), david_id); @@ -1642,7 +1643,7 @@ fn fails_authentication_when_handling_invoice_request() { let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); assert_eq!( - invoice_request.payable_amount_msats(), + invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()), Ok(10_000_000) ); assert_ne!(invoice_request.payer_signing_pubkey(), david_id); @@ -1746,7 +1747,7 @@ fn fails_authentication_when_handling_invoice_for_offer() { let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); assert_eq!( - invoice_request.payable_amount_msats(), + invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()), Ok(10_000_000) ); assert_ne!(invoice_request.payer_signing_pubkey(), david_id); @@ -2385,6 +2386,7 @@ fn fails_paying_invoice_with_unknown_required_features() { .build(); 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); @@ -2420,7 +2422,9 @@ 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, + .respond_using_derived_keys_no_std( + &conversion, + payment_paths, payment_hash, created_at, ) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 57b95e8a6cb..aa1ca2997af 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::DefaultCurrencyConversion; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_request::InvoiceRequest; use crate::offers::nonce::Nonce; @@ -655,6 +656,11 @@ pub enum Bolt12PaymentError { UnexpectedInvoice, /// Payment for an invoice with the corresponding [`PaymentId`] was already initiated. DuplicateInvoice, + /// The invoice was valid for the corresponding [`PaymentId`], but quoted an invalid amount. + InvalidAmount, + /// The invoice was valid for the corresponding [`PaymentId`], but the payer could not validate + /// its amount because local currency conversion was unavailable. + UnsupportedCurrency, /// The invoice was valid for the corresponding [`PaymentId`], but required unknown features. UnknownRequiredFeatures, /// The invoice was valid for the corresponding [`PaymentId`], but sending the payment failed. @@ -1104,7 +1110,6 @@ impl OutboundPayments { IH: Fn() -> InFlightHtlcs, SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, { - let (payment_hash, retry_strategy, params_config, _) = self .mark_invoice_received_and_get_details(invoice, payment_id)?; @@ -1285,7 +1290,10 @@ impl OutboundPayments { )); } - let amount_msat = match invreq.payable_amount_msats() { + let amount_msat = match invreq + .payable_amount(&DefaultCurrencyConversion) + .map(|amount| amount.amount_msats()) + { Ok(amt) => amt, Err(_) => { // We check this during invoice request parsing, when constructing the invreq's @@ -2092,9 +2100,12 @@ impl OutboundPayments { // event generation remains idempotent, even if the same invoice is received again before the // event is handled by the user. PendingOutboundPayment::InvoiceReceived { - retry_strategy, route_params_config, .. + payment_hash, retry_strategy, route_params_config, } => { - Ok((invoice.payment_hash(), *retry_strategy, *route_params_config, false)) + if *payment_hash != invoice.payment_hash() { + return Err(Bolt12PaymentError::DuplicateInvoice); + } + Ok((*payment_hash, *retry_strategy, *route_params_config, false)) }, _ => Err(Bolt12PaymentError::DuplicateInvoice), }, @@ -3259,7 +3270,7 @@ mod tests { .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), created_at).unwrap() + .respond_with_no_std(&TestCurrencyConversion, payment_paths(), payment_hash(), created_at).unwrap() .build().unwrap() .sign(recipient_sign).unwrap(); @@ -3309,7 +3320,7 @@ mod tests { .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()).unwrap() .build().unwrap() .sign(recipient_sign).unwrap(); @@ -3375,7 +3386,7 @@ mod tests { .build() .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap() .build_and_sign().unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap() + .respond_with_no_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 5b0e62b5699..b0fbe637bcf 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -33,6 +33,7 @@ use crate::ln::channel_state::ChannelDetails; use crate::ln::channelmanager::{InterceptId, PaymentId, CLTV_FAR_FAR_AWAY}; use crate::ln::inbound_payment; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; +use crate::offers::currency::CurrencyConversion; use crate::offers::invoice::{ Bolt12Invoice, DerivedSigningPubkey, ExplicitSigningPubkey, InvoiceBuilder, DEFAULT_RELATIVE_EXPIRY, @@ -978,17 +979,24 @@ impl OffersMessageFlow { /// Returns a [`Bolt12SemanticError`] if: /// - Valid blinded payment paths could not be generated for the [`Bolt12Invoice`]. /// - The [`InvoiceBuilder`] could not be created from the [`InvoiceRequest`]. - pub fn create_invoice_builder_from_invoice_request_with_keys<'a, R: Router, F>( + pub fn create_invoice_builder_from_invoice_request_with_keys< + 'a, + R: Router, + CC: CurrencyConversion, + F, + >( &self, router: &R, invoice_request: &'a VerifiedInvoiceRequest, - usable_channels: Vec, get_payment_info: F, + usable_channels: Vec, currency_conversion: &CC, get_payment_info: F, ) -> Result<(InvoiceBuilder<'a, DerivedSigningPubkey>, MessageContext), Bolt12SemanticError> where F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, { let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; - let amount_msats = - InvoiceBuilder::::amount_msats(&invoice_request.inner)?; + let amount_msats = InvoiceBuilder::::amount_msats( + &invoice_request.inner, + currency_conversion, + )?; let (payment_hash, payment_secret) = get_payment_info(amount_msats, relative_expiry)?; @@ -1009,9 +1017,14 @@ impl OffersMessageFlow { .map_err(|_| Bolt12SemanticError::MissingPaths)?; #[cfg(all(feature = "std", not(fuzzing)))] - let builder = invoice_request.respond_using_derived_keys(payment_paths, payment_hash); + let builder = invoice_request.respond_using_derived_keys( + currency_conversion, + payment_paths, + payment_hash, + ); #[cfg(any(not(feature = "std"), fuzzing))] let builder = invoice_request.respond_using_derived_keys_no_std( + currency_conversion, payment_paths, payment_hash, Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), @@ -1037,17 +1050,24 @@ impl OffersMessageFlow { /// Returns a [`Bolt12SemanticError`] if: /// - Valid blinded payment paths could not be generated for the [`Bolt12Invoice`]. /// - The [`InvoiceBuilder`] could not be created from the [`InvoiceRequest`]. - pub fn create_invoice_builder_from_invoice_request_without_keys<'a, R: Router, F>( + pub fn create_invoice_builder_from_invoice_request_without_keys< + 'a, + R: Router, + CC: CurrencyConversion, + F, + >( &self, router: &R, invoice_request: &'a VerifiedInvoiceRequest, - usable_channels: Vec, get_payment_info: F, + usable_channels: Vec, currency_conversion: &CC, get_payment_info: F, ) -> Result<(InvoiceBuilder<'a, ExplicitSigningPubkey>, MessageContext), Bolt12SemanticError> where F: Fn(u64, u32) -> Result<(PaymentHash, PaymentSecret), Bolt12SemanticError>, { let relative_expiry = DEFAULT_RELATIVE_EXPIRY.as_secs() as u32; - let amount_msats = - InvoiceBuilder::::amount_msats(&invoice_request.inner)?; + let amount_msats = InvoiceBuilder::::amount_msats( + &invoice_request.inner, + currency_conversion, + )?; let (payment_hash, payment_secret) = get_payment_info(amount_msats, relative_expiry)?; @@ -1068,9 +1088,10 @@ impl OffersMessageFlow { .map_err(|_| Bolt12SemanticError::MissingPaths)?; #[cfg(all(feature = "std", not(fuzzing)))] - let builder = invoice_request.respond_with(payment_paths, payment_hash); + let builder = invoice_request.respond_with(currency_conversion, payment_paths, payment_hash); #[cfg(any(not(feature = "std"), fuzzing))] let builder = invoice_request.respond_with_no_std( + currency_conversion, payment_paths, payment_hash, Duration::from_secs(self.highest_seen_timestamp.load(Ordering::Acquire) as u64), @@ -1388,9 +1409,9 @@ impl OffersMessageFlow { /// the cache can self-regulate the number of messages sent out. /// /// Errors if we failed to create blinded reply paths when sending an [`OfferPathsRequest`] message. - pub fn check_refresh_async_receive_offer_cache( + pub fn check_refresh_async_receive_offer_cache( &self, peers: Vec, usable_channels: Vec, router: R, - timer_tick_occurred: bool, + currency_conversion: &CC, timer_tick_occurred: bool, ) -> Result<(), ()> { // Terminate early if this node does not intend to receive async payments. { @@ -1403,7 +1424,7 @@ impl OffersMessageFlow { self.check_refresh_async_offers(peers.clone(), timer_tick_occurred)?; if timer_tick_occurred { - self.check_refresh_static_invoices(peers, usable_channels, router); + self.check_refresh_static_invoices(peers, usable_channels, router, currency_conversion); } Ok(()) @@ -1460,8 +1481,9 @@ impl OffersMessageFlow { /// Enqueue onion messages that will used to request invoice refresh from the static invoice /// server, based on the offers provided by the cache. - fn check_refresh_static_invoices( + fn check_refresh_static_invoices( &self, peers: Vec, usable_channels: Vec, router: R, + currency_conversion: &CC, ) { let mut serve_static_invoice_msgs = Vec::new(); { @@ -1476,6 +1498,7 @@ impl OffersMessageFlow { peers.clone(), usable_channels.clone(), &router, + currency_conversion, ) { Ok((invoice, path)) => (invoice, path), Err(()) => continue, @@ -1583,10 +1606,10 @@ impl OffersMessageFlow { /// /// Returns `None` if we have enough offers cached already, verification of `message` fails, or we /// fail to create blinded paths. - pub fn handle_offer_paths( + pub fn handle_offer_paths( &self, message: OfferPaths, context: AsyncPaymentsContext, responder: Responder, peers: Vec, usable_channels: Vec, entropy: ES, - router: R, + router: R, currency_conversion: &CC, ) -> Option<(ServeStaticInvoice, MessageContext)> { let duration_since_epoch = self.duration_since_epoch(); let invoice_slot = match context { @@ -1631,6 +1654,7 @@ impl OffersMessageFlow { peers, usable_channels, router, + currency_conversion, ) { Ok(res) => res, Err(()) => { @@ -1664,9 +1688,9 @@ impl OffersMessageFlow { /// Creates a [`StaticInvoice`] and a blinded path for the server to forward invoice requests from /// payers to our node. - fn create_static_invoice_for_server( + fn create_static_invoice_for_server( &self, offer: &Offer, offer_nonce: Nonce, peers: Vec, - usable_channels: Vec, router: R, + usable_channels: Vec, router: R, currency_conversion: &CC, ) -> Result<(StaticInvoice, BlindedMessagePath), ()> { let expanded_key = &self.inbound_payment_key; let duration_since_epoch = self.duration_since_epoch(); @@ -1681,9 +1705,13 @@ impl OffersMessageFlow { // Set the invoice to expire at the same time as the offer. We aim to update this invoice as // often as possible, so there shouldn't be any reason to have it expire earlier than the // offer. + let amount_msat = offer + .resolve_offer_amount(currency_conversion) + .map(|amount| amount.map(|range| range.amount_msats())) + .map_err(|_| ())?; let payment_secret = inbound_payment::create_for_spontaneous_payment( expanded_key, - None, // The async receive offers we create are always amount-less + amount_msat, offer_relative_expiry, duration_since_epoch.as_secs(), None, diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index bff3ed3109a..35c5717a51a 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -23,6 +23,7 @@ //! use bitcoin::hashes::Hash; //! use bitcoin::secp256k1::{Keypair, PublicKey, Secp256k1, SecretKey}; //! use core::convert::TryFrom; +//! use lightning::offers::currency::DefaultCurrencyConversion; //! use lightning::offers::invoice::UnsignedBolt12Invoice; //! use lightning::offers::invoice_request::InvoiceRequest; //! use lightning::offers::refund::Refund; @@ -36,6 +37,7 @@ //! # fn create_payment_hash() -> 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,7 +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::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}; @@ -135,8 +138,8 @@ use crate::offers::merkle::{ }; use crate::offers::nonce::Nonce; use crate::offers::offer::{ - Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OfferId, OfferTlvStream, - OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, + Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, MsatsRange, OfferId, + OfferTlvStream, OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; use crate::offers::payer::{PayerTlvStream, PayerTlvStreamRef, PAYER_METADATA_TYPE}; @@ -241,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( @@ -313,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(), @@ -391,12 +396,41 @@ macro_rules! invoice_derived_signing_pubkey_builder_methods { 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, + $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, currency_conversion: &CC, ) -> Result { - invoice_request.payable_amount_msats() + let requested_msats = match invoice_request.amount_msats() { + Some(explicit_requested_msats) => { + // Explicit payer-provided amounts must still satisfy the offer-implied + // minimum for the requested quantity. + let quantity = invoice_request.quantity().unwrap_or(1); + let minimum_offer_msats = invoice_request + .resolve_offer_amount(currency_conversion)? + .map(|unit_msats| { + unit_msats.checked_mul(quantity).map(|amount| amount.minimum_msats()) + }) + .transpose()?; + + if let Some(minimum) = minimum_offer_msats { + if explicit_requested_msats < minimum { + return Err(Bolt12SemanticError::InsufficientAmount); + } + } + + explicit_requested_msats + }, + // Omitted amounts are resolved by the request helper that derives the + // payable amount from the underlying offer. + None => invoice_request.payable_amount(currency_conversion)?.amount_msats(), + }; + + if requested_msats > MAX_VALUE_MSAT { + return Err(Bolt12SemanticError::InvalidAmount); + } + + Ok(requested_msats) } #[cfg_attr(c_bindings, allow(dead_code))] @@ -632,14 +666,12 @@ impl UnsignedBolt12Invoice { record.write(&mut bytes).unwrap(); } - let remaining_bytes = &invreq_bytes[bytes.len()..]; - invoice_tlv_stream.write(&mut bytes).unwrap(); const EXPERIMENTAL_TLV_ALLOCATION_SIZE: usize = 0; let mut experimental_bytes = Vec::with_capacity(EXPERIMENTAL_TLV_ALLOCATION_SIZE); - let experimental_tlv_stream = TlvStream::new(remaining_bytes).range(EXPERIMENTAL_TYPES); + let experimental_tlv_stream = TlvStream::new(invreq_bytes).range(EXPERIMENTAL_TYPES); for record in experimental_tlv_stream { record.write(&mut experimental_bytes).unwrap(); } @@ -936,6 +968,7 @@ macro_rules! invoice_accessors { ($self: ident, $contents: expr) => { pub fn amount_msats(&$self) -> u64 { $contents.amount_msats() } + } } macro_rules! invoice_accessors_signing_pubkey { @@ -970,6 +1003,21 @@ impl Bolt12Invoice { invoice_accessors_signing_pubkey!(self, self.contents, Bolt12Invoice); invoice_accessors!(self, self.contents); + /// Returns the locally payable amount range implied by the authenticated + /// offer or refund data embedded in this invoice. + pub(crate) fn payable_amount( + &self, currency_conversion: &CC, + ) -> Result { + match &self.contents { + InvoiceContents::ForOffer { invoice_request, .. } => { + invoice_request.payable_amount(currency_conversion) + }, + InvoiceContents::ForRefund { refund, .. } => { + Ok(MsatsRange { amount_msats: refund.amount_msats(), tolerance: 0 }) + }, + } + } + /// Signature of the invoice verified using [`Bolt12Invoice::signing_pubkey`]. pub fn signature(&self) -> Signature { self.signature @@ -1764,6 +1812,26 @@ impl TryFrom for InvoiceContents { if amount_msats != requested_amount_msats { return Err(Bolt12SemanticError::InvalidAmount); } + } else { + // If the invoice request omits `amount_msats`, parse-time validation can only + // recompute the expected amount for offers whose amount is already in msats. + // We therefore use `DefaultCurrencyConversion` here and deliberately tolerate + // `UnsupportedCurrency` so fiat-denominated offers can still parse. Those + // omitted-amount fiat invoices are validated later in the payment flow, where + // a real `CurrencyConversion` is available. + match invoice_request.payable_amount(&DefaultCurrencyConversion) { + Ok(requested_amount) => { + if amount_msats != requested_amount.amount_msats() { + return Err(Bolt12SemanticError::InvalidAmount); + } + }, + Err(Bolt12SemanticError::UnsupportedCurrency) => (), + Err(e) => return Err(e), + } + } + + if amount_msats > MAX_VALUE_MSAT { + return Err(Bolt12SemanticError::InvalidAmount); } Ok(InvoiceContents::ForOffer { invoice_request, fields }) @@ -1835,6 +1903,7 @@ mod tests { use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::DecodeError; + use crate::offers::currency::DefaultCurrencyConversion; use crate::offers::invoice_request::{ ExperimentalInvoiceRequestTlvStreamRef, InvoiceRequestTlvStreamRef, InvoiceRequestVerifiedFromOffer, @@ -1897,7 +1966,12 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths.clone(), payment_hash, now) + .respond_with_no_std( + &DefaultCurrencyConversion, + payment_paths.clone(), + payment_hash, + now, + ) .unwrap() .build() .unwrap(); @@ -2169,7 +2243,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with(payment_paths(), payment_hash()) + .respond_with(&DefaultCurrencyConversion, payment_paths(), payment_hash()) .unwrap() .build() { @@ -2184,7 +2258,7 @@ mod tests { .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) .unwrap() .build_unchecked_and_sign() - .respond_with(payment_paths(), payment_hash()) + .respond_with(&DefaultCurrencyConversion, payment_paths(), payment_hash()) .unwrap() .build() { @@ -2266,7 +2340,12 @@ mod tests { match verified_request { InvoiceRequestVerifiedFromOffer::DerivedKeys(req) => { let invoice = req - .respond_using_derived_keys_no_std(payment_paths(), payment_hash(), now()) + .respond_using_derived_keys_no_std( + &DefaultCurrencyConversion, + payment_paths(), + payment_hash(), + now(), + ) .unwrap() .build_and_sign(&secp_ctx); @@ -2369,7 +2448,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now) .unwrap() .relative_expiry(one_hour.as_secs() as u32) .build() @@ -2390,7 +2469,12 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now - one_hour) + .respond_with_no_std( + &DefaultCurrencyConversion, + payment_paths(), + payment_hash(), + now - one_hour, + ) .unwrap() .relative_expiry(one_hour.as_secs() as u32 - 1) .build() @@ -2423,7 +2507,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2454,7 +2538,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2474,7 +2558,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(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidAmount), @@ -2503,7 +2587,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .fallback_v0_p2wsh(&script.wscript_hash()) .fallback_v0_p2wpkh(&pubkey.wpubkey_hash().unwrap()) @@ -2560,7 +2644,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .allow_mpp() .build() @@ -2589,7 +2673,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2607,7 +2691,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2635,7 +2719,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2713,7 +2797,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2758,7 +2842,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .relative_expiry(3600) .build() @@ -2792,7 +2876,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2837,7 +2921,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -2880,7 +2964,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .allow_mpp() .build() @@ -2924,11 +3008,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(&DefaultCurrencyConversion, 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(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) + .unwrap(); let invoice_builder = invoice_builder .fallback_v0_p2wsh(&script.wscript_hash()) .fallback_v0_p2wpkh(&pubkey.wpubkey_hash().unwrap()) @@ -2988,7 +3074,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3077,6 +3163,7 @@ mod tests { .build_and_sign() .unwrap() .respond_with_no_std_using_signing_pubkey( + &DefaultCurrencyConversion, payment_paths(), payment_hash(), now(), @@ -3107,6 +3194,7 @@ mod tests { .build_and_sign() .unwrap() .respond_with_no_std_using_signing_pubkey( + &DefaultCurrencyConversion, payment_paths(), payment_hash(), now(), @@ -3149,7 +3237,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .amount_msats_unchecked(2000) .build() @@ -3160,11 +3248,14 @@ mod tests { let mut buffer = Vec::new(); invoice.write(&mut buffer).unwrap(); - // When the payer omits `invoice_request.amount_msats`, invoice parsing cannot validate the - // final amount yet. That check is deferred until a later stage where the offer amount can be - // resolved with currency conversion if necessary. - if let Err(e) = Bolt12Invoice::try_from(buffer) { - panic!("error parsing invoice: {:?}", e); + // When the payer omits `invoice_request.amount_msats`, invoice parsing can still validate + // the final amount if the offer amount is already bitcoin-denominated. + match Bolt12Invoice::try_from(buffer) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidAmount) + ), } let invoice = OfferBuilder::new(recipient_pubkey()) @@ -3177,7 +3268,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .amount_msats_unchecked(2000) .build() @@ -3242,7 +3333,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3276,7 +3367,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3320,7 +3411,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3359,7 +3450,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3406,7 +3497,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .experimental_baz(42) .build() @@ -3432,7 +3523,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3473,7 +3564,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap(); @@ -3511,7 +3602,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3553,7 +3644,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3589,7 +3680,7 @@ mod tests { .unwrap() .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3638,7 +3729,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .build() .unwrap() @@ -3685,7 +3776,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths, payment_hash(), now) + .respond_with_no_std(&DefaultCurrencyConversion, 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 c697bbfb05b..a5f61958ae1 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -78,7 +78,7 @@ use crate::offers::merkle::{ }; use crate::offers::nonce::Nonce; use crate::offers::offer::{ - Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, Offer, OfferContents, + ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, MsatsRange, Offer, OfferContents, OfferId, OfferTlvStream, OfferTlvStreamRef, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; @@ -726,16 +726,13 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { $contents.amount_msats() } - /// The amount payable for the request, in msats. - /// - /// If the invoice request explicitly sets an amount, that amount is returned. - /// Otherwise, the amount is inferred from [`Offer::amount`] and [`quantity`] - /// when the offer amount is already bitcoin-denominated. - /// - /// Returns an error if the payable amount is missing, unsupported, or - /// semantically invalid. - pub fn payable_amount_msats(&$self) -> Result { - $contents.payable_amount_msats() + /// The payable amount for the request, resolved using the provided currency + /// conversion snapshot when the offer amount is currency-denominated. + #[allow(dead_code)] + pub(crate) fn payable_amount( + &$self, currency_conversion: &CC + ) -> Result { + $contents.payable_amount(currency_conversion) } /// Features pertaining to requesting an invoice. @@ -782,14 +779,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 + 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. @@ -817,10 +815,9 @@ 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, - created_at: core::time::Duration, + pub fn respond_with_no_std( + &$self, currency_conversion: &CC, payment_paths: Vec, + payment_hash: PaymentHash, created_at: core::time::Duration, ) -> Result<$builder, Bolt12SemanticError> { if $contents.invoice_request_features().requires_unknown_bits() { return Err(Bolt12SemanticError::UnknownRequiredFeatures); @@ -831,15 +828,17 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( None => return Err(Bolt12SemanticError::MissingIssuerSigningPubkey), }; - <$builder>::for_offer(&$contents, payment_paths, created_at, payment_hash, signing_pubkey) + <$builder>::for_offer( + &$contents, currency_conversion, payment_paths, created_at, payment_hash, signing_pubkey + ) } #[cfg(test)] #[allow(dead_code)] - pub(super) fn respond_with_no_std_using_signing_pubkey( - &$self, - payment_paths: Vec, payment_hash: PaymentHash, - created_at: core::time::Duration, signing_pubkey: PublicKey, + pub(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> { debug_assert!($contents.contents.inner.offer.issuer_signing_pubkey().is_none()); @@ -847,7 +846,9 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( 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 + ) } } } @@ -1016,15 +1017,17 @@ macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( /// /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice #[cfg(feature = "std")] - pub fn respond_using_derived_keys( - &$self, - payment_paths: Vec, payment_hash: PaymentHash + 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 @@ -1034,11 +1037,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, - created_at: core::time::Duration - ) -> Result<$builder, Bolt12SemanticError> { + 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> + { if $self.inner.invoice_request_features().requires_unknown_bits() { return Err(Bolt12SemanticError::UnknownRequiredFeatures); } @@ -1051,7 +1054,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 ) } } } @@ -1167,18 +1170,23 @@ impl InvoiceRequestContents { self.inner.amount_msats() } - pub(super) fn payable_amount_msats(&self) -> Result { + pub(crate) fn resolve_offer_amount( + &self, currency_conversion: &CC, + ) -> Result, Bolt12SemanticError> { + self.inner.offer.resolve_offer_amount(currency_conversion) + } + + pub(crate) fn payable_amount( + &self, currency_conversion: &CC, + ) -> Result { if let Some(amount_msats) = self.inner.amount_msats() { - return Ok(amount_msats); + return Ok(MsatsRange { amount_msats, tolerance: 0 }); } - match self.inner.offer.amount() { - Some(Amount::Bitcoin { amount_msats }) => amount_msats - .checked_mul(self.quantity().unwrap_or(1)) - .ok_or(Bolt12SemanticError::InvalidAmount), - Some(Amount::Currency { .. }) => Err(Bolt12SemanticError::UnsupportedCurrency), - None => Err(Bolt12SemanticError::MissingAmount), - } + let amount = self + .resolve_offer_amount(currency_conversion)? + .ok_or(Bolt12SemanticError::MissingAmount)?; + amount.checked_mul(self.quantity().unwrap_or(1)) } pub(super) fn features(&self) -> &InvoiceRequestFeatures { @@ -1585,6 +1593,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}; @@ -1649,7 +1658,12 @@ mod tests { 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(), None); - assert_eq!(invoice_request.payable_amount_msats(), Ok(1000)); + assert_eq!( + invoice_request + .payable_amount(&DefaultCurrencyConversion) + .map(|amount| amount.amount_msats()), + Ok(1000) + ); assert_eq!(invoice_request.invoice_request_features(), &InvoiceRequestFeatures::empty()); assert_eq!(invoice_request.quantity(), None); assert_eq!(invoice_request.payer_note(), None); @@ -1762,7 +1776,7 @@ mod tests { .unwrap(); let invoice = invoice_request - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) .unwrap() .experimental_baz(42) .build() @@ -1975,7 +1989,12 @@ mod tests { let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert!(invoice_request.amount_msats().is_some()); assert_eq!(invoice_request.amount_msats(), Some(1000)); - assert_eq!(invoice_request.payable_amount_msats(), Ok(1000)); + assert_eq!( + invoice_request + .payable_amount(&DefaultCurrencyConversion) + .map(|amount| amount.amount_msats()), + Ok(1000) + ); assert_eq!(tlv_stream.amount, Some(1000)); let invoice_request = OfferBuilder::new(recipient_pubkey()) @@ -1993,7 +2012,12 @@ mod tests { let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert!(invoice_request.amount_msats().is_some()); assert_eq!(invoice_request.amount_msats(), Some(1000)); - assert_eq!(invoice_request.payable_amount_msats(), Ok(1000)); + assert_eq!( + invoice_request + .payable_amount(&DefaultCurrencyConversion) + .map(|amount| amount.amount_msats()), + Ok(1000) + ); assert_eq!(tlv_stream.amount, Some(1000)); let invoice_request = OfferBuilder::new(recipient_pubkey()) @@ -2009,7 +2033,12 @@ mod tests { let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert!(invoice_request.amount_msats().is_some()); assert_eq!(invoice_request.amount_msats(), Some(1001)); - assert_eq!(invoice_request.payable_amount_msats(), Ok(1001)); + assert_eq!( + invoice_request + .payable_amount(&DefaultCurrencyConversion) + .map(|amount| amount.amount_msats()), + Ok(1001) + ); assert_eq!(tlv_stream.amount, Some(1001)); match OfferBuilder::new(recipient_pubkey()) @@ -2119,7 +2148,12 @@ mod tests { .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert_eq!(invoice_request.amount_msats(), None); - assert_eq!(invoice_request.payable_amount_msats(), Ok(1000)); + assert_eq!( + invoice_request + .payable_amount(&DefaultCurrencyConversion) + .map(|amount| amount.amount_msats()), + Ok(1000) + ); assert_eq!(tlv_stream.amount, None); let invoice_request = OfferBuilder::new(recipient_pubkey()) @@ -2135,7 +2169,12 @@ mod tests { .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert_eq!(invoice_request.amount_msats(), None); - assert_eq!(invoice_request.payable_amount_msats(), Ok(2000)); + assert_eq!( + invoice_request + .payable_amount(&DefaultCurrencyConversion) + .map(|amount| amount.amount_msats()), + Ok(2000) + ); assert_eq!(tlv_stream.amount, None); let invoice_request = OfferBuilder::new(recipient_pubkey()) @@ -2151,7 +2190,9 @@ mod tests { let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert_eq!(invoice_request.amount_msats(), None); assert_eq!( - invoice_request.payable_amount_msats(), + invoice_request + .payable_amount(&DefaultCurrencyConversion) + .map(|amount| amount.amount_msats()), Err(Bolt12SemanticError::UnsupportedCurrency) ); assert_eq!(tlv_stream.amount, None); @@ -2163,7 +2204,12 @@ mod tests { .build_unchecked_and_sign(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); assert_eq!(invoice_request.amount_msats(), None); - assert_eq!(invoice_request.payable_amount_msats(), Err(Bolt12SemanticError::MissingAmount)); + assert_eq!( + invoice_request + .payable_amount(&DefaultCurrencyConversion) + .map(|amount| amount.amount_msats()), + Err(Bolt12SemanticError::MissingAmount) + ); assert_eq!(tlv_stream.amount, None); } @@ -2258,7 +2304,12 @@ mod tests { .build_and_sign() .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); - assert_eq!(invoice_request.payable_amount_msats(), Ok(10_000)); + assert_eq!( + invoice_request + .payable_amount(&DefaultCurrencyConversion) + .map(|amount| amount.amount_msats()), + Ok(10_000) + ); assert_eq!(tlv_stream.amount, Some(10_000)); match OfferBuilder::new(recipient_pubkey()) @@ -2290,7 +2341,12 @@ mod tests { .build_and_sign() .unwrap(); let (_, _, tlv_stream, _, _, _) = invoice_request.as_tlv_stream(); - assert_eq!(invoice_request.payable_amount_msats(), Ok(2_000)); + assert_eq!( + invoice_request + .payable_amount(&DefaultCurrencyConversion) + .map(|amount| amount.amount_msats()), + Ok(2_000) + ); assert_eq!(tlv_stream.amount, Some(2_000)); match OfferBuilder::new(recipient_pubkey()) @@ -2375,7 +2431,7 @@ mod tests { .features_unchecked(InvoiceRequestFeatures::unknown()) .build_and_sign() .unwrap() - .respond_with_no_std(payment_paths(), payment_hash(), now()) + .respond_with_no_std(&DefaultCurrencyConversion, payment_paths(), payment_hash(), now()) { Ok(_) => panic!("expected error"), Err(e) => assert_eq!(e, Bolt12SemanticError::UnknownRequiredFeatures), diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index ccb24c76b35..392bf9fec9b 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -723,7 +723,7 @@ macro_rules! offer_accessors { ($self: ident, $contents: expr) => { /// - `Ok(Some(range))` 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> + pub fn resolve_offer_amount(&$self, currency_conversion: &CC) -> Result, Bolt12SemanticError> { $contents.resolve_offer_amount(currency_conversion) } From 6eef7ce3f72eceadc5a48dd8fbaa2d9740979c7c Mon Sep 17 00:00:00 2001 From: shaavan Date: Thu, 9 Apr 2026 14:07:54 +0530 Subject: [PATCH 10/10] [test] Cover deferred BOLT12 invoice amount validation Add tests for the deferred amount-validation model used by offer-backed BOLT12 invoices. The parsing coverage now exercises currency-denominated offers whose invoice requests omit amount_msats, confirming that the authenticated invoice may quote a concrete msat amount while the request remains omitted on the wire. The functional tests also cover the payer-side failure paths after invoice verification. An authenticated invoice with an out-of-range quoted amount and an authenticated invoice that requires unsupported local currency conversion both fail immediately with PaymentFailed and do not remain tracked as recent pending payments. Co-Authored-By: OpenAI Codex --- lightning/src/ln/offers_tests.rs | 260 ++++++++++++++++++++++++++++++- lightning/src/offers/invoice.rs | 40 ++++- 2 files changed, 298 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 9b4443f168d..bce5bd06d67 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -56,11 +56,12 @@ use crate::types::features::Bolt12InvoiceFeatures; use crate::ln::functional_test_utils::*; use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnouncement, OnionMessage, OnionMessageHandler, RoutingMessageHandler, SocketAddress, UnsignedGossipMessage, UnsignedNodeAnnouncement}; use crate::ln::outbound_payment::IDEMPOTENCY_TIMEOUT_TICKS; -use crate::offers::currency::DefaultCurrencyConversion; +use crate::offers::currency::{CurrencyConversion, DefaultCurrencyConversion}; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; +use crate::offers::offer::{Amount, CurrencyCode}; use crate::offers::parse::Bolt12SemanticError; use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH}; use crate::onion_message::offers::OffersMessage; @@ -94,6 +95,20 @@ macro_rules! expect_recent_payment { }} } +macro_rules! expect_no_recent_payment { + ($node: expr, $payment_id: expr) => {{ + let found_payment = $node.node.list_recent_payments().iter().any(|payment| { + match payment { + RecentPaymentDetails::AwaitingInvoice { payment_id, .. } + | RecentPaymentDetails::Pending { payment_id, .. } + | RecentPaymentDetails::Fulfilled { payment_id, .. } + | RecentPaymentDetails::Abandoned { payment_id, .. } => *payment_id == $payment_id, + } + }); + assert!(!found_payment); + }} +} + fn connect_peers<'a, 'b, 'c>(node_a: &Node<'a, 'b, 'c>, node_b: &Node<'a, 'b, 'c>) { let node_id_a = node_a.node.get_our_node_id(); let node_id_b = node_b.node.get_our_node_id(); @@ -2463,6 +2478,249 @@ fn fails_paying_invoice_with_unknown_required_features() { }, _ => panic!("Expected Event::PaymentFailed with reason"), } + expect_no_recent_payment!(david, payment_id); +} + +#[test] +fn fails_paying_invoice_with_invalid_amount() { + let mut accept_forward_cfg = test_default_channel_config(); + accept_forward_cfg.accept_forwards_to_priv_channels = true; + + let chanmon_cfgs = create_chanmon_cfgs(6); + let node_cfgs = create_node_cfgs(6, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs( + 6, &node_cfgs, &[None, Some(accept_forward_cfg), None, None, None, None] + ); + let nodes = create_network(6, &node_cfgs, &node_chanmgrs); + + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 4, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 5, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 2, 4, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 2, 5, 10_000_000, 1_000_000_000); + + let (alice, bob, charlie, david) = (&nodes[0], &nodes[1], &nodes[2], &nodes[3]); + let alice_id = alice.node.get_our_node_id(); + let bob_id = bob.node.get_our_node_id(); + let charlie_id = charlie.node.get_our_node_id(); + let david_id = david.node.get_our_node_id(); + + disconnect_peers(alice, &[charlie, david, &nodes[4], &nodes[5]]); + disconnect_peers(david, &[bob, &nodes[4], &nodes[5]]); + + let conversion = TestCurrencyConversion; + let offer = alice.node + .create_offer_builder() + .unwrap() + .amount( + Amount::Currency { + iso4217_code: CurrencyCode::new(*b"USD").unwrap(), + amount: 10, + }, + &conversion, + ) + .unwrap() + .build(); + + let payment_id = PaymentId([1; 32]); + david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + + connect_peers(david, bob); + + let onion_message = david.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(david_id, &onion_message); + + connect_peers(alice, charlie); + + 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); + let nonce = extract_offer_nonce(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, + }, + }); + + let expanded_key = alice.keys_manager.get_expanded_key(); + let secp_ctx = Secp256k1::new(); + let created_at = alice.node.duration_since_epoch(); + let requested_amount = invoice_request.payable_amount(&conversion).unwrap(); + let amount_msats = requested_amount.amount_msats(); + let invalid_amount_msats = requested_amount.maximum_msats().checked_add(1).unwrap(); + let (payment_hash, payment_secret) = + alice.node.create_inbound_payment(Some(amount_msats), 3600, None).unwrap(); + let payment_paths = alice + .node + .test_create_blinded_payment_paths(Some(amount_msats), payment_secret, payment_context, 3600) + .unwrap(); + let verified_invoice_request = invoice_request + .verify_using_recipient_data(nonce, &expanded_key, &secp_ctx) + .unwrap(); + + let invoice = match verified_invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => request + .respond_using_derived_keys_no_std(&conversion, payment_paths, payment_hash, created_at) + .unwrap() + .amount_msats_unchecked(invalid_amount_msats) + .build_and_sign(&secp_ctx) + .unwrap(), + InvoiceRequestVerifiedFromOffer::ExplicitKeys(_) => panic!("Expected invoice request with keys"), + }; + + let instructions = MessageSendInstructions::WithoutReplyPath { + destination: Destination::BlindedPath(reply_path), + }; + let message = OffersMessage::Invoice(invoice); + alice.node.flow.pending_offers_messages.lock().unwrap().push((message, instructions)); + + let onion_message = alice.onion_messenger.next_onion_message_for_peer(charlie_id).unwrap(); + charlie.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let onion_message = charlie.onion_messenger.next_onion_message_for_peer(david_id).unwrap(); + david.onion_messenger.handle_onion_message(charlie_id, &onion_message); + + match get_event!(david, Event::PaymentFailed) { + Event::PaymentFailed { + payment_id: event_payment_id, + payment_hash: None, + reason: Some(event_reason), + } => { + assert_eq!(event_payment_id, payment_id); + assert_eq!(event_reason, PaymentFailureReason::UnexpectedError); + }, + _ => panic!("Expected Event::PaymentFailed with reason"), + } + expect_no_recent_payment!(david, payment_id); +} + +#[test] +fn fails_paying_invoice_with_unsupported_currency() { + struct EuroConversion; + + impl CurrencyConversion for EuroConversion { + fn msats_per_minor_unit(&self, iso4217_code: CurrencyCode) -> Result<(f64, u8), ()> { + if iso4217_code.as_str() == "EUR" { + Ok((2_000.0, 5)) + } else { + Err(()) + } + } + } + + let mut accept_forward_cfg = test_default_channel_config(); + accept_forward_cfg.accept_forwards_to_priv_channels = true; + + let chanmon_cfgs = create_chanmon_cfgs(6); + let node_cfgs = create_node_cfgs(6, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs( + 6, &node_cfgs, &[None, Some(accept_forward_cfg), None, None, None, None] + ); + let nodes = create_network(6, &node_cfgs, &node_chanmgrs); + + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 4, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 1, 5, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 2, 4, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 2, 5, 10_000_000, 1_000_000_000); + + let (alice, bob, charlie, david) = (&nodes[0], &nodes[1], &nodes[2], &nodes[3]); + let alice_id = alice.node.get_our_node_id(); + let bob_id = bob.node.get_our_node_id(); + let charlie_id = charlie.node.get_our_node_id(); + let david_id = david.node.get_our_node_id(); + + disconnect_peers(alice, &[charlie, david, &nodes[4], &nodes[5]]); + disconnect_peers(david, &[bob, &nodes[4], &nodes[5]]); + + let offer = alice.node + .create_offer_builder() + .unwrap() + .amount(Amount::Currency { iso4217_code: CurrencyCode::new(*b"EUR").unwrap(), amount: 10 }, &EuroConversion) + .unwrap() + .build(); + + let payment_id = PaymentId([1; 32]); + david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + + connect_peers(david, bob); + + let onion_message = david.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(david_id, &onion_message); + + connect_peers(alice, charlie); + + 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); + let nonce = extract_offer_nonce(alice, &onion_message); + let amount_msats = invoice_request.payable_amount(&EuroConversion).unwrap().amount_msats(); + 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, + }, + }); + + let expanded_key = alice.keys_manager.get_expanded_key(); + let secp_ctx = Secp256k1::new(); + let created_at = alice.node.duration_since_epoch(); + let (payment_hash, payment_secret) = + alice.node.create_inbound_payment(Some(amount_msats), 3600, None).unwrap(); + let payment_paths = alice + .node + .test_create_blinded_payment_paths(Some(amount_msats), payment_secret, payment_context, 3600) + .unwrap(); + let verified_invoice_request = invoice_request + .verify_using_recipient_data(nonce, &expanded_key, &secp_ctx) + .unwrap(); + + let invoice = match verified_invoice_request { + InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => request + .respond_using_derived_keys_no_std( + &EuroConversion, + payment_paths, + payment_hash, + created_at, + ) + .unwrap() + .build_and_sign(&secp_ctx) + .unwrap(), + InvoiceRequestVerifiedFromOffer::ExplicitKeys(_) => panic!("Expected invoice request with keys"), + }; + let instructions = MessageSendInstructions::WithoutReplyPath { + destination: Destination::BlindedPath(reply_path), + }; + let message = OffersMessage::Invoice(invoice); + alice.node.flow.pending_offers_messages.lock().unwrap().push((message, instructions)); + + let onion_message = alice.onion_messenger.next_onion_message_for_peer(charlie_id).unwrap(); + charlie.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let onion_message = charlie.onion_messenger.next_onion_message_for_peer(david_id).unwrap(); + david.onion_messenger.handle_onion_message(charlie_id, &onion_message); + + match get_event!(david, Event::PaymentFailed) { + Event::PaymentFailed { + payment_id: event_payment_id, + payment_hash: None, + reason: Some(event_reason), + } => { + assert_eq!(event_payment_id, payment_id); + assert_eq!(event_reason, PaymentFailureReason::UnexpectedError); + }, + _ => panic!("Expected Event::PaymentFailed with reason"), + } + expect_no_recent_payment!(david, payment_id); } #[test] diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 35c5717a51a..eb12a272c0a 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1911,7 +1911,7 @@ mod tests { use crate::offers::merkle::{self, SignError, SignatureTlvStreamRef, TaggedHash, TlvStream}; use crate::offers::nonce::Nonce; use crate::offers::offer::{ - Amount, ExperimentalOfferTlvStreamRef, OfferTlvStreamRef, Quantity, + Amount, CurrencyCode, ExperimentalOfferTlvStreamRef, OfferTlvStreamRef, Quantity, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError}; use crate::offers::payer::PayerTlvStreamRef; @@ -2118,6 +2118,44 @@ mod tests { } } + #[test] + fn parses_invoice_for_fiat_offer_without_explicit_request_amount() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + 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( + Amount::Currency { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 10 }, + &conversion, + ) + .unwrap() + .build() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .build_and_sign() + .unwrap() + .respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let mut encoded_invoice = Vec::new(); + invoice.write(&mut encoded_invoice).unwrap(); + + let parsed_invoice = Bolt12Invoice::try_from(encoded_invoice).unwrap(); + let (_, _, invoice_request_tlv_stream, invoice_tlv_stream, _, _, _, _) = + parsed_invoice.as_tlv_stream(); + assert_eq!(invoice_request_tlv_stream.amount, None); + assert_eq!(invoice_tlv_stream.amount, Some(10_000)); + assert_eq!(parsed_invoice.amount_msats(), 10_000); + } + #[test] fn builds_invoice_for_refund_with_defaults() { let payment_paths = payment_paths();