Skip to content

fix: invalidate quorum commitment cache on disconnect - #7476

Closed
thepastaclaw wants to merge 1 commit into
dashpay:developfrom
thepastaclaw:fix/cbtx-quorum-cache-reorg
Closed

fix: invalidate quorum commitment cache on disconnect#7476
thepastaclaw wants to merge 1 commit into
dashpay:developfrom
thepastaclaw:fix/cbtx-quorum-cache-reorg

Conversation

@thepastaclaw

@thepastaclaw thepastaclaw commented Jul 21, 2026

Copy link
Copy Markdown

Issue being fixed or feature implemented

CalcCbTxMerkleRootQuorums memoizes quorum commitment hashes in two layers. The inner LRU is keyed only by quorum base-block hash, not by the serialized CFinalCommitment mined for that base. Different valid branches can mine different valid commitments for the same base, so after evoDb rolls back a disconnect, the LRU can still return the old branch's commitment hash and make validation reject a valid replacement branch with bad-cbtx-quorummerkleroot.

The outer whole-result cache is not itself the stale layer: it is keyed by the active quorum base-block list, and undoing the commitment removes the inversed-height entry, so that list changes and the outer cache misses. The stale value is reintroduced when that rebuild consults the surviving inner LRU.

Dash triggerability is lower than in a pure PoS setting because of PoW and ChainLocks, but the cache key remains branch-insufficient and therefore a correctness bug.

Out of scope: automatic reconsideration of a chainlocked branch already marked failed.

What was done?

  • Moved the CbTx quorum-hash caches into CQuorumBlockProcessor, which owns the mined-commitment evoDb state they derive from.
  • Seeded the per-LLMQ LRUs from the processor's chainstate consensus parameters and made missing-type lookups fall back to uncached computation instead of default-constructing a zero-capacity LRU.
  • Invalidate the cache from CQuorumBlockProcessor::UndoBlock whenever a non-null mined commitment is undone.
  • Removed the process-global cache state and the separate evo/cbtx_cache.h API.
  • Corrected the comments and tests to identify the inner base-hash LRU as the stale layer.
  • Reworked the regression tests to assert only the desired post-invalidation result, verify UndoBlock performs the invalidation, and make duplicated private DB-key assumptions fail loudly through the public reader.

How Has This Been Tested?

  • Built src/test/test_dash in the worktree-local debug build.
  • Ran ./src/test/test_dash --run_test=evo_cbtx_tests --catch_system_errors=no (pass: 4 cases).
  • Temporarily removed the UndoBlock invalidation and confirmed the regression test fails, then restored it and confirmed the suite passes.
  • Ran test/lint/lint-circular-dependencies.py, test/lint/lint-includes.py, and test/lint/lint-python.py (pass).
  • A broader validation_chainstate_tests run crashes identically at the prior PR head, so that failure is pre-existing and unrelated to this change.

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 (N/A for this correctness fix)
  • I have assigned this pull request to a milestone (if appropriate)

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Repo admins can enable using credits for code reviews in their settings.

@thepastaclaw

thepastaclaw commented Jul 21, 2026

Copy link
Copy Markdown
Author

✅ Final review complete — no blockers (commit acb611a)

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 804a1faf-7032-4110-aacf-b7b5314172fd

📥 Commits

Reviewing files that changed from the base of the PR and between 8a897de and acb611a.

📒 Files selected for processing (5)
  • src/Makefile.am
  • src/evo/cbtx.cpp
  • src/evo/cbtx_cache.h
  • src/llmq/blockprocessor.cpp
  • src/test/evo_cbtx_tests.cpp
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/llmq/blockprocessor.cpp
  • src/evo/cbtx.cpp

Walkthrough

The CbTx quorum commitment hash cache now uses mutex-protected process-lifetime state with whole-result caches and per-LMQ-type LRU entries. A public invalidation helper clears cached results and LRU contents. UndoBlock invokes invalidation after undoing non-null quorum commitments. Tests cover explicit invalidation after commitment replacement and automatic invalidation during block undo.

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

Sequence Diagram(s)

