Create fresh shutdown nonce on reconnection (#3184)
What changed, and why it matters
This patch fixes a bug in the Eclair Lightning node that occurs when a channel is closing using the newer Taproot channel type and the connection drops after a shutdown message was sent. On reconnection, the node must resend shutdown, but it was reusing an old cryptographic nonce. For Taproot, each shutdown needs a fresh nonce, and the node must keep the matching private nonce to later sign the closing transaction. Without the fix, the node could be unable to produce its closing signature after reconnecting, potentially stalling or complicating mutual channel closure.
Apply the patch. Ensure nodes running Taproot channels are upgraded, as the bug can prevent successful closing signature creation after a reconnection during shutdown. Monitor for any stuck closing negotiations and consider alerting peers to upgrade if mutual close stalls.
Security signals we found
Taproot channel close nonce reuse bug fixed
Reconnection shutdown retransmission now regenerates shutdown instead of replaying stale message
Private nonce storage dependency for closing_sig creation
Test coverage added for fresh nonce on reconnection and successful closing transaction publication
Evidence from the diff
In Channel.scala, on reconnection in both NORMAL and SHUTDOWN states, the code now regenerates the shutdown message via createShutdown() instead of retransmitting the stored d.localShutdown. This ensures a fresh closeeNonce is generated for Taproot channels and the local private nonce is stored, which is required to create closing_sig later. The state data is updated with the new shutdown message. Tests were updated to verify that Taproot shutdown retransmissions include a nonce and that the channel can proceed to publish a closing transaction after reconnection.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scalaeclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scalaTaproot channel mutual close flowChannel reconnection / re-establishment logicInspect captured patch +29 / −13
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
index da9611c..bef8a0c 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala
@@ -30,7 +30,6 @@ import fr.acinq.eclair.blockchain._
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
-import fr.acinq.eclair.channel.ChannelTypes.SimpleTaprootChannelsPhoenix
import fr.acinq.eclair.channel.Commitments.PostRevocationAction
import fr.acinq.eclair.channel.Helpers.Closing.MutualClose
import fr.acinq.eclair.channel.Helpers.Syncing.SyncResult
@@ -2720,10 +2719,13 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
}
// BOLT 2: A node if it has sent a previous shutdown MUST retransmit shutdown.
- d.localShutdown.foreach {
- localShutdown =>
- log.debug("re-sending local_shutdown")
- sendQueue = sendQueue :+ localShutdown
+ val shutdown_opt = d.localShutdown match {
+ case None => None
+ case Some(shutdown) =>
+ log.debug("re-sending local shutdown")
+ val shutdown1 = createShutdown(commitments1, shutdown.scriptPubKey)
+ sendQueue = sendQueue :+ shutdown1
+ Some(shutdown1)
}
if (d.commitments.announceChannel) {
@@ -2754,7 +2756,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
peer ! ChannelReadyForPayments(self, remoteNodeId, d.channelId, fundingTxIndex)
}
- goto(NORMAL) using d.copy(commitments = commitments1, spliceStatus = spliceStatus1) sending sendQueue
+ goto(NORMAL) using d.copy(commitments = commitments1, spliceStatus = spliceStatus1, localShutdown = shutdown_opt) sending sendQueue
}
}
@@ -2780,9 +2782,11 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
handleSyncFailure(channelReestablish, syncFailure, d)
case syncSuccess: SyncResult.Success =>
val commitments1 = d.commitments.discardUnsignedUpdates()
- val sendQueue = Queue.empty[LightningMessage] ++ syncSuccess.retransmit :+ d.localShutdown
+ // We retransmit our shutdown: we may have updated our script and they may not have received it.
+ val shutdown = createShutdown(commitments1, d.localShutdown.scriptPubKey)
+ val sendQueue = Queue.empty[LightningMessage] ++ syncSuccess.retransmit :+ shutdown
// BOLT 2: A node if it has sent a previous shutdown MUST retransmit shutdown.
- goto(SHUTDOWN) using d.copy(commitments = commitments1) sending sendQueue
+ goto(SHUTDOWN) using d.copy(commitments = commitments1, localShutdown = shutdown) sending sendQueue
}
}
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala
index 4141a07..ad70162 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala
@@ -36,7 +36,7 @@ import fr.acinq.eclair.reputation.Reputation
import fr.acinq.eclair.testutils.PimpTestProbe.convert
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.transactions.Transactions._
-import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelReestablish, ChannelUpdate, ClosingSigned, CommitSig, Error, FailureMessageCodecs, FailureReason, Init, PermanentChannelFailure, RevokeAndAck, Shutdown, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc}
+import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelReestablish, ChannelUpdate, ClosingComplete, ClosingSig, ClosingSigned, CommitSig, Error, FailureMessageCodecs, FailureReason, Init, PermanentChannelFailure, RevokeAndAck, Shutdown, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc}
import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey}
import org.scalatest.funsuite.FixtureAnyFunSuiteLike
import org.scalatest.{Outcome, Tag}
@@ -1023,10 +1023,14 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit
alice2bob.forward(bob, channelReestablishAlice)
bob2alice.forward(alice, channelReestablishBob)
// They retransmit shutdown.
- alice2bob.expectMsgType[Shutdown]
- alice2bob.forward(bob)
- bob2alice.expectMsgType[Shutdown]
- bob2alice.forward(alice)
+ val shutdownAlice = alice2bob.expectMsgType[Shutdown]
+ alice2bob.forward(bob, shutdownAlice)
+ val shutdownBob = bob2alice.expectMsgType[Shutdown]
+ bob2alice.forward(alice, shutdownBob)
+ Seq(shutdownAlice, shutdownBob).foreach(shutdown => commitmentFormat match {
+ case _: SegwitV0CommitmentFormat => assert(shutdown.closeeNonce_opt.isEmpty)
+ case _: TaprootCommitmentFormat => assert(shutdown.closeeNonce_opt.nonEmpty)
+ })
// They resume HTLC settlement.
fulfillHtlc(0, r1, bob, alice, bob2alice, alice2bob)
crossSign(bob, alice, bob2alice, alice2bob)
@@ -1035,6 +1039,14 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit
crossSign(bob, alice, bob2alice, alice2bob)
awaitCond(alice.stateName == NEGOTIATING_SIMPLE)
awaitCond(bob.stateName == NEGOTIATING_SIMPLE)
+ // They can now sign the closing transaction.
+ val closingCompleteAlice = alice2bob.expectMsgType[ClosingComplete]
+ alice2bob.forward(bob, closingCompleteAlice)
+ bob2alice.expectMsgType[ClosingComplete] // ignored
+ val closingTx = bob2blockchain.expectMsgType[PublishFinalTx]
+ val closingSigBob = bob2alice.expectMsgType[ClosingSig]
+ bob2alice.forward(alice, closingSigBob)
+ assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == closingTx.tx.txid)
}
test("recv INPUT_RESTORED", Tag(ChannelStateTestsTags.SimpleClose), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f =>
Why this scored 59/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.