Skip to content

Commit 9e20b7d

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 e5dca8d commit 9e20b7d

File tree

5 files changed

+222
-1
lines changed

5 files changed

+222
-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
@@ -4953,6 +4953,18 @@ where
49534953
)
49544954
}
49554955

4956+
#[cfg(async_payments)]
4957+
fn check_refresh_async_receive_offers(&self) {
4958+
match self.flow.check_refresh_async_receive_offers(
4959+
self.get_peers_for_blinded_path(), &*self.entropy_source
4960+
) {
4961+
Err(()) => {
4962+
log_error!(self.logger, "Failed to create blinded paths when requesting async receive offer paths");
4963+
},
4964+
Ok(()) => {},
4965+
}
4966+
}
4967+
49564968
#[cfg(async_payments)]
49574969
fn initiate_async_payment(
49584970
&self, invoice: &StaticInvoice, payment_id: PaymentId
@@ -6876,6 +6888,9 @@ where
68766888
duration_since_epoch, &self.pending_events
68776889
);
68786890

6891+
#[cfg(async_payments)]
6892+
self.check_refresh_async_receive_offers();
6893+
68796894
// Technically we don't need to do this here, but if we have holding cell entries in a
68806895
// channel that need freeing, it's better to do that here and block a background task
68816896
// than block the message queueing pipeline.
@@ -11193,6 +11208,9 @@ where
1119311208
return NotifyOption::SkipPersistHandleEvents;
1119411209
//TODO: Also re-broadcast announcement_signatures
1119511210
});
11211+
11212+
#[cfg(async_payments)]
11213+
self.check_refresh_async_receive_offers();
1119611214
res
1119711215
}
1119811216

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: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ use {
6565
crate::offers::offer::Amount,
6666
crate::offers::signer,
6767
crate::offers::static_invoice::{StaticInvoice, StaticInvoiceBuilder},
68-
crate::onion_message::async_payments::HeldHtlcAvailable,
68+
crate::onion_message::async_payments::{HeldHtlcAvailable, OfferPathsRequest},
6969
};
7070

7171
#[cfg(feature = "dnssec")]
@@ -216,6 +216,11 @@ where
216216
/// even if multiple invoices are received.
217217
const OFFERS_MESSAGE_REQUEST_LIMIT: usize = 10;
218218

219+
/// The default relative expiry for reply paths where a quick response is expected and the reply
220+
/// path is single-use.
221+
#[cfg(async_payments)]
222+
const TEMP_REPLY_PATH_RELATIVE_EXPIRY: Duration = Duration::from_secs(7200);
223+
219224
impl<MR: Deref> OffersMessageFlow<MR>
220225
where
221226
MR::Target: MessageRouter,
@@ -1103,4 +1108,67 @@ where
11031108
) -> Vec<(DNSResolverMessage, MessageSendInstructions)> {
11041109
core::mem::take(&mut self.pending_dns_onion_messages.lock().unwrap())
11051110
}
1111+
1112+
/// Sends out [`OfferPathsRequest`] onion messages if we are an often-offline recipient and are
1113+
/// configured to interactively build offers and static invoices with a static invoice server.
1114+
///
1115+
/// # Usage
1116+
///
1117+
/// This method should be called on peer connection and every few minutes or so, to keep the
1118+
/// offers cache updated.
1119+
///
1120+
/// Errors if we failed to create blinded reply paths when sending an [`OfferPathsRequest`] message.
1121+
#[cfg(async_payments)]
1122+
pub(crate) fn check_refresh_async_receive_offers<ES: Deref>(
1123+
&self, peers: Vec<MessageForwardNode>, entropy: ES,
1124+
) -> Result<(), ()>
1125+
where
1126+
ES::Target: EntropySource,
1127+
{
1128+
// Terminate early if this node does not intend to receive async payments.
1129+
if self.paths_to_static_invoice_server.is_empty() {
1130+
return Ok(());
1131+
}
1132+
1133+
let expanded_key = &self.inbound_payment_key;
1134+
let duration_since_epoch = self.duration_since_epoch();
1135+
1136+
// Check with the cache to see whether we need new offers to be interactively built with the
1137+
// static invoice server.
1138+
let mut async_receive_offer_cache = self.async_receive_offer_cache.lock().unwrap();
1139+
async_receive_offer_cache.prune_expired_offers(duration_since_epoch);
1140+
let needs_new_offers =
1141+
async_receive_offer_cache.should_request_offer_paths(duration_since_epoch);
1142+
1143+
// If we need new offers, send out offer paths request messages to the static invoice server.
1144+
if needs_new_offers {
1145+
let nonce = Nonce::from_entropy_source(&*entropy);
1146+
let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths {
1147+
nonce,
1148+
hmac: signer::hmac_for_offer_paths_context(nonce, expanded_key),
1149+
path_absolute_expiry: duration_since_epoch
1150+
.saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY),
1151+
});
1152+
let reply_paths = match self.create_blinded_paths(peers, context) {
1153+
Ok(paths) => paths,
1154+
Err(()) => {
1155+
return Err(());
1156+
},
1157+
};
1158+
1159+
// We can't fail past this point, so indicate to the cache that we've requested new offers.
1160+
async_receive_offer_cache.new_offers_requested(duration_since_epoch);
1161+
core::mem::drop(async_receive_offer_cache);
1162+
1163+
let message = AsyncPaymentsMessage::OfferPathsRequest(OfferPathsRequest {});
1164+
enqueue_onion_message_with_reply_paths(
1165+
message,
1166+
&self.paths_to_static_invoice_server[..],
1167+
reply_paths,
1168+
&mut self.pending_async_payments_messages.lock().unwrap(),
1169+
);
1170+
}
1171+
1172+
Ok(())
1173+
}
11061174
}

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)