Skip to content
Open
61 changes: 61 additions & 0 deletions lightning/src/events/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<W: Writer>(&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<u8>)
self.payer_offer.as_ref().to_vec().write(writer)?;
Ok(())
}
}

impl Readable for ContactInfo {
fn read<R: io::Read>(reader: &mut R) -> Result<Self, crate::ln::msgs::DecodeError> {
// 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<u8>)
let payer_offer_bytes: Vec<u8> = 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.
///
Expand Down Expand Up @@ -1064,6 +1113,13 @@ pub enum Event {
///
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
bolt12_invoice: Option<PaidBolt12Invoice>,
/// 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<ContactInfo>,
},
/// Indicates an outbound payment failed. Individual [`Event::PaymentPathFailed`] events
/// provide failure information for each path attempt in the payment, including retries.
Expand Down Expand Up @@ -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, {
Expand All @@ -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 {
Expand Down Expand Up @@ -2422,13 +2480,15 @@ 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),
(3, payment_id, option),
(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(
Expand All @@ -2442,6 +2502,7 @@ impl MaybeReadable for Event {
amount_msat,
fee_paid_msat,
bolt12_invoice,
contact_info,
}))
};
f()
Expand Down
12 changes: 6 additions & 6 deletions lightning/src/ln/async_payments_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())));

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)));
}
Expand Down Expand Up @@ -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)));
}
Expand Down Expand Up @@ -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)));
}
Expand Down Expand Up @@ -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)));
}
22 changes: 21 additions & 1 deletion lightning/src/ln/channelmanager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs to be substantially more filled-out, including information about intended UX and UI integration logic, how/when to derive secrets, etc.

pub contact_secrects: Option<ContactSecrets>,
}

impl Default for OptionalOfferPaymentParams {
Expand All @@ -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,
}
}
}
Expand Down Expand Up @@ -12944,6 +12949,7 @@ where
payment_id,
None,
create_pending_payment_fn,
optional_params.contact_secrects,
)
}

Expand Down Expand Up @@ -12973,6 +12979,7 @@ where
payment_id,
Some(offer.hrn),
create_pending_payment_fn,
optional_params.contact_secrects,
)
}

Expand Down Expand Up @@ -13015,6 +13022,7 @@ where
payment_id,
None,
create_pending_payment_fn,
optional_params.contact_secrects,
)
}

Expand All @@ -13023,6 +13031,7 @@ where
&self, offer: &Offer, quantity: Option<u64>, amount_msats: Option<u64>,
payer_note: Option<String>, payment_id: PaymentId,
human_readable_name: Option<HumanReadableName>, create_pending_payment: CPP,
contacts: Option<ContactSecrets>,
) -> Result<(), Bolt12SemanticError> {
let entropy = &*self.entropy_source;
let nonce = Nonce::from_entropy_source(entropy);
Expand All @@ -13048,6 +13057,17 @@ where
Some(hrn) => builder.sourced_from_human_readable_name(hrn),
};

