Adjust `batch_size` on `commit_sig` retransmission (#3147)
What changed, and why it matters
This commit fixes a Lightning Network channel bug where, after a disconnect, one side could re-send an outdated 'batch_size' value in commitment signature messages. The peer would then wait forever for extra messages that no longer exist, causing the payment channel to stall. The fix recalculates the correct batch size from the current active commitments before retransmission.
Apply the patch and run the updated NormalSplicesStateSpec regression tests. Node operators using Phoenix-style splices should upgrade to avoid channels getting stuck after reconnections during splice races.
Security signals we found
Protocol-level liveness failure / indefinite wait in Lightning channel state machine
Incorrect retransmission of stale tlvStream batch_size after commitment set changed
Race condition between splice_locked and commit_sig batching
Regression test added for the fixed edge case
Evidence from the diff
In Eclair’s channel synchronization logic (Helpers.scala), when retransmitting commit_sig messages after reconnection, the code previously reused the stored CommitSig tlvStream including its BatchTlv batch_size. If a splice_locked race reduced the number of active commitments while the peer had already moved to a single commitment, the retransmitted batch_size would be wrong and the peer would wait indefinitely for additional commit_sigs. The patch rebuilds the BatchTlv based on commitments.active.size: omitted if size is 1, otherwise set to the current active count. A regression test simulates the exact race (Alice sends HTLC+commit_sig batch for two commitments, Bob confirms splice to one commitment, disconnect, reconnect) and verifies the retransmitted commit_sig has no BatchTlv and the channel resumes normally.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scalaeclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scalaInspect captured patch +57 / −4
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
index a1643e3..1e4ab9d 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Helpers.scala
@@ -499,7 +499,16 @@ object Helpers {
// we just sent a new commit_sig but they didn't receive it
// we resend the same updates and the same sig, and preserve the same ordering
val signedUpdates = commitments.changes.localChanges.signed
- val commitSigs = CommitSigs(commitments.active.flatMap(_.nextRemoteCommit_opt).map(_.sig))
+ val commitSigs = CommitSigs(commitments.active.flatMap(_.nextRemoteCommit_opt).map { nextRemoteCommit =>
+ // If there was a race condition with the remote splice_locked, we may need to adjust the batch size
+ // on reconnection: we may have less commit_sig messages to send than before the disconnection.
+ val commitSig = nextRemoteCommit.sig
+ if (commitments.active.size == 1) {
+ commitSig.copy(tlvStream = TlvStream(commitSig.tlvStream.records.filterNot(_.isInstanceOf[CommitSigTlv.BatchTlv])))
+ } else {
+ commitSig.copy(tlvStream = TlvStream(commitSig.tlvStream.records.filterNot(_.isInstanceOf[CommitSigTlv.BatchTlv]) + CommitSigTlv.BatchTlv(commitments.active.size)))
+ }
+ })
retransmitRevocation_opt match {
case None =>
SyncResult.Success(retransmit = signedUpdates :+ commitSigs)
diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala
index fc18f2c..f1c7658 100644
--- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala
+++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala
@@ -2856,7 +2856,51 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
}
}
- test("disconnection after exchanging tx_signatures and one side sends commit_sig for channel update") { f =>
+ test("disconnect while updating channel before receiving splice_locked", Tag(ChannelStateTestsTags.OptionSimpleTaprootPhoenix)) { f =>
+ import f._
+
+ val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat, pushAmount = 0 msat)))
+ checkWatchConfirmed(f, spliceTx)
+
+ alice2bob.ignoreMsg { case _: ChannelUpdate => true }
+ bob2alice.ignoreMsg { case _: ChannelUpdate => true }
+
+ // The splice confirms on Alice's side.
+ alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, spliceTx)
+ alice2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == spliceTx.txid)
+ alice2bob.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == spliceTx.txid)
+ alice2bob.forward(bob)
+
+ // Alice sends an HTLC to Bob, but Bob doesn't receive the commit_sig messages.
+ addHtlc(25_000_000 msat, alice, bob, alice2bob, bob2alice)
+ alice ! CMD_SIGN()
+ assert(alice2bob.expectMsgType[CommitSigBatch].batchSize == 2)
+
+ // At the same time, the splice confirms on Bob's side, who now expects a single commit_sig message.
+ bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, spliceTx)
+ bob2blockchain.expectMsgTypeHaving[WatchFundingSpent](_.txId == spliceTx.txid)
+ bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == spliceTx.txid)
+ bob2alice.forward(alice)
+
+ disconnect(f)
+ reconnect(f)
+
+ // On reconnection, Alice will only re-send commit_sig for the (locked) splice transaction.
+ assert(alice.commitments.active.size == 1)
+ assert(bob.commitments.active.size == 1)
+ alice2bob.expectMsgType[UpdateAddHtlc]
+ alice2bob.forward(bob)
+ assert(alice2bob.expectMsgType[CommitSig].tlvStream.get[CommitSigTlv.BatchTlv].isEmpty)
+ alice2bob.forward(bob)
+ bob2alice.expectMsgType[RevokeAndAck]
+ bob2alice.forward(alice)
+ bob2alice.expectMsgType[CommitSig]
+ bob2alice.forward(alice)
+ alice2bob.expectMsgType[RevokeAndAck]
+ alice2bob.forward(bob)
+ }
+
+ test("disconnect after exchanging tx_signatures and one side sends commit_sig for channel update") { f =>
import f._
// alice bob
@@ -2924,7 +2968,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
bob2alice.expectNoMessage(100 millis)
}
- test("Disconnection after exchanging tx_signatures and both sides send commit_sig for channel update; revoke_and_ack not received") { f =>
+ test("disconnect after exchanging tx_signatures and both sides send commit_sig for channel update; revoke_and_ack not received") { f =>
import f._
// alice bob
// | ... |
@@ -2989,7 +3033,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
bob2alice.expectNoMessage(100 millis)
}
- test("Disconnection after exchanging tx_signatures and both sides send commit_sig for channel update") { f =>
+ test("disconnect after exchanging tx_signatures and both sides send commit_sig for channel update") { f =>
import f._
// alice bob
// | ... |
Why this scored 57/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.