chancloser: remove MarkCoopBroadcasted(nil) calls
What changed, and why it matters
This commit cleans up how LND records that a Lightning channel is being closed cooperatively. Previously, the code would mark a channel as 'cooperatively closed' before an actual closing transaction existed, storing a placeholder 'nil' transaction. That created a confusing in-between state in the database. The change removes those premature marks and now requires a real, non-nil closing transaction before the status is set. It also updates restart logic so the node can still recover an unfinished close from other stored information. The commit is defensive hardening rather than a fix for an active exploit.
Reviewers should verify that all call sites of MarkCoopBroadcasted now supply a non-nil transaction, that restartCoopClose correctly falls through to ShutdownInfo-based recovery when no close tx exists, and that no RPC or wallet path still relies on the old nil-tx 'pending close' signal. Consider adding a regression test for crash recovery in the middle of a cooperative close.
Security signals we found
Eliminates a 'limbo' channel status where ChanStatusCoopBroadcasted is set without a stored close transaction
Adds an explicit non-nil guard in markBroadcasted to prevent future nil close-tx persistence
Reduces risk of incorrect state recovery/restart behavior after a crash during cooperative close
Improves internal consistency between channel status and stored close transaction
Evidence from the diff
The patch removes two MarkCoopBroadcasted(nil) call sites in the legacy and RBF cooperative-close paths and makes channeldb’s markBroadcasted reject nil closeTx values. ChanStatusCoopBroadcasted is now only set once a concrete close transaction exists. ShutdownInfo, persisted earlier by MarkShutdownSent, remains the durable signal that shutdown was initiated. peer/brontide.go’s restartCoopClose is updated to distinguish ‘status set + tx present’ (rebroadcast) from ‘status set + no tx’ (re-drive negotiation via ShutdownInfo/upfront script). Tests are adjusted accordingly.
Changed components
channeldb.OpenChannel.markBroadcasted / MarkCoopBroadcastedlnwallet/chancloser.ChanCloser.BeginNegotiationlnwallet/chancloser.ChannelFlushing.ProcessEventpeer.Brontide.restartCoopCloseRBF cooperative close state machineLegacy cooperative close state machineInspect captured patch +37 / −78
diff --git a/channeldb/channel.go b/channeldb/channel.go
index 127e0ac..676c109 100644
--- a/channeldb/channel.go
+++ b/channeldb/channel.go
@@ -2208,21 +2208,20 @@ func (c *OpenChannel) MarkCoopBroadcasted(closeTx *wire.MsgTx,
func (c *OpenChannel) markBroadcasted(status ChannelStatus, key []byte,
closeTx *wire.MsgTx, closer lntypes.ChannelParty) error {
+ if closeTx == nil {
+ return fmt.Errorf("closeTx must be non-nil")
+ }
+
c.Lock()
defer c.Unlock()
- // If a closing tx is provided, we'll generate a closure to write the
- // transaction in the appropriate bucket under the given key.
- var putClosingTx func(kvdb.RwBucket) error
- if closeTx != nil {
- var b bytes.Buffer
- if err := WriteElement(&b, closeTx); err != nil {
- return err
- }
+ var b bytes.Buffer
+ if err := WriteElement(&b, closeTx); err != nil {
+ return err
+ }
- putClosingTx = func(chanBucket kvdb.RwBucket) error {
- return chanBucket.Put(key, b.Bytes())
- }
+ putClosingTx := func(chanBucket kvdb.RwBucket) error {
+ return chanBucket.Put(key, b.Bytes())
}
// Add the initiator status to the status provided. These statuses are
diff --git a/channeldb/channel_test.go b/channeldb/channel_test.go
index 4750406..7039abe 100644
--- a/channeldb/channel_test.go
+++ b/channeldb/channel_test.go
@@ -1239,21 +1239,16 @@ func TestFetchWaitingCloseChannels(t *testing.T) {
t.Fatalf("unable to mark commitment broadcast: %v", err)
}
- // Now try to marking a coop close with a nil tx. This should
- // succeed, but it shouldn't exit when queried.
- if err = channel.MarkCoopBroadcasted(
+ // A nil close tx must be rejected.
+ err = channel.MarkCoopBroadcasted(
nil, lntypes.Local,
- ); err != nil {
- t.Fatalf("unable to mark nil coop broadcast: %v", err)
- }
- _, err := channel.BroadcastedCooperative()
- if err != ErrNoCloseTx {
- t.Fatalf("expected no closing tx error, got: %v", err)
- }
+ )
+ require.Error(t, err, "nil tx should be rejected")
- // Finally, modify the close tx deterministically and also mark
+ // Modify the close tx deterministically and also mark
// it as coop closed. Later we will test that distinct
- // transactions are returned for both coop and force closes.
+ // transactions are returned for both coop and force
+ // closes.
closeTx.TxIn[0].PreviousOutPoint.Index ^= 1
if err := channel.MarkCoopBroadcasted(
closeTx, lntypes.Local,
diff --git a/channeldb/db_test.go b/channeldb/db_test.go
index 277820b..27428a6 100644
--- a/channeldb/db_test.go
+++ b/channeldb/db_test.go
@@ -621,7 +621,7 @@ func TestFetchChannels(t *testing.T) {
)
err = pendingClosing.MarkCoopBroadcasted(
- nil, lntypes.Local,
+ wire.NewMsgTx(2), lntypes.Local,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
@@ -643,7 +643,7 @@ func TestFetchChannels(t *testing.T) {
openChannelOption(),
)
err = openClosing.MarkCoopBroadcasted(
- nil, lntypes.Local,
+ wire.NewMsgTx(2), lntypes.Local,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
diff --git a/lnwallet/chancloser/chancloser.go b/lnwallet/chancloser/chancloser.go
index 6e5eaf3..2875042 100644
--- a/lnwallet/chancloser/chancloser.go
+++ b/lnwallet/chancloser/chancloser.go
@@ -742,18 +742,6 @@ func (c *ChanCloser) BeginNegotiation() (fn.Option[lnwire.ClosingSigned],
// compute what our max/ideal fee will be.
c.initFeeBaseline()
- // Before continuing, mark the channel as cooperatively closed
- // with a nil txn. Even though we haven't negotiated the final
- // txn, this guarantees that our listchannels rpc will be
- // externally consistent, and reflect that the channel is being
- // shutdown by the time the closing request returns.
- err := c.cfg.Channel.MarkCoopBroadcasted(
- nil, c.closer,
- )
- if err != nil {
- return noClosingSigned, err
- }
-
// At this point, we can now start the fee negotiation state, by
// constructing and sending our initial signature for what we
// think the closing transaction should look like.
@@ -764,7 +752,7 @@ func (c *ChanCloser) BeginNegotiation() (fn.Option[lnwire.ClosingSigned],
// to check if we have a cached remote offer to process.
// If we do, we'll process it here.
res := noClosingSigned
- err = nil
+ var err error
c.cachedClosingSigned.WhenSome(
func(cs lnwire.ClosingSigned) {
res, err = c.ReceiveClosingSigned(cs)
diff --git a/lnwallet/chancloser/interface.go b/lnwallet/chancloser/interface.go
index f55eec8..1dedeb0 100644
--- a/lnwallet/chancloser/interface.go
+++ b/lnwallet/chancloser/interface.go
@@ -41,8 +41,10 @@ type Channel interface { //nolint:interfacebloat
// funding details for the channel.
FundingBlob() fn.Option[tlv.Blob]
- // MarkCoopBroadcasted persistently marks that the channel close
- // transaction has been broadcast.
+ // MarkCoopBroadcasted persistently marks that the channel
+ // close transaction has been broadcast. The tx MUST be
+ // non-nil; callers must not invoke this until a concrete
+ // close tx has been constructed.
MarkCoopBroadcasted(*wire.MsgTx, lntypes.ChannelParty) error
// MarkShutdownSent persists the given ShutdownInfo. The existence of
diff --git a/lnwallet/chancloser/rbf_coop_states.go b/lnwallet/chancloser/rbf_coop_states.go
index 9319c1d..e359e53 100644
--- a/lnwallet/chancloser/rbf_coop_states.go
+++ b/lnwallet/chancloser/rbf_coop_states.go
@@ -274,8 +274,10 @@ type ChanStateObserver interface {
// channel.
DisableChannel() error
- // MarkCoopBroadcasted persistently marks that the channel close
- // transaction has been broadcast.
+ // MarkCoopBroadcasted persistently marks that the channel
+ // close transaction has been broadcast. The tx MUST be
+ // non-nil; callers must not invoke this until a concrete
+ // close tx has been constructed.
MarkCoopBroadcasted(*wire.MsgTx, bool) error
// MarkShutdownSent persists the given ShutdownInfo. The existence of
diff --git a/lnwallet/chancloser/rbf_coop_test.go b/lnwallet/chancloser/rbf_coop_test.go
index e8bbcc3..05d44f7 100644
--- a/lnwallet/chancloser/rbf_coop_test.go
+++ b/lnwallet/chancloser/rbf_coop_test.go
@@ -523,11 +523,6 @@ func (r *rbfCloserTestHarness) expectCloseFinalized(
).Return(nil)
}
-func (r *rbfCloserTestHarness) expectChanPendingClose() {
- var nilTx *wire.MsgTx
- r.chanObserver.On("MarkCoopBroadcasted", nilTx, true).Return(nil)
-}
-
func (r *rbfCloserTestHarness) assertLocalClosePending() {
// We should then remain in the outer close negotiation state.
r.assertStateTransitions(&ClosingNegotiation{})
@@ -1809,12 +1804,6 @@ func TestRbfChannelFlushingTransitions(t *testing.T) {
// balance of the local party.
closeHarness.expectFeeEstimate(absoluteFee, 1)
- // If this is a fresh flush, then we expect the state
- // to be marked on disk.
- if isFreshFlush {
- closeHarness.expectChanPendingClose()
- }
-
// We'll now send in the event which should trigger
// this code path.
closeHarness.chanCloser.SendEvent(
@@ -1858,12 +1847,6 @@ func TestRbfChannelFlushingTransitions(t *testing.T) {
localBalance := flushEvent.ShutdownBalances.LocalBalance
balanceAfterClose := localBalance.ToSatoshis() - absoluteFee //nolint:ll
- // If this is a fresh flush, then we expect the state
- // to be marked on disk.
- if isFreshFlush {
- closeHarness.expectChanPendingClose()
- }
-
// From here, we expect the state transition to go
// back to closing negotiated, for a ClosingComplete
// message to be sent and then for us to terminate at
diff --git a/lnwallet/chancloser/rbf_coop_transitions.go b/lnwallet/chancloser/rbf_coop_transitions.go
index eb8b1d6..a651b37 100644
--- a/lnwallet/chancloser/rbf_coop_transitions.go
+++ b/lnwallet/chancloser/rbf_coop_transitions.go
@@ -585,18 +585,6 @@ func (c *ChannelFlushing) ProcessEvent(event ProtocolEvent, env *Environment,
chancloserLog.Infof("ChannelPoint(%v): channel flushed! "+
"proceeding with co-op close", env.ChanPoint)
- // Now that the channel has been flushed, we'll mark on disk
- // that we're approaching the point of no return where we'll
- // send a new signature to the remote party.
- //
- // TODO(roasbeef): doesn't actually matter if initiator here?
- if msg.FreshFlush {
- err := env.ChanObserver.MarkCoopBroadcasted(nil, true)
- if err != nil {
- return nil, err
- }
- }
-
// If an ideal fee rate was specified, then we'll use that,
// otherwise we'll fall back to the default value given in the
// env.
diff --git a/peer/brontide.go b/peer/brontide.go
index f7a01cd..5f6cbe5 100644
--- a/peer/brontide.go
+++ b/peer/brontide.go
@@ -3627,13 +3627,15 @@ func chooseDeliveryScript(upfront, requested lnwire.DeliveryAddress,
func (p *Brontide) restartCoopClose(lnChan *lnwallet.LightningChannel) (
*lnwire.Shutdown, error) {
- // If this channel has status ChanStatusCoopBroadcasted and does not
- // have a closing transaction, then the cooperative close process was
- // started but never finished. We'll re-create the chanCloser state
- // machine and resend Shutdown. BOLT#2 requires that we retransmit
- // Shutdown exactly, but doing so would mean persisting the RPC
- // provided close script. Instead use the LocalUpfrontShutdownScript
- // or generate a script.
+ // If this channel has status ChanStatusCoopBroadcasted and a closing
+ // transaction was recorded, we just need to rebroadcast (handled by
+ // the chain arbitrator) and exit. If the status is set but no closing
+ // tx exists, fall through and re-drive the close negotiation via
+ // ShutdownInfo or LocalUpfrontShutdownScript.
+ //
+ // BOLT#2 requires that we retransmit Shutdown exactly, but doing so
+ // would mean persisting the RPC-provided close script. Instead use
+ // the LocalUpfrontShutdownScript or generate a script.
c := lnChan.State()
_, err := c.BroadcastedCooperative()
if err != nil && err != channeldb.ErrNoCloseTx {
Why this scored 46/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.