Skip to content

fix: remove never-invalidated mined-commitment cache from cbtx merkle root - #64

Draft
PastaPastaPasta wants to merge 4 commits into
developfrom
sec/v034
Draft

fix: remove never-invalidated mined-commitment cache from cbtx merkle root#64
PastaPastaPasta wants to merge 4 commits into
developfrom
sec/v034

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Owner

Audit finding V034 (high, CONFIRMED). Class: chain-split — worth noting this is not a plain DoS.

Issue

CachedGetQcHashesQcIndexedHashes in src/evo/cbtx.cpp held a process-lifetime qc_hashes_cached LRU memoising ::SerializeHash(minedCommitment), keyed on the quorum base block hash. That key does not identify the value.

The DB_MINED_COMMITMENT row for a given quorumHash is mutable: CQuorumBlockProcessor::UndoBlock erases it on disconnect and re-adds the commitment as mineable, so after a reorg a competing chain can mine a different but equally valid CFinalCommitment for the same quorum. The signed commitmentHash covers only (llmqType, quorumHash, validMembers, quorumPublicKey, quorumVvecHash) — not the signers bitset — while signers does feed into ::SerializeHash.

Surviving that mutation, the cache returns the pre-reorg hash. The node then computes a quorum merkle root nobody else agrees on and rejects the honest-majority tip with bad-cbtx-quorummerkleroot (BLOCK_CONSENSUS), marking it BLOCK_FAILED_VALID on disk. A restart does not clear that — the split is permanent and requires reindex to recover.

Fix

The cache is removed outright and the commitment hash computed directly from GetMinedCommitment.

The initial approach invalidated the LRU on quorum-set change, but review showed the cached value was then provably dead — the invalidation fires on exactly the paths that would have produced a hit, so the cache never served anything. Keeping it would have been complexity with no benefit and a live footgun.

A comment at the former cache site records why, so nobody reintroduces it: any such cache must be keyed on something that pins the commitment content (e.g. the mined block hash GetMinedCommitment already returns alongside it), not on the quorum base block hash alone.

Tests

test: prove mined-commitment cache survives reorg in cbtx precedes the fix and fails without it.

Review notes

  • Performance is the thing to check here. This removes a memoisation from a path that runs during block validation and template creation. The surrounding qcHashes_cached/qcIndexedHashes_cached caches (keyed on the quorum set, and correctly invalidated) are untouched and still absorb the common case. If benchmarking shows the removed layer mattered, the correct fix is to re-add it keyed on the mined block hash — but correctness first.
  • Based on dashpay/dash develop @ 6d04c60ef36. Not rebase-tested against a newer tip; full functional suite not run.

🤖 Generated with Claude Code

Add a unit test that warms CachedGetQcHashesQcIndexedHashes with commitment C1 for a quorum base hash, simulates a reorg that replaces the EvoDB mined-commitment row with a distinct but equally valid C2 (signers-only difference), and asserts CalcCbTxMerkleRootQuorums tracks C2 rather than the process-lifetime LRU hit on C1. Fails before the qc_hashes_cached invalidation fix.
qc_hashes_cached in CachedGetQcHashesQcIndexedHashes was process-lifetime and keyed only by the quorum base block hash. On reorg, UndoBlock erases the DB_MINED_COMMITMENT row and a competing chain may mine a different but valid CFinalCommitment for the same quorum (signers is part of SerializeHash but not of the signed commitmentHash). Sibling caches were already cleared when the active-quorum set changed; clear and re-init qc_hashes_cached in the same reset block so CalcCbTxMerkleRootQuorums cannot return a stale hash and reject the honest majority tip with bad-cbtx-quorummerkleroot. Consensus-safe: for any fixed chain tip the recomputed root is identical to a cold-process computation; only the reorg-stale path changes.
The previous commit cleared qc_hashes_cached in the quorum-set reset block. That is correct but leaves the cache provably dead: the clear is unconditional and sits above the cache's only read, so every lookup now misses. Within a single invocation all (llmqType, quorumHash) keys are distinct -- bad-qc-dup forbids two mined commitments for one quorum on a chain, and rotated types contribute one entry per quorumIndex -- so a hit is impossible. It costs a per-call map rebuild and an InitQuorumsCache walk to memoise nothing.

Remove it instead. This restores the loop to its shape before 8958152 (perf: cache mined commitment for quorum merkle root calculation), the commit that introduced the defect, and drops the now-unused llmq/utils.h include.

The key, not the invalidation, was the root cause: the quorum base block hash does not identify the value, because the DB_MINED_COMMITMENT row is mutable across a reorg. Leaving a cleared-but-present cache invites a future reader to re-add a lookup path under the same unsound key. The reset block now carries a comment stating why no cache may be keyed on the base block hash alone, and what a correct key would have to pin.

Behaviour is unchanged for every chain: the value returned is what a cold process computes. The regression test is unchanged and still fails when the old LRU is restored.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6728c1f3-8b65-4c2e-b996-6bbb0bc1adc3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/v034

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.

The V0NN/U0NN labels were private working identifiers from a local review pass. They carry no meaning outside that pass, so they are removed while the surrounding technical rationale is kept.
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.

1 participant