Skip to content

invoices: clean up invoice lookup handling - #11161

Open
ziggie1984 wants to merge 3 commits into
lightningnetwork:masterfrom
ziggie1984:invoice-lookup-cleanup
Open

invoices: clean up invoice lookup handling#11161
ziggie1984 wants to merge 3 commits into
lightningnetwork:masterfrom
ziggie1984:invoice-lookup-cleanup

Conversation

@ziggie1984

@ziggie1984 ziggie1984 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Change Description

This PR cleans up how final-hop invoice processing reports errors that occur
before an invoice update is attempted. Unexpected invoice lookup errors remain
retryable internal errors and trigger link recovery. HTLC interceptor errors
for confirmed-new HTLCs produce a distinct failure resolution while retaining
the generic FailIncorrectDetails response on the wire.

After a successful lookup, known replays are resolved from their persisted
invoice HTLC state before the interceptor runs. This preserves accepted,
settled, and canceled outcomes and avoids invoking the interceptor again for a
decided HTLC. A second replay check remains inside the atomic invoice update.

The interceptor-error handling remains local to the affected HTLC. Since the
same invoice registry entry point is also used during on-chain resolution, the
incoming contest resolver keeps this processing outcome non-terminal,
preserving its existing unresolved behavior. Unexpected lookup errors also
leave the resolver unresolved through the existing Go error path.

The existing wrong-payment-address integration test now also checks that both
peers continue to report the channel as active after the payment fails.

Review map

 Callers
 ┌────────────────────────────┐       ┌────────────────────────────┐
 │ OFF-CHAIN                  │       │ ON-CHAIN                   │
 │ channelLink.processExitHop │       │ incoming contest resolver  │
 └─────────────┬──────────────┘       └──────────────┬─────────────┘
               └──────────────────┬──────────────────┘
                                  ▼
                 InvoiceRegistry.NotifyExitHopHtlc
                                  │
                                  ▼
                         LookupInvoice(...)
                                  │
             ┌────────────────────┼────────────────────┐
             ▼                    ▼                    ▼
   Expected "not found"    Unexpected DB error      Invoice found
             │                    │                   │
             ▼                    ▼                   ▼
 ResultInvoiceNotFound    Log underlying error   resolveReplayedHtlc
 existing behavior       with invoice and              │
                         circuit context       ┌────────┴────────┐
                                 │             ▼                 ▼
                                 │        Known replay        New HTLC
                                 │             │                 │
                                 │     ┌───────┼───────┐         ▼
                                 │     ▼       ▼       ▼    HtlcInterceptor
                                 │  Canceled Accepted Settled      .Intercept
                                 │     │       │       │              │
                                 │     └───────┼───────┘       ┌──────┴──────┐
                                 │             ▼               ▼             ▼
                                 │    Return recorded      Error          Success
                                 │    outcome; bypass         │              │
                                 │    interceptor and         ▼              ▼
                                 │    invoice update    Log underlying   UpdateInvoice
                                 │                       error with       atomically
                                 │                       invoice and          │
                                 │                       circuit context      ▼
                                 │                              │       Recheck replay
                                 │                              │       before mutation
                                 ▼                              ▼
                    Return nil resolution and      Return HtlcFailResolution
                    non-nil Go error               ResultInvoiceInterceptorError
                    UpdateInvoice not called       Go error: nil
                                                    UpdateInvoice not called
                                                    IsSetFailure: false
                                 │                              │
                    ┌────────────┴────────────┐     ┌───────────┴───────────┐
                    ▼                         ▼     ▼                       ▼
             OFF-CHAIN                 ON-CHAIN  OFF-CHAIN             ON-CHAIN
                    │                         │     │                       │
                    ▼                         ▼     ▼                       ▼
       failf(ErrInternalError)      Return Go error Map to            Return resolver
                    │               without resolving FailIncorrect-  error without
                    ▼                         │       Details          markResolved()
       Link fails and recovers;               ▼         │             or Checkpoint()
       peer receives generic         Resolver remains   ▼                  │
       "internal error"              persisted       Fail this HTLC        ▼
                                                   only; link active  Resolver remains
                                                                      persisted

 Lookup DB error:
 The invoice state is unknown, so no HTLC decision is made. Link recovery can
 replay the HTLC after database access succeeds.

 Known replay:
 The persisted canceled, accepted, or settled outcome wins. The external
 interceptor is not allowed to decide the HTLC differently.

 New HTLC interceptor error:
 FailIncorrectDetails fails only this HTLC and leaves the channel link active.

Steps to Test

go test ./invoices
go test ./contractcourt -run '^TestHtlcIncomingResolverExitProcessingError$'
go test ./htlcswitch -run '^TestInvoiceProcessingFailResolution$'
make lint

