Skip to content

fix: fetch governance orphan-vote parents through the request tracker and bound the orphan cache per masternode - #7886

Open
PastaPastaPasta wants to merge 2 commits into
dashpay:developfrom
PastaPastaPasta:fix/governance-orphan-vote-fetch
Open

PastaPastaPasta wants to merge 2 commits into
dashpay:developfrom
PastaPastaPasta:fix/governance-orphan-vote-fetch

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

A governance vote whose parent object we do not have is kept as an orphan, and the node tries to fetch the parent. Two things in that path grow with input a peer controls:

Fan-out. Every 5 minutes, NetGovernance::Schedule() sent one MNGOVERNANCESYNC(parent, empty filter) per orphan parent hash to every connected peer, with no cap. That is O(orphan parents × peers) messages per tick. PushMessage queues them whether or not fPauseSend is set. With 30k orphan parents and 50 peers, one tick is about 1.5M messages: roughly 180 MB of vSendMsg and 100 MB of egress, repeated every 5 minutes for as long as the orphans live.

Cache. cmmapOrphanVotes allowed 1,000,000 entries at roughly 800 bytes each.

Nothing makes orphan votes scarce. A vote needs a valid masternode signature, but nothing ties the signed nParentHash to an object that exists. One masternode key can sign votes for as many invented parents as it likes.

The broadcast was also unnecessary. When a peer receives a non-zero MNGOVERNANCESYNC with an empty filter (object_fetch in net_governance.cpp), it replies with a plain INV(MSG_GOVERNANCE_OBJECT, parent), and that INV goes through the normal object request tracker. The message only made the peer announce the object.

This replaces #7526, which was closed so it could be rebuilt. The design is the same: fetch through the tracker, delete the sweep, and bound the cache globally and per masternode. Changes relative to #7526:

  • AskPeersForTransaction is not generalized. fix: fetch orphan-vote parents via the request tracker instead of broadcasting #7526 did that in a commit labelled refactor: that changed InstantSend behavior. Here the InstantSend path is untouched. The vote's sender is simply treated as having announced the parent.
  • No force_preferred. A vote relayer gets the same preferred/non-preferred treatment as any announcer, so an inbound relayer cannot jump ahead of outbound peers.
  • No default argument on a virtual function, and no test-only accessor.
  • The per-masternode bound is not split from the global one. A prefix of the commits is never weaker than develop.
  • No governance.dat field change, no new test files, and no framework message classes. +291/−70 in 8 files, compared with +699/−153 in 17.
  • The bounds are re-derived (see below): 100 votes per masternode and 50,000 in total, against 500 and 100,000 in fix: fetch orphan-vote parents via the request tracker instead of broadcasting #7526.

What was done?

fix: fetch orphan-vote parents through the object request tracker

  • New PeerManagerInternal::PeerAddObjectAnnouncement(node, inv), which is if (!AlreadyHave(inv)) AddObjectAnnouncement(...). This is what a received INV does, and how orphan transactions already fetch their parents. So it gets the same MAX_PEER_OBJECT_ANNOUNCEMENTS cap, overload delay and preferred/non-preferred scheduling. An orphan vote gives a peer no request capacity beyond what it already has by announcing hashes itself.
  • When a vote is orphaned, NetGovernance registers the vote's sender this way instead of sending it MNGOVERNANCESYNC. Nodes only relay votes whose parent they have, so the sender is the peer to ask.
  • The 5-minute sweep and GetOrphanVoteObjectHashes() are deleted. Orphan expiry used to happen inside GetOrphanVoteObjectHashes(). It moves to CheckAndRemove(), which still runs on the same tick, so orphans whose parent never arrives are still dropped.
  • ProcessVote() now returns the parent hash for every relay of an orphan vote, including a vote that is already held. Every relayer becomes a fetch candidate, and the tracker moves to the next one when a request times out or the peer disconnects. Before this change, the sweep was the fallback.
  • Nodes still answer the object_fetch form of MNGOVERNANCESYNC from older peers. Only the sending side changes.

