From dd887ffa5b4f2b40cb03e28e8d1dd555648b6b0c Mon Sep 17 00:00:00 2001 From: Maurice Poirrier Chuden Date: Tue, 8 Jul 2025 12:35:55 -0700 Subject: [PATCH 1/8] add `expiry_time` to `PendingOutboundPayment::StaticInvoiceReceived` Previous this commit if the StaticInvoice has an expiration time of months or years would make our HTLC to hold for that time until is abandoned. This patch adds a defaults of 1 week of expiry time that the HTLC will be held waiting for the often-offline node comes online. --- lightning/src/ln/outbound_payment.rs | 135 ++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index fdbfcc089e5..f10f4365810 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -54,6 +54,11 @@ use crate::sync::Mutex; /// [`ChannelManager::timer_tick_occurred`]: crate::ln::channelmanager::ChannelManager::timer_tick_occurred pub(crate) const IDEMPOTENCY_TIMEOUT_TICKS: u8 = 7; +#[cfg(async_payments)] +/// Relative expiration in seconds to wait for a pending outbound HTLC to a often-offline +/// payee to fulfill. +const DEFAULT_ASYNC_PAYMENT_FULFILLMENT_EXPIRY_TIME: u64 = 60 * 60 * 24 * 7; + /// Stores the session_priv for each part of a payment that is still pending. For versions 0.0.102 /// and later, also stores information for retrying the payment. pub(crate) enum PendingOutboundPayment { @@ -98,6 +103,9 @@ pub(crate) enum PendingOutboundPayment { route_params: RouteParameters, invoice_request: InvoiceRequest, static_invoice: StaticInvoice, + // Stale time expiration of how much time we will wait to the payment to fulfill. + // Defaults to [`DEFAULT_ASYNC_PAYMENT_FULFILLMENT_EXPIRY_TIME`]. + expiry_time: StaleExpiration, }, Retryable { retry_strategy: Option, @@ -1164,6 +1172,7 @@ impl OutboundPayments { abandon_with_entry!(entry, PaymentFailureReason::RouteNotFound); return Err(Bolt12PaymentError::SendingFailed(RetryableSendFailure::OnionPacketSizeExceeded)) } + let absolute_expiry = invoice.created_at().saturating_add(Duration::from_secs(DEFAULT_ASYNC_PAYMENT_FULFILLMENT_EXPIRY_TIME)); *entry.into_mut() = PendingOutboundPayment::StaticInvoiceReceived { payment_hash, @@ -1176,6 +1185,7 @@ impl OutboundPayments { .ok_or(Bolt12PaymentError::UnexpectedInvoice)? .invoice_request, static_invoice: invoice.clone(), + expiry_time: StaleExpiration::AbsoluteTimeout(absolute_expiry), }; return Ok(()) }, @@ -2242,11 +2252,24 @@ impl OutboundPayments { true } }, - PendingOutboundPayment::StaticInvoiceReceived { route_params, payment_hash, .. } => { - let is_stale = + PendingOutboundPayment::StaticInvoiceReceived { route_params, payment_hash, expiry_time, .. } => { + let is_stale = match expiry_time { + StaleExpiration::AbsoluteTimeout(expiration_time) => { + *expiration_time < duration_since_epoch + }, + StaleExpiration::TimerTicks(timer_ticks_remaining) => { + if *timer_ticks_remaining > 0 { + *timer_ticks_remaining -= 1; + false + } else { + true + } + } + }; + let is_static_invoice_stale = route_params.payment_params.expiry_time.unwrap_or(u64::MAX) < duration_since_epoch.as_secs(); - if is_stale { + if is_stale || is_static_invoice_stale { let fail_ev = events::Event::PaymentFailed { payment_id: *payment_id, payment_hash: Some(*payment_hash), @@ -2661,6 +2684,7 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, (6, route_params, required), (8, invoice_request, required), (10, static_invoice, required), + (12, expiry_time, required), }, // Added in 0.1. Prior versions will drop these outbounds on downgrade, which is safe because // no HTLCs are in-flight. @@ -3311,6 +3335,7 @@ mod tests { route_params, invoice_request: dummy_invoice_request(), static_invoice: dummy_static_invoice(), + expiry_time: StaleExpiration::AbsoluteTimeout(Duration::from_secs(absolute_expiry + 2)), }; outbounds.insert(payment_id, outbound); core::mem::drop(outbounds); @@ -3336,6 +3361,109 @@ mod tests { }, None)); } + #[test] + #[rustfmt::skip] + fn time_out_unreleased_async_payments_using_stale_absolute_time() { + let pending_events = Mutex::new(VecDeque::new()); + let outbound_payments = OutboundPayments::new(new_hash_map()); + let payment_id = PaymentId([0; 32]); + let absolute_expiry = 60; + + let mut outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); + let payment_params = PaymentParameters::from_node_id(test_utils::pubkey(42), 0) + .with_expiry_time(absolute_expiry); + let route_params = RouteParameters { + payment_params, + final_value_msat: 0, + max_total_routing_fee_msat: None, + }; + let payment_hash = PaymentHash([0; 32]); + let outbound = PendingOutboundPayment::StaticInvoiceReceived { + payment_hash, + keysend_preimage: PaymentPreimage([0; 32]), + retry_strategy: Retry::Attempts(0), + route_params, + invoice_request: dummy_invoice_request(), + static_invoice: dummy_static_invoice(), + expiry_time: StaleExpiration::AbsoluteTimeout(Duration::from_secs(absolute_expiry)), + }; + outbounds.insert(payment_id, outbound); + core::mem::drop(outbounds); + + // The payment will not be removed if it isn't expired yet. + outbound_payments.remove_stale_payments(Duration::from_secs(absolute_expiry), &pending_events); + let outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); + assert_eq!(outbounds.len(), 1); + let events = pending_events.lock().unwrap(); + assert_eq!(events.len(), 0); + core::mem::drop(outbounds); + core::mem::drop(events); + + outbound_payments.remove_stale_payments(Duration::from_secs(absolute_expiry + 1), &pending_events); + let outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); + assert_eq!(outbounds.len(), 0); + let events = pending_events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0], (Event::PaymentFailed { + payment_hash: Some(payment_hash), + payment_id, + reason: Some(PaymentFailureReason::PaymentExpired), + }, None)); + } + + #[test] + #[rustfmt::skip] + fn time_out_unreleased_async_payments_using_stale_timer_ticks() { + let pending_events = Mutex::new(VecDeque::new()); + let outbound_payments = OutboundPayments::new(new_hash_map()); + let payment_id = PaymentId([0; 32]); + let absolute_expiry = 60; + + let mut outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); + let payment_params = PaymentParameters::from_node_id(test_utils::pubkey(42), 0) + .with_expiry_time(absolute_expiry); + let route_params = RouteParameters { + payment_params, + final_value_msat: 0, + max_total_routing_fee_msat: None, + }; + let payment_hash = PaymentHash([0; 32]); + let timer_ticks = 1; + let expiration = StaleExpiration::TimerTicks(timer_ticks); + let outbound = PendingOutboundPayment::StaticInvoiceReceived { + payment_hash, + keysend_preimage: PaymentPreimage([0; 32]), + retry_strategy: Retry::Attempts(0), + route_params, + invoice_request: dummy_invoice_request(), + static_invoice: dummy_static_invoice(), + expiry_time: expiration, + }; + outbounds.insert(payment_id, outbound); + core::mem::drop(outbounds); + + // First time should go through + outbound_payments.remove_stale_payments(Duration::from_secs(absolute_expiry), &pending_events); + let outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); + assert_eq!(outbounds.len(), 1); + let events = pending_events.lock().unwrap(); + assert_eq!(events.len(), 0); + core::mem::drop(outbounds); + core::mem::drop(events); + + // As timer ticks is 1, payment should be timed out + outbound_payments.remove_stale_payments(Duration::from_secs(absolute_expiry), &pending_events); + let outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); + assert_eq!(outbounds.len(), 0); + let events = pending_events.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0], (Event::PaymentFailed { + payment_hash: Some(payment_hash), + payment_id, + reason: Some(PaymentFailureReason::PaymentExpired), + }, None)); + } + #[test] #[rustfmt::skip] fn abandon_unreleased_async_payment() { @@ -3360,6 +3488,7 @@ mod tests { route_params, invoice_request: dummy_invoice_request(), static_invoice: dummy_static_invoice(), + expiry_time: StaleExpiration::AbsoluteTimeout(now()), }; outbounds.insert(payment_id, outbound); core::mem::drop(outbounds); From 048e088810eef1f6e18db03ff53b1c263d6146c9 Mon Sep 17 00:00:00 2001 From: Maurice Poirrier Chuden Date: Wed, 9 Jul 2025 12:30:35 -0700 Subject: [PATCH 2/8] f: rename constant to `ASYNC_PAYMENT_TIMEOUT_SECS` --- lightning/src/ln/outbound_payment.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index f10f4365810..73648492192 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -57,7 +57,7 @@ pub(crate) const IDEMPOTENCY_TIMEOUT_TICKS: u8 = 7; #[cfg(async_payments)] /// Relative expiration in seconds to wait for a pending outbound HTLC to a often-offline /// payee to fulfill. -const DEFAULT_ASYNC_PAYMENT_FULFILLMENT_EXPIRY_TIME: u64 = 60 * 60 * 24 * 7; +const ASYNC_PAYMENT_TIMEOUT_SECS: u64 = 60 * 60 * 24 * 7; /// Stores the session_priv for each part of a payment that is still pending. For versions 0.0.102 /// and later, also stores information for retrying the payment. @@ -104,7 +104,7 @@ pub(crate) enum PendingOutboundPayment { invoice_request: InvoiceRequest, static_invoice: StaticInvoice, // Stale time expiration of how much time we will wait to the payment to fulfill. - // Defaults to [`DEFAULT_ASYNC_PAYMENT_FULFILLMENT_EXPIRY_TIME`]. + // Defaults to [`ASYNC_PAYMENT_TIMEOUT_SECS`]. expiry_time: StaleExpiration, }, Retryable { @@ -1172,7 +1172,7 @@ impl OutboundPayments { abandon_with_entry!(entry, PaymentFailureReason::RouteNotFound); return Err(Bolt12PaymentError::SendingFailed(RetryableSendFailure::OnionPacketSizeExceeded)) } - let absolute_expiry = invoice.created_at().saturating_add(Duration::from_secs(DEFAULT_ASYNC_PAYMENT_FULFILLMENT_EXPIRY_TIME)); + let absolute_expiry = invoice.created_at().saturating_add(Duration::from_secs(ASYNC_PAYMENT_TIMEOUT_SECS)); *entry.into_mut() = PendingOutboundPayment::StaticInvoiceReceived { payment_hash, From 5bf4553761eeceb73261833e4d0cbbb89be2812d Mon Sep 17 00:00:00 2001 From: Maurice Poirrier Chuden Date: Wed, 9 Jul 2025 12:33:24 -0700 Subject: [PATCH 3/8] f: use current time instead of invoice creation time --- lightning/src/ln/outbound_payment.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 73648492192..d6b118ba1f8 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -1172,7 +1172,7 @@ impl OutboundPayments { abandon_with_entry!(entry, PaymentFailureReason::RouteNotFound); return Err(Bolt12PaymentError::SendingFailed(RetryableSendFailure::OnionPacketSizeExceeded)) } - let absolute_expiry = invoice.created_at().saturating_add(Duration::from_secs(ASYNC_PAYMENT_TIMEOUT_SECS)); + let absolute_expiry = duration_since_epoch.saturating_add(Duration::from_secs(ASYNC_PAYMENT_TIMEOUT_SECS)); *entry.into_mut() = PendingOutboundPayment::StaticInvoiceReceived { payment_hash, From 657b3a9242f986b8291bac5cab70d76622a6ecbc Mon Sep 17 00:00:00 2001 From: Maurice Poirrier Chuden Date: Wed, 9 Jul 2025 12:40:12 -0700 Subject: [PATCH 4/8] f: change timeout to relative expiry duration --- lightning/src/ln/outbound_payment.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index d6b118ba1f8..4d64b889ea9 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -55,9 +55,9 @@ use crate::sync::Mutex; pub(crate) const IDEMPOTENCY_TIMEOUT_TICKS: u8 = 7; #[cfg(async_payments)] -/// Relative expiration in seconds to wait for a pending outbound HTLC to a often-offline +/// The default relative expiration to wait for a pending outbound HTLC to a often-offline /// payee to fulfill. -const ASYNC_PAYMENT_TIMEOUT_SECS: u64 = 60 * 60 * 24 * 7; +const ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24 * 7); /// Stores the session_priv for each part of a payment that is still pending. For versions 0.0.102 /// and later, also stores information for retrying the payment. @@ -104,7 +104,7 @@ pub(crate) enum PendingOutboundPayment { invoice_request: InvoiceRequest, static_invoice: StaticInvoice, // Stale time expiration of how much time we will wait to the payment to fulfill. - // Defaults to [`ASYNC_PAYMENT_TIMEOUT_SECS`]. + // Defaults to [`ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY`]. expiry_time: StaleExpiration, }, Retryable { @@ -1172,7 +1172,7 @@ impl OutboundPayments { abandon_with_entry!(entry, PaymentFailureReason::RouteNotFound); return Err(Bolt12PaymentError::SendingFailed(RetryableSendFailure::OnionPacketSizeExceeded)) } - let absolute_expiry = duration_since_epoch.saturating_add(Duration::from_secs(ASYNC_PAYMENT_TIMEOUT_SECS)); + let absolute_expiry = duration_since_epoch.saturating_add(ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY); *entry.into_mut() = PendingOutboundPayment::StaticInvoiceReceived { payment_hash, From 58b51a51e33a8a3d1159379237ced1cd79901966 Mon Sep 17 00:00:00 2001 From: Maurice Poirrier Chuden Date: Wed, 9 Jul 2025 15:28:33 -0700 Subject: [PATCH 5/8] f: add end-to-end functional test --- lightning/src/ln/async_payments_tests.rs | 76 +++++++++++++++- lightning/src/ln/outbound_payment.rs | 106 +---------------------- 2 files changed, 77 insertions(+), 105 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index ab9f172ce1b..ef20f051b68 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -23,8 +23,7 @@ use crate::ln::msgs::{ }; use crate::ln::offers_tests; use crate::ln::onion_utils::LocalHTLCFailureReason; -use crate::ln::outbound_payment::PendingOutboundPayment; -use crate::ln::outbound_payment::Retry; +use crate::ln::outbound_payment::{PendingOutboundPayment, Retry, TEST_ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY}; use crate::offers::async_receive_offer_cache::{ TEST_MAX_CACHED_OFFERS_TARGET, TEST_MAX_UPDATE_ATTEMPTS, TEST_MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS, TEST_OFFER_REFRESH_THRESHOLD, @@ -681,6 +680,79 @@ fn expired_static_invoice_fail() { // doesn't currently provide them with a reply path to do so. } +#[cfg_attr(feature = "std", ignore)] +#[test] +fn timeout_unreleased_payment() { + // If a server holds a pending HTLC for too long, payment is considered expired. + let chanmon_cfgs = create_chanmon_cfgs(3); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); + create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + + let sender = &nodes[0]; + let server = &nodes[1]; + let recipient = &nodes[2]; + + let recipient_id = vec![42; 32]; + 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(); + + let static_invoice = + pass_static_invoice_server_messages(server, recipient, recipient_id.clone()).invoice; + let offer = recipient.node.get_async_receive_offer().unwrap(); + + let amt_msat = 5000; + let payment_id = PaymentId([1; 32]); + let params = RouteParametersConfig::default(); + sender + .node + .pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(0), params) + .unwrap(); + + let invreq_om = sender + .onion_messenger + .next_onion_message_for_peer(server.node.get_our_node_id()) + .unwrap(); + server.onion_messenger.handle_onion_message(sender.node.get_our_node_id(), &invreq_om); + + let mut events = server.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + let reply_path = match events.pop().unwrap() { + Event::StaticInvoiceRequested { reply_path, .. } => reply_path, + _ => panic!(), + }; + + server.node.send_static_invoice(static_invoice.clone(), reply_path).unwrap(); + let static_invoice_om = server + .onion_messenger + .next_onion_message_for_peer(sender.node.get_our_node_id()) + .unwrap(); + + // We handle the static invoice to held the pending HTLC + sender + .onion_messenger + .handle_onion_message(sender.node.get_our_node_id(), &static_invoice_om); + + // We advance enough time to expire the payment. + // We add 2 hours as is the margin added to remove stale payments in non-std implementation. + let timeout_time_expiry = TEST_ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY + + Duration::from_secs(7200) + + Duration::from_secs(1); + advance_time_by(timeout_time_expiry, sender); + sender.node.timer_tick_occurred(); + let events = sender.node.get_and_clear_pending_events(); + assert_eq!(events.len(), 1); + match events[0] { + Event::PaymentFailed { payment_id: ev_payment_id, reason, .. } => { + assert_eq!(reason.unwrap(), PaymentFailureReason::PaymentExpired); + assert_eq!(ev_payment_id, payment_id); + }, + _ => panic!(), + } +} + #[test] fn async_receive_mpp() { let chanmon_cfgs = create_chanmon_cfgs(4); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 4d64b889ea9..0600048e701 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -59,6 +59,9 @@ pub(crate) const IDEMPOTENCY_TIMEOUT_TICKS: u8 = 7; /// payee to fulfill. const ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24 * 7); +#[cfg(all(async_payments, test))] +pub(crate) const TEST_ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY: Duration = ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY; + /// Stores the session_priv for each part of a payment that is still pending. For versions 0.0.102 /// and later, also stores information for retrying the payment. pub(crate) enum PendingOutboundPayment { @@ -3361,109 +3364,6 @@ mod tests { }, None)); } - #[test] - #[rustfmt::skip] - fn time_out_unreleased_async_payments_using_stale_absolute_time() { - let pending_events = Mutex::new(VecDeque::new()); - let outbound_payments = OutboundPayments::new(new_hash_map()); - let payment_id = PaymentId([0; 32]); - let absolute_expiry = 60; - - let mut outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); - let payment_params = PaymentParameters::from_node_id(test_utils::pubkey(42), 0) - .with_expiry_time(absolute_expiry); - let route_params = RouteParameters { - payment_params, - final_value_msat: 0, - max_total_routing_fee_msat: None, - }; - let payment_hash = PaymentHash([0; 32]); - let outbound = PendingOutboundPayment::StaticInvoiceReceived { - payment_hash, - keysend_preimage: PaymentPreimage([0; 32]), - retry_strategy: Retry::Attempts(0), - route_params, - invoice_request: dummy_invoice_request(), - static_invoice: dummy_static_invoice(), - expiry_time: StaleExpiration::AbsoluteTimeout(Duration::from_secs(absolute_expiry)), - }; - outbounds.insert(payment_id, outbound); - core::mem::drop(outbounds); - - // The payment will not be removed if it isn't expired yet. - outbound_payments.remove_stale_payments(Duration::from_secs(absolute_expiry), &pending_events); - let outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); - assert_eq!(outbounds.len(), 1); - let events = pending_events.lock().unwrap(); - assert_eq!(events.len(), 0); - core::mem::drop(outbounds); - core::mem::drop(events); - - outbound_payments.remove_stale_payments(Duration::from_secs(absolute_expiry + 1), &pending_events); - let outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); - assert_eq!(outbounds.len(), 0); - let events = pending_events.lock().unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0], (Event::PaymentFailed { - payment_hash: Some(payment_hash), - payment_id, - reason: Some(PaymentFailureReason::PaymentExpired), - }, None)); - } - - #[test] - #[rustfmt::skip] - fn time_out_unreleased_async_payments_using_stale_timer_ticks() { - let pending_events = Mutex::new(VecDeque::new()); - let outbound_payments = OutboundPayments::new(new_hash_map()); - let payment_id = PaymentId([0; 32]); - let absolute_expiry = 60; - - let mut outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); - let payment_params = PaymentParameters::from_node_id(test_utils::pubkey(42), 0) - .with_expiry_time(absolute_expiry); - let route_params = RouteParameters { - payment_params, - final_value_msat: 0, - max_total_routing_fee_msat: None, - }; - let payment_hash = PaymentHash([0; 32]); - let timer_ticks = 1; - let expiration = StaleExpiration::TimerTicks(timer_ticks); - let outbound = PendingOutboundPayment::StaticInvoiceReceived { - payment_hash, - keysend_preimage: PaymentPreimage([0; 32]), - retry_strategy: Retry::Attempts(0), - route_params, - invoice_request: dummy_invoice_request(), - static_invoice: dummy_static_invoice(), - expiry_time: expiration, - }; - outbounds.insert(payment_id, outbound); - core::mem::drop(outbounds); - - // First time should go through - outbound_payments.remove_stale_payments(Duration::from_secs(absolute_expiry), &pending_events); - let outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); - assert_eq!(outbounds.len(), 1); - let events = pending_events.lock().unwrap(); - assert_eq!(events.len(), 0); - core::mem::drop(outbounds); - core::mem::drop(events); - - // As timer ticks is 1, payment should be timed out - outbound_payments.remove_stale_payments(Duration::from_secs(absolute_expiry), &pending_events); - let outbounds = outbound_payments.pending_outbound_payments.lock().unwrap(); - assert_eq!(outbounds.len(), 0); - let events = pending_events.lock().unwrap(); - assert_eq!(events.len(), 1); - assert_eq!(events[0], (Event::PaymentFailed { - payment_hash: Some(payment_hash), - payment_id, - reason: Some(PaymentFailureReason::PaymentExpired), - }, None)); - } - #[test] #[rustfmt::skip] fn abandon_unreleased_async_payment() { From 78cd2a528589e8b45abd33cb75b00799c80fe30a Mon Sep 17 00:00:00 2001 From: Maurice Poirrier Chuden Date: Thu, 10 Jul 2025 09:34:25 -0700 Subject: [PATCH 6/8] f: use a default value on `StaleExpiration` --- lightning/src/ln/async_payments_tests.rs | 27 +++++++++++------------- lightning/src/ln/outbound_payment.rs | 8 +++++-- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index ef20f051b68..7f3855197b0 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -23,7 +23,9 @@ use crate::ln::msgs::{ }; use crate::ln::offers_tests; use crate::ln::onion_utils::LocalHTLCFailureReason; -use crate::ln::outbound_payment::{PendingOutboundPayment, Retry, TEST_ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY}; +use crate::ln::outbound_payment::{ + PendingOutboundPayment, Retry, TEST_ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY, +}; use crate::offers::async_receive_offer_cache::{ TEST_MAX_CACHED_OFFERS_TARGET, TEST_MAX_UPDATE_ATTEMPTS, TEST_MIN_OFFER_PATHS_RELATIVE_EXPIRY_SECS, TEST_OFFER_REFRESH_THRESHOLD, @@ -696,7 +698,8 @@ fn timeout_unreleased_payment() { let recipient = &nodes[2]; let recipient_id = vec![42; 32]; - let inv_server_paths = server.node.blinded_paths_for_async_recipient(recipient_id.clone(), None).unwrap(); + 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(); let static_invoice = @@ -711,10 +714,8 @@ fn timeout_unreleased_payment() { .pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(0), params) .unwrap(); - let invreq_om = sender - .onion_messenger - .next_onion_message_for_peer(server.node.get_our_node_id()) - .unwrap(); + let invreq_om = + sender.onion_messenger.next_onion_message_for_peer(server.node.get_our_node_id()).unwrap(); server.onion_messenger.handle_onion_message(sender.node.get_our_node_id(), &invreq_om); let mut events = server.node.get_and_clear_pending_events(); @@ -725,21 +726,17 @@ fn timeout_unreleased_payment() { }; server.node.send_static_invoice(static_invoice.clone(), reply_path).unwrap(); - let static_invoice_om = server - .onion_messenger - .next_onion_message_for_peer(sender.node.get_our_node_id()) - .unwrap(); + let static_invoice_om = + server.onion_messenger.next_onion_message_for_peer(sender.node.get_our_node_id()).unwrap(); // We handle the static invoice to held the pending HTLC - sender - .onion_messenger - .handle_onion_message(sender.node.get_our_node_id(), &static_invoice_om); + sender.onion_messenger.handle_onion_message(sender.node.get_our_node_id(), &static_invoice_om); // We advance enough time to expire the payment. // We add 2 hours as is the margin added to remove stale payments in non-std implementation. let timeout_time_expiry = TEST_ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY - + Duration::from_secs(7200) - + Duration::from_secs(1); + + Duration::from_secs(7200) + + Duration::from_secs(1); advance_time_by(timeout_time_expiry, sender); sender.node.timer_tick_occurred(); let events = sender.node.get_and_clear_pending_events(); diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs index 0600048e701..044ebdcc44a 100644 --- a/lightning/src/ln/outbound_payment.rs +++ b/lightning/src/ln/outbound_payment.rs @@ -60,7 +60,8 @@ pub(crate) const IDEMPOTENCY_TIMEOUT_TICKS: u8 = 7; const ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY: Duration = Duration::from_secs(60 * 60 * 24 * 7); #[cfg(all(async_payments, test))] -pub(crate) const TEST_ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY: Duration = ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY; +pub(crate) const TEST_ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY: Duration = + ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY; /// Stores the session_priv for each part of a payment that is still pending. For versions 0.0.102 /// and later, also stores information for retrying the payment. @@ -107,6 +108,7 @@ pub(crate) enum PendingOutboundPayment { invoice_request: InvoiceRequest, static_invoice: StaticInvoice, // Stale time expiration of how much time we will wait to the payment to fulfill. + // // Defaults to [`ASYNC_PAYMENT_TIMEOUT_RELATIVE_EXPIRY`]. expiry_time: StaleExpiration, }, @@ -2687,7 +2689,9 @@ impl_writeable_tlv_based_enum_upgradable!(PendingOutboundPayment, (6, route_params, required), (8, invoice_request, required), (10, static_invoice, required), - (12, expiry_time, required), + // Added in 0.2. Prior versions would have this TLV type defaulted to 0, which is safe because + // the type is not used. + (11, expiry_time, (default_value, StaleExpiration::AbsoluteTimeout(Duration::from_secs(0)))), }, // Added in 0.1. Prior versions will drop these outbounds on downgrade, which is safe because // no HTLCs are in-flight. From e3bc8c3618814c6eb4f42224397d2951dcc1b932 Mon Sep 17 00:00:00 2001 From: Maurice Poirrier Chuden Date: Thu, 10 Jul 2025 09:46:49 -0700 Subject: [PATCH 7/8] f: add release note --- ...piry-time-when-waiting-often-offline-peer-asyncpayment.txt | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 pending_changelog/3918-expiry-time-when-waiting-often-offline-peer-asyncpayment.txt diff --git a/pending_changelog/3918-expiry-time-when-waiting-often-offline-peer-asyncpayment.txt b/pending_changelog/3918-expiry-time-when-waiting-often-offline-peer-asyncpayment.txt new file mode 100644 index 00000000000..61ae1cbd21c --- /dev/null +++ b/pending_changelog/3918-expiry-time-when-waiting-often-offline-peer-asyncpayment.txt @@ -0,0 +1,4 @@ + ## API Updates (0.2) + +* Upgrading to v0.2.0 will timeout any pending async payment waiting for the often offline peer + come online. From 9b548bcba41419efcde1cd8fb5f161ca8131a860 Mon Sep 17 00:00:00 2001 From: Maurice Poirrier Chuden Date: Thu, 10 Jul 2025 09:53:00 -0700 Subject: [PATCH 8/8] f: messed up test use server --- lightning/src/ln/async_payments_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 7f3855197b0..11bba47a732 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -730,7 +730,7 @@ fn timeout_unreleased_payment() { server.onion_messenger.next_onion_message_for_peer(sender.node.get_our_node_id()).unwrap(); // We handle the static invoice to held the pending HTLC - sender.onion_messenger.handle_onion_message(sender.node.get_our_node_id(), &static_invoice_om); + sender.onion_messenger.handle_onion_message(server.node.get_our_node_id(), &static_invoice_om); // We advance enough time to expire the payment. // We add 2 hours as is the margin added to remove stale payments in non-std implementation.