Skip to content

fix: memoize governance vote signature checks to bound govsync cost - #7518

Open
PastaPastaPasta wants to merge 8 commits into
dashpay:developfrom
PastaPastaPasta:sec/u007
Open

fix: memoize governance vote signature checks to bound govsync cost#7518
PastaPastaPasta wants to merge 8 commits into
dashpay:developfrom
PastaPastaPasta:sec/u007

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

CGovernanceManager::GetSyncableVoteInvs() walks every stored vote for a requested object and calls CGovernanceVote::IsValid(), which unconditionally re-runs full cryptographic verification via CheckSignature() - ECDSA recovery for proposal funding votes, or a BLS pairing (~1 ms) for operator-key votes. Those signatures were already verified at acceptance in CGovernanceObject::ProcessVote, so the work is pure waste.

The walk is driven by the P2P MNGOVERNANCESYNC handler and runs on the single message-handler thread while holding cs_store and govobj.cs. On a mainnet trigger with thousands of masternode votes this is seconds of pairing work per request, stalling all P2P message processing.

Any unauthenticated peer can trigger it once the victim is synced. The existing CNetFulfilledRequestManager quota does not help: it is keyed on peer.addr, a CService that includes the ephemeral source port, and it is per-object, so it is bypassed by reconnecting or by cycling through other known objects.

What was done?

  • Add a signature-verification memo to CGovernanceVote, keyed on the verification key, GetSignatureHash() and the signature bytes. GetSignatureHash() is a SerializeHash over exactly the fields that GetSignatureString() signs, so the memo cannot be bypassed by mutating a covered field, and key-type confusion is not possible.
  • Add CGovernanceObjectVoteFile::GetVoteList() so GetSyncableVoteInvs iterates live votes instead of copies.

On the reverted commit: an intermediate commit in this branch throttled govsync by IP and is deliberately reverted by the last commit. Keying the throttle on IP alone, combined with a misbehaviour score, would let one attacker get NAT-colocated honest peers discouraged. That trade was not worth it, and the history is kept so the reasoning is visible rather than looking like an oversight.

Known remaining gap: the residual per-request cost is still O(votes) under cs_store - a SerializeHash, a bloom contains and a masternode lookup per vote - with no working rate limit, and a cold cache after restart pays full price once. A proper request-rate or work budget on MNGOVERNANCESYNC, keyed per CNode rather than per CService, is still needed and is not attempted here.

How Has This Been Tested?

The first commit adds a regression test proving the govsync fulfilled-request throttle is port-keyed, ordered before the fix.

Full build and test validation is delegated to CI on this PR; the changes were not built locally.

Breaking Changes

None.

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

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@PastaPastaPasta, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0a916f8e-4a36-4f2e-b3dc-4df154bfef07

📥 Commits

Reviewing files that changed from the base of the PR and between 7f40ff7 and 23e8e5f.

📒 Files selected for processing (6)
  • src/Makefile.test.include
  • src/governance/governance.cpp
  • src/governance/vote.cpp
  • src/governance/vote.h
  • src/governance/votedb.h
  • src/test/governance_vote_sync_tests.cpp

Walkthrough

Governance votes now memoize ECDSA and BLS signature-verification results using vote content and verification-key data. Vote synchronization iterates stored votes without copying them. New tests cover request quotas, peer-port isolation, repeated checks, cache invalidation, key changes, and separate ECDSA and BLS cache entries.

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

Sequence Diagram(s)

sequenceDiagram
  participant GetSyncableVoteInvs
  participant CGovernanceObjectVoteFile
  participant CGovernanceVote
  GetSyncableVoteInvs->>CGovernanceObjectVoteFile: iterate stored votes by const reference
  CGovernanceObjectVoteFile-->>GetSyncableVoteInvs: provide stored vote
  GetSyncableVoteInvs->>CGovernanceVote: check signature
  CGovernanceVote-->>GetSyncableVoteInvs: return cached or computed verdict
Loading

Suggested reviewers: knst

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: memoizing governance vote signature checks to reduce synchronization cost.
Description check ✅ Passed The description explains the performance issue, implemented memoization, iteration changes, testing, and remaining limitations.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
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.
✨ 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.

@thepastaclaw

thepastaclaw commented Aug 2, 2026

