channelnotifier: add NotifyEarlyClosedChannelEvent
What changed, and why it matters
This commit adds a new internal notification path in LND so that the chain watcher can tell RPC subscribers about a cooperative channel close as soon as it is seen on the Bitcoin blockchain, before the usual database bookkeeping is finished. It is a feature/refactoring change, not a fix for an active security bug. The new function simply forwards a caller-supplied close summary to subscribers without reading from the database.
Review the chain watcher integration that will call this new function to ensure it always sets IsPending=true and supplies a correct, consistent ChannelCloseSummary. Consider whether subscribers of CLOSED_CHANNEL events handle IsPending=true safely, since they may now receive events before the close is finalized in the database. No immediate security patch is indicated by this commit alone.
Security signals we found
New internal notification dispatch path bypasses database lookup
Caller-supplied summary is forwarded directly to subscribers
Expected use case relies on caller setting IsPending=true
No input validation or sanitization visible in the new function
No authentication/authorization changes
Evidence from the diff
The patch introduces NotifyEarlyClosedChannelEvent in channelnotifier/channelnotifier.go. Unlike the existing NotifyClosedChannelEvent, which reconstructs the event by round-tripping through FetchClosedChannel from the closed-channel bucket, the new method builds a ClosedChannelEvent directly from a caller-provided channeldb.ChannelCloseSummary and sends it via c.ntfnServer.SendUpdate. The summary is expected to carry IsPending=true. Two unit tests verify that the supplied summary is delivered verbatim and that a single call produces exactly one event. The change enables the chain watcher to emit CLOSED_CHANNEL events at first on-chain detection of a coop close, prior to the N-confirmation depth at which MarkChannelClosed would normally persist the summary.
Changed components
channelnotifier/channelnotifier.gochannelnotifier/channelnotifier_test.gochain watcher (future consumer, not in diff)Inspect captured patch +108 / −0
diff --git a/channelnotifier/channelnotifier.go b/channelnotifier/channelnotifier.go
index 3f44203..96285aa 100644
--- a/channelnotifier/channelnotifier.go
+++ b/channelnotifier/channelnotifier.go
@@ -192,6 +192,23 @@ func (c *ChannelNotifier) NotifyClosedChannelEvent(chanPoint wire.OutPoint) {
}
}
+// NotifyEarlyClosedChannelEvent dispatches a ClosedChannelEvent built from the
+// supplied close summary, without consulting the channel database. This is
+// used by the chain watcher to insta-dispatch CLOSED_CHANNEL events to RPC
+// subscribers as soon as a coop close is first detected on chain, before the
+// async N-conf path has persisted the close in the database. The summary's
+// IsPending field will typically be true at this point; callers should set it
+// accordingly.
+func (c *ChannelNotifier) NotifyEarlyClosedChannelEvent(
+ summary *channeldb.ChannelCloseSummary) {
+
+ event := ClosedChannelEvent{CloseSummary: summary}
+ if err := c.ntfnServer.SendUpdate(event); err != nil {
+ log.Warnf("Unable to send early closed channel update: %v",
+ err)
+ }
+}
+
// NotifyFullyResolvedChannelEvent notifies the channelEventNotifier goroutine
// that a channel was fully resolved on chain.
func (c *ChannelNotifier) NotifyFullyResolvedChannelEvent(
diff --git a/channelnotifier/channelnotifier_test.go b/channelnotifier/channelnotifier_test.go
index 5dbdb4a..0f815f7 100644
--- a/channelnotifier/channelnotifier_test.go
+++ b/channelnotifier/channelnotifier_test.go
@@ -4,6 +4,8 @@ import (
"testing"
"time"
+ "github.com/btcsuite/btcd/chaincfg/chainhash"
+ "github.com/btcsuite/btcd/wire"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/stretchr/testify/require"
)
@@ -41,3 +43,92 @@ func TestChannelUpdateEvent(t *testing.T) {
t.Fatalf("expected to receive channel update event")
}
}
+
+// TestNotifyEarlyClosedChannelEvent verifies that the early-dispatch path
+// delivers exactly the supplied close summary to subscribers without
+// consulting the channel database. This is the path used by the chain watcher
+// at first conf to insta-dispatch CLOSED_CHANNEL events for cooperative
+// closes, before the close summary is persisted.
+func TestNotifyEarlyClosedChannelEvent(t *testing.T) {
+ t.Parallel()
+
+ // Pass nil for chanDB; the early-dispatch path must not touch it.
+ ntfnServer := New(nil)
+ require.NoError(t, ntfnServer.Start())
+ t.Cleanup(func() {
+ require.NoError(t, ntfnServer.Stop())
+ })
+
+ sub, err := ntfnServer.SubscribeChannelEvents()
+ require.NoError(t, err)
+ t.Cleanup(sub.Cancel)
+
+ // Build a close summary with IsPending=true to mirror what the chain
+ // watcher will hand in at first-conf detection.
+ chanPoint := wire.OutPoint{
+ Hash: chainhash.Hash{0x01, 0x02, 0x03},
+ Index: 4,
+ }
+ summary := &channeldb.ChannelCloseSummary{
+ ChanPoint: chanPoint,
+ CloseType: channeldb.CooperativeClose,
+ IsPending: true,
+ }
+
+ ntfnServer.NotifyEarlyClosedChannelEvent(summary)
+
+ select {
+ case event := <-sub.Updates():
+ closedEvent, ok := event.(ClosedChannelEvent)
+ require.True(
+ t, ok, "expected ClosedChannelEvent, got %T", event,
+ )
+ require.NotNil(t, closedEvent.CloseSummary)
+ require.True(t, closedEvent.CloseSummary.IsPending,
+ "early dispatched summary must carry IsPending=true")
+ require.Equal(t, summary, closedEvent.CloseSummary,
+ "early dispatched summary must reach subscriber "+
+ "verbatim")
+
+ case <-time.After(time.Second):
+ t.Fatal("expected to receive early closed channel event")
+ }
+}
+
+// TestNotifyEarlyClosedChannelEventSingleEvent guards against accidental
+// re-dispatch: a single early-notify call must produce exactly one event,
+// not two (e.g. a fan-out bug between the early and the legacy paths).
+func TestNotifyEarlyClosedChannelEventSingleEvent(t *testing.T) {
+ t.Parallel()
+
+ ntfnServer := New(nil)
+ require.NoError(t, ntfnServer.Start())
+ t.Cleanup(func() {
+ require.NoError(t, ntfnServer.Stop())
+ })
+
+ sub, err := ntfnServer.SubscribeChannelEvents()
+ require.NoError(t, err)
+ t.Cleanup(sub.Cancel)
+
+ summary := &channeldb.ChannelCloseSummary{
+ ChanPoint: wire.OutPoint{Index: 7},
+ CloseType: channeldb.CooperativeClose,
+ IsPending: true,
+ }
+ ntfnServer.NotifyEarlyClosedChannelEvent(summary)
+
+ // Drain the single expected event.
+ select {
+ case <-sub.Updates():
+ case <-time.After(time.Second):
+ t.Fatal("expected to receive early closed channel event")
+ }
+
+ // Any further read should not produce another event.
+ select {
+ case extra := <-sub.Updates():
+ t.Fatalf("unexpected second event: %T", extra)
+ case <-time.After(50 * time.Millisecond):
+ }
+}
Why this scored 18/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.