What changed, and why it matters
This update changes how the Eclair Lightning node handles repeated bad 'tx_signatures' messages from a peer during dual-funded channel opens and splices. Previously, every bad signature was processed and logged, which could waste CPU and disk writes and might be abused to keep a node busy. Now, after three bad signatures for the same funding transaction, the node stops processing them and just waits for the on-chain confirmation. The patch also removes unnecessary database writes when receiving repeated 'shutdown' messages. The commit message frames this as a robustness improvement against buggy or malicious peers, not as a critical vulnerability fix.
Treat as a defensive hardening patch. Operators should upgrade to reduce resource-consumption risk from misbehaving peers during dual-funded opens and splices. No immediate emergency response is indicated by the commit content alone. Monitor for follow-up security advisories from ACINQ.
Security signals we found
Rate-limiting of invalid peer messages (tx_signatures) to prevent repeated CPU/disk consumption
Removal of unnecessary DB writes on shutdown receipt, reducing disk-wear and potential DoS surface
No channel force-close on invalid signatures; node remains open and waits for on-chain confirmation
Counter reset on funding confirmation and state transitions to avoid stale state
Evidence from the diff
The commit introduces a mutable counter, invalidTxSigsReceived, in the Channel actor to track invalid interactive-tx (dual-funded/splice) signatures per funding txId. In both ChannelOpenDualFunded (WAIT_FOR_DUAL_FUNDING_CONFIRMED) and Channel (DATA_NORMAL splice handling), after three invalid TxSignatures for the same txId, subsequent messages are silently dropped instead of being validated and logged. Valid signatures still complete the funding flow as before. The counter is reset on funding confirmation, channel ready transitions, and OFFLINE. Additionally, several shutdown-handling paths stop calling storing() because the BOLT spec requires shutdown retransmission on reconnect, making the DB writes redundant.
Changed components
eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scalaeclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scalaDual-funded channel opening state machineSplicing / interactive-tx signing session handlingShutdown message handlingInspect captured patch +74 / −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 0753320..e073753 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
@@ -256,6 +256,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
// we keep track of the splice_locked we sent after channel_reestablish and it's funding tx index to avoid sending it again
// TODO: we can remove that once we stop supporting the legacy splicing protocol
private var spliceLockedSent = Map.empty[TxId, Long]
+ // we keep track of invalid interactive-tx signatures we receive
+ var invalidTxSigsReceived = Map.empty[TxId, Int]
private def trimAnnouncementSigsStashIfNeeded(): Unit = {
if (announcementSigsStash.size >= 10) {
@@ -871,7 +873,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
case Event(ProcessCurrentBlockHeight(c), d: DATA_NORMAL) => handleNewBlock(c, d)
- case Event(c: CurrentFeerates.BitcoinCore, d: DATA_NORMAL) => handleCurrentFeerate(d)
+ case Event(_: CurrentFeerates.BitcoinCore, d: DATA_NORMAL) => handleCurrentFeerate(d)
case Event(_: ChannelReady, d: DATA_NORMAL) =>
// After a reconnection, if the channel hasn't been used yet, our peer cannot be sure we received their channel_ready
@@ -1438,22 +1440,31 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
case Event(msg: TxSignatures, d: DATA_NORMAL) =>
d.commitments.latest.localFundingStatus match {
case dfu@LocalFundingStatus.DualFundedUnconfirmedFundingTx(fundingTx: PartiallySignedSharedTransaction, _, _, _) if fundingTx.txId == msg.txId =>
- // we already sent our tx_signatures
- InteractiveTxSigningSession.addRemoteSigs(channelKeys, dfu.fundingParams, fundingTx, msg) match {
- case Left(cause) =>
- log.warning("received invalid tx_signatures for fundingTxId={}: {}", msg.txId, cause.getMessage)
- // The funding transaction may still confirm (since our peer should be able to generate valid signatures),
- // so we cannot close the channel yet.
- stay() sending Error(d.channelId, InvalidFundingSignature(d.channelId, Some(fundingTx.txId)).getMessage)
- case Right(fundingTx) =>
- val dfu1 = dfu.copy(sharedTx = fundingTx)
- d.commitments.updateLocalFundingStatus(msg.txId, dfu1, d.lastAnnouncedFundingTxId_opt) match {
- case Right((commitments1, _)) =>
- log.info("publishing funding tx for channelId={} fundingTxId={}", d.channelId, fundingTx.signedTx.txid)
- Metrics.recordSplice(dfu.fundingParams, fundingTx.tx)
- stay() using d.copy(commitments = commitments1) storing() calling publishFundingTx(dfu1)
- case Left(_) =>
- stay()
+ // We already sent our tx_signatures: the transaction may be broadcast and may confirm in the next blocks.
+ invalidTxSigsReceived.get(msg.txId) match {
+ case Some(count) if count >= 3 =>
+ // Our peer already sent us 3 invalid tx_signatures for that splice: they're either buggy or malicious,
+ // so we stop processing their additional tx_signatures and simply wait for the transaction to confirm.
+ log.warning("received too many invalid tx_signatures for fundingTxId={}, ignoring this one", msg.txId)
+ stay()
+ case _ =>
+ InteractiveTxSigningSession.addRemoteSigs(channelKeys, dfu.fundingParams, fundingTx, msg) match {
+ case Left(cause) =>
+ log.warning("received invalid tx_signatures for fundingTxId={}: {}", msg.txId, cause.getMessage)
+ // The funding transaction may still confirm (since our peer should be able to generate valid signatures),
+ // so we cannot close the channel yet. We record that they sent an invalid signature though.
+ invalidTxSigsReceived += (msg.txId -> (invalidTxSigsReceived.getOrElse(msg.txId, 0) + 1))
+ stay() sending Error(d.channelId, InvalidFundingSignature(d.channelId, Some(fundingTx.txId)).getMessage)
+ case Right(fundingTx) =>
+ val dfu1 = dfu.copy(sharedTx = fundingTx)
+ d.commitments.updateLocalFundingStatus(msg.txId, dfu1, d.lastAnnouncedFundingTxId_opt) match {
+ case Right((commitments1, _)) =>
+ log.info("publishing funding tx for channelId={} fundingTxId={}", d.channelId, fundingTx.signedTx.txid)
+ Metrics.recordSplice(dfu.fundingParams, fundingTx.tx)
+ stay() using d.copy(commitments = commitments1) storing() calling publishFundingTx(dfu1)
+ case Left(_) =>
+ stay()
+ }
}
}
case _ =>
@@ -1489,7 +1500,10 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
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 Some((commitments1, spliceLocked_opt, annSigs_opt)) =>
+ // We reset the counter for *all* invalid tx_signatures received for simplicity whenever a splice confirms.
+ invalidTxSigsReceived = Map.empty
+ stay() using d.copy(commitments = commitments1) storing() sending spliceLocked_opt.toSeq ++ annSigs_opt.toSeq
case None => stay()
}
@@ -1744,13 +1758,14 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
log.debug("our peer updated their shutdown script (previous={}, current={})", d.remoteShutdown.scriptPubKey, shutdown.scriptPubKey)
}
remoteCloseeNonce_opt = shutdown.closeeNonce_opt
- stay() using d.copy(remoteShutdown = shutdown) storing()
+ // No need to persist the change in the DB: they will re-send shutdown on reconnection.
+ stay() using d.copy(remoteShutdown = shutdown)
case Event(r: RevocationTimeout, d: DATA_SHUTDOWN) => handleRevocationTimeout(r, d)
case Event(ProcessCurrentBlockHeight(c), d: DATA_SHUTDOWN) => handleNewBlock(c, d)
- case Event(c: CurrentFeerates.BitcoinCore, d: DATA_SHUTDOWN) => handleCurrentFeerate(d)
+ case Event(_: CurrentFeerates.BitcoinCore, d: DATA_SHUTDOWN) => handleCurrentFeerate(d)
case Event(c: CMD_CLOSE, d: DATA_SHUTDOWN) =>
val useSimpleClose = Features.canUseFeature(d.commitments.localChannelParams.initFeatures, d.commitments.remoteChannelParams.initFeatures, Features.SimpleClose)
@@ -1781,7 +1796,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
if (remoteShutdown.scriptPubKey != d.remoteShutdown.scriptPubKey) {
// This may lead to a signature mismatch if our peer changed their script without using option_simple_close.
log.warning("received shutdown changing remote script, this may lead to a signature mismatch: previous={}, current={}", d.remoteShutdown.scriptPubKey, remoteShutdown.scriptPubKey)
- stay() using d.copy(remoteShutdown = remoteShutdown) storing()
+ stay() using d.copy(remoteShutdown = remoteShutdown)
} else {
stay()
}
@@ -1890,7 +1905,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
if (shutdown.scriptPubKey != d.remoteScriptPubKey) {
// This may lead to a signature mismatch: peers must use closing_complete to update their closing script.
log.warning("received shutdown changing remote script, this may lead to a signature mismatch: previous={}, current={}", d.remoteScriptPubKey, shutdown.scriptPubKey)
- stay() using d.copy(remoteScriptPubKey = shutdown.scriptPubKey) storing()
+ stay() using d.copy(remoteScriptPubKey = shutdown.scriptPubKey)
} else {
stay()
}
@@ -2867,7 +2882,10 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
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 Some((commitments1, spliceLocked_opt, annSigs_opt)) =>
+ // We reset the counter for *all* invalid tx_signatures received for simplicity whenever a splice confirms.
+ invalidTxSigsReceived = Map.empty
+ stay() using d.copy(commitments = commitments1) storing() sending spliceLocked_opt.toSeq ++ annSigs_opt.toSeq
case None => stay()
}
@@ -3052,6 +3070,8 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
case d: DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT => d.copy(commitments = commitments1)
case d: DATA_CLOSING => d // there is a dedicated handler in CLOSING state
}
+ // We reset the counter for *all* invalid tx_signatures received for simplicity whenever a splice confirms.
+ invalidTxSigsReceived = Map.empty
stay() using d1 storing()
case Left(_) => stay()
}
@@ -3248,7 +3268,7 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall
case _ -> OFFLINE =>
announcementSigsStash = Map.empty
announcementSigsSent = Set.empty
- spliceLockedSent = Map.empty[TxId, Long]
+ spliceLockedSent = Map.empty
remoteNextCommitNonces = Map.empty
localCloseeNonce_opt = None
remoteCloseeNonce_opt = None
diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala
index 5ec2f91..78fdce5 100644
--- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala
+++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenDualFunded.scala
@@ -445,22 +445,34 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
when(WAIT_FOR_DUAL_FUNDING_CONFIRMED)(handleExceptions {
case Event(txSigs: TxSignatures, d: DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED) =>
d.latestFundingTx.sharedTx match {
- case fundingTx: PartiallySignedSharedTransaction => InteractiveTxSigningSession.addRemoteSigs(channelKeys, d.latestFundingTx.fundingParams, fundingTx, txSigs) match {
- case Left(cause) =>
- val unsignedFundingTx = fundingTx.tx.buildUnsignedTx()
- log.warning("received invalid tx_signatures for txid={} (current funding txid={}): {}", txSigs.txId, unsignedFundingTx.txid, cause.getMessage)
- // The funding transaction may still confirm (since our peer should be able to generate valid signatures),
- // so we cannot close the channel yet.
- stay() sending Error(d.channelId, InvalidFundingSignature(d.channelId, Some(unsignedFundingTx.txid)).getMessage)
- case Right(fundingTx) =>
- log.info("publishing funding tx for channelId={} fundingTxId={}", d.channelId, fundingTx.signedTx.txid)
- val dfu1 = d.latestFundingTx.copy(sharedTx = fundingTx)
- val d1 = d.copy(commitments = d.commitments.updateLocalFundingStatus(fundingTx.txId, dfu1, None) match {
- case Left(commitments) => commitments
- case Right((commitments, _)) => commitments
- })
- stay() using d1 storing() calling publishFundingTx(dfu1)
- }
+ case fundingTx: PartiallySignedSharedTransaction =>
+ invalidTxSigsReceived.get(txSigs.txId) match {
+ case Some(count) if count >= 3 =>
+ // Our peer already sent us 3 invalid tx_signatures for that channel: they're either buggy or malicious,
+ // so we stop processing their additional tx_signatures and simply wait for the transaction to confirm.
+ log.warning("received too many invalid tx_signatures for fundingTxId={}, ignoring this one", txSigs.txId)
+ stay()
+ case _ =>
+ InteractiveTxSigningSession.addRemoteSigs(channelKeys, d.latestFundingTx.fundingParams, fundingTx, txSigs) match {
+ case Left(cause) =>
+ val unsignedFundingTx = fundingTx.tx.buildUnsignedTx()
+ log.warning("received invalid tx_signatures for txid={} (current funding txid={}): {}", txSigs.txId, unsignedFundingTx.txid, cause.getMessage)
+ // The funding transaction may still confirm (since our peer should be able to generate valid signatures),
+ // so we cannot close the channel yet. We record that they sent an invalid signature though.
+ if (txSigs.txId == fundingTx.txId) {
+ invalidTxSigsReceived += (txSigs.txId -> (invalidTxSigsReceived.getOrElse(txSigs.txId, 0) + 1))
+ }
+ stay() sending Error(d.channelId, InvalidFundingSignature(d.channelId, Some(unsignedFundingTx.txid)).getMessage)
+ case Right(fundingTx) =>
+ log.info("publishing funding tx for channelId={} fundingTxId={}", d.channelId, fundingTx.signedTx.txid)
+ val dfu1 = d.latestFundingTx.copy(sharedTx = fundingTx)
+ val d1 = d.copy(commitments = d.commitments.updateLocalFundingStatus(fundingTx.txId, dfu1, None) match {
+ case Left(commitments) => commitments
+ case Right((commitments, _)) => commitments
+ })
+ stay() using d1 storing() calling publishFundingTx(dfu1)
+ }
+ }
case _: FullySignedSharedTransaction =>
d.status match {
case DualFundingStatus.RbfWaitingForSigs(signingSession) =>
@@ -729,6 +741,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
val shortIds = createShortIdAliases(d.channelId)
val channelReady = createChannelReady(shortIds, d.commitments)
d.deferred.foreach(self ! _)
+ invalidTxSigsReceived = Map.empty
goto(WAIT_FOR_DUAL_FUNDING_READY) using DATA_WAIT_FOR_DUAL_FUNDING_READY(commitments1, shortIds) storing() sending channelReady
case Left(_) => stay()
}
@@ -744,6 +757,7 @@ trait ChannelOpenDualFunded extends DualFundingHandlers with ErrorHandlers {
case _: DualFundingStatus.RbfRequested | _: DualFundingStatus.RbfInProgress | _: DualFundingStatus.RbfWaitingForSigs => Seq(TxAbort(d.channelId, InvalidRbfTxConfirmed(d.channelId).getMessage), channelReady)
}
d.deferred.foreach(self ! _)
+ invalidTxSigsReceived = Map.empty
goto(WAIT_FOR_DUAL_FUNDING_READY) using DATA_WAIT_FOR_DUAL_FUNDING_READY(commitments1, shortIds) storing() sending toSend
case Left(_) => stay()
}
Why this scored 47/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.