Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Makefile.test.include
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ BITCOIN_TESTS =\
test/net_peer_eviction_tests.cpp \
test/net_tests.cpp \
test/netbase_tests.cpp \
test/orphan_vote_cache_tests.cpp \
test/orphanage_tests.cpp \
test/pmt_tests.cpp \
test/policyestimator_tests.cpp \
Expand Down
9 changes: 9 additions & 0 deletions src/cachemultimap.h
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ class CacheMultiMap
return (mapIndex.find(key) != mapIndex.end());
}

bool HasEntry(const K& key, const V& value) const
{
map_cit mit = mapIndex.find(key);
if (mit == mapIndex.end()) {
return false;
}
return mit->second.count(value) > 0;
}

bool Get(const K& key, V& value) const
{
map_cit it = mapIndex.find(key);
Expand Down
66 changes: 38 additions & 28 deletions src/governance/governance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ GovernanceStore::GovernanceStore() :
cs_store(),
mapObjects(),
mapErasedGovernanceObjects(),
cmmapOrphanVotes(MAX_CACHE_SIZE),
m_orphan_votes(MAX_ORPHAN_VOTES, MAX_ORPHAN_VOTES_PER_MN),
mapLastMasternodeObject(),
lastMNListForVotingKeys(std::make_shared<CDeterministicMNList>())
{
Expand Down Expand Up @@ -279,7 +279,7 @@ void CGovernanceManager::CheckOrphanVotes(CGovernanceObject& govobj)

uint256 nHash = govobj.GetHash();
std::vector<governance::OrphanVote> orphan_votes;
cmmapOrphanVotes.GetAll(nHash, orphan_votes);
m_orphan_votes.GetAll(nHash, orphan_votes);

ScopedLockBool guard(cs_store, fRateChecksEnabled, false);

Expand All @@ -296,7 +296,7 @@ void CGovernanceManager::CheckOrphanVotes(CGovernanceObject& govobj)
fRemove = true;
}
if (fRemove) {
cmmapOrphanVotes.Erase(nHash, orphan_vote);
m_orphan_votes.Erase(nHash, orphan_vote);
}
}
}
Expand Down Expand Up @@ -406,6 +406,11 @@ void CGovernanceManager::CheckAndRemove()

ScopedLockBool guard(cs_store, fRateChecksEnabled, false);

// Drop orphan votes whose parent never arrived. Votes for an object that did arrive are
// consumed by CheckOrphanVotes() at that point, so anything still here is either waiting or
// dead; this is the only thing that removes the latter.
ExpireOrphanVotes();

// Clean up any expired or invalid triggers
m_superblocks.Clean(nCachedBlockHeight);

Expand Down Expand Up @@ -824,14 +829,29 @@ bool CGovernanceManager::ProcessVote(const CGovernanceVote& vote, CGovernanceExc
exception = CGovernanceException(msg, GOVERNANCE_EXCEPTION_PERMANENT_ERROR, 20);
return false;
}
const auto insert_result = m_orphan_votes.Insert(
nHashGovobj, governance::OrphanVote{vote, Now<NodeSeconds>() + GOVERNANCE_ORPHAN_EXPIRATION_TIME});
if (insert_result == governance::OrphanVoteCache::InsertResult::MN_LIMIT) {
// This masternode already fills its share of the cache with votes for parents that
// never arrived; drop the vote and stop letting the key seed parent requests. Still no
// penalty: the sending peer may merely be relaying, and misbehaviour scores never decay.
std::string msg{strprintf("CGovernanceManager::%s -- Masternode %s is over its orphan-vote share, "
"dropping vote for unknown parent object %s",
__func__, vote.GetMasternodeOutpoint().ToStringShort(), nHashGovobj.ToString())};
exception = CGovernanceException(msg, GOVERNANCE_EXCEPTION_WARNING);
LogPrint(BCLog::GOBJECT, "%s\n", msg);
return false;
}
std::string msg{strprintf("CGovernanceManager::%s -- Unknown parent object %s, MN outpoint = %s", __func__,
nHashGovobj.ToString(), vote.GetMasternodeOutpoint().ToStringShort())};
// No penalty: the vote is signed by a masternode, it just arrived before its parent object,
// which routinely happens during governance sync. Misbehaviour scores never decay.
exception = CGovernanceException(msg, GOVERNANCE_EXCEPTION_WARNING);
if (cmmapOrphanVotes.Insert(nHashGovobj, governance::OrphanVote{vote, Now<NodeSeconds>() + GOVERNANCE_ORPHAN_EXPIRATION_TIME})) {
hashToRequest = nHashGovobj; // Caller should request this object
}
// Ask for the parent whether or not the vote itself was new to us. A vote we already hold,
// relayed by a second peer, is fresh evidence that this peer has the parent -- and it is the
// only evidence we will get, since a peer relays a given vote once. Suppressing the request
// on a duplicate would strand the parent whenever the first peer we asked fails to deliver.
hashToRequest = nHashGovobj;
LogPrint(BCLog::GOBJECT, "%s\n", msg);
return false;
}
Expand Down Expand Up @@ -1011,7 +1031,7 @@ void GovernanceStore::Clear()
LOCK(cs_store);
mapObjects.clear();
mapErasedGovernanceObjects.clear();
cmmapOrphanVotes.Clear();
m_orphan_votes.Clear();
mapLastMasternodeObject.clear();
lastMNListForVotingKeys = std::make_shared<CDeterministicMNList>();
}
Expand Down Expand Up @@ -1089,33 +1109,24 @@ void CGovernanceManager::UpdatedBlockTip(const CBlockIndex* pindex)
m_superblocks.ExecuteBestSuperblock(m_dmnman.GetListAtChainTip(), pindex->nHeight);
}

