diff --git a/eclair-core/src/main/resources/reference.conf b/eclair-core/src/main/resources/reference.conf index 682c74af01..ee3274907e 100644 --- a/eclair-core/src/main/resources/reference.conf +++ b/eclair-core/src/main/resources/reference.conf @@ -145,10 +145,11 @@ eclair { // number of blocks to target when computing fees for each transaction type target-blocks { - funding = 6 // target for the funding transaction - commitment = 2 // target for the commitment transaction (used in force-close scenario) *do not change this unless you know what you are doing* - mutual-close = 12 // target for the mutual close transaction - claim-main = 12 // target for the claim main transaction (tx that spends main channel output back to wallet) + funding = 6 // target for the funding transaction + commitment = 2 // target for the commitment transaction (used in force-close scenario) *do not change this unless you know what you are doing* + commitment-without-htlcs = 12 // target for the commitment transaction when we have no htlcs to claim (used in force-close scenario) *do not change this unless you know what you are doing* + mutual-close = 12 // target for the mutual close transaction + claim-main = 12 // target for the claim main transaction (tx that spends main channel output back to wallet) } feerate-tolerance { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/BlockHeight.scala b/eclair-core/src/main/scala/fr/acinq/eclair/BlockHeight.scala new file mode 100644 index 0000000000..9189d9db54 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/BlockHeight.scala @@ -0,0 +1,37 @@ +/* + * Copyright 2022 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 + +/** + * Created by t-bast on 11/01/2022. + */ + +case class BlockHeight(private val underlying: Long) extends Ordered[BlockHeight] { + // @formatter:off + override def compare(other: BlockHeight): Int = underlying.compareTo(other.underlying) + def +(other: BlockHeight) = BlockHeight(underlying + other.underlying) + def +(i: Int) = BlockHeight(underlying + i) + def +(l: Long) = BlockHeight(underlying + l) + def -(other: BlockHeight) = BlockHeight(underlying - other.underlying) + def -(i: Int) = BlockHeight(underlying - i) + def -(l: Long) = BlockHeight(underlying - l) + def unary_- = BlockHeight(-underlying) + + def toLong: Long = underlying + def toInt: Int = underlying.toInt + // @formatter:on +} diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala index a498d78f96..245f3597b8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/NodeParams.scala @@ -317,6 +317,7 @@ object NodeParams extends Logging { val feeTargets = FeeTargets( fundingBlockTarget = config.getInt("on-chain-fees.target-blocks.funding"), commitmentBlockTarget = config.getInt("on-chain-fees.target-blocks.commitment"), + commitmentWithoutHtlcsBlockTarget = config.getInt("on-chain-fees.target-blocks.commitment-without-htlcs"), mutualCloseBlockTarget = config.getInt("on-chain-fees.target-blocks.mutual-close"), claimMainBlockTarget = config.getInt("on-chain-fees.target-blocks.claim-main") ) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala index 7dd4ec744b..10590d8965 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/fee/FeeEstimator.scala @@ -30,7 +30,7 @@ trait FeeEstimator { // @formatter:on } -case class FeeTargets(fundingBlockTarget: Int, commitmentBlockTarget: Int, mutualCloseBlockTarget: Int, claimMainBlockTarget: Int) +case class FeeTargets(fundingBlockTarget: Int, commitmentBlockTarget: Int, commitmentWithoutHtlcsBlockTarget: Int, mutualCloseBlockTarget: Int, claimMainBlockTarget: Int) /** * @param maxExposure maximum exposure to pending dust htlcs we tolerate: we will automatically fail HTLCs when going above this threshold. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala index 6bed4afef7..bc0f1e9469 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala @@ -1405,12 +1405,12 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo Commitments.sendFulfill(d.commitments, c) match { case Right((commitments1, _)) => log.info("got valid payment preimage, recalculating transactions to redeem the corresponding htlc on-chain") - val localCommitPublished1 = d.localCommitPublished.map(localCommitPublished => Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, commitments1, localCommitPublished.commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets)) - val remoteCommitPublished1 = d.remoteCommitPublished.map(remoteCommitPublished => Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, commitments1, commitments1.remoteCommit, remoteCommitPublished.commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets)) + val localCommitPublished1 = d.localCommitPublished.map(localCommitPublished => Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, commitments1, localCommitPublished.commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets)) + val remoteCommitPublished1 = d.remoteCommitPublished.map(remoteCommitPublished => Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, commitments1, commitments1.remoteCommit, remoteCommitPublished.commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets)) val nextRemoteCommitPublished1 = d.nextRemoteCommitPublished.map(remoteCommitPublished => { require(commitments1.remoteNextCommitInfo.isLeft, "next remote commit must be defined") val remoteCommit = commitments1.remoteNextCommitInfo.swap.toOption.get.nextRemoteCommit - Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, commitments1, remoteCommit, remoteCommitPublished.commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) + Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, commitments1, remoteCommit, remoteCommitPublished.commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) }) def republish(): Unit = { @@ -2413,7 +2413,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo stay() } else { val commitTx = d.commitments.fullySignedLocalCommitTx(keyManager).tx - val localCommitPublished = Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, d.commitments, commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) + val localCommitPublished = Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, d.commitments, commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) val nextData = d match { case closing: DATA_CLOSING => closing.copy(localCommitPublished = Some(localCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, fundingTx = None, waitingSinceBlock = nodeParams.currentBlockHeight, negotiating.closingTxProposed.flatten.map(_.unsignedTx), localCommitPublished = Some(localCommitPublished)) @@ -2467,8 +2467,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo val redeemableHtlcTxs = htlcTxs.values.flatten.map(tx => PublishFinalTx(tx, tx.fee, Some(commitTx.txid))) List(PublishFinalTx(commitTx, commitInput, "commit-tx", Closing.commitTxFee(commitments.commitInput, commitTx, isFunder), None)) ++ (claimMainDelayedOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)) ++ redeemableHtlcTxs ++ claimHtlcDelayedTxs.map(tx => PublishFinalTx(tx, tx.fee, None))) case _: Transactions.AnchorOutputsCommitmentFormat => + val redeemableHtlcTxs = htlcTxs.values.flatten.map(tx => PublishReplaceableTx(tx, commitments)) val claimLocalAnchor = claimAnchorTxs.collect { case tx: Transactions.ClaimLocalAnchorOutputTx => PublishReplaceableTx(tx, commitments) } - val redeemableHtlcTxs = htlcTxs.values.collect { case Some(tx) => PublishReplaceableTx(tx, commitments) } List(PublishFinalTx(commitTx, commitInput, "commit-tx", Closing.commitTxFee(commitments.commitInput, commitTx, isFunder), None)) ++ claimLocalAnchor ++ claimMainDelayedOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)) ++ redeemableHtlcTxs ++ claimHtlcDelayedTxs.map(tx => PublishFinalTx(tx, tx.fee, None)) } publishIfNeeded(publishQueue, irrevocablySpent) @@ -2491,7 +2491,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo require(commitTx.txid == d.commitments.remoteCommit.txid, "txid mismatch") context.system.eventStream.publish(TransactionPublished(d.channelId, remoteNodeId, commitTx, Closing.commitTxFee(d.commitments.commitInput, commitTx, d.commitments.localParams.isFunder), "remote-commit")) - val remoteCommitPublished = Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, d.commitments, d.commitments.remoteCommit, commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) + val remoteCommitPublished = Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, d.commitments, d.commitments.remoteCommit, commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) val nextData = d match { case closing: DATA_CLOSING => closing.copy(remoteCommitPublished = Some(remoteCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, fundingTx = None, waitingSinceBlock = nodeParams.currentBlockHeight, negotiating.closingTxProposed.flatten.map(_.unsignedTx), remoteCommitPublished = Some(remoteCommitPublished)) @@ -2525,7 +2525,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo require(commitTx.txid == remoteCommit.txid, "txid mismatch") context.system.eventStream.publish(TransactionPublished(d.channelId, remoteNodeId, commitTx, Closing.commitTxFee(d.commitments.commitInput, commitTx, d.commitments.localParams.isFunder), "next-remote-commit")) - val remoteCommitPublished = Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, d.commitments, remoteCommit, commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) + val remoteCommitPublished = Helpers.Closing.claimRemoteCommitTxOutputs(keyManager, d.commitments, remoteCommit, commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) val nextData = d match { case closing: DATA_CLOSING => closing.copy(nextRemoteCommitPublished = Some(remoteCommitPublished)) case negotiating: DATA_NEGOTIATING => DATA_CLOSING(d.commitments, fundingTx = None, waitingSinceBlock = nodeParams.currentBlockHeight, negotiating.closingTxProposed.flatten.map(_.unsignedTx), nextRemoteCommitPublished = Some(remoteCommitPublished)) @@ -2538,7 +2538,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo private def doPublish(remoteCommitPublished: RemoteCommitPublished, commitments: Commitments): Unit = { import remoteCommitPublished._ - val publishQueue = claimMainOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)).toSeq ++ claimHtlcTxs.values.flatten.map(tx => PublishReplaceableTx(tx, commitments)) + val redeemableHtlcTxs = claimHtlcTxs.values.flatten.map(tx => PublishReplaceableTx(tx, commitments)) + val publishQueue = claimMainOutputTx.map(tx => PublishFinalTx(tx, tx.fee, None)).toSeq ++ redeemableHtlcTxs publishIfNeeded(publishQueue, irrevocablySpent) // we watch: @@ -2602,7 +2603,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo // let's try to spend our current local tx val commitTx = d.commitments.fullySignedLocalCommitTx(keyManager).tx - val localCommitPublished = Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, d.commitments, commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) + val localCommitPublished = Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, d.commitments, commitTx, BlockHeight(nodeParams.currentBlockHeight), nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) goto(ERR_INFORMATION_LEAK) calling doPublish(localCommitPublished, d.commitments) sending error } 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 6f8a56874d..e7e3093d71 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 @@ -290,6 +290,8 @@ sealed trait CommitPublished { * 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. */ 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 { /** @@ -322,6 +324,8 @@ case class LocalCommitPublished(commitTx: Transaction, claimMainDelayedOutputTx: * @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. */ case class RemoteCommitPublished(commitTx: Transaction, claimMainOutputTx: Option[ClaimRemoteCommitMainOutputTx], claimHtlcTxs: Map[OutPoint, Option[ClaimHtlcTx]], claimAnchorTxs: List[ClaimAnchorOutputTx], irrevocablySpent: Map[OutPoint, Transaction]) extends CommitPublished { /** @@ -455,39 +459,39 @@ final case class DATA_WAIT_FOR_REMOTE_PUBLISH_FUTURE_COMMITMENT(commitments: Com /** * @param initFeatures current connection features, or last features used if the channel is disconnected. Note that these - * features are updated at each reconnection and may be different from the channel permanent features - * (see [[ChannelFeatures]]). + * features are updated at each reconnection and may be different from the channel permanent features + * (see [[ChannelFeatures]]). */ -final case class LocalParams(nodeId: PublicKey, - fundingKeyPath: DeterministicWallet.KeyPath, - dustLimit: Satoshi, - maxHtlcValueInFlightMsat: UInt64, // this is not MilliSatoshi because it can exceed the total amount of MilliSatoshi - channelReserve: Satoshi, - htlcMinimum: MilliSatoshi, - toSelfDelay: CltvExpiryDelta, - maxAcceptedHtlcs: Int, - isFunder: Boolean, - defaultFinalScriptPubKey: ByteVector, - walletStaticPaymentBasepoint: Option[PublicKey], - initFeatures: Features) +case class LocalParams(nodeId: PublicKey, + fundingKeyPath: DeterministicWallet.KeyPath, + dustLimit: Satoshi, + maxHtlcValueInFlightMsat: UInt64, // this is not MilliSatoshi because it can exceed the total amount of MilliSatoshi + channelReserve: Satoshi, + htlcMinimum: MilliSatoshi, + toSelfDelay: CltvExpiryDelta, + maxAcceptedHtlcs: Int, + isFunder: Boolean, + defaultFinalScriptPubKey: ByteVector, + walletStaticPaymentBasepoint: Option[PublicKey], + initFeatures: Features) /** * @param initFeatures see [[LocalParams.initFeatures]] */ -final case class RemoteParams(nodeId: PublicKey, - dustLimit: Satoshi, - maxHtlcValueInFlightMsat: UInt64, // this is not MilliSatoshi because it can exceed the total amount of MilliSatoshi - channelReserve: Satoshi, - htlcMinimum: MilliSatoshi, - toSelfDelay: CltvExpiryDelta, - maxAcceptedHtlcs: Int, - fundingPubKey: PublicKey, - revocationBasepoint: PublicKey, - paymentBasepoint: PublicKey, - delayedPaymentBasepoint: PublicKey, - htlcBasepoint: PublicKey, - initFeatures: Features, - shutdownScript: Option[ByteVector]) +case class RemoteParams(nodeId: PublicKey, + dustLimit: Satoshi, + maxHtlcValueInFlightMsat: UInt64, // this is not MilliSatoshi because it can exceed the total amount of MilliSatoshi + channelReserve: Satoshi, + htlcMinimum: MilliSatoshi, + toSelfDelay: CltvExpiryDelta, + maxAcceptedHtlcs: Int, + fundingPubKey: PublicKey, + revocationBasepoint: PublicKey, + paymentBasepoint: PublicKey, + delayedPaymentBasepoint: PublicKey, + htlcBasepoint: PublicKey, + initFeatures: Features, + shutdownScript: Option[ByteVector]) object ChannelFlags { val AnnounceChannel = 0x01.toByte 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 486acf0e9c..dac0bdd376 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 @@ -633,7 +633,7 @@ object Helpers { * @param commitments our commitment data, which include payment preimages * @return a list of transactions (one per output of the commit tx that we can claim) */ - def claimCurrentLocalCommitTxOutputs(keyManager: ChannelKeyManager, commitments: Commitments, tx: Transaction, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): LocalCommitPublished = { + def claimCurrentLocalCommitTxOutputs(keyManager: ChannelKeyManager, commitments: Commitments, tx: Transaction, currentBlockHeight: BlockHeight, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): LocalCommitPublished = { import commitments._ require(localCommit.commitTxAndRemoteSig.commitTx.tx.txid == tx.txid, "txid mismatch, provided tx is not the current local commit tx") val channelKeyPath = keyManager.keyPath(localParams, channelConfig) @@ -655,7 +655,7 @@ object Helpers { val preimages = commitments.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage }.map(r => Crypto.sha256(r) -> r).toMap val htlcTxs: Map[OutPoint, Option[HtlcTx]] = localCommit.htlcTxsAndRemoteSigs.collect { - case HtlcTxAndRemoteSig(txInfo@HtlcSuccessTx(_, _, paymentHash, _), remoteSig) => + case HtlcTxAndRemoteSig(txInfo@HtlcSuccessTx(_, _, paymentHash, _, _), remoteSig) => if (preimages.contains(paymentHash)) { // incoming htlc for which we have the preimage: we can spend it immediately txInfo.input.outPoint -> withTxGenerationLog("htlc-success") { @@ -675,9 +675,11 @@ object Helpers { } }.toMap + // 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 => htlcTx.confirmBefore).minOption.getOrElse(currentBlockHeight + feeTargets.commitmentWithoutHtlcsBlockTarget) val claimAnchorTxs: List[ClaimAnchorOutputTx] = List( withTxGenerationLog("local-anchor") { - Transactions.makeClaimLocalAnchorOutputTx(tx, localFundingPubKey) + Transactions.makeClaimLocalAnchorOutputTx(tx, localFundingPubKey, confirmCommitBefore) }, withTxGenerationLog("remote-anchor") { Transactions.makeClaimRemoteAnchorOutputTx(tx, commitments.remoteParams.fundingPubKey) @@ -729,7 +731,7 @@ object Helpers { * @param tx the remote commitment transaction that has just been published * @return a list of transactions (one per output of the commit tx that we can claim) */ - def claimRemoteCommitTxOutputs(keyManager: ChannelKeyManager, commitments: Commitments, remoteCommit: RemoteCommit, tx: Transaction, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): RemoteCommitPublished = { + def claimRemoteCommitTxOutputs(keyManager: ChannelKeyManager, commitments: Commitments, remoteCommit: RemoteCommit, tx: Transaction, currentBlockHeight: BlockHeight, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): RemoteCommitPublished = { require(remoteCommit.txid == tx.txid, "txid mismatch, provided tx is not the current remote commit tx") val (remoteCommitTx, _) = Commitments.makeRemoteTxs(keyManager, commitments.channelConfig, commitments.channelFeatures, remoteCommit.index, commitments.localParams, commitments.remoteParams, commitments.commitInput, remoteCommit.remotePerCommitmentPoint, remoteCommit.spec) require(remoteCommitTx.tx.txid == tx.txid, "txid mismatch, cannot recompute the current remote commit tx") @@ -780,9 +782,11 @@ object Helpers { }) }.toSeq.flatten.toMap + // 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 => htlcTx.confirmBefore).minOption.getOrElse(currentBlockHeight + feeTargets.commitmentWithoutHtlcsBlockTarget) val claimAnchorTxs: List[ClaimAnchorOutputTx] = List( withTxGenerationLog("local-anchor") { - Transactions.makeClaimLocalAnchorOutputTx(tx, localFundingPubkey) + Transactions.makeClaimLocalAnchorOutputTx(tx, localFundingPubkey, confirmCommitBefore) }, withTxGenerationLog("remote-anchor") { Transactions.makeClaimRemoteAnchorOutputTx(tx, commitments.remoteParams.fundingPubKey) @@ -1053,9 +1057,9 @@ object Helpers { } /** - * Before eclair v0.5.2, we didn't store the mapping between htlc txs and the htlc id. - * This function is only used for channels that were closing before upgrading to eclair v0.5.2 and can be removed - * once we're confident all eclair nodes on the network have been upgraded. + * Before eclair v0.6.0, we didn't store the mapping between htlc txs and the htlc id. + * This function is only used for channels that were closing before upgrading to eclair v0.6.0 (released in may 2021). + * TODO: remove once we're confident all eclair nodes on the network have been upgraded. * * We may have multiple HTLCs with the same payment hash because of MPP. * When a timeout transaction is confirmed, we need to find the best matching HTLC to fail upstream. @@ -1078,7 +1082,12 @@ object Helpers { log.error(s"some htlcs don't have a corresponding timeout transaction: tx=$tx, htlcs=$matchingHtlcs, timeout-txs=$matchingTxs") } matchingHtlcs.zip(matchingTxs).collectFirst { - case (add, timeoutTx) if timeoutTx.txid == tx.txid => add + // HTLC transactions cannot change when anchor outputs is not used, so we could just check that the txids match. + // But claim-htlc transactions can be updated to pay more or less fees by changing the output amount, so we cannot + // rely on txid equality for them. + // We instead check that the mempool transaction spends exactly the same inputs and sends the funds to exactly + // the same addresses as our transaction. + case (add, timeoutTx) if timeoutTx.txIn.map(_.outPoint).toSet == tx.txIn.map(_.outPoint).toSet && timeoutTx.txOut.map(_.publicKeyScript).toSet == tx.txOut.map(_.publicKeyScript).toSet => add } } @@ -1097,19 +1106,19 @@ object Helpers { localCommit.spec.htlcs.collect(outgoing) -- untrimmedHtlcs } else { // maybe this is a timeout tx, in that case we can resolve and fail the corresponding htlc - val isMissingHtlcIndex = localCommitPublished.htlcTxs.values.collect { case Some(HtlcTimeoutTx(_, _, htlcId)) => htlcId }.toSet == Set(0) + val isMissingHtlcIndex = localCommitPublished.htlcTxs.values.collect { case Some(HtlcTimeoutTx(_, _, htlcId, _)) => htlcId }.toSet == Set(0) if (isMissingHtlcIndex && commitmentFormat == DefaultCommitmentFormat) { tx.txIn .map(_.witness) .collect(Scripts.extractPaymentHashFromHtlcTimeout) .flatMap { paymentHash160 => log.info(s"htlc-timeout tx for paymentHash160=${paymentHash160.toHex} expiry=${tx.lockTime} has been confirmed (tx=$tx)") - val timeoutTxs = localCommitPublished.htlcTxs.values.collect { case Some(HtlcTimeoutTx(_, tx, _)) => tx }.toSeq + val timeoutTxs = localCommitPublished.htlcTxs.values.collect { case Some(HtlcTimeoutTx(_, tx, _, _)) => tx }.toSeq findTimedOutHtlc(tx, paymentHash160, untrimmedHtlcs, timeoutTxs, Scripts.extractPaymentHashFromHtlcTimeout) }.toSet } else { tx.txIn.flatMap(txIn => localCommitPublished.htlcTxs.get(txIn.outPoint) match { - case Some(Some(HtlcTimeoutTx(_, _, htlcId))) if isHtlcTimeout(tx, localCommitPublished) => + case Some(Some(HtlcTimeoutTx(_, _, htlcId, _))) if isHtlcTimeout(tx, localCommitPublished) => untrimmedHtlcs.find(_.id == htlcId) match { case Some(htlc) => log.info(s"htlc-timeout tx for htlc #$htlcId paymentHash=${htlc.paymentHash} expiry=${tx.lockTime} has been confirmed (tx=$tx)") @@ -1139,19 +1148,19 @@ object Helpers { remoteCommit.spec.htlcs.collect(incoming) -- untrimmedHtlcs } else { // maybe this is a timeout tx, in that case we can resolve and fail the corresponding htlc - val isMissingHtlcIndex = remoteCommitPublished.claimHtlcTxs.values.collect { case Some(ClaimHtlcTimeoutTx(_, _, htlcId)) => htlcId }.toSet == Set(0) + val isMissingHtlcIndex = remoteCommitPublished.claimHtlcTxs.values.collect { case Some(ClaimHtlcTimeoutTx(_, _, htlcId, _)) => htlcId }.toSet == Set(0) if (isMissingHtlcIndex && commitmentFormat == DefaultCommitmentFormat) { tx.txIn .map(_.witness) .collect(Scripts.extractPaymentHashFromClaimHtlcTimeout) .flatMap { paymentHash160 => log.info(s"claim-htlc-timeout tx for paymentHash160=${paymentHash160.toHex} expiry=${tx.lockTime} has been confirmed (tx=$tx)") - val timeoutTxs = remoteCommitPublished.claimHtlcTxs.values.collect { case Some(ClaimHtlcTimeoutTx(_, tx, _)) => tx }.toSeq + val timeoutTxs = remoteCommitPublished.claimHtlcTxs.values.collect { case Some(ClaimHtlcTimeoutTx(_, tx, _, _)) => tx }.toSeq findTimedOutHtlc(tx, paymentHash160, untrimmedHtlcs, timeoutTxs, Scripts.extractPaymentHashFromClaimHtlcTimeout) }.toSet } else { tx.txIn.flatMap(txIn => remoteCommitPublished.claimHtlcTxs.get(txIn.outPoint) match { - case Some(Some(ClaimHtlcTimeoutTx(_, _, htlcId))) if isClaimHtlcTimeout(tx, remoteCommitPublished) => + case Some(Some(ClaimHtlcTimeoutTx(_, _, htlcId, _))) if isClaimHtlcTimeout(tx, remoteCommitPublished) => untrimmedHtlcs.find(_.id == htlcId) match { case Some(htlc) => log.info(s"claim-htlc-timeout tx for htlc #$htlcId paymentHash=${htlc.paymentHash} expiry=${tx.lockTime} has been confirmed (tx=$tx)") 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 8dc1c8ac65..d3ca8cd043 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 @@ -54,7 +54,10 @@ object FinalTxPublisher { Behaviors.setup { context => Behaviors.withTimers { timers => Behaviors.withMdc(loggingInfo.mdc()) { - new FinalTxPublisher(nodeParams, bitcoinClient, watcher, context, timers, loggingInfo).start() + Behaviors.receiveMessagePartial { + case Publish(replyTo, cmd) => new FinalTxPublisher(nodeParams, replyTo, cmd, bitcoinClient, watcher, context, timers, loggingInfo).checkTimeLocks() + case Stop => Behaviors.stopped + } } } } @@ -63,6 +66,8 @@ object FinalTxPublisher { } private class FinalTxPublisher(nodeParams: NodeParams, + replyTo: ActorRef[TxPublisher.PublishTxResult], + cmd: TxPublisher.PublishFinalTx, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[FinalTxPublisher.Command], @@ -73,25 +78,16 @@ private class FinalTxPublisher(nodeParams: NodeParams, private val log = context.log - def start(): Behavior[Command] = { - Behaviors.receiveMessagePartial { - case Publish(replyTo, cmd) => checkTimeLocks(replyTo, cmd) - case Stop => Behaviors.stopped - } - } - - def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishFinalTx): Behavior[Command] = { + def checkTimeLocks(): Behavior[Command] = { val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, loggingInfo), "time-locks-monitor") timeLocksChecker ! CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.tx, cmd.desc) Behaviors.receiveMessagePartial { - case TimeLocksOk => checkParentPublished(replyTo, cmd) - case Stop => - timeLocksChecker ! TxTimeLocksMonitor.Stop - Behaviors.stopped + case TimeLocksOk => checkParentPublished() + case Stop => Behaviors.stopped } } - def checkParentPublished(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishFinalTx): Behavior[Command] = { + def checkParentPublished(): Behavior[Command] = { cmd.parentTx_opt match { case Some(parentTxId) => context.self ! CheckParentTx @@ -103,33 +99,35 @@ private class FinalTxPublisher(nodeParams: NodeParams, case Failure(reason) => UnknownFailure(reason) } Behaviors.same - case ParentTxOk => publish(replyTo, cmd) + case ParentTxOk => publish() case ParentTxMissing => log.debug("parent tx is missing, retrying after delay...") timers.startSingleTimer(CheckParentTx, (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) Behaviors.same case UnknownFailure(reason) => log.error("could not check parent tx", reason) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.UnknownTxFailure)) + sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.UnknownTxFailure)) case Stop => Behaviors.stopped } - case None => publish(replyTo, cmd) + case None => publish() } } - def publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishFinalTx): Behavior[Command] = { + def publish(): Behavior[Command] = { val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), "mempool-tx-monitor") txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), cmd.tx, cmd.input, cmd.desc, cmd.fee) Behaviors.receiveMessagePartial { - case WrappedTxResult(MempoolTxMonitor.TxConfirmed) => sendResult(replyTo, TxPublisher.TxConfirmed(cmd, cmd.tx)) - case WrappedTxResult(MempoolTxMonitor.TxRejected(reason)) => sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) - case Stop => - txMonitor ! MempoolTxMonitor.Stop - Behaviors.stopped + case WrappedTxResult(txResult) => + txResult match { + case _: MempoolTxMonitor.IntermediateTxResult => Behaviors.same + case MempoolTxMonitor.TxRejected(_, reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason)) + case MempoolTxMonitor.TxDeeplyBuried(tx) => sendResult(TxPublisher.TxConfirmed(cmd, tx)) + } + case Stop => Behaviors.stopped } } - def sendResult(replyTo: ActorRef[TxPublisher.PublishTxResult], result: TxPublisher.PublishTxResult): Behavior[Command] = { + def sendResult(result: TxPublisher.PublishTxResult): Behavior[Command] = { replyTo ! result Behaviors.receiveMessagePartial { case Stop => Behaviors.stopped 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 7268fa2709..0a3f0d0b24 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 @@ -17,7 +17,7 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.eventstream.EventStream -import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} import akka.actor.typed.{ActorRef, Behavior} import fr.acinq.bitcoin.{ByteVector32, OutPoint, Satoshi, Transaction} import fr.acinq.eclair.NodeParams @@ -27,7 +27,8 @@ import fr.acinq.eclair.channel.publish.TxPublisher.{TxPublishLogContext, TxRejec import fr.acinq.eclair.channel.{TransactionConfirmed, TransactionPublished} import scala.concurrent.ExecutionContext -import scala.util.{Failure, Success} +import scala.concurrent.duration.DurationLong +import scala.util.{Failure, Random, Success} /** * This actor publishes a fully signed transaction and monitors its status. @@ -42,107 +43,124 @@ object MempoolTxMonitor { private case class PublishFailed(reason: Throwable) extends Command private case class InputStatus(spentConfirmed: Boolean, spentUnconfirmed: Boolean) extends Command private case class CheckInputFailed(reason: Throwable) extends Command - private case class TxConfirmations(count: Int) extends Command + private case class TxConfirmations(confirmations: Int, currentBlockCount: Long) extends Command private case object TxNotFound extends Command private case class GetTxConfirmationsFailed(reason: Throwable) extends Command private case class WrappedCurrentBlockCount(currentBlockCount: Long) extends Command - case object Stop extends Command + private case class CheckTxConfirmations(currentBlockCount: Long) extends Command // @formatter:on + // Timer key to ensure we don't have multiple concurrent timers running. + private case object CheckTxConfirmationsKey + // @formatter:off + /** Once the transaction is published, we notify the sender of its confirmation status at every block. */ sealed trait TxResult - case object TxConfirmed extends TxResult - case class TxRejected(reason: TxPublisher.TxRejectedReason) extends TxResult + sealed trait IntermediateTxResult extends TxResult + /** The transaction is still unconfirmed and available in the mempool. */ + case class TxInMempool(txid: ByteVector32, currentBlockCount: Long) extends IntermediateTxResult + /** The transaction is confirmed, but hasn't reached min depth yet, we should wait for more confirmations. */ + case class TxRecentlyConfirmed(txid: ByteVector32, confirmations: Int) extends IntermediateTxResult + sealed trait FinalTxResult extends TxResult + /** The transaction is confirmed and has reached min depth. */ + case class TxDeeplyBuried(tx: Transaction) extends FinalTxResult + /** The transaction has been evicted from the mempool. */ + case class TxRejected(txid: ByteVector32, reason: TxPublisher.TxRejectedReason) extends FinalTxResult // @formatter:on def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => - Behaviors.withMdc(loggingInfo.mdc()) { - new MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo, context).start() + Behaviors.withTimers { timers => + Behaviors.withMdc(loggingInfo.mdc()) { + Behaviors.receiveMessagePartial { + case cmd: Publish => new MempoolTxMonitor(nodeParams, cmd, bitcoinClient, loggingInfo, context, timers).publish() + } + } } } } } -private class MempoolTxMonitor(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext, context: ActorContext[MempoolTxMonitor.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { +private class MempoolTxMonitor(nodeParams: NodeParams, + cmd: MempoolTxMonitor.Publish, + bitcoinClient: BitcoinCoreClient, + loggingInfo: TxPublishLogContext, + context: ActorContext[MempoolTxMonitor.Command], + timers: TimerScheduler[MempoolTxMonitor.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { import MempoolTxMonitor._ private val log = context.log - def start(): Behavior[Command] = { - Behaviors.receiveMessagePartial { - case Publish(replyTo, tx, input, desc, fee) => publish(replyTo, tx, input, desc, fee) - case Stop => Behaviors.stopped - } - } - - def publish(replyTo: ActorRef[TxResult], tx: Transaction, input: OutPoint, desc: String, fee: Satoshi): Behavior[Command] = { - context.pipeToSelf(bitcoinClient.publishTransaction(tx)) { + def publish(): Behavior[Command] = { + context.pipeToSelf(bitcoinClient.publishTransaction(cmd.tx)) { case Success(_) => PublishOk case Failure(reason) => PublishFailed(reason) } Behaviors.receiveMessagePartial { case PublishOk => - log.debug("txid={} was successfully published, waiting for confirmation...", tx.txid) - context.system.eventStream ! EventStream.Publish(TransactionPublished(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, tx, fee, desc)) - waitForConfirmation(replyTo, tx, input) + log.debug("txid={} was successfully published, waiting for confirmation...", cmd.tx.txid) + context.system.eventStream ! EventStream.Publish(TransactionPublished(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, cmd.tx, cmd.fee, cmd.desc)) + waitForConfirmation() case PublishFailed(reason) if reason.getMessage.contains("rejecting replacement") => log.info("could not publish tx: a conflicting mempool transaction is already in the mempool") - sendResult(replyTo, TxRejected(TxRejectedReason.ConflictingTxUnconfirmed)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) case PublishFailed(reason) if reason.getMessage.contains("bad-txns-inputs-missingorspent") => // This can only happen if one of our inputs is already spent by a confirmed transaction or doesn't exist (e.g. // unconfirmed wallet input that has been replaced). - checkInputStatus(input) + checkInputStatus(cmd.input) Behaviors.same case PublishFailed(reason) => log.error("could not publish transaction", reason) - sendResult(replyTo, TxRejected(TxRejectedReason.UnknownTxFailure)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.UnknownTxFailure)) case status: InputStatus => if (status.spentConfirmed) { log.info("could not publish tx: a conflicting transaction is already confirmed") - sendResult(replyTo, TxRejected(TxRejectedReason.ConflictingTxConfirmed)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) } else if (status.spentUnconfirmed) { log.info("could not publish tx: a conflicting mempool transaction is already in the mempool") - sendResult(replyTo, TxRejected(TxRejectedReason.ConflictingTxUnconfirmed)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) } else { log.info("could not publish tx: one of our wallet inputs is not available") - sendResult(replyTo, TxRejected(TxRejectedReason.WalletInputGone)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) } case CheckInputFailed(reason) => log.error("could not check input status", reason) - sendResult(replyTo, TxRejected(TxRejectedReason.TxSkipped(retryNextBlock = true))) // we act as if the input is potentially still spendable - case Stop => - Behaviors.stopped + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true))) // we act as if the input is potentially still spendable } } - def waitForConfirmation(replyTo: ActorRef[TxResult], tx: Transaction, input: OutPoint): Behavior[Command] = { + def waitForConfirmation(): Behavior[Command] = { val messageAdapter = context.messageAdapter[CurrentBlockCount](cbc => WrappedCurrentBlockCount(cbc.blockCount)) context.system.eventStream ! EventStream.Subscribe(messageAdapter) Behaviors.receiveMessagePartial { - case WrappedCurrentBlockCount(_) => - context.pipeToSelf(bitcoinClient.getTxConfirmations(tx.txid)) { - case Success(Some(confirmations)) => TxConfirmations(confirmations) + case WrappedCurrentBlockCount(currentBlockCount) => + timers.startSingleTimer(CheckTxConfirmationsKey, CheckTxConfirmations(currentBlockCount), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) + Behaviors.same + case CheckTxConfirmations(currentBlockCount) => + context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.tx.txid)) { + case Success(Some(confirmations)) => TxConfirmations(confirmations, currentBlockCount) case Success(None) => TxNotFound case Failure(reason) => GetTxConfirmationsFailed(reason) } Behaviors.same - case TxConfirmations(confirmations) => - if (confirmations == 1) { - log.info("txid={} has been confirmed, waiting to reach min depth", tx.txid) - } - if (nodeParams.minDepthBlocks <= confirmations) { - log.info("txid={} has reached min depth", tx.txid) - context.system.eventStream ! EventStream.Publish(TransactionConfirmed(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, tx)) - sendResult(replyTo, TxConfirmed, Some(messageAdapter)) - } else { + case TxConfirmations(confirmations, currentBlockCount) => + if (confirmations == 0) { + cmd.replyTo ! TxInMempool(cmd.tx.txid, currentBlockCount) Behaviors.same + } else if (confirmations < nodeParams.minDepthBlocks) { + log.info("txid={} has {} confirmations, waiting to reach min depth", cmd.tx.txid, confirmations) + cmd.replyTo ! TxRecentlyConfirmed(cmd.tx.txid, confirmations) + Behaviors.same + } else { + log.info("txid={} has reached min depth", cmd.tx.txid) + context.system.eventStream ! EventStream.Publish(TransactionConfirmed(loggingInfo.channelId_opt.getOrElse(ByteVector32.Zeroes), loggingInfo.remoteNodeId, cmd.tx)) + sendFinalResult(TxDeeplyBuried(cmd.tx), Some(messageAdapter)) } case TxNotFound => - log.warn("txid={} has been evicted from the mempool", tx.txid) - checkInputStatus(input) + log.warn("txid={} has been evicted from the mempool", cmd.tx.txid) + checkInputStatus(cmd.input) Behaviors.same case GetTxConfirmationsFailed(reason) => log.error("could not get tx confirmations", reason) @@ -151,26 +169,23 @@ private class MempoolTxMonitor(nodeParams: NodeParams, bitcoinClient: BitcoinCor case status: InputStatus => if (status.spentConfirmed) { log.info("tx was evicted from the mempool: a conflicting transaction has been confirmed") - sendResult(replyTo, TxRejected(TxRejectedReason.ConflictingTxConfirmed)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxConfirmed)) } else if (status.spentUnconfirmed) { log.info("tx was evicted from the mempool: a conflicting transaction replaced it") - sendResult(replyTo, TxRejected(TxRejectedReason.ConflictingTxUnconfirmed)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.ConflictingTxUnconfirmed)) } else { log.info("tx was evicted from the mempool: one of our wallet inputs disappeared") - sendResult(replyTo, TxRejected(TxRejectedReason.WalletInputGone)) + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.WalletInputGone)) } case CheckInputFailed(reason) => log.error("could not check input status", reason) - sendResult(replyTo, TxRejected(TxRejectedReason.TxSkipped(retryNextBlock = true)), Some(messageAdapter)) - case Stop => - context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) - Behaviors.stopped + sendFinalResult(TxRejected(cmd.tx.txid, TxRejectedReason.TxSkipped(retryNextBlock = true)), Some(messageAdapter)) } } - def sendResult(replyTo: ActorRef[TxResult], result: TxResult, blockSubscriber_opt: Option[ActorRef[CurrentBlockCount]] = None): Behavior[Command] = { + def sendFinalResult(result: FinalTxResult, blockSubscriber_opt: Option[ActorRef[CurrentBlockCount]] = None): Behavior[Command] = { blockSubscriber_opt.foreach(actor => context.system.eventStream ! EventStream.Unsubscribe(actor)) - replyTo ! result + cmd.replyTo ! result Behaviors.stopped } 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 new file mode 100644 index 0000000000..313c770fa4 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala @@ -0,0 +1,480 @@ +/* + * 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 akka.actor.typed.eventstream.EventStream +import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import fr.acinq.bitcoin.{ByteVector32, OutPoint, Satoshi, Script, Transaction, TxOut} +import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.FundTransactionOptions +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.Commitments +import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._ +import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext +import fr.acinq.eclair.transactions.Transactions._ +import fr.acinq.eclair.{NodeParams, NotificationsLogger} + +import scala.concurrent.{ExecutionContext, Future} +import scala.util.{Failure, Success} + +/** + * Created by t-bast on 20/12/2021. + */ + +/** + * This actor funds a replaceable transaction to reach the requested feerate, signs it, and returns the resulting + * transaction to the caller. Whenever possible, we avoid adding new inputs. + * This actor does not publish the resulting transaction. + */ +object ReplaceableTxFunder { + + // @formatter:off + sealed trait Command + case class FundTransaction(replyTo: ActorRef[FundingResult], cmd: TxPublisher.PublishReplaceableTx, tx: Either[FundedTx, ReplaceableTxWithWitnessData], targetFeerate: FeeratePerKw) extends Command + + private case class AddInputsOk(tx: ReplaceableTxWithWitnessData, totalAmountIn: Satoshi) 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) { + 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 + val fee: Satoshi = totalAmountIn - signedTx.txOut.map(_.amount).sum + } + + // @formatter:off + sealed trait FundingResult + case class TransactionReady(fundedTx: FundedTx) extends FundingResult + case class FundingFailed(reason: TxPublisher.TxRejectedReason) extends FundingResult + // @formatter:on + + def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, loggingInfo: TxPublishLogContext): Behavior[Command] = { + Behaviors.setup { context => + Behaviors.withMdc(loggingInfo.mdc()) { + Behaviors.receiveMessagePartial { + case FundTransaction(replyTo, cmd, tx, targetFeerate) => + val txFunder = new ReplaceableTxFunder(nodeParams, replyTo, cmd, bitcoinClient, context) + tx match { + case Right(txWithWitnessData) => txFunder.fund(txWithWitnessData, targetFeerate) + case Left(previousTx) => txFunder.bump(previousTx, targetFeerate) + } + } + } + } + } + + /** + * Adjust the amount of the change output of an anchor tx to match our target feerate. + * We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them + * afterwards which may bring the resulting feerate below our target. + */ + def adjustAnchorOutputChange(unsignedTx: ClaimLocalAnchorWithWitnessData, commitTx: Transaction, amountIn: Satoshi, commitFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi): ClaimLocalAnchorWithWitnessData = { + require(unsignedTx.txInfo.tx.txOut.size == 1, "funded transaction should have a single change output") + // We take into account witness weight and adjust the fee to match our desired feerate. + val dummySignedClaimAnchorTx = addSigs(unsignedTx.txInfo, PlaceHolderSig) + // NB: we assume that our bitcoind wallet uses only P2WPKH inputs when funding txs. + val estimatedWeight = commitTx.weight() + dummySignedClaimAnchorTx.tx.weight() + claimP2WPKHOutputWitnessWeight * (dummySignedClaimAnchorTx.tx.txIn.size - 1) + val targetFee = weight2fee(targetFeerate, estimatedWeight) - weight2fee(commitFeerate, commitTx.weight()) + val amountOut = dustLimit.max(amountIn - targetFee) + val updatedAnchorTx = unsignedTx.updateTx(unsignedTx.txInfo.tx.copy(txOut = Seq(unsignedTx.txInfo.tx.txOut.head.copy(amount = amountOut)))) + updatedAnchorTx + } + + private def dummySignedCommitTx(commitments: Commitments): CommitTx = { + val unsignedCommitTx = commitments.localCommit.commitTxAndRemoteSig.commitTx + addSigs(unsignedCommitTx, PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderSig, PlaceHolderSig) + } + + /** + * Adjust the change output of an htlc tx to match our target feerate. + * We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them + * afterwards which may bring the resulting feerate below our target. + */ + def adjustHtlcTxChange(unsignedTx: HtlcWithWitnessData, amountIn: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, commitmentFormat: CommitmentFormat): HtlcWithWitnessData = { + require(unsignedTx.txInfo.tx.txOut.size <= 2, "funded transaction should have at most one change output") + val dummySignedTx = unsignedTx.txInfo match { + case tx: HtlcSuccessTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, ByteVector32.Zeroes, commitmentFormat) + case tx: HtlcTimeoutTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, commitmentFormat) + } + // We adjust the change output to obtain the targeted feerate. + val estimatedWeight = dummySignedTx.tx.weight() + claimP2WPKHOutputWitnessWeight * (dummySignedTx.tx.txIn.size - 1) + val targetFee = weight2fee(targetFeerate, estimatedWeight) + val changeAmount = amountIn - dummySignedTx.tx.txOut.head.amount - targetFee + val updatedHtlcTx = if (dummySignedTx.tx.txOut.length == 2 && changeAmount >= dustLimit) { + unsignedTx.updateTx(unsignedTx.txInfo.tx.copy(txOut = Seq(unsignedTx.txInfo.tx.txOut.head, unsignedTx.txInfo.tx.txOut.last.copy(amount = changeAmount)))) + } else { + unsignedTx.updateTx(unsignedTx.txInfo.tx.copy(txOut = Seq(unsignedTx.txInfo.tx.txOut.head))) + } + updatedHtlcTx + } + + /** + * 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): Either[TxGenerationSkipped, ClaimHtlcWithWitnessData] = { + 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 dummySignedTx = claimHtlcTx.txInfo match { + case tx: ClaimHtlcSuccessTx => addSigs(tx, PlaceHolderSig, ByteVector32.Zeroes) + case tx: ClaimHtlcTimeoutTx => addSigs(tx, PlaceHolderSig) + case tx: LegacyClaimHtlcSuccessTx => tx + } + val targetFee = weight2fee(targetFeerate, dummySignedTx.tx.weight()) + val outputAmount = claimHtlcTx.txInfo.amountIn - targetFee + if (outputAmount < dustLimit) { + Left(AmountBelowDustLimit) + } else { + val updatedClaimHtlcTx = claimHtlcTx match { + // NB: we don't modify legacy claim-htlc-success, it's already signed. + case legacyClaimHtlcSuccess: LegacyClaimHtlcSuccessWithWitnessData => legacyClaimHtlcSuccess + case _ => claimHtlcTx.updateTx(claimHtlcTx.txInfo.tx.copy(txOut = Seq(claimHtlcTx.txInfo.tx.txOut.head.copy(amount = outputAmount)))) + } + Right(updatedClaimHtlcTx) + } + } + + // @formatter:off + sealed trait AdjustPreviousTxOutputResult + object AdjustPreviousTxOutputResult { + case class Skip(reason: String) extends AdjustPreviousTxOutputResult + case class AddWalletInputs(previousTx: ReplaceableTxWithWalletInputs) extends AdjustPreviousTxOutputResult + case class TxOutputAdjusted(updatedTx: ReplaceableTxWithWitnessData) 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. + */ + def adjustPreviousTxOutput(previousTx: FundedTx, targetFeerate: FeeratePerKw, commitments: Commitments): AdjustPreviousTxOutputResult = { + val dustLimit = commitments.localParams.dustLimit + val targetFee = previousTx.signedTxWithWitnessData match { + case _: ClaimLocalAnchorWithWitnessData => + val commitTx = dummySignedCommitTx(commitments) + val totalWeight = previousTx.signedTx.weight() + commitTx.tx.weight() + weight2fee(targetFeerate, totalWeight) - commitTx.fee + case _ => weight2fee(targetFeerate, previousTx.signedTx.weight()) + } + previousTx.signedTxWithWitnessData match { + case claimLocalAnchor: ClaimLocalAnchorWithWitnessData => + val changeAmount = previousTx.totalAmountIn - targetFee + if (changeAmount < dustLimit) { + AdjustPreviousTxOutputResult.AddWalletInputs(claimLocalAnchor) + } else { + val updatedTxOut = Seq(claimLocalAnchor.txInfo.tx.txOut.head.copy(amount = changeAmount)) + AdjustPreviousTxOutputResult.TxOutputAdjusted(claimLocalAnchor.updateTx(claimLocalAnchor.txInfo.tx.copy(txOut = updatedTxOut))) + } + case htlcTx: HtlcWithWitnessData => + 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 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))) + } 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 fee = previousTx.totalAmountIn - htlcAmount + if (fee <= htlcAmount) { + val feerate = fee2rate(fee, htlcTxNoChange.txInfo.tx.weight()) + if (targetFeerate <= feerate) { + // Without the change output, we're able to reach our desired feerate. + AdjustPreviousTxOutputResult.TxOutputAdjusted(htlcTxNoChange) + } else { + // Even without the change output, the feerate is too low: we must add new wallet inputs. + AdjustPreviousTxOutputResult.AddWalletInputs(htlcTx) + } + } else { + AdjustPreviousTxOutputResult.Skip("fee higher than htlc amount") + } + } + } + case claimHtlcTx: ClaimHtlcWithWitnessData => + val updatedAmount = previousTx.totalAmountIn - targetFee + if (updatedAmount < dustLimit) { + AdjustPreviousTxOutputResult.Skip("fee higher than htlc amount") + } else { + val updatedTxOut = Seq(claimHtlcTx.txInfo.tx.txOut.head.copy(amount = updatedAmount)) + claimHtlcTx match { + // NB: we don't modify legacy claim-htlc-success, it's already signed. + case _: LegacyClaimHtlcSuccessWithWitnessData => AdjustPreviousTxOutputResult.Skip("legacy claim-htlc-success should not be updated") + case _ => AdjustPreviousTxOutputResult.TxOutputAdjusted(claimHtlcTx.updateTx(claimHtlcTx.txInfo.tx.copy(txOut = updatedTxOut))) + } + } + } + } + +} + +private class ReplaceableTxFunder(nodeParams: NodeParams, + replyTo: ActorRef[ReplaceableTxFunder.FundingResult], + cmd: TxPublisher.PublishReplaceableTx, + bitcoinClient: BitcoinCoreClient, + context: ActorContext[ReplaceableTxFunder.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { + + import ReplaceableTxFunder._ + import nodeParams.{channelKeyManager => keyManager} + + private val log = context.log + + def fund(txWithWitnessData: ReplaceableTxWithWitnessData, targetFeerate: FeeratePerKw): Behavior[Command] = { + txWithWitnessData match { + case claimLocalAnchor: ClaimLocalAnchorWithWitnessData => + val commitFeerate = cmd.commitments.localCommit.spec.commitTxFeerate + if (targetFeerate <= commitFeerate) { + log.info("skipping {}: commit feerate is high enough (feerate={})", cmd.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) + } + case htlcTx: HtlcWithWitnessData => + val htlcFeerate = cmd.commitments.localCommit.spec.htlcTxFeerate(cmd.commitments.commitmentFormat) + if (targetFeerate <= htlcFeerate) { + log.info("publishing {} without adding inputs: txid={}", cmd.desc, htlcTx.txInfo.tx.txid) + sign(txWithWitnessData, htlcFeerate, htlcTx.txInfo.amountIn) + } else { + addWalletInputs(htlcTx, targetFeerate) + } + case claimHtlcTx: ClaimHtlcWithWitnessData => + adjustClaimHtlcTxOutput(claimHtlcTx, targetFeerate, cmd.commitments.localParams.dustLimit) 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) + replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) + Behaviors.stopped + case Right(updatedClaimHtlcTx) => + sign(updatedClaimHtlcTx, targetFeerate, updatedClaimHtlcTx.txInfo.amountIn) + } + } + } + + def bump(previousTx: FundedTx, targetFeerate: FeeratePerKw): Behavior[Command] = { + adjustPreviousTxOutput(previousTx, targetFeerate, cmd.commitments) match { + case AdjustPreviousTxOutputResult.Skip(reason) => + log.warn("skipping {} fee bumping: {} (feerate={})", cmd.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) + case AdjustPreviousTxOutputResult.AddWalletInputs(tx) => + log.debug("bumping {} fees requires adding new inputs (feerate={})", cmd.desc, targetFeerate) + // We restore the original transaction (remove previous attempt's wallet inputs). + val resetTx = tx.updateTx(cmd.txInfo.tx) + addWalletInputs(resetTx, targetFeerate) + } + } + + def addWalletInputs(txWithWitnessData: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw): Behavior[Command] = { + context.pipeToSelf(addInputs(txWithWitnessData, targetFeerate, cmd.commitments)) { + case Success((fundedTx, totalAmountIn)) => AddInputsOk(fundedTx, totalAmountIn) + case Failure(reason) => AddInputsFailed(reason) + } + Behaviors.receiveMessagePartial { + case AddInputsOk(fundedTx, totalAmountIn) => + log.info("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) + case AddInputsFailed(reason) => + if (reason.getMessage.contains("Insufficient funds")) { + val nodeOperatorMessage = + s"""Insufficient funds in bitcoin wallet to set feerate=$targetFeerate for ${cmd.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) + } else { + log.error("cannot add inputs to {}: {}", cmd.desc, reason) + } + replyTo ! FundingFailed(TxPublisher.TxRejectedReason.CouldNotFund) + Behaviors.stopped + } + } + + def sign(fundedTx: ReplaceableTxWithWitnessData, txFeerate: FeeratePerKw, amountIn: Satoshi): Behavior[Command] = { + val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) + fundedTx match { + case ClaimLocalAnchorWithWitnessData(anchorTx) => + val localSig = keyManager.sign(anchorTx, keyManager.fundingPublicKey(cmd.commitments.localParams.fundingKeyPath), TxOwner.Local, cmd.commitments.commitmentFormat) + val signedTx = ClaimLocalAnchorWithWitnessData(addSigs(anchorTx, localSig)) + signWalletInputs(signedTx, txFeerate, amountIn) + case htlcTx: HtlcWithWitnessData => + val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, cmd.commitments.localCommit.index) + val localHtlcBasepoint = keyManager.htlcPoint(channelKeyPath) + val localSig = keyManager.sign(htlcTx.txInfo, localHtlcBasepoint, localPerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) + val signedTx = htlcTx match { + case htlcSuccess: HtlcSuccessWithWitnessData => htlcSuccess.copy(txInfo = addSigs(htlcSuccess.txInfo, localSig, htlcSuccess.remoteSig, htlcSuccess.preimage, cmd.commitments.commitmentFormat)) + case htlcTimeout: HtlcTimeoutWithWitnessData => htlcTimeout.copy(txInfo = addSigs(htlcTimeout.txInfo, localSig, htlcTimeout.remoteSig, cmd.commitments.commitmentFormat)) + } + val hasWalletInputs = htlcTx.txInfo.tx.txIn.size > 1 + if (hasWalletInputs) { + signWalletInputs(signedTx, txFeerate, amountIn) + } else { + replyTo ! TransactionReady(FundedTx(signedTx, amountIn, txFeerate)) + Behaviors.stopped + } + case claimHtlcTx: ClaimHtlcWithWitnessData => + val sig = keyManager.sign(claimHtlcTx.txInfo, keyManager.htlcPoint(channelKeyPath), cmd.commitments.remoteCommit.remotePerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) + val signedTx = claimHtlcTx match { + case ClaimHtlcSuccessWithWitnessData(txInfo, preimage) => ClaimHtlcSuccessWithWitnessData(addSigs(txInfo, sig, preimage), preimage) + case legacyClaimHtlcSuccess: LegacyClaimHtlcSuccessWithWitnessData => legacyClaimHtlcSuccess + case ClaimHtlcTimeoutWithWitnessData(txInfo) => ClaimHtlcTimeoutWithWitnessData(addSigs(txInfo, sig)) + } + replyTo ! TransactionReady(FundedTx(signedTx, amountIn, txFeerate)) + Behaviors.stopped + } + } + + def signWalletInputs(locallySignedTx: ReplaceableTxWithWalletInputs, txFeerate: FeeratePerKw, amountIn: Satoshi): Behavior[Command] = { + locallySignedTx match { + case ClaimLocalAnchorWithWitnessData(anchorTx) => + val commitInfo = BitcoinCoreClient.PreviousTx(anchorTx.input, anchorTx.tx.txIn.head.witness) + context.pipeToSelf(bitcoinClient.signTransaction(anchorTx.tx, Seq(commitInfo))) { + case Success(signedTx) => SignWalletInputsOk(signedTx.tx) + case Failure(reason) => SignWalletInputsFailed(reason) + } + case htlcTx: HtlcWithWitnessData => + val inputInfo = BitcoinCoreClient.PreviousTx(htlcTx.txInfo.input, htlcTx.txInfo.tx.txIn.head.witness) + context.pipeToSelf(bitcoinClient.signTransaction(htlcTx.txInfo.tx, Seq(inputInfo), allowIncomplete = true).map(signTxResponse => { + // NB: bitcoind versions older than 0.21.1 messes up the witness stack for our htlc input, so we need to restore it. + // See https://github.com/bitcoin/bitcoin/issues/21151 + htlcTx.txInfo.tx.copy(txIn = htlcTx.txInfo.tx.txIn.head +: signTxResponse.tx.txIn.tail) + })) { + case Success(signedTx) => SignWalletInputsOk(signedTx) + case Failure(reason) => SignWalletInputsFailed(reason) + } + } + Behaviors.receiveMessagePartial { + case SignWalletInputsOk(signedTx) => + val fullySignedTx = locallySignedTx.updateTx(signedTx) + replyTo ! TransactionReady(FundedTx(fullySignedTx, amountIn, txFeerate)) + Behaviors.stopped + case SignWalletInputsFailed(reason) => + log.error("cannot sign {}: {}", cmd.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.input.outPoint, locallySignedTx.txInfo.tx, TxPublisher.TxRejectedReason.UnknownTxFailure) + } + } + + def unlockAndStop(input: OutPoint, tx: Transaction, failure: TxPublisher.TxRejectedReason): Behavior[Command] = { + val toUnlock = tx.txIn.filterNot(_.outPoint == input).map(_.outPoint) + log.debug("unlocking utxos={}", toUnlock.mkString(", ")) + context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock))(_ => UtxosUnlocked) + Behaviors.receiveMessagePartial { + case UtxosUnlocked => + log.debug("utxos unlocked") + replyTo ! FundingFailed(failure) + Behaviors.stopped + } + } + + private def addInputs(tx: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(ReplaceableTxWithWalletInputs, Satoshi)] = { + tx match { + case anchorTx: ClaimLocalAnchorWithWitnessData => addInputs(anchorTx, targetFeerate, commitments) + case htlcTx: HtlcWithWitnessData => addInputs(htlcTx, targetFeerate, commitments) + } + } + + private def addInputs(anchorTx: ClaimLocalAnchorWithWitnessData, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(ClaimLocalAnchorWithWitnessData, Satoshi)] = { + val dustLimit = commitments.localParams.dustLimit + val commitFeerate = commitments.localCommit.spec.commitTxFeerate + val commitTx = dummySignedCommitTx(commitments).tx + // We want the feerate of the package (commit tx + tx spending anchor) to equal targetFeerate. + // Thus we have: anchorFeerate = targetFeerate + (weight-commit-tx / weight-anchor-tx) * (targetFeerate - commitTxFeerate) + // If we use the smallest weight possible for the anchor tx, the feerate we use will thus be greater than what we want, + // and we can adjust it afterwards by raising the change output amount. + val anchorFeerate = targetFeerate + FeeratePerKw(targetFeerate.feerate - commitFeerate.feerate) * commitTx.weight() / claimAnchorOutputMinWeight + // NB: fundrawtransaction requires at least one output, and may add at most one additional change output. + // Since the purpose of this transaction is just to do a CPFP, the resulting tx should have a single change output + // (note that bitcoind doesn't let us publish a transaction with no outputs). + // To work around these limitations, we start with a dummy output and later merge that dummy output with the optional + // change output added by bitcoind. + // NB: fundrawtransaction doesn't support non-wallet inputs, so we have to remove our anchor input and re-add it later. + // That means bitcoind will not take our anchor input's weight into account when adding inputs to set the fee. + // That's ok, we can increase the fee later by decreasing the output amount. But we need to ensure we'll have enough + // to cover the weight of our anchor input, which is why we set it to the following value. + val dummyChangeAmount = weight2fee(anchorFeerate, claimAnchorOutputMinWeight) + dustLimit + val txNotFunded = Transaction(2, Nil, TxOut(dummyChangeAmount, Script.pay2wpkh(PlaceHolderPubKey)) :: Nil, 0) + bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(anchorFeerate, lockUtxos = true)).flatMap(fundTxResponse => { + // We merge the outputs if there's more than one. + fundTxResponse.changePosition match { + case Some(changePos) => + val changeOutput = fundTxResponse.tx.txOut(changePos) + val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(changeOutput.copy(amount = changeOutput.amount + dummyChangeAmount))) + Future.successful(fundTxResponse.copy(tx = txSingleOutput)) + case None => + bitcoinClient.getChangeAddress().map(pubkeyHash => { + val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(TxOut(dummyChangeAmount, Script.pay2wpkh(pubkeyHash)))) + fundTxResponse.copy(tx = txSingleOutput) + }) + } + }).map(fundTxResponse => { + require(fundTxResponse.tx.txOut.size == 1, "funded transaction should have a single change output") + // NB: we insert the anchor input in the *first* position because our signing helpers only sign input #0. + val unsignedTx = anchorTx.updateTx(fundTxResponse.tx.copy(txIn = anchorTx.txInfo.tx.txIn.head +: fundTxResponse.tx.txIn)) + val totalAmountIn = fundTxResponse.amountIn + AnchorOutputsCommitmentFormat.anchorAmount + (adjustAnchorOutputChange(unsignedTx, commitTx, totalAmountIn, commitFeerate, targetFeerate, dustLimit), totalAmountIn) + }) + } + + private def addInputs(htlcTx: HtlcWithWitnessData, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(HtlcWithWitnessData, Satoshi)] = { + // NB: fundrawtransaction doesn't support non-wallet inputs, so we clear the input and re-add it later. + val txNotFunded = htlcTx.txInfo.tx.copy(txIn = Nil, txOut = htlcTx.txInfo.tx.txOut.head.copy(amount = commitments.localParams.dustLimit) :: Nil) + val htlcTxWeight = htlcTx.txInfo match { + case _: HtlcSuccessTx => commitments.commitmentFormat.htlcSuccessWeight + case _: HtlcTimeoutTx => commitments.commitmentFormat.htlcTimeoutWeight + } + // We want the feerate of our final HTLC tx to equal targetFeerate. However, we removed the HTLC input from what we + // send to fundrawtransaction, so bitcoind will not know the total weight of the final tx. In order to make up for + // this difference, we need to tell bitcoind to target a higher feerate that takes into account the weight of the + // input we removed. + // That feerate will satisfy the following equality: + // feerate * weight_seen_by_bitcoind = target_feerate * (weight_seen_by_bitcoind + htlc_input_weight) + // So: feerate = target_feerate * (1 + htlc_input_weight / weight_seen_by_bitcoind) + // Because bitcoind will add at least one P2WPKH input, weight_seen_by_bitcoind >= htlc_tx_weight + p2wpkh_weight + // Thus: feerate <= target_feerate * (1 + htlc_input_weight / (htlc_tx_weight + p2wpkh_weight)) + // NB: we don't take into account the fee paid by our HTLC input: we will take it into account when we adjust the + // change output amount (unless bitcoind didn't add any change output, in that case we will overpay the fee slightly). + val weightRatio = 1.0 + (htlcInputMaxWeight.toDouble / (htlcTxWeight + claimP2WPKHOutputWeight)) + bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate * weightRatio, lockUtxos = true, changePosition = Some(1))).map(fundTxResponse => { + // We add the HTLC input (from the commit tx) and restore the HTLC output. + // NB: we can't modify them because they are signed by our peer (with SIGHASH_SINGLE | SIGHASH_ANYONECANPAY). + val txWithHtlcInput = fundTxResponse.tx.copy( + txIn = htlcTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn, + txOut = htlcTx.txInfo.tx.txOut ++ fundTxResponse.tx.txOut.tail + ) + val unsignedTx = htlcTx.updateTx(txWithHtlcInput) + val totalAmountIn = fundTxResponse.amountIn + unsignedTx.txInfo.amountIn + (adjustHtlcTxChange(unsignedTx, totalAmountIn, targetFeerate, commitments.localParams.dustLimit, commitments.commitmentFormat), totalAmountIn) + }) + } + +} 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 new file mode 100644 index 0000000000..fa3f008af4 --- /dev/null +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala @@ -0,0 +1,296 @@ +/* + * 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 akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.{ActorRef, Behavior} +import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, Transaction} +import fr.acinq.eclair.NodeParams +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient +import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext +import fr.acinq.eclair.channel.{Commitments, 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} + +/** + * Created by t-bast on 20/12/2021. + */ + +/** + * This actor verifies that preconditions are met before attempting to publish a replaceable transaction. + * It verifies for example that we're not trying to publish htlc transactions while the remote commitment has already + * been confirmed, or that we have all the data necessary to sign transactions. + */ +object ReplaceableTxPrePublisher { + + // @formatter:off + sealed trait Command + case class CheckPreconditions(replyTo: ActorRef[PreconditionsResult], cmd: TxPublisher.PublishReplaceableTx) 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 RemoteCommitTxConfirmed extends Command + private case object RemoteCommitTxPublished 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 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 ClaimLocalAnchorWithWitnessData(txInfo: ClaimLocalAnchorOutputTx) extends ReplaceableTxWithWalletInputs { + override def updateTx(tx: Transaction): ClaimLocalAnchorWithWitnessData = 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 LegacyClaimHtlcSuccessWithWitnessData(txInfo: LegacyClaimHtlcSuccessTx, preimage: ByteVector32) extends ClaimHtlcWithWitnessData { + override def updateTx(tx: Transaction): LegacyClaimHtlcSuccessWithWitnessData = 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, loggingInfo: TxPublishLogContext): Behavior[Command] = { + Behaviors.setup { context => + Behaviors.withMdc(loggingInfo.mdc()) { + Behaviors.receiveMessagePartial { + case CheckPreconditions(replyTo, cmd) => + val prePublisher = new ReplaceableTxPrePublisher(nodeParams, replyTo, cmd, bitcoinClient, context) + cmd.txInfo match { + case localAnchorTx: Transactions.ClaimLocalAnchorOutputTx => prePublisher.checkAnchorPreconditions(localAnchorTx) + case htlcTx: Transactions.HtlcTx => prePublisher.checkHtlcPreconditions(htlcTx) + case claimHtlcTx: Transactions.ClaimHtlcTx => prePublisher.checkClaimHtlcPreconditions(claimHtlcTx) + } + } + } + } + } + +} + +private class ReplaceableTxPrePublisher(nodeParams: NodeParams, + replyTo: ActorRef[ReplaceableTxPrePublisher.PreconditionsResult], + cmd: TxPublisher.PublishReplaceableTx, + bitcoinClient: BitcoinCoreClient, + context: ActorContext[ReplaceableTxPrePublisher.Command])(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { + + import ReplaceableTxPrePublisher._ + + private val log = context.log + + def checkAnchorPreconditions(localAnchorTx: ClaimLocalAnchorOutputTx): 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) + // - our commit tx is in the mempool (otherwise we can't claim our anchor) + val commitTx = cmd.commitments.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx + val fundingOutpoint = cmd.commitments.commitInput.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 => + // We must ensure our local commit tx is in the mempool before publishing the anchor transaction. + // If it's already published, this call will be a no-op. + bitcoinClient.publishTransaction(commitTx) + } + case None => + // If the funding transaction cannot be found (e.g. when using 0-conf), we should retry later. + Future.failed(FundingTxNotFound) + }) { + case Success(_) => ParentTxOk + case Failure(FundingTxNotFound) => FundingTxNotFound + case Failure(CommitTxAlreadyConfirmed) => CommitTxAlreadyConfirmed + case Failure(reason) if reason.getMessage.contains("rejecting replacement") => RemoteCommitTxPublished + case Failure(reason) => UnknownFailure(reason) + } + Behaviors.receiveMessagePartial { + case ParentTxOk => + replyTo ! PreconditionsOk(ClaimLocalAnchorWithWitnessData(localAnchorTx)) + 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") + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) + Behaviors.stopped + case RemoteCommitTxPublished => + log.warn("cannot publish commit tx: there is a conflicting tx in the mempool") + // We retry until that conflicting commit tx is confirmed or we're able to publish our local commit tx. + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) + 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 commit tx. + replyTo ! PreconditionsOk(ClaimLocalAnchorWithWitnessData(localAnchorTx)) + Behaviors.stopped + } + } + + def checkHtlcPreconditions(htlcTx: HtlcTx): Behavior[Command] = { + // We verify that: + // - their commit is not confirmed: if it is, there is no need to publish our htlc transactions + // - if this is an htlc-success transaction, we have the preimage + context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.commitments.remoteCommit.txid)) { + case Success(Some(depth)) if depth >= nodeParams.minDepthBlocks => RemoteCommitTxConfirmed + case Success(_) => ParentTxOk + case Failure(reason) => UnknownFailure(reason) + } + Behaviors.receiveMessagePartial { + case ParentTxOk => + extractHtlcWitnessData(htlcTx, cmd.commitments) match { + case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) + case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) + } + Behaviors.stopped + case RemoteCommitTxConfirmed => + log.warn("cannot publish {}: remote commit has been confirmed", cmd.desc) + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.ConflictingTxConfirmed) + 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.commitments) match { + case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) + case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) + } + Behaviors.stopped + } + } + + private def extractHtlcWitnessData(htlcTx: HtlcTx, commitments: Commitments): Option[ReplaceableTxWithWitnessData] = { + htlcTx match { + case tx: HtlcSuccessTx => + commitments.localCommit.htlcTxsAndRemoteSigs.collectFirst { + case HtlcTxAndRemoteSig(HtlcSuccessTx(input, _, _, _, _), remoteSig) if input.outPoint == tx.input.outPoint => remoteSig + } match { + case Some(remoteSig) => + commitments.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("preimage not found for htlcId={}, skipping...", tx.htlcId) + None + } + case None => + log.error("remote signature not found for htlcId={}, skipping...", tx.htlcId) + None + } + case tx: HtlcTimeoutTx => + commitments.localCommit.htlcTxsAndRemoteSigs.collectFirst { + case HtlcTxAndRemoteSig(HtlcTimeoutTx(input, _, _, _), remoteSig) if input.outPoint == tx.input.outPoint => remoteSig + } match { + case Some(remoteSig) => Some(HtlcTimeoutWithWitnessData(tx, remoteSig)) + case None => + log.error("remote signature not found for htlcId={}, skipping...", tx.htlcId) + None + } + } + } + + def checkClaimHtlcPreconditions(claimHtlcTx: ClaimHtlcTx): Behavior[Command] = { + // We verify that: + // - our commit is not confirmed: if it is, there is no need to publish our claim-htlc transactions + // - if this is a claim-htlc-success transaction, we have the preimage + context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx.txid)) { + case Success(Some(depth)) if depth >= nodeParams.minDepthBlocks => LocalCommitTxConfirmed + case Success(_) => ParentTxOk + case Failure(reason) => UnknownFailure(reason) + } + Behaviors.receiveMessagePartial { + case ParentTxOk => + extractClaimHtlcWitnessData(claimHtlcTx, cmd.commitments) match { + case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) + case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) + } + Behaviors.stopped + case LocalCommitTxConfirmed => + log.warn("cannot publish {}: local commit has been confirmed", cmd.desc) + replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.ConflictingTxConfirmed) + 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. + extractClaimHtlcWitnessData(claimHtlcTx, cmd.commitments) match { + case Some(txWithWitnessData) => replyTo ! PreconditionsOk(txWithWitnessData) + case None => replyTo ! PreconditionsFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false)) + } + Behaviors.stopped + } + } + + private def extractClaimHtlcWitnessData(claimHtlcTx: ClaimHtlcTx, commitments: Commitments): Option[ReplaceableTxWithWitnessData] = { + claimHtlcTx match { + case tx: LegacyClaimHtlcSuccessTx => + commitments.localChanges.all.collectFirst { + case u: UpdateFulfillHtlc if u.id == tx.htlcId => u.paymentPreimage + } match { + case Some(preimage) => Some(LegacyClaimHtlcSuccessWithWitnessData(tx, preimage)) + case None => + log.error("preimage not found for legacy htlcId={}, skipping...", tx.htlcId) + None + } + case tx: ClaimHtlcSuccessTx => + commitments.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("preimage not found for htlcId={}, skipping...", tx.htlcId) + None + } + case tx: ClaimHtlcTimeoutTx => Some(ClaimHtlcTimeoutWithWitnessData(tx)) + } + } + +} 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 7db9def4b7..a2e7ff95a5 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 @@ -18,22 +18,18 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} import akka.actor.typed.{ActorRef, Behavior} -import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, OutPoint, Satoshi, Script, Transaction, TxOut} -import fr.acinq.eclair.NodeParams +import fr.acinq.bitcoin.{OutPoint, Transaction} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient -import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.FundTransactionOptions -import fr.acinq.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.channel.publish.TxPublisher.{TxPublishLogContext, TxRejectedReason} -import fr.acinq.eclair.channel.publish.TxTimeLocksMonitor.CheckTx -import fr.acinq.eclair.channel.{Commitments, HtlcTxAndRemoteSig} -import fr.acinq.eclair.transactions.Transactions -import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.wire.protocol.UpdateFulfillHtlc +import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeratePerKw} +import fr.acinq.eclair.channel.publish.ReplaceableTxFunder.FundedTx +import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher.{ClaimLocalAnchorWithWitnessData, ReplaceableTxWithWitnessData} +import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext +import fr.acinq.eclair.{BlockHeight, NodeParams} -import scala.concurrent.duration.DurationInt +import scala.concurrent.duration.{DurationInt, DurationLong} import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success} +import scala.util.Random /** * Created by t-bast on 10/06/2021. @@ -41,154 +37,63 @@ import scala.util.{Failure, Success} /** * This actor sets the fees, signs and publishes a transaction that can be RBF-ed. + * It regularly RBFs the transaction as we get closer to its confirmation target. * It waits for confirmation or failure before reporting back to the requesting actor. */ object ReplaceableTxPublisher { // @formatter:off sealed trait Command - case class Publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, targetFeerate: FeeratePerKw) extends Command + case class Publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx) extends Command + case class UpdateConfirmationTarget(confirmBefore: BlockHeight) extends Command + case object Stop extends Command + + private case class WrappedPreconditionsResult(result: ReplaceableTxPrePublisher.PreconditionsResult) extends Command private case object TimeLocksOk extends Command - private case object CommitTxAlreadyConfirmed extends RuntimeException with Command - private case object RemoteCommitTxPublished extends RuntimeException with Command - private case object LocalCommitTxConfirmed extends Command - private case object RemoteCommitTxConfirmed extends Command - private case object PreconditionsOk extends Command - private case class FundingFailed(reason: Throwable) extends Command - private case class SignFundedTx(tx: ReplaceableTransactionWithInputInfo, fee: Satoshi) extends Command - private case class PublishSignedTx(tx: Transaction) extends Command + private case class WrappedFundingResult(result: ReplaceableTxFunder.FundingResult) extends Command private case class WrappedTxResult(result: MempoolTxMonitor.TxResult) extends Command - private case class UnknownFailure(reason: Throwable) extends Command + private case class BumpFee(targetFeerate: FeeratePerKw) extends Command + private case object UnlockUtxos extends Command private case object UtxosUnlocked extends Command - case object Stop extends Command // @formatter:on + // Timer key to ensure we don't have multiple concurrent timers running. + private case object BumpFeeKey + def apply(nodeParams: NodeParams, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => Behaviors.withTimers { timers => Behaviors.withMdc(loggingInfo.mdc()) { - new ReplaceableTxPublisher(nodeParams, bitcoinClient, watcher, context, timers, loggingInfo).start() + Behaviors.receiveMessagePartial { + case Publish(replyTo, cmd) => new ReplaceableTxPublisher(nodeParams, replyTo, cmd, bitcoinClient, watcher, context, timers, loggingInfo).checkPreconditions() + case Stop => Behaviors.stopped + } } } } } - /** - * Adjust the amount of the change output of an anchor tx to match our target feerate. - * We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them - * afterwards which may bring the resulting feerate below our target. - */ - def adjustAnchorOutputChange(unsignedTx: ClaimLocalAnchorOutputTx, commitTx: Transaction, amountIn: Satoshi, currentFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi): (ClaimLocalAnchorOutputTx, Satoshi) = { - require(unsignedTx.tx.txOut.size == 1, "funded transaction should have a single change output") - // We take into account witness weight and adjust the fee to match our desired feerate. - val dummySignedClaimAnchorTx = addSigs(unsignedTx, PlaceHolderSig) - // NB: we assume that our bitcoind wallet uses only P2WPKH inputs when funding txs. - val estimatedWeight = commitTx.weight() + dummySignedClaimAnchorTx.tx.weight() + claimP2WPKHOutputWitnessWeight * (dummySignedClaimAnchorTx.tx.txIn.size - 1) - val targetFee = weight2fee(targetFeerate, estimatedWeight) - weight2fee(currentFeerate, commitTx.weight()) - val amountOut = dustLimit.max(amountIn - targetFee) - val updatedAnchorTx = unsignedTx.copy(tx = unsignedTx.tx.copy(txOut = unsignedTx.tx.txOut.head.copy(amount = amountOut) :: Nil)) - val fee = amountIn - updatedAnchorTx.tx.txOut.map(_.amount).sum - (updatedAnchorTx, fee) - } - - /** - * Adjust the change output of an htlc tx to match our target feerate. - * We need this because fundrawtransaction doesn't allow us to leave non-wallet inputs, so we have to add them - * afterwards which may bring the resulting feerate below our target. - */ - def adjustHtlcTxChange(unsignedTx: HtlcTx, amountIn: Satoshi, targetFeerate: FeeratePerKw, commitments: Commitments): (HtlcTx, Satoshi) = { - require(unsignedTx.tx.txOut.size <= 2, "funded transaction should have at most one change output") - val dummySignedTx = unsignedTx match { - case tx: HtlcSuccessTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, ByteVector32.Zeroes, commitments.commitmentFormat) - case tx: HtlcTimeoutTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, commitments.commitmentFormat) - } - // We adjust the change output to obtain the targeted feerate. - val estimatedWeight = dummySignedTx.tx.weight() + claimP2WPKHOutputWitnessWeight * (dummySignedTx.tx.txIn.size - 1) - val targetFee = weight2fee(targetFeerate, estimatedWeight) - val changeAmount = amountIn - dummySignedTx.tx.txOut.head.amount - targetFee - val updatedHtlcTx = if (dummySignedTx.tx.txOut.length == 2 && changeAmount >= commitments.localParams.dustLimit) { - unsignedTx match { - case htlcSuccess: HtlcSuccessTx => htlcSuccess.copy(tx = htlcSuccess.tx.copy(txOut = Seq(htlcSuccess.tx.txOut.head, htlcSuccess.tx.txOut(1).copy(amount = changeAmount)))) - case htlcTimeout: HtlcTimeoutTx => htlcTimeout.copy(tx = htlcTimeout.tx.copy(txOut = Seq(htlcTimeout.tx.txOut.head, htlcTimeout.tx.txOut(1).copy(amount = changeAmount)))) - } - } else { - unsignedTx match { - case htlcSuccess: HtlcSuccessTx => htlcSuccess.copy(tx = htlcSuccess.tx.copy(txOut = Seq(htlcSuccess.tx.txOut.head))) - case htlcTimeout: HtlcTimeoutTx => htlcTimeout.copy(tx = htlcTimeout.tx.copy(txOut = Seq(htlcTimeout.tx.txOut.head))) - } - } - val fee = amountIn - updatedHtlcTx.tx.txOut.map(_.amount).sum - (updatedHtlcTx, fee) - } - - def adjustClaimHtlcTxOutput(unsignedTx: ClaimHtlcTx, targetFeerate: FeeratePerKw, commitments: Commitments): Either[TxGenerationSkipped, (ClaimHtlcTx, Satoshi)] = { - require(unsignedTx.tx.txIn.size == 1, "claim-htlc transaction should have a single input") - require(unsignedTx.tx.txOut.size == 1, "claim-htlc transaction should have a single output") - val dummySignedTx = unsignedTx match { - case tx: ClaimHtlcSuccessTx => addSigs(tx, PlaceHolderSig, ByteVector32.Zeroes) - case tx: ClaimHtlcTimeoutTx => addSigs(tx, PlaceHolderSig) - case tx: LegacyClaimHtlcSuccessTx => tx - } - val targetFee = weight2fee(targetFeerate, dummySignedTx.tx.weight()) - val outputAmount = unsignedTx.input.txOut.amount - targetFee - if (outputAmount < commitments.localParams.dustLimit) { - Left(AmountBelowDustLimit) - } else { - val updatedClaimHtlcTx = unsignedTx match { - case claimHtlcSuccess: ClaimHtlcSuccessTx => claimHtlcSuccess.copy(tx = claimHtlcSuccess.tx.copy(txOut = Seq(claimHtlcSuccess.tx.txOut.head.copy(amount = outputAmount)))) - case claimHtlcTimeout: ClaimHtlcTimeoutTx => claimHtlcTimeout.copy(tx = claimHtlcTimeout.tx.copy(txOut = Seq(claimHtlcTimeout.tx.txOut.head.copy(amount = outputAmount)))) - case legacyClaimHtlcSuccess: LegacyClaimHtlcSuccessTx => legacyClaimHtlcSuccess - } - Right(updatedClaimHtlcTx, targetFee) - } - } - - sealed trait HtlcTxAndWitnessData { - // @formatter:off - def txInfo: HtlcTx - def updateTx(tx: Transaction): HtlcTxAndWitnessData - def addSigs(localSig: ByteVector64, commitmentFormat: CommitmentFormat): HtlcTx - // @formatter:on - } - - object HtlcTxAndWitnessData { - - case class HtlcSuccess(txInfo: HtlcSuccessTx, remoteSig: ByteVector64, preimage: ByteVector32) extends HtlcTxAndWitnessData { - // @formatter:off - override def updateTx(tx: Transaction): HtlcTxAndWitnessData = copy(txInfo = txInfo.copy(tx = tx)) - override def addSigs(localSig: ByteVector64, commitmentFormat: CommitmentFormat): HtlcTx = Transactions.addSigs(txInfo, localSig, remoteSig, preimage, commitmentFormat) - // @formatter:on - } - - case class HtlcTimeout(txInfo: HtlcTimeoutTx, remoteSig: ByteVector64) extends HtlcTxAndWitnessData { - // @formatter:off - override def updateTx(tx: Transaction): HtlcTxAndWitnessData = copy(txInfo = txInfo.copy(tx = tx)) - override def addSigs(localSig: ByteVector64, commitmentFormat: CommitmentFormat): HtlcTx = Transactions.addSigs(txInfo, localSig, remoteSig, commitmentFormat) - // @formatter:on - } - - def apply(txInfo: HtlcTx, commitments: Commitments): Option[HtlcTxAndWitnessData] = { - txInfo match { - case tx: HtlcSuccessTx => - commitments.localChanges.all.collectFirst { - case u: UpdateFulfillHtlc if Crypto.sha256(u.paymentPreimage) == tx.paymentHash => u.paymentPreimage - }.flatMap(preimage => { - commitments.localCommit.htlcTxsAndRemoteSigs.collectFirst { - case HtlcTxAndRemoteSig(HtlcSuccessTx(input, _, _, _), remoteSig) if input.outPoint == tx.input.outPoint => HtlcSuccess(tx, remoteSig, preimage) - } - }) - case tx: HtlcTimeoutTx => - commitments.localCommit.htlcTxsAndRemoteSigs.collectFirst { - case HtlcTxAndRemoteSig(HtlcTimeoutTx(input, _, _), remoteSig) if input.outPoint == tx.input.outPoint => HtlcTimeout(tx, remoteSig) - } - } + def getFeerate(feeEstimator: FeeEstimator, confirmBefore: BlockHeight, currentBlockHeight: BlockHeight): FeeratePerKw = { + val remainingBlocks = (confirmBefore - currentBlockHeight).toLong + val blockTarget = remainingBlocks match { + // If our target is still very far in the future, no need to rush + case t if t >= 144 => 144 + case t if t >= 72 => 72 + case t if t >= 36 => 36 + // However, if we get closer to the target, we start being more aggressive + case t if t >= 18 => 12 + case t if t >= 12 => 6 + case t if t >= 2 => 2 + case _ => 1 } - + feeEstimator.getFeeratePerKw(blockTarget) } } private class ReplaceableTxPublisher(nodeParams: NodeParams, + replyTo: ActorRef[TxPublisher.PublishTxResult], + cmd: TxPublisher.PublishReplaceableTx, bitcoinClient: BitcoinCoreClient, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[ReplaceableTxPublisher.Command], @@ -196,362 +101,251 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, loggingInfo: TxPublishLogContext)(implicit ec: ExecutionContext = ExecutionContext.Implicits.global) { import ReplaceableTxPublisher._ - import nodeParams.{channelKeyManager => keyManager} private val log = context.log - def start(): Behavior[Command] = { + private var confirmBefore: BlockHeight = cmd.txInfo.confirmBefore + + def checkPreconditions(): Behavior[Command] = { + val prePublisher = context.spawn(ReplaceableTxPrePublisher(nodeParams, bitcoinClient, loggingInfo), "pre-publisher") + prePublisher ! ReplaceableTxPrePublisher.CheckPreconditions(context.messageAdapter[ReplaceableTxPrePublisher.PreconditionsResult](WrappedPreconditionsResult), cmd) Behaviors.receiveMessagePartial { - case Publish(replyTo, cmd, targetFeerate) => - cmd.txInfo match { - case _: ClaimLocalAnchorOutputTx => checkAnchorPreconditions(replyTo, cmd, targetFeerate) - case htlcTx: HtlcTx => checkHtlcPreconditions(replyTo, cmd, htlcTx, targetFeerate) - case claimHtlcTx: ClaimHtlcTx => checkClaimHtlcPreconditions(replyTo, cmd, claimHtlcTx, targetFeerate) + case WrappedPreconditionsResult(result) => + result match { + case ReplaceableTxPrePublisher.PreconditionsOk(txWithWitnessData) => checkTimeLocks(txWithWitnessData) + case ReplaceableTxPrePublisher.PreconditionsFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason), None) } + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same case Stop => Behaviors.stopped } } - def checkAnchorPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, targetFeerate: FeeratePerKw): 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) - // - our commit tx is in the mempool (otherwise we can't claim our anchor) - val commitTx = cmd.commitments.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx - val fundingOutpoint = cmd.commitments.commitInput.outPoint - context.pipeToSelf(bitcoinClient.isTransactionOutputSpendable(fundingOutpoint.txid, fundingOutpoint.index.toInt, includeMempool = false).flatMap { - case false => Future.failed(CommitTxAlreadyConfirmed) - case true => - // We must ensure our local commit tx is in the mempool before publishing the anchor transaction. - // If it's already published, this call will be a no-op. - bitcoinClient.publishTransaction(commitTx) - }) { - case Success(_) => PreconditionsOk - case Failure(CommitTxAlreadyConfirmed) => CommitTxAlreadyConfirmed - case Failure(reason) if reason.getMessage.contains("rejecting replacement") => RemoteCommitTxPublished - case Failure(reason) => UnknownFailure(reason) - } - Behaviors.receiveMessagePartial { - case PreconditionsOk => - val commitFeerate = cmd.commitments.localCommit.spec.commitTxFeerate - if (targetFeerate <= commitFeerate) { - log.info("skipping {}: commit feerate is high enough (feerate={})", cmd.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. - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true))) - } else { - fund(replyTo, cmd, targetFeerate) + def checkTimeLocks(txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { + txWithWitnessData match { + // There are no time locks on anchor transactions, we can claim them right away. + case _: ClaimLocalAnchorWithWitnessData => fund(txWithWitnessData) + case _ => + val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, loggingInfo), "time-locks-monitor") + timeLocksChecker ! TxTimeLocksMonitor.CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) + Behaviors.receiveMessagePartial { + case TimeLocksOk => fund(txWithWitnessData) + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same + case Stop => Behaviors.stopped } - case CommitTxAlreadyConfirmed => - log.debug("commit tx is already confirmed, no need to claim our anchor") - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false))) - case RemoteCommitTxPublished => - log.warn("cannot publish commit tx: there is a conflicting tx in the mempool") - // We retry until that conflicting commit tx is confirmed or we're able to publish our local commit tx. - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true))) - case UnknownFailure(reason) => - log.error(s"could not check ${cmd.desc} preconditions", reason) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.UnknownTxFailure)) - case Stop => Behaviors.stopped } } - def checkHtlcPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, htlcTx: HtlcTx, targetFeerate: FeeratePerKw): Behavior[Command] = { - // We verify that: - // - their commit is not confirmed: if it is, there is no need to publish our HTLC transactions - context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.commitments.remoteCommit.txid)) { - case Success(Some(depth)) if depth >= nodeParams.minDepthBlocks => RemoteCommitTxConfirmed - case Success(_) => PreconditionsOk - case Failure(_) => PreconditionsOk // if our checks fail, we don't want it to prevent us from publishing HTLC transactions - } + def fund(txWithWitnessData: ReplaceableTxWithWitnessData): Behavior[Command] = { + val targetFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, confirmBefore, BlockHeight(nodeParams.currentBlockHeight)) + val txFunder = context.spawn(ReplaceableTxFunder(nodeParams, bitcoinClient, loggingInfo), "tx-funder") + txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Right(txWithWitnessData), targetFeerate) Behaviors.receiveMessagePartial { - case PreconditionsOk => - HtlcTxAndWitnessData(htlcTx, cmd.commitments) match { - case Some(txWithWitnessData) => checkTimeLocks(replyTo, cmd, txWithWitnessData, targetFeerate) - case None => - log.error("witness data not found for htlcId={}, skipping...", htlcTx.htlcId) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false))) + case WrappedFundingResult(result) => + result match { + case ReplaceableTxFunder.TransactionReady(tx) => + log.debug("publishing {} with confirmation target in {} blocks", cmd.desc, confirmBefore.toLong - nodeParams.currentBlockHeight) + val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), s"mempool-tx-monitor-${tx.signedTx.txid}") + txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, cmd.input, cmd.desc, tx.fee) + wait(tx) + case ReplaceableTxFunder.FundingFailed(reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason), None) } - case RemoteCommitTxConfirmed => - log.warn("cannot publish {}: remote commit has been confirmed", cmd.desc) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.ConflictingTxConfirmed)) - case Stop => Behaviors.stopped + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same + case Stop => + // We can't stop right now, the child actor is currently funding the transaction and will send its result soon. + // We just wait for the funding process to finish before stopping (in the next state). + timers.startSingleTimer(Stop, 1 second) + Behaviors.same } } - def checkClaimHtlcPreconditions(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, claimHtlcTx: ClaimHtlcTx, targetFeerate: FeeratePerKw): Behavior[Command] = { - // We verify that: - // - our commit is not confirmed: if it is, there is no need to publish our claim-HTLC transactions - context.pipeToSelf(bitcoinClient.getTxConfirmations(cmd.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx.txid)) { - case Success(Some(depth)) if depth >= nodeParams.minDepthBlocks => LocalCommitTxConfirmed - case Success(_) => PreconditionsOk - case Failure(_) => PreconditionsOk // if our checks fail, we don't want it to prevent us from publishing claim-HTLC transactions - } + // Wait for our transaction to be confirmed or rejected from the mempool. + // If we get close to the confirmation target and our transaction is stuck in the mempool, we will initiate an RBF attempt. + def wait(tx: FundedTx): Behavior[Command] = { Behaviors.receiveMessagePartial { - case PreconditionsOk => checkTimeLocks(replyTo, cmd, claimHtlcTx, targetFeerate) - case LocalCommitTxConfirmed => - log.warn("cannot publish {}: local commit has been confirmed", cmd.desc) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.ConflictingTxConfirmed)) - case Stop => Behaviors.stopped + case WrappedTxResult(txResult) => + txResult match { + case MempoolTxMonitor.TxInMempool(_, currentBlockCount) => + // We make sure we increase the fees by at least 20% as we get closer to the confirmation target. + val bumpRatio = 1.2 + val currentFeerate = getFeerate(nodeParams.onChainFeeConf.feeEstimator, confirmBefore, BlockHeight(currentBlockCount)) + val targetFeerate_opt = if (confirmBefore.toLong <= currentBlockCount + 6) { + log.debug("{} confirmation target is close (in {} blocks): bumping fees", cmd.desc, confirmBefore.toLong - currentBlockCount) + Some(currentFeerate.max(tx.feerate * bumpRatio)) + } else if (tx.feerate * bumpRatio <= currentFeerate) { + log.debug("{} confirmation target is in {} blocks: bumping fees", cmd.desc, confirmBefore.toLong - currentBlockCount) + Some(currentFeerate) + } else { + log.debug("{} confirmation target is in {} blocks: no need to bump fees", cmd.desc, confirmBefore.toLong - currentBlockCount) + None + } + // We avoid a herd effect whenever we fee bump transactions. + targetFeerate_opt.foreach(targetFeerate => timers.startSingleTimer(BumpFeeKey, BumpFee(targetFeerate), (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis)) + Behaviors.same + case MempoolTxMonitor.TxRecentlyConfirmed(_, _) => Behaviors.same // just wait for the tx to be deeply buried + case MempoolTxMonitor.TxDeeplyBuried(confirmedTx) => sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx), None) + case MempoolTxMonitor.TxRejected(_, reason) => sendResult(TxPublisher.TxRejected(loggingInfo.id, cmd, reason), Some(Seq(tx.signedTx))) + } + case BumpFee(targetFeerate) => fundReplacement(targetFeerate, tx) + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same + case Stop => unlockAndStop(cmd.input, Seq(tx.signedTx)) } } - def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, htlcTxWithWitnessData: HtlcTxAndWitnessData, targetFeerate: FeeratePerKw): Behavior[Command] = { - val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, loggingInfo), "time-locks-monitor") - timeLocksChecker ! CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) + // Fund a replacement transaction because our previous attempt seems to be stuck in the mempool. + 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, loggingInfo), "tx-funder-rbf") + txFunder ! ReplaceableTxFunder.FundTransaction(context.messageAdapter[ReplaceableTxFunder.FundingResult](WrappedFundingResult), cmd, Left(previousTx), targetFeerate) Behaviors.receiveMessagePartial { - case TimeLocksOk => - val htlcFeerate = cmd.commitments.localCommit.spec.htlcTxFeerate(cmd.commitments.commitmentFormat) - if (targetFeerate <= htlcFeerate) { - val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) - val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, cmd.commitments.localCommit.index) - val localHtlcBasepoint = keyManager.htlcPoint(channelKeyPath) - val localSig = keyManager.sign(htlcTxWithWitnessData.txInfo, localHtlcBasepoint, localPerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) - val signedHtlcTx = htlcTxWithWitnessData.addSigs(localSig, cmd.commitments.commitmentFormat) - log.info("publishing {} without adding inputs: txid={}", cmd.desc, signedHtlcTx.tx.txid) - publish(replyTo, cmd, signedHtlcTx.tx, signedHtlcTx.fee) - } else { - fund(replyTo, cmd, targetFeerate) + case WrappedFundingResult(result) => + result match { + case success: ReplaceableTxFunder.TransactionReady => publishReplacement(previousTx, success.fundedTx) + case ReplaceableTxFunder.FundingFailed(_) => + log.warn("could not fund {} replacement transaction (target feerate={})", cmd.desc, targetFeerate) + wait(previousTx) } + case txResult: WrappedTxResult => + // This is the result of the previous publishing attempt. + // We don't need to handle it now that we're in the middle of funding, we can defer it to the next state. + timers.startSingleTimer(txResult, 1 second) + Behaviors.same + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same case Stop => - timeLocksChecker ! TxTimeLocksMonitor.Stop - Behaviors.stopped + // We can't stop right away, because the child actor may need to unlock utxos first. + // We just wait for the funding process to finish before stopping. + timers.startSingleTimer(Stop, 1 second) + Behaviors.same } } - def checkTimeLocks(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, claimHtlcTx: ClaimHtlcTx, targetFeerate: FeeratePerKw): Behavior[Command] = { - val timeLocksChecker = context.spawn(TxTimeLocksMonitor(nodeParams, watcher, loggingInfo), "time-locks-monitor") - timeLocksChecker ! CheckTx(context.messageAdapter[TxTimeLocksMonitor.TimeLocksOk](_ => TimeLocksOk), cmd.txInfo.tx, cmd.desc) + // Publish an RBF attempt. We then have two concurrent transactions: the previous one and the updated one. + // Only one of them can be in the mempool, so we wait for the other to be rejected. Once that's done, we're back to a + // situation where we have one transaction in the mempool and wait for it to confirm. + def publishReplacement(previousTx: FundedTx, bumpedTx: FundedTx): Behavior[Command] = { + val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), s"mempool-tx-monitor-${bumpedTx.signedTx.txid}") + txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), bumpedTx.signedTx, cmd.input, cmd.desc, bumpedTx.fee) Behaviors.receiveMessagePartial { - case TimeLocksOk => adjustClaimHtlcTxOutput(claimHtlcTx, targetFeerate, cmd.commitments) 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("cannot publish {}: {}", cmd.desc, reason) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true))) - case Right((updatedClaimHtlcTx, fee)) => - val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) - val sig = keyManager.sign(updatedClaimHtlcTx, keyManager.htlcPoint(channelKeyPath), cmd.commitments.remoteCommit.remotePerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) - updatedClaimHtlcTx match { - case claimHtlcSuccess: LegacyClaimHtlcSuccessTx => - // The payment hash has been added to claim-htlc-success in https://github.com/ACINQ/eclair/pull/2101 - // Some transactions made with older versions of eclair may not set it correctly, in which case we simply - // publish the transaction as initially signed. - log.warn("payment hash not set for htlcId={}, publishing original transaction", claimHtlcSuccess.htlcId) - publish(replyTo, cmd, cmd.txInfo.tx, cmd.txInfo.fee) - case claimHtlcSuccess: ClaimHtlcSuccessTx => - val preimage_opt = cmd.commitments.localChanges.all.collectFirst { - case u: UpdateFulfillHtlc if Crypto.sha256(u.paymentPreimage) == claimHtlcSuccess.paymentHash => u.paymentPreimage - } - preimage_opt match { - case Some(preimage) => - val signedClaimHtlcTx = addSigs(claimHtlcSuccess, sig, preimage) - publish(replyTo, cmd, signedClaimHtlcTx.tx, fee) - case None => - log.error("preimage not found for htlcId={}, skipping...", claimHtlcSuccess.htlcId) - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = false))) - } - case claimHtlcTimeout: ClaimHtlcTimeoutTx => - val signedClaimHtlcTx = addSigs(claimHtlcTimeout, sig) - publish(replyTo, cmd, signedClaimHtlcTx.tx, fee) - } - } + case WrappedTxResult(txResult) => + txResult match { + case MempoolTxMonitor.TxDeeplyBuried(confirmedTx) => + // Since our transactions conflict, we should always receive a failure from the evicted transaction before + // one of them confirms: this case should not happen, so we don't bother unlocking utxos. + log.warn("{} was confirmed while we're publishing an RBF attempt", cmd.desc) + sendResult(TxPublisher.TxConfirmed(cmd, confirmedTx), None) + case MempoolTxMonitor.TxRejected(txid, _) => + if (txid == bumpedTx.signedTx.txid) { + log.warn("{} transaction paying more fees (txid={}) failed to replace previous transaction", cmd.desc, txid) + cleanUpFailedTxAndWait(bumpedTx.signedTx, previousTx) + } else { + log.info("previous {} replaced by new transaction paying more fees (txid={})", cmd.desc, bumpedTx.signedTx.txid) + cleanUpFailedTxAndWait(previousTx.signedTx, bumpedTx) + } + case _: MempoolTxMonitor.IntermediateTxResult => + // If a new block is found before our replacement transaction reaches the MempoolTxMonitor, we may receive + // an intermediate result for the previous transaction. We want to handle this event once we're back in the + // waiting state, because we may want to fee-bump even more aggressively if we're getting too close to the + // confirmation target. + timers.startSingleTimer(WrappedTxResult(txResult), 1 second) + Behaviors.same + } + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same case Stop => - timeLocksChecker ! TxTimeLocksMonitor.Stop - Behaviors.stopped + // We don't know yet which transaction won, so we try abandoning both and unlocking their utxos. + // One of the calls will fail (for the transaction that is in the mempool), but we will simply ignore that failure. + unlockAndStop(cmd.input, Seq(previousTx.signedTx, bumpedTx.signedTx)) } } - def fund(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, targetFeerate: FeeratePerKw): Behavior[Command] = { - context.pipeToSelf(addInputs(cmd.txInfo, targetFeerate, cmd.commitments)) { - case Success((fundedTx, fee)) => SignFundedTx(fundedTx, fee) - case Failure(reason) => FundingFailed(reason) - } + // Clean up the failed transaction attempt. Once that's done, go back to the waiting state with the new transaction. + def cleanUpFailedTxAndWait(failedTx: Transaction, mempoolTx: FundedTx): Behavior[Command] = { + context.pipeToSelf(bitcoinClient.abandonTransaction(failedTx.txid))(_ => UnlockUtxos) Behaviors.receiveMessagePartial { - case SignFundedTx(fundedTx, fee) => - log.info("added {} wallet input(s) and {} wallet output(s) to {}", fundedTx.tx.txIn.length - 1, fundedTx.tx.txOut.length - 1, cmd.desc) - sign(replyTo, cmd, fundedTx, fee) - case FundingFailed(reason) => - if (reason.getMessage.contains("Insufficient funds")) { - log.warn("cannot add inputs to {}: {}", cmd.desc, reason.getMessage) + case UnlockUtxos => + val toUnlock = failedTx.txIn.map(_.outPoint).toSet -- mempoolTx.signedTx.txIn.map(_.outPoint).toSet + if (toUnlock.isEmpty) { + context.self ! UtxosUnlocked } else { - log.error("cannot add inputs to {}: {}", cmd.desc, reason) + log.debug("unlocking utxos={}", toUnlock.mkString(", ")) + context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock.toSeq))(_ => UtxosUnlocked) } - sendResult(replyTo, TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.CouldNotFund)) + Behaviors.same + case UtxosUnlocked => + // Now that we've cleaned up the failed transaction, we can go back to waiting for the current mempool transaction + // or bump it if it doesn't confirm fast enough either. + wait(mempoolTx) + case txResult: WrappedTxResult => + // This is the result of the current mempool tx: we will handle this command once we're back in the waiting + // state for this transaction. + timers.startSingleTimer(txResult, 1 second) + Behaviors.same + case UpdateConfirmationTarget(target) => + confirmBefore = target + Behaviors.same case Stop => - // We've asked bitcoind to lock utxos, so we can't stop right now without unlocking them. - // Since we don't know yet what utxos have been locked, we defer the message. + // We don't stop right away, because we're cleaning up the failed transaction. + // This shouldn't take long so we'll handle this command once we're back in the waiting state. timers.startSingleTimer(Stop, 1 second) Behaviors.same } } - def sign(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, fundedTx: ReplaceableTransactionWithInputInfo, fee: Satoshi): Behavior[Command] = { - fundedTx match { - case claimAnchorTx: ClaimLocalAnchorOutputTx => - val claimAnchorSig = keyManager.sign(claimAnchorTx, keyManager.fundingPublicKey(cmd.commitments.localParams.fundingKeyPath), TxOwner.Local, cmd.commitments.commitmentFormat) - val signedClaimAnchorTx = addSigs(claimAnchorTx, claimAnchorSig) - val commitInfo = BitcoinCoreClient.PreviousTx(signedClaimAnchorTx.input, signedClaimAnchorTx.tx.txIn.head.witness) - context.pipeToSelf(bitcoinClient.signTransaction(signedClaimAnchorTx.tx, Seq(commitInfo))) { - case Success(signedTx) => PublishSignedTx(signedTx.tx) - case Failure(reason) => UnknownFailure(reason) - } - case htlcTx: HtlcTx => - // NB: we've already checked witness data in the precondition phase. Witness data extraction should be done - // earlier by the channel to remove this duplication. - val txWithWitnessData = HtlcTxAndWitnessData(htlcTx, cmd.commitments).get - val channelKeyPath = keyManager.keyPath(cmd.commitments.localParams, cmd.commitments.channelConfig) - val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, cmd.commitments.localCommit.index) - val localHtlcBasepoint = keyManager.htlcPoint(channelKeyPath) - val localSig = keyManager.sign(htlcTx, localHtlcBasepoint, localPerCommitmentPoint, TxOwner.Local, cmd.commitments.commitmentFormat) - val signedHtlcTx = txWithWitnessData.addSigs(localSig, cmd.commitments.commitmentFormat) - val inputInfo = BitcoinCoreClient.PreviousTx(signedHtlcTx.input, signedHtlcTx.tx.txIn.head.witness) - context.pipeToSelf(bitcoinClient.signTransaction(signedHtlcTx.tx, Seq(inputInfo), allowIncomplete = true).map(signTxResponse => { - // NB: bitcoind versions older than 0.21.1 messes up the witness stack for our htlc input, so we need to restore it. - // See https://github.com/bitcoin/bitcoin/issues/21151 - signedHtlcTx.tx.copy(txIn = signedHtlcTx.tx.txIn.head +: signTxResponse.tx.txIn.tail) - })) { - case Success(signedTx) => PublishSignedTx(signedTx) - case Failure(reason) => UnknownFailure(reason) - } - case _: ClaimHtlcTx => log.error("claim-htlc-tx should not use external inputs") - } - Behaviors.receiveMessagePartial { - case PublishSignedTx(signedTx) => publish(replyTo, cmd, signedTx, fee) - case UnknownFailure(reason) => - log.error("cannot sign {}: {}", cmd.desc, reason) - replyTo ! TxPublisher.TxRejected(loggingInfo.id, cmd, TxPublisher.TxRejectedReason.UnknownTxFailure) - // We wait for our parent to stop us: when that happens we will unlock utxos. - Behaviors.same - case Stop => unlockAndStop(cmd.input, fundedTx.tx) + def sendResult(result: TxPublisher.PublishTxResult, toUnlock_opt: Option[Seq[Transaction]]): Behavior[Command] = { + replyTo ! result + toUnlock_opt match { + case Some(txs) => unlockAndStop(cmd.input, txs) + case None => stop() } } - def publish(replyTo: ActorRef[TxPublisher.PublishTxResult], cmd: TxPublisher.PublishReplaceableTx, tx: Transaction, fee: Satoshi): Behavior[Command] = { - val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, loggingInfo), "mempool-tx-monitor") - txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx, cmd.input, cmd.desc, fee) + def unlockAndStop(input: OutPoint, txs: Seq[Transaction]): Behavior[Command] = { + // The bitcoind wallet will keep transactions around even when they can't be published (e.g. one of their inputs has + // disappeared but bitcoind thinks it may reappear later), hoping that it will be able to automatically republish + // them later. In our case this is unnecessary, we will publish ourselves, and we don't want to pollute the wallet + // state with transactions that will never be valid, so we eagerly abandon every time. + // If the transaction is in the mempool or confirmed, it will be a no-op. + context.pipeToSelf(Future.traverse(txs)(tx => bitcoinClient.abandonTransaction(tx.txid)))(_ => UnlockUtxos) Behaviors.receiveMessagePartial { - case WrappedTxResult(MempoolTxMonitor.TxConfirmed) => sendResult(replyTo, TxPublisher.TxConfirmed(cmd, tx)) - case WrappedTxResult(MempoolTxMonitor.TxRejected(reason)) => - reason match { - case TxRejectedReason.WalletInputGone => - // The transaction now has an unknown input from bitcoind's point of view, so it will keep it in the wallet in - // case that input appears later in the mempool or the blockchain. In our case, we know it won't happen so we - // abandon that transaction and will retry with a different set of inputs (if it still makes sense to publish). - bitcoinClient.abandonTransaction(tx.txid) - case _ => // nothing to do + case UnlockUtxos => + val toUnlock = txs.flatMap(_.txIn).filterNot(_.outPoint == input).map(_.outPoint).toSet + if (toUnlock.isEmpty) { + context.self ! UtxosUnlocked + } else { + log.debug("unlocking utxos={}", toUnlock.mkString(", ")) + context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock.toSeq))(_ => UtxosUnlocked) } - replyTo ! TxPublisher.TxRejected(loggingInfo.id, cmd, reason) - // We wait for our parent to stop us: when that happens we will unlock utxos. Behaviors.same - case Stop => - txMonitor ! MempoolTxMonitor.Stop - unlockAndStop(cmd.input, tx) - } - } - - def unlockAndStop(input: OutPoint, tx: Transaction): Behavior[Command] = { - val toUnlock = tx.txIn.filterNot(_.outPoint == input).map(_.outPoint) - log.debug("unlocking utxos={}", toUnlock.mkString(", ")) - context.pipeToSelf(bitcoinClient.unlockOutpoints(toUnlock))(_ => UtxosUnlocked) - Behaviors.receiveMessagePartial { case UtxosUnlocked => log.debug("utxos unlocked") Behaviors.stopped + case _: WrappedTxResult => + log.debug("ignoring transaction result while stopping") + Behaviors.same + case _: UpdateConfirmationTarget => + log.debug("ignoring confirmation target update while stopping") + Behaviors.same case Stop => log.debug("waiting for utxos to be unlocked before stopping") Behaviors.same } } - def sendResult(replyTo: ActorRef[TxPublisher.PublishTxResult], result: TxPublisher.PublishTxResult): Behavior[Command] = { - replyTo ! result + def stop(): Behavior[Command] = { Behaviors.receiveMessagePartial { case Stop => Behaviors.stopped } } - private def addInputs(txInfo: ReplaceableTransactionWithInputInfo, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(ReplaceableTransactionWithInputInfo, Satoshi)] = { - txInfo match { - case anchorTx: ClaimLocalAnchorOutputTx => addInputs(anchorTx, targetFeerate, commitments) - case htlcTx: HtlcTx => addInputs(htlcTx, targetFeerate, commitments) - case _: ClaimHtlcTx => Future.failed(new RuntimeException("claim-htlc-tx should not use external inputs")) - } - } - - private def addInputs(txInfo: ClaimLocalAnchorOutputTx, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(ClaimLocalAnchorOutputTx, Satoshi)] = { - val dustLimit = commitments.localParams.dustLimit - val commitFeerate = commitments.localCommit.spec.commitTxFeerate - val commitTx = commitments.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx - // We want the feerate of the package (commit tx + tx spending anchor) to equal targetFeerate. - // Thus we have: anchorFeerate = targetFeerate + (weight-commit-tx / weight-anchor-tx) * (targetFeerate - commitTxFeerate) - // If we use the smallest weight possible for the anchor tx, the feerate we use will thus be greater than what we want, - // and we can adjust it afterwards by raising the change output amount. - val anchorFeerate = targetFeerate + FeeratePerKw(targetFeerate.feerate - commitFeerate.feerate) * commitTx.weight() / claimAnchorOutputMinWeight - // NB: fundrawtransaction requires at least one output, and may add at most one additional change output. - // Since the purpose of this transaction is just to do a CPFP, the resulting tx should have a single change output - // (note that bitcoind doesn't let us publish a transaction with no outputs). - // To work around these limitations, we start with a dummy output and later merge that dummy output with the optional - // change output added by bitcoind. - // NB: fundrawtransaction doesn't support non-wallet inputs, so we have to remove our anchor input and re-add it later. - // That means bitcoind will not take our anchor input's weight into account when adding inputs to set the fee. - // That's ok, we can increase the fee later by decreasing the output amount. But we need to ensure we'll have enough - // to cover the weight of our anchor input, which is why we set it to the following value. - val dummyChangeAmount = weight2fee(anchorFeerate, claimAnchorOutputMinWeight) + dustLimit - val txNotFunded = Transaction(2, Nil, TxOut(dummyChangeAmount, Script.pay2wpkh(PlaceHolderPubKey)) :: Nil, 0) - bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(anchorFeerate, lockUtxos = true)).flatMap(fundTxResponse => { - // We merge the outputs if there's more than one. - fundTxResponse.changePosition match { - case Some(changePos) => - val changeOutput = fundTxResponse.tx.txOut(changePos) - val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(changeOutput.copy(amount = changeOutput.amount + dummyChangeAmount))) - Future.successful(fundTxResponse.copy(tx = txSingleOutput)) - case None => - bitcoinClient.getChangeAddress().map(pubkeyHash => { - val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(TxOut(dummyChangeAmount, Script.pay2wpkh(pubkeyHash)))) - fundTxResponse.copy(tx = txSingleOutput) - }) - } - }).map(fundTxResponse => { - require(fundTxResponse.tx.txOut.size == 1, "funded transaction should have a single change output") - // NB: we insert the anchor input in the *first* position because our signing helpers only sign input #0. - val unsignedTx = txInfo.copy(tx = fundTxResponse.tx.copy(txIn = txInfo.tx.txIn.head +: fundTxResponse.tx.txIn)) - adjustAnchorOutputChange(unsignedTx, commitTx, fundTxResponse.amountIn + AnchorOutputsCommitmentFormat.anchorAmount, commitFeerate, targetFeerate, dustLimit) - }) - } - - private def addInputs(txInfo: HtlcTx, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(HtlcTx, Satoshi)] = { - // NB: fundrawtransaction doesn't support non-wallet inputs, so we clear the input and re-add it later. - val txNotFunded = txInfo.tx.copy(txIn = Nil, txOut = txInfo.tx.txOut.head.copy(amount = commitments.localParams.dustLimit) :: Nil) - val htlcTxWeight = txInfo match { - case _: HtlcSuccessTx => commitments.commitmentFormat.htlcSuccessWeight - case _: HtlcTimeoutTx => commitments.commitmentFormat.htlcTimeoutWeight - } - // We want the feerate of our final HTLC tx to equal targetFeerate. However, we removed the HTLC input from what we - // send to fundrawtransaction, so bitcoind will not know the total weight of the final tx. In order to make up for - // this difference, we need to tell bitcoind to target a higher feerate that takes into account the weight of the - // input we removed. - // That feerate will satisfy the following equality: - // feerate * weight_seen_by_bitcoind = target_feerate * (weight_seen_by_bitcoind + htlc_input_weight) - // So: feerate = target_feerate * (1 + htlc_input_weight / weight_seen_by_bitcoind) - // Because bitcoind will add at least one P2WPKH input, weight_seen_by_bitcoind >= htlc_tx_weight + p2wpkh_weight - // Thus: feerate <= target_feerate * (1 + htlc_input_weight / (htlc_tx_weight + p2wpkh_weight)) - // NB: we don't take into account the fee paid by our HTLC input: we will take it into account when we adjust the - // change output amount (unless bitcoind didn't add any change output, in that case we will overpay the fee slightly). - val weightRatio = 1.0 + (htlcInputMaxWeight.toDouble / (htlcTxWeight + claimP2WPKHOutputWeight)) - bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate * weightRatio, lockUtxos = true, changePosition = Some(1))).map(fundTxResponse => { - // We add the HTLC input (from the commit tx) and restore the HTLC output. - // NB: we can't modify them because they are signed by our peer (with SIGHASH_SINGLE | SIGHASH_ANYONECANPAY). - val txWithHtlcInput = fundTxResponse.tx.copy( - txIn = txInfo.tx.txIn ++ fundTxResponse.tx.txIn, - txOut = txInfo.tx.txOut ++ fundTxResponse.tx.txOut.tail - ) - val unsignedTx = txInfo match { - case htlcSuccess: HtlcSuccessTx => htlcSuccess.copy(tx = txWithHtlcInput) - case htlcTimeout: HtlcTimeoutTx => htlcTimeout.copy(tx = txWithHtlcInput) - } - adjustHtlcTxChange(unsignedTx, fundTxResponse.amountIn + unsignedTx.input.txOut.amount, targetFeerate, commitments) - }) - } - } 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 6ae8f23a60..aa28dec5d6 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 @@ -24,10 +24,9 @@ import fr.acinq.bitcoin.{ByteVector32, OutPoint, Satoshi, Transaction} import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient -import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.Commitments import fr.acinq.eclair.transactions.Transactions.{ReplaceableTransactionWithInputInfo, TransactionWithInputInfo} -import fr.acinq.eclair.{Logs, NodeParams} +import fr.acinq.eclair.{BlockHeight, Logs, NodeParams} import java.util.UUID import scala.concurrent.duration.DurationLong @@ -51,7 +50,7 @@ object TxPublisher { // +---------+ | +--------------------+ // | PublishTx | TxPublisher | // +------------>| - stores txs and |---+ +-----------------+ - // | deadlines | | create child actor | TxPublish | + // | block targets | | create child actor | TxPublish | // | - create child | | ask it to publish | - preconditions | // | actors that fund | | at a given feerate | - (funding) | // | and publish txs | +--------------------->| - (signing) | @@ -80,6 +79,9 @@ object TxPublisher { * Publish a fully signed transaction without modifying it. * NB: the parent tx should only be provided when it's being concurrently published, it's unnecessary when it is * confirmed or when the tx has a relative delay. + * + * @param fee the fee that we're actually paying: it must be set to the mining fee, unless our peer is paying it (in + * which case it must be set to zero here). */ case class PublishFinalTx(tx: Transaction, input: OutPoint, desc: String, fee: Satoshi, parentTx_opt: Option[ByteVector32]) extends PublishTx object PublishFinalTx { @@ -148,6 +150,41 @@ object TxPublisher { // @formatter:on } + // @formatter:off + sealed trait PublishAttempt { + def id: UUID + def cmd: PublishTx + } + case class FinalAttempt(id: UUID, cmd: PublishFinalTx, actor: ActorRef[FinalTxPublisher.Command]) extends PublishAttempt + case class ReplaceableAttempt(id: UUID, cmd: PublishReplaceableTx, confirmBefore: BlockHeight, actor: ActorRef[ReplaceableTxPublisher.Command]) extends PublishAttempt + // @formatter:on + + /** + * There can be multiple attempts to spend the same [[OutPoint]]. + * Only one of them will work, but we keep track of all of them. + * There is only one [[ReplaceableAttempt]] because we will replace the existing attempt instead of creating a new one. + */ + case class PublishAttempts(finalAttempts: Seq[FinalAttempt], replaceableAttempt_opt: Option[ReplaceableAttempt]) { + val attempts: Seq[PublishAttempt] = finalAttempts ++ replaceableAttempt_opt.toSeq + val count: Int = attempts.length + + def add(finalAttempt: FinalAttempt): PublishAttempts = copy(finalAttempts = finalAttempts :+ finalAttempt) + + def remove(id: UUID): (Seq[PublishAttempt], PublishAttempts) = { + val (removed, remaining) = finalAttempts.partition(_.id == id) + replaceableAttempt_opt match { + case Some(replaceableAttempt) if replaceableAttempt.id == id => (removed :+ replaceableAttempt, PublishAttempts(remaining, None)) + case _ => (removed, PublishAttempts(remaining, replaceableAttempt_opt)) + } + } + + def isEmpty: Boolean = replaceableAttempt_opt.isEmpty && finalAttempts.isEmpty + } + + object PublishAttempts { + val empty: PublishAttempts = PublishAttempts(Nil, None) + } + def apply(nodeParams: NodeParams, remoteNodeId: PublicKey, factory: ChildFactory): Behavior[Command] = Behaviors.setup { context => Behaviors.withTimers { timers => @@ -163,63 +200,58 @@ object TxPublisher { private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFactory, context: ActorContext[TxPublisher.Command], timers: TimerScheduler[TxPublisher.Command]) { import TxPublisher._ - import nodeParams.onChainFeeConf.{feeEstimator, feeTargets} private val log = context.log - // @formatter:off - private sealed trait PublishAttempt { - def id: UUID - def cmd: PublishTx - } - private case class FinalAttempt(id: UUID, cmd: PublishFinalTx, actor: ActorRef[FinalTxPublisher.Command]) extends PublishAttempt - private case class ReplaceableAttempt(id: UUID, cmd: PublishReplaceableTx, feerate: FeeratePerKw, actor: ActorRef[ReplaceableTxPublisher.Command]) extends PublishAttempt - // @formatter:on - - private def run(pending: Map[OutPoint, Seq[PublishAttempt]], retryNextBlock: Seq[PublishTx], channelInfo: ChannelLogContext): Behavior[Command] = { + private def run(pending: Map[OutPoint, PublishAttempts], retryNextBlock: Seq[PublishTx], channelInfo: ChannelLogContext): Behavior[Command] = { Behaviors.receiveMessage { case cmd: PublishFinalTx => - val attempts = pending.getOrElse(cmd.input, Seq.empty) - val alreadyPublished = attempts.exists { - case a: FinalAttempt => a.cmd.tx.txid == cmd.tx.txid - case _ => false - } + val attempts = pending.getOrElse(cmd.input, PublishAttempts.empty) + val alreadyPublished = attempts.finalAttempts.exists(_.cmd.tx.txid == cmd.tx.txid) if (alreadyPublished) { log.info("not publishing {} txid={} spending {}:{}, publishing is already in progress", cmd.desc, cmd.tx.txid, cmd.input.txid, cmd.input.index) Behaviors.same } else { val publishId = UUID.randomUUID() - log.info("publishing {} txid={} spending {}:{} with id={} ({} other attempts)", cmd.desc, cmd.tx.txid, cmd.input.txid, cmd.input.index, publishId, attempts.length) + log.info("publishing {} txid={} spending {}:{} with id={} ({} other attempts)", cmd.desc, cmd.tx.txid, cmd.input.txid, cmd.input.index, publishId, attempts.count) val actor = factory.spawnFinalTxPublisher(context, TxPublishLogContext(publishId, channelInfo.remoteNodeId, channelInfo.channelId_opt)) actor ! FinalTxPublisher.Publish(context.self, cmd) - run(pending + (cmd.input -> attempts.appended(FinalAttempt(publishId, cmd, actor))), retryNextBlock, channelInfo) + run(pending + (cmd.input -> attempts.add(FinalAttempt(publishId, cmd, actor))), retryNextBlock, channelInfo) } case cmd: PublishReplaceableTx => - val targetFeerate = feeEstimator.getFeeratePerKw(feeTargets.commitmentBlockTarget) - val attempts = pending.getOrElse(cmd.input, Seq.empty) - val alreadyPublished = attempts.exists { - // If there is already an attempt at spending this outpoint with a higher feerate, there is no point in publishing again. - case a: ReplaceableAttempt => targetFeerate <= a.feerate - case _ => false - } - if (alreadyPublished) { - log.info("not publishing replaceable {} spending {}:{} with feerate={}, publishing is already in progress", cmd.desc, cmd.input.txid, cmd.input.index, targetFeerate) - Behaviors.same - } else { - val publishId = UUID.randomUUID() - log.info("publishing replaceable {} spending {}:{} with id={} ({} other attempts)", cmd.desc, cmd.input.txid, cmd.input.index, publishId, attempts.length) - val actor = factory.spawnReplaceableTxPublisher(context, TxPublishLogContext(publishId, channelInfo.remoteNodeId, channelInfo.channelId_opt)) - actor ! ReplaceableTxPublisher.Publish(context.self, cmd, targetFeerate) - run(pending + (cmd.input -> attempts.appended(ReplaceableAttempt(publishId, cmd, targetFeerate, actor))), retryNextBlock, channelInfo) + val proposedConfirmationTarget = cmd.txInfo.confirmBefore + 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.confirmBefore + if (currentConfirmationTarget <= proposedConfirmationTarget) { + log.info("not publishing replaceable {} spending {}:{} with confirmation target={}, publishing is already in progress with confirmation target={}", cmd.desc, cmd.input.txid, cmd.input.index, proposedConfirmationTarget, currentConfirmationTarget) + Behaviors.same + } else { + log.info("replaceable {} spending {}:{} has new confirmation target={} (previous={})", cmd.desc, cmd.input.txid, cmd.input.index, proposedConfirmationTarget, currentConfirmationTarget) + currentAttempt.actor ! ReplaceableTxPublisher.UpdateConfirmationTarget(proposedConfirmationTarget) + val attempts2 = attempts.copy(replaceableAttempt_opt = Some(currentAttempt.copy(confirmBefore = proposedConfirmationTarget))) + run(pending + (cmd.input -> attempts2), retryNextBlock, channelInfo) + } + case None => + val publishId = UUID.randomUUID() + log.info("publishing replaceable {} spending {}:{} with id={} ({} other attempts)", cmd.desc, cmd.input.txid, cmd.input.index, publishId, attempts.count) + val actor = factory.spawnReplaceableTxPublisher(context, TxPublishLogContext(publishId, channelInfo.remoteNodeId, channelInfo.channelId_opt)) + actor ! ReplaceableTxPublisher.Publish(context.self, cmd) + val attempts2 = attempts.copy(replaceableAttempt_opt = Some(ReplaceableAttempt(publishId, cmd, proposedConfirmationTarget, actor))) + run(pending + (cmd.input -> attempts2), retryNextBlock, channelInfo) } case result: PublishTxResult => result match { case TxConfirmed(cmd, _) => - pending.get(cmd.input).foreach(stopAttempts) + pending.get(cmd.input).foreach(a => stopAttempts(a.attempts)) run(pending - cmd.input, retryNextBlock, channelInfo) case TxRejected(id, cmd, reason) => - val (rejectedAttempts, remainingAttempts) = pending.getOrElse(cmd.input, Seq.empty).partition(_.id == id) + val (rejectedAttempts, remainingAttempts) = pending.getOrElse(cmd.input, PublishAttempts.empty).remove(id) stopAttempts(rejectedAttempts) val pending2 = if (remainingAttempts.isEmpty) pending - cmd.input else pending + (cmd.input -> remainingAttempts) reason match { @@ -237,9 +269,17 @@ private class TxPublisher(nodeParams: NodeParams, factory: TxPublisher.ChildFact val retryNextBlock2 = if (retry) retryNextBlock ++ rejectedAttempts.map(_.cmd) else retryNextBlock run(pending2, retryNextBlock2, channelInfo) case TxRejectedReason.ConflictingTxUnconfirmed => - // Our transaction was replaced by a transaction that pays more fees, so it doesn't make sense to retry now. - // We will automatically retry with a higher fee if we get close to the deadline. - run(pending2, retryNextBlock, channelInfo) + cmd match { + case _: PublishFinalTx => + // Our transaction is not replaceable, and the mempool contains a transaction that pays more fees, so + // it doesn't make sense to retry, we will keep getting rejected. + run(pending2, retryNextBlock, channelInfo) + case _: PublishReplaceableTx => + // The mempool contains a transaction that pays more fees, but as we get closer to the confirmation + // target, we will try to publish with higher fees, so if the conflicting transaction doesn't confirm, + // we should be able to replace it before we reach the confirmation target. + run(pending2, retryNextBlock ++ rejectedAttempts.map(_.cmd), channelInfo) + } case TxRejectedReason.ConflictingTxConfirmed => // Our transaction was double-spent by a competing transaction that has been confirmed, so it doesn't make // sense to retry. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala index 8b4bf22a1d..66281af1b8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitor.scala @@ -17,7 +17,7 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.eventstream.EventStream -import akka.actor.typed.scaladsl.{ActorContext, Behaviors} +import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler} import akka.actor.typed.{ActorRef, Behavior} import fr.acinq.bitcoin.{ByteVector32, Transaction} import fr.acinq.eclair.NodeParams @@ -27,6 +27,9 @@ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchParentTxConfirmed, W import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext import fr.acinq.eclair.transactions.Scripts +import scala.concurrent.duration.DurationLong +import scala.util.Random + /** * Created by t-bast on 10/06/2021. */ @@ -43,34 +46,35 @@ object TxTimeLocksMonitor { sealed trait Command case class CheckTx(replyTo: ActorRef[TimeLocksOk], tx: Transaction, desc: String) extends Command private case class WrappedCurrentBlockCount(currentBlockCount: Long) extends Command + private case object CheckRelativeTimeLock extends Command private case class ParentTxConfirmed(parentTxId: ByteVector32) extends Command - case object Stop extends Command // @formatter:on def apply(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], loggingInfo: TxPublishLogContext): Behavior[Command] = { Behaviors.setup { context => - Behaviors.withMdc(loggingInfo.mdc()) { - new TxTimeLocksMonitor(nodeParams, watcher, context).start() + Behaviors.withTimers { timers => + Behaviors.withMdc(loggingInfo.mdc()) { + Behaviors.receiveMessagePartial { + case cmd: CheckTx => new TxTimeLocksMonitor(nodeParams, cmd, watcher, context, timers).checkAbsoluteTimeLock() + } + } } } } } -private class TxTimeLocksMonitor(nodeParams: NodeParams, watcher: ActorRef[ZmqWatcher.Command], context: ActorContext[TxTimeLocksMonitor.Command]) { +private class TxTimeLocksMonitor(nodeParams: NodeParams, + cmd: TxTimeLocksMonitor.CheckTx, + watcher: ActorRef[ZmqWatcher.Command], + context: ActorContext[TxTimeLocksMonitor.Command], + timers: TimerScheduler[TxTimeLocksMonitor.Command]) { import TxTimeLocksMonitor._ private val log = context.log - def start(): Behavior[Command] = { - Behaviors.receiveMessagePartial { - case cmd: CheckTx => checkAbsoluteTimeLock(cmd) - case Stop => Behaviors.stopped - } - } - - def checkAbsoluteTimeLock(cmd: CheckTx): Behavior[Command] = { + def checkAbsoluteTimeLock(): Behavior[Command] = { val blockCount = nodeParams.currentBlockHeight val cltvTimeout = Scripts.cltvTimeout(cmd.tx) if (blockCount < cltvTimeout) { @@ -81,20 +85,19 @@ private class TxTimeLocksMonitor(nodeParams: NodeParams, watcher: ActorRef[ZmqWa case WrappedCurrentBlockCount(currentBlockCount) => if (cltvTimeout <= currentBlockCount) { context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) - checkRelativeTimeLocks(cmd) + timers.startSingleTimer(CheckRelativeTimeLock, (1 + Random.nextLong(nodeParams.maxTxPublishRetryDelay.toMillis)).millis) + Behaviors.same } else { Behaviors.same } - case Stop => - context.system.eventStream ! EventStream.Unsubscribe(messageAdapter) - Behaviors.stopped + case CheckRelativeTimeLock => checkRelativeTimeLocks() } } else { - checkRelativeTimeLocks(cmd) + checkRelativeTimeLocks() } } - def checkRelativeTimeLocks(cmd: CheckTx): Behavior[Command] = { + def checkRelativeTimeLocks(): Behavior[Command] = { val csvTimeouts = Scripts.csvTimeouts(cmd.tx) if (csvTimeouts.nonEmpty) { val watchConfirmedResponseMapper: ActorRef[WatchParentTxConfirmedTriggered] = context.messageAdapter(w => ParentTxConfirmed(w.tx.txid)) @@ -103,29 +106,28 @@ private class TxTimeLocksMonitor(nodeParams: NodeParams, watcher: ActorRef[ZmqWa log.info("{} has a relative timeout of {} blocks, watching parentTxId={}", cmd.desc, csvTimeout, parentTxId) watcher ! WatchParentTxConfirmed(watchConfirmedResponseMapper, parentTxId, minDepth = csvTimeout) } - waitForParentsToConfirm(cmd, csvTimeouts.keySet) + waitForParentsToConfirm(csvTimeouts.keySet) } else { - notifySender(cmd) + notifySender() } } - def waitForParentsToConfirm(cmd: CheckTx, parentTxIds: Set[ByteVector32]): Behavior[Command] = { + def waitForParentsToConfirm(parentTxIds: Set[ByteVector32]): Behavior[Command] = { Behaviors.receiveMessagePartial { case ParentTxConfirmed(parentTxId) => log.info("parent tx of {} has been confirmed (parent txid={})", cmd.desc, parentTxId) val remainingParentTxIds = parentTxIds - parentTxId if (remainingParentTxIds.isEmpty) { log.info("all parent txs of {} have been confirmed", cmd.desc) - notifySender(cmd) + notifySender() } else { log.debug("some parent txs of {} are still unconfirmed (parent txids={})", cmd.desc, remainingParentTxIds.mkString(",")) - waitForParentsToConfirm(cmd, remainingParentTxIds) + waitForParentsToConfirm(remainingParentTxIds) } - case Stop => Behaviors.stopped } } - def notifySender(cmd: CheckTx): Behavior[Command] = { + def notifySender(): Behavior[Command] = { log.debug("time locks satisfied for {}", cmd.desc) cmd.replyTo ! TimeLocksOk() Behaviors.stopped diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala index 46f98f1b76..48852b5824 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/json/JsonSerializers.scala @@ -211,17 +211,27 @@ object TransactionWithInputInfoSerializer extends MinimalSerializer({ JField("txid", JString(x.tx.txid.toHex)), JField("tx", JString(x.tx.toString())), JField("paymentHash", JString(x.paymentHash.toString())), - JField("htlcId", JLong(x.htlcId)) + JField("htlcId", JLong(x.htlcId)), + JField("confirmBeforeBlock", JLong(x.confirmBefore.toLong)) )) case x: HtlcTimeoutTx => JObject(List( JField("txid", JString(x.tx.txid.toHex)), JField("tx", JString(x.tx.toString())), - JField("htlcId", JLong(x.htlcId)) + JField("htlcId", JLong(x.htlcId)), + JField("confirmBeforeBlock", JLong(x.confirmBefore.toLong)) + )) + case x: ClaimHtlcSuccessTx => JObject(List( + JField("txid", JString(x.tx.txid.toHex)), + JField("tx", JString(x.tx.toString())), + JField("paymentHash", JString(x.paymentHash.toString())), + JField("htlcId", JLong(x.htlcId)), + JField("confirmBeforeBlock", JLong(x.confirmBefore.toLong)) )) case x: ClaimHtlcTx => JObject(List( JField("txid", JString(x.tx.txid.toHex)), JField("tx", JString(x.tx.toString())), - JField("htlcId", JLong(x.htlcId)) + JField("htlcId", JLong(x.htlcId)), + JField("confirmBeforeBlock", JLong(x.confirmBefore.toLong)) )) case x: ClosingTx => val txFields = List( @@ -238,6 +248,11 @@ object TransactionWithInputInfoSerializer extends MinimalSerializer({ JObject(txFields :+ toLocalField) case None => JObject(txFields) } + case x: ReplaceableTransactionWithInputInfo => JObject(List( + JField("txid", JString(x.tx.txid.toHex)), + JField("tx", JString(x.tx.toString())), + JField("confirmBeforeBlock", JLong(x.confirmBefore.toLong)) + )) case x: TransactionWithInputInfo => JObject(List( JField("txid", JString(x.tx.txid.toHex)), JField("tx", JString(x.tx.toString())) 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 2346c23616..d7e6f13ace 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 @@ -100,7 +100,8 @@ object Transactions { def input: InputInfo def desc: String def tx: Transaction - def fee: Satoshi = input.txOut.amount - tx.txOut.map(_.amount).sum + def amountIn: Satoshi = input.txOut.amount + def fee: Satoshi = amountIn - tx.txOut.map(_.amount).sum def minRelayFee: Satoshi = { val vsize = (tx.weight() + 3) / 4 Satoshi(FeeratePerKw.MinimumRelayFeeRate * vsize / 1000) @@ -108,9 +109,24 @@ object Transactions { /** Sighash flags to use when signing the transaction. */ def sighash(txOwner: TxOwner, commitmentFormat: CommitmentFormat): Int = SIGHASH_ALL } - sealed trait ReplaceableTransactionWithInputInfo extends TransactionWithInputInfo + sealed trait ReplaceableTransactionWithInputInfo extends TransactionWithInputInfo { + /** Block before which the transaction must be confirmed. */ + def confirmBefore: BlockHeight + } case class CommitTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "commit-tx" } + /** + * 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 + * htlc transactions, but we introduced that before implementing the replacement strategy. + * Unfortunately, if we wanted to change that, we would have to update the codecs and implement a migration of channel + * data, which isn't trivial, so we chose to temporarily live with that inconsistency (and have the transaction + * replacement logic abort when non-anchor outputs htlc transactions are provided). + * Ideally, we'd like to implement a dynamic commitment format upgrade mechanism and depreciate the pre-anchor outputs + * format soon, which will get rid of this inconsistency. + * 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 { def htlcId: Long override def sighash(txOwner: TxOwner, commitmentFormat: CommitmentFormat): Int = commitmentFormat match { @@ -121,15 +137,15 @@ object Transactions { } } } - case class HtlcSuccessTx(input: InputInfo, tx: Transaction, paymentHash: ByteVector32, htlcId: Long) extends HtlcTx { override def desc: String = "htlc-success" } - case class HtlcTimeoutTx(input: InputInfo, tx: Transaction, htlcId: Long) extends HtlcTx { override def desc: String = "htlc-timeout" } + case class HtlcSuccessTx(input: InputInfo, tx: Transaction, paymentHash: ByteVector32, htlcId: Long, confirmBefore: BlockHeight) extends HtlcTx { override def desc: String = "htlc-success" } + case class HtlcTimeoutTx(input: InputInfo, tx: Transaction, htlcId: Long, confirmBefore: BlockHeight) extends HtlcTx { override def desc: String = "htlc-timeout" } case class HtlcDelayedTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo { override def desc: String = "htlc-delayed" } sealed trait ClaimHtlcTx extends ReplaceableTransactionWithInputInfo { def htlcId: Long } - case class LegacyClaimHtlcSuccessTx(input: InputInfo, tx: Transaction, htlcId: Long) extends ClaimHtlcTx { override def desc: String = "claim-htlc-success" } - case class ClaimHtlcSuccessTx(input: InputInfo, tx: Transaction, paymentHash: ByteVector32, htlcId: Long) extends ClaimHtlcTx { override def desc: String = "claim-htlc-success" } - case class ClaimHtlcTimeoutTx(input: InputInfo, tx: Transaction, htlcId: Long) extends ClaimHtlcTx { override def desc: String = "claim-htlc-timeout" } + case class LegacyClaimHtlcSuccessTx(input: InputInfo, tx: Transaction, htlcId: Long, confirmBefore: BlockHeight) extends ClaimHtlcTx { override def desc: String = "claim-htlc-success" } + case class ClaimHtlcSuccessTx(input: InputInfo, tx: Transaction, paymentHash: ByteVector32, htlcId: Long, confirmBefore: BlockHeight) extends ClaimHtlcTx { override def desc: String = "claim-htlc-success" } + case class ClaimHtlcTimeoutTx(input: InputInfo, tx: Transaction, htlcId: Long, confirmBefore: BlockHeight) extends ClaimHtlcTx { override def desc: String = "claim-htlc-timeout" } sealed trait ClaimAnchorOutputTx extends TransactionWithInputInfo - case class ClaimLocalAnchorOutputTx(input: InputInfo, tx: Transaction) extends ClaimAnchorOutputTx with ReplaceableTransactionWithInputInfo { override def desc: String = "local-anchor" } + case class ClaimLocalAnchorOutputTx(input: InputInfo, tx: Transaction, confirmBefore: BlockHeight) extends ClaimAnchorOutputTx with ReplaceableTransactionWithInputInfo { override def desc: String = "local-anchor" } case class ClaimRemoteAnchorOutputTx(input: InputInfo, tx: Transaction) extends ClaimAnchorOutputTx { override def desc: String = "remote-anchor" } sealed trait ClaimRemoteCommitMainOutputTx extends TransactionWithInputInfo case class ClaimP2WPKHOutputTx(input: InputInfo, tx: Transaction) extends ClaimRemoteCommitMainOutputTx { override def desc: String = "remote-main" } @@ -452,7 +468,7 @@ object Transactions { txOut = TxOut(amount, pay2wsh(toLocalDelayed(localRevocationPubkey, toLocalDelay, localDelayedPaymentPubkey))) :: Nil, lockTime = htlc.cltvExpiry.toLong ) - Right(HtlcTimeoutTx(input, tx, htlc.id)) + Right(HtlcTimeoutTx(input, tx, htlc.id, BlockHeight(htlc.cltvExpiry.toLong))) } } @@ -479,7 +495,7 @@ object Transactions { txOut = TxOut(amount, pay2wsh(toLocalDelayed(localRevocationPubkey, toLocalDelay, localDelayedPaymentPubkey))) :: Nil, lockTime = 0 ) - Right(HtlcSuccessTx(input, tx, htlc.paymentHash, htlc.id)) + Right(HtlcSuccessTx(input, tx, htlc.paymentHash, htlc.id, BlockHeight(htlc.cltvExpiry.toLong))) } } @@ -520,23 +536,20 @@ object Transactions { } match { case Some(outputIndex) => val input = InputInfo(OutPoint(commitTx, outputIndex), commitTx.txOut(outputIndex), write(redeemScript)) - val sequence = commitmentFormat match { - case DefaultCommitmentFormat => 0xffffffffL // RBF disabled - case _: AnchorOutputsCommitmentFormat => 1 // txs have a 1-block delay to allow CPFP carve-out on anchors - } + // unsigned tx val tx = Transaction( version = 2, - txIn = TxIn(input.outPoint, ByteVector.empty, sequence) :: Nil, + txIn = TxIn(input.outPoint, ByteVector.empty, getHtlcTxInputSequence(commitmentFormat)) :: Nil, txOut = TxOut(Satoshi(0), localFinalScriptPubKey) :: Nil, lockTime = 0) - val weight = addSigs(ClaimHtlcSuccessTx(input, tx, htlc.paymentHash, htlc.id), PlaceHolderSig, ByteVector32.Zeroes).tx.weight() + val weight = addSigs(ClaimHtlcSuccessTx(input, tx, htlc.paymentHash, htlc.id, BlockHeight(htlc.cltvExpiry.toLong)), PlaceHolderSig, ByteVector32.Zeroes).tx.weight() val fee = weight2fee(feeratePerKw, weight) val amount = input.txOut.amount - fee if (amount < localDustLimit) { Left(AmountBelowDustLimit) } else { val tx1 = tx.copy(txOut = tx.txOut.head.copy(amount = amount) :: Nil) - Right(ClaimHtlcSuccessTx(input, tx1, htlc.paymentHash, htlc.id)) + Right(ClaimHtlcSuccessTx(input, tx1, htlc.paymentHash, htlc.id, BlockHeight(htlc.cltvExpiry.toLong))) } case None => Left(OutputNotFound) } @@ -564,14 +577,14 @@ object Transactions { txIn = TxIn(input.outPoint, ByteVector.empty, getHtlcTxInputSequence(commitmentFormat)) :: Nil, txOut = TxOut(Satoshi(0), localFinalScriptPubKey) :: Nil, lockTime = htlc.cltvExpiry.toLong) - val weight = addSigs(ClaimHtlcTimeoutTx(input, tx, htlc.id), PlaceHolderSig).tx.weight() + val weight = addSigs(ClaimHtlcTimeoutTx(input, tx, htlc.id, BlockHeight(htlc.cltvExpiry.toLong)), PlaceHolderSig).tx.weight() val fee = weight2fee(feeratePerKw, weight) val amount = input.txOut.amount - fee if (amount < localDustLimit) { Left(AmountBelowDustLimit) } else { val tx1 = tx.copy(txOut = tx.txOut.head.copy(amount = amount) :: Nil) - Right(ClaimHtlcTimeoutTx(input, tx1, htlc.id)) + Right(ClaimHtlcTimeoutTx(input, tx1, htlc.id, BlockHeight(htlc.cltvExpiry.toLong))) } case None => Left(OutputNotFound) } @@ -684,8 +697,8 @@ object Transactions { } } - def makeClaimLocalAnchorOutputTx(commitTx: Transaction, localFundingPubkey: PublicKey): Either[TxGenerationSkipped, ClaimLocalAnchorOutputTx] = { - makeClaimAnchorOutputTx(commitTx, localFundingPubkey).map { case (input, tx) => ClaimLocalAnchorOutputTx(input, tx) } + def makeClaimLocalAnchorOutputTx(commitTx: Transaction, localFundingPubkey: PublicKey, confirmBefore: BlockHeight): Either[TxGenerationSkipped, ClaimLocalAnchorOutputTx] = { + makeClaimAnchorOutputTx(commitTx, localFundingPubkey).map { case (input, tx) => ClaimLocalAnchorOutputTx(input, tx, confirmBefore) } } def makeClaimRemoteAnchorOutputTx(commitTx: Transaction, remoteFundingPubkey: PublicKey): Either[TxGenerationSkipped, ClaimRemoteAnchorOutputTx] = { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala index e5ce53cd53..f8b35db9f8 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelCodecs0.scala @@ -18,7 +18,6 @@ package fr.acinq.eclair.wire.internal.channel.version0 import fr.acinq.bitcoin.DeterministicWallet.{ExtendedPrivateKey, KeyPath} import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, OutPoint, Transaction, TxOut} -import fr.acinq.eclair.TimestampSecond import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.Transactions._ @@ -27,6 +26,7 @@ import fr.acinq.eclair.wire.internal.channel.version0.ChannelTypes0.{HtlcTxAndSi import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.wire.protocol.LightningMessageCodecs.{channelAnnouncementCodec, channelUpdateCodec, combinedFeaturesCodec} import fr.acinq.eclair.wire.protocol._ +import fr.acinq.eclair.{BlockHeight, TimestampSecond} import scodec.Codec import scodec.bits.{BitVector, ByteVector} import scodec.codecs._ @@ -130,14 +130,17 @@ private[channel] object ChannelCodecs0 { ("txOut" | txOutCodec) :: ("redeemScript" | varsizebinarydata)).as[InputInfo].decodeOnly - // NB: we can safely set htlcId = 0 for htlc txs. This information is only used to find upstream htlcs to fail when a + // We can safely set htlcId = 0 for htlc txs. This information is only used to find upstream htlcs to fail when a // downstream htlc times out, and `Helpers.Closing.timedOutHtlcs` explicitly handles the case where htlcId is missing. + // We can also safely set confirmBefore = 0: we will simply use a high feerate to make these transactions confirm + // as quickly as possible. It's very unlikely that nodes will run into this, so it's a good trade-off between code + // complexity and real world impact. val txWithInputInfoCodec: Codec[TransactionWithInputInfo] = discriminated[TransactionWithInputInfo].by(uint16) .typecase(0x01, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[CommitTx]) - .typecase(0x02, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | provide(0L))).as[HtlcSuccessTx]) - .typecase(0x03, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[HtlcTimeoutTx]) - .typecase(0x04, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[LegacyClaimHtlcSuccessTx]) - .typecase(0x05, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[ClaimHtlcTimeoutTx]) + .typecase(0x02, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcSuccessTx]) + .typecase(0x03, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcTimeoutTx]) + .typecase(0x04, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[LegacyClaimHtlcSuccessTx]) + .typecase(0x05, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimHtlcTimeoutTx]) .typecase(0x06, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimP2WPKHOutputTx]) .typecase(0x07, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalDelayedOutputTx]) .typecase(0x08, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[MainPenaltyTx]) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala index b7d193c053..f136208bb3 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version0/ChannelTypes0.scala @@ -23,7 +23,7 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.CommitmentSpec import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.{Feature, Features, channel} +import fr.acinq.eclair.{BlockHeight, Feature, Features, channel} import scodec.bits.BitVector private[channel] object ChannelTypes0 { @@ -57,8 +57,8 @@ private[channel] object ChannelTypes0 { // the channel will put a watch at start-up which will make us fetch the spending transaction. val irrevocablySpentNew = irrevocablySpent.collect { case (outpoint, txid) if knownTxs.contains(txid) => (outpoint, knownTxs(txid)) } val claimMainDelayedOutputTxNew = claimMainDelayedOutputTx.map(tx => ClaimLocalDelayedOutputTx(getPartialInputInfo(commitTx, tx), tx)) - val htlcSuccessTxsNew = htlcSuccessTxs.map(tx => HtlcSuccessTx(getPartialInputInfo(commitTx, tx), tx, ByteVector32.Zeroes, 0)) - val htlcTimeoutTxsNew = htlcTimeoutTxs.map(tx => HtlcTimeoutTx(getPartialInputInfo(commitTx, tx), tx, 0)) + val htlcSuccessTxsNew = htlcSuccessTxs.map(tx => HtlcSuccessTx(getPartialInputInfo(commitTx, tx), tx, ByteVector32.Zeroes, 0, BlockHeight(0))) + val htlcTimeoutTxsNew = htlcTimeoutTxs.map(tx => HtlcTimeoutTx(getPartialInputInfo(commitTx, tx), tx, 0, BlockHeight(0))) val htlcTxsNew = (htlcSuccessTxsNew ++ htlcTimeoutTxsNew).map(tx => tx.input.outPoint -> Some(tx)).toMap val claimHtlcDelayedTxsNew = claimHtlcDelayedTxs.map(tx => { val htlcTx = htlcTxs.find(_.txid == tx.txIn.head.outPoint.txid) @@ -77,8 +77,8 @@ private[channel] object ChannelTypes0 { // the channel will put a watch at start-up which will make us fetch the spending transaction. val irrevocablySpentNew = irrevocablySpent.collect { case (outpoint, txid) if knownTxs.contains(txid) => (outpoint, knownTxs(txid)) } val claimMainOutputTxNew = claimMainOutputTx.map(tx => ClaimP2WPKHOutputTx(getPartialInputInfo(commitTx, tx), tx)) - val claimHtlcSuccessTxsNew = claimHtlcSuccessTxs.map(tx => LegacyClaimHtlcSuccessTx(getPartialInputInfo(commitTx, tx), tx, 0)) - val claimHtlcTimeoutTxsNew = claimHtlcTimeoutTxs.map(tx => ClaimHtlcTimeoutTx(getPartialInputInfo(commitTx, tx), tx, 0)) + val claimHtlcSuccessTxsNew = claimHtlcSuccessTxs.map(tx => LegacyClaimHtlcSuccessTx(getPartialInputInfo(commitTx, tx), tx, 0, BlockHeight(0))) + val claimHtlcTimeoutTxsNew = claimHtlcTimeoutTxs.map(tx => ClaimHtlcTimeoutTx(getPartialInputInfo(commitTx, tx), tx, 0, BlockHeight(0))) val claimHtlcTxsNew = (claimHtlcSuccessTxsNew ++ claimHtlcTimeoutTxsNew).map(tx => tx.input.outPoint -> Some(tx)).toMap channel.RemoteCommitPublished(commitTx, claimMainOutputTxNew, claimHtlcTxsNew, Nil, irrevocablySpentNew) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala index 42415f02ad..4d4af56fed 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/internal/channel/version1/ChannelCodecs1.scala @@ -18,6 +18,7 @@ package fr.acinq.eclair.wire.internal.channel.version1 import fr.acinq.bitcoin.DeterministicWallet.{ExtendedPrivateKey, KeyPath} import fr.acinq.bitcoin.{ByteVector32, OutPoint, Transaction, TxOut} +import fr.acinq.eclair.BlockHeight import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.Transactions._ @@ -108,10 +109,10 @@ private[channel] object ChannelCodecs1 { // downstream htlc times out, and `Helpers.Closing.timedOutHtlcs` explicitly handles the case where htlcId is missing. val txWithInputInfoCodec: Codec[TransactionWithInputInfo] = discriminated[TransactionWithInputInfo].by(uint16) .typecase(0x01, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[CommitTx]) - .typecase(0x02, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | provide(0L))).as[HtlcSuccessTx]) - .typecase(0x03, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[HtlcTimeoutTx]) - .typecase(0x04, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[LegacyClaimHtlcSuccessTx]) - .typecase(0x05, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L))).as[ClaimHtlcTimeoutTx]) + .typecase(0x02, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcSuccessTx]) + .typecase(0x03, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcTimeoutTx]) + .typecase(0x04, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[LegacyClaimHtlcSuccessTx]) + .typecase(0x05, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | provide(0L)) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimHtlcTimeoutTx]) .typecase(0x06, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimP2WPKHOutputTx]) .typecase(0x07, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalDelayedOutputTx]) .typecase(0x08, (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[MainPenaltyTx]) 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 d83dea764d..c37b8cd73b 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 @@ -18,6 +18,7 @@ package fr.acinq.eclair.wire.internal.channel.version2 import fr.acinq.bitcoin.DeterministicWallet.{ExtendedPrivateKey, KeyPath} import fr.acinq.bitcoin.{OutPoint, Transaction, TxOut} +import fr.acinq.eclair.BlockHeight import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.Transactions._ @@ -105,18 +106,18 @@ private[channel] object ChannelCodecs2 { ("scriptPubKey" | lengthDelimited(bytes))).as[OutputInfo] val commitTxCodec: Codec[CommitTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[CommitTx] - val htlcSuccessTxCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow)).as[HtlcSuccessTx] - val htlcTimeoutTxCodec: Codec[HtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[HtlcTimeoutTx] + val htlcSuccessTxCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcSuccessTx] + val htlcTimeoutTxCodec: Codec[HtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcTimeoutTx] val htlcDelayedTxCodec: Codec[HtlcDelayedTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[HtlcDelayedTx] - val claimHtlcSuccessTxCodec: Codec[LegacyClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[LegacyClaimHtlcSuccessTx] - val claimHtlcTimeoutTxCodec: Codec[ClaimHtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[ClaimHtlcTimeoutTx] + val claimHtlcSuccessTxCodec: Codec[LegacyClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[LegacyClaimHtlcSuccessTx] + val claimHtlcTimeoutTxCodec: Codec[ClaimHtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimHtlcTimeoutTx] val claimLocalDelayedOutputTxCodec: Codec[ClaimLocalDelayedOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalDelayedOutputTx] val claimP2WPKHOutputTxCodec: Codec[ClaimP2WPKHOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimP2WPKHOutputTx] val claimRemoteDelayedOutputTxCodec: Codec[ClaimRemoteDelayedOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimRemoteDelayedOutputTx] val mainPenaltyTxCodec: Codec[MainPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[MainPenaltyTx] val htlcPenaltyTxCodec: Codec[HtlcPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[HtlcPenaltyTx] val claimHtlcDelayedOutputPenaltyTxCodec: Codec[ClaimHtlcDelayedOutputPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimHtlcDelayedOutputPenaltyTx] - val claimLocalAnchorOutputTxCodec: Codec[ClaimLocalAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalAnchorOutputTx] + val claimLocalAnchorOutputTxCodec: Codec[ClaimLocalAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimLocalAnchorOutputTx] val claimRemoteAnchorOutputTxCodec: Codec[ClaimRemoteAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimRemoteAnchorOutputTx] val closingTxCodec: Codec[ClosingTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("outputIndex" | optional(bool8, outputInfoCodec))).as[ClosingTx] 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 e833daeb01..4a3b5d9bb5 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 @@ -17,7 +17,7 @@ package fr.acinq.eclair.wire.internal.channel.version3 import fr.acinq.bitcoin.DeterministicWallet.{ExtendedPrivateKey, KeyPath} -import fr.acinq.bitcoin.{ByteVector32, OutPoint, Transaction, TxOut} +import fr.acinq.bitcoin.{OutPoint, Transaction, TxOut} import fr.acinq.eclair.channel._ import fr.acinq.eclair.crypto.ShaChain import fr.acinq.eclair.transactions.Transactions._ @@ -25,7 +25,7 @@ import fr.acinq.eclair.transactions.{CommitmentSpec, DirectedHtlc, IncomingHtlc, import fr.acinq.eclair.wire.protocol.CommonCodecs._ import fr.acinq.eclair.wire.protocol.LightningMessageCodecs._ import fr.acinq.eclair.wire.protocol.UpdateMessage -import fr.acinq.eclair.{FeatureSupport, Features} +import fr.acinq.eclair.{BlockHeight, FeatureSupport, Features} import scodec.bits.{BitVector, ByteVector} import scodec.codecs._ import scodec.{Attempt, Codec} @@ -125,56 +125,75 @@ private[channel] object ChannelCodecs3 { ("scriptPubKey" | lengthDelimited(bytes))).as[OutputInfo] val commitTxCodec: Codec[CommitTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[CommitTx] - val htlcSuccessTxCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow)).as[HtlcSuccessTx] - val htlcTimeoutTxCodec: Codec[HtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[HtlcTimeoutTx] + val htlcSuccessTxCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | blockHeight)).as[HtlcSuccessTx] + val htlcTimeoutTxCodec: Codec[HtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | blockHeight)).as[HtlcTimeoutTx] + private val htlcSuccessTxNoConfirmCodec: Codec[HtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcSuccessTx] + private val htlcTimeoutTxNoConfirmCodec: Codec[HtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[HtlcTimeoutTx] val htlcDelayedTxCodec: Codec[HtlcDelayedTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[HtlcDelayedTx] - private val legacyClaimHtlcSuccessTxCodec: Codec[LegacyClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[LegacyClaimHtlcSuccessTx] - val claimHtlcSuccessTxCodec: Codec[ClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow)).as[ClaimHtlcSuccessTx] - val claimHtlcTimeoutTxCodec: Codec[ClaimHtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow)).as[ClaimHtlcTimeoutTx] + private val legacyClaimHtlcSuccessTxCodec: Codec[LegacyClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[LegacyClaimHtlcSuccessTx] + val claimHtlcSuccessTxCodec: Codec[ClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | blockHeight)).as[ClaimHtlcSuccessTx] + val claimHtlcTimeoutTxCodec: Codec[ClaimHtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | blockHeight)).as[ClaimHtlcTimeoutTx] + private val claimHtlcSuccessTxNoConfirmCodec: Codec[ClaimHtlcSuccessTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("paymentHash" | bytes32) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimHtlcSuccessTx] + private val claimHtlcTimeoutTxNoConfirmCodec: Codec[ClaimHtlcTimeoutTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("htlcId" | uint64overflow) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimHtlcTimeoutTx] val claimLocalDelayedOutputTxCodec: Codec[ClaimLocalDelayedOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalDelayedOutputTx] val claimP2WPKHOutputTxCodec: Codec[ClaimP2WPKHOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimP2WPKHOutputTx] val claimRemoteDelayedOutputTxCodec: Codec[ClaimRemoteDelayedOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimRemoteDelayedOutputTx] val mainPenaltyTxCodec: Codec[MainPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[MainPenaltyTx] val htlcPenaltyTxCodec: Codec[HtlcPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[HtlcPenaltyTx] val claimHtlcDelayedOutputPenaltyTxCodec: Codec[ClaimHtlcDelayedOutputPenaltyTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimHtlcDelayedOutputPenaltyTx] - val claimLocalAnchorOutputTxCodec: Codec[ClaimLocalAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimLocalAnchorOutputTx] + val claimLocalAnchorOutputTxCodec: Codec[ClaimLocalAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmBefore" | blockHeight)).as[ClaimLocalAnchorOutputTx] + private val claimLocalAnchorOutputTxNoConfirmCodec: Codec[ClaimLocalAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec) :: ("confirmBefore" | provide(BlockHeight(0)))).as[ClaimLocalAnchorOutputTx] val claimRemoteAnchorOutputTxCodec: Codec[ClaimRemoteAnchorOutputTx] = (("inputInfo" | inputInfoCodec) :: ("tx" | txCodec)).as[ClaimRemoteAnchorOutputTx] 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(0x21, htlcSuccessTxCodec) + .typecase(0x22, htlcTimeoutTxCodec) + .typecase(0x23, claimHtlcSuccessTxCodec) + .typecase(0x24, claimHtlcTimeoutTxCodec) .typecase(0x01, commitTxCodec) - .typecase(0x02, htlcSuccessTxCodec) - .typecase(0x03, htlcTimeoutTxCodec) + .typecase(0x02, htlcSuccessTxNoConfirmCodec) + .typecase(0x03, htlcTimeoutTxNoConfirmCodec) .typecase(0x04, legacyClaimHtlcSuccessTxCodec) - .typecase(0x05, claimHtlcTimeoutTxCodec) + .typecase(0x05, claimHtlcTimeoutTxNoConfirmCodec) .typecase(0x06, claimP2WPKHOutputTxCodec) .typecase(0x07, claimLocalDelayedOutputTxCodec) .typecase(0x08, mainPenaltyTxCodec) .typecase(0x09, htlcPenaltyTxCodec) .typecase(0x10, closingTxCodec) - .typecase(0x11, claimLocalAnchorOutputTxCodec) + .typecase(0x11, claimLocalAnchorOutputTxNoConfirmCodec) .typecase(0x12, claimRemoteAnchorOutputTxCodec) .typecase(0x13, claimRemoteDelayedOutputTxCodec) .typecase(0x14, claimHtlcDelayedOutputPenaltyTxCodec) .typecase(0x15, htlcDelayedTxCodec) - .typecase(0x16, claimHtlcSuccessTxCodec) + .typecase(0x16, claimHtlcSuccessTxNoConfirmCodec) val claimRemoteCommitMainOutputTxCodec: Codec[ClaimRemoteCommitMainOutputTx] = discriminated[ClaimRemoteCommitMainOutputTx].by(uint8) .typecase(0x01, claimP2WPKHOutputTxCodec) .typecase(0x02, claimRemoteDelayedOutputTxCodec) val claimAnchorOutputTxCodec: Codec[ClaimAnchorOutputTx] = discriminated[ClaimAnchorOutputTx].by(uint8) - .typecase(0x01, claimLocalAnchorOutputTxCodec) + // Important: order matters! + .typecase(0x11, claimLocalAnchorOutputTxCodec) + .typecase(0x01, claimLocalAnchorOutputTxNoConfirmCodec) .typecase(0x02, claimRemoteAnchorOutputTxCodec) val htlcTxCodec: Codec[HtlcTx] = discriminated[HtlcTx].by(uint8) - .typecase(0x01, htlcSuccessTxCodec) - .typecase(0x02, htlcTimeoutTxCodec) + // Important: order matters! + .typecase(0x11, htlcSuccessTxCodec) + .typecase(0x12, htlcTimeoutTxCodec) + .typecase(0x01, htlcSuccessTxNoConfirmCodec) + .typecase(0x02, htlcTimeoutTxNoConfirmCodec) val claimHtlcTxCodec: Codec[ClaimHtlcTx] = discriminated[ClaimHtlcTx].by(uint8) + // Important: order matters! + .typecase(0x22, claimHtlcTimeoutTxCodec) + .typecase(0x23, claimHtlcSuccessTxCodec) .typecase(0x01, legacyClaimHtlcSuccessTxCodec) - .typecase(0x02, claimHtlcTimeoutTxCodec) - .typecase(0x03, claimHtlcSuccessTxCodec) + .typecase(0x02, claimHtlcTimeoutTxNoConfirmCodec) + .typecase(0x03, claimHtlcSuccessTxNoConfirmCodec) val htlcTxsAndRemoteSigsCodec: Codec[HtlcTxAndRemoteSig] = ( ("txinfo" | htlcTxCodec) :: diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala index 973367bb92..047c0307e1 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/wire/protocol/CommonCodecs.scala @@ -20,7 +20,7 @@ import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Satoshi} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.crypto.Mac32 -import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, MilliSatoshi, ShortChannelId, TimestampSecond, UInt64} +import fr.acinq.eclair.{BlockHeight, CltvExpiry, CltvExpiryDelta, MilliSatoshi, ShortChannelId, TimestampSecond, UInt64} import org.apache.commons.codec.binary.Base32 import scodec.bits.{BitVector, ByteVector} import scodec.codecs._ @@ -62,8 +62,9 @@ object CommonCodecs { val feeratePerKw: Codec[FeeratePerKw] = uint32.xmapc(l => FeeratePerKw(Satoshi(l)))(_.toLong) - val cltvExpiry: Codec[CltvExpiry] = uint32.xmapc(CltvExpiry)((_: CltvExpiry).toLong) - val cltvExpiryDelta: Codec[CltvExpiryDelta] = uint16.xmapc(CltvExpiryDelta)((_: CltvExpiryDelta).toInt) + val blockHeight: Codec[BlockHeight] = uint32.xmapc(BlockHeight)(_.toLong) + val cltvExpiry: Codec[CltvExpiry] = uint32.xmapc(CltvExpiry)(_.toLong) + val cltvExpiryDelta: Codec[CltvExpiryDelta] = uint16.xmapc(CltvExpiryDelta)(_.toInt) // this is needed because some millisatoshi values are encoded on 32 bits in the BOLTs // this codec will fail if the amount does not fit on 32 bits diff --git a/eclair-core/src/test/resources/logback-test.xml b/eclair-core/src/test/resources/logback-test.xml index 02005a0eea..21441ee3ad 100644 --- a/eclair-core/src/test/resources/logback-test.xml +++ b/eclair-core/src/test/resources/logback-test.xml @@ -53,6 +53,7 @@ + diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala index 3fa2155fbf..e26c650e74 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/StartupSpec.scala @@ -39,7 +39,7 @@ class StartupSpec extends AnyFunSuite { val blockCount = new AtomicLong(0) val nodeKeyManager = new LocalNodeKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) val channelKeyManager = new LocalChannelKeyManager(randomBytes32(), chainHash = Block.TestnetGenesisBlock.hash) - val feeEstimator = new TestConstants.TestFeeEstimator + val feeEstimator = new TestFeeEstimator() val db = TestDatabases.inMemoryDb() NodeParams.makeNodeParams(conf, UUID.fromString("01234567-0123-4567-89ab-0123456789ab"), nodeKeyManager, channelKeyManager, None, db, blockCount, feeEstimator) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala index 0dca0ae088..29d112d22e 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestConstants.scala @@ -50,20 +50,6 @@ object TestConstants { val anchorOutputsFeeratePerKw: FeeratePerKw = FeeratePerKw(2500 sat) val emptyOnionPacket: OnionRoutingPacket = OnionRoutingPacket(0, ByteVector.fill(33)(0), ByteVector.fill(1300)(0), ByteVector32.Zeroes) - class TestFeeEstimator extends FeeEstimator { - private var currentFeerates = FeeratesPerKw.single(feeratePerKw) - - // @formatter:off - override def getFeeratePerKb(target: Int): FeeratePerKB = FeeratePerKB(currentFeerates.feePerBlock(target)) - override def getFeeratePerKw(target: Int): FeeratePerKw = currentFeerates.feePerBlock(target) - override def getMempoolMinFeeratePerKw(): FeeratePerKw = currentFeerates.mempoolMinFee - // @formatter:on - - def setFeerate(feeratesPerKw: FeeratesPerKw): Unit = { - currentFeerates = feeratesPerKw - } - } - case object TestFeature extends Feature with InitFeature with NodeFeature { val rfcName = "test_feature" val mandatory = 50000 @@ -84,7 +70,6 @@ object TestConstants { "mempool.space" ) - object Alice { val seed: ByteVector32 = ByteVector32(ByteVector.fill(32)(1)) val nodeKeyManager = new LocalNodeKeyManager(seed, Block.RegtestGenesisBlock.hash) @@ -117,7 +102,7 @@ object TestConstants { dustLimit = 1100 sat, maxRemoteDustLimit = 1500 sat, onChainFeeConf = OnChainFeeConf( - feeTargets = FeeTargets(6, 2, 2, 6), + feeTargets = FeeTargets(6, 2, 36, 12, 18), feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, @@ -250,7 +235,7 @@ object TestConstants { dustLimit = 1000 sat, maxRemoteDustLimit = 1500 sat, onChainFeeConf = OnChainFeeConf( - feeTargets = FeeTargets(6, 2, 2, 6), + feeTargets = FeeTargets(6, 2, 36, 12, 18), feeEstimator = new TestFeeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/TestFeeEstimator.scala b/eclair-core/src/test/scala/fr/acinq/eclair/TestFeeEstimator.scala new file mode 100644 index 0000000000..ac33b31650 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/TestFeeEstimator.scala @@ -0,0 +1,47 @@ +/* + * Copyright 2019 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 + +import fr.acinq.eclair.TestConstants.feeratePerKw +import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeratePerKB, FeeratePerKw, FeeratesPerKw} + +class TestFeeEstimator extends FeeEstimator { + private var currentFeerates = FeeratesPerKw.single(feeratePerKw) + + // @formatter:off + override def getFeeratePerKb(target: Int): FeeratePerKB = FeeratePerKB(currentFeerates.feePerBlock(target)) + override def getFeeratePerKw(target: Int): FeeratePerKw = currentFeerates.feePerBlock(target) + override def getMempoolMinFeeratePerKw(): FeeratePerKw = currentFeerates.mempoolMinFee + // @formatter:on + + def setFeerate(target: Int, feerate: FeeratePerKw): Unit = { + target match { + case 1 => currentFeerates = currentFeerates.copy(block_1 = feerate) + case 2 => currentFeerates = currentFeerates.copy(blocks_2 = feerate) + case t if t <= 6 => currentFeerates = currentFeerates.copy(blocks_6 = feerate) + case t if t <= 12 => currentFeerates = currentFeerates.copy(blocks_12 = feerate) + case t if t <= 36 => currentFeerates = currentFeerates.copy(blocks_36 = feerate) + case t if t <= 72 => currentFeerates = currentFeerates.copy(blocks_72 = feerate) + case t if t <= 144 => currentFeerates = currentFeerates.copy(blocks_144 = feerate) + case _ => currentFeerates = currentFeerates.copy(blocks_1008 = feerate) + } + } + + def setFeerate(feeratesPerKw: FeeratesPerKw): Unit = { + currentFeerates = feeratesPerKw + } +} diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala index 571f262704..13bccc9b11 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/fee/FeeEstimatorSpec.scala @@ -17,10 +17,9 @@ package fr.acinq.eclair.blockchain.fee import fr.acinq.bitcoin.SatoshiLong -import fr.acinq.eclair.TestConstants.TestFeeEstimator import fr.acinq.eclair.blockchain.CurrentFeerates import fr.acinq.eclair.channel.ChannelTypes -import fr.acinq.eclair.randomKey +import fr.acinq.eclair.{TestFeeEstimator, randomKey} import org.scalatest.funsuite.AnyFunSuite class FeeEstimatorSpec extends AnyFunSuite { @@ -28,7 +27,7 @@ class FeeEstimatorSpec extends AnyFunSuite { val defaultFeerateTolerance = FeerateTolerance(0.5, 2.0, FeeratePerKw(2500 sat), DustTolerance(15000 sat, closeOnUpdateFeeOverflow = false)) test("should update fee when diff ratio exceeded") { - val feeConf = OnChainFeeConf(FeeTargets(1, 1, 1, 1), new TestFeeEstimator(), closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) + val feeConf = OnChainFeeConf(FeeTargets(1, 1, 1, 1, 1), new TestFeeEstimator(), closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1000 sat))) assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(900 sat))) assert(!feeConf.shouldUpdateFee(FeeratePerKw(1000 sat), FeeratePerKw(1100 sat))) @@ -39,7 +38,7 @@ class FeeEstimatorSpec extends AnyFunSuite { test("get commitment feerate") { val feeEstimator = new TestFeeEstimator() val channelType = ChannelTypes.Standard - val feeConf = OnChainFeeConf(FeeTargets(1, 2, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) + val feeConf = OnChainFeeConf(FeeTargets(1, 2, 6, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map.empty) feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(blocks_2 = FeeratePerKw(5000 sat))) assert(feeConf.getCommitmentFeerate(randomKey().publicKey, channelType, 100000 sat, None) === FeeratePerKw(5000 sat)) @@ -54,7 +53,7 @@ class FeeEstimatorSpec extends AnyFunSuite { val defaultMaxCommitFeerate = defaultFeerateTolerance.anchorOutputMaxCommitFeerate val overrideNodeId = randomKey().publicKey val overrideMaxCommitFeerate = defaultMaxCommitFeerate * 2 - val feeConf = OnChainFeeConf(FeeTargets(1, 2, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate))) + val feeConf = OnChainFeeConf(FeeTargets(1, 2, 6, 1, 1), feeEstimator, closeOnOfflineMismatch = true, updateFeeMinDiffRatio = 0.1, defaultFeerateTolerance, Map(overrideNodeId -> defaultFeerateTolerance.copy(anchorOutputMaxCommitFeerate = overrideMaxCommitFeerate))) feeEstimator.setFeerate(FeeratesPerKw.single(FeeratePerKw(10000 sat)).copy(blocks_2 = defaultMaxCommitFeerate / 2, mempoolMinFee = FeeratePerKw(250 sat))) assert(feeConf.getCommitmentFeerate(defaultNodeId, ChannelTypes.AnchorOutputs, 100000 sat, None) === defaultMaxCommitFeerate / 2) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala index 781337e2ce..5c49bae4f7 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/CommitmentsSpec.scala @@ -18,7 +18,6 @@ package fr.acinq.eclair.channel import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{ByteVector64, DeterministicWallet, Satoshi, SatoshiLong, Transaction} -import fr.acinq.eclair.TestConstants.TestFeeEstimator import fr.acinq.eclair.blockchain.fee._ import fr.acinq.eclair.channel.Commitments._ import fr.acinq.eclair.channel.Helpers.Funding @@ -42,7 +41,7 @@ class CommitmentsSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with implicit val log: akka.event.LoggingAdapter = akka.event.NoLogging val feeConfNoMismatch = OnChainFeeConf( - FeeTargets(6, 2, 2, 6), + FeeTargets(6, 2, 12, 2, 6), new TestFeeEstimator(), closeOnOfflineMismatch = false, 1.0, diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala index d3da3d6c6f..c00a71dfc5 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/HelpersSpec.scala @@ -17,6 +17,7 @@ package fr.acinq.eclair.channel import akka.testkit.{TestFSMRef, TestProbe} +import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin._ import fr.acinq.eclair.TestConstants.Alice.nodeParams import fr.acinq.eclair.TestUtils.NoLoggingDiagnostics @@ -180,15 +181,18 @@ class HelpersSpec extends TestKitBaseClass with AnyFunSuiteLike with ChannelStat val localCommit = alice.stateData.asInstanceOf[DATA_CLOSING].commitments.localCommit val remoteCommit = bob.stateData.asInstanceOf[DATA_CLOSING].commitments.remoteCommit - // Channels without anchor outputs that were closing before eclair v0.5.2 will not have their htlcId set after the + // Channels without anchor outputs that were closing before eclair v0.6.0 will not have their htlcId set after the // update, but still need to be able to identify timed out htlcs. val localCommitPublished = if (withoutHtlcId) aliceCommitPublished.copy(htlcTxs = removeHtlcIds(aliceCommitPublished.htlcTxs)) else aliceCommitPublished val remoteCommitPublished = if (withoutHtlcId) bobCommitPublished.copy(claimHtlcTxs = removeClaimHtlcIds(bobCommitPublished.claimHtlcTxs)) else bobCommitPublished val htlcTimeoutTxs = getHtlcTimeoutTxs(localCommitPublished) val htlcSuccessTxs = getHtlcSuccessTxs(localCommitPublished) + // Claim-HTLC txs can be modified to pay more (or less) fees by changing the output amount. val claimHtlcTimeoutTxs = getClaimHtlcTimeoutTxs(remoteCommitPublished) + val claimHtlcTimeoutTxsModifiedFees = claimHtlcTimeoutTxs.map(tx => tx.modify(_.tx.txOut).setTo(Seq(tx.tx.txOut.head.copy(amount = 5000 sat)))) val claimHtlcSuccessTxs = getClaimHtlcSuccessTxs(remoteCommitPublished) + val claimHtlcSuccessTxsModifiedFees = claimHtlcSuccessTxs.map(tx => tx.modify(_.tx.txOut).setTo(Seq(tx.tx.txOut.head.copy(amount = 5000 sat)))) val aliceTimedOutHtlcs = htlcTimeoutTxs.map(htlcTimeout => { val timedOutHtlcs = Closing.trimmedOrTimedOutHtlcs(commitmentFormat, localCommit, localCommitPublished, dustLimit, htlcTimeout.tx) @@ -204,10 +208,19 @@ class HelpersSpec extends TestKitBaseClass with AnyFunSuiteLike with ChannelStat }) assert(bobTimedOutHtlcs.toSet === bobHtlcs) + val bobTimedOutHtlcs2 = claimHtlcTimeoutTxsModifiedFees.map(claimHtlcTimeout => { + val timedOutHtlcs = Closing.trimmedOrTimedOutHtlcs(commitmentFormat, remoteCommit, remoteCommitPublished, dustLimit, claimHtlcTimeout.tx) + assert(timedOutHtlcs.size === 1) + timedOutHtlcs.head + }) + assert(bobTimedOutHtlcs2.toSet === bobHtlcs) + htlcSuccessTxs.foreach(htlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, localCommit, localCommitPublished, dustLimit, htlcSuccess.tx).isEmpty)) htlcSuccessTxs.foreach(htlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, remoteCommit, remoteCommitPublished, dustLimit, htlcSuccess.tx).isEmpty)) claimHtlcSuccessTxs.foreach(claimHtlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, localCommit, localCommitPublished, dustLimit, claimHtlcSuccess.tx).isEmpty)) + claimHtlcSuccessTxsModifiedFees.foreach(claimHtlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, localCommit, localCommitPublished, dustLimit, claimHtlcSuccess.tx).isEmpty)) claimHtlcSuccessTxs.foreach(claimHtlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, remoteCommit, remoteCommitPublished, dustLimit, claimHtlcSuccess.tx).isEmpty)) + claimHtlcSuccessTxsModifiedFees.foreach(claimHtlcSuccess => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, remoteCommit, remoteCommitPublished, dustLimit, claimHtlcSuccess.tx).isEmpty)) htlcTimeoutTxs.foreach(htlcTimeout => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, remoteCommit, remoteCommitPublished, dustLimit, htlcTimeout.tx).isEmpty)) claimHtlcTimeoutTxs.foreach(claimHtlcTimeout => assert(Closing.trimmedOrTimedOutHtlcs(commitmentFormat, localCommit, localCommitPublished, dustLimit, claimHtlcTimeout.tx).isEmpty)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala index 33cce3ca04..16c9044289 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/MempoolTxMonitorSpec.scala @@ -26,7 +26,7 @@ import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.WatcherSpec.{createSpendManyP2WPKH, createSpendP2WPKH} import fr.acinq.eclair.blockchain.bitcoind.BitcoindService import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient -import fr.acinq.eclair.channel.publish.MempoolTxMonitor.{Publish, Stop, TxConfirmed, TxRejected} +import fr.acinq.eclair.channel.publish.MempoolTxMonitor._ import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ import fr.acinq.eclair.channel.{TransactionConfirmed, TransactionPublished} @@ -81,14 +81,20 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi monitor ! Publish(probe.ref, tx, tx.txIn.head.outPoint, "test-tx", 50 sat) waitTxInMempool(bitcoinClient, tx.txid, probe) + // NB: we don't really generate a block, we're testing the case where the tx is still in the mempool. + system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) + probe.expectMsg(TxInMempool(tx.txid, currentBlockHeight(probe))) + probe.expectNoMessage(100 millis) + assert(TestConstants.Alice.nodeParams.minDepthBlocks > 1) generateBlocks(1) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) + probe.expectMsg(TxRecentlyConfirmed(tx.txid, 1)) probe.expectNoMessage(100 millis) // we wait for more than one confirmation to protect against reorgs generateBlocks(TestConstants.Alice.nodeParams.minDepthBlocks - 1) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxConfirmed) + probe.expectMsg(TxDeeplyBuried(tx)) } test("transaction confirmed after replacing existing mempool transaction") { @@ -105,7 +111,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi generateBlocks(TestConstants.Alice.nodeParams.minDepthBlocks) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxConfirmed) + probe.expectMsg(TxDeeplyBuried(tx2)) } test("publish failed (conflicting mempool transaction)") { @@ -118,7 +124,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val tx2 = createSpendP2WPKH(parentTx, priv, priv.publicKey, 7_500 sat, 0, 0) monitor ! Publish(probe.ref, tx2, tx2.txIn.head.outPoint, "test-tx", 25 sat) - probe.expectMsg(TxRejected(ConflictingTxUnconfirmed)) + probe.expectMsg(TxRejected(tx2.txid, ConflictingTxUnconfirmed)) } test("publish failed (conflicting confirmed transaction)") { @@ -132,7 +138,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val tx2 = createSpendP2WPKH(parentTx, priv, priv.publicKey, 15_000 sat, 0, 0) monitor ! Publish(probe.ref, tx2, tx2.txIn.head.outPoint, "test-tx", 10 sat) - probe.expectMsg(TxRejected(ConflictingTxConfirmed)) + probe.expectMsg(TxRejected(tx2.txid, ConflictingTxConfirmed)) } test("publish failed (unconfirmed parent, wallet input doesn't exist)") { @@ -142,7 +148,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val tx = createSpendP2WPKH(parentTx, priv, priv.publicKey, 5_000 sat, 0, 0) val txUnknownInput = tx.copy(txIn = tx.txIn ++ Seq(TxIn(OutPoint(randomBytes32(), 13), Nil, 0))) monitor ! Publish(probe.ref, txUnknownInput, txUnknownInput.txIn.head.outPoint, "test-tx", 10 sat) - probe.expectMsg(TxRejected(WalletInputGone)) + probe.expectMsg(TxRejected(txUnknownInput.txid, WalletInputGone)) } test("publish failed (confirmed parent, wallet input doesn't exist)") { @@ -155,7 +161,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val tx = createSpendP2WPKH(parentTx, priv, priv.publicKey, 5_000 sat, 0, 0) val txUnknownInput = tx.copy(txIn = tx.txIn ++ Seq(TxIn(OutPoint(randomBytes32(), 13), Nil, 0))) monitor ! Publish(probe.ref, txUnknownInput, txUnknownInput.txIn.head.outPoint, "test-tx", 10 sat) - probe.expectMsg(TxRejected(WalletInputGone)) + probe.expectMsg(TxRejected(txUnknownInput.txid, WalletInputGone)) } test("publish failed (wallet input spent by conflicting confirmed transaction)") { @@ -170,7 +176,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi val tx = createSpendManyP2WPKH(Seq(parentTx, walletTx), priv, priv.publicKey, 5_000 sat, 0, 0) monitor ! Publish(probe.ref, tx, tx.txIn.head.outPoint, "test-tx", 10 sat) - probe.expectMsg(TxRejected(WalletInputGone)) + probe.expectMsg(TxRejected(tx.txid, WalletInputGone)) } test("publish succeeds then transaction is replaced by an unconfirmed tx") { @@ -187,7 +193,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi // When a new block is found, we detect that the transaction has been replaced. system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxRejected(ConflictingTxUnconfirmed)) + probe.expectMsg(TxRejected(tx1.txid, ConflictingTxUnconfirmed)) } test("publish succeeds then transaction is replaced by a confirmed tx") { @@ -205,7 +211,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi // When a new block is found, we detect that the transaction has been replaced. generateBlocks(1) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxRejected(ConflictingTxConfirmed)) + probe.expectMsg(TxRejected(tx1.txid, ConflictingTxConfirmed)) } test("publish succeeds then wallet input disappears") { @@ -229,7 +235,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi // When a new block is found, we detect that the transaction has been evicted. generateBlocks(1) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) - probe.expectMsg(TxRejected(WalletInputGone)) + probe.expectMsg(TxRejected(tx.txid, WalletInputGone)) } test("emit transaction events") { @@ -256,19 +262,4 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi eventListener.expectMsg(TransactionConfirmed(txPublished.channelId, txPublished.remoteNodeId, tx)) } - test("stop actor before transaction confirms") { - val f = createFixture() - import f._ - - val tx = createSpendP2WPKH(parentTx, priv, priv.publicKey, 1_000 sat, 0, 0) - monitor ! Publish(probe.ref, tx, tx.txIn.head.outPoint, "test-tx", 10 sat) - waitTxInMempool(bitcoinClient, tx.txid, probe) - - probe.watch(monitor.toClassic) - probe.expectNoMessage(100 millis) - - monitor ! Stop - probe.expectTerminated(monitor.toClassic, max = 5 seconds) - } - } 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 new file mode 100644 index 0000000000..d44f10dd28 --- /dev/null +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunderSpec.scala @@ -0,0 +1,320 @@ +/* + * 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.{ByteVector32, Crypto, OutPoint, SatoshiLong, Script, Transaction, TxIn, TxOut} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.channel.Helpers.Funding +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.channel.{CommitTxAndRemoteSig, Commitments, LocalCommit, LocalParams} +import fr.acinq.eclair.transactions.Scripts +import fr.acinq.eclair.transactions.Transactions._ +import fr.acinq.eclair.{BlockHeight, CltvExpiry, TestKitBaseClass, randomBytes32} +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, ClaimLocalAnchorOutputTx) = { + val anchorScript = Scripts.anchor(PlaceHolderPubKey) + val commitInput = Funding.makeFundingInputInfo(randomBytes32(), 1, 500 sat, PlaceHolderPubKey, PlaceHolderPubKey) + val commitTx = Transaction( + 2, + Seq(TxIn(commitInput.outPoint, commitInput.redeemScript, 0, Scripts.witness2of2(PlaceHolderSig, PlaceHolderSig, PlaceHolderPubKey, PlaceHolderPubKey))), + Seq(TxOut(330 sat, Script.pay2wsh(anchorScript))), + 0 + ) + val anchorTx = ClaimLocalAnchorOutputTx( + InputInfo(OutPoint(commitTx, 0), commitTx.txOut.head, anchorScript), + Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 0)), Nil, 0), + BlockHeight(0) + ) + (CommitTx(commitInput, commitTx), anchorTx) + } + + test("adjust anchor tx change amount", Tag("fuzzy")) { + val (commitTx, anchorTx) = createAnchorTx() + val dustLimit = 600 sat + val commitFeerate = FeeratePerKw(2500 sat) + val targetFeerate = FeeratePerKw(10000 sat) + for (_ <- 1 to 100) { + val walletInputsCount = 1 + Random.nextInt(5) + val walletInputs = (1 to walletInputsCount).map(_ => TxIn(OutPoint(randomBytes32(), 0), Nil, 0)) + val amountIn = dustLimit * walletInputsCount + Random.nextInt(25_000_000).sat + val amountOut = dustLimit + Random.nextLong(amountIn.toLong).sat + val unsignedTx = ClaimLocalAnchorWithWitnessData(anchorTx.copy(tx = anchorTx.tx.copy( + txIn = anchorTx.tx.txIn ++ walletInputs, + txOut = TxOut(amountOut, Script.pay2wpkh(PlaceHolderPubKey)) :: Nil, + ))) + val adjustedTx = adjustAnchorOutputChange(unsignedTx, commitTx.tx, amountIn, commitFeerate, targetFeerate, dustLimit) + assert(adjustedTx.txInfo.tx.txIn.size === unsignedTx.txInfo.tx.txIn.size) + assert(adjustedTx.txInfo.tx.txOut.size === 1) + assert(adjustedTx.txInfo.tx.txOut.head.amount >= dustLimit) + if (adjustedTx.txInfo.tx.txOut.head.amount > dustLimit) { + // Simulate tx signing to check final feerate. + val signedTx = { + val anchorSigned = addSigs(adjustedTx.txInfo, PlaceHolderSig) + val signedWalletInputs = anchorSigned.tx.txIn.tail.map(txIn => txIn.copy(witness = Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig))) + anchorSigned.tx.copy(txIn = anchorSigned.tx.txIn.head +: signedWalletInputs) + } + // We want the package anchor tx + commit tx to reach our target feerate, but the commit tx already pays a (smaller) fee + val targetFee = weight2fee(targetFeerate, signedTx.weight() + commitTx.tx.weight()) - weight2fee(commitFeerate, commitTx.tx.weight()) + val actualFee = amountIn - signedTx.txOut.map(_.amount).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee amountIn=$amountIn tx=$signedTx") + } + } + } + + private def createHtlcTxs(): (HtlcSuccessWithWitnessData, HtlcTimeoutWithWitnessData) = { + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage) + val htlcSuccessScript = Scripts.htlcReceived(PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderPubKey, paymentHash, CltvExpiry(0), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + val htlcTimeoutScript = Scripts.htlcOffered(PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderPubKey, randomBytes32(), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + val commitTx = Transaction( + 2, + Seq(TxIn(OutPoint(randomBytes32(), 1), Script.write(Script.pay2wpkh(PlaceHolderPubKey)), 0, Script.witnessPay2wpkh(PlaceHolderPubKey, 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, htlcSuccessScript), + Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), + paymentHash, + 17, + BlockHeight(0) + ), PlaceHolderSig, preimage) + val htlcTimeout = HtlcTimeoutWithWitnessData(HtlcTimeoutTx( + InputInfo(OutPoint(commitTx, 1), commitTx.txOut.last, htlcTimeoutScript), + Transaction(2, Seq(TxIn(OutPoint(commitTx, 1), ByteVector.empty, 0)), Seq(TxOut(4000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), + 12, + BlockHeight(0) + ), PlaceHolderSig) + (htlcSuccess, htlcTimeout) + } + + test("adjust htlc tx change amount", Tag("fuzzy")) { + val dustLimit = 600 sat + val targetFeerate = FeeratePerKw(10000 sat) + val (htlcSuccess, htlcTimeout) = createHtlcTxs() + for (_ <- 1 to 100) { + val walletInputsCount = 1 + Random.nextInt(5) + val walletInputs = (1 to walletInputsCount).map(_ => TxIn(OutPoint(randomBytes32(), 0), Nil, 0)) + val walletAmountIn = dustLimit * walletInputsCount + Random.nextInt(25_000_000).sat + val changeOutput = TxOut(Random.nextLong(walletAmountIn.toLong).sat, Script.pay2wpkh(PlaceHolderPubKey)) + val unsignedHtlcSuccessTx = htlcSuccess.updateTx(htlcSuccess.txInfo.tx.copy( + txIn = htlcSuccess.txInfo.tx.txIn ++ walletInputs, + txOut = htlcSuccess.txInfo.tx.txOut ++ Seq(changeOutput) + )) + val unsignedHtlcTimeoutTx = htlcTimeout.updateTx(htlcTimeout.txInfo.tx.copy( + txIn = htlcTimeout.txInfo.tx.txIn ++ walletInputs, + txOut = htlcTimeout.txInfo.tx.txOut ++ Seq(changeOutput) + )) + for (unsignedTx <- Seq(unsignedHtlcSuccessTx, unsignedHtlcTimeoutTx)) { + val totalAmountIn = unsignedTx.txInfo.input.txOut.amount + walletAmountIn + val adjustedTx = adjustHtlcTxChange(unsignedTx, totalAmountIn, targetFeerate, dustLimit, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + assert(adjustedTx.txInfo.tx.txIn.size === unsignedTx.txInfo.tx.txIn.size) + assert(adjustedTx.txInfo.tx.txOut.size === 1 || adjustedTx.txInfo.tx.txOut.size === 2) + if (adjustedTx.txInfo.tx.txOut.size == 2) { + // Simulate tx signing to check final feerate. + val signedTx = { + val htlcSigned = adjustedTx.txInfo match { + case tx: HtlcSuccessTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, ByteVector32.Zeroes, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + case tx: HtlcTimeoutTx => addSigs(tx, PlaceHolderSig, PlaceHolderSig, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + } + val signedWalletInputs = htlcSigned.tx.txIn.tail.map(txIn => txIn.copy(witness = Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig))) + htlcSigned.tx.copy(txIn = htlcSigned.tx.txIn.head +: signedWalletInputs) + } + val targetFee = weight2fee(targetFeerate, signedTx.weight()) + val actualFee = totalAmountIn - signedTx.txOut.map(_.amount).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee amountIn=$walletAmountIn tx=$signedTx") + } + } + } + } + + private def createClaimHtlcTx(): (ClaimHtlcSuccessWithWitnessData, ClaimHtlcTimeoutWithWitnessData) = { + val preimage = randomBytes32() + val paymentHash = Crypto.sha256(preimage) + val htlcSuccessScript = Scripts.htlcReceived(PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderPubKey, paymentHash, CltvExpiry(0), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + val htlcTimeoutScript = Scripts.htlcOffered(PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderPubKey, randomBytes32(), ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) + val claimHtlcSuccess = ClaimHtlcSuccessWithWitnessData(ClaimHtlcSuccessTx( + InputInfo(OutPoint(ByteVector32.Zeroes, 3), TxOut(5000 sat, Script.pay2wsh(htlcSuccessScript)), htlcSuccessScript), + Transaction(2, Seq(TxIn(OutPoint(ByteVector32.Zeroes, 3), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), + paymentHash, + 5, + BlockHeight(0) + ), preimage) + val claimHtlcTimeout = ClaimHtlcTimeoutWithWitnessData(ClaimHtlcTimeoutTx( + InputInfo(OutPoint(ByteVector32.Zeroes, 7), TxOut(5000 sat, Script.pay2wsh(htlcTimeoutScript)), htlcTimeoutScript), + Transaction(2, Seq(TxIn(OutPoint(ByteVector32.Zeroes, 7), ByteVector.empty, 0)), Seq(TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey))), 0), + 7, + BlockHeight(0) + )) + (claimHtlcSuccess, claimHtlcTimeout) + } + + test("adjust claim htlc tx change amount") { + val dustLimit = 750 sat + val (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) + adjustClaimHtlcTxOutput(claimHtlc, targetFeerate, dustLimit) 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) => addSigs(txInfo, PlaceHolderSig, preimage) + case ClaimHtlcTimeoutWithWitnessData(txInfo) => addSigs(txInfo, PlaceHolderSig) + case _: LegacyClaimHtlcSuccessWithWitnessData => fail("legacy claim htlc success not supported") + } + 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 = ClaimLocalAnchorWithWitnessData(initialAnchorTx).updateTx(initialAnchorTx.tx.copy( + txIn = Seq( + initialAnchorTx.tx.txIn.head, + // The previous funding attempt added two wallet inputs: + TxIn(OutPoint(randomBytes32(), 3), ByteVector.empty, 0, Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig)), + TxIn(OutPoint(randomBytes32(), 1), ByteVector.empty, 0, Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig)) + ), + // And a change output: + txOut = Seq(TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey))) + )) + + val commitments = mock[Commitments] + val localParams = mock[LocalParams] + localParams.dustLimit.returns(1000 sat) + commitments.localParams.returns(localParams) + val localCommit = mock[LocalCommit] + localCommit.commitTxAndRemoteSig.returns(CommitTxAndRemoteSig(commitTx, PlaceHolderSig)) + commitments.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)), FeeratePerKw(5000 sat), commitments) + 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)), FeeratePerKw(6000 sat), commitments) + 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)), FeeratePerKw(10000 sat), commitments) + assert(previousTx === previousAnchorTx) + } + + test("adjust previous htlc transaction outputs", Tag("fuzzy")) { + val commitments = mock[Commitments] + val localParams = mock[LocalParams] + localParams.dustLimit.returns(600 sat) + commitments.localParams.returns(localParams) + val (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(randomBytes32(), 3), ByteVector.empty, 0, Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig)), + TxIn(OutPoint(randomBytes32(), 1), ByteVector.empty, 0, Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig)), + TxIn(OutPoint(randomBytes32(), 5), ByteVector.empty, 0, Script.witnessPay2wpkh(PlaceHolderPubKey, PlaceHolderSig)) + ), + txOut = Seq( + initialHtlcTx.txInfo.tx.txOut.head, + // And one change output: + TxOut(5000 sat, Script.pay2wpkh(PlaceHolderPubKey)) + ) + )) + + // We can handle a small feerate update by lowering the change output. + val TxOutputAdjusted(feerateUpdate1) = adjustPreviousTxOutput(FundedTx(previousTx, 15000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(5000 sat), commitments) + 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)), FeeratePerKw(6000 sat), commitments) + 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)), FeeratePerKw(5000 sat), commitments) + 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(PlaceHolderPubKey)) + ))) + val targetFeerate = FeeratePerKw(2500 sat) + FeeratePerKw(Random.nextInt(20000).sat) + adjustPreviousTxOutput(FundedTx(fuzzyPreviousTx, amountIn, FeeratePerKw(2500 sat)), targetFeerate, commitments) 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 commitments = mock[Commitments] + val localParams = mock[LocalParams] + localParams.dustLimit.returns(500 sat) + commitments.localParams.returns(localParams) + val (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)), targetFeerate, commitments) 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 5dfacbc476..a568efb7dd 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 @@ -20,29 +20,29 @@ import akka.actor.typed.ActorRef import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter} import akka.pattern.pipe import akka.testkit.{TestFSMRef, TestProbe} -import fr.acinq.bitcoin.{BtcAmount, ByteVector32, MilliBtcDouble, OutPoint, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut} +import fr.acinq.bitcoin.{BtcAmount, ByteVector32, MilliBtcDouble, OutPoint, SatoshiLong, Transaction, TxOut} +import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.BitcoindService import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.MempoolTx import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinCoreClient, BitcoinJsonRPCClient} -import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} import fr.acinq.eclair.channel._ -import fr.acinq.eclair.channel.publish.ReplaceableTxPublisher.{Publish, Stop} +import fr.acinq.eclair.channel.publish.ReplaceableTxPublisher.{Publish, Stop, UpdateConfirmationTarget} import fr.acinq.eclair.channel.publish.TxPublisher.TxRejectedReason._ import fr.acinq.eclair.channel.publish.TxPublisher._ import fr.acinq.eclair.channel.states.{ChannelStateTestsHelperMethods, ChannelStateTestsTags} +import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ -import fr.acinq.eclair.transactions.{Scripts, Transactions} -import fr.acinq.eclair.{MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey} +import fr.acinq.eclair.{BlockHeight, MilliSatoshiLong, TestConstants, TestFeeEstimator, TestKitBaseClass, randomBytes32, randomKey} +import org.scalatest.BeforeAndAfterAll import org.scalatest.funsuite.AnyFunSuiteLike -import org.scalatest.{BeforeAndAfterAll, Tag} import java.util.UUID import java.util.concurrent.atomic.AtomicLong import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration.DurationInt -import scala.util.Random class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike with BitcoindService with ChannelStateTestsHelperMethods with BeforeAndAfterAll { @@ -70,23 +70,43 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w system.spawnAnonymous(ReplaceableTxPublisher(alice.underlyingActor.nodeParams, wallet, alice2blockchain.ref, TxPublishLogContext(UUID.randomUUID(), randomKey().publicKey, None))) } - def getMempool: Seq[Transaction] = { + def aliceBlockHeight(): BlockHeight = BlockHeight(alice.underlyingActor.nodeParams.currentBlockHeight) + + def bobBlockHeight(): BlockHeight = BlockHeight(bob.underlyingActor.nodeParams.currentBlockHeight) + + /** Set uniform feerate for all block targets. */ + def setFeerate(feerate: FeeratePerKw): Unit = { + alice.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(FeeratesPerKw.single(feerate)) + bob.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(FeeratesPerKw.single(feerate)) + } + + /** Set feerate for a specific block target. */ + def setFeerate(feerate: FeeratePerKw, blockTarget: Int): Unit = { + alice.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(blockTarget, feerate) + bob.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(blockTarget, feerate) + } + + def getMempool(): Seq[Transaction] = { wallet.getMempool().pipeTo(probe.ref) probe.expectMsgType[Seq[Transaction]] } def getMempoolTxs(expectedTxCount: Int): Seq[MempoolTx] = { - awaitCond(getMempool.size == expectedTxCount, interval = 200 milliseconds) - getMempool.map(tx => { + awaitCond(getMempool().size == expectedTxCount, interval = 200 milliseconds) + getMempool().map(tx => { wallet.getMempoolTx(tx.txid).pipeTo(probe.ref) probe.expectMsgType[MempoolTx] }) } + def isInMempool(txid: ByteVector32): Boolean = { + getMempool().exists(_.txid == txid) + } + } // NB: we can't use ScalaTest's fixtures, they would see uninitialized bitcoind fields because they sandbox each test. - private def withFixture(utxos: Seq[BtcAmount], channelType: SupportedChannelType, testFun: Fixture => Any): Unit = { + private def withFixture(utxos: Seq[BtcAmount], channelType: SupportedChannelType)(testFun: Fixture => Any): Unit = { // Create a unique wallet for this test and ensure it has some btc. val testId = UUID.randomUUID() val walletRpcClient = createWallet(s"lightning-$testId") @@ -122,14 +142,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // Execute our test. val publisher = system.spawn(ReplaceableTxPublisher(aliceNodeParams, walletClient, alice2blockchain.ref, TxPublishLogContext(testId, TestConstants.Bob.nodeParams.nodeId, None)), testId.toString) - try { - testFun(Fixture(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, walletClient, walletRpcClient, publisher, probe)) - } finally { - publisher ! Stop - } + testFun(Fixture(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, walletClient, walletRpcClient, publisher, probe)) } - def closeChannelWithoutHtlcs(f: Fixture): (PublishFinalTx, PublishReplaceableTx) = { + def closeChannelWithoutHtlcs(f: Fixture, overrideCommitTarget: BlockHeight): (PublishFinalTx, PublishReplaceableTx) = { import f._ val commitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(alice.underlyingActor.nodeParams.channelKeyManager) @@ -142,94 +158,100 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val publishAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(publishAnchor.txInfo.input.outPoint.txid === commitTx.tx.txid) assert(publishAnchor.txInfo.isInstanceOf[ClaimLocalAnchorOutputTx]) + val anchorTx = publishAnchor.txInfo.asInstanceOf[ClaimLocalAnchorOutputTx].copy(confirmBefore = overrideCommitTarget) - (publishCommitTx, publishAnchor) + (publishCommitTx, publishAnchor.copy(txInfo = anchorTx)) } test("commit tx feerate high enough, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val commitFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate - val (_, anchorTx) = closeChannelWithoutHtlcs(f) - publisher ! Publish(probe.ref, anchorTx, commitFeerate) + setFeerate(commitFeerate) + val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 24) + publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === TxSkipped(retryNextBlock = true)) - }) + } } test("commit tx confirmed, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) generateBlocks(1) - publisher ! Publish(probe.ref, anchorTx, FeeratePerKw(10_000 sat)) + 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)) - }) + } } test("commit tx feerate high enough and commit tx confirmed, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val commitFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + setFeerate(commitFeerate) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 6) wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) generateBlocks(1) - publisher ! Publish(probe.ref, anchorTx, commitFeerate) + publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === TxSkipped(retryNextBlock = false)) - }) + } } test("remote commit tx confirmed, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(bob.underlyingActor.nodeParams.channelKeyManager) - val (_, anchorTx) = closeChannelWithoutHtlcs(f) + val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) wallet.publishTransaction(remoteCommit.tx).pipeTo(probe.ref) probe.expectMsg(remoteCommit.tx.txid) generateBlocks(1) - publisher ! Publish(probe.ref, anchorTx, FeeratePerKw(10_000 sat)) + 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)) - }) + } } test("remote commit tx published, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(bob.underlyingActor.nodeParams.channelKeyManager) - val (_, anchorTx) = closeChannelWithoutHtlcs(f) + val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) wallet.publishTransaction(remoteCommit.tx).pipeTo(probe.ref) probe.expectMsg(remoteCommit.tx.txid) - publisher ! Publish(probe.ref, anchorTx, FeeratePerKw(10_000 sat)) + setFeerate(FeeratePerKw(10_000 sat)) + publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) // When the remote commit tx is still unconfirmed, we want to retry in case it is evicted from the mempool and our // commit is then published. assert(result.reason === TxSkipped(retryNextBlock = true)) - }) + } } test("remote commit tx replaces local commit tx, not spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.fullySignedLocalCommitTx(bob.underlyingActor.nodeParams.channelKeyManager) @@ -238,8 +260,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // We lower the feerate to make it easy to replace our commit tx by theirs in the mempool. val lowFeerate = FeeratePerKw(500 sat) updateFee(lowFeerate, alice, bob, alice2bob, bob2alice) - val (localCommit, anchorTx) = closeChannelWithoutHtlcs(f) - publisher ! Publish(probe.ref, anchorTx, FeeratePerKw(600 sat)) + val (localCommit, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 16) + // We set a slightly higher feerate to ensure the local anchor is used. + setFeerate(FeeratePerKw(600 sat)) + publisher ! Publish(probe.ref, anchorTx) val mempoolTxs = getMempoolTxs(2) assert(mempoolTxs.map(_.txid).contains(localCommit.tx.txid)) @@ -252,38 +276,64 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) assert(result.reason === WalletInputGone) - }) + + // Since our wallet input is gone, we will retry and discover that a commit tx has been confirmed. + val publisher2 = createPublisher() + publisher2 ! Publish(probe.ref, anchorTx) + val result2 = probe.expectMsgType[TxRejected] + assert(result2.cmd === anchorTx) + assert(result2.reason === TxSkipped(retryNextBlock = false)) + } + } + + test("funding tx not found, skipping anchor output") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) + // We simulate an unconfirmed funding transaction that cannot be found in the mempool either. + // This may happen when using 0-conf channels if the funding transaction is evicted from the mempool for some reason. + val cmd = anchorTx.copy(commitments = anchorTx.commitments.copy(commitInput = InputInfo(OutPoint(randomBytes32(), 1), TxOut(0 sat, Nil), Nil))) + publisher ! Publish(probe.ref, cmd) + val result = probe.expectMsgType[TxRejected] + assert(result.cmd === cmd) + // We should keep retrying until the funding transaction is available. + assert(result.reason === TxSkipped(retryNextBlock = true)) + } } test("not enough funds to increase commit tx feerate") { - withFixture(Seq(10.5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(10.5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ // close channel and wait for the commit tx to be published, anchor will not be published because we don't have enough funds - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 6) wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) - publisher ! Publish(probe.ref, anchorTx, FeeratePerKw(25_000 sat)) + setFeerate(FeeratePerKw(25_000 sat)) + publisher ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.cmd === anchorTx) // When the remote commit tx is still unconfirmed, we want to retry in case it is evicted from the mempool and our // commit is then published. assert(result.reason === CouldNotFund) - }) + } } test("commit tx feerate too low, spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) probe.expectMsg(commitTx.tx.txid) - assert(getMempool.length === 1) + assert(getMempool().length === 1) val targetFeerate = FeeratePerKw(3000 sat) - publisher ! Publish(probe.ref, anchorTx, targetFeerate) + // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target what's provided. + setFeerate(targetFeerate, blockTarget = 12) + publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) assert(mempoolTxs.map(_.txid).contains(commitTx.tx.txid)) @@ -298,18 +348,20 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result.cmd === anchorTx) assert(result.tx.txIn.map(_.outPoint.txid).contains(commitTx.tx.txid)) assert(mempoolTxs.map(_.txid).contains(result.tx.txid)) - }) + } } test("commit tx not published, publishing it and spending anchor output") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - assert(getMempool.isEmpty) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 32) + assert(getMempool().isEmpty) val targetFeerate = FeeratePerKw(3000 sat) - publisher ! Publish(probe.ref, anchorTx, targetFeerate) + // 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) + publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) assert(mempoolTxs.map(_.txid).contains(commitTx.tx.txid)) @@ -324,7 +376,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result.cmd === anchorTx) assert(result.tx.txIn.map(_.outPoint.txid).contains(commitTx.tx.txid)) assert(mempoolTxs.map(_.txid).contains(result.tx.txid)) - }) + } } test("commit tx feerate too low, spending anchor outputs with multiple wallet inputs") { @@ -336,12 +388,14 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w 22000 sat, 15000 sat ) - withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ + // NB: we try to get transactions confirmed *before* their confirmation target, so we aim for a more aggressive block target than what's provided. val targetFeerate = FeeratePerKw(10_000 sat) - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - publisher ! Publish(probe.ref, anchorTx, targetFeerate) + setFeerate(targetFeerate, blockTarget = 12) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 32) + publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) @@ -358,16 +412,196 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result.tx.txIn.map(_.outPoint.txid).contains(commitTx.tx.txid)) assert(result.tx.txIn.length > 2) // we added more than 1 wallet input assert(mempoolTxs.map(_.txid).contains(result.tx.txid)) - }) + } + } + + test("commit tx fees not increased when confirmation target is far and feerate hasn't changed") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + setFeerate(FeeratePerKw(3000 sat)) + publisher ! Publish(probe.ref, anchorTx) + // wait for the commit tx and anchor tx to be published + val mempoolTxs = getMempoolTxs(2) + assert(mempoolTxs.map(_.txid).contains(commitTx.tx.txid)) + + // A new block is found, but we still have time and the feerate hasn't changed, so we don't bump the fees. + // Note that we don't generate blocks, so the transactions are still unconfirmed. + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 5)) + probe.expectNoMessage(500 millis) + val mempoolTxs2 = getMempool() + assert(mempoolTxs.map(_.txid).toSet === mempoolTxs2.map(_.txid).toSet) + } + } + + test("commit tx not confirming, lowering anchor output amount") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + val oldFeerate = FeeratePerKw(3000 sat) + setFeerate(oldFeerate) + publisher ! Publish(probe.ref, anchorTx) + // wait for the commit tx and anchor tx to be published + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.tx.txid)) + val mempoolAnchorTx1 = mempoolTxs1.filter(_.txid != commitTx.tx.txid).head + + // A new block is found, and the feerate has increased for our block target, so we bump the fees. + val newFeerate = FeeratePerKw(5000 sat) + setFeerate(newFeerate, blockTarget = 12) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 5)) + awaitCond(!isInMempool(mempoolAnchorTx1.txid), interval = 200 millis, max = 30 seconds) + val mempoolTxs2 = getMempoolTxs(2) + val mempoolAnchorTx2 = mempoolTxs2.filter(_.txid != commitTx.tx.txid).head + assert(mempoolAnchorTx1.fees < mempoolAnchorTx2.fees) + + val targetFee = Transactions.weight2fee(newFeerate, mempoolTxs2.map(_.weight).sum.toInt) + val actualFee = mempoolTxs2.map(_.fees).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + } + } + + test("commit tx not confirming, adding other wallet inputs") { + withFixture(Seq(10.5 millibtc, 5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + // The feerate is (much) higher for higher block targets + val targetFeerate = FeeratePerKw(75_000 sat) + setFeerate(FeeratePerKw(3000 sat)) + setFeerate(targetFeerate, blockTarget = 6) + publisher ! Publish(probe.ref, anchorTx) + // wait for the commit tx and anchor tx to be published + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.tx.txid)) + val anchorTx1 = getMempool().filter(_.txid != commitTx.tx.txid).head + + // A new block is found, and the feerate has increased for our block target, so we bump the fees. + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 15)) + awaitCond(!isInMempool(anchorTx1.txid), interval = 200 millis, max = 30 seconds) + val anchorTx2 = getMempool().filter(_.txid != commitTx.tx.txid).head + // We used different inputs to be able to bump to the desired feerate. + assert(anchorTx1.txIn.map(_.outPoint).toSet != anchorTx2.txIn.map(_.outPoint).toSet) + + val mempoolTxs2 = getMempoolTxs(2) + val targetFee = Transactions.weight2fee(targetFeerate, mempoolTxs2.map(_.weight).sum.toInt) + val actualFee = mempoolTxs2.map(_.fees).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + } + } + + test("commit tx not confirming, not enough funds to increase fees") { + withFixture(Seq(10.2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + // The feerate is higher for higher block targets + val targetFeerate = FeeratePerKw(25_000 sat) + setFeerate(FeeratePerKw(3000 sat)) + setFeerate(targetFeerate, blockTarget = 6) + publisher ! Publish(probe.ref, anchorTx) + // wait for the commit tx and anchor tx to be published + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.tx.txid)) + + // A new block is found, and the feerate has increased for our block target, but we don't have enough funds to bump the fees. + system.eventStream.subscribe(probe.ref, classOf[NotifyNodeOperator]) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 15)) + probe.expectMsgType[NotifyNodeOperator] + val mempoolTxs2 = getMempool() + assert(mempoolTxs1.map(_.txid).toSet === mempoolTxs2.map(_.txid).toSet) + } + } + + test("commit tx not confirming, cannot use new unconfirmed inputs to increase fees") { + withFixture(Seq(10.2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + // The feerate is higher for higher block targets + val targetFeerate = FeeratePerKw(25_000 sat) + setFeerate(FeeratePerKw(3000 sat)) + setFeerate(targetFeerate, blockTarget = 6) + publisher ! Publish(probe.ref, anchorTx) + // wait for the commit tx and anchor tx to be published + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.tx.txid)) + + // Our wallet receives new unconfirmed utxos: unfortunately, BIP 125 rule #2 doesn't let us use that input... + wallet.getReceiveAddress().pipeTo(probe.ref) + val walletAddress = probe.expectMsgType[String] + val walletTx = sendToAddress(walletAddress, 5 millibtc, probe) + + // A new block is found, and the feerate has increased for our block target, but we can't use our unconfirmed input. + system.eventStream.subscribe(probe.ref, classOf[NotifyNodeOperator]) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 15)) + probe.expectMsgType[NotifyNodeOperator] + val mempoolTxs2 = getMempool() + assert(mempoolTxs1.map(_.txid).toSet + walletTx.txid === mempoolTxs2.map(_.txid).toSet) + } + } + + test("commit tx not confirming, updating confirmation target") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 30) + wallet.publishTransaction(commitTx.tx).pipeTo(probe.ref) + probe.expectMsg(commitTx.tx.txid) + + val feerateLow = FeeratePerKw(3000 sat) + val feerateHigh = FeeratePerKw(5000 sat) + setFeerate(feerateLow) + setFeerate(feerateHigh, blockTarget = 6) + // With the initial confirmation target, this will use the low feerate. + publisher ! Publish(probe.ref, anchorTx) + val mempoolTxs1 = getMempoolTxs(2) + assert(mempoolTxs1.map(_.txid).contains(commitTx.tx.txid)) + val mempoolAnchorTx1 = mempoolTxs1.filter(_.txid != commitTx.tx.txid).head + val targetFee1 = Transactions.weight2fee(feerateLow, mempoolTxs1.map(_.weight).sum.toInt) + val actualFee1 = mempoolTxs1.map(_.fees).sum + assert(targetFee1 * 0.9 <= actualFee1 && actualFee1 <= targetFee1 * 1.1, s"actualFee=$actualFee1 targetFee=$targetFee1") + + // The confirmation target has changed (probably because we learnt a payment preimage). + // We should now use the high feerate, which corresponds to that new target. + publisher ! UpdateConfirmationTarget(aliceBlockHeight() + 15) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong)) + awaitCond(!isInMempool(mempoolAnchorTx1.txid), interval = 200 millis, max = 30 seconds) + val mempoolTxs2 = getMempoolTxs(2) + val mempoolAnchorTx2 = mempoolTxs2.filter(_.txid != commitTx.tx.txid).head + assert(mempoolAnchorTx1.fees < mempoolAnchorTx2.fees) + + val targetFee2 = Transactions.weight2fee(feerateHigh, mempoolTxs2.map(_.weight).sum.toInt) + val actualFee2 = mempoolTxs2.map(_.fees).sum + assert(targetFee2 * 0.9 <= actualFee2 && actualFee2 <= targetFee2 * 1.1, s"actualFee=$actualFee2 targetFee=$targetFee2") + } } test("unlock utxos when anchor tx cannot be published") { - withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val targetFeerate = FeeratePerKw(3000 sat) - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - publisher ! Publish(probe.ref, anchorTx, targetFeerate) + setFeerate(targetFeerate) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 36) + publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) @@ -376,7 +610,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // we try to publish the anchor again (can be caused by a node restart): it will fail to replace the existing one // in the mempool but we must ensure we don't leave some utxos locked. val publisher2 = createPublisher() - publisher2 ! Publish(probe.ref, anchorTx, targetFeerate) + publisher2 ! Publish(probe.ref, anchorTx) val result = probe.expectMsgType[TxRejected] assert(result.reason === ConflictingTxUnconfirmed) getMempoolTxs(2) // the previous anchor tx and the commit tx are still in the mempool @@ -389,16 +623,17 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(5) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) assert(probe.expectMsgType[TxConfirmed].cmd === anchorTx) - }) + } } test("unlock anchor utxos when stopped before completion") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val targetFeerate = FeeratePerKw(3000 sat) - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - publisher ! Publish(probe.ref, anchorTx, targetFeerate) + setFeerate(targetFeerate) + val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 16) + publisher ! Publish(probe.ref, anchorTx) // wait for the commit tx and anchor tx to be published val mempoolTxs = getMempoolTxs(2) @@ -407,48 +642,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // we unlock utxos before stopping publisher ! Stop awaitCond(getLocks(probe, walletRpcClient).isEmpty) - }) - } - - test("adjust anchor tx change amount", Tag("fuzzy")) { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { - val commitFeerate = f.alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate - assert(commitFeerate < TestConstants.feeratePerKw) - val (commitTx, anchorTx) = closeChannelWithoutHtlcs(f) - val anchorTxInfo = anchorTx.txInfo.asInstanceOf[ClaimLocalAnchorOutputTx] - val dustLimit = anchorTx.commitments.localParams.dustLimit - for (_ <- 1 to 100) { - val walletInputsCount = 1 + Random.nextInt(5) - val walletInputs = (1 to walletInputsCount).map(_ => TxIn(OutPoint(randomBytes32(), 0), Nil, 0)) - val amountIn = dustLimit * walletInputsCount + Random.nextInt(25_000_000).sat - val amountOut = dustLimit + Random.nextLong(amountIn.toLong).sat - val unsignedTx = anchorTxInfo.copy(tx = anchorTxInfo.tx.copy( - txIn = anchorTxInfo.tx.txIn ++ walletInputs, - txOut = TxOut(amountOut, Script.pay2wpkh(randomKey().publicKey)) :: Nil, - )) - val (adjustedTx, fee) = ReplaceableTxPublisher.adjustAnchorOutputChange(unsignedTx, commitTx.tx, amountIn, commitFeerate, TestConstants.feeratePerKw, dustLimit) - assert(fee === amountIn - adjustedTx.tx.txOut.map(_.amount).sum) - assert(adjustedTx.tx.txIn.size === unsignedTx.tx.txIn.size) - assert(adjustedTx.tx.txOut.size === 1) - assert(adjustedTx.tx.txOut.head.amount >= dustLimit) - if (adjustedTx.tx.txOut.head.amount > dustLimit) { - // Simulate tx signing to check final feerate. - val signedTx = { - val anchorSigned = Transactions.addSigs(adjustedTx, Transactions.PlaceHolderSig) - val signedWalletInputs = anchorSigned.tx.txIn.tail.map(txIn => txIn.copy(witness = ScriptWitness(Seq(Scripts.der(Transactions.PlaceHolderSig), Transactions.PlaceHolderPubKey.value)))) - anchorSigned.tx.copy(txIn = anchorSigned.tx.txIn.head +: signedWalletInputs) - } - // We want the package anchor tx + commit tx to reach our target feerate, but the commit tx already pays a (smaller) fee - val targetFee = Transactions.weight2fee(TestConstants.feeratePerKw, signedTx.weight() + commitTx.tx.weight()) - Transactions.weight2fee(commitFeerate, commitTx.tx.weight()) - val actualFee = amountIn - signedTx.txOut.map(_.amount).sum - assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee amountIn=$amountIn tx=$signedTx") - } - } - }) + } } test("remote commit tx confirmed, not publishing htlc tx") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -477,24 +675,24 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(5) // Verify that HTLC transactions immediately fail to publish. - val targetFeerate = FeeratePerKw(15_000 sat) + setFeerate(FeeratePerKw(15_000 sat)) val htlcSuccessPublisher = createPublisher() - htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess, targetFeerate) + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) val result1 = probe.expectMsgType[TxRejected] assert(result1.cmd === htlcSuccess) assert(result1.reason === ConflictingTxConfirmed) htlcSuccessPublisher ! Stop val htlcTimeoutPublisher = createPublisher() - htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeout, targetFeerate) + htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeout) val result2 = probe.expectMsgType[TxRejected] assert(result2.cmd === htlcTimeout) assert(result2.reason === ConflictingTxConfirmed) htlcTimeoutPublisher ! Stop - }) + } } - def closeChannelWithHtlcs(f: Fixture): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { + def closeChannelWithHtlcs(f: Fixture, overrideHtlcTarget: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -521,8 +719,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w alice2blockchain.expectMsgType[PublishFinalTx] // claim main output val htlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(htlcSuccess.txInfo.isInstanceOf[HtlcSuccessTx]) + val htlcSuccessTx = htlcSuccess.txInfo.asInstanceOf[HtlcSuccessTx].copy(confirmBefore = overrideHtlcTarget) val htlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(htlcTimeout.txInfo.isInstanceOf[HtlcTimeoutTx]) + val htlcTimeoutTx = htlcTimeout.txInfo.asInstanceOf[HtlcTimeoutTx].copy(confirmBefore = overrideHtlcTarget) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx alice2blockchain.expectMsgType[WatchTxConfirmed] // claim main output @@ -531,16 +731,17 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w alice2blockchain.expectMsgType[WatchOutputSpent] // htlc-timeout tx alice2blockchain.expectNoMessage(100 millis) - (commitTx.tx, htlcSuccess, htlcTimeout) + (commitTx.tx, htlcSuccess.copy(txInfo = htlcSuccessTx), htlcTimeout.copy(txInfo = htlcTimeoutTx)) } test("not enough funds to increase htlc tx feerate") { - withFixture(Seq(10.5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(10.5 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ - val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f) + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight()) val htlcSuccessPublisher = createPublisher() - htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess, FeeratePerKw(75_000 sat)) + setFeerate(FeeratePerKw(75_000 sat), blockTarget = 1) + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) @@ -548,15 +749,14 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result.cmd === htlcSuccess) assert(result.reason === CouldNotFund) htlcSuccessPublisher ! Stop - }) + } } private def testPublishHtlcSuccess(f: Fixture, commitTx: Transaction, htlcSuccess: PublishReplaceableTx, targetFeerate: FeeratePerKw): Transaction = { import f._ - // The HTLC-success tx will be immediately published since the commit tx is confirmed. val htlcSuccessPublisher = createPublisher() - htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess, targetFeerate) + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) val htlcSuccessTx = getMempoolTxs(1).head @@ -575,12 +775,17 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w private def testPublishHtlcTimeout(f: Fixture, commitTx: Transaction, htlcTimeout: PublishReplaceableTx, targetFeerate: FeeratePerKw): Transaction = { import f._ + // We start with a low feerate, that will then rise during the CLTV period. + // The publisher should use the feerate available when the transaction can be published (after the timeout). + setFeerate(targetFeerate / 2) + // The HTLC-timeout will be published after the timeout. val htlcTimeoutPublisher = createPublisher() - htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeout, targetFeerate) + htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeout) alice2blockchain.expectNoMessage(100 millis) generateBlocks(144) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) + setFeerate(targetFeerate) // the feerate is higher than what it was when the channel force-closed val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) val htlcTimeoutTx = getMempoolTxs(1).head @@ -597,42 +802,68 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } test("htlc tx feerate high enough, not adding wallet inputs") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputs, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputs) { f => import f._ val currentFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate - val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f) + val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 64) + setFeerate(currentFeerate) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, currentFeerate) assert(htlcSuccess.txInfo.fee > 0.sat) assert(htlcSuccessTx.txIn.length === 1) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, currentFeerate) assert(htlcTimeout.txInfo.fee > 0.sat) assert(htlcTimeoutTx.txIn.length === 1) - }) + } } test("htlc tx feerate too low, adding wallet inputs") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputs, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputs) { f => + import f._ + val targetFeerate = FeeratePerKw(15_000 sat) - val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f) + val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 64) + // 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 = 36) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) assert(htlcSuccessTx.txIn.length > 1) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) assert(htlcTimeoutTx.txIn.length > 1) - }) + } } test("htlc tx feerate zero, adding wallet inputs") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + val targetFeerate = FeeratePerKw(15_000 sat) - val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f) + 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) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) assert(htlcSuccessTx.txIn.length > 1) assert(htlcTimeout.txInfo.fee === 0.sat) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) assert(htlcTimeoutTx.txIn.length > 1) - }) + } + } + + test("htlc tx feerate zero, high commit feerate, adding wallet inputs") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val commitFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.commitTxFeerate + val targetFeerate = commitFeerate / 2 + val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30) + setFeerate(targetFeerate) + assert(htlcSuccess.txInfo.fee === 0.sat) + val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) + assert(htlcSuccessTx.txIn.length > 1) + assert(htlcTimeout.txInfo.fee === 0.sat) + val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) + assert(htlcTimeoutTx.txIn.length > 1) + } } test("htlc tx feerate too low, adding multiple wallet inputs") { @@ -651,24 +882,143 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w 5200 sat, 5100 sat ) - withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(utxos, ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + val targetFeerate = FeeratePerKw(8_000 sat) - val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f) + 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) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, targetFeerate) assert(htlcSuccessTx.txIn.length > 2) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, targetFeerate) assert(htlcTimeoutTx.txIn.length > 2) - }) + } + } + + test("htlc success tx not confirming, lowering output amount") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val initialFeerate = FeeratePerKw(15_000 sat) + setFeerate(initialFeerate) + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30) + + val htlcSuccessPublisher = createPublisher() + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) + val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] + w.replyTo ! WatchParentTxConfirmedTriggered(aliceBlockHeight().toInt, 0, commitTx) + val htlcSuccessTx1 = getMempoolTxs(1).head + val htlcSuccessInputs1 = getMempool().head.txIn.map(_.outPoint).toSet + + // New blocks are found, which makes us aim for a more aggressive block target, so we bump the fees. + val targetFeerate = FeeratePerKw(25_000 sat) + setFeerate(targetFeerate, blockTarget = 6) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 15)) + awaitCond(!isInMempool(htlcSuccessTx1.txid), interval = 200 millis, max = 30 seconds) + val htlcSuccessTx2 = getMempoolTxs(1).head + val htlcSuccessInputs2 = getMempool().head.txIn.map(_.outPoint).toSet + assert(htlcSuccessTx1.fees < htlcSuccessTx2.fees) + assert(htlcSuccessInputs1 === htlcSuccessInputs2) + val htlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, htlcSuccessTx2.weight.toInt) + assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx2.fees && htlcSuccessTx2.fees <= htlcSuccessTargetFee * 1.4, s"actualFee=${htlcSuccessTx2.fees} targetFee=$htlcSuccessTargetFee") + } + } + + test("htlc success tx not confirming, adding other wallet inputs") { + withFixture(Seq(10.2 millibtc, 2 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val initialFeerate = FeeratePerKw(15_000 sat) + setFeerate(initialFeerate) + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight() + 15) + + val htlcSuccessPublisher = createPublisher() + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) + val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] + w.replyTo ! WatchParentTxConfirmedTriggered(aliceBlockHeight().toInt, 0, commitTx) + val htlcSuccessTx1 = getMempoolTxs(1).head + val htlcSuccessInputs1 = getMempool().head.txIn.map(_.outPoint).toSet + + // New blocks are found, which makes us aim for a more aggressive block target, so we bump the fees. + val targetFeerate = FeeratePerKw(75_000 sat) + setFeerate(targetFeerate, blockTarget = 2) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 10)) + awaitCond(!isInMempool(htlcSuccessTx1.txid), interval = 200 millis, max = 30 seconds) + val htlcSuccessTx2 = getMempoolTxs(1).head + val htlcSuccessInputs2 = getMempool().head.txIn.map(_.outPoint).toSet + assert(htlcSuccessTx1.fees < htlcSuccessTx2.fees) + assert(htlcSuccessInputs1 !== htlcSuccessInputs2) + val htlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, htlcSuccessTx2.weight.toInt) + assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx2.fees && htlcSuccessTx2.fees <= htlcSuccessTargetFee * 1.4, s"actualFee=${htlcSuccessTx2.fees} targetFee=$htlcSuccessTargetFee") + } + } + + test("htlc success tx confirmation target reached, increasing fees") { + withFixture(Seq(50 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val initialFeerate = FeeratePerKw(10_000 sat) + setFeerate(initialFeerate) + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight() + 6) + + val htlcSuccessPublisher = createPublisher() + htlcSuccessPublisher ! Publish(probe.ref, htlcSuccess) + val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] + w.replyTo ! WatchParentTxConfirmedTriggered(aliceBlockHeight().toInt, 0, commitTx) + var htlcSuccessTx = getMempoolTxs(1).head + + // We are only 6 blocks away from the confirmation target, so we bump the fees at each new block. + (1 to 3).foreach(i => { + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + i)) + awaitCond(!isInMempool(htlcSuccessTx.txid), interval = 200 millis, max = 30 seconds) + val bumpedHtlcSuccessTx = getMempoolTxs(1).head + assert(htlcSuccessTx.fees < bumpedHtlcSuccessTx.fees) + htlcSuccessTx = bumpedHtlcSuccessTx + }) + } + } + + test("htlc timeout tx not confirming, increasing fees") { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => + import f._ + + val feerate = FeeratePerKw(15_000 sat) + setFeerate(feerate) + // The confirmation target for htlc-timeout corresponds to their CLTV: we should claim them asap once the htlc has timed out. + val (commitTx, _, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 144) + + val htlcTimeoutPublisher = createPublisher() + htlcTimeoutPublisher ! Publish(probe.ref, htlcTimeout) + generateBlocks(144) + system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) + val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] + w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) + val htlcTimeoutTx1 = getMempoolTxs(1).head + val htlcTimeoutInputs1 = getMempool().head.txIn.map(_.outPoint).toSet + + // A new block is found, and we've already reached the confirmation target, so we bump the fees. + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 145)) + awaitCond(!isInMempool(htlcTimeoutTx1.txid), interval = 200 millis, max = 30 seconds) + val htlcTimeoutTx2 = getMempoolTxs(1).head + val htlcTimeoutInputs2 = getMempool().head.txIn.map(_.outPoint).toSet + assert(htlcTimeoutTx1.fees < htlcTimeoutTx2.fees) + assert(htlcTimeoutInputs1 === htlcTimeoutInputs2) + // Once the confirmation target is reach, we should raise the feerate by at least 20% at every block. + val htlcTimeoutTargetFee = Transactions.weight2fee(feerate * 1.2, htlcTimeoutTx2.weight.toInt) + assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx2.fees && htlcTimeoutTx2.fees <= htlcTimeoutTargetFee * 1.1, s"actualFee=${htlcTimeoutTx2.fees} targetFee=$htlcTimeoutTargetFee") + } } test("unlock utxos when htlc tx cannot be published") { - withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc, 200 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val targetFeerate = FeeratePerKw(5_000 sat) - val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f) + setFeerate(targetFeerate) + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight() + 18) val publisher1 = createPublisher() - publisher1 ! Publish(probe.ref, htlcSuccess, targetFeerate) + publisher1 ! Publish(probe.ref, htlcSuccess) val w1 = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w1.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) getMempoolTxs(1) @@ -676,7 +1026,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // we try to publish the htlc-success again (can be caused by a node restart): it will fail to replace the existing // one in the mempool but we must ensure we don't leave some utxos locked. val publisher2 = createPublisher() - publisher2 ! Publish(probe.ref, htlcSuccess, targetFeerate) + publisher2 ! Publish(probe.ref, htlcSuccess) val w2 = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w2.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) val result = probe.expectMsgType[TxRejected] @@ -692,16 +1042,16 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) assert(probe.expectMsgType[TxConfirmed].cmd === htlcSuccess) publisher1 ! Stop - }) + } } test("unlock htlc utxos when stopped before completion") { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ - val targetFeerate = FeeratePerKw(5_000 sat) - val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f) - publisher ! Publish(probe.ref, htlcSuccess, targetFeerate) + setFeerate(FeeratePerKw(5_000 sat)) + val (commitTx, htlcSuccess, _) = closeChannelWithHtlcs(f, aliceBlockHeight() + 48) + publisher ! Publish(probe.ref, htlcSuccess) val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, commitTx) getMempoolTxs(1) @@ -709,55 +1059,11 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // We unlock utxos before stopping. publisher ! Stop awaitCond(getLocks(probe, walletRpcClient).isEmpty) - }) - } - - test("adjust htlc tx change amount", Tag("fuzzy")) { - withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { - val (_, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f) - val commitments = htlcSuccess.commitments - val dustLimit = commitments.localParams.dustLimit - val targetFeerate = TestConstants.feeratePerKw - for (_ <- 1 to 100) { - val walletInputsCount = 1 + Random.nextInt(5) - val walletInputs = (1 to walletInputsCount).map(_ => TxIn(OutPoint(randomBytes32(), 0), Nil, 0)) - val walletAmountIn = dustLimit * walletInputsCount + Random.nextInt(25_000_000).sat - val changeOutput = TxOut(Random.nextLong(walletAmountIn.toLong).sat, Script.pay2wpkh(randomKey().publicKey)) - val unsignedHtlcSuccessTx = htlcSuccess.txInfo.asInstanceOf[HtlcSuccessTx].copy(tx = htlcSuccess.txInfo.tx.copy( - txIn = htlcSuccess.txInfo.tx.txIn ++ walletInputs, - txOut = htlcSuccess.txInfo.tx.txOut ++ Seq(changeOutput) - )) - val unsignedHtlcTimeoutTx = htlcTimeout.txInfo.asInstanceOf[HtlcTimeoutTx].copy(tx = htlcTimeout.txInfo.tx.copy( - txIn = htlcTimeout.txInfo.tx.txIn ++ walletInputs, - txOut = htlcTimeout.txInfo.tx.txOut ++ Seq(changeOutput) - )) - for (unsignedTx <- Seq(unsignedHtlcSuccessTx, unsignedHtlcTimeoutTx)) { - val totalAmountIn = unsignedTx.input.txOut.amount + walletAmountIn - val (adjustedTx, fee) = ReplaceableTxPublisher.adjustHtlcTxChange(unsignedTx, totalAmountIn, targetFeerate, commitments) - assert(fee === totalAmountIn - adjustedTx.tx.txOut.map(_.amount).sum) - assert(adjustedTx.tx.txIn.size === unsignedTx.tx.txIn.size) - assert(adjustedTx.tx.txOut.size === 1 || adjustedTx.tx.txOut.size === 2) - if (adjustedTx.tx.txOut.size == 2) { - // Simulate tx signing to check final feerate. - val signedTx = { - val htlcSigned = adjustedTx match { - case tx: HtlcSuccessTx => Transactions.addSigs(tx, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig, ByteVector32.Zeroes, commitments.commitmentFormat) - case tx: HtlcTimeoutTx => Transactions.addSigs(tx, Transactions.PlaceHolderSig, Transactions.PlaceHolderSig, commitments.commitmentFormat) - } - val signedWalletInputs = htlcSigned.tx.txIn.tail.map(txIn => txIn.copy(witness = ScriptWitness(Seq(Scripts.der(Transactions.PlaceHolderSig), Transactions.PlaceHolderPubKey.value)))) - htlcSigned.tx.copy(txIn = htlcSigned.tx.txIn.head +: signedWalletInputs) - } - val targetFee = Transactions.weight2fee(targetFeerate, signedTx.weight()) - val actualFee = totalAmountIn - signedTx.txOut.map(_.amount).sum - assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee amountIn=$walletAmountIn tx=$signedTx") - } - } - } - }) + } } test("local commit tx confirmed, not publishing claim htlc tx") { - withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -785,24 +1091,24 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w generateBlocks(5) // Verify that Claim-HTLC transactions immediately fail to publish. - val targetFeerate = FeeratePerKw(5_000 sat) + setFeerate(FeeratePerKw(5_000 sat)) val claimHtlcSuccessPublisher = createPublisher() - claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess, targetFeerate) + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) val result1 = probe.expectMsgType[TxRejected] assert(result1.cmd === claimHtlcSuccess) assert(result1.reason === ConflictingTxConfirmed) claimHtlcSuccessPublisher ! Stop val claimHtlcTimeoutPublisher = createPublisher() - claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout, targetFeerate) + claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout) val result2 = probe.expectMsgType[TxRejected] assert(result2.cmd === claimHtlcTimeout) assert(result2.reason === ConflictingTxConfirmed) claimHtlcTimeoutPublisher ! Stop - }) + } } - def remoteCloseChannelWithHtlcs(f: Fixture): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { + def remoteCloseChannelWithHtlcs(f: Fixture, overrideHtlcTarget: BlockHeight): (Transaction, PublishReplaceableTx, PublishReplaceableTx) = { import f._ // Add htlcs in both directions and ensure that preimages are available. @@ -830,8 +1136,10 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w alice2blockchain.expectMsgType[PublishFinalTx] // claim main output val claimHtlcTimeout = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(claimHtlcTimeout.txInfo.isInstanceOf[ClaimHtlcTimeoutTx]) + val claimHtlcTimeoutTx = claimHtlcTimeout.txInfo.asInstanceOf[ClaimHtlcTimeoutTx].copy(confirmBefore = overrideHtlcTarget) val claimHtlcSuccess = alice2blockchain.expectMsgType[PublishReplaceableTx] assert(claimHtlcSuccess.txInfo.isInstanceOf[ClaimHtlcSuccessTx]) + val claimHtlcSuccessTx = claimHtlcSuccess.txInfo.asInstanceOf[ClaimHtlcSuccessTx].copy(confirmBefore = overrideHtlcTarget) alice2blockchain.expectMsgType[WatchTxConfirmed] // commit tx alice2blockchain.expectMsgType[WatchTxConfirmed] // claim main output @@ -839,15 +1147,14 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w alice2blockchain.expectMsgType[WatchOutputSpent] // claim-htlc-timeout tx alice2blockchain.expectNoMessage(100 millis) - (remoteCommitTx.tx, claimHtlcSuccess, claimHtlcTimeout) + (remoteCommitTx.tx, claimHtlcSuccess.copy(txInfo = claimHtlcSuccessTx), claimHtlcTimeout.copy(txInfo = claimHtlcTimeoutTx)) } private def testPublishClaimHtlcSuccess(f: Fixture, remoteCommitTx: Transaction, claimHtlcSuccess: PublishReplaceableTx, targetFeerate: FeeratePerKw): Transaction = { import f._ - // The Claim-HTLC-success tx will be immediately published since the commit tx is confirmed. val claimHtlcSuccessPublisher = createPublisher() - claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess, targetFeerate) + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, remoteCommitTx) val claimHtlcSuccessTx = getMempoolTxs(1).head @@ -866,12 +1173,17 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w private def testPublishClaimHtlcTimeout(f: Fixture, remoteCommitTx: Transaction, claimHtlcTimeout: PublishReplaceableTx, targetFeerate: FeeratePerKw): Transaction = { import f._ + // We start with a low feerate, that will then rise during the CLTV period. + // The publisher should use the feerate available when the transaction can be published (after the timeout). + setFeerate(targetFeerate / 2) + // The Claim-HTLC-timeout will be published after the timeout. val claimHtlcTimeoutPublisher = createPublisher() - claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout, targetFeerate) + claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout) alice2blockchain.expectNoMessage(100 millis) generateBlocks(144) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) + setFeerate(targetFeerate) // the feerate is higher than what it was when the channel force-closed val w = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, remoteCommitTx) val claimHtlcTimeoutTx = getMempoolTxs(1).head @@ -888,24 +1200,28 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } test("claim htlc tx feerate high enough, not changing output amount") { - withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx, f => { + withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx) { f => import f._ val currentFeerate = alice.underlyingActor.nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(2) - val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f) + val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 50) val claimHtlcSuccessTx = testPublishClaimHtlcSuccess(f, remoteCommitTx, claimHtlcSuccess, currentFeerate) assert(claimHtlcSuccess.txInfo.fee > 0.sat) assert(claimHtlcSuccessTx.txIn.length === 1) val claimHtlcTimeoutTx = testPublishClaimHtlcTimeout(f, remoteCommitTx, claimHtlcTimeout, currentFeerate) assert(claimHtlcTimeout.txInfo.fee > 0.sat) assert(claimHtlcTimeoutTx.txIn.length === 1) - }) + } } test("claim htlc tx feerate too low, lowering output amount") { - withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs, f => { + withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs) { f => + import f._ + val targetFeerate = FeeratePerKw(15_000 sat) - val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f) + val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 32) + // 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) val claimHtlcSuccessTx = testPublishClaimHtlcSuccess(f, remoteCommitTx, claimHtlcSuccess, targetFeerate) assert(claimHtlcSuccessTx.txIn.length === 1) assert(claimHtlcSuccessTx.txOut.length === 1) @@ -914,19 +1230,20 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(claimHtlcTimeoutTx.txIn.length === 1) assert(claimHtlcTimeoutTx.txOut.length === 1) assert(claimHtlcTimeoutTx.txOut.head.amount < claimHtlcTimeout.txInfo.tx.txOut.head.amount) - }) + } } test("claim htlc tx feerate too low, lowering output amount (standard commitment format)") { - withFixture(Seq(11 millibtc), ChannelTypes.Standard, f => { + withFixture(Seq(11 millibtc), ChannelTypes.Standard) { f => import f._ val targetFeerate = FeeratePerKw(15_000 sat) - val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f) + val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 300) // The Claim-HTLC-success tx will be immediately published. + setFeerate(targetFeerate) val claimHtlcSuccessPublisher = createPublisher() - claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess, targetFeerate) + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) val claimHtlcSuccessTx = getMempoolTxs(1).head val claimHtlcSuccessTargetFee = Transactions.weight2fee(targetFeerate, claimHtlcSuccessTx.weight.toInt) assert(claimHtlcSuccessTargetFee * 0.9 <= claimHtlcSuccessTx.fees && claimHtlcSuccessTx.fees <= claimHtlcSuccessTargetFee * 1.1, s"actualFee=${claimHtlcSuccessTx.fees} targetFee=$claimHtlcSuccessTargetFee") @@ -939,7 +1256,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w // The Claim-HTLC-timeout will be published after the timeout. val claimHtlcTimeoutPublisher = createPublisher() - claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout, targetFeerate) + claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout) alice2blockchain.expectNoMessage(100 millis) generateBlocks(144) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) @@ -953,18 +1270,18 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(claimHtlcTimeoutResult.cmd === claimHtlcTimeout) assert(claimHtlcTimeoutResult.tx.txIn.map(_.outPoint.txid).contains(remoteCommitTx.txid)) claimHtlcTimeoutPublisher ! Stop - }) + } } test("claim htlc tx feerate way too low, skipping output") { - withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs, f => { + withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs) { f => import f._ - val targetFeerate = FeeratePerKw(50_000 sat) - val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f) + val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 300) + setFeerate(FeeratePerKw(50_000 sat)) val claimHtlcSuccessPublisher = createPublisher() - claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess, targetFeerate) + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) val w1 = alice2blockchain.expectMsgType[WatchParentTxConfirmed] w1.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, remoteCommitTx) val result1 = probe.expectMsgType[TxRejected] @@ -973,7 +1290,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w claimHtlcSuccessPublisher ! Stop val claimHtlcTimeoutPublisher = createPublisher() - claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout, targetFeerate) + claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout) generateBlocks(144) system.eventStream.publish(CurrentBlockCount(currentBlockHeight(probe))) val w2 = alice2blockchain.expectMsgType[WatchParentTxConfirmed] @@ -982,7 +1299,79 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w assert(result2.cmd === claimHtlcTimeout) assert(result2.reason === TxSkipped(retryNextBlock = true)) claimHtlcTimeoutPublisher ! Stop - }) + } + } + + test("claim htlc tx not confirming, lowering output amount again (standard commitment format)") { + withFixture(Seq(11 millibtc), ChannelTypes.Standard) { f => + import f._ + + val initialFeerate = FeeratePerKw(15_000 sat) + val targetFeerate = FeeratePerKw(20_000 sat) + + val (remoteCommitTx, claimHtlcSuccess, claimHtlcTimeout) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 144) + + // The Claim-HTLC-success tx will be immediately published. + setFeerate(initialFeerate) + val claimHtlcSuccessPublisher = createPublisher() + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) + val claimHtlcSuccessTx1 = getMempoolTxs(1).head + + setFeerate(targetFeerate) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 5)) + awaitCond(!isInMempool(claimHtlcSuccessTx1.txid), interval = 200 millis, max = 30 seconds) + val claimHtlcSuccessTx2 = getMempoolTxs(1).head + assert(claimHtlcSuccessTx1.fees < claimHtlcSuccessTx2.fees) + val targetHtlcSuccessFee = Transactions.weight2fee(targetFeerate, claimHtlcSuccessTx2.weight.toInt) + assert(targetHtlcSuccessFee * 0.9 <= claimHtlcSuccessTx2.fees && claimHtlcSuccessTx2.fees <= targetHtlcSuccessFee * 1.1, s"actualFee=${claimHtlcSuccessTx2.fees} targetFee=$targetHtlcSuccessFee") + val finalHtlcSuccessTx = getMempool().head + assert(finalHtlcSuccessTx.txIn.length === 1) + assert(finalHtlcSuccessTx.txOut.length === 1) + assert(finalHtlcSuccessTx.txIn.head.outPoint.txid === remoteCommitTx.txid) + + // The Claim-HTLC-timeout will be published after the timeout. + setFeerate(initialFeerate) + val claimHtlcTimeoutPublisher = createPublisher() + claimHtlcTimeoutPublisher ! Publish(probe.ref, claimHtlcTimeout) + generateBlocks(144) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 144)) + assert(probe.expectMsgType[TxConfirmed].tx.txid === finalHtlcSuccessTx.txid) // the claim-htlc-success is now confirmed + val claimHtlcTimeoutTx1 = getMempoolTxs(1).head + + setFeerate(targetFeerate) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 145)) + awaitCond(!isInMempool(claimHtlcTimeoutTx1.txid), interval = 200 millis, max = 30 seconds) + val claimHtlcTimeoutTx2 = getMempoolTxs(1).head + assert(claimHtlcTimeoutTx1.fees < claimHtlcTimeoutTx2.fees) + val targetHtlcTimeoutFee = Transactions.weight2fee(targetFeerate, claimHtlcTimeoutTx2.weight.toInt) + assert(targetHtlcTimeoutFee * 0.9 <= claimHtlcTimeoutTx2.fees && claimHtlcTimeoutTx2.fees <= targetHtlcTimeoutFee * 1.1, s"actualFee=${claimHtlcTimeoutTx2.fees} targetFee=$targetHtlcTimeoutFee") + val finalHtlcTimeoutTx = getMempool().head + assert(finalHtlcTimeoutTx.txIn.length === 1) + assert(finalHtlcTimeoutTx.txOut.length === 1) + assert(finalHtlcTimeoutTx.txIn.head.outPoint.txid === remoteCommitTx.txid) + } + } + + test("claim htlc tx not confirming, but cannot lower output amount again") { + withFixture(Seq(11 millibtc), ChannelTypes.AnchorOutputs) { f => + import f._ + + val (remoteCommitTx, claimHtlcSuccess, _) = remoteCloseChannelWithHtlcs(f, aliceBlockHeight() + 300) + + setFeerate(FeeratePerKw(5_000 sat)) + val claimHtlcSuccessPublisher = createPublisher() + claimHtlcSuccessPublisher ! Publish(probe.ref, claimHtlcSuccess) + val w1 = alice2blockchain.expectMsgType[WatchParentTxConfirmed] + w1.replyTo ! WatchParentTxConfirmedTriggered(currentBlockHeight(probe).toInt, 0, remoteCommitTx) + val claimHtlcSuccessTx = getMempoolTxs(1).head + + // New blocks are found and the feerate is higher, but the htlc would become dust, so we don't bump the fees. + setFeerate(FeeratePerKw(50_000 sat)) + system.eventStream.publish(CurrentBlockCount(aliceBlockHeight().toLong + 5)) + probe.expectNoMessage(500 millis) + val mempoolTxs = getMempool() + assert(mempoolTxs.map(_.txid).toSet === Set(claimHtlcSuccessTx.txid)) + } } } 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 c8fb84e816..9940960867 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 @@ -21,14 +21,12 @@ import akka.actor.typed.scaladsl.ActorContext import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, TypedActorRefOps, actorRefAdapter} import akka.testkit.TestProbe import fr.acinq.bitcoin.{OutPoint, SatoshiLong, Transaction, TxIn, TxOut} -import fr.acinq.eclair.TestConstants.TestFeeEstimator import fr.acinq.eclair.blockchain.CurrentBlockCount -import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} 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.{ClaimLocalAnchorOutputTx, HtlcSuccessTx, InputInfo} -import fr.acinq.eclair.{NodeParams, TestConstants, TestKitBaseClass, randomBytes32, randomKey} +import fr.acinq.eclair.{BlockHeight, NodeParams, TestConstants, TestKitBaseClass, randomBytes32, randomKey} import org.scalatest.Outcome import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -37,11 +35,7 @@ import scala.concurrent.duration.DurationInt class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { - case class FixtureParam(nodeParams: NodeParams, txPublisher: ActorRef[TxPublisher.Command], factory: TestProbe, probe: TestProbe) { - def setFeerate(feerate: FeeratePerKw): Unit = { - nodeParams.onChainFeeConf.feeEstimator.asInstanceOf[TestFeeEstimator].setFeerate(FeeratesPerKw.single(feerate)) - } - } + case class FixtureParam(nodeParams: NodeParams, txPublisher: ActorRef[TxPublisher.Command], factory: TestProbe, probe: TestProbe) override def withFixture(test: OneArgTest): Outcome = { within(max = 30 seconds) { @@ -107,40 +101,46 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { test("publish replaceable tx") { f => import f._ - f.setFeerate(FeeratePerKw(750 sat)) + val confirmBefore = BlockHeight(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomBytes32(), 3) - val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null) + val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), confirmBefore), null) txPublisher ! cmd val child = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor val p = child.expectMsgType[ReplaceableTxPublisher.Publish] assert(p.cmd === cmd) - assert(p.targetFeerate === FeeratePerKw(750 sat)) } test("publish replaceable tx duplicate") { f => import f._ - f.setFeerate(FeeratePerKw(750 sat)) + val confirmBefore = BlockHeight(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomBytes32(), 3) - val cmd = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0)), null) + val anchorTx = ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), confirmBefore) + val cmd = PublishReplaceableTx(anchorTx, null) txPublisher ! cmd - val child1 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor - val p1 = child1.expectMsgType[ReplaceableTxPublisher.Publish] - assert(p1.cmd === cmd) - assert(p1.targetFeerate === FeeratePerKw(750 sat)) + val child = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor + assert(child.expectMsgType[ReplaceableTxPublisher.Publish].cmd === cmd) - // We ignore duplicates that use a lower feerate: - f.setFeerate(FeeratePerKw(700 sat)) - txPublisher ! cmd + // We ignore duplicates that don't use a more aggressive confirmation target: + txPublisher ! PublishReplaceableTx(anchorTx, null) + child.expectNoMessage(100 millis) + factory.expectNoMessage(100 millis) + val cmdHigherTarget = cmd.copy(txInfo = anchorTx.copy(confirmBefore = confirmBefore + 1)) + txPublisher ! cmdHigherTarget + child.expectNoMessage(100 millis) factory.expectNoMessage(100 millis) - // But we retry publishing if the feerate is greater than previous attempts: - f.setFeerate(FeeratePerKw(1000 sat)) - txPublisher ! cmd - val child2 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor - val p2 = child2.expectMsgType[ReplaceableTxPublisher.Publish] - assert(p2.cmd === cmd) - assert(p2.targetFeerate === FeeratePerKw(1000 sat)) + // But we update the confirmation target when it is more aggressive than previous attempts: + val cmdLowerTarget = cmd.copy(txInfo = anchorTx.copy(confirmBefore = confirmBefore - 6)) + txPublisher ! cmdLowerTarget + child.expectMsg(ReplaceableTxPublisher.UpdateConfirmationTarget(confirmBefore - 6)) + factory.expectNoMessage(100 millis) + + // And we update our internal threshold accordingly: + val cmdInBetween = cmd.copy(txInfo = anchorTx.copy(confirmBefore = confirmBefore - 3)) + txPublisher ! cmdInBetween + child.expectNoMessage(100 millis) + factory.expectNoMessage(100 millis) } test("stop publishing attempts when transaction confirms") { f => @@ -159,7 +159,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt2 = factory.expectMsgType[FinalTxPublisherSpawned].actor attempt2.expectMsgType[FinalTxPublisher.Publish] - val cmd3 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null) + val cmd3 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0), BlockHeight(nodeParams.currentBlockHeight)), null) txPublisher ! cmd3 val attempt3 = factory.expectMsgType[ReplaceableTxPublisherSpawned].actor attempt3.expectMsgType[ReplaceableTxPublisher.Publish] @@ -181,7 +181,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { val attempt1 = factory.expectMsgType[FinalTxPublisherSpawned] attempt1.actor.expectMsgType[FinalTxPublisher.Publish] - val cmd2 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0)), null) + val cmd2 = PublishReplaceableTx(ClaimLocalAnchorOutputTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, TxOut(20_000 sat, Nil) :: Nil, 0), BlockHeight(nodeParams.currentBlockHeight)), null) txPublisher ! cmd2 val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] @@ -198,29 +198,22 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { test("publishing attempt fails (not enough funds)") { f => import f._ - f.setFeerate(FeeratePerKw(600 sat)) + val target = BlockHeight(nodeParams.currentBlockHeight + 12) val input = OutPoint(randomBytes32(), 7) val paymentHash = randomBytes32() - val cmd1 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null) - txPublisher ! cmd1 + val cmd = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, target), null) + txPublisher ! cmd val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] - f.setFeerate(FeeratePerKw(750 sat)) - val cmd2 = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3), null) - txPublisher ! cmd2 - val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] - attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish] - - txPublisher ! TxRejected(attempt2.id, cmd2, CouldNotFund) - attempt2.actor.expectMsg(ReplaceableTxPublisher.Stop) - attempt1.actor.expectNoMessage(100 millis) // this error doesn't impact other publishing attempts + txPublisher ! TxRejected(attempt1.id, cmd, CouldNotFund) + attempt1.actor.expectMsg(ReplaceableTxPublisher.Stop) // We automatically retry the failed attempt once a new block is found (we may have more funds now): factory.expectNoMessage(100 millis) system.eventStream.publish(CurrentBlockCount(8200)) - val attempt3 = factory.expectMsgType[ReplaceableTxPublisherSpawned] - assert(attempt3.actor.expectMsgType[ReplaceableTxPublisher.Publish].cmd === cmd2) + val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] + assert(attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish].cmd === cmd) } test("publishing attempt fails (transaction skipped)") { f => @@ -250,7 +243,7 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { factory.expectNoMessage(100 millis) } - test("publishing attempt fails (unconfirmed conflicting transaction)") { f => + test("publishing attempt fails (unconfirmed conflicting raw transaction)") { f => import f._ val tx = Transaction(2, TxIn(OutPoint(randomBytes32(), 1), Nil, 0) :: Nil, Nil, 0) @@ -267,6 +260,26 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { factory.expectNoMessage(100 millis) } + test("publishing attempt fails (unconfirmed conflicting replaceable transaction)") { f => + import f._ + + val input = OutPoint(randomBytes32(), 7) + val paymentHash = randomBytes32() + val cmd = PublishReplaceableTx(HtlcSuccessTx(InputInfo(input, TxOut(25_000 sat, Nil), Nil), Transaction(2, TxIn(input, Nil, 0) :: Nil, Nil, 0), paymentHash, 3, BlockHeight(nodeParams.currentBlockHeight)), null) + txPublisher ! cmd + val attempt1 = factory.expectMsgType[ReplaceableTxPublisherSpawned] + attempt1.actor.expectMsgType[ReplaceableTxPublisher.Publish] + + txPublisher ! TxRejected(attempt1.id, cmd, ConflictingTxUnconfirmed) + attempt1.actor.expectMsg(ReplaceableTxPublisher.Stop) + factory.expectNoMessage(100 millis) + + // We retry when a new block is found: + system.eventStream.publish(CurrentBlockCount(nodeParams.currentBlockHeight + 1)) + val attempt2 = factory.expectMsgType[ReplaceableTxPublisherSpawned] + assert(attempt2.actor.expectMsgType[ReplaceableTxPublisher.Publish].cmd === cmd) + } + test("publishing attempt fails (confirmed conflicting transaction)") { f => import f._ @@ -301,4 +314,46 @@ class TxPublisherSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike { factory.expectNoMessage(100 millis) } + test("update publishing attempts") { _ => + { + // No attempts. + val attempts = PublishAttempts.empty + assert(attempts.isEmpty) + assert(attempts.count === 0) + assert(attempts.attempts.isEmpty) + assert(attempts.remove(UUID.randomUUID()) === (Nil, attempts)) + } + { + // Only final attempts. + val attempt1 = FinalAttempt(UUID.randomUUID(), null, null) + val attempt2 = FinalAttempt(UUID.randomUUID(), null, null) + val attempts = PublishAttempts.empty.add(attempt1).add(attempt2) + assert(!attempts.isEmpty) + assert(attempts.count === 2) + assert(attempts.replaceableAttempt_opt.isEmpty) + assert(attempts.remove(UUID.randomUUID()) === (Nil, attempts)) + assert(attempts.remove(attempt1.id) === (Seq(attempt1), PublishAttempts(Seq(attempt2), None))) + } + { + // Only replaceable attempts. + val attempt = ReplaceableAttempt(UUID.randomUUID(), null, BlockHeight(0), null) + val attempts = PublishAttempts(Nil, Some(attempt)) + assert(!attempts.isEmpty) + assert(attempts.count === 1) + assert(attempts.remove(UUID.randomUUID()) === (Nil, attempts)) + assert(attempts.remove(attempt.id) === (Seq(attempt), PublishAttempts.empty)) + } + { + // Mix of final and replaceable attempts with the same id. + val attempt1 = ReplaceableAttempt(UUID.randomUUID(), null, BlockHeight(0), null) + val attempt2 = FinalAttempt(attempt1.id, null, null) + val attempt3 = FinalAttempt(UUID.randomUUID(), null, null) + val attempts = PublishAttempts(Seq(attempt2), Some(attempt1)).add(attempt3) + assert(!attempts.isEmpty) + assert(attempts.count === 3) + assert(attempts.remove(attempt3.id) === (Seq(attempt3), PublishAttempts(Seq(attempt2), Some(attempt1)))) + assert(attempts.remove(attempt1.id) === (Seq(attempt2, attempt1), PublishAttempts(Seq(attempt3), None))) + } + } + } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitorSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitorSpec.scala index 9bf97974e9..e2f70fe0af 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitorSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/publish/TxTimeLocksMonitorSpec.scala @@ -17,13 +17,13 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.ActorRef -import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, TypedActorRefOps, actorRefAdapter} +import akka.actor.typed.scaladsl.adapter.{ClassicActorSystemOps, actorRefAdapter} import akka.testkit.TestProbe import fr.acinq.bitcoin.{OutPoint, SatoshiLong, Script, Transaction, TxIn, TxOut} import fr.acinq.eclair.blockchain.CurrentBlockCount import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{WatchParentTxConfirmed, WatchParentTxConfirmedTriggered} import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishLogContext -import fr.acinq.eclair.channel.publish.TxTimeLocksMonitor.{CheckTx, Stop, TimeLocksOk} +import fr.acinq.eclair.channel.publish.TxTimeLocksMonitor.{CheckTx, TimeLocksOk} import fr.acinq.eclair.{NodeParams, TestConstants, TestKitBaseClass, randomKey} import org.scalatest.Outcome import org.scalatest.funsuite.FixtureAnyFunSuiteLike @@ -131,16 +131,4 @@ class TxTimeLocksMonitorSpec extends TestKitBaseClass with FixtureAnyFunSuiteLik probe.expectMsg(TimeLocksOk()) } - test("stop actor before time locks") { f => - import f._ - - val tx = Transaction(2, Nil, TxOut(100_000 sat, Script.pay2wpkh(randomKey().publicKey)) :: Nil, nodeParams.currentBlockHeight + 3) - monitor ! CheckTx(probe.ref, tx, "absolute-delay") - probe.watch(monitor.toClassic) - probe.expectNoMessage(100 millis) - - monitor ! Stop - probe.expectTerminated(monitor.toClassic, max = 5 seconds) - } - } 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 0af494ab92..63b311f8e3 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 @@ -22,7 +22,7 @@ import akka.testkit.{TestFSMRef, TestKitBase, TestProbe} import com.softwaremill.quicklens.ModifyPimp import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{ByteVector32, Crypto, SatoshiLong, ScriptFlags, Transaction} -import fr.acinq.eclair.TestConstants.{Alice, Bob, TestFeeEstimator} +import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeTargets, FeeratePerKw} 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 36d5469768..4a1df18823 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 @@ -19,7 +19,7 @@ package fr.acinq.eclair.channel.states.e import akka.actor.ActorRef import akka.testkit.TestProbe import fr.acinq.bitcoin.Crypto.PrivateKey -import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, SatoshiLong, Script, ScriptFlags, Transaction} +import fr.acinq.bitcoin.{ByteVector32, ByteVector64, Crypto, OutPoint, SatoshiLong, Script, ScriptFlags, Transaction} import fr.acinq.eclair.Features.StaticRemoteKey import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.UInt64.Conversions._ @@ -37,8 +37,8 @@ import fr.acinq.eclair.payment.OutgoingPaymentPacket import fr.acinq.eclair.payment.relay.Relayer._ import fr.acinq.eclair.router.Announcements import fr.acinq.eclair.transactions.DirectedHtlc.{incoming, outgoing} -import fr.acinq.eclair.transactions.Transactions.{DefaultCommitmentFormat, HtlcSuccessTx, weight2fee} -import fr.acinq.eclair.transactions.{CommitmentSpec, Transactions} +import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.protocol.{AnnouncementSignatures, ChannelUpdate, ClosingSigned, CommitSig, Error, FailureMessageCodecs, PermanentChannelFailure, RevokeAndAck, Shutdown, TemporaryNodeFailure, UpdateAddHtlc, UpdateFailHtlc, UpdateFailMalformedHtlc, UpdateFee, UpdateFulfillHtlc, Warning} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -2741,7 +2741,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // condition between his HTLC-success and Alice's HTLC-timeout val initialState = bob.stateData.asInstanceOf[DATA_NORMAL] val initialCommitTx = initialState.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx - val HtlcSuccessTx(_, htlcSuccessTx, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx + val HtlcSuccessTx(_, htlcSuccessTx, _, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx bob ! CMD_FULFILL_HTLC(htlc.id, r, commit = true) bob2alice.expectMsgType[UpdateFulfillHtlc] @@ -2774,7 +2774,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // condition between his HTLC-success and Alice's HTLC-timeout val initialState = bob.stateData.asInstanceOf[DATA_NORMAL] val initialCommitTx = initialState.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx - val HtlcSuccessTx(_, htlcSuccessTx, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx + val HtlcSuccessTx(_, htlcSuccessTx, _, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx bob ! CMD_FULFILL_HTLC(htlc.id, r, commit = false) bob2alice.expectMsgType[UpdateFulfillHtlc] @@ -2807,7 +2807,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // condition between his HTLC-success and Alice's HTLC-timeout val initialState = bob.stateData.asInstanceOf[DATA_NORMAL] val initialCommitTx = initialState.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx - val HtlcSuccessTx(_, htlcSuccessTx, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx + val HtlcSuccessTx(_, htlcSuccessTx, _, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx bob ! CMD_FULFILL_HTLC(htlc.id, r, commit = true) bob2alice.expectMsgType[UpdateFulfillHtlc] @@ -2928,14 +2928,14 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchFundingSpentTriggered (their commit w/ htlc)") { f => import f._ - val (ra1, htlca1) = addHtlc(250000000 msat, alice, bob, alice2bob, bob2alice) - val (ra2, htlca2) = addHtlc(100000000 msat, alice, bob, alice2bob, bob2alice) + val (ra1, htlca1) = addHtlc(250000000 msat, CltvExpiryDelta(50), alice, bob, alice2bob, bob2alice) + val (ra2, htlca2) = addHtlc(100000000 msat, CltvExpiryDelta(60), alice, bob, alice2bob, bob2alice) val (ra3, htlca3) = addHtlc(10000 msat, alice, bob, alice2bob, bob2alice) - val (rb1, htlcb1) = addHtlc(50000000 msat, bob, alice, bob2alice, alice2bob) - val (rb2, htlcb2) = addHtlc(55000000 msat, bob, alice, bob2alice, alice2bob) + val (rb1, htlcb1) = addHtlc(50000000 msat, CltvExpiryDelta(55), bob, alice, bob2alice, alice2bob) + val (rb2, htlcb2) = addHtlc(55000000 msat, CltvExpiryDelta(65), bob, alice, bob2alice, alice2bob) crossSign(alice, bob, alice2bob, bob2alice) - fulfillHtlc(1, ra2, bob, alice, bob2alice, alice2bob) - fulfillHtlc(0, rb1, alice, bob, alice2bob, bob2alice) + fulfillHtlc(htlca2.id, ra2, bob, alice, bob2alice, alice2bob) + fulfillHtlc(htlcb1.id, rb1, alice, bob, alice2bob, bob2alice) // at this point here is the situation from alice pov and what she should do when bob publishes his commit tx: // balances : @@ -2956,17 +2956,21 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // in response to that, alice publishes her claim txs 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].txInfo.tx) + val claimHtlcTxs = (1 to 3).map(_ => alice2blockchain.expectMsgType[PublishReplaceableTx]) val htlcAmountClaimed = (for (claimHtlcTx <- claimHtlcTxs) yield { - assert(claimHtlcTx.txIn.size == 1) - assert(claimHtlcTx.txOut.size == 1) - Transaction.correctlySpends(claimHtlcTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - claimHtlcTx.txOut.head.amount + 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 }).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 === 814880.sat) + // alice sets the confirmation targets to the HTLC expiry + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcSuccessTx, _) => (tx.htlcId, tx.confirmBefore.toLong) }.toMap === Map(htlcb1.id -> htlcb1.cltvExpiry.toLong)) + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _) => (tx.htlcId, tx.confirmBefore.toLong) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.txid) alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 1 @@ -3008,14 +3012,14 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchFundingSpentTriggered (their *next* commit w/ htlc)") { f => import f._ - val (ra1, htlca1) = addHtlc(250000000 msat, alice, bob, alice2bob, bob2alice) - val (ra2, htlca2) = addHtlc(100000000 msat, alice, bob, alice2bob, bob2alice) + val (ra1, htlca1) = addHtlc(250000000 msat, CltvExpiryDelta(24), alice, bob, alice2bob, bob2alice) + val (ra2, htlca2) = addHtlc(100000000 msat, CltvExpiryDelta(30), alice, bob, alice2bob, bob2alice) val (ra3, htlca3) = addHtlc(10000 msat, alice, bob, alice2bob, bob2alice) val (rb1, htlcb1) = addHtlc(50000000 msat, bob, alice, bob2alice, alice2bob) val (rb2, htlcb2) = addHtlc(55000000 msat, bob, alice, bob2alice, alice2bob) crossSign(alice, bob, alice2bob, bob2alice) - fulfillHtlc(1, ra2, bob, alice, bob2alice, alice2bob) - fulfillHtlc(0, rb1, alice, bob, alice2bob, bob2alice) + fulfillHtlc(htlca2.id, ra2, bob, alice, bob2alice, alice2bob) + fulfillHtlc(htlcb1.id, rb1, alice, bob, alice2bob, bob2alice) // alice sign but we intercept bob's revocation alice ! CMD_SIGN() alice2bob.expectMsgType[CommitSig] @@ -3043,17 +3047,20 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // in response to that, alice publishes her claim txs 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]) val htlcAmountClaimed = (for (claimHtlcTx <- claimHtlcTxs) yield { - assert(claimHtlcTx.txIn.size == 1) - assert(claimHtlcTx.txOut.size == 1) - Transaction.correctlySpends(claimHtlcTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - claimHtlcTx.txOut.head.amount + 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 }).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 === 822310.sat) + // alice sets the confirmation targets to the HTLC expiry + assert(claimHtlcTxs.collect { case PublishReplaceableTx(tx: ClaimHtlcTimeoutTx, _) => (tx.htlcId, tx.confirmBefore.toLong) }.toMap === Map(htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.txid) // claim-main alice2blockchain.expectMsgType[WatchOutputSpent] // htlc 1 @@ -3260,7 +3267,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(getHtlcTimeoutTxs(localCommitPublished).length === 2) assert(localCommitPublished.claimHtlcDelayedTxs.isEmpty) - // 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 htlc + // 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 // so we expect 4 transactions: // - 1 tx to claim the main delayed output // - 3 txs for each htlc @@ -3293,6 +3300,71 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with alice2blockchain.expectNoMessage(1 second) } + test("recv Error (anchor outputs zero fee htlc txs)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + + val (ra1, htlca1) = addHtlc(250000000 msat, CltvExpiryDelta(20), alice, bob, alice2bob, bob2alice) + val (ra2, htlca2) = addHtlc(100000000 msat, CltvExpiryDelta(25), alice, bob, alice2bob, bob2alice) + val (ra3, htlca3) = addHtlc(10000 msat, alice, bob, alice2bob, bob2alice) + val (rb1, htlcb1) = addHtlc(50000000 msat, CltvExpiryDelta(30), bob, alice, bob2alice, alice2bob) + val (rb2, htlcb2) = addHtlc(55000000 msat, CltvExpiryDelta(35), bob, alice, bob2alice, alice2bob) + crossSign(alice, bob, alice2bob, bob2alice) + fulfillHtlc(htlcb1.id, rb1, alice, bob, alice2bob, bob2alice) + + // an error occurs and alice publishes her commit tx + val aliceCommitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx + alice ! Error(ByteVector32.Zeroes, "oops") + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid === aliceCommitTx.txid) + assert(aliceCommitTx.txOut.size == 8) // two main outputs, two anchors and 4 pending htlcs + awaitCond(alice.stateName == CLOSING) + + val localAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(localAnchor.txInfo.confirmBefore.toLong === htlca1.cltvExpiry.toLong) // the target is set to match the first htlc that expires + val claimMain = alice2blockchain.expectMsgType[PublishFinalTx] + // 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 htlcConfirmationTargets = Seq( + alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 1 + alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 2 + alice2blockchain.expectMsgType[PublishReplaceableTx], // htlc 3 + ).map(p => p.txInfo.asInstanceOf[HtlcTx].htlcId -> p.txInfo.confirmBefore.toLong).toMap + assert(htlcConfirmationTargets === Map(htlcb1.id -> htlcb1.cltvExpiry.toLong, htlca1.id -> htlca1.cltvExpiry.toLong, htlca2.id -> htlca2.cltvExpiry.toLong)) + + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === aliceCommitTx.txid) + assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid) + 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], // local anchor + ).map(w => OutPoint(w.txId.reverse, 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) + } + + test("recv Error (anchor outputs zero fee htlc txs without htlcs)", Tag(ChannelStateTestsTags.AnchorOutputsZeroFeeHtlcTxs)) { f => + import f._ + + // an error occurs and alice publishes her commit tx + val aliceCommitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx + alice ! Error(ByteVector32.Zeroes, "oops") + assert(alice2blockchain.expectMsgType[PublishFinalTx].tx.txid === aliceCommitTx.txid) + assert(aliceCommitTx.txOut.size == 4) // two main outputs and two anchors + awaitCond(alice.stateName == CLOSING) + + val currentBlockHeight = alice.underlyingActor.nodeParams.currentBlockHeight + val blockTargets = alice.underlyingActor.nodeParams.onChainFeeConf.feeTargets + val localAnchor = alice2blockchain.expectMsgType[PublishReplaceableTx] + // When there are no pending HTLCs, there is no rush to get the commit tx confirmed + assert(localAnchor.txInfo.confirmBefore.toLong === currentBlockHeight + blockTargets.commitmentWithoutHtlcsBlockTarget) + val claimMain = alice2blockchain.expectMsgType[PublishFinalTx] + 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) + } + test("recv Error (nothing at stake)", Tag(ChannelStateTestsTags.NoPushMsat)) { f => import 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 ea8bbe70d5..40180d8ee0 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 @@ -20,7 +20,7 @@ import akka.actor.ActorRef import akka.testkit.{TestFSMRef, TestProbe} import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} import fr.acinq.bitcoin.{ByteVector32, ScriptFlags, Transaction} -import fr.acinq.eclair.TestConstants.{Alice, Bob, TestFeeEstimator} +import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.FeeratesPerKw import fr.acinq.eclair.blockchain.{CurrentBlockCount, CurrentFeerates} @@ -29,7 +29,7 @@ import fr.acinq.eclair.channel.publish.TxPublisher.{PublishFinalTx, PublishTx} import fr.acinq.eclair.channel.states.ChannelStateTestsBase import fr.acinq.eclair.transactions.Transactions.HtlcSuccessTx import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, MilliSatoshiLong, TestConstants, TestFeeEstimator, TestKitBaseClass, randomBytes32} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -529,7 +529,7 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val initialState = bob.stateData.asInstanceOf[DATA_NORMAL] val initialCommitTx = initialState.commitments.localCommit.commitTxAndRemoteSig.commitTx.tx - val HtlcSuccessTx(_, htlcSuccessTx, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx + val HtlcSuccessTx(_, htlcSuccessTx, _, _, _) = initialState.commitments.localCommit.htlcTxsAndRemoteSigs.head.htlcTx disconnect(alice, bob) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala index d2a63103d2..9c4d508936 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/g/NegotiatingStateSpec.scala @@ -27,7 +27,7 @@ import fr.acinq.eclair.channel.states.{ChannelStateTestsBase, ChannelStateTestsT import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire.protocol.ClosingSignedTlv.FeeRange import fr.acinq.eclair.wire.protocol.{ClosingSigned, Error, Shutdown, TlvStream, Warning} -import fr.acinq.eclair.{CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.{CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestFeeEstimator, TestKitBaseClass, randomBytes32} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} @@ -89,7 +89,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike } } - def setFeerate(feeEstimator: TestConstants.TestFeeEstimator, feerate: FeeratePerKw, minFeerate: FeeratePerKw = FeeratePerKw(250 sat)): Unit = { + def setFeerate(feeEstimator: TestFeeEstimator, feerate: FeeratePerKw, minFeerate: FeeratePerKw = FeeratePerKw(250 sat)): Unit = { feeEstimator.setFeerate(FeeratesPerKw.single(feerate).copy(mempoolMinFee = minFeerate, blocks_1008 = minFeerate)) } @@ -109,10 +109,10 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike import f._ // alice and bob see different on-chain feerates - alice.feeEstimator.setFeerate(FeeratesPerKw(FeeratePerKw(250 sat), FeeratePerKw(10000 sat), FeeratePerKw(5000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat))) - bob.feeEstimator.setFeerate(FeeratesPerKw(FeeratePerKw(250 sat), FeeratePerKw(15000 sat), FeeratePerKw(7500 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat))) - assert(alice.feeTargets.mutualCloseBlockTarget == 2) - assert(bob.feeTargets.mutualCloseBlockTarget == 2) + alice.feeEstimator.setFeerate(FeeratesPerKw(FeeratePerKw(250 sat), FeeratePerKw(10000 sat), FeeratePerKw(8000 sat), FeeratePerKw(7500 sat), FeeratePerKw(5000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat), FeeratePerKw(2000 sat))) + bob.feeEstimator.setFeerate(FeeratesPerKw(FeeratePerKw(250 sat), FeeratePerKw(15000 sat), FeeratePerKw(12500 sat), FeeratePerKw(10000 sat), FeeratePerKw(7500 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat), FeeratePerKw(3000 sat))) + assert(alice.feeTargets.mutualCloseBlockTarget == 12) + assert(bob.feeTargets.mutualCloseBlockTarget == 12) if (bobInitiates) { bobClose(f) @@ -494,7 +494,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike // alice starts with a very low proposal val (aliceClosing1, _) = makeLegacyClosingSigned(f, 500 sat) alice2bob.send(bob, aliceClosing1) - val bobClosing1 = bob2alice.expectMsgType[ClosingSigned] + bob2alice.expectMsgType[ClosingSigned] // at this point bob has received a mutual close signature from alice, but doesn't yet agree on the fee // bob's mutual close is published from the outside of the actor assert(bob.stateName === NEGOTIATING) 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 27b680ce0a..496918602d 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 @@ -30,7 +30,7 @@ import fr.acinq.eclair.payment.relay.Relayer._ import fr.acinq.eclair.transactions.Transactions.{AnchorOutputsCommitmentFormat, DefaultCommitmentFormat, HtlcSuccessTx, HtlcTimeoutTx, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat} import fr.acinq.eclair.transactions.{Scripts, Transactions} import fr.acinq.eclair.wire.protocol._ -import fr.acinq.eclair.{CltvExpiry, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, TimestampSecond, randomBytes32, randomKey} +import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshiLong, TestConstants, TestKitBaseClass, TimestampSecond, randomBytes32, randomKey} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.{Outcome, Tag} import scodec.bits.ByteVector @@ -752,12 +752,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchTxConfirmedTriggered (remote commit) followed by CMD_FULFILL_HTLC") { f => import f._ // An HTLC Bob -> Alice is cross-signed that will be fulfilled later. - val (r1, htlc1) = addHtlc(110000000 msat, bob, alice, bob2alice, alice2bob) + val (r1, htlc1) = addHtlc(110000000 msat, CltvExpiryDelta(48), bob, alice, bob2alice, alice2bob) crossSign(bob, alice, bob2alice, alice2bob) relayerA.expectMsgType[RelayForward] // An HTLC Alice -> Bob is only signed by Alice: Bob has two spendable commit tx. - val (_, htlc2) = addHtlc(95000000 msat, alice, bob, alice2bob, bob2alice) + val (_, htlc2) = addHtlc(95000000 msat, CltvExpiryDelta(144), alice, bob, alice2bob, bob2alice) alice ! CMD_SIGN() alice2bob.expectMsgType[CommitSig] // We stop here: Alice sent her CommitSig, but doesn't hear back from Bob. @@ -775,7 +775,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice2blockchain.expectMsgType[PublishFinalTx].tx === closingState.claimMainOutputTx.get.tx) val claimHtlcSuccessTx = getClaimHtlcSuccessTxs(alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get).head.tx Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx === claimHtlcSuccessTx) + val publishHtlcSuccessTx = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishHtlcSuccessTx.txInfo.tx === claimHtlcSuccessTx) + assert(publishHtlcSuccessTx.txInfo.confirmBefore.toLong === htlc1.cltvExpiry.toLong) // Alice resets watches on all relevant transactions. assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) @@ -800,7 +802,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ // alice sends an htlc to bob - addHtlc(50000000 msat, alice, bob, alice2bob, bob2alice) + val (_, htlca) = addHtlc(50000000 msat, CltvExpiryDelta(24), alice, bob, alice2bob, bob2alice) crossSign(alice, bob, alice2bob, bob2alice) val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.commitTxAndRemoteSig.commitTx.tx val closingState = remoteClose(bobCommitTx, alice, alice2blockchain) @@ -816,7 +818,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // we should re-publish unconfirmed transactions closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[PublishFinalTx].tx === claimMain.tx)) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx === htlcTimeoutTx.tx) + val publishHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishHtlcTimeoutTx.txInfo.tx === htlcTimeoutTx.tx) + assert(publishHtlcTimeoutTx.txInfo.confirmBefore.toLong === htlca.cltvExpiry.toLong) closingState.claimMainOutputTx.foreach(claimMain => assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === claimMain.tx.txid)) assert(alice2blockchain.expectMsgType[WatchOutputSpent].outputIndex === htlcTimeoutTx.input.outPoint.index) } @@ -919,12 +923,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with test("recv WatchTxConfirmedTriggered (next remote commit) followed by CMD_FULFILL_HTLC") { f => import f._ // An HTLC Bob -> Alice is cross-signed that will be fulfilled later. - val (r1, htlc1) = addHtlc(110000000 msat, bob, alice, bob2alice, alice2bob) + val (r1, htlc1) = addHtlc(110000000 msat, CltvExpiryDelta(64), bob, alice, bob2alice, alice2bob) crossSign(bob, alice, bob2alice, alice2bob) relayerA.expectMsgType[RelayForward] // An HTLC Alice -> Bob is only signed by Alice: Bob has two spendable commit tx. - val (_, htlc2) = addHtlc(95000000 msat, alice, bob, alice2bob, bob2alice) + val (_, htlc2) = addHtlc(95000000 msat, CltvExpiryDelta(32), alice, bob, alice2bob, bob2alice) alice ! CMD_SIGN() alice2bob.expectMsgType[CommitSig] alice2bob.forward(bob) @@ -946,8 +950,12 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(alice2blockchain.expectMsgType[PublishFinalTx].tx === closingState.claimMainOutputTx.get.tx) val claimHtlcSuccessTx = getClaimHtlcSuccessTxs(alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.get).head.tx Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx === claimHtlcSuccessTx) - assert(alice2blockchain.expectMsgType[PublishReplaceableTx].txInfo.tx === claimHtlcTimeoutTx) + val publishHtlcSuccessTx = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishHtlcSuccessTx.txInfo.tx === claimHtlcSuccessTx) + assert(publishHtlcSuccessTx.txInfo.confirmBefore.toLong === htlc1.cltvExpiry.toLong) + val publishHtlcTimeoutTx = alice2blockchain.expectMsgType[PublishReplaceableTx] + assert(publishHtlcTimeoutTx.txInfo.tx === claimHtlcTimeoutTx) + assert(publishHtlcTimeoutTx.txInfo.confirmBefore.toLong === htlc2.cltvExpiry.toLong) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === bobCommitTx.txid) assert(alice2blockchain.expectMsgType[WatchTxConfirmed].txId === closingState.claimMainOutputTx.get.tx.txid) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/interop/rustytests/RustyTestsSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/interop/rustytests/RustyTestsSpec.scala index 0865e8f048..6f969cc1e2 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/interop/rustytests/RustyTestsSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/interop/rustytests/RustyTestsSpec.scala @@ -20,7 +20,7 @@ import akka.actor.typed.scaladsl.adapter.actorRefAdapter import akka.actor.{ActorRef, Props} import akka.testkit.{TestFSMRef, TestKit, TestProbe} import fr.acinq.bitcoin.{ByteVector32, SatoshiLong} -import fr.acinq.eclair.TestConstants.{Alice, Bob, TestFeeEstimator} +import fr.acinq.eclair.TestConstants.{Alice, Bob} import fr.acinq.eclair.blockchain.DummyOnChainWallet import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw} @@ -29,7 +29,7 @@ import fr.acinq.eclair.channel.publish.TxPublisher import fr.acinq.eclair.channel.states.ChannelStateTestsHelperMethods.FakeTxPublisherFactory import fr.acinq.eclair.payment.receive.{ForwardHandler, PaymentHandler} import fr.acinq.eclair.wire.protocol.Init -import fr.acinq.eclair.{MilliSatoshiLong, TestKitBaseClass, TestUtils} +import fr.acinq.eclair.{MilliSatoshiLong, TestFeeEstimator, TestKitBaseClass, TestUtils} import org.scalatest.funsuite.FixtureAnyFunSuiteLike import org.scalatest.matchers.should.Matchers import org.scalatest.{BeforeAndAfterAll, Outcome} @@ -63,7 +63,7 @@ class RustyTestsSpec extends TestKitBaseClass with Matchers with FixtureAnyFunSu // we just bypass the relayer for this test val relayer = paymentHandler val wallet = new DummyOnChainWallet() - val feeEstimator = new TestFeeEstimator + val feeEstimator = new TestFeeEstimator() val aliceNodeParams = Alice.nodeParams.copy(blockCount = blockCount, onChainFeeConf = Alice.nodeParams.onChainFeeConf.copy(feeEstimator = feeEstimator)) val bobNodeParams = Bob.nodeParams.copy(blockCount = blockCount, onChainFeeConf = Bob.nodeParams.onChainFeeConf.copy(feeEstimator = feeEstimator)) val channelConfig = ChannelConfig.standard diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala index 10c546249f..e9962f5685 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/json/JsonSerializersSpec.scala @@ -218,24 +218,26 @@ class JsonSerializersSpec extends AnyFunSuite with Matchers { val dummyInputInfo = InputInfo(OutPoint(ByteVector32.Zeroes, 0), TxOut(Satoshi(0), Nil), Nil) val htlcSuccessTx = Transaction.read("0200000001c8a8934fb38a44b969528252bc37be66ee166c7897c57384d1e561449e110c93010000006b483045022100dc6c50f445ed53d2fb41067fdcb25686fe79492d90e6e5db43235726ace247210220773d35228af0800c257970bee9cf75175d75217de09a8ecd83521befd040c4ca012102082b751372fe7e3b012534afe0bb8d1f2f09c724b1a10a813ce704e5b9c217ccfdffffff0247ba2300000000001976a914f97a7641228e6b17d4b0b08252ae75bd62a95fe788ace3de24000000000017a914a9fefd4b9a9282a1d7a17d2f14ac7d1eb88141d287f7d50800") - val htlcSuccessTxInfo = HtlcSuccessTx(dummyInputInfo, htlcSuccessTx, ByteVector32.One, 3) + val htlcSuccessTxInfo = HtlcSuccessTx(dummyInputInfo, htlcSuccessTx, ByteVector32.One, 3, BlockHeight(1105)) val htlcSuccessJson = s"""{ | "txid": "${htlcSuccessTx.txid.toHex}", | "tx": "0200000001c8a8934fb38a44b969528252bc37be66ee166c7897c57384d1e561449e110c93010000006b483045022100dc6c50f445ed53d2fb41067fdcb25686fe79492d90e6e5db43235726ace247210220773d35228af0800c257970bee9cf75175d75217de09a8ecd83521befd040c4ca012102082b751372fe7e3b012534afe0bb8d1f2f09c724b1a10a813ce704e5b9c217ccfdffffff0247ba2300000000001976a914f97a7641228e6b17d4b0b08252ae75bd62a95fe788ace3de24000000000017a914a9fefd4b9a9282a1d7a17d2f14ac7d1eb88141d287f7d50800", | "paymentHash": "0100000000000000000000000000000000000000000000000000000000000000", - | "htlcId": 3 + | "htlcId": 3, + | "confirmBeforeBlock": 1105 |} """.stripMargin assertJsonEquals(JsonSerializers.serialization.write(htlcSuccessTxInfo)(JsonSerializers.formats), htlcSuccessJson) val claimHtlcTimeoutTx = Transaction.read("010000000110f01d4a4228ef959681feb1465c2010d0135be88fd598135b2e09d5413bf6f1000000006a473044022074658623424cebdac8290488b76f893cfb17765b7a3805e773e6770b7b17200102202892cfa9dda662d5eac394ba36fcfd1ea6c0b8bb3230ab96220731967bbdb90101210372d437866d9e4ead3d362b01b615d24cc0d5152c740d51e3c55fb53f6d335d82ffffffff01408b0700000000001976a914678db9a7caa2aca887af1177eda6f3d0f702df0d88ac00000000") - val claimHtlcTimeoutTxInfo = ClaimHtlcTimeoutTx(dummyInputInfo, claimHtlcTimeoutTx, 2) + val claimHtlcTimeoutTxInfo = ClaimHtlcTimeoutTx(dummyInputInfo, claimHtlcTimeoutTx, 2, BlockHeight(144)) val claimHtlcTimeoutJson = s"""{ | "txid": "${claimHtlcTimeoutTx.txid.toHex}", | "tx": "010000000110f01d4a4228ef959681feb1465c2010d0135be88fd598135b2e09d5413bf6f1000000006a473044022074658623424cebdac8290488b76f893cfb17765b7a3805e773e6770b7b17200102202892cfa9dda662d5eac394ba36fcfd1ea6c0b8bb3230ab96220731967bbdb90101210372d437866d9e4ead3d362b01b615d24cc0d5152c740d51e3c55fb53f6d335d82ffffffff01408b0700000000001976a914678db9a7caa2aca887af1177eda6f3d0f702df0d88ac00000000", - | "htlcId": 2 + | "htlcId": 2, + | "confirmBeforeBlock": 144 |} """.stripMargin assertJsonEquals(JsonSerializers.serialization.write(claimHtlcTimeoutTxInfo)(JsonSerializers.formats), claimHtlcTimeoutJson) 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 59b747503a..389596caca 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 @@ -197,8 +197,9 @@ class TransactionsSpec extends AnyFunSuite with Logging { // first we create a fake commitTx tx, containing only the output that will be spent by the ClaimAnchorOutputTx val pubKeyScript = write(pay2wsh(anchor(localFundingPriv.publicKey))) val commitTx = Transaction(version = 0, txIn = Nil, txOut = TxOut(anchorAmount, pubKeyScript) :: Nil, lockTime = 0) - val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx, localFundingPriv.publicKey) + val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx, localFundingPriv.publicKey, BlockHeight(1105)) assert(claimAnchorOutputTx.tx.txOut.isEmpty) + assert(claimAnchorOutputTx.confirmBefore === BlockHeight(1105)) // we will always add at least one input and one output to be able to set our desired feerate // we use dummy signatures to compute the weight val p2wpkhWitness = ScriptWitness(Seq(Scripts.der(PlaceHolderSig), PlaceHolderPubKey.value)) @@ -299,6 +300,8 @@ class TransactionsSpec extends AnyFunSuite with Logging { val htlcTxs = makeHtlcTxs(commitTx.tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, spec.htlcTxFeerate(DefaultCommitmentFormat), outputs, DefaultCommitmentFormat) assert(htlcTxs.length === 4) + val confirmationTargets = htlcTxs.map(tx => tx.htlcId -> tx.confirmBefore.toLong).toMap + assert(confirmationTargets === Map(0 -> 300, 1 -> 310, 2 -> 295, 3 -> 300)) val htlcSuccessTxs = htlcTxs.collect { case tx: HtlcSuccessTx => tx } val htlcTimeoutTxs = htlcTxs.collect { case tx: HtlcTimeoutTx => tx } assert(htlcTimeoutTxs.size == 2) // htlc1 and htlc3 @@ -525,6 +528,8 @@ class TransactionsSpec extends AnyFunSuite with Logging { val htlcTxs = makeHtlcTxs(commitTx.tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, spec.htlcTxFeerate(UnsafeLegacyAnchorOutputsCommitmentFormat), outputs, UnsafeLegacyAnchorOutputsCommitmentFormat) assert(htlcTxs.length === 5) + val confirmationTargets = htlcTxs.map(tx => tx.htlcId -> tx.confirmBefore.toLong).toMap + assert(confirmationTargets === Map(0 -> 300, 1 -> 310, 2 -> 310, 3 -> 295, 4 -> 300)) val htlcSuccessTxs = htlcTxs.collect { case tx: HtlcSuccessTx => tx } val htlcTimeoutTxs = htlcTxs.collect { case tx: HtlcTimeoutTx => tx } assert(htlcTimeoutTxs.size == 2) // htlc1 and htlc3 @@ -536,6 +541,8 @@ class TransactionsSpec extends AnyFunSuite with Logging { val zeroFeeCommitTx = makeCommitTx(commitInput, commitTxNumber, localPaymentPriv.publicKey, remotePaymentPriv.publicKey, localIsFunder = true, zeroFeeOutputs) val zeroFeeHtlcTxs = makeHtlcTxs(zeroFeeCommitTx.tx, localDustLimit, localRevocationPriv.publicKey, toLocalDelay, localDelayedPaymentPriv.publicKey, spec.htlcTxFeerate(ZeroFeeHtlcTxAnchorOutputsCommitmentFormat), zeroFeeOutputs, ZeroFeeHtlcTxAnchorOutputsCommitmentFormat) assert(zeroFeeHtlcTxs.length === 7) + val zeroFeeConfirmationTargets = zeroFeeHtlcTxs.map(tx => tx.htlcId -> tx.confirmBefore.toLong).toMap + assert(zeroFeeConfirmationTargets === Map(0 -> 300, 1 -> 310, 2 -> 310, 3 -> 295, 4 -> 300, 7 -> 300, 8 -> 302)) val zeroFeeHtlcSuccessTxs = zeroFeeHtlcTxs.collect { case tx: HtlcSuccessTx => tx } val zeroFeeHtlcTimeoutTxs = zeroFeeHtlcTxs.collect { case tx: HtlcTimeoutTx => tx } zeroFeeHtlcSuccessTxs.foreach(tx => assert(tx.fee === 0.sat)) @@ -569,7 +576,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { } { // local spends local anchor - val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx.tx, localFundingPriv.publicKey) + val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx.tx, localFundingPriv.publicKey, BlockHeight(0)) assert(checkSpendable(claimAnchorOutputTx).isFailure) val localSig = sign(claimAnchorOutputTx, localFundingPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(claimAnchorOutputTx, localSig) @@ -577,7 +584,7 @@ class TransactionsSpec extends AnyFunSuite with Logging { } { // remote spends remote anchor - val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx.tx, remoteFundingPriv.publicKey) + val Right(claimAnchorOutputTx) = makeClaimLocalAnchorOutputTx(commitTx.tx, remoteFundingPriv.publicKey, BlockHeight(0)) assert(checkSpendable(claimAnchorOutputTx).isFailure) val localSig = sign(claimAnchorOutputTx, remoteFundingPriv, TxOwner.Local, UnsafeLegacyAnchorOutputsCommitmentFormat) val signedTx = addSigs(claimAnchorOutputTx, localSig) 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 0355694819..b6fc95aa4e 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 @@ -19,11 +19,11 @@ import fr.acinq.bitcoin.{ByteVector32, Satoshi} import fr.acinq.eclair.FeatureSupport.{Mandatory, Optional} import fr.acinq.eclair.Features.{ChannelRangeQueries, PaymentSecret, VariableLengthOnion} import fr.acinq.eclair.channel._ -import fr.acinq.eclair.transactions.Transactions.{ClaimHtlcSuccessTx, LegacyClaimHtlcSuccessTx} +import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.wire.internal.channel.ChannelCodecsSpec.normal import fr.acinq.eclair.wire.internal.channel.version3.ChannelCodecs3.Codecs._ import fr.acinq.eclair.wire.internal.channel.version3.ChannelCodecs3.stateDataCodec -import fr.acinq.eclair.{CltvExpiryDelta, Features, MilliSatoshi, UInt64, randomKey} +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, Features, MilliSatoshi, UInt64, randomKey} import org.scalatest.funsuite.AnyFunSuite import scodec.bits.{ByteVector, HexStringSyntax} @@ -138,7 +138,7 @@ class ChannelCodecs3Spec extends AnyFunSuite { } // We can encode data that contains a payment hash. - val claimHtlcSuccess = ClaimHtlcSuccessTx(legacyClaimHtlcSuccess.input, legacyClaimHtlcSuccess.tx, ByteVector32(hex"0101010101010101010101010101010101010101010101010101010101010101"), legacyClaimHtlcSuccess.htlcId) + val claimHtlcSuccess = ClaimHtlcSuccessTx(legacyClaimHtlcSuccess.input, legacyClaimHtlcSuccess.tx, ByteVector32(hex"0101010101010101010101010101010101010101010101010101010101010101"), legacyClaimHtlcSuccess.htlcId, BlockHeight(0)) val txWithInputInfoCodecBin = txWithInputInfoCodec.encode(claimHtlcSuccess).require.bytes assert(txWithInputInfoCodecBin !== oldTxWithInputInfoCodecBin) val claimHtlcTxBin = claimHtlcTxCodec.encode(claimHtlcSuccess).require.bytes @@ -151,4 +151,52 @@ class ChannelCodecs3Spec extends AnyFunSuite { assert(claimHtlcSuccess === decoded2) } + test("backwards compatibility with transactions missing a confirmation target") { + { + val oldAnchorTxBin = hex"0011 24bd0be30e31c748c7afdde7d2c527d711fadf88500971a4a1136bca375dba07b8000000002b4a0100000000000022002036c067df8952dbcd5db347e7c152ca3fa4514f2072d27867837b1c2d319a7e01282103cc89f1459b5201cda08e08c6fb7b1968c54e8172c555896da27c6fdc10522ceeac736460b268330200000001bd0be30e31c748c7afdde7d2c527d711fadf88500971a4a1136bca375dba07b80000000000000000000000000000" + val oldAnchorTx = txWithInputInfoCodec.decode(oldAnchorTxBin.bits).require.value + assert(oldAnchorTx.isInstanceOf[ClaimLocalAnchorOutputTx]) + assert(oldAnchorTx.asInstanceOf[ClaimLocalAnchorOutputTx].confirmBefore === BlockHeight(0)) + val anchorTx = oldAnchorTx.asInstanceOf[ClaimLocalAnchorOutputTx].copy(confirmBefore = BlockHeight(1105)) + val anchorTx2 = txWithInputInfoCodec.decode(txWithInputInfoCodec.encode(anchorTx).require).require.value + assert(anchorTx === anchorTx2) + } + { + val oldHtlcSuccessTxBin = hex"0002 24f5580de0577271dce09d2de26e19ec58bf2373b0171473291a8f8be9b04fb289000000002bb0ad010000000000220020462cf8912ffc5f27764c109bed188950500011a2837ff8b9c8f9a39cffa395a58b76a91406b0950d9feded82239b3e6c9082308900f389de8763ac672102d65aa07658a7214ff129f91a1a22ade2ea4d1b07cc14b2f85a2842c34240836f7c8201208763a914c461c897e2165c7e44e14850dfcfd68f99127aed88527c21033c8d41cfbe1511a909b63fed68e75a29c3ce30418c39bbb8294c8b36c6a6c16a52ae677503101b06b175ac6868fd01a002000000000101f5580de0577271dce09d2de26e19ec58bf2373b0171473291a8f8be9b04fb289000000000000000000013a920100000000002200208742b16c9fd4e74854dcd84322dd1de06f7993fe627fd2ca0be4b996a936d56b050047304402201b4527c8f420852550af00bbd9149db9b31adcb7e1f127766e75e1e01746df0302202a57bb1e274ed7d3e8dbe5f205de721a23092c1e2ce2135f4750f18f6c0b51b001483045022100b6df309c8e5746a077b1f7c2f299528e164946bd514a5049475af7f5665805da0220392ae877112a3c52f74d190b354b4f5c020da9c1a71a7a08ced0a5363e795a27012017ea8f5afde8f708258d5669e1bbd454e82ddca8c6c480ec5302b4b1e8051d3d8b76a91406b0950d9feded82239b3e6c9082308900f389de8763ac672102d65aa07658a7214ff129f91a1a22ade2ea4d1b07cc14b2f85a2842c34240836f7c8201208763a914c461c897e2165c7e44e14850dfcfd68f99127aed88527c21033c8d41cfbe1511a909b63fed68e75a29c3ce30418c39bbb8294c8b36c6a6c16a52ae677503101b06b175ac686800000000dc7002a387673f17ebaf08545ccec712a9b6914813cdb83b4270932294f20f660000000000000000" + val oldHtlcSuccessTx = txWithInputInfoCodec.decode(oldHtlcSuccessTxBin.bits).require.value + assert(oldHtlcSuccessTx.isInstanceOf[HtlcSuccessTx]) + assert(oldHtlcSuccessTx.asInstanceOf[HtlcSuccessTx].confirmBefore === BlockHeight(0)) + val htlcSuccessTx = oldHtlcSuccessTx.asInstanceOf[HtlcSuccessTx].copy(confirmBefore = BlockHeight(1105)) + val htlcSuccessTx2 = txWithInputInfoCodec.decode(txWithInputInfoCodec.encode(htlcSuccessTx).require).require.value + assert(htlcSuccessTx === htlcSuccessTx2) + } + { + val oldHtlcTimeoutTxBin = hex"0003 248f0619a4b2a351977b3e5b0ddd700482e1d697d40deea2dd7356df99345d51d0000000002b50c300000000000022002005ff644937d7f5f32ec194424f551371e8d4bcf2cda3e1096cdd2fe88687fc408576a914c98707b6420ef3454f3bd10d663adcc04452baea8763ac672102fb20469287c8ade948011bd001440d74633d5e1a98574e4783dd38b76509d8f67c820120876475527c210279d3a1c2086a0968404a0160c8f8c6f88c0ce7184022bb7406f98fdb503ea51452ae67a9140550b2b1621e788d795fe3ae308e7dec06a6a1e088ac6868fd0179020000000001018f0619a4b2a351977b3e5b0ddd700482e1d697d40deea2dd7356df99345d51d0000000000000000000016aa9000000000000220020c980a1573ce6dc6a1bb8a1d60ecffdf2f0a7aa983c49786a2ab1ba8f0cd74a76050047304402200d2b631fb0e5a7f406f3de2b36fe3c0ab3337fe98f114dadf38de8f5548e87eb02203ad7c385c7cf62ac17ec15329dee071ee2c479aceb34a14d700dfeba80008574014730440220653b4ff490b03de06a9053aa7ab0b30e85c484c76900c586db65f7308f38ad86022019ac12ffb127e4a17a01dc6db730d3eccea837d2dc85a0275bdea8b393a2f11d01008576a914c98707b6420ef3454f3bd10d663adcc04452baea8763ac672102fb20469287c8ade948011bd001440d74633d5e1a98574e4783dd38b76509d8f67c820120876475527c210279d3a1c2086a0968404a0160c8f8c6f88c0ce7184022bb7406f98fdb503ea51452ae67a9140550b2b1621e788d795fe3ae308e7dec06a6a1e088ac6868101b06000000000000000003" + val oldHtlcTimeoutTx = txWithInputInfoCodec.decode(oldHtlcTimeoutTxBin.bits).require.value + assert(oldHtlcTimeoutTx.isInstanceOf[HtlcTimeoutTx]) + assert(oldHtlcTimeoutTx.asInstanceOf[HtlcTimeoutTx].confirmBefore === BlockHeight(0)) + val htlcTimeoutTx = oldHtlcTimeoutTx.asInstanceOf[HtlcTimeoutTx].copy(confirmBefore = BlockHeight(1105)) + val htlcTimeoutTx2 = txWithInputInfoCodec.decode(txWithInputInfoCodec.encode(htlcTimeoutTx).require).require.value + assert(htlcTimeoutTx === htlcTimeoutTx2) + } + { + val oldClaimHtlcSuccessTxBin = hex"0016 24e75b5236d1cdd482a6f540d5f08b9aa27b74a9ecae6e2622a67110b3ee1b3d89000000002bb0ad01000000000022002063e22369052a2bad9eb124737742690b8d1aba7693869d041da16443e2973e638576a91494957f4639ebc6f8a30e126552aff8429174dfb18763ac672102e1aa04ff55771238012edb958e6e0525af0415a01d52dd8d5f69fb391e586adc7c820120876475527c2102384e785d34b3b1fe35d3c093a750b234fbc79d8316c149e7845929f628a5baa052ae67a9145e3be49c9ace2d9eab11dc5fc29e40cd4148262e88ac6868fd014502000000000101e75b5236d1cdd482a6f540d5f08b9aa27b74a9ecae6e2622a67110b3ee1b3d890000000000000000000162970100000000001600140262586eef1a2c8f47ebc139a2733123d09e315603483045022100898f6b51361f044c8c54468dcb1b9decd75edcc1b69b62e584059b512eb075900220675f8b23aa402bb7d5bfdefbac4074088f82bd34d09b34c651c0dff48e6967930120efb38d645311af59c028cd8a9bf8ee21ff9e7c1a1cff1a0398a0315280247ac38576a91494957f4639ebc6f8a30e126552aff8429174dfb18763ac672102e1aa04ff55771238012edb958e6e0525af0415a01d52dd8d5f69fb391e586adc7c820120876475527c2102384e785d34b3b1fe35d3c093a750b234fbc79d8316c149e7845929f628a5baa052ae67a9145e3be49c9ace2d9eab11dc5fc29e40cd4148262e88ac686800000000ad02394dd18774f6f403f783afb518b6c69a89d531f718b29868ddca3e7905020000000000000000" + val oldClaimHtlcSuccessTx = txWithInputInfoCodec.decode(oldClaimHtlcSuccessTxBin.bits).require.value + assert(oldClaimHtlcSuccessTx.isInstanceOf[ClaimHtlcSuccessTx]) + assert(oldClaimHtlcSuccessTx.asInstanceOf[ClaimHtlcSuccessTx].confirmBefore === BlockHeight(0)) + val claimHtlcSuccessTx = oldClaimHtlcSuccessTx.asInstanceOf[ClaimHtlcSuccessTx].copy(confirmBefore = BlockHeight(1105)) + val claimHtlcSuccessTx2 = txWithInputInfoCodec.decode(txWithInputInfoCodec.encode(claimHtlcSuccessTx).require).require.value + assert(claimHtlcSuccessTx === claimHtlcSuccessTx2) + } + { + val oldClaimHtlcTimeoutTxBin = hex"0005 24df6aa4cd4e8877e4b3a363d6180951036d6f18ef214dabd50a6c05a60077d4d8000000002b50c30000000000002200205db892d76fb358ed508ec09c77b5a184cd9de3aa3e74a025a5c5d3f7adc221f78b76a914e0c241e0656088953f84475cbe5c70ded12e05b58763ac6721028876f3e23b21e07f889f10fc2aa0875b96021359a06d0b7f52a79fc284f6b2837c8201208763a9144ae5c96e8b7495fd35a5ca5681aa7c8f4ab6bc9b88527c2102b4d398ee7a42e87012de5a76832d9ebfa8532ef267490a7f3fe75b2c2e18cc9152ae677503981a06b175ac6868fd012b02000000000101df6aa4cd4e8877e4b3a363d6180951036d6f18ef214dabd50a6c05a60077d4d80000000000000000000106ae0000000000001600142b8e221121004b248f6551a0c6fb8ce219f4997e03483045022100ecdb8179f92c097594844756ac2bd7948f1c44ae74b54dfda86971800e59bf980220125edbe563f24cde15fa1fa25f4eac7cb938a3ace6f9329eb487938005c7621501008b76a914e0c241e0656088953f84475cbe5c70ded12e05b58763ac6721028876f3e23b21e07f889f10fc2aa0875b96021359a06d0b7f52a79fc284f6b2837c8201208763a9144ae5c96e8b7495fd35a5ca5681aa7c8f4ab6bc9b88527c2102b4d398ee7a42e87012de5a76832d9ebfa8532ef267490a7f3fe75b2c2e18cc9152ae677503981a06b175ac6868981a06000000000000000003" + val oldClaimHtlcTimeoutTx = txWithInputInfoCodec.decode(oldClaimHtlcTimeoutTxBin.bits).require.value + assert(oldClaimHtlcTimeoutTx.isInstanceOf[ClaimHtlcTimeoutTx]) + assert(oldClaimHtlcTimeoutTx.asInstanceOf[ClaimHtlcTimeoutTx].confirmBefore === BlockHeight(0)) + val claimHtlcTimeoutTx = oldClaimHtlcTimeoutTx.asInstanceOf[ClaimHtlcTimeoutTx].copy(confirmBefore = BlockHeight(1105)) + val claimHtlcTimeoutTx2 = txWithInputInfoCodec.decode(txWithInputInfoCodec.encode(claimHtlcTimeoutTx).require).require.value + assert(claimHtlcTimeoutTx === claimHtlcTimeoutTx2) + } + } + }