Skip to content

Commit 84917d3

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 7eb8579 commit 84917d3

File tree

5 files changed

+271
-3
lines changed

5 files changed

+271
-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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,10 @@ const MAX_UPDATE_ATTEMPTS: u8 = 3;
187187
#[cfg(async_payments)]
188188
const OFFER_REFRESH_THRESHOLD: Duration = Duration::from_secs(2 * 60 * 60);
189189

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

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

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")]
@@ -1190,6 +1193,156 @@ where
11901193
Ok(())
11911194
}
11921195

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

0 commit comments

Comments
 (0)