Skip to content

Commit

Permalink
Refactor bitcoin clients (#1697)
Browse files Browse the repository at this point in the history
And improve test coverage specifically for the calls we'll rely on for CPFP
and RBF.
  • Loading branch information
t-bast committed Feb 19, 2021
1 parent 0835150 commit d9c0b86
Show file tree
Hide file tree
Showing 15 changed files with 622 additions and 478 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ package fr.acinq.eclair.blockchain.bitcoind
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin._
import fr.acinq.eclair.blockchain._
import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinJsonRPCClient, Error, ExtendedBitcoinClient, JsonRPCError}
import fr.acinq.eclair.blockchain.bitcoind.rpc.ExtendedBitcoinClient.{FundTransactionOptions, FundTransactionResponse, SignTransactionResponse, toSatoshi}
import fr.acinq.eclair.blockchain.bitcoind.rpc.{BitcoinJsonRPCClient, ExtendedBitcoinClient, JsonRPCError}
import fr.acinq.eclair.blockchain.fee.{FeeratePerKB, FeeratePerKw}
import fr.acinq.eclair.transactions.Transactions
import grizzled.slf4j.Logging
Expand All @@ -39,38 +40,22 @@ class BitcoinCoreWallet(rpcClient: BitcoinJsonRPCClient)(implicit ec: ExecutionC

val bitcoinClient = new ExtendedBitcoinClient(rpcClient)

def fundTransaction(tx: Transaction, lockUnspents: Boolean, feeRatePerKw: FeeratePerKw): Future[FundTransactionResponse] = fundTransaction(Transaction.write(tx).toHex, lockUnspents, feeRatePerKw)

private def fundTransaction(hex: String, lockUnspents: Boolean, feeRatePerKw: FeeratePerKw): Future[FundTransactionResponse] = {
val requestedFeeRatePerKB = FeeratePerKB(feeRatePerKw)
def fundTransaction(tx: Transaction, lockUtxos: Boolean, feerate: FeeratePerKw): Future[FundTransactionResponse] = {
val requestedFeeRatePerKB = FeeratePerKB(feerate)
rpcClient.invoke("getmempoolinfo").map(json => json \ "mempoolminfee" match {
case JDecimal(feerate) => FeeratePerKB(Btc(feerate).toSatoshi).max(requestedFeeRatePerKB)
case JInt(feerate) => FeeratePerKB(Btc(feerate.toLong).toSatoshi).max(requestedFeeRatePerKB)
case other =>
logger.warn(s"cannot retrieve mempool minimum fee: $other")
requestedFeeRatePerKB
}).flatMap(feeRatePerKB => {
rpcClient.invoke("fundrawtransaction", hex, Options(lockUnspents, feeRatePerKB.toLong.bigDecimal.scaleByPowerOfTen(-8))).map(json => {
val JString(hex) = json \ "hex"
val JInt(changepos) = json \ "changepos"
val JDecimal(fee) = json \ "fee"
FundTransactionResponse(Transaction.read(hex), changepos.intValue, toSatoshi(fee))
})
bitcoinClient.fundTransaction(tx, FundTransactionOptions(FeeratePerKw(feeRatePerKB), lockUtxos = lockUtxos))
})
}

def signTransaction(tx: Transaction): Future[SignTransactionResponse] = signTransaction(Transaction.write(tx).toHex)

private def signTransaction(hex: String): Future[SignTransactionResponse] =
rpcClient.invoke("signrawtransactionwithwallet", hex).map(json => {
val JString(hex) = json \ "hex"
val JBool(complete) = json \ "complete"
if (!complete) {
val message = (json \ "errors" \\ classOf[JString]).mkString(",")
throw JsonRPCError(Error(-1, message))
}
SignTransactionResponse(Transaction.read(hex), complete)
})
def signTransaction(tx: Transaction): Future[SignTransactionResponse] = {
bitcoinClient.signTransaction(tx, Nil)
}

private def signTransactionOrUnlock(tx: Transaction): Future[SignTransactionResponse] = {
val f = signTransaction(tx)
Expand Down Expand Up @@ -137,24 +122,24 @@ class BitcoinCoreWallet(rpcClient: BitcoinJsonRPCClient)(implicit ec: ExecutionC
JString(rawKey) <- rpcClient.invoke("getaddressinfo", address).map(_ \ "pubkey")
} yield PublicKey(ByteVector.fromValidHex(rawKey))

override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw): Future[MakeFundingTxResponse] = {
override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feerate: FeeratePerKw): Future[MakeFundingTxResponse] = {
val partialFundingTx = Transaction(
version = 2,
txIn = Seq.empty[TxIn],
txOut = TxOut(amount, pubkeyScript) :: Nil,
lockTime = 0)
for {
// we ask bitcoin core to add inputs to the funding tx, and use the specified change address
FundTransactionResponse(unsignedFundingTx, _, fee) <- fundTransaction(partialFundingTx, lockUnspents = true, feeRatePerKw)
fundTxResponse <- fundTransaction(partialFundingTx, lockUtxos = true, feerate)
// now let's sign the funding tx
SignTransactionResponse(fundingTx, true) <- signTransactionOrUnlock(unsignedFundingTx)
SignTransactionResponse(fundingTx, true) <- signTransactionOrUnlock(fundTxResponse.tx)
// there will probably be a change output, so we need to find which output is ours
outputIndex <- Transactions.findPubKeyScriptIndex(fundingTx, pubkeyScript, amount_opt = None) 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=$fee")
} yield MakeFundingTxResponse(fundingTx, outputIndex, fee)
_ = logger.debug(s"created funding txid=${fundingTx.txid} outputIndex=$outputIndex fee=${fundTxResponse.fee}")
} yield MakeFundingTxResponse(fundingTx, outputIndex, fundTxResponse.fee)
}

override def commit(tx: Transaction): Future[Boolean] = bitcoinClient.publishTransaction(tx).transformWith {
Expand Down Expand Up @@ -200,13 +185,8 @@ class BitcoinCoreWallet(rpcClient: BitcoinJsonRPCClient)(implicit ec: ExecutionC
object BitcoinCoreWallet {

// @formatter:off
case class Options(lockUnspents: Boolean, feeRate: BigDecimal)
case class Utxo(txid: ByteVector32, vout: Long)
case class WalletTransaction(address: String, amount: Satoshi, fees: Satoshi, blockHash: ByteVector32, confirmations: Long, txid: ByteVector32, timestamp: Long)
case class FundTransactionResponse(tx: Transaction, changepos: Int, fee: Satoshi)
case class SignTransactionResponse(tx: Transaction, complete: Boolean)
// @formatter:on

private def toSatoshi(amount: BigDecimal): Satoshi = Satoshi(amount.bigDecimal.scaleByPowerOfTen(8).longValue)

}
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend
context become watching(watches, watchedUtxos, block2tx1, nextTick)
} else publish(tx)

case WatchEventConfirmed(BITCOIN_PARENT_TX_CONFIRMED(tx), blockHeight, _, _) =>
case WatchEventConfirmed(BITCOIN_PARENT_TX_CONFIRMED(tx), _, _, _) =>
log.info(s"parent tx of txid=${tx.txid} has been confirmed")
val blockCount = this.blockCount.get()
val cltvTimeout = Scripts.cltvTimeout(tx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ package fr.acinq.eclair.blockchain.bitcoind.rpc
import fr.acinq.bitcoin._
import fr.acinq.eclair.ShortChannelId.coordinates
import fr.acinq.eclair.TxCoordinates
import fr.acinq.eclair.blockchain.fee.{FeeratePerKB, FeeratePerKw}
import fr.acinq.eclair.blockchain.{GetTxWithMetaResponse, UtxoStatus, ValidateResult}
import fr.acinq.eclair.transactions.Transactions
import fr.acinq.eclair.wire.ChannelAnnouncement
import org.json4s.Formats
import org.json4s.JsonAST._
import scodec.bits.ByteVector

import scala.concurrent.{ExecutionContext, Future}
import scala.util.Try
Expand All @@ -38,6 +41,8 @@ import scala.util.Try
*/
class ExtendedBitcoinClient(val rpcClient: BitcoinJsonRPCClient) {

import ExtendedBitcoinClient._

implicit val formats: Formats = org.json4s.DefaultFormats

def getTransaction(txid: ByteVector32)(implicit ec: ExecutionContext): Future[Transaction] =
Expand Down Expand Up @@ -84,6 +89,40 @@ class ExtendedBitcoinClient(val rpcClient: BitcoinJsonRPCClient) {
index = txs.indexOf(JString(txid.toHex))
} yield (height.toInt, index)

def fundTransaction(tx: Transaction, options: FundTransactionOptions)(implicit ec: ExecutionContext): Future[FundTransactionResponse] = {
rpcClient.invoke("fundrawtransaction", tx.toString(), options).map(json => {
val JString(hex) = json \ "hex"
val JInt(changePos) = json \ "changepos"
val JDecimal(fee) = json \ "fee"
val fundedTx = Transaction.read(hex)
val changePos_opt = if (changePos >= 0) Some(changePos.intValue) else None
FundTransactionResponse(fundedTx, toSatoshi(fee), changePos_opt)
})
}

/**
* @return the public key hash of a bech32 raw change address.
*/
def getChangeAddress()(implicit ec: ExecutionContext): Future[ByteVector] = {
rpcClient.invoke("getrawchangeaddress", "bech32").collect {
case JString(changeAddress) =>
val (_, _, pubkeyHash) = Bech32.decodeWitnessAddress(changeAddress)
pubkeyHash
}
}

def signTransaction(tx: Transaction, previousTxs: Seq[PreviousTx])(implicit ec: ExecutionContext): Future[SignTransactionResponse] = {
rpcClient.invoke("signrawtransactionwithwallet", tx.toString(), previousTxs).map(json => {
val JString(hex) = json \ "hex"
val JBool(complete) = json \ "complete"
if (!complete) {
val message = (json \ "errors" \\ classOf[JString]).mkString(",")
throw JsonRPCError(Error(-1, message))
}
SignTransactionResponse(Transaction.read(hex), complete)
})
}

/**
* Publish a transaction on the bitcoin network.
*
Expand All @@ -101,7 +140,7 @@ class ExtendedBitcoinClient(val rpcClient: BitcoinJsonRPCClient) {
Future.successful(tx.txid)
case e@JsonRPCError(Error(-25, _)) =>
// "missing inputs (code: -25)": it may be that the tx has already been published and its output spent.
getRawTransaction(tx.txid).map { _ => tx.txid }.recoverWith { case _ => Future.failed(e) }
getRawTransaction(tx.txid).map(_ => tx.txid).recoverWith { case _ => Future.failed(e) }
}

def isTransactionOutputSpendable(txid: ByteVector32, outputIndex: Int, includeMempool: Boolean)(implicit ec: ExecutionContext): Future[Boolean] =
Expand Down Expand Up @@ -162,6 +201,21 @@ class ExtendedBitcoinClient(val rpcClient: BitcoinJsonRPCClient) {
txs <- Future.sequence(txids.map(getTransaction(_)))
} yield txs

def getMempoolTx(txid: ByteVector32)(implicit ec: ExecutionContext): Future[MempoolTx] = {
rpcClient.invoke("getmempoolentry", txid).map(json => {
val JInt(vsize) = json \ "vsize"
val JInt(weight) = json \ "weight"
val JInt(ancestorCount) = json \ "ancestorcount"
val JInt(descendantCount) = json \ "descendantcount"
val JDecimal(fees) = json \ "fees" \ "base"
val JDecimal(ancestorFees) = json \ "fees" \ "ancestor"
val JDecimal(descendantFees) = json \ "fees" \ "descendant"
val JBool(replaceable) = json \ "bip125-replaceable"
// NB: bitcoind counts the transaction itself as its own ancestor and descendant, which is confusing: we fix that by decrementing these counters.
MempoolTx(vsize.toLong, weight.toLong, replaceable, toSatoshi(fees), ancestorCount.toInt - 1, toSatoshi(ancestorFees), descendantCount.toInt - 1, toSatoshi(descendantFees))
})
}

def getBlockCount(implicit ec: ExecutionContext): Future[Long] =
rpcClient.invoke("getblockcount").collect {
case JInt(count) => count.toLong
Expand Down Expand Up @@ -189,3 +243,50 @@ class ExtendedBitcoinClient(val rpcClient: BitcoinJsonRPCClient) {
}

}

object ExtendedBitcoinClient {

case class FundTransactionOptions(feeRate: BigDecimal, replaceable: Boolean, lockUnspents: Boolean, changePosition: Option[Int])

object FundTransactionOptions {
def apply(feerate: FeeratePerKw, replaceable: Boolean = true, lockUtxos: Boolean = false, changePosition: Option[Int] = None): FundTransactionOptions = {
FundTransactionOptions(BigDecimal(FeeratePerKB(feerate).toLong).bigDecimal.scaleByPowerOfTen(-8), replaceable, lockUtxos, changePosition)
}
}

case class FundTransactionResponse(tx: Transaction, fee: Satoshi, changePosition: Option[Int]) {
val amountIn: Satoshi = fee + tx.txOut.map(_.amount).sum
}

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

object PreviousTx {
def apply(inputInfo: Transactions.InputInfo, witness: ScriptWitness): PreviousTx = PreviousTx(
inputInfo.outPoint.txid,
inputInfo.outPoint.index,
inputInfo.txOut.publicKeyScript.toHex,
inputInfo.redeemScript.toHex,
ScriptWitness.write(witness).toHex,
inputInfo.txOut.amount.toBtc.toBigDecimal
)
}

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

/**
* Information about a transaction currently in the mempool.
*
* @param vsize virtual transaction size as defined in BIP 141.
* @param weight transaction weight as defined in BIP 141.
* @param replaceable Whether this transaction could be replaced with RBF (BIP125).
* @param fees transaction fees.
* @param ancestorCount number of unconfirmed parent transactions.
* @param ancestorFees transactions fees for the package consisting of this transaction and its unconfirmed parents.
* @param descendantCount number of unconfirmed child transactions.
* @param descendantFees transactions fees for the package consisting of this transaction and its unconfirmed children (without its unconfirmed parents).
*/
case class MempoolTx(vsize: Long, weight: Long, replaceable: Boolean, fees: Satoshi, ancestorCount: Int, ancestorFees: Satoshi, descendantCount: Int, descendantFees: Satoshi)

def toSatoshi(btcAmount: BigDecimal): Satoshi = Satoshi(btcAmount.bigDecimal.scaleByPowerOfTen(8).longValue)

}
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ class ElectrumWatcher(blockCount: AtomicLong, client: ActorRef) extends Actor wi
context become running(height, tip, watches, scriptHashStatus, block2tx, sent :+ tx)
}

case WatchEventConfirmed(BITCOIN_PARENT_TX_CONFIRMED(tx), blockHeight, _, _) =>
case WatchEventConfirmed(BITCOIN_PARENT_TX_CONFIRMED(tx), _, _, _) =>
log.info(s"parent tx of txid=${tx.txid} has been confirmed")
val blockCount = this.blockCount.get()
val cltvTimeout = Scripts.cltvTimeout(tx)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,8 @@

package fr.acinq.eclair.blockchain

import akka.actor.{ActorRef, ActorSystem}
import akka.testkit.TestProbe
import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey}
import fr.acinq.bitcoin.{Base58, OutPoint, SIGHASH_ALL, Satoshi, SatoshiLong, Script, ScriptFlags, ScriptWitness, SigVersion, Transaction, TxIn, TxOut}
import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq
import org.json4s.JsonAST.{JString, JValue}
import fr.acinq.bitcoin.{OutPoint, SIGHASH_ALL, Satoshi, SatoshiLong, Script, ScriptFlags, ScriptWitness, SigVersion, Transaction, TxIn, TxOut}
import org.scalatest.funsuite.AnyFunSuiteLike

/**
Expand All @@ -46,35 +42,6 @@ class WatcherSpec extends AnyFunSuiteLike {

object WatcherSpec {

/**
* Create a new address and dumps its private key.
*/
def getNewAddress(bitcoincli: ActorRef)(implicit system: ActorSystem): (String, PrivateKey) = {
val probe = TestProbe()
probe.send(bitcoincli, BitcoinReq("getnewaddress"))
val JString(address) = probe.expectMsgType[JValue]

probe.send(bitcoincli, BitcoinReq("dumpprivkey", address))
val JString(wif) = probe.expectMsgType[JValue]
val (priv, true) = PrivateKey.fromBase58(wif, Base58.Prefix.SecretKeyTestnet)
(address, priv)
}

/**
* Send to a given address, without generating blocks to confirm.
*
* @return the corresponding transaction.
*/
def sendToAddress(bitcoincli: ActorRef, address: String, amount: Double)(implicit system: ActorSystem): Transaction = {
val probe = TestProbe()
probe.send(bitcoincli, BitcoinReq("sendtoaddress", address, amount))
val JString(txid) = probe.expectMsgType[JValue]

probe.send(bitcoincli, BitcoinReq("getrawtransaction", txid))
val JString(hex) = probe.expectMsgType[JValue]
Transaction.read(hex)
}

/**
* Create a transaction that spends a p2wpkh output from an input transaction and sends it to the same address.
*
Expand Down
Loading

0 comments on commit d9c0b86

Please sign in to comment.