Skip to content

Commit 87e1439

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.
1 parent 14d7a72 commit 87e1439

File tree

5 files changed

+305
-12
lines changed

5 files changed

+305
-12
lines changed

lightning/src/blinded_path/message.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ 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::Offer;
27+
use crate::onion_message::messenger::Responder;
2628
use crate::onion_message::packet::ControlTlvs;
2729
use crate::routing::gossip::{NodeId, ReadOnlyNetworkGraph};
2830
use crate::sign::{EntropySource, NodeSigner, Recipient};
@@ -424,6 +426,58 @@ pub enum AsyncPaymentsContext {
424426
/// is no longer configured to accept paths from them.
425427
path_absolute_expiry: core::time::Duration,
426428
},
429+
/// Context used by a reply path to a [`ServeStaticInvoice`] message, provided back to us in
430+
/// corresponding [`StaticInvoicePersisted`] messages.
431+
///
432+
/// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice
433+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
434+
StaticInvoicePersisted {
435+
/// The offer corresponding to the [`StaticInvoice`] that has been persisted. This invoice is
436+
/// now ready to be provided by the static invoice server in response to [`InvoiceRequest`]s.
437+
///
438+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
439+
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
440+
offer: Offer,
441+
/// A [`Nonce`] useful for updating the [`StaticInvoice`] that corresponds to the
442+
/// [`AsyncPaymentsContext::StaticInvoicePersisted::offer`], since the offer may be much longer
443+
/// lived than the invoice.
444+
///
445+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
446+
offer_nonce: Nonce,
447+
/// Useful to determine how far an offer is into its lifespan, to decide whether the offer is
448+
/// expiring soon and we should start building a new one.
449+
offer_created_at: core::time::Duration,
450+
/// A [`Responder`] useful for updating the [`StaticInvoice`] that corresponds to the
451+
/// [`AsyncPaymentsContext::StaticInvoicePersisted::offer`], since the offer may be much longer
452+
/// lived than the invoice.
453+
///
454+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
455+
update_static_invoice_path: Responder,
456+
/// The time as duration since the Unix epoch at which the [`StaticInvoice`] expires, used to track
457+
/// when we need to generate and persist a new invoice with the static invoice server.
458+
///
459+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
460+
static_invoice_absolute_expiry: core::time::Duration,
461+
/// A nonce used for authenticating that a [`StaticInvoicePersisted`] message is valid for a
462+
/// preceding [`ServeStaticInvoice`] message.
463+
///
464+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
465+
/// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice
466+
nonce: Nonce,
467+
/// Authentication code for the [`StaticInvoicePersisted`] message.
468+
///
469+
/// Prevents nodes from creating their own blinded path to us and causing us to cache an
470+
/// unintended async receive offer.
471+
///
472+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
473+
hmac: Hmac<Sha256>,
474+
/// The time as duration since the Unix epoch at which this path expires and messages sent over
475+
/// it should be ignored.
476+
///
477+
/// Prevents a static invoice server from causing an async recipient to cache an old offer if
478+
/// the recipient is no longer configured to use that server.
479+
path_absolute_expiry: core::time::Duration,
480+
},
427481
/// Context contained within the reply [`BlindedMessagePath`] we put in outbound
428482
/// [`HeldHtlcAvailable`] messages, provided back to us in corresponding [`ReleaseHeldHtlc`]
429483
/// messages.
@@ -511,6 +565,16 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
511565
(2, hmac, required),
512566
(4, path_absolute_expiry, required),
513567
},
568+
(3, StaticInvoicePersisted) => {
569+
(0, offer, required),
570+
(2, offer_nonce, required),
571+
(4, offer_created_at, required),
572+
(6, update_static_invoice_path, required),
573+
(8, static_invoice_absolute_expiry, required),
574+
(10, nonce, required),
575+
(12, hmac, required),
576+
(14, path_absolute_expiry, required),
577+
},
514578
);
515579

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

lightning/src/ln/channelmanager.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12356,7 +12356,25 @@ where
1235612356
fn handle_offer_paths(
1235712357
&self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option<Responder>,
1235812358
) -> Option<(ServeStaticInvoice, ResponseInstruction)> {
12359-
None
12359+
#[cfg(async_payments)] {
12360+
let responder = match _responder {
12361+
Some(responder) => responder,
12362+
None => return None
12363+
};
12364+
let (serve_static_invoice, reply_context) =
12365+
match self.flow.handle_offer_paths(
12366+
_message, _context, responder.clone(), self.get_peers_for_blinded_path(),
12367+
self.list_usable_channels()
12368+
) {
12369+
Some((msg, ctx)) => (msg, ctx),
12370+
None => return None,
12371+
};
12372+
let response_instructions = responder.respond_with_reply_path(reply_context);
12373+
return Some((serve_static_invoice, response_instructions))
12374+
}
12375+
12376+
#[cfg(not(async_payments))]
12377+
return None
1236012378
}
1236112379

1236212380
fn handle_serve_static_invoice(

lightning/src/offers/async_receive_offer_cache.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ 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;
1921
use crate::onion_message::messenger::Responder;
2022
use crate::util::ser::{Readable, Writeable, Writer};
2123
use core::time::Duration;
@@ -94,6 +96,29 @@ impl AsyncReceiveOfferCache {
9496
&& self.offer_paths_request_attempts < Self::MAX_UPDATE_ATTEMPTS
9597
}
9698

99+
/// Returns whether the new paths we've just received from the static invoice server should be used
100+
/// to build a new offer.
101+
pub(super) fn should_build_offer_with_paths(
102+
&mut self, message: &OfferPaths, duration_since_epoch: Duration,
103+
) -> bool {
104+
let needs_new_offers = self.check_expire_offers(duration_since_epoch);
105+
if !needs_new_offers {
106+
return false;
107+
}
108+
109+
// Require the offer that would be built using these paths to last at least a few hours.
110+
let min_offer_paths_absolute_expiry =
111+
duration_since_epoch.as_secs().saturating_add(3 * 60 * 60);
112+
let offer_paths_absolute_expiry =
113+
message.paths_absolute_expiry.map(|exp| exp.as_secs()).unwrap_or(u64::MAX);
114+
if offer_paths_absolute_expiry < min_offer_paths_absolute_expiry {
115+
return false;
116+
}
117+
118+
// Check that we don't have any current offers that already contain these paths
119+
self.offers.iter().all(|offer| offer.offer.paths() != message.paths)
120+
}
121+
97122
/// Removes expired offers from our cache, returning whether new offers are needed.
98123
fn check_expire_offers(&mut self, duration_since_epoch: Duration) -> bool {
99124
// Remove expired offers from the cache.

lightning/src/offers/flow.rs

Lines changed: 168 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ use {
7070
StaticInvoice, StaticInvoiceBuilder,
7171
DEFAULT_RELATIVE_EXPIRY as STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY,
7272
},
73-
crate::onion_message::async_payments::{HeldHtlcAvailable, OfferPathsRequest},
73+
crate::onion_message::async_payments::{
74+
HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ServeStaticInvoice,
75+
},
76+
crate::onion_message::messenger::Responder,
7477
};
7578

7679
#[cfg(feature = "dnssec")]
@@ -477,29 +480,61 @@ where
477480
&self, absolute_expiry: Option<Duration>, nonce: Option<Nonce>,
478481
peers: Vec<MessageForwardNode>,
479482
) -> Result<OfferBuilder<DerivedMetadata, secp256k1::All>, Bolt12SemanticError> {
483+
self.create_offer_builder_internal(absolute_expiry, nonce, peers, None)
484+
.map(|(offer, _nonce)| offer)
485+
}
486+
487+
/// NB: the offer utils diff in this commit will go away when the offers message flow PR is updated
488+
pub fn create_async_receive_offer_builder(
489+
&self, absolute_expiry: Option<Duration>, peers: Vec<MessageForwardNode>,
490+
message_paths_to_always_online_node: Vec<BlindedMessagePath>,
491+
) -> Result<(OfferBuilder<DerivedMetadata, secp256k1::All>, Nonce), Bolt12SemanticError> {
492+
self.create_offer_builder_internal(
493+
absolute_expiry,
494+
None,
495+
peers,
496+
Some(message_paths_to_always_online_node),
497+
)
498+
}
499+
500+
fn create_offer_builder_internal(
501+
&self, absolute_expiry: Option<Duration>, nonce: Option<Nonce>,
502+
peers: Vec<MessageForwardNode>,
503+
message_paths_to_always_online_node: Option<Vec<BlindedMessagePath>>,
504+
) -> Result<(OfferBuilder<DerivedMetadata, secp256k1::All>, Nonce), Bolt12SemanticError> {
480505
let node_id = self.get_our_node_id();
481506
let expanded_key = &self.inbound_payment_key;
482507
let entropy = &*self.entropy_source;
483508
let secp_ctx = &self.secp_ctx;
484509

485-
let nonce = nonce.unwrap_or(Nonce::from_entropy_source(entropy));
486-
let context = OffersContext::InvoiceRequest { nonce };
510+
let offer_nonce = nonce.unwrap_or(Nonce::from_entropy_source(entropy));
511+
let context = OffersContext::InvoiceRequest { nonce: offer_nonce };
487512

488-
let path = self
489-
.create_blinded_paths_using_absolute_expiry(context, absolute_expiry, peers)
490-
.and_then(|paths| paths.into_iter().next().ok_or(()))
491-
.map_err(|_| Bolt12SemanticError::MissingPaths)?;
513+
let mut builder =
514+
OfferBuilder::deriving_signing_pubkey(node_id, expanded_key, offer_nonce, secp_ctx)
515+
.chain_hash(self.chain_hash);
492516

493-
let builder = OfferBuilder::deriving_signing_pubkey(node_id, expanded_key, nonce, secp_ctx)
494-
.chain_hash(self.chain_hash)
495-
.path(path);
517+
match message_paths_to_always_online_node {
518+
Some(paths) => {
519+
for path in paths {
520+
builder = builder.path(path);
521+
}
522+
},
523+
None => {
524+
let path = self
525+
.create_blinded_paths_using_absolute_expiry(context, absolute_expiry, peers)
526+
.and_then(|paths| paths.into_iter().next().ok_or(()))
527+
.map_err(|_| Bolt12SemanticError::MissingPaths)?;
528+
builder = builder.path(path);
529+
},
530+
}
496531

497532
let builder = match absolute_expiry {
498533
None => builder,
499534
Some(absolute_expiry) => builder.absolute_expiry(absolute_expiry),
500535
};
501536

502-
Ok(builder)
537+
Ok((builder, offer_nonce))
503538
}
504539

