Skip to content

Commit 4ce9d16

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 c19bf67 commit 4ce9d16

File tree

4 files changed

+166
-3
lines changed

4 files changed

+166
-3
lines changed

lightning/src/ln/channelmanager.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12465,7 +12465,17 @@ where
1246512465

1246612466
fn handle_static_invoice_persisted(
1246712467
&self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext,
12468-
) {}
12468+
) {
12469+
#[cfg(async_payments)] {
12470+
let should_persist = self.flow.handle_static_invoice_persisted(_context);
12471+
let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || {
12472+
match should_persist {
12473+
true => NotifyOption::DoPersist,
12474+
false => NotifyOption::SkipPersistNoEvents,
12475+
}
12476+
});
12477+
}
12478+
}
1246912479

1247012480
fn handle_held_htlc_available(
1247112481
&self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext,

lightning/src/offers/async_receive_offer_cache.rs

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

191300
impl Writeable for AsyncReceiveOfferCache {

lightning/src/offers/flow.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,4 +1346,37 @@ where
13461346

13471347
Ok((ServeStaticInvoice { invoice: static_invoice }, reply_path_context))
13481348
}
1349+
1350+
/// Handles an incoming [`StaticInvoicePersisted`] onion message from the static invoice server.
1351+
/// Returns a bool indicating whether the async receive offer cache needs to be re-persisted.
1352+
///
1353+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
1354+
#[cfg(async_payments)]
1355+
pub(crate) fn handle_static_invoice_persisted(&self, context: AsyncPaymentsContext) -> bool {
1356+
let expanded_key = &self.inbound_payment_key;
1357+
let duration_since_epoch = self.duration_since_epoch();
1358+
1359+
if let AsyncPaymentsContext::StaticInvoicePersisted {
1360+
nonce,
1361+
hmac,
1362+
path_absolute_expiry,
1363+
..
1364+
} = context
1365+
{
1366+
if let Err(()) =
1367+
signer::verify_static_invoice_persisted_context(nonce, hmac, expanded_key)
1368+
{
1369+
return false;
1370+
}
1371+
1372+
if duration_since_epoch > path_absolute_expiry {
1373+
return false;
1374+
}
1375+
} else {
1376+
return false;
1377+
}
1378+
1379+
let mut cache = self.async_receive_offer_cache.lock().unwrap();
1380+
cache.static_invoice_persisted(context, duration_since_epoch)
1381+
}
13491382
}

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)