@@ -171,6 +171,129 @@ 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 empty_slot_idx_opt = self . offers . iter ( ) . position ( |offer_opt| offer_opt. is_none ( ) ) ;
229
+ if empty_slot_idx_opt. is_some ( ) {
230
+ return empty_slot_idx_opt;
231
+ }
232
+
233
+ // If all of our offers are already used or pending, then none are available to be replaced
234
+ let no_replaceable_offers = self . offers_with_idx ( ) . all ( |( _, offer) | {
235
+ matches ! ( offer. status, OfferStatus :: Used )
236
+ || matches ! ( offer. status, OfferStatus :: Pending )
237
+ } ) ;
238
+ if no_replaceable_offers {
239
+ return None ;
240
+ }
241
+
242
+ // All offers are pending except for one, so we shouldn't request an update of the only usable
243
+ // offer
244
+ let num_payable_offers = self
245
+ . offers_with_idx ( )
246
+ . filter ( |( _, offer) | {
247
+ matches ! ( offer. status, OfferStatus :: Used )
248
+ || matches ! ( offer. status, OfferStatus :: Ready { .. } )
249
+ } )
250
+ . count ( ) ;
251
+ if num_payable_offers <= 1 {
252
+ return None ;
253
+ }
254
+
255
+ // Filter for unused offers that were last updated more than two hours ago, so they are stale
256
+ // enough to warrant replacement.
257
+ let two_hours_ago = duration_since_epoch. saturating_sub ( OFFER_REFRESH_THRESHOLD ) ;
258
+ self . offers_with_idx ( )
259
+ . filter_map ( |( idx, offer) | match offer. status {
260
+ OfferStatus :: Ready { invoice_confirmed_persisted_at } => {
261
+ Some ( ( idx, offer, invoice_confirmed_persisted_at) )
262
+ } ,
263
+ _ => None ,
264
+ } )
265
+ . filter ( |( _, _, invoice_confirmed_persisted_at) | {
266
+ * invoice_confirmed_persisted_at < two_hours_ago
267
+ } )
268
+ // Get the stalest offer and return its index
269
+ . min_by ( |a, b| a. 2 . cmp ( & b. 2 ) )
270
+ . map ( |( idx, _, _) | idx)
271
+ }
272
+
273
+ /// Returns an iterator over (offer_idx, offer)
274
+ fn offers_with_idx ( & self ) -> impl Iterator < Item = ( usize , & AsyncReceiveOffer ) > {
275
+ self . offers . iter ( ) . enumerate ( ) . filter_map ( |( idx, offer_opt) | {
276
+ if let Some ( offer) = offer_opt {
277
+ Some ( ( idx, offer) )
278
+ } else {
279
+ None
280
+ }
281
+ } )
282
+ }
283
+
284
+ // Indicates that onion messages requesting new offer paths have been sent to the static invoice
285
+ // server. Calling this method allows the cache to self-limit how many requests are sent.
286
+ pub ( super ) fn new_offers_requested ( & mut self ) {
287
+ self . offer_paths_request_attempts += 1 ;
288
+ }
289
+
290
+ /// Called on timer tick (roughly once per minute) to allow another MAX_UPDATE_ATTEMPTS offer
291
+ /// paths requests to go out.
292
+ fn reset_offer_paths_request_attempts ( & mut self ) {
293
+ self . offer_paths_request_attempts = 0 ;
294
+ }
295
+ }
296
+
174
297
impl Writeable for AsyncReceiveOfferCache {
175
298
fn write < W : Writer > ( & self , w : & mut W ) -> Result < ( ) , io:: Error > {
176
299
write_tlv_fields ! ( w, {
0 commit comments