Wallet send from outpoints (#1405)

This commit is contained in:
Ben Carman 2020-05-12 07:24:36 -05:00 committed by GitHub
parent 583da51958
commit c571585b3b
10 changed files with 347 additions and 50 deletions

View file

@ -2,7 +2,7 @@ package org.bitcoins.commons.serializers
import org.bitcoins.core.crypto.ExtPublicKey
import org.bitcoins.core.currency.{Bitcoins, Satoshis}
import org.bitcoins.core.protocol.transaction.Transaction
import org.bitcoins.core.protocol.transaction.{Transaction, TransactionOutPoint}
import org.bitcoins.core.protocol.{BitcoinAddress, BlockStamp}
import org.bitcoins.core.psbt.PSBT
import org.bitcoins.core.wallet.fee.SatoshisPerVirtualByte
@ -38,4 +38,7 @@ object Picklers {
implicit val extPubKeyPickler: ReadWriter[ExtPublicKey] =
readwriter[String].bimap(_.toString, ExtPublicKey.fromString(_).get)
implicit val transactionOutPointPickler: ReadWriter[TransactionOutPoint] =
readwriter[String].bimap(_.hex, TransactionOutPoint.fromHex)
}

View file

@ -6,7 +6,7 @@ import org.bitcoins.core.config.{NetworkParameters, Networks}
import org.bitcoins.core.currency._
import org.bitcoins.core.protocol.BlockStamp.BlockTime
import org.bitcoins.core.protocol._
import org.bitcoins.core.protocol.transaction.Transaction
import org.bitcoins.core.protocol.transaction.{Transaction, TransactionOutPoint}
import org.bitcoins.core.psbt.PSBT
import org.bitcoins.core.wallet.fee.SatoshisPerVirtualByte
import scopt._
@ -86,4 +86,11 @@ object CliReaders {
val reads: String => Transaction = Transaction.fromHex
}
implicit val outPointsRead: Read[TransactionOutPoint] =
new Read[TransactionOutPoint] {
val arity: Int = 1
val reads: String => TransactionOutPoint = TransactionOutPoint.fromHex
}
}

View file

@ -4,7 +4,11 @@ import org.bitcoins.cli.CliCommand._
import org.bitcoins.cli.CliReaders._
import org.bitcoins.core.config.NetworkParameters
import org.bitcoins.core.currency._
import org.bitcoins.core.protocol.transaction.{EmptyTransaction, Transaction}
import org.bitcoins.core.protocol.transaction.{
EmptyTransaction,
Transaction,
TransactionOutPoint
}
import org.bitcoins.core.protocol.{BitcoinAddress, BlockStamp}
import org.bitcoins.core.psbt.PSBT
import org.bitcoins.core.wallet.fee.SatoshisPerVirtualByte
@ -31,8 +35,8 @@ object ConsoleCli {
.action((_, conf) => conf.copy(debug = true))
.text("Print debugging information"),
opt[Int]("rpcport")
.action((port,conf) => conf.copy(rpcPort = port))
.text(s"The port to send our rpc request to on the server"),
.action((port, conf) => conf.copy(rpcPort = port))
.text(s"The port to send our rpc request to on the server"),
help('h', "help").text("Display this help message and exit"),
note(sys.props("line.separator") + "Commands:"),
note(sys.props("line.separator") + "===Blockchain ==="),
@ -218,6 +222,49 @@ object ConsoleCli {
case other => other
}))
),
cmd("sendfromoutpoints")
.action((_, conf) =>
conf.copy(
command = SendFromOutPoints(Vector.empty, null, 0.bitcoin, None)))
.text("Send money to the given address")
.children(
arg[Seq[TransactionOutPoint]]("outpoints")
.text("Out Points to send from")
.required()
.action((outPoints, conf) =>
conf.copy(command = conf.command match {
case send: SendFromOutPoints =>
send.copy(outPoints = outPoints.toVector)
case other => other
})),
arg[BitcoinAddress]("address")
.text("Address to send to")
.required()
.action((addr, conf) =>
conf.copy(command = conf.command match {
case send: SendFromOutPoints =>
send.copy(destination = addr)
case other => other
})),
arg[Bitcoins]("amount")
.text("amount to send in BTC")
.required()
.action((btc, conf) =>
conf.copy(command = conf.command match {
case send: SendFromOutPoints =>
send.copy(amount = btc)
case other => other
})),
opt[SatoshisPerVirtualByte]("feerate")
.text("Fee rate in sats per virtual byte")
.optional()
.action((feeRate, conf) =>
conf.copy(command = conf.command match {
case send: SendFromOutPoints =>
send.copy(satoshisPerVirtualByte = Some(feeRate))
case other => other
}))
),
note(sys.props("line.separator") + "=== Network ==="),
cmd("getpeers")
.action((_, conf) => conf.copy(command = GetPeers))
@ -382,6 +429,15 @@ object ConsoleCli {
Seq(up.writeJs(address),
up.writeJs(bitcoins),
up.writeJs(satoshisPerVirtualByte)))
case SendFromOutPoints(outPoints,
address,
bitcoins,
satoshisPerVirtualByte) =>
RequestParam("sendfromoutpoints",
Seq(up.writeJs(outPoints),
up.writeJs(address),
up.writeJs(bitcoins),
up.writeJs(satoshisPerVirtualByte)))
// height
case GetBlockCount => RequestParam("getblockcount")
// filter count
@ -505,6 +561,12 @@ object CliCommand {
amount: Bitcoins,
satoshisPerVirtualByte: Option[SatoshisPerVirtualByte])
extends CliCommand
case class SendFromOutPoints(
outPoints: Vector[TransactionOutPoint],
destination: BitcoinAddress,
amount: Bitcoins,
satoshisPerVirtualByte: Option[SatoshisPerVirtualByte])
extends CliCommand
case object GetNewAddress extends CliCommand
case object GetUtxos extends CliCommand
case object GetAddresses extends CliCommand

