Skip to content

Commit b8dbf64

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 dbd0a44 commit b8dbf64

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,
@@ -85,6 +88,13 @@ impl AsyncReceiveOfferCache {
8588
#[cfg(async_payments)]
8689
const NUM_CACHED_OFFERS_TARGET: usize = 3;
8790

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

204318
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)