Merge bitcoin/bitcoin#33230: cli: Handle arguments that can be either JSON or string

df67bb6fd8 test: Remove convert_to_json_for_cli (Ava Chow)
44a493e150 cli: Allow arguments to be both strings and json (Ava Chow)

Pull request description:

  There are some RPCs where the argument can be either JSON that needs to be parsed, or a string that we can pass straight through. However, `bitcoin-cli` would always parse those arguments as JSON which makes for some cumbersome argument passing when using those RPCs. Notably, `hash_or_height` in `getblockstats` and `gettxoutsetinfo` do this, and results in a more cumbersome command of `bitcoin-cli getblockstats '"<hash>"'`. Otherwise, using a normal invocation of `bitcoin-cli getblockstats <hash>` results in `error: Error parsing JSON`. This PR marks those particular options as also being a string so that when `bitcoin-cli` fails to parse the argument as JSON, it will assume that the argument is a string and pass it straight through.

ACKs for top commit:
  ryanofsky:
    Code review ACK df67bb6fd8, just rebased since last review. I do still think it would be good to improve the test (https://github.com/bitcoin/bitcoin/pull/33230#discussion_r2369570345)
  rkrux:
    Light code review, lgtm ACK df67bb6fd8
  mzumsande:
    Code Review ACK df67bb6fd8

Tree-SHA512: 6c488570fbb24d0cf10508416c56accfc7af5163b7a7187d22d78c812424a9e3ecc95906d3e295fbf6af54bf80903aa448fd879dd6a9944ba8b4d1a33eb29ef2
This commit is contained in:
merge-script committed 2025-09-25 15:50:56 -04:00
commit e62e0a12b3
8 files changed
+37 -32

No files matched your search

+23 -11
View File
@@ -18,6 +18,7 @@ public:
std::string methodName; //!< method whose params want conversion std::string methodName; //!< method whose params want conversion
int paramIdx; //!< 0-based idx of param to convert int paramIdx; //!< 0-based idx of param to convert
std::string paramName; //!< parameter name std::string paramName; //!< parameter name
bool also_string{false}; //!< The parameter is also a string
}; };
// clang-format off // clang-format off
@@ -188,10 +189,10 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "gettxout", 1, "n" }, { "gettxout", 1, "n" },
{ "gettxout", 2, "include_mempool" }, { "gettxout", 2, "include_mempool" },
{ "gettxoutproof", 0, "txids" }, { "gettxoutproof", 0, "txids" },
{ "gettxoutsetinfo", 1, "hash_or_height" }, { "gettxoutsetinfo", 1, "hash_or_height", /*also_string=*/true },
{ "gettxoutsetinfo", 2, "use_index"}, { "gettxoutsetinfo", 2, "use_index"},
{ "dumptxoutset", 2, "options" }, { "dumptxoutset", 2, "options" },
{ "dumptxoutset", 2, "rollback" }, { "dumptxoutset", 2, "rollback", /*also_string=*/true },
{ "lockunspent", 0, "unlock" }, { "lockunspent", 0, "unlock" },
{ "lockunspent", 1, "transactions" }, { "lockunspent", 1, "transactions" },
{ "lockunspent", 2, "persistent" }, { "lockunspent", 2, "persistent" },
@@ -246,7 +247,7 @@ static const CRPCConvertParam vRPCConvertParams[] =
{ "listdescriptors", 0, "private" }, { "listdescriptors", 0, "private" },
{ "verifychain", 0, "checklevel" }, { "verifychain", 0, "checklevel" },
{ "verifychain", 1, "nblocks" }, { "verifychain", 1, "nblocks" },
{ "getblockstats", 0, "hash_or_height" }, { "getblockstats", 0, "hash_or_height", /*also_string=*/true },
{ "getblockstats", 1, "stats" }, { "getblockstats", 1, "stats" },
{ "pruneblockchain", 0, "height" }, { "pruneblockchain", 0, "height" },
{ "keypoolrefill", 0, "newsize" }, { "keypoolrefill", 0, "newsize" },
@@ -318,18 +319,21 @@ static const CRPCConvertParam vRPCConvertParams[] =
// clang-format on // clang-format on
/** Parse string to UniValue or throw runtime_error if string contains invalid JSON */ /** Parse string to UniValue or throw runtime_error if string contains invalid JSON */
static UniValue Parse(std::string_view raw) static UniValue Parse(std::string_view raw, bool also_string)
{ {
UniValue parsed; UniValue parsed;
if (!parsed.read(raw)) throw std::runtime_error(tfm::format("Error parsing JSON: %s", raw)); if (!parsed.read(raw)) {
if (!also_string) throw std::runtime_error(tfm::format("Error parsing JSON: %s", raw));
return raw;
}
return parsed; return parsed;
} }
class CRPCConvertTable class CRPCConvertTable
{ {
private: private:
std::set<std::pair<std::string, int>> members; std::map<std::pair<std::string, int>, bool> members;
std::set<std::pair<std::string, std::string>> membersByName; std::map<std::pair<std::string, std::string>, bool> membersByName;
public: public:
CRPCConvertTable(); CRPCConvertTable();
@@ -337,21 +341,29 @@ public:
/** Return arg_value as UniValue, and first parse it if it is a non-string parameter */ /** Return arg_value as UniValue, and first parse it if it is a non-string parameter */
UniValue ArgToUniValue(std::string_view arg_value, const std::string& method, int param_idx) UniValue ArgToUniValue(std::string_view arg_value, const std::string& method, int param_idx)
{ {
return members.count({method, param_idx}) > 0 ? Parse(arg_value) : arg_value; const auto& it = members.find({method, param_idx});
if (it != members.end()) {
return Parse(arg_value, it->second);
}
return arg_value;
} }
/** Return arg_value as UniValue, and first parse it if it is a non-string parameter */ /** Return arg_value as UniValue, and first parse it if it is a non-string parameter */
UniValue ArgToUniValue(std::string_view arg_value, const std::string& method, const std::string& param_name) UniValue ArgToUniValue(std::string_view arg_value, const std::string& method, const std::string& param_name)
{ {
return membersByName.count({method, param_name}) > 0 ? Parse(arg_value) : arg_value; const auto& it = membersByName.find({method, param_name});
if (it != membersByName.end()) {
return Parse(arg_value, it->second);
}
return arg_value;
} }
}; };
CRPCConvertTable::CRPCConvertTable() CRPCConvertTable::CRPCConvertTable()
{ {
for (const auto& cp : vRPCConvertParams) { for (const auto& cp : vRPCConvertParams) {
members.emplace(cp.methodName, cp.paramIdx); members.emplace(std::make_pair(cp.methodName, cp.paramIdx), cp.also_string);
membersByName.emplace(cp.methodName, cp.paramName); membersByName.emplace(std::make_pair(cp.methodName, cp.paramName), cp.also_string);
} }
} }
+1 -1
View File
@@ -481,7 +481,7 @@ class AssumeutxoTest(BitcoinTestFramework):
# Use a hash instead of a height # Use a hash instead of a height
prev_snap_hash = n0.getblockhash(prev_snap_height) prev_snap_hash = n0.getblockhash(prev_snap_height)
dump_output5 = n0.dumptxoutset('utxos5.dat', rollback=self.convert_to_json_for_cli(prev_snap_hash)) dump_output5 = n0.dumptxoutset('utxos5.dat', rollback=prev_snap_hash)
assert_equal(sha256sum_file(dump_output4['path']), sha256sum_file(dump_output5['path'])) assert_equal(sha256sum_file(dump_output4['path']), sha256sum_file(dump_output5['path']))
# Ensure n0 is back at the tip # Ensure n0 is back at the tip
+3 -3
View File
@@ -103,7 +103,7 @@ class CoinStatsIndexTest(BitcoinTestFramework):
assert_equal(res0, res2) assert_equal(res0, res2)
# Fetch old stats by hash # Fetch old stats by hash
res3 = index_node.gettxoutsetinfo(hash_option, self.convert_to_json_for_cli(res0['bestblock'])) res3 = index_node.gettxoutsetinfo(hash_option, res0['bestblock'])
del res3['block_info'], res3['total_unspendable_amount'] del res3['block_info'], res3['total_unspendable_amount']
res3.pop('muhash', None) res3.pop('muhash', None)
assert_equal(res0, res3) assert_equal(res0, res3)
@@ -242,7 +242,7 @@ class CoinStatsIndexTest(BitcoinTestFramework):
assert_equal(res12, res10) assert_equal(res12, res10)
self.log.info("Test obtaining info for a non-existent block hash") self.log.info("Test obtaining info for a non-existent block hash")
assert_raises_rpc_error(-5, "Block not found", index_node.gettxoutsetinfo, hash_type="none", hash_or_height=self.convert_to_json_for_cli("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"), use_index=True) assert_raises_rpc_error(-5, "Block not found", index_node.gettxoutsetinfo, hash_type="none", hash_or_height="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", use_index=True)
def _test_use_index_option(self): def _test_use_index_option(self):
self.log.info("Test use_index option for nodes running the index") self.log.info("Test use_index option for nodes running the index")
@@ -277,7 +277,7 @@ class CoinStatsIndexTest(BitcoinTestFramework):
assert_not_equal(res["muhash"], res_invalid["muhash"]) assert_not_equal(res["muhash"], res_invalid["muhash"])
# Test that requesting reorged out block by hash is still returning correct results # Test that requesting reorged out block by hash is still returning correct results
res_invalid2 = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=self.convert_to_json_for_cli(reorg_block)) res_invalid2 = index_node.gettxoutsetinfo(hash_type='muhash', hash_or_height=reorg_block)
assert_equal(res_invalid2["muhash"], res_invalid["muhash"]) assert_equal(res_invalid2["muhash"], res_invalid["muhash"])
assert_not_equal(res["muhash"], res_invalid2["muhash"]) assert_not_equal(res["muhash"], res_invalid2["muhash"])
+4 -4
View File
@@ -67,7 +67,7 @@ class FeatureIndexPruneTest(BitcoinTestFramework):
for node in filter_nodes: for node in filter_nodes:
assert_greater_than(len(node.getblockfilter(tip)['filter']), 0) assert_greater_than(len(node.getblockfilter(tip)['filter']), 0)
for node in stats_nodes: for node in stats_nodes:
assert node.gettxoutsetinfo(hash_type="muhash", hash_or_height=self.convert_to_json_for_cli(tip))['muhash'] assert node.gettxoutsetinfo(hash_type="muhash", hash_or_height=tip)['muhash']
self.generate(self.nodes[0], 500) self.generate(self.nodes[0], 500)
self.sync_index(height=700) self.sync_index(height=700)
@@ -85,14 +85,14 @@ class FeatureIndexPruneTest(BitcoinTestFramework):
for node in filter_nodes: for node in filter_nodes:
assert_greater_than(len(node.getblockfilter(tip)['filter']), 0) assert_greater_than(len(node.getblockfilter(tip)['filter']), 0)
for node in stats_nodes: for node in stats_nodes:
assert node.gettxoutsetinfo(hash_type="muhash", hash_or_height=self.convert_to_json_for_cli(tip))['muhash'] assert node.gettxoutsetinfo(hash_type="muhash", hash_or_height=tip)['muhash']
self.log.info("check if we can access the blockfilter and coinstats of a pruned block") self.log.info("check if we can access the blockfilter and coinstats of a pruned block")
height_hash = self.nodes[0].getblockhash(2) height_hash = self.nodes[0].getblockhash(2)
for node in filter_nodes: for node in filter_nodes:
assert_greater_than(len(node.getblockfilter(height_hash)['filter']), 0) assert_greater_than(len(node.getblockfilter(height_hash)['filter']), 0)
for node in stats_nodes: for node in stats_nodes:
assert node.gettxoutsetinfo(hash_type="muhash", hash_or_height=self.convert_to_json_for_cli(height_hash))['muhash'] assert node.gettxoutsetinfo(hash_type="muhash", hash_or_height=height_hash)['muhash']
# mine and sync index up to a height that will later be the pruneheight # mine and sync index up to a height that will later be the pruneheight
self.generate(self.nodes[0], 51) self.generate(self.nodes[0], 51)
@@ -106,7 +106,7 @@ class FeatureIndexPruneTest(BitcoinTestFramework):
assert_raises_rpc_error(-1, msg, node.getblockfilter, height_hash) assert_raises_rpc_error(-1, msg, node.getblockfilter, height_hash)
for node in stats_nodes: for node in stats_nodes:
msg = "Querying specific block heights requires coinstatsindex" msg = "Querying specific block heights requires coinstatsindex"
assert_raises_rpc_error(-8, msg, node.gettxoutsetinfo, "muhash", self.convert_to_json_for_cli(height_hash)) assert_raises_rpc_error(-8, msg, node.gettxoutsetinfo, "muhash", height_hash)
self.generate(self.nodes[0], 749) self.generate(self.nodes[0], 749)
+4 -4
View File
@@ -119,7 +119,7 @@ class GetblockstatsTest(BitcoinTestFramework):
# Check selecting block by hash too # Check selecting block by hash too
blockhash = self.expected_stats[i]['blockhash'] blockhash = self.expected_stats[i]['blockhash']
stats_by_hash = self.nodes[0].getblockstats(hash_or_height=self.convert_to_json_for_cli(blockhash)) stats_by_hash = self.nodes[0].getblockstats(hash_or_height=blockhash)
assert_equal(stats_by_hash, self.expected_stats[i]) assert_equal(stats_by_hash, self.expected_stats[i])
# Make sure each stat can be queried on its own # Make sure each stat can be queried on its own
@@ -161,10 +161,10 @@ class GetblockstatsTest(BitcoinTestFramework):
self.nodes[0].getblockstats, hash_or_height=1, stats=['minfee', f'aaa{inv_sel_stat}']) self.nodes[0].getblockstats, hash_or_height=1, stats=['minfee', f'aaa{inv_sel_stat}'])
# Mainchain's genesis block shouldn't be found on regtest # Mainchain's genesis block shouldn't be found on regtest
assert_raises_rpc_error(-5, 'Block not found', self.nodes[0].getblockstats, assert_raises_rpc_error(-5, 'Block not found', self.nodes[0].getblockstats,
hash_or_height=self.convert_to_json_for_cli('000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f')) hash_or_height='000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f')
# Invalid number of args # Invalid number of args
assert_raises_rpc_error(-1, 'getblockstats hash_or_height ( stats )', self.nodes[0].getblockstats, self.convert_to_json_for_cli('00'), 1, 2) assert_raises_rpc_error(-1, 'getblockstats hash_or_height ( stats )', self.nodes[0].getblockstats, '00', 1, 2)
assert_raises_rpc_error(-1, 'getblockstats hash_or_height ( stats )', self.nodes[0].getblockstats) assert_raises_rpc_error(-1, 'getblockstats hash_or_height ( stats )', self.nodes[0].getblockstats)
self.log.info('Test block height 0') self.log.info('Test block height 0')
@@ -185,7 +185,7 @@ class GetblockstatsTest(BitcoinTestFramework):
self.log.info("Test when only header is known") self.log.info("Test when only header is known")
block = self.generateblock(self.nodes[0], output="raw(55)", transactions=[], submit=False) block = self.generateblock(self.nodes[0], output="raw(55)", transactions=[], submit=False)
self.nodes[0].submitheader(block["hex"]) self.nodes[0].submitheader(block["hex"])
assert_raises_rpc_error(-1, "Block not available (not fully downloaded)", lambda: self.nodes[0].getblockstats(self.convert_to_json_for_cli(block['hash']))) assert_raises_rpc_error(-1, "Block not available (not fully downloaded)", lambda: self.nodes[0].getblockstats(block['hash']))
self.log.info('Test when block is missing') self.log.info('Test when block is missing')
(self.nodes[0].blocks_path / 'blk00000.dat').rename(self.nodes[0].blocks_path / 'blk00000.dat.backup') (self.nodes[0].blocks_path / 'blk00000.dat').rename(self.nodes[0].blocks_path / 'blk00000.dat.backup')
+1 -1
View File
@@ -32,7 +32,7 @@ def process_mapping(fname):
if line.startswith('};'): if line.startswith('};'):
in_rpcs = False in_rpcs = False
elif '{' in line and '"' in line: elif '{' in line and '"' in line:
m = re.search(r'{ *("[^"]*"), *([0-9]+) *, *("[^"]*") *},', line) m = re.search(r'{ *("[^"]*"), *([0-9]+) *, *("[^"]*") *(, \/\*also_string=\*\/true *)?},', line)
assert m, 'No match to table expression: %s' % line assert m, 'No match to table expression: %s' % line
name = parse_string(m.group(1)) name = parse_string(m.group(1))
idx = int(m.group(2)) idx = int(m.group(2))
@@ -8,7 +8,6 @@ import configparser
from enum import Enum from enum import Enum
import argparse import argparse
from datetime import datetime, timezone from datetime import datetime, timezone
import json
import logging import logging
import os import os
import platform import platform
@@ -1106,11 +1105,6 @@ class BitcoinTestFramework(metaclass=BitcoinTestMetaClass):
def has_blockfile(self, node, filenum: str): def has_blockfile(self, node, filenum: str):
return (node.blocks_path/ f"blk{filenum}.dat").is_file() return (node.blocks_path/ f"blk{filenum}.dat").is_file()
def convert_to_json_for_cli(self, text):
if self.options.usecli:
return json.dumps(text)
return text
def inspect_sqlite_db(self, path, fn, *args, **kwargs): def inspect_sqlite_db(self, path, fn, *args, **kwargs):
try: try:
import sqlite3 # type: ignore[import] import sqlite3 # type: ignore[import]
@@ -1121,4 +1115,3 @@ class BitcoinTestFramework(metaclass=BitcoinTestMetaClass):
return result return result
except ImportError: except ImportError:
self.log.warning("sqlite3 module not available, skipping tests that inspect the database") self.log.warning("sqlite3 module not available, skipping tests that inspect the database")
+1 -1
View File
@@ -104,7 +104,7 @@ class ReorgsRestoreTest(BitcoinTestFramework):
# Disconnect tip and sync wallet state # Disconnect tip and sync wallet state
tip = wallet.getbestblockhash() tip = wallet.getbestblockhash()
tip_height = wallet.getblockstats(hash_or_height=self.convert_to_json_for_cli(tip))["height"] tip_height = wallet.getblockstats(hash_or_height=tip)["height"]
wallet.invalidateblock(tip) wallet.invalidateblock(tip)
wallet.syncwithvalidationinterfacequeue() wallet.syncwithvalidationinterfacequeue()