Skip to content

Commit 1eb8a7e

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 ae6810b commit 1eb8a7e

File tree

4 files changed

+187
-6
lines changed

4 files changed

+187
-6
lines changed

lightning/src/ln/channelmanager.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ use crate::ln::outbound_payment::{
9292
};
9393
use crate::ln::types::ChannelId;
9494
use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache;
95+
use crate::offers::contacts::ContactSecrets;
9596
use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow};
9697
use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice};
9798
use crate::offers::invoice_error::InvoiceError;
@@ -728,6 +729,34 @@ pub struct OptionalOfferPaymentParams {
728729
/// will ultimately fail once all pending paths have failed (generating an
729730
/// [`Event::PaymentFailed`]).
730731
pub retry_strategy: Retry,
732+
/// Contact secrets to include in the invoice request for BLIP-42 contact management.
733+
/// If provided, these secrets will be used to establish a contact relationship with the recipient.
734+
pub contact_secrects: Option<ContactSecrets>,
735+
/// A custom payer offer to include in the invoice request for BLIP-42 contact management.
736+
///
737+
/// If provided, this offer will be included in the invoice request, allowing the recipient to
738+
/// contact you back. If `None`, **no payer offer will be included** in the invoice request.
739+
///
740+
/// You can create custom offers using [`OffersMessageFlow::create_compact_offer_builder`]:
741+
/// - Pass `None` for no blinded path (smallest size, ~70 bytes)
742+
/// - Pass `Some(intro_node_id)` for a single blinded path (~200 bytes)
743+
///
744+
/// # Example
745+
/// ```rust,ignore
746+
/// // Include a compact offer with a single blinded path
747+
/// let payer_offer = flow.create_compact_offer_builder(
748+
/// &entropy_source,
749+
/// Some(trusted_peer_pubkey)
750+
/// )?.build()?;
751+
///
752+
/// let params = OptionalOfferPaymentParams {
753+
/// payer_offer: Some(payer_offer),
754+
/// ..Default::default()
755+
/// };
756+
/// ```
757+
///
758+
/// [`OffersMessageFlow::create_compact_offer_builder`]: crate::offers::flow::OffersMessageFlow::create_compact_offer_builder
759+
pub payer_offer: Option<Offer>,
731760
}
732761

733762
impl Default for OptionalOfferPaymentParams {
@@ -739,6 +768,8 @@ impl Default for OptionalOfferPaymentParams {
739768
retry_strategy: Retry::Timeout(core::time::Duration::from_secs(2)),
740769
#[cfg(not(feature = "std"))]
741770
retry_strategy: Retry::Attempts(3),
771+
contact_secrects: None,
772+
payer_offer: None,
742773
}
743774
}
744775
}
@@ -12944,6 +12975,8 @@ where
1294412975
payment_id,
1294512976
None,
1294612977
create_pending_payment_fn,
12978+
optional_params.contact_secrects,
12979+
optional_params.payer_offer,
1294712980
)
1294812981
}
1294912982

@@ -12973,6 +13006,8 @@ where
1297313006
payment_id,
1297413007
Some(offer.hrn),
1297513008
create_pending_payment_fn,
13009+
optional_params.contact_secrects,
13010+
optional_params.payer_offer,
1297613011
)
1297713012
}
1297813013

@@ -13015,6 +13050,8 @@ where
1301513050
payment_id,
1301613051
None,
1301713052
create_pending_payment_fn,
13053+
optional_params.contact_secrects,
13054+
optional_params.payer_offer,
1301813055
)
1301913056
}
1302013057

@@ -13023,6 +13060,7 @@ where
1302313060
&self, offer: &Offer, quantity: Option<u64>, amount_msats: Option<u64>,
1302413061
payer_note: Option<String>, payment_id: PaymentId,
1302513062
human_readable_name: Option<HumanReadableName>, create_pending_payment: CPP,
13063+
contacts: Option<ContactSecrets>, payer_offer: Option<Offer>,
1302613064
) -> Result<(), Bolt12SemanticError> {
1302713065
let entropy = &*self.entropy_source;
1302813066
let nonce = Nonce::from_entropy_source(entropy);
@@ -13048,6 +13086,20 @@ where
1304813086
Some(hrn) => builder.sourced_from_human_readable_name(hrn),
1304913087
};
1305013088

13089+
let builder = if let Some(secrets) = contacts.as_ref() {
13090+
builder.contact_secrets(secrets.clone())
13091+
} else {
13092+
builder
13093+
};
13094+
13095+
// Add payer offer only if provided by the user.
13096+
// If the user explicitly wants to include an offer, they should provide it via payer_offer parameter.
13097+
let builder = if let Some(offer) = payer_offer {
13098+
builder.payer_offer(&offer)
13099+
} else {
13100+
builder
13101+
};
13102+
1305113103
let invoice_request = builder.build_and_sign()?;
1305213104
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
1305313105

@@ -15649,7 +15701,7 @@ where
1564915701
self.pending_outbound_payments
1565015702
.received_offer(payment_id, Some(retryable_invoice_request))
1565115703
.map_err(|_| Bolt12SemanticError::DuplicatePaymentId)
15652-
});
15704+
}, None, None);
1565315705
if offer_pay_res.is_err() {
1565415706
// The offer we tried to pay is the canonical current offer for the name we
1565515707
// wanted to pay. If we can't pay it, there's no way to recover so fail the

lightning/src/ln/offers_tests.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,8 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() {
683683
quantity: None,
684684
payer_note_truncated: None,
685685
human_readable_name: None,
686+
contact_secret: None,
687+
payer_offer: None,
686688
},
687689
});
688690
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
@@ -841,6 +843,8 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() {
841843
quantity: None,
842844
payer_note_truncated: None,
843845
human_readable_name: None,
846+
contact_secret: None,
847+
payer_offer: None,
844848
},
845849
});
846850
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
@@ -962,6 +966,8 @@ fn pays_for_offer_without_blinded_paths() {
962966
quantity: None,
963967
payer_note_truncated: None,
964968
human_readable_name: None,
969+
contact_secret: None,
970+
payer_offer: None,
965971
},
966972
});
967973

@@ -1229,6 +1235,8 @@ fn creates_and_pays_for_offer_with_retry() {
12291235
quantity: None,
12301236
payer_note_truncated: None,
12311237
human_readable_name: None,
1238+
contact_secret: None,
1239+
payer_offer: None,
12321240
},
12331241
});
12341242
assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
@@ -1294,6 +1302,8 @@ fn pays_bolt12_invoice_asynchronously() {
12941302
quantity: None,
12951303
payer_note_truncated: None,
12961304
human_readable_name: None,
1305+
contact_secret: None,
1306+
payer_offer: None,
12971307
},
12981308
});
12991309

@@ -1391,6 +1401,8 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() {
13911401
quantity: None,
13921402
payer_note_truncated: None,
13931403
human_readable_name: None,
1404+
contact_secret: None,
1405+
payer_offer: None,
13941406
},
13951407
});
13961408
assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);

lightning/src/offers/flow.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,46 @@ where
574574
Ok((builder.into(), nonce))
575575
}
576576

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

0 commit comments

Comments
 (0)