Skip to content

Commit 165748c

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 9b1b644 commit 165748c

File tree

6 files changed

+308
-4
lines changed

6 files changed

+308
-4
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
/// offer paths if we are 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: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12842,7 +12842,30 @@ where
1284212842
fn handle_offer_paths(
1284312843
&self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option<Responder>,
1284412844
) -> Option<(ServeStaticInvoice, ResponseInstruction)> {
12845-
None
12845+
#[cfg(async_payments)]
12846+
{
12847+
let responder = match _responder {
12848+
Some(responder) => responder,
12849+
None => return None,
12850+
};
12851+
let (serve_static_invoice, reply_context) = match self.flow.handle_offer_paths(
12852+
_message,
12853+
_context,
12854+
responder.clone(),
12855+
self.get_peers_for_blinded_path(),
12856+
self.list_usable_channels(),
12857+
&*self.entropy_source,
12858+
&*self.router,
12859+
) {
12860+
Some((msg, ctx)) => (msg, ctx),
12861+
None => return None,
12862+
};
12863+
let response_instructions = responder.respond_with_reply_path(reply_context);
12864+
return Some((serve_static_invoice, response_instructions));
12865+
}
12866+
12867+
#[cfg(not(async_payments))]
12868+
return None;
1284612869
}
1284712870

1284812871
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: 27 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};
@@ -103,6 +105,9 @@ const PATHS_REQUESTS_RESET_INTERVAL: Duration = Duration::from_secs(3 * 60 * 60)
103105
#[cfg(async_payments)]
104106
const OFFER_EXPIRES_SOON_THRESHOLD_PERCENT: u64 = 90;
105107

108+
#[cfg(async_payments)]
109+
const MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 = 3 * 60 * 60;
110+
106111
#[cfg(async_payments)]
107112
impl AsyncReceiveOfferCache {
108113
/// Remove expired offers from the cache, returning whether new offers are needed.
@@ -130,6 +135,28 @@ impl AsyncReceiveOfferCache {
130135
&& self.offer_paths_request_attempts < MAX_UPDATE_ATTEMPTS
131136
}
132137

138+
/// Returns whether the new paths we've just received from the static invoice server should be used
139+
/// to build a new offer.
140+
pub(super) fn should_build_offer_with_paths(
141+
&self, message: &OfferPaths, duration_since_epoch: Duration,
142+
) -> bool {
143+
if !self.needs_new_offers(duration_since_epoch) {
144+
return false;
145+
}
146+
147+
// Require the offer that would be built using these paths to last at least a few hours.
148+
let min_offer_paths_absolute_expiry =
149+
duration_since_epoch.as_secs().saturating_add(MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS);
150+
let offer_paths_absolute_expiry =
151+
message.paths_absolute_expiry.map(|exp| exp.as_secs()).unwrap_or(u64::MAX);
152+
if offer_paths_absolute_expiry < min_offer_paths_absolute_expiry {
153+
return false;
154+
}
155+
156+
// Check that we don't have any current offers that already contain these paths
157+
self.offers.iter().all(|offer| offer.offer.paths() != message.paths)
158+
}
159+
133160
/// Returns a bool indicating whether new offers are needed in the cache.
134161
fn needs_new_offers(&self, duration_since_epoch: Duration) -> bool {
135162
// If we have fewer than NUM_CACHED_OFFERS_TARGET offers that aren't expiring soon, indicate

lightning/src/offers/flow.rs

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,14 @@ use {
6565
crate::blinded_path::payment::AsyncBolt12OfferContext,
6666
crate::offers::offer::Amount,
6767
crate::offers::signer,
68-
crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder},
69-
crate::onion_message::async_payments::{HeldHtlcAvailable, OfferPathsRequest},
68+
crate::offers::static_invoice::{
69+
StaticInvoice, StaticInvoiceBuilder,
70+
DEFAULT_RELATIVE_EXPIRY as STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY,
71+
},
72+
crate::onion_message::async_payments::{
73+
HeldHtlcAvailable, OfferPaths, OfferPathsRequest, ServeStaticInvoice,
74+
},
75+
crate::onion_message::messenger::Responder,
7076
};
7177

7278
#[cfg(feature = "dnssec")]
@@ -1176,6 +1182,161 @@ where
11761182
Ok(())
11771183
}
11781184

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

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)