Skip to content

Commit

Permalink
Use psbt to fund transactions
Browse files Browse the repository at this point in the history
  • Loading branch information
sstone committed Jul 19, 2022
1 parent 3196462 commit fb5521f
Show file tree
Hide file tree
Showing 8 changed files with 367 additions and 121 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, UpdateFailure}
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat.{Satoshi, Transaction}
import fr.acinq.bitcoin.scalacompat.DeterministicWallet.ExtendedPublicKey
import fr.acinq.bitcoin.scalacompat.{ByteVector32, KotlinUtils, Satoshi, Transaction}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import scodec.bits.ByteVector

Expand All @@ -33,7 +35,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(chainHash: ByteVector32, 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 +97,10 @@ 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) {
def fundingTx(): Either[Throwable, Transaction] = {
val extracted = psbt.extract()
if (extracted.isLeft) Left(new RuntimeException(extracted.getLeft.toString)) else Right(KotlinUtils.kmp2scala(extracted.getRight))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,28 @@

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

import fr.acinq.bitcoin.psbt.{KeyPathWithMaster, Psbt}
import fr.acinq.bitcoin.scalacompat.Crypto.PublicKey
import fr.acinq.bitcoin.scalacompat._
import fr.acinq.bitcoin.{Bech32, Block}
import fr.acinq.bitcoin.{Bech32, Block, SigHash}
import fr.acinq.bitcoin.scalacompat.DeterministicWallet.ExtendedPublicKey
import fr.acinq.eclair.ShortChannelId.coordinates
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.bitcoind.rpc.BitcoinCoreClient.{FundPsbtInput, FundPsbtOptions, FundPsbtResponse, ProcessPsbtResponse}
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 @@ -220,25 +224,97 @@ 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 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}"))
rpcClient.invoke("walletprocesspsbt", Base64.getEncoder.encodeToString(Psbt.write(psbt).toByteArray), 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)
})
}

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

override def makeFundingTx(chainHash: ByteVector32, localFundingKey: ExtendedPublicKey, remoteFundingKey: PublicKey, amount: Satoshi, feeRatePerKw: FeeratePerKw)(implicit ec: ExecutionContext): Future[MakeFundingTxResponse] = {
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 Some(fundingAddress) = computeScriptAddress(chainHash, fundingPubkeyScript)

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
}
val updated = psbt.updateWitnessOutput(outputIndex, null, null, Map(
KotlinUtils.scala2kmp(localFundingKey.publicKey) -> new KeyPathWithMaster(localFundingKey.parent, DeterministicWallet.KeyPath(ourbip32path).keyPath),
KotlinUtils.scala2kmp(remoteFundingKey) -> new KeyPathWithMaster(0L, DeterministicWallet.KeyPath("1/2/3/4").keyPath)
).asJava
)
require(updated.isRight, s"cannot update psbt (${updated.getLeft.toString}")
updated.getRight
}

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.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.isLeft) logger.error(s"psbt failure $extracted")
fundingTx = extracted.getRight
// 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(KotlinUtils.kmp2scala(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 @@ -477,6 +553,22 @@ 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 = Option(psbt.computeFees()).map(KotlinUtils.kmp2scala).get + psbt.getGlobal.getTx.txOut.asScala.map(_.amount).map(KotlinUtils.kmp2scala).sum
}

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

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 @@ -21,7 +21,7 @@ import akka.actor.typed.scaladsl.adapter.{ClassicActorContextOps, actorRefAdapte
import akka.actor.{Actor, ActorContext, ActorRef, FSM, OneForOneStrategy, PossiblyHarmful, Props, SupervisorStrategy, typed}
import akka.event.Logging.MDC
import fr.acinq.bitcoin.scalacompat.Crypto.{PrivateKey, PublicKey}
import fr.acinq.bitcoin.scalacompat.{ByteVector32, Satoshi, SatoshiLong, Transaction}
import fr.acinq.bitcoin.scalacompat.{ByteVector32, KotlinUtils, Satoshi, SatoshiLong, Transaction}
import fr.acinq.eclair.Logs.LogCategory
import fr.acinq.eclair._
import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse
Expand Down Expand Up @@ -1243,10 +1243,14 @@ class Channel(val nodeParams: NodeParams, val wallet: OnChainChannelFunder, val
log.info("shutting down")
stop(FSM.Normal)

case Event(MakeFundingTxResponse(fundingTx, _, _), _) =>
// 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)
case Event(resp: MakeFundingTxResponse, _) =>
resp.fundingTx() match {
case Left(error) => log.error(s"Cannot extract funding tx from psbt (${error.toString}")
case Right(fundingTx) =>
// 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)
}
stay()

case Event(INPUT_DISCONNECTED, _) => stay() // we are disconnected, but it doesn't matter anymore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import akka.actor.Status
import akka.actor.typed.scaladsl.adapter.actorRefAdapter
import akka.pattern.pipe
import fr.acinq.bitcoin.ScriptFlags
import fr.acinq.bitcoin.scalacompat.{SatoshiLong, Script, Transaction}
import fr.acinq.bitcoin.scalacompat.{KotlinUtils, SatoshiLong, Script, Transaction}
import fr.acinq.eclair.blockchain.OnChainWallet.MakeFundingTxResponse
import fr.acinq.eclair.blockchain.bitcoind.ZmqWatcher._
import fr.acinq.eclair.channel.Helpers.{Funding, getRelayFees}
Expand Down Expand Up @@ -154,8 +154,7 @@ trait ChannelOpenSingleFunder extends FundingHandlers with ErrorHandlers {
log.debug("remote params: {}", remoteParams)
log.info("remote will use fundingMinDepth={}", accept.minimumDepth)
val localFundingPubkey = keyManager.fundingPublicKey(localParams.fundingKeyPath)
val fundingPubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(localFundingPubkey.publicKey, remoteParams.fundingPubKey)))
wallet.makeFundingTx(fundingPubkeyScript, fundingSatoshis, fundingTxFeerate).pipeTo(self)
wallet.makeFundingTx(nodeParams.chainHash, localFundingPubkey, remoteParams.fundingPubKey, fundingSatoshis, fundingTxFeerate).pipeTo(self)
goto(WAIT_FOR_FUNDING_INTERNAL) using DATA_WAIT_FOR_FUNDING_INTERNAL(temporaryChannelId, localParams, remoteParams, fundingSatoshis, pushMsat, commitTxFeerate, accept.firstPerCommitmentPoint, channelConfig, channelFeatures, open)
}

