Merge pull request #11480 from Roasbeef/pub/restored-force-close
What changed, and why it matters
This change fixes a bug where LND could try to force-close a Lightning channel that had been restored from a Static Channel Backup (SCB) even though the local node does not actually have a current commitment transaction to broadcast. In Bitcoin Lightning, force-closing without the right transaction can lead to broadcasting an outdated state, which the remote party could punish as a protocol violation (a 'justice transaction'), potentially causing loss of funds. The patch now rejects force-close requests for restored channels and logs the situation clearly instead of pretending a close transaction was published.
Apply the patch. Operators using SCB/restored channels should ensure they are running a version containing this fix and should avoid manually triggering force-close on restored channels. Monitor logs for the new 'waiting for remote close' warning.
Security signals we found
Prevents broadcasting a force-close commitment for restored/SCB channels that lack a usable local commitment
Avoids potential outdated-state broadcast, which could be penalized by a counterparty via a justice transaction
Adds explicit error wrapping and logging for the restored-channel case
Adds unit tests for both the wallet-level rejection and the peer-level nil-transaction handling
Evidence from the diff
The commit extends the existing local-data-loss guard in LightningChannel.ForceClose to also cover channels marked ChanStatusRestored, not only ChanStatusLocalDataLoss. It refactors peer/brontide.go’s link-failure handler to handle the nil-transaction case returned by the chain arbitrator when it is waiting for a remote close, and adds tests covering the restored-channel force-close rejection and the nil-transaction logging path. The release notes classify this as a bug fix.
Changed components
lnwallet/channel.go - LightningChannel.ForceClosepeer/brontide.go - handleLinkFailure, new logForceCloseResult helpercontractcourt/channel_arbitrator_test.golnwallet/channel_test.gopeer/brontide_test.goInspect captured patch +92 / −18
### contractcourt/channel_arbitrator_test.go
@@ -3593,6 +3593,8 @@ func TestChannelArbitratorStartForceCloseFail(t *testing.T) {
expectedState ArbitratorState
expectedStartup bool
+
+ synchronousRestart bool
}{
{
name: "Commitment is rejected because of low mempool " +
@@ -3621,14 +3623,20 @@ func TestChannelArbitratorStartForceCloseFail(t *testing.T) {
expectedStartup: true,
},
- // We started after the DLP was triggered, and try to force
- // close. This is rejected as we can't force close with local
- // data loss. We should still be able to start up however.
+ // A restored channel restarts after its force-close state was
+ // persisted. The restored shell returns the local-data-loss
+ // sentinel because it has no commitment to broadcast. Startup
+ // must leave the arbitrator waiting for the remote close.
{
- name: "ignore force close local data loss",
- forceCloseErr: lnwallet.ErrForceCloseLocalDataLoss,
- expectedState: StateBroadcastCommit,
- expectedStartup: true,
+ name: "restored channel waits for remote close " +
+ "after restart",
+ forceCloseErr: fmt.Errorf(
+ "restored channel: %w",
+ lnwallet.ErrForceCloseLocalDataLoss,
+ ),
+ expectedState: StateBroadcastCommit,
+ expectedStartup: true,
+ synchronousRestart: true,
},
}
@@ -3662,6 +3670,17 @@ func TestChannelArbitratorStartForceCloseFail(t *testing.T) {
chanArb := chanArbCtx.chanArb
+ if test.synchronousRestart {
+ chanArb.state = test.expectedState
+ err = chanArb.progressStateMachineAfterRestart(
+ 0, nil,
+ )
+ require.NoError(t, err)
+ chanArbCtx.AssertState(test.expectedState)
+
+ return
+ }
+
// Customize the PublishTx function of the arbitrator.
chanArb.cfg.PublishTx = func(*wire.MsgTx,
string) error {
### docs/release-notes/unreleased/11480.md
@@ -0,0 +1,3 @@
+# Bug Fixes
+
+* [Reject force-close requests for restored channels](https://github.com/lightningnetwork/lnd/pull/11480) when no usable commitment transaction is available.
### lnwallet/channel.go
@@ -8429,10 +8429,17 @@ func (lc *LightningChannel) ForceClose(opts ...ForceCloseOpt) (
// If we've detected local data loss for this channel, then we won't
// allow a force close, as it may be the case that we have a dated
// version of the commitment, or this is actually a channel shell.
- if lc.channelState.HasChanStatus(channeldb.ChanStatusLocalDataLoss) {
- return nil, fmt.Errorf("%w: channel_state=%v",
+ hasRestoredShell := lc.channelState.HasChanStatus(
+ channeldb.ChanStatusLocalDataLoss,
+ ) || lc.channelState.HasChanStatus(
+ channeldb.ChanStatusRestored,
+ )
+ if hasRestoredShell {
+ return nil, fmt.Errorf(
+ "%w: channel_state=%v",
ErrForceCloseLocalDataLoss,
- lc.channelState.ChanStatus())
+ lc.channelState.ChanStatus(),
+ )
}
commitTx, err := lc.getSignedCommitTx()
### lnwallet/channel_test.go
@@ -8434,6 +8434,25 @@ func TestForceCloseFailLocalDataLoss(t *testing.T) {
require.ErrorIs(t, err, ErrForceCloseLocalDataLoss)
}
+// TestForceCloseRestoredChannel verifies that a channel restored from an SCB
+// cannot enter the local force-close path without a local commitment.
+func TestForceCloseRestoredChannel(t *testing.T) {
+ t.Parallel()
+
+ aliceChannel, _, err := CreateTestChannels(
+ t, channeldb.SingleFunderBit,
+ )
+ require.NoError(t, err)
+
+ err = aliceChannel.channelState.ApplyChanStatus(
+ channeldb.ChanStatusRestored,
+ )
+ require.NoError(t, err)
+
+ _, err = aliceChannel.ForceClose()
+ require.ErrorIs(t, err, ErrForceCloseLocalDataLoss)
+}
+
// TestForceCloseBorkedState tests that once we force close a channel, it's
// marked as borked in the database. Additionally, all calls to mutate channel
// state should also fail.
### peer/brontide.go
@@ -5182,14 +5182,7 @@ func (p *Brontide) handleLinkFailure(failure linkFailureReport) {
closeTx, err := p.cfg.ChainArb.ForceCloseContract(
failure.chanPoint,
)
- if err != nil {
- p.log.Errorf("unable to force close "+
- "link(%v): %v", failure.shortChanID, err)
- } else {
- p.log.Infof("channel(%v) force "+
- "closed with txid %v",
- failure.shortChanID, closeTx.TxHash())
- }
+ p.logForceCloseResult(failure.shortChanID, closeTx, err)
}
// If this is a permanent failure, we will mark the channel borked.
@@ -5242,6 +5235,28 @@ func (p *Brontide) handleLinkFailure(failure linkFailureReport) {
}
}
+// logForceCloseResult records the result of a force-close request. The
+// arbitrator can return no transaction when it waits for a remote close.
+func (p *Brontide) logForceCloseResult(shortChanID lnwire.ShortChannelID,
+ closeTx *wire.MsgTx, err error) {
+
+ if err != nil {
+ p.log.Errorf("unable to force close link(%v): %v",
+ shortChanID, err)
+
+ return
+ }
+ if closeTx == nil {
+ p.log.Warnf("no local close transaction for link(%v); "+
+ "waiting for remote close", shortChanID)
+
+ return
+ }
+
+ p.log.Infof("channel(%v) force closed with txid %v", shortChanID,
+ closeTx.TxHash())
+}
+
// fetchLinkFromKeyAndCid fetches a link from the switch via the remote's
// public key and the channel id.
func (p *Brontide) fetchLinkFromKeyAndCid(
### peer/brontide_test.go
@@ -2940,6 +2940,17 @@ func TestAlwaysSendChannelUpdate(t *testing.T) {
assertMsgSent(t, harness.mockConn, lnwire.MsgChannelUpdate)
}
+// TestLogForceCloseResultWithoutTx verifies that a deferred local force close
+// can return without a transaction while waiting for the remote close.
+func TestLogForceCloseResultWithoutTx(t *testing.T) {
+ t.Parallel()
+
+ testPeer := NewBrontide(Config{})
+ require.NotPanics(t, func() {
+ testPeer.logForceCloseResult(lnwire.ShortChannelID{}, nil, nil)
+ })
+}
+
// TODO(yy): add test for `addActiveChannel` and `handleNewActiveChannel` once
// we have interfaced `lnwallet.LightningChannel` and
// `*contractcourt.ChainArbitrator`.Why this scored 58/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.