The itest package was compiled locally. The full wrong-payment-address
integration test was not executed locally.

Pull Request Checklist

Testing

  • Your PR passes all CI checks.
  • Tests covering the positive and negative (error paths) are included.
  • Bug fixes contain tests triggering the bug to prevent regressions.

Code Style and Documentation

@ziggie1984 ziggie1984 self-assigned this Sep 3, 2026
@ziggie1984 ziggie1984 added this to v0.21 Sep 3, 2026
@ziggie1984 ziggie1984 added this to the v0.21.4 milestone Sep 3, 2026
@ziggie1984 ziggie1984 added backport-v0.20.x-branch This label is used to trigger the creation of a backport PR to the branch `v0.20.x-branch`. backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` labels Sep 3, 2026
@ziggie1984 ziggie1984 moved this to In review in v0.21 Sep 3, 2026
@ziggie1984
ziggie1984 force-pushed the invoice-lookup-cleanup branch from 18a5107 to bee2865 Compare September 3, 2026 11:53
@ziggie1984
ziggie1984 requested a review from gijswijs September 3, 2026 11:58

@bhandras bhandras left a comment

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.

LGTM 🎉

@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

gh pr view | 10 files | 330 lines changed

🔴 Critical (1 file)
  • contractcourt/htlc_incoming_contest_resolver.go - on-chain dispute/breach resolution logic (contractcourt/*)
🟠 High (2 files)
  • invoices/invoiceregistry.go - invoice settlement/resolution logic (invoices/*)
  • invoices/resolution_result.go - invoice resolution result handling (invoices/*)
🟢 Low (7 files)
  • contractcourt/htlc_incoming_contest_resolver_test.go - test-only change
  • docs/release-notes/release-notes-0.20.5.md - release notes
  • docs/release-notes/release-notes-0.21.4.md - release notes
  • htlcswitch/link_test.go - test-only change
  • invoices/invoiceregistry_test.go - test-only change
  • invoices/resolution_result_test.go - test-only change
  • itest/lnd_payment_test.go - integration test-only change

Analysis

The highest-severity file touched is contractcourt/htlc_incoming_contest_resolver.go, which is part of the on-chain contract resolution/dispute-handling code (contractcourt/*), a package that requires expert review per policy. The accompanying invoices/* changes (also touched) are HIGH severity on their own. Non-test, non-doc code changes are small (≈58 lines across 3 files), so no additional severity bump applies from file/line-count thresholds. Given the change touches incoming HTLC contest resolution combined with invoice resolution-result handling, and is already flagged for backport to both v0.20.x and v0.21.x branches, expert review of the contractcourt logic is warranted.


To override, add a severity-override-{critical,high,medium,low} label.

@ziggie1984
ziggie1984 removed the request for review from gijswijs September 3, 2026 14:01
@GeorgeTsagk
GeorgeTsagk self-requested a review September 3, 2026 14:03
@GeorgeTsagk
GeorgeTsagk dismissed their stale review September 3, 2026 14:30

Superseded by inline review

@GeorgeTsagk GeorgeTsagk left a comment

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.

Thanks for the cleanup, the core change is sound and well covered by unit tests. Verified locally: the new unit tests pass, full invoices and contractcourt suites pass, and htlcswitch passes with -tags dev. Requesting changes for the rpcFailureResolution gap, the rest is informational. (Supersedes my earlier dismissed review, same content as inline threads.)

Comment thread invoices/resolution_result.go Outdated

// ResultInvoiceLookupError is returned when an unexpected error occurs
// while looking up an invoice.
ResultInvoiceLookupError

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.

Blocking: these two new outcomes also need handling in rpcFailureResolution (lnrpc/routerrpc/subscribe_events.go). That switch's default returns unknown fail resolution, and the fail path emits a LinkFailEvent carrying the outcome as FailureDetail (link.go sendHTLCError -> NotifyLinkFailEvent), so any open SubscribeHtlcEvents stream terminates with that error once such an event fires (router_server.go returns the error from rpcHtlcEvent). Confirmed with a quick test calling rpcFailureResolution(ResultInvoiceLookupError): unknown fail resolution: invoice lookup failed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in the rewritten first commit (e7111c4f77). I added INVOICE_LOOKUP_ERROR and INVOICE_INTERCEPTOR_ERROR to the router RPC FailureDetail enum, mapped both outcomes in rpcFailureResolution, regenerated the protobuf/Swagger outputs, and added focused mapping tests. make rpc, go test ./lnrpc/routerrpc, and make lint pass.

Comment thread itest/lnd_payment_test.go

// The malformed payment should only fail the HTLC, leaving the channel
// active on both sides.
ht.AssertChannelActive(alice, chanPoint)

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.

Note this doesn't exercise the new code path: wrong payment address fails via ResultAddressMismatch inside UpdateInvoice, which was already an HTLC-level fail before this PR. Covering the new paths in an itest would require injecting DB or interceptor faults, which the itest framework can't easily do, so unit-test coverage is the practical option. The added assertions still have value as hardening of a related path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. This assertion hardens the related ResultAddressMismatch path; it does not exercise either new pre-update processing failure. Direct itest coverage would require DB/interceptor fault injection that the harness does not currently expose, so the invoice registry, link, and contractcourt unit tests are the intended coverage for the new paths. I will keep this assertion as related channel-stability hardening in its separate itest commit.

Comment thread invoices/invoiceregistry.go Outdated
log.Errorf("Unable to look up invoice %v for circuit %v: %v",
invoiceRef, ctx.circuitKey, err)

return ctx.failRes(ResultInvoiceLookupError), nil, nil

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.

Discussion / non-blocking: this changes semantics on transient errors, not just link stability. A transient lookup error now definitively fails the payment (FailIncorrectDetails), whereas before the HTLC replayed after link re-establishment and could still settle once the DB recovered. Arguably the lesser evil given the old path also sent a BOLT-1 Error to the peer, but senders now see a terminal-looking failure for a possibly transient local problem.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed. This is an intentional tradeoff. Previously the error tore down the link and left the HTLC eligible for replay after reconnection; now the link stays active, but FailIncorrectDetails makes the sender stop the current payment attempt even when the local fault was transient. We prefer containing an unexpected processing fault to the affected HTLC rather than disconnecting the peer. The PR description calls out this before/after behavior explicitly, so I do not plan a code change for this thread.

case invoices.ResultInvoiceLookupError,
invoices.ResultInvoiceInterceptorError:

return nil, fmt.Errorf("invoice processing "+

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.

Minor: the returned error drops the underlying DB/interceptor error and only carries the outcome. It's logged at Error level in the registry with invoice ref and circuit key, so it's recoverable, but wrapping it here would make contractcourt logs self-contained.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed that the contractcourt error is less self-contained. The registry logs the original cause together with the invoice reference and circuit key before returning the resolution. Carrying that cause further would require extending HtlcFailResolution (which currently contains only semantic resolution metadata) or introducing another resolution type. Since the cause is already retained in the daemon logs and this does not affect resolver correctness, I would avoid that extra plumbing in this PR and treat self-contained contractcourt errors as possible follow-up hardening.

@GeorgeTsagk GeorgeTsagk left a comment

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.

LGTM

Comment thread invoices/invoiceregistry.go Outdated
log.Errorf("Unable to look up invoice %v for circuit %v: %v",
invoiceRef, ctx.circuitKey, err)

return ctx.failRes(ResultInvoiceLookupError), nil, nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i think this needs to distinguish a new HTLC from a replay before returning a fail resolution.

  • the first pass may already have recorded this circuit as accepted or settled while the channel response is not locked in yet.
  • these lookup/interceptor branches return before resolveReplayedHtlc, so the replay can get a different outcome.
  • in the settled case, the invoice can remain SETTLED while the payer receives FailIncorrectDetails and gets the HTLC back.

can we resolve a known replay first, and keep or retry the HTLC when lookup fails and we cannot read its recorded state?

Comment thread invoices/invoiceregistry_test.go Outdated

// TestInvoiceRegistryPreMutationErrors verifies that errors before the invoice
// update fail only the incoming HTLC and don't mutate the invoice database.
func TestInvoiceRegistryPreMutationErrors(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

can we run this through the existing invoice DB matrix instead of creating a KV-only factory here?

  • TestInvoiceRegistry already owns the KV, sqlite, and postgres setup.
  • these cases should also cover accepted and settled replays, since that is where returning an immediate failure becomes unsafe.

that would keep the regression tied to the actual affected backends.

Keep unexpected invoice lookup errors retryable instead of
turning them into terminal HTLC failures. Resolve recorded HTLCs
before invoking the interceptor, while retaining the atomic replay
check during the invoice update.

Only turn interceptor errors for confirmed-new HTLCs into individual
failures. Update failure reporting and multi-backend regression tests
to cover the final behavior.
Assert that malformed invoice payments fail only the HTLC and leave
the channel active on both peers.
Document that lookup errors remain retryable, interceptor errors fail
new HTLCs individually, and replayed HTLCs retain their recorded
outcomes.
@ziggie1984
ziggie1984 force-pushed the invoice-lookup-cleanup branch from 934988d to ff7f12e Compare September 4, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-v0.20.x-branch This label is used to trigger the creation of a backport PR to the branch `v0.20.x-branch`. backport-v0.21.x-branch This label triggers a backport to branch `v0.21.x-branch ` severity-critical Requires expert review - security/consensus critical

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

4 participants