@@ -171,6 +171,131 @@ impl AsyncReceiveOfferCache {
171
171
#[ cfg( async_payments) ]
172
172
const MAX_CACHED_OFFERS_TARGET : usize = 10 ;
173
173
174
+ // The max number of times we'll attempt to request offer paths per timer tick.
175
+ #[ cfg( async_payments) ]
176
+ const MAX_UPDATE_ATTEMPTS : u8 = 3 ;
177
+
178
+ // If we have an offer that is replaceable and its invoice was confirmed as persisted more than 2
179
+ // hours ago, we can go ahead and refresh it because we always want to have the freshest offer
180
+ // possible when a user goes to retrieve a cached offer.
181
+ //
182
+ // We avoid replacing unused offers too quickly -- this prevents the case where we send multiple
183
+ // invoices from different offers competing for the same slot to the server, messages are received
184
+ // delayed or out-of-order, and we end up providing an offer to the user that the server just
185
+ // deleted and replaced.
186
+ #[ cfg( async_payments) ]
187
+ const OFFER_REFRESH_THRESHOLD : Duration = Duration :: from_secs ( 2 * 60 * 60 ) ;
188
+
189
+ #[ cfg( async_payments) ]
190
+ impl AsyncReceiveOfferCache {
191
+ /// Remove expired offers from the cache, returning whether new offers are needed.
192
+ pub ( super ) fn prune_expired_offers (
193
+ & mut self , duration_since_epoch : Duration , timer_tick_occurred : bool ,
194
+ ) -> bool {
195
+ // Remove expired offers from the cache.
196
+ let mut offer_was_removed = false ;
197
+ for offer_opt in self . offers . iter_mut ( ) {
198
+ let offer_is_expired = offer_opt
199
+ . as_ref ( )
200
+ . map_or ( false , |offer| offer. offer . is_expired_no_std ( duration_since_epoch) ) ;
201
+ if offer_is_expired {
202
+ offer_opt. take ( ) ;
203
+ offer_was_removed = true ;
204
+ }
205
+ }
206
+
207
+ // Allow more offer paths requests to be sent out in a burst roughly once per minute, or if an
208
+ // offer was removed.
209
+ if timer_tick_occurred || offer_was_removed {
210
+ self . reset_offer_paths_request_attempts ( )
211
+ }
212
+
213
+ self . needs_new_offer_idx ( duration_since_epoch) . is_some ( )
214
+ && self . offer_paths_request_attempts < MAX_UPDATE_ATTEMPTS
215
+ }
216
+
217
+ /// If we have any empty slots in the cache or offers where the offer can and should be replaced
218
+ /// with a fresh offer, here we return the index of the slot that needs a new offer. The index is
219
+ /// used for setting [`ServeStaticInvoice::invoice_slot`] when sending the corresponding new
220
+ /// static invoice to the server, so the server knows which existing persisted invoice is being
221
+ /// replaced, if any.
222
+ ///
223
+ /// Returns `None` if the cache is full and no offers can currently be replaced.
224
+ ///
225
+ /// [`ServeStaticInvoice::invoice_slot`]: crate::onion_message::async_payments::ServeStaticInvoice::invoice_slot
226
+ fn needs_new_offer_idx ( & self , duration_since_epoch : Duration ) -> Option < usize > {
227
+ // If we have any empty offer slots, return the first one we find
228
+ let mut offers_opt_iter = self . offers . iter ( ) . enumerate ( ) ;
229
+ let empty_slot_idx_opt =
230
+ offers_opt_iter. find_map ( |( idx, offer_opt) | offer_opt. is_none ( ) . then ( || idx) ) ;
231
+ if empty_slot_idx_opt. is_some ( ) {
232
+ return empty_slot_idx_opt;
233
+ }
234
+
235
+ // If all of our offers are already used or pending, then none are available to be replaced
236
+ let no_replaceable_offers = self . offers_with_idx ( ) . all ( |( _, offer) | {
237
+ matches ! ( offer. status, OfferStatus :: Used )
238
+ || matches ! ( offer. status, OfferStatus :: Pending )
239
+ } ) ;
240
+ if no_replaceable_offers {
241
+ return None ;
242
+ }
243
+
244
+ // If we only have 1 offer that is available for payments, then none are available to be
245
+ // replaced
246
+ let num_payable_offers = self
247
+ . offers_with_idx ( )
248
+ . filter ( |( _, offer) | {
249
+ matches ! ( offer. status, OfferStatus :: Used )
250
+ || matches ! ( offer. status, OfferStatus :: Ready { .. } )
251
+ } )
252
+ . count ( ) ;
253
+ if num_payable_offers <= 1 {
254
+ return None ;
255
+ }
256
+
257
+ // Filter for unused offers that were last updated more than two hours ago, so they are stale
258
+ // enough to warrant replacement.
259
+ let two_hours_ago = duration_since_epoch. saturating_sub ( OFFER_REFRESH_THRESHOLD ) ;
260
+ self . offers_with_idx ( )
261
+ . filter_map ( |( idx, offer) | match offer. status {
262
+ OfferStatus :: Ready { invoice_confirmed_persisted_at } => {
263
+ Some ( ( idx, offer, invoice_confirmed_persisted_at) )
264
+ } ,
265
+ _ => None ,
266
+ } )
267
+ . filter ( |( _, _, invoice_confirmed_persisted_at) | {
268
+ * invoice_confirmed_persisted_at < two_hours_ago
269
+ } )
270
+ // Get the stalest offer and return its index
271
+ . min_by ( |a, b| a. 2 . cmp ( & b. 2 ) )
272
+ . map ( |( idx, _, _) | idx)
273
+ }
274
+
275
+ /// Returns an iterator over (offer_idx, offer)
276
+ fn offers_with_idx ( & self ) -> impl Iterator < Item = ( usize , & AsyncReceiveOffer ) > {
277
+ self . offers . iter ( ) . enumerate ( ) . filter_map ( |( idx, offer_opt) | {
278
+ if let Some ( offer) = offer_opt {
279
+ Some ( ( idx, offer) )
280
+ } else {
281
+ None
282
+ }
283
+ } )
284
+ }
285
+
286
+ // Indicates that onion messages requesting new offer paths have been sent to the static invoice
287
+ // server. Calling this method allows the cache to self-limit how many requests are sent.
288
+ pub ( super ) fn new_offers_requested ( & mut self ) {
289
+ self . offer_paths_request_attempts += 1 ;
290
+ }
291
+
292
+ /// Called on timer tick (roughly once per minute) to allow another MAX_UPDATE_ATTEMPTS offer
293
+ /// paths requests to go out.
294
+ fn reset_offer_paths_request_attempts ( & mut self ) {
295
+ self . offer_paths_request_attempts = 0 ;
296
+ }
297
+ }
298
+
174
299
impl Writeable for AsyncReceiveOfferCache {
175
300
fn write < W : Writer > ( & self , w : & mut W ) -> Result < ( ) , io:: Error > {
176
301
write_tlv_fields ! ( w, {
0 commit comments