std::vector<uint256> CGovernanceManager::GetOrphanVoteObjectHashes()
void CGovernanceManager::ExpireOrphanVotes()
{
LOCK(cs_store);
AssertLockHeld(cs_store);

const auto now{Now<NodeSeconds>()};

// Clean up expired orphan votes
const vote_cmm_t::list_t& items = cmmapOrphanVotes.GetItemList();
const auto& items = m_orphan_votes.GetItemList();
for (auto it = items.begin(); it != items.end();) {
auto prevIt = it;
++it;
if (prevIt->value.expiration < now) {
cmmapOrphanVotes.Erase(prevIt->key, prevIt->value);
}
}

// Get hashes of objects we don't have yet
std::vector<uint256> vecHashesFiltered;
std::vector<uint256> vecHashes;
cmmapOrphanVotes.GetKeys(vecHashes);
for (const uint256& nHash : vecHashes) {
if (mapObjects.find(nHash) == mapObjects.end()) {
vecHashesFiltered.push_back(nHash);
m_orphan_votes.Erase(prevIt->key, prevIt->value);
}
}
}

return vecHashesFiltered;
size_t CGovernanceManager::GetOrphanVoteCount() const
{
return WITH_LOCK(cs_store, return m_orphan_votes.GetSize());
}

void CGovernanceManager::RemoveInvalidVotes()
Expand Down Expand Up @@ -1152,14 +1163,13 @@ void CGovernanceManager::RemoveInvalidVotes()
for (const auto& outpoint : changedKeyMNs) {
for (auto& [_, govobj] : mapObjects) {
auto removed = Assert(govobj)->RemoveInvalidVotes(tip_mn_list, outpoint);
if (removed.empty()) {
continue;
}
for (const auto& voteHash : removed) {
cmapVoteToObject.Erase(voteHash);
cmmapOrphanVotes.Erase(voteHash);
}
}
// Cached orphans from this masternode were signed with the replaced key and can no longer
// validate on replay.
m_orphan_votes.EraseAllForMasternode(outpoint);
}

// store current MN list for the next run so that we can determine which keys changed
Expand Down
134 changes: 129 additions & 5 deletions src/governance/governance.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,109 @@ inline bool operator<(const OrphanVote& lhs, const OrphanVote& rhs)
{
return lhs.vote < rhs.vote;
}

