From a673b516e40824e41f6e0db2d42912e5afdf7728 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 24 Feb 2026 18:43:09 +0100 Subject: [PATCH 1/7] refactor(offers): extract payer key derivation helpers Move the invoice/refund payer key derivation logic into reusable helpers so payer proofs can derive the same signing keys without duplicating the metadata and signer flow. --- lightning/src/offers/invoice.rs | 87 +++++++++++++++++++++++++++------ lightning/src/offers/signer.rs | 63 +++++++++++++++++++++--- 2 files changed, 129 insertions(+), 21 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..43a50ce3afe 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -131,7 +131,8 @@ use crate::offers::invoice_request::{ IV_BYTES as INVOICE_REQUEST_IV_BYTES, }; use crate::offers::merkle::{ - self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, + self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvRecord, + TlvStream, }; use crate::offers::nonce::Nonce; use crate::offers::offer::{ @@ -1032,6 +1033,34 @@ impl Bolt12Invoice { ) } + /// Re-derives the payer's signing keypair for payer proof creation. + /// + /// This performs the same key derivation that occurs during invoice request creation + /// with `deriving_signing_pubkey`, allowing the payer to recover their signing keypair. + /// + /// The `nonce` and `payment_id` must be the same ones used when creating the original + /// invoice request. In the common proof-of-payment flow, callers can instead use + /// `PaidBolt12Invoice::prove_payer_derived` together with the `payment_id` from + /// [`Event::PaymentSent`]. + /// + /// [`Event::PaymentSent`]: crate::events::Event::PaymentSent + pub fn derive_payer_signing_keys( + &self, payment_id: PaymentId, nonce: Nonce, key: &ExpandedKey, secp_ctx: &Secp256k1, + ) -> Result { + let iv_bytes = match &self.contents { + InvoiceContents::ForOffer { .. } => INVOICE_REQUEST_IV_BYTES, + InvoiceContents::ForRefund { .. } => REFUND_IV_BYTES_WITHOUT_METADATA, + }; + self.contents.derive_payer_signing_keys( + &self.bytes, + payment_id, + nonce, + key, + iv_bytes, + secp_ctx, + ) + } + pub(crate) fn as_tlv_stream(&self) -> FullInvoiceTlvStreamRef<'_> { let ( payer_tlv_stream, @@ -1317,20 +1346,8 @@ impl InvoiceContents { &self, bytes: &[u8], metadata: &Metadata, key: &ExpandedKey, iv_bytes: &[u8; IV_LEN], secp_ctx: &Secp256k1, ) -> Result { - const EXPERIMENTAL_TYPES: core::ops::Range = - EXPERIMENTAL_OFFER_TYPES.start..EXPERIMENTAL_INVOICE_REQUEST_TYPES.end; - - let offer_records = TlvStream::new(bytes).range(OFFER_TYPES); - let invreq_records = TlvStream::new(bytes).range(INVOICE_REQUEST_TYPES).filter(|record| { - match record.r#type { - PAYER_METADATA_TYPE => false, // Should be outside range - INVOICE_REQUEST_PAYER_ID_TYPE => !metadata.derives_payer_keys(), - _ => true, - } - }); - let experimental_records = TlvStream::new(bytes).range(EXPERIMENTAL_TYPES); - let tlv_stream = offer_records.chain(invreq_records).chain(experimental_records); - + let exclude_payer_id = metadata.derives_payer_keys(); + let tlv_stream = Self::payer_tlv_stream(bytes, exclude_payer_id); let signing_pubkey = self.payer_signing_pubkey(); signer::verify_payer_metadata( metadata.as_ref(), @@ -1342,6 +1359,46 @@ impl InvoiceContents { ) } + fn derive_payer_signing_keys( + &self, bytes: &[u8], payment_id: PaymentId, nonce: Nonce, key: &ExpandedKey, + iv_bytes: &[u8; IV_LEN], secp_ctx: &Secp256k1, + ) -> Result { + let tlv_stream = Self::payer_tlv_stream(bytes, true); + let signing_pubkey = self.payer_signing_pubkey(); + signer::derive_payer_keys( + payment_id, + nonce, + key, + iv_bytes, + signing_pubkey, + tlv_stream, + secp_ctx, + ) + } + + /// Builds the TLV stream used for payer metadata verification and key derivation. + /// + /// When `exclude_payer_id` is true, the payer signing pubkey (type 88) is excluded + /// from the stream, which is needed when deriving payer keys. + fn payer_tlv_stream( + bytes: &[u8], exclude_payer_id: bool, + ) -> impl core::iter::Iterator> { + const EXPERIMENTAL_TYPES: core::ops::Range = + EXPERIMENTAL_OFFER_TYPES.start..EXPERIMENTAL_INVOICE_REQUEST_TYPES.end; + + let offer_records = TlvStream::new(bytes).range(OFFER_TYPES); + let invreq_records = + TlvStream::new(bytes).range(INVOICE_REQUEST_TYPES).filter(move |record| { + match record.r#type { + PAYER_METADATA_TYPE => false, + INVOICE_REQUEST_PAYER_ID_TYPE => !exclude_payer_id, + _ => true, + } + }); + let experimental_records = TlvStream::new(bytes).range(EXPERIMENTAL_TYPES); + offer_records.chain(invreq_records).chain(experimental_records) + } + fn as_tlv_stream(&self) -> PartialInvoiceTlvStreamRef<'_> { let (payer, offer, invoice_request, experimental_offer, experimental_invoice_request) = match self { diff --git a/lightning/src/offers/signer.rs b/lightning/src/offers/signer.rs index e51a120b6d7..e73654b3059 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -321,6 +321,34 @@ pub(super) fn derive_keys(nonce: Nonce, expanded_key: &ExpandedKey) -> Keypair { Keypair::from_secret_key(&secp_ctx, &privkey) } +/// Re-derives the payer signing keypair from the given components. +/// +/// Performs the same derivation as keys created by [`Metadata::derive_from`] when using +/// [`Metadata::DerivedSigningPubkey`] with a [`MetadataMaterial`] built from a `payment_id`. +/// +/// The `tlv_stream` must contain the records matching what was used during the original +/// key derivation. +pub(super) fn derive_payer_keys<'a, T: secp256k1::Signing>( + payment_id: PaymentId, nonce: Nonce, expanded_key: &ExpandedKey, iv_bytes: &[u8; IV_LEN], + signing_pubkey: PublicKey, tlv_stream: impl core::iter::Iterator>, + secp_ctx: &Secp256k1, +) -> Result { + let metadata = Metadata::payer_data(payment_id, nonce, expanded_key); + let metadata_ref = metadata.as_ref(); + + match verify_payer_metadata_inner( + metadata_ref, + expanded_key, + iv_bytes, + signing_pubkey, + tlv_stream, + secp_ctx, + )? { + Some(keys) => Ok(keys), + None => Err(()), + } +} + /// Verifies data given in a TLV stream was used to produce the given metadata, consisting of: /// - a 256-bit [`PaymentId`], /// - a 128-bit [`Nonce`], and possibly @@ -339,6 +367,34 @@ pub(super) fn verify_payer_metadata<'a, T: secp256k1::Signing>( return Err(()); } + verify_payer_metadata_inner( + metadata, + expanded_key, + iv_bytes, + signing_pubkey, + tlv_stream, + secp_ctx, + )?; + + let mut encrypted_payment_id = [0u8; PaymentId::LENGTH]; + encrypted_payment_id.copy_from_slice(&metadata[..PaymentId::LENGTH]); + let nonce = Nonce::try_from(&metadata[PaymentId::LENGTH..][..Nonce::LENGTH]).unwrap(); + let payment_id = expanded_key.crypt_for_offer(encrypted_payment_id, nonce); + + Ok(PaymentId(payment_id)) +} + +/// Shared core of [`verify_payer_metadata`] and [`derive_payer_keys`]. +/// +/// Builds the payer HMAC from the given metadata and TLV stream, then verifies it against the +/// `signing_pubkey`. The `metadata` must be at least `PaymentId::LENGTH` bytes, with the first +/// `PaymentId::LENGTH` bytes being the encrypted payment ID and the remainder being the nonce +/// (and possibly an HMAC). +fn verify_payer_metadata_inner<'a, T: secp256k1::Signing>( + metadata: &[u8], expanded_key: &ExpandedKey, iv_bytes: &[u8; IV_LEN], + signing_pubkey: PublicKey, tlv_stream: impl core::iter::Iterator>, + secp_ctx: &Secp256k1, +) -> Result, ()> { let mut encrypted_payment_id = [0u8; PaymentId::LENGTH]; encrypted_payment_id.copy_from_slice(&metadata[..PaymentId::LENGTH]); @@ -352,12 +408,7 @@ pub(super) fn verify_payer_metadata<'a, T: secp256k1::Signing>( Hmac::from_engine(hmac), signing_pubkey, secp_ctx, - )?; - - let nonce = Nonce::try_from(&metadata[PaymentId::LENGTH..][..Nonce::LENGTH]).unwrap(); - let payment_id = expanded_key.crypt_for_offer(encrypted_payment_id, nonce); - - Ok(PaymentId(payment_id)) + ) } /// Verifies data given in a TLV stream was used to produce the given metadata, consisting of: From 2f863d976fd078cc7a0bf5b4bf8a21eb1e6ac442 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 24 Feb 2026 18:44:00 +0100 Subject: [PATCH 2/7] 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 https://github.com/lightning/bolts/pull/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 Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/ln/offers_tests.rs | 260 ++++ lightning/src/offers/invoice.rs | 33 +- lightning/src/offers/merkle.rs | 581 +++++++- lightning/src/offers/mod.rs | 1 + lightning/src/offers/offer.rs | 10 +- lightning/src/offers/payer_proof.rs | 2038 +++++++++++++++++++++++++++ lightning/src/util/ser.rs | 15 + 7 files changed, 2925 insertions(+), 13 deletions(-) create mode 100644 lightning/src/offers/payer_proof.rs diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index de08af5d276..a5bac3f72e7 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -61,6 +61,8 @@ use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; use crate::offers::parse::Bolt12SemanticError; +use crate::offers::payer_proof::{self, Bolt12InvoiceType, PayerProof, PayerProofError}; +use crate::types::payment::PaymentPreimage; 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; use crate::routing::gossip::{NodeAlias, NodeId}; @@ -264,6 +266,21 @@ fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessa } } +/// Extract the payer's nonce from an invoice onion message received by the payer. +/// +/// When the payer receives an invoice through their reply path, the blinded path context +/// contains the nonce originally used for deriving their payer signing key. This nonce is +/// needed to build a [`PayerProof`] using [`payer_proof::PaidBolt12Invoice::prove_payer_derived`]. +fn extract_payer_context<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> (PaymentId, Nonce) { + match node.onion_messenger.peel_onion_message(message) { + Ok(PeeledOnion::Offers(_, Some(OffersContext::OutboundPaymentForOffer { payment_id, nonce, .. }), _)) => (payment_id, nonce), + Ok(PeeledOnion::Offers(_, context, _)) => panic!("Expected OutboundPaymentForOffer context, got: {:?}", context), + Ok(PeeledOnion::Forward(_, _)) => panic!("Unexpected onion message forward"), + Ok(_) => panic!("Unexpected onion message"), + Err(e) => panic!("Failed to process onion message {:?}", e), + } +} + pub(super) fn extract_invoice_request<'a, 'b, 'c>( node: &Node<'a, 'b, 'c>, message: &OnionMessage ) -> (InvoiceRequest, BlindedMessagePath) { @@ -2667,3 +2684,246 @@ fn creates_and_pays_for_phantom_offer() { assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none()); } } + +/// Tests the full payer proof lifecycle: offer -> invoice_request -> invoice -> payment -> +/// proof creation with derived key signing -> verification -> bech32 round-trip. +/// +/// This exercises the primary API path where a wallet pays a BOLT 12 offer and then creates +/// a payer proof using the derived signing key (same key derivation as the invoice request). +#[test] +fn creates_and_verifies_payer_proof_after_offer_payment() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; // recipient (offer creator) + let alice_id = alice.node.get_our_node_id(); + let bob = &nodes[1]; // payer + let bob_id = bob.node.get_our_node_id(); + + // Alice creates an offer + let offer = alice.node + .create_offer_builder().unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + // Bob initiates payment + let payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); + + // Bob sends invoice request to Alice + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(alice, &onion_message); + + // Alice sends invoice back to Bob + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let (invoice, _) = extract_invoice(bob, &onion_message); + assert_eq!(invoice.amount_msats(), 10_000_000); + + // Extract the payer nonce and payment_id from Bob's reply path context. In a real wallet, + // these would be persisted alongside the payment for later payer proof creation. + let (context_payment_id, payer_nonce) = extract_payer_context(bob, &onion_message); + assert_eq!(context_payment_id, payment_id); + + // Route the payment + route_bolt12_payment(bob, &[alice], &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + // Get the payment preimage from Alice's PaymentClaimable event and claim it. + // In a real wallet, the payer receives the preimage via Event::PaymentSent after the + // recipient claims. For the test, we extract it from the recipient's claimable event. + let payment_preimage = match get_event!(alice, Event::PaymentClaimable) { + Event::PaymentClaimable { purpose, .. } => { + match &purpose { + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { + assert_eq!(payment_context.offer_id, offer.id()); + assert_eq!( + payment_context.invoice_request.payer_signing_pubkey, + invoice_request.payer_signing_pubkey(), + ); + }, + _ => panic!("Expected Bolt12OfferPayment purpose"), + } + purpose.preimage().unwrap() + }, + _ => panic!("Expected Event::PaymentClaimable"), + }; + + claim_payment(bob, &[alice], payment_preimage); + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); + + // --- Payer Proof Creation --- + // Bob (the payer) creates a proof-of-payment with selective disclosure. + // He includes the offer description and invoice amount, but omits other fields for privacy. + let expanded_key = bob.keys_manager.get_expanded_key(); + let secp_ctx = Secp256k1::new(); + let paid_invoice = payer_proof::PaidBolt12Invoice::new( + Bolt12InvoiceType::Bolt12Invoice(invoice.clone()), + payment_preimage, + Some(payer_nonce), + ); + let proof = paid_invoice + .prove_payer_derived(&expanded_key, payment_id, &secp_ctx).unwrap() + .include_offer_description() + .include_invoice_amount() + .include_invoice_created_at() + .build_and_sign(None) + .unwrap(); + + // Check proof contents match the original payment + assert_eq!(proof.payment_preimage(), payment_preimage); + assert_eq!(proof.payment_hash(), invoice.payment_hash()); + assert_eq!(proof.payer_signing_pubkey(), invoice.payer_signing_pubkey()); + assert_eq!(proof.issuer_signing_pubkey(), invoice.signing_pubkey()); + assert!(proof.payer_note().is_none()); + + // --- Serialization Round-Trip --- + // The proof can be serialized to a bech32 string (lnp...) for sharing. + let encoded = proof.to_string(); + assert!(encoded.starts_with("lnp1")); + + // Round-trip through TLV bytes: re-parse the raw bytes (verification happens at parse time). + let decoded = PayerProof::try_from(proof.bytes().to_vec()).unwrap(); + assert_eq!(decoded.payment_preimage(), proof.payment_preimage()); + assert_eq!(decoded.payment_hash(), proof.payment_hash()); + assert_eq!(decoded.payer_signing_pubkey(), proof.payer_signing_pubkey()); + assert_eq!(decoded.issuer_signing_pubkey(), proof.issuer_signing_pubkey()); + assert_eq!(decoded.merkle_root(), proof.merkle_root()); +} + +/// Tests payer proof creation with a payer note, selective disclosure of specific invoice +/// fields, and error cases. Verifies that: +/// - A wrong preimage is rejected +/// - A minimal proof (required fields only) works +/// - Selective disclosure with a payer note works +/// - The proof survives a bech32 round-trip with the note intact +#[test] +fn creates_payer_proof_with_note_and_selective_disclosure() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; + let alice_id = alice.node.get_our_node_id(); + let bob = &nodes[1]; + let bob_id = bob.node.get_our_node_id(); + + // Alice creates an offer with a description + let offer = alice.node + .create_offer_builder().unwrap() + .amount_msats(5_000_000) + .description("Coffee beans - 1kg".into()) + .build().unwrap(); + + // Bob pays for the offer + let payment_id = PaymentId([2; 32]); + bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); + + // Exchange messages + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + let (invoice_request, _) = extract_invoice_request(alice, &onion_message); + + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let (invoice, _) = extract_invoice(bob, &onion_message); + let (context_payment_id, payer_nonce) = extract_payer_context(bob, &onion_message); + assert_eq!(context_payment_id, payment_id); + + // Route and claim the payment, extracting the preimage + route_bolt12_payment(bob, &[alice], &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + let payment_preimage = match get_event!(alice, Event::PaymentClaimable) { + Event::PaymentClaimable { purpose, .. } => { + match &purpose { + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { + assert_eq!(payment_context.offer_id, offer.id()); + assert_eq!( + payment_context.invoice_request.payer_signing_pubkey, + invoice_request.payer_signing_pubkey(), + ); + }, + _ => panic!("Expected Bolt12OfferPayment purpose"), + } + purpose.preimage().unwrap() + }, + _ => panic!("Expected Event::PaymentClaimable"), + }; + + claim_payment(bob, &[alice], payment_preimage); + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); + + // --- Test 1: Wrong preimage is rejected --- + let wrong_preimage = PaymentPreimage([0xDE; 32]); + let wrong_paid = payer_proof::PaidBolt12Invoice::new( + Bolt12InvoiceType::Bolt12Invoice(invoice.clone()), wrong_preimage, Some(payer_nonce), + ); + assert!(matches!(wrong_paid.prove_payer(), Err(PayerProofError::PreimageMismatch))); + + // --- Test 2: Wrong payment_id causes key derivation failure --- + let expanded_key = bob.keys_manager.get_expanded_key(); + let secp_ctx = Secp256k1::new(); + let paid_invoice = payer_proof::PaidBolt12Invoice::new( + Bolt12InvoiceType::Bolt12Invoice(invoice.clone()), + payment_preimage, + Some(payer_nonce), + ); + let wrong_payment_id = PaymentId([0xFF; 32]); + let result = paid_invoice.prove_payer_derived(&expanded_key, wrong_payment_id, &secp_ctx); + assert!(matches!(result, Err(PayerProofError::KeyDerivationFailed))); + + // --- Test 3: Wrong nonce causes key derivation failure --- + let wrong_nonce = Nonce::from_entropy_source(&chanmon_cfgs[0].keys_manager); + let wrong_nonce_paid = payer_proof::PaidBolt12Invoice::new( + Bolt12InvoiceType::Bolt12Invoice(invoice.clone()), payment_preimage, Some(wrong_nonce), + ); + let result = wrong_nonce_paid.prove_payer_derived(&expanded_key, payment_id, &secp_ctx); + assert!(matches!(result, Err(PayerProofError::KeyDerivationFailed))); + + // --- Test 4: Minimal proof (only required fields) --- + let minimal_proof = paid_invoice + .prove_payer_derived(&expanded_key, payment_id, &secp_ctx).unwrap() + .build_and_sign(None) + .unwrap(); + // --- Test 5: Proof with selective disclosure and payer note --- + let proof_with_note = paid_invoice + .prove_payer_derived(&expanded_key, payment_id, &secp_ctx).unwrap() + .include_offer_description() + .include_offer_issuer() + .include_invoice_amount() + .include_invoice_created_at() + .build_and_sign(Some("Paid for coffee".into())) + .unwrap(); + assert_eq!(proof_with_note.payer_note().map(|p| p.0), Some("Paid for coffee")); + + // Both proofs should verify and have the same core fields + assert_eq!(minimal_proof.payment_preimage(), proof_with_note.payment_preimage()); + assert_eq!(minimal_proof.payment_hash(), proof_with_note.payment_hash()); + assert_eq!(minimal_proof.payer_signing_pubkey(), proof_with_note.payer_signing_pubkey()); + assert_eq!(minimal_proof.issuer_signing_pubkey(), proof_with_note.issuer_signing_pubkey()); + + // The merkle roots are the same since both reconstruct from the same invoice + assert_eq!(minimal_proof.merkle_root(), proof_with_note.merkle_root()); + + // --- Test 6: Round-trip the proof with note through TLV bytes --- + let encoded = proof_with_note.to_string(); + assert!(encoded.starts_with("lnp1")); + + let decoded = PayerProof::try_from(proof_with_note.bytes().to_vec()).unwrap(); + assert_eq!(decoded.payer_note().map(|p| p.0), Some("Paid for coffee")); + assert_eq!(decoded.payment_preimage(), payment_preimage); +} diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 43a50ce3afe..3c64c14f111 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -985,6 +985,11 @@ impl Bolt12Invoice { self.signature } + /// The raw serialized bytes of the invoice. + pub(super) fn invoice_bytes(&self) -> &[u8] { + &self.bytes + } + /// Hash that was used for signing the invoice. pub fn signable_hash(&self) -> [u8; 32] { self.tagged_hash.as_digest().as_ref().clone() @@ -1035,9 +1040,6 @@ impl Bolt12Invoice { /// Re-derives the payer's signing keypair for payer proof creation. /// - /// This performs the same key derivation that occurs during invoice request creation - /// with `deriving_signing_pubkey`, allowing the payer to recover their signing keypair. - /// /// The `nonce` and `payment_id` must be the same ones used when creating the original /// invoice request. In the common proof-of-payment flow, callers can instead use /// `PaidBolt12Invoice::prove_payer_derived` together with the `payment_id` from @@ -1557,16 +1559,31 @@ impl TryFrom> for Bolt12Invoice { /// Valid type range for invoice TLV records. pub(super) const INVOICE_TYPES: core::ops::Range = 160..240; +/// TLV record type for the invoice creation timestamp. +pub(super) const INVOICE_CREATED_AT_TYPE: u64 = 164; + +/// TLV record type for [`Bolt12Invoice::payment_hash`]. +pub(super) const INVOICE_PAYMENT_HASH_TYPE: u64 = 168; + +/// TLV record type for [`Bolt12Invoice::amount_msats`]. +pub(super) const INVOICE_AMOUNT_TYPE: u64 = 170; + +/// TLV record type for [`Bolt12Invoice::invoice_features`]. +pub(super) const INVOICE_FEATURES_TYPE: u64 = 174; + +/// TLV record type for [`Bolt12Invoice::signing_pubkey`]. +pub(super) const INVOICE_NODE_ID_TYPE: u64 = 176; + tlv_stream!(InvoiceTlvStream, InvoiceTlvStreamRef<'a>, INVOICE_TYPES, { (160, paths: (Vec, WithoutLength, Iterable<'a, BlindedPathIter<'a>, BlindedPath>)), (162, blindedpay: (Vec, WithoutLength, Iterable<'a, BlindedPayInfoIter<'a>, BlindedPayInfo>)), - (164, created_at: (u64, HighZeroBytesDroppedBigSize)), + (INVOICE_CREATED_AT_TYPE, created_at: (u64, HighZeroBytesDroppedBigSize)), (166, relative_expiry: (u32, HighZeroBytesDroppedBigSize)), - (168, payment_hash: PaymentHash), - (170, amount: (u64, HighZeroBytesDroppedBigSize)), + (INVOICE_PAYMENT_HASH_TYPE, payment_hash: PaymentHash), + (INVOICE_AMOUNT_TYPE, amount: (u64, HighZeroBytesDroppedBigSize)), (172, fallbacks: (Vec, WithoutLength)), - (174, features: (Bolt12InvoiceFeatures, WithoutLength)), - (176, node_id: PublicKey), + (INVOICE_FEATURES_TYPE, features: (Bolt12InvoiceFeatures, WithoutLength)), + (INVOICE_NODE_ID_TYPE, node_id: PublicKey), // Only present in `StaticInvoice`s. (236, held_htlc_available_paths: (Vec, WithoutLength)), }); diff --git a/lightning/src/offers/merkle.rs b/lightning/src/offers/merkle.rs index 1a38fe5441f..4e770222477 100644 --- a/lightning/src/offers/merkle.rs +++ b/lightning/src/offers/merkle.rs @@ -73,6 +73,13 @@ impl TaggedHash { self.merkle_root } + /// Creates a tagged hash from a pre-computed merkle root. + pub(super) fn from_merkle_root(tag: &'static str, merkle_root: sha256::Hash) -> Self { + let tag_hash = sha256::Hash::hash(tag.as_bytes()); + let digest = Message::from_digest(tagged_hash(tag_hash, merkle_root).to_byte_array()); + Self { tag, merkle_root, digest } + } + pub(super) fn to_bytes(&self) -> [u8; 32] { *self.digest.as_ref() } @@ -243,9 +250,23 @@ pub(super) struct TlvRecord<'a> { type_bytes: &'a [u8], // The entire TLV record. pub(super) record_bytes: &'a [u8], + // The value portion of the TLV record (after type and length). + pub(super) value_bytes: &'a [u8], pub(super) end: usize, } +impl<'a> TlvRecord<'a> { + /// Read a value from this TLV record's value bytes using [`Readable`]. + pub(super) fn read_value(&self) -> Result { + let mut value_bytes = self.value_bytes; + let value = Readable::read(&mut value_bytes)?; + if !value_bytes.is_empty() { + return Err(crate::ln::msgs::DecodeError::InvalidValue); + } + Ok(value) + } +} + impl<'a> Iterator for TlvStream<'a> { type Item = TlvRecord<'a>; @@ -261,12 +282,12 @@ impl<'a> Iterator for TlvStream<'a> { let offset = self.data.position(); let end = offset + length; - let _value = &self.data.get_ref()[offset as usize..end as usize]; let record_bytes = &self.data.get_ref()[start as usize..end as usize]; + let value_bytes = &self.data.get_ref()[offset as usize..end as usize]; self.data.set_position(end); - Some(TlvRecord { r#type, type_bytes, record_bytes, end: end as usize }) + Some(TlvRecord { r#type, type_bytes, record_bytes, value_bytes, end: end as usize }) } else { None } @@ -280,9 +301,356 @@ impl<'a> Writeable for TlvRecord<'a> { } } +use alloc::collections::BTreeSet; + +/// Error during selective disclosure operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SelectiveDisclosureError { + /// The omitted markers are not in strict ascending order. + InvalidOmittedMarkersOrder, + /// The omitted markers contain an invalid marker (0 or signature type). + InvalidOmittedMarkersMarker, + /// The leaf_hashes count doesn't match included TLVs. + LeafHashCountMismatch, + /// Insufficient missing_hashes to reconstruct the tree. + InsufficientMissingHashes, + /// The TLV stream is empty. + EmptyTlvStream, +} + +/// Data needed to reconstruct a merkle root with selective disclosure. +/// +/// This is used in payer proofs to allow verification of an invoice signature +/// without revealing all invoice fields. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct SelectiveDisclosure { + /// Nonce hashes for included TLVs (in TLV type order). + pub(super) leaf_hashes: Vec, + /// Marker numbers for omitted TLVs (excluding implicit TLV0). + pub(super) omitted_markers: Vec, + /// Minimal merkle hashes for omitted subtrees. + pub(super) missing_hashes: Vec, + /// The complete merkle root. + pub(super) merkle_root: sha256::Hash, +} + +/// Internal data for each TLV during tree construction. +struct TlvMerkleData { + tlv_type: u64, + per_tlv_hash: sha256::Hash, + is_included: bool, +} + +/// Compute selective disclosure data from a TLV stream. +/// +/// This builds the full merkle tree and extracts the data needed for a payer proof: +/// - `leaf_hashes`: nonce hashes for included TLVs +/// - `omitted_markers`: marker numbers for omitted TLVs +/// - `missing_hashes`: minimal merkle hashes for omitted subtrees +/// +/// # Arguments +/// * `records` - Iterator of [`TlvRecord`]s (non-signature TLVs from the invoice) +/// * `included_types` - Set of TLV types to include in the disclosure +pub(super) fn compute_selective_disclosure<'a>( + records: impl Iterator>, included_types: &BTreeSet, +) -> Result { + let mut records = records.peekable(); + let first_record = records.peek().ok_or(SelectiveDisclosureError::EmptyTlvStream)?; + let nonce_tag_hash = sha256::Hash::from_engine({ + let mut engine = sha256::Hash::engine(); + engine.input("LnNonce".as_bytes()); + engine.input(first_record.record_bytes); + engine + }); + + let leaf_tag = tagged_hash_engine(sha256::Hash::hash("LnLeaf".as_bytes())); + let nonce_tag = tagged_hash_engine(nonce_tag_hash); + let branch_tag = tagged_hash_engine(sha256::Hash::hash("LnBranch".as_bytes())); + + let mut tlv_data: Vec = Vec::new(); + let mut leaf_hashes: Vec = Vec::new(); + for record in records { + let leaf_hash = tagged_hash_from_engine(leaf_tag.clone(), record.record_bytes); + let nonce_hash = tagged_hash_from_engine(nonce_tag.clone(), record.type_bytes); + let per_tlv_hash = + tagged_branch_hash_from_engine(branch_tag.clone(), leaf_hash, nonce_hash); + + let is_included = included_types.contains(&record.r#type); + if is_included { + leaf_hashes.push(nonce_hash); + } + tlv_data.push(TlvMerkleData { tlv_type: record.r#type, per_tlv_hash, is_included }); + } + + if tlv_data.is_empty() { + return Err(SelectiveDisclosureError::EmptyTlvStream); + } + let num_omitted_markers = + tlv_data.iter().filter(|data| !data.is_included && data.tlv_type != 0).count(); + let mut omitted_markers = Vec::with_capacity(num_omitted_markers); + omitted_markers.extend(compute_omitted_markers(tlv_data.iter())); + let (merkle_root, missing_hashes) = build_tree_with_disclosure(&tlv_data, &branch_tag); + + Ok(SelectiveDisclosure { leaf_hashes, omitted_markers, missing_hashes, merkle_root }) +} + +/// Compute omitted markers per BOLT 12 payer proof spec. +/// +/// Each omitted TLV gets a marker equal to `prev_value + 1`, where `prev_value` +/// tracks the last included type or last marker. TLV type 0 is implicitly +/// omitted (never included in markers). +fn compute_omitted_markers<'a>( + tlv_data: impl Iterator + 'a, +) -> impl Iterator + 'a { + tlv_data + .filter(|data| data.tlv_type != 0) + .scan(0u64, |prev_value, data| { + if data.is_included { + *prev_value = data.tlv_type; + Some(None) + } else { + let marker = *prev_value + 1; + *prev_value = marker; + Some(Some(marker)) + } + }) + .flatten() +} + +/// Build merkle tree recursively (DFS, left-to-right) and collect missing_hashes. +/// +/// Per the spec, missing_hashes are in depth-first left-to-right order. +/// +/// Note: a level-by-level approach (as used by `root_hash()`) cannot produce +/// DFS-ordered missing_hashes because it processes all subtrees at each depth +/// simultaneously rather than completing each subtree before the next. +fn build_tree_with_disclosure( + tlv_data: &[TlvMerkleData], branch_tag: &sha256::HashEngine, +) -> (sha256::Hash, Vec) { + debug_assert!(!tlv_data.is_empty(), "TLV stream must contain at least one record"); + + let mut missing_hashes = Vec::new(); + let (root, _) = build_tree_dfs(tlv_data, branch_tag, &mut missing_hashes); + (root, missing_hashes) +} + +fn build_tree_dfs( + tlv_data: &[TlvMerkleData], branch_tag: &sha256::HashEngine, + missing_hashes: &mut Vec, +) -> (sha256::Hash, bool) { + if tlv_data.len() == 1 { + return (tlv_data[0].per_tlv_hash, tlv_data[0].is_included); + } + + let mid = tlv_data.len().next_power_of_two() / 2; + let (left_data, right_data) = tlv_data.split_at(mid); + let (left_hash, left_incl) = build_tree_dfs(left_data, branch_tag, missing_hashes); + let (right_hash, right_incl) = build_tree_dfs(right_data, branch_tag, missing_hashes); + + if left_incl && !right_incl { + missing_hashes.push(right_hash); + } else if !left_incl && right_incl { + missing_hashes.push(left_hash); + } + + let combined = tagged_branch_hash_from_engine(branch_tag.clone(), left_hash, right_hash); + (combined, left_incl || right_incl) +} + +/// Reconstruct merkle root from selective disclosure data. +/// +/// `missing_hashes` must be in DFS (left-to-right recursive traversal) order, +/// matching the order produced by [`build_tree_with_disclosure`]. +pub(super) fn reconstruct_merkle_root( + included_records: &[TlvRecord<'_>], leaf_hashes: &[sha256::Hash], omitted_markers: &[u64], + missing_hashes: &[sha256::Hash], +) -> Result { + debug_assert!(validate_omitted_markers(omitted_markers).is_ok()); + + if included_records.len() != leaf_hashes.len() { + return Err(SelectiveDisclosureError::LeafHashCountMismatch); + } + + let leaf_tag = tagged_hash_engine(sha256::Hash::hash("LnLeaf".as_bytes())); + let branch_tag = tagged_hash_engine(sha256::Hash::hash("LnBranch".as_bytes())); + + // Build per-position hash array: Some(hash) for included positions, None for omitted. + // TLV0 is always at position 0 (implicitly omitted). + let num_nodes = 1 + included_records.len() + omitted_markers.len(); + let mut hashes: Vec> = Vec::with_capacity(num_nodes); + hashes.push(None); // TLV0 always omitted + + let mut inc_idx = 0; + let mut mrk_idx = 0; + let mut prev_marker: u64 = 0; + + while inc_idx < included_records.len() || mrk_idx < omitted_markers.len() { + if mrk_idx >= omitted_markers.len() { + let record = &included_records[inc_idx]; + let leaf_hash = tagged_hash_from_engine(leaf_tag.clone(), record.record_bytes); + let nonce_hash = leaf_hashes[inc_idx]; + hashes.push(Some(tagged_branch_hash_from_engine( + branch_tag.clone(), + leaf_hash, + nonce_hash, + ))); + inc_idx += 1; + } else if inc_idx >= included_records.len() { + hashes.push(None); + prev_marker = omitted_markers[mrk_idx]; + mrk_idx += 1; + } else { + let marker = omitted_markers[mrk_idx]; + let inc_type = included_records[inc_idx].r#type; + if marker == prev_marker + 1 { + hashes.push(None); + prev_marker = marker; + mrk_idx += 1; + } else { + let record = &included_records[inc_idx]; + let leaf_hash = tagged_hash_from_engine(leaf_tag.clone(), record.record_bytes); + let nonce_hash = leaf_hashes[inc_idx]; + hashes.push(Some(tagged_branch_hash_from_engine( + branch_tag.clone(), + leaf_hash, + nonce_hash, + ))); + prev_marker = inc_type; + inc_idx += 1; + } + } + } + + let mut missing_idx: usize = 0; + let root = reconstruct_merkle_root_dfs(&hashes, &branch_tag, missing_hashes, &mut missing_idx)?; + + if missing_idx != missing_hashes.len() { + return Err(SelectiveDisclosureError::InsufficientMissingHashes); + } + + root.ok_or(SelectiveDisclosureError::InsufficientMissingHashes) +} + +fn reconstruct_merkle_root_dfs( + hashes: &[Option], branch_tag: &sha256::HashEngine, + missing_hashes: &[sha256::Hash], missing_idx: &mut usize, +) -> Result, SelectiveDisclosureError> { + if hashes.len() == 1 { + return Ok(hashes[0]); + } + + let mid = hashes.len().next_power_of_two() / 2; + let (left_hashes, right_hashes) = hashes.split_at(mid); + let left = reconstruct_merkle_root_dfs(left_hashes, branch_tag, missing_hashes, missing_idx)?; + let right = reconstruct_merkle_root_dfs(right_hashes, branch_tag, missing_hashes, missing_idx)?; + + match (left, right) { + (None, None) => Ok(None), + (Some(l), None) => { + if *missing_idx >= missing_hashes.len() { + return Err(SelectiveDisclosureError::InsufficientMissingHashes); + } + let r = missing_hashes[*missing_idx]; + *missing_idx += 1; + Ok(Some(tagged_branch_hash_from_engine(branch_tag.clone(), l, r))) + }, + (None, Some(r)) => { + if *missing_idx >= missing_hashes.len() { + return Err(SelectiveDisclosureError::InsufficientMissingHashes); + } + let l = missing_hashes[*missing_idx]; + *missing_idx += 1; + Ok(Some(tagged_branch_hash_from_engine(branch_tag.clone(), l, r))) + }, + (Some(l), Some(r)) => Ok(Some(tagged_branch_hash_from_engine(branch_tag.clone(), l, r))), + } +} + +fn validate_omitted_markers(markers: &[u64]) -> Result<(), SelectiveDisclosureError> { + let mut prev = 0u64; + for &marker in markers { + if marker == 0 { + return Err(SelectiveDisclosureError::InvalidOmittedMarkersMarker); + } + if SIGNATURE_TYPES.contains(&marker) { + return Err(SelectiveDisclosureError::InvalidOmittedMarkersMarker); + } + if marker <= prev { + return Err(SelectiveDisclosureError::InvalidOmittedMarkersOrder); + } + prev = marker; + } + Ok(()) +} + +/// Reconstruct position inclusion map from included types and omitted markers. +/// +/// This reverses the marker encoding algorithm from `compute_omitted_markers`: +/// - Markers form "runs" of consecutive values (e.g., [11, 12] is a run) +/// - A "jump" in markers (e.g., 12 → 41) indicates an included TLV came between +/// - After included type X, the next marker in that run equals X + 1 +/// +/// The algorithm tracks `prev_marker` to detect continuations vs jumps: +/// - If `marker == prev_marker + 1`: continuation → omitted position +/// - Otherwise: jump → included position comes first, then process marker as continuation +/// +/// Example: included=[10, 40], markers=[11, 12, 41, 42] +/// - Position 0: TLV0 (always omitted) +/// - marker=11, prev=0: 11 != 1, jump! Insert included (10), prev=10 +/// - marker=11, prev=10: 11 == 11, continuation → omitted, prev=11 +/// - marker=12, prev=11: 12 == 12, continuation → omitted, prev=12 +/// - marker=41, prev=12: 41 != 13, jump! Insert included (40), prev=40 +/// - marker=41, prev=40: 41 == 41, continuation → omitted, prev=41 +/// - marker=42, prev=41: 42 == 42, continuation → omitted, prev=42 +/// Result: [O, I, O, O, I, O, O] +#[cfg(test)] +fn reconstruct_positions(included_types: &[u64], omitted_markers: &[u64]) -> Vec { + let total = 1 + included_types.len() + omitted_markers.len(); + let mut positions = Vec::with_capacity(total); + positions.push(false); // TLV0 is always omitted + + let mut inc_idx = 0; + let mut mrk_idx = 0; + // After TLV0 (implicit marker 0), next continuation would be marker 1 + let mut prev_marker: u64 = 0; + + while inc_idx < included_types.len() || mrk_idx < omitted_markers.len() { + if mrk_idx >= omitted_markers.len() { + // No more markers, remaining positions are included + positions.push(true); + inc_idx += 1; + } else if inc_idx >= included_types.len() { + // No more included types, remaining positions are omitted + positions.push(false); + prev_marker = omitted_markers[mrk_idx]; + mrk_idx += 1; + } else { + let marker = omitted_markers[mrk_idx]; + let inc_type = included_types[inc_idx]; + + if marker == prev_marker + 1 { + // Continuation of current run → this position is omitted + positions.push(false); + prev_marker = marker; + mrk_idx += 1; + } else { + // Jump detected! An included TLV comes before this marker. + // After the included type, prev_marker resets to that type, + // so the marker will be processed as a continuation next iteration. + positions.push(true); + prev_marker = inc_type; + inc_idx += 1; + // Don't advance mrk_idx - same marker will be continuation next + } + } + } + + positions +} + #[cfg(test)] mod tests { - use super::{TlvStream, SIGNATURE_TYPES}; + use super::{TlvRecord, TlvStream, SIGNATURE_TYPES}; use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::ExpandedKey; @@ -497,4 +865,211 @@ mod tests { self.fmt_bech32_str(f) } } + + /// Test reconstruct_positions with the BOLT 12 payer proof spec example. + /// + /// TLVs: 0(omit), 10(incl), 20(omit), 30(omit), 40(incl), 50(omit), 60(omit) + /// Markers: [11, 12, 41, 42] + /// Expected positions: [O, I, O, O, I, O, O] + #[test] + fn test_reconstruct_positions_spec_example() { + let included_types = vec![10, 40]; + let markers = vec![11, 12, 41, 42]; + let positions = super::reconstruct_positions(&included_types, &markers); + assert_eq!(positions, vec![false, true, false, false, true, false, false]); + } + + /// Test reconstruct_positions when there are omitted TLVs before the first included. + /// + /// TLVs: 0(omit), 5(omit), 10(incl), 20(omit) + /// Markers: [1, 11] (1 is first omitted after TLV0, 11 is after included 10) + /// Expected positions: [O, O, I, O] + #[test] + fn test_reconstruct_positions_omitted_before_included() { + let included_types = vec![10]; + let markers = vec![1, 11]; + let positions = super::reconstruct_positions(&included_types, &markers); + assert_eq!(positions, vec![false, false, true, false]); + } + + /// Test reconstruct_positions with only included TLVs (no omitted except TLV0). + /// + /// TLVs: 0(omit), 10(incl), 20(incl) + /// Markers: [] (no omitted TLVs after TLV0) + /// Expected positions: [O, I, I] + #[test] + fn test_reconstruct_positions_no_omitted() { + let included_types = vec![10, 20]; + let markers = vec![]; + let positions = super::reconstruct_positions(&included_types, &markers); + assert_eq!(positions, vec![false, true, true]); + } + + /// Test reconstruct_positions with only omitted TLVs (no included). + /// + /// TLVs: 0(omit), 5(omit), 10(omit) + /// Markers: [1, 2] (consecutive omitted after TLV0) + /// Expected positions: [O, O, O] + #[test] + fn test_reconstruct_positions_no_included() { + let included_types = vec![]; + let markers = vec![1, 2]; + let positions = super::reconstruct_positions(&included_types, &markers); + assert_eq!(positions, vec![false, false, false]); + } + + /// Test round-trip: compute selective disclosure then reconstruct merkle root. + #[test] + fn test_selective_disclosure_round_trip() { + use alloc::collections::BTreeSet; + + // Build TLV stream matching spec example structure + // TLVs: 0, 10, 20, 30, 40, 50, 60 + let mut tlv_bytes = Vec::new(); + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // TLV 0 + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); // TLV 10 + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); // TLV 20 + tlv_bytes.extend_from_slice(&[0x1e, 0x02, 0x00, 0x00]); // TLV 30 + tlv_bytes.extend_from_slice(&[0x28, 0x02, 0x00, 0x00]); // TLV 40 + tlv_bytes.extend_from_slice(&[0x32, 0x02, 0x00, 0x00]); // TLV 50 + tlv_bytes.extend_from_slice(&[0x3c, 0x02, 0x00, 0x00]); // TLV 60 + + // Include types 10 and 40 + let mut included = BTreeSet::new(); + included.insert(10); + included.insert(40); + + // Compute selective disclosure + let disclosure = + super::compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included).unwrap(); + + // Verify markers match spec example + assert_eq!(disclosure.omitted_markers, vec![11, 12, 41, 42]); + + // Verify leaf_hashes count matches included TLVs + assert_eq!(disclosure.leaf_hashes.len(), 2); + + // Collect included records for reconstruction + let included_records: Vec> = + TlvStream::new(&tlv_bytes).filter(|r| included.contains(&r.r#type)).collect(); + + // Reconstruct merkle root + let reconstructed = super::reconstruct_merkle_root( + &included_records, + &disclosure.leaf_hashes, + &disclosure.omitted_markers, + &disclosure.missing_hashes, + ) + .unwrap(); + + // Must match original + assert_eq!(reconstructed, disclosure.merkle_root); + } + + /// Test that the synthetic 7-node example still requires four missing hashes. + /// + /// For the synthetic tree with TLVs [0(o), 10(I), 20(o), 30(o), 40(I), 50(o), 60(o)]: + /// - hash(0) covers type 0 + /// - hash(B(20,30)) covers types 20-30 + /// - hash(50) covers type 50 + /// - hash(60) covers type 60 + /// + /// This still needs 4 missing hashes. The DFS-ordering fix changes the order + /// they are emitted and consumed in, but not the count for this tree shape. + #[test] + fn test_missing_hashes_for_synthetic_tree() { + use alloc::collections::BTreeSet; + + // Build TLV stream: 0, 10, 20, 30, 40, 50, 60 + let mut tlv_bytes = Vec::new(); + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // TLV 0 + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); // TLV 10 + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); // TLV 20 + tlv_bytes.extend_from_slice(&[0x1e, 0x02, 0x00, 0x00]); // TLV 30 + tlv_bytes.extend_from_slice(&[0x28, 0x02, 0x00, 0x00]); // TLV 40 + tlv_bytes.extend_from_slice(&[0x32, 0x02, 0x00, 0x00]); // TLV 50 + tlv_bytes.extend_from_slice(&[0x3c, 0x02, 0x00, 0x00]); // TLV 60 + + // Include types 10 and 40 (same as spec example) + let mut included = BTreeSet::new(); + included.insert(10); + included.insert(40); + + let disclosure = + super::compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included).unwrap(); + + // We should still have 4 missing hashes for omitted types: + // - type 0 (single leaf) + // - types 20+30 (combined branch) + // - type 50 (single leaf) + // - type 60 (single leaf) + assert_eq!( + disclosure.missing_hashes.len(), + 4, + "Expected 4 missing hashes for omitted types [0, 20+30, 50, 60]" + ); + + // Verify the round-trip still works with the correct ordering + let included_records: Vec> = + TlvStream::new(&tlv_bytes).filter(|r| included.contains(&r.r#type)).collect(); + + let reconstructed = super::reconstruct_merkle_root( + &included_records, + &disclosure.leaf_hashes, + &disclosure.omitted_markers, + &disclosure.missing_hashes, + ) + .unwrap(); + + assert_eq!(reconstructed, disclosure.merkle_root); + } + + /// Test that reconstruction fails with wrong number of missing_hashes. + #[test] + fn test_reconstruction_fails_with_wrong_missing_hashes() { + use alloc::collections::BTreeSet; + + let mut tlv_bytes = Vec::new(); + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // TLV 0 + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); // TLV 10 + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); // TLV 20 + + let mut included = BTreeSet::new(); + included.insert(10); + + let disclosure = + super::compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included).unwrap(); + + let included_records: Vec> = + TlvStream::new(&tlv_bytes).filter(|r| included.contains(&r.r#type)).collect(); + + // Try with empty missing_hashes (should fail) + let result = super::reconstruct_merkle_root( + &included_records, + &disclosure.leaf_hashes, + &disclosure.omitted_markers, + &[], // Wrong! + ); + + assert!(result.is_err()); + } + + #[test] + fn test_tlv_record_read_value_rejects_trailing_bytes() { + use bitcoin::secp256k1::PublicKey; + + use crate::offers::test_utils::payer_pubkey; + use crate::util::ser::{BigSize, Writeable}; + + let pubkey = payer_pubkey(); + let mut tlv_bytes = Vec::new(); + BigSize(88).write(&mut tlv_bytes).unwrap(); + BigSize(35).write(&mut tlv_bytes).unwrap(); + pubkey.write(&mut tlv_bytes).unwrap(); + tlv_bytes.extend_from_slice(&[0x00, 0x01]); + + let record = TlvStream::new(&tlv_bytes).next().unwrap(); + let result: Result = record.read_value(); + assert!(matches!(result, Err(crate::ln::msgs::DecodeError::InvalidValue))); + } } diff --git a/lightning/src/offers/mod.rs b/lightning/src/offers/mod.rs index 5b5cf6cdc78..bbbf91a1f1c 100644 --- a/lightning/src/offers/mod.rs +++ b/lightning/src/offers/mod.rs @@ -25,6 +25,7 @@ pub mod merkle; pub mod nonce; pub mod parse; mod payer; +pub mod payer_proof; pub mod refund; pub(crate) mod signer; pub mod static_invoice; diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index b2703454169..7cc5754bd61 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -1211,6 +1211,12 @@ pub(super) const OFFER_TYPES: core::ops::Range = 1..80; /// TLV record type for [`Offer::metadata`]. const OFFER_METADATA_TYPE: u64 = 4; +/// TLV record type for [`Offer::description`]. +pub(super) const OFFER_DESCRIPTION_TYPE: u64 = 10; + +/// TLV record type for [`Offer::issuer`]. +pub(super) const OFFER_ISSUER_TYPE: u64 = 18; + /// TLV record type for [`Offer::issuer_signing_pubkey`]. const OFFER_ISSUER_ID_TYPE: u64 = 22; @@ -1219,11 +1225,11 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef<'a>, OFFER_TYPES, { (OFFER_METADATA_TYPE, metadata: (Vec, WithoutLength)), (6, currency: [u8; 3]), (8, amount: (u64, HighZeroBytesDroppedBigSize)), - (10, description: (String, WithoutLength)), + (OFFER_DESCRIPTION_TYPE, description: (String, WithoutLength)), (12, features: (OfferFeatures, WithoutLength)), (14, absolute_expiry: (u64, HighZeroBytesDroppedBigSize)), (16, paths: (Vec, WithoutLength)), - (18, issuer: (String, WithoutLength)), + (OFFER_ISSUER_TYPE, issuer: (String, WithoutLength)), (20, quantity_max: (u64, HighZeroBytesDroppedBigSize)), (OFFER_ISSUER_ID_TYPE, issuer_id: PublicKey), }); diff --git a/lightning/src/offers/payer_proof.rs b/lightning/src/offers/payer_proof.rs new file mode 100644 index 00000000000..cc1274a903e --- /dev/null +++ b/lightning/src/offers/payer_proof.rs @@ -0,0 +1,2038 @@ +// 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. + +//! Payer proofs for BOLT 12 invoices. +//! +//! A [`PayerProof`] cryptographically proves that a BOLT 12 invoice was paid by demonstrating: +//! - Possession of the payment preimage (proving the payment occurred) +//! - A valid invoice signature over a merkle root (proving the invoice is authentic) +//! - The payer's signature (proving who authorized the payment) +//! +//! This implements the payer proof extension to BOLT 12 as specified in +//! . + +use alloc::collections::BTreeSet; + +use crate::io; +use crate::ln::channelmanager::PaymentId; +use crate::ln::inbound_payment::ExpandedKey; +use crate::ln::msgs::DecodeError; +use crate::offers::invoice::{ + Bolt12Invoice, DerivedSigningPubkey, ExperimentalInvoiceTlvStream, ExplicitSigningPubkey, + InvoiceTlvStream, SigningPubkeyStrategy, INVOICE_AMOUNT_TYPE, INVOICE_CREATED_AT_TYPE, + INVOICE_FEATURES_TYPE, INVOICE_NODE_ID_TYPE, INVOICE_PAYMENT_HASH_TYPE, SIGNATURE_TAG, +}; +use crate::offers::invoice_request::{ + ExperimentalInvoiceRequestTlvStream, InvoiceRequestTlvStream, INVOICE_REQUEST_PAYER_ID_TYPE, +}; +use crate::offers::merkle::{ + self, SelectiveDisclosure, SelectiveDisclosureError, SignError, TaggedHash, TlvRecord, + TlvStream, SIGNATURE_TYPES, +}; +use crate::offers::nonce::Nonce; +use crate::offers::offer::{ + ExperimentalOfferTlvStream, OfferTlvStream, EXPERIMENTAL_OFFER_TYPES, OFFER_DESCRIPTION_TYPE, + OFFER_ISSUER_TYPE, +}; +use crate::offers::parse::{Bech32Encode, Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; +use crate::offers::payer::PAYER_METADATA_TYPE; +use crate::offers::static_invoice::StaticInvoice; +use crate::types::payment::{PaymentHash, PaymentPreimage}; +use crate::util::ser::{ + BigSize, CursorReadable, HighZeroBytesDroppedBigSize, Readable, WithoutLength, Writeable, + Writer, +}; +use lightning_types::string::PrintableString; + +use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::secp256k1; +use bitcoin::secp256k1::schnorr::Signature; +use bitcoin::secp256k1::{PublicKey, Secp256k1}; + +use core::convert::TryFrom; +use core::time::Duration; + +#[allow(unused_imports)] +use crate::prelude::*; + +/// The type of BOLT 12 invoice that was paid. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Bolt12InvoiceType { + /// A standard BOLT 12 invoice, allowing proof of payment. + Bolt12Invoice(Bolt12Invoice), + /// A static invoice used in async payments, where proof of payment is not possible. + StaticInvoice(StaticInvoice), +} + +impl_writeable_tlv_based_enum!(Bolt12InvoiceType, + {0, Bolt12Invoice} => (), + {2, StaticInvoice} => (), +); + +/// A paid BOLT 12 invoice with the data needed to construct payer proofs. +/// +/// For standard [`Bolt12Invoice`] payments, use [`Self::prove_payer`] or +/// [`Self::prove_payer_derived`] to build a [`PayerProof`] that selectively discloses +/// invoice fields to a third-party verifier. +/// +/// For async payments (i.e., [`StaticInvoice`]), payer proofs are not supported and those +/// methods will return [`PayerProofError::IncompatibleInvoice`]. +/// +/// Surfaced in [`Event::PaymentSent::bolt12_invoice`]. +/// +/// [`Event::PaymentSent::bolt12_invoice`]: crate::events::Event::PaymentSent::bolt12_invoice +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PaidBolt12Invoice { + invoice: Bolt12InvoiceType, + preimage: PaymentPreimage, + nonce: Option, +} + +impl PaidBolt12Invoice { + pub(crate) fn new( + invoice: Bolt12InvoiceType, preimage: PaymentPreimage, nonce: Option, + ) -> Self { + Self { invoice, preimage, nonce } + } + + /// The payment preimage proving the invoice was paid. + pub fn payment_preimage(&self) -> PaymentPreimage { + self.preimage + } + + pub(crate) fn invoice_type(&self) -> &Bolt12InvoiceType { + &self.invoice + } + + pub(crate) fn nonce(&self) -> Option { + self.nonce + } + + /// Returns the [`Bolt12Invoice`] if the payment was for a standard BOLT 12 invoice. + pub fn bolt12_invoice(&self) -> Option<&Bolt12Invoice> { + match &self.invoice { + Bolt12InvoiceType::Bolt12Invoice(invoice) => Some(invoice), + _ => None, + } + } + + /// Returns the [`StaticInvoice`] if the payment was for an async payment. + pub fn static_invoice(&self) -> Option<&StaticInvoice> { + match &self.invoice { + Bolt12InvoiceType::StaticInvoice(invoice) => Some(invoice), + _ => None, + } + } + + /// Creates a [`PayerProofBuilder`] for this paid invoice. + pub fn prove_payer( + &self, + ) -> Result, PayerProofError> { + let invoice = self.bolt12_invoice().ok_or(PayerProofError::IncompatibleInvoice)?; + PayerProofBuilder::new(invoice, self.preimage) + } + + /// Creates a [`PayerProofBuilder`] with a pre-derived signing keypair. + /// + /// This re-derives the payer signing key, failing early if derivation fails. + pub fn prove_payer_derived( + &self, expanded_key: &ExpandedKey, payment_id: PaymentId, secp_ctx: &Secp256k1, + ) -> Result, PayerProofError> { + // Check invoice type first: a `StaticInvoice` never carries a `Nonce`, so checking + // `nonce` before the invoice type would surface a misleading `KeyDerivationFailed` + // error instead of `IncompatibleInvoice`. + let invoice = self.bolt12_invoice().ok_or(PayerProofError::IncompatibleInvoice)?; + let nonce = self.nonce.ok_or(PayerProofError::KeyDerivationFailed)?; + PayerProofBuilder::new_derived( + invoice, + self.preimage, + expanded_key, + nonce, + payment_id, + secp_ctx, + ) + } +} + +const PAYER_PROOF_SIGNATURE_TYPE: u64 = 240; +const PAYER_PROOF_PREIMAGE_TYPE: u64 = 242; +const PAYER_PROOF_OMITTED_TLVS_TYPE: u64 = 244; +const PAYER_PROOF_MISSING_HASHES_TYPE: u64 = 246; +const PAYER_PROOF_LEAF_HASHES_TYPE: u64 = 248; +const PAYER_PROOF_PAYER_SIGNATURE_TYPE: u64 = 250; + +/// Human-readable prefix for payer proofs in bech32 encoding. +pub const PAYER_PROOF_HRP: &str = "lnp"; + +/// Tag for payer signature computation per BOLT 12 signature calculation. +/// Format: "lightning" || messagename || fieldname +const PAYER_SIGNATURE_TAG: &str = concat!("lightning", "payer_proof", "payer_signature"); + +/// Error when building or verifying a payer proof. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PayerProofError { + /// The invoice is not a [`Bolt12Invoice`] (e.g., it is a [`StaticInvoice`]). + /// + /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice + IncompatibleInvoice, + /// The preimage doesn't match the invoice's payment hash. + PreimageMismatch, + /// Error during merkle tree operations. + MerkleError(SelectiveDisclosureError), + /// The invoice signature is invalid. + InvalidInvoiceSignature, + /// Failed to re-derive the payer signing key from the provided nonce and payment ID. + KeyDerivationFailed, + /// `PAYER_METADATA_TYPE` (type 0) cannot be included (per spec). + PayerMetadataNotAllowed, + /// TLV types in the signature/payer-proof range (`SIGNATURE_TYPES`, i.e. + /// `240..=1000`) or in the unsupported gap between `SIGNATURE_TYPES` and + /// `EXPERIMENTAL_OFFER_TYPES` cannot be included. + DisallowedTlvType, + + /// Error decoding the payer proof. + DecodeError(DecodeError), +} + +impl From for PayerProofError { + fn from(e: SelectiveDisclosureError) -> Self { + PayerProofError::MerkleError(e) + } +} + +impl From for PayerProofError { + fn from(e: DecodeError) -> Self { + PayerProofError::DecodeError(e) + } +} + +/// A cryptographic proof that a BOLT 12 invoice was paid. +/// +/// Contains the payment preimage, selective disclosure of invoice fields, +/// the invoice signature, and a payer signature proving who paid. +#[derive(Clone, Debug)] +pub struct PayerProof { + bytes: Vec, + contents: PayerProofContents, + merkle_root: sha256::Hash, +} + +#[derive(Clone, Debug)] +struct PayerProofContents { + payer_signing_pubkey: PublicKey, + payment_hash: PaymentHash, + issuer_signing_pubkey: PublicKey, + preimage: PaymentPreimage, + invoice_signature: Signature, + payer_signature_tlv: PayerSignatureWithNote, + disclosed_fields: DisclosedFields, +} + +#[derive(Clone, Debug, Default)] +struct DisclosedFields { + offer_description: Option, + offer_issuer: Option, + invoice_amount_msats: Option, + invoice_created_at: Option, +} + +/// Builds a [`PayerProof`] from a paid invoice and its preimage. +/// +/// By default, only the required fields are included ([`payer_signing_pubkey`], +/// [`payment_hash`], [`issuer_signing_pubkey`]). Additional fields can be included for +/// selective disclosure using the `include_*` methods. +/// +/// [`payer_signing_pubkey`]: PayerProof::payer_signing_pubkey +/// [`payment_hash`]: PayerProof::payment_hash +/// [`issuer_signing_pubkey`]: PayerProof::issuer_signing_pubkey +pub struct PayerProofBuilder<'a, S: SigningPubkeyStrategy> { + invoice: &'a Bolt12Invoice, + preimage: PaymentPreimage, + included_types: BTreeSet, + signing_strategy: S, +} + +/// The default set of TLV types always included in a payer proof: payer_id, +/// payment_hash, issuer signing pubkey, and invoice features when present. +fn default_included_types(invoice: &Bolt12Invoice) -> BTreeSet { + let mut types = BTreeSet::new(); + types.insert(INVOICE_REQUEST_PAYER_ID_TYPE); + types.insert(INVOICE_PAYMENT_HASH_TYPE); + types.insert(INVOICE_NODE_ID_TYPE); + if TlvStream::new(invoice.invoice_bytes()).any(|r| r.r#type == INVOICE_FEATURES_TYPE) { + types.insert(INVOICE_FEATURES_TYPE); + } + types +} + +impl<'a> PayerProofBuilder<'a, ExplicitSigningPubkey> { + /// Create a new builder from an invoice and its payment preimage. + /// + /// Returns an error if the preimage doesn't match the invoice's payment hash. + pub(super) fn new( + invoice: &'a Bolt12Invoice, preimage: PaymentPreimage, + ) -> Result { + let computed_hash: PaymentHash = preimage.into(); + if computed_hash != invoice.payment_hash() { + return Err(PayerProofError::PreimageMismatch); + } + + Ok(Self { + invoice, + preimage, + included_types: default_included_types(invoice), + signing_strategy: ExplicitSigningPubkey {}, + }) + } + + /// Builds an [`UnsignedPayerProof`] that can be signed with [`UnsignedPayerProof::sign`]. + /// + /// `payer_note` is an optional note scoped to this proof that is committed to by the + /// payer signature. It is independent of any [`InvoiceRequest::payer_note`] set during + /// the payment flow. + /// + /// [`InvoiceRequest::payer_note`]: crate::offers::invoice_request::InvoiceRequest::payer_note + pub fn build( + self, payer_note: Option, + ) -> Result, PayerProofError> { + self.build_unsigned(payer_note) + } +} + +impl<'a> PayerProofBuilder<'a, DerivedSigningPubkey> { + /// Create a new builder with a pre-derived signing keypair. + /// + /// Derives the payer signing key using the same derivation scheme as invoice requests + /// created with `deriving_signing_pubkey`. Fails early if key derivation fails. + fn new_derived( + invoice: &'a Bolt12Invoice, preimage: PaymentPreimage, expanded_key: &ExpandedKey, + nonce: Nonce, payment_id: PaymentId, secp_ctx: &Secp256k1, + ) -> Result { + let computed_hash = sha256::Hash::hash(&preimage.0); + if computed_hash.as_byte_array() != &invoice.payment_hash().0 { + return Err(PayerProofError::PreimageMismatch); + } + + let keys = invoice + .derive_payer_signing_keys(payment_id, nonce, expanded_key, secp_ctx) + .map_err(|_| PayerProofError::KeyDerivationFailed)?; + + Ok(Self { + invoice, + preimage, + included_types: default_included_types(invoice), + signing_strategy: DerivedSigningPubkey(keys), + }) + } + + /// Builds and signs a [`PayerProof`] using the keypair derived at construction time. + /// + /// `payer_note` is an optional note scoped to this proof that is committed to by the + /// payer signature. It is independent of any [`InvoiceRequest::payer_note`] set during + /// the payment flow. + /// + /// [`InvoiceRequest::payer_note`]: crate::offers::invoice_request::InvoiceRequest::payer_note + pub fn build_and_sign(self, payer_note: Option) -> Result { + let secp_ctx = Secp256k1::signing_only(); + let keys = self.signing_strategy.0; + let unsigned = self.build_unsigned(payer_note)?; + // Signing with a derived keypair and an infallible closure cannot fail: + // the signing function never errors and verification succeeds because we + // derived the matching pubkey. + let proof = unsigned + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &keys)) + }) + .expect("signing with derived keys and infallible closure cannot fail"); + Ok(proof) + } +} + +impl<'a, S: SigningPubkeyStrategy> PayerProofBuilder<'a, S> { + /// Include a specific TLV type in the proof. + /// + /// Returns an error if the type is not allowed: `PAYER_METADATA_TYPE`, types in + /// `SIGNATURE_TYPES`, or types in the unsupported gap below + /// `EXPERIMENTAL_OFFER_TYPES.start`. + pub fn include_type(mut self, tlv_type: u64) -> Result { + if tlv_type == PAYER_METADATA_TYPE { + return Err(PayerProofError::PayerMetadataNotAllowed); + } + if SIGNATURE_TYPES.contains(&tlv_type) + || (tlv_type > *SIGNATURE_TYPES.end() && tlv_type < EXPERIMENTAL_OFFER_TYPES.start) + { + return Err(PayerProofError::DisallowedTlvType); + } + self.included_types.insert(tlv_type); + Ok(self) + } + + /// Include the offer description in the proof. + pub fn include_offer_description(mut self) -> Self { + self.included_types.insert(OFFER_DESCRIPTION_TYPE); + self + } + + /// Include the offer issuer in the proof. + pub fn include_offer_issuer(mut self) -> Self { + self.included_types.insert(OFFER_ISSUER_TYPE); + self + } + + /// Include the invoice amount in the proof. + pub fn include_invoice_amount(mut self) -> Self { + self.included_types.insert(INVOICE_AMOUNT_TYPE); + self + } + + /// Include the invoice creation timestamp in the proof. + pub fn include_invoice_created_at(mut self) -> Self { + self.included_types.insert(INVOICE_CREATED_AT_TYPE); + self + } + + fn build_unsigned( + self, payer_note: Option, + ) -> Result, PayerProofError> { + let invoice_bytes = self.invoice.invoice_bytes(); + let disclosed_fields = + DisclosedFields::from_records(TlvStream::new(invoice_bytes).filter(|r| { + self.included_types.contains(&r.r#type) && !SIGNATURE_TYPES.contains(&r.r#type) + }))?; + + let disclosure = merkle::compute_selective_disclosure( + TlvStream::new(invoice_bytes).filter(|r| !SIGNATURE_TYPES.contains(&r.r#type)), + &self.included_types, + )?; + + let invoice_signature = self.invoice.signature(); + + let tagged_hash = payer_signature_hash(payer_note.as_deref(), &disclosure.merkle_root); + + Ok(UnsignedPayerProof { + invoice_signature, + preimage: self.preimage, + payer_signing_pubkey: self.invoice.payer_signing_pubkey(), + payment_hash: self.invoice.payment_hash().clone(), + issuer_signing_pubkey: self.invoice.signing_pubkey(), + invoice_bytes, + included_types: self.included_types, + disclosed_fields, + disclosure, + payer_note, + tagged_hash, + }) + } +} + +/// Computes the [`TaggedHash`] for a payer proof signature. +/// +/// The payer signature is computed over `H(tag||tag||H(note||merkle_root))`. The inner +/// hash `H(note||merkle_root)` serves as the "merkle root" for [`TaggedHash::from_merkle_root`]. +fn payer_signature_hash(note: Option<&str>, merkle_root: &sha256::Hash) -> TaggedHash { + let mut engine = sha256::Hash::engine(); + if let Some(n) = note { + engine.input(n.as_bytes()); + } + engine.input(merkle_root.as_ref()); + let inner_hash = sha256::Hash::from_engine(engine); + + TaggedHash::from_merkle_root(PAYER_SIGNATURE_TAG, inner_hash) +} + +/// An unsigned [`PayerProof`] ready for signing. +pub struct UnsignedPayerProof<'a> { + invoice_signature: Signature, + preimage: PaymentPreimage, + payer_signing_pubkey: PublicKey, + payment_hash: PaymentHash, + issuer_signing_pubkey: PublicKey, + invoice_bytes: &'a [u8], + included_types: BTreeSet, + disclosed_fields: DisclosedFields, + disclosure: SelectiveDisclosure, + payer_note: Option, + tagged_hash: TaggedHash, +} + +impl AsRef for UnsignedPayerProof<'_> { + fn as_ref(&self) -> &TaggedHash { + &self.tagged_hash + } +} + +/// A function for signing an [`UnsignedPayerProof`]. +pub trait SignPayerProofFn { + /// Signs a [`TaggedHash`] computed over the payer note and the invoice's merkle root. + fn sign_payer_proof(&self, message: &UnsignedPayerProof) -> Result; +} + +impl SignPayerProofFn for F +where + F: Fn(&UnsignedPayerProof) -> Result, +{ + fn sign_payer_proof(&self, message: &UnsignedPayerProof) -> Result { + self(message) + } +} + +impl merkle::SignFn> for F +where + F: SignPayerProofFn, +{ + fn sign(&self, message: &UnsignedPayerProof) -> Result { + self.sign_payer_proof(message) + } +} + +/// Compound value for the payer signature TLV (type 250): a schnorr signature +/// followed by optional UTF-8 note bytes. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct PayerSignatureWithNote { + signature: Signature, + note: Option, +} + +impl PayerSignatureWithNote { + fn signature(&self) -> &Signature { + &self.signature + } + + fn note(&self) -> Option<&str> { + self.note.as_deref() + } +} + +impl Readable for PayerSignatureWithNote { + fn read(r: &mut R) -> Result { + let signature = Readable::read(r)?; + let note_bytes = crate::io_extras::read_to_end(r).map_err(|_| DecodeError::ShortRead)?; + let note = if note_bytes.is_empty() { + None + } else { + Some(String::from_utf8(note_bytes).map_err(|_| DecodeError::InvalidValue)?) + }; + + Ok(Self { signature, note }) + } +} + +impl Writeable for PayerSignatureWithNote { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.signature.write(w)?; + w.write_all(self.note.as_deref().map(str::as_bytes).unwrap_or(&[])) + } +} + +tlv_stream!( + PayerProofTlvStream, PayerProofTlvStreamRef<'a>, SIGNATURE_TYPES, { + (PAYER_PROOF_SIGNATURE_TYPE, invoice_signature: Signature), + (PAYER_PROOF_PREIMAGE_TYPE, preimage: PaymentPreimage), + (PAYER_PROOF_OMITTED_TLVS_TYPE, omitted_markers: (Vec, WithoutLength)), + (PAYER_PROOF_MISSING_HASHES_TYPE, missing_hashes: (Vec, WithoutLength)), + (PAYER_PROOF_LEAF_HASHES_TYPE, leaf_hashes: (Vec, WithoutLength)), + (PAYER_PROOF_PAYER_SIGNATURE_TYPE, payer_signature: PayerSignatureWithNote), + } +); + +type FullPayerProofTlvStream = ( + OfferTlvStream, + InvoiceRequestTlvStream, + InvoiceTlvStream, + PayerProofTlvStream, + ExperimentalOfferTlvStream, + ExperimentalInvoiceRequestTlvStream, + ExperimentalInvoiceTlvStream, +); + +impl CursorReadable for FullPayerProofTlvStream { + fn read>(r: &mut io::Cursor) -> Result { + let offer = CursorReadable::read(r)?; + let invoice_request = CursorReadable::read(r)?; + let invoice = CursorReadable::read(r)?; + let payer_proof = CursorReadable::read(r)?; + let experimental_offer = CursorReadable::read(r)?; + let experimental_invoice_request = CursorReadable::read(r)?; + let experimental_invoice = CursorReadable::read(r)?; + + Ok(( + offer, + invoice_request, + invoice, + payer_proof, + experimental_offer, + experimental_invoice_request, + experimental_invoice, + )) + } +} + +impl UnsignedPayerProof<'_> { + /// Signs the [`UnsignedPayerProof`] using the given function. + pub fn sign(mut self, sign: F) -> Result { + let pubkey = self.payer_signing_pubkey; + let payer_signature = merkle::sign_message(sign, &self, pubkey)?; + let payer_signature_tlv = + PayerSignatureWithNote { signature: payer_signature, note: self.payer_note.take() }; + + let bytes = self.serialize_payer_proof(&payer_signature_tlv); + + Ok(PayerProof { + bytes, + contents: PayerProofContents { + payer_signing_pubkey: self.payer_signing_pubkey, + payment_hash: self.payment_hash, + issuer_signing_pubkey: self.issuer_signing_pubkey, + preimage: self.preimage, + invoice_signature: self.invoice_signature, + payer_signature_tlv, + disclosed_fields: self.disclosed_fields, + }, + merkle_root: self.disclosure.merkle_root, + }) + } + + fn serialize_payer_proof(&self, payer_signature: &PayerSignatureWithNote) -> Vec { + const PAYER_PROOF_ALLOCATION_SIZE: usize = 512; + let mut bytes = Vec::with_capacity(PAYER_PROOF_ALLOCATION_SIZE); + + // Preserve TLV ordering by emitting included invoice records below the + // payer-proof range first, then payer-proof TLVs (240..=250), then any + // disclosed experimental invoice records above the reserved range. + for record in TlvStream::new(&self.invoice_bytes) + .range(0..PAYER_PROOF_SIGNATURE_TYPE) + .filter(|r| self.included_types.contains(&r.r#type)) + { + bytes.extend_from_slice(record.record_bytes); + } + + let omitted_markers = (!self.disclosure.omitted_markers.is_empty()).then(|| { + self.disclosure.omitted_markers.iter().copied().map(BigSize).collect::>() + }); + let payer_proof = PayerProofTlvStreamRef { + invoice_signature: Some(&self.invoice_signature), + preimage: Some(&self.preimage), + omitted_markers: omitted_markers.as_ref(), + missing_hashes: (!self.disclosure.missing_hashes.is_empty()) + .then_some(&self.disclosure.missing_hashes), + leaf_hashes: (!self.disclosure.leaf_hashes.is_empty()) + .then_some(&self.disclosure.leaf_hashes), + payer_signature: Some(payer_signature), + }; + payer_proof.write(&mut bytes).expect("Vec write should not fail"); + + for record in TlvStream::new(&self.invoice_bytes) + .range(EXPERIMENTAL_OFFER_TYPES.start..) + .filter(|r| self.included_types.contains(&r.r#type)) + { + bytes.extend_from_slice(record.record_bytes); + } + + bytes + } +} + +impl PayerProof { + /// The payment preimage proving the invoice was paid. + pub fn payment_preimage(&self) -> PaymentPreimage { + self.contents.preimage + } + + /// The payer's public key (who paid). + pub fn payer_signing_pubkey(&self) -> PublicKey { + self.contents.payer_signing_pubkey + } + + /// The issuer's signing public key (the key that signed the invoice). + pub fn issuer_signing_pubkey(&self) -> PublicKey { + self.contents.issuer_signing_pubkey + } + + /// The payment hash. + pub fn payment_hash(&self) -> PaymentHash { + self.contents.payment_hash + } + + /// The invoice signature over the merkle root. + pub fn invoice_signature(&self) -> Signature { + self.contents.invoice_signature + } + + /// The payer's schnorr signature proving who authorized the payment. + pub fn payer_signature(&self) -> Signature { + self.contents.payer_signature_tlv.signature().clone() + } + + /// The disclosed offer description, if included in the proof. + pub fn offer_description(&self) -> Option> { + self.contents.disclosed_fields.offer_description.as_deref().map(PrintableString) + } + + /// The disclosed offer issuer, if included in the proof. + pub fn offer_issuer(&self) -> Option> { + self.contents.disclosed_fields.offer_issuer.as_deref().map(PrintableString) + } + + /// The disclosed invoice amount, if included in the proof. + pub fn invoice_amount_msats(&self) -> Option { + self.contents.disclosed_fields.invoice_amount_msats + } + + /// The disclosed invoice creation time, if included in the proof. + pub fn invoice_created_at(&self) -> Option { + self.contents.disclosed_fields.invoice_created_at + } + + /// A note the payer attached to this proof, if any. + /// + /// This is distinct from [`InvoiceRequest::payer_note`]: the invoice-request note is + /// sent to the payee at payment time, while this note is scoped to the proof and is + /// committed to by the [`payer_signature`] alongside the invoice's merkle root. + /// + /// [`InvoiceRequest::payer_note`]: crate::offers::invoice_request::InvoiceRequest::payer_note + /// [`payer_signature`]: Self::payer_signature + pub fn payer_note(&self) -> Option> { + self.contents.payer_signature_tlv.note().map(PrintableString) + } + + /// The merkle root of the original invoice. + pub fn merkle_root(&self) -> sha256::Hash { + self.merkle_root + } + + /// The raw bytes of the payer proof. + pub fn bytes(&self) -> &[u8] { + &self.bytes + } +} + +impl Bech32Encode for PayerProof { + const BECH32_HRP: &'static str = PAYER_PROOF_HRP; +} + +impl AsRef<[u8]> for PayerProof { + fn as_ref(&self) -> &[u8] { + &self.bytes + } +} + +impl DisclosedFields { + fn update(&mut self, record: &TlvRecord<'_>) -> Result<(), DecodeError> { + match record.r#type { + OFFER_DESCRIPTION_TYPE => { + self.offer_description = Some( + String::from_utf8(record.value_bytes.to_vec()) + .map_err(|_| DecodeError::InvalidValue)?, + ); + }, + OFFER_ISSUER_TYPE => { + self.offer_issuer = Some( + String::from_utf8(record.value_bytes.to_vec()) + .map_err(|_| DecodeError::InvalidValue)?, + ); + }, + INVOICE_CREATED_AT_TYPE => { + self.invoice_created_at = Some(Duration::from_secs( + record.read_value::>()?.0, + )); + }, + INVOICE_AMOUNT_TYPE => { + self.invoice_amount_msats = + Some(record.read_value::>()?.0); + }, + _ => {}, + } + + Ok(()) + } + + fn from_records<'a>( + records: impl core::iter::Iterator>, + ) -> Result { + let mut disclosed_fields = DisclosedFields::default(); + for record in records { + disclosed_fields.update(&record)?; + } + Ok(disclosed_fields) + } +} + +struct ParsedPayerProofFields { + contents: PayerProofContents, + omitted_markers: Vec, + missing_hashes: Vec, + leaf_hashes: Vec, +} + +impl TryFrom for ParsedPayerProofFields { + type Error = Bolt12ParseError; + + fn try_from(tlv_stream: FullPayerProofTlvStream) -> Result { + let ( + OfferTlvStream { description, issuer, .. }, + // `payer_id` is the TLV-stream field name (tied to the spec TLV). Rebind to + // `payer_signing_pubkey` to match `PayerProofContents` naming. + InvoiceRequestTlvStream { payer_id: payer_signing_pubkey, .. }, + InvoiceTlvStream { created_at, payment_hash, amount, node_id, .. }, + PayerProofTlvStream { + invoice_signature, + preimage, + omitted_markers, + missing_hashes, + leaf_hashes, + payer_signature, + }, + _experimental_offer, + _experimental_invoice_request, + _experimental_invoice, + ) = tlv_stream; + + let payer_signing_pubkey = payer_signing_pubkey.ok_or( + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingPayerSigningPubkey), + )?; + let payment_hash = payment_hash + .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingPaymentHash))?; + let issuer_signing_pubkey = node_id + .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSigningPubkey))?; + let invoice_signature = invoice_signature + .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSignature))?; + let preimage = preimage.ok_or(Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + let payer_signature = payer_signature + .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSignature))?; + + Ok(Self { + contents: PayerProofContents { + payer_signing_pubkey, + payment_hash, + issuer_signing_pubkey, + preimage, + invoice_signature, + payer_signature_tlv: payer_signature, + disclosed_fields: DisclosedFields { + offer_description: description, + offer_issuer: issuer, + invoice_amount_msats: amount, + invoice_created_at: created_at.map(Duration::from_secs), + }, + }, + omitted_markers: omitted_markers + .unwrap_or_default() + .into_iter() + .map(|marker| marker.0) + .collect(), + missing_hashes: missing_hashes.unwrap_or_default(), + leaf_hashes: leaf_hashes.unwrap_or_default(), + }) + } +} + +fn tlv_stream_iter<'a>(bytes: &'a [u8]) -> impl core::iter::Iterator> { + // By the time we get here, `ParsedMessage::` has + // already parsed `bytes` through every sub-stream (offer, invoice request, + // invoice, payer-proof/signature, and each experimental range) and the + // `tlv_stream!`-generated parsers have rejected any unknown even TLV in any + // sub-stream's range. Anything in the unused gap between the signature and + // experimental ranges is rejected by `ParsedMessage`'s all-bytes-consumed + // check. The raw reconstruction pass therefore only needs to skip the + // payer-proof/signature TLVs themselves. + TlvStream::new(bytes).filter(|record| !SIGNATURE_TYPES.contains(&record.r#type)) +} + +impl TryFrom> for PayerProof { + type Error = Bolt12ParseError; + + fn try_from(bytes: Vec) -> Result { + let parsed_proof = ParsedMessage::::try_from(bytes)?; + let ParsedMessage { bytes, tlv_stream } = parsed_proof; + let ParsedPayerProofFields { contents, omitted_markers, missing_hashes, leaf_hashes } = + ParsedPayerProofFields::try_from(tlv_stream)?; + let included_records: Vec<_> = tlv_stream_iter(&bytes).collect(); + let included_types = included_records.iter().map(|record| record.r#type).collect(); + + validate_omitted_markers_for_parsing(&omitted_markers, &included_types) + .map_err(Bolt12ParseError::Decode)?; + + if leaf_hashes.len() != included_records.len() { + return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); + } + + let merkle_root = merkle::reconstruct_merkle_root( + &included_records, + &leaf_hashes, + &omitted_markers, + &missing_hashes, + ) + .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + + // Verify preimage matches payment hash. + let computed = sha256::Hash::hash(&contents.preimage.0); + if computed.as_byte_array() != &contents.payment_hash.0 { + return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); + } + + // Verify the invoice signature against the issuer signing pubkey. + let tagged_hash = TaggedHash::from_merkle_root(SIGNATURE_TAG, merkle_root); + merkle::verify_signature( + &contents.invoice_signature, + &tagged_hash, + contents.issuer_signing_pubkey, + ) + .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + + // Verify the payer signature. + let payer_tagged_hash = + payer_signature_hash(contents.payer_signature_tlv.note(), &merkle_root); + merkle::verify_signature( + contents.payer_signature_tlv.signature(), + &payer_tagged_hash, + contents.payer_signing_pubkey, + ) + .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + + Ok(PayerProof { bytes, contents, merkle_root }) + } +} + +/// Validate omitted markers during parsing. +/// +/// Per spec: +/// - MUST NOT contain 0 +/// - MUST NOT contain signature TLV element numbers (240-1000) +/// - MUST be in strict ascending order +/// - MUST NOT contain the number of an included TLV field +/// - Markers MUST be minimized: each marker must be exactly prev_value + 1 within +/// a run, and the first marker after an included type X must be X + 1. This +/// naturally allows a trailing run of omitted TLVs after the final included +/// type. +fn validate_omitted_markers_for_parsing( + omitted_markers: &[u64], included_types: &BTreeSet, +) -> Result<(), DecodeError> { + let mut inc_iter = included_types.iter().copied().peekable(); + // After implicit TLV0 (marker 0), the first minimized marker would be 1 + let mut expected_next: u64 = 1; + let mut prev = 0u64; + + for &marker in omitted_markers { + // MUST NOT contain 0 + if marker == 0 { + return Err(DecodeError::InvalidValue); + } + + // MUST NOT contain signature TLV types + if SIGNATURE_TYPES.contains(&marker) { + return Err(DecodeError::InvalidValue); + } + + // MUST be strictly ascending + if marker <= prev { + return Err(DecodeError::InvalidValue); + } + + // MUST NOT contain included TLV types + if included_types.contains(&marker) { + return Err(DecodeError::InvalidValue); + } + + // Validate minimization: marker must equal expected_next (continuation + // of current run), or there must be an included type X between the + // previous position and this marker such that X + 1 == marker. + if marker != expected_next { + let mut found = false; + for inc_type in inc_iter.by_ref() { + if inc_type + 1 == marker { + found = true; + break; + } + if inc_type >= marker { + return Err(DecodeError::InvalidValue); + } + } + if !found { + return Err(DecodeError::InvalidValue); + } + } + + expected_next = marker + 1; + prev = marker; + } + + Ok(()) +} + +impl core::str::FromStr for PayerProof { + type Err = Bolt12ParseError; + + fn from_str(s: &str) -> Result::Err> { + Self::from_bech32_str(s) + } +} + +impl core::fmt::Display for PayerProof { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + self.fmt_bech32_str(f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ln::channelmanager::PaymentId; + use crate::ln::inbound_payment::ExpandedKey; + use crate::offers::merkle::compute_selective_disclosure; + use crate::offers::nonce::Nonce; + #[cfg(not(c_bindings))] + use crate::offers::refund::RefundBuilder; + #[cfg(c_bindings)] + use crate::offers::refund::RefundMaybeWithDerivedMetadataBuilder as RefundBuilder; + use crate::offers::test_utils::*; + use crate::util::ser::HighZeroBytesDroppedBigSize; + use bitcoin::hashes::Hash; + use bitcoin::secp256k1::{Keypair, Secp256k1, SecretKey}; + use core::time::Duration; + + const EXPERIMENTAL_TEST_TLV_TYPE: u64 = 1_000_000_001; + + fn write_tlv_record(bytes: &mut Vec, tlv_type: u64, value: &T) { + let mut value_bytes = Vec::new(); + value.write(&mut value_bytes).expect("Vec write should not fail"); + + BigSize(tlv_type).write(bytes).expect("Vec write should not fail"); + BigSize(value_bytes.len() as u64).write(bytes).expect("Vec write should not fail"); + bytes.extend_from_slice(&value_bytes); + } + + fn write_tlv_record_bytes(bytes: &mut Vec, tlv_type: u64, value_bytes: &[u8]) { + BigSize(tlv_type).write(bytes).expect("Vec write should not fail"); + BigSize(value_bytes.len() as u64).write(bytes).expect("Vec write should not fail"); + bytes.extend_from_slice(value_bytes); + } + + fn build_round_trip_proof_with_included_experimental_tlv() -> PayerProof { + let secp_ctx = Secp256k1::new(); + + let payer_secret = SecretKey::from_slice(&[42; 32]).unwrap(); + let payer_keys = Keypair::from_secret_key(&secp_ctx, &payer_secret); + let payer_signing_pubkey = payer_keys.public_key(); + + let issuer_secret = SecretKey::from_slice(&[43; 32]).unwrap(); + let issuer_keys = Keypair::from_secret_key(&secp_ctx, &issuer_secret); + let issuer_signing_pubkey = issuer_keys.public_key(); + + let preimage = PaymentPreimage([44; 32]); + let payment_hash = PaymentHash(sha256::Hash::hash(&preimage.0).to_byte_array()); + + let mut invoice_bytes = Vec::new(); + write_tlv_record_bytes(&mut invoice_bytes, PAYER_METADATA_TYPE, &[45; 32]); + write_tlv_record(&mut invoice_bytes, INVOICE_REQUEST_PAYER_ID_TYPE, &payer_signing_pubkey); + write_tlv_record(&mut invoice_bytes, INVOICE_PAYMENT_HASH_TYPE, &payment_hash); + write_tlv_record(&mut invoice_bytes, INVOICE_NODE_ID_TYPE, &issuer_signing_pubkey); + write_tlv_record_bytes( + &mut invoice_bytes, + EXPERIMENTAL_TEST_TLV_TYPE, + b"experimental-payer-proof-field", + ); + + let invoice_message = + TaggedHash::from_valid_tlv_stream_bytes(SIGNATURE_TAG, &invoice_bytes); + let invoice_signature = + secp_ctx.sign_schnorr_no_aux_rand(invoice_message.as_digest(), &issuer_keys); + + let included_types: BTreeSet = [ + INVOICE_REQUEST_PAYER_ID_TYPE, + INVOICE_PAYMENT_HASH_TYPE, + INVOICE_NODE_ID_TYPE, + EXPERIMENTAL_TEST_TLV_TYPE, + ] + .into_iter() + .collect(); + let disclosed_fields = DisclosedFields::from_records( + TlvStream::new(&invoice_bytes).filter(|r| included_types.contains(&r.r#type)), + ) + .unwrap(); + let disclosure = + compute_selective_disclosure(TlvStream::new(&invoice_bytes), &included_types).unwrap(); + + let unsigned = UnsignedPayerProof { + invoice_signature, + preimage, + payer_signing_pubkey, + payment_hash, + issuer_signing_pubkey, + invoice_bytes: &invoice_bytes, + included_types, + disclosed_fields, + tagged_hash: payer_signature_hash(None, &disclosure.merkle_root), + disclosure, + payer_note: None, + }; + + unsigned + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) + .unwrap() + } + + fn build_round_trip_proof_with_multiple_trailing_omitted_tlvs() -> PayerProof { + let secp_ctx = Secp256k1::new(); + + let payer_secret = SecretKey::from_slice(&[52; 32]).unwrap(); + let payer_keys = Keypair::from_secret_key(&secp_ctx, &payer_secret); + let payer_signing_pubkey = payer_keys.public_key(); + + let issuer_secret = SecretKey::from_slice(&[53; 32]).unwrap(); + let issuer_keys = Keypair::from_secret_key(&secp_ctx, &issuer_secret); + let issuer_signing_pubkey = issuer_keys.public_key(); + + let preimage = PaymentPreimage([54; 32]); + let payment_hash = PaymentHash(sha256::Hash::hash(&preimage.0).to_byte_array()); + + let mut invoice_bytes = Vec::new(); + write_tlv_record_bytes(&mut invoice_bytes, PAYER_METADATA_TYPE, &[55; 32]); + write_tlv_record(&mut invoice_bytes, INVOICE_REQUEST_PAYER_ID_TYPE, &payer_signing_pubkey); + write_tlv_record(&mut invoice_bytes, INVOICE_PAYMENT_HASH_TYPE, &payment_hash); + write_tlv_record(&mut invoice_bytes, INVOICE_NODE_ID_TYPE, &issuer_signing_pubkey); + write_tlv_record_bytes(&mut invoice_bytes, 1_000_000_001, b"first-omitted-experimental"); + write_tlv_record_bytes(&mut invoice_bytes, 1_000_000_003, b"second-omitted-experimental"); + + let invoice_message = + TaggedHash::from_valid_tlv_stream_bytes(SIGNATURE_TAG, &invoice_bytes); + let invoice_signature = + secp_ctx.sign_schnorr_no_aux_rand(invoice_message.as_digest(), &issuer_keys); + + let included_types: BTreeSet = + [INVOICE_REQUEST_PAYER_ID_TYPE, INVOICE_PAYMENT_HASH_TYPE, INVOICE_NODE_ID_TYPE] + .into_iter() + .collect(); + let disclosed_fields = DisclosedFields::from_records( + TlvStream::new(&invoice_bytes).filter(|r| included_types.contains(&r.r#type)), + ) + .unwrap(); + let disclosure = + compute_selective_disclosure(TlvStream::new(&invoice_bytes), &included_types).unwrap(); + assert_eq!(disclosure.omitted_markers, vec![177, 178]); + + let unsigned = UnsignedPayerProof { + invoice_signature, + preimage, + payer_signing_pubkey, + payment_hash, + issuer_signing_pubkey, + invoice_bytes: &invoice_bytes, + included_types, + disclosed_fields, + tagged_hash: payer_signature_hash(None, &disclosure.merkle_root), + disclosure, + payer_note: None, + }; + + unsigned + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) + .unwrap() + } + + fn build_round_trip_proof_with_disclosed_fields() -> PayerProof { + let secp_ctx = Secp256k1::new(); + + let payer_secret = SecretKey::from_slice(&[62; 32]).unwrap(); + let payer_keys = Keypair::from_secret_key(&secp_ctx, &payer_secret); + let payer_signing_pubkey = payer_keys.public_key(); + + let issuer_secret = SecretKey::from_slice(&[63; 32]).unwrap(); + let issuer_keys = Keypair::from_secret_key(&secp_ctx, &issuer_secret); + let issuer_signing_pubkey = issuer_keys.public_key(); + + let preimage = PaymentPreimage([64; 32]); + let payment_hash = PaymentHash(sha256::Hash::hash(&preimage.0).to_byte_array()); + + let mut invoice_bytes = Vec::new(); + write_tlv_record_bytes(&mut invoice_bytes, PAYER_METADATA_TYPE, &[65; 32]); + write_tlv_record_bytes(&mut invoice_bytes, OFFER_DESCRIPTION_TYPE, b"coffee beans"); + write_tlv_record_bytes(&mut invoice_bytes, OFFER_ISSUER_TYPE, b"LDK Roastery"); + write_tlv_record(&mut invoice_bytes, INVOICE_REQUEST_PAYER_ID_TYPE, &payer_signing_pubkey); + write_tlv_record( + &mut invoice_bytes, + INVOICE_CREATED_AT_TYPE, + &HighZeroBytesDroppedBigSize(1_700_000_000u64), + ); + write_tlv_record(&mut invoice_bytes, INVOICE_PAYMENT_HASH_TYPE, &payment_hash); + write_tlv_record( + &mut invoice_bytes, + INVOICE_AMOUNT_TYPE, + &HighZeroBytesDroppedBigSize(42_000u64), + ); + write_tlv_record(&mut invoice_bytes, INVOICE_NODE_ID_TYPE, &issuer_signing_pubkey); + + let invoice_message = + TaggedHash::from_valid_tlv_stream_bytes(SIGNATURE_TAG, &invoice_bytes); + let invoice_signature = + secp_ctx.sign_schnorr_no_aux_rand(invoice_message.as_digest(), &issuer_keys); + + let included_types: BTreeSet = [ + OFFER_DESCRIPTION_TYPE, + OFFER_ISSUER_TYPE, + INVOICE_REQUEST_PAYER_ID_TYPE, + INVOICE_CREATED_AT_TYPE, + INVOICE_PAYMENT_HASH_TYPE, + INVOICE_AMOUNT_TYPE, + INVOICE_NODE_ID_TYPE, + ] + .into_iter() + .collect(); + let disclosed_fields = DisclosedFields::from_records( + TlvStream::new(&invoice_bytes).filter(|r| included_types.contains(&r.r#type)), + ) + .unwrap(); + let disclosure = + compute_selective_disclosure(TlvStream::new(&invoice_bytes), &included_types).unwrap(); + + let unsigned = UnsignedPayerProof { + invoice_signature, + preimage, + payer_signing_pubkey, + payment_hash, + issuer_signing_pubkey, + invoice_bytes: &invoice_bytes, + included_types, + disclosed_fields, + tagged_hash: payer_signature_hash(None, &disclosure.merkle_root), + disclosure, + payer_note: None, + }; + + unsigned + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) + .unwrap() + } + + #[test] + fn test_selective_disclosure_computation() { + // Test that the merkle selective disclosure works correctly + // Simple TLV stream with types 1, 2 + let tlv_bytes = vec![ + 0x01, 0x03, 0xe8, 0x03, 0xe8, // type 1, length 3, value + 0x02, 0x08, 0x00, 0x00, 0x01, 0x00, 0x00, 0x02, 0x00, 0x03, // type 2 + ]; + + let mut included = BTreeSet::new(); + included.insert(1); + + let result = compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included); + assert!(result.is_ok()); + + let disclosure = result.unwrap(); + assert_eq!(disclosure.leaf_hashes.len(), 1); // One included TLV + assert!(!disclosure.missing_hashes.is_empty()); // Should have missing hashes for omitted + } + + /// Test the omitted_markers marker algorithm per BOLT 12 payer proof spec. + /// + /// From the spec example: + /// TLVs: 0 (omitted), 10 (included), 20 (omitted), 30 (omitted), + /// 40 (included), 50 (omitted), 60 (omitted), 240 (signature) + /// + /// Expected markers: [11, 12, 41, 42] + /// + /// The algorithm: + /// - TLV 0 is always omitted and implicit (not in markers) + /// - For omitted TLV after included: marker = prev_included_type + 1 + /// - For consecutive omitted TLVs: marker = prev_marker + 1 + #[test] + fn test_omitted_markers_spec_example() { + // Build a synthetic TLV stream matching the spec example + // TLV format: type (BigSize) || length (BigSize) || value + let mut tlv_bytes = Vec::new(); + + // TLV 0: type=0, len=4, value=dummy + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); + // TLV 10: type=10, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); + // TLV 20: type=20, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); + // TLV 30: type=30, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x1e, 0x02, 0x00, 0x00]); + // TLV 40: type=40, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x28, 0x02, 0x00, 0x00]); + // TLV 50: type=50, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x32, 0x02, 0x00, 0x00]); + // TLV 60: type=60, len=2, value=dummy + tlv_bytes.extend_from_slice(&[0x3c, 0x02, 0x00, 0x00]); + + // Include types 10 and 40 + let mut included = BTreeSet::new(); + included.insert(10); + included.insert(40); + + let disclosure = + compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included).unwrap(); + + // Per spec example, omitted_markers should be [11, 12, 41, 42] + assert_eq!(disclosure.omitted_markers, vec![11, 12, 41, 42]); + + // leaf_hashes should have 2 entries (one for each included TLV) + assert_eq!(disclosure.leaf_hashes.len(), 2); + } + + /// Test that the marker algorithm handles edge cases correctly. + #[test] + fn test_omitted_markers_edge_cases() { + // Test with only one included TLV at the start + let mut tlv_bytes = Vec::new(); + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // TLV 0 + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); // TLV 10 + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); // TLV 20 + tlv_bytes.extend_from_slice(&[0x1e, 0x02, 0x00, 0x00]); // TLV 30 + + let mut included = BTreeSet::new(); + included.insert(10); + + let disclosure = + compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included).unwrap(); + + // After included type 10, omitted types 20 and 30 get markers 11 and 12 + assert_eq!(disclosure.omitted_markers, vec![11, 12]); + } + + /// Test that all included TLVs produce no omitted markers (except implicit TLV0). + #[test] + fn test_omitted_markers_all_included() { + let mut tlv_bytes = Vec::new(); + tlv_bytes.extend_from_slice(&[0x00, 0x04, 0x00, 0x00, 0x00, 0x00]); // TLV 0 (always omitted) + tlv_bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); // TLV 10 + tlv_bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); // TLV 20 + + let mut included = BTreeSet::new(); + included.insert(10); + included.insert(20); + + let disclosure = + compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included).unwrap(); + + // Only TLV 0 is omitted (implicit), so no markers needed + assert!(disclosure.omitted_markers.is_empty()); + } + + /// Test validation of omitted_markers - must not contain 0. + #[test] + fn test_validate_omitted_markers_rejects_zero() { + let omitted = vec![0, 11, 12]; + let included: BTreeSet = [10, 30].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_err()); + } + + /// Test validation of omitted_markers - must not contain signature types. + #[test] + fn test_validate_omitted_markers_rejects_signature_types() { + // included=[10], markers=[1, 2, 250] — 250 is a signature type + let omitted = vec![1, 2, 250]; + let included: BTreeSet = [10].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_err()); + } + + /// Test validation of omitted_markers - must be strictly ascending. + #[test] + fn test_validate_omitted_markers_rejects_non_ascending() { + // markers=[1, 11, 9]: 1 ok, 11 ok (after included 10), but 9 <= 11 fails ascending + let omitted = vec![1, 11, 9]; + let included: BTreeSet = [10, 30].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_err()); + } + + /// Test validation of omitted_markers - must not contain included types. + #[test] + fn test_validate_omitted_markers_rejects_included_types() { + // included=[10, 30], markers=[1, 10] — 10 is in included set + let omitted = vec![1, 10]; + let included: BTreeSet = [10, 30].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(matches!(result, Err(DecodeError::InvalidValue))); + } + + /// Test that a minimized trailing run is accepted. + #[test] + fn test_validate_omitted_markers_accepts_trailing_run() { + // included=[10, 20], markers=[1, 21, 22] — both 21 and 22 > max included (20) + let omitted = vec![1, 21, 22]; + let included: BTreeSet = [10, 20].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_ok()); + } + + /// Test that valid minimized omitted_markers pass validation. + #[test] + fn test_validate_omitted_markers_accepts_valid() { + // Realistic payer proof: included types include required fields (88, 168, 176) + // so max_included=176 and markers are well below it. + // Layout: 0(omit), 10(incl), 20(omit), 30(omit), 40(incl), 50(omit), 88(incl), + // 168(incl), 176(incl) + // markers=[11, 12, 41, 89] + let omitted = vec![11, 12, 41, 89]; + let included: BTreeSet = [10, 40, 88, 168, 176].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_ok()); + } + + /// Test that non-minimized markers are rejected. + #[test] + fn test_validate_omitted_markers_rejects_non_minimized() { + // included=[10, 40], markers=[11, 15, 41, 42] + // marker 15 should be 12 (continuation of run after 11) + let omitted = vec![11, 15, 41, 42]; + let included: BTreeSet = [10, 40].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_err()); + } + + /// Test that non-minimized first marker in a run is rejected. + #[test] + fn test_validate_omitted_markers_rejects_non_minimized_run_start() { + // included=[10, 40], markers=[11, 12, 45, 46] + // marker 45 should be 41 (first omitted after included 40) + let omitted = vec![11, 12, 45, 46]; + let included: BTreeSet = [10, 40].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_err()); + } + + /// Test minimized markers with omitted TLVs before any included type. + #[test] + fn test_validate_omitted_markers_accepts_leading_run() { + // included=[40], markers=[1, 2, 41] + // Two omitted before any included type, one after 40 + let omitted = vec![1, 2, 41]; + let included: BTreeSet = [40].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_ok()); + } + + /// Test minimized markers with consecutive included types (no markers between them). + #[test] + fn test_validate_omitted_markers_accepts_consecutive_included() { + // included=[10, 20, 40], markers=[1, 41] + // One omitted before 10, no omitted between 10-20 or 20-40, one after 40 + let omitted = vec![1, 41]; + let included: BTreeSet = [10, 20, 40].iter().copied().collect(); + + let result = validate_omitted_markers_for_parsing(&omitted, &included); + assert!(result.is_ok()); + } + + /// Test that invreq_metadata (type 0) cannot be explicitly included via include_type. + #[test] + fn test_invreq_metadata_not_allowed() { + assert_eq!(PAYER_METADATA_TYPE, 0); + } + + /// Test that out-of-order TLVs are rejected during parsing. + #[test] + fn test_parsing_rejects_out_of_order_tlvs() { + use core::convert::TryFrom; + + // Create a malformed TLV stream with out-of-order types (20 before 10) + // TLV format: type (BigSize) || length (BigSize) || value + let mut bytes = Vec::new(); + // TLV type 20, length 2, value + bytes.extend_from_slice(&[0x14, 0x02, 0x00, 0x00]); + // TLV type 10, length 2, value (OUT OF ORDER!) + bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); + + let result = PayerProof::try_from(bytes); + assert!(result.is_err()); + } + + /// Test that duplicate TLVs are rejected during parsing. + #[test] + fn test_parsing_rejects_duplicate_tlvs() { + use core::convert::TryFrom; + + // Create a malformed TLV stream with duplicate type 10 + let mut bytes = Vec::new(); + // TLV type 10, length 2, value + bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); + // TLV type 10 again (DUPLICATE!) + bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); + + let result = PayerProof::try_from(bytes); + assert!(result.is_err()); + } + + /// Test that invalid hash lengths (not multiple of 32) are rejected. + #[test] + fn test_parsing_rejects_invalid_hash_length() { + use core::convert::TryFrom; + + // Create a TLV stream with missing_hashes (type 246) that has invalid length + // BigSize encoding: values 0-252 are single byte, 253-65535 use 0xFD prefix + let mut bytes = Vec::new(); + // TLV type 246 (missing_hashes) - 246 < 253 so single byte + bytes.push(0xf6); // type 246 + bytes.push(0x21); // length 33 (not multiple of 32!) + bytes.extend_from_slice(&[0x00; 33]); // 33 bytes of zeros + + let result = PayerProof::try_from(bytes); + assert!(result.is_err()); + } + + /// Test that invalid leaf_hashes length (not multiple of 32) is rejected. + #[test] + fn test_parsing_rejects_invalid_leaf_hashes_length() { + use core::convert::TryFrom; + + // Create a TLV stream with leaf_hashes (type 248) that has invalid length + // BigSize encoding: values 0-252 are single byte, 253-65535 use 0xFD prefix + let mut bytes = Vec::new(); + // TLV type 248 (leaf_hashes) - 248 < 253 so single byte + bytes.push(0xf8); // type 248 + bytes.push(0x1f); // length 31 (not multiple of 32!) + bytes.extend_from_slice(&[0x00; 31]); // 31 bytes of zeros + + let result = PayerProof::try_from(bytes); + assert!(result.is_err()); + } + + /// Test that TLV types >= 240 are rejected by include_type. + /// + /// Per spec, all types >= 240 are in the signature/payer-proof range and + /// handled separately. This includes types > 1000 (experimental range) + /// which were previously allowed through. + #[test] + fn test_include_type_rejects_signature_types() { + // Test the type validation logic directly. + fn check_include_type(tlv_type: u64) -> Result<(), PayerProofError> { + if tlv_type == PAYER_METADATA_TYPE { + return Err(PayerProofError::PayerMetadataNotAllowed); + } + if SIGNATURE_TYPES.contains(&tlv_type) + || (tlv_type > *SIGNATURE_TYPES.end() && tlv_type < EXPERIMENTAL_OFFER_TYPES.start) + { + return Err(PayerProofError::DisallowedTlvType); + } + Ok(()) + } + + // Signature-range types 240..=1000 and the unsupported gap before experimental + // ranges begins must be rejected. + assert!(matches!(check_include_type(240), Err(PayerProofError::DisallowedTlvType))); + assert!(matches!(check_include_type(250), Err(PayerProofError::DisallowedTlvType))); + assert!(matches!(check_include_type(1000), Err(PayerProofError::DisallowedTlvType))); + assert!(matches!(check_include_type(1001), Err(PayerProofError::DisallowedTlvType))); + assert!(matches!( + check_include_type(EXPERIMENTAL_OFFER_TYPES.start - 1), + Err(PayerProofError::DisallowedTlvType) + )); + // Experimental TLV ranges should remain includable. + assert!(check_include_type(EXPERIMENTAL_OFFER_TYPES.start).is_ok()); + assert!(check_include_type(u64::MAX).is_ok()); + // Just below the boundary + assert!(check_include_type(239).is_ok()); + // Payer metadata still rejected + assert!(matches!(check_include_type(0), Err(PayerProofError::PayerMetadataNotAllowed))); + } + + #[test] + fn test_round_trip_accepts_included_experimental_tlv() { + let proof = build_round_trip_proof_with_included_experimental_tlv(); + let result = PayerProof::try_from(proof.bytes().to_vec()); + assert!( + result.is_ok(), + "Included experimental TLVs should survive payer proof parsing: {:?}", + result + ); + } + + #[test] + fn test_round_trip_accepts_multiple_trailing_omitted_tlvs() { + let proof = build_round_trip_proof_with_multiple_trailing_omitted_tlvs(); + let result = PayerProof::try_from(proof.bytes().to_vec()); + assert!( + result.is_ok(), + "Multiple trailing omitted TLVs should survive payer proof parsing: {:?}", + result + ); + } + + /// Confirms that type 0 (`payer_metadata`) is rejected when parsing a payer proof — + /// matching the same behavior as `FullOfferTlvStream`. + /// + /// `FullPayerProofTlvStream` has no sub-stream that covers type 0 (the lowest sub-stream + /// is `OfferTlvStream`, range `1..80`). Each `CursorReadable` impl reads the type BigSize, + /// finds it out of range, rewinds the type bytes, and breaks — without consuming the + /// length or value. The cursor is therefore left before the type-0 TLV, and the + /// all-bytes-consumed check in `ParsedMessage::try_from` rejects the input with + /// `DecodeError::InvalidValue` before any semantic validation runs. + #[test] + fn test_parsing_rejects_payer_metadata() { + let proof = build_round_trip_proof_with_multiple_trailing_omitted_tlvs(); + let mut bytes = Vec::new(); + write_tlv_record_bytes(&mut bytes, PAYER_METADATA_TYPE, &[1; 32]); + bytes.extend_from_slice(proof.bytes()); + + let result = PayerProof::try_from(bytes); + assert!(matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)))); + } + + /// Confirms that a TLV with a type in the unused range between `SIGNATURE_TYPES` and + /// `EXPERIMENTAL_OFFER_TYPES` is rejected during parsing, regardless of whether its + /// length prefix is well-formed. + /// + /// No sub-stream in `FullPayerProofTlvStream` covers `(*SIGNATURE_TYPES.end() + + /// 1)..EXPERIMENTAL_OFFER_TYPES.start`. Each `CursorReadable` impl rewinds on the + /// out-of-range type and breaks without ever reading the length or value bytes. + /// The cursor is left before the gap TLV, and the all-bytes-consumed check in + /// `ParsedMessage::try_from` rejects the input with `DecodeError::InvalidValue`. + /// A malformed length prefix in the gap TLV is therefore never touched and cannot + /// panic downstream parsing. + #[test] + fn test_parsing_rejects_tlv_in_unused_range() { + const GAP_TYPE: u64 = 1_000_000; + assert!(GAP_TYPE > *SIGNATURE_TYPES.end()); + assert!(GAP_TYPE < EXPERIMENTAL_OFFER_TYPES.start); + + let proof = build_round_trip_proof_with_multiple_trailing_omitted_tlvs(); + + // Case 1: a well-formed TLV in the gap is rejected by the all-bytes-consumed check. + let mut well_formed = proof.bytes().to_vec(); + write_tlv_record_bytes(&mut well_formed, GAP_TYPE, b"ignored"); + let result = PayerProof::try_from(well_formed); + assert!(matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)))); + + // Case 2: a truncated/malformed length prefix after the gap type also rejects, + // without panicking — the length bytes are never read because the sub-streams + // rewind on the out-of-range type. + let mut malformed_length = proof.bytes().to_vec(); + BigSize(GAP_TYPE).write(&mut malformed_length).expect("Vec write should not fail"); + // `0xFD` promises two more bytes for a u16 length, but only one follows. If the + // parser ever tried to read this length, `BigSize::read` would return + // `DecodeError::ShortRead`. The fact that we still get `InvalidValue` below + // proves the sub-streams rewound before the length was ever touched. + malformed_length.push(0xFD); + malformed_length.push(0x01); + let result = PayerProof::try_from(malformed_length); + assert!(matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)))); + } + + #[test] + fn test_round_trip_ignores_unknown_odd_signature_range_tlv_for_reconstruction() { + let unknown_odd_payer_proof_type = PAYER_PROOF_PAYER_SIGNATURE_TYPE + 1; + assert_eq!(unknown_odd_payer_proof_type % 2, 1); + assert!(SIGNATURE_TYPES.contains(&unknown_odd_payer_proof_type)); + + let proof = build_round_trip_proof_with_multiple_trailing_omitted_tlvs(); + let mut bytes = proof.bytes().to_vec(); + write_tlv_record_bytes(&mut bytes, unknown_odd_payer_proof_type, b"ignored"); + + let parsed = PayerProof::try_from(bytes).unwrap(); + assert_eq!(parsed.payment_hash(), proof.payment_hash()); + assert_eq!(parsed.payer_signing_pubkey(), proof.payer_signing_pubkey()); + } + + #[test] + fn test_parsing_rejects_unknown_tlvs_above_signature_range() { + let unknown_odd_payer_proof_type = *SIGNATURE_TYPES.end() + 1; + assert_eq!(unknown_odd_payer_proof_type % 2, 1); + + let proof = build_round_trip_proof_with_multiple_trailing_omitted_tlvs(); + let mut bytes = proof.bytes().to_vec(); + write_tlv_record_bytes(&mut bytes, unknown_odd_payer_proof_type, b"ignored"); + + let result = PayerProof::try_from(bytes); + assert!(matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)))); + } + + #[test] + fn test_parsed_proof_exposes_disclosed_fields() { + let proof = build_round_trip_proof_with_disclosed_fields(); + let parsed = PayerProof::try_from(proof.bytes().to_vec()).unwrap(); + + assert_eq!(parsed.offer_description().map(|s| s.0), Some("coffee beans")); + assert_eq!(parsed.offer_issuer().map(|s| s.0), Some("LDK Roastery")); + assert_eq!(parsed.invoice_amount_msats(), Some(42_000)); + assert_eq!(parsed.invoice_created_at(), Some(Duration::from_secs(1_700_000_000))); + } + + /// Test that unknown even TLV types in every payer-proof BOLT 12 sub-stream + /// namespace are rejected by the `tlv_stream!`-based parser, and that types + /// in the unused gap ranges between sub-streams are rejected by + /// `ParsedMessage`'s all-bytes-consumed check. + /// + /// Per BOLT convention, even types are mandatory-to-understand. For payer + /// proofs this is stricter than the general invoice rule because including + /// an unknown even TLV in a proof implies the verifier must check something + /// about it, and it cannot. See the upstream discussion: + /// . + #[test] + fn test_parsing_rejects_unknown_even_tlvs_in_every_range() { + use core::convert::TryFrom; + + /// Parse a payer-proof byte stream that contains only a single TLV with + /// the given type and a 4-byte dummy value, and assert it is rejected + /// with the expected error variant. + fn assert_rejected(tlv_type: u64, expected: DecodeError, label: &str) { + let mut bytes = Vec::new(); + BigSize(tlv_type).write(&mut bytes).expect("Vec write should not fail"); + BigSize(4).write(&mut bytes).expect("Vec write should not fail"); + bytes.extend_from_slice(b"test"); + + match PayerProof::try_from(bytes) { + Err(Bolt12ParseError::Decode(ref err)) if err == &expected => {}, + other => panic!( + "{} (type {}): expected {:?}, got {:?}", + label, tlv_type, expected, other, + ), + } + } + + // Sub-stream ranges: rejected by `tlv_stream!`'s unknown-even fallback. + assert_rejected(50, DecodeError::UnknownRequiredFeature, "offer range"); + assert_rejected(100, DecodeError::UnknownRequiredFeature, "invoice_request range"); + assert_rejected(200, DecodeError::UnknownRequiredFeature, "invoice range"); + assert_rejected(252, DecodeError::UnknownRequiredFeature, "payer-proof/signature range"); + assert_rejected( + 1_500_000_000, + DecodeError::UnknownRequiredFeature, + "experimental offer range", + ); + assert_rejected( + 2_500_000_000, + DecodeError::UnknownRequiredFeature, + "experimental invoice_request range", + ); + assert_rejected( + 3_500_000_000, + DecodeError::UnknownRequiredFeature, + "experimental invoice range", + ); + + // Gap between the signature range and the experimental ranges: no + // sub-stream covers `1001..1_000_000_000`, so the sub-streams rewind + // and `ParsedMessage::try_from`'s all-bytes-consumed check rejects. + // (There is no gap above `EXPERIMENTAL_INVOICE_TYPES`: it is open-ended + // to `u64::MAX`, so unknown even types there are caught by the + // unknown-even fallback above.) + assert_rejected( + 1_000_000, + DecodeError::InvalidValue, + "gap between signature and experimental ranges", + ); + } + + /// Test that malformed TLV framing is rejected without panicking. + /// + /// TlvStream::new() panics on malformed BigSize values or out-of-bounds + /// lengths. The parser must validate framing before constructing TlvStream. + #[test] + fn test_parsing_rejects_malformed_tlv_framing() { + use core::convert::TryFrom; + + // Truncated BigSize type (0xFD prefix requires 2 more bytes) + let result = PayerProof::try_from(vec![0xFD, 0x01]); + assert!(result.is_err(), "Truncated BigSize type should be rejected"); + + // Valid type but truncated length + let result = PayerProof::try_from(vec![0x0a]); + assert!(result.is_err(), "Missing length should be rejected"); + + // Length exceeds remaining bytes + let result = PayerProof::try_from(vec![0x0a, 0x04, 0x00, 0x00]); + assert!(result.is_err(), "Length exceeding data should be rejected"); + + // Empty input should not panic + let result = PayerProof::try_from(vec![]); + assert!(result.is_err(), "Empty input should be rejected"); + + // Completely invalid bytes + let result = PayerProof::try_from(vec![0xFF, 0xFF]); + assert!(result.is_err(), "Invalid bytes should be rejected"); + } + + /// Test that duplicate type-0 TLVs are rejected. + /// + /// Previously the ordering check used `u64` initialized to 0, which + /// skipped the check for the first TLV if its type was 0, allowing + /// duplicate type-0 records. + #[test] + fn test_parsing_rejects_duplicate_type_zero() { + use core::convert::TryFrom; + + // Two TLV records both with type 0 + let mut bytes = Vec::new(); + bytes.extend_from_slice(&[0x00, 0x02, 0x00, 0x00]); // type 0, len 2 + bytes.extend_from_slice(&[0x00, 0x02, 0x00, 0x00]); // type 0 again (DUPLICATE!) + + let result = PayerProof::try_from(bytes); + assert!(result.is_err(), "Duplicate type-0 TLVs should be rejected"); + } + + /// Test that payer_signature TLV with length < 64 is rejected. + /// + /// The payer_signature value contains a 64-byte schnorr signature + /// followed by an optional note. A length < 64 is always invalid. + #[test] + fn test_parsing_rejects_short_payer_signature() { + use core::convert::TryFrom; + + // Craft a TLV with type 250 (payer_signature) but only 32 bytes of value + let mut bytes = Vec::new(); + bytes.push(0xfa); // type 250 + bytes.push(0x20); // length 32 (too short for 64-byte signature) + bytes.extend_from_slice(&[0x00; 32]); + + let result = PayerProof::try_from(bytes); + assert!(result.is_err(), "payer_signature with len < 64 should be rejected"); + } + + #[test] + fn test_round_trip_with_trailing_experimental_tlvs() { + use core::convert::TryFrom; + + let preimage = PaymentPreimage([1; 32]); + let payment_hash = PaymentHash(*sha256::Hash::hash(&preimage.0).as_byte_array()); + let invoice = RefundBuilder::new(vec![1; 32], payer_pubkey(), 1000) + .unwrap() + .experimental_foo(42) + .experimental_bar(43) + .build() + .unwrap() + .respond_with_no_std(payment_paths(), payment_hash, recipient_pubkey(), now()) + .unwrap() + .experimental_baz(44) + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let secp_ctx = Secp256k1::signing_only(); + let payer_keys = payer_keys(); + let paid_invoice = + PaidBolt12Invoice::new(Bolt12InvoiceType::Bolt12Invoice(invoice), preimage, None); + let payer_proof = paid_invoice + .prove_payer() + .unwrap() + .build(None) + .unwrap() + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) + .unwrap(); + let parsed = PayerProof::try_from(payer_proof.bytes().to_vec()).unwrap(); + + assert_eq!(parsed.bytes(), payer_proof.bytes()); + assert_eq!(parsed.payment_preimage(), preimage); + assert_eq!(parsed.payment_hash(), payment_hash); + } + + #[test] + fn test_build_with_derived_signing_keys_for_refund_invoice() { + use core::convert::TryFrom; + + 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 preimage = PaymentPreimage([2; 32]); + let payment_hash = PaymentHash(*sha256::Hash::hash(&preimage.0).as_byte_array()); + + let invoice = RefundBuilder::deriving_signing_pubkey( + payer_pubkey(), + &expanded_key, + nonce, + &secp_ctx, + 1000, + payment_id, + ) + .unwrap() + .path(blinded_path()) + .experimental_foo(42) + .experimental_bar(43) + .build() + .unwrap() + .respond_with_no_std(payment_paths(), payment_hash, recipient_pubkey(), now()) + .unwrap() + .experimental_baz(44) + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let paid_invoice = PaidBolt12Invoice::new( + Bolt12InvoiceType::Bolt12Invoice(invoice), + preimage, + Some(nonce), + ); + let payer_proof = paid_invoice + .prove_payer_derived(&expanded_key, payment_id, &secp_ctx) + .unwrap() + .build_and_sign(Some("refund".into())) + .unwrap(); + let parsed = PayerProof::try_from(payer_proof.bytes().to_vec()).unwrap(); + + assert_eq!(parsed.payment_preimage(), preimage); + assert_eq!(parsed.payment_hash(), payment_hash); + assert_eq!(parsed.payer_note().map(|note| note.to_string()), Some("refund".to_string())); + } + + // BOLT 12 payer proof test vectors (from bolt12/payer-proof-test.json). + // All four vectors share the same invoice and preimage. + const PAYER_SECRET_HEX: &str = + "4242424242424242424242424242424242424242424242424242424242424242"; + const INVOICE_HEX: &str = "0010000000000000000000000000000000001621024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382520203e858210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1ca076027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e6686809910102edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145001000000000000000000000000000000000a21c00000001000000020003000000000000000400000000000000050000a40467527988a82072cd6e8422c407fb6d098690f1130b7ded7ec2f7f5e1d30bd9d521f015363793aa0203e8b021024bc2a31265153f07e70e0bab08724e6b85e217f8cd628ceb62974247bb493382f04098c093015fb630fa7aeeecebb7af826edc447244d4fab5d535fbf1ca008ff086bcb7d612f105d0aeeaf5711c30af20e8b438d736ca4d774af4cbdc7d855c8f88feb2d05e010142"; + const PREIMAGE_HEX: &str = "0101010101010101010101010101010101010101010101010101010101010101"; + + struct PayerProofVector { + name: &'static str, + included_types: &'static [u64], + note: Option<&'static str>, + leaf_hashes_hex: &'static str, + omitted_tlvs: &'static [u64], + missing_hashes_hex: &'static str, + merkle_root_hex: &'static str, + bech32: &'static str, + } + + const PAYER_PROOF_VECTORS: &[PayerProofVector] = &[ + PayerProofVector { + name: "full_disclosure", + included_types: &[22, 82, 160, 162, 164, 170, 3000000001], + note: None, + leaf_hashes_hex: "8c9057ed88f3c5a6b6441dcac3b5e4cefb3615904d7362b86e78427fb695f4618dc54a97453dee6f207fa5216a30f1567442712ca98852bc789b73885029283cf2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f54f80c94a87383f2a8ef7c3e461c62b67a51da5bccf6cd96a7dbab29bea51fa7849b8b856e1d2a63d9ce7dc1a78e05cbb2def1f5d7709c48e8707e0a59fe51e19e7e4eee6bf56c6c589fe50035490c1a7c91b753cb8007c4b52838a6772f997f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1adc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cbabaab91b367e30fea7026daf9f2590bb7e9cc31db8221f4013c67289e38f22c8", + omitted_tlvs: &[], + missing_hashes_hex: "0b510ba4c6884d603159ced2f0ca21e772424b59e52a2191bbfbcf07377805a1", + merkle_root_hex: "d75cc1c4a81b39f841f8db4e8b3156f73d973f32fc982cdce884f2d396504db1", + bech32: "lnp1zcssyj7z5vfx29flqlnsuzatppeyu6u9ugtl3ntz3n4k996zg7a5jvuz2gpq86zcyypjgef743p5fzqq9nqxh0ah7y87rzv3ud0eleps9kl2d5348hq2k89qwcp87v0tc4rzc87uuxmn0m8l2tfh6aw75s7wz8r56fd299ckt74zqpcr9s9he72nyjs86pfe3vjqzaxups47g3xedv2e4fk877c7v6rgpxgszqhd4w73ddqusdcmjthj7pxprpd57qakmn2jh2dh3kwhezwg7gs3g5qpqqqqqqqqqqqqqqqqqqqqqqqqqq9zrsqqqqqpqqqqqqsqqvqqqqqqqqqqqpqqqqqqqqqqqqzsqq9yq3n4y7vg4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ya2qgp73vppqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyec9uzqnrqfxq2lkcc057hwan4m0tuzdmwygujy6natt4f4l0cu5qy07zrted7kztcst59wat6hz8ps4usw3dpc6umv5nthft6vhhras4wglz8jyqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsra3qpdgshfxx3pxkqv2eemf0pj3puaeyyj6eu54zrydml08swdmcqksl3lgpgzxfq4ld3reutf4kgswu4sa4un80kds4jpxhxc4cdeuyylakjh6xrrw9f2t5200wdus8lffpdgc0z4n5gfcje2vg22783xmn3pgzj2pu7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenl2nuqe99gwwpl92800slyv8rzkea9rkjmenmvm948mw4jn049r7ncfxuts4hp62nrm888msd83czuhvk7786awuyufr58qls2t8l9rcv70e8wu6l4d3k938l9qq65jrq60jgmw57tsqrufdfg8zn8wtue0uqers6sqqj8249c6zsedzv2099l8h5fnqjhz9udjvd0ldj57rq6ms9cmcplrg9s2vdl79rfsttavyl0dc0035aam9vsju0urrvrtlahay4h0w0rssm9pakd0m55ke6na2wlx5ehzzcymmngdtfhv526tjat42u3kdn7xrl2wqnd470jty9m06wvx8dcyg05qy7xw2y78rezerayq3hqtyn5aat00khnft954rp9e9xe5rcjwujcf9haa46ngfrszv8pctgspa890llf6qh0emq2gr2lv87ta6ly7vrnk583tcaj0kvv33p0avkstcqszss", + }, + PayerProofVector { + name: "minimal_disclosure", + included_types: &[], + note: None, + leaf_hashes_hex: "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb", + omitted_tlvs: &[1, 2, 89, 90, 91, 169, 177], + missing_hashes_hex: "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db3358d8be254efbc28a1f7f9caa8c21432ba93b512d07349764d61386f186471a", + merkle_root_hex: "d75cc1c4a81b39f841f8db4e8b3156f73d973f32fc982cdce884f2d396504db1", + bech32: "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgzvvpycpt7mrp7n6amkwhda0sfhdc3rjgn204dw4xhalrjsq3lcgd09h6cf0zpws4m402uguxzhjp6958rtndjjdwa90fj7u0kz4erug7gsqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszq05quqsyk26tw5mrakqh7xt9vwkl2dumj44qx6elqkxt3gxkl6r29rn0ace0u0ul6ht44qmjsr0fnjjdf4q6nst8f37mzdgxt33ewvnnhlp576a6u3j6vkq927dn3zt2we3wqxfa58rxvxwgf0h7x86ct7p64n2x3rggwf8fu8rz60esv8jcvrse7adz077xrhrdnt3gdv3ze8dzgzq48x4jhykss9vnxv2klafcaplh8dakrvdgma78tfnxsqt6pln9rwdkcqg0y9un5kmxdvd3039fmau9zsl07w24rppgv46jw6395rnf9my6cfcduvxgud0sc8jm6h47v978nkcnlruyn2z9qvm7p40pey2x9prh0gwyc608s77vlcpj8p4qqpyw42t359pj6yc572t700gnxp9wytcmyc6l7m9fuxp5l5jkaaeuwzrv58ke4lwjjm8204fmu6nxugtqn0wdp4dxaj3tfwtlfqydczeya802mma4u62ed9gcfwffkdq7ynhykzfdl0dw56zguqnpcwz6yq0fetll6ws9m7wczjq6hmpljlwhe8nqua4pu278vnanryvgg", + }, + PayerProofVector { + name: "with_note", + included_types: &[], + note: Some("test note"), + leaf_hashes_hex: "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1a7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb", + omitted_tlvs: &[1, 2, 89, 90, 91, 169, 177], + missing_hashes_hex: "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac9998ab7fa9c743fb9dbdb0d8d46fbe3ad333400bd07f328dcdb6008790bc9d2db3358d8be254efbc28a1f7f9caa8c21432ba93b512d07349764d61386f186471a", + merkle_root_hex: "d75cc1c4a81b39f841f8db4e8b3156f73d973f32fc982cdce884f2d396504db1", + bech32: "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0yasyypyhs4rzfj320c8uu8qh2cgwf8xhp0zzluv6c5vad3fwsj8hdyn8qhsgzvvpycpt7mrp7n6amkwhda0sfhdc3rjgn204dw4xhalrjsq3lcgd09h6cf0zpws4m402uguxzhjp6958rtndjjdwa90fj7u0kz4erug7gsqzqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszq05quqsyk26tw5mrakqh7xt9vwkl2dumj44qx6elqkxt3gxkl6r29rn0ace0u0ul6ht44qmjsr0fnjjdf4q6nst8f37mzdgxt33ewvnnhlp576a6u3j6vkq927dn3zt2we3wqxfa58rxvxwgf0h7x86ct7p64n2x3rggwf8fu8rz60esv8jcvrse7adz077xrhrdnt3gdv3ze8dzgzq48x4jhykss9vnxv2klafcaplh8dakrvdgma78tfnxsqt6pln9rwdkcqg0y9un5kmxdvd3039fmau9zsl07w24rppgv46jw6395rnf9my6cfcduvxgud0sc8jm6h47v978nkcnlruyn2z9qvm7p40pey2x9prh0gwyc608s77vlcpj8p4qqpyw42t359pj6yc572t700gnxp9wytcmyc6l7m9fuxp5l5jkaaeuwzrv58ke4lwjjm8204fmu6nxugtqn0wdp4dxaj3tfwtlfyuphgt5cgcrfg50lvxftvudtmrf7ns44kal2njhfqqqy23vh0v0vn4uv74dv966eq8gmsx3xkgt3nmq6f0kzztcj9xqfcs80g6aj6sde6x2um5yphx7ar9", + }, + PayerProofVector { + name: "left_subtree_omitted", + included_types: &[170], + note: None, + leaf_hashes_hex: "f2deaf5f30be3ced89fc7c24d422819bf06af0e48a31423bbd0e2634f3c3de67f0191c35000247554b8d0a196898a794bf3de89982571178d931affb654f0c1adc0b8de03f1a0b0531bff146982d7d613ef6e1ef8d3bdd9590971fc18d835ffb7e92b77b9e3843650f6cd7ee94b6753ea9df3533710b04dee686ad376515a5cb", + omitted_tlvs: &[1, 2, 89, 90, 91, 177], + missing_hashes_hex: "bf8cb2b1d6fa9bcdcab501b59f82c65c506b7f43514737f7197f1fcfeaebad41b9406f4ce526a6a0d4e0b3a63ed89a832e31cb9939dfe1a7b5dd7232d32c02abcd9c44b53b31700c9ed0e3330ce425f7f18fac2fc1d566a34468439274f0e3169f9830f2c3070cfbad13fde30ee36cd7143591164ed12040a9cd595c96840ac93358d8be254efbc28a1f7f9caa8c21432ba93b512d07349764d61386f186471a", + merkle_root_hex: "d75cc1c4a81b39f841f8db4e8b3156f73d973f32fc982cdce884f2d396504db1", + bech32: "lnp1tqssxfr986kyx3ygqqkvq6alklcslcvfj834l8lyxqkmafkjx57up2cu4qs89ntwss3vgplmd5ycdy83zv9hmmt7ctmltcwnp0va2g0sz5mr0ya2qgp73vppqf9u9gcjv52n7pl8pc96kzrjfe4ctcshlrxk9r8tv2t5y3amfyec9uzqnrqfxq2lkcc057hwan4m0tuzdmwygujy6natt4f4l0cu5qy07zrted7kztcst59wat6hz8ps4usw3dpc6umv5nthft6vhhras4wglz8jyqqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqsraqxqyp9jkjmk8m2p0uvk2cad75meh9t2qd4n7pvvhzsddl5x528xlm3jlclel4wht2ph9qx7n89y6n2p48qkwnraky6svhrrjue8807rfa4m4er95evq24um8zyk5anzuqvnmgwxvcvusjl0uv04shur4tx5dzxssujwncwx95lnqc09sc8pna66ylauv8wxmxhzs6ez9jw6ysyp2wdt9wfdpq2eye43k97y480hs52ralee25vy9pjh2fm2ykswdyhvntp8ph3ser347yq7t027heshc7wmz0u0sjdgg5pn0cx4u8y3gc5ywaapcnrfu7rmenlqxgux5qqy364fwxs5xtgnznef0eaazvcy4c30rvnrtlmv48scxkupwx7q0c6pvznr0l3g6vz6ltp8mmwrmud80wetyyhrlqcmq6lldlf9dmmncuyxeg0dnt7a99kw5l2nhe4xdcskpx7u6r26dm9zkjuh7jqgms9jf6w74hhmte54j623sjujnv6puf8wfvyjm776af5y3cpxrsu95gq7njhll5aqthuas9yp40krl97a0j0xpem2rc4uwe8mxxgcss", + }, + ]; + + fn hex_decode(s: &str) -> Vec { + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() + } + + fn hex_encode(b: &[u8]) -> String { + b.iter().map(|x| format!("{:02x}", x)).collect() + } + + /// Split a concatenated hex string into 32-byte hash hex strings. + fn split_hashes_hex(hex: &str) -> Vec { + (0..hex.len()).step_by(64).map(|i| hex[i..i + 64].to_string()).collect() + } + + #[test] + fn check_against_spec_vectors() { + let secp_ctx = Secp256k1::new(); + let payer_keys = Keypair::from_secret_key( + &secp_ctx, + &SecretKey::from_slice(&hex_decode(PAYER_SECRET_HEX)).unwrap(), + ); + + let invoice = Bolt12Invoice::try_from(hex_decode(INVOICE_HEX)) + .expect("failed to parse invoice from test vector"); + + let preimage = PaymentPreimage(hex_decode(PREIMAGE_HEX).try_into().unwrap()); + + for vector in PAYER_PROOF_VECTORS { + let mut builder = PayerProofBuilder::new(&invoice, preimage) + .unwrap_or_else(|e| panic!("{}: builder failed: {:?}", vector.name, e)); + for &typ in vector.included_types { + if typ != INVOICE_REQUEST_PAYER_ID_TYPE + && typ != INVOICE_PAYMENT_HASH_TYPE + && typ != INVOICE_NODE_ID_TYPE + { + builder = builder.include_type(typ).unwrap_or_else(|e| { + panic!("{}: include_type({}) failed: {:?}", vector.name, typ, e) + }); + } + } + + let unsigned = builder + .build_unsigned(vector.note.map(str::to_owned)) + .unwrap_or_else(|e| panic!("{}: build failed: {:?}", vector.name, e)); + + let got_leaves: Vec = + unsigned.disclosure.leaf_hashes.iter().map(|h| hex_encode(h.as_ref())).collect(); + assert_eq!( + got_leaves, + split_hashes_hex(vector.leaf_hashes_hex), + "{}: leaf_hashes mismatch", + vector.name + ); + + assert_eq!( + unsigned.disclosure.omitted_markers, vector.omitted_tlvs, + "{}: omitted_tlvs mismatch", + vector.name + ); + + let got_missing: Vec = + unsigned.disclosure.missing_hashes.iter().map(|h| hex_encode(h.as_ref())).collect(); + assert_eq!( + got_missing, + split_hashes_hex(vector.missing_hashes_hex), + "{}: missing_hashes mismatch", + vector.name + ); + + let got_root = hex_encode(unsigned.disclosure.merkle_root.as_ref()); + assert_eq!(got_root, vector.merkle_root_hex, "{}: merkle_root mismatch", vector.name); + + let proof = unsigned + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) + .unwrap_or_else(|e| panic!("{}: sign failed: {:?}", vector.name, e)); + + assert_eq!(proof.to_string(), vector.bech32, "{}: bech32 mismatch", vector.name); + } + } +} diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index bd2488bd8d1..20ae07dbf60 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -1205,6 +1205,21 @@ impl Readable for SecretKey { } } +impl Writeable for Sha256 { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + w.write_all(&self[..]) + } +} + +impl Readable for Sha256 { + fn read(r: &mut R) -> Result { + use bitcoin::hashes::Hash; + + let buf: [u8; 32] = Readable::read(r)?; + Ok(Sha256::from_byte_array(buf)) + } +} + impl Writeable for Hmac { fn write(&self, w: &mut W) -> Result<(), io::Error> { w.write_all(&self[..]) From b7a9e81b3af79482871102ee6d1ae3ebfe9f9f83 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Thu, 9 Apr 2026 19:53:49 +0200 Subject: [PATCH 3/7] refactor(offers): move Bolt12InvoiceType into payer_proof Rename the old PaidBolt12Invoice enum to Bolt12InvoiceType, move it out of events, and update outbound payment plumbing to store the renamed invoice type directly. --- lightning/src/events/mod.rs | 22 ++++------------------ lightning/src/ln/async_payments_tests.rs | 21 +++++++++++---------- lightning/src/ln/channelmanager.rs | 9 +++++---- lightning/src/ln/functional_test_utils.rs | 11 ++++++----- lightning/src/ln/offers_tests.rs | 4 ++-- lightning/src/ln/outbound_payment.rs | 23 ++++++++++++----------- 6 files changed, 40 insertions(+), 50 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 73c4a39c76f..256cda36588 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -31,6 +31,7 @@ use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::types::ChannelId; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_request::InvoiceRequest; +use crate::offers::payer_proof::Bolt12InvoiceType; use crate::offers::static_invoice::StaticInvoice; use crate::onion_message::messenger::Responder; use crate::routing::gossip::NetworkUpdate; @@ -1096,11 +1097,12 @@ pub enum Event { /// showing the invoice and confirming that the payment hash matches /// the hash of the payment preimage. /// - /// However, the [`PaidBolt12Invoice`] can also be of type [`StaticInvoice`], which + /// However, the [`Bolt12InvoiceType`] can also be of type [`StaticInvoice`], which /// is a special [`Bolt12Invoice`] where proof of payment is not possible. /// + /// [`Bolt12InvoiceType`]: crate::offers::payer_proof::Bolt12InvoiceType /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice - bolt12_invoice: Option, + bolt12_invoice: Option, }, /// Indicates an outbound payment failed. Individual [`Event::PaymentPathFailed`] events /// provide failure information for each path attempt in the payment, including retries. @@ -3146,19 +3148,3 @@ impl EventHandler for Arc { self.deref().handle_event(event) } } - -/// The BOLT 12 invoice that was paid, surfaced in [`Event::PaymentSent::bolt12_invoice`]. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub enum PaidBolt12Invoice { - /// The BOLT 12 invoice specified by the BOLT 12 specification, - /// allowing the user to perform proof of payment. - Bolt12Invoice(Bolt12Invoice), - /// The Static invoice, used in the async payment specification update proposal, - /// where the user cannot perform proof of payment. - StaticInvoice(StaticInvoice), -} - -impl_writeable_tlv_based_enum!(PaidBolt12Invoice, - {0, Bolt12Invoice} => (), - {2, StaticInvoice} => (), -); diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index bd07d13c13d..1f726eb697b 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -14,7 +14,7 @@ use crate::blinded_path::payment::{AsyncBolt12OfferContext, BlindedPaymentTlvs}; use crate::blinded_path::payment::{DummyTlvs, PaymentContext}; use crate::chain::channelmonitor::{HTLC_FAIL_BACK_BUFFER, LATENCY_GRACE_PERIOD_BLOCKS}; use crate::events::{ - Event, EventsProvider, HTLCHandlingFailureReason, HTLCHandlingFailureType, PaidBolt12Invoice, + Event, EventsProvider, HTLCHandlingFailureReason, HTLCHandlingFailureType, PaymentFailureReason, PaymentPurpose, }; use crate::ln::blinded_payment_tests::{fail_blinded_htlc_backwards, get_blinded_route_parameters}; @@ -42,6 +42,7 @@ use crate::offers::flow::{ use crate::offers::invoice_request::InvoiceRequest; use crate::offers::nonce::Nonce; use crate::offers::offer::{Amount, Offer}; +use crate::offers::payer_proof::Bolt12InvoiceType; use crate::offers::static_invoice::{ StaticInvoice, StaticInvoiceBuilder, DEFAULT_RELATIVE_EXPIRY as STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY, @@ -991,7 +992,7 @@ fn ignore_duplicate_invoice() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice.clone()))); + assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice.clone()))); // After paying the static invoice, check that regular invoice received from async recipient is ignored. match sender.onion_messenger.peel_onion_message(&invoice_om) { @@ -1076,7 +1077,7 @@ fn ignore_duplicate_invoice() { // After paying invoice, check that static invoice is ignored. let res = claim_payment(sender, route[0], payment_preimage); - assert_eq!(res, Some(PaidBolt12Invoice::Bolt12Invoice(invoice))); + assert_eq!(res, Some(Bolt12InvoiceType::Bolt12Invoice(invoice))); sender.onion_messenger.handle_onion_message(always_online_node_id, &static_invoice_om); let async_pmts_msgs = AsyncPaymentsMessageHandler::release_pending_messages(sender.node); @@ -1147,7 +1148,7 @@ fn async_receive_flow_success() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); - assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); + assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice))); } #[cfg_attr(feature = "std", ignore)] @@ -2390,7 +2391,7 @@ fn refresh_static_invoices_for_used_offers() { let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); let res = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res.0, Some(PaidBolt12Invoice::StaticInvoice(updated_invoice))); + assert_eq!(res.0, Some(Bolt12InvoiceType::StaticInvoice(updated_invoice))); } #[cfg_attr(feature = "std", ignore)] @@ -2725,7 +2726,7 @@ fn invoice_server_is_not_channel_peer() { let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); let res = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res.0, Some(PaidBolt12Invoice::StaticInvoice(invoice))); + assert_eq!(res.0, Some(Bolt12InvoiceType::StaticInvoice(invoice))); } #[test] @@ -2968,7 +2969,7 @@ fn async_payment_e2e() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); + assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice))); } #[test] @@ -3205,7 +3206,7 @@ fn intercepted_hold_htlc() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); + assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice))); } #[test] @@ -3455,7 +3456,7 @@ fn release_htlc_races_htlc_onion_decode() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); + assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice))); } #[test] @@ -3619,5 +3620,5 @@ fn async_payment_e2e_release_before_hold_registered() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); + assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice))); } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 1f32423507f..e371da8dc49 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -49,11 +49,11 @@ use crate::chain::channelmonitor::{ }; use crate::chain::transaction::{OutPoint, TransactionData}; use crate::chain::{BlockLocator, ChannelMonitorUpdateStatus, Confirm, Watch}; +use crate::events::FundingInfo; use crate::events::{ self, ClosureReason, Event, EventHandler, EventsProvider, HTLCHandlingFailureType, InboundChannelFunds, PaymentFailureReason, ReplayEvent, }; -use crate::events::{FundingInfo, PaidBolt12Invoice}; use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight; #[cfg(any(test, fuzzing, feature = "_test_utils"))] use crate::ln::channel::QuiescentAction; @@ -102,6 +102,7 @@ use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestVerifiedFromO use crate::offers::nonce::Nonce; use crate::offers::offer::{Offer, OfferFromHrn}; use crate::offers::parse::Bolt12SemanticError; +use crate::offers::payer_proof::Bolt12InvoiceType; use crate::offers::refund::Refund; use crate::offers::static_invoice::StaticInvoice; use crate::onion_message::async_payments::{ @@ -873,7 +874,7 @@ mod fuzzy_channelmanager { /// The BOLT12 invoice associated with this payment, if any. This is stored here to ensure /// we can provide proof-of-payment details in payment claim events even after a restart /// with a stale ChannelManager state. - bolt12_invoice: Option, + bolt12_invoice: Option, }, } @@ -1017,7 +1018,7 @@ impl HTLCSource { pub(crate) fn static_invoice(&self) -> Option { match self { Self::OutboundRoute { - bolt12_invoice: Some(PaidBolt12Invoice::StaticInvoice(inv)), + bolt12_invoice: Some(Bolt12InvoiceType::StaticInvoice(inv)), .. } => Some(inv.clone()), _ => None, @@ -17843,7 +17844,7 @@ impl Readable for HTLCSource { let mut payment_id = None; let mut payment_params: Option = None; let mut blinded_tail: Option = None; - let mut bolt12_invoice: Option = None; + let mut bolt12_invoice: Option = None; read_tlv_fields!(reader, { (0, session_priv, required), (1, payment_id, option), diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index b48d76d646d..7a3ff18adfe 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -19,8 +19,8 @@ use crate::chain::{BlockLocator, ChannelMonitorUpdateStatus, Confirm, Listen, Wa use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ - ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, PaidBolt12Invoice, - PathFailure, PaymentFailureReason, PaymentPurpose, + ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, PathFailure, + PaymentFailureReason, PaymentPurpose, }; use crate::ln::chan_utils::{ commitment_tx_base_weight, COMMITMENT_TX_WEIGHT_PER_HTLC, TRUC_MAX_WEIGHT, @@ -39,6 +39,7 @@ use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::outbound_payment::Retry; use crate::ln::peer_handler::IgnoringMessageHandler; use crate::ln::types::ChannelId; +use crate::offers::payer_proof::Bolt12InvoiceType; use crate::onion_message::messenger::OnionMessenger; use crate::routing::gossip::{NetworkGraph, NetworkUpdate, P2PGossipSync}; use crate::routing::router::{self, PaymentParameters, Route, RouteParameters}; @@ -2988,7 +2989,7 @@ pub fn expect_payment_sent>( node: &H, expected_payment_preimage: PaymentPreimage, expected_fee_msat_opt: Option>, expect_per_path_claims: bool, expect_post_ev_mon_update: bool, -) -> (Option, Vec) { +) -> (Option, Vec) { if expect_post_ev_mon_update { check_added_monitors(node, 0); } @@ -4212,7 +4213,7 @@ pub fn pass_claimed_payment_along_route_from_ev( pub fn claim_payment_along_route( args: ClaimAlongRouteArgs, -) -> (Option, Vec) { +) -> (Option, Vec) { let ClaimAlongRouteArgs { origin_node, payment_preimage, @@ -4234,7 +4235,7 @@ pub fn claim_payment_along_route( pub fn claim_payment<'a, 'b, 'c>( origin_node: &Node<'a, 'b, 'c>, expected_route: &[&Node<'a, 'b, 'c>], our_payment_preimage: PaymentPreimage, -) -> Option { +) -> Option { claim_payment_along_route(ClaimAlongRouteArgs::new( origin_node, &[expected_route], diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index a5bac3f72e7..79fe94f0d7a 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -49,7 +49,7 @@ use crate::blinded_path::IntroductionNode; use crate::blinded_path::message::BlindedMessagePath; use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, DummyTlvs, PaymentContext}; use crate::blinded_path::message::OffersContext; -use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose}; +use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaymentFailureReason, PaymentPurpose}; use crate::ln::channelmanager::{PaymentId, RecentPaymentDetails, self}; use crate::ln::outbound_payment::{Bolt12PaymentError, RecipientOnionFields, Retry}; use crate::types::features::Bolt12InvoiceFeatures; @@ -253,7 +253,7 @@ fn claim_bolt12_payment_with_extra_fees<'a, 'b, 'c>( } let (inv, _) = claim_payment_along_route(args); - assert_eq!(inv, Some(PaidBolt12Invoice::Bolt12Invoice(invoice.clone()))); + assert_eq!(inv, Some(Bolt12InvoiceType::Bolt12Invoice(invoice.clone()))); } fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> Nonce { diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 7259f60796f..7ccf2f9e454 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -15,7 +15,7 @@ use bitcoin::secp256k1::{self, Secp256k1, SecretKey}; use lightning_invoice::Bolt11Invoice; use crate::blinded_path::{IntroductionNode, NodeIdLookUp}; -use crate::events::{self, PaidBolt12Invoice, PaymentFailureReason}; +use crate::events::{self, PaymentFailureReason}; use crate::ln::channel_state::ChannelDetails; use crate::ln::channelmanager::{ EventCompletionAction, HTLCSource, OptionalBolt11PaymentParams, PaymentCompleteUpdate, @@ -27,6 +27,7 @@ use crate::ln::onion_utils::{DecodedOnionFailure, HTLCFailReason}; use crate::offers::invoice::{Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder}; use crate::offers::invoice_request::InvoiceRequest; use crate::offers::nonce::Nonce; +use crate::offers::payer_proof::Bolt12InvoiceType; use crate::offers::static_invoice::StaticInvoice; use crate::routing::router::{ BlindedTail, InFlightHtlcs, Path, PaymentParameters, Route, RouteParameters, @@ -126,7 +127,7 @@ pub(crate) enum PendingOutboundPayment { invoice_request: Option, // Storing the BOLT 12 invoice here to allow Proof of Payment after // the payment is made. - bolt12_invoice: Option, + bolt12_invoice: Option, custom_tlvs: Vec<(u64, Vec)>, pending_amt_msat: u64, /// Used to track the fee paid. Present iff the payment was serialized on 0.0.103+. @@ -181,7 +182,7 @@ impl_writeable_tlv_based!(RetryableInvoiceRequest, { }); impl PendingOutboundPayment { - fn bolt12_invoice(&self) -> Option<&PaidBolt12Invoice> { + fn bolt12_invoice(&self) -> Option<&Bolt12InvoiceType> { match self { PendingOutboundPayment::Retryable { bolt12_invoice, .. } => bolt12_invoice.as_ref(), _ => None, @@ -934,7 +935,7 @@ pub(super) struct SendAlongPathArgs<'a> { pub payment_id: PaymentId, pub keysend_preimage: &'a Option, pub invoice_request: Option<&'a InvoiceRequest>, - pub bolt12_invoice: Option<&'a PaidBolt12Invoice>, + pub bolt12_invoice: Option<&'a Bolt12InvoiceType>, pub session_priv_bytes: [u8; 32], pub hold_htlc_at_next_hop: bool, } @@ -1122,7 +1123,7 @@ impl OutboundPayments { if let Some(max_fee_msat) = params_config.max_total_routing_fee_msat { route_params.max_total_routing_fee_msat = Some(max_fee_msat); } - let invoice = PaidBolt12Invoice::Bolt12Invoice(invoice.clone()); + let invoice = Bolt12InvoiceType::Bolt12Invoice(invoice.clone()); self.send_payment_for_bolt12_invoice_internal( payment_id, payment_hash, None, None, invoice, route_params, retry_strategy, false, router, first_hops, inflight_htlcs, entropy_source, node_signer, node_id_lookup, secp_ctx, @@ -1136,7 +1137,7 @@ impl OutboundPayments { >( &self, payment_id: PaymentId, payment_hash: PaymentHash, keysend_preimage: Option, invoice_request: Option<&InvoiceRequest>, - bolt12_invoice: PaidBolt12Invoice, + bolt12_invoice: Bolt12InvoiceType, mut route_params: RouteParameters, retry_strategy: Retry, hold_htlcs_at_next_hop: bool, router: &R, first_hops: Vec, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, node_id_lookup: &NL, secp_ctx: &Secp256k1, best_block_height: u32, @@ -1395,7 +1396,7 @@ impl OutboundPayments { retry_strategy = Retry::Attempts(0); } - let invoice = PaidBolt12Invoice::StaticInvoice(invoice); + let invoice = Bolt12InvoiceType::StaticInvoice(invoice); self.send_payment_for_bolt12_invoice_internal( payment_id, payment_hash, @@ -1977,7 +1978,7 @@ impl OutboundPayments { &self, payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, payment_id: PaymentId, keysend_preimage: Option, route: &Route, retry_strategy: Option, payment_params: Option, entropy_source: &ES, best_block_height: u32, - bolt12_invoice: Option + bolt12_invoice: Option ) -> Result, PaymentSendFailure> { let mut pending_outbounds = self.pending_outbound_payments.lock().unwrap(); match pending_outbounds.entry(payment_id) { @@ -1997,7 +1998,7 @@ impl OutboundPayments { fn create_pending_payment( payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, keysend_preimage: Option, invoice_request: Option, - bolt12_invoice: Option, route: &Route, retry_strategy: Option, + bolt12_invoice: Option, route: &Route, retry_strategy: Option, payment_params: Option, entropy_source: &ES, best_block_height: u32 ) -> (PendingOutboundPayment, Vec<[u8; 32]>) { let mut onion_session_privs = Vec::with_capacity(route.paths.len()); @@ -2107,7 +2108,7 @@ impl OutboundPayments { #[rustfmt::skip] fn pay_route_internal( &self, route: &Route, payment_hash: PaymentHash, recipient_onion: &RecipientOnionFields, - keysend_preimage: Option, invoice_request: Option<&InvoiceRequest>, bolt12_invoice: Option<&PaidBolt12Invoice>, + keysend_preimage: Option, invoice_request: Option<&InvoiceRequest>, bolt12_invoice: Option<&Bolt12InvoiceType>, payment_id: PaymentId, onion_session_privs: &Vec<[u8; 32]>, hold_htlcs_at_next_hop: bool, node_signer: &NS, best_block_height: u32, send_payment_along_path: &F ) -> Result<(), PaymentSendFailure> @@ -2247,7 +2248,7 @@ impl OutboundPayments { #[rustfmt::skip] pub(super) fn claim_htlc( - &self, payment_id: PaymentId, payment_preimage: PaymentPreimage, bolt12_invoice: Option, + &self, payment_id: PaymentId, payment_preimage: PaymentPreimage, bolt12_invoice: Option, session_priv: SecretKey, path: Path, from_onchain: bool, ev_completion_action: &mut Option, pending_events: &Mutex)>>, logger: &WithContext, From 54ec8f24d6505661851a098af2bc1809ba9efa58 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Fri, 17 Apr 2026 08:54:48 +0200 Subject: [PATCH 4/7] refactor(offers): bundle paid invoice data for payer proofs Encapsulate the paid invoice, preimage, and payer nonce in the PaidBolt12Invoice struct and surface it through Event::PaymentSent::bolt12_invoice. To support the nonce round-trip, plumb payment_nonce through HTLCSource::OutboundRoute, SendAlongPathArgs, PendingOutboundPayment::Retryable and the outbound payment internals, and extract it from the OffersContext variants so payers can later re-derive the payer signing key from the same nonce used for the invoice request. Update expect_payment_sent, claim_payment, claim_payment_along_route and the async-payments test assertions to surface and consume the PaidBolt12Invoice. Also add Writeable/Readable impls for sha256::Hash in util::ser so PaidBolt12Invoice serialization compiles. Co-Authored-By: Jeffrey Czyz Co-Authored-By: Claude Opus 4.7 (1M context) --- fuzz/src/process_onion_failure.rs | 1 + lightning/src/events/mod.rs | 33 ++++---- lightning/src/ln/async_payments_tests.rs | 19 +++-- lightning/src/ln/channel.rs | 2 + lightning/src/ln/channelmanager.rs | 59 +++++++++++--- lightning/src/ln/functional_test_utils.rs | 9 ++- lightning/src/ln/offers_tests.rs | 97 +++++++++++------------ lightning/src/ln/onion_utils.rs | 4 + lightning/src/ln/outbound_payment.rs | 72 ++++++++++++----- 9 files changed, 187 insertions(+), 109 deletions(-) diff --git a/fuzz/src/process_onion_failure.rs b/fuzz/src/process_onion_failure.rs index ac70562c006..69f12a9fb49 100644 --- a/fuzz/src/process_onion_failure.rs +++ b/fuzz/src/process_onion_failure.rs @@ -122,6 +122,7 @@ fn do_test(data: &[u8], out: Out) { first_hop_htlc_msat: 0, payment_id, bolt12_invoice: None, + payment_nonce: None, }; let failure_len = get_u16!(); diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 256cda36588..2147846baef 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -31,7 +31,9 @@ use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::types::ChannelId; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_request::InvoiceRequest; +use crate::offers::nonce::Nonce; use crate::offers::payer_proof::Bolt12InvoiceType; +pub use crate::offers::payer_proof::PaidBolt12Invoice; use crate::offers::static_invoice::StaticInvoice; use crate::onion_message::messenger::Responder; use crate::routing::gossip::NetworkUpdate; @@ -1090,19 +1092,14 @@ pub enum Event { /// /// [`Route::get_total_fees`]: crate::routing::router::Route::get_total_fees fee_paid_msat: Option, - /// The BOLT 12 invoice that was paid. `None` if the payment was a non BOLT 12 payment. + /// The paid BOLT 12 invoice bundled with the data needed to construct a + /// [`PayerProof`], which selectively discloses invoice fields to prove payment to a + /// third party. /// - /// The BOLT 12 invoice is useful for proof of payment because it contains the - /// payment hash. A third party can verify that the payment was made by - /// showing the invoice and confirming that the payment hash matches - /// the hash of the payment preimage. + /// `None` for non-BOLT 12 payments. /// - /// However, the [`Bolt12InvoiceType`] can also be of type [`StaticInvoice`], which - /// is a special [`Bolt12Invoice`] where proof of payment is not possible. - /// - /// [`Bolt12InvoiceType`]: crate::offers::payer_proof::Bolt12InvoiceType - /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice - bolt12_invoice: Option, + /// [`PayerProof`]: crate::offers::payer_proof::PayerProof + bolt12_invoice: Option, }, /// Indicates an outbound payment failed. Individual [`Event::PaymentPathFailed`] events /// provide failure information for each path attempt in the payment, including retries. @@ -1977,13 +1974,16 @@ impl Writeable for Event { ref bolt12_invoice, } => { 2u8.write(writer)?; + let invoice_type = bolt12_invoice.as_ref().map(|paid| paid.invoice_type()); + let payment_nonce = bolt12_invoice.as_ref().and_then(|paid| paid.nonce()); write_tlv_fields!(writer, { (0, payment_preimage, required), (1, payment_hash, required), (3, payment_id, option), (5, fee_paid_msat, option), (7, amount_msat, option), - (9, bolt12_invoice, option), + (9, invoice_type, option), + (11, payment_nonce, option), }); }, &Event::PaymentPathFailed { @@ -2475,20 +2475,25 @@ impl MaybeReadable for Event { let mut payment_id = None; let mut amount_msat = None; let mut fee_paid_msat = None; - let mut bolt12_invoice = None; + let mut invoice_type: Option = None; + let mut payment_nonce: Option = None; read_tlv_fields!(reader, { (0, payment_preimage, required), (1, payment_hash, option), (3, payment_id, option), (5, fee_paid_msat, option), (7, amount_msat, option), - (9, bolt12_invoice, option), + (9, invoice_type, option), + (11, payment_nonce, option), }); if payment_hash.is_none() { payment_hash = Some(PaymentHash( Sha256::hash(&payment_preimage.0[..]).to_byte_array(), )); } + let bolt12_invoice = invoice_type.map(|invoice| { + PaidBolt12Invoice::new(invoice, payment_preimage, payment_nonce) + }); Ok(Some(Event::PaymentSent { payment_id, payment_preimage, diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 1f726eb697b..16687466c6d 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -42,7 +42,6 @@ use crate::offers::flow::{ use crate::offers::invoice_request::InvoiceRequest; use crate::offers::nonce::Nonce; use crate::offers::offer::{Amount, Offer}; -use crate::offers::payer_proof::Bolt12InvoiceType; use crate::offers::static_invoice::{ StaticInvoice, StaticInvoiceBuilder, DEFAULT_RELATIVE_EXPIRY as STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY, @@ -992,7 +991,7 @@ fn ignore_duplicate_invoice() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice.clone()))); + assert_eq!(res.as_ref().and_then(|paid| paid.static_invoice()), Some(&static_invoice)); // After paying the static invoice, check that regular invoice received from async recipient is ignored. match sender.onion_messenger.peel_onion_message(&invoice_om) { @@ -1077,7 +1076,7 @@ fn ignore_duplicate_invoice() { // After paying invoice, check that static invoice is ignored. let res = claim_payment(sender, route[0], payment_preimage); - assert_eq!(res, Some(Bolt12InvoiceType::Bolt12Invoice(invoice))); + assert_eq!(res.as_ref().and_then(|paid| paid.bolt12_invoice()), Some(&invoice)); sender.onion_messenger.handle_onion_message(always_online_node_id, &static_invoice_om); let async_pmts_msgs = AsyncPaymentsMessageHandler::release_pending_messages(sender.node); @@ -1148,7 +1147,7 @@ fn async_receive_flow_success() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); - assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice))); + assert_eq!(res.as_ref().and_then(|paid| paid.static_invoice()), Some(&static_invoice)); } #[cfg_attr(feature = "std", ignore)] @@ -2391,7 +2390,7 @@ fn refresh_static_invoices_for_used_offers() { let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); let res = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res.0, Some(Bolt12InvoiceType::StaticInvoice(updated_invoice))); + assert_eq!(res.0.as_ref().and_then(|paid| paid.static_invoice()), Some(&updated_invoice)); } #[cfg_attr(feature = "std", ignore)] @@ -2726,7 +2725,7 @@ fn invoice_server_is_not_channel_peer() { let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); let res = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res.0, Some(Bolt12InvoiceType::StaticInvoice(invoice))); + assert_eq!(res.0.as_ref().and_then(|paid| paid.static_invoice()), Some(&invoice)); } #[test] @@ -2969,7 +2968,7 @@ fn async_payment_e2e() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice))); + assert_eq!(res.as_ref().and_then(|paid| paid.static_invoice()), Some(&static_invoice)); } #[test] @@ -3206,7 +3205,7 @@ fn intercepted_hold_htlc() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice))); + assert_eq!(res.as_ref().and_then(|paid| paid.static_invoice()), Some(&static_invoice)); } #[test] @@ -3456,7 +3455,7 @@ fn release_htlc_races_htlc_onion_decode() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice))); + assert_eq!(res.as_ref().and_then(|paid| paid.static_invoice()), Some(&static_invoice)); } #[test] @@ -3620,5 +3619,5 @@ fn async_payment_e2e_release_before_hold_registered() { let keysend_preimage = extract_payment_preimage(&claimable_ev); let (res, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); - assert_eq!(res, Some(Bolt12InvoiceType::StaticInvoice(static_invoice))); + assert_eq!(res.as_ref().and_then(|paid| paid.static_invoice()), Some(&static_invoice)); } diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index e6397aefbcb..1810c805a7f 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -17081,6 +17081,7 @@ mod tests { first_hop_htlc_msat: 548, payment_id: PaymentId([42; 32]), bolt12_invoice: None, + payment_nonce: None, }, skimmed_fee_msat: None, blinding_point: None, @@ -17574,6 +17575,7 @@ mod tests { first_hop_htlc_msat: 0, payment_id: PaymentId([42; 32]), bolt12_invoice: None, + payment_nonce: None, }; let dummy_outbound_output = OutboundHTLCOutput { htlc_id: 0, diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index e371da8dc49..02b521e79dd 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -871,10 +871,20 @@ mod fuzzy_channelmanager { /// doing a double-pass on route when we get a failure back first_hop_htlc_msat: u64, payment_id: PaymentId, - /// The BOLT12 invoice associated with this payment, if any. This is stored here to ensure - /// we can provide proof-of-payment details in payment claim events even after a restart - /// with a stale ChannelManager state. + /// The BOLT 12 invoice associated with this payment, if any. Stored here so it can + /// be bundled into [`PaidBolt12Invoice`] in [`Event::PaymentSent`] even after a + /// restart with a stale `ChannelManager` state. + /// + /// [`PaidBolt12Invoice`]: crate::offers::payer_proof::PaidBolt12Invoice + /// [`Event::PaymentSent`]: crate::events::Event::PaymentSent bolt12_invoice: Option, + /// The [`Nonce`] used when the BOLT 12 [`InvoiceRequest`] was created. Stored here so + /// it can be bundled into [`PaidBolt12Invoice`] for building payer proofs, even after + /// a restart with a stale `ChannelManager` state. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`PaidBolt12Invoice`]: crate::offers::payer_proof::PaidBolt12Invoice + payment_nonce: Option, }, } @@ -956,6 +966,7 @@ impl core::hash::Hash for HTLCSource { payment_id, first_hop_htlc_msat, bolt12_invoice, + .. } => { 1u8.hash(hasher); path.hash(hasher); @@ -990,6 +1001,7 @@ impl HTLCSource { first_hop_htlc_msat: 0, payment_id: PaymentId([2; 32]), bolt12_invoice: None, + payment_nonce: None, } } @@ -1018,9 +1030,9 @@ impl HTLCSource { pub(crate) fn static_invoice(&self) -> Option { match self { Self::OutboundRoute { - bolt12_invoice: Some(Bolt12InvoiceType::StaticInvoice(inv)), + bolt12_invoice: Some(Bolt12InvoiceType::StaticInvoice(invoice)), .. - } => Some(inv.clone()), + } => Some(invoice.clone()), _ => None, } } @@ -5432,6 +5444,7 @@ impl< keysend_preimage, invoice_request: None, bolt12_invoice: None, + payment_nonce: None, session_priv_bytes, hold_htlc_at_next_hop: false, }) @@ -5447,6 +5460,7 @@ impl< keysend_preimage, invoice_request, bolt12_invoice, + payment_nonce, session_priv_bytes, hold_htlc_at_next_hop, } = args; @@ -5523,6 +5537,7 @@ impl< first_hop_htlc_msat: htlc_msat, payment_id, bolt12_invoice: bolt12_invoice.cloned(), + payment_nonce, }; let send_res = chan.send_htlc_and_commit( htlc_msat, @@ -5816,14 +5831,21 @@ impl< pub fn send_payment_for_bolt12_invoice( &self, invoice: &Bolt12Invoice, context: Option<&OffersContext>, ) -> Result<(), Bolt12PaymentError> { + let nonce = context.and_then(|ctx| match ctx { + OffersContext::OutboundPaymentForOffer { nonce, .. } + | OffersContext::OutboundPaymentForRefund { nonce, .. } => Some(*nonce), + _ => None, + }); match self.flow.verify_bolt12_invoice(invoice, context) { - Ok(payment_id) => self.send_payment_for_verified_bolt12_invoice(invoice, payment_id), + Ok(payment_id) => { + self.send_payment_for_verified_bolt12_invoice(invoice, payment_id, nonce) + }, Err(()) => Err(Bolt12PaymentError::UnexpectedInvoice), } } fn send_payment_for_verified_bolt12_invoice( - &self, invoice: &Bolt12Invoice, payment_id: PaymentId, + &self, invoice: &Bolt12Invoice, payment_id: PaymentId, payment_nonce: Option, ) -> Result<(), Bolt12PaymentError> { let best_block_height = self.best_block.read().unwrap().height; let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); @@ -5831,6 +5853,7 @@ impl< self.pending_outbound_payments.send_payment_for_bolt12_invoice( invoice, payment_id, + payment_nonce, &self.router, self.list_usable_channels(), features, @@ -10129,7 +10152,12 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ let htlc_id = SentHTLCId::from_source(&source); match source { HTLCSource::OutboundRoute { - session_priv, payment_id, path, bolt12_invoice, .. + session_priv, + payment_id, + path, + bolt12_invoice, + payment_nonce, + .. } => { debug_assert!(!startup_replay, "We don't support claim_htlc claims during startup - monitors may not be available yet"); @@ -10161,6 +10189,7 @@ This indicates a bug inside LDK. Please report this error at https://github.com/ payment_id, payment_preimage, bolt12_invoice, + payment_nonce, session_priv, path, from_onchain, @@ -17202,7 +17231,12 @@ impl< return None; } - let res = self.send_payment_for_verified_bolt12_invoice(&invoice, payment_id); + let payment_nonce = context.as_ref().and_then(|ctx| match ctx { + OffersContext::OutboundPaymentForOffer { nonce, .. } + | OffersContext::OutboundPaymentForRefund { nonce, .. } => Some(*nonce), + _ => None, + }); + let res = self.send_payment_for_verified_bolt12_invoice(&invoice, payment_id, payment_nonce); handle_pay_invoice_res!(res, invoice, logger); }, OffersMessage::StaticInvoice(invoice) => { @@ -17845,6 +17879,7 @@ impl Readable for HTLCSource { let mut payment_params: Option = None; let mut blinded_tail: Option = None; let mut bolt12_invoice: Option = None; + let mut payment_nonce: Option = None; read_tlv_fields!(reader, { (0, session_priv, required), (1, payment_id, option), @@ -17853,6 +17888,7 @@ impl Readable for HTLCSource { (5, payment_params, (option: ReadableArgs, 0)), (6, blinded_tail, option), (7, bolt12_invoice, option), + (9, payment_nonce, option), }); if payment_id.is_none() { // For backwards compat, if there was no payment_id written, use the session_priv bytes @@ -17876,6 +17912,7 @@ impl Readable for HTLCSource { path, payment_id: payment_id.unwrap(), bolt12_invoice, + payment_nonce, }) } 1 => Ok(HTLCSource::PreviousHopData(Readable::read(reader)?)), @@ -17895,6 +17932,7 @@ impl Writeable for HTLCSource { ref path, payment_id, bolt12_invoice, + payment_nonce, } => { 0u8.write(writer)?; let payment_id_opt = Some(payment_id); @@ -17907,6 +17945,7 @@ impl Writeable for HTLCSource { (5, None::, option), // payment_params in LDK versions prior to 0.0.115 (6, path.blinded_tail, option), (7, bolt12_invoice, option), + (9, payment_nonce, option), }); }, HTLCSource::PreviousHopData(ref field) => { @@ -19771,6 +19810,7 @@ impl< session_priv, path, bolt12_invoice, + payment_nonce, .. } => { if let Some(preimage) = preimage_opt { @@ -19788,6 +19828,7 @@ impl< payment_id, preimage, bolt12_invoice, + payment_nonce, session_priv, path, true, diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 7a3ff18adfe..5497e9ef057 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -39,7 +39,7 @@ use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::outbound_payment::Retry; use crate::ln::peer_handler::IgnoringMessageHandler; use crate::ln::types::ChannelId; -use crate::offers::payer_proof::Bolt12InvoiceType; +use crate::offers::payer_proof::PaidBolt12Invoice; use crate::onion_message::messenger::OnionMessenger; use crate::routing::gossip::{NetworkGraph, NetworkUpdate, P2PGossipSync}; use crate::routing::router::{self, PaymentParameters, Route, RouteParameters}; @@ -2989,7 +2989,7 @@ pub fn expect_payment_sent>( node: &H, expected_payment_preimage: PaymentPreimage, expected_fee_msat_opt: Option>, expect_per_path_claims: bool, expect_post_ev_mon_update: bool, -) -> (Option, Vec) { +) -> (Option, Vec) { if expect_post_ev_mon_update { check_added_monitors(node, 0); } @@ -3016,6 +3016,7 @@ pub fn expect_payment_sent>( ref amount_msat, ref fee_paid_msat, ref bolt12_invoice, + .. } => { assert_eq!(expected_payment_preimage, *payment_preimage); assert_eq!(expected_payment_hash, *payment_hash); @@ -4213,7 +4214,7 @@ pub fn pass_claimed_payment_along_route_from_ev( pub fn claim_payment_along_route( args: ClaimAlongRouteArgs, -) -> (Option, Vec) { +) -> (Option, Vec) { let ClaimAlongRouteArgs { origin_node, payment_preimage, @@ -4235,7 +4236,7 @@ pub fn claim_payment_along_route( pub fn claim_payment<'a, 'b, 'c>( origin_node: &Node<'a, 'b, 'c>, expected_route: &[&Node<'a, 'b, 'c>], our_payment_preimage: PaymentPreimage, -) -> Option { +) -> Option { claim_payment_along_route(ClaimAlongRouteArgs::new( origin_node, &[expected_route], diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 79fe94f0d7a..5db175b3dd6 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -61,7 +61,7 @@ use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; use crate::offers::parse::Bolt12SemanticError; -use crate::offers::payer_proof::{self, Bolt12InvoiceType, PayerProof, PayerProofError}; +use crate::offers::payer_proof::{Bolt12InvoiceType, PaidBolt12Invoice, PayerProof, PayerProofError}; use crate::types::payment::PaymentPreimage; 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; @@ -252,8 +252,8 @@ fn claim_bolt12_payment_with_extra_fees<'a, 'b, 'c>( args = args.with_expected_extra_total_fees_msat(extra); } - let (inv, _) = claim_payment_along_route(args); - assert_eq!(inv, Some(Bolt12InvoiceType::Bolt12Invoice(invoice.clone()))); + let (paid_invoice, _) = claim_payment_along_route(args); + assert_eq!(paid_invoice.as_ref().and_then(|paid| paid.bolt12_invoice()), Some(invoice)); } fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> Nonce { @@ -270,7 +270,7 @@ fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessa /// /// When the payer receives an invoice through their reply path, the blinded path context /// contains the nonce originally used for deriving their payer signing key. This nonce is -/// needed to build a [`PayerProof`] using [`payer_proof::PaidBolt12Invoice::prove_payer_derived`]. +/// needed to build a [`PayerProof`] using [`PaidBolt12Invoice::prove_payer_derived`]. fn extract_payer_context<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> (PaymentId, Nonce) { match node.onion_messenger.peel_onion_message(message) { Ok(PeeledOnion::Offers(_, Some(OffersContext::OutboundPaymentForOffer { payment_id, nonce, .. }), _)) => (payment_id, nonce), @@ -2730,7 +2730,7 @@ fn creates_and_verifies_payer_proof_after_offer_payment() { // Extract the payer nonce and payment_id from Bob's reply path context. In a real wallet, // these would be persisted alongside the payment for later payer proof creation. - let (context_payment_id, payer_nonce) = extract_payer_context(bob, &onion_message); + let (context_payment_id, _payer_nonce) = extract_payer_context(bob, &onion_message); assert_eq!(context_payment_id, payment_id); // Route the payment @@ -2757,7 +2757,7 @@ fn creates_and_verifies_payer_proof_after_offer_payment() { _ => panic!("Expected Event::PaymentClaimable"), }; - claim_payment(bob, &[alice], payment_preimage); + let paid_invoice = claim_payment(bob, &[alice], payment_preimage).unwrap(); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); // --- Payer Proof Creation --- @@ -2765,13 +2765,9 @@ fn creates_and_verifies_payer_proof_after_offer_payment() { // He includes the offer description and invoice amount, but omits other fields for privacy. let expanded_key = bob.keys_manager.get_expanded_key(); let secp_ctx = Secp256k1::new(); - let paid_invoice = payer_proof::PaidBolt12Invoice::new( - Bolt12InvoiceType::Bolt12Invoice(invoice.clone()), - payment_preimage, - Some(payer_nonce), - ); - let proof = paid_invoice - .prove_payer_derived(&expanded_key, payment_id, &secp_ctx).unwrap() + let payer_proof = paid_invoice.prove_payer_derived( + &expanded_key, payment_id, &secp_ctx, + ).unwrap() .include_offer_description() .include_invoice_amount() .include_invoice_created_at() @@ -2779,24 +2775,24 @@ fn creates_and_verifies_payer_proof_after_offer_payment() { .unwrap(); // Check proof contents match the original payment - assert_eq!(proof.payment_preimage(), payment_preimage); - assert_eq!(proof.payment_hash(), invoice.payment_hash()); - assert_eq!(proof.payer_signing_pubkey(), invoice.payer_signing_pubkey()); - assert_eq!(proof.issuer_signing_pubkey(), invoice.signing_pubkey()); - assert!(proof.payer_note().is_none()); + assert_eq!(payer_proof.payment_preimage(), payment_preimage); + assert_eq!(payer_proof.payment_hash(), invoice.payment_hash()); + assert_eq!(payer_proof.payer_signing_pubkey(), invoice.payer_signing_pubkey()); + assert_eq!(payer_proof.issuer_signing_pubkey(), invoice.signing_pubkey()); + assert!(payer_proof.payer_note().is_none()); // --- Serialization Round-Trip --- // The proof can be serialized to a bech32 string (lnp...) for sharing. - let encoded = proof.to_string(); + let encoded = payer_proof.to_string(); assert!(encoded.starts_with("lnp1")); // Round-trip through TLV bytes: re-parse the raw bytes (verification happens at parse time). - let decoded = PayerProof::try_from(proof.bytes().to_vec()).unwrap(); - assert_eq!(decoded.payment_preimage(), proof.payment_preimage()); - assert_eq!(decoded.payment_hash(), proof.payment_hash()); - assert_eq!(decoded.payer_signing_pubkey(), proof.payer_signing_pubkey()); - assert_eq!(decoded.issuer_signing_pubkey(), proof.issuer_signing_pubkey()); - assert_eq!(decoded.merkle_root(), proof.merkle_root()); + let decoded = PayerProof::try_from(payer_proof.bytes().to_vec()).unwrap(); + assert_eq!(decoded.payment_preimage(), payer_proof.payment_preimage()); + assert_eq!(decoded.payment_hash(), payer_proof.payment_hash()); + assert_eq!(decoded.payer_signing_pubkey(), payer_proof.payer_signing_pubkey()); + assert_eq!(decoded.issuer_signing_pubkey(), payer_proof.issuer_signing_pubkey()); + assert_eq!(decoded.merkle_root(), payer_proof.merkle_root()); } /// Tests payer proof creation with a payer note, selective disclosure of specific invoice @@ -2864,66 +2860,67 @@ fn creates_payer_proof_with_note_and_selective_disclosure() { _ => panic!("Expected Event::PaymentClaimable"), }; - claim_payment(bob, &[alice], payment_preimage); + let paid_invoice = claim_payment(bob, &[alice], payment_preimage).unwrap(); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); // --- Test 1: Wrong preimage is rejected --- let wrong_preimage = PaymentPreimage([0xDE; 32]); - let wrong_paid = payer_proof::PaidBolt12Invoice::new( + let wrong_paid = PaidBolt12Invoice::new( Bolt12InvoiceType::Bolt12Invoice(invoice.clone()), wrong_preimage, Some(payer_nonce), ); assert!(matches!(wrong_paid.prove_payer(), Err(PayerProofError::PreimageMismatch))); - // --- Test 2: Wrong payment_id causes key derivation failure --- + // --- Test 2: Wrong payment_id causes key derivation failure at construction --- let expanded_key = bob.keys_manager.get_expanded_key(); let secp_ctx = Secp256k1::new(); - let paid_invoice = payer_proof::PaidBolt12Invoice::new( - Bolt12InvoiceType::Bolt12Invoice(invoice.clone()), - payment_preimage, - Some(payer_nonce), - ); let wrong_payment_id = PaymentId([0xFF; 32]); - let result = paid_invoice.prove_payer_derived(&expanded_key, wrong_payment_id, &secp_ctx); + let result = paid_invoice.prove_payer_derived( + &expanded_key, wrong_payment_id, &secp_ctx, + ); assert!(matches!(result, Err(PayerProofError::KeyDerivationFailed))); - // --- Test 3: Wrong nonce causes key derivation failure --- + // --- Test 3: Wrong nonce causes key derivation failure at construction --- let wrong_nonce = Nonce::from_entropy_source(&chanmon_cfgs[0].keys_manager); - let wrong_nonce_paid = payer_proof::PaidBolt12Invoice::new( + let wrong_nonce_paid = PaidBolt12Invoice::new( Bolt12InvoiceType::Bolt12Invoice(invoice.clone()), payment_preimage, Some(wrong_nonce), ); - let result = wrong_nonce_paid.prove_payer_derived(&expanded_key, payment_id, &secp_ctx); + let result = wrong_nonce_paid.prove_payer_derived( + &expanded_key, payment_id, &secp_ctx, + ); assert!(matches!(result, Err(PayerProofError::KeyDerivationFailed))); // --- Test 4: Minimal proof (only required fields) --- - let minimal_proof = paid_invoice - .prove_payer_derived(&expanded_key, payment_id, &secp_ctx).unwrap() + let minimal_payer_proof = paid_invoice.prove_payer_derived( + &expanded_key, payment_id, &secp_ctx, + ).unwrap() .build_and_sign(None) .unwrap(); // --- Test 5: Proof with selective disclosure and payer note --- - let proof_with_note = paid_invoice - .prove_payer_derived(&expanded_key, payment_id, &secp_ctx).unwrap() + let payer_proof_with_note = paid_invoice.prove_payer_derived( + &expanded_key, payment_id, &secp_ctx, + ).unwrap() .include_offer_description() .include_offer_issuer() .include_invoice_amount() .include_invoice_created_at() .build_and_sign(Some("Paid for coffee".into())) .unwrap(); - assert_eq!(proof_with_note.payer_note().map(|p| p.0), Some("Paid for coffee")); + assert_eq!(payer_proof_with_note.payer_note().map(|note| note.0), Some("Paid for coffee")); // Both proofs should verify and have the same core fields - assert_eq!(minimal_proof.payment_preimage(), proof_with_note.payment_preimage()); - assert_eq!(minimal_proof.payment_hash(), proof_with_note.payment_hash()); - assert_eq!(minimal_proof.payer_signing_pubkey(), proof_with_note.payer_signing_pubkey()); - assert_eq!(minimal_proof.issuer_signing_pubkey(), proof_with_note.issuer_signing_pubkey()); + assert_eq!(minimal_payer_proof.payment_preimage(), payer_proof_with_note.payment_preimage()); + assert_eq!(minimal_payer_proof.payment_hash(), payer_proof_with_note.payment_hash()); + assert_eq!(minimal_payer_proof.payer_signing_pubkey(), payer_proof_with_note.payer_signing_pubkey()); + assert_eq!(minimal_payer_proof.issuer_signing_pubkey(), payer_proof_with_note.issuer_signing_pubkey()); // The merkle roots are the same since both reconstruct from the same invoice - assert_eq!(minimal_proof.merkle_root(), proof_with_note.merkle_root()); + assert_eq!(minimal_payer_proof.merkle_root(), payer_proof_with_note.merkle_root()); // --- Test 6: Round-trip the proof with note through TLV bytes --- - let encoded = proof_with_note.to_string(); + let encoded = payer_proof_with_note.to_string(); assert!(encoded.starts_with("lnp1")); - let decoded = PayerProof::try_from(proof_with_note.bytes().to_vec()).unwrap(); - assert_eq!(decoded.payer_note().map(|p| p.0), Some("Paid for coffee")); + let decoded = PayerProof::try_from(payer_proof_with_note.bytes().to_vec()).unwrap(); + assert_eq!(decoded.payer_note().map(|note| note.0), Some("Paid for coffee")); assert_eq!(decoded.payment_preimage(), payment_preimage); } diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 602d731bac6..9ab1ca52242 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -3551,6 +3551,7 @@ mod tests { first_hop_htlc_msat: 0, payment_id: PaymentId([1; 32]), bolt12_invoice: None, + payment_nonce: None, }; process_onion_failure(&ctx_full, &logger, &htlc_source, onion_error) @@ -3737,6 +3738,7 @@ mod tests { first_hop_htlc_msat: dummy_amt_msat, payment_id: PaymentId([1; 32]), bolt12_invoice: None, + payment_nonce: None, }; { @@ -3925,6 +3927,7 @@ mod tests { first_hop_htlc_msat: 0, payment_id: PaymentId([1; 32]), bolt12_invoice: None, + payment_nonce: None, }; // Iterate over all possible failure positions and check that the cases that can be attributed are. @@ -4034,6 +4037,7 @@ mod tests { first_hop_htlc_msat: 0, payment_id: PaymentId([1; 32]), bolt12_invoice: None, + payment_nonce: None, }; let decrypted_failure = process_onion_failure(&ctx_full, &logger, &htlc_source, packet); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 7ccf2f9e454..30a3da2d4c3 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -27,7 +27,7 @@ use crate::ln::onion_utils::{DecodedOnionFailure, HTLCFailReason}; use crate::offers::invoice::{Bolt12Invoice, DerivedSigningPubkey, InvoiceBuilder}; use crate::offers::invoice_request::InvoiceRequest; use crate::offers::nonce::Nonce; -use crate::offers::payer_proof::Bolt12InvoiceType; +use crate::offers::payer_proof::{Bolt12InvoiceType, PaidBolt12Invoice}; use crate::offers::static_invoice::StaticInvoice; use crate::routing::router::{ BlindedTail, InFlightHtlcs, Path, PaymentParameters, Route, RouteParameters, @@ -128,6 +128,12 @@ pub(crate) enum PendingOutboundPayment { // Storing the BOLT 12 invoice here to allow Proof of Payment after // the payment is made. bolt12_invoice: Option, + /// The [`Nonce`] used when the BOLT 12 [`InvoiceRequest`] was created. Stored here so + /// retried paths can include the nonce in [`HTLCSource::OutboundRoute`] for payer proof + /// construction after payment success. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + payment_nonce: Option, custom_tlvs: Vec<(u64, Vec)>, pending_amt_msat: u64, /// Used to track the fee paid. Present iff the payment was serialized on 0.0.103+. @@ -189,6 +195,13 @@ impl PendingOutboundPayment { } } + fn payment_nonce(&self) -> Option<&Nonce> { + match self { + PendingOutboundPayment::Retryable { payment_nonce, .. } => payment_nonce.as_ref(), + _ => None, + } + } + fn increment_attempts(&mut self) { if let PendingOutboundPayment::Retryable { attempts, .. } = self { attempts.count += 1; @@ -936,6 +949,7 @@ pub(super) struct SendAlongPathArgs<'a> { pub keysend_preimage: &'a Option, pub invoice_request: Option<&'a InvoiceRequest>, pub bolt12_invoice: Option<&'a Bolt12InvoiceType>, + pub payment_nonce: Option, pub session_priv_bytes: [u8; 32], pub hold_htlc_at_next_hop: bool, } @@ -1094,7 +1108,7 @@ impl OutboundPayments { pub(super) fn send_payment_for_bolt12_invoice< R: Router, ES: EntropySource, NS: NodeSigner, NL: NodeIdLookUp, IH, SP, L: Logger, >( - &self, invoice: &Bolt12Invoice, payment_id: PaymentId, router: &R, + &self, invoice: &Bolt12Invoice, payment_id: PaymentId, payment_nonce: Option, router: &R, first_hops: Vec, features: Bolt12InvoiceFeatures, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, node_id_lookup: &NL, secp_ctx: &Secp256k1, best_block_height: u32, @@ -1125,7 +1139,7 @@ impl OutboundPayments { } let invoice = Bolt12InvoiceType::Bolt12Invoice(invoice.clone()); self.send_payment_for_bolt12_invoice_internal( - payment_id, payment_hash, None, None, invoice, route_params, retry_strategy, false, router, + payment_id, payment_hash, None, None, invoice, payment_nonce, route_params, retry_strategy, false, router, first_hops, inflight_htlcs, entropy_source, node_signer, node_id_lookup, secp_ctx, best_block_height, pending_events, send_payment_along_path, logger, ) @@ -1137,7 +1151,7 @@ impl OutboundPayments { >( &self, payment_id: PaymentId, payment_hash: PaymentHash, keysend_preimage: Option, invoice_request: Option<&InvoiceRequest>, - bolt12_invoice: Bolt12InvoiceType, + bolt12_invoice: Bolt12InvoiceType, payment_nonce: Option, mut route_params: RouteParameters, retry_strategy: Retry, hold_htlcs_at_next_hop: bool, router: &R, first_hops: Vec, inflight_htlcs: IH, entropy_source: &ES, node_signer: &NS, node_id_lookup: &NL, secp_ctx: &Secp256k1, best_block_height: u32, @@ -1196,7 +1210,8 @@ impl OutboundPayments { hash_map::Entry::Occupied(entry) => match entry.get() { PendingOutboundPayment::InvoiceReceived { .. } => { let (retryable_payment, onion_session_privs) = Self::create_pending_payment( - payment_hash, recipient_onion.clone(), keysend_preimage, None, Some(bolt12_invoice.clone()), &route, + payment_hash, recipient_onion.clone(), keysend_preimage, None, Some(bolt12_invoice.clone()), + payment_nonce, &route, Some(retry_strategy), payment_params, entropy_source, best_block_height, ); *entry.into_mut() = retryable_payment; @@ -1207,7 +1222,8 @@ impl OutboundPayments { invoice_request } else { unreachable!() }; let (retryable_payment, onion_session_privs) = Self::create_pending_payment( - payment_hash, recipient_onion.clone(), keysend_preimage, Some(invreq), Some(bolt12_invoice.clone()), &route, + payment_hash, recipient_onion.clone(), keysend_preimage, Some(invreq), Some(bolt12_invoice.clone()), + payment_nonce, &route, Some(retry_strategy), payment_params, entropy_source, best_block_height ); outbounds.insert(payment_id, retryable_payment); @@ -1220,7 +1236,8 @@ impl OutboundPayments { core::mem::drop(outbounds); let result = self.pay_route_internal( - &route, payment_hash, &recipient_onion, keysend_preimage, invoice_request, Some(&bolt12_invoice), payment_id, + &route, payment_hash, &recipient_onion, keysend_preimage, invoice_request, Some(&bolt12_invoice), + payment_nonce, payment_id, &onion_session_privs, hold_htlcs_at_next_hop, node_signer, best_block_height, &send_payment_along_path ); @@ -1403,6 +1420,7 @@ impl OutboundPayments { Some(keysend_preimage), Some(&invoice_request), invoice, + None, route_params, retry_strategy, hold_htlcs_at_next_hop, @@ -1612,7 +1630,7 @@ impl OutboundPayments { })?; let res = self.pay_route_internal(&route, payment_hash, &recipient_onion, - keysend_preimage, None, None, payment_id, &onion_session_privs, false, node_signer, + keysend_preimage, None, None, None, payment_id, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path); log_info!(logger, "Sending payment with id {} and hash {} returned {:?}", payment_id, payment_hash, res); @@ -1679,7 +1697,7 @@ impl OutboundPayments { } } } - let (recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice) = { + let (recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice, payment_nonce) = { let mut outbounds = self.pending_outbound_payments.lock().unwrap(); match outbounds.entry(payment_id) { hash_map::Entry::Occupied(mut payment) => { @@ -1722,8 +1740,9 @@ impl OutboundPayments { payment.get_mut().increment_attempts(); let bolt12_invoice = payment.get().bolt12_invoice(); + let payment_nonce = payment.get().payment_nonce().copied(); - (recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice.cloned()) + (recipient_onion, keysend_preimage, onion_session_privs, invoice_request, bolt12_invoice.cloned(), payment_nonce) }, PendingOutboundPayment::Legacy { .. } => { log_error!(logger, "Unable to retry payments that were initially sent on LDK versions prior to 0.0.102"); @@ -1763,7 +1782,8 @@ impl OutboundPayments { } }; let res = self.pay_route_internal(&route, payment_hash, &recipient_onion, keysend_preimage, - invoice_request.as_ref(), bolt12_invoice.as_ref(), payment_id, + invoice_request.as_ref(), bolt12_invoice.as_ref(), payment_nonce, + payment_id, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path); log_info!(logger, "Result retrying payment id {}: {:?}", &payment_id, res); if let Err(e) = res { @@ -1922,7 +1942,7 @@ impl OutboundPayments { })?; match self.pay_route_internal(&route, payment_hash, &recipient_onion_fields, - None, None, None, payment_id, &onion_session_privs, false, node_signer, + None, None, None, None, payment_id, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path ) { Ok(()) => Ok((payment_hash, payment_id)), @@ -1985,7 +2005,7 @@ impl OutboundPayments { hash_map::Entry::Occupied(_) => Err(PaymentSendFailure::DuplicatePayment), hash_map::Entry::Vacant(entry) => { let (payment, onion_session_privs) = Self::create_pending_payment( - payment_hash, recipient_onion, keysend_preimage, None, bolt12_invoice, route, retry_strategy, + payment_hash, recipient_onion, keysend_preimage, None, bolt12_invoice, None, route, retry_strategy, payment_params, entropy_source, best_block_height ); entry.insert(payment); @@ -1998,7 +2018,8 @@ impl OutboundPayments { fn create_pending_payment( payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, keysend_preimage: Option, invoice_request: Option, - bolt12_invoice: Option, route: &Route, retry_strategy: Option, + bolt12_invoice: Option, payment_nonce: Option, + route: &Route, retry_strategy: Option, payment_params: Option, entropy_source: &ES, best_block_height: u32 ) -> (PendingOutboundPayment, Vec<[u8; 32]>) { let mut onion_session_privs = Vec::with_capacity(route.paths.len()); @@ -2019,6 +2040,7 @@ impl OutboundPayments { keysend_preimage, invoice_request, bolt12_invoice, + payment_nonce, custom_tlvs: recipient_onion.custom_tlvs, starting_block_height: best_block_height, total_msat: route.get_total_amount(), @@ -2109,6 +2131,7 @@ impl OutboundPayments { fn pay_route_internal( &self, route: &Route, payment_hash: PaymentHash, recipient_onion: &RecipientOnionFields, keysend_preimage: Option, invoice_request: Option<&InvoiceRequest>, bolt12_invoice: Option<&Bolt12InvoiceType>, + payment_nonce: Option, payment_id: PaymentId, onion_session_privs: &Vec<[u8; 32]>, hold_htlcs_at_next_hop: bool, node_signer: &NS, best_block_height: u32, send_payment_along_path: &F ) -> Result<(), PaymentSendFailure> @@ -2158,7 +2181,7 @@ impl OutboundPayments { let path_res = send_payment_along_path(SendAlongPathArgs { path: &path, payment_hash: &payment_hash, recipient_onion, cur_height, payment_id, keysend_preimage: &keysend_preimage, invoice_request, - bolt12_invoice, hold_htlc_at_next_hop: hold_htlcs_at_next_hop, + bolt12_invoice, payment_nonce, hold_htlc_at_next_hop: hold_htlcs_at_next_hop, session_priv_bytes: *session_priv_bytes }); results.push(path_res); @@ -2225,7 +2248,7 @@ impl OutboundPayments { F: Fn(SendAlongPathArgs) -> Result<(), APIError>, { self.pay_route_internal(route, payment_hash, &recipient_onion, - keysend_preimage, None, None, payment_id, &onion_session_privs, + keysend_preimage, None, None, None, payment_id, &onion_session_privs, false, node_signer, best_block_height, &send_payment_along_path) .map_err(|e| { self.remove_outbound_if_all_failed(payment_id, &e); e }) } @@ -2249,6 +2272,7 @@ impl OutboundPayments { #[rustfmt::skip] pub(super) fn claim_htlc( &self, payment_id: PaymentId, payment_preimage: PaymentPreimage, bolt12_invoice: Option, + payment_nonce: Option, session_priv: SecretKey, path: Path, from_onchain: bool, ev_completion_action: &mut Option, pending_events: &Mutex)>>, logger: &WithContext, @@ -2271,7 +2295,9 @@ impl OutboundPayments { payment_hash, amount_msat, fee_paid_msat, - bolt12_invoice: bolt12_invoice, + bolt12_invoice: bolt12_invoice.map(|invoice| { + PaidBolt12Invoice::new(invoice, payment_preimage, payment_nonce) + }), }, ev_completion_action.take())); payment.get_mut().mark_fulfilled(); } @@ -2667,6 +2693,7 @@ impl OutboundPayments { keysend_preimage: None, // only used for retries, and we'll never retry on startup invoice_request: None, // only used for retries, and we'll never retry on startup bolt12_invoice: None, // only used for retries, and we'll never retry on startup! + payment_nonce: None, // only used for retries, and we'll never retry on startup custom_tlvs: Vec::new(), // only used for retries, and we'll never retry on startup pending_amt_msat: path_amt, pending_fee_msat: Some(path_fee), @@ -2771,6 +2798,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, })), (13, invoice_request, option), (15, bolt12_invoice, option), + (17, payment_nonce, option), (not_written, retry_strategy, (static_value, None)), (not_written, attempts, (static_value, PaymentAttempts::new())), }, @@ -3267,7 +3295,7 @@ mod tests { assert_eq!( outbound_payments.send_payment_for_bolt12_invoice( - &invoice, payment_id, &&router, vec![], Bolt12InvoiceFeatures::empty(), + &invoice, payment_id, None, &&router, vec![], Bolt12InvoiceFeatures::empty(), || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, &EmptyNodeIdLookUp {}, &secp_ctx, 0, &pending_events, |_| panic!(), &log ), @@ -3332,7 +3360,7 @@ mod tests { assert_eq!( outbound_payments.send_payment_for_bolt12_invoice( - &invoice, payment_id, &&router, vec![], Bolt12InvoiceFeatures::empty(), + &invoice, payment_id, None, &&router, vec![], Bolt12InvoiceFeatures::empty(), || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, &EmptyNodeIdLookUp {}, &secp_ctx, 0, &pending_events, |_| panic!(), &log ), @@ -3410,7 +3438,7 @@ mod tests { assert!(!outbound_payments.has_pending_payments()); assert_eq!( outbound_payments.send_payment_for_bolt12_invoice( - &invoice, payment_id, &&router, vec![], Bolt12InvoiceFeatures::empty(), + &invoice, payment_id, None, &&router, vec![], Bolt12InvoiceFeatures::empty(), || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, &EmptyNodeIdLookUp {}, &secp_ctx, 0, &pending_events, |_| panic!(), &log ), @@ -3430,7 +3458,7 @@ mod tests { assert_eq!( outbound_payments.send_payment_for_bolt12_invoice( - &invoice, payment_id, &&router, vec![], Bolt12InvoiceFeatures::empty(), + &invoice, payment_id, None, &&router, vec![], Bolt12InvoiceFeatures::empty(), || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, &EmptyNodeIdLookUp {}, &secp_ctx, 0, &pending_events, |_| Ok(()), &log ), @@ -3441,7 +3469,7 @@ mod tests { assert_eq!( outbound_payments.send_payment_for_bolt12_invoice( - &invoice, payment_id, &&router, vec![], Bolt12InvoiceFeatures::empty(), + &invoice, payment_id, None, &&router, vec![], Bolt12InvoiceFeatures::empty(), || InFlightHtlcs::new(), &&keys_manager, &&keys_manager, &EmptyNodeIdLookUp {}, &secp_ctx, 0, &pending_events, |_| panic!(), &log ), From 7fa8d4cffe1c476990656c42998037eef890cffb Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Thu, 30 Apr 2026 20:03:01 +0200 Subject: [PATCH 5/7] fixup! feat(offers): add BOLT 12 payer proof primitives --- lightning/src/offers/payer_proof.rs | 522 ++++++++++++++++++---------- 1 file changed, 340 insertions(+), 182 deletions(-) diff --git a/lightning/src/offers/payer_proof.rs b/lightning/src/offers/payer_proof.rs index cc1274a903e..3dbc7e1383d 100644 --- a/lightning/src/offers/payer_proof.rs +++ b/lightning/src/offers/payer_proof.rs @@ -16,6 +16,51 @@ //! //! This implements the payer proof extension to BOLT 12 as specified in //! . +//! +//! # Proposed authentication change (full payer-proof merkle root) +//! +//! The current spec (BOLT 12 PR 1295) signs only `SHA256(payer_signature.note || +//! invoice_merkle_root)` with `invreq_payer_id`. Tamper-resistance for the +//! remaining payer-proof TLVs (`preimage`, `omitted_tlvs`, `missing_hashes`, +//! `leaf_hashes`) is *transitive*: each one has a separate binding (the +//! preimage hashes to `invoice_payment_hash`; the rest reconstruct the invoice +//! merkle root that the issuer's `signature` covers). It works only because +//! every payer_proof TLV today is either part of the already-signed invoice or +//! has an out-of-band binding to it. Any future payer-side TLV outside both +//! categories silently loses authentication — see +//! . +//! +//! T-bast's proposal is to sign the merkle root of *all* payer_proof TLVs and +//! to extract `note` into its own TLV (a normal merkle leaf instead of being +//! bundled inside the `payer_signature` TLV). Under that scheme: +//! +//! 1. `payer_signature` becomes a plain `bip340sig` like every other signature +//! TLV in BOLT 12. +//! 2. `note` becomes a dedicated TLV (`PAYER_PROOF_PROOF_NOTE_TYPE`, see +//! below); the type number is provisional and may move when the spec +//! reallocates payer-proof data TLVs out of the `SIGNATURE_TYPES` range. +//! 3. The `payer_signature` is a tagged signature over the merkle root of all +//! payer_proof TLVs except the `payer_signature` TLV itself, computed +//! exactly as `signature` is computed for invoices/offers/invoice requests. +//! +//! Verifier flow under the new scheme is two signature checks (instead of the +//! current implicit chain of three): +//! +//! 1. Verify `payer_signature` against the payer-proof merkle root using +//! `invreq_payer_id`. +//! 2. Reconstruct the invoice merkle root from `leaf_hashes`, +//! `missing_hashes`, `omitted_tlvs`, and the disclosed invoice TLVs, then +//! verify the issuer's `signature` against it using `invoice_node_id`. +//! +//! `SHA256(preimage) == invoice_payment_hash` remains a separate check. +//! +//! This branch carries the design and a placeholder TLV constant for the new +//! `payer_note` TLV; the structural code change to switch over has been +//! deferred until the spec settles on the final TLV layout (in particular +//! whether the data-bearing payer-proof TLVs `preimage`/`omitted_tlvs`/ +//! `missing_hashes`/`leaf_hashes` move out of the `SIGNATURE_TYPES` range so +//! they can be merkle leaves under the standard BOLT 12 signing convention). +//! Tracking issue: . use alloc::collections::BTreeSet; @@ -45,12 +90,11 @@ use crate::offers::payer::PAYER_METADATA_TYPE; use crate::offers::static_invoice::StaticInvoice; use crate::types::payment::{PaymentHash, PaymentPreimage}; use crate::util::ser::{ - BigSize, CursorReadable, HighZeroBytesDroppedBigSize, Readable, WithoutLength, Writeable, - Writer, + BigSize, CursorReadable, HighZeroBytesDroppedBigSize, WithoutLength, Writeable, }; use lightning_types::string::PrintableString; -use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::hashes::{sha256, Hash}; use bitcoin::secp256k1; use bitcoin::secp256k1::schnorr::Signature; use bitcoin::secp256k1::{PublicKey, Secp256k1}; @@ -160,19 +204,50 @@ impl PaidBolt12Invoice { } } -const PAYER_PROOF_SIGNATURE_TYPE: u64 = 240; -const PAYER_PROOF_PREIMAGE_TYPE: u64 = 242; -const PAYER_PROOF_OMITTED_TLVS_TYPE: u64 = 244; -const PAYER_PROOF_MISSING_HASHES_TYPE: u64 = 246; -const PAYER_PROOF_LEAF_HASHES_TYPE: u64 = 248; -const PAYER_PROOF_PAYER_SIGNATURE_TYPE: u64 = 250; +// TLV layout per BOLT 12 PR 1295 commit 0f2b026 (2026-04-30): +// +// 240 signature (issuer) bip340sig +// 241 proof_signature (payer) bip340sig +// 1001 preimage 32*byte +// 1002 omitted_tlvs ...*bigsize +// 1003 missing_hashes ...*sha256 +// 1004 leaf_hashes ...*sha256 +// 1005 proof_note ...*utf8 +// +// The data-bearing TLVs are now outside the BOLT 12 `SIGNATURE_TYPES` range +// (240..=1000), so the standard merkle-root computation includes them as +// leaves and `proof_signature` covers the entire proof. The issuer +// `signature` covers the merkle-root of the invoice "without fields 1001 +// through 999999999 inclusive" (per the spec); `tlv_stream_iter` strips +// that range during invoice merkle reconstruction. + +/// TLV type for the issuer's signature on the invoice (copied into the proof). +const PAYER_PROOF_ISSUER_SIGNATURE_TYPE: u64 = 240; +/// TLV type for the payer's `proof_signature` over the proof's merkle root. +const PAYER_PROOF_PROOF_SIGNATURE_TYPE: u64 = 241; +/// TLV type for the payment preimage. +const PAYER_PROOF_PREIMAGE_TYPE: u64 = 1001; +/// TLV type for the omitted-TLV markers. +const PAYER_PROOF_OMITTED_TLVS_TYPE: u64 = 1002; +/// TLV type for the missing-merkle-branch hashes. +const PAYER_PROOF_MISSING_HASHES_TYPE: u64 = 1003; +/// TLV type for the per-included-leaf nonce hashes. +const PAYER_PROOF_LEAF_HASHES_TYPE: u64 = 1004; +/// TLV type for the optional proof note. +const PAYER_PROOF_PROOF_NOTE_TYPE: u64 = 1005; + +/// Range covering the data-bearing payer-proof TLVs as defined in BOLT 12 +/// PR 1295 commit 0f2b026: `1001..=999_999_999`. The standard BOLT 12 merkle +/// root for the invoice excludes everything in this range; the merkle root +/// for `proof_signature` includes everything in this range as leaves. +pub(super) const PAYER_PROOF_DATA_TYPES: core::ops::RangeInclusive = 1001..=999_999_999; /// Human-readable prefix for payer proofs in bech32 encoding. pub const PAYER_PROOF_HRP: &str = "lnp"; -/// Tag for payer signature computation per BOLT 12 signature calculation. -/// Format: "lightning" || messagename || fieldname -const PAYER_SIGNATURE_TAG: &str = concat!("lightning", "payer_proof", "payer_signature"); +/// Tag for `proof_signature` computation per BOLT 12 signature calculation. +/// Format: `"lightning" || messagename || fieldname`. +const PROOF_SIGNATURE_TAG: &str = concat!("lightning", "payer_proof", "proof_signature"); /// Error when building or verifying a payer proof. #[derive(Debug, Clone, PartialEq, Eq)] @@ -230,7 +305,13 @@ struct PayerProofContents { issuer_signing_pubkey: PublicKey, preimage: PaymentPreimage, invoice_signature: Signature, - payer_signature_tlv: PayerSignatureWithNote, + /// Schnorr signature by `invreq_payer_id` over the merkle root of all + /// payer-proof TLVs except the `proof_signature` TLV itself. See module + /// docs for the authentication scheme. + payer_signature: Signature, + /// Optional payer-supplied note. Its own TLV (a regular merkle leaf) + /// per BOLT 12 PR 1295 commit `0f2b026`. + payer_note: Option, disclosed_fields: DisclosedFields, } @@ -413,9 +494,9 @@ impl<'a, S: SigningPubkeyStrategy> PayerProofBuilder<'a, S> { let invoice_signature = self.invoice.signature(); - let tagged_hash = payer_signature_hash(payer_note.as_deref(), &disclosure.merkle_root); - - Ok(UnsignedPayerProof { + // Construct a partial UnsignedPayerProof so we can call its serializer. + // `tagged_hash` is filled in below once we have the proof bytes. + let mut unsigned = UnsignedPayerProof { invoice_signature, preimage: self.preimage, payer_signing_pubkey: self.invoice.payer_signing_pubkey(), @@ -426,24 +507,33 @@ impl<'a, S: SigningPubkeyStrategy> PayerProofBuilder<'a, S> { disclosed_fields, disclosure, payer_note, - tagged_hash, - }) + tagged_hash: TaggedHash::from_merkle_root( + PROOF_SIGNATURE_TAG, + sha256::Hash::all_zeros(), + ), + }; + + // Serialize the proof bytes excluding the `payer_signature` TLV. The + // tagged hash for the payer signature is computed over those bytes. + let bytes_for_signing = unsigned.serialize_payer_proof(None); + unsigned.tagged_hash = proof_signature_hash(&bytes_for_signing); + + Ok(unsigned) } } -/// Computes the [`TaggedHash`] for a payer proof signature. +/// Computes the [`TaggedHash`] for the `proof_signature` over the merkle root +/// of the payer-proof TLV stream. /// -/// The payer signature is computed over `H(tag||tag||H(note||merkle_root))`. The inner -/// hash `H(note||merkle_root)` serves as the "merkle root" for [`TaggedHash::from_merkle_root`]. -fn payer_signature_hash(note: Option<&str>, merkle_root: &sha256::Hash) -> TaggedHash { - let mut engine = sha256::Hash::engine(); - if let Some(n) = note { - engine.input(n.as_bytes()); - } - engine.input(merkle_root.as_ref()); - let inner_hash = sha256::Hash::from_engine(engine); - - TaggedHash::from_merkle_root(PAYER_SIGNATURE_TAG, inner_hash) +/// `bytes` must be a well-formed TLV stream containing all payer-proof TLVs. +/// Per BOLT 12 PR 1295 commit 0f2b026, the data-bearing payer-proof TLVs +/// (`preimage`, `omitted_tlvs`, `missing_hashes`, `leaf_hashes`, `proof_note`) +/// live in the `PAYER_PROOF_DATA_TYPES` range (1001..=999_999_999), so the +/// standard BOLT 12 merkle-root computation includes them as leaves +/// automatically; only the `SIGNATURE_TYPES` range (which holds the issuer's +/// `signature` and the `proof_signature` itself) is excluded. +fn proof_signature_hash(bytes: &[u8]) -> TaggedHash { + TaggedHash::from_valid_tlv_stream_bytes(PROOF_SIGNATURE_TAG, bytes) } /// An unsigned [`PayerProof`] ready for signing. @@ -491,61 +581,38 @@ where } } -/// Compound value for the payer signature TLV (type 250): a schnorr signature -/// followed by optional UTF-8 note bytes. -#[derive(Clone, Debug, PartialEq)] -pub(super) struct PayerSignatureWithNote { - signature: Signature, - note: Option, -} - -impl PayerSignatureWithNote { - fn signature(&self) -> &Signature { - &self.signature - } - - fn note(&self) -> Option<&str> { - self.note.as_deref() - } -} - -impl Readable for PayerSignatureWithNote { - fn read(r: &mut R) -> Result { - let signature = Readable::read(r)?; - let note_bytes = crate::io_extras::read_to_end(r).map_err(|_| DecodeError::ShortRead)?; - let note = if note_bytes.is_empty() { - None - } else { - Some(String::from_utf8(note_bytes).map_err(|_| DecodeError::InvalidValue)?) - }; - - Ok(Self { signature, note }) - } -} - -impl Writeable for PayerSignatureWithNote { - fn write(&self, w: &mut W) -> Result<(), io::Error> { - self.signature.write(w)?; - w.write_all(self.note.as_deref().map(str::as_bytes).unwrap_or(&[])) +// The proof's signature TLVs sit in the BOLT 12 `SIGNATURE_TYPES` range +// (240..=1000) and are excluded from the standard merkle-root computation. +tlv_stream!( + PayerProofSignatureTlvStream, PayerProofSignatureTlvStreamRef<'a>, SIGNATURE_TYPES, { + (PAYER_PROOF_ISSUER_SIGNATURE_TYPE, invoice_signature: Signature), + (PAYER_PROOF_PROOF_SIGNATURE_TYPE, proof_signature: Signature), } -} +); +// The data-bearing TLVs sit in `PAYER_PROOF_DATA_TYPES` (1001..=999_999_999), +// outside the signature range, so the standard merkle root for `proof_signature` +// includes them as leaves. tlv_stream!( - PayerProofTlvStream, PayerProofTlvStreamRef<'a>, SIGNATURE_TYPES, { - (PAYER_PROOF_SIGNATURE_TYPE, invoice_signature: Signature), + PayerProofDataTlvStream, PayerProofDataTlvStreamRef<'a>, PAYER_PROOF_DATA_TYPES, { (PAYER_PROOF_PREIMAGE_TYPE, preimage: PaymentPreimage), (PAYER_PROOF_OMITTED_TLVS_TYPE, omitted_markers: (Vec, WithoutLength)), (PAYER_PROOF_MISSING_HASHES_TYPE, missing_hashes: (Vec, WithoutLength)), (PAYER_PROOF_LEAF_HASHES_TYPE, leaf_hashes: (Vec, WithoutLength)), - (PAYER_PROOF_PAYER_SIGNATURE_TYPE, payer_signature: PayerSignatureWithNote), + (PAYER_PROOF_PROOF_NOTE_TYPE, proof_note: (String, WithoutLength)), } ); +// Ordered to match canonical TLV ordering: offer (1..80), invoice_request +// (80..160), invoice (160..240), signature (240..=1000), proof data +// (1001..=999_999_999), experimental_offer (1B..2B), experimental_invoice_request +// (2B..3B), experimental_invoice (3B..). type FullPayerProofTlvStream = ( OfferTlvStream, InvoiceRequestTlvStream, InvoiceTlvStream, - PayerProofTlvStream, + PayerProofSignatureTlvStream, + PayerProofDataTlvStream, ExperimentalOfferTlvStream, ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceTlvStream, @@ -556,7 +623,8 @@ impl CursorReadable for FullPayerProofTlvStream { let offer = CursorReadable::read(r)?; let invoice_request = CursorReadable::read(r)?; let invoice = CursorReadable::read(r)?; - let payer_proof = CursorReadable::read(r)?; + let payer_proof_signatures = CursorReadable::read(r)?; + let payer_proof_data = CursorReadable::read(r)?; let experimental_offer = CursorReadable::read(r)?; let experimental_invoice_request = CursorReadable::read(r)?; let experimental_invoice = CursorReadable::read(r)?; @@ -565,7 +633,8 @@ impl CursorReadable for FullPayerProofTlvStream { offer, invoice_request, invoice, - payer_proof, + payer_proof_signatures, + payer_proof_data, experimental_offer, experimental_invoice_request, experimental_invoice, @@ -577,11 +646,9 @@ impl UnsignedPayerProof<'_> { /// Signs the [`UnsignedPayerProof`] using the given function. pub fn sign(mut self, sign: F) -> Result { let pubkey = self.payer_signing_pubkey; - let payer_signature = merkle::sign_message(sign, &self, pubkey)?; - let payer_signature_tlv = - PayerSignatureWithNote { signature: payer_signature, note: self.payer_note.take() }; + let proof_signature = merkle::sign_message(sign, &self, pubkey)?; - let bytes = self.serialize_payer_proof(&payer_signature_tlv); + let bytes = self.serialize_payer_proof(Some(&proof_signature)); Ok(PayerProof { bytes, @@ -591,41 +658,51 @@ impl UnsignedPayerProof<'_> { issuer_signing_pubkey: self.issuer_signing_pubkey, preimage: self.preimage, invoice_signature: self.invoice_signature, - payer_signature_tlv, + payer_signature: proof_signature, + payer_note: self.payer_note.take(), disclosed_fields: self.disclosed_fields, }, merkle_root: self.disclosure.merkle_root, }) } - fn serialize_payer_proof(&self, payer_signature: &PayerSignatureWithNote) -> Vec { + /// Serialize the proof. If `proof_signature` is `None`, the proof-signature + /// TLV is omitted from the output, producing the bytes that the merkle + /// root for `proof_signature` is computed over. + fn serialize_payer_proof(&self, proof_signature: Option<&Signature>) -> Vec { const PAYER_PROOF_ALLOCATION_SIZE: usize = 512; let mut bytes = Vec::with_capacity(PAYER_PROOF_ALLOCATION_SIZE); // Preserve TLV ordering by emitting included invoice records below the - // payer-proof range first, then payer-proof TLVs (240..=250), then any - // disclosed experimental invoice records above the reserved range. + // payer-proof range first, then payer-proof TLVs, then any disclosed + // experimental invoice records above the reserved range. for record in TlvStream::new(&self.invoice_bytes) - .range(0..PAYER_PROOF_SIGNATURE_TYPE) + .range(0..PAYER_PROOF_ISSUER_SIGNATURE_TYPE) .filter(|r| self.included_types.contains(&r.r#type)) { bytes.extend_from_slice(record.record_bytes); } + // Signature TLVs (240, 241) come first, then data TLVs (1001..=1005). + let signatures = PayerProofSignatureTlvStreamRef { + invoice_signature: Some(&self.invoice_signature), + proof_signature, + }; + signatures.write(&mut bytes).expect("Vec write should not fail"); + let omitted_markers = (!self.disclosure.omitted_markers.is_empty()).then(|| { self.disclosure.omitted_markers.iter().copied().map(BigSize).collect::>() }); - let payer_proof = PayerProofTlvStreamRef { - invoice_signature: Some(&self.invoice_signature), + let data = PayerProofDataTlvStreamRef { preimage: Some(&self.preimage), omitted_markers: omitted_markers.as_ref(), missing_hashes: (!self.disclosure.missing_hashes.is_empty()) .then_some(&self.disclosure.missing_hashes), leaf_hashes: (!self.disclosure.leaf_hashes.is_empty()) .then_some(&self.disclosure.leaf_hashes), - payer_signature: Some(payer_signature), + proof_note: self.payer_note.as_ref(), }; - payer_proof.write(&mut bytes).expect("Vec write should not fail"); + data.write(&mut bytes).expect("Vec write should not fail"); for record in TlvStream::new(&self.invoice_bytes) .range(EXPERIMENTAL_OFFER_TYPES.start..) @@ -666,7 +743,7 @@ impl PayerProof { /// The payer's schnorr signature proving who authorized the payment. pub fn payer_signature(&self) -> Signature { - self.contents.payer_signature_tlv.signature().clone() + self.contents.payer_signature } /// The disclosed offer description, if included in the proof. @@ -698,7 +775,7 @@ impl PayerProof { /// [`InvoiceRequest::payer_note`]: crate::offers::invoice_request::InvoiceRequest::payer_note /// [`payer_signature`]: Self::payer_signature pub fn payer_note(&self) -> Option> { - self.contents.payer_signature_tlv.note().map(PrintableString) + self.contents.payer_note.as_deref().map(PrintableString) } /// The merkle root of the original invoice. @@ -780,13 +857,13 @@ impl TryFrom for ParsedPayerProofFields { // `payer_signing_pubkey` to match `PayerProofContents` naming. InvoiceRequestTlvStream { payer_id: payer_signing_pubkey, .. }, InvoiceTlvStream { created_at, payment_hash, amount, node_id, .. }, - PayerProofTlvStream { - invoice_signature, + PayerProofSignatureTlvStream { invoice_signature, proof_signature }, + PayerProofDataTlvStream { preimage, omitted_markers, missing_hashes, leaf_hashes, - payer_signature, + proof_note, }, _experimental_offer, _experimental_invoice_request, @@ -803,7 +880,7 @@ impl TryFrom for ParsedPayerProofFields { let invoice_signature = invoice_signature .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSignature))?; let preimage = preimage.ok_or(Bolt12ParseError::Decode(DecodeError::InvalidValue))?; - let payer_signature = payer_signature + let proof_signature = proof_signature .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSignature))?; Ok(Self { @@ -813,7 +890,8 @@ impl TryFrom for ParsedPayerProofFields { issuer_signing_pubkey, preimage, invoice_signature, - payer_signature_tlv: payer_signature, + payer_signature: proof_signature, + payer_note: proof_note, disclosed_fields: DisclosedFields { offer_description: description, offer_issuer: issuer, @@ -833,15 +911,21 @@ impl TryFrom for ParsedPayerProofFields { } fn tlv_stream_iter<'a>(bytes: &'a [u8]) -> impl core::iter::Iterator> { - // By the time we get here, `ParsedMessage::` has - // already parsed `bytes` through every sub-stream (offer, invoice request, - // invoice, payer-proof/signature, and each experimental range) and the + // Iterate the invoice TLVs only, for reconstructing the invoice merkle + // root. By the time we get here, `ParsedMessage::` + // has already parsed `bytes` through every sub-stream and the // `tlv_stream!`-generated parsers have rejected any unknown even TLV in any - // sub-stream's range. Anything in the unused gap between the signature and - // experimental ranges is rejected by `ParsedMessage`'s all-bytes-consumed - // check. The raw reconstruction pass therefore only needs to skip the - // payer-proof/signature TLVs themselves. - TlvStream::new(bytes).filter(|record| !SIGNATURE_TYPES.contains(&record.r#type)) + // sub-stream's range. + // + // Per BOLT 12 PR 1295 commit 0f2b026, the issuer's `signature` covers the + // merkle-root of the invoice "without fields 1001 through 999999999 + // inclusive" — i.e., excluding both `SIGNATURE_TYPES` (240..=1000, the + // standard convention) and `PAYER_PROOF_DATA_TYPES` (1001..=999_999_999, + // the new payer-proof data range). + TlvStream::new(bytes).filter(|record| { + !SIGNATURE_TYPES.contains(&record.r#type) + && !PAYER_PROOF_DATA_TYPES.contains(&record.r#type) + }) } impl TryFrom> for PayerProof { @@ -885,11 +969,12 @@ impl TryFrom> for PayerProof { ) .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; - // Verify the payer signature. - let payer_tagged_hash = - payer_signature_hash(contents.payer_signature_tlv.note(), &merkle_root); + // Verify the payer signature against the merkle root of the proof + // itself, computed over every payer-proof TLV except the + // `proof_signature` TLV being verified. See module docs. + let payer_tagged_hash = proof_signature_hash(&bytes); merkle::verify_signature( - contents.payer_signature_tlv.signature(), + &contents.payer_signature, &payer_tagged_hash, contents.payer_signing_pubkey, ) @@ -1058,7 +1143,7 @@ mod tests { let disclosure = compute_selective_disclosure(TlvStream::new(&invoice_bytes), &included_types).unwrap(); - let unsigned = UnsignedPayerProof { + let mut unsigned = UnsignedPayerProof { invoice_signature, preimage, payer_signing_pubkey, @@ -1067,10 +1152,15 @@ mod tests { invoice_bytes: &invoice_bytes, included_types, disclosed_fields, - tagged_hash: payer_signature_hash(None, &disclosure.merkle_root), + tagged_hash: TaggedHash::from_merkle_root( + PROOF_SIGNATURE_TAG, + sha256::Hash::all_zeros(), + ), disclosure, payer_note: None, }; + let bytes_for_signing = unsigned.serialize_payer_proof(None); + unsigned.tagged_hash = proof_signature_hash(&bytes_for_signing); unsigned .sign(|proof: &UnsignedPayerProof| { @@ -1118,7 +1208,7 @@ mod tests { compute_selective_disclosure(TlvStream::new(&invoice_bytes), &included_types).unwrap(); assert_eq!(disclosure.omitted_markers, vec![177, 178]); - let unsigned = UnsignedPayerProof { + let mut unsigned = UnsignedPayerProof { invoice_signature, preimage, payer_signing_pubkey, @@ -1127,10 +1217,15 @@ mod tests { invoice_bytes: &invoice_bytes, included_types, disclosed_fields, - tagged_hash: payer_signature_hash(None, &disclosure.merkle_root), + tagged_hash: TaggedHash::from_merkle_root( + PROOF_SIGNATURE_TAG, + sha256::Hash::all_zeros(), + ), disclosure, payer_note: None, }; + let bytes_for_signing = unsigned.serialize_payer_proof(None); + unsigned.tagged_hash = proof_signature_hash(&bytes_for_signing); unsigned .sign(|proof: &UnsignedPayerProof| { @@ -1194,7 +1289,7 @@ mod tests { let disclosure = compute_selective_disclosure(TlvStream::new(&invoice_bytes), &included_types).unwrap(); - let unsigned = UnsignedPayerProof { + let mut unsigned = UnsignedPayerProof { invoice_signature, preimage, payer_signing_pubkey, @@ -1203,10 +1298,15 @@ mod tests { invoice_bytes: &invoice_bytes, included_types, disclosed_fields, - tagged_hash: payer_signature_hash(None, &disclosure.merkle_root), + tagged_hash: TaggedHash::from_merkle_root( + PROOF_SIGNATURE_TAG, + sha256::Hash::all_zeros(), + ), disclosure, payer_note: None, }; + let bytes_for_signing = unsigned.serialize_payer_proof(None); + unsigned.tagged_hash = proof_signature_hash(&bytes_for_signing); unsigned .sign(|proof: &UnsignedPayerProof| { @@ -1593,73 +1693,50 @@ mod tests { assert!(matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)))); } - /// Confirms that a TLV with a type in the unused range between `SIGNATURE_TYPES` and - /// `EXPERIMENTAL_OFFER_TYPES` is rejected during parsing, regardless of whether its - /// length prefix is well-formed. - /// - /// No sub-stream in `FullPayerProofTlvStream` covers `(*SIGNATURE_TYPES.end() + - /// 1)..EXPERIMENTAL_OFFER_TYPES.start`. Each `CursorReadable` impl rewinds on the - /// out-of-range type and breaks without ever reading the length or value bytes. - /// The cursor is left before the gap TLV, and the all-bytes-consumed check in - /// `ParsedMessage::try_from` rejects the input with `DecodeError::InvalidValue`. - /// A malformed length prefix in the gap TLV is therefore never touched and cannot - /// panic downstream parsing. - #[test] - fn test_parsing_rejects_tlv_in_unused_range() { - const GAP_TYPE: u64 = 1_000_000; - assert!(GAP_TYPE > *SIGNATURE_TYPES.end()); - assert!(GAP_TYPE < EXPERIMENTAL_OFFER_TYPES.start); - - let proof = build_round_trip_proof_with_multiple_trailing_omitted_tlvs(); + // `test_parsing_rejects_tlv_in_unused_range` was removed: per BOLT 12 PR + // 1295 commit 0f2b026, the previously-unused range between + // `SIGNATURE_TYPES` and `EXPERIMENTAL_OFFER_TYPES` is now covered by + // `PAYER_PROOF_DATA_TYPES` (1001..=999_999_999). Coverage of unknown even + // types inside that range is provided by + // `test_parsing_rejects_unknown_even_tlvs_in_every_range`. - // Case 1: a well-formed TLV in the gap is rejected by the all-bytes-consumed check. - let mut well_formed = proof.bytes().to_vec(); - write_tlv_record_bytes(&mut well_formed, GAP_TYPE, b"ignored"); - let result = PayerProof::try_from(well_formed); - assert!(matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)))); - - // Case 2: a truncated/malformed length prefix after the gap type also rejects, - // without panicking — the length bytes are never read because the sub-streams - // rewind on the out-of-range type. - let mut malformed_length = proof.bytes().to_vec(); - BigSize(GAP_TYPE).write(&mut malformed_length).expect("Vec write should not fail"); - // `0xFD` promises two more bytes for a u16 length, but only one follows. If the - // parser ever tried to read this length, `BigSize::read` would return - // `DecodeError::ShortRead`. The fact that we still get `InvalidValue` below - // proves the sub-streams rewound before the length was ever touched. - malformed_length.push(0xFD); - malformed_length.push(0x01); - let result = PayerProof::try_from(malformed_length); - assert!(matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)))); - } + // Direct coverage for "unknown odd in signature range is silently skipped" + // is hard to exercise after the spec change: any byte appended to the + // proof's serialized form lands after the experimental TLVs (1B+), which + // is out of TLV order for a sub-1B type. Splicing into the canonical + // position would require parsing the bytes and reassembling. The + // equivalent invariant is covered indirectly: the standard + // `from_valid_tlv_stream_bytes` excludes the entire `SIGNATURE_TYPES` + // range by construction, so the implementation cannot regress it without + // the merkle helper itself changing. #[test] - fn test_round_trip_ignores_unknown_odd_signature_range_tlv_for_reconstruction() { - let unknown_odd_payer_proof_type = PAYER_PROOF_PAYER_SIGNATURE_TYPE + 1; - assert_eq!(unknown_odd_payer_proof_type % 2, 1); - assert!(SIGNATURE_TYPES.contains(&unknown_odd_payer_proof_type)); + fn test_round_trip_rejects_unknown_odd_data_range_tlv() { + // In contrast to the previous test, unknown odd TLVs in the + // `PAYER_PROOF_DATA_TYPES` range (1001..=999_999_999) are merkle + // leaves under the full-tree signing scheme. Inserting one after + // signing shifts the merkle root and the `proof_signature` no longer + // verifies. The parser rejects with `InvalidValue` (signature check + // fails inside `try_from`). + let unknown_odd_data_range_type = PAYER_PROOF_PROOF_NOTE_TYPE + 2; + assert_eq!(unknown_odd_data_range_type % 2, 1); + assert!(PAYER_PROOF_DATA_TYPES.contains(&unknown_odd_data_range_type)); let proof = build_round_trip_proof_with_multiple_trailing_omitted_tlvs(); let mut bytes = proof.bytes().to_vec(); - write_tlv_record_bytes(&mut bytes, unknown_odd_payer_proof_type, b"ignored"); + write_tlv_record_bytes(&mut bytes, unknown_odd_data_range_type, b"ignored"); - let parsed = PayerProof::try_from(bytes).unwrap(); - assert_eq!(parsed.payment_hash(), proof.payment_hash()); - assert_eq!(parsed.payer_signing_pubkey(), proof.payer_signing_pubkey()); + assert!(matches!( + PayerProof::try_from(bytes), + Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)) + )); } - #[test] - fn test_parsing_rejects_unknown_tlvs_above_signature_range() { - let unknown_odd_payer_proof_type = *SIGNATURE_TYPES.end() + 1; - assert_eq!(unknown_odd_payer_proof_type % 2, 1); - - let proof = build_round_trip_proof_with_multiple_trailing_omitted_tlvs(); - let mut bytes = proof.bytes().to_vec(); - write_tlv_record_bytes(&mut bytes, unknown_odd_payer_proof_type, b"ignored"); - - let result = PayerProof::try_from(bytes); - assert!(matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)))); - } + // `test_parsing_rejects_unknown_tlvs_above_signature_range` was removed: + // `*SIGNATURE_TYPES.end() + 1 == 1001` is now `PAYER_PROOF_PREIMAGE_TYPE` + // (a known TLV) per BOLT 12 PR 1295 commit 0f2b026. The relevant + // "unknown TLV in payer-proof data range" coverage is in + // `test_round_trip_rejects_unknown_odd_data_range_tlv`. #[test] fn test_parsed_proof_exposes_disclosed_fields() { @@ -1708,7 +1785,20 @@ mod tests { assert_rejected(50, DecodeError::UnknownRequiredFeature, "offer range"); assert_rejected(100, DecodeError::UnknownRequiredFeature, "invoice_request range"); assert_rejected(200, DecodeError::UnknownRequiredFeature, "invoice range"); - assert_rejected(252, DecodeError::UnknownRequiredFeature, "payer-proof/signature range"); + // Probe an unallocated even type in the signature range. The known + // payer-proof signature TLVs are 240 (issuer signature) and 241 + // (proof_signature); 254 sits above them and is unknown. + assert_rejected(254, DecodeError::UnknownRequiredFeature, "payer-proof/signature range"); + // Per BOLT 12 PR 1295 commit 0f2b026, the data-bearing payer-proof + // TLVs sit in `PAYER_PROOF_DATA_TYPES` (1001..=999_999_999). The + // known types are 1001..=1005; 1006 is unknown even and should be + // rejected. + assert_rejected(1006, DecodeError::UnknownRequiredFeature, "payer-proof data range (low)"); + assert_rejected( + 1_000_000, + DecodeError::UnknownRequiredFeature, + "payer-proof data range (mid)", + ); assert_rejected( 1_500_000_000, DecodeError::UnknownRequiredFeature, @@ -1725,17 +1815,11 @@ mod tests { "experimental invoice range", ); - // Gap between the signature range and the experimental ranges: no - // sub-stream covers `1001..1_000_000_000`, so the sub-streams rewind - // and `ParsedMessage::try_from`'s all-bytes-consumed check rejects. - // (There is no gap above `EXPERIMENTAL_INVOICE_TYPES`: it is open-ended - // to `u64::MAX`, so unknown even types there are caught by the - // unknown-even fallback above.) - assert_rejected( - 1_000_000, - DecodeError::InvalidValue, - "gap between signature and experimental ranges", - ); + // There is no gap between sub-streams now: signature range + // (240..=1000), payer-proof data range (1001..=999_999_999), and the + // experimental ranges (1B..) cover everything from type 240 onward. + // Below the offer range (type 0) is rejected separately by the + // `payer_metadata` check (see `test_parsing_rejects_payer_metadata`). } /// Test that malformed TLV framing is rejected without panicking. @@ -1968,7 +2052,70 @@ mod tests { (0..hex.len()).step_by(64).map(|i| hex[i..i + 64].to_string()).collect() } + /// Build a focused failure report for two bech32 strings that are expected + /// to be byte-identical except in the `payer_signature` region. + /// + /// Returns `None` when the strings match exactly. Otherwise returns a + /// `String` summarizing the divergence: how many leading/trailing bytes + /// match, the byte range of the differing region, and a short snippet + /// from each side. This avoids dumping ~1700-char bech32 strings into + /// the panic message. + fn report_bech32_mismatch(label: &str, got: &str, want: &str) -> Option { + if got == want { + return None; + } + + let first_diff = got.bytes().zip(want.bytes()).position(|(a, b)| a != b); + let Some(first) = first_diff else { + return Some(format!( + "{}: bech32 length differs (got {} chars, want {} chars), \ + but the common prefix matches", + label, + got.len(), + want.len(), + )); + }; + + // Walk from the end to find where the strings reconverge. + let trailing_match = + got.bytes().rev().zip(want.bytes().rev()).position(|(a, b)| a != b).unwrap_or(0); + let got_diff_end = got.len() - trailing_match; + let want_diff_end = want.len() - trailing_match; + let snippet = 40usize; + let got_snippet = &got[first..got_diff_end.min(first + snippet)]; + let want_snippet = &want[first..want_diff_end.min(first + snippet)]; + let got_truncated = got_diff_end > first + snippet; + let want_truncated = want_diff_end > first + snippet; + + Some(format!( + "{label}: bech32 differs in chars [{first}..{got_diff_end}] (got len {got_len}) \ + and [{first}..{want_diff_end}] (want len {want_len}). \ + First {first} chars match; last {trailing_match} chars match.\n \ + got : \"{got_snippet}{got_ellipsis}\"\n \ + want : \"{want_snippet}{want_ellipsis}\"\n \ + (Under the proposed full-tree signing scheme — see module docs — \ + only the payer_signature value bytes are expected to differ from \ + the spec vectors. If anything outside that region differs, the \ + implementation has drifted from the proposal.)", + label = label, + first = first, + got_diff_end = got_diff_end, + want_diff_end = want_diff_end, + got_len = got.len(), + want_len = want.len(), + trailing_match = trailing_match, + got_snippet = got_snippet, + got_ellipsis = if got_truncated { "…" } else { "" }, + want_snippet = want_snippet, + want_ellipsis = if want_truncated { "…" } else { "" }, + )) + } + #[test] + #[ignore = "spec test vectors are pinned to the previous payer_signature scheme \ + (sign(SHA256(note || invoice_merkle_root))); they need to be regenerated \ + from the proposed full-tree signing scheme once the BOLT 12 spec change \ + (see module docs) lands"] fn check_against_spec_vectors() { let secp_ctx = Secp256k1::new(); let payer_keys = Keypair::from_secret_key( @@ -2032,7 +2179,18 @@ mod tests { }) .unwrap_or_else(|e| panic!("{}: sign failed: {:?}", vector.name, e)); - assert_eq!(proof.to_string(), vector.bech32, "{}: bech32 mismatch", vector.name); + // Under the proposed full-tree signing scheme, every disclosure- + // level field above (leaf_hashes, omitted_markers, missing_hashes, + // invoice merkle_root) still matches the spec vectors because they + // are determined by the invoice and the disclosure rules, not by + // the payer_signature scheme. Only the payer_signature value bytes + // are expected to differ. If anything else differs, the report + // below pinpoints where so the divergence is easy to triage. + if let Some(report) = + report_bech32_mismatch(vector.name, &proof.to_string(), vector.bech32) + { + panic!("{}", report); + } } } } From 35575788117c397180f0f63c739f1b96bcfdc981 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Thu, 30 Apr 2026 20:05:04 +0200 Subject: [PATCH 6/7] fixup! feat(offers): add BOLT 12 payer proof primitives --- lightning/src/ln/offers_tests.rs | 6 +++--- lightning/src/offers/payer_proof.rs | 32 ++++++++++++++--------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 5db175b3dd6..b6af557295c 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2779,7 +2779,7 @@ fn creates_and_verifies_payer_proof_after_offer_payment() { assert_eq!(payer_proof.payment_hash(), invoice.payment_hash()); assert_eq!(payer_proof.payer_signing_pubkey(), invoice.payer_signing_pubkey()); assert_eq!(payer_proof.issuer_signing_pubkey(), invoice.signing_pubkey()); - assert!(payer_proof.payer_note().is_none()); + assert!(payer_proof.proof_note().is_none()); // --- Serialization Round-Trip --- // The proof can be serialized to a bech32 string (lnp...) for sharing. @@ -2905,7 +2905,7 @@ fn creates_payer_proof_with_note_and_selective_disclosure() { .include_invoice_created_at() .build_and_sign(Some("Paid for coffee".into())) .unwrap(); - assert_eq!(payer_proof_with_note.payer_note().map(|note| note.0), Some("Paid for coffee")); + assert_eq!(payer_proof_with_note.proof_note().map(|note| note.0), Some("Paid for coffee")); // Both proofs should verify and have the same core fields assert_eq!(minimal_payer_proof.payment_preimage(), payer_proof_with_note.payment_preimage()); @@ -2921,6 +2921,6 @@ fn creates_payer_proof_with_note_and_selective_disclosure() { assert!(encoded.starts_with("lnp1")); let decoded = PayerProof::try_from(payer_proof_with_note.bytes().to_vec()).unwrap(); - assert_eq!(decoded.payer_note().map(|note| note.0), Some("Paid for coffee")); + assert_eq!(decoded.proof_note().map(|note| note.0), Some("Paid for coffee")); assert_eq!(decoded.payment_preimage(), payment_preimage); } diff --git a/lightning/src/offers/payer_proof.rs b/lightning/src/offers/payer_proof.rs index 3dbc7e1383d..8293115fba4 100644 --- a/lightning/src/offers/payer_proof.rs +++ b/lightning/src/offers/payer_proof.rs @@ -308,10 +308,10 @@ struct PayerProofContents { /// Schnorr signature by `invreq_payer_id` over the merkle root of all /// payer-proof TLVs except the `proof_signature` TLV itself. See module /// docs for the authentication scheme. - payer_signature: Signature, + proof_signature: Signature, /// Optional payer-supplied note. Its own TLV (a regular merkle leaf) /// per BOLT 12 PR 1295 commit `0f2b026`. - payer_note: Option, + proof_note: Option, disclosed_fields: DisclosedFields, } @@ -658,8 +658,8 @@ impl UnsignedPayerProof<'_> { issuer_signing_pubkey: self.issuer_signing_pubkey, preimage: self.preimage, invoice_signature: self.invoice_signature, - payer_signature: proof_signature, - payer_note: self.payer_note.take(), + proof_signature, + proof_note: self.payer_note.take(), disclosed_fields: self.disclosed_fields, }, merkle_root: self.disclosure.merkle_root, @@ -742,8 +742,8 @@ impl PayerProof { } /// The payer's schnorr signature proving who authorized the payment. - pub fn payer_signature(&self) -> Signature { - self.contents.payer_signature + pub fn proof_signature(&self) -> Signature { + self.contents.proof_signature } /// The disclosed offer description, if included in the proof. @@ -770,12 +770,12 @@ impl PayerProof { /// /// This is distinct from [`InvoiceRequest::payer_note`]: the invoice-request note is /// sent to the payee at payment time, while this note is scoped to the proof and is - /// committed to by the [`payer_signature`] alongside the invoice's merkle root. + /// committed to by the [`proof_signature`] alongside the invoice's merkle root. /// /// [`InvoiceRequest::payer_note`]: crate::offers::invoice_request::InvoiceRequest::payer_note - /// [`payer_signature`]: Self::payer_signature - pub fn payer_note(&self) -> Option> { - self.contents.payer_note.as_deref().map(PrintableString) + /// [`proof_signature`]: Self::proof_signature + pub fn proof_note(&self) -> Option> { + self.contents.proof_note.as_deref().map(PrintableString) } /// The merkle root of the original invoice. @@ -890,8 +890,8 @@ impl TryFrom for ParsedPayerProofFields { issuer_signing_pubkey, preimage, invoice_signature, - payer_signature: proof_signature, - payer_note: proof_note, + proof_signature, + proof_note, disclosed_fields: DisclosedFields { offer_description: description, offer_issuer: issuer, @@ -972,10 +972,10 @@ impl TryFrom> for PayerProof { // Verify the payer signature against the merkle root of the proof // itself, computed over every payer-proof TLV except the // `proof_signature` TLV being verified. See module docs. - let payer_tagged_hash = proof_signature_hash(&bytes); + let proof_tagged_hash = proof_signature_hash(&bytes); merkle::verify_signature( - &contents.payer_signature, - &payer_tagged_hash, + &contents.proof_signature, + &proof_tagged_hash, contents.payer_signing_pubkey, ) .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; @@ -1975,7 +1975,7 @@ mod tests { assert_eq!(parsed.payment_preimage(), preimage); assert_eq!(parsed.payment_hash(), payment_hash); - assert_eq!(parsed.payer_note().map(|note| note.to_string()), Some("refund".to_string())); + assert_eq!(parsed.proof_note().map(|note| note.to_string()), Some("refund".to_string())); } // BOLT 12 payer proof test vectors (from bolt12/payer-proof-test.json). From 5b1d61f552cd86a99794e5dab15333b9b517e5a8 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Thu, 30 Apr 2026 22:36:33 +0200 Subject: [PATCH 7/7] fixup! feat(offers): add BOLT 12 payer proof primitives --- lightning/src/offers/payer_proof.rs | 169 +++------------------------- 1 file changed, 14 insertions(+), 155 deletions(-) diff --git a/lightning/src/offers/payer_proof.rs b/lightning/src/offers/payer_proof.rs index 8293115fba4..57275a72695 100644 --- a/lightning/src/offers/payer_proof.rs +++ b/lightning/src/offers/payer_proof.rs @@ -16,51 +16,6 @@ //! //! This implements the payer proof extension to BOLT 12 as specified in //! . -//! -//! # Proposed authentication change (full payer-proof merkle root) -//! -//! The current spec (BOLT 12 PR 1295) signs only `SHA256(payer_signature.note || -//! invoice_merkle_root)` with `invreq_payer_id`. Tamper-resistance for the -//! remaining payer-proof TLVs (`preimage`, `omitted_tlvs`, `missing_hashes`, -//! `leaf_hashes`) is *transitive*: each one has a separate binding (the -//! preimage hashes to `invoice_payment_hash`; the rest reconstruct the invoice -//! merkle root that the issuer's `signature` covers). It works only because -//! every payer_proof TLV today is either part of the already-signed invoice or -//! has an out-of-band binding to it. Any future payer-side TLV outside both -//! categories silently loses authentication — see -//! . -//! -//! T-bast's proposal is to sign the merkle root of *all* payer_proof TLVs and -//! to extract `note` into its own TLV (a normal merkle leaf instead of being -//! bundled inside the `payer_signature` TLV). Under that scheme: -//! -//! 1. `payer_signature` becomes a plain `bip340sig` like every other signature -//! TLV in BOLT 12. -//! 2. `note` becomes a dedicated TLV (`PAYER_PROOF_PROOF_NOTE_TYPE`, see -//! below); the type number is provisional and may move when the spec -//! reallocates payer-proof data TLVs out of the `SIGNATURE_TYPES` range. -//! 3. The `payer_signature` is a tagged signature over the merkle root of all -//! payer_proof TLVs except the `payer_signature` TLV itself, computed -//! exactly as `signature` is computed for invoices/offers/invoice requests. -//! -//! Verifier flow under the new scheme is two signature checks (instead of the -//! current implicit chain of three): -//! -//! 1. Verify `payer_signature` against the payer-proof merkle root using -//! `invreq_payer_id`. -//! 2. Reconstruct the invoice merkle root from `leaf_hashes`, -//! `missing_hashes`, `omitted_tlvs`, and the disclosed invoice TLVs, then -//! verify the issuer's `signature` against it using `invoice_node_id`. -//! -//! `SHA256(preimage) == invoice_payment_hash` remains a separate check. -//! -//! This branch carries the design and a placeholder TLV constant for the new -//! `payer_note` TLV; the structural code change to switch over has been -//! deferred until the spec settles on the final TLV layout (in particular -//! whether the data-bearing payer-proof TLVs `preimage`/`omitted_tlvs`/ -//! `missing_hashes`/`leaf_hashes` move out of the `SIGNATURE_TYPES` range so -//! they can be merkle leaves under the standard BOLT 12 signing convention). -//! Tracking issue: . use alloc::collections::BTreeSet; @@ -204,49 +159,22 @@ impl PaidBolt12Invoice { } } -// TLV layout per BOLT 12 PR 1295 commit 0f2b026 (2026-04-30): -// -// 240 signature (issuer) bip340sig -// 241 proof_signature (payer) bip340sig -// 1001 preimage 32*byte -// 1002 omitted_tlvs ...*bigsize -// 1003 missing_hashes ...*sha256 -// 1004 leaf_hashes ...*sha256 -// 1005 proof_note ...*utf8 -// -// The data-bearing TLVs are now outside the BOLT 12 `SIGNATURE_TYPES` range -// (240..=1000), so the standard merkle-root computation includes them as -// leaves and `proof_signature` covers the entire proof. The issuer -// `signature` covers the merkle-root of the invoice "without fields 1001 -// through 999999999 inclusive" (per the spec); `tlv_stream_iter` strips -// that range during invoice merkle reconstruction. - -/// TLV type for the issuer's signature on the invoice (copied into the proof). const PAYER_PROOF_ISSUER_SIGNATURE_TYPE: u64 = 240; -/// TLV type for the payer's `proof_signature` over the proof's merkle root. const PAYER_PROOF_PROOF_SIGNATURE_TYPE: u64 = 241; -/// TLV type for the payment preimage. const PAYER_PROOF_PREIMAGE_TYPE: u64 = 1001; -/// TLV type for the omitted-TLV markers. const PAYER_PROOF_OMITTED_TLVS_TYPE: u64 = 1002; -/// TLV type for the missing-merkle-branch hashes. const PAYER_PROOF_MISSING_HASHES_TYPE: u64 = 1003; -/// TLV type for the per-included-leaf nonce hashes. const PAYER_PROOF_LEAF_HASHES_TYPE: u64 = 1004; -/// TLV type for the optional proof note. const PAYER_PROOF_PROOF_NOTE_TYPE: u64 = 1005; -/// Range covering the data-bearing payer-proof TLVs as defined in BOLT 12 -/// PR 1295 commit 0f2b026: `1001..=999_999_999`. The standard BOLT 12 merkle -/// root for the invoice excludes everything in this range; the merkle root -/// for `proof_signature` includes everything in this range as leaves. +/// Range covering the data-bearing payer-proof TLVs. pub(super) const PAYER_PROOF_DATA_TYPES: core::ops::RangeInclusive = 1001..=999_999_999; /// Human-readable prefix for payer proofs in bech32 encoding. pub const PAYER_PROOF_HRP: &str = "lnp"; /// Tag for `proof_signature` computation per BOLT 12 signature calculation. -/// Format: `"lightning" || messagename || fieldname`. +/// Format: "lightning" || messagename || fieldname const PROOF_SIGNATURE_TAG: &str = concat!("lightning", "payer_proof", "proof_signature"); /// Error when building or verifying a payer proof. @@ -305,12 +233,7 @@ struct PayerProofContents { issuer_signing_pubkey: PublicKey, preimage: PaymentPreimage, invoice_signature: Signature, - /// Schnorr signature by `invreq_payer_id` over the merkle root of all - /// payer-proof TLVs except the `proof_signature` TLV itself. See module - /// docs for the authentication scheme. proof_signature: Signature, - /// Optional payer-supplied note. Its own TLV (a regular merkle leaf) - /// per BOLT 12 PR 1295 commit `0f2b026`. proof_note: Option, disclosed_fields: DisclosedFields, } @@ -524,14 +447,6 @@ impl<'a, S: SigningPubkeyStrategy> PayerProofBuilder<'a, S> { /// Computes the [`TaggedHash`] for the `proof_signature` over the merkle root /// of the payer-proof TLV stream. -/// -/// `bytes` must be a well-formed TLV stream containing all payer-proof TLVs. -/// Per BOLT 12 PR 1295 commit 0f2b026, the data-bearing payer-proof TLVs -/// (`preimage`, `omitted_tlvs`, `missing_hashes`, `leaf_hashes`, `proof_note`) -/// live in the `PAYER_PROOF_DATA_TYPES` range (1001..=999_999_999), so the -/// standard BOLT 12 merkle-root computation includes them as leaves -/// automatically; only the `SIGNATURE_TYPES` range (which holds the issuer's -/// `signature` and the `proof_signature` itself) is excluded. fn proof_signature_hash(bytes: &[u8]) -> TaggedHash { TaggedHash::from_valid_tlv_stream_bytes(PROOF_SIGNATURE_TAG, bytes) } @@ -911,17 +826,8 @@ impl TryFrom for ParsedPayerProofFields { } fn tlv_stream_iter<'a>(bytes: &'a [u8]) -> impl core::iter::Iterator> { - // Iterate the invoice TLVs only, for reconstructing the invoice merkle - // root. By the time we get here, `ParsedMessage::` - // has already parsed `bytes` through every sub-stream and the - // `tlv_stream!`-generated parsers have rejected any unknown even TLV in any - // sub-stream's range. - // - // Per BOLT 12 PR 1295 commit 0f2b026, the issuer's `signature` covers the - // merkle-root of the invoice "without fields 1001 through 999999999 - // inclusive" — i.e., excluding both `SIGNATURE_TYPES` (240..=1000, the - // standard convention) and `PAYER_PROOF_DATA_TYPES` (1001..=999_999_999, - // the new payer-proof data range). + // Strip both `SIGNATURE_TYPES` and `PAYER_PROOF_DATA_TYPES` so the + // remaining records reconstruct the invoice merkle root. TlvStream::new(bytes).filter(|record| { !SIGNATURE_TYPES.contains(&record.r#type) && !PAYER_PROOF_DATA_TYPES.contains(&record.r#type) @@ -1693,31 +1599,11 @@ mod tests { assert!(matches!(result, Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)))); } - // `test_parsing_rejects_tlv_in_unused_range` was removed: per BOLT 12 PR - // 1295 commit 0f2b026, the previously-unused range between - // `SIGNATURE_TYPES` and `EXPERIMENTAL_OFFER_TYPES` is now covered by - // `PAYER_PROOF_DATA_TYPES` (1001..=999_999_999). Coverage of unknown even - // types inside that range is provided by - // `test_parsing_rejects_unknown_even_tlvs_in_every_range`. - - // Direct coverage for "unknown odd in signature range is silently skipped" - // is hard to exercise after the spec change: any byte appended to the - // proof's serialized form lands after the experimental TLVs (1B+), which - // is out of TLV order for a sub-1B type. Splicing into the canonical - // position would require parsing the bytes and reassembling. The - // equivalent invariant is covered indirectly: the standard - // `from_valid_tlv_stream_bytes` excludes the entire `SIGNATURE_TYPES` - // range by construction, so the implementation cannot regress it without - // the merkle helper itself changing. - #[test] fn test_round_trip_rejects_unknown_odd_data_range_tlv() { - // In contrast to the previous test, unknown odd TLVs in the - // `PAYER_PROOF_DATA_TYPES` range (1001..=999_999_999) are merkle - // leaves under the full-tree signing scheme. Inserting one after - // signing shifts the merkle root and the `proof_signature` no longer - // verifies. The parser rejects with `InvalidValue` (signature check - // fails inside `try_from`). + // Unknown odd TLVs in the `PAYER_PROOF_DATA_TYPES` range are merkle + // leaves; inserting one after signing shifts the merkle root and the + // `proof_signature` no longer verifies. let unknown_odd_data_range_type = PAYER_PROOF_PROOF_NOTE_TYPE + 2; assert_eq!(unknown_odd_data_range_type % 2, 1); assert!(PAYER_PROOF_DATA_TYPES.contains(&unknown_odd_data_range_type)); @@ -1732,12 +1618,6 @@ mod tests { )); } - // `test_parsing_rejects_unknown_tlvs_above_signature_range` was removed: - // `*SIGNATURE_TYPES.end() + 1 == 1001` is now `PAYER_PROOF_PREIMAGE_TYPE` - // (a known TLV) per BOLT 12 PR 1295 commit 0f2b026. The relevant - // "unknown TLV in payer-proof data range" coverage is in - // `test_round_trip_rejects_unknown_odd_data_range_tlv`. - #[test] fn test_parsed_proof_exposes_disclosed_fields() { let proof = build_round_trip_proof_with_disclosed_fields(); @@ -1785,14 +1665,9 @@ mod tests { assert_rejected(50, DecodeError::UnknownRequiredFeature, "offer range"); assert_rejected(100, DecodeError::UnknownRequiredFeature, "invoice_request range"); assert_rejected(200, DecodeError::UnknownRequiredFeature, "invoice range"); - // Probe an unallocated even type in the signature range. The known - // payer-proof signature TLVs are 240 (issuer signature) and 241 - // (proof_signature); 254 sits above them and is unknown. + // 240 and 241 are the known signature TLVs; 254 is unknown. assert_rejected(254, DecodeError::UnknownRequiredFeature, "payer-proof/signature range"); - // Per BOLT 12 PR 1295 commit 0f2b026, the data-bearing payer-proof - // TLVs sit in `PAYER_PROOF_DATA_TYPES` (1001..=999_999_999). The - // known types are 1001..=1005; 1006 is unknown even and should be - // rejected. + // 1001..=1005 are the known data TLVs; 1006 is unknown. assert_rejected(1006, DecodeError::UnknownRequiredFeature, "payer-proof data range (low)"); assert_rejected( 1_000_000, @@ -1815,11 +1690,8 @@ mod tests { "experimental invoice range", ); - // There is no gap between sub-streams now: signature range - // (240..=1000), payer-proof data range (1001..=999_999_999), and the - // experimental ranges (1B..) cover everything from type 240 onward. - // Below the offer range (type 0) is rejected separately by the - // `payer_metadata` check (see `test_parsing_rejects_payer_metadata`). + // Type 0 is rejected separately by the `payer_metadata` check + // (see `test_parsing_rejects_payer_metadata`). } /// Test that malformed TLV framing is rejected without panicking. @@ -2092,11 +1964,7 @@ mod tests { and [{first}..{want_diff_end}] (want len {want_len}). \ First {first} chars match; last {trailing_match} chars match.\n \ got : \"{got_snippet}{got_ellipsis}\"\n \ - want : \"{want_snippet}{want_ellipsis}\"\n \ - (Under the proposed full-tree signing scheme — see module docs — \ - only the payer_signature value bytes are expected to differ from \ - the spec vectors. If anything outside that region differs, the \ - implementation has drifted from the proposal.)", + want : \"{want_snippet}{want_ellipsis}\"", label = label, first = first, got_diff_end = got_diff_end, @@ -2112,10 +1980,8 @@ mod tests { } #[test] - #[ignore = "spec test vectors are pinned to the previous payer_signature scheme \ - (sign(SHA256(note || invoice_merkle_root))); they need to be regenerated \ - from the proposed full-tree signing scheme once the BOLT 12 spec change \ - (see module docs) lands"] + #[ignore = "spec test vectors need to be regenerated against the current \ + full-tree signing scheme; see "] fn check_against_spec_vectors() { let secp_ctx = Secp256k1::new(); let payer_keys = Keypair::from_secret_key( @@ -2179,13 +2045,6 @@ mod tests { }) .unwrap_or_else(|e| panic!("{}: sign failed: {:?}", vector.name, e)); - // Under the proposed full-tree signing scheme, every disclosure- - // level field above (leaf_hashes, omitted_markers, missing_hashes, - // invoice merkle_root) still matches the spec vectors because they - // are determined by the invoice and the disclosure rules, not by - // the payer_signature scheme. Only the payer_signature value bytes - // are expected to differ. If anything else differs, the report - // below pinpoints where so the divergence is easy to triage. if let Some(report) = report_bech32_mismatch(vector.name, &proof.to_string(), vector.bech32) {