Skip to content

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

Open
Roasbeef wants to merge 3 commits intolightningnetwork: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 intolightningnetwork: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 flag on the chain watcher keeps the
dispatch idempotent across blockbeat replays of the same spend, and the
closeObserver clears it on negativeConfChan and on RBF replacement
detection so a re-mined or replacement close still re-fires the
preliminary event with its own summary. The flag rides along on
CooperativeCloseInfo.EarlyDispatched so the channel arbitrator's
handleCoopCloseEvent can suppress the duplicate NotifyClosedChannel
that MarkChannelClosed would otherwise fire at N confs. To make the
suppression possible without breaking force closes, NotifyClosedChannel
is moved out of the MarkChannelClosed closure in
chain_arbitrator.go and called explicitly by each close handler in
channel_arbitrator.go. The coop handler gates on EarlyDispatched;
local force, remote force, and breach handlers always fire it (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 (either the same
tx re-mined or a replacement) we fire the early event again with the
new summary. 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, RBF replacement re-fire, fast-path unchanged, nil-callback
    no-op) and the channel arbitrator suppression (coop close gates on
    EarlyDispatched, force-close handlers always fire).
  • 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.

@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 on lines +1402 to +1415
func isCoopCloseSpend(spendingTx *wire.MsgTx) bool {
if len(spendingTx.TxIn) == 0 {
return false
}

log.Infof("Cooperative closure for ChannelPoint(%v): %v",
c.cfg.chanState.FundingOutpoint,
lnutils.SpewLogClosure(broadcastTx))
switch spendingTx.TxIn[0].Sequence {
case wire.MaxTxInSequenceNum:
return true
case mempool.MaxRBFSequence:
return true
}

return false
}
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

The isCoopCloseSpend function currently hardcodes the input index to 0 when checking the sequence number. While cooperative close transactions typically have a single input, it is safer and more accurate to use the actual input index that spends the funding output, which is provided by the SpendDetail. This ensures correctness if the funding spend happens to be part of a larger transaction where the funding input is not at index 0.

Suggested change
func isCoopCloseSpend(spendingTx *wire.MsgTx) bool {
if len(spendingTx.TxIn) == 0 {
return false
}
log.Infof("Cooperative closure for ChannelPoint(%v): %v",
c.cfg.chanState.FundingOutpoint,
lnutils.SpewLogClosure(broadcastTx))
switch spendingTx.TxIn[0].Sequence {
case wire.MaxTxInSequenceNum:
return true
case mempool.MaxRBFSequence:
return true
}
return false
}
func isCoopCloseSpend(spendingTx *wire.MsgTx, inputIdx uint32) bool {
if int(inputIdx) >= len(spendingTx.TxIn) {
return false
}
switch spendingTx.TxIn[inputIdx].Sequence {
case wire.MaxTxInSequenceNum:
return true
case mempool.MaxRBFSequence:
return true
}
return false
}

if c.cfg.notifyEarlyCoopClose == nil {
return
}
if !isCoopCloseSpend(spend.SpendingTx) {
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

Pass the actual input index from the SpendDetail to isCoopCloseSpend to ensure the correct input's sequence number is checked.

Suggested change
if !isCoopCloseSpend(spend.SpendingTx) {
if !isCoopCloseSpend(spend.SpendingTx, spend.InputIndex) {

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 added 3 commits May 7, 2026 13:55
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.
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 bc783e2 to 82c17a5 Compare May 7, 2026 18:56
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

None yet

Development

Successfully merging this pull request may close these issues.

1 participant