diff --git a/src/Makefile.test.include b/src/Makefile.test.include index faaee5aa1913..7fadbf7b3512 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -121,6 +121,7 @@ BITCOIN_TESTS =\ test/fs_tests.cpp \ test/getarg_tests.cpp \ test/governance_inv_tests.cpp \ + test/governance_orphan_vote_tests.cpp \ test/governance_superblock_tests.cpp \ test/governance_validators_tests.cpp \ test/governance_vote_wire_tests.cpp \ diff --git a/src/governance/governance.cpp b/src/governance/governance.cpp index d87fef40fac5..fdebfc020caa 100644 --- a/src/governance/governance.cpp +++ b/src/governance/governance.cpp @@ -19,14 +19,18 @@ #include #include #include +#include #include #include #include #include +#include #include -const std::string GovernanceStore::SERIALIZATION_VERSION_STRING = "CGovernanceManager-Version-16"; +// Version 17 drops cmmapOrphanVotes from the on-disk format so an unauthenticated +// orphan flood cannot survive restart. +const std::string GovernanceStore::SERIALIZATION_VERSION_STRING = "CGovernanceManager-Version-17"; namespace { constexpr std::chrono::seconds GOVERNANCE_DELETION_DELAY{10min}; @@ -64,7 +68,7 @@ GovernanceStore::GovernanceStore() : mapObjects(), mapErasedGovernanceObjects(), cmapInvalidVotes(MAX_CACHE_SIZE), - cmmapOrphanVotes(MAX_CACHE_SIZE), + cmmapOrphanVotes(MAX_ORPHAN_VOTES), mapLastMasternodeObject(), lastMNListForVotingKeys(std::make_shared()) { @@ -819,10 +823,61 @@ bool CGovernanceManager::ProcessVote(const CGovernanceVote& vote, CGovernanceExc return false; } + const auto tip_mn_list = m_dmnman.GetListAtChainTip(); auto it = mapObjects.find(nHashGovobj); if (it == mapObjects.end()) { + // Validate before orphan caching so an unauthenticated peer cannot fill + // cmmapOrphanVotes with attacker-chosen parent hashes. + // Match the cheap structural checks in CGovernanceVote::IsValid, then + // require a tip-list masternode and a verifiable signature. + const auto max_time{std::chrono::time_point_cast(GetAdjustedTime() + MAX_TIME_FUTURE_DEVIATION)}; + if (vote.Time() > max_time) { + std::string msg{strprintf("CGovernanceManager::%s -- vote is too far ahead of current time, hash = %s", + __func__, nHashVote.ToString())}; + LogPrint(BCLog::GOBJECT, "%s\n", msg); + exception = CGovernanceException(msg, GOVERNANCE_EXCEPTION_TEMPORARY_ERROR, 20); + return false; + } + if (vote.GetSignal() <= VOTE_SIGNAL_NONE || vote.GetSignal() >= VOTE_SIGNAL_UNKNOWN || + vote.GetOutcome() <= VOTE_OUTCOME_NONE || vote.GetOutcome() >= VOTE_OUTCOME_UNKNOWN) { + std::string msg{strprintf("CGovernanceManager::%s -- invalid vote signal/outcome, hash = %s", + __func__, nHashVote.ToString())}; + LogPrint(BCLog::GOBJECT, "%s\n", msg); + exception = CGovernanceException(msg, GOVERNANCE_EXCEPTION_PERMANENT_ERROR, 20); + return false; + } + auto dmn = tip_mn_list.GetMNByCollateral(vote.GetMasternodeOutpoint()); + if (!dmn) { + std::string msg{strprintf("CGovernanceManager::%s -- Unknown Masternode - %s, governance object hash = %s", + __func__, vote.GetMasternodeOutpoint().ToStringShort(), nHashGovobj.ToString())}; + LogPrint(BCLog::GOBJECT, "%s\n", msg); + // Match CGovernanceObject::ProcessVote scoring so repeated injection is banned. + exception = CGovernanceException(msg, GOVERNANCE_EXCEPTION_PERMANENT_ERROR, 20); + return false; + } + // FUNDING votes on proposals may use the voting key; everything else uses + // the operator BLS key. Parent type is unknown here, so try both. + const bool sig_ok = vote.CheckSignature(dmn->pdmnState->keyIDVoting) || + vote.CheckSignature(dmn->pdmnState->pubKeyOperator.Get()); + if (!sig_ok) { + std::string msg{strprintf("CGovernanceManager::%s -- Invalid vote signature, MN outpoint = %s, " + "governance object hash = %s, vote hash = %s", + __func__, vote.GetMasternodeOutpoint().ToStringShort(), nHashGovobj.ToString(), nHashVote.ToString())}; + LogPrint(BCLog::GOBJECT, "%s\n", msg); + exception = CGovernanceException(msg, GOVERNANCE_EXCEPTION_PERMANENT_ERROR, 20); + return false; + } + std::string msg{strprintf("CGovernanceManager::%s -- Unknown parent object %s, MN outpoint = %s", __func__, nHashGovobj.ToString(), vote.GetMasternodeOutpoint().ToStringShort())}; + // Keep the penalty at zero. Reaching this point means the vote carries a valid + // signature from a tip-list masternode, so the only remaining reason we cannot + // apply it is that the parent object has not arrived yet - a benign relay race + // that happens routinely during governance sync. The peer that forwarded it is + // typically an honest relay, and misbehavior scores never decay, so scoring here + // would discourage honest peers after DISCOURAGEMENT_THRESHOLD/penalty races. + // The flood is bounded by the validation gate above plus MAX_ORPHAN_VOTES, not + // by ban scoring. exception = CGovernanceException(msg, GOVERNANCE_EXCEPTION_WARNING); if (cmmapOrphanVotes.Insert(nHashGovobj, governance::OrphanVote{vote, Now() + GOVERNANCE_ORPHAN_EXPIRATION_TIME})) { hashToRequest = nHashGovobj; // Caller should request this object @@ -839,7 +894,7 @@ bool CGovernanceManager::ProcessVote(const CGovernanceVote& vote, CGovernanceExc return false; } - bool fOk = govobj.ProcessVote(m_mn_metaman, fRateChecksEnabled, m_dmnman.GetListAtChainTip(), vote, exception); + bool fOk = govobj.ProcessVote(m_mn_metaman, fRateChecksEnabled, tip_mn_list, vote, exception); if (fOk) { fOk = cmapVoteToObject.Insert(nHashVote, it->second); } else if (exception.GetType() == GOVERNANCE_EXCEPTION_PERMANENT_ERROR && exception.GetNodePenalty() == 20) { @@ -1103,7 +1158,9 @@ std::vector CGovernanceManager::GetOrphanVoteObjectHashes() } } - // Get hashes of objects we don't have yet + // Get hashes of objects we don't have yet, capped so the scheduler fan-out + // stays O(MAX_ORPHAN_OBJECT_REQUESTS_PER_TICK × peers) rather than + // O(orphans × peers). std::vector vecHashesFiltered; std::vector vecHashes; cmmapOrphanVotes.GetKeys(vecHashes); @@ -1113,6 +1170,16 @@ std::vector CGovernanceManager::GetOrphanVoteObjectHashes() } } + // Sample randomly rather than truncating: GetKeys() returns ascending uint256 + // order, so a fixed prefix would request the same numerically-lowest hashes + // every tick and starve every other orphan until it expires — a parent whose + // hash sorts high would never be requested at all. Matches the shuffle used + // for the governance object-vote sync in CSyncManager::RequestGovernanceData(). + if (vecHashesFiltered.size() > MAX_ORPHAN_OBJECT_REQUESTS_PER_TICK) { + Shuffle(vecHashesFiltered.begin(), vecHashesFiltered.end(), FastRandomContext()); + vecHashesFiltered.resize(MAX_ORPHAN_OBJECT_REQUESTS_PER_TICK); + } + return vecHashesFiltered; } diff --git a/src/governance/governance.h b/src/governance/governance.h index cca00d8cdb60..c64a7600f743 100644 --- a/src/governance/governance.h +++ b/src/governance/governance.h @@ -177,6 +177,21 @@ class GovernanceStore using txout_m_t = std::map; using vote_cmm_t = CacheMultiMap; +public: + // Bound for orphan-vote amplification. Far below MAX_CACHE_SIZE + // so a peer cannot stockpile ~1e6 parent hashes for the + // 5-minute MNGOVERNANCESYNC fan-out. Validated-but-orphaned votes still need + // a modest window for out-of-order object arrival. + static constexpr int MAX_ORPHAN_VOTES = 1000; + // Cap scheduler orphan-object requests per 5-minute tick so fan-out stays + // O(cap × peers), not O(orphans × peers). + static constexpr size_t MAX_ORPHAN_OBJECT_REQUESTS_PER_TICK = 100; + + /** The on-disk format version of governance.dat. Exposed so tests can assert the + * string was bumped alongside a layout change (a stale version would make new + * code misparse an old file instead of discarding it). */ + static const std::string& GetSerializationVersionString() { return SERIALIZATION_VERSION_STRING; } + protected: static constexpr int MAX_CACHE_SIZE = 1000000; static const std::string SERIALIZATION_VERSION_STRING; @@ -205,10 +220,12 @@ class GovernanceStore void Serialize(Stream &s) const EXCLUSIVE_LOCKS_REQUIRED(!cs_store) { LOCK(cs_store); + // Intentionally omit cmmapOrphanVotes: orphan votes are transient recovery + // state. Persisting them lets an unauthenticated flood survive restart + // and re-drive the scheduler fan-out on every boot. s << SERIALIZATION_VERSION_STRING << mapErasedGovernanceObjects << cmapInvalidVotes - << cmmapOrphanVotes << mapObjects << mapLastMasternodeObject << *lastMNListForVotingKeys; @@ -228,10 +245,11 @@ class GovernanceStore s >> mapErasedGovernanceObjects >> cmapInvalidVotes - >> cmmapOrphanVotes >> mapObjects >> mapLastMasternodeObject >> *lastMNListForVotingKeys; + // Fresh orphan map on load; see Serialize note above. + cmmapOrphanVotes.Clear(); } void Clear() @@ -363,8 +381,15 @@ class CGovernanceManager : public GovernanceStore // Used by NetGovernance std::vector FetchRelayInventory() EXCLUSIVE_LOCKS_REQUIRED(!cs_relay); void CheckAndRemove() EXCLUSIVE_LOCKS_REQUIRED(!cs_store); - /** Get hashes of governance objects for which we have orphan votes. Also cleans up expired orphans. */ + /** Get hashes of governance objects for which we have orphan votes. Also cleans up expired orphans. + * Randomly sampled down to MAX_ORPHAN_OBJECT_REQUESTS_PER_TICK for scheduler fan-out safety. */ [[nodiscard]] std::vector GetOrphanVoteObjectHashes() EXCLUSIVE_LOCKS_REQUIRED(!cs_store); + /** The bound actually enforced by the orphan-vote cache. Exposed so tests can assert + * the memory-exhaustion bound is in force, not just that the constant exists. */ + [[nodiscard]] size_t GetOrphanVoteCacheMaxSize() const EXCLUSIVE_LOCKS_REQUIRED(!cs_store) + { + return WITH_LOCK(cs_store, return cmmapOrphanVotes.GetMaxSize()); + } std::pair, std::vector> FetchGovernanceObjectVotes( size_t peers_per_hash_max, int64_t now, std::map>& map_asked_recently) const EXCLUSIVE_LOCKS_REQUIRED(!cs_store); diff --git a/src/governance/net_governance.cpp b/src/governance/net_governance.cpp index 44465e4d5bf6..5b35581c619f 100644 --- a/src/governance/net_governance.cpp +++ b/src/governance/net_governance.cpp @@ -45,7 +45,9 @@ void NetGovernance::Schedule(CScheduler& scheduler) [this]() -> void { if (!m_node_sync.IsSynced()) return; - // Request governance objects for orphan votes + // Request governance objects for orphan votes. GetOrphanVoteObjectHashes() is + // already capped; also skip peers whose send buffer is full so a burst of + // orphans cannot balloon vSendMsg. auto vecOrphanHashes = m_gov_manager.GetOrphanVoteObjectHashes(); if (!vecOrphanHashes.empty()) { LogPrint(BCLog::GOBJECT, "NetGovernance::Schedule -- requesting %d orphan objects\n", @@ -54,6 +56,7 @@ void NetGovernance::Schedule(CScheduler& scheduler) for (const uint256& nHash : vecOrphanHashes) { for (CNode* pnode : snap.Nodes()) { if (!pnode->CanRelay()) continue; + if (pnode->fPauseSend) continue; CNetMsgMaker msgMaker(pnode->GetCommonVersion()); CBloomFilter filter; // Empty filter - we want the object, not votes m_connman.PushMessage(pnode, msgMaker.Make(NetMsgType::MNGOVERNANCESYNC, nHash, filter)); diff --git a/src/test/governance_inv_tests.cpp b/src/test/governance_inv_tests.cpp index 93e63e9ac33f..b4c048e9b7c5 100644 --- a/src/test/governance_inv_tests.cpp +++ b/src/test/governance_inv_tests.cpp @@ -406,20 +406,24 @@ BOOST_AUTO_TEST_CASE(governance_votes_require_peer_announcement_or_request) ProcessInv(*m_node.peerman, *announcing_peer, vote_inv); ProcessInv(*m_node.peerman, *second_announcing_peer, vote_inv); + // Votes with an unknown masternode outpoint are rejected before orphan caching. + // No MNGOVERNANCESYNC courtesy request is issued, and the peer is scored once + // fully synced. + m_node.mn_sync->SwitchToNextAsset(); + BOOST_REQUIRE(m_node.mn_sync->IsSynced()); + connman.FlushSendBuffer(*announcing_peer); ProcessGovernanceVote(net_gov, *announcing_peer, vote); - BOOST_CHECK_EQUAL(CountQueuedMessages(*announcing_peer, NetMsgType::MNGOVERNANCESYNC), 1U); - AssertMisbehaviorScore(*m_node.peerman, *announcing_peer, 0); + BOOST_CHECK_EQUAL(CountQueuedMessages(*announcing_peer, NetMsgType::MNGOVERNANCESYNC), 0U); + AssertMisbehaviorScore(*m_node.peerman, *announcing_peer, 20); connman.FlushSendBuffer(*second_announcing_peer); ProcessGovernanceVote(net_gov, *second_announcing_peer, vote); - // Second announcer: the gate accepts (it independently announced the vote) and consumes - // its per-peer request entry. A rejected vote would return before the gate consumes and - // leave the entry intact, so this proves the accept path independently of the (deduped) - // orphan-request side effect. + // Second announcer: the gate still accepts (it independently announced the vote) and + // consumes its per-peer request entry even though ProcessVote then rejects the vote. BOOST_CHECK(!WITH_LOCK(::cs_main, return m_node.peerman->PeerConsumeObjectRequest(second_announcing_peer->GetId(), vote_inv))); - AssertMisbehaviorScore(*m_node.peerman, *second_announcing_peer, 0); + AssertMisbehaviorScore(*m_node.peerman, *second_announcing_peer, 20); m_node.peerman->FinalizeNode(*announcing_peer); m_node.peerman->FinalizeNode(*second_announcing_peer); @@ -458,14 +462,20 @@ BOOST_AUTO_TEST_CASE(governance_vote_authorization_survives_unsynced_drop) ProcessGovernanceVote(net_gov, *peer, vote); BOOST_CHECK_EQUAL(CountQueuedMessages(*peer, NetMsgType::MNGOVERNANCESYNC), 0U); - // Back in sync, the retransmit is still authorized: ProcessVote runs and (orphan parent) - // requests the missing object. Had the unsynced drop consumed the request, the gate would now - // reject the vote as unrequested and send no MNGOVERNANCESYNC. + // Back in sync, the retransmit is still authorized: the gate consumes the request and + // ProcessVote runs. The vote uses an unknown MN outpoint so it is rejected with a + // misbehavior score rather than cached as an orphan. Had the unsynced + // drop consumed the request, the gate would now reject the vote as unrequested and leave + // the misbehavior score at 0. m_node.mn_sync->SwitchToNextAsset(); BOOST_REQUIRE(m_node.mn_sync->IsBlockchainSynced()); + // Advance fully to FINISHED so the penalty path applies (gated on IsSynced()). + m_node.mn_sync->SwitchToNextAsset(); + BOOST_REQUIRE(m_node.mn_sync->IsSynced()); connman.FlushSendBuffer(*peer); ProcessGovernanceVote(net_gov, *peer, vote); - BOOST_CHECK_EQUAL(CountQueuedMessages(*peer, NetMsgType::MNGOVERNANCESYNC), 1U); + BOOST_CHECK_EQUAL(CountQueuedMessages(*peer, NetMsgType::MNGOVERNANCESYNC), 0U); + AssertMisbehaviorScore(*m_node.peerman, *peer, 20); m_node.peerman->FinalizeNode(*peer); chainstate.ResetIbd(); diff --git a/src/test/governance_orphan_vote_tests.cpp b/src/test/governance_orphan_vote_tests.cpp new file mode 100644 index 000000000000..166f3d06c4eb --- /dev/null +++ b/src/test/governance_orphan_vote_tests.cpp @@ -0,0 +1,179 @@ +// Copyright (c) 2026 The Dash Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include +#include + +using namespace std::chrono_literals; + +namespace { +struct GovernanceOrphanVoteSetup : public TestingSetup { + GovernanceOrphanVoteSetup() : TestingSetup{CBaseChainParams::MAIN} + { + BOOST_REQUIRE(m_node.mn_sync); + m_node.mn_sync->SwitchToNextAsset(); + BOOST_REQUIRE(m_node.mn_sync->IsBlockchainSynced()); + + BOOST_REQUIRE(m_node.mn_metaman); + // Left unloaded: no test here reaches CGovernanceObject::ProcessVote. + m_node.govman = std::make_unique(*m_node.mn_metaman, *m_node.chainman, + *m_node.chain_helper->superblocks, *m_node.dmnman, + *m_node.mn_sync); + BOOST_REQUIRE(m_node.govman->LoadCache(/*load_cache=*/false)); + + // Deterministic timestamps for constructed votes. + SetMockTime(1'700'000'000s); + } + ~GovernanceOrphanVoteSetup() + { + // govman holds a reference to chain_helper->superblocks; tear down first. + m_node.govman.reset(); + } +}; + +CGovernanceVote MakeUnvalidatedOrphanVote(const uint256& parent_hash, uint32_t outpoint_n) +{ + // Garbage masternode outpoint + garbage signature: well-formed on the wire, + // but never a valid tip-list MN vote. Pre-fix ProcessVote still caches these. + CGovernanceVote vote{COutPoint{uint256S("11"), outpoint_n}, parent_hash, VOTE_SIGNAL_FUNDING, VOTE_OUTCOME_YES}; + vote.SetTime(GetTime().count()); + vote.SetSignature(std::vector(CGovernanceVote::COMPACT_SIG_SIZE, 0xab)); + return vote; +} +} // namespace + +BOOST_FIXTURE_TEST_SUITE(governance_orphan_vote_tests, GovernanceOrphanVoteSetup) + +// SECURITY regression: +// Unauthenticated peers must not be able to fill cmmapOrphanVotes with unvalidated +// votes keyed by attacker-chosen parent hashes. Pre-fix, ProcessVote inserts +// before any masternode/signature check and returns GOVERNANCE_EXCEPTION_WARNING +// with nNodePenalty=0, so N distinct parents become N orphan keys that the +// 5-minute scheduler then fans out as MNGOVERNANCESYNC to every peer. +// +// Post-fix, unvalidated votes are rejected before orphan insertion with the same +// penalty 20 that CGovernanceObject::ProcessVote applies to an unknown masternode, +// so the orphan set stays empty. (The orphan branch itself keeps penalty 0: getting +// there now requires a valid MN signature, so it is a benign relay race.) +BOOST_AUTO_TEST_CASE(orphan_vote_cache_rejects_unvalidated_votes) +{ + constexpr size_t N = 50; + + size_t inserted_as_orphan{0}; + size_t zero_penalty_rejects{0}; + size_t penalty_20_rejects{0}; + + for (size_t i = 0; i < N; ++i) { + // Distinct attacker-chosen parent hashes so each would occupy its own + // CacheMultiMap key (and therefore produce one MNGOVERNANCESYNC per peer). + const uint256 parent_hash{uint256S(strprintf("%02x", static_cast(i + 1)))}; + const CGovernanceVote vote{MakeUnvalidatedOrphanVote(parent_hash, /*outpoint_n=*/static_cast(i + 1))}; + + CGovernanceException exception; + uint256 hash_to_request; + const bool accepted = m_node.govman->ProcessVote(vote, exception, hash_to_request); + BOOST_CHECK(!accepted); + + if (!hash_to_request.IsNull()) { + ++inserted_as_orphan; + BOOST_CHECK_EQUAL(hash_to_request, parent_hash); + } + if (exception.GetNodePenalty() == 0) { + ++zero_penalty_rejects; + } + if (exception.GetNodePenalty() == 20) { + ++penalty_20_rejects; + } + } + + const std::vector orphan_parents = m_node.govman->GetOrphanVoteObjectHashes(); + + // Pre-fix this fails: every unvalidated vote is cached under its parent hash + // with a zero-penalty WARNING, so inserted_as_orphan == N and orphan_parents + // grows without a protective validation gate. + // + // Post-fix invariants: + // - no unvalidated vote enters the orphan cache + // - no courtesy object-request is advertised for garbage parents + // - every rejection is scored 20, so repeated injection reaches + // DISCOURAGEMENT_THRESHOLD and the peer is disconnected + BOOST_CHECK_EQUAL(inserted_as_orphan, 0U); + BOOST_CHECK_EQUAL(zero_penalty_rejects, 0U); + BOOST_CHECK_EQUAL(penalty_20_rejects, N); + BOOST_CHECK_EQUAL(orphan_parents.size(), 0U); +} + +// The orphan cache must be bounded well below MAX_CACHE_SIZE (1e6). At ~600 bytes +// per retained CacheMultiMap entry, the pre-fix ceiling was ~600 MB of +// attacker-controlled data; MAX_ORPHAN_VOTES keeps the worst case under ~1 MB. +// Asserted against the shared MAX_CACHE_SIZE bound rather than a repeated literal +// so this fails if someone reverts the constructor back to MAX_CACHE_SIZE. +BOOST_AUTO_TEST_CASE(orphan_vote_cache_is_bounded_far_below_max_cache_size) +{ + BOOST_CHECK_GT(CGovernanceManager::MAX_ORPHAN_VOTES, 0); + // Comfortably above the number of governance objects a real network syncs + // out of order, so honest orphan recovery is unaffected. + BOOST_CHECK_GE(CGovernanceManager::MAX_ORPHAN_VOTES, 500); + // ~1000x below the generic cache ceiling: the memory-exhaustion fix. + BOOST_CHECK_LE(CGovernanceManager::MAX_ORPHAN_VOTES, 10'000); + + // The cache actually enforces it. Pre-fix (cmmapOrphanVotes(MAX_CACHE_SIZE)) + // this reports 1'000'000 and fails. + BOOST_CHECK_EQUAL(m_node.govman->GetOrphanVoteCacheMaxSize(), + static_cast(CGovernanceManager::MAX_ORPHAN_VOTES)); + + // A single tick must never fan out more requests than the cache can hold. + BOOST_CHECK_GT(CGovernanceManager::MAX_ORPHAN_OBJECT_REQUESTS_PER_TICK, 0U); + BOOST_CHECK_LE(CGovernanceManager::MAX_ORPHAN_OBJECT_REQUESTS_PER_TICK, + static_cast(CGovernanceManager::MAX_ORPHAN_VOTES)); +} + +// governance.dat must not carry orphan votes across a restart, and the +// version string must be bumped whenever that layout changes so old files are +// discarded rather than misparsed. +BOOST_AUTO_TEST_CASE(orphan_votes_are_not_persisted) +{ + // A v16 file has an extra CacheMultiMap between cmapInvalidVotes and mapObjects. + // Reading it with the v17 layout would misparse, so the version string must differ. + BOOST_CHECK_NE(CGovernanceManager::GetSerializationVersionString(), + std::string{"CGovernanceManager-Version-16"}); + + // Round-trip the store. Serialize writes the v17 field sequence; Unserialize must + // consume it exactly, leaving no trailing bytes. If Serialize and Unserialize ever + // disagree about the orphan map (one writing it, the other not), the reader either + // throws or leaves the stream non-empty here. + CDataStream ss{SER_DISK, CLIENT_VERSION}; + ss << *m_node.govman; + const size_t written = ss.size(); + + BOOST_REQUIRE_NO_THROW(ss >> *m_node.govman); + BOOST_CHECK(ss.empty()); + + // Writing the reloaded store must reproduce the identical byte stream, so no + // orphan state was smuggled in or dropped across the round trip. + CDataStream ss2{SER_DISK, CLIENT_VERSION}; + ss2 << *m_node.govman; + BOOST_CHECK_EQUAL(ss2.size(), written); + BOOST_CHECK(m_node.govman->GetOrphanVoteObjectHashes().empty()); +} + +BOOST_AUTO_TEST_SUITE_END()