Send `splice_locked` if necessary while reconnecting (#3318)
What changed, and why it matters
This commit fixes a bug in Eclair, a Bitcoin Lightning Network node implementation. During reconnection after a splice (a way to resize a payment channel), if a funding transaction became confirmed at a specific moment, the node would fail to notify its peer. That missing notification caused the peer to expect a different number of signature messages, leading both sides to force-close the channel and settle on the blockchain. The fix makes the node send the required `splice_locked` message even while it is still synchronizing with the peer. It is a bug fix, not an exploit by an attacker, but it could be triggered by normal network timing.
Apply the patch and run the new regression test. Operators should upgrade nodes that handle splicing to avoid unnecessary force-closes during reconnections. No immediate incident response is required because the issue is a protocol bug rather than an externally exploitable vulnerability.
Security signals we found
State-machine bug in channel reconnection handshake
Missing protocol message (`splice_locked`) during `SYNCING` state
Can lead to unwanted channel force-close and on-chain settlement
Refactor of existing handlers to cover additional FSM state
Regression test added for the reconnecting splice-locking scenario
Evidence from the diff
The patch refactors WatchPublishedTriggered and WatchFundingConfirmedTriggered handling for DATA_NORMAL into two helper methods (handleFundingPublishedWhileConnected and handleFundingConfirmedWhileConnected) and reuses them in the SYNCING state. Previously, those events were only handled in NORMAL, so if a funding tx confirmed after channel_reestablish was sent but before the remote channel_reestablish was received, the local node stayed in SYNCING and never sent splice_locked. The peer then counted commit_sig messages against the wrong commitment set and force-closed. The new test splice local/remote locking (while reconnecting) reproduces the exact interleaving and verifies the fix.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scalaLightning channel state machine (NORMAL and SYNCING states)Splice locking / reconnection protocolInspect captured patch +106 / −40
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 1eef0ad..0753320 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
@@ -1482,46 +1482,15 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
}
case Event(w: WatchPublishedTriggered, d: DATA_NORMAL) =>
- val fundingStatus = LocalFundingStatus.ZeroconfPublishedFundingTx(w.tx, d.commitments.localFundingSigs(w.tx.txid), d.commitments.liquidityPurchase(w.tx.txid))
- d.commitments.updateLocalFundingStatus(w.tx.txid, fundingStatus, d.lastAnnouncedFundingTxId_opt) match {
- case Right((commitments1, commitment)) =>
- watchFundingConfirmed(w.tx.txid, Some(nodeParams.channelConf.minDepth), delay_opt = None)
- maybeEmitEventsPostSplice(d.aliases, d.commitments, commitments1, d.lastAnnouncement_opt)
- maybeUpdateMaxHtlcAmount(d.channelUpdate.htlcMaximumMsat, commitments1)
- spliceLockedSent += (commitment.fundingTxId -> commitment.fundingTxIndex)
- trimSpliceLockedSentIfNeeded()
- stay() using d.copy(commitments = commitments1) storing() sending SpliceLocked(d.channelId, w.tx.txid)
- case Left(_) => stay()
+ handleFundingPublishedWhileConnected(w, d) match {
+ case Some((commitments1, spliceLocked)) => stay() using d.copy(commitments = commitments1) storing() sending spliceLocked
+ case None => stay()
}
case Event(w: WatchFundingConfirmedTriggered, d: DATA_NORMAL) =>
- acceptFundingTxConfirmed(w, d) match {
- case Right((commitments1, commitment)) =>
- // We check if this commitment was already locked before receiving the event (which happens when using 0-conf
- // or for the initial funding transaction). If it was previously not locked, we must send splice_locked now.
- val previouslyNotLocked = d.commitments.all.exists(c => c.fundingTxId == commitment.fundingTxId && c.localFundingStatus.isInstanceOf[LocalFundingStatus.NotLocked])
- val spliceLocked_opt = if (previouslyNotLocked) {
- spliceLockedSent += (commitment.fundingTxId -> commitment.fundingTxIndex)
- trimSpliceLockedSentIfNeeded()
- Some(SpliceLocked(d.channelId, w.tx.txid))
- } else None
- // If the channel is public and we've received the remote splice_locked, we send our announcement_signatures
- // in order to generate the channel_announcement.
- val remoteLocked = commitment.fundingTxIndex == 0 || d.commitments.all.exists(c => c.fundingTxId == commitment.fundingTxId && c.remoteFundingStatus == RemoteFundingStatus.Locked)
- val localAnnSigs_opt = if (d.commitments.announceChannel && remoteLocked) commitment.signAnnouncement(nodeParams, commitments1.channelParams, channelKeys.fundingKey(commitment.fundingTxIndex)) else None
- localAnnSigs_opt match {
- case Some(localAnnSigs) =>
- announcementSigsSent += localAnnSigs.shortChannelId
- // If we've already received the remote announcement_signatures, we're now ready to process them.
- announcementSigsStash.get(localAnnSigs.shortChannelId).foreach(self ! _)
- case None => // The channel is private or the commitment isn't locked on the remote side.
- }
- if (commitment.fundingTxIndex > 0) {
- maybeEmitEventsPostSplice(d.aliases, d.commitments, commitments1, d.lastAnnouncement_opt)
- maybeUpdateMaxHtlcAmount(d.channelUpdate.htlcMaximumMsat, commitments1)
- }
- stay() using d.copy(commitments = commitments1) storing() sending spliceLocked_opt.toSeq ++ localAnnSigs_opt.toSeq
- case Left(_) => stay()
+ handleFundingConfirmedWhileConnected(w, d) match {
+ case Some((commitments1, spliceLocked_opt, annSigs_opt)) => stay() using d.copy(commitments = commitments1) storing() sending spliceLocked_opt.toSeq ++ annSigs_opt.toSeq
+ case None => stay()
}
case Event(msg: SpliceLocked, d: DATA_NORMAL) =>
@@ -2890,9 +2859,21 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
context.system.scheduler.scheduleOnce(5 seconds, self, remoteAnnSigs)
stay() sending Warning(d.channelId, "spec violation: you sent announcement_signatures before channel_reestablish")
+ case Event(w: WatchPublishedTriggered, d: DATA_NORMAL) =>
+ handleFundingPublishedWhileConnected(w, d) match {
+ case Some((commitments1, spliceLocked)) => stay() using d.copy(commitments = commitments1) storing() sending spliceLocked
+ case None => stay()
+ }
+
+ case Event(w: WatchFundingConfirmedTriggered, d: DATA_NORMAL) =>
+ handleFundingConfirmedWhileConnected(w, d) match {
+ case Some((commitments1, spliceLocked_opt, annSigs_opt)) => stay() using d.copy(commitments = commitments1) storing() sending spliceLocked_opt.toSeq ++ annSigs_opt.toSeq
+ case None => stay()
+ }
+
case Event(ProcessCurrentBlockHeight(c), d: ChannelDataWithCommitments) => handleNewBlock(c, d)
- case Event(c: CurrentFeerates.BitcoinCore, d: ChannelDataWithCommitments) => stay()
+ case Event(_: CurrentFeerates.BitcoinCore, _: ChannelDataWithCommitments) => stay()
case Event(getTxResponse: GetTxWithMetaResponse, d: DATA_WAIT_FOR_FUNDING_CONFIRMED) if getTxResponse.txid == d.commitments.latest.fundingTxId => handleGetFundingTx(getTxResponse, d.waitingSince, d.fundingTx_opt)
@@ -3032,9 +3013,9 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
case d: DATA_NORMAL => d.lastAnnouncedFundingTxId_opt
case _ => None
}
+ log.info("zero-conf funding txid={} has been published", w.tx.txid)
d.commitments.updateLocalFundingStatus(w.tx.txid, fundingStatus, lastAnnouncedFundingTxId_opt) match {
case Right((commitments1, _)) =>
- log.info("zero-conf funding txid={} has been published", w.tx.txid)
// This is a zero-conf channel, the min-depth isn't critical: we use the default.
watchFundingConfirmed(w.tx.txid, Some(nodeParams.channelConf.minDepth), delay_opt = None)
val d1 = d match {
@@ -3058,7 +3039,6 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
case Event(w: WatchFundingConfirmedTriggered, d: ChannelDataWithCommitments) =>
acceptFundingTxConfirmed(w, d) match {
case Right((commitments1, _)) =>
- log.info("funding txid={} has been confirmed", w.tx.txid)
val d1 = d match {
// NB: we discard remote's stashed channel_ready, they will send it back at reconnection
case d: DATA_WAIT_FOR_FUNDING_CONFIRMED => DATA_WAIT_FOR_CHANNEL_READY(commitments1, aliases = createShortIdAliases(d.channelId))
@@ -3546,6 +3526,52 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
(spliceStatus1, sendQueue)
}
+ private def handleFundingPublishedWhileConnected(w: WatchPublishedTriggered, d: DATA_NORMAL): Option[(Commitments, SpliceLocked)] = {
+ log.info("zero-conf funding txid={} has been published", w.tx.txid)
+ val fundingStatus = LocalFundingStatus.ZeroconfPublishedFundingTx(w.tx, d.commitments.localFundingSigs(w.tx.txid), d.commitments.liquidityPurchase(w.tx.txid))
+ d.commitments.updateLocalFundingStatus(w.tx.txid, fundingStatus, d.lastAnnouncedFundingTxId_opt) match {
+ case Left(_) => None
+ case Right((commitments1, commitment)) =>
+ watchFundingConfirmed(w.tx.txid, Some(nodeParams.channelConf.minDepth), delay_opt = None)
+ maybeEmitEventsPostSplice(d.aliases, d.commitments, commitments1, d.lastAnnouncement_opt)
+ maybeUpdateMaxHtlcAmount(d.channelUpdate.htlcMaximumMsat, commitments1)
+ spliceLockedSent += (commitment.fundingTxId -> commitment.fundingTxIndex)
+ trimSpliceLockedSentIfNeeded()
+ Some((commitments1, SpliceLocked(d.channelId, w.tx.txid)))
+ }
+ }
+
+ private def handleFundingConfirmedWhileConnected(w: WatchFundingConfirmedTriggered, d: DATA_NORMAL): Option[(Commitments, Option[SpliceLocked], Option[AnnouncementSignatures])] = {
+ acceptFundingTxConfirmed(w, d) match {
+ case Left(_) => None
+ case Right((commitments1, commitment)) =>
+ // We check if this commitment was already locked before receiving the event (which happens when using 0-conf
+ // or for the initial funding transaction). If it was previously not locked, we must send splice_locked now.
+ val previouslyNotLocked = d.commitments.all.exists(c => c.fundingTxId == commitment.fundingTxId && c.localFundingStatus.isInstanceOf[LocalFundingStatus.NotLocked])
+ val spliceLocked_opt = if (previouslyNotLocked) {
+ spliceLockedSent += (commitment.fundingTxId -> commitment.fundingTxIndex)
+ trimSpliceLockedSentIfNeeded()
+ Some(SpliceLocked(d.channelId, w.tx.txid))
+ } else None
+ // If the channel is public and we've received the remote splice_locked, we send our announcement_signatures
+ // in order to generate the channel_announcement.
+ val remoteLocked = commitment.fundingTxIndex == 0 || d.commitments.all.exists(c => c.fundingTxId == commitment.fundingTxId && c.remoteFundingStatus == RemoteFundingStatus.Locked)
+ val localAnnSigs_opt = if (d.commitments.announceChannel && remoteLocked) commitment.signAnnouncement(nodeParams, commitments1.channelParams, channelKeys.fundingKey(commitment.fundingTxIndex)) else None
+ localAnnSigs_opt match {
+ case Some(localAnnSigs) =>
+ announcementSigsSent += localAnnSigs.shortChannelId
+ // If we've already received the remote announcement_signatures, we're now ready to process them.
+ announcementSigsStash.get(localAnnSigs.shortChannelId).foreach(self ! _)
+ case None => // The channel is private or the commitment isn't locked on the remote side.
+ }
+ if (commitment.fundingTxIndex > 0) {
+ maybeEmitEventsPostSplice(d.aliases, d.commitments, commitments1, d.lastAnnouncement_opt)
+ maybeUpdateMaxHtlcAmount(d.channelUpdate.htlcMaximumMsat, commitments1)
+ }
+ Some(commitments1, spliceLocked_opt, localAnnSigs_opt)
+ }
+ }
+
private def resendSpliceLockedIfNeeded(commitments: Commitments): Option[SpliceLocked] = {
commitments.lastLocalLocked_opt match {
case None => None
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 182c627..37957ff 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
@@ -1382,6 +1382,46 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik
assert(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.inactive.map(_.fundingTxIndex) == Seq.empty)
}
+ test("splice local/remote locking (while reconnecting)", Tag(ChannelStateTestsTags.NoMaxHtlcValueInFlight)) { f =>
+ import f._
+
+ val fundingInput = alice.commitments.latest.fundingInput
+ val spliceTx = initiateSplice(f, spliceIn_opt = Some(SpliceIn(500_000 sat)))
+ checkWatchConfirmed(f, spliceTx)
+ val commitAlice1 = alice.signCommitTx()
+ val commitBob1 = bob.signCommitTx()
+
+ // Bob sees the splice confirm, but Alice doesn't.
+ bob ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, spliceTx)
+ bob2blockchain.expectWatchFundingSpent(spliceTx.txid, Some(Set(commitAlice1.txid, commitBob1.txid)))
+ bob2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
+ bob2alice.expectMsgTypeHaving[SpliceLocked](_.fundingTxId == spliceTx.txid)
+ bob2alice.forward(alice)
+
+ // They disconnect.
+ disconnect(f)
+ val (channelReestablishAlice, channelReestablishBob) = reconnect(f, sendReestablish = false)
+ assert(channelReestablishAlice.myCurrentFundingLocked_opt.contains(fundingInput.txid))
+ assert(channelReestablishBob.myCurrentFundingLocked_opt.contains(spliceTx.txid))
+
+ // While reconnecting, Alice sees the splice confirm after sending her channel_reestablish, but before receiving
+ // Bob's channel_reestablish: she must send splice_locked since her channel_reestablish used the previous funding.
+ alice ! WatchFundingConfirmedTriggered(BlockHeight(400000), 42, spliceTx)
+ alice2blockchain.expectWatchFundingSpent(spliceTx.txid, Some(Set(commitAlice1.txid, commitBob1.txid)))
+ alice2blockchain.expectMsg(UnwatchFundingSpent(fundingInput.txid, fundingInput.index.toInt))
+ val spliceLockedAlice = alice2bob.expectMsgType[SpliceLocked]
+ assert(spliceLockedAlice.fundingTxId == spliceTx.txid)
+ alice2bob.forward(bob, channelReestablishAlice)
+ alice2bob.forward(bob, spliceLockedAlice)
+ bob2alice.forward(alice, channelReestablishBob)
+
+ // Once reconnected, Alice and Bob can send HTLCs without signing commitments for the previous funding tx.
+ val (preimage, add) = addHtlc(40_000_000 msat, alice, bob, alice2bob, bob2alice)
+ crossSign(alice, bob, alice2bob, bob2alice)
+ fulfillHtlc(add.id, preimage, bob, alice, bob2alice, alice2bob)
+ crossSign(bob, alice, bob2alice, alice2bob)
+ }
+
test("emit post-splice events", Tag(ChannelStateTestsTags.NoMaxHtlcValueInFlight)) { f =>
import f._
Why this scored 55/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.