Skip to content

Commit 70576cb

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 85e07a8 commit 70576cb

File tree

6 files changed

+309
-4
lines changed

6 files changed

+309
-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};
@@ -417,6 +419,58 @@ pub enum AsyncPaymentsContext {
417419
/// offer paths if we are no longer configured to accept paths from them.
418420
path_absolute_expiry: core::time::Duration,
419421
},
422+
/// Context used by a reply path to a [`ServeStaticInvoice`] message, provided back to us in
423+
/// corresponding [`StaticInvoicePersisted`] messages.
424+
///
425+
/// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice
426+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
427+
StaticInvoicePersisted {
428+
/// The offer corresponding to the [`StaticInvoice`] that has been persisted. This invoice is
429+
/// now ready to be provided by the static invoice server in response to [`InvoiceRequest`]s.
430+
///
431+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
432+
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
433+
offer: Offer,
434+
/// A [`Nonce`] useful for updating the [`StaticInvoice`] that corresponds to the
435+
/// [`AsyncPaymentsContext::StaticInvoicePersisted::offer`], since the offer may be much longer
436+
/// lived than the invoice.
437+
///
438+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
439+
offer_nonce: Nonce,
440+
/// Useful to determine how far an offer is into its lifespan, to decide whether the offer is
441+
/// expiring soon and we should start building a new one.
442+
offer_created_at: core::time::Duration,
443+
/// A [`Responder`] useful for updating the [`StaticInvoice`] that corresponds to the
444+
/// [`AsyncPaymentsContext::StaticInvoicePersisted::offer`], since the offer may be much longer
445+
/// lived than the invoice.
446+
///
447+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
448+
update_static_invoice_path: Responder,
449+
/// The time as duration since the Unix epoch at which the [`StaticInvoice`] expires, used to track
450+
/// when we need to generate and persist a new invoice with the static invoice server.
451+
///
452+
/// [`StaticInvoice`]: crate::offers::static_invoice::StaticInvoice
453+
static_invoice_absolute_expiry: core::time::Duration,
454+
/// A nonce used for authenticating that a [`StaticInvoicePersisted`] message is valid for a
455+
/// preceding [`ServeStaticInvoice`] message.
456+
///
457+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
458+
/// [`ServeStaticInvoice`]: crate::onion_message::async_payments::ServeStaticInvoice
459+
nonce: Nonce,
460+
/// Authentication code for the [`StaticInvoicePersisted`] message.
461+
///
462+
/// Prevents nodes from creating their own blinded path to us and causing us to cache an
463+
/// unintended async receive offer.
464+
///
465+
/// [`StaticInvoicePersisted`]: crate::onion_message::async_payments::StaticInvoicePersisted
466+
hmac: Hmac<Sha256>,
467+
/// The time as duration since the Unix epoch at which this path expires and messages sent over
468+
/// it should be ignored.
469+
///
470+
/// Prevents a static invoice server from causing an async recipient to cache an old offer if
471+
/// the recipient is no longer configured to use that server.
472+
path_absolute_expiry: core::time::Duration,
473+
},
420474
/// Context contained within the reply [`BlindedMessagePath`] we put in outbound
421475
/// [`HeldHtlcAvailable`] messages, provided back to us in corresponding [`ReleaseHeldHtlc`]
422476
/// messages.
@@ -502,6 +556,16 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
502556
(2, OfferPaths) => {
503557
(0, path_absolute_expiry, required),
504558
},
559+
(3, StaticInvoicePersisted) => {
560+
(0, offer, required),
561+
(2, offer_nonce, required),
562+
(4, offer_created_at, required),
563+
(6, update_static_invoice_path, required),
564+
(8, static_invoice_absolute_expiry, required),
565+
(10, nonce, required),
566+
(12, hmac, required),
567+
(14, path_absolute_expiry, required),
568+
},
505569
);
506570

507571
/// 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
@@ -13042,7 +13042,30 @@ where
1304213042
fn handle_offer_paths(
1304313043
&self, _message: OfferPaths, _context: AsyncPaymentsContext, _responder: Option<Responder>,
1304413044
) -> Option<(ServeStaticInvoice, ResponseInstruction)> {
13045-
None
13045+
#[cfg(async_payments)]
13046+
{
13047+
let responder = match _responder {
13048+
Some(responder) => responder,
13049+
None => return None,
13050+
};
13051+
let (serve_static_invoice, reply_context) = match self.flow.handle_offer_paths(
13052+
_message,
13053+
_context,
13054+
responder.clone(),
13055+
self.get_peers_for_blinded_path(),
13056+
self.list_usable_channels(),
13057+
&*self.entropy_source,
13058+
&*self.router,
13059+
) {
13060+
Some((msg, ctx)) => (msg, ctx),
13061+
None => return None,
13062+
};
13063+
let response_instructions = responder.respond_with_reply_path(reply_context);
13064+
return Some((serve_static_invoice, response_instructions));
13065+
}
13066+
13067+
#[cfg(not(async_payments))]
13068+
return None;
1304613069
}
1304713070