fix: bound the governance orphan vote cache per masternode

  • governance::OrphanVoteCache wraps the CacheMultiMap. It applies a per-masternode share (MAX_ORPHAN_VOTES_PER_MN = 100) and a global bound (MAX_ORPHAN_VOTES = 50,000). Eviction is oldest-first. Without the share, one key could evict every other masternode's orphans.
  • The wrapper evicts entries itself, so the evicted vote's masternode gets that part of its share back. A vote that is already held stays held when its masternode is at its share, so every peer relaying it still becomes a fetch candidate.
  • governance.dat stores CacheMultiMap's capacity, and earlier versions wrote 1,000,000. Adopting the stored map would make that the live bound and leave the per-masternode counts empty. Stored orphans are therefore re-inserted through the bounded Insert(). The on-disk format is unchanged.
  • The orphan erase in RemoveInvalidVotes() is removed. It looked up a vote hash in a map keyed by parent hash, so it never matched anything.

Sizing. I measured about 800 bytes per entry (allocator-reported, macOS, BLS-size signature, one parent hash per entry). That puts the global bound at about 40 MB. A masternode has one current vote per signal on an object, and an orphan expires after ten minutes, so an honest masternode needs far fewer than 100 pending votes for objects we lack. Filling the cache takes 500 masternodes. These figures still hold with the corrected vote ordering from #7819, which allows many votes per parent: entries cost the same (slightly less when they share a parent), and #7819 keeps at most one current vote per masternode, signal, outcome and signing key for each parent.

Deliberate behavior narrowing. The sweep asked every connected peer every 5 minutes for the whole 10-minute life of an orphan. Now only peers with evidence are asked: those that relayed the vote. If all of them fail to deliver, a synced node recovers through ordinary flood relay of the object. A restarting node is covered by governance sync. Orphans loaded from governance.dat no longer cause any fetch either: they are replayed if their parent arrives by other means, and otherwise expire within ten minutes. The fan-out cannot be removed while keeping that persistence, because the persistence was the fan-out.

Deliberately not done.

  • The orphan branch stays at misbehavior penalty 0. Reaching it is a routine relay race, scoring is suppressed while !IsSynced(), and the relaying peer may be innocent. The per-masternode share is the response to a misbehaving key.

Relationship to #7819. #7819 fixes CGovernanceVote::operator<, which has returned false for every pair since 2016, so today the orphan cache keeps only the first vote per parent. It also fixes a dangling reference in CacheMultiMap::Insert, and keeps only the newest orphan per signer. The two PRs are independent and either can land first. OrphanVoteCache is correct under either ordering: it evicts before the inner map would prune, so it never reaches the Insert path #7819 fixes, and Erase() credits the share of the stored entry it finds.

Merging them conflicts in two places:

  • ProcessVote() in governance.cpp: fix: preserve and bound orphan governance votes #7819's InsertOrphanVote() takes an OrphanVoteCache& instead of the raw map, with a ranged GetAll() added to the wrapper. It should also return true (held) rather than false when a newer vote from the same signer is already cached. Otherwise a second peer relaying an already-held vote is not registered for the parent, and orphan_vote_parent_is_fetched_from_the_peers_relaying_the_vote fails, which guards this.
  • governance_vote_processing_tests.cpp: both PRs add cases at the same spot. fix: preserve and bound orphan governance votes #7819's new test still calls GetOrphanVoteObjectHashes(), which this PR removes.

I merged #7819's head into this branch with that resolution. On the result, the governance_*, cachemultimap_tests, net_tests, denialofservice_tests, txrequest_tests and peerman_tests unit suites pass, including both PRs' new tests.

How Has This Been Tested?

Built on aarch64-apple-darwin with --enable-debug --enable-werror --enable-suppress-external-warnings. Each commit was built and passed the unit tests below.