/** Bounded holding area for votes whose parent object has not arrived yet. Entries are
* peer-supplied and evicted oldest-first, so two bounds are enforced together: a global one that
* caps memory, and a per-masternode one so that a single voting key cannot mint votes naming
* invented parents and flush everyone else's orphans on demand. */
class OrphanVoteCache
{
public:
using cache_t = CacheMultiMap<uint256, OrphanVote>;

enum class InsertResult {
OK,
DUPLICATE, //!< this (parent, vote) pair is already cached
MN_LIMIT, //!< the vote's masternode already holds its full share of the cache
};

OrphanVoteCache(size_t max_total, size_t max_per_mn) :
m_max_total{max_total},
m_max_per_mn{max_per_mn},
// Eviction happens here, before the inner map is ever full, so its own self-pruning
// (which would bypass the per-masternode accounting) can never trigger.
m_cache{static_cast<cache_t::size_type>(max_total + 1)}
{
}

InsertResult Insert(const uint256& parent_hash, const OrphanVote& orphan_vote)
{
// A pair we already hold is reported as a duplicate even when its masternode is over its
// share: it costs no capacity, and the caller treats a repeat relay as fresh evidence that
// the sending peer has the missing parent.
if (m_cache.HasEntry(parent_hash, orphan_vote)) {
return InsertResult::DUPLICATE;
}
const COutPoint outpoint{orphan_vote.vote.GetMasternodeOutpoint()};
if (const auto it{m_counts.find(outpoint)}; it != m_counts.end() && it->second >= m_max_per_mn) {
return InsertResult::MN_LIMIT;
Comment on lines +94 to +95

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check duplicates before enforcing the masternode cap

When a masternode has exactly 500 cached orphans, relaying any one of those votes from a second peer returns MN_LIMIT before checking whether the entry is a duplicate. ProcessVote() consequently exits without setting hashToRequest, so the second relayer is not registered as a fallback even though the duplicate consumes no additional cache capacity; if the first peer fails and no periodic sweep remains, that parent can be stranded. Fresh evidence in the final code is this limit check's position ahead of m_cache.Insert, which contradicts the newly added duplicate-relay recovery path; detect duplicates first and add a boundary test with a full per-masternode share.

AGENTS.md reference: AGENTS.md:L171-L171

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 082f399. OrphanVoteCache::Insert() now checks for an existing (parent, vote) pair before applying the per-masternode share, via a new CacheMultiMap::HasEntry(). Covered by duplicates_are_recognized_at_the_per_masternode_share in the new src/test/orphan_vote_cache_tests.cpp.


🤖 Posted autonomously by Claude on behalf of pasta.

}
if (!m_cache.Insert(parent_hash, orphan_vote)) {
return InsertResult::DUPLICATE;
}
Comment on lines +85 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Recognize duplicate orphans before enforcing the masternode limit

When a masternode has exactly MAX_ORPHAN_VOTES_PER_MN cached entries, OrphanVoteCache::Insert() returns MN_LIMIT before checking whether the incoming (parent, vote) pair is already cached. A second peer relaying one of those existing votes therefore causes CGovernanceManager::ProcessVote() to return before assigning hashToRequest, so NetGovernance never registers that peer as a fallback source for the missing parent. The duplicate consumes no additional cache capacity, and suppressing it violates the PR's stated invariant that every relay of a cached orphan can add its sender as a candidate now that the periodic sweep is gone. Detect an existing pair before applying the per-masternode capacity check, and add a boundary test that repeats a cached vote after filling that masternode's share.

source: ['codex']

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid — real bug, fixed in 082f399 (fixup into the commit that introduced it). Confirmed the consequence you describe: MN_LIMIT returned before the duplicate check meant ProcessVote() returned without setting hashToRequest, so a second peer relaying an already-cached vote from an at-share masternode was never registered as a fallback source — exactly the invariant the PR relies on now that the sweep is gone.

OrphanVoteCache::Insert() now tests for an existing (parent, vote) pair first (new CacheMultiMap::HasEntry(), mirroring the dedupe check Insert() already does internally) and only then applies the per-masternode share. Added duplicates_are_recognized_at_the_per_masternode_share, which fills a masternode share, asserts a new parent is refused with MN_LIMIT, and asserts both cached votes still repeat as DUPLICATE without changing cache size.


🤖 Posted autonomously by Claude on behalf of pasta.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in this update — Recognize duplicate orphans before enforcing the masternode limit no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

++m_counts[outpoint];
if (m_cache.GetSize() > m_max_total) {
// Insert prepends, so the back is the oldest entry cache-wide and never the one just
// added. Copied, not referenced: Erase destroys the node.
const auto oldest{m_cache.GetItemList().back()};
Erase(oldest.key, oldest.value);
}
return InsertResult::OK;
}

void Erase(const uint256& parent_hash, const OrphanVote& orphan_vote)
{
// Callers may pass a reference into the cache's own item list (e.g. the expiry sweep),
// which the erase below invalidates; take what we need first.
const COutPoint outpoint{orphan_vote.vote.GetMasternodeOutpoint()};
const auto size_before{m_cache.GetSize()};
m_cache.Erase(parent_hash, orphan_vote);
if (m_cache.GetSize() == size_before) return;
if (const auto it{m_counts.find(outpoint)}; it != m_counts.end() && --it->second == 0) {
m_counts.erase(it);
}
}

//! Drop every cached vote from one masternode, e.g. because its keys changed and the votes
//! can no longer validate on replay. Returns how many were dropped.
size_t EraseAllForMasternode(const COutPoint& outpoint)
{
if (m_counts.find(outpoint) == m_counts.end()) return 0;
// Collect first: Erase destroys the nodes being iterated.
std::vector<std::pair<uint256, OrphanVote>> expelled;
for (const auto& item : m_cache.GetItemList()) {
if (item.value.vote.GetMasternodeOutpoint() == outpoint) {
expelled.emplace_back(item.key, item.value);
}
}
for (const auto& [parent_hash, orphan_vote] : expelled) {
Erase(parent_hash, orphan_vote);
}
return expelled.size();
}

bool GetAll(const uint256& parent_hash, std::vector<OrphanVote>& votes)
{
return m_cache.GetAll(parent_hash, votes);
}

void Clear()
{
m_cache.Clear();
m_counts.clear();
}

size_t GetSize() const { return m_cache.GetSize(); }
const cache_t::list_t& GetItemList() const { return m_cache.GetItemList(); }
//! The inner map, for writing the legacy on-disk field.
const cache_t& Store() const { return m_cache; }

private:
const size_t m_max_total;
const size_t m_max_per_mn;
cache_t m_cache;
std::map<COutPoint, size_t> m_counts;
};
} // namespace governance

