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}