View file

@ -17,12 +17,7 @@ import org.bitcoins.core.protocol.BlockStamp.{
InvalidBlockStamp
}
import org.bitcoins.core.protocol.script.EmptyScriptWitness
import org.bitcoins.core.protocol.transaction.{
EmptyTransaction,
EmptyTransactionOutPoint,
EmptyTransactionOutput,
Transaction
}
import org.bitcoins.core.protocol.transaction._
import org.bitcoins.core.protocol.{BitcoinAddress, BlockStamp, P2PKHAddress}
import org.bitcoins.core.psbt.PSBT
import org.bitcoins.core.util.FutureUtil
@ -477,6 +472,85 @@ class RoutesSpec
}
"send from outpoints" in {
// positive cases
(mockWalletApi
.sendFromOutPoints(_: Vector[TransactionOutPoint],
_: BitcoinAddress,
_: CurrencyUnit,
_: FeeUnit))
.expects(Vector.empty[TransactionOutPoint],
testAddress,
Bitcoins(100),
*)
.returning(Future.successful(EmptyTransaction))
(mockNode.broadcastTransaction _)
.expects(EmptyTransaction)
.returning(FutureUtil.unit)
.anyNumberOfTimes()
val route = walletRoutes.handleCommand(
ServerCommand("sendfromoutpoints",
Arr(Arr(), Str(testAddressStr), Num(100), Num(4))))
Post() ~> route ~> check {
contentType shouldEqual `application/json`
responseAs[String] shouldEqual """{"result":"0000000000000000000000000000000000000000000000000000000000000000","error":null}"""
}
// negative cases
val route1 = walletRoutes.handleCommand(
ServerCommand("sendfromoutpoints", Arr(Arr(), Null, Null, Null)))
Post() ~> route1 ~> check {
rejection shouldEqual ValidationRejection(
"failure",
Some(InvalidData(Null, "Expected ujson.Str")))
}
val route2 = walletRoutes.handleCommand(
ServerCommand("sendfromoutpoints", Arr(Arr(), "Null", Null, Null)))
Post() ~> route2 ~> check {
rejection shouldEqual ValidationRejection(
"failure",
Some(InvalidData("Null", "Expected a valid address")))
}
val route3 = walletRoutes.handleCommand(
ServerCommand("sendfromoutpoints",
Arr(Arr(), Str(testAddressStr), Null, Null)))
Post() ~> route3 ~> check {
rejection shouldEqual ValidationRejection(
"failure",
Some(InvalidData(Null, "Expected ujson.Num")))
}
val route4 = walletRoutes.handleCommand(
ServerCommand("sendfromoutpoints",
Arr(Arr(), Str(testAddressStr), Str("abc"), Null)))
Post() ~> route4 ~> check {
rejection shouldEqual ValidationRejection(
"failure",
Some(InvalidData("abc", "Expected ujson.Num")))
}
val route5 = walletRoutes.handleCommand(
ServerCommand("sendfromoutpoints",
Arr(Null, Str(testAddressStr), Num(100), Num(4))))
Post() ~> route5 ~> check {
rejection shouldEqual ValidationRejection(
"failure",
Some(InvalidData(Null, "Expected ujson.Arr")))
}
}
"return the peer list" in {
val route =
nodeRoutes.handleCommand(ServerCommand("getpeers", Arr()))

View file

@ -2,7 +2,7 @@ package org.bitcoins.server
import org.bitcoins.core.currency.{Bitcoins, Satoshis}
import org.bitcoins.core.protocol.BlockStamp.BlockHeight
import org.bitcoins.core.protocol.transaction.Transaction
import org.bitcoins.core.protocol.transaction.{Transaction, TransactionOutPoint}
import org.bitcoins.core.protocol.{BitcoinAddress, BlockStamp}
import org.bitcoins.core.psbt.PSBT
import org.bitcoins.core.wallet.fee.SatoshisPerVirtualByte
@ -235,6 +235,43 @@ object SendToAddress extends ServerJsonModels {
}
case class SendFromOutpoints(
outPoints: Vector[TransactionOutPoint],
address: BitcoinAddress,
amount: Bitcoins,
satoshisPerVirtualByte: Option[SatoshisPerVirtualByte])
object SendFromOutpoints extends ServerJsonModels {
def fromJsArr(jsArr: ujson.Arr): Try[SendFromOutpoints] = {
jsArr.arr.toList match {
case outPointsJs :: addrJs :: bitcoinsJs :: satsPerVBytesJs :: Nil =>
Try {
val outPoints = jsToTransactionOutPointSeq(outPointsJs).toVector
val address = jsToBitcoinAddress(addrJs)
val bitcoins = Bitcoins(bitcoinsJs.num)
val satoshisPerVirtualByte =
nullToOpt(satsPerVBytesJs).map(satsPerVBytes =>
SatoshisPerVirtualByte(Satoshis(satsPerVBytes.num.toLong)))
SendFromOutpoints(outPoints,
address,
bitcoins,
satoshisPerVirtualByte)
}
case Nil =>
Failure(
new IllegalArgumentException(
"Missing outPoints, address, amount, and fee rate arguments"))
case other =>
Failure(
new IllegalArgumentException(
s"Bad number of arguments: ${other.length}. Expected: 3"))
}
}
}
trait ServerJsonModels {
def jsToBitcoinAddress(js: Value): BitcoinAddress = {
@ -252,6 +289,14 @@ trait ServerJsonModels {
def jsToPSBT(js: Value): PSBT = PSBT.fromString(js.str)
def jsToTransactionOutPointSeq(js: Value): Seq[TransactionOutPoint] = {
js.arr.foldLeft(Seq.empty[TransactionOutPoint])((seq, outPoint) =>
seq :+ jsToTransactionOutPoint(outPoint))
}
def jsToTransactionOutPoint(js: Value): TransactionOutPoint =
TransactionOutPoint(js.str)
def jsToTx(js: Value): Transaction = Transaction.fromHex(js.str)
def nullToOpt(value: Value): Option[Value] = value match {

View file

@ -107,6 +107,30 @@ case class WalletRoutes(wallet: WalletApi, node: Node)(
}
}
case ServerCommand("sendfromoutpoints", arr) =>
SendFromOutpoints.fromJsArr(arr) match {
case Failure(exception) =>
reject(ValidationRejection("failure", Some(exception)))
case Success(
SendFromOutpoints(outPoints,
address,
bitcoins,
satoshisPerVirtualByteOpt)) =>
complete {
// TODO dynamic fees based off mempool and recent blocks
val feeRate =
satoshisPerVirtualByteOpt.getOrElse(SatoshisPerByte(100.satoshis))
for {
tx <- wallet.sendFromOutPoints(outPoints,
address,
bitcoins,
feeRate)
_ <- node.broadcastTransaction(tx)
} yield Server.httpSuccess(tx.txIdBE)
}
}
case ServerCommand("rescan", arr) =>
Rescan.fromJsArr(arr) match {
case Failure(exception) =>

View file

@ -17,15 +17,16 @@ class WalletSendingTest extends BitcoinSWalletTest {
behavior of "Wallet"
val testAddress: BitcoinAddress =
BitcoinAddress("bcrt1qlhctylgvdsvaanv539rg7hyn0sjkdm23y70kgq").get
val amountToSend: Bitcoins = Bitcoins(0.5)
val feeRate: SatoshisPerByte = SatoshisPerByte(Satoshis.one)
it should "correctly send to an address" in { fundedWallet =>
val wallet = fundedWallet.wallet
val testAddress =
BitcoinAddress("bcrt1qlhctylgvdsvaanv539rg7hyn0sjkdm23y70kgq").get
val amountToSend: Bitcoins = Bitcoins(0.5)
for {
tx <- wallet.sendToAddress(testAddress, amountToSend, feeRate)
} yield {
@ -140,4 +141,27 @@ class WalletSendingTest extends BitcoinSWalletTest {
sendToAddressesF
}
}
it should "correctly send from outpoints" in { fundedWallet =>
val wallet = fundedWallet.wallet
for {
allOutPoints <- wallet.spendingInfoDAO.findAllOutpoints()
// use half of them
outPoints = allOutPoints.drop(allOutPoints.size / 2)
tx <- wallet.sendFromOutPoints(outPoints,
testAddress,
amountToSend,
feeRate)
} yield {
assert(outPoints.forall(outPoint =>
tx.inputs.exists(_.previousOutput == outPoint)),
"Every outpoint was not included included")
assert(tx.inputs.size == outPoints.size, "An extra input was added")
val expectedOutput =
TransactionOutput(amountToSend, testAddress.scriptPubKey)
assert(tx.outputs.contains(expectedOutput),
"Did not contain expected output")
}
}
}

View file

@ -4,12 +4,14 @@ import java.time.Instant
import org.bitcoins.core.api.{ChainQueryApi, NodeApi}
import org.bitcoins.core.bloom.{BloomFilter, BloomUpdateAll}
import org.bitcoins.core.config.BitcoinNetwork
import org.bitcoins.core.crypto.ExtPublicKey
import org.bitcoins.core.currency._
import org.bitcoins.core.hd.{HDAccount, HDCoin, HDPurposes}
import org.bitcoins.core.protocol.BitcoinAddress
import org.bitcoins.core.protocol.blockchain.BlockHeader
import org.bitcoins.core.protocol.transaction._
import org.bitcoins.core.wallet.builder.BitcoinTxBuilder
import org.bitcoins.core.wallet.fee.FeeUnit
import org.bitcoins.core.wallet.utxo.TxoState
import org.bitcoins.core.wallet.utxo.TxoState.{
@ -205,6 +207,62 @@ abstract class Wallet
} yield updatedInfos
}
/** Takes a [[BitcoinTxBuilder]] for a transaction to be sent, and completes it by:
* signing the transaction, then correctly processing the it and logging it
*/
private def finishSend(txBuilder: BitcoinTxBuilder): Future[Transaction] = {
for {
signed <- txBuilder.sign
ourOuts <- findOurOuts(signed)
_ <- processOurTransaction(transaction = signed,
feeRate = txBuilder.feeRate,
inputAmount = txBuilder.creditingAmount,
sentAmount = txBuilder.destinationAmount,
blockHashOpt = None)
} yield {
logger.debug(
s"Signed transaction=${signed.txIdBE.hex} with outputs=${signed.outputs.length}, inputs=${signed.inputs.length}")
logger.trace(s"Change output(s) for transaction=${signed.txIdBE.hex}")
ourOuts.foreach { out =>
logger.trace(s" $out")
}
signed
}
}
override def sendFromOutPoints(
outPoints: Vector[TransactionOutPoint],
address: BitcoinAddress,
amount: CurrencyUnit,
feeRate: FeeUnit,
fromAccount: AccountDb): Future[Transaction] = {
require(
address.networkParameters.isSameNetworkBytes(networkParameters),
s"Cannot send to address on other network, got ${address.networkParameters}"
)
logger.info(s"Sending $amount to $address at feerate $feeRate")
for {
utxoDbs <- spendingInfoDAO.findByOutPoints(outPoints)
diff = utxoDbs.map(_.outPoint).diff(outPoints)
_ = require(diff.isEmpty,
s"Not all OutPoints belong to this wallet, diff $diff")
utxos = utxoDbs.map(_.toUTXOSpendingInfo(keyManager))
changeAddr <- getNewChangeAddress(fromAccount.hdAccount)
output = TransactionOutput(amount, address.scriptPubKey)
txBuilder <- BitcoinTxBuilder(
Vector(output),
utxos,
feeRate,
changeAddr.scriptPubKey,
networkParameters.asInstanceOf[BitcoinNetwork])
tx <- finishSend(txBuilder)
} yield tx
}
override def sendToAddress(
address: BitcoinAddress,
amount: CurrencyUnit,
@ -222,23 +280,9 @@ abstract class Wallet
feeRate = feeRate,
fromAccount = fromAccount,
keyManagerOpt = Some(keyManager))
signed <- txBuilder.sign
ourOuts <- findOurOuts(signed)
_ <- processOurTransaction(transaction = signed,
feeRate = feeRate,
inputAmount = txBuilder.creditingAmount,
sentAmount = txBuilder.destinationAmount,
blockHashOpt = None)
} yield {
logger.debug(
s"Signed transaction=${signed.txIdBE.hex} with outputs=${signed.outputs.length}, inputs=${signed.inputs.length}")
logger.trace(s"Change output(s) for transaction=${signed.txIdBE.hex}")
ourOuts.foreach { out =>
logger.trace(s" $out")
}
signed
}
tx <- finishSend(txBuilder)
} yield tx
}
override def sendToAddresses(
@ -273,23 +317,8 @@ abstract class Wallet
fromAccount = fromAccount,
keyManagerOpt = Some(keyManager),
markAsReserved = reserveUtxos)
signed <- txBuilder.sign
ourOuts <- findOurOuts(signed)
_ <- processOurTransaction(transaction = signed,
feeRate = feeRate,
inputAmount = txBuilder.creditingAmount,
sentAmount = txBuilder.destinationAmount,
blockHashOpt = None)
} yield {
logger.debug(
s"Signed transaction=${signed.txIdBE.hex} with outputs=${signed.outputs.length}, inputs=${signed.inputs.length}")
logger.trace(s"Change output(s) for transaction=${signed.txIdBE.hex}")
ourOuts.foreach { out =>
logger.trace(s" $out")
}
signed
}
tx <- finishSend(txBuilder)
} yield tx
}
/** Creates a new account my reading from our account database, finding the last account,

View file

@ -10,7 +10,11 @@ import org.bitcoins.core.gcs.{GolombFilter, SimpleFilterMatcher}
import org.bitcoins.core.hd.{AddressType, HDAccount, HDChainType, HDPurpose}
import org.bitcoins.core.protocol.blockchain.{Block, BlockHeader, ChainParams}
import org.bitcoins.core.protocol.script.ScriptPubKey
import org.bitcoins.core.protocol.transaction.{Transaction, TransactionOutput}
import org.bitcoins.core.protocol.transaction.{
Transaction,
TransactionOutPoint,
TransactionOutput
}
import org.bitcoins.core.protocol.{BitcoinAddress, BlockStamp}
import org.bitcoins.core.util.FutureUtil
import org.bitcoins.core.wallet.fee.FeeUnit
@ -415,6 +419,24 @@ trait WalletApi extends WalletLogger {
def keyManager: BIP39KeyManager
def sendFromOutPoints(
outPoints: Vector[TransactionOutPoint],
address: BitcoinAddress,
amount: CurrencyUnit,
feeRate: FeeUnit,
fromAccount: AccountDb): Future[Transaction]
def sendFromOutPoints(
outPoints: Vector[TransactionOutPoint],
address: BitcoinAddress,
amount: CurrencyUnit,
feeRate: FeeUnit): Future[Transaction] = {
for {
account <- getDefaultAccount()
tx <- sendFromOutPoints(outPoints, address, amount, feeRate, account)
} yield tx
}
/**
*
* Sends money from the specified account

View file

@ -163,6 +163,13 @@ case class SpendingInfoDAO()(
safeDatabase.runVec(query.result).map(_.toVector)
}
/** Enumerates all TX outpoints in the wallet */
def findByOutPoints(outPoints: Vector[TransactionOutPoint]): Future[
Vector[SpendingInfoDb]] = {
val query = table.filter(_.outPoint.inSet(outPoints))
safeDatabase.runVec(query.result).map(_.toVector)
}
/**
* This table stores the necessary information to spend
* a transaction output (TXO) at a later point in time. It