From 923886d383a474e3d605706d01aae7516aafa441 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 16 Dec 2025 19:13:16 +0100 Subject: [PATCH 1/3] 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 f157b15aba95e17c9520eed1a95ea70580f4a419 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 16 Dec 2025 19:28:26 +0100 Subject: [PATCH 2/3] Add experimental TLV fields for invoice requests: invreq_contact_secret, invreq_payer_offer Signed-off-by: Vincenzo Palazzo --- lightning/src/offers/invoice.rs | 16 +++++++++++---- lightning/src/offers/invoice_request.rs | 27 +++++++++++++++++++------ lightning/src/offers/refund.rs | 12 +++++++++-- 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 6dfd6eac508..79065d51027 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1546,7 +1546,7 @@ type FullInvoiceTlvStreamRef<'a> = ( InvoiceTlvStreamRef<'a>, SignatureTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ExperimentalInvoiceTlvStreamRef, ); @@ -1590,7 +1590,7 @@ type PartialInvoiceTlvStreamRef<'a> = ( InvoiceRequestTlvStreamRef<'a>, InvoiceTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ExperimentalInvoiceTlvStreamRef, ); @@ -2014,7 +2014,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 }, ), ); @@ -2117,7 +2121,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 4311d194dca..c45f87b7578 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 dd2c3e2a92e..d727b0f1723 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -816,6 +816,8 @@ impl RefundContents { }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -861,7 +863,7 @@ type RefundTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl CursorReadable for RefundTlvStream { @@ -934,6 +936,8 @@ impl TryFrom for RefundContents { experimental_foo, }, ExperimentalInvoiceRequestTlvStream { + invreq_contact_secret: _, + invreq_payer_offer: _, #[cfg(test)] experimental_bar, }, @@ -1120,7 +1124,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 40bd9e479a9c8173ca7a6ecd5dc581fac4397219 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 17 Dec 2025 13:03:27 +0100 Subject: [PATCH 3/3] 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 | 81 +++++++++++++++++++++++- lightning/src/ln/offers_tests.rs | 12 ++++ lightning/src/offers/flow.rs | 40 ++++++++++++ lightning/src/offers/invoice_request.rs | 84 +++++++++++++++++++++++-- 5 files changed, 213 insertions(+), 6 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 f2e8fa70e4f..fdc07b435d4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -90,6 +90,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; @@ -698,6 +699,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 { @@ -709,6 +738,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, } } } @@ -13083,6 +13114,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) => { @@ -13324,6 +13382,8 @@ where payment_id, None, create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } @@ -13353,6 +13413,8 @@ where payment_id, Some(offer.hrn), create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } @@ -13395,6 +13457,8 @@ where payment_id, None, create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } @@ -13403,6 +13467,7 @@ where &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); @@ -13428,6 +13493,20 @@ where 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); @@ -16076,7 +16155,7 @@ where self.pending_outbound_payments .received_offer(payment_id, Some(retryable_invoice_request)) .map_err(|_| Bolt12SemanticError::DuplicatePaymentId) - }); + }, None, None); if offer_pay_res.is_err() { // The offer we tried to pay is the canonical current offer for the name we // wanted to pay. If we can't pay it, there's no way to recover so fail the diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 906d9e247ce..150751ad63d 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -683,6 +683,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)); @@ -841,6 +843,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)); @@ -962,6 +966,8 @@ fn pays_for_offer_without_blinded_paths() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, }); @@ -1229,6 +1235,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)); @@ -1294,6 +1302,8 @@ fn pays_bolt12_invoice_asynchronously() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, }); @@ -1391,6 +1401,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 94a4534c61a..2e580cc73fc 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -569,6 +569,46 @@ where 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> + where + ES::Target: EntropySource, + { + 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`]. The offer will expire at `absolute_expiry` if `Some`, diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index c45f87b7578..d804302b01c 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, }) } } @@ -3127,6 +3199,8 @@ mod tests { quantity: Some(1), payer_note_truncated: Some(UntrustedString(expected_payer_note)), human_readable_name: None, + contact_secret: None, + payer_offer: None, } );