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.

@ldk-reviews-bot

ldk-reviews-bot commented Jun 26, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @jkczyz 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 thread lightning/src/ln/channelmanager.rs
Comment thread lightning/src/ln/channelmanager.rs Outdated
@ldk-claude-review-bot

ldk-claude-review-bot commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

One new issue found in this re-review pass (prior comments on timer double-send and local fee-change rate-limiting still stand):

  • lightning/src/ln/channelmanager.rs:17060-17063 — The forced-refresh trigger in handle_channel_update is remote-peer-controlled. internal_channel_update returns DoPersist whenever a counterparty's channel_update for a private channel has a higher timestamp (did_change). A counterparty can stream channel_updates with incrementing timestamps to repeatedly mark + process a forced refresh, rebuilding and re-sending ServeStaticInvoice onion messages for every Used/Pending offer and bypassing INVOICE_REFRESH_THRESHOLD — an amplification vector. Suggested gating on actual changes to invoice-path params and/or debouncing.

Cross-cutting note (not re-posted): the establishment channel-open path (channelmanager.rs:16445) does not itself mark the refresh pending; on the recipient, channel-open refresh relies on receiving the counterparty's channel_update via handle_channel_update. This works for the tested topology but means a newly usable channel that does not produce a received channel_update would not trigger an immediate forced refresh (it would fall back to the timer path).

@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 90.78947% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.95%. Comparing base (6965bc9) to head (f3c61ed).
⚠️ Report is 3231 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/ln/channelmanager.rs 90.47% 3 Missing and 1 partial ⚠️
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.95%   +2.39%     
==========================================
  Files         137      161      +24     
  Lines       77617   111684   +34067     
  Branches    77617   111684   +34067     
==========================================
+ Hits        65627    97111   +31484     
- Misses       9948    12068    +2120     
- Partials     2042     2505     +463     
Flag Coverage Δ
fuzzing-fake-hashes 8.44% <3.94%> (?)
fuzzing-real-hashes 32.40% <5.26%> (?)
tests 86.27% <90.78%> (?)

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.

@wpaulino wpaulino requested review from jkczyz and removed request for wpaulino June 26, 2026 19:56
shaavan and others added 4 commits June 27, 2026 17:20
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 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.
Accept peer and channel inputs lazily so recipients without a configured static
invoice server return before collecting an unnecessary channel snapshot.

This keeps the later channel-change behavior focused on deciding when to
refresh, while avoiding reads of transitional channel state when no refresh can
be produced. No new refresh behavior is activated in this commit.

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 usable channels and counterparty forwarding parameters. These paths
can become stale when channels close, complete a splice, or receive changed
routing parameters.

Mark those path-affecting 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 or channel locks,
since rebuilding needs a fresh usable-channel snapshot.

Only applied ChannelUpdates for local channels trigger routing-related
refreshes. This lets a newly opened channel provide its forwarding parameters
before its payment path is rebuilt. Commitment feerate updates remain excluded
because they do not affect blinded payment paths.

AI-assisted: planning and writing commit

Co-Authored-By: OpenAI Codex <codex@openai.com>
Used async receive offers may already be published, so changes to their
payment paths must update server-side static invoices without waiting for the
normal age threshold.

Cover channel-opening and counterparty forwarding-update flows. Verify that
a newly usable channel adds another payment path and that a changed forwarding
fee is encoded in the replacement invoice.

Both flows confirm that the server receives the replacement invoice for the
same offer slot. Ignore concurrent OfferPathsRequest messages so assertions
remain focused on ServeStaticInvoice updates.

AI-assisted: planning and writing commit

Co-Authored-By: OpenAI Codex <codex@openai.com>
@shaavan

shaavan commented Jun 27, 2026

Copy link
Copy Markdown
Member Author

Updated .01 → .02

  • Refresh static invoices only after path-affecting channel updates.
  • Avoid unnecessary channel snapshots and expand regression coverage.

Comment on lines +17060 to +17063
if persist == NotifyOption::DoPersist {
// Static invoices encode the counterparty's forwarding parameters. Refresh
// them when an update changes those parameters for a local channel.
self.mark_async_receive_static_invoice_refresh_pending();

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.

This forced-refresh trigger is remote-peer-controlled, which makes it a stronger amplification concern than the local fee-change trigger. internal_channel_update returns DoPersist whenever the counterparty's channel_update for a private channel actually changed our stored copy (did_change, i.e. a strictly higher timestamp). A counterparty can therefore send a stream of channel_updates with incrementing timestamps, and each one will mark the refresh pending and (via process_... at line 17071) rebuild and re-send ServeStaticInvoice onion messages for every Used/Pending async offer to the static invoice server — bypassing the INVOICE_REFRESH_THRESHOLD rate-limiting entirely.

Consider only marking the refresh pending when the update actually changes a forwarding parameter encoded in the invoice paths (fees / cltv / htlc bounds), and/or debouncing so a chatty peer can't drive unbounded onion traffic.

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