Skip to content

Commit c19bf67

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 6fef373 commit c19bf67

File tree

5 files changed

+294
-3
lines changed

5 files changed

+294
-3
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};
@@ -430,6 +432,58 @@ pub enum AsyncPaymentsContext {
430432
/// is no longer configured to accept paths from them.
431433
path_absolute_expiry: core::time::Duration,
432434
},
435+
/// Context used by a reply path to a [`ServeStaticInvoice`] message, provided back to us in
436+
/// corresponding [`StaticInvoicePersisted`] messages.
437+
///
438+
/// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice
439+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
440+
StaticInvoicePersisted {
441+
/// The offer corresponding to the [`StaticInvoice`] that has been persisted. This invoice is
442+
/// now ready to be provided by the static invoice server in response to [`InvoiceRequest`]s.
443+
///
444+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
445+
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
446+
offer: Offer,
447+
/// A [`Nonce`] useful for updating the [`StaticInvoice`] that corresponds to the
448+
/// [`AsyncPaymentsContext::StaticInvoicePersisted::offer`], since the offer may be much longer
449+
/// lived than the invoice.
450+
///
451+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
452+
offer_nonce: Nonce,
453+
/// Useful to determine how far an offer is into its lifespan, to decide whether the offer is
454+
/// expiring soon and we should start building a new one.
455+
offer_created_at: core::time::Duration,
456+
/// A [`Responder`] useful for updating the [`StaticInvoice`] that corresponds to the
457+
/// [`AsyncPaymentsContext::StaticInvoicePersisted::offer`], since the offer may be much longer
458+
/// lived than the invoice.
459+
///
460+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
461+
update_static_invoice_path: Responder,
462+
/// The time as duration since the Unix epoch at which the [`StaticInvoice`] expires, used to track
463+
/// when we need to generate and persist a new invoice with the static invoice server.
464+
///
465+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
466+
static_invoice_absolute_expiry: core::time::Duration,
467+
/// A nonce used for authenticating that a [`StaticInvoicePersisted`] message is valid for a
468+
/// preceding [`ServeStaticInvoice`] message.
469+
///
470+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
471+
/// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice
472+
nonce: Nonce,
473+
/// Authentication code for the [`StaticInvoicePersisted`] message.
474+
///
475+
/// Prevents nodes from creating their own blinded path to us and causing us to cache an
476+
/// unintended async receive offer.
477+
///
478+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
479+
hmac: Hmac<Sha256>,
480+
/// The time as duration since the Unix epoch at which this path expires and messages sent over
481+
/// it should be ignored.
482+
///
483+
/// Prevents a static invoice server from causing an async recipient to cache an old offer if
484+
/// the recipient is no longer configured to use that server.
485+
path_absolute_expiry: core::time::Duration,
486+
},
433487
/// Context contained within the reply [`BlindedMessagePath`] we put in outbound
434488
/// [`HeldHtlcAvailable`] messages, provided back to us in corresponding [`ReleaseHeldHtlc`]
435489
/// messages.
@@ -517,6 +571,16 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
517571
(2, hmac, required),
518572
(4, path_absolute_expiry, required),
519573
},
574+
(3, StaticInvoicePersisted) => {
575+
(0, offer, required),
576+
(2, offer_nonce, required),
577+
(4, offer_created_at, required),
578+
(6, update_static_invoice_path, required),
579+
(8, static_invoice_absolute_expiry, required),
580+
(10, nonce, required),
581+
(12, hmac, required),
582+
(14, path_absolute_expiry, required),
583+
},
520584
);
521585

