Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use psbt to fund and sign transactions #1876

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eclair-core/src/main/scala/fr/acinq/eclair/Setup.scala
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ class Setup(val datadir: File,
})
_ <- feeratesRetrieved.future

bitcoinClient = new BitcoinCoreClient(new BatchingBitcoinJsonRPCClient(bitcoin))
bitcoinClient = new BitcoinCoreClient(nodeParams.chainHash, new BatchingBitcoinJsonRPCClient(bitcoin))
watcher = {
system.actorOf(SimpleSupervisor.props(Props(new ZMQActor(config.getString("bitcoind.zmqblock"), ZMQActor.Topics.HashBlock, Some(zmqBlockConnected))), "zmqblock", SupervisorStrategy.Restart))
system.actorOf(SimpleSupervisor.props(Props(new ZMQActor(config.getString("bitcoind.zmqtx"), ZMQActor.Topics.RawTx, Some(zmqTxConnected))), "zmqtx", SupervisorStrategy.Restart))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
package fr.acinq.eclair.blockchain

import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin.{Satoshi, Transaction}
import fr.acinq.bitcoin.DeterministicWallet.ExtendedPublicKey
import fr.acinq.bitcoin.{Psbt, Satoshi, Transaction}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import scodec.bits.ByteVector

Expand All @@ -33,7 +34,7 @@ trait OnChainChannelFunder {
import OnChainWallet.MakeFundingTxResponse

/** Create a channel funding transaction with the provided pubkeyScript. */
def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse]
def makeFundingTx(localFundingKey: ExtendedPublicKey, remoteFundingKey: PublicKey, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse]

/**
* Committing *must* include publishing the transaction on the network.
Expand Down Expand Up @@ -95,6 +96,6 @@ 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)

}
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
package fr.acinq.eclair.blockchain.bitcoind.rpc

import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin.DeterministicWallet.ExtendedPublicKey
import fr.acinq.bitcoin._
import fr.acinq.eclair.ShortChannelId.coordinates
import fr.acinq.eclair.TxCoordinates
import fr.acinq.eclair.blockchain.OnChainWallet
import fr.acinq.eclair.blockchain.OnChainWallet.{MakeFundingTxResponse, OnChainBalance}
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 grizzled.slf4j.Logging
import org.json4s.Formats
Expand All @@ -41,7 +42,7 @@ import scala.util.{Failure, Success, Try}
/**
* The Bitcoin Core client provides some high-level utility methods to interact with Bitcoin Core.
*/
class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWallet with Logging {
class BitcoinCoreClient(val chainHash: ByteVector32, val rpcClient: BitcoinJsonRPCClient) extends OnChainWallet with Logging {

import BitcoinCoreClient._

Expand Down Expand Up @@ -180,25 +181,102 @@ class BitcoinCoreClient(val rpcClient: BitcoinJsonRPCClient) extends OnChainWall
})
}

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 psbt = Psbt.fromBase64(base64).get
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_ALL)(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = {
val sighashStrings = Map(
SIGHASH_ALL -> "ALL",
SIGHASH_NONE -> "NONE",
SIGHASH_SINGLE -> "SINGLE",
(SIGHASH_ALL | SIGHASH_ANYONECANPAY) -> "ALL|ANYONECANPAY",
(SIGHASH_NONE | SIGHASH_ANYONECANPAY) -> "NONE|ANYONECANPAY",
(SIGHASH_SINGLE | SIGHASH_ANYONECANPAY) -> "SINGLE|ANYONECANPAY")
val sighash = sighashStrings.getOrElse(sighashType, throw new IllegalArgumentException(s"invalid sighash flag ${sighashType}"))
rpcClient.invoke("walletprocesspsbt", Psbt.toBase64(psbt), sign, sighash).map(json => {
val JString(base64) = json \ "psbt"
val JBool(complete) = json \ "complete"
val psbt = Psbt.fromBase64(base64).get
ProcessPsbtResponse(psbt, complete)
})
}

private def signPsbtOrUnlock(psbt: Psbt)(implicit ec: ExecutionContext): Future[ProcessPsbtResponse] = {
val f1 = processPsbt(psbt).withFilter(_.complete == true)

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.global.tx.txIn.map(_.outPoint))
.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.global.tx.txid}", t)
t
}
.flatMap(_ => f) // return signTransaction error
.recoverWith { case _ => f } // return signTransaction error
}
}