Expand All @@ -177,26 +176,30 @@ trait ChannelOpenSingleFunder extends FundingHandlers with ErrorHandlers {
})

when(WAIT_FOR_FUNDING_INTERNAL)(handleExceptions {
case Event(MakeFundingTxResponse(fundingTx, fundingTxOutputIndex, fundingTxFee), d@DATA_WAIT_FOR_FUNDING_INTERNAL(temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, commitTxFeerate, remoteFirstPerCommitmentPoint, channelConfig, channelFeatures, open)) =>
// let's create the first commitment tx that spends the yet uncommitted funding tx
Funding.makeFirstCommitTxs(keyManager, channelConfig, channelFeatures, temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, commitTxFeerate, fundingTx.hash, fundingTxOutputIndex, remoteFirstPerCommitmentPoint) match {
case Event(fundingTxResponse: MakeFundingTxResponse, d@DATA_WAIT_FOR_FUNDING_INTERNAL(temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, commitTxFeerate, remoteFirstPerCommitmentPoint, channelConfig, channelFeatures, open)) =>
fundingTxResponse.fundingTx() match {
case Left(ex) => handleLocalError(ex, d, None)
case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx)) =>
require(fundingTx.txOut(fundingTxOutputIndex).publicKeyScript == localCommitTx.input.txOut.publicKeyScript, s"pubkey script mismatch!")
val localSigOfRemoteTx = keyManager.sign(remoteCommitTx, keyManager.fundingPublicKey(localParams.fundingKeyPath), TxOwner.Remote, channelFeatures.commitmentFormat)
// signature of their initial commitment tx that pays remote pushMsat
val fundingCreated = FundingCreated(
temporaryChannelId = temporaryChannelId,
fundingTxid = fundingTx.hash,
fundingOutputIndex = fundingTxOutputIndex,
signature = localSigOfRemoteTx
)
val channelId = toLongId(fundingTx.hash, fundingTxOutputIndex)
peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages
txPublisher ! SetChannelId(remoteNodeId, channelId)
context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId))
// NB: we don't send a ChannelSignatureSent for the first commit
goto(WAIT_FOR_FUNDING_SIGNED) using DATA_WAIT_FOR_FUNDING_SIGNED(channelId, localParams, remoteParams, fundingTx, fundingTxFee, localSpec, localCommitTx, RemoteCommit(0, remoteSpec, remoteCommitTx.tx.txid, remoteFirstPerCommitmentPoint), open.channelFlags, channelConfig, channelFeatures, fundingCreated) sending fundingCreated
case Right(fundingTx) =>
// let's create the first commitment tx that spends the yet uncommitted funding tx
Funding.makeFirstCommitTxs(keyManager, channelConfig, channelFeatures, temporaryChannelId, localParams, remoteParams, fundingAmount, pushMsat, commitTxFeerate, fundingTx.hash, fundingTxResponse.fundingTxOutputIndex, remoteFirstPerCommitmentPoint) match {
case Left(ex) => handleLocalError(ex, d, None)
case Right((localSpec, localCommitTx, remoteSpec, remoteCommitTx)) =>
require(fundingTx.txOut(fundingTxResponse.fundingTxOutputIndex).publicKeyScript == localCommitTx.input.txOut.publicKeyScript, s"pubkey script mismatch!")
val localSigOfRemoteTx = keyManager.sign(remoteCommitTx, keyManager.fundingPublicKey(localParams.fundingKeyPath), TxOwner.Remote, channelFeatures.commitmentFormat)
// signature of their initial commitment tx that pays remote pushMsat
val fundingCreated = FundingCreated(
temporaryChannelId = temporaryChannelId,
fundingTxid = fundingTx.hash,
fundingOutputIndex = fundingTxResponse.fundingTxOutputIndex,
signature = localSigOfRemoteTx
)
val channelId = toLongId(fundingTx.hash, fundingTxResponse.fundingTxOutputIndex)
peer ! ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId) // we notify the peer asap so it knows how to route messages
txPublisher ! SetChannelId(remoteNodeId, channelId)
context.system.eventStream.publish(ChannelIdAssigned(self, remoteNodeId, temporaryChannelId, channelId))
// NB: we don't send a ChannelSignatureSent for the first commit
goto(WAIT_FOR_FUNDING_SIGNED) using DATA_WAIT_FOR_FUNDING_SIGNED(channelId, localParams, remoteParams, fundingTx, fundingTxResponse.fee, localSpec, localCommitTx, RemoteCommit(0, remoteSpec, remoteCommitTx.tx.txid, remoteFirstPerCommitmentPoint), open.channelFlags, channelConfig, channelFeatures, fundingCreated) sending fundingCreated
}
}

case Event(Status.Failure(t), d: DATA_WAIT_FOR_FUNDING_INTERNAL) =>
Expand Down
Loading

0 comments on commit fb5521f

Please sign in to comment.