Skip to content

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

Open
PastaPastaPasta wants to merge 1 commit into
dashpay:developfrom
PastaPastaPasta:sec/u007
Open

fix: memoize governance vote signature checks to bound govsync cost#7518
PastaPastaPasta wants to merge 1 commit 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: 34 minutes

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

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: 9c36cf5f-15bc-4cd0-ab9b-39fa0f13b7bd

📥 Commits

Reviewing files that changed from the base of the PR and between 7f40ff7 and 3b03544.

📒 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

✅ Final review complete — no blockers (commit 3b03544)

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

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

GetSyncableVoteInvs walks every stored vote for a requested object and calls IsValid(), which unconditionally re-runs full cryptographic verification: ECDSA recovery for proposal funding votes, or a BLS pairing for operator-key votes. Those signatures were already verified at acceptance, so the work is pure waste. It runs on the message-handler thread while holding cs_store, driven by an unauthenticated MNGOVERNANCESYNC, and the existing quota is keyed on a CService that includes the ephemeral source port, so reconnecting bypasses it.

Memoize the verdict, keyed on the verification key, GetSignatureHash() and the signature bytes. GetSignatureHash() is a SerializeHash over exactly the fields GetSignatureString() signs, so mutating a covered field invalidates the memo. The BLS key is fingerprinted with the non-legacy scheme that VerifyInsecure actually uses, rather than via operator<< and the mutable bls_legacy_scheme global: a legacy encoding of P can equal the basic encoding of -P, so serializing under the global would let a verdict cached for one key be served for the other across an activation or reorg.

An intermediate revision also throttled govsync by IP; that was dropped deliberately, because keying on IP alone plus a misbehaviour score would let an attacker get NAT-colocated honest peers discouraged. The residual per-request cost is still O(votes) under cs_store with no working rate limit; a per-CNode request budget is still needed and is not attempted here.

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

Final validation — Codex + Sonnet

The rewritten one-commit range resolves both prior history findings and directly introduces the final content-sensitive signature memo and callback-based vote traversal. The implementation is sound, but the fixed-scheme BLS key encoding protects a subtle security invariant that is not covered by the new regression tests.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: gpt-5.6-sol — final_verifier (fallback)
  • Sonnet reviewers: claude-sonnet-5 — general (failed), claude-sonnet-5 — dash-core-commit-history (failed), claude-sonnet-5 — general (completed), claude-sonnet-5 — dash-core-commit-history (completed)

🟡 1 suggestion(s)

🤖 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/vote.cpp`:
- [SUGGESTION] src/governance/vote.cpp:41-43: Add a regression for cross-scheme BLS fingerprint collisions
  The explicit non-legacy key encoding prevents a valid cached verdict from being reused for a different point after `bls_legacy_scheme` changes, but the new tests do not exercise this invariant. For a public key whose legacy sign bit is set, parsing its legacy bytes under the basic scheme produces the opposite point, and `basic(-P)` has the same bytes as `legacy(P)`. A future refactor from `ToBytes(false)` back to generic serialization would therefore let a verdict cached for P under the legacy global scheme be returned for -P under the basic scheme. Add a regression that selects such a key, warms a valid verdict for P while the global scheme is legacy, parses `P.ToBytes(true)` as a basic public key, switches the global scheme to basic, and verifies that the opposite key has no memoized verdict and fails verification. Restore the global scheme on test exit.

Comment thread src/governance/vote.cpp
Comment on lines +41 to +43
const auto key_bytes = key.ToBytes(/*specificLegacyScheme=*/false);
ss.write(MakeByteSpan(key_bytes));
ss << 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.

🟡 Suggestion: Add a regression for cross-scheme BLS fingerprint collisions

The explicit non-legacy key encoding prevents a valid cached verdict from being reused for a different point after bls_legacy_scheme changes, but the new tests do not exercise this invariant. For a public key whose legacy sign bit is set, parsing its legacy bytes under the basic scheme produces the opposite point, and basic(-P) has the same bytes as legacy(P). A future refactor from ToBytes(false) back to generic serialization would therefore let a verdict cached for P under the legacy global scheme be returned for -P under the basic scheme. Add a regression that selects such a key, warms a valid verdict for P while the global scheme is legacy, parses P.ToBytes(true) as a basic public key, switches the global scheme to basic, and verifies that the opposite key has no memoized verdict and fails verification. Restore the global scheme on test exit.

source: ['codex']

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