From 3139fae2598990ba6070dac66879d89be51bda2b Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 16 Dec 2025 19:13:16 +0100 Subject: [PATCH 1/4] Add contacts module for Lightning offer contact management Implements bLIP 42 contact secret derivation for mutual authentication in Lightning Network payments. - Add ContactSecret struct for TLV serialization with Readable/Writeable - Add ContactSecrets for managing primary and additional remote secrets - Add compute_contact_secret() for deterministic secret derivation - Support offers with issuer_signing_pubkey and blinded paths Signed-off-by: Vincenzo Palazzo --- lightning/src/offers/contacts.rs | 298 +++++++++++++++++++++++++++++++ lightning/src/offers/mod.rs | 1 + 2 files changed, 299 insertions(+) create mode 100644 lightning/src/offers/contacts.rs diff --git a/lightning/src/offers/contacts.rs b/lightning/src/offers/contacts.rs new file mode 100644 index 00000000000..caa323d7613 --- /dev/null +++ b/lightning/src/offers/contacts.rs @@ -0,0 +1,298 @@ +// 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. + +//! Data structures and utilities for managing Lightning Network contacts. +//! +//! Contacts are trusted people to which we may want to reveal our identity when paying them. +//! We're also able to figure out when incoming payments have been made by one of our contacts. +//! See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + +use crate::io::{self, Read}; +use crate::ln::msgs::DecodeError; +use crate::offers::offer::Offer; +use crate::offers::parse::Bolt12SemanticError; +use crate::util::ser::{Readable, Writeable, Writer}; +use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::secp256k1::Scalar; +use bitcoin::secp256k1::{Secp256k1, SecretKey}; + +#[allow(unused_imports)] +use crate::prelude::*; + +/// A contact secret used in experimental TLV fields for BLIP-42. +/// +/// This is a 32-byte secret that can be included in invoice requests to establish +/// contact relationships between Lightning nodes. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ContactSecret { + contents: [u8; 32], +} + +impl ContactSecret { + /// Creates a new [`ContactSecret`] from a 32-byte array. + pub fn new(contents: [u8; 32]) -> Self { + Self { contents } + } + + /// Returns the inner 32-byte array. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.contents + } +} + +impl From<[u8; 32]> for ContactSecret { + fn from(contents: [u8; 32]) -> Self { + Self { contents } + } +} + +impl AsRef<[u8; 32]> for ContactSecret { + fn as_ref(&self) -> &[u8; 32] { + &self.contents + } +} + +impl Readable for ContactSecret { + fn read(r: &mut R) -> Result { + let mut buf = [0u8; 32]; + r.read_exact(&mut buf)?; + Ok(ContactSecret { contents: buf }) + } +} + +impl Writeable for ContactSecret { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + w.write_all(&self.contents) + } +} + +/// Contact secrets are used to mutually authenticate payments. +/// +/// The first node to add the other to its contacts list will generate the `primary_secret` and +/// send it when paying. If the second node adds the first node to its contacts list from the +/// received payment, it will use the same `primary_secret` and both nodes are able to identify +/// payments from each other. +/// +/// But if the second node independently added the first node to its contacts list, it may have +/// generated a different `primary_secret`. Each node has a different `primary_secret`, but they +/// will store the other node's `primary_secret` in their `additional_remote_secrets`, which lets +/// them correctly identify payments. +/// +/// When sending a payment, we must always send the `primary_secret`. +/// When receiving payments, we must check if the received contact_secret matches either the +/// `primary_secret` or any of the `additional_remote_secrets`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContactSecrets { + primary_secret: ContactSecret, + additional_remote_secrets: Vec, +} + +impl ContactSecrets { + /// Creates a new [`ContactSecrets`] with the given primary secret. + pub fn new(primary_secret: ContactSecret) -> Self { + Self { primary_secret, additional_remote_secrets: Vec::new() } + } + + /// Creates a new [`ContactSecrets`] with the given primary secret and additional remote secrets. + pub fn with_additional_secrets( + primary_secret: ContactSecret, additional_remote_secrets: Vec, + ) -> Self { + Self { primary_secret, additional_remote_secrets } + } + + /// Returns the primary secret. + pub fn primary_secret(&self) -> &ContactSecret { + &self.primary_secret + } + + /// Returns the additional remote secrets. + pub fn additional_remote_secrets(&self) -> &[ContactSecret] { + &self.additional_remote_secrets + } + + /// This function should be used when we attribute an incoming payment to an existing contact. + /// + /// This can be necessary when: + /// - our contact added us without using the contact_secret we initially sent them + /// - our contact is using a different wallet from the one(s) we have already stored + pub fn add_remote_secret(&mut self, remote_secret: ContactSecret) { + if !self.additional_remote_secrets.contains(&remote_secret) { + self.additional_remote_secrets.push(remote_secret); + } + } + + /// Checks if the given secret matches either the primary secret or any additional remote secret. + pub fn matches(&self, secret: &ContactSecret) -> bool { + &self.primary_secret == secret || self.additional_remote_secrets.contains(secret) + } +} + +/// We derive our contact secret deterministically based on our offer and our contact's offer. +/// +/// This provides a few interesting properties: +/// - if we remove a contact and re-add it using the same offer, we will generate the same +/// contact secret +/// - if our contact is using the same deterministic algorithm with a single static offer, they +/// will also generate the same contact secret +/// +/// Note that this function must only be used when adding a contact that hasn't paid us before. +/// If we're adding a contact that paid us before, we must use the contact_secret they sent us, +/// which ensures that when we pay them, they'll be able to know it was coming from us (see +/// [`from_remote_secret`]). +/// +/// # Arguments +/// * `our_private_key` - The private key associated with our node identity +/// * `their_offer` - The offer from the contact +/// +/// # Errors +/// Returns [`Bolt12SemanticError::MissingSigningPubkey`] if the offer has neither an +/// issuer signing key nor a blinded path. +pub fn compute_contact_secret( + our_private_key: &SecretKey, their_offer: &Offer, +) -> Result { + let offer_node_id = if let Some(issuer) = their_offer.issuer_signing_pubkey() { + // If the offer has an issuer signing key, use it + issuer + } else { + // Otherwise, use the last node in the first blinded path (if any) + their_offer + .paths() + .iter() + .filter_map(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .next() + .ok_or(Bolt12SemanticError::MissingSigningPubkey)? + }; + // Compute ECDH shared secret (multiply their public key by our private key) + let scalar: Scalar = our_private_key.clone().into(); + let secp = Secp256k1::verification_only(); + let ecdh = offer_node_id.mul_tweak(&secp, &scalar).expect("Multiply"); + // Hash the shared secret with the bLIP 42 tag + let mut engine = sha256::Hash::engine(); + engine.input(b"blip42_contact_secret"); + engine.input(&ecdh.serialize()); + let primary_secret = ContactSecret::new(sha256::Hash::from_engine(engine).to_byte_array()); + + Ok(ContactSecrets::new(primary_secret)) +} + +/// When adding a contact from which we've received a payment, we must use the contact_secret +/// they sent us: this ensures that they'll be able to identify payments coming from us. +pub fn from_remote_secret(remote_secret: ContactSecret) -> ContactSecrets { + ContactSecrets::new(remote_secret) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::{hex::DisplayHex, secp256k1::Secp256k1}; + use core::str::FromStr; + + // FIXME: there is a better way to have test vectors? Loading them from + // the json file for instance? + + // derive deterministic contact_secret when both offers use blinded paths only + #[test] + fn test_compute_contact_secret_test_vector_blinded_paths() { + let alice_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h"; + let alice_priv_key = + SecretKey::from_str("4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb") + .unwrap(); + let alice_offer = Offer::from_str(alice_offer_str).unwrap(); + + assert!(alice_offer.issuer_signing_pubkey().is_none()); + assert_eq!(alice_offer.paths().len(), 1); + + let alice_offer_node_id = alice_offer + .paths() + .iter() + .filter_map(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .collect::>(); + let alice_offer_node_id = alice_offer_node_id.first().unwrap(); + assert_eq!( + alice_offer_node_id.to_string(), + "0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9" + ); + + let bob_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qj"; + let bob_priv_key = + SecretKey::from_str("12afb8248c7336e6aea5fe247bc4bac5dcabfb6017bd67b32c8195a6c56b8333") + .unwrap(); + let bob_offer = Offer::from_str(bob_offer_str).unwrap(); + assert!(bob_offer.issuer_signing_pubkey().is_none()); + assert_eq!(bob_offer.paths().len(), 1); + + let bob_offer_node_id = bob_offer + .paths() + .iter() + .filter_map(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .collect::>(); + let bob_offer_node_id = bob_offer_node_id.first().unwrap(); + assert_eq!( + bob_offer_node_id.to_string(), + "035e4d1b7237898390e7999b6835ef83cd93b98200d599d29075b45ab0fedc2b34" + ); + + let alice_computed = compute_contact_secret(&alice_priv_key, &bob_offer).unwrap(); + let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer).unwrap(); + + assert_eq!( + alice_computed.primary_secret().as_bytes().to_hex_string(bitcoin::hex::Case::Lower), + "810641fab614f8bc1441131dc50b132fd4d1e2ccd36f84b887bbab3a6d8cc3d8".to_owned() + ); + assert_eq!(alice_computed, bob_computed); + } + + // derive deterministic contact_secret when one offer uses both blinded paths and issuer_id + #[test] + fn test_compute_contact_secret_test_vector_blinded_paths_and_issuer_id() { + let alice_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h"; + let alice_priv_key = + SecretKey::from_str("4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb") + .unwrap(); + let alice_offer = Offer::from_str(alice_offer_str).unwrap(); + + assert!(alice_offer.issuer_signing_pubkey().is_none()); + assert_eq!(alice_offer.paths().len(), 1); + + let alice_offer_node_id = alice_offer + .paths() + .iter() + .filter_map(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .collect::>(); + let alice_offer_node_id = alice_offer_node_id.first().unwrap(); + assert_eq!( + alice_offer_node_id.to_string(), + "0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9" + ); + + let bob_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qjzcssy065ctv38c5h03lu0hlvq2t4p5fg6u668y6pmzcg64hmdm050jxx"; + let bob_priv_key = + SecretKey::from_str("bcaafa8ed73da11437ce58c7b3458567a870168c0da325a40292fed126b97845") + .unwrap(); + let bob_offer = Offer::from_str(bob_offer_str).unwrap(); + let bob_offer_node_id = bob_offer.issuer_signing_pubkey().unwrap(); + assert_eq!( + bob_offer_node_id.to_string(), + "023f54c2d913e2977c7fc7dfec029750d128d735a39341d8b08d56fb6edf47c8c6" + ); + + let alice_computed = compute_contact_secret(&alice_priv_key, &bob_offer).unwrap(); + let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer).unwrap(); + + assert_eq!( + alice_computed.primary_secret().as_bytes().to_hex_string(bitcoin::hex::Case::Lower), + "4e0aa72cc42eae9f8dc7c6d2975bbe655683ada2e9abfdfe9f299d391ed9736c".to_owned() + ); + assert_eq!(alice_computed, bob_computed); + } +} diff --git a/lightning/src/offers/mod.rs b/lightning/src/offers/mod.rs index 5b5cf6cdc78..95e2bb046c0 100644 --- a/lightning/src/offers/mod.rs +++ b/lightning/src/offers/mod.rs @@ -17,6 +17,7 @@ pub mod offer; pub mod flow; pub mod async_receive_offer_cache; +pub mod contacts; pub mod invoice; pub mod invoice_error; mod invoice_macros; From 8861e8400a810112702a2630097469afdc3c2397 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 16 Dec 2025 19:28:26 +0100 Subject: [PATCH 2/4] Add experimental TLV fields for invoice requests: invreq_contact_secret, invreq_payer_offer Signed-off-by: Vincenzo Palazzo --- lightning/src/ln/offers_tests.rs | 2 ++ lightning/src/offers/invoice.rs | 16 +++++++++++---- lightning/src/offers/invoice_request.rs | 27 +++++++++++++++++++------ lightning/src/offers/refund.rs | 12 +++++++++-- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index de08af5d276..140a03165a3 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2647,6 +2647,8 @@ fn creates_and_pays_for_phantom_offer() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, }); diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..36649c95a62 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1573,7 +1573,7 @@ type FullInvoiceTlvStreamRef<'a> = ( InvoiceTlvStreamRef<'a>, SignatureTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ExperimentalInvoiceTlvStreamRef, ); @@ -1617,7 +1617,7 @@ type PartialInvoiceTlvStreamRef<'a> = ( InvoiceRequestTlvStreamRef<'a>, InvoiceTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ExperimentalInvoiceTlvStreamRef, ); @@ -2041,7 +2041,11 @@ mod tests { }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, - ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, + ExperimentalInvoiceRequestTlvStreamRef { + experimental_bar: None, + invreq_contact_secret: None, + invreq_payer_offer: None, + }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ), ); @@ -2144,7 +2148,11 @@ mod tests { }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, - ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, + ExperimentalInvoiceRequestTlvStreamRef { + experimental_bar: None, + invreq_contact_secret: None, + invreq_payer_offer: None, + }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ), ); diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 7805882ef73..1a11e1f5812 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -500,7 +500,10 @@ impl UnsignedInvoiceRequest { invoice_request_tlv_stream.write(&mut bytes).unwrap(); - const EXPERIMENTAL_TLV_ALLOCATION_SIZE: usize = 0; + // Allocate sufficient capacity for experimental TLV fields to avoid reallocations. + // The new fields (invreq_contact_secret: ~48 bytes, invreq_payer_offer: ~116 bytes) + // total ~164 bytes, with 600 providing headroom for future experimental fields. + const EXPERIMENTAL_TLV_ALLOCATION_SIZE: usize = 600; let mut experimental_bytes = Vec::with_capacity(EXPERIMENTAL_TLV_ALLOCATION_SIZE); let experimental_tlv_stream = @@ -1225,6 +1228,8 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -1291,9 +1296,11 @@ pub(super) const EXPERIMENTAL_INVOICE_REQUEST_TYPES: core::ops::Range = #[cfg(not(test))] tlv_stream!( ExperimentalInvoiceRequestTlvStream, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, EXPERIMENTAL_INVOICE_REQUEST_TYPES, { + (2_000_001_729, invreq_contact_secret: [u8; 32]), + (2_000_001_731, invreq_payer_offer: (Vec, WithoutLength)), // When adding experimental TLVs, update EXPERIMENTAL_TLV_ALLOCATION_SIZE accordingly in // UnsignedInvoiceRequest::new to avoid unnecessary allocations. } @@ -1301,8 +1308,10 @@ tlv_stream!( #[cfg(test)] tlv_stream!( - ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceRequestTlvStreamRef<'a>, EXPERIMENTAL_INVOICE_REQUEST_TYPES, { + (2_000_001_729, invreq_contact_secret: [u8; 32]), + (2_000_001_731, invreq_payer_offer: (Vec, WithoutLength)), (2_999_999_999, experimental_bar: (u64, HighZeroBytesDroppedBigSize)), } ); @@ -1322,7 +1331,7 @@ type FullInvoiceRequestTlvStreamRef<'a> = ( InvoiceRequestTlvStreamRef<'a>, SignatureTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl CursorReadable for FullInvoiceRequestTlvStream { @@ -1358,7 +1367,7 @@ type PartialInvoiceRequestTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl TryFrom> for UnsignedInvoiceRequest { @@ -1437,6 +1446,8 @@ impl TryFrom for InvoiceRequestContents { }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { + invreq_contact_secret: _, + invreq_payer_offer: _, #[cfg(test)] experimental_bar, }, @@ -1660,7 +1671,11 @@ mod tests { }, SignatureTlvStreamRef { signature: Some(&invoice_request.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, - ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, + ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, + experimental_bar: None, + }, ), ); diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index c0fd9dfdd3e..7495c559261 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -809,6 +809,8 @@ impl RefundContents { }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -854,7 +856,7 @@ type RefundTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl CursorReadable for RefundTlvStream { @@ -927,6 +929,8 @@ impl TryFrom for RefundContents { experimental_foo, }, ExperimentalInvoiceRequestTlvStream { + invreq_contact_secret: _, + invreq_payer_offer: _, #[cfg(test)] experimental_bar, }, @@ -1113,7 +1117,11 @@ mod tests { offer_from_hrn: None, }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, - ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, + ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, + experimental_bar: None, + }, ), ); From 11801716d528ad98f9d6e76fc1ea3322e81b9707 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 17 Dec 2025 13:03:27 +0100 Subject: [PATCH 3/4] blip42: Add contact secret and payer offer support to invoice requests Implements BLIP-42 contact management for the sender side: - Add contact_secret and payer_offer fields to InvoiceRequestContents - Add builder methods: contact_secrets(), payer_offer() - Add accessor methods: contact_secret(), payer_offer() - Add OptionalOfferPaymentParams fields for contact_secrects and payer_offer - Update ChannelManager::pay_for_offer to pass contact information - Add create_compact_offer_builder to OffersMessageFlow for small payer offers - Update tests to include new InvoiceRequestFields Signed-off-by: Vincenzo Palazzo --- fuzz/src/invoice_request_deser.rs | 2 + lightning/src/ln/channelmanager.rs | 79 +++++++++++++++++++++++ lightning/src/ln/offers_tests.rs | 12 ++++ lightning/src/offers/flow.rs | 34 ++++++++++ lightning/src/offers/invoice_request.rs | 84 +++++++++++++++++++++++-- 5 files changed, 206 insertions(+), 5 deletions(-) diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index a21303debd7..7c9c2bd7168 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -98,6 +98,8 @@ fn build_response( .payer_note() .map(|s| UntrustedString(s.to_string())), human_readable_name: None, + contact_secret: None, + payer_offer: None, } }; diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 19fd2f96797..a4d0d46533e 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -94,6 +94,7 @@ use crate::ln::outbound_payment::{ }; use crate::ln::types::ChannelId; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; +use crate::offers::contacts::ContactSecrets; use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow}; use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use crate::offers::invoice_error::InvoiceError; @@ -773,6 +774,34 @@ pub struct OptionalOfferPaymentParams { /// will ultimately fail once all pending paths have failed (generating an /// [`Event::PaymentFailed`]). pub retry_strategy: Retry, + /// Contact secrets to include in the invoice request for BLIP-42 contact management. + /// If provided, these secrets will be used to establish a contact relationship with the recipient. + pub contact_secrets: Option, + /// A custom payer offer to include in the invoice request for BLIP-42 contact management. + /// + /// If provided, this offer will be included in the invoice request, allowing the recipient to + /// contact you back. If `None`, **no payer offer will be included** in the invoice request. + /// + /// You can create custom offers using [`OffersMessageFlow::create_compact_offer_builder`]: + /// - Pass `None` for no blinded path (smallest size, ~70 bytes) + /// - Pass `Some(intro_node_id)` for a single blinded path (~200 bytes) + /// + /// # Example + /// ```rust,ignore + /// // Include a compact offer with a single blinded path + /// let payer_offer = flow.create_compact_offer_builder( + /// &entropy_source, + /// Some(trusted_peer_pubkey) + /// )?.build()?; + /// + /// let params = OptionalOfferPaymentParams { + /// payer_offer: Some(payer_offer), + /// ..Default::default() + /// }; + /// ``` + /// + /// [`OffersMessageFlow::create_compact_offer_builder`]: crate::offers::flow::OffersMessageFlow::create_compact_offer_builder + pub payer_offer: Option, } impl Default for OptionalOfferPaymentParams { @@ -784,6 +813,8 @@ impl Default for OptionalOfferPaymentParams { retry_strategy: Retry::Timeout(core::time::Duration::from_secs(2)), #[cfg(not(feature = "std"))] retry_strategy: Retry::Attempts(3), + contact_secrets: None, + payer_offer: None, } } } @@ -14619,6 +14650,33 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { Ok(builder.into()) } + + /// Creates a compact [`OfferBuilder`] suitable for BLIP-42's `payer_offer` field. + /// + /// This creates an offer with minimal size by either: + /// - Having no blinded paths when `intro_node_id` is `None` (for public nodes) + /// - Having a single one-hop blinded path when `intro_node_id` is `Some` (for private nodes) + /// + /// The compact format is ideal for encoding in invoice request fields where space is limited. + /// + /// # Privacy + /// + /// Uses a derived signing pubkey in the offer for recipient privacy. + /// + /// # Errors + /// + /// Errors if a blinded path cannot be created when `intro_node_id` is provided. + /// + /// [`Offer`]: crate::offers::offer::Offer + pub fn create_compact_offer_builder( + &$self, intro_node_id: Option, + ) -> Result<$builder, Bolt12SemanticError> { + let builder = $self.flow.create_compact_offer_builder( + &$self.entropy_source, intro_node_id + )?; + + Ok(builder.into()) + } } } macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { @@ -14854,6 +14912,8 @@ impl< payment_id, None, create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } @@ -14883,6 +14943,8 @@ impl< payment_id, Some(offer.hrn), create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } @@ -14925,6 +14987,8 @@ impl< payment_id, None, create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } @@ -14933,6 +14997,7 @@ impl< &self, offer: &Offer, quantity: Option, amount_msats: Option, payer_note: Option, payment_id: PaymentId, human_readable_name: Option, create_pending_payment: CPP, + contacts: Option, payer_offer: Option, ) -> Result<(), Bolt12SemanticError> { let entropy = &self.entropy_source; let nonce = Nonce::from_entropy_source(entropy); @@ -14958,6 +15023,20 @@ impl< Some(hrn) => builder.sourced_from_human_readable_name(hrn), }; + let builder = if let Some(secrets) = contacts.as_ref() { + builder.contact_secrets(secrets.clone()) + } else { + builder + }; + + // Add payer offer only if provided by the user. + // If the user explicitly wants to include an offer, they should provide it via payer_offer parameter. + let builder = if let Some(offer) = payer_offer { + builder.payer_offer(&offer) + } else { + builder + }; + let invoice_request = builder.build_and_sign()?; let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 140a03165a3..ee6d7fd7468 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -727,6 +727,8 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -885,6 +887,8 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -1006,6 +1010,8 @@ fn pays_for_offer_without_blinded_paths() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, }); @@ -1274,6 +1280,8 @@ fn creates_and_pays_for_offer_with_retry() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); @@ -1339,6 +1347,8 @@ fn pays_bolt12_invoice_asynchronously() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, }); @@ -1436,6 +1446,8 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, }); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 6c1b7a5befe..9a0e9b4b9ba 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -572,6 +572,40 @@ impl OffersMessageFlow { Ok((builder.into(), nonce)) } + /// Creates a minimal [`OfferBuilder`] with derived metadata and an optional blinded path. + /// + /// If `intro_node_id` is `None`, creates an offer with no blinded paths (~70 bytes) suitable + /// for scenarios like BLIP-42 where the payer intentionally shares their contact info. + /// + /// If `intro_node_id` is `Some`, creates an offer with a single blinded path (~200 bytes) + /// providing privacy/routability for unannounced nodes. The intro node must be a public + /// peer (routable via gossip) with an outbound channel. + /// + /// # Privacy + /// + /// - `None`: Exposes the derived signing pubkey directly without blinded path privacy + /// - `Some`: Intro node learns payer identity (choose trusted/routable peer) + /// + /// This is not exported to bindings users as builder patterns don't map outside of move semantics. + pub fn create_compact_offer_builder( + &self, entropy_source: ES, intro_node_id: Option, + ) -> Result, Bolt12SemanticError> { + match intro_node_id { + None => { + // Use the internal builder but don't add any paths + self.create_offer_builder_intern(&entropy_source, |_, _, _| Ok(core::iter::empty())) + .map(|(builder, _)| builder) + }, + Some(node_id) => { + // Delegate to create_offer_builder with a single-peer list to reuse the router logic + self.create_offer_builder( + entropy_source, + vec![MessageForwardNode { node_id, short_channel_id: None }], + ) + }, + } + } + /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by the /// [`OffersMessageFlow`], and any corresponding [`InvoiceRequest`] can be verified using /// [`Self::verify_invoice_request`]. diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 1a11e1f5812..e0cd59bf3ae 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -186,7 +186,7 @@ macro_rules! invoice_request_builder_methods { ( InvoiceRequestContentsWithoutPayerSigningPubkey { payer: PayerContents(metadata), offer, chain: None, amount_msats: None, features: InvoiceRequestFeatures::empty(), quantity: None, payer_note: None, - offer_from_hrn: None, + offer_from_hrn: None, invreq_contact_secret: None, invreq_payer_offer: None, #[cfg(test)] experimental_bar: None, } @@ -255,6 +255,29 @@ macro_rules! invoice_request_builder_methods { ( $return_value } + /// Sets the contact secret for BLIP-42 contact authentication. + /// + /// This will include the primary secret from the [`ContactSecrets`] in the invoice request. + /// + /// Successive calls to this method will override the previous setting. + /// + /// [`ContactSecrets`]: crate::offers::contacts::ContactSecrets + pub fn contact_secrets($($self_mut)* $self: $self_type, contact_secrets: crate::offers::contacts::ContactSecrets) -> $return_type { + $self.invoice_request.invreq_contact_secret = Some(*contact_secrets.primary_secret().as_bytes()); + $return_value + } + + /// Sets the payer's offer for BLIP-42 contact management. + /// + /// This will include the serialized offer bytes in the invoice request, + /// allowing the recipient to identify which offer the payer is responding to. + /// + /// Successive calls to this method will override the previous setting. + pub fn payer_offer($($self_mut)* $self: $self_type, offer: &Offer) -> $return_type { + $self.invoice_request.invreq_payer_offer = Some(offer.bytes.clone()); + $return_value + } + fn build_with_checks($($self_mut)* $self: $self_type) -> Result< (UnsignedInvoiceRequest, Option, Option<&'b Secp256k1<$secp_context>>), Bolt12SemanticError @@ -689,6 +712,8 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { quantity: Option, payer_note: Option, offer_from_hrn: Option, + invreq_contact_secret: Option<[u8; 32]>, + invreq_payer_offer: Option>, #[cfg(test)] experimental_bar: Option, } @@ -750,6 +775,16 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { pub fn offer_from_hrn(&$self) -> &Option { $contents.offer_from_hrn() } + + /// Returns the contact secret if present in the invoice request. + pub fn contact_secret(&$self) -> Option<[u8; 32]> { + $contents.contact_secret() + } + + /// Returns the payer offer if present in the invoice request. + pub fn payer_offer(&$self) -> Option { + $contents.payer_offer() + } } } impl UnsignedInvoiceRequest { @@ -1057,6 +1092,10 @@ macro_rules! fields_accessor { }, } = &$inner; + // Extract BLIP-42 contact information if present + let contact_secret = $self.contact_secret(); + let payer_offer = $self.payer_offer(); + InvoiceRequestFields { payer_signing_pubkey: *payer_signing_pubkey, quantity: *quantity, @@ -1066,6 +1105,8 @@ macro_rules! fields_accessor { // down to the nearest valid UTF-8 code point boundary. .map(|s| UntrustedString(string_truncate_safe(s, PAYER_NOTE_LIMIT))), human_readable_name: $self.offer_from_hrn().clone(), + contact_secret, + payer_offer, } } }; @@ -1182,6 +1223,17 @@ impl InvoiceRequestContents { &self.inner.offer_from_hrn } + pub(super) fn contact_secret(&self) -> Option<[u8; 32]> { + self.inner.invreq_contact_secret + } + + pub(super) fn payer_offer(&self) -> Option { + self.inner + .invreq_payer_offer + .as_ref() + .and_then(|bytes| crate::offers::offer::Offer::try_from(bytes.clone()).ok()) + } + pub(super) fn as_tlv_stream(&self) -> PartialInvoiceRequestTlvStreamRef<'_> { let (payer, offer, mut invoice_request, experimental_offer, experimental_invoice_request) = self.inner.as_tlv_stream(); @@ -1228,8 +1280,8 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { - invreq_contact_secret: None, - invreq_payer_offer: None, + invreq_contact_secret: self.invreq_contact_secret.as_ref(), + invreq_payer_offer: self.invreq_payer_offer.as_ref(), #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -1446,8 +1498,8 @@ impl TryFrom for InvoiceRequestContents { }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { - invreq_contact_secret: _, - invreq_payer_offer: _, + invreq_contact_secret, + invreq_payer_offer, #[cfg(test)] experimental_bar, }, @@ -1491,6 +1543,8 @@ impl TryFrom for InvoiceRequestContents { quantity, payer_note, offer_from_hrn, + invreq_contact_secret, + invreq_payer_offer, #[cfg(test)] experimental_bar, }, @@ -1516,6 +1570,14 @@ pub struct InvoiceRequestFields { /// The Human Readable Name which the sender indicated they were paying to. pub human_readable_name: Option, + + /// BLIP-42: The contact secret included by the payer for contact management. + /// This allows the recipient to establish a contact relationship with the payer. + pub contact_secret: Option<[u8; 32]>, + + /// BLIP-42: The payer's minimal offer included in the invoice request. + /// This is a compact offer (just node_id) to fit within payment onion size constraints. + pub payer_offer: Option, } /// The maximum number of characters included in [`InvoiceRequestFields::payer_note_truncated`]. @@ -1528,11 +1590,14 @@ pub const PAYER_NOTE_LIMIT: usize = 8; impl Writeable for InvoiceRequestFields { fn write(&self, writer: &mut W) -> Result<(), io::Error> { + let payer_offer_bytes = self.payer_offer.as_ref().map(|offer| offer.as_ref().to_vec()); write_tlv_fields!(writer, { (0, self.payer_signing_pubkey, required), (1, self.human_readable_name, option), (2, self.quantity.map(|v| HighZeroBytesDroppedBigSize(v)), option), (4, self.payer_note_truncated.as_ref().map(|s| WithoutLength(&s.0)), option), + (6, self.contact_secret, option), + (8, payer_offer_bytes.as_ref().map(|v| WithoutLength(&v[..])), option), }); Ok(()) } @@ -1545,13 +1610,20 @@ impl Readable for InvoiceRequestFields { (1, human_readable_name, option), (2, quantity, (option, encoding: (u64, HighZeroBytesDroppedBigSize))), (4, payer_note_truncated, (option, encoding: (String, WithoutLength))), + (6, contact_secret, option), + (8, payer_offer_bytes, (option, encoding: (Vec, WithoutLength))), }); + let payer_offer = + payer_offer_bytes.and_then(|bytes| crate::offers::offer::Offer::try_from(bytes).ok()); + Ok(InvoiceRequestFields { payer_signing_pubkey: payer_signing_pubkey.0.unwrap(), quantity, payer_note_truncated: payer_note_truncated.map(|s| UntrustedString(s)), human_readable_name, + contact_secret, + payer_offer, }) } } @@ -3133,6 +3205,8 @@ mod tests { quantity: Some(1), payer_note_truncated: Some(UntrustedString(expected_payer_note)), human_readable_name: None, + contact_secret: None, + payer_offer: None, } ); From e409d396645d6ec35105b0d442cd2815dc76889d Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Sat, 16 May 2026 11:06:05 +0200 Subject: [PATCH 4/4] offers: introduce ContactSecret newtype and BLIP 42 TLV-type consts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `Option<[u8; 32]>` with `Option` across the invoice_request internal storage, the `InvoiceRequest::contact_secret()` accessor, and the `InvoiceRequestFields` round-trip type. The wire format is unchanged (`ContactSecret` writes/reads the same 32 raw bytes via its existing `Writeable`/`Readable` impls), so this is a pure type-system refactor. Add `INVREQ_CONTACT_SECRET_TYPE` and `INVREQ_PAYER_OFFER_TYPE` `pub(super)` constants in `offers::contacts`, mirroring the pattern of `INVOICE_REQUEST_PAYER_ID_TYPE` and the other TLV-type constants in `offers/`. Replace the numeric literals in `ExperimentalInvoiceRequestTlvStream` with these constants so the BLIP 42 TLV numbers are defined in exactly one place. Also document BLIP 42's anti-redirection rule on the public `InvoiceRequestFields::contact_secret` field, since enforcement is the application's responsibility — LDK does not own a contacts store. Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/offers/contacts.rs | 8 ++++++++ lightning/src/offers/invoice_request.rs | 24 +++++++++++++++--------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/lightning/src/offers/contacts.rs b/lightning/src/offers/contacts.rs index caa323d7613..4ed013fa96d 100644 --- a/lightning/src/offers/contacts.rs +++ b/lightning/src/offers/contacts.rs @@ -25,6 +25,14 @@ use bitcoin::secp256k1::{Secp256k1, SecretKey}; #[allow(unused_imports)] use crate::prelude::*; +/// TLV record type for the `invreq_contact_secret` field defined in +/// [BLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md). +pub(super) const INVREQ_CONTACT_SECRET_TYPE: u64 = 2_000_001_729; + +/// TLV record type for the `invreq_payer_offer` field defined in +/// [BLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md). +pub(super) const INVREQ_PAYER_OFFER_TYPE: u64 = 2_000_001_731; + /// A contact secret used in experimental TLV fields for BLIP-42. /// /// This is a 32-byte secret that can be included in invoice requests to establish diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index e0cd59bf3ae..ba71da37a06 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -71,6 +71,7 @@ use crate::io; use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::{ExpandedKey, IV_LEN}; use crate::ln::msgs::DecodeError; +use crate::offers::contacts::{ContactSecret, INVREQ_CONTACT_SECRET_TYPE, INVREQ_PAYER_OFFER_TYPE}; use crate::offers::invoice::{DerivedSigningPubkey, ExplicitSigningPubkey, SigningPubkeyStrategy}; use crate::offers::merkle::{ self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, @@ -263,7 +264,7 @@ macro_rules! invoice_request_builder_methods { ( /// /// [`ContactSecrets`]: crate::offers::contacts::ContactSecrets pub fn contact_secrets($($self_mut)* $self: $self_type, contact_secrets: crate::offers::contacts::ContactSecrets) -> $return_type { - $self.invoice_request.invreq_contact_secret = Some(*contact_secrets.primary_secret().as_bytes()); + $self.invoice_request.invreq_contact_secret = Some(*contact_secrets.primary_secret()); $return_value } @@ -712,7 +713,7 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { quantity: Option, payer_note: Option, offer_from_hrn: Option, - invreq_contact_secret: Option<[u8; 32]>, + invreq_contact_secret: Option, invreq_payer_offer: Option>, #[cfg(test)] experimental_bar: Option, @@ -777,7 +778,7 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { } /// Returns the contact secret if present in the invoice request. - pub fn contact_secret(&$self) -> Option<[u8; 32]> { + pub fn contact_secret(&$self) -> Option { $contents.contact_secret() } @@ -1223,7 +1224,7 @@ impl InvoiceRequestContents { &self.inner.offer_from_hrn } - pub(super) fn contact_secret(&self) -> Option<[u8; 32]> { + pub(super) fn contact_secret(&self) -> Option { self.inner.invreq_contact_secret } @@ -1351,8 +1352,8 @@ tlv_stream!( ExperimentalInvoiceRequestTlvStreamRef<'a>, EXPERIMENTAL_INVOICE_REQUEST_TYPES, { - (2_000_001_729, invreq_contact_secret: [u8; 32]), - (2_000_001_731, invreq_payer_offer: (Vec, WithoutLength)), + (INVREQ_CONTACT_SECRET_TYPE, invreq_contact_secret: ContactSecret), + (INVREQ_PAYER_OFFER_TYPE, invreq_payer_offer: (Vec, WithoutLength)), // When adding experimental TLVs, update EXPERIMENTAL_TLV_ALLOCATION_SIZE accordingly in // UnsignedInvoiceRequest::new to avoid unnecessary allocations. } @@ -1362,8 +1363,8 @@ tlv_stream!( tlv_stream!( ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceRequestTlvStreamRef<'a>, EXPERIMENTAL_INVOICE_REQUEST_TYPES, { - (2_000_001_729, invreq_contact_secret: [u8; 32]), - (2_000_001_731, invreq_payer_offer: (Vec, WithoutLength)), + (INVREQ_CONTACT_SECRET_TYPE, invreq_contact_secret: ContactSecret), + (INVREQ_PAYER_OFFER_TYPE, invreq_payer_offer: (Vec, WithoutLength)), (2_999_999_999, experimental_bar: (u64, HighZeroBytesDroppedBigSize)), } ); @@ -1573,7 +1574,12 @@ pub struct InvoiceRequestFields { /// BLIP-42: The contact secret included by the payer for contact management. /// This allows the recipient to establish a contact relationship with the payer. - pub contact_secret: Option<[u8; 32]>, + /// + /// Per BLIP 42, if this matches an existing contact of the recipient, the recipient MUST + /// ignore [`Self::payer_offer`] (and any future `bip_353_name` field) to prevent a leaked + /// `contact_secret` from being used to redirect future payments. Enforcement of this rule + /// is the responsibility of the application that owns the contacts list. + pub contact_secret: Option, /// BLIP-42: The payer's minimal offer included in the invoice request. /// This is a compact offer (just node_id) to fit within payment onion size constraints.