diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala index 44cecb48c0..e8e4bceb98 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelData.scala @@ -19,7 +19,7 @@ package fr.acinq.eclair.channel import akka.actor.{ActorRef, PossiblyHarmful, typed} import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, DeterministicWallet, OutPoint, Satoshi, SatoshiLong, Transaction, TxId, TxOut} -import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw} +import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw, OnChainFeeConf} import fr.acinq.eclair.channel.LocalFundingStatus.DualFundedUnconfirmedFundingTx import fr.acinq.eclair.channel.fund.InteractiveTxBuilder._ import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxSigningSession} @@ -313,7 +313,7 @@ sealed trait CommitPublished { def commitTx: Transaction /** Map of relevant outpoints that have been spent and the confirmed transaction that spends them. */ def irrevocablySpent: Map[OutPoint, Transaction] - + /** Returns true if the commitment transaction is confirmed. */ def isConfirmed: Boolean = { // NB: if multiple transactions end up in the same block, the first confirmation we receive may not be the commit tx. // However if the confirmed tx spends from the commit tx, we know that the commit tx is already confirmed and we know @@ -329,11 +329,26 @@ sealed trait CommitPublished { * @param htlcTxs txs claiming HTLCs. There will be one entry for each pending HTLC. The value will be * None only for incoming HTLCs for which we don't have the preimage (we can't claim them yet). * @param claimHtlcDelayedTxs 3rd-stage txs (spending the output of HTLC txs). - * @param claimAnchorTxs txs spending anchor outputs to bump the feerate of the commitment tx (if applicable). - * We currently only claim our local anchor, but it would be nice to claim both when it - * is economical to do so to avoid polluting the utxo set. + * @param claimAnchorTxs txs spending our anchor output to bump the feerate of the commitment tx (if applicable). */ case class LocalCommitPublished(commitTx: Transaction, claimMainDelayedOutputTx: Option[ClaimLocalDelayedOutputTx], htlcTxs: Map[OutPoint, Option[HtlcTx]], claimHtlcDelayedTxs: List[HtlcDelayedTx], claimAnchorTxs: List[ClaimAnchorOutputTx], irrevocablySpent: Map[OutPoint, Transaction]) extends CommitPublished { + // We previously used a list of anchor transactions because we included the confirmation target, but that's obsolete and should be overridden on updates. + val claimAnchorTx_opt: Option[ClaimAnchorOutputTx] = claimAnchorTxs.headOption + + /** Compute the confirmation target that should be used to get the [[commitTx]] confirmed. */ + def confirmationTarget(onChainFeeConf: OnChainFeeConf): Option[ConfirmationTarget] = { + if (isConfirmed) { + None + } else { + htlcTxs.values.flatten.map(_.htlcExpiry.blockHeight).minOption match { + // If there are pending HTLCs, we must get the commit tx confirmed before they timeout. + case Some(htlcExpiry) => Some(ConfirmationTarget.Absolute(htlcExpiry)) + // Otherwise, we don't have funds at risk, so we can aim for a slower confirmation. + case None => Some(ConfirmationTarget.Priority(onChainFeeConf.feeTargets.closing)) + } + } + } + /** * A local commit is considered done when: * - all commitment tx outputs that we can spend have been spent and confirmed (even if the spending tx was not ours) @@ -363,11 +378,26 @@ case class LocalCommitPublished(commitTx: Transaction, claimMainDelayedOutputTx: * @param claimMainOutputTx tx claiming our main output (if we have one). * @param claimHtlcTxs txs claiming HTLCs. There will be one entry for each pending HTLC. The value will be None * only for incoming HTLCs for which we don't have the preimage (we can't claim them yet). - * @param claimAnchorTxs txs spending anchor outputs to bump the feerate of the commitment tx (if applicable). - * We currently only claim our local anchor, but it would be nice to claim both when it is - * economical to do so to avoid polluting the utxo set. + * @param claimAnchorTxs txs spending our anchor output to bump the feerate of the commitment tx (if applicable). */ case class RemoteCommitPublished(commitTx: Transaction, claimMainOutputTx: Option[ClaimRemoteCommitMainOutputTx], claimHtlcTxs: Map[OutPoint, Option[ClaimHtlcTx]], claimAnchorTxs: List[ClaimAnchorOutputTx], irrevocablySpent: Map[OutPoint, Transaction]) extends CommitPublished { + // We previously used a list of anchor transactions because we included the confirmation target, but that's obsolete and should be overridden on updates. + val claimAnchorTx_opt: Option[ClaimAnchorOutputTx] = claimAnchorTxs.headOption + + /** Compute the confirmation target that should be used to get the [[commitTx]] confirmed. */ + def confirmationTarget(onChainFeeConf: OnChainFeeConf): Option[ConfirmationTarget] = { + if (isConfirmed) { + None + } else { + claimHtlcTxs.values.flatten.map(_.htlcExpiry.blockHeight).minOption match { + // If there are pending HTLCs, we must get the commit tx confirmed before they timeout. + case Some(htlcExpiry) => Some(ConfirmationTarget.Absolute(htlcExpiry)) + // Otherwise, we don't have funds at risk, so we can aim for a slower confirmation. + case None => Some(ConfirmationTarget.Priority(onChainFeeConf.feeTargets.closing)) + } + } + } + /** * A remote commit is considered done when all commitment tx outputs that we can spend have been spent and confirmed * (even if the spending tx was not ours). diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index 721612c515..3cf51d14a6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -237,6 +237,19 @@ case class RemoteCommit(index: Long, spec: CommitmentSpec, txid: TxId, remotePer /** We have the next remote commit when we've sent our commit_sig but haven't yet received their revoke_and_ack. */ case class NextRemoteCommit(sig: CommitSig, commit: RemoteCommit) +/** + * If we ignore revoked commitments, there can be at most three concurrent commitment transactions during a force-close: + * - the local commitment + * - the remote commitment + * - the next remote commitment, if we sent commit_sig but haven't yet received revoke_and_ack + */ +case class CommitTxIds(localCommitTxId: TxId, remoteCommitTxId: TxId, nextRemoteCommitTxId_opt: Option[TxId]) { + val txIds: Set[TxId] = nextRemoteCommitTxId_opt match { + case Some(nextRemoteCommitTxId) => Set(localCommitTxId, remoteCommitTxId, nextRemoteCommitTxId) + case None => Set(localCommitTxId, remoteCommitTxId) + } +} + /** * A minimal commitment for a given funding tx. * @@ -255,6 +268,7 @@ case class Commitment(fundingTxIndex: Long, localCommit: LocalCommit, remoteCommit: RemoteCommit, nextRemoteCommit_opt: Option[NextRemoteCommit]) { val commitInput: InputInfo = localCommit.commitTxAndRemoteSig.commitTx.input val fundingTxId: TxId = commitInput.outPoint.txid + val commitTxIds: CommitTxIds = CommitTxIds(localCommit.commitTxAndRemoteSig.commitTx.tx.txid, remoteCommit.txid, nextRemoteCommit_opt.map(_.commit.txid)) val capacity: Satoshi = commitInput.txOut.amount /** Once the funding transaction is confirmed, short_channel_id matching this transaction. */ val shortChannelId_opt: Option[RealShortChannelId] = localFundingStatus match { @@ -735,6 +749,7 @@ case class FullCommitment(params: ChannelParams, changes: CommitmentChanges, val remoteParams: RemoteParams = params.remoteParams val commitInput: InputInfo = localCommit.commitTxAndRemoteSig.commitTx.input val fundingTxId: TxId = commitInput.outPoint.txid + val commitTxIds: CommitTxIds = CommitTxIds(localCommit.commitTxAndRemoteSig.commitTx.tx.txid, remoteCommit.txid, nextRemoteCommit_opt.map(_.commit.txid)) val capacity: Satoshi = commitInput.txOut.amount val commitment: Commitment = Commitment(fundingTxIndex, firstRemoteCommitIndex, remoteFundingPubKey, localFundingStatus, remoteFundingStatus, localCommit, remoteCommit, nextRemoteCommit_opt) 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 65afc6e37f..144d277e01 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 @@ -850,25 +850,6 @@ object Helpers { if (localPaysCommitTxFees) commitInput.txOut.amount - commitTx.txOut.map(_.amount).sum else 0 sat } - /** - * This function checks if the proposed confirmation target is more aggressive than whatever confirmation target - * we previously had. Note that absolute targets are always considered more aggressive than relative targets. - */ - private def shouldUpdateAnchorTxs(anchorTxs: List[ClaimAnchorOutputTx], confirmationTarget: ConfirmationTarget): Boolean = { - anchorTxs - .collect { case tx: ClaimAnchorOutputTx => tx.confirmationTarget } - .forall { - case ConfirmationTarget.Absolute(current) => confirmationTarget match { - case ConfirmationTarget.Absolute(proposed) => proposed < current - case _: ConfirmationTarget.Priority => false - } - case ConfirmationTarget.Priority(current) => confirmationTarget match { - case _: ConfirmationTarget.Absolute => true - case ConfirmationTarget.Priority(proposed) => current < proposed - } - } - } - object LocalClose { /** @@ -900,23 +881,17 @@ object Helpers { ) val spendAnchors = htlcTxs.nonEmpty || onChainFeeConf.spendAnchorWithoutHtlcs if (spendAnchors) { - // If we don't have pending HTLCs, we don't have funds at risk, so we can aim for a slower confirmation. - val confirmCommitBefore = htlcTxs.values.flatten.map(htlcTx => ConfirmationTarget.Absolute(htlcTx.htlcExpiry.blockHeight)).minByOption(_.confirmBefore).getOrElse(ConfirmationTarget.Priority(onChainFeeConf.feeTargets.closing)) - claimAnchors(fundingKey, commitmentKeys, lcp, confirmCommitBefore, commitment.params.commitmentFormat) + claimAnchors(fundingKey, commitmentKeys, lcp, commitment.params.commitmentFormat) } else { lcp } } - def claimAnchors(fundingKey: PrivateKey, commitKeys: LocalCommitmentKeys, lcp: LocalCommitPublished, confirmationTarget: ConfirmationTarget, commitmentFormat: CommitmentFormat)(implicit log: LoggingAdapter): LocalCommitPublished = { - if (shouldUpdateAnchorTxs(lcp.claimAnchorTxs, confirmationTarget)) { - val claimAnchorTx = withTxGenerationLog("local-anchor") { - ClaimAnchorOutputTx.createUnsignedTx(fundingKey, commitKeys.publicKeys, lcp.commitTx, confirmationTarget, commitmentFormat) - } - lcp.copy(claimAnchorTxs = claimAnchorTx.toList) - } else { - lcp + def claimAnchors(fundingKey: PrivateKey, commitKeys: LocalCommitmentKeys, lcp: LocalCommitPublished, commitmentFormat: CommitmentFormat)(implicit log: LoggingAdapter): LocalCommitPublished = { + val claimAnchorTx = withTxGenerationLog("local-anchor") { + ClaimAnchorOutputTx.createUnsignedTx(fundingKey, commitKeys.publicKeys, lcp.commitTx, commitmentFormat) } + lcp.copy(claimAnchorTxs = claimAnchorTx.toList) } /** @@ -1015,23 +990,17 @@ object Helpers { ) val spendAnchors = htlcTxs.nonEmpty || onChainFeeConf.spendAnchorWithoutHtlcs if (spendAnchors) { - // If we don't have pending HTLCs, we don't have funds at risk, so we use the normal closing priority. - val confirmCommitBefore = htlcTxs.values.flatten.map(htlcTx => ConfirmationTarget.Absolute(htlcTx.htlcExpiry.blockHeight)).minByOption(_.confirmBefore).getOrElse(ConfirmationTarget.Priority(onChainFeeConf.feeTargets.closing)) - claimAnchors(fundingKey, commitKeys, rcp, confirmCommitBefore, commitment.params.commitmentFormat) + claimAnchors(fundingKey, commitKeys, rcp, commitment.params.commitmentFormat) } else { rcp } } - def claimAnchors(fundingKey: PrivateKey, commitKeys: RemoteCommitmentKeys, rcp: RemoteCommitPublished, confirmationTarget: ConfirmationTarget, commitmentFormat: CommitmentFormat)(implicit log: LoggingAdapter): RemoteCommitPublished = { - if (shouldUpdateAnchorTxs(rcp.claimAnchorTxs, confirmationTarget)) { - val claimAnchorTx = withTxGenerationLog("remote-anchor") { - ClaimAnchorOutputTx.createUnsignedTx(fundingKey, commitKeys.publicKeys, rcp.commitTx, confirmationTarget, commitmentFormat) - } - rcp.copy(claimAnchorTxs = claimAnchorTx.toList) - } else { - rcp + def claimAnchors(fundingKey: PrivateKey, commitKeys: RemoteCommitmentKeys, rcp: RemoteCommitPublished, commitmentFormat: CommitmentFormat)(implicit log: LoggingAdapter): RemoteCommitPublished = { + val claimAnchorTx = withTxGenerationLog("remote-anchor") { + ClaimAnchorOutputTx.createUnsignedTx(fundingKey, commitKeys.publicKeys, rcp.commitTx, commitmentFormat) } + rcp.copy(claimAnchorTxs = claimAnchorTx.toList) } /** 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 44241283ba..0d59f8e626 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 @@ -38,8 +38,8 @@ import fr.acinq.eclair.channel.Monitoring.{Metrics, Tags} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fund.InteractiveTxBuilder._ import fr.acinq.eclair.channel.fund.{InteractiveTxBuilder, InteractiveTxFunder, InteractiveTxSigningSession} -import fr.acinq.eclair.channel.publish.TxPublisher import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx, SetChannelId} +import fr.acinq.eclair.channel.publish.{ReplaceableLocalCommitAnchor, ReplaceableRemoteCommitAnchor, TxPublisher} import fr.acinq.eclair.crypto.keymanager.ChannelKeys import fr.acinq.eclair.db.DbEventHandler.ChannelEvent.EventType import fr.acinq.eclair.db.PendingCommandsDb @@ -2168,27 +2168,36 @@ class Channel(val nodeParams: NodeParams, val channelKeys: ChannelKeys, val wall case commitmentFormat: Transactions.AnchorOutputsCommitmentFormat => val commitment = d.commitments.latest val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) - val lcp1 = d.localCommitPublished.map(lcp => Closing.LocalClose.claimAnchors(fundingKey, commitment.localKeys(channelKeys), lcp, c.confirmationTarget, commitmentFormat)) - val rcp1 = d.remoteCommitPublished.map(rcp => Closing.RemoteClose.claimAnchors(fundingKey, commitment.remoteKeys(channelKeys, commitment.remoteCommit.remotePerCommitmentPoint), rcp, c.confirmationTarget, commitmentFormat)) - val nrcp1 = d.nextRemoteCommitPublished.map(nrcp => Closing.RemoteClose.claimAnchors(fundingKey, commitment.remoteKeys(channelKeys, commitment.nextRemoteCommit_opt.get.commit.remotePerCommitmentPoint), nrcp, c.confirmationTarget, commitmentFormat)) + val localAnchor_opt = for { + lcp <- d.localCommitPublished + commitKeys = commitment.localKeys(channelKeys) + anchorTx <- Closing.LocalClose.claimAnchors(fundingKey, commitKeys, lcp, commitmentFormat).claimAnchorTx_opt + } yield PublishReplaceableTx(ReplaceableLocalCommitAnchor(anchorTx, fundingKey, commitKeys, lcp.commitTx, commitment), c.confirmationTarget) + val remoteAnchor_opt = for { + rcp <- d.remoteCommitPublished + commitKeys = commitment.remoteKeys(channelKeys, commitment.remoteCommit.remotePerCommitmentPoint) + anchorTx <- Closing.RemoteClose.claimAnchors(fundingKey, commitKeys, rcp, commitmentFormat).claimAnchorTx_opt + } yield PublishReplaceableTx(ReplaceableRemoteCommitAnchor(anchorTx, fundingKey, commitKeys, rcp.commitTx, commitment), c.confirmationTarget) + val nextRemoteAnchor_opt = for { + nrcp <- d.nextRemoteCommitPublished + commitKeys = commitment.remoteKeys(channelKeys, commitment.nextRemoteCommit_opt.get.commit.remotePerCommitmentPoint) + anchorTx <- Closing.RemoteClose.claimAnchors(fundingKey, commitKeys, nrcp, commitmentFormat).claimAnchorTx_opt + } yield PublishReplaceableTx(ReplaceableRemoteCommitAnchor(anchorTx, fundingKey, commitKeys, nrcp.commitTx, commitment), c.confirmationTarget) // We favor the remote commitment(s) because they're more interesting than the local commitment (no CSV delays). - if (rcp1.nonEmpty) { - rcp1.foreach(rcp => rcp.claimAnchorTxs.foreach { tx => txPublisher ! PublishReplaceableTx(tx, channelKeys, d.commitments.latest, rcp.commitTx, tx.confirmationTarget) }) + if (remoteAnchor_opt.nonEmpty) { + remoteAnchor_opt.foreach { publishTx => txPublisher ! publishTx } c.replyTo ! RES_SUCCESS(c, d.channelId) - stay() using d.copy(remoteCommitPublished = rcp1) storing() - } else if (nrcp1.nonEmpty) { - nrcp1.foreach(rcp => rcp.claimAnchorTxs.foreach { tx => txPublisher ! PublishReplaceableTx(tx, channelKeys, d.commitments.latest, rcp.commitTx, tx.confirmationTarget) }) + } else if (nextRemoteAnchor_opt.nonEmpty) { + nextRemoteAnchor_opt.foreach { publishTx => txPublisher ! publishTx } c.replyTo ! RES_SUCCESS(c, d.channelId) - stay() using d.copy(nextRemoteCommitPublished = nrcp1) storing() - } else if (lcp1.nonEmpty) { - lcp1.foreach(lcp => lcp.claimAnchorTxs.foreach { tx => txPublisher ! PublishReplaceableTx(tx, channelKeys, d.commitments.latest, lcp.commitTx, tx.confirmationTarget) }) + } else if (localAnchor_opt.nonEmpty) { + localAnchor_opt.foreach { publishTx => txPublisher ! publishTx } c.replyTo ! RES_SUCCESS(c, d.channelId) - stay() using d.copy(localCommitPublished = lcp1) storing() } else { log.warning("cannot bump force-close fees, local or remote commit not published") c.replyTo ! RES_FAILURE(c, CommandUnavailableInThisState(d.channelId, "rbf-force-close", stateName)) - stay() } + stay() case _ => log.warning("cannot bump force-close fees, channel is not using anchor outputs") c.replyTo ! RES_FAILURE(c, CommandUnavailableInThisState(d.channelId, "rbf-force-close", stateName)) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala index b7c84857ff..e7cfe24ea3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ErrorHandlers.scala @@ -18,7 +18,7 @@ package fr.acinq.eclair.channel.fsm import akka.actor.typed.scaladsl.adapter.actorRefAdapter import akka.actor.{ActorRef, FSM} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, SatoshiLong, Transaction, TxId} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, OutPoint, SatoshiLong, Transaction, TxId} import fr.acinq.eclair.NotificationsLogger import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{RelativeDelay, WatchOutputSpent, WatchTxConfirmed} @@ -27,9 +27,11 @@ import fr.acinq.eclair.channel.Helpers.Closing import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel.UnhandledExceptionStrategy import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx, PublishTx} +import fr.acinq.eclair.channel.publish._ +import fr.acinq.eclair.crypto.keymanager.{LocalCommitmentKeys, RemoteCommitmentKeys} import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.transactions.Transactions.ClosingTx -import fr.acinq.eclair.wire.protocol.{AcceptChannel, ChannelReestablish, Error, OpenChannel, Warning} +import fr.acinq.eclair.transactions.Transactions._ +import fr.acinq.eclair.wire.protocol.{AcceptChannel, ChannelReestablish, Error, OpenChannel, UpdateFulfillHtlc, Warning} import java.sql.SQLException @@ -229,15 +231,20 @@ trait ErrorHandlers extends CommonHandlers { def doPublish(localCommitPublished: LocalCommitPublished, commitment: FullCommitment): Unit = { import localCommitPublished._ + val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) + val commitKeys = commitment.localKeys(channelKeys) val localPaysCommitTxFees = commitment.localParams.paysCommitTxFees val publishQueue = commitment.params.commitmentFormat match { case Transactions.DefaultCommitmentFormat => - val redeemableHtlcTxs = htlcTxs.values.flatten.map(tx => PublishFinalTx(tx, tx.fee, Some(commitTx.txid))) - List(PublishFinalTx(commitTx, commitment.commitInput.outPoint, commitment.capacity, "commit-tx", Closing.commitTxFee(commitment.commitInput, commitTx, localPaysCommitTxFees), None)) ++ (claimMainDelayedOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)) ++ redeemableHtlcTxs ++ claimHtlcDelayedTxs.map(tx => PublishFinalTx(tx, tx.fee, None))) + val htlcTxs = redeemableHtlcTxs(commitTx, commitKeys, commitment) + List(PublishFinalTx(commitTx, commitment.commitInput.outPoint, commitment.capacity, "commit-tx", Closing.commitTxFee(commitment.commitInput, commitTx, localPaysCommitTxFees), None)) ++ (claimMainDelayedOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)) ++ htlcTxs ++ claimHtlcDelayedTxs.map(tx => PublishFinalTx(tx, tx.fee, None))) case _: Transactions.AnchorOutputsCommitmentFormat => - val redeemableHtlcTxs = htlcTxs.values.flatten.map(tx => PublishReplaceableTx(tx, channelKeys, commitment, commitTx, ConfirmationTarget.Absolute(tx.htlcExpiry.blockHeight))) - val claimLocalAnchor = claimAnchorTxs.collect { case tx: Transactions.ClaimAnchorOutputTx if !localCommitPublished.isConfirmed => PublishReplaceableTx(tx, channelKeys, commitment, commitTx, tx.confirmationTarget) } - List(PublishFinalTx(commitTx, commitment.commitInput.outPoint, commitment.capacity, "commit-tx", Closing.commitTxFee(commitment.commitInput, commitTx, localPaysCommitTxFees), None)) ++ claimLocalAnchor ++ claimMainDelayedOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)) ++ redeemableHtlcTxs ++ claimHtlcDelayedTxs.map(tx => PublishFinalTx(tx, tx.fee, None)) + val claimAnchor = for { + confirmationTarget <- localCommitPublished.confirmationTarget(nodeParams.onChainFeeConf) + anchorTx <- claimAnchorTx_opt + } yield PublishReplaceableTx(ReplaceableLocalCommitAnchor(anchorTx, fundingKey, commitKeys, commitTx, commitment), confirmationTarget) + val htlcTxs = redeemableHtlcTxs(commitTx, commitKeys, commitment) + List(PublishFinalTx(commitTx, commitment.commitInput.outPoint, commitment.capacity, "commit-tx", Closing.commitTxFee(commitment.commitInput, commitTx, localPaysCommitTxFees), None)) ++ claimAnchor ++ claimMainDelayedOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)) ++ htlcTxs ++ claimHtlcDelayedTxs.map(tx => PublishFinalTx(tx, tx.fee, None)) } publishIfNeeded(publishQueue, irrevocablySpent) @@ -252,10 +259,38 @@ trait ErrorHandlers extends CommonHandlers { // We watch outputs of the commitment tx that both parties may spend. // We also watch our local anchor: this ensures that we will correctly detect when it's confirmed and count its fees // in the audit DB, even if we restart before confirmation. - val watchSpentQueue = htlcTxs.keys ++ claimAnchorTxs.collect { case tx: Transactions.ClaimAnchorOutputTx if !localCommitPublished.isConfirmed => tx.input.outPoint } + val watchSpentQueue = htlcTxs.keys.toSeq ++ claimAnchorTx_opt.collect { case tx if !localCommitPublished.isConfirmed => tx.input.outPoint } watchSpentIfNeeded(commitTx, watchSpentQueue, irrevocablySpent) } + private def redeemableHtlcTxs(commitTx: Transaction, commitKeys: LocalCommitmentKeys, commitment: FullCommitment): Iterable[PublishTx] = { + val preimages = (commitment.changes.localChanges.all ++ commitment.changes.remoteChanges.all).collect { + case fulfill: UpdateFulfillHtlc => Crypto.sha256(fulfill.paymentPreimage) -> fulfill.paymentPreimage + }.toMap + commitment.localCommit.htlcTxsAndRemoteSigs.flatMap { + case HtlcTxAndRemoteSig(htlcTx, remoteSig) => + val preimage_opt = preimages.get(htlcTx.paymentHash) + commitment.params.commitmentFormat match { + case Transactions.DefaultCommitmentFormat => + val localSig = htlcTx.sign(commitKeys, commitment.params.commitmentFormat, extraUtxos = Map.empty) + val signedTx_opt = (htlcTx, preimage_opt) match { + case (htlcTx: HtlcSuccessTx, Some(preimage)) => Some(htlcTx.addSigs(commitKeys, localSig, remoteSig, preimage, commitment.params.commitmentFormat)) + case (htlcTx: HtlcTimeoutTx, _) => Some(htlcTx.addSigs(commitKeys, localSig, remoteSig, commitment.params.commitmentFormat)) + case _ => None + } + signedTx_opt.map(tx => PublishFinalTx(tx, tx.fee, Some(commitTx.txid))) + case _: Transactions.AnchorOutputsCommitmentFormat => + val confirmationTarget = ConfirmationTarget.Absolute(htlcTx.htlcExpiry.blockHeight) + val replaceableTx_opt = (htlcTx, preimage_opt) match { + case (htlcTx: HtlcSuccessTx, Some(preimage)) => Some(ReplaceableHtlcSuccess(htlcTx, commitKeys, preimage, remoteSig, commitTx, commitment)) + case (htlcTx: HtlcTimeoutTx, _) => Some(ReplaceableHtlcTimeout(htlcTx, commitKeys, remoteSig, commitTx, commitment)) + case _ => None + } + replaceableTx_opt.map(tx => PublishReplaceableTx(tx, confirmationTarget)) + } + } + } + def handleRemoteSpentCurrent(commitTx: Transaction, d: ChannelDataWithCommitments) = { val commitments = d.commitments.latest log.warning(s"they published their current commit in txid=${commitTx.txid}") @@ -295,9 +330,18 @@ trait ErrorHandlers extends CommonHandlers { def doPublish(remoteCommitPublished: RemoteCommitPublished, commitment: FullCommitment): Unit = { import remoteCommitPublished._ - val claimLocalAnchor = claimAnchorTxs.collect { case tx: Transactions.ClaimAnchorOutputTx if !remoteCommitPublished.isConfirmed => PublishReplaceableTx(tx, channelKeys, commitment, commitTx, tx.confirmationTarget) } - val redeemableHtlcTxs = claimHtlcTxs.values.flatten.map(tx => PublishReplaceableTx(tx, channelKeys, commitment, commitTx, ConfirmationTarget.Absolute(tx.htlcExpiry.blockHeight))) - val publishQueue = claimLocalAnchor ++ claimMainOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)).toSeq ++ redeemableHtlcTxs + val fundingKey = channelKeys.fundingKey(commitment.fundingTxIndex) + val remotePerCommitmentPoint = commitment.nextRemoteCommit_opt match { + case Some(c) if remoteCommitPublished.commitTx.txid == c.commit.txid => c.commit.remotePerCommitmentPoint + case _ => commitment.remoteCommit.remotePerCommitmentPoint + } + val commitKeys = commitment.remoteKeys(channelKeys, remotePerCommitmentPoint) + val claimAnchor = for { + confirmationTarget <- remoteCommitPublished.confirmationTarget(nodeParams.onChainFeeConf) + anchorTx <- claimAnchorTx_opt + } yield PublishReplaceableTx(ReplaceableRemoteCommitAnchor(anchorTx, fundingKey, commitKeys, commitTx, commitment), confirmationTarget) + val htlcTxs = redeemableClaimHtlcTxs(remoteCommitPublished, commitKeys, commitment) + val publishQueue = claimAnchor ++ claimMainOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)).toSeq ++ htlcTxs publishIfNeeded(publishQueue, irrevocablySpent) // We watch: @@ -307,10 +351,28 @@ trait ErrorHandlers extends CommonHandlers { watchConfirmedIfNeeded(watchConfirmedQueue, irrevocablySpent, relativeDelays = Map.empty) // We watch outputs of the commitment tx that both parties may spend. - val watchSpentQueue = claimHtlcTxs.keys + // We also watch our local anchor: this ensures that we will correctly detect when it's confirmed and count its fees + // in the audit DB, even if we restart before confirmation. + val watchSpentQueue = claimHtlcTxs.keys.toSeq ++ claimAnchorTx_opt.collect { case tx if !remoteCommitPublished.isConfirmed => tx.input.outPoint } watchSpentIfNeeded(commitTx, watchSpentQueue, irrevocablySpent) } + private def redeemableClaimHtlcTxs(remoteCommitPublished: RemoteCommitPublished, commitKeys: RemoteCommitmentKeys, commitment: FullCommitment): Iterable[PublishReplaceableTx] = { + val preimages = (commitment.changes.localChanges.all ++ commitment.changes.remoteChanges.all).collect { + case fulfill: UpdateFulfillHtlc => Crypto.sha256(fulfill.paymentPreimage) -> fulfill.paymentPreimage + }.toMap + remoteCommitPublished.claimHtlcTxs.values.flatten.flatMap { claimHtlcTx => + val confirmationTarget = ConfirmationTarget.Absolute(claimHtlcTx.htlcExpiry.blockHeight) + val preimage_opt = preimages.get(claimHtlcTx.paymentHash) + val replaceableTx_opt = (claimHtlcTx, preimage_opt) match { + case (claimHtlcTx: ClaimHtlcSuccessTx, Some(preimage)) => Some(ReplaceableClaimHtlcSuccess(claimHtlcTx, commitKeys, preimage, remoteCommitPublished.commitTx, commitment)) + case (claimHtlcTx: ClaimHtlcTimeoutTx, _) => Some(ReplaceableClaimHtlcTimeout(claimHtlcTx, commitKeys, remoteCommitPublished.commitTx, commitment)) + case _ => None + } + replaceableTx_opt.map(tx => PublishReplaceableTx(tx, confirmationTarget)) + } + } + def handleRemoteSpentOther(tx: Transaction, d: ChannelDataWithCommitments) = { val commitment = d.commitments.latest log.warning(s"funding tx spent in txid=${tx.txid}") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala index 38da6d9ae5..2bdee8b37b 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/FinalTxPublisher.scala @@ -85,7 +85,7 @@ private class FinalTxPublisher(nodeParams: NodeParams, } } - def checkParentPublished(): Behavior[Command] = { + private def checkParentPublished(): Behavior[Command] = { cmd.parentTx_opt match { case Some(parentTxId) => context.self ! CheckParentTx diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala index cf380922d5..0470229637 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitor.scala @@ -134,7 +134,7 @@ private class MempoolTxMonitor(nodeParams: NodeParams, } } - def waitForConfirmation(): Behavior[Command] = { + private def waitForConfirmation(): Behavior[Command] = { context.system.eventStream ! EventStream.Subscribe(context.messageAdapter[CurrentBlockHeight](cbc => WrappedCurrentBlockHeight(cbc.blockHeight))) context.system.eventStream ! EventStream.Publish(TransactionPublished(txPublishContext.channelId_opt.getOrElse(ByteVector32.Zeroes), txPublishContext.remoteNodeId, cmd.tx, cmd.fee, cmd.desc)) Behaviors.receiveMessagePartial { @@ -192,7 +192,7 @@ private class MempoolTxMonitor(nodeParams: NodeParams, } } - def sendFinalResult(result: FinalTxResult): Behavior[Command] = { + private def sendFinalResult(result: FinalTxResult): Behavior[Command] = { cmd.replyTo ! result Behaviors.stopped } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTx.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTx.scala new file mode 100644 index 0000000000..361d7af4de --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTx.scala @@ -0,0 +1,204 @@ +/* + * Copyright 2025 ACINQ SAS + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package fr.acinq.eclair.channel.publish + +import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, OutPoint, Satoshi, Transaction, TxId, TxIn, TxOut} +import fr.acinq.eclair.channel.FullCommitment +import fr.acinq.eclair.crypto.keymanager.{LocalCommitmentKeys, RemoteCommitmentKeys} +import fr.acinq.eclair.transactions.Transactions._ +import scodec.bits.ByteVector + +/** + * Created by t-bast on 02/05/2025. + */ + +/** + * A transaction that should be automatically replaced to match a specific [[fr.acinq.eclair.blockchain.fee.ConfirmationTarget]]. + * This is only used for 2nd-stage transactions spending the local or remote commitment transaction. + */ +sealed trait ReplaceableTx { + // @formatter:off + def txInfo: ForceCloseTransaction + def desc: String = txInfo.desc + def commitTx: Transaction + def commitment: FullCommitment + def commitmentFormat: CommitmentFormat = commitment.params.commitmentFormat + def dustLimit: Satoshi = commitment.localParams.dustLimit + def commitFee: Satoshi = commitment.commitInput.txOut.amount - commitTx.txOut.map(_.amount).sum + def concurrentCommitTxs: Set[TxId] = commitment.commitTxIds.txIds - commitTx.txid + // @formatter:on +} + +/** A replaceable transaction that uses additional wallet inputs to pay fees. */ +sealed trait ReplaceableTxWithWalletInputs extends ReplaceableTx { + def redeemInfo(): RedeemInfo + + /** Add wallet inputs obtained from Bitcoin Core. */ + def addWalletInputs(inputs: Seq[TxIn]): ReplaceableTxWithWalletInputs = { + // We always keep the channel input in first position. + val updatedTx = txInfo.tx.copy(txIn = txInfo.tx.txIn.take(1) ++ inputs.filter(_.outPoint != txInfo.input.outPoint)) + updateTx(updatedTx) + } + + /** Add wallet signatures obtained from Bitcoin Core. */ + def addWalletSigs(inputs: Seq[TxIn]): ReplaceableTxWithWalletInputs = { + val signedTx = inputs.filter(_.outPoint != txInfo.input.outPoint).foldLeft(txInfo.tx) { + case (tx, input) => + val inputIndex = tx.txIn.indexWhere(_.outPoint == input.outPoint) + if (inputIndex >= 0) { + tx.updateWitness(inputIndex, input.witness) + } else { + tx + } + } + updateTx(signedTx) + } + + /** Set the change output. */ + def setChangeOutput(amount: Satoshi, changeScript: ByteVector): ReplaceableTxWithWalletInputs + + /** Remove wallet inputs and change output. */ + def reset(): ReplaceableTxWithWalletInputs + + def sign(extraUtxos: Map[OutPoint, TxOut]): ReplaceableTxWithWalletInputs + + protected def updateTx(tx: Transaction): ReplaceableTxWithWalletInputs +} + +/** A transaction spending an anchor output to CPFP the corresponding commitment transaction. */ +sealed trait ReplaceableAnchor extends ReplaceableTxWithWalletInputs { + override def reset(): ReplaceableAnchor = { + val updatedTx = txInfo.tx.copy(txIn = txInfo.tx.txIn.take(1), txOut = Nil) + updateTx(updatedTx) + } + + override def setChangeOutput(amount: Satoshi, changeScript: ByteVector): ReplaceableAnchor = { + val updatedTx = txInfo.tx.copy(txOut = Seq(TxOut(amount, changeScript))) + updateTx(updatedTx) + } + + def updateChangeAmount(amount: Satoshi): ReplaceableAnchor = txInfo.tx.txOut.headOption match { + case Some(txOut) => setChangeOutput(amount, txOut.publicKeyScript) + case None => this + } + + override protected def updateTx(tx: Transaction): ReplaceableAnchor = this match { + case anchorTx: ReplaceableLocalCommitAnchor => anchorTx.copy(txInfo = anchorTx.txInfo.copy(tx = tx)) + case anchorTx: ReplaceableRemoteCommitAnchor => anchorTx.copy(txInfo = anchorTx.txInfo.copy(tx = tx)) + } +} + +case class ReplaceableLocalCommitAnchor(txInfo: ClaimAnchorOutputTx, fundingKey: PrivateKey, commitKeys: LocalCommitmentKeys, commitTx: Transaction, commitment: FullCommitment) extends ReplaceableAnchor { + override def redeemInfo(): RedeemInfo = ClaimAnchorOutputTx.redeemInfo(fundingKey, commitKeys.publicKeys, commitment.params.commitmentFormat) + + override def sign(extraUtxos: Map[OutPoint, TxOut]): ReplaceableLocalCommitAnchor = { + copy(txInfo = txInfo.sign(fundingKey, commitKeys, commitment.params.commitmentFormat, extraUtxos)) + } +} + +case class ReplaceableRemoteCommitAnchor(txInfo: ClaimAnchorOutputTx, fundingKey: PrivateKey, commitKeys: RemoteCommitmentKeys, commitTx: Transaction, commitment: FullCommitment) extends ReplaceableAnchor { + override def redeemInfo(): RedeemInfo = ClaimAnchorOutputTx.redeemInfo(fundingKey, commitKeys.publicKeys, commitment.params.commitmentFormat) + + override def sign(extraUtxos: Map[OutPoint, TxOut]): ReplaceableRemoteCommitAnchor = { + copy(txInfo = txInfo.sign(fundingKey, commitKeys, commitment.params.commitmentFormat, extraUtxos)) + } +} + +/** + * A transaction spending an HTLC output from the local commitment. + * We always keep the HTLC input and output at index 0 for simplicity. + */ +sealed trait ReplaceableHtlc extends ReplaceableTxWithWalletInputs { + override def reset(): ReplaceableHtlc = { + val updatedTx = txInfo.tx.copy(txIn = txInfo.tx.txIn.take(1), txOut = txInfo.tx.txOut.take(1)) + updateTx(updatedTx) + } + + def setChangeOutput(amount: Satoshi, changeScript: ByteVector): ReplaceableHtlc = { + // We always keep the HTLC output in the first position. + val updatedTx = txInfo.tx.copy(txOut = txInfo.tx.txOut.take(1) ++ Seq(TxOut(amount, changeScript))) + updateTx(updatedTx) + } + + def updateChangeAmount(amount: Satoshi): ReplaceableHtlc = { + if (txInfo.tx.txOut.size > 1) { + setChangeOutput(amount, txInfo.tx.txOut.last.publicKeyScript) + } else { + this + } + } + + def removeChangeOutput(): ReplaceableHtlc = { + val updatedTx = txInfo.tx.copy(txOut = txInfo.tx.txOut.take(1)) + updateTx(updatedTx) + } + + override protected def updateTx(tx: Transaction): ReplaceableHtlc = this match { + case htlcTx: ReplaceableHtlcSuccess => htlcTx.copy(txInfo = htlcTx.txInfo.copy(tx = tx)) + case htlcTx: ReplaceableHtlcTimeout => htlcTx.copy(txInfo = htlcTx.txInfo.copy(tx = tx)) + } +} + +case class ReplaceableHtlcSuccess(txInfo: HtlcSuccessTx, commitKeys: LocalCommitmentKeys, preimage: ByteVector32, remoteSig: ByteVector64, commitTx: Transaction, commitment: FullCommitment) extends ReplaceableHtlc { + override def redeemInfo(): RedeemInfo = txInfo.redeemInfo(commitKeys.publicKeys, commitment.params.commitmentFormat) + + override def sign(extraUtxos: Map[OutPoint, TxOut]): ReplaceableHtlcSuccess = { + val localSig = txInfo.sign(commitKeys, commitment.params.commitmentFormat, extraUtxos) + copy(txInfo = txInfo.addSigs(commitKeys, localSig, remoteSig, preimage, commitment.params.commitmentFormat)) + } +} + +case class ReplaceableHtlcTimeout(txInfo: HtlcTimeoutTx, commitKeys: LocalCommitmentKeys, remoteSig: ByteVector64, commitTx: Transaction, commitment: FullCommitment) extends ReplaceableHtlc { + override def redeemInfo(): RedeemInfo = txInfo.redeemInfo(commitKeys.publicKeys, commitment.params.commitmentFormat) + + override def sign(extraUtxos: Map[OutPoint, TxOut]): ReplaceableHtlcTimeout = { + val localSig = txInfo.sign(commitKeys, commitment.params.commitmentFormat, extraUtxos) + copy(txInfo = txInfo.addSigs(commitKeys, localSig, remoteSig, commitment.params.commitmentFormat)) + } +} + +/** + * A transaction spending an HTLC output from a remote commitment. + * We're not using a pre-signed transaction (whereas we do for [[ReplaceableHtlc]]), so we don't need to add wallet + * inputs to set fees: we simply lower the amount of our output. + */ +sealed trait ReplaceableClaimHtlc extends ReplaceableTx { + def sign(): ReplaceableClaimHtlc + + def updateOutputAmount(amount: Satoshi): ReplaceableClaimHtlc = { + val updatedTx = txInfo.tx.copy(txOut = Seq(txInfo.tx.txOut.head.copy(amount = amount))) + updateTx(updatedTx) + } + + private def updateTx(tx: Transaction): ReplaceableClaimHtlc = this match { + case claimHtlcTx: ReplaceableClaimHtlcSuccess => claimHtlcTx.copy(txInfo = claimHtlcTx.txInfo.copy(tx = tx)) + case claimHtlcTx: ReplaceableClaimHtlcTimeout => claimHtlcTx.copy(txInfo = claimHtlcTx.txInfo.copy(tx = tx)) + } +} + +case class ReplaceableClaimHtlcSuccess(txInfo: ClaimHtlcSuccessTx, commitKeys: RemoteCommitmentKeys, preimage: ByteVector32, commitTx: Transaction, commitment: FullCommitment) extends ReplaceableClaimHtlc { + override def sign(): ReplaceableClaimHtlcSuccess = { + copy(txInfo = txInfo.sign(commitKeys, preimage, commitment.params.commitmentFormat)) + } +} + +case class ReplaceableClaimHtlcTimeout(txInfo: ClaimHtlcTimeoutTx, commitKeys: RemoteCommitmentKeys, commitTx: Transaction, commitment: FullCommitment) extends ReplaceableClaimHtlc { + override def sign(): ReplaceableClaimHtlcTimeout = { + copy(txInfo = txInfo.sign(commitKeys, commitment.params.commitmentFormat)) + } +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala index ca032cf615..79fca97e4e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala @@ -20,12 +20,10 @@ import akka.actor.typed.eventstream.EventStream import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior} import fr.acinq.bitcoin.psbt.Psbt -import fr.acinq.bitcoin.scalacompat.{OutPoint, Satoshi, Transaction, TxOut} +import fr.acinq.bitcoin.scalacompat.{OutPoint, Satoshi, Script, Transaction, TxOut} import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw, OnChainFeeConf} -import fr.acinq.eclair.channel.FullCommitment -import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._ import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishContext import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ @@ -47,19 +45,20 @@ object ReplaceableTxFunder { // @formatter:off sealed trait Command - case class FundTransaction(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, tx: Either[FundedTx, ReplaceableTxWithWitnessData], targetFeerate: FeeratePerKw) extends Command + case class FundTransaction(replyTo: ActorRef[FundingResult], tx: Either[FundedTx, ReplaceableTx], targetFeerate: FeeratePerKw) extends Command - private case class AddInputsOk(tx: ReplaceableTxWithWitnessData, totalAmountIn: Satoshi, walletUtxos: Map[OutPoint, TxOut]) extends Command + private case class AddInputsOk(fundedTx: ReplaceableTxWithWalletInputs, walletUtxos: Map[OutPoint, TxOut]) extends Command private case class AddInputsFailed(reason: Throwable) extends Command private case class SignWalletInputsOk(signedTx: Transaction) extends Command private case class SignWalletInputsFailed(reason: Throwable) extends Command private case object UtxosUnlocked extends Command // @formatter:on - case class FundedTx(signedTxWithWitnessData: ReplaceableTxWithWitnessData, totalAmountIn: Satoshi, feerate: FeeratePerKw, walletInputs: Map[OutPoint, TxOut]) { - require(signedTxWithWitnessData.txInfo.tx.txIn.nonEmpty, "funded transaction must have inputs") - require(signedTxWithWitnessData.txInfo.tx.txOut.nonEmpty, "funded transaction must have outputs") - val signedTx: Transaction = signedTxWithWitnessData.txInfo.tx + case class FundedTx(tx: ReplaceableTx, feerate: FeeratePerKw, walletInputs: Map[OutPoint, TxOut]) { + require(tx.txInfo.tx.txIn.nonEmpty, "funded transaction must have inputs") + require(tx.txInfo.tx.txOut.nonEmpty, "funded transaction must have outputs") + val signedTx: Transaction = tx.txInfo.tx + val totalAmountIn: Satoshi = tx.txInfo.amountIn + walletInputs.values.map(_.amount).sum val fee: Satoshi = totalAmountIn - signedTx.txOut.map(_.amount).sum } @@ -73,11 +72,11 @@ object ReplaceableTxFunder { Behaviors.setup { context => Behaviors.withMdc(txPublishContext.mdc()) { Behaviors.receiveMessagePartial { - case FundTransaction(replyTo, cmd, tx, requestedFeerate) => - val targetFeerate = requestedFeerate.min(maxFeerate(cmd.txInfo, cmd.commitment, cmd.commitTx, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf)) - val txFunder = new ReplaceableTxFunder(replyTo, cmd, bitcoinClient, context) + case FundTransaction(replyTo, tx, requestedFeerate) => + val targetFeerate = requestedFeerate.min(maxFeerate(tx.fold(fundedTx => fundedTx.tx, tx => tx), nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf)) + val txFunder = new ReplaceableTxFunder(replyTo, bitcoinClient, context) tx match { - case Right(txWithWitnessData) => txFunder.fund(txWithWitnessData, targetFeerate) + case Right(tx) => txFunder.fund(tx, targetFeerate) case Left(previousTx) => txFunder.bump(previousTx, targetFeerate) } } @@ -89,30 +88,30 @@ object ReplaceableTxFunder { * The on-chain feerate can be arbitrarily high, but it wouldn't make sense to pay more fees than the amount we're * trying to claim on-chain. We compute how much funds we have at risk and the feerate that matches this amount. */ - def maxFeerate(txInfo: ReplaceableTransactionWithInputInfo, commitment: FullCommitment, commitTx: Transaction, currentFeerates: FeeratesPerKw, feeConf: OnChainFeeConf): FeeratePerKw = { + def maxFeerate(tx: ReplaceableTx, currentFeerates: FeeratesPerKw, feeConf: OnChainFeeConf): FeeratePerKw = { // We don't want to pay more in fees than the amount at risk in untrimmed pending HTLCs. - val maxFee = txInfo match { - case tx: HtlcTx => tx.input.txOut.amount - case tx: ClaimHtlcTx => tx.input.txOut.amount - case _: ClaimAnchorOutputTx => - val htlcBalance = commitment.localCommit.htlcTxsAndRemoteSigs.map(_.htlcTx.input.txOut.amount).sum - val mainBalance = commitment.localCommit.spec.toLocal.truncateToSatoshi + val maxFee = tx match { + case _: ReplaceableHtlc => tx.txInfo.amountIn + case _: ReplaceableClaimHtlc => tx.txInfo.amountIn + case _: ReplaceableAnchor => + val htlcBalance = tx.commitment.localCommit.htlcTxsAndRemoteSigs.map(_.htlcTx.amountIn).sum + val mainBalance = tx.commitment.localCommit.spec.toLocal.truncateToSatoshi // If there are no HTLCs or a low HTLC amount, we still want to get back our main balance. // In that case, we spend at most 5% of our balance in fees, with a hard cap configured by the node operator. val mainBalanceFee = (mainBalance * 5 / 100).min(feeConf.anchorWithoutHtlcsMaxFee) htlcBalance.max(mainBalanceFee) } // We cannot know beforehand how many wallet inputs will be added, but an estimation should be good enough. - val weight = txInfo match { + val weight = tx match { // For HTLC transactions, we usually add a p2wpkh input and a p2wpkh change output. - case _: HtlcSuccessTx => commitment.params.commitmentFormat.htlcSuccessWeight + Transactions.p2wpkhInputWeight + Transactions.p2wpkhOutputWeight - case _: HtlcTimeoutTx => commitment.params.commitmentFormat.htlcTimeoutWeight + Transactions.p2wpkhInputWeight + Transactions.p2wpkhOutputWeight + case tx: ReplaceableHtlcSuccess => tx.commitmentFormat.htlcSuccessWeight + Transactions.p2wpkhInputWeight + Transactions.p2wpkhOutputWeight + case tx: ReplaceableHtlcTimeout => tx.commitmentFormat.htlcTimeoutWeight + Transactions.p2wpkhInputWeight + Transactions.p2wpkhOutputWeight // Claim-HTLC transactions don't use any additional inputs or outputs. - case _: ClaimHtlcSuccessTx => commitment.params.commitmentFormat.claimHtlcSuccessWeight - case _: ClaimHtlcTimeoutTx => commitment.params.commitmentFormat.claimHtlcTimeoutWeight + case tx: ReplaceableClaimHtlcSuccess => tx.commitmentFormat.claimHtlcSuccessWeight + case tx: ReplaceableClaimHtlcTimeout => tx.commitmentFormat.claimHtlcTimeoutWeight // When claiming our anchor output, it must pay for the weight of the commitment transaction. // We usually add a wallet input and a change output. - case _: ClaimAnchorOutputTx => commitTx.weight() + commitment.params.commitmentFormat.anchorInputWeight + Transactions.p2wpkhInputWeight + Transactions.p2wpkhOutputWeight + case tx: ReplaceableAnchor => tx.commitTx.weight() + tx.commitmentFormat.anchorInputWeight + Transactions.p2wpkhInputWeight + Transactions.p2wpkhOutputWeight } // It doesn't make sense to use a feerate that is much higher than the current feerate for inclusion into the next block, // so we restrict the weight-based feerate obtained. Since ECDSA signature sizes are variable, we also add 1% to the @@ -124,20 +123,19 @@ object ReplaceableTxFunder { * Adjust the main output of a claim-htlc tx to match our target feerate. * If the resulting output is too small, we skip the transaction. */ - def adjustClaimHtlcTxOutput(claimHtlcTx: ClaimHtlcWithWitnessData, targetFeerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): Either[TxGenerationSkipped, ClaimHtlcWithWitnessData] = { + private def adjustClaimHtlcTxOutput(claimHtlcTx: ReplaceableClaimHtlc, targetFeerate: FeeratePerKw): Either[TxGenerationSkipped, ReplaceableClaimHtlc] = { require(claimHtlcTx.txInfo.tx.txIn.size == 1, "claim-htlc transaction should have a single input") require(claimHtlcTx.txInfo.tx.txOut.size == 1, "claim-htlc transaction should have a single output") - val expectedWeight = claimHtlcTx.txInfo match { - case _: ClaimHtlcSuccessTx => commitmentFormat.claimHtlcSuccessWeight - case _: ClaimHtlcTimeoutTx => commitmentFormat.claimHtlcTimeoutWeight + val expectedWeight = claimHtlcTx match { + case _: ReplaceableClaimHtlcSuccess => claimHtlcTx.commitmentFormat.claimHtlcSuccessWeight + case _: ReplaceableClaimHtlcTimeout => claimHtlcTx.commitmentFormat.claimHtlcTimeoutWeight } val targetFee = weight2fee(targetFeerate, expectedWeight) val outputAmount = claimHtlcTx.txInfo.amountIn - targetFee - if (outputAmount < dustLimit) { + if (outputAmount < claimHtlcTx.dustLimit) { Left(AmountBelowDustLimit) } else { - val updatedClaimHtlcTx = claimHtlcTx.updateTx(claimHtlcTx.txInfo.tx.copy(txOut = Seq(claimHtlcTx.txInfo.tx.txOut.head.copy(amount = outputAmount)))) - Right(updatedClaimHtlcTx) + Right(claimHtlcTx.updateOutputAmount(outputAmount)) } } @@ -146,46 +144,42 @@ object ReplaceableTxFunder { object AdjustPreviousTxOutputResult { case class Skip(reason: String) extends AdjustPreviousTxOutputResult case class AddWalletInputs(previousTx: ReplaceableTxWithWalletInputs) extends AdjustPreviousTxOutputResult - case class TxOutputAdjusted(updatedTx: ReplaceableTxWithWitnessData) extends AdjustPreviousTxOutputResult + case class TxOutputAdjusted(updatedTx: ReplaceableTx) extends AdjustPreviousTxOutputResult } // @formatter:on /** * Adjust the outputs of a transaction that was previously published at a lower feerate. - * If the current set of inputs doesn't let us to reach the target feerate, we should request new wallet inputs from bitcoind. + * If the current set of inputs doesn't let us reach the target feerate, we will request new wallet inputs from bitcoind. */ - def adjustPreviousTxOutput(previousTx: FundedTx, targetFeerate: FeeratePerKw, commitment: FullCommitment, commitTx: Transaction): AdjustPreviousTxOutputResult = { - val dustLimit = commitment.localParams.dustLimit - val targetFee = previousTx.signedTxWithWitnessData match { - case _: ClaimAnchorWithWitnessData => - val commitFee = commitment.localCommit.commitTxAndRemoteSig.commitTx.fee - val totalWeight = previousTx.signedTx.weight() + commitTx.weight() - weight2fee(targetFeerate, totalWeight) - commitFee + def adjustPreviousTxOutput(previousTx: FundedTx, targetFeerate: FeeratePerKw): AdjustPreviousTxOutputResult = { + val targetFee = previousTx.tx match { + case anchorTx: ReplaceableAnchor => + val totalWeight = previousTx.signedTx.weight() + anchorTx.commitTx.weight() + weight2fee(targetFeerate, totalWeight) - previousTx.tx.commitFee case _ => weight2fee(targetFeerate, previousTx.signedTx.weight()) } - previousTx.signedTxWithWitnessData match { - case claimLocalAnchor: ClaimAnchorWithWitnessData => + previousTx.tx match { + case anchorTx: ReplaceableAnchor => val changeAmount = previousTx.totalAmountIn - targetFee - if (changeAmount < dustLimit) { - AdjustPreviousTxOutputResult.AddWalletInputs(claimLocalAnchor) + if (changeAmount < anchorTx.dustLimit) { + AdjustPreviousTxOutputResult.AddWalletInputs(anchorTx) } else { - val updatedTxOut = Seq(claimLocalAnchor.txInfo.tx.txOut.head.copy(amount = changeAmount)) - AdjustPreviousTxOutputResult.TxOutputAdjusted(claimLocalAnchor.updateTx(claimLocalAnchor.txInfo.tx.copy(txOut = updatedTxOut))) + AdjustPreviousTxOutputResult.TxOutputAdjusted(anchorTx.updateChangeAmount(changeAmount)) } - case htlcTx: HtlcWithWitnessData => + case htlcTx: ReplaceableHtlc => if (htlcTx.txInfo.tx.txOut.length <= 1) { // There is no change output, so we can't increase the fees without adding new inputs. AdjustPreviousTxOutputResult.AddWalletInputs(htlcTx) } else { - val htlcAmount = htlcTx.txInfo.tx.txOut.head.amount + val htlcAmount = htlcTx.txInfo.amountIn val changeAmount = previousTx.totalAmountIn - targetFee - htlcAmount - if (dustLimit <= changeAmount) { - val updatedTxOut = Seq(htlcTx.txInfo.tx.txOut.head, htlcTx.txInfo.tx.txOut.last.copy(amount = changeAmount)) - AdjustPreviousTxOutputResult.TxOutputAdjusted(htlcTx.updateTx(htlcTx.txInfo.tx.copy(txOut = updatedTxOut))) + if (htlcTx.dustLimit <= changeAmount) { + AdjustPreviousTxOutputResult.TxOutputAdjusted(htlcTx.updateChangeAmount(changeAmount)) } else { // We try removing the change output to see if it provides a high enough feerate. - val htlcTxNoChange = htlcTx.updateTx(htlcTx.txInfo.tx.copy(txOut = Seq(htlcTx.txInfo.tx.txOut.head))) + val htlcTxNoChange = htlcTx.removeChangeOutput() val fee = previousTx.totalAmountIn - htlcAmount if (fee <= htlcAmount) { val feerate = fee2rate(fee, htlcTxNoChange.txInfo.tx.weight()) @@ -201,13 +195,12 @@ object ReplaceableTxFunder { } } } - case claimHtlcTx: ClaimHtlcWithWitnessData => + case claimHtlcTx: ReplaceableClaimHtlc => val updatedAmount = previousTx.totalAmountIn - targetFee - if (updatedAmount < dustLimit) { + if (updatedAmount < claimHtlcTx.dustLimit) { AdjustPreviousTxOutputResult.Skip("fee higher than htlc amount") } else { - val updatedTxOut = Seq(claimHtlcTx.txInfo.tx.txOut.head.copy(amount = updatedAmount)) - AdjustPreviousTxOutputResult.TxOutputAdjusted(claimHtlcTx.updateTx(claimHtlcTx.txInfo.tx.copy(txOut = updatedTxOut))) + AdjustPreviousTxOutputResult.TxOutputAdjusted(claimHtlcTx.updateOutputAmount(updatedAmount)) } } } @@ -215,7 +208,6 @@ object ReplaceableTxFunder { } private class ReplaceableTxFunder(replyTo: ActorRef[ReplaceableTxFunder.FundingResult], - cmd: TxPublisher.PublishReplaceableTx, bitcoinClient: BitcoinCoreClient, context: ActorContext[ReplaceableTxFunder.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { @@ -223,140 +215,113 @@ private class ReplaceableTxFunder(replyTo: ActorRef[ReplaceableTxFunder.FundingR private val log = context.log - def fund(txWithWitnessData: ReplaceableTxWithWitnessData, targetFeerate: FeeratePerKw): Behavior[Command] = { - log.info("funding {} tx (targetFeerate={})", txWithWitnessData.txInfo.desc, targetFeerate) - txWithWitnessData match { - case claimLocalAnchor: ClaimAnchorWithWitnessData => - val commitFeerate = cmd.commitment.localCommit.spec.commitTxFeerate + def fund(tx: ReplaceableTx, targetFeerate: FeeratePerKw): Behavior[Command] = { + log.info("funding {} tx (targetFeerate={})", tx.desc, targetFeerate) + tx match { + case anchorTx: ReplaceableAnchor => + val commitFeerate = anchorTx.commitment.localCommit.spec.commitTxFeerate if (targetFeerate <= commitFeerate) { - log.info("skipping {}: commit feerate is high enough (feerate={})", cmd.desc, commitFeerate) + log.info("skipping {}: commit feerate is high enough (feerate={})", tx.desc, commitFeerate) // We set retry = true in case the on-chain feerate rises before the commit tx is confirmed: if that happens // we'll want to claim our anchor to raise the feerate of the commit tx and get it confirmed faster. replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) Behaviors.stopped } else { - addWalletInputs(claimLocalAnchor, targetFeerate) + addWalletInputs(anchorTx, targetFeerate) } - case htlcTx: HtlcWithWitnessData => - val htlcFeerate = cmd.commitment.localCommit.spec.htlcTxFeerate(cmd.commitment.params.commitmentFormat) + case htlcTx: ReplaceableHtlc => + val htlcFeerate = htlcTx.commitment.localCommit.spec.htlcTxFeerate(htlcTx.commitmentFormat) if (targetFeerate <= htlcFeerate) { - log.debug("publishing {} without adding inputs: txid={}", cmd.desc, htlcTx.txInfo.tx.txid) - sign(txWithWitnessData, htlcFeerate, htlcTx.txInfo.amountIn, Map.empty) + log.debug("publishing {} without adding inputs: txid={}", tx.desc, htlcTx.txInfo.tx.txid) + sign(htlcTx, htlcFeerate, Map.empty) } else { addWalletInputs(htlcTx, targetFeerate) } - case claimHtlcTx: ClaimHtlcWithWitnessData => - adjustClaimHtlcTxOutput(claimHtlcTx, targetFeerate, cmd.commitment.localParams.dustLimit, cmd.commitment.params.commitmentFormat) match { + case claimHtlcTx: ReplaceableClaimHtlc => + adjustClaimHtlcTxOutput(claimHtlcTx, targetFeerate) match { case Left(reason) => // The htlc isn't economical to claim at the current feerate, but if the feerate goes down, we may want to claim it later. - log.warn("skipping {}: {} (feerate={})", cmd.desc, reason, targetFeerate) + log.warn("skipping {}: {} (feerate={})", tx.desc, reason, targetFeerate) replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) Behaviors.stopped case Right(updatedClaimHtlcTx) => - sign(updatedClaimHtlcTx, targetFeerate, updatedClaimHtlcTx.txInfo.amountIn, Map.empty) + sign(updatedClaimHtlcTx, targetFeerate, Map.empty) } } } private def bump(previousTx: FundedTx, targetFeerate: FeeratePerKw): Behavior[Command] = { - log.info("bumping {} tx (targetFeerate={})", previousTx.signedTxWithWitnessData.txInfo.desc, targetFeerate) - adjustPreviousTxOutput(previousTx, targetFeerate, cmd.commitment, cmd.commitTx) match { + log.info("bumping {} tx (targetFeerate={})", previousTx.tx.desc, targetFeerate) + adjustPreviousTxOutput(previousTx, targetFeerate) match { case AdjustPreviousTxOutputResult.Skip(reason) => - log.warn("skipping {} fee bumping: {} (feerate={})", cmd.desc, reason, targetFeerate) + log.warn("skipping {} fee bumping: {} (feerate={})", previousTx.tx.desc, reason, targetFeerate) replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) Behaviors.stopped case AdjustPreviousTxOutputResult.TxOutputAdjusted(updatedTx) => - log.debug("bumping {} fees without adding new inputs: txid={}", cmd.desc, updatedTx.txInfo.tx.txid) - sign(updatedTx, targetFeerate, previousTx.totalAmountIn, previousTx.walletInputs) + log.debug("bumping {} fees without adding new inputs: txid={}", previousTx.tx.desc, updatedTx.txInfo.tx.txid) + sign(updatedTx, targetFeerate, previousTx.walletInputs) case AdjustPreviousTxOutputResult.AddWalletInputs(tx) => - log.debug("bumping {} fees requires adding new inputs (feerate={})", cmd.desc, targetFeerate) + log.debug("bumping {} fees requires adding new inputs (feerate={})", previousTx.tx.desc, targetFeerate) // We restore the original transaction (remove previous attempt's wallet inputs). - val resetTx = tx.updateTx(cmd.txInfo.tx) - addWalletInputs(resetTx, targetFeerate) + addWalletInputs(tx.reset(), targetFeerate) } } - private def addWalletInputs(txWithWitnessData: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw): Behavior[Command] = { - context.pipeToSelf(addInputs(txWithWitnessData, targetFeerate, cmd.commitment)) { - case Success((fundedTx, totalAmountIn, psbt)) => AddInputsOk(fundedTx, totalAmountIn, psbt) + private def addWalletInputs(tx: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw): Behavior[Command] = { + context.pipeToSelf(addInputs(tx, targetFeerate)) { + case Success((fundedTx, walletUtxos)) => AddInputsOk(fundedTx, walletUtxos) case Failure(reason) => AddInputsFailed(reason) } Behaviors.receiveMessagePartial { - case AddInputsOk(fundedTx, totalAmountIn, walletUtxos) => - log.debug("added {} wallet input(s) and {} wallet output(s) to {}", fundedTx.txInfo.tx.txIn.length - 1, fundedTx.txInfo.tx.txOut.length - 1, cmd.desc) - sign(fundedTx, targetFeerate, totalAmountIn, walletUtxos) + case AddInputsOk(fundedTx, walletUtxos) => + log.debug("added {} wallet input(s) and {} wallet output(s) to {}", fundedTx.txInfo.tx.txIn.length - 1, fundedTx.txInfo.tx.txOut.length - 1, tx.desc) + sign(fundedTx, targetFeerate, walletUtxos) case AddInputsFailed(reason) => if (reason.getMessage.contains("Insufficient funds")) { val nodeOperatorMessage = - s"""Insufficient funds in bitcoin wallet to set feerate=$targetFeerate for ${cmd.desc}. + s"""Insufficient funds in bitcoin wallet to set feerate=$targetFeerate for ${tx.desc}. |You should add more utxos to your bitcoin wallet to guarantee funds safety. |Attempts will be made periodically to re-publish this transaction. |""".stripMargin context.system.eventStream ! EventStream.Publish(NotifyNodeOperator(NotificationsLogger.Warning, nodeOperatorMessage)) - log.warn("cannot add inputs to {}: {}", cmd.desc, reason.getMessage) + log.warn("cannot add inputs to {}: {}", tx.desc, reason.getMessage) } else { - log.error(s"cannot add inputs to ${cmd.desc}: ", reason) + log.error(s"cannot add inputs to ${tx.desc}: ", reason) } replyTo ! FundingFailed(TxPublisher.TxRejectedReason.CouldNotFund) Behaviors.stopped } } - private def sign(fundedTx: ReplaceableTxWithWitnessData, txFeerate: FeeratePerKw, amountIn: Satoshi, walletUtxos: Map[OutPoint, TxOut]): Behavior[Command] = { + private def sign(fundedTx: ReplaceableTx, txFeerate: FeeratePerKw, walletUtxos: Map[OutPoint, TxOut]): Behavior[Command] = { fundedTx match { - case claimAnchorTx: ClaimAnchorWithWitnessData if cmd.isLocalCommitAnchor => - val commitKeys = cmd.commitment.localKeys(cmd.channelKeys) - val signedTx = claimAnchorTx.copy(txInfo = claimAnchorTx.txInfo.sign(cmd.fundingKey, commitKeys, cmd.commitment.params.commitmentFormat, walletUtxos)) - signWalletInputs(signedTx, txFeerate, amountIn, walletUtxos) - case claimAnchorTx: ClaimAnchorWithWitnessData => - val commitKeys = cmd.commitment.remoteKeys(cmd.channelKeys, cmd.remotePerCommitmentPoint) - val signedTx = claimAnchorTx.copy(txInfo = claimAnchorTx.txInfo.sign(cmd.fundingKey, commitKeys, cmd.commitment.params.commitmentFormat, walletUtxos)) - signWalletInputs(signedTx, txFeerate, amountIn, walletUtxos) - case htlcTx: HtlcWithWitnessData => - val commitKeys = cmd.commitment.localKeys(cmd.channelKeys) - val localSig = htlcTx.txInfo.sign(commitKeys, cmd.commitment.params.commitmentFormat, walletUtxos) - val signedTx = htlcTx match { - case htlcSuccess: HtlcSuccessWithWitnessData => htlcSuccess.copy(txInfo = htlcSuccess.txInfo.addSigs(commitKeys, localSig, htlcSuccess.remoteSig, htlcSuccess.preimage, cmd.commitment.params.commitmentFormat)) - case htlcTimeout: HtlcTimeoutWithWitnessData => htlcTimeout.copy(txInfo = htlcTimeout.txInfo.addSigs(commitKeys, localSig, htlcTimeout.remoteSig, cmd.commitment.params.commitmentFormat)) - } - val hasWalletInputs = htlcTx.txInfo.tx.txIn.size > 1 - if (hasWalletInputs) { - signWalletInputs(signedTx, txFeerate, amountIn, walletUtxos) + case anchorTx: ReplaceableAnchor => + val locallySignedTx = anchorTx.sign(walletUtxos) + signWalletInputs(locallySignedTx, txFeerate, walletUtxos) + case htlcTx: ReplaceableHtlc => + val locallySignedTx = htlcTx.sign(walletUtxos) + if (htlcTx.txInfo.tx.txIn.size > 1) { + signWalletInputs(locallySignedTx, txFeerate, walletUtxos) } else { - replyTo ! TransactionReady(FundedTx(signedTx, amountIn, txFeerate, walletUtxos)) + replyTo ! TransactionReady(FundedTx(locallySignedTx, txFeerate, walletUtxos)) Behaviors.stopped } - case claimHtlcTx: ClaimHtlcWithWitnessData => - val commitKeys = cmd.commitment.remoteKeys(cmd.channelKeys, cmd.remotePerCommitmentPoint) - val signedTx = claimHtlcTx match { - case claimSuccess: ClaimHtlcSuccessWithWitnessData => claimSuccess.copy(txInfo = claimSuccess.txInfo.sign(commitKeys, claimSuccess.preimage, cmd.commitment.params.commitmentFormat)) - case claimTimeout: ClaimHtlcTimeoutWithWitnessData => claimTimeout.copy(txInfo = claimTimeout.txInfo.sign(commitKeys, cmd.commitment.params.commitmentFormat)) - } - replyTo ! TransactionReady(FundedTx(signedTx, amountIn, txFeerate, walletUtxos)) + case claimHtlcTx: ReplaceableClaimHtlc => + val signedTx = claimHtlcTx.sign() + replyTo ! TransactionReady(FundedTx(signedTx, txFeerate, walletUtxos)) Behaviors.stopped } } - private def signWalletInputs(locallySignedTx: ReplaceableTxWithWalletInputs, txFeerate: FeeratePerKw, amountIn: Satoshi, walletUtxos: Map[OutPoint, TxOut]): Behavior[Command] = { + private def signWalletInputs(locallySignedTx: ReplaceableTxWithWalletInputs, txFeerate: FeeratePerKw, walletUtxos: Map[OutPoint, TxOut]): Behavior[Command] = { import fr.acinq.bitcoin.scalacompat.KotlinUtils._ // We create a PSBT with the non-wallet input already signed: - val redeemInfo = locallySignedTx match { - case _: ClaimAnchorWithWitnessData if cmd.isLocalCommitAnchor => - val commitKeys = cmd.commitment.localKeys(cmd.channelKeys).publicKeys - ClaimAnchorOutputTx.redeemInfo(cmd.fundingKey, commitKeys, cmd.commitment.params.commitmentFormat) - case _: ClaimAnchorWithWitnessData => - val commitKeys = cmd.commitment.remoteKeys(cmd.channelKeys, cmd.remotePerCommitmentPoint).publicKeys - ClaimAnchorOutputTx.redeemInfo(cmd.fundingKey, commitKeys, cmd.commitment.params.commitmentFormat) - case htlcTx: HtlcWithWitnessData => - val commitKeys = cmd.commitment.localKeys(cmd.channelKeys).publicKeys - htlcTx.txInfo.redeemInfo(commitKeys, cmd.commitment.params.commitmentFormat) - } - val witnessScript = redeemInfo match { + val witnessScript = locallySignedTx.redeemInfo() match { case redeemInfo: RedeemInfo.SegwitV0 => fr.acinq.bitcoin.Script.parse(redeemInfo.redeemScript) case _: RedeemInfo.Taproot => null } - val sigHash = locallySignedTx.txInfo.sighash(TxOwner.Local, cmd.commitment.params.commitmentFormat) + val sigHash = locallySignedTx.txInfo.sighash(TxOwner.Local, locallySignedTx.commitmentFormat) val psbt = new Psbt(locallySignedTx.txInfo.tx) .updateWitnessInput( locallySignedTx.txInfo.input.outPoint, @@ -371,16 +336,17 @@ private class ReplaceableTxFunder(replyTo: ActorRef[ReplaceableTxFunder.FundingR ).flatMap(_.finalizeWitnessInput(0, locallySignedTx.txInfo.tx.txIn.head.witness)) psbt match { case Left(failure) => - log.error(s"cannot sign ${cmd.desc}: $failure") + log.error(s"cannot sign ${locallySignedTx.desc}: $failure") unlockAndStop(locallySignedTx.txInfo.tx, TxPublisher.TxRejectedReason.UnknownTxFailure) case Right(psbt1) => - // The transaction that we want to fund/replace has one input, the first one. Additional inputs are provided by our on-chain wallet. + // The transaction that we want to fund/replace has one input, the first one. + // Additional inputs are provided by our on-chain wallet. val ourWalletInputs = locallySignedTx.txInfo.tx.txIn.indices.tail // For "claim anchor txs" there is a single change output that sends to our on-chain wallet. // For htlc txs the first output is the one we want to fund/bump, additional outputs send to our on-chain wallet. val ourWalletOutputs = locallySignedTx match { - case _: ClaimAnchorWithWitnessData => Seq(0) - case _: HtlcWithWitnessData => locallySignedTx.txInfo.tx.txOut.indices.tail + case _: ReplaceableAnchor => Seq(0) + case _: ReplaceableHtlc => locallySignedTx.txInfo.tx.txOut.indices.tail } context.pipeToSelf(bitcoinClient.signPsbt(psbt1, ourWalletInputs, ourWalletOutputs)) { case Success(processPsbtResponse) => @@ -388,8 +354,8 @@ private class ReplaceableTxFunder(replyTo: ActorRef[ReplaceableTxFunder.FundingR case Right(signedTx) => val actualFees = kmp2scala(processPsbtResponse.psbt.computeFees()) val actualWeight = locallySignedTx match { - case _: ClaimAnchorWithWitnessData => signedTx.weight() + cmd.commitTx.weight() - case _ => signedTx.weight() + case _: ReplaceableAnchor => signedTx.weight() + locallySignedTx.commitTx.weight() + case _: ReplaceableHtlc => signedTx.weight() } val actualFeerate = Transactions.fee2rate(actualFees, actualWeight) if (actualFeerate >= txFeerate * 2) { @@ -403,11 +369,11 @@ private class ReplaceableTxFunder(replyTo: ActorRef[ReplaceableTxFunder.FundingR } Behaviors.receiveMessagePartial { case SignWalletInputsOk(signedTx) => - val fullySignedTx = locallySignedTx.updateTx(signedTx) - replyTo ! TransactionReady(FundedTx(fullySignedTx, amountIn, txFeerate, walletUtxos)) + val fullySignedTx = locallySignedTx.addWalletSigs(signedTx.txIn) + replyTo ! TransactionReady(FundedTx(fullySignedTx, txFeerate, walletUtxos)) Behaviors.stopped case SignWalletInputsFailed(reason) => - log.error(s"cannot sign ${cmd.desc}: ", reason) + log.error(s"cannot sign ${locallySignedTx.desc}: ", reason) // We reply with the failure only once the utxos are unlocked, otherwise there is a risk that our parent stops // itself, which will automatically stop us before we had a chance to unlock them. unlockAndStop(locallySignedTx.txInfo.tx, TxPublisher.TxRejectedReason.UnknownTxFailure) @@ -436,57 +402,57 @@ private class ReplaceableTxFunder(replyTo: ActorRef[ReplaceableTxFunder.FundingR })).map(_.toMap) } - private def addInputs(tx: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw, commitment: FullCommitment): Future[(ReplaceableTxWithWalletInputs, Satoshi, Map[OutPoint, TxOut])] = { + private def addInputs(tx: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw): Future[(ReplaceableTxWithWalletInputs, Map[OutPoint, TxOut])] = { for { - (fundedTx, amountIn) <- tx match { - case anchorTx: ClaimAnchorWithWitnessData => addInputs(anchorTx, targetFeerate, commitment) - case htlcTx: HtlcWithWitnessData => addInputs(htlcTx, targetFeerate, commitment) + fundedTx <- tx match { + case anchorTx: ReplaceableAnchor => addInputs(anchorTx, targetFeerate) + case htlcTx: ReplaceableHtlc => addInputs(htlcTx, targetFeerate) } spentUtxos <- getWalletUtxos(fundedTx.txInfo) - } yield (fundedTx, amountIn, spentUtxos) + } yield (fundedTx, spentUtxos) } - private def addInputs(anchorTx: ClaimAnchorWithWitnessData, targetFeerate: FeeratePerKw, commitment: FullCommitment): Future[(ClaimAnchorWithWitnessData, Satoshi)] = { + private def addInputs(anchorTx: ReplaceableAnchor, targetFeerate: FeeratePerKw): Future[ReplaceableTxWithWalletInputs] = { // We want to pay the commit fees using CPFP. Since the commit tx may not be in the mempool yet (its feerate may be // below the minimum acceptable mempool feerate), we cannot ask bitcoind to fund a transaction that spends that // commit tx: it would fail because it cannot find the input in the utxo set. So we instead ask bitcoind to fund an // empty transaction that pays the fees we must add to the transaction package, and we then add the input spending // the commit tx and adjust the change output. - val expectedCommitFee = Transactions.weight2fee(targetFeerate, cmd.commitTx.weight()) - val actualCommitFee = commitment.commitInput.txOut.amount - cmd.commitTx.txOut.map(_.amount).sum - val anchorInputFee = Transactions.weight2fee(targetFeerate, commitment.params.commitmentFormat.anchorInputWeight) + val expectedCommitFee = Transactions.weight2fee(targetFeerate, anchorTx.commitTx.weight()) + val actualCommitFee = anchorTx.commitFee + val anchorInputFee = Transactions.weight2fee(targetFeerate, anchorTx.commitmentFormat.anchorInputWeight) val missingFee = expectedCommitFee - actualCommitFee + anchorInputFee for { changeScript <- bitcoinClient.getChangePublicKeyScript() - txNotFunded = Transaction(2, Nil, TxOut(commitment.localParams.dustLimit + missingFee, changeScript) :: Nil, 0) + txNotFunded = Transaction(2, Nil, TxOut(anchorTx.dustLimit + missingFee, changeScript) :: Nil, 0) // We only use confirmed inputs for anchor transactions to be able to leverage 1-parent-1-child package relay. fundTxResponse <- bitcoinClient.fundTransaction(txNotFunded, targetFeerate, minInputConfirmations_opt = Some(1)) } yield { // We merge our dummy change output with the one added by Bitcoin Core, if any, and adjust the change amount to // pay the expected package feerate. - val txIn = anchorTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn - val packageWeight = cmd.commitTx.weight() + commitment.params.commitmentFormat.anchorInputWeight + fundTxResponse.tx.weight() + val packageWeight = anchorTx.commitTx.weight() + anchorTx.commitmentFormat.anchorInputWeight + fundTxResponse.tx.weight() val expectedFee = Transactions.weight2fee(targetFeerate, packageWeight) val currentFee = actualCommitFee + fundTxResponse.fee - val changeAmount = (fundTxResponse.tx.txOut.map(_.amount).sum - expectedFee + currentFee).max(commitment.localParams.dustLimit) - val changeOutput = fundTxResponse.changePosition match { - case Some(changePos) => fundTxResponse.tx.txOut(changePos).copy(amount = changeAmount) - case None => TxOut(changeAmount, changeScript) - } - val txSingleOutput = fundTxResponse.tx.copy(txIn = txIn, txOut = Seq(changeOutput)) - (anchorTx.updateTx(txSingleOutput), fundTxResponse.amountIn) + val changeAmount = (fundTxResponse.tx.txOut.map(_.amount).sum - expectedFee + currentFee).max(anchorTx.dustLimit) + anchorTx.addWalletInputs(fundTxResponse.tx.txIn).setChangeOutput(changeAmount, Script.write(changeScript)) } } - private def addInputs(htlcTx: HtlcWithWitnessData, targetFeerate: FeeratePerKw, commitment: FullCommitment): Future[(HtlcWithWitnessData, Satoshi)] = { - val htlcInputWeight = htlcTx.txInfo match { - case _: HtlcSuccessTx => commitment.params.commitmentFormat.htlcSuccessInputWeight.toLong - case _: HtlcTimeoutTx => commitment.params.commitmentFormat.htlcTimeoutInputWeight.toLong + private def addInputs(htlcTx: ReplaceableHtlc, targetFeerate: FeeratePerKw): Future[ReplaceableTxWithWalletInputs] = { + val htlcInputWeight = htlcTx match { + case _: ReplaceableHtlcSuccess => htlcTx.commitmentFormat.htlcSuccessInputWeight.toLong + case _: ReplaceableHtlcTimeout => htlcTx.commitmentFormat.htlcTimeoutInputWeight.toLong } bitcoinClient.fundTransaction(htlcTx.txInfo.tx, targetFeerate, changePosition = Some(1), externalInputsWeight = Map(htlcTx.txInfo.input.outPoint -> htlcInputWeight)).map(fundTxResponse => { - // Bitcoin Core may not preserve the order of inputs, we need to make sure the htlc is the first input. - val fundedTx = fundTxResponse.tx.copy(txIn = htlcTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn.filterNot(_.outPoint == htlcTx.txInfo.input.outPoint)) - (htlcTx.updateTx(fundedTx), fundTxResponse.amountIn) + fundTxResponse.changePosition match { + case Some(changeIndex) if changeIndex < fundTxResponse.tx.txOut.size => + // Bitcoin Core added a change output and wallet inputs. + val change = fundTxResponse.tx.txOut(changeIndex) + htlcTx.addWalletInputs(fundTxResponse.tx.txIn).setChangeOutput(change.amount, change.publicKeyScript) + case _ => + // Bitcoin Core did not add any change output, we only need to add the wallet inputs. + htlcTx.addWalletInputs(fundTxResponse.tx.txIn) + } }) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala index 26a0b2b33c..ffac811bb5 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala @@ -18,14 +18,10 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, Transaction} +import fr.acinq.bitcoin.scalacompat.{Transaction, TxId} import fr.acinq.eclair.NodeParams import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishContext -import fr.acinq.eclair.channel.{FullCommitment, HtlcTxAndRemoteSig} -import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.wire.protocol.UpdateFulfillHtlc import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success} @@ -43,68 +39,38 @@ object ReplaceableTxPrePublisher { // @formatter:off sealed trait Command - case class CheckPreconditions(replyTo: ActorRef[PreconditionsResult], cmd: TxPublisher.PublishReplaceableTx) extends Command + case class CheckPreconditions(replyTo: ActorRef[PreconditionsResult], tx: ReplaceableTx) extends Command private case object ParentTxOk extends Command - private case object FundingTxNotFound extends RuntimeException with Command - private case object CommitTxAlreadyConfirmed extends RuntimeException with Command - private case object LocalCommitTxConfirmed extends Command - private case object LocalCommitTxPublished extends Command - private case object RemoteCommitTxConfirmed extends Command - private case object RemoteCommitTxPublished extends RuntimeException with Command + private case object FundingTxNotFound extends Command + private case object CommitTxRecentlyConfirmed extends Command + private case object CommitTxDeeplyConfirmed extends Command + private case object ConcurrentCommitAvailable extends Command + private case object ConcurrentCommitRecentlyConfirmed extends Command + private case object ConcurrentCommitDeeplyConfirmed extends Command private case object HtlcOutputAlreadySpent extends Command private case class UnknownFailure(reason: Throwable) extends Command // @formatter:on // @formatter:off sealed trait PreconditionsResult - case class PreconditionsOk(txWithWitnessData: ReplaceableTxWithWitnessData) extends PreconditionsResult + case object PreconditionsOk extends PreconditionsResult case class PreconditionsFailed(reason: TxPublisher.TxRejectedReason) extends PreconditionsResult - - /** Replaceable transaction with all the witness data necessary to finalize. */ - sealed trait ReplaceableTxWithWitnessData { - def txInfo: ReplaceableTransactionWithInputInfo - def updateTx(tx: Transaction): ReplaceableTxWithWitnessData - } - /** Replaceable transaction for which we may need to add wallet inputs. */ - sealed trait ReplaceableTxWithWalletInputs extends ReplaceableTxWithWitnessData { - override def updateTx(tx: Transaction): ReplaceableTxWithWalletInputs - } - case class ClaimAnchorWithWitnessData(txInfo: ClaimAnchorOutputTx) extends ReplaceableTxWithWalletInputs { - override def updateTx(tx: Transaction): ClaimAnchorWithWitnessData = this.copy(txInfo = this.txInfo.copy(tx = tx)) - } - sealed trait HtlcWithWitnessData extends ReplaceableTxWithWalletInputs { - override def txInfo: HtlcTx - override def updateTx(tx: Transaction): HtlcWithWitnessData - } - case class HtlcSuccessWithWitnessData(txInfo: HtlcSuccessTx, remoteSig: ByteVector64, preimage: ByteVector32) extends HtlcWithWitnessData { - override def updateTx(tx: Transaction): HtlcSuccessWithWitnessData = this.copy(txInfo = this.txInfo.copy(tx = tx)) - } - case class HtlcTimeoutWithWitnessData(txInfo: HtlcTimeoutTx, remoteSig: ByteVector64) extends HtlcWithWitnessData { - override def updateTx(tx: Transaction): HtlcTimeoutWithWitnessData = this.copy(txInfo = this.txInfo.copy(tx = tx)) - } - sealed trait ClaimHtlcWithWitnessData extends ReplaceableTxWithWitnessData { - override def txInfo: ClaimHtlcTx - override def updateTx(tx: Transaction): ClaimHtlcWithWitnessData - } - case class ClaimHtlcSuccessWithWitnessData(txInfo: ClaimHtlcSuccessTx, preimage: ByteVector32) extends ClaimHtlcWithWitnessData { - override def updateTx(tx: Transaction): ClaimHtlcSuccessWithWitnessData = this.copy(txInfo = this.txInfo.copy(tx = tx)) - } - case class ClaimHtlcTimeoutWithWitnessData(txInfo: ClaimHtlcTimeoutTx) extends ClaimHtlcWithWitnessData { - override def updateTx(tx: Transaction): ClaimHtlcTimeoutWithWitnessData = this.copy(txInfo = this.txInfo.copy(tx = tx)) - } // @formatter:on def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, txPublishContext: TxPublishContext): Behavior[Command] = { Behaviors.setup { context => Behaviors.withMdc(txPublishContext.mdc()) { Behaviors.receiveMessagePartial { - case CheckPreconditions(replyTo, cmd) => - val prePublisher = new ReplaceableTxPrePublisher(nodeParams, replyTo, cmd, bitcoinClient, context) - cmd.txInfo match { - case localAnchorTx: Transactions.ClaimAnchorOutputTx => prePublisher.checkAnchorPreconditions(localAnchorTx) - case htlcTx: Transactions.HtlcTx => prePublisher.checkHtlcPreconditions(htlcTx) - case claimHtlcTx: Transactions.ClaimHtlcTx => prePublisher.checkClaimHtlcPreconditions(claimHtlcTx) + case CheckPreconditions(replyTo, tx) => + val prePublisher = new ReplaceableTxPrePublisher(nodeParams, replyTo, bitcoinClient, context) + tx match { + case tx: ReplaceableLocalCommitAnchor => prePublisher.checkLocalCommitAnchorPreconditions(tx.commitTx) + case tx: ReplaceableRemoteCommitAnchor => prePublisher.checkRemoteCommitAnchorPreconditions(tx.commitTx) + case _: ReplaceableHtlcSuccess => prePublisher.checkHtlcPreconditions(tx, tx.commitTx) + case _: ReplaceableHtlcTimeout => prePublisher.checkHtlcPreconditions(tx, tx.commitTx) + case _: ReplaceableClaimHtlcSuccess => prePublisher.checkHtlcPreconditions(tx, tx.commitTx) + case _: ReplaceableClaimHtlcTimeout => prePublisher.checkHtlcPreconditions(tx, tx.commitTx) } } } @@ -115,7 +81,6 @@ object ReplaceableTxPrePublisher { private class ReplaceableTxPrePublisher(nodeParams: NodeParams, replyTo: ActorRef[ReplaceableTxPrePublisher.PreconditionsResult], - cmd: TxPublisher.PublishReplaceableTx, bitcoinClient: BitcoinCoreClient, context: ActorContext[ReplaceableTxPrePublisher.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { @@ -123,224 +88,188 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, private val log = context.log - private def checkAnchorPreconditions(localAnchorTx: ClaimAnchorOutputTx): Behavior[Command] = { - // We verify that: - // - our commit is not confirmed (if it is, no need to claim our anchor) - // - their commit is not confirmed (if it is, no need to claim our anchor either) - val fundingOutpoint = cmd.commitment.commitInput.outPoint + /** + * We only claim our anchor output for our local commitment if: + * - our local commitment is unconfirmed + * - and we haven't seen a remote commitment (in which case it is more interesting to spend than the local commitment) + */ + private def checkLocalCommitAnchorPreconditions(commitTx: Transaction): Behavior[Command] = { + val fundingOutpoint = commitTx.txIn.head.outPoint context.pipeToSelf(bitcoinClient.getTxConfirmations(fundingOutpoint.txid).flatMap { case Some(_) => // The funding transaction was found, let's see if we can still spend it. - bitcoinClient.isTransactionOutputSpendable(fundingOutpoint.txid, fundingOutpoint.index.toInt, includeMempool = false).flatMap { - case false => Future.failed(CommitTxAlreadyConfirmed) - case true if cmd.isLocalCommitAnchor => - // We are trying to bump our local commitment. Let's check if the remote commitment is published: if it is, - // we will skip publishing our local commitment, because the remote commitment is more interesting (we don't - // have any CSV delays and don't need 2nd-stage HTLC transactions). - getRemoteCommitConfirmations(cmd.commitment).flatMap { - case Some(_) => Future.failed(RemoteCommitTxPublished) - // We're trying to bump the local commit tx: no need to do anything, we will publish it alongside the anchor transaction. - case None => Future.successful(cmd.commitTx.txid) - } + bitcoinClient.isTransactionOutputSpendable(fundingOutpoint.txid, fundingOutpoint.index.toInt, includeMempool = true).flatMap { case true => - // We're trying to bump a remote commitment: no need to do anything, we will publish it alongside the anchor transaction. - Future.successful(cmd.commitTx.txid) + // The funding output is unspent: let's publish our anchor transaction to get our local commit confirmed. + Future.successful(ParentTxOk) + case false => + // The funding output is spent: we check whether our local commit is confirmed or in our mempool. + bitcoinClient.getTxConfirmations(commitTx.txid).transformWith { + case Success(Some(confirmations)) if confirmations >= nodeParams.channelConf.minDepth => Future.successful(CommitTxDeeplyConfirmed) + case Success(Some(confirmations)) if confirmations > 0 => Future.successful(CommitTxRecentlyConfirmed) + case Success(Some(0)) => Future.successful(ParentTxOk) // our commit tx is unconfirmed, let's publish our anchor transaction + case _ => + // Our commit tx is unconfirmed and cannot be found in our mempool: this means that a remote commit is + // either confirmed or in our mempool. In that case, we don't want to use our local commit tx: the + // remote commit is more interesting to us because we won't have any CSV delays on our outputs. + Future.successful(ConcurrentCommitAvailable) + } } case None => // If the funding transaction cannot be found (e.g. when using 0-conf), we should retry later. - Future.failed(FundingTxNotFound) + Future.successful(FundingTxNotFound) }) { - case Success(_) => ParentTxOk - case Failure(FundingTxNotFound) => FundingTxNotFound - case Failure(CommitTxAlreadyConfirmed) => CommitTxAlreadyConfirmed - case Failure(RemoteCommitTxPublished) => RemoteCommitTxPublished - case Failure(reason) if reason.getMessage.contains("rejecting replacement") => RemoteCommitTxPublished + case Success(result) => result case Failure(reason) => UnknownFailure(reason) } Behaviors.receiveMessagePartial { case ParentTxOk => - replyTo ! PreconditionsOk(ClaimAnchorWithWitnessData(localAnchorTx)) + replyTo ! PreconditionsOk Behaviors.stopped case FundingTxNotFound => log.debug("funding tx could not be found, we don't know yet if we need to claim our anchor") replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) Behaviors.stopped - case CommitTxAlreadyConfirmed => - log.debug("commit tx is already confirmed, no need to claim our anchor") + case CommitTxRecentlyConfirmed => + log.debug("local commit tx was recently confirmed, let's check again later") + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) + Behaviors.stopped + case CommitTxDeeplyConfirmed => + log.debug("local commit tx is deeply confirmed, no need to claim our anchor") replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) Behaviors.stopped - case RemoteCommitTxPublished => - log.warn("not publishing local commit tx: we're using the remote commit tx instead") + case ConcurrentCommitAvailable => + log.warn("not publishing local anchor for commitTxId={}: we will use the remote commit tx instead", commitTx.txid) replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) Behaviors.stopped case UnknownFailure(reason) => - log.error(s"could not check ${cmd.desc} preconditions, proceeding anyway: ", reason) + log.error("could not check local anchor preconditions, proceeding anyway: ", reason) // If our checks fail, we don't want it to prevent us from trying to publish our commit tx. - replyTo ! PreconditionsOk(ClaimAnchorWithWitnessData(localAnchorTx)) + replyTo ! PreconditionsOk Behaviors.stopped } } - private def getRemoteCommitConfirmations(commitment: FullCommitment): Future[Option[Int]] = { - bitcoinClient.getTxConfirmations(commitment.remoteCommit.txid).transformWith { - // NB: this handles the case where the remote commit is in the mempool because we will get Some(0). - case Success(Some(remoteCommitConfirmations)) => Future.successful(Some(remoteCommitConfirmations)) - case notFoundOrFailed => commitment.nextRemoteCommit_opt match { - case Some(nextRemoteCommit) => bitcoinClient.getTxConfirmations(nextRemoteCommit.commit.txid) - case None => Future.fromTry(notFoundOrFailed) - } - } - } - /** - * We verify that: - * - their commit is not confirmed: if it is, there is no need to publish our htlc transactions - * - the HTLC output isn't already spent by a confirmed transaction (race between HTLC-timeout and HTLC-success) + * We only claim our anchor output for a remote commitment if: + * - that remote commitment is unconfirmed + * - there is no other commitment that is already confirmed */ - private def checkHtlcOutput(commitment: FullCommitment, htlcTx: HtlcTx): Future[Command] = { - getRemoteCommitConfirmations(commitment).flatMap { - case Some(depth) if depth >= nodeParams.channelConf.minDepth => Future.successful(RemoteCommitTxConfirmed) - case Some(_) => Future.successful(RemoteCommitTxPublished) - case _ => bitcoinClient.isTransactionOutputSpent(htlcTx.input.outPoint.txid, htlcTx.input.outPoint.index.toInt).map { - case true => HtlcOutputAlreadySpent - case false => ParentTxOk - } - } - } - - private def checkHtlcPreconditions(htlcTx: HtlcTx): Behavior[Command] = { - context.pipeToSelf(checkHtlcOutput(cmd.commitment, htlcTx)) { + private def checkRemoteCommitAnchorPreconditions(commitTx: Transaction): Behavior[Command] = { + val fundingOutpoint = commitTx.txIn.head.outPoint + context.pipeToSelf(bitcoinClient.getTxConfirmations(fundingOutpoint.txid).flatMap { + case Some(_) => + // The funding transaction was found, let's see if we can still spend it. Note that in this case, we only look + // at *confirmed* spending transactions (unlike the local commit case). + bitcoinClient.isTransactionOutputSpendable(fundingOutpoint.txid, fundingOutpoint.index.toInt, includeMempool = false).flatMap { + case true => + // The funding output is unspent, or spent by an *unconfirmed* transaction: let's publish our anchor + // transaction, we may be able to replace our local commit with this (more interesting) remote commit. + Future.successful(ParentTxOk) + case false => + // The funding output is spent by a confirmed commit tx: we check the status of our anchor's commit tx. + bitcoinClient.getTxConfirmations(commitTx.txid).transformWith { + case Success(Some(confirmations)) if confirmations >= nodeParams.channelConf.minDepth => Future.successful(CommitTxDeeplyConfirmed) + case Success(_) => Future.successful(CommitTxRecentlyConfirmed) + // The spending tx is another commit tx: we can stop trying to publish this one. + case _ => Future.successful(ConcurrentCommitAvailable) + } + } + case None => + // If the funding transaction cannot be found (e.g. when using 0-conf), we should retry later. + Future.successful(FundingTxNotFound) + }) { case Success(result) => result case Failure(reason) => UnknownFailure(reason) } Behaviors.receiveMessagePartial { case ParentTxOk => - // We make sure that if this is an htlc-success transaction, we have the preimage. - extractHtlcWitnessData(htlcTx, cmd.commitment) match { - case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) - case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) - } + replyTo ! PreconditionsOk Behaviors.stopped - case RemoteCommitTxPublished => - log.info("cannot publish {}: remote commit has been published", cmd.desc) - // We keep retrying until the remote commit reaches min-depth to protect against reorgs. + case FundingTxNotFound => + log.debug("funding tx could not be found, we don't know yet if we need to claim our anchor") replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) Behaviors.stopped - case RemoteCommitTxConfirmed => - log.warn("cannot publish {}: remote commit has been confirmed", cmd.desc) - replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.ConflictingTxConfirmed) + case CommitTxRecentlyConfirmed => + log.debug("remote commit tx was recently confirmed, let's check again later") + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) Behaviors.stopped - case HtlcOutputAlreadySpent => - log.warn("cannot publish {}: htlc output has already been spent", cmd.desc) - replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.ConflictingTxConfirmed) + case CommitTxDeeplyConfirmed => + log.debug("remote commit tx is deeply confirmed, no need to claim our anchor") + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) + Behaviors.stopped + case ConcurrentCommitAvailable => + log.warn("not publishing remote anchor for commitTxId={}: a concurrent commit tx is confirmed", commitTx.txid) + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) Behaviors.stopped case UnknownFailure(reason) => - log.error(s"could not check ${cmd.desc} preconditions, proceeding anyway: ", reason) - // If our checks fail, we don't want it to prevent us from trying to publish our htlc transactions. - extractHtlcWitnessData(htlcTx, cmd.commitment) match { - case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) - case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) - } + log.error("could not check remote anchor preconditions, proceeding anyway: ", reason) + // If our checks fail, we don't want it to prevent us from trying to publish our commit tx. + replyTo ! PreconditionsOk Behaviors.stopped } } - private def extractHtlcWitnessData(htlcTx: HtlcTx, commitment: FullCommitment): Option[ReplaceableTxWithWitnessData] = { - htlcTx match { - case tx: HtlcSuccessTx => - commitment.localCommit.htlcTxsAndRemoteSigs.collectFirst { - case HtlcTxAndRemoteSig(HtlcSuccessTx(input, _, _, _, _), remoteSig) if input.outPoint == tx.input.outPoint => remoteSig - } match { - case Some(remoteSig) => - commitment.changes.localChanges.all.collectFirst { - case u: UpdateFulfillHtlc if Crypto.sha256(u.paymentPreimage) == tx.paymentHash => u.paymentPreimage - } match { - case Some(preimage) => Some(HtlcSuccessWithWitnessData(tx, remoteSig, preimage)) - case None => - log.error(s"preimage not found for htlcId=${tx.htlcId}, skipping...") - None - } - case None => - log.error(s"remote signature not found for htlcId=${tx.htlcId}, skipping...") - None - } - case tx: HtlcTimeoutTx => - commitment.localCommit.htlcTxsAndRemoteSigs.collectFirst { - case HtlcTxAndRemoteSig(htlcTimeoutTx: HtlcTimeoutTx, remoteSig) if htlcTimeoutTx.input.outPoint == tx.input.outPoint => remoteSig - } match { - case Some(remoteSig) => Some(HtlcTimeoutWithWitnessData(tx, remoteSig)) - case None => - log.error(s"remote signature not found for htlcId=${tx.htlcId}, skipping...") - None - } - } - } - /** - * We verify that: - * - our commit is not confirmed: if it is, there is no need to publish our claim-htlc transactions - * - the HTLC output isn't already spent by a confirmed transaction (race between HTLC-timeout and HTLC-success) + * We first verify that the commit tx we're spending may confirm: if a conflicting commit tx is already confirmed, our + * HTLC transaction has become obsolete. Then we check that the HTLC output that we're spending isn't already spent + * by a confirmed transaction, which may happen in case of a race between HTLC-timeout and HTLC-success. */ - private def checkClaimHtlcOutput(commitment: FullCommitment, claimHtlcTx: ClaimHtlcTx): Future[Command] = { - bitcoinClient.getTxConfirmations(commitment.localCommit.commitTxAndRemoteSig.commitTx.tx.txid).flatMap { - case Some(depth) if depth >= nodeParams.channelConf.minDepth => Future.successful(LocalCommitTxConfirmed) - case Some(_) => Future.successful(LocalCommitTxPublished) - case _ => bitcoinClient.isTransactionOutputSpent(claimHtlcTx.input.outPoint.txid, claimHtlcTx.input.outPoint.index.toInt).map { - case true => HtlcOutputAlreadySpent - case false => ParentTxOk - } - } - } - - private def checkClaimHtlcPreconditions(claimHtlcTx: ClaimHtlcTx): Behavior[Command] = { - context.pipeToSelf(checkClaimHtlcOutput(cmd.commitment, claimHtlcTx)) { + private def checkHtlcPreconditions(tx: ReplaceableTx, commitTx: Transaction): Behavior[Command] = { + context.pipeToSelf(bitcoinClient.getTxConfirmations(commitTx.txid).flatMap { + case Some(_) => + // If the HTLC output is already spent by a confirmed transaction, there is no need for RBF: either this is one + // of our transactions (which thus has a high enough feerate), or it was a race with our peer and we lost. + bitcoinClient.isTransactionOutputSpent(tx.txInfo.input.outPoint.txid, tx.txInfo.input.outPoint.index.toInt).map { + case true => HtlcOutputAlreadySpent + case false => ParentTxOk + } + case None => + // The parent commitment is unconfirmed: we shouldn't try to publish this HTLC transaction if a concurrent + // commitment is deeply confirmed. + checkConcurrentCommits(tx.concurrentCommitTxs.toSeq).map { + case Some(confirmations) if confirmations >= nodeParams.channelConf.minDepth => ConcurrentCommitDeeplyConfirmed + case Some(_) => ConcurrentCommitRecentlyConfirmed + case None => ParentTxOk + } + }) { case Success(result) => result case Failure(reason) => UnknownFailure(reason) } Behaviors.receiveMessagePartial { case ParentTxOk => - // We verify that if this is a claim-htlc-success transaction, we have the preimage. - extractClaimHtlcWitnessData(claimHtlcTx, cmd.commitment) match { - case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) - case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) - } + replyTo ! PreconditionsOk Behaviors.stopped - case LocalCommitTxPublished => - log.info("cannot publish {}: local commit has been published", cmd.desc) - // We keep retrying until the local commit reaches min-depth to protect against reorgs. + case ConcurrentCommitRecentlyConfirmed => + log.debug("cannot publish {} spending commitTxId={}: concurrent commit tx was recently confirmed, let's check again later", tx.txInfo.desc, commitTx.txid) + // We keep retrying until the concurrent commit reaches min-depth to protect against reorgs. replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) Behaviors.stopped - case LocalCommitTxConfirmed => - log.warn("cannot publish {}: local commit has been confirmed", cmd.desc) + case ConcurrentCommitDeeplyConfirmed => + log.warn("cannot publish {} spending commitTxId={}: concurrent commit is deeply confirmed", tx.txInfo.desc, commitTx.txid) replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.ConflictingTxConfirmed) Behaviors.stopped case HtlcOutputAlreadySpent => - log.warn("cannot publish {}: htlc output has already been spent", cmd.desc) + log.warn("cannot publish {}: htlc output {} has already been spent", tx.txInfo.desc, tx.txInfo.input.outPoint) replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.ConflictingTxConfirmed) Behaviors.stopped case UnknownFailure(reason) => - log.error(s"could not check ${cmd.desc} preconditions, proceeding anyway: ", reason) + log.error(s"could not check ${tx.txInfo.desc} preconditions, proceeding anyway: ", reason) // If our checks fail, we don't want it to prevent us from trying to publish our htlc transactions. - extractClaimHtlcWitnessData(claimHtlcTx, cmd.commitment) match { - case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) - case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) - } + replyTo ! PreconditionsOk Behaviors.stopped } } - private def extractClaimHtlcWitnessData(claimHtlcTx: ClaimHtlcTx, commitment: FullCommitment): Option[ReplaceableTxWithWitnessData] = { - claimHtlcTx match { - case tx: ClaimHtlcSuccessTx => - commitment.changes.localChanges.all.collectFirst { - case u: UpdateFulfillHtlc if Crypto.sha256(u.paymentPreimage) == tx.paymentHash => u.paymentPreimage - } match { - case Some(preimage) => Some(ClaimHtlcSuccessWithWitnessData(tx, preimage)) - case None => - log.error(s"preimage not found for htlcId=${tx.htlcId}, skipping...") - None + /** Check the confirmation status of concurrent commitment transactions. */ + private def checkConcurrentCommits(txIds: Seq[TxId]): Future[Option[Int]] = { + txIds.headOption match { + case Some(txId) => + bitcoinClient.getTxConfirmations(txId).transformWith { + case Success(Some(confirmations)) => Future.successful(Some(confirmations)) + case _ => checkConcurrentCommits(txIds.tail) } - case tx: ClaimHtlcTimeoutTx => Some(ClaimHtlcTimeoutWithWitnessData(tx)) + case None => Future.successful(None) } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index 07d190b705..328699acd7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala @@ -22,9 +22,7 @@ import fr.acinq.bitcoin.scalacompat.{SatoshiLong, Transaction} import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel.publish.ReplaceableTxFunder.FundedTx -import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher.{ClaimAnchorWithWitnessData, ReplaceableTxWithWitnessData} import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishContext -import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.{BlockHeight, NodeParams} import scala.concurrent.duration.{DurationInt, DurationLong} @@ -115,16 +113,16 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, private val log = context.log - /** The confirmation target may be updated in some corner cases (e.g. for a htlc if we learn a payment preimage). */ + /** The confirmation target may be updated in some corner cases (e.g. if we learn a payment preimage after initiating a force-close). */ private var confirmationTarget: ConfirmationTarget = cmd.confirmationTarget private def checkPreconditions(): Behavior[Command] = { val prePublisher = context.spawn(ReplaceableTxPrePublisher(nodeParams, bitcoinClient, txPublishContext), "pre-publisher") - prePublisher ! ReplaceableTxPrePublisher.CheckPreconditions(context.messageAdapter[ReplaceableTxPrePublisher.PreconditionsResult](WrappedPreconditionsResult), cmd) + prePublisher ! ReplaceableTxPrePublisher.CheckPreconditions(context.messageAdapter[ReplaceableTxPrePublisher.PreconditionsResult](WrappedPreconditionsResult), cmd.tx) Behaviors.receiveMessagePartial { case WrappedPreconditionsResult(result) => result match { - case ReplaceableTxPrePublisher.PreconditionsOk(txWithWitnessData) => checkTimeLocks(txWithWitnessData) + case ReplaceableTxPrePublisher.PreconditionsOk => checkTimeLocks() case ReplaceableTxPrePublisher.PreconditionsFailed(reason) => sendResult(TxPublisher.TxRejected(txPublishContext.id, cmd, reason), None) } case UpdateConfirmationTarget(target) => @@ -134,15 +132,15 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - def checkTimeLocks(txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { - txWithWitnessData match { + def checkTimeLocks(): Behavior[Command] = { + cmd.tx match { // There are no time locks on anchor transactions, we can claim them right away. - case _: ClaimAnchorWithWitnessData => chooseFeerate(txWithWitnessData) - case _ => + case _: ReplaceableAnchor => chooseFeerate() + case _: ReplaceableHtlc | _: ReplaceableClaimHtlc => val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, bitcoinClient, txPublishContext), "time-locks-monitor") - timeLocksChecker ! TxTimeLocksMonitor.CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) + timeLocksChecker ! TxTimeLocksMonitor.CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.tx.txInfo.tx, cmd.desc) Behaviors.receiveMessagePartial { - case TimeLocksOk => chooseFeerate(txWithWitnessData) + case TimeLocksOk => chooseFeerate() case UpdateConfirmationTarget(target) => confirmationTarget = target Behaviors.same @@ -151,7 +149,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - private def chooseFeerate(txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { + private def chooseFeerate(): Behavior[Command] = { context.pipeToSelf(hasEnoughSafeUtxos(nodeParams.onChainFeeConf.safeUtxosThreshold)) { case Success(isSafe) => CheckUtxosResult(isSafe, nodeParams.currentBlockHeight) case Failure(_) => CheckUtxosResult(isSafe = false, nodeParams.currentBlockHeight) // if we can't check our utxos, we assume the worst @@ -159,7 +157,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, Behaviors.receiveMessagePartial { case CheckUtxosResult(isSafe, currentBlockHeight) => val targetFeerate = getFeerate(nodeParams.currentBitcoinCoreFeerates, confirmationTarget, currentBlockHeight, isSafe) - fund(txWithWitnessData, targetFeerate) + fund(targetFeerate) case UpdateConfirmationTarget(target) => confirmationTarget = target Behaviors.same @@ -167,9 +165,9 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, } } - def fund(txWithWitnessData: ReplaceableTxWithWitnessData, targetFeerate: FeeratePerKw): Behavior[Command] = { + def fund(targetFeerate: FeeratePerKw): Behavior[Command] = { val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, txPublishContext), "tx-funder") - txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Right(txWithWitnessData), targetFeerate) + txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), Right(cmd.tx), targetFeerate) Behaviors.receiveMessagePartial { case WrappedFundingResult(result) => result match { @@ -179,10 +177,11 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case ConfirmationTarget.Priority(priority) => log.debug("publishing {} with priority {}", cmd.desc, priority) } val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, txPublishContext), s"mempool-tx-monitor-${tx.signedTx.txid}") - val parentTx_opt = cmd.txInfo match { + val parentTx_opt = cmd.tx match { // Anchor output transactions are packaged with the corresponding commitment transaction. - case _: Transactions.ClaimAnchorOutputTx => Some(cmd.commitTx) - case _ => None + case _: ReplaceableAnchor => Some(cmd.tx.commitTx) + case _: ReplaceableHtlc => None + case _: ReplaceableClaimHtlc => None } txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, parentTx_opt, cmd.input, nodeParams.channelConf.minDepth, cmd.desc, tx.fee) wait(tx) @@ -206,9 +205,9 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case WrappedTxResult(txResult) => txResult match { case MempoolTxMonitor.TxInMempool(_, currentBlockHeight, parentConfirmed) => - val shouldRbf = cmd.txInfo match { + val shouldRbf = cmd.tx match { // Our commit tx was confirmed on its own, so there's no need to increase fees on the anchor tx. - case _: Transactions.ClaimAnchorOutputTx if parentConfirmed => false + case _: ReplaceableAnchor if parentConfirmed => false case _ => true } if (shouldRbf) { @@ -262,7 +261,7 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, private def fundReplacement(targetFeerate: FeeratePerKw, previousTx: FundedTx): Behavior[Command] = { log.info("bumping {} fees: previous feerate={}, next feerate={}", cmd.desc, previousTx.feerate, targetFeerate) val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, txPublishContext), "tx-funder-rbf") - txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Left(previousTx), targetFeerate) + txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), Left(previousTx), targetFeerate) Behaviors.receiveMessagePartial { case WrappedFundingResult(result) => result match { @@ -292,10 +291,11 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, // situation where we have one transaction in the mempool and wait for it to confirm. private def publishReplacement(previousTx: FundedTx, bumpedTx: FundedTx): Behavior[Command] = { val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, txPublishContext), s"mempool-tx-monitor-${bumpedTx.signedTx.txid}") - val parentTx_opt = cmd.txInfo match { + val parentTx_opt = cmd.tx match { // Anchor output transactions are packaged with the corresponding commitment transaction. - case _: Transactions.ClaimAnchorOutputTx => Some(cmd.commitTx) - case _ => None + case _: ReplaceableAnchor => Some(cmd.tx.commitTx) + case _: ReplaceableHtlc => None + case _: ReplaceableClaimHtlc => None } txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), bumpedTx.signedTx, parentTx_opt, cmd.input, nodeParams.channelConf.minDepth, cmd.desc, bumpedTx.fee) Behaviors.receiveMessagePartial { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala index 6f9d74b0f7..8d6a263d03 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxPublisher.scala @@ -19,14 +19,12 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.eventstream.EventStream import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} import akka.actor.typed.{ActorRef, Behavior} -import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Satoshi, Transaction, TxId} import fr.acinq.eclair.blockchain.CurrentBlockHeight import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.blockchain.fee.ConfirmationTarget -import fr.acinq.eclair.channel.FullCommitment -import fr.acinq.eclair.crypto.keymanager.ChannelKeys -import fr.acinq.eclair.transactions.Transactions.{ClaimAnchorOutputTx, ReplaceableTransactionWithInputInfo, TransactionWithInputInfo} +import fr.acinq.eclair.transactions.Transactions.TransactionWithInputInfo import fr.acinq.eclair.{BlockHeight, Logs, NodeParams} import java.util.UUID @@ -92,25 +90,10 @@ object TxPublisher { /** * Publish an unsigned transaction that can be RBF-ed. - * - * @param commitTx commitment transaction that this transaction is spending. */ - case class PublishReplaceableTx(txInfo: ReplaceableTransactionWithInputInfo, channelKeys: ChannelKeys, commitment: FullCommitment, commitTx: Transaction, confirmationTarget: ConfirmationTarget) extends PublishTx { - override def input: OutPoint = txInfo.input.outPoint - override def desc: String = txInfo.desc - - lazy val fundingKey: PrivateKey = channelKeys.fundingKey(commitment.fundingTxIndex) - - lazy val remotePerCommitmentPoint: PublicKey = commitment.nextRemoteCommit_opt match { - case Some(c) if input.txid == c.commit.txid => c.commit.remotePerCommitmentPoint - case _ => commitment.remoteCommit.remotePerCommitmentPoint - } - - /** True if we're trying to bump our local commit with an anchor transaction. */ - lazy val isLocalCommitAnchor: Boolean = txInfo match { - case txInfo: ClaimAnchorOutputTx => txInfo.input.outPoint.txid == commitment.localCommit.commitTxAndRemoteSig.commitTx.tx.txid - case _ => false - } + case class PublishReplaceableTx(tx: ReplaceableTx, confirmationTarget: ConfirmationTarget) extends PublishTx { + override def input: OutPoint = tx.txInfo.input.outPoint + override def desc: String = tx.txInfo.desc } sealed trait PublishTxResult extends Command { def cmd: PublishTx } @@ -244,9 +227,6 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact val attempts = pending.getOrElse(cmd.input, PublishAttempts.empty) attempts.replaceableAttempt_opt match { case Some(currentAttempt) => - if (currentAttempt.cmd.txInfo.tx.txOut.headOption.map(_.publicKeyScript) != cmd.txInfo.tx.txOut.headOption.map(_.publicKeyScript)) { - log.error("replaceable {} sends to a different address than the previous attempt, this should not happen: proposed={}, previous={}", currentAttempt.cmd.desc, cmd.txInfo, currentAttempt.cmd.txInfo) - } val currentConfirmationTarget = currentAttempt.confirmationTarget def updateConfirmationTarget() = { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala index 7d1ec776f4..0b5197fe92 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala @@ -23,7 +23,7 @@ import fr.acinq.bitcoin.scalacompat.Script._ import fr.acinq.bitcoin.scalacompat._ import fr.acinq.bitcoin.{ScriptFlags, ScriptTree} import fr.acinq.eclair._ -import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.ChannelSpendSignature import fr.acinq.eclair.channel.ChannelSpendSignature._ import fr.acinq.eclair.crypto.keymanager.{CommitmentPublicKeys, LocalCommitmentKeys, RemoteCommitmentKeys} @@ -361,8 +361,6 @@ object Transactions { } } - sealed trait ReplaceableTransactionWithInputInfo extends ForceCloseTransaction - /** * It's important to note that htlc transactions with the default commitment format are not actually replaceable: only * anchor outputs htlc transactions are replaceable. We should have used different types for these different kinds of @@ -375,7 +373,7 @@ object Transactions { * The next time we introduce a new type of commitment, we should avoid repeating that mistake and define separate * types right from the start. */ - sealed trait HtlcTx extends ReplaceableTransactionWithInputInfo { + sealed trait HtlcTx extends ForceCloseTransaction { // @formatter:off def htlcId: Long def paymentHash: ByteVector32 @@ -526,7 +524,7 @@ object Transactions { } } - sealed trait ClaimHtlcTx extends ReplaceableTransactionWithInputInfo { + sealed trait ClaimHtlcTx extends ForceCloseTransaction { // @formatter:off def htlcId: Long def paymentHash: ByteVector32 @@ -642,7 +640,7 @@ object Transactions { } /** This transaction claims our anchor output in either the local or remote commitment, to CPFP and get it confirmed. */ - case class ClaimAnchorOutputTx(input: InputInfo, tx: Transaction, confirmationTarget: ConfirmationTarget) extends ReplaceableTransactionWithInputInfo { + case class ClaimAnchorOutputTx(input: InputInfo, tx: Transaction) extends ForceCloseTransaction { override val desc: String = "local-anchor" def sign(fundingKey: PrivateKey, commitKeys: LocalCommitmentKeys, commitmentFormat: CommitmentFormat, extraUtxos: Map[OutPoint, TxOut]): ClaimAnchorOutputTx = { @@ -677,7 +675,7 @@ object Transactions { } } - def createUnsignedTx(fundingKey: PrivateKey, commitKeys: CommitmentPublicKeys, commitTx: Transaction, confirmationTarget: ConfirmationTarget, commitmentFormat: CommitmentFormat): Either[TxGenerationSkipped, ClaimAnchorOutputTx] = { + def createUnsignedTx(fundingKey: PrivateKey, commitKeys: CommitmentPublicKeys, commitTx: Transaction, commitmentFormat: CommitmentFormat): Either[TxGenerationSkipped, ClaimAnchorOutputTx] = { val pubkeyScript = redeemInfo(fundingKey, commitKeys, commitmentFormat).pubkeyScript findPubKeyScriptIndex(commitTx, pubkeyScript).map { outputIndex => val input = InputInfo(OutPoint(commitTx, outputIndex), commitTx.txOut(outputIndex), ByteVector.empty) @@ -687,7 +685,7 @@ object Transactions { txOut = Nil, // anchor is only used to bump fees, the output will be added later depending on available inputs lockTime = 0 ) - ClaimAnchorOutputTx(input, unsignedTx, confirmationTarget) + ClaimAnchorOutputTx(input, unsignedTx) } } } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala index 0e275a96ce..0b337d8dd6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version2/ChannelCodecs2.scala @@ -117,7 +117,6 @@ private[channel] object ChannelCodecs2 { private val missingHtlcExpiry: Codec[CltvExpiry] = provide(CltvExpiry(0)) private val missingPaymentHash: Codec[ByteVector32] = provide(ByteVector32.Zeroes) private val missingToSelfDelay: Codec[CltvExpiryDelta] = provide(CltvExpiryDelta(0)) - private val missingConfirmationTarget: Codec[ConfirmationTarget] = provide(ConfirmationTarget.Absolute(BlockHeight(0))).upcast[ConfirmationTarget] val commitTxCodec: Codec[CommitTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[CommitTx] val htlcSuccessTxCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("htlcExpiry" | missingHtlcExpiry)).as[HtlcSuccessTx] @@ -131,9 +130,9 @@ private[channel] object ChannelCodecs2 { val mainPenaltyTxCodec: Codec[MainPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("toSelfDelay" | missingToSelfDelay)).as[MainPenaltyTx] val htlcPenaltyTxCodec: Codec[HtlcPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | missingPaymentHash) :: ("htlcExpiry" | missingHtlcExpiry)).as[HtlcPenaltyTx] val claimHtlcDelayedOutputPenaltyTxCodec: Codec[ClaimHtlcDelayedOutputPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("toSelfDelay" | missingToSelfDelay)).as[ClaimHtlcDelayedOutputPenaltyTx] - val claimLocalAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmationTarget" | missingConfirmationTarget)).as[ClaimAnchorOutputTx] + val claimLocalAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimAnchorOutputTx] // We previously created an unused transaction spending the remote anchor (after the 16-blocks delay). - val unusedRemoteAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: provide(ConfirmationTarget.Absolute(BlockHeight(42))).upcast[ConfirmationTarget]).as[ClaimAnchorOutputTx] + val unusedRemoteAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimAnchorOutputTx] val closingTxCodec: Codec[ClosingTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("outputIndex" | optional(bool8, outputInfoCodec))).as[ClosingTx] val claimRemoteCommitMainOutputTxCodec: Codec[ClaimRemoteCommitMainOutputTx] = discriminated[ClaimRemoteCommitMainOutputTx].by(uint8) @@ -144,8 +143,6 @@ private[channel] object ChannelCodecs2 { .typecase(0x01, claimLocalAnchorOutputTxCodec) .typecase(0x02, unusedRemoteAnchorOutputTxCodec) - def filterUnusedRemoteAnchorOutputTx(anchorTxs: List[ClaimAnchorOutputTx]): List[ClaimAnchorOutputTx] = anchorTxs.filter(_.confirmationTarget != ConfirmationTarget.Absolute(BlockHeight(42))) - val htlcTxCodec: Codec[HtlcTx] = discriminated[HtlcTx].by(uint8) .typecase(0x01, htlcSuccessTxCodec) .typecase(0x02, htlcTimeoutTxCodec) @@ -253,14 +250,14 @@ private[channel] object ChannelCodecs2 { ("claimMainDelayedOutputTx" | optional(bool8, claimLocalDelayedOutputTxCodec)) :: ("htlcTxs" | mapCodec(outPointCodec, optional(bool8, htlcTxCodec))) :: ("claimHtlcDelayedTx" | listOfN(uint16, htlcDelayedTxCodec)) :: - ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec).xmap(txs => filterUnusedRemoteAnchorOutputTx(txs), { txs: List[ClaimAnchorOutputTx] => txs })) :: + ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec)) :: ("spent" | spentMapCodec)).as[LocalCommitPublished] val remoteCommitPublishedCodec: Codec[RemoteCommitPublished] = ( ("commitTx" | txCodec) :: ("claimMainOutputTx" | optional(bool8, claimRemoteCommitMainOutputTxCodec)) :: ("claimHtlcTxs" | mapCodec(outPointCodec, optional(bool8, claimHtlcTxCodec))) :: - ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec).xmap(txs => filterUnusedRemoteAnchorOutputTx(txs), { txs: List[ClaimAnchorOutputTx] => txs })) :: + ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec)) :: ("spent" | spentMapCodec)).as[RemoteCommitPublished] val revokedCommitPublishedCodec: Codec[RevokedCommitPublished] = ( diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala index d308bf6e0c..8552a82575 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3.scala @@ -129,7 +129,6 @@ private[channel] object ChannelCodecs3 { private val missingHtlcExpiry: Codec[CltvExpiry] = provide(CltvExpiry(0)) private val missingPaymentHash: Codec[ByteVector32] = provide(ByteVector32.Zeroes) private val missingToSelfDelay: Codec[CltvExpiryDelta] = provide(CltvExpiryDelta(0)) - private val missingConfirmationTarget: Codec[ConfirmationTarget] = provide(ConfirmationTarget.Absolute(BlockHeight(0))).upcast[ConfirmationTarget] val commitTxCodec: Codec[CommitTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[CommitTx] val htlcSuccessTxCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("htlcExpiry" | cltvExpiry)).as[HtlcSuccessTx] @@ -148,15 +147,17 @@ private[channel] object ChannelCodecs3 { val mainPenaltyTxCodec: Codec[MainPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("toSelfDelay" | missingToSelfDelay)).as[MainPenaltyTx] val htlcPenaltyTxCodec: Codec[HtlcPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | missingPaymentHash) :: ("htlcExpiry" | missingHtlcExpiry)).as[HtlcPenaltyTx] val claimHtlcDelayedOutputPenaltyTxCodec: Codec[ClaimHtlcDelayedOutputPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("toSelfDelay" | missingToSelfDelay)).as[ClaimHtlcDelayedOutputPenaltyTx] - val claimLocalAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmationTarget" | blockHeight.map(ConfirmationTarget.Absolute).decodeOnly).upcast[ConfirmationTarget]).as[ClaimAnchorOutputTx] - private val claimLocalAnchorOutputTxNoConfirmCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmationTarget" | missingConfirmationTarget)).as[ClaimAnchorOutputTx] + val claimLocalAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimAnchorOutputTx] + private val claimLocalAnchorOutputTxWithConfirmationTargetCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmationTarget" | ignore(32))).as[ClaimAnchorOutputTx] + private val claimLocalAnchorOutputTxNoConfirmCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimAnchorOutputTx] // We previously created an unused transaction spending the remote anchor (after the 16-blocks delay). - val unusedRemoteAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: provide(ConfirmationTarget.Absolute(BlockHeight(42))).upcast[ConfirmationTarget]).as[ClaimAnchorOutputTx] + val unusedRemoteAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimAnchorOutputTx] val closingTxCodec: Codec[ClosingTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("outputIndex" | optional(bool8, outputInfoCodec))).as[ClosingTx] val txWithInputInfoCodec: Codec[TransactionWithInputInfo] = discriminated[TransactionWithInputInfo].by(uint16) // Important: order matters! - .typecase(0x20, claimLocalAnchorOutputTxCodec) + .typecase(0x25, claimLocalAnchorOutputTxCodec) + .typecase(0x20, claimLocalAnchorOutputTxWithConfirmationTargetCodec) .typecase(0x21, htlcSuccessTxCodec) .typecase(0x22, htlcTimeoutTxCodec) .typecase(0x23, claimHtlcSuccessTxCodec) @@ -184,12 +185,11 @@ private[channel] object ChannelCodecs3 { val claimAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = discriminated[ClaimAnchorOutputTx].by(uint8) // Important: order matters! - .typecase(0x11, claimLocalAnchorOutputTxCodec) + .typecase(0x12, claimLocalAnchorOutputTxCodec) + .typecase(0x11, claimLocalAnchorOutputTxWithConfirmationTargetCodec) .typecase(0x01, claimLocalAnchorOutputTxNoConfirmCodec) .typecase(0x02, unusedRemoteAnchorOutputTxCodec) - def filterUnusedRemoteAnchorOutputTx(anchorTxs: List[ClaimAnchorOutputTx]): List[ClaimAnchorOutputTx] = anchorTxs.filter(_.confirmationTarget != ConfirmationTarget.Absolute(BlockHeight(42))) - val htlcTxCodec: Codec[HtlcTx] = discriminated[HtlcTx].by(uint8) // Important: order matters! .typecase(0x11, htlcSuccessTxCodec) @@ -320,14 +320,14 @@ private[channel] object ChannelCodecs3 { ("claimMainDelayedOutputTx" | optional(bool8, claimLocalDelayedOutputTxCodec)) :: ("htlcTxs" | mapCodec(outPointCodec, optional(bool8, htlcTxCodec))) :: ("claimHtlcDelayedTx" | listOfN(uint16, htlcDelayedTxCodec)) :: - ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec).xmap(txs => filterUnusedRemoteAnchorOutputTx(txs), { txs: List[ClaimAnchorOutputTx] => txs })) :: + ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec)) :: ("spent" | spentMapCodec)).as[LocalCommitPublished] val remoteCommitPublishedCodec: Codec[RemoteCommitPublished] = ( ("commitTx" | txCodec) :: ("claimMainOutputTx" | optional(bool8, claimRemoteCommitMainOutputTxCodec)) :: ("claimHtlcTxs" | mapCodec(outPointCodec, optional(bool8, claimHtlcTxCodec))) :: - ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec).xmap(txs => filterUnusedRemoteAnchorOutputTx(txs), { txs: List[ClaimAnchorOutputTx] => txs })) :: + ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec)) :: ("spent" | spentMapCodec)).as[RemoteCommitPublished] val revokedCommitPublishedCodec: Codec[RevokedCommitPublished] = ( diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala index 368bebe747..3f5f622ead 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version4/ChannelCodecs4.scala @@ -20,7 +20,7 @@ import fr.acinq.eclair.{Alias, BlockHeight, CltvExpiry, CltvExpiryDelta, Feature import scodec.bits.{BitVector, ByteVector} import scodec.codecs._ import scodec.{Attempt, Codec} -import shapeless.HList +import shapeless.{::, HList, HNil} private[channel] object ChannelCodecs4 { @@ -125,7 +125,6 @@ private[channel] object ChannelCodecs4 { private val missingHtlcExpiry: Codec[CltvExpiry] = provide(CltvExpiry(0)) private val missingPaymentHash: Codec[ByteVector32] = provide(ByteVector32.Zeroes) private val missingToSelfDelay: Codec[CltvExpiryDelta] = provide(CltvExpiryDelta(0)) - private val missingConfirmationTarget: Codec[ConfirmationTarget] = provide(ConfirmationTarget.Absolute(BlockHeight(0))).upcast[ConfirmationTarget] private val blockHeightConfirmationTarget: Codec[ConfirmationTarget.Absolute] = blockHeight.xmap(ConfirmationTarget.Absolute, _.confirmBefore) private val confirmationPriority: Codec[ConfirmationPriority] = discriminated[ConfirmationPriority].by(uint8) .typecase(0x01, provide(ConfirmationPriority.Slow)) @@ -160,11 +159,14 @@ private[channel] object ChannelCodecs4 { val htlcPenaltyTxCodec: Codec[HtlcPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcExpiry" | cltvExpiry)).as[HtlcPenaltyTx] private val claimHtlcDelayedOutputPenaltyTxNoToSelfDelayCodec: Codec[ClaimHtlcDelayedOutputPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("toSelfDelay" | missingToSelfDelay)).as[ClaimHtlcDelayedOutputPenaltyTx] val claimHtlcDelayedOutputPenaltyTxCodec: Codec[ClaimHtlcDelayedOutputPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("toSelfDelay" | cltvExpiryDelta)).as[ClaimHtlcDelayedOutputPenaltyTx] - val claimLocalAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmationTarget" | confirmationTarget)).as[ClaimAnchorOutputTx] - private val claimLocalAnchorOutputTxBlockHeightConfirmCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmationTarget" | blockHeightConfirmationTarget).upcast[ConfirmationTarget]).as[ClaimAnchorOutputTx] - private val claimLocalAnchorOutputTxNoConfirmCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmationTarget" | missingConfirmationTarget)).as[ClaimAnchorOutputTx] + val claimLocalAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimAnchorOutputTx] + private val claimLocalAnchorOutputTxWithConfirmationTargetCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmationTarget" | confirmationTarget)).map { + case inputInfo :: tx :: _ :: HNil => ClaimAnchorOutputTx(inputInfo, tx) + }.decodeOnly + private val claimLocalAnchorOutputTxBlockHeightConfirmCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmationTarget" | ignore(32))).as[ClaimAnchorOutputTx] + private val claimLocalAnchorOutputTxNoConfirmCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimAnchorOutputTx] // We previously created an unused transaction spending the remote anchor (after the 16-blocks delay). - private val unusedRemoteAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: provide(ConfirmationTarget.Absolute(BlockHeight(42))).upcast[ConfirmationTarget]).as[ClaimAnchorOutputTx] + private val unusedRemoteAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimAnchorOutputTx] val closingTxCodec: Codec[ClosingTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("outputIndex" | optional(bool8, outputInfoCodec))).as[ClosingTx] val claimRemoteCommitMainOutputTxCodec: Codec[ClaimRemoteCommitMainOutputTx] = discriminated[ClaimRemoteCommitMainOutputTx].by(uint8) @@ -173,13 +175,12 @@ private[channel] object ChannelCodecs4 { val claimAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = discriminated[ClaimAnchorOutputTx].by(uint8) // Important: order matters! - .typecase(0x12, claimLocalAnchorOutputTxCodec) + .typecase(0x13, claimLocalAnchorOutputTxCodec) + .typecase(0x12, claimLocalAnchorOutputTxWithConfirmationTargetCodec) .typecase(0x11, claimLocalAnchorOutputTxBlockHeightConfirmCodec) .typecase(0x01, claimLocalAnchorOutputTxNoConfirmCodec) .typecase(0x02, unusedRemoteAnchorOutputTxCodec) - def filterUnusedRemoteAnchorOutputTx(anchorTxs: List[ClaimAnchorOutputTx]): List[ClaimAnchorOutputTx] = anchorTxs.filter(_.confirmationTarget != ConfirmationTarget.Absolute(BlockHeight(42))) - val htlcTxCodec: Codec[HtlcTx] = discriminated[HtlcTx].by(uint8) // Important: order matters! .typecase(0x13, htlcTimeoutTxCodec) @@ -613,7 +614,7 @@ private[channel] object ChannelCodecs4 { ("claimMainDelayedOutputTx" | optional(bool8, claimLocalDelayedOutputTxNoToSelfDelayCodec)) :: ("htlcTxs" | mapCodec(outPointCodec, optional(bool8, htlcTxCodec))) :: ("claimHtlcDelayedTx" | listOfN(uint16, htlcDelayedTxNoToSelfDelayCodec)) :: - ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec).xmap(txs => filterUnusedRemoteAnchorOutputTx(txs), { txs: List[ClaimAnchorOutputTx] => txs })) :: + ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec)) :: ("spent" | spentMapCodec)).as[LocalCommitPublished] val localCommitPublishedCodec: Codec[LocalCommitPublished] = ( @@ -621,14 +622,14 @@ private[channel] object ChannelCodecs4 { ("claimMainDelayedOutputTx" | optional(bool8, claimLocalDelayedOutputTxCodec)) :: ("htlcTxs" | mapCodec(outPointCodec, optional(bool8, htlcTxCodec))) :: ("claimHtlcDelayedTx" | listOfN(uint16, htlcDelayedTxCodec)) :: - ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec).xmap(txs => filterUnusedRemoteAnchorOutputTx(txs), { txs: List[ClaimAnchorOutputTx] => txs })) :: + ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec)) :: ("spent" | spentMapCodec)).as[LocalCommitPublished] val remoteCommitPublishedCodec: Codec[RemoteCommitPublished] = ( ("commitTx" | txCodec) :: ("claimMainOutputTx" | optional(bool8, claimRemoteCommitMainOutputTxCodec)) :: ("claimHtlcTxs" | mapCodec(outPointCodec, optional(bool8, claimHtlcTxCodec))) :: - ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec).xmap(txs => filterUnusedRemoteAnchorOutputTx(txs), { txs: List[ClaimAnchorOutputTx] => txs })) :: + ("claimAnchorTxs" | listOfN(uint16, claimAnchorOutputTxCodec)) :: ("spent" | spentMapCodec)).as[RemoteCommitPublished] private val legacyRevokedCommitPublishedCodec: Codec[RevokedCommitPublished] = ( diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala index e380760364..36413742a3 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/balance/CheckBalanceSpec.scala @@ -94,7 +94,7 @@ class CheckBalanceSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // in response to that, alice publishes her claim txs alice2blockchain.expectMsgType[PublishReplaceableTx] // claim-anchor alice2blockchain.expectMsgType[PublishFinalTx] // claim-main - val claimHtlcTxs = (1 to 3).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx) + val claimHtlcTxs = (1 to 3).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx].tx.txInfo.tx) val commitments = alice.stateData.asInstanceOf[DATA_CLOSING].commitments val remoteCommitPublished = alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get @@ -143,7 +143,7 @@ class CheckBalanceSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // in response to that, alice publishes her claim txs alice2blockchain.expectMsgType[PublishReplaceableTx] // claim-anchor alice2blockchain.expectMsgType[PublishFinalTx] // claim-main - val claimHtlcTxs = (1 to 2).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx) + val claimHtlcTxs = (1 to 2).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx].tx.txInfo.tx) val commitments = alice.stateData.asInstanceOf[DATA_CLOSING].commitments val remoteCommitPublished = alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.get diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala deleted file mode 100644 index 47b57d9abe..0000000000 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala +++ /dev/null @@ -1,267 +0,0 @@ -/* - * Copyright 2021 ACINQ SAS - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package fr.acinq.eclair.channel.publish - -import fr.acinq.bitcoin.scalacompat.{Crypto, OutPoint, SatoshiLong, Script, Transaction, TxIn, TxOut} -import fr.acinq.eclair.TestUtils.randomTxId -import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, FeeratePerKw} -import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature -import fr.acinq.eclair.channel.Helpers.Funding -import fr.acinq.eclair.channel._ -import fr.acinq.eclair.channel.publish.ReplaceableTxFunder.AdjustPreviousTxOutputResult.{AddWalletInputs, TxOutputAdjusted} -import fr.acinq.eclair.channel.publish.ReplaceableTxFunder._ -import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._ -import fr.acinq.eclair.crypto.keymanager.{CommitmentPublicKeys, RemoteCommitmentKeys} -import fr.acinq.eclair.transactions.Scripts -import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.{BlockHeight, CltvExpiry, TestKitBaseClass, randomBytes32, randomKey} -import org.mockito.IdiomaticMockito.StubbingOps -import org.mockito.MockitoSugar.mock -import org.scalatest.Tag -import org.scalatest.funsuite.AnyFunSuiteLike -import scodec.bits.ByteVector - -import scala.util.Random - -class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { - - private def createAnchorTx(): (CommitTx, ClaimAnchorOutputTx) = { - val anchorKey = randomKey() - val anchorScript = Scripts.anchor(anchorKey.publicKey) - val fundingScript = Script.write(Scripts.multiSig2of2(randomKey().publicKey, randomKey().publicKey)) - val commitInput = Funding.makeFundingInputInfo(randomTxId(), 1, 500 sat, randomKey().publicKey, randomKey().publicKey, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) - val commitTx = Transaction( - 2, - Seq(TxIn(commitInput.outPoint, fundingScript, 0, Scripts.witness2of2(PlaceHolderSig, PlaceHolderSig, randomKey().publicKey, randomKey().publicKey))), - Seq(TxOut(330 sat, Script.pay2wsh(anchorScript))), - 0 - ) - val anchorTx = ClaimAnchorOutputTx( - InputInfo(OutPoint(commitTx, 0), commitTx.txOut.head, ByteVector.empty), - Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 0)), Nil, 0), - ConfirmationTarget.Absolute(BlockHeight(0)) - ) - (CommitTx(commitInput, commitTx), anchorTx) - } - - private def createHtlcTxs(): (Transaction, HtlcSuccessWithWitnessData, HtlcTimeoutWithWitnessData) = { - val preimage = randomBytes32() - val paymentHash = Crypto.sha256(preimage) - val expiry = CltvExpiry(850_000) - val commitmentKeys = CommitmentPublicKeys(randomKey().publicKey, randomKey().publicKey, randomKey().publicKey, randomKey().publicKey, randomKey().publicKey) - val htlcSuccessScript = Scripts.htlcReceived(commitmentKeys, paymentHash, expiry, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) - val htlcTimeoutScript = Scripts.htlcOffered(commitmentKeys, randomBytes32(), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) - val commitTx = Transaction( - 2, - Seq(TxIn(OutPoint(randomTxId(), 1), Script.write(Script.pay2wpkh(randomKey().publicKey)), 0, Script.witnessPay2wpkh(randomKey().publicKey, PlaceHolderSig))), - Seq(TxOut(5000 sat, Script.pay2wsh(htlcSuccessScript)), TxOut(4000 sat, Script.pay2wsh(htlcTimeoutScript))), - 0 - ) - val htlcSuccess = HtlcSuccessWithWitnessData(HtlcSuccessTx( - InputInfo(OutPoint(commitTx, 0), commitTx.txOut.head, ByteVector.empty), - Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(randomKey().publicKey))), 0), - paymentHash, - 17, - expiry, - ), PlaceHolderSig, preimage) - val htlcTimeout = HtlcTimeoutWithWitnessData(HtlcTimeoutTx( - InputInfo(OutPoint(commitTx, 1), commitTx.txOut.last, ByteVector.empty), - Transaction(2, Seq(TxIn(OutPoint(commitTx, 1), ByteVector.empty, 0)), Seq(TxOut(4000 sat, Script.pay2wpkh(randomKey().publicKey))), 0), - paymentHash, - 12, - expiry - ), PlaceHolderSig) - (commitTx, htlcSuccess, htlcTimeout) - } - - private def createClaimHtlcTx(commitKeys_opt: Option[RemoteCommitmentKeys] = None): (Transaction, ClaimHtlcSuccessWithWitnessData, ClaimHtlcTimeoutWithWitnessData) = { - val preimage = randomBytes32() - val paymentHash = Crypto.sha256(preimage) - val expiry = CltvExpiry(850_000) - val commitKeys = commitKeys_opt match { - case Some(commitKeys) => commitKeys.publicKeys - case None => CommitmentPublicKeys(randomKey().publicKey, randomKey().publicKey, randomKey().publicKey, randomKey().publicKey, randomKey().publicKey) - } - val htlcSuccessScript = Scripts.htlcReceived(commitKeys, paymentHash, expiry, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) - val htlcTimeoutScript = Scripts.htlcOffered(commitKeys, randomBytes32(), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) - val commitTx = Transaction( - 2, - Seq(TxIn(OutPoint(randomTxId(), 1), Script.write(Script.pay2wpkh(randomKey().publicKey)), 0, Script.witnessPay2wpkh(randomKey().publicKey, PlaceHolderSig))), - Seq(TxOut(5000 sat, Script.pay2wsh(htlcSuccessScript)), TxOut(5000 sat, Script.pay2wsh(htlcTimeoutScript))), - 0 - ) - val claimHtlcSuccess = ClaimHtlcSuccessWithWitnessData(ClaimHtlcSuccessTx( - InputInfo(OutPoint(commitTx, 0), commitTx.txOut.head, ByteVector.empty), - Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(randomKey().publicKey))), 0), - paymentHash, - 5, - expiry - ), preimage) - val claimHtlcTimeout = ClaimHtlcTimeoutWithWitnessData(ClaimHtlcTimeoutTx( - InputInfo(OutPoint(commitTx, 1), commitTx.txOut.last, ByteVector.empty), - Transaction(2, Seq(TxIn(OutPoint(commitTx, 1), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(randomKey().publicKey))), 0), - paymentHash, - 7, - expiry, - )) - (commitTx, claimHtlcSuccess, claimHtlcTimeout) - } - - test("adjust claim htlc tx change amount") { - val dustLimit = 750 sat - val commitKeys = RemoteCommitmentKeys(Right(randomKey()), randomKey().publicKey, randomKey().publicKey, randomKey(), randomKey().publicKey, randomKey().publicKey) - val (_, claimHtlcSuccess, claimHtlcTimeout) = createClaimHtlcTx(Some(commitKeys)) - for (claimHtlc <- Seq(claimHtlcSuccess, claimHtlcTimeout)) { - var previousAmount = claimHtlc.txInfo.tx.txOut.head.amount - for (i <- 1 to 100) { - val targetFeerate = FeeratePerKw(250 * i sat) - adjustClaimHtlcTxOutput(claimHtlc, targetFeerate, dustLimit, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) match { - case Left(_) => assert(targetFeerate >= FeeratePerKw(7000 sat)) - case Right(updatedClaimHtlc) => - assert(updatedClaimHtlc.txInfo.tx.txIn.length == 1) - assert(updatedClaimHtlc.txInfo.tx.txOut.length == 1) - assert(updatedClaimHtlc.txInfo.tx.txOut.head.amount < previousAmount) - previousAmount = updatedClaimHtlc.txInfo.tx.txOut.head.amount - val signedTx = updatedClaimHtlc match { - case ClaimHtlcSuccessWithWitnessData(txInfo, preimage) => txInfo.sign(commitKeys, preimage, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) - case ClaimHtlcTimeoutWithWitnessData(txInfo) => txInfo.sign(commitKeys, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) - } - val txFeerate = fee2rate(signedTx.fee, signedTx.tx.weight()) - assert(targetFeerate * 0.9 <= txFeerate && txFeerate <= targetFeerate * 1.1, s"actualFeerate=$txFeerate targetFeerate=$targetFeerate") - } - } - } - } - - test("adjust previous anchor transaction outputs") { - val (commitTx, initialAnchorTx) = createAnchorTx() - val previousAnchorTx = ClaimAnchorWithWitnessData(initialAnchorTx).updateTx(initialAnchorTx.tx.copy( - txIn = Seq( - initialAnchorTx.tx.txIn.head, - // The previous funding attempt added two wallet inputs: - TxIn(OutPoint(randomTxId(), 3), ByteVector.empty, 0, Script.witnessPay2wpkh(randomKey().publicKey, PlaceHolderSig)), - TxIn(OutPoint(randomTxId(), 1), ByteVector.empty, 0, Script.witnessPay2wpkh(randomKey().publicKey, PlaceHolderSig)) - ), - // And a change output: - txOut = Seq(TxOut(5000 sat, Script.pay2wpkh(randomKey().publicKey))) - )) - - val commitment = mock[FullCommitment] - val localParams = mock[LocalParams] - localParams.dustLimit.returns(1000 sat) - commitment.localParams.returns(localParams) - val localCommit = mock[LocalCommit] - localCommit.commitTxAndRemoteSig.returns(CommitTxAndRemoteSig(commitTx, IndividualSignature(PlaceHolderSig))) - commitment.localCommit.returns(localCommit) - - // We can handle a small feerate update by lowering the change output. - val TxOutputAdjusted(feerateUpdate1) = adjustPreviousTxOutput(FundedTx(previousAnchorTx, 12000 sat, FeeratePerKw(2500 sat), Map.empty), FeeratePerKw(5000 sat), commitment, commitTx.tx) - assert(feerateUpdate1.txInfo.tx.txIn == previousAnchorTx.txInfo.tx.txIn) - assert(feerateUpdate1.txInfo.tx.txOut.length == 1) - val TxOutputAdjusted(feerateUpdate2) = adjustPreviousTxOutput(FundedTx(previousAnchorTx, 12000 sat, FeeratePerKw(2500 sat), Map.empty), FeeratePerKw(6000 sat), commitment, commitTx.tx) - assert(feerateUpdate2.txInfo.tx.txIn == previousAnchorTx.txInfo.tx.txIn) - assert(feerateUpdate2.txInfo.tx.txOut.length == 1) - assert(feerateUpdate2.txInfo.tx.txOut.head.amount < feerateUpdate1.txInfo.tx.txOut.head.amount) - - // But if the feerate increase is too large, we must add new wallet inputs. - val AddWalletInputs(previousTx) = adjustPreviousTxOutput(FundedTx(previousAnchorTx, 12000 sat, FeeratePerKw(2500 sat), Map.empty), FeeratePerKw(10000 sat), commitment, commitTx.tx) - assert(previousTx == previousAnchorTx) - } - - test("adjust previous htlc transaction outputs", Tag("fuzzy")) { - val commitment = mock[FullCommitment] - val localParams = mock[LocalParams] - localParams.dustLimit.returns(600 sat) - commitment.localParams.returns(localParams) - val (commitTx, initialHtlcSuccess, initialHtlcTimeout) = createHtlcTxs() - for (initialHtlcTx <- Seq(initialHtlcSuccess, initialHtlcTimeout)) { - val previousTx = initialHtlcTx.updateTx(initialHtlcTx.txInfo.tx.copy( - txIn = Seq( - initialHtlcTx.txInfo.tx.txIn.head, - // The previous funding attempt added three wallet inputs: - TxIn(OutPoint(randomTxId(), 3), ByteVector.empty, 0, Script.witnessPay2wpkh(randomKey().publicKey, PlaceHolderSig)), - TxIn(OutPoint(randomTxId(), 1), ByteVector.empty, 0, Script.witnessPay2wpkh(randomKey().publicKey, PlaceHolderSig)), - TxIn(OutPoint(randomTxId(), 5), ByteVector.empty, 0, Script.witnessPay2wpkh(randomKey().publicKey, PlaceHolderSig)) - ), - txOut = Seq( - initialHtlcTx.txInfo.tx.txOut.head, - // And one change output: - TxOut(5000 sat, Script.pay2wpkh(randomKey().publicKey)) - ) - )) - - // We can handle a small feerate update by lowering the change output. - val TxOutputAdjusted(feerateUpdate1) = adjustPreviousTxOutput(FundedTx(previousTx, 15000 sat, FeeratePerKw(2500 sat), Map.empty), FeeratePerKw(5000 sat), commitment, commitTx) - assert(feerateUpdate1.txInfo.tx.txIn == previousTx.txInfo.tx.txIn) - assert(feerateUpdate1.txInfo.tx.txOut.length == 2) - assert(feerateUpdate1.txInfo.tx.txOut.head == previousTx.txInfo.tx.txOut.head) - val TxOutputAdjusted(feerateUpdate2) = adjustPreviousTxOutput(FundedTx(previousTx, 15000 sat, FeeratePerKw(2500 sat), Map.empty), FeeratePerKw(6000 sat), commitment, commitTx) - assert(feerateUpdate2.txInfo.tx.txIn == previousTx.txInfo.tx.txIn) - assert(feerateUpdate2.txInfo.tx.txOut.length == 2) - assert(feerateUpdate2.txInfo.tx.txOut.head == previousTx.txInfo.tx.txOut.head) - assert(feerateUpdate2.txInfo.tx.txOut.last.amount < feerateUpdate1.txInfo.tx.txOut.last.amount) - - // If the previous funding attempt didn't add a change output, we must add new wallet inputs. - val previousTxNoChange = previousTx.updateTx(previousTx.txInfo.tx.copy(txOut = Seq(previousTx.txInfo.tx.txOut.head))) - val AddWalletInputs(tx) = adjustPreviousTxOutput(FundedTx(previousTxNoChange, 25000 sat, FeeratePerKw(2500 sat), Map.empty), FeeratePerKw(5000 sat), commitment, commitTx) - assert(tx == previousTxNoChange) - - for (_ <- 1 to 100) { - val amountIn = Random.nextInt(25_000_000).sat - val changeAmount = Random.nextInt(amountIn.toLong.toInt).sat - val fuzzyPreviousTx = previousTx.updateTx(previousTx.txInfo.tx.copy(txOut = Seq( - initialHtlcTx.txInfo.tx.txOut.head, - TxOut(changeAmount, Script.pay2wpkh(randomKey().publicKey)) - ))) - val targetFeerate = FeeratePerKw(2500 sat) + FeeratePerKw(Random.nextInt(20000).sat) - adjustPreviousTxOutput(FundedTx(fuzzyPreviousTx, amountIn, FeeratePerKw(2500 sat), Map.empty), targetFeerate, commitment, commitTx) match { - case AdjustPreviousTxOutputResult.Skip(_) => // nothing do check - case AddWalletInputs(tx) => assert(tx == fuzzyPreviousTx) - case TxOutputAdjusted(updatedTx) => - assert(updatedTx.txInfo.tx.txIn == fuzzyPreviousTx.txInfo.tx.txIn) - assert(Set(1, 2).contains(updatedTx.txInfo.tx.txOut.length)) - assert(updatedTx.txInfo.tx.txOut.head == fuzzyPreviousTx.txInfo.tx.txOut.head) - assert(updatedTx.txInfo.tx.txOut.last.amount >= 600.sat) - } - } - } - } - - test("adjust previous claim htlc transaction outputs") { - val commitment = mock[FullCommitment] - val localParams = mock[LocalParams] - localParams.dustLimit.returns(500 sat) - commitment.localParams.returns(localParams) - val (commitTx, claimHtlcSuccess, claimHtlcTimeout) = createClaimHtlcTx() - for (claimHtlc <- Seq(claimHtlcSuccess, claimHtlcTimeout)) { - var previousAmount = claimHtlc.txInfo.tx.txOut.head.amount - for (i <- 1 to 100) { - val targetFeerate = FeeratePerKw(250 * i sat) - adjustPreviousTxOutput(FundedTx(claimHtlc, claimHtlc.txInfo.amountIn, FeeratePerKw(2500 sat), Map.empty), targetFeerate, commitment, commitTx) match { - case AdjustPreviousTxOutputResult.Skip(_) => assert(targetFeerate >= FeeratePerKw(10000 sat)) - case AddWalletInputs(_) => fail("shouldn't add wallet inputs to claim-htlc-tx") - case TxOutputAdjusted(updatedTx) => - assert(updatedTx.txInfo.tx.txIn == claimHtlc.txInfo.tx.txIn) - assert(updatedTx.txInfo.tx.txOut.length == 1) - assert(updatedTx.txInfo.tx.txOut.head.amount < previousAmount) - previousAmount = updatedTx.txInfo.tx.txOut.head.amount - } - } - } - } - -} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala index 7984522be0..e7f834c511 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisherSpec.scala @@ -196,8 +196,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val publishCommitTx = alice2blockchain.expectMsg(PublishFinalTx(commitTx, commitTx.fee, None)) // Forward the anchor tx to the publisher. val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = ConfirmationTarget.Absolute(overrideCommitTarget)) - assert(publishAnchor.txInfo.input.outPoint.txid == signedCommitTx.txid) - assert(publishAnchor.txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(publishAnchor.tx.commitTx == signedCommitTx) + assert(publishAnchor.tx.isInstanceOf[ReplaceableLocalCommitAnchor]) (publishCommitTx, publishAnchor) } @@ -212,8 +212,8 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // Forward the anchor tx to the publisher. val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = ConfirmationTarget.Absolute(overrideCommitTarget)) - assert(publishAnchor.txInfo.input.outPoint.txid == commitTx.txid) - assert(publishAnchor.txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(publishAnchor.tx.commitTx == commitTx) + assert(publishAnchor.tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) (commitTx, publishAnchor) } @@ -248,7 +248,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } - test("commit tx confirmed, not spending anchor output") { + test("commit tx recently confirmed, not spending anchor output") { withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f => import f._ @@ -259,9 +259,30 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w setFeerate(FeeratePerKw(10_000 sat)) publisher ! Publish(probe.ref, anchorTx) - val result = probe.expectMsgType[TxRejected] - assert(result.cmd == anchorTx) - assert(result.reason == TxSkipped(retryNextBlock = false)) + inside(probe.expectMsgType[TxRejected]) { result => + assert(result.cmd == anchorTx) + // The commit tx isn't deeply confirmed yet: we will check again later. + assert(result.reason == TxSkipped(retryNextBlock = true)) + } + } + } + + test("commit tx deeply confirmed, not spending anchor output") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f => + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + generateBlocks(6) + + setFeerate(FeeratePerKw(10_000 sat)) + publisher ! Publish(probe.ref, anchorTx) + inside(probe.expectMsgType[TxRejected]) { result => + assert(result.cmd == anchorTx) + // The commit tx is deeply confirmed: we don't need to retry again. + assert(result.reason == TxSkipped(retryNextBlock = false)) + } } } @@ -274,7 +295,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 6) wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) - generateBlocks(1) + generateBlocks(6) publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] @@ -507,7 +528,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectMsg(commitTx.tx.txid) assert(getMempool().length == 1) - val maxFeerate = ReplaceableTxFunder.maxFeerate(anchorTx.txInfo, anchorTx.commitment, anchorTx.commitTx, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) + val maxFeerate = ReplaceableTxFunder.maxFeerate(anchorTx.tx, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) val targetFeerate = FeeratePerKw(50_000 sat) assert(maxFeerate <= targetFeerate / 2) setFeerate(targetFeerate, blockTarget = 12) @@ -590,9 +611,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // Note that we don't publish the remote commit, to simulate the case where the watch triggers but the remote commit is then evicted from our mempool. probe.send(alice, WatchFundingSpentTriggered(commitTx)) val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(publishAnchor.commitTx == commitTx) - assert(publishAnchor.txInfo.input.outPoint.txid == commitTx.txid) - assert(publishAnchor.txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(publishAnchor.tx.commitTx == commitTx) + assert(publishAnchor.tx.txInfo.input.outPoint.txid == commitTx.txid) + assert(publishAnchor.tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val targetFeerate = FeeratePerKw(3000 sat) setFeerate(targetFeerate) @@ -938,12 +959,12 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.send(alice, CMD_FORCECLOSE(probe.ref)) probe.expectMsgType[CommandSuccess[CMD_FORCECLOSE]] alice2blockchain.expectMsgType[PublishFinalTx] - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[PublishReplaceableTx].tx.isInstanceOf[ReplaceableLocalCommitAnchor]) alice2blockchain.expectMsgType[PublishFinalTx] // claim main output val htlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(htlcSuccess.txInfo.isInstanceOf[HtlcSuccessTx]) + assert(htlcSuccess.tx.isInstanceOf[ReplaceableHtlcSuccess]) val htlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(htlcTimeout.txInfo.isInstanceOf[HtlcTimeoutTx]) + assert(htlcTimeout.tx.isInstanceOf[ReplaceableHtlcTimeout]) // The remote commit tx has a few confirmations, but isn't deeply confirmed yet. val remoteCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys) @@ -1010,10 +1031,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.send(alice, CMD_FORCECLOSE(probe.ref)) probe.expectMsgType[CommandSuccess[CMD_FORCECLOSE]] alice2blockchain.expectMsgType[PublishFinalTx] - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[PublishReplaceableTx].tx.isInstanceOf[ReplaceableLocalCommitAnchor]) alice2blockchain.expectMsgType[PublishFinalTx] // claim main output val htlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(htlcTimeout.txInfo.isInstanceOf[HtlcTimeoutTx]) + assert(htlcTimeout.tx.isInstanceOf[ReplaceableHtlcTimeout]) // Ensure remote commit tx confirms. val nextRemoteCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.channelKeys) @@ -1058,12 +1079,12 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectMsg(signedCommitTx.txid) generateBlocks(1) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[PublishReplaceableTx].tx.isInstanceOf[ReplaceableLocalCommitAnchor]) alice2blockchain.expectMsgType[PublishFinalTx] // claim main output val htlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = ConfirmationTarget.Absolute(overrideHtlcTarget)) - assert(htlcSuccess.txInfo.isInstanceOf[HtlcSuccessTx]) + assert(htlcSuccess.tx.isInstanceOf[ReplaceableHtlcSuccess]) val htlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = ConfirmationTarget.Absolute(overrideHtlcTarget)) - assert(htlcTimeout.txInfo.isInstanceOf[HtlcTimeoutTx]) + assert(htlcTimeout.tx.isInstanceOf[ReplaceableHtlcTimeout]) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx alice2blockchain.expectMsgType[WatchTxConfirmed] // claim main output @@ -1099,7 +1120,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val htlcSuccessTx = getMempoolTxs(1).head val htlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, htlcSuccessTx.weight.toInt) assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.2, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee") - assert(htlcSuccessTx.fees <= htlcSuccess.txInfo.amountIn) + assert(htlcSuccessTx.fees <= htlcSuccess.tx.txInfo.amountIn) generateBlocks(6) system.eventStream.publish(CurrentBlockHeight(currentBlockHeight(probe))) @@ -1127,7 +1148,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val htlcTimeoutTx = getMempoolTxs(1).head val htlcTimeoutTargetFee = Transactions.weight2fee(targetFeerate, htlcTimeoutTx.weight.toInt) assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx.fees && htlcTimeoutTx.fees <= htlcTimeoutTargetFee * 1.2, s"actualFee=${htlcTimeoutTx.fees} targetFee=$htlcTimeoutTargetFee") - assert(htlcTimeoutTx.fees <= htlcTimeout.txInfo.amountIn) + assert(htlcTimeoutTx.fees <= htlcTimeout.tx.txInfo.amountIn) generateBlocks(6) system.eventStream.publish(CurrentBlockHeight(currentBlockHeight(probe))) @@ -1146,10 +1167,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 64) setFeerate(currentFeerate) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, currentFeerate) - assert(htlcSuccess.txInfo.fee > 0.sat) + assert(htlcSuccess.tx.txInfo.fee > 0.sat) assert(htlcSuccessTx.txIn.length == 1) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, currentFeerate) - assert(htlcTimeout.txInfo.fee > 0.sat) + assert(htlcTimeout.tx.txInfo.fee > 0.sat) assert(htlcTimeoutTx.txIn.length == 1) } } @@ -1177,10 +1198,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30) // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target than what's provided. setFeerate(targetFeerate, blockTarget = 12) - assert(htlcSuccess.txInfo.fee == 0.sat) + assert(htlcSuccess.tx.txInfo.fee == 0.sat) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) assert(htlcSuccessTx.txIn.length > 1) - assert(htlcTimeout.txInfo.fee == 0.sat) + assert(htlcTimeout.tx.txInfo.fee == 0.sat) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) assert(htlcTimeoutTx.txIn.length > 1) } @@ -1194,10 +1215,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val targetFeerate = commitFeerate / 2 val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30) setFeerate(targetFeerate) - assert(htlcSuccess.txInfo.fee == 0.sat) + assert(htlcSuccess.tx.txInfo.fee == 0.sat) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) assert(htlcSuccessTx.txIn.length > 1) - assert(htlcTimeout.txInfo.fee == 0.sat) + assert(htlcTimeout.tx.txInfo.fee == 0.sat) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) assert(htlcTimeoutTx.txIn.length > 1) } @@ -1211,13 +1232,13 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // HTLC amount is small, so we should cap the feerate to avoid paying more in fees than what we're claiming. val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30, outgoingHtlcAmount = 5_000_000 msat, incomingHtlcAmount = 4_000_000 msat) setFeerate(targetFeerate, blockTarget = 12) - assert(htlcSuccess.txInfo.fee == 0.sat) - val htlcSuccessMaxFeerate = ReplaceableTxFunder.maxFeerate(htlcSuccess.txInfo, htlcSuccess.commitment, htlcSuccess.commitTx, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) + assert(htlcSuccess.tx.txInfo.fee == 0.sat) + val htlcSuccessMaxFeerate = ReplaceableTxFunder.maxFeerate(htlcSuccess.tx, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) assert(htlcSuccessMaxFeerate < targetFeerate / 2) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, htlcSuccessMaxFeerate) assert(htlcSuccessTx.txIn.length > 1) - assert(htlcTimeout.txInfo.fee == 0.sat) - val htlcTimeoutMaxFeerate = ReplaceableTxFunder.maxFeerate(htlcTimeout.txInfo, htlcTimeout.commitment, htlcTimeout.commitTx, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) + assert(htlcTimeout.tx.txInfo.fee == 0.sat) + val htlcTimeoutMaxFeerate = ReplaceableTxFunder.maxFeerate(htlcTimeout.tx, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) assert(htlcTimeoutMaxFeerate < targetFeerate / 2) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, htlcTimeoutMaxFeerate) assert(htlcTimeoutTx.txIn.length > 1) @@ -1338,9 +1359,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w bob2blockchain.expectMsgType[PublishReplaceableTx] // claim anchor bob2blockchain.expectMsgType[PublishFinalTx] // claim main output val claimHtlcTimeout = bob2blockchain.expectMsgType[PublishReplaceableTx] // claim-htlc-timeout - assert(claimHtlcTimeout.txInfo.isInstanceOf[ClaimHtlcTimeoutTx]) - wallet.publishTransaction(claimHtlcTimeout.txInfo.tx).pipeTo(probe.ref) - probe.expectMsg(claimHtlcTimeout.txInfo.tx.txid) + assert(claimHtlcTimeout.tx.isInstanceOf[ReplaceableClaimHtlcTimeout]) + wallet.publishTransaction(claimHtlcTimeout.tx.txInfo.tx).pipeTo(probe.ref) + probe.expectMsg(claimHtlcTimeout.tx.txInfo.tx.txid) generateBlocks(1) // When Alice tries to publish her HTLC-success, it is immediately aborted. @@ -1512,9 +1533,9 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w alice2blockchain.expectMsgType[PublishReplaceableTx] // claim anchor alice2blockchain.expectMsgType[PublishFinalTx] // claim main output val claimHtlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(claimHtlcTimeout.txInfo.isInstanceOf[ClaimHtlcTimeoutTx]) + assert(claimHtlcTimeout.tx.isInstanceOf[ReplaceableClaimHtlcTimeout]) val claimHtlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(claimHtlcSuccess.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) + assert(claimHtlcSuccess.tx.isInstanceOf[ReplaceableClaimHtlcSuccess]) // The local commit tx has a few confirmations, but isn't deeply confirmed yet. wallet.publishTransaction(localCommitTx).pipeTo(probe.ref) @@ -1592,20 +1613,21 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectMsg(remoteCommitTx.txid) generateBlocks(1) - bob.stateData.asInstanceOf[DATA_NORMAL].commitments.params.commitmentFormat match { - case Transactions.DefaultCommitmentFormat => () - case _: AnchorOutputsCommitmentFormat => alice2blockchain.expectMsgType[PublishReplaceableTx] // claim anchor + val anchorTx_opt = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.params.commitmentFormat match { + case Transactions.DefaultCommitmentFormat => None + case _: AnchorOutputsCommitmentFormat => Some(alice2blockchain.expectMsgType[PublishReplaceableTx]) } if (!bob.stateData.asInstanceOf[DATA_NORMAL].commitments.params.channelFeatures.paysDirectlyToWallet) alice2blockchain.expectMsgType[PublishFinalTx] // claim main output val claimHtlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = ConfirmationTarget.Absolute(overrideHtlcTarget)) - assert(claimHtlcSuccess.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) + assert(claimHtlcSuccess.tx.isInstanceOf[ReplaceableClaimHtlcSuccess]) val claimHtlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx].copy(confirmationTarget = ConfirmationTarget.Absolute(overrideHtlcTarget)) - assert(claimHtlcTimeout.txInfo.isInstanceOf[ClaimHtlcTimeoutTx]) + assert(claimHtlcTimeout.tx.isInstanceOf[ReplaceableClaimHtlcTimeout]) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx if (!bob.stateData.asInstanceOf[DATA_NORMAL].commitments.params.channelFeatures.paysDirectlyToWallet) alice2blockchain.expectMsgType[WatchTxConfirmed] // claim main output alice2blockchain.expectMsgType[WatchOutputSpent] // claim-htlc-success tx alice2blockchain.expectMsgType[WatchOutputSpent] // claim-htlc-timeout tx + anchorTx_opt.foreach(anchor => alice2blockchain.expectMsgType[WatchOutputSpent]) alice2blockchain.expectNoMessage(100 millis) (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) @@ -1663,10 +1685,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val currentFeerate = alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates.fast val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 50, nextCommit = false) val claimHtlcSuccessTx = testPublishClaimHtlcSuccess(f, remoteCommitTx, claimHtlcSuccess, currentFeerate) - assert(claimHtlcSuccess.txInfo.fee > 0.sat) + assert(claimHtlcSuccess.tx.txInfo.fee > 0.sat) assert(claimHtlcSuccessTx.txIn.length == 1) val claimHtlcTimeoutTx = testPublishClaimHtlcTimeout(f, remoteCommitTx, claimHtlcTimeout, currentFeerate) - assert(claimHtlcTimeout.txInfo.fee > 0.sat) + assert(claimHtlcTimeout.tx.txInfo.fee > 0.sat) assert(claimHtlcTimeoutTx.txIn.length == 1) } } @@ -1682,11 +1704,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val claimHtlcSuccessTx = testPublishClaimHtlcSuccess(f, remoteCommitTx, claimHtlcSuccess, targetFeerate) assert(claimHtlcSuccessTx.txIn.length == 1) assert(claimHtlcSuccessTx.txOut.length == 1) - assert(claimHtlcSuccessTx.txOut.head.amount < claimHtlcSuccess.txInfo.tx.txOut.head.amount) + assert(claimHtlcSuccessTx.txOut.head.amount < claimHtlcSuccess.tx.txInfo.tx.txOut.head.amount) val claimHtlcTimeoutTx = testPublishClaimHtlcTimeout(f, remoteCommitTx, claimHtlcTimeout, targetFeerate) assert(claimHtlcTimeoutTx.txIn.length == 1) assert(claimHtlcTimeoutTx.txOut.length == 1) - assert(claimHtlcTimeoutTx.txOut.head.amount < claimHtlcTimeout.txInfo.tx.txOut.head.amount) + assert(claimHtlcTimeoutTx.txOut.head.amount < claimHtlcTimeout.tx.txInfo.tx.txOut.head.amount) } } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala index 353443a268..8fa620a1cf 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxPublisherSpec.scala @@ -27,8 +27,8 @@ import fr.acinq.eclair.blockchain.fee.{ConfirmationPriority, ConfirmationTarget} import fr.acinq.eclair.channel.publish import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ import fr.acinq.eclair.channel.publish.TxPublisher._ -import fr.acinq.eclair.transactions.Transactions.{ClaimAnchorOutputTx, HtlcSuccessTx, InputInfo} -import fr.acinq.eclair.{BlockHeight, CltvExpiry, NodeParams, TestConstants, TestKitBaseClass, randomBytes32, randomKey} +import fr.acinq.eclair.transactions.Transactions.{ClaimAnchorOutputTx, HtlcSuccessTx, HtlcTimeoutTx, InputInfo} +import fr.acinq.eclair.{BlockHeight, CltvExpiry, NodeParams, TestConstants, TestKitBaseClass, randomBytes32, randomBytes64, randomKey} import org.scalatest.Outcome import org.scalatest.funsuite.FixtureAnyFunSuiteLike import scodec.bits.ByteVector @@ -106,7 +106,8 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val confirmBefore = ConfirmationTarget.Absolute(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomTxId(), 3) - val cmd = PublishReplaceableTx(ClaimAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), confirmBefore), null, null, null, confirmBefore) + val anchorTx = ClaimAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)) + val cmd = PublishReplaceableTx(ReplaceableLocalCommitAnchor(anchorTx, randomKey(), null, null, null), confirmBefore) txPublisher ! cmd val child = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p = child.expectMsgType[ReplaceableTxPublisher.Publish] @@ -118,14 +119,14 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val confirmBefore = nodeParams.currentBlockHeight + 12 val input = OutPoint(randomTxId(), 3) - val anchorTx = ClaimAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), ConfirmationTarget.Priority(ConfirmationPriority.Medium)) - val cmd = PublishReplaceableTx(anchorTx, null, null, null, ConfirmationTarget.Priority(ConfirmationPriority.Medium)) + val anchorTx = ClaimAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)) + val cmd = PublishReplaceableTx(ReplaceableLocalCommitAnchor(anchorTx, randomKey(), null, null, null), ConfirmationTarget.Priority(ConfirmationPriority.Medium)) txPublisher ! cmd val child = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor assert(child.expectMsgType[ReplaceableTxPublisher.Publish].cmd == cmd) // We ignore duplicates that don't use a more aggressive priority: - txPublisher ! PublishReplaceableTx(anchorTx, null, null, null, ConfirmationTarget.Priority(ConfirmationPriority.Slow)) + txPublisher ! cmd.copy(confirmationTarget = ConfirmationTarget.Priority(ConfirmationPriority.Slow)) child.expectNoMessage(100 millis) factory.expectNoMessage(100 millis) @@ -176,7 +177,8 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt2 = factory.expectMsgType[FinalTxPublisherSpawned].actor attempt2.expectMsgType[FinalTxPublisher.Publish] - val cmd3 = PublishReplaceableTx(ClaimAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0), ConfirmationTarget.Absolute(nodeParams.currentBlockHeight)), null, null, null, ConfirmationTarget.Absolute(nodeParams.currentBlockHeight)) + val anchorTx = ClaimAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)) + val cmd3 = PublishReplaceableTx(ReplaceableLocalCommitAnchor(anchorTx, randomKey(), null, null, null), ConfirmationTarget.Absolute(nodeParams.currentBlockHeight)) txPublisher ! cmd3 val attempt3 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor attempt3.expectMsgType[ReplaceableTxPublisher.Publish] @@ -198,7 +200,8 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt1 = factory.expectMsgType[FinalTxPublisherSpawned] attempt1.actor.expectMsgType[FinalTxPublisher.Publish] - val cmd2 = PublishReplaceableTx(ClaimAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0), ConfirmationTarget.Absolute(nodeParams.currentBlockHeight)), null, null, null, ConfirmationTarget.Absolute(nodeParams.currentBlockHeight)) + val anchorTx = ClaimAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)) + val cmd2 = PublishReplaceableTx(ReplaceableRemoteCommitAnchor(anchorTx, randomKey(), null, null, null), ConfirmationTarget.Absolute(nodeParams.currentBlockHeight)) txPublisher ! cmd2 val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] @@ -238,7 +241,8 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val expiry = CltvExpiry(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomTxId(), 7) val paymentHash = randomBytes32() - val cmd = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, expiry), null, null, null, ConfirmationTarget.Absolute(expiry.blockHeight)) + val htlcTx = HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, expiry) + val cmd = PublishReplaceableTx(ReplaceableHtlcSuccess(htlcTx, null, randomBytes32(), randomBytes64(), null, null), ConfirmationTarget.Absolute(expiry.blockHeight)) txPublisher ! cmd val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] @@ -302,7 +306,8 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val input = OutPoint(randomTxId(), 7) val paymentHash = randomBytes32() - val cmd = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, CltvExpiry(nodeParams.currentBlockHeight)), null, null, null, ConfirmationTarget.Absolute(nodeParams.currentBlockHeight)) + val htlcTx = HtlcTimeoutTx(InputInfo(input, TxOut(25_000 sat, Nil), ByteVector.empty), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, CltvExpiry(nodeParams.currentBlockHeight)) + val cmd = PublishReplaceableTx(ReplaceableHtlcTimeout(htlcTx, null, randomBytes64(), null, null), ConfirmationTarget.Absolute(nodeParams.currentBlockHeight)) txPublisher ! cmd val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala index 48ebba08a2..31ede8402a 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/ChannelStateTestsHelperMethods.scala @@ -27,10 +27,10 @@ import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} -import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainWallet, OnChainPubkeyCache, SingleKeyOnChainWallet} +import fr.acinq.eclair.blockchain.{DummyOnChainWallet, OnChainPubkeyCache, OnChainWallet, SingleKeyOnChainWallet} import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel -import fr.acinq.eclair.channel.publish.TxPublisher +import fr.acinq.eclair.channel.publish.{ReplaceableLocalCommitAnchor, ReplaceableRemoteCommitAnchor, TxPublisher} import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.FakeTxPublisherFactory import fr.acinq.eclair.payment.send.SpontaneousRecipient @@ -585,7 +585,7 @@ trait ChannelStateTestsBase extends Assertions with Eventually { val commitInput = closingState.commitments.latest.commitInput Transaction.correctlySpends(publishedLocalCommitTx, Map(commitInput.outPoint -> commitInput.txOut), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) if (closingState.commitments.params.commitmentFormat.isInstanceOf[Transactions.AnchorOutputsCommitmentFormat]) { - assert(s2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(s2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableLocalCommitAnchor]) } // if s has a main output in the commit tx (when it has a non-dust balance), it should be claimed localCommitPublished.claimMainDelayedOutputTx.foreach(tx => s2blockchain.expectMsg(TxPublisher.PublishFinalTx(tx, tx.fee, None))) @@ -632,12 +632,11 @@ trait ChannelStateTestsBase extends Assertions with Eventually { val remoteCommitPublished = remoteCommitPublished_opt.get // If anchor outputs is used, we use the anchor output to bump the fees if necessary. - closingData.commitments.params.commitmentFormat match { - case _: AnchorOutputsCommitmentFormat => - val anchorTx = s2blockchain.expectMsgType[PublishReplaceableTx] - assert(anchorTx.txInfo.isInstanceOf[ClaimAnchorOutputTx]) - case Transactions.DefaultCommitmentFormat => () + val anchorTx_opt = closingData.commitments.params.commitmentFormat match { + case _: AnchorOutputsCommitmentFormat => Some(s2blockchain.expectMsgType[PublishReplaceableTx]) + case Transactions.DefaultCommitmentFormat => None } + anchorTx_opt.foreach(anchor => assert(anchor.tx.isInstanceOf[ReplaceableRemoteCommitAnchor])) // if s has a main output in the commit tx (when it has a non-dust balance), it should be claimed remoteCommitPublished.claimMainOutputTx.foreach(claimMain => { Transaction.correctlySpends(claimMain.tx, rCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) @@ -655,9 +654,10 @@ trait ChannelStateTestsBase extends Assertions with Eventually { // we watch outputs of the commitment tx that both parties may spend val htlcOutputIndexes = remoteCommitPublished.claimHtlcTxs.keySet.map(_.index) - val spentWatches = htlcOutputIndexes.map(_ => s2blockchain.expectMsgType[WatchOutputSpent]) - spentWatches.foreach(ws => assert(ws.txId == rCommitTx.txid)) - assert(spentWatches.map(_.outputIndex) == htlcOutputIndexes) + val spentHtlcWatches = htlcOutputIndexes.map(_ => s2blockchain.expectMsgType[WatchOutputSpent]) + spentHtlcWatches.foreach(ws => assert(ws.txId == rCommitTx.txid)) + assert(spentHtlcWatches.map(_.outputIndex) == htlcOutputIndexes) + anchorTx_opt.foreach(anchor => assert(s2blockchain.expectMsgType[WatchOutputSpent].outputIndex == anchor.input.index)) s2blockchain.expectNoMessage(100 millis) // s is now in CLOSING state with txs pending for confirmation before going in CLOSED state diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala index 9085c8cc34..1e4bb125d2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingConfirmedStateSpec.scala @@ -28,12 +28,11 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fsm.Channel.ProcessCurrentBlockHeight import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.FullySignedSharedTransaction -import fr.acinq.eclair.channel.publish.TxPublisher import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, SetChannelId} +import fr.acinq.eclair.channel.publish.{ReplaceableLocalCommitAnchor, ReplaceableRemoteCommitAnchor, TxPublisher} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.FakeTxPublisherFactory import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.transactions.Transactions.ClaimAnchorOutputTx import fr.acinq.eclair.wire.protocol._ import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestKitBaseClass, ToMilliSatoshiConversion} import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -751,7 +750,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture val bobCommitTx = bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].commitments.latest.localCommit.commitTxAndRemoteSig.commitTx alice ! WatchFundingSpentTriggered(bobCommitTx.tx) aliceListener.expectMsgType[TransactionPublished] - assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMain = alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx] assert(claimMain.input.txid == bobCommitTx.tx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.tx.txid) @@ -787,7 +786,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture // Bob broadcasts his commit tx. alice ! WatchFundingSpentTriggered(bobCommitTx1) assert(aliceListener.expectMsgType[TransactionPublished].tx.txid == bobCommitTx1.txid) - assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMain = alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx] assert(claimMain.input.txid == bobCommitTx1.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx1.txid) @@ -808,7 +807,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture assert(aliceListener.expectMsgType[TransactionConfirmed].tx == fundingTx) assert(alice2blockchain.expectMsgType[WatchFundingSpent].txId == fundingTx.txid) alice2 ! WatchFundingSpentTriggered(bobData.commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx) - assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMainAlice = alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx] assert(claimMainAlice.input.txid == bobData.commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobData.commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx.txid) @@ -819,7 +818,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture assert(bobListener.expectMsgType[TransactionConfirmed].tx == fundingTx) assert(bob2blockchain.expectMsgType[WatchFundingSpent].txId == fundingTx.txid) bob2 ! WatchFundingSpentTriggered(aliceData.commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx) - assert(bob2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(bob2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMainBob = bob2blockchain.expectMsgType[TxPublisher.PublishFinalTx] assert(claimMainBob.input.txid == aliceData.commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx.txid) assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceData.commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx.txid) @@ -847,7 +846,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture assert(alice2blockchain.expectMsgType[WatchFundingSpent].txId == fundingTx1.txid) alice2blockchain.expectMsg(UnwatchTxConfirmed(fundingTx2.txId)) alice2 ! WatchFundingSpentTriggered(bobCommitTx1) - assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMainAlice = alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx] assert(claimMainAlice.input.txid == bobCommitTx1.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx1.txid) @@ -860,7 +859,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture assert(bob2blockchain.expectMsgType[WatchFundingSpent].txId == fundingTx1.txid) bob2blockchain.expectMsg(UnwatchTxConfirmed(fundingTx2.txId)) bob2 ! WatchFundingSpentTriggered(aliceCommitTx1) - assert(bob2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(bob2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMainBob = bob2blockchain.expectMsgType[TxPublisher.PublishFinalTx] assert(claimMainBob.input.txid == aliceCommitTx1.txid) assert(bob2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceCommitTx1.txid) @@ -1111,7 +1110,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture awaitCond(alice.stateName == CLOSING) aliceListener.expectMsgType[ChannelAborted] assert(alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx].tx.txid == aliceCommitTx.txid) - assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableLocalCommitAnchor]) val claimMainLocal = alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx] assert(claimMainLocal.input.txid == aliceCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceCommitTx.txid) @@ -1120,7 +1119,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture val bobCommitTx = bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_CONFIRMED].commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx alice ! WatchFundingSpentTriggered(bobCommitTx) alice2blockchain.expectMsgType[WatchOutputSpent] - assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMainRemote = alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx] assert(claimMainRemote.input.txid == bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) @@ -1145,7 +1144,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture awaitCond(alice.stateName == CLOSING) aliceListener.expectMsgType[ChannelAborted] assert(alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx].tx.txid == aliceCommitTx2.tx.txid) - assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableLocalCommitAnchor]) val claimMain2 = alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx] assert(claimMain2.input.txid == aliceCommitTx2.tx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceCommitTx2.tx.txid) @@ -1158,7 +1157,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture assert(alice2blockchain.expectMsgType[WatchFundingSpent].txId == fundingTx1.txid) alice2blockchain.expectMsg(UnwatchTxConfirmed(fundingTx2.txId)) assert(alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx].tx.txid == aliceCommitTx1.tx.txid) - assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableLocalCommitAnchor]) val claimMain1 = alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx] assert(claimMain1.input.txid == aliceCommitTx1.tx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == aliceCommitTx1.tx.txid) @@ -1168,7 +1167,7 @@ class WaitForDualFundingConfirmedStateSpec extends TestKitBaseClass with Fixture // Bob publishes his commit tx, Alice reacts by spending her remote main output. alice ! WatchFundingSpentTriggered(bobCommitTx1.tx) alice2blockchain.expectMsgType[WatchOutputSpent] - assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMainRemote = alice2blockchain.expectMsgType[TxPublisher.PublishFinalTx] assert(claimMainRemote.input.txid == bobCommitTx1.tx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx1.tx.txid) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingReadyStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingReadyStateSpec.scala index 19d2fdf159..f39290ed27 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingReadyStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForDualFundingReadyStateSpec.scala @@ -23,7 +23,7 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.FullySignedSharedTransaction -import fr.acinq.eclair.channel.publish.TxPublisher +import fr.acinq.eclair.channel.publish.{ReplaceableRemoteCommitAnchor, TxPublisher} import fr.acinq.eclair.channel.publish.TxPublisher.SetChannelId import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.payment.relay.Relayer.RelayFees @@ -270,7 +270,7 @@ class WaitForDualFundingReadyStateSpec extends TestKitBaseClass with FixtureAnyF // bob publishes his commitment tx val bobCommitTx = bob.stateData.asInstanceOf[DATA_WAIT_FOR_DUAL_FUNDING_READY].commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx alice ! WatchFundingSpentTriggered(bobCommitTx) - assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[TxPublisher.PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) alice2blockchain.expectMsgType[TxPublisher.PublishTx] assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) listener.expectMsgType[ChannelAborted] diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala index c51e43176b..197bab4e07 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/e/NormalSplicesStateSpec.scala @@ -22,7 +22,7 @@ import akka.testkit.{TestFSMRef, TestProbe} import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.NumericSatoshi.abs -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.SingleKeyOnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ @@ -32,6 +32,7 @@ import fr.acinq.eclair.channel.LocalFundingStatus.DualFundedUnconfirmedFundingTx import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fund.InteractiveTxBuilder.FullySignedSharedTransaction +import fr.acinq.eclair.channel.publish.ReplaceableRemoteCommitAnchor import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx, PublishTx, SetChannelId} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.{FakeTxPublisherFactory, PimpTestFSM} import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} @@ -2676,7 +2677,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assert(desc == p.desc) p match { case p: PublishFinalTx => p.tx - case p: PublishReplaceableTx => p.txInfo.tx + case p: PublishReplaceableTx => p.tx.txInfo.tx } } @@ -2776,7 +2777,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assertPublished(alice2blockchain, "local-anchor") assertPublished(alice2blockchain, "local-main-delayed") val htlcsTxsOut = htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "htlc-timeout")) - htlcsTxsOut.foreach(tx => Transaction.correctlySpends(tx, Seq(aliceCommitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + htlcsTxsOut.foreach(tx => assert(tx.txIn.forall(_.outPoint.txid == aliceCommitTx2.txid))) alice2blockchain.expectMsgType[WatchTxConfirmed] alice2blockchain.expectMsgType[WatchTxConfirmed] alice2blockchain.expectMsgType[WatchOutputSpent] @@ -2811,6 +2812,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik watchConfirmedClaimMain.replyTo ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, claimMain) val watchHtlcsOut1 = htlcs.aliceToBob.map(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) htlcs.bobToAlice.map(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) + alice2blockchain.expectMsgType[WatchOutputSpent] // anchor output watchHtlcsOut1.zip(htlcsTxsOut1).foreach { case (watch, tx) => watch.replyTo ! WatchOutputSpentTriggered(watch.amount, tx) } htlcsTxsOut1.foreach { tx => alice2blockchain.expectWatchTxConfirmed(tx.txid) @@ -2974,7 +2976,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik assertPublished(alice2blockchain, "local-anchor") val claimMainDelayed2 = assertPublished(alice2blockchain, "local-main-delayed") val htlcsTxsOut = htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "htlc-timeout")) - htlcsTxsOut.foreach(tx => Transaction.correctlySpends(tx, Seq(aliceCommitTx2), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) + htlcsTxsOut.foreach(tx => assert(tx.txIn.forall(_.outPoint.txid == aliceCommitTx2.txid))) alice2blockchain.expectWatchTxConfirmed(aliceCommitTx2.txid) alice2blockchain.expectWatchTxConfirmed(claimMainDelayed2.txid) @@ -2986,10 +2988,9 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // bob's remote tx wins alice ! WatchAlternativeCommitTxConfirmedTriggered(BlockHeight(400000), 42, bobCommitTx1) // we're back to the normal handling of remote commit - inside(alice2blockchain.expectMsgType[PublishReplaceableTx]) { tx => - assert(tx.txInfo.isInstanceOf[Transactions.ClaimAnchorOutputTx]) - assert(tx.commitTx == bobCommitTx1) - } + val anchorTx = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(anchorTx.tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) + assert(anchorTx.tx.commitTx == bobCommitTx1) val claimMain = alice2blockchain.expectMsgType[PublishFinalTx].tx val claimHtlcsTxsOut = htlcs.aliceToBob.map(_ => assertPublished(alice2blockchain, "claim-htlc-timeout")) claimHtlcsTxsOut.foreach(tx => Transaction.correctlySpends(tx, Seq(bobCommitTx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) @@ -3003,6 +3004,7 @@ class NormalSplicesStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik // watch alice and bob's htlcs and publish alice's htlcs-timeout txs htlcs.aliceToBob.map(_ => assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == bobCommitTx1.txid)) htlcs.bobToAlice.map(_ => assert(alice2blockchain.expectMsgType[WatchOutputSpent].txId == bobCommitTx1.txid)) + inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(OutPoint(w.txId, w.outputIndex.toLong) == anchorTx.input) } claimHtlcsTxsOut.foreach { tx => alice ! WatchTxConfirmedTriggered(BlockHeight(400000), 42, tx) } // publish bob's htlc-timeout txs 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 c47b62c170..90eddc5d28 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 @@ -30,6 +30,7 @@ import fr.acinq.eclair.blockchain.fee._ 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.{ReplaceableClaimHtlcSuccess, ReplaceableClaimHtlcTimeout, ReplaceableRemoteCommitAnchor} import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx, PublishTx} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} @@ -874,7 +875,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ val sender = TestProbe() alice ! CMD_SIGN() - sender.expectNoMessage(1 second) // just ignored + sender.expectNoMessage(100 millis) // just ignored //sender.expectMsg("cannot sign when there are no changes") } @@ -2447,7 +2448,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val localUpdate = channelUpdateListener.expectMsgType[LocalChannelUpdate] assert(localUpdate.channelUpdate.feeBaseMsat == newFeeBaseMsat) assert(localUpdate.channelUpdate.feeProportionalMillionths == newFeeProportionalMillionth) - alice2relayer.expectNoMessage(1 seconds) + alice2relayer.expectNoMessage(100 millis) } def testCmdClose(f: FixtureParam, script_opt: Option[ByteVector]): Unit = { @@ -3106,41 +3107,45 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx assert(bobCommitTx.txOut.size == 8) // two anchor outputs, two main outputs and 4 pending htlcs alice ! WatchFundingSpentTriggered(bobCommitTx) + awaitCond(alice.stateName == CLOSING) + assert(alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.isDefined) + val rcp = alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get + assert(rcp.claimHtlcTxs.size == 4) + assert(getClaimHtlcSuccessTxs(rcp).length == 1) + assert(getClaimHtlcTimeoutTxs(rcp).length == 2) // in response to that, alice publishes her claim txs - alice2blockchain.expectMsgType[PublishReplaceableTx] + val claimAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(claimAnchor.tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMain = alice2blockchain.expectMsgType[PublishFinalTx].tx // in addition to her main output, alice can only claim 3 out of 4 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage val claimHtlcTxs = (1 to 3).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx]) val htlcAmountClaimed = (for (claimHtlcTx <- claimHtlcTxs) yield { - assert(claimHtlcTx.txInfo.tx.txIn.size == 1) - assert(claimHtlcTx.txInfo.tx.txOut.size == 1) - Transaction.correctlySpends(claimHtlcTx.txInfo.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - claimHtlcTx.txInfo.tx.txOut.head.amount + assert(claimHtlcTx.tx.txInfo.tx.txIn.size == 1) + assert(claimHtlcTx.tx.txInfo.tx.txOut.size == 1) + Transaction.correctlySpends(claimHtlcTx.tx.txInfo.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + claimHtlcTx.tx.txInfo.tx.txOut.head.amount }).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) // alice sets the confirmation targets to the HTLC expiry - assert(claimHtlcTxs.map(_.commitTx.txid).toSet == Set(bobCommitTx.txid)) - assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcSuccessTx, _, _, _, confirmationTarget) => (tx.htlcId, confirmationTarget) }.toMap == Map(htlcb1.id -> ConfirmationTarget.Absolute(htlcb1.cltvExpiry.blockHeight))) - assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _, _, _, confirmationTarget) => (tx.htlcId, confirmationTarget) }.toMap == Map(htlca1.id -> ConfirmationTarget.Absolute(htlca1.cltvExpiry.blockHeight), htlca2.id -> ConfirmationTarget.Absolute(htlca2.cltvExpiry.blockHeight))) + assert(claimHtlcTxs.map(_.tx.commitTx.txid).toSet == Set(bobCommitTx.txid)) + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ReplaceableClaimHtlcSuccess, confirmationTarget) => (tx.txInfo.htlcId, confirmationTarget) }.toMap == Map(htlcb1.id -> ConfirmationTarget.Absolute(htlcb1.cltvExpiry.blockHeight))) + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ReplaceableClaimHtlcTimeout, confirmationTarget) => (tx.txInfo.htlcId, confirmationTarget) }.toMap == Map(htlca1.id -> ConfirmationTarget.Absolute(htlca1.cltvExpiry.blockHeight), htlca2.id -> ConfirmationTarget.Absolute(htlca2.cltvExpiry.blockHeight))) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.txid) - alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 1 - alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 2 - alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 3 - alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 4 - alice2blockchain.expectNoMessage(1 second) - - awaitCond(alice.stateName == CLOSING) - assert(alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.isDefined) - val rcp = alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get - assert(rcp.claimHtlcTxs.size == 4) - assert(getClaimHtlcSuccessTxs(rcp).length == 1) - assert(getClaimHtlcTimeoutTxs(rcp).length == 2) + val watchedOutputs = Seq( + alice2blockchain.expectMsgType[WatchOutputSpent], // htlc 1 + alice2blockchain.expectMsgType[WatchOutputSpent], // htlc 2 + alice2blockchain.expectMsgType[WatchOutputSpent], // htlc 3 + alice2blockchain.expectMsgType[WatchOutputSpent], // htlc 4 + alice2blockchain.expectMsgType[WatchOutputSpent], // anchor + ) + assert(watchedOutputs.map(w => OutPoint(w.txId, w.outputIndex.toLong)).toSet == rcp.claimHtlcTxs.keySet + claimAnchor.input) + alice2blockchain.expectNoMessage(100 millis) // assert the feerate of the claim main is what we expect val expectedFeeRate = alice.underlyingActor.nodeParams.onChainFeeConf.getClosingFeerate(alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates) @@ -3199,38 +3204,42 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.localCommit.commitTxAndRemoteSig.commitTx.tx assert(bobCommitTx.txOut.size == 7) // two anchor outputs, two main outputs and 3 pending htlcs alice ! WatchFundingSpentTriggered(bobCommitTx) + awaitCond(alice.stateName == CLOSING) + assert(alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.isDefined) + val rcp = alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.get + assert(getClaimHtlcSuccessTxs(rcp).length == 0) + assert(getClaimHtlcTimeoutTxs(rcp).length == 2) // in response to that, alice publishes her claim txs - val claimAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx + val claimAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(claimAnchor.tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMain = alice2blockchain.expectMsgType[PublishFinalTx].tx // in addition to her main output, alice can only claim 2 out of 3 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage val claimHtlcTxs = (1 to 2).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx]) val htlcAmountClaimed = (for (claimHtlcTx <- claimHtlcTxs) yield { - assert(claimHtlcTx.txInfo.tx.txIn.size == 1) - assert(claimHtlcTx.txInfo.tx.txOut.size == 1) - Transaction.correctlySpends(claimHtlcTx.txInfo.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - claimHtlcTx.txInfo.tx.txOut.head.amount + assert(claimHtlcTx.tx.txInfo.tx.txIn.size == 1) + assert(claimHtlcTx.tx.txInfo.tx.txOut.size == 1) + Transaction.correctlySpends(claimHtlcTx.tx.txInfo.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + claimHtlcTx.tx.txInfo.tx.txOut.head.amount }).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) // alice sets the confirmation targets to the HTLC expiry - assert(claimHtlcTxs.map(_.commitTx.txid).toSet == Set(bobCommitTx.txid)) - assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _, _, _, confirmationTarget) => (tx.htlcId, confirmationTarget) }.toMap == Map(htlca1.id -> ConfirmationTarget.Absolute(htlca1.cltvExpiry.blockHeight), htlca2.id -> ConfirmationTarget.Absolute(htlca2.cltvExpiry.blockHeight))) + assert(claimHtlcTxs.map(_.tx.commitTx.txid).toSet == Set(bobCommitTx.txid)) + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ReplaceableClaimHtlcTimeout, confirmationTarget) => (tx.txInfo.htlcId, confirmationTarget) }.toMap == Map(htlca1.id -> ConfirmationTarget.Absolute(htlca1.cltvExpiry.blockHeight), htlca2.id -> ConfirmationTarget.Absolute(htlca2.cltvExpiry.blockHeight))) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) - assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.txid) // claim-main - alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 1 - alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 2 - alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 3 - alice2blockchain.expectNoMessage(1 second) - - awaitCond(alice.stateName == CLOSING) - assert(alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.isDefined) - val rcp = alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.get - assert(getClaimHtlcSuccessTxs(rcp).length == 0) - assert(getClaimHtlcTimeoutTxs(rcp).length == 2) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.txid) + val watchedOutputs = Seq( + alice2blockchain.expectMsgType[WatchOutputSpent], // htlc 1 + alice2blockchain.expectMsgType[WatchOutputSpent], // htlc 2 + alice2blockchain.expectMsgType[WatchOutputSpent], // htlc 3 + alice2blockchain.expectMsgType[WatchOutputSpent], // anchor + ) + assert(watchedOutputs.map(w => OutPoint(w.txId, w.outputIndex.toLong)).toSet == rcp.claimHtlcTxs.keySet + claimAnchor.input) + alice2blockchain.expectNoMessage(100 millis) } test("recv WatchFundingSpentTriggered (their *next* commit w/ pending unsigned htlcs)") { f => @@ -3295,7 +3304,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // let's make sure that htlc-penalty txs each spend a different output assert(htlcPenaltyTxs.map(_.txIn.head.outPoint.index).toSet.size == htlcPenaltyTxs.size) htlcPenaltyTxs.foreach(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.expectNoMessage(100 millis) Transaction.correctlySpends(mainTx, Seq(revokedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) Transaction.correctlySpends(mainPenaltyTx, Seq(revokedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) @@ -3361,7 +3370,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == mainTx.txid) alice2blockchain.expectMsgType[WatchOutputSpent] // main-penalty htlcPenaltyTxs.foreach(_ => alice2blockchain.expectMsgType[WatchOutputSpent]) - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.expectNoMessage(100 millis) Transaction.correctlySpends(mainTx, Seq(revokedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) Transaction.correctlySpends(mainPenaltyTx, Seq(revokedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) @@ -3452,7 +3461,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 2 alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 3 alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 4 - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.expectNoMessage(100 millis) // 3rd-stage txs are published when htlc txs confirm Seq(htlcTx1, htlcTx2, htlcTx3).foreach { htlcTimeoutTx => @@ -3464,7 +3473,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimHtlcDelayedTx.txid) } awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get.claimHtlcDelayedTxs.length == 3) - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.expectNoMessage(100 millis) } test("recv Error (ignored internal error from lnd)") { f => @@ -3501,7 +3510,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 1 alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 2 alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 3 - ).map(p => p.txInfo.asInstanceOf[HtlcTx].htlcId -> p.confirmationTarget).toMap + ).map(p => p.tx.txInfo.asInstanceOf[HtlcTx].htlcId -> p.confirmationTarget).toMap assert(htlcConfirmationTargets == Map( htlcb1.id -> ConfirmationTarget.Absolute(htlcb1.cltvExpiry.blockHeight), htlca1.id -> ConfirmationTarget.Absolute(htlca1.cltvExpiry.blockHeight), @@ -3518,8 +3527,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectMsgType[WatchOutputSpent], // local anchor ).map(w => OutPoint(w.txId, w.outputIndex)).toSet val localCommitPublished = alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get - assert(watchedOutputs == localCommitPublished.htlcTxs.keySet + localAnchor.txInfo.input.outPoint) - alice2blockchain.expectNoMessage(1 second) + assert(watchedOutputs == localCommitPublished.htlcTxs.keySet + localAnchor.tx.txInfo.input.outPoint) + alice2blockchain.expectNoMessage(100 millis) } test("recv Error (anchor outputs zero fee htlc txs)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => @@ -3546,7 +3555,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val claimMain = alice2blockchain.expectMsgType[PublishFinalTx] assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === aliceCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid) - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.expectNoMessage(100 millis) } else { val localAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] // When there are no pending HTLCs, there is no absolute deadline to get the commit tx confirmed, we use priority @@ -3555,7 +3564,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === aliceCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid) assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex === localAnchor.input.index) - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.expectNoMessage(100 millis) } } @@ -3578,7 +3587,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob ! Error(ByteVector32.Zeroes, "oops") assert(bob2blockchain.expectMsgType[PublishFinalTx].tx.txid == bobCommitTx.txid) assert(bobCommitTx.txOut.size == 1) // only one main output - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.expectNoMessage(100 millis) awaitCond(bob.stateName == CLOSING) assert(bob.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.isDefined) @@ -3726,7 +3735,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // actual test starts here Thread.sleep(1100) alice ! BroadcastChannelUpdate(Reconnected) - channelUpdateListener.expectNoMessage(1 second) + channelUpdateListener.expectNoMessage(100 millis) } test("recv INPUT_DISCONNECTED") { f => @@ -3736,8 +3745,8 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // actual test starts here alice ! INPUT_DISCONNECTED awaitCond(alice.stateName == OFFLINE) - alice2bob.expectNoMessage(1 second) - channelUpdateListener.expectNoMessage(1 second) + alice2bob.expectNoMessage(100 millis) + channelUpdateListener.expectNoMessage(100 millis) } test("recv INPUT_DISCONNECTED (with pending unsigned htlcs)") { f => @@ -3774,7 +3783,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // actual test starts here alice ! INPUT_DISCONNECTED awaitCond(alice.stateName == OFFLINE) - channelUpdateListener.expectNoMessage(1 second) + channelUpdateListener.expectNoMessage(100 millis) } test("recv INPUT_DISCONNECTED (public channel, with pending unsigned htlcs)", Tag(ChannelStateTestsTags.ChannelsPublic), Tag(ChannelStateTestsTags.DoNotInterceptGossip)) { f => 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 312137d64b..fd2ba85cde 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 @@ -29,9 +29,10 @@ 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.{ReplaceableClaimHtlcTimeout, ReplaceableRemoteCommitAnchor} import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} -import fr.acinq.eclair.transactions.Transactions.{ClaimHtlcTimeoutTx, HtlcSuccessTx} +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.funsuite.FixtureAnyFunSuiteLike @@ -252,8 +253,8 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with { val (aliceCurrentPerCommitmentPoint, bobCurrentPerCommitmentPoint) = reconnect(alice, bob, alice2bob, bob2alice) - val reestablishA = alice2bob.expectMsg(ChannelReestablish(htlc.channelId, 2, 1, revB.perCommitmentSecret, aliceCurrentPerCommitmentPoint,TlvStream(lastFundingLockedTlvs(alice.stateData.asInstanceOf[DATA_NORMAL].commitments)))) - val reestablishB = bob2alice.expectMsg(ChannelReestablish(htlc.channelId, 2, 0, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint,TlvStream(lastFundingLockedTlvs(bob.stateData.asInstanceOf[DATA_NORMAL].commitments)))) + val reestablishA = alice2bob.expectMsg(ChannelReestablish(htlc.channelId, 2, 1, revB.perCommitmentSecret, aliceCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(alice.stateData.asInstanceOf[DATA_NORMAL].commitments)))) + val reestablishB = bob2alice.expectMsg(ChannelReestablish(htlc.channelId, 2, 0, PrivateKey(ByteVector32.Zeroes), bobCurrentPerCommitmentPoint, TlvStream(lastFundingLockedTlvs(bob.stateData.asInstanceOf[DATA_NORMAL].commitments)))) alice2bob.forward(bob, reestablishA) bob2alice.forward(alice, reestablishB) } @@ -444,12 +445,12 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice ! WatchFundingSpentTriggered(bobCommitTx) // alice is able to claim her main output and the htlc (once it times out) - alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx + assert(alice2blockchain.expectMsgType[PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMainOutput = alice2blockchain.expectMsgType[PublishFinalTx].tx Transaction.correctlySpends(claimMainOutput, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) val claimHtlc = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(claimHtlc.txInfo.isInstanceOf[ClaimHtlcTimeoutTx]) - Transaction.correctlySpends(claimHtlc.txInfo.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + assert(claimHtlc.tx.isInstanceOf[ReplaceableClaimHtlcTimeout]) + Transaction.correctlySpends(claimHtlc.tx.txInfo.tx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } test("counterparty lies about having a more recent commitment and publishes revoked commitment", Tag(IgnoreChannelUpdates), Tag(ChannelStateTestsTags.StaticRemoteKey), Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { 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 dadc60cea4..e999591da5 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 @@ -20,20 +20,21 @@ import akka.testkit.TestProbe import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.ScriptFlags import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey -import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, SatoshiLong, Script, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, ByteVector64, Crypto, OutPoint, SatoshiLong, Script, Transaction} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.blockchain.{CurrentBlockHeight, CurrentFeerates} import fr.acinq.eclair.channel._ +import fr.acinq.eclair.channel.publish.ReplaceableRemoteCommitAnchor import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx, PublishTx} 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.payment.send.SpontaneousRecipient -import fr.acinq.eclair.transactions.Transactions.ClaimAnchorOutputTx import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelUpdate, ClosingSigned, CommitSig, Error, FailureMessageCodecs, FailureReason, PermanentChannelFailure, RevokeAndAck, Shutdown, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc} import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey} +import org.scalatest.Inside.inside import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} import scodec.bits.ByteVector @@ -112,7 +113,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit systemA.eventStream.subscribe(aliceListener.ref, classOf[LocalChannelUpdate]) val bobListener = TestProbe() systemB.eventStream.subscribe(bobListener.ref, classOf[LocalChannelUpdate]) - + alice2bob.expectMsgType[AnnouncementSignatures] alice2bob.forward(bob) alice2bob.expectMsgType[ChannelUpdate] @@ -375,7 +376,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit import f._ val sender = TestProbe() alice ! CMD_SIGN(replyTo_opt = Some(sender.ref)) - sender.expectNoMessage(1 second) // just ignored + sender.expectNoMessage(100 millis) // just ignored //sender.expectMsg("cannot sign when there are no changes") } @@ -637,7 +638,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit val newFeeProportionalMillionth = TestConstants.Alice.nodeParams.relayParams.publicChannelFees.feeProportionalMillionths * 2 alice ! CMD_UPDATE_RELAY_FEE(sender.ref, newFeeBaseMsat, newFeeProportionalMillionth) sender.expectMsgType[RES_SUCCESS[CMD_UPDATE_RELAY_FEE]] - alice2relayer.expectNoMessage(1 seconds) + alice2relayer.expectNoMessage(100 millis) } test("recv CurrentBlockCount (no htlc timed out)") { f => @@ -725,10 +726,10 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit // in response to that, alice publishes her claim txs val anchorTx = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(anchorTx.txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(anchorTx.tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimMain = alice2blockchain.expectMsgType[PublishFinalTx].tx // in addition to her main output, alice can only claim 2 out of 3 htlcs, she can't do anything regarding the htlc sent by bob for which she does not have the preimage - val claimHtlcTxs = (1 to 2).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx) + val claimHtlcTxs = (1 to 2).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx].tx.txInfo.tx) val htlcAmountClaimed = (for (claimHtlcTx <- claimHtlcTxs) yield { assert(claimHtlcTx.txIn.size == 1) assert(claimHtlcTx.txOut.size == 1) @@ -741,9 +742,10 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.txid) - alice2blockchain.expectMsgType[WatchOutputSpent] - alice2blockchain.expectMsgType[WatchOutputSpent] - alice2blockchain.expectNoMessage(1 second) + inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(claimHtlcTxs.flatMap(_.txIn).map(_.outPoint).contains(OutPoint(w.txId, w.outputIndex.toLong))) } + inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(claimHtlcTxs.flatMap(_.txIn).map(_.outPoint).contains(OutPoint(w.txId, w.outputIndex.toLong))) } + inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(anchorTx.input == OutPoint(w.txId, w.outputIndex.toLong)) } + alice2blockchain.expectNoMessage(100 millis) awaitCond(alice.stateName == CLOSING) assert(alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.isDefined) @@ -775,11 +777,12 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit alice ! WatchFundingSpentTriggered(bobCommitTx) // in response to that, alice publishes her claim txs - alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx // claim local anchor output + val anchorTx = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(anchorTx.tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) val claimTxs = Seq( alice2blockchain.expectMsgType[PublishFinalTx].tx, // there is only one htlc to claim in the commitment bob published - alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx + alice2blockchain.expectMsgType[PublishReplaceableTx].tx.txInfo.tx ) val amountClaimed = (for (claimTx <- claimTxs) yield { assert(claimTx.txIn.size == 1) @@ -792,8 +795,9 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimTxs(0).txid) - alice2blockchain.expectMsgType[WatchOutputSpent] - alice2blockchain.expectNoMessage(1 second) + inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(claimTxs(1).txIn.head.outPoint == OutPoint(w.txId, w.outputIndex.toLong)) } + inside(alice2blockchain.expectMsgType[WatchOutputSpent]) { w => assert(anchorTx.input == OutPoint(w.txId, w.outputIndex.toLong)) } + alice2blockchain.expectNoMessage(100 millis) awaitCond(alice.stateName == CLOSING) assert(alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.isDefined) @@ -828,7 +832,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit alice2blockchain.expectMsgType[WatchOutputSpent] // main-penalty alice2blockchain.expectMsgType[WatchOutputSpent] // htlc1-penalty alice2blockchain.expectMsgType[WatchOutputSpent] // htlc2-penalty - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.expectNoMessage(100 millis) Transaction.correctlySpends(mainTx, Seq(revokedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) Transaction.correctlySpends(mainPenaltyTx, Seq(revokedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) @@ -872,7 +876,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == mainTx.txid) alice2blockchain.expectMsgType[WatchOutputSpent] // main-penalty alice2blockchain.expectMsgType[WatchOutputSpent] // htlc-penalty - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.expectNoMessage(100 millis) Transaction.correctlySpends(mainTx, Seq(revokedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) Transaction.correctlySpends(mainPenaltyTx, Seq(revokedTx), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) @@ -954,7 +958,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimMain.tx.txid) alice2blockchain.expectMsgType[WatchOutputSpent] alice2blockchain.expectMsgType[WatchOutputSpent] - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.expectNoMessage(100 millis) // 3rd-stage txs are published when htlc txs confirm Seq(htlc1, htlc2).foreach(htlcTimeoutTx => { @@ -966,7 +970,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimHtlcDelayedTx.txid) }) awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get.claimHtlcDelayedTxs.length == 2) - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.expectNoMessage(100 millis) } test("recv WatchFundingSpentTriggered (unrecognized commit)") { f => @@ -996,7 +1000,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimTxs(0).txid) // main-delayed alice2blockchain.expectMsgType[WatchOutputSpent] alice2blockchain.expectMsgType[WatchOutputSpent] - alice2blockchain.expectNoMessage(1 second) + alice2blockchain.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 265b230f67..80ae2dc76e 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 @@ -29,6 +29,7 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel import fr.acinq.eclair.channel.fsm.Channel.{BITCOIN_FUNDING_PUBLISH_FAILED, BITCOIN_FUNDING_TIMEOUT} import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishReplaceableTx, PublishTx, SetChannelId} +import fr.acinq.eclair.channel.publish._ import fr.acinq.eclair.channel.states.ChannelStateTestsBase.PimpTestFSM import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsTags} import fr.acinq.eclair.payment._ @@ -400,17 +401,17 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Bob claims the htlc output from Alice's commit tx using its preimage. bob ! WatchFundingSpentTriggered(lcp.commitTx) if (initialState.commitments.params.channelFeatures.hasFeature(Features.AnchorOutputsZeroFeeHtlcTx)) { - assert(bob2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) - bob2blockchain.expectMsgType[PublishFinalTx] // main-delayed + assert(bob2blockchain.expectMsgType[PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) + assert(bob2blockchain.expectMsgType[PublishFinalTx].desc == "remote-main-delayed") } val claimHtlcSuccessTx1 = bob2blockchain.expectMsgType[PublishReplaceableTx] - assert(claimHtlcSuccessTx1.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) + assert(claimHtlcSuccessTx1.tx.isInstanceOf[ReplaceableClaimHtlcSuccess]) val claimHtlcSuccessTx2 = bob2blockchain.expectMsgType[PublishReplaceableTx] - assert(claimHtlcSuccessTx2.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) + assert(claimHtlcSuccessTx2.tx.isInstanceOf[ReplaceableClaimHtlcSuccess]) assert(claimHtlcSuccessTx1.input != claimHtlcSuccessTx2.input) // Alice extracts the preimage and forwards it upstream. - alice ! WatchOutputSpentTriggered(htlc1.amountMsat.truncateToSatoshi, claimHtlcSuccessTx1.txInfo.tx) + alice ! WatchOutputSpentTriggered(htlc1.amountMsat.truncateToSatoshi, claimHtlcSuccessTx1.tx.txInfo.tx) Seq(htlc1, htlc2).foreach(htlc => inside(alice2relayer.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFulfill]]) { fulfill => assert(fulfill.htlc == htlc) assert(fulfill.result.paymentPreimage == preimage) @@ -419,8 +420,8 @@ 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.txInfo.tx.txid) - alice ! WatchTxConfirmedTriggered(alice.nodeParams.currentBlockHeight, 6, claimHtlcSuccessTx1.txInfo.tx) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId == claimHtlcSuccessTx1.tx.txInfo.tx.txid) + alice ! WatchTxConfirmedTriggered(alice.nodeParams.currentBlockHeight, 6, claimHtlcSuccessTx1.tx.txInfo.tx) alice2blockchain.expectNoMessage(100 millis) alice2relayer.expectNoMessage(100 millis) } @@ -513,10 +514,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Alice prepares Claim-HTLC-timeout transactions for each HTLC. alice ! WatchFundingSpentTriggered(rcp.commitTx) if (alice.stateData.asInstanceOf[DATA_CLOSING].commitments.params.channelFeatures.hasFeature(Features.AnchorOutputsZeroFeeHtlcTx)) { - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) assert(alice2blockchain.expectMsgType[PublishFinalTx].desc == "remote-main-delayed") } - Seq(htlc1, htlc2, htlc3).foreach(_ => assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimHtlcTimeoutTx])) + 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) @@ -528,6 +529,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex, alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex, ) == claimHtlcTimeoutTxs.map(_.input.outPoint.index).toSet) + 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.expectNoMessage(100 millis) // Bob's commitment confirms. @@ -603,10 +608,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Alice prepares Claim-HTLC-timeout transactions for each HTLC. alice ! WatchFundingSpentTriggered(rcp.commitTx) if (alice.stateData.asInstanceOf[DATA_CLOSING].commitments.params.channelFeatures.hasFeature(Features.AnchorOutputsZeroFeeHtlcTx)) { - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimAnchorOutputTx]) + assert(alice2blockchain.expectMsgType[PublishReplaceableTx].tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) assert(alice2blockchain.expectMsgType[PublishFinalTx].desc == "remote-main-delayed") } - Seq(htlc1, htlc2, htlc3).foreach(_ => assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[ClaimHtlcTimeoutTx])) + 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) @@ -618,6 +623,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex, alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex, ) == claimHtlcTimeoutTxs.map(_.input.outPoint.index).toSet) + 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.expectNoMessage(100 millis) // Bob's commitment confirms. @@ -661,33 +670,17 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with localClose(alice, alice2blockchain) val initialState = alice.stateData.asInstanceOf[DATA_CLOSING] assert(initialState.localCommitPublished.nonEmpty) - val localCommitPublished1 = initialState.localCommitPublished.get - assert(localCommitPublished1.claimAnchorTxs.nonEmpty) - val Some(localAnchor1) = localCommitPublished1.claimAnchorTxs.collectFirst { case tx: ClaimAnchorOutputTx => tx } - assert(localAnchor1.confirmationTarget == ConfirmationTarget.Priority(ConfirmationPriority.Medium)) + val localCommitPublished = initialState.localCommitPublished.get + assert(localCommitPublished.claimAnchorTxs.nonEmpty) val replyTo = TestProbe() alice ! CMD_BUMP_FORCE_CLOSE_FEE(replyTo.ref, ConfirmationTarget.Priority(ConfirmationPriority.Fast)) replyTo.expectMsgType[RES_SUCCESS[CMD_BUMP_FORCE_CLOSE_FEE]] - val localAnchor2 = inside(alice2blockchain.expectMsgType[PublishReplaceableTx]) { tx => - assert(tx.txInfo.isInstanceOf[ClaimAnchorOutputTx]) - assert(tx.commitTx == localCommitPublished1.commitTx) - tx.txInfo.asInstanceOf[ClaimAnchorOutputTx] + inside(alice2blockchain.expectMsgType[PublishReplaceableTx]) { publish => + assert(publish.tx.isInstanceOf[ReplaceableLocalCommitAnchor]) + assert(publish.tx.commitTx == localCommitPublished.commitTx) + assert(publish.confirmationTarget == ConfirmationTarget.Priority(ConfirmationPriority.Fast)) } - assert(localAnchor2.confirmationTarget == ConfirmationTarget.Priority(ConfirmationPriority.Fast)) - val localCommitPublished2 = alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get - assert(localCommitPublished2.claimAnchorTxs.contains(localAnchor2)) - - // If we try bumping again, but with a lower priority, this won't override the previous priority. - alice ! CMD_BUMP_FORCE_CLOSE_FEE(replyTo.ref, ConfirmationTarget.Priority(ConfirmationPriority.Medium)) - replyTo.expectMsgType[RES_SUCCESS[CMD_BUMP_FORCE_CLOSE_FEE]] - val localAnchor3 = inside(alice2blockchain.expectMsgType[PublishReplaceableTx]) { tx => - assert(tx.txInfo.isInstanceOf[ClaimAnchorOutputTx]) - assert(tx.commitTx == localCommitPublished1.commitTx) - tx.txInfo.asInstanceOf[ClaimAnchorOutputTx] - } - assert(localAnchor3.confirmationTarget == ConfirmationTarget.Priority(ConfirmationPriority.Fast)) - assert(alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.contains(localCommitPublished2)) } def testLocalCommitTxConfirmed(f: FixtureParam, channelFeatures: ChannelFeatures): Unit = { @@ -1016,8 +1009,8 @@ 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].txInfo.isInstanceOf[HtlcTimeoutTx]) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.isInstanceOf[HtlcSuccessTx]) + 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] @@ -1186,33 +1179,17 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ val bobCommitTx = bobCommitTxs.last.commitTx.tx - val closingState1 = remoteClose(bobCommitTx, alice, alice2blockchain) - assert(closingState1.claimAnchorTxs.nonEmpty) - val Some(localAnchor1) = closingState1.claimAnchorTxs.collectFirst { case tx: ClaimAnchorOutputTx => tx } - assert(localAnchor1.confirmationTarget == ConfirmationTarget.Priority(ConfirmationPriority.Medium)) + val closingState = remoteClose(bobCommitTx, alice, alice2blockchain) + assert(closingState.claimAnchorTxs.nonEmpty) val replyTo = TestProbe() alice ! CMD_BUMP_FORCE_CLOSE_FEE(replyTo.ref, ConfirmationTarget.Priority(ConfirmationPriority.Fast)) replyTo.expectMsgType[RES_SUCCESS[CMD_BUMP_FORCE_CLOSE_FEE]] - val localAnchor2 = inside(alice2blockchain.expectMsgType[PublishReplaceableTx]) { tx => - assert(tx.txInfo.isInstanceOf[ClaimAnchorOutputTx]) - assert(tx.commitTx == bobCommitTx) - tx.txInfo.asInstanceOf[ClaimAnchorOutputTx] - } - assert(localAnchor2.confirmationTarget == ConfirmationTarget.Priority(ConfirmationPriority.Fast)) - val closingState2 = alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get - assert(closingState2.claimAnchorTxs.contains(localAnchor2)) - - // If we try bumping again, but with a lower priority, this won't override the previous priority. - alice ! CMD_BUMP_FORCE_CLOSE_FEE(replyTo.ref, ConfirmationTarget.Priority(ConfirmationPriority.Medium)) - replyTo.expectMsgType[RES_SUCCESS[CMD_BUMP_FORCE_CLOSE_FEE]] - val localAnchor3 = inside(alice2blockchain.expectMsgType[PublishReplaceableTx]) { tx => - assert(tx.txInfo.isInstanceOf[ClaimAnchorOutputTx]) - assert(tx.commitTx == bobCommitTx) - tx.txInfo.asInstanceOf[ClaimAnchorOutputTx] + inside(alice2blockchain.expectMsgType[PublishReplaceableTx]) { publish => + assert(publish.tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) + assert(publish.tx.commitTx == bobCommitTx) + assert(publish.confirmationTarget == ConfirmationTarget.Priority(ConfirmationPriority.Fast)) } - assert(localAnchor3.confirmationTarget == ConfirmationTarget.Priority(ConfirmationPriority.Fast)) - assert(alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.contains(closingState2)) } test("recv WatchTxConfirmedTriggered (remote commit)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => @@ -1352,8 +1329,8 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with 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.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) - assert(publishHtlcSuccessTx.txInfo.tx == claimHtlcSuccessTx) + assert(publishHtlcSuccessTx.tx.isInstanceOf[ReplaceableClaimHtlcSuccess]) + assert(publishHtlcSuccessTx.tx.txInfo.tx == claimHtlcSuccessTx) assert(publishHtlcSuccessTx.confirmationTarget == ConfirmationTarget.Absolute(htlc1.cltvExpiry.blockHeight)) // Alice resets watches on all relevant transactions. @@ -1394,7 +1371,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // we should re-publish unconfirmed transactions closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimMain.tx)) val publishClaimHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(publishClaimHtlcTimeoutTx.txInfo == htlcTimeoutTx) + 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) @@ -1530,12 +1507,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with 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.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) - assert(publishHtlcSuccessTx.txInfo.tx == claimHtlcSuccessTx) + assert(publishHtlcSuccessTx.tx.isInstanceOf[ReplaceableClaimHtlcSuccess]) + assert(publishHtlcSuccessTx.tx.txInfo.tx == claimHtlcSuccessTx) assert(publishHtlcSuccessTx.confirmationTarget == ConfirmationTarget.Absolute(htlc1.cltvExpiry.blockHeight)) val publishHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] - assert(publishHtlcTimeoutTx.txInfo.isInstanceOf[ClaimHtlcTimeoutTx]) - assert(publishHtlcTimeoutTx.txInfo.tx == claimHtlcTimeoutTx) + 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) @@ -1571,12 +1548,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // the commit tx hasn't been confirmed yet, so we watch the funding output first alice2blockchain.expectMsgType[WatchFundingSpent] // then we should re-publish unconfirmed transactions - inside(alice2blockchain.expectMsgType[PublishReplaceableTx]) { tx => - assert(tx.txInfo.isInstanceOf[ClaimAnchorOutputTx]) - assert(tx.commitTx == bobCommitTx) + inside(alice2blockchain.expectMsgType[PublishReplaceableTx]) { publish => + assert(publish.tx.isInstanceOf[ReplaceableRemoteCommitAnchor]) + assert(publish.tx.commitTx == bobCommitTx) } closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx == claimMain.tx)) - claimHtlcTimeoutTxs.foreach(claimHtlcTimeout => assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx == claimHtlcTimeout.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)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala index 3cef6568ed..62da32a8de 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/payment/PaymentPacketSpec.scala @@ -18,9 +18,10 @@ package fr.acinq.eclair.payment import akka.actor.ActorRef import fr.acinq.bitcoin.scalacompat.DeterministicWallet.ExtendedPrivateKey -import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, ByteVector64, Crypto, DeterministicWallet, OutPoint, Satoshi, SatoshiLong, Transaction, TxOut} +import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, ByteVector64, Crypto, DeterministicWallet, OutPoint, Satoshi, SatoshiLong, Transaction, TxIn, TxOut} import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional} import fr.acinq.eclair.Features._ +import fr.acinq.eclair.TestUtils.randomTxId import fr.acinq.eclair.channel.ChannelSpendSignature.IndividualSignature import fr.acinq.eclair.channel._ import fr.acinq.eclair.channel.fsm.Channel @@ -716,8 +717,8 @@ object PaymentPacketSpec { val remoteParams = RemoteParams(randomKey().publicKey, null, UInt64.MaxValue, Some(channelReserve), null, null, maxAcceptedHtlcs = 0, null, null, null, null, null, None) val fundingTx = Transaction(2, Nil, Seq(TxOut(testCapacity, Nil)), 0) val commitInput = InputInfo(OutPoint(fundingTx, 0), fundingTx.txOut.head, ByteVector.empty) - val localCommit = LocalCommit(0, null, CommitTxAndRemoteSig(Transactions.CommitTx(commitInput, null), IndividualSignature(ByteVector64.Zeroes)), Nil) - val remoteCommit = RemoteCommit(0, null, null, randomKey().publicKey) + val localCommit = LocalCommit(0, null, CommitTxAndRemoteSig(Transactions.CommitTx(commitInput, Transaction(2, Seq(TxIn(commitInput.outPoint, Nil, 0)), Seq(TxOut(testCapacity, Nil)), 0)), IndividualSignature(ByteVector64.Zeroes)), Nil) + val remoteCommit = RemoteCommit(0, null, randomTxId(), randomKey().publicKey) val localChanges = LocalChanges(Nil, Nil, Nil) val remoteChanges = RemoteChanges(Nil, Nil, Nil) val localFundingStatus = announcement_opt match { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala index d7e6400bb0..460e3dd8b7 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/transactions/TransactionsSpec.scala @@ -501,7 +501,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { OutPoint(randomTxId(), 3) -> TxOut(walletAmount, Script.pay2wpkh(walletPub)), OutPoint(randomTxId(), 0) -> TxOut(walletAmount, Script.pay2wpkh(walletPub)), ) - val Right(claimAnchorOutputTx) = ClaimAnchorOutputTx.createUnsignedTx(localFundingPriv, localKeys.publicKeys, commitTx, ConfirmationTarget.Absolute(BlockHeight(0)), UnsafeLegacyAnchorOutputsCommitmentFormat).map(anchorTx => { + val Right(claimAnchorOutputTx) = ClaimAnchorOutputTx.createUnsignedTx(localFundingPriv, localKeys.publicKeys, commitTx, UnsafeLegacyAnchorOutputsCommitmentFormat).map(anchorTx => { val walletTxIn = walletInputs.map { case (outpoint, _) => TxIn(outpoint, ByteVector.empty, 0) } val unsignedTx = anchorTx.tx.copy(txIn = anchorTx.tx.txIn ++ walletTxIn) val sig1 = unsignedTx.signInput(1, Script.pay2pkh(walletPub), SIGHASH_ALL, walletAmount, SigVersion.SIGVERSION_WITNESS_V0, walletPriv) @@ -523,7 +523,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { } { // remote spends remote anchor - val Right(claimAnchorOutputTx) = ClaimAnchorOutputTx.createUnsignedTx(remoteFundingPriv, remoteKeys.publicKeys, commitTx, ConfirmationTarget.Absolute(BlockHeight(0)), UnsafeLegacyAnchorOutputsCommitmentFormat) + val Right(claimAnchorOutputTx) = ClaimAnchorOutputTx.createUnsignedTx(remoteFundingPriv, remoteKeys.publicKeys, commitTx, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(!claimAnchorOutputTx.validate(Map.empty)) val signedTx = claimAnchorOutputTx.sign(remoteFundingPriv, remoteKeys, UnsafeLegacyAnchorOutputsCommitmentFormat, Map.empty) assert(signedTx.validate(Map.empty)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3Spec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3Spec.scala index c7d7eae5be..7bde314add 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3Spec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/wire/internal/channel/version3/ChannelCodecs3Spec.scala @@ -15,7 +15,6 @@ */ package fr.acinq.eclair.wire.internal.channel.version3 -import fr.acinq.eclair.blockchain.fee.ConfirmationTarget import fr.acinq.eclair.channel._ import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.internal.channel.version3.ChannelCodecs3.Codecs._ @@ -79,7 +78,6 @@ class ChannelCodecs3Spec extends AnyFunSuite { val oldAnchorTxBin = hex"0011 24bd0be30e31c748c7afdde7d2c527d711fadf88500971a4a1136bca375dba07b8000000002b4a0100000000000022002036c067df8952dbcd5db347e7c152ca3fa4514f2072d27867837b1c2d319a7e01282103cc89f1459b5201cda08e08c6fb7b1968c54e8172c555896da27c6fdc10522ceeac736460b268330200000001bd0be30e31c748c7afdde7d2c527d711fadf88500971a4a1136bca375dba07b80000000000000000000000000000" val oldAnchorTx = txWithInputInfoCodec.decode(oldAnchorTxBin.bits).require.value assert(oldAnchorTx.isInstanceOf[ClaimAnchorOutputTx]) - assert(oldAnchorTx.asInstanceOf[ClaimAnchorOutputTx].confirmationTarget == ConfirmationTarget.Absolute(BlockHeight(0))) } { val oldHtlcSuccessTxBin = hex"0002 24f5580de0577271dce09d2de26e19ec58bf2373b0171473291a8f8be9b04fb289000000002bb0ad010000000000220020462cf8912ffc5f27764c109bed188950500011a2837ff8b9c8f9a39cffa395a58b76a91406b0950d9feded82239b3e6c9082308900f389de8763ac672102d65aa07658a7214ff129f91a1a22ade2ea4d1b07cc14b2f85a2842c34240836f7c8201208763a914c461c897e2165c7e44e14850dfcfd68f99127aed88527c21033c8d41cfbe1511a909b63fed68e75a29c3ce30418c39bbb8294c8b36c6a6c16a52ae677503101b06b175ac6868fd01a002000000000101f5580de0577271dce09d2de26e19ec58bf2373b0171473291a8f8be9b04fb289000000000000000000013a920100000000002200208742b16c9fd4e74854dcd84322dd1de06f7993fe627fd2ca0be4b996a936d56b050047304402201b4527c8f420852550af00bbd9149db9b31adcb7e1f127766e75e1e01746df0302202a57bb1e274ed7d3e8dbe5f205de721a23092c1e2ce2135f4750f18f6c0b51b001483045022100b6df309c8e5746a077b1f7c2f299528e164946bd514a5049475af7f5665805da0220392ae877112a3c52f74d190b354b4f5c020da9c1a71a7a08ced0a5363e795a27012017ea8f5afde8f708258d5669e1bbd454e82ddca8c6c480ec5302b4b1e8051d3d8b76a91406b0950d9feded82239b3e6c9082308900f389de8763ac672102d65aa07658a7214ff129f91a1a22ade2ea4d1b07cc14b2f85a2842c34240836f7c8201208763a914c461c897e2165c7e44e14850dfcfd68f99127aed88527c21033c8d41cfbe1511a909b63fed68e75a29c3ce30418c39bbb8294c8b36c6a6c16a52ae677503101b06b175ac686800000000dc7002a387673f17ebaf08545ccec712a9b6914813cdb83b4270932294f20f660000000000000000"