Skip to content

Commit 1180171

Browse files
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 <vincenzopalazzodev@gmail.com>
1 parent 8861e84 commit 1180171

5 files changed

Lines changed: 206 additions & 5 deletions

File tree

fuzz/src/invoice_request_deser.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ fn build_response<T: secp256k1::Signing + secp256k1::Verification>(
9898
.payer_note()
9999
.map(|s| UntrustedString(s.to_string())),
100100
human_readable_name: None,
101+
contact_secret: None,
102+
payer_offer: None,
101103
}
102104
};
103105

lightning/src/ln/channelmanager.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ use crate::ln::outbound_payment::{
9494
};
9595
use crate::ln::types::ChannelId;
9696
use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache;
97+
use crate::offers::contacts::ContactSecrets;
9798
use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow};
9899
use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice};
99100
use crate::offers::invoice_error::InvoiceError;
@@ -773,6 +774,34 @@ pub struct OptionalOfferPaymentParams {
773774
/// will ultimately fail once all pending paths have failed (generating an
774775
/// [`Event::PaymentFailed`]).
775776
pub retry_strategy: Retry,
777+
/// Contact secrets to include in the invoice request for BLIP-42 contact management.
778+
/// If provided, these secrets will be used to establish a contact relationship with the recipient.
779+
pub contact_secrets: Option<ContactSecrets>,
780+
/// A custom payer offer to include in the invoice request for BLIP-42 contact management.
781+
///
782+
/// If provided, this offer will be included in the invoice request, allowing the recipient to
783+
/// contact you back. If `None`, **no payer offer will be included** in the invoice request.
784+
///
785+
/// You can create custom offers using [`OffersMessageFlow::create_compact_offer_builder`]:
786+
/// - Pass `None` for no blinded path (smallest size, ~70 bytes)
787+
/// - Pass `Some(intro_node_id)` for a single blinded path (~200 bytes)
788+
///
789+
/// # Example
790+
/// ```rust,ignore
791+
/// // Include a compact offer with a single blinded path
792+
/// let payer_offer = flow.create_compact_offer_builder(
793+
/// &entropy_source,
794+
/// Some(trusted_peer_pubkey)
795+
/// )?.build()?;
796+
///
797+
/// let params = OptionalOfferPaymentParams {
798+
/// payer_offer: Some(payer_offer),
799+
/// ..Default::default()
800+
/// };
801+
/// ```
802+
///
803+
/// [`OffersMessageFlow::create_compact_offer_builder`]: crate::offers::flow::OffersMessageFlow::create_compact_offer_builder
804+
pub payer_offer: Option<Offer>,
776805
}
777806

778807
impl Default for OptionalOfferPaymentParams {
@@ -784,6 +813,8 @@ impl Default for OptionalOfferPaymentParams {
784813
retry_strategy: Retry::Timeout(core::time::Duration::from_secs(2)),
785814
#[cfg(not(feature = "std"))]
786815
retry_strategy: Retry::Attempts(3),
816+
contact_secrets: None,
817+
payer_offer: None,
787818
}
788819
}
789820
}
@@ -14619,6 +14650,33 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => {
1461914650

1462014651
Ok(builder.into())
1462114652
}
14653+
14654+
/// Creates a compact [`OfferBuilder`] suitable for BLIP-42's `payer_offer` field.
14655+
///
14656+
/// This creates an offer with minimal size by either:
14657+
/// - Having no blinded paths when `intro_node_id` is `None` (for public nodes)
14658+
/// - Having a single one-hop blinded path when `intro_node_id` is `Some` (for private nodes)
14659+
///
14660+
/// The compact format is ideal for encoding in invoice request fields where space is limited.
14661+
///
14662+
/// # Privacy
14663+
///
14664+
/// Uses a derived signing pubkey in the offer for recipient privacy.
14665+
///
14666+
/// # Errors
14667+
///
14668+
/// Errors if a blinded path cannot be created when `intro_node_id` is provided.
14669+
///
14670+
/// [`Offer`]: crate::offers::offer::Offer
14671+
pub fn create_compact_offer_builder(
14672+
&$self, intro_node_id: Option<PublicKey>,
14673+
) -> Result<$builder, Bolt12SemanticError> {
14674+
let builder = $self.flow.create_compact_offer_builder(
14675+
&$self.entropy_source, intro_node_id
14676+
)?;
14677+
14678+
Ok(builder.into())
14679+
}
1462214680
} }
1462314681

1462414682
macro_rules! create_refund_builder { ($self: ident, $builder: ty) => {
@@ -14854,6 +14912,8 @@ impl<
1485414912
payment_id,
1485514913
None,
1485614914
create_pending_payment_fn,
14915+
optional_params.contact_secrets,
14916+
optional_params.payer_offer,
1485714917
)
1485814918
}
1485914919

@@ -14883,6 +14943,8 @@ impl<
1488314943
payment_id,
1488414944
Some(offer.hrn),
1488514945
create_pending_payment_fn,
14946+
optional_params.contact_secrets,
14947+
optional_params.payer_offer,
1488614948
)
1488714949
}
1488814950

