Skip to content

Commit c5fe6a3

Browse files
offer: add OfferId to Bolt12Invoice
- Add an Option<OfferId> 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 <[email protected]>
1 parent 655b898 commit c5fe6a3

File tree

3 files changed

+156
-8
lines changed

3 files changed

+156
-8
lines changed

lightning/src/offers/invoice.rs

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ use crate::offers::merkle::{
135135
};
136136
use crate::offers::nonce::Nonce;
137137
use crate::offers::offer::{
138-
Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OfferTlvStream,
138+
Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OfferId, OfferTlvStream,
139139
OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES,
140140
};
141141
use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage};
@@ -686,6 +686,13 @@ macro_rules! unsigned_invoice_sign_method { ($self: ident, $self_type: ty $(, $s
686686
// Append the experimental bytes after the signature.
687687
$self.bytes.extend_from_slice(&$self.experimental_bytes);
688688

689+
let offer_id = match &$self.contents {
690+
InvoiceContents::ForOffer { .. } => {
691+
Some(OfferId::from_valid_bolt12_tlv_stream(&$self.bytes))
692+
},
693+
InvoiceContents::ForRefund { .. } => None,
694+
};
695+
689696
Ok(Bolt12Invoice {
690697
#[cfg(not(c_bindings))]
691698
bytes: $self.bytes,
@@ -700,6 +707,7 @@ macro_rules! unsigned_invoice_sign_method { ($self: ident, $self_type: ty $(, $s
700707
tagged_hash: $self.tagged_hash,
701708
#[cfg(c_bindings)]
702709
tagged_hash: $self.tagged_hash.clone(),
710+
offer_id,
703711
})
704712
}
705713
} }
@@ -734,6 +742,7 @@ pub struct Bolt12Invoice {
734742
contents: InvoiceContents,
735743
signature: Signature,
736744
tagged_hash: TaggedHash,
745+
offer_id: Option<OfferId>,
737746
}
738747

739748
/// The contents of an [`Bolt12Invoice`] for responding to either an [`Offer`] or a [`Refund`].
@@ -967,6 +976,13 @@ impl Bolt12Invoice {
967976
self.tagged_hash.as_digest().as_ref().clone()
968977
}
969978

979+
/// Returns the [`OfferId`] if this invoice corresponds to an [`Offer`].
980+
///
981+
/// [`Offer`]: crate::offers::offer::Offer
982+
pub fn offer_id(&self) -> Option<OfferId> {
983+
self.offer_id
984+
}
985+
970986
/// Verifies that the invoice was for a request or refund created using the given key by
971987
/// checking the payer metadata from the invoice request.
972988
///
@@ -1032,6 +1048,11 @@ impl Bolt12Invoice {
10321048
InvoiceContents::ForRefund { .. } => self.message_paths().is_empty(),
10331049
}
10341050
}
1051+
1052+
/// Returns the [`TaggedHash`] of the invoice that was signed.
1053+
pub fn tagged_hash(&self) -> &TaggedHash {
1054+
&self.tagged_hash
1055+
}
10351056
}
10361057

10371058
impl PartialEq for Bolt12Invoice {
@@ -1626,7 +1647,11 @@ impl TryFrom<ParsedMessage<FullInvoiceTlvStream>> for Bolt12Invoice {
16261647
let pubkey = contents.fields().signing_pubkey;
16271648
merkle::verify_signature(&signature, &tagged_hash, pubkey)?;
16281649

1629-
Ok(Bolt12Invoice { bytes, contents, signature, tagged_hash })
1650+
let offer_id = match &contents {
1651+
InvoiceContents::ForOffer { .. } => Some(OfferId::from_valid_bolt12_tlv_stream(&bytes)),
1652+
InvoiceContents::ForRefund { .. } => None,
1653+
};
1654+
Ok(Bolt12Invoice { bytes, contents, signature, tagged_hash, offer_id })
16301655
}
16311656
}
16321657

@@ -1785,7 +1810,6 @@ mod tests {
17851810
use bitcoin::script::ScriptBuf;
17861811
use bitcoin::secp256k1::{self, Keypair, Message, Secp256k1, SecretKey, XOnlyPublicKey};
17871812
use bitcoin::{CompressedPublicKey, WitnessProgram, WitnessVersion};
1788-
17891813
use core::time::Duration;
17901814

17911815
use crate::blinded_path::message::BlindedMessagePath;
@@ -3560,4 +3584,85 @@ mod tests {
35603584
),
35613585
}
35623586
}
3587+
3588+
#[test]
3589+
fn invoice_offer_id_matches_offer_id() {
3590+
let expanded_key = ExpandedKey::new([42; 32]);
3591+
let entropy = FixedEntropy {};
3592+
let nonce = Nonce::from_entropy_source(&entropy);
3593+
let secp_ctx = Secp256k1::new();
3594+
let payment_id = PaymentId([1; 32]);
3595+
3596+
let offer = OfferBuilder::new(recipient_pubkey()).amount_msats(1000).build().unwrap();
3597+
3598+
let offer_id = offer.id();
3599+
3600+
let invoice_request = offer
3601+
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)
3602+
.unwrap()
3603+
.build_and_sign()
3604+
.unwrap();
3605+
3606+
let invoice = invoice_request
3607+
.respond_with_no_std(payment_paths(), payment_hash(), now())
3608+
.unwrap()
3609+
.build()
3610+
.unwrap()
3611+
.sign(recipient_sign)
3612+
.unwrap();
3613+
3614+
assert_eq!(invoice.offer_id(), Some(offer_id));
3615+
}
3616+
3617+
#[test]
3618+
fn refund_invoice_has_no_offer_id() {
3619+
let refund =
3620+
RefundBuilder::new(vec![1; 32], payer_pubkey(), 1000).unwrap().build().unwrap();
3621+
3622+
let invoice = refund
3623+
.respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now())
3624+
.unwrap()
3625+
.build()
3626+
.unwrap()
3627+
.sign(recipient_sign)
3628+
.unwrap();
3629+
3630+
assert_eq!(invoice.offer_id(), None);
3631+
}
3632+
3633+
#[test]
3634+
fn verifies_invoice_signature_with_tagged_hash() {
3635+
let secp_ctx = Secp256k1::new();
3636+
let expanded_key = ExpandedKey::new([42; 32]);
3637+
let entropy = FixedEntropy {};
3638+
let nonce = Nonce::from_entropy_source(&entropy);
3639+
let node_id = recipient_pubkey();
3640+
let payment_paths = payment_paths();
3641+
let now = Duration::from_secs(123456);
3642+
let payment_id = PaymentId([1; 32]);
3643+
3644+
let offer = OfferBuilder::new(node_id)
3645+
.amount_msats(1000)
3646+
.build()
3647+
.unwrap();
3648+
3649+
let invoice_request = offer
3650+
.request_invoice(&expanded_key, nonce, &secp_ctx, payment_id)
3651+
.unwrap()
3652+
.build_and_sign()
3653+
.unwrap();
3654+
3655+
let invoice = invoice_request
3656+
.respond_with_no_std(payment_paths, payment_hash(), now)
3657+
.unwrap()
3658+
.build()
3659+
.unwrap()
3660+
.sign(recipient_sign)
3661+
.unwrap();
3662+
3663+
let issuer_sign_pubkey = offer.issuer_signing_pubkey().unwrap();
3664+
let tagged_hash = invoice.tagged_hash();
3665+
let signature = invoice.signature();
3666+
assert!(merkle::verify_signature(&signature, tagged_hash, issuer_sign_pubkey).is_ok());
3667+
}
35633668
}