static constexpr int RATE_BUFFER_SIZE = 5;
Expand Down Expand Up @@ -176,6 +279,17 @@ class GovernanceStore
using txout_m_t = std::map<COutPoint, last_object_rec>;
using vote_cmm_t = CacheMultiMap<uint256, governance::OrphanVote>;

public:
/** Bounds for the orphan-vote cache, which is filled from the network by any peer with a
* parent object we do not have. The per-masternode bound is the security control: eviction
* is oldest-first, and every entry costs its sender a registered masternode voting key, so
* one key can occupy at most its share instead of flushing the whole cache; the value is
* far above the number of in-flight objects one masternode can plausibly have voted on.
* The global bound caps memory: at roughly 400 bytes per entry this allows ~40 MB, and
* filling it takes 200 distinct masternode keys. */
static constexpr size_t MAX_ORPHAN_VOTES = 100'000;
static constexpr size_t MAX_ORPHAN_VOTES_PER_MN = 500;

protected:
static constexpr int MAX_CACHE_SIZE = 1000000;
static const std::string SERIALIZATION_VERSION_STRING;
Expand All @@ -190,7 +304,7 @@ class GovernanceStore
// key - governance object's hash
// value - expiration time for deleted objects
std::map<uint256, int64_t> mapErasedGovernanceObjects GUARDED_BY(cs_store);
vote_cmm_t cmmapOrphanVotes GUARDED_BY(cs_store);
governance::OrphanVoteCache m_orphan_votes GUARDED_BY(cs_store);
txout_m_t mapLastMasternodeObject GUARDED_BY(cs_store);
// used to check for changed voting keys
std::shared_ptr<CDeterministicMNList> lastMNListForVotingKeys GUARDED_BY(cs_store);
Expand All @@ -208,7 +322,7 @@ class GovernanceStore
s << SERIALIZATION_VERSION_STRING
<< mapErasedGovernanceObjects
<< empty_invalid_votes
<< cmmapOrphanVotes
<< m_orphan_votes.Store()
<< mapObjects
<< mapLastMasternodeObject
<< *lastMNListForVotingKeys;
Expand All @@ -228,9 +342,15 @@ class GovernanceStore

// TODO: Stop consuming the historical invalid-vote-cache field on the next disk-format version bump.
CacheMap<uint256, CGovernanceVote> discarded_invalid_votes;
// The historical format stores CacheMultiMap's capacity with its entries. Consume that
// field to preserve the format, but keep both the stale orphan votes and their disk-supplied
// capacity out of the live cache. Orphans are a ten-minute recovery window invalidated by
// the restart; the live capacity is fixed at construction.
vote_cmm_t discarded_orphan_votes;

s >> mapErasedGovernanceObjects
>> discarded_invalid_votes
>> cmmapOrphanVotes
>> discarded_orphan_votes
>> mapObjects
>> mapLastMasternodeObject
>> *lastMNListForVotingKeys;
Expand Down Expand Up @@ -365,8 +485,8 @@ class CGovernanceManager : public GovernanceStore
// Used by NetGovernance
std::vector<CInv> 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. */
[[nodiscard]] std::vector<uint256> GetOrphanVoteObjectHashes() EXCLUSIVE_LOCKS_REQUIRED(!cs_store);
/** Number of orphan votes currently held, so the MAX_ORPHAN_VOTES bound can be asserted. */
[[nodiscard]] size_t GetOrphanVoteCount() const EXCLUSIVE_LOCKS_REQUIRED(!cs_store);
std::pair<std::vector<uint256>, std::vector<uint256>> FetchGovernanceObjectVotes(
size_t peers_per_hash_max, int64_t now, std::map<uint256, std::map<CService, int64_t>>& map_asked_recently) const
EXCLUSIVE_LOCKS_REQUIRED(!cs_store);
Expand Down Expand Up @@ -417,6 +537,10 @@ class CGovernanceManager : public GovernanceStore
void CheckOrphanVotes(CGovernanceObject& govobj)
EXCLUSIVE_LOCKS_REQUIRED(cs_store, !cs_relay);