New or changed unit tests in src/test/governance_vote_processing_tests.cpp (existing fixture with a registered masternode):

  • orphan_vote_parent_is_fetched_from_the_peers_relaying_the_vote: two peers relay the same orphan vote through NetGovernance. SendMessages queues a GETDATA for the parent to the first peer. After the first peer disconnects, the GETDATA goes to the second. The second serves the object, and the orphan vote is applied. Fails on develop. Also fails if the parent hash is returned only for a newly stored vote.
  • orphan_votes_are_limited_per_masternode: fills one masternode's share through ProcessVote(). Its next vote, for a real proposal, is dropped with no parent to fetch, and it is not applied when the proposal arrives (yes count 0). A repeat of a held vote still returns its parent. The share is still in effect after a governance.dat round trip, and it frees up once the orphans expire and CheckAndRemove() runs. Fails on develop.
  • orphan_vote_eviction_frees_the_masternode_share: OrphanVoteCache with bounds 2/2. A global eviction returns the evicted masternode's share.
  • Mutation-checked: each of these makes a test fail: dropping the eviction accounting, checking the share before the duplicate check, loading stored entries without counts, and removing the expiry call.
  • governance_inv_tests/orphan_votes_require_a_valid_masternode_signature now checks that no GETDATA is sent instead of checking for no MNGOVERNANCESYNC. Two GetOrphanVoteObjectHashes() checks in orphan_vote_is_cached_and_applied_when_parent_arrives are removed: the test's yes-count check already shows the vote was held and applied.

Unit tests run: the full test_dash suite at the tip, and governance_*, net_tests, denialofservice_tests, txrequest_tests, peerman_tests at each commit.
Functional tests run: feature_governance.py --descriptors, feature_governance_cl.py --descriptors, feature_governance_objects.py, feature_governance_txindex_devnet.py, p2p_govsync_bloom.py, p2p_instantsend.py.
Lint: lint-whitespace.py, lint-includes.py, lint-circular-dependencies.py, and clang-format-diff on the changed lines.

Breaking Changes

None. No consensus, P2P message or governance.dat format change. Nodes no longer send the object_fetch form of MNGOVERNANCESYNC, but they still answer it.

Checklist:

  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone (for repository code-owners and collaborators only)

🤖 Generated with Claude Code

A governance vote whose parent object we do not have is kept as an
orphan, and we try to fetch the parent. That fetch had two parts:

- the peer that sent the vote was sent MNGOVERNANCESYNC(parent, empty
  filter), and
- every 5 minutes NetGovernance::Schedule() sent the same message for
  every orphan parent hash to every connected peer.

The sweep is O(orphan parents x peers) messages per tick, without a
cap. PushMessage queues them whether or not fPauseSend is set. Every
orphan parent comes from a peer-supplied vote: the parent hash is
covered by the masternode signature, but nothing ties it to an object
that exists. So one masternode key can invent parents without limit.
At 30k orphan parents and 50 peers, one tick is about 1.5M messages
(~180 MB of vSendMsg and ~100 MB of egress), repeated every 5 minutes
for as long as the orphans live.

The message only induces an announcement. A peer serves a non-zero
MNGOVERNANCESYNC with an empty filter by replying
INV(MSG_GOVERNANCE_OBJECT, parent), which goes through the normal
object request tracker. So skip the round trip and register the vote's
sender with the tracker directly, as if it had announced the parent.
This is how orphan transactions already fetch their parents. It uses
the same AlreadyHave() check, MAX_PEER_OBJECT_ANNOUNCEMENTS cap,
overload delay and preferred/non-preferred scheduling as a received
INV. The new PeerAddObjectAnnouncement() exposes it to NetGovernance.
Because of this, an orphan vote gives a peer no more request capacity
than it already has by announcing hashes itself.

The sweep and GetOrphanVoteObjectHashes() are removed. Orphan expiry
used to live in that function. It moves to CheckAndRemove(), which
still runs on the same 5-minute tick, so orphans whose parent never
arrives are still dropped.

The sweep was also the fallback when the first peer asked did not
deliver the parent. Instead, ProcessVote() now returns the parent hash
for every relay of an orphan vote, including a vote we already hold.
Each peer that relays it becomes a fetch candidate, and the tracker
moves on to the next one when a request times out or a peer
disconnects.

Nodes on this version still serve the object-fetch form of
MNGOVERNANCESYNC to older peers. Only the sending side changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-15T04:24:53.521031Z 1045665 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 43 seconds.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 44471aa5-2441-4de1-8bdb-c758c06b0404
📥 Commits

Reviewing files that changed from the base of the PR and between 33670ce and 1045665.

