Skip to content

Commit 9b1b644

Browse files
Check and refresh async receive offers
As an async recipient, we need to interactively build static invoices that an always-online node will serve to payers on our behalf. At the start of this process, we send a requests for paths to include in our offers to the always-online node on startup and refresh the cached offers when they expire.
1 parent 2a6c744 commit 9b1b644

File tree

5 files changed

+242
-1
lines changed

5 files changed

+242
-1
lines changed

lightning/src/blinded_path/message.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,32 @@ pub enum OffersContext {
404404
/// [`AsyncPaymentsMessage`]: crate::onion_message::async_payments::AsyncPaymentsMessage
405405
#[derive(Clone, Debug)]
406406
pub enum AsyncPaymentsContext {
407+
/// Context used by a reply path to an [`OfferPathsRequest`], provided back to us as an async
408+
/// recipient in corresponding [`OfferPaths`] messages from the static invoice server.
409+
///
410+
/// [`OfferPathsRequest`]: crate::onion_message::async_payments::OfferPathsRequest
411+
/// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths
412+
OfferPaths {
413+
/// A nonce used for authenticating that an [`OfferPaths`] message is valid for a preceding
414+
/// [`OfferPathsRequest`] that we sent as an async recipient.
415+
///
416+
/// [`OfferPathsRequest`]: crate::onion_message::async_payments::OfferPathsRequest
417+
/// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths
418+
nonce: Nonce,
419+
/// Authentication code for the [`OfferPaths`] message.
420+
///
421+
/// Prevents nodes from creating their own blinded path that terminates at our async recipient
422+
/// node and causing us to cache an unintended async receive offer.
423+
///
424+
/// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths
425+
hmac: Hmac<Sha256>,
426+
/// The time as duration since the Unix epoch at which this path expires and messages sent over
427+
/// it should be ignored.
428+
///
429+
/// As an async recipient we use this field to time out a static invoice server from sending us
430+
/// offer paths if we are no longer configured to accept paths from them.
431+
path_absolute_expiry: core::time::Duration,
432+
},
407433
/// Context contained within the reply [`BlindedMessagePath`] we put in outbound
408434
/// [`HeldHtlcAvailable`] messages, provided back to us in corresponding [`ReleaseHeldHtlc`]
409435
/// messages.
@@ -486,6 +512,11 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
486512
(2, hmac, required),
487513
(4, path_absolute_expiry, required),
488514
},
515+
(2, OfferPaths) => {
516+
(0, nonce, required),
517+
(2, hmac, required),
518+
(4, path_absolute_expiry, required),
519+
},
489520
);
490521

491522
/// Contains a simple nonce for use in a blinded path's context.

lightning/src/ln/channelmanager.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5105,7 +5105,21 @@ where
51055105
}
51065106

51075107
#[cfg(async_payments)]
5108+
fn check_refresh_async_receive_offers(&self) {
5109+
let peers = self.get_peers_for_blinded_path();
5110+
match self.flow.check_refresh_async_receive_offers(peers, &*self.entropy_source) {
5111+
Err(()) => {
5112+
log_error!(
5113+
self.logger,
5114+
"Failed to create blinded paths when requesting async receive offer paths"
5115+
);
5116+
},
5117+
Ok(()) => {},
5118+
}
5119+
}
5120+
51085121
#[rustfmt::skip]
5122+
#[cfg(async_payments)]
51095123
fn initiate_async_payment(
51105124
&self, invoice: &StaticInvoice, payment_id: PaymentId
51115125
) -> Result<(), Bolt12PaymentError> {
@@ -7049,6 +7063,9 @@ where
70497063
duration_since_epoch, &self.pending_events
70507064
);
70517065

7066+
#[cfg(async_payments)]
7067+
self.check_refresh_async_receive_offers();
7068+
70527069
// Technically we don't need to do this here, but if we have holding cell entries in a
70537070
// channel that need freeing, it's better to do that here and block a background task
70547071
// than block the message queueing pipeline.
@@ -11484,6 +11501,13 @@ where
1148411501
return NotifyOption::SkipPersistHandleEvents;
1148511502
//TODO: Also re-broadcast announcement_signatures
1148611503
});
11504+
11505+
// While we usually refresh the AsyncReceiveOfferCache on a timer, we also want to start
11506+
// interactively building offers as soon as we can after startup. We can't start building offers
11507+
// until we have some peer connection(s) to send onion messages over, so as a minor optimization
11508+
// refresh the cache when a peer connects.
11509+
#[cfg(async_payments)]
11510+
self.check_refresh_async_receive_offers();
1148711511
res
1148811512
}
1148911513

lightning/src/offers/async_receive_offer_cache.rs

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,103 @@ impl AsyncReceiveOfferCache {
8181
}
8282
}
8383

