Skip to content

Commit 2d9df59

Browse files
Cache offer on StaticInvoicePersisted onion message
As an async recipient, we need to interactively build a static invoice that an always-online node will serve on our behalf. Once this invoice is built and persisted by the static invoice server, they will send us a confirmation onion message. At this time, cache the corresponding offer and mark it as ready to receive async payments.
1 parent 433da78 commit 2d9df59

File tree

4 files changed

+167
-2
lines changed

4 files changed

+167
-2
lines changed

lightning/src/ln/channelmanager.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12877,6 +12877,15 @@ where
1287712877
fn handle_static_invoice_persisted(
1287812878
&self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext,
1287912879
) {
12880+
#[cfg(async_payments)]
12881+
{
12882+
let should_persist = self.flow.handle_static_invoice_persisted(_context);
12883+
let _persistence_guard =
12884+
PersistenceNotifierGuard::optionally_notify(self, || match should_persist {
12885+
true => NotifyOption::DoPersist,
12886+
false => NotifyOption::SkipPersistNoEvents,
12887+
});
12888+
}
1288012889
}
1288112890

1288212891
#[rustfmt::skip]

lightning/src/offers/async_receive_offer_cache.rs

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@ use crate::io::Read;
1616
use crate::ln::msgs::DecodeError;
1717
use crate::offers::nonce::Nonce;
1818
use crate::offers::offer::Offer;
19-
#[cfg(async_payments)]
20-
use crate::onion_message::async_payments::OfferPaths;
2119
use crate::onion_message::messenger::Responder;
2220
use crate::prelude::*;
2321
use crate::util::ser::{Readable, Writeable, Writer};
2422
use core::time::Duration;
23+
#[cfg(async_payments)]
24+
use {
25+
crate::blinded_path::message::AsyncPaymentsContext,
26+
crate::onion_message::async_payments::OfferPaths,
27+
};
2528

