Skip to content

Commit

Permalink
Use PSBT to fund and sign transactions
Browse files Browse the repository at this point in the history
  • Loading branch information
sstone committed Nov 16, 2022
1 parent 1e252e5 commit 7b262ba
Show file tree
Hide file tree
Showing 6 changed files with 231 additions and 42 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@

package fr.acinq.eclair.blockchain

import fr.acinq.bitcoin.psbt.Psbt
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, Transaction}
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.ProcessPsbtResponse
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import scodec.bits.ByteVector

Expand All @@ -38,7 +40,9 @@ trait OnChainChannelFunder {
/** Sign the wallet inputs of the provided transaction. */
def signTransaction(tx: Transaction, allowIncomplete: Boolean)(implicit ec: ExecutionContext): Future[SignTransactionResponse]

/**
def signPsbt(psbt: Psbt)(implicit ec: ExecutionContext): Future[ProcessPsbtResponse]

/**
* Publish a transaction on the bitcoin network.
* This method must be idempotent: if the tx was already published, it must return a success.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,27 @@

package fr.acinq.eclair.blockchain.bitcoind.rpc

import fr.acinq.bitcoin.psbt.{KeyPathWithMaster, Psbt, UpdateFailure}
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
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, SeqHasAsJava}
import scala.util.{Failure, Success, Try}

/**
Expand Down Expand Up @@ -224,7 +227,74 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall
fundTransaction(tx, FundTransactionOptions(feeRate, replaceable, lockUtxos))
}

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}"))
val encoded = Base64.getEncoder.encodeToString(Psbt.write(psbt).toByteArray)
rpcClient.invoke("walletprocesspsbt", encoded, 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)
})
}

def utxoUpdatePsbt(psbt: Psbt)(implicit ec: ExecutionContext): Future[Psbt] = {
val encoded = Base64.getEncoder.encodeToString(Psbt.write(psbt).toByteArray)

rpcClient.invoke("utxoupdatepsbt", encoded).map(json => {
val JString(base64) = json
val bin = Base64.getDecoder.decode(base64)
val decoded = Psbt.read(bin)
require(decoded.isRight, s"cannot decode psbt from $base64")
val psbt = decoded.getRight
psbt
})
}

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

def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, targetFeerate: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = {
import KotlinUtils._
val partialFundingTx = Transaction(
version = 2,
txIn = Seq.empty[TxIn],
Expand All @@ -235,7 +305,10 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall
// we ask bitcoin core to add inputs to the funding tx, and use the specified change address
fundTxResponse <- fundTransaction(partialFundingTx, FundTransactionOptions(feerate, lockUtxos = true))
// now let's sign the funding tx
SignTransactionResponse(fundingTx, true) <- signTransactionOrUnlock(fundTxResponse.tx)
psbt = new Psbt(fundTxResponse.tx)
updatedPsbt <- utxoUpdatePsbt(psbt)
ProcessPsbtResponse(signedPsbt, true) <- signPsbtOrUnlock(updatedPsbt)
fundingTx = signedPsbt.extract().getRight
// there will probably be a change output, so we need to find which output is ours
outputIndex <- Transactions.findPubKeyScriptIndex(fundingTx, pubkeyScript) match {
case Right(outputIndex) => Future.successful(outputIndex)
Expand All @@ -257,6 +330,10 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall

//------------------------- SIGNING -------------------------//

def signPsbt(psbt: Psbt)(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = {
utxoUpdatePsbt(psbt).flatMap(p => processPsbt(p))
}

def signTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[SignTransactionResponse] = signTransaction(tx, Nil)

def signTransaction(tx: Transaction, allowIncomplete: Boolean)(implicit ec: ExecutionContext): Future[SignTransactionResponse] = signTransaction(tx, Nil, allowIncomplete)
Expand Down Expand Up @@ -493,6 +570,50 @@ object BitcoinCoreClient {
}
}

case class FundPsbtInput(txid: ByteVector32, vout: Int, sequence: Option[Long] = None, weight: Option[Int] = None)

object FundPsbtInput {
def apply(outPoint: OutPoint, sequence: Option[Long], weight: Option[Int]): FundPsbtInput = FundPsbtInput(outPoint.txid, outPoint.index.toInt, sequence, weight)
}

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 tx: Transaction = KotlinUtils.kmp2scala(psbt.getGlobal.getTx)
val amountIn: Satoshi = fee + tx.txOut.map(_.amount).sum
}

case class ProcessPsbtResponse(psbt: Psbt, complete: Boolean) {

// this can only work if the psbt if fully signed
def extractFinalTx: Either[UpdateFailure, Transaction] = {
val extracted = psbt.extract()
if (extracted.isLeft) Left(extracted.getLeft) else Right(KotlinUtils.kmp2scala(extracted.getRight))
}

// use if you're not expecting the psbt to be fully signed
def extractPartiallySignedTx: Transaction = {
import KotlinUtils._

var partiallySignedTx: Transaction = psbt.getGlobal.getTx
for (i <- 0 until psbt.getInputs.size()) {
val scriptWitness = psbt.getInputs.get(i).getScriptWitness
if (scriptWitness != null) {
partiallySignedTx = partiallySignedTx.updateWitness(i, scriptWitness)
}
}
partiallySignedTx
}

def finalTx = extractFinalTx.getOrElse(throw new RuntimeException("cannot extract transaction from psbt"))
}

case class PreviousTx(txid: ByteVector32, vout: Long, scriptPubKey: String, redeemScript: String, witnessScript: String, amount: BigDecimal)

object PreviousTx {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ package fr.acinq.eclair.channel
import akka.actor.typed.scaladsl.{ActorContext, Behaviors, StashBuffer}
import akka.actor.typed.{ActorRef, Behavior}
import fr.acinq.bitcoin.ScriptFlags
import fr.acinq.bitcoin.psbt.{Global, Psbt}
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{ByteVector32, LexicographicalOrdering, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxIn, TxOut}
import fr.acinq.eclair.blockchain.OnChainChannelFunder
import fr.acinq.eclair.blockchain.OnChainWallet.SignTransactionResponse
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.ProcessPsbtResponse
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel.Helpers.Funding
import fr.acinq.eclair.crypto.ShaChain
Expand Down Expand Up @@ -812,15 +813,18 @@ private class InteractiveTxBuilder(replyTo: ActorRef[InteractiveTxBuilder.Respon
}

private def signTx(unsignedTx: SharedTransaction, remoteSigs_opt: Option[TxSignatures]): Unit = {
import fr.acinq.bitcoin.scalacompat.KotlinUtils._

val tx = unsignedTx.buildUnsignedTx()
if (unsignedTx.localInputs.isEmpty) {
context.self ! SignTransactionResult(PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, tx, Nil)), remoteSigs_opt)
} else {
context.pipeToSelf(wallet.signTransaction(tx, allowIncomplete = true).map {
case SignTransactionResponse(signedTx, _) =>
context.pipeToSelf(wallet.signPsbt(new Psbt(tx)).map {
case ppr@ProcessPsbtResponse(signedTx, _) =>
val localOutpoints = unsignedTx.localInputs.map(toOutPoint).toSet
val sigs = signedTx.txIn.filter(txIn => localOutpoints.contains(txIn.outPoint)).map(_.witness)
PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, tx, sigs))
val partiallySignedTx = ppr.extractPartiallySignedTx
val sigs = partiallySignedTx.txIn.filter(txIn => localOutpoints.contains(txIn.outPoint)).map(_.witness)
PartiallySignedSharedTransaction(unsignedTx, TxSignatures(fundingParams.channelId, partiallySignedTx, sigs))
}) {
case Failure(t) => WalletFailure(t)
case Success(signedTx) => SignTransactionResult(signedTx, remoteSigs_opt)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ 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, OutPoint, Satoshi, Script, Transaction, TxOut, computeBIP44Address, computeBIP84Address}
import fr.acinq.bitcoin.utils.EitherKt
import fr.acinq.eclair.NotificationsLogger.NotifyNodeOperator
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{FundTransactionOptions, InputWeight}
import fr.acinq.eclair.blockchain.bitcoind.rpc.BitcoinCoreClient.{FundPsbtInput, FundPsbtOptions, FundTransactionOptions, InputWeight}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel.Commitments
import fr.acinq.eclair.channel.publish.ReplaceableTxPrePublisher._
Expand Down Expand Up @@ -314,10 +316,19 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
}

def signWalletInputs(locallySignedTx: ReplaceableTxWithWalletInputs, txFeerate: FeeratePerKw, amountIn: Satoshi): Behavior[Command] = {
val inputInfo = BitcoinCoreClient.PreviousTx(locallySignedTx.txInfo.input, locallySignedTx.txInfo.tx.txIn.head.witness)
context.pipeToSelf(bitcoinClient.signTransaction(locallySignedTx.txInfo.tx, Seq(inputInfo))) {
case Success(signedTx) => SignWalletInputsOk(signedTx.tx)
case Failure(reason) => SignWalletInputsFailed(reason)
import fr.acinq.bitcoin.scalacompat.KotlinUtils._

val psbt = new Psbt(locallySignedTx.txInfo.tx)
val updated = psbt.updateWitnessInput(locallySignedTx.txInfo.input.outPoint, locallySignedTx.txInfo.input.txOut, null, fr.acinq.bitcoin.Script.parse(locallySignedTx.txInfo.input.redeemScript), null, java.util.Map.of())
val signed = EitherKt.flatMap(updated, (psbt: Psbt) => psbt.finalizeWitnessInput(0, locallySignedTx.txInfo.tx.txIn.head.witness))
val psbt1 = signed.getRight
val f = bitcoinClient.utxoUpdatePsbt(psbt1).flatMap(p => bitcoinClient.processPsbt(p))
context.pipeToSelf(f) {
case Success(processPsbtResponse) =>
val signedTx = processPsbtResponse.finalTx
SignWalletInputsOk(signedTx)
case Failure(reason) =>
SignWalletInputsFailed(reason)
}
Behaviors.receiveMessagePartial {
case SignWalletInputsOk(signedTx) =>
Expand Down Expand Up @@ -352,6 +363,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 commitTx = dummySignedCommitTx(commitments).tx
// NB: fundrawtransaction requires at least one output, and may add at most one additional change output.
Expand All @@ -368,8 +381,11 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
val changeOutput = fundTxResponse.tx.txOut(changePos)
val txSingleOutput = fundTxResponse.tx.copy(txOut = Seq(changeOutput))
// We ask bitcoind to sign the wallet inputs to learn their final weight and adjust the change amount.
bitcoinClient.signTransaction(txSingleOutput, allowIncomplete = true).map(signTxResponse => {
val dummySignedTx = addSigs(anchorTx.updateTx(signTxResponse.tx).txInfo, PlaceHolderSig)
val psbt = new Psbt(txSingleOutput)
bitcoinClient.utxoUpdatePsbt(psbt).flatMap(p => bitcoinClient.processPsbt(p)).map(processPsbtResponse => {
// we cannot extract the final tx from the psbt because it is not fully signed yet
val partiallySignedTx = processPsbtResponse.extractPartiallySignedTx
val dummySignedTx = addSigs(anchorTx.updateTx(partiallySignedTx).txInfo, PlaceHolderSig)
val packageWeight = commitTx.weight() + dummySignedTx.tx.weight()
val anchorTxFee = weight2fee(targetFeerate, packageWeight) - weight2fee(commitments.localCommit.spec.commitTxFeerate, commitTx.weight())
val changeAmount = dustLimit.max(fundTxResponse.amountIn - anchorTxFee)
Expand All @@ -395,5 +411,4 @@ private class ReplaceableTxFunder(nodeParams: NodeParams,
(unsignedTx, fundTxResponse.amountIn)
})
}

}
Loading

0 comments on commit 7b262ba

Please sign in to comment.