diff --git a/app-commons/src/main/scala/org/bitcoins/commons/serializers/Picklers.scala b/app-commons/src/main/scala/org/bitcoins/commons/serializers/Picklers.scala index 73c7a1dab5..cd0580d8e1 100644 --- a/app-commons/src/main/scala/org/bitcoins/commons/serializers/Picklers.scala +++ b/app-commons/src/main/scala/org/bitcoins/commons/serializers/Picklers.scala @@ -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) } diff --git a/app/cli/src/main/scala/org/bitcoins/cli/CliReaders.scala b/app/cli/src/main/scala/org/bitcoins/cli/CliReaders.scala index 3aeca5694b..6954320c3b 100644 --- a/app/cli/src/main/scala/org/bitcoins/cli/CliReaders.scala +++ b/app/cli/src/main/scala/org/bitcoins/cli/CliReaders.scala @@ -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 + } } diff --git a/app/cli/src/main/scala/org/bitcoins/cli/ConsoleCli.scala b/app/cli/src/main/scala/org/bitcoins/cli/ConsoleCli.scala index 3ea10fab88..d2c9652795 100644 --- a/app/cli/src/main/scala/org/bitcoins/cli/ConsoleCli.scala +++ b/app/cli/src/main/scala/org/bitcoins/cli/ConsoleCli.scala @@ -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 diff --git a/app/server-test/src/test/scala/org/bitcoins/server/RoutesSpec.scala b/app/server-test/src/test/scala/org/bitcoins/server/RoutesSpec.scala index 38f6d9eb4c..3da0c095e3 100644 --- a/app/server-test/src/test/scala/org/bitcoins/server/RoutesSpec.scala +++ b/app/server-test/src/test/scala/org/bitcoins/server/RoutesSpec.scala @@ -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())) diff --git a/app/server/src/main/scala/org/bitcoins/server/ServerJsonModels.scala b/app/server/src/main/scala/org/bitcoins/server/ServerJsonModels.scala index 8114323274..9f9a5bc1c6 100644 --- a/app/server/src/main/scala/org/bitcoins/server/ServerJsonModels.scala +++ b/app/server/src/main/scala/org/bitcoins/server/ServerJsonModels.scala @@ -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 { diff --git a/app/server/src/main/scala/org/bitcoins/server/WalletRoutes.scala b/app/server/src/main/scala/org/bitcoins/server/WalletRoutes.scala index 4ac992d2b9..9ed4c7cfad 100644 --- a/app/server/src/main/scala/org/bitcoins/server/WalletRoutes.scala +++ b/app/server/src/main/scala/org/bitcoins/server/WalletRoutes.scala @@ -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) => diff --git a/wallet-test/src/test/scala/org/bitcoins/wallet/WalletSendingTest.scala b/wallet-test/src/test/scala/org/bitcoins/wallet/WalletSendingTest.scala index 5604783937..38f048d9b2 100644 --- a/wallet-test/src/test/scala/org/bitcoins/wallet/WalletSendingTest.scala +++ b/wallet-test/src/test/scala/org/bitcoins/wallet/WalletSendingTest.scala @@ -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") + } + } } diff --git a/wallet/src/main/scala/org/bitcoins/wallet/Wallet.scala b/wallet/src/main/scala/org/bitcoins/wallet/Wallet.scala index 0b2dd82c1a..ba44afb496 100644 --- a/wallet/src/main/scala/org/bitcoins/wallet/Wallet.scala +++ b/wallet/src/main/scala/org/bitcoins/wallet/Wallet.scala @@ -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, diff --git a/wallet/src/main/scala/org/bitcoins/wallet/api/WalletApi.scala b/wallet/src/main/scala/org/bitcoins/wallet/api/WalletApi.scala index 934f2331db..bf262b6b12 100644 --- a/wallet/src/main/scala/org/bitcoins/wallet/api/WalletApi.scala +++ b/wallet/src/main/scala/org/bitcoins/wallet/api/WalletApi.scala @@ -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 diff --git a/wallet/src/main/scala/org/bitcoins/wallet/models/SpendingInfoDAO.scala b/wallet/src/main/scala/org/bitcoins/wallet/models/SpendingInfoDAO.scala index 364ded6c32..ea9931b11c 100644 --- a/wallet/src/main/scala/org/bitcoins/wallet/models/SpendingInfoDAO.scala +++ b/wallet/src/main/scala/org/bitcoins/wallet/models/SpendingInfoDAO.scala @@ -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