Skip to content

Commit c9341fc

Browse files
authored
Stricter requirements on input details for signing (#3052)
We verify that details about all inputs are provided to the `sign` function. While this isn't mandatory for segwit v0, it ensures that all of our existing tests exercise this codepath and reduces the risk that we forget to provide some wallet inputs, which would result in an invalid signature which would be hard to investigate. With this change, some of the unit tests started failing, which showed that we weren't correctly setting wallet inputs in the fee-bumping case in `ReplaceableTxFunder`, which we've fixed. We also add a test in `TransactionsSpec.scala` to verify that signing fails when details about some inputs are missing.
1 parent 5dcf2b2 commit c9341fc

File tree

8 files changed

+88
-100
lines changed

8 files changed

+88
-100
lines changed

eclair-core/src/main/scala/fr/acinq/eclair/blockchain/bitcoind/rpc/BitcoinCoreClient.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient, val lockUtxos: Bool
305305
})
306306
}
307307

308-
def utxoUpdatePsbt(psbt: Psbt)(implicit ec: ExecutionContext): Future[Psbt] = {
308+
private def utxoUpdatePsbt(psbt: Psbt)(implicit ec: ExecutionContext): Future[Psbt] = {
309309
val encoded = Base64.getEncoder.encodeToString(Psbt.write(psbt).toByteArray)
310310
rpcClient.invoke("utxoupdatepsbt", encoded).map(json => {
311311
val JString(base64) = json

eclair-core/src/main/scala/fr/acinq/eclair/channel/fund/InteractiveTxBuilder.scala

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -105,18 +105,15 @@ object InteractiveTxBuilder {
105105
// @formatter:off
106106
def info: InputInfo
107107
def weight: Int
108-
// we don't need to provide extra inputs here, as this method will only be called for multisig-2-of-2 inputs which only require the output that is spent for signing
109-
// for taproot channels, we'll use a musig2 input that is not signed like this: instead, the signature will be the Musig2 aggregation if a local and remote partial signature
110-
def sign(keyManager: ChannelKeyManager, params: ChannelParams, tx: Transaction): ByteVector64
111108
// @formatter:on
112109
}
113110

114111
case class Multisig2of2Input(info: InputInfo, fundingTxIndex: Long, remoteFundingPubkey: PublicKey) extends SharedFundingInput {
115112
override val weight: Int = 388
116113

117-
override def sign(keyManager: ChannelKeyManager, params: ChannelParams, tx: Transaction): ByteVector64 = {
114+
def sign(keyManager: ChannelKeyManager, params: ChannelParams, tx: Transaction, spentUtxos: Map[OutPoint, TxOut]): ByteVector64 = {
118115
val localFundingPubkey = keyManager.fundingPublicKey(params.localParams.fundingKeyPath, fundingTxIndex)
119-
keyManager.sign(Transactions.SpliceTx(info, tx), localFundingPubkey, TxOwner.Local, params.commitmentFormat, Map.empty)
116+
keyManager.sign(Transactions.SpliceTx(info, tx), localFundingPubkey, TxOwner.Local, params.commitmentFormat, spentUtxos)
120117
}
121118
}
122119

@@ -339,6 +336,8 @@ object InteractiveTxBuilder {
339336
val remoteFees: MilliSatoshi = remoteAmountIn - remoteAmountOut
340337
// Note that the truncation is a no-op: sub-satoshi balances are carried over from inputs to outputs and cancel out.
341338
val fees: Satoshi = (localFees + remoteFees).truncateToSatoshi
339+
// When signing transactions that include taproot inputs, we must provide details about all of the transaction's inputs.
340+
val inputDetails: Map[OutPoint, TxOut] = (sharedInput_opt.toSeq.map(i => i.outPoint -> i.txOut) ++ localInputs.map(i => i.outPoint -> i.txOut) ++ remoteInputs.map(i => i.outPoint -> i.txOut)).toMap
342341

343342
def localOnlyNonChangeOutputs: List[Output.Local.NonChange] = localOutputs.collect { case o: Local.NonChange => o }
344343

@@ -914,7 +913,9 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
914913
import fr.acinq.bitcoin.scalacompat.KotlinUtils._
915914

916915
val tx = unsignedTx.buildUnsignedTx()
917-
val sharedSig_opt = fundingParams.sharedInput_opt.map(_.sign(keyManager, channelParams, tx))
916+
val sharedSig_opt = fundingParams.sharedInput_opt.collect {
917+
case i: Multisig2of2Input => i.sign(keyManager, channelParams, tx, unsignedTx.inputDetails)
918+
}
918919
if (unsignedTx.localInputs.isEmpty) {
919920
context.self ! SignTransactionResult(PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, tx, Nil, sharedSig_opt)))
920921
} else {

eclair-core/src/main/scala/fr/acinq/eclair/channel/publish/ReplaceableTxFunder.scala

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
274274
Behaviors.stopped
275275
case AdjustPreviousTxOutputResult.TxOutputAdjusted(updatedTx) =>
276276
log.debug("bumping {} fees without adding new inputs: txid={}", cmd.desc, updatedTx.txInfo.tx.txid)
277-
sign(updatedTx, targetFeerate, previousTx.totalAmountIn, Map.empty)
277+
sign(updatedTx, targetFeerate, previousTx.totalAmountIn, previousTx.walletInputs)
278278
case AdjustPreviousTxOutputResult.AddWalletInputs(tx) =>
279279
log.debug("bumping {} fees requires adding new inputs (feerate={})", cmd.desc, targetFeerate)
280280
// We restore the original transaction (remove previous attempt's wallet inputs).
@@ -426,32 +426,23 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
426426
}
427427
}
428428

429-
private def addInputs(tx: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw, commitment: FullCommitment): Future[(ReplaceableTxWithWalletInputs, Satoshi, Map[OutPoint, TxOut])] = {
430-
431-
def getWalletUtxos(txInfo: TransactionWithInputInfo): Future[Map[OutPoint, TxOut]] = {
432-
val inputs = txInfo.tx.txIn.filterNot(_.outPoint == txInfo.input.outPoint)
433-
val txids = inputs.map(_.outPoint.txid).toSet
434-
for {
435-
txs <- Future.sequence(txids.toSeq.map(bitcoinClient.getTransaction))
436-
txMap = txs.map(tx => tx.txid -> tx).toMap
437-
} yield {
438-
inputs.map(_.outPoint).map(outPoint => {
439-
require(txMap.contains(outPoint.txid) && txMap(outPoint.txid).txOut.size >= outPoint.index, s"missing wallet input $outPoint")
440-
outPoint -> txMap(outPoint.txid).txOut(outPoint.index.toInt)
441-
}).toMap
429+
private def getWalletUtxos(txInfo: TransactionWithInputInfo): Future[Map[OutPoint, TxOut]] = {
430+
Future.sequence(txInfo.tx.txIn.filter(_.outPoint != txInfo.input.outPoint).map(txIn => {
431+
bitcoinClient.getTransaction(txIn.outPoint.txid).flatMap {
432+
case inputTx if inputTx.txOut.size <= txIn.outPoint.index => Future.failed(new IllegalArgumentException(s"input ${inputTx.txid}:${txIn.outPoint.index} doesn't exist"))
433+
case inputTx => Future.successful(txIn.outPoint -> inputTx.txOut(txIn.outPoint.index.toInt))
442434
}
443-
}
435+
})).map(_.toMap)
436+
}
444437

445-
tx match {
446-
case anchorTx: ClaimLocalAnchorWithWitnessData => for {
447-
(fundedTx, amountIn) <- addInputs(anchorTx, targetFeerate, commitment)
448-
spentUtxos <- getWalletUtxos(fundedTx.txInfo)
449-
} yield (fundedTx, amountIn, spentUtxos)
450-
case htlcTx: HtlcWithWitnessData => for {
451-
(fundedTx, amountIn) <- addInputs(htlcTx, targetFeerate, commitment)
452-
spentUtxos <- getWalletUtxos(fundedTx.txInfo)
453-
} yield (fundedTx, amountIn, spentUtxos)
454-
}
438+
private def addInputs(tx: ReplaceableTxWithWalletInputs, targetFeerate: FeeratePerKw, commitment: FullCommitment): Future[(ReplaceableTxWithWalletInputs, Satoshi, Map[OutPoint, TxOut])] = {
439+
for {
440+
(fundedTx, amountIn) <- tx match {
441+
case anchorTx: ClaimLocalAnchorWithWitnessData => addInputs(anchorTx, targetFeerate, commitment)
442+
case htlcTx: HtlcWithWitnessData => addInputs(htlcTx, targetFeerate, commitment)
443+
}
444+
spentUtxos <- getWalletUtxos(fundedTx.txInfo)
445+
} yield (fundedTx, amountIn, spentUtxos)
455446
}
456447

457448
private def addInputs(anchorTx: ClaimLocalAnchorWithWitnessData, targetFeerate: FeeratePerKw, commitment: FullCommitment): Future[(ClaimLocalAnchorWithWitnessData, Satoshi)] = {

eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/ChannelKeyManager.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ trait ChannelKeyManager {
6464
* @param publicKey extended public key
6565
* @param txOwner owner of the transaction (local/remote)
6666
* @param commitmentFormat format of the commitment tx
67-
* @param extraUtxos extra outputs spent by this transaction (in addition to our [[fr.acinq.eclair.transactions.Transactions.InputInfo]] output, which is assumed to always be the first spent output)
67+
* @param extraUtxos extra outputs spent by this transaction (in addition to [[fr.acinq.eclair.transactions.Transactions.InputInfo]])
6868
* @return a signature generated with the private key that matches the input extended public key
6969
*/
7070
def sign(tx: TransactionWithInputInfo, publicKey: ExtendedPublicKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat, extraUtxos: Map[OutPoint, TxOut]): ByteVector64
@@ -78,7 +78,7 @@ trait ChannelKeyManager {
7878
* @param remotePoint remote point
7979
* @param txOwner owner of the transaction (local/remote)
8080
* @param commitmentFormat format of the commitment tx
81-
* @param extraUtxos extra outputs spent by this transaction (in addition to our [[fr.acinq.eclair.transactions.Transactions.InputInfo]] output, which is assumed to always be the first spent output)
81+
* @param extraUtxos extra outputs spent by this transaction (in addition to [[fr.acinq.eclair.transactions.Transactions.InputInfo]])
8282
* @return a signature generated with a private key generated from the input key's matching private key and the remote point.
8383
*/
8484
def sign(tx: TransactionWithInputInfo, publicKey: ExtendedPublicKey, remotePoint: PublicKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat, extraUtxos: Map[OutPoint, TxOut]): ByteVector64
@@ -91,7 +91,7 @@ trait ChannelKeyManager {
9191
* @param remoteSecret remote secret
9292
* @param txOwner owner of the transaction (local/remote)
9393
* @param commitmentFormat format of the commitment tx
94-
* @param extraUtxos extra outputs spent by this transaction (in addition to our [[fr.acinq.eclair.transactions.Transactions.InputInfo]] output, which is assumed to always be the first spent output)
94+
* @param extraUtxos extra outputs spent by this transaction (in addition to [[fr.acinq.eclair.transactions.Transactions.InputInfo]])
9595
* @return a signature generated with a private key generated from the input key's matching private key and the remote secret.
9696
*/
9797
def sign(tx: TransactionWithInputInfo, publicKey: ExtendedPublicKey, remoteSecret: PrivateKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat, extraUtxos: Map[OutPoint, TxOut]): ByteVector64

eclair-core/src/main/scala/fr/acinq/eclair/crypto/keymanager/LocalChannelKeyManager.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ class LocalChannelKeyManager(seed: ByteVector, chainHash: BlockHash) extends Cha
104104
* @param publicKey extended public key
105105
* @param txOwner owner of the transaction (local/remote)
106106
* @param commitmentFormat format of the commitment tx
107-
* @param extraUtxos extra outputs spent by this transaction (in addition to our [[fr.acinq.eclair.transactions.Transactions.InputInfo]] output, which is assumed to always be the first spent output)
107+
* @param extraUtxos extra outputs spent by this transaction (in addition to [[fr.acinq.eclair.transactions.Transactions.InputInfo]])
108108
* @return a signature generated with the private key that matches the input extended public key
109109
*/
110110
override def sign(tx: TransactionWithInputInfo, publicKey: ExtendedPublicKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat, extraUtxos: Map[OutPoint, TxOut]): ByteVector64 = {
@@ -125,7 +125,7 @@ class LocalChannelKeyManager(seed: ByteVector, chainHash: BlockHash) extends Cha
125125
* @param remotePoint remote point
126126
* @param txOwner owner of the transaction (local/remote)
127127
* @param commitmentFormat format of the commitment tx
128-
* @param extraUtxos extra outputs spent by this transaction (in addition to our [[fr.acinq.eclair.transactions.Transactions.InputInfo]] output, which is assumed to always be the first spent output)
128+
* @param extraUtxos extra outputs spent by this transaction (in addition to [[fr.acinq.eclair.transactions.Transactions.InputInfo]])
129129
* @return a signature generated with a private key generated from the input key's matching private key and the remote point.
130130
*/
131131
override def sign(tx: TransactionWithInputInfo, publicKey: ExtendedPublicKey, remotePoint: PublicKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat, extraUtxos: Map[OutPoint, TxOut]): ByteVector64 = {
@@ -147,7 +147,7 @@ class LocalChannelKeyManager(seed: ByteVector, chainHash: BlockHash) extends Cha
147147
* @param remoteSecret remote secret
148148
* @param txOwner owner of the transaction (local/remote)
149149
* @param commitmentFormat format of the commitment tx
150-
* @param extraUtxos extra outputs spent by this transaction (in addition to our [[fr.acinq.eclair.transactions.Transactions.InputInfo]] output, which is assumed to always be the first spent output)
150+
* @param extraUtxos extra outputs spent by this transaction (in addition to [[fr.acinq.eclair.transactions.Transactions.InputInfo]])
151151
* @return a signature generated with a private key generated from the input key's matching private key and the remote secret.
152152
*/
153153
override def sign(tx: TransactionWithInputInfo, publicKey: ExtendedPublicKey, remoteSecret: PrivateKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat, extraUtxos: Map[OutPoint, TxOut]): ByteVector64 = {

eclair-core/src/main/scala/fr/acinq/eclair/transactions/Transactions.scala

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,18 +131,31 @@ object Transactions {
131131
/** Sighash flags to use when signing the transaction. */
132132
def sighash(txOwner: TxOwner, commitmentFormat: CommitmentFormat): Int = SIGHASH_ALL
133133

134+
/**
135+
* @param extraUtxos extra outputs spent by this transaction (in addition to the main [[input]]).
136+
*/
134137
def sign(key: PrivateKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat, extraUtxos: Map[OutPoint, TxOut]): ByteVector64 = {
135138
sign(key, sighash(txOwner, commitmentFormat), extraUtxos)
136139
}
137140

138-
def sign(key: PrivateKey, sighashType: Int, extraUtxos: Map[OutPoint, TxOut]): ByteVector64 = input match {
139-
case _: InputInfo.TaprootInput => ByteVector64.Zeroes
140-
case InputInfo.SegwitInput(outPoint, txOut, redeemScript) =>
141-
// NB: the tx may have multiple inputs, we will only sign the one provided in txinfo.input. Bear in mind that the
142-
// signature will be invalidated if other inputs are added *afterwards* and sighashType was SIGHASH_ALL.
143-
val inputIndex = tx.txIn.indexWhere(_.outPoint == outPoint)
144-
val sigDER = Transaction.signInput(tx, inputIndex, redeemScript, sighashType, txOut.amount, SIGVERSION_WITNESS_V0, key)
145-
Crypto.der2compact(sigDER)
141+
def sign(key: PrivateKey, sighashType: Int, extraUtxos: Map[OutPoint, TxOut]): ByteVector64 = {
142+
val inputsMap = extraUtxos + (input.outPoint -> input.txOut)
143+
tx.txIn.foreach(txIn => {
144+
// Note that using a require here is dangerous, because callers don't except this function to throw.
145+
// But we want to ensure that we're correctly providing input details, otherwise our signature will silently be
146+
// invalid when using taproot. We verify this in all cases, even when using segwit v0, to ensure that we have as
147+
// many tests as possible that exercise this codepath.
148+
require(inputsMap.contains(txIn.outPoint), s"cannot sign $desc with txId=${tx.txid}: missing input details for ${txIn.outPoint}")
149+
})
150+
input match {
151+
case InputInfo.SegwitInput(outPoint, txOut, redeemScript) =>
152+
// NB: the tx may have multiple inputs, we will only sign the one provided in txinfo.input. Bear in mind that the
153+
// signature will be invalidated if other inputs are added *afterwards* and sighashType was SIGHASH_ALL.
154+
val inputIndex = tx.txIn.indexWhere(_.outPoint == outPoint)
155+
val sigDER = Transaction.signInput(tx, inputIndex, redeemScript, sighashType, txOut.amount, SIGVERSION_WITNESS_V0, key)
156+
Crypto.der2compact(sigDER)
157+
case _: InputInfo.TaprootInput => ???
158+
}
146159
}
147160

148161
def checkSig(sig: ByteVector64, pubKey: PublicKey, txOwner: TxOwner, commitmentFormat: CommitmentFormat): Boolean = input match {

0 commit comments

Comments
 (0)