1304813071
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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ use crate::io::Read;
1717
use crate::ln::msgs::DecodeError;
1818
use crate::offers::nonce::Nonce;
1919
use crate::offers::offer::Offer;
20+
#[cfg(async_payments)]
21+
use crate::onion_message::async_payments::OfferPaths;
2022
use crate::onion_message::messenger::Responder;
2123
use crate::prelude::*;
2224
use crate::util::ser::{Readable, Writeable, Writer};
@@ -158,6 +160,9 @@ const MAX_UPDATE_ATTEMPTS: u8 = 3;
158160
#[cfg(async_payments)]
159161
const OFFER_REFRESH_THRESHOLD: Duration = Duration::from_secs(2 * 60 * 60);
160162

163+
#[cfg(async_payments)]
164+
const MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS: u64 = 3 * 60 * 60;
165+
161166
#[cfg(async_payments)]
162167
impl AsyncReceiveOfferCache {
163168
/// Remove expired offers from the cache, returning whether new offers are needed.
@@ -183,6 +188,27 @@ impl AsyncReceiveOfferCache {
183188
&& self.offer_paths_request_attempts < MAX_UPDATE_ATTEMPTS
184189
}
185190

191+
/// Returns whether the new paths we've just received from the static invoice server should be used
192+
/// to build a new offer.
193+
pub(super) fn should_build_offer_with_paths(
194+
&self, message: &OfferPaths, duration_since_epoch: Duration,
195+
) -> bool {
196+
if !self.needs_new_offers(duration_since_epoch) {
197+
return false;
198+
}
199+
200+
// Require the offer that would be built using these paths to last at least a few hours.
201+
let min_offer_paths_absolute_expiry =
202+
duration_since_epoch.as_secs().saturating_add(MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS);
203+
let offer_paths_absolute_expiry = message.paths_absolute_expiry.unwrap_or(u64::MAX);
204+
if offer_paths_absolute_expiry < min_offer_paths_absolute_expiry {
205+
return false;
206+
}
207+
208+
// Check that we don't have any current offers that already contain these paths
209+
self.offers.iter().all(|offer| offer.offer.paths() != message.paths)
210+
}
211+
186212
/// Returns a bool indicating whether new offers are needed in the cache.
187213
fn needs_new_offers(&self, duration_since_epoch: Duration) -> bool {
188214
// We always want to have the freshest offer possible when a user goes to retrieve a cached

lightning/src/offers/flow.rs

Lines changed: 165 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")]
@@ -1184,6 +1190,163 @@ where
11841190
Ok(())
11851191
}
11861192

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

lightning/src/offers/signer.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ const PAYMENT_TLVS_HMAC_INPUT: &[u8; 16] = &[8; 16];
5555
#[cfg(async_payments)]
5656
const ASYNC_PAYMENTS_HELD_HTLC_HMAC_INPUT: &[u8; 16] = &[9; 16];
5757

58+
// HMAC input used in `AsyncPaymentsContext::StaticInvoicePersisted` to authenticate inbound
59+
// static_invoice_persisted onion messages.
60+
#[cfg(async_payments)]
61+
const ASYNC_PAYMENTS_STATIC_INV_PERSISTED_INPUT: &[u8; 16] = &[11; 16];
62+
5863
/// Message metadata which possibly is derived from [`MetadataMaterial`] such that it can be
5964
/// verified.
6065
#[derive(Clone)]
@@ -570,3 +575,27 @@ pub(crate) fn verify_held_htlc_available_context(
570575
Err(())
571576
}
572577
}
578+
579+
#[cfg(async_payments)]
580+
pub(crate) fn verify_offer_paths_context(
581+
nonce: Nonce, hmac: Hmac<Sha256>, expanded_key: &ExpandedKey,
582+
) -> Result<(), ()> {
583+
if hmac_for_offer_paths_context(nonce, expanded_key) == hmac {
584+
Ok(())
585+
} else {
586+
Err(())
587+
}
588+
}
589+
590+
#[cfg(async_payments)]
591+
pub(crate) fn hmac_for_static_invoice_persisted_context(
592+
nonce: Nonce, expanded_key: &ExpandedKey,
593+
) -> Hmac<Sha256> {
594+
const IV_BYTES: &[u8; IV_LEN] = b"LDK InvPersisted";
595+
let mut hmac = expanded_key.hmac_for_offer();
596+
hmac.input(IV_BYTES);
597+
hmac.input(&nonce.0);
598+
hmac.input(ASYNC_PAYMENTS_STATIC_INV_PERSISTED_INPUT);
599+
600+
Hmac::from_engine(hmac)
601+
}

0 commit comments

Comments
 (0)