Skip to content

Commit b97ecf8

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

2 files changed

Lines changed: 298 additions & 2 deletions

File tree

lightning/src/ln/offers_tests.rs

Lines changed: 259 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,12 @@ 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;
59+
use crate::offers::currency::{CurrencyConversion, DefaultCurrencyConversion};
6060
use crate::offers::invoice::Bolt12Invoice;
6161
use crate::offers::invoice_error::InvoiceError;
6262
use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer};
6363
use crate::offers::nonce::Nonce;
64+
use crate::offers::offer::{Amount, CurrencyCode};
6465
use crate::offers::parse::Bolt12SemanticError;
6566
use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH};
6667
use crate::onion_message::offers::OffersMessage;
@@ -94,6 +95,20 @@ macro_rules! expect_recent_payment {
9495
}}
9596
}
9697

98+
macro_rules! expect_no_recent_payment {
99+
($node: expr, $payment_id: expr) => {{
100+
let found_payment = $node.node.list_recent_payments().iter().any(|payment| {
101+
match payment {
102+
RecentPaymentDetails::AwaitingInvoice { payment_id, .. }
103+
| RecentPaymentDetails::Pending { payment_id, .. }
104+
| RecentPaymentDetails::Fulfilled { payment_id, .. }
105+
| RecentPaymentDetails::Abandoned { payment_id, .. } => *payment_id == $payment_id,
106+
}
107+
});
108+
assert!(!found_payment);
109+
}}
110+
}
111+
97112
fn connect_peers<'a, 'b, 'c>(node_a: &Node<'a, 'b, 'c>, node_b: &Node<'a, 'b, 'c>) {
98113
let node_id_a = node_a.node.get_our_node_id();
99114
let node_id_b = node_b.node.get_our_node_id();
@@ -2463,6 +2478,249 @@ fn fails_paying_invoice_with_unknown_required_features() {
24632478
},
24642479
_ => panic!("Expected Event::PaymentFailed with reason"),
24652480
}
2481+
expect_no_recent_payment!(david, payment_id);
2482+
}
2483+
2484+
#[test]
2485+
fn fails_paying_invoice_with_invalid_amount() {
2486+
let mut accept_forward_cfg = test_default_channel_config();
2487+
accept_forward_cfg.accept_forwards_to_priv_channels = true;
2488+
2489+
let chanmon_cfgs = create_chanmon_cfgs(6);
2490+
let node_cfgs = create_node_cfgs(6, &chanmon_cfgs);
2491+
let node_chanmgrs = create_node_chanmgrs(
2492+
6, &node_cfgs, &[None, Some(accept_forward_cfg), None, None, None, None]
2493+
);
2494+
let nodes = create_network(6, &node_cfgs, &node_chanmgrs);
2495+
2496+
create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000);
2497+
create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 10_000_000, 1_000_000_000);
2498+
create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 10_000_000, 1_000_000_000);
2499+
create_announced_chan_between_nodes_with_value(&nodes, 1, 4, 10_000_000, 1_000_000_000);
2500+
create_announced_chan_between_nodes_with_value(&nodes, 1, 5, 10_000_000, 1_000_000_000);
2501+
create_announced_chan_between_nodes_with_value(&nodes, 2, 4, 10_000_000, 1_000_000_000);
2502+
create_announced_chan_between_nodes_with_value(&nodes, 2, 5, 10_000_000, 1_000_000_000);
2503+
2504+
let (alice, bob, charlie, david) = (&nodes[0], &nodes[1], &nodes[2], &nodes[3]);
2505+
let alice_id = alice.node.get_our_node_id();
2506+
let bob_id = bob.node.get_our_node_id();
2507+
let charlie_id = charlie.node.get_our_node_id();
2508+
let david_id = david.node.get_our_node_id();
2509+
2510+
disconnect_peers(alice, &[charlie, david, &nodes[4], &nodes[5]]);
2511+
disconnect_peers(david, &[bob, &nodes[4], &nodes[5]]);
2512+
2513+
let conversion = TestCurrencyConversion;
2514+
let offer = alice.node
2515+
.create_offer_builder()
2516+
.unwrap()
2517+
.amount(
2518+
Amount::Currency {
2519+
iso4217_code: CurrencyCode::new(*b"USD").unwrap(),
2520+
amount: 10,
2521+
},
2522+
&conversion,
2523+
)
2524+
.unwrap()
2525+
.build();
2526+
2527+
let payment_id = PaymentId([1; 32]);
2528+
david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap();
2529+
2530+
connect_peers(david, bob);
2531+
2532+
let onion_message = david.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
2533+
bob.onion_messenger.handle_onion_message(david_id, &onion_message);
2534+
2535+
connect_peers(alice, charlie);
2536+
2537+
let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
2538+
let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
2539+
let nonce = extract_offer_nonce(alice, &onion_message);
2540+
let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext {
2541+
offer_id: offer.id(),
2542+
invoice_request: InvoiceRequestFields {
2543+
payer_signing_pubkey: invoice_request.payer_signing_pubkey(),
2544+
quantity: None,
2545+
payer_note_truncated: None,
2546+
human_readable_name: None,
2547+
},
2548+
});
2549+
2550+
let expanded_key = alice.keys_manager.get_expanded_key();
2551+
let secp_ctx = Secp256k1::new();
2552+
let created_at = alice.node.duration_since_epoch();
2553+
let requested_amount = invoice_request.payable_amount(&conversion).unwrap();
2554+
let amount_msats = requested_amount.amount_msats();
2555+
let invalid_amount_msats = requested_amount.maximum_msats().checked_add(1).unwrap();
2556+
let (payment_hash, payment_secret) =
2557+
alice.node.create_inbound_payment(Some(amount_msats), 3600, None).unwrap();
2558+
let payment_paths = alice
2559+
.node
2560+
.test_create_blinded_payment_paths(Some(amount_msats), payment_secret, payment_context, 3600)
2561+
.unwrap();
2562+
let verified_invoice_request = invoice_request
2563+
.verify_using_recipient_data(nonce, &expanded_key, &secp_ctx)
2564+
.unwrap();
2565+
2566+
let invoice = match verified_invoice_request {
2567+
InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => request
2568+
.respond_using_derived_keys_no_std(&conversion, payment_paths, payment_hash, created_at)
2569+
.unwrap()
2570+
.amount_msats_unchecked(invalid_amount_msats)
2571+
.build_and_sign(&secp_ctx)
2572+
.unwrap(),
2573+
InvoiceRequestVerifiedFromOffer::ExplicitKeys(_) => panic!("Expected invoice request with keys"),
2574+
};
2575+
2576+
let instructions = MessageSendInstructions::WithoutReplyPath {
2577+
destination: Destination::BlindedPath(reply_path),
2578+
};
2579+
let message = OffersMessage::Invoice(invoice);
2580+
alice.node.flow.pending_offers_messages.lock().unwrap().push((message, instructions));
2581+
2582+
let onion_message = alice.onion_messenger.next_onion_message_for_peer(charlie_id).unwrap();
2583+
charlie.onion_messenger.handle_onion_message(alice_id, &onion_message);
2584+
2585+
let onion_message = charlie.onion_messenger.next_onion_message_for_peer(david_id).unwrap();
2586+
david.onion_messenger.handle_onion_message(charlie_id, &onion_message);
2587+
2588+
match get_event!(david, Event::PaymentFailed) {
2589+
Event::PaymentFailed {
2590+
payment_id: event_payment_id,
2591+
payment_hash: None,
2592+
reason: Some(event_reason),
2593+
} => {
2594+
assert_eq!(event_payment_id, payment_id);
2595+
assert_eq!(event_reason, PaymentFailureReason::UnexpectedError);
2596+
},
2597+
_ => panic!("Expected Event::PaymentFailed with reason"),
2598+
}
2599+
expect_no_recent_payment!(david, payment_id);
2600+
}
2601+
2602+
#[test]
2603+
fn fails_paying_invoice_with_unsupported_currency() {
2604+
struct EuroConversion;
2605+
2606+
impl CurrencyConversion for EuroConversion {
2607+
fn msats_per_minor_unit(&self, iso4217_code: CurrencyCode) -> Result<(f64, u8), ()> {
2608+
if iso4217_code.as_str() == "EUR" {
2609+
Ok((2_000.0, 5))
2610+
} else {
2611+
Err(())
2612+
}
2613+
}
2614+
}
2615+
2616+
let mut accept_forward_cfg = test_default_channel_config();
2617+
accept_forward_cfg.accept_forwards_to_priv_channels = true;
2618+
2619+
let chanmon_cfgs = create_chanmon_cfgs(6);
2620+
let node_cfgs = create_node_cfgs(6, &chanmon_cfgs);
2621+
let node_chanmgrs = create_node_chanmgrs(
2622+
6, &node_cfgs, &[None, Some(accept_forward_cfg), None, None, None, None]
2623+
);
2624+
let nodes = create_network(6, &node_cfgs, &node_chanmgrs);
2625+
2626+
create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000);
2627+
create_unannounced_chan_between_nodes_with_value(&nodes, 2, 3, 10_000_000, 1_000_000_000);
2628+
create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 10_000_000, 1_000_000_000);
2629+
create_announced_chan_between_nodes_with_value(&nodes, 1, 4, 10_000_000, 1_000_000_000);
2630+
create_announced_chan_between_nodes_with_value(&nodes, 1, 5, 10_000_000, 1_000_000_000);
2631+
create_announced_chan_between_nodes_with_value(&nodes, 2, 4, 10_000_000, 1_000_000_000);
2632+
create_announced_chan_between_nodes_with_value(&nodes, 2, 5, 10_000_000, 1_000_000_000);
2633+
2634+
let (alice, bob, charlie, david) = (&nodes[0], &nodes[1], &nodes[2], &nodes[3]);
2635+
let alice_id = alice.node.get_our_node_id();
2636+
let bob_id = bob.node.get_our_node_id();
2637+
let charlie_id = charlie.node.get_our_node_id();
2638+
let david_id = david.node.get_our_node_id();
2639+
2640+
disconnect_peers(alice, &[charlie, david, &nodes[4], &nodes[5]]);
2641+
disconnect_peers(david, &[bob, &nodes[4], &nodes[5]]);
2642+
2643+
let offer = alice.node
2644+
.create_offer_builder()
2645+
.unwrap()
2646+
.amount(Amount::Currency { iso4217_code: CurrencyCode::new(*b"EUR").unwrap(), amount: 10 }, &EuroConversion)
2647+
.unwrap()
2648+
.build();
2649+
2650+
let payment_id = PaymentId([1; 32]);
2651+
david.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap();
2652+
2653+
connect_peers(david, bob);
2654+
2655+
let onion_message = david.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
2656+
bob.onion_messenger.handle_onion_message(david_id, &onion_message);
2657+
2658+
connect_peers(alice, charlie);
2659+
2660+
let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
2661+
let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
2662+
let nonce = extract_offer_nonce(alice, &onion_message);
2663+
let amount_msats = invoice_request.payable_amount(&EuroConversion).unwrap().amount_msats();
2664+
let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext {
2665+
offer_id: offer.id(),
2666+
invoice_request: InvoiceRequestFields {
2667+
payer_signing_pubkey: invoice_request.payer_signing_pubkey(),
2668+
quantity: None,
2669+
payer_note_truncated: None,
2670+
human_readable_name: None,
2671+
},
2672+
});
2673+
2674+
let expanded_key = alice.keys_manager.get_expanded_key();
2675+
let secp_ctx = Secp256k1::new();
2676+
let created_at = alice.node.duration_since_epoch();
2677+
let (payment_hash, payment_secret) =
2678+
alice.node.create_inbound_payment(Some(amount_msats), 3600, None).unwrap();
2679+
let payment_paths = alice
2680+
.node
2681+
.test_create_blinded_payment_paths(Some(amount_msats), payment_secret, payment_context, 3600)
2682+
.unwrap();
2683+
let verified_invoice_request = invoice_request
2684+
.verify_using_recipient_data(nonce, &expanded_key, &secp_ctx)
2685+
.unwrap();
2686+
2687+
let invoice = match verified_invoice_request {
2688+
InvoiceRequestVerifiedFromOffer::DerivedKeys(request) => request
2689+
.respond_using_derived_keys_no_std(
2690+
&EuroConversion,
2691+
payment_paths,
2692+
payment_hash,
2693+
created_at,
2694+
)
2695+
.unwrap()
2696+
.build_and_sign(&secp_ctx)
2697+
.unwrap(),
2698+
InvoiceRequestVerifiedFromOffer::ExplicitKeys(_) => panic!("Expected invoice request with keys"),
2699+
};
2700+
let instructions = MessageSendInstructions::WithoutReplyPath {
2701+
destination: Destination::BlindedPath(reply_path),
2702+
};
2703+
let message = OffersMessage::Invoice(invoice);
2704+
alice.node.flow.pending_offers_messages.lock().unwrap().push((message, instructions));
2705+
2706+
let onion_message = alice.onion_messenger.next_onion_message_for_peer(charlie_id).unwrap();
2707+
charlie.onion_messenger.handle_onion_message(alice_id, &onion_message);
2708+
2709+
let onion_message = charlie.onion_messenger.next_onion_message_for_peer(david_id).unwrap();
2710+
david.onion_messenger.handle_onion_message(charlie_id, &onion_message);
2711+
2712+
match get_event!(david, Event::PaymentFailed) {
2713+
Event::PaymentFailed {
2714+
payment_id: event_payment_id,
2715+
payment_hash: None,
2716+
reason: Some(event_reason),
2717+
} => {
2718+
assert_eq!(event_payment_id, payment_id);
2719+
assert_eq!(event_reason, PaymentFailureReason::UnexpectedError);
2720+
},
2721+
_ => panic!("Expected Event::PaymentFailed with reason"),
2722+
}
2723+
expect_no_recent_payment!(david, payment_id);
24662724
}
24672725