2629
struct AsyncReceiveOffer {
2730
offer: Offer,
@@ -84,6 +87,11 @@ impl AsyncReceiveOfferCache {
8487
// reuse of the same offer.
8588
const NUM_CACHED_OFFERS_TARGET: usize = 3;
8689

90+
// Refuse to store offers if they will exceed the maximum cache size or the maximum number of
91+
// offers.
92+
const MAX_CACHE_SIZE: usize = (1 << 10) * 70; // 70KiB
93+
const MAX_OFFERS: usize = 100;
94+
8795
// The max number of times we'll attempt to request offer paths or attempt to refresh a static
8896
// invoice before giving up.
8997
const MAX_UPDATE_ATTEMPTS: u8 = 3;
@@ -190,6 +198,110 @@ impl AsyncReceiveOfferCache {
190198
self.offer_paths_request_attempts = 0;
191199
self.last_offer_paths_request_timestamp = Duration::from_secs(0);
192200
}
201+
202+
/// Should be called when we receive a [`StaticInvoicePersisted`] message from the static invoice
203+
/// server, which indicates that a new offer was persisted by the server and they are ready to
204+
/// serve the corresponding static invoice to payers on our behalf.
205+
///
206+
/// Returns a bool indicating whether an offer was added/updated and re-persistence of the cache
207+
/// is needed.
208+
pub(super) fn static_invoice_persisted(
209+
&mut self, context: AsyncPaymentsContext, duration_since_epoch: Duration,
210+
) -> bool {
211+
let (
212+
candidate_offer,
213+
candidate_offer_nonce,
214+
offer_created_at,
215+
update_static_invoice_path,
216+
static_invoice_absolute_expiry,
217+
) = match context {
218+
AsyncPaymentsContext::StaticInvoicePersisted {
219+
offer,
220+
offer_nonce,
221+
offer_created_at,
222+
update_static_invoice_path,
223+
static_invoice_absolute_expiry,
224+
..
225+
} => (
226+
offer,
227+
offer_nonce,
228+
offer_created_at,
229+
update_static_invoice_path,
230+
static_invoice_absolute_expiry,
231+
),
232+
_ => return false,
233+
};
234+
235+
if candidate_offer.is_expired_no_std(duration_since_epoch) {
236+
return false;
237+
}
238+
if static_invoice_absolute_expiry < duration_since_epoch {
239+
return false;
240+
}
241+
242+
// If the candidate offer is known, either this is a duplicate message or we updated the
243+
// corresponding static invoice that is stored with the server.
244+
if let Some(existing_offer) =
245+
self.offers.iter_mut().find(|cached_offer| cached_offer.offer == candidate_offer)
246+
{
247+
// The blinded path used to update the static invoice corresponding to an offer should never
248+
// change because we reuse the same path every time we update.
249+
debug_assert_eq!(existing_offer.update_static_invoice_path, update_static_invoice_path);
250+
debug_assert_eq!(existing_offer.offer_nonce, candidate_offer_nonce);
251+
252+
let needs_persist =
253+
existing_offer.static_invoice_absolute_expiry != static_invoice_absolute_expiry;
254+
255+
// Since this is the most recent update we've received from the static invoice server, assume
256+
// that the invoice that was just persisted is the only invoice that the server has stored
257+
// corresponding to this offer.
258+
existing_offer.static_invoice_absolute_expiry = static_invoice_absolute_expiry;
259+
existing_offer.invoice_update_attempts = 0;
260+
261+
return needs_persist;
262+
}
263+
264+
let candidate_offer = AsyncReceiveOffer {
265+
offer: candidate_offer,
266+
offer_nonce: candidate_offer_nonce,
267+
offer_created_at,
268+
update_static_invoice_path,
269+
static_invoice_absolute_expiry,
270+
invoice_update_attempts: 0,
271+
};
272+
273+
// If we have room in the cache, go ahead and add this new offer so we have more options. We
274+
// should generally never get close to the cache limit because we limit the number of requests
275+
// for offer persistence that are sent to begin with.
276+
let candidate_cache_size =
277+
self.serialized_length().saturating_add(candidate_offer.serialized_length());
278+
if self.offers.len() < MAX_OFFERS && candidate_cache_size <= MAX_CACHE_SIZE {
279+
self.offers.push(candidate_offer);
280+
return true;
281+
}
282+
283+
// Swap out our lowest expiring offer for this candidate offer if needed. Otherwise we'd be
284+
// risking a situation where all of our existing offers expire soon but we still ignore this one
285+
// even though it's fresh.
286+
const NEVER_EXPIRES: Duration = Duration::from_secs(u64::MAX);
287+
let (soonest_expiring_offer_idx, soonest_offer_expiry) = self
288+
.offers
289+
.iter()
290+
.map(|offer| offer.offer.absolute_expiry().unwrap_or(NEVER_EXPIRES))
291+
.enumerate()
292+
.min_by(|(_, offer_exp_a), (_, offer_exp_b)| offer_exp_a.cmp(offer_exp_b))
293+
.unwrap_or_else(|| {
294+
debug_assert!(false);
295+
(0, NEVER_EXPIRES)
296+
});
297+
298+
if soonest_offer_expiry < candidate_offer.offer.absolute_expiry().unwrap_or(NEVER_EXPIRES) {
299+
self.offers[soonest_expiring_offer_idx] = candidate_offer;
300+
return true;
301+
}
302+
303+
false
304+
}
193305
}
194306

195307
impl Writeable for AsyncReceiveOfferCache {

lightning/src/offers/flow.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,6 +1330,39 @@ where
13301330
Ok((ServeStaticInvoice { invoice: static_invoice }, reply_path_context))
13311331
}
13321332

1333+
/// Handles an incoming [`StaticInvoicePersisted`] onion message from the static invoice server.
1334+
/// Returns a bool indicating whether the async receive offer cache needs to be re-persisted.
1335+
///
1336+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
1337+
#[cfg(async_payments)]
1338+
pub(crate) fn handle_static_invoice_persisted(&self, context: AsyncPaymentsContext) -> bool {
1339+
let expanded_key = &self.inbound_payment_key;
1340+
let duration_since_epoch = self.duration_since_epoch();
1341+
1342+
if let AsyncPaymentsContext::StaticInvoicePersisted {
1343+
nonce,
1344+
hmac,
1345+
path_absolute_expiry,
1346+
..
1347+
} = context
1348+
{
1349+
if let Err(()) =
1350+
signer::verify_static_invoice_persisted_context(nonce, hmac, expanded_key)
1351+
{
1352+
return false;
1353+
}
1354+
1355+
if duration_since_epoch > path_absolute_expiry {
1356+
return false;
1357+
}
1358+
} else {
1359+
return false;
1360+
}
1361+
1362+
let mut cache = self.async_receive_offer_cache.lock().unwrap();
1363+
cache.static_invoice_persisted(context, duration_since_epoch)
1364+
}
1365+
13331366
/// Get the `AsyncReceiveOfferCache` for persistence.
13341367
pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ {
13351368
&self.async_receive_offer_cache

lightning/src/offers/signer.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -617,3 +617,14 @@ pub(crate) fn hmac_for_static_invoice_persisted_context(
617617

618618
Hmac::from_engine(hmac)
619619
}
620+
621+
#[cfg(async_payments)]
622+
pub(crate) fn verify_static_invoice_persisted_context(
623+
nonce: Nonce, hmac: Hmac<Sha256>, expanded_key: &ExpandedKey,
624+
) -> Result<(), ()> {
625+
if hmac_for_static_invoice_persisted_context(nonce, expanded_key) == hmac {
626+
Ok(())
627+
} else {
628+
Err(())
629+
}
630+
}

0 commit comments

Comments
 (0)