From 384ce7b90f544be11597404ae4ccc29f212dce2a Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 24 Feb 2026 18:43:09 +0100 Subject: [PATCH 1/6] 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 | 88 +++++++++++++++++++++++++++------ lightning/src/offers/signer.rs | 67 ++++++++++++++++++++++--- 2 files changed, 134 insertions(+), 21 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..8513299080b 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,35 @@ 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 + /// [`PaidBolt12Invoice::prove_payer_derived`]: crate::offers::payer_proof::PaidBolt12Invoice::prove_payer_derived + 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 +1347,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 +1360,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..bc0442ba093 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -321,6 +321,38 @@ 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. +/// +/// This re-performs the same key derivation that occurs during invoice request creation with +/// [`InvoiceRequestBuilder::deriving_signing_pubkey`], allowing the payer to recover their +/// signing keypair for creating payer proofs. +/// +/// The `tlv_stream` must contain the offer and invoice request TLV records (excluding +/// payer metadata type 0 and payer_id type 88), matching what was used during +/// the original key derivation. +/// +/// [`InvoiceRequestBuilder::deriving_signing_pubkey`]: crate::offers::invoice_request::InvoiceRequestBuilder +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 +371,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 +412,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 7257a0220303941bdfff085d07b786c59246a68a Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 24 Feb 2026 18:44:00 +0100 Subject: [PATCH 2/6] 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. --- lightning/src/ln/offers_tests.rs | 241 ++++ lightning/src/offers/invoice.rs | 28 + lightning/src/offers/merkle.rs | 679 +++++++++++- lightning/src/offers/mod.rs | 1 + lightning/src/offers/offer.rs | 6 + lightning/src/offers/payer_proof.rs | 1595 +++++++++++++++++++++++++++ 6 files changed, 2548 insertions(+), 2 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..d657c4e0ac4 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::{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 [`PayerProofBuilder::build_with_derived_key`]. +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,227 @@ 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 proof = invoice.payer_proof_builder(payment_preimage).unwrap() + .include_offer_description() + .include_invoice_amount() + .include_invoice_created_at() + .build_with_derived_key(&expanded_key, payer_nonce, payment_id, None) + .unwrap(); + + // Check proof contents match the original payment + assert_eq!(proof.preimage(), payment_preimage); + assert_eq!(proof.payment_hash(), invoice.payment_hash()); + assert_eq!(proof.payer_id(), 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.preimage(), proof.preimage()); + assert_eq!(decoded.payment_hash(), proof.payment_hash()); + assert_eq!(decoded.payer_id(), proof.payer_id()); + 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]); + assert!(invoice.payer_proof_builder(wrong_preimage).is_err()); + + // --- Test 2: Wrong payment_id causes key derivation failure --- + let expanded_key = bob.keys_manager.get_expanded_key(); + let wrong_payment_id = PaymentId([0xFF; 32]); + let result = invoice.payer_proof_builder(payment_preimage).unwrap() + .build_with_derived_key(&expanded_key, payer_nonce, wrong_payment_id, None); + 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 result = invoice.payer_proof_builder(payment_preimage).unwrap() + .build_with_derived_key(&expanded_key, wrong_nonce, payment_id, None); + assert!(matches!(result, Err(PayerProofError::KeyDerivationFailed))); + + // --- Test 4: Minimal proof (only required fields) --- + let minimal_proof = invoice.payer_proof_builder(payment_preimage).unwrap() + .build_with_derived_key(&expanded_key, payer_nonce, payment_id, None) + .unwrap(); + // --- Test 5: Proof with selective disclosure and payer note --- + let proof_with_note = invoice.payer_proof_builder(payment_preimage).unwrap() + .include_offer_description() + .include_offer_issuer() + .include_invoice_amount() + .include_invoice_created_at() + .build_with_derived_key(&expanded_key, payer_nonce, payment_id, Some("Paid for coffee")) + .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.preimage(), proof_with_note.preimage()); + assert_eq!(minimal_proof.payment_hash(), proof_with_note.payment_hash()); + assert_eq!(minimal_proof.payer_id(), proof_with_note.payer_id()); + 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.preimage(), payment_preimage); +} diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 8513299080b..8d138012edd 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -141,6 +141,7 @@ use crate::offers::offer::{ }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; use crate::offers::payer::{PayerTlvStream, PayerTlvStreamRef, PAYER_METADATA_TYPE}; +use crate::offers::payer_proof::{PayerProofBuilder, PayerProofError}; use crate::offers::refund::{ Refund, RefundContents, IV_BYTES_WITHOUT_METADATA as REFUND_IV_BYTES_WITHOUT_METADATA, IV_BYTES_WITH_METADATA as REFUND_IV_BYTES_WITH_METADATA, @@ -148,6 +149,7 @@ use crate::offers::refund::{ use crate::offers::signer::{self, Metadata}; use crate::types::features::{Bolt12InvoiceFeatures, InvoiceRequestFeatures, OfferFeatures}; use crate::types::payment::PaymentHash; +use crate::types::payment::PaymentPreimage; use crate::types::string::PrintableString; use crate::util::ser::{ CursorReadable, HighZeroBytesDroppedBigSize, Iterable, LengthLimitedRead, LengthReadable, @@ -1033,6 +1035,17 @@ impl Bolt12Invoice { ) } + /// Creates a [`PayerProofBuilder`] for this invoice using the given payment preimage. + /// + /// Returns an error if the preimage doesn't match the invoice's payment hash. + /// + /// [`PayerProofBuilder`]: crate::offers::payer_proof::PayerProofBuilder + pub fn payer_proof_builder( + &self, preimage: PaymentPreimage, + ) -> Result, PayerProofError> { + PayerProofBuilder::new(self, preimage) + } + /// Re-derives the payer's signing keypair for payer proof creation. /// /// This performs the same key derivation that occurs during invoice request creation @@ -1558,6 +1571,21 @@ 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>)), diff --git a/lightning/src/offers/merkle.rs b/lightning/src/offers/merkle.rs index 1a38fe5441f..886a1e9ad66 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,6 +301,440 @@ impl<'a> Writeable for TlvRecord<'a> { } } +// ============================================================================ +// Selective Disclosure for Payer Proofs (BOLT 12 extension) +// ============================================================================ + +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 +/// * `tlv_bytes` - Complete TLV stream (e.g., invoice bytes without signature) +/// * `included_types` - Set of TLV types to include in the disclosure +pub(super) fn compute_selective_disclosure( + tlv_bytes: &[u8], included_types: &BTreeSet, +) -> Result { + let mut tlv_stream = TlvStream::new(tlv_bytes).peekable(); + let first_record = tlv_stream.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 tlv_stream.filter(|r| !SIGNATURE_TYPES.contains(&r.r#type)) { + 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() +} + +/// A node in the merkle tree during selective disclosure processing. +struct TreeNode { + hash: Option, + included: bool, + min_type: u64, +} + +/// Build merkle tree and collect missing_hashes for omitted subtrees. +/// +/// Returns hashes sorted by ascending TLV type as required by the spec. For internal +/// nodes, the type used for ordering is the minimum TLV type in that subtree. +/// +/// Uses `n` tree nodes (one per TLV) rather than `2n`, since the per-TLV hashes +/// already combine leaf and nonce. The tree traversal starts at level 0 to pair +/// adjacent per-TLV hashes, matching the structure of `root_hash()`. +fn build_tree_with_disclosure( + tlv_data: &[TlvMerkleData], branch_tag: &sha256::HashEngine, +) -> (sha256::Hash, Vec) { + let num_nodes = tlv_data.len(); + debug_assert!(num_nodes > 0, "TLV stream must contain at least one record"); + + let num_omitted = tlv_data.iter().filter(|d| !d.is_included).count(); + + let mut nodes: Vec = tlv_data + .iter() + .map(|data| TreeNode { + hash: Some(data.per_tlv_hash), + included: data.is_included, + min_type: data.tlv_type, + }) + .collect(); + + let mut missing_with_types: Vec<(u64, sha256::Hash)> = Vec::with_capacity(num_omitted); + + for level in 0.. { + let step = 2 << level; + let offset = step / 2; + if offset >= num_nodes { + break; + } + + for (left_pos, right_pos) in + (0..num_nodes).step_by(step).zip((offset..num_nodes).step_by(step)) + { + let left_hash = nodes[left_pos].hash; + let right_hash = nodes[right_pos].hash; + let left_incl = nodes[left_pos].included; + let right_incl = nodes[right_pos].included; + let right_min_type = nodes[right_pos].min_type; + + match (left_hash, right_hash) { + (Some(l), Some(r)) => { + if left_incl != right_incl { + let (missing_type, missing_hash) = if right_incl { + (nodes[left_pos].min_type, l) + } else { + (right_min_type, r) + }; + missing_with_types.push((missing_type, missing_hash)); + } + nodes[left_pos].hash = + Some(tagged_branch_hash_from_engine(branch_tag.clone(), l, r)); + nodes[left_pos].included |= left_incl || right_incl; + nodes[left_pos].min_type = + core::cmp::min(nodes[left_pos].min_type, right_min_type); + }, + (Some(_), None) => {}, + _ => unreachable!("Invalid state in merkle tree construction"), + } + } + } + + missing_with_types.sort_by_key(|(min_type, _)| *min_type); + let missing_hashes: Vec = + missing_with_types.into_iter().map(|(_, h)| h).collect(); + + (nodes[0].hash.expect("Tree should have a root"), missing_hashes) +} + +/// Reconstruct merkle root from selective disclosure data. +/// +/// The `missing_hashes` must be in ascending type order per spec. +/// +/// Uses `n` tree nodes (one per TLV position) rather than `2n`, since per-TLV +/// hashes already combine leaf and nonce. Two passes over the tree determine +/// where missing hashes are needed and then combine all hashes to the root. +pub(super) fn reconstruct_merkle_root<'a>( + included_records: &[(u64, &'a [u8])], leaf_hashes: &[sha256::Hash], omitted_markers: &[u64], + missing_hashes: &[sha256::Hash], +) -> Result { + // Callers are expected to validate omitted_markers before calling this function + // (e.g., via validate_omitted_markers_for_parsing). Debug-assert for safety. + 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 TreeNode vec directly by interleaving included/omitted positions, + // eliminating the intermediate Vec from reconstruct_positions_from_records. + let num_nodes = 1 + included_records.len() + omitted_markers.len(); + let mut nodes: Vec = Vec::with_capacity(num_nodes); + + // TLV0 is always omitted + nodes.push(TreeNode { hash: None, included: false, min_type: 0 }); + + let mut inc_idx = 0; + let mut mrk_idx = 0; + let mut prev_marker: u64 = 0; + let mut node_idx: u64 = 1; + + while inc_idx < included_records.len() || mrk_idx < omitted_markers.len() { + if mrk_idx >= omitted_markers.len() { + // No more markers, remaining positions are included + let (_, record_bytes) = included_records[inc_idx]; + let leaf_hash = tagged_hash_from_engine(leaf_tag.clone(), record_bytes); + let nonce_hash = leaf_hashes[inc_idx]; + let hash = tagged_branch_hash_from_engine(branch_tag.clone(), leaf_hash, nonce_hash); + nodes.push(TreeNode { hash: Some(hash), included: true, min_type: node_idx }); + inc_idx += 1; + } else if inc_idx >= included_records.len() { + // No more included types, remaining positions are omitted + nodes.push(TreeNode { hash: None, included: false, min_type: node_idx }); + prev_marker = omitted_markers[mrk_idx]; + mrk_idx += 1; + } else { + let marker = omitted_markers[mrk_idx]; + let (inc_type, _) = included_records[inc_idx]; + + if marker == prev_marker + 1 { + // Continuation of current run -> omitted position + nodes.push(TreeNode { hash: None, included: false, min_type: node_idx }); + prev_marker = marker; + mrk_idx += 1; + } else { + // Jump detected -> included position comes first + let (_, record_bytes) = included_records[inc_idx]; + let leaf_hash = tagged_hash_from_engine(leaf_tag.clone(), record_bytes); + let nonce_hash = leaf_hashes[inc_idx]; + let hash = + tagged_branch_hash_from_engine(branch_tag.clone(), leaf_hash, nonce_hash); + nodes.push(TreeNode { hash: Some(hash), included: true, min_type: node_idx }); + prev_marker = inc_type; + inc_idx += 1; + } + } + node_idx += 1; + } + + // First pass: walk the tree to discover which positions need missing hashes. + // We mutate nodes[].included and nodes[].min_type directly since the second + // pass only reads nodes[].hash, making this safe without a separate allocation. + let num_omitted = omitted_markers.len() + 1; // +1 for implicit TLV0 + let mut needs_hash: Vec<(u64, usize)> = Vec::with_capacity(num_omitted); + + for level in 0.. { + let step = 2 << level; + let offset = step / 2; + if offset >= num_nodes { + break; + } + + for left_pos in (0..num_nodes).step_by(step) { + let right_pos = left_pos + offset; + if right_pos >= num_nodes { + continue; + } + + let r_min = nodes[right_pos].min_type; + + match (nodes[left_pos].included, nodes[right_pos].included) { + (true, false) => { + needs_hash.push((r_min, right_pos)); + nodes[left_pos].min_type = core::cmp::min(nodes[left_pos].min_type, r_min); + }, + (false, true) => { + needs_hash.push((nodes[left_pos].min_type, left_pos)); + nodes[left_pos].included = true; + nodes[left_pos].min_type = core::cmp::min(nodes[left_pos].min_type, r_min); + }, + (true, true) => { + nodes[left_pos].min_type = core::cmp::min(nodes[left_pos].min_type, r_min); + }, + (false, false) => { + nodes[left_pos].min_type = core::cmp::min(nodes[left_pos].min_type, r_min); + }, + } + } + } + + needs_hash.sort_by_key(|(min_pos, _)| *min_pos); + + if needs_hash.len() != missing_hashes.len() { + return Err(SelectiveDisclosureError::InsufficientMissingHashes); + } + + // Place missing hashes directly into the nodes array. + for (i, &(_, tree_pos)) in needs_hash.iter().enumerate() { + nodes[tree_pos].hash = Some(missing_hashes[i]); + } + + // Second pass: combine hashes up the tree. + for level in 0.. { + let step = 2 << level; + let offset = step / 2; + if offset >= num_nodes { + break; + } + + for left_pos in (0..num_nodes).step_by(step) { + let right_pos = left_pos + offset; + if right_pos >= num_nodes { + continue; + } + + match (nodes[left_pos].hash, nodes[right_pos].hash) { + (Some(l), Some(r)) => { + nodes[left_pos].hash = + Some(tagged_branch_hash_from_engine(branch_tag.clone(), l, r)); + }, + (Some(_), None) => {}, + (None, _) => {}, + }; + } + } + + nodes[0].hash.ok_or(SelectiveDisclosureError::InsufficientMissingHashes) +} + +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}; @@ -497,4 +952,224 @@ mod tests { self.fmt_bech32_str(f) } } + + // ============================================================================ + // Tests for selective disclosure / payer proof reconstruction + // ============================================================================ + + /// 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(&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<(u64, &[u8])> = TlvStream::new(&tlv_bytes) + .filter(|r| included.contains(&r.r#type)) + .map(|r| (r.r#type, r.record_bytes)) + .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 missing_hashes are in ascending type order per spec. + /// + /// Per spec: "MUST include the minimal set of merkle hashes of missing merkle + /// leaves or nodes in `missing_hashes`, in ascending type order." + /// + /// For the spec example 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 (min=20) + /// - hash(50) covers type 50 + /// - hash(60) covers type 60 + /// + /// Expected order: [type 0, type 20, type 50, type 60] + /// This means 4 missing_hashes in this order. + #[test] + fn test_missing_hashes_ascending_type_order() { + 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(&tlv_bytes, &included).unwrap(); + + // We should have 4 missing hashes for omitted types: + // - type 0 (single leaf) + // - types 20+30 (combined branch, min_type=20) + // - type 50 (single leaf) + // - type 60 (single leaf) + // + // The spec example only shows 3, but that appears to be incomplete + // (missing hash for type 60). Our implementation should produce 4. + 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<(u64, &[u8])> = TlvStream::new(&tlv_bytes) + .filter(|r| included.contains(&r.r#type)) + .map(|r| (r.r#type, r.record_bytes)) + .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(&tlv_bytes, &included).unwrap(); + + let included_records: Vec<(u64, &[u8])> = TlvStream::new(&tlv_bytes) + .filter(|r| included.contains(&r.r#type)) + .map(|r| (r.r#type, r.record_bytes)) + .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..2763df4940b 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; diff --git a/lightning/src/offers/payer_proof.rs b/lightning/src/offers/payer_proof.rs new file mode 100644 index 00000000000..bee1c8217bd --- /dev/null +++ b/lightning/src/offers/payer_proof.rs @@ -0,0 +1,1595 @@ +// 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::offers::invoice::{ + Bolt12Invoice, 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::INVOICE_REQUEST_PAYER_ID_TYPE; +use crate::offers::merkle::{ + self, SelectiveDisclosure, SelectiveDisclosureError, TaggedHash, TlvStream, SIGNATURE_TYPES, +}; +use crate::offers::nonce::Nonce; +use crate::offers::offer::{OFFER_DESCRIPTION_TYPE, OFFER_ISSUER_TYPE}; +use crate::offers::parse::Bech32Encode; +use crate::offers::payer::PAYER_METADATA_TYPE; +use crate::types::payment::{PaymentHash, PaymentPreimage}; +use crate::util::ser::{BigSize, HighZeroBytesDroppedBigSize, Readable, Writeable}; +use lightning_types::string::PrintableString; + +use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::secp256k1::schnorr::Signature; +use bitcoin::secp256k1::{Message, PublicKey, Secp256k1}; + +use core::convert::TryFrom; +use core::time::Duration; + +#[allow(unused_imports)] +use crate::prelude::*; + +const TLV_SIGNATURE: u64 = 240; +const TLV_PREIMAGE: u64 = 242; +const TLV_OMITTED_TLVS: u64 = 244; +const TLV_MISSING_HASHES: u64 = 246; +const TLV_LEAF_HASHES: u64 = 248; +const TLV_PAYER_SIGNATURE: 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 preimage doesn't match the invoice's payment hash. + PreimageMismatch, + /// Error during merkle tree operations. + MerkleError(SelectiveDisclosureError), + /// The invoice signature is invalid. + InvalidInvoiceSignature, + /// The payer signature is invalid. + InvalidPayerSignature, + /// Failed to re-derive the payer signing key from the provided nonce and payment ID. + KeyDerivationFailed, + /// Error during signing. + SigningError, + /// The invreq_metadata field cannot be included (per spec). + InvreqMetadataNotAllowed, + /// TLV types >= 240 cannot be included — they are in the + /// signature/payer-proof range and handled separately. + SignatureTypeNotAllowed, + + /// Error decoding the payer proof. + DecodeError(crate::ln::msgs::DecodeError), +} + +impl From for PayerProofError { + fn from(e: SelectiveDisclosureError) -> Self { + PayerProofError::MerkleError(e) + } +} + +impl From for PayerProofError { + fn from(e: crate::ln::msgs::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_id: PublicKey, + payment_hash: PaymentHash, + issuer_signing_pubkey: PublicKey, + preimage: PaymentPreimage, + invoice_signature: Signature, + payer_signature: Signature, + payer_note: Option, + 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_id, payment_hash, +/// issuer_signing_pubkey). Additional fields can be included for selective disclosure +/// using the `include_*` methods. +pub struct PayerProofBuilder<'a> { + invoice: &'a Bolt12Invoice, + preimage: PaymentPreimage, + included_types: BTreeSet, + invoice_bytes: Vec, +} + +impl<'a> PayerProofBuilder<'a> { + /// Create a new builder from a paid invoice and its 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 = sha256::Hash::hash(&preimage.0); + if computed_hash.as_byte_array() != &invoice.payment_hash().0 { + return Err(PayerProofError::PreimageMismatch); + } + + let mut invoice_bytes = Vec::new(); + invoice.write(&mut invoice_bytes).expect("Vec write should not fail"); + + let mut included_types = BTreeSet::new(); + included_types.insert(INVOICE_REQUEST_PAYER_ID_TYPE); + included_types.insert(INVOICE_PAYMENT_HASH_TYPE); + included_types.insert(INVOICE_NODE_ID_TYPE); + + // Per spec, invoice_features MUST be included "if present" — meaning if the + // TLV exists in the invoice byte stream, regardless of whether the parsed + // value is empty. Check the raw bytes so we handle invoices from other + // implementations that may serialize empty features. + let has_features_tlv = + TlvStream::new(&invoice_bytes).any(|r| r.r#type == INVOICE_FEATURES_TYPE); + if has_features_tlv { + included_types.insert(INVOICE_FEATURES_TYPE); + } + + Ok(Self { invoice, preimage, included_types, invoice_bytes }) + } + + /// Include a specific TLV type in the proof. + /// + /// Returns an error if the type is not allowed (e.g., invreq_metadata or + /// types in the signature/payer-proof range (240..=1000), which are handled + /// separately. + pub fn include_type(mut self, tlv_type: u64) -> Result { + if tlv_type == PAYER_METADATA_TYPE { + return Err(PayerProofError::InvreqMetadataNotAllowed); + } + if SIGNATURE_TYPES.contains(&tlv_type) { + return Err(PayerProofError::SignatureTypeNotAllowed); + } + 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 + } + + /// Builds a signed [`PayerProof`] using the provided signing function. + /// + /// Use this when you have direct access to the payer's signing key. + pub fn build(self, sign_fn: F, note: Option<&str>) -> Result + where + F: FnOnce(&Message) -> Result, + { + let unsigned = self.build_unsigned()?; + unsigned.sign(sign_fn, note) + } + + /// Builds a signed [`PayerProof`] using a key derived from an [`ExpandedKey`] and [`Nonce`]. + /// + /// This re-derives the payer signing key using the same derivation scheme as invoice requests + /// created with `deriving_signing_pubkey`. The `nonce` and `payment_id` must be the same ones + /// used when creating the original invoice request (available from the + /// [`OffersContext::OutboundPaymentForOffer`]). + /// + /// [`OffersContext::OutboundPaymentForOffer`]: crate::blinded_path::message::OffersContext::OutboundPaymentForOffer + pub fn build_with_derived_key( + self, expanded_key: &ExpandedKey, nonce: Nonce, payment_id: PaymentId, note: Option<&str>, + ) -> Result { + let secp_ctx = Secp256k1::signing_only(); + let keys = self + .invoice + .derive_payer_signing_keys(payment_id, nonce, expanded_key, &secp_ctx) + .map_err(|_| PayerProofError::KeyDerivationFailed)?; + + let unsigned = self.build_unsigned()?; + unsigned.sign(|message| Ok(secp_ctx.sign_schnorr_no_aux_rand(message, &keys)), note) + } + + fn build_unsigned(self) -> Result { + let invoice_bytes = self.invoice_bytes; + let mut bytes_without_sig = Vec::with_capacity(invoice_bytes.len()); + for r in TlvStream::new(&invoice_bytes).filter(|r| !SIGNATURE_TYPES.contains(&r.r#type)) { + bytes_without_sig.extend_from_slice(r.record_bytes); + } + let disclosed_fields = + extract_disclosed_fields(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(&bytes_without_sig, &self.included_types)?; + + let invoice_signature = self.invoice.signature(); + + Ok(UnsignedPayerProof { + invoice_signature, + preimage: self.preimage, + payer_id: 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, + }) + } +} + +/// An unsigned [`PayerProof`] ready for signing. +struct UnsignedPayerProof { + invoice_signature: Signature, + preimage: PaymentPreimage, + payer_id: PublicKey, + payment_hash: PaymentHash, + issuer_signing_pubkey: PublicKey, + invoice_bytes: Vec, + included_types: BTreeSet, + disclosed_fields: DisclosedFields, + disclosure: SelectiveDisclosure, +} + +impl UnsignedPayerProof { + fn sign(self, sign_fn: F, note: Option<&str>) -> Result + where + F: FnOnce(&Message) -> Result, + { + let message = Self::compute_payer_signature_message(note, &self.disclosure.merkle_root); + let payer_signature = sign_fn(&message).map_err(|_| PayerProofError::SigningError)?; + + let secp_ctx = Secp256k1::verification_only(); + secp_ctx + .verify_schnorr(&payer_signature, &message, &self.payer_id.into()) + .map_err(|_| PayerProofError::InvalidPayerSignature)?; + + let bytes = self.serialize_payer_proof(&payer_signature, note); + + Ok(PayerProof { + bytes, + contents: PayerProofContents { + payer_id: self.payer_id, + payment_hash: self.payment_hash, + issuer_signing_pubkey: self.issuer_signing_pubkey, + preimage: self.preimage, + invoice_signature: self.invoice_signature, + payer_signature, + payer_note: note.map(String::from), + disclosed_fields: self.disclosed_fields, + }, + merkle_root: self.disclosure.merkle_root, + }) + } + + /// Compute the payer signature message per BOLT 12 signature calculation. + fn compute_payer_signature_message(note: Option<&str>, merkle_root: &sha256::Hash) -> Message { + let mut inner_hasher = sha256::Hash::engine(); + if let Some(n) = note { + inner_hasher.input(n.as_bytes()); + } + inner_hasher.input(merkle_root.as_ref()); + let inner_msg = sha256::Hash::from_engine(inner_hasher); + + let tag_hash = sha256::Hash::hash(PAYER_SIGNATURE_TAG.as_bytes()); + + let mut final_hasher = sha256::Hash::engine(); + final_hasher.input(tag_hash.as_ref()); + final_hasher.input(tag_hash.as_ref()); + final_hasher.input(inner_msg.as_ref()); + let final_digest = sha256::Hash::from_engine(final_hasher); + + Message::from_digest(*final_digest.as_byte_array()) + } + + fn serialize_payer_proof(&self, payer_signature: &Signature, note: Option<&str>) -> Vec { + let mut bytes = Vec::new(); + + // 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) + .filter(|r| self.included_types.contains(&r.r#type) && r.r#type < TLV_SIGNATURE) + { + bytes.extend_from_slice(record.record_bytes); + } + + BigSize(TLV_SIGNATURE).write(&mut bytes).expect("Vec write should not fail"); + BigSize(64).write(&mut bytes).expect("Vec write should not fail"); + self.invoice_signature.write(&mut bytes).expect("Vec write should not fail"); + + BigSize(TLV_PREIMAGE).write(&mut bytes).expect("Vec write should not fail"); + BigSize(32).write(&mut bytes).expect("Vec write should not fail"); + bytes.extend_from_slice(&self.preimage.0); + + if !self.disclosure.omitted_markers.is_empty() { + let omitted_len: u64 = self + .disclosure + .omitted_markers + .iter() + .map(|m| BigSize(*m).serialized_length() as u64) + .sum(); + BigSize(TLV_OMITTED_TLVS).write(&mut bytes).expect("Vec write should not fail"); + BigSize(omitted_len).write(&mut bytes).expect("Vec write should not fail"); + for marker in &self.disclosure.omitted_markers { + BigSize(*marker).write(&mut bytes).expect("Vec write should not fail"); + } + } + + if !self.disclosure.missing_hashes.is_empty() { + let len = self.disclosure.missing_hashes.len() * 32; + BigSize(TLV_MISSING_HASHES).write(&mut bytes).expect("Vec write should not fail"); + BigSize(len as u64).write(&mut bytes).expect("Vec write should not fail"); + for hash in &self.disclosure.missing_hashes { + bytes.extend_from_slice(hash.as_ref()); + } + } + + if !self.disclosure.leaf_hashes.is_empty() { + let len = self.disclosure.leaf_hashes.len() * 32; + BigSize(TLV_LEAF_HASHES).write(&mut bytes).expect("Vec write should not fail"); + BigSize(len as u64).write(&mut bytes).expect("Vec write should not fail"); + for hash in &self.disclosure.leaf_hashes { + bytes.extend_from_slice(hash.as_ref()); + } + } + + let note_bytes = note.map(|n| n.as_bytes()).unwrap_or(&[]); + let payer_sig_len = 64 + note_bytes.len(); + BigSize(TLV_PAYER_SIGNATURE).write(&mut bytes).expect("Vec write should not fail"); + BigSize(payer_sig_len as u64).write(&mut bytes).expect("Vec write should not fail"); + payer_signature.write(&mut bytes).expect("Vec write should not fail"); + bytes.extend_from_slice(note_bytes); + + for record in TlvStream::new(&self.invoice_bytes).filter(|r| { + self.included_types.contains(&r.r#type) + && !SIGNATURE_TYPES.contains(&r.r#type) + && r.r#type > *SIGNATURE_TYPES.end() + }) { + bytes.extend_from_slice(record.record_bytes); + } + + bytes + } +} + +impl PayerProof { + /// The payment preimage proving the invoice was paid. + pub fn preimage(&self) -> PaymentPreimage { + self.contents.preimage + } + + /// The payer's public key (who paid). + pub fn payer_id(&self) -> PublicKey { + self.contents.payer_id + } + + /// 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 + } + + /// 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 + } + + /// The payer's note, if any. + pub fn payer_note(&self) -> Option> { + self.contents.payer_note.as_deref().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 + } +} + +/// Validate that the byte slice is a well-formed TLV stream. +/// +/// `TlvStream::new()` assumes well-formed input and panics on malformed BigSize +/// values or out-of-bounds lengths. This function validates the framing first, +/// returning an error instead of panicking on untrusted input. +fn validate_tlv_framing(bytes: &[u8]) -> Result<(), crate::ln::msgs::DecodeError> { + use crate::ln::msgs::DecodeError; + let mut cursor = io::Cursor::new(bytes); + while (cursor.position() as usize) < bytes.len() { + let _type: BigSize = Readable::read(&mut cursor).map_err(|_| DecodeError::InvalidValue)?; + let length: BigSize = Readable::read(&mut cursor).map_err(|_| DecodeError::InvalidValue)?; + let end = cursor.position().checked_add(length.0).ok_or(DecodeError::InvalidValue)?; + let end_usize = usize::try_from(end).map_err(|_| DecodeError::InvalidValue)?; + if end_usize > bytes.len() { + return Err(DecodeError::ShortRead); + } + cursor.set_position(end); + } + Ok(()) +} + +fn update_disclosed_fields( + record: &crate::offers::merkle::TlvRecord<'_>, disclosed_fields: &mut DisclosedFields, +) -> Result<(), crate::ln::msgs::DecodeError> { + use crate::ln::msgs::DecodeError; + + match record.r#type { + OFFER_DESCRIPTION_TYPE => { + disclosed_fields.offer_description = Some( + String::from_utf8(record.value_bytes.to_vec()) + .map_err(|_| DecodeError::InvalidValue)?, + ); + }, + OFFER_ISSUER_TYPE => { + disclosed_fields.offer_issuer = Some( + String::from_utf8(record.value_bytes.to_vec()) + .map_err(|_| DecodeError::InvalidValue)?, + ); + }, + INVOICE_CREATED_AT_TYPE => { + disclosed_fields.invoice_created_at = Some(Duration::from_secs( + record.read_value::>()?.0, + )); + }, + INVOICE_AMOUNT_TYPE => { + disclosed_fields.invoice_amount_msats = + Some(record.read_value::>()?.0); + }, + _ => {}, + } + + Ok(()) +} + +fn extract_disclosed_fields<'a>( + records: impl core::iter::Iterator>, +) -> Result { + let mut disclosed_fields = DisclosedFields::default(); + for record in records { + update_disclosed_fields(&record, &mut disclosed_fields)?; + } + Ok(disclosed_fields) +} + +// Payer proofs use manual TLV parsing rather than `ParsedMessage` / `tlv_stream!` +// because of their hybrid structure: a dynamic, variable set of included invoice +// TLV records (types 0-239, preserved as raw bytes for merkle reconstruction) plus +// payer-proof-specific TLVs (types 240-250) with non-standard encodings such as +// BigSize lists (`omitted_tlvs`) and concatenated 32-byte hashes +// (`missing_hashes`, `leaf_hashes`). The `tlv_stream!` macro assumes a fixed set +// of known fields with standard `Readable`/`Writeable` encodings, so it cannot +// express the passthrough-or-parse logic required here. +impl TryFrom> for PayerProof { + type Error = crate::offers::parse::Bolt12ParseError; + + fn try_from(bytes: Vec) -> Result { + use crate::ln::msgs::DecodeError; + use crate::offers::parse::Bolt12ParseError; + + // Validate TLV framing before passing to TlvStream, which assumes + // well-formed input and panics on malformed BigSize or out-of-bounds + // lengths. This mirrors the validation that ParsedMessage / CursorReadable + // provides for other BOLT 12 types. + validate_tlv_framing(&bytes) + .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + + let mut payer_id: Option = None; + let mut payment_hash: Option = None; + let mut issuer_signing_pubkey: Option = None; + let mut invoice_signature: Option = None; + let mut preimage: Option = None; + let mut payer_signature: Option = None; + let mut payer_note: Option = None; + let mut disclosed_fields = DisclosedFields::default(); + + let mut leaf_hashes: Vec = Vec::new(); + let mut omitted_markers: Vec = Vec::new(); + let mut missing_hashes: Vec = Vec::new(); + + let mut included_types: BTreeSet = BTreeSet::new(); + let mut included_records: Vec<(u64, usize, usize)> = Vec::new(); + + let mut prev_tlv_type: Option = None; + + for record in TlvStream::new(&bytes) { + let tlv_type = record.r#type; + + // Strict ascending order check covers both ordering and duplicates. + if let Some(prev) = prev_tlv_type { + if tlv_type <= prev { + return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); + } + } + prev_tlv_type = Some(tlv_type); + update_disclosed_fields(&record, &mut disclosed_fields)?; + + match tlv_type { + INVOICE_REQUEST_PAYER_ID_TYPE => { + payer_id = Some(record.read_value()?); + included_types.insert(tlv_type); + included_records.push(( + tlv_type, + record.end - record.record_bytes.len(), + record.end, + )); + }, + INVOICE_PAYMENT_HASH_TYPE => { + payment_hash = Some(record.read_value()?); + included_types.insert(tlv_type); + included_records.push(( + tlv_type, + record.end - record.record_bytes.len(), + record.end, + )); + }, + INVOICE_NODE_ID_TYPE => { + issuer_signing_pubkey = Some(record.read_value()?); + included_types.insert(tlv_type); + included_records.push(( + tlv_type, + record.end - record.record_bytes.len(), + record.end, + )); + }, + TLV_SIGNATURE => { + invoice_signature = Some(record.read_value()?); + }, + TLV_PREIMAGE => { + preimage = Some(record.read_value()?); + }, + TLV_OMITTED_TLVS => { + let mut cursor = io::Cursor::new(record.value_bytes); + while (cursor.position() as usize) < record.value_bytes.len() { + let marker: BigSize = Readable::read(&mut cursor)?; + omitted_markers.push(marker.0); + } + }, + TLV_MISSING_HASHES => { + if record.value_bytes.len() % 32 != 0 { + return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); + } + for chunk in record.value_bytes.chunks_exact(32) { + let hash_bytes: [u8; 32] = chunk.try_into().expect("chunks_exact(32)"); + missing_hashes.push(sha256::Hash::from_byte_array(hash_bytes)); + } + }, + TLV_LEAF_HASHES => { + if record.value_bytes.len() % 32 != 0 { + return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); + } + for chunk in record.value_bytes.chunks_exact(32) { + let hash_bytes: [u8; 32] = chunk.try_into().expect("chunks_exact(32)"); + leaf_hashes.push(sha256::Hash::from_byte_array(hash_bytes)); + } + }, + TLV_PAYER_SIGNATURE => { + if record.value_bytes.len() < 64 { + return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); + } + let mut cursor = io::Cursor::new(record.value_bytes); + payer_signature = Some(Readable::read(&mut cursor)?); + if record.value_bytes.len() > 64 { + let note_bytes = &record.value_bytes[64..]; + payer_note = Some( + String::from_utf8(note_bytes.to_vec()) + .map_err(|_| DecodeError::InvalidValue)?, + ); + } + }, + _ => { + if tlv_type == PAYER_METADATA_TYPE { + return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); + } + if !SIGNATURE_TYPES.contains(&tlv_type) { + // Included invoice TLV record (passthrough for merkle + // reconstruction). + included_types.insert(tlv_type); + included_records.push(( + tlv_type, + record.end - record.record_bytes.len(), + record.end, + )); + } else if tlv_type % 2 == 0 { + // Unknown even types are mandatory-to-understand per + // BOLT convention — reject them. + return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); + } + // Unknown odd types can be safely ignored. + }, + } + } + + let payer_id = payer_id.ok_or(Bolt12ParseError::InvalidSemantics( + crate::offers::parse::Bolt12SemanticError::MissingPayerSigningPubkey, + ))?; + let payment_hash = payment_hash.ok_or(Bolt12ParseError::InvalidSemantics( + crate::offers::parse::Bolt12SemanticError::MissingPaymentHash, + ))?; + let issuer_signing_pubkey = + issuer_signing_pubkey.ok_or(Bolt12ParseError::InvalidSemantics( + crate::offers::parse::Bolt12SemanticError::MissingSigningPubkey, + ))?; + let invoice_signature = invoice_signature.ok_or(Bolt12ParseError::InvalidSemantics( + crate::offers::parse::Bolt12SemanticError::MissingSignature, + ))?; + let preimage = preimage.ok_or(Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + let payer_signature = payer_signature.ok_or(Bolt12ParseError::InvalidSemantics( + crate::offers::parse::Bolt12SemanticError::MissingSignature, + ))?; + + 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 included_refs: Vec<(u64, &[u8])> = + included_records.iter().map(|&(t, start, end)| (t, &bytes[start..end])).collect(); + let merkle_root = merkle::reconstruct_merkle_root( + &included_refs, + &leaf_hashes, + &omitted_markers, + &missing_hashes, + ) + .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + + // Verify preimage matches payment hash. + let computed = sha256::Hash::hash(&preimage.0); + if computed.as_byte_array() != &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(&invoice_signature, &tagged_hash, issuer_signing_pubkey) + .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + + // Verify the payer signature. + let message = UnsignedPayerProof::compute_payer_signature_message( + payer_note.as_deref(), + &merkle_root, + ); + let secp_ctx = Secp256k1::verification_only(); + secp_ctx + .verify_schnorr(&payer_signature, &message, &payer_id.into()) + .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; + + Ok(PayerProof { + bytes, + contents: PayerProofContents { + payer_id, + payment_hash, + issuer_signing_pubkey, + preimage, + invoice_signature, + payer_signature, + payer_note, + disclosed_fields, + }, + 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<(), crate::ln::msgs::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(crate::ln::msgs::DecodeError::InvalidValue); + } + + // MUST NOT contain signature TLV types + if SIGNATURE_TYPES.contains(&marker) { + return Err(crate::ln::msgs::DecodeError::InvalidValue); + } + + // MUST be strictly ascending + if marker <= prev { + return Err(crate::ln::msgs::DecodeError::InvalidValue); + } + + // MUST NOT contain included TLV types + if included_types.contains(&marker) { + return Err(crate::ln::msgs::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(crate::ln::msgs::DecodeError::InvalidValue); + } + } + if !found { + return Err(crate::ln::msgs::DecodeError::InvalidValue); + } + } + + expected_next = marker + 1; + prev = marker; + } + + Ok(()) +} + +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_id = 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_id); + 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 = extract_disclosed_fields( + TlvStream::new(&invoice_bytes).filter(|r| included_types.contains(&r.r#type)), + ) + .unwrap(); + let disclosure = compute_selective_disclosure(&invoice_bytes, &included_types).unwrap(); + + let unsigned = UnsignedPayerProof { + invoice_signature, + preimage, + payer_id, + payment_hash, + issuer_signing_pubkey, + invoice_bytes, + included_types, + disclosed_fields, + disclosure, + }; + + unsigned + .sign(|message| Ok(secp_ctx.sign_schnorr_no_aux_rand(message, &payer_keys)), None) + .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_id = 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_id); + 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 = extract_disclosed_fields( + TlvStream::new(&invoice_bytes).filter(|r| included_types.contains(&r.r#type)), + ) + .unwrap(); + let disclosure = compute_selective_disclosure(&invoice_bytes, &included_types).unwrap(); + assert_eq!(disclosure.omitted_markers, vec![177, 178]); + + let unsigned = UnsignedPayerProof { + invoice_signature, + preimage, + payer_id, + payment_hash, + issuer_signing_pubkey, + invoice_bytes, + included_types, + disclosed_fields, + disclosure, + }; + + unsigned + .sign(|message| Ok(secp_ctx.sign_schnorr_no_aux_rand(message, &payer_keys)), None) + .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_id = 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_id); + 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 = extract_disclosed_fields( + TlvStream::new(&invoice_bytes).filter(|r| included_types.contains(&r.r#type)), + ) + .unwrap(); + let disclosure = compute_selective_disclosure(&invoice_bytes, &included_types).unwrap(); + + let unsigned = UnsignedPayerProof { + invoice_signature, + preimage, + payer_id, + payment_hash, + issuer_signing_pubkey, + invoice_bytes, + included_types, + disclosed_fields, + disclosure, + }; + + unsigned + .sign(|message| Ok(secp_ctx.sign_schnorr_no_aux_rand(message, &payer_keys)), None) + .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(&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(&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(&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(&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(crate::ln::msgs::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::InvreqMetadataNotAllowed); + } + if SIGNATURE_TYPES.contains(&tlv_type) { + return Err(PayerProofError::SignatureTypeNotAllowed); + } + Ok(()) + } + + // Signature-range types 240..=1000 must be rejected. + assert!(matches!(check_include_type(240), Err(PayerProofError::SignatureTypeNotAllowed))); + assert!(matches!(check_include_type(250), Err(PayerProofError::SignatureTypeNotAllowed))); + assert!(matches!(check_include_type(1000), Err(PayerProofError::SignatureTypeNotAllowed))); + // Types above 1000 are experimental/non-signature TLVs and should remain includable. + assert!(check_include_type(1001).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::InvreqMetadataNotAllowed))); + } + + #[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 + ); + } + + #[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 >= 240 are rejected during parsing. + /// + /// Per BOLT convention, even types are mandatory-to-understand. The parser + /// must reject unknown even types in the signature range to prevent + /// accepting malformed proofs. + #[test] + fn test_parsing_rejects_unknown_even_signature_range_types() { + use core::convert::TryFrom; + + // Craft a payer proof with an unknown even type 252 (in signature range, + // but not one of the known payer proof TLVs) + let mut bytes = Vec::new(); + // Some included invoice TLV first (type 10) + bytes.extend_from_slice(&[0x0a, 0x02, 0x00, 0x00]); + // Unknown even type 252 (in signature range 240-1000) + bytes.push(0xfc); // type 252 + bytes.push(0x02); // length 2 + bytes.extend_from_slice(&[0x00, 0x00]); + + let result = PayerProof::try_from(bytes); + assert!(result.is_err(), "Unknown even type 252 should be rejected"); + } + + /// 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 proof = invoice + .payer_proof_builder(preimage) + .unwrap() + .build(|message| Ok(secp_ctx.sign_schnorr_no_aux_rand(message, &payer_keys)), None) + .unwrap(); + let parsed = PayerProof::try_from(proof.bytes().to_vec()).unwrap(); + + assert_eq!(parsed.bytes(), proof.bytes()); + assert_eq!(parsed.preimage(), preimage); + assert_eq!(parsed.payment_hash(), payment_hash); + } + + #[test] + fn test_build_with_derived_key_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 proof = invoice + .payer_proof_builder(preimage) + .unwrap() + .build_with_derived_key(&expanded_key, nonce, payment_id, Some("refund")) + .unwrap(); + let parsed = PayerProof::try_from(proof.bytes().to_vec()).unwrap(); + + assert_eq!(parsed.preimage(), preimage); + assert_eq!(parsed.payment_hash(), payment_hash); + assert_eq!(parsed.payer_note().map(|note| note.to_string()), Some("refund".to_string())); + } +} From 5fa4995c6f204b2ea1244c28856c78834d42c1d6 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Thu, 9 Apr 2026 19:53:49 +0200 Subject: [PATCH 3/6] 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 | 19 ++++++++++--------- lightning/src/ln/channelmanager.rs | 9 +++++---- lightning/src/ln/functional_test_utils.rs | 9 +++++---- lightning/src/ln/offers_tests.rs | 5 +++-- lightning/src/ln/outbound_payment.rs | 23 ++++++++++++----------- lightning/src/offers/payer_proof.rs | 17 +++++++++++++++++ 7 files changed, 56 insertions(+), 48 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 011b7f595bc..5de6cfdd565 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 25522346d9c..fd338d0fe86 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}; @@ -25,6 +25,7 @@ use crate::ln::msgs; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, MessageSendEvent, OnionMessageHandler, }; +use crate::offers::payer_proof::Bolt12InvoiceType; use crate::ln::offers_tests; use crate::ln::onion_utils::LocalHTLCFailureReason; use crate::ln::outbound_payment::{Bolt12PaymentError, RecipientOnionFields}; @@ -988,7 +989,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) { @@ -1073,7 +1074,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); @@ -1144,7 +1145,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)] @@ -2384,7 +2385,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)] @@ -2719,7 +2720,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] @@ -2962,7 +2963,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] @@ -3199,7 +3200,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] @@ -3449,5 +3450,5 @@ 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))); } diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index f9772bb120b..bd0c9d042c2 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -53,7 +53,7 @@ use crate::events::{ self, ClosureReason, Event, EventHandler, EventsProvider, HTLCHandlingFailureType, InboundChannelFunds, PaymentFailureReason, ReplayEvent, }; -use crate::events::{FundingInfo, PaidBolt12Invoice}; +use crate::events::FundingInfo; 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::{ @@ -835,7 +836,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, }, } @@ -971,7 +972,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, @@ -17766,7 +17767,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 e8859494071..0c12d9a18e7 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -17,7 +17,7 @@ use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ - ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, PaidBolt12Invoice, + ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, PathFailure, PaymentFailureReason, PaymentPurpose, }; use crate::ln::chan_utils::{ @@ -33,6 +33,7 @@ use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, MessageSendEvent, RoutingMessageHandler, }; use crate::ln::onion_utils::LocalHTLCFailureReason; +use crate::offers::payer_proof::Bolt12InvoiceType; use crate::ln::outbound_payment::RecipientOnionFields; use crate::ln::outbound_payment::Retry; use crate::ln::peer_handler::IgnoringMessageHandler; @@ -2998,7 +2999,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); } @@ -4222,7 +4223,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, @@ -4244,7 +4245,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 d657c4e0ac4..09f3b4f650e 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; @@ -58,6 +58,7 @@ use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnou use crate::ln::outbound_payment::IDEMPOTENCY_TIMEOUT_TICKS; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_error::InvoiceError; +use crate::offers::payer_proof::Bolt12InvoiceType; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; use crate::offers::parse::Bolt12SemanticError; @@ -253,7 +254,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 b08b0f5a886..3312afa5728 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, @@ -927,7 +928,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, } @@ -1115,7 +1116,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, @@ -1129,7 +1130,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, @@ -1388,7 +1389,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, @@ -1970,7 +1971,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) { @@ -1990,7 +1991,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()); @@ -2159,7 +2160,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> @@ -2299,7 +2300,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, diff --git a/lightning/src/offers/payer_proof.rs b/lightning/src/offers/payer_proof.rs index bee1c8217bd..4905757c301 100644 --- a/lightning/src/offers/payer_proof.rs +++ b/lightning/src/offers/payer_proof.rs @@ -34,6 +34,7 @@ use crate::offers::nonce::Nonce; use crate::offers::offer::{OFFER_DESCRIPTION_TYPE, OFFER_ISSUER_TYPE}; use crate::offers::parse::Bech32Encode; use crate::offers::payer::PAYER_METADATA_TYPE; +use crate::offers::static_invoice::StaticInvoice; use crate::types::payment::{PaymentHash, PaymentPreimage}; use crate::util::ser::{BigSize, HighZeroBytesDroppedBigSize, Readable, Writeable}; use lightning_types::string::PrintableString; @@ -62,6 +63,22 @@ pub const PAYER_PROOF_HRP: &str = "lnp"; /// Format: "lightning" || messagename || fieldname const PAYER_SIGNATURE_TAG: &str = concat!("lightning", "payer_proof", "payer_signature"); +/// The type of BOLT 12 invoice that was paid. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Bolt12InvoiceType { + /// 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!(Bolt12InvoiceType, + {0, Bolt12Invoice} => (), + {2, StaticInvoice} => (), +); + /// Error when building or verifying a payer proof. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PayerProofError { From d18637157bab0ec8dcaef6662c43f4553ea4f886 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Thu, 9 Apr 2026 19:55:15 +0200 Subject: [PATCH 4/6] refactor(offers): bundle paid invoice data for payer proofs Encapsulate invoice, preimage, and nonce in PaidBolt12Invoice and surface it in PaymentSent. Rework builder to return UnsignedPayerProof with SignFn/sign_message integration, use encode_tlv_stream! for serialization, move helpers to DisclosedFields methods, and address naming conventions and TLV validation feedback. Co-Authored-By: Jeffrey Czyz Co-Authored-By: Claude Opus 4.6 --- fuzz/src/process_onion_failure.rs | 1 + lightning/src/events/mod.rs | 33 +- lightning/src/ln/async_payments_tests.rs | 17 +- lightning/src/ln/channel.rs | 2 + lightning/src/ln/channelmanager.rs | 61 +- lightning/src/ln/functional_test_utils.rs | 13 +- lightning/src/ln/offers_tests.rs | 99 +-- lightning/src/ln/onion_utils.rs | 4 + lightning/src/ln/outbound_payment.rs | 72 +- lightning/src/offers/invoice.rs | 28 +- lightning/src/offers/merkle.rs | 55 +- lightning/src/offers/offer.rs | 4 +- lightning/src/offers/payer_proof.rs | 848 +++++++++++++--------- lightning/src/util/ser.rs | 16 + 14 files changed, 771 insertions(+), 482 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 5de6cfdd565..4fea2951125 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 fd338d0fe86..4ee14976f38 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -25,7 +25,6 @@ use crate::ln::msgs; use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, MessageSendEvent, OnionMessageHandler, }; -use crate::offers::payer_proof::Bolt12InvoiceType; use crate::ln::offers_tests; use crate::ln::onion_utils::LocalHTLCFailureReason; use crate::ln::outbound_payment::{Bolt12PaymentError, RecipientOnionFields}; @@ -989,7 +988,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) { @@ -1074,7 +1073,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); @@ -1145,7 +1144,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)] @@ -2385,7 +2384,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)] @@ -2720,7 +2719,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] @@ -2963,7 +2962,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] @@ -3200,7 +3199,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] @@ -3450,5 +3449,5 @@ 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)); } diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs index 8b05d984e30..e482fc39328 100644 --- a/lightning/src/ln/channel.rs +++ b/lightning/src/ln/channel.rs @@ -16535,6 +16535,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, @@ -16986,6 +16987,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 bd0c9d042c2..c77bef06624 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::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Watch}; +use crate::events::FundingInfo; use crate::events::{ self, ClosureReason, Event, EventHandler, EventsProvider, HTLCHandlingFailureType, InboundChannelFunds, PaymentFailureReason, ReplayEvent, }; -use crate::events::FundingInfo; use crate::ln::chan_utils::selected_commitment_sat_per_1000_weight; #[cfg(any(test, fuzzing, feature = "_test_utils"))] use crate::ln::channel::QuiescentAction; @@ -833,10 +833,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, }, } @@ -910,6 +920,7 @@ impl core::hash::Hash for HTLCSource { payment_id, first_hop_htlc_msat, bolt12_invoice, + .. } => { 1u8.hash(hasher); path.hash(hasher); @@ -944,6 +955,7 @@ impl HTLCSource { first_hop_htlc_msat: 0, payment_id: PaymentId([2; 32]), bolt12_invoice: None, + payment_nonce: None, } } @@ -972,9 +984,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, } } @@ -5395,6 +5407,7 @@ impl< keysend_preimage, invoice_request: None, bolt12_invoice: None, + payment_nonce: None, session_priv_bytes, hold_htlc_at_next_hop: false, }) @@ -5410,6 +5423,7 @@ impl< keysend_preimage, invoice_request, bolt12_invoice, + payment_nonce, session_priv_bytes, hold_htlc_at_next_hop, } = args; @@ -5486,6 +5500,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, @@ -5773,14 +5788,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); @@ -5788,6 +5810,7 @@ impl< self.pending_outbound_payments.send_payment_for_bolt12_invoice( invoice, payment_id, + payment_nonce, &self.router, self.list_usable_channels(), features, @@ -9953,7 +9976,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"); @@ -9985,6 +10013,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, @@ -17080,7 +17109,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) => { @@ -17768,6 +17802,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), @@ -17776,6 +17811,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 @@ -17799,6 +17835,7 @@ impl Readable for HTLCSource { path, payment_id: payment_id.unwrap(), bolt12_invoice, + payment_nonce, }) } 1 => Ok(HTLCSource::PreviousHopData(Readable::read(reader)?)), @@ -17818,6 +17855,7 @@ impl Writeable for HTLCSource { ref path, payment_id, bolt12_invoice, + payment_nonce, } => { 0u8.write(writer)?; let payment_id_opt = Some(payment_id); @@ -17830,6 +17868,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) => { @@ -19688,6 +19727,7 @@ impl< session_priv, path, bolt12_invoice, + payment_nonce, .. } => { if let Some(preimage) = preimage_opt { @@ -19705,6 +19745,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 0c12d9a18e7..811ba1e6351 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -17,8 +17,8 @@ use crate::chain::{BestBlock, ChannelMonitorUpdateStatus, Confirm, Listen, Watch use crate::events::bump_transaction::sync::BumpTransactionEventHandlerSync; use crate::events::bump_transaction::BumpTransactionEvent; use crate::events::{ - ClaimedHTLC, ClosureReason, Event, FundingInfo, HTLCHandlingFailureType, - 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, @@ -33,11 +33,11 @@ use crate::ln::msgs::{ BaseMessageHandler, ChannelMessageHandler, MessageSendEvent, RoutingMessageHandler, }; use crate::ln::onion_utils::LocalHTLCFailureReason; -use crate::offers::payer_proof::Bolt12InvoiceType; 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::PaidBolt12Invoice; use crate::onion_message::messenger::OnionMessenger; use crate::routing::gossip::{NetworkGraph, NetworkUpdate, P2PGossipSync}; use crate::routing::router::{self, PaymentParameters, Route, RouteParameters}; @@ -2999,7 +2999,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); } @@ -3026,6 +3026,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); @@ -4223,7 +4224,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, @@ -4245,7 +4246,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 09f3b4f650e..51972bed35c 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -58,11 +58,10 @@ use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnou use crate::ln::outbound_payment::IDEMPOTENCY_TIMEOUT_TICKS; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_error::InvoiceError; -use crate::offers::payer_proof::Bolt12InvoiceType; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; use crate::offers::parse::Bolt12SemanticError; -use crate::offers::payer_proof::{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; @@ -253,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 { @@ -271,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 [`PayerProofBuilder::build_with_derived_key`]. +/// 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), @@ -2731,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 @@ -2758,39 +2757,42 @@ 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 --- // 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 proof = invoice.payer_proof_builder(payment_preimage).unwrap() + let secp_ctx = Secp256k1::new(); + 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() - .build_with_derived_key(&expanded_key, payer_nonce, payment_id, None) + .build_and_sign(None) .unwrap(); // Check proof contents match the original payment - assert_eq!(proof.preimage(), payment_preimage); - assert_eq!(proof.payment_hash(), invoice.payment_hash()); - assert_eq!(proof.payer_id(), invoice.payer_signing_pubkey()); - assert_eq!(proof.issuer_signing_pubkey(), invoice.signing_pubkey()); - assert!(proof.payer_note().is_none()); + assert_eq!(payer_proof.preimage(), payment_preimage); + assert_eq!(payer_proof.payment_hash(), invoice.payment_hash()); + assert_eq!(payer_proof.payer_id(), 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.preimage(), proof.preimage()); - assert_eq!(decoded.payment_hash(), proof.payment_hash()); - assert_eq!(decoded.payer_id(), proof.payer_id()); - 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.preimage(), payer_proof.preimage()); + assert_eq!(decoded.payment_hash(), payer_proof.payment_hash()); + assert_eq!(decoded.payer_id(), payer_proof.payer_id()); + 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 @@ -2858,54 +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]); - assert!(invoice.payer_proof_builder(wrong_preimage).is_err()); + 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 wrong_payment_id = PaymentId([0xFF; 32]); - let result = invoice.payer_proof_builder(payment_preimage).unwrap() - .build_with_derived_key(&expanded_key, payer_nonce, wrong_payment_id, None); + 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 result = invoice.payer_proof_builder(payment_preimage).unwrap() - .build_with_derived_key(&expanded_key, wrong_nonce, payment_id, None); + 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, + ); assert!(matches!(result, Err(PayerProofError::KeyDerivationFailed))); // --- Test 4: Minimal proof (only required fields) --- - let minimal_proof = invoice.payer_proof_builder(payment_preimage).unwrap() - .build_with_derived_key(&expanded_key, payer_nonce, payment_id, None) + 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 = invoice.payer_proof_builder(payment_preimage).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_with_derived_key(&expanded_key, payer_nonce, payment_id, Some("Paid for coffee")) + .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.preimage(), proof_with_note.preimage()); - assert_eq!(minimal_proof.payment_hash(), proof_with_note.payment_hash()); - assert_eq!(minimal_proof.payer_id(), proof_with_note.payer_id()); - assert_eq!(minimal_proof.issuer_signing_pubkey(), proof_with_note.issuer_signing_pubkey()); + assert_eq!(minimal_payer_proof.preimage(), payer_proof_with_note.preimage()); + assert_eq!(minimal_payer_proof.payment_hash(), payer_proof_with_note.payment_hash()); + assert_eq!(minimal_payer_proof.payer_id(), payer_proof_with_note.payer_id()); + 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.preimage(), payment_preimage); } diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 9b1b009e93a..eb3cac89fb9 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -3547,6 +3547,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) @@ -3733,6 +3734,7 @@ mod tests { first_hop_htlc_msat: dummy_amt_msat, payment_id: PaymentId([1; 32]), bolt12_invoice: None, + payment_nonce: None, }; { @@ -3921,6 +3923,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. @@ -4030,6 +4033,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 3312afa5728..67acb6cbb1c 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; @@ -929,6 +942,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, } @@ -1087,7 +1101,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, @@ -1118,7 +1132,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, ) @@ -1130,7 +1144,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, @@ -1189,7 +1203,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; @@ -1200,7 +1215,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); @@ -1213,7 +1229,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 ); @@ -1396,6 +1413,7 @@ impl OutboundPayments { Some(keysend_preimage), Some(&invoice_request), invoice, + None, route_params, retry_strategy, hold_htlcs_at_next_hop, @@ -1605,7 +1623,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); @@ -1672,7 +1690,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) => { @@ -1715,8 +1733,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"); @@ -1756,7 +1775,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 { @@ -1915,7 +1935,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)), @@ -1978,7 +1998,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); @@ -1991,7 +2011,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()); @@ -2012,6 +2033,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(), @@ -2161,6 +2183,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> @@ -2210,7 +2233,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); @@ -2277,7 +2300,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 }) } @@ -2301,6 +2324,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, @@ -2323,7 +2347,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(); } @@ -2719,6 +2745,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), @@ -2823,6 +2850,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())), }, @@ -3317,7 +3345,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 ), @@ -3382,7 +3410,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 ), @@ -3460,7 +3488,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 ), @@ -3480,7 +3508,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 ), @@ -3491,7 +3519,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 ), diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 8d138012edd..f156ef57ddb 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -141,7 +141,6 @@ use crate::offers::offer::{ }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; use crate::offers::payer::{PayerTlvStream, PayerTlvStreamRef, PAYER_METADATA_TYPE}; -use crate::offers::payer_proof::{PayerProofBuilder, PayerProofError}; use crate::offers::refund::{ Refund, RefundContents, IV_BYTES_WITHOUT_METADATA as REFUND_IV_BYTES_WITHOUT_METADATA, IV_BYTES_WITH_METADATA as REFUND_IV_BYTES_WITH_METADATA, @@ -149,7 +148,6 @@ use crate::offers::refund::{ use crate::offers::signer::{self, Metadata}; use crate::types::features::{Bolt12InvoiceFeatures, InvoiceRequestFeatures, OfferFeatures}; use crate::types::payment::PaymentHash; -use crate::types::payment::PaymentPreimage; use crate::types::string::PrintableString; use crate::util::ser::{ CursorReadable, HighZeroBytesDroppedBigSize, Iterable, LengthLimitedRead, LengthReadable, @@ -987,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,17 +1038,6 @@ impl Bolt12Invoice { ) } - /// Creates a [`PayerProofBuilder`] for this invoice using the given payment preimage. - /// - /// Returns an error if the preimage doesn't match the invoice's payment hash. - /// - /// [`PayerProofBuilder`]: crate::offers::payer_proof::PayerProofBuilder - pub fn payer_proof_builder( - &self, preimage: PaymentPreimage, - ) -> Result, PayerProofError> { - PayerProofBuilder::new(self, preimage) - } - /// Re-derives the payer's signing keypair for payer proof creation. /// /// This performs the same key derivation that occurs during invoice request creation @@ -1589,13 +1581,13 @@ 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 886a1e9ad66..dd53efc6072 100644 --- a/lightning/src/offers/merkle.rs +++ b/lightning/src/offers/merkle.rs @@ -353,13 +353,13 @@ struct TlvMerkleData { /// - `missing_hashes`: minimal merkle hashes for omitted subtrees /// /// # Arguments -/// * `tlv_bytes` - Complete TLV stream (e.g., invoice bytes without signature) +/// * `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( - tlv_bytes: &[u8], included_types: &BTreeSet, +pub(super) fn compute_selective_disclosure<'a>( + records: impl Iterator>, included_types: &BTreeSet, ) -> Result { - let mut tlv_stream = TlvStream::new(tlv_bytes).peekable(); - let first_record = tlv_stream.peek().ok_or(SelectiveDisclosureError::EmptyTlvStream)?; + 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()); @@ -373,7 +373,7 @@ pub(super) fn compute_selective_disclosure( let mut tlv_data: Vec = Vec::new(); let mut leaf_hashes: Vec = Vec::new(); - for record in tlv_stream.filter(|r| !SIGNATURE_TYPES.contains(&r.r#type)) { + 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 = @@ -507,8 +507,8 @@ fn build_tree_with_disclosure( /// Uses `n` tree nodes (one per TLV position) rather than `2n`, since per-TLV /// hashes already combine leaf and nonce. Two passes over the tree determine /// where missing hashes are needed and then combine all hashes to the root. -pub(super) fn reconstruct_merkle_root<'a>( - included_records: &[(u64, &'a [u8])], leaf_hashes: &[sha256::Hash], omitted_markers: &[u64], +pub(super) fn reconstruct_merkle_root( + included_records: &[TlvRecord<'_>], leaf_hashes: &[sha256::Hash], omitted_markers: &[u64], missing_hashes: &[sha256::Hash], ) -> Result { // Callers are expected to validate omitted_markers before calling this function @@ -538,8 +538,8 @@ pub(super) fn reconstruct_merkle_root<'a>( while inc_idx < included_records.len() || mrk_idx < omitted_markers.len() { if mrk_idx >= omitted_markers.len() { // No more markers, remaining positions are included - let (_, record_bytes) = included_records[inc_idx]; - let leaf_hash = tagged_hash_from_engine(leaf_tag.clone(), record_bytes); + 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]; let hash = tagged_branch_hash_from_engine(branch_tag.clone(), leaf_hash, nonce_hash); nodes.push(TreeNode { hash: Some(hash), included: true, min_type: node_idx }); @@ -551,7 +551,7 @@ pub(super) fn reconstruct_merkle_root<'a>( mrk_idx += 1; } else { let marker = omitted_markers[mrk_idx]; - let (inc_type, _) = included_records[inc_idx]; + let inc_type = included_records[inc_idx].r#type; if marker == prev_marker + 1 { // Continuation of current run -> omitted position @@ -560,8 +560,8 @@ pub(super) fn reconstruct_merkle_root<'a>( mrk_idx += 1; } else { // Jump detected -> included position comes first - let (_, record_bytes) = included_records[inc_idx]; - let leaf_hash = tagged_hash_from_engine(leaf_tag.clone(), record_bytes); + 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]; let hash = tagged_branch_hash_from_engine(branch_tag.clone(), leaf_hash, nonce_hash); @@ -737,7 +737,7 @@ fn reconstruct_positions(included_types: &[u64], omitted_markers: &[u64]) -> Vec #[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; @@ -1031,7 +1031,8 @@ mod tests { included.insert(40); // Compute selective disclosure - let disclosure = super::compute_selective_disclosure(&tlv_bytes, &included).unwrap(); + 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]); @@ -1040,10 +1041,8 @@ mod tests { assert_eq!(disclosure.leaf_hashes.len(), 2); // Collect included records for reconstruction - let included_records: Vec<(u64, &[u8])> = TlvStream::new(&tlv_bytes) - .filter(|r| included.contains(&r.r#type)) - .map(|r| (r.r#type, r.record_bytes)) - .collect(); + 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( @@ -1090,7 +1089,8 @@ mod tests { included.insert(10); included.insert(40); - let disclosure = super::compute_selective_disclosure(&tlv_bytes, &included).unwrap(); + let disclosure = + super::compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included).unwrap(); // We should have 4 missing hashes for omitted types: // - type 0 (single leaf) @@ -1107,10 +1107,8 @@ mod tests { ); // Verify the round-trip still works with the correct ordering - let included_records: Vec<(u64, &[u8])> = TlvStream::new(&tlv_bytes) - .filter(|r| included.contains(&r.r#type)) - .map(|r| (r.r#type, r.record_bytes)) - .collect(); + let included_records: Vec> = + TlvStream::new(&tlv_bytes).filter(|r| included.contains(&r.r#type)).collect(); let reconstructed = super::reconstruct_merkle_root( &included_records, @@ -1136,12 +1134,11 @@ mod tests { let mut included = BTreeSet::new(); included.insert(10); - let disclosure = super::compute_selective_disclosure(&tlv_bytes, &included).unwrap(); + let disclosure = + super::compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included).unwrap(); - let included_records: Vec<(u64, &[u8])> = TlvStream::new(&tlv_bytes) - .filter(|r| included.contains(&r.r#type)) - .map(|r| (r.r#type, r.record_bytes)) - .collect(); + 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( diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 2763df4940b..7cc5754bd61 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -1225,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 index 4905757c301..08bbcdf8c30 100644 --- a/lightning/src/offers/payer_proof.rs +++ b/lightning/src/offers/payer_proof.rs @@ -22,26 +22,33 @@ 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, 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::INVOICE_REQUEST_PAYER_ID_TYPE; use crate::offers::merkle::{ - self, SelectiveDisclosure, SelectiveDisclosureError, TaggedHash, TlvStream, SIGNATURE_TYPES, + self, SelectiveDisclosure, SelectiveDisclosureError, SignError, TaggedHash, TlvRecord, + TlvStream, SIGNATURE_TYPES, }; use crate::offers::nonce::Nonce; -use crate::offers::offer::{OFFER_DESCRIPTION_TYPE, OFFER_ISSUER_TYPE}; -use crate::offers::parse::Bech32Encode; +use crate::offers::offer::{EXPERIMENTAL_OFFER_TYPES, OFFER_DESCRIPTION_TYPE, OFFER_ISSUER_TYPE}; +use crate::offers::parse::{Bech32Encode, Bolt12ParseError, Bolt12SemanticError}; use crate::offers::payer::PAYER_METADATA_TYPE; use crate::offers::static_invoice::StaticInvoice; use crate::types::payment::{PaymentHash, PaymentPreimage}; -use crate::util::ser::{BigSize, HighZeroBytesDroppedBigSize, Readable, Writeable}; +use crate::util::ser::{ + BigSize, HighZeroBytesDroppedBigSize, IterableOwned, LengthReadable, Readable, WithoutLength, + Writeable, Writer, +}; use lightning_types::string::PrintableString; use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::secp256k1; +use bitcoin::secp256k1::constants::SCHNORR_SIGNATURE_SIZE; use bitcoin::secp256k1::schnorr::Signature; -use bitcoin::secp256k1::{Message, PublicKey, Secp256k1}; +use bitcoin::secp256k1::{Keypair, PublicKey, Secp256k1}; use core::convert::TryFrom; use core::time::Duration; @@ -49,28 +56,12 @@ use core::time::Duration; #[allow(unused_imports)] use crate::prelude::*; -const TLV_SIGNATURE: u64 = 240; -const TLV_PREIMAGE: u64 = 242; -const TLV_OMITTED_TLVS: u64 = 244; -const TLV_MISSING_HASHES: u64 = 246; -const TLV_LEAF_HASHES: u64 = 248; -const TLV_PAYER_SIGNATURE: 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"); - /// The type of BOLT 12 invoice that was paid. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum Bolt12InvoiceType { - /// The BOLT 12 invoice specified by the BOLT 12 specification, - /// allowing the user to perform proof of payment. + /// A standard BOLT 12 invoice, allowing proof of payment. Bolt12Invoice(Bolt12Invoice), - /// The Static invoice, used in the async payment specification update proposal, - /// where the user cannot perform proof of payment. + /// A static invoice used in async payments, where proof of payment is not possible. StaticInvoice(StaticInvoice), } @@ -79,9 +70,109 @@ impl_writeable_tlv_based_enum!(Bolt12InvoiceType, {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 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> { + let nonce = self.nonce.ok_or(PayerProofError::KeyDerivationFailed)?; + let invoice = self.bolt12_invoice().ok_or(PayerProofError::IncompatibleInvoice)?; + 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. @@ -101,7 +192,7 @@ pub enum PayerProofError { SignatureTypeNotAllowed, /// Error decoding the payer proof. - DecodeError(crate::ln::msgs::DecodeError), + DecodeError(DecodeError), } impl From for PayerProofError { @@ -110,8 +201,8 @@ impl From for PayerProofError { } } -impl From for PayerProofError { - fn from(e: crate::ln::msgs::DecodeError) -> Self { +impl From for PayerProofError { + fn from(e: DecodeError) -> Self { PayerProofError::DecodeError(e) } } @@ -147,51 +238,115 @@ struct DisclosedFields { invoice_created_at: Option, } +/// The signing key was explicitly provided. +pub struct ExplicitSigningKey {} + +/// The signing key was derived from an [`ExpandedKey`] and [`Nonce`]. +pub struct DerivedSigningKey(Keypair); + /// Builds a [`PayerProof`] from a paid invoice and its preimage. /// /// By default, only the required fields are included (payer_id, payment_hash, /// issuer_signing_pubkey). Additional fields can be included for selective disclosure /// using the `include_*` methods. -pub struct PayerProofBuilder<'a> { +pub struct PayerProofBuilder<'a, S: SigningStrategy> { invoice: &'a Bolt12Invoice, preimage: PaymentPreimage, included_types: BTreeSet, - invoice_bytes: Vec, + signing_strategy: S, } -impl<'a> PayerProofBuilder<'a> { - /// Create a new builder from a paid invoice and its preimage. +/// Sealed trait for signing strategy type-state. +pub trait SigningStrategy: sealed_signing::Sealed {} +impl SigningStrategy for ExplicitSigningKey {} +impl SigningStrategy for DerivedSigningKey {} + +mod sealed_signing { + pub trait Sealed {} + impl Sealed for super::ExplicitSigningKey {} + impl Sealed for super::DerivedSigningKey {} +} + +impl<'a> PayerProofBuilder<'a, ExplicitSigningKey> { + /// 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, + fn new(invoice: &'a Bolt12Invoice, preimage: PaymentPreimage) -> Result { + let computed_hash = sha256::Hash::hash(&preimage.0); + if computed_hash.as_byte_array() != &invoice.payment_hash().0 { + return Err(PayerProofError::PreimageMismatch); + } + + let invoice_bytes = invoice.invoice_bytes(); + + let mut included_types = BTreeSet::new(); + included_types.insert(INVOICE_REQUEST_PAYER_ID_TYPE); + included_types.insert(INVOICE_PAYMENT_HASH_TYPE); + included_types.insert(INVOICE_NODE_ID_TYPE); + + let has_features_tlv = + TlvStream::new(invoice_bytes).any(|r| r.r#type == INVOICE_FEATURES_TYPE); + if has_features_tlv { + included_types.insert(INVOICE_FEATURES_TYPE); + } + + Ok(Self { invoice, preimage, included_types, signing_strategy: ExplicitSigningKey {} }) + } + + /// Builds an [`UnsignedPayerProof`] that can be signed with [`UnsignedPayerProof::sign`]. + pub fn build( + self, payer_note: Option, + ) -> Result, PayerProofError> { + self.build_unsigned(payer_note) + } +} + +impl<'a> PayerProofBuilder<'a, DerivedSigningKey> { + /// 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 mut invoice_bytes = Vec::new(); - invoice.write(&mut invoice_bytes).expect("Vec write should not fail"); + let keys = invoice + .derive_payer_signing_keys(payment_id, nonce, expanded_key, secp_ctx) + .map_err(|_| PayerProofError::KeyDerivationFailed)?; + + let invoice_bytes = invoice.invoice_bytes(); let mut included_types = BTreeSet::new(); included_types.insert(INVOICE_REQUEST_PAYER_ID_TYPE); included_types.insert(INVOICE_PAYMENT_HASH_TYPE); included_types.insert(INVOICE_NODE_ID_TYPE); - // Per spec, invoice_features MUST be included "if present" — meaning if the - // TLV exists in the invoice byte stream, regardless of whether the parsed - // value is empty. Check the raw bytes so we handle invoices from other - // implementations that may serialize empty features. let has_features_tlv = - TlvStream::new(&invoice_bytes).any(|r| r.r#type == INVOICE_FEATURES_TYPE); + TlvStream::new(invoice_bytes).any(|r| r.r#type == INVOICE_FEATURES_TYPE); if has_features_tlv { included_types.insert(INVOICE_FEATURES_TYPE); } - Ok(Self { invoice, preimage, included_types, invoice_bytes }) + Ok(Self { invoice, preimage, included_types, signing_strategy: DerivedSigningKey(keys) }) } + /// Builds and signs a [`PayerProof`] using the keypair derived at construction time. + 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)?; + unsigned.sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &keys)) + }) + } +} + +impl<'a, S: SigningStrategy> PayerProofBuilder<'a, S> { /// Include a specific TLV type in the proof. /// /// Returns an error if the type is not allowed (e.g., invreq_metadata or @@ -232,54 +387,24 @@ impl<'a> PayerProofBuilder<'a> { self } - /// Builds a signed [`PayerProof`] using the provided signing function. - /// - /// Use this when you have direct access to the payer's signing key. - pub fn build(self, sign_fn: F, note: Option<&str>) -> Result - where - F: FnOnce(&Message) -> Result, - { - let unsigned = self.build_unsigned()?; - unsigned.sign(sign_fn, note) - } - - /// Builds a signed [`PayerProof`] using a key derived from an [`ExpandedKey`] and [`Nonce`]. - /// - /// This re-derives the payer signing key using the same derivation scheme as invoice requests - /// created with `deriving_signing_pubkey`. The `nonce` and `payment_id` must be the same ones - /// used when creating the original invoice request (available from the - /// [`OffersContext::OutboundPaymentForOffer`]). - /// - /// [`OffersContext::OutboundPaymentForOffer`]: crate::blinded_path::message::OffersContext::OutboundPaymentForOffer - pub fn build_with_derived_key( - self, expanded_key: &ExpandedKey, nonce: Nonce, payment_id: PaymentId, note: Option<&str>, - ) -> Result { - let secp_ctx = Secp256k1::signing_only(); - let keys = self - .invoice - .derive_payer_signing_keys(payment_id, nonce, expanded_key, &secp_ctx) - .map_err(|_| PayerProofError::KeyDerivationFailed)?; - - let unsigned = self.build_unsigned()?; - unsigned.sign(|message| Ok(secp_ctx.sign_schnorr_no_aux_rand(message, &keys)), note) - } - - fn build_unsigned(self) -> Result { - let invoice_bytes = self.invoice_bytes; - let mut bytes_without_sig = Vec::with_capacity(invoice_bytes.len()); - for r in TlvStream::new(&invoice_bytes).filter(|r| !SIGNATURE_TYPES.contains(&r.r#type)) { - bytes_without_sig.extend_from_slice(r.record_bytes); - } + fn build_unsigned( + self, payer_note: Option, + ) -> Result, PayerProofError> { + let invoice_bytes = self.invoice.invoice_bytes(); let disclosed_fields = - extract_disclosed_fields(TlvStream::new(&invoice_bytes).filter(|r| { + 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(&bytes_without_sig, &self.included_types)?; + 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, @@ -290,37 +415,97 @@ impl<'a> PayerProofBuilder<'a> { 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. -struct UnsignedPayerProof { +pub struct UnsignedPayerProof<'a> { invoice_signature: Signature, preimage: PaymentPreimage, payer_id: PublicKey, payment_hash: PaymentHash, issuer_signing_pubkey: PublicKey, - invoice_bytes: Vec, + invoice_bytes: &'a [u8], included_types: BTreeSet, disclosed_fields: DisclosedFields, disclosure: SelectiveDisclosure, + payer_note: Option, + tagged_hash: TaggedHash, } -impl UnsignedPayerProof { - fn sign(self, sign_fn: F, note: Option<&str>) -> Result - where - F: FnOnce(&Message) -> Result, - { - let message = Self::compute_payer_signature_message(note, &self.disclosure.merkle_root); - let payer_signature = sign_fn(&message).map_err(|_| PayerProofError::SigningError)?; +impl AsRef for UnsignedPayerProof<'_> { + fn as_ref(&self) -> &TaggedHash { + &self.tagged_hash + } +} - let secp_ctx = Secp256k1::verification_only(); - secp_ctx - .verify_schnorr(&payer_signature, &message, &self.payer_id.into()) - .map_err(|_| PayerProofError::InvalidPayerSignature)?; +/// 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; +} - let bytes = self.serialize_payer_proof(&payer_signature, note); +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. +struct PayerSignatureWithNote<'a> { + signature: &'a Signature, + note_bytes: &'a [u8], +} + +impl Writeable for PayerSignatureWithNote<'_> { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.signature.write(w)?; + w.write_all(self.note_bytes) + } +} + +impl UnsignedPayerProof<'_> { + /// Signs the [`UnsignedPayerProof`] using the given function. + pub fn sign(self, sign: F) -> Result { + let pubkey = self.payer_id; + let payer_signature = merkle::sign_message(sign, &self, pubkey).map_err(|e| match e { + SignError::Signing => PayerProofError::SigningError, + SignError::Verification(_) => PayerProofError::InvalidPayerSignature, + })?; + + let bytes = + self.serialize_payer_proof(&payer_signature).expect("Vec write should not fail"); Ok(PayerProof { bytes, @@ -331,101 +516,52 @@ impl UnsignedPayerProof { preimage: self.preimage, invoice_signature: self.invoice_signature, payer_signature, - payer_note: note.map(String::from), + payer_note: self.payer_note, disclosed_fields: self.disclosed_fields, }, merkle_root: self.disclosure.merkle_root, }) } - /// Compute the payer signature message per BOLT 12 signature calculation. - fn compute_payer_signature_message(note: Option<&str>, merkle_root: &sha256::Hash) -> Message { - let mut inner_hasher = sha256::Hash::engine(); - if let Some(n) = note { - inner_hasher.input(n.as_bytes()); - } - inner_hasher.input(merkle_root.as_ref()); - let inner_msg = sha256::Hash::from_engine(inner_hasher); - - let tag_hash = sha256::Hash::hash(PAYER_SIGNATURE_TAG.as_bytes()); - - let mut final_hasher = sha256::Hash::engine(); - final_hasher.input(tag_hash.as_ref()); - final_hasher.input(tag_hash.as_ref()); - final_hasher.input(inner_msg.as_ref()); - let final_digest = sha256::Hash::from_engine(final_hasher); - - Message::from_digest(*final_digest.as_byte_array()) - } - - fn serialize_payer_proof(&self, payer_signature: &Signature, note: Option<&str>) -> Vec { - let mut bytes = Vec::new(); + fn serialize_payer_proof(&self, payer_signature: &Signature) -> Result, io::Error> { + 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) - .filter(|r| self.included_types.contains(&r.r#type) && r.r#type < TLV_SIGNATURE) + .range(0..PAYER_PROOF_SIGNATURE_TYPE) + .filter(|r| self.included_types.contains(&r.r#type)) { bytes.extend_from_slice(record.record_bytes); } - BigSize(TLV_SIGNATURE).write(&mut bytes).expect("Vec write should not fail"); - BigSize(64).write(&mut bytes).expect("Vec write should not fail"); - self.invoice_signature.write(&mut bytes).expect("Vec write should not fail"); - - BigSize(TLV_PREIMAGE).write(&mut bytes).expect("Vec write should not fail"); - BigSize(32).write(&mut bytes).expect("Vec write should not fail"); - bytes.extend_from_slice(&self.preimage.0); - - if !self.disclosure.omitted_markers.is_empty() { - let omitted_len: u64 = self - .disclosure - .omitted_markers - .iter() - .map(|m| BigSize(*m).serialized_length() as u64) - .sum(); - BigSize(TLV_OMITTED_TLVS).write(&mut bytes).expect("Vec write should not fail"); - BigSize(omitted_len).write(&mut bytes).expect("Vec write should not fail"); - for marker in &self.disclosure.omitted_markers { - BigSize(*marker).write(&mut bytes).expect("Vec write should not fail"); - } - } + let note_bytes = self.payer_note.as_deref().map(|n| n.as_bytes()).unwrap_or(&[]); + let payer_sig = PayerSignatureWithNote { signature: payer_signature, note_bytes }; + let omitted_markers = if self.disclosure.omitted_markers.is_empty() { + None + } else { + Some(IterableOwned(self.disclosure.omitted_markers.iter().map(|m| BigSize(*m)))) + }; - if !self.disclosure.missing_hashes.is_empty() { - let len = self.disclosure.missing_hashes.len() * 32; - BigSize(TLV_MISSING_HASHES).write(&mut bytes).expect("Vec write should not fail"); - BigSize(len as u64).write(&mut bytes).expect("Vec write should not fail"); - for hash in &self.disclosure.missing_hashes { - bytes.extend_from_slice(hash.as_ref()); - } - } + encode_tlv_stream!(&mut bytes, { + (PAYER_PROOF_SIGNATURE_TYPE, &self.invoice_signature, required), + (PAYER_PROOF_PREIMAGE_TYPE, &self.preimage, required), + (PAYER_PROOF_OMITTED_TLVS_TYPE, omitted_markers, option), + (PAYER_PROOF_MISSING_HASHES_TYPE, &self.disclosure.missing_hashes, optional_vec), + (PAYER_PROOF_LEAF_HASHES_TYPE, &self.disclosure.leaf_hashes, optional_vec), + (PAYER_PROOF_PAYER_SIGNATURE_TYPE, &payer_sig, required), + }); - if !self.disclosure.leaf_hashes.is_empty() { - let len = self.disclosure.leaf_hashes.len() * 32; - BigSize(TLV_LEAF_HASHES).write(&mut bytes).expect("Vec write should not fail"); - BigSize(len as u64).write(&mut bytes).expect("Vec write should not fail"); - for hash in &self.disclosure.leaf_hashes { - bytes.extend_from_slice(hash.as_ref()); - } - } - - let note_bytes = note.map(|n| n.as_bytes()).unwrap_or(&[]); - let payer_sig_len = 64 + note_bytes.len(); - BigSize(TLV_PAYER_SIGNATURE).write(&mut bytes).expect("Vec write should not fail"); - BigSize(payer_sig_len as u64).write(&mut bytes).expect("Vec write should not fail"); - payer_signature.write(&mut bytes).expect("Vec write should not fail"); - bytes.extend_from_slice(note_bytes); - - for record in TlvStream::new(&self.invoice_bytes).filter(|r| { - self.included_types.contains(&r.r#type) - && !SIGNATURE_TYPES.contains(&r.r#type) - && r.r#type > *SIGNATURE_TYPES.end() - }) { + 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 + Ok(bytes) } } @@ -510,12 +646,20 @@ impl AsRef<[u8]> for PayerProof { /// /// `TlvStream::new()` assumes well-formed input and panics on malformed BigSize /// values or out-of-bounds lengths. This function validates the framing first, -/// returning an error instead of panicking on untrusted input. -fn validate_tlv_framing(bytes: &[u8]) -> Result<(), crate::ln::msgs::DecodeError> { - use crate::ln::msgs::DecodeError; +/// returning an error instead of panicking on untrusted input. It also checks +/// strict ascending TLV type ordering (which covers duplicates). +fn validate_tlv_framing(bytes: &[u8]) -> Result<(), DecodeError> { let mut cursor = io::Cursor::new(bytes); + let mut prev_type: Option = None; while (cursor.position() as usize) < bytes.len() { - let _type: BigSize = Readable::read(&mut cursor).map_err(|_| DecodeError::InvalidValue)?; + let tlv_type: BigSize = + Readable::read(&mut cursor).map_err(|_| DecodeError::InvalidValue)?; + if let Some(prev) = prev_type { + if tlv_type.0 <= prev { + return Err(DecodeError::InvalidValue); + } + } + prev_type = Some(tlv_type.0); let length: BigSize = Readable::read(&mut cursor).map_err(|_| DecodeError::InvalidValue)?; let end = cursor.position().checked_add(length.0).ok_or(DecodeError::InvalidValue)?; let end_usize = usize::try_from(end).map_err(|_| DecodeError::InvalidValue)?; @@ -527,47 +671,45 @@ fn validate_tlv_framing(bytes: &[u8]) -> Result<(), crate::ln::msgs::DecodeError Ok(()) } -fn update_disclosed_fields( - record: &crate::offers::merkle::TlvRecord<'_>, disclosed_fields: &mut DisclosedFields, -) -> Result<(), crate::ln::msgs::DecodeError> { - use crate::ln::msgs::DecodeError; - - match record.r#type { - OFFER_DESCRIPTION_TYPE => { - disclosed_fields.offer_description = Some( - String::from_utf8(record.value_bytes.to_vec()) - .map_err(|_| DecodeError::InvalidValue)?, - ); - }, - OFFER_ISSUER_TYPE => { - disclosed_fields.offer_issuer = Some( - String::from_utf8(record.value_bytes.to_vec()) - .map_err(|_| DecodeError::InvalidValue)?, - ); - }, - INVOICE_CREATED_AT_TYPE => { - disclosed_fields.invoice_created_at = Some(Duration::from_secs( - record.read_value::>()?.0, - )); - }, - INVOICE_AMOUNT_TYPE => { - disclosed_fields.invoice_amount_msats = - Some(record.read_value::>()?.0); - }, - _ => {}, - } +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(()) -} + Ok(()) + } -fn extract_disclosed_fields<'a>( - records: impl core::iter::Iterator>, -) -> Result { - let mut disclosed_fields = DisclosedFields::default(); - for record in records { - update_disclosed_fields(&record, &mut disclosed_fields)?; + 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) } - Ok(disclosed_fields) } // Payer proofs use manual TLV parsing rather than `ParsedMessage` / `tlv_stream!` @@ -579,12 +721,9 @@ fn extract_disclosed_fields<'a>( // of known fields with standard `Readable`/`Writeable` encodings, so it cannot // express the passthrough-or-parse logic required here. impl TryFrom> for PayerProof { - type Error = crate::offers::parse::Bolt12ParseError; + type Error = Bolt12ParseError; fn try_from(bytes: Vec) -> Result { - use crate::ln::msgs::DecodeError; - use crate::offers::parse::Bolt12ParseError; - // Validate TLV framing before passing to TlvStream, which assumes // well-formed input and panics on malformed BigSize or out-of-bounds // lengths. This mirrors the validation that ParsedMessage / CursorReadable @@ -606,89 +745,61 @@ impl TryFrom> for PayerProof { let mut missing_hashes: Vec = Vec::new(); let mut included_types: BTreeSet = BTreeSet::new(); - let mut included_records: Vec<(u64, usize, usize)> = Vec::new(); - - let mut prev_tlv_type: Option = None; + let mut included_records: Vec> = Vec::new(); for record in TlvStream::new(&bytes) { let tlv_type = record.r#type; - - // Strict ascending order check covers both ordering and duplicates. - if let Some(prev) = prev_tlv_type { - if tlv_type <= prev { - return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); - } - } - prev_tlv_type = Some(tlv_type); - update_disclosed_fields(&record, &mut disclosed_fields)?; + disclosed_fields.update(&record)?; match tlv_type { INVOICE_REQUEST_PAYER_ID_TYPE => { payer_id = Some(record.read_value()?); included_types.insert(tlv_type); - included_records.push(( - tlv_type, - record.end - record.record_bytes.len(), - record.end, - )); + included_records.push(record); }, INVOICE_PAYMENT_HASH_TYPE => { payment_hash = Some(record.read_value()?); included_types.insert(tlv_type); - included_records.push(( - tlv_type, - record.end - record.record_bytes.len(), - record.end, - )); + included_records.push(record); }, INVOICE_NODE_ID_TYPE => { issuer_signing_pubkey = Some(record.read_value()?); included_types.insert(tlv_type); - included_records.push(( - tlv_type, - record.end - record.record_bytes.len(), - record.end, - )); + included_records.push(record); }, - TLV_SIGNATURE => { + PAYER_PROOF_SIGNATURE_TYPE => { invoice_signature = Some(record.read_value()?); }, - TLV_PREIMAGE => { + PAYER_PROOF_PREIMAGE_TYPE => { preimage = Some(record.read_value()?); }, - TLV_OMITTED_TLVS => { + PAYER_PROOF_OMITTED_TLVS_TYPE => { let mut cursor = io::Cursor::new(record.value_bytes); while (cursor.position() as usize) < record.value_bytes.len() { let marker: BigSize = Readable::read(&mut cursor)?; omitted_markers.push(marker.0); } }, - TLV_MISSING_HASHES => { - if record.value_bytes.len() % 32 != 0 { - return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); - } - for chunk in record.value_bytes.chunks_exact(32) { - let hash_bytes: [u8; 32] = chunk.try_into().expect("chunks_exact(32)"); - missing_hashes.push(sha256::Hash::from_byte_array(hash_bytes)); - } + PAYER_PROOF_MISSING_HASHES_TYPE => { + let WithoutLength(hashes) = LengthReadable::read_from_fixed_length_buffer( + &mut &record.value_bytes[..], + )?; + missing_hashes = hashes; }, - TLV_LEAF_HASHES => { - if record.value_bytes.len() % 32 != 0 { - return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); - } - for chunk in record.value_bytes.chunks_exact(32) { - let hash_bytes: [u8; 32] = chunk.try_into().expect("chunks_exact(32)"); - leaf_hashes.push(sha256::Hash::from_byte_array(hash_bytes)); - } + PAYER_PROOF_LEAF_HASHES_TYPE => { + let WithoutLength(hashes) = LengthReadable::read_from_fixed_length_buffer( + &mut &record.value_bytes[..], + )?; + leaf_hashes = hashes; }, - TLV_PAYER_SIGNATURE => { - if record.value_bytes.len() < 64 { + PAYER_PROOF_PAYER_SIGNATURE_TYPE => { + if record.value_bytes.len() < SCHNORR_SIGNATURE_SIZE { return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); } let mut cursor = io::Cursor::new(record.value_bytes); payer_signature = Some(Readable::read(&mut cursor)?); - if record.value_bytes.len() > 64 { - let note_bytes = &record.value_bytes[64..]; + if record.value_bytes.len() > SCHNORR_SIGNATURE_SIZE { + let note_bytes = &record.value_bytes[SCHNORR_SIGNATURE_SIZE..]; payer_note = Some( String::from_utf8(note_bytes.to_vec()) .map_err(|_| DecodeError::InvalidValue)?, @@ -701,13 +812,13 @@ impl TryFrom> for PayerProof { } if !SIGNATURE_TYPES.contains(&tlv_type) { // Included invoice TLV record (passthrough for merkle - // reconstruction). + // reconstruction). These are raw bytes the payer selected + // for disclosure; we don't apply the unknown-even check + // here because all standard invoice TLV types are even + // and the verifier will reject any record that doesn't + // match the original invoice's merkle root. included_types.insert(tlv_type); - included_records.push(( - tlv_type, - record.end - record.record_bytes.len(), - record.end, - )); + included_records.push(record); } else if tlv_type % 2 == 0 { // Unknown even types are mandatory-to-understand per // BOLT convention — reject them. @@ -719,22 +830,17 @@ impl TryFrom> for PayerProof { } let payer_id = payer_id.ok_or(Bolt12ParseError::InvalidSemantics( - crate::offers::parse::Bolt12SemanticError::MissingPayerSigningPubkey, - ))?; - let payment_hash = payment_hash.ok_or(Bolt12ParseError::InvalidSemantics( - crate::offers::parse::Bolt12SemanticError::MissingPaymentHash, - ))?; - let issuer_signing_pubkey = - issuer_signing_pubkey.ok_or(Bolt12ParseError::InvalidSemantics( - crate::offers::parse::Bolt12SemanticError::MissingSigningPubkey, - ))?; - let invoice_signature = invoice_signature.ok_or(Bolt12ParseError::InvalidSemantics( - crate::offers::parse::Bolt12SemanticError::MissingSignature, + Bolt12SemanticError::MissingPayerSigningPubkey, ))?; + let payment_hash = payment_hash + .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingPaymentHash))?; + let issuer_signing_pubkey = issuer_signing_pubkey + .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( - crate::offers::parse::Bolt12SemanticError::MissingSignature, - ))?; + let payer_signature = payer_signature + .ok_or(Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSignature))?; validate_omitted_markers_for_parsing(&omitted_markers, &included_types) .map_err(Bolt12ParseError::Decode)?; @@ -743,10 +849,8 @@ impl TryFrom> for PayerProof { return Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)); } - let included_refs: Vec<(u64, &[u8])> = - included_records.iter().map(|&(t, start, end)| (t, &bytes[start..end])).collect(); let merkle_root = merkle::reconstruct_merkle_root( - &included_refs, + &included_records, &leaf_hashes, &omitted_markers, &missing_hashes, @@ -765,13 +869,8 @@ impl TryFrom> for PayerProof { .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; // Verify the payer signature. - let message = UnsignedPayerProof::compute_payer_signature_message( - payer_note.as_deref(), - &merkle_root, - ); - let secp_ctx = Secp256k1::verification_only(); - secp_ctx - .verify_schnorr(&payer_signature, &message, &payer_id.into()) + let payer_tagged_hash = payer_signature_hash(payer_note.as_deref(), &merkle_root); + merkle::verify_signature(&payer_signature, &payer_tagged_hash, payer_id) .map_err(|_| Bolt12ParseError::Decode(DecodeError::InvalidValue))?; Ok(PayerProof { @@ -804,7 +903,7 @@ impl TryFrom> for PayerProof { /// type. fn validate_omitted_markers_for_parsing( omitted_markers: &[u64], included_types: &BTreeSet, -) -> Result<(), crate::ln::msgs::DecodeError> { +) -> 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; @@ -813,22 +912,22 @@ fn validate_omitted_markers_for_parsing( for &marker in omitted_markers { // MUST NOT contain 0 if marker == 0 { - return Err(crate::ln::msgs::DecodeError::InvalidValue); + return Err(DecodeError::InvalidValue); } // MUST NOT contain signature TLV types if SIGNATURE_TYPES.contains(&marker) { - return Err(crate::ln::msgs::DecodeError::InvalidValue); + return Err(DecodeError::InvalidValue); } // MUST be strictly ascending if marker <= prev { - return Err(crate::ln::msgs::DecodeError::InvalidValue); + return Err(DecodeError::InvalidValue); } // MUST NOT contain included TLV types if included_types.contains(&marker) { - return Err(crate::ln::msgs::DecodeError::InvalidValue); + return Err(DecodeError::InvalidValue); } // Validate minimization: marker must equal expected_next (continuation @@ -842,11 +941,11 @@ fn validate_omitted_markers_for_parsing( break; } if inc_type >= marker { - return Err(crate::ln::msgs::DecodeError::InvalidValue); + return Err(DecodeError::InvalidValue); } } if !found { - return Err(crate::ln::msgs::DecodeError::InvalidValue); + return Err(DecodeError::InvalidValue); } } @@ -857,6 +956,14 @@ fn validate_omitted_markers_for_parsing( 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) @@ -935,11 +1042,12 @@ mod tests { ] .into_iter() .collect(); - let disclosed_fields = extract_disclosed_fields( + let disclosed_fields = DisclosedFields::from_records( TlvStream::new(&invoice_bytes).filter(|r| included_types.contains(&r.r#type)), ) .unwrap(); - let disclosure = compute_selective_disclosure(&invoice_bytes, &included_types).unwrap(); + let disclosure = + compute_selective_disclosure(TlvStream::new(&invoice_bytes), &included_types).unwrap(); let unsigned = UnsignedPayerProof { invoice_signature, @@ -947,14 +1055,18 @@ mod tests { payer_id, payment_hash, issuer_signing_pubkey, - invoice_bytes, + invoice_bytes: &invoice_bytes, included_types, disclosed_fields, + tagged_hash: payer_signature_hash(None, &disclosure.merkle_root), disclosure, + payer_note: None, }; unsigned - .sign(|message| Ok(secp_ctx.sign_schnorr_no_aux_rand(message, &payer_keys)), None) + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) .unwrap() } @@ -989,11 +1101,12 @@ mod tests { [INVOICE_REQUEST_PAYER_ID_TYPE, INVOICE_PAYMENT_HASH_TYPE, INVOICE_NODE_ID_TYPE] .into_iter() .collect(); - let disclosed_fields = extract_disclosed_fields( + let disclosed_fields = DisclosedFields::from_records( TlvStream::new(&invoice_bytes).filter(|r| included_types.contains(&r.r#type)), ) .unwrap(); - let disclosure = compute_selective_disclosure(&invoice_bytes, &included_types).unwrap(); + let disclosure = + compute_selective_disclosure(TlvStream::new(&invoice_bytes), &included_types).unwrap(); assert_eq!(disclosure.omitted_markers, vec![177, 178]); let unsigned = UnsignedPayerProof { @@ -1002,14 +1115,18 @@ mod tests { payer_id, payment_hash, issuer_signing_pubkey, - invoice_bytes, + invoice_bytes: &invoice_bytes, included_types, disclosed_fields, + tagged_hash: payer_signature_hash(None, &disclosure.merkle_root), disclosure, + payer_note: None, }; unsigned - .sign(|message| Ok(secp_ctx.sign_schnorr_no_aux_rand(message, &payer_keys)), None) + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) .unwrap() } @@ -1061,11 +1178,12 @@ mod tests { ] .into_iter() .collect(); - let disclosed_fields = extract_disclosed_fields( + let disclosed_fields = DisclosedFields::from_records( TlvStream::new(&invoice_bytes).filter(|r| included_types.contains(&r.r#type)), ) .unwrap(); - let disclosure = compute_selective_disclosure(&invoice_bytes, &included_types).unwrap(); + let disclosure = + compute_selective_disclosure(TlvStream::new(&invoice_bytes), &included_types).unwrap(); let unsigned = UnsignedPayerProof { invoice_signature, @@ -1073,14 +1191,18 @@ mod tests { payer_id, payment_hash, issuer_signing_pubkey, - invoice_bytes, + invoice_bytes: &invoice_bytes, included_types, disclosed_fields, + tagged_hash: payer_signature_hash(None, &disclosure.merkle_root), disclosure, + payer_note: None, }; unsigned - .sign(|message| Ok(secp_ctx.sign_schnorr_no_aux_rand(message, &payer_keys)), None) + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) .unwrap() } @@ -1096,7 +1218,7 @@ mod tests { let mut included = BTreeSet::new(); included.insert(1); - let result = compute_selective_disclosure(&tlv_bytes, &included); + let result = compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included); assert!(result.is_ok()); let disclosure = result.unwrap(); @@ -1142,7 +1264,8 @@ mod tests { included.insert(10); included.insert(40); - let disclosure = compute_selective_disclosure(&tlv_bytes, &included).unwrap(); + 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]); @@ -1164,7 +1287,8 @@ mod tests { let mut included = BTreeSet::new(); included.insert(10); - let disclosure = compute_selective_disclosure(&tlv_bytes, &included).unwrap(); + 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]); @@ -1182,7 +1306,8 @@ mod tests { included.insert(10); included.insert(20); - let disclosure = compute_selective_disclosure(&tlv_bytes, &included).unwrap(); + 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()); @@ -1228,7 +1353,7 @@ mod tests { let included: BTreeSet = [10, 30].iter().copied().collect(); let result = validate_omitted_markers_for_parsing(&omitted, &included); - assert!(matches!(result, Err(crate::ln::msgs::DecodeError::InvalidValue))); + assert!(matches!(result, Err(DecodeError::InvalidValue))); } /// Test that a minimized trailing run is accepted. @@ -1465,6 +1590,58 @@ mod tests { assert!(result.is_err(), "Unknown even type 252 should be rejected"); } + /// Test that even TLV types outside the signature range are accepted as + /// passthrough invoice records, while unknown even types inside the + /// signature range (240..=1000) are rejected. + /// + /// Non-signature types are invoice TLV records selected for disclosure. + /// They bypass the unknown-even check because all standard invoice TLV + /// types are even and the verifier rejects any record not matching the + /// original invoice's merkle root. + #[test] + fn test_parsing_even_type_handling_by_range() { + use core::convert::TryFrom; + + // Craft minimal TLV streams to test just the parsing logic. + // These will fail later validation (missing required fields), but the + // match arm behavior is what we're testing. + + // Case 1: Unknown even type 200 (outside signature range) — should be + // accepted as a passthrough record. The parse will fail later due to + // missing required fields, not due to the even type. + let mut bytes = Vec::new(); + BigSize(200).write(&mut bytes).unwrap(); + BigSize(4).write(&mut bytes).unwrap(); + bytes.extend_from_slice(b"test"); + + let result = PayerProof::try_from(bytes); + // Fails because required fields (payer_id, etc.) are missing — but NOT + // because of an unknown-even-type rejection. + match result { + Err(Bolt12ParseError::InvalidSemantics(_)) => {}, + Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)) => { + panic!( + "Even type 200 was rejected as invalid, but should be accepted as passthrough" + ); + }, + Ok(_) => panic!("Should fail due to missing required fields"), + Err(e) => panic!("Unexpected error: {:?}", e), + } + + // Case 2: Unknown even type 252 (inside signature range) — should be + // rejected immediately as unknown-even. + let mut bytes = Vec::new(); + BigSize(252).write(&mut bytes).unwrap(); + BigSize(4).write(&mut bytes).unwrap(); + bytes.extend_from_slice(b"test"); + + let result = PayerProof::try_from(bytes); + match result { + Err(Bolt12ParseError::Decode(DecodeError::InvalidValue)) => {}, + _ => panic!("Even type 252 in signature range should be rejected"), + } + } + /// Test that malformed TLV framing is rejected without panicking. /// /// TlvStream::new() panics on malformed BigSize values or out-of-bounds @@ -1552,20 +1729,26 @@ mod tests { let secp_ctx = Secp256k1::signing_only(); let payer_keys = payer_keys(); - let proof = invoice - .payer_proof_builder(preimage) + let paid_invoice = + PaidBolt12Invoice::new(Bolt12InvoiceType::Bolt12Invoice(invoice), preimage, None); + let payer_proof = paid_invoice + .prove_payer() + .unwrap() + .build(None) .unwrap() - .build(|message| Ok(secp_ctx.sign_schnorr_no_aux_rand(message, &payer_keys)), None) + .sign(|proof: &UnsignedPayerProof| { + Ok(secp_ctx.sign_schnorr_no_aux_rand(proof.as_ref().as_digest(), &payer_keys)) + }) .unwrap(); - let parsed = PayerProof::try_from(proof.bytes().to_vec()).unwrap(); + let parsed = PayerProof::try_from(payer_proof.bytes().to_vec()).unwrap(); - assert_eq!(parsed.bytes(), proof.bytes()); + assert_eq!(parsed.bytes(), payer_proof.bytes()); assert_eq!(parsed.preimage(), preimage); assert_eq!(parsed.payment_hash(), payment_hash); } #[test] - fn test_build_with_derived_key_for_refund_invoice() { + fn test_build_with_derived_signing_keys_for_refund_invoice() { use core::convert::TryFrom; let expanded_key = ExpandedKey::new([42; 32]); @@ -1598,12 +1781,17 @@ mod tests { .sign(recipient_sign) .unwrap(); - let proof = invoice - .payer_proof_builder(preimage) + 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_with_derived_key(&expanded_key, nonce, payment_id, Some("refund")) + .build_and_sign(Some("refund".into())) .unwrap(); - let parsed = PayerProof::try_from(proof.bytes().to_vec()).unwrap(); + let parsed = PayerProof::try_from(payer_proof.bytes().to_vec()).unwrap(); assert_eq!(parsed.preimage(), preimage); assert_eq!(parsed.payment_hash(), payment_hash); diff --git a/lightning/src/util/ser.rs b/lightning/src/util/ser.rs index 7d0acacdccb..6343d53d622 100644 --- a/lightning/src/util/ser.rs +++ b/lightning/src/util/ser.rs @@ -33,6 +33,7 @@ use bitcoin::consensus::Encodable; use bitcoin::constants::ChainHash; use bitcoin::hash_types::{BlockHash, Txid}; use bitcoin::hashes::hmac::Hmac; +use bitcoin::hashes::sha256; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::sha256d::Hash as Sha256dHash; use bitcoin::script::{self, ScriptBuf}; @@ -1205,6 +1206,21 @@ impl Readable for SecretKey { } } +impl Writeable for sha256::Hash { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + w.write_all(&self[..]) + } +} + +impl Readable for sha256::Hash { + fn read(r: &mut R) -> Result { + use bitcoin::hashes::Hash; + + let buf: [u8; 32] = Readable::read(r)?; + Ok(sha256::Hash::from_byte_array(buf)) + } +} + impl Writeable for Hmac { fn write(&self, w: &mut W) -> Result<(), io::Error> { w.write_all(&self[..]) From 1db500a3beda60fa8ed6509b2f9ba776a35d01b0 Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Thu, 9 Apr 2026 20:38:59 +0200 Subject: [PATCH 5/6] fix(offers): use DFS order for payer proof missing hashes Don't require payer proof missing hashes to remain TLV-sorted now that the spec uses DFS traversal order. (cherry picked from commit 334bb058738ad889769d139ccbee99d7fa14306e) --- lightning/src/offers/merkle.rs | 314 ++++++++++++++------------------- 1 file changed, 134 insertions(+), 180 deletions(-) diff --git a/lightning/src/offers/merkle.rs b/lightning/src/offers/merkle.rs index dd53efc6072..2462644e192 100644 --- a/lightning/src/offers/merkle.rs +++ b/lightning/src/offers/merkle.rs @@ -421,98 +421,75 @@ fn compute_omitted_markers<'a>( .flatten() } -/// A node in the merkle tree during selective disclosure processing. -struct TreeNode { - hash: Option, - included: bool, - min_type: u64, +/// Task for iterative post-order DFS over the implicit merkle tree. +enum DfsTask { + /// Descend into a subtree covering the range [start..start+len). + Descend { start: usize, len: usize }, + /// Combine two child hashes after both subtrees have been processed. + Combine, } -/// Build merkle tree and collect missing_hashes for omitted subtrees. +/// Build merkle tree iteratively (DFS, left-to-right) and collect missing_hashes. /// -/// Returns hashes sorted by ascending TLV type as required by the spec. For internal -/// nodes, the type used for ordering is the minimum TLV type in that subtree. +/// Per the spec, missing_hashes are in depth-first left-to-right order. +/// Uses an explicit stack to simulate post-order DFS without recursion. /// -/// Uses `n` tree nodes (one per TLV) rather than `2n`, since the per-TLV hashes -/// already combine leaf and nonce. The tree traversal starts at level 0 to pair -/// adjacent per-TLV hashes, matching the structure of `root_hash()`. +/// 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) { - let num_nodes = tlv_data.len(); - debug_assert!(num_nodes > 0, "TLV stream must contain at least one record"); - - let num_omitted = tlv_data.iter().filter(|d| !d.is_included).count(); - - let mut nodes: Vec = tlv_data - .iter() - .map(|data| TreeNode { - hash: Some(data.per_tlv_hash), - included: data.is_included, - min_type: data.tlv_type, - }) - .collect(); - - let mut missing_with_types: Vec<(u64, sha256::Hash)> = Vec::with_capacity(num_omitted); - - for level in 0.. { - let step = 2 << level; - let offset = step / 2; - if offset >= num_nodes { - break; - } - - for (left_pos, right_pos) in - (0..num_nodes).step_by(step).zip((offset..num_nodes).step_by(step)) - { - let left_hash = nodes[left_pos].hash; - let right_hash = nodes[right_pos].hash; - let left_incl = nodes[left_pos].included; - let right_incl = nodes[right_pos].included; - let right_min_type = nodes[right_pos].min_type; - - match (left_hash, right_hash) { - (Some(l), Some(r)) => { - if left_incl != right_incl { - let (missing_type, missing_hash) = if right_incl { - (nodes[left_pos].min_type, l) - } else { - (right_min_type, r) - }; - missing_with_types.push((missing_type, missing_hash)); - } - nodes[left_pos].hash = - Some(tagged_branch_hash_from_engine(branch_tag.clone(), l, r)); - nodes[left_pos].included |= left_incl || right_incl; - nodes[left_pos].min_type = - core::cmp::min(nodes[left_pos].min_type, right_min_type); - }, - (Some(_), None) => {}, - _ => unreachable!("Invalid state in merkle tree construction"), - } + debug_assert!(!tlv_data.is_empty(), "TLV stream must contain at least one record"); + + let mut missing_hashes = Vec::new(); + // Each entry: (hash, has_included_leaf_in_subtree) + let mut hash_stack: Vec<(sha256::Hash, bool)> = Vec::new(); + let mut task_stack: Vec = vec![DfsTask::Descend { start: 0, len: tlv_data.len() }]; + + while let Some(task) = task_stack.pop() { + match task { + DfsTask::Descend { start, len } => { + if len == 1 { + hash_stack.push((tlv_data[start].per_tlv_hash, tlv_data[start].is_included)); + } else { + let mid = len.next_power_of_two() / 2; + // Push combine first (processed after both children). + task_stack.push(DfsTask::Combine); + // Push right then left so left is processed first (LIFO). + task_stack.push(DfsTask::Descend { start: start + mid, len: len - mid }); + task_stack.push(DfsTask::Descend { start, len: mid }); + } + }, + DfsTask::Combine => { + let (right_hash, right_incl) = hash_stack.pop().unwrap(); + let (left_hash, left_incl) = hash_stack.pop().unwrap(); + + 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); + hash_stack.push((combined, left_incl || right_incl)); + }, } } - missing_with_types.sort_by_key(|(min_type, _)| *min_type); - let missing_hashes: Vec = - missing_with_types.into_iter().map(|(_, h)| h).collect(); - - (nodes[0].hash.expect("Tree should have a root"), missing_hashes) + let (root, _) = hash_stack.pop().unwrap(); + (root, missing_hashes) } /// Reconstruct merkle root from selective disclosure data. /// -/// The `missing_hashes` must be in ascending type order per spec. -/// -/// Uses `n` tree nodes (one per TLV position) rather than `2n`, since per-TLV -/// hashes already combine leaf and nonce. Two passes over the tree determine -/// where missing hashes are needed and then combine all hashes to the root. +/// `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 { - // Callers are expected to validate omitted_markers before calling this function - // (e.g., via validate_omitted_markers_for_parsing). Debug-assert for safety. debug_assert!(validate_omitted_markers(omitted_markers).is_ok()); if included_records.len() != leaf_hashes.len() { @@ -522,135 +499,118 @@ pub(super) fn reconstruct_merkle_root( 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 TreeNode vec directly by interleaving included/omitted positions, - // eliminating the intermediate Vec from reconstruct_positions_from_records. + // 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 nodes: Vec = Vec::with_capacity(num_nodes); - - // TLV0 is always omitted - nodes.push(TreeNode { hash: None, included: false, min_type: 0 }); + 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; - let mut node_idx: u64 = 1; while inc_idx < included_records.len() || mrk_idx < omitted_markers.len() { if mrk_idx >= omitted_markers.len() { - // No more markers, remaining positions are included 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]; - let hash = tagged_branch_hash_from_engine(branch_tag.clone(), leaf_hash, nonce_hash); - nodes.push(TreeNode { hash: Some(hash), included: true, min_type: node_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() { - // No more included types, remaining positions are omitted - nodes.push(TreeNode { hash: None, included: false, min_type: node_idx }); + 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 { - // Continuation of current run -> omitted position - nodes.push(TreeNode { hash: None, included: false, min_type: node_idx }); + hashes.push(None); prev_marker = marker; mrk_idx += 1; } else { - // Jump detected -> included position comes first 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]; - let hash = - tagged_branch_hash_from_engine(branch_tag.clone(), leaf_hash, nonce_hash); - nodes.push(TreeNode { hash: Some(hash), included: true, min_type: node_idx }); + hashes.push(Some(tagged_branch_hash_from_engine( + branch_tag.clone(), + leaf_hash, + nonce_hash, + ))); prev_marker = inc_type; inc_idx += 1; } } - node_idx += 1; } - // First pass: walk the tree to discover which positions need missing hashes. - // We mutate nodes[].included and nodes[].min_type directly since the second - // pass only reads nodes[].hash, making this safe without a separate allocation. - let num_omitted = omitted_markers.len() + 1; // +1 for implicit TLV0 - let mut needs_hash: Vec<(u64, usize)> = Vec::with_capacity(num_omitted); - - for level in 0.. { - let step = 2 << level; - let offset = step / 2; - if offset >= num_nodes { - break; - } - - for left_pos in (0..num_nodes).step_by(step) { - let right_pos = left_pos + offset; - if right_pos >= num_nodes { - continue; - } - - let r_min = nodes[right_pos].min_type; - - match (nodes[left_pos].included, nodes[right_pos].included) { - (true, false) => { - needs_hash.push((r_min, right_pos)); - nodes[left_pos].min_type = core::cmp::min(nodes[left_pos].min_type, r_min); - }, - (false, true) => { - needs_hash.push((nodes[left_pos].min_type, left_pos)); - nodes[left_pos].included = true; - nodes[left_pos].min_type = core::cmp::min(nodes[left_pos].min_type, r_min); - }, - (true, true) => { - nodes[left_pos].min_type = core::cmp::min(nodes[left_pos].min_type, r_min); - }, - (false, false) => { - nodes[left_pos].min_type = core::cmp::min(nodes[left_pos].min_type, r_min); - }, - } + // Iterative DFS reconstruction: consume missing_hashes in DFS order. + // result_stack holds Option: Some = subtree has included leaves, None = all omitted. + let mut result_stack: Vec> = Vec::new(); + let mut task_stack: Vec = vec![DfsTask::Descend { start: 0, len: num_nodes }]; + let mut missing_idx: usize = 0; + + while let Some(task) = task_stack.pop() { + match task { + DfsTask::Descend { start, len } => { + if len == 1 { + result_stack.push(hashes[start]); + } else { + let mid = len.next_power_of_two() / 2; + task_stack.push(DfsTask::Combine); + task_stack.push(DfsTask::Descend { start: start + mid, len: len - mid }); + task_stack.push(DfsTask::Descend { start, len: mid }); + } + }, + DfsTask::Combine => { + let right = result_stack.pop().unwrap(); + let left = result_stack.pop().unwrap(); + + match (left, right) { + (None, None) => result_stack.push(None), + (Some(l), None) => { + if missing_idx >= missing_hashes.len() { + return Err(SelectiveDisclosureError::InsufficientMissingHashes); + } + let r = missing_hashes[missing_idx]; + missing_idx += 1; + result_stack.push(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; + result_stack.push(Some(tagged_branch_hash_from_engine( + branch_tag.clone(), + l, + r, + ))); + }, + (Some(l), Some(r)) => { + result_stack.push(Some(tagged_branch_hash_from_engine( + branch_tag.clone(), + l, + r, + ))); + }, + } + }, } } - needs_hash.sort_by_key(|(min_pos, _)| *min_pos); - - if needs_hash.len() != missing_hashes.len() { + if missing_idx != missing_hashes.len() { return Err(SelectiveDisclosureError::InsufficientMissingHashes); } - // Place missing hashes directly into the nodes array. - for (i, &(_, tree_pos)) in needs_hash.iter().enumerate() { - nodes[tree_pos].hash = Some(missing_hashes[i]); - } - - // Second pass: combine hashes up the tree. - for level in 0.. { - let step = 2 << level; - let offset = step / 2; - if offset >= num_nodes { - break; - } - - for left_pos in (0..num_nodes).step_by(step) { - let right_pos = left_pos + offset; - if right_pos >= num_nodes { - continue; - } - - match (nodes[left_pos].hash, nodes[right_pos].hash) { - (Some(l), Some(r)) => { - nodes[left_pos].hash = - Some(tagged_branch_hash_from_engine(branch_tag.clone(), l, r)); - }, - (Some(_), None) => {}, - (None, _) => {}, - }; - } - } - - nodes[0].hash.ok_or(SelectiveDisclosureError::InsufficientMissingHashes) + result_stack.pop().unwrap().ok_or(SelectiveDisclosureError::InsufficientMissingHashes) } fn validate_omitted_markers(markers: &[u64]) -> Result<(), SelectiveDisclosureError> { @@ -1057,21 +1017,18 @@ mod tests { assert_eq!(reconstructed, disclosure.merkle_root); } - /// Test that missing_hashes are in ascending type order per spec. - /// - /// Per spec: "MUST include the minimal set of merkle hashes of missing merkle - /// leaves or nodes in `missing_hashes`, in ascending type order." + /// Test that the synthetic 7-node example still requires four missing hashes. /// - /// For the spec example with TLVs [0(o), 10(I), 20(o), 30(o), 40(I), 50(o), 60(o)]: + /// 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 (min=20) + /// - hash(B(20,30)) covers types 20-30 /// - hash(50) covers type 50 /// - hash(60) covers type 60 /// - /// Expected order: [type 0, type 20, type 50, type 60] - /// This means 4 missing_hashes in this order. + /// 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_ascending_type_order() { + fn test_missing_hashes_for_synthetic_tree() { use alloc::collections::BTreeSet; // Build TLV stream: 0, 10, 20, 30, 40, 50, 60 @@ -1092,14 +1049,11 @@ mod tests { let disclosure = super::compute_selective_disclosure(TlvStream::new(&tlv_bytes), &included).unwrap(); - // We should have 4 missing hashes for omitted types: + // We should still have 4 missing hashes for omitted types: // - type 0 (single leaf) - // - types 20+30 (combined branch, min_type=20) + // - types 20+30 (combined branch) // - type 50 (single leaf) // - type 60 (single leaf) - // - // The spec example only shows 3, but that appears to be incomplete - // (missing hash for type 60). Our implementation should produce 4. assert_eq!( disclosure.missing_hashes.len(), 4, From 564682f90e6dcd48e249593481285a5d4998e63c Mon Sep 17 00:00:00 2001 From: Rusty Russell Date: Thu, 9 Apr 2026 20:39:03 +0200 Subject: [PATCH 6/6] test(offers): add latest BOLT12 payer proof vectors Embed the latest payer proof vectors and keep the test harness aligned with the current unsigned builder and signing flow. (cherry picked from commit 3a500c8c1145ac9a0cdaae9b13ad49afe5a04eb8) --- lightning/src/offers/payer_proof.rs | 142 ++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/lightning/src/offers/payer_proof.rs b/lightning/src/offers/payer_proof.rs index 08bbcdf8c30..525be7aab65 100644 --- a/lightning/src/offers/payer_proof.rs +++ b/lightning/src/offers/payer_proof.rs @@ -1797,4 +1797,146 @@ mod tests { 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); + } + } }