From db10cf8ae36693cb4d3ed1b47b84709cf9c0d849 Mon Sep 17 00:00:00 2001 From: Karl-Johan Alm Date: Fri, 20 Aug 2021 15:09:03 +0900 Subject: [PATCH] rpc/wallet: add simulaterawtransaction RPC This command iterates over the inputs and outputs of the given transactions, and tallies up the balance change for the given wallet. This can be useful e.g. when verifying that a coin join like transaction doesn't contain unexpected inputs that the wallet will then sign for unintentionally. --- src/rpc/client.cpp | 2 + src/wallet/rpc/wallet.cpp | 112 ++++++++++++++++++++ test/functional/test_runner.py | 2 + test/functional/wallet_simulaterawtx.py | 129 ++++++++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100755 test/functional/wallet_simulaterawtx.py diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index 9be3ab7df04..ee287656b61 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -147,6 +147,8 @@ static const CRPCConvertParam vRPCConvertParams[] = { "sendall", 1, "conf_target" }, { "sendall", 3, "fee_rate"}, { "sendall", 4, "options" }, + { "simulaterawtransaction", 0, "rawtxs" }, + { "simulaterawtransaction", 1, "options" }, { "importprivkey", 2, "rescan" }, { "importaddress", 2, "rescan" }, { "importaddress", 3, "p2sh" }, diff --git a/src/wallet/rpc/wallet.cpp b/src/wallet/rpc/wallet.cpp index 0fe88711525..eb275f99519 100644 --- a/src/wallet/rpc/wallet.cpp +++ b/src/wallet/rpc/wallet.cpp @@ -590,6 +590,117 @@ static RPCHelpMan upgradewallet() }; } +RPCHelpMan simulaterawtransaction() +{ + return RPCHelpMan{"simulaterawtransaction", + "\nCalculate the balance change resulting in the signing and broadcasting of the given transaction(s).\n", + { + {"rawtxs", RPCArg::Type::ARR, RPCArg::Optional::OMITTED_NAMED_ARG, "An array of hex strings of raw transactions.\n", + { + {"rawtx", RPCArg::Type::STR_HEX, RPCArg::Optional::OMITTED, ""}, + }, + }, + {"options", RPCArg::Type::OBJ_USER_KEYS, RPCArg::Optional::OMITTED_NAMED_ARG, "Options", + { + {"include_watchonly", RPCArg::Type::BOOL, RPCArg::DefaultHint{"true for watch-only wallets, otherwise false"}, "Whether to include watch-only addresses (see RPC importaddress)"}, + }, + }, + }, + RPCResult{ + RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::STR_AMOUNT, "balance_change", "The wallet balance change (negative means decrease)."}, + } + }, + RPCExamples{ + HelpExampleCli("simulaterawtransaction", "[\"myhex\"]") + + HelpExampleRpc("simulaterawtransaction", "[\"myhex\"]") + }, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue +{ + const std::shared_ptr rpc_wallet = GetWalletForJSONRPCRequest(request); + if (!rpc_wallet) return UniValue::VNULL; + const CWallet& wallet = *rpc_wallet; + + RPCTypeCheck(request.params, {UniValue::VARR, UniValue::VOBJ}, true); + + LOCK(wallet.cs_wallet); + + UniValue include_watchonly(UniValue::VNULL); + if (request.params[1].isObject()) { + UniValue options = request.params[1]; + RPCTypeCheckObj(options, + { + {"include_watchonly", UniValueType(UniValue::VBOOL)}, + }, + true, true); + + include_watchonly = options["include_watchonly"]; + } + + isminefilter filter = ISMINE_SPENDABLE; + if (ParseIncludeWatchonly(include_watchonly, wallet)) { + filter |= ISMINE_WATCH_ONLY; + } + + const auto& txs = request.params[0].get_array(); + CAmount changes{0}; + std::map new_utxos; // UTXO:s that were made available in transaction array + std::set spent; + + for (size_t i = 0; i < txs.size(); ++i) { + CMutableTransaction mtx; + if (!DecodeHexTx(mtx, txs[i].get_str(), /* try_no_witness */ true, /* try_witness */ true)) { + throw JSONRPCError(RPC_DESERIALIZATION_ERROR, "Transaction hex string decoding failure."); + } + + // Fetch previous transactions (inputs) + std::map coins; + for (const CTxIn& txin : mtx.vin) { + coins[txin.prevout]; // Create empty map entry keyed by prevout. + } + wallet.chain().findCoins(coins); + + // Fetch debit; we are *spending* these; if the transaction is signed and + // broadcast, we will lose everything in these + for (const auto& txin : mtx.vin) { + const auto& outpoint = txin.prevout; + if (spent.count(outpoint)) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Transaction(s) are spending the same output more than once"); + } + if (new_utxos.count(outpoint)) { + changes -= new_utxos.at(outpoint); + new_utxos.erase(outpoint); + } else { + if (coins.at(outpoint).IsSpent()) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "One or more transaction inputs are missing or have been spent already"); + } + changes -= wallet.GetDebit(txin, filter); + } + spent.insert(outpoint); + } + + // Iterate over outputs; we are *receiving* these, if the wallet considers + // them "mine"; if the transaction is signed and broadcast, we will receive + // everything in these + // Also populate new_utxos in case these are spent in later transactions + + const auto& hash = mtx.GetHash(); + for (size_t i = 0; i < mtx.vout.size(); ++i) { + const auto& txout = mtx.vout[i]; + bool is_mine = 0 < (wallet.IsMine(txout) & filter); + changes += new_utxos[COutPoint(hash, i)] = is_mine ? txout.nValue : 0; + } + } + + UniValue result(UniValue::VOBJ); + result.pushKV("balance_change", ValueFromAmount(changes)); + + return result; +} + }; +} + // addresses RPCHelpMan getaddressinfo(); RPCHelpMan getnewaddress(); @@ -721,6 +832,7 @@ Span GetWalletRPCCommands() {"wallet", &setwalletflag}, {"wallet", &signmessage}, {"wallet", &signrawtransactionwithwallet}, + {"wallet", &simulaterawtransaction}, {"wallet", &sendall}, {"wallet", &unloadwallet}, {"wallet", &upgradewallet}, diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index e5784eb614f..2b365d8d106 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -265,6 +265,8 @@ BASE_SCRIPTS = [ 'wallet_implicitsegwit.py --legacy-wallet', 'rpc_named_arguments.py', 'feature_startupnotify.py', + 'wallet_simulaterawtx.py --legacy-wallet', + 'wallet_simulaterawtx.py --descriptors', 'wallet_listsinceblock.py --legacy-wallet', 'wallet_listsinceblock.py --descriptors', 'wallet_listdescriptors.py --descriptors', diff --git a/test/functional/wallet_simulaterawtx.py b/test/functional/wallet_simulaterawtx.py new file mode 100755 index 00000000000..a408b995157 --- /dev/null +++ b/test/functional/wallet_simulaterawtx.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# Copyright (c) 2021 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test simulaterawtransaction. +""" + +from decimal import Decimal +from test_framework.blocktools import COINBASE_MATURITY +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_approx, + assert_equal, + assert_raises_rpc_error, +) + +class SimulateTxTest(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + def setup_network(self, split=False): + self.setup_nodes() + + def run_test(self): + node = self.nodes[0] + + self.generate(node, 1, sync_fun=self.no_op) # Leave IBD + + node.createwallet(wallet_name='w0') + node.createwallet(wallet_name='w1') + node.createwallet(wallet_name='w2', disable_private_keys=True) + w0 = node.get_wallet_rpc('w0') + w1 = node.get_wallet_rpc('w1') + w2 = node.get_wallet_rpc('w2') + + self.generatetoaddress(node, COINBASE_MATURITY + 1, w0.getnewaddress()) + assert_equal(w0.getbalance(), 50.0) + assert_equal(w1.getbalance(), 0.0) + + address1 = w1.getnewaddress() + address2 = w1.getnewaddress() + + # Add address1 as watch-only to w2 + w2.importpubkey(pubkey=w1.getaddressinfo(address1)["pubkey"]) + + tx1 = node.createrawtransaction([], [{address1: 5.0}]) + tx2 = node.createrawtransaction([], [{address2: 10.0}]) + + # w0 should be unaffected, w2 should see +5 for tx1 + assert_equal(w0.simulaterawtransaction([tx1])["balance_change"], 0.0) + assert_equal(w2.simulaterawtransaction([tx1])["balance_change"], 5.0) + + # w1 should see +5 balance for tx1 + assert_equal(w1.simulaterawtransaction([tx1])["balance_change"], 5.0) + + # w0 should be unaffected, w2 should see +5 for both transactions + assert_equal(w0.simulaterawtransaction([tx1, tx2])["balance_change"], 0.0) + assert_equal(w2.simulaterawtransaction([tx1, tx2])["balance_change"], 5.0) + + # w1 should see +15 balance for both transactions + assert_equal(w1.simulaterawtransaction([tx1, tx2])["balance_change"], 15.0) + + # w0 funds transaction; it should now see a decrease in (tx fee and payment), and w1 should see the same as above + funding = w0.fundrawtransaction(tx1) + tx1 = funding["hex"] + tx1changepos = funding["changepos"] + bitcoin_fee = Decimal(funding["fee"]) + + # w0 sees fee + 5 btc decrease, w2 sees + 5 btc + assert_approx(w0.simulaterawtransaction([tx1])["balance_change"], -(Decimal("5") + bitcoin_fee)) + assert_approx(w2.simulaterawtransaction([tx1])["balance_change"], Decimal("5")) + + # w1 sees same as before + assert_equal(w1.simulaterawtransaction([tx1])["balance_change"], 5.0) + + # same inputs (tx) more than once should error + assert_raises_rpc_error(-8, "Transaction(s) are spending the same output more than once", w0.simulaterawtransaction, [tx1,tx1]) + + tx1ob = node.decoderawtransaction(tx1) + tx1hex = tx1ob["txid"] + tx1vout = 1 - tx1changepos + # tx3 spends new w1 UTXO paying to w0 + tx3 = node.createrawtransaction([{"txid": tx1hex, "vout": tx1vout}], {w0.getnewaddress(): 4.9999}) + # tx4 spends new w1 UTXO paying to w1 + tx4 = node.createrawtransaction([{"txid": tx1hex, "vout": tx1vout}], {w1.getnewaddress(): 4.9999}) + + # on their own, both should fail due to missing input(s) + assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w0.simulaterawtransaction, [tx3]) + assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w1.simulaterawtransaction, [tx3]) + assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w0.simulaterawtransaction, [tx4]) + assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w1.simulaterawtransaction, [tx4]) + + # they should succeed when including tx1: + # wallet tx3 tx4 + # w0 -5 - bitcoin_fee + 4.9999 -5 - bitcoin_fee + # w1 0 +4.9999 + assert_approx(w0.simulaterawtransaction([tx1, tx3])["balance_change"], -Decimal("5") - bitcoin_fee + Decimal("4.9999")) + assert_approx(w1.simulaterawtransaction([tx1, tx3])["balance_change"], 0) + assert_approx(w0.simulaterawtransaction([tx1, tx4])["balance_change"], -Decimal("5") - bitcoin_fee) + assert_approx(w1.simulaterawtransaction([tx1, tx4])["balance_change"], Decimal("4.9999")) + + # they should fail if attempting to include both tx3 and tx4 + assert_raises_rpc_error(-8, "Transaction(s) are spending the same output more than once", w0.simulaterawtransaction, [tx1, tx3, tx4]) + assert_raises_rpc_error(-8, "Transaction(s) are spending the same output more than once", w1.simulaterawtransaction, [tx1, tx3, tx4]) + + # send tx1 to avoid reusing same UTXO below + node.sendrawtransaction(w0.signrawtransactionwithwallet(tx1)["hex"]) + self.generate(node, 1, sync_fun=self.no_op) # Confirm tx to trigger error below + self.sync_all() + + # w0 funds transaction 2; it should now see a decrease in (tx fee and payment), and w1 should see the same as above + funding = w0.fundrawtransaction(tx2) + tx2 = funding["hex"] + bitcoin_fee2 = Decimal(funding["fee"]) + assert_approx(w0.simulaterawtransaction([tx2])["balance_change"], -(Decimal("10") + bitcoin_fee2)) + assert_approx(w1.simulaterawtransaction([tx2])["balance_change"], +(Decimal("10"))) + assert_approx(w2.simulaterawtransaction([tx2])["balance_change"], 0) + + # w0-w2 error due to tx1 already being mined + assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w0.simulaterawtransaction, [tx1, tx2]) + assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w1.simulaterawtransaction, [tx1, tx2]) + assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w2.simulaterawtransaction, [tx1, tx2]) + +if __name__ == '__main__': + SimulateTxTest().main()