Skip to content

multi: improve resolution lifecycle safety - #11054

Open
yyforyongyu wants to merge 24 commits into
lightningnetwork:masterfrom
yyforyongyu:contractcourt-resolution-safety
Open

multi: improve resolution lifecycle safety#11054
yyforyongyu wants to merge 24 commits into
lightningnetwork:masterfrom
yyforyongyu:contractcourt-resolution-safety

Conversation

@yyforyongyu

@yyforyongyu yyforyongyu commented Aug 10, 2026

Copy link
Copy Markdown
Member

Summary

Prevent contract resolutions and owned sweeps from finalizing on shallow spends that can be reorged.

Change Description

  • Let spend notifications wait for a caller-selected depth while preserving one-confirmation defaults.
  • Delay terminal resolver, breach, and owned-sweep results until their spends are mature.
  • Add focused coverage for reorg, restart, cancellation, and missing-input recovery paths.

Notes

This is the preserved PR 3 of 3 for the SI #95 resolution-lifecycle fix. It depends on PR #11147 and the forthcoming PR 2 sweep lifecycle layer. Until those prerequisites merge, this PR remains draft and retains the full stack to preserve its review history. After PR 2 merges, this branch will be rebased and reduced to the final resolver, Nursery, and breach-recovery consumer layer before being marked ready.

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

Copy link
Copy Markdown

🔴 PR Severity: CRITICAL

gh pr view | 21 files | 1265 lines changed

🔴 Critical (10 files)
  • contractcourt/breach_arbitrator.go - breach handling / on-chain dispute resolution logic
  • contractcourt/commit_sweep_resolver.go - commitment output sweep resolution state machine
  • contractcourt/contract_resolver.go - core contract resolver interface/state
  • contractcourt/htlc_outgoing_contest_resolver.go - HTLC contest resolution logic
  • contractcourt/htlc_success_resolver.go - HTLC success resolution logic
  • contractcourt/htlc_timeout_resolver.go - HTLC timeout resolution logic (largest diff, 144 lines)
  • server.go - core server coordination
  • sweep/fee_bumper.go - fee-bumping for fund recovery sweeps
  • sweep/sweeper.go - output sweeping engine
  • sweep/test_utils.go - sweep package test scaffolding (non-_test.go, still counted)
🟠 High (1 file)
  • chainntnfs/spend.go - new spend-notification helper (new file)

Analysis