24682726
#[test]

lightning/src/offers/invoice.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1910,7 +1910,7 @@ mod tests {
19101910
use crate::offers::merkle::{self, SignError, SignatureTlvStreamRef, TaggedHash, TlvStream};
19111911
use crate::offers::nonce::Nonce;
19121912
use crate::offers::offer::{
1913-
Amount, ExperimentalOfferTlvStreamRef, OfferTlvStreamRef, Quantity,
1913+
Amount, CurrencyCode, ExperimentalOfferTlvStreamRef, OfferTlvStreamRef, Quantity,
19141914
};
19151915
use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError};
19161916
use crate::offers::payer::PayerTlvStreamRef;
@@ -2117,6 +2117,44 @@ mod tests {
21172117
}
21182118
}
21192119

2120+
#[test]
2121+
fn parses_invoice_for_fiat_offer_without_explicit_request_amount() {
2122+
let expanded_key = ExpandedKey::new([42; 32]);
2123+
let entropy = FixedEntropy {};
2124+
let nonce = Nonce::from_entropy_source(&entropy);
2125+
let secp_ctx = Secp256k1::new();
2126+
let payment_id = PaymentId([1; 32]);
2127+
let conversion = TestCurrencyConversion;
2128+
2129+
let invoice = OfferBuilder::new(recipient_pubkey())
2130+
.amount(
2131+
Amount::Currency { iso4217_code: CurrencyCode::new(*b"USD").unwrap(), amount: 10 },
2132+
&conversion,
2133+
)
2134+
.unwrap()
2135+
.build()
2136+
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)
2137+
.unwrap()
2138+
.build_and_sign()
2139+
.unwrap()
2140+
.respond_with_no_std(&conversion, payment_paths(), payment_hash(), now())
2141+
.unwrap()
2142+
.build()
2143+
.unwrap()
2144+
.sign(recipient_sign)
2145+
.unwrap();
2146+
2147+
let mut encoded_invoice = Vec::new();
2148+
invoice.write(&mut encoded_invoice).unwrap();
2149+
2150+
let parsed_invoice = Bolt12Invoice::try_from(encoded_invoice).unwrap();
2151+
let (_, _, invoice_request_tlv_stream, invoice_tlv_stream, _, _, _, _) =
2152+
parsed_invoice.as_tlv_stream();
2153+
assert_eq!(invoice_request_tlv_stream.amount, None);
2154+
assert_eq!(invoice_tlv_stream.amount, Some(10_000));
2155+
assert_eq!(parsed_invoice.amount_msats(), 10_000);
2156+
}
2157+
21202158
#[test]
21212159
fn builds_invoice_for_refund_with_defaults() {
21222160
let payment_paths = payment_paths();

0 commit comments

Comments
 (0)