Skip to content

Commit 35305c7

Browse files
committed
Merge bitcoin/bitcoin#22751: rpc/wallet: add simulaterawtransaction RPC
db10cf8 rpc/wallet: add simulaterawtransaction RPC (Karl-Johan Alm) 701a64f test: add support for Decimal to assert_approx (Karl-Johan Alm) Pull request description: (note: this was originally titled "add analyzerawtransaction 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. I originally proposed this to Elements (ElementsProject/elements#1016) and it was suggested that I propose this upstream. There is an alternative #22776 to instead add this info to `getbalances` when providing an optional transaction as argument. ACKs for top commit: jonatack: ACK db10cf8 achow101: re-ACK db10cf8 Tree-SHA512: adf222ec7dcdc068d007ae6f465dbc35b692dc7bb2db337be25340ad0c2f9c64cfab4124df23400995c700f41c83c29a2c34812121782c26063b100c7969b89d
2 parents 7d3817b + db10cf8 commit 35305c7

File tree

5 files changed

+249
-0
lines changed

5 files changed

+249
-0
lines changed

src/rpc/client.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ static const CRPCConvertParam vRPCConvertParams[] =
147147
{ "sendall", 1, "conf_target" },
148148
{ "sendall", 3, "fee_rate"},
149149
{ "sendall", 4, "options" },
150+
{ "simulaterawtransaction", 0, "rawtxs" },
151+
{ "simulaterawtransaction", 1, "options" },
150152
{ "importprivkey", 2, "rescan" },
151153
{ "importaddress", 2, "rescan" },
152154
{ "importaddress", 3, "p2sh" },

src/wallet/rpc/wallet.cpp

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,117 @@ static RPCHelpMan upgradewallet()
590590
};
591591
}
592592

