Skip to content

Commit a2e3647

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. We previously weren't dropping the cache lock if no new offers were needed, then attempted to acquire it again in a later method We're gonna expose these for testing later so move them
1 parent b5a5506 commit a2e3647

File tree

5 files changed

+244
-1
lines changed

5 files changed

+244
-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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5105,7 +5105,21 @@ where
51055105
}
51065106

51075107
#[cfg(async_payments)]
5108+
fn check_refresh_async_receive_offers(&self) {
5109+
let peers = self.get_peers_for_blinded_path();
5110+
match self.flow.check_refresh_async_receive_offers(peers, &*self.entropy_source) {
5111+
Err(()) => {
5112+
log_error!(
5113+
self.logger,
5114+
"Failed to create blinded paths when requesting async receive offer paths"
5115+
);
5116+
},
5117+
Ok(()) => {},
5118+
}
5119+
}
5120+
51085121
#[rustfmt::skip]
5122+
#[cfg(async_payments)]
51095123
fn initiate_async_payment(
51105124
&self, invoice: &StaticInvoice, payment_id: PaymentId
51115125
) -> Result<(), Bolt12PaymentError> {
@@ -7049,6 +7063,9 @@ where
70497063
duration_since_epoch, &self.pending_events
70507064
);
70517065

7066+
#[cfg(async_payments)]
7067+
self.check_refresh_async_receive_offers();
7068+
70527069
// Technically we don't need to do this here, but if we have holding cell entries in a
70537070
// channel that need freeing, it's better to do that here and block a background task
70547071
// than block the message queueing pipeline.
@@ -11484,6 +11501,13 @@ where
1148411501
return NotifyOption::SkipPersistHandleEvents;
1148511502
//TODO: Also re-broadcast announcement_signatures
1148611503
});
11504+
11505+
// While we usually refresh the AsyncReceiveOfferCache on a timer, we also want to start
11506+
// interactively building offers as soon as we can after startup. We can't start building offers
11507+
// until we have some peer connection(s) to send onion messages over, so as a minor optimization
11508+
// refresh the cache when a peer connects.
11509+
#[cfg(async_payments)]
11510+
self.check_refresh_async_receive_offers();
1148711511
res
1148811512
}
1148911513

lightning/src/offers/async_receive_offer_cache.rs

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

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

lightning/src/offers/flow.rs

Lines changed: 72 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")]
@@ -217,6 +217,11 @@ where
217217
/// even if multiple invoices are received.
218218
const OFFERS_MESSAGE_REQUEST_LIMIT: usize = 10;
219219

220+
/// The default relative expiry for reply paths where a quick response is expected and the reply
221+
/// path is single-use.
222+
#[cfg(async_payments)]
223+
const TEMP_REPLY_PATH_RELATIVE_EXPIRY: Duration = Duration::from_secs(7200);
224+
220225
impl<MR: Deref> OffersMessageFlow<MR>
221226
where
222227
MR::Target: MessageRouter,
@@ -1105,6 +1110,72 @@ where
11051110
core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap())
11061111
}
11071112

1113+
/// Sends out [`OfferPathsRequest`] onion messages if we are an often-offline recipient and are
1114+
/// configured to interactively build offers and static invoices with a static invoice server.
1115+
///
1116+
/// # Usage
1117+
///
1118+
/// This method should be called on peer connection and every few minutes or so, to keep the
1119+
/// offers cache updated.
1120+
///
1121+
/// Errors if we failed to create blinded reply paths when sending an [`OfferPathsRequest`] message.
1122+
#[cfg(async_payments)]
1123+
pub(crate) fn check_refresh_async_receive_offers<ES: Deref>(
1124+
&self, peers: Vec<MessageForwardNode>, entropy: ES,
1125+
) -> Result<(), ()>
1126+
where
1127+
ES::Target: EntropySource,
1128+
{
1129+
// Terminate early if this node does not intend to receive async payments.
1130+
if self.paths_to_static_invoice_server.is_empty() {
1131+
return Ok(());
1132+
}
1133+
1134+
let expanded_key = &self.inbound_payment_key;
1135+
let duration_since_epoch = self.duration_since_epoch();
1136+
1137+
// Check with the cache to see whether we need new offers to be interactively built with the
1138+
// static invoice server.
1139+
let needs_new_offers = {
1140+
let mut async_receive_offer_cache = self.async_receive_offer_cache.lock().unwrap();
1141+
async_receive_offer_cache.prune_expired_offers(duration_since_epoch);
1142+
async_receive_offer_cache.should_request_offer_paths(duration_since_epoch)
1143+
};
1144+
1145+
// If we need new offers, send out offer paths request messages to the static invoice server.
1146+
if needs_new_offers {
1147+
let nonce = Nonce::from_entropy_source(&*entropy);
1148+
let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths {
1149+
nonce,
1150+
hmac: signer::hmac_for_offer_paths_context(nonce, expanded_key),
1151+
path_absolute_expiry: duration_since_epoch
1152+
.saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY),
1153+
});
1154+
let reply_paths = match self.create_blinded_paths(peers, context) {
1155+
Ok(paths) => paths,
1156+
Err(()) => {
1157+
return Err(());
1158+
},
1159+
};
1160+
1161+
// We can't fail past this point, so indicate to the cache that we've requested new offers.
1162+
self.async_receive_offer_cache
1163+
.lock()
1164+
.unwrap()
1165+
.new_offers_requested(duration_since_epoch);
1166+
1167+
let message = AsyncPaymentsMessage::OfferPathsRequest(OfferPathsRequest {});
1168+
enqueue_onion_message_with_reply_paths(
1169+
message,
1170+
&self.paths_to_static_invoice_server[..],
1171+
reply_paths,
1172+
&mut self.pending_async_payments_messages.lock().unwrap(),
1173+
);
1174+
}
1175+
1176+
Ok(())
1177+
}
1178+
11081179
/// Get the `AsyncReceiveOfferCache` for persistence.
11091180
pub(crate) fn writeable_async_receive_offer_cache(&self) -> impl Writeable + '_ {
11101181
&self.async_receive_offer_cache

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)