Skip to content

fix: fetch orphan-vote parents via the request tracker instead of broadcasting - #7526

Closed
PastaPastaPasta wants to merge 5 commits into
dashpay:developfrom
PastaPastaPasta:sec/u006-tracker
Closed

PastaPastaPasta wants to merge 5 commits into
dashpay:developfrom
PastaPastaPasta:sec/u006-tracker

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Aug 2, 2026 •

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

CGovernanceManager holds votes whose parent governance object has not arrived yet ("orphan votes") in cmmapOrphanVotes, keyed by the parent hash carried in the vote. Current admission requires a valid signature from a registered masternode voting or operator key, in addition to the standard announce-then-request tracker. One such key can still sign votes naming arbitrarily many invented parents, so authentication alone does not bound the retained or outbound work.

Two things then scale with that masternode-controlled input.

Fan-out. NetGovernance::Schedule() ran a 5-minute sweep that sent one MNGOVERNANCESYNC per orphan parent hash per connected peer, uncapped. That is O(orphans x peers) outbound messages every 5 minutes, for as long as the orphans live. With ~30k orphans and 50 peers that is ~1.5M messages per tick — roughly 180 MB of vSendMsg allocated in a burst plus ~100 MB of egress, repeating. CSerializedNetMsg is appended by PushMessage regardless of fPauseSend (that flag only throttles reading from a peer), so the per-peer -maxsendbuffer ceiling of 1 MB does not stop it.

Cache size. cmmapOrphanVotes was constructed with MAX_CACHE_SIZE = 1'000'000. Each retained entry costs roughly 750 bytes: CacheMultiMap stores the value twice — once in listItems, once as the key of the inner std::map<V, list_it> — and each copy of CGovernanceVote carries a heap-allocated signature. Reaching the full ceiling is throttled by the fetch path, so the realistic figure is tens of MB rather than the ~750 MB the bound permits; it is still not a bound this node chose.

The fan-out is the larger of the two, in bandwidth and in memory.

Worth noting what the sweep was actually doing. On the serving side, MNGOVERNANCESYNC with a non-zero nProp and an empty bloom filter is special-cased (object_fetch in net_governance.cpp) to reply with a plain INV{MSG_GOVERNANCE_OBJECT, nProp} — which then flows into the ordinary object request tracker. So the sweep was an unbounded broadcast whose only purpose was to induce an announcement that the tracker would act on. It also had to be exempted from the HasFulfilledRequest anti-spam accounting to work at all.

This is resource exhaustion only. Orphan votes never reach consensus, and the worst functional outcome is dropped governance votes that re-sync.

What was done?

Fetch orphan parents through the object request tracker instead of broadcasting.

PeerManagerImpl::AskPeersForTransaction(txid) already implemented the right pattern for exactly this problem — fetching a parent you know you want but were never offered — for orphan transactions. It is generalized to AskPeersForObject(const CInv&, NodeId explicit_peer) and exposed as PeerAskPeersForObject. It registers a preferred announcement with m_object_request for a small number of peers and lets the tracker own the fetch: GETDATA scheduling, MAX_PEER_OBJECT_REQUEST_IN_FLIGHT, OVERLOADED_PEER_OBJECT_DELAY, expiry-driven fallback to the next candidate, and AlreadyHave() dedup once the object turns up from any source. The synthetic announcements go through the same per-peer admission accounting (AddObjectAnnouncement) as peer-sent ones.

explicit_peer is new. A peer that holds an object without having announced it appears in no inventory filter, so the existing filter-based candidate search cannot reach it. The peer that sent us an orphan vote is registered explicitly alongside peers whose known-inventory filter contains the parent hash; the request tracker chooses among those candidates and handles timeout-driven fallback.

The orphan branch in NetGovernance::ProcessMessage now calls this instead of pushing MNGOVERNANCESYNC, and the 5-minute sweep plus GetOrphanVoteObjectHashes() are deleted. Each evidence-bearing relay can register its sender as a candidate, while per-peer tracker accounting bounds retained announcements and the tracker deduplicates repeated peer/object pairs. One round trip is also saved, since the tracker is seeded directly rather than via an induced INV.

Keep expiring orphans. Expiry lived inside GetOrphanVoteObjectHashes(). It moves to ExpireOrphanVotes(), called from CheckAndRemove() — the same 5-minute tick, one gate looser (IsBlockchainSynced rather than IsSynced).

