Skip to content

Commit 84d2374

Browse files
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.
1 parent 02a99b9 commit 84d2374

File tree

6 files changed

+2221
-2
lines changed

6 files changed

+2221
-2
lines changed

lightning/src/ln/offers_tests.rs

Lines changed: 241 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::{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 [`PayerProofBuilder::build_with_derived_key`].
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,227 @@ 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 proof = invoice.payer_proof_builder(payment_preimage).unwrap()
2768+
.include_offer_description()
2769+
.include_invoice_amount()
2770+
.include_invoice_created_at()
2771+
.build_with_derived_key(&expanded_key, payer_nonce, payment_id, None)
2772+
.unwrap();
2773+
2774+
// Check proof contents match the original payment
2775+
assert_eq!(proof.preimage(), payment_preimage);
2776+
assert_eq!(proof.payment_hash(), invoice.payment_hash());
2777+
assert_eq!(proof.payer_id(), invoice.payer_signing_pubkey());
2778+
assert_eq!(proof.issuer_signing_pubkey(), invoice.signing_pubkey());
2779+
assert!(proof.payer_note().is_none());
2780+
2781+
// --- Serialization Round-Trip ---
2782+
// The proof can be serialized to a bech32 string (lnp...) for sharing.
2783+
let encoded = proof.to_string();
2784+
assert!(encoded.starts_with("lnp1"));
2785+
2786+
// Round-trip through TLV bytes: re-parse the raw bytes (verification happens at parse time).
2787+
let decoded = PayerProof::try_from(proof.bytes().to_vec()).unwrap();
2788+
assert_eq!(decoded.preimage(), proof.preimage());
2789+
assert_eq!(decoded.payment_hash(), proof.payment_hash());
2790+
assert_eq!(decoded.payer_id(), proof.payer_id());
2791+
assert_eq!(decoded.issuer_signing_pubkey(), proof.issuer_signing_pubkey());
2792+
assert_eq!(decoded.merkle_root(), proof.merkle_root());
2793+
}
2794+
2795+
/// Tests payer proof creation with a payer note, selective disclosure of specific invoice
2796+
/// fields, and error cases. Verifies that:
2797+
/// - A wrong preimage is rejected
2798+
/// - A minimal proof (required fields only) works
2799+
/// - Selective disclosure with a payer note works
2800+
/// - The proof survives a bech32 round-trip with the note intact
2801+
#[test]
2802+
fn creates_payer_proof_with_note_and_selective_disclosure() {
2803+
let chanmon_cfgs = create_chanmon_cfgs(2);
2804+
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
2805+
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
2806+
let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
2807+
2808+
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000);
2809+
2810+
let alice = &nodes[0];
2811+
let alice_id = alice.node.get_our_node_id();
2812+
let bob = &nodes[1];
2813+
let bob_id = bob.node.get_our_node_id();
2814+
2815+
// Alice creates an offer with a description
2816+
let offer = alice.node
2817+
.create_offer_builder().unwrap()
2818+
.amount_msats(5_000_000)
2819+
.description("Coffee beans - 1kg".into())
2820+
.build().unwrap();
2821+
2822+
// Bob pays for the offer
2823+
let payment_id = PaymentId([2; 32]);
2824+
bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap();
2825+
expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id);
2826+
2827+
// Exchange messages
2828+
let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
2829+
alice.onion_messenger.handle_onion_message(bob_id, &onion_message);
2830+
let (invoice_request, _) = extract_invoice_request(alice, &onion_message);
2831+
2832+
let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
2833+
bob.onion_messenger.handle_onion_message(alice_id, &onion_message);
2834+
2835+
let (invoice, _) = extract_invoice(bob, &onion_message);
2836+
let (context_payment_id, payer_nonce) = extract_payer_context(bob, &onion_message);
2837+
assert_eq!(context_payment_id, payment_id);
2838+
2839+
// Route and claim the payment, extracting the preimage
2840+
route_bolt12_payment(bob, &[alice], &invoice);
2841+
expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id);
2842+
2843+
let payment_preimage = match get_event!(alice, Event::PaymentClaimable) {
2844+
Event::PaymentClaimable { purpose, .. } => {
2845+
match &purpose {
2846+
PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => {
2847+
assert_eq!(payment_context.offer_id, offer.id());
2848+
assert_eq!(
2849+
payment_context.invoice_request.payer_signing_pubkey,
2850+
invoice_request.payer_signing_pubkey(),
2851+
);
2852+
},
2853+
_ => panic!("Expected Bolt12OfferPayment purpose"),
2854+
}
2855+
purpose.preimage().unwrap()
2856+
},
2857+
_ => panic!("Expected Event::PaymentClaimable"),
2858+
};
2859+
2860+
claim_payment(bob, &[alice], payment_preimage);
2861+
expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id);
2862+
2863+
// --- Test 1: Wrong preimage is rejected ---
2864+
let wrong_preimage = PaymentPreimage([0xDE; 32]);
2865+
assert!(invoice.payer_proof_builder(wrong_preimage).is_err());
2866+
2867+
// --- Test 2: Wrong payment_id causes key derivation failure ---
2868+
let expanded_key = bob.keys_manager.get_expanded_key();
2869+
let wrong_payment_id = PaymentId([0xFF; 32]);
2870+
let result = invoice.payer_proof_builder(payment_preimage).unwrap()
2871+
.build_with_derived_key(&expanded_key, payer_nonce, wrong_payment_id, None);
2872+
assert!(matches!(result, Err(PayerProofError::KeyDerivationFailed)));
2873+
2874+
// --- Test 3: Wrong nonce causes key derivation failure ---
2875+
let wrong_nonce = Nonce::from_entropy_source(&chanmon_cfgs[0].keys_manager);
2876+
let result = invoice.payer_proof_builder(payment_preimage).unwrap()
2877+
.build_with_derived_key(&expanded_key, wrong_nonce, payment_id, None);
2878+
assert!(matches!(result, Err(PayerProofError::KeyDerivationFailed)));
2879+
2880+
// --- Test 4: Minimal proof (only required fields) ---
2881+
let minimal_proof = invoice.payer_proof_builder(payment_preimage).unwrap()
2882+
.build_with_derived_key(&expanded_key, payer_nonce, payment_id, None)
2883+
.unwrap();
2884+
// --- Test 5: Proof with selective disclosure and payer note ---
2885+
let proof_with_note = invoice.payer_proof_builder(payment_preimage).unwrap()
2886+
.include_offer_description()
2887+
.include_offer_issuer()
2888+
.include_invoice_amount()
2889+
.include_invoice_created_at()
2890+
.build_with_derived_key(&expanded_key, payer_nonce, payment_id, Some("Paid for coffee"))
2891+
.unwrap();
2892+
assert_eq!(proof_with_note.payer_note().map(|p| p.0), Some("Paid for coffee"));
2893+
2894+
// Both proofs should verify and have the same core fields
2895+
assert_eq!(minimal_proof.preimage(), proof_with_note.preimage());
2896+
assert_eq!(minimal_proof.payment_hash(), proof_with_note.payment_hash());
2897+
assert_eq!(minimal_proof.payer_id(), proof_with_note.payer_id());
2898+
assert_eq!(minimal_proof.issuer_signing_pubkey(), proof_with_note.issuer_signing_pubkey());
2899+
2900+
// The merkle roots are the same since both reconstruct from the same invoice
2901+
assert_eq!(minimal_proof.merkle_root(), proof_with_note.merkle_root());
2902+
2903+
// --- Test 6: Round-trip the proof with note through TLV bytes ---
2904+
let encoded = proof_with_note.to_string();
2905+
assert!(encoded.starts_with("lnp1"));
2906+
2907+
let decoded = PayerProof::try_from(proof_with_note.bytes().to_vec()).unwrap();
2908+
assert_eq!(decoded.payer_note().map(|p| p.0), Some("Paid for coffee"));
2909+
assert_eq!(decoded.preimage(), payment_preimage);
2910+
}

