Merge 30708 via rpc_getdescriptoractivity-28

This commit is contained in:
Luke Dashjr 2025-03-05 03:27:08 +00:00
commit 9165f95006
10 changed files with 477 additions and 1 deletions

View File

@ -23,6 +23,9 @@ Supporting RPCs are:
- `listdescriptors` outputs descriptors imported into a descriptor wallet (since v22).
- `scanblocks` takes as input descriptors to scan for in blocks and returns the
relevant blockhashes (since v25).
- `getdescriptoractivity` takes as input descriptors and blockhashes (as output
by `scanblocks`) and returns rich event data related to spends or receives associated
with the given descriptors.
This document describes the language. For the specifics on usage, see the RPC
documentation for the functions mentioned above.

View File

@ -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.

View File

@ -10,6 +10,7 @@
#include <string>
#include <vector>
#include <optional>
class CBlock;
class CBlockHeader;

View File

@ -56,8 +56,10 @@
#include <stdint.h>
#include <condition_variable>
#include <iterator>
#include <memory>
#include <mutex>
#include <vector>
using kernel::CCoinsStats;
using kernel::CoinStatsHashType;
@ -2736,6 +2738,236 @@ 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. Order doesn't matter. Must be along main chain or an error is thrown.\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_AMOUNT, "amount", "The total amount in " + CURRENCY_UNIT + " of the spent output"},
{RPCResult::Type::STR_HEX, "blockhash", /*optional=*/true, "The blockhash this spend appears in (omitted if unconfirmed)"},
{RPCResult::Type::NUM, "height", /*optional=*/true, "Height of the spend (omitted 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, "prevout_spk", "", ScriptPubKeyDoc()},
}},
{RPCResult::Type::OBJ, "", "", {
{RPCResult::Type::STR, "type", "always 'receive'"},
{RPCResult::Type::STR_AMOUNT, "amount", "The total amount in " + CURRENCY_UNIT + " of the new output"},
{RPCResult::Type::STR_HEX, "blockhash", /*optional=*/true, "The block that this receive is in (omitted if unconfirmed)"},
{RPCResult::Type::NUM, "height", /*optional=*/true, "The height of the receive (omitted if unconfirmed)"},
{RPCResult::Type::STR_HEX, "txid", "The txid of the receiving transaction"},
{RPCResult::Type::NUM, "vout", "The vout of the receiving output"},
{RPCResult::Type::OBJ, "output_spk", "", ScriptPubKeyDoc()},
}},
// 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);
struct CompareByHeightAscending {
bool operator()(const CBlockIndex* a, const CBlockIndex* b) const {
return a->nHeight < b->nHeight;
}
};
std::set<const CBlockIndex*, CompareByHeightAscending> blockindexes_sorted;
{
// Validate all given blockhashes, and ensure blocks are along a single chain.
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_sorted.insert(pindex);
}
}
std::set<CScript> scripts_to_watch;
// Determine scripts to watch.
for (const UniValue& scanobject : request.params[1].get_array().getValues()) {
FlatSigningProvider provider;
std::vector<CScript> scripts = EvalDescriptorStringOrObject(scanobject, provider);
for (const CScript& script : scripts) {
scripts_to_watch.insert(script);
}
}
const auto AddSpend = [&](
const CScript& spk,
const CAmount val,
const CTransactionRef& tx,
int vin,
const CTxIn& txin,
const CBlockIndex* index
) {
UniValue event(UniValue::VOBJ);
UniValue spkUv(UniValue::VOBJ);
ScriptToUniv(spk, /*out=*/spkUv, /*include_hex=*/true, /*include_address=*/true);
event.pushKV("type", "spend");
event.pushKV("amount", ValueFromAmount(val));
if (index) {
event.pushKV("blockhash", index->GetBlockHash().ToString());
event.pushKV("height", index->nHeight);
}
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);
event.pushKV("prevout_spk", spkUv);
return event;
};
const auto AddReceive = [&](const CTxOut& txout, const CBlockIndex* index, int vout, const CTransactionRef& tx) {
UniValue event(UniValue::VOBJ);
UniValue spkUv(UniValue::VOBJ);
ScriptToUniv(txout.scriptPubKey, /*out=*/spkUv, /*include_hex=*/true, /*include_address=*/true);
event.pushKV("type", "receive");
event.pushKV("amount", ValueFromAmount(txout.nValue));
if (index) {
event.pushKV("blockhash", index->GetBlockHash().ToString());
event.pushKV("height", index->nHeight);
}
event.pushKV("txid", tx->GetHash().ToString());
event.pushKV("vout", vout);
event.pushKV("output_spk", spkUv);
return event;
};
BlockManager* blockman;
Chainstate& active_chainstate = chainman.ActiveChainstate();
{
LOCK(::cs_main);
blockman = CHECK_NONFATAL(&active_chainstate.m_blockman);
}
for (const CBlockIndex* blockindex : blockindexes_sorted) {
const CBlock block{GetBlockChecked(chainman.m_blockman, *blockindex)};
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 (!tx->IsCoinBase()) {
// skip coinbase; spends can't happen there.
const auto& txundo = block_undo.vtxundo.at(i - 1);
for (size_t vin_idx = 0; vin_idx < tx->vin.size(); ++vin_idx) {
const auto& coin = txundo.vprevout.at(vin_idx);
const auto& txin = tx->vin.at(vin_idx);
if (scripts_to_watch.contains(coin.out.scriptPubKey)) {
activity.push_back(AddSpend(
coin.out.scriptPubKey, coin.out.nValue, tx, vin_idx, txin, blockindex));
}
}
}
for (size_t vout_idx = 0; vout_idx < tx->vout.size(); ++vout_idx) {
const auto& vout = tx->vout.at(vout_idx);
if (scripts_to_watch.contains(vout.scriptPubKey)) {
activity.push_back(AddReceive(vout, blockindex, vout_idx, 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);
const CCoinsViewCache& coins_view = &active_chainstate.CoinsTip();
for (const CTxMemPoolEntry& e : mempool.entryAll()) {
const auto& tx = e.GetSharedTx();
for (size_t vin_idx = 0; vin_idx < tx->vin.size(); ++vin_idx) {
CScript scriptPubKey;
CAmount value;
const auto& txin = tx->vin.at(vin_idx);
Coin coin;
const bool have_coin = coins_view.GetCoin(txin.prevout, coin);
// Check if the previous output is in the chain
if (!have_coin) {
// If not found in the chain, check the mempool. Likely, this is a
// child transaction of another transaction in the mempool.
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.contains(scriptPubKey)) {
UniValue event(UniValue::VOBJ);
activity.push_back(AddSpend(
scriptPubKey, value, tx, vin_idx, txin, nullptr));
}
}
for (size_t vout_idx = 0; vout_idx < tx->vout.size(); ++vout_idx) {
const auto& vout = tx->vout.at(vout_idx);
if (scripts_to_watch.contains(vout.scriptPubKey)) {
activity.push_back(AddReceive(vout, nullptr, vout_idx, tx));
}
}
}
}
ret.pushKV("activity", activity);
return ret;
},
};
}
static RPCHelpMan getblockfilter()
{
return RPCHelpMan{"getblockfilter",
@ -3342,6 +3574,7 @@ void RegisterBlockchainRPCCommands(CRPCTable& t)
{"blockchain", &preciousblock},
{"blockchain", &scantxoutset},
{"blockchain", &scanblocks},
{"blockchain", &getdescriptoractivity},
{"blockchain", &getblockfilter},
{"blockchain", &dumptxoutset},
{"blockchain", &loadtxoutset},

View File

@ -96,6 +96,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" },
{ "dumptxoutset", 1, "format" },
{ "dumptxoutset", 2, "show_header" },

View File

@ -89,7 +89,7 @@ static void TxToJSON(const CTransaction& tx, const uint256 hashBlock, UniValue&
}
}
static std::vector<RPCResult> ScriptPubKeyDoc() {
std::vector<RPCResult> ScriptPubKeyDoc() {
return
{
{RPCResult::Type::STR, "asm", "Disassembly of the output script"},

View File

@ -514,6 +514,8 @@ private:
size_t GetParamIndex(std::string_view key) const;
};
std::vector<RPCResult> ScriptPubKeyDoc();
/**
* Push warning messages to an RPC "warnings" field as a JSON array of strings.
*

View File

@ -133,6 +133,7 @@ const std::vector<std::string> RPC_COMMANDS_SAFE_FOR_FUZZING{
"getchaintxstats",
"getconnectioncount",
"getdeploymentinfo",
"getdescriptoractivity",
"getdescriptorinfo",
"getdifficulty",
"getindexinfo",

View File

@ -0,0 +1,226 @@
#!/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, MiniWalletMode, 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_invalid_descriptor(node, wallet)
self.test_confirmed_and_unconfirmed(node, wallet)
self.test_receive_then_spend(node, wallet)
self.test_no_address(node, wallet)
def test_no_activity(self, node):
_, _, 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(address_type='bech32m')
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)
assert_equal(list(result.keys()), ['activity'])
[activity] = result['activity']
for k, v in {
'amount': Decimal('1.00000000'),
'blockhash': blockhash,
'height': 201,
'txid': txid,
'type': 'receive',
'vout': 1,
}.items():
assert_equal(activity[k], v)
outspk = activity['output_spk']
assert_equal(outspk['asm'][:2], '1 ')
assert_equal(outspk['desc'].split('(')[0], 'rawtr')
assert_equal(outspk['hex'], spk_1.hex())
assert_equal(outspk['address'], addr_1)
assert_equal(outspk['type'], 'witness_v1_taproot')
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)
# Duplicate address specification is fine.
assert_equal(
result,
node.getdescriptoractivity([blockhash], [
f"addr({addr_1})", f"addr({addr_1})", f"addr({addr_2})"], True))
# Flipping descriptor order doesn't affect results.
result_flipped = node.getdescriptoractivity(
[blockhash], [f"addr({addr_2})", f"addr({addr_1})"], True)
assert_equal(result, result_flipped)
[a1] = [a for a in result['activity'] if a['output_spk']['address'] == addr_1]
[a2] = [a for a in result['activity'] if a['output_spk']['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_invalid_descriptor(self, node, wallet):
blockhash = self.generate(node, 1)[0]
_, _, addr_1 = getnewdestination()
assert_raises_rpc_error(
-5, "is not a valid descriptor",
node.getdescriptoractivity, [blockhash], [f"addrx({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.get('blockhash') == blockhash]
assert confirmed['txid'] == txid_1
assert confirmed['height'] == node.getblockchaininfo()['blocks']
[unconfirmed] = [a for a in activity if not a.get('blockhash')]
assert 'blockhash' not in unconfirmed
assert 'height' not in unconfirmed
assert any(a['txid'] == txid_2 for a in activity if not a.get('blockhash'))
def test_receive_then_spend(self, node, wallet):
"""Also important because this tests multiple blockhashes."""
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
# Test that reversing the blockorder yields the same result.
assert_equal(result, node.getdescriptoractivity(
[blockhash_1, blockhash_2], [wallet.get_descriptor()], True))
# Test that duplicating a blockhash yields the same result.
assert_equal(result, node.getdescriptoractivity(
[blockhash_1, blockhash_2, blockhash_2], [wallet.get_descriptor()], True))
def test_no_address(self, node, wallet):
raw_wallet = MiniWallet(self.nodes[0], mode=MiniWalletMode.RAW_P2PK)
raw_wallet.generate(100, invalid_call=False)
no_addr_tx = raw_wallet.send_self_transfer(from_node=node)
raw_desc = raw_wallet.get_descriptor()
blockhash = self.generate(node, 1)[0]
result = node.getdescriptoractivity([blockhash], [raw_desc], False)
assert_equal(len(result['activity']), 2)
a1 = result['activity'][0]
a2 = result['activity'][1]
assert a1['type'] == "spend"
assert a1['blockhash'] == blockhash
# sPK lacks address.
assert_equal(list(a1['prevout_spk'].keys()), ['asm', 'desc', 'hex', 'type'])
assert a1['amount'] == no_addr_tx["fee"] + Decimal(no_addr_tx["tx"].vout[0].nValue) / COIN
assert a2['type'] == "receive"
assert a2['blockhash'] == blockhash
# sPK lacks address.
assert_equal(list(a2['output_spk'].keys()), ['asm', 'desc', 'hex', 'type'])
assert a2['amount'] == Decimal(no_addr_tx["tx"].vout[0].nValue) / COIN
if __name__ == '__main__':
GetBlocksActivityTest(__file__).main()

View File

@ -380,6 +380,7 @@ BASE_SCRIPTS = [
'rpc_deriveaddresses.py --usecli',
'p2p_ping.py',
'p2p_tx_privacy.py',
'rpc_getdescriptoractivity.py',
'rpc_scanblocks.py',
'p2p_sendtxrcncl.py',
'rpc_scantxoutset.py',