@@ -14925,6 +14987,8 @@ impl<
1492514987
payment_id,
1492614988
None,
1492714989
create_pending_payment_fn,
14990+
optional_params.contact_secrets,
14991+
optional_params.payer_offer,
1492814992
)
1492914993
}
1493014994

@@ -14933,6 +14997,7 @@ impl<
1493314997
&self, offer: &Offer, quantity: Option<u64>, amount_msats: Option<u64>,
1493414998
payer_note: Option<String>, payment_id: PaymentId,
1493514999
human_readable_name: Option<HumanReadableName>, create_pending_payment: CPP,
15000+
contacts: Option<ContactSecrets>, payer_offer: Option<Offer>,
1493615001
) -> Result<(), Bolt12SemanticError> {
1493715002
let entropy = &self.entropy_source;
1493815003
let nonce = Nonce::from_entropy_source(entropy);
@@ -14958,6 +15023,20 @@ impl<
1495815023
Some(hrn) => builder.sourced_from_human_readable_name(hrn),
1495915024
};
1496015025

15026+
let builder = if let Some(secrets) = contacts.as_ref() {
15027+
builder.contact_secrets(secrets.clone())
15028+
} else {
15029+
builder
15030+
};
15031+
15032+
// Add payer offer only if provided by the user.
15033+
// If the user explicitly wants to include an offer, they should provide it via payer_offer parameter.
15034+
let builder = if let Some(offer) = payer_offer {
15035+
builder.payer_offer(&offer)
15036+
} else {
15037+
builder
15038+
};
15039+
1496115040
let invoice_request = builder.build_and_sign()?;
1496215041
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
1496315042

lightning/src/ln/offers_tests.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,8 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() {
727727
quantity: None,
728728
payer_note_truncated: None,
729729
human_readable_name: None,
730+
contact_secret: None,
731+
payer_offer: None,
730732
},
731733
});
732734
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
@@ -885,6 +887,8 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() {
885887
quantity: None,
886888
payer_note_truncated: None,
887889
human_readable_name: None,
890+
contact_secret: None,
891+
payer_offer: None,
888892
},
889893
});
890894
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
@@ -1006,6 +1010,8 @@ fn pays_for_offer_without_blinded_paths() {
10061010
quantity: None,
10071011
payer_note_truncated: None,
10081012
human_readable_name: None,
1013+
contact_secret: None,
1014+
payer_offer: None,
10091015
},
10101016
});
10111017

@@ -1274,6 +1280,8 @@ fn creates_and_pays_for_offer_with_retry() {
12741280
quantity: None,
12751281
payer_note_truncated: None,
12761282
human_readable_name: None,
1283+
contact_secret: None,
1284+
payer_offer: None,
12771285
},
12781286
});
12791287
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
@@ -1339,6 +1347,8 @@ fn pays_bolt12_invoice_asynchronously() {
13391347
quantity: None,
13401348
payer_note_truncated: None,
13411349
human_readable_name: None,
1350+
contact_secret: None,
1351+
payer_offer: None,
13421352
},
13431353
});
13441354

@@ -1436,6 +1446,8 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() {
14361446
quantity: None,
14371447
payer_note_truncated: None,
14381448
human_readable_name: None,
1449+
contact_secret: None,
1450+
payer_offer: None,
14391451
},
14401452
});
14411453
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);

lightning/src/offers/flow.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,40 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
572572
Ok((builder.into(), nonce))
573573
}
574574

575+
/// Creates a minimal [`OfferBuilder`] with derived metadata and an optional blinded path.
576+
///
577+
/// If `intro_node_id` is `None`, creates an offer with no blinded paths (~70 bytes) suitable
578+
/// for scenarios like BLIP-42 where the payer intentionally shares their contact info.
579+
///
580+
/// If `intro_node_id` is `Some`, creates an offer with a single blinded path (~200 bytes)
581+
/// providing privacy/routability for unannounced nodes. The intro node must be a public
582+
/// peer (routable via gossip) with an outbound channel.
583+
///
584+
/// # Privacy
585+
///
586+
/// - `None`: Exposes the derived signing pubkey directly without blinded path privacy
587+
/// - `Some`: Intro node learns payer identity (choose trusted/routable peer)
588+
///
589+
/// This is not exported to bindings users as builder patterns don't map outside of move semantics.
590+
pub fn create_compact_offer_builder<ES: EntropySource>(
591+
&self, entropy_source: ES, intro_node_id: Option<PublicKey>,
592+
) -> Result<OfferBuilder<'_, DerivedMetadata, secp256k1::All>, Bolt12SemanticError> {
593+
match intro_node_id {
594+
None => {
595+
// Use the internal builder but don't add any paths
596+
self.create_offer_builder_intern(&entropy_source, |_, _, _| Ok(core::iter::empty()))
597+
.map(|(builder, _)| builder)
598+
},
599+
Some(node_id) => {
600+
// Delegate to create_offer_builder with a single-peer list to reuse the router logic
601+
self.create_offer_builder(
602+
entropy_source,
603+
vec![MessageForwardNode { node_id, short_channel_id: None }],
604+
)
605+
},
606+
}
607+
}
608+
575609
/// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by the
576610
/// [`OffersMessageFlow`], and any corresponding [`InvoiceRequest`] can be verified using
577611
/// [`Self::verify_invoice_request`].

0 commit comments

Comments
 (0)