1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-03-12 10:30:45 +01:00

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`.
This commit is contained in:
Bastien Teinturier 2025-03-06 19:44:02 +01:00 committed by GitHub
parent 95bbf063c9
commit 7ef55a6abb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 70 additions and 26 deletions

View file

@ -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. *

View file

@ -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.

View file

@ -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))
}
}
})
}
}
}

View file

@ -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.

View file

@ -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.

View file

@ -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._