From 8817c672dba6963149b7e0372b24d55c7d785581 Mon Sep 17 00:00:00 2001 From: Eric Voskuil Date: Tue, 25 Aug 2026 19:27:03 -0400 Subject: [PATCH] Full bitcoind parity for gettxoutsetinfo and scantxoutset. --- .../server/interfaces/bitcoind_blockchain.hpp | 2 +- .../protocol_bitcoind_blockchain.hpp | 9 +- .../bitcoind/protocol_bitcoind_blockchain.cpp | 345 ++++++++++++++---- test/protocols/bitcoind/bitcoind_rpc.cpp | 143 +++++++- .../bitcoind/bitcoind_setup_fixture.cpp | 10 +- .../bitcoind/bitcoind_setup_fixture.hpp | 16 + 6 files changed, 437 insertions(+), 88 deletions(-) diff --git a/include/bitcoin/server/interfaces/bitcoind_blockchain.hpp b/include/bitcoin/server/interfaces/bitcoind_blockchain.hpp index 90296866..1b8f1075 100644 --- a/include/bitcoin/server/interfaces/bitcoind_blockchain.hpp +++ b/include/bitcoin/server/interfaces/bitcoind_blockchain.hpp @@ -40,7 +40,7 @@ struct bitcoind_blockchain_methods method<"getblockstats", value_t, optional>{ "hash_or_height", "stats" }, method<"getchaintxstats", optional<-1.0>, optional<""_t>>{ "nblocks", "blockhash" }, method<"gettxout", string_t, number_t, optional>{ "txid", "n", "include_mempool" }, - method<"gettxoutsetinfo", optional<"none"_t>>{ "hash_type" }, + method<"gettxoutsetinfo", optional<"hash_serialized_3"_t>, optional, optional>{ "hash_type", "hash_or_height", "use_index" }, method<"pruneblockchain", number_t>{ unimplemented, "height" }, method<"savemempool">{ unimplemented }, method<"scantxoutset", string_t, optional>{ "action", "scanobjects" }, diff --git a/include/bitcoin/server/protocols/protocol_bitcoind_blockchain.hpp b/include/bitcoin/server/protocols/protocol_bitcoind_blockchain.hpp index bba36d48..3cda2ec0 100644 --- a/include/bitcoin/server/protocols/protocol_bitcoind_blockchain.hpp +++ b/include/bitcoin/server/protocols/protocol_bitcoind_blockchain.hpp @@ -83,7 +83,8 @@ class BCS_API protocol_bitcoind_blockchain bool handle_get_tx_out(const code& ec, rpc_interface::get_tx_out, const std::string&, double, bool) NOEXCEPT; bool handle_get_tx_out_set_info(const code& ec, - rpc_interface::get_tx_out_set_info, const std::string&) NOEXCEPT; + rpc_interface::get_tx_out_set_info, const std::string&, + const network::rpc::value_t&, bool) NOEXCEPT; bool handle_prune_block_chain(const code& ec, rpc_interface::prune_block_chain, double) NOEXCEPT; bool handle_save_mempool(const code& ec, @@ -162,9 +163,11 @@ class BCS_API protocol_bitcoind_blockchain bool wait_done() const NOEXCEPT; void send_tip() NOEXCEPT; - void do_get_tx_out_set_info() NOEXCEPT; + enum class set_hash : uint8_t { none, muhash, serialized }; + + void do_get_tx_out_set_info(set_hash type, size_t height) NOEXCEPT; void do_scan_tx_out_set( - const std::shared_ptr& scripts) NOEXCEPT; + const std::shared_ptr& objects) NOEXCEPT; void complete_scan(const code& ec, network::rpc::object_t& result, size_t size) NOEXCEPT; diff --git a/src/protocols/bitcoind/protocol_bitcoind_blockchain.cpp b/src/protocols/bitcoind/protocol_bitcoind_blockchain.cpp index 5c4d6cb3..b7831b1a 100644 --- a/src/protocols/bitcoind/protocol_bitcoind_blockchain.cpp +++ b/src/protocols/bitcoind/protocol_bitcoind_blockchain.cpp @@ -20,6 +20,7 @@ #include #include +#include #include #include #include @@ -88,7 +89,7 @@ void protocol_bitcoind_blockchain::start() NOEXCEPT SUBSCRIBE_BITCOIND(handle_get_block_stats, _1, _2, _3, _4); SUBSCRIBE_BITCOIND(handle_get_chain_tx_stats, _1, _2, _3, _4); SUBSCRIBE_BITCOIND(handle_get_tx_out, _1, _2, _3, _4, _5); - SUBSCRIBE_BITCOIND(handle_get_tx_out_set_info, _1, _2, _3); + SUBSCRIBE_BITCOIND(handle_get_tx_out_set_info, _1, _2, _3, _4, _5); SUBSCRIBE_BITCOIND(handle_prune_block_chain, _1, _2, _3); SUBSCRIBE_BITCOIND(handle_save_mempool, _1, _2); SUBSCRIBE_BITCOIND(handle_scan_tx_out_set, _1, _2, _3, _4); @@ -585,47 +586,158 @@ bool protocol_bitcoind_blockchain::handle_get_tx_out(const code& ec, return true; } +// bitcoind's utxo set coin serialization (outpoint, height code, value, +// script), the element of both set commitment forms. +static void to_coin_data(data_chunk& out, + const database::unspent_coin& coin) NOEXCEPT +{ + constexpr auto overhead = hash_size + sizeof(uint32_t) + sizeof(uint32_t) + + sizeof(uint64_t); + + out.resize(overhead + variable_size(coin.script.size()) + + coin.script.size()); + stream::out::fast ostream(out); + write::bytes::fast sink(ostream); + sink.write_bytes(coin.txid); + sink.write_4_bytes_little_endian(coin.index); + sink.write_4_bytes_little_endian(bit_or(shift_left( + possible_narrow_cast(coin.height), 1), + to_int(coin.coinbase))); + sink.write_8_bytes_little_endian(coin.value); + sink.write_variable(coin.script.size()); + sink.write_bytes(coin.script); +} + // The response defers to completion of the store scan (see dispatch). This is // an administrative query, expected to run long, performed off the strand. bool protocol_bitcoind_blockchain::handle_get_tx_out_set_info(const code& ec, - rpc_interface::get_tx_out_set_info, const std::string& hash_type) NOEXCEPT + rpc_interface::get_tx_out_set_info, const std::string& hash_type, + const value_t& hash_or_height, bool use_index) NOEXCEPT { if (stopped(ec)) return false; - // Utxo set commitments have no consumer here (no assumeutxo). - if (hash_type != "none") + set_hash type{}; + if (hash_type == "hash_serialized_3") + type = set_hash::serialized; + else if (hash_type == "muhash") + type = set_hash::muhash; + else if (hash_type == "none") + type = set_hash::none; + else { send_error(error::invalid_argument); return true; } + const auto& query = archive(); + auto height = query.get_top_confirmed(); + if (!std::holds_alternative(hash_or_height.value())) + { + // bitcoind restricts specific block queries (coinstatsindex bounds). + if ((type == set_hash::serialized) || !use_index) + { + send_error(error::invalid_argument); + return true; + } + + database::header_link link{}; + if (std::holds_alternative(hash_or_height.value())) + { + hash_digest hash{}; + if (!decode_hash(hash, std::get(hash_or_height.value()))) + { + send_error(error::invalid_argument); + return true; + } + + link = query.to_header(hash); + } + else if (std::holds_alternative(hash_or_height.value())) + { + if (!to_integer(height, + std::get(hash_or_height.value()))) + { + send_error(error::invalid_argument); + return true; + } + + link = query.to_confirmed(height); + } + else + { + send_error(error::invalid_argument); + return true; + } + + if (!query.get_height(height, link) || + query.to_confirmed(height) != link) + { + send_error(error::not_found); + return true; + } + } + monitor(true); - PARALLEL(do_get_tx_out_set_info); + PARALLEL(do_get_tx_out_set_info, type, height); return true; } -void protocol_bitcoind_blockchain::do_get_tx_out_set_info() NOEXCEPT +void protocol_bitcoind_blockchain::do_get_tx_out_set_info(set_hash type, + size_t height) NOEXCEPT { BC_ASSERT(!stranded()); object_t result{}; const auto& query = archive(); - const auto top = query.get_top_confirmed(); - const auto link = query.to_confirmed(top); + const auto link = query.to_confirmed(height); // The pinned ancestry excludes genesis, absent from the set (as bitcoind). database::header_links branch{}; - if (!query.get_ancestry(branch, link, top)) + if (!query.get_ancestry(branch, link, height)) { POST(complete_scan, database::error::integrity, std::move(result), zero); return; } + code ec{}; + hash_digest digest{}; database::unspent_totals totals{}; - const auto ec = query.get_unspent_totals(stopping_, totals, branch, - database_settings().turbo); + if (type == set_hash::none) + { + ec = query.get_unspent_totals(stopping_, totals, branch, + database_settings().turbo); + } + else + { + // The commitment accumulates each visited coin, and the totals with + // it, so the totals scan is not repeated for hashed queries. + muhash3072 muhash{}; + accumulator serial{}; + data_chunk element{}; + const auto visit = [&](const database::unspent_coin& coin) NOEXCEPT + { + if (coin.first) + ++totals.transactions; + + to_coin_data(element, coin); + ++totals.outputs; + totals.value += coin.value; + totals.script_bytes += coin.script.size(); + totals.coin_bytes += element.size(); + if (type == set_hash::muhash) + muhash.insert(element); + else + serial.write(element); + }; + + ec = query.get_unspent_coins(stopping_, visit, branch, + type == set_hash::serialized, database_settings().turbo); + digest = (type == set_hash::muhash) ? muhash.flush() : + serial.double_flush(); + } + if (ec) { POST(complete_scan, ec, @@ -642,17 +754,26 @@ void protocol_bitcoind_blockchain::do_get_tx_out_set_info() NOEXCEPT result = object_t { - { "height", top }, + { "height", height }, { "bestblock", encode_hash(query.get_header_key(link)) }, { "transactions", totals.transactions }, { "txouts", totals.outputs }, // bitcoind's per-utxo accounting fiction (50 byte overhead). { "bogosize", 50u * totals.outputs + totals.script_bytes }, + + // The coin-serialized set size (bitcoind estimates leveldb size). + { "disk_size", totals.coin_bytes }, { "total_amount", to_floating(totals.value) / chain::satoshi_per_bitcoin } }; + if (type == set_hash::serialized) + result.emplace("hash_serialized_3", encode_hash(digest)); + + if (type == set_hash::muhash) + result.emplace("muhash", encode_hash(digest)); + POST(complete_scan, code{}, std::move(result), 512); } @@ -673,7 +794,9 @@ bool protocol_bitcoind_blockchain::handle_save_mempool(const code& ec, return true; } -// The response defers to completion of the indexed scan (see dispatch). +// The response defers to completion of the utxo scan (see dispatch). The +// expansion may require a million derivations and without the address index +// the scan walks the full utxo set (recovery-grade), both off the strand. bool protocol_bitcoind_blockchain::handle_scan_tx_out_set(const code& ec, rpc_interface::scan_tx_out_set, const std::string& action, const array_t& scanobjects) NOEXCEPT @@ -694,88 +817,173 @@ bool protocol_bitcoind_blockchain::handle_scan_tx_out_set(const code& ec, return true; } - if (action != "start" || scanobjects.empty()) + if (action != "start") { send_error(error::invalid_argument); return true; } - if (!archive().address_enabled()) - { - send_error(error::not_implemented); - return true; - } - - const auto scripts = std::make_shared(); - for (const auto& item: scanobjects) - { - if (!expand_scan_object(*scripts, item)) - { - send_error(error::invalid_argument); - return true; - } - } - monitor(true); - PARALLEL(do_scan_tx_out_set, scripts); + PARALLEL(do_scan_tx_out_set, std::make_shared(scanobjects)); return true; } void protocol_bitcoind_blockchain::do_scan_tx_out_set( - const std::shared_ptr& scripts) NOEXCEPT + const std::shared_ptr& objects) NOEXCEPT { BC_ASSERT(!stranded()); - uint64_t amount{}; - array_t unspents{}; object_t result{}; + chain::scripts scripts{}; + for (const auto& item: *objects) + { + if (!expand_scan_object(scripts, item)) + { + POST(complete_scan, error::invalid_argument, std::move(result), + zero); + return; + } + } + + // Needle identity is the script hash (the address index key). The set + // excludes unspendable outputs, so those needles are dropped (as + // bitcoind), and needles deduplicate repeated expressions. + struct needle { std::string hex; std::string desc; }; + std::unordered_map needles{}; + for (const auto& script: scripts) + { + if (script.is_unspendable()) + continue; + + const auto data = script.to_data(false); + needles.emplace(sha256_hash(data), + needle{ encode_base16(data), infer_descriptor(script) }); + } + + struct match + { + hash_digest txid; + uint32_t index; + uint64_t value; + size_t height; + bool coinbase; + const needle* found; + }; + + code ec{}; + uint64_t txouts{}; + std_vector matches{}; const auto& query = archive(); const auto top = query.get_top_confirmed(); + const auto link = query.to_confirmed(top); + const auto bip30 = query.envelope().forks.bip30; - for (const auto& script: *scripts) + if (query.address_enabled()) { - database::unspents outs{}; - const auto data = script.to_data(false); - auto ec = query.get_confirmed_unspent(stopping_, outs, - sha256_hash(data)); - if (ec) + // Indexed candidates avoid the full scan, so the scanned coin count + // reflects only examined candidates (bitcoind scans the whole set). + for (const auto& [key, item]: needles) { - POST(complete_scan, ec, - std::move(result), zero); - return; - } + database::unspents outs{}; + ec = query.get_confirmed_unspent(stopping_, outs, key, + database_settings().turbo); + if (ec) + break; - const auto hex = encode_base16(data); - const auto desc = infer_descriptor(script); - for (const auto& utxo: outs) + for (const auto& utxo: outs) + { + // The genesis output is excluded from the set (as bitcoind). + if (is_zero(utxo.height) && is_zero(utxo.position)) + continue; + + ++txouts; + matches.emplace_back(utxo.out.point().hash(), + utxo.out.point().index(), utxo.out.value(), utxo.height, + is_zero(utxo.position), &item); + } + } + } + else + { + // The pinned ancestry excludes genesis, absent from the set. + database::header_links branch{}; + if (!query.get_ancestry(branch, link, top)) + { + ec = database::error::integrity; + } + else { - const auto& target = utxo.out.point(); - unspents.emplace_back(object_t + const auto visit = [&](const database::unspent_coin& coin) NOEXCEPT { - { "txid", encode_hash(target.hash()) }, - { "vout", target.index() }, - { "scriptPubKey", hex }, - { "desc", desc }, - { "amount", to_floating(utxo.out.value()) / - chain::satoshi_per_bitcoin }, - { "coinbase", query.is_coinbase(query.to_tx(target.hash())) }, - { "height", utxo.height }, - { "blockhash", encode_hash(query.get_header_key( - query.to_confirmed(utxo.height))) }, - { "confirmations", add1(floored_subtract(top, utxo.height)) } - }); - - amount += utxo.out.value(); + ++txouts; + const auto it = needles.find(accumulator::hash( + coin.script)); + if (it != needles.end()) + matches.emplace_back(coin.txid, coin.index, coin.value, + coin.height, coin.coinbase, &it->second); + }; + + ec = query.get_unspent_coins(stopping_, visit, branch, false, + database_settings().turbo); } } + if (ec) + { + POST(complete_scan, ec, + std::move(result), zero); + return; + } + + // A reorganization across the pinned top voids the scan. + if (!query.is_confirmed_block(link)) + { + POST(complete_scan, error::server_error, std::move(result), zero); + return; + } + + // Report in canonical (txid, index) order (as bitcoind). + std::sort(matches.begin(), matches.end(), + [](const auto& left, const auto& right) NOEXCEPT + { + return (left.txid == right.txid) ? (left.index < right.index) : + (left.txid < right.txid); + }); + + uint64_t amount{}; + array_t unspents{}; + for (auto& item: matches) + { + // bitcoind retains duplicated coinbases at the overwriting heights. + if (bip30 && item.coinbase) + item.height = (item.height == 91812) ? 91842 : + (item.height == 91722) ? 91880 : item.height; + + unspents.emplace_back(object_t + { + { "txid", encode_hash(item.txid) }, + { "vout", item.index }, + { "scriptPubKey", item.found->hex }, + { "desc", item.found->desc }, + { "amount", to_floating(item.value) / + chain::satoshi_per_bitcoin }, + { "coinbase", item.coinbase }, + { "height", item.height }, + { "blockhash", encode_hash(query.get_header_key( + query.to_confirmed(item.height))) }, + { "confirmations", add1(floored_subtract(top, item.height)) } + }); + + amount += item.value; + } + const auto size = add1(unspents.size()) * 384u; result = object_t { { "success", true }, + { "txouts", txouts }, { "height", top }, - { "bestblock", encode_hash(query.get_header_key( - query.to_confirmed(top))) }, + { "bestblock", encode_hash(query.get_header_key(link)) }, { "unspents", std::move(unspents) }, { "total_amount", to_floating(amount) / chain::satoshi_per_bitcoin } }; @@ -1310,9 +1518,9 @@ static bool expand_scan_object(chain::scripts& out, uint32_t begin{}; uint32_t end{}; - // bitcoind's default range for ranged descriptors. + // bitcoind's default and maximum ranges for ranged descriptors. constexpr uint32_t default_range = 1'000; - constexpr uint32_t maximum_range = 10'000; + constexpr uint32_t maximum_range = 1'000'000; if (std::holds_alternative(item.value())) { @@ -1363,7 +1571,8 @@ static bool expand_scan_object(chain::scripts& out, } const wallet::descriptor parsed{ expression }; - if (!parsed || floored_subtract(end, begin) >= maximum_range) + if (!parsed || to_bool(shift_right(end, 31u)) || + floored_subtract(end, begin) >= maximum_range) return false; if (!parsed.ranged()) diff --git a/test/protocols/bitcoind/bitcoind_rpc.cpp b/test/protocols/bitcoind/bitcoind_rpc.cpp index 606556e3..dab7d78f 100644 --- a/test/protocols/bitcoind/bitcoind_rpc.cpp +++ b/test/protocols/bitcoind/bitcoind_rpc.cpp @@ -1136,6 +1136,7 @@ BOOST_AUTO_TEST_CASE(bitcoind_rpc__scanblocks__bad_action__invalid) // gettxoutsetinfo // The genesis output is excluded from the utxo set (as bitcoind). +// TODO: pin the hash_serialized_3/muhash digests against bitcoind vectors. BOOST_AUTO_TEST_CASE(bitcoind_rpc__gettxoutsetinfo__default__expected) { const auto response = rpc("gettxoutsetinfo"); @@ -1146,13 +1147,75 @@ BOOST_AUTO_TEST_CASE(bitcoind_rpc__gettxoutsetinfo__default__expected) BOOST_REQUIRE_EQUAL(result.at("transactions").as_int64(), 9); BOOST_REQUIRE_EQUAL(result.at("txouts").as_int64(), 9); BOOST_REQUIRE_EQUAL(result.at("bogosize").as_int64(), 9 * (50 + 67)); + BOOST_REQUIRE_EQUAL(result.at("disk_size").as_int64(), 9 * (48 + 1 + 67)); BOOST_REQUIRE_EQUAL(result.at("total_amount").as_double(), 450.0); + BOOST_REQUIRE_EQUAL(as_text(result.at("hash_serialized_3")).size(), 64u); + BOOST_REQUIRE(!result.as_object().contains("muhash")); } -// Utxo set commitments have no consumer here (no assumeutxo). -BOOST_AUTO_TEST_CASE(bitcoind_rpc__gettxoutsetinfo__hash_type__invalid) +BOOST_AUTO_TEST_CASE(bitcoind_rpc__gettxoutsetinfo__muhash__expected) { const auto response = rpc("gettxoutsetinfo", "[\"muhash\"]"); + const auto& result = response.at("result"); + BOOST_REQUIRE(result.is_object()); + BOOST_REQUIRE_EQUAL(result.at("txouts").as_int64(), 9); + BOOST_REQUIRE_EQUAL(result.at("total_amount").as_double(), 450.0); + BOOST_REQUIRE_EQUAL(as_text(result.at("muhash")).size(), 64u); + BOOST_REQUIRE(!result.as_object().contains("hash_serialized_3")); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__gettxoutsetinfo__none__expected) +{ + const auto response = rpc("gettxoutsetinfo", "[\"none\"]"); + const auto& result = response.at("result"); + BOOST_REQUIRE(result.is_object()); + BOOST_REQUIRE_EQUAL(result.at("transactions").as_int64(), 9); + BOOST_REQUIRE_EQUAL(result.at("txouts").as_int64(), 9); + BOOST_REQUIRE_EQUAL(result.at("disk_size").as_int64(), 9 * (48 + 1 + 67)); + BOOST_REQUIRE(!result.as_object().contains("hash_serialized_3")); + BOOST_REQUIRE(!result.as_object().contains("muhash")); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__gettxoutsetinfo__bad_hash_type__invalid) +{ + const auto response = rpc("gettxoutsetinfo", "[\"sha256\"]"); + BOOST_REQUIRE(has_error(response)); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__gettxoutsetinfo__height__expected) +{ + const auto response = rpc("gettxoutsetinfo", "[\"none\", 5]"); + const auto& result = response.at("result"); + BOOST_REQUIRE(result.is_object()); + BOOST_REQUIRE_EQUAL(result.at("height").as_int64(), 5); + BOOST_REQUIRE_EQUAL(as_text(result.at("bestblock")), block5); + BOOST_REQUIRE_EQUAL(result.at("transactions").as_int64(), 5); + BOOST_REQUIRE_EQUAL(result.at("txouts").as_int64(), 5); + BOOST_REQUIRE_EQUAL(result.at("total_amount").as_double(), 250.0); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__gettxoutsetinfo__hash__expected) +{ + const auto params = "[\"muhash\", \"" + block5 + "\"]"; + const auto response = rpc("gettxoutsetinfo", params); + const auto& result = response.at("result"); + BOOST_REQUIRE(result.is_object()); + BOOST_REQUIRE_EQUAL(result.at("height").as_int64(), 5); + BOOST_REQUIRE_EQUAL(as_text(result.at("bestblock")), block5); + BOOST_REQUIRE_EQUAL(as_text(result.at("muhash")).size(), 64u); +} + +// bitcoind restricts specific block queries (coinstatsindex bounds). +BOOST_AUTO_TEST_CASE(bitcoind_rpc__gettxoutsetinfo__serialized_at_height__invalid) +{ + const auto response = rpc("gettxoutsetinfo", "[\"hash_serialized_3\", 5]"); + BOOST_REQUIRE(has_error(response)); +} + +// bitcoind restricts specific block queries (coinstatsindex bounds). +BOOST_AUTO_TEST_CASE(bitcoind_rpc__gettxoutsetinfo__no_index_at_height__invalid) +{ + const auto response = rpc("gettxoutsetinfo", "[\"muhash\", 5, false]"); BOOST_REQUIRE(has_error(response)); } @@ -1170,18 +1233,39 @@ BOOST_AUTO_TEST_CASE(bitcoind_rpc__scantxoutset__abort__false) REQUIRE_NO_THROW_FALSE(response.at("result").as_bool()); } -BOOST_AUTO_TEST_CASE(bitcoind_rpc__scantxoutset__empty_scanobjects__invalid) +// bitcoind scans the full set when given nothing to match (success, empty). +BOOST_AUTO_TEST_CASE(bitcoind_rpc__scantxoutset__empty_scanobjects__empty) { const auto response = rpc("scantxoutset", "[\"start\", []]"); - BOOST_REQUIRE(has_error(response)); + const auto& result = response.at("result"); + BOOST_REQUIRE(result.is_object()); + BOOST_REQUIRE(result.at("success").as_bool()); + BOOST_REQUIRE(result.at("unspents").as_array().empty()); + BOOST_REQUIRE_EQUAL(result.at("total_amount").as_double(), 0.0); } -BOOST_AUTO_TEST_CASE(bitcoind_rpc__scantxoutset__genesis_pk__expected) +// The genesis output is excluded from the utxo set (as bitcoind). +BOOST_AUTO_TEST_CASE(bitcoind_rpc__scantxoutset__genesis_pk__excluded) { const auto response = rpc("scantxoutset", "[\"start\", [\"pk(04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f)\"]]"); const auto& result = response.at("result"); BOOST_REQUIRE(result.is_object()); BOOST_REQUIRE(result.at("success").as_bool()); + BOOST_REQUIRE(result.at("unspents").as_array().empty()); + BOOST_REQUIRE_EQUAL(result.at("total_amount").as_double(), 0.0); +} + +BOOST_AUTO_TEST_CASE(bitcoind_rpc__scantxoutset__block1_script__expected) +{ + const auto& coinbase = *test::block1.transactions_ptr()->front(); + const auto txid = encode_hash(coinbase.hash(false)); + const auto script = coinbase.outputs_ptr()->front()->script().to_data(false); + const auto params = "[\"start\", [\"raw(" + encode_base16(script) + ")\"]]"; + + const auto response = rpc("scantxoutset", params); + const auto& result = response.at("result"); + BOOST_REQUIRE(result.is_object()); + BOOST_REQUIRE(result.at("success").as_bool()); BOOST_REQUIRE_EQUAL(result.at("height").as_int64(), 9); BOOST_REQUIRE_EQUAL(as_text(result.at("bestblock")), block9); BOOST_REQUIRE_EQUAL(result.at("total_amount").as_double(), 50.0); @@ -1189,15 +1273,52 @@ BOOST_AUTO_TEST_CASE(bitcoind_rpc__scantxoutset__genesis_pk__expected) const auto& unspents = result.at("unspents").as_array(); BOOST_REQUIRE_EQUAL(unspents.size(), 1u); const auto& unspent = unspents.front(); - BOOST_REQUIRE_EQUAL(as_text(unspent.at("txid")), "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"); + BOOST_REQUIRE_EQUAL(as_text(unspent.at("txid")), txid); BOOST_REQUIRE_EQUAL(unspent.at("vout").as_int64(), 0); - BOOST_REQUIRE_EQUAL(as_text(unspent.at("scriptPubKey")), "4104678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac"); - BOOST_REQUIRE_EQUAL(as_text(unspent.at("desc")).find("pk(04678afd"), 0u); + BOOST_REQUIRE_EQUAL(as_text(unspent.at("scriptPubKey")), + encode_base16(script)); + BOOST_REQUIRE_EQUAL(as_text(unspent.at("desc")).find("pk(04"), 0u); BOOST_REQUIRE_EQUAL(unspent.at("amount").as_double(), 50.0); BOOST_REQUIRE(unspent.at("coinbase").as_bool()); - BOOST_REQUIRE_EQUAL(unspent.at("height").as_int64(), 0); - BOOST_REQUIRE_EQUAL(as_text(unspent.at("blockhash")), block0); - BOOST_REQUIRE_EQUAL(unspent.at("confirmations").as_int64(), 10); + BOOST_REQUIRE_EQUAL(unspent.at("height").as_int64(), 1); + BOOST_REQUIRE_EQUAL(as_text(unspent.at("blockhash")), block1); + BOOST_REQUIRE_EQUAL(unspent.at("confirmations").as_int64(), 9); +} + +// bitcoind's maximum descriptor range (ParseDescriptorRange). +BOOST_AUTO_TEST_CASE(bitcoind_rpc__scantxoutset__range_too_large__invalid) +{ + const auto response = rpc("scantxoutset", "[\"start\", [{\"desc\": " + "\"pk(04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61de" + "b649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f)\"," + " \"range\": [0, 1000000]}]]"); + BOOST_REQUIRE(has_error(response)); +} + +// The scan path serves without the address index (recovery-grade). +BOOST_FIXTURE_TEST_CASE(bitcoind_rpc__scantxoutset__no_address_index__scans, + bitcoind_no_address_setup_fixture) +{ + const auto& coinbase = *test::block1.transactions_ptr()->front(); + const auto txid = encode_hash(coinbase.hash(false)); + const auto script = coinbase.outputs_ptr()->front()->script().to_data(false); + const auto params = "[\"start\", [\"raw(" + encode_base16(script) + ")\"]]"; + + const auto response = rpc("scantxoutset", params); + const auto& result = response.at("result"); + BOOST_REQUIRE(result.is_object()); + BOOST_REQUIRE(result.at("success").as_bool()); + + // The scanned coin count is the full set (blocks 1-9 coinbases). + BOOST_REQUIRE_EQUAL(result.at("txouts").as_int64(), 9); + BOOST_REQUIRE_EQUAL(result.at("total_amount").as_double(), 50.0); + + const auto& unspents = result.at("unspents").as_array(); + BOOST_REQUIRE_EQUAL(unspents.size(), 1u); + const auto& unspent = unspents.front(); + BOOST_REQUIRE_EQUAL(as_text(unspent.at("txid")), txid); + BOOST_REQUIRE_EQUAL(unspent.at("height").as_int64(), 1); + BOOST_REQUIRE_EQUAL(unspent.at("confirmations").as_int64(), 9); } // openrpc diff --git a/test/protocols/bitcoind/bitcoind_setup_fixture.cpp b/test/protocols/bitcoind/bitcoind_setup_fixture.cpp index e64993d1..3baf856a 100644 --- a/test/protocols/bitcoind/bitcoind_setup_fixture.cpp +++ b/test/protocols/bitcoind/bitcoind_setup_fixture.cpp @@ -36,7 +36,12 @@ bitcoind_setup_fixture::bitcoind_setup_fixture(const initializer& setup, { [&]() NOEXCEPT -> const database::settings& { + // The store snapshots database settings at construction. config_.database.path = TEST_DIRECTORY; + config_.database.interval_depth = 2; + if (configure) + configure(config_); + return config_.database; }() }, @@ -45,7 +50,6 @@ bitcoind_setup_fixture::bitcoind_setup_fixture(const initializer& setup, { test::clear(test::directory); - auto& database_settings = config_.database; auto& network_settings = config_.network; auto& node_settings = config_.node; auto& server_settings = config_.server; @@ -53,15 +57,11 @@ bitcoind_setup_fixture::bitcoind_setup_fixture(const initializer& setup, bitcoind.binds = { { BITCOIND_ENDPOINT } }; bitcoind.connections = 1; - database_settings.interval_depth = 2; node_settings.delay_inbound = false; node_settings.minimum_fee_rate = 99.0; network_settings.inbound.connections = 0; network_settings.outbound.connections = 0; - if (configure) - configure(config_); - // Create and populate the store. auto ec = store_.create([](auto, auto) {}); BOOST_REQUIRE_MESSAGE(!ec, ec.message()); diff --git a/test/protocols/bitcoind/bitcoind_setup_fixture.hpp b/test/protocols/bitcoind/bitcoind_setup_fixture.hpp index 2dc75fdc..6dbacb0d 100644 --- a/test/protocols/bitcoind/bitcoind_setup_fixture.hpp +++ b/test/protocols/bitcoind/bitcoind_setup_fixture.hpp @@ -134,6 +134,22 @@ struct bitcoind_current_setup_fixture } }; +// Configured with no address index -- for tests of the utxo set scan path. +struct bitcoind_no_address_setup_fixture + : bitcoind_setup_fixture +{ + inline bitcoind_no_address_setup_fixture() + : bitcoind_setup_fixture([](test::query_t& query) + { + return test::setup_ten_block_store(query); + }, [](configuration& config) + { + config.database.outs.buckets = 0; + }) + { + } +}; + struct bitcoind_witness_setup_fixture : bitcoind_setup_fixture {