84+
// The target number of offers we want to have cached at any given time, to mitigate too much
85+
// reuse of the same offer.
86+
#[cfg(async_payments)]
87+
const NUM_CACHED_OFFERS_TARGET: usize = 3;
88+
89+
// The max number of times we'll attempt to request offer paths or attempt to refresh a static
90+
// invoice before giving up.
91+
#[cfg(async_payments)]
92+
const MAX_UPDATE_ATTEMPTS: u8 = 3;
93+
94+
// If we run out of attempts to request offer paths from the static invoice server, we'll stop
95+
// sending requests for some time. After this amount of time has passed, more requests are allowed
96+
// to be sent out.
97+
#[cfg(async_payments)]
98+
const PATHS_REQUESTS_RESET_INTERVAL: Duration = Duration::from_secs(3 * 60 * 60);
99+
100+
// If an offer is 90% of the way through its lifespan, it's expiring soon. This allows us to be
101+
// flexible for various offer lifespans, i.e. an offer that lasts 10 days expires soon after 9 days
102+
// and an offer that lasts 10 years expires soon after 9 years.
103+
#[cfg(async_payments)]
104+
const OFFER_EXPIRES_SOON_THRESHOLD_PERCENT: u64 = 90;
105+
106+
#[cfg(async_payments)]
107+
impl AsyncReceiveOfferCache {
108+
/// Remove expired offers from the cache, returning whether new offers are needed.
109+
pub(super) fn prune_expired_offers(&mut self, duration_since_epoch: Duration) -> bool {
110+
// Remove expired offers from the cache.
111+
let mut offer_was_removed = false;
112+
self.offers.retain(|offer| {
113+
if offer.offer.is_expired_no_std(duration_since_epoch) {
114+
offer_was_removed = true;
115+
return false;
116+
}
117+
true
118+
});
119+
120+
// If we just removed a newly expired offer, force allowing more paths request attempts.
121+
if offer_was_removed {
122+
self.reset_offer_paths_request_attempts();
123+
} else {
124+
// If we haven't attempted to request new paths in a long time, allow more requests to go out
125+
// if/when needed.
126+
self.check_reset_offer_paths_request_attempts(duration_since_epoch);
127+
}
128+
129+
self.needs_new_offers(duration_since_epoch)
130+
&& self.offer_paths_request_attempts < MAX_UPDATE_ATTEMPTS
131+
}
132+
133+
/// Returns a bool indicating whether new offers are needed in the cache.
134+
fn needs_new_offers(&self, duration_since_epoch: Duration) -> bool {
135+
// If we have fewer than NUM_CACHED_OFFERS_TARGET offers that aren't expiring soon, indicate
136+
// that new offers should be interactively built.
137+
let num_unexpiring_offers = self
138+
.offers
139+
.iter()
140+
.filter(|offer| {
141+
let offer_absolute_expiry = offer.offer.absolute_expiry().unwrap_or(Duration::MAX);
142+
let offer_created_at = offer.offer_created_at;
143+
let offer_lifespan =
144+
offer_absolute_expiry.saturating_sub(offer_created_at).as_secs();
145+
let elapsed = duration_since_epoch.saturating_sub(offer_created_at).as_secs();
146+
147+
// If an offer is in the last 10% of its lifespan, it's expiring soon.
148+
elapsed.saturating_mul(100)
149+
< offer_lifespan.saturating_mul(OFFER_EXPIRES_SOON_THRESHOLD_PERCENT)
150+
})
151+
.count();
152+
153+
num_unexpiring_offers < NUM_CACHED_OFFERS_TARGET
154+
}
155+
156+
// Indicates that onion messages requesting new offer paths have been sent to the static invoice
157+
// server. Calling this method allows the cache to self-limit how many requests are sent, in case
158+
// the server goes unresponsive.
159+
pub(super) fn new_offers_requested(&mut self, duration_since_epoch: Duration) {
160+
self.offer_paths_request_attempts += 1;
161+
self.last_offer_paths_request_timestamp = duration_since_epoch;
162+
}
163+
164+
/// If we haven't sent an offer paths request in a long time, reset the limit to allow more
165+
/// requests to be sent out if/when needed.
166+
fn check_reset_offer_paths_request_attempts(&mut self, duration_since_epoch: Duration) {
167+
let should_reset =
168+
self.last_offer_paths_request_timestamp.saturating_add(PATHS_REQUESTS_RESET_INTERVAL)
169+
< duration_since_epoch;
170+
if should_reset {
171+
self.reset_offer_paths_request_attempts();
172+
}
173+
}
174+
175+
fn reset_offer_paths_request_attempts(&mut self) {
176+
self.offer_paths_request_attempts = 0;
177+
self.last_offer_paths_request_timestamp = Duration::from_secs(0);
178+
}
179+
}
180+
84181
impl Writeable for AsyncReceiveOfferCache {
85182
fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
86183
write_tlv_fields!(w, {

lightning/src/offers/flow.rs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ use {
6666
crate::offers::offer::Amount,
6767
crate::offers::signer,
6868
crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder},
69-
crate::onion_message::async_payments::HeldHtlcAvailable,
69+
crate::onion_message::async_payments::{HeldHtlcAvailable, OfferPathsRequest},
7070
};
7171

7272
#[cfg(feature = "dnssec")]
@@ -217,6 +217,11 @@ where
217217
/// even if multiple invoices are received.
218218
const OFFERS_MESSAGE_REQUEST_LIMIT: usize = 10;
219219

220+
/// The default relative expiry for reply paths where a quick response is expected and the reply
221+
/// path is single-use.
222+
#[cfg(async_payments)]
223+
const TEMP_REPLY_PATH_RELATIVE_EXPIRY: Duration = Duration::from_secs(7200);
224+
220225
impl<MR: Deref> OffersMessageFlow<MR>
221226
where
222227
MR::Target: MessageRouter,
@@ -1105,6 +1110,72 @@ where
11051110
core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap())
11061111
}
11071112

