Skip to content

Commit eb45da9

Browse files
vincenzopalazzorustyrussellclaude
committed
feat(offers): add BOLT 12 payer proof primitives
Add the payer proof types, selective disclosure merkle support, parsing, and tests for constructing and validating BOLT 12 payer proofs from invoices. This implements the payer proof extension to BOLT 12 as specified in lightning/bolts#1295. Missing hashes in a proof are emitted in the DFS traversal order defined by the spec. The BOLT 12 payer proof spec test vectors from bolt12/payer-proof-test.json (full disclosure, minimal disclosure, with payer note, and left-subtree omitted) validate the end-to-end output. The parser rejects unknown even TLVs in every sub-stream range (offer, invoice request, invoice, payer-proof/signature, and the three experimental ranges) via the `tlv_stream!` macro's unknown-even fallback, and rejects types in the unused gap between the signature range and the experimental ranges via the all-bytes-consumed check in `ParsedMessage::try_from`. Co-Authored-By: Rusty Russell <rusty@rustcorp.com.au> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent df8cd15 commit eb45da9

7 files changed

Lines changed: 2925 additions & 13 deletions

File tree

lightning/src/ln/offers_tests.rs

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ use crate::offers::invoice_error::InvoiceError;
6161
use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer};
6262
use crate::offers::nonce::Nonce;
6363
use crate::offers::parse::Bolt12SemanticError;
64+
use crate::offers::payer_proof::{self, Bolt12InvoiceType, PayerProof, PayerProofError};
65+
use crate::types::payment::PaymentPreimage;
6466
use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, DUMMY_HOPS_PATH_LENGTH, QR_CODED_DUMMY_HOPS_PATH_LENGTH};
6567
use crate::onion_message::offers::OffersMessage;
6668
use crate::routing::gossip::{NodeAlias, NodeId};
@@ -264,6 +266,21 @@ fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessa
264266
}
265267
}
266268