Copy link
Copy Markdown

🔍 Review in progress — actively reviewing now (commit 23e8e5f)
Stage: Codex precheck complete — publishing preliminary
ETA: complete ~03:55 UTC (median 18m across 30 recent reviews)
Running 19m · Last checked: 2026-08-03 03:50 UTC

Comment thread src/governance/governance.cpp Outdated
// Iterate the stored votes in place: CheckSignature memoises its verdict on
// the vote instance, and a GetVotes() copy would discard that memo, so every
// walk would pay a fresh ECDSA recovery or BLS pairing per vote.
for (const auto& vote : fileVotes.GetVoteList()) {

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.

fileVotes.GetVotes copies; fileVotes.GetVoteList takes a reference; so when we later do vote.IsValid; when using GetVotes, nothing is cached, w/ GetVoteList it's cached (as my understanding)

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

🧹 Nitpick comments (2)
src/governance/vote.cpp (2)

186-206: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid recomputing GetSignatureHash() twice on a cache miss.

GetSignatureHash() re-serializes and hashes the whole vote object. CheckSignature(const CBLSPublicKey&) calls it once to build cache_key and a second time to pass into VerifyInsecure. This duplicate work runs on every fresh verification (new vote or new key), on the mainnet BLS path used for ordinary masternode voting. Since this PR's purpose is to remove redundant signature-related work, cache the hash once and reuse it.

♻️ Proposed fix
 bool CGovernanceVote::CheckSignature(const CBLSPublicKey& pubKey) const
 {
-    const uint256 cache_key{SignatureCacheKey(pubKey, GetSignatureHash(), vchSig)};
+    const uint256 sigHash{GetSignatureHash()};
+    const uint256 cache_key{SignatureCacheKey(pubKey, sigHash, vchSig)};
     if (m_sig_checked && m_sig_check_key == cache_key) {
         return m_sig_valid;
     }

     g_governance_vote_signature_checks.fetch_add(1, std::memory_order_relaxed);

     CBLSSignature sig;
     sig.SetBytes(vchSig, false);
-    const bool valid{sig.VerifyInsecure(pubKey, GetSignatureHash(), false)};
+    const bool valid{sig.VerifyInsecure(pubKey, sigHash, false)};
🤖 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/vote.cpp` around lines 186 - 206, Update
CGovernanceVote::CheckSignature so GetSignatureHash() is evaluated once on a
cache miss, store that hash in a local variable, and reuse it both when
constructing cache_key and when calling VerifyInsecure. Preserve the existing
cache-hit behavior and signature validation flow.

155-184: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Same redundant GetSignatureHash() call in the TESTNET branch.

For consistency with the fix suggested for CheckSignature(const CBLSPublicKey&), apply the same hash reuse here for CHashSigner::VerifyHash. Impact is smaller since this branch only runs on testnet, but the fix is the same one-line change.

♻️ Proposed fix
 bool CGovernanceVote::CheckSignature(const CKeyID& keyID) const
 {
-    const uint256 cache_key{SignatureCacheKey(keyID, GetSignatureHash(), vchSig)};
+    const uint256 sigHash{GetSignatureHash()};
+    const uint256 cache_key{SignatureCacheKey(keyID, sigHash, vchSig)};
     if (m_sig_checked && m_sig_check_key == cache_key) {
         return m_sig_valid;
     }
@@
     if (Params().NetworkIDString() == CBaseChainParams::TESTNET) {
-        valid = CHashSigner::VerifyHash(GetSignatureHash(), keyID, vchSig, strError);
+        valid = CHashSigner::VerifyHash(sigHash, keyID, vchSig, strError);
🤖 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/vote.cpp` around lines 155 - 184, Update
CGovernanceVote::CheckSignature(const CKeyID&) so the TESTNET branch computes
GetSignatureHash() once and reuses that value when calling
CHashSigner::VerifyHash, matching the existing hash-reuse fix for the BLS
overload.
🤖 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.

Nitpick comments:
In `@src/governance/vote.cpp`:
- Around line 186-206: Update CGovernanceVote::CheckSignature so
GetSignatureHash() is evaluated once on a cache miss, store that hash in a local
variable, and reuse it both when constructing cache_key and when calling
VerifyInsecure. Preserve the existing cache-hit behavior and signature
validation flow.
- Around line 155-184: Update CGovernanceVote::CheckSignature(const CKeyID&) so
the TESTNET branch computes GetSignatureHash() once and reuses that value when
calling CHashSigner::VerifyHash, matching the existing hash-reuse fix for the
BLS overload.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a92fbd0d-4564-47ce-b5c6-f20d3f0d5ca9

📥 Commits

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

📒 Files selected for processing (7)
  • src/Makefile.test.include
  • src/governance/governance.cpp
  • src/governance/net_governance.cpp
  • src/governance/vote.cpp
  • src/governance/vote.h
  • src/governance/votedb.h
  • src/test/governance_vote_sync_tests.cpp

@PastaPastaPasta
PastaPastaPasta force-pushed the sec/u007 branch 5 times, most recently from 3414191 to 7f40ff7 Compare August 2, 2026 23:21
@github-actions

github-actions Bot commented Aug 2, 2026

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 these PRs merge first

This PR 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: ef602678a9

ℹ️ 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/vote.cpp
Comment on lines +28 to +29
HashWriter ss{};
ss << key << sigHash << vchSig;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Serialize BLS cache keys using the verification scheme

During a BLS activation or reorg where bls_legacy_scheme changes, ss << key serializes CBLSPublicKey using that mutable global, while the cached operation is always VerifyInsecure(..., false). Legacy serialization of a key can equal basic serialization of its negation, so if an operator key rotates from P to -P across such a boundary, a valid verdict cached for P can be returned for -P, allowing the old vote to survive revalidation and be advertised despite failing verification. Serialize the fingerprint key explicitly with the non-legacy scheme used by verification.

AGENTS.md reference: AGENTS.md:L169-L170

Useful? React with 👍 / 👎.

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

Preliminary review — Codex only

The final tree implements the governance vote signature memo safely and leaves fulfilled-request keying unchanged, with relevant regression coverage. However, the commit stack deliberately includes a failing test, an unsafe signature-cache implementation, and a network-throttle change later reverted after breaking functional tests, so the history must be rewritten before merge.

Validated blockers were found in the Codex precheck. Sonnet 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)

🔴 1 blocking | 🟡 1 suggestion(s)

2 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 `<commit:7647f98>`:
- [BLOCKING] <commit:7647f98>:1: Rewrite the known-red throttle-and-revert sequence
  The stack is not bisectable. Commit 7647f983a51 explicitly adds a test that its message says fails until the next commit. Commit 00b3b2bcadb then makes fulfilled requests port-agnostic, which 747cbdc8bd6 later reverts because it causes honest peers sharing an address to accumulate misbehavior scores and makes feature_governance.py fail. The same intermediate commit caches signature verdicts using only the verification key, allowing signed-content mutation to reuse a stale verdict until cde8a4668a0 repairs it. Rewrite the stack so the signature memo is introduced with its final content-sensitive fingerprint and tests, remove the netfulfilledman.cpp change/revert, and introduce any retained port-sensitivity test directly in passing form. Preserve the rejected throttle rationale in the final commit message or test comments rather than permanent reverted history.

In `<commit:43eefde>`:
- [SUGGESTION] <commit:43eefde>:1: Fold cleanup of newly introduced comments and vote traversal API
  Commit 43eefdea957 only rewrites or removes comments introduced earlier in this stack, including deleting the net_governance.cpp explanation added by 747cbdc8bd6. Similarly, 7f40ff728c3 replaces GetVoteList(), which this stack had just introduced, with the final ForEachVote() API. Fold these review-time corrections into the commits that introduce the signature memo and stored-vote traversal so permanent history contains the final documentation and API directly.

A single MNGOVERNANCESYNC vote-sync request is rate-limited via CNetFulfilledRequestManager keyed on peer.addr. For inbound peers that address includes the ephemeral TCP source port, so reconnecting or opening parallel connections used to mint a fresh throttle key and re-run the expensive per-vote signature walk.

Add a unit test that records a fulfilled request for one source port and asserts the same request from the same IP with a different port is still throttled. Pre-fix this fails; the following fix makes it pass.
MNGOVERNANCESYNC vote-sync walked every stored vote under cs_store and re-ran ECDSA/BLS verification on each request. The only throttle was CNetFulfilledRequestManager keyed on peer.addr including the ephemeral source port, so reconnecting reset the quota and the first request per (connection, object) still paid full verification cost.

Key fulfilled requests on the peer network address with port zero so reconnects from the same host share a quota, memoize CheckSignature results on CGovernanceVote (invalidated on key or signature change), and iterate the live vote list in GetSyncableVoteInvs so that memo survives the sync path. Narrowing cs_store is deferred: the expensive work is now memoized and throttled; moving the loop off the lock needs a careful snapshot design.
The memo introduced for the govsync re-verification DoS was keyed on the verification key alone. nTime is part of the signed payload but is mutable via SetTime(), which governance.cpp and object.cpp call on reconstructed votes before verification, so a mutated vote could reuse a verdict computed for content it never carried -- a signature-check bypass.

Key the memo on the verification key, the signature hash and the signature bytes, so any change to signed content misses the cache and re-verifies. Add a regression test asserting a mutated vote is rejected.
Keying mapFulfilledRequests on the network address without the port traded a CPU DoS for a remotely triggerable ban of honest peers. The map is shared with SyncManager's per-peer outbound bookkeeping (full-sync, governance-sync, spork-sync, mempool-sync), and the govsync quota is coupled to PeerMisbehaving(20). Several honest nodes legitimately share one address (NAT, colocation, CI), so collapsing them onto one key makes the second honest peer that asks about an object look like a repeat offender; the ban score escalates to DISCOURAGE THRESHOLD EXCEEDED.

This was measured, not theorised: with the port stripped, feature_governance.py fails in both wallet variants (every node is on 127.0.0.1) with honest peers scored to 100; with netfulfilledman.cpp restored to upstream it passes. Restore upstream keying and document at the govsync call site why the port must stay in the key.

The expensive half of U007/V058/U011 is addressed where the cost lives: the CGovernanceVote signature memo, which is retained. Replace the throttle unit test with one pinning port-sensitivity as intended behaviour.

Also document the memo's thread-safety and content-keying invariants, and add a regression test proving a BLS verdict cannot be reused by the CKeyID overload (key-type confusion).
The comments were written as review answers rather than as documentation: they described what the code used to do, what this change fixes, and what was measured while making it. None of that survives the merge, and "re-running BLS/ECDSA on every govsync is the DoS" is meaningless once there is no diff to read it against.

State the standing constraints instead, and keep in each place only the rule a future reader could otherwise violate: do not narrow the signature-memo key. Drop the vote.cpp duplicate of the vote.h memo documentation. Drop the fulfilled-request keying note from net_governance.cpp entirely - it explained a reverted approach, the same reasoning already heads the port-sensitivity unit test, and that copy is the one enforced by assertions. net_governance.cpp is no longer touched by this branch.
The memo was three loose members (m_sig_checked, m_sig_valid, m_sig_check_key) with the rule "compare the fingerprint before trusting the verdict" living only in a comment. Wrap them in a SignatureMemo whose Lookup() takes the fingerprint and returns std::optional<bool>, so a caller cannot reach the verdict without supplying the fingerprint it belongs to.

This also drops the invalidation in SetSignature. Since the fingerprint covers vchSig, a signature swap changes the fingerprint and misses on its own; the explicit reset was belt-and-braces for an invariant the fingerprint already provides. signature_cache_invalidated_by_signed_field_mutation covers this.
GetVoteList() returned a reference into state guarded by CGovernanceObject::cs. Holding cs is enforced on GetVoteFile(), but no annotation carries that requirement onto a returned reference, so the idiom used elsewhere in this file -- WITH_LOCK(obj->cs, return obj->GetVoteFile().GetVotes()) -- would silently produce an unguarded read if applied to the list accessor. It is safe with GetVotes() only because that returns a copy.

ForEachVote runs the callback inside the traversal, so the caller's lock necessarily covers every vote it sees, and there is no reference for a caller to hold past the lock.
Reserve invs against the known vote count instead of letting it grow geometrically under cs_store and the object's cs, and compute GetSignatureHash() once per CheckSignature rather than once for the memo fingerprint and again for the verifier on the miss path.

In the tests, collapse the four copies of the build-sign-attach setup into one MakeSignedVote helper so each case opens with what it is actually about.
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.

2 participants