This PR touches multiple contractcourt/* resolvers (breach, commit-sweep, HTLC success/timeout/outgoing-contest) together with sweep/* (fee bumper, sweeper) and server.go. Each of these packages individually maps to CRITICAL (on-chain dispute resolution / fund recovery / core server coordination), and the change spans several of them at once, touching commitment/HTLC resolution state transitions and sweep fee-bumping logic — exactly the kind of cross-cutting, safety-sensitive change that needs expert review. Non-test/non-generated lines changed are ~684, which alone would trigger a one-level bump, but the base classification is already at the highest tier. The PR also adds substantial regression test coverage (*_test.go files, chainntnfs/spend_test.go) for the affected recovery paths, which is a good sign but does not reduce the review bar given the sensitivity of the production code touched.


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

@yyforyongyu
yyforyongyu force-pushed the contractcourt-resolution-safety branch from 34933ca to 7c564ec Compare August 11, 2026 12:02
@saubyk saubyk added this to v0.21 Aug 13, 2026
@saubyk saubyk moved this to In progress in v0.21 Aug 13, 2026
@saubyk saubyk added this to the v0.21.3 milestone Aug 13, 2026
@yyforyongyu
yyforyongyu force-pushed the contractcourt-resolution-safety branch from 7c564ec to 6e0fff8 Compare August 15, 2026 10:41
Comment thread chainntnfs/neutrinonotify/neutrino.go Outdated
Comment thread chainntnfs/interface.go Outdated
Comment thread chainntnfs/spend_finality_test.go Outdated
Comment thread chainntnfs/spend.go Outdated
Comment thread chainntnfs/spend.go Outdated
Comment thread chainntnfs/spend.go Outdated
Comment thread chainntnfs/spend_test.go Outdated
Comment thread chainntnfs/spend_test.go Outdated
Comment thread lntest/mock/chainnotifier.go Outdated
Comment thread contractcourt/breach_arbitrator.go Outdated
@yyforyongyu
yyforyongyu marked this pull request as draft August 19, 2026 22:55
@yyforyongyu
yyforyongyu force-pushed the contractcourt-resolution-safety branch from 6e0fff8 to ead9dea Compare August 23, 2026 12:42
@yyforyongyu
yyforyongyu marked this pull request as ready for review August 23, 2026 12:42
@yyforyongyu
yyforyongyu force-pushed the contractcourt-resolution-safety branch from ead9dea to da4a243 Compare August 23, 2026 14:16
Comment thread chainntnfs/txnotifier_test.go Outdated
Comment thread chainntnfs/txnotifier_test.go Outdated
Comment thread contractcourt/htlc_timeout_resolver.go Outdated
Comment thread contractcourt/breach_arbitrator.go Outdated
Comment thread contractcourt/breach_arbitrator.go Outdated
Comment thread contractcourt/breach_arbitrator.go Outdated
Comment thread contractcourt/breach_arbitrator.go Outdated
Comment thread sweep/sweeper.go
Comment thread sweep/fee_bumper.go Outdated
@yyforyongyu
yyforyongyu force-pushed the contractcourt-resolution-safety branch from da4a243 to 13a152e Compare August 24, 2026 05:01
@yyforyongyu
yyforyongyu requested a review from gijswijs August 24, 2026 05:37
@yyforyongyu
yyforyongyu force-pushed the contractcourt-resolution-safety branch 5 times, most recently from e07d220 to 0ced69c Compare August 25, 2026 12:58
@yyforyongyu
yyforyongyu requested a review from starius August 25, 2026 13:11
@yyforyongyu
yyforyongyu force-pushed the contractcourt-resolution-safety branch 6 times, most recently from ae62e91 to 6071a7f Compare August 26, 2026 11:53

@gijswijs gijswijs 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.

Did a first pass. Have made some inline comments throughout.

But the commit structure is not sound yet. Also six of nineteen commits don't build or pass their own tests: 7a8e9a2, 35c7d37, c9f1363, fc83d2d, d0d8bc9, 66af0c9.

Three broken windows, each repaired one commit later: #2#3, #12–14→#15, #16–17→#18.

The linter isn't happy. There are a few lines too long.

Comment thread chainntnfs/txnotifier.go
"request %v", details.SpendingHeight, spendRequest)

spendSet.details = details
n.trackSpendByHeight(spendRequest, details)

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 change and the one at L1410 is a fix of the previous commit. There are a few of those in this commit. I'll comment on each of them. Please update the commit structure.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in the rebuilt stack: candidate indexing and reorg reset are isolated in chainntnfs: reset immature spends, while client scheduling remains in chainntnfs: schedule spend maturity.

Comment thread chainntnfs/txnotifier.go

// TODO(wilmer): cancel pending historical rescans if any?
spendSet := n.spendNotifications[spendRequest]

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.

I would separate this spendSet.details != nil guard out as it's own commit.

potential commit message: chainntnfs: keep the first canonical spend candidate immutable

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed as suggested: the immutable first-candidate guard and its ownership regression are isolated in chainntnfs: preserve spend candidate.

Comment thread chainntnfs/txnotifier.go
Comment on lines -1852 to +1861
// We'll note the spending height of the request in order to correctly
// handle dispatching notifications when the spending transactions gets
// reorged out of the chain.
spendHeight := uint32(details.SpendingHeight)
opSet, exists := n.spendsByHeight[spendHeight]
if !exists {
opSet = make(map[SpendRequest]struct{})
n.spendsByHeight[spendHeight] = opSet
// Queue tip candidates even at depth one so callers cannot
// observe them until NotifyHeight completes the block.
if err := n.scheduleSpendNtfn(ntfn, details, true); err != nil {
Log.Errorf("Unable to schedule spend for %v: %v",
spendRequest, err)
}
}
opSet[spendRequest] = struct{}{}

Log.Debugf("Spend request %v spent at tip=%d", spendRequest,
spendHeight)
details.SpendingHeight)

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 whole change should be folded into the previous commit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in the rebuilt stack: the notification scheduling change is folded into chainntnfs: schedule spend maturity; candidate indexing remains separately scoped to reorg reset.

Comment thread chainntnfs/txnotifier.go
Comment on lines +2046 to +2051
spendSet := n.spendNotifications[op]
// Remove each client maturity before clearing the shared
// candidate. Its inclusion height selects the bucket.
for _, ntfn := range spendSet.ntfns {
n.removeSpendMaturity(ntfn)
}

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 is also a fix of the previous commit. Fold it in.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed: maturity-index cleanup during disconnect is now part of the scheduling commit that introduces that index.

Comment thread chainntnfs/txnotifier.go
Comment on lines -1530 to -1542
spendHeight := uint32(details.SpendingHeight)

// We also add to spendsByHeight to notify on chain reorgs.
reorgSafeHeight := spendHeight + n.reorgSafetyLimit
if reorgSafeHeight > n.currentHeight {
txSet, exists := n.spendsByHeight[spendHeight]
if !exists {
txSet = make(map[SpendRequest]struct{})
n.spendsByHeight[spendHeight] = txSet
}
txSet[ntfn.SpendRequest] = struct{}{}
}

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.

iiuc the block was put there deliberately by 2fbee31 ("chainntnfs: populate spendsByHeight during historical dispatch").

The hole: if the historical rescan completes after the last client cancelled, updateSpendDetails sets spendSet.details but the dispatch loop body never runs, so nothing is tracked — and DisconnectTip can then never invalidate those cached details. A later client registering hits the rescanComplete branch and receives a stale spend. Moving the tracking to candidate-discovery time closes that, independently of the depth feature.

So I agree that this should be removed here. I think this should be the entire 3rd commit tho. Everything else should be folded into the previous commit, or separated out into its own commit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed: discovery-time candidate indexing and its disconnect reset now form the focused chainntnfs: reset immature spends commit; scheduling changes were folded into the preceding commit.

t.Fatalf("historical spend did not mature")
}

var cachedClient *chainntnfs.SpendEvent

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 lands on spendConfirmHeight == currentHeight, so only the == half of scheduleSpendNtfn's condition is covered. The < branch (a client registering well after maturity, i.e. the restart path) is untested. Mining one extra block before this registration would cover it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed: the historical backend test mines an extra block after maturity before registering cachedClient, explicitly exercising the < currentHeight cached/restart path.

the reported network statistics such as total network capacity, channel
count and max out degree.

* [Fixed a reorganization safety

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.

nit: Again about commit structure, but can this be a separate commit and the last one of the PR?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed: docs: note spend lifecycle safety is a separate signed commit and is the final commit in the 22-commit stack.

Comment thread contractcourt/chain_arbitrator.go
Comment thread sweep/sweeper.go
// A missing-input response can describe a shallow confirmation that
// the publisher observes before the depth-aware input monitor. Retry
// the set on the next block and let that monitor decide terminality.
if resp.deferTerminal && errors.Is(r.Err, ErrInputMissing) {

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.

The comment says this covers "a shallow confirmation that the publisher observes before the depth-aware input monitor", but handleMissingInputs only emits TxFatal/ErrInputMissing when no spend was found at all — its own comment calls that "an orphan tx that's no longer valid, e.g. the anchor output from the remote commitment after the local commitment has confirmed". For a genuinely orphaned input the outpoint doesn't exist on chain, so the terminal observer will never fire; the input cycles PublishFailed → re-cluster → ErrInputMissing every block forever, no Result ever reaches the resolver, and each attempt leaks a monitorFeeBumpResult goroutine. There's no attempt counter (publishAttempts is reported, never enforced) and no timeout. Master marks it Fatal and the resolver moves on.

Suggested fix: record a firstMissingHeight on SweeperInput and fall through to markInputsFatal once currentHeight - firstMissing >= RequiredConfs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed: missingSince bounds missing-input deferral by RequiredConfs, then falls through to terminal failure; sweep: test bounded orphan recovery covers the terminal boundary.

Comment thread sweep/sweeper.go
// Keep this provisionally spent input quarantined while
// its terminal observer waits for the requested depth.
// A shallow reorg requeues this outpoint independently.
input.state = Published

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.

Unconditional. If the shallow observer's reorg already moved the input to PublishFailed, a racing TxUnknownSpend puts it back to Published, where updateSweeperInputs skips it. At that point nothing can wake it: the publisher record is gone, the terminal observer won't fire because the spend has vanished, and the shallow observer won't re-fire because dispatchSpendReorg already reset dispatched (chainntnfs/txnotifier.go:2246). Stranded until restart.

Fix:

Snapshot before the flatten in line 2132:

// Snapshot inputs a reorg observer has already requeued, so the
// quarantine below cannot undo that requeue with stale spend evidence.
requeued := make(map[wire.OutPoint]struct{})
for _, inp := range r.set.Inputs() {
        if pi, ok := s.inputs[inp.OutPoint()]; ok &&
                pi.state == PublishFailed {

                requeued[inp.OutPoint()] = struct{}{}
        }
}

s.markInputsPublishFailed(r.set, r.result.FeeRate)

And then here:

if _, wasRequeued := requeued[op]; !wasRequeued &&
        input.params.RequiredConfs > DefaultRequiredConfs {

        input.state = Published
        continue
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed: handleBumpEventTxUnknownSpend snapshots PublishFailed inputs before flattening the batch and does not restore a reorg-requeued input to Published.

// Subscribe at the selected depth so only the block path controls
// terminality. The mempool path can reveal a preimage early because
// that secret remains actionable across a reorg.
blockSpent, err := h.Notifier.RegisterSpendNtfn(

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.

The split subscription only happens when h.Mempool == nil. On btcd/bitcoind this registers the block client at spendConfDepth, and relies on the mempool subscription for early preimage discovery — but that fallback is empty exactly when it matters: a preimage spend that is already confirmed at registration time was never in our mempool.

That's the common case, not an edge case. The chain watcher itself gates force-close dispatch on spendConfDepth, so resolvers launch 2-5 blocks after the commitment confirms, and any restart re-registers against an already-mined spend.

So resolveTimeoutTx and resolveRemoteCommitOutput can still delay claimCleanUp -> AddPreimages -> DeliverResolutionMsg by up to SpendConfDepth - 1 blocks on the majority backend, while neutrino now learns it at depth 1. The fix landed on the backend that needed it less.

Worth noting the contradiction is internal to this file: the mempool arm of consumeSpendEvents acts on a preimage at zero confirmations, from a tx that may never confirm, while the block path on the same outpoint is gated at 3-6. A preimage in a block is strictly stronger evidence than one in a mempool.

Could waitForPreimageOrMatureSpend's two-client structure be used on both paths? The mempool subscription then becomes an optimisation rather than the only early channel.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed: every deep policy now enters waitForPreimageOrMatureSpend before backend selection, registering a one-confirmation block observer plus the terminal observer; mempool delivery is only an optimization.

Keep canonical spend candidates separate from client delivery. Clear
queued maturity entries when a spending block disconnects so a
replacement can be discovered without notifying immature clients.
Exercise independent one- and multi-confirmation clients, historical
spend delivery, reorg replacement, and cancellation at the maturity
boundary.
Keep the first same-script spend immutable until it disconnects or
ages out. Cover candidate ownership, cancellation turnover, and depth
validation so queued clients cannot inherit a later immature spend.
Run future spend maturity and boundary checks through the shared
notifier suite so every backend forwards the explicit registration
option for live transactions.
Run historical, cached, reorg, and cancellation maturity cases through
the backend-neutral notifier suite without exceeding the per-commit
review budget.
Derive one validated channel-level confirmation depth and pass it to
the funding watcher and resolvers. This keeps existing close behavior
while giving terminal lifecycle consumers one policy authority.
Attach an immutable confirmation depth to each admitted input and use
it for the input spend registration. Reject invalid or conflicting
updates without stopping the collector.
Apply the channel spend depth to resolver terminal waits and sweep
requests. Keep preimage observations and synchronous preparatory
lookups at one confirmation because those actions remain reorg-safe.
Carry the channel maturity policy into live Nursery registrations and
reconstruct it from the closed-channel summary after restart. Apply it
to promotion and sweep handoffs without changing persisted formats.
@yyforyongyu
yyforyongyu force-pushed the contractcourt-resolution-safety branch from 26abe2f to 65257de Compare August 27, 2026 12:43
Clamp late Crib promotions to the next unprocessed class and reject
replayed incubation once the same output has advanced to Kindergarten.
This keeps persisted claims reachable across restart and shallow reorg.
Verify actionable preimages and preparatory lookups remain available at
one confirmation while terminal resolver waits use the channel policy.
Assert exact notifier options and registration cleanup.
Verify Preschool and Crib promotion depths, restart height clamping,
and final sweep policy reconstruction with exact mock expectations and
controlled goroutine shutdown.
Treat publisher confirmations and missing-input observations as
provisional for deeper inputs. Finalize only the outpoint backed by its
mature notifier event and retain rebroadcast through shallow reorgs.
Verify mature spend evidence finalizes and cleans only its notified
outpoint, while deeper siblings remain publishable. Assert the input
monitor registers the admitted confirmation depth.
Cover inherited and conflicting input policies, provisional publisher
confirmation handling, and admission rejection for unsupported depths
with exact mock cardinality.
Exercise a legacy Nursery final sweep through shallow confirmation,
reorg, node restart, rebroadcast, and terminal maturity using existing
lntest helpers and no new harness API.
Bound missing-input retries while shallow discovery is pending.

Keep unknown-spend evidence authoritative across retry fee errors.

Terminalize only viable siblings.
Observe first-level breached HTLC transitions at one
confirmation so justice can follow the new outpoint promptly. Keep
terminal cleanup behind the maximum channel-close depth so a shallow
reorg cannot discard persisted recovery ownership.
Carry shallow and terminal subscriptions under one recovery owner.

Surface lifecycle metadata through the serialized waiter.

Terminal mutation semantics remain unchanged.
Observe time-sensitive first-level HTLC spends at one confirmation.

Redirect justice through an in-memory candidate.

Retain the original recovery output until terminal depth or reorg.
Exercise provisional justice spends independently from
terminal spends and verify that first-level HTLC transitions register
both early and mature observers. Pin the confirmation policy used by
each lifecycle path.
Document that contract resolvers and the sweeper now wait for the
channel's configured close depth before irreversible spend handling.
@yyforyongyu
yyforyongyu force-pushed the contractcourt-resolution-safety branch from 65257de to 01d71df Compare August 27, 2026 14:48
@yyforyongyu yyforyongyu removed this from the v0.21.3 milestone Aug 27, 2026
@ziggie1984 ziggie1984 moved this from In progress to In review in v0.21 Aug 27, 2026
@ziggie1984 ziggie1984 added this to the v0.21.4 milestone Aug 31, 2026
@litbot-9000

Copy link
Copy Markdown
Collaborator

@yyforyongyu, remember to re-request review from reviewers when ready

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

Labels

severity-critical Requires expert review - security/consensus critical

Projects

Status: In review

Development

Successfully merging this pull request may close these issues.

6 participants