Skip to content

Commit 8e48861

Browse files
Check and refresh async receive offer
As an async recipient, we need to interactively build static invoices that an always-online node will serve to payers on our behalf. At the start of this process, we send a requests for paths to include in our offers to the always-online node on startup and refresh the cached offers when they expire.
1 parent a9de9fb commit 8e48861

File tree

5 files changed

+217
-1
lines changed

5 files changed

+217
-1
lines changed

lightning/src/blinded_path/message.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,32 @@ pub enum OffersContext {
404404
/// [`AsyncPaymentsMessage`]: crate::onion_message::async_payments::AsyncPaymentsMessage
405405
#[derive(Clone, Debug)]
406406
pub enum AsyncPaymentsContext {
407+
/// Context used by a reply path to an [`OfferPathsRequest`], provided back to us in corresponding
408+
/// [`OfferPaths`] messages.
409+
///
410+
/// [`OfferPathsRequest`]: crate::onion_message::async_payments::OfferPathsRequest
411+
/// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths
412+
OfferPaths {
413+
/// A nonce used for authenticating that an [`OfferPaths`] message is valid for a preceding
414+
/// [`OfferPathsRequest`].
415+
///
416+
/// [`OfferPathsRequest`]: crate::onion_message::async_payments::OfferPathsRequest
417+
/// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths
418+
nonce: Nonce,
419+
/// Authentication code for the [`OfferPaths`] message.
420+
///
421+
/// Prevents nodes from creating their own blinded path to us and causing us to cache an
422+
/// unintended async receive offer.
423+
///
424+
/// [`OfferPaths`]: crate::onion_message::async_payments::OfferPaths
425+
hmac: Hmac<Sha256>,
426+
/// The time as duration since the Unix epoch at which this path expires and messages sent over
427+
/// it should be ignored.
428+
///
429+
/// Used to time out a static invoice server from providing offer paths if the async recipient
430+
/// is no longer configured to accept paths from them.
431+
path_absolute_expiry: core::time::Duration,
432+
},
407433
/// Context contained within the reply [`BlindedMessagePath`] we put in outbound
408434
/// [`HeldHtlcAvailable`] messages, provided back to us in corresponding [`ReleaseHeldHtlc`]
409435
/// messages.
@@ -486,6 +512,11 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
486512
(2, hmac, required),
487513
(4, path_absolute_expiry, required),
488514
},
515+
(2, OfferPaths) => {
516+
(0, nonce, required),
517+
(2, hmac, required),
518+
(4, path_absolute_expiry, required),
519+
},
489520
);
490521

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

lightning/src/ln/channelmanager.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4951,6 +4951,18 @@ where
49514951
)
49524952
}
49534953

4954+
#[cfg(async_payments)]
4955+
fn check_refresh_async_receive_offers(&self) {
4956+
match self.flow.check_refresh_async_receive_offers(
4957+
self.get_peers_for_blinded_path(), &*self.entropy_source
4958+
) {
4959+
Err(()) => {
4960+
log_error!(self.logger, "Failed to create blinded paths when requesting async receive offer paths");
4961+
},
4962+
Ok(()) => {},
4963+
}
4964+
}
4965+
49544966
#[cfg(async_payments)]
49554967
fn initiate_async_payment(
49564968
&self, invoice: &StaticInvoice, payment_id: PaymentId
@@ -6873,6 +6885,9 @@ where
68736885
duration_since_epoch, &self.pending_events
68746886
);
68756887

6888+
#[cfg(async_payments)]
6889+
self.check_refresh_async_receive_offers();
6890+
68766891
// Technically we don't need to do this here, but if we have holding cell entries in a
68776892
// channel that need freeing, it's better to do that here and block a background task
68786893
// than block the message queueing pipeline.
@@ -11177,6 +11192,9 @@ where
1117711192
return NotifyOption::SkipPersistHandleEvents;
1117811193
//TODO: Also re-broadcast announcement_signatures
1117911194
});
11195+
11196+
#[cfg(async_payments)]
11197+
self.check_refresh_async_receive_offers();
1118011198
res
1118111199
}
1118211200

lightning/src/offers/async_receive_offer_cache.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,92 @@ impl AsyncReceiveOfferCache {
7878
}
7979
}
8080

