Skip to content

Commit 1cb0087

Browse files
Send static invoice in response to offer paths
As an async recipient, we need to interactively build a static invoice that an always-online node will serve to payers on our behalf. As part of this process, the static invoice server sends us blinded message paths to include in our offer so they'll receive invoice requests from senders trying to pay us while we're offline. On receipt of these paths, create an offer and static invoice and send the invoice back to the server so they can provide the invoice to payers, as well as caching the offer as pending.
1 parent d988c46 commit 1cb0087

File tree

5 files changed

+277
-3
lines changed

5 files changed

+277
-3
lines changed

lightning/src/blinded_path/message.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,25 @@ pub enum AsyncPaymentsContext {
417417
/// offer paths if we are no longer configured to accept paths from them.
418418
path_absolute_expiry: core::time::Duration,
419419
},
420+
/// Context used by a reply path to a [`ServeStaticInvoice`] message, provided back to us in
421+
/// corresponding [`StaticInvoicePersisted`] messages.
422+
///
423+
/// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice
424+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
425+
StaticInvoicePersisted {
426+
/// The index of the offer in the cache corresponding to the [`StaticInvoice`] that has been
427+
/// persisted. This invoice is now ready to be provided by the static invoice server in response
428+
/// to [`InvoiceRequest`]s, so the corresponding offer can be marked as ready to receive
429+
/// payments.
430+
///
431+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
432+
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
433+
offer_slot: u8,
434+
/// The time as duration since the Unix epoch at which this path expires and messages sent over
435+
/// it should be ignored. This prevents edge cases if the onion message sent over this blinded
436+
/// path is incredibly delayed and the static invoice confirmation is outdated.
437+
path_absolute_expiry: core::time::Duration,
438+
},
420439
/// Context contained within the reply [`BlindedMessagePath`] we put in outbound
421440
/// [`HeldHtlcAvailable`] messages, provided back to us in corresponding [`ReleaseHeldHtlc`]
422441
/// messages.
@@ -502,6 +521,10 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
502521
(2, OfferPaths) => {
503522
(0, path_absolute_expiry, required),
504523
},
524+
(3, StaticInvoicePersisted) => {
525+
(0, offer_slot, required),
526+
(2, path_absolute_expiry, required),
527+
},
505528
);
506529

507530
/// Contains a simple nonce for use in a blinded path's context.

lightning/src/ln/channelmanager.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13041,7 +13041,34 @@ where
1304113041
fn handle_offer_paths(
1304213042
&self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option<Responder>,
1304313043
) -> Option<(ServeStaticInvoice, ResponseInstruction)> {
13044-
None
13044+
#[cfg(async_payments)]
13045+
{
13046+
let responder = match _responder {
13047+
Some(responder) => responder,
13048+
None => return None,
13049+
};
13050+
let (serve_static_invoice, reply_context) = match self.flow.handle_offer_paths(
13051+
_message,
13052+
_context,
13053+
responder.clone(),
13054+
self.get_peers_for_blinded_path(),
13055+
self.list_usable_channels(),
13056+
&*self.entropy_source,
13057+
&*self.router,
13058+
) {
13059+
Some((msg, ctx)) => (msg, ctx),
13060+
None => return None,
13061+
};
13062+
13063+
// We cached a new pending offer, so persist the cache.
13064+
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
13065+
13066+
let response_instructions = responder.respond_with_reply_path(reply_context);
13067+
return Some((serve_static_invoice, response_instructions));
13068+
}
13069+
13070+
#[cfg(not(async_payments))]
13071+
return None;
1304513072
}
1304613073

