Skip to content

Commit 7359c1d

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 87e1439 commit 7359c1d

File tree

4 files changed

+166
-3
lines changed

4 files changed

+166
-3
lines changed

lightning/src/ln/channelmanager.rs

+11-1
Original file line numberDiff line numberDiff line change
@@ -12384,7 +12384,17 @@ where
1238412384

1238512385
fn handle_static_invoice_persisted(
1238612386
&self, _message: StaticInvoicePersisted, _context: AsyncPaymentsContext,
12387-
) {}
12387+
) {
12388+
#[cfg(async_payments)] {
12389+
let should_persist = self.flow.handle_static_invoice_persisted(_context);
12390+
let _persistence_guard = PersistenceNotifierGuard::optionally_notify(self, || {
12391+
match should_persist {
12392+
true => NotifyOption::DoPersist,
12393+
false => NotifyOption::SkipPersistNoEvents,
12394+
}
12395+
});
12396+
}
12397+
}
1238812398

1238912399
fn handle_held_htlc_available(
1239012400
&self, _message: HeldHtlcAvailable, _context: AsyncPaymentsContext,

lightning/src/offers/async_receive_offer_cache.rs

+111-2
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,14 @@ 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::util::ser::{Readable, Writeable, Writer};
2321
use core::time::Duration;
22+
#[cfg(async_payments)]
23+
use {
24+
crate::blinded_path::message::AsyncPaymentsContext,
25+
crate::onion_message::async_payments::OfferPaths,
26+
};
2427

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

188297
impl Writeable for AsyncReceiveOfferCache {

lightning/src/offers/flow.rs

+33
Original file line numberDiff line numberDiff line change
@@ -1257,4 +1257,37 @@ where
12571257

12581258
Ok((ServeStaticInvoice { invoice: static_invoice }, reply_path_context))
12591259
}
1260+
1261+
/// Handles an incoming [`StaticInvoicePersisted`] onion message from the static invoice server.
1262+
/// Returns a bool indicating whether the async receive offer cache needs to be re-persisted.
1263+
///
1264+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
1265+
#[cfg(async_payments)]
1266+
pub(crate) fn handle_static_invoice_persisted(&self, context: AsyncPaymentsContext) -> bool {
1267+
let expanded_key = &self.inbound_payment_key;
1268+
let duration_since_epoch = self.duration_since_epoch();
1269+
1270+
if let AsyncPaymentsContext::StaticInvoicePersisted {
1271+
nonce,
1272+
hmac,
1273+
path_absolute_expiry,
1274+
..
1275+
} = context
1276+
{
1277+
if let Err(()) =
1278+
signer::verify_static_invoice_persisted_context(nonce, hmac, expanded_key)
1279+
{
1280+
return false;
1281+
}
1282+
1283+
if duration_since_epoch > path_absolute_expiry {
1284+
return false;
1285+
}
1286+
} else {
1287+
return false;
1288+
}
1289+
1290+
let mut cache = self.async_receive_offer_cache.lock().unwrap();
1291+
cache.static_invoice_persisted(context, duration_since_epoch)
1292+
}
12601293
}

lightning/src/offers/signer.rs

+11
Original file line numberDiff line numberDiff line change
@@ -602,3 +602,14 @@ pub(crate) fn hmac_for_static_invoice_persisted_context(
602602

603603
Hmac::from_engine(hmac)
604604
}
605+
606+
#[cfg(async_payments)]
607+
pub(crate) fn verify_static_invoice_persisted_context(
608+
nonce: Nonce, hmac: Hmac<Sha256>, expanded_key: &ExpandedKey,
609+
) -> Result<(), ()> {
610+
if hmac_for_static_invoice_persisted_context(nonce, expanded_key) == hmac {
611+
Ok(())
612+
} else {
613+
Err(())
614+
}
615+
}

0 commit comments

Comments
 (0)