From 2c70281397778d5095069d78fd9a4d505a76f3f3 Mon Sep 17 00:00:00 2001 From: sstone Date: Wed, 19 Jan 2022 10:59:58 +0100 Subject: [PATCH] Use psbt to fund transactions --- .../eclair/blockchain/OnChainWallet.scala | 12 +- .../bitcoind/rpc/BitcoinCoreClient.scala | 123 ++++++++++-- .../fr/acinq/eclair/channel/fsm/Channel.scala | 14 +- .../channel/fsm/ChannelOpenSingleFunded.scala | 47 +++-- .../channel/publish/ReplaceableTxFunder.scala | 125 ++++++++---- .../blockchain/DummyOnChainWallet.scala | 42 ++-- .../bitcoind/BitcoinCoreClientSpec.scala | 188 +++++++++++++----- .../AnnouncementsBatchValidationSpec.scala | 17 +- 8 files changed, 413 insertions(+), 155 deletions(-) diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala index f3ada93f87..72088eb6fe 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala @@ -16,8 +16,11 @@ package fr.acinq.eclair.blockchain +import fr.acinq.bitcoin.psbt.{Psbt, UpdateFailure} import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Transaction} +import fr.acinq.bitcoin.scalacompat.DeterministicWallet.ExtendedPublicKey +import fr.acinq.bitcoin.scalacompat.{ByteVector32, KotlinUtils, Satoshi, Transaction} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import scodec.bits.ByteVector @@ -45,7 +48,7 @@ trait OnChainChannelFunder { def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[ByteVector32] /** Create a fully signed channel funding transaction with the provided pubkeyScript. */ - def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRate: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] + def makeFundingTx(chainHash: ByteVector32, localFundingKey: ExtendedPublicKey, remoteFundingKey: PublicKey, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] /** * Committing *must* include publishing the transaction on the network. @@ -108,7 +111,12 @@ object OnChainWallet { final case class OnChainBalance(confirmed: Satoshi, unconfirmed: Satoshi) - final case class MakeFundingTxResponse(fundingTx: Transaction, fundingTxOutputIndex: Int, fee: Satoshi) + final case class MakeFundingTxResponse(psbt: Psbt, fundingTxOutputIndex: Int, fee: Satoshi) { + def fundingTx(): Either[Throwable, Transaction] = { + val extracted = psbt.extract() + if (extracted.isLeft) Left(new RuntimeException(extracted.getLeft.toString)) else Right(KotlinUtils.kmp2scala(extracted.getRight)) + } + } final case class FundTransactionResponse(tx: Transaction, fee: Satoshi, changePosition: Option[Int]) { val amountIn: Satoshi = fee + tx.txOut.map(_.amount).sum diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala index 9d92ab761f..9c7f8aadc7 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala @@ -16,15 +16,17 @@ package fr.acinq.eclair.blockchain.bitcoind.rpc +import fr.acinq.bitcoin.psbt.{KeyPathWithMaster, Psbt} import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey +import fr.acinq.bitcoin.scalacompat.DeterministicWallet.ExtendedPublicKey import fr.acinq.bitcoin.scalacompat._ -import fr.acinq.bitcoin.{Bech32, Block} +import fr.acinq.bitcoin.{Bech32, Block, SigHash} import fr.acinq.eclair.ShortChannelId.coordinates import fr.acinq.eclair.blockchain.OnChainWallet import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, SignTransactionResponse} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.{GetTxWithMetaResponse, UtxoStatus, ValidateResult} import fr.acinq.eclair.blockchain.fee.{FeeratePerKB, FeeratePerKw} -import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.{Scripts, Transactions} import fr.acinq.eclair.wire.protocol.ChannelAnnouncement import fr.acinq.eclair.{BlockHeight, TimestampSecond, TxCoordinates} import grizzled.slf4j.Logging @@ -32,8 +34,9 @@ import org.json4s.Formats import org.json4s.JsonAST._ import scodec.bits.ByteVector +import java.util.Base64 import scala.concurrent.{ExecutionContext, Future} -import scala.jdk.CollectionConverters.ListHasAsScala +import scala.jdk.CollectionConverters.{ListHasAsScala, MapHasAsJava} import scala.util.{Failure, Success, Try} /** @@ -224,25 +227,97 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall fundTransaction(tx, FundTransactionOptions(feeRate, replaceable, lockUtxos)) } - def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, targetFeerate: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { - val partialFundingTx = Transaction( - version = 2, - txIn = Seq.empty[TxIn], - txOut = TxOut(amount, pubkeyScript) :: Nil, - lockTime = 0) + def fundPsbt(inputs: Seq[FundPsbtInput], outputs: Seq[(String, Satoshi)], locktime: Long, options: FundPsbtOptions)(implicit ec: ExecutionContext): Future[FundPsbtResponse] = { + rpcClient.invoke("walletcreatefundedpsbt", inputs.toArray, outputs.map { case (a, b) => a -> b.toBtc.toBigDecimal }, locktime, options).map(json => { + val JString(base64) = json \ "psbt" + val JInt(changePos) = json \ "changepos" + val JDecimal(fee) = json \ "fee" + val bin = Base64.getDecoder.decode(base64) + val decoded = Psbt.read(bin) + require(decoded.isRight, s"cannot decode psbt from $base64") + val psbt = decoded.getRight + val changePos_opt = if (changePos >= 0) Some(changePos.intValue) else None + FundPsbtResponse(psbt, toSatoshi(fee), changePos_opt) + }) + } + + def fundPsbt(outputs: Seq[(String, Satoshi)], locktime: Long, options: FundPsbtOptions)(implicit ec: ExecutionContext): Future[FundPsbtResponse] = + fundPsbt(Seq(), outputs, locktime, options) + + def processPsbt(psbt: Psbt, sign: Boolean = true, sighashType: Int = SigHash.SIGHASH_ALL)(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = { + val sighashStrings = Map( + SigHash.SIGHASH_ALL -> "ALL", + SigHash.SIGHASH_NONE -> "NONE", + SigHash.SIGHASH_SINGLE -> "SINGLE", + (SigHash.SIGHASH_ALL | SigHash.SIGHASH_ANYONECANPAY) -> "ALL|ANYONECANPAY", + (SigHash.SIGHASH_NONE | SigHash.SIGHASH_ANYONECANPAY) -> "NONE|ANYONECANPAY", + (SigHash.SIGHASH_SINGLE | SigHash.SIGHASH_ANYONECANPAY) -> "SINGLE|ANYONECANPAY") + val sighash = sighashStrings.getOrElse(sighashType, throw new IllegalArgumentException(s"invalid sighash flag ${sighashType}")) + rpcClient.invoke("walletprocesspsbt", Base64.getEncoder.encodeToString(Psbt.write(psbt).toByteArray), sign, sighash).map(json => { + val JString(base64) = json \ "psbt" + val JBool(complete) = json \ "complete" + val decoded = Psbt.read(Base64.getDecoder.decode(base64)) + require(decoded.isRight, s"cannot decode psbt from $base64") + ProcessPsbtResponse(decoded.getRight, complete) + }) + } + + private def signPsbtOrUnlock(psbt: Psbt)(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = { + val f = for { + ProcessPsbtResponse(psbt1, complete) <- processPsbt(psbt) + _ = if (!complete) throw JsonRPCError(Error(0, "cannot sign psbt")) + } yield ProcessPsbtResponse(psbt1, complete) + // if signature fails (e.g. because wallet is encrypted) we need to unlock the utxos + f.recoverWith { case _ => + unlockOutpoints(psbt.getGlobal.getTx.txIn.asScala.toSeq.map(_.outPoint).map(KotlinUtils.kmp2scala)) + .recover { case t: Throwable => // no-op, just add a log in case of failure + logger.warn(s"Cannot unlock failed transaction's UTXOs txid=${psbt.getGlobal.getTx.txid}", t) + t + } + .flatMap(_ => f) // return signTransaction error + .recoverWith { case _ => f } // return signTransaction error + } + } + + override def makeFundingTx(chainHash: ByteVector32, localFundingKey: ExtendedPublicKey, remoteFundingKey: PublicKey, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { + logger.info(s"funding psbt with local_funding_key=$localFundingKey and remote_funding_key=$remoteFundingKey") + val fundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(localFundingKey.publicKey, remoteFundingKey))) + val Some(fundingAddress) = computeScriptAddress(chainHash, fundingPubkeyScript) + + def updatePsbt(psbt: Psbt, changepos_opt: Option[Int], ourbip32path: Seq[Long]): Psbt = { + val outputIndex = changepos_opt match { + case None => 0 + case Some(changePos) => 1 - changePos + } + val updated = psbt.updateWitnessOutput(outputIndex, null, null, Map( + KotlinUtils.scala2kmp(localFundingKey.publicKey) -> new KeyPathWithMaster(localFundingKey.parent, DeterministicWallet.KeyPath(ourbip32path).keyPath), + KotlinUtils.scala2kmp(remoteFundingKey) -> new KeyPathWithMaster(0L, DeterministicWallet.KeyPath("1/2/3/4").keyPath) + ).asJava + ) + require(updated.isRight, s"cannot update psbt (${updated.getLeft.toString}") + updated.getRight + } + for { - feerate <- mempoolMinFee().map(minFee => FeeratePerKw(minFee).max(targetFeerate)) - // we ask bitcoin core to add inputs to the funding tx, and use the specified change address - fundTxResponse <- fundTransaction(partialFundingTx, FundTransactionOptions(feerate, lockUtxos = true)) + // we ask bitcoin core to create and fund the funding tx + feerate <- mempoolMinFee().map(minFee => FeeratePerKw(minFee).max(feeRatePerKw)) + FundPsbtResponse(psbt, fee, changePos_opt) <- fundPsbt(Seq(fundingAddress -> amount), 0, FundPsbtOptions(feerate, lockUtxos = true, changePosition = Some(1))) + ourbip32path = localFundingKey.path.path.drop(2) + _ = logger.info(s"funded psbt = $psbt") + psbt1 = updatePsbt(psbt, changePos_opt, ourbip32path) // now let's sign the funding tx - SignTransactionResponse(fundingTx, true) <- signTransactionOrUnlock(fundTxResponse.tx) + ProcessPsbtResponse(signedPsbt, complete) <- signPsbtOrUnlock(psbt1) + _ = logger.info(s"psbt signing complete = $complete") + extracted = signedPsbt.extract() + _ = if (extracted.isLeft) logger.error(s"psbt failure $extracted") + fundingTx = extracted.getRight // there will probably be a change output, so we need to find which output is ours - outputIndex <- Transactions.findPubKeyScriptIndex(fundingTx, pubkeyScript) match { + outputIndex <- Transactions.findPubKeyScriptIndex(KotlinUtils.kmp2scala(fundingTx), fundingPubkeyScript) match { case Right(outputIndex) => Future.successful(outputIndex) case Left(skipped) => Future.failed(new RuntimeException(skipped.toString)) } - _ = logger.debug(s"created funding txid=${fundingTx.txid} outputIndex=$outputIndex fee=${fundTxResponse.fee}") - } yield MakeFundingTxResponse(fundingTx, outputIndex, fundTxResponse.fee) + _ = logger.debug(s"created funding txid=${fundingTx.txid} outputIndex=$outputIndex fee=${fee}") + } yield MakeFundingTxResponse(signedPsbt, outputIndex, fee) } def commit(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = publishTransaction(tx).transformWith { @@ -477,6 +552,22 @@ object BitcoinCoreClient { } } + case class FundPsbtInput(txid: ByteVector32, vout: Int, sequence_opt: Option[Long] = None) + + case class FundPsbtOptions(feeRate: BigDecimal, replaceable: Boolean, lockUnspents: Boolean, changePosition: Option[Int], add_inputs: Boolean) + + object FundPsbtOptions { + def apply(feerate: FeeratePerKw, replaceable: Boolean = true, lockUtxos: Boolean = false, changePosition: Option[Int] = None, add_inputs: Boolean = true): FundPsbtOptions = { + FundPsbtOptions(BigDecimal(FeeratePerKB(feerate).toLong).bigDecimal.scaleByPowerOfTen(-8), replaceable, lockUtxos, changePosition, add_inputs) + } + } + + case class FundPsbtResponse(psbt: Psbt, fee: Satoshi, changePosition: Option[Int]) { + val amountIn: Satoshi = Option(psbt.computeFees()).map(KotlinUtils.kmp2scala).get + psbt.getGlobal.getTx.txOut.asScala.map(_.amount).map(KotlinUtils.kmp2scala).sum + } + + case class ProcessPsbtResponse(psbt: Psbt, complete: Boolean) + case class PreviousTx(txid: ByteVector32, vout: Long, scriptPubKey: String, redeemScript: String, witnessScript: String, amount: BigDecimal) object PreviousTx { diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala index 8f4333a41a..6c12794ba6 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/Channel.scala @@ -21,7 +21,7 @@ import akka.actor.typed.scaladsl.adapter.{ClassicActorContextOps, actorRefAdapte import akka.actor.{Actor, ActorContext, ActorRef, FSM, OneForOneStrategy, PossiblyHarmful, Props, SupervisorStrategy, typed} import akka.event.Logging.MDC import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, KotlinUtils, Satoshi, SatoshiLong, Transaction} import fr.acinq.eclair.Logs.LogCategory import fr.acinq.eclair._ import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse @@ -1225,10 +1225,14 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val log.info("shutting down") stop(FSM.Normal) - case Event(MakeFundingTxResponse(fundingTx, _, _), _) => - // this may happen if connection is lost, or remote sends an error while we were waiting for the funding tx to be created by our wallet - // in that case we rollback the tx - wallet.rollback(fundingTx) + case Event(resp: MakeFundingTxResponse, _) => + resp.fundingTx() match { + case Left(error) => log.error(s"Cannot extract funding tx from psbt (${error.toString}") + case Right(fundingTx) => + // this may happen if connection is lost, or remote sends an error while we were waiting for the funding tx to be created by our wallet + // in that case we rollback the tx + wallet.rollback(fundingTx) + } stay() case Event(INPUT_DISCONNECTED, _) => stay() // we are disconnected, but it doesn't matter anymore diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala index 2318dd46b0..1f03ce1f56 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fsm/ChannelOpenSingleFunded.scala @@ -20,7 +20,7 @@ import akka.actor.Status import akka.actor.typed.scaladsl.adapter.actorRefAdapter import akka.pattern.pipe import fr.acinq.bitcoin.ScriptFlags -import fr.acinq.bitcoin.scalacompat.{SatoshiLong, Script, Transaction} +import fr.acinq.bitcoin.scalacompat.{KotlinUtils, SatoshiLong, Script, Transaction} import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._ import fr.acinq.eclair.channel.Helpers.{Funding, getRelayFees} @@ -191,8 +191,7 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { log.debug("remote params: {}", remoteParams) log.info("remote will use fundingMinDepth={}", accept.minimumDepth) val localFundingPubkey = keyManager.fundingPublicKey(localParams.fundingKeyPath) - val fundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(localFundingPubkey.publicKey, remoteParams.fundingPubKey))) - wallet.makeFundingTx(fundingPubkeyScript, fundingSatoshis, fundingTxFeerate).pipeTo(self) + wallet.makeFundingTx(nodeParams.chainHash, localFundingPubkey, remoteParams.fundingPubKey, fundingSatoshis, fundingTxFeerate).pipeTo(self) goto(WAIT_FOR_FUNDING_INTERNAL) using DATA_WAIT_FOR_FUNDING_INTERNAL(temporaryChannelId, localParams, remoteParams, fundingSatoshis, pushMsat_opt.getOrElse(0 msat), commitTxFeerate, accept.firstPerCommitmentPoint, channelConfig, channelFeatures, open) } @@ -214,26 +213,30 @@ trait ChannelOpenSingleFunded extends SingleFundingHandlers with ErrorHandlers { }) when(WAIT_FOR_FUNDING_INTERNAL)(handleExceptions { - case Event(MakeFundingTxResponse(fundingTx, fundingTxOutputIndex, fundingTxFee), d@DATA_WAIT_FOR_FUNDING_INTERNAL(temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, commitTxFeerate, remoteFirstPerCommitmentPoint, channelConfig, channelFeatures, open)) => - // let's create the first commitment tx that spends the yet uncommitted funding tx - Funding.makeFirstCommitTxs(keyManager, channelConfig, channelFeatures, temporaryChannelId, localParams, remoteParams, localFundingAmount = fundingAmount, remoteFundingAmount = 0 sat, pushMsat, commitTxFeerate, fundingTx.hash, fundingTxOutputIndex, remoteFirstPerCommitmentPoint) match { + case Event(fundingTxResponse: MakeFundingTxResponse, d@DATA_WAIT_FOR_FUNDING_INTERNAL(temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, commitTxFeerate, remoteFirstPerCommitmentPoint, channelConfig, channelFeatures, open)) => + fundingTxResponse.fundingTx() match { case Left(ex) => handleLocalError(ex, d, None) - case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx)) => - require(fundingTx.txOut(fundingTxOutputIndex).publicKeyScript == localCommitTx.input.txOut.publicKeyScript, s"pubkey script mismatch!") - val localSigOfRemoteTx = keyManager.sign(remoteCommitTx, keyManager.fundingPublicKey(localParams.fundingKeyPath), TxOwner.Remote, channelFeatures.commitmentFormat) - // signature of their initial commitment tx that pays remote pushMsat - val fundingCreated = FundingCreated( - temporaryChannelId = temporaryChannelId, - fundingTxid = fundingTx.hash, - fundingOutputIndex = fundingTxOutputIndex, - signature = localSigOfRemoteTx - ) - val channelId = toLongId(fundingTx.hash, fundingTxOutputIndex) - peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages - txPublisher ! SetChannelId(remoteNodeId, channelId) - context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId)) - // NB: we don't send a ChannelSignatureSent for the first commit - goto(WAIT_FOR_FUNDING_SIGNED) using DATA_WAIT_FOR_FUNDING_SIGNED(channelId, localParams, remoteParams, fundingTx, fundingTxFee, localSpec, localCommitTx, RemoteCommit(0, remoteSpec, remoteCommitTx.tx.txid, remoteFirstPerCommitmentPoint), open.channelFlags, channelConfig, channelFeatures, fundingCreated) sending fundingCreated + case Right(fundingTx) => + // let's create the first commitment tx that spends the yet uncommitted funding tx + Funding.makeFirstCommitTxs(keyManager, channelConfig, channelFeatures, temporaryChannelId, localParams, remoteParams, localFundingAmount = fundingAmount, remoteFundingAmount = 0 sat, pushMsat, commitTxFeerate, fundingTx.hash, fundingTxResponse.fundingTxOutputIndex, remoteFirstPerCommitmentPoint) match { + case Left(ex) => handleLocalError(ex, d, None) + case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx)) => + require(fundingTx.txOut(fundingTxResponse.fundingTxOutputIndex).publicKeyScript == localCommitTx.input.txOut.publicKeyScript, s"pubkey script mismatch!") + val localSigOfRemoteTx = keyManager.sign(remoteCommitTx, keyManager.fundingPublicKey(localParams.fundingKeyPath), TxOwner.Remote, channelFeatures.commitmentFormat) + // signature of their initial commitment tx that pays remote pushMsat + val fundingCreated = FundingCreated( + temporaryChannelId = temporaryChannelId, + fundingTxid = fundingTx.hash, + fundingOutputIndex = fundingTxResponse.fundingTxOutputIndex, + signature = localSigOfRemoteTx + ) + val channelId = toLongId(fundingTx.hash, fundingTxResponse.fundingTxOutputIndex) + peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages + txPublisher ! SetChannelId(remoteNodeId, channelId) + context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId)) + // NB: we don't send a ChannelSignatureSent for the first commit + goto(WAIT_FOR_FUNDING_SIGNED) using DATA_WAIT_FOR_FUNDING_SIGNED(channelId, localParams, remoteParams, fundingTx, fundingTxResponse.fee, localSpec, localCommitTx, RemoteCommit(0, remoteSpec, remoteCommitTx.tx.txid, remoteFirstPerCommitmentPoint), open.channelFlags, channelConfig, channelFeatures, fundingCreated) sending fundingCreated + } } case Event(Status.Failure(t), d: DATA_WAIT_FOR_FUNDING_INTERNAL) => diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala index 7edffd673b..742747c95f 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala @@ -19,10 +19,11 @@ package fr.acinq.eclair.channel.publish import akka.actor.typed.eventstream.EventStream import akka.actor.typed.scaladsl.{ActorContext, Behaviors} import akka.actor.typed.{ActorRef, Behavior} -import fr.acinq.bitcoin.scalacompat.{ByteVector32, OutPoint, Satoshi, Script, Transaction, TxOut} +import fr.acinq.bitcoin.psbt.Psbt +import fr.acinq.bitcoin.scalacompat.{ByteVector32, KotlinUtils, OutPoint, Satoshi, Script, ScriptWitness, Transaction, TxIn, TxOut, computeScriptAddress} import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient -import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.FundTransactionOptions +import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{FundPsbtOptions, FundPsbtResponse} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.channel.Commitments import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._ @@ -276,6 +277,12 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, } def bump(previousTx: FundedTx, targetFeerate: FeeratePerKw): Behavior[Command] = { + def removeWitness1(txIn: TxIn): TxIn = txIn.copy(witness = ScriptWitness.empty) + + def removeWitness2(inputs: Seq[TxIn]): Seq[TxIn] = inputs.map(removeWitness1) + + def removeWitness(tx: Transaction): Transaction = tx.copy(txIn = removeWitness2(tx.txIn)) + adjustPreviousTxOutput(previousTx, targetFeerate, cmd.commitments) match { case AdjustPreviousTxOutputResult.Skip(reason) => log.warn("skipping {} fee bumping: {} (feerate={})", cmd.desc, reason, targetFeerate) @@ -283,7 +290,8 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, Behaviors.stopped case AdjustPreviousTxOutputResult.TxOutputAdjusted(updatedTx) => log.debug("bumping {} fees without adding new inputs: txid={}", cmd.desc, updatedTx.txInfo.tx.txid) - sign(updatedTx, targetFeerate, previousTx.totalAmountIn) + val updatedTx1 = updatedTx.updateTx(removeWitness(updatedTx.txInfo.tx)) + sign(updatedTx1, targetFeerate, previousTx.totalAmountIn) case AdjustPreviousTxOutputResult.AddWalletInputs(tx) => log.debug("bumping {} fees requires adding new inputs (feerate={})", cmd.desc, targetFeerate) // We restore the original transaction (remove previous attempt's wallet inputs). @@ -353,22 +361,38 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, } def signWalletInputs(locallySignedTx: ReplaceableTxWithWalletInputs, txFeerate: FeeratePerKw, amountIn: Satoshi): Behavior[Command] = { + import KotlinUtils._ + import fr.acinq.bitcoin.utils.EitherKt + locallySignedTx match { case ClaimLocalAnchorWithWitnessData(anchorTx) => - val commitInfo = BitcoinCoreClient.PreviousTx(anchorTx.input, anchorTx.tx.txIn.head.witness) - context.pipeToSelf(bitcoinClient.signTransaction(anchorTx.tx, Seq(commitInfo))) { - case Success(signedTx) => SignWalletInputsOk(signedTx.tx) - case Failure(reason) => SignWalletInputsFailed(reason) + val psbt = new Psbt(anchorTx.tx) + val updated = psbt.updateWitnessInput(anchorTx.input.outPoint, anchorTx.input.txOut, null, fr.acinq.bitcoin.Script.parse(anchorTx.input.redeemScript), null, java.util.Map.of()) + val signed = EitherKt.flatMap(updated, (psbt: Psbt) => psbt.finalizeWitnessInput(0, anchorTx.tx.txIn.head.witness)) + if (signed.isLeft) { + SignWalletInputsFailed(new RuntimeException(signed.getLeft.toString)) + } else { + context.pipeToSelf(bitcoinClient.processPsbt(signed.getRight)) { + case Success(processPsbtResponse) => + val extracted = processPsbtResponse.psbt.extract() + if (extracted.isLeft) SignWalletInputsFailed(new RuntimeException(extracted.getLeft.toString)) else SignWalletInputsOk(extracted.getRight) + case Failure(reason) => SignWalletInputsFailed(reason) + } } + case htlcTx: HtlcWithWitnessData => - val inputInfo = BitcoinCoreClient.PreviousTx(htlcTx.txInfo.input, htlcTx.txInfo.tx.txIn.head.witness) - context.pipeToSelf(bitcoinClient.signTransaction(htlcTx.txInfo.tx, Seq(inputInfo), allowIncomplete = true).map(signTxResponse => { - // NB: bitcoind versions older than 0.21.1 messes up the witness stack for our htlc input, so we need to restore it. - // See https://github.com/bitcoin/bitcoin/issues/21151 - htlcTx.txInfo.tx.copy(txIn = htlcTx.txInfo.tx.txIn.head +: signTxResponse.tx.txIn.tail) - })) { - case Success(signedTx) => SignWalletInputsOk(signedTx) - case Failure(reason) => SignWalletInputsFailed(reason) + val psbt = new Psbt(htlcTx.txInfo.tx) + val updated = psbt.updateWitnessInput(htlcTx.txInfo.input.outPoint, htlcTx.txInfo.input.txOut, null, fr.acinq.bitcoin.Script.parse(htlcTx.txInfo.input.redeemScript), null, java.util.Map.of()) + val signed = EitherKt.flatMap(updated, (psbt: Psbt) => psbt.finalizeWitnessInput(0, htlcTx.txInfo.tx.txIn.head.witness)) + if (signed.isLeft) { + SignWalletInputsFailed(new RuntimeException(signed.getLeft.toString)) + } else { + context.pipeToSelf(bitcoinClient.processPsbt(signed.getRight)) { + case Success(processPsbtResponse) => + val extracted = processPsbtResponse.psbt.extract() + if (extracted.isLeft) SignWalletInputsFailed(new RuntimeException(extracted.getLeft.toString)) else SignWalletInputsOk(extracted.getRight) + case Failure(reason) => SignWalletInputsFailed(reason) + } } } Behaviors.receiveMessagePartial { @@ -404,6 +428,8 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, } private def addInputs(anchorTx: ClaimLocalAnchorWithWitnessData, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(ClaimLocalAnchorWithWitnessData, Satoshi)] = { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + val dustLimit = commitments.localParams.dustLimit val commitFeerate = commitments.localCommit.spec.commitTxFeerate val commitTx = dummySignedCommitTx(commitments).tx @@ -422,32 +448,50 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, // That's ok, we can increase the fee later by decreasing the output amount. But we need to ensure we'll have enough // to cover the weight of our anchor input, which is why we set it to the following value. val dummyChangeAmount = weight2fee(anchorFeerate, claimAnchorOutputMinWeight) + dustLimit - val txNotFunded = Transaction(2, Nil, TxOut(dummyChangeAmount, Script.pay2wpkh(PlaceHolderPubKey)) :: Nil, 0) - bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(anchorFeerate, lockUtxos = true)).flatMap(fundTxResponse => { - // We merge the outputs if there's more than one. - fundTxResponse.changePosition match { + val Some(address) = computeScriptAddress(nodeParams.chainHash, Script.pay2wpkh(PlaceHolderPubKey)) + + // merge outptuts if needed to get a PSBT with a single output + def makeSingleOutput(fundPsbtResponse: FundPsbtResponse): Future[Psbt] = { + fundPsbtResponse.changePosition match { case Some(changePos) => - val changeOutput = fundTxResponse.tx.txOut(changePos) - val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(changeOutput.copy(amount = changeOutput.amount + dummyChangeAmount))) - Future.successful(fundTxResponse.copy(tx = txSingleOutput)) + // add our main output to the change output + val changeOutput = fundPsbtResponse.psbt.getGlobal.getTx.txOut.get(changePos) + val changeOutput1 = changeOutput.updateAmount(changeOutput.amount + dummyChangeAmount) + val psbt = fundPsbtResponse.psbt + val global = psbt.getGlobal + val tx = global.getTx + val tx1 = tx.updateOutputs(java.util.List.of(changeOutput1)) + val global1 = global.copy(global.getVersion, tx1, global.getExtendedPublicKeys, global.getUnknown) + val psbt1 = psbt.copy(global1, psbt.getInputs, java.util.List.of(psbt.getOutputs.get(changePos))) + Future.successful(psbt1) case None => + // replace our main output with a dummy change output bitcoinClient.getChangeAddress().map(pubkeyHash => { - val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(TxOut(dummyChangeAmount, Script.pay2wpkh(pubkeyHash)))) - fundTxResponse.copy(tx = txSingleOutput) + val changeOutput1 = TxOut(dummyChangeAmount, Script.pay2wpkh(pubkeyHash)) + val global = fundPsbtResponse.psbt.getGlobal + val tx = global.getTx + val tx1 = tx.updateOutputs(java.util.List.of(changeOutput1)) + val global1 = global.copy(global.getVersion, tx1, global.getExtendedPublicKeys, global.getUnknown) + fundPsbtResponse.psbt.copy(global1, fundPsbtResponse.psbt.getInputs, fundPsbtResponse.psbt.getOutputs) }) } - }).map(fundTxResponse => { - require(fundTxResponse.tx.txOut.size == 1, "funded transaction should have a single change output") + } + + for { + fundPsbtResponse <- bitcoinClient.fundPsbt(Seq(address -> dummyChangeAmount), 0, FundPsbtOptions(anchorFeerate, lockUtxos = true, changePosition = Some(1))) + psbt <- makeSingleOutput(fundPsbtResponse) // NB: we insert the anchor input in the *first* position because our signing helpers only sign input #0. - val unsignedTx = anchorTx.updateTx(fundTxResponse.tx.copy(txIn = anchorTx.txInfo.tx.txIn.head +: fundTxResponse.tx.txIn)) - val totalAmountIn = fundTxResponse.amountIn + AnchorOutputsCommitmentFormat.anchorAmount - (adjustAnchorOutputChange(unsignedTx, commitTx, totalAmountIn, commitFeerate, targetFeerate, dustLimit), totalAmountIn) - }) + tx = KotlinUtils.kmp2scala(psbt.getGlobal.getTx) + unsignedTx = anchorTx.updateTx(tx.copy(txIn = anchorTx.txInfo.tx.txIn.head +: tx.txIn)) + totalAmountIn = fundPsbtResponse.amountIn + AnchorOutputsCommitmentFormat.anchorAmount + adjustedTx = adjustAnchorOutputChange(unsignedTx, commitTx, totalAmountIn, commitFeerate, targetFeerate, dustLimit) + } yield { + (adjustedTx, totalAmountIn) + } } private def addInputs(htlcTx: HtlcWithWitnessData, targetFeerate: FeeratePerKw, commitments: Commitments): Future[(HtlcWithWitnessData, Satoshi)] = { - // NB: fundrawtransaction doesn't support non-wallet inputs, so we clear the input and re-add it later. - val txNotFunded = htlcTx.txInfo.tx.copy(txIn = Nil, txOut = htlcTx.txInfo.tx.txOut.head.copy(amount = commitments.localParams.dustLimit) :: Nil) + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ val htlcTxWeight = htlcTx.txInfo match { case _: HtlcSuccessTx => commitments.commitmentFormat.htlcSuccessWeight case _: HtlcTimeoutTx => commitments.commitmentFormat.htlcTimeoutWeight @@ -464,16 +508,21 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, // NB: we don't take into account the fee paid by our HTLC input: we will take it into account when we adjust the // change output amount (unless bitcoind didn't add any change output, in that case we will overpay the fee slightly). val weightRatio = 1.0 + (htlcInputMaxWeight.toDouble / (htlcTxWeight + claimP2WPKHOutputWeight)) - bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(targetFeerate * weightRatio, lockUtxos = true, changePosition = Some(1))).map(fundTxResponse => { + val Some(address) = computeScriptAddress(nodeParams.chainHash, Script.pay2wpkh(PlaceHolderPubKey)) + + // NB: fundpsbt doesn't support non-wallet inputs, so we clear the input and re-add it later. + bitcoinClient.fundPsbt(Seq(address -> commitments.localParams.dustLimit), htlcTx.txInfo.tx.lockTime, FundPsbtOptions(targetFeerate * weightRatio, lockUtxos = true, changePosition = Some(1))).map(fundPsbtResponse => { // We add the HTLC input (from the commit tx) and restore the HTLC output. // NB: we can't modify them because they are signed by our peer (with SIGHASH_SINGLE | SIGHASH_ANYONECANPAY). - val txWithHtlcInput = fundTxResponse.tx.copy( - txIn = htlcTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn, - txOut = htlcTx.txInfo.tx.txOut ++ fundTxResponse.tx.txOut.tail + val tx: Transaction = fundPsbtResponse.psbt.getGlobal.getTx + val txWithHtlcInput = tx.copy( + txIn = htlcTx.txInfo.tx.txIn ++ tx.txIn, + txOut = htlcTx.txInfo.tx.txOut ++ tx.txOut.tail ) val unsignedTx = htlcTx.updateTx(txWithHtlcInput) - val totalAmountIn = fundTxResponse.amountIn + unsignedTx.txInfo.amountIn - (adjustHtlcTxChange(unsignedTx, totalAmountIn, targetFeerate, commitments.localParams.dustLimit, commitments.commitmentFormat), totalAmountIn) + val totalAmountIn = fundPsbtResponse.amountIn + unsignedTx.txInfo.amountIn + val adjustedTx = adjustHtlcTxChange(unsignedTx, totalAmountIn, targetFeerate, commitments.localParams.dustLimit, commitments.commitmentFormat) + (adjustedTx, totalAmountIn) }) } diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala index 2991b89efa..a404891700 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala @@ -17,12 +17,13 @@ package fr.acinq.eclair.blockchain import fr.acinq.bitcoin.TxIn.SEQUENCE_FINAL +import fr.acinq.bitcoin.psbt.{Psbt, SignPsbtResult} import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxIn, TxOut} +import fr.acinq.bitcoin.scalacompat.{ByteVector32, Crypto, DeterministicWallet, KotlinUtils, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxIn, TxOut} import fr.acinq.bitcoin.{Bech32, SigHash, SigVersion} import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, SignTransactionResponse} import fr.acinq.eclair.blockchain.fee.FeeratePerKw -import fr.acinq.eclair.transactions.Transactions +import fr.acinq.eclair.transactions.{Scripts, Transactions} import fr.acinq.eclair.{randomBytes32, randomKey} import scodec.bits._ @@ -50,9 +51,10 @@ class DummyOnChainWallet extends OnChainWallet { override def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[ByteVector32] = Future.successful(tx.txid) - override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { - val tx = DummyOnChainWallet.makeDummyFundingTx(pubkeyScript, amount) - funded += (tx.fundingTx.txid -> tx.fundingTx) + override def makeFundingTx(chainHash: ByteVector32, localFundingKey: DeterministicWallet.ExtendedPublicKey, remoteFundingKey: PublicKey, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { + val tx = DummyOnChainWallet.makeDummyFundingTx(Script.write(Script.pay2wsh(Scripts.multiSig2of2(localFundingKey.publicKey, remoteFundingKey))), amount) + val Right(fundingTx) = tx.fundingTx() + funded += (fundingTx.txid -> fundingTx) Future.successful(tx) } @@ -88,7 +90,8 @@ class NoOpOnChainWallet extends OnChainWallet { override def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[ByteVector32] = Future.successful(tx.txid) - override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = Promise().future // will never be completed + override def makeFundingTx(chainHash: ByteVector32, localFundingKey: DeterministicWallet.ExtendedPublicKey, remoteFundingKey: PublicKey, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = + Promise[MakeFundingTxResponse]().future // will never be completed override def commit(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(true) @@ -151,12 +154,15 @@ class SingleKeyOnChainWallet extends OnChainWallet { override def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[ByteVector32] = Future.successful(tx.txid) - override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { + override def makeFundingTx(chainHash: ByteVector32, localFundingKey: DeterministicWallet.ExtendedPublicKey, remoteFundingKey: PublicKey, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = { + import KotlinUtils._ + + val pubkeyScript = Script.pay2wsh(Scripts.multiSig2of2(localFundingKey.publicKey, remoteFundingKey)) val tx = Transaction(2, Nil, Seq(TxOut(amount, pubkeyScript)), 0) for { fundedTx <- fundTransaction(tx, feeRatePerKw, replaceable = true, lockUtxos = true) signedTx <- signTransaction(fundedTx.tx, allowIncomplete = true) - } yield MakeFundingTxResponse(signedTx.tx, 0, fundedTx.fee) + } yield MakeFundingTxResponse(new Psbt(signedTx.tx), 0, fundedTx.fee) } override def commit(tx: Transaction)(implicit ec: ExecutionContext): Future[Boolean] = Future.successful(true) @@ -182,13 +188,21 @@ object DummyOnChainWallet { val dummyReceivePubkey: PublicKey = PublicKey(hex"028feba10d0eafd0fad8fe20e6d9206e6bd30242826de05c63f459a00aced24b12") def makeDummyFundingTx(pubkeyScript: ByteVector, amount: Satoshi): MakeFundingTxResponse = { - val fundingTx = Transaction( - version = 2, - txIn = TxIn(OutPoint(ByteVector32(ByteVector.fill(32)(1)), 42), signatureScript = Nil, sequence = SEQUENCE_FINAL) :: Nil, + import KotlinUtils._ + import fr.acinq.bitcoin.utils.EitherKt + + val key = randomKey() + val baseTx = Transaction(version = 2, txIn = Nil, txOut = TxOut(amount, Script.pay2wpkh(key.publicKey)) :: Nil, lockTime = 0) + val fundingTx = Transaction(version = 2, + txIn = TxIn(OutPoint(baseTx, 0), signatureScript = Nil, sequence = SEQUENCE_FINAL) :: Nil, txOut = TxOut(amount, pubkeyScript) :: Nil, - lockTime = 0 - ) - MakeFundingTxResponse(fundingTx, 0, 420 sat) + lockTime = 0) + + val updated = new Psbt(fundingTx).updateWitnessInputTx(baseTx, 0, null, fr.acinq.bitcoin.Script.pay2pkh(key.publicKey), null, java.util.Map.of()) + val signed = EitherKt.flatMap(updated, (p: Psbt) => p.sign(key, 0)) + val finalized = EitherKt.flatMap(signed, (p: SignPsbtResult) => p.getPsbt.finalizeWitnessInput(0, Script.witnessPay2wpkh(key.publicKey, p.getSig))) + require(finalized.isRight) + MakeFundingTxResponse(finalized.getRight, 0, 420 sat) } } \ No newline at end of file diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala index 26d2b19a9b..602275c873 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala @@ -20,8 +20,10 @@ import akka.actor.Status.Failure import akka.pattern.pipe import akka.testkit.TestProbe import fr.acinq.bitcoin -import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey -import fr.acinq.bitcoin.scalacompat.{Block, BtcDouble, ByteVector32, MilliBtcDouble, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut} +import fr.acinq.bitcoin.Bech32 +import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey} +import fr.acinq.bitcoin.scalacompat.DeterministicWallet.{ExtendedPublicKey, KeyPath} +import fr.acinq.bitcoin.scalacompat.{Block, BtcDouble, ByteVector32, Crypto, KotlinUtils, MilliBtcDouble, OutPoint, Satoshi, SatoshiLong, Script, ScriptWitness, Transaction, TxIn, TxOut, computeScriptAddress} import fr.acinq.eclair.blockchain.OnChainWallet.{FundTransactionResponse, MakeFundingTxResponse, OnChainBalance, SignTransactionResponse} import fr.acinq.eclair.blockchain.WatcherSpec.{createSpendManyP2WPKH, createSpendP2WPKH} import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq @@ -30,7 +32,7 @@ import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCAuthMethod.UserPass import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient, JsonRPCError} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.transactions.{Scripts, Transactions} -import fr.acinq.eclair.{BlockHeight, TestConstants, TestKitBaseClass, addressToPublicKeyScript, randomKey} +import fr.acinq.eclair.{BlockHeight, TestConstants, TestKitBaseClass, addressToPublicKeyScript, randomBytes32, randomKey} import grizzled.slf4j.Logging import org.json4s.JsonAST._ import org.json4s.{DefaultFormats, Formats} @@ -64,70 +66,125 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A sender.expectMsgType[JString](60 seconds) restartBitcoind(sender) - val pubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(randomKey().publicKey, randomKey().publicKey))) - bitcoinClient.makeFundingTx(pubkeyScript, 50 millibtc, FeeratePerKw(10000 sat)).pipeTo(sender.ref) + bitcoinClient.makeFundingTx(Block.RegtestGenesisBlock.hash, ExtendedPublicKey(randomKey().publicKey.value, randomBytes32(), 4, KeyPath("m/1/2/3/4"), 0), randomKey().publicKey, 50 millibtc, FeeratePerKw(10000 sat)).pipeTo(sender.ref) val error = sender.expectMsgType[Failure].cause.asInstanceOf[JsonRPCError].error - assert(error.message.contains("Please enter the wallet passphrase with walletpassphrase first")) + assert(error.message.contains("cannot sign psbt")) sender.send(bitcoincli, BitcoinReq("walletpassphrase", walletPassword, 3600)) // wallet stay unlocked for 3600s sender.expectMsgType[JValue] } + test("fund and sign psbt") { + val sender = TestProbe() + val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) + val priv1 = PrivateKey(ByteVector32.fromValidHex("01" * 32)) + val priv2 = PrivateKey(ByteVector32.fromValidHex("02" * 32)) + val script = Script.createMultiSigMofN(2, Seq(priv1.publicKey, priv2.publicKey)) + val address = Bech32.encodeWitnessAddress("bcrt", 0, Crypto.sha256(Script.write(script)).toArray) + + bitcoinClient.fundPsbt(Seq(address -> 10000.sat), 0, FundPsbtOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) + val FundPsbtResponse(psbt, _, _) = sender.expectMsgType[FundPsbtResponse] + + bitcoinClient.processPsbt(psbt).pipeTo(sender.ref) + val ProcessPsbtResponse(psbt1, true) = sender.expectMsgType[ProcessPsbtResponse] + assert(psbt1.extract().isRight) + } + + test("fund psbt (invalid requests)") { + val sender = TestProbe() + val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) + val priv = PrivateKey(ByteVector32.fromValidHex("01" * 32)) + val address = Bech32.encodeWitnessAddress("bcrt", 0, priv.publicKey.hash160.toArray) + + { + // check that it does work + bitcoinClient.fundPsbt(Seq(address -> 10000.sat), 0, FundPsbtOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) + sender.expectMsgType[FundPsbtResponse] + } + { + // invalid address + bitcoinClient.fundPsbt(Seq("invalid address" -> 10000.sat), 0, FundPsbtOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) + sender.expectMsgType[akka.actor.Status.Failure] + } + { + // amount is too small + bitcoinClient.fundPsbt(Seq(address -> 100.sat), 0, FundPsbtOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) + sender.expectMsgType[akka.actor.Status.Failure] + } + { + // amount is too large + bitcoinClient.fundPsbt(Seq(address -> 11_000_000.btc), 0, FundPsbtOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) + sender.expectMsgType[akka.actor.Status.Failure] + } + } + test("fund transactions") { + import KotlinUtils._ + val sender = TestProbe() val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) - val txToRemote = { - val txNotFunded = Transaction(2, Nil, TxOut(150000 sat, Script.pay2wpkh(randomKey().publicKey)) :: Nil, 0) - bitcoinClient.fundTransaction(txNotFunded, FundTransactionOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) - val fundTxResponse = sender.expectMsgType[FundTransactionResponse] + val txToRemote: Transaction = { + bitcoinClient.fundPsbt(Seq(computeScriptAddress(Block.RegtestGenesisBlock.hash, Script.pay2wpkh(randomKey().publicKey)).get -> 150000.sat), locktime = 0, FundPsbtOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) + val fundTxResponse = sender.expectMsgType[FundPsbtResponse] assert(fundTxResponse.changePosition.nonEmpty) assert(fundTxResponse.amountIn > 0.sat) assert(fundTxResponse.fee > 0.sat) - fundTxResponse.tx.txIn.foreach(txIn => assert(txIn.signatureScript.isEmpty && txIn.witness.isNull)) - fundTxResponse.tx.txIn.foreach(txIn => assert(txIn.sequence == bitcoin.TxIn.SEQUENCE_FINAL - 2)) + val unsigned: Transaction = fundTxResponse.psbt.getGlobal.getTx + unsigned.txIn.foreach(txIn => assert(txIn.signatureScript.isEmpty && txIn.witness.isNull)) + unsigned.txIn.foreach(txIn => assert(txIn.sequence == bitcoin.TxIn.SEQUENCE_FINAL - 2)) - bitcoinClient.signTransaction(fundTxResponse.tx, Nil).pipeTo(sender.ref) - val signTxResponse = sender.expectMsgType[SignTransactionResponse] + bitcoinClient.processPsbt(fundTxResponse.psbt).pipeTo(sender.ref) + val signTxResponse = sender.expectMsgType[ProcessPsbtResponse] assert(signTxResponse.complete) - assert(signTxResponse.tx.txOut.size == 2) + val signed: Transaction = signTxResponse.psbt.extract().getRight + assert(signed.txOut.size == 2) - bitcoinClient.publishTransaction(signTxResponse.tx).pipeTo(sender.ref) - sender.expectMsg(signTxResponse.tx.txid) + bitcoinClient.publishTransaction(signed).pipeTo(sender.ref) + sender.expectMsg(signed.txid) generateBlocks(1) - signTxResponse.tx + signed } { // txs with no outputs are not supported. val emptyTx = Transaction(2, Nil, Nil, 0) - bitcoinClient.fundTransaction(emptyTx, FundTransactionOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) + bitcoinClient.fundPsbt(Seq.empty, locktime = 0, FundPsbtOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) sender.expectMsgType[Failure] } { // bitcoind requires that "all existing inputs must have their previous output transaction be in the wallet". - val txNonWalletInputs = Transaction(2, Seq(TxIn(OutPoint(txToRemote, 0), Nil, 0), TxIn(OutPoint(txToRemote, 1), Nil, 0)), Seq(TxOut(100000 sat, Script.pay2wpkh(randomKey().publicKey))), 0) - bitcoinClient.fundTransaction(txNonWalletInputs, FundTransactionOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) + bitcoinClient.fundPsbt( + Seq(FundPsbtInput(txToRemote.txid, 0), FundPsbtInput(txToRemote.txid, 1)), + Seq(computeScriptAddress(Block.RegtestGenesisBlock.hash, Script.pay2wpkh(randomKey().publicKey)).get -> 100000.sat), + 0, + FundPsbtOptions(TestConstants.feeratePerKw) + ).pipeTo(sender.ref) sender.expectMsgType[Failure] } { // we can increase the feerate. - bitcoinClient.fundTransaction(Transaction(2, Nil, TxOut(250000 sat, Script.pay2wpkh(randomKey().publicKey)) :: Nil, 0), FundTransactionOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) - val fundTxResponse1 = sender.expectMsgType[FundTransactionResponse] - bitcoinClient.fundTransaction(fundTxResponse1.tx, FundTransactionOptions(TestConstants.feeratePerKw * 2)).pipeTo(sender.ref) - val fundTxResponse2 = sender.expectMsgType[FundTransactionResponse] - assert(fundTxResponse1.tx !== fundTxResponse2.tx) + bitcoinClient.fundPsbt(Seq(computeScriptAddress(Block.RegtestGenesisBlock.hash, Script.pay2wpkh(randomKey().publicKey)).get -> 250000.sat), locktime = 0, FundPsbtOptions(TestConstants.feeratePerKw)).pipeTo(sender.ref) + val fundTxResponse1 = sender.expectMsgType[FundPsbtResponse] + bitcoinClient.fundPsbt(Seq(computeScriptAddress(Block.RegtestGenesisBlock.hash, Script.pay2wpkh(randomKey().publicKey)).get -> 250000.sat), locktime = 0, FundPsbtOptions(TestConstants.feeratePerKw * 2)).pipeTo(sender.ref) + val fundTxResponse2 = sender.expectMsgType[FundPsbtResponse] + assert(fundTxResponse1.psbt.getGlobal !== fundTxResponse2.psbt.getGlobal) assert(fundTxResponse1.fee < fundTxResponse2.fee) } { // we can control where the change output is inserted and opt-out of RBF. - val txManyOutputs = Transaction(2, Nil, TxOut(410000 sat, Script.pay2wpkh(randomKey().publicKey)) :: TxOut(230000 sat, Script.pay2wpkh(randomKey().publicKey)) :: Nil, 0) - bitcoinClient.fundTransaction(txManyOutputs, FundTransactionOptions(TestConstants.feeratePerKw, replaceable = false, changePosition = Some(1))).pipeTo(sender.ref) - val fundTxResponse = sender.expectMsgType[FundTransactionResponse] - assert(fundTxResponse.tx.txOut.size == 3) - assert(fundTxResponse.changePosition == Some(1)) - assert(!Set(230000 sat, 410000 sat).contains(fundTxResponse.tx.txOut(1).amount)) - assert(Set(230000 sat, 410000 sat) == Set(fundTxResponse.tx.txOut.head.amount, fundTxResponse.tx.txOut.last.amount)) - fundTxResponse.tx.txIn.foreach(txIn => assert(txIn.sequence == bitcoin.TxIn.SEQUENCE_FINAL - 1)) + bitcoinClient.fundPsbt( + Seq( + computeScriptAddress(Block.RegtestGenesisBlock.hash, Script.pay2wpkh(randomKey().publicKey)).get -> 410000.sat, + computeScriptAddress(Block.RegtestGenesisBlock.hash, Script.pay2wpkh(randomKey().publicKey)).get -> 230000.sat, + ), + locktime = 0, FundPsbtOptions(TestConstants.feeratePerKw, changePosition = Some(1), replaceable = false) + ).pipeTo(sender.ref) + val fundTxResponse = sender.expectMsgType[FundPsbtResponse] + assert(fundTxResponse.changePosition.contains(1)) + val tx: Transaction = fundTxResponse.psbt.getGlobal.getTx + assert(tx.txOut.size == 3) + assert(Set(230000 sat, 410000 sat) == Set(tx.txOut(0).amount, tx.txOut(2).amount)) + fundTxResponse.psbt.getGlobal.getTx.txIn.forEach(txIn => assert(txIn.sequence == bitcoin.TxIn.SEQUENCE_FINAL - 1)) } } @@ -169,17 +226,25 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A assert(Try(addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash)).isSuccess) val fundingTxs = for (_ <- 0 to 3) yield { - val pubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(randomKey().publicKey, randomKey().publicKey))) - bitcoinClient.makeFundingTx(pubkeyScript, Satoshi(500), FeeratePerKw(250 sat)).pipeTo(sender.ref) - val fundingTx = sender.expectMsgType[MakeFundingTxResponse].fundingTx + bitcoinClient.makeFundingTx( + Block.RegtestGenesisBlock.hash, + ExtendedPublicKey(randomKey().publicKey.value, randomBytes32(), 4, KeyPath("m/1/2/3/4"), 0), + randomKey().publicKey, + Satoshi(500), FeeratePerKw(250 sat)).pipeTo(sender.ref) + val Right(fundingTx) = sender.expectMsgType[MakeFundingTxResponse].fundingTx() bitcoinClient.publishTransaction(fundingTx.copy(txIn = Nil)).pipeTo(sender.ref) // try publishing an invalid version of the tx sender.expectMsgType[Failure] bitcoinClient.rollback(fundingTx).pipeTo(sender.ref) // rollback the locked outputs assert(sender.expectMsgType[Boolean]) // now fund a tx with correct feerate - bitcoinClient.makeFundingTx(pubkeyScript, 50 millibtc, FeeratePerKw(250 sat)).pipeTo(sender.ref) - sender.expectMsgType[MakeFundingTxResponse].fundingTx + bitcoinClient.makeFundingTx( + Block.RegtestGenesisBlock.hash, + ExtendedPublicKey(randomKey().publicKey.value, randomBytes32(), 4, KeyPath("m/1/2/3/4"), 0), + randomKey().publicKey, + 50 millibtc, FeeratePerKw(250 sat)).pipeTo(sender.ref) + val Right(tx) = sender.expectMsgType[MakeFundingTxResponse].fundingTx() + tx } assert(getLocks(sender).size == 4) @@ -210,10 +275,12 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val sender = TestProbe() val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) - val pubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(randomKey().publicKey, randomKey().publicKey))) - // 200 sat/kw is below the min-relay-fee - bitcoinClient.makeFundingTx(pubkeyScript, 5 millibtc, FeeratePerKw(200 sat)).pipeTo(sender.ref) - val MakeFundingTxResponse(fundingTx, _, _) = sender.expectMsgType[MakeFundingTxResponse] + bitcoinClient.makeFundingTx( + Block.RegtestGenesisBlock.hash, + ExtendedPublicKey(randomKey().publicKey.value, randomBytes32(), 4, KeyPath("m/1/2/3/4"), 0), + randomKey().publicKey, + 5 millibtc, FeeratePerKw(200 sat)).pipeTo(sender.ref) + val Right(fundingTx) = sender.expectMsgType[MakeFundingTxResponse].fundingTx() bitcoinClient.commit(fundingTx).pipeTo(sender.ref) sender.expectMsg(true) @@ -232,9 +299,12 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A assert(getLocks(sender).isEmpty) - val pubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(randomKey().publicKey, randomKey().publicKey))) - bitcoinClient.makeFundingTx(pubkeyScript, 50 millibtc, FeeratePerKw(10000 sat)).pipeTo(sender.ref) - val MakeFundingTxResponse(fundingTx, _, _) = sender.expectMsgType[MakeFundingTxResponse] + bitcoinClient.makeFundingTx( + Block.RegtestGenesisBlock.hash, + ExtendedPublicKey(randomKey().publicKey.value, randomBytes32(), 4, KeyPath("m/1/2/3/4"), 0), + randomKey().publicKey, + 50 millibtc, FeeratePerKw(10000 sat)).pipeTo(sender.ref) + val Right(fundingTx) = sender.expectMsgType[MakeFundingTxResponse].fundingTx() bitcoinClient.commit(fundingTx).pipeTo(sender.ref) sender.expectMsg(true) @@ -305,8 +375,14 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A val bitcoinClient = new BitcoinCoreClient(bitcoinrpcclient) // create a huge tx so we make sure it has > 1 inputs - bitcoinClient.makeFundingTx(pubkeyScript, 250 btc, FeeratePerKw(1000 sat)).pipeTo(sender.ref) - val MakeFundingTxResponse(fundingTx, outputIndex, _) = sender.expectMsgType[MakeFundingTxResponse] + bitcoinClient.makeFundingTx( + Block.RegtestGenesisBlock.hash, + ExtendedPublicKey(randomKey().publicKey.value, randomBytes32(), 4, KeyPath("m/1/2/3/4"), 0), + randomKey().publicKey, + 250 btc, FeeratePerKw(1000 sat)).pipeTo(sender.ref) + val fundingResponse = sender.expectMsgType[MakeFundingTxResponse] + val Right(fundingTx) = fundingResponse.fundingTx() + val outputIndex = fundingResponse.fundingTxOutputIndex // spend the first 2 inputs val tx1 = fundingTx.copy( @@ -346,8 +422,12 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A { // test #1: unlock outpoints that are actually locked // create a huge tx so we make sure it has > 1 inputs - bitcoinClient.makeFundingTx(pubkeyScript, 250 btc, FeeratePerKw(1000 sat)).pipeTo(sender.ref) - val MakeFundingTxResponse(fundingTx, _, _) = sender.expectMsgType[MakeFundingTxResponse] + bitcoinClient.makeFundingTx( + Block.RegtestGenesisBlock.hash, + ExtendedPublicKey(randomKey().publicKey.value, randomBytes32(), 4, KeyPath("m/1/2/3/4"), 0), + randomKey().publicKey, + 250 btc, FeeratePerKw(1000 sat)).pipeTo(sender.ref) + val Right(fundingTx) = sender.expectMsgType[MakeFundingTxResponse].fundingTx() assert(fundingTx.txIn.size > 2) assert(getLocks(sender) == fundingTx.txIn.map(_.outPoint).toSet) bitcoinClient.rollback(fundingTx).pipeTo(sender.ref) @@ -355,8 +435,12 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A } { // test #2: some outpoints are locked, some are unlocked - bitcoinClient.makeFundingTx(pubkeyScript, 250 btc, FeeratePerKw(1000 sat)).pipeTo(sender.ref) - val MakeFundingTxResponse(fundingTx, _, _) = sender.expectMsgType[MakeFundingTxResponse] + bitcoinClient.makeFundingTx( + Block.RegtestGenesisBlock.hash, + ExtendedPublicKey(randomKey().publicKey.value, randomBytes32(), 4, KeyPath("m/1/2/3/4"), 0), + randomKey().publicKey, + 250 btc, FeeratePerKw(1000 sat)).pipeTo(sender.ref) + val Right(fundingTx) = sender.expectMsgType[MakeFundingTxResponse].fundingTx() assert(fundingTx.txIn.size > 2) assert(getLocks(sender) == fundingTx.txIn.map(_.outPoint).toSet) diff --git a/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsBatchValidationSpec.scala b/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsBatchValidationSpec.scala index b5d7775a4f..b6ea401bb4 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsBatchValidationSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/router/AnnouncementsBatchValidationSpec.scala @@ -21,14 +21,15 @@ import akka.pattern.pipe import akka.testkit.TestProbe import sttp.client3.okhttp.OkHttpFutureBackend import fr.acinq.bitcoin.scalacompat.Crypto.PrivateKey -import fr.acinq.bitcoin.scalacompat.{Block, Satoshi, SatoshiLong, Script, Transaction} +import fr.acinq.bitcoin.scalacompat.DeterministicWallet.{ExtendedPublicKey, KeyPath} +import fr.acinq.bitcoin.scalacompat.{Block, KotlinUtils, Satoshi, SatoshiLong, Script, Transaction} import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher.ValidateResult import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinJsonRPCAuthMethod.UserPassword import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BitcoinCoreClient} import fr.acinq.eclair.blockchain.fee.FeeratePerKw import fr.acinq.eclair.transactions.Scripts import fr.acinq.eclair.wire.protocol.{ChannelAnnouncement, ChannelUpdate} -import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, Features, MilliSatoshiLong, RealShortChannelId, ShortChannelId, randomKey} +import fr.acinq.eclair.{BlockHeight, CltvExpiryDelta, Features, MilliSatoshiLong, RealShortChannelId, ShortChannelId, randomBytes32, randomKey} import org.json4s.JsonAST.JString import org.scalatest.funsuite.AnyFunSuite @@ -92,11 +93,15 @@ object AnnouncementsBatchValidationSpec { val node2BitcoinKey = randomKey() val amount = 1000000 sat // first we publish the funding tx - val fundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(node1BitcoinKey.publicKey, node2BitcoinKey.publicKey))) - val fundingTxFuture = bitcoinClient.makeFundingTx(fundingPubkeyScript, amount, FeeratePerKw(10000 sat)) + val fundingTxFuture = bitcoinClient.makeFundingTx( + Block.RegtestGenesisBlock.hash, + ExtendedPublicKey(node1BitcoinKey.publicKey.value, randomBytes32(), 4, KeyPath("m/1/2/3/4"), 0), + node2BitcoinKey.publicKey, + amount, FeeratePerKw(10000 sat)) val res = Await.result(fundingTxFuture, 10 seconds) - Await.result(bitcoinClient.publishTransaction(res.fundingTx), 10 seconds) - SimulatedChannel(node1Key, node2Key, node1BitcoinKey, node2BitcoinKey, amount, res.fundingTx, res.fundingTxOutputIndex) + val fundingTx = KotlinUtils.kmp2scala(res.psbt.extract().getRight) + Await.result(bitcoinClient.publishTransaction(fundingTx), 10 seconds) + SimulatedChannel(node1Key, node2Key, node1BitcoinKey, node2BitcoinKey, amount, fundingTx, res.fundingTxOutputIndex) } def makeChannelAnnouncement(c: SimulatedChannel, bitcoinClient: BitcoinCoreClient)(implicit ec: ExecutionContext): ChannelAnnouncement = {