Skip to content

fix(dash): key rotation ChainLock signatures per quorum work block - #934

Draft
xdustinface wants to merge 6 commits into
dashpay:devfrom
xdustinface:fix/rotated-quorum-cl-sig-alignment
Draft

fix(dash): key rotation ChainLock signatures per quorum work block#934
xdustinface wants to merge 6 commits into
dashpay:devfrom
xdustinface:fix/rotated-quorum-cl-sig-alignment

Conversation

@xdustinface

Copy link
Copy Markdown
Collaborator

Problem

A fresh mainnet sync deterministically fails on the first QRInfo response. MasternodeListEngine::feed_qr_info rejects it with AllCommitmentAggregatedSignatureNotValid("invalid signature"), which dash-spv treats as fatal, permanently wedging masternode sync, ChainLock validation and InstantSend verification for the rest of the session. The same failure was reported on testnet in #907.

Root cause

Core keys each rotated quorum's ChainLock signature to that quorum's own work block (quorumsCLSigs, work block is the cycle base minus 8, see CSimplifiedMNListDiff::BuildQuorumChainlockInfo). The engine collapsed that mapping to one signature per diff, taken from the first rotating quorum it encountered, and stamped the same 4-signature tuple onto every quorum in a batch.

The active rotation set is the latest commitment per quorum index regardless of cycle. Whenever an index fails its DKG, the previous cycle's quorum stays in the set at that index. At the captured mainnet state (tip 2518986, cycle 2518848) quorum index 0 carried the commitment from cycle 2518560. Such stragglers got quarter modifiers built from another cycle's signatures, the reconstructed member sets disagreed with the real signers, and the BLS aggregate check correctly failed, killing the whole feed.

Two more divergences from Core were found in the same audit and are fixed in separate commits, both chain-state dependent drivers of the intermittency seen in #907:

  • a work block without a coinbase ChainLock arrives as an all-zero signature, and Core derives the quorum modifier from the block hash in that case, while the engine always used the signature form
  • Core fills NoSkipping quarters from one shared cursor that wraps around the sorted list, while the engine chunked, diverging whenever fewer than quorum_count * quarter_size masternodes exist

Fix

  • feed_qr_info builds a work-height-to-signature map from every diff's per-quorum quorumsCLSigs data, and each rotated quorum looks up the four quarter signatures of its own cycle. Signature groups whose quorum hash heights are unknown are keyed by elimination, walking diffs oldest first.
  • Entries whose signatures or history are missing settle as Skipped individually instead of poisoning the batch. find_rotated_masternodes_for_quorums returns a per-quorum Result for the same reason.
  • The cycle key for rotated_quorums_per_cycle is derived from the newest cycle base among the entries instead of the first entry's quorum hash. The old key stored the active set under the previous cycle's hash whenever index 0 was a straggler, which broke InstantSend lock cycle lookups.
  • new_quorum_modifier_type falls back to the block-hash modifier for zeroed signatures.
  • The NoSkipping quarter fill wraps circularly and empty member lists return empty quarters, mirroring Core.
  • Previous-cycle validation is best-effort enrichment, so an invalid entry there now skips storing that cycle instead of aborting the feed. Corrupt current-cycle data still rejects.

Reproduction and testing

The failing chain state was captured live from mainnet via a new env-gated hook (DASH_SML_DUMP_QRINFO_DIR) and committed as fixture qrinfo_2518986 with its block container. The new regression test validate_first_qr_info_on_fresh_engine_with_mixed_cycle_quorums replays production cold start offline: it failed with the exact production error before the fix and now asserts all 32 quorums verify and the set is stored under the current cycle hash.

New unit tests pin the skip-list strategies (NoSkipping wrap-around, SkipFirst relative decoding, empty-list behavior), the modifier selection, and the degraded previous-cycle error contract.

Verified: full dashcore and dash-spv suites including the dashd regtest integration tests, clippy with all features, and a live mainnet fresh sync that now processes the first QRInfo cleanly (rotated_quorum_count=32, fully_verified_count=32, stored_cycle_height=2518848).

Closes #907.

Follow-ups (out of scope)

  • dash-spv has no retry path when feed_qr_info errors: one failed QRInfo permanently stalls masternode sync until restart. That area is being rewritten in refactor(dash-spv): network manager refactor and sync pipelines optimized  #902, so the retry fix should land on top of it.
  • The SkipExcept arm still constructs quarters from the skip list, while Core returns empty quarters for that mode. Dormant, since Core never emits the mode.
  • Core also rejects duplicate operator keys and addresses during New-quarter construction, which the engine does not yet mirror.