269+
/// Extract the payer's nonce from an invoice onion message received by the payer.
270+
///
271+
/// When the payer receives an invoice through their reply path, the blinded path context
272+
/// contains the nonce originally used for deriving their payer signing key. This nonce is
273+
/// needed to build a [`PayerProof`] using [`payer_proof::PaidBolt12Invoice::prove_payer_derived`].
274+
fn extract_payer_context<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> (PaymentId, Nonce) {
275+
match node.onion_messenger.peel_onion_message(message) {
276+
Ok(PeeledOnion::Offers(_, Some(OffersContext::OutboundPaymentForOffer { payment_id, nonce, .. }), _)) => (payment_id, nonce),
277+
Ok(PeeledOnion::Offers(_, context, _)) => panic!("Expected OutboundPaymentForOffer context, got: {:?}", context),
278+
Ok(PeeledOnion::Forward(_, _)) => panic!("Unexpected onion message forward"),
279+
Ok(_) => panic!("Unexpected onion message"),
280+
Err(e) => panic!("Failed to process onion message {:?}", e),
281+
}
282+
}
283+
267284
pub(super) fn extract_invoice_request<'a, 'b, 'c>(
268285
node: &Node<'a, 'b, 'c>, message: &OnionMessage
269286
) -> (InvoiceRequest, BlindedMessagePath) {
@@ -2667,3 +2684,246 @@ fn creates_and_pays_for_phantom_offer() {
26672684
assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none());
26682685
}
26692686
}
2687+
2688+
/// Tests the full payer proof lifecycle: offer -> invoice_request -> invoice -> payment ->
2689+
/// proof creation with derived key signing -> verification -> bech32 round-trip.
2690+
///
2691+
/// This exercises the primary API path where a wallet pays a BOLT 12 offer and then creates
2692+
/// a payer proof using the derived signing key (same key derivation as the invoice request).
2693+
#[test]
2694+
fn creates_and_verifies_payer_proof_after_offer_payment() {
2695+
let chanmon_cfgs = create_chanmon_cfgs(2);
2696+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
2697+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
2698+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
2699+
2700+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000);
2701+
2702+
let alice = &nodes[0]; // recipient (offer creator)
2703+
let alice_id = alice.node.get_our_node_id();
2704+
let bob = &nodes[1]; // payer
2705+
let bob_id = bob.node.get_our_node_id();
2706+
2707+
// Alice creates an offer
2708+
let offer = alice.node
2709+
.create_offer_builder().unwrap()
2710+
.amount_msats(10_000_000)
2711+
.build().unwrap();
2712+
2713+
// Bob initiates payment
2714+
let payment_id = PaymentId([1; 32]);
2715+
bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap();
2716+
expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id);
2717+
2718+
// Bob sends invoice request to Alice
2719+
let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
2720+
alice.onion_messenger.handle_onion_message(bob_id, &onion_message);
2721+
2722+
let (invoice_request, _) = extract_invoice_request(alice, &onion_message);
2723+
2724+
// Alice sends invoice back to Bob
2725+
let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
2726+
bob.onion_messenger.handle_onion_message(alice_id, &onion_message);
2727+
2728+
let (invoice, _) = extract_invoice(bob, &onion_message);
2729+
assert_eq!(invoice.amount_msats(), 10_000_000);
2730+
2731+
// Extract the payer nonce and payment_id from Bob's reply path context. In a real wallet,
2732+
// these would be persisted alongside the payment for later payer proof creation.
2733+
let (context_payment_id, payer_nonce) = extract_payer_context(bob, &onion_message);
2734+
assert_eq!(context_payment_id, payment_id);
2735+
2736+
// Route the payment
2737+
route_bolt12_payment(bob, &[alice], &invoice);
2738+
expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id);
2739+
2740+
// Get the payment preimage from Alice's PaymentClaimable event and claim it.
2741+
// In a real wallet, the payer receives the preimage via Event::PaymentSent after the
2742+
// recipient claims. For the test, we extract it from the recipient's claimable event.
2743+
let payment_preimage = match get_event!(alice, Event::PaymentClaimable) {
2744+
Event::PaymentClaimable { purpose, .. } => {
2745+
match &purpose {
2746+
PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => {
2747+
assert_eq!(payment_context.offer_id, offer.id());
2748+
assert_eq!(
2749+
payment_context.invoice_request.payer_signing_pubkey,
2750+
invoice_request.payer_signing_pubkey(),
2751+
);
2752+
},
2753+
_ => panic!("Expected Bolt12OfferPayment purpose"),
2754+
}
2755+
purpose.preimage().unwrap()
2756+
},
2757+
_ => panic!("Expected Event::PaymentClaimable"),
2758+
};
2759+
2760+
claim_payment(bob, &[alice], payment_preimage);
2761+
expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id);
2762+
2763+
// --- Payer Proof Creation ---
2764+
// Bob (the payer) creates a proof-of-payment with selective disclosure.
2765+
// He includes the offer description and invoice amount, but omits other fields for privacy.
2766+
let expanded_key = bob.keys_manager.get_expanded_key();
2767+
let secp_ctx = Secp256k1::new();
2768+
let paid_invoice = payer_proof::PaidBolt12Invoice::new(
2769+
Bolt12InvoiceType::Bolt12Invoice(invoice.clone()),
2770+
payment_preimage,
2771+
Some(payer_nonce),
2772+
);
2773+
let proof = paid_invoice
2774+
.prove_payer_derived(&expanded_key, payment_id, &secp_ctx).unwrap()
2775+
.include_offer_description()
2776+
.include_invoice_amount()
2777+
.include_invoice_created_at()
2778+
.build_and_sign(None)
2779+
.unwrap();
2780+
2781+
// Check proof contents match the original payment
2782+
assert_eq!(proof.payment_preimage(), payment_preimage);
2783+
assert_eq!(proof.payment_hash(), invoice.payment_hash());
2784+
assert_eq!(proof.payer_signing_pubkey(), invoice.payer_signing_pubkey());
2785+
assert_eq!(proof.issuer_signing_pubkey(), invoice.signing_pubkey());
2786+
assert!(proof.payer_note().is_none());
2787+
2788+
// --- Serialization Round-Trip ---
2789+
// The proof can be serialized to a bech32 string (lnp...) for sharing.
2790+
let encoded = proof.to_string();
2791+
assert!(encoded.starts_with("lnp1"));
2792+
2793+
// Round-trip through TLV bytes: re-parse the raw bytes (verification happens at parse time).
2794+
let decoded = PayerProof::try_from(proof.bytes().to_vec()).unwrap();
2795+
assert_eq!(decoded.payment_preimage(), proof.payment_preimage());
2796+
assert_eq!(decoded.payment_hash(), proof.payment_hash());
2797+
assert_eq!(decoded.payer_signing_pubkey(), proof.payer_signing_pubkey());
2798+
assert_eq!(decoded.issuer_signing_pubkey(), proof.issuer_signing_pubkey());
2799+
assert_eq!(decoded.merkle_root(), proof.merkle_root());
2800+
}
2801+
2802+
/// Tests payer proof creation with a payer note, selective disclosure of specific invoice
2803+
/// fields, and error cases. Verifies that:
2804+
/// - A wrong preimage is rejected
2805+
/// - A minimal proof (required fields only) works
2806+
/// - Selective disclosure with a payer note works
2807+
/// - The proof survives a bech32 round-trip with the note intact
2808+
#[test]
2809+
fn creates_payer_proof_with_note_and_selective_disclosure() {
2810+
let chanmon_cfgs = create_chanmon_cfgs(2);
2811+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
2812+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
2813+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
2814+
2815+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000);
2816+
2817+
let alice = &nodes[0];
2818+
let alice_id = alice.node.get_our_node_id();
2819+
let bob = &nodes[1];
2820+
let bob_id = bob.node.get_our_node_id();
2821+
2822+
// Alice creates an offer with a description
2823+
let offer = alice.node
2824+
.create_offer_builder().unwrap()
2825+
.amount_msats(5_000_000)
2826+
.description("Coffee beans - 1kg".into())
2827+
.build().unwrap();
2828+
2829+
// Bob pays for the offer
2830+
let payment_id = PaymentId([2; 32]);
2831+
bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap();
2832+
expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id);
2833+
2834+
// Exchange messages
2835+
let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
2836+
alice.onion_messenger.handle_onion_message(bob_id, &onion_message);
2837+
let (invoice_request, _) = extract_invoice_request(alice, &onion_message);
2838+
2839+
let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
2840+
bob.onion_messenger.handle_onion_message(alice_id, &onion_message);
2841+
2842+
let (invoice, _) = extract_invoice(bob, &onion_message);
2843+
let (context_payment_id, payer_nonce) = extract_payer_context(bob, &onion_message);
2844+
assert_eq!(context_payment_id, payment_id);
2845+
2846+
// Route and claim the payment, extracting the preimage
2847+
route_bolt12_payment(bob, &[alice], &invoice);
2848+
expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id);
2849+
2850+
let payment_preimage = match get_event!(alice, Event::PaymentClaimable) {
2851+
Event::PaymentClaimable { purpose, .. } => {
2852+
match &purpose {
2853+
PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => {
2854+
assert_eq!(payment_context.offer_id, offer.id());
2855+
assert_eq!(
2856+
payment_context.invoice_request.payer_signing_pubkey,
2857+
invoice_request.payer_signing_pubkey(),
2858+
);
2859+
},
2860+
_ => panic!("Expected Bolt12OfferPayment purpose"),
2861+
}
2862+
purpose.preimage().unwrap()
2863+
},
2864+
_ => panic!("Expected Event::PaymentClaimable"),
2865+
};
2866+
2867+
claim_payment(bob, &[alice], payment_preimage);
2868+
expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id);
2869+
2870+
// --- Test 1: Wrong preimage is rejected ---
2871+
let wrong_preimage = PaymentPreimage([0xDE; 32]);
2872+
let wrong_paid = payer_proof::PaidBolt12Invoice::new(
2873+
Bolt12InvoiceType::Bolt12Invoice(invoice.clone()), wrong_preimage, Some(payer_nonce),
2874+
);
2875+
assert!(matches!(wrong_paid.prove_payer(), Err(PayerProofError::PreimageMismatch)));
2876+
2877+
// --- Test 2: Wrong payment_id causes key derivation failure ---
2878+
let expanded_key = bob.keys_manager.get_expanded_key();
2879+
let secp_ctx = Secp256k1::new();
2880+
let paid_invoice = payer_proof::PaidBolt12Invoice::new(
2881+
Bolt12InvoiceType::Bolt12Invoice(invoice.clone()),
2882+
payment_preimage,
2883+
Some(payer_nonce),
2884+
);
2885+
let wrong_payment_id = PaymentId([0xFF; 32]);
2886+
let result = paid_invoice.prove_payer_derived(&expanded_key, wrong_payment_id, &secp_ctx);
2887+
assert!(matches!(result, Err(PayerProofError::KeyDerivationFailed)));
2888+
2889+
// --- Test 3: Wrong nonce causes key derivation failure ---
2890+
let wrong_nonce = Nonce::from_entropy_source(&chanmon_cfgs[0].keys_manager);
2891+
let wrong_nonce_paid = payer_proof::PaidBolt12Invoice::new(
2892+
Bolt12InvoiceType::Bolt12Invoice(invoice.clone()), payment_preimage, Some(wrong_nonce),
2893+
);
2894+
let result = wrong_nonce_paid.prove_payer_derived(&expanded_key, payment_id, &secp_ctx);
2895+
assert!(matches!(result, Err(PayerProofError::KeyDerivationFailed)));
2896+
2897+
// --- Test 4: Minimal proof (only required fields) ---
2898+
let minimal_proof = paid_invoice
2899+
.prove_payer_derived(&expanded_key, payment_id, &secp_ctx).unwrap()
2900+
.build_and_sign(None)
2901+
.unwrap();
2902+
// --- Test 5: Proof with selective disclosure and payer note ---
2903+
let proof_with_note = paid_invoice
2904+
.prove_payer_derived(&expanded_key, payment_id, &secp_ctx).unwrap()
2905+
.include_offer_description()
2906+
.include_offer_issuer()
2907+
.include_invoice_amount()
2908+
.include_invoice_created_at()
2909+
.build_and_sign(Some("Paid for coffee".into()))
2910+
.unwrap();
2911+
assert_eq!(proof_with_note.payer_note().map(|p| p.0), Some("Paid for coffee"));
2912+
2913+
// Both proofs should verify and have the same core fields
2914+
assert_eq!(minimal_proof.payment_preimage(), proof_with_note.payment_preimage());
2915+
assert_eq!(minimal_proof.payment_hash(), proof_with_note.payment_hash());
2916+
assert_eq!(minimal_proof.payer_signing_pubkey(), proof_with_note.payer_signing_pubkey());
2917+
assert_eq!(minimal_proof.issuer_signing_pubkey(), proof_with_note.issuer_signing_pubkey());
2918+
2919+
// The merkle roots are the same since both reconstruct from the same invoice
2920+
assert_eq!(minimal_proof.merkle_root(), proof_with_note.merkle_root());
2921+
2922+
// --- Test 6: Round-trip the proof with note through TLV bytes ---
2923+
let encoded = proof_with_note.to_string();
2924+
assert!(encoded.starts_with("lnp1"));
2925+
2926+
let decoded = PayerProof::try_from(proof_with_note.bytes().to_vec()).unwrap();
2927+
assert_eq!(decoded.payer_note().map(|p| p.0), Some("Paid for coffee"));
2928+
assert_eq!(decoded.payment_preimage(), payment_preimage);
2929+
}