sequenceDiagram
  participant CQuorumBlockProcessor
  participant UndoBlock
  participant InvalidateCachedQcHashes
  participant CachedGetQcHashesQcIndexedHashes
  CQuorumBlockProcessor->>UndoBlock: undo block commitments
  UndoBlock->>InvalidateCachedQcHashes: invalidate after non-null commitment undo
  InvalidateCachedQcHashes-->>CachedGetQcHashesQcIndexedHashes: clear cached QC hash state
  CachedGetQcHashesQcIndexedHashes->>GetMinedCommitment: reload commitment data on cache miss
Loading

Suggested reviewers: pastapastapasta

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main fix: invalidating quorum commitment cache state on disconnect.
Description check ✅ Passed The description is directly about the same cache invalidation bug, the fix, and the added tests.
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

Copy link
Copy Markdown
Author

The five container/cache failures in run 29848453760 are the known pull_request_target bootstrap failure, not a #7476 source failure.

Each failing job stops at checkout before compiling this branch because the reusable workflow is loaded from base develop, where checkout v6.1 reports allow-unsafe-pr-checkout: false and refuses the fork head. PR #7474 contains the base-workflow compatibility fix. I am leaving #7476 unchanged and not rerunning these checks.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Final validation — Codex + Sonnet

This PR fixes a real correctness bug where process-lifetime CbTx quorum-commitment hash caches were keyed only by base-block identity, not by the mined CFinalCommitment, allowing a disconnect/reorg to leave stale commitment hashes and incorrectly reject a valid replacement branch with bad-cbtx-quorummerkleroot. I verified the fix directly: InvalidateCachedQcHashes() correctly clears both the outer whole-result cache and the per-quorum LRU under g_qc_hashes_cache_mutex, and is called from CQuorumBlockProcessor::UndoBlock whenever a non-null commitment is undone; the new unit tests demonstrate both the bug (stale hash surviving an evoDb swap) and the fix. The only issue is commit hygiene: the second and third commits are pure iteration on the first commit's own comments/tests within the same PR (one deletes an UndoBlock comment, the next re-adds a shorter version of the same comment one commit later), which is confirmed noise for permanent git blame/bisect history and should be squashed.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — dash-core-commit-history (completed)
  • Verifier: claude-sonnet-5 — verifier
  • 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/llmq/blockprocessor.cpp`:
- [SUGGESTION] src/llmq/blockprocessor.cpp:417-424: Squash the two iterative follow-up commits into the primary fix commit
  I diffed all three commits directly. 5a142a8 introduces the cache-invalidation fix, an explanatory comment at the UndoBlock call site, and the regression tests. c39393a then trims that same comment down and simplifies the tests it just added (including removing an EraseMinedCommitment helper and inlining CBlockIndex/genesis setup). 52abbf3 immediately re-adds a shortened version of the identical comment c39393a had just deleted, word-for-word restating the same rationale ("branch-dependent even when the active quorum base-block list is unchanged"). None of these three commits is independently meaningful in permanent history: c39393a's comment removal is undone one commit later, and 52abbf3 exists solely to patch what c39393a just deleted. A future `git blame`/`git log -p` on this line will show a comment added, deleted, and re-added within the same PR — pure noise for bisect/blame with no reviewable content difference. Squash all three into one commit via interactive rebase before merge.

Comment on lines 417 to +424
AddMineableCommitment(qc);
}

// Drop both CbTx qc-hash cache layers: mined commitments are branch-dependent
// even when the active quorum base-block list is unchanged.
if (undone_commitment) {
InvalidateCachedQcHashes();
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🟡 Suggestion: Squash the two iterative follow-up commits into the primary fix commit

I diffed all three commits directly. 5a142a8 introduces the cache-invalidation fix, an explanatory comment at the UndoBlock call site, and the regression tests. c39393a then trims that same comment down and simplifies the tests it just added (including removing an EraseMinedCommitment helper and inlining CBlockIndex/genesis setup). 52abbf3 immediately re-adds a shortened version of the identical comment c39393a had just deleted, word-for-word restating the same rationale ("branch-dependent even when the active quorum base-block list is unchanged"). None of these three commits is independently meaningful in permanent history: c39393a's comment removal is undone one commit later, and 52abbf3 exists solely to patch what c39393a just deleted. A future git blame/git log -p on this line will show a comment added, deleted, and re-added within the same PR — pure noise for bisect/blame with no reviewable content difference. Squash all three into one commit via interactive rebase before merge.

source: ['claude', 'codex']

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 8a897dee04f: squashed the two iterative follow-ups into the primary fix, leaving one signed commit. The resulting tree is byte-identical to the reviewed 52abbf390d3d head; evo_cbtx_tests and git diff --check pass.

@thepastaclaw
thepastaclaw force-pushed the fix/cbtx-quorum-cache-reorg branch from 52abbf3 to 8a897de Compare July 22, 2026 21:11

@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/test/evo_cbtx_tests.cpp`:
- Around line 165-218: Add a local RAII cleanup guard to both
qc_hash_cache_invalidated_on_commitment_branch_change
(src/test/evo_cbtx_tests.cpp lines 165-218) and the UndoBlock test
(src/test/evo_cbtx_tests.cpp lines 232-279) so InvalidateCachedQcHashes() runs
when each test exits, while retaining the existing entry invalidation; ensure
the guard restores the process-global cache even on early failure or exceptions.
🪄 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

