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)
+ }
+ }
+
}