lightning/src/offers/invoice.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,13 +141,15 @@ use crate::offers::offer::{
141141
};
142142
use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage};
143143
use crate::offers::payer::{PayerTlvStream, PayerTlvStreamRef, PAYER_METADATA_TYPE};
144+
use crate::offers::payer_proof::{PayerProofBuilder, PayerProofError};
144145
use crate::offers::refund::{
145146
Refund, RefundContents, IV_BYTES_WITHOUT_METADATA as REFUND_IV_BYTES_WITHOUT_METADATA,
146147
IV_BYTES_WITH_METADATA as REFUND_IV_BYTES_WITH_METADATA,
147148
};
148149
use crate::offers::signer::{self, Metadata};
149150
use crate::types::features::{Bolt12InvoiceFeatures, InvoiceRequestFeatures, OfferFeatures};
150151
use crate::types::payment::PaymentHash;
152+
use crate::types::payment::PaymentPreimage;
151153
use crate::types::string::PrintableString;
152154
use crate::util::ser::{
153155
CursorReadable, HighZeroBytesDroppedBigSize, Iterable, LengthLimitedRead, LengthReadable,
@@ -1033,6 +1035,17 @@ impl Bolt12Invoice {
10331035
)
10341036
}
10351037

1038+
/// Creates a [`PayerProofBuilder`] for this invoice using the given payment preimage.
1039+
///
1040+
/// Returns an error if the preimage doesn't match the invoice's payment hash.
1041+
///
1042+
/// [`PayerProofBuilder`]: crate::offers::payer_proof::PayerProofBuilder
1043+
pub fn payer_proof_builder(
1044+
&self, preimage: PaymentPreimage,
1045+
) -> Result<PayerProofBuilder<'_>, PayerProofError> {
1046+
PayerProofBuilder::new(self, preimage)
1047+
}
1048+
10361049
/// Re-derives the payer's signing keypair for payer proof creation.
10371050
///
10381051
/// This performs the same key derivation that occurs during invoice request creation
@@ -1554,6 +1567,21 @@ impl TryFrom<Vec<u8>> for Bolt12Invoice {
15541567
/// Valid type range for invoice TLV records.
15551568
pub(super) const INVOICE_TYPES: core::ops::Range<u64> = 160..240;
15561569

1570+
/// TLV record type for the invoice creation timestamp.
1571+
pub(super) const INVOICE_CREATED_AT_TYPE: u64 = 164;
1572+
1573+
/// TLV record type for [`Bolt12Invoice::payment_hash`].
1574+
pub(super) const INVOICE_PAYMENT_HASH_TYPE: u64 = 168;
1575+
1576+
/// TLV record type for [`Bolt12Invoice::amount_msats`].
1577+
pub(super) const INVOICE_AMOUNT_TYPE: u64 = 170;
1578+
1579+
/// TLV record type for [`Bolt12Invoice::invoice_features`].
1580+
pub(super) const INVOICE_FEATURES_TYPE: u64 = 174;
1581+
1582+
/// TLV record type for [`Bolt12Invoice::signing_pubkey`].
1583+
pub(super) const INVOICE_NODE_ID_TYPE: u64 = 176;
1584+
15571585
tlv_stream!(InvoiceTlvStream, InvoiceTlvStreamRef<'a>, INVOICE_TYPES, {
15581586
(160, paths: (Vec<BlindedPath>, WithoutLength, Iterable<'a, BlindedPathIter<'a>, BlindedPath>)),
15591587
(162, blindedpay: (Vec<BlindedPayInfo>, WithoutLength, Iterable<'a, BlindedPayInfoIter<'a>, BlindedPayInfo>)),

0 commit comments

Comments
 (0)