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 8c0d316c21..2201957624 100644 --- a/app/cli/src/main/scala/org/bitcoins/cli/ConsoleCli.scala +++ b/app/cli/src/main/scala/org/bitcoins/cli/ConsoleCli.scala @@ -319,6 +319,39 @@ object ConsoleCli { case other => other })) ), + cmd("opreturncommit") + .action((_, conf) => + conf.copy(command = OpReturnCommit("", hashMessage = false, None))) + .text("Send money to the given address") + .children( + arg[String]("message") + .text("message to put into OP_RETURN commitment") + .required() + .action((message, conf) => + conf.copy(command = conf.command match { + case opReturnCommit: OpReturnCommit => + opReturnCommit.copy(message = message) + case other => other + })), + opt[Unit]("hashMessage") + .text("should the message be hashed before commitment") + .optional() + .action((_, conf) => + conf.copy(command = conf.command match { + case opReturnCommit: OpReturnCommit => + opReturnCommit.copy(hashMessage = true) + 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 opReturnCommit: OpReturnCommit => + opReturnCommit.copy(feeRateOpt = Some(feeRate)) + case other => other + })) + ), note(sys.props("line.separator") + "=== Network ==="), cmd("getpeers") .action((_, conf) => conf.copy(command = GetPeers)) @@ -501,6 +534,11 @@ object ConsoleCli { up.writeJs(bitcoins), up.writeJs(feeRateOpt), up.writeJs(algo))) + case OpReturnCommit(message, hashMessage, satoshisPerVirtualByte) => + RequestParam("opreturncommit", + Seq(up.writeJs(message), + up.writeJs(hashMessage), + up.writeJs(satoshisPerVirtualByte))) // height case GetBlockCount => RequestParam("getblockcount") // filter count @@ -636,6 +674,11 @@ object CliCommand { feeRateOpt: Option[SatoshisPerVirtualByte], algo: CoinSelectionAlgo) extends CliCommand + case class OpReturnCommit( + message: String, + hashMessage: Boolean, + feeRateOpt: 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 0f7d76f5cc..b26d53574d 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 @@ -703,6 +703,29 @@ class RoutesSpec } } + "make an OP_RETURN commitment" in { + + val message = "Never gonna give you up, never gonna let you down" + + (mockWalletApi + .makeOpReturnCommitment(_: String, _: Boolean, _: FeeUnit)) + .expects(message, false, *) + .returning(Future.successful(EmptyTransaction)) + + (mockNode.broadcastTransaction _) + .expects(EmptyTransaction) + .returning(FutureUtil.unit) + .anyNumberOfTimes() + + val route = walletRoutes.handleCommand( + ServerCommand("opreturncommit", Arr(message, Bool(false), Num(4)))) + + Post() ~> route ~> check { + contentType shouldEqual `application/json` + responseAs[String] shouldEqual """{"result":"0000000000000000000000000000000000000000000000000000000000000000","error":null}""" + } + } + "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 57b552669f..fae6e8276f 100644 --- a/app/server/src/main/scala/org/bitcoins/server/ServerJsonModels.scala +++ b/app/server/src/main/scala/org/bitcoins/server/ServerJsonModels.scala @@ -308,6 +308,38 @@ object SendWithAlgo extends ServerJsonModels { } +case class OpReturnCommit( + message: String, + hashMessage: Boolean, + feeRateOpt: Option[SatoshisPerVirtualByte]) + +object OpReturnCommit extends ServerJsonModels { + + def fromJsArr(jsArr: ujson.Arr): Try[OpReturnCommit] = { + jsArr.arr.toList match { + case messageJs :: hashMessageJs :: feeRateOptJs :: Nil => + Try { + val message = messageJs.str + val hashMessage = hashMessageJs.bool + val feeRateOpt = + nullToOpt(feeRateOptJs).map(satsPerVBytes => + SatoshisPerVirtualByte(Satoshis(satsPerVBytes.num.toLong))) + OpReturnCommit(message, hashMessage, feeRateOpt) + } + case Nil => + Failure( + new IllegalArgumentException( + "Missing message, hashMessage, 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 = { 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 642a8824b8..d168133bb6 100644 --- a/app/server/src/main/scala/org/bitcoins/server/WalletRoutes.scala +++ b/app/server/src/main/scala/org/bitcoins/server/WalletRoutes.scala @@ -149,6 +149,24 @@ case class WalletRoutes(wallet: WalletApi, node: Node)( } } + case ServerCommand("opreturncommit", arr) => + OpReturnCommit.fromJsArr(arr) match { + case Failure(exception) => + reject(ValidationRejection("failure", Some(exception))) + case Success( + OpReturnCommit(message, hashMessage, satoshisPerVirtualByteOpt)) => + complete { + // TODO dynamic fees based off mempool and recent blocks + val feeRate = + satoshisPerVirtualByteOpt.getOrElse(SatoshisPerByte(100.satoshis)) + wallet.makeOpReturnCommitment(message, hashMessage, feeRate).map { + tx => + node.broadcastTransaction(tx) + 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 74ed76b87b..a3b38f05fe 100644 --- a/wallet-test/src/test/scala/org/bitcoins/wallet/WalletSendingTest.scala +++ b/wallet-test/src/test/scala/org/bitcoins/wallet/WalletSendingTest.scala @@ -4,11 +4,15 @@ import org.bitcoins.commons.jsonmodels.wallet.CoinSelectionAlgo import org.bitcoins.core.currency._ import org.bitcoins.core.protocol.BitcoinAddress import org.bitcoins.core.protocol.transaction.TransactionOutput -import org.bitcoins.core.wallet.fee.SatoshisPerByte +import org.bitcoins.core.script.constant.{BytesToPushOntoStack, ScriptConstant} +import org.bitcoins.core.script.control.OP_RETURN +import org.bitcoins.core.wallet.fee.{SatoshisPerByte, SatoshisPerVirtualByte} +import org.bitcoins.crypto.CryptoUtil import org.bitcoins.testkit.wallet.BitcoinSWalletTest import org.bitcoins.testkit.wallet.FundWalletUtil.FundedWallet import org.bitcoins.wallet.api.CoinSelector import org.scalatest.{Assertion, FutureOutcome} +import scodec.bits.ByteVector import scala.concurrent.Future @@ -114,6 +118,66 @@ class WalletSendingTest extends BitcoinSWalletTest { } } + def testOpReturnCommitment( + wallet: Wallet, + hashMessage: Boolean): Future[Assertion] = { + val message = "ben was here" + + for { + tx <- wallet.makeOpReturnCommitment(message, + hashMessage = hashMessage, + SatoshisPerVirtualByte.one) + + outgoingTxDbOpt <- wallet.outgoingTxDAO.read(tx.txIdBE) + } yield { + val opReturnOutputOpt = tx.outputs.find(_.value == 0.satoshis) + assert(opReturnOutputOpt.isDefined, "Missing output with 0 value") + val opReturnOutput = opReturnOutputOpt.get + + val messageBytes = if (hashMessage) { + CryptoUtil.sha256(ByteVector(message.getBytes)).bytes + } else { + ByteVector(message.getBytes) + } + + val expectedAsm = + Seq(OP_RETURN, + BytesToPushOntoStack(messageBytes.size), + ScriptConstant(messageBytes)) + + assert(opReturnOutput.scriptPubKey.asm == expectedAsm) + + assert(outgoingTxDbOpt.isDefined, "Missing outgoing tx in database") + val outgoingTxDb = outgoingTxDbOpt.get + + assert(outgoingTxDb.sentAmount == 0.satoshis) + val changeOutput = tx.outputs.find(_.value > 0.satoshis).get + assert( + outgoingTxDb.actualFee + changeOutput.value == outgoingTxDb.inputAmount) + } + } + + it should "correctly make a hashed OP_RETURN commitment" in { fundedWallet => + testOpReturnCommitment(fundedWallet.wallet, hashMessage = true) + } + + it should "correctly make an unhashed OP_RETURN commitment" in { + fundedWallet => + testOpReturnCommitment(fundedWallet.wallet, hashMessage = false) + } + + it should "fail to make an OP_RETURN commitment that is too long" in { + fundedWallet => + val wallet = fundedWallet.wallet + + recoverToSucceededIf[IllegalArgumentException] { + wallet.makeOpReturnCommitment( + "This message is much too long and is over 80 bytes, the limit for OP_RETURN. It should cause an error.", + hashMessage = false, + feeRate) + } + } + it should "fail to send to a different network address" in { fundedWallet => val wallet = fundedWallet.wallet diff --git a/wallet/src/main/scala/org/bitcoins/wallet/Wallet.scala b/wallet/src/main/scala/org/bitcoins/wallet/Wallet.scala index 98d3b0cbc5..9fcdfe9f1d 100644 --- a/wallet/src/main/scala/org/bitcoins/wallet/Wallet.scala +++ b/wallet/src/main/scala/org/bitcoins/wallet/Wallet.scala @@ -11,7 +11,11 @@ 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.script.ScriptPubKey import org.bitcoins.core.protocol.transaction._ +import org.bitcoins.core.script.constant.ScriptConstant +import org.bitcoins.core.script.control.OP_RETURN +import org.bitcoins.core.util.BitcoinScriptUtil import org.bitcoins.core.wallet.builder.BitcoinTxBuilder import org.bitcoins.core.wallet.fee.FeeUnit import org.bitcoins.core.wallet.utxo.TxoState @@ -19,7 +23,7 @@ import org.bitcoins.core.wallet.utxo.TxoState.{ ConfirmedReceived, PendingConfirmationsReceived } -import org.bitcoins.crypto.ECPublicKey +import org.bitcoins.crypto.{CryptoUtil, ECPublicKey} import org.bitcoins.keymanager.KeyManagerParams import org.bitcoins.keymanager.bip39.BIP39KeyManager import org.bitcoins.keymanager.util.HDUtil @@ -27,6 +31,7 @@ import org.bitcoins.wallet.api._ import org.bitcoins.wallet.config.WalletAppConfig import org.bitcoins.wallet.internal._ import org.bitcoins.wallet.models.{SpendingInfoDb, _} +import scodec.bits.ByteVector import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success} @@ -320,6 +325,30 @@ abstract class Wallet sendToOutputs(destinations, feeRate, fromAccount, reserveUtxos) } + override def makeOpReturnCommitment( + message: String, + hashMessage: Boolean, + feeRate: FeeUnit, + fromAccount: AccountDb): Future[Transaction] = { + val messageToUse = if (hashMessage) { + CryptoUtil.sha256(ByteVector(message.getBytes)).bytes + } else { + if (message.length > 80) { + throw new IllegalArgumentException( + s"Message cannot be greater than 80 characters, it should be hashed, got $message") + } else ByteVector(message.getBytes) + } + + val asm = Seq(OP_RETURN) ++ BitcoinScriptUtil.calculatePushOp(messageToUse) :+ ScriptConstant( + messageToUse) + + val scriptPubKey = ScriptPubKey(asm) + + val output = TransactionOutput(0.satoshis, scriptPubKey) + + sendToOutputs(Vector(output), feeRate, fromAccount, reserveUtxos = false) + } + def sendToOutputs( outputs: Vector[TransactionOutput], feeRate: FeeUnit, 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 cbbce97081..1d395604f5 100644 --- a/wallet/src/main/scala/org/bitcoins/wallet/api/WalletApi.scala +++ b/wallet/src/main/scala/org/bitcoins/wallet/api/WalletApi.scala @@ -553,6 +553,22 @@ trait WalletApi extends WalletLogger { } yield tx } + def makeOpReturnCommitment( + message: String, + hashMessage: Boolean, + feeRate: FeeUnit, + fromAccount: AccountDb): Future[Transaction] + + def makeOpReturnCommitment( + message: String, + hashMessage: Boolean, + feeRate: FeeUnit): Future[Transaction] = { + for { + account <- getDefaultAccount() + tx <- makeOpReturnCommitment(message, hashMessage, feeRate, account) + } yield tx + } + def createNewAccount(keyManagerParams: KeyManagerParams): Future[Wallet] /**