505540
/// Creates a [`RefundBuilder`] such that the [`Refund`] it builds is recognized by the
@@ -1100,4 +1135,126 @@ where
11001135

11011136
Ok(())
11021137
}
1138+
1139+
/// Handles an incoming [`OfferPaths`] onion message from the static invoice server, sending out
1140+
/// [`ServeStaticInvoice`] onion messages in response if we want to use the paths we've received
1141+
/// to build and cache an async receive offer.
1142+
#[cfg(async_payments)]
1143+
pub(crate) fn handle_offer_paths(
1144+
&self, message: OfferPaths, context: AsyncPaymentsContext, responder: Responder,
1145+
peers: Vec<MessageForwardNode>, usable_channels: Vec<ChannelDetails>,
1146+
) -> Option<(ServeStaticInvoice, MessageContext)> {
1147+
let expanded_key = &self.inbound_payment_key;
1148+
let duration_since_epoch = self.duration_since_epoch();
1149+
1150+
match context {
1151+
AsyncPaymentsContext::OfferPaths { nonce, hmac, path_absolute_expiry } => {
1152+
if let Err(()) = signer::verify_offer_paths_context(nonce, hmac, expanded_key) {
1153+
return None;
1154+
}
1155+
if duration_since_epoch > path_absolute_expiry {
1156+
return None;
1157+
}
1158+
},
1159+
_ => return None,
1160+
}
1161+
1162+
{
1163+
// Only respond with ServeStaticInvoice if we actually need a new offer built.
1164+
let mut cache = self.async_receive_offer_cache.lock().unwrap();
1165+
if !cache.should_build_offer_with_paths(&message, duration_since_epoch) {
1166+
return None;
1167+
}
1168+
}
1169+
1170+
let (offer_builder, offer_nonce) = match self.create_async_receive_offer_builder(
1171+
message.paths_absolute_expiry,
1172+
peers.clone(),
1173+
message.paths,
1174+
) {
1175+
Ok((builder, nonce)) => (builder, nonce),
1176+
Err(_e) => return None, // TODO log error
1177+
};
1178+
let offer = match offer_builder.build() {
1179+
Ok(offer) => offer,
1180+
Err(_e) => return None, // TODO log error
1181+
};
1182+
1183+
let (serve_invoice_message, reply_path_context) = match self
1184+
.create_serve_static_invoice_message(
1185+
offer,
1186+
offer_nonce,
1187+
duration_since_epoch,
1188+
peers,
1189+
usable_channels,
1190+
responder,
1191+
) {
1192+
Ok((msg, context)) => (msg, context),
1193+
Err(()) => return None,
1194+
};
1195+
1196+
let context = MessageContext::AsyncPayments(reply_path_context);
1197+
Some((serve_invoice_message, context))
1198+
}
1199+
1200+
/// Creates a [`ServeStaticInvoice`] onion message, including reply path context for the static
1201+
/// invoice server to respond with [`StaticInvoicePersisted`].
1202+
///
1203+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
1204+
#[cfg(async_payments)]
1205+
fn create_serve_static_invoice_message(
1206+
&self, offer: Offer, offer_nonce: Nonce, offer_created_at: Duration,
1207+
peers: Vec<MessageForwardNode>, usable_channels: Vec<ChannelDetails>,
1208+
update_static_invoice_path: Responder,
1209+
) -> Result<(ServeStaticInvoice, AsyncPaymentsContext), ()> {
1210+
let expanded_key = &self.inbound_payment_key;
1211+
let entropy = &*self.entropy_source;
1212+
let duration_since_epoch = self.duration_since_epoch();
1213+
let secp_ctx = &self.secp_ctx;
1214+
const REPLY_PATH_RELATIVE_EXPIRY: Duration = Duration::from_secs(7200);
1215+
1216+
let offer_relative_expiry = offer
1217+
.absolute_expiry()
1218+
.unwrap_or_else(|| Duration::from_secs(u64::MAX))
1219+
.saturating_sub(duration_since_epoch);
1220+
1221+
// We limit the static invoice lifetime to STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY, meaning we'll
1222+
// need to refresh the static invoice using the reply path to the `OfferPaths` message if the
1223+
// offer expires later than that.
1224+
let static_invoice_relative_expiry = Duration::from_secs(core::cmp::min(
1225+
offer_relative_expiry.as_secs(),
1226+
STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY.as_secs(),
1227+
));
1228+
1229+
let static_invoice = self
1230+
.create_static_invoice_builder(
1231+
&offer,
1232+
offer_nonce,
1233+
Some(static_invoice_relative_expiry),
1234+
usable_channels,
1235+
peers,
1236+
)
1237+
.and_then(|builder| builder.build_and_sign(secp_ctx))
1238+
.map_err(|_e| ())?; // TODO: log error
1239+
1240+
let reply_path_context = {
1241+
let nonce = Nonce::from_entropy_source(entropy);
1242+
let hmac = signer::hmac_for_static_invoice_persisted_context(nonce, expanded_key);
1243+
AsyncPaymentsContext::StaticInvoicePersisted {
1244+
offer,
1245+
offer_nonce,
1246+
offer_created_at,
1247+
update_static_invoice_path,
1248+
static_invoice_absolute_expiry: static_invoice
1249+
.created_at()
1250+
.saturating_add(static_invoice.relative_expiry()),
1251+
nonce,
1252+
hmac,
1253+
path_absolute_expiry: duration_since_epoch
1254+
.saturating_add(REPLY_PATH_RELATIVE_EXPIRY),
1255+
}
1256+
};
1257+
1258+
Ok((ServeStaticInvoice { invoice: static_invoice }, reply_path_context))
1259+
}
11031260
}

0 commit comments

Comments
 (0)