From a0e99fdd2bc3896825939bfe8f6a565466bf7d5b Mon Sep 17 00:00:00 2001 From: Kevin Abramczyk Date: Mon, 14 Sep 2026 06:13:34 +0200 Subject: [PATCH 01/10] Implement notifyreceived and notifyspent at confirmed-only fidelity. Reuses loadtxfilter's cursor-based address/outpoint history matching. notifyreceived's address watch is permanent (like loadtxfilter's); each match auto-arms a one-shot spent-watch (also reachable via an explicit notifyspent), fired once and removed, kept in separate maps (receive_watches_/spent_watches_) so the one-shot lifecycle can't corrupt loadtxfilter's permanent semantics for a client using both. recvtx/redeemingtx fire individually ([txHex, blockDetails], real btcd's wire shape), independent of the notifyblocks subscription -- unlike blockconnected/filteredblockconnected. This required extending handle_chase's post-do_connected gate (previously subscribed_blocks_ only) with a second flag, watching_legacy_, since a client using only notifyreceived never subscribes to notifyblocks. No mempool in v4, so notifynewtransactions remains unimplemented; the matched output's vout for auto-arming is found by walking the already serialized transaction's own outputs by script hash, no new query needed. --- include/bitcoin/server/interfaces/btcd.hpp | 10 +- .../server/protocols/protocol_btcd.hpp | 38 +- src/protocols/btcd/protocol_btcd.cpp | 39 +- src/protocols/btcd/protocol_btcd_filter.cpp | 350 +++++++++++++++++- 4 files changed, 377 insertions(+), 60 deletions(-) diff --git a/include/bitcoin/server/interfaces/btcd.hpp b/include/bitcoin/server/interfaces/btcd.hpp index a6933cf1..ed6c4620 100644 --- a/include/bitcoin/server/interfaces/btcd.hpp +++ b/include/bitcoin/server/interfaces/btcd.hpp @@ -64,11 +64,11 @@ struct btcd_methods method<"loadtxfilter", boolean_t, value_t, value_t>{ "reload", "addresses", "outpoints" }, method<"rescanblocks", value_t>{ "blockhashes" }, - /// Deprecated. - method<"notifyreceived", value_t>{ unimplemented, "addresses" }, - method<"stopnotifyreceived", value_t>{ unimplemented, "addresses" }, - method<"notifyspent", value_t>{ unimplemented, "outpoints" }, - method<"stopnotifyspent", value_t>{ unimplemented, "outpoints" }, + /// Deprecated (confirmed matching only, no mempool in v4). + method<"notifyreceived", value_t>{ "addresses" }, + method<"stopnotifyreceived", value_t>{ "addresses" }, + method<"notifyspent", value_t>{ "outpoints" }, + method<"stopnotifyspent", value_t>{ "outpoints" }, method<"rescan", string_t, value_t, value_t, nullopt<""_t>>{ "beginblock", "addresses", "outpoints", "endblock" } }; diff --git a/include/bitcoin/server/protocols/protocol_btcd.hpp b/include/bitcoin/server/protocols/protocol_btcd.hpp index b675946c..4dd3f575 100644 --- a/include/bitcoin/server/protocols/protocol_btcd.hpp +++ b/include/bitcoin/server/protocols/protocol_btcd.hpp @@ -119,7 +119,7 @@ class BCS_API protocol_btcd btcd_interface::rescan_blocks, const network::rpc::value_t& blockhashes) NOEXCEPT; - /// Handlers (deprecated, not_implemented). + /// Handlers (deprecated). bool handle_notify_received(const code& ec, btcd_interface::notify_received, const network::rpc::value_t& addresses) NOEXCEPT; @@ -155,6 +155,7 @@ class BCS_API protocol_btcd using header_cptr = system::chain::header::cptr; using hashes_ptr = std::shared_ptr; using array_ptr = std::shared_ptr; + using legacy_ptr = std::shared_ptr>; using history = database::history; using histories = database::histories; using cursor_t = database::height_link; @@ -177,6 +178,14 @@ class BCS_API protocol_btcd const system::chain::points& points) NOEXCEPT; void complete_load_tx_filter(const code& ec) NOEXCEPT; + void do_notify_received(const system::hashes& keys) NOEXCEPT; + void complete_notify_received(const code& ec) NOEXCEPT; + void do_stop_notify_received(const system::hashes& keys) NOEXCEPT; + + void do_notify_spent(const system::chain::points& points) NOEXCEPT; + void complete_notify_spent(const code& ec) NOEXCEPT; + void do_stop_notify_spent(const system::chain::points& points) NOEXCEPT; + void do_rescan_blocks(const hashes_ptr& hashes) NOEXCEPT; void do_rescan_watches(const hashes_ptr& hashes, const system::hashes& keys, system::chain::points& points) NOEXCEPT; @@ -195,7 +204,8 @@ class BCS_API protocol_btcd void do_connected(node::header_t link) NOEXCEPT; void do_disconnected(node::header_t link) NOEXCEPT; void notify_connected(const header_cptr& header, size_t height, - const array_ptr& txs) NOEXCEPT; + const array_ptr& txs, const legacy_ptr& received, + const legacy_ptr& redeemed) NOEXCEPT; void notify_disconnected(const header_cptr& header, size_t height) NOEXCEPT; @@ -211,6 +221,21 @@ class BCS_API protocol_btcd const point& prevout, const sizes& heights) NOEXCEPT; network::rpc::array_t serialize_matches(const matched_txs& txs) NOEXCEPT; + /// Legacy (notifyreceived/notifyspent) individual notifications. + /// ----------------------------------------------------------------------- + + // Arms a one-shot spent-watch on the output a receive watch matched. + void arm_spent_watches(const system::chain::transaction& tx, + const hash_digest& hash) NOEXCEPT; + + // Builds [txHex, blockDetails] params for a recvtx/redeemingtx. + network::rpc::array_t serialize_legacy( + const system::chain::transaction& tx, const header_cptr& header, + size_t height, size_t position) NOEXCEPT; + + // Combined DoS budget across all watch-list maps. + size_t watch_count() const NOEXCEPT; + private: template inline void btcd_subscribe(Method&& method, Args&&... args) NOEXCEPT @@ -238,6 +263,10 @@ class BCS_API protocol_btcd std::atomic_bool stopping_{}; std::atomic_bool subscribed_blocks_{}; + // Set once a legacy watch arms, so handle_chase posts do_connected + // even without notifyblocks (unlike loadtxfilter, not required here). + std::atomic_bool watching_legacy_{}; + // This is protected by strand. btcd_dispatcher btcd_dispatcher_{}; @@ -247,6 +276,11 @@ class BCS_API protocol_btcd // These are protected by notification strand. std::map outpoint_watches_{}; std::map address_watches_{}; + + // Legacy (notifyreceived/notifyspent) watches: notifyspent (explicit or + // auto-armed) is one-shot, kept separate from loadtxfilter's permanent maps. + std::map receive_watches_{}; + std::map spent_watches_{}; }; } // namespace server diff --git a/src/protocols/btcd/protocol_btcd.cpp b/src/protocols/btcd/protocol_btcd.cpp index 74e146be..38802c2d 100644 --- a/src/protocols/btcd/protocol_btcd.cpp +++ b/src/protocols/btcd/protocol_btcd.cpp @@ -273,40 +273,9 @@ bool protocol_btcd::handle_stop_notify_new_transactions(const code& ec, return true; } -// Handlers (deprecated, not_implemented). +// Handlers (deprecated). // ---------------------------------------------------------------------------- - -bool protocol_btcd::handle_notify_received(const code& ec, - btcd_interface::notify_received, const value_t&) NOEXCEPT -{ - if (stopped(ec)) return false; - send_error(error::btcd::unimplemented); - return true; -} - -bool protocol_btcd::handle_stop_notify_received(const code& ec, - btcd_interface::stop_notify_received, const value_t&) NOEXCEPT -{ - if (stopped(ec)) return false; - send_error(error::btcd::unimplemented); - return true; -} - -bool protocol_btcd::handle_notify_spent(const code& ec, - btcd_interface::notify_spent, const value_t&) NOEXCEPT -{ - if (stopped(ec)) return false; - send_error(error::btcd::unimplemented); - return true; -} - -bool protocol_btcd::handle_stop_notify_spent(const code& ec, - btcd_interface::stop_notify_spent, const value_t&) NOEXCEPT -{ - if (stopped(ec)) return false; - send_error(error::btcd::unimplemented); - return true; -} +// notify_received/notify_spent bodies live in protocol_btcd_filter.cpp. // Implemented only for the empty addresses/outpoints case (as btcd). // This is the call btcwallet makes to bootstrap its sync starting point. @@ -375,7 +344,7 @@ bool protocol_btcd::handle_chase(const code&, node::chase event_, { case node::chase::organized: { - if (subscribed_blocks_.load(relaxed)) + if (subscribed_blocks_.load(relaxed) || watching_legacy_.load(relaxed)) { BC_ASSERT(std::holds_alternative(value)); POST_NOTIFY(do_connected, std::get(value)); @@ -384,7 +353,7 @@ bool protocol_btcd::handle_chase(const code&, node::chase event_, } case node::chase::reorganized: { - if (subscribed_blocks_.load(relaxed)) + if (subscribed_blocks_.load(relaxed) || watching_legacy_.load(relaxed)) { BC_ASSERT(std::holds_alternative(value)); POST_NOTIFY(do_disconnected, std::get(value)); diff --git a/src/protocols/btcd/protocol_btcd_filter.cpp b/src/protocols/btcd/protocol_btcd_filter.cpp index ea4c4f1a..455d0828 100644 --- a/src/protocols/btcd/protocol_btcd_filter.cpp +++ b/src/protocols/btcd/protocol_btcd_filter.cpp @@ -100,8 +100,7 @@ void protocol_btcd::do_load_tx_filter(bool reload, const hashes& keys, if (stopping_) return; - if (ceilinged_add(address_watches_.size(), outpoint_watches_.size()) >= - maximum) + if (watch_count() >= maximum) { ec = error::btcd::misc_error; break; @@ -126,8 +125,7 @@ void protocol_btcd::do_load_tx_filter(bool reload, const hashes& keys, if (ec) break; - if (ceilinged_add(address_watches_.size(), outpoint_watches_.size()) >= - maximum) + if (watch_count() >= maximum) { ec = error::btcd::misc_error; break; @@ -164,6 +162,207 @@ void protocol_btcd::complete_load_tx_filter(const code& ec) NOEXCEPT send_result({}, 4); } +// Handlers (notifyreceived/notifyspent). +// ---------------------------------------------------------------------------- +// Confirmed-block matching only (no tx pool in v4), reusing loadtxfilter's +// cursor-based history matching. + +bool protocol_btcd::handle_notify_received(const code& ec, + btcd_interface::notify_received, const value_t& addresses) NOEXCEPT +{ + if (stopped(ec)) + return false; + + hashes keys{}; + if (btcd::filter_keys(keys, addresses, p2kh_, p2sh_, witness_)) + { + send_error(error::btcd::invalid_parameter); + return true; + } + + if (!keys.empty() && !archive().address_enabled()) + { + send_error(error::btcd::unimplemented); + return true; + } + + monitor(true); + POST_NOTIFY(do_notify_received, std::move(keys)); + return true; +} + +void protocol_btcd::do_notify_received(const hashes& keys) NOEXCEPT +{ + BC_ASSERT(notification_strand_.running_in_this_thread()); + + histories discard{}; + code ec{ error::success }; + const auto& query = archive(); + const auto limit = btcd_options().maximum_history; + + for (const auto& key: keys) + { + if (stopping_) + return; + + if (watch_count() >= btcd_options().maximum_filters) + { + ec = error::btcd::misc_error; + break; + } + + // Prime the cursor to present, so matching reports new blocks only. + const auto at = receive_watches_.try_emplace(key, address_watch{}); + if (at.second) + { + watching_legacy_.store(true, relaxed); + const auto fault = query.get_history(stopping_, + at.first->second.cursor, discard, key, limit, turbo_); + if (fault == database::error::query_canceled) + return; + } + } + + POST_BTCD(complete_notify_received, ec); +} + +void protocol_btcd::complete_notify_received(const code& ec) NOEXCEPT +{ + BC_ASSERT(stranded()); + + monitor(false); + if (stopped()) + return; + + if (ec) + { + using namespace error::btcd; + send_error(translate(ec, internal_error)); + return; + } + + send_result({}, 4); +} + +bool protocol_btcd::handle_stop_notify_received(const code& ec, + btcd_interface::stop_notify_received, const value_t& addresses) NOEXCEPT +{ + if (stopped(ec)) + return false; + + hashes keys{}; + if (btcd::filter_keys(keys, addresses, p2kh_, p2sh_, witness_)) + { + send_error(error::btcd::invalid_parameter); + return true; + } + + POST_NOTIFY(do_stop_notify_received, std::move(keys)); + send_result({}, 4); + return true; +} + +void protocol_btcd::do_stop_notify_received(const hashes& keys) NOEXCEPT +{ + BC_ASSERT(notification_strand_.running_in_this_thread()); + + for (const auto& key: keys) + receive_watches_.erase(key); +} + +bool protocol_btcd::handle_notify_spent(const code& ec, + btcd_interface::notify_spent, const value_t& outpoints) NOEXCEPT +{ + if (stopped(ec)) + return false; + + chain::points points{}; + if (btcd::filter_points(points, outpoints)) + { + send_error(error::btcd::invalid_parameter); + return true; + } + + monitor(true); + POST_NOTIFY(do_notify_spent, std::move(points)); + return true; +} + +void protocol_btcd::do_notify_spent(const chain::points& points) NOEXCEPT +{ + BC_ASSERT(notification_strand_.running_in_this_thread()); + + code ec{ error::success }; + const auto& query = archive(); + + for (const auto& prevout: points) + { + if (stopping_) + return; + + if (watch_count() >= btcd_options().maximum_filters) + { + ec = error::btcd::misc_error; + break; + } + + const auto at = spent_watches_.try_emplace(prevout, outpoint_watch{}); + if (at.second) + { + watching_legacy_.store(true, relaxed); + auto& sub = at.first->second; + sub.outpoint = query.get_tx_history(query.to_tx(prevout.hash())); + sub.spenders = query.get_spenders_history(prevout); + } + } + + POST_BTCD(complete_notify_spent, ec); +} + +void protocol_btcd::complete_notify_spent(const code& ec) NOEXCEPT +{ + BC_ASSERT(stranded()); + + monitor(false); + if (stopped()) + return; + + if (ec) + { + using namespace error::btcd; + send_error(translate(ec, internal_error)); + return; + } + + send_result({}, 4); +} + +bool protocol_btcd::handle_stop_notify_spent(const code& ec, + btcd_interface::stop_notify_spent, const value_t& outpoints) NOEXCEPT +{ + if (stopped(ec)) + return false; + + chain::points points{}; + if (btcd::filter_points(points, outpoints)) + { + send_error(error::btcd::invalid_parameter); + return true; + } + + POST_NOTIFY(do_stop_notify_spent, std::move(points)); + send_result({}, 4); + return true; +} + +void protocol_btcd::do_stop_notify_spent(const chain::points& points) NOEXCEPT +{ + BC_ASSERT(notification_strand_.running_in_this_thread()); + + for (const auto& prevout: points) + spent_watches_.erase(prevout); +} + bool protocol_btcd::handle_rescan_blocks(const code& ec, btcd_interface::rescan_blocks, const value_t& blockhashes) NOEXCEPT { @@ -516,8 +715,57 @@ void protocol_btcd::do_connected(node::header_t link_value) NOEXCEPT if (at != matched.end()) txs = serialize_matches(at->second); + // Legacy (notifyreceived) individual notifications; auto-arms spent-watches. + matches received{}; + for (auto& [key, sub]: receive_watches_) + { + if (stopping_) + return; + + const auto fault = match_addresses(received, sub, key, heights); + if (fault == database::error::query_canceled) + return; + } + + std::vector receive_notifications{}; + const auto receive_at = received.find(height); + if (receive_at != received.end()) + for (const auto& [position, hash]: receive_at->second) + if (const auto tx = query.get_transaction(query.to_tx(hash), true); tx) + { + arm_spent_watches(*tx, hash); + receive_notifications.push_back( + serialize_legacy(*tx, header, height, position)); + } + + // Legacy (notifyspent, including auto-armed) one-shot notifications. + std::vector spent_notifications{}; + for (auto it = spent_watches_.begin(); it != spent_watches_.end();) + { + if (stopping_) + return; + + matches spent{}; + match_outpoints(spent, it->second, it->first, heights); + const auto spent_at = spent.find(height); + if (spent_at == spent.end() || spent_at->second.empty()) + { + ++it; + continue; + } + + for (const auto& [position, hash]: spent_at->second) + if (const auto tx = query.get_transaction(query.to_tx(hash), true); tx) + spent_notifications.push_back( + serialize_legacy(*tx, header, height, position)); + + it = spent_watches_.erase(it); + } + POST_BTCD(notify_connected, header, height, - emplace_shared(std::move(txs))); + emplace_shared(std::move(txs)), + emplace_shared>(std::move(receive_notifications)), + emplace_shared>(std::move(spent_notifications))); } void protocol_btcd::do_disconnected(node::header_t link_value) NOEXCEPT @@ -528,6 +776,9 @@ void protocol_btcd::do_disconnected(node::header_t link_value) NOEXCEPT for (auto& [key, sub]: address_watches_) sub.cursor = {}; + for (auto& [key, sub]: receive_watches_) + sub.cursor = {}; + const database::header_link link{ link_value }; const auto& query = archive(); @@ -543,25 +794,36 @@ void protocol_btcd::do_disconnected(node::header_t link_value) NOEXCEPT } void protocol_btcd::notify_connected(const header_cptr& header, - size_t height, const array_ptr& txs) NOEXCEPT + size_t height, const array_ptr& txs, const legacy_ptr& received, + const legacy_ptr& redeemed) NOEXCEPT { BC_ASSERT(stranded()); - if (stopped() || !subscribed_blocks_.load(relaxed)) + if (stopped()) return; - // Elements are moved, as a braced initializer list always copies. - array_t connected{}; - connected.emplace_back(encode_hash(header->get_hash())); - connected.emplace_back(height); - connected.emplace_back(header->timestamp()); - send_notification("blockconnected", std::move(connected), 256); + // recvtx/redeemingtx (below), unlike blockconnected, don't need notifyblocks. + if (subscribed_blocks_.load(relaxed)) + { + // Elements are moved, as a braced initializer list always copies. + array_t connected{}; + connected.emplace_back(encode_hash(header->get_hash())); + connected.emplace_back(height); + connected.emplace_back(header->timestamp()); + send_notification("blockconnected", std::move(connected), 256); + + array_t filtered{}; + filtered.emplace_back(height); + filtered.emplace_back(to_text(*header, chain::header::serialized_size())); + filtered.emplace_back(std::move(*txs)); + send_notification("filteredblockconnected", std::move(filtered), 256); + } - array_t filtered{}; - filtered.emplace_back(height); - filtered.emplace_back(to_text(*header, chain::header::serialized_size())); - filtered.emplace_back(std::move(*txs)); - send_notification("filteredblockconnected", std::move(filtered), 256); + for (auto& params: *received) + send_notification("recvtx", std::move(params), 256); + + for (auto& params: *redeemed) + send_notification("redeemingtx", std::move(params), 256); } void protocol_btcd::notify_disconnected(const header_cptr& header, @@ -639,6 +901,58 @@ array_t protocol_btcd::serialize_matches(const matched_txs& txs) NOEXCEPT return out; } +// Mirrors real btcd's auto-registration of a spent-watch on a match. +void protocol_btcd::arm_spent_watches(const chain::transaction& tx, + const hash_digest& hash) NOEXCEPT +{ + BC_ASSERT(notification_strand_.running_in_this_thread()); + + const auto& query = archive(); + uint32_t index{}; + for (const auto& out: *tx.outputs_ptr()) + { + if (receive_watches_.contains(out->script().hash())) + { + const point prevout{ hash, index }; + const auto at = spent_watches_.try_emplace(prevout, outpoint_watch{}); + if (at.second) + { + auto& sub = at.first->second; + sub.outpoint = query.get_tx_history(query.to_tx(prevout.hash())); + sub.spenders = query.get_spenders_history(prevout); + } + } + + ++index; + } +} + +// [txHex, blockDetails] wire shape of a real btcd recvtx/redeemingtx. +array_t protocol_btcd::serialize_legacy(const chain::transaction& tx, + const header_cptr& header, size_t height, size_t position) NOEXCEPT +{ + constexpr auto witness = true; + return array_t + { + to_text(tx, tx.serialized_size(witness), witness), + object_t + { + { "height", height }, + { "hash", encode_hash(header->get_hash()) }, + { "index", position }, + { "time", header->timestamp() } + } + }; +} + +// Combined DoS budget across all watch-list maps. +size_t protocol_btcd::watch_count() const NOEXCEPT +{ + return ceilinged_add(ceilinged_add(ceilinged_add( + address_watches_.size(), outpoint_watches_.size()), + receive_watches_.size()), spent_watches_.size()); +} + BC_POP_WARNING() BC_POP_WARNING() BC_POP_WARNING() From ce02d827f3790c49da0d18ae43a0318bbd0a7f76 Mon Sep 17 00:00:00 2001 From: Kevin Abramczyk Date: Mon, 14 Sep 2026 06:13:43 +0200 Subject: [PATCH 02/10] Add Boost.Test coverage for notifyreceived and notifyspent. Replaces the four not_implemented regression tests with real coverage: input validation, register/unregister acks, a recvtx delivery test (notifyreceived without notifyblocks, proving the two subscriptions are independent), and a redeemingtx delivery test against mock_block10's known spend of block1's coinbase. Updates btcd_methods::names and the served/unserved static_asserts in test/interfaces/btcd.cpp now that these four methods are no longer tagged unimplemented. --- test/interfaces/btcd.cpp | 14 ++++-- test/protocols/btcd/btcd_rpc.cpp | 77 ++++++++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 12 deletions(-) diff --git a/test/interfaces/btcd.cpp b/test/interfaces/btcd.cpp index 431fce19..1235f8e5 100644 --- a/test/interfaces/btcd.cpp +++ b/test/interfaces/btcd.cpp @@ -51,12 +51,17 @@ constexpr bool btcd_unserved(const std::string_view& name) NOEXCEPT // ----------------------------------------------------------------------------- // These are dispatchable but answer not_implemented (see protocol_btcd). +// No mempool in v4, so unconfirmed-tx notification has no substitute. static_assert(btcd_unserved("notifynewtransactions")); static_assert(btcd_unserved("stopnotifynewtransactions")); -static_assert(btcd_unserved("notifyreceived")); -static_assert(btcd_unserved("stopnotifyreceived")); -static_assert(btcd_unserved("notifyspent")); -static_assert(btcd_unserved("stopnotifyspent")); + +// notifyreceived/notifyspent are served at confirmed-only fidelity (no +// mempool in v4), reusing loadtxfilter's cursor-based matching -- see +// protocol_btcd_filter.cpp. +static_assert(btcd_served("notifyreceived")); +static_assert(btcd_served("stopnotifyreceived")); +static_assert(btcd_served("notifyspent")); +static_assert(btcd_served("stopnotifyspent")); // rescan is served for the empty addresses/outpoints form (btcwallet sync // bootstrap), so it is published despite its group. @@ -77,4 +82,5 @@ static_assert(btcd_methods::names == "version " "notifyblocks stopnotifyblocks " "loadtxfilter rescanblocks " + "notifyreceived stopnotifyreceived notifyspent stopnotifyspent " "rescan"); diff --git a/test/protocols/btcd/btcd_rpc.cpp b/test/protocols/btcd/btcd_rpc.cpp index 50051aba..42b6b8c4 100644 --- a/test/protocols/btcd/btcd_rpc.cpp +++ b/test/protocols/btcd/btcd_rpc.cpp @@ -569,24 +569,85 @@ BOOST_AUTO_TEST_CASE(btcd_rpc__searchrawtransactions__filteraddrs__filtered_vin_ // deprecated // ---------------------------------------------------------------------------- -BOOST_AUTO_TEST_CASE(btcd_rpc__notifyreceived__default__not_implemented) +BOOST_AUTO_TEST_CASE(btcd_rpc__notifyreceived__invalid_address__invalid_parameter) { - BOOST_REQUIRE_EQUAL(rpc_error("notifyreceived", "[[]]"), unimplemented.value()); + const auto result = rpc_error("notifyreceived", (boost_format(R"([["%1%"]])") % bogus_address).str()); + BOOST_REQUIRE_EQUAL(result, invalid_parameter.value()); +} + +BOOST_AUTO_TEST_CASE(btcd_rpc__notifyreceived__valid_address__null_result) +{ + const auto response = rpc("notifyreceived", (boost_format(R"([["%1%"]])") % found_address).str()); + BOOST_REQUIRE_MESSAGE(response.is_object() && response.as_object().contains("result"), serialize(response)); + REQUIRE_NO_THROW_TRUE(response.at("result").is_null()); +} + +BOOST_AUTO_TEST_CASE(btcd_rpc__stopnotifyreceived__default__null_result) +{ + const auto response = rpc("stopnotifyreceived", (boost_format(R"([["%1%"]])") % found_address).str()); + BOOST_REQUIRE_MESSAGE(response.is_object() && response.as_object().contains("result"), serialize(response)); + REQUIRE_NO_THROW_TRUE(response.at("result").is_null()); +} + +BOOST_AUTO_TEST_CASE(btcd_rpc__notifyspent__invalid_outpoint__invalid_parameter) +{ + const auto result = rpc_error("notifyspent", R"([[{"hash":"00"}]])"); + BOOST_REQUIRE_EQUAL(result, invalid_parameter.value()); +} + +BOOST_AUTO_TEST_CASE(btcd_rpc__notifyspent__valid_outpoint__null_result) +{ + const auto request = R"([[{"hash":"%1%","index":0}]])"; + const auto response = rpc("notifyspent", (boost_format(request) % coinbase_txid(test::block1)).str()); + BOOST_REQUIRE_MESSAGE(response.is_object() && response.as_object().contains("result"), serialize(response)); + REQUIRE_NO_THROW_TRUE(response.at("result").is_null()); } -BOOST_AUTO_TEST_CASE(btcd_rpc__stopnotifyreceived__default__not_implemented) +BOOST_AUTO_TEST_CASE(btcd_rpc__stopnotifyspent__default__null_result) { - BOOST_REQUIRE_EQUAL(rpc_error("stopnotifyreceived", "[[]]"), unimplemented.value()); + const auto request = R"([[{"hash":"%1%","index":0}]])"; + const auto response = rpc("stopnotifyspent", (boost_format(request) % coinbase_txid(test::block1)).str()); + BOOST_REQUIRE_MESSAGE(response.is_object() && response.as_object().contains("result"), serialize(response)); + REQUIRE_NO_THROW_TRUE(response.at("result").is_null()); } -BOOST_AUTO_TEST_CASE(btcd_rpc__notifyspent__default__not_implemented) +BOOST_AUTO_TEST_CASE(btcd_rpc__recvtx__address_match__delivered_without_notifyblocks) { - BOOST_REQUIRE_EQUAL(rpc_error("notifyspent", "[[]]"), unimplemented.value()); + // recvtx does not require notifyblocks (unlike filteredblockconnected). + rpc("notifyreceived", (boost_format(R"([["%1%"]])") % found_address).str()); + + BOOST_REQUIRE(query_.set(test::mock_block10, database::context{ 0, 10, 0 }, false, false)); + BOOST_REQUIRE(query_.push_confirmed(query_.to_header(test::mock_block10.hash()), true)); + + notify(node::chase::organized, node::header_t{ 10 }); + + const auto recvtx = receive_notification(); + BOOST_REQUIRE_EQUAL(as_text(recvtx.at("method")), "recvtx"); + + const auto& params = recvtx.at("params").as_array(); + BOOST_REQUIRE_EQUAL(params.size(), 2u); + BOOST_REQUIRE(params[0].is_string()); + BOOST_REQUIRE_EQUAL(params[1].at("height").as_int64(), 10); + BOOST_REQUIRE_EQUAL(as_text(params[1].at("hash")), encode_hash(test::mock_block10.hash())); } -BOOST_AUTO_TEST_CASE(btcd_rpc__stopnotifyspent__default__not_implemented) +BOOST_AUTO_TEST_CASE(btcd_rpc__redeemingtx__notified_outpoint_spent__delivered_once) { - BOOST_REQUIRE_EQUAL(rpc_error("stopnotifyspent", "[[]]"), unimplemented.value()); + // The paying transaction in mock_block10 spends block1's coinbase. + const auto request = R"([[{"hash":"%1%","index":0}]])"; + rpc("notifyspent", (boost_format(request) % coinbase_txid(test::block1)).str()); + + BOOST_REQUIRE(query_.set(test::mock_block10, database::context{ 0, 10, 0 }, false, false)); + BOOST_REQUIRE(query_.push_confirmed(query_.to_header(test::mock_block10.hash()), true)); + + notify(node::chase::organized, node::header_t{ 10 }); + + const auto redeemingtx = receive_notification(); + BOOST_REQUIRE_EQUAL(as_text(redeemingtx.at("method")), "redeemingtx"); + + const auto& params = redeemingtx.at("params").as_array(); + BOOST_REQUIRE_EQUAL(params.size(), 2u); + BOOST_REQUIRE_EQUAL(params[1].at("height").as_int64(), 10); } BOOST_AUTO_TEST_CASE(btcd_rpc__rescan__unknown_beginblock__not_found) From c997e11aa00f0f7be0d783a383bef93df7c5ab33 Mon Sep 17 00:00:00 2001 From: Kevin Abramczyk Date: Mon, 14 Sep 2026 06:13:53 +0200 Subject: [PATCH 03/10] Update btcd Python acceptance suite for notifyreceived and notifyspent. Removes the DEPRECATED_STUBS blanket "always not_implemented" table and its regression test, now that these methods are real. Adds ack/reject tests for notifyreceived/stopnotifyreceived/notifyspent/stopnotifyspent mirroring the existing loadtxfilter test pattern. No live recvtx/ redeemingtx delivery test here -- would need real mainnet funds arriving on cue; the C++ mock-store tests already cover the wire behavior deterministically. --- endpoints/test_btcd_rpc.py | 47 ++++++++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/endpoints/test_btcd_rpc.py b/endpoints/test_btcd_rpc.py index fa6daa3f..2c6df689 100644 --- a/endpoints/test_btcd_rpc.py +++ b/endpoints/test_btcd_rpc.py @@ -532,15 +532,6 @@ def test_btcd_and_bitcoind_method_share_one_websocket_connection(conn): ("stopnotifynewtransactions", []), ] -# Deprecated upstream (superseded by loadtxfilter/rescanblocks) but still -# wired -- included so a regression can't silently start "working" in a way -# that contradicts the deliberate scope decision to leave these stubbed. -DEPRECATED_STUBS = [ - ("notifyreceived", [[]]), - ("stopnotifyreceived", [[]]), - ("notifyspent", [[]]), - ("stopnotifyspent", [[]]), -] @pytest.mark.xfail(reason="wired stub, handler not yet implemented", @@ -553,12 +544,38 @@ def test_stub_not_yet_implemented(conn, method, params): ) -@pytest.mark.parametrize("method,params", DEPRECATED_STUBS) -def test_deprecated_method_stays_not_implemented(conn, method, params): - """Regression guard, not a development target: these are deliberately - never implemented (superseded upstream by loadtxfilter/rescanblocks).""" - data = conn.raw_rpc(method, params) - assert data.get("error") is not None +def test_notifyreceived_valid_address_acknowledges(conn): + """Confirmed-only matching (no mempool in v4), reusing loadtxfilter's + cursor-based history matching -- see protocol_btcd_filter.cpp.""" + response = conn.send_rpc("notifyreceived", [[ReferenceData.EXAMPLE_ADDRESS]]) + assert response.get("error") is None + + +def test_notifyreceived_invalid_address_rejected(conn): + response = conn.raw_rpc("notifyreceived", [["not-an-address"]]) + assert response.get("error") is not None + + +def test_stopnotifyreceived_acknowledges(conn): + response = conn.send_rpc("stopnotifyreceived", [[ReferenceData.EXAMPLE_ADDRESS]]) + assert response.get("error") is None + + +def test_notifyspent_valid_outpoint_acknowledges(conn): + response = conn.send_rpc("notifyspent", + [[{"hash": ReferenceData.GENESIS_TX_HASH, "index": 0}]]) + assert response.get("error") is None + + +def test_notifyspent_malformed_outpoint_rejected(conn): + response = conn.raw_rpc("notifyspent", [[{"hash": "00"}]]) + assert response.get("error") is not None + + +def test_stopnotifyspent_acknowledges(conn): + response = conn.send_rpc("stopnotifyspent", + [[{"hash": ReferenceData.GENESIS_TX_HASH, "index": 0}]]) + assert response.get("error") is None def test_stop_always_not_implemented(conn): From d1ef8212dec3406fc9c075c429126c12f5656ac1 Mon Sep 17 00:00:00 2001 From: Eric Voskuil Date: Mon, 14 Sep 2026 13:52:04 -0400 Subject: [PATCH 04/10] Bound auto-armed spent watches by the configured filter maximum. --- .../server/protocols/protocol_btcd.hpp | 5 ++++- src/protocols/btcd/protocol_btcd_filter.cpp | 21 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/include/bitcoin/server/protocols/protocol_btcd.hpp b/include/bitcoin/server/protocols/protocol_btcd.hpp index 4dd3f575..2fd5ccd9 100644 --- a/include/bitcoin/server/protocols/protocol_btcd.hpp +++ b/include/bitcoin/server/protocols/protocol_btcd.hpp @@ -225,9 +225,12 @@ class BCS_API protocol_btcd /// ----------------------------------------------------------------------- // Arms a one-shot spent-watch on the output a receive watch matched. - void arm_spent_watches(const system::chain::transaction& tx, + code arm_spent_watches(const system::chain::transaction& tx, const hash_digest& hash) NOEXCEPT; + // Drops the channel on auto-armed spent-watch overflow. + void complete_overflow(const code& ec) NOEXCEPT; + // Builds [txHex, blockDetails] params for a recvtx/redeemingtx. network::rpc::array_t serialize_legacy( const system::chain::transaction& tx, const header_cptr& header, diff --git a/src/protocols/btcd/protocol_btcd_filter.cpp b/src/protocols/btcd/protocol_btcd_filter.cpp index 455d0828..181f4a8a 100644 --- a/src/protocols/btcd/protocol_btcd_filter.cpp +++ b/src/protocols/btcd/protocol_btcd_filter.cpp @@ -733,7 +733,12 @@ void protocol_btcd::do_connected(node::header_t link_value) NOEXCEPT for (const auto& [position, hash]: receive_at->second) if (const auto tx = query.get_transaction(query.to_tx(hash), true); tx) { - arm_spent_watches(*tx, hash); + if (const auto fault = arm_spent_watches(*tx, hash)) + { + POST_BTCD(complete_overflow, fault); + return; + } + receive_notifications.push_back( serialize_legacy(*tx, header, height, position)); } @@ -902,17 +907,21 @@ array_t protocol_btcd::serialize_matches(const matched_txs& txs) NOEXCEPT } // Mirrors real btcd's auto-registration of a spent-watch on a match. -void protocol_btcd::arm_spent_watches(const chain::transaction& tx, +code protocol_btcd::arm_spent_watches(const chain::transaction& tx, const hash_digest& hash) NOEXCEPT { BC_ASSERT(notification_strand_.running_in_this_thread()); const auto& query = archive(); + const auto maximum = btcd_options().maximum_filters; uint32_t index{}; for (const auto& out: *tx.outputs_ptr()) { if (receive_watches_.contains(out->script().hash())) { + if (watch_count() >= maximum) + return error::btcd::misc_error; + const point prevout{ hash, index }; const auto at = spent_watches_.try_emplace(prevout, outpoint_watch{}); if (at.second) @@ -925,6 +934,14 @@ void protocol_btcd::arm_spent_watches(const chain::transaction& tx, ++index; } + + return error::success; +} + +void protocol_btcd::complete_overflow(const code& ec) NOEXCEPT +{ + BC_ASSERT(stranded()); + stop(ec); } // [txHex, blockDetails] wire shape of a real btcd recvtx/redeemingtx. From c110877e79dd735192564c86f7b8610afe30dd1a Mon Sep 17 00:00:00 2001 From: Eric Voskuil Date: Mon, 14 Sep 2026 13:52:13 -0400 Subject: [PATCH 05/10] Clear the legacy watch flag when the last watch is removed. --- src/protocols/btcd/protocol_btcd_filter.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/protocols/btcd/protocol_btcd_filter.cpp b/src/protocols/btcd/protocol_btcd_filter.cpp index 181f4a8a..0a5f3e09 100644 --- a/src/protocols/btcd/protocol_btcd_filter.cpp +++ b/src/protocols/btcd/protocol_btcd_filter.cpp @@ -268,6 +268,9 @@ void protocol_btcd::do_stop_notify_received(const hashes& keys) NOEXCEPT for (const auto& key: keys) receive_watches_.erase(key); + + if (receive_watches_.empty() && spent_watches_.empty()) + watching_legacy_.store(false, relaxed); } bool protocol_btcd::handle_notify_spent(const code& ec, @@ -361,6 +364,9 @@ void protocol_btcd::do_stop_notify_spent(const chain::points& points) NOEXCEPT for (const auto& prevout: points) spent_watches_.erase(prevout); + + if (receive_watches_.empty() && spent_watches_.empty()) + watching_legacy_.store(false, relaxed); } bool protocol_btcd::handle_rescan_blocks(const code& ec, From c4d9a3e17300bd7ecb54d2d3b99d992c599ba1a7 Mon Sep 17 00:00:00 2001 From: Eric Voskuil Date: Mon, 14 Sep 2026 14:12:59 -0400 Subject: [PATCH 06/10] Notify recvtx only for a transaction that pays a watched address. --- include/bitcoin/server/protocols/protocol_btcd.hpp | 4 ++-- src/protocols/btcd/protocol_btcd_filter.cpp | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/include/bitcoin/server/protocols/protocol_btcd.hpp b/include/bitcoin/server/protocols/protocol_btcd.hpp index 2fd5ccd9..2f95c92d 100644 --- a/include/bitcoin/server/protocols/protocol_btcd.hpp +++ b/include/bitcoin/server/protocols/protocol_btcd.hpp @@ -224,8 +224,8 @@ class BCS_API protocol_btcd /// Legacy (notifyreceived/notifyspent) individual notifications. /// ----------------------------------------------------------------------- - // Arms a one-shot spent-watch on the output a receive watch matched. - code arm_spent_watches(const system::chain::transaction& tx, + // Arms a one-shot spent-watch on each output a receive watch matched. + code arm_spent_watches(bool& paid, const system::chain::transaction& tx, const hash_digest& hash) NOEXCEPT; // Drops the channel on auto-armed spent-watch overflow. diff --git a/src/protocols/btcd/protocol_btcd_filter.cpp b/src/protocols/btcd/protocol_btcd_filter.cpp index 0a5f3e09..bce34b84 100644 --- a/src/protocols/btcd/protocol_btcd_filter.cpp +++ b/src/protocols/btcd/protocol_btcd_filter.cpp @@ -739,14 +739,17 @@ void protocol_btcd::do_connected(node::header_t link_value) NOEXCEPT for (const auto& [position, hash]: receive_at->second) if (const auto tx = query.get_transaction(query.to_tx(hash), true); tx) { - if (const auto fault = arm_spent_watches(*tx, hash)) + // The address walk matches spends, which are not receives. + bool paid{}; + if (const auto fault = arm_spent_watches(paid, *tx, hash)) { POST_BTCD(complete_overflow, fault); return; } - receive_notifications.push_back( - serialize_legacy(*tx, header, height, position)); + if (paid) + receive_notifications.push_back( + serialize_legacy(*tx, header, height, position)); } // Legacy (notifyspent, including auto-armed) one-shot notifications. @@ -913,11 +916,12 @@ array_t protocol_btcd::serialize_matches(const matched_txs& txs) NOEXCEPT } // Mirrors real btcd's auto-registration of a spent-watch on a match. -code protocol_btcd::arm_spent_watches(const chain::transaction& tx, +code protocol_btcd::arm_spent_watches(bool& paid, const chain::transaction& tx, const hash_digest& hash) NOEXCEPT { BC_ASSERT(notification_strand_.running_in_this_thread()); + paid = false; const auto& query = archive(); const auto maximum = btcd_options().maximum_filters; uint32_t index{}; @@ -925,6 +929,8 @@ code protocol_btcd::arm_spent_watches(const chain::transaction& tx, { if (receive_watches_.contains(out->script().hash())) { + paid = true; + if (watch_count() >= maximum) return error::btcd::misc_error; From dcd1ea50ad42aaa4ab00af211eac7f44832b9841 Mon Sep 17 00:00:00 2001 From: Eric Voskuil Date: Mon, 14 Sep 2026 13:52:22 -0400 Subject: [PATCH 07/10] Report a spend occurring in the block that armed its watch. --- src/protocols/btcd/protocol_btcd_filter.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/protocols/btcd/protocol_btcd_filter.cpp b/src/protocols/btcd/protocol_btcd_filter.cpp index bce34b84..1a400916 100644 --- a/src/protocols/btcd/protocol_btcd_filter.cpp +++ b/src/protocols/btcd/protocol_btcd_filter.cpp @@ -940,7 +940,6 @@ code protocol_btcd::arm_spent_watches(bool& paid, const chain::transaction& tx, { auto& sub = at.first->second; sub.outpoint = query.get_tx_history(query.to_tx(prevout.hash())); - sub.spenders = query.get_spenders_history(prevout); } } From a046206b2807698def288e0447911c29fa652a1b Mon Sep 17 00:00:00 2001 From: Eric Voskuil Date: Mon, 14 Sep 2026 14:02:50 -0400 Subject: [PATCH 08/10] Prime the notifyspent watch as the auto-armed watch, and cover the same block. --- src/protocols/btcd/protocol_btcd_filter.cpp | 1 - test/mocks/blocks.cpp | 88 +++++++++++++++++++++ test/mocks/blocks.hpp | 3 + test/protocols/btcd/btcd_rpc.cpp | 18 +++++ 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/protocols/btcd/protocol_btcd_filter.cpp b/src/protocols/btcd/protocol_btcd_filter.cpp index 1a400916..1e1982aa 100644 --- a/src/protocols/btcd/protocol_btcd_filter.cpp +++ b/src/protocols/btcd/protocol_btcd_filter.cpp @@ -315,7 +315,6 @@ void protocol_btcd::do_notify_spent(const chain::points& points) NOEXCEPT watching_legacy_.store(true, relaxed); auto& sub = at.first->second; sub.outpoint = query.get_tx_history(query.to_tx(prevout.hash())); - sub.spenders = query.get_spenders_history(prevout); } } diff --git a/test/mocks/blocks.cpp b/test/mocks/blocks.cpp index 334bd27d..926fbcc9 100644 --- a/test/mocks/blocks.cpp +++ b/test/mocks/blocks.cpp @@ -926,4 +926,92 @@ const transaction tx1c 0x00 // locktime (not absolute locked) }; +// Pays found_address, spent by a later transaction in the same block. +const transaction mock_tx13 +{ + 0x06, + inputs + { + input + { + point{ block3.transactions_ptr()->front()->hash(false), 0x00 }, + script{}, + witness{}, + 0x07 + } + }, + outputs + { + output + { + 0x09, + // "1BaMPFdqMUQ46BV8iRcwbVfsam57oBLMM" + script::to_pay_key_hash_pattern({ 0x02 }) + } + }, + 0x0a +}; +const block mock_block13 +{ + header + { + 0x31323334, + block9_hash, + hash_digest{ 0x13, 0xbb }, + 0x41424344, + 0x51525354, + 0x61626364 + }, + transactions + { + transaction + { + 0x01, + inputs + { + input + { + point{}, + script{}, + witness{}, + 0x02 + } + }, + outputs + { + output + { + 0x04, + script::to_pay_key_hash_pattern({ 0x01 }) + } + }, + 0x05 + }, + mock_tx13, + transaction + { + 0x0b, + inputs + { + input + { + point{ mock_tx13.hash(false), 0x00 }, + script{}, + witness{}, + 0x0c + } + }, + outputs + { + output + { + 0x0e, + script::to_pay_script_hash_pattern({ 0x04 }) + } + }, + 0x0f + } + } +}; + } // namespace test diff --git a/test/mocks/blocks.hpp b/test/mocks/blocks.hpp index 50ab1e3b..9fd46d11 100644 --- a/test/mocks/blocks.hpp +++ b/test/mocks/blocks.hpp @@ -88,6 +88,9 @@ extern const system::chain::block mock_block10; extern const system::chain::block mock_block11; extern const system::chain::block mock_block12; +extern const system::chain::transaction mock_tx13; +extern const system::chain::block mock_block13; + extern const system::chain::block mock_block_a; extern const system::chain::block block1a; extern const system::chain::block block2a; diff --git a/test/protocols/btcd/btcd_rpc.cpp b/test/protocols/btcd/btcd_rpc.cpp index 42b6b8c4..ad2f652e 100644 --- a/test/protocols/btcd/btcd_rpc.cpp +++ b/test/protocols/btcd/btcd_rpc.cpp @@ -631,6 +631,24 @@ BOOST_AUTO_TEST_CASE(btcd_rpc__recvtx__address_match__delivered_without_notifybl BOOST_REQUIRE_EQUAL(as_text(params[1].at("hash")), encode_hash(test::mock_block10.hash())); } +BOOST_AUTO_TEST_CASE(btcd_rpc__redeemingtx__spent_in_arming_block__delivered) +{ + // The receive match arms the spent watch, and the spender is in that block. + rpc("notifyreceived", (boost_format(R"([["%1%"]])") % found_address).str()); + + BOOST_REQUIRE(query_.set(test::mock_block13, database::context{ 0, 10, 0 }, false, false)); + BOOST_REQUIRE(query_.push_confirmed(query_.to_header(test::mock_block13.hash()), true)); + + notify(node::chase::organized, node::header_t{ 10 }); + + const auto recvtx = receive_notification(); + BOOST_REQUIRE_EQUAL(as_text(recvtx.at("method")), "recvtx"); + + const auto redeemingtx = receive_notification(); + BOOST_REQUIRE_EQUAL(as_text(redeemingtx.at("method")), "redeemingtx"); + BOOST_REQUIRE_EQUAL(redeemingtx.at("params").as_array()[1].at("height").as_int64(), 10); +} + BOOST_AUTO_TEST_CASE(btcd_rpc__redeemingtx__notified_outpoint_spent__delivered_once) { // The paying transaction in mock_block10 spends block1's coinbase. From 3f5609896a3c294829a1ab8f2dd3d5c38f43897d Mon Sep 17 00:00:00 2001 From: Eric Voskuil Date: Mon, 14 Sep 2026 14:44:00 -0400 Subject: [PATCH 09/10] Decompose the connected-block matcher by notification family. --- .../server/protocols/protocol_btcd.hpp | 9 ++ src/protocols/btcd/protocol_btcd_filter.cpp | 121 ++++++++++++------ 2 files changed, 89 insertions(+), 41 deletions(-) diff --git a/include/bitcoin/server/protocols/protocol_btcd.hpp b/include/bitcoin/server/protocols/protocol_btcd.hpp index 2f95c92d..9d633a4a 100644 --- a/include/bitcoin/server/protocols/protocol_btcd.hpp +++ b/include/bitcoin/server/protocols/protocol_btcd.hpp @@ -215,6 +215,15 @@ class BCS_API protocol_btcd using matches = std::map; using sizes = std::set; + code match_filters(network::rpc::array_t& out, size_t height, + const sizes& heights) NOEXCEPT; + code match_receives(std::vector& out, + const header_cptr& header, size_t height, + const sizes& heights) NOEXCEPT; + code match_spends(std::vector& out, + const header_cptr& header, size_t height, + const sizes& heights) NOEXCEPT; + code match_addresses(matches& out, address_watch& sub, const hash_digest& key, const sizes& heights) NOEXCEPT; void match_outpoints(matches& out, outpoint_watch& sub, diff --git a/src/protocols/btcd/protocol_btcd_filter.cpp b/src/protocols/btcd/protocol_btcd_filter.cpp index 1e1982aa..c7c9da0b 100644 --- a/src/protocols/btcd/protocol_btcd_filter.cpp +++ b/src/protocols/btcd/protocol_btcd_filter.cpp @@ -693,92 +693,131 @@ void protocol_btcd::do_connected(node::header_t link_value) NOEXCEPT if (!header) return; - // Match the watch-list against the connected block (cursored delta). - // Cursors advance here, so this stays on the notification strand. - matches matched{}; + // Cursors advance in the matchers, so this stays on the notification + // strand, and receives are matched first, as each arms a spent-watch. const sizes heights{ height }; + array_t txs{}; + if (match_filters(txs, height, heights)) + return; + + std::vector receive_notifications{}; + if (const auto fault = match_receives(receive_notifications, header, height, + heights)) + { + if (fault != database::error::query_canceled) + POST_BTCD(complete_overflow, fault); + + return; + } + + std::vector spent_notifications{}; + if (match_spends(spent_notifications, header, height, heights)) + return; + + POST_BTCD(notify_connected, header, height, + emplace_shared(std::move(txs)), + emplace_shared>(std::move(receive_notifications)), + emplace_shared>(std::move(spent_notifications))); +} + +// Filter (loadtxfilter) block-level notification. +code protocol_btcd::match_filters(array_t& out, size_t height, + const sizes& heights) NOEXCEPT +{ + BC_ASSERT(notification_strand_.running_in_this_thread()); + + matches matched{}; for (auto& [key, sub]: address_watches_) { if (stopping_) - return; + return database::error::query_canceled; const auto fault = match_addresses(matched, sub, key, heights); if (fault == database::error::query_canceled) - return; + return fault; } for (auto& [prevout, sub]: outpoint_watches_) { if (stopping_) - return; + return database::error::query_canceled; match_outpoints(matched, sub, prevout, heights); } - array_t txs{}; const auto at = matched.find(height); if (at != matched.end()) - txs = serialize_matches(at->second); + out = serialize_matches(at->second); + + return error::success; +} + +// Legacy (notifyreceived) individual notifications; auto-arms spent-watches. +code protocol_btcd::match_receives(std::vector& out, + const header_cptr& header, size_t height, const sizes& heights) NOEXCEPT +{ + BC_ASSERT(notification_strand_.running_in_this_thread()); - // Legacy (notifyreceived) individual notifications; auto-arms spent-watches. matches received{}; for (auto& [key, sub]: receive_watches_) { if (stopping_) - return; + return database::error::query_canceled; const auto fault = match_addresses(received, sub, key, heights); if (fault == database::error::query_canceled) - return; + return fault; } - std::vector receive_notifications{}; - const auto receive_at = received.find(height); - if (receive_at != received.end()) - for (const auto& [position, hash]: receive_at->second) - if (const auto tx = query.get_transaction(query.to_tx(hash), true); tx) - { - // The address walk matches spends, which are not receives. - bool paid{}; - if (const auto fault = arm_spent_watches(paid, *tx, hash)) - { - POST_BTCD(complete_overflow, fault); - return; - } - - if (paid) - receive_notifications.push_back( - serialize_legacy(*tx, header, height, position)); - } + const auto& query = archive(); + const auto at = received.find(height); + if (at == received.end()) + return error::success; - // Legacy (notifyspent, including auto-armed) one-shot notifications. - std::vector spent_notifications{}; + for (const auto& [position, hash]: at->second) + if (const auto tx = query.get_transaction(query.to_tx(hash), true); tx) + { + // The address walk matches spends, which are not receives. + bool paid{}; + if (const auto fault = arm_spent_watches(paid, *tx, hash)) + return fault; + + if (paid) + out.push_back(serialize_legacy(*tx, header, height, position)); + } + + return error::success; +} + +// Legacy (notifyspent, including auto-armed) one-shot notifications. +code protocol_btcd::match_spends(std::vector& out, + const header_cptr& header, size_t height, const sizes& heights) NOEXCEPT +{ + BC_ASSERT(notification_strand_.running_in_this_thread()); + + const auto& query = archive(); for (auto it = spent_watches_.begin(); it != spent_watches_.end();) { if (stopping_) - return; + return database::error::query_canceled; matches spent{}; match_outpoints(spent, it->second, it->first, heights); - const auto spent_at = spent.find(height); - if (spent_at == spent.end() || spent_at->second.empty()) + const auto at = spent.find(height); + if (at == spent.end() || at->second.empty()) { ++it; continue; } - for (const auto& [position, hash]: spent_at->second) + for (const auto& [position, hash]: at->second) if (const auto tx = query.get_transaction(query.to_tx(hash), true); tx) - spent_notifications.push_back( - serialize_legacy(*tx, header, height, position)); + out.push_back(serialize_legacy(*tx, header, height, position)); it = spent_watches_.erase(it); } - POST_BTCD(notify_connected, header, height, - emplace_shared(std::move(txs)), - emplace_shared>(std::move(receive_notifications)), - emplace_shared>(std::move(spent_notifications))); + return error::success; } void protocol_btcd::do_disconnected(node::header_t link_value) NOEXCEPT From 400b846ce4f17d259a6b6a1def1577e650548756 Mon Sep 17 00:00:00 2001 From: Eric Voskuil Date: Mon, 14 Sep 2026 15:51:51 -0400 Subject: [PATCH 10/10] Remove the unread funding history from the btcd outpoint watch. --- .../server/protocols/protocol_btcd.hpp | 1 - src/protocols/btcd/protocol_btcd_filter.cpp | 27 +++---------------- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/include/bitcoin/server/protocols/protocol_btcd.hpp b/include/bitcoin/server/protocols/protocol_btcd.hpp index 9d633a4a..b73fdc7b 100644 --- a/include/bitcoin/server/protocols/protocol_btcd.hpp +++ b/include/bitcoin/server/protocols/protocol_btcd.hpp @@ -167,7 +167,6 @@ class BCS_API protocol_btcd struct outpoint_watch final { - database::history outpoint{}; database::histories spenders{}; }; diff --git a/src/protocols/btcd/protocol_btcd_filter.cpp b/src/protocols/btcd/protocol_btcd_filter.cpp index c7c9da0b..d35ae7dc 100644 --- a/src/protocols/btcd/protocol_btcd_filter.cpp +++ b/src/protocols/btcd/protocol_btcd_filter.cpp @@ -134,11 +134,7 @@ void protocol_btcd::do_load_tx_filter(bool reload, const hashes& keys, // Prime the spender set to present, so matching reports new only. const auto at = outpoint_watches_.try_emplace(prevout, outpoint_watch{}); if (at.second) - { - auto& sub = at.first->second; - sub.outpoint = query.get_tx_history(query.to_tx(prevout.hash())); - sub.spenders = query.get_spenders_history(prevout); - } + at.first->second.spenders = query.get_spenders_history(prevout); } POST_BTCD(complete_load_tx_filter, ec); @@ -296,7 +292,6 @@ void protocol_btcd::do_notify_spent(const chain::points& points) NOEXCEPT BC_ASSERT(notification_strand_.running_in_this_thread()); code ec{ error::success }; - const auto& query = archive(); for (const auto& prevout: points) { @@ -309,13 +304,8 @@ void protocol_btcd::do_notify_spent(const chain::points& points) NOEXCEPT break; } - const auto at = spent_watches_.try_emplace(prevout, outpoint_watch{}); - if (at.second) - { + if (spent_watches_.try_emplace(prevout, outpoint_watch{}).second) watching_legacy_.store(true, relaxed); - auto& sub = at.first->second; - sub.outpoint = query.get_tx_history(query.to_tx(prevout.hash())); - } } POST_BTCD(complete_notify_spent, ec); @@ -924,9 +914,7 @@ void protocol_btcd::match_outpoints(matches& out, outpoint_watch& sub, const point& prevout, const sizes& heights) NOEXCEPT { outpoint_watch next{}; - const auto& query = archive(); - next.outpoint = query.get_tx_history(query.to_tx(prevout.hash())); - next.spenders = query.get_spenders_history(prevout); + next.spenders = archive().get_spenders_history(prevout); for (const auto& spender: difference(next.spenders, sub.spenders)) if (spender.confirmed() && heights.contains(spender.tx.height())) @@ -960,7 +948,6 @@ code protocol_btcd::arm_spent_watches(bool& paid, const chain::transaction& tx, BC_ASSERT(notification_strand_.running_in_this_thread()); paid = false; - const auto& query = archive(); const auto maximum = btcd_options().maximum_filters; uint32_t index{}; for (const auto& out: *tx.outputs_ptr()) @@ -972,13 +959,7 @@ code protocol_btcd::arm_spent_watches(bool& paid, const chain::transaction& tx, if (watch_count() >= maximum) return error::btcd::misc_error; - const point prevout{ hash, index }; - const auto at = spent_watches_.try_emplace(prevout, outpoint_watch{}); - if (at.second) - { - auto& sub = at.first->second; - sub.outpoint = query.get_tx_history(query.to_tx(prevout.hash())); - } + spent_watches_.try_emplace(point{ hash, index }, outpoint_watch{}); } ++index;