81+
#[cfg(async_payments)]
82+
impl AsyncReceiveOfferCache {
83+
// The target number of offers we want to have cached at any given time, to mitigate too much
84+
// reuse of the same offer.
85+
const NUM_CACHED_OFFERS_TARGET: usize = 3;
86+
87+
// The max number of times we'll attempt to request offer paths or attempt to refresh a static
88+
// invoice before giving up.
89+
const MAX_UPDATE_ATTEMPTS: u8 = 3;
90+
91+
/// Remove expired offers from the cache.
92+
pub(super) fn prune_expired_offers(&mut self, duration_since_epoch: Duration) {
93+
// Remove expired offers from the cache.
94+
let mut offer_was_removed = false;
95+
self.offers.retain(|offer| {
96+
if offer.offer.is_expired_no_std(duration_since_epoch) {
97+
offer_was_removed = true;
98+
return false;
99+
}
100+
true
101+
});
102+
103+
// If we just removed a newly expired offer, force allowing more paths request attempts.
104+
if offer_was_removed {
105+
self.reset_offer_paths_request_attempts();
106+
}
107+
108+
// If we haven't attempted to request new paths in a long time, allow more requests to go out
109+
// if/when needed.
110+
self.check_reset_offer_paths_request_attempts(duration_since_epoch);
111+
}
112+
113+
/// Checks whether we should request new offer paths from the always-online static invoice server.
114+
pub(super) fn should_request_offer_paths(&self, duration_since_epoch: Duration) -> bool {
115+
self.needs_new_offers(duration_since_epoch)
116+
&& self.offer_paths_request_attempts < Self::MAX_UPDATE_ATTEMPTS
117+
}
118+
119+
/// Returns a bool indicating whether new offers are needed in the cache.
120+
fn needs_new_offers(&self, duration_since_epoch: Duration) -> bool {
121+
// If we have fewer than NUM_CACHED_OFFERS_TARGET offers that aren't expiring soon, indicate
122+
// that new offers should be interactively built.
123+
let num_unexpiring_offers = self
124+
.offers
125+
.iter()
126+
.filter(|offer| {
127+
let offer_absolute_expiry = offer.offer.absolute_expiry().unwrap_or(Duration::MAX);
128+
let offer_created_at = offer.offer_created_at;
129+
let offer_lifespan =
130+
offer_absolute_expiry.saturating_sub(offer_created_at).as_secs();
131+
let elapsed = duration_since_epoch.saturating_sub(offer_created_at).as_secs();
132+
133+
// If an offer is in the last 10% of its lifespan, it's expiring soon.
134+
elapsed.saturating_mul(10) >= offer_lifespan.saturating_mul(9)
135+
})
136+
.count();
137+
138+
num_unexpiring_offers < Self::NUM_CACHED_OFFERS_TARGET
139+
}
140+
141+
// Indicates that onion messages requesting new offer paths have been sent to the static invoice
142+
// server. Calling this method allows the cache to self-limit how many requests are sent, in case
143+
// the server goes unresponsive.
144+
pub(super) fn new_offers_requested(&mut self, duration_since_epoch: Duration) {
145+
self.offer_paths_request_attempts += 1;
146+
self.last_offer_paths_request_timestamp = duration_since_epoch;
147+
}
148+
149+
/// If we haven't sent an offer paths request in a long time, reset the limit to allow more
150+
/// requests to be sent out if/when needed.
151+
fn check_reset_offer_paths_request_attempts(&mut self, duration_since_epoch: Duration) {
152+
const REQUESTS_TIME_BUFFER: Duration = Duration::from_secs(3 * 60 * 60);
153+
let should_reset =
154+
self.last_offer_paths_request_timestamp.saturating_add(REQUESTS_TIME_BUFFER)
155+
< duration_since_epoch;
156+
if should_reset {
157+
self.reset_offer_paths_request_attempts();
158+
}
159+
}
160+
161+
fn reset_offer_paths_request_attempts(&mut self) {
162+
self.offer_paths_request_attempts = 0;
163+
self.last_offer_paths_request_timestamp = Duration::from_secs(0);
164+
}
165+
}
166+
81167
impl Writeable for AsyncReceiveOfferCache {
82168
fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
83169
write_tlv_fields!(w, {

lightning/src/offers/flow.rs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ use {
6666
crate::offers::offer::Amount,
6767
crate::offers::signer,
6868
crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder},
69-
crate::onion_message::async_payments::HeldHtlcAvailable,
69+
crate::onion_message::async_payments::{HeldHtlcAvailable, OfferPathsRequest},
7070
};
7171

7272
#[cfg(feature = "dnssec")]
@@ -214,6 +214,11 @@ where
214214
/// even if multiple invoices are received.
215215
pub(crate) const OFFERS_MESSAGE_REQUEST_LIMIT: usize = 10;
216216

217+
/// The default relative expiry for reply paths where a quick response is expected and the reply
218+
/// path is single-use.
219+
#[cfg(async_payments)]
220+
const TEMP_REPLY_PATH_RELATIVE_EXPIRY: Duration = Duration::from_secs(7200);
221+
217222
impl<MR: Deref> OffersMessageFlow<MR>
218223
where
219224
MR::Target: MessageRouter,
@@ -1129,4 +1134,62 @@ where
11291134
) -> Vec<(DNSResolverMessage, MessageSendInstructions)> {
11301135
core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap())
11311136
}
1137+
1138+
/// Sends out [`OfferPathsRequest`] onion messages if we are an often-offline recipient and are
1139+
/// configured to interactively build offers and static invoices with a static invoice server.
1140+
///
1141+
/// Errors if we failed to create blinded reply paths when sending an [`OfferPathsRequest`] message.
1142+
#[cfg(async_payments)]
1143+
pub(crate) fn check_refresh_async_receive_offers<ES: Deref>(
1144+
&self, peers: Vec<MessageForwardNode>, entropy: ES,
1145+
) -> Result<(), ()>
1146+
where
1147+
ES::Target: EntropySource,
1148+
{
1149+
// Terminate early if this node does not intend to receive async payments.
1150+
if self.paths_to_static_invoice_server.is_empty() {
1151+
return Ok(());
1152+
}
1153+
1154+
let expanded_key = &self.inbound_payment_key;
1155+
let duration_since_epoch = self.duration_since_epoch();
1156+
1157+
// Check with the cache to see whether we need new offers to be interactively built with the
1158+
// static invoice server.
1159+
let mut async_receive_offer_cache = self.async_receive_offer_cache.lock().unwrap();
1160+
async_receive_offer_cache.prune_expired_offers(duration_since_epoch);
1161+
let needs_new_offers =
1162+
async_receive_offer_cache.should_request_offer_paths(duration_since_epoch);
1163+
1164+
// If we need new offers, send out offer paths request messages to the static invoice server.
1165+
if needs_new_offers {
1166+
let nonce = Nonce::from_entropy_source(&*entropy);
1167+
let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths {
1168+
nonce,
1169+
hmac: signer::hmac_for_offer_paths_context(nonce, expanded_key),
1170+
path_absolute_expiry: duration_since_epoch
1171+
.saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY),
1172+
});
1173+
let reply_paths = match self.create_blinded_paths(peers, context) {
1174+
Ok(paths) => paths,
1175+
Err(()) => {
1176+
return Err(());
1177+
},
1178+
};
1179+
1180+
// We can't fail past this point, so indicate to the cache that we've requested new offers.
1181+
async_receive_offer_cache.new_offers_requested(duration_since_epoch);
1182+
core::mem::drop(async_receive_offer_cache);
1183+
1184+
let message = AsyncPaymentsMessage::OfferPathsRequest(OfferPathsRequest {});
1185+
enqueue_onion_message_with_reply_paths(
1186+
message,
1187+
&self.paths_to_static_invoice_server[..],
1188+
reply_paths,
1189+
&mut self.pending_async_payments_messages.lock().unwrap(),
1190+
);
1191+
}
1192+
1193+
Ok(())
1194+
}
11321195
}

lightning/src/offers/signer.rs

Lines changed: 18 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::OfferPaths` to authenticate inbound offer_paths onion
59+
// messages.
60+
#[cfg(async_payments)]
61+
const ASYNC_PAYMENTS_OFFER_PATHS_INPUT: &[u8; 16] = &[10; 16];
62+
5863
/// Message metadata which possibly is derived from [`MetadataMaterial`] such that it can be
5964
/// verified.
6065
#[derive(Clone)]
@@ -570,3 +575,16 @@ pub(crate) fn verify_held_htlc_available_context(
570575
Err(())
571576
}
572577
}
578+
579+
#[cfg(async_payments)]
580+
pub(crate) fn hmac_for_offer_paths_context(
581+
nonce: Nonce, expanded_key: &ExpandedKey,
582+
) -> Hmac<Sha256> {
583+
const IV_BYTES: &[u8; IV_LEN] = b"LDK Offer Paths~";
584+
let mut hmac = expanded_key.hmac_for_offer();
585+
hmac.input(IV_BYTES);
586+
hmac.input(&nonce.0);
587+
hmac.input(ASYNC_PAYMENTS_OFFER_PATHS_INPUT);
588+
589+
Hmac::from_engine(hmac)
590+
}

0 commit comments

Comments
 (0)