From 7ef55a6abbdd53bb2bbd1e9703a4a110bb265579 Mon Sep 17 00:00:00 2001 From: Bastien Teinturier <31281497+t-bast@users.noreply.github.com> Date: Thu, 6 Mar 2025 19:44:02 +0100 Subject: [PATCH] Use confirmed inputs for anchor transactions (#3020) In order to use opportunistic package relay (with 1-parent-1-child) we must use confirmed inputs when funding our anchor transactions. This will also be a requirement when using v3 transactions. We also take this opportunity to honor the `require_confirmed_inputs` parameter set by our peer during `interactive-tx`. --- .../eclair/blockchain/OnChainWallet.scala | 2 +- .../bitcoind/rpc/BitcoinCoreClient.scala | 15 +++--- .../channel/fund/InteractiveTxFunder.scala | 24 ++++------ .../channel/publish/ReplaceableTxFunder.scala | 3 +- .../blockchain/DummyOnChainWallet.scala | 6 +-- .../bitcoind/BitcoinCoreClientSpec.scala | 46 +++++++++++++++++++ 6 files changed, 70 insertions(+), 26 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 95bc598e2..8735a8acc 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/OnChainWallet.scala @@ -37,7 +37,7 @@ trait OnChainChannelFunder { * Fund the provided transaction by adding inputs (and a change output if necessary). * Callers must verify that the resulting transaction isn't sending funds to unexpected addresses (malicious bitcoin node). */ - def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean = true, changePosition: Option[Int] = None, externalInputsWeight: Map[OutPoint, Long] = Map.empty, feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] + def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean = true, changePosition: Option[Int] = None, externalInputsWeight: Map[OutPoint, Long] = Map.empty, minInputConfirmations_opt: Option[Int] = None, feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] /** * Sign a PSBT. Result may be partially signed: only inputs known to our bitcoin wallet will be signed. * diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala b/eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala index 339af99df..1d2fe4b17 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 @@ -260,10 +260,10 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val lockUtxos: Bool }) } - def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean = true, changePosition: Option[Int] = None, externalInputsWeight: Map[OutPoint, Long] = Map.empty, feeBudget_opt: Option[Satoshi] = None)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = { + def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean = true, changePosition: Option[Int] = None, externalInputsWeight: Map[OutPoint, Long] = Map.empty, minInputConfirmations_opt: Option[Int] = None, feeBudget_opt: Option[Satoshi] = None)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = { val options = FundTransactionOptions( - BigDecimal(FeeratePerKB(feeRate).toLong).bigDecimal.scaleByPowerOfTen(-8), - replaceable, + feeRate = BigDecimal(FeeratePerKB(feeRate).toLong).bigDecimal.scaleByPowerOfTen(-8), + replaceable = replaceable, // We must either *always* lock inputs selected for funding or *never* lock them, otherwise locking wouldn't work // at all, as the following scenario highlights: // - we fund a transaction for which we don't lock utxos @@ -272,9 +272,10 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val lockUtxos: Bool // - but the first transaction confirms, invalidating the second one // This would break the assumptions of the second transaction: its inputs are locked, so it doesn't expect to // potentially be double-spent. - lockUtxos, - changePosition, - if (externalInputsWeight.isEmpty) None else Some(externalInputsWeight.map { case (outpoint, weight) => InputWeight(outpoint, weight) }.toSeq) + lockUnspents = lockUtxos, + changePosition = changePosition, + minconf = minInputConfirmations_opt, + input_weights = if (externalInputsWeight.isEmpty) None else Some(externalInputsWeight.map { case (outpoint, weight) => InputWeight(outpoint, weight) }.toSeq), ) fundTransaction(tx, options, feeBudget_opt = feeBudget_opt) } @@ -746,7 +747,7 @@ object BitcoinCoreClient { def apply(outPoint: OutPoint, weight: Long): InputWeight = InputWeight(outPoint.txid.value.toHex, outPoint.index, weight) } - case class FundTransactionOptions(feeRate: BigDecimal, replaceable: Boolean, lockUnspents: Boolean, changePosition: Option[Int], input_weights: Option[Seq[InputWeight]]) + case class FundTransactionOptions(feeRate: BigDecimal, replaceable: Boolean, lockUnspents: Boolean, changePosition: Option[Int], minconf: Option[Int], input_weights: Option[Seq[InputWeight]]) /** * Information about a transaction currently in the mempool. diff --git a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala index 418ca53ca..79a55f978 100644 --- a/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala +++ b/eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxFunder.scala @@ -120,14 +120,12 @@ object InteractiveTxFunder { } } - private def canUseInput(fundingParams: InteractiveTxParams, txIn: TxIn, previousTx: Transaction, confirmations: Int): Boolean = { + private def canUseInput(txIn: TxIn, previousTx: Transaction): Boolean = { // Wallet input transaction must fit inside the tx_add_input message. val previousTxSizeOk = Transaction.write(previousTx).length <= 65000 // Wallet input must be a native segwit input. val isNativeSegwit = Script.isNativeWitnessScript(previousTx.txOut(txIn.outPoint.index.toInt).publicKeyScript) - // Wallet input must be confirmed if our peer requested it. - val confirmationsOk = !fundingParams.requireConfirmedInputs.forLocal || confirmations > 0 - previousTxSizeOk && isNativeSegwit && confirmationsOk + previousTxSizeOk && isNativeSegwit } private def sortFundingContributions(fundingParams: InteractiveTxParams, inputs: Seq[OutgoingInput], outputs: Seq[OutgoingOutput]): FundingContributions = { @@ -237,7 +235,8 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response case p: SpliceTxRbf => p.feeBudget_opt case _ => None } - context.pipeToSelf(wallet.fundTransaction(txNotFunded, fundingParams.targetFeerate, replaceable = true, externalInputsWeight = sharedInputWeight, feeBudget_opt = feeBudget_opt)) { + val minConfirmations_opt = if (fundingParams.requireConfirmedInputs.forLocal) Some(1) else None + context.pipeToSelf(wallet.fundTransaction(txNotFunded, fundingParams.targetFeerate, externalInputsWeight = sharedInputWeight, minInputConfirmations_opt = minConfirmations_opt, feeBudget_opt = feeBudget_opt)) { case Failure(t) => WalletFailure(t) case Success(result) => FundTransactionResult(result.tx, result.changePosition) } @@ -345,18 +344,15 @@ private class InteractiveTxFunder(replyTo: ActorRef[InteractiveTxFunder.Response // We don't need to validate the shared input, it comes from a valid lightning channel. Future.successful(Right(Input.Shared(UInt64(0), sharedInput.info.outPoint, sharedInput.info.txOut.publicKeyScript, txIn.sequence, purpose.previousLocalBalance, purpose.previousRemoteBalance, purpose.htlcBalance))) case _ => - for { - previousTx <- wallet.getTransaction(txIn.outPoint.txid) - // Strip input witnesses to save space (there is a max size on txs due to lightning message limits). - .map(_.modify(_.txIn.each.witness).setTo(ScriptWitness.empty)) - confirmations_opt <- if (fundingParams.requireConfirmedInputs.forLocal) wallet.getTxConfirmations(txIn.outPoint.txid) else Future.successful(None) - } yield { - if (canUseInput(fundingParams, txIn, previousTx, confirmations_opt.getOrElse(0))) { - Right(Input.Local(UInt64(0), previousTx, txIn.outPoint.index, txIn.sequence)) + wallet.getTransaction(txIn.outPoint.txid).map(tx => { + // Strip input witnesses to save space (there is a max size on txs due to lightning message limits). + val txWithoutWitness = tx.modify(_.txIn.each.witness).setTo(ScriptWitness.empty) + if (canUseInput(txIn, txWithoutWitness)) { + Right(Input.Local(UInt64(0), txWithoutWitness, txIn.outPoint.index, txIn.sequence)) } else { Left(UnusableInput(txIn.outPoint)) } - } + }) } } } 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 33b8aaf23..37add66a3 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 @@ -446,7 +446,8 @@ private class ReplaceableTxFunder(nodeParams: NodeParams, // start with a dummy output and later merge that dummy output with the optional change output added by bitcoind. val txNotFunded = anchorTx.txInfo.tx.copy(txOut = TxOut(dustLimit, Script.pay2wpkh(PlaceHolderPubKey)) :: Nil) val anchorWeight = Map(anchorTx.txInfo.input.outPoint -> anchorInputWeight.toLong) - bitcoinClient.fundTransaction(txNotFunded, targetFeerate, externalInputsWeight = anchorWeight).flatMap { fundTxResponse => + // We only use confirmed inputs for anchor transactions to be able to leverage 1-parent-1-child package relay. + bitcoinClient.fundTransaction(txNotFunded, targetFeerate, externalInputsWeight = anchorWeight, minInputConfirmations_opt = Some(1)).flatMap { fundTxResponse => // Bitcoin Core may not preserve the order of inputs, we need to make sure the anchor is the first input. val txIn = anchorTx.txInfo.tx.txIn ++ fundTxResponse.tx.txIn.filterNot(_.outPoint == anchorTx.txInfo.input.outPoint) // We merge our dummy change output with the one added by Bitcoin Core, if any. 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 81e000f58..098eed4d8 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/DummyOnChainWallet.scala @@ -50,7 +50,7 @@ class DummyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { override def getP2wpkhPubkey()(implicit ec: ExecutionContext): Future[Crypto.PublicKey] = Future.successful(dummyReceivePubkey) - override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, changePosition: Option[Int], externalInputsWeight: Map[OutPoint, Long], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = { + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, changePosition: Option[Int], externalInputsWeight: Map[OutPoint, Long], minInputConfirmations_opt: Option[Int], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = { funded += (tx.txid -> tx) Future.successful(FundTransactionResponse(tx, 0 sat, None)) } @@ -105,7 +105,7 @@ class NoOpOnChainWallet extends OnChainWallet with OnchainPubkeyCache { override def getP2wpkhPubkey()(implicit ec: ExecutionContext): Future[Crypto.PublicKey] = Future.successful(dummyReceivePubkey) - override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, changePosition: Option[Int], externalInputsWeight: Map[OutPoint, Long], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = Promise().future // will never be completed + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, changePosition: Option[Int], externalInputsWeight: Map[OutPoint, Long], minInputConfirmations_opt: Option[Int], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = Promise().future // will never be completed override def signPsbt(psbt: Psbt, ourInputs: Seq[Int], ourOutputs: Seq[Int])(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = Promise().future // will never be completed @@ -152,7 +152,7 @@ class SingleKeyOnChainWallet extends OnChainWallet with OnchainPubkeyCache { override def getP2wpkhPubkey()(implicit ec: ExecutionContext): Future[Crypto.PublicKey] = Future.successful(pubkey) - override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, changePosition: Option[Int], externalInputsWeight: Map[OutPoint, Long], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = synchronized { + override def fundTransaction(tx: Transaction, feeRate: FeeratePerKw, replaceable: Boolean, changePosition: Option[Int], externalInputsWeight: Map[OutPoint, Long], minInputConfirmations_opt: Option[Int], feeBudget_opt: Option[Satoshi])(implicit ec: ExecutionContext): Future[FundTransactionResponse] = synchronized { val currentAmountIn = tx.txIn.flatMap(txIn => inputs.find(_.txid == txIn.outPoint.txid).flatMap(_.txOut.lift(txIn.outPoint.index.toInt))).map(_.amount).sum val amountOut = tx.txOut.map(_.amount).sum // We add a single input to reach the desired feerate. 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 0c3c01888..bc57a88cf 100644 --- a/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala +++ b/eclair-core/src/test/scala/fr/acinq/eclair/blockchain/bitcoind/BitcoinCoreClientSpec.scala @@ -194,6 +194,52 @@ class BitcoinCoreClientSpec extends TestKitBaseClass with BitcoindService with A } } + test("fund transactions with confirmed inputs") { + import fr.acinq.bitcoin.scalacompat.KotlinUtils._ + + val sender = TestProbe() + val miner = makeBitcoinCoreClient() + val wallet = new BitcoinCoreClient(createWallet("funding_confirmed_inputs", sender)) + wallet.getReceiveAddress().pipeTo(sender.ref) + val address = sender.expectMsgType[String] + val pubkeyScript = Script.write(addressToPublicKeyScript(Block.RegtestGenesisBlock.hash, address).toOption.get) + + // We first receive some confirmed funds. + miner.sendToPubkeyScript(pubkeyScript, 150_000 sat, FeeratePerKw(FeeratePerByte(5 sat))).pipeTo(sender.ref) + val externalTxId = sender.expectMsgType[TxId] + generateBlocks(1) + + // Our utxo has 1 confirmation: we can spend it if we allow this confirmation count. + val tx1 = { + val txNotFunded = Transaction(2, Nil, Seq(TxOut(125_000 sat, pubkeyScript)), 0) + wallet.fundTransaction(txNotFunded, FeeratePerKw(1_000 sat), minInputConfirmations_opt = Some(2)).pipeTo(sender.ref) + assert(sender.expectMsgType[Failure].cause.getMessage.contains("Insufficient funds")) + wallet.fundTransaction(txNotFunded, FeeratePerKw(1_000 sat), minInputConfirmations_opt = Some(1)).pipeTo(sender.ref) + val unsignedTx = sender.expectMsgType[FundTransactionResponse].tx + wallet.signPsbt(new Psbt(unsignedTx), unsignedTx.txIn.indices, Nil).pipeTo(sender.ref) + val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get + wallet.publishTransaction(signedTx).pipeTo(sender.ref) + sender.expectMsg(signedTx.txid) + signedTx + } + assert(tx1.txIn.map(_.outPoint.txid).toSet == Set(externalTxId)) + + // We now have an unconfirmed utxo, which we can spend if we allow spending unconfirmed transactions. + val tx2 = { + val txNotFunded = Transaction(2, Nil, Seq(TxOut(100_000 sat, pubkeyScript)), 0) + wallet.fundTransaction(txNotFunded, FeeratePerKw(1_000 sat), minInputConfirmations_opt = Some(1)).pipeTo(sender.ref) + assert(sender.expectMsgType[Failure].cause.getMessage.contains("Insufficient funds")) + wallet.fundTransaction(txNotFunded, FeeratePerKw(1_000 sat), minInputConfirmations_opt = None).pipeTo(sender.ref) + val unsignedTx = sender.expectMsgType[FundTransactionResponse].tx + wallet.signPsbt(new Psbt(unsignedTx), unsignedTx.txIn.indices, Nil).pipeTo(sender.ref) + val signedTx = sender.expectMsgType[ProcessPsbtResponse].finalTx_opt.toOption.get + wallet.publishTransaction(signedTx).pipeTo(sender.ref) + sender.expectMsg(signedTx.txid) + signedTx + } + assert(tx2.txIn.map(_.outPoint.txid).toSet == Set(tx1.txid)) + } + test("fund transactions with external inputs") { import fr.acinq.bitcoin.scalacompat.KotlinUtils._