Bound the cache, globally and per masternode. cmmapOrphanVotes becomes governance::OrphanVoteCache, which enforces two bounds together: a per-masternode share (MAX_ORPHAN_VOTES_PER_MN = 500) and a global cap (MAX_ORPHAN_VOTES = 100'000, ~75 MB worst case since each entry is stored twice at roughly 750 bytes, replacing the ~750 MB MAX_CACHE_SIZE ceiling). Eviction is oldest-first cache-wide, so with only a global bound one voting key could mint votes naming invented parents and keep flushing everyone else's orphans out of the cache; with the per-key share, a key past its slice has further votes dropped without seeding parent requests, and filling the cache at all takes 200 distinct masternode keys. The wrapper owns the eviction accounting, and the inner map's capacity sits one above the global cap so its own pruning can never bypass the per-masternode counts. The orphan erase in RemoveInvalidVotes is dropped: it looked a vote hash up in a map keyed by parent hash, so it never matched anything. Orphans signed with a since-replaced key are left to expire rather than re-verified on every key change: replay re-verifies each vote against the tip masternode list, and within ten minutes they are gone, so they can only briefly occupy their own masternode's share.

Stop persisting orphans. governance.dat keeps its orphan field, but Unserialize discards it, so Serialize now writes it empty. The empty map keeps the 1'000'000 capacity that builds without this change wrote, because they load that capacity as their live cache bound; a CacheMultiMap with capacity 0 never prunes.

We still serve object_fetch requests from older peers; only the sending side changes.

Deliberately not done

The orphan branch stays at penalty 0. Reaching it is a routine relay race for honest peers, misbehavior scoring is suppressed while !IsSynced() — precisely when orphans are common — and the sending peer may merely be relaying someone else's votes. A valid MN signature is also not treated as scarce: nParentHash is covered by the signature, but nothing ties it to an object that exists, so any one masternode key can sign unlimited votes naming invented parents. The per-masternode share is the response to that — rather than punishing peers, each voting key is limited to its slice of the cache.

How Has This Been Tested?

Rebased onto develop c8bfd7a and built on aarch64-apple-darwin with --enable-debug --enable-werror; every commit builds and passes governance_vote_processing_tests, governance_inv_tests, net_tests, denialofservice_tests (and orphan_vote_cache_tests once it exists). At the tip, governance_superblock_tests, governance_validators_tests, governance_vote_wire_tests, governance_vote_sync_tests, txrequest_tests and peerman_tests also pass, as do the functional tests p2p_governance_orphan_vote.py, p2p_govsync_bloom.py, feature_governance.py --descriptors, feature_governance_cl.py --descriptors, feature_governance_objects.py, feature_governance_txindex_devnet.py and p2p_instantsend.py. lint-whitespace.py, lint-includes.py and lint-circular-dependencies.py are clean.

Develop's #7831 moved governance.dat to CGovernanceManager-Version-17 and dropped the invalid-vote field; this PR keeps the orphan field in that layout, so it needs no further format change.

Unit tests in src/test/governance_vote_processing_tests.cpp:

  • orphan_votes_per_masternode_are_bounded verifies one voting key's orphans are capped at MAX_ORPHAN_VOTES_PER_MN and that votes past the share stop seeding parent requests.
  • orphan_vote_relayers_seed_parent_request_candidates drives a vote relayed by two peers through NetGovernance and verifies both relayers, including the one that relayed a vote we already held, but not an unrelated peer, are registered as parent-fetch candidates.
  • orphan_votes_expire_on_check_and_remove verifies that CheckAndRemove(), which now does all orphan expiry, drops expired orphans and keeps unexpired ones. It fails when the ExpireOrphanVotes() call is removed.
  • legacy_orphan_votes_are_discarded_on_load verifies a cache file's orphan entries and serialized capacity are not loaded into the live cache.
  • saved_store_writes_an_empty_orphan_field verifies a saved store carries an empty orphan field with the 1'000'000 capacity.

src/test/orphan_vote_cache_tests.cpp exercises OrphanVoteCache accounting directly with small bounds: per-key share enforcement and release on erase, duplicates recognized at a full share, and global eviction releasing the evicted key's slot.

New functional test test/functional/p2p_governance_orphan_vote.py covers the path end to end at the wire level: a P2P peer delivers a vote whose parent object the node lacks, the node responds with a tracker-driven getdata for the parent to that same peer (which never announced the object, so nothing else could request it), no MNGOVERNANCESYNC is sent, and serving the object replays the orphan vote. Against a pre-change dashd the test fails waiting for the parent getdata. Supporting framework work: govobj/govobjvote/govsync message classes (previously known-but-ignored types), and a fix for CGovernanceObject.serialize() in messages.py, which was broken and had never been usable; p2p_govsync_bloom.py now uses the framework msg_govsync.

Breaking Changes

None. No message format changes, no governance.dat format change (the field layout of develop's CGovernanceManager-Version-17 is kept), no consensus or P2P protocol change. Purely a change in what this node sends.

One behavioral note for reviewers, called out explicitly because it is a deliberate narrowing rather than a strict improvement. The old sweep re-asked every connected peer every 5 minutes for an orphan's full 10-minute life. The new path registers only peers that give us evidence they have the parent: the peer that relayed the vote, plus any that already announced that specific object hash. So the set of peers asked is driven by who actually relays to us rather than by who happens to be connected.

Every relay of a vote for a still-missing parent adds its sender as a candidate, including a relay of a vote we already hold, so fallbacks accumulate as the vote propagates rather than being fixed at the first sender. The tracker retries and moves to the next candidate on expiry. If every registered candidate fails, recovery for a synced node comes from ordinary flood relay — any peer that accepts the object announces it to us — not from governance sync, which only runs during initial sync; a restarting node is covered by that initial sync. The accepted trade is that the old persistence was the amplification: it cannot be kept without keeping the O(orphans x peers) term.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have made corresponding changes to the documentation
  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

@thepastaclaw

thepastaclaw commented Aug 2, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Final review complete — no blockers (commit 10abb8d)

@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: 45467b7ddc

ℹ️ 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
Comment thread src/net_processing.cpp Outdated
@coderabbitai

coderabbitai Bot commented Aug 2, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The change limits the governance orphan-vote cache to 1,000 entries and expires stale votes during cleanup. Legacy serialized orphan votes are discarded while the current cache limit is restored. Governance and InstantSend missing-object retrieval now use PeerAskPeersForObject. Peer selection prioritizes an explicit peer and limits initial requests to four candidates. Tests cover cache bounds, duplicate orphan votes, and legacy cache loading.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant GovernanceManager
  participant PeerManagerImpl
  participant SupplyingPeer
  GovernanceManager->>PeerManagerImpl: Request missing parent CInv
  PeerManagerImpl->>SupplyingPeer: Register preferred GETDATA request
  SupplyingPeer-->>PeerManagerImpl: Provide parent object
  PeerManagerImpl-->>GovernanceManager: Process parent object and orphan vote
Loading

Possibly related PRs

Suggested reviewers: udjinm6, thepastaclaw

Pre-merge checks · Passed 4 · Failed 1

Failed Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 37.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks (4 passed)
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Title check Passed The title clearly summarizes the primary change: orphan-vote parent fetching now uses the request tracker instead of broadcasting.
Description check Passed The description is directly related to the changeset and explains the request-tracker migration, orphan expiration, cache bounds, persistence behavior, and test coverage.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/net_processing.cpp`:
- Around line 2396-2446: Update AskPeersForObject’s candidate discovery to cover
non-transaction CInv types as well, since IsInvInFilter only reflects
transaction inventory knowledge. Track or otherwise consult peers’ known
non-transaction inventory (including entries populated by PushInv) when building
peersToAsk, while preserving prefer_first prioritization and the existing
request limits.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 637b22bb-7ce5-4c0d-adb2-77221a62b2f5

📥 Commits

Reviewing files that changed from the base of the PR and between c751ae4 and 45467b7.

📒 Files selected for processing (7)
  • src/governance/governance.cpp
  • src/governance/governance.h
  • src/governance/net_governance.cpp
  • src/instantsend/net_instantsend.cpp
  • src/net_processing.cpp
  • src/net_processing.h
  • src/test/governance_inv_tests.cpp

Comment thread src/net_processing.cpp Outdated
@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Went through all three automated findings. Two were real and are fixed in 7bf1403; the third was based on an incorrect premise, but pointed at a docstring that did overclaim.

1. Codex — orphan-cache limit lost after deserialization: correct, and the most important one

Confirmed and fixed. CacheMultiMap::SERIALIZE_METHODS does READWRITE(obj.nMaxSize, obj.listItems), so the capacity is part of the on-disk format. Setting it in the GovernanceStore constructor is undone by Unserialize, and because this PR deliberately leaves the format at Version-16, existing files still load. MAX_ORPHAN_VOTES would have applied to freshly-initialised nodes only — the case that needs it least — with no visible symptom. Clear() does not reset the capacity either, so it could not have saved us.

Fixed by reasserting the bound after reading, and dropping the orphans the file carried (they are a ten-minute recovery window that the restart has already invalidated). The field stays in the stream so the on-disk format is unchanged.

Added orphan_vote_bound_survives_loading_an_old_cache_file, which feeds a synthetic legacy file with a 1,000,000-capacity orphan map. Verified it catches the bug by reverting the fix:

check m_node.govman->GetOrphanVoteCount() == 0U has failed [1 != 0]
check m_node.govman->GetOrphanVoteCount() == MAX_ORPHAN_VOTES has failed [1025 != 1000]

Good catch — this would have shipped as a silent no-op on every upgraded node.

2. Codex — request-tracker limits on synthetic announcements: partly correct, fixed the valid part

Agreed on the accounting gap. AskPeersForObject called m_object_request.ReceivedInv directly, skipping the MAX_PEER_OBJECT_ANNOUNCEMENTS ceiling and the overload delay that AddObjectAnnouncement applies to peer-sent announcements. That was harmless while InstantSend was the only caller, but the governance orphan path lets a peer drive it. Both are now applied; the announcement stays preferred since we asked for it deliberately.

I did not implement the second half ("discard requests when their orphan is evicted"). Tracker entries already expire on their own via GetObjectInterval, and AlreadyHave() plus ForgetTxHash() clear them as soon as the object arrives from any source. Wiring orphan-cache eviction into net-layer request state would couple the two subsystems for a bounded amount of state that clears itself, which seems like the worse trade. Happy to revisit if you disagree.

3. CodeRabbit — IsInvInFilter and non-transaction inventory: premise is incorrect

The stated mechanism does not hold. The claim is that PushInv() only puts non-tx inventory into vInventoryOtherToSend, so the known filter never sees governance hashes. In fact m_tx_inventory_known_filter is populated for every inventory type:

  • AddKnownInv() inserts every received INV hash regardless of type, from the generic INV handler in ProcessMessage — it is not gated on MSG_TX.
  • The vInventoryOtherToSend loop in SendMessages inserts into that same filter as it sends.

GetInvRelay() and GetTxRelay() return the same m_tx_relay object; GetTxRelay() merely gates it on the m_can_tx_relay flag. So governance hashes are in the filter, and the filter is readable — for peers that enabled transaction relay.

That leaves a real but different and much smaller point: peers that did not enable transaction relay are skipped as candidates even though the filter holds the hash. I have deliberately not changed that. Block-relay-only connections exist to carry blocks and nothing else, and the 5-minute sweep this PR removes also skipped them (if (!pnode->CanRelay()) continue;), so excluding them preserves the prior intent rather than regressing it.

What was genuinely wrong was my docstring, which claimed candidates are "peers known to have the hash" without qualification. Reworded to state that the filter is only consulted for transaction-relaying peers, and that prefer_first may therefore be the only candidate — which is exactly why the governance caller passes it.


Full unit suite (794 cases), feature_governance.py, p2p_instantsend.py, and lint all pass on 7bf1403.


🤖 Posted autonomously by Claude on behalf of pasta.

@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: 7bf1403ae3

ℹ️ 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/net_governance.cpp
@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Correct, and this one is squarely a regression this PR introduced. Fixed in 44b9656.

governance::OrphanVote orders by the vote alone, so a second peer relaying a vote we already hold is a duplicate: CacheMultiMap::Insert returns false at its mapIt.count(value) > 0 guard, hashToRequest stayed null, and that peer never became a candidate for the parent.

The root of it is that the Insert() condition was written for a different mechanism. When the request was a direct PushMessage, gating on insert success avoided sending the same peer a redundant message, and it cost nothing anyway because the five-minute sweep asked every peer regardless. Routing requests through the object request tracker removed the reason for the gate — the tracker already dedups per peer, so a repeat call for an existing candidate is a no-op — while removing the sweep removed the thing that was quietly compensating for it. The condition survived both changes.

The request is for the parent object, not for the vote, so it is now issued on every relay while the parent is unknown. A peer relays a given vote once, which makes a duplicate relay the only evidence we will ever get that this particular peer has the parent; discarding it left nothing to fall back on once the first peer we asked went quiet.

Regression test orphan_vote_relayed_by_a_second_peer_adds_it_as_a_fallback covers it. Verified it bites by restoring the old condition:

check ... PeerConsumeObjectRequest(second_peer->GetId(), parent_inv) has failed

It also asserts the orphan cache still holds one entry, so the duplicate is not double-counted as orphan state.

I did not add a path that re-derives a parent hash from the cache for an already-known vote, which was your other suggested shape. Requesting unconditionally on the orphan path gets the same coverage without a cache lookup, and it keeps hashToRequest meaning "the parent this vote is waiting on" rather than "a new orphan was stored".

I have also reworded the behavioural caveat in the PR description, which previously said fallbacks accumulate only as further votes arrive — that was written against the buggy behaviour and understated things in a way this fix corrects.

Full unit suite (794 cases), feature_governance.py, and feature_governance_cl.py pass on 44b9656.


🤖 Posted autonomously by Claude on behalf of pasta.

@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: 44b965602a

ℹ️ 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/net_processing.cpp Outdated
PastaPastaPasta added a commit that referenced this pull request Aug 7, 2026
… an orphan governance vote

1e37fc4 fix: only accept the voting key for funding votes in the orphan-vote gate (pasta)
1a51025 fix: require a valid masternode signature before caching an orphan vote (pasta)

Pull request description:

  ## Issue being fixed or feature implemented

  `CGovernanceManager::ProcessVote()` caches votes whose parent governance object is not yet
  in `mapObjects` ("orphan votes") into `cmmapOrphanVotes`, keyed by the vote's `nParentHash`.
  Today the insert happens **before any masternode-membership or signature check**, and the
  exception raised carries a zero misbehaviour penalty. The only thing standing between a peer
  and that cache is the announce-then-request tracker in `src/governance/net_governance.cpp`
  (the peer has to INV the vote hash first). Nothing about the vote's contents is verified.

  So a peer can put arbitrary unvalidated attacker-chosen data into a node's governance cache
  and pay nothing for it, and the node will additionally emit an `MNGOVERNANCESYNC` request for
  the invented parent hash. Caching unverified peer data is the wrong default regardless of how
  much of it fits.

  Note also that the same garbage vote is scored differently depending on whether its parent
  object happens to have arrived: with a parent present, `CGovernanceObject::ProcessVote`
  rejects an unknown masternode with `GOVERNANCE_EXCEPTION_PERMANENT_ERROR` / penalty 20;
  without a parent, the identical vote is silently cached with penalty 0.

  ## What was done?

  In the orphan branch of `CGovernanceManager::ProcessVote`, require the vote to carry a valid
  signature from a masternode present in the tip list before it may enter `cmmapOrphanVotes`:

  ```cpp
  if (!vote.IsValidForUnknownParent(tip_mn_list)) {
      // GOVERNANCE_EXCEPTION_PERMANENT_ERROR, penalty 20
  }
  ```

  Notes on the specifics:

  * **The existing validator is called rather than re-implementing its checks inline.**
    `CGovernanceVote::IsValid` already performs the future-time check, the signal/outcome bounds
    checks, the `GetMNByCollateral` lookup and the signature verification. Duplicating those
    inline would guarantee they drift apart from the known-object path over time.
  * **Key selection is signal-aware** (`CGovernanceVote::IsValidForUnknownParent`). Which key is
    correct depends on the parent object's type and the vote signal (`onlyVotingKeyAllowed` in
    `CGovernanceObject::ProcessVote`): only `PROPOSAL` + `VOTE_SIGNAL_FUNDING` may ever use the
    voting key; every other signal requires the operator BLS key for every object type. So for a
    funding vote — whose parent type is by definition unknown on this path — either key is
    accepted, while all other signals are checked against the operator key only. This matters
    because the voting key is the lower-trust credential (routinely delegated to third-party
    voting services): without the signal check, a voting-key holder could cache non-funding votes
    that can never validate once their parent arrives. A funding vote on a non-proposal object
    still gets re-checked against the operator-key requirement at replay time.
  * **Penalty 20 / `GOVERNANCE_EXCEPTION_PERMANENT_ERROR`** matches exactly what
    `CGovernanceObject::ProcessVote` already applies for an unknown masternode or a failed
    `IsValid` on the known-object path, so the same bad vote now costs the sender the same
    either way.
  * **The orphan branch itself stays at penalty 0.** Once the gate passes, reaching that branch
    means the vote is signed by a masternode and the only reason it cannot be applied is that
    its parent has not arrived — a benign relay race that happens routinely during governance
    sync. Misbehaviour scores never decay, so scoring there would eventually disconnect honest
    relays.
  * **Gate rejections are deliberately not inserted into `cmapInvalidVotes`.** That would make
    replays cheaper to reject, but `cmapInvalidVotes` is sized `MAX_CACHE_SIZE = 1'000'000` and
    caching gate rejections would create a *new* unauthenticated path for filling it with
    attacker-chosen entries — i.e. exactly the class of problem this change is meant to reduce.
  * `m_dmnman.GetListAtChainTip()` is hoisted to the top of `ProcessVote` so both the orphan gate
    and the known-object path share a single call; previously it was fetched inline at the
    `govobj.ProcessVote` call site.

  **On verifying signatures under `cs_store`:** this is not a new class of work under that lock.
  The known-object path already does exactly this — `CGovernanceManager::ProcessVote` holds
  `cs_store` across `govobj.ProcessVote(...)`, which calls `vote.IsValid(...)` at
  `src/governance/object.cpp:458`. This change applies the established pattern to the orphan
  branch. It does add up to two verifications for a vote that fails both, but only on the orphan
  path and only for peers that already passed the announce-then-request gate.

  ### What this does and does not fix

  This is a validation change. It does **not** close the underlying resource-exhaustion issue on
  `cmmapOrphanVotes`, for four reasons worth stating plainly:

  1. **A valid masternode signature is not scarce.** `nParentHash` *is* covered by the signature
     (see `GetSignatureString()` and the `SER_GETHASH` serialization in `src/governance/vote.h`),
     but nothing ties the signed parent hash to an object that actually exists. Any one of the
     ~4000 masternode keys can sign an unbounded number of votes naming invented parent hashes,
     and each one lands in a distinct cache slot.
  2. **That path is penalty-0 by design** (see above), so a flood of well-signed orphan votes is
     unscored on purpose.
  3. **Misbehaviour scoring is suppressed while `!IsSynced()`** — see the `m_node_sync.IsSynced()`
     condition guarding `PeerMisbehaving` in `net_governance.cpp` — which is precisely the window
     in which orphan votes are most common.
  4. **Per-masternode vote rate limiting is unreachable here.** `GOVERNANCE_UPDATE_MIN` is
     enforced inside `CGovernanceObject::ProcessVote`, i.e. after the parent lookup, and it is
     explicitly disabled on replay (`ScopedLockBool guard(cs_store, fRateChecksEnabled, false)`
     in `CheckOrphanVotes`).

  What it does buy: the cost of entry into the orphan cache goes from *free for any
  unauthenticated peer* to *requires a masternode key*, and garbage votes that previously
  vanished into the cache unscored are now scoreable — consistently with the known-object path.
  That is correct hygiene, but the bound on the data structure is what actually caps the damage.
  Bounding/expiring the cache is complementary work and is being handled separately in #7517 and
  #7526; this PR is intentionally independent of both and will conflict with them textually.

  One known side effect is deliberately left out of scope here.
  `CGovernanceVote::CheckSignature(const CBLSPublicKey&)` logs its failure with an unconditional
  `LogPrintf`, unlike its `CKeyID` sibling and unlike the rest of `IsValid`, which use
  `LogPrint(BCLog::GOBJECT, ...)`. Reaching it previously required a vote naming a governance object
  we actually have; after the gate, a vote naming an invented parent hash reaches it too, so a peer
  holding a real masternode outpoint (public data) plus a garbage signature can write a line to
  debug.log per message without `-debug` being set. Putting that log behind the `gobject` category
  is a one-word fix but touches an unrelated file, so it is not bundled here.

  ## How Has This Been Tested?

  Built with `--enable-debug --enable-suppress-external-warnings --without-gui` on
  aarch64-apple-darwin (clang).

  New unit tests in `src/test/governance_inv_tests.cpp`:

  * `orphan_votes_require_a_valid_masternode_signature` — a vote naming an outpoint that is not in
    the tip masternode list, delivered by a peer that legitimately announced it, does not enter
    the orphan cache (`GetOrphanVoteObjectHashes()` stays empty), triggers no `MNGOVERNANCESYNC`
    request for the invented parent, and scores the sender 20.
  * `invalid_vote_is_scored_alike_with_and_without_a_parent_object` — the same unauthenticated
    vote costs 20 whether or not its parent object is present, i.e. the orphan gate and
    `CGovernanceObject::ProcessVote` agree.

  Two existing tests were updated. `governance_votes_require_peer_announcement_or_request` and
  `governance_vote_authorization_survives_unsynced_drop` previously used "an `MNGOVERNANCESYNC`
  was emitted" as the observable proving that a vote reached `ProcessVote`; the votes they build
  carry a placeholder signature, so under this change they no longer reach the orphan branch and
  no such message is sent. They now use the misbehaviour score as the observable instead: a peer
  that passes the announce-then-request gate reaches `ProcessVote` and is scored 20, while a peer
  that fails the gate returns before `ProcessVote` and stays at 0. That is a stricter test of the
  authorization gate than the old one — it distinguishes "reached `ProcessVote`" from "did not"
  rather than relying on an incidental side effect. Both now advance `mn_sync` to
  `MASTERNODE_SYNC_FINISHED`, since penalties are only applied once `IsSynced()`.

  Coverage limit, stated plainly: `GovernanceInvSetup` is a `TestingSetup{MAIN}` fixture with no
  chain and therefore an empty deterministic masternode list, so `CGovernanceVote::IsValid`
  short-circuits on the `GetMNByCollateral` lookup before reaching `CheckSignature`. These tests
  therefore prove that the gate exists, runs on the orphan path, rejects a vote no masternode
  could have authored, and scores it identically to the known-object path — but they do not
  exercise `CheckSignature` itself, in either direction. Covering that (a registered masternode
  with a forged signature rejected, and one with a valid signature still accepted into the orphan
  cache) needs a chain-backed fixture with a real ProRegTx, which would mean rebuilding this
  fixture on `TestChainSetup` and is deliberately not attempted here. The positive path is
  covered end-to-end by `feature_governance.py`, which votes with real masternodes.

  The new assertions were verified to fail against unmodified code: with the change to
  `governance.cpp` reverted and the tests kept, the suite reports 7 failures, including
  `check m_node.govman->GetOrphanVoteObjectHashes().empty() has failed` and
  `check CountQueuedMessages(*peer, NetMsgType::MNGOVERNANCESYNC) == 0U has failed [1 != 0]`.

  Ran:

  * `./src/test/test_dash --run_test=governance_inv_tests` — passes (6 cases)
  * `./src/test/test_dash` — passes (794 cases)
  * `test/functional/test_runner.py feature_governance.py feature_governance_cl.py` — passes
  * `test/lint/lint-whitespace.py`, `test/lint/lint-circular-dependencies.py` — clean

  ## Breaking Changes

  None to consensus, RPC or the P2P wire format. Behavioural change on the P2P vote path: a
  governance vote whose parent object is unknown is now dropped instead of cached unless it
  carries a valid masternode signature, and a peer that sends such a vote is assigned a
  misbehaviour score of 20 (only while fully synced). A node that legitimately relays orphan
  votes ahead of their parent objects is unaffected, since those votes are validly signed.

  ## Checklist:
  - [x] I have performed a self-review of my own code
  - [x] I have commented my code, particularly in hard-to-understand areas
  - [x] I have added or updated relevant unit/integration/functional/e2e tests
  - [ ] I have made corresponding changes to the documentation
  - [ ] I have assigned this pull request to a milestone _(for repository code-owners and collaborators only)_

Top commit has no ACKs.

Tree-SHA512: 6f6a2ad05c896e3774c8ca15985cae3d43481565053f3aacf43336a0a4402e171bd8ca16e73b010c5b2d14f81dd6c00e1da8cbd9223d45a6ca6a2b8f53ac5e08
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/governance/governance.cpp (1)

1112-1125: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add scheduled orphan-expiry coverage.

ExpireOrphanVotes() moves expiry into CheckAndRemove(). Add a test that retains an unexpired orphan and removes an expired orphan after cleanup runs.

As per coding guidelines, “Add small tests proving invariants when changing … timing/shutdown behavior.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/governance/governance.cpp` around lines 1112 - 1125, Add
scheduled-cleanup test coverage for CGovernanceManager::ExpireOrphanVotes via
CheckAndRemove(): create both an unexpired and an expired orphan vote, run
cleanup, and assert the unexpired vote remains while the expired vote is
removed.

Source: Coding guidelines

src/test/governance_vote_processing_tests.cpp (1)

247-250: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Index votes that succeed during orphan replay.

Line 247 records a known failure as expected behavior. CheckOrphanVotes() relays a successfully replayed vote without inserting its hash into cmapVoteToObject. Peers that receive the relayed inventory cannot retrieve that vote through the normal lookup path.

Insert the replayed vote into cmapVoteToObject, then change this assertion to require HaveVoteForHash(vote.GetHash()).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/governance_vote_processing_tests.cpp` around lines 247 - 250, Update
CheckOrphanVotes() to insert each successfully replayed vote into
cmapVoteToObject before relaying it, then change the governance vote processing
test assertion to require HaveVoteForHash(vote.GetHash()) to return true.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/governance/governance.cpp`:
- Around line 1112-1125: Add scheduled-cleanup test coverage for
CGovernanceManager::ExpireOrphanVotes via CheckAndRemove(): create both an
unexpired and an expired orphan vote, run cleanup, and assert the unexpired vote
remains while the expired vote is removed.

In `@src/test/governance_vote_processing_tests.cpp`:
- Around line 247-250: Update CheckOrphanVotes() to insert each successfully
replayed vote into cmapVoteToObject before relaying it, then change the
governance vote processing test assertion to require
HaveVoteForHash(vote.GetHash()) to return true.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6580af66-e660-4c7b-9bdd-fef61cdf1502

📥 Commits

Reviewing files that changed from the base of the PR and between 7bf1403 and efa5c3e.

📒 Files selected for processing (7)
  • 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
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/net_processing.h
  • src/governance/governance.h
  • src/net_processing.cpp
  • src/governance/net_governance.cpp

@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: efa5c3e626

ℹ️ 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/net_processing.cpp Outdated
@PastaPastaPasta
PastaPastaPasta force-pushed the sec/u006-tracker branch 2 times, most recently from 5c47901 to 097ec83 Compare August 9, 2026 16:33

@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: 097ec831f9

ℹ️ 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/net_processing.cpp Outdated

@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.

Preliminary review — Codex only

The orphan-cache bound and removal of the periodic broadcast address the primary resource-exhaustion paths, but the new routing helper does not actually prioritize the explicitly named peer: all newly registered candidates receive equal tracker preference and randomized selection decides who is queried first. The commit stack also contains a guaranteed failing test commit and should be rewritten so each introducing commit is complete and bisectable; focused routing coverage is still needed.
Source: reviewer backends: gpt-5.6-sol (Codex general), gpt-5.6-sol (Codex dash-core-commit-history); final verifier backend: gpt-5.6-sol. Orchestration-only, not reviewer evidence: openclaw-agent/cliproxy/gpt-5.6-sol.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking | 🟡 2 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/net_processing.cpp`:
- [BLOCKING] src/net_processing.cpp:2422-2425: prefer_first is not actually selected first
  `try_ask_peer()` registers both `prefer_first` and newly synthesized fallback candidates with `preferred=true` and the same request time. `TxRequestTracker` does not honor insertion order within that preference class; it selects the candidate with the highest salted priority. A fallback can therefore receive the first GETDATA even though the named vote relayer is the candidate for which there is direct evidence and the caller explicitly says to ask it first. If that fallback is stale or malicious, fetching the governance parent can be delayed until the request expires. Give the named candidate a strictly stronger tracker position, or keep fallback candidates ineligible until the named candidate fails; also cover the multi-candidate case with a focused test.
- [SUGGESTION] src/net_processing.cpp:2399-2453: Add focused coverage for the new object-fetch routing
  No test invokes `PeerAskPeersForObject` or drives a valid orphan vote through `NetGovernance` and observes request-tracker routing. The duplicate-relay test calls `CGovernanceManager::ProcessVote()` directly and verifies only the returned parent hash, so it cannot prove that the supplying peer becomes a candidate, an unrelated bystander is excluded, a second relayer is retained as a fallback, or `prefer_first` wins when another candidate exists. Add an end-to-end multi-peer test that checks those observable tracker or GETDATA outcomes; the multi-candidate case would also catch the priority defect above.

In `src/test/governance_vote_processing_tests.cpp`:
- [BLOCKING] src/test/governance_vote_processing_tests.cpp:406-424: Keep the duplicate-relay fix and its regression test in one commit
  Commit `a40423d9fff` adds this test and requires the second `ProcessVote()` call to return `parent_hash`, but production code in that same commit still assigns `hashToRequest` only when `cmmapOrphanVotes.Insert()` succeeds. The second insertion is a duplicate and returns false, so this test necessarily fails until the subsequent commit `774911bc018`. Rewrite the stack so the every-relay behavior and its regression test are introduced together in `155ddd91766`, then remove the standalone corrective commit. The rewritten commit message should also stop claiming the operation happens "at most 4 requests, once," because the final behavior registers a candidate on every relay.

In `<commit:a40423d>`:
- [SUGGESTION] <commit:a40423d>:1: Split and fold the bundled review corrections into their source commits
  Commit `a40423d9fff` combines independent corrections to both preceding commits: restoring the orphan-cache bound after deserialization completes the governance cache change from `155ddd91766`, while request accounting, locking, and API-contract changes revise the helper introduced by `1f66b5e182c`. These implementations have not shipped, so retaining their review corrections as a mixed follow-up obscures the logical history. Split the commit and fold the governance load-bound change and test into `155ddd91766`, and fold the helper corrections into `1f66b5e182c` or the first commit that relies on the peer-driven governance caller.

Comment thread src/net_processing.cpp Outdated
Comment thread src/test/governance_vote_processing_tests.cpp Outdated
Comment thread src/net_processing.cpp Outdated

@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: 977efd95c1

ℹ️ 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.h Outdated

@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.

Preliminary review — Codex only

The current head addresses the live orphan-cache bounds and request-routing defects, but commit 155ddd91766 remains syntactically invalid because a brace repair is deferred to a later commit. Focused multi-peer routing coverage is also absent, and independent review corrections remain bundled rather than folded into their introducing commits.
Source: reviewer backends: gpt-5.6-sol (Codex general), gpt-5.6-sol (Codex dash-core-commit-history), gpt-5.6-sol (Codex backport-reviewer); final verifier backend: gpt-5.6-sol. Orchestration-only, not reviewer evidence: openclaw-agent/cliproxy/gpt-5.6-sol.

Validated blockers were found in the Codex precheck. Opus is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed), gpt-5.6-sol — backport-reviewer (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 1 suggestion(s)

2 additional finding(s) omitted (not in diff).

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/test/governance_inv_tests.cpp`:
- [BLOCKING] src/test/governance_inv_tests.cpp:552: Fold the unmatched-brace repair into the introducing commit
  Commit `155ddd91766` contains an unmatched closing brace immediately before `BOOST_AUTO_TEST_SUITE_END()`: the file has 101 opening braces and 102 closing braces at that revision. Commit `977efd95c15` removes the stray brace, so the primary governance change is not independently compilable and creates a direct `git bisect` failure. Fold the brace removal into `155ddd91766` so every commit in the permanent stack builds.

In `<commit:977efd9>`:
- [SUGGESTION] <commit:977efd9>:1: Split and fold the bundled review corrections into their source commits
  Commit `977efd95c15` still combines independent corrections to the unshipped implementation: legacy orphan-cache deserialization and duplicate-relay behavior complete `155ddd91766`, while synthetic-announcement accounting and candidate scanning revise the helper introduced by `1f66b5e182c`. Later commits continue repairing those same changes: `39553b8396f` replaces the inaccurate ordering contract, `2435a2b8942` handles deserialization failures, and `e1b4b73370f` corrects the helper's lock scope. Fold the final helper API, accounting, naming, candidate scanning, and lock scope into its introducing commit, and fold all orphan-bound, deserialization, duplicate-relay behavior, tests, and the brace repair into `155ddd91766`. Reword the latter commit's stale claim that fetching occurs "at most 4 requests, once," because later relays can register additional candidates.

In `src/net_processing.cpp`:
- [SUGGESTION] src/net_processing.cpp:2398-2458: Add focused coverage for the new object-fetch routing
  (existing thread: https://github.com/dashpay/dash/pull/7526#discussion_r3744602094)
  No test invokes `PeerAskPeersForObject()` or drives a valid orphan vote through `NetGovernance` while observing the resulting request-tracker or GETDATA behavior. The duplicate-relay test calls `CGovernanceManager::ProcessVote()` directly and checks only the returned parent hash, so it would still pass if the network layer failed to register the relaying peer, selected unrelated candidates, lost the second relayer as a fallback, or mishandled capped and disconnected candidates. Add a focused multi-peer test that verifies the explicit sender and subsequent relayers become request candidates while an unrelated peer does not.

@thepastaclaw thepastaclaw removed the pastaclaw:commented thepastaclaw's latest review was comment-only label Oct 5, 2026
@github-actions github-actions Bot mentioned this pull request Oct 6, 2026
8 tasks

@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.

Re-review — Final validation — Phase 1 + Phase 2

Independently reviewed the complete diff at c2896e2 and verified all four prior findings are fixed; no in-scope runtime defects were identified. One non-blocking commit-history suggestion remains, and a concrete pre-existing vote-ordering defect is retained only for separate follow-up. Validation was static: git diff --check passed, and the supplied exact-head CI snapshot reports successful source builds, test lanes, and lint checks, with some jobs skipped and one advisory conflict-prediction job cancelled.

🟡 1 suggestion(s)

1 finding(s) not shown inline (the lines are not part of this PR's diff)

🟡 Suggestion: Fold the orphan-bound correction into its introducing commit
<commit:d72c34b857>:1

Commit d72c34b removes the unnecessary SetMaxSize call introduced by 6c198ef, corrects its capacity explanation, and replaces its two expensive legacy-load tests with streamlined coverage. These changes correct the implementation introduced earlier in this same stack; keeping both commits also leaves 6c198ef's message claiming that runtime bounds must be reasserted after deserialization failures, although its reader already discards the serialized capacity into a local cache. Fold d72c34b into 6c198ef, retain the simplified tests and shared helper, and update the introducing message to explain that the live capacity remains fixed at construction. The other substantive commits can remain separate.

source: gpt-6.1-sol (phase2-reviewer: dash-core-commit-history)

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); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 5: 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 changes are substantial and cross-cutting but primarily alter orphan-cache bounds and request scheduling, preserving the disk format rather than introducing a storage migration and not changing consensus, funds handling, cryptography, or peer-facing network deserialization.
  • 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 15% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • 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, 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
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `<commit:d72c34b857>`:
- [SUGGESTION] <commit:d72c34b857>:1: Fold the orphan-bound correction into its introducing commit
  Commit d72c34b857 removes the unnecessary SetMaxSize call introduced by 6c198ef9fc, corrects its capacity explanation, and replaces its two expensive legacy-load tests with streamlined coverage. These changes correct the implementation introduced earlier in this same stack; keeping both commits also leaves 6c198ef9fc's message claiming that runtime bounds must be reasserted after deserialization failures, although its reader already discards the serialized capacity into a local cache. Fold d72c34b857 into 6c198ef9fc, retain the simplified tests and shared helper, and update the introducing message to explain that the live capacity remains fixed at construction. The other substantive commits can remain separate.
Out-of-scope follow-up suggestions (1)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Governance vote comparator treats distinct orphan votes as equivalent — CGovernanceVote::operator< in src/governance/vote.cpp:263–291 always returns false: if the initial outpoint less-than check succeeds, the subsequent equality assignment is necessarily false, causing the next check to return false. CacheMultiMap consequently treats distinct votes under the same parent as equivalent and retains only the first orphan for that parent. The identical comparator and inner-map indexing exist at the review base, so this vote-loss defect is not introduced by this PR.
    • Follow-up: Track a separate correctness fix implementing lexicographic vote ordering, with regression coverage proving that distinct masternodes' orphan votes for the same parent are retained and replayed.

@github-actions

Copy link
Copy Markdown

This pull request has conflicts, please rebase.

PastaPastaPasta and others added 5 commits October 13, 2026 12:57
The orphan-parent fetch helper was transaction-specific only in its CInv
construction. Accept a CInv so other subsystems can use the object request
tracker for objects they want but were never offered.

Allow callers to name an explicit peer that demonstrably holds the object
without having announced it. Register that peer together with the
inventory-filter candidates, and leave request order and fallback scheduling
to the tracker.

Route the synthetic announcements through AddObjectAnnouncement, retyped to
take a NodeId, report whether the announcement was admitted, and accept a
force_preferred flag, so they get the same per-peer announcement cap and
in-flight backoff as announcements a peer sends. Every matching peer is
snapshotted under m_peer_mutex, which is released before cs_main is taken;
the MAX_PEERS_TO_ASK_FOR_OBJECT budget is then spent only on candidates that
AddObjectAnnouncement admits, so disconnected or capped peers do not use it
up.

Demote the per-peer log line to BCLog::NET, since callers may now invoke this
on a peer-driven path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…han cache

NetGovernance::Schedule sent one MNGOVERNANCESYNC per orphan parent hash per
connected peer every five minutes for as long as the orphan remained. Orphan
keys are peer-controlled, so this created repeated O(orphan parents x peers)
outbound work outside the request tracker's limits.

Seed the object request tracker directly from each peer that relays a valid
orphan vote. Return the missing parent for duplicate relays as well, so later
relayers become fallback candidates. The tracker owns GETDATA scheduling,
in-flight limits, expiry and fallback.

Move orphan expiry, which lived inside the deleted sweep, into CheckAndRemove,
and cap the cache at MAX_ORPHAN_VOTES. Keep the governance.dat layout by
consuming the orphan field into a local and discarding it: neither the stored
orphans nor the capacity CacheMultiMap serializes with them reach the live
cache, whose capacity stays fixed at construction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Exercise the full network path the unit tests stop short of: a P2P peer
delivers a governance vote whose parent object the node does not have, and
the node must respond with a tracker-driven getdata for the parent to that
same peer -- the peer never announced the object, so no other mechanism can
request it. Serving the object then replays the orphan vote. The old
MNGOVERNANCESYNC recovery broadcast must not be sent; against a pre-change
dashd this test fails waiting for the parent getdata.

The test framework previously could not speak governance messages:
govobj/govobjvote/govsync were known-but-ignored message types, and
CGovernanceObject.serialize() was broken (wrong attribute name, deser_* used
for writing, list defaults for byte fields). Fix the serializer and register
proper message classes, and have p2p_govsync_bloom.py use the framework
msg_govsync instead of its own send-only copy.

The collateral is validated through node1's txindex when the object arrives,
and a txindex miss is a hard reject with no retry. The test therefore drains
node1's validation queue after feeding it the blocks, mirroring the guard on
node0, so the object cannot race the async index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The orphan cache was one global 1000-entry pool with oldest-first eviction
and no per-source accounting, so a single registered voting key could mint
valid votes naming invented parents and flush every honest orphan on demand
-- and deliberately without penalty, since valid-MN-signed votes for unknown
parents are routine during sync. Wrap the cache in
governance::OrphanVoteCache, which enforces two bounds together: a
per-masternode share (500) so one key can no longer displace everyone else,
and a global cap raised to 100'000 (~75 MB worst case, since each entry is
stored twice) now that filling it takes 200 distinct masternode keys. A vote
over its key's share is dropped without seeding a parent request; duplicates
keep seeding them, as before. The wrapper owns the eviction accounting: the
inner map's capacity sits one above the global cap so its own pruning can
never bypass the per-masternode counts.

The wrapper has no single-key erase, so drop the
cmmapOrphanVotes.Erase(voteHash) in RemoveInvalidVotes. The map is keyed by
parent hash, so that erase never matched anything. Orphans signed with a
since-replaced key are left to expire: replay re-verifies every vote against
the tip masternode list, and within ten minutes they are gone, so they can
only briefly occupy their own masternode's share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unserialize consumes the orphan-vote field and discards it, yet Serialize
still wrote the whole live cache into it -- up to MAX_ORPHAN_VOTES entries
serialized, checksummed and written at shutdown, then read and indexed at
startup only to be thrown away. Write the field empty instead and drop the
OrphanVoteCache::Store() accessor that only existed to feed it.

The field order and format are unchanged. Builds that predate this read the
field, capacity included, directly into their live cache, so the empty map
carries the 1'000'000 capacity they always wrote; a CacheMultiMap with
capacity 0 never prunes. Writing the live cache had also handed them its
inner capacity (MAX_ORPHAN_VOTES + 1) instead.

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

chatgpt-codex-connector Bot commented Oct 13, 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-13T18:34:03.506936Z 10abb8d 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.

@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Rebased onto develop c8bfd7a and simplified. New head: 10abb8d.

Rebase. Develop's #7831 moved governance.dat to Version-17 and dropped the invalid-vote field. The orphan field keeps its place in that layout: it is written empty and discarded on load, so no further format change is needed. The tests that built Version-16 streams now build Version-17 ones.

History. Squashed from 9 commits to 5, each of which builds and passes the governance/net unit suites:

  • The admission-check refactor is folded into the AskPeersForObject generalization.
  • The reassert removal and the expiry test are folded into the fix commit. Its message now says the live capacity is fixed at construction, which was thepastaclaw's remaining history suggestion.
  • The msg_govsync cleanup is folded into the functional-test commit.

Simplifications:

  • Dropped the orphan revalidation on key change: EraseForMasternodeIf, its unit test, the ProUpRegTx test with its -dip3params=109:110 fixture change, and UpdateRegistrar. Replay already re-verifies each orphan against the tip list, and orphans expire within ten minutes. Re-verifying up to 500 orphans per changed masternode under cs_main therefore bought nothing. The mis-keyed erase it replaced is simply removed.
  • Removed orphan_vote_relayed_by_a_second_peer_adds_it_as_a_fallback. orphan_vote_relayers_seed_parent_request_candidates covers the same regression through NetGovernance, and I checked that it fails when duplicates stop returning the parent hash.
  • Removed the truncated-load half of the legacy-load test (it could not fail) and the round-trip half of the empty-field test (develop's legacy_governance_cache_is_discarded already covers it).

Net diff: +699/-153, down from +883/-158.

Tested at the tip:

  • Unit: governance_*, orphan_vote_cache_tests, net_tests, denialofservice_tests, txrequest_tests, peerman_tests.
  • Functional: p2p_governance_orphan_vote.py, p2p_govsync_bloom.py, feature_governance.py --descriptors, feature_governance_cl.py --descriptors, feature_governance_objects.py, feature_governance_txindex_devnet.py, p2p_instantsend.py.

The PR description is updated to match.


🤖 Posted autonomously by Claude on behalf of pasta.

@thepastaclaw thepastaclaw removed the pastaclaw:commented thepastaclaw's latest review was comment-only label Oct 13, 2026

@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.

Re-review — Final validation — Phase 1 + Phase 2

The bounded orphan cache, request-tracker routing, and legacy serialization changes are coherent at this head. One scheduling gate still prevents orphan expiry during the interval when blockchain synchronization is complete but full masternode synchronization is not, so the PR has one in-scope suggestion.

🟡 1 suggestion(s)

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); reviewer 4: gpt-6.1-sol (agent: phase2-reviewer, role: general); reviewer 5: 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) — This is a substantial cross-cutting change to peer-facing governance networking, request tracking, caching, and functional tests, but it does not alter consensus rules, cryptography, funds, 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 final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • 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, 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
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/governance/net_governance.cpp`:
- [SUGGESTION] src/governance/net_governance.cpp:46-47: Run orphan expiry while blockchain sync is complete
  The scheduler returns unless `IsSynced()` is true, but `CheckAndRemove()` and `ProcessMessage()` deliberately use the broader `IsBlockchainSynced()` gate. During the interval where blockchain synchronization has completed but masternode/governance synchronization is still in progress, orphan votes can be accepted while `ExpireOrphanVotes()` is not called every five minutes. They may therefore remain beyond their ten-minute lifetime and continue consuming the per-masternode and global cache bounds. Use the same blockchain-sync gate as `CheckAndRemove()` so the scheduled expiry path matches the behavior described by the PR.

Comment on lines 46 to 47
if (!m_node_sync.IsSynced()) return;

@thepastaclaw thepastaclaw Oct 13, 2026 •

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.

⏭️ Deferred (maintainer decision; not blocking) at 10abb8d3; see the replies below.

🟡 Suggestion: Run orphan expiry while blockchain sync is complete

The scheduler returns unless IsSynced() is true, but CheckAndRemove() and ProcessMessage() deliberately use the broader IsBlockchainSynced() gate. During the interval where blockchain synchronization has completed but masternode/governance synchronization is still in progress, orphan votes can be accepted while ExpireOrphanVotes() is not called every five minutes. They may therefore remain beyond their ten-minute lifetime and continue consuming the per-masternode and global cache bounds. Use the same blockchain-sync gate as CheckAndRemove() so the scheduled expiry path matches the behavior described by the PR.

Suggested change
if (!m_node_sync.IsSynced()) return;
if (!m_node_sync.IsBlockchainSynced()) return;

source: gpt-6.1-sol (phase2-reviewer: general, dash-core-commit-history)

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.

Leaving the gate as is. On develop the 5-minute task already returns unless IsSynced(), and orphan expiry already ran under that gate, inside GetOrphanVoteObjectHashes() in the same task. So during the "blockchain synced, governance still syncing" window, expiry is no more delayed than it was before this PR.

Loosening it to IsBlockchainSynced() would also start running all of CheckAndRemove() (object reprocessing, trigger cleanup, vote cleanup) while governance sync is still in progress. That changes existing behaviour beyond this fix. In that window the orphan cache is still bounded (500 per masternode, 100'000 total), and expiry resumes on the first pass after full sync.


🤖 Posted autonomously by Claude on behalf of pasta.

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.

Agreed that this is pre-existing behavior: the IsSynced() gate and the resulting expiry timing were already present before this change. I accept keeping that gate here to avoid broadening CheckAndRemove() into the governance-sync window; this can be addressed separately if the wider sync-state policy changes.

Noted as intentionally deferred; I will not press it further here.

@thepastaclaw thepastaclaw added the pastaclaw:commented thepastaclaw's latest review was comment-only label Oct 13, 2026

@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.

Re-review after discussion — commit 10abb8d

Standing review was COMMENTED; this re-review is APPROVE: no blocking findings remain.

Approved: the discussion withdrew or deferred every finding the full review of 10abb8d3 (run 4871) raised, and the approval rests on that review.

Same commit as the standing review; the inline threads above carry the per-finding outcome. This follow-up exists only to correct the verdict.

Review provenance

Source: conversation lane gpt-6.1-sol (agent: conversation); no reviewer or verifier lanes ran for this follow-up

  • Verdict moved because the inline discussion withdrew or deferred findings of the standing review of this commit; the code was not re-reviewed

@thepastaclaw thepastaclaw added pastaclaw:approved thepastaclaw's latest review approved this PR and removed pastaclaw:commented thepastaclaw's latest review was comment-only labels Oct 13, 2026
@PastaPastaPasta

Copy link
Copy Markdown
Member Author

Closing this in favor of a rebuild from scratch. The design stays the same (fetch orphan-vote parents through the object request tracker and bound the orphan cache per masternode), but the commit structure, the scope and the tests will be redone. A new PR will reference this one.


🤖 Posted autonomously by Claude on behalf of pasta.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants