diff --git a/doc/release-notes-30708.md b/doc/release-notes-30708.md new file mode 100644 index 00000000000000..5cf17c7b6506b1 --- /dev/null +++ b/doc/release-notes-30708.md @@ -0,0 +1,6 @@ +New RPCs +-------- + +- `getdescriptoractivity` can be used to find all spend/receive activity relevant to + a given set of descriptors within a set of specified blocks. This call can be used with + `scanblocks` to lessen the need for additional indexing programs. diff --git a/src/core_io.h b/src/core_io.h index 9305bb72393117..53718fa8c0441f 100644 --- a/src/core_io.h +++ b/src/core_io.h @@ -10,6 +10,7 @@ #include #include +#include class CBlock; class CBlockHeader; @@ -46,5 +47,6 @@ std::string EncodeHexTx(const CTransaction& tx); std::string SighashToStr(unsigned char sighash_type); void ScriptToUniv(const CScript& script, UniValue& out, bool include_hex = true, bool include_address = false, const SigningProvider* provider = nullptr); void TxToUniv(const CTransaction& tx, const uint256& block_hash, UniValue& entry, bool include_hex = true, const CTxUndo* txundo = nullptr, TxVerbosity verbosity = TxVerbosity::SHOW_DETAILS); +std::optional ScriptToAddress(const CScript& script); #endif // BITCOIN_CORE_IO_H diff --git a/src/core_write.cpp b/src/core_write.cpp index 253dfde1006b2f..f77dd7510ad679 100644 --- a/src/core_write.cpp +++ b/src/core_write.cpp @@ -168,6 +168,17 @@ void ScriptToUniv(const CScript& script, UniValue& out, bool include_hex, bool i out.pushKV("type", GetTxnOutputType(type)); } +std::optional ScriptToAddress(const CScript& script) +{ + CTxDestination address; + ExtractDestination(script, address); + std::string addr = EncodeDestination(address); + if (addr.size() > 0) { + return addr; + } + return {}; +} + void TxToUniv(const CTransaction& tx, const uint256& block_hash, UniValue& entry, bool include_hex, const CTxUndo* txundo, TxVerbosity verbosity) { CHECK_NONFATAL(verbosity >= TxVerbosity::SHOW_DETAILS); diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 4894cecfbda05f..9ff96ab300236f 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -2585,6 +2585,231 @@ static RPCHelpMan scanblocks() }; } +static RPCHelpMan getdescriptoractivity() +{ + return RPCHelpMan{"getdescriptoractivity", + "\nGet spend and receive activity associated with a set of descriptors for a set of blocks. " + "This command pairs well with the `relevant_blocks` output of `scanblocks()`.\n" + "This call may take several minutes. If you encounter timeouts, try specifying no RPC timeout (bitcoin-cli -rpcclienttimeout=0)", + { + RPCArg{"blockhashes", RPCArg::Type::ARR, RPCArg::Optional::OMITTED, "The list of blockhashes to examine for activity\n", { + {"blockhash", RPCArg::Type::STR_HEX, RPCArg::Optional::OMITTED, "A valid blockhash"}, + }}, + scan_objects_arg_desc, + {"include_mempool", RPCArg::Type::BOOL, RPCArg::Default{true}, "Whether to include unconfirmed activity"}, + }, + RPCResult{ + RPCResult::Type::OBJ, "", "", { + {RPCResult::Type::ARR, "activity", "events", { + {RPCResult::Type::OBJ, "", "", { + {RPCResult::Type::STR, "type", "always 'spend'"}, + {RPCResult::Type::STR, "address", "The address being spent from"}, + {RPCResult::Type::STR_HEX, "scriptpubkey_hex", "A hex string of the scriptPubKey being spent from"}, + {RPCResult::Type::STR, "desc", "The inferred descriptor being spent from"}, + {RPCResult::Type::STR_AMOUNT, "amount", "The total amount in " + CURRENCY_UNIT + " of the spent output"}, + {RPCResult::Type::STR_HEX, "blockhash", "The blockhash this spend appears in. Empty if in mempool"}, + {RPCResult::Type::NUM, "height", "Height of the spend (-1 if unconfirmed)"}, + {RPCResult::Type::STR_HEX, "spend_txid", "The txid of the spending transaction"}, + {RPCResult::Type::NUM, "spend_vout", "The vout of the spend"}, + {RPCResult::Type::STR_HEX, "prevout_txid", "The txid of the prevout"}, + {RPCResult::Type::NUM, "prevout_vout", "The vout of the prevout"}, + }}, + {RPCResult::Type::OBJ, "", "", { + {RPCResult::Type::STR, "type", "always 'receive'"}, + {RPCResult::Type::STR, "address", "The address receiving value"}, + {RPCResult::Type::STR_HEX, "scriptpubkey_hex", "A hex string of the scriptPubKey receiving value"}, + {RPCResult::Type::STR, "desc", "The inferred descriptor receiving value"}, + {RPCResult::Type::STR_AMOUNT, "amount", "The total amount in " + CURRENCY_UNIT + " of the new output"}, + {RPCResult::Type::STR_HEX, "blockhash", "The block that this receive is in"}, + {RPCResult::Type::NUM, "height", "Height of the receive (-1 if unconfirmed)"}, + {RPCResult::Type::STR_HEX, "txid", "Txid of the receiving transaction"}, + {RPCResult::Type::NUM, "vout", "Vout of the receiving output"}, + }}, + // TODO is the skip_type_check avoidable with a heterogeneous ARR? + }, /*skip_type_check=*/true}, + }, + }, + RPCExamples{ + HelpExampleCli("getdescriptoractivity", "'[\"000000000000000000001347062c12fded7c528943c8ce133987e2e2f5a840ee\"]' '[\"addr(bc1qzl6nsgqzu89a66l50cvwapnkw5shh23zarqkw9)\"]'") + }, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue +{ + UniValue ret(UniValue::VOBJ); + UniValue activity(UniValue::VARR); + NodeContext& node = EnsureAnyNodeContext(request.context); + ChainstateManager& chainman = EnsureChainman(node); + std::vector blockindexes; + + { + // Validate all given blockhashes. + LOCK(::cs_main); + for (const UniValue& blockhash : request.params[0].get_array().getValues()) { + uint256 bhash = ParseHashV(blockhash, "blockhash"); + CBlockIndex* pindex = chainman.m_blockman.LookupBlockIndex(bhash); + if (!pindex) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Block not found"); + } + if (!chainman.ActiveChain().Contains(pindex)) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "Block is not in main chain"); + } + blockindexes.push_back(pindex); + } + } + + std::set scripts_to_watch; + std::map descriptors_watched; + + // Determine scripts to watch + for (const UniValue& scanobject : request.params[1].get_array().getValues()) { + FlatSigningProvider provider; + std::vector scripts = EvalDescriptorStringOrObject(scanobject, provider); + + for (const CScript& script : scripts) { + scripts_to_watch.insert(script); + descriptors_watched.emplace(script, InferDescriptor(script, provider)->ToString()); + } + } + + const auto AddSpend = [&]( + const CScript& spk, + const CAmount val, + const CTransactionRef& tx, + int vin, + const CTxIn& txin, + const CBlockIndex* index + ) { + UniValue event(UniValue::VOBJ); + event.pushKV("type", "spend"); + event.pushKV("address", ScriptToAddress(spk).value_or("")); + event.pushKV("scriptpubkey_hex", HexStr(spk)); + event.pushKV("desc", descriptors_watched.at(spk)); + event.pushKV("amount", ValueFromAmount(val)); + event.pushKV("blockhash", index ? index->GetBlockHash().ToString() : ""); + event.pushKV("height", index ? index->nHeight : -1); + event.pushKV("spend_txid", tx->GetHash().ToString()); + event.pushKV("spend_vin", vin); + event.pushKV("prevout_txid", txin.prevout.hash.ToString()); + event.pushKV("prevout_vout", txin.prevout.n); + + return event; + }; + + const auto AddReceive = [&](const CTxOut& txout, const CBlockIndex* index, int vout, const CTransactionRef& tx) { + UniValue event(UniValue::VOBJ); + event.pushKV("type", "receive"); + event.pushKV("address", ScriptToAddress(txout.scriptPubKey).value_or("")); + event.pushKV("scriptpubkey_hex", HexStr(txout.scriptPubKey)); + event.pushKV("desc", descriptors_watched.at(txout.scriptPubKey)); + event.pushKV("amount", ValueFromAmount(txout.nValue)); + event.pushKV("blockhash", index ? index->GetBlockHash().ToString() : ""); + event.pushKV("height", index ? index->nHeight : -1); + event.pushKV("txid", tx->GetHash().ToString()); + event.pushKV("vout", vout); + + return event; + }; + + BlockManager* blockman; + Chainstate& active_chainstate = chainman.ActiveChainstate(); + { + LOCK(::cs_main); + blockman = CHECK_NONFATAL(&active_chainstate.m_blockman); + } + + for (const CBlockIndex* blockindex : blockindexes) { + const std::vector block_data{GetRawBlockChecked(chainman.m_blockman, *blockindex)}; + DataStream block_stream{block_data}; + CBlock block{}; + block_stream >> TX_WITH_WITNESS(block); + + const CBlockUndo block_undo{GetUndoChecked(*blockman, *blockindex)}; + + for (size_t i = 0; i < block.vtx.size(); ++i) { + const auto& tx = block.vtx.at(i); + + if (i > 0) { + // skip coinbase; spends can't happen there. + const auto& txundo = block_undo.vtxundo.at(i - 1); + + for (size_t vinIdx = 0; vinIdx < tx->vin.size(); ++vinIdx) { + const auto& coin = txundo.vprevout.at(vinIdx); + const auto& txin = tx->vin.at(vinIdx); + if (scripts_to_watch.find(coin.out.scriptPubKey) != scripts_to_watch.end()) { + activity.push_back(AddSpend( + coin.out.scriptPubKey, coin.out.nValue, tx, vinIdx, txin, blockindex)); + } + } + } + + for (size_t voutIdx = 0; voutIdx < tx->vout.size(); ++voutIdx) { + const auto& vout = tx->vout.at(voutIdx); + if (scripts_to_watch.find(vout.scriptPubKey) != scripts_to_watch.end()) { + activity.push_back(AddReceive(vout, blockindex, voutIdx, tx)); + } + } + } + } + + bool search_mempool = true; + if (!request.params[2].isNull()) + search_mempool = request.params[2].get_bool(); + + if (search_mempool) { + const CTxMemPool& mempool = EnsureMemPool(node); + LOCK(::cs_main); + LOCK(mempool.cs); + for (const CTxMemPoolEntry& e : mempool.entryAll()) { + const auto& tx = e.GetSharedTx(); + + const CCoinsViewCache& coins_view = &active_chainstate.CoinsTip(); + CScript scriptPubKey; + CAmount value; + + for (size_t vinIdx = 0; vinIdx < tx->vin.size(); ++vinIdx) { + const auto& txin = tx->vin.at(vinIdx); + std::optional coin = coins_view.GetCoin(txin.prevout); + + // Check if the previous output is in the chain + if (!coin) { + // If not found in the chain, check the mempool. Likely, a child + // transaction in the mempool has spent the coin. + CTransactionRef prev_tx = CHECK_NONFATAL(mempool.get(txin.prevout.hash)); + + if (txin.prevout.n >= prev_tx->vout.size()) { + throw std::runtime_error("Invalid output index"); + } + const CTxOut& out = prev_tx->vout[txin.prevout.n]; + scriptPubKey = out.scriptPubKey; + value = out.nValue; + } else { + // Coin found in the chain + const CTxOut& out = coin->out; + scriptPubKey = out.scriptPubKey; + value = out.nValue; + } + + if (scripts_to_watch.find(scriptPubKey) != scripts_to_watch.end()) { + UniValue event(UniValue::VOBJ); + activity.push_back(AddSpend( + scriptPubKey, value, tx, vinIdx, txin, nullptr)); + } + } + + for (size_t voutIdx = 0; voutIdx < tx->vout.size(); ++voutIdx) { + const auto& vout = tx->vout.at(voutIdx); + if (scripts_to_watch.find(vout.scriptPubKey) != scripts_to_watch.end()) { + activity.push_back(AddReceive(vout, nullptr, voutIdx, tx)); + } + } + } + } + + ret.pushKV("activity", activity); + return ret; +}, + }; +} + static RPCHelpMan getblockfilter() { return RPCHelpMan{"getblockfilter", @@ -3152,6 +3377,7 @@ void RegisterBlockchainRPCCommands(CRPCTable& t) {"blockchain", &preciousblock}, {"blockchain", &scantxoutset}, {"blockchain", &scanblocks}, + {"blockchain", &getdescriptoractivity}, {"blockchain", &getblockfilter}, {"blockchain", &dumptxoutset}, {"blockchain", &loadtxoutset}, diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index 601e4fa7bf572c..23461a0cfa6771 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -92,6 +92,9 @@ static const CRPCConvertParam vRPCConvertParams[] = { "scanblocks", 3, "stop_height" }, { "scanblocks", 5, "options" }, { "scanblocks", 5, "filter_false_positives" }, + { "getdescriptoractivity", 0, "blockhashes" }, + { "getdescriptoractivity", 1, "scanobjects" }, + { "getdescriptoractivity", 2, "include_mempool" }, { "scantxoutset", 1, "scanobjects" }, { "addmultisigaddress", 0, "nrequired" }, { "addmultisigaddress", 1, "keys" }, diff --git a/src/test/fuzz/rpc.cpp b/src/test/fuzz/rpc.cpp index 4db37ab7b7ae6d..9be21677937e9b 100644 --- a/src/test/fuzz/rpc.cpp +++ b/src/test/fuzz/rpc.cpp @@ -130,6 +130,7 @@ const std::vector RPC_COMMANDS_SAFE_FOR_FUZZING{ "getchaintxstats", "getconnectioncount", "getdeploymentinfo", + "getdescriptoractivity", "getdescriptorinfo", "getdifficulty", "getindexinfo", diff --git a/test/functional/rpc_getdescriptoractivity.py b/test/functional/rpc_getdescriptoractivity.py new file mode 100755 index 00000000000000..e1c4be84e0c392 --- /dev/null +++ b/test/functional/rpc_getdescriptoractivity.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# Copyright (c) 2024-present The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +from decimal import Decimal + +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal, assert_raises_rpc_error +from test_framework.messages import COIN +from test_framework.wallet import MiniWallet, getnewdestination + + +class GetBlocksActivityTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + self.setup_clean_chain = True + + def run_test(self): + node = self.nodes[0] + wallet = MiniWallet(node) + node.setmocktime(node.getblockheader(node.getbestblockhash())['time']) + wallet.generate(200, invalid_call=False) + + self.test_no_activity(node) + self.test_activity_in_block(node, wallet) + self.test_no_mempool_inclusion(node, wallet) + self.test_multiple_addresses(node, wallet) + self.test_invalid_blockhash(node, wallet) + self.test_confirmed_and_unconfirmed(node, wallet) + self.test_receive_then_spend(node, wallet) + + def test_no_activity(self, node): + _, spk_1, addr_1 = getnewdestination() + result = node.getdescriptoractivity([], [f"addr({addr_1})"], True) + assert_equal(len(result['activity']), 0) + + def test_activity_in_block(self, node, wallet): + _, spk_1, addr_1 = getnewdestination() + txid = wallet.send_to(from_node=node, scriptPubKey=spk_1, amount=1 * COIN)['txid'] + blockhash = self.generate(node, 1)[0] + + # Test getdescriptoractivity with the specific blockhash + result = node.getdescriptoractivity([blockhash], [f"addr({addr_1})"], True) + + for k, v in { + 'address': addr_1, + 'amount': Decimal('1.00000000'), + 'blockhash': blockhash, + 'desc': 'rawtr', # partial + 'height': 201, + 'scriptpubkey_hex': spk_1.hex(), + 'txid': txid, + 'type': 'receive', + 'vout': 1, + }.items(): + if k == 'desc': + assert_equal(result['activity'][0][k].split('(')[0], v) + else: + assert_equal(result['activity'][0][k], v) + + + def test_no_mempool_inclusion(self, node, wallet): + _, spk_1, addr_1 = getnewdestination() + wallet.send_to(from_node=node, scriptPubKey=spk_1, amount=1 * COIN) + + _, spk_2, addr_2 = getnewdestination() + wallet.send_to( + from_node=node, scriptPubKey=spk_2, amount=1 * COIN) + + # Do not generate a block to keep the transaction in the mempool + + result = node.getdescriptoractivity([], [f"addr({addr_1})", f"addr({addr_2})"], False) + + assert_equal(len(result['activity']), 0) + + def test_multiple_addresses(self, node, wallet): + _, spk_1, addr_1 = getnewdestination() + _, spk_2, addr_2 = getnewdestination() + wallet.send_to(from_node=node, scriptPubKey=spk_1, amount=1 * COIN) + wallet.send_to(from_node=node, scriptPubKey=spk_2, amount=2 * COIN) + + blockhash = self.generate(node, 1)[0] + + result = node.getdescriptoractivity([blockhash], [f"addr({addr_1})", f"addr({addr_2})"], True) + + assert_equal(len(result['activity']), 2) + + [a1] = [a for a in result['activity'] if a['address'] == addr_1] + [a2] = [a for a in result['activity'] if a['address'] == addr_2] + + assert a1['blockhash'] == blockhash + assert a1['amount'] == 1.0 + + assert a2['blockhash'] == blockhash + assert a2['amount'] == 2.0 + + def test_invalid_blockhash(self, node, wallet): + self.generate(node, 20) # Generate to get more fees + + _, spk_1, addr_1 = getnewdestination() + wallet.send_to(from_node=node, scriptPubKey=spk_1, amount=1 * COIN) + + invalid_blockhash = "0000000000000000000000000000000000000000000000000000000000000000" + + assert_raises_rpc_error( + -5, "Block not found", + node.getdescriptoractivity, [invalid_blockhash], [f"addr({addr_1})"], True) + + def test_confirmed_and_unconfirmed(self, node, wallet): + self.generate(node, 20) # Generate to get more fees + + _, spk_1, addr_1 = getnewdestination() + txid_1 = wallet.send_to( + from_node=node, scriptPubKey=spk_1, amount=1 * COIN)['txid'] + blockhash = self.generate(node, 1)[0] + + _, spk_2, to_addr = getnewdestination() + txid_2 = wallet.send_to( + from_node=node, scriptPubKey=spk_2, amount=1 * COIN)['txid'] + + result = node.getdescriptoractivity( + [blockhash], [f"addr({addr_1})", f"addr({to_addr})"], True) + + activity = result['activity'] + assert_equal(len(activity), 2) + + [confirmed] = [a for a in activity if a['blockhash'] == blockhash] + assert confirmed['txid'] == txid_1 + assert confirmed['height'] == node.getblockchaininfo()['blocks'] + + [unconfirmed] = [a for a in activity if not a['blockhash']] + assert unconfirmed['blockhash'] == "" + assert unconfirmed['height'] == -1 + + assert any(a['txid'] == txid_2 for a in activity if a['blockhash'] == "") + + def test_receive_then_spend(self, node, wallet): + self.generate(node, 20) # Generate to get more fees + + sent1 = wallet.send_self_transfer(from_node=node) + utxo = sent1['new_utxo'] + blockhash_1 = self.generate(node, 1)[0] + + sent2 = wallet.send_self_transfer(from_node=node, utxo_to_spend=utxo) + blockhash_2 = self.generate(node, 1)[0] + + result = node.getdescriptoractivity( + [blockhash_1, blockhash_2], [wallet.get_descriptor()], True) + + assert_equal(len(result['activity']), 4) + + assert result['activity'][1]['type'] == 'receive' + assert result['activity'][1]['txid'] == sent1['txid'] + assert result['activity'][1]['blockhash'] == blockhash_1 + + assert result['activity'][2]['type'] == 'spend' + assert result['activity'][2]['spend_txid'] == sent2['txid'] + assert result['activity'][2]['prevout_txid'] == sent1['txid'] + assert result['activity'][2]['blockhash'] == blockhash_2 + + +if __name__ == '__main__': + GetBlocksActivityTest(__file__).main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 3d8c230066304c..0233209c0b4a80 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -378,6 +378,7 @@ 'rpc_deriveaddresses.py --usecli', 'p2p_ping.py', 'p2p_tx_privacy.py', + 'rpc_getdescriptoractivity.py', 'rpc_scanblocks.py', 'p2p_sendtxrcncl.py', 'rpc_scantxoutset.py',