From c005b11dc36d3d48f1916d24a21da742d37709d6 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 5 May 2026 20:58:22 +0200 Subject: [PATCH] Fix `StaticInvoice::is_offer_expired` to check the offer's expiry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The std-only `StaticInvoice::is_offer_expired` accessor delegated to `InvoiceContents::is_expired`, which compares `created_at + relative_expiry` against the current time — that is the *invoice*'s expiry, not the offer's. The `_no_std` sibling and `flow.rs:: enqueue_static_invoice` already treat the two as distinct checks. A payer or forwarder using the std API to decide whether to honor a static invoice would therefore get the wrong answer in either direction: forwarding offers the issuer has already retired (when the invoice is still fresh), or refusing offers that are still valid (when the invoice has aged past its `relative_expiry` but the offer itself has no `absolute_expiry`). Route the std accessor through `InvoiceContents::is_offer_expired` so both the std and no-std paths consult the offer's expiry. Co-Authored-By: HAL 9000 --- lightning/src/offers/static_invoice.rs | 39 +++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/lightning/src/offers/static_invoice.rs b/lightning/src/offers/static_invoice.rs index c8afb7cfc12..860835912a1 100644 --- a/lightning/src/offers/static_invoice.rs +++ b/lightning/src/offers/static_invoice.rs @@ -408,7 +408,7 @@ impl StaticInvoice { /// Whether the [`Offer`] that this invoice is based on is expired. #[cfg(feature = "std")] pub fn is_offer_expired(&self) -> bool { - self.contents.is_expired() + self.contents.is_offer_expired() } /// Whether the [`Offer`] that this invoice is based on is expired, given the current time as @@ -1003,6 +1003,43 @@ mod tests { } } + #[cfg(feature = "std")] + #[test] + fn is_offer_expired_does_not_check_invoice_expiry() { + // Regression test: `StaticInvoice::is_offer_expired` must reflect the offer's expiry, + // not the invoice's own expiry. Build an invoice whose offer has no absolute expiry + // (so the offer never expires) but whose own `created_at + relative_expiry` lies in + // the past (so the invoice itself is expired). + let node_id = recipient_pubkey(); + let payment_paths = payment_paths(); + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + + let offer = OfferBuilder::deriving_signing_pubkey(node_id, &expanded_key, nonce, &secp_ctx) + .path(blinded_path()) + .build() + .unwrap(); + + let invoice = StaticInvoiceBuilder::for_offer_using_derived_keys( + &offer, + payment_paths.clone(), + vec![blinded_path()], + Duration::from_secs(0), + &expanded_key, + nonce, + &secp_ctx, + ) + .unwrap() + .relative_expiry(1) + .build_and_sign(&secp_ctx) + .unwrap(); + + assert!(invoice.is_expired()); + assert!(!invoice.is_offer_expired()); + } + #[test] fn builds_invoice_from_offer_using_derived_key() { let node_id = recipient_pubkey();