522586
/// 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
@@ -12437,7 +12437,25 @@ where
1243712437
fn handle_offer_paths(
1243812438
&self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option<Responder>,
1243912439
) -> Option<(ServeStaticInvoice, ResponseInstruction)> {
12440-
None
12440+
#[cfg(async_payments)] {
12441+
let responder = match _responder {
12442+
Some(responder) => responder,
12443+
None => return None
12444+
};
12445+
let (serve_static_invoice, reply_context) =
12446+
match self.flow.handle_offer_paths(
12447+
_message, _context, responder.clone(), self.get_peers_for_blinded_path(),
12448+
self.list_usable_channels(), &*self.entropy_source, &*self.router
12449+
) {
12450+
Some((msg, ctx)) => (msg, ctx),
12451+
None => return None,
12452+
};
12453+
let response_instructions = responder.respond_with_reply_path(reply_context);
12454+
return Some((serve_static_invoice, response_instructions))
12455+
}
12456+
12457+
#[cfg(not(async_payments))]
12458+
return None
1244112459
}
1244212460

1244312461
fn handle_serve_static_invoice(

lightning/src/offers/async_receive_offer_cache.rs

Lines changed: 24 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::prelude::*;
2123
use crate::util::ser::{Readable, Writeable, Writer};
@@ -116,6 +118,28 @@ impl AsyncReceiveOfferCache {
116118
&& self.offer_paths_request_attempts < Self::MAX_UPDATE_ATTEMPTS
117119
}
118120

121+
/// Returns whether the new paths we've just received from the static invoice server should be used
122+
/// to build a new offer.
123+
pub(super) fn should_build_offer_with_paths(
124+
&self, message: &OfferPaths, duration_since_epoch: Duration,
125+
) -> bool {
126+
if !self.needs_new_offers(duration_since_epoch) {
127+
return false;
128+
}
129+
130+
// Require the offer that would be built using these paths to last at least a few hours.
131+
let min_offer_paths_absolute_expiry =
132+
duration_since_epoch.as_secs().saturating_add(3 * 60 * 60);
133+
let offer_paths_absolute_expiry =
134+
message.paths_absolute_expiry.map(|exp| exp.as_secs()).unwrap_or(u64::MAX);
135+
if offer_paths_absolute_expiry < min_offer_paths_absolute_expiry {
136+
return false;
137+
}
138+
139+
// Check that we don't have any current offers that already contain these paths
140+
self.offers.iter().all(|offer| offer.offer.paths() != message.paths)
141+
}
142+
119143
/// Returns a bool indicating whether new offers are needed in the cache.
120144
fn needs_new_offers(&self, duration_since_epoch: Duration) -> bool {
121145
// If we have fewer than NUM_CACHED_OFFERS_TARGET offers that aren't expiring soon, indicate

lightning/src/offers/flow.rs

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,14 @@ use {
6464
crate::blinded_path::message::AsyncPaymentsContext,
6565
crate::blinded_path::payment::AsyncBolt12OfferContext,
6666
crate::offers::signer,
67-
crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder},
68-
crate::onion_message::async_payments::{HeldHtlcAvailable, OfferPathsRequest},
67+
crate::offers::static_invoice::{
68+
StaticInvoice, StaticInvoiceBuilder,
69+
DEFAULT_RELATIVE_EXPIRY as STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY,
70+
},
71+
crate::onion_message::async_payments::{
72+
HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ServeStaticInvoice,
73+
},
74+
crate::onion_message::messenger::Responder,
6975
};
7076

7177
#[cfg(feature = "dnssec")]
@@ -1190,4 +1196,154 @@ where
11901196

11911197
Ok(())
11921198
}
1199+
1200+
/// Handles an incoming [`OfferPaths`] message from the static invoice server, sending out
1201+
/// [`ServeStaticInvoice`] onion messages in response if we want to use the paths we've received
1202+
/// to build and cache an async receive offer.
1203+
///
1204+
/// Returns `None` if we have enough offers cached already, verification of `message` fails, or we
1205+
/// fail to create blinded paths.
1206+
#[cfg(async_payments)]
1207+
pub(crate) fn handle_offer_paths<ES: Deref, R: Deref>(
1208+
&self, message: OfferPaths, context: AsyncPaymentsContext, responder: Responder,
1209+
peers: Vec<MessageForwardNode>, usable_channels: Vec<ChannelDetails>, entropy: ES,
1210+
router: R,
1211+
) -> Option<(ServeStaticInvoice, MessageContext)>
1212+
where
1213+
ES::Target: EntropySource,
1214+
R::Target: Router,
1215+
{
1216+
let expanded_key = &self.inbound_payment_key;
1217+
let duration_since_epoch = self.duration_since_epoch();
1218+
1219+
match context {
1220+
AsyncPaymentsContext::OfferPaths { nonce, hmac, path_absolute_expiry } => {
1221+
if let Err(()) = signer::verify_offer_paths_context(nonce, hmac, expanded_key) {
1222+
return None;
1223+
}
1224+
if duration_since_epoch > path_absolute_expiry {
1225+
return None;
1226+
}
1227+
},
1228+
_ => return None,
1229+
}
1230+
1231+
{
1232+
// Only respond with `ServeStaticInvoice` if we actually need a new offer built.
1233+
let cache = self.async_receive_offer_cache.lock().unwrap();
1234+
if !cache.should_build_offer_with_paths(&message, duration_since_epoch) {
1235+
return None;
1236+
}
1237+
}
1238+
1239+
let (mut offer_builder, offer_nonce) =
1240+
match self.create_async_receive_offer_builder(&*entropy, message.paths) {
1241+
Ok((builder, nonce)) => (builder, nonce),
1242+
Err(_) => return None, // Only reachable if OfferPaths::paths is empty
1243+
};
1244+
if let Some(paths_absolute_expiry) = message.paths_absolute_expiry {
1245+
offer_builder = offer_builder.absolute_expiry(paths_absolute_expiry);
1246+
}
1247+
let offer = match offer_builder.build() {
1248+
Ok(offer) => offer,
1249+
Err(_) => {
1250+
debug_assert!(false);
1251+
return None;
1252+
},
1253+
};
1254+
1255+
let (serve_invoice_message, reply_path_context) = match self
1256+
.create_serve_static_invoice_message(
1257+
offer,
1258+
offer_nonce,
1259+
duration_since_epoch,
1260+
peers,
1261+
usable_channels,
1262+
responder,
1263+
&*entropy,
1264+
router,
1265+
) {
1266+
Ok((msg, context)) => (msg, context),
1267+
Err(()) => return None,
1268+
};
1269+
1270+
let context = MessageContext::AsyncPayments(reply_path_context);
1271+
Some((serve_invoice_message, context))
1272+
}
1273+
1274+
/// Creates a [`ServeStaticInvoice`] onion message, including reply path context for the static
1275+
/// invoice server to respond with [`StaticInvoicePersisted`].
1276+
///
1277+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
1278+
#[cfg(async_payments)]
1279+
fn create_serve_static_invoice_message<ES: Deref, R: Deref>(
1280+
&self, offer: Offer, offer_nonce: Nonce, offer_created_at: Duration,
1281+
peers: Vec<MessageForwardNode>, usable_channels: Vec<ChannelDetails>,
1282+
update_static_invoice_path: Responder, entropy: ES, router: R,
1283+
) -> Result<(ServeStaticInvoice, AsyncPaymentsContext), ()>
1284+
where
1285+
ES::Target: EntropySource,
1286+
R::Target: Router,
1287+
{
1288+
let expanded_key = &self.inbound_payment_key;
1289+
let duration_since_epoch = self.duration_since_epoch();
1290+
let secp_ctx = &self.secp_ctx;
1291+
const REPLY_PATH_RELATIVE_EXPIRY: Duration = Duration::from_secs(7200);
1292+
1293+
let offer_relative_expiry = offer
1294+
.absolute_expiry()
1295+
.map(|exp| exp.saturating_sub(duration_since_epoch))
1296+
.unwrap_or_else(|| Duration::from_secs(u64::MAX));
1297+
1298+
// We limit the static invoice lifetime to STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY, meaning we'll
1299+
// need to refresh the static invoice using the reply path to the `OfferPaths` message if the
1300+
// offer expires later than that.
1301+
let static_invoice_relative_expiry = core::cmp::min(
1302+
offer_relative_expiry.as_secs(),
1303+
STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY.as_secs(),
1304+
) as u32;
1305+
1306+
let payment_secret = inbound_payment::create_for_spontaneous_payment(
1307+
expanded_key,
1308+
None,
1309+
static_invoice_relative_expiry,
1310+
self.duration_since_epoch().as_secs(),
1311+
None,
1312+
)?;
1313+
1314+
let static_invoice = self
1315+
.create_static_invoice_builder(
1316+
&router,
1317+
&*entropy,
1318+
&offer,
1319+
offer_nonce,
1320+
None, // The async receive offers we create are always amount-less
1321+
payment_secret,
1322+
static_invoice_relative_expiry,
1323+
usable_channels,
1324+
peers,
1325+
)
1326+
.and_then(|builder| builder.build_and_sign(secp_ctx))
1327+
.map_err(|_| ())?;
1328+
1329+
let reply_path_context = {
1330+
let nonce = Nonce::from_entropy_source(entropy);
1331+
let hmac = signer::hmac_for_static_invoice_persisted_context(nonce, expanded_key);
1332+
AsyncPaymentsContext::StaticInvoicePersisted {
1333+
offer,
1334+
offer_nonce,
1335+
offer_created_at,
1336+
update_static_invoice_path,
1337+
static_invoice_absolute_expiry: static_invoice
1338+
.created_at()
1339+
.saturating_add(static_invoice.relative_expiry()),
1340+
nonce,
1341+
hmac,
1342+
path_absolute_expiry: duration_since_epoch
1343+
.saturating_add(REPLY_PATH_RELATIVE_EXPIRY),
1344+
}
1345+
};
1346+
1347+
Ok((ServeStaticInvoice { invoice: static_invoice }, reply_path_context))
1348+
}
11931349
}