1304713074
fn handle_serve_static_invoice(

lightning/src/ln/inbound_payment.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ pub fn create_from_hash(
214214
}
215215

216216
#[cfg(async_payments)]
217-
pub(super) fn create_for_spontaneous_payment(
217+
pub(crate) fn create_for_spontaneous_payment(
218218
keys: &ExpandedKey, min_value_msat: Option<u64>, invoice_expiry_delta_secs: u32,
219219
current_time: u64, min_final_cltv_expiry_delta: Option<u16>,
220220
) -> Result<PaymentSecret, ()> {

lightning/src/offers/async_receive_offer_cache.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,10 @@ const MAX_UPDATE_ATTEMPTS: u8 = 3;
186186
#[cfg(async_payments)]
187187
const OFFER_REFRESH_THRESHOLD: Duration = Duration::from_secs(2 * 60 * 60);
188188

189+
// Require offer paths that we receive to last at least 3 months.
190+
#[cfg(async_payments)]
191+
const MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 = 3 * 30 * 24 * 60 * 60;
192+
189193
#[cfg(async_payments)]
190194
impl AsyncReceiveOfferCache {
191195
/// Remove expired offers from the cache, returning whether new offers are needed.
@@ -214,6 +218,71 @@ impl AsyncReceiveOfferCache {
214218
&& self.offer_paths_request_attempts < MAX_UPDATE_ATTEMPTS
215219
}
216220

221+
/// Returns whether the new paths we've just received from the static invoice server should be used
222+
/// to build a new offer.
223+
pub(super) fn should_build_offer_with_paths(
224+
&self, offer_paths: &[BlindedMessagePath], offer_paths_absolute_expiry_secs: Option<u64>,
225+
duration_since_epoch: Duration,
226+
) -> bool {
227+
if self.needs_new_offer_idx(duration_since_epoch).is_none() {
228+
return false;
229+
}
230+
231+
// Require the offer that would be built using these paths to last at least
232+
// MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS.
233+
let min_offer_paths_absolute_expiry =
234+
duration_since_epoch.as_secs().saturating_add(MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS);
235+
let offer_paths_absolute_expiry = offer_paths_absolute_expiry_secs.unwrap_or(u64::MAX);
236+
if offer_paths_absolute_expiry < min_offer_paths_absolute_expiry {
237+
return false;
238+
}
239+
240+
// Check that we don't have any current offers that already contain these paths
241+
self.offers_with_idx().all(|(_, offer)| offer.offer.paths() != offer_paths)
242+
}
243+
244+
/// We've sent a static invoice to the static invoice server for persistence. Cache the
245+
/// corresponding pending offer so we can retry persisting a corresponding invoice with the server
246+
/// until it succeeds.
247+
///
248+
/// We need to keep retrying an invoice for this particular offer until it succeeds to prevent
249+
/// edge cases where we send invoices from different offers competing for the same slot on the
250+
/// server's end, receive messages out-of-order, and end up returning an offer to the user where
251+
/// the server just deleted and replaced the corresponding invoice. See `OFFER_REFRESH_THRESHOLD`.
252+
pub(super) fn cache_pending_offer(
253+
&mut self, offer: Offer, offer_paths_absolute_expiry_secs: Option<u64>, offer_nonce: Nonce,
254+
update_static_invoice_path: Responder, duration_since_epoch: Duration,
255+
) -> Result<u8, ()> {
256+
self.prune_expired_offers(duration_since_epoch, false);
257+
258+
if !self.should_build_offer_with_paths(
259+
offer.paths(),
260+
offer_paths_absolute_expiry_secs,
261+
duration_since_epoch,
262+
) {
263+
return Err(());
264+
}
265+
266+
let idx = match self.needs_new_offer_idx(duration_since_epoch) {
267+
Some(idx) => idx,
268+
None => return Err(()),
269+
};
270+
271+
match self.offers.get_mut(idx) {
272+
Some(offer_opt) => {
273+
*offer_opt = Some(AsyncReceiveOffer {
274+
offer,
275+
offer_nonce,
276+
status: OfferStatus::Pending,
277+
update_static_invoice_path,
278+
});
279+
},
280+
None => return Err(()),
281+
}
282+
283+
Ok(idx.try_into().map_err(|_| ())?)
284+
}
285+
217286
/// If we have any empty slots in the cache or offers where the offer can and should be replaced
218287
/// with a fresh offer, here we return the index of the slot that needs a new offer. The index is
219288
/// used for setting [`ServeStaticInvoice::invoice_slot`] when sending the corresponding new

lightning/src/offers/flow.rs

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@ use {
6666
crate::offers::offer::Amount,
6767
crate::offers::signer,
6868
crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder},
69-
crate::onion_message::async_payments::{HeldHtlcAvailable, OfferPathsRequest},
69+
crate::onion_message::async_payments::{
70+
HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ServeStaticInvoice,
71+
},
72+
crate::onion_message::messenger::Responder,
7073
};
7174

7275
#[cfg(feature = "dnssec")]
@@ -1189,6 +1192,158 @@ where
11891192
Ok(())
11901193
}
11911194

1195+
/// Handles an incoming [`OfferPaths`] message from the static invoice server, sending out
1196+
/// [`ServeStaticInvoice`] onion messages in response if we've built a new async receive offer and
1197+
/// need the corresponding [`StaticInvoice`] to be persisted by the static invoice server.
1198+
///
1199+
/// Returns `None` if we have enough offers cached already, verification of `message` fails, or we
1200+
/// fail to create blinded paths.
1201+
#[cfg(async_payments)]
1202+
pub(crate) fn handle_offer_paths<ES: Deref, R: Deref>(
1203+
&self, message: OfferPaths, context: AsyncPaymentsContext, responder: Responder,
1204+
peers: Vec<MessageForwardNode>, usable_channels: Vec<ChannelDetails>, entropy: ES,
1205+
router: R,
1206+
) -> Option<(ServeStaticInvoice, MessageContext)>
1207+
where
1208+
ES::Target: EntropySource,
1209+
R::Target: Router,
1210+
{
1211+
let duration_since_epoch = self.duration_since_epoch();
1212+
match context {
1213+
AsyncPaymentsContext::OfferPaths { path_absolute_expiry } => {
1214+
if duration_since_epoch > path_absolute_expiry {
1215+
return None;
1216+
}
1217+
},
1218+
_ => return None,
1219+
}
1220+
1221+
{
1222+
// Only respond with `ServeStaticInvoice` if we actually need a new offer built.
1223+
let mut cache = self.async_receive_offer_cache.lock().unwrap();
1224+
cache.prune_expired_offers(duration_since_epoch, false);
1225+
if !cache.should_build_offer_with_paths(
1226+
&message.paths[..],
1227+
message.paths_absolute_expiry,
1228+
duration_since_epoch,
1229+
) {
1230+
return None;
1231+
}
1232+
}
1233+
1234+
let (mut offer_builder, offer_nonce) =
1235+
match self.create_async_receive_offer_builder(&*entropy, message.paths) {
1236+
Ok((builder, nonce)) => (builder, nonce),
1237+
Err(_) => return None, // Only reachable if OfferPaths::paths is empty
1238+
};
1239+
if let Some(paths_absolute_expiry) = message.paths_absolute_expiry {
1240+
offer_builder =
1241+
offer_builder.absolute_expiry(Duration::from_secs(paths_absolute_expiry));
1242+
}
1243+
let offer = match offer_builder.build() {
1244+
Ok(offer) => offer,
1245+
Err(_) => {
1246+
debug_assert!(false);
1247+
return None;
1248+
},
1249+
};
1250+
1251+
let (invoice, forward_invoice_request_path) = match self.create_static_invoice_for_server(
1252+
&offer,
1253+
offer_nonce,
1254+
peers,
1255+
usable_channels,
1256+
&*entropy,
1257+
router,
1258+
) {
1259+
Ok(res) => res,
1260+
Err(()) => return None,
1261+
};
1262+
1263+
let mut cache = self.async_receive_offer_cache.lock().unwrap();
1264+
let cache_offer_res = cache.cache_pending_offer(
1265+
offer,
1266+
message.paths_absolute_expiry,
1267+
offer_nonce,
1268+
responder,
1269+
duration_since_epoch,
1270+
);
1271+
core::mem::drop(cache);
1272+
1273+
let invoice_slot = match cache_offer_res {
1274+
Ok(idx) => idx,
1275+
Err(()) => return None,
1276+
};
1277+
1278+
let reply_path_context = {
1279+
let path_absolute_expiry =
1280+
duration_since_epoch.saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY);
1281+
MessageContext::AsyncPayments(AsyncPaymentsContext::StaticInvoicePersisted {
1282+
path_absolute_expiry,
1283+
offer_slot: invoice_slot,
1284+
})
1285+
};
1286+
1287+
let serve_invoice_message =
1288+
ServeStaticInvoice { invoice, forward_invoice_request_path, invoice_slot };
1289+
Some((serve_invoice_message, reply_path_context))
1290+
}
1291+
1292+
/// Creates a [`StaticInvoice`] and a blinded path for the server to forward invoice requests from
1293+
/// payers to our node.
1294+
#[cfg(async_payments)]
1295+
fn create_static_invoice_for_server<ES: Deref, R: Deref>(
1296+
&self, offer: &Offer, offer_nonce: Nonce, peers: Vec<MessageForwardNode>,
1297+
usable_channels: Vec<ChannelDetails>, entropy: ES, router: R,
1298+
) -> Result<(StaticInvoice, BlindedMessagePath), ()>
1299+
where
1300+
ES::Target: EntropySource,
1301+
R::Target: Router,
1302+
{
1303+
let expanded_key = &self.inbound_payment_key;
1304+
let duration_since_epoch = self.duration_since_epoch();
1305+
let secp_ctx = &self.secp_ctx;
1306+
1307+
let offer_relative_expiry = offer
1308+
.absolute_expiry()
1309+
.map(|exp| exp.saturating_sub(duration_since_epoch).as_secs())
1310+
.map(|exp_u64| exp_u64.try_into().unwrap_or(u32::MAX))
1311+
.unwrap_or(u32::MAX);
1312+
1313+
// Set the invoice to expire at the same time as the offer. We aim to update this invoice as
1314+
// often as possible, so there shouldn't be any reason to have it expire earlier than the
1315+
// offer.
1316+
let payment_secret = inbound_payment::create_for_spontaneous_payment(
1317+
expanded_key,
1318+
None, // The async receive offers we create are always amount-less
1319+
offer_relative_expiry,
1320+
duration_since_epoch.as_secs(),
1321+
None,
1322+
)?;
1323+
1324+
let invoice = self
1325+
.create_static_invoice_builder(
1326+
&router,
1327+
&*entropy,
1328+
&offer,
1329+
offer_nonce,
1330+
payment_secret,
1331+
offer_relative_expiry,
1332+
usable_channels,
1333+
peers.clone(),
1334+
)
1335+
.and_then(|builder| builder.build_and_sign(secp_ctx))
1336+
.map_err(|_| ())?;
1337+
1338+
let nonce = Nonce::from_entropy_source(&*entropy);
1339+
let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce });
1340+
let forward_invoice_request_path = self
1341+
.create_blinded_paths(peers, context)
1342+
.and_then(|paths| paths.into_iter().next().ok_or(()))?;
1343+
1344+
Ok((invoice, forward_invoice_request_path))
1345+
}
1346+
11921347
/// Get the `AsyncReceiveOfferCache` for persistence.
11931348
pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ {
11941349
&self.async_receive_offer_cache

0 commit comments

Comments
 (0)