Refresh async offers on set_inv_server_paths
What changed, and why it matters
This commit changes how a Lightning node sets up 'async receive' offers. Previously, the node only asked the static invoice server to create offers during periodic timer ticks. Now it also sends that request immediately when the user configures the invoice server paths. This is a functional/availability improvement to make async receiving ready sooner after startup, not a fix for an exploitable vulnerability. There is no evidence in the commit of a security bug being patched.
Review as a normal functional improvement. No security patch or incident response is indicated by the commit content. If deploying, verify that immediate offer-paths requests do not cause unwanted startup noise or duplicate requests in your environment.
Security signals we found
Behavioral change in async payment offer setup timing
Refactoring of refresh logic into a separate helper
New test assertions that outbound onion messages are produced immediately after configuration
No input validation, cryptographic, or memory-safety changes visible
Evidence from the diff
The patch refactors set_paths_to_static_invoice_server in lightning/src/offers/flow.rs so that, after storing the new blinded paths, it immediately calls a new helper check_refresh_async_offers. That helper extracts the existing offer-refresh logic from the timer-driven check_refresh_offers path. channelmanager.rs now passes the current peer list into set_paths_to_static_invoice_server so reply paths can be created. Tests are updated to assert that OfferPathsRequest messages are enqueued right after set_paths_to_static_invoice_server is called. The change is defensive/robustness-oriented: it ensures async receive offers are refreshed as soon as configuration is available, rather than waiting for the next timer tick.
Changed components
lightning/src/offers/flow.rslightning/src/ln/channelmanager.rslightning/src/ln/async_payments_tests.rsInspect captured patch +101 / −33
diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs
index 54815f4..0e412a2 100644
--- a/lightning/src/ln/async_payments_tests.rs
+++ b/lightning/src/ln/async_payments_tests.rs
@@ -330,6 +330,38 @@ fn extract_payment_preimage(event: &Event) -> PaymentPreimage {
}
}
+fn expect_offer_paths_requests(recipient: &Node, next_hop_nodes: &[&Node]) {
+ // We want to check that the async recipient has enqueued at least one `OfferPathsRequest` and no
+ // other message types. Check this by iterating through all their outbound onion messages, peeling
+ // multiple times if the messages are forwarded through other nodes.
+ let per_msg_recipient_msgs = recipient.onion_messenger.release_pending_msgs();
+ let mut pk_to_msg = Vec::new();
+ for (pk, msgs) in per_msg_recipient_msgs {
+ for msg in msgs {
+ pk_to_msg.push((pk, msg));
+ }
+ }
+ let mut num_offer_paths_reqs: u8 = 0;
+ while let Some((pk, msg)) = pk_to_msg.pop() {
+ let node = next_hop_nodes.iter().find(|node| node.node.get_our_node_id() == pk).unwrap();
+ let peeled_msg = node.onion_messenger.peel_onion_message(&msg).unwrap();
+ match peeled_msg {
+ PeeledOnion::AsyncPayments(AsyncPaymentsMessage::OfferPathsRequest(_), _, _) => {
+ num_offer_paths_reqs += 1;
+ },
+ PeeledOnion::Forward(next_hop, msg) => {
+ let next_pk = match next_hop {
+ crate::blinded_path::message::NextMessageHop::NodeId(pk) => pk,
+ _ => panic!(),
+ };
+ pk_to_msg.push((next_pk, msg));
+ },
+ _ => panic!("Unexpected message"),
+ }
+ }
+ assert!(num_offer_paths_reqs > 0);
+}
+
fn advance_time_by(duration: Duration, node: &Node) {
let target_time = (node.node.duration_since_epoch() + duration).as_secs() as u32;
let block = create_dummy_block(node.best_block_hash(), target_time, Vec::new());
@@ -512,6 +544,7 @@ fn ignore_unexpected_static_invoice() {
let inv_server_paths =
nodes[1].node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
nodes[2].node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1]]);
// Initiate payment to the sender's intended offer.
let valid_static_invoice =
@@ -609,6 +642,7 @@ fn async_receive_flow_success() {
let inv_server_paths =
nodes[1].node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
nodes[2].node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1]]);
let invoice_flow_res =
pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id.clone());
@@ -671,6 +705,7 @@ fn expired_static_invoice_fail() {
let inv_server_paths =
nodes[1].node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
nodes[2].node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1]]);
let static_invoice =
pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id.clone()).invoice;
@@ -746,6 +781,7 @@ fn timeout_unreleased_payment() {
let inv_server_paths =
server.node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
recipient.node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1]]);
let static_invoice =
pass_static_invoice_server_messages(server, recipient, recipient_id.clone()).invoice;
@@ -831,6 +867,7 @@ fn async_receive_mpp() {
let inv_server_paths =
nodes[1].node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
nodes[3].node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[3], &[&nodes[0], &nodes[1], &nodes[2]]);
let static_invoice =
pass_static_invoice_server_messages(&nodes[1], &nodes[3], recipient_id.clone()).invoice;
@@ -924,6 +961,7 @@ fn amount_doesnt_match_invreq() {
let inv_server_paths =
nodes[1].node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
nodes[3].node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[3], &[&nodes[0], &nodes[1], &nodes[2]]);
let static_invoice =
pass_static_invoice_server_messages(&nodes[1], &nodes[3], recipient_id.clone()).invoice;
@@ -1124,6 +1162,7 @@ fn invalid_async_receive_with_retry<F1, F2>(
let inv_server_paths =
nodes[1].node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
nodes[2].node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1]]);
// Set the random bytes so we can predict the offer nonce.
let hardcoded_random_bytes = [42; 32];
@@ -1251,6 +1290,7 @@ fn expired_static_invoice_message_path() {
let inv_server_paths =
nodes[1].node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
nodes[2].node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1]]);
let static_invoice =
pass_static_invoice_server_messages(&nodes[1], &nodes[2], recipient_id.clone()).invoice;
@@ -1315,6 +1355,7 @@ fn expired_static_invoice_payment_path() {
let inv_server_paths =
nodes[1].node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
nodes[2].node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1]]);
// Make sure all nodes are at the same block height in preparation for CLTV timeout things.
let node_max_height =
@@ -1576,10 +1617,12 @@ fn limit_offer_paths_requests() {
let inv_server_paths =
server.node.blinded_paths_for_async_recipient(recipient_id, None).unwrap();
recipient.node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[1], &[&nodes[0]]);
// Up to TEST_MAX_UPDATE_ATTEMPTS offer_paths_requests are allowed to be sent out before the async
// recipient should give up.
- for _ in 0..TEST_MAX_UPDATE_ATTEMPTS {
+ // Subtract 1 because we sent the first request when invoice server paths were set above.
+ for _ in 0..TEST_MAX_UPDATE_ATTEMPTS - 1 {
recipient.node.test_check_refresh_async_receive_offers();
let offer_paths_req = recipient
.onion_messenger
@@ -1744,6 +1787,7 @@ fn refresh_static_invoices() {
let inv_server_paths =
server.node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
recipient.node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1]]);
// Set up the recipient to have one offer and an invoice with the static invoice server.
let flow_res = pass_static_invoice_server_messages(server, recipient, recipient_id.clone());
@@ -2136,6 +2180,7 @@ fn invoice_server_is_not_channel_peer() {
let inv_server_paths =
invoice_server.node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap();
recipient.node.set_paths_to_static_invoice_server(inv_server_paths).unwrap();
+ expect_offer_paths_requests(&nodes[2], &[&nodes[0], &nodes[1], &nodes[3]]);
let invoice =
pass_static_invoice_server_messages(invoice_server, recipient, recipient_id.clone())
.invoice;
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 8846f1c..b2ee350 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -11905,7 +11905,8 @@ where
pub fn set_paths_to_static_invoice_server(
&self, paths_to_static_invoice_server: Vec<BlindedMessagePath>,
) -> Result<(), ()> {
- self.flow.set_paths_to_static_invoice_server(paths_to_static_invoice_server)?;
+ let peers = self.get_peers_for_blinded_path();
+ self.flow.set_paths_to_static_invoice_server(paths_to_static_invoice_server, peers)?;
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
Ok(())
diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs
index 66bd582..a80ba4b 100644
--- a/lightning/src/offers/flow.rs
+++ b/lightning/src/offers/flow.rs
@@ -162,14 +162,24 @@ where
/// [`Offer`]s with a static invoice server, so the server can serve [`StaticInvoice`]s to payers
/// on our behalf when we're offline.
///
+ /// This method will also send out messages initiating async offer creation to the static invoice
+ /// server, if any peers are connected.
+ ///
/// This method only needs to be called once when the server first takes on the recipient as a
/// client, or when the paths change, e.g. if the paths are set to expire at a particular time.
#[cfg(async_payments)]
pub fn set_paths_to_static_invoice_server(
&self, paths_to_static_invoice_server: Vec<BlindedMessagePath>,
+ peers: Vec<MessageForwardNode>,
) -> Result<(), ()> {
let mut cache = self.async_receive_offer_cache.lock().unwrap();
cache.set_paths_to_static_invoice_server(paths_to_static_invoice_server.clone())?;
+ core::mem::drop(cache);
+
+ // We'll only fail here if no peers are connected yet for us to create reply paths to outbound
+ // offer_paths_requests, so ignore the error.
+ let _ = self.check_refresh_async_offers(peers, false);
+
Ok(())
}
@@ -1252,49 +1262,61 @@ where
R::Target: Router,
{
// Terminate early if this node does not intend to receive async payments.
- let mut cache = self.async_receive_offer_cache.lock().unwrap();
- if cache.paths_to_static_invoice_server().is_empty() {
- return Ok(());
+ {
+ let cache = self.async_receive_offer_cache.lock().unwrap();
+ if cache.paths_to_static_invoice_server().is_empty() {
+ return Ok(());
+ }
}
+ self.check_refresh_async_offers(peers.clone(), timer_tick_occurred)?;
+
+ if timer_tick_occurred {
+ self.check_refresh_static_invoices(peers, usable_channels, entropy, router);
+ }
+
+ Ok(())
+ }
+
+ #[cfg(async_payments)]
+ fn check_refresh_async_offers(
+ &self, peers: Vec<MessageForwardNode>, timer_tick_occurred: bool,
+ ) -> Result<(), ()> {
let duration_since_epoch = self.duration_since_epoch();
+ let mut cache = self.async_receive_offer_cache.lock().unwrap();
// Update the cache to remove expired offers, and check to see whether we need new offers to be
// interactively built with the static invoice server.
let needs_new_offers =
cache.prune_expired_offers(duration_since_epoch, timer_tick_occurred);
+ if !needs_new_offers {
+ return Ok(());
+ }
// If we need new offers, send out offer paths request messages to the static invoice server.
- if needs_new_offers {
- let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths {
- path_absolute_expiry: duration_since_epoch
- .saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY),
- });
- let reply_paths = match self.create_blinded_paths(peers.clone(), context) {
- Ok(paths) => paths,
- Err(()) => {
- return Err(());
- },
- };
-
- // We can't fail past this point, so indicate to the cache that we've requested new offers.
- cache.new_offers_requested();
+ let context = MessageContext::AsyncPayments(AsyncPaymentsContext::OfferPaths {
+ path_absolute_expiry: duration_since_epoch
+ .saturating_add(TEMP_REPLY_PATH_RELATIVE_EXPIRY),
+ });
+ let reply_paths = match self.create_blinded_paths(peers, context) {
+ Ok(paths) => paths,
+ Err(()) => {
+ return Err(());
+ },
+ };
- let mut pending_async_payments_messages =
- self.pending_async_payments_messages.lock().unwrap();
- let message = AsyncPaymentsMessage::OfferPathsRequest(OfferPathsRequest {});
- enqueue_onion_message_with_reply_paths(
- message,
- cache.paths_to_static_invoice_server(),
- reply_paths,
- &mut pending_async_payments_messages,
- );
- }
- core::mem::drop(cache);
+ // We can't fail past this point, so indicate to the cache that we've requested new offers.
+ cache.new_offers_requested();
- if timer_tick_occurred {
- self.check_refresh_static_invoices(peers, usable_channels, entropy, router);
- }
+ let mut pending_async_payments_messages =
+ self.pending_async_payments_messages.lock().unwrap();
+ let message = AsyncPaymentsMessage::OfferPathsRequest(OfferPathsRequest {});
+ enqueue_onion_message_with_reply_paths(
+ message,
+ cache.paths_to_static_invoice_server(),
+ reply_paths,
+ &mut pending_async_payments_messages,
+ );
Ok(())
}
Why this scored 28/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.