Skip to content

Commit 37b328d

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 165748c commit 37b328d

File tree

4 files changed

+167
-2
lines changed

4 files changed

+167
-2
lines changed

lightning/src/ln/channelmanager.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12877,6 +12877,13 @@ 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+
if should_persist {
12884+
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
12885+
}
12886+
}
1288012887
}
1288112888

1288212889
#[rustfmt::skip]

lightning/src/offers/async_receive_offer_cache.rs

Lines changed: 116 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,
@@ -88,6 +91,13 @@ impl AsyncReceiveOfferCache {
8891
#[cfg(async_payments)]
8992
const NUM_CACHED_OFFERS_TARGET: usize = 3;
9093

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

208322
impl Writeable for AsyncReceiveOfferCache {

lightning/src/offers/flow.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,6 +1337,39 @@ where
13371337
Ok((ServeStaticInvoice { invoice, forward_invoice_request_path }, reply_path_context))
13381338
}
13391339

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