From ecb3323f3bf4816e54e079c4a47c6a774c3861f4 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 21 May 2025 16:55:12 +0200 Subject: [PATCH 1/2] Cleaner handling of HTLC settlement during force-close When an HTLC settles downstream while the upstream channel is closing, this allows us to either publish HTLC-success transactions for which we were missing the preimage (if the downstream HTLC was fulfilled) or stop watching the HTLC output (if the downstream HTLC was failed). We previously re-computed every closing transaction in that case, which was confusing and useless. We now explicitly handle those two cases and only republish the HTLC-success transactions that become available, if any. We also change the default feerate used for `claim-htlc-txs`: we used a high feerate in the channel actor, which meant we would skip small HTLCs that weren't economical to spend at that high feerate. But the feerate is actually set inside the tx-publisher actor based on the HTLC expiry, which may happen many blocks after the beginning of the force-close, in which case the feerate may have changed a lot. We now use the minimum feerate in the channel actor to ensure we don't skip HTLCs and let the tx-publisher actor handle RBF. --- .../fr/acinq/eclair/channel/Helpers.scala | 116 ++++++++-- .../fr/acinq/eclair/channel/fsm/Channel.scala | 39 +++- .../states/e/NormalQuiescentStateSpec.scala | 25 +-- .../channel/states/e/NormalStateSpec.scala | 4 +- .../channel/states/e/OfflineStateSpec.scala | 55 +++-- .../channel/states/f/ShutdownStateSpec.scala | 4 +- .../channel/states/h/ClosingStateSpec.scala | 206 +++++++++++++----- 7 files changed, 318 insertions(+), 131 deletions(-) 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 40969eaedd..c7dad1c874 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 @@ -25,6 +25,7 @@ import fr.acinq.eclair.blockchain.OnChainPubkeyCache import fr.acinq.eclair.blockchain.fee._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fsm.Channel.REFRESH_CHANNEL_UPDATE_INTERVAL +import fr.acinq.eclair.channel.publish.{ReplaceableClaimHtlcSuccess, ReplaceableHtlcSuccess, TxPublisher} import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.crypto.keymanager.{ChannelKeys, LocalCommitmentKeys, RemoteCommitmentKeys} import fr.acinq.eclair.db.ChannelsDb @@ -910,9 +911,11 @@ object Helpers { * Claim the outputs of a local commit tx corresponding to HTLCs. If we don't have the preimage for a received * * HTLC, we still include an entry in the map because we may receive that preimage later. */ - def claimHtlcOutputs(commitKeys: LocalCommitmentKeys, commitment: FullCommitment)(implicit log: LoggingAdapter): Map[OutPoint, Option[HtlcTx]] = { - // We collect all the preimages we wanted to reveal to our peer. - val hash2Preimage: Map[ByteVector32, ByteVector32] = commitment.changes.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage }.map(r => Crypto.sha256(r) -> r).toMap + private def claimHtlcOutputs(commitKeys: LocalCommitmentKeys, commitment: FullCommitment)(implicit log: LoggingAdapter): Map[OutPoint, Option[HtlcTx]] = { + // We collect all the preimages available. + val preimages = (commitment.changes.localChanges.all ++ commitment.changes.remoteChanges.all).collect { + case u: UpdateFulfillHtlc => Crypto.sha256(u.paymentPreimage) -> u.paymentPreimage + }.toMap // We collect incoming HTLCs that we started failing but didn't cross-sign. val failedIncomingHtlcs: Set[Long] = commitment.changes.localChanges.all.collect { case u: UpdateFailHtlc => u.id @@ -923,9 +926,9 @@ object Helpers { val nonRelayedIncomingHtlcs: Set[Long] = commitment.changes.remoteChanges.all.collect { case add: UpdateAddHtlc => add.id }.toSet commitment.localCommit.htlcTxsAndRemoteSigs.collect { case HtlcTxAndRemoteSig(txInfo: HtlcSuccessTx, remoteSig) => - if (hash2Preimage.contains(txInfo.paymentHash)) { + if (preimages.contains(txInfo.paymentHash)) { // We immediately spend incoming htlcs for which we have the preimage. - val preimage = hash2Preimage(txInfo.paymentHash) + val preimage = preimages(txInfo.paymentHash) Some(txInfo.input.outPoint -> withTxGenerationLog("htlc-success") { val localSig = txInfo.sign(commitKeys, commitment.params.commitmentFormat, Map.empty) Right(txInfo.addSigs(commitKeys, localSig, remoteSig, preimage, commitment.params.commitmentFormat)) @@ -954,6 +957,45 @@ object Helpers { }.flatten.toMap } + /** Claim the outputs of incoming HTLCs for the payment_hash matching the preimage provided. */ + def claimHtlcsWithPreimage(commitKeys: LocalCommitmentKeys, localCommitPublished: LocalCommitPublished, commitment: FullCommitment, preimage: ByteVector32)(implicit log: LoggingAdapter): (LocalCommitPublished, Seq[TxPublisher.PublishTx]) = { + val (htlcTxs, toPublish) = commitment.localCommit.htlcTxsAndRemoteSigs.collect { + case HtlcTxAndRemoteSig(txInfo: HtlcSuccessTx, remoteSig) if txInfo.paymentHash == Crypto.sha256(preimage) => + withTxGenerationLog("htlc-success") { + val localSig = txInfo.sign(commitKeys, commitment.params.commitmentFormat, Map.empty) + Right(txInfo.addSigs(commitKeys, localSig, remoteSig, preimage, commitment.params.commitmentFormat)) + }.map(signedTx => { + val toPublish = commitment.params.commitmentFormat match { + case DefaultCommitmentFormat => TxPublisher.PublishFinalTx(signedTx, signedTx.fee, Some(localCommitPublished.commitTx.txid)) + case _: AnchorOutputsCommitmentFormat | _: SimpleTaprootChannelCommitmentFormat => + val confirmationTarget = ConfirmationTarget.Absolute(txInfo.htlcExpiry.blockHeight) + TxPublisher.PublishReplaceableTx(ReplaceableHtlcSuccess(signedTx, commitKeys, preimage, remoteSig, localCommitPublished.commitTx, commitment), confirmationTarget) + } + (signedTx, toPublish) + }) + }.flatten.unzip + val additionalHtlcTxs = htlcTxs.map(tx => tx.input.outPoint -> Some(tx)).toMap[OutPoint, Option[HtlcTx]] + val localCommitPublished1 = localCommitPublished.copy(htlcTxs = localCommitPublished.htlcTxs ++ additionalHtlcTxs) + (localCommitPublished1, toPublish) + } + + /** + * An incoming HTLC that we've forwarded has been failed downstream: if the channel wasn't closing we would relay + * that failure. Since the channel is closing, our peer should claim the HTLC on-chain after the timeout. + * We stop tracking the corresponding output because we want to move to the CLOSED state even if our peer never + * claims it (which may happen if the HTLC amount is low and on-chain fees are high). + */ + def ignoreFailedIncomingHtlc(htlcId: Long, localCommitPublished: LocalCommitPublished, commitment: FullCommitment): LocalCommitPublished = { + // If we have the preimage (e.g. for partially fulfilled multi-part payments), we keep the HTLC-success tx. + val preimages = (commitment.changes.localChanges.all ++ commitment.changes.remoteChanges.all).collect { + case u: UpdateFulfillHtlc => Crypto.sha256(u.paymentPreimage) -> u.paymentPreimage + }.toMap + val outpoints = commitment.localCommit.htlcTxsAndRemoteSigs.collect { + case HtlcTxAndRemoteSig(txInfo: HtlcSuccessTx, _) if txInfo.htlcId == htlcId && !preimages.contains(txInfo.paymentHash) => txInfo.input.outPoint + }.toSet + localCommitPublished.copy(htlcTxs = localCommitPublished.htlcTxs -- outpoints) + } + /** * Claim the output of a 2nd-stage HTLC transaction. If the provided transaction isn't an htlc, this will be a no-op. * @@ -986,7 +1028,7 @@ object Helpers { val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) val commitKeys = commitment.remoteKeys(channelKeys, remoteCommit.remotePerCommitmentPoint) val mainTx_opt = claimMainOutput(commitment.params, commitKeys, commitTx, feerates, onChainFeeConf, finalScriptPubKey) - val htlcTxs = claimHtlcOutputs(channelKeys, commitKeys, commitment, remoteCommit, feerates, finalScriptPubKey) + val htlcTxs = claimHtlcOutputs(channelKeys, commitKeys, commitment, remoteCommit, finalScriptPubKey) val spendAnchors = htlcTxs.nonEmpty || onChainFeeConf.spendAnchorWithoutHtlcs val anchorTx_opt = if (spendAnchors) { claimAnchor(fundingKey, commitKeys, commitTx, commitment.params.commitmentFormat) @@ -1031,15 +1073,17 @@ object Helpers { * Claim the outputs of a remote commit tx corresponding to HTLCs. If we don't have the preimage for a received * * HTLC, we still include an entry in the map because we may receive that preimage later. */ - def claimHtlcOutputs(channelKeys: ChannelKeys, commitKeys: RemoteCommitmentKeys, commitment: FullCommitment, remoteCommit: RemoteCommit, feerates: FeeratesPerKw, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): Map[OutPoint, Option[ClaimHtlcTx]] = { + private def claimHtlcOutputs(channelKeys: ChannelKeys, commitKeys: RemoteCommitmentKeys, commitment: FullCommitment, remoteCommit: RemoteCommit, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): Map[OutPoint, Option[ClaimHtlcTx]] = { val outputs = makeRemoteCommitTxOutputs(channelKeys, commitKeys, commitment, remoteCommit) val remoteCommitTx = makeCommitTx(commitment.commitInput, remoteCommit.index, commitment.params.remoteParams.paymentBasepoint, commitKeys.ourPaymentBasePoint, !commitment.params.localParams.isChannelOpener, outputs) require(remoteCommitTx.tx.txid == remoteCommit.txid, "txid mismatch, cannot recompute the current remote commit tx") - // We need to use a rather high fee for htlc-claim because we compete with the counterparty. - val feerateHtlc = feerates.fast + // The feerate will be set by the publisher actor based on the HTLC expiry, we don't care which feerate is used here. + val feerate = FeeratePerKw(FeeratePerByte(1 sat)) - // We collect all the preimages we wanted to reveal to our peer. - val hash2Preimage: Map[ByteVector32, ByteVector32] = commitment.changes.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage }.map(r => Crypto.sha256(r) -> r).toMap + // We collect all the preimages available. + val preimages = (commitment.changes.localChanges.all ++ commitment.changes.remoteChanges.all).collect { + case u: UpdateFulfillHtlc => Crypto.sha256(u.paymentPreimage) -> u.paymentPreimage + }.toMap // We collect incoming HTLCs that we started failing but didn't cross-sign. val failedIncomingHtlcs: Set[Long] = commitment.changes.localChanges.all.collect { case u: UpdateFailHtlc => u.id @@ -1052,11 +1096,11 @@ object Helpers { // Remember we are looking at the remote commitment so IN for them is really OUT for us and vice versa. remoteCommit.spec.htlcs.collect { case OutgoingHtlc(add: UpdateAddHtlc) => - if (hash2Preimage.contains(add.paymentHash)) { + if (preimages.contains(add.paymentHash)) { // We immediately spend incoming htlcs for which we have the preimage. - val preimage = hash2Preimage(add.paymentHash) + val preimage = preimages(add.paymentHash) withTxGenerationLog("claim-htlc-success") { - ClaimHtlcSuccessTx.createSignedTx(commitKeys, remoteCommitTx.tx, commitment.localParams.dustLimit, outputs, finalScriptPubKey, add, preimage, feerateHtlc, commitment.params.commitmentFormat) + ClaimHtlcSuccessTx.createSignedTx(commitKeys, remoteCommitTx.tx, commitment.localParams.dustLimit, outputs, finalScriptPubKey, add, preimage, feerate, commitment.params.commitmentFormat) }.map(claimHtlcTx => claimHtlcTx.input.outPoint -> Some(claimHtlcTx)) } else if (failedIncomingHtlcs.contains(add.id)) { // We can ignore incoming htlcs that we started failing: our peer will claim them after the timeout. @@ -1076,11 +1120,53 @@ object Helpers { // claim the output, we will learn the preimage from their transaction, otherwise we will get our funds // back after the timeout. withTxGenerationLog("claim-htlc-timeout") { - ClaimHtlcTimeoutTx.createSignedTx(commitKeys, remoteCommitTx.tx, commitment.localParams.dustLimit, outputs, finalScriptPubKey, add, feerateHtlc, commitment.params.commitmentFormat) + ClaimHtlcTimeoutTx.createSignedTx(commitKeys, remoteCommitTx.tx, commitment.localParams.dustLimit, outputs, finalScriptPubKey, add, feerate, commitment.params.commitmentFormat) }.map(claimHtlcTx => claimHtlcTx.input.outPoint -> Some(claimHtlcTx)) }.flatten.toMap } + /** Claim the outputs of incoming HTLCs for the payment_hash matching the preimage provided. */ + def claimHtlcsWithPreimage(channelKeys: ChannelKeys, remoteCommitPublished: RemoteCommitPublished, commitment: FullCommitment, remoteCommit: RemoteCommit, preimage: ByteVector32, finalScriptPubKey: ByteVector)(implicit log: LoggingAdapter): (RemoteCommitPublished, Seq[TxPublisher.PublishReplaceableTx]) = { + val commitKeys = commitment.remoteKeys(channelKeys, remoteCommit.remotePerCommitmentPoint) + val outputs = makeRemoteCommitTxOutputs(channelKeys, commitKeys, commitment, remoteCommit) + // The feerate will be set by the publisher actor based on the HTLC expiry, we don't care which feerate is used here. + val feerate = FeeratePerKw(FeeratePerByte(1 sat)) + val toPublish = remoteCommit.spec.htlcs.collect { + // Remember we are looking at the remote commitment so IN for them is really OUT for us and vice versa. + case OutgoingHtlc(add: UpdateAddHtlc) if add.paymentHash == Crypto.sha256(preimage) => + withTxGenerationLog("claim-htlc-success") { + ClaimHtlcSuccessTx.createSignedTx(commitKeys, remoteCommitPublished.commitTx, commitment.localParams.dustLimit, outputs, finalScriptPubKey, add, preimage, feerate, commitment.params.commitmentFormat) + }.map { signedTx => + val confirmationTarget = ConfirmationTarget.Absolute(add.cltvExpiry.blockHeight) + TxPublisher.PublishReplaceableTx(ReplaceableClaimHtlcSuccess(signedTx, commitKeys, preimage, remoteCommitPublished.commitTx, commitment), confirmationTarget) + } + }.flatten.toSeq + val additionalHtlcTxs = toPublish.map(p => p.input -> Some(p.tx.txInfo.asInstanceOf[ClaimHtlcSuccessTx])).toMap[OutPoint, Option[ClaimHtlcTx]] + val remoteCommitPublished1 = remoteCommitPublished.copy(claimHtlcTxs = remoteCommitPublished.claimHtlcTxs ++ additionalHtlcTxs) + (remoteCommitPublished1, toPublish) + } + + /** + * An incoming HTLC that we've forwarded has been failed downstream: if the channel wasn't closing we would relay + * that failure. Since the channel is closing, our peer should claim the HTLC on-chain after the timeout. + * We stop tracking the corresponding output because we want to move to the CLOSED state even if our peer never + * claims it (which may happen if the HTLC amount is low and on-chain fees are high). + */ + def ignoreFailedIncomingHtlc(channelKeys: ChannelKeys, htlcId: Long, remoteCommitPublished: RemoteCommitPublished, commitment: FullCommitment, remoteCommit: RemoteCommit): RemoteCommitPublished = { + // If we have the preimage (e.g. for partially fulfilled multi-part payments), we keep the HTLC-success tx. + val preimages = (commitment.changes.localChanges.all ++ commitment.changes.remoteChanges.all).collect { + case u: UpdateFulfillHtlc => Crypto.sha256(u.paymentPreimage) -> u.paymentPreimage + }.toMap + val commitKeys = commitment.remoteKeys(channelKeys, remoteCommit.remotePerCommitmentPoint) + val outputs = makeRemoteCommitTxOutputs(channelKeys, commitKeys, commitment, remoteCommit) + val outpoints = remoteCommit.spec.htlcs.collect { + // Remember we are looking at the remote commitment so IN for them is really OUT for us and vice versa. + case OutgoingHtlc(add: UpdateAddHtlc) if add.id == htlcId && !preimages.contains(add.paymentHash) => + ClaimHtlcSuccessTx.findInput(remoteCommitPublished.commitTx, outputs, add).map(_.outPoint) + }.flatten + remoteCommitPublished.copy(claimHtlcTxs = remoteCommitPublished.claimHtlcTxs -- outpoints) + } + } object RevokedClose { 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 6e04ac69eb..d8fd0bf572 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 @@ -1857,19 +1857,36 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case c: CMD_FAIL_MALFORMED_HTLC => d.commitments.sendFailMalformed(c) }) match { case Right((commitments1, _)) => - log.info("got valid settlement for htlc={}, recalculating htlc transactions", c.id) val commitment = commitments1.latest - val localCommitPublished1 = d.localCommitPublished.map(localCommitPublished => localCommitPublished.copy(htlcTxs = Closing.LocalClose.claimHtlcOutputs(commitment.localKeys(channelKeys), commitment))) - val remoteCommitPublished1 = d.remoteCommitPublished.map(remoteCommitPublished => remoteCommitPublished.copy(claimHtlcTxs = Closing.RemoteClose.claimHtlcOutputs(channelKeys, commitment.remoteKeys(channelKeys, commitment.remoteCommit.remotePerCommitmentPoint), commitment, commitment.remoteCommit, nodeParams.currentBitcoinCoreFeerates, d.finalScriptPubKey))) - val nextRemoteCommitPublished1 = d.nextRemoteCommitPublished.map(remoteCommitPublished => remoteCommitPublished.copy(claimHtlcTxs = Closing.RemoteClose.claimHtlcOutputs(channelKeys, commitment.remoteKeys(channelKeys, commitment.nextRemoteCommit_opt.get.commit.remotePerCommitmentPoint), commitment, commitment.nextRemoteCommit_opt.get.commit, nodeParams.currentBitcoinCoreFeerates, d.finalScriptPubKey))) - - def republish(): Unit = { - localCommitPublished1.foreach(lcp => doPublish(lcp, commitment)) - remoteCommitPublished1.foreach(rcp => doPublish(rcp, commitment)) - nextRemoteCommitPublished1.foreach(rcp => doPublish(rcp, commitment)) + val d1 = c match { + case c: CMD_FULFILL_HTLC => + log.info("htlc #{} with payment_hash={} was fulfilled downstream, recalculating htlc-success transactions", c.id, c.r) + // We may be able to publish HTLC-success transactions for which we didn't have the preimage. + // We are already watching the corresponding outputs: no need to set additional watches. + val lcp1 = d.localCommitPublished.map(lcp => { + val (lcp1, toPublish) = Closing.LocalClose.claimHtlcsWithPreimage(commitment.localKeys(channelKeys), lcp, commitment, c.r) + toPublish.foreach(publishTx => txPublisher ! publishTx) + lcp1 + }) + val rcp1 = d.remoteCommitPublished.map(rcp => { + val (rcp1, toPublish) = Closing.RemoteClose.claimHtlcsWithPreimage(channelKeys, rcp, commitment, commitment.remoteCommit, c.r, d.finalScriptPubKey) + toPublish.foreach(publishTx => txPublisher ! publishTx) + rcp1 + }) + val nrcp1 = d.nextRemoteCommitPublished.map(nrcp => { + val (nrcp1, toPublish) = Closing.RemoteClose.claimHtlcsWithPreimage(channelKeys, nrcp, commitment, commitment.nextRemoteCommit_opt.get.commit, c.r, d.finalScriptPubKey) + toPublish.foreach(publishTx => txPublisher ! publishTx) + nrcp1 + }) + d.copy(commitments = commitments1, localCommitPublished = lcp1, remoteCommitPublished = rcp1, nextRemoteCommitPublished = nrcp1) + case _: CMD_FAIL_HTLC | _: CMD_FAIL_MALFORMED_HTLC => + log.info("htlc #{} was failed downstream, recalculating watched htlc outputs", c.id) + val lcp1 = d.localCommitPublished.map(lcp => Closing.LocalClose.ignoreFailedIncomingHtlc(c.id, lcp, commitment)) + val rcp1 = d.remoteCommitPublished.map(rcp => Closing.RemoteClose.ignoreFailedIncomingHtlc(channelKeys, c.id, rcp, commitment, commitment.remoteCommit)) + val nrcp1 = d.nextRemoteCommitPublished.map(nrcp => Closing.RemoteClose.ignoreFailedIncomingHtlc(channelKeys, c.id, nrcp, commitment, commitment.nextRemoteCommit_opt.get.commit)) + d.copy(commitments = commitments1, localCommitPublished = lcp1, remoteCommitPublished = rcp1, nextRemoteCommitPublished = nrcp1) } - - handleCommandSuccess(c, d.copy(commitments = commitments1, localCommitPublished = localCommitPublished1, remoteCommitPublished = remoteCommitPublished1, nextRemoteCommitPublished = nextRemoteCommitPublished1)) storing() calling republish() + handleCommandSuccess(c, d1) storing() case Left(cause) => handleCommandError(cause, c) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalQuiescentStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalQuiescentStateSpec.scala index 5795657979..be93c15085 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalQuiescentStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalQuiescentStateSpec.scala @@ -19,7 +19,7 @@ package fr.acinq.eclair.channel.states.e import akka.actor.ActorRef import akka.actor.typed.scaladsl.adapter.actorRefAdapter import akka.testkit.{TestFSMRef, TestProbe} -import fr.acinq.bitcoin.scalacompat.{SatoshiLong, Script} +import fr.acinq.bitcoin.scalacompat.{OutPoint, SatoshiLong, Script} import fr.acinq.eclair.TestConstants.Bob import fr.acinq.eclair.blockchain.CurrentBlockHeight import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ @@ -31,8 +31,10 @@ import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx} import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.io.Peer import fr.acinq.eclair.payment.relay.Relayer.RelayForward +import fr.acinq.eclair.transactions.Transactions.HtlcSuccessTx import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{channel, _} +import org.scalatest.Inside.inside import org.scalatest.Outcome import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.time.SpanSugar.convertIntToGrainOfTime @@ -471,7 +473,8 @@ class NormalQuiescentStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteL val bobCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit val commitTx = bobCommit.commitTxAndRemoteSig.commitTx.tx assert(bobCommit.htlcTxsAndRemoteSigs.size == 1) - val htlcSuccessTx = bobCommit.htlcTxsAndRemoteSigs.head.htlcTx.tx + val htlcSuccessTx = bobCommit.htlcTxsAndRemoteSigs.head.htlcTx + assert(htlcSuccessTx.isInstanceOf[HtlcSuccessTx]) // bob does not force-close unless there is a pending preimage for the incoming htlc bob ! CurrentBlockHeight(add.cltvExpiry.blockHeight - Bob.nodeParams.channelConf.fulfillSafetyBeforeTimeout.toInt) @@ -483,20 +486,14 @@ class NormalQuiescentStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteL // the HTLC timeout from alice is near, bob needs to close the channel to avoid an on-chain race condition bob ! CurrentBlockHeight(add.cltvExpiry.blockHeight - Bob.nodeParams.channelConf.fulfillSafetyBeforeTimeout.toInt) - // bob publishes a first set of force-close transactions + // bob publishes a set of force-close transactions, including the HTLC-success using the received preimage assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == commitTx.txid) - bob2blockchain.expectMsgType[PublishTx] // main delayed + val mainDelayedTx = bob2blockchain.expectMsgType[PublishFinalTx] + assert(mainDelayedTx.desc == "local-main-delayed") assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == commitTx.txid) - bob2blockchain.expectMsgType[WatchTxConfirmed] - bob2blockchain.expectMsgType[WatchOutputSpent] // htlc output - - // when transitioning to the closing state, bob checks the pending commands DB and replays the HTLC fulfill - assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == commitTx.txid) - bob2blockchain.expectMsgType[PublishTx] // main delayed - assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == htlcSuccessTx.txid) - assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == commitTx.txid) - bob2blockchain.expectMsgType[WatchTxConfirmed] // main delayed - bob2blockchain.expectMsgType[WatchOutputSpent] // htlc output + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == mainDelayedTx.tx.txid) + inside(bob2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(OutPoint(w.txId, w.outputIndex.toLong) == htlcSuccessTx.input.outPoint) } + assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == htlcSuccessTx.tx.txid) bob2blockchain.expectNoMessage(100 millis) channelUpdateListener.expectMsgType[LocalChannelDown] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala index 88e4597c5d..7f38840160 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalStateSpec.scala @@ -3128,7 +3128,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with }).sum // at best we have a little less than 450 000 + 250 000 + 100 000 + 50 000 = 850 000 (because fees) val amountClaimed = claimMain.txOut.head.amount + htlcAmountClaimed - assert(amountClaimed == 823_700.sat) + assert(amountClaimed == 839_959.sat) // alice sets the confirmation targets to the HTLC expiry assert(claimHtlcTxs.map(_.tx.commitTx.txid).toSet == Set(bobCommitTx.txid)) @@ -3224,7 +3224,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with }).sum // at best we have a little less than 500 000 + 250 000 + 100 000 = 850 000 (because fees) val amountClaimed = claimMain.txOut.head.amount + htlcAmountClaimed - assert(amountClaimed == 829_870.sat) + assert(amountClaimed == 840_534.sat) // alice sets the confirmation targets to the HTLC expiry assert(claimHtlcTxs.map(_.tx.commitTx.txid).toSet == Set(bobCommitTx.txid)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala index 5f75420333..6424575891 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala @@ -21,20 +21,21 @@ import akka.testkit.{TestFSMRef, TestProbe} import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Transaction} import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratesPerKw import fr.acinq.eclair.blockchain.{CurrentBlockHeight, CurrentFeerates} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel -import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx, PublishTx} +import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx} import fr.acinq.eclair.channel.publish.{ReplaceableClaimHtlcTimeout, ReplaceableRemoteCommitAnchor} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.transactions.Transactions.HtlcSuccessTx import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, TestUtils, randomBytes32} +import org.scalatest.Inside.inside import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -141,8 +142,8 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2bob.expectMsgType[RevokeAndAck] alice2bob.forward(bob) - alice2bob.expectNoMessage(500 millis) - bob2alice.expectNoMessage(500 millis) + alice2bob.expectNoMessage(100 millis) + bob2alice.expectNoMessage(100 millis) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.changes.localNextHtlcId == 1) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.changes.remoteNextHtlcId == 1) @@ -170,7 +171,7 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // bob received the signature, but alice won't receive the revocation val revB = bob2alice.expectMsgType[RevokeAndAck] val sigB = bob2alice.expectMsgType[CommitSig] - bob2alice.expectNoMessage(500 millis) + bob2alice.expectNoMessage(100 millis) disconnect(alice, bob) val (aliceCurrentPerCommitmentPoint, bobCurrentPerCommitmentPoint) = reconnect(alice, bob, alice2bob, bob2alice) @@ -185,8 +186,8 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // and a signature bob2alice.expectMsg(sigB) - alice2bob.expectNoMessage(500 millis) - bob2alice.expectNoMessage(500 millis) + alice2bob.expectNoMessage(100 millis) + bob2alice.expectNoMessage(100 millis) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.changes.localNextHtlcId == 1) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.changes.remoteNextHtlcId == 1) @@ -214,7 +215,7 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // bob sends a revocation and a signature val revB = bob2alice.expectMsgType[RevokeAndAck] val sigB = bob2alice.expectMsgType[CommitSig] - bob2alice.expectNoMessage(500 millis) + bob2alice.expectNoMessage(100 millis) // alice receives the revocation but not the signature bob2alice.forward(alice, revB) @@ -231,8 +232,8 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // bob re-sends the lost signature (but not the revocation) bob2alice.expectMsg(sigB) - alice2bob.expectNoMessage(500 millis) - bob2alice.expectNoMessage(500 millis) + alice2bob.expectNoMessage(100 millis) + bob2alice.expectNoMessage(100 millis) awaitCond(alice.stateData.asInstanceOf[DATA_NORMAL].commitments.changes.localNextHtlcId == 1) awaitCond(bob.stateData.asInstanceOf[DATA_NORMAL].commitments.changes.remoteNextHtlcId == 1) @@ -247,7 +248,7 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // |<--- sig ---| // |--- rev --X | bob2alice.forward(alice, sigB) - bob2alice.expectNoMessage(500 millis) + bob2alice.expectNoMessage(100 millis) val revA = alice2bob.expectMsgType[RevokeAndAck] disconnect(alice, bob) @@ -261,7 +262,7 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // alice re-sends the lost revocation alice2bob.expectMsg(revA) - alice2bob.expectNoMessage(500 millis) + alice2bob.expectNoMessage(100 millis) awaitCond(alice.stateName == NORMAL) awaitCond(bob.stateName == NORMAL) @@ -605,7 +606,7 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("pending non-relayed fulfill htlcs will timeout upstream") { f => import f._ - val (r, htlc) = addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) + val (r, htlc) = addHtlc(50_000_000 msat, alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) val listener = TestProbe() @@ -613,7 +614,8 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val initialState = bob.stateData.asInstanceOf[DATA_NORMAL] val initialCommitTx = initialState.commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx - val HtlcSuccessTx(_, htlcSuccessTx, _, _, _) = initialState.commitments.latest.localCommit.htlcTxsAndRemoteSigs.head.htlcTx + val htlcSuccessTx = initialState.commitments.latest.localCommit.htlcTxsAndRemoteSigs.head.htlcTx + assert(htlcSuccessTx.isInstanceOf[HtlcSuccessTx]) disconnect(alice, bob) @@ -627,23 +629,19 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(err.isInstanceOf[HtlcsWillTimeoutUpstream]) assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == initialCommitTx.txid) - bob2blockchain.expectMsgType[PublishTx] // main delayed - assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == initialCommitTx.txid) - bob2blockchain.expectMsgType[WatchTxConfirmed] // main delayed - bob2blockchain.expectMsgType[WatchOutputSpent] // htlc - - assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == initialCommitTx.txid) - bob2blockchain.expectMsgType[PublishTx] // main delayed - assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txOut == htlcSuccessTx.txOut) + val mainDelayedTx = bob2blockchain.expectMsgType[PublishFinalTx] + assert(mainDelayedTx.desc == "local-main-delayed") assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == initialCommitTx.txid) - bob2blockchain.expectMsgType[WatchTxConfirmed] // main delayed - bob2blockchain.expectMsgType[WatchOutputSpent] // htlc - bob2blockchain.expectNoMessage(500 millis) + assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == mainDelayedTx.tx.txid) + inside(bob2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(OutPoint(w.txId, w.outputIndex.toLong) == htlcSuccessTx.input.outPoint) } + val publishHtlcTx = bob2blockchain.expectMsgType[PublishFinalTx] + assert(publishHtlcTx.input == htlcSuccessTx.input.outPoint) + bob2blockchain.expectNoMessage(100 millis) } test("pending non-relayed fail htlcs will timeout upstream") { f => import f._ - val (_, htlc) = addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) + val (_, htlc) = addHtlc(50_000_000 msat, alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) disconnect(alice, bob) @@ -652,9 +650,8 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Even if we get close to expiring upstream we shouldn't close the channel, because we have nothing to lose. bob ! CMD_FAIL_HTLC(htlc.id, FailureReason.LocalFailure(IncorrectOrUnknownPaymentDetails(0 msat, BlockHeight(0))), None) bob ! CurrentBlockHeight(htlc.cltvExpiry.blockHeight - bob.underlyingActor.nodeParams.channelConf.fulfillSafetyBeforeTimeout.toInt) - - bob2blockchain.expectNoMessage(250 millis) - alice2blockchain.expectNoMessage(250 millis) + bob2blockchain.expectNoMessage(100 millis) + alice2blockchain.expectNoMessage(100 millis) } test("handle feerate changes while offline (funder scenario)") { f => 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 033a39d04b..5c603b83c0 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 @@ -738,7 +738,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit }).sum // htlc will timeout and be eventually refunded so we have a little less than fundingSatoshis - pushMsat = 1000000 - 200000 = 800000 (because fees) val amountClaimed = htlcAmountClaimed + claimMain.txOut.head.amount - assert(amountClaimed == 780_310.sat) + assert(amountClaimed == 790_974.sat) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.txid) @@ -791,7 +791,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit claimTx.txOut.head.amount }).sum // htlc will timeout and be eventually refunded so we have a little less than fundingSatoshis - pushMsat - htlc1 = 1000000 - 200000 - 300 000 = 500000 (because fees) - assert(amountClaimed == 486_210.sat) + assert(amountClaimed == 491_542.sat) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimTxs(0).txid) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala index 3807bbf3d9..fc1c8a38f0 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala @@ -937,6 +937,73 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2relayer.expectNoMessage(100 millis) } + test("recv WatchTxConfirmedTriggered (local commit followed by htlc settlement)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + // Bob sends 2 HTLCs to Alice that will be settled during the force-close: one will be fulfilled, the other will be failed. + val (r1, htlc1) = addHtlc(75_000_000 msat, CltvExpiryDelta(48), bob, alice, bob2alice, alice2bob) + val (_, htlc2) = addHtlc(65_000_000 msat, CltvExpiryDelta(36), bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + assert(alice2relayer.expectMsgType[RelayForward].add == htlc1) + assert(alice2relayer.expectMsgType[RelayForward].add == htlc2) + + // Alice force-closes. + val closingState = localClose(alice, alice2blockchain) + assert(closingState.commitTx.txOut.length == 6) // 2 main outputs + 2 anchor outputs + 2 htlcs + assert(closingState.claimMainDelayedOutputTx.nonEmpty) + assert(closingState.htlcTxs.size == 2) + assert(getHtlcSuccessTxs(closingState).isEmpty) // we don't have the preimage to claim the htlc-success yet + assert(getHtlcTimeoutTxs(closingState).isEmpty) + + // Alice's commitment and main transaction confirm: she waits for the HTLC outputs to be spent. + alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, closingState.commitTx) + closingState.claimMainDelayedOutputTx.foreach(claimMain => alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, claimMain.tx)) + assert(alice.stateName == CLOSING) + + // Alice receives the preimage for the first HTLC from downstream; she can now claim the corresponding HTLC output. + alice ! CMD_FULFILL_HTLC(htlc1.id, r1, commit = true) + val publishHtlcSuccessTx = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishHtlcSuccessTx.tx.isInstanceOf[ReplaceableHtlcSuccess]) + assert(publishHtlcSuccessTx.tx.asInstanceOf[ReplaceableHtlcSuccess].preimage == r1) + assert(publishHtlcSuccessTx.confirmationTarget == ConfirmationTarget.Absolute(htlc1.cltvExpiry.blockHeight)) + val htlcSuccessTx = publishHtlcSuccessTx.tx.txInfo.tx + Transaction.correctlySpends(htlcSuccessTx, closingState.commitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + alice2blockchain.expectNoMessage(100 millis) + + // Alice receives a failure for the second HTLC from downstream; she can stop watching the corresponding HTLC output. + alice ! CMD_FAIL_HTLC(htlc2.id, FailureReason.EncryptedDownstreamFailure(ByteVector.empty, None), None) + alice2blockchain.expectNoMessage(100 millis) + + // Alice restarts before the HTLC transaction confirmed. + val beforeRestart1 = alice.stateData.asInstanceOf[DATA_CLOSING] + alice.setState(WAIT_FOR_INIT_INTERNAL, Nothing) + alice ! INPUT_RESTORED(beforeRestart1) + alice2blockchain.expectMsgType[SetChannelId] + awaitCond(alice.stateName == CLOSING) + // Alice republishes the HTLC-success transaction, which then confirms. + assert(alice2blockchain.expectMsgType[PublishReplaceableTx].input == publishHtlcSuccessTx.input) + inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(OutPoint(w.txId, w.outputIndex.toLong) == publishHtlcSuccessTx.input) } + alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, htlcSuccessTx) + // Alice publishes a 3rd-stage HTLC transaction. + val htlcDelayedTx = alice2blockchain.expectMsgType[PublishFinalTx] + assert(htlcDelayedTx.input == OutPoint(publishHtlcSuccessTx.tx.txInfo.tx, 0)) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == htlcDelayedTx.tx.txid) + alice2blockchain.expectNoMessage(100 millis) + + // Alice restarts again before the 3rd-stage HTLC transaction confirmed. + val beforeRestart2 = alice.stateData.asInstanceOf[DATA_CLOSING] + alice.setState(WAIT_FOR_INIT_INTERNAL, Nothing) + alice ! INPUT_RESTORED(beforeRestart2) + alice2blockchain.expectMsgType[SetChannelId] + awaitCond(alice.stateName == CLOSING) + // Alice republishes the 3rd-stage HTLC transaction, which then confirms. + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == htlcDelayedTx.tx.txid) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == htlcDelayedTx.tx.txid) + alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, htlcDelayedTx.tx) + alice2blockchain.expectNoMessage(100 millis) + alice2relayer.expectNoMessage(100 millis) + awaitCond(alice.stateName == CLOSED) + } + test("recv INPUT_RESTORED (local commit)") { f => import f._ @@ -984,6 +1051,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimHtlcTimeoutTx.tx) closingState.claimMainDelayedOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.tx.txid)) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimHtlcTimeoutTx.tx.txid) + // the main transaction confirms + closingState.claimMainDelayedOutputTx.foreach(claimMain => alice ! WatchTxConfirmedTriggered(BlockHeight(2801), 5, claimMain.tx)) + assert(alice.stateName == CLOSING) + // the htlc delayed transaction confirms + alice ! WatchTxConfirmedTriggered(BlockHeight(2802), 5, claimHtlcTimeoutTx.tx) + awaitCond(alice.stateName == CLOSED) } test("recv INPUT_RESTORED (local commit with htlc-delayed transactions)", Tag(ChannelStateTestsTags.AnchorOutputs)) { f => @@ -1008,16 +1081,14 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Alice receives the preimage for the incoming HTLC. alice ! CMD_FULFILL_HTLC(incomingHtlc.id, preimage, commit = true) - assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == claimMainTx.txid) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].tx.isInstanceOf[ReplaceableHtlcTimeout]) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].tx.isInstanceOf[ReplaceableHtlcSuccess]) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMainTx.txid) - alice2blockchain.expectMsgType[WatchOutputSpent] - alice2blockchain.expectMsgType[WatchOutputSpent] + val htlcSuccessTx = inside(alice2blockchain.expectMsgType[PublishReplaceableTx]) { p => + assert(p.tx.isInstanceOf[ReplaceableHtlcSuccess]) + assert(p.tx.asInstanceOf[ReplaceableHtlcSuccess].preimage == preimage) + p.tx.txInfo.tx + } alice2blockchain.expectNoMessage(100 millis) val closingState2 = alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get assert(getHtlcSuccessTxs(closingState2).length == 1) - val htlcSuccessTx = getHtlcSuccessTxs(closingState2).head.tx // The HTLC txs confirms, so we publish 3rd-stage txs. alice ! WatchTxConfirmedTriggered(BlockHeight(201), 0, htlcTimeoutTx) @@ -1302,52 +1373,62 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with testRemoteCommitTxWithHtlcsConfirmed(f, ChannelFeatures(Features.StaticRemoteKey, Features.AnchorOutputsZeroFeeHtlcTx)) } - test("recv WatchTxConfirmedTriggered (remote commit) followed by CMD_FULFILL_HTLC") { f => + test("recv WatchTxConfirmedTriggered (remote commit) followed by htlc settlement", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ - // An HTLC Bob -> Alice is cross-signed that will be fulfilled later. + // Bob sends 2 HTLCs to Alice that will be settled during the force-close: one will be fulfilled, the other will be failed. val (r1, htlc1) = addHtlc(110_000_000 msat, CltvExpiryDelta(48), bob, alice, bob2alice, alice2bob) + val (_, htlc2) = addHtlc(60_000_000 msat, CltvExpiryDelta(36), bob, alice, bob2alice, alice2bob) crossSign(bob, alice, bob2alice, alice2bob) assert(alice2relayer.expectMsgType[RelayForward].add == htlc1) + assert(alice2relayer.expectMsgType[RelayForward].add == htlc2) - // An HTLC Alice -> Bob is only signed by Alice: Bob has two spendable commit tx. - val (_, htlc2) = addHtlc(95_000_000 msat, CltvExpiryDelta(144), alice, bob, alice2bob, bob2alice) + // Alice sends an HTLC to Bob: Bob has two spendable commit txs. + val (_, htlc3) = addHtlc(95_000_000 msat, CltvExpiryDelta(144), alice, bob, alice2bob, bob2alice) alice ! CMD_SIGN() alice2bob.expectMsgType[CommitSig] // We stop here: Alice sent her CommitSig, but doesn't hear back from Bob. // Now Bob publishes the first commit tx (force-close). val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx - assert(bobCommitTx.txOut.length == 3) // two main outputs + 1 HTLC + assert(bobCommitTx.txOut.length == 6) // 2 main outputs + 2 anchor outputs + 2 HTLCs val closingState = remoteClose(bobCommitTx, alice, alice2blockchain) - assert(closingState.claimMainOutputTx.isEmpty) - assert(bobCommitTx.txOut.exists(_.publicKeyScript == Script.write(Script.pay2wpkh(DummyOnChainWallet.dummyReceivePubkey)))) - assert(closingState.claimHtlcTxs.size == 1) + assert(closingState.claimMainOutputTx.nonEmpty) + assert(closingState.claimHtlcTxs.size == 2) assert(getClaimHtlcSuccessTxs(closingState).isEmpty) // we don't have the preimage to claim the htlc-success yet assert(getClaimHtlcTimeoutTxs(closingState).isEmpty) // Alice receives the preimage for the first HTLC from downstream; she can now claim the corresponding HTLC output. alice ! CMD_FULFILL_HTLC(htlc1.id, r1, commit = true) - val claimHtlcSuccessTx = getClaimHtlcSuccessTxs(alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get).head.tx - Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val publishHtlcSuccessTx = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishHtlcSuccessTx.tx.isInstanceOf[ReplaceableClaimHtlcSuccess]) - assert(publishHtlcSuccessTx.tx.txInfo.tx == claimHtlcSuccessTx) + assert(publishHtlcSuccessTx.tx.asInstanceOf[ReplaceableClaimHtlcSuccess].preimage == r1) assert(publishHtlcSuccessTx.confirmationTarget == ConfirmationTarget.Absolute(htlc1.cltvExpiry.blockHeight)) - - // Alice resets watches on all relevant transactions. - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) - val watchHtlcSuccess = alice2blockchain.expectMsgType[WatchOutputSpent] - assert(watchHtlcSuccess.txId == bobCommitTx.txid) - assert(watchHtlcSuccess.outputIndex == claimHtlcSuccessTx.txIn.head.outPoint.index) + val claimHtlcSuccessTx = publishHtlcSuccessTx.tx.txInfo.tx + Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice2blockchain.expectNoMessage(100 millis) + // Bob's commitment confirms: the third htlc was not included in the commit tx published on-chain, so we can consider it failed. alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, bobCommitTx) - // The second htlc was not included in the commit tx published on-chain, so we can consider it failed - assert(alice2relayer.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]].htlc == htlc2) + assert(alice2relayer.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]].htlc == htlc3) + // Alice's main transaction confirms. + closingState.claimMainOutputTx.foreach(claimMain => alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, claimMain.tx)) + + // Alice receives a failure for the second HTLC from downstream; she can stop watching the corresponding HTLC output. + alice ! CMD_FAIL_HTLC(htlc2.id, FailureReason.EncryptedDownstreamFailure(ByteVector.empty, None), None) + alice2blockchain.expectNoMessage(100 millis) + + // Alice restarts, and pending transactions confirm. + val beforeRestart = alice.stateData.asInstanceOf[DATA_CLOSING] + alice.setState(WAIT_FOR_INIT_INTERNAL, Nothing) + alice ! INPUT_RESTORED(beforeRestart) + alice2blockchain.expectMsgType[SetChannelId] + awaitCond(alice.stateName == CLOSING) + // Alice republishes the HTLC-success transaction, which then confirms. + assert(alice2blockchain.expectMsgType[PublishReplaceableTx].input == publishHtlcSuccessTx.input) + inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(OutPoint(w.txId, w.outputIndex.toLong) == publishHtlcSuccessTx.input) } alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, claimHtlcSuccessTx) - assert(alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get.irrevocablySpent.values.toSet == Set(bobCommitTx, claimHtlcSuccessTx)) - awaitCond(alice.stateName == CLOSED) alice2blockchain.expectNoMessage(100 millis) alice2relayer.expectNoMessage(100 millis) + awaitCond(alice.stateName == CLOSED) } test("recv INPUT_RESTORED (remote commit)") { f => @@ -1472,15 +1553,17 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSED) } - test("recv WatchTxConfirmedTriggered (next remote commit) followed by CMD_FULFILL_HTLC") { f => + test("recv WatchTxConfirmedTriggered (next remote commit) followed by htlc settlement", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => import f._ - // An HTLC Bob -> Alice is cross-signed that will be fulfilled later. + // Bob sends 2 HTLCs to Alice that will be settled during the force-close: one will be fulfilled, the other will be failed. val (r1, htlc1) = addHtlc(110_000_000 msat, CltvExpiryDelta(64), bob, alice, bob2alice, alice2bob) + val (_, htlc2) = addHtlc(70_000_000 msat, CltvExpiryDelta(96), bob, alice, bob2alice, alice2bob) crossSign(bob, alice, bob2alice, alice2bob) assert(alice2relayer.expectMsgType[RelayForward].add == htlc1) + assert(alice2relayer.expectMsgType[RelayForward].add == htlc2) - // An HTLC Alice -> Bob is only signed by Alice: Bob has two spendable commit tx. - val (_, htlc2) = addHtlc(95_000_000 msat, CltvExpiryDelta(32), alice, bob, alice2bob, bob2alice) + // Alice sends an HTLC to Bob: Bob has two spendable commit txs. + val (_, htlc3) = addHtlc(95_000_000 msat, CltvExpiryDelta(32), alice, bob, alice2bob, bob2alice) alice ! CMD_SIGN() alice2bob.expectMsgType[CommitSig] alice2bob.forward(bob) @@ -1489,47 +1572,54 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Now Bob publishes the next commit tx (force-close). val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx - assert(bobCommitTx.txOut.length == 4) // two main outputs + 2 HTLCs + assert(bobCommitTx.txOut.length == 7) // 2 main outputs + 2 anchor outputs + 3 HTLCs val closingState = remoteClose(bobCommitTx, alice, alice2blockchain) - if (!bob.stateData.asInstanceOf[DATA_NORMAL].commitments.params.channelFeatures.paysDirectlyToWallet) { - assert(closingState.claimMainOutputTx.nonEmpty) - } else { - assert(closingState.claimMainOutputTx.isEmpty) - } - assert(closingState.claimHtlcTxs.size == 2) + assert(closingState.claimMainOutputTx.nonEmpty) + assert(closingState.claimHtlcTxs.size == 3) assert(getClaimHtlcSuccessTxs(closingState).isEmpty) // we don't have the preimage to claim the htlc-success yet assert(getClaimHtlcTimeoutTxs(closingState).length == 1) - val claimHtlcTimeoutTx = getClaimHtlcTimeoutTxs(closingState).head.tx + val claimHtlcTimeoutTx = getClaimHtlcTimeoutTxs(closingState).head // Alice receives the preimage for the first HTLC from downstream; she can now claim the corresponding HTLC output. alice ! CMD_FULFILL_HTLC(htlc1.id, r1, commit = true) - closingState.claimMainOutputTx.foreach(claimMainOutputTx => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimMainOutputTx.tx)) - val claimHtlcSuccessTx = getClaimHtlcSuccessTxs(alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.get).head.tx - Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val publishHtlcSuccessTx = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishHtlcSuccessTx.tx.isInstanceOf[ReplaceableClaimHtlcSuccess]) - assert(publishHtlcSuccessTx.tx.txInfo.tx == claimHtlcSuccessTx) + assert(publishHtlcSuccessTx.tx.asInstanceOf[ReplaceableClaimHtlcSuccess].preimage == r1) assert(publishHtlcSuccessTx.confirmationTarget == ConfirmationTarget.Absolute(htlc1.cltvExpiry.blockHeight)) - val publishHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(publishHtlcTimeoutTx.tx.isInstanceOf[ReplaceableClaimHtlcTimeout]) - assert(publishHtlcTimeoutTx.tx.txInfo.tx == claimHtlcTimeoutTx) - assert(publishHtlcTimeoutTx.confirmationTarget == ConfirmationTarget.Absolute(htlc2.cltvExpiry.blockHeight)) - - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) - closingState.claimMainOutputTx.foreach(claimMainOutputTx => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMainOutputTx.tx.txid)) - val watchHtlcs = alice2blockchain.expectMsgType[WatchOutputSpent] :: alice2blockchain.expectMsgType[WatchOutputSpent] :: Nil - watchHtlcs.foreach(ws => assert(ws.txId == bobCommitTx.txid)) - assert(watchHtlcs.map(_.outputIndex).toSet == Set(claimHtlcSuccessTx, claimHtlcTimeoutTx).map(_.txIn.head.outPoint.index)) + val claimHtlcSuccessTx = publishHtlcSuccessTx.tx.txInfo.asInstanceOf[ClaimHtlcSuccessTx] + Transaction.correctlySpends(claimHtlcSuccessTx.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice2blockchain.expectNoMessage(100 millis) + // Bob's commitment and Alice's main transaction confirm. alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, bobCommitTx) closingState.claimMainOutputTx.foreach(claimMainOutputTx => alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, claimMainOutputTx.tx)) - alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, claimHtlcSuccessTx) - alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, claimHtlcTimeoutTx) - assert(alice2relayer.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]].htlc == htlc2) - awaitCond(alice.stateName == CLOSED) + + // Alice receives a failure for the second HTLC from downstream; she can stop watching the corresponding HTLC output. + alice ! CMD_FAIL_HTLC(htlc2.id, FailureReason.EncryptedDownstreamFailure(ByteVector.empty, None), None) + alice2blockchain.expectNoMessage(100 millis) + + // Alice restarts, and pending HTLC transactions confirm. + val beforeRestart = alice.stateData.asInstanceOf[DATA_CLOSING] + alice.setState(WAIT_FOR_INIT_INTERNAL, Nothing) + alice ! INPUT_RESTORED(beforeRestart) + alice2blockchain.expectMsgType[SetChannelId] + awaitCond(alice.stateName == CLOSING) + // Alice republishes the HTLC transactions, which then confirm. + val htlcTx1 = alice2blockchain.expectMsgType[PublishReplaceableTx] + val htlcTx2 = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(Set(htlcTx1.input, htlcTx2.input) == Set(claimHtlcTimeoutTx.input.outPoint, claimHtlcSuccessTx.input.outPoint)) + assert(Set( + alice2blockchain.expectMsgType[WatchOutputSpent], + alice2blockchain.expectMsgType[WatchOutputSpent], + ).map(w => OutPoint(w.txId, w.outputIndex.toLong)) == Set(htlcTx1.input, htlcTx2.input)) + alice2blockchain.expectNoMessage(100 millis) + alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, claimHtlcSuccessTx.tx) + assert(alice.stateName == CLOSING) + alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, claimHtlcTimeoutTx.tx) + assert(alice2relayer.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]].htlc == htlc3) alice2blockchain.expectNoMessage(100 millis) alice2relayer.expectNoMessage(100 millis) + awaitCond(alice.stateName == CLOSED) } test("recv INPUT_RESTORED (next remote commit, anchor outputs zero fee htlc txs)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => From 1f9fe780ca6bb6b5ecc611e0d911718fb4ed7e7f Mon Sep 17 00:00:00 2001 From: t-bast Date: Thu, 22 May 2025 10:45:54 +0200 Subject: [PATCH 2/2] Add watch helpers and use them in closing tests This is something we should have added a long time ago to help the closing tests be more readable. Better late than never! --- .../states/e/NormalQuiescentStateSpec.scala | 26 +-- .../channel/states/e/OfflineStateSpec.scala | 10 +- .../channel/states/h/ClosingStateSpec.scala | 158 ++++++++---------- .../eclair/testutils/PimpTestProbe.scala | 20 ++- 4 files changed, 103 insertions(+), 111 deletions(-) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalQuiescentStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalQuiescentStateSpec.scala index be93c15085..95458b9730 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalQuiescentStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalQuiescentStateSpec.scala @@ -19,22 +19,21 @@ package fr.acinq.eclair.channel.states.e import akka.actor.ActorRef import akka.actor.typed.scaladsl.adapter.actorRefAdapter import akka.testkit.{TestFSMRef, TestProbe} -import fr.acinq.bitcoin.scalacompat.{OutPoint, SatoshiLong, Script} +import fr.acinq.bitcoin.scalacompat.{SatoshiLong, Script} import fr.acinq.eclair.TestConstants.Bob import fr.acinq.eclair.blockchain.CurrentBlockHeight -import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fund.InteractiveTxBuilder -import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx} +import fr.acinq.eclair.channel.publish.TxPublisher.PublishFinalTx import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.io.Peer import fr.acinq.eclair.payment.relay.Relayer.RelayForward +import fr.acinq.eclair.testutils.PimpTestProbe.convert import fr.acinq.eclair.transactions.Transactions.HtlcSuccessTx import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{channel, _} -import org.scalatest.Inside.inside import org.scalatest.Outcome import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.time.SpanSugar.convertIntToGrainOfTime @@ -449,16 +448,17 @@ class NormalQuiescentStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteL val aliceCommit = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit val commitTx = aliceCommit.commitTxAndRemoteSig.commitTx.tx assert(aliceCommit.htlcTxsAndRemoteSigs.size == 1) - val htlcTimeoutTx = aliceCommit.htlcTxsAndRemoteSigs.head.htlcTx.tx + val htlcTimeoutTx = aliceCommit.htlcTxsAndRemoteSigs.head.htlcTx // the HTLC times out, alice needs to close the channel alice ! CurrentBlockHeight(add.cltvExpiry.blockHeight) assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == commitTx.txid) - alice2blockchain.expectMsgType[PublishTx] // main delayed - assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == htlcTimeoutTx.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == commitTx.txid) - alice2blockchain.expectMsgType[WatchTxConfirmed] // main delayed - alice2blockchain.expectMsgType[WatchOutputSpent] // htlc output + val mainDelayedTx = alice2blockchain.expectMsgType[PublishFinalTx] + assert(mainDelayedTx.desc == "local-main-delayed") + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == htlcTimeoutTx.tx.txid) + alice2blockchain.expectWatchTxConfirmed(commitTx.txid) + alice2blockchain.expectWatchTxConfirmed(mainDelayedTx.tx.txid) + alice2blockchain.expectWatchOutputSpent(htlcTimeoutTx.input.outPoint) alice2blockchain.expectNoMessage(100 millis) channelUpdateListener.expectMsgType[LocalChannelDown] @@ -490,9 +490,9 @@ class NormalQuiescentStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteL assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == commitTx.txid) val mainDelayedTx = bob2blockchain.expectMsgType[PublishFinalTx] assert(mainDelayedTx.desc == "local-main-delayed") - assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == commitTx.txid) - assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == mainDelayedTx.tx.txid) - inside(bob2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(OutPoint(w.txId, w.outputIndex.toLong) == htlcSuccessTx.input.outPoint) } + bob2blockchain.expectWatchTxConfirmed(commitTx.txid) + bob2blockchain.expectWatchTxConfirmed(mainDelayedTx.tx.txid) + bob2blockchain.expectWatchOutputSpent(htlcSuccessTx.input.outPoint) assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == htlcSuccessTx.tx.txid) bob2blockchain.expectNoMessage(100 millis) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala index 6424575891..c13bf27dca 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/OfflineStateSpec.scala @@ -21,7 +21,7 @@ import akka.testkit.{TestFSMRef, TestProbe} import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Transaction} import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratesPerKw @@ -32,10 +32,10 @@ import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishRepla import fr.acinq.eclair.channel.publish.{ReplaceableClaimHtlcTimeout, ReplaceableRemoteCommitAnchor} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} +import fr.acinq.eclair.testutils.PimpTestProbe.convert import fr.acinq.eclair.transactions.Transactions.HtlcSuccessTx import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, TestUtils, randomBytes32} -import org.scalatest.Inside.inside import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -631,9 +631,9 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == initialCommitTx.txid) val mainDelayedTx = bob2blockchain.expectMsgType[PublishFinalTx] assert(mainDelayedTx.desc == "local-main-delayed") - assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == initialCommitTx.txid) - assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == mainDelayedTx.tx.txid) - inside(bob2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(OutPoint(w.txId, w.outputIndex.toLong) == htlcSuccessTx.input.outPoint) } + bob2blockchain.expectWatchTxConfirmed(initialCommitTx.txid) + bob2blockchain.expectWatchTxConfirmed(mainDelayedTx.tx.txid) + bob2blockchain.expectWatchOutputSpent(htlcSuccessTx.input.outPoint) val publishHtlcTx = bob2blockchain.expectMsgType[PublishFinalTx] assert(publishHtlcTx.input == htlcSuccessTx.input.outPoint) bob2blockchain.expectNoMessage(100 millis) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala index fc1c8a38f0..02831fa7b6 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/h/ClosingStateSpec.scala @@ -34,6 +34,7 @@ import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.payment._ import fr.acinq.eclair.payment.relay.Relayer._ +import fr.acinq.eclair.testutils.PimpTestProbe.convert import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, TimestampSecond, randomBytes32, randomKey} @@ -420,7 +421,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice.stateData == initialState) // this was a no-op // The Claim-HTLC-success transaction confirms: nothing to do, preimage has already been relayed. - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimHtlcSuccessTx1.tx.txInfo.tx.txid) + alice2blockchain.expectWatchTxConfirmed(claimHtlcSuccessTx1.tx.txInfo.tx.txid) alice ! WatchTxConfirmedTriggered(alice.nodeParams.currentBlockHeight, 6, claimHtlcSuccessTx1.tx.txInfo.tx) alice2blockchain.expectNoMessage(100 millis) alice2relayer.expectNoMessage(100 millis) @@ -520,18 +521,15 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Seq(htlc1, htlc2, htlc3).foreach(_ => assert(alice2blockchain.expectMsgType[PublishReplaceableTx].tx.isInstanceOf[ReplaceableClaimHtlcTimeout])) val claimHtlcTimeoutTxs = getClaimHtlcTimeoutTxs(alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get) assert(claimHtlcTimeoutTxs.size == 3) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == rcp.commitTx.txid) + alice2blockchain.expectWatchTxConfirmed(rcp.commitTx.txid) if (alice.stateData.asInstanceOf[DATA_CLOSING].commitments.params.channelFeatures.hasFeature(Features.AnchorOutputsZeroFeeHtlcTx)) { - alice2blockchain.expectMsgType[WatchTxConfirmed] // remote-main-delayed + val claimMainTx = alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get.claimMainOutputTx.get + alice2blockchain.expectWatchTxConfirmed(claimMainTx.tx.txid) } - assert(Set( - alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex, - alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex, - alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex, - ) == claimHtlcTimeoutTxs.map(_.input.outPoint.index).toSet) + alice2blockchain.expectWatchOutputsSpent(claimHtlcTimeoutTxs.map(_.input.outPoint)) if (alice.stateData.asInstanceOf[DATA_CLOSING].commitments.params.channelFeatures.hasFeature(Features.AnchorOutputsZeroFeeHtlcTx)) { val anchorOutput = alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get.claimAnchorTx_opt.get.input.outPoint - inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(OutPoint(w.txId, w.outputIndex.toLong) == anchorOutput) } + alice2blockchain.expectWatchOutputSpent(anchorOutput) } alice2blockchain.expectNoMessage(100 millis) @@ -614,18 +612,15 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with Seq(htlc1, htlc2, htlc3).foreach(_ => assert(alice2blockchain.expectMsgType[PublishReplaceableTx].tx.isInstanceOf[ReplaceableClaimHtlcTimeout])) val claimHtlcTimeoutTxs = getClaimHtlcTimeoutTxs(alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.get) assert(claimHtlcTimeoutTxs.size == 3) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == rcp.commitTx.txid) + alice2blockchain.expectWatchTxConfirmed(rcp.commitTx.txid) if (alice.stateData.asInstanceOf[DATA_CLOSING].commitments.params.channelFeatures.hasFeature(Features.AnchorOutputsZeroFeeHtlcTx)) { - alice2blockchain.expectMsgType[WatchTxConfirmed] // remote-main-delayed + val claimMainTx = alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.get.claimMainOutputTx.get + alice2blockchain.expectWatchTxConfirmed(claimMainTx.tx.txid) } - assert(Set( - alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex, - alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex, - alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex, - ) == claimHtlcTimeoutTxs.map(_.input.outPoint.index).toSet) + alice2blockchain.expectWatchOutputsSpent(claimHtlcTimeoutTxs.map(_.input.outPoint)) if (alice.stateData.asInstanceOf[DATA_CLOSING].commitments.params.channelFeatures.hasFeature(Features.AnchorOutputsZeroFeeHtlcTx)) { val anchorOutput = alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.get.claimAnchorTx_opt.get.input.outPoint - inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(OutPoint(w.txId, w.outputIndex.toLong) == anchorOutput) } + alice2blockchain.expectWatchOutputSpent(anchorOutput) } alice2blockchain.expectNoMessage(100 millis) @@ -981,12 +976,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // Alice republishes the HTLC-success transaction, which then confirms. assert(alice2blockchain.expectMsgType[PublishReplaceableTx].input == publishHtlcSuccessTx.input) - inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(OutPoint(w.txId, w.outputIndex.toLong) == publishHtlcSuccessTx.input) } + alice2blockchain.expectWatchOutputSpent(publishHtlcSuccessTx.input) alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, htlcSuccessTx) // Alice publishes a 3rd-stage HTLC transaction. val htlcDelayedTx = alice2blockchain.expectMsgType[PublishFinalTx] assert(htlcDelayedTx.input == OutPoint(publishHtlcSuccessTx.tx.txInfo.tx, 0)) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == htlcDelayedTx.tx.txid) + alice2blockchain.expectWatchTxConfirmed(htlcDelayedTx.tx.txid) alice2blockchain.expectNoMessage(100 millis) // Alice restarts again before the 3rd-stage HTLC transaction confirmed. @@ -997,7 +992,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // Alice republishes the 3rd-stage HTLC transaction, which then confirms. assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == htlcDelayedTx.tx.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == htlcDelayedTx.tx.txid) + alice2blockchain.expectWatchTxConfirmed(htlcDelayedTx.tx.txid) alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, htlcDelayedTx.tx) alice2blockchain.expectNoMessage(100 millis) alice2relayer.expectNoMessage(100 millis) @@ -1027,9 +1022,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == closingState.commitTx) closingState.claimMainDelayedOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimMain.tx)) assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == htlcTimeoutTx.tx) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == closingState.commitTx.txid) - closingState.claimMainDelayedOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.tx.txid)) - assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex == htlcTimeoutTx.input.outPoint.index) + alice2blockchain.expectWatchTxConfirmed(closingState.commitTx.txid) + closingState.claimMainDelayedOutputTx.foreach(claimMain => alice2blockchain.expectWatchTxConfirmed(claimMain.tx.txid)) + alice2blockchain.expectWatchOutputSpent(htlcTimeoutTx.input.outPoint) // the htlc transaction confirms, so we publish a 3rd-stage transaction alice ! WatchTxConfirmedTriggered(BlockHeight(2701), 1, closingState.commitTx) @@ -1038,7 +1033,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val beforeSecondRestart = alice.stateData.asInstanceOf[DATA_CLOSING] val claimHtlcTimeoutTx = beforeSecondRestart.localCommitPublished.get.claimHtlcDelayedTxs.head assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimHtlcTimeoutTx.tx) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimHtlcTimeoutTx.tx.txid) + alice2blockchain.expectWatchTxConfirmed(claimHtlcTimeoutTx.tx.txid) // simulate another node restart alice.setState(WAIT_FOR_INIT_INTERNAL, Nothing) @@ -1049,8 +1044,8 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // we should re-publish unconfirmed transactions closingState.claimMainDelayedOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimMain.tx)) assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimHtlcTimeoutTx.tx) - closingState.claimMainDelayedOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.tx.txid)) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimHtlcTimeoutTx.tx.txid) + closingState.claimMainDelayedOutputTx.foreach(claimMain => alice2blockchain.expectWatchTxConfirmed(claimMain.tx.txid)) + alice2blockchain.expectWatchTxConfirmed(claimHtlcTimeoutTx.tx.txid) // the main transaction confirms closingState.claimMainDelayedOutputTx.foreach(claimMain => alice ! WatchTxConfirmedTriggered(BlockHeight(2801), 5, claimMain.tx)) assert(alice.stateName == CLOSING) @@ -1093,17 +1088,11 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // The HTLC txs confirms, so we publish 3rd-stage txs. alice ! WatchTxConfirmedTriggered(BlockHeight(201), 0, htlcTimeoutTx) val claimHtlcTimeoutDelayedTx = alice2blockchain.expectMsgType[PublishFinalTx].tx - inside(alice2blockchain.expectMsgType[WatchTxConfirmed]) { w => - assert(w.txId == claimHtlcTimeoutDelayedTx.txid) - assert(w.delay_opt.map(_.parentTxId).contains(htlcTimeoutTx.txid)) - } + alice2blockchain.expectWatchTxConfirmed(claimHtlcTimeoutDelayedTx.txid, parentTxId = htlcTimeoutTx.txid) Transaction.correctlySpends(claimHtlcTimeoutDelayedTx, Seq(htlcTimeoutTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) alice ! WatchTxConfirmedTriggered(BlockHeight(201), 0, htlcSuccessTx) val claimHtlcSuccessDelayedTx = alice2blockchain.expectMsgType[PublishFinalTx].tx - inside(alice2blockchain.expectMsgType[WatchTxConfirmed]) { w => - assert(w.txId == claimHtlcSuccessDelayedTx.txid) - assert(w.delay_opt.map(_.parentTxId).contains(htlcSuccessTx.txid)) - } + alice2blockchain.expectWatchTxConfirmed(claimHtlcSuccessDelayedTx.txid, parentTxId = htlcSuccessTx.txid) Transaction.correctlySpends(claimHtlcSuccessDelayedTx, Seq(htlcSuccessTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) // We simulate a node restart after a feerate increase. @@ -1118,9 +1107,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == claimMainTx.txid) assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == claimHtlcTimeoutDelayedTx.txid) assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid == claimHtlcSuccessDelayedTx.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMainTx.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimHtlcTimeoutDelayedTx.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimHtlcSuccessDelayedTx.txid) + alice2blockchain.expectWatchTxConfirmed(claimMainTx.txid) + alice2blockchain.expectWatchTxConfirmed(claimHtlcTimeoutDelayedTx.txid) + alice2blockchain.expectWatchTxConfirmed(claimHtlcSuccessDelayedTx.txid) // We replay the HTLC fulfillment: nothing happens since we already published a 3rd-stage transaction. alice ! CMD_FULFILL_HTLC(incomingHtlc.id, preimage, commit = true) @@ -1424,7 +1413,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // Alice republishes the HTLC-success transaction, which then confirms. assert(alice2blockchain.expectMsgType[PublishReplaceableTx].input == publishHtlcSuccessTx.input) - inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(OutPoint(w.txId, w.outputIndex.toLong) == publishHtlcSuccessTx.input) } + alice2blockchain.expectWatchOutputSpent(publishHtlcSuccessTx.input) alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, claimHtlcSuccessTx) alice2blockchain.expectNoMessage(100 millis) alice2relayer.expectNoMessage(100 millis) @@ -1454,8 +1443,8 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val publishClaimHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishClaimHtlcTimeoutTx.tx.txInfo == htlcTimeoutTx) assert(publishClaimHtlcTimeoutTx.confirmationTarget == ConfirmationTarget.Absolute(htlca.cltvExpiry.blockHeight)) - closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.tx.txid)) - assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex == htlcTimeoutTx.input.outPoint.index) + closingState.claimMainOutputTx.foreach(claimMain => alice2blockchain.expectWatchTxConfirmed(claimMain.tx.txid)) + alice2blockchain.expectWatchOutputSpent(htlcTimeoutTx.input.outPoint) } private def testNextRemoteCommitTxConfirmed(f: FixtureParam, channelFeatures: ChannelFeatures): (Transaction, RemoteCommitPublished, Set[UpdateAddHtlc]) = { @@ -1608,10 +1597,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val htlcTx1 = alice2blockchain.expectMsgType[PublishReplaceableTx] val htlcTx2 = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(Set(htlcTx1.input, htlcTx2.input) == Set(claimHtlcTimeoutTx.input.outPoint, claimHtlcSuccessTx.input.outPoint)) - assert(Set( - alice2blockchain.expectMsgType[WatchOutputSpent], - alice2blockchain.expectMsgType[WatchOutputSpent], - ).map(w => OutPoint(w.txId, w.outputIndex.toLong)) == Set(htlcTx1.input, htlcTx2.input)) + alice2blockchain.expectWatchOutputsSpent(htlcTx1.input :: htlcTx2.input :: Nil) alice2blockchain.expectNoMessage(100 millis) alice ! WatchTxConfirmedTriggered(BlockHeight(0), 0, claimHtlcSuccessTx.tx) assert(alice.stateName == CLOSING) @@ -1644,9 +1630,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimMain.tx)) claimHtlcTimeoutTxs.foreach(claimHtlcTimeout => assert(alice2blockchain.expectMsgType[PublishReplaceableTx].tx.txInfo.tx == claimHtlcTimeout.tx)) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) - closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.tx.txid)) - claimHtlcTimeoutTxs.foreach(claimHtlcTimeout => assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex == claimHtlcTimeout.input.outPoint.index)) + alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) + closingState.claimMainOutputTx.foreach(claimMain => alice2blockchain.expectWatchTxConfirmed(claimMain.tx.txid)) + alice2blockchain.expectWatchOutputsSpent(claimHtlcTimeoutTxs.map(_.input.outPoint)) } private def testFutureRemoteCommitTxConfirmed(f: FixtureParam, channelFeatures: ChannelFeatures): Transaction = { @@ -1701,7 +1687,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(txPublished.tx == bobCommitTx) assert(txPublished.miningFee > 0.sat) // alice is funder, she pays the fee for the remote commit // bob's commit tx sends directly to alice's wallet - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) + alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].futureRemoteCommitPublished.isDefined) alice2blockchain.expectNoMessage(100 millis) // alice ignores the htlc-timeout @@ -1727,9 +1713,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // alice is able to claim its main output val claimMainTx = alice2blockchain.expectMsgType[PublishFinalTx].tx Transaction.correctlySpends(claimMainTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) + alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].futureRemoteCommitPublished.isDefined) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMainTx.txid) + alice2blockchain.expectWatchTxConfirmed(claimMainTx.txid) alice2blockchain.expectNoMessage(100 millis) // alice ignores the htlc-timeout // actual test starts here @@ -1750,7 +1736,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // bob's commit tx sends funds directly to our wallet - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) + alice2blockchain.expectWatchTxConfirmed(bobCommitTx.txid) } case class RevokedCloseFixture(bobRevokedTxs: Seq[LocalCommit], htlcsAlice: Seq[(UpdateAddHtlc, ByteVector32)], htlcsBob: Seq[(UpdateAddHtlc, ByteVector32)]) @@ -1859,14 +1845,14 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with } // alice watches confirmation for the outputs only her can claim - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobRevokedTx.txid) + alice2blockchain.expectWatchTxConfirmed(bobRevokedTx.txid) if (!channelFeatures.paysDirectlyToWallet) { - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == rvk.claimMainOutputTx.get.tx.txid) + alice2blockchain.expectWatchTxConfirmed(rvk.claimMainOutputTx.get.tx.txid) } // alice watches outputs that can be spent by both parties - val watchedOutpoints = Seq(alice2blockchain.expectMsgType[WatchOutputSpent], alice2blockchain.expectMsgType[WatchOutputSpent], alice2blockchain.expectMsgType[WatchOutputSpent]).map(_.outputIndex).toSet - assert(watchedOutpoints == (rvk.mainPenaltyTx.get :: rvk.htlcPenaltyTxs).map(_.input.outPoint.index).toSet) + alice2blockchain.expectWatchOutputSpent(rvk.mainPenaltyTx.get.input.outPoint) + alice2blockchain.expectWatchOutputsSpent(rvk.htlcPenaltyTxs.map(_.input.outPoint)) alice2blockchain.expectNoMessage(100 millis) (bobRevokedTx, rvk) @@ -1923,13 +1909,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with (mainPenalty +: (claimMain_opt.toList ++ htlcPenaltyTxs)).foreach(tx => Transaction.correctlySpends(tx, revokedTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) // alice watches confirmation for the outputs only her can claim - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == revokedTx.txid) - claimMain_opt.foreach(claimMain => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.txid)) + alice2blockchain.expectWatchTxConfirmed(revokedTx.txid) + claimMain_opt.foreach(claimMain => alice2blockchain.expectWatchTxConfirmed(claimMain.txid)) // alice watches outputs that can be spent by both parties - assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex == mainPenalty.txIn.head.outPoint.index) - val htlcOutpoints = (1 to htlcCount).map(_ => alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex).toSet - assert(htlcOutpoints == htlcPenaltyTxs.flatMap(_.txIn.map(_.outPoint.index)).toSet) + alice2blockchain.expectWatchOutputSpent(mainPenalty.txIn.head.outPoint) + alice2blockchain.expectWatchOutputsSpent(htlcPenaltyTxs.flatMap(_.txIn.map(_.outPoint))) alice2blockchain.expectNoMessage(100 millis) alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.last @@ -1971,10 +1956,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with rvk.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimMain.tx)) assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == rvk.mainPenaltyTx.get.tx) rvk.htlcPenaltyTxs.foreach(htlcPenalty => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == htlcPenalty.tx)) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobRevokedTx.txid) - rvk.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.tx.txid)) - assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex == rvk.mainPenaltyTx.get.input.outPoint.index) - rvk.htlcPenaltyTxs.foreach(htlcPenalty => assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex == htlcPenalty.input.outPoint.index)) + alice2blockchain.expectWatchTxConfirmed(bobRevokedTx.txid) + rvk.claimMainOutputTx.foreach(claimMain => alice2blockchain.expectWatchTxConfirmed(claimMain.tx.txid)) + alice2blockchain.expectWatchOutputSpent(rvk.mainPenaltyTx.get.input.outPoint) + alice2blockchain.expectWatchOutputsSpent(rvk.htlcPenaltyTxs.map(_.input.outPoint)) } test("recv INPUT_RESTORED (one revoked tx)") { f => @@ -2014,11 +1999,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // alice publishes the penalty txs and watches outputs val claimTxsCount = if (channelFeatures.paysDirectlyToWallet) 5 else 6 // 2 main outputs and 4 htlcs (1 to claimTxsCount).foreach(_ => alice2blockchain.expectMsgType[PublishTx]) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == rvk.commitTx.txid) + alice2blockchain.expectWatchTxConfirmed(rvk.commitTx.txid) if (!channelFeatures.paysDirectlyToWallet) { - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == rvk.claimMainOutputTx.get.tx.txid) + alice2blockchain.expectWatchTxConfirmed(rvk.claimMainOutputTx.get.tx.txid) } - (1 to 5).foreach(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) // main output penalty and 4 htlc penalties + alice2blockchain.expectWatchOutputSpent(rvk.mainPenaltyTx.get.input.outPoint) + alice2blockchain.expectWatchOutputsSpent(rvk.htlcPenaltyTxs.map(_.input.outPoint)) alice2blockchain.expectNoMessage(100 millis) // the revoked commit and main penalty transactions confirm @@ -2033,20 +2019,20 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val bobHtlcSuccessTx1 = bobRevokedCommit.htlcTxsAndRemoteSigs.collectFirst { case HtlcTxAndRemoteSig(txInfo: HtlcSuccessTx, _) if txInfo.htlcId == fulfilledHtlc.id => txInfo }.get assert(bobHtlcSuccessTx1.paymentHash == fulfilledHtlc.paymentHash) alice ! WatchOutputSpentTriggered(bobHtlcSuccessTx1.amountIn, bobHtlcSuccessTx1.tx) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobHtlcSuccessTx1.tx.txid) + alice2blockchain.expectWatchTxConfirmed(bobHtlcSuccessTx1.tx.txid) // bob publishes one of his HTLC-timeout transactions val (failedHtlc, _) = revokedCloseFixture.htlcsBob.last val bobHtlcTimeoutTx = bobRevokedCommit.htlcTxsAndRemoteSigs.collectFirst { case HtlcTxAndRemoteSig(txInfo: HtlcTimeoutTx, _) if txInfo.htlcId == failedHtlc.id => txInfo }.get assert(bobHtlcTimeoutTx.paymentHash == failedHtlc.paymentHash) alice ! WatchOutputSpentTriggered(bobHtlcTimeoutTx.amountIn, bobHtlcTimeoutTx.tx) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobHtlcTimeoutTx.tx.txid) + alice2blockchain.expectWatchTxConfirmed(bobHtlcTimeoutTx.tx.txid) // bob RBFs his htlc-success with a different transaction val bobHtlcSuccessTx2 = bobHtlcSuccessTx1.tx.copy(txIn = TxIn(OutPoint(randomTxId(), 0), Nil, 0) +: bobHtlcSuccessTx1.tx.txIn) assert(bobHtlcSuccessTx2.txid !== bobHtlcSuccessTx1.tx.txid) alice ! WatchOutputSpentTriggered(bobHtlcSuccessTx1.amountIn, bobHtlcSuccessTx2) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobHtlcSuccessTx2.txid) + alice2blockchain.expectWatchTxConfirmed(bobHtlcSuccessTx2.txid) // bob's HTLC-timeout confirms: alice reacts by publishing a penalty tx alice ! WatchTxConfirmedTriggered(BlockHeight(115), 0, bobHtlcTimeoutTx.tx) @@ -2054,10 +2040,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val claimHtlcTimeoutPenalty = alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head.claimHtlcDelayedPenaltyTxs.head Transaction.correctlySpends(claimHtlcTimeoutPenalty.tx, bobHtlcTimeoutTx.tx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimHtlcTimeoutPenalty.tx) - inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => - assert(w.txId == bobHtlcTimeoutTx.tx.txid) - assert(w.outputIndex == claimHtlcTimeoutPenalty.input.outPoint.index) - } + alice2blockchain.expectWatchOutputSpent(claimHtlcTimeoutPenalty.input.outPoint) alice2blockchain.expectNoMessage(100 millis) // bob's htlc-success RBF confirms: alice reacts by publishing a penalty tx @@ -2066,10 +2049,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val claimHtlcSuccessPenalty = alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head.claimHtlcDelayedPenaltyTxs.last Transaction.correctlySpends(claimHtlcSuccessPenalty.tx, bobHtlcSuccessTx2 :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimHtlcSuccessPenalty.tx) - inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => - assert(w.txId == bobHtlcSuccessTx2.txid) - assert(w.outputIndex == claimHtlcSuccessPenalty.input.outPoint.index) - } + alice2blockchain.expectWatchOutputSpent(claimHtlcSuccessPenalty.input.outPoint) alice2blockchain.expectNoMessage(100 millis) // transactions confirm: alice can move to the closed state @@ -2114,9 +2094,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // alice publishes the penalty txs and watches outputs (1 to 6).foreach(_ => alice2blockchain.expectMsgType[PublishTx]) // 2 main outputs and 4 htlcs - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == rvk.commitTx.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == rvk.claimMainOutputTx.get.tx.txid) - (1 to 5).foreach(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) // main output penalty and 4 htlc penalties + alice2blockchain.expectWatchTxConfirmed(rvk.commitTx.txid) + alice2blockchain.expectWatchTxConfirmed(rvk.claimMainOutputTx.get.tx.txid) + alice2blockchain.expectWatchOutputSpent(rvk.mainPenaltyTx.get.input.outPoint) + alice2blockchain.expectWatchOutputsSpent(rvk.htlcPenaltyTxs.map(_.input.outPoint)) alice2blockchain.expectNoMessage(100 millis) // bob claims multiple htlc outputs in a single transaction (this is possible with anchor outputs because signatures @@ -2151,12 +2132,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // alice reacts by publishing penalty txs that spend bob's htlc transaction alice ! WatchOutputSpentTriggered(bobHtlcTxs(0).amountIn, bobHtlcTx) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobHtlcTx.txid) + alice2blockchain.expectWatchTxConfirmed(bobHtlcTx.txid) alice ! WatchTxConfirmedTriggered(BlockHeight(129), 7, bobHtlcTx) awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head.claimHtlcDelayedPenaltyTxs.size == 4) val claimHtlcDelayedPenaltyTxs = alice.stateData.asInstanceOf[DATA_CLOSING].revokedCommitPublished.head.claimHtlcDelayedPenaltyTxs - val spentOutpoints = Set(OutPoint(bobHtlcTx, 1), OutPoint(bobHtlcTx, 2), OutPoint(bobHtlcTx, 3), OutPoint(bobHtlcTx, 4)) - assert(claimHtlcDelayedPenaltyTxs.map(_.input.outPoint).toSet == spentOutpoints) + val spentOutpoints = Seq(OutPoint(bobHtlcTx, 1), OutPoint(bobHtlcTx, 2), OutPoint(bobHtlcTx, 3), OutPoint(bobHtlcTx, 4)) + assert(claimHtlcDelayedPenaltyTxs.map(_.input.outPoint).toSet == spentOutpoints.toSet) claimHtlcDelayedPenaltyTxs.foreach(claimHtlcPenalty => Transaction.correctlySpends(claimHtlcPenalty.tx, bobHtlcTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) val publishedPenaltyTxs = Set( alice2blockchain.expectMsgType[PublishFinalTx], @@ -2165,13 +2146,8 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectMsgType[PublishFinalTx] ) assert(publishedPenaltyTxs.map(_.tx) == claimHtlcDelayedPenaltyTxs.map(_.tx).toSet) - val watchedOutpoints = Seq( - alice2blockchain.expectMsgType[WatchOutputSpent], - alice2blockchain.expectMsgType[WatchOutputSpent], - alice2blockchain.expectMsgType[WatchOutputSpent], - alice2blockchain.expectMsgType[WatchOutputSpent] - ).map(w => OutPoint(w.txId, w.outputIndex)).toSet - assert(watchedOutpoints == spentOutpoints) + assert(publishedPenaltyTxs.map(_.input) == spentOutpoints.toSet) + alice2blockchain.expectWatchOutputsSpent(spentOutpoints) alice2blockchain.expectNoMessage(100 millis) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/testutils/PimpTestProbe.scala b/eclair-core/src/test/scala/fr/acinq/eclair/testutils/PimpTestProbe.scala index be46dc636a..b3bd4e6d68 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/testutils/PimpTestProbe.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/testutils/PimpTestProbe.scala @@ -1,9 +1,9 @@ package fr.acinq.eclair.testutils import akka.testkit.TestProbe -import fr.acinq.bitcoin.scalacompat.{Satoshi, TxId} +import fr.acinq.bitcoin.scalacompat.{OutPoint, Satoshi, TxId} import fr.acinq.eclair.MilliSatoshi -import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchFundingConfirmed, WatchFundingSpent, WatchPublished, WatchTxConfirmed} +import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.channel.AvailableBalanceChanged import org.scalatest.Assertions @@ -31,9 +31,25 @@ case class PimpTestProbe(probe: TestProbe) extends Assertions { def expectWatchFundingConfirmed(txid: TxId): WatchFundingConfirmed = expectMsgTypeHaving[WatchFundingConfirmed](w => assert(w.txId == txid, "txid")) + def expectWatchOutputSpent(outpoint: OutPoint): WatchOutputSpent = + expectMsgTypeHaving[WatchOutputSpent](w => assert(OutPoint(w.txId, w.outputIndex.toLong) == outpoint, "outpoint")) + + def expectWatchOutputsSpent(outpoints: Seq[OutPoint]): Seq[WatchOutputSpent] = { + val watches = outpoints.map(_ => probe.expectMsgType[WatchOutputSpent]) + val watched = watches.map(w => OutPoint(w.txId, w.outputIndex.toLong)) + assert(watched.toSet == outpoints.toSet) + watches + } + def expectWatchTxConfirmed(txid: TxId): WatchTxConfirmed = expectMsgTypeHaving[WatchTxConfirmed](w => assert(w.txId == txid, "txid")) + def expectWatchTxConfirmed(txid: TxId, parentTxId: TxId): WatchTxConfirmed = + expectMsgTypeHaving[WatchTxConfirmed](w => { + assert(w.txId == txid, "txid") + assert(w.delay_opt.map(_.parentTxId).contains(parentTxId)) + }) + def expectWatchPublished(txid: TxId): WatchPublished = expectMsgTypeHaving[WatchPublished](w => assert(w.txId == txid, "txid"))