/** Drop orphan votes whose parent object never arrived within GOVERNANCE_ORPHAN_EXPIRATION_TIME. */
void ExpireOrphanVotes()
EXCLUSIVE_LOCKS_REQUIRED(cs_store);

void RebuildIndexes()
EXCLUSIVE_LOCKS_REQUIRED(cs_store);

Expand Down
30 changes: 9 additions & 21 deletions src/governance/net_governance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -45,23 +45,9 @@ void NetGovernance::Schedule(CScheduler& scheduler)
[this]() -> void {
if (!m_node_sync.IsSynced()) return;

// Request governance objects for orphan votes
auto vecOrphanHashes = m_gov_manager.GetOrphanVoteObjectHashes();
if (!vecOrphanHashes.empty()) {
LogPrint(BCLog::GOBJECT, "NetGovernance::Schedule -- requesting %d orphan objects\n",
vecOrphanHashes.size());
const CConnman::NodesSnapshot snap{m_connman, CConnman::FullyConnectedOnly};
for (const uint256& nHash : vecOrphanHashes) {
for (CNode* pnode : snap.Nodes()) {
if (!pnode->CanRelay()) 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));
}
}
}

// CHECK AND REMOVE - REPROCESS GOVERNANCE OBJECTS
// Also expires orphan votes whose parent object never arrived. Fetching those parents
// is driven by the object request tracker from ProcessMessage(), not from here.
m_gov_manager.CheckAndRemove();
},
std::chrono::minutes{5});
Expand Down Expand Up @@ -257,11 +243,13 @@ void NetGovernance::ProcessMessage(CNode& peer, const std::string& msg_type, CDa
// m_peer_manager->PeerRelayInv(CInv{MSG_GOVERNANCE_OBJECT_VOTE, nHash});
} else {
LogPrint(BCLog::GOBJECT, "MNGOVERNANCEOBJECTVOTE -- Rejected vote, error = %s\n", exception.what());
if (hashToRequest != uint256()) {
// Orphan vote - request the missing governance object
CNetMsgMaker msgMaker(peer.GetCommonVersion());
CBloomFilter filter; // Empty filter - we just want the object, not votes
m_connman.PushMessage(&peer, msgMaker.Make(NetMsgType::MNGOVERNANCESYNC, hashToRequest, filter));
if (!hashToRequest.IsNull()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-seed cached orphan parents from later relays

When the same orphan vote is later received from a second peer—particularly after the first peer's parent request timed out—ProcessVote rejects the duplicate cache insertion and leaves hashToRequest null, so this condition skips PeerAskPeersForObject and never registers the second peer as a fallback. Because this change also removes the periodic all-peer orphan sweep, the parent can remain unavailable until an unrelated object announcement or full governance resync, despite the second peer providing the same evidence that motivated preferring the first peer. Return the cached orphan's parent for later relays, or otherwise register each relaying peer while the orphan remains pending.

AGENTS.md reference: AGENTS.md:L165-L175

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed some time ago and further hardened in 082f399. ProcessVote() assigns hashToRequest for an orphan whether or not the cache insert was new, so every relay of a still-parentless vote registers its sender as a fallback source — covered by orphan_vote_relayed_by_a_second_peer_adds_it_as_a_fallback. As of 082f399 that also holds when the voting masternode is at its per-masternode share, since a cached pair is now reported as a duplicate rather than being turned away by the share check.


🤖 Posted autonomously by Claude on behalf of pasta.

// Orphan vote: fetch the parent object through the request tracker, which owns
// GETDATA scheduling, per-peer in-flight limits, expiry and fallback to another
// peer. Register this peer explicitly -- holding a vote for the object is evidence
// it has the object, and it may never have announced the object to us.
m_peer_manager->PeerAskPeersForObject(CInv{MSG_GOVERNANCE_OBJECT, hashToRequest},
peer.GetId());
}
if ((exception.GetNodePenalty() != 0) && m_node_sync.IsSynced()) {
m_peer_manager->PeerMisbehaving(peer.GetId(), exception.GetNodePenalty());
Expand Down
Loading
Loading