From 7f2d8064c7405eb857321afc2bd3d39151d9198a Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 24 Sep 2025 16:58:27 +0200 Subject: [PATCH 1/9] Add experimental TLV fields for invoice requests: invreq_contact_secret, invreq_payer_offer, invreq_payer_bip_353_name Signed-off-by: Vincenzo Palazzo --- lightning/src/offers/invoice.rs | 18 +++++++-- lightning/src/offers/invoice_request.rs | 54 ++++++++++++++++++++++--- lightning/src/offers/refund.rs | 15 ++++++- 3 files changed, 75 insertions(+), 12 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 6dfd6eac508..5fc833a5630 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,12 @@ 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, + invreq_payer_bip_353_name: None, + }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ), ); @@ -2117,7 +2122,12 @@ 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, + invreq_payer_bip_353_name: None, + }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ), ); diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 4311d194dca..ab4d37634ea 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -68,6 +68,7 @@ use crate::blinded_path::message::BlindedMessagePath; use crate::blinded_path::payment::BlindedPaymentPath; use crate::io; +use crate::io::Read; use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::{ExpandedKey, IV_LEN}; use crate::ln::msgs::DecodeError; @@ -500,7 +501,11 @@ 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, + // invreq_payer_bip_353_name: ~116 bytes) total ~280 bytes, with 600 providing headroom + // for future experimental fields and variable-length data. + const EXPERIMENTAL_TLV_ALLOCATION_SIZE: usize = 600; let mut experimental_bytes = Vec::with_capacity(EXPERIMENTAL_TLV_ALLOCATION_SIZE); let experimental_tlv_stream = @@ -1225,6 +1230,9 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, + invreq_payer_bip_353_name: None, #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -1288,12 +1296,35 @@ tlv_stream!(InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef<'a>, INVOICE_REQ pub(super) const EXPERIMENTAL_INVOICE_REQUEST_TYPES: core::ops::Range = 2_000_000_000..3_000_000_000; +/// A contact secret used in experimental TLV fields. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ContactSecret { + contents: [u8; 32], +} + +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) + } +} + #[cfg(not(test))] tlv_stream!( ExperimentalInvoiceRequestTlvStream, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, EXPERIMENTAL_INVOICE_REQUEST_TYPES, { + (2_000_001_729, invreq_contact_secret: (Vec, WithoutLength)), + (2_000_001_731, invreq_payer_offer: (Vec, WithoutLength)), + (2_000_001_733, invreq_payer_bip_353_name: (Vec, WithoutLength)), // When adding experimental TLVs, update EXPERIMENTAL_TLV_ALLOCATION_SIZE accordingly in // UnsignedInvoiceRequest::new to avoid unnecessary allocations. } @@ -1301,8 +1332,11 @@ tlv_stream!( #[cfg(test)] tlv_stream!( - ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceRequestTlvStreamRef<'a>, EXPERIMENTAL_INVOICE_REQUEST_TYPES, { + (2_000_001_729, invreq_contact_secret: (Vec, WithoutLength)), + (2_000_001_731, invreq_payer_offer: (Vec, WithoutLength)), + (2_000_001_733, invreq_payer_bip_353_name: (Vec, WithoutLength)), (2_999_999_999, experimental_bar: (u64, HighZeroBytesDroppedBigSize)), } ); @@ -1322,7 +1356,7 @@ type FullInvoiceRequestTlvStreamRef<'a> = ( InvoiceRequestTlvStreamRef<'a>, SignatureTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl CursorReadable for FullInvoiceRequestTlvStream { @@ -1358,7 +1392,7 @@ type PartialInvoiceRequestTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl TryFrom> for UnsignedInvoiceRequest { @@ -1437,6 +1471,9 @@ impl TryFrom for InvoiceRequestContents { }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { + invreq_contact_secret: _, + invreq_payer_offer: _, + invreq_payer_bip_353_name: _, #[cfg(test)] experimental_bar, }, @@ -1660,7 +1697,12 @@ 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, + invreq_payer_bip_353_name: None, + experimental_bar: None, + }, ), ); diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index dd2c3e2a92e..361a0b80df7 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -816,6 +816,9 @@ impl RefundContents { }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, + invreq_payer_bip_353_name: None, #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -861,7 +864,7 @@ type RefundTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl CursorReadable for RefundTlvStream { @@ -934,6 +937,9 @@ impl TryFrom for RefundContents { experimental_foo, }, ExperimentalInvoiceRequestTlvStream { + invreq_contact_secret: _, + invreq_payer_offer: _, + invreq_payer_bip_353_name: _, #[cfg(test)] experimental_bar, }, @@ -1120,7 +1126,12 @@ mod tests { offer_from_hrn: None, }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, - ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, + ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, + invreq_payer_bip_353_name: None, + experimental_bar: None, + }, ), ); From 2d1a9a6a8504c978fd3b2a47f1946a8607244003 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 30 Sep 2025 18:53:19 +0200 Subject: [PATCH 2/9] Add contacts module for Lightning offer contact management Implements BIP 353 human-readable contact addresses and bLIP 42 contact secret derivation for mutual authentication in Lightning Network payments. The implementation supports both offers with issuer_signing_pubkey and offers using blinded paths for privacy-preserving contact management. Signed-off-by: Vincenzo Palazzo --- lightning/src/offers/contacts.rs | 366 +++++++++++++++++++++++++++++++ lightning/src/offers/mod.rs | 1 + 2 files changed, 367 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..f79df9becda --- /dev/null +++ b/lightning/src/offers/contacts.rs @@ -0,0 +1,366 @@ +// 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::blinded_path::IntroductionNode; +use crate::offers::offer::Offer; +use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::secp256k1::Scalar; +use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use core::fmt; + +#[allow(unused_imports)] +use crate::prelude::*; + +/// BIP 353 human-readable address of a contact. +/// +/// This represents an address in the form `name@domain` that can be used to identify +/// a contact in a human-readable way. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ContactAddress { + name: String, + domain: String, +} + +impl ContactAddress { + /// Creates a new [`ContactAddress`] with the given name and domain. + /// + /// Returns `None` if either the name or domain exceeds 255 characters. + pub fn new(name: String, domain: String) -> Result { + if name.len() >= 256 || domain.len() >= 256 { + // TODO: return a better error string in here! + return Err(()); + } + Ok(Self { name, domain }) + } + + /// Returns the name part of the contact address. + pub fn name(&self) -> &str { + &self.name + } + + /// Returns the domain part of the contact address. + pub fn domain(&self) -> &str { + &self.domain + } + + /// Parses a contact address from a string in the format `name@domain`. + /// + /// The Bitcoin symbol (₿) is stripped if present. + /// Returns `None` if the format is invalid or if name/domain exceed 255 characters. + pub fn from_str(address: &str) -> Result { + let address = address.replace("₿", ""); + let parts: Vec<&str> = address.split('@').collect(); + + if parts.len() != 2 { + return Err(()); + } + + if parts[0].len() > 255 || parts[1].len() > 255 { + return Err(()); + } + + Self::new(parts[0].to_string(), parts[1].to_string()) + } +} + +impl fmt::Display for ContactAddress { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}@{}", self.name, self.domain) + } +} + +/// When we receive an invoice_request containing a contact address, we don't immediately fetch +/// the offer from the BIP 353 address, because this could otherwise be used as a DoS vector +/// since we haven't received a payment yet. +/// +/// After receiving the payment, we resolve the BIP 353 address to store the contact. +/// In the invoice_request, they committed to the signing key used for their offer. +/// We verify that the offer uses this signing key, otherwise the BIP 353 address most likely +/// doesn't belong to them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UnverifiedContactAddress { + address: ContactAddress, + expected_offer_signing_key: PublicKey, +} + +// FIXME: this can be simply a function call? +impl UnverifiedContactAddress { + /// Creates a new [`UnverifiedContactAddress`]. + pub fn new(address: ContactAddress, expected_offer_signing_key: PublicKey) -> Self { + Self { address, expected_offer_signing_key } + } + + /// Returns the contact address. + pub fn address(&self) -> &ContactAddress { + &self.address + } + + /// Returns the expected offer signing key. + pub fn expected_offer_signing_key(&self) -> PublicKey { + self.expected_offer_signing_key + } + + /// Verify that the offer obtained by resolving the BIP 353 address matches the + /// invoice_request commitment. + /// + /// If this returns false, it means that either: + /// - the contact address doesn't belong to the node + /// - or they changed the signing key of the offer associated with their BIP 353 address + /// + /// Since the second case should be very infrequent, it's more likely that the remote node + /// is malicious and we shouldn't store them in our contacts list. + pub fn verify(&self, offer: &Offer) -> bool { + // Check if the expected key matches the offer's issuer ID + if let Some(issuer_id) = offer.issuer_signing_pubkey() { + if issuer_id == self.expected_offer_signing_key { + return true; + } + } + + // Check if the expected key matches any of the blinded path node IDs + for path in offer.paths() { + if let IntroductionNode::NodeId(node_id) = path.introduction_node() { + if *node_id == self.expected_offer_signing_key { + return true; + } + } + } + + false + } +} + +/// 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: [u8; 32], + additional_remote_secrets: Vec<[u8; 32]>, +} + +impl ContactSecrets { + /// Creates a new [`ContactSecrets`] with the given primary secret. + pub fn new(primary_secret: [u8; 32]) -> 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: [u8; 32], additional_remote_secrets: Vec<[u8; 32]>, + ) -> Self { + Self { primary_secret, additional_remote_secrets } + } + + /// Returns the primary secret. + pub fn primary_secret(&self) -> &[u8; 32] { + &self.primary_secret + } + + /// Returns the additional remote secrets. + pub fn additional_remote_secrets(&self) -> &[[u8; 32]] { + &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: [u8; 32]) { + 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: &[u8; 32]) -> 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_public_key` - The public key of the contact's node identity +pub fn compute_contact_secret(our_private_key: &SecretKey, their_offer: &Offer) -> ContactSecrets { + 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) + let node_ids = their_offer + .paths() + .iter() + .filter_map(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .collect::>(); + if node_ids.is_empty() { + // FIXME: do not panic but return a proper error! + panic!("Offer must have either an issuer signing key or a blinded path"); + } + node_ids[0] + }; + // Compute ECDH shared secret (multiply their public key by our private key) + let scalar: Scalar = our_private_key.clone().into(); + let secp = Secp256k1::new(); + 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 = sha256::Hash::from_engine(engine).to_byte_array(); + + 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: [u8; 32]) -> 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); + let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer); + + assert_eq!( + alice_computed.primary_secret().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); + let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer); + + assert_eq!( + alice_computed.primary_secret().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 a54d2bac2d77f818ecbb40309f1cb662873cb1a3 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Thu, 2 Oct 2025 00:38:51 +0200 Subject: [PATCH 3/9] blip42: mock the test workflow Signed-off-by: Vincenzo Palazzo --- lightning/src/ln/offers_tests.rs | 83 ++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 3a6965c6646..bd53f0acf90 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2517,3 +2517,86 @@ fn no_double_pay_with_stale_channelmanager() { // generated in response to the duplicate invoice. assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); } + +// Pay and offer while adding the contacts information the invoice request! +#[test] +fn pay_offer_and_add_contacts_info_blip42() { + let mut features = channelmanager::provided_init_features(&accept_forward_cfg); + features.set_onion_messages_optional(); + features.set_route_blinding_optional(); + + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + + *node_cfgs[1].override_init_features.borrow_mut() = Some(features); + + let node_chanmgrs = create_node_chanmgrs( + 2, &node_cfgs, &[None, None] + ); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_unannounced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let (alice, bob) = (&nodes[0], &nodes[1]); + let alice_id = alice.node.get_our_node_id(); + let bob_id = bob.node.get_our_node_id(); + + // For the Offer Builder we do not need to change anything + // because the contacts are not included in the offer itself. + let offer = alice.node + .create_offer_builder() + .unwrap() + .amount_msats(10_000_000) + .build() + .unwrap(); + + + assert_ne!(offer.issuer_signing_pubkey(), Some(alice_id)); + assert!(!offer.paths().is_empty()); + + let payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + // Probably a good place to add the information that we use for the contact secret. + // but need to double check if the sender of the invoice request still need to ask anything. + expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + + let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); + // TODO: check if the invoice request contains the contact information. + + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }); + assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); + assert!(check_compact_path_introduction_node(&reply_path, alice, bob_id)); + + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let (invoice, reply_path) = extract_invoice(bob, &onion_message); + assert_eq!(invoice.amount_msats(), 10_000_000); + assert_ne!(invoice.signing_pubkey(), alice_id); + assert!(!invoice.payment_paths().is_empty()); + + for path in invoice.payment_paths() { + assert_eq!(path.introduction_node(), &IntroductionNode::NodeId(alice_id)); + } + assert!(check_compact_path_introduction_node(&reply_path, bob, alice_id)); + + route_bolt12_payment(bob, &[alice], &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); + + // TODO: now should be possible that alice will be able to repay bob without that + // bob give any offer in exchange!! but there is a contact list somewhere that allow + // to run something like bob.pay_for_contact(alice_contact_name, amount); +} From 569366059f5babe966bca5aa40856cdaff39dbb0 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Fri, 3 Oct 2025 19:11:08 +0200 Subject: [PATCH 4/9] blip42: add the possibility to inject conctact secret inside the pay for offer Signed-off-by: Vincenzo Palazzo --- lightning/src/ln/channelmanager.rs | 17 ++++++++++++++- lightning/src/ln/offers_tests.rs | 22 +++++++------------ lightning/src/offers/invoice_request.rs | 29 ++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 18 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 644920557d2..c3373d0b1a0 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -92,6 +92,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; @@ -728,6 +729,9 @@ 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_secrects: Option, } impl Default for OptionalOfferPaymentParams { @@ -739,6 +743,7 @@ impl Default for OptionalOfferPaymentParams { retry_strategy: Retry::Timeout(core::time::Duration::from_secs(2)), #[cfg(not(feature = "std"))] retry_strategy: Retry::Attempts(3), + contact_secrects: None, } } } @@ -12944,6 +12949,7 @@ where payment_id, None, create_pending_payment_fn, + optional_params.contact_secrects, ) } @@ -12973,6 +12979,7 @@ where payment_id, Some(offer.hrn), create_pending_payment_fn, + optional_params.contact_secrects, ) } @@ -13015,6 +13022,7 @@ where payment_id, None, create_pending_payment_fn, + optional_params.contact_secrects, ) } @@ -13023,6 +13031,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, ) -> Result<(), Bolt12SemanticError> { let entropy = &*self.entropy_source; let nonce = Nonce::from_entropy_source(entropy); @@ -13048,6 +13057,12 @@ where Some(hrn) => builder.sourced_from_human_readable_name(hrn), }; + let contacts = match contacts { + None => ContactSecrets::new(self.entropy_source.get_secure_random_bytes()), + Some(c) => c, + }; + let builder = builder.contact_secrets(contacts.clone()); + let invoice_request = builder.build_and_sign()?; let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); @@ -15649,7 +15664,7 @@ where self.pending_outbound_payments .received_offer(payment_id, Some(retryable_invoice_request)) .map_err(|_| Bolt12SemanticError::DuplicatePaymentId) - }); + }, 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 bd53f0acf90..5151f1e0afd 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2521,10 +2521,6 @@ fn no_double_pay_with_stale_channelmanager() { // Pay and offer while adding the contacts information the invoice request! #[test] fn pay_offer_and_add_contacts_info_blip42() { - let mut features = channelmanager::provided_init_features(&accept_forward_cfg); - features.set_onion_messages_optional(); - features.set_route_blinding_optional(); - let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); @@ -2559,10 +2555,10 @@ fn pay_offer_and_add_contacts_info_blip42() { // Probably a good place to add the information that we use for the contact secret. // but need to double check if the sender of the invoice request still need to ask anything. expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); - let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); - let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); - // TODO: check if the invoice request contains the contact information. + let (invoice_request, _reply_path) = extract_invoice_request(alice, &onion_message); let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { offer_id: offer.id(), @@ -2575,21 +2571,19 @@ fn pay_offer_and_add_contacts_info_blip42() { }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); - assert!(check_compact_path_introduction_node(&reply_path, alice, bob_id)); + // Now we check that there are the contact secret and the + // contact secret is the same that we inject by bob. + assert!(invoice_request.contact_secret().is_some()); + // TODO: we should check also if the contact secret is the same that we inject by bob. let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); bob.onion_messenger.handle_onion_message(alice_id, &onion_message); - let (invoice, reply_path) = extract_invoice(bob, &onion_message); + let (invoice, _reply_path) = extract_invoice(bob, &onion_message); assert_eq!(invoice.amount_msats(), 10_000_000); assert_ne!(invoice.signing_pubkey(), alice_id); assert!(!invoice.payment_paths().is_empty()); - for path in invoice.payment_paths() { - assert_eq!(path.introduction_node(), &IntroductionNode::NodeId(alice_id)); - } - assert!(check_compact_path_introduction_node(&reply_path, bob, alice_id)); - route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index ab4d37634ea..00c9431bcb3 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -187,7 +187,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, #[cfg(test)] experimental_bar: None, } @@ -256,6 +256,18 @@ 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().to_vec()); + $return_value + } + fn build_with_checks($($self_mut)* $self: $self_type) -> Result< (UnsignedInvoiceRequest, Option, Option<&'b Secp256k1<$secp_context>>), Bolt12SemanticError @@ -691,6 +703,7 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { quantity: Option, payer_note: Option, offer_from_hrn: Option, + invreq_contact_secret: Option>, #[cfg(test)] experimental_bar: Option, } @@ -752,6 +765,11 @@ 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]> { + $contents.contact_secret() + } } } impl UnsignedInvoiceRequest { @@ -1184,6 +1202,10 @@ impl InvoiceRequestContents { &self.inner.offer_from_hrn } + pub(super) fn contact_secret(&self) -> Option<&[u8]> { + self.inner.invreq_contact_secret.as_ref().map(|secret| secret.as_slice()) + } + pub(super) fn as_tlv_stream(&self) -> PartialInvoiceRequestTlvStreamRef<'_> { let (payer, offer, mut invoice_request, experimental_offer, experimental_invoice_request) = self.inner.as_tlv_stream(); @@ -1230,7 +1252,7 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { - invreq_contact_secret: None, + invreq_contact_secret: self.invreq_contact_secret.as_ref(), invreq_payer_offer: None, invreq_payer_bip_353_name: None, #[cfg(test)] @@ -1471,7 +1493,7 @@ impl TryFrom for InvoiceRequestContents { }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { - invreq_contact_secret: _, + invreq_contact_secret, invreq_payer_offer: _, invreq_payer_bip_353_name: _, #[cfg(test)] @@ -1517,6 +1539,7 @@ impl TryFrom for InvoiceRequestContents { quantity, payer_note, offer_from_hrn, + invreq_contact_secret, #[cfg(test)] experimental_bar, }, From 8f4b9441a711389c6dd736958b2d49fa838c3b29 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Mon, 3 Nov 2025 15:41:13 +0100 Subject: [PATCH 5/9] Add payer_offer() accessor method for invoice requests Implements a public accessor method to retrieve the payer_offer field from invoice requests. This completes the interface for accessing the invreq_payer_offer experimental TLV field that was added in commit 61799bf7f91ad494adeaefe92ac7463697c44afc. Signed-off-by: Vincenzo Palazzo --- lightning/src/ln/offers_tests.rs | 3 ++- lightning/src/offers/invoice_request.rs | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 5151f1e0afd..833605c0efc 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -55,7 +55,7 @@ use crate::types::features::Bolt12InvoiceFeatures; use crate::ln::functional_test_utils::*; use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnouncement, OnionMessage, OnionMessageHandler, RoutingMessageHandler, SocketAddress, UnsignedGossipMessage, UnsignedNodeAnnouncement}; use crate::ln::outbound_payment::IDEMPOTENCY_TIMEOUT_TICKS; -use crate::offers::invoice::Bolt12Invoice; +use crate::offers::invoice::{self, Bolt12Invoice}; use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; @@ -2574,6 +2574,7 @@ fn pay_offer_and_add_contacts_info_blip42() { // Now we check that there are the contact secret and the // contact secret is the same that we inject by bob. assert!(invoice_request.contact_secret().is_some()); + assert!(invoice_request.payer_offer().is_some()); // TODO: we should check also if the contact secret is the same that we inject by bob. let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 00c9431bcb3..72072195dfd 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -187,7 +187,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, invreq_contact_secret: None, + offer_from_hrn: None, invreq_contact_secret: None, invreq_payer_offer: None, #[cfg(test)] experimental_bar: None, } @@ -704,6 +704,7 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { payer_note: Option, offer_from_hrn: Option, invreq_contact_secret: Option>, + invreq_payer_offer: Option>, #[cfg(test)] experimental_bar: Option, } @@ -770,6 +771,11 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { pub fn contact_secret(&$self) -> Option<&[u8]> { $contents.contact_secret() } + + /// Returns the payer offer if present in the invoice request. + pub fn payer_offer(&$self) -> Option<&[u8]> { + $contents.payer_offer() + } } } impl UnsignedInvoiceRequest { @@ -1206,6 +1212,10 @@ impl InvoiceRequestContents { self.inner.invreq_contact_secret.as_ref().map(|secret| secret.as_slice()) } + pub(super) fn payer_offer(&self) -> Option<&[u8]> { + self.inner.invreq_payer_offer.as_ref().map(|offer| offer.as_slice()) + } + pub(super) fn as_tlv_stream(&self) -> PartialInvoiceRequestTlvStreamRef<'_> { let (payer, offer, mut invoice_request, experimental_offer, experimental_invoice_request) = self.inner.as_tlv_stream(); @@ -1253,7 +1263,7 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { invreq_contact_secret: self.invreq_contact_secret.as_ref(), - invreq_payer_offer: None, + invreq_payer_offer: self.invreq_payer_offer.as_ref(), invreq_payer_bip_353_name: None, #[cfg(test)] experimental_bar: self.experimental_bar, @@ -1494,7 +1504,7 @@ impl TryFrom for InvoiceRequestContents { experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { invreq_contact_secret, - invreq_payer_offer: _, + invreq_payer_offer, invreq_payer_bip_353_name: _, #[cfg(test)] experimental_bar, @@ -1540,6 +1550,7 @@ impl TryFrom for InvoiceRequestContents { payer_note, offer_from_hrn, invreq_contact_secret, + invreq_payer_offer, #[cfg(test)] experimental_bar, }, From 94b56f36a549a5a54d13351e03a8f2bd9823637d Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Mon, 3 Nov 2025 16:31:07 +0100 Subject: [PATCH 6/9] Inject payer offer into invoice requests for BLIP-42 Implements automatic injection of the payer's offer into invoice requests to support BLIP-42 contact management. This allows recipients to identify which specific offer is being paid, enabling better contact tracking and payment relationship management. Signed-off-by: Vincenzo Palazzo --- lightning/src/ln/channelmanager.rs | 5 +++++ lightning/src/ln/offers_tests.rs | 10 +++++++--- lightning/src/offers/invoice_request.rs | 11 +++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index c3373d0b1a0..1bcbbf5aade 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13062,6 +13062,11 @@ where Some(c) => c, }; let builder = builder.contact_secrets(contacts.clone()); + // Create a minimal offer for BLIP-42 contact exchange (just node_id, no description/paths) + // TODO: Create a better minimal offer with a single blinded path hop for privacy, + // while keeping the size small enough to fit in the onion packet. + let payer_offer = self.create_offer_builder()?.build()?; + let builder = builder.payer_offer(&payer_offer); 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 833605c0efc..ae553b2f72f 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -51,6 +51,7 @@ use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, Paym use crate::blinded_path::message::OffersContext; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose}; use crate::ln::channelmanager::{Bolt12PaymentError, PaymentId, RecentPaymentDetails, RecipientOnionFields, Retry, self}; +use crate::offers::offer::Offer; use crate::types::features::Bolt12InvoiceFeatures; use crate::ln::functional_test_utils::*; use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnouncement, OnionMessage, OnionMessageHandler, RoutingMessageHandler, SocketAddress, UnsignedGossipMessage, UnsignedNodeAnnouncement}; @@ -2571,12 +2572,15 @@ fn pay_offer_and_add_contacts_info_blip42() { }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); - // Now we check that there are the contact secret and the - // contact secret is the same that we inject by bob. assert!(invoice_request.contact_secret().is_some()); - assert!(invoice_request.payer_offer().is_some()); // TODO: we should check also if the contact secret is the same that we inject by bob. + assert!(invoice_request.payer_offer().is_some()); + // FIXME: this is wrong but make sure that we are following correctly the code path. + let payer_offer_bytes = invoice_request.payer_offer().unwrap(); + let payer_offer = Offer::try_from(payer_offer_bytes.to_vec()).unwrap(); + assert_eq!(payer_offer, offer); + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); bob.onion_messenger.handle_onion_message(alice_id, &onion_message); diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 72072195dfd..3840aae22bb 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -268,6 +268,17 @@ macro_rules! invoice_request_builder_methods { ( $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 From a8b98b47a53336db5096ee1894cae79ad6613ba5 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Mon, 3 Nov 2025 18:58:25 +0100 Subject: [PATCH 7/9] blip42: Add ContactInfo field to PaymentSent event for contact management This commit implements the infrastructure to expose BLIP-42 contact information through the PaymentSent event, allowing applications to manage contact relationships when BOLT12 offer payments complete. --- lightning/src/events/mod.rs | 61 ++++++++++++++++ lightning/src/ln/async_payments_tests.rs | 10 +-- lightning/src/ln/functional_test_utils.rs | 19 ++--- lightning/src/ln/offers_tests.rs | 40 ++++++----- lightning/src/ln/outbound_payment.rs | 88 +++++++++++++++++++---- lightning/src/ln/payment_tests.rs | 2 +- 6 files changed, 175 insertions(+), 45 deletions(-) diff --git a/lightning/src/events/mod.rs b/lightning/src/events/mod.rs index 9f7e4c5620d..6a2d4e66add 100644 --- a/lightning/src/events/mod.rs +++ b/lightning/src/events/mod.rs @@ -727,6 +727,55 @@ pub enum InboundChannelFunds { /// who is the channel opener in this case. DualFunded, } +/// Contact information for BLIP-42 contact management, containing the contact secrets +/// and payer offer that were used when paying a BOLT12 offer. +/// +/// This information allows the payer to establish a contact relationship with the recipient, +/// enabling future direct payments without needing a new offer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContactInfo { + /// The contact secrets that were generated and sent in the invoice request. + pub contact_secrets: crate::offers::contacts::ContactSecrets, + /// The payer's offer that was sent in the invoice request. + pub payer_offer: crate::offers::offer::Offer, +} + +impl Writeable for ContactInfo { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + // Serialize ContactSecrets by writing its fields + self.contact_secrets.primary_secret().write(writer)?; + (self.contact_secrets.additional_remote_secrets().len() as u16).write(writer)?; + for secret in self.contact_secrets.additional_remote_secrets() { + secret.write(writer)?; + } + // Serialize Offer as bytes (as a length-prefixed Vec) + self.payer_offer.as_ref().to_vec().write(writer)?; + Ok(()) + } +} + +impl Readable for ContactInfo { + fn read(reader: &mut R) -> Result { + // Deserialize ContactSecrets + let primary_secret: [u8; 32] = Readable::read(reader)?; + let num_secrets: u16 = Readable::read(reader)?; + let mut additional_remote_secrets = Vec::with_capacity(num_secrets as usize); + for _ in 0..num_secrets { + additional_remote_secrets.push(Readable::read(reader)?); + } + let contact_secrets = crate::offers::contacts::ContactSecrets::with_additional_secrets( + primary_secret, + additional_remote_secrets, + ); + + // Deserialize Offer (as a length-prefixed Vec) + let payer_offer_bytes: Vec = Readable::read(reader)?; + let payer_offer = crate::offers::offer::Offer::try_from(payer_offer_bytes) + .map_err(|_| crate::ln::msgs::DecodeError::InvalidValue)?; + + Ok(ContactInfo { contact_secrets, payer_offer }) + } +} /// An Event which you should probably take some action in response to. /// @@ -1064,6 +1113,13 @@ pub enum Event { /// /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice bolt12_invoice: Option, + /// Contact information for BLIP-42 contact management. + /// + /// This is `Some` when paying a BOLT12 offer with contact information enabled, + /// containing the contact secrets and payer offer that were sent in the invoice request. + /// + /// This allows the payer to establish a contact relationship with the recipient. + contact_info: Option, }, /// Indicates an outbound payment failed. Individual [`Event::PaymentPathFailed`] events /// provide failure information for each path attempt in the payment, including retries. @@ -1951,6 +2007,7 @@ impl Writeable for Event { ref amount_msat, ref fee_paid_msat, ref bolt12_invoice, + ref contact_info, } => { 2u8.write(writer)?; write_tlv_fields!(writer, { @@ -1960,6 +2017,7 @@ impl Writeable for Event { (5, fee_paid_msat, option), (7, amount_msat, option), (9, bolt12_invoice, option), + (11, contact_info, option), }); }, &Event::PaymentPathFailed { @@ -2422,6 +2480,7 @@ impl MaybeReadable for Event { let mut amount_msat = None; let mut fee_paid_msat = None; let mut bolt12_invoice = None; + let mut contact_info = None; read_tlv_fields!(reader, { (0, payment_preimage, required), (1, payment_hash, option), @@ -2429,6 +2488,7 @@ impl MaybeReadable for Event { (5, fee_paid_msat, option), (7, amount_msat, option), (9, bolt12_invoice, option), + (11, contact_info, option), }); if payment_hash.is_none() { payment_hash = Some(PaymentHash( @@ -2442,6 +2502,7 @@ impl MaybeReadable for Event { amount_msat, fee_paid_msat, bolt12_invoice, + contact_info, })) }; f() diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index d56670f4d67..adbec39dd13 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -988,7 +988,7 @@ fn ignore_duplicate_invoice() { let args = PassAlongPathArgs::new(sender, route[0], amt_msat, payment_hash, ev); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); - let (res, _) = + let (res, _, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice.clone()))); @@ -1073,7 +1073,7 @@ fn ignore_duplicate_invoice() { }; // After paying invoice, check that static invoice is ignored. - let res = claim_payment(sender, route[0], payment_preimage); + let (res, _) = claim_payment(sender, route[0], payment_preimage); assert_eq!(res, Some(PaidBolt12Invoice::Bolt12Invoice(invoice))); sender.onion_messenger.handle_onion_message(always_online_node_id, &static_invoice_om); @@ -1142,7 +1142,7 @@ fn async_receive_flow_success() { let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev); let claimable_ev = do_pass_along_path(args).unwrap(); let keysend_preimage = extract_payment_preimage(&claimable_ev); - let (res, _) = + let (res, _, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, keysend_preimage)); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); } @@ -2942,7 +2942,7 @@ fn async_payment_e2e() { let route: &[&[&Node]] = &[&[sender_lsp, invoice_server, recipient]]; let keysend_preimage = extract_payment_preimage(&claimable_ev); - let (res, _) = + let (res, _, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); } @@ -3180,7 +3180,7 @@ fn intercepted_hold_htlc() { let route: &[&[&Node]] = &[&[lsp, recipient]]; let keysend_preimage = extract_payment_preimage(&claimable_ev); - let (res, _) = + let (res, _, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); } diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 45c8e072f8d..cc4465b0cbb 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -3033,7 +3033,7 @@ pub fn expect_payment_sent>( node: &H, expected_payment_preimage: PaymentPreimage, expected_fee_msat_opt: Option>, expect_per_path_claims: bool, expect_post_ev_mon_update: bool, -) -> (Option, Vec) { +) -> (Option, Vec, Option) { if expect_post_ev_mon_update { check_added_monitors(node, 0); } @@ -3051,6 +3051,7 @@ pub fn expect_payment_sent>( } // We return the invoice because some test may want to check the invoice details. let invoice; + let contact_info_result; let mut path_events = Vec::new(); let expected_payment_id = match events[0] { Event::PaymentSent { @@ -3060,6 +3061,7 @@ pub fn expect_payment_sent>( ref amount_msat, ref fee_paid_msat, ref bolt12_invoice, + ref contact_info, } => { assert_eq!(expected_payment_preimage, *payment_preimage); assert_eq!(expected_payment_hash, *payment_hash); @@ -3070,6 +3072,7 @@ pub fn expect_payment_sent>( assert!(fee_paid_msat.is_some()); } invoice = bolt12_invoice.clone(); + contact_info_result = contact_info.clone(); payment_id.unwrap() }, _ => panic!("Unexpected event"), @@ -3087,7 +3090,7 @@ pub fn expect_payment_sent>( } } } - (invoice, path_events) + (invoice, path_events, contact_info_result) } #[macro_export] @@ -4119,7 +4122,7 @@ pub fn pass_claimed_payment_along_route(args: ClaimAlongRouteArgs) -> u64 { } pub fn claim_payment_along_route( args: ClaimAlongRouteArgs, -) -> (Option, Vec) { +) -> (Option, Vec, Option) { let origin_node = args.origin_node; let payment_preimage = args.payment_preimage; let skip_last = args.skip_last; @@ -4127,20 +4130,20 @@ pub fn claim_payment_along_route( if !skip_last { expect_payment_sent!(origin_node, payment_preimage, Some(expected_total_fee_msat)) } else { - (None, Vec::new()) + (None, Vec::new(), None) } } pub fn claim_payment<'a, 'b, 'c>( origin_node: &Node<'a, 'b, 'c>, expected_route: &[&Node<'a, 'b, 'c>], our_payment_preimage: PaymentPreimage, -) -> Option { - claim_payment_along_route(ClaimAlongRouteArgs::new( +) -> (Option, Option) { + let result = claim_payment_along_route(ClaimAlongRouteArgs::new( origin_node, &[expected_route], our_payment_preimage, - )) - .0 + )); + (result.0, result.2) } pub const TEST_FINAL_CLTV: u32 = 70; diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index ae553b2f72f..264d108a57c 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -185,7 +185,7 @@ fn route_bolt12_payment<'a, 'b, 'c>( fn claim_bolt12_payment<'a, 'b, 'c>( node: &Node<'a, 'b, 'c>, path: &[&Node<'a, 'b, 'c>], expected_payment_context: PaymentContext, invoice: &Bolt12Invoice -) { +) -> Option { let recipient = &path[path.len() - 1]; let payment_purpose = match get_event!(recipient, Event::PaymentClaimable) { Event::PaymentClaimable { purpose, .. } => purpose, @@ -204,11 +204,13 @@ fn claim_bolt12_payment<'a, 'b, 'c>( }, _ => panic!("Unexpected payment purpose: {:?}", payment_purpose), } - if let Some(inv) = claim_payment(node, path, payment_preimage) { - assert_eq!(inv, PaidBolt12Invoice::Bolt12Invoice(invoice.to_owned())); + let (inv, contact_info) = claim_payment(node, path, payment_preimage); + if let Some(paid_inv) = inv { + assert_eq!(paid_inv, PaidBolt12Invoice::Bolt12Invoice(invoice.to_owned())); } else { panic!("Expected PaidInvoice::Bolt12Invoice"); }; + contact_info } fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessage) -> Nonce { @@ -714,7 +716,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { route_bolt12_payment(david, &[charlie, bob, alice], &invoice); expect_recent_payment!(david, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(david, &[charlie, bob, alice], payment_context, &invoice); + let _ = claim_bolt12_payment(david, &[charlie, bob, alice], payment_context, &invoice); expect_recent_payment!(david, RecentPaymentDetails::Fulfilled, payment_id); } @@ -796,7 +798,7 @@ fn creates_and_pays_for_refund_using_two_hop_blinded_path() { route_bolt12_payment(david, &[charlie, bob, alice], &invoice); expect_recent_payment!(david, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(david, &[charlie, bob, alice], payment_context, &invoice); + let _ = claim_bolt12_payment(david, &[charlie, bob, alice], payment_context, &invoice); expect_recent_payment!(david, RecentPaymentDetails::Fulfilled, payment_id); } @@ -863,7 +865,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + let _ = claim_bolt12_payment(bob, &[alice], payment_context, &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } @@ -919,7 +921,7 @@ fn creates_and_pays_for_refund_using_one_hop_blinded_path() { route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + let _ = claim_bolt12_payment(bob, &[alice], payment_context, &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } @@ -973,7 +975,7 @@ fn pays_for_offer_without_blinded_paths() { route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + let _ = claim_bolt12_payment(bob, &[alice], payment_context, &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } @@ -1016,7 +1018,7 @@ fn pays_for_refund_without_blinded_paths() { route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + let _ = claim_bolt12_payment(bob, &[alice], payment_context, &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } @@ -1253,7 +1255,7 @@ fn creates_and_pays_for_offer_with_retry() { } route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + let _ = claim_bolt12_payment(bob, &[alice], payment_context, &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } @@ -1331,7 +1333,7 @@ fn pays_bolt12_invoice_asynchronously() { route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + let _ = claim_bolt12_payment(bob, &[alice], payment_context, &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); assert_eq!( @@ -1411,7 +1413,7 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + let _ = claim_bolt12_payment(bob, &[alice], payment_context, &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } @@ -2251,7 +2253,7 @@ fn fails_paying_invoice_more_than_once() { assert!(david.node.get_and_clear_pending_msg_events().is_empty()); // Complete paying the first invoice - claim_bolt12_payment(david, &[charlie, bob, alice], payment_context, &invoice1); + let _ = claim_bolt12_payment(david, &[charlie, bob, alice], payment_context, &invoice1); expect_recent_payment!(david, RecentPaymentDetails::Fulfilled, payment_id); } @@ -2576,10 +2578,6 @@ fn pay_offer_and_add_contacts_info_blip42() { // TODO: we should check also if the contact secret is the same that we inject by bob. assert!(invoice_request.payer_offer().is_some()); - // FIXME: this is wrong but make sure that we are following correctly the code path. - let payer_offer_bytes = invoice_request.payer_offer().unwrap(); - let payer_offer = Offer::try_from(payer_offer_bytes.to_vec()).unwrap(); - assert_eq!(payer_offer, offer); let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); bob.onion_messenger.handle_onion_message(alice_id, &onion_message); @@ -2592,9 +2590,15 @@ fn pay_offer_and_add_contacts_info_blip42() { route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); - claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + let contact_info = claim_bolt12_payment(bob, &[alice], payment_context, &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); + assert!(contact_info.is_some()); + let contact_info = contact_info.unwrap(); + + assert!(invoice_request.contact_secret().is_some()); + assert_eq!(invoice_request.contact_secret().unwrap(), contact_info.contact_secrets.primary_secret()); + // TODO: now should be possible that alice will be able to repay bob without that // bob give any offer in exchange!! but there is a contact list somewhere that allow // to run something like bob.pay_for_contact(alice_contact_name, amount); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 75fe55bfeac..5387e7642ef 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -93,6 +93,7 @@ pub(crate) enum PendingOutboundPayment { // race conditions where this field might be missing upon reload. It may be required // for future retries. route_params_config: RouteParametersConfig, + retryable_invoice_request: Option, }, // This state applies when we are paying an often-offline recipient and another node on the // network served us a static invoice on the recipient's behalf in response to our invoice @@ -133,6 +134,9 @@ pub(crate) enum PendingOutboundPayment { /// Our best known block height at the time this payment was initiated. starting_block_height: u32, remaining_max_total_routing_fee_msat: Option, + /// Contact information for BLIP-42 contact management, containing the contact secrets + /// and payer offer that were sent in the invoice request. + contact_info: Option, }, /// When a pending payment is fulfilled, we continue tracking it until all pending HTLCs have /// been resolved. This ensures we don't look up pending payments in ChannelMonitors on restart @@ -981,7 +985,7 @@ where SP: Fn(SendAlongPathArgs) -> Result<(), APIError>, { - let (payment_hash, retry_strategy, params_config, _) = self + let (payment_hash, retry_strategy, params_config, _, invoice_request) = self .mark_invoice_received_and_get_details(invoice, payment_id)?; if invoice.invoice_features().requires_unknown_bits_from(&features) { @@ -1000,7 +1004,7 @@ where } let invoice = PaidBolt12Invoice::Bolt12Invoice(invoice.clone()); self.send_payment_for_bolt12_invoice_internal( - payment_id, payment_hash, None, None, invoice, route_params, retry_strategy, false, router, + payment_id, payment_hash, None, invoice_request.as_ref(), invoice, route_params, retry_strategy, false, router, first_hops, inflight_htlcs, entropy_source, node_signer, node_id_lookup, secp_ctx, best_block_height, pending_events, send_payment_along_path ) @@ -1072,9 +1076,27 @@ where let onion_session_privs = match outbounds.entry(payment_id) { hash_map::Entry::Occupied(entry) => match entry.get() { PendingOutboundPayment::InvoiceReceived { .. } => { + // Extract ContactInfo from invoice_request if present + let contact_info = invoice_request.and_then(|inv_req| { + let contact_secret_bytes = inv_req.contact_secret()?; + let payer_offer_bytes = inv_req.payer_offer()?; + + // Deserialize into ContactInfo + use crate::offers::contacts::ContactSecrets; + use crate::offers::offer::Offer; + + // For the initial payment, we only have the primary secret + let contact_secrets = ContactSecrets::new( + contact_secret_bytes.try_into().ok()? + ); + let payer_offer = Offer::try_from(payer_offer_bytes.to_vec()).ok()?; + + Some(events::ContactInfo { contact_secrets, payer_offer }) + }); + let (retryable_payment, onion_session_privs) = Self::create_pending_payment( - payment_hash, recipient_onion.clone(), keysend_preimage, None, Some(bolt12_invoice.clone()), &route, - Some(retry_strategy), payment_params, entropy_source, best_block_height, + payment_hash, recipient_onion.clone(), keysend_preimage, invoice_request.cloned(), Some(bolt12_invoice.clone()), &route, + Some(retry_strategy), payment_params, entropy_source, best_block_height, contact_info, ); *entry.into_mut() = retryable_payment; onion_session_privs @@ -1083,9 +1105,32 @@ where let invreq = if let PendingOutboundPayment::StaticInvoiceReceived { invoice_request, .. } = entry.remove() { invoice_request } else { unreachable!() }; + + // Extract ContactInfo from invreq + let contact_info = { + let contact_secret_bytes = invreq.contact_secret(); + let payer_offer_bytes = invreq.payer_offer(); + + match (contact_secret_bytes, payer_offer_bytes) { + (Some(secret), Some(offer_bytes)) => { + use crate::offers::contacts::ContactSecrets; + use crate::offers::offer::Offer; + + let contact_secrets = secret.try_into().ok() + .map(|s| ContactSecrets::new(s)); + let payer_offer = Offer::try_from(offer_bytes.to_vec()).ok(); + + contact_secrets.and_then(|cs| payer_offer.map(|po| + events::ContactInfo { contact_secrets: cs, payer_offer: po } + )) + }, + _ => None, + } + }; + let (retryable_payment, onion_session_privs) = Self::create_pending_payment( payment_hash, recipient_onion.clone(), keysend_preimage, Some(invreq), Some(bolt12_invoice.clone()), &route, - Some(retry_strategy), payment_params, entropy_source, best_block_height + Some(retry_strategy), payment_params, entropy_source, best_block_height, contact_info, ); outbounds.insert(payment_id, retryable_payment); onion_session_privs @@ -1887,7 +1932,7 @@ where hash_map::Entry::Vacant(entry) => { let (payment, onion_session_privs) = Self::create_pending_payment( payment_hash, recipient_onion, keysend_preimage, None, bolt12_invoice, route, retry_strategy, - payment_params, entropy_source, best_block_height + payment_params, entropy_source, best_block_height, None, ); entry.insert(payment); Ok(onion_session_privs) @@ -1900,7 +1945,8 @@ where payment_hash: PaymentHash, recipient_onion: RecipientOnionFields, keysend_preimage: Option, invoice_request: Option, bolt12_invoice: Option, route: &Route, retry_strategy: Option, - payment_params: Option, entropy_source: &ES, best_block_height: u32 + payment_params: Option, entropy_source: &ES, best_block_height: u32, + contact_info: Option, ) -> (PendingOutboundPayment, Vec<[u8; 32]>) where ES::Target: EntropySource, @@ -1928,6 +1974,7 @@ where total_msat: route.get_total_amount(), remaining_max_total_routing_fee_msat: route.route_params.as_ref().and_then(|p| p.max_total_routing_fee_msat), + contact_info, }; for (path, session_priv_bytes) in route.paths.iter().zip(onion_session_privs.iter()) { @@ -2025,7 +2072,7 @@ where &self, invoice: &Bolt12Invoice, payment_id: PaymentId ) -> Result<(), Bolt12PaymentError> { self.mark_invoice_received_and_get_details(invoice, payment_id) - .and_then(|(_, _, _, is_newly_marked)| { + .and_then(|(_, _, _, is_newly_marked, _)| { is_newly_marked .then_some(()) .ok_or(Bolt12PaymentError::DuplicateInvoice) @@ -2035,31 +2082,35 @@ where #[rustfmt::skip] fn mark_invoice_received_and_get_details( &self, invoice: &Bolt12Invoice, payment_id: PaymentId - ) -> Result<(PaymentHash, Retry, RouteParametersConfig, bool), Bolt12PaymentError> { + ) -> Result<(PaymentHash, Retry, RouteParametersConfig, bool, Option), Bolt12PaymentError> { match self.pending_outbound_payments.lock().unwrap().entry(payment_id) { hash_map::Entry::Occupied(entry) => match entry.get() { PendingOutboundPayment::AwaitingInvoice { - retry_strategy: retry, route_params_config, .. + retry_strategy: retry, route_params_config, retryable_invoice_request, .. } => { let payment_hash = invoice.payment_hash(); let retry = *retry; let config = *route_params_config; + let invreq = retryable_invoice_request.clone(); + let invoice_request = invreq.as_ref().map(|r| r.invoice_request.clone()); *entry.into_mut() = PendingOutboundPayment::InvoiceReceived { payment_hash, retry_strategy: retry, route_params_config: config, + retryable_invoice_request: invreq, }; - Ok((payment_hash, retry, config, true)) + Ok((payment_hash, retry, config, true, invoice_request)) }, // When manual invoice handling is enabled, the corresponding `PendingOutboundPayment` entry // is already updated at the time the invoice is received. This ensures that `InvoiceReceived` // event generation remains idempotent, even if the same invoice is received again before the // event is handled by the user. PendingOutboundPayment::InvoiceReceived { - retry_strategy, route_params_config, .. + retry_strategy, route_params_config, retryable_invoice_request, .. } => { - Ok((invoice.payment_hash(), *retry_strategy, *route_params_config, false)) + let invoice_request = retryable_invoice_request.as_ref().map(|r| r.invoice_request.clone()); + Ok((invoice.payment_hash(), *retry_strategy, *route_params_config, false, invoice_request)) }, _ => Err(Bolt12PaymentError::DuplicateInvoice), }, @@ -2231,6 +2282,13 @@ where log_info!(self.logger, "Payment with id {} and hash {} sent!", payment_id, payment_hash); let fee_paid_msat = payment.get().get_pending_fee_msat(); let amount_msat = payment.get().total_msat(); + + // Extract contact_info from payment if present + let contact_info = match payment.get() { + PendingOutboundPayment::Retryable { contact_info, .. } => contact_info.clone(), + _ => None, + }; + pending_events.push_back((events::Event::PaymentSent { payment_id: Some(payment_id), payment_preimage, @@ -2238,6 +2296,7 @@ where amount_msat, fee_paid_msat, bolt12_invoice: bolt12_invoice, + contact_info, }, ev_completion_action.take())); payment.get_mut().mark_fulfilled(); } @@ -2644,6 +2703,7 @@ where total_msat: path_amt, starting_block_height: best_block_height, remaining_max_total_routing_fee_msat: None, // only used for retries, and we'll never retry on startup + contact_info: None, // only used for BLIP-42 payments, not available on startup } } } @@ -2726,6 +2786,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, (11, remaining_max_total_routing_fee_msat, option), (13, invoice_request, option), (15, bolt12_invoice, option), + (17, contact_info, option), (not_written, retry_strategy, (static_value, None)), (not_written, attempts, (static_value, PaymentAttempts::new())), }, @@ -2767,6 +2828,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, |fee_msat| RouteParametersConfig::default().with_max_total_routing_fee_msat(fee_msat) ) ))), + (6, retryable_invoice_request, option), }, // Added in 0.1. Prior versions will drop these outbounds on downgrade, which is safe because no // HTLCs are in-flight. diff --git a/lightning/src/ln/payment_tests.rs b/lightning/src/ln/payment_tests.rs index 9eb85173a83..eb2e9b5091e 100644 --- a/lightning/src/ln/payment_tests.rs +++ b/lightning/src/ln/payment_tests.rs @@ -564,7 +564,7 @@ fn test_fulfill_hold_times() { // Delay claiming so that we get a non-zero hold time. thread::sleep(Duration::from_millis(200)); - let (_, path_events) = + let (_, path_events, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, preimage.unwrap())); assert_eq!(path_events.len(), 1); From a5aa96f37091a9414ca14a4a6acbc15c6ddd5b87 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 5 Nov 2025 17:14:07 +0100 Subject: [PATCH 8/9] receiver side exstract the payer_offer from a PaymentClaimable Signed-off-by: Vincenzo Palazzo --- lightning/src/ln/offers_tests.rs | 104 ++++++++++++++++++++---- lightning/src/offers/invoice_request.rs | 31 +++++++ 2 files changed, 117 insertions(+), 18 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 264d108a57c..57b65817459 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -50,7 +50,8 @@ use crate::blinded_path::message::BlindedMessagePath; use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, PaymentContext}; use crate::blinded_path::message::OffersContext; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose}; -use crate::ln::channelmanager::{Bolt12PaymentError, PaymentId, RecentPaymentDetails, RecipientOnionFields, Retry, self}; +use crate::ln::channelmanager::{self, Bolt12PaymentError, OptionalOfferPaymentParams, PaymentId, RecentPaymentDetails, RecipientOnionFields, Retry}; +use crate::offers::contacts::ContactSecrets; use crate::offers::offer::Offer; use crate::types::features::Bolt12InvoiceFeatures; use crate::ln::functional_test_utils::*; @@ -686,6 +687,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)); @@ -844,6 +847,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)); @@ -965,6 +970,8 @@ fn pays_for_offer_without_blinded_paths() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, }); @@ -1232,6 +1239,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)); @@ -1297,6 +1306,8 @@ fn pays_bolt12_invoice_asynchronously() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, }); @@ -1394,6 +1405,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); @@ -2555,28 +2568,16 @@ fn pay_offer_and_add_contacts_info_blip42() { let payment_id = PaymentId([1; 32]); bob.node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); - // Probably a good place to add the information that we use for the contact secret. - // but need to double check if the sender of the invoice request still need to ask anything. + expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); alice.onion_messenger.handle_onion_message(bob_id, &onion_message); let (invoice_request, _reply_path) = extract_invoice_request(alice, &onion_message); - let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { - offer_id: offer.id(), - invoice_request: InvoiceRequestFields { - payer_signing_pubkey: invoice_request.payer_signing_pubkey(), - quantity: None, - payer_note_truncated: None, - human_readable_name: None, - }, - }); assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); assert!(invoice_request.contact_secret().is_some()); - // TODO: we should check also if the contact secret is the same that we inject by bob. - assert!(invoice_request.payer_offer().is_some()); let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); @@ -2590,7 +2591,18 @@ fn pay_offer_and_add_contacts_info_blip42() { route_bolt12_payment(bob, &[alice], &invoice); expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); - let contact_info = claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + let (alice_payment_purpose, alice_payment_preimage) = match get_event!(alice, Event::PaymentClaimable) { + Event::PaymentClaimable { purpose, .. } => { + let preimage = match purpose.preimage() { + Some(p) => p, + None => panic!("No preimage in PaymentClaimable"), + }; + (purpose, preimage) + }, + _ => panic!("No Event::PaymentClaimable for Alice"), + }; + + let (_, contact_info) = claim_payment(bob, &[alice], alice_payment_preimage); expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); assert!(contact_info.is_some()); @@ -2599,7 +2611,63 @@ fn pay_offer_and_add_contacts_info_blip42() { assert!(invoice_request.contact_secret().is_some()); assert_eq!(invoice_request.contact_secret().unwrap(), contact_info.contact_secrets.primary_secret()); - // TODO: now should be possible that alice will be able to repay bob without that - // bob give any offer in exchange!! but there is a contact list somewhere that allow - // to run something like bob.pay_for_contact(alice_contact_name, amount); + let alice_invoice_request_fields = match alice_payment_purpose { + PaymentPurpose::Bolt12OfferPayment { payment_context, .. } => { + assert_eq!(payment_context.offer_id, offer.id()); + payment_context.invoice_request + }, + _ => panic!("Expected Bolt12OfferPayment purpose for Alice"), + }; + + assert!(alice_invoice_request_fields.contact_secret.is_some()); + assert_eq!(alice_invoice_request_fields.contact_secret.unwrap(), *contact_info.contact_secrets.primary_secret()); + + assert!(alice_invoice_request_fields.payer_offer.is_some()); + assert_eq!(alice_invoice_request_fields.payer_offer.as_ref().unwrap(), &contact_info.payer_offer); + + let alice_contact_secret = alice_invoice_request_fields.contact_secret.unwrap(); + let alice_payer_offer = alice_invoice_request_fields.payer_offer.unwrap(); + + let payment_id = PaymentId([1; 32]); + alice.node.pay_for_offer(&alice_payer_offer, Some(5_000_000), payment_id, OptionalOfferPaymentParams{ + contact_secrects: Some(ContactSecrets::new(alice_contact_secret)), + ..Default::default() + }).unwrap(); + + + expect_recent_payment!(alice, RecentPaymentDetails::AwaitingInvoice, payment_id); + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let (invoice_request, _reply_path) = extract_invoice_request(bob, &onion_message); + + assert_eq!(invoice_request.amount_msats(), Some(5_000_000)); + assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); + assert!(invoice_request.contact_secret().is_some()); + assert!(invoice_request.payer_offer().is_some()); + + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + bob.onion_messenger.handle_onion_message(bob_id, &onion_message); + + let (invoice, _reply_path) = extract_invoice(alice, &onion_message); + assert_eq!(invoice.amount_msats(), 10_000_000); + assert_ne!(invoice.signing_pubkey(), alice_id); + assert!(!invoice.payment_paths().is_empty()); + + route_bolt12_payment(bob, &[alice], &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + let (_, alice_payment_preimage) = match get_event!(alice, Event::PaymentClaimable) { + Event::PaymentClaimable { purpose, .. } => { + let preimage = match purpose.preimage() { + Some(p) => p, + None => panic!("No preimage in PaymentClaimable"), + }; + (purpose, preimage) + }, + _ => panic!("No Event::PaymentClaimable for Alice"), + }; + + let (_, _) = claim_payment(bob, &[alice], alice_payment_preimage); + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 3840aae22bb..f59733276fe 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -1094,6 +1094,12 @@ macro_rules! fields_accessor { }, } = &$inner; + // Extract BLIP-42 contact information if present + let contact_secret = $self.contact_secret().and_then(|bytes| bytes.try_into().ok()); + let payer_offer = $self + .payer_offer() + .and_then(|bytes| crate::offers::offer::Offer::try_from(bytes.to_vec()).ok()); + InvoiceRequestFields { payer_signing_pubkey: *payer_signing_pubkey, quantity: *quantity, @@ -1103,6 +1109,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, } } }; @@ -1587,6 +1595,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`]. @@ -1599,11 +1615,17 @@ 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| { + use core::convert::AsRef; + 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(()) } @@ -1616,13 +1638,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, }) } } @@ -3199,6 +3228,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 be4f248a26aa616ac70d6a3d14052c1acdd8057d Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Thu, 6 Nov 2025 13:46:11 +0100 Subject: [PATCH 9/9] fix the rebase stuff Signed-off-by: Vincenzo Palazzo --- lightning/src/ln/async_payments_tests.rs | 2 +- lightning/src/ln/offers_tests.rs | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index adbec39dd13..2749506d944 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -3427,7 +3427,7 @@ fn release_htlc_races_htlc_onion_decode() { let route: &[&[&Node]] = &[&[sender_lsp, invoice_server, recipient]]; let keysend_preimage = extract_payment_preimage(&claimable_ev); - let (res, _) = + let (res, _, _) = claim_payment_along_route(ClaimAlongRouteArgs::new(sender, route, keysend_preimage)); assert_eq!(res, Some(PaidBolt12Invoice::StaticInvoice(static_invoice))); } diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 57b65817459..ef903635948 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2540,8 +2540,6 @@ fn pay_offer_and_add_contacts_info_blip42() { let chanmon_cfgs = create_chanmon_cfgs(2); let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); - *node_cfgs[1].override_init_features.borrow_mut() = Some(features); - let node_chanmgrs = create_node_chanmgrs( 2, &node_cfgs, &[None, None] );