593+
RPCHelpMan simulaterawtransaction()
594+
{
595+
return RPCHelpMan{"simulaterawtransaction",
596+
"\nCalculate the balance change resulting in the signing and broadcasting of the given transaction(s).\n",
597+
{
598+
{"rawtxs", RPCArg::Type::ARR, RPCArg::Optional::OMITTED_NAMED_ARG, "An array of hex strings of raw transactions.\n",
599+
{
600+
{"rawtx", RPCArg::Type::STR_HEX, RPCArg::Optional::OMITTED, ""},
601+
},
602+
},
603+
{"options", RPCArg::Type::OBJ_USER_KEYS, RPCArg::Optional::OMITTED_NAMED_ARG, "Options",
604+
{
605+
{"include_watchonly", RPCArg::Type::BOOL, RPCArg::DefaultHint{"true for watch-only wallets, otherwise false"}, "Whether to include watch-only addresses (see RPC importaddress)"},
606+
},
607+
},
608+
},
609+
RPCResult{
610+
RPCResult::Type::OBJ, "", "",
611+
{
612+
{RPCResult::Type::STR_AMOUNT, "balance_change", "The wallet balance change (negative means decrease)."},
613+
}
614+
},
615+
RPCExamples{
616+
HelpExampleCli("simulaterawtransaction", "[\"myhex\"]")
617+
+ HelpExampleRpc("simulaterawtransaction", "[\"myhex\"]")
618+
},
619+
[&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue
620+
{
621+
const std::shared_ptr<const CWallet> rpc_wallet = GetWalletForJSONRPCRequest(request);
622+
if (!rpc_wallet) return UniValue::VNULL;
623+
const CWallet& wallet = *rpc_wallet;
624+
625+
RPCTypeCheck(request.params, {UniValue::VARR, UniValue::VOBJ}, true);
626+
627+
LOCK(wallet.cs_wallet);
628+
629+
UniValue include_watchonly(UniValue::VNULL);
630+
if (request.params[1].isObject()) {
631+
UniValue options = request.params[1];
632+
RPCTypeCheckObj(options,
633+
{
634+
{"include_watchonly", UniValueType(UniValue::VBOOL)},
635+
},
636+
true, true);
637+
638+
include_watchonly = options["include_watchonly"];
639+
}
640+
641+
isminefilter filter = ISMINE_SPENDABLE;
642+
if (ParseIncludeWatchonly(include_watchonly, wallet)) {
643+
filter |= ISMINE_WATCH_ONLY;
644+
}
645+
646+
const auto& txs = request.params[0].get_array();
647+
CAmount changes{0};
648+
std::map<COutPoint, CAmount> new_utxos; // UTXO:s that were made available in transaction array
649+
std::set<COutPoint> spent;
650+
651+
for (size_t i = 0; i < txs.size(); ++i) {
652+
CMutableTransaction mtx;
653+
if (!DecodeHexTx(mtx, txs[i].get_str(), /* try_no_witness */ true, /* try_witness */ true)) {
654+
throw JSONRPCError(RPC_DESERIALIZATION_ERROR, "Transaction hex string decoding failure.");
655+
}
656+
657+
// Fetch previous transactions (inputs)
658+
std::map<COutPoint, Coin> coins;
659+
for (const CTxIn& txin : mtx.vin) {
660+
coins[txin.prevout]; // Create empty map entry keyed by prevout.
661+
}
662+
wallet.chain().findCoins(coins);
663+
664+
// Fetch debit; we are *spending* these; if the transaction is signed and
665+
// broadcast, we will lose everything in these
666+
for (const auto& txin : mtx.vin) {
667+
const auto& outpoint = txin.prevout;
668+
if (spent.count(outpoint)) {
669+
throw JSONRPCError(RPC_INVALID_PARAMETER, "Transaction(s) are spending the same output more than once");
670+
}
671+
if (new_utxos.count(outpoint)) {
672+
changes -= new_utxos.at(outpoint);
673+
new_utxos.erase(outpoint);
674+
} else {
675+
if (coins.at(outpoint).IsSpent()) {
676+
throw JSONRPCError(RPC_INVALID_PARAMETER, "One or more transaction inputs are missing or have been spent already");
677+
}
678+
changes -= wallet.GetDebit(txin, filter);
679+
}
680+
spent.insert(outpoint);
681+
}
682+
683+
// Iterate over outputs; we are *receiving* these, if the wallet considers
684+
// them "mine"; if the transaction is signed and broadcast, we will receive
685+
// everything in these
686+
// Also populate new_utxos in case these are spent in later transactions
687+
688+
const auto& hash = mtx.GetHash();
689+
for (size_t i = 0; i < mtx.vout.size(); ++i) {
690+
const auto& txout = mtx.vout[i];
691+
bool is_mine = 0 < (wallet.IsMine(txout) & filter);
692+
changes += new_utxos[COutPoint(hash, i)] = is_mine ? txout.nValue : 0;
693+
}
694+
}
695+
696+
UniValue result(UniValue::VOBJ);
697+
result.pushKV("balance_change", ValueFromAmount(changes));
698+
699+
return result;
700+
}
701+
};
702+
}
703+
593704
// addresses
594705
RPCHelpMan getaddressinfo();
595706
RPCHelpMan getnewaddress();
@@ -721,6 +832,7 @@ Span<const CRPCCommand> GetWalletRPCCommands()
721832
{"wallet", &setwalletflag},
722833
{"wallet", &signmessage},
723834
{"wallet", &signrawtransactionwithwallet},
835+
{"wallet", &simulaterawtransaction},
724836
{"wallet", &sendall},
725837
{"wallet", &unloadwallet},
726838
{"wallet", &upgradewallet},

test/functional/test_framework/util.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929

3030
def assert_approx(v, vexp, vspan=0.00001):
3131
"""Assert that `v` is within `vspan` of `vexp`"""
32+
if isinstance(v, Decimal) or isinstance(vexp, Decimal):
33+
v=Decimal(v)
34+
vexp=Decimal(vexp)
35+
vspan=Decimal(vspan)
3236
if v < vexp - vspan:
3337
raise AssertionError("%s < [%s..%s]" % (str(v), str(vexp - vspan), str(vexp + vspan)))
3438
if v > vexp + vspan:

test/functional/test_runner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,8 @@
265265
'wallet_implicitsegwit.py --legacy-wallet',
266266
'rpc_named_arguments.py',
267267
'feature_startupnotify.py',
268+
'wallet_simulaterawtx.py --legacy-wallet',
269+
'wallet_simulaterawtx.py --descriptors',
268270
'wallet_listsinceblock.py --legacy-wallet',
269271
'wallet_listsinceblock.py --descriptors',
270272
'wallet_listdescriptors.py --descriptors',
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2021 The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
"""Test simulaterawtransaction.
6+
"""
7+
8+
from decimal import Decimal
9+
from test_framework.blocktools import COINBASE_MATURITY
10+
from test_framework.test_framework import BitcoinTestFramework
11+
from test_framework.util import (
12+
assert_approx,
13+
assert_equal,
14+
assert_raises_rpc_error,
15+
)
16+
17+
class SimulateTxTest(BitcoinTestFramework):
18+
def set_test_params(self):
19+
self.setup_clean_chain = True
20+
self.num_nodes = 1
21+
22+
def skip_test_if_missing_module(self):
23+
self.skip_if_no_wallet()
24+
25+
def setup_network(self, split=False):
26+
self.setup_nodes()
27+
28+
def run_test(self):
29+
node = self.nodes[0]
30+
31+
self.generate(node, 1, sync_fun=self.no_op) # Leave IBD
32+
33+
node.createwallet(wallet_name='w0')
34+
node.createwallet(wallet_name='w1')
35+
node.createwallet(wallet_name='w2', disable_private_keys=True)
36+
w0 = node.get_wallet_rpc('w0')
37+
w1 = node.get_wallet_rpc('w1')
38+
w2 = node.get_wallet_rpc('w2')
39+
40+
self.generatetoaddress(node, COINBASE_MATURITY + 1, w0.getnewaddress())
41+
assert_equal(w0.getbalance(), 50.0)
42+
assert_equal(w1.getbalance(), 0.0)
43+
44+
address1 = w1.getnewaddress()
45+
address2 = w1.getnewaddress()
46+
47+
# Add address1 as watch-only to w2
48+
w2.importpubkey(pubkey=w1.getaddressinfo(address1)["pubkey"])
49+
50+
tx1 = node.createrawtransaction([], [{address1: 5.0}])
51+
tx2 = node.createrawtransaction([], [{address2: 10.0}])
52+
53+
# w0 should be unaffected, w2 should see +5 for tx1
54+
assert_equal(w0.simulaterawtransaction([tx1])["balance_change"], 0.0)
55+
assert_equal(w2.simulaterawtransaction([tx1])["balance_change"], 5.0)
56+
57+
# w1 should see +5 balance for tx1
58+
assert_equal(w1.simulaterawtransaction([tx1])["balance_change"], 5.0)
59+
60+
# w0 should be unaffected, w2 should see +5 for both transactions
61+
assert_equal(w0.simulaterawtransaction([tx1, tx2])["balance_change"], 0.0)
62+
assert_equal(w2.simulaterawtransaction([tx1, tx2])["balance_change"], 5.0)
63+
64+
# w1 should see +15 balance for both transactions
65+
assert_equal(w1.simulaterawtransaction([tx1, tx2])["balance_change"], 15.0)
66+
67+
# w0 funds transaction; it should now see a decrease in (tx fee and payment), and w1 should see the same as above
68+
funding = w0.fundrawtransaction(tx1)
69+
tx1 = funding["hex"]
70+
tx1changepos = funding["changepos"]
71+
bitcoin_fee = Decimal(funding["fee"])
72+
73+
# w0 sees fee + 5 btc decrease, w2 sees + 5 btc
74+
assert_approx(w0.simulaterawtransaction([tx1])["balance_change"], -(Decimal("5") + bitcoin_fee))
75+
assert_approx(w2.simulaterawtransaction([tx1])["balance_change"], Decimal("5"))
76+
77+
# w1 sees same as before
78+
assert_equal(w1.simulaterawtransaction([tx1])["balance_change"], 5.0)
79+
80+
# same inputs (tx) more than once should error
81+
assert_raises_rpc_error(-8, "Transaction(s) are spending the same output more than once", w0.simulaterawtransaction, [tx1,tx1])
82+
83+
tx1ob = node.decoderawtransaction(tx1)
84+
tx1hex = tx1ob["txid"]
85+
tx1vout = 1 - tx1changepos
86+
# tx3 spends new w1 UTXO paying to w0
87+
tx3 = node.createrawtransaction([{"txid": tx1hex, "vout": tx1vout}], {w0.getnewaddress(): 4.9999})
88+
# tx4 spends new w1 UTXO paying to w1
89+
tx4 = node.createrawtransaction([{"txid": tx1hex, "vout": tx1vout}], {w1.getnewaddress(): 4.9999})
90+
91+
# on their own, both should fail due to missing input(s)
92+
assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w0.simulaterawtransaction, [tx3])
93+
assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w1.simulaterawtransaction, [tx3])
94+
assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w0.simulaterawtransaction, [tx4])
95+
assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w1.simulaterawtransaction, [tx4])
96+
97+
# they should succeed when including tx1:
98+
# wallet tx3 tx4
99+
# w0 -5 - bitcoin_fee + 4.9999 -5 - bitcoin_fee
100+
# w1 0 +4.9999
101+
assert_approx(w0.simulaterawtransaction([tx1, tx3])["balance_change"], -Decimal("5") - bitcoin_fee + Decimal("4.9999"))
102+
assert_approx(w1.simulaterawtransaction([tx1, tx3])["balance_change"], 0)
103+
assert_approx(w0.simulaterawtransaction([tx1, tx4])["balance_change"], -Decimal("5") - bitcoin_fee)
104+
assert_approx(w1.simulaterawtransaction([tx1, tx4])["balance_change"], Decimal("4.9999"))
105+
106+
# they should fail if attempting to include both tx3 and tx4
107+
assert_raises_rpc_error(-8, "Transaction(s) are spending the same output more than once", w0.simulaterawtransaction, [tx1, tx3, tx4])
108+
assert_raises_rpc_error(-8, "Transaction(s) are spending the same output more than once", w1.simulaterawtransaction, [tx1, tx3, tx4])
109+
110+
# send tx1 to avoid reusing same UTXO below
111+
node.sendrawtransaction(w0.signrawtransactionwithwallet(tx1)["hex"])
112+
self.generate(node, 1, sync_fun=self.no_op) # Confirm tx to trigger error below
113+
self.sync_all()
114+
115+
# w0 funds transaction 2; it should now see a decrease in (tx fee and payment), and w1 should see the same as above
116+
funding = w0.fundrawtransaction(tx2)
117+
tx2 = funding["hex"]
118+
bitcoin_fee2 = Decimal(funding["fee"])
119+
assert_approx(w0.simulaterawtransaction([tx2])["balance_change"], -(Decimal("10") + bitcoin_fee2))
120+
assert_approx(w1.simulaterawtransaction([tx2])["balance_change"], +(Decimal("10")))
121+
assert_approx(w2.simulaterawtransaction([tx2])["balance_change"], 0)
122+
123+
# w0-w2 error due to tx1 already being mined
124+
assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w0.simulaterawtransaction, [tx1, tx2])
125+
assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w1.simulaterawtransaction, [tx1, tx2])
126+
assert_raises_rpc_error(-8, "One or more transaction inputs are missing or have been spent already", w2.simulaterawtransaction, [tx1, tx2])
127+
128+
if __name__ == '__main__':
129+
SimulateTxTest().main()

0 commit comments

Comments
 (0)