1113+
/// Sends out [`OfferPathsRequest`] onion messages if we are an often-offline recipient and are
1114+
/// configured to interactively build offers and static invoices with a static invoice server.
1115+
///
1116+
/// # Usage
1117+
///
1118+
/// This method should be called on peer connection and every few minutes or so, to keep the
1119+
/// offers cache updated.
1120+
///
1121+
/// Errors if we failed to create blinded reply paths when sending an [`OfferPathsRequest`] message.
1122+
#[cfg(async_payments)]
1123+
pub(crate) fn check_refresh_async_receive_offers<ES: Deref>(
1124+
&self, peers: Vec<MessageForwardNode>, entropy: ES,
1125+
) -> Result<(), ()>
1126+
where
1127+
ES::Target: EntropySource,
1128+
{
1129+
// Terminate early if this node does not intend to receive async payments.
1130+
if self.paths_to_static_invoice_server.is_empty() {
1131+
return Ok(());
1132+
}
1133+
1134+
let expanded_key = &self.inbound_payment_key;
1135+
let duration_since_epoch = self.duration_since_epoch();
1136+
1137+
// Update the cache to remove expired offers, and check to see whether we need new offers to be
1138+
// interactively built with the static invoice server.
1139+
let needs_new_offers = self
1140+
.async_receive_offer_cache
1141+
.lock()
1142+
.unwrap()
1143+
.prune_expired_offers(duration_since_epoch);
1144+
1145+
// If we need new offers, send out offer paths request messages to the static invoice server.
1146+
if needs_new_offers {
1147+
let nonce = Nonce::from_entropy_source(&*entropy);
1148+
let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths {
1149+
nonce,
1150+
hmac: signer::hmac_for_offer_paths_context(nonce, expanded_key),
1151+
path_absolute_expiry: duration_since_epoch
1152+
.saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY),
1153+
});
1154+
let reply_paths = match self.create_blinded_paths(peers, context) {
1155+
Ok(paths) => paths,
1156+
Err(()) => {
1157+
return Err(());
1158+
},
1159+
};
1160+
1161+
// We can't fail past this point, so indicate to the cache that we've requested new offers.
1162+
self.async_receive_offer_cache
1163+
.lock()
1164+
.unwrap()
1165+
.new_offers_requested(duration_since_epoch);
1166+
1167+
let message = AsyncPaymentsMessage::OfferPathsRequest(OfferPathsRequest {});
1168+
enqueue_onion_message_with_reply_paths(
1169+
message,
1170+
&self.paths_to_static_invoice_server[..],
1171+
reply_paths,
1172+
&mut self.pending_async_payments_messages.lock().unwrap(),
1173+
);
1174+
}
1175+
1176+
Ok(())
1177+
}
1178+
11081179
/// Get the `AsyncReceiveOfferCache` for persistence.
11091180
pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ {
11101181
&self.async_receive_offer_cache

lightning/src/offers/signer.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ const PAYMENT_TLVS_HMAC_INPUT: &[u8; 16] = &[8; 16];
5555
#[cfg(async_payments)]
5656
const ASYNC_PAYMENTS_HELD_HTLC_HMAC_INPUT: &[u8; 16] = &[9; 16];
5757

58+
// HMAC input used in `AsyncPaymentsContext::OfferPaths` to authenticate inbound offer_paths onion
59+
// messages.
60+
#[cfg(async_payments)]
61+
const ASYNC_PAYMENTS_OFFER_PATHS_INPUT: &[u8; 16] = &[10; 16];
62+
5863
/// Message metadata which possibly is derived from [`MetadataMaterial`] such that it can be
5964
/// verified.
6065
#[derive(Clone)]
@@ -570,3 +575,16 @@ pub(crate) fn verify_held_htlc_available_context(
570575
Err(())
571576
}
572577
}
578+
579+
#[cfg(async_payments)]
580+
pub(crate) fn hmac_for_offer_paths_context(
581+
nonce: Nonce, expanded_key: &ExpandedKey,
582+
) -> Hmac<Sha256> {
583+
const IV_BYTES: &[u8; IV_LEN] = b"LDK Offer Paths~";
584+
let mut hmac = expanded_key.hmac_for_offer();
585+
hmac.input(IV_BYTES);
586+
hmac.input(&nonce.0);
587+
hmac.input(ASYNC_PAYMENTS_OFFER_PATHS_INPUT);
588+
589+
Hmac::from_engine(hmac)
590+
}

0 commit comments

Comments
 (0)