lightning/src/offers/offer.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ impl OfferId {
128128
Self(tagged_hash.to_bytes())
129129
}
130130

131-
fn from_valid_invreq_tlv_stream(bytes: &[u8]) -> Self {
131+
pub(super) fn from_valid_bolt12_tlv_stream(bytes: &[u8]) -> Self {
132132
let tlv_stream = Offer::tlv_stream_iter(bytes);
133133
let tagged_hash = TaggedHash::from_tlv_stream(Self::ID_TAG, tlv_stream);
134134
Self(tagged_hash.to_bytes())
@@ -987,7 +987,7 @@ impl OfferContents {
987987
secp_ctx,
988988
)?;
989989

990-
let offer_id = OfferId::from_valid_invreq_tlv_stream(bytes);
990+
let offer_id = OfferId::from_valid_bolt12_tlv_stream(bytes);
991991

992992
Ok((offer_id, keys))
993993
},

lightning/src/offers/static_invoice.rs

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ use crate::offers::merkle::{
2929
use crate::offers::nonce::Nonce;
3030
use crate::offers::offer::{
3131
Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, Offer, OfferContents,
32-
OfferTlvStream, OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES,
32+
OfferId, OfferTlvStream, OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES,
3333
};
3434
use crate::offers::parse::{Bolt12ParseError, Bolt12SemanticError, ParsedMessage};
3535
use crate::types::features::{Bolt12InvoiceFeatures, OfferFeatures};
@@ -70,6 +70,7 @@ pub struct StaticInvoice {
7070
bytes: Vec<u8>,
7171
contents: InvoiceContents,
7272
signature: Signature,
73+
offer_id: OfferId,
7374
}
7475

7576
impl PartialEq for StaticInvoice {
@@ -347,7 +348,8 @@ impl UnsignedStaticInvoice {
347348
// Append the experimental bytes after the signature.
348349
self.bytes.extend_from_slice(&self.experimental_bytes);
349350

350-
Ok(StaticInvoice { bytes: self.bytes, contents: self.contents, signature })
351+
let offer_id = OfferId::from_valid_bolt12_tlv_stream(&self.bytes);
352+
Ok(StaticInvoice { bytes: self.bytes, contents: self.contents, signature, offer_id })
351353
}
352354

353355
invoice_accessors_common!(self, self.contents, UnsignedStaticInvoice);
@@ -407,6 +409,13 @@ impl StaticInvoice {
407409
self.contents.is_offer_expired_no_std(duration_since_epoch)
408410
}
409411

412+
/// Returns the [`OfferId`] corresponding to the originating [`Offer`].
413+
///
414+
/// [`Offer`]: crate::offers::offer::Offer
415+
pub fn offer_id(&self) -> OfferId {
416+
self.offer_id
417+
}
418+
410419
#[allow(unused)] // TODO: remove this once we remove the `async_payments` cfg flag
411420
pub(crate) fn is_from_same_offer(&self, invreq: &InvoiceRequest) -> bool {
412421
let invoice_offer_tlv_stream =
@@ -642,7 +651,8 @@ impl TryFrom<ParsedMessage<FullInvoiceTlvStream>> for StaticInvoice {
642651
let pubkey = contents.signing_pubkey;
643652
merkle::verify_signature(&signature, &tagged_hash, pubkey)?;
644653

645-
Ok(StaticInvoice { bytes, contents, signature })
654+
let offer_id = OfferId::from_valid_bolt12_tlv_stream(&bytes);
655+
Ok(StaticInvoice { bytes, contents, signature, offer_id })
646656
}
647657
}
648658

@@ -1666,4 +1676,37 @@ mod tests {
16661676
},
16671677
}
16681678
}
1679+
1680+
#[test]
1681+
fn static_invoice_offer_id_matches_offer_id() {
1682+
let node_id = recipient_pubkey();
1683+
let payment_paths = payment_paths();
1684+
let now = now();
1685+
let expanded_key = ExpandedKey::new([42; 32]);
1686+
let entropy = FixedEntropy {};
1687+
let nonce = Nonce::from_entropy_source(&entropy);
1688+
let secp_ctx = Secp256k1::new();
1689+
1690+
let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx)
1691+
.path(blinded_path())
1692+
.build()
1693+
.unwrap();
1694+
1695+
let offer_id = offer.id();
1696+
1697+
let invoice = StaticInvoiceBuilder::for_offer_using_derived_keys(
1698+
&offer,
1699+
payment_paths.clone(),
1700+
vec![blinded_path()],
1701+
now,
1702+
&expanded_key,
1703+
nonce,
1704+
&secp_ctx,
1705+
)
1706+
.unwrap()
1707+
.build_and_sign(&secp_ctx)
1708+
.unwrap();
1709+
1710+
assert_eq!(invoice.offer_id(), offer_id);
1711+
}
16691712
}

0 commit comments

Comments
 (0)