Skip to content

Commit 93cfb86

Browse files
shaavancodex
andcommitted
[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 <codex@openai.com>
1 parent 05bb476 commit 93cfb86

9 files changed

Lines changed: 399 additions & 168 deletions

File tree

fuzz/src/invoice_request_deser.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,10 @@ fn build_response<T: secp256k1::Signing + secp256k1::Verification>(
153153
.unwrap();
154154

155155
let payment_hash = PaymentHash([42; 32]);
156-
invoice_request.respond_with(vec![payment_path], payment_hash)?.build()
156+
let conversion = FuzzCurrencyConversion;
157+
invoice_request
158+
.respond_with(&conversion, vec![payment_path], payment_hash)?
159+
.build()
157160
}
158161

159162
pub fn invoice_request_deser_test<Out: test_logger::Output>(data: &[u8], out: Out) {

lightning/src/ln/async_payments_tests.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ use crate::types::features::Bolt12InvoiceFeatures;
6060
use crate::types::payment::{PaymentHash, PaymentPreimage, PaymentSecret};
6161
use crate::util::config::{HTLCInterceptionFlags, UserConfig};
6262
use crate::util::ser::Writeable;
63-
use crate::util::test_utils::TestCurrencyConversion;
6463
use bitcoin::constants::ChainHash;
6564
use bitcoin::network::Network;
6665
use bitcoin::secp256k1;
@@ -1456,8 +1455,6 @@ fn amount_doesnt_match_invreq() {
14561455

14571456
let amt_msat = 5000;
14581457
let payment_id = PaymentId([1; 32]);
1459-
let conversion = TestCurrencyConversion;
1460-
14611458
nodes[0].node.pay_for_offer(&offer, Some(amt_msat), payment_id, Default::default()).unwrap();
14621459
let release_held_htlc_om_3_0 = pass_async_payments_oms(
14631460
static_invoice,

lightning/src/ln/channelmanager.rs

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5760,6 +5760,15 @@ impl<
57605760
fn send_payment_for_verified_bolt12_invoice(
57615761
&self, invoice: &Bolt12Invoice, payment_id: PaymentId,
57625762
) -> Result<(), Bolt12PaymentError> {
5763+
self.check_bolt12_invoice_amount(invoice).inspect_err(|e| {
5764+
if matches!(
5765+
e,
5766+
Bolt12PaymentError::InvalidAmount | Bolt12PaymentError::UnsupportedCurrency
5767+
) {
5768+
self.abandon_payment_with_reason(payment_id, PaymentFailureReason::UnexpectedError);
5769+
}
5770+
})?;
5771+
57635772
let best_block_height = self.best_block.read().unwrap().height;
57645773
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
57655774
let features = self.bolt12_invoice_features();
@@ -5781,6 +5790,24 @@ impl<
57815790
)
57825791
}
57835792

5793+
fn check_bolt12_invoice_amount(
5794+
&self, invoice: &Bolt12Invoice,
5795+
) -> Result<(), Bolt12PaymentError> {
5796+
let requested_amount =
5797+
invoice.payable_amount(&self.currency_conversion).map_err(|e| match e {
5798+
Bolt12SemanticError::UnsupportedCurrency => Bolt12PaymentError::UnsupportedCurrency,
5799+
_ => Bolt12PaymentError::UnexpectedInvoice,
5800+
})?;
5801+
// A returned invoice quotes the amount the payee expects to receive. Make
5802+
// sure it matches the payer's locally expected amount before recording the
5803+
// invoice as received or initiating payment.
5804+
if !requested_amount.contains(invoice.amount_msats()) {
5805+
return Err(Bolt12PaymentError::InvalidAmount);
5806+
}
5807+
5808+
Ok(())
5809+
}
5810+
57845811
fn check_refresh_async_receive_offer_cache(&self, timer_tick_occurred: bool) {
57855812
let peers = self.get_peers_for_blinded_path();
57865813
let channels = self.list_usable_channels();
@@ -5789,6 +5816,7 @@ impl<
57895816
peers,
57905817
channels,
57915818
router,
5819+
&self.currency_conversion,
57925820
timer_tick_occurred,
57935821
);
57945822
match refresh_res {
@@ -16909,12 +16937,12 @@ impl<
1690916937
{
1691016938
#[rustfmt::skip]
1691116939
fn handle_message(
16912-
&self, message: OffersMessage, context: Option<OffersContext>, responder: Option<Responder>,
16913-
) -> Option<(OffersMessage, ResponseInstruction)> {
16914-
macro_rules! handle_pay_invoice_res {
16915-
($res: expr, $invoice: expr, $logger: expr) => {{
16916-
let error = match $res {
16917-
Err(Bolt12PaymentError::UnknownRequiredFeatures) => {
16940+
&self, message: OffersMessage, context: Option<OffersContext>, responder: Option<Responder>,
16941+
) -> Option<(OffersMessage, ResponseInstruction)> {
16942+
macro_rules! handle_pay_invoice_res {
16943+
($res: expr, $invoice: expr, $payment_id: expr, $logger: expr) => {{
16944+
let error = match $res {
16945+
Err(Bolt12PaymentError::UnknownRequiredFeatures) => {
1691816946
log_trace!(
1691916947
$logger, "Invoice requires unknown features: {:?}",
1692016948
$invoice.invoice_features()
@@ -16930,6 +16958,13 @@ impl<
1693016958
log_trace!($logger, "{}", err_msg);
1693116959
InvoiceError::from_string(err_msg.to_string())
1693216960
},
16961+
Err(Bolt12PaymentError::InvalidAmount)
16962+
| Err(Bolt12PaymentError::UnsupportedCurrency) => {
16963+
self.abandon_payment_with_reason(
16964+
$payment_id, PaymentFailureReason::UnexpectedError
16965+
);
16966+
return None;
16967+
},
1693316968
Err(Bolt12PaymentError::UnexpectedInvoice)
1693416969
| Err(Bolt12PaymentError::DuplicateInvoice)
1693516970
| Ok(()) => return None,
@@ -16978,6 +17013,7 @@ impl<
1697817013
&self.router,
1697917014
&request,
1698017015
self.list_usable_channels(),
17016+
&self.currency_conversion,
1698117017
get_payment_info,
1698217018
);
1698317019

@@ -17002,6 +17038,7 @@ impl<
1700217038
&self.router,
1700317039
&request,
1700417040
self.list_usable_channels(),
17041+
&self.currency_conversion,
1700517042
get_payment_info,
1700617043
);
1700717044

@@ -17051,6 +17088,10 @@ impl<
1705117088
);
1705217089

1705317090
if self.config.read().unwrap().manually_handle_bolt12_invoices {
17091+
if let Err(e) = self.check_bolt12_invoice_amount(&invoice) {
17092+
handle_pay_invoice_res!(Err(e), invoice, payment_id, logger);
17093+
}
17094+
1705417095
// Update the corresponding entry in `PendingOutboundPayment` for this invoice.
1705517096
// This ensures that event generation remains idempotent in case we receive
1705617097
// the same invoice multiple times.
@@ -17064,15 +17105,15 @@ impl<
1706417105
}
1706517106

1706617107
let res = self.send_payment_for_verified_bolt12_invoice(&invoice, payment_id);
17067-
handle_pay_invoice_res!(res, invoice, logger);
17108+
handle_pay_invoice_res!(res, invoice, payment_id, logger);
1706817109
},
1706917110
OffersMessage::StaticInvoice(invoice) => {
1707017111
let payment_id = match context {
1707117112
Some(OffersContext::OutboundPaymentForOffer { payment_id, .. }) => payment_id,
1707217113
_ => return None
1707317114
};
1707417115
let res = self.initiate_async_payment(&invoice, payment_id);
17075-
handle_pay_invoice_res!(res, invoice, self.logger);
17116+
handle_pay_invoice_res!(res, invoice, payment_id, self.logger);
1707617117
},
1707717118
OffersMessage::InvoiceError(invoice_error) => {
1707817119
let payment_hash = match context {
@@ -17144,6 +17185,7 @@ impl<
1714417185
self.list_usable_channels(),
1714517186
&self.entropy_source,
1714617187
&self.router,
17188+
&self.currency_conversion,
1714717189
) {
1714817190
Some((msg, ctx)) => (msg, ctx),
1714917191
None => return None,

lightning/src/ln/offers_tests.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,10 @@ use crate::types::features::Bolt12InvoiceFeatures;
5656
use crate::ln::functional_test_utils::*;
5757
use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnouncement, OnionMessage, OnionMessageHandler, RoutingMessageHandler, SocketAddress, UnsignedGossipMessage, UnsignedNodeAnnouncement};
5858
use crate::ln::outbound_payment::IDEMPOTENCY_TIMEOUT_TICKS;
59+
use crate::offers::currency::DefaultCurrencyConversion;
5960
use crate::offers::invoice::Bolt12Invoice;
6061
use crate::offers::invoice_error::InvoiceError;
6162
use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer};
62-
use crate::offers::currency::DefaultCurrencyConversion;
6363
use crate::offers::nonce::Nonce;
6464
use crate::offers::parse::Bolt12SemanticError;
6565
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};
6868
use crate::routing::router::{DEFAULT_PAYMENT_DUMMY_HOPS, PaymentParameters, RouteParameters, RouteParametersConfig};
6969
use crate::sign::{NodeSigner, Recipient};
7070
use crate::util::ser::Writeable;
71+
use crate::util::test_utils::TestCurrencyConversion;
7172

7273
/// This used to determine whether we built a compact path or not, but now its just a random
7374
/// constant we apply to blinded path expiry in these tests.
@@ -525,7 +526,7 @@ fn check_dummy_hop_pattern_in_offer() {
525526
let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
526527

527528
assert_eq!(
528-
invoice_request.payable_amount_msats(),
529+
invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()),
529530
Ok(10_000_000)
530531
);
531532
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
@@ -550,7 +551,7 @@ fn check_dummy_hop_pattern_in_offer() {
550551
let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
551552

552553
assert_eq!(
553-
invoice_request.payable_amount_msats(),
554+
invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()),
554555
Ok(10_000_000)
555556
);
556557
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
@@ -739,7 +740,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() {
739740
},
740741
});
741742
assert_eq!(
742-
invoice_request.payable_amount_msats(),
743+
invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()),
743744
Ok(10_000_000)
744745
);
745746
assert_ne!(invoice_request.payer_signing_pubkey(), david_id);
@@ -900,7 +901,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() {
900901
},
901902
});
902903
assert_eq!(
903-
invoice_request.payable_amount_msats(),
904+
invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()),
904905
Ok(10_000_000)
905906
);
906907
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
@@ -1292,7 +1293,7 @@ fn creates_and_pays_for_offer_with_retry() {
12921293
},
12931294
});
12941295
assert_eq!(
1295-
invoice_request.payable_amount_msats(),
1296+
invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()),
12961297
Ok(10_000_000)
12971298
);
12981299
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
@@ -1610,7 +1611,7 @@ fn fails_authentication_when_handling_invoice_request() {
16101611

16111612
let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
16121613
assert_eq!(
1613-
invoice_request.payable_amount_msats(),
1614+
invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()),
16141615
Ok(10_000_000)
16151616
);
16161617
assert_ne!(invoice_request.payer_signing_pubkey(), david_id);
@@ -1642,7 +1643,7 @@ fn fails_authentication_when_handling_invoice_request() {
16421643

16431644
let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
16441645
assert_eq!(
1645-
invoice_request.payable_amount_msats(),
1646+
invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()),
16461647
Ok(10_000_000)
16471648
);
16481649
assert_ne!(invoice_request.payer_signing_pubkey(), david_id);
@@ -1746,7 +1747,7 @@ fn fails_authentication_when_handling_invoice_for_offer() {
17461747

17471748
let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
17481749
assert_eq!(
1749-
invoice_request.payable_amount_msats(),
1750+
invoice_request.payable_amount(&DefaultCurrencyConversion).map(|amount| amount.amount_msats()),
17501751
Ok(10_000_000)
17511752
);
17521753
assert_ne!(invoice_request.payer_signing_pubkey(), david_id);
@@ -2385,6 +2386,7 @@ fn fails_paying_invoice_with_unknown_required_features() {
23852386
.build();
23862387

23872388
let payment_id = PaymentId([1; 32]);
2389+
let conversion = TestCurrencyConversion;
23882390
david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap();
23892391

23902392
connect_peers(david, bob);
@@ -2420,7 +2422,9 @@ fn fails_paying_invoice_with_unknown_required_features() {
24202422
let invoice = match verified_invoice_request {
24212423
InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => {
24222424
request
2423-
.respond_using_derived_keys_no_std(payment_paths,
2425+
.respond_using_derived_keys_no_std(
2426+
&conversion,
2427+
payment_paths,
24242428
payment_hash,
24252429
created_at,
24262430
)

lightning/src/ln/outbound_payment.rs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crate::ln::channelmanager::{
2424
use crate::ln::msgs::DecodeError;
2525
use crate::ln::onion_utils;
2626
use crate::ln::onion_utils::{DecodedOnionFailure, HTLCFailReason};
27+
use crate::offers::currency::DefaultCurrencyConversion;
2728
use crate::offers::invoice::Bolt12Invoice;
2829
use crate::offers::invoice_request::InvoiceRequest;
2930
use crate::offers::nonce::Nonce;
@@ -655,6 +656,11 @@ pub enum Bolt12PaymentError {
655656
UnexpectedInvoice,
656657
/// Payment for an invoice with the corresponding [`PaymentId`] was already initiated.
657658
DuplicateInvoice,
659+
/// The invoice was valid for the corresponding [`PaymentId`], but quoted an invalid amount.
660+
InvalidAmount,
661+
/// The invoice was valid for the corresponding [`PaymentId`], but the payer could not validate
662+
/// its amount because local currency conversion was unavailable.
663+
UnsupportedCurrency,
658664
/// The invoice was valid for the corresponding [`PaymentId`], but required unknown features.
659665
UnknownRequiredFeatures,
660666
/// The invoice was valid for the corresponding [`PaymentId`], but sending the payment failed.
@@ -1104,7 +1110,6 @@ impl OutboundPayments {
11041110
IH: Fn() -> InFlightHtlcs,
11051111
SP: Fn(SendAlongPathArgs) -> Result<(), APIError>,
11061112
{
1107-
11081113
let (payment_hash, retry_strategy, params_config, _) = self
11091114
.mark_invoice_received_and_get_details(invoice, payment_id)?;
11101115

@@ -1285,7 +1290,10 @@ impl OutboundPayments {
12851290
));
12861291
}
12871292

1288-
let amount_msat = match invreq.payable_amount_msats() {
1293+
let amount_msat = match invreq
1294+
.payable_amount(&DefaultCurrencyConversion)
1295+
.map(|amount| amount.amount_msats())
1296+
{
12891297
Ok(amt) => amt,
12901298
Err(_) => {
12911299
// We check this during invoice request parsing, when constructing the invreq's
@@ -2092,9 +2100,12 @@ impl OutboundPayments {
20922100
// event generation remains idempotent, even if the same invoice is received again before the
20932101
// event is handled by the user.
20942102
PendingOutboundPayment::InvoiceReceived {
2095-
retry_strategy, route_params_config, ..
2103+
payment_hash, retry_strategy, route_params_config,
20962104
} => {
2097-
Ok((invoice.payment_hash(), *retry_strategy, *route_params_config, false))
2105+
if *payment_hash != invoice.payment_hash() {
2106+
return Err(Bolt12PaymentError::DuplicateInvoice);
2107+
}
2108+
Ok((*payment_hash, *retry_strategy, *route_params_config, false))
20982109
},
20992110
_ => Err(Bolt12PaymentError::DuplicateInvoice),
21002111
},
@@ -3259,7 +3270,7 @@ mod tests {
32593270
.build()
32603271
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap()
32613272
.build_and_sign().unwrap()
3262-
.respond_with_no_std(payment_paths(), payment_hash(), created_at).unwrap()
3273+
.respond_with_no_std(&TestCurrencyConversion, payment_paths(), payment_hash(), created_at).unwrap()
32633274
.build().unwrap()
32643275
.sign(recipient_sign).unwrap();
32653276

@@ -3309,7 +3320,7 @@ mod tests {
33093320
.build()
33103321
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap()
33113322
.build_and_sign().unwrap()
3312-
.respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap()
3323+
.respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()).unwrap()
33133324
.build().unwrap()
33143325
.sign(recipient_sign).unwrap();
33153326

@@ -3375,7 +3386,7 @@ mod tests {
33753386
.build()
33763387
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id).unwrap()
33773388
.build_and_sign().unwrap()
3378-
.respond_with_no_std(payment_paths(), payment_hash(), now()).unwrap()
3389+
.respond_with_no_std(&conversion, payment_paths(), payment_hash(), now()).unwrap()
33793390
.build().unwrap()
33803391
.sign(recipient_sign).unwrap();
33813392

0 commit comments

Comments
 (0)