Skip to content

contractcourt: insta-dispatch CLOSED_CHANNEL on first conf of coop close#10794

Open
Roasbeef wants to merge 3 commits into
lightningnetwork:masterfrom
Roasbeef:coop-close-insta-dispatch
Open

contractcourt: insta-dispatch CLOSED_CHANNEL on first conf of coop close#10794
Roasbeef wants to merge 3 commits into
lightningnetwork:masterfrom
Roasbeef:coop-close-insta-dispatch

Conversation

@Roasbeef
Copy link
Copy Markdown
Member

@Roasbeef Roasbeef commented May 7, 2026

In this PR, we fix a regression that landed with the multi-confirmation
reorg-aware close dispatch in #10331: SubscribeChannelEvents stopped
emitting CLOSED_CHANNEL on cooperative closes for production builds
until the close had reached the full required confirmation depth
(CloseConfsForCapacity, min 3). On test cycles that didn't mine far
enough past the close tx the event never arrived at all, and on longer
cycles it was delayed by two extra blocks compared to the v0.20.1
surface. This is what alexbosworth was hitting on zero-conf channels in
v0.21.0-beta.rc1 and master (gist:
https://gist.github.com/alexbosworth/b0efefe06264b6f06103437312d9d1b1).

The original intent of #10331 was to wait three confirmations under the
hood for reorg safety while still firing a CLOSED_CHANNEL event over
RPC immediately, so user-visible behavior didn't change. That
insta-dispatch was wired into peer.WaitForChanToClose for the local
CloseChannel response stream, but we never extended it to the
channel-notifier path that drives SubscribeChannelEvents. This PR
plugs that gap.

How it works

The chain watcher's processDetectedSpend now calls a new optional
notifyEarlyCoopClose callback the first time it sees a coop close
spend in the async path (numConfs > 1). The callback is wired through
ChainArbitratorConfig.NotifyEarlyClosedChannel to a new
ChannelNotifier.NotifyEarlyClosedChannelEvent, which dispatches the
ClosedChannelEvent from a caller-supplied ChannelCloseSummary with
IsPending=true, no DB round-trip. The summary is built by
buildCoopCloseSummary, extracted out of dispatchCooperativeClose so
the early dispatch and the post-N-conf dispatch produce equivalent
payloads.

A coopCloseEarlyDispatched atomic.Bool on the chain watcher keeps
the dispatch idempotent across blockbeat replays of the same spend, and
the closeObserver clears it on negativeConfChan so a re-mined close
still re-fires the preliminary event with its own summary. The flag is
read across goroutine boundaries (writer is the closeObserver, reader
is the channel arbitrator's MarkChannelClosed callback), so the
atomic.Bool makes that cross-goroutine read race-free.

Suppression of the duplicate CLOSED_CHANNEL at full conf depth is
handled inside the MarkChannelClosed closure constructed in
chain_arbitrator.go: when the supplied close summary is a coop close,
it looks up the chain watcher via c.activeWatchers[summary.ChanPoint]
and skips NotifyClosedChannel if EarlyCoopCloseDispatched() returns
true. Force-close and breach summaries always fall through to
NotifyClosedChannel, so force closes intentionally remain on the
N-conf dispatch contract for now.

The fast-path (numConfs == 1) is left untouched, so integration tests
with the --dev.force-channel-close-confs=1 build-tag override still
see exactly one synchronous CLOSED_CHANNEL from
handleCommitSpend, no early dispatch.

Resulting event timeline

For a coop close on a 1M sat channel with default
CloseConfsForCapacity == 3:

Event When (block depth from close tx)
CLOSED_CHANNEL 1 conf (early dispatch)
MarkChannelClosed persist 3 confs
FULLY_RESOLVED_CHANNEL 3 confs (after resolver flow)

This matches the v0.20.1 surface for CLOSED_CHANNEL and keeps the
FULLY_RESOLVED_CHANNEL event behind the reorg-safe N-conf gate as
intended.

Reorg behavior

If the close gets reorged out at depth less than N, the existing
negativeConfChan handler resets state and we now also clear the
early-dispatch flag. The next time a coop close lands (the same tx
re-mined or a different one mined in the new chain) we fire the early
event again with the new summary. RBF replacement isn't a concern here
because the early dispatch only fires after the spend has confirmed
once, and a confirmed tx can't be RBF'd while it remains in the chain.
We don't emit a synthetic "un-close" event — clients that acted on
CLOSED_CHANNEL before FULLY_RESOLVED_CHANNEL can re-derive state
from ListChannels if needed. This matches the chain semantics and
avoids inventing a new event type that callers would need to handle.

Scope notes

Force closes are out of scope. Their CLOSED_CHANNEL timing is
unchanged. Once we have confidence in the coop close path we can
revisit.

No proto change. lnrpc.ChannelCloseSummary doesn't gain an
is_pending field; v0.20.1 also fired CLOSED_CHANNEL at coop-close
detection without that distinction and clients use
FULLY_RESOLVED_CHANNEL as the reorg-safe signal.

See each commit message for a detailed description w.r.t the
incremental changes.

Test plan

  • New unit tests in channelnotifier cover the early-dispatch
    notifier API.
  • New unit tests in contractcourt cover the chain watcher
    insta-dispatch (idempotency, force-close carve-out, reorg
    re-fire, fast-path unchanged, nil-callback no-op) and the
    EarlyCoopCloseDispatched gate the arbitrator reads.
  • New itest testZeroConfCoopCloseSubscribeEvents opens a zero-conf
    channel under --dev.force-channel-close-confs=3, subscribes to
    channel events, coop-closes, and asserts that CLOSED_CHANNEL fires
    after one block and FULLY_RESOLVED_CHANNEL after three, with no
    duplicate CLOSED_CHANNEL in between.
  • Existing contractcourt and channelnotifier test suites pass
    with -race.

@github-actions github-actions Bot added the severity-critical Requires expert review - security/consensus critical label May 7, 2026
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 7, 2026

🔴 PR Severity: CRITICAL

Automated classification | 6 files | 376 lines changed

🔴 Critical (5 files)
  • contractcourt/chain_arbitrator.go - on-chain dispute resolution, chain arbitrator coordination
  • contractcourt/chain_watcher.go - chain watcher for breach/close detection
  • contractcourt/chain_watcher_test_harness.go - test harness in contractcourt package (non-test file)
  • contractcourt/channel_arbitrator.go - per-channel arbitrator state machine
  • server.go - core server coordination
🟡 Medium (1 file)
  • channelnotifier/channelnotifier.go - channel event notification (uncategorized Go package)

Analysis

This PR touches the contractcourt package, which handles on-chain dispute resolution and breach remediation — one of the most security-sensitive areas of LND. Changes to chain_watcher.go and chain_arbitrator.go affect how LND detects and responds to channel closures and potential breaches. A bug here could result in loss of funds. The server.go modification (1 line) also falls in the CRITICAL tier. Together, these span multiple distinct critical packages (contractcourt/* and server.go), confirming the CRITICAL classification.

Expert review is required before merging.


To override, add a severity-override-{critical,high,medium,low} label.
<!-- pr-severity-bot -->

@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This PR addresses a regression where cooperative channel closes were delayed until the full confirmation depth, causing issues for clients expecting immediate notification. By introducing an early-dispatch callback in the chain watcher, the system now fires a CLOSED_CHANNEL event upon the first confirmation of a cooperative close, while maintaining reorg safety by deferring the full resolution event.

Highlights

  • Early Dispatch of CLOSED_CHANNEL events: Introduced an early-dispatch mechanism for cooperative closes to fire CLOSED_CHANNEL events at the first confirmation, restoring previous behavior.
  • Idempotency and Suppression: Added logic to ensure early dispatch is idempotent and that the ChannelArbitrator suppresses duplicate notifications when the final confirmation depth is reached.
  • Testing: Added comprehensive unit tests and a new integration test (testZeroConfCoopCloseSubscribeEvents) to verify the event timeline and suppression logic.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces an early notification mechanism for cooperative channel closes, ensuring that RPC subscribers are notified as soon as a close transaction is detected on-chain. This is implemented by adding an early dispatch path in the chain watcher and suppression logic in the channel arbitrator to avoid duplicate events, supported by new unit and integration tests. The review feedback identifies an improvement opportunity in the isCoopCloseSpend helper to use the specific input index from the spend details rather than a hardcoded value, which enhances the robustness of the detection logic for transactions with multiple inputs.

Comment thread contractcourt/chain_watcher.go
Comment thread contractcourt/chain_watcher.go
tx := &wire.MsgTx{
TxIn: []*wire.TxIn{{Sequence: tc.sequence}},
}
require.Equal(t, tc.want, isCoopCloseSpend(tx))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Update the test call to isCoopCloseSpend to include the input index.

Suggested change
require.Equal(t, tc.want, isCoopCloseSpend(tx))
require.Equal(t, tc.want, isCoopCloseSpend(tx, 0))


// A degenerate tx with no inputs should not be classified as a
// coop close.
require.False(t, isCoopCloseSpend(&wire.MsgTx{}))
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Update the test call to isCoopCloseSpend to include the input index.

Suggested change
require.False(t, isCoopCloseSpend(&wire.MsgTx{}))
require.False(t, isCoopCloseSpend(&wire.MsgTx{}, 0))

@Roasbeef Roasbeef force-pushed the coop-close-insta-dispatch branch from bc783e2 to 82c17a5 Compare May 7, 2026 18:56
@Roasbeef Roasbeef added this to the v0.21.0 milestone May 12, 2026
@lightninglabs-deploy
Copy link
Copy Markdown
Collaborator

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

@Roasbeef Roasbeef force-pushed the coop-close-insta-dispatch branch from 82c17a5 to 29b4d35 Compare May 20, 2026 00:26
Today NotifyClosedChannelEvent rebuilds its event by round-tripping
through FetchClosedChannel, which forces the caller to have already
persisted the close summary to the closed-channel bucket. The chain
watcher needs to surface a CLOSED_CHANNEL event to RPC subscribers as
soon as a coop close spend is first detected on chain, well before
the close has reached the required confirmation depth at which the
state machine would normally call MarkChannelClosed.

In this commit, we add NotifyEarlyClosedChannelEvent, which dispatches
a ClosedChannelEvent built from a caller-supplied summary directly
through the subscribe server. The summary is expected to carry
IsPending=true so subscribers can recognize that the close has not
yet been finalized in the database.

Two unit tests assert that the new path delivers the supplied summary
verbatim and produces exactly one event per call.
@Roasbeef Roasbeef force-pushed the coop-close-insta-dispatch branch from 29b4d35 to c1253e4 Compare May 20, 2026 00:31
@saubyk saubyk added this to v0.21 May 20, 2026
@saubyk saubyk moved this to In progress in v0.21 May 20, 2026
@saubyk saubyk moved this from In progress to In review in v0.21 May 20, 2026
@saubyk saubyk moved this from In review to In progress in v0.21 May 20, 2026
@ziggie1984 ziggie1984 self-requested a review May 20, 2026 01:18
Roasbeef added 2 commits May 19, 2026 18:57
PR lightningnetwork#10331 introduced a multi-confirmation reorg-aware dispatch in the
chain watcher. In production builds CloseConfsForCapacity is at least
3, so the chain watcher waits for three confirmations of a close tx
before running dispatchCooperativeClose, MarkChannelClosed, and
NotifyClosedChannel. Subscribers of the SubscribeChannelEvents stream
that used to receive a CLOSED_CHANNEL event after a single
confirmation in v0.20.1 stopped seeing the event entirely on shorter
test cycles and were delayed by two extra blocks on longer ones. This
is the regression alexbosworth reported on zero-conf channels.

The intent behind the original change was to wait three confirmations
under the hood for reorg safety while still dispatching a
CLOSED_CHANNEL event to RPC subscribers immediately, matching the
v0.20.1 surface. That insta-dispatch was wired into
peer.WaitForChanToClose for the local CloseChannel response stream
but was never extended to the channel-notifier path that drives
SubscribeChannelEvents.

In this commit, we wire a new optional notifyEarlyCoopClose callback
into the chain watcher's processDetectedSpend. The first time a coop
close spend is detected on chain, the chain watcher synthesizes a
ChannelCloseSummary with IsPending=true and dispatches a
CLOSED_CHANNEL event over the channel notifier, no DB round-trip
required. The callback is plumbed through ChainArbitratorConfig
.NotifyEarlyClosedChannel to the new
ChannelNotifier.NotifyEarlyClosedChannelEvent. The summary builder
shared with dispatchCooperativeClose is extracted into
buildCoopCloseSummary so the early and post-N-conf paths produce
equivalent payloads.

A coopCloseEarlyDispatched flag on the chain watcher keeps the
dispatch idempotent across blockbeat replays of the same spend, and
the closeObserver clears it on negativeConfChan so a re-mined or
replacement close after a deep reorg re-fires the preliminary event
with its own summary. The early-dispatch call sits before the
fast-path check so numConfs==1 also fires the early event through the
same code path.

Suppressing the duplicate notify at MarkChannelClosed time happens
inline in the chain_arbitrator MarkChannelClosed callback: after
CloseChannel succeeds, NotifyClosedChannel is fired only when the
close type is not CooperativeClose. Force, breach, and abandon paths
intentionally remain on the existing N-confirmation dispatch contract.
In this commit, we add three focused unit tests in contractcourt
plus an itest that exercises the regression end-to-end.

The chain watcher harness gains an opt-in early-dispatch capture that
records every notifyEarlyCoopClose invocation so tests can assert how
many fired and what summaries they carried. On top of that:

TestEarlyDispatchCoopClose verifies the headline behavior. An
async-path coop close fires exactly one early dispatch with
IsPending=true and the post-N-conf flow still produces the regular
CooperativeCloseInfo downstream.

TestEarlyDispatchForceCloseNotInvoked guards the carve-out: force
closes never fire the early dispatch since their CLOSED_CHANNEL
event timing is intentionally unchanged.

TestEarlyDispatchReorgRefiresOnReReplacement nails down the reorg
path. Once a deep reorg removes the close, the early-dispatch flag
is cleared and the next coop close re-fires the early event with its
own summary, so a subscriber observes each distinct close attempt.

testZeroConfCoopCloseSubscribeEvents brings up a zero-conf channel
between Alice and Bob with --dev.force-channel-close-confs=3 so the
chain watcher takes the async multi-confirmation path. Alice
subscribes to channel events, initiates a cooperative close, and the
test asserts that CLOSED_CHANNEL fires after only one confirmation
of the close tx (not after the full three) and that
FULLY_RESOLVED_CHANNEL arrives once the close has reached three
confirmations. A quiet-window assertion at the end verifies that
exactly one CLOSED_CHANNEL event is delivered. If the suppression in
MarkChannelClosed broke and let it re-fire NotifyClosedChannel at N
confs, this assertion would catch the duplicate.
@Roasbeef Roasbeef force-pushed the coop-close-insta-dispatch branch from c1253e4 to 6dc9fe6 Compare May 20, 2026 01:58
[clarifies](https://github.com/lightningnetwork/lnd/issues/10568) the ZMQ
port-mismatch warnings so they no longer suggest that the connection failed.

* [Restored insta-dispatch of `CLOSED_CHANNEL` on the first confirmation of a
Copy link
Copy Markdown
Collaborator

@ziggie1984 ziggie1984 May 21, 2026

Choose a reason for hiding this comment

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

move to 21 ?

@ziggie1984
Copy link
Copy Markdown
Collaborator

New design:

sequenceDiagram
    participant CW as chainWatcher
    participant CA as ChannelArbitrator
    participant CHA as ChainArbitrator
    participant CN as ChannelNotifier
    participant RPC as SubscribeChannelEvents client

    Note over CW: close tx reaches 1 conf
    CW->>CW: build in-memory summary (IsPending=true)
    CW->>CN: NotifyEarlyClosedChannelEvent(summary)
    CN->>RPC: CLOSED_CHANNEL
    CW->>CW: coopCloseEarlyDispatched.Store(true)

    Note over CW: close tx reaches N confs
    CW->>CA: CooperativeClosure event over channel
    CA->>CHA: invoke MarkChannelClosed callback
    CHA->>CHA: channel.CloseChannel persists summary
    CHA->>CW: EarlyCoopCloseDispatched()?
    CW-->>CHA: true
    Note over CHA: skip NotifyClosedChannel

    CA->>CHA: invoke NotifyChannelResolved callback
    CHA->>CN: NotifyFullyResolvedChannel(chanPoint)
    CN->>RPC: FULLY_RESOLVED_CHANNEL
Loading

@ziggie1984 ziggie1984 requested a review from gijswijs May 21, 2026 12:59
Copy link
Copy Markdown
Collaborator

@ziggie1984 ziggie1984 left a comment

Choose a reason for hiding this comment

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

I have one comment regarding a potential race see below, otherwise gtg

I am proposing however a new design in the long run:

This PR fixes the regression, but the notification ownership feels split: the watcher emits CLOSED_CHANNEL early, then the arbitrator later has to ask the watcher whether to suppress its own CLOSED_CHANNEL.

A cleaner model would make the watcher the only owner of CLOSED_CHANNEL emission, at first close-spend detection, and leave the arbitrator to persist the close and emit FULLY_RESOLVED_CHANNEL once resolution is final.

That would remove NotifyEarlyClosedChannel, coopCloseEarlyDispatched, the EarlyCoopCloseDispatched() lookup, and the duplicate-suppression branch. It would also give all close types the same lifecycle shape instead of special-casing coop closes.

sequenceDiagram
    participant CW as chainWatcher
    participant CA as ChannelArbitrator
    participant CHA as ChainArbitrator
    participant CN as ChannelNotifier
    participant RPC as SubscribeChannelEvents client

    Note over CW: close tx reaches 1 conf
    CW->>CW: detect spend, build summary (IsPending=true)
    CW->>CN: NotifyClosedChannelEvent(summary)
    CN->>RPC: CLOSED_CHANNEL

    Note over CW: close tx reaches N confs
    CW->>CA: CooperativeClosure event over channel
    CA->>CHA: invoke MarkChannelClosed callback
    CHA->>CHA: channel.CloseChannel persists summary
    Note over CHA: no notify step anymore

    CA->>CHA: invoke NotifyChannelResolved callback
    CHA->>CN: NotifyFullyResolvedChannel(chanPoint)
    CN->>RPC: FULLY_RESOLVED_CHANNEL
Loading

// reorg-aware dispatch was introduced. The suppression of the
// duplicate notify at MarkChannelClosed time is handled in
// chain_arbitrator.go via a CloseType check.
c.maybeDispatchEarlyCoopClose(spend)
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.

Would be nice to fix: The early-dispatch flag can survive a replacement spend if the replacement is processed before the old confirmation subscription’s NegativeConf.

processDetectedSpend currently calls maybeDispatchEarlyCoopClose before it checks whether there is already a different currentPendingSpend. If the first coop close spend already fired
the early CLOSED_CHANNEL, then coopCloseEarlyDispatched is true. A later replacement coop close can enter this function while currentPendingSpend is still the old tx. In that ordering,
the replacement’s early dispatch is skipped because the flag is still true, then the different-spend branch cancels/re-registers the confirmation notification without clearing the flag.

At final confirmation depth, MarkChannelClosed sees a cooperative close and EarlyCoopCloseDispatched()==true, so it suppresses the final NotifyClosedChannel. That leaves subscribers
with the stale early event for the old txid and no closed-channel event for the replacement txid.

This is a narrow reorg race, not ordinary mempool RBF: it requires the original confirmed spend to be reorged out, a different coop close to confirm, and the watcher to process the
replacement spend before draining the old NegativeConf. But because the watcher selects over both channels independently, that ordering is possible once both notifications are ready.

Suggested fix comment

Consider handling duplicate/replacement spend detection before attempting early coop-close dispatch. For a different pending spender, clear the early-dispatch flag after canceling the
old confirmation notification, then let the replacement spend decide whether to emit its own early close event. That makes the flag represent the currently tracked pending spend rather
than any previous close attempt.

if c.cfg.notifyEarlyCoopClose == nil {
return
}
if !isCoopCloseSpend(spend.SpendingTx) {
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: maybe add a comment to clarify why we only to it for the Coop-close case ?


// The fast path dispatches the regular CooperativeCloseInfo
// synchronously without going through the multi-conf state machine.
closeInfo := harness.waitForCoopClose(5 * time.Second)
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.

can we not use something like wait until to make the test less flaky ?

c.Lock()
w := c.activeWatchers[summary.ChanPoint]
c.Unlock()
if w != nil && w.EarlyCoopCloseDispatched() {
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 think this case is missing a unit test


// FULLY_RESOLVED_CHANNEL must arrive after the close advances
// through the channel arbitrator at full N confs.
resolvedSeen := waitForChannelEventOfType(
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.

missing a check here that no second CLOSED_CHANNEL is sent before the FULLY_RESOLVED_CHANNEL

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 progress

Development

Successfully merging this pull request may close these issues.

4 participants