Also, could we add a targeted test that stresses
The simplest test I found adds two equal-work siblings, resets block-sequence state, seeds CoinsTip with the worst-sorting candidate, calls LoadBlockIndex && LoadChainTip, then verifies every setBlockIndexCandidates entry is not worse than the tip and that set iteration order is comparator-consistent:
0diff --git a/src/test/validation_chainstatemanager_tests.cpp b/src/test/validation_chainstatemanager_tests.cpp
1--- a/src/test/validation_chainstatemanager_tests.cpp (revision b2805eec35ce9fdb69892e864556be4ae057ce6a)
2+++ b/src/test/validation_chainstatemanager_tests.cpp (date 1770547026792)
3@@ -24,6 +24,9 @@
4
5 #include <tinyformat.h>
6
7+#include <algorithm>
8+#include <array>
9+#include <ranges>
10 #include <vector>
11
12 #include <boost/test/unit_test.hpp>
13@@ -591,6 +594,47 @@
14 BOOST_CHECK_EQUAL(cs2.setBlockIndexCandidates.size(), num_indexes - last_assumed_valid_idx + 1);
15 }
16
17+//! Regression test for candidate pruning when LoadChainTip rewrites nSequenceId.
18+BOOST_FIXTURE_TEST_CASE(chainstatemanager_loadchaintip_prunes_worse_candidates, TestChain100Setup)
19+{
20+ constexpr auto comparator{node::CBlockIndexWorkComparator{}};
21+ auto& chainman{*Assert(m_node.chainman)};
22+ auto& chainstate{chainman.ActiveChainstate()};
23+ auto* active_tip{WITH_LOCK(chainman.GetMutex(), return chainman.ActiveTip())};
24+ BOOST_REQUIRE(active_tip && active_tip->pprev);
25+
26+ // Add two equal-work siblings and use the worse one as CoinsTip best block.
27+ LOCK(cs_main);
28+ auto header{active_tip->GetBlockHeader()};
29+ auto make_sibling{[&]() EXCLUSIVE_LOCKS_REQUIRED(cs_main) {
30+ ++header.nNonce;
31+ auto* sibling{chainman.m_blockman.AddToBlockIndex(header, chainman.m_best_header)};
32+ sibling->RaiseValidity(BLOCK_VALID_TRANSACTIONS);
33+ sibling->m_chain_tx_count = active_tip->m_chain_tx_count;
34+ return sibling;
35+ }};
36+ std::array candidates{active_tip, make_sibling(), make_sibling()};
37+ chainman.ResetBlockSequenceCounters();
38+ for (auto& index : chainman.m_blockman.m_block_index | std::views::values) {
39+ index.nSequenceId = SEQ_ID_INIT_FROM_DISK;
40+ }
41+ std::ranges::sort(candidates, comparator);
42+
43+ chainstate.m_chain.SetTip(*active_tip);
44+ chainstate.CoinsTip().SetBestBlock(candidates.front()->GetBlockHash());
45+ chainstate.ClearBlockIndexCandidates();
46+
47+ BOOST_REQUIRE(chainman.LoadBlockIndex() && chainstate.LoadChainTip());
48+ const CBlockIndex* prev{nullptr};
49+ for (auto* pindex : chainstate.setBlockIndexCandidates) {
50+ BOOST_CHECK_MESSAGE(!comparator(pindex, chainstate.m_chain.Tip()), "Candidate sorts worse than the tip");
51+ if (prev) {
52+ BOOST_CHECK_MESSAGE(!comparator(pindex, prev), "Wrong candidate set iteration order");
53+ }
54+ prev = pindex;
55+ }
56+}
57+
58 //! Ensure that snapshot chainstate can be loaded when found on disk after a
59 //! restart, and that new blocks can be connected to both chainstates.
60 BOOST_FIXTURE_TEST_CASE(chainstatemanager_snapshot_init, SnapshotTestSetup)
Before the fix this fails with:
test/validation_chainstatemanager_tests.cpp:630: error: in “validation_chainstatemanager_tests/chainstatemanager_loadchaintip_prunes_worse_candidates”: Candidate sorts worse than the tip
test/validation_chainstatemanager_tests.cpp:632: error: in “validation_chainstatemanager_tests/chainstatemanager_loadchaintip_prunes_worse_candidates”: Wrong candidate set iteration order
And passes after with the erase in the loop:
0diff --git a/src/validation.cpp b/src/validation.cpp
1--- a/src/validation.cpp (revision b2805eec35ce9fdb69892e864556be4ae057ce6a)
2+++ b/src/validation.cpp (date 1770547212278)
3@@ -4614,15 +4614,15 @@
4
5 // Make sure our chain tip before shutting down scores better than any other candidate
6 // to maintain a consistent best tip over reboots in case of a tie.
7+ // Erase each block from candidates before changing its nSequenceId. The set
8+ // uses nSequenceId for ordering, so modifying it in-place is undefined behavior.
9 auto target = tip;
10 while (target) {
11+ setBlockIndexCandidates.erase(target);
12 target->nSequenceId = SEQ_ID_BEST_CHAIN_FROM_DISK;
13 target = target->pprev;
14 }
15
16- // Block index candidates are loaded before the chain tip, so we need to replace this entry
17- // Otherwise the scoring will be based on the memory address location instead of the nSequenceId
18- setBlockIndexCandidates.erase(tip);
19 TryAddBlockIndexCandidate(tip);
20 PruneBlockIndexCandidates();
(though I couldn’t write a test that fails for your solution only (but passes for the looped erase), so the two fixes are likely equivalent - though conceptually mutating any key still in a std::set is UB so we should likely delete (or not insert) all of them).