From 5c4134e2c535c4e907ea2b9a53e57f3c38856cc7 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Mon, 21 Jul 2025 19:14:19 +0200 Subject: [PATCH 1/2] offer: make the merkle tree signature public This is helpfull for the users that want to use the merkle tree signature in their own code, for example to verify the signature of bolt12 invoices or recreate it. Very useful for people that are building command line tools for the bolt12 offers. Signed-off-by: Vincenzo Palazzo --- lightning/src/offers/invoice.rs | 38 +++++++++++++++++++++++++++++++++ lightning/src/offers/merkle.rs | 8 +++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index f39b43fa6e0..4add06169a9 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1032,6 +1032,11 @@ impl Bolt12Invoice { InvoiceContents::ForRefund { .. } => self.message_paths().is_empty(), } } + + /// Returns the [`TaggedHash`] of the invoice that was signed. + pub fn tagged_hash(&self) -> &TaggedHash { + &self.tagged_hash + } } impl PartialEq for Bolt12Invoice { @@ -3560,4 +3565,37 @@ mod tests { ), } } + + #[test] + fn verifies_invoice_signature_with_tagged_hash() { + let secp_ctx = Secp256k1::new(); + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let node_id = recipient_pubkey(); + let payment_paths = payment_paths(); + let now = Duration::from_secs(123456); + let payment_id = PaymentId([1; 32]); + + let offer = OfferBuilder::new(node_id).amount_msats(1000).build().unwrap(); + + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .build_and_sign() + .unwrap(); + + let invoice = invoice_request + .respond_with_no_std(payment_paths, payment_hash(), now) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + let issuer_sign_pubkey = offer.issuer_signing_pubkey().unwrap(); + let tagged_hash = invoice.tagged_hash(); + let signature = invoice.signature(); + assert!(merkle::verify_signature(&signature, tagged_hash, issuer_sign_pubkey).is_ok()); + } } diff --git a/lightning/src/offers/merkle.rs b/lightning/src/offers/merkle.rs index 2afd001017c..4f27130bcc9 100644 --- a/lightning/src/offers/merkle.rs +++ b/lightning/src/offers/merkle.rs @@ -94,7 +94,7 @@ pub enum SignError { } /// A function for signing a [`TaggedHash`]. -pub(super) trait SignFn> { +pub trait SignFn> { /// Signs a [`TaggedHash`] computed over the merkle root of `message`'s TLV stream. fn sign(&self, message: &T) -> Result; } @@ -117,9 +117,7 @@ where /// /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest -pub(super) fn sign_message( - f: F, message: &T, pubkey: PublicKey, -) -> Result +pub fn sign_message(f: F, message: &T, pubkey: PublicKey) -> Result where F: SignFn, T: AsRef, @@ -136,7 +134,7 @@ where /// Verifies the signature with a pubkey over the given message using a tagged hash as the message /// digest. -pub(super) fn verify_signature( +pub fn verify_signature( signature: &Signature, message: &TaggedHash, pubkey: PublicKey, ) -> Result<(), secp256k1::Error> { let digest = message.as_digest(); From 93cf4090a0f440703e8f620b9722edfc4a6593a6 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 23 Jul 2025 19:42:46 +0200 Subject: [PATCH 2/2] offer: add OfferId to Bolt12Invoice - Add an Option field to Bolt12Invoice to track the originating offer. - Compute the offer_id for invoices created from offers by extracting the offer TLV records and hashing them with the correct tag. - Expose a public offer_id() accessor on invoice. - Add tests to ensure the offer_id in the invoice matches the originating Offer, and that refund invoices have no offer_id. - All existing and new tests pass. This enables linking invoices to their originating offers in a robust and spec-compliant way. Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Signed-off-by: Vincenzo Palazzo --- lightning/src/offers/invoice.rs | 70 ++++++++++++++++++++++++-- lightning/src/offers/offer.rs | 4 +- lightning/src/offers/static_invoice.rs | 49 ++++++++++++++++-- 3 files changed, 115 insertions(+), 8 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 4add06169a9..198e544fefc 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -135,7 +135,7 @@ use crate::offers::merkle::{ }; use crate::offers::nonce::Nonce; use crate::offers::offer::{ - Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OfferTlvStream, + Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OfferId, OfferTlvStream, OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; @@ -686,6 +686,13 @@ macro_rules! unsigned_invoice_sign_method { ($self: ident, $self_type: ty $(, $s // Append the experimental bytes after the signature. $self.bytes.extend_from_slice(&$self.experimental_bytes); + let offer_id = match &$self.contents { + InvoiceContents::ForOffer { .. } => { + Some(OfferId::from_valid_bolt12_tlv_stream(&$self.bytes)) + }, + InvoiceContents::ForRefund { .. } => None, + }; + Ok(Bolt12Invoice { #[cfg(not(c_bindings))] bytes: $self.bytes, @@ -700,6 +707,7 @@ macro_rules! unsigned_invoice_sign_method { ($self: ident, $self_type: ty $(, $s tagged_hash: $self.tagged_hash, #[cfg(c_bindings)] tagged_hash: $self.tagged_hash.clone(), + offer_id, }) } } } @@ -734,6 +742,7 @@ pub struct Bolt12Invoice { contents: InvoiceContents, signature: Signature, tagged_hash: TaggedHash, + offer_id: Option, } /// The contents of an [`Bolt12Invoice`] for responding to either an [`Offer`] or a [`Refund`]. @@ -967,6 +976,13 @@ impl Bolt12Invoice { self.tagged_hash.as_digest().as_ref().clone() } + /// Returns the [`OfferId`] if this invoice corresponds to an [`Offer`]. + /// + /// [`Offer`]: crate::offers::offer::Offer + pub fn offer_id(&self) -> Option { + self.offer_id + } + /// Verifies that the invoice was for a request or refund created using the given key by /// checking the payer metadata from the invoice request. /// @@ -1631,7 +1647,11 @@ impl TryFrom> for Bolt12Invoice { let pubkey = contents.fields().signing_pubkey; merkle::verify_signature(&signature, &tagged_hash, pubkey)?; - Ok(Bolt12Invoice { bytes, contents, signature, tagged_hash }) + let offer_id = match &contents { + InvoiceContents::ForOffer { .. } => Some(OfferId::from_valid_bolt12_tlv_stream(&bytes)), + InvoiceContents::ForRefund { .. } => None, + }; + Ok(Bolt12Invoice { bytes, contents, signature, tagged_hash, offer_id }) } } @@ -1790,7 +1810,6 @@ mod tests { use bitcoin::script::ScriptBuf; use bitcoin::secp256k1::{self, Keypair, Message, Secp256k1, SecretKey, XOnlyPublicKey}; use bitcoin::{CompressedPublicKey, WitnessProgram, WitnessVersion}; - use core::time::Duration; use crate::blinded_path::message::BlindedMessagePath; @@ -3566,6 +3585,51 @@ mod tests { } } + #[test] + fn invoice_offer_id_matches_offer_id() { + 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 offer = OfferBuilder::new(recipient_pubkey()).amount_msats(1000).build().unwrap(); + + let offer_id = offer.id(); + + let invoice_request = offer + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .build_and_sign() + .unwrap(); + + let invoice = invoice_request + .respond_with_no_std(payment_paths(), payment_hash(), now()) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + assert_eq!(invoice.offer_id(), Some(offer_id)); + } + + #[test] + fn refund_invoice_has_no_offer_id() { + let refund = + RefundBuilder::new(vec![1; 32], payer_pubkey(), 1000).unwrap().build().unwrap(); + + let invoice = refund + .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now()) + .unwrap() + .build() + .unwrap() + .sign(recipient_sign) + .unwrap(); + + assert_eq!(invoice.offer_id(), None); + } + #[test] fn verifies_invoice_signature_with_tagged_hash() { let secp_ctx = Secp256k1::new(); diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index abd12ea1e9d..bdc9e7c8b87 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -128,7 +128,7 @@ impl OfferId { Self(tagged_hash.to_bytes()) } - fn from_valid_invreq_tlv_stream(bytes: &[u8]) -> Self { + pub(super) fn from_valid_bolt12_tlv_stream(bytes: &[u8]) -> Self { let tlv_stream = Offer::tlv_stream_iter(bytes); let tagged_hash = TaggedHash::from_tlv_stream(Self::ID_TAG, tlv_stream); Self(tagged_hash.to_bytes()) @@ -987,7 +987,7 @@ impl OfferContents { secp_ctx, )?; - let offer_id = OfferId::from_valid_invreq_tlv_stream(bytes); + let offer_id = OfferId::from_valid_bolt12_tlv_stream(bytes); Ok((offer_id, keys)) }, diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index d74ac282941..805a2ffe9f8 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -29,7 +29,7 @@ use crate::offers::merkle::{ use crate::offers::nonce::Nonce; use crate::offers::offer::{ Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, Offer, OfferContents, - OfferTlvStream, OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, + OfferId, OfferTlvStream, OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES, }; use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage}; use crate::types::features::{Bolt12InvoiceFeatures, OfferFeatures}; @@ -70,6 +70,7 @@ pub struct StaticInvoice { bytes: Vec, contents: InvoiceContents, signature: Signature, + offer_id: OfferId, } impl PartialEq for StaticInvoice { @@ -347,7 +348,8 @@ impl UnsignedStaticInvoice { // Append the experimental bytes after the signature. self.bytes.extend_from_slice(&self.experimental_bytes); - Ok(StaticInvoice { bytes: self.bytes, contents: self.contents, signature }) + let offer_id = OfferId::from_valid_bolt12_tlv_stream(&self.bytes); + Ok(StaticInvoice { bytes: self.bytes, contents: self.contents, signature, offer_id }) } invoice_accessors_common!(self, self.contents, UnsignedStaticInvoice); @@ -407,6 +409,13 @@ impl StaticInvoice { self.contents.is_offer_expired_no_std(duration_since_epoch) } + /// Returns the [`OfferId`] corresponding to the originating [`Offer`]. + /// + /// [`Offer`]: crate::offers::offer::Offer + pub fn offer_id(&self) -> OfferId { + self.offer_id + } + #[allow(unused)] // TODO: remove this once we remove the `async_payments` cfg flag pub(crate) fn is_from_same_offer(&self, invreq: &InvoiceRequest) -> bool { let invoice_offer_tlv_stream = @@ -642,7 +651,8 @@ impl TryFrom> for StaticInvoice { let pubkey = contents.signing_pubkey; merkle::verify_signature(&signature, &tagged_hash, pubkey)?; - Ok(StaticInvoice { bytes, contents, signature }) + let offer_id = OfferId::from_valid_bolt12_tlv_stream(&bytes); + Ok(StaticInvoice { bytes, contents, signature, offer_id }) } } @@ -1666,4 +1676,37 @@ mod tests { }, } } + + #[test] + fn static_invoice_offer_id_matches_offer_id() { + let node_id = recipient_pubkey(); + let payment_paths = payment_paths(); + let now = now(); + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + + let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) + .path(blinded_path()) + .build() + .unwrap(); + + let offer_id = offer.id(); + + let invoice = StaticInvoiceBuilder::for_offer_using_derived_keys( + &offer, + payment_paths.clone(), + vec![blinded_path()], + now, + &expanded_key, + nonce, + &secp_ctx, + ) + .unwrap() + .build_and_sign(&secp_ctx) + .unwrap(); + + assert_eq!(invoice.offer_id(), offer_id); + } }