diff --git a/dash-spv/src/bridge/mod.rs b/dash-spv/src/bridge/mod.rs index 89374299b..4ef69ee45 100644 --- a/dash-spv/src/bridge/mod.rs +++ b/dash-spv/src/bridge/mod.rs @@ -9,6 +9,7 @@ use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; +use dashcore::sml::llmq_entry_verification::LLMQEntryVerificationStatus; use dashcore::Network; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet_manager::wallet_manager::WalletManager; @@ -679,6 +680,73 @@ impl SpvClient { } } +/// UniFFI-compatible record representing a single quorum (LLMQ) entry. +/// +/// Fields are mapped from the `QualifiedQuorumEntry` and its inner `QuorumEntry`. +/// All hashes are represented as hex `String` values for cross-language convenience. +#[derive(uniffi::Record, Clone, Debug)] +pub struct QuorumInfo { + /// Quorum hash that identifies this quorum instance. + pub quorum_hash: String, + /// Quorum type string (e.g. `"1_50/60"`, `"100_Test"`). + pub quorum_type: String, + /// Number of members (signers slots) in this quorum. + pub members_count: u32, + /// `true` when the quorum signature has been successfully verified. + pub active: bool, +} + +#[uniffi::export] +impl SpvClient { + /// Looks up a single masternode by its ProRegTx hash. + /// + /// Scans the current masternode list for an entry whose `pro_tx_hash` matches + /// the provided string. Returns `None` when masternodes are disabled, no list + /// has been received yet, or no entry with that hash exists. + pub async fn get_masternode(&self, pro_tx_hash: String) -> Option { + self.get_masternodes().await.into_iter().find(|mn| mn.pro_tx_hash == pro_tx_hash) + } + + /// Returns all quorums from the current masternode list. + /// + /// Iterates the `quorums` map of the latest masternode list and maps each + /// [`dashcore::sml::quorum_entry::qualified_quorum_entry::QualifiedQuorumEntry`] + /// to a [`QuorumInfo`] record. Returns an empty `Vec` when masternodes are + /// disabled, no list has been received yet, or no quorums are present. + /// + /// # Field mapping + /// + /// | Source field | `QuorumInfo` field | + /// |---|---| + /// | `quorum_entry.quorum_hash` | `quorum_hash` | + /// | `LLMQType` (map key) | `quorum_type` | + /// | `quorum_entry.signers.len()` | `members_count` | + /// | `verified == Verified` | `active` | + pub async fn get_active_quorums(&self) -> Vec { + let Some(engine) = self.inner.masternode_engine().await else { + return vec![]; + }; + let guard = engine.read().await; + let Some(list) = guard.latest_masternode_list() else { + return vec![]; + }; + list.quorums + .iter() + .flat_map(|(llmq_type, quorums_by_hash)| { + quorums_by_hash.values().map(|entry| { + let qe = &entry.quorum_entry; + QuorumInfo { + quorum_hash: qe.quorum_hash.to_string(), + quorum_type: llmq_type.to_string(), + members_count: qe.signers.len() as u32, + active: entry.verified == LLMQEntryVerificationStatus::Verified, + } + }) + }) + .collect() + } +} + // ============ Stub functions ============ /// Returns a greeting string (sanity-check export). @@ -1386,4 +1454,90 @@ mod tests { "should return empty vec when engine has no list yet" ); } + + // ---- QuorumInfo record tests ---- + + #[test] + fn test_quorum_info_fields() { + let info = QuorumInfo { + quorum_hash: "deadbeef".to_string(), + quorum_type: "100_Test".to_string(), + members_count: 4, + active: true, + }; + assert_eq!(info.quorum_hash, "deadbeef"); + assert_eq!(info.quorum_type, "100_Test"); + assert_eq!(info.members_count, 4); + assert!(info.active); + } + + #[test] + fn test_quorum_info_inactive() { + let info = QuorumInfo { + quorum_hash: "aabbccdd".to_string(), + quorum_type: "1_50/60".to_string(), + members_count: 50, + active: false, + }; + assert!(!info.active); + assert_eq!(info.members_count, 50); + } + + /// `get_masternode` returns `None` when masternodes are disabled (no engine). + #[tokio::test] + async fn test_get_masternode_no_engine() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let config = ClientConfig::regtest() + .without_filters() + .without_masternodes() + .with_storage_path(temp_dir.path()); + + let client = SpvClient::new(config).await.expect("SpvClient construction must succeed"); + assert!( + client.get_masternode("abc123".to_string()).await.is_none(), + "should return None when engine is None" + ); + } + + /// `get_masternode` returns `None` when masternodes are enabled but no list has been received. + #[tokio::test] + async fn test_get_masternode_empty_engine() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let config = ClientConfig::regtest().without_filters().with_storage_path(temp_dir.path()); + + let client = SpvClient::new(config).await.expect("SpvClient construction must succeed"); + assert!( + client.get_masternode("abc123".to_string()).await.is_none(), + "should return None when engine has no list yet" + ); + } + + /// `get_active_quorums` returns an empty vec when masternodes are disabled (no engine). + #[tokio::test] + async fn test_get_active_quorums_no_engine() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let config = ClientConfig::regtest() + .without_filters() + .without_masternodes() + .with_storage_path(temp_dir.path()); + + let client = SpvClient::new(config).await.expect("SpvClient construction must succeed"); + assert!( + client.get_active_quorums().await.is_empty(), + "should return empty vec when engine is None" + ); + } + + /// `get_active_quorums` returns an empty vec when masternodes are enabled but no list received. + #[tokio::test] + async fn test_get_active_quorums_empty_engine() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let config = ClientConfig::regtest().without_filters().with_storage_path(temp_dir.path()); + + let client = SpvClient::new(config).await.expect("SpvClient construction must succeed"); + assert!( + client.get_active_quorums().await.is_empty(), + "should return empty vec when engine has no list yet" + ); + } }