📒 Files selected for processing: 8
  • src/cachemultimap.h
  • src/governance/governance.cpp
  • src/governance/governance.h
  • src/governance/net_governance.cpp
  • src/net_processing.cpp
  • src/net_processing.h
  • src/test/governance_inv_tests.cpp
  • src/test/governance_vote_processing_tests.cpp
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Final review complete — no blockers (commit 1045665) · triage: normal

@PastaPastaPasta
PastaPastaPasta force-pushed the fix/governance-orphan-vote-fetch branch from e550e5d to 6634275 Compare October 15, 2026 04:13
@github-actions

Copy link
Copy Markdown

Potential PR merge conflicts

This is advisory only. It does not block CI, but it marks PRs that will likely need a rebase depending on merge order.

If this PR merges first

These open PRs will likely need a rebase:

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e550e5d849

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/governance/governance.cpp Outdated
The orphan vote cache allowed 1'000'000 entries, at roughly 800 bytes
each. Nothing makes its entries scarce. A vote needs a valid masternode
signature, but nothing ties the signed parent hash to an object that
exists, so one masternode key can sign votes for as many invented
parents as it likes. Eviction is oldest-first across the whole cache,
so a global bound alone would let that one key push every other
masternode's orphans out.

governance::OrphanVoteCache wraps the CacheMultiMap and applies two
bounds:

- MAX_ORPHAN_VOTES_PER_MN = 100. Once a masternode holds that many
  orphan votes, its further new votes are dropped and ProcessVote() no
  longer returns their parent for fetching. A masternode has one
  current vote per signal on an object, and an orphan expires after
  ten minutes, so an honest masternode needs far fewer than 100
  pending votes for objects we lack.
- MAX_ORPHAN_VOTES = 50'000, about 40 MB in the worst case (every
  entry with its own parent hash). Filling it takes 500 masternodes.

The wrapper evicts the oldest entry itself, before the inner map would
prune on its own, so the evicted vote's masternode gets that part of
its share back. A vote that is already held is still reported as held
when its masternode is at its share, so every peer that relays it
still becomes a candidate for fetching the parent.

Erase() releases the share of the stored entry, not of the vote passed
in. The two only have to compare equal, and CGovernanceVote::operator<
currently treats every vote for the same parent as equal (fixed
separately in dashpay#7819).

CacheMultiMap stores its capacity in governance.dat. Reading it into
the live cache would set the bound to whatever the writing version
used (1'000'000 for all earlier versions) and would leave the
per-masternode counts empty. The stored orphans are now re-inserted
through the bounded Insert() instead. The on-disk format does not
change.

The orphan erase in RemoveInvalidVotes() is removed. It looked up a
vote hash in a map keyed by parent hash, so it never matched anything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PastaPastaPasta
PastaPastaPasta force-pushed the fix/governance-orphan-vote-fetch branch from 6634275 to 1045665 Compare October 15, 2026 04:18

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Final validation — Phase 1 + Phase 2

Static verification of the complete PR diff at 1045665 supports the reviewers' clean assessments: orphan-parent fetching uses the existing request tracker, and cache bounds account for eviction, expiry, and deserialization without changing the stored format. The previously discussed zero-capacity precondition is enforced with assert(max_total > 0), and no actionable in-scope defects were identified. No builds or tests were run in this lane; the supplied CI snapshot shows lint and ClangFormat passing, while source builds and platform checks remain queued.

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 3: gpt-6.1-sol (agent: phase2-reviewer, role: dash-core-commit-history); final verifier: gpt-6.1-sol (agent: sol-verifier, role: final-verifier)

  • Triage: normal by gpt-6.1-sol (effort low) — The change spans governance orphan-cache accounting, expiry, and peer request scheduling, but does not itself change consensus, funds handling, cryptography, network deserialization, or storage migrations.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 14% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Fresh verifier: gpt-6.1-sol — final-verifier; agent sol-verifier
  • Phase 2 reviewers: gpt-6.1-sol — general (completed, effort high); agent phase2-reviewer, gpt-6.1-sol — dash-core-commit-history (completed, effort high); agent phase2-reviewer

@thepastaclaw thepastaclaw added the pastaclaw:approved thepastaclaw's latest review approved this PR label Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pastaclaw:approved thepastaclaw's latest review approved this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants