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..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 @@ -22,15 +22,16 @@ import akka.testkit.{TestFSMRef, TestProbe} 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.Outcome @@ -447,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] @@ -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 - 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 + val mainDelayedTx = bob2blockchain.expectMsgType[PublishFinalTx] + assert(mainDelayedTx.desc == "local-main-delayed") + 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) 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..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 @@ -28,10 +28,11 @@ 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.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} @@ -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) - assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == initialCommitTx.txid) - bob2blockchain.expectMsgType[WatchTxConfirmed] // main delayed - bob2blockchain.expectMsgType[WatchOutputSpent] // htlc - bob2blockchain.expectNoMessage(500 millis) + val mainDelayedTx = bob2blockchain.expectMsgType[PublishFinalTx] + assert(mainDelayedTx.desc == "local-main-delayed") + 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) } 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..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) @@ -937,6 +932,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) + 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)) + alice2blockchain.expectWatchTxConfirmed(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) + alice2blockchain.expectWatchTxConfirmed(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._ @@ -960,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) @@ -971,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) @@ -982,8 +1044,14 @@ 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) + // 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,31 +1076,23 @@ 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) 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. @@ -1047,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) @@ -1302,52 +1362,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) + alice2blockchain.expectWatchOutputSpent(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 => @@ -1373,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]) = { @@ -1472,15 +1542,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 +1561,51 @@ 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)) + alice2blockchain.expectWatchOutputsSpent(htlcTx1.input :: htlcTx2.input :: Nil) + 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 => @@ -1554,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 = { @@ -1611,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 @@ -1637,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 @@ -1660,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)]) @@ -1769,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) @@ -1833,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 @@ -1881,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 => @@ -1924,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 @@ -1943,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) @@ -1964,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 @@ -1976,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 @@ -2024,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 @@ -2061,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], @@ -2075,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"))