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 0753320adb..e073753c01 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 5ec2f916ac..78fdce5a02 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() }