Skip to content

Commit 95a4644

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 ff4e60b commit 95a4644

File tree

5 files changed

+276
-3
lines changed

5 files changed

+276
-3
lines changed

lightning/src/blinded_path/message.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use crate::ln::channelmanager::PaymentId;
2323
use crate::ln::msgs::DecodeError;
2424
use crate::ln::onion_utils;
2525
use crate::offers::nonce::Nonce;
26+
use crate::offers::offer::OfferId;
2627
use crate::onion_message::packet::ControlTlvs;
2728
use crate::routing::gossip::{NodeId, ReadOnlyNetworkGraph};
2829
use crate::sign::{EntropySource, NodeSigner, Recipient};
@@ -419,6 +420,25 @@ pub enum AsyncPaymentsContext {
419420
/// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths
420421
path_absolute_expiry: core::time::Duration,
421422
},
423+
/// Context used by a reply path to a [`ServeStaticInvoice`] message, provided back to us in
424+
/// corresponding [`StaticInvoicePersisted`] messages.
425+
///
426+
/// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice
427+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
428+
StaticInvoicePersisted {
429+
/// The id of the offer in the cache corresponding to the [`StaticInvoice`] that has been
430+
/// persisted. This invoice is now ready to be provided by the static invoice server in response
431+
/// to [`InvoiceRequest`]s, so the corresponding offer can be marked as ready to receive
432+
/// payments.
433+
///
434+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
435+
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
436+
offer_id: OfferId,
437+
/// The time as duration since the Unix epoch at which this path expires and messages sent over
438+
/// it should be ignored. If we receive confirmation of an invoice over this path after its
439+
/// expiry, it may be outdated and a new invoice update should be sent instead.
440+
path_absolute_expiry: core::time::Duration,
441+
},
422442
/// Context contained within the reply [`BlindedMessagePath`] we put in outbound
423443
/// [`HeldHtlcAvailable`] messages, provided back to us in corresponding [`ReleaseHeldHtlc`]
424444
/// messages.
@@ -504,6 +524,10 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
504524
(2, OfferPaths) => {
505525
(0, path_absolute_expiry, required),
506526
},
527+
(3, StaticInvoicePersisted) => {
528+
(0, offer_id, required),
529+
(2, path_absolute_expiry, required),
530+
},
507531
);
508532

509533
/// 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
@@ -13426,7 +13426,34 @@ where
1342613426
fn handle_offer_paths(
1342713427
&self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option<Responder>,
1342813428
) -> Option<(ServeStaticInvoice, ResponseInstruction)> {
13429-
None
13429+
#[cfg(async_payments)]
13430+
{
13431+
let responder = match _responder {
13432+
Some(responder) => responder,
13433+
None => return None,
13434+
};
13435+
let (serve_static_invoice, reply_context) = match self.flow.handle_offer_paths(
13436+
_message,
13437+
_context,
13438+
responder.clone(),
13439+
self.get_peers_for_blinded_path(),
13440+
self.list_usable_channels(),
13441+
&*self.entropy_source,
13442+
&*self.router,
13443+
) {
13444+
Some((msg, ctx)) => (msg, ctx),
13445+
None => return None,
13446+
};
13447+
13448+
// We cached a new pending offer, so persist the cache.
13449+
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
13450+
13451+
let response_instructions = responder.respond_with_reply_path(reply_context);
13452+
return Some((serve_static_invoice, response_instructions));
13453+
}
13454+
13455+
#[cfg(not(async_payments))]
13456+
return None;
1343013457
}
1343113458

1343213459
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: 154 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,156 @@ 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_id, offer) = match offer_builder.build() {
1244+
Ok(offer) => (offer.id(), 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 res = self.async_receive_offer_cache.lock().unwrap().cache_pending_offer(
1264+
offer,
1265+
message.paths_absolute_expiry,
1266+
offer_nonce,
1267+
responder,
1268+
duration_since_epoch,
1269+
);
1270+
1271+
let invoice_slot = match res {
1272+
Ok(idx) => idx,
1273+
Err(()) => return None,
1274+
};
1275+
1276+
let reply_path_context = {
1277+
let path_absolute_expiry =
1278+
duration_since_epoch.saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY);
1279+
MessageContext::AsyncPayments(AsyncPaymentsContext::StaticInvoicePersisted {
1280+
offer_id,
1281+
path_absolute_expiry,
1282+
})
1283+
};
1284+
1285+
let serve_invoice_message =
1286+
ServeStaticInvoice { invoice, forward_invoice_request_path, invoice_slot };
1287+
Some((serve_invoice_message, reply_path_context))
1288+
}
1289+
1290+
/// Creates a [`StaticInvoice`] and a blinded path for the server to forward invoice requests from
1291+
/// payers to our node.
1292+
#[cfg(async_payments)]
1293+
fn create_static_invoice_for_server<ES: Deref, R: Deref>(
1294+
&self, offer: &Offer, offer_nonce: Nonce, peers: Vec<MessageForwardNode>,
1295+
usable_channels: Vec<ChannelDetails>, entropy: ES, router: R,
1296+
) -> Result<(StaticInvoice, BlindedMessagePath), ()>
1297+
where
1298+
ES::Target: EntropySource,
1299+
R::Target: Router,
1300+
{
1301+
let expanded_key = &self.inbound_payment_key;
1302+
let duration_since_epoch = self.duration_since_epoch();
1303+
let secp_ctx = &self.secp_ctx;
1304+
1305+
let offer_relative_expiry = offer
1306+
.absolute_expiry()
1307+
.map(|exp| exp.saturating_sub(duration_since_epoch).as_secs())
1308+
.map(|exp_u64| exp_u64.try_into().unwrap_or(u32::MAX))
1309+
.unwrap_or(u32::MAX);
1310+
1311+
// Set the invoice to expire at the same time as the offer. We aim to update this invoice as
1312+
// often as possible, so there shouldn't be any reason to have it expire earlier than the
1313+
// offer.
1314+
let payment_secret = inbound_payment::create_for_spontaneous_payment(
1315+
expanded_key,
1316+
None, // The async receive offers we create are always amount-less
1317+
offer_relative_expiry,
1318+
duration_since_epoch.as_secs(),
1319+
None,
1320+
)?;
1321+
1322+
let invoice = self
1323+
.create_static_invoice_builder(
1324+
&router,
1325+
&*entropy,
1326+
&offer,
1327+
offer_nonce,
1328+
payment_secret,
1329+
offer_relative_expiry,
1330+
usable_channels,
1331+
peers.clone(),
1332+
)
1333+
.and_then(|builder| builder.build_and_sign(secp_ctx))
1334+
.map_err(|_| ())?;
1335+
1336+
let nonce = Nonce::from_entropy_source(&*entropy);
1337+
let context = MessageContext::Offers(OffersContext::InvoiceRequest { nonce });
1338+
let forward_invoice_request_path = self
1339+
.create_blinded_paths(peers, context)
1340+
.and_then(|paths| paths.into_iter().next().ok_or(()))?;
1341+
1342+
Ok((invoice, forward_invoice_request_path))
1343+
}
1344+
11921345
/// Get the `AsyncReceiveOfferCache` for persistence.
11931346
pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ {
11941347
&self.async_receive_offer_cache

0 commit comments

Comments
 (0)