Skip to content

Refresh async static invoices after channel changes#4753

Open
shaavan wants to merge 4 commits into
lightningdevkit:mainfrom
shaavan:static-update
Open

Refresh async static invoices after channel changes#4753
shaavan wants to merge 4 commits into
lightningdevkit:mainfrom
shaavan:static-update

Conversation

@shaavan

@shaavan shaavan commented Jun 26, 2026

Copy link
Copy Markdown
Member

Implements an async receive follow-up from #4135: refresh async offers/static invoices when a channel opens, closes, or fees change.

Static invoices are built from the recipient's current payment paths. If local channel state changes, an already-published async receive offer can otherwise continue serving stale server-side invoices until the normal refresh threshold passes.

This PR adds a forced refresh path for async receive static invoices. The existing timer-based refresh behavior remains unchanged; channel changes simply add an immediate refresh trigger for affected offers.

The existing invoice construction flow remains the single source of truth. Forced refresh changes when invoices are refreshed, not how they are built. The refresh itself is deferred until after channel locks are released, preserving the existing lock-safety model while ensuring invoices are updated promptly.

shaavan and others added 4 commits June 26, 2026 14:54
Async receive offers currently decide static invoice refreshes from
timer-based freshness only. Channel changes need a separate selector so
callers can rebuild server-side invoices without waiting for the normal age
threshold.

Add a cache helper that returns used and pending offers for forced invoice
refresh. Ready offers stay on the existing offer rotation path because they
have not been returned to the application yet.

AI-assisted: planning and writing commit

Co-Authored-By: OpenAI Codex <codex@openai.com>
The cache can now identify offers whose static invoices should be rebuilt
immediately, but callers still need one canonical path that turns those offers
into ServeStaticInvoice messages.

Thread a forced-refresh entry point through OffersMessageFlow and
ChannelManager while reusing the existing static invoice construction code.
This keeps the later channel-change behavior focused on deciding when to
refresh, not how to rebuild invoices.

AI-assisted: planning and writing commit

Co-Authored-By: OpenAI Codex <codex@openai.com>
Static invoices contain blinded payment paths built from the recipient's
current channels and fees. When channels become usable, close, or require a
local fee update, cached async receive offers can otherwise keep serving stale
payment instructions until the normal refresh threshold passes.

Mark those channel changes as requiring a forced async static invoice refresh,
then process the refresh after channel locks are released. The deferred flag
avoids rebuilding invoices while holding peer/channel locks, since invoice
rebuilding needs a fresh usable-channel snapshot.

AI-assisted: planning and writing commit

Co-Authored-By: OpenAI Codex <codex@openai.com>
Used async receive offers may already be published by the application, so a
new usable channel should refresh the static invoice stored by the server
without waiting for the normal age threshold.

Add a focused async payments test that marks an offer used, opens another
channel to the static invoice server, and asserts that a replacement
ServeStaticInvoice is sent for the same server slot.

AI-assisted: planning and writing commit

Co-Authored-By: OpenAI Codex <codex@openai.com>
@ldk-reviews-bot

ldk-reviews-bot commented Jun 26, 2026

Copy link
Copy Markdown

I've assigned @wpaulino as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

Comment on lines 9167 to +9171
self.check_refresh_async_receive_offer_cache(true);
if should_refresh_static_invoices {
self.mark_async_receive_static_invoice_refresh_pending();
}
self.process_pending_async_receive_static_invoice_refresh();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a timer tick where a fee update occurred, this double-sends ServeStaticInvoice messages. check_refresh_async_receive_offer_cache(true) (line 9167) already runs the timer-driven static invoice refresh via check_refresh_static_invoices(..., false), which sends Pending offers (always) and stale Used offers. The forced refresh here then runs check_refresh_static_invoices(..., true), which selects Pending (always) and Used (always) — so any Pending (and stale Used) offer gets two ServeStaticInvoice messages and produces two PersistStaticInvoice events on the server in the same tick.

static_invoice_persisted is idempotent so this isn't a correctness bug, but it's wasteful onion traffic / server churn. Consider skipping the forced refresh on timer ticks (the timer path already covers the relevant offers), or deferring the should_refresh_static_invoices trigger to the next tick.

Comment on lines +8956 to +8958
if chan_needs_persist == NotifyOption::DoPersist {
should_persist = NotifyOption::DoPersist;
should_refresh_static_invoices = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tying the forced refresh to any fee change means a full, unconditional refresh of all Used/Pending static invoices whenever update_channel_fee returns DoPersist. Commitment feerate estimates can fluctuate frequently, so this bypasses the INVOICE_REFRESH_THRESHOLD rate-limiting that normally throttles Used-offer refreshes and can generate a steady stream of ServeStaticInvoice messages even when payment paths are otherwise unchanged. Consider whether a fee delta should be debounced or only trigger when it materially affects the encoded invoice paths.

@ldk-claude-review-bot

Copy link
Copy Markdown
Collaborator

Summary

The PR is mostly sound on lock-safety (the deferred-refresh pattern correctly releases per-peer/persistence locks before refreshing, and handle_error's debug-asserts guarantee per_peer_state is free at the close call site). Findings are about refresh redundancy / rate-limiting:

  • lightning/src/ln/channelmanager.rs:9167-9171 — On a timer tick where a fee update occurred, both the timer refresh (check_refresh_async_receive_offer_cache(true)) and the new forced refresh run, double-sending ServeStaticInvoice for Pending/stale-Used offers (two PersistStaticInvoice events on the server). Idempotent, so churn rather than corruption.
  • lightning/src/ln/channelmanager.rs:8956-8958 — Triggering a forced refresh on any fee change bypasses the INVOICE_REFRESH_THRESHOLD rate-limiting; frequent feerate fluctuations can produce a steady stream of forced invoice refreshes even when paths don't materially change.

Cross-cutting note: when the last usable channel closes, create_static_invoice_for_server will fail and the forced refresh sends nothing, so the server keeps serving the now-stale invoice. This is a pre-existing limitation not introduced here, but the forced-refresh-on-close path doesn't address it.

@ldk-reviews-bot ldk-reviews-bot requested a review from wpaulino June 26, 2026 11:47
@codecov

codecov Bot commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.13483% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.96%. Comparing base (6965bc9) to head (5f3f1f0).
⚠️ Report is 3228 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/ln/channelmanager.rs 92.98% 2 Missing and 2 partials ⚠️
lightning/src/offers/async_receive_offer_cache.rs 76.92% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4753      +/-   ##
==========================================
+ Coverage   84.55%   86.96%   +2.41%     
==========================================
  Files         137      161      +24     
  Lines       77617   111673   +34056     
  Branches    77617   111673   +34056     
==========================================
+ Hits        65627    97119   +31492     
- Misses       9948    12050    +2102     
- Partials     2042     2504     +462     
Flag Coverage Δ
fuzzing-fake-hashes 8.44% <5.61%> (?)
fuzzing-real-hashes 32.44% <51.68%> (?)
tests 86.29% <92.13%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants