From ff7a84d7effc2ee47b8ebe4b8bea927a5c8cd0bd Mon Sep 17 00:00:00 2001 From: xdustinface Date: Sun, 3 May 2026 14:25:31 +1000 Subject: [PATCH 01/11] fix(dash): make `feed_qr_info` resilient to missing rotation CL sigs QRInfos that span a block range with no successful rotating DKG arrive with per-diff CL sigs missing. Previously this hard-errored with `RequiredRotatedChainLockSigNotPresent`. Now affected entries land as `Skipped(MissingRotationChainLockSigs)` and the cycle is left unstored, deferring IS lock verification for that cycle until a later QRInfo carries complete sigs. Tighten the `rotated_quorums_per_cycle` insertion gate: store a cycle only when every entry is `Verified`, and never overwrite an already-fully-verified cycle (a thin follow-up QRInfo must not downgrade prior trust). Add a previous-cycle storage path (`validate_and_store_previous_cycle_quorums`) that uses the 4 historical sigs `[h-3c, h-2c, h-c, h]` to validate and store the rotated quorums living on `masternode_lists[h]` under their cycle boundary hash. This enables IS lock verification for the previous cycle on a fresh sync, where `lastCommitmentPerIndex` only covers the current cycle. Change `feed_qr_info` to return `Result>` so callers can observe rotated quorum counts, fully-verified counts, and which cycle (if any) was stored. New tests cover the soft-skip storage gate, corrupt aggregate-signature rejection, and the anti-clobber guarantee. The existing `validate_from_qr_info_and_mn_list_diffs` test is extended to assert both cycles are stored and to re-validate every stored quorum. --- dash/src/sml/masternode_list_engine/mod.rs | 823 ++++++++++++++++++--- 1 file changed, 716 insertions(+), 107 deletions(-) diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index 064b58d68..a178d3da2 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -6,6 +6,7 @@ mod rotated_quorum_construction; #[cfg(feature = "quorum_validation")] mod validation; +use core::fmt; use std::collections::{BTreeMap, BTreeSet}; use crate::Network; @@ -37,6 +38,91 @@ use serde::{Deserialize, Serialize}; /// The mnListDiffH in QRInfo is at (cycle_height - WORK_DIFF_DEPTH), not at the cycle boundary itself pub const WORK_DIFF_DEPTH: u32 = 8; +/// Identifies one of the rotation ChainLock signature slots carried by a QRInfo +/// response. Each slot corresponds to one per-cycle diff in the rotation quorum +/// formation proof: three historical cycles, the h diff, and the tip. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "bincode", derive(Encode, Decode))] +pub(crate) enum RotationChainLockSignatureSlot { + HMinus3c, + HMinus2c, + HMinusC, + H, + Tip, +} + +impl fmt::Display for RotationChainLockSignatureSlot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::HMinus3c => "h-3c", + Self::HMinus2c => "h-2c", + Self::HMinusC => "h-c", + Self::H => "h", + Self::Tip => "tip", + }; + f.write_str(name) + } +} + +/// Transient observability report returned from [`MasternodeListEngine::feed_qr_info`]. +/// Not persisted; lives only for the duration of the call. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct QRInfoFeedResult { + /// Total number of rotated quorums in `last_commitment_per_index`. + pub rotated_quorum_count: usize, + /// Rotated quorums for which no cached `QualifiedQuorumEntry` existed and one + /// was constructed fresh. Incremented independently of `verify_rotated_quorums` + /// and the final verification outcome. Pair with `fully_verified_count` to + /// distinguish cache-hit observability from a trust signal. + pub newly_qualified_count: usize, + /// Rotated quorums whose `.verified` is `LLMQEntryVerificationStatus::Verified` + /// after this call settled. Always `0` when `verify_rotated_quorums == false`. + /// A cycle is only stored in `rotated_quorums_per_cycle` when every entry is + /// `Verified`. + pub fully_verified_count: usize, + /// Height of the cycle from `last_commitment_per_index` if (and only if) + /// the storage gate fired and the cycle was inserted into + /// `rotated_quorums_per_cycle`. `None` when storage was skipped because + /// `last_commitment_per_index` was empty, the gate blocked the write, or + /// the cycle hash had no resolvable height. The cycle stored by + /// `validate_and_store_previous_cycle_quorums` is not reflected here. + pub stored_cycle_height: Option, +} + +impl QRInfoFeedResult { + /// Returns true when every rotated quorum in this QRInfo settled as + /// `LLMQEntryVerificationStatus::Verified`, the exact condition under which + /// `feed_qr_info` stores the cycle in `rotated_quorums_per_cycle`. Use this + /// as the trust gate for callers that need to know the cycle is fully + /// validated. + pub fn all_fully_verified(&self) -> bool { + self.rotated_quorum_count > 0 && self.fully_verified_count == self.rotated_quorum_count + } +} + +/// Returns the 4 rotation CL signatures when every slot is present, or `None` +/// when any slot is missing. On a miss, logs which slots were absent at debug +/// level under the given `cycle_label`. +#[cfg(feature = "quorum_validation")] +fn collect_rotation_sigs( + slots: [(Option, RotationChainLockSignatureSlot); 4], + cycle_label: &str, +) -> Option<[BLSSignature; 4]> { + let missing: Vec = + slots.iter().filter_map(|(sig, slot)| sig.is_none().then_some(*slot)).collect(); + if !missing.is_empty() { + tracing::debug!( + "QRInfo missing rotation ChainLock signatures: {:?}; \ + {} rotated quorums will not be stored", + missing, + cycle_label + ); + return None; + } + Some(slots.map(|(sig, _)| sig.expect("all slots checked above"))) +} + #[derive(Clone, Eq, PartialEq, Default)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "bincode", derive(Encode, Decode))] @@ -442,6 +528,135 @@ impl MasternodeListEngine { self.block_container.feed_block_height(height, block_hash) } + /// Loads the rotation quorums from the MN list at the h work block, attaches + /// the captured CL signatures, and returns them along with the cycle hash + /// they should be stored under. Returns `None` when prerequisite state is + /// not yet loaded, when the MN list carries no rotating quorums, or when + /// the cycle is already stored. All three cases are expected on a fresh + /// sync and not errors. + #[cfg(feature = "quorum_validation")] + fn try_load_previous_cycle_entries( + &self, + work_block_hash: BlockHash, + isd_type: LLMQType, + sigs: [BLSSignature; 4], + ) -> Option<(QuorumHash, Vec)> { + let work_height = self.block_container.get_height(&work_block_hash)?; + let mn_list = self.masternode_lists.get(&work_height)?; + let quorums_of_type = mn_list.quorums.get(&isd_type)?; + let entries: Vec = quorums_of_type + .values() + .cloned() + .map(|mut q| { + q.verifying_chain_lock_signature = + Some(VerifyingChainLockSignaturesType::Rotating(sigs)); + q + }) + .collect(); + let cycle_hash = entries.first().map(|q| q.quorum_entry.quorum_hash)?; + if self.rotated_quorums_per_cycle.contains_key(&cycle_hash) { + return None; + } + Some((cycle_hash, entries)) + } + + /// Validate and store rotated quorums from the MN list at the h work block + /// under their cycle boundary hash. This enables IS lock verification for + /// the previous cycle after a fresh sync where `lastCommitmentPerIndex` + /// only provides the current cycle's quorums. + /// + /// The 4 CL sigs `[sig_h_minus_3c, sig_h_minus_2c, sig_h_minus_c, sig_h]` + /// captured by `feed_qr_info` align with the h-c cycle's quarters: the + /// per-diff extraction yields sig_h_minus_3c from h-4c work block (quarter + /// 0), sig_h_minus_2c from h-3c (quarter 1), sig_h_minus_c from h-2c + /// (quarter 2), sig_h from h-c (quarter 3). + #[cfg(feature = "quorum_validation")] + fn validate_and_store_previous_cycle_quorums( + &mut self, + work_block_hash: BlockHash, + sigs: [BLSSignature; 4], + ) -> Result<(), QuorumValidationError> { + let isd_type = self.network.isd_llmq_type(); + let Some((cycle_hash, mut entries)) = + self.try_load_previous_cycle_entries(work_block_hash, isd_type, sigs) + else { + return Ok(()); + }; + + let validation_statuses = self.validate_rotation_cycle_quorums_validation_statuses( + entries.iter().collect::>().as_slice(), + ); + let mut all_verified = true; + for entry in entries.iter_mut() { + entry.verified = validation_statuses + .get(&entry.quorum_entry.quorum_hash) + .cloned() + .unwrap_or_default(); + match entry.verified { + LLMQEntryVerificationStatus::Verified => {} + LLMQEntryVerificationStatus::Invalid(ref e) => return Err(e.clone()), + _ => { + // Can't fully validate (e.g. MN list at a required work + // block is missing because it's deeper than the QRInfo's + // diff range). Don't store unverified quorums: IS lock + // verification for this cycle will fail, which is correct. + tracing::debug!( + "Previous-cycle quorum {} at cycle {} could not be validated ({}); skipping storage", + entry.quorum_entry.quorum_hash, + cycle_hash, + entry.verified + ); + all_verified = false; + break; + } + } + } + if !all_verified { + return Ok(()); + } + + // Mirror statuses into `quorum_statuses` and the MN list. + let mut updates: Vec<(BTreeSet, QuorumHash, LLMQEntryVerificationStatus)> = + Vec::new(); + { + let statuses_for_type = self.quorum_statuses.entry(isd_type).or_default(); + for entry in entries.iter() { + let (heights, _, status) = + statuses_for_type.entry(entry.quorum_entry.quorum_hash).or_insert(( + BTreeSet::default(), + entry.quorum_entry.quorum_public_key, + LLMQEntryVerificationStatus::Unknown, + )); + updates.push(( + heights.clone(), + entry.quorum_entry.quorum_hash, + entry.verified.clone(), + )); + *status = entry.verified.clone(); + } + } + for (heights, quorum_hash, new_status) in updates { + for height in heights { + if let Some(list_at_height) = self.masternode_lists.get_mut(&height) + && let Some(quorum_at_height) = list_at_height + .quorums + .get_mut(&isd_type) + .and_then(|qs| qs.get_mut(&quorum_hash)) + { + quorum_at_height.verified = new_status.clone(); + } + } + } + + let cycle_map = build_cycle_quorum_map(entries, isd_type)?; + self.rotated_quorums_per_cycle.insert(cycle_hash, cycle_map); + tracing::debug!( + "Validated and stored previous-cycle rotated quorums under cycle hash {}", + cycle_hash + ); + Ok(()) + } + /// Block hashes referenced by a QRInfo message that the engine needs heights for. /// /// Covers every diff endpoint (base and target) and every rotating commitment hash @@ -504,7 +719,7 @@ impl MasternodeListEngine { qr_info: QRInfo, verify_tip_non_rotated_quorums: bool, verify_rotated_quorums: bool, - ) -> Result<(), QuorumValidationError> { + ) -> Result, QuorumValidationError> { #[allow(unused_variables)] let QRInfo { quorum_snapshot_at_h_minus_c, @@ -521,6 +736,15 @@ impl MasternodeListEngine { mn_list_diff_list, } = qr_info; + // When the tip is mid-cycle (before the DKG commitment is mined), + // `sig_tip` will naturally be `None` but isn't needed for validating + // the historical rotation quorums in `last_commitment_per_index`. + // The check below selects which 4-sig tuple `rotation_sigs` aligns + // to: current cycle when this is true, previous cycle otherwise. + #[cfg(feature = "quorum_validation")] + let tip_diff_has_rotating_quorums = + mn_list_diff_tip.new_quorums.iter().any(|q| q.llmq_type.is_rotating_quorum_type()); + // Apply quorum snapshots and masternode list diffs for (snapshot, diff) in quorum_snapshot_list.into_iter().zip(mn_list_diff_list) { self.known_snapshots.insert(diff.block_hash, snapshot); @@ -552,6 +776,14 @@ impl MasternodeListEngine { .map(|quorum_entry| quorum_entry.llmq_type) .unwrap_or(self.network.isd_llmq_type()); + #[cfg(feature = "quorum_validation")] + let mut stored_cycle_height: Option = None; + #[cfg(feature = "quorum_validation")] + let rotated_quorum_count = last_commitment_per_index.len(); + #[cfg(feature = "quorum_validation")] + let mut newly_qualified_count: usize = 0; + #[cfg(feature = "quorum_validation")] + let mut fully_verified_count: usize = 0; if let Some((quorum_snapshot_at_h_minus_4c, mn_list_diff_at_h_minus_4c)) = quorum_snapshot_and_mn_list_diff_at_h_minus_4c { @@ -562,32 +794,86 @@ impl MasternodeListEngine { self.known_snapshots .insert(mn_list_diff_at_h_minus_3c.block_hash, quorum_snapshot_at_h_minus_3c); - self.apply_diff(mn_list_diff_at_h_minus_3c, None, false, None)?; + let maybe_sig_h_minus_3c = + self.apply_diff(mn_list_diff_at_h_minus_3c, None, false, None)?; self.known_snapshots .insert(mn_list_diff_at_h_minus_2c.block_hash, quorum_snapshot_at_h_minus_2c); - #[cfg(feature = "quorum_validation")] - let mn_list_diff_at_h_minus_2c_block_hash = mn_list_diff_at_h_minus_2c.block_hash; - let maybe_sigm2 = self.apply_diff(mn_list_diff_at_h_minus_2c, None, false, None)?; + let maybe_sig_h_minus_2c = + self.apply_diff(mn_list_diff_at_h_minus_2c, None, false, None)?; self.known_snapshots .insert(mn_list_diff_at_h_minus_c.block_hash, quorum_snapshot_at_h_minus_c); - #[cfg(feature = "quorum_validation")] - let mn_list_diff_at_h_minus_c_block_hash = mn_list_diff_at_h_minus_c.block_hash; - let maybe_sigm1 = self.apply_diff(mn_list_diff_at_h_minus_c, None, false, None)?; - #[cfg(feature = "quorum_validation")] - let mn_list_diff_at_h_block_hash = mn_list_diff_h.block_hash; - let maybe_sigm0 = self.apply_diff(mn_list_diff_h, None, false, None)?; - - let sigs = match (maybe_sigm2, maybe_sigm1, maybe_sigm0) { + let maybe_sig_h_minus_c = self.apply_diff(mn_list_diff_at_h_minus_c, None, false, None)?; + // The h-c cycle's rotated quorums live on `masternode_lists[h]` (mined + // in the `(h-c, h]` diff range), not on `masternode_lists[h-c]` where + // the cycle two back resides; using h here is load-bearing for the + // sig alignment in `validate_and_store_previous_cycle_quorums`. + let work_block_hash_h = mn_list_diff_h.block_hash; + let maybe_sig_h = self.apply_diff(mn_list_diff_h, None, false, None)?; + + let sigs = match (maybe_sig_h_minus_2c, maybe_sig_h_minus_c, maybe_sig_h) { (Some(s2), Some(s1), Some(s0)) => Some([s2, s1, s0]), _ => None, }; - #[allow(unused_variables)] - let mn_list_diff_tip_block_hash = mn_list_diff_tip.block_hash; - #[allow(unused_variables)] - let maybe_sigmtip = + let maybe_sig_tip = self.apply_diff(mn_list_diff_tip, None, verify_tip_non_rotated_quorums, sigs)?; + // The 4 historical sigs `[sig_h_minus_3c..sig_h]` align with the h-c + // cycle's 4 quarters (work blocks h-4c-8, h-3c-8, h-2c-8, h-c-8). + #[cfg(feature = "quorum_validation")] + if let (Some(s3), Some(s2), Some(s1), Some(s0)) = + (maybe_sig_h_minus_3c, maybe_sig_h_minus_2c, maybe_sig_h_minus_c, maybe_sig_h) + { + self.validate_and_store_previous_cycle_quorums(work_block_hash_h, [s3, s2, s1, s0])?; + } + + // Missing CL sigs are a legitimate state when a diff spans a range + // with no successful rotating DKG: Dash Core's `BuildQuorumsDiff` only + // emits mined-AND-active commitments into `new_quorums`, so failed + // or null DKG commits leave `new_quorums` empty for that diff and + // `apply_diff` returns no rotating sig for it. We log once and leave + // `rotation_sigs = None`. Affected entries in `last_commitment_per_index` + // are recorded without `VerifyingChainLockSignaturesType::Rotating` and + // end up `Skipped(MissingRotationChainLockSigs)`, which keeps + // them out of `rotated_quorums_per_cycle`. IS locks signed under such a + // cycle cannot be verified by this SPV until a later QRInfo arrives + // with complete sigs. + #[cfg(feature = "quorum_validation")] + let rotation_sigs: Option<[BLSSignature; 4]> = { + let any_rotated_quorum_needs_fresh_validation = last_commitment_per_index + .iter() + .any(|quorum_entry| self.known_qualified_quorum_entry(quorum_entry).is_none()); + if !any_rotated_quorum_needs_fresh_validation { + None + } else if tip_diff_has_rotating_quorums { + // Tip has new rotating quorums: `lastCommitmentPerIndex` is + // from the current cycle. The 4 sigs align as + // `[sig_h_minus_2c, sig_h_minus_c, sig_h, sig_tip]`. + collect_rotation_sigs( + [ + (maybe_sig_h_minus_2c, RotationChainLockSignatureSlot::HMinus2c), + (maybe_sig_h_minus_c, RotationChainLockSignatureSlot::HMinusC), + (maybe_sig_h, RotationChainLockSignatureSlot::H), + (maybe_sig_tip, RotationChainLockSignatureSlot::Tip), + ], + "current-cycle", + ) + } else { + // Tip has no new rotating quorums: `lastCommitmentPerIndex` + // is from the previous cycle. The 4 sigs come from the + // historical diffs `[sig_h_minus_3c, sig_h_minus_2c, sig_h_minus_c, sig_h]`. + collect_rotation_sigs( + [ + (maybe_sig_h_minus_3c, RotationChainLockSignatureSlot::HMinus3c), + (maybe_sig_h_minus_2c, RotationChainLockSignatureSlot::HMinus2c), + (maybe_sig_h_minus_c, RotationChainLockSignatureSlot::HMinusC), + (maybe_sig_h, RotationChainLockSignatureSlot::H), + ], + "previous-cycle", + ) + } + }; + #[cfg(feature = "quorum_validation")] let mut qualified_last_commitment_per_index = last_commitment_per_index .into_iter() @@ -597,37 +883,17 @@ impl MasternodeListEngine { { Ok(qualified_quorum_entry) } else { - let sigm2 = maybe_sigm2.ok_or( - QuorumValidationError::RequiredRotatedChainLockSigNotPresent( - 3, - mn_list_diff_at_h_minus_2c_block_hash, - ), - )?; - - let sigm1 = maybe_sigm1.ok_or( - QuorumValidationError::RequiredRotatedChainLockSigNotPresent( - 2, - mn_list_diff_at_h_minus_c_block_hash, - ), - )?; - - let sigm0 = maybe_sigm0.ok_or( - QuorumValidationError::RequiredRotatedChainLockSigNotPresent( - 1, - mn_list_diff_at_h_block_hash, - ), - )?; - let sigmtip = maybe_sigmtip.ok_or( - QuorumValidationError::RequiredRotatedChainLockSigNotPresent( - 0, - mn_list_diff_tip_block_hash, - ), - )?; + newly_qualified_count += 1; let mut qualified_quorum_entry: QualifiedQuorumEntry = quorum_entry.into(); - qualified_quorum_entry.verifying_chain_lock_signature = - Some(VerifyingChainLockSignaturesType::Rotating([ - sigm2, sigm1, sigm0, sigmtip, - ])); + if let Some([sig_h_minus_2c, sig_h_minus_c, sig_h, sig_tip]) = rotation_sigs { + qualified_quorum_entry.verifying_chain_lock_signature = + Some(VerifyingChainLockSignaturesType::Rotating([ + sig_h_minus_2c, + sig_h_minus_c, + sig_h, + sig_tip, + ])); + } Ok(qualified_quorum_entry) } }) @@ -635,10 +901,6 @@ impl MasternodeListEngine { #[cfg(feature = "quorum_validation")] if verify_rotated_quorums { - let validation_statuses = self.validate_rotation_cycle_quorums_validation_statuses( - qualified_last_commitment_per_index.iter().collect::>().as_slice(), - ); - let mut updates: Vec<( BTreeSet, LLMQType, @@ -646,8 +908,9 @@ impl MasternodeListEngine { LLMQEntryVerificationStatus, )> = Vec::new(); - let cycle_key = - qualified_last_commitment_per_index.first().map(|q| q.quorum_entry.quorum_hash); + let validation_statuses = self.validate_rotation_cycle_quorums_validation_statuses( + qualified_last_commitment_per_index.iter().collect::>().as_slice(), + ); for rotated_quorum in qualified_last_commitment_per_index.iter_mut() { tracing::debug!( @@ -662,7 +925,15 @@ impl MasternodeListEngine { .cloned() .unwrap_or_default(); - // Store status updates separately to prevent multiple mutable borrows + if let LLMQEntryVerificationStatus::Invalid(ref e) = rotated_quorum.verified { + return Err(e.clone()); + } + } + + let cycle_key = + qualified_last_commitment_per_index.first().map(|q| q.quorum_entry.quorum_hash); + + for rotated_quorum in qualified_last_commitment_per_index.iter() { let masternode_lists_having_quorum_hash_for_quorum_type = self.quorum_statuses.entry(rotated_quorum.quorum_entry.llmq_type).or_default(); let (heights, _, status) = masternode_lists_having_quorum_hash_for_quorum_type @@ -683,12 +954,38 @@ impl MasternodeListEngine { *status = rotated_quorum.verified.clone(); } + fully_verified_count = qualified_last_commitment_per_index + .iter() + .filter(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)) + .count(); + if let Some(key) = cycle_key { - let cycle_map = build_cycle_quorum_map( - qualified_last_commitment_per_index, - rotation_quorum_type, - )?; - *self.rotated_quorums_per_cycle.entry(key).or_default() = cycle_map; + // `rotated_quorums_per_cycle` is the authoritative map for IS + // lock verification, so only store a cycle when every entry is + // `Verified`. Skipped entries (e.g. from incomplete CL sigs or + // missing context) cannot sign and must not enter the map. A + // later QRInfo with complete context will store the cycle. + // Also preserve an already-fully-Verified cycle across + // subsequent QRInfo responses: a thin `mn_list_diff_h` can + // produce Skipped entries that must not downgrade it. + let already_fully_verified = + self.rotated_quorums_per_cycle.get(&key).is_some_and(|existing| { + !existing.is_empty() + && existing.values().all(|q| { + matches!(q.verified, LLMQEntryVerificationStatus::Verified) + }) + }); + let all_entries_verified = qualified_last_commitment_per_index + .iter() + .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)); + if all_entries_verified && !already_fully_verified { + let cycle_map = build_cycle_quorum_map( + qualified_last_commitment_per_index, + rotation_quorum_type, + )?; + *self.rotated_quorums_per_cycle.entry(key).or_default() = cycle_map; + stored_cycle_height = self.block_container.get_height(&key); + } } // Apply collected updates after iteration to avoid borrow conflicts @@ -783,9 +1080,27 @@ impl MasternodeListEngine { } else if let Some(cycle_key) = qualified_last_commitment_per_index.first().map(|q| q.quorum_entry.quorum_hash) { - let cycle_map = - build_cycle_quorum_map(qualified_last_commitment_per_index, rotation_quorum_type)?; - *self.rotated_quorums_per_cycle.entry(cycle_key).or_default() = cycle_map; + fully_verified_count = qualified_last_commitment_per_index + .iter() + .filter(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)) + .count(); + // Never overwrite an already-fully-Verified cycle with unverified entries from a + // `verify_rotated_quorums == false` call. + let already_fully_verified = + self.rotated_quorums_per_cycle.get(&cycle_key).is_some_and(|existing| { + !existing.is_empty() + && existing + .values() + .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)) + }); + if !already_fully_verified { + let cycle_map = build_cycle_quorum_map( + qualified_last_commitment_per_index, + rotation_quorum_type, + )?; + *self.rotated_quorums_per_cycle.entry(cycle_key).or_default() = cycle_map; + stored_cycle_height = self.block_container.get_height(&cycle_key); + } } #[cfg(not(feature = "quorum_validation"))] @@ -795,7 +1110,19 @@ impl MasternodeListEngine { )); } - Ok(()) + #[cfg(feature = "quorum_validation")] + { + Ok(Some(QRInfoFeedResult { + rotated_quorum_count, + newly_qualified_count, + fully_verified_count, + stored_cycle_height, + })) + } + #[cfg(not(feature = "quorum_validation"))] + { + Ok(None) + } } /// Applies a masternode list diff to create or update a masternode list. @@ -1102,11 +1429,14 @@ mod tests { use crate::network::message_qrinfo::QRInfo; use crate::network::message_sml::MnListDiff; use crate::prelude::CoreBlockHeight; - use crate::sml::llmq_entry_verification::LLMQEntryVerificationStatus; + use crate::sml::llmq_entry_verification::{ + LLMQEntryVerificationSkipStatus, LLMQEntryVerificationStatus, + }; use crate::sml::llmq_type::LLMQType; use crate::sml::llmq_type::LLMQType::{ Llmqtype50_60, Llmqtype60_75, Llmqtype400_60, Llmqtype400_85, }; + use crate::sml::llmq_type::network::NetworkLLMQExt; use crate::sml::masternode_list::MasternodeList; use crate::sml::masternode_list_engine::{ MasternodeListEngine, MasternodeListEngineBlockContainer, @@ -1121,9 +1451,12 @@ mod tests { #[cfg(feature = "quorum_validation")] use { super::build_cycle_quorum_map, + crate::BlockHash, crate::QuorumHash, + crate::Transaction, crate::bls_sig_utils::{BLSPublicKey, BLSSignature}, crate::hash_types::QuorumVVecHash, + crate::network::message_qrinfo::{MNSkipListMode, QuorumSnapshot}, crate::transaction::special_transaction::quorum_commitment::QuorumEntry, }; @@ -1319,13 +1652,12 @@ mod tests { } } - #[test] - fn validate_from_qr_info_and_mn_list_diffs() { + #[cfg(feature = "quorum_validation")] + fn load_qrinfo_2240504_fixture() -> (MasternodeListEngine, QRInfo) { let mn_list_diff_bytes: &[u8] = include_bytes!("../../../tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin"); - // This one is serialized not with bincode, but with core consensus let diff: MnListDiff = deserialize(mn_list_diff_bytes).expect("expected to deserialize"); - let mut masternode_list_engine = + let mut engine = MasternodeListEngine::initialize_with_diff_to_height(diff, 2227096, Network::Mainnet) .expect("expected to start engine"); @@ -1348,17 +1680,45 @@ mod tests { .expect("expected to decode") .0; - // We know these are the blocks we need to know about. - masternode_list_engine.block_container = block_container; - + engine.block_container = block_container; for ((_start_height, height), diff) in mn_list_diffs.into_iter() { - masternode_list_engine - .apply_diff(diff, Some(height), false, None) - .expect("expected to apply diff"); + engine.apply_diff(diff, Some(height), false, None).expect("expected to apply diff"); } + (engine, qr_info) + } + + #[test] + fn validate_from_qr_info_and_mn_list_diffs() { + let (mut masternode_list_engine, qr_info) = load_qrinfo_2240504_fixture(); + + // The 2240504 fixture exercises the current-cycle storage branch + // (rotating quorums in `mn_list_diff_tip`). A fixture swap that flips + // this assertion changes which code path the test covers. + let tip_diff_has_rotating_quorums = qr_info + .mn_list_diff_tip + .new_quorums + .iter() + .any(|q| q.llmq_type.is_rotating_quorum_type()); + assert!( + tip_diff_has_rotating_quorums, + "fixture invariant: 2240504 QRInfo must have rotating quorums in mn_list_diff_tip; \ + swap fixture or update assertion if this changes" + ); + masternode_list_engine.feed_qr_info(qr_info, true, true).expect("expected to feed_qr_info"); + // Both cycles must be stored: the current cycle from + // `last_commitment_per_index` and the previous cycle from + // `validate_and_store_previous_cycle_quorums`. The previous-cycle + // path uses `masternode_lists[h]`, not `masternode_lists[h-c]`, + // because the h-c cycle is only mined in the `(h-c, h]` diff range. + assert_eq!( + masternode_list_engine.rotated_quorums_per_cycle.len(), + 2, + "expected both tip and previous rotation cycles stored" + ); + verify_masternode_list_quorums( &masternode_list_engine, masternode_list_engine @@ -1368,6 +1728,21 @@ mod tests { .1, &[Llmqtype400_85, Llmqtype50_60, Llmqtype400_60], ); + + // Every stored rotated quorum must re-validate under its captured CL + // sigs. If the previous-cycle path picked the wrong work block, the + // reconstructed members would not match the real signers and + // `validate_quorum` would return `AllCommitmentAggregatedSignatureNotValid`. + for (cycle_hash, quorums) in masternode_list_engine.rotated_quorums_per_cycle.iter() { + for (index, quorum) in quorums.iter() { + masternode_list_engine.validate_quorum(quorum).unwrap_or_else(|e| { + panic!( + "stored rotated quorum at index {} in cycle {} failed re-validation: {}", + index, cycle_hash, e + ) + }); + } + } } #[test] @@ -1482,39 +1857,7 @@ mod tests { #[test] fn feed_qr_info_rejects_post_v20_with_missing_chainlock_signatures() { - let mn_list_diff_bytes: &[u8] = - include_bytes!("../../../tests/data/test_DML_diffs/mn_list_diff_0_2227096.bin"); - let diff: MnListDiff = deserialize(mn_list_diff_bytes).expect("expected to deserialize"); - let mut masternode_list_engine = - MasternodeListEngine::initialize_with_diff_to_height(diff, 2227096, Network::Mainnet) - .expect("expected to start engine"); - - let block_container_bytes: &[u8] = - include_bytes!("../../../tests/data/test_DML_diffs/block_container_2240504.dat"); - let block_container: MasternodeListEngineBlockContainer = - bincode::decode_from_slice(block_container_bytes, bincode::config::standard()) - .expect("expected to decode") - .0; - let mn_list_diffs_bytes: &[u8] = - include_bytes!("../../../tests/data/test_DML_diffs/mnlistdiffs_2240504.dat"); - let mn_list_diffs: BTreeMap<(CoreBlockHeight, CoreBlockHeight), MnListDiff> = - bincode::decode_from_slice(mn_list_diffs_bytes, bincode::config::standard()) - .expect("expected to decode") - .0; - let qr_info_bytes: &[u8] = - include_bytes!("../../../tests/data/test_DML_diffs/qrinfo_2240504.dat"); - let mut qr_info: QRInfo = - bincode::decode_from_slice(qr_info_bytes, bincode::config::standard()) - .expect("expected to decode") - .0; - - masternode_list_engine.block_container = block_container; - - for ((_start_height, height), diff) in mn_list_diffs.into_iter() { - masternode_list_engine - .apply_diff(diff, Some(height), false, None) - .expect("expected to apply diff"); - } + let (mut masternode_list_engine, mut qr_info) = load_qrinfo_2240504_fixture(); // Clear chainlock signatures to simulate missing data for post-V20 block qr_info.mn_list_diff_at_h_minus_2c.quorums_chainlock_signatures.clear(); @@ -1526,5 +1869,271 @@ mod tests { result.is_err(), "Post-V20 feed_qr_info should reject missing chainlock signatures" ); + assert!( + masternode_list_engine.rotated_quorums_per_cycle.is_empty(), + "Rejected QRInfo must not have stored any rotation cycle" + ); + } + + /// Storage gate: when a QRInfo carries no rotation chain-lock signatures + /// and rotated quorums in `last_commitment_per_index` would need fresh + /// validation, the cycle must NOT enter `rotated_quorums_per_cycle`. The + /// validated entries are recorded as `Skipped` and storage is skipped. + #[cfg(feature = "quorum_validation")] + #[test] + fn feed_qr_info_does_not_store_cycle_when_rotation_sigs_missing() { + let (mut engine, mut qr_info) = load_qrinfo_2240504_fixture(); + + // The post-V20 strict check requires every `new_quorums` slot to have + // a matching `quorums_chainlock_signatures` entry; clearing both keeps + // `apply_diff` happy while ensuring no per-diff rotating sig is captured. + let strip = |diff: &mut MnListDiff| { + diff.new_quorums.clear(); + diff.quorums_chainlock_signatures.clear(); + }; + strip(&mut qr_info.mn_list_diff_tip); + strip(&mut qr_info.mn_list_diff_h); + strip(&mut qr_info.mn_list_diff_at_h_minus_c); + strip(&mut qr_info.mn_list_diff_at_h_minus_2c); + strip(&mut qr_info.mn_list_diff_at_h_minus_3c); + if let Some((_, ref mut diff)) = qr_info.quorum_snapshot_and_mn_list_diff_at_h_minus_4c { + strip(diff); + } + for diff in qr_info.mn_list_diff_list.iter_mut() { + strip(diff); + } + + // The cycle key from `last_commitment_per_index` must still be present + // so the post-feed assertion is meaningful. + let expected_cycle_key = qr_info + .last_commitment_per_index + .first() + .map(|q| q.quorum_hash) + .expect("fixture has rotation commitments"); + + let isd_type = engine.network.isd_llmq_type(); + + engine + .feed_qr_info(qr_info, false, true) + .expect("feed_qr_info should succeed even when rotation sigs are missing"); + + assert!( + !engine.rotated_quorums_per_cycle.contains_key(&expected_cycle_key), + "Cycle {} must not be stored when rotation sigs are missing; current keys: {:?}", + expected_cycle_key, + engine.rotated_quorums_per_cycle.keys().collect::>() + ); + + // The skip status must be `MissingRotationChainLockSigs` specifically. + // Storage could be blocked for unrelated reasons (e.g. a different + // skip variant from missing context); this assertion proves the test + // exercises the intended soft-skip path. + let entry_status = engine + .quorum_statuses + .get(&isd_type) + .and_then(|m| m.get(&expected_cycle_key)) + .map(|(_, _, status)| status.clone()); + assert!( + matches!( + entry_status, + Some(LLMQEntryVerificationStatus::Skipped( + LLMQEntryVerificationSkipStatus::MissingRotationChainLockSigs(_) + )) + ), + "expected MissingRotationChainLockSigs skip for {}, got {:?}", + expected_cycle_key, + entry_status + ); + } + + /// A QRInfo whose rotated quorum carries a corrupt + /// `all_commitment_aggregated_signature` must be rejected with + /// `AllCommitmentAggregatedSignatureNotValid` rather than silently + /// stored: a fully-Verified entry with an invalid aggregate signature + /// would let bogus signed messages pass IS lock verification. + #[cfg(feature = "quorum_validation")] + #[test] + fn feed_qr_info_rejects_corrupt_aggregate_signature() { + let (mut engine, mut qr_info) = load_qrinfo_2240504_fixture(); + + // Capture the target cycle key before mutation so we can assert it + // never makes it into `rotated_quorums_per_cycle`. Other cycles (e.g. + // the previous cycle from `validate_and_store_previous_cycle_quorums`) + // may remain since they are stored before the rejection point. + let target_key = qr_info + .last_commitment_per_index + .first() + .map(|q| q.quorum_hash) + .expect("fixture must carry at least one rotation commitment"); + + // Precondition: the engine must know the height for `target_key` so + // member reconstruction reaches the aggregate-signature check rather + // than skipping for `RequiredBlockNotPresent`. Without this guard a + // fixture change could mask a real signature-validation regression. + assert!( + engine.block_container.get_height(&target_key).is_some(), + "fixture must carry block height for {} so the test exercises aggregate-signature validation", + target_key + ); + + // Every other field stays intact so the entry still parses and reaches + // signature validation. + qr_info.last_commitment_per_index[0].all_commitment_aggregated_signature = + BLSSignature::from([0u8; 96]); + + let result = engine.feed_qr_info(qr_info, false, true); + + // Both `AllCommitmentAggregatedSignatureNotValid` (per-quorum check) + // and `InvalidFinalSignature` (rotation-cycle aggregate) are valid + // rejection signals; what matters is that we don't silently store. + let err = result.expect_err("corrupt aggregate signature must reject"); + assert!( + matches!( + err, + QuorumValidationError::AllCommitmentAggregatedSignatureNotValid(_) + | QuorumValidationError::InvalidFinalSignature + ), + "expected aggregate-signature rejection, got {:?}", + err + ); + let corrupted_cycle_key = + engine.rotated_quorums_per_cycle.keys().copied().collect::>(); + assert!( + !corrupted_cycle_key.contains(&target_key), + "rejected QRInfo must not have stored cycle keyed at {} (stored keys: {:?})", + target_key, + corrupted_cycle_key + ); + } + + /// Anti-clobber: a thin/incomplete second QRInfo where rotated entries + /// land as `Skipped` (no rotation CL sigs reachable) must not downgrade + /// an already-fully-Verified rotation cycle. Feeds the mainnet fixture + /// once to populate `rotated_quorums_per_cycle[K]` with all entries + /// `Verified`, then feeds a synthetic self-loop QRInfo whose diffs do + /// not carry rotation sigs, with `last_commitment_per_index` matching + /// the existing cycle's first quorum hash plus a fresh fake quorum + /// that needs new validation. Asserts the existing cycle's entries + /// remain `Verified`. + #[cfg(feature = "quorum_validation")] + #[test] + fn feed_qr_info_does_not_downgrade_already_verified_cycle() { + let (mut engine, qr_info) = load_qrinfo_2240504_fixture(); + + engine.feed_qr_info(qr_info, false, true).expect("first feed should succeed"); + + let cycle_key = *engine + .rotated_quorums_per_cycle + .keys() + .next() + .expect("first feed must have stored at least one rotation cycle"); + + let original_cycle = + engine.rotated_quorums_per_cycle.get(&cycle_key).expect("cycle present").clone(); + assert!( + !original_cycle.is_empty() + && original_cycle + .values() + .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)), + "first feed must leave the cycle fully Verified" + ); + + let known_quorum = original_cycle + .values() + .find(|q| q.quorum_entry.quorum_hash == cycle_key) + .expect("cycle map must contain its key entry") + .clone(); + let rotation_quorum_type = known_quorum.quorum_entry.llmq_type; + let active_count = rotation_quorum_type.active_quorum_count() as i16; + + // Build `last_commitment_per_index`: first entry is the existing + // cycle key (so cycle_key matches, exercising the gate), rest are + // fresh fakes that need new validation. + let mut last_commitment_per_index = vec![known_quorum.quorum_entry.clone()]; + for i in 1..active_count { + last_commitment_per_index.push(QuorumEntry { + version: 2, + llmq_type: rotation_quorum_type, + quorum_hash: QuorumHash::from_byte_array([0xCC; 32].map(|b| b ^ i as u8)), + quorum_index: Some(i), + signers: vec![true], + valid_members: vec![true], + quorum_public_key: BLSPublicKey::from([0; 48]), + quorum_vvec_hash: QuorumVVecHash::all_zeros(), + threshold_sig: BLSSignature::from([0; 96]), + all_commitment_aggregated_signature: BLSSignature::from([0; 96]), + }); + } + + // Self-loop diffs at the engine's current tip make the second feed a + // no-op for masternode-list state while still exercising the QRInfo + // verification pipeline. + let tip_hash = engine + .masternode_lists + .iter() + .next_back() + .map(|(_, list)| list.block_hash) + .expect("engine has a tip list after first feed"); + let make_self_loop = |hash: BlockHash| MnListDiff { + version: 1, + base_block_hash: hash, + block_hash: hash, + total_transactions: 0, + merkle_hashes: vec![], + merkle_flags: vec![], + coinbase_tx: Transaction { + version: 1, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + deleted_masternodes: vec![], + new_masternodes: vec![], + deleted_quorums: vec![], + new_quorums: vec![], + quorums_chainlock_signatures: vec![], + }; + let empty_snapshot = || QuorumSnapshot { + skip_list_mode: MNSkipListMode::NoSkipping, + active_quorum_members: vec![], + skip_list: vec![], + }; + + let thin_qr_info = QRInfo { + quorum_snapshot_at_h_minus_c: empty_snapshot(), + quorum_snapshot_at_h_minus_2c: empty_snapshot(), + quorum_snapshot_at_h_minus_3c: empty_snapshot(), + mn_list_diff_tip: make_self_loop(tip_hash), + mn_list_diff_h: make_self_loop(tip_hash), + mn_list_diff_at_h_minus_c: make_self_loop(tip_hash), + mn_list_diff_at_h_minus_2c: make_self_loop(tip_hash), + mn_list_diff_at_h_minus_3c: make_self_loop(tip_hash), + quorum_snapshot_and_mn_list_diff_at_h_minus_4c: None, + last_commitment_per_index, + quorum_snapshot_list: vec![], + mn_list_diff_list: vec![], + }; + + let _ = engine.feed_qr_info(thin_qr_info, false, true); + + let after_cycle = engine + .rotated_quorums_per_cycle + .get(&cycle_key) + .expect("cycle must still exist after thin second feed"); + assert_eq!( + after_cycle.len(), + original_cycle.len(), + "thin second feed must not change cycle entry count" + ); + for (idx, entry) in after_cycle { + assert!( + matches!(entry.verified, LLMQEntryVerificationStatus::Verified), + "cycle quorum at index {} (hash {}) must remain Verified after thin second feed, got {}", + idx, + entry.quorum_entry.quorum_hash, + entry.verified + ); + } } } From a60372201a125b85dfa2675f1412b78e3da97ab6 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Wed, 6 May 2026 18:04:22 +1000 Subject: [PATCH 02/11] refactor(dash): drop `expect()` from `collect_rotation_sigs` Replace the provably safe but rule-violating `expect()` with `?` propagation on a destructured array. Behavior is unchanged: the missing-slot debug log still fires before the unwrap path, and `?` on already-checked `Some` values short-circuits cleanly. Addresses CodeRabbit review comment on PR #736 https://github.com/dashpay/rust-dashcore/pull/736#discussion_r3192574900 --- dash/src/sml/masternode_list_engine/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index a178d3da2..cfafb20c8 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -120,7 +120,8 @@ fn collect_rotation_sigs( ); return None; } - Some(slots.map(|(sig, _)| sig.expect("all slots checked above"))) + let [a, b, c, d] = slots.map(|(sig, _)| sig); + Some([a?, b?, c?, d?]) } #[derive(Clone, Eq, PartialEq, Default)] From 84207b84e2f11ea24160a12ef57570528c61dcbc Mon Sep 17 00:00:00 2001 From: xdustinface Date: Wed, 6 May 2026 18:04:49 +1000 Subject: [PATCH 03/11] fix(dash): key previous-cycle storage by index-0 quorum hash `try_load_previous_cycle_entries` derived its `cycle_hash` via `entries.first()` from a `BTreeMap`, so the key was the hash-order minimum rather than the cycle-boundary quorum. Dash Core defines `cycleHash` as the block hash at the cycle-base height, which equals the `quorum_hash` of the index-0 quorum, and IS lock verification looks up `rotated_quorums_per_cycle` with that key. Mis-keyed storage caused silent IS-lock verification failures for the previous cycle. Find the index-0 entry's `quorum_hash` instead, and short-circuit the already-stored check before allocating the `entries` vector. Addresses CodeRabbit review comment on PR #736 https://github.com/dashpay/rust-dashcore/pull/736#discussion_r3192574905 --- dash/src/sml/masternode_list_engine/mod.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index cfafb20c8..124662006 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -545,6 +545,13 @@ impl MasternodeListEngine { let work_height = self.block_container.get_height(&work_block_hash)?; let mn_list = self.masternode_lists.get(&work_height)?; let quorums_of_type = mn_list.quorums.get(&isd_type)?; + let cycle_hash = quorums_of_type + .values() + .find(|q| q.quorum_entry.quorum_index == Some(0)) + .map(|q| q.quorum_entry.quorum_hash)?; + if self.rotated_quorums_per_cycle.contains_key(&cycle_hash) { + return None; + } let entries: Vec = quorums_of_type .values() .cloned() @@ -554,10 +561,6 @@ impl MasternodeListEngine { q }) .collect(); - let cycle_hash = entries.first().map(|q| q.quorum_entry.quorum_hash)?; - if self.rotated_quorums_per_cycle.contains_key(&cycle_hash) { - return None; - } Some((cycle_hash, entries)) } From 3dfd0a880fa066db9ef77bc8e2ecc5e2f7572023 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Wed, 6 May 2026 18:05:14 +1000 Subject: [PATCH 04/11] fix(dash): gate `verify_rotated_quorums == false` storage on all-verified The `else if` branch in `feed_qr_info` skipped verification but still wrote into `rotated_quorums_per_cycle` whenever `already_fully_verified` was false. That contradicted the documented invariant ("a cycle is only stored when every entry is `Verified`") and could poison IS-lock BLS verification with unverified `quorum_public_key` values. Mirror the verified-only gate from the `verify_rotated_quorums == true` path: only store when every entry is `Verified` (which can still hold via cache hits from `known_qualified_quorum_entry`). Addresses CodeRabbit review comment on PR #736 https://github.com/dashpay/rust-dashcore/pull/736#discussion_r3192574908 --- dash/src/sml/masternode_list_engine/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index 124662006..5b920d5f6 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -1097,7 +1097,10 @@ impl MasternodeListEngine { .values() .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)) }); - if !already_fully_verified { + let all_entries_verified = qualified_last_commitment_per_index + .iter() + .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)); + if all_entries_verified && !already_fully_verified { let cycle_map = build_cycle_quorum_map( qualified_last_commitment_per_index, rotation_quorum_type, From b61303ae088ea86d2c12ad278da7ffe01bd22923 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Wed, 6 May 2026 19:57:59 +1000 Subject: [PATCH 05/11] fix(dash): silence unused-variable warnings without `quorum_validation` Gate the per-cycle rotation-sig bindings, the `RotationChainLockSignatureSlot` enum, its `Display` impl, and the `core::fmt` import behind `#[cfg(feature = "quorum_validation")]` so default-feature builds stay warning-free. Addresses CodeRabbit review comment on PR #736 https://github.com/dashpay/rust-dashcore/pull/736#discussion_r3194057271 --- dash/src/sml/masternode_list_engine/mod.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index 5b920d5f6..09aa79fb7 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -6,6 +6,7 @@ mod rotated_quorum_construction; #[cfg(feature = "quorum_validation")] mod validation; +#[cfg(feature = "quorum_validation")] use core::fmt; use std::collections::{BTreeMap, BTreeSet}; @@ -41,6 +42,7 @@ pub const WORK_DIFF_DEPTH: u32 = 8; /// Identifies one of the rotation ChainLock signature slots carried by a QRInfo /// response. Each slot corresponds to one per-cycle diff in the rotation quorum /// formation proof: three historical cycles, the h diff, and the tip. +#[cfg(feature = "quorum_validation")] #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] #[cfg_attr(feature = "bincode", derive(Encode, Decode))] @@ -52,6 +54,7 @@ pub(crate) enum RotationChainLockSignatureSlot { Tip, } +#[cfg(feature = "quorum_validation")] impl fmt::Display for RotationChainLockSignatureSlot { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match self { @@ -798,8 +801,11 @@ impl MasternodeListEngine { self.known_snapshots .insert(mn_list_diff_at_h_minus_3c.block_hash, quorum_snapshot_at_h_minus_3c); + #[cfg(feature = "quorum_validation")] let maybe_sig_h_minus_3c = self.apply_diff(mn_list_diff_at_h_minus_3c, None, false, None)?; + #[cfg(not(feature = "quorum_validation"))] + self.apply_diff(mn_list_diff_at_h_minus_3c, None, false, None)?; self.known_snapshots .insert(mn_list_diff_at_h_minus_2c.block_hash, quorum_snapshot_at_h_minus_2c); let maybe_sig_h_minus_2c = @@ -811,6 +817,7 @@ impl MasternodeListEngine { // in the `(h-c, h]` diff range), not on `masternode_lists[h-c]` where // the cycle two back resides; using h here is load-bearing for the // sig alignment in `validate_and_store_previous_cycle_quorums`. + #[cfg(feature = "quorum_validation")] let work_block_hash_h = mn_list_diff_h.block_hash; let maybe_sig_h = self.apply_diff(mn_list_diff_h, None, false, None)?; @@ -819,8 +826,11 @@ impl MasternodeListEngine { _ => None, }; + #[cfg(feature = "quorum_validation")] let maybe_sig_tip = self.apply_diff(mn_list_diff_tip, None, verify_tip_non_rotated_quorums, sigs)?; + #[cfg(not(feature = "quorum_validation"))] + self.apply_diff(mn_list_diff_tip, None, verify_tip_non_rotated_quorums, sigs)?; // The 4 historical sigs `[sig_h_minus_3c..sig_h]` align with the h-c // cycle's 4 quarters (work blocks h-4c-8, h-3c-8, h-2c-8, h-c-8). From 111897b20679c645cd2f3f78f9b87e148bcb091f Mon Sep 17 00:00:00 2001 From: xdustinface Date: Wed, 6 May 2026 19:58:14 +1000 Subject: [PATCH 06/11] fix(dash): gate `feed_qr_info_rejects_post_v20_with_missing_chainlock_signatures` test on `quorum_validation` The test calls `load_qrinfo_2240504_fixture` which is gated on `quorum_validation`. Without the matching gate the test fails to compile when the feature is off. Addresses CodeRabbit review comment on PR #736 https://github.com/dashpay/rust-dashcore/pull/736#discussion_r3194057281 --- dash/src/sml/masternode_list_engine/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index 09aa79fb7..fdf92d220 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -1872,6 +1872,7 @@ mod tests { } } + #[cfg(feature = "quorum_validation")] #[test] fn feed_qr_info_rejects_post_v20_with_missing_chainlock_signatures() { let (mut masternode_list_engine, mut qr_info) = load_qrinfo_2240504_fixture(); From 1b742e1b6dfa35b01b627647d09517fa240c8e56 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Wed, 6 May 2026 20:09:03 +1000 Subject: [PATCH 07/11] refactor(dash): extract `store_cycle_if_fully_verified` from `feed_qr_info` Both the `verify_rotated_quorums == true` and `== false` branches in `feed_qr_info` had identical logic for gating storage of a rotation cycle into `rotated_quorums_per_cycle`: check that every entry is `Verified`, refuse to overwrite a cycle that is already fully verified, then build the cycle map and record the stored height. Move that logic into a single helper so the two call sites stay aligned. --- dash/src/sml/masternode_list_engine/mod.rs | 93 +++++++++++----------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index fdf92d220..8d4fe0cfb 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -707,6 +707,43 @@ impl MasternodeListEngine { hashes } + /// `rotated_quorums_per_cycle` is the authoritative map for IS lock + /// verification, so only store a cycle when every entry is `Verified`. + /// Skipped entries (e.g. from incomplete CL sigs or missing context) cannot + /// sign and must not enter the map. A later QRInfo with complete context + /// will store the cycle. Also preserve an already-fully-Verified cycle + /// across subsequent QRInfo responses: a thin `mn_list_diff_h` can produce + /// Skipped entries, and a `verify_rotated_quorums == false` call can leave + /// entries unverified, neither of which must downgrade it. + /// + /// Returns the height of the stored cycle, or `None` if storage was + /// skipped because the gate did not fire. + #[cfg(feature = "quorum_validation")] + fn store_cycle_if_fully_verified( + &mut self, + cycle_key: BlockHash, + qualified_last_commitment_per_index: Vec, + rotation_quorum_type: LLMQType, + ) -> Result, QuorumValidationError> { + let already_fully_verified = + self.rotated_quorums_per_cycle.get(&cycle_key).is_some_and(|existing| { + !existing.is_empty() + && existing + .values() + .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)) + }); + let all_entries_verified = qualified_last_commitment_per_index + .iter() + .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)); + if !all_entries_verified || already_fully_verified { + return Ok(None); + } + let cycle_map = + build_cycle_quorum_map(qualified_last_commitment_per_index, rotation_quorum_type)?; + *self.rotated_quorums_per_cycle.entry(cycle_key).or_default() = cycle_map; + Ok(self.block_container.get_height(&cycle_key)) + } + /// Processes and applies a QRInfo message to the masternode list engine. /// /// The caller is expected to pre-populate [`Self::block_container`] with heights @@ -974,32 +1011,11 @@ impl MasternodeListEngine { .count(); if let Some(key) = cycle_key { - // `rotated_quorums_per_cycle` is the authoritative map for IS - // lock verification, so only store a cycle when every entry is - // `Verified`. Skipped entries (e.g. from incomplete CL sigs or - // missing context) cannot sign and must not enter the map. A - // later QRInfo with complete context will store the cycle. - // Also preserve an already-fully-Verified cycle across - // subsequent QRInfo responses: a thin `mn_list_diff_h` can - // produce Skipped entries that must not downgrade it. - let already_fully_verified = - self.rotated_quorums_per_cycle.get(&key).is_some_and(|existing| { - !existing.is_empty() - && existing.values().all(|q| { - matches!(q.verified, LLMQEntryVerificationStatus::Verified) - }) - }); - let all_entries_verified = qualified_last_commitment_per_index - .iter() - .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)); - if all_entries_verified && !already_fully_verified { - let cycle_map = build_cycle_quorum_map( - qualified_last_commitment_per_index, - rotation_quorum_type, - )?; - *self.rotated_quorums_per_cycle.entry(key).or_default() = cycle_map; - stored_cycle_height = self.block_container.get_height(&key); - } + stored_cycle_height = self.store_cycle_if_fully_verified( + key, + qualified_last_commitment_per_index, + rotation_quorum_type, + )?; } // Apply collected updates after iteration to avoid borrow conflicts @@ -1098,26 +1114,11 @@ impl MasternodeListEngine { .iter() .filter(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)) .count(); - // Never overwrite an already-fully-Verified cycle with unverified entries from a - // `verify_rotated_quorums == false` call. - let already_fully_verified = - self.rotated_quorums_per_cycle.get(&cycle_key).is_some_and(|existing| { - !existing.is_empty() - && existing - .values() - .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)) - }); - let all_entries_verified = qualified_last_commitment_per_index - .iter() - .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)); - if all_entries_verified && !already_fully_verified { - let cycle_map = build_cycle_quorum_map( - qualified_last_commitment_per_index, - rotation_quorum_type, - )?; - *self.rotated_quorums_per_cycle.entry(cycle_key).or_default() = cycle_map; - stored_cycle_height = self.block_container.get_height(&cycle_key); - } + stored_cycle_height = self.store_cycle_if_fully_verified( + cycle_key, + qualified_last_commitment_per_index, + rotation_quorum_type, + )?; } #[cfg(not(feature = "quorum_validation"))] From 4c5459efc09dfb894888452258e8967b069547da Mon Sep 17 00:00:00 2001 From: xdustinface Date: Wed, 6 May 2026 20:17:51 +1000 Subject: [PATCH 08/11] refactor(dash): unify cycle-already-verified check via `is_cycle_fully_verified` `store_cycle_if_fully_verified` and `try_load_previous_cycle_entries` both decide whether to skip work based on what is already in `rotated_quorums_per_cycle`, but the predicates had drifted. The storage gate required a non-empty cycle with every entry `Verified`, while the previous-cycle loader only checked `contains_key`. The two now share a single `is_cycle_fully_verified` helper so the previous-cycle path also refuses to short-circuit on a partial entry. --- dash/src/sml/masternode_list_engine/mod.rs | 25 ++++++++++++++-------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index 8d4fe0cfb..3d0602d90 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -552,7 +552,7 @@ impl MasternodeListEngine { .values() .find(|q| q.quorum_entry.quorum_index == Some(0)) .map(|q| q.quorum_entry.quorum_hash)?; - if self.rotated_quorums_per_cycle.contains_key(&cycle_hash) { + if self.is_cycle_fully_verified(&cycle_hash) { return None; } let entries: Vec = quorums_of_type @@ -707,6 +707,20 @@ impl MasternodeListEngine { hashes } + /// `true` iff `rotated_quorums_per_cycle` already holds a non-empty cycle + /// for `cycle_hash` whose every entry is `Verified`. Used by the storage + /// gate to refuse downgrading a cycle and by the previous-cycle revalidation + /// path to skip work that would not improve trust. + #[cfg(feature = "quorum_validation")] + fn is_cycle_fully_verified(&self, cycle_hash: &BlockHash) -> bool { + self.rotated_quorums_per_cycle.get(cycle_hash).is_some_and(|existing| { + !existing.is_empty() + && existing + .values() + .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)) + }) + } + /// `rotated_quorums_per_cycle` is the authoritative map for IS lock /// verification, so only store a cycle when every entry is `Verified`. /// Skipped entries (e.g. from incomplete CL sigs or missing context) cannot @@ -725,17 +739,10 @@ impl MasternodeListEngine { qualified_last_commitment_per_index: Vec, rotation_quorum_type: LLMQType, ) -> Result, QuorumValidationError> { - let already_fully_verified = - self.rotated_quorums_per_cycle.get(&cycle_key).is_some_and(|existing| { - !existing.is_empty() - && existing - .values() - .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)) - }); let all_entries_verified = qualified_last_commitment_per_index .iter() .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)); - if !all_entries_verified || already_fully_verified { + if !all_entries_verified || self.is_cycle_fully_verified(&cycle_key) { return Ok(None); } let cycle_map = From f9ac60d647b78078b1b9ceb92cc50b5ad176d4d5 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Wed, 6 May 2026 20:23:23 +1000 Subject: [PATCH 09/11] docs(dash): align `try_load_previous_cycle_entries` short-circuit comment The `None`-return condition is now \"already fully verified\", not bare \"already stored\", to match the underlying `is_cycle_fully_verified` predicate. --- dash/src/sml/masternode_list_engine/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index 3d0602d90..2de9e5324 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -536,8 +536,8 @@ impl MasternodeListEngine { /// the captured CL signatures, and returns them along with the cycle hash /// they should be stored under. Returns `None` when prerequisite state is /// not yet loaded, when the MN list carries no rotating quorums, or when - /// the cycle is already stored. All three cases are expected on a fresh - /// sync and not errors. + /// the cycle is already fully verified. All three cases are expected on a + /// fresh sync and not errors. #[cfg(feature = "quorum_validation")] fn try_load_previous_cycle_entries( &self, From 2b174475271def29ea73de2be7137a691c8410f3 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Wed, 6 May 2026 21:34:46 +1000 Subject: [PATCH 10/11] test(dash): replace `feed_qr_info_does_not_downgrade_already_verified_cycle` with direct gate test The old integration-style test claimed to exercise the storage gate's anti-clobber behavior, but actually never reached the gate. The synthetic fake quorums in `last_commitment_per_index` had a single-element `signers` bitvector, so `validate_structure` rejected them with `InsufficientSigners` and `feed_qr_info` returned `Err` before `store_cycle_if_fully_verified` was ever called. The post-call assertion that `rotated_quorums_per_cycle[K]` was unchanged passed for a trivial reason: nothing wrote to it. The named invariant ("does not downgrade already-verified cycle") was not under test. Replace it with a focused unit test that calls `store_cycle_if_fully_verified` directly and covers both short-circuit branches: (a) the cycle is already fully `Verified`, (b) at least one input entry is not `Verified`. The test asserts the gate returns `Ok(None)` and does not mutate `rotated_quorums_per_cycle` in either case. Net change: -115/+44 lines, 1 of 2 unused test imports also removed. Addresses CodeRabbit review comment on PR #736 https://github.com/dashpay/rust-dashcore/pull/736#discussion_r3194848554 --- dash/src/sml/masternode_list_engine/mod.rs | 155 ++++++--------------- 1 file changed, 40 insertions(+), 115 deletions(-) diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index 2de9e5324..46ab9a017 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -1478,10 +1478,8 @@ mod tests { super::build_cycle_quorum_map, crate::BlockHash, crate::QuorumHash, - crate::Transaction, crate::bls_sig_utils::{BLSPublicKey, BLSSignature}, crate::hash_types::QuorumVVecHash, - crate::network::message_qrinfo::{MNSkipListMode, QuorumSnapshot}, crate::transaction::special_transaction::quorum_commitment::QuorumEntry, }; @@ -2032,134 +2030,61 @@ mod tests { ); } - /// Anti-clobber: a thin/incomplete second QRInfo where rotated entries - /// land as `Skipped` (no rotation CL sigs reachable) must not downgrade - /// an already-fully-Verified rotation cycle. Feeds the mainnet fixture - /// once to populate `rotated_quorums_per_cycle[K]` with all entries - /// `Verified`, then feeds a synthetic self-loop QRInfo whose diffs do - /// not carry rotation sigs, with `last_commitment_per_index` matching - /// the existing cycle's first quorum hash plus a fresh fake quorum - /// that needs new validation. Asserts the existing cycle's entries - /// remain `Verified`. + /// Direct coverage for the `store_cycle_if_fully_verified` storage gate. + /// The gate must short-circuit with `Ok(None)` (and not write) in two + /// cases: any input entry is not `Verified`, or the target cycle is + /// already fully `Verified`. #[cfg(feature = "quorum_validation")] #[test] - fn feed_qr_info_does_not_downgrade_already_verified_cycle() { + fn store_cycle_if_fully_verified_short_circuits() { let (mut engine, qr_info) = load_qrinfo_2240504_fixture(); - engine.feed_qr_info(qr_info, false, true).expect("first feed should succeed"); let cycle_key = *engine .rotated_quorums_per_cycle .keys() .next() - .expect("first feed must have stored at least one rotation cycle"); - + .expect("first feed must store at least one rotation cycle"); let original_cycle = engine.rotated_quorums_per_cycle.get(&cycle_key).expect("cycle present").clone(); + let rotation_quorum_type = + original_cycle.values().next().expect("cycle non-empty").quorum_entry.llmq_type; + + let already_verified: Vec = + original_cycle.values().cloned().collect(); + let result = engine + .store_cycle_if_fully_verified(cycle_key, already_verified, rotation_quorum_type) + .expect("gate must not error on already-verified cycle"); assert!( - !original_cycle.is_empty() - && original_cycle - .values() - .all(|q| matches!(q.verified, LLMQEntryVerificationStatus::Verified)), - "first feed must leave the cycle fully Verified" + result.is_none(), + "gate must short-circuit when target cycle is already fully Verified, got {:?}", + result + ); + assert_eq!( + engine.rotated_quorums_per_cycle.get(&cycle_key).unwrap(), + &original_cycle, + "gate must not mutate the stored cycle on the already-verified short-circuit" ); - let known_quorum = original_cycle - .values() - .find(|q| q.quorum_entry.quorum_hash == cycle_key) - .expect("cycle map must contain its key entry") - .clone(); - let rotation_quorum_type = known_quorum.quorum_entry.llmq_type; + // Use a fresh cycle_key so the already-verified short-circuit cannot + // fire. `make_qualified_quorum_entry` defaults `verified` to `Skipped`, + // so the gate must refuse to write a degraded cycle. + let fresh_key = BlockHash::from_byte_array([0xAB; 32]); let active_count = rotation_quorum_type.active_quorum_count() as i16; - - // Build `last_commitment_per_index`: first entry is the existing - // cycle key (so cycle_key matches, exercising the gate), rest are - // fresh fakes that need new validation. - let mut last_commitment_per_index = vec![known_quorum.quorum_entry.clone()]; - for i in 1..active_count { - last_commitment_per_index.push(QuorumEntry { - version: 2, - llmq_type: rotation_quorum_type, - quorum_hash: QuorumHash::from_byte_array([0xCC; 32].map(|b| b ^ i as u8)), - quorum_index: Some(i), - signers: vec![true], - valid_members: vec![true], - quorum_public_key: BLSPublicKey::from([0; 48]), - quorum_vvec_hash: QuorumVVecHash::all_zeros(), - threshold_sig: BLSSignature::from([0; 96]), - all_commitment_aggregated_signature: BLSSignature::from([0; 96]), - }); - } - - // Self-loop diffs at the engine's current tip make the second feed a - // no-op for masternode-list state while still exercising the QRInfo - // verification pipeline. - let tip_hash = engine - .masternode_lists - .iter() - .next_back() - .map(|(_, list)| list.block_hash) - .expect("engine has a tip list after first feed"); - let make_self_loop = |hash: BlockHash| MnListDiff { - version: 1, - base_block_hash: hash, - block_hash: hash, - total_transactions: 0, - merkle_hashes: vec![], - merkle_flags: vec![], - coinbase_tx: Transaction { - version: 1, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: None, - }, - deleted_masternodes: vec![], - new_masternodes: vec![], - deleted_quorums: vec![], - new_quorums: vec![], - quorums_chainlock_signatures: vec![], - }; - let empty_snapshot = || QuorumSnapshot { - skip_list_mode: MNSkipListMode::NoSkipping, - active_quorum_members: vec![], - skip_list: vec![], - }; - - let thin_qr_info = QRInfo { - quorum_snapshot_at_h_minus_c: empty_snapshot(), - quorum_snapshot_at_h_minus_2c: empty_snapshot(), - quorum_snapshot_at_h_minus_3c: empty_snapshot(), - mn_list_diff_tip: make_self_loop(tip_hash), - mn_list_diff_h: make_self_loop(tip_hash), - mn_list_diff_at_h_minus_c: make_self_loop(tip_hash), - mn_list_diff_at_h_minus_2c: make_self_loop(tip_hash), - mn_list_diff_at_h_minus_3c: make_self_loop(tip_hash), - quorum_snapshot_and_mn_list_diff_at_h_minus_4c: None, - last_commitment_per_index, - quorum_snapshot_list: vec![], - mn_list_diff_list: vec![], - }; - - let _ = engine.feed_qr_info(thin_qr_info, false, true); - - let after_cycle = engine - .rotated_quorums_per_cycle - .get(&cycle_key) - .expect("cycle must still exist after thin second feed"); - assert_eq!( - after_cycle.len(), - original_cycle.len(), - "thin second feed must not change cycle entry count" + let degraded: Vec = (0..active_count) + .map(|i| make_qualified_quorum_entry(rotation_quorum_type, Some(i))) + .collect(); + let result = engine + .store_cycle_if_fully_verified(fresh_key, degraded, rotation_quorum_type) + .expect("gate must not error when no entries are verified"); + assert!( + result.is_none(), + "gate must short-circuit when not all entries are Verified, got {:?}", + result + ); + assert!( + !engine.rotated_quorums_per_cycle.contains_key(&fresh_key), + "gate must not write a degraded cycle" ); - for (idx, entry) in after_cycle { - assert!( - matches!(entry.verified, LLMQEntryVerificationStatus::Verified), - "cycle quorum at index {} (hash {}) must remain Verified after thin second feed, got {}", - idx, - entry.quorum_entry.quorum_hash, - entry.verified - ); - } } } From 25c3347393031d648bad72d8db17c88dace96858 Mon Sep 17 00:00:00 2001 From: xdustinface Date: Wed, 6 May 2026 22:56:08 +1000 Subject: [PATCH 11/11] docs(dash): correct `fully_verified_count` doc on `verify_rotated_quorums == false` path The comment claimed `fully_verified_count` is always `0` when `verify_rotated_quorums == false`, but the else branch at the `feed_qr_info` storage step counts every entry of `qualified_last_commitment_per_index` whose `.verified` is `Verified`. Cache hits returned by `known_qualified_quorum_entry` retain their prior `Verified` status (the gate only stores fully-Verified cycles), so the count can be non-zero even with verification disabled. Update the doc to describe the actual behavior. Addresses CodeRabbit review comment on PR #736 https://github.com/dashpay/rust-dashcore/pull/736#discussion_r3195521308 --- dash/src/sml/masternode_list_engine/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/dash/src/sml/masternode_list_engine/mod.rs b/dash/src/sml/masternode_list_engine/mod.rs index 46ab9a017..42f1b35ff 100644 --- a/dash/src/sml/masternode_list_engine/mod.rs +++ b/dash/src/sml/masternode_list_engine/mod.rs @@ -80,9 +80,11 @@ pub struct QRInfoFeedResult { /// distinguish cache-hit observability from a trust signal. pub newly_qualified_count: usize, /// Rotated quorums whose `.verified` is `LLMQEntryVerificationStatus::Verified` - /// after this call settled. Always `0` when `verify_rotated_quorums == false`. - /// A cycle is only stored in `rotated_quorums_per_cycle` when every entry is - /// `Verified`. + /// after this call settled. May be non-zero even when + /// `verify_rotated_quorums == false` because cached entries returned by + /// `known_qualified_quorum_entry` retain their prior `Verified` status. + /// A cycle is only stored in `rotated_quorums_per_cycle` when every entry + /// is `Verified`. pub fully_verified_count: usize, /// Height of the cycle from `last_commitment_per_index` if (and only if) /// the storage gate fired and the cycle was inserted into