diff --git a/README.md b/README.md index 9edebaa1a9..4d4006e393 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ This means that instead of re-implementing them, Eclair benefits from the verifi * Eclair needs a _synchronized_, _segwit-ready_, **_zeromq-enabled_**, _wallet-enabled_, _non-pruning_, _tx-indexing_ [Bitcoin Core](https://github.com/bitcoin/bitcoin) node. * You must configure your Bitcoin node to use `bech32` or `bech32m` (segwit) addresses. If your wallet has "non-segwit UTXOs" (outputs that are neither `p2sh-segwit`, `bech32` or `bech32m`), you must send them to a `bech32` or `bech32m` address before running Eclair. -* Eclair requires Bitcoin Core 27.2 or higher. If you are upgrading an existing wallet, you may need to create a new address and send all your funds to that address. +* Eclair requires Bitcoin Core 28.1 or higher. If you are upgrading an existing wallet, you may need to create a new address and send all your funds to that address. Run bitcoind with the following minimal `bitcoin.conf`: diff --git a/docs/release-notes/eclair-vnext.md b/docs/release-notes/eclair-vnext.md index 5562da1032..cf16ba5fdd 100644 --- a/docs/release-notes/eclair-vnext.md +++ b/docs/release-notes/eclair-vnext.md @@ -4,7 +4,18 @@ ## Major changes - +### Update minimal version of Bitcoin Core + +With this release, eclair requires using Bitcoin Core 28.1. +Newer versions of Bitcoin Core may be used, but have not been extensively tested. + +### Package relay + +With Bitcoin Core 28.1, eclair starts relying on the `submitpackage` RPC during channel force-close. +When using anchor outputs, this allows replacing the remote commitment transaction with the local commitment transaction (and a CPFP child). +This allows propagating our local commitment transaction to peers who are also running Bitcoin Core 28.x or newer, even if the commitment feerate is low (package relay). + +This removes the need for increasing the commitment feerate based on mempool conditions, which ensures that channels won't be force-closed anymore when nodes disagree on the current feerate. ### API changes diff --git a/eclair-core/pom.xml b/eclair-core/pom.xml index 05526216a2..b49eba0414 100644 --- a/eclair-core/pom.xml +++ b/eclair-core/pom.xml @@ -88,9 +88,9 @@ true - https://bitcoincore.org/bin/bitcoin-core-27.2/bitcoin-27.2-x86_64-linux-gnu.tar.gz - c6dcec7ce5c43dafa48fe459911a8049 - 4342a03bbcc98d81fca2c4fb404f96d5dbae4e10 + https://bitcoincore.org/bin/bitcoin-core-28.0/bitcoin-28.0-x86_64-linux-gnu.tar.gz + 2c915b5ea3a7e6662dd059d720109d7a + e0fd253757e5f8d7d9c9cd73936e92cc6e168558 @@ -101,9 +101,9 @@ - https://bitcoincore.org/bin/bitcoin-core-27.2/bitcoin-27.2-x86_64-apple-darwin.tar.gz - 25857522febc428160bc4eedf46eb6db - 574d753359ef2b5c1bc0ef1e028d516da86392af + https://bitcoincore.org/bin/bitcoin-core-28.0/bitcoin-28.0-x86_64-apple-darwin.tar.gz + d4ad664051072de807d4a864d58ccd2a + f3e10c839a04929da870fea16829567d26dbecd8 @@ -114,9 +114,9 @@ - https://bitcoincore.org/bin/bitcoin-core-27.2/bitcoin-27.2-win64.zip - 1a05b7880a01c0437e5e0b7e13a02635 - 84c0b8d1a02d3c024881a180e8a3c670c1e0073a + https://bitcoincore.org/bin/bitcoin-core-28.0/bitcoin-28.0-win64.zip + 29748a873277cbb112b96c3662dcb3a4 + a7815c48d8f879f3728b50ad06a5fa1f1e10e0dc diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala index 73b2c0f358..bd5566b08e 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala @@ -188,7 +188,7 @@ class Setup(val datadir: File, await(getBitcoinStatus(bitcoinClient), 30 seconds, "bitcoind did not respond after 30 seconds") } logger.info(s"bitcoind version=${bitcoinStatus.version}") - assert(bitcoinStatus.version >= 270200, "Eclair requires Bitcoin Core 27.2 or higher") + assert(bitcoinStatus.version >= 280000, "Eclair requires Bitcoin Core 28.x or higher") bitcoinStatus.unspentAddresses.foreach { address => val isSegwit = addressToPublicKeyScript(bitcoinStatus.chainHash, address).map(script => Script.isNativeWitnessScript(script)).getOrElse(false) assert(isSegwit, s"Your wallet contains non-segwit UTXOs (e.g. address=$address). You must send those UTXOs to a segwit address to use Eclair (check out our README for more details).") diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala index 3544636678..40f73fb939 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala @@ -37,7 +37,7 @@ trait OnChainChannelFunder { * Fund the provided transaction by adding inputs (and a change output if necessary). * Callers must verify that the resulting transaction isn't sending funds to unexpected addresses (malicious bitcoin node). */ - def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long] = Map.empty, feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] + def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long] = Map.empty, feeBudget_opt: Option[Satoshi], minConfirmations_opt: Option[Int])(implicit ec: ExecutionContext): Future[FundTransactionResponse] /** * Sign a PSBT. Result may be partially signed: only inputs known to our bitcoin wallet will be signed. * diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala index 721e0179e3..52a4e03c17 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala @@ -26,7 +26,6 @@ import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFu import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{GetTxWithMetaResponse, UtxoStatus, ValidateResult} import fr.acinq.eclair.blockchain.fee.{FeeratePerKB, FeeratePerKw} import fr.acinq.eclair.crypto.keymanager.OnChainKeyManager -import fr.acinq.eclair.json.SatoshiSerializer import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire.protocol.ChannelAnnouncement import fr.acinq.eclair.{BlockHeight, TimestampSecond, TxCoordinates} @@ -262,8 +261,8 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onChainKeyManag }) } - def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long] = Map.empty, feeBudget_opt: Option[Satoshi] = None)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = { - fundTransaction(tx, FundTransactionOptions(feeRate, replaceable, inputWeights = externalInputsWeight.map { case (outpoint, weight) => InputWeight(outpoint, weight) }.toSeq), feeBudget_opt = feeBudget_opt) + def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long] = Map.empty, feeBudget_opt: Option[Satoshi] = None, minConfirmations_opt: Option[Int] = None)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = { + fundTransaction(tx, FundTransactionOptions(feeRate, replaceable, inputWeights = externalInputsWeight.map { case (outpoint, weight) => InputWeight(outpoint, weight) }.toSeq, minConfirmations = minConfirmations_opt), feeBudget_opt = feeBudget_opt) } private def processPsbt(psbt: Psbt, sign: Boolean = true, sighashType: Option[Int] = None)(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = { @@ -483,6 +482,25 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val onChainKeyManag getRawTransaction(tx.txid).map(_ => tx.txid).recoverWith { case _ => Future.failed(e) } } + /** + * Publish a 1-parent-1-child transaction package, which allows replacing a conflicting parent transaction that has + * the same (or a higher) feerate by leveraging CPFP. The child transaction cannot have other unconfirmed parents. + */ + def publishPackage(parentTx: Transaction, childTx: Transaction)(implicit ec: ExecutionContext): Future[TxId] = { + rpcClient.invoke("submitpackage", Seq(parentTx, childTx).map(_.toString())).flatMap(json => { + val JString(msg) = json \ "package_msg" + if (msg == "success") { + // All transactions were accepted into or are already in the mempool. + Future.successful(childTx.txid) + } else { + val childError = (json \ "tx-results" \ childTx.wtxid.toHex \ "error").extractOpt[String] + val parentError = (json \ "tx-results" \ parentTx.wtxid.toHex \ "error").extractOpt[String] + val error = childError.orElse(parentError).getOrElse("unknown failure") + Future.failed(new IllegalArgumentException(error)) + } + }) + } + override def abandon(txId: TxId)(implicit ec: ExecutionContext): Future[Boolean] = { rpcClient.invoke("abandontransaction", txId).map(_ => true).recover(_ => false) } @@ -704,10 +722,10 @@ object BitcoinCoreClient { def apply(outPoint: OutPoint, weight: Long): InputWeight = InputWeight(outPoint.txid.value.toHex, outPoint.index, weight) } - case class FundTransactionOptions(feeRate: BigDecimal, replaceable: Boolean, lockUnspents: Boolean, changePosition: Option[Int], input_weights: Option[Seq[InputWeight]]) + case class FundTransactionOptions(feeRate: BigDecimal, replaceable: Boolean, lockUnspents: Boolean, changePosition: Option[Int], minconf: Option[Int], input_weights: Option[Seq[InputWeight]]) object FundTransactionOptions { - def apply(feerate: FeeratePerKw, replaceable: Boolean = true, changePosition: Option[Int] = None, inputWeights: Seq[InputWeight] = Nil): FundTransactionOptions = { + def apply(feerate: FeeratePerKw, replaceable: Boolean = true, changePosition: Option[Int] = None, minConfirmations: Option[Int] = None, inputWeights: Seq[InputWeight] = Nil): FundTransactionOptions = { FundTransactionOptions( BigDecimal(FeeratePerKB(feerate).toLong).bigDecimal.scaleByPowerOfTen(-8), replaceable, @@ -721,6 +739,7 @@ object BitcoinCoreClient { // potentially be double-spent. lockUnspents = true, changePosition, + minConfirmations, if (inputWeights.isEmpty) None else Some(inputWeights) ) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala index 418ca53cac..190817d89d 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala @@ -237,7 +237,8 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response case p: SpliceTxRbf => p.feeBudget_opt case _ => None } - context.pipeToSelf(wallet.fundTransaction(txNotFunded, fundingParams.targetFeerate, replaceable = true, externalInputsWeight = sharedInputWeight, feeBudget_opt = feeBudget_opt)) { + val minConfirmations_opt = if (fundingParams.requireConfirmedInputs.forLocal) Some(1) else None + context.pipeToSelf(wallet.fundTransaction(txNotFunded, fundingParams.targetFeerate, replaceable = true, externalInputsWeight = sharedInputWeight, feeBudget_opt = feeBudget_opt, minConfirmations_opt)) { case Failure(t) => WalletFailure(t) case Success(result) => FundTransactionResult(result.tx, result.changePosition) } 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 6dba5078d2..7a0c7bca3f 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 @@ -113,7 +113,7 @@ private class FinalTxPublisher(nodeParams: NodeParams, def publish(): Behavior[Command] = { val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, txPublishContext), "mempool-tx-monitor") - txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), cmd.tx, cmd.input, cmd.desc, cmd.fee) + txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), cmd.tx, None, cmd.input, cmd.desc, cmd.fee) Behaviors.receiveMessagePartial { case WrappedTxResult(txResult) => txResult match { 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 4346645507..9b540f011a 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 @@ -38,7 +38,7 @@ object MempoolTxMonitor { // @formatter:off sealed trait Command - case class Publish(replyTo: ActorRef[TxResult], tx: Transaction, input: OutPoint, desc: String, fee: Satoshi) extends Command + case class Publish(replyTo: ActorRef[TxResult], tx: Transaction, parentTx_opt: Option[Transaction], input: OutPoint, desc: String, fee: Satoshi) extends Command private case object PublishOk extends Command private case class PublishFailed(reason: Throwable) extends Command private case class InputStatus(spentConfirmed: Boolean, spentUnconfirmed: Boolean) extends Command @@ -95,7 +95,10 @@ private class MempoolTxMonitor(nodeParams: NodeParams, private val log = context.log def publish(): Behavior[Command] = { - context.pipeToSelf(bitcoinClient.publishTransaction(cmd.tx)) { + context.pipeToSelf(cmd.parentTx_opt match { + case Some(parentTx) => bitcoinClient.publishPackage(parentTx, cmd.tx) + case None => bitcoinClient.publishTransaction(cmd.tx) + }) { case Success(_) => PublishOk case Failure(reason) => PublishFailed(reason) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala index d0be968257..ccb0c3c488 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala @@ -25,9 +25,10 @@ import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{FundTransactionOptions, InputWeight} import fr.acinq.eclair.blockchain.fee.{FeeratePerKw, FeeratesPerKw, OnChainFeeConf} -import fr.acinq.eclair.channel.FullCommitment import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._ import fr.acinq.eclair.channel.publish.TxPublisher.TxPublishContext +import fr.acinq.eclair.channel.{Commitment, FullCommitment, RemoteCommit} +import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.{NodeParams, NotificationsLogger} @@ -75,7 +76,7 @@ object ReplaceableTxFunder { Behaviors.withMdc(txPublishContext.mdc()) { Behaviors.receiveMessagePartial { case FundTransaction(replyTo, cmd, tx, requestedFeerate) => - val targetFeerate = requestedFeerate.min(maxFeerate(cmd.txInfo, cmd.commitment, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf)) + val targetFeerate = requestedFeerate.min(maxFeerate(nodeParams.channelKeyManager, cmd.txInfo, cmd.commitment, nodeParams.currentBitcoinCoreFeerates, nodeParams.onChainFeeConf)) val txFunder = new ReplaceableTxFunder(nodeParams, replyTo, cmd, bitcoinClient, context) tx match { case Right(txWithWitnessData) => txFunder.fund(txWithWitnessData, targetFeerate) @@ -86,17 +87,39 @@ object ReplaceableTxFunder { } } - private def commitWeight(commitment: FullCommitment): Int = { - val unsignedCommitTx = commitment.localCommit.commitTxAndRemoteSig.commitTx + /** Add dummy signatures to the commitment transaction spent by this anchor transaction. */ + private def dummySignedCommitTx(keyManager: ChannelKeyManager, anchorTx: ClaimLocalAnchorOutputTx, commitment: FullCommitment): CommitTx = { + val unsignedCommitTx = commitment.nextRemoteCommit_opt match { + case Some(nextRemoteCommit) if nextRemoteCommit.commit.txid == anchorTx.input.outPoint.txid => makeRemoteCommitTx(keyManager, nextRemoteCommit.commit, commitment) + case _ if commitment.remoteCommit.txid == anchorTx.input.outPoint.txid => makeRemoteCommitTx(keyManager, commitment.remoteCommit, commitment) + case _ => commitment.localCommit.commitTxAndRemoteSig.commitTx + } val dummySignedCommitTx = addSigs(unsignedCommitTx, PlaceHolderPubKey, PlaceHolderPubKey, PlaceHolderSig, PlaceHolderSig) - dummySignedCommitTx.tx.weight() + dummySignedCommitTx + } + + private def makeRemoteCommitTx(keyManager: ChannelKeyManager, remoteCommit: RemoteCommit, commitment: FullCommitment): CommitTx = { + val (remoteCommitTx, _) = Commitment.makeRemoteTxs( + keyManager, + commitment.params.channelConfig, + commitment.params.channelFeatures, + remoteCommit.index, + commitment.params.localParams, + commitment.params.remoteParams, + commitment.fundingTxIndex, + commitment.remoteFundingPubKey, + commitment.commitInput, + remoteCommit.remotePerCommitmentPoint, + remoteCommit.spec + ) + remoteCommitTx } /** * The on-chain feerate can be arbitrarily high, but it wouldn't make sense to pay more fees than the amount we're * trying to claim on-chain. We compute how much funds we have at risk and the feerate that matches this amount. */ - def maxFeerate(txInfo: ReplaceableTransactionWithInputInfo, commitment: FullCommitment, currentFeerates: FeeratesPerKw, feeConf: OnChainFeeConf): FeeratePerKw = { + def maxFeerate(keyManager: ChannelKeyManager, txInfo: ReplaceableTransactionWithInputInfo, commitment: FullCommitment, currentFeerates: FeeratesPerKw, feeConf: OnChainFeeConf): FeeratePerKw = { // We don't want to pay more in fees than the amount at risk in untrimmed pending HTLCs. val maxFee = txInfo match { case tx: HtlcTx => tx.input.txOut.amount @@ -117,7 +140,7 @@ object ReplaceableTxFunder { case _: ClaimHtlcSuccessTx => Transactions.claimHtlcSuccessWeight case _: LegacyClaimHtlcSuccessTx => Transactions.claimHtlcSuccessWeight case _: ClaimHtlcTimeoutTx => Transactions.claimHtlcTimeoutWeight - case _: ClaimLocalAnchorOutputTx => commitWeight(commitment) + Transactions.claimAnchorOutputMinWeight + case anchorTx: ClaimLocalAnchorOutputTx => dummySignedCommitTx(keyManager, anchorTx, commitment).tx.weight() + Transactions.claimAnchorOutputMinWeight } // It doesn't make sense to use a feerate that is much higher than the current feerate for inclusion into the next block. Transactions.fee2rate(maxFee, weight).min(currentFeerates.fastest * 1.25) @@ -162,12 +185,12 @@ object ReplaceableTxFunder { * 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, commitment: FullCommitment): AdjustPreviousTxOutputResult = { + def adjustPreviousTxOutput(keyManager: ChannelKeyManager, previousTx: FundedTx, targetFeerate: FeeratePerKw, commitment: FullCommitment): AdjustPreviousTxOutputResult = { val dustLimit = commitment.localParams.dustLimit val targetFee = previousTx.signedTxWithWitnessData match { - case _: ClaimLocalAnchorWithWitnessData => + case anchorTx: ClaimLocalAnchorWithWitnessData => val commitFee = commitment.localCommit.commitTxAndRemoteSig.commitTx.fee - val totalWeight = previousTx.signedTx.weight() + commitWeight(commitment) + val totalWeight = previousTx.signedTx.weight() + dummySignedCommitTx(keyManager, anchorTx.txInfo, commitment).tx.weight() weight2fee(targetFeerate, totalWeight) - commitFee case _ => weight2fee(targetFeerate, previousTx.signedTx.weight()) @@ -274,7 +297,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, private def bump(previousTx: FundedTx, targetFeerate: FeeratePerKw): Behavior[Command] = { log.info("bumping {} tx (targetFeerate={})", previousTx.signedTxWithWitnessData.txInfo.desc, targetFeerate) - adjustPreviousTxOutput(previousTx, targetFeerate, cmd.commitment) match { + adjustPreviousTxOutput(nodeParams.channelKeyManager, previousTx, targetFeerate, cmd.commitment) match { case AdjustPreviousTxOutputResult.Skip(reason) => log.warn("skipping {} fee bumping: {} (feerate={})", cmd.desc, reason, targetFeerate) replyTo ! FundingFailed(TxPublisher.TxRejectedReason.TxSkipped(retryNextBlock = true)) @@ -389,7 +412,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, case Right(signedTx) => val actualFees = kmp2scala(processPsbtResponse.psbt.computeFees()) val actualWeight = locallySignedTx match { - case _: ClaimLocalAnchorWithWitnessData => signedTx.weight() + commitWeight(cmd.commitment) + case anchorTx: ClaimLocalAnchorWithWitnessData => signedTx.weight() + dummySignedCommitTx(nodeParams.channelKeyManager, anchorTx.txInfo, cmd.commitment).tx.weight() case _ => signedTx.weight() } val actualFeerate = Transactions.fee2rate(actualFees, actualWeight) @@ -436,26 +459,34 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, } private def addInputs(anchorTx: ClaimLocalAnchorWithWitnessData, targetFeerate: FeeratePerKw, commitment: FullCommitment): Future[(ClaimLocalAnchorWithWitnessData, Satoshi)] = { - val dustLimit = commitment.localParams.dustLimit - // 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. - val txNotFunded = anchorTx.txInfo.tx.copy(txOut = TxOut(dustLimit, Script.pay2wpkh(PlaceHolderPubKey)) :: Nil) - val anchorWeight = Seq(InputWeight(anchorTx.txInfo.input.outPoint, anchorInputWeight)) - bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate, inputWeights = anchorWeight), feeBudget_opt = None).flatMap { fundTxResponse => - // Bitcoin Core may not preserve the order of inputs, we need to make sure the anchor is the first input. - val txIn = anchorTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn.filterNot(_.outPoint == anchorTx.txInfo.input.outPoint) - // We merge our dummy change output with the one added by Bitcoin Core, if any. + // We want to pay the commit fees using CPFP. Since the commit tx may not be in the mempool yet (its feerate may be + // below the minimum acceptable mempool feerate), we cannot ask bitcoind to fund a transaction that spends that + // commit tx: it would fail because it cannot find the input in the utxo set. So we instead ask bitcoind to fund an + // empty transaction that pays the fees we must add to the transaction package, and we then add the input spending + // the commit tx and adjust the change output. + val commitTx = dummySignedCommitTx(nodeParams.channelKeyManager, anchorTx.txInfo, commitment) + val expectedCommitFee = Transactions.weight2fee(targetFeerate, commitTx.tx.weight()) + val anchorInputFee = Transactions.weight2fee(targetFeerate, anchorInputWeight) + val missingFee = expectedCommitFee - commitTx.fee + anchorInputFee + val txNotFunded = Transaction(2, Nil, TxOut(commitment.localParams.dustLimit + missingFee, Script.pay2wpkh(PlaceHolderPubKey)) :: Nil, 0) + // We only use confirmed inputs for anchor transactions to be able to leverage 1-parent-1-child package relay. + bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate, minConfirmations = Some(1)), feeBudget_opt = None).flatMap { fundTxResponse => + // We merge our dummy change output with the one added by Bitcoin Core, if any, and adjust the change amount to + // pay the expected package feerate. + val txIn = anchorTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn + val packageWeight = commitTx.tx.weight() + anchorInputWeight + fundTxResponse.tx.weight() + val expectedFee = Transactions.weight2fee(targetFeerate, packageWeight) + val currentFee = commitTx.fee + fundTxResponse.fee + val changeAmount = (fundTxResponse.tx.txOut.map(_.amount).sum - expectedFee + currentFee).max(commitment.localParams.dustLimit) fundTxResponse.changePosition match { case Some(changePos) => - val changeOutput = fundTxResponse.tx.txOut(changePos).copy(amount = fundTxResponse.tx.txOut.map(_.amount).sum) + val changeOutput = fundTxResponse.tx.txOut(changePos).copy(amount = changeAmount) val txSingleOutput = fundTxResponse.tx.copy(txIn = txIn, txOut = Seq(changeOutput)) Future.successful(anchorTx.updateTx(txSingleOutput), fundTxResponse.amountIn) case None => bitcoinClient.getP2wpkhPubkeyHashForChange().map(pubkeyHash => { // We must have a change output, otherwise the transaction is invalid: we replace the PlaceHolderPubKey with a real wallet key. - val txSingleOutput = fundTxResponse.tx.copy(txIn = txIn, txOut = Seq(TxOut(dustLimit, Script.pay2wpkh(pubkeyHash)))) + val txSingleOutput = fundTxResponse.tx.copy(txIn = txIn, txOut = Seq(TxOut(changeAmount, Script.pay2wpkh(pubkeyHash)))) (anchorTx.updateTx(txSingleOutput), fundTxResponse.amountIn) }) } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala index a971cf7970..3ecac100a4 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPrePublisher.scala @@ -130,7 +130,6 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, // 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) - // - the local or remote commit tx is in the mempool (otherwise we can't claim our anchor) val fundingOutpoint = cmd.commitment.commitInput.outPoint context.pipeToSelf(bitcoinClient.getTxConfirmations(fundingOutpoint.txid).flatMap { case Some(_) => @@ -142,15 +141,13 @@ private class ReplaceableTxPrePublisher(nodeParams: NodeParams, if (remoteCommits.contains(localAnchorTx.input.outPoint.txid)) { // We're trying to bump the remote commit tx: we must make sure it is in our mempool first. bitcoinClient.getMempoolTx(localAnchorTx.input.outPoint.txid).map(_.txid).transformWith { - // We could improve this: we've seen the remote commit in our mempool at least once, so we could try to republish it ourselves. + // We could improve this: we've seen the remote commit in our mempool at least once, so we could republish it ourselves. case Failure(_) => Future.failed(RemoteCommitTxNotInMempool) case Success(remoteCommitTxId) => Future.successful(remoteCommitTxId) } } else { - // 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. - val commitTx = cmd.commitment.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx - bitcoinClient.publishTransaction(commitTx) + // We're trying to bump the local commit tx: no need to do anything, we will publish it alongside the anchor transaction. + Future.successful(cmd.commitment.localCommit.commitTxAndRemoteSig.commitTx.tx.txid) } } case None => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxPublisher.scala index bc68f87e93..144375e9b7 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 @@ -115,6 +115,12 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, private val log = context.log + /** True if we're trying to bump our local commit with an anchor transaction. */ + private val isLocalCommitAnchor = cmd.txInfo match { + case txInfo: Transactions.ClaimLocalAnchorOutputTx => txInfo.input.outPoint.txid == cmd.commitment.localCommit.commitTxAndRemoteSig.commitTx.tx.txid + case _ => false + } + /** The confirmation target may be updated in some corner cases (e.g. for a htlc if we learn a payment preimage). */ private var confirmationTarget: ConfirmationTarget = cmd.txInfo.confirmationTarget @@ -179,7 +185,11 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, case ConfirmationTarget.Priority(priority) => log.debug("publishing {} with priority {}", cmd.desc, priority) } val txMonitor = context.spawn(MempoolTxMonitor(nodeParams, bitcoinClient, txPublishContext), s"mempool-tx-monitor-${tx.signedTx.txid}") - txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, cmd.input, cmd.desc, tx.fee) + val parentTx_opt = cmd.txInfo match { + case _: Transactions.ClaimLocalAnchorOutputTx if isLocalCommitAnchor => Some(cmd.commitment.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx) + case _ => None + } + txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), tx.signedTx, parentTx_opt, cmd.input, cmd.desc, tx.fee) wait(tx) case ReplaceableTxFunder.FundingFailed(reason) => sendResult(TxPublisher.TxRejected(txPublishContext.id, cmd, reason), None) } @@ -287,7 +297,11 @@ private class ReplaceableTxPublisher(nodeParams: NodeParams, // 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, txPublishContext), s"mempool-tx-monitor-${bumpedTx.signedTx.txid}") - txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), bumpedTx.signedTx, cmd.input, cmd.desc, bumpedTx.fee) + val parentTx_opt = cmd.txInfo match { + case _: Transactions.ClaimLocalAnchorOutputTx if isLocalCommitAnchor => Some(cmd.commitment.fullySignedLocalCommitTx(nodeParams.channelKeyManager).tx) + case _ => None + } + txMonitor ! MempoolTxMonitor.Publish(context.messageAdapter[MempoolTxMonitor.TxResult](WrappedTxResult), bumpedTx.signedTx, parentTx_opt, cmd.input, cmd.desc, bumpedTx.fee) Behaviors.receiveMessagePartial { case WrappedTxResult(txResult) => txResult match { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala index 88089fa25a..f4e63c960c 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala @@ -50,7 +50,7 @@ class DummyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { override def getP2wpkhPubkey()(implicit ec: ExecutionContext): Future[Crypto.PublicKey] = Future.successful(dummyReceivePubkey) - override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = { + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long], feeBudget_opt: Option[Satoshi], minConfirmations_opt: Option[Int])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = { funded += (tx.txid -> tx) Future.successful(FundTransactionResponse(tx, 0 sat, None)) } @@ -105,7 +105,7 @@ class NoOpOnChainWallet extends OnChainWallet with OnchainPubkeyCache { override def getP2wpkhPubkey()(implicit ec: ExecutionContext): Future[Crypto.PublicKey] = Future.successful(dummyReceivePubkey) - override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = Promise().future // will never be completed + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long], feeBudget_opt: Option[Satoshi], minConfirmations_opt: Option[Int])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = Promise().future // will never be completed override def signPsbt(psbt: Psbt, ourInputs: Seq[Int], ourOutputs: Seq[Int])(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = Promise().future // will never be completed @@ -152,7 +152,7 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { override def getP2wpkhPubkey()(implicit ec: ExecutionContext): Future[Crypto.PublicKey] = Future.successful(pubkey) - override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = synchronized { + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, externalInputsWeight: Map[OutPoint, Long], feeBudget_opt: Option[Satoshi], minConfirmations_opt: Option[Int])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = synchronized { val currentAmountIn = tx.txIn.flatMap(txIn => inputs.find(_.txid == txIn.outPoint.txid).flatMap(_.txOut.lift(txIn.outPoint.index.toInt))).map(_.amount).sum val amountOut = tx.txOut.map(_.amount).sum // We add a single input to reach the desired feerate. @@ -225,7 +225,7 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw, feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { val tx = Transaction(2, Nil, Seq(TxOut(amount, pubkeyScript)), 0) for { - fundedTx <- fundTransaction(tx, feeRatePerKw, replaceable = true, feeBudget_opt = feeBudget_opt) + fundedTx <- fundTransaction(tx, feeRatePerKw, replaceable = true, feeBudget_opt = feeBudget_opt, minConfirmations_opt = None) signedTx <- signTransaction(fundedTx.tx) } yield MakeFundingTxResponse(signedTx.tx, 0, fundedTx.fee) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala index dfde6aa86e..6ace1ff4f9 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala @@ -194,6 +194,52 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A } } + test("fund transactions with confirmed inputs") { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + + val sender = TestProbe() + val miner = makeBitcoinCoreClient() + val wallet = new BitcoinCoreClient(createWallet("funding_confirmed_inputs", sender)) + wallet.getReceiveAddress().pipeTo(sender.ref) + val address = sender.expectMsgType[String] + val pubkeyScript = Script.write(addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, address).toOption.get) + + // We first receive some confirmed funds. + miner.sendToPubkeyScript(pubkeyScript, 150_000 sat, FeeratePerKw(FeeratePerByte(5 sat))).pipeTo(sender.ref) + val externalTxId = sender.expectMsgType[TxId] + generateBlocks(1) + + // Our utxo has 1 confirmation: we can spend it if we allow this confirmation count. + val tx1 = { + val txNotFunded = Transaction(2, Nil, Seq(TxOut(125_000 sat, pubkeyScript)), 0) + wallet.fundTransaction(txNotFunded, FundTransactionOptions(FeeratePerKw(1_000 sat), minConfirmations = Some(2)), feeBudget_opt = None).pipeTo(sender.ref) + assert(sender.expectMsgType[Failure].cause.getMessage.contains("Insufficient funds")) + wallet.fundTransaction(txNotFunded, FundTransactionOptions(FeeratePerKw(1_000 sat), minConfirmations = Some(1)), feeBudget_opt = None).pipeTo(sender.ref) + val unsignedTx = sender.expectMsgType[FundTransactionResponse].tx + wallet.signPsbt(new Psbt(unsignedTx), unsignedTx.txIn.indices, Nil).pipeTo(sender.ref) + val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get + wallet.publishTransaction(signedTx).pipeTo(sender.ref) + sender.expectMsg(signedTx.txid) + signedTx + } + assert(tx1.txIn.map(_.outPoint.txid).toSet == Set(externalTxId)) + + // We now have an unconfirmed utxo, which we can spend if we allow spending unconfirmed transactions. + val tx2 = { + val txNotFunded = Transaction(2, Nil, Seq(TxOut(100_000 sat, pubkeyScript)), 0) + wallet.fundTransaction(txNotFunded, FundTransactionOptions(FeeratePerKw(1_000 sat), minConfirmations = Some(1)), feeBudget_opt = None).pipeTo(sender.ref) + assert(sender.expectMsgType[Failure].cause.getMessage.contains("Insufficient funds")) + wallet.fundTransaction(txNotFunded, FundTransactionOptions(FeeratePerKw(1_000 sat), minConfirmations = None), feeBudget_opt = None).pipeTo(sender.ref) + val unsignedTx = sender.expectMsgType[FundTransactionResponse].tx + wallet.signPsbt(new Psbt(unsignedTx), unsignedTx.txIn.indices, Nil).pipeTo(sender.ref) + val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get + wallet.publishTransaction(signedTx).pipeTo(sender.ref) + sender.expectMsg(signedTx.txid) + signedTx + } + assert(tx2.txIn.map(_.outPoint.txid).toSet == Set(tx1.txid)) + } + test("fund transactions with external inputs") { import fr.acinq.bitcoin.scalacompat.KotlinUtils._ @@ -500,22 +546,26 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val pubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(randomKey().publicKey, randomKey().publicKey))) val bitcoinClient = makeBitcoinCoreClient() - // create a huge tx so we make sure it has > 2 inputs + // Create a huge tx so we make sure it has > 2 inputs without publishing it. bitcoinClient.makeFundingTx(pubkeyScript, 250 btc, FeeratePerKw(1000 sat)).pipeTo(sender.ref) - val MakeFundingTxResponse(fundingTx, outputIndex, _) = sender.expectMsgType[MakeFundingTxResponse] + val fundingTx = sender.expectMsgType[MakeFundingTxResponse].fundingTx assert(fundingTx.txIn.length > 2) - // spend the first 2 inputs + // Double-spend the first 2 inputs. + val amountIn = fundingTx.txIn.take(2).map(txIn => { + bitcoinClient.getTransaction(txIn.outPoint.txid).pipeTo(sender.ref) + sender.expectMsgType[Transaction].txOut(txIn.outPoint.index.toInt).amount + }).sum val tx1 = fundingTx.copy( txIn = fundingTx.txIn.take(2), - txOut = fundingTx.txOut.updated(outputIndex, fundingTx.txOut(outputIndex).copy(amount = 50 btc)) + txOut = Seq(TxOut(amountIn - 15_000.sat, Script.pay2wpkh(randomKey().publicKey))) ) bitcoinClient.signPsbt(new Psbt(tx1), tx1.txIn.indices, Nil).pipeTo(sender.ref) val tx2 = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get bitcoinClient.commit(tx2).pipeTo(sender.ref) sender.expectMsg(true) - // fundingTx inputs are still locked except for the first 2 that were just spent + // The inputs of the first transaction are still locked except for the first 2 that were just spent. val expectedLocks = fundingTx.txIn.drop(2).map(_.outPoint).toSet assert(expectedLocks.nonEmpty) awaitAssert({ @@ -523,11 +573,11 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A sender.expectMsg(expectedLocks) }, max = 10 seconds, interval = 1 second) - // publishing fundingTx will fail as its first 2 inputs are already spent by tx above in the mempool + // Publishing the first transaction will fail as its first 2 inputs are already spent by the second transaction. bitcoinClient.commit(fundingTx).pipeTo(sender.ref) sender.expectMsg(false) - // and all locked inputs should now be unlocked + // And all locked inputs should now be unlocked. awaitAssert({ bitcoinClient.listLockedOutpoints().pipeTo(sender.ref) sender.expectMsg(Set.empty[OutPoint]) @@ -594,15 +644,12 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A assert(sender.expectMsgType[Failure].cause.getMessage.contains("Transaction not in mempool")) wallet.getMempoolTx(anchorTx2.txid).pipeTo(sender.ref) sender.expectMsgType[MempoolTx] - val txNotFunded = Transaction(2, Nil, Seq(TxOut(150_000 sat, Script.pay2wpkh(priv.publicKey))), 0) - wallet.fundTransaction(txNotFunded, FeeratePerKw(1000 sat), replaceable = true).pipeTo(sender.ref) - assert(sender.expectMsgType[Failure].cause.getMessage.contains("Insufficient funds")) - // The second anchor transaction confirms, which frees up the wallet input of the first anchor transaction. - generateBlocks(1) + // Bitcoin Core automatically detects that the wallet input of the first anchor transaction is available again. wallet.listUnspent().pipeTo(sender.ref) val walletUtxos = sender.expectMsgType[Seq[Utxo]] assert(walletUtxos.exists(_.txid == walletInput1.txid)) + val txNotFunded = Transaction(2, Nil, Seq(TxOut(150_000 sat, Script.pay2wpkh(priv.publicKey))), 0) wallet.fundTransaction(txNotFunded, FeeratePerKw(1000 sat), replaceable = true).pipeTo(sender.ref) sender.expectMsgType[FundTransactionResponse] } @@ -668,15 +715,12 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A assert(sender.expectMsgType[Failure].cause.getMessage.contains("Transaction not in mempool")) miner.getMempoolTx(htlcTimeoutTx.txid).pipeTo(sender.ref) sender.expectMsgType[MempoolTx] - val txNotFunded = Transaction(2, Nil, Seq(TxOut(150_000 sat, Script.pay2wpkh(priv.publicKey))), 0) - wallet1.fundTransaction(txNotFunded, FeeratePerKw(1000 sat), replaceable = true).pipeTo(sender.ref) - assert(sender.expectMsgType[Failure].cause.getMessage.contains("Insufficient funds")) - // The second anchor transaction confirms, which frees up the wallet input of the first anchor transaction. - generateBlocks(1) + // Bitcoin Core automatically detects that the wallet input of the first HTLC transaction is available again. wallet1.listUnspent().pipeTo(sender.ref) val walletUtxos = sender.expectMsgType[Seq[Utxo]] assert(walletUtxos.exists(_.txid == walletInput1.txid)) + val txNotFunded = Transaction(2, Nil, Seq(TxOut(150_000 sat, Script.pay2wpkh(priv.publicKey))), 0) wallet1.fundTransaction(txNotFunded, FeeratePerKw(1000 sat), replaceable = true).pipeTo(sender.ref) sender.expectMsgType[FundTransactionResponse] } @@ -940,6 +984,78 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A bitcoinClient.publishTransaction(signTxResponse.finalTx_opt.toOption.get).pipeTo(sender.ref) sender.expectMsg(signTxResponse.finalTx_opt.toOption.get.txid) + generateBlocks(1) + } + + test("publish transaction package") { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + + val sender = TestProbe() + val bitcoinClient = makeBitcoinCoreClient() + + // The mempool contains a first parent transaction. + val priv1 = randomKey() + val parentTx1 = { + bitcoinClient.fundTransaction(Transaction(2, Nil, TxOut(300_000 sat, Script.pay2wpkh(priv1.publicKey)) :: Nil, 0), FundTransactionOptions(FeeratePerKw(1000 sat), changePosition = Some(1)), None).pipeTo(sender.ref) + val unsignedTx = sender.expectMsgType[FundTransactionResponse].tx + bitcoinClient.signPsbt(new Psbt(unsignedTx), unsignedTx.txIn.indices, Nil).pipeTo(sender.ref) + val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get + bitcoinClient.publishTransaction(signedTx).pipeTo(sender.ref) + sender.expectMsg(signedTx.txid) + signedTx + } + + // We create a conflicting transaction that pays the same feerate. + val priv2 = randomKey() + val parentTx2 = { + val unsignedTx = parentTx1.copy(txOut = TxOut(300_000 sat, Script.pay2wpkh(priv2.publicKey)) +: parentTx1.txOut.tail) + bitcoinClient.signPsbt(new Psbt(unsignedTx), unsignedTx.txIn.indices, Nil).pipeTo(sender.ref) + sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get + } + + // We cannot publish this transaction on its own, but we can publish a package using CPFP. + bitcoinClient.publishTransaction(parentTx2).pipeTo(sender.ref) + sender.expectMsgType[Failure] + val childTx2a = { + val unsignedTx = Transaction(2, Seq(TxIn(OutPoint(parentTx2, 0), Nil, 0)), Seq(TxOut(280_000 sat, Script.pay2wpkh(priv2.publicKey))), 0) + val sig = Transaction.signInput(unsignedTx, 0, Script.pay2pkh(priv2.publicKey), SigHash.SIGHASH_ALL, 300_000 sat, SigVersion.SIGVERSION_WITNESS_V0, priv2) + unsignedTx.updateWitness(0, Script.witnessPay2wpkh(priv2.publicKey, sig)) + } + bitcoinClient.publishTransaction(childTx2a).pipeTo(sender.ref) + sender.expectMsgType[Failure] + bitcoinClient.publishPackage(parentTx2, childTx2a).pipeTo(sender.ref) + sender.expectMsg(childTx2a.txid) + // The initial parent tx has been replaced. + bitcoinClient.getMempool().map(txs => txs.map(_.txid).toSet).pipeTo(sender.ref) + sender.expectMsg(Set(parentTx2.txid, childTx2a.txid)) + + // We can replace the child transaction to increase the package feerate (sibling eviction). + val childTx2b = { + val unsignedTx = Transaction(2, Seq(TxIn(OutPoint(parentTx2, 0), Nil, 0)), Seq(TxOut(275_000 sat, Script.pay2wpkh(priv2.publicKey))), 0) + val sig = Transaction.signInput(unsignedTx, 0, Script.pay2pkh(priv2.publicKey), SigHash.SIGHASH_ALL, 300_000 sat, SigVersion.SIGVERSION_WITNESS_V0, priv2) + unsignedTx.updateWitness(0, Script.witnessPay2wpkh(priv2.publicKey, sig)) + } + bitcoinClient.publishPackage(parentTx2, childTx2b).pipeTo(sender.ref) + sender.expectMsg(childTx2b.txid) + bitcoinClient.getMempool().map(txs => txs.map(_.txid).toSet).pipeTo(sender.ref) + sender.expectMsg(Set(parentTx2.txid, childTx2b.txid)) + + // We cannot replace the child transaction with the previous one that pays less fees. + bitcoinClient.publishPackage(parentTx2, childTx2a).pipeTo(sender.ref) + assert(sender.expectMsgType[Failure].cause.getMessage.contains("insufficient fee")) + bitcoinClient.getMempool().map(txs => txs.map(_.txid).toSet).pipeTo(sender.ref) + sender.expectMsg(Set(parentTx2.txid, childTx2b.txid)) + + // We can replace the whole package by a different package paying more fees. + val childTx1 = { + val unsignedTx = Transaction(2, Seq(TxIn(OutPoint(parentTx1, 0), Nil, 0)), Seq(TxOut(270_000 sat, Script.pay2wpkh(priv1.publicKey))), 0) + val sig = Transaction.signInput(unsignedTx, 0, Script.pay2pkh(priv1.publicKey), SigHash.SIGHASH_ALL, 300_000 sat, SigVersion.SIGVERSION_WITNESS_V0, priv1) + unsignedTx.updateWitness(0, Script.witnessPay2wpkh(priv1.publicKey, sig)) + } + bitcoinClient.publishPackage(parentTx1, childTx1).pipeTo(sender.ref) + sender.expectMsg(childTx1.txid) + bitcoinClient.getMempool().map(txs => txs.map(_.txid).toSet).pipeTo(sender.ref) + sender.expectMsg(Set(parentTx1.txid, childTx1.txid)) } test("send and list transactions") { diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala index 8f87d409ba..8c34e6610b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoindService.scala @@ -62,7 +62,7 @@ trait BitcoindService extends Logging { val PATH_BITCOIND = sys.env.get("BITCOIND_DIR") match { case Some(customBitcoinDir) => new File(customBitcoinDir, "bitcoind") - case None => new File(TestUtils.BUILD_DIRECTORY, "bitcoin-27.2/bin/bitcoind") + case None => new File(TestUtils.BUILD_DIRECTORY, "bitcoin-28.0/bin/bitcoind") } logger.info(s"using bitcoind: $PATH_BITCOIND") val PATH_BITCOIND_DATADIR = new File(INTEGRATION_TMP_DIR, "datadir-bitcoin") 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 36722db8be..d41bb5e6cb 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 @@ -79,7 +79,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi 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", 50 sat) + monitor ! Publish(probe.ref, tx, Some(parentTx), tx.txIn.head.outPoint, "test-tx", 50 sat) assert(eventListener.expectMsgType[TransactionPublished].tx == tx) waitTxInMempool(bitcoinClient, tx.txid, probe) @@ -97,7 +97,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi generateBlocks(1) val tx = createSpendP2WPKH(parentTx, priv, priv.publicKey, 1_000 sat, 0, 0) - monitor ! Publish(probe.ref, tx, tx.txIn.head.outPoint, "test-tx", 50 sat) + monitor ! Publish(probe.ref, tx, None, tx.txIn.head.outPoint, "test-tx", 50 sat) assert(eventListener.expectMsgType[TransactionPublished].tx == tx) waitTxInMempool(bitcoinClient, tx.txid, probe) @@ -126,7 +126,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi probe.expectMsg(tx1.txid) val tx2 = createSpendP2WPKH(parentTx, priv, priv.publicKey, 10_000 sat, 0, 0) - monitor ! Publish(probe.ref, tx2, tx2.txIn.head.outPoint, "test-tx", 10 sat) + monitor ! Publish(probe.ref, tx2, None, tx2.txIn.head.outPoint, "test-tx", 10 sat) waitTxInMempool(bitcoinClient, tx2.txid, probe) generateBlocks(TestConstants.Alice.nodeParams.channelConf.minDepthBlocks) @@ -143,7 +143,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi probe.expectMsg(tx1.txid) 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) + monitor ! Publish(probe.ref, tx2, Some(parentTx), tx2.txIn.head.outPoint, "test-tx", 25 sat) probe.expectMsg(TxRejected(tx2.txid, ConflictingTxUnconfirmed)) } @@ -157,7 +157,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi generateBlocks(1) 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) + monitor ! Publish(probe.ref, tx2, None, tx2.txIn.head.outPoint, "test-tx", 10 sat) probe.expectMsg(TxRejected(tx2.txid, ConflictingTxConfirmed)) } @@ -167,7 +167,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(randomTxId(), 13), Nil, 0))) - monitor ! Publish(probe.ref, txUnknownInput, txUnknownInput.txIn.head.outPoint, "test-tx", 10 sat) + monitor ! Publish(probe.ref, txUnknownInput, None, txUnknownInput.txIn.head.outPoint, "test-tx", 10 sat) probe.expectMsg(TxRejected(txUnknownInput.txid, InputGone)) } @@ -180,7 +180,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(randomTxId(), 13), Nil, 0))) - monitor ! Publish(probe.ref, txUnknownInput, txUnknownInput.txIn.head.outPoint, "test-tx", 10 sat) + monitor ! Publish(probe.ref, txUnknownInput, None, txUnknownInput.txIn.head.outPoint, "test-tx", 10 sat) probe.expectMsg(TxRejected(txUnknownInput.txid, InputGone)) } @@ -195,7 +195,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi generateBlocks(1) // we ensure the wallet input is already spent by a confirmed transaction 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) + monitor ! Publish(probe.ref, tx, None, tx.txIn.head.outPoint, "test-tx", 10 sat) probe.expectMsg(TxRejected(tx.txid, InputGone)) } @@ -204,7 +204,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi import f._ val tx1 = createSpendP2WPKH(parentTx, priv, priv.publicKey, 5_000 sat, 0, 0) - monitor ! Publish(probe.ref, tx1, tx1.txIn.head.outPoint, "test-tx", 0 sat) + monitor ! Publish(probe.ref, tx1, Some(parentTx), tx1.txIn.head.outPoint, "test-tx", 0 sat) waitTxInMempool(bitcoinClient, tx1.txid, probe) val tx2 = createSpendP2WPKH(parentTx, priv, priv.publicKey, 15_000 sat, 0, 0) @@ -221,7 +221,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi import f._ val tx1 = createSpendP2WPKH(parentTx, priv, priv.publicKey, 5_000 sat, 0, 0) - monitor ! Publish(probe.ref, tx1, tx1.txIn.head.outPoint, "test-tx", 10 sat) + monitor ! Publish(probe.ref, tx1, Some(parentTx), tx1.txIn.head.outPoint, "test-tx", 10 sat) waitTxInMempool(bitcoinClient, tx1.txid, probe) val tx2 = createSpendP2WPKH(parentTx, priv, priv.publicKey, 15_000 sat, 0, 0) @@ -244,7 +244,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi probe.expectMsg(walletTx.txid) val tx = createSpendManyP2WPKH(Seq(parentTx, walletTx), priv, priv.publicKey, 1_000 sat, 0, 0) - monitor ! Publish(probe.ref, tx, tx.txIn.head.outPoint, "test-tx", 10 sat) + monitor ! Publish(probe.ref, tx, None, tx.txIn.head.outPoint, "test-tx", 10 sat) waitTxInMempool(bitcoinClient, tx.txid, probe) // A transaction replaces our unconfirmed wallet input. @@ -266,7 +266,7 @@ class MempoolTxMonitorSpec extends TestKitBaseClass with AnyFunSuiteLike with Bi generateBlocks(1) val tx = createSpendP2WPKH(parentTx, priv, priv.publicKey, 1_000 sat, 0, 0) - monitor ! Publish(probe.ref, tx, tx.txIn.head.outPoint, "test-tx", 15 sat) + monitor ! Publish(probe.ref, tx, None, tx.txIn.head.outPoint, "test-tx", 15 sat) waitTxInMempool(bitcoinClient, tx.txid, probe) val txPublished = eventListener.expectMsgType[TransactionPublished] assert(txPublished.tx == tx) 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 index ff7591e67a..cf5778e0fc 100644 --- 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 @@ -16,14 +16,15 @@ package fr.acinq.eclair.channel.publish -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, OutPoint, SatoshiLong, Script, Transaction, TxId, TxIn, TxOut} +import fr.acinq.bitcoin.scalacompat.{Block, ByteVector32, Crypto, OutPoint, SatoshiLong, Script, Transaction, TxId, TxIn, TxOut} import fr.acinq.eclair.TestUtils.randomTxId import fr.acinq.eclair.blockchain.fee.{ConfirmationTarget, 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, FullCommitment, LocalCommit, LocalParams} +import fr.acinq.eclair.channel._ +import fr.acinq.eclair.crypto.keymanager.LocalChannelKeyManager import fr.acinq.eclair.transactions.Scripts import fr.acinq.eclair.transactions.Transactions._ import fr.acinq.eclair.{BlockHeight, CltvExpiry, TestKitBaseClass, randomBytes32} @@ -129,6 +130,7 @@ class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { } test("adjust previous anchor transaction outputs") { + val keyManager = new LocalChannelKeyManager(randomBytes32(), Block.RegtestGenesisBlock.hash) val (commitTx, initialAnchorTx) = createAnchorTx() val previousAnchorTx = ClaimLocalAnchorWithWitnessData(initialAnchorTx).updateTx(initialAnchorTx.tx.copy( txIn = Seq( @@ -148,22 +150,26 @@ class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { val localCommit = mock[LocalCommit] localCommit.commitTxAndRemoteSig.returns(CommitTxAndRemoteSig(commitTx, PlaceHolderSig)) commitment.localCommit.returns(localCommit) + val remoteCommit = mock[RemoteCommit] + remoteCommit.txid.returns(TxId(ByteVector32.Zeroes)) + commitment.remoteCommit.returns(remoteCommit) // 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), commitment) + val TxOutputAdjusted(feerateUpdate1) = adjustPreviousTxOutput(keyManager, FundedTx(previousAnchorTx, 12000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(5000 sat), commitment) 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), commitment) + val TxOutputAdjusted(feerateUpdate2) = adjustPreviousTxOutput(keyManager, FundedTx(previousAnchorTx, 12000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(6000 sat), commitment) 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), commitment) + val AddWalletInputs(previousTx) = adjustPreviousTxOutput(keyManager, FundedTx(previousAnchorTx, 12000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(10000 sat), commitment) assert(previousTx == previousAnchorTx) } test("adjust previous htlc transaction outputs", Tag("fuzzy")) { + val keyManager = new LocalChannelKeyManager(randomBytes32(), Block.RegtestGenesisBlock.hash) val commitment = mock[FullCommitment] val localParams = mock[LocalParams] localParams.dustLimit.returns(600 sat) @@ -186,11 +192,11 @@ class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { )) // 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), commitment) + val TxOutputAdjusted(feerateUpdate1) = adjustPreviousTxOutput(keyManager, FundedTx(previousTx, 15000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(5000 sat), commitment) 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), commitment) + val TxOutputAdjusted(feerateUpdate2) = adjustPreviousTxOutput(keyManager, FundedTx(previousTx, 15000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(6000 sat), commitment) 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) @@ -198,7 +204,7 @@ class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { // 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), commitment) + val AddWalletInputs(tx) = adjustPreviousTxOutput(keyManager, FundedTx(previousTxNoChange, 25000 sat, FeeratePerKw(2500 sat)), FeeratePerKw(5000 sat), commitment) assert(tx == previousTxNoChange) for (_ <- 1 to 100) { @@ -209,7 +215,7 @@ class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { TxOut(changeAmount, Script.pay2wpkh(PlaceHolderPubKey)) ))) val targetFeerate = FeeratePerKw(2500 sat) + FeeratePerKw(Random.nextInt(20000).sat) - adjustPreviousTxOutput(FundedTx(fuzzyPreviousTx, amountIn, FeeratePerKw(2500 sat)), targetFeerate, commitment) match { + adjustPreviousTxOutput(keyManager, FundedTx(fuzzyPreviousTx, amountIn, FeeratePerKw(2500 sat)), targetFeerate, commitment) match { case AdjustPreviousTxOutputResult.Skip(_) => // nothing do check case AddWalletInputs(tx) => assert(tx == fuzzyPreviousTx) case TxOutputAdjusted(updatedTx) => @@ -223,6 +229,7 @@ class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { } test("adjust previous claim htlc transaction outputs") { + val keyManager = new LocalChannelKeyManager(randomBytes32(), Block.RegtestGenesisBlock.hash) val commitment = mock[FullCommitment] val localParams = mock[LocalParams] localParams.dustLimit.returns(500 sat) @@ -232,7 +239,7 @@ class ReplaceableTxFunderSpec extends TestKitBaseClass with AnyFunSuiteLike { 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, commitment) match { + adjustPreviousTxOutput(keyManager, FundedTx(claimHtlc, claimHtlc.txInfo.amountIn, FeeratePerKw(2500 sat)), targetFeerate, commitment) match { case AdjustPreviousTxOutputResult.Skip(_) => assert(targetFeerate >= FeeratePerKw(10000 sat)) case AddWalletInputs(_) => fail("shouldn't add wallet inputs to claim-htlc-tx") case TxOutputAdjusted(updatedTx) => 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 84e161dfd5..f5dd477f41 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 @@ -327,22 +327,32 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w } } - test("remote commit tx published, not spending local anchor output") { + test("remote commit tx published, replacing it with local commit") { withFixture(Seq(500 millibtc), ChannelTypes.AnchorOutputsZeroFeeHtlcTx()) { f => import f._ val remoteCommit = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.latest.fullySignedLocalCommitTx(bob.underlyingActor.nodeParams.channelKeyManager) - val (_, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) + val (localCommit, anchorTx) = closeChannelWithoutHtlcs(f, aliceBlockHeight() + 12) wallet.publishTransaction(remoteCommit.tx).pipeTo(probe.ref) probe.expectMsg(remoteCommit.tx.txid) - setFeerate(FeeratePerKw(10_000 sat)) + val targetFeerate = FeeratePerKw(10_000 sat) + setFeerate(targetFeerate) publisher ! Publish(probe.ref, anchorTx) - val result = probe.expectMsgType[TxRejected] + val mempoolTxs = getMempoolTxs(2) + assert(mempoolTxs.map(_.txid).contains(localCommit.tx.txid)) + assert(!mempoolTxs.map(_.txid).contains(remoteCommit.tx.txid)) + + val targetFee = Transactions.weight2fee(targetFeerate, mempoolTxs.map(_.weight).sum.toInt) + val actualFee = mempoolTxs.map(_.fees).sum + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + + generateBlocks(5) + system.eventStream.publish(CurrentBlockHeight(currentBlockHeight(probe))) + val result = probe.expectMsgType[TxConfirmed] 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)) + assert(result.tx.txIn.map(_.outPoint.txid).contains(localCommit.tx.txid)) + assert(mempoolTxs.map(_.txid).contains(result.tx.txid)) } } @@ -496,7 +506,7 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w probe.expectMsg(commitTx.tx.txid) assert(getMempool().length == 1) - val maxFeerate = ReplaceableTxFunder.maxFeerate(anchorTx.txInfo, anchorTx.commitment, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) + val maxFeerate = ReplaceableTxFunder.maxFeerate(alice.underlyingActor.nodeParams.channelKeyManager, anchorTx.txInfo, anchorTx.commitment, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) val targetFeerate = FeeratePerKw(50_000 sat) assert(maxFeerate <= targetFeerate / 2) setFeerate(targetFeerate, blockTarget = 12) @@ -1168,12 +1178,12 @@ class ReplaceableTxPublisherSpec extends TestKitBaseClass with AnyFunSuiteLike w val (commitTx, htlcSuccess, htlcTimeout) = closeChannelWithHtlcs(f, aliceBlockHeight() + 30, outgoingHtlcAmount = 5_000_000 msat, incomingHtlcAmount = 4_000_000 msat) setFeerate(targetFeerate, blockTarget = 12) assert(htlcSuccess.txInfo.fee == 0.sat) - val htlcSuccessMaxFeerate = ReplaceableTxFunder.maxFeerate(htlcSuccess.txInfo, htlcSuccess.commitment, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) + val htlcSuccessMaxFeerate = ReplaceableTxFunder.maxFeerate(alice.underlyingActor.nodeParams.channelKeyManager, htlcSuccess.txInfo, htlcSuccess.commitment, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) assert(htlcSuccessMaxFeerate < targetFeerate / 2) val htlcSuccessTx = testPublishHtlcSuccess(f, commitTx, htlcSuccess, htlcSuccessMaxFeerate) assert(htlcSuccessTx.txIn.length > 1) assert(htlcTimeout.txInfo.fee == 0.sat) - val htlcTimeoutMaxFeerate = ReplaceableTxFunder.maxFeerate(htlcTimeout.txInfo, htlcTimeout.commitment, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) + val htlcTimeoutMaxFeerate = ReplaceableTxFunder.maxFeerate(alice.underlyingActor.nodeParams.channelKeyManager, htlcTimeout.txInfo, htlcTimeout.commitment, alice.underlyingActor.nodeParams.currentBitcoinCoreFeerates, alice.underlyingActor.nodeParams.onChainFeeConf) assert(htlcTimeoutMaxFeerate < targetFeerate / 2) val htlcTimeoutTx = testPublishHtlcTimeout(f, commitTx, htlcTimeout, htlcTimeoutMaxFeerate) assert(htlcTimeoutTx.txIn.length > 1)