Run ID: 020a5135-daf9-4f95-8d64-2c6c715c31d3

📥 Commits

Reviewing files that changed from the base of the PR and between 52abbf3 and 8a897de.

📒 Files selected for processing (4)
  • src/evo/cbtx.cpp
  • src/evo/cbtx.h
  • src/llmq/blockprocessor.cpp
  • src/test/evo_cbtx_tests.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/evo/cbtx.cpp

Comment thread src/test/evo_cbtx_tests.cpp
CachedGetQcHashesQcIndexedHashes keyed both process-lifetime layers only by
quorum base blocks/hashes, not by the mined CFinalCommitment. Different valid
branches can therefore produce different commitment hashes for the same base
list. evoDb rolled back on disconnect, but the caches did not, which can make
CalcCbTxMerkleRootQuorums reject a valid replacement branch with
bad-cbtx-quorummerkleroot.

Invalidate both the outer whole-result cache and the per-quorum LRU from
CQuorumBlockProcessor::UndoBlock when a non-null commitment is undone, and add
unit coverage for the branch-change and UndoBlock invalidation seams.

Co-Authored-By: Claude <noreply@anthropic.com>
@thepastaclaw
thepastaclaw force-pushed the fix/cbtx-quorum-cache-reorg branch from 8a897de to acb611a Compare July 22, 2026 21:40
@thepastaclaw

Copy link
Copy Markdown
Author

Addressed the latest review and current-head CI findings in amended commit acb611aacfc (kept as a single clean-history commit):

  • added RAII cache cleanup to both new tests for failure/exception-safe isolation;
  • switched the file-scope cache lock to the established GlobalMutex pattern for Clang thread-safety analysis;
  • split the invalidation declaration into evo/cbtx_cache.h to remove the new evo/cbtx -> llmq/blockprocessor -> evo/cbtx include cycle.

Local validation: changed translation units syntax-check cleanly, circular-dependency/include-guard/whitespace lints pass, and independent Codex review found no actionable regression. CI has been re-triggered by the push.

PastaPastaPasta added a commit that referenced this pull request Jul 28, 2026
…tments change

01f012a test: cover post-undo qc-hash cache visibility (UdjinM6)
8e92667 fix: own the CbTx quorum hash caches in CQuorumBlockProcessor (UdjinM6)