When `DASH_SML_DUMP_QRINFO_DIR` is set, `feed_qr_info` writes the pre-feed block container and the raw `QRInfo` to that directory before processing. Validation failures against live chain state are otherwise unreproducible once the rotation cycle advances, so the capture preserves the failing input as a test fixture. Inert without the environment variable.
Core attaches each rotated quorum's ChainLock signature to that quorum's own work block (`quorumsCLSigs`, cycle base minus 8), but `feed_qr_info` collapsed them to one signature per diff, taken from the first rotating quorum encountered, and stamped the same 4-signature tuple onto every entry of a batch. The active rotation set can span several cycles at once, because a failed DKG leaves the previous cycle's quorum in place at that index, so every such straggler got quarter modifiers built from another cycle's signatures. The reconstructed member sets then disagreed with the real signers and `feed_qr_info` hard-failed the whole QRInfo with `AllCommitmentAggregatedSignatureNotValid` on the first fresh-sync response, permanently wedging masternode sync, ChainLock validation and InstantSend verification.

`feed_qr_info` now builds a work-height-to-signature map from every diff's per-quorum `quorumsCLSigs` data and each rotated quorum looks up the four quarter signatures of its own cycle. Groups whose quorum hash heights are unknown are keyed by elimination oldest diff first. Entries whose signatures are missing settle as `Skipped` individually instead of poisoning the batch, and `find_rotated_masternodes_for_quorums` returns a per-quorum `Result` for the same reason.

The cycle key for `rotated_quorums_per_cycle` is now derived from the newest cycle base among the entries instead of the first entry's quorum hash, which stored the set under the old cycle's hash whenever quorum index 0 was a straggler and broke IS lock cycle lookups. `qr_info_referenced_block_hashes` includes rotating quorum hashes from all diffs so callers feed the heights the signature map needs.

Regression fixture `qrinfo_2518986` was captured from a live mainnet fresh sync at tip 2518986, where index 0 of the active set carried the previous cycle's commitment and reproduced the failure deterministically.
…inLock signatures

A work block whose coinbase carries no ChainLock reaches the wire as an all-zero signature in `quorumsCLSigs`. Core then derives the quorum modifier from the work block hash, but `new_quorum_modifier_type` always used the signature form post-v20, so member scores diverged from Core and rotated quorum validation failed with an invalid aggregated signature whenever a quarter's work block lacked a coinbase ChainLock.
Core fills all rotated quorum quarters from one shared cursor that wraps back to the front of the sorted combined masternode list, reusing masternodes when fewer than `quorum_count * quarter_size` are available. The `chunks` based fill produced short or missing quarters in that case, which silently diverged from Core's member sets on networks with few masternodes. Also mirror Core's empty-list early return, which additionally guards the `SkipFirst` arm's modulo arithmetic against an empty list.
…fo feed

Previous-cycle validation is best-effort enrichment for IS lock verification, but an `Invalid` entry there propagated as a hard error out of `feed_qr_info`, discarding the entire QRInfo including the fully valid current cycle. An invalid previous-cycle entry now only prevents that cycle from being stored, logged at warn level, while corrupt current-cycle data in `lastCommitmentPerIndex` still rejects the feed.
…s cycle

The previous rotation cycle's active set can carry a straggler from a cycle older than the QRInfo's diff range, whose quarter masternode lists are never shipped, so that one entry cannot possibly verify. The all-or-nothing storage gate then dropped the entire otherwise-verified cycle and every IS lock referencing it failed with a missing cycle.

The previous-cycle path now stores the verified entries and leaves unverifiable or invalid ones out individually. `build_cycle_quorum_map` accepts partial sets, which is safe because IS lock verification selects the quorum by index and fails cleanly with `QuorumIndexNotFound` when a lock selects an absent one, so only locks signed by exactly the missing quorum are affected.
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: cce74d2a-43c5-4594-9624-00e5c4683019

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

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.94923% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.31%. Comparing base (b056d07) to head (db46189).

Files with missing lines Patch % Lines
dash/src/sml/masternode_list_engine/mod.rs 88.40% 32 Missing ⚠️
dash/src/sml/masternode_list_engine/validation.rs 66.66% 5 Missing ⚠️
...ternode_list_engine/rotated_quorum_construction.rs 96.89% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #934      +/-   ##
==========================================
+ Coverage   75.18%   75.31%   +0.12%     
==========================================
  Files         328      328              
  Lines       78194    78487     +293     
==========================================
+ Hits        58792    59110     +318     
+ Misses      19402    19377      -25     
Flag Coverage Δ
core 77.72% <90.94%> (+0.42%) ⬆️
ffi 48.58% <ø> (ø)
rpc 20.00% <ø> (ø)
spv 91.28% <ø> (-0.09%) ⬇️
wallet 76.88% <ø> (ø)
Files with missing lines Coverage Δ
dash/src/sml/quorum_entry/quorum_modifier_type.rs 80.00% <100.00%> (+19.47%) ⬆️
...ternode_list_engine/rotated_quorum_construction.rs 81.35% <96.89%> (+13.41%) ⬆️
dash/src/sml/masternode_list_engine/validation.rs 84.39% <66.66%> (-2.20%) ⬇️
dash/src/sml/masternode_list_engine/mod.rs 85.36% <88.40%> (+2.98%) ⬆️

... and 8 files with indirect coverage changes

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.

bug: fresh MasternodeListEngine rejects first QRInfo with AllCommitmentAggregatedSignatureNotValid (rotated quorum, testnet)

1 participant