lightning/src/offers/signer.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ const ASYNC_PAYMENTS_HELD_HTLC_HMAC_INPUT: &[u8; 16] = &[9; 16];
6060
#[cfg(async_payments)]
6161
const ASYNC_PAYMENTS_OFFER_PATHS_INPUT: &[u8; 16] = &[10; 16];
6262

63+
// HMAC input used in `AsyncPaymentsContext::StaticInvoicePersisted` to authenticate inbound
64+
// static_invoice_persisted onion messages.
65+
#[cfg(async_payments)]
66+
const ASYNC_PAYMENTS_STATIC_INV_PERSISTED_INPUT: &[u8; 16] = &[11; 16];
67+
6368
/// Message metadata which possibly is derived from [`MetadataMaterial`] such that it can be
6469
/// verified.
6570
#[derive(Clone)]
@@ -588,3 +593,27 @@ pub(crate) fn hmac_for_offer_paths_context(
588593

589594
Hmac::from_engine(hmac)
590595
}
596+
597+
#[cfg(async_payments)]
598+
pub(crate) fn verify_offer_paths_context(
599+
nonce: Nonce, hmac: Hmac<Sha256>, expanded_key: &ExpandedKey,
600+
) -> Result<(), ()> {
601+
if hmac_for_offer_paths_context(nonce, expanded_key) == hmac {
602+
Ok(())
603+
} else {
604+
Err(())
605+
}
606+
}
607+
608+
#[cfg(async_payments)]
609+
pub(crate) fn hmac_for_static_invoice_persisted_context(
610+
nonce: Nonce, expanded_key: &ExpandedKey,
611+
) -> Hmac<Sha256> {
612+
const IV_BYTES: &[u8; IV_LEN] = b"LDK InvPersisted";
613+
let mut hmac = expanded_key.hmac_for_offer();
614+
hmac.input(IV_BYTES);
615+
hmac.input(&nonce.0);
616+
hmac.input(ASYNC_PAYMENTS_STATIC_INV_PERSISTED_INPUT);
617+
618+
Hmac::from_engine(hmac)
619+
}

0 commit comments

Comments
 (0)