Pull request description:

  ## Issue being fixed or feature implemented

  `CalcCbTxMerkleRootQuorums` memoized into two function-local statics in `src/evo/cbtx.cpp`: an outer whole-result cache keyed on the vector of active quorum base `CBlockIndex*`, and an inner per-LLMQ-type LRU keyed on those base-block hashes.

  **Keying defect.** Neither key identifies which `CFinalCommitment` was actually mined for a base on the active chain. Different valid branches can mine different valid commitments for the same base list. On a disconnect the evoDb state is rolled back correctly, but the caches were not invalidated, so both layers kept serving hashes from the abandoned branch. `CalcCbTxMerkleRootQuorums` could then reject an otherwise valid replacement branch with `bad-cbtx-quorummerkleroot`.

  Concretely: branch A mines `QC_A` for base `B` at height H. A reorg disconnects H and connects H' mining `QC_B` for the same base `B`. Validating H'+1 uses a base-block list identical to branch A's, so the outer cache hits and returns branch A's hashes wholesale; even on an outer miss the LRU still returns `hash(QC_A)` for base `B`.

  Triggerability on Dash is lower than in a pure PoS setting because of PoW and ChainLocks, but the keying is branch-dependent and therefore a correctness bug.

  **Lifetime defect.** The caches lived for the whole process, while the block index whose `CBlockIndex*` the outer cache stores is per-node. The outer cache's hit test is a pointer comparison, so after a chainstate teardown a recycled address can make two unrelated chains compare equal. In the unit-test binary this leaks state between fixtures, since `TestChainSetup::CreateBlock` populates the caches.

  This is an alternative to PR #7476, which fixes the keying defect only, via a process-global cache plus an exported invalidation function called from `UndoBlock`.

  ## What was done?

  Moved both cache layers onto `llmq::CQuorumBlockProcessor` as `mutable` members guarded by a new `m_qc_hashes_cache_mutex`, and replaced the free helper with `GetQcHashes(pindexPrev) const`. Every caller already receives a `CQuorumBlockProcessor&`, so no signatures changed.

  Ownership removes the cross-instance hazard by construction: a fresh `CQuorumBlockProcessor` starts with empty caches, so no node or test fixture can observe another's entries, and the pointer-comparison hit test can no longer report a match between unrelated chains.

  It also bounds the stored `CBlockIndex*` lifetime. `CBlockIndex` objects are held by value in `BlockManager::m_block_index` and are never cleared or erased at runtime, so they are freed only with the `ChainstateManager`; both teardown paths destroy the quorum block processor first (`init.cpp` calls `DashChainstateSetupClose` before `chainman.reset()`, and in tests `~TestingSetup` runs before the base `~ChainTestingSetup` which resets chainman).

  It also puts the caches in the same class as the only two sites that mutate mined commitment state, so the keying defect is handled by a private `DropQcHashesCache()` called from the write in `ProcessCommitment` and the erase in `UndoBlock` — a private detail rather than a cross-module call. The drop runs only after that commitment's evoDb writes/erases are complete, so the caches cannot be repopulated from a half-updated view; every caller holds `cs_main` today, but the invalidation should not depend on a lock discipline enforced elsewhere.

  Notes for review:

  - `DB_MINED_COMMITMENT` and the inverted-height index are always mutated as a pair, in those two functions only, so guarding both together is sufficient.
  - `fJustCheck` returns before the write, so `TestBlockValidity` and the miner's speculative validation do not churn the caches.
  - Lock ordering is unchanged: `GetMinedAndActiveCommitmentsUntilBlock` is still called before the cache lock, and the only call made while holding it is `GetMinedCommitment`, a bare evoDb read that takes no other lock.
  - A rolled-back evoDb transaction leaves an empty cache that repopulates from committed state; it can never retain uncommitted commitment hashes.

  Two commits: the fix, then the regression test.

  ## How Has This Been Tested?

  Built on macOS arm64 against the depends prefix with `--enable-debug
  --enable-crash-hooks --enable-werror`.

  - Full unit suite `./src/test/test_dash`: 760 test cases, no errors.
  - `./src/test/test_dash --run_test=evo_cbtx_tests`: passes.
  - The first commit was checked out and built on its own to confirm it compiles and passes the full suite independently.
  - Falsification: with the `UndoBlock` cache drop removed, the new `qc_hash_cache_invalidated_by_undoblock` case fails on a merkle-root mismatch; restored and re-confirmed green. The test therefore genuinely covers the bug rather than passing vacuously.
  - `test/lint/all-lint.py`: only `lint-cppcheck-dash.py` fails, on files untouched by this change (`llmq/params.h`, `evo/dmn_types.h`, `evo/netinfo.h`); reproduced identically on an unmodified checkout. `lint-circular-dependencies.py` passes.

  Functional tests were not run; the change is confined to the CbTx merkle-root cache path and is covered by unit tests.

  ## Breaking Changes

  None. Consensus rules are unchanged; this only affects caching of values that were already being computed.

  ## 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
  - [x] I have assigned this pull request to a milestone

ACKs for top commit:
  PastaPastaPasta:
    utACK 01f012a

Tree-SHA512: df2ebf07db13f1f305fbcfb30082b2d1c36e8614e3000984a2f2c1529b1743ae4418a99cd66deb6a4ab2f48c2793af5611cba800da69f53319c2459844088af9
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