diff --git a/dash-spv-ffi/src/platform_integration.rs b/dash-spv-ffi/src/platform_integration.rs index ce1609788..bb323c3bf 100644 --- a/dash-spv-ffi/src/platform_integration.rs +++ b/dash-spv-ffi/src/platform_integration.rs @@ -101,53 +101,25 @@ pub unsafe extern "C" fn ffi_dash_spv_get_quorum_public_key( }; let engine_guard = engine.blocking_read(); - let (before, _after) = engine_guard.masternode_lists_around_height(core_chain_locked_height); - let ml = match before { - Some(ml) => ml, - None => { - return FFIResult::error( - FFIErrorCode::ValidationError, - &format!( - "No masternode list found at or before height {}", - core_chain_locked_height - ), - ); - } - }; - - let list_height = ml.known_height; - match ml.quorums.get(&llmq_type) { - Some(quorums) => match quorums.get(&quorum_hash) { - Some(quorum) => { - let pubkey_bytes: &[u8; 48] = quorum.quorum_entry.quorum_public_key.as_ref(); - std::ptr::copy_nonoverlapping( - pubkey_bytes.as_ptr(), - out_pubkey, - QUORUM_PUBKEY_SIZE, - ); - - FFIResult { - error_code: 0, - error_message: ptr::null(), - } + match engine_guard.quorum_entry_for_hash_at_or_before_height( + llmq_type, + quorum_hash, + core_chain_locked_height, + ) { + Some((_list_height, quorum)) => { + let pubkey_bytes: &[u8; 48] = quorum.quorum_entry.quorum_public_key.as_ref(); + std::ptr::copy_nonoverlapping(pubkey_bytes.as_ptr(), out_pubkey, QUORUM_PUBKEY_SIZE); + + FFIResult { + error_code: 0, + error_message: ptr::null(), } - None => FFIResult::error( - FFIErrorCode::ValidationError, - &format!( - "Quorum not found: type {} at list height {} (requested {}) with hash {:x} (masternode list exists with {} quorums of this type)", - quorum_type, - list_height, - core_chain_locked_height, - quorum_hash, - quorums.len() - ), - ), - }, + } None => FFIResult::error( FFIErrorCode::ValidationError, &format!( - "No quorums of type {} found at list height {} (requested {})", - quorum_type, list_height, core_chain_locked_height + "Quorum not found: type {} at or before height {} with hash {:x}", + quorum_type, core_chain_locked_height, quorum_hash ), ), } diff --git a/dash-spv/src/client/queries.rs b/dash-spv/src/client/queries.rs index 7e215cc86..00836b220 100644 --- a/dash-spv/src/client/queries.rs +++ b/dash-spv/src/client/queries.rs @@ -53,56 +53,31 @@ impl DashSpvClient Result { let masternode_engine = self.masternode_list_engine()?; let masternode_engine_guard = masternode_engine.read().await; - let (before, _after) = masternode_engine_guard.masternode_lists_around_height(height); - if let Some(ml) = before { - let list_height = ml.known_height; - match ml.quorums.get(&quorum_type) { - Some(quorums) => match quorums.get(&quorum_hash) { - Some(quorum) => { - tracing::debug!( - "Found quorum type {} at list height {} (requested {}) with hash {}", - quorum_type, - list_height, - height, - hex::encode(quorum_hash) - ); - return Ok(quorum.clone()); - } - None => { - let message = format!( - "Quorum not found: type {} at list height {} (requested {}) with hash {} (masternode list exists with {} quorums of this type)", - quorum_type, - list_height, - height, - hex::encode(quorum_hash), - quorums.len() - ); - tracing::warn!(message); - return Err(SpvError::QuorumLookupError(message)); - } - }, - None => { - tracing::warn!( - "No quorums of type {} found at list height {} (requested {}) (masternode list exists)", - quorum_type, - list_height, - height - ); - return Err(SpvError::QuorumLookupError(format!( - "No quorums of type {} found at list height {} (requested {})", - quorum_type, list_height, height - ))); - } + match masternode_engine_guard.quorum_entry_for_hash_at_or_before_height( + quorum_type, + quorum_hash, + height, + ) { + Some((list_height, quorum)) => { + tracing::debug!( + "Found quorum type {} at list height {} (requested {}) with hash {}", + quorum_type, + list_height, + height, + hex::encode(quorum_hash) + ); + Ok(quorum.clone()) + } + None => { + let message = format!( + "Quorum not found: type {} at or before height {} with hash {}", + quorum_type, + height, + hex::encode(quorum_hash) + ); + tracing::warn!("{}", message); + Err(SpvError::QuorumLookupError(message)) } } - - tracing::warn!( - "No masternode list found at or before height {} - cannot retrieve quorum", - height - ); - Err(SpvError::QuorumLookupError(format!( - "No masternode list found at or before height {}", - height - ))) } } diff --git a/dash/src/sml/masternode_list_engine/helpers.rs b/dash/src/sml/masternode_list_engine/helpers.rs index 3fa11bb15..9dfbaeccc 100644 --- a/dash/src/sml/masternode_list_engine/helpers.rs +++ b/dash/src/sml/masternode_list_engine/helpers.rs @@ -1,6 +1,17 @@ +use crate::QuorumHash; use crate::prelude::CoreBlockHeight; +use crate::sml::llmq_entry_verification::LLMQEntryVerificationStatus; +use crate::sml::llmq_type::LLMQType; use crate::sml::masternode_list::MasternodeList; use crate::sml::masternode_list_engine::MasternodeListEngine; +use crate::sml::quorum_entry::qualified_quorum_entry::QualifiedQuorumEntry; + +/// How many active windows below the lookup height [`MasternodeListEngine::quorum_entry_for_hash_at_or_before_height`] +/// searches before giving up. A signing quorum referenced by a proof was selected at a lagged +/// height that can exceed one active window (Platform selects roughly 4.5 DKG intervals back), so a +/// single window is too tight. Four windows covers that lag with wide margin while still bounding a +/// miss to a fixed span of lists rather than every list the engine has accumulated. +const QUORUM_WALK_BACK_ACTIVE_WINDOWS: u32 = 4; impl MasternodeListEngine { /// Retrieves the closest masternode lists before and after a given core block height. @@ -38,4 +49,201 @@ impl MasternodeListEngine { (lower, upper) } + + /// Resolves a quorum entry by type and hash, searching masternode lists at or below + /// `height` from the nearest downward and returning the first one that still holds it. + /// + /// The nearest list at or below `height` may no longer contain the quorum: once a quorum + /// retires out of the active set, `apply_diff` drops it from every list built from that + /// point on. A signing quorum selected at a lagged height can therefore be absent from the + /// nearest list yet still present in an earlier, retained one. Walking backward returns that + /// earlier full entry rather than failing the lookup. Entries marked `Invalid` are skipped. + /// + /// The returned `CoreBlockHeight` is the height of the list the entry was resolved from. The + /// first match is the highest list still holding the quorum, so a hit stops a few cycles back at + /// most. The walk is floored at `QUORUM_WALK_BACK_ACTIVE_WINDOWS` active windows below `height` + /// (derived from the type's DKG interval and active quorum count): a legitimately referenced + /// signing quorum cannot be older than that, so flooring it bounds a miss to a fixed span of + /// lists rather than scanning every list the engine has accumulated. + pub fn quorum_entry_for_hash_at_or_before_height( + &self, + llmq_type: LLMQType, + quorum_hash: QuorumHash, + height: CoreBlockHeight, + ) -> Option<(CoreBlockHeight, &QualifiedQuorumEntry)> { + let params = llmq_type.params(); + let active_window = + params.signing_active_quorum_count.saturating_mul(params.dkg_params.interval); + let floor = + height.saturating_sub(active_window.saturating_mul(QUORUM_WALK_BACK_ACTIVE_WINDOWS)); + + self.masternode_lists.range(floor..=height).rev().find_map(|(_, list)| { + list.quorum_entry_of_type_for_quorum_hash(llmq_type, quorum_hash) + .filter(|quorum| { + !matches!(quorum.verified, LLMQEntryVerificationStatus::Invalid(_)) + }) + .map(|quorum| (list.known_height, quorum)) + }) + } +} + +#[cfg(test)] +mod tests { + use std::slice; + + use hashes::Hash; + + use super::*; + use crate::BlockHash; + use crate::bls_sig_utils::{BLSPublicKey, BLSSignature}; + use crate::hash_types::QuorumVVecHash; + use crate::sml::quorum_validation_error::QuorumValidationError; + use crate::transaction::special_transaction::quorum_commitment::QuorumEntry; + + const PLATFORM_TYPE: LLMQType = LLMQType::LlmqtypeDevnetPlatform; + + fn quorum_entry(quorum_hash: QuorumHash, pubkey: u8) -> QualifiedQuorumEntry { + let mut entry: QualifiedQuorumEntry = QuorumEntry { + version: 2, + llmq_type: PLATFORM_TYPE, + quorum_hash, + quorum_index: Some(0), + signers: vec![true; 4], + valid_members: vec![true; 4], + quorum_public_key: BLSPublicKey::from([pubkey; 48]), + quorum_vvec_hash: QuorumVVecHash::all_zeros(), + threshold_sig: BLSSignature::from([1; 96]), + all_commitment_aggregated_signature: BLSSignature::from([1; 96]), + } + .into(); + entry.verified = LLMQEntryVerificationStatus::Verified; + entry + } + + fn list_with_quorums(height: u32, quorums: &[QualifiedQuorumEntry]) -> MasternodeList { + let mut list = + MasternodeList::empty(BlockHash::from_byte_array([height as u8; 32]), height); + let by_hash = list.quorums.entry(PLATFORM_TYPE).or_default(); + for quorum in quorums { + by_hash.insert(quorum.quorum_entry.quorum_hash, quorum.clone()); + } + list + } + + /// A quorum retired out of the active set is dropped from the nearest list at or below the + /// lookup height, but the backward walk resolves it from the earlier list that still holds it. + #[test] + fn resolves_retired_quorum_from_earlier_list() { + let retired_hash = QuorumHash::from_byte_array([0xAB; 32]); + let active_hash = QuorumHash::from_byte_array([0xCD; 32]); + let retired = quorum_entry(retired_hash, 7); + + let mut engine = MasternodeListEngine::default(); + // Pre-retirement list still holds the retired quorum. + engine.masternode_lists.insert(148, list_with_quorums(148, slice::from_ref(&retired))); + // Post-retirement list holds only the then-active quorum, not the retired one. + engine + .masternode_lists + .insert(208, list_with_quorums(208, &[quorum_entry(active_hash, 9)])); + + // The nearest list at or below the lookup height no longer holds the retired quorum. + let nearest = engine.masternode_lists_around_height(208).0.unwrap(); + assert!( + nearest.quorum_entry_of_type_for_quorum_hash(PLATFORM_TYPE, retired_hash).is_none() + ); + + // The walk resolves it from the earlier retained list, returning that list's height. + let (resolved_height, resolved) = engine + .quorum_entry_for_hash_at_or_before_height(PLATFORM_TYPE, retired_hash, 208) + .expect("retired quorum resolves from earlier list"); + assert_eq!(resolved_height, 148); + assert_eq!(resolved.quorum_entry.quorum_public_key, retired.quorum_entry.quorum_public_key); + } + + /// While still in the active set the quorum resolves from the nearest list directly. + #[test] + fn resolves_active_quorum_from_nearest_list() { + let hash = QuorumHash::from_byte_array([0xAB; 32]); + let mut engine = MasternodeListEngine::default(); + engine.masternode_lists.insert(148, list_with_quorums(148, &[quorum_entry(hash, 7)])); + + let (resolved_height, _) = engine + .quorum_entry_for_hash_at_or_before_height(PLATFORM_TYPE, hash, 148) + .expect("active quorum resolves"); + assert_eq!(resolved_height, 148); + } + + /// A lookup below every retained list, or for an unknown hash, finds nothing. + #[test] + fn returns_none_when_not_present() { + let hash = QuorumHash::from_byte_array([0xAB; 32]); + let mut engine = MasternodeListEngine::default(); + engine.masternode_lists.insert(148, list_with_quorums(148, &[quorum_entry(hash, 7)])); + + assert!( + engine.quorum_entry_for_hash_at_or_before_height(PLATFORM_TYPE, hash, 100).is_none() + ); + assert!( + engine + .quorum_entry_for_hash_at_or_before_height( + PLATFORM_TYPE, + QuorumHash::from_byte_array([0xEE; 32]), + 208 + ) + .is_none() + ); + } + + /// An `Invalid` entry is skipped, even when it is the only list holding the hash. + #[test] + fn skips_invalid_entries() { + let hash = QuorumHash::from_byte_array([0xAB; 32]); + let mut invalid = quorum_entry(hash, 7); + invalid.verified = + LLMQEntryVerificationStatus::Invalid(QuorumValidationError::InvalidQuorumPublicKey); + + let mut engine = MasternodeListEngine::default(); + engine.masternode_lists.insert(148, list_with_quorums(148, &[invalid])); + + assert!( + engine.quorum_entry_for_hash_at_or_before_height(PLATFORM_TYPE, hash, 208).is_none() + ); + } + + /// The walk is floored at a few active windows below the lookup height: a quorum that only + /// survives in a list older than the floor is treated as not found, while one within the window + /// still resolves. This bounds a miss instead of scanning every accumulated list. + #[test] + fn does_not_walk_below_active_window_floor() { + let params = PLATFORM_TYPE.params(); + let span = params.signing_active_quorum_count + * params.dkg_params.interval + * QUORUM_WALK_BACK_ACTIVE_WINDOWS; + let height = span + 5_000; + let floor = height - span; + + let within_hash = QuorumHash::from_byte_array([0x11; 32]); + let below_hash = QuorumHash::from_byte_array([0x22; 32]); + + let mut engine = MasternodeListEngine::default(); + // One list just above the floor and one well below it. + engine + .masternode_lists + .insert(floor + 100, list_with_quorums(floor + 100, &[quorum_entry(within_hash, 7)])); + engine + .masternode_lists + .insert(floor - 100, list_with_quorums(floor - 100, &[quorum_entry(below_hash, 9)])); + + let (resolved_height, _) = engine + .quorum_entry_for_hash_at_or_before_height(PLATFORM_TYPE, within_hash, height) + .expect("quorum within the window resolves"); + assert_eq!(resolved_height, floor + 100); + + assert!( + engine + .quorum_entry_for_hash_at_or_before_height(PLATFORM_TYPE, below_hash, height) + .is_none(), + "quorum below the floor must not be walked to" + ); + } }