let contacts = match contacts {
None => ContactSecrets::new(self.entropy_source.get_secure_random_bytes()),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My recollection of the intended UX is that the wallet would have a "save recipient to contacts" checkbox and only if that is checked would we include contact information and refund offer. If our refund offer had perfect privacy, maybe it'd make sense to include it always, but as-is including it is a somewhat annoying privacy leak as the sender.

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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Luckily the onion packet is 64K so we're not too worried about the size. Indeed, as you note in your questions, we should probably try to figure out if the dev probably wants us to use an async offer (based on async availability/config) and use one of those if we can.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will add a complete answer later but my message was refering to the current CI error https://github.com/lightningdevkit/rust-lightning/actions/runs/19137908502/job/54694666815?pr=4210#step:9:6040 that probably an offer with two blinded path is too big?

node 0 ERROR [lightning::ln::outbound_payment:1486]     Can't construct an onion packet without exceeding 1300-byte onion hop_data length for payment with id 0101010101010101010101010101010101010101010101010101010101010101 and hash 74b87079b704f2162403361478f1bca041556cd1103e9882b394d5e4bb379c41
node 0 TRACE [lightning::ln::channelmanager:15385]      Failed paying invoice: OnionPacketSizeExceeded
node 0 TRACE [lightning::onion_message::messenger:1538] Constructing onion message when responding with Invoice Error to an onion message: InvoiceError { erroneous_field: None, message: UntrustedString("OnionPacketSizeExceeded") }
node 0 TRACE [lightning::onion_message::messenger:1575] Buffered onion message when responding with Invoice Error to an onion message

thread 'ln::offers_tests::rejects_keysend_to_non_static_invoice_path' (4319) panicked at lightning/src/ln/functional_test_utils.rs:1283:9:
assertion `left == right` failed: expected 1 monitors to be added, not 0
  left: 0
 right: 1

async offer (based on async availability/config)

Oh good point will look into it next!

let payer_offer = self.create_offer_builder()?.build()?;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we do keep always passing an offer instead of doing it conditionally, we should probably consider including (encrypted) data in the offer so that we can tie return payments back to the offer we paid (incl, eg, the BIP 353 we used to look up the name to begin with).

let builder = builder.payer_offer(&payer_offer);

let invoice_request = builder.build_and_sign()?;
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);

Expand Down Expand Up @@ -15649,7 +15669,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
Expand Down
19 changes: 11 additions & 8 deletions lightning/src/ln/functional_test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3033,7 +3033,7 @@ pub fn expect_payment_sent<CM: AChannelManager, H: NodeHolder<CM = CM>>(
node: &H, expected_payment_preimage: PaymentPreimage,
expected_fee_msat_opt: Option<Option<u64>>, expect_per_path_claims: bool,
expect_post_ev_mon_update: bool,
) -> (Option<PaidBolt12Invoice>, Vec<Event>) {
) -> (Option<PaidBolt12Invoice>, Vec<Event>, Option<crate::events::ContactInfo>) {
if expect_post_ev_mon_update {
check_added_monitors(node, 0);
}
Expand All @@ -3051,6 +3051,7 @@ pub fn expect_payment_sent<CM: AChannelManager, H: NodeHolder<CM = CM>>(
}
// 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 {
Expand All @@ -3060,6 +3061,7 @@ pub fn expect_payment_sent<CM: AChannelManager, H: NodeHolder<CM = CM>>(
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);
Expand All @@ -3070,6 +3072,7 @@ pub fn expect_payment_sent<CM: AChannelManager, H: NodeHolder<CM = CM>>(
assert!(fee_paid_msat.is_some());
}
invoice = bolt12_invoice.clone();
contact_info_result = contact_info.clone();
payment_id.unwrap()
},
_ => panic!("Unexpected event"),
Expand All @@ -3087,7 +3090,7 @@ pub fn expect_payment_sent<CM: AChannelManager, H: NodeHolder<CM = CM>>(
}
}
}
(invoice, path_events)
(invoice, path_events, contact_info_result)
}

#[macro_export]
Expand Down Expand Up @@ -4119,28 +4122,28 @@ pub fn pass_claimed_payment_along_route(args: ClaimAlongRouteArgs) -> u64 {
}
pub fn claim_payment_along_route(
args: ClaimAlongRouteArgs,
) -> (Option<PaidBolt12Invoice>, Vec<Event>) {
) -> (Option<PaidBolt12Invoice>, Vec<Event>, Option<crate::events::ContactInfo>) {
let origin_node = args.origin_node;
let payment_preimage = args.payment_preimage;
let skip_last = args.skip_last;
let expected_total_fee_msat = do_claim_payment_along_route(args);
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<PaidBolt12Invoice> {
claim_payment_along_route(ClaimAlongRouteArgs::new(
) -> (Option<PaidBolt12Invoice>, Option<crate::events::ContactInfo>) {
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;
Expand Down
Loading
Loading