lightning/src/offers/invoice.rs

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -985,6 +985,11 @@ impl Bolt12Invoice {
985985
self.signature
986986
}
987987

988+
/// The raw serialized bytes of the invoice.
989+
pub(super) fn invoice_bytes(&self) -> &[u8] {
990+
&self.bytes
991+
}
992+
988993
/// Hash that was used for signing the invoice.
989994
pub fn signable_hash(&self) -> [u8; 32] {
990995
self.tagged_hash.as_digest().as_ref().clone()
@@ -1035,9 +1040,6 @@ impl Bolt12Invoice {
10351040

10361041
/// Re-derives the payer's signing keypair for payer proof creation.
10371042
///
1038-
/// This performs the same key derivation that occurs during invoice request creation
1039-
/// with `deriving_signing_pubkey`, allowing the payer to recover their signing keypair.
1040-
///
10411043
/// The `nonce` and `payment_id` must be the same ones used when creating the original
10421044
/// invoice request. In the common proof-of-payment flow, callers can instead use
10431045
/// `PaidBolt12Invoice::prove_payer_derived` together with the `payment_id` from
@@ -1557,16 +1559,31 @@ impl TryFrom<Vec<u8>> for Bolt12Invoice {
15571559
/// Valid type range for invoice TLV records.
15581560
pub(super) const INVOICE_TYPES: core::ops::Range<u64> = 160..240;
15591561

1562+
/// TLV record type for the invoice creation timestamp.
1563+
pub(super) const INVOICE_CREATED_AT_TYPE: u64 = 164;
1564+
1565+
/// TLV record type for [`Bolt12Invoice::payment_hash`].
1566+
pub(super) const INVOICE_PAYMENT_HASH_TYPE: u64 = 168;
1567+
1568+
/// TLV record type for [`Bolt12Invoice::amount_msats`].
1569+
pub(super) const INVOICE_AMOUNT_TYPE: u64 = 170;
1570+
1571+
/// TLV record type for [`Bolt12Invoice::invoice_features`].
1572+
pub(super) const INVOICE_FEATURES_TYPE: u64 = 174;
1573+
1574+
/// TLV record type for [`Bolt12Invoice::signing_pubkey`].
1575+
pub(super) const INVOICE_NODE_ID_TYPE: u64 = 176;
1576+
15601577
tlv_stream!(InvoiceTlvStream, InvoiceTlvStreamRef<'a>, INVOICE_TYPES, {
15611578
(160, paths: (Vec<BlindedPath>, WithoutLength, Iterable<'a, BlindedPathIter<'a>, BlindedPath>)),
15621579
(162, blindedpay: (Vec<BlindedPayInfo>, WithoutLength, Iterable<'a, BlindedPayInfoIter<'a>, BlindedPayInfo>)),
1563-
(164, created_at: (u64, HighZeroBytesDroppedBigSize)),
1580+
(INVOICE_CREATED_AT_TYPE, created_at: (u64, HighZeroBytesDroppedBigSize)),
15641581
(166, relative_expiry: (u32, HighZeroBytesDroppedBigSize)),
1565-
(168, payment_hash: PaymentHash),
1566-
(170, amount: (u64, HighZeroBytesDroppedBigSize)),
1582+
(INVOICE_PAYMENT_HASH_TYPE, payment_hash: PaymentHash),
1583+
(INVOICE_AMOUNT_TYPE, amount: (u64, HighZeroBytesDroppedBigSize)),
15671584
(172, fallbacks: (Vec<FallbackAddress>, WithoutLength)),
1568-
(174, features: (Bolt12InvoiceFeatures, WithoutLength)),
1569-
(176, node_id: PublicKey),
1585+
(INVOICE_FEATURES_TYPE, features: (Bolt12InvoiceFeatures, WithoutLength)),
1586+
(INVOICE_NODE_ID_TYPE, node_id: PublicKey),
15701587
// Only present in `StaticInvoice`s.
15711588
(236, held_htlc_available_paths: (Vec<BlindedMessagePath>, WithoutLength)),
15721589
});

0 commit comments

Comments
 (0)