From 382ca7fbd193828a9b3cb2941ccea398a1336c94 Mon Sep 17 00:00:00 2001 From: t-bast Date: Mon, 22 Feb 2021 16:19:41 +0100 Subject: [PATCH 1/5] Set anchor output feerates when force-closing When using anchor outputs, the commitment feerate is kept low (<10 sat/byte). When we need to force-close a channel, we must ensure the commit tx and htlc txs confirm before a given deadline, so we need to increase their feerates. This is currently done only once, at broadcast time. We use CPFP for the commit tx and RBF for the htlc txs. If publishing fails because we don't have enough utxos available, it will be retried after the next block is confirmed. Note that it's still not recommended to activate anchor outputs. More work needs to be done on this fee bumping logic and utxos management. --- .../eclair/blockchain/WatcherTypes.scala | 16 +- .../blockchain/bitcoind/ZmqWatcher.scala | 205 ++++- .../bitcoind/rpc/ExtendedBitcoinClient.scala | 19 +- .../blockchain/electrum/ElectrumWatcher.scala | 10 +- .../fr/acinq/eclair/channel/Channel.scala | 108 ++- .../acinq/eclair/channel/ChannelTypes.scala | 7 +- .../fr/acinq/eclair/channel/Commitments.scala | 5 + .../fr/acinq/eclair/channel/Helpers.scala | 168 ++-- .../fr/acinq/eclair/io/Switchboard.scala | 2 +- .../relay/PostRestartHtlcCleaner.scala | 7 +- .../eclair/transactions/Transactions.scala | 25 +- .../bitcoind/ExtendedBitcoinClientSpec.scala | 16 +- .../blockchain/bitcoind/ZmqWatcherSpec.scala | 755 ++++++++++++++---- .../electrum/ElectrumWatcherSpec.scala | 6 +- .../eclair/channel/ChannelTypesSpec.scala | 448 ++++++++--- .../states/StateTestsHelperMethods.scala | 26 +- .../c/WaitForFundingConfirmedStateSpec.scala | 6 +- .../c/WaitForFundingLockedStateSpec.scala | 6 +- .../channel/states/e/NormalStateSpec.scala | 84 +- .../channel/states/e/OfflineStateSpec.scala | 8 +- .../channel/states/f/ShutdownStateSpec.scala | 28 +- .../states/g/NegotiatingStateSpec.scala | 10 +- .../channel/states/h/ClosingStateSpec.scala | 36 +- .../integration/ChannelIntegrationSpec.scala | 23 +- .../transactions/TransactionsSpec.scala | 15 +- 25 files changed, 1559 insertions(+), 480 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/WatcherTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/WatcherTypes.scala index d564a592db..cfc98103ce 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/WatcherTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/WatcherTypes.scala @@ -18,8 +18,10 @@ package fr.acinq.eclair.blockchain import akka.actor.ActorRef import fr.acinq.bitcoin.Crypto.PublicKey -import fr.acinq.bitcoin.{ByteVector32, Script, ScriptWitness, Transaction} +import fr.acinq.bitcoin.{ByteVector32, Satoshi, Script, ScriptWitness, Transaction} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.BitcoinEvent +import fr.acinq.eclair.transactions.Transactions.TransactionSigningKit import fr.acinq.eclair.wire.ChannelAnnouncement import scodec.bits.ByteVector @@ -136,8 +138,16 @@ final case class WatchEventSpentBasic(event: BitcoinEvent) extends WatchEvent // TODO: not implemented yet. final case class WatchEventLost(event: BitcoinEvent) extends WatchEvent -/** Publish the provided tx as soon as possible depending on locktime and csv */ -final case class PublishAsap(tx: Transaction) +sealed trait PublishStrategy +object PublishStrategy { + case object JustPublish extends PublishStrategy + case class SetFeerate(currentFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi, signingKit: TransactionSigningKit) extends PublishStrategy { + override def toString = s"SetFeerate(target=$targetFeerate)" + } +} + +/** Publish the provided tx as soon as possible depending on lock time, csv and publishing strategy. */ +final case class PublishAsap(tx: Transaction, strategy: PublishStrategy) sealed trait UtxoStatus object UtxoStatus { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala index 91b887e707..325c651103 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala @@ -23,14 +23,20 @@ import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl.adapter.ClassicActorContextOps import akka.actor.{Actor, ActorLogging, Cancellable, Props, Terminated} import akka.pattern.pipe +import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.DeterministicWallet.ExtendedPublicKey import fr.acinq.bitcoin._ import fr.acinq.eclair.KamonExt import fr.acinq.eclair.blockchain.Monitoring.Metrics import fr.acinq.eclair.blockchain._ import fr.acinq.eclair.blockchain.bitcoind.rpc.ExtendedBitcoinClient +import fr.acinq.eclair.blockchain.bitcoind.rpc.ExtendedBitcoinClient.{FundTransactionOptions, FundTransactionResponse} +import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.blockchain.watchdogs.BlockchainWatchdog -import fr.acinq.eclair.channel.BITCOIN_PARENT_TX_CONFIRMED -import fr.acinq.eclair.transactions.Scripts +import fr.acinq.eclair.channel.{BITCOIN_PARENT_TX_CONFIRMED, Commitments} +import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager +import fr.acinq.eclair.transactions.Transactions.{HtlcSuccessTx, HtlcTimeoutTx, TransactionSigningKit, TransactionWithInputInfo, weight2fee} +import fr.acinq.eclair.transactions.{Scripts, Transactions} import org.json4s.JsonAST.{JArray, JBool, JDecimal, JInt, JString} import scodec.bits.ByteVector @@ -62,6 +68,7 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend self ! TickNewBlock // @formatter:off + private case class PublishNextBlock(p: PublishAsap) private case class TriggerEvent(w: Watch, e: WatchEvent) private sealed trait AddWatchResult @@ -71,7 +78,7 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend def receive: Receive = watching(Set(), Map(), SortedMap(), None) - def watching(watches: Set[Watch], watchedUtxos: Map[OutPoint, Set[Watch]], block2tx: SortedMap[Long, Seq[Transaction]], nextTick: Option[Cancellable]): Receive = { + def watching(watches: Set[Watch], watchedUtxos: Map[OutPoint, Set[Watch]], block2tx: SortedMap[Long, Seq[PublishAsap]], nextTick: Option[Cancellable]): Receive = { case NewTransaction(tx) => log.debug("analyzing txid={} tx={}", tx.txid, tx) @@ -193,7 +200,7 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend case Ignore => () } - case PublishAsap(tx) => + case p@PublishAsap(tx, _) => val blockCount = this.blockCount.get() val cltvTimeout = Scripts.cltvTimeout(tx) val csvTimeouts = Scripts.csvTimeouts(tx) @@ -203,23 +210,28 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend csvTimeouts.foreach { case (parentTxId, csvTimeout) => log.info(s"txid=${tx.txid} has a relative timeout of $csvTimeout blocks, watching parentTxId=$parentTxId tx={}", tx) val parentPublicKeyScript = Script.write(Script.pay2wsh(tx.txIn.find(_.outPoint.txid == parentTxId).get.witness.stack.last)) - self ! WatchConfirmed(self, parentTxId, parentPublicKeyScript, minDepth = csvTimeout, BITCOIN_PARENT_TX_CONFIRMED(tx)) + self ! WatchConfirmed(self, parentTxId, parentPublicKeyScript, minDepth = csvTimeout, BITCOIN_PARENT_TX_CONFIRMED(p)) } } else if (cltvTimeout > blockCount) { log.info(s"delaying publication of txid=${tx.txid} until block=$cltvTimeout (curblock=$blockCount)") - val block2tx1 = block2tx.updated(cltvTimeout, block2tx.getOrElse(cltvTimeout, Seq.empty[Transaction]) :+ tx) + val block2tx1 = block2tx.updated(cltvTimeout, block2tx.getOrElse(cltvTimeout, Seq.empty[PublishAsap]) :+ p) context become watching(watches, watchedUtxos, block2tx1, nextTick) - } else publish(tx) + } else publish(p) - case WatchEventConfirmed(BITCOIN_PARENT_TX_CONFIRMED(tx), _, _, _) => + case WatchEventConfirmed(BITCOIN_PARENT_TX_CONFIRMED(p@PublishAsap(tx, _)), _, _, _) => log.info(s"parent tx of txid=${tx.txid} has been confirmed") val blockCount = this.blockCount.get() val cltvTimeout = Scripts.cltvTimeout(tx) if (cltvTimeout > blockCount) { log.info(s"delaying publication of txid=${tx.txid} until block=$cltvTimeout (curblock=$blockCount)") - val block2tx1 = block2tx.updated(cltvTimeout, block2tx.getOrElse(cltvTimeout, Seq.empty[Transaction]) :+ tx) + val block2tx1 = block2tx.updated(cltvTimeout, block2tx.getOrElse(cltvTimeout, Seq.empty[PublishAsap]) :+ p) context become watching(watches, watchedUtxos, block2tx1, nextTick) - } else publish(tx) + } else publish(p) + + case PublishNextBlock(p) => + val nextBlockCount = this.blockCount.get() + 1 + val block2tx1 = block2tx.updated(nextBlockCount, block2tx.getOrElse(nextBlockCount, Seq.empty[PublishAsap]) :+ p) + context become watching(watches, watchedUtxos, block2tx1, nextTick) case ValidateRequest(ann) => client.validate(ann).pipeTo(sender) @@ -239,13 +251,132 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend // CHANGING THIS WILL RESULT IN CONCURRENCY ISSUES WHILE PUBLISHING PARENT AND CHILD TXS val singleThreadExecutionContext = ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor()) - def publish(tx: Transaction, isRetry: Boolean = false): Unit = { - log.info(s"publishing tx (isRetry=$isRetry): txid=${tx.txid} tx={}", tx) - client.publishTransaction(tx)(singleThreadExecutionContext).recover { + def publish(p: PublishAsap): Future[ByteVector32] = { + p.strategy match { + case PublishStrategy.SetFeerate(currentFeerate, targetFeerate, dustLimit, signingKit) => + val spentOutpoint = signingKit match { + case signingKit: TransactionSigningKit.ClaimAnchorOutputSigningKit => signingKit.txWithInput.input.outPoint + case signingKit: TransactionSigningKit.HtlcTxSigningKit => signingKit.txWithInput.input.outPoint + } + log.info("publishing tx: input={}:{} txid={} tx={}", spentOutpoint.txid, spentOutpoint.index, p.tx.txid, p.tx) + val publishF = signingKit match { + case signingKit: TransactionSigningKit.ClaimAnchorOutputSigningKit => publishCommitWithAnchor(p.tx, currentFeerate, targetFeerate, dustLimit, signingKit) + case signingKit: TransactionSigningKit.HtlcTxSigningKit => publishHtlcTx(currentFeerate, targetFeerate, dustLimit, signingKit) + } + publishF.recoverWith { + case t: Throwable if t.getMessage.contains("(code: -4)") || t.getMessage.contains("(code: -6)") => + log.warning("not enough funds to publish tx, will retry next block: reason={} input={}:{} txid={}", t.getMessage, spentOutpoint.txid, spentOutpoint.index, p.tx.txid) + self ! PublishNextBlock(p) + Future.failed(t) + case t: Throwable => + log.error("cannot publish tx: reason={} input={}:{} txid={}", t.getMessage, spentOutpoint.txid, spentOutpoint.index, p.tx.txid) + Future.failed(t) + } + case PublishStrategy.JustPublish => + log.info("publishing tx: txid={} tx={}", p.tx.txid, p.tx) + publish(p.tx, isRetry = false) + } + } + + def publish(tx: Transaction, isRetry: Boolean): Future[ByteVector32] = { + client.publishTransaction(tx)(singleThreadExecutionContext).recoverWith { case t: Throwable if t.getMessage.contains("(code: -25)") && !isRetry => // we retry only once import akka.pattern.after - after(3 seconds, context.system.scheduler)(Future.successful({})).map(_ => publish(tx, isRetry = true)) - case t: Throwable => log.error("cannot publish tx: reason={} txid={} tx={}", t.getMessage, tx.txid, tx) + after(3 seconds, context.system.scheduler)(Future.successful({})).flatMap(_ => publish(tx, isRetry = true)) + case t: Throwable => + log.error("cannot publish tx: reason={} txid={}", t.getMessage, tx.txid) + Future.failed(t) + } + } + + def publishCommitWithAnchor(commitTx: Transaction, currentFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi, signingKit: TransactionSigningKit.ClaimAnchorOutputSigningKit): Future[ByteVector32] = { + import signingKit._ + if (targetFeerate <= currentFeerate) { + log.info(s"publishing commit tx without the anchor (current feerate=$currentFeerate): txid=${commitTx.txid}") + publish(commitTx, isRetry = false) + } else { + log.info(s"publishing commit tx with the anchor (target feerate=$targetFeerate): txid=${commitTx.txid}") + // 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 - currentFeerate.feerate) * commitTx.weight() / Transactions.claimAnchorOutputMinWeight + // NB: bitcoind requires txs to have at least one output, but we'll remove it later to keep a single change output. + // In case we have the perfect set of utxo amounts and no change output is added, we need the amount to be greater + // than the fee because we may need to deduce the fee from that output. + val dummyChangeAmount = Transactions.weight2fee(anchorFeerate, Transactions.claimAnchorOutputMinWeight) + dustLimit + publish(commitTx, isRetry = false).flatMap(commitTxId => { + val txNotFunded = Transaction(2, Nil, TxOut(dummyChangeAmount, Script.pay2wpkh(Transactions.PlaceHolderPubKey)) :: Nil, 0) + client.fundTransaction(txNotFunded, FundTransactionOptions(anchorFeerate))(singleThreadExecutionContext) + }).flatMap(fundTxResponse => { + // We merge the outputs if there's more than one. + fundTxResponse.changePosition match { + case Some(changePos) => + val changeOutput = fundTxResponse.tx.txOut(changePos.toInt) + val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(changeOutput.copy(amount = changeOutput.amount + dummyChangeAmount))) + Future.successful(fundTxResponse.copy(tx = txSingleOutput)) + case None => + client.getChangeAddress()(singleThreadExecutionContext).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 = txWithInput.copy(tx = fundTxResponse.tx.copy(txIn = txWithInput.tx.txIn.head +: fundTxResponse.tx.txIn)) + adjustAnchorOutputChange(unsignedTx, commitTx, fundTxResponse.amountIn + Transactions.AnchorOutputsCommitmentFormat.anchorAmount, currentFeerate, targetFeerate, dustLimit) + }).flatMap(claimAnchorTx => { + val claimAnchorSig = keyManager.sign(claimAnchorTx, localFundingPubKey, Transactions.TxOwner.Local, commitmentFormat) + val signedClaimAnchorTx = Transactions.addSigs(claimAnchorTx, claimAnchorSig) + val commitInfo = ExtendedBitcoinClient.PreviousTx(signedClaimAnchorTx.input, signedClaimAnchorTx.tx.txIn.head.witness) + client.signTransaction(signedClaimAnchorTx.tx, Seq(commitInfo))(singleThreadExecutionContext) + }).flatMap(signTxResponse => { + client.publishTransaction(signTxResponse.tx)(singleThreadExecutionContext) + }) + } + } + + def publishHtlcTx(currentFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi, signingKit: TransactionSigningKit.HtlcTxSigningKit): Future[ByteVector32] = { + import signingKit._ + if (targetFeerate <= currentFeerate) { + val localSig = keyManager.sign(txWithInput, localHtlcBasepoint, localPerCommitmentPoint, Transactions.TxOwner.Local, commitmentFormat) + val signedHtlcTx = addHtlcTxSigs(txWithInput, localSig, signingKit) + log.info("publishing htlc tx without adding inputs: txid={}", signedHtlcTx.tx.txid) + client.publishTransaction(signedHtlcTx.tx)(singleThreadExecutionContext) + } else { + log.info("publishing htlc tx with additional inputs: commit input={}:{} target feerate={}", txWithInput.input.outPoint.txid, txWithInput.input.outPoint.index, targetFeerate) + val txNotFunded = txWithInput.tx.copy(txIn = Nil, txOut = txWithInput.tx.txOut.head.copy(amount = dustLimit) :: Nil) + val htlcTxWeight = signingKit match { + case _: TransactionSigningKit.HtlcSuccessSigningKit => commitmentFormat.htlcSuccessWeight + case _: TransactionSigningKit.HtlcTimeoutSigningKit => commitmentFormat.htlcTimeoutWeight + } + // NB: bitcoind will add at least one P2WPKH input. + val weightRatio = htlcTxWeight.toDouble / (txNotFunded.weight() + Transactions.claimP2WPKHOutputWeight) + client.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate * weightRatio, changePosition = Some(1)))(singleThreadExecutionContext).map(fundTxResponse => { + log.info(s"added ${fundTxResponse.tx.txIn.length} wallet input(s) and ${fundTxResponse.tx.txOut.length - 1} wallet output(s) to htlc tx spending commit input=${txWithInput.input.outPoint.txid}:${txWithInput.input.outPoint.index}") + // We add the HTLC input (from the commit tx) and restore the HTLC output. + val txWithHtlcInput = fundTxResponse.tx.copy( + txIn = txWithInput.tx.txIn ++ fundTxResponse.tx.txIn, + txOut = txWithInput.tx.txOut ++ fundTxResponse.tx.txOut.tail + ) + val unsignedTx = signingKit match { + case htlcSuccess: TransactionSigningKit.HtlcSuccessSigningKit => htlcSuccess.txWithInput.copy(tx = txWithHtlcInput) + case htlcTimeout: TransactionSigningKit.HtlcTimeoutSigningKit => htlcTimeout.txWithInput.copy(tx = txWithHtlcInput) + } + adjustHtlcTxChange(unsignedTx, fundTxResponse.amountIn + unsignedTx.input.txOut.amount, targetFeerate, dustLimit, signingKit) + }).flatMap(unsignedTx => { + val localSig = keyManager.sign(unsignedTx, localHtlcBasepoint, localPerCommitmentPoint, Transactions.TxOwner.Local, commitmentFormat) + val signedHtlcTx = addHtlcTxSigs(unsignedTx, localSig, signingKit) + val inputInfo = ExtendedBitcoinClient.PreviousTx(signedHtlcTx.input, signedHtlcTx.tx.txIn.head.witness) + client.signTransaction(signedHtlcTx.tx, Seq(inputInfo), allowIncomplete = true)(singleThreadExecutionContext).flatMap(signTxResponse => { + // NB: bitcoind messes up the witness stack for our htlc input, so we need to restore it. + // See https://github.com/bitcoin/bitcoin/issues/21151 + val completeTx = signedHtlcTx.tx.copy(txIn = signedHtlcTx.tx.txIn.head +: signTxResponse.tx.txIn.tail) + log.info("publishing bumped htlc tx: commit input={}:{} txid={} tx={}", txWithInput.input.outPoint.txid, txWithInput.input.outPoint.index, completeTx.txid, completeTx) + client.publishTransaction(completeTx)(singleThreadExecutionContext) + }) + }) } } @@ -325,7 +456,7 @@ object ZmqWatcher { } /** - * The resulting map allows checking spent txes in constant time wrt number of watchers + * The resulting map allows checking spent txs in constant time wrt number of watchers */ def addWatchedUtxos(m: Map[OutPoint, Set[Watch]], w: Watch): Map[OutPoint, Set[Watch]] = { utxo(w) match { @@ -348,4 +479,46 @@ object ZmqWatcher { } } + /** Adjust the amount of the change output of an anchor tx to match our target feerate. */ + def adjustAnchorOutputChange(unsignedTx: Transactions.ClaimAnchorOutputTx, commitTx: Transaction, amountIn: Satoshi, currentFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi): Transactions.ClaimAnchorOutputTx = { + 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 = Transactions.addSigs(unsignedTx, Transactions.PlaceHolderSig) + // NB: we assume that our bitcoind wallet uses only P2WPKH inputs when funding txs. + val estimatedWeight = commitTx.weight() + dummySignedClaimAnchorTx.tx.weight() + Transactions.claimP2WPKHOutputWitnessWeight * (dummySignedClaimAnchorTx.tx.txIn.size - 1) + val targetFee = Transactions.weight2fee(targetFeerate, estimatedWeight) - Transactions.weight2fee(currentFeerate, commitTx.weight()) + val amountOut = dustLimit.max(amountIn - targetFee) + unsignedTx.copy(tx = unsignedTx.tx.copy(txOut = unsignedTx.tx.txOut.head.copy(amount = amountOut) :: Nil)) + } + + def addHtlcTxSigs(unsignedHtlcTx: Transactions.HtlcTx, localSig: ByteVector64, signingKit: TransactionSigningKit.HtlcTxSigningKit): Transactions.HtlcTx = { + signingKit match { + case htlcSuccess: TransactionSigningKit.HtlcSuccessSigningKit => + Transactions.addSigs(unsignedHtlcTx.asInstanceOf[HtlcSuccessTx], localSig, signingKit.remoteSig, htlcSuccess.preimage, signingKit.commitmentFormat) + case htlcTimeout: TransactionSigningKit.HtlcTimeoutSigningKit => + Transactions.addSigs(unsignedHtlcTx.asInstanceOf[HtlcTimeoutTx], localSig, signingKit.remoteSig, signingKit.commitmentFormat) + } + } + + /** Adjust the change output of an htlc tx to match our target feerate. */ + def adjustHtlcTxChange(unsignedTx: Transactions.HtlcTx, amountIn: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, signingKit: TransactionSigningKit.HtlcTxSigningKit): Transactions.HtlcTx = { + require(unsignedTx.tx.txOut.size <= 2, "funded transaction should have at most one change output") + val dummySignedTx = addHtlcTxSigs(unsignedTx, Transactions.PlaceHolderSig, signingKit) + // We adjust the change output to obtain the targeted feerate. + val estimatedWeight = dummySignedTx.tx.weight() + Transactions.claimP2WPKHOutputWitnessWeight * (dummySignedTx.tx.txIn.size - 1) + val targetFee = Transactions.weight2fee(targetFeerate, estimatedWeight) + val changeAmount = amountIn - dummySignedTx.tx.txOut.head.amount - targetFee + if (dummySignedTx.tx.txOut.length == 2 && changeAmount >= 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))) + } + } + } + } diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala index 54dba745cb..732e3d69a6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/ExtendedBitcoinClient.scala @@ -111,12 +111,20 @@ class ExtendedBitcoinClient(val rpcClient: BitcoinJsonRPCClient) { } } - def signTransaction(tx: Transaction, previousTxs: Seq[PreviousTx])(implicit ec: ExecutionContext): Future[SignTransactionResponse] = { + def signTransaction(tx: Transaction, previousTxs: Seq[PreviousTx], allowIncomplete: Boolean = false)(implicit ec: ExecutionContext): Future[SignTransactionResponse] = { rpcClient.invoke("signrawtransactionwithwallet", tx.toString(), previousTxs).map(json => { val JString(hex) = json \ "hex" val JBool(complete) = json \ "complete" - if (!complete) { - val message = (json \ "errors" \\ classOf[JString]).mkString(",") + // TODO: remove allowIncomplete once https://github.com/bitcoin/bitcoin/issues/21151 is fixed + if (!complete && !allowIncomplete) { + val JArray(errors) = json \ "errors" + val message = errors.map(error => { + val JString(txid) = error \ "txid" + val JInt(vout) = error \ "vout" + val JString(scriptSig) = error \ "scriptSig" + val JString(message) = error \ "error" + s"txid=$txid vout=$vout scriptSig=$scriptSig error=$message" + }).mkString(", ") throw JsonRPCError(Error(-1, message)) } SignTransactionResponse(Transaction.read(hex), complete) @@ -212,7 +220,7 @@ class ExtendedBitcoinClient(val rpcClient: BitcoinJsonRPCClient) { val JDecimal(descendantFees) = json \ "fees" \ "descendant" val JBool(replaceable) = json \ "bip125-replaceable" // NB: bitcoind counts the transaction itself as its own ancestor and descendant, which is confusing: we fix that by decrementing these counters. - MempoolTx(vsize.toLong, weight.toLong, replaceable, toSatoshi(fees), ancestorCount.toInt - 1, toSatoshi(ancestorFees), descendantCount.toInt - 1, toSatoshi(descendantFees)) + MempoolTx(txid, vsize.toLong, weight.toLong, replaceable, toSatoshi(fees), ancestorCount.toInt - 1, toSatoshi(ancestorFees), descendantCount.toInt - 1, toSatoshi(descendantFees)) }) } @@ -276,6 +284,7 @@ object ExtendedBitcoinClient { /** * Information about a transaction currently in the mempool. * + * @param txid transaction id. * @param vsize virtual transaction size as defined in BIP 141. * @param weight transaction weight as defined in BIP 141. * @param replaceable Whether this transaction could be replaced with RBF (BIP125). @@ -285,7 +294,7 @@ object ExtendedBitcoinClient { * @param descendantCount number of unconfirmed child transactions. * @param descendantFees transactions fees for the package consisting of this transaction and its unconfirmed children (without its unconfirmed parents). */ - case class MempoolTx(vsize: Long, weight: Long, replaceable: Boolean, fees: Satoshi, ancestorCount: Int, ancestorFees: Satoshi, descendantCount: Int, descendantFees: Satoshi) + case class MempoolTx(txid: ByteVector32, vsize: Long, weight: Long, replaceable: Boolean, fees: Satoshi, ancestorCount: Int, ancestorFees: Satoshi, descendantCount: Int, descendantFees: Satoshi) def toSatoshi(btcAmount: BigDecimal): Satoshi = Satoshi(btcAmount.bigDecimal.scaleByPowerOfTen(8).longValue) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcher.scala index 78f7f9c702..22e5d1205c 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcher.scala @@ -170,7 +170,7 @@ class ElectrumWatcher(blockCount: AtomicLong, client: ActorRef) extends Actor wi case ElectrumClient.ServerError(ElectrumClient.GetTransaction(txid, Some(origin: ActorRef)), _) => origin ! GetTxWithMetaResponse(txid, None, tip.time) - case PublishAsap(tx) => + case PublishAsap(tx, _) => val blockCount = this.blockCount.get() val cltvTimeout = Scripts.cltvTimeout(tx) val csvTimeouts = Scripts.csvTimeouts(tx) @@ -180,7 +180,7 @@ class ElectrumWatcher(blockCount: AtomicLong, client: ActorRef) extends Actor wi csvTimeouts.foreach { case (parentTxId, csvTimeout) => log.info(s"txid=${tx.txid} has a relative timeout of $csvTimeout blocks, watching parentTxId=$parentTxId tx={}", tx) val parentPublicKeyScript = WatchConfirmed.extractPublicKeyScript(tx.txIn.find(_.outPoint.txid == parentTxId).get.witness) - self ! WatchConfirmed(self, parentTxId, parentPublicKeyScript, minDepth = csvTimeout, BITCOIN_PARENT_TX_CONFIRMED(tx)) + self ! WatchConfirmed(self, parentTxId, parentPublicKeyScript, minDepth = csvTimeout, BITCOIN_PARENT_TX_CONFIRMED(PublishAsap(tx, PublishStrategy.JustPublish))) } } else if (cltvTimeout > blockCount) { log.info(s"delaying publication of txid=${tx.txid} until block=$cltvTimeout (curblock=$blockCount)") @@ -191,7 +191,7 @@ class ElectrumWatcher(blockCount: AtomicLong, client: ActorRef) extends Actor wi context become running(height, tip, watches, scriptHashStatus, block2tx, sent :+ tx) } - case WatchEventConfirmed(BITCOIN_PARENT_TX_CONFIRMED(tx), _, _, _) => + case WatchEventConfirmed(BITCOIN_PARENT_TX_CONFIRMED(PublishAsap(tx, _)), _, _, _) => log.info(s"parent tx of txid=${tx.txid} has been confirmed") val blockCount = this.blockCount.get() val cltvTimeout = Scripts.cltvTimeout(tx) @@ -214,8 +214,8 @@ class ElectrumWatcher(blockCount: AtomicLong, client: ActorRef) extends Actor wi case ElectrumClient.ElectrumDisconnected => // we remember watches and keep track of tx that have not yet been published - // we also re-send the txes that we previously sent but hadn't yet received the confirmation - context become disconnected(watches, sent.map(PublishAsap), block2tx, Queue.empty) + // we also re-send the txs that we previously sent but hadn't yet received the confirmation + context become disconnected(watches, sent.map(tx => PublishAsap(tx, PublishStrategy.JustPublish)), block2tx, Queue.empty) } def publish(tx: Transaction): Unit = { 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 28198f32cf..459d27da60 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 @@ -20,7 +20,7 @@ import akka.actor.{ActorRef, FSM, OneForOneStrategy, Props, Status, SupervisorSt import akka.event.Logging.MDC import akka.pattern.pipe import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.{ByteVector32, OutPoint, Satoshi, SatoshiLong, Script, ScriptFlags, Transaction} +import fr.acinq.bitcoin.{ByteVector32, Crypto, OutPoint, Satoshi, SatoshiLong, Script, ScriptFlags, Transaction} import fr.acinq.eclair.Logs.LogCategory import fr.acinq.eclair._ import fr.acinq.eclair.blockchain._ @@ -32,7 +32,7 @@ import fr.acinq.eclair.db.PendingRelayDb import fr.acinq.eclair.io.Peer import fr.acinq.eclair.payment.PaymentSettlingOnChain import fr.acinq.eclair.router.Announcements -import fr.acinq.eclair.transactions.Transactions.TxOwner +import fr.acinq.eclair.transactions.Transactions.{TransactionSigningKit, TxOwner} import fr.acinq.eclair.transactions._ import fr.acinq.eclair.wire._ import scodec.bits.ByteVector @@ -214,7 +214,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId case Some(c: Closing.MutualClose) => doPublish(c.tx) case Some(c: Closing.LocalClose) => - doPublish(c.localCommitPublished) + doPublish(c.localCommitPublished, closing.commitments) case Some(c: Closing.RemoteClose) => doPublish(c.remoteCommitPublished) case Some(c: Closing.RecoveryClose) => @@ -225,7 +225,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId // in all other cases we need to be ready for any type of closing watchFundingTx(data.commitments, closing.spendingTxes.map(_.txid).toSet) closing.mutualClosePublished.foreach(doPublish) - closing.localCommitPublished.foreach(doPublish) + closing.localCommitPublished.foreach(lcp => doPublish(lcp, closing.commitments)) closing.remoteCommitPublished.foreach(doPublish) closing.nextRemoteCommitPublished.foreach(doPublish) closing.revokedCommitPublished.foreach(doPublish) @@ -1103,6 +1103,16 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId Commitments.sendCommit(d.commitments, keyManager) match { case Right((commitments1, commit)) => log.debug("sending a new sig, spec:\n{}", Commitments.specs2String(commitments1)) + val nextRemoteCommit = commitments1.remoteNextCommitInfo.left.get.nextRemoteCommit + val nextCommitNumber = nextRemoteCommit.index + // we persist htlc data in order to be able to claim htlc outputs in case a revoked tx is published by our + // counterparty, so only htlcs above remote's dust_limit matter + val trimmedHtlcs = Transactions.trimOfferedHtlcs(d.commitments.remoteParams.dustLimit, nextRemoteCommit.spec, d.commitments.commitmentFormat) ++ + Transactions.trimReceivedHtlcs(d.commitments.remoteParams.dustLimit, nextRemoteCommit.spec, d.commitments.commitmentFormat) + trimmedHtlcs.map(_.add).foreach { htlc => + log.info(s"adding paymentHash=${htlc.paymentHash} cltvExpiry=${htlc.cltvExpiry} to htlcs db for commitNumber=$nextCommitNumber") + nodeParams.db.channels.addHtlcInfo(d.channelId, nextCommitNumber, htlc.paymentHash, htlc.cltvExpiry) + } context.system.eventStream.publish(ChannelSignatureSent(self, commitments1)) // we expect a quick response from our peer setTimer(RevocationTimeout.toString, RevocationTimeout(commitments1.remoteCommit.index, peer), timeout = nodeParams.revocationTimeout, repeat = false) @@ -1260,12 +1270,12 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId }) def republish(): Unit = { - localCommitPublished1.foreach(doPublish) + localCommitPublished1.foreach(lcp => doPublish(lcp, commitments1)) remoteCommitPublished1.foreach(doPublish) nextRemoteCommitPublished1.foreach(doPublish) } - stay using d.copy(commitments = commitments1, localCommitPublished = localCommitPublished1, remoteCommitPublished = remoteCommitPublished1, nextRemoteCommitPublished = nextRemoteCommitPublished1) storing() calling republish() + handleCommandSuccess(c, d.copy(commitments = commitments1, localCommitPublished = localCommitPublished1, remoteCommitPublished = remoteCommitPublished1, nextRemoteCommitPublished = nextRemoteCommitPublished1)) storing() calling republish() case Left(cause) => handleCommandError(cause, c) } @@ -1331,7 +1341,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId } val revokedCommitPublished1 = d.revokedCommitPublished.map { rev => val (rev1, tx_opt) = Closing.claimRevokedHtlcTxOutputs(keyManager, d.commitments, rev, tx, nodeParams.onChainFeeConf.feeEstimator) - tx_opt.foreach(claimTx => blockchain ! PublishAsap(claimTx)) + tx_opt.foreach(claimTx => blockchain ! PublishAsap(claimTx, PublishStrategy.JustPublish)) tx_opt.foreach(claimTx => blockchain ! WatchSpent(self, tx, claimTx.txIn.filter(_.outPoint.txid == tx.txid).head.outPoint.index.toInt, BITCOIN_OUTPUT_SPENT, hints = Set(claimTx.txid))) rev1 } @@ -1341,7 +1351,19 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId log.info(s"txid=${tx.txid} has reached mindepth, updating closing state") // first we check if this tx belongs to one of the current local/remote commits, update it and update the channel data val d1 = d.copy( - localCommitPublished = d.localCommitPublished.map(Closing.updateLocalCommitPublished(_, tx)), + localCommitPublished = d.localCommitPublished.map(localCommitPublished => d.commitments.commitmentFormat match { + case Transactions.AnchorOutputsCommitmentFormat => + // When using anchor outputs, the HTLC tx will be RBF-ed by the watcher, so we can only publish the claim-htlc-tx + // once the HTLC tx confirms (and its final txid is known). + val (localCommitPublished1, claimHtlcTx_opt) = Closing.claimLocalCommitHtlcTxOutput(localCommitPublished, keyManager, d.commitments, tx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) + claimHtlcTx_opt.foreach(claimHtlcTx => { + blockchain ! PublishAsap(claimHtlcTx, PublishStrategy.JustPublish) + blockchain ! WatchConfirmed(self, claimHtlcTx, nodeParams.minDepthBlocks, BITCOIN_TX_CONFIRMED(claimHtlcTx)) + }) + Closing.updateLocalCommitPublished(localCommitPublished1, tx) + case _ => + Closing.updateLocalCommitPublished(localCommitPublished, tx) + }), remoteCommitPublished = d.remoteCommitPublished.map(Closing.updateRemoteCommitPublished(_, tx)), nextRemoteCommitPublished = d.nextRemoteCommitPublished.map(Closing.updateRemoteCommitPublished(_, tx)), futureRemoteCommitPublished = d.futureRemoteCommitPublished.map(Closing.updateRemoteCommitPublished(_, tx)), @@ -1386,7 +1408,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId // and we also send events related to fee Closing.networkFeePaid(tx, d1) foreach { case (fee, desc) => feePaid(fee, tx, desc, d.channelId) } // then let's see if any of the possible close scenarii can be considered done - val closingType_opt = Closing.isClosed(d1, Some(tx)) + val closingType_opt = Closing.isClosed(keyManager, d1, Some(tx)) // finally, if one of the unilateral closes is done, we move to CLOSED state, otherwise we stay (note that we don't store the state) closingType_opt match { case Some(closingType) => @@ -1970,7 +1992,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId case Some(fundingTx) => // if we are funder, we never give up log.info(s"republishing the funding tx...") - blockchain ! PublishAsap(fundingTx) + blockchain ! PublishAsap(fundingTx, PublishStrategy.JustPublish) // we also check if the funding tx has been double-spent checkDoubleSpent(fundingTx) context.system.scheduler.scheduleOnce(1 day, blockchain, GetTxWithMeta(txid)) @@ -2111,7 +2133,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId } private def doPublish(closingTx: Transaction): Unit = { - blockchain ! PublishAsap(closingTx) + blockchain ! PublishAsap(closingTx, PublishStrategy.JustPublish) blockchain ! WatchConfirmed(self, closingTx, nodeParams.minDepthBlocks, BITCOIN_TX_CONFIRMED(closingTx)) } @@ -2133,20 +2155,20 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId case waitForFundingConfirmed: DATA_WAIT_FOR_FUNDING_CONFIRMED => DATA_CLOSING(d.commitments, fundingTx = waitForFundingConfirmed.fundingTx, waitingSinceBlock = nodeParams.currentBlockHeight, mutualCloseProposed = Nil, localCommitPublished = Some(localCommitPublished)) case _ => DATA_CLOSING(d.commitments, fundingTx = None, waitingSinceBlock = nodeParams.currentBlockHeight, mutualCloseProposed = Nil, localCommitPublished = Some(localCommitPublished)) } - goto(CLOSING) using nextData storing() calling doPublish(localCommitPublished) + goto(CLOSING) using nextData storing() calling doPublish(localCommitPublished, d.commitments) } } /** * This helper method will publish txs only if they haven't yet reached minDepth */ - private def publishIfNeeded(txs: Iterable[Transaction], irrevocablySpent: Map[OutPoint, ByteVector32]): Unit = { - val (skip, process) = txs.partition(Closing.inputsAlreadySpent(_, irrevocablySpent)) - process.foreach { tx => - log.info(s"publishing txid=${tx.txid}") - blockchain ! PublishAsap(tx) + private def publishIfNeeded(txs: Iterable[PublishAsap], irrevocablySpent: Map[OutPoint, ByteVector32]): Unit = { + val (skip, process) = txs.partition(publishTx => Closing.inputsAlreadySpent(publishTx.tx, irrevocablySpent)) + process.foreach { publishTx => + log.info(s"publishing txid=${publishTx.tx.txid}") + blockchain ! publishTx } - skip.foreach(tx => log.info(s"no need to republish txid=${tx.txid}, it has already been confirmed")) + skip.foreach(publishTx => log.info(s"no need to republish txid=${publishTx.tx.txid}, it has already been confirmed")) } /** @@ -2167,16 +2189,52 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId skip.foreach(tx => log.info(s"no need to watch txid=${tx.txid}, it has already been confirmed")) } - private def doPublish(localCommitPublished: LocalCommitPublished): Unit = { + private def doPublish(localCommitPublished: LocalCommitPublished, commitments: Commitments): Unit = { import localCommitPublished._ - val publishQueue = List(commitTx) ++ claimMainDelayedOutputTx ++ htlcSuccessTxs ++ htlcTimeoutTxs ++ claimHtlcDelayedTxs + val publishQueue = commitments.commitmentFormat match { + case Transactions.DefaultCommitmentFormat => + val txs = List(commitTx) ++ claimMainDelayedOutputTx ++ htlcSuccessTxs ++ htlcTimeoutTxs ++ claimHtlcDelayedTxs + txs.map(tx => PublishAsap(tx, PublishStrategy.JustPublish)) + case Transactions.AnchorOutputsCommitmentFormat => + val currentFeerate = commitments.localCommit.spec.feeratePerKw + val targetFeerate = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(nodeParams.onChainFeeConf.feeTargets.commitmentBlockTarget) + val localFundingPubKey = keyManager.fundingPublicKey(commitments.localParams.fundingKeyPath) + val channelKeyPath = keyManager.keyPath(commitments.localParams, commitments.channelVersion) + val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, commitments.localCommit.index) + val localHtlcBasepoint = keyManager.htlcPoint(channelKeyPath) + // If we have an anchor output available, we will use it to CPFP the commit tx. + val publishCommitTx = Transactions.makeClaimAnchorOutputTx(commitTx, localFundingPubKey.publicKey).map(claimAnchorOutputTx => { + TransactionSigningKit.ClaimAnchorOutputSigningKit(keyManager, commitments.commitmentFormat, claimAnchorOutputTx, localFundingPubKey) + }) match { + case Left(_) => PublishAsap(commitTx, PublishStrategy.JustPublish) + case Right(signingKit) => PublishAsap(commitTx, PublishStrategy.SetFeerate(currentFeerate, targetFeerate, commitments.localParams.dustLimit, signingKit)) + } + // HTLC txs will use RBF to add wallet inputs to reach the targeted feerate. + val preimages = commitments.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage } + val htlcTxs = commitments.localCommit.publishableTxs.htlcTxsAndSigs.collect { + case HtlcTxAndSigs(htlcSuccess: Transactions.HtlcSuccessTx, localSig, remoteSig) if preimages.exists(r => Crypto.sha256(r) == htlcSuccess.paymentHash) => + val preimage = preimages.find(r => Crypto.sha256(r) == htlcSuccess.paymentHash).get + val signedTx = Transactions.addSigs(htlcSuccess, localSig, remoteSig, preimage, commitments.commitmentFormat) + val signingKit = TransactionSigningKit.HtlcSuccessSigningKit(keyManager, commitments.commitmentFormat, signedTx, localHtlcBasepoint, localPerCommitmentPoint, remoteSig, preimage) + PublishAsap(signedTx.tx, PublishStrategy.SetFeerate(currentFeerate, targetFeerate, commitments.localParams.dustLimit, signingKit)) + case HtlcTxAndSigs(htlcTimeout: Transactions.HtlcTimeoutTx, localSig, remoteSig) => + val signedTx = Transactions.addSigs(htlcTimeout, localSig, remoteSig, commitments.commitmentFormat) + val signingKit = TransactionSigningKit.HtlcTimeoutSigningKit(keyManager, commitments.commitmentFormat, signedTx, localHtlcBasepoint, localPerCommitmentPoint, remoteSig) + PublishAsap(signedTx.tx, PublishStrategy.SetFeerate(currentFeerate, targetFeerate, commitments.localParams.dustLimit, signingKit)) + } + // NB: we don't publish the claimHtlcDelayedTxs: we will publish them once their parent htlc tx confirms. + List(publishCommitTx) ++ claimMainDelayedOutputTx.map(tx => PublishAsap(tx, PublishStrategy.JustPublish)) ++ htlcTxs + } publishIfNeeded(publishQueue, irrevocablySpent) // we watch: // - the commitment tx itself, so that we can handle the case where we don't have any outputs - // - 'final txes' that send funds to our wallet and that spend outputs that only us control - val watchConfirmedQueue = List(commitTx) ++ claimMainDelayedOutputTx ++ claimHtlcDelayedTxs + // - 'final txs' that send funds to our wallet and that spend outputs that only us control + val watchConfirmedQueue = commitments.commitmentFormat match { + case Transactions.DefaultCommitmentFormat => List(commitTx) ++ claimMainDelayedOutputTx ++ claimHtlcDelayedTxs + case Transactions.AnchorOutputsCommitmentFormat => List(commitTx) ++ claimMainDelayedOutputTx + } watchConfirmedIfNeeded(watchConfirmedQueue, irrevocablySpent) // we watch outputs of the commitment tx that both parties may spend @@ -2232,7 +2290,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId private def doPublish(remoteCommitPublished: RemoteCommitPublished): Unit = { import remoteCommitPublished._ - val publishQueue = claimMainOutputTx ++ claimHtlcSuccessTxs ++ claimHtlcTimeoutTxs + val publishQueue = (claimMainOutputTx ++ claimHtlcSuccessTxs ++ claimHtlcTimeoutTxs).map(tx => PublishAsap(tx, PublishStrategy.JustPublish)) publishIfNeeded(publishQueue, irrevocablySpent) // we watch: @@ -2271,7 +2329,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId private def doPublish(revokedCommitPublished: RevokedCommitPublished): Unit = { import revokedCommitPublished._ - val publishQueue = claimMainOutputTx ++ mainPenaltyTx ++ htlcPenaltyTxs ++ claimHtlcDelayedPenaltyTxs + val publishQueue = (claimMainOutputTx ++ mainPenaltyTx ++ htlcPenaltyTxs ++ claimHtlcDelayedPenaltyTxs).map(tx => PublishAsap(tx, PublishStrategy.JustPublish)) publishIfNeeded(publishQueue, irrevocablySpent) // we watch: @@ -2295,7 +2353,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId val commitTx = d.commitments.localCommit.publishableTxs.commitTx.tx val localCommitPublished = Helpers.Closing.claimCurrentLocalCommitTxOutputs(keyManager, d.commitments, commitTx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) - goto(ERR_INFORMATION_LEAK) calling doPublish(localCommitPublished) sending error + goto(ERR_INFORMATION_LEAK) calling doPublish(localCommitPublished, d.commitments) sending error } private def handleSync(channelReestablish: ChannelReestablish, d: HasCommitments): (Commitments, Queue[LightningMessage]) = { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala index 2e335fb1d3..5571c3db19 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/ChannelTypes.scala @@ -16,11 +16,10 @@ package fr.acinq.eclair.channel -import java.util.UUID - import akka.actor.{ActorRef, PossiblyHarmful} import fr.acinq.bitcoin.Crypto.PublicKey import fr.acinq.bitcoin.{ByteVector32, DeterministicWallet, OutPoint, Satoshi, Transaction} +import fr.acinq.eclair.blockchain.PublishAsap import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.payment.OutgoingPacket.Upstream import fr.acinq.eclair.router.Announcements @@ -30,6 +29,8 @@ import fr.acinq.eclair.wire.{AcceptChannel, ChannelAnnouncement, ChannelReestabl import fr.acinq.eclair.{CltvExpiry, CltvExpiryDelta, Features, MilliSatoshi, ShortChannelId, UInt64} import scodec.bits.{BitVector, ByteVector} +import java.util.UUID + /** * Created by PM on 20/05/2016. */ @@ -104,7 +105,7 @@ case object BITCOIN_FUNDING_SPENT extends BitcoinEvent case object BITCOIN_OUTPUT_SPENT extends BitcoinEvent case class BITCOIN_TX_CONFIRMED(tx: Transaction) extends BitcoinEvent case class BITCOIN_FUNDING_EXTERNAL_CHANNEL_SPENT(shortChannelId: ShortChannelId) extends BitcoinEvent -case class BITCOIN_PARENT_TX_CONFIRMED(childTx: Transaction) extends BitcoinEvent +case class BITCOIN_PARENT_TX_CONFIRMED(publishChildTx: PublishAsap) extends BitcoinEvent /* .d8888b. .d88888b. 888b d888 888b d888 d8888 888b 888 8888888b. .d8888b. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala index ee66917be3..e0d7af8987 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/Commitments.scala @@ -268,6 +268,11 @@ object Commitments { * @return either Left(failure, error message) where failure is a failure message (see BOLT #4 and the Failure Message class) or Right(new commitments, updateAddHtlc) */ def sendAdd(commitments: Commitments, cmd: CMD_ADD_HTLC, blockHeight: Long, feeConf: OnChainFeeConf): Either[ChannelException, (Commitments, UpdateAddHtlc)] = { + // our counterparty needs a reasonable amount of time to pull the funds from downstream before we can get refunded (see BOLT 2 and BOLT 11 for a calculation and rationale) + val minExpiry = CltvExpiry(blockHeight) + if (cmd.cltvExpiry <= minExpiry) { + return Left(ExpiryTooSmall(commitments.channelId, minimum = minExpiry, actual = cmd.cltvExpiry, blockCount = blockHeight)) + } // we don't want to use too high a refund timeout, because our funds will be locked during that time if the payment is never fulfilled val maxExpiry = Channel.MAX_CLTV_EXPIRY_DELTA.toCltvExpiry(blockHeight) if (cmd.cltvExpiry >= maxExpiry) { 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 426fb21336..2bac56b4b0 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 @@ -331,7 +331,6 @@ object Helpers { } } - object Closing { // @formatter:off @@ -391,16 +390,16 @@ object Helpers { * because we don't store the closing tx in the channel state * @return the channel closing type, if applicable */ - def isClosed(data: HasCommitments, additionalConfirmedTx_opt: Option[Transaction]): Option[ClosingType] = data match { + def isClosed(keyManager: ChannelKeyManager, data: HasCommitments, additionalConfirmedTx_opt: Option[Transaction]): Option[ClosingType] = data match { case closing: DATA_CLOSING if additionalConfirmedTx_opt.exists(closing.mutualClosePublished.contains) => Some(MutualClose(additionalConfirmedTx_opt.get)) - case closing: DATA_CLOSING if closing.localCommitPublished.exists(Closing.isLocalCommitDone) => + case closing: DATA_CLOSING if closing.localCommitPublished.exists(lcp => Closing.isLocalCommitDone(lcp, data.commitments)) => Some(LocalClose(closing.commitments.localCommit, closing.localCommitPublished.get)) - case closing: DATA_CLOSING if closing.remoteCommitPublished.exists(Closing.isRemoteCommitDone) => + case closing: DATA_CLOSING if closing.remoteCommitPublished.exists(rcp => Closing.isRemoteCommitDone(keyManager, rcp, data.commitments)) => Some(CurrentRemoteClose(closing.commitments.remoteCommit, closing.remoteCommitPublished.get)) - case closing: DATA_CLOSING if closing.nextRemoteCommitPublished.exists(Closing.isRemoteCommitDone) => + case closing: DATA_CLOSING if closing.nextRemoteCommitPublished.exists(rcp => Closing.isRemoteCommitDone(keyManager, rcp, data.commitments)) => Some(NextRemoteClose(closing.commitments.remoteNextCommitInfo.left.get.nextRemoteCommit, closing.nextRemoteCommitPublished.get)) - case closing: DATA_CLOSING if closing.futureRemoteCommitPublished.exists(Closing.isRemoteCommitDone) => + case closing: DATA_CLOSING if closing.futureRemoteCommitPublished.exists(rcp => Closing.isFutureRemoteCommitDone(rcp)) => Some(RecoveryClose(closing.futureRemoteCommitPublished.get)) case closing: DATA_CLOSING if closing.revokedCommitPublished.exists(Closing.isRevokedCommitDone) => Some(RevokedClose(closing.revokedCommitPublished.find(Closing.isRevokedCommitDone).get)) @@ -495,8 +494,7 @@ object Helpers { } /** - * Claim all the HTLCs that we've received from our current commit tx. This will be - * done using 2nd stage HTLC transactions + * Claim all the HTLCs that we've received from our current commit tx. This will be done using 2nd stage HTLC transactions. * * @param commitments our commitment data, which include payment preimages * @return a list of transactions (one per HTLC that we can claim) @@ -521,7 +519,7 @@ object Helpers { // those are the preimages to existing received htlcs val preimages = commitments.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage } - val htlcTxes = localCommit.publishableTxs.htlcTxsAndSigs.collect { + val htlcTxs = localCommit.publishableTxs.htlcTxsAndSigs.collect { // incoming htlc for which we have the preimage: we spend it directly case HtlcTxAndSigs(txinfo@HtlcSuccessTx(_, _, paymentHash), localSig, remoteSig) if preimages.exists(r => sha256(r) == paymentHash) => generateTx("htlc-success") { @@ -539,25 +537,66 @@ object Helpers { }.flatten // all htlc output to us are delayed, so we need to claim them as soon as the delay is over - val htlcDelayedTxes = htlcTxes.flatMap { - txinfo: TransactionWithInputInfo => - generateTx("claim-htlc-delayed") { - Transactions.makeClaimLocalDelayedOutputTx(txinfo.tx, localParams.dustLimit, localRevocationPubkey, remoteParams.toSelfDelay, localDelayedPubkey, localParams.defaultFinalScriptPubKey, feeratePerKwDelayed).right.map(claimDelayed => { - val sig = keyManager.sign(claimDelayed, keyManager.delayedPaymentPoint(channelKeyPath), localPerCommitmentPoint, TxOwner.Local, commitmentFormat) - Transactions.addSigs(claimDelayed, sig) - }) + // NB: when using anchor outputs, we will claim them once the corresponding HTLC txs confirm + val htlcDelayedTxs = commitmentFormat match { + case Transactions.DefaultCommitmentFormat => + htlcTxs.flatMap { + txinfo: TransactionWithInputInfo => + generateTx("claim-htlc-delayed") { + Transactions.makeClaimLocalDelayedOutputTx(txinfo.tx, localParams.dustLimit, localRevocationPubkey, remoteParams.toSelfDelay, localDelayedPubkey, localParams.defaultFinalScriptPubKey, feeratePerKwDelayed).map(claimDelayed => { + val sig = keyManager.sign(claimDelayed, keyManager.delayedPaymentPoint(channelKeyPath), localPerCommitmentPoint, TxOwner.Local, commitmentFormat) + Transactions.addSigs(claimDelayed, sig) + }) + } } + case Transactions.AnchorOutputsCommitmentFormat => Nil } LocalCommitPublished( commitTx = tx, claimMainDelayedOutputTx = mainDelayedTx.map(_.tx), - htlcSuccessTxs = htlcTxes.collect { case c: HtlcSuccessTx => c.tx }, - htlcTimeoutTxs = htlcTxes.collect { case c: HtlcTimeoutTx => c.tx }, - claimHtlcDelayedTxs = htlcDelayedTxes.map(_.tx), + htlcSuccessTxs = htlcTxs.collect { case c: HtlcSuccessTx => c.tx }, + htlcTimeoutTxs = htlcTxs.collect { case c: HtlcTimeoutTx => c.tx }, + claimHtlcDelayedTxs = htlcDelayedTxs.map(_.tx), irrevocablySpent = Map.empty) } + /** + * Claim the output of a 2nd-stage HTLC transaction and replace the obsolete HTLC transaction in our local commit. + */ + def claimLocalCommitHtlcTxOutput(localCommitPublished: LocalCommitPublished, keyManager: ChannelKeyManager, commitments: Commitments, tx: Transaction, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): (LocalCommitPublished, Option[Transaction]) = { + import commitments._ + val isHtlcTx = tx.txIn.map(_.outPoint.txid).contains(localCommitPublished.commitTx.txid) && + tx.txIn.map(_.witness).collect(Scripts.extractPreimageFromHtlcSuccess.orElse(Scripts.extractPaymentHashFromHtlcTimeout)).nonEmpty + if (isHtlcTx) { + val feeratePerKwDelayed = feeEstimator.getFeeratePerKw(feeTargets.claimMainBlockTarget) + val channelKeyPath = keyManager.keyPath(localParams, channelVersion) + val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, commitments.localCommit.index.toInt) + val localRevocationPubkey = Generators.revocationPubKey(remoteParams.revocationBasepoint, localPerCommitmentPoint) + val localDelayedPubkey = Generators.derivePubKey(keyManager.delayedPaymentPoint(channelKeyPath).publicKey, localPerCommitmentPoint) + val htlcDelayedTx = generateTx("claim-htlc-delayed") { + Transactions.makeClaimLocalDelayedOutputTx(tx, localParams.dustLimit, localRevocationPubkey, remoteParams.toSelfDelay, localDelayedPubkey, localParams.defaultFinalScriptPubKey, feeratePerKwDelayed).map(claimDelayed => { + val sig = keyManager.sign(claimDelayed, keyManager.delayedPaymentPoint(channelKeyPath), localPerCommitmentPoint, TxOwner.Local, commitmentFormat) + Transactions.addSigs(claimDelayed, sig) + }) + } + + def updateHtlcTx(newTx: Transaction, previousTxs: List[Transaction]): List[Transaction] = { + val replaceAt = previousTxs.indexWhere(_.txIn.head.outPoint == newTx.txIn.head.outPoint) + if (replaceAt >= 0) previousTxs.updated(replaceAt, newTx) else previousTxs + } + + val localCommitPublished1 = localCommitPublished.copy( + claimHtlcDelayedTxs = localCommitPublished.claimHtlcDelayedTxs ++ htlcDelayedTx.map(_.tx).toSeq, + htlcSuccessTxs = updateHtlcTx(tx, localCommitPublished.htlcSuccessTxs), + htlcTimeoutTxs = updateHtlcTx(tx, localCommitPublished.htlcTimeoutTxs) + ) + (localCommitPublished1, htlcDelayedTx.map(_.tx)) + } else { + (localCommitPublished, None) + } + } + /** * Claim all the HTLCs that we've received from their current commit tx, if the channel used option_static_remotekey * we don't need to claim our main output because it directly pays to one of our wallet's p2wpkh addresses. @@ -1066,52 +1105,85 @@ object Helpers { /** * A local commit is considered done when: * - all commitment tx outputs that we can spend have been spent and confirmed (even if the spending tx was not ours) - * - all 3rd stage txes (txes spending htlc txes) have been confirmed + * - all 3rd stage txs (txs spending htlc txs) have been confirmed */ - def isLocalCommitDone(localCommitPublished: LocalCommitPublished): Boolean = { - // is the commitment tx buried? (we need to check this because we may not have any outputs) - val isCommitTxConfirmed = localCommitPublished.irrevocablySpent.values.toSet.contains(localCommitPublished.commitTx.txid) - // are there remaining spendable outputs from the commitment tx? we just subtract all known spent outputs from the ones we control - // NB: we ignore anchors here, claiming them can be batched later - val commitOutputsSpendableByUs = (localCommitPublished.claimMainDelayedOutputTx.toSeq ++ localCommitPublished.htlcSuccessTxs ++ localCommitPublished.htlcTimeoutTxs) - .flatMap(_.txIn.map(_.outPoint)).toSet -- localCommitPublished.irrevocablySpent.keys - // which htlc delayed txes can we expect to be confirmed? - val unconfirmedHtlcDelayedTxes = localCommitPublished.claimHtlcDelayedTxs - .filter(tx => (tx.txIn.map(_.outPoint.txid).toSet -- localCommitPublished.irrevocablySpent.values).isEmpty) // only the txes which parents are already confirmed may get confirmed (note that this also eliminates outputs that have been double-spent by a competing tx) - .filterNot(tx => localCommitPublished.irrevocablySpent.values.toSet.contains(tx.txid)) // has the tx already been confirmed? - isCommitTxConfirmed && commitOutputsSpendableByUs.isEmpty && unconfirmedHtlcDelayedTxes.isEmpty + def isLocalCommitDone(localCommitPublished: LocalCommitPublished, commitments: Commitments): Boolean = { + val confirmedTxs = localCommitPublished.irrevocablySpent.values.toSet + // is the commitment tx confirmed (we need to check this because we may not have any outputs)? + val isCommitTxConfirmed = confirmedTxs.contains(localCommitPublished.commitTx.txid) + // is our main output confirmed (if we have one)? + val isMainOutputConfirmed = localCommitPublished.claimMainDelayedOutputTx.forall(tx => confirmedTxs.contains(tx.txid)) + // are all htlc outputs from the commitment tx spent? + val unspentCommitTxHtlcOutputs = commitments.localCommit.publishableTxs.htlcTxsAndSigs.map(_.txinfo.input.outPoint).toSet -- localCommitPublished.irrevocablySpent.keys + // are all outputs from htlc txs spent? + val unconfirmedHtlcDelayedTxs = localCommitPublished.claimHtlcDelayedTxs + // only the txs which parents are already confirmed may get confirmed (note that this eliminates outputs that have been double-spent by a competing tx) + .filter(tx => (tx.txIn.map(_.outPoint.txid).toSet -- confirmedTxs).isEmpty) + // has the tx already been confirmed? + .filterNot(tx => confirmedTxs.contains(tx.txid)) + isCommitTxConfirmed && isMainOutputConfirmed && unspentCommitTxHtlcOutputs.isEmpty && unconfirmedHtlcDelayedTxs.isEmpty } /** * A remote commit is considered done when all commitment tx outputs that we can spend have been spent and confirmed * (even if the spending tx was not ours). */ - def isRemoteCommitDone(remoteCommitPublished: RemoteCommitPublished): Boolean = { - // is the commitment tx buried? (we need to check this because we may not have any outputs) - val isCommitTxConfirmed = remoteCommitPublished.irrevocablySpent.values.toSet.contains(remoteCommitPublished.commitTx.txid) - // are there remaining spendable outputs from the commitment tx? - val commitOutputsSpendableByUs = (remoteCommitPublished.claimMainOutputTx.toSeq ++ remoteCommitPublished.claimHtlcSuccessTxs ++ remoteCommitPublished.claimHtlcTimeoutTxs) - .flatMap(_.txIn.map(_.outPoint)).toSet -- remoteCommitPublished.irrevocablySpent.keys - isCommitTxConfirmed && commitOutputsSpendableByUs.isEmpty + def isRemoteCommitDone(keyManager: ChannelKeyManager, remoteCommitPublished: RemoteCommitPublished, commitments: Commitments): Boolean = { + val remoteCommit = commitments.remoteNextCommitInfo match { + case Left(WaitingForRevocation(nextRemoteCommit, _, _, _)) if nextRemoteCommit.txid == remoteCommitPublished.commitTx.txid => nextRemoteCommit + case _ => commitments.remoteCommit + } + val (_, htlcTimeoutTxs, htlcSuccessTxs) = Commitments.makeRemoteTxs( + keyManager, + commitments.channelVersion, + remoteCommit.index, + commitments.localParams, + commitments.remoteParams, + commitments.commitInput, + remoteCommit.remotePerCommitmentPoint, + remoteCommit.spec + ) + val confirmedTxs = remoteCommitPublished.irrevocablySpent.values.toSet + // is the commitment tx confirmed (we need to check this because we may not have any outputs)? + val isCommitTxConfirmed = confirmedTxs.contains(remoteCommitPublished.commitTx.txid) + // is our main output confirmed (if we have one)? + val isMainOutputConfirmed = remoteCommitPublished.claimMainOutputTx.forall(tx => confirmedTxs.contains(tx.txid)) + // are all htlc outputs from the commitment tx spent? + val unspentCommitTxHtlcOutputs = (htlcTimeoutTxs.map(_.input.outPoint) ++ htlcSuccessTxs.map(_.input.outPoint)).toSet -- remoteCommitPublished.irrevocablySpent.keys + isCommitTxConfirmed && isMainOutputConfirmed && unspentCommitTxHtlcOutputs.isEmpty } /** - * A remote commit is considered done when all commitment tx outputs that we can spend have been spent and confirmed + * A future remote commit (the case where we lost data about the commitment) is considered done once we've recovered + * our main output. We can't recover HTLC outputs in that scenario. + */ + def isFutureRemoteCommitDone(remoteCommitPublished: RemoteCommitPublished): Boolean = { + val confirmedTxs = remoteCommitPublished.irrevocablySpent.values.toSet + val isCommitTxConfirmed = confirmedTxs.contains(remoteCommitPublished.commitTx.txid) + val isMainOutputConfirmed = remoteCommitPublished.claimMainOutputTx.forall(tx => confirmedTxs.contains(tx.txid)) + isCommitTxConfirmed && isMainOutputConfirmed + } + + /** + * A revoked commit is considered done when all commitment tx outputs that we can spend have been spent and confirmed * (even if the spending tx was not ours). */ def isRevokedCommitDone(revokedCommitPublished: RevokedCommitPublished): Boolean = { - // is the commitment tx buried? (we need to check this because we may not have any outputs) - val isCommitTxConfirmed = revokedCommitPublished.irrevocablySpent.values.toSet.contains(revokedCommitPublished.commitTx.txid) + val confirmedTxs = revokedCommitPublished.irrevocablySpent.values.toSet + // is the commitment tx confirmed (we need to check this because we may not have any outputs)? + val isCommitTxConfirmed = confirmedTxs.contains(revokedCommitPublished.commitTx.txid) // are there remaining spendable outputs from the commitment tx? - val commitOutputsSpendableByUs = (revokedCommitPublished.claimMainOutputTx.toSeq ++ revokedCommitPublished.mainPenaltyTx ++ revokedCommitPublished.htlcPenaltyTxs) - .flatMap(_.txIn.map(_.outPoint)).toSet -- revokedCommitPublished.irrevocablySpent.keys - // which htlc delayed txs can we expect to be confirmed? + val unspentCommitTxOutputs = { + val commitOutputsSpendableByUs = (revokedCommitPublished.claimMainOutputTx.toSeq ++ revokedCommitPublished.mainPenaltyTx ++ revokedCommitPublished.htlcPenaltyTxs).flatMap(_.txIn.map(_.outPoint)) + commitOutputsSpendableByUs.toSet -- revokedCommitPublished.irrevocablySpent.keys + } + // are all outputs from htlc txs spent? val unconfirmedHtlcDelayedTxs = revokedCommitPublished.claimHtlcDelayedPenaltyTxs - // only the txs which parents are already confirmed may get confirmed (note that this also eliminates outputs that have been double-spent by a competing tx) - .filter(tx => (tx.txIn.map(_.outPoint.txid).toSet -- revokedCommitPublished.irrevocablySpent.values).isEmpty) + // only the txs which parents are already confirmed may get confirmed (note that this eliminates outputs that have been double-spent by a competing tx) + .filter(tx => (tx.txIn.map(_.outPoint.txid).toSet -- confirmedTxs).isEmpty) // if one of the tx inputs has been spent, the tx has already been confirmed or a competing tx has been confirmed .filterNot(tx => tx.txIn.exists(txIn => revokedCommitPublished.irrevocablySpent.contains(txIn.outPoint))) - isCommitTxConfirmed && commitOutputsSpendableByUs.isEmpty && unconfirmedHtlcDelayedTxs.isEmpty + isCommitTxConfirmed && unspentCommitTxOutputs.isEmpty && unconfirmedHtlcDelayedTxs.isEmpty } /** diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala index 7d5744e02e..66c984e823 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/io/Switchboard.scala @@ -41,7 +41,7 @@ class Switchboard(nodeParams: NodeParams, watcher: ActorRef, relayer: ActorRef, // Check if channels that are still in CLOSING state have actually been closed. This can happen when the app is stopped // just after a channel state has transitioned to CLOSED and before it has effectively been removed. // Closed channels will be removed, other channels will be restored. - val (channels, closedChannels) = nodeParams.db.channels.listLocalChannels().partition(c => Closing.isClosed(c, None).isEmpty) + val (channels, closedChannels) = nodeParams.db.channels.listLocalChannels().partition(c => Closing.isClosed(nodeParams.channelKeyManager, c, None).isEmpty) closedChannels.foreach(c => { log.info(s"closing channel ${c.channelId}") nodeParams.db.channels.removeChannel(c.channelId) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/PostRestartHtlcCleaner.scala b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/PostRestartHtlcCleaner.scala index 65c8227dc6..aab113fbcc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/PostRestartHtlcCleaner.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/payment/relay/PostRestartHtlcCleaner.scala @@ -23,6 +23,7 @@ import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.Crypto.PrivateKey import fr.acinq.eclair.channel.Helpers.Closing import fr.acinq.eclair.channel._ +import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager import fr.acinq.eclair.db._ import fr.acinq.eclair.payment.Monitoring.Tags import fr.acinq.eclair.payment.{ChannelPaymentRelayed, IncomingPacket, PaymentFailed, PaymentSent} @@ -64,7 +65,7 @@ class PostRestartHtlcCleaner(nodeParams: NodeParams, register: ActorRef, initial // Outgoing HTLC sets that are still pending may either succeed or fail: we need to watch them to properly forward the // result upstream to preserve channels. val brokenHtlcs: BrokenHtlcs = { - val channels = listLocalChannels(nodeParams.db.channels) + val channels = listLocalChannels(nodeParams.channelKeyManager, nodeParams.db.channels) val nonStandardIncomingHtlcs: Seq[IncomingHtlc] = nodeParams.pluginParams.collect { case p: CustomCommitmentsPlugin => p.getIncomingHtlcs(nodeParams, log) }.flatten val htlcsIn: Seq[IncomingHtlc] = getIncomingHtlcs(channels, nodeParams.db.payments, nodeParams.privateKey) ++ nonStandardIncomingHtlcs val nonStandardRelayedOutHtlcs: Map[Origin, Set[(ByteVector32, Long)]] = nodeParams.pluginParams.collect { case p: CustomCommitmentsPlugin => p.getHtlcsRelayedOut(htlcsIn, nodeParams, log) }.flatten.toMap @@ -385,8 +386,8 @@ object PostRestartHtlcCleaner { * and before it has effectively been removed. Such closed channels will automatically be removed once the channel is * restored. */ - private def listLocalChannels(channelsDb: ChannelsDb): Seq[HasCommitments] = - channelsDb.listLocalChannels().filterNot(c => Closing.isClosed(c, None).isDefined) + private def listLocalChannels(keyManager: ChannelKeyManager, channelsDb: ChannelsDb): Seq[HasCommitments] = + channelsDb.listLocalChannels().filterNot(c => Closing.isClosed(keyManager, c, None).isDefined) /** * We store [[CMD_FULFILL_HTLC]]/[[CMD_FAIL_HTLC]]/[[CMD_FAIL_MALFORMED_HTLC]] in a database 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 b7d92989bd..f218eef75f 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 @@ -17,11 +17,13 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey, ripemd160} +import fr.acinq.bitcoin.DeterministicWallet.ExtendedPublicKey import fr.acinq.bitcoin.Script._ import fr.acinq.bitcoin.SigVersion._ import fr.acinq.bitcoin._ import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.fee.FeeratePerKw +import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager import fr.acinq.eclair.transactions.CommitmentOutput._ import fr.acinq.eclair.transactions.Scripts._ import fr.acinq.eclair.wire.UpdateAddHtlc @@ -114,6 +116,23 @@ object Transactions { case class HtlcPenaltyTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo case class ClosingTx(input: InputInfo, tx: Transaction) extends TransactionWithInputInfo + trait TransactionSigningKit { + def keyManager: ChannelKeyManager + def commitmentFormat: CommitmentFormat + } + object TransactionSigningKit { + case class ClaimAnchorOutputSigningKit(keyManager: ChannelKeyManager, commitmentFormat: CommitmentFormat, txWithInput: ClaimAnchorOutputTx, localFundingPubKey: ExtendedPublicKey) extends TransactionSigningKit + + sealed trait HtlcTxSigningKit extends TransactionSigningKit { + def txWithInput: HtlcTx + def localHtlcBasepoint: ExtendedPublicKey + def localPerCommitmentPoint: PublicKey + def remoteSig: ByteVector64 + } + case class HtlcSuccessSigningKit(keyManager: ChannelKeyManager, commitmentFormat: CommitmentFormat, txWithInput: HtlcSuccessTx, localHtlcBasepoint: ExtendedPublicKey, localPerCommitmentPoint: PublicKey, remoteSig: ByteVector64, preimage: ByteVector32) extends HtlcTxSigningKit + case class HtlcTimeoutSigningKit(keyManager: ChannelKeyManager, commitmentFormat: CommitmentFormat, txWithInput: HtlcTimeoutTx, localHtlcBasepoint: ExtendedPublicKey, localPerCommitmentPoint: PublicKey, remoteSig: ByteVector64) extends HtlcTxSigningKit + } + sealed trait TxGenerationSkipped case object OutputNotFound extends TxGenerationSkipped { override def toString = "output not found (probably trimmed)" } case object AmountBelowDustLimit extends TxGenerationSkipped { override def toString = "amount is below dust limit" } @@ -150,8 +169,12 @@ object Transactions { /** * these values are specific to us (not defined in the specification) and used to estimate fees */ + val claimP2WPKHOutputWitnessWeight = 109 val claimP2WPKHOutputWeight = 438 - val claimAnchorOutputWeight = 321 + // The smallest transaction that spends an anchor contains 2 inputs (the commit tx output and a wallet input to set the feerate) + // and 1 output (change). If we're using P2WPKH wallet inputs/outputs with 72 bytes signatures, this results in a weight of 717. + // We round it down to 700 to allow for some error margin (e.g. signatures smaller than 72 bytes). + val claimAnchorOutputMinWeight = 700 val claimHtlcDelayedWeight = 483 val claimHtlcSuccessWeight = 571 val claimHtlcTimeoutWeight = 545 diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ExtendedBitcoinClientSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ExtendedBitcoinClientSpec.scala index 62aaa4fe1b..1344c047ea 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ExtendedBitcoinClientSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ExtendedBitcoinClientSpec.scala @@ -123,18 +123,26 @@ class ExtendedBitcoinClientSpec extends TestKitBaseClass with BitcoindService wi bitcoinClient.fundTransaction(Transaction(2, Nil, Seq(TxOut(400000 sat, Script.pay2wpkh(randomKey.publicKey))), 0), opts).pipeTo(sender.ref) val fundTxResponse = sender.expectMsgType[FundTransactionResponse] val txWithNonWalletInput = fundTxResponse.tx.copy(txIn = TxIn(OutPoint(txToRemote, 0), ByteVector.empty, 0) +: fundTxResponse.tx.txIn) + // bitcoind returns an error if there are unsigned non-wallet input. bitcoinClient.signTransaction(txWithNonWalletInput, Nil).pipeTo(sender.ref) val Failure(JsonRPCError(error)) = sender.expectMsgType[Failure] assert(error.message.contains(txToRemote.txid.toHex)) - // but if these inputs are signed, bitcoind signs the remaining wallet inputs. + + // we can ignore that error with allowIncomplete = true, and in that case bitcoind signs the wallet inputs. + bitcoinClient.signTransaction(txWithNonWalletInput, Nil, allowIncomplete = true).pipeTo(sender.ref) + val signTxResponse1 = sender.expectMsgType[SignTransactionResponse] + assert(!signTxResponse1.complete) + signTxResponse1.tx.txIn.tail.foreach(walletTxIn => assert(walletTxIn.witness.stack.nonEmpty)) + + // if the non-wallet inputs are signed, bitcoind signs the remaining wallet inputs. val nonWalletSig = Transaction.signInput(txWithNonWalletInput, 0, Script.pay2pkh(nonWalletKey.publicKey), SIGHASH_ALL, txToRemote.txOut.head.amount, SIGVERSION_WITNESS_V0, nonWalletKey) val nonWalletWitness = ScriptWitness(Seq(nonWalletSig, nonWalletKey.publicKey.value)) val txWithSignedNonWalletInput = txWithNonWalletInput.updateWitness(0, nonWalletWitness) bitcoinClient.signTransaction(txWithSignedNonWalletInput, Nil).pipeTo(sender.ref) - val signTxResponse = sender.expectMsgType[SignTransactionResponse] - assert(signTxResponse.complete) - Transaction.correctlySpends(signTxResponse.tx, Seq(txToRemote), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + val signTxResponse2 = sender.expectMsgType[SignTransactionResponse] + assert(signTxResponse2.complete) + Transaction.correctlySpends(signTxResponse2.tx, Seq(txToRemote), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) } { // bitcoind does not sign inputs that have already been confirmed. diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala index 9b69670011..d8cea4b012 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala @@ -19,29 +19,32 @@ package fr.acinq.eclair.blockchain.bitcoind import akka.Done import akka.actor.{ActorRef, Props} import akka.pattern.pipe -import akka.testkit.{TestKit, TestProbe} -import fr.acinq.bitcoin.{Btc, OutPoint, SatoshiLong, Script, Transaction, TxOut} +import akka.testkit.{TestActorRef, TestFSMRef, TestProbe} +import fr.acinq.bitcoin.{Block, Btc, BtcAmount, MilliBtcDouble, OutPoint, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut} import fr.acinq.eclair.blockchain.WatcherSpec._ import fr.acinq.eclair.blockchain._ -import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.blockchain.bitcoind.rpc.ExtendedBitcoinClient -import fr.acinq.eclair.blockchain.bitcoind.rpc.ExtendedBitcoinClient.{FundTransactionResponse, SignTransactionResponse} +import fr.acinq.eclair.blockchain.bitcoind.rpc.ExtendedBitcoinClient.{FundTransactionResponse, MempoolTx, SignTransactionResponse} import fr.acinq.eclair.blockchain.bitcoind.zmq.ZMQActor import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel._ -import fr.acinq.eclair.{TestKitBaseClass, randomBytes32} +import fr.acinq.eclair.channel.states.{StateTestsHelperMethods, StateTestsTags} +import fr.acinq.eclair.transactions.Transactions.TransactionSigningKit.{ClaimAnchorOutputSigningKit, HtlcSuccessSigningKit, HtlcTimeoutSigningKit} +import fr.acinq.eclair.transactions.{Scripts, Transactions} +import fr.acinq.eclair.{MilliSatoshiLong, TestConstants, TestKitBaseClass, randomBytes32, randomKey} import grizzled.slf4j.Logging -import org.json4s.JsonAST.JValue -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.Promise import scala.concurrent.duration._ +import scala.util.Random -class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with BitcoindService with BeforeAndAfterAll with Logging { +class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with BitcoindService with StateTestsHelperMethods with BeforeAndAfterAll with Logging { var zmqBlock: ActorRef = _ var zmqTx: ActorRef = _ @@ -65,7 +68,51 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind logger.info("stopping bitcoind") stopBitcoind() super.afterAll() - TestKit.shutdownActorSystem(system) + } + + case class Fixture(alice: TestFSMRef[State, Data, Channel], + bob: TestFSMRef[State, Data, Channel], + alice2bob: TestProbe, + bob2alice: TestProbe, + alice2watcher: TestProbe, + bob2watcher: TestProbe, + blockCount: AtomicLong, + bitcoinClient: ExtendedBitcoinClient, + bitcoinWallet: BitcoinCoreWallet, + watcher: TestActorRef[ZmqWatcher], + probe: TestProbe) + + // NB: we can't use ScalaTest's fixtures, they would see uninitialized bitcoind fields because they sandbox each test. + private def withWatcher(utxos: Seq[BtcAmount], testFun: Fixture => Any): Unit = { + val probe = TestProbe() + + // Create a unique wallet for this test and ensure it has some btc. + val walletRpcClient = createWallet(s"lightning-${UUID.randomUUID()}") + val bitcoinClient = new ExtendedBitcoinClient(walletRpcClient) + val bitcoinWallet = new BitcoinCoreWallet(walletRpcClient) + utxos.foreach(amount => { + bitcoinWallet.getReceiveAddress.pipeTo(probe.ref) + val walletAddress = probe.expectMsgType[String] + sendToAddress(walletAddress, amount, probe) + }) + generateBlocks(1) + + val blockCount = new AtomicLong() + val watcher = TestActorRef[ZmqWatcher](ZmqWatcher.props(Block.RegtestGenesisBlock.hash, blockCount, bitcoinClient)) + // Setup a valid channel between alice and bob. + val setup = init(TestConstants.Alice.nodeParams.copy(blockCount = blockCount), TestConstants.Bob.nodeParams.copy(blockCount = blockCount), bitcoinWallet) + reachNormal(setup, Set(StateTestsTags.AnchorOutputs)) + import setup._ + awaitCond(alice.stateName == NORMAL) + awaitCond(bob.stateName == NORMAL) + // Generate blocks to ensure the funding tx is confirmed. + generateBlocks(1) + // Execute our test. + try { + testFun(Fixture(alice, bob, alice2bob, bob2alice, alice2blockchain, bob2blockchain, blockCount, bitcoinClient, bitcoinWallet, watcher, probe)) + } finally { + system.stop(watcher) + } } test("add/remove watches from/to utxo map") { @@ -104,168 +151,560 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind } test("watch for confirmed transactions") { - val probe = TestProbe() - val blockCount = new AtomicLong() - val watcher = system.actorOf(ZmqWatcher.props(randomBytes32, blockCount, new ExtendedBitcoinClient(bitcoinrpcclient))) - val address = getNewAddress(probe) - val tx = sendToAddress(address, Btc(1)) - - val listener = TestProbe() - probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, tx.txOut.head.publicKeyScript, 4, BITCOIN_FUNDING_DEPTHOK)) - probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, tx.txOut.head.publicKeyScript, 4, BITCOIN_FUNDING_DEPTHOK)) // setting the watch multiple times should be a no-op - generateBlocks(5) - assert(listener.expectMsgType[WatchEventConfirmed].tx.txid === tx.txid) - listener.expectNoMsg(1 second) - - // If we try to watch a transaction that has already been confirmed, we should immediately receive a WatchEventConfirmed. - probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, tx.txOut.head.publicKeyScript, 4, BITCOIN_FUNDING_DEPTHOK)) - assert(listener.expectMsgType[WatchEventConfirmed].tx.txid === tx.txid) - listener.expectNoMsg(1 second) - system.stop(watcher) + withWatcher(Seq(500 millibtc), f => { + import f._ + + val address = getNewAddress(probe) + val tx = sendToAddress(address, Btc(1), probe) + + val listener = TestProbe() + probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, tx.txOut.head.publicKeyScript, 4, BITCOIN_FUNDING_DEPTHOK)) + probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, tx.txOut.head.publicKeyScript, 4, BITCOIN_FUNDING_DEPTHOK)) // setting the watch multiple times should be a no-op + generateBlocks(5) + assert(listener.expectMsgType[WatchEventConfirmed].tx.txid === tx.txid) + listener.expectNoMsg(1 second) + + // If we try to watch a transaction that has already been confirmed, we should immediately receive a WatchEventConfirmed. + probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, tx.txOut.head.publicKeyScript, 4, BITCOIN_FUNDING_DEPTHOK)) + assert(listener.expectMsgType[WatchEventConfirmed].tx.txid === tx.txid) + listener.expectNoMsg(1 second) + }) } test("watch for spent transactions") { - val probe = TestProbe() - val blockCount = new AtomicLong() - val watcher = system.actorOf(ZmqWatcher.props(randomBytes32, blockCount, new ExtendedBitcoinClient(bitcoinrpcclient))) - val address = getNewAddress(probe) - val priv = dumpPrivateKey(address, probe) - val tx = sendToAddress(address, Btc(1)) - val outputIndex = tx.txOut.indexWhere(_.publicKeyScript == Script.write(Script.pay2wpkh(priv.publicKey))) - val (tx1, tx2) = createUnspentTxChain(tx, priv) - - val listener = TestProbe() - probe.send(watcher, WatchSpentBasic(listener.ref, tx, outputIndex, BITCOIN_FUNDING_SPENT)) - probe.send(watcher, WatchSpent(listener.ref, tx, outputIndex, BITCOIN_FUNDING_SPENT, hints = Set.empty)) - listener.expectNoMsg(1 second) - probe.send(bitcoincli, BitcoinReq("sendrawtransaction", tx1.toString())) - probe.expectMsgType[JValue] - // tx and tx1 aren't confirmed yet, but we trigger the WatchEventSpent when we see tx1 in the mempool. - listener.expectMsgAllOf( - WatchEventSpentBasic(BITCOIN_FUNDING_SPENT), - WatchEventSpent(BITCOIN_FUNDING_SPENT, tx1) - ) - // Let's confirm tx and tx1: seeing tx1 in a block should trigger WatchEventSpent again, but not WatchEventSpentBasic - // (which only triggers once). - generateBlocks(2) - listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx1)) - - // Let's submit tx2, and set a watch after it has been confirmed this time. - probe.send(bitcoincli, BitcoinReq("sendrawtransaction", tx2.toString())) - probe.expectMsgType[JValue] - listener.expectNoMsg(1 second) - generateBlocks(1) - probe.send(watcher, WatchSpentBasic(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT)) - probe.send(watcher, WatchSpent(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT, hints = Set.empty)) - listener.expectMsgAllOf( - WatchEventSpentBasic(BITCOIN_FUNDING_SPENT), - WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2) - ) + withWatcher(Seq(500 millibtc), f => { + import f._ + + val address = getNewAddress(probe) + val priv = dumpPrivateKey(address, probe) + val tx = sendToAddress(address, Btc(1), probe) + val outputIndex = tx.txOut.indexWhere(_.publicKeyScript == Script.write(Script.pay2wpkh(priv.publicKey))) + val (tx1, tx2) = createUnspentTxChain(tx, priv) - // We use hints and see if we can find tx2 - probe.send(watcher, WatchSpent(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT, hints = Set(tx2.txid))) - listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2)) + val listener = TestProbe() + probe.send(watcher, WatchSpentBasic(listener.ref, tx, outputIndex, BITCOIN_FUNDING_SPENT)) + probe.send(watcher, WatchSpent(listener.ref, tx, outputIndex, BITCOIN_FUNDING_SPENT, hints = Set.empty)) + listener.expectNoMsg(1 second) + bitcoinClient.publishTransaction(tx1).pipeTo(probe.ref) + probe.expectMsg(tx1.txid) + // tx and tx1 aren't confirmed yet, but we trigger the WatchEventSpent when we see tx1 in the mempool. + listener.expectMsgAllOf( + WatchEventSpentBasic(BITCOIN_FUNDING_SPENT), + WatchEventSpent(BITCOIN_FUNDING_SPENT, tx1) + ) + // Let's confirm tx and tx1: seeing tx1 in a block should trigger WatchEventSpent again, but not WatchEventSpentBasic + // (which only triggers once). + generateBlocks(2) + listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx1)) - // We should still find tx2 if the provided hint is wrong - probe.send(watcher, WatchSpent(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT, hints = Set(randomBytes32))) - listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2)) + // Let's submit tx2, and set a watch after it has been confirmed this time. + bitcoinClient.publishTransaction(tx2).pipeTo(probe.ref) + probe.expectMsg(tx2.txid) + listener.expectNoMsg(1 second) + generateBlocks(1) + probe.send(watcher, WatchSpentBasic(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT)) + probe.send(watcher, WatchSpent(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT, hints = Set.empty)) + listener.expectMsgAllOf( + WatchEventSpentBasic(BITCOIN_FUNDING_SPENT), + WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2) + ) - system.stop(watcher) + // We use hints and see if we can find tx2 + probe.send(watcher, WatchSpent(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT, hints = Set(tx2.txid))) + listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2)) + + // We should still find tx2 if the provided hint is wrong + probe.send(watcher, WatchSpent(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT, hints = Set(randomBytes32))) + listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2)) + }) } test("watch for unknown spent transactions") { - val probe = TestProbe() - val blockCount = new AtomicLong() - val wallet = new BitcoinCoreWallet(bitcoinrpcclient) - val client = new ExtendedBitcoinClient(bitcoinrpcclient) - val watcher = system.actorOf(ZmqWatcher.props(randomBytes32, blockCount, client)) - - // create a chain of transactions that we don't broadcast yet - val priv = dumpPrivateKey(getNewAddress(probe), probe) - val tx1 = { - wallet.fundTransaction(Transaction(2, Nil, TxOut(150000 sat, Script.pay2wpkh(priv.publicKey)) :: Nil, 0), lockUtxos = true, FeeratePerKw(250 sat)).pipeTo(probe.ref) - val funded = probe.expectMsgType[FundTransactionResponse].tx - wallet.signTransaction(funded).pipeTo(probe.ref) - probe.expectMsgType[SignTransactionResponse].tx - } - val outputIndex = tx1.txOut.indexWhere(_.publicKeyScript == Script.write(Script.pay2wpkh(priv.publicKey))) - val tx2 = createSpendP2WPKH(tx1, priv, priv.publicKey, 10000 sat, 1, 0) - - // setup watches before we publish transactions - probe.send(watcher, WatchSpent(probe.ref, tx1, outputIndex, BITCOIN_FUNDING_SPENT, hints = Set.empty)) - probe.send(watcher, WatchConfirmed(probe.ref, tx1, 3, BITCOIN_FUNDING_SPENT)) - client.publishTransaction(tx1).pipeTo(probe.ref) - probe.expectMsg(tx1.txid) - generateBlocks(1) - probe.expectNoMsg(1 second) - client.publishTransaction(tx2).pipeTo(probe.ref) - probe.expectMsgAllOf(tx2.txid, WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2)) - probe.expectNoMsg(1 second) - generateBlocks(1) - probe.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2)) // tx2 is confirmed which triggers WatchEventSpent again - generateBlocks(1) - assert(probe.expectMsgType[WatchEventConfirmed].tx === tx1) // tx1 now has 3 confirmations - system.stop(watcher) + withWatcher(Seq(500 millibtc), f => { + import f._ + + // create a chain of transactions that we don't broadcast yet + val priv = dumpPrivateKey(getNewAddress(probe), probe) + val tx1 = { + bitcoinWallet.fundTransaction(Transaction(2, Nil, TxOut(150000 sat, Script.pay2wpkh(priv.publicKey)) :: Nil, 0), lockUtxos = true, FeeratePerKw(250 sat)).pipeTo(probe.ref) + val funded = probe.expectMsgType[FundTransactionResponse].tx + bitcoinWallet.signTransaction(funded).pipeTo(probe.ref) + probe.expectMsgType[SignTransactionResponse].tx + } + val outputIndex = tx1.txOut.indexWhere(_.publicKeyScript == Script.write(Script.pay2wpkh(priv.publicKey))) + val tx2 = createSpendP2WPKH(tx1, priv, priv.publicKey, 10000 sat, 1, 0) + + // setup watches before we publish transactions + probe.send(watcher, WatchSpent(probe.ref, tx1, outputIndex, BITCOIN_FUNDING_SPENT, hints = Set.empty)) + probe.send(watcher, WatchConfirmed(probe.ref, tx1, 3, BITCOIN_FUNDING_SPENT)) + bitcoinClient.publishTransaction(tx1).pipeTo(probe.ref) + probe.expectMsg(tx1.txid) + generateBlocks(1) + probe.expectNoMsg(1 second) + bitcoinClient.publishTransaction(tx2).pipeTo(probe.ref) + probe.expectMsgAllOf(tx2.txid, WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2)) + probe.expectNoMsg(1 second) + generateBlocks(1) + probe.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2)) // tx2 is confirmed which triggers WatchEventSpent again + generateBlocks(1) + assert(probe.expectMsgType[WatchEventConfirmed].tx === tx1) // tx1 now has 3 confirmations + }) } test("publish transactions with relative and absolute delays") { - val probe = TestProbe() - val blockCount = new AtomicLong() - val wallet = new BitcoinCoreWallet(bitcoinrpcclient) - val client = new ExtendedBitcoinClient(bitcoinrpcclient) - val watcher = system.actorOf(ZmqWatcher.props(randomBytes32, blockCount, client)) - awaitCond(blockCount.get > 0) - val priv = dumpPrivateKey(getNewAddress(probe), probe) - - // tx1 has an absolute delay but no relative delay - val initialBlockCount = blockCount.get - val tx1 = { - wallet.fundTransaction(Transaction(2, Nil, TxOut(150000 sat, Script.pay2wpkh(priv.publicKey)) :: Nil, initialBlockCount + 5), lockUtxos = true, FeeratePerKw(250 sat)).pipeTo(probe.ref) - val funded = probe.expectMsgType[FundTransactionResponse].tx - wallet.signTransaction(funded).pipeTo(probe.ref) - probe.expectMsgType[SignTransactionResponse].tx - } - probe.send(watcher, PublishAsap(tx1)) - generateBlocks(4) - awaitCond(blockCount.get === initialBlockCount + 4) - client.getMempool().pipeTo(probe.ref) - assert(!probe.expectMsgType[Seq[Transaction]].exists(_.txid === tx1.txid)) // tx should not be broadcast yet - generateBlocks(1) - awaitCond({ - client.getMempool().pipeTo(probe.ref) - probe.expectMsgType[Seq[Transaction]].exists(_.txid === tx1.txid) - }, max = 20 seconds, interval = 1 second) - - // tx2 has a relative delay but no absolute delay - val tx2 = createSpendP2WPKH(tx1, priv, priv.publicKey, 10000 sat, sequence = 2, lockTime = 0) - probe.send(watcher, WatchConfirmed(probe.ref, tx1, 1, BITCOIN_FUNDING_DEPTHOK)) - probe.send(watcher, PublishAsap(tx2)) - generateBlocks(1) - assert(probe.expectMsgType[WatchEventConfirmed].tx === tx1) - generateBlocks(2) + withWatcher(Seq(500 millibtc), f => { + import f._ + + // Ensure watcher is synchronized with the latest block height. + bitcoinClient.getBlockCount.pipeTo(probe.ref) + val initialBlockCount = probe.expectMsgType[Long] + awaitCond(blockCount.get === initialBlockCount) + + // tx1 has an absolute delay but no relative delay + val priv = dumpPrivateKey(getNewAddress(probe), probe) + val tx1 = { + bitcoinWallet.fundTransaction(Transaction(2, Nil, TxOut(150000 sat, Script.pay2wpkh(priv.publicKey)) :: Nil, initialBlockCount + 5), lockUtxos = true, FeeratePerKw(250 sat)).pipeTo(probe.ref) + val funded = probe.expectMsgType[FundTransactionResponse].tx + bitcoinWallet.signTransaction(funded).pipeTo(probe.ref) + probe.expectMsgType[SignTransactionResponse].tx + } + probe.send(watcher, PublishAsap(tx1, PublishStrategy.JustPublish)) + generateBlocks(4) + awaitCond(blockCount.get === initialBlockCount + 4) + bitcoinClient.getMempool().pipeTo(probe.ref) + assert(!probe.expectMsgType[Seq[Transaction]].exists(_.txid === tx1.txid)) // tx should not be broadcast yet + generateBlocks(1) + awaitCond({ + bitcoinClient.getMempool().pipeTo(probe.ref) + probe.expectMsgType[Seq[Transaction]].exists(_.txid === tx1.txid) + }, max = 20 seconds, interval = 1 second) + + // tx2 has a relative delay but no absolute delay + val tx2 = createSpendP2WPKH(tx1, priv, priv.publicKey, 10000 sat, sequence = 2, lockTime = 0) + probe.send(watcher, WatchConfirmed(probe.ref, tx1, 1, BITCOIN_FUNDING_DEPTHOK)) + probe.send(watcher, PublishAsap(tx2, PublishStrategy.JustPublish)) + generateBlocks(1) + assert(probe.expectMsgType[WatchEventConfirmed].tx === tx1) + generateBlocks(2) + awaitCond({ + bitcoinClient.getMempool().pipeTo(probe.ref) + probe.expectMsgType[Seq[Transaction]].exists(_.txid === tx2.txid) + }, max = 20 seconds, interval = 1 second) + + // tx3 has both relative and absolute delays + val tx3 = createSpendP2WPKH(tx2, priv, priv.publicKey, 10000 sat, sequence = 1, lockTime = blockCount.get + 5) + probe.send(watcher, WatchConfirmed(probe.ref, tx2, 1, BITCOIN_FUNDING_DEPTHOK)) + probe.send(watcher, WatchSpent(probe.ref, tx2, 0, BITCOIN_FUNDING_SPENT, hints = Set.empty)) + probe.send(watcher, PublishAsap(tx3, PublishStrategy.JustPublish)) + generateBlocks(1) + assert(probe.expectMsgType[WatchEventConfirmed].tx === tx2) + val currentBlockCount = blockCount.get + // after 1 block, the relative delay is elapsed, but not the absolute delay + generateBlocks(1) + awaitCond(blockCount.get == currentBlockCount + 1) + probe.expectNoMsg(1 second) + generateBlocks(3) + probe.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx3)) + bitcoinClient.getMempool().pipeTo(probe.ref) + probe.expectMsgType[Seq[Transaction]].exists(_.txid === tx3.txid) + }) + } + + private def getMempoolTxs(bitcoinClient: ExtendedBitcoinClient, expectedTxCount: Int, probe: TestProbe = TestProbe()): Seq[MempoolTx] = { awaitCond({ - client.getMempool().pipeTo(probe.ref) - probe.expectMsgType[Seq[Transaction]].exists(_.txid === tx2.txid) - }, max = 20 seconds, interval = 1 second) - - // tx3 has both relative and absolute delays - val tx3 = createSpendP2WPKH(tx2, priv, priv.publicKey, 10000 sat, sequence = 1, lockTime = blockCount.get + 5) - probe.send(watcher, WatchConfirmed(probe.ref, tx2, 1, BITCOIN_FUNDING_DEPTHOK)) - probe.send(watcher, WatchSpent(probe.ref, tx2, 0, BITCOIN_FUNDING_SPENT, hints = Set.empty)) - probe.send(watcher, PublishAsap(tx3)) - generateBlocks(1) - assert(probe.expectMsgType[WatchEventConfirmed].tx === tx2) - val currentBlockCount = blockCount.get - // after 1 block, the relative delay is elapsed, but not the absolute delay - generateBlocks(1) - awaitCond(blockCount.get == currentBlockCount + 1) - probe.expectNoMsg(1 second) - generateBlocks(3) - probe.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx3)) - client.getMempool().pipeTo(probe.ref) - probe.expectMsgType[Seq[Transaction]].exists(_.txid === tx3.txid) - - system.stop(watcher) + bitcoinClient.getMempool().pipeTo(probe.ref) + probe.expectMsgType[Seq[Transaction]].size == expectedTxCount + }, interval = 250 milliseconds) + + bitcoinClient.getMempool().pipeTo(probe.ref) + probe.expectMsgType[Seq[Transaction]].map(tx => { + bitcoinClient.getMempoolTx(tx.txid).pipeTo(probe.ref) + probe.expectMsgType[MempoolTx] + }) + } + + def closeChannelWithoutHtlcs(f: Fixture): PublishAsap = { + import f._ + + val commitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx + val currentFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.feeratePerKw + probe.send(alice, CMD_FORCECLOSE(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FORCECLOSE]] + + val publishCommitTx = alice2watcher.expectMsgType[PublishAsap] + assert(publishCommitTx.tx.txid === commitTx.txid) + assert(publishCommitTx.strategy.isInstanceOf[PublishStrategy.SetFeerate]) + val publishStrategy = publishCommitTx.strategy.asInstanceOf[PublishStrategy.SetFeerate] + assert(publishStrategy.currentFeerate < publishStrategy.targetFeerate) + assert(publishStrategy.currentFeerate === currentFeerate) + assert(publishStrategy.targetFeerate === TestConstants.feeratePerKw) + publishCommitTx + } + + test("commit tx feerate high enough, not spending anchor output") { + withWatcher(Seq(500 millibtc), f => { + import f._ + + val publishCommitTx = closeChannelWithoutHtlcs(f) + val publishStrategy = publishCommitTx.strategy.asInstanceOf[PublishStrategy.SetFeerate] + alice2watcher.forward(watcher, publishCommitTx.copy(strategy = publishStrategy.copy(targetFeerate = publishStrategy.currentFeerate))) + + // wait for the commit tx and anchor tx to be published + val mempoolTx = getMempoolTxs(bitcoinClient, 1, probe).head + assert(mempoolTx.txid === publishCommitTx.tx.txid) + + val targetFee = Transactions.weight2fee(publishStrategy.currentFeerate, mempoolTx.weight.toInt) + val actualFee = mempoolTx.fees + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + }) + } + + test("commit tx feerate too low, not enough wallet inputs to increase feerate") { + withWatcher(Seq(10.1 millibtc), f => { + import f._ + + val publishCommitTx = closeChannelWithoutHtlcs(f) + alice2watcher.forward(watcher, publishCommitTx) + + // wait for the commit tx to be published, anchor will not be published because we don't have enough funds + val mempoolTx1 = getMempoolTxs(bitcoinClient, 1, probe).head + assert(mempoolTx1.txid === publishCommitTx.tx.txid) + + // add more funds to our wallet + bitcoinWallet.getReceiveAddress.pipeTo(probe.ref) + val walletAddress = probe.expectMsgType[String] + sendToAddress(walletAddress, 1 millibtc, probe) + generateBlocks(1) + + // wait for the anchor tx to be published + val mempoolTx2 = getMempoolTxs(bitcoinClient, 1, probe).head + bitcoinClient.getTransaction(mempoolTx2.txid).pipeTo(probe.ref) + val anchorTx = probe.expectMsgType[Transaction] + assert(anchorTx.txIn.exists(_.outPoint.txid == mempoolTx1.txid)) + val targetFee = Transactions.weight2fee(TestConstants.feeratePerKw, (mempoolTx1.weight + mempoolTx2.weight).toInt) + val actualFee = mempoolTx1.fees + mempoolTx2.fees + assert(targetFee * 0.9 <= actualFee && actualFee <= targetFee * 1.1, s"actualFee=$actualFee targetFee=$targetFee") + }) + } + + test("commit tx feerate too low, spending anchor output") { + withWatcher(Seq(500 millibtc), f => { + import f._ + + val publishCommitTx = closeChannelWithoutHtlcs(f) + alice2watcher.forward(watcher, publishCommitTx) + + // wait for the commit tx and anchor tx to be published + val mempoolTxs = getMempoolTxs(bitcoinClient, 2, probe) + assert(mempoolTxs.map(_.txid).contains(publishCommitTx.tx.txid)) + + val targetFee = Transactions.weight2fee(TestConstants.feeratePerKw, 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") + }) + } + + test("commit tx feerate too low, spending anchor outputs with multiple wallet inputs") { + val utxos = Seq( + // channel funding + 10 millibtc, + // bumping utxos + 25000 sat, + 22000 sat, + 15000 sat + ) + withWatcher(utxos, f => { + import f._ + + val publishCommitTx = closeChannelWithoutHtlcs(f) + alice2watcher.forward(watcher, publishCommitTx) + + // wait for the commit tx and anchor tx to be published + val mempoolTxs = getMempoolTxs(bitcoinClient, 2, probe) + assert(mempoolTxs.map(_.txid).contains(publishCommitTx.tx.txid)) + val claimAnchorTx = mempoolTxs.find(_.txid != publishCommitTx.tx.txid).map(tx => { + bitcoinClient.getTransaction(tx.txid).pipeTo(probe.ref) + probe.expectMsgType[Transaction] + }) + assert(claimAnchorTx.nonEmpty) + assert(claimAnchorTx.get.txIn.length > 2) // we added more than 1 wallet input + + val targetFee = Transactions.weight2fee(TestConstants.feeratePerKw, 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") + }) + } + + test("adjust anchor tx change amount", Tag("fuzzy")) { + withWatcher(Seq(500 millibtc), f => { + val PublishAsap(commitTx, PublishStrategy.SetFeerate(currentFeerate, targetFeerate, dustLimit, signingKit: ClaimAnchorOutputSigningKit)) = closeChannelWithoutHtlcs(f) + 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 = signingKit.txWithInput.copy(tx = signingKit.txWithInput.tx.copy( + txIn = signingKit.txWithInput.tx.txIn ++ walletInputs, + txOut = TxOut(amountOut, Script.pay2wpkh(randomKey.publicKey)) :: Nil, + )) + val adjustedTx = adjustAnchorOutputChange(unsignedTx, commitTx, amountIn, currentFeerate, targetFeerate, dustLimit) + 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(targetFeerate, signedTx.weight() + commitTx.weight()) - Transactions.weight2fee(currentFeerate, commitTx.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") + } + } + }) + } + + def closeChannelWithHtlcs(f: Fixture): (PublishAsap, PublishAsap, PublishAsap) = { + import f._ + + // Add htlcs in both directions and ensure that preimages are available. + addHtlc(5_000_000 msat, alice, bob, alice2bob, bob2alice) + crossSign(alice, bob, alice2bob, bob2alice) + val (r, htlc) = addHtlc(4_000_000 msat, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + probe.send(alice, CMD_FULFILL_HTLC(htlc.id, r, replyTo_opt = Some(probe.ref))) + probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] + + // Force-close channel and verify txs sent to watcher. + val commitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx + val currentFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.feeratePerKw + assert(commitTx.txOut.size === 6) + probe.send(alice, CMD_FORCECLOSE(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FORCECLOSE]] + val publishCommitTx = alice2watcher.expectMsgType[PublishAsap] + assert(alice2watcher.expectMsgType[PublishAsap].strategy === PublishStrategy.JustPublish) // claim main output + val publishHtlcSuccess = alice2watcher.expectMsgType[PublishAsap] + val publishHtlcTimeout = alice2watcher.expectMsgType[PublishAsap] + Seq(publishCommitTx, publishHtlcSuccess, publishHtlcTimeout).foreach(publishTx => { + assert(publishTx.strategy.isInstanceOf[PublishStrategy.SetFeerate]) + val publishStrategy = publishTx.strategy.asInstanceOf[PublishStrategy.SetFeerate] + assert(publishStrategy.currentFeerate === currentFeerate) + assert(publishStrategy.currentFeerate < publishStrategy.targetFeerate) + assert(publishStrategy.targetFeerate === TestConstants.feeratePerKw) + }) + + (publishCommitTx, publishHtlcSuccess, publishHtlcTimeout) + } + + test("htlc tx feerate high enough, not adding wallet inputs") { + withWatcher(Seq(500 millibtc), f => { + import f._ + + val currentFeerate = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.spec.feeratePerKw + val (publishCommitTx, publishHtlcSuccess, publishHtlcTimeout) = closeChannelWithHtlcs(f) + + // Publish the commit tx. + alice2watcher.forward(watcher, publishCommitTx) + alice2watcher.forward(watcher, publishHtlcSuccess.copy(strategy = publishHtlcSuccess.strategy.asInstanceOf[PublishStrategy.SetFeerate].copy(targetFeerate = currentFeerate))) + alice2watcher.forward(watcher, publishHtlcTimeout.copy(strategy = publishHtlcTimeout.strategy.asInstanceOf[PublishStrategy.SetFeerate].copy(targetFeerate = currentFeerate))) + // HTLC txs will only be published once the commit tx is confirmed (csv delay) + getMempoolTxs(bitcoinClient, 2, probe) + generateBlocks(2) + + // The HTLC-success tx will be immediately published. + val htlcSuccessTx = getMempoolTxs(bitcoinClient, 1, probe).head + val htlcSuccessTargetFee = Transactions.weight2fee(currentFeerate, htlcSuccessTx.weight.toInt) + assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.1, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee") + + // The HTLC-timeout tx will be published once its absolute timeout is satisfied. + generateBlocks(144) + val htlcTimeoutTx = getMempoolTxs(bitcoinClient, 1, probe).head + val htlcTimeoutTargetFee = Transactions.weight2fee(currentFeerate, htlcTimeoutTx.weight.toInt) + assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx.fees && htlcTimeoutTx.fees <= htlcTimeoutTargetFee * 1.1, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcTimeoutTargetFee") + }) + } + + test("htlc tx feerate too low, not enough wallet inputs to increase feerate") { + withWatcher(Seq(10.1 millibtc), f => { + import f._ + + val initialBlockCount = blockCount.get() + val (publishCommitTx, publishHtlcSuccess, _) = closeChannelWithHtlcs(f) + val publishCommitStrategy = publishCommitTx.strategy.asInstanceOf[PublishStrategy.SetFeerate] + + // Publish the commit tx without the anchor. + alice2watcher.forward(watcher, publishCommitTx.copy(strategy = publishCommitStrategy.copy(targetFeerate = publishCommitStrategy.currentFeerate))) + alice2watcher.forward(watcher, publishHtlcSuccess) + // HTLC txs will only be published once the commit tx is confirmed (csv delay) + getMempoolTxs(bitcoinClient, 1, probe) + generateBlocks(2) + awaitCond(blockCount.get() > initialBlockCount) + + // Add more funds to our wallet to allow bumping HTLC txs. + bitcoinWallet.getReceiveAddress.pipeTo(probe.ref) + val walletAddress = probe.expectMsgType[String] + sendToAddress(walletAddress, 1 millibtc, probe) + generateBlocks(1) + + // The HTLC-success tx will be immediately published. + val htlcSuccessTx = getMempoolTxs(bitcoinClient, 1, probe).head + val htlcSuccessTargetFee = Transactions.weight2fee(TestConstants.feeratePerKw, htlcSuccessTx.weight.toInt) + assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.1, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee") + }) + } + + test("htlc tx feerate too low, adding wallet inputs") { + withWatcher(Seq(500 millibtc), f => { + import f._ + + val (publishCommitTx, publishHtlcSuccess, publishHtlcTimeout) = closeChannelWithHtlcs(f) + + // Publish the commit tx. + alice2watcher.forward(watcher, publishCommitTx) + alice2watcher.forward(watcher, publishHtlcSuccess) + alice2watcher.forward(watcher, publishHtlcTimeout) + // HTLC txs will only be published once the commit tx is confirmed (csv delay) + getMempoolTxs(bitcoinClient, 2, probe) + generateBlocks(2) + + // The HTLC-success tx will be immediately published. + val htlcSuccessTx = getMempoolTxs(bitcoinClient, 1, probe).head + val htlcSuccessTargetFee = Transactions.weight2fee(TestConstants.feeratePerKw, htlcSuccessTx.weight.toInt) + assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.1, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee") + + // The HTLC-timeout tx will be published once its absolute timeout is satisfied. + generateBlocks(144) + val htlcTimeoutTx = getMempoolTxs(bitcoinClient, 1, probe).head + val htlcTimeoutTargetFee = Transactions.weight2fee(TestConstants.feeratePerKw, htlcTimeoutTx.weight.toInt) + assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx.fees && htlcTimeoutTx.fees <= htlcTimeoutTargetFee * 1.1, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcTimeoutTargetFee") + }) + } + + test("htlc tx feerate too low, adding multiple wallet inputs") { + val utxos = Seq( + // channel funding + 10 millibtc, + // bumping utxos + 6000 sat, + 5900 sat, + 5800 sat, + 5700 sat, + 5600 sat, + 5500 sat, + 5400 sat, + 5300 sat, + 5200 sat, + 5100 sat + ) + withWatcher(utxos, f => { + import f._ + + val (publishCommitTx, publishHtlcSuccess, publishHtlcTimeout) = closeChannelWithHtlcs(f) + val publishCommitStrategy = publishCommitTx.strategy.asInstanceOf[PublishStrategy.SetFeerate] + + // Publish the commit tx without the anchor. + alice2watcher.forward(watcher, publishCommitTx.copy(strategy = publishCommitStrategy.copy(targetFeerate = publishCommitStrategy.currentFeerate))) + alice2watcher.forward(watcher, publishHtlcSuccess) + alice2watcher.forward(watcher, publishHtlcTimeout) + // HTLC txs will only be published once the commit tx is confirmed (csv delay) + getMempoolTxs(bitcoinClient, 1, probe) + generateBlocks(2) + + // The HTLC-success tx will be immediately published. + val htlcSuccessTx = getMempoolTxs(bitcoinClient, 1, probe).head + bitcoinClient.getTransaction(htlcSuccessTx.txid).pipeTo(probe.ref) + assert(probe.expectMsgType[Transaction].txIn.length > 2) // we added more than 1 wallet input + val htlcSuccessTargetFee = Transactions.weight2fee(TestConstants.feeratePerKw, htlcSuccessTx.weight.toInt) + assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.1, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee") + + // The HTLC-timeout tx will be published once its absolute timeout is satisfied. + generateBlocks(144) + val htlcTimeoutTx = getMempoolTxs(bitcoinClient, 1, probe).head + bitcoinClient.getTransaction(htlcTimeoutTx.txid).pipeTo(probe.ref) + assert(probe.expectMsgType[Transaction].txIn.length > 2) // we added more than 1 wallet input + val htlcTimeoutTargetFee = Transactions.weight2fee(TestConstants.feeratePerKw, htlcTimeoutTx.weight.toInt) + assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx.fees && htlcTimeoutTx.fees <= htlcTimeoutTargetFee * 1.1, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcTimeoutTargetFee") + }) + } + + test("htlc tx sent after commit tx confirmed") { + withWatcher(Seq(500 millibtc), f => { + import f._ + + // Add incoming htlc. + val (r, htlc) = addHtlc(5_000_000 msat, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + + // Force-close channel and verify txs sent to watcher. + val commitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx + assert(commitTx.txOut.size === 5) + probe.send(alice, CMD_FORCECLOSE(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FORCECLOSE]] + val publishCommitTx = alice2watcher.expectMsgType[PublishAsap] + assert(alice2watcher.expectMsgType[PublishAsap].strategy === PublishStrategy.JustPublish) // claim main output + alice2watcher.expectMsgType[WatchConfirmed] // commit tx + alice2watcher.expectMsgType[WatchConfirmed] // claim main output + alice2watcher.expectNoMsg(100 millis) // alice doesn't have the preimage yet to redeem the htlc + + // Publish and confirm the commit tx. + alice2watcher.forward(watcher, publishCommitTx) + getMempoolTxs(bitcoinClient, 2, probe) + generateBlocks(2) + + probe.send(alice, CMD_FULFILL_HTLC(htlc.id, r, replyTo_opt = Some(probe.ref))) + probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] + alice2watcher.expectMsg(publishCommitTx) + assert(alice2watcher.expectMsgType[PublishAsap].strategy === PublishStrategy.JustPublish) // claim main output + val publishHtlcSuccess = alice2watcher.expectMsgType[PublishAsap] + alice2watcher.forward(watcher, publishHtlcSuccess) + + // The HTLC-success tx will be immediately published. + val htlcSuccessTx = getMempoolTxs(bitcoinClient, 1, probe).head + val htlcSuccessTargetFee = Transactions.weight2fee(TestConstants.feeratePerKw, htlcSuccessTx.weight.toInt) + assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.1, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee") + }) + } + + test("adjust htlc tx change amount", Tag("fuzzy")) { + withWatcher(Seq(500 millibtc), f => { + val (_, publishHtlcSuccess, publishHtlcTimeout) = closeChannelWithHtlcs(f) + val PublishAsap(htlcSuccessTx, PublishStrategy.SetFeerate(_, targetFeerate, dustLimit, successSigningKit: HtlcSuccessSigningKit)) = publishHtlcSuccess + val PublishAsap(htlcTimeoutTx, PublishStrategy.SetFeerate(_, _, _, timeoutSigningKit: HtlcTimeoutSigningKit)) = publishHtlcTimeout + 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 = successSigningKit.txWithInput.copy(tx = htlcSuccessTx.copy( + txIn = htlcSuccessTx.txIn ++ walletInputs, + txOut = htlcSuccessTx.txOut ++ Seq(changeOutput) + )) + val unsignedHtlcTimeoutTx = timeoutSigningKit.txWithInput.copy(tx = htlcTimeoutTx.copy( + txIn = htlcTimeoutTx.txIn ++ walletInputs, + txOut = htlcTimeoutTx.txOut ++ Seq(changeOutput) + )) + for ((unsignedTx, signingKit) <- Seq((unsignedHtlcSuccessTx, successSigningKit), (unsignedHtlcTimeoutTx, timeoutSigningKit))) { + val totalAmountIn = unsignedTx.input.txOut.amount + walletAmountIn + val adjustedTx = adjustHtlcTxChange(unsignedTx, totalAmountIn, targetFeerate, dustLimit, signingKit) + 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 = addHtlcTxSigs(adjustedTx, Transactions.PlaceHolderSig, signingKit) + 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") + } + } + } + }) } } \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcherSpec.scala index 2ff19bf5db..154c25399d 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/electrum/ElectrumWatcherSpec.scala @@ -184,11 +184,11 @@ class ElectrumWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bit // spend tx1 with an absolute delay but no relative delay val spend1 = createSpendP2WPKH(tx1, priv1, recipient, 5000 sat, sequence = 0, lockTime = blockCount.get + 1) probe.send(watcher, WatchSpent(listener.ref, tx1, spend1.txIn.head.outPoint.index.toInt, BITCOIN_FUNDING_SPENT, hints = Set.empty)) - probe.send(watcher, PublishAsap(spend1)) + probe.send(watcher, PublishAsap(spend1, PublishStrategy.JustPublish)) // spend tx2 with a relative delay but no absolute delay val spend2 = createSpendP2WPKH(tx2, priv2, recipient, 3000 sat, sequence = 1, lockTime = 0) probe.send(watcher, WatchSpent(listener.ref, tx2, spend2.txIn.head.outPoint.index.toInt, BITCOIN_FUNDING_SPENT, hints = Set.empty)) - probe.send(watcher, PublishAsap(spend2)) + probe.send(watcher, PublishAsap(spend2, PublishStrategy.JustPublish)) generateBlocks(1) listener.expectMsgAllOf(WatchEventSpent(BITCOIN_FUNDING_SPENT, spend1), WatchEventSpent(BITCOIN_FUNDING_SPENT, spend2)) @@ -220,7 +220,7 @@ class ElectrumWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bit // spend tx with both relative and absolute delays val spend = createSpendP2WPKH(tx, priv, recipient, 6000 sat, sequence = 1, lockTime = blockCount.get + 2) probe.send(watcher, WatchSpent(listener.ref, tx, spend.txIn.head.outPoint.index.toInt, BITCOIN_FUNDING_SPENT, hints = Set.empty)) - probe.send(watcher, PublishAsap(spend)) + probe.send(watcher, PublishAsap(spend, PublishStrategy.JustPublish)) generateBlocks(2) listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, spend)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelTypesSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelTypesSpec.scala index d0457fd187..cb91bf3898 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelTypesSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/ChannelTypesSpec.scala @@ -1,13 +1,17 @@ package fr.acinq.eclair.channel -import fr.acinq.bitcoin.{OutPoint, SatoshiLong, Transaction, TxIn, TxOut} +import akka.testkit.{TestFSMRef, TestProbe} +import fr.acinq.bitcoin.{ByteVector32, OutPoint, SatoshiLong, Transaction, TxIn, TxOut} +import fr.acinq.eclair.blockchain.WatchEventSpent import fr.acinq.eclair.channel.Helpers.Closing -import fr.acinq.eclair.randomBytes32 +import fr.acinq.eclair.channel.states.StateTestsHelperMethods import fr.acinq.eclair.transactions.Transactions -import org.scalatest.funsuite.AnyFunSuite +import fr.acinq.eclair.wire.{CommitSig, RevokeAndAck, UpdateAddHtlc} +import fr.acinq.eclair.{MilliSatoshiLong, TestKitBaseClass} +import org.scalatest.funsuite.AnyFunSuiteLike import scodec.bits.ByteVector -class ChannelTypesSpec extends AnyFunSuite { +class ChannelTypesSpec extends TestKitBaseClass with AnyFunSuiteLike with StateTestsHelperMethods { test("standard channel features include deterministic channel key path") { assert(!ChannelVersion.ZEROES.hasPubkeyKeyPath) @@ -49,106 +53,383 @@ class ChannelTypesSpec extends AnyFunSuite { } } + case class HtlcWithPreimage(preimage: ByteVector32, htlc: UpdateAddHtlc) + + case class Fixture(alice: TestFSMRef[State, Data, Channel], alicePendingHtlc: HtlcWithPreimage, bob: TestFSMRef[State, Data, Channel], bobPendingHtlc: HtlcWithPreimage, probe: TestProbe) + + private def setupClosingChannel(testTags: Set[String] = Set.empty): Fixture = { + val probe = TestProbe() + val setup = init() + reachNormal(setup, testTags) + import setup._ + awaitCond(alice.stateName == NORMAL) + awaitCond(bob.stateName == NORMAL) + val (ra1, htlca1) = addHtlc(15_000_000 msat, alice, bob, alice2bob, bob2alice) + val (ra2, htlca2) = addHtlc(16_000_000 msat, alice, bob, alice2bob, bob2alice) + addHtlc(500_000 msat, alice, bob, alice2bob, bob2alice) // below dust + crossSign(alice, bob, alice2bob, bob2alice) + val (rb1, htlcb1) = addHtlc(17_000_000 msat, bob, alice, bob2alice, alice2bob) + val (rb2, htlcb2) = addHtlc(18_000_000 msat, bob, alice, bob2alice, alice2bob) + addHtlc(400_000 msat, bob, alice, bob2alice, alice2bob) // below dust + crossSign(bob, alice, bob2alice, alice2bob) + + // Alice and Bob both know the preimage for only one of the two HTLCs they received. + alice ! CMD_FULFILL_HTLC(htlcb1.id, rb1, replyTo_opt = Some(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] + bob ! CMD_FULFILL_HTLC(htlca1.id, ra1, replyTo_opt = Some(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] + + // Alice publishes her commitment. + alice ! CMD_FORCECLOSE(probe.ref) + probe.expectMsgType[CommandSuccess[CMD_FORCECLOSE]] + awaitCond(alice.stateName == CLOSING) + + // Bob detects it. + bob ! WatchEventSpent(BITCOIN_FUNDING_SPENT, alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get.commitTx) + awaitCond(bob.stateName == CLOSING) + + Fixture(alice, HtlcWithPreimage(rb2, htlcb2), bob, HtlcWithPreimage(ra2, htlca2), TestProbe()) + } + test("local commit published") { - val (lcp, _, _) = createClosingTransactions() + val f = setupClosingChannel() + import f._ + + val aliceClosing = alice.stateData.asInstanceOf[DATA_CLOSING] + assert(aliceClosing.localCommitPublished.nonEmpty) + val lcp = aliceClosing.localCommitPublished.get + assert(lcp.commitTx.txOut.length === 6) + assert(lcp.claimMainDelayedOutputTx.nonEmpty) + assert(lcp.htlcTimeoutTxs.length === 2) + assert(lcp.htlcSuccessTxs.length === 1) // we only have the preimage for 1 of the 2 non-dust htlcs + assert(lcp.claimHtlcDelayedTxs.length === 3) assert(!lcp.isConfirmed) - assert(!Closing.isLocalCommitDone(lcp)) + assert(!Closing.isLocalCommitDone(lcp, aliceClosing.commitments)) // Commit tx has been confirmed. val lcp1 = Closing.updateLocalCommitPublished(lcp, lcp.commitTx) assert(lcp1.irrevocablySpent.nonEmpty) assert(lcp1.isConfirmed) - assert(!Closing.isLocalCommitDone(lcp1)) + assert(!Closing.isLocalCommitDone(lcp1, aliceClosing.commitments)) // Main output has been confirmed. val lcp2 = Closing.updateLocalCommitPublished(lcp1, lcp.claimMainDelayedOutputTx.get) assert(lcp2.isConfirmed) - assert(!Closing.isLocalCommitDone(lcp2)) + assert(!Closing.isLocalCommitDone(lcp2, aliceClosing.commitments)) + + val bobClosing = bob.stateData.asInstanceOf[DATA_CLOSING] + assert(bobClosing.remoteCommitPublished.nonEmpty) + val rcp = bobClosing.remoteCommitPublished.get + + // Scenario 1: our HTLC txs are confirmed, they claim the remaining HTLC + { + val lcp3 = (lcp.htlcSuccessTxs ++ lcp.htlcTimeoutTxs ++ lcp.claimHtlcDelayedTxs).foldLeft(lcp2) { + case (current, tx) => Closing.updateLocalCommitPublished(current, tx) + } + assert(!Closing.isLocalCommitDone(lcp3, aliceClosing.commitments)) - // Our htlc-success txs and their 3rd-stage claim txs have been confirmed. - val lcp3 = Seq(lcp.htlcSuccessTxs.head, lcp.claimHtlcDelayedTxs.head, lcp.htlcSuccessTxs(1), lcp.claimHtlcDelayedTxs(1)).foldLeft(lcp2) { - case (current, tx) => Closing.updateLocalCommitPublished(current, tx) + val theirClaimHtlcTimeout = rcp.claimHtlcTimeoutTxs.find(tx => tx.txIn.head.outPoint != lcp.htlcSuccessTxs.head.txIn.head.outPoint).get + val lcp4 = Closing.updateLocalCommitPublished(lcp3, theirClaimHtlcTimeout) + assert(Closing.isLocalCommitDone(lcp4, aliceClosing.commitments)) } - assert(lcp3.isConfirmed) - assert(!Closing.isLocalCommitDone(lcp3)) - // Scenario 1: our htlc-timeout txs and their 3rd-stage claim txs have been confirmed. + // Scenario 2: our HTLC txs are confirmed and we claim the remaining HTLC { - val lcp4a = Seq(lcp.htlcTimeoutTxs.head, lcp.claimHtlcDelayedTxs(2), lcp.htlcTimeoutTxs(1)).foldLeft(lcp3) { + val lcp3 = (lcp.htlcSuccessTxs ++ lcp.htlcTimeoutTxs ++ lcp.claimHtlcDelayedTxs).foldLeft(lcp2) { case (current, tx) => Closing.updateLocalCommitPublished(current, tx) } - assert(lcp4a.isConfirmed) - assert(!Closing.isLocalCommitDone(lcp4a)) + assert(!Closing.isLocalCommitDone(lcp3, aliceClosing.commitments)) + + alice ! CMD_FULFILL_HTLC(alicePendingHtlc.htlc.id, alicePendingHtlc.preimage, replyTo_opt = Some(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] + val aliceClosing1 = alice.stateData.asInstanceOf[DATA_CLOSING] + val lcp4 = aliceClosing1.localCommitPublished.get.copy(irrevocablySpent = lcp3.irrevocablySpent) + assert(lcp4.htlcSuccessTxs.length === 2) + assert(lcp4.claimHtlcDelayedTxs.length === 4) + val newHtlcSuccessTx = lcp4.htlcSuccessTxs.find(tx => tx.txid != lcp.htlcSuccessTxs.head.txid).get + val newClaimHtlcDelayedTx = lcp4.claimHtlcDelayedTxs.find(tx => tx.txIn.head.outPoint.txid === newHtlcSuccessTx.txid).get + + val lcp5 = Closing.updateLocalCommitPublished(lcp4, newHtlcSuccessTx) + assert(!Closing.isLocalCommitDone(lcp5, aliceClosing1.commitments)) + + val lcp6 = Closing.updateLocalCommitPublished(lcp5, newClaimHtlcDelayedTx) + assert(Closing.isLocalCommitDone(lcp6, aliceClosing1.commitments)) + } - val lcp4b = Closing.updateLocalCommitPublished(lcp4a, lcp.claimHtlcDelayedTxs(3)) - assert(lcp4b.isConfirmed) - assert(Closing.isLocalCommitDone(lcp4b)) + // Scenario 3: they fulfill one of the HTLCs we sent them + { + val lcp3 = (lcp.htlcSuccessTxs ++ rcp.claimHtlcSuccessTxs).foldLeft(lcp2) { + case (current, tx) => Closing.updateLocalCommitPublished(current, tx) + } + assert(!Closing.isLocalCommitDone(lcp3, aliceClosing.commitments)) + + val remainingHtlcTimeoutTxs = lcp.htlcTimeoutTxs.filter(tx => tx.txIn.head.outPoint != rcp.claimHtlcSuccessTxs.head.txIn.head.outPoint) + val claimHtlcDelayedTxs = lcp.claimHtlcDelayedTxs.filter(tx => (remainingHtlcTimeoutTxs ++ lcp.htlcSuccessTxs).map(_.txid).contains(tx.txIn.head.outPoint.txid)) + val lcp4 = (remainingHtlcTimeoutTxs ++ claimHtlcDelayedTxs).foldLeft(lcp3) { + case (current, tx) => Closing.updateLocalCommitPublished(current, tx) + } + assert(!Closing.isLocalCommitDone(lcp4, aliceClosing.commitments)) + + val theirClaimHtlcTimeout = rcp.claimHtlcTimeoutTxs.find(tx => tx.txIn.head.outPoint != lcp.htlcSuccessTxs.head.txIn.head.outPoint).get + val lcp5 = Closing.updateLocalCommitPublished(lcp4, theirClaimHtlcTimeout) + assert(Closing.isLocalCommitDone(lcp5, aliceClosing.commitments)) } - // Scenario 2: they claim the htlcs we sent before our htlc-timeout. + // Scenario 4: they get back the HTLCs they sent us { - val claimHtlcSuccess1 = lcp.htlcTimeoutTxs.head.copy(txOut = Seq(TxOut(3000.sat, ByteVector.empty), TxOut(2500.sat, ByteVector.empty))) - val lcp4a = Closing.updateLocalCommitPublished(lcp3, claimHtlcSuccess1) - assert(lcp4a.isConfirmed) - assert(!Closing.isLocalCommitDone(lcp4a)) + val claimHtlcTimeoutDelayedTxs = lcp.claimHtlcDelayedTxs.filter(tx => lcp.htlcTimeoutTxs.map(_.txid).contains(tx.txIn.head.outPoint.txid)) + val lcp3 = (lcp.htlcTimeoutTxs ++ claimHtlcTimeoutDelayedTxs).foldLeft(lcp2) { + case (current, tx) => Closing.updateLocalCommitPublished(current, tx) + } + assert(!Closing.isLocalCommitDone(lcp3, aliceClosing.commitments)) - val claimHtlcSuccess2 = lcp.htlcTimeoutTxs(1).copy(txOut = Seq(TxOut(3500.sat, ByteVector.empty), TxOut(3100.sat, ByteVector.empty))) - val lcp4b = Closing.updateLocalCommitPublished(lcp4a, claimHtlcSuccess2) - assert(lcp4b.isConfirmed) - assert(Closing.isLocalCommitDone(lcp4b)) + val lcp4 = Closing.updateLocalCommitPublished(lcp3, rcp.claimHtlcTimeoutTxs.head) + assert(!Closing.isLocalCommitDone(lcp4, aliceClosing.commitments)) + + val lcp5 = Closing.updateLocalCommitPublished(lcp4, rcp.claimHtlcTimeoutTxs.last) + assert(Closing.isLocalCommitDone(lcp5, aliceClosing.commitments)) } } test("remote commit published") { - val (_, rcp, _) = createClosingTransactions() + val f = setupClosingChannel() + import f._ + + val keyManager = bob.underlyingActor.nodeParams.channelKeyManager + val bobClosing = bob.stateData.asInstanceOf[DATA_CLOSING] + assert(bobClosing.remoteCommitPublished.nonEmpty) + val rcp = bobClosing.remoteCommitPublished.get + assert(rcp.commitTx.txOut.length === 6) + assert(rcp.claimMainOutputTx.nonEmpty) + assert(rcp.claimHtlcTimeoutTxs.length === 2) + assert(rcp.claimHtlcSuccessTxs.length === 1) // we only have the preimage for 1 of the 2 non-dust htlcs + assert(!rcp.isConfirmed) + assert(!Closing.isRemoteCommitDone(keyManager, rcp, bobClosing.commitments)) + + // Commit tx has been confirmed. + val rcp1 = Closing.updateRemoteCommitPublished(rcp, rcp.commitTx) + assert(rcp1.irrevocablySpent.nonEmpty) + assert(rcp1.isConfirmed) + assert(!Closing.isRemoteCommitDone(keyManager, rcp1, bobClosing.commitments)) + + // Main output has been confirmed. + val rcp2 = Closing.updateRemoteCommitPublished(rcp1, rcp.claimMainOutputTx.get) + assert(rcp2.isConfirmed) + assert(!Closing.isRemoteCommitDone(keyManager, rcp2, bobClosing.commitments)) + + val aliceClosing = alice.stateData.asInstanceOf[DATA_CLOSING] + assert(aliceClosing.localCommitPublished.nonEmpty) + val lcp = aliceClosing.localCommitPublished.get + + // Scenario 1: our claim-HTLC txs are confirmed, they claim the remaining HTLC + { + val rcp3 = (rcp.claimHtlcSuccessTxs ++ rcp.claimHtlcTimeoutTxs).foldLeft(rcp2) { + case (current, tx) => Closing.updateRemoteCommitPublished(current, tx) + } + assert(!Closing.isRemoteCommitDone(keyManager, rcp3, bobClosing.commitments)) + + val theirHtlcTimeout = lcp.htlcTimeoutTxs.find(tx => tx.txIn.head.outPoint != rcp.claimHtlcSuccessTxs.head.txIn.head.outPoint).get + val rcp4 = Closing.updateRemoteCommitPublished(rcp3, theirHtlcTimeout) + assert(Closing.isRemoteCommitDone(keyManager, rcp4, bobClosing.commitments)) + } + + // Scenario 2: our claim-HTLC txs are confirmed and we claim the remaining HTLC + { + val rcp3 = (rcp.claimHtlcSuccessTxs ++ rcp.claimHtlcTimeoutTxs).foldLeft(rcp2) { + case (current, tx) => Closing.updateRemoteCommitPublished(current, tx) + } + assert(!Closing.isRemoteCommitDone(keyManager, rcp3, bobClosing.commitments)) + + bob ! CMD_FULFILL_HTLC(bobPendingHtlc.htlc.id, bobPendingHtlc.preimage, replyTo_opt = Some(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] + val bobClosing1 = bob.stateData.asInstanceOf[DATA_CLOSING] + val rcp4 = bobClosing1.remoteCommitPublished.get.copy(irrevocablySpent = rcp3.irrevocablySpent) + assert(rcp4.claimHtlcSuccessTxs.length === 2) + val newClaimHtlcSuccessTx = rcp4.claimHtlcSuccessTxs.find(tx => tx.txid != rcp.claimHtlcSuccessTxs.head.txid).get + + val rcp5 = Closing.updateRemoteCommitPublished(rcp4, newClaimHtlcSuccessTx) + assert(Closing.isRemoteCommitDone(keyManager, rcp5, bobClosing1.commitments)) + } + + // Scenario 3: they fulfill one of the HTLCs we sent them + { + val rcp3 = (lcp.htlcSuccessTxs ++ rcp.claimHtlcSuccessTxs).foldLeft(rcp2) { + case (current, tx) => Closing.updateRemoteCommitPublished(current, tx) + } + assert(!Closing.isRemoteCommitDone(keyManager, rcp3, bobClosing.commitments)) + + val remainingClaimHtlcTimeoutTx = rcp.claimHtlcTimeoutTxs.find(tx => tx.txIn.head.outPoint != lcp.htlcSuccessTxs.head.txIn.head.outPoint).get + val rcp4 = Closing.updateRemoteCommitPublished(rcp3, remainingClaimHtlcTimeoutTx) + assert(!Closing.isRemoteCommitDone(keyManager, rcp4, bobClosing.commitments)) + + val theirHtlcTimeout = lcp.htlcTimeoutTxs.find(tx => tx.txIn.head.outPoint != rcp.claimHtlcSuccessTxs.head.txIn.head.outPoint).get + val rcp5 = Closing.updateRemoteCommitPublished(rcp4, theirHtlcTimeout) + assert(Closing.isRemoteCommitDone(keyManager, rcp5, bobClosing.commitments)) + } + + // Scenario 4: they get back the HTLCs they sent us + { + val rcp3 = rcp.claimHtlcTimeoutTxs.foldLeft(rcp2) { + case (current, tx) => Closing.updateRemoteCommitPublished(current, tx) + } + assert(!Closing.isRemoteCommitDone(keyManager, rcp3, bobClosing.commitments)) + + val rcp4 = Closing.updateRemoteCommitPublished(rcp3, lcp.htlcTimeoutTxs.head) + assert(!Closing.isRemoteCommitDone(keyManager, rcp4, bobClosing.commitments)) + + val rcp5 = Closing.updateRemoteCommitPublished(rcp4, lcp.htlcTimeoutTxs.last) + assert(Closing.isRemoteCommitDone(keyManager, rcp5, bobClosing.commitments)) + } + } + + test("next remote commit published") { + val probe = TestProbe() + val setup = init() + reachNormal(setup) + import setup._ + awaitCond(alice.stateName == NORMAL) + awaitCond(bob.stateName == NORMAL) + val (ra1, htlca1) = addHtlc(15_000_000 msat, alice, bob, alice2bob, bob2alice) + val (ra2, htlca2) = addHtlc(16_000_000 msat, alice, bob, alice2bob, bob2alice) + addHtlc(500_000 msat, alice, bob, alice2bob, bob2alice) // below dust + crossSign(alice, bob, alice2bob, bob2alice) + val (rb1, htlcb1) = addHtlc(17_000_000 msat, bob, alice, bob2alice, alice2bob) + addHtlc(400_000 msat, bob, alice, bob2alice, alice2bob) // below dust + crossSign(bob, alice, bob2alice, alice2bob) + addHtlc(18_000_000 msat, bob, alice, bob2alice, alice2bob) + bob ! CMD_SIGN(Some(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_SIGN]] + bob2alice.expectMsgType[CommitSig] + bob2alice.forward(alice) + alice2bob.expectMsgType[RevokeAndAck] + + // Alice and Bob both know the preimage for only one of the two HTLCs they received. + alice ! CMD_FULFILL_HTLC(htlcb1.id, rb1, replyTo_opt = Some(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] + bob ! CMD_FULFILL_HTLC(htlca1.id, ra1, replyTo_opt = Some(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] + + // Alice publishes her last commitment. + alice ! CMD_FORCECLOSE(probe.ref) + probe.expectMsgType[CommandSuccess[CMD_FORCECLOSE]] + awaitCond(alice.stateName == CLOSING) + val aliceClosing = alice.stateData.asInstanceOf[DATA_CLOSING] + val lcp = aliceClosing.localCommitPublished.get + + // Bob detects it. + bob ! WatchEventSpent(BITCOIN_FUNDING_SPENT, alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get.commitTx) + awaitCond(bob.stateName == CLOSING) + + val keyManager = bob.underlyingActor.nodeParams.channelKeyManager + val bobClosing = bob.stateData.asInstanceOf[DATA_CLOSING] + assert(bobClosing.nextRemoteCommitPublished.nonEmpty) + val rcp = bobClosing.nextRemoteCommitPublished.get + assert(rcp.commitTx.txOut.length === 6) + assert(rcp.claimMainOutputTx.nonEmpty) + assert(rcp.claimHtlcTimeoutTxs.length === 2) + assert(rcp.claimHtlcSuccessTxs.length === 1) // we only have the preimage for 1 of the 2 non-dust htlcs assert(!rcp.isConfirmed) - assert(!Closing.isRemoteCommitDone(rcp)) + assert(!Closing.isRemoteCommitDone(keyManager, rcp, bobClosing.commitments)) // Commit tx has been confirmed. val rcp1 = Closing.updateRemoteCommitPublished(rcp, rcp.commitTx) assert(rcp1.irrevocablySpent.nonEmpty) assert(rcp1.isConfirmed) - assert(!Closing.isRemoteCommitDone(rcp1)) + assert(!Closing.isRemoteCommitDone(keyManager, rcp1, bobClosing.commitments)) // Main output has been confirmed. val rcp2 = Closing.updateRemoteCommitPublished(rcp1, rcp.claimMainOutputTx.get) assert(rcp2.isConfirmed) - assert(!Closing.isRemoteCommitDone(rcp2)) + assert(!Closing.isRemoteCommitDone(keyManager, rcp2, bobClosing.commitments)) - // One of our claim-htlc-success and claim-htlc-timeout has been confirmed. - val rcp3 = Seq(rcp.claimHtlcSuccessTxs.head, rcp.claimHtlcTimeoutTxs.head).foldLeft(rcp2) { - case (current, tx) => Closing.updateRemoteCommitPublished(current, tx) + // Scenario 1: our claim-HTLC txs are confirmed, they claim the remaining HTLC + { + val rcp3 = (rcp.claimHtlcSuccessTxs ++ rcp.claimHtlcTimeoutTxs).foldLeft(rcp2) { + case (current, tx) => Closing.updateRemoteCommitPublished(current, tx) + } + assert(!Closing.isRemoteCommitDone(keyManager, rcp3, bobClosing.commitments)) + + val theirHtlcTimeout = lcp.htlcTimeoutTxs.find(tx => tx.txIn.head.outPoint != rcp.claimHtlcSuccessTxs.head.txIn.head.outPoint).get + val rcp4 = Closing.updateRemoteCommitPublished(rcp3, theirHtlcTimeout) + assert(Closing.isRemoteCommitDone(keyManager, rcp4, bobClosing.commitments)) } - assert(rcp3.isConfirmed) - assert(!Closing.isRemoteCommitDone(rcp3)) - // Scenario 1: our remaining claim-htlc txs have been confirmed. + // Scenario 2: our claim-HTLC txs are confirmed and we claim the remaining HTLC { - val rcp4a = Closing.updateRemoteCommitPublished(rcp3, rcp.claimHtlcSuccessTxs(1)) - assert(rcp4a.isConfirmed) - assert(!Closing.isRemoteCommitDone(rcp4a)) + val rcp3 = (rcp.claimHtlcSuccessTxs ++ rcp.claimHtlcTimeoutTxs).foldLeft(rcp2) { + case (current, tx) => Closing.updateRemoteCommitPublished(current, tx) + } + assert(!Closing.isRemoteCommitDone(keyManager, rcp3, bobClosing.commitments)) + + bob ! CMD_FULFILL_HTLC(htlca2.id, ra2, replyTo_opt = Some(probe.ref)) + probe.expectMsgType[CommandSuccess[CMD_FULFILL_HTLC]] + val bobClosing1 = bob.stateData.asInstanceOf[DATA_CLOSING] + val rcp4 = bobClosing1.nextRemoteCommitPublished.get.copy(irrevocablySpent = rcp3.irrevocablySpent) + assert(rcp4.claimHtlcSuccessTxs.length === 2) + val newClaimHtlcSuccessTx = rcp4.claimHtlcSuccessTxs.find(tx => tx.txid != rcp.claimHtlcSuccessTxs.head.txid).get + + val rcp5 = Closing.updateRemoteCommitPublished(rcp4, newClaimHtlcSuccessTx) + assert(Closing.isRemoteCommitDone(keyManager, rcp5, bobClosing1.commitments)) + } + + // Scenario 3: they fulfill one of the HTLCs we sent them + { + val rcp3 = (lcp.htlcSuccessTxs ++ rcp.claimHtlcSuccessTxs).foldLeft(rcp2) { + case (current, tx) => Closing.updateRemoteCommitPublished(current, tx) + } + assert(!Closing.isRemoteCommitDone(keyManager, rcp3, bobClosing.commitments)) + + val remainingClaimHtlcTimeoutTx = rcp.claimHtlcTimeoutTxs.find(tx => tx.txIn.head.outPoint != lcp.htlcSuccessTxs.head.txIn.head.outPoint).get + val rcp4 = Closing.updateRemoteCommitPublished(rcp3, remainingClaimHtlcTimeoutTx) + assert(!Closing.isRemoteCommitDone(keyManager, rcp4, bobClosing.commitments)) - val rcp4b = Closing.updateRemoteCommitPublished(rcp4a, rcp.claimHtlcTimeoutTxs(1)) - assert(rcp4b.isConfirmed) - assert(Closing.isRemoteCommitDone(rcp4b)) + val theirHtlcTimeout = lcp.htlcTimeoutTxs.find(tx => tx.txIn.head.outPoint != rcp.claimHtlcSuccessTxs.head.txIn.head.outPoint).get + val rcp5 = Closing.updateRemoteCommitPublished(rcp4, theirHtlcTimeout) + assert(Closing.isRemoteCommitDone(keyManager, rcp5, bobClosing.commitments)) } - // Scenario 2: they claim the remaining htlc outputs. + // Scenario 4: they get back the HTLCs they sent us { - val htlcSuccess = rcp.claimHtlcSuccessTxs(1).copy(txOut = Seq(TxOut(3000.sat, ByteVector.empty), TxOut(2500.sat, ByteVector.empty))) - val rcp4a = Closing.updateRemoteCommitPublished(rcp3, htlcSuccess) - assert(rcp4a.isConfirmed) - assert(!Closing.isRemoteCommitDone(rcp4a)) + val rcp3 = rcp.claimHtlcTimeoutTxs.foldLeft(rcp2) { + case (current, tx) => Closing.updateRemoteCommitPublished(current, tx) + } + assert(!Closing.isRemoteCommitDone(keyManager, rcp3, bobClosing.commitments)) + + val rcp4 = Closing.updateRemoteCommitPublished(rcp3, lcp.htlcTimeoutTxs.head) + assert(!Closing.isRemoteCommitDone(keyManager, rcp4, bobClosing.commitments)) - val htlcTimeout = rcp.claimHtlcTimeoutTxs(1).copy(txOut = Seq(TxOut(3500.sat, ByteVector.empty), TxOut(3100.sat, ByteVector.empty))) - val rcp4b = Closing.updateRemoteCommitPublished(rcp4a, htlcTimeout) - assert(rcp4b.isConfirmed) - assert(Closing.isRemoteCommitDone(rcp4b)) + val rcp5 = Closing.updateRemoteCommitPublished(rcp4, lcp.htlcTimeoutTxs.last) + assert(Closing.isRemoteCommitDone(keyManager, rcp5, bobClosing.commitments)) } } test("revoked commit published") { - val (_, _, rvk) = createClosingTransactions() + val setup = init() + reachNormal(setup) + import setup._ + awaitCond(alice.stateName == NORMAL) + awaitCond(bob.stateName == NORMAL) + val (ra1, htlca1) = addHtlc(15_000_000 msat, alice, bob, alice2bob, bob2alice) + addHtlc(16_000_000 msat, alice, bob, alice2bob, bob2alice) + addHtlc(500_000 msat, alice, bob, alice2bob, bob2alice) // below dust + crossSign(alice, bob, alice2bob, bob2alice) + addHtlc(17_000_000 msat, bob, alice, bob2alice, alice2bob) + addHtlc(18_000_000 msat, bob, alice, bob2alice, alice2bob) + addHtlc(400_000 msat, bob, alice, bob2alice, alice2bob) // below dust + crossSign(bob, alice, bob2alice, alice2bob) + val revokedCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx + fulfillHtlc(htlca1.id, ra1, bob, alice, bob2alice, alice2bob) + crossSign(bob, alice, bob2alice, alice2bob) + + alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, revokedCommitTx) + awaitCond(alice.stateName == CLOSING) + val aliceClosing = alice.stateData.asInstanceOf[DATA_CLOSING] + assert(aliceClosing.revokedCommitPublished.length === 1) + val rvk = aliceClosing.revokedCommitPublished.head + assert(rvk.claimMainOutputTx.nonEmpty) + assert(rvk.mainPenaltyTx.nonEmpty) + assert(rvk.htlcPenaltyTxs.length === 4) + assert(rvk.claimHtlcDelayedPenaltyTxs.isEmpty) assert(!Closing.isRevokedCommitDone(rvk)) // Commit tx has been confirmed. @@ -161,14 +442,14 @@ class ChannelTypesSpec extends AnyFunSuite { assert(!Closing.isRevokedCommitDone(rvk2)) // Two of our htlc penalty txs have been confirmed. - val rvk3 = Seq(rvk.htlcPenaltyTxs.head, rvk.htlcPenaltyTxs(1)).foldLeft(rvk2) { + val rvk3 = rvk.htlcPenaltyTxs.take(2).foldLeft(rvk2) { case (current, tx) => Closing.updateRevokedCommitPublished(current, tx) } assert(!Closing.isRevokedCommitDone(rvk3)) // Scenario 1: the remaining penalty txs have been confirmed. { - val rvk4a = Seq(rvk.htlcPenaltyTxs(2), rvk.htlcPenaltyTxs(3)).foldLeft(rvk3) { + val rvk4a = rvk.htlcPenaltyTxs.drop(2).foldLeft(rvk3) { case (current, tx) => Closing.updateRevokedCommitPublished(current, tx) } assert(!Closing.isRevokedCommitDone(rvk4a)) @@ -179,19 +460,20 @@ class ChannelTypesSpec extends AnyFunSuite { // Scenario 2: they claim the remaining outputs. { - val remoteMainOutput = rvk.mainPenaltyTx.get.copy(txOut = Seq(TxOut(35000.sat, ByteVector.empty))) + val remoteMainOutput = rvk.mainPenaltyTx.get.copy(txOut = Seq(TxOut(35_000 sat, ByteVector.empty))) val rvk4a = Closing.updateRevokedCommitPublished(rvk3, remoteMainOutput) assert(!Closing.isRevokedCommitDone(rvk4a)) - val htlcSuccess = rvk.htlcPenaltyTxs(2).copy(txOut = Seq(TxOut(3000.sat, ByteVector.empty), TxOut(2500.sat, ByteVector.empty))) - val htlcTimeout = rvk.htlcPenaltyTxs(3).copy(txOut = Seq(TxOut(3500.sat, ByteVector.empty), TxOut(3100.sat, ByteVector.empty))) + val htlcSuccess = rvk.htlcPenaltyTxs(2).copy(txOut = Seq(TxOut(3_000 sat, ByteVector.empty), TxOut(2_500 sat, ByteVector.empty))) + val htlcTimeout = rvk.htlcPenaltyTxs(3).copy(txOut = Seq(TxOut(3_500 sat, ByteVector.empty), TxOut(3_100 sat, ByteVector.empty))) // When Bob claims these outputs, the channel should call Helpers.claimRevokedHtlcTxOutputs to punish them by claiming the output of their htlc tx. + // This is tested in ClosingStateSpec. val rvk4b = Seq(htlcSuccess, htlcTimeout).foldLeft(rvk4a) { case (current, tx) => Closing.updateRevokedCommitPublished(current, tx) }.copy( claimHtlcDelayedPenaltyTxs = List( - Transaction(2, Seq(TxIn(OutPoint(htlcSuccess, 0), ByteVector.empty, 0)), Seq(TxOut(5000.sat, ByteVector.empty)), 0), - Transaction(2, Seq(TxIn(OutPoint(htlcTimeout, 0), ByteVector.empty, 0)), Seq(TxOut(6000.sat, ByteVector.empty)), 0) + Transaction(2, Seq(TxIn(OutPoint(htlcSuccess, 0), ByteVector.empty, 0)), Seq(TxOut(5_000 sat, ByteVector.empty)), 0), + Transaction(2, Seq(TxIn(OutPoint(htlcTimeout, 0), ByteVector.empty, 0)), Seq(TxOut(6_000 sat, ByteVector.empty)), 0) ) ) assert(!Closing.isRevokedCommitDone(rvk4b)) @@ -199,48 +481,10 @@ class ChannelTypesSpec extends AnyFunSuite { // We claim one of the remaining outputs, they claim the other. val rvk5a = Closing.updateRevokedCommitPublished(rvk4b, rvk4b.claimHtlcDelayedPenaltyTxs.head) assert(!Closing.isRevokedCommitDone(rvk5a)) - val theyClaimHtlcTimeout = rvk4b.claimHtlcDelayedPenaltyTxs(1).copy(txOut = Seq(TxOut(1500.sat, ByteVector.empty), TxOut(2500.sat, ByteVector.empty))) - val rvk5b = Closing.updateRevokedCommitPublished(rvk5a, theyClaimHtlcTimeout) + val theirClaimHtlcTimeout = rvk4b.claimHtlcDelayedPenaltyTxs(1).copy(txOut = Seq(TxOut(1_500.sat, ByteVector.empty), TxOut(2_500.sat, ByteVector.empty))) + val rvk5b = Closing.updateRevokedCommitPublished(rvk5a, theirClaimHtlcTimeout) assert(Closing.isRevokedCommitDone(rvk5b)) } } - private def createClosingTransactions(): (LocalCommitPublished, RemoteCommitPublished, RevokedCommitPublished) = { - val commitTx = Transaction( - 2, - Seq(TxIn(OutPoint(randomBytes32, 0), ByteVector.empty, 0)), - Seq( - TxOut(50000.sat, ByteVector.empty), // main output Alice - TxOut(40000.sat, ByteVector.empty), // main output Bob - TxOut(4000.sat, ByteVector.empty), // htlc received #1 - TxOut(5000.sat, ByteVector.empty), // htlc received #2 - TxOut(6000.sat, ByteVector.empty), // htlc sent #1 - TxOut(7000.sat, ByteVector.empty), // htlc sent #2 - ), - 0 - ) - val claimMainAlice = Transaction(2, Seq(TxIn(OutPoint(commitTx, 0), ByteVector.empty, 144)), Seq(TxOut(49500.sat, ByteVector.empty)), 0) - val htlcSuccess1 = Transaction(2, Seq(TxIn(OutPoint(commitTx, 2), ByteVector.empty, 1)), Seq(TxOut(3500.sat, ByteVector.empty)), 0) - val htlcSuccess2 = Transaction(2, Seq(TxIn(OutPoint(commitTx, 3), ByteVector.empty, 1)), Seq(TxOut(4500.sat, ByteVector.empty)), 0) - val htlcTimeout1 = Transaction(2, Seq(TxIn(OutPoint(commitTx, 4), ByteVector.empty, 1)), Seq(TxOut(5500.sat, ByteVector.empty)), 0) - val htlcTimeout2 = Transaction(2, Seq(TxIn(OutPoint(commitTx, 5), ByteVector.empty, 1)), Seq(TxOut(6500.sat, ByteVector.empty)), 0) - - val localCommit = { - val claimHtlcDelayedTxs = List( - Transaction(2, Seq(TxIn(OutPoint(htlcSuccess1, 0), ByteVector.empty, 1)), Seq(TxOut(3400.sat, ByteVector.empty)), 0), - Transaction(2, Seq(TxIn(OutPoint(htlcSuccess2, 0), ByteVector.empty, 1)), Seq(TxOut(4400.sat, ByteVector.empty)), 0), - Transaction(2, Seq(TxIn(OutPoint(htlcTimeout1, 0), ByteVector.empty, 1)), Seq(TxOut(5400.sat, ByteVector.empty)), 0), - Transaction(2, Seq(TxIn(OutPoint(htlcTimeout2, 0), ByteVector.empty, 1)), Seq(TxOut(6400.sat, ByteVector.empty)), 0), - ) - LocalCommitPublished(commitTx, Some(claimMainAlice), List(htlcSuccess1, htlcSuccess2), List(htlcTimeout1, htlcTimeout2), claimHtlcDelayedTxs, Map.empty) - } - val remoteCommit = RemoteCommitPublished(commitTx, Some(claimMainAlice), List(htlcSuccess1, htlcSuccess2), List(htlcTimeout1, htlcTimeout2), Map.empty) - val revokedCommit = { - val mainPenalty = Transaction(2, Seq(TxIn(OutPoint(commitTx, 1), ByteVector.empty, 0)), Seq(TxOut(39500.sat, ByteVector.empty)), 0) - RevokedCommitPublished(commitTx, Some(claimMainAlice), Some(mainPenalty), List(htlcSuccess1, htlcSuccess2, htlcTimeout1, htlcTimeout2), Nil, Map.empty) - } - - (localCommit, remoteCommit, revokedCommit) - } - } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/StateTestsHelperMethods.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/StateTestsHelperMethods.scala index 045177420c..a370a38f06 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/StateTestsHelperMethods.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/StateTestsHelperMethods.scala @@ -27,6 +27,7 @@ import fr.acinq.eclair.channel._ import fr.acinq.eclair.payment.OutgoingPacket import fr.acinq.eclair.payment.OutgoingPacket.Upstream import fr.acinq.eclair.router.Router.ChannelHop +import fr.acinq.eclair.transactions.Transactions import fr.acinq.eclair.wire.Onion.FinalLegacyPayload import fr.acinq.eclair.wire._ import fr.acinq.eclair.{FeatureSupport, Features, NodeParams, TestConstants, randomBytes32, _} @@ -252,18 +253,27 @@ trait StateTestsHelperMethods extends TestKitBase { // an error occurs and s publishes its commit tx val commitTx = s.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx s ! Error(ByteVector32.Zeroes, "oops") - s2blockchain.expectMsg(PublishAsap(commitTx)) + assert(s2blockchain.expectMsgType[PublishAsap].tx == commitTx) awaitCond(s.stateName == CLOSING) val closingState = s.stateData.asInstanceOf[DATA_CLOSING] assert(closingState.localCommitPublished.isDefined) val localCommitPublished = closingState.localCommitPublished.get // if s has a main output in the commit tx (when it has a non-dust balance), it should be claimed - localCommitPublished.claimMainDelayedOutputTx.foreach(tx => s2blockchain.expectMsg(PublishAsap(tx))) - // all htlcs success/timeout should be published - s2blockchain.expectMsgAllOf((localCommitPublished.htlcSuccessTxs ++ localCommitPublished.htlcTimeoutTxs).map(PublishAsap): _*) - // and their outputs should be claimed - s2blockchain.expectMsgAllOf(localCommitPublished.claimHtlcDelayedTxs.map(PublishAsap): _*) + localCommitPublished.claimMainDelayedOutputTx.foreach(tx => s2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish))) + s.stateData.asInstanceOf[DATA_CLOSING].commitments.commitmentFormat match { + case Transactions.DefaultCommitmentFormat => + // all htlcs success/timeout should be published + s2blockchain.expectMsgAllOf((localCommitPublished.htlcSuccessTxs ++ localCommitPublished.htlcTimeoutTxs).map(tx => PublishAsap(tx, PublishStrategy.JustPublish)): _*) + // and their outputs should be claimed + s2blockchain.expectMsgAllOf(localCommitPublished.claimHtlcDelayedTxs.map(tx => PublishAsap(tx, PublishStrategy.JustPublish)): _*) + case Transactions.AnchorOutputsCommitmentFormat => + // all htlcs success/timeout should be published, but their outputs should not be claimed yet + val htlcTxs = localCommitPublished.htlcSuccessTxs ++ localCommitPublished.htlcTimeoutTxs + val publishedTxs = htlcTxs.map(_ => s2blockchain.expectMsgType[PublishAsap]) + assert(publishedTxs.map(_.tx).toSet == htlcTxs.toSet) + publishedTxs.foreach(p => p.strategy.isInstanceOf[PublishStrategy.SetFeerate]) + } // we watch the confirmation of the "final" transactions that send funds to our wallets (main delayed output and 2nd stage htlc transactions) assert(s2blockchain.expectMsgType[WatchConfirmed].event == BITCOIN_TX_CONFIRMED(commitTx)) @@ -297,12 +307,12 @@ trait StateTestsHelperMethods extends TestKitBase { // if s has a main output in the commit tx (when it has a non-dust balance), it should be claimed remoteCommitPublished.claimMainOutputTx.foreach(tx => { Transaction.correctlySpends(tx, rCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - s2blockchain.expectMsg(PublishAsap(tx)) + s2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) }) // all htlcs success/timeout should be claimed val claimHtlcTxs = remoteCommitPublished.claimHtlcSuccessTxs ++ remoteCommitPublished.claimHtlcTimeoutTxs claimHtlcTxs.foreach(tx => Transaction.correctlySpends(tx, rCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)) - s2blockchain.expectMsgAllOf(claimHtlcTxs.map(PublishAsap): _*) + s2blockchain.expectMsgAllOf(claimHtlcTxs.map(tx => PublishAsap(tx, PublishStrategy.JustPublish)): _*) // we watch the confirmation of the "final" transactions that send funds to our wallets (main delayed output and 2nd stage htlc transactions) assert(s2blockchain.expectMsgType[WatchConfirmed].event == BITCOIN_TX_CONFIRMED(rCommitTx)) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala index 9022f912dc..0229df1148 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingConfirmedStateSpec.scala @@ -169,7 +169,7 @@ class WaitForFundingConfirmedStateSpec extends TestKitBaseClass with FixtureAnyF val tx = alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_CONFIRMED].commitments.localCommit.publishableTxs.commitTx.tx alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, Transaction(0, Nil, Nil, 0)) alice2bob.expectMsgType[Error] - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) awaitCond(alice.stateName == ERR_INFORMATION_LEAK) } @@ -178,7 +178,7 @@ class WaitForFundingConfirmedStateSpec extends TestKitBaseClass with FixtureAnyF val tx = alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_CONFIRMED].commitments.localCommit.publishableTxs.commitTx.tx alice ! Error(ByteVector32.Zeroes, "oops") awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] // claim-main-delayed assert(alice2blockchain.expectMsgType[WatchConfirmed].event === BITCOIN_TX_CONFIRMED(tx)) } @@ -197,7 +197,7 @@ class WaitForFundingConfirmedStateSpec extends TestKitBaseClass with FixtureAnyF val tx = alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_CONFIRMED].commitments.localCommit.publishableTxs.commitTx.tx alice ! CMD_FORCECLOSE(sender.ref) awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] // claim-main-delayed assert(alice2blockchain.expectMsgType[WatchConfirmed].event === BITCOIN_TX_CONFIRMED(tx)) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingLockedStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingLockedStateSpec.scala index b4ca733813..2c0269ed1b 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingLockedStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/c/WaitForFundingLockedStateSpec.scala @@ -98,7 +98,7 @@ class WaitForFundingLockedStateSpec extends TestKitBaseClass with FixtureAnyFunS val tx = alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_LOCKED].commitments.localCommit.publishableTxs.commitTx.tx alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, Transaction(0, Nil, Nil, 0)) alice2bob.expectMsgType[Error] - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] awaitCond(alice.stateName == ERR_INFORMATION_LEAK) } @@ -108,7 +108,7 @@ class WaitForFundingLockedStateSpec extends TestKitBaseClass with FixtureAnyFunS val tx = alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_LOCKED].commitments.localCommit.publishableTxs.commitTx.tx alice ! Error(ByteVector32.Zeroes, "oops") awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] assert(alice2blockchain.expectMsgType[WatchConfirmed].event === BITCOIN_TX_CONFIRMED(tx)) } @@ -127,7 +127,7 @@ class WaitForFundingLockedStateSpec extends TestKitBaseClass with FixtureAnyFunS val tx = alice.stateData.asInstanceOf[DATA_WAIT_FOR_FUNDING_LOCKED].commitments.localCommit.publishableTxs.commitTx.tx alice ! CMD_FORCECLOSE(sender.ref) awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] assert(alice2blockchain.expectMsgType[WatchConfirmed].event === BITCOIN_TX_CONFIRMED(tx)) } 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 0f580d10b1..a3809bd8cf 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 @@ -144,22 +144,12 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with import f._ val sender = TestProbe() val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] - // It's usually dangerous for Bob to accept HTLCs that are expiring soon. However it's not Alice's decision to reject - // them when she's asked to relay; she should forward those HTLCs to Bob, and Bob will choose whether to fail them - // or fulfill them (Bob could be #reckless and fulfill HTLCs with a very low expiry delta). - val expiryTooSmall = CltvExpiry(currentBlockHeight + 3) + val expiryTooSmall = CltvExpiry(currentBlockHeight) val add = CMD_ADD_HTLC(sender.ref, 500000000 msat, randomBytes32, expiryTooSmall, TestConstants.emptyOnionPacket, localOrigin(sender.ref)) alice ! add - sender.expectMsgType[RES_SUCCESS[CMD_ADD_HTLC]] - val htlc = alice2bob.expectMsgType[UpdateAddHtlc] - assert(htlc.id === 0) - assert(htlc.cltvExpiry === expiryTooSmall) - awaitCond(alice.stateData == initialState.copy( - commitments = initialState.commitments.copy( - localNextHtlcId = 1, - localChanges = initialState.commitments.localChanges.copy(proposed = htlc :: Nil), - originChannels = Map(0L -> add.origin) - ))) + val error = ExpiryTooSmall(channelId(alice), CltvExpiry(currentBlockHeight), expiryTooSmall, currentBlockHeight) + sender.expectMsg(RES_ADD_FAILED(add, error, Some(initialState.channelUpdate))) + alice2bob.expectNoMsg(200 millis) } test("recv CMD_ADD_HTLC (expiry too big)") { f => @@ -453,7 +443,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray) === UnexpectedHtlcId(channelId(bob), expected = 4, actual = 42).getMessage) awaitCond(bob.stateName == CLOSING) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -468,7 +458,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -483,7 +473,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -499,7 +489,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(tx)) + assert(bob2blockchain.expectMsgType[PublishAsap].tx === tx) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -516,7 +506,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -532,7 +522,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -546,7 +536,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === alice.stateData.asInstanceOf[DATA_CLOSING].channelId) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] alice2blockchain.expectMsgType[WatchConfirmed] } @@ -564,7 +554,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -878,7 +868,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -893,7 +883,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray).startsWith("invalid commitment signature")) awaitCond(bob.stateName == CLOSING) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -912,7 +902,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob ! badCommitSig val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray) === HtlcSigCountMismatch(channelId(bob), expected = 1, actual = 2).getMessage) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -931,7 +921,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with bob ! badCommitSig val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray).startsWith("invalid htlc signature")) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -1043,7 +1033,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === alice.stateData.asInstanceOf[DATA_CLOSING].channelId) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] alice2blockchain.expectMsgType[WatchConfirmed] } @@ -1057,7 +1047,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === alice.stateData.asInstanceOf[DATA_CLOSING].channelId) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] alice2blockchain.expectMsgType[WatchConfirmed] } @@ -1297,7 +1287,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === alice.stateData.asInstanceOf[DATA_CLOSING].channelId) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] alice2blockchain.expectMsgType[WatchConfirmed] } @@ -1310,7 +1300,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === alice.stateData.asInstanceOf[DATA_CLOSING].channelId) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] alice2blockchain.expectMsgType[WatchConfirmed] } @@ -1328,7 +1318,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === alice.stateData.asInstanceOf[DATA_CLOSING].channelId) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] // main delayed alice2blockchain.expectMsgType[PublishAsap] // htlc timeout alice2blockchain.expectMsgType[PublishAsap] // htlc delayed @@ -1499,7 +1489,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val error = alice2bob.expectMsgType[Error] assert(new String(error.data.toArray) === InvalidFailureCode(ByteVector32.Zeroes).getMessage) awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) // commit tx + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx alice2blockchain.expectMsgType[PublishAsap] // main delayed alice2blockchain.expectMsgType[PublishAsap] // htlc timeout alice2blockchain.expectMsgType[PublishAsap] // htlc delayed @@ -1519,7 +1509,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === alice.stateData.asInstanceOf[DATA_CLOSING].channelId) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] alice2blockchain.expectMsgType[WatchConfirmed] } @@ -1532,7 +1522,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === alice.stateData.asInstanceOf[DATA_CLOSING].channelId) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] alice2blockchain.expectMsgType[WatchConfirmed] } @@ -1624,7 +1614,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(alice.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === alice.stateData.asInstanceOf[DATA_CLOSING].channelId) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] alice2blockchain.expectMsgType[WatchConfirmed] } @@ -1641,7 +1631,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(tx)) // commit tx + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx //bob2blockchain.expectMsgType[PublishAsap] // main delayed (removed because of the high fees) bob2blockchain.expectMsgType[WatchConfirmed] } @@ -1656,7 +1646,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(tx)) // commit tx + assert(bob2blockchain.expectMsgType[PublishAsap].tx === tx) // commit tx bob2blockchain.expectMsgType[PublishAsap] // main delayed bob2blockchain.expectMsgType[WatchConfirmed] } @@ -1677,7 +1667,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(commitTx)) + bob2blockchain.expectMsg(PublishAsap(commitTx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -1698,7 +1688,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(commitTx)) + assert(bob2blockchain.expectMsgType[PublishAsap].tx === commitTx) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -1719,7 +1709,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(commitTx)) + assert(bob2blockchain.expectMsgType[PublishAsap].tx === commitTx) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -1736,7 +1726,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with awaitCond(bob.stateName == CLOSING) // channel should be advertised as down assert(channelUpdateListener.expectMsgType[LocalChannelDown].channelId === bob.stateData.asInstanceOf[DATA_CLOSING].channelId) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -2066,7 +2056,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with val initialState = alice.stateData.asInstanceOf[DATA_NORMAL] val aliceCommitTx = initialState.commitments.localCommit.publishableTxs.commitTx.tx alice ! CurrentBlockCount(400145) - alice2blockchain.expectMsg(PublishAsap(aliceCommitTx)) + alice2blockchain.expectMsg(PublishAsap(aliceCommitTx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] // main delayed alice2blockchain.expectMsgType[PublishAsap] // htlc timeout @@ -2101,7 +2091,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(isFatal) assert(err.isInstanceOf[HtlcsWillTimeoutUpstream]) - bob2blockchain.expectMsg(PublishAsap(initialCommitTx)) + bob2blockchain.expectMsg(PublishAsap(initialCommitTx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] // main delayed assert(bob2blockchain.expectMsgType[PublishAsap].tx.txOut === htlcSuccessTx.txOut) bob2blockchain.expectMsgType[PublishAsap] // htlc delayed @@ -2135,7 +2125,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(isFatal) assert(err.isInstanceOf[HtlcsWillTimeoutUpstream]) - bob2blockchain.expectMsg(PublishAsap(initialCommitTx)) + bob2blockchain.expectMsg(PublishAsap(initialCommitTx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] // main delayed assert(bob2blockchain.expectMsgType[PublishAsap].tx.txOut === htlcSuccessTx.txOut) bob2blockchain.expectMsgType[PublishAsap] // htlc delayed @@ -2173,7 +2163,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(isFatal) assert(err.isInstanceOf[HtlcsWillTimeoutUpstream]) - bob2blockchain.expectMsg(PublishAsap(initialCommitTx)) + bob2blockchain.expectMsg(PublishAsap(initialCommitTx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] // main delayed assert(bob2blockchain.expectMsgType[PublishAsap].tx.txOut === htlcSuccessTx.txOut) bob2blockchain.expectMsgType[PublishAsap] // htlc delayed @@ -2531,7 +2521,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // an error occurs and alice publishes her commit tx val aliceCommitTx = alice.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx alice ! Error(ByteVector32.Zeroes, "oops") - alice2blockchain.expectMsg(PublishAsap(aliceCommitTx)) + alice2blockchain.expectMsg(PublishAsap(aliceCommitTx, PublishStrategy.JustPublish)) assert(aliceCommitTx.txOut.size == 6) // two main outputs and 4 pending htlcs // 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 @@ -2582,7 +2572,7 @@ class NormalStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // an error occurs and alice publishes her commit tx val bobCommitTx = bob.stateData.asInstanceOf[DATA_NORMAL].commitments.localCommit.publishableTxs.commitTx.tx bob ! Error(ByteVector32.Zeroes, "oops") - bob2blockchain.expectMsg(PublishAsap(bobCommitTx)) + bob2blockchain.expectMsg(PublishAsap(bobCommitTx, PublishStrategy.JustPublish)) assert(bobCommitTx.txOut.size == 1) // only one main output alice2blockchain.expectNoMsg(1 second) 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 27726c8ce8..3698bb1f4b 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 @@ -482,12 +482,12 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(isFatal) assert(err.isInstanceOf[HtlcsWillTimeoutUpstream]) - bob2blockchain.expectMsg(PublishAsap(initialCommitTx)) + bob2blockchain.expectMsg(PublishAsap(initialCommitTx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] // main delayed assert(bob2blockchain.expectMsgType[WatchConfirmed].event === BITCOIN_TX_CONFIRMED(initialCommitTx)) bob2blockchain.expectMsgType[WatchConfirmed] // main delayed - bob2blockchain.expectMsg(PublishAsap(initialCommitTx)) + bob2blockchain.expectMsg(PublishAsap(initialCommitTx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] // main delayed assert(bob2blockchain.expectMsgType[PublishAsap].tx.txOut === htlcSuccessTx.txOut) bob2blockchain.expectMsgType[PublishAsap] // htlc delayed @@ -542,7 +542,7 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // alice is funder alice ! CurrentFeerates(networkFeerate) if (shouldClose) { - alice2blockchain.expectMsg(PublishAsap(aliceCommitTx)) + alice2blockchain.expectMsg(PublishAsap(aliceCommitTx, PublishStrategy.JustPublish)) } else { alice2blockchain.expectNoMsg() } @@ -651,7 +651,7 @@ class OfflineStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // bob is fundee bob ! CurrentFeerates(networkFeerate) if (shouldClose) { - bob2blockchain.expectMsg(PublishAsap(bobCommitTx)) + bob2blockchain.expectMsg(PublishAsap(bobCommitTx, PublishStrategy.JustPublish)) } else { bob2blockchain.expectNoMsg() } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala index 2e4e4f5025..9dfe7c5006 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/channel/states/f/ShutdownStateSpec.scala @@ -183,7 +183,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit alice ! fulfill alice2bob.expectMsgType[Error] awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) // commit tx + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx alice2blockchain.expectMsgType[PublishAsap] // main delayed alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 1 alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 2 @@ -198,7 +198,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit alice ! UpdateFulfillHtlc(ByteVector32.Zeroes, 42, ByteVector32.Zeroes) alice2bob.expectMsgType[Error] awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) // commit tx + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx alice2blockchain.expectMsgType[PublishAsap] // main delayed alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 1 alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 2 @@ -291,7 +291,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit alice ! UpdateFailHtlc(ByteVector32.Zeroes, 42, ByteVector.fill(152)(0)) alice2bob.expectMsgType[Error] awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) // commit tx + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx alice2blockchain.expectMsgType[PublishAsap] // main delayed alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 1 alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 2 @@ -316,7 +316,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit val error = alice2bob.expectMsgType[Error] assert(new String(error.data.toArray) === InvalidFailureCode(ByteVector32.Zeroes).getMessage) awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) // commit tx + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx alice2blockchain.expectMsgType[PublishAsap] // main delayed alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 1 alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 2 @@ -386,7 +386,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit bob ! CommitSig(ByteVector32.Zeroes, ByteVector64.Zeroes, Nil) bob2alice.expectMsgType[Error] awaitCond(bob.stateName == CLOSING) - bob2blockchain.expectMsg(PublishAsap(tx)) // commit tx + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx bob2blockchain.expectMsgType[PublishAsap] // main delayed bob2blockchain.expectMsgType[WatchConfirmed] } @@ -397,7 +397,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit bob ! CommitSig(ByteVector32.Zeroes, ByteVector64.Zeroes, Nil) bob2alice.expectMsgType[Error] awaitCond(bob.stateName == CLOSING) - bob2blockchain.expectMsg(PublishAsap(tx)) // commit tx + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx bob2blockchain.expectMsgType[PublishAsap] // main delayed bob2blockchain.expectMsgType[WatchConfirmed] } @@ -453,7 +453,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit bob ! RevokeAndAck(ByteVector32.Zeroes, PrivateKey(randomBytes32), PrivateKey(randomBytes32).publicKey) bob2alice.expectMsgType[Error] awaitCond(bob.stateName == CLOSING) - bob2blockchain.expectMsg(PublishAsap(tx)) // commit tx + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx bob2blockchain.expectMsgType[PublishAsap] // main delayed bob2blockchain.expectMsgType[PublishAsap] // htlc success bob2blockchain.expectMsgType[PublishAsap] // htlc delayed @@ -467,7 +467,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit alice ! RevokeAndAck(ByteVector32.Zeroes, PrivateKey(randomBytes32), PrivateKey(randomBytes32).publicKey) alice2bob.expectMsgType[Error] awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) // commit tx + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx alice2blockchain.expectMsgType[PublishAsap] // main delayed alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 1 alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 2 @@ -557,7 +557,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit alice ! UpdateFee(ByteVector32.Zeroes, FeeratePerKw(12000 sat)) alice2bob.expectMsgType[Error] awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) // commit tx + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx alice2blockchain.expectMsgType[PublishAsap] // main delayed alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 1 alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 2 @@ -576,7 +576,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray) === CannotAffordFees(channelId(bob), missing = 72120000L sat, reserve = 20000L sat, fees = 72400000L sat).getMessage) awaitCond(bob.stateName == CLOSING) - bob2blockchain.expectMsg(PublishAsap(tx)) // commit tx + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx //bob2blockchain.expectMsgType[PublishAsap] // main delayed (removed because of the high fees) bob2blockchain.expectMsgType[WatchConfirmed] } @@ -588,7 +588,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray) === "local/remote feerates are too different: remoteFeeratePerKw=65000 localFeeratePerKw=10000") awaitCond(bob.stateName == CLOSING) - bob2blockchain.expectMsg(PublishAsap(tx)) // commit tx + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx bob2blockchain.expectMsgType[PublishAsap] // main delayed bob2blockchain.expectMsgType[WatchConfirmed] } @@ -600,7 +600,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray) === "remote fee rate is too small: remoteFeeratePerKw=252") awaitCond(bob.stateName == CLOSING) - bob2blockchain.expectMsg(PublishAsap(tx)) // commit tx + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) // commit tx bob2blockchain.expectMsgType[PublishAsap] // main delayed bob2blockchain.expectMsgType[WatchConfirmed] } @@ -627,7 +627,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit val initialState = alice.stateData.asInstanceOf[DATA_SHUTDOWN] val aliceCommitTx = initialState.commitments.localCommit.publishableTxs.commitTx.tx alice ! CurrentBlockCount(400145) - alice2blockchain.expectMsg(PublishAsap(aliceCommitTx)) // commit tx + alice2blockchain.expectMsg(PublishAsap(aliceCommitTx, PublishStrategy.JustPublish)) // commit tx alice2blockchain.expectMsgType[PublishAsap] // main delayed alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 1 alice2blockchain.expectMsgType[PublishAsap] // htlc timeout 2 @@ -814,7 +814,7 @@ class ShutdownStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike wit import f._ val aliceCommitTx = alice.stateData.asInstanceOf[DATA_SHUTDOWN].commitments.localCommit.publishableTxs.commitTx.tx alice ! Error(ByteVector32.Zeroes, "oops") - alice2blockchain.expectMsg(PublishAsap(aliceCommitTx)) + alice2blockchain.expectMsg(PublishAsap(aliceCommitTx, PublishStrategy.JustPublish)) assert(aliceCommitTx.txOut.size == 4) // two main outputs and two htlcs // alice can claim both htlc after a timeout 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 c7c5ce21da..03178f68e4 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 @@ -162,7 +162,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike sender.send(bob, aliceCloseSig.copy(feeSatoshis = 99000 sat)) // sig doesn't matter, it is checked later val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray).startsWith("invalid close fee: fee_satoshis=Satoshi(99000)")) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -174,7 +174,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike bob ! aliceCloseSig.copy(signature = ByteVector64.Zeroes) val error = bob2alice.expectMsgType[Error] assert(new String(error.data.toArray).startsWith("invalid close signature")) - bob2blockchain.expectMsg(PublishAsap(tx)) + bob2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) bob2blockchain.expectMsgType[PublishAsap] bob2blockchain.expectMsgType[WatchConfirmed] } @@ -199,7 +199,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike val mutualCloseTx = bob2blockchain.expectMsgType[PublishAsap].tx assert(bob2blockchain.expectMsgType[WatchConfirmed].event === BITCOIN_TX_CONFIRMED(mutualCloseTx)) alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, mutualCloseTx) - alice2blockchain.expectMsg(PublishAsap(mutualCloseTx)) + alice2blockchain.expectMsg(PublishAsap(mutualCloseTx, PublishStrategy.JustPublish)) assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === mutualCloseTx.txid) alice2blockchain.expectNoMsg(100 millis) assert(alice.stateName == CLOSING) @@ -219,7 +219,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike val Right(bobClosingTx) = Closing.checkClosingSignature(Bob.channelKeyManager, d.commitments, d.localShutdown.scriptPubKey, d.remoteShutdown.scriptPubKey, aliceClose1.feeSatoshis, aliceClose1.signature) alice ! WatchEventSpent(BITCOIN_FUNDING_SPENT, bobClosingTx) - alice2blockchain.expectMsg(PublishAsap(bobClosingTx)) + alice2blockchain.expectMsg(PublishAsap(bobClosingTx, PublishStrategy.JustPublish)) assert(alice2blockchain.expectMsgType[WatchConfirmed].txId === bobClosingTx.txid) alice2blockchain.expectNoMsg(100 millis) assert(alice.stateName == CLOSING) @@ -237,7 +237,7 @@ class NegotiatingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike val tx = alice.stateData.asInstanceOf[DATA_NEGOTIATING].commitments.localCommit.publishableTxs.commitTx.tx alice ! Error(ByteVector32.Zeroes, "oops") awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(tx)) + alice2blockchain.expectMsg(PublishAsap(tx, PublishStrategy.JustPublish)) alice2blockchain.expectMsgType[PublishAsap] assert(alice2blockchain.expectMsgType[WatchConfirmed].event === BITCOIN_TX_CONFIRMED(tx)) } 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 72a8c44c95..583e928742 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 @@ -188,7 +188,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // test starts here alice ! GetTxWithMetaResponse(fundingTx.txid, None, System.currentTimeMillis.milliseconds.toSeconds) alice2bob.expectNoMsg(200 millis) - alice2blockchain.expectMsg(PublishAsap(fundingTx)) // we republish the funding tx + alice2blockchain.expectMsg(PublishAsap(fundingTx, PublishStrategy.JustPublish)) // we republish the funding tx assert(alice.stateName == CLOSING) // the above expectNoMsg will make us wait, so this checks that we are still in CLOSING } @@ -298,7 +298,7 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // let's make alice publish this closing tx alice ! Error(ByteVector32.Zeroes, "") awaitCond(alice.stateName == CLOSING) - alice2blockchain.expectMsg(PublishAsap(mutualCloseTx)) + alice2blockchain.expectMsg(PublishAsap(mutualCloseTx, PublishStrategy.JustPublish)) assert(mutualCloseTx === alice.stateData.asInstanceOf[DATA_CLOSING].mutualClosePublished.last) // actual test starts here @@ -390,7 +390,11 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with assert(closingState.claimMainDelayedOutputTx.isDefined) assert(closingState.htlcSuccessTxs.isEmpty) assert(closingState.htlcTimeoutTxs.length === 1) - assert(closingState.claimHtlcDelayedTxs.length === 1) + if (channelVersion.hasAnchorOutputs) { + assert(closingState.claimHtlcDelayedTxs.length === 0) + } else { + assert(closingState.claimHtlcDelayedTxs.length === 1) + } alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(closingState.commitTx), 42, 0, closingState.commitTx) assert(listener.expectMsgType[LocalCommitConfirmed].refundAtBlock == 42 + TestConstants.Bob.channelParams.toSelfDelay.toInt) assert(listener.expectMsgType[PaymentSettlingOnChain].paymentHash == htlca1.paymentHash) @@ -406,7 +410,17 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with )) assert(relayerA.expectMsgType[RES_ADD_SETTLED[Origin, HtlcResult.OnChainFail]].htlc === htlca1) relayerA.expectNoMsg(100 millis) - alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(closingState.claimHtlcDelayedTxs.head), 202, 0, closingState.claimHtlcDelayedTxs.head) + + if (channelVersion.hasAnchorOutputs) { + // We claim the htlc-delayed output now that the HTLC tx has been confirmed. + val claimHtlcDelayedTx = alice2blockchain.expectMsgType[PublishAsap] + assert(claimHtlcDelayedTx.strategy === PublishStrategy.JustPublish) + Transaction.correctlySpends(claimHtlcDelayedTx.tx, Seq(closingState.htlcTimeoutTxs.head), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) + awaitCond(alice.stateData.asInstanceOf[DATA_CLOSING].localCommitPublished.get.claimHtlcDelayedTxs.length === 1) + alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(claimHtlcDelayedTx.tx), 202, 0, claimHtlcDelayedTx.tx) + } else { + alice ! WatchEventConfirmed(BITCOIN_TX_CONFIRMED(closingState.claimHtlcDelayedTxs.head), 202, 0, closingState.claimHtlcDelayedTxs.head) + } awaitCond(alice.stateName == CLOSED) } @@ -712,10 +726,10 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Alice receives the preimage for the first HTLC from downstream; she can now claim the corresponding HTLC output. alice ! CMD_FULFILL_HTLC(htlc1.id, r1, commit = true) - alice2blockchain.expectMsg(PublishAsap(closingState.claimMainOutputTx.get)) + alice2blockchain.expectMsg(PublishAsap(closingState.claimMainOutputTx.get, PublishStrategy.JustPublish)) val claimHtlcSuccessTx = alice.stateData.asInstanceOf[DATA_CLOSING].remoteCommitPublished.get.claimHtlcSuccessTxs.head Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - alice2blockchain.expectMsg(PublishAsap(claimHtlcSuccessTx)) + alice2blockchain.expectMsg(PublishAsap(claimHtlcSuccessTx, PublishStrategy.JustPublish)) // Alice resets watches on all relevant transactions. assert(alice2blockchain.expectMsgType[WatchConfirmed].event === BITCOIN_TX_CONFIRMED(bobCommitTx)) @@ -856,11 +870,11 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // Alice receives the preimage for the first HTLC from downstream; she can now claim the corresponding HTLC output. alice ! CMD_FULFILL_HTLC(htlc1.id, r1, commit = true) - alice2blockchain.expectMsg(PublishAsap(closingState.claimMainOutputTx.get)) + alice2blockchain.expectMsg(PublishAsap(closingState.claimMainOutputTx.get, PublishStrategy.JustPublish)) val claimHtlcSuccessTx = alice.stateData.asInstanceOf[DATA_CLOSING].nextRemoteCommitPublished.get.claimHtlcSuccessTxs.head Transaction.correctlySpends(claimHtlcSuccessTx, bobCommitTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) - alice2blockchain.expectMsg(PublishAsap(claimHtlcSuccessTx)) - alice2blockchain.expectMsg(PublishAsap(claimHtlcTimeoutTx)) + alice2blockchain.expectMsg(PublishAsap(claimHtlcSuccessTx, PublishStrategy.JustPublish)) + alice2blockchain.expectMsg(PublishAsap(claimHtlcTimeoutTx, PublishStrategy.JustPublish)) assert(alice2blockchain.expectMsgType[WatchConfirmed].event === BITCOIN_TX_CONFIRMED(bobCommitTx)) assert(alice2blockchain.expectMsgType[WatchConfirmed].event === BITCOIN_TX_CONFIRMED(closingState.claimMainOutputTx.get)) @@ -1058,9 +1072,9 @@ class ClosingStateSpec extends TestKitBaseClass with FixtureAnyFunSuiteLike with // alice publishes the penalty txs if (!channelVersion.paysDirectlyToWallet) { - alice2blockchain.expectMsg(PublishAsap(rvk.claimMainOutputTx.get)) + alice2blockchain.expectMsg(PublishAsap(rvk.claimMainOutputTx.get, PublishStrategy.JustPublish)) } - alice2blockchain.expectMsg(PublishAsap(rvk.mainPenaltyTx.get)) + alice2blockchain.expectMsg(PublishAsap(rvk.mainPenaltyTx.get, PublishStrategy.JustPublish)) assert(Set(alice2blockchain.expectMsgType[PublishAsap].tx, alice2blockchain.expectMsgType[PublishAsap].tx) === rvk.htlcPenaltyTxs.toSet) for (penaltyTx <- penaltyTxs) { Transaction.correctlySpends(penaltyTx, bobRevokedTx :: Nil, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala index e3aa7b071e..4dc65cf943 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/integration/ChannelIntegrationSpec.scala @@ -22,7 +22,7 @@ import akka.testkit.TestProbe import com.google.common.net.HostAndPort import com.typesafe.config.ConfigFactory import fr.acinq.bitcoin.Crypto.PublicKey -import fr.acinq.bitcoin.{Base58, Base58Check, Bech32, BtcDouble, ByteVector32, Crypto, OP_0, OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_EQUALVERIFY, OP_HASH160, OP_PUSHDATA, SatoshiLong, Script, ScriptFlags, Transaction} +import fr.acinq.bitcoin.{Base58, Base58Check, Bech32, BtcDouble, ByteVector32, Crypto, OP_0, OP_CHECKSIG, OP_DUP, OP_EQUAL, OP_EQUALVERIFY, OP_HASH160, OP_PUSHDATA, OutPoint, SatoshiLong, Script, ScriptFlags, Transaction} import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq import fr.acinq.eclair.blockchain.bitcoind.rpc.ExtendedBitcoinClient import fr.acinq.eclair.channel._ @@ -92,6 +92,15 @@ abstract class ChannelIntegrationSpec extends IntegrationSpec { }, max = 30 seconds, interval = 1 second) } + /** Wait for the given outpoint to be spent (either by a mempool or confirmed transaction). */ + def waitForOutputSpent(outpoint: OutPoint, bitcoinClient: ExtendedBitcoinClient, sender: TestProbe): Unit = { + awaitCond({ + bitcoinClient.isTransactionOutputSpendable(outpoint.txid, outpoint.index.toInt, includeMempool = true).pipeTo(sender.ref) + val isSpendable = sender.expectMsgType[Boolean] + !isSpendable + }, max = 30 seconds, interval = 1 second) + } + /** Disconnect node C from a given F node. */ def disconnectCF(channelId: ByteVector32, sender: TestProbe = TestProbe()): Unit = { val (stateListenerC, stateListenerF) = (TestProbe(), TestProbe()) @@ -249,7 +258,13 @@ abstract class ChannelIntegrationSpec extends IntegrationSpec { // we generate a few blocks to get the commit tx confirmed generateBlocks(3, Some(minerAddress)) // we wait until the htlc-timeout has been broadcast - waitForTxBroadcastOrConfirmed(localCommit.htlcTimeoutTxs.head.txid, bitcoinClient, sender) + commitmentFormat match { + case Transactions.DefaultCommitmentFormat => + waitForTxBroadcastOrConfirmed(localCommit.htlcTimeoutTxs.head.txid, bitcoinClient, sender) + case Transactions.AnchorOutputsCommitmentFormat => + // we don't know the txid of the HTLC-timeout, so we just check that the corresponding output has been spent + waitForOutputSpent(localCommit.htlcTimeoutTxs.head.txIn.head.outPoint, bitcoinClient, sender) + } // we generate more blocks for the htlc-timeout to reach enough confirmations generateBlocks(3, Some(minerAddress)) // this will fail the htlc @@ -271,7 +286,6 @@ abstract class ChannelIntegrationSpec extends IntegrationSpec { generateBlocks(2, Some(minerAddress)) // and we wait for the channel to close awaitCond(stateListenerC.expectMsgType[ChannelStateChanged].currentState == CLOSED, max = 60 seconds) - awaitCond(stateListenerF.expectMsgType[ChannelStateChanged].currentState == CLOSED, max = 60 seconds) awaitAnnouncements(1) } @@ -322,7 +336,6 @@ abstract class ChannelIntegrationSpec extends IntegrationSpec { generateBlocks(2, Some(minerAddress)) // and we wait for the channel to close awaitCond(stateListenerC.expectMsgType[ChannelStateChanged].currentState == CLOSED, max = 60 seconds) - awaitCond(stateListenerF.expectMsgType[ChannelStateChanged].currentState == CLOSED, max = 60 seconds) awaitAnnouncements(1) } @@ -678,6 +691,8 @@ class AnchorOutputChannelIntegrationSpec extends ChannelIntegrationSpec { val stateListener = TestProbe() nodes("C").system.eventStream.subscribe(stateListener.ref, classOf[ChannelStateChanged]) + // we kill the connection between C and F to ensure the close can only be detected on-chain + disconnectCF(channelId, sender) // now let's force close the channel and check the toRemote is what we had at the beginning sender.send(nodes("F").register, Register.Forward(sender.ref, channelId, CMD_FORCECLOSE(sender.ref))) sender.expectMsgType[RES_SUCCESS[CMD_FORCECLOSE]] 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 6310c2441c..b02714d505 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 @@ -18,7 +18,7 @@ package fr.acinq.eclair.transactions import fr.acinq.bitcoin.Crypto.{PrivateKey, ripemd160, sha256} import fr.acinq.bitcoin.Script.{pay2wpkh, pay2wsh, write} -import fr.acinq.bitcoin.{Btc, ByteVector32, Crypto, MilliBtc, MilliBtcDouble, OutPoint, Protocol, SIGHASH_ALL, SIGHASH_ANYONECANPAY, SIGHASH_NONE, SIGHASH_SINGLE, Satoshi, SatoshiLong, Script, Transaction, TxIn, TxOut, millibtc2satoshi} +import fr.acinq.bitcoin.{Btc, ByteVector32, Crypto, MilliBtc, MilliBtcDouble, OutPoint, Protocol, SIGHASH_ALL, SIGHASH_ANYONECANPAY, SIGHASH_NONE, SIGHASH_SINGLE, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut, millibtc2satoshi} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.Helpers.Funding import fr.acinq.eclair.transactions.CommitmentOutput.{InHtlc, OutHtlc} @@ -198,10 +198,17 @@ class TransactionsSpec extends AnyFunSuite with Logging { val pubKeyScript = write(pay2wsh(anchor(localFundingPriv.publicKey))) val commitTx = Transaction(version = 0, txIn = Nil, txOut = TxOut(anchorAmount, pubKeyScript) :: Nil, lockTime = 0) val Right(claimAnchorOutputTx) = makeClaimAnchorOutputTx(commitTx, localFundingPriv.publicKey) + assert(claimAnchorOutputTx.tx.txOut.isEmpty) + // 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 weight = Transaction.weight(addSigs(claimAnchorOutputTx, PlaceHolderSig).tx) - assert(claimAnchorOutputWeight == weight) - assert(claimAnchorOutputTx.fee >= claimAnchorOutputTx.minRelayFee) + val p2wpkhWitness = ScriptWitness(Seq(Scripts.der(PlaceHolderSig), PlaceHolderPubKey.value)) + val claimAnchorOutputTxWithFees = claimAnchorOutputTx.copy(tx = claimAnchorOutputTx.tx.copy( + txIn = claimAnchorOutputTx.tx.txIn :+ TxIn(OutPoint(randomBytes32, 3), ByteVector.empty, 0, p2wpkhWitness), + txOut = Seq(TxOut(1500 sat, Script.pay2wpkh(randomKey.publicKey))) + )) + val weight = Transaction.weight(addSigs(claimAnchorOutputTxWithFees, PlaceHolderSig).tx) + assert(weight === 717) + assert(weight >= claimAnchorOutputMinWeight) } } From a9f1b537a83d9a42a1fa85f9e97710d0462801cc Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 24 Feb 2021 09:38:53 +0100 Subject: [PATCH 2/5] Fix first round of PR comments --- .../blockchain/bitcoind/ZmqWatcher.scala | 10 ++-- .../fr/acinq/eclair/channel/Channel.scala | 31 ++--------- .../fr/acinq/eclair/channel/Helpers.scala | 52 ++++++++++++++++--- .../eclair/transactions/Transactions.scala | 7 ++- 4 files changed, 58 insertions(+), 42 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala index 325c651103..ec79bc6728 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala @@ -254,22 +254,18 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend def publish(p: PublishAsap): Future[ByteVector32] = { p.strategy match { case PublishStrategy.SetFeerate(currentFeerate, targetFeerate, dustLimit, signingKit) => - val spentOutpoint = signingKit match { - case signingKit: TransactionSigningKit.ClaimAnchorOutputSigningKit => signingKit.txWithInput.input.outPoint - case signingKit: TransactionSigningKit.HtlcTxSigningKit => signingKit.txWithInput.input.outPoint - } - log.info("publishing tx: input={}:{} txid={} tx={}", spentOutpoint.txid, spentOutpoint.index, p.tx.txid, p.tx) + log.info("publishing tx: input={}:{} txid={} tx={}", signingKit.spentOutpoint.txid, signingKit.spentOutpoint.index, p.tx.txid, p.tx) val publishF = signingKit match { case signingKit: TransactionSigningKit.ClaimAnchorOutputSigningKit => publishCommitWithAnchor(p.tx, currentFeerate, targetFeerate, dustLimit, signingKit) case signingKit: TransactionSigningKit.HtlcTxSigningKit => publishHtlcTx(currentFeerate, targetFeerate, dustLimit, signingKit) } publishF.recoverWith { case t: Throwable if t.getMessage.contains("(code: -4)") || t.getMessage.contains("(code: -6)") => - log.warning("not enough funds to publish tx, will retry next block: reason={} input={}:{} txid={}", t.getMessage, spentOutpoint.txid, spentOutpoint.index, p.tx.txid) + log.warning("not enough funds to publish tx, will retry next block: reason={} input={}:{} txid={}", t.getMessage, signingKit.spentOutpoint.txid, signingKit.spentOutpoint.index, p.tx.txid) self ! PublishNextBlock(p) Future.failed(t) case t: Throwable => - log.error("cannot publish tx: reason={} input={}:{} txid={}", t.getMessage, spentOutpoint.txid, spentOutpoint.index, p.tx.txid) + log.error("cannot publish tx: reason={} input={}:{} txid={}", t.getMessage, signingKit.spentOutpoint.txid, signingKit.spentOutpoint.index, p.tx.txid) Future.failed(t) } case PublishStrategy.JustPublish => 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 459d27da60..9b9674faf8 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 @@ -20,7 +20,7 @@ import akka.actor.{ActorRef, FSM, OneForOneStrategy, Props, Status, SupervisorSt import akka.event.Logging.MDC import akka.pattern.pipe import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.{ByteVector32, Crypto, OutPoint, Satoshi, SatoshiLong, Script, ScriptFlags, Transaction} +import fr.acinq.bitcoin.{ByteVector32, OutPoint, Satoshi, SatoshiLong, Script, ScriptFlags, Transaction} import fr.acinq.eclair.Logs.LogCategory import fr.acinq.eclair._ import fr.acinq.eclair.blockchain._ @@ -32,7 +32,7 @@ import fr.acinq.eclair.db.PendingRelayDb import fr.acinq.eclair.io.Peer import fr.acinq.eclair.payment.PaymentSettlingOnChain import fr.acinq.eclair.router.Announcements -import fr.acinq.eclair.transactions.Transactions.{TransactionSigningKit, TxOwner} +import fr.acinq.eclair.transactions.Transactions.TxOwner import fr.acinq.eclair.transactions._ import fr.acinq.eclair.wire._ import scodec.bits.ByteVector @@ -2197,32 +2197,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId val txs = List(commitTx) ++ claimMainDelayedOutputTx ++ htlcSuccessTxs ++ htlcTimeoutTxs ++ claimHtlcDelayedTxs txs.map(tx => PublishAsap(tx, PublishStrategy.JustPublish)) case Transactions.AnchorOutputsCommitmentFormat => - val currentFeerate = commitments.localCommit.spec.feeratePerKw - val targetFeerate = nodeParams.onChainFeeConf.feeEstimator.getFeeratePerKw(nodeParams.onChainFeeConf.feeTargets.commitmentBlockTarget) - val localFundingPubKey = keyManager.fundingPublicKey(commitments.localParams.fundingKeyPath) - val channelKeyPath = keyManager.keyPath(commitments.localParams, commitments.channelVersion) - val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, commitments.localCommit.index) - val localHtlcBasepoint = keyManager.htlcPoint(channelKeyPath) - // If we have an anchor output available, we will use it to CPFP the commit tx. - val publishCommitTx = Transactions.makeClaimAnchorOutputTx(commitTx, localFundingPubKey.publicKey).map(claimAnchorOutputTx => { - TransactionSigningKit.ClaimAnchorOutputSigningKit(keyManager, commitments.commitmentFormat, claimAnchorOutputTx, localFundingPubKey) - }) match { - case Left(_) => PublishAsap(commitTx, PublishStrategy.JustPublish) - case Right(signingKit) => PublishAsap(commitTx, PublishStrategy.SetFeerate(currentFeerate, targetFeerate, commitments.localParams.dustLimit, signingKit)) - } - // HTLC txs will use RBF to add wallet inputs to reach the targeted feerate. - val preimages = commitments.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage } - val htlcTxs = commitments.localCommit.publishableTxs.htlcTxsAndSigs.collect { - case HtlcTxAndSigs(htlcSuccess: Transactions.HtlcSuccessTx, localSig, remoteSig) if preimages.exists(r => Crypto.sha256(r) == htlcSuccess.paymentHash) => - val preimage = preimages.find(r => Crypto.sha256(r) == htlcSuccess.paymentHash).get - val signedTx = Transactions.addSigs(htlcSuccess, localSig, remoteSig, preimage, commitments.commitmentFormat) - val signingKit = TransactionSigningKit.HtlcSuccessSigningKit(keyManager, commitments.commitmentFormat, signedTx, localHtlcBasepoint, localPerCommitmentPoint, remoteSig, preimage) - PublishAsap(signedTx.tx, PublishStrategy.SetFeerate(currentFeerate, targetFeerate, commitments.localParams.dustLimit, signingKit)) - case HtlcTxAndSigs(htlcTimeout: Transactions.HtlcTimeoutTx, localSig, remoteSig) => - val signedTx = Transactions.addSigs(htlcTimeout, localSig, remoteSig, commitments.commitmentFormat) - val signingKit = TransactionSigningKit.HtlcTimeoutSigningKit(keyManager, commitments.commitmentFormat, signedTx, localHtlcBasepoint, localPerCommitmentPoint, remoteSig) - PublishAsap(signedTx.tx, PublishStrategy.SetFeerate(currentFeerate, targetFeerate, commitments.localParams.dustLimit, signingKit)) - } + val (publishCommitTx, htlcTxs) = Helpers.Closing.createLocalCommitAnchorPublishStrategy(keyManager, commitments, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets) // NB: we don't publish the claimHtlcDelayedTxs: we will publish them once their parent htlc tx confirms. List(publishCommitTx) ++ claimMainDelayedOutputTx.map(tx => PublishAsap(tx, PublishStrategy.JustPublish)) ++ htlcTxs } 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 2bac56b4b0..5308d17ff7 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 @@ -21,8 +21,8 @@ import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey, ripemd160, sha256} import fr.acinq.bitcoin.Script._ import fr.acinq.bitcoin._ import fr.acinq.eclair._ -import fr.acinq.eclair.blockchain.EclairWallet import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeTargets, FeeratePerKw} +import fr.acinq.eclair.blockchain.{EclairWallet, PublishAsap, PublishStrategy} import fr.acinq.eclair.channel.Channel.REFRESH_CHANNEL_UPDATE_INTERVAL import fr.acinq.eclair.crypto.Generators import fr.acinq.eclair.crypto.keymanager.ChannelKeyManager @@ -563,6 +563,7 @@ object Helpers { /** * Claim the output of a 2nd-stage HTLC transaction and replace the obsolete HTLC transaction in our local commit. + * Currently only used in the context of the anchor output commit format. If the provided transaction isn't an htlc, this will be a no-op. */ def claimLocalCommitHtlcTxOutput(localCommitPublished: LocalCommitPublished, keyManager: ChannelKeyManager, commitments: Commitments, tx: Transaction, feeEstimator: FeeEstimator, feeTargets: FeeTargets)(implicit log: LoggingAdapter): (LocalCommitPublished, Option[Transaction]) = { import commitments._ @@ -582,8 +583,10 @@ object Helpers { } def updateHtlcTx(newTx: Transaction, previousTxs: List[Transaction]): List[Transaction] = { - val replaceAt = previousTxs.indexWhere(_.txIn.head.outPoint == newTx.txIn.head.outPoint) - if (replaceAt >= 0) previousTxs.updated(replaceAt, newTx) else previousTxs + previousTxs.map { + case previousTx if previousTx.txIn.head.outPoint == newTx.txIn.head.outPoint => newTx + case previousTx => previousTx + } } val localCommitPublished1 = localCommitPublished.copy( @@ -597,6 +600,43 @@ object Helpers { } } + /** + * Create tx publishing strategy (target feerate) for our local commit tx and its HTLC txs. Only used for anchor outputs. + */ + def createLocalCommitAnchorPublishStrategy(keyManager: ChannelKeyManager, commitments: Commitments, feeEstimator: FeeEstimator, feeTargets: FeeTargets): (PublishAsap, List[PublishAsap]) = { + val commitTx = commitments.localCommit.publishableTxs.commitTx.tx + val currentFeerate = commitments.localCommit.spec.feeratePerKw + val targetFeerate = feeEstimator.getFeeratePerKw(feeTargets.commitmentBlockTarget) + val localFundingPubKey = keyManager.fundingPublicKey(commitments.localParams.fundingKeyPath) + val channelKeyPath = keyManager.keyPath(commitments.localParams, commitments.channelVersion) + val localPerCommitmentPoint = keyManager.commitmentPoint(channelKeyPath, commitments.localCommit.index) + val localHtlcBasepoint = keyManager.htlcPoint(channelKeyPath) + + // If we have an anchor output available, we will use it to CPFP the commit tx. + val publishCommitTx = Transactions.makeClaimAnchorOutputTx(commitTx, localFundingPubKey.publicKey).map(claimAnchorOutputTx => { + TransactionSigningKit.ClaimAnchorOutputSigningKit(keyManager, claimAnchorOutputTx, localFundingPubKey) + }) match { + case Left(_) => PublishAsap(commitTx, PublishStrategy.JustPublish) + case Right(signingKit) => PublishAsap(commitTx, PublishStrategy.SetFeerate(currentFeerate, targetFeerate, commitments.localParams.dustLimit, signingKit)) + } + + // HTLC txs will use RBF to add wallet inputs to reach the targeted feerate. + val preimages = commitments.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage }.map(r => Crypto.sha256(r) -> r).toMap + val htlcTxs = commitments.localCommit.publishableTxs.htlcTxsAndSigs.collect { + case HtlcTxAndSigs(htlcSuccess: Transactions.HtlcSuccessTx, localSig, remoteSig) if preimages.contains(htlcSuccess.paymentHash) => + val preimage = preimages(htlcSuccess.paymentHash) + val signedTx = Transactions.addSigs(htlcSuccess, localSig, remoteSig, preimage, commitments.commitmentFormat) + val signingKit = TransactionSigningKit.HtlcSuccessSigningKit(keyManager, commitments.commitmentFormat, signedTx, localHtlcBasepoint, localPerCommitmentPoint, remoteSig, preimage) + PublishAsap(signedTx.tx, PublishStrategy.SetFeerate(currentFeerate, targetFeerate, commitments.localParams.dustLimit, signingKit)) + case HtlcTxAndSigs(htlcTimeout: Transactions.HtlcTimeoutTx, localSig, remoteSig) => + val signedTx = Transactions.addSigs(htlcTimeout, localSig, remoteSig, commitments.commitmentFormat) + val signingKit = TransactionSigningKit.HtlcTimeoutSigningKit(keyManager, commitments.commitmentFormat, signedTx, localHtlcBasepoint, localPerCommitmentPoint, remoteSig) + PublishAsap(signedTx.tx, PublishStrategy.SetFeerate(currentFeerate, targetFeerate, commitments.localParams.dustLimit, signingKit)) + } + + (publishCommitTx, htlcTxs) + } + /** * Claim all the HTLCs that we've received from their current commit tx, if the channel used option_static_remotekey * we don't need to claim our main output because it directly pays to one of our wallet's p2wpkh addresses. @@ -624,14 +664,14 @@ object Helpers { val feeratePerKwHtlc = feeEstimator.getFeeratePerKw(target = 2) // those are the preimages to existing received htlcs - val preimages = commitments.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage } + val preimages = commitments.localChanges.all.collect { case u: UpdateFulfillHtlc => u.paymentPreimage }.map(r => Crypto.sha256(r) -> r).toMap // remember we are looking at the remote commitment so IN for them is really OUT for us and vice versa val txes = remoteCommit.spec.htlcs.collect { // incoming htlc for which we have the preimage: we spend it directly. // NB: we are looking at the remote's commitment, from its point of view it's an outgoing htlc. - case OutgoingHtlc(add: UpdateAddHtlc) if preimages.exists(r => sha256(r) == add.paymentHash) => generateTx("claim-htlc-success") { - val preimage = preimages.find(r => sha256(r) == add.paymentHash).get + case OutgoingHtlc(add: UpdateAddHtlc) if preimages.contains(add.paymentHash) => generateTx("claim-htlc-success") { + val preimage = preimages(add.paymentHash) Transactions.makeClaimHtlcSuccessTx(remoteCommitTx.tx, outputs, localParams.dustLimit, localHtlcPubkey, remoteHtlcPubkey, remoteRevocationPubkey, localParams.defaultFinalScriptPubKey, add, feeratePerKwHtlc, commitments.commitmentFormat).right.map(txinfo => { val sig = keyManager.sign(txinfo, keyManager.htlcPoint(channelKeyPath), remoteCommit.remotePerCommitmentPoint, TxOwner.Local, commitments.commitmentFormat) Transactions.addSigs(txinfo, sig, preimage) 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 f218eef75f..1ae893ad2b 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 @@ -119,12 +119,17 @@ object Transactions { trait TransactionSigningKit { def keyManager: ChannelKeyManager def commitmentFormat: CommitmentFormat + def spentOutpoint: OutPoint } object TransactionSigningKit { - case class ClaimAnchorOutputSigningKit(keyManager: ChannelKeyManager, commitmentFormat: CommitmentFormat, txWithInput: ClaimAnchorOutputTx, localFundingPubKey: ExtendedPublicKey) extends TransactionSigningKit + case class ClaimAnchorOutputSigningKit(keyManager: ChannelKeyManager, txWithInput: ClaimAnchorOutputTx, localFundingPubKey: ExtendedPublicKey) extends TransactionSigningKit { + override val commitmentFormat: CommitmentFormat = AnchorOutputsCommitmentFormat + override val spentOutpoint = txWithInput.input.outPoint + } sealed trait HtlcTxSigningKit extends TransactionSigningKit { def txWithInput: HtlcTx + override def spentOutpoint = txWithInput.input.outPoint def localHtlcBasepoint: ExtendedPublicKey def localPerCommitmentPoint: PublicKey def remoteSig: ByteVector64 From 3f1cba3d719b0b92b7458576245012a3b3cca888 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 24 Feb 2021 11:59:06 +0100 Subject: [PATCH 3/5] Clarify commitTxFeeMsat It's a bit counter-intuitive that we deduce anchor amounts in this function, because it's not technically a fee. --- .../fr/acinq/eclair/transactions/Transactions.scala | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 1ae893ad2b..b486ab4e57 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 @@ -231,13 +231,16 @@ object Transactions { def commitTxFeeMsat(dustLimit: Satoshi, spec: CommitmentSpec, commitmentFormat: CommitmentFormat): MilliSatoshi = { val trimmedOfferedHtlcs = trimOfferedHtlcs(dustLimit, spec, commitmentFormat) val trimmedReceivedHtlcs = trimReceivedHtlcs(dustLimit, spec, commitmentFormat) + val weight = commitmentFormat.commitWeight + commitmentFormat.htlcOutputWeight * (trimmedOfferedHtlcs.size + trimmedReceivedHtlcs.size) + val fee = weight2feeMsat(spec.feeratePerKw, weight) + // When using anchor outputs, the funder pays for *both* anchors all the time, even if only one anchor is present. + // This is not technically a fee (it doesn't go to miners) but it has to be deduced from the funder's main output, + // so for simplicity we deduce it here. val anchorsCost = commitmentFormat match { case DefaultCommitmentFormat => Satoshi(0) - // the funder pays for both anchors all the time, even if only one anchor is present case AnchorOutputsCommitmentFormat => AnchorOutputsCommitmentFormat.anchorAmount * 2 } - val weight = commitmentFormat.commitWeight + commitmentFormat.htlcOutputWeight * (trimmedOfferedHtlcs.size + trimmedReceivedHtlcs.size) - weight2feeMsat(spec.feeratePerKw, weight) + anchorsCost + fee + anchorsCost } def commitTxFee(dustLimit: Satoshi, spec: CommitmentSpec, commitmentFormat: CommitmentFormat): Satoshi = commitTxFeeMsat(dustLimit, spec, commitmentFormat).truncateToSatoshi From d72bb56d391ea7287237083615ec2fba280460f7 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 24 Feb 2021 14:46:42 +0100 Subject: [PATCH 4/5] Lock utxos --- .../eclair/blockchain/bitcoind/ZmqWatcher.scala | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala index ec79bc6728..0c4fb972db 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala @@ -297,13 +297,19 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend // 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 - currentFeerate.feerate) * commitTx.weight() / Transactions.claimAnchorOutputMinWeight - // NB: bitcoind requires txs to have at least one output, but we'll remove it later to keep a single change output. - // In case we have the perfect set of utxo amounts and no change output is added, we need the amount to be greater - // than the fee because we may need to deduce the fee from that output. + // 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 = Transactions.weight2fee(anchorFeerate, Transactions.claimAnchorOutputMinWeight) + dustLimit publish(commitTx, isRetry = false).flatMap(commitTxId => { val txNotFunded = Transaction(2, Nil, TxOut(dummyChangeAmount, Script.pay2wpkh(Transactions.PlaceHolderPubKey)) :: Nil, 0) - client.fundTransaction(txNotFunded, FundTransactionOptions(anchorFeerate))(singleThreadExecutionContext) + client.fundTransaction(txNotFunded, FundTransactionOptions(anchorFeerate, lockUtxos = true))(singleThreadExecutionContext) }).flatMap(fundTxResponse => { // We merge the outputs if there's more than one. fundTxResponse.changePosition match { @@ -349,7 +355,7 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend } // NB: bitcoind will add at least one P2WPKH input. val weightRatio = htlcTxWeight.toDouble / (txNotFunded.weight() + Transactions.claimP2WPKHOutputWeight) - client.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate * weightRatio, changePosition = Some(1)))(singleThreadExecutionContext).map(fundTxResponse => { + client.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate * weightRatio, lockUtxos = true, changePosition = Some(1)))(singleThreadExecutionContext).map(fundTxResponse => { log.info(s"added ${fundTxResponse.tx.txIn.length} wallet input(s) and ${fundTxResponse.tx.txOut.length - 1} wallet output(s) to htlc tx spending commit input=${txWithInput.input.outPoint.txid}:${txWithInput.input.outPoint.index}") // We add the HTLC input (from the commit tx) and restore the HTLC output. val txWithHtlcInput = fundTxResponse.tx.copy( From 5b072b1554c53c9ccbd43dc44f7b17c47de75774 Mon Sep 17 00:00:00 2001 From: t-bast Date: Wed, 24 Feb 2021 18:34:03 +0100 Subject: [PATCH 5/5] Update htlc tx weight ratio formula --- .../blockchain/bitcoind/ZmqWatcher.scala | 34 ++++++++++++++++--- .../eclair/transactions/Transactions.scala | 4 +++ .../blockchain/bitcoind/ZmqWatcherSpec.scala | 4 +-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala index 0c4fb972db..287b476e96 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcher.scala @@ -285,6 +285,9 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend } } + /** + * Publish the commit tx, and optionally an anchor tx that spends from the commit tx and helps get it confirmed with CPFP. + */ def publishCommitWithAnchor(commitTx: Transaction, currentFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi, signingKit: TransactionSigningKit.ClaimAnchorOutputSigningKit): Future[ByteVector32] = { import signingKit._ if (targetFeerate <= currentFeerate) { @@ -339,6 +342,9 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend } } + /** + * Publish an htlc tx, and optionally RBF it before by adding new inputs/outputs to help get it confirmed. + */ def publishHtlcTx(currentFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi, signingKit: TransactionSigningKit.HtlcTxSigningKit): Future[ByteVector32] = { import signingKit._ if (targetFeerate <= currentFeerate) { @@ -348,16 +354,28 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend client.publishTransaction(signedHtlcTx.tx)(singleThreadExecutionContext) } else { log.info("publishing htlc tx with additional inputs: commit input={}:{} target feerate={}", txWithInput.input.outPoint.txid, txWithInput.input.outPoint.index, targetFeerate) + // NB: fundrawtransaction doesn't support non-wallet inputs, so we clear the input and re-add it later. val txNotFunded = txWithInput.tx.copy(txIn = Nil, txOut = txWithInput.tx.txOut.head.copy(amount = dustLimit) :: Nil) val htlcTxWeight = signingKit match { case _: TransactionSigningKit.HtlcSuccessSigningKit => commitmentFormat.htlcSuccessWeight case _: TransactionSigningKit.HtlcTimeoutSigningKit => commitmentFormat.htlcTimeoutWeight } - // NB: bitcoind will add at least one P2WPKH input. - val weightRatio = htlcTxWeight.toDouble / (txNotFunded.weight() + Transactions.claimP2WPKHOutputWeight) + // 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 + (Transactions.htlcInputMaxWeight.toDouble / (htlcTxWeight + Transactions.claimP2WPKHOutputWeight)) client.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate * weightRatio, lockUtxos = true, changePosition = Some(1)))(singleThreadExecutionContext).map(fundTxResponse => { log.info(s"added ${fundTxResponse.tx.txIn.length} wallet input(s) and ${fundTxResponse.tx.txOut.length - 1} wallet output(s) to htlc tx spending commit input=${txWithInput.input.outPoint.txid}:${txWithInput.input.outPoint.index}") // 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 = txWithInput.tx.txIn ++ fundTxResponse.tx.txIn, txOut = txWithInput.tx.txOut ++ fundTxResponse.tx.txOut.tail @@ -481,7 +499,11 @@ object ZmqWatcher { } } - /** Adjust the amount of the change output of an anchor tx to match our target feerate. */ + /** + * 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: Transactions.ClaimAnchorOutputTx, commitTx: Transaction, amountIn: Satoshi, currentFeerate: FeeratePerKw, targetFeerate: FeeratePerKw, dustLimit: Satoshi): Transactions.ClaimAnchorOutputTx = { 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. @@ -502,7 +524,11 @@ object ZmqWatcher { } } - /** Adjust the change output of an htlc tx to match our target feerate. */ + /** + * 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: Transactions.HtlcTx, amountIn: Satoshi, targetFeerate: FeeratePerKw, dustLimit: Satoshi, signingKit: TransactionSigningKit.HtlcTxSigningKit): Transactions.HtlcTx = { require(unsignedTx.tx.txOut.size <= 2, "funded transaction should have at most one change output") val dummySignedTx = addHtlcTxSigs(unsignedTx, Transactions.PlaceHolderSig, signingKit) 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 b486ab4e57..4ba852345e 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 @@ -180,6 +180,10 @@ object Transactions { // and 1 output (change). If we're using P2WPKH wallet inputs/outputs with 72 bytes signatures, this results in a weight of 717. // We round it down to 700 to allow for some error margin (e.g. signatures smaller than 72 bytes). val claimAnchorOutputMinWeight = 700 + // The biggest htlc input is an HTLC-success with anchor outputs: + // 143 bytes (accepted_htlc_script) + 327 bytes (success_witness) + 41 bytes (commitment_input) = 511 bytes + // See https://github.com/lightningnetwork/lightning-rfc/blob/master/03-transactions.md#expected-weight-of-htlc-timeout-and-htlc-success-transactions + val htlcInputMaxWeight = 511 val claimHtlcDelayedWeight = 483 val claimHtlcSuccessWeight = 571 val claimHtlcTimeoutWeight = 545 diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala index d8cea4b012..f7af4477e8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/ZmqWatcherSpec.scala @@ -618,7 +618,7 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind bitcoinClient.getTransaction(htlcSuccessTx.txid).pipeTo(probe.ref) assert(probe.expectMsgType[Transaction].txIn.length > 2) // we added more than 1 wallet input val htlcSuccessTargetFee = Transactions.weight2fee(TestConstants.feeratePerKw, htlcSuccessTx.weight.toInt) - assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.1, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee") + assert(htlcSuccessTargetFee * 0.9 <= htlcSuccessTx.fees && htlcSuccessTx.fees <= htlcSuccessTargetFee * 1.4, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcSuccessTargetFee") // The HTLC-timeout tx will be published once its absolute timeout is satisfied. generateBlocks(144) @@ -626,7 +626,7 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind bitcoinClient.getTransaction(htlcTimeoutTx.txid).pipeTo(probe.ref) assert(probe.expectMsgType[Transaction].txIn.length > 2) // we added more than 1 wallet input val htlcTimeoutTargetFee = Transactions.weight2fee(TestConstants.feeratePerKw, htlcTimeoutTx.weight.toInt) - assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx.fees && htlcTimeoutTx.fees <= htlcTimeoutTargetFee * 1.1, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcTimeoutTargetFee") + assert(htlcTimeoutTargetFee * 0.9 <= htlcTimeoutTx.fees && htlcTimeoutTx.fees <= htlcTimeoutTargetFee * 1.4, s"actualFee=${htlcSuccessTx.fees} targetFee=$htlcTimeoutTargetFee") }) }