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..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 @@ -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,152 @@ 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) => + 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, 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, signingKit.spentOutpoint.txid, signingKit.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) + } + } + + /** + * 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) { + 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: 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, lockUtxos = true))(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) + }) + } + } + + /** + * 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) { + 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) + // 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 + } + // 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 + ) + 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 +476,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 +499,54 @@ object ZmqWatcher { } } + /** + * 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. + 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. + * 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) + // 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..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 @@ -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,27 @@ 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 (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 + } 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 +2265,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 +2304,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 +2328,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..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 @@ -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,106 @@ 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. + * 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._ + 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] = { + previousTxs.map { + case previousTx if previousTx.txIn.head.outPoint == newTx.txIn.head.outPoint => newTx + case previousTx => previousTx + } + } + + 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) + } + } + + /** + * 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. @@ -585,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) @@ -1066,52 +1145,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..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 @@ -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,28 @@ 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 + def spentOutpoint: OutPoint + } + object 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 + } + 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 +174,16 @@ 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 + // 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 @@ -203,13 +235,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 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..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 @@ -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.4, 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.4, 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) } }