From 2b2ddf238ef91a57049cc333c27d83f72b4691b3 Mon Sep 17 00:00:00 2001 From: Abdullah1738 Date: Thu, 6 Aug 2026 03:41:52 +0400 Subject: [PATCH 1/2] feat(rpc): expose node generation identity --- src/rpc/blockchain.cpp | 39 +++ src/rpc/blockchain.h | 3 + src/test/rpc_tests.cpp | 17 ++ .../validation_chainstatemanager_tests.cpp | 28 ++ src/validation.cpp | 77 ++++- src/validation.h | 21 ++ test/functional/rpc_getnodegeneration.py | 270 ++++++++++++++++++ test/functional/test_runner.py | 1 + 8 files changed, 450 insertions(+), 6 deletions(-) create mode 100755 test/functional/rpc_getnodegeneration.py diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 8f2ebb12f22..aefab717e3c 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -3757,6 +3757,44 @@ static RPCHelpMan getsidechaininfo() }; } +UniValue NodeGenerationToJSON(const NodeGenerationSnapshot& generation) +{ + UniValue result(UniValue::VOBJ); + result.pushKV("startup_id", generation.startup_id.GetHex()); + result.pushKV("chainstate_revision", generation.chainstate_revision); + result.pushKV("blocks", generation.blocks); + result.pushKV("bestblockhash", generation.bestblockhash.GetHex()); + return result; +} + +static RPCHelpMan getnodegeneration() +{ + return RPCHelpMan{"getnodegeneration", + "Returns process and active-chain generation fields for stale-response and reorganization ABA detection.\n" + "These fields do not establish binary provenance and do not prove that the connected node is honest.\n", + {}, + RPCResult{ + RPCResult::Type::OBJ, "", "", + { + {RPCResult::Type::STR_HEX, "startup_id", "a cryptographically random 256-bit identifier that is constant for this daemon process"}, + {RPCResult::Type::NUM, "chainstate_revision", "an unsigned counter advanced for every active-chain connect, disconnect, or active chainstate switch"}, + {RPCResult::Type::NUM, "blocks", "the active chain height bound atomically to chainstate_revision"}, + {RPCResult::Type::STR_HEX, "bestblockhash", "the active best block hash bound atomically to chainstate_revision"}, + }}, + RPCExamples{ + HelpExampleCli("getnodegeneration", "") + + HelpExampleRpc("getnodegeneration", "") + }, + [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue +{ + ChainstateManager& chainman = EnsureAnyChainman(request.context); + LOCK(chainman.GetMutex()); + const NodeGenerationSnapshot generation{chainman.GetNodeGeneration()}; + return NodeGenerationToJSON(generation); +}, + }; +} + // END ELEMENTS // @@ -3765,6 +3803,7 @@ void RegisterBlockchainRPCCommands(CRPCTable &t) static const CRPCCommand commands[] = { {"blockchain", &getblockchaininfo}, + {"blockchain", &getnodegeneration}, {"blockchain", &getchaintxstats}, {"blockchain", &getblockstats}, {"blockchain", &getbestblockhash}, diff --git a/src/rpc/blockchain.h b/src/rpc/blockchain.h index 954ede6519b..364afa623db 100644 --- a/src/rpc/blockchain.h +++ b/src/rpc/blockchain.h @@ -44,6 +44,9 @@ UniValue blockheaderToJSON(const CBlockIndex& tip, const CBlockIndex& blockindex /** Used by getblockstats to get feerates at different percentiles by weight */ void CalculatePercentilesByWeight(CAmount result[NUM_GETBLOCKSTATS_PERCENTILES], std::vector>& scores, int64_t total_weight); +/** Serialize an atomic node-generation snapshot without narrowing its unsigned revision. */ +UniValue NodeGenerationToJSON(const NodeGenerationSnapshot& generation); + /** * Test-only helper to create UTXO snapshots given a chainstate and a file handle. * @return a UniValue map containing metadata about the snapshot. diff --git a/src/test/rpc_tests.cpp b/src/test/rpc_tests.cpp index 8eea33681b4..0605df84013 100644 --- a/src/test/rpc_tests.cpp +++ b/src/test/rpc_tests.cpp @@ -14,6 +14,7 @@ #include #include +#include #include @@ -84,6 +85,22 @@ UniValue RPCTestingSetup::CallRPC(std::string args) BOOST_FIXTURE_TEST_SUITE(rpc_tests, RPCTestingSetup) +BOOST_AUTO_TEST_CASE(node_generation_revision_json_boundary) +{ + const NodeGenerationSnapshot generation{ + .startup_id = uint256{}, + .chainstate_revision = std::numeric_limits::max(), + .blocks = 0, + .bestblockhash = uint256{}, + }; + const UniValue result{NodeGenerationToJSON(generation)}; + const UniValue& revision{result["chainstate_revision"]}; + + BOOST_CHECK(revision.isNum()); + BOOST_CHECK_EQUAL(revision.getValStr(), "18446744073709551615"); + BOOST_CHECK_EQUAL(revision.getInt(), std::numeric_limits::max()); +} + BOOST_AUTO_TEST_CASE(rpc_namedparams) { const std::vector> arg_names{{"arg1", false}, {"arg2", false}, {"arg3", false}, {"arg4", false}, {"arg5", false}}; diff --git a/src/test/validation_chainstatemanager_tests.cpp b/src/test/validation_chainstatemanager_tests.cpp index 321dba827c9..8e0a417f3a1 100644 --- a/src/test/validation_chainstatemanager_tests.cpp +++ b/src/test/validation_chainstatemanager_tests.cpp @@ -49,6 +49,10 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager, TestChain100Setup) Chainstate& c1 = manager.ActiveChainstate(); chainstates.push_back(&c1); + const NodeGenerationSnapshot initial_generation{ + WITH_LOCK(manager.GetMutex(), return manager.GetNodeGeneration())}; + BOOST_CHECK_GT(initial_generation.chainstate_revision, 0U); + BOOST_CHECK(!manager.IsSnapshotActive()); BOOST_CHECK(WITH_LOCK(::cs_main, return !manager.IsSnapshotValidated())); auto all = manager.GetAll(); @@ -59,6 +63,10 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager, TestChain100Setup) // Get to a valid assumeutxo tip (per chainparams); mineBlocks(10); + const NodeGenerationSnapshot mined_generation{ + WITH_LOCK(manager.GetMutex(), return manager.GetNodeGeneration())}; + BOOST_CHECK_EQUAL(mined_generation.startup_id, initial_generation.startup_id); + BOOST_CHECK_EQUAL(mined_generation.chainstate_revision, initial_generation.chainstate_revision + 10); BOOST_CHECK_EQUAL(WITH_LOCK(manager.GetMutex(), return manager.ActiveHeight()), 110); auto active_tip = WITH_LOCK(manager.GetMutex(), return manager.ActiveTip()); auto exp_tip = c1.m_chain.Tip(); @@ -83,6 +91,11 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager, TestChain100Setup) BlockValidationState _; BOOST_CHECK(c2.ActivateBestChain(_, nullptr)); + const NodeGenerationSnapshot snapshot_generation{ + WITH_LOCK(manager.GetMutex(), return manager.GetNodeGeneration())}; + BOOST_CHECK_EQUAL(snapshot_generation.startup_id, initial_generation.startup_id); + BOOST_CHECK_EQUAL(snapshot_generation.chainstate_revision, mined_generation.chainstate_revision + 1); + BOOST_CHECK_EQUAL(manager.SnapshotBlockhash().value(), snapshot_blockhash); BOOST_CHECK(manager.IsSnapshotActive()); BOOST_CHECK(WITH_LOCK(::cs_main, return !manager.IsSnapshotValidated())); @@ -96,6 +109,9 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager, TestChain100Setup) BOOST_CHECK_EQUAL(WITH_LOCK(manager.GetMutex(), return manager.ActiveHeight()), 110); mineBlocks(1); + const NodeGenerationSnapshot snapshot_mined_generation{ + WITH_LOCK(manager.GetMutex(), return manager.GetNodeGeneration())}; + BOOST_CHECK_EQUAL(snapshot_mined_generation.chainstate_revision, snapshot_generation.chainstate_revision + 1); BOOST_CHECK_EQUAL(WITH_LOCK(manager.GetMutex(), return manager.ActiveHeight()), 111); BOOST_CHECK_EQUAL(WITH_LOCK(manager.GetMutex(), return c1.m_chain.Height()), 110); @@ -574,6 +590,8 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_init, SnapshotTestSetup) BOOST_CHECK(chainman.IsSnapshotActive()); const uint256 snapshot_tip_hash = WITH_LOCK(chainman.GetMutex(), return chainman.ActiveTip()->GetBlockHash()); + const NodeGenerationSnapshot generation_before_background_disconnect{ + WITH_LOCK(chainman.GetMutex(), return chainman.GetNodeGeneration())}; auto all_chainstates = chainman.GetAll(); BOOST_CHECK_EQUAL(all_chainstates.size(), 2); @@ -591,6 +609,12 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_init, SnapshotTestSetup) unused_pool.clear(); // to avoid queuedTx assertion errors on teardown } BOOST_CHECK_EQUAL(bg_chainstate.m_chain.Height(), 109); + const NodeGenerationSnapshot generation_after_background_disconnect{ + WITH_LOCK(chainman.GetMutex(), return chainman.GetNodeGeneration())}; + BOOST_CHECK_EQUAL(generation_after_background_disconnect.startup_id, generation_before_background_disconnect.startup_id); + BOOST_CHECK_EQUAL(generation_after_background_disconnect.chainstate_revision, generation_before_background_disconnect.chainstate_revision); + BOOST_CHECK_EQUAL(generation_after_background_disconnect.blocks, generation_before_background_disconnect.blocks); + BOOST_CHECK_EQUAL(generation_after_background_disconnect.bestblockhash, generation_before_background_disconnect.bestblockhash); // Test that simulating a shutdown (resetting ChainstateManager) and then performing // chainstate reinitializing successfully cleans up the background-validation @@ -610,6 +634,10 @@ BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_init, SnapshotTestSetup) BOOST_CHECK_EQUAL(chainman_restarted.ActiveTip()->GetBlockHash(), snapshot_tip_hash); BOOST_CHECK_EQUAL(chainman_restarted.ActiveHeight(), 210); + + const NodeGenerationSnapshot restarted_generation{chainman_restarted.GetNodeGeneration()}; + BOOST_CHECK_EQUAL(restarted_generation.startup_id, generation_before_background_disconnect.startup_id); + BOOST_CHECK_GT(restarted_generation.chainstate_revision, generation_before_background_disconnect.chainstate_revision); } BOOST_TEST_MESSAGE( diff --git a/src/validation.cpp b/src/validation.cpp index 79789aae3b2..79d21b00447 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -118,6 +118,27 @@ const std::vector CHECKLEVEL_DOC { * */ static constexpr int PRUNE_LOCK_BUFFER{10}; +namespace { + +/** + * Generation state belongs to the daemon process rather than a datadir or a + * ChainstateManager instance. The function-local static ensures that retries + * which reconstruct the chain manager in one process retain the same startup + * identifier and monotonically increasing revision. + */ +struct ProcessNodeGeneration { + const uint256 startup_id{GetRandHash()}; + uint64_t chainstate_revision GUARDED_BY(::cs_main){0}; +}; + +ProcessNodeGeneration& GetProcessNodeGeneration() +{ + static ProcessNodeGeneration generation; + return generation; +} + +} // namespace + TRACEPOINT_SEMAPHORE(validation, block_connected); TRACEPOINT_SEMAPHORE(utxocache, flush); TRACEPOINT_SEMAPHORE(mempool, replaced); @@ -3588,6 +3609,7 @@ bool Chainstate::DisconnectTip(BlockValidationState& state, DisconnectedBlockTra } m_chain.SetTip(*pindexDelete->pprev); + m_chainman.NotifyChainstateMutation(*this); UpdateTip(pindexDelete->pprev); // Let wallets know transactions went from 1-confirmed to @@ -3730,6 +3752,7 @@ bool Chainstate::ConnectTip(BlockValidationState& state, CBlockIndex* pindexNew, } // Update m_chain & related variables. m_chain.SetTip(*pindexNew); + m_chainman.NotifyChainstateMutation(*this); UpdateTip(pindexNew); const auto time_6{SteadyClock::now()}; @@ -6308,6 +6331,44 @@ std::vector ChainstateManager::GetAll() return out; } +void ChainstateManager::AdvanceChainstateRevision() +{ + AssertLockHeld(::cs_main); + ProcessNodeGeneration& generation{GetProcessNodeGeneration()}; + Assert(generation.chainstate_revision != std::numeric_limits::max()); + ++generation.chainstate_revision; +} + +void ChainstateManager::NotifyChainstateMutation(const Chainstate& chainstate) +{ + AssertLockHeld(::cs_main); + if (&chainstate == m_active_chainstate) { + AdvanceChainstateRevision(); + } +} + +void ChainstateManager::SetActiveChainstate(Chainstate* chainstate) +{ + AssertLockHeld(::cs_main); + if (chainstate != nullptr && chainstate != m_active_chainstate) { + AdvanceChainstateRevision(); + } + m_active_chainstate = chainstate; +} + +NodeGenerationSnapshot ChainstateManager::GetNodeGeneration() const +{ + AssertLockHeld(::cs_main); + const CBlockIndex& tip{*Assert(Assert(m_active_chainstate)->m_chain.Tip())}; + const ProcessNodeGeneration& generation{GetProcessNodeGeneration()}; + return { + generation.startup_id, + generation.chainstate_revision, + tip.nHeight, + tip.GetBlockHash(), + }; +} + Chainstate& ChainstateManager::InitializeChainstate(CTxMemPool* mempool) { AssertLockHeld(::cs_main); @@ -6315,7 +6376,7 @@ Chainstate& ChainstateManager::InitializeChainstate(CTxMemPool* mempool) assert(!m_active_chainstate); m_ibd_chainstate = std::make_unique(mempool, m_blockman, *this); - m_active_chainstate = m_ibd_chainstate.get(); + SetActiveChainstate(m_ibd_chainstate.get()); return *m_active_chainstate; } @@ -6500,7 +6561,7 @@ util::Result ChainstateManager::ActivateSnapshot( Assert(!m_snapshot_chainstate->m_mempool); m_snapshot_chainstate->m_mempool = m_active_chainstate->m_mempool; m_active_chainstate->m_mempool = nullptr; - m_active_chainstate = m_snapshot_chainstate.get(); + SetActiveChainstate(m_snapshot_chainstate.get()); m_blockman.m_snapshot_height = this->GetSnapshotBaseHeight(); LogPrintf("[snapshot] successfully activated snapshot %s\n", base_blockhash.ToString()); @@ -6794,7 +6855,7 @@ SnapshotCompletionResult ChainstateManager::MaybeCompleteSnapshotValidation() LogError("[snapshot] !!! %s\n", user_error.original); LogError("[snapshot] deleting snapshot, reverting to validated chain, and stopping node\n"); - m_active_chainstate = m_ibd_chainstate.get(); + SetActiveChainstate(m_ibd_chainstate.get()); m_snapshot_chainstate->m_disabled = true; assert(!this->IsUsable(m_snapshot_chainstate.get())); assert(this->IsUsable(m_ibd_chainstate.get())); @@ -6934,9 +6995,9 @@ void ChainstateManager::MaybeRebalanceCaches() void ChainstateManager::ResetChainstates() { + SetActiveChainstate(nullptr); m_ibd_chainstate.reset(); m_snapshot_chainstate.reset(); - m_active_chainstate = nullptr; } /** @@ -6959,6 +7020,10 @@ ChainstateManager::ChainstateManager(const util::SignalInterrupt& interrupt, Opt m_blockman{interrupt, std::move(blockman_options)}, m_validation_cache{m_options.script_execution_cache_bytes, m_options.signature_cache_bytes} { + // The daemon constructs kernel::Context (which calls RandomInit) before it + // constructs the chain manager. Force the process generation here, before + // RPC warmup can finish. GetRandHash itself also obtains strong randomness. + (void)GetProcessNodeGeneration(); } ChainstateManager::~ChainstateManager() @@ -6998,7 +7063,7 @@ Chainstate& ChainstateManager::ActivateExistingSnapshot(uint256 base_blockhash) Assert(!m_snapshot_chainstate->m_mempool); m_snapshot_chainstate->m_mempool = m_active_chainstate->m_mempool; m_active_chainstate->m_mempool = nullptr; - m_active_chainstate = m_snapshot_chainstate.get(); + SetActiveChainstate(m_snapshot_chainstate.get()); return *m_snapshot_chainstate; } @@ -7070,7 +7135,7 @@ bool ChainstateManager::DeleteSnapshotChainstate() fs::PathToString(snapshot_datadir)); return false; } - m_active_chainstate = m_ibd_chainstate.get(); + SetActiveChainstate(m_ibd_chainstate.get()); m_active_chainstate->m_mempool = m_snapshot_chainstate->m_mempool; m_snapshot_chainstate.reset(); return true; diff --git a/src/validation.h b/src/validation.h index b62d800699d..61497555842 100644 --- a/src/validation.h +++ b/src/validation.h @@ -57,6 +57,15 @@ class DisconnectedBlockTransactions; struct PrecomputedTransactionData; struct LockPoints; struct AssumeutxoData; + +/** Process and active-chain generation fields returned by getnodegeneration. */ +struct NodeGenerationSnapshot { + uint256 startup_id; + uint64_t chainstate_revision; + int blocks; + uint256 bestblockhash; +}; + namespace node { class SnapshotMetadata; } // namespace node @@ -959,6 +968,15 @@ class ChainstateManager return cs && !cs->m_disabled; } + /** Advance the process-local active-chain revision without allowing wraparound. */ + void AdvanceChainstateRevision() EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + + /** Record a successful chain mutation if it affected the active chainstate. */ + void NotifyChainstateMutation(const Chainstate& chainstate) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + + /** Replace the active chainstate and record the newly exposed state. */ + void SetActiveChainstate(Chainstate* chainstate) EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + //! A queue for script verifications that have to be performed by worker threads. CCheckQueue m_script_check_queue; @@ -1125,6 +1143,9 @@ class ChainstateManager int ActiveHeight() const EXCLUSIVE_LOCKS_REQUIRED(GetMutex()) { return ActiveChain().Height(); } CBlockIndex* ActiveTip() const EXCLUSIVE_LOCKS_REQUIRED(GetMutex()) { return ActiveChain().Tip(); } + /** Atomically snapshot the process generation and active chain tip. */ + NodeGenerationSnapshot GetNodeGeneration() const EXCLUSIVE_LOCKS_REQUIRED(::cs_main); + //! The state of a background sync (for net processing) bool BackgroundSyncInProgress() const EXCLUSIVE_LOCKS_REQUIRED(GetMutex()) { return IsUsable(m_snapshot_chainstate.get()) && IsUsable(m_ibd_chainstate.get()); diff --git a/test/functional/rpc_getnodegeneration.py b/test/functional/rpc_getnodegeneration.py new file mode 100755 index 00000000000..5e7ef79d96c --- /dev/null +++ b/test/functional/rpc_getnodegeneration.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test process and active-chain generation identity.""" + +import hashlib +import platform +import threading +import time + +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import ( + assert_equal, + assert_raises_rpc_error, + get_rpc_proxy, +) + + +class NodeGenerationTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + self.setup_clean_chain = True + self.extra_args = [["-rpcdoccheck=1"]] + + @staticmethod + def assert_generation_schema(generation): + assert_equal(set(generation), { + "startup_id", + "chainstate_revision", + "blocks", + "bestblockhash", + }) + assert isinstance(generation["startup_id"], str) + assert_equal(len(generation["startup_id"]), 64) + int(generation["startup_id"], 16) + assert generation["startup_id"] != "0" * 64 + assert isinstance(generation["chainstate_revision"], int) + assert 0 <= generation["chainstate_revision"] <= 2**64 - 1 + assert isinstance(generation["blocks"], int) + assert_equal(len(generation["bestblockhash"]), 64) + int(generation["bestblockhash"], 16) + + @staticmethod + def common_derived_identifiers(pid, observed_time): + """Return common PID/time/uptime derivations a mutation must not use.""" + source_bytes = { + str(pid).encode(), + pid.to_bytes(4, byteorder="big", signed=False), + pid.to_bytes(4, byteorder="little", signed=False), + } + numeric_values = {pid, *range(0, 11)} + for second in range(observed_time - 5, observed_time + 6): + numeric_values.add(second) + observed_millis = observed_time * 1000 + for millis in range(observed_millis - 2000, observed_millis + 2001): + numeric_values.add(millis) + + for value in numeric_values: + source_bytes.add(str(value).encode()) + if value >= 0: + source_bytes.add(value.to_bytes(32, byteorder="big", signed=False)) + source_bytes.add(value.to_bytes(32, byteorder="little", signed=False)) + + derived = set() + for source in source_bytes: + if len(source) <= 32: + derived.add(source.rjust(32, b"\x00").hex()) + derived.add(source.ljust(32, b"\x00").hex()) + first_hash = hashlib.sha256(source).digest() + second_hash = hashlib.sha256(first_hash).digest() + for digest in (first_hash, second_hash): + derived.add(digest.hex()) + derived.add(digest[::-1].hex()) + return derived + + def assert_fresh_process_generation(self, seen_startup_ids): + node = self.nodes[0] + generation = node.getnodegeneration() + self.assert_generation_schema(generation) + assert generation["startup_id"] not in seen_startup_ids + assert generation["startup_id"] not in self.common_derived_identifiers( + node.process.pid, + int(time.time()), + ) + seen_startup_ids.add(generation["startup_id"]) + return generation + + def test_process_identity(self): + node = self.nodes[0] + seen_startup_ids = set() + + first = self.assert_fresh_process_generation(seen_startup_ids) + assert_equal(node.getnodegeneration(), first) + assert_equal(node.getnodegeneration(), first) + + self.restart_node(0) + self.assert_fresh_process_generation(seen_startup_ids) + + environment_sentinel = "a5" * 32 + self.stop_node(0) + self.start_node(0, env={"ELEMENTS_STARTUP_ID": environment_sentinel}) + environment_generation = self.assert_fresh_process_generation(seen_startup_ids) + assert environment_generation["startup_id"] != environment_sentinel + + node.process.kill() + node.wait_until_stopped(expected_ret_code=1 if platform.system() == "Windows" else -9) + self.start_node(0) + self.assert_fresh_process_generation(seen_startup_ids) + + self.stop_node(0) + node.assert_start_raises_init_error( + extra_args=[f"-nodegenerationstartupid={environment_sentinel}"], + ) + with open(node.bitcoinconf, "a", encoding="utf8") as config: + config.write(f"nodegenerationstartupid={environment_sentinel}\n") + with node.assert_debug_log(expected_msgs=[ + "Ignoring unknown configuration value elementsregtest.nodegenerationstartupid", + ]): + self.start_node(0) + config_generation = self.assert_fresh_process_generation(seen_startup_ids) + assert config_generation["startup_id"] != environment_sentinel + + def test_schema_and_help(self): + node = self.nodes[0] + generation = node.getnodegeneration() + self.assert_generation_schema(generation) + assert_equal(generation["blocks"], node.getblockcount()) + assert_equal(generation["bestblockhash"], node.getbestblockhash()) + assert_raises_rpc_error(-1, "getnodegeneration", node.getnodegeneration, 1) + + help_text = node.help("getnodegeneration") + for text in ( + "startup_id", + "chainstate_revision", + "blocks", + "bestblockhash", + "ABA detection", + "do not establish binary provenance", + "do not prove that the connected node is honest", + ): + assert text in help_text + + def test_active_chain_transitions_and_aba(self): + node = self.nodes[0] + base = node.getnodegeneration() + base_height = base["blocks"] + base_hash = base["bestblockhash"] + + first_block = self.generate(node, 1, sync_fun=self.no_op)[0] + connected = node.getnodegeneration() + assert_equal(connected["chainstate_revision"], base["chainstate_revision"] + 1) + assert_equal((connected["blocks"], connected["bestblockhash"]), (base_height + 1, first_block)) + + node.invalidateblock(first_block) + disconnected = node.getnodegeneration() + assert_equal(disconnected["chainstate_revision"], connected["chainstate_revision"] + 1) + assert_equal((disconnected["blocks"], disconnected["bestblockhash"]), (base_height, base_hash)) + + node.reconsiderblock(first_block) + reconnected = node.getnodegeneration() + assert_equal(reconnected["chainstate_revision"], disconnected["chainstate_revision"] + 1) + assert_equal((reconnected["blocks"], reconnected["bestblockhash"]), (base_height + 1, first_block)) + + before_aba = node.getnodegeneration() + branch_a = self.generate(node, 3, sync_fun=self.no_op) + branch_a_tip = node.getnodegeneration() + assert_equal(branch_a_tip["chainstate_revision"], before_aba["chainstate_revision"] + 3) + + node.invalidateblock(branch_a[0]) + branch_point = node.getnodegeneration() + assert_equal(branch_point["chainstate_revision"], branch_a_tip["chainstate_revision"] + 3) + assert_equal(branch_point["bestblockhash"], first_block) + + # Ensure the replacement branch does not reproduce the deterministic + # signed block that was just invalidated. + node.setmocktime(node.getblockheader(branch_a[-1])["time"] + 100) + branch_b = self.generate(node, 2, sync_fun=self.no_op) + branch_b_tip = node.getnodegeneration() + assert_equal(branch_b_tip["chainstate_revision"], branch_point["chainstate_revision"] + 2) + assert_equal(branch_b_tip["bestblockhash"], branch_b[-1]) + + node.reconsiderblock(branch_a[0]) + aba_result = node.getnodegeneration() + assert_equal(aba_result["chainstate_revision"], branch_b_tip["chainstate_revision"] + 5) + assert_equal(aba_result["blocks"], branch_a_tip["blocks"]) + assert_equal(aba_result["bestblockhash"], branch_a_tip["bestblockhash"]) + + def test_concurrent_atomic_sampling(self): + node = self.nodes[0] + sampler_rpc = get_rpc_proxy( + node.url, + node.index + 100, + timeout=600, + coveragedir=node.coverage_dir, + ) + observations = [] + sampler_errors = [] + stop_sampling = threading.Event() + + def sample_generation(): + try: + while not stop_sampling.is_set(): + observations.append(sampler_rpc.getnodegeneration()) + time.sleep(0.001) + except Exception as error: # Test thread reports failures to the main thread. + sampler_errors.append(error) + + initial = node.getnodegeneration() + expected_height = initial["blocks"] + expected_hash = initial["bestblockhash"] + previous_revision = initial["chainstate_revision"] + expected_by_revision = { + previous_revision: (expected_height, expected_hash), + } + + def record_transition(height, block_hash): + nonlocal previous_revision + generation = node.getnodegeneration() + assert_equal(generation["chainstate_revision"], previous_revision + 1) + assert_equal((generation["blocks"], generation["bestblockhash"]), (height, block_hash)) + previous_revision = generation["chainstate_revision"] + expected_by_revision[previous_revision] = (height, block_hash) + + sampler = threading.Thread(target=sample_generation, daemon=True) + sampler.start() + try: + for _ in range(25): + parent_height = expected_height + parent_hash = expected_hash + block_hash = self.generate(node, 1, sync_fun=self.no_op)[0] + expected_height += 1 + expected_hash = block_hash + record_transition(expected_height, expected_hash) + + node.invalidateblock(block_hash) + expected_height = parent_height + expected_hash = parent_hash + record_transition(expected_height, expected_hash) + + node.reconsiderblock(block_hash) + expected_height += 1 + expected_hash = block_hash + record_transition(expected_height, expected_hash) + finally: + stop_sampling.set() + sampler.join(timeout=10) + + assert not sampler.is_alive() + assert not sampler_errors + assert observations + startup_id = initial["startup_id"] + for observation in observations: + assert_equal(observation["startup_id"], startup_id) + revision = observation["chainstate_revision"] + assert revision in expected_by_revision + assert_equal( + (observation["blocks"], observation["bestblockhash"]), + expected_by_revision[revision], + ) + + def run_test(self): + self.test_process_identity() + self.test_schema_and_help() + self.test_active_chain_transitions_and_aba() + self.test_concurrent_atomic_sampling() + + +if __name__ == "__main__": + NodeGenerationTest(__file__).main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 61940c3eb9a..17a4081ace2 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -107,6 +107,7 @@ 'feature_pak.py --legacy-wallet', 'feature_blocksign.py --legacy-wallet', 'rpc_calcfastmerkleroot.py', + 'rpc_getnodegeneration.py', 'feature_txwitness.py', 'rpc_tweakfedpeg.py --legacy-wallet', 'feature_issuance.py --legacy-wallet', From edc32491d35ebfc693250e93a8c84e81924e6c25 Mon Sep 17 00:00:00 2001 From: Abdullah1738 Date: Tue, 11 Aug 2026 16:08:05 +0400 Subject: [PATCH 2/2] fix(rpc): address getnodegeneration review feedback --- doc/release-notes-1578.md | 7 +++++++ src/rpc/blockchain.cpp | 1 + src/test/fuzz/rpc.cpp | 1 + src/validation.cpp | 1 + test/functional/rpc_getnodegeneration.py | 2 ++ 5 files changed, 12 insertions(+) create mode 100644 doc/release-notes-1578.md diff --git a/doc/release-notes-1578.md b/doc/release-notes-1578.md new file mode 100644 index 00000000000..1362b4f1d0f --- /dev/null +++ b/doc/release-notes-1578.md @@ -0,0 +1,7 @@ +New RPCs +-------- + +- The new `getnodegeneration` RPC returns a process-scoped `startup_id`, a + monotonic `chainstate_revision`, and the active tip height and hash. Callers + can use these fields to detect daemon restarts, stale responses, and active + chain ABA changes. diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index aefab717e3c..e6c8552b733 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -3771,6 +3771,7 @@ static RPCHelpMan getnodegeneration() { return RPCHelpMan{"getnodegeneration", "Returns process and active-chain generation fields for stale-response and reorganization ABA detection.\n" + "Treat a startup_id mismatch as a process restart, and use chainstate_revision changes to detect ABA.\n" "These fields do not establish binary provenance and do not prove that the connected node is honest.\n", {}, RPCResult{ diff --git a/src/test/fuzz/rpc.cpp b/src/test/fuzz/rpc.cpp index 755a4da07c1..1283e636d5b 100644 --- a/src/test/fuzz/rpc.cpp +++ b/src/test/fuzz/rpc.cpp @@ -201,6 +201,7 @@ const std::vector RPC_COMMANDS_SAFE_FOR_FUZZING{ "finalizecompactblock", "getcompactsketch", "getnewblockhex", + "getnodegeneration", "getpakinfo", "getsidechaininfo", "parsepsbt", diff --git a/src/validation.cpp b/src/validation.cpp index 79d21b00447..82be187a07c 100644 --- a/src/validation.cpp +++ b/src/validation.cpp @@ -6335,6 +6335,7 @@ void ChainstateManager::AdvanceChainstateRevision() { AssertLockHeld(::cs_main); ProcessNodeGeneration& generation{GetProcessNodeGeneration()}; + // Fail-stop rather than wrap because reuse would break ABA detection. Assert(generation.chainstate_revision != std::numeric_limits::max()); ++generation.chainstate_revision; } diff --git a/test/functional/rpc_getnodegeneration.py b/test/functional/rpc_getnodegeneration.py index 5e7ef79d96c..e379a6794d5 100755 --- a/test/functional/rpc_getnodegeneration.py +++ b/test/functional/rpc_getnodegeneration.py @@ -136,6 +136,8 @@ def test_schema_and_help(self): "blocks", "bestblockhash", "ABA detection", + "process restart", + "chainstate_revision changes", "do not establish binary provenance", "do not prove that the connected node is honest", ):