lightning/offers/async_receive_offer_cache.rs
1// This file is Copyright its original authors, visible in version control
2// history.
3//
4// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
5// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
6// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
7// You may not use this file except in accordance with one or both of these
8// licenses.
9
10//! Data structures and methods for caching offers that we interactively build with a static invoice
11//! server as an async recipient. The static invoice server will serve the resulting invoices to
12//! payers on our behalf when we're offline.
13
14use crate::blinded_path::message::{AsyncPaymentsContext, BlindedMessagePath};
15use crate::io;
16use crate::io::Read;
17use crate::ln::msgs::DecodeError;
18use crate::offers::nonce::Nonce;
19use crate::offers::offer::Offer;
20use crate::onion_message::messenger::Responder;
21use crate::prelude::*;
22use crate::util::ser::{Readable, Writeable, Writer};
23use core::time::Duration;
24
25/// The status of this offer in the cache.
26#[derive(Clone, PartialEq)]
27enum OfferStatus {
28 /// This offer has been returned to the user from the cache, so it needs to be stored until it
29 /// expires and its invoice needs to be kept updated.
30 Used {
31 /// The creation time of the invoice that was last confirmed as persisted by the server. Useful
32 /// to know when the invoice needs refreshing.
33 invoice_created_at: Duration,
34 },
35 /// This offer has not yet been returned to the user, and is safe to replace to ensure we always
36 /// have a maximally fresh offer. We always want to have at least 1 offer in this state,
37 /// preferably a few so we can respond to user requests for new offers without returning the same
38 /// one multiple times. Returning a new offer each time is better for privacy.
39 Ready {
40 /// The creation time of the invoice that was last confirmed as persisted by the server. Useful
41 /// to know when the invoice needs refreshing.
42 invoice_created_at: Duration,
43 },
44 /// This offer's invoice is not yet confirmed as persisted by the static invoice server, so it is
45 /// not yet ready to receive payments.
46 Pending,
47}
48
49#[derive(Clone)]
50struct AsyncReceiveOffer {
51 offer: Offer,
52 /// The time as duration since the Unix epoch at which this offer was created. Useful when
53 /// refreshing unused offers.
54 created_at: Duration,
55 /// Whether this offer is used, ready for use, or pending invoice persistence with the static
56 /// invoice server.
57 status: OfferStatus,
58
59 /// The below fields are used to generate and persist a new static invoice with the invoice
60 /// server. We support automatically rotating the invoice for long-lived offers so users don't
61 /// have to update the offer they've posted on e.g. their website if fees change or the invoices'
62 /// payment paths become otherwise outdated.
63 offer_nonce: Nonce,
64 update_static_invoice_path: Responder,
65}
66
67impl AsyncReceiveOffer {
68 /// An offer needs to be refreshed if it is unused and has been cached longer than
69 /// `OFFER_REFRESH_THRESHOLD`.
70 fn needs_refresh(&self, duration_since_epoch: Duration) -> bool {
71 let awhile_ago = duration_since_epoch.saturating_sub(OFFER_REFRESH_THRESHOLD);
72 match self.status {
73 OfferStatus::Ready { .. } => self.created_at < awhile_ago,
74 _ => false,
75 }
76 }
77}
78
79impl_writeable_tlv_based_enum!(OfferStatus,
80 (0, Used) => {
81 (0, invoice_created_at, required),
82 },
83 (1, Ready) => {
84 (0, invoice_created_at, required),
85 },
86 (2, Pending) => {},
87);
88
89impl_writeable_tlv_based!(AsyncReceiveOffer, {
90 (0, offer, required),
91 (2, offer_nonce, required),
92 (4, status, required),
93 (6, update_static_invoice_path, required),
94 (8, created_at, required),
95});
96
97/// If we are an often-offline recipient, we'll want to interactively build offers and static
98/// invoices with an always-online node that will serve those static invoices to payers on our
99/// behalf when we are offline.
100///
101/// This struct is used to cache those interactively built offers, and should be passed into
102/// [`OffersMessageFlow`] on startup as well as persisted whenever an offer or invoice is updated.
103///
104/// ## Lifecycle of a cached offer
105///
106/// 1. On initial startup, recipients will request offer paths from the static invoice server
107/// 2. Once a set of offer paths is received, recipients will build an offer and corresponding
108/// static invoice, cache the offer as pending, and send the invoice to the server for
109/// persistence
110/// 3. Once the invoice is confirmed as persisted by the server, the recipient will mark the
111/// corresponding offer as ready to receive payments
112/// 4. If the offer is later returned to the user, it will be kept cached and its invoice will be
113/// kept up-to-date until the offer expires
114/// 5. If the offer does not get returned to the user within a certain timeframe, it will be
115/// replaced with a new one using fresh offer paths requested from the static invoice server
116///
117/// ## Staying in sync with the Static Invoice Server
118///
119/// * Pending offers: for a given cached offer where a corresponding invoice is not yet confirmed as
120/// persisted by the static invoice server, we will retry persisting an invoice for that offer until
121/// it succeeds, once per timer tick
122/// * Confirmed offers that have not yet been returned to the user: we will periodically replace an
123/// unused confirmed offer with a new one, to try to always have a fresh offer available. We wait
124/// several hours in between replacements to ensure the new offer replacement doesn't conflict with
125/// the old one
126/// * Confirmed offers that have been returned to the user: we will send the server a fresh invoice
127/// corresponding to each used offer once per timer tick until the offer expires
128///
129/// [`OffersMessageFlow`]: crate::offers::flow::OffersMessageFlow
130pub struct AsyncReceiveOfferCache {
131 /// The cache is allocated up-front with a fixed number of slots for offers, where each slot is
132 /// filled in with an AsyncReceiveOffer as they are interactively built.
133 ///
134 /// We only want to store a limited number of static invoices with the server, and those stored
135 /// invoices need to regularly be replaced with new ones. When sending a replacement invoice to
136 /// the server, we indicate which invoice is being replaced by the invoice's "slot number",
137 /// see [`ServeStaticInvoice::invoice_slot`]. So rather than internally tracking which cached
138 /// offer corresponds to what invoice slot number on the server's end, we always set the slot
139 /// number to the index of the offer in the cache.
140 ///
141 /// [`ServeStaticInvoice::invoice_slot`]: crate::onion_message::async_payments::ServeStaticInvoice
142 offers: Vec<Option<AsyncReceiveOffer>>,
143 /// Used to limit the number of times we request paths for our offer from the static invoice
144 /// server.
145 #[allow(unused)] // TODO: remove when we get rid of async payments cfg flag
146 offer_paths_request_attempts: u8,
147 /// Blinded paths used to request offer paths from the static invoice server.
148 #[allow(unused)] // TODO: remove when we get rid of async payments cfg flag
149 paths_to_static_invoice_server: Vec<BlindedMessagePath>,
150}
151
152impl AsyncReceiveOfferCache {
153 /// Creates an empty [`AsyncReceiveOfferCache`] to be passed into [`OffersMessageFlow`].
154 ///
155 /// [`OffersMessageFlow`]: crate::offers::flow::OffersMessageFlow
156 pub fn new() -> Self {
157 Self {
158 offers: Vec::new(),
159 offer_paths_request_attempts: 0,
160 paths_to_static_invoice_server: Vec::new(),
161 }
162 }
163
164 pub(super) fn paths_to_static_invoice_server(&self) -> &[BlindedMessagePath] {
165 &self.paths_to_static_invoice_server[..]
166 }
167
168 /// Sets the [`BlindedMessagePath`]s that we will use as an async recipient to interactively build
169 /// [`Offer`]s with a static invoice server, so the server can serve [`StaticInvoice`]s to payers
170 /// on our behalf when we're offline.
171 ///
172 /// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
173 pub(crate) fn set_paths_to_static_invoice_server(
174 &mut self, paths_to_static_invoice_server: Vec<BlindedMessagePath>,
175 ) -> Result<(), ()> {
176 if paths_to_static_invoice_server.is_empty() {
177 return Err(());
178 }
179
180 self.paths_to_static_invoice_server = paths_to_static_invoice_server;
181 if self.offers.is_empty() {
182 // See `AsyncReceiveOfferCache::offers`.
183 self.offers = vec![None; MAX_CACHED_OFFERS_TARGET];
184 }
185 Ok(())
186 }
187}
188
189// The target number of offers we want to have cached at any given time, to mitigate too much
190// reuse of the same offer while also limiting the amount of space our offers take up on the
191// server's end.
192const MAX_CACHED_OFFERS_TARGET: usize = 10;
193
194// The max number of times we'll attempt to request offer paths per timer tick.
195const MAX_UPDATE_ATTEMPTS: u8 = 3;
196
197// If we have an offer that is replaceable and is more than 2 hours old, we can go ahead and refresh
198// it because we always want to have the freshest offer possible when a user goes to retrieve a
199// cached offer.
200//
201// We avoid replacing unused offers too quickly -- this prevents the case where we send multiple
202// invoices from different offers competing for the same slot to the server, messages are received
203// delayed or out-of-order, and we end up providing an offer to the user that the server just
204// deleted and replaced.
205const OFFER_REFRESH_THRESHOLD: Duration = Duration::from_secs(2 * 60 * 60);
206
207/// Invoices stored with the static invoice server may become stale due to outdated channel and fee
208/// info, so they should be updated regularly.
209const INVOICE_REFRESH_THRESHOLD: Duration = Duration::from_secs(2 * 60 * 60);
210
211// Require offer paths that we receive to last at least 3 months.
212const MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 = 3 * 30 * 24 * 60 * 60;
213
214#[cfg(test)]
215pub(crate) const TEST_MAX_CACHED_OFFERS_TARGET: usize = MAX_CACHED_OFFERS_TARGET;
216#[cfg(test)]
217pub(crate) const TEST_MAX_UPDATE_ATTEMPTS: u8 = MAX_UPDATE_ATTEMPTS;
218#[cfg(test)]
219pub(crate) const TEST_OFFER_REFRESH_THRESHOLD: Duration = OFFER_REFRESH_THRESHOLD;
220#[cfg(test)]
221pub(crate) const TEST_INVOICE_REFRESH_THRESHOLD: Duration = INVOICE_REFRESH_THRESHOLD;
222#[cfg(test)]
223pub(crate) const TEST_MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 =
224 MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS;
225
226impl AsyncReceiveOfferCache {
227 /// Retrieve a cached [`Offer`] for receiving async payments as an often-offline recipient, as
228 /// well as returning a bool indicating whether the cache needs to be re-persisted.
229 ///
230 // We need to re-persist the cache if a fresh offer was just marked as used to ensure we continue
231 // to keep this offer's invoice updated and don't replace it with the server.
232 pub(crate) fn get_async_receive_offer(
233 &mut self, duration_since_epoch: Duration,
234 ) -> Result<(Offer, bool), ()> {
235 self.prune_expired_offers(duration_since_epoch, false);
236
237 // Find the freshest unused offer. See `OfferStatus::Ready`.
238 let newest_unused_offer_opt = self
239 .unused_ready_offers()
240 .max_by(|(_, offer_a, _), (_, offer_b, _)| offer_a.created_at.cmp(&offer_b.created_at))
241 .map(|(idx, offer, invoice_created_at)| (idx, offer.offer.clone(), invoice_created_at));
242 if let Some((idx, newest_ready_offer, invoice_created_at)) = newest_unused_offer_opt {
243 self.offers[idx]
244 .as_mut()
245 .map(|offer| offer.status = OfferStatus::Used { invoice_created_at });
246 return Ok((newest_ready_offer, true));
247 }
248
249 // If no unused offers are available, return the used offer with the latest absolute expiry
250 self.offers_with_idx()
251 .filter(|(_, offer)| matches!(offer.status, OfferStatus::Used { .. }))
252 .max_by(|a, b| {
253 let abs_expiry_a = a.1.offer.absolute_expiry().unwrap_or(Duration::MAX);
254 let abs_expiry_b = b.1.offer.absolute_expiry().unwrap_or(Duration::MAX);
255 abs_expiry_a.cmp(&abs_expiry_b)
256 })
257 .map(|(_, cache_offer)| (cache_offer.offer.clone(), false))
258 .ok_or(())
259 }
260
261 /// Remove expired offers from the cache, returning the first slot number in the cache that needs
262 /// a new offer, if any exist.
263 pub(super) fn prune_expired_offers(
264 &mut self, duration_since_epoch: Duration, force_reset_request_attempts: bool,
265 ) -> Option<u16> {
266 // Remove expired offers from the cache.
267 let mut offer_was_removed = false;
268 for offer_opt in self.offers.iter_mut() {
269 let offer_is_expired = offer_opt
270 .as_ref()
271 .map_or(false, |offer| offer.offer.is_expired_no_std(duration_since_epoch));
272 if offer_is_expired {
273 offer_opt.take();
274 offer_was_removed = true;
275 }
276 }
277
278 // Allow up to `MAX_UPDATE_ATTEMPTS` offer paths requests to be sent out roughly once per
279 // minute, or if an offer was removed.
280 if force_reset_request_attempts || offer_was_removed {
281 self.reset_offer_paths_request_attempts()
282 }
283
284 if self.offer_paths_request_attempts >= MAX_UPDATE_ATTEMPTS {
285 return None;
286 }
287
288 self.needs_new_offer_idx(duration_since_epoch).and_then(|idx| {
289 debug_assert!(idx < MAX_CACHED_OFFERS_TARGET);
290 idx.try_into().ok()
291 })
292 }
293
294 /// Returns whether the new paths we've just received from the static invoice server should be used
295 /// to build a new offer.
296 pub(super) fn should_build_offer_with_paths(
297 &self, offer_paths: &[BlindedMessagePath], offer_paths_absolute_expiry_secs: Option<u64>,
298 slot: u16, duration_since_epoch: Duration,
299 ) -> bool {
300 if !self.slot_needs_offer(slot, duration_since_epoch) {
301 return false;
302 }
303
304 // Require the offer that would be built using these paths to last at least
305 // `MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS`.
306 let min_offer_paths_absolute_expiry =
307 duration_since_epoch.as_secs().saturating_add(MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS);
308 let offer_paths_absolute_expiry = offer_paths_absolute_expiry_secs.unwrap_or(u64::MAX);
309 if offer_paths_absolute_expiry < min_offer_paths_absolute_expiry {
310 return false;
311 }
312
313 // Check that we don't have any current offers that already contain these paths
314 self.offers_with_idx().all(|(_, offer)| offer.offer.paths() != offer_paths)
315 }
316
317 /// We've sent a static invoice to the static invoice server for persistence. Cache the
318 /// corresponding pending offer so we can retry persisting a corresponding invoice with the server
319 /// until it succeeds, see [`AsyncReceiveOfferCache`] docs.
320 pub(super) fn cache_pending_offer(
321 &mut self, offer: Offer, offer_paths_absolute_expiry_secs: Option<u64>, offer_nonce: Nonce,
322 update_static_invoice_path: Responder, duration_since_epoch: Duration, slot: u16,
323 ) -> Result<(), ()> {
324 self.prune_expired_offers(duration_since_epoch, false);
325
326 if !self.should_build_offer_with_paths(
327 offer.paths(),
328 offer_paths_absolute_expiry_secs,
329 slot,
330 duration_since_epoch,
331 ) {
332 return Err(());
333 }
334
335 match self.offers.get_mut(slot as usize) {
336 Some(slot) => {
337 *slot = Some(AsyncReceiveOffer {
338 offer,
339 created_at: duration_since_epoch,
340 offer_nonce,
341 status: OfferStatus::Pending,
342 update_static_invoice_path,
343 })
344 },
345 None => {
346 debug_assert!(false, "Slot in cache was invalid but should'be been checked above");
347 return Err(());
348 },
349 }
350
351 Ok(())
352 }
353
354 fn slot_needs_offer(&self, slot: u16, duration_since_epoch: Duration) -> bool {
355 match self.offers.get(slot as usize) {
356 Some(Some(offer)) => offer.needs_refresh(duration_since_epoch),
357 // This slot in the cache was pre-allocated as needing an offer in
358 // `set_paths_to_static_invoice_server` and is currently vacant
359 Some(None) => true,
360 // `slot` is out-of-range. Note that the cache only has `MAX_CACHED_OFFERS_TARGET` slots
361 // total, so any slots outside of that range are invalid.
362 None => {
363 debug_assert!(false, "Got offer paths for a non-existent slot in the cache");
364 false
365 },
366 }
367 }
368
369 /// If we have any empty slots in the cache or offers that can and should be replaced with a fresh
370 /// offer, here we return the index of the slot that needs a new offer. The index is used for
371 /// setting [`OfferPathsRequest::invoice_slot`] when requesting offer paths from the server, so
372 /// the server can include the slot in the offer paths and reply paths that they create in
373 /// response.
374 ///
375 /// Returns `None` if the cache is full and no offers can currently be replaced.
376 ///
377 /// [`OfferPathsRequest::invoice_slot`]: crate::onion_message::async_payments::OfferPathsRequest::invoice_slot
378 fn needs_new_offer_idx(&self, duration_since_epoch: Duration) -> Option<usize> {
379 // If we have any empty offer slots, return the first one we find
380 let empty_slot_idx_opt = self.offers.iter().position(|offer_opt| offer_opt.is_none());
381 if empty_slot_idx_opt.is_some() {
382 return empty_slot_idx_opt;
383 }
384
385 // If all of our offers are already used or pending, then none are available to be replaced
386 let no_replaceable_offers = self.offers_with_idx().all(|(_, offer)| {
387 matches!(offer.status, OfferStatus::Used { .. } | OfferStatus::Pending)
388 });
389 if no_replaceable_offers {
390 return None;
391 }
392
393 // All offers are pending except for one, so we shouldn't request an update of the only usable
394 // offer
395 let num_payable_offers = self
396 .offers_with_idx()
397 .filter(|(_, offer)| {
398 matches!(offer.status, OfferStatus::Used { .. } | OfferStatus::Ready { .. })
399 })
400 .count();
401 if num_payable_offers <= 1 {
402 return None;
403 }
404
405 // Filter for unused offers where longer than OFFER_REFRESH_THRESHOLD time has passed since they
406 // were last updated, so they are stale enough to warrant replacement.
407 self.offers_with_idx()
408 .filter(|(_, offer)| offer.needs_refresh(duration_since_epoch))
409 // Get the stalest offer and return its index
410 .min_by(|(_, offer_a), (_, offer_b)| offer_a.created_at.cmp(&offer_b.created_at))
411 .map(|(idx, _)| idx)
412 }
413
414 /// Returns an iterator over (offer_idx, offer)
415 fn offers_with_idx(&self) -> impl Iterator<Item = (usize, &AsyncReceiveOffer)> {
416 self.offers.iter().enumerate().filter_map(|(idx, offer_opt)| {
417 if let Some(offer) = offer_opt {
418 Some((idx, offer))
419 } else {
420 None
421 }
422 })
423 }
424
425 /// Returns an iterator over (offer_idx, offer, invoice_created_at) where all returned offers are
426 /// [`OfferStatus::Ready`]
427 fn unused_ready_offers(&self) -> impl Iterator<Item = (usize, &AsyncReceiveOffer, Duration)> {
428 self.offers_with_idx().filter_map(|(idx, offer)| match offer.status {
429 OfferStatus::Ready { invoice_created_at } => Some((idx, offer, invoice_created_at)),
430 _ => None,
431 })
432 }
433
434 // Indicates that onion messages requesting new offer paths have been sent to the static invoice
435 // server. Calling this method allows the cache to self-limit how many requests are sent.
436 pub(super) fn new_offers_requested(&mut self) {
437 self.offer_paths_request_attempts += 1;
438 }
439
440 /// Called on timer tick (roughly once per minute) to allow another [`MAX_UPDATE_ATTEMPTS`] offer
441 /// paths requests to go out.
442 fn reset_offer_paths_request_attempts(&mut self) {
443 self.offer_paths_request_attempts = 0;
444 }
445
446 /// Returns an iterator over the list of cached offers where we need to send an updated invoice to
447 /// the static invoice server.
448 pub(super) fn offers_needing_invoice_refresh(
449 &self, duration_since_epoch: Duration,
450 ) -> impl Iterator<Item = (&Offer, Nonce, &Responder)> {
451 // For any offers which are either in use or pending confirmation by the server, we should send
452 // them a fresh invoice on each timer tick.
453 self.offers_with_idx().filter_map(move |(_, offer)| {
454 let needs_invoice_update = match offer.status {
455 OfferStatus::Used { invoice_created_at } => {
456 invoice_created_at.saturating_add(INVOICE_REFRESH_THRESHOLD)
457 < duration_since_epoch
458 },
459 OfferStatus::Pending => true,
460 // Don't bother updating `Ready` offers' invoices on a timer because the offers themselves
461 // are regularly rotated anyway.
462 OfferStatus::Ready { .. } => false,
463 };
464 if needs_invoice_update {
465 Some((&offer.offer, offer.offer_nonce, &offer.update_static_invoice_path))
466 } else {
467 None
468 }
469 })
470 }
471
472 /// Should be called when we receive a [`StaticInvoicePersisted`] message from the static invoice
473 /// server, which indicates that a new offer was persisted by the server and they are ready to
474 /// serve the corresponding static invoice to payers on our behalf.
475 ///
476 /// Returns a bool indicating whether an offer was added/updated and re-persistence of the cache
477 /// is needed.
478 ///
479 /// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
480 pub(super) fn static_invoice_persisted(&mut self, context: AsyncPaymentsContext) -> bool {
481 let (invoice_created_at, offer_id) = match context {
482 AsyncPaymentsContext::StaticInvoicePersisted { invoice_created_at, offer_id } => {
483 (invoice_created_at, offer_id)
484 },
485 _ => return false,
486 };
487
488 let mut offers = self.offers.iter_mut();
489 let offer_entry = offers.find(|o| o.as_ref().map_or(false, |o| o.offer.id() == offer_id));
490 if let Some(Some(ref mut offer)) = offer_entry {
491 match offer.status {
492 OfferStatus::Used { invoice_created_at: ref mut inv_created_at }
493 | OfferStatus::Ready { invoice_created_at: ref mut inv_created_at } => {
494 *inv_created_at = core::cmp::min(invoice_created_at, *inv_created_at);
495 },
496 OfferStatus::Pending => offer.status = OfferStatus::Ready { invoice_created_at },
497 }
498
499 return true;
500 }
501
502 false
503 }
504
505 #[cfg(test)]
506 pub(super) fn test_get_payable_offers(&self) -> Vec<Offer> {
507 self.offers_with_idx()
508 .filter_map(|(_, offer)| {
509 if matches!(offer.status, OfferStatus::Ready { .. })
510 || matches!(offer.status, OfferStatus::Used { .. })
511 {
512 Some(offer.offer.clone())
513 } else {
514 None
515 }
516 })
517 .collect()
518 }
519}
520
521impl Writeable for AsyncReceiveOfferCache {
522 fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
523 write_tlv_fields!(w, {
524 (0, self.offers, required_vec),
525 (2, self.paths_to_static_invoice_server, required_vec),
526 // offer paths request retry info always resets on restart
527 });
528 Ok(())
529 }
530}
531
532impl Readable for AsyncReceiveOfferCache {
533 fn read<R: Read>(r: &mut R) -> Result<Self, DecodeError> {
534 _init_and_read_len_prefixed_tlv_fields!(r, {
535 (0, offers, required_vec),
536 (2, paths_to_static_invoice_server, required_vec),
537 });
538 let offers: Vec<Option<AsyncReceiveOffer>> = offers;
539 Ok(Self { offers, offer_paths_request_attempts: 0, paths_to_static_invoice_server })
540 }
541}