override def makeFundingTx(localFundingKey: ExtendedPublicKey, remoteFundingKey: PublicKey, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = {
val hrp = chainHash match {
case Block.RegtestGenesisBlock.hash => "bcrt"
case Block.TestnetGenesisBlock.hash => "tb"
case Block.LivenetGenesisBlock.hash => "bc"
case _ => return Future.failed(new IllegalArgumentException(s"invalid chain hash ${chainHash}"))
}
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 fundingAddress = Script.parse(fundingPubkeyScript) match {
case OP_0 :: OP_PUSHDATA(data, _) :: Nil if data.size == 20 || data.size == 32 => Bech32.encodeWitnessAddress(hrp, 0, data)
case _ => return Future.failed(new IllegalArgumentException("invalid pubkey script"))
}

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
}
psbt.updateWitnessOutput(outputIndex, derivationPaths = Map(
localFundingKey.publicKey -> Psbt.KeyPathWithMaster(localFundingKey.parent, ourbip32path),
remoteFundingKey -> Psbt.KeyPathWithMaster(0L, DeterministicWallet.KeyPath("1/2/3/4"))
)
).get
}

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.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.isFailure) logger.error(s"psbt failure $extracted")
fundingTx = extracted.get
// 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(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 {
Expand Down Expand Up @@ -437,6 +515,20 @@ object BitcoinCoreClient {
val amountIn: Satoshi = fee + tx.txOut.map(_.amount).sum
}

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 = psbt.computeFees().get + psbt.global.tx.txOut.map(_.amount).sum
}

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

object PreviousTx {
Expand All @@ -452,6 +544,8 @@ object BitcoinCoreClient {

case class SignTransactionResponse(tx: Transaction, complete: Boolean)

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

/**
* Information about a transaction currently in the mempool.
*
Expand Down
10 changes: 5 additions & 5 deletions eclair-core/src/main/scala/fr/acinq/eclair/channel/Channel.scala
Original file line number Diff line number Diff line change
Expand Up @@ -413,8 +413,7 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo
shutdownScript = remoteShutdownScript)
log.debug("remote params: {}", remoteParams)
val localFundingPubkey = keyManager.fundingPublicKey(localParams.fundingKeyPath)
val fundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(localFundingPubkey.publicKey, remoteParams.fundingPubKey)))
wallet.makeFundingTx(fundingPubkeyScript, fundingSatoshis, fundingTxFeeratePerKw).pipeTo(self)
wallet.makeFundingTx(localFundingPubkey, remoteParams.fundingPubKey, fundingSatoshis, fundingTxFeeratePerKw).pipeTo(self)
goto(WAIT_FOR_FUNDING_INTERNAL) using DATA_WAIT_FOR_FUNDING_INTERNAL(temporaryChannelId, localParams, remoteParams, fundingSatoshis, pushMsat, initialFeeratePerKw, accept.firstPerCommitmentPoint, channelConfig, channelFeatures, open)
}

Expand All @@ -436,7 +435,8 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo
})

when(WAIT_FOR_FUNDING_INTERNAL)(handleExceptions {
case Event(MakeFundingTxResponse(fundingTx, fundingTxOutputIndex, fundingTxFee), d@DATA_WAIT_FOR_FUNDING_INTERNAL(temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, initialFeeratePerKw, remoteFirstPerCommitmentPoint, channelConfig, channelFeatures, open)) =>
case Event(MakeFundingTxResponse(psbt, fundingTxOutputIndex, fundingTxFee), d@DATA_WAIT_FOR_FUNDING_INTERNAL(temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, initialFeeratePerKw, remoteFirstPerCommitmentPoint, channelConfig, channelFeatures, open)) =>
val fundingTx = psbt.extract().get
// let's create the first commitment tx that spends the yet uncommitted funding tx
Funding.makeFirstCommitTxs(keyManager, channelConfig, channelFeatures, temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, initialFeeratePerKw, fundingTx.hash, fundingTxOutputIndex, remoteFirstPerCommitmentPoint) match {
case Left(ex) => handleLocalError(ex, d, None)
Expand Down Expand Up @@ -1574,10 +1574,10 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, remo
log.info("shutting down")
stop(FSM.Normal)

case Event(MakeFundingTxResponse(fundingTx, _, _), _) =>
case Event(MakeFundingTxResponse(psbt, _, _), _) =>
// 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)
wallet.rollback(psbt.extract().get)
stay()

case Event(INPUT_DISCONNECTED, _) => stay() // we are disconnected, but it doesn't matter anymore
Expand Down
Loading