From fe39408c27b02b2b984b8397a9e6abc20c58958c Mon Sep 17 00:00:00 2001 From: cuteolaf Date: Thu, 8 Jan 2026 17:23:02 +0100 Subject: [PATCH 1/6] test(consensus): add comprehensive test coverage for consensus module Add 57 new tests covering previously untested methods in governance, PBFT, stake-weighted consensus, and state management modules. Key areas tested: - Governance: all SudoAction types, proposal titles/descriptions, state mutations - PBFT: proposal validation, vote handling, timeouts, consensus thresholds - Stake-weighted: stake-based voting, double-vote prevention, thresholds - Stake governance: voting edge cases, rate limiting, bootstrap authority, proposal lifecycle (create/vote/execute/cancel), hybrid governance --- .../consensus/src/governance_integration.rs | 426 ++++++++++++ crates/consensus/src/pbft.rs | 362 +++++++++- crates/consensus/src/stake_governance.rs | 651 ++++++++++++++++++ crates/consensus/src/stake_weighted_pbft.rs | 255 +++++++ crates/consensus/src/state.rs | 62 ++ 5 files changed, 1755 insertions(+), 1 deletion(-) diff --git a/crates/consensus/src/governance_integration.rs b/crates/consensus/src/governance_integration.rs index 60760bba7..4bca762e0 100644 --- a/crates/consensus/src/governance_integration.rs +++ b/crates/consensus/src/governance_integration.rs @@ -490,6 +490,7 @@ impl MetagraphGovernanceSync { #[cfg(test)] mod tests { use super::*; + use platform_core::ChallengeConfig; #[test] fn test_sudo_action_conversion() { @@ -504,6 +505,431 @@ mod tests { assert_eq!(gtype2, GovernanceActionType::Resume); } + #[test] + fn test_sudo_action_conversion_all_variants() { + // Test UpdateConfig + let action = SudoAction::UpdateConfig { + config: platform_core::NetworkConfig::default(), + }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::UpdateConfig + ); + + // Test AddChallenge + let challenge_id = platform_core::ChallengeId::new(); + let config = platform_core::ChallengeContainerConfig::new("test", "test:latest", 1, 0.5); + let action = SudoAction::AddChallenge { config }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::AddChallenge + ); + + // Test UpdateChallenge + let config = platform_core::ChallengeContainerConfig::new("test", "test:latest", 1, 0.5); + let action = SudoAction::UpdateChallenge { config }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::UpdateChallenge + ); + + // Test RemoveChallenge + let action = SudoAction::RemoveChallenge { id: challenge_id }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::RemoveChallenge + ); + + // Test SetChallengeWeight + let action = SudoAction::SetChallengeWeight { + challenge_id, + mechanism_id: 1, + weight_ratio: 0.5, + }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::SetChallengeWeight + ); + + // Test SetMechanismBurnRate + let action = SudoAction::SetMechanismBurnRate { + mechanism_id: 1, + burn_rate: 0.1, + }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::SetMechanismBurnRate + ); + + // Test SetMechanismConfig + let action = SudoAction::SetMechanismConfig { + mechanism_id: 1, + config: platform_core::MechanismWeightConfig::new(1), + }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::SetMechanismBurnRate + ); + + // Test SetRequiredVersion + let action = SudoAction::SetRequiredVersion { + min_version: "1.0.0".to_string(), + recommended_version: "1.1.0".to_string(), + docker_image: "image".to_string(), + mandatory: false, + deadline_block: Some(1000), + release_notes: None, + }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::SetRequiredVersion + ); + + // Test AddValidator + let info = ValidatorInfo::new(Hotkey([1u8; 32]), Stake(1000)); + let action = SudoAction::AddValidator { info }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::AddValidator + ); + + // Test RemoveValidator + let action = SudoAction::RemoveValidator { + hotkey: Hotkey([1u8; 32]), + }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::RemoveValidator + ); + + // Test ForceStateUpdate + let state = ChainState::new(Hotkey([1u8; 32]), platform_core::NetworkConfig::default()); + let action = SudoAction::ForceStateUpdate { state }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::ForceStateUpdate + ); + + // Test RefreshChallenges + let action = SudoAction::RefreshChallenges { + challenge_id: Some(challenge_id), + }; + assert_eq!( + sudo_action_to_governance_type(&action), + GovernanceActionType::UpdateChallenge + ); + } + + #[test] + fn test_generate_proposal_title_all_variants() { + let challenge_id = platform_core::ChallengeId::new(); + + // UpdateConfig + let action = SudoAction::UpdateConfig { + config: platform_core::NetworkConfig::default(), + }; + assert_eq!( + generate_proposal_title(&action), + "Update Network Configuration" + ); + + // AddChallenge + let config = + platform_core::ChallengeContainerConfig::new("TestChallenge", "test:latest", 1, 0.5); + let action = SudoAction::AddChallenge { + config: config.clone(), + }; + assert_eq!( + generate_proposal_title(&action), + "Add Challenge: TestChallenge" + ); + + // UpdateChallenge + let action = SudoAction::UpdateChallenge { config }; + assert_eq!( + generate_proposal_title(&action), + "Update Challenge: TestChallenge" + ); + + // RemoveChallenge + let action = SudoAction::RemoveChallenge { id: challenge_id }; + let title = generate_proposal_title(&action); + assert!(title.starts_with("Remove Challenge:")); + + // RefreshChallenges with ID + let action = SudoAction::RefreshChallenges { + challenge_id: Some(challenge_id), + }; + let title = generate_proposal_title(&action); + assert!(title.starts_with("Refresh Challenge:")); + + // RefreshChallenges without ID + let action = SudoAction::RefreshChallenges { challenge_id: None }; + assert_eq!(generate_proposal_title(&action), "Refresh All Challenges"); + + // SetChallengeWeight + let action = SudoAction::SetChallengeWeight { + challenge_id, + mechanism_id: 1, + weight_ratio: 0.5, + }; + let title = generate_proposal_title(&action); + assert!(title.starts_with("Set Weight for Challenge:")); + + // SetMechanismBurnRate + let action = SudoAction::SetMechanismBurnRate { + mechanism_id: 42, + burn_rate: 0.1, + }; + assert_eq!( + generate_proposal_title(&action), + "Set Burn Rate for Mechanism: 42" + ); + + // SetMechanismConfig + let action = SudoAction::SetMechanismConfig { + mechanism_id: 99, + config: platform_core::MechanismWeightConfig::new(99), + }; + assert_eq!(generate_proposal_title(&action), "Configure Mechanism: 99"); + + // SetRequiredVersion + let action = SudoAction::SetRequiredVersion { + min_version: "2.0.0".to_string(), + recommended_version: "2.1.0".to_string(), + docker_image: "image".to_string(), + mandatory: true, + deadline_block: Some(1000), + release_notes: None, + }; + assert_eq!( + generate_proposal_title(&action), + "Set Required Version: 2.0.0" + ); + + // AddValidator + let hotkey = Hotkey([42u8; 32]); + let info = ValidatorInfo::new(hotkey.clone(), Stake(1000)); + let action = SudoAction::AddValidator { info }; + let title = generate_proposal_title(&action); + assert!(title.starts_with("Add Validator:")); + + // RemoveValidator + let action = SudoAction::RemoveValidator { hotkey }; + let title = generate_proposal_title(&action); + assert!(title.starts_with("Remove Validator:")); + + // EmergencyPause + let action = SudoAction::EmergencyPause { + reason: "Critical bug".to_string(), + }; + assert_eq!( + generate_proposal_title(&action), + "Emergency Pause: Critical bug" + ); + + // Resume + let action = SudoAction::Resume; + assert_eq!(generate_proposal_title(&action), "Resume Network"); + + // ForceStateUpdate + let state = ChainState::new(Hotkey([1u8; 32]), platform_core::NetworkConfig::default()); + let action = SudoAction::ForceStateUpdate { state }; + assert_eq!( + generate_proposal_title(&action), + "Force State Update (Emergency)" + ); + } + + #[test] + fn test_generate_proposal_description() { + let challenge_id = platform_core::ChallengeId::new(); + + // AddChallenge + let config = platform_core::ChallengeContainerConfig::new( + "TestChallenge", + "test/image:latest", + 1, + 0.25, + ); + let action = SudoAction::AddChallenge { + config: config.clone(), + }; + let desc = generate_proposal_description(&action); + assert!(desc.contains("TestChallenge")); + assert!(desc.contains("test/image:latest")); + assert!(desc.contains("25%")); + + // UpdateChallenge + let action = SudoAction::UpdateChallenge { config }; + let desc = generate_proposal_description(&action); + assert!(desc.contains("TestChallenge")); + assert!(desc.contains("test/image:latest")); + + // SetRequiredVersion + let action = SudoAction::SetRequiredVersion { + min_version: "1.5.0".to_string(), + recommended_version: "1.6.0".to_string(), + docker_image: "image".to_string(), + mandatory: true, + deadline_block: Some(1000), + release_notes: None, + }; + let desc = generate_proposal_description(&action); + assert!(desc.contains("1.5.0")); + assert!(desc.contains("true")); + + // EmergencyPause + let action = SudoAction::EmergencyPause { + reason: "Security vulnerability".to_string(), + }; + let desc = generate_proposal_description(&action); + assert!(desc.contains("Security vulnerability")); + + // Other actions return default description + let action = SudoAction::Resume; + let desc = generate_proposal_description(&action); + assert_eq!(desc, "No additional description available."); + } + + #[test] + fn test_apply_sudo_action_all_variants() { + let keypair = Keypair::generate(); + let mut state = ChainState::new(keypair.hotkey(), platform_core::NetworkConfig::default()); + + // Test UpdateConfig + let new_config = platform_core::NetworkConfig::default(); + let action = SudoAction::UpdateConfig { + config: new_config.clone(), + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + assert_eq!(state.config.subnet_id, new_config.subnet_id); + + // Test AddChallenge + let config = platform_core::ChallengeContainerConfig::new( + "test", + "ghcr.io/platformnetwork/test:latest", + 1, + 0.5, + ); + let challenge_id = config.challenge_id; + let action = SudoAction::AddChallenge { + config: config.clone(), + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + assert!(state.challenge_configs.contains_key(&challenge_id)); + + // Test UpdateChallenge + let mut updated_config = platform_core::ChallengeContainerConfig::new( + "test", + "ghcr.io/platformnetwork/updated:latest", + 1, + 0.5, + ); + updated_config.challenge_id = challenge_id; // Use same ID for update + let action = SudoAction::UpdateChallenge { + config: updated_config.clone(), + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + assert_eq!( + state.challenge_configs[&challenge_id].docker_image, + "ghcr.io/platformnetwork/updated:latest" + ); + + // Test RemoveChallenge + let action = SudoAction::RemoveChallenge { id: challenge_id }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + assert!(!state.challenge_configs.contains_key(&challenge_id)); + + // Test SetChallengeWeight + let action = SudoAction::SetChallengeWeight { + challenge_id, + mechanism_id: 1, + weight_ratio: 0.75, + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + assert!(state.challenge_weights.contains_key(&challenge_id)); + + // Test SetMechanismBurnRate + let action = SudoAction::SetMechanismBurnRate { + mechanism_id: 1, + burn_rate: 0.15, + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + assert_eq!(state.mechanism_configs[&1].base_burn_rate, 0.15); + + // Test SetMechanismConfig + let mut mechanism_config = platform_core::MechanismWeightConfig::new(2); + mechanism_config.base_burn_rate = 0.2; + mechanism_config.max_weight_cap = 0.5; + let action = SudoAction::SetMechanismConfig { + mechanism_id: 2, + config: mechanism_config.clone(), + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + assert_eq!(state.mechanism_configs[&2].base_burn_rate, 0.2); + assert_eq!(state.mechanism_configs[&2].max_weight_cap, 0.5); + + // Test SetRequiredVersion + let action = SudoAction::SetRequiredVersion { + min_version: "1.0.0".to_string(), + recommended_version: "1.1.0".to_string(), + docker_image: "validator:1.1.0".to_string(), + mandatory: true, + deadline_block: Some(10000), + release_notes: Some("Important update".to_string()), + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + assert!(state.required_version.is_some()); + let req_ver = state.required_version.as_ref().unwrap(); + assert_eq!(req_ver.min_version, "1.0.0"); + assert!(req_ver.mandatory); + + // Test AddValidator + let new_validator = ValidatorInfo::new(Hotkey([99u8; 32]), Stake(500_000_000_000)); + let action = SudoAction::AddValidator { + info: new_validator.clone(), + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + assert!(state.get_validator(&new_validator.hotkey).is_some()); + + // Test RemoveValidator + let action = SudoAction::RemoveValidator { + hotkey: new_validator.hotkey.clone(), + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + assert!(state.get_validator(&new_validator.hotkey).is_none()); + + // Test EmergencyPause + let action = SudoAction::EmergencyPause { + reason: "Test pause".to_string(), + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + + // Test Resume + let action = SudoAction::Resume; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + + // Test ForceStateUpdate + let new_state = + ChainState::new(Hotkey([88u8; 32]), platform_core::NetworkConfig::default()); + let action = SudoAction::ForceStateUpdate { + state: new_state.clone(), + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + assert_eq!(state.sudo_key, Hotkey([88u8; 32])); + + // Test RefreshChallenges with ID + let action = SudoAction::RefreshChallenges { + challenge_id: Some(challenge_id), + }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + + // Test RefreshChallenges without ID + let action = SudoAction::RefreshChallenges { challenge_id: None }; + assert!(apply_sudo_action(&mut state, &action).is_ok()); + } + #[test] fn test_proposal_title_generation() { let action = SudoAction::EmergencyPause { diff --git a/crates/consensus/src/pbft.rs b/crates/consensus/src/pbft.rs index 4479f4caa..98617b1e3 100644 --- a/crates/consensus/src/pbft.rs +++ b/crates/consensus/src/pbft.rs @@ -453,7 +453,7 @@ pub struct ConsensusStatus { #[cfg(test)] mod tests { use super::*; - use platform_core::{NetworkConfig, Stake, ValidatorInfo}; + use platform_core::{ChallengeConfig, NetworkConfig, Stake, ValidatorInfo}; use tokio::sync::mpsc; fn create_test_engine() -> (PBFTEngine, mpsc::Receiver) { @@ -497,4 +497,364 @@ mod tests { assert!(matches!(msg1.message, NetworkMessage::Proposal(_))); assert!(matches!(msg2.message, NetworkMessage::Vote(_))); } + + #[tokio::test] + async fn test_handle_proposal_valid_sudo() { + let (engine, _rx) = create_test_engine(); + let sudo_key = engine.keypair.hotkey(); + + let action = SudoAction::UpdateConfig { + config: NetworkConfig::default(), + }; + let proposal = Proposal::new(ProposalAction::Sudo(action), sudo_key.clone(), 0); + + let result = engine.handle_proposal(proposal, &sudo_key).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_proposal_wrong_signer() { + let (engine, _rx) = create_test_engine(); + let other_key = Keypair::generate().hotkey(); + + let action = SudoAction::UpdateConfig { + config: NetworkConfig::default(), + }; + let proposal = Proposal::new(ProposalAction::Sudo(action), other_key.clone(), 0); + + // Proposal signed by different key should fail + let result = engine + .handle_proposal(proposal, &engine.keypair.hotkey()) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_handle_proposal_non_sudo_action() { + let (engine, _rx) = create_test_engine(); + let non_sudo = Keypair::generate(); + + // Add non-sudo as validator + { + let mut state = engine.chain_state.write(); + let info = ValidatorInfo::new(non_sudo.hotkey(), Stake::new(10_000_000_000)); + state.add_validator(info).unwrap(); + } + + let action = SudoAction::UpdateConfig { + config: NetworkConfig::default(), + }; + let proposal = Proposal::new(ProposalAction::Sudo(action), non_sudo.hotkey(), 0); + + // Non-sudo proposing sudo action should fail + let result = engine.handle_proposal(proposal, &non_sudo.hotkey()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_handle_proposal_invalid_challenge_config() { + let (engine, _rx) = create_test_engine(); + let sudo_key = engine.keypair.hotkey(); + + // Create challenge with invalid config (empty name) + let config = platform_core::ChallengeContainerConfig::new("", "", 1, 0.5); + + let action = SudoAction::AddChallenge { config }; + let proposal = Proposal::new(ProposalAction::Sudo(action), sudo_key.clone(), 0); + + let result = engine.handle_proposal(proposal, &sudo_key).await; + // Should succeed but vote NO internally + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_vote_from_non_validator() { + let (engine, _rx) = create_test_engine(); + let non_validator = Keypair::generate(); + + let proposal_id = uuid::Uuid::new_v4(); + let vote = Vote::approve(proposal_id, non_validator.hotkey()); + + let result = engine.handle_vote(vote, &non_validator.hotkey()).await; + // Should succeed but be ignored + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_proposal_new_block() { + let (engine, _rx) = create_test_engine(); + + let state_hash = { + let state = engine.chain_state.read(); + state.state_hash + }; + + let proposal = Proposal::new( + ProposalAction::NewBlock { state_hash }, + engine.keypair.hotkey(), + 0, + ); + + let result = engine.apply_proposal(proposal).await; + assert!(result.is_ok()); + + // Block should have incremented + let block = engine.chain_state.read().block_height; + assert_eq!(block, 1); + } + + #[tokio::test] + async fn test_apply_proposal_job_completion() { + let (engine, _rx) = create_test_engine(); + + let job_id = uuid::Uuid::new_v4(); + let validator = engine.keypair.hotkey(); + + let proposal = Proposal::new( + ProposalAction::JobCompletion { + job_id, + result: platform_core::Score::new(0.85, 1.0), + validator, + }, + engine.keypair.hotkey(), + 0, + ); + + let result = engine.apply_proposal(proposal).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_sudo_action_all_variants() { + let (engine, _rx) = create_test_engine(); + let challenge_id = platform_core::ChallengeId::new(); + + // Test SetChallengeWeight + let action = SudoAction::SetChallengeWeight { + challenge_id, + mechanism_id: 1, + weight_ratio: 0.5, + }; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + assert!(state.challenge_weights.contains_key(&challenge_id)); + drop(state); + + // Test SetMechanismBurnRate + let action = SudoAction::SetMechanismBurnRate { + mechanism_id: 1, + burn_rate: 0.2, + }; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + drop(state); + + // Test SetMechanismConfig + let action = SudoAction::SetMechanismConfig { + mechanism_id: 2, + config: platform_core::MechanismWeightConfig::new(2), + }; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + drop(state); + + // Test EmergencyPause + let action = SudoAction::EmergencyPause { + reason: "Test".to_string(), + }; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + drop(state); + + // Test Resume + let action = SudoAction::Resume; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + drop(state); + + // Test ForceStateUpdate + let new_state = ChainState::new(Hotkey([99u8; 32]), NetworkConfig::default()); + let action = SudoAction::ForceStateUpdate { + state: new_state.clone(), + }; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + assert_eq!(state.sudo_key, Hotkey([99u8; 32])); + } + + #[tokio::test] + async fn test_validate_sudo_action_add_challenge() { + let (engine, _rx) = create_test_engine(); + let state = engine.chain_state.read(); + + // Valid challenge config + let config = platform_core::ChallengeContainerConfig::new( + "ValidChallenge", + "ghcr.io/platformnetwork/valid:latest", + 1, + 0.5, + ); + let action = SudoAction::AddChallenge { config }; + assert!(engine.validate_sudo_action(&state, &action)); + + // Invalid challenge config (empty name) + let invalid_config = platform_core::ChallengeContainerConfig::new("", "", 1, 0.5); + let action = SudoAction::AddChallenge { + config: invalid_config, + }; + assert!(!engine.validate_sudo_action(&state, &action)); + } + + #[tokio::test] + async fn test_validate_sudo_action_update_challenge() { + let (engine, _rx) = create_test_engine(); + let state = engine.chain_state.read(); + + // Valid update + let config = platform_core::ChallengeContainerConfig::new( + "ValidChallenge", + "ghcr.io/platformnetwork/valid:latest", + 1, + 0.5, + ); + let action = SudoAction::UpdateChallenge { config }; + assert!(engine.validate_sudo_action(&state, &action)); + + // Invalid update (empty name) + let invalid_config = platform_core::ChallengeContainerConfig::new("", "", 1, 0.5); + let action = SudoAction::UpdateChallenge { + config: invalid_config, + }; + assert!(!engine.validate_sudo_action(&state, &action)); + } + + #[tokio::test] + async fn test_validate_sudo_action_other_actions() { + let (engine, _rx) = create_test_engine(); + let state = engine.chain_state.read(); + + // All other sudo actions should validate to true + let action = SudoAction::UpdateConfig { + config: NetworkConfig::default(), + }; + assert!(engine.validate_sudo_action(&state, &action)); + + let action = SudoAction::RemoveChallenge { + id: platform_core::ChallengeId::new(), + }; + assert!(engine.validate_sudo_action(&state, &action)); + + let action = SudoAction::Resume; + assert!(engine.validate_sudo_action(&state, &action)); + } + + #[tokio::test] + async fn test_check_timeouts() { + let (engine, _rx) = create_test_engine(); + + // Start a round with short timeout + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 0, + ); + engine.state.start_round(proposal); + + // Wait for timeout (ConsensusConfig default is 30 seconds, but we can check immediately) + // For testing, we can use check_timeouts + let result = engine.check_timeouts().await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_validate_proposal_block_height() { + let (engine, _rx) = create_test_engine(); + + // Proposal with valid block height + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 0, + ); + assert!(engine.validate_proposal(&proposal)); + + // Proposal with too far future block height + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 100, + ); + assert!(!engine.validate_proposal(&proposal)); + } + + #[tokio::test] + async fn test_validate_proposal_sudo_non_sudo_proposer() { + let (engine, _rx) = create_test_engine(); + let non_sudo = Keypair::generate(); + + let action = SudoAction::UpdateConfig { + config: NetworkConfig::default(), + }; + let proposal = Proposal::new(ProposalAction::Sudo(action), non_sudo.hotkey(), 0); + + assert!(!engine.validate_proposal(&proposal)); + } + + #[tokio::test] + async fn test_validate_proposal_new_block() { + let (engine, _rx) = create_test_engine(); + + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 0, + ); + + assert!(engine.validate_proposal(&proposal)); + } + + #[tokio::test] + async fn test_validate_proposal_job_completion() { + let (engine, _rx) = create_test_engine(); + + let proposal = Proposal::new( + ProposalAction::JobCompletion { + job_id: uuid::Uuid::new_v4(), + result: platform_core::Score::new(0.9, 1.0), + validator: engine.keypair.hotkey(), + }, + engine.keypair.hotkey(), + 0, + ); + + assert!(engine.validate_proposal(&proposal)); + } + + #[tokio::test] + async fn test_consensus_status() { + let (engine, _rx) = create_test_engine(); + + // Add validators + { + let mut state = engine.chain_state.write(); + for _ in 0..5 { + let kp = Keypair::generate(); + let info = ValidatorInfo::new(kp.hotkey(), Stake::new(10_000_000_000)); + state.add_validator(info).unwrap(); + } + } + engine.sync_validators(); + + let status = engine.status(); + assert_eq!(status.validator_count, 5); + assert_eq!(status.threshold, 2); // ceil(5 * 0.33) = 2 + } } diff --git a/crates/consensus/src/stake_governance.rs b/crates/consensus/src/stake_governance.rs index 68f21959f..034abb0a0 100644 --- a/crates/consensus/src/stake_governance.rs +++ b/crates/consensus/src/stake_governance.rs @@ -1520,4 +1520,655 @@ mod tests { "5GziQCcRpN8NCJktX343brnfuVe3w6gUYieeStXPD1Dag2At" ); } + + #[test] + fn test_can_execute() { + let gov = create_test_governance(); + gov.set_block_height(1000); + let owner = subnet_owner_hotkey(); + + // During bootstrap, owner can execute + let result = gov.execute_bootstrap_action(&owner, &GovernanceActionType::AddChallenge); + assert!(result.is_ok()); + + // After bootstrap, requires stake consensus + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + let result = gov.execute_bootstrap_action(&owner, &GovernanceActionType::AddChallenge); + assert!(result.is_err()); + } + + #[test] + fn test_execute_bootstrap_action_non_owner() { + let gov = create_test_governance(); + gov.set_block_height(1000); + let (kp, _) = create_test_validator(1_000_000_000_000); + + let result = + gov.execute_bootstrap_action(&kp.hotkey(), &GovernanceActionType::AddChallenge); + assert!(result.is_err()); + } + + #[test] + fn test_create_proposal_no_stake() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, _) = create_test_validator(0); + + let result = gov.create_proposal( + GovernanceActionType::AddChallenge, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ); + + assert!(result.is_err()); + } + + #[test] + fn test_vote_on_expired_proposal() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let mut proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + // Manually expire the proposal + proposal.expires_at = chrono::Utc::now() - chrono::Duration::hours(1); + gov.proposals.write().insert(proposal.id, proposal.clone()); + + let result = gov.vote(proposal.id, &kp.hotkey(), true, &kp); + assert!(matches!( + result.unwrap(), + StakeConsensusResult::Expired { .. } + )); + } + + #[test] + fn test_vote_on_executed_proposal() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let mut proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + // Mark as executed + proposal.executed = true; + gov.proposals.write().insert(proposal.id, proposal.clone()); + + let result = gov.vote(proposal.id, &kp.hotkey(), true, &kp); + assert!(result.is_err()); + } + + #[test] + fn test_vote_on_cancelled_proposal() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let mut proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + // Mark as cancelled + proposal.cancelled = true; + gov.proposals.write().insert(proposal.id, proposal.clone()); + + let result = gov.vote(proposal.id, &kp.hotkey(), true, &kp); + assert!(matches!( + result.unwrap(), + StakeConsensusResult::Cancelled { .. } + )); + } + + #[test] + fn test_vote_no_stake() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp1, stake1) = create_test_validator(1_000_000_000_000); + let (kp2, _) = create_test_validator(0); + gov.update_validator_stakes(vec![stake1]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp1.hotkey(), + &kp1, + ) + .unwrap(); + + let result = gov.vote(proposal.id, &kp2.hotkey(), true, &kp2); + assert!(result.is_err()); + } + + #[test] + fn test_vote_rejection() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + // Create validators with different stakes + let (kp1, stake1) = create_test_validator(200_000_000_000); // 20% + let (kp2, stake2) = create_test_validator(800_000_000_000); // 80% + gov.update_validator_stakes(vec![stake1, stake2]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp1.hotkey(), + &kp1, + ) + .unwrap(); + + // kp2 (80% stake) votes NO + let result = gov.vote(proposal.id, &kp2.hotkey(), false, &kp2).unwrap(); + + // Should be rejected due to >50% stake voting NO + assert!(matches!(result, StakeConsensusResult::Rejected { .. })); + } + + #[test] + fn test_vote_pending_before_voting_period() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + // Vote immediately (before voting period completes) + let result = gov.check_consensus(proposal.id).unwrap(); + + // Should be pending even with 100% stake because voting period not complete + assert!(matches!(result, StakeConsensusResult::Pending { .. })); + } + + #[test] + fn test_mark_executed() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + assert!(gov.mark_executed(proposal.id).is_ok()); + + // Should not be able to execute again + let result = gov.mark_executed(proposal.id); + assert!(result.is_err()); + } + + #[test] + fn test_cancel_proposal_by_proposer() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + assert!(gov.cancel_proposal(proposal.id, &kp.hotkey()).is_ok()); + + // Verify cancelled + let p = gov.get_proposal(proposal.id).unwrap(); + assert!(p.cancelled); + } + + #[test] + fn test_cancel_proposal_by_owner() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + let owner = subnet_owner_hotkey(); + assert!(gov.cancel_proposal(proposal.id, &owner).is_ok()); + } + + #[test] + fn test_cancel_proposal_unauthorized() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp1, stake1) = create_test_validator(1_000_000_000_000); + let (kp2, stake2) = create_test_validator(500_000_000_000); + gov.update_validator_stakes(vec![stake1, stake2]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp1.hotkey(), + &kp1, + ) + .unwrap(); + + // kp2 cannot cancel kp1's proposal + let result = gov.cancel_proposal(proposal.id, &kp2.hotkey()); + assert!(result.is_err()); + } + + #[test] + fn test_cancel_executed_proposal() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + gov.mark_executed(proposal.id).unwrap(); + + let result = gov.cancel_proposal(proposal.id, &kp.hotkey()); + assert!(result.is_err()); + } + + #[test] + fn test_get_proposal() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + let retrieved = gov.get_proposal(proposal.id); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().id, proposal.id); + + let non_existent = gov.get_proposal(uuid::Uuid::new_v4()); + assert!(non_existent.is_none()); + } + + #[test] + fn test_get_votes() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp1, stake1) = create_test_validator(600_000_000_000); + let (kp2, stake2) = create_test_validator(400_000_000_000); + gov.update_validator_stakes(vec![stake1, stake2]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp1.hotkey(), + &kp1, + ) + .unwrap(); + + // Vote from kp2 + gov.vote(proposal.id, &kp2.hotkey(), true, &kp2).unwrap(); + + let votes = gov.get_votes(proposal.id); + // Should have 1 vote: kp2 + assert_eq!(votes.len(), 1); + } + + #[test] + fn test_cleanup_expired() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let mut proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + // Manually expire + proposal.expires_at = chrono::Utc::now() - chrono::Duration::hours(1); + gov.proposals.write().insert(proposal.id, proposal.clone()); + + gov.cleanup_expired(); + + // Should be removed + assert!(gov.get_proposal(proposal.id).is_none()); + } + + #[test] + fn test_check_rate_limit() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake.clone()]); + + // Create MAX_PROPOSALS_PER_DAY proposals + for i in 0..MAX_PROPOSALS_PER_DAY { + let result = gov.create_proposal( + GovernanceActionType::UpdateConfig, + format!("Test {}", i), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ); + assert!(result.is_ok()); + } + + // Next proposal should fail due to rate limit + let result = gov.create_proposal( + GovernanceActionType::UpdateConfig, + "Too many".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ); + assert!(result.is_err()); + } + + #[test] + fn test_rate_limit_resets_daily() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + // Create a proposal + let result = gov.create_proposal( + GovernanceActionType::UpdateConfig, + "Test 1".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ); + assert!(result.is_ok()); + + // Manually reset the counter date to yesterday + let yesterday = chrono::Utc::now() - chrono::Duration::days(1); + gov.proposal_counts + .write() + .insert(kp.hotkey(), (yesterday, 1)); + + // Should be able to create another proposal (counter reset) + let result = gov.create_proposal( + GovernanceActionType::UpdateConfig, + "Test 2".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_can_use_bootstrap() { + let gov = create_test_governance(); + let owner = subnet_owner_hotkey(); + let other = Hotkey([42u8; 32]); + + // During bootstrap, owner can use bootstrap + gov.set_block_height(1000); + assert!(gov.can_use_bootstrap(&owner)); + assert!(!gov.can_use_bootstrap(&other)); + + // After bootstrap, no one can use bootstrap + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + assert!(!gov.can_use_bootstrap(&owner)); + assert!(!gov.can_use_bootstrap(&other)); + } + + #[test] + fn test_default_stake_governance() { + let gov = StakeGovernance::default(); + assert_eq!(gov.block_height(), 0); + assert_eq!(gov.total_stake().0, 0); + } + + #[test] + fn test_bootstrap_sync_policy_default() { + let policy = BootstrapSyncPolicy::default(); + assert_eq!(policy.current_block, 0); + assert!(policy.is_bootstrap_active()); + } + + #[test] + fn test_bootstrap_sync_policy_owner_hotkey() { + let policy = BootstrapSyncPolicy::new(1000); + assert_eq!(policy.owner_hotkey(), &subnet_owner_hotkey()); + } + + #[test] + fn test_check_authorization_not_owner() { + let stake_gov = Arc::new(StakeGovernance::new()); + stake_gov.set_block_height(1000); // During bootstrap + + let hybrid = HybridGovernance::new(stake_gov); + let non_owner = Hotkey([42u8; 32]); + + let result = hybrid.check_authorization(&non_owner, &GovernanceActionType::AddChallenge); + assert!(matches!( + result, + AuthorizationResult::BootstrapOwnerOnly { .. } + )); + } + + #[test] + fn test_check_authorization_insufficient_stake() { + let stake_gov = Arc::new(StakeGovernance::new()); + stake_gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); // After bootstrap + + let hybrid = HybridGovernance::new(stake_gov); + let no_stake = Hotkey([42u8; 32]); + + let result = hybrid.check_authorization(&no_stake, &GovernanceActionType::AddChallenge); + assert!(matches!( + result, + AuthorizationResult::InsufficientStake { .. } + )); + } + + #[test] + fn test_execute_with_authorization_bootstrap() { + let stake_gov = Arc::new(StakeGovernance::new()); + stake_gov.set_block_height(1000); + + let hybrid = HybridGovernance::new(stake_gov); + let owner = subnet_owner_hotkey(); + + let mut executed = false; + let result = + hybrid.execute_with_authorization(&owner, GovernanceActionType::AddChallenge, || { + executed = true; + Ok(()) + }); + + assert!(result.is_ok()); + assert!(executed); + assert!(matches!( + result.unwrap(), + ExecutionResult::ExecutedViaBootstrap { .. } + )); + } + + #[test] + fn test_execute_with_authorization_requires_proposal() { + let stake_gov = Arc::new(StakeGovernance::new()); + stake_gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + stake_gov.update_validator_stakes(vec![stake]); + + let hybrid = HybridGovernance::new(stake_gov); + + let mut executed = false; + let result = hybrid.execute_with_authorization( + &kp.hotkey(), + GovernanceActionType::AddChallenge, + || { + executed = true; + Ok(()) + }, + ); + + assert!(result.is_ok()); + assert!(!executed); // Should not execute, requires proposal + assert!(matches!( + result.unwrap(), + ExecutionResult::RequiresProposal { .. } + )); + } + + #[test] + fn test_execute_with_authorization_bootstrap_non_owner() { + let stake_gov = Arc::new(StakeGovernance::new()); + stake_gov.set_block_height(1000); + + let hybrid = HybridGovernance::new(stake_gov); + let non_owner = Hotkey([42u8; 32]); + + let result = hybrid.execute_with_authorization( + &non_owner, + GovernanceActionType::AddChallenge, + || Ok(()), + ); + + assert!(result.is_err()); + } + + #[test] + fn test_execute_with_authorization_no_stake() { + let stake_gov = Arc::new(StakeGovernance::new()); + stake_gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let hybrid = HybridGovernance::new(stake_gov); + let no_stake = Hotkey([42u8; 32]); + + let result = hybrid.execute_with_authorization( + &no_stake, + GovernanceActionType::AddChallenge, + || Ok(()), + ); + + assert!(result.is_err()); + } + + #[test] + fn test_stake_governance_constructor() { + let gov = StakeGovernance::new(); + assert_eq!(gov.block_height(), 0); + assert_eq!(gov.total_stake().0, 0); + assert!(gov.is_bootstrap_period()); + assert_eq!(gov.active_proposals().len(), 0); + } } diff --git a/crates/consensus/src/stake_weighted_pbft.rs b/crates/consensus/src/stake_weighted_pbft.rs index 662e40bda..42f955cb9 100644 --- a/crates/consensus/src/stake_weighted_pbft.rs +++ b/crates/consensus/src/stake_weighted_pbft.rs @@ -912,4 +912,259 @@ mod tests { assert!(matches!(msg1.message, NetworkMessage::Proposal(_))); assert!(matches!(msg2.message, NetworkMessage::Vote(_))); } + + #[tokio::test] + async fn test_handle_proposal_wrong_signer() { + let (engine, _rx) = create_test_engine(); + let other_key = Keypair::generate().hotkey(); + + let action = SudoAction::UpdateConfig { + config: NetworkConfig::default(), + }; + let proposal = Proposal::new(ProposalAction::Sudo(action), other_key.clone(), 0); + + let result = engine + .handle_proposal(proposal, &engine.keypair.hotkey()) + .await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_handle_proposal_non_sudo() { + let (engine, _rx) = create_test_engine(); + let non_sudo = Keypair::generate(); + + let action = SudoAction::UpdateConfig { + config: NetworkConfig::default(), + }; + let proposal = Proposal::new(ProposalAction::Sudo(action), non_sudo.hotkey(), 0); + + let result = engine.handle_proposal(proposal, &non_sudo.hotkey()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_handle_proposal_invalid_config() { + let (engine, _rx) = create_test_engine(); + let sudo_key = engine.keypair.hotkey(); + + let config = platform_core::ChallengeContainerConfig::new("", "", 1, 0.5); + + let action = SudoAction::AddChallenge { config }; + let proposal = Proposal::new(ProposalAction::Sudo(action), sudo_key.clone(), 0); + + let result = engine.handle_proposal(proposal, &sudo_key).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_vote_non_validator() { + let (engine, _rx) = create_test_engine(); + let non_validator = Keypair::generate(); + + let proposal_id = uuid::Uuid::new_v4(); + let vote = Vote::approve(proposal_id, non_validator.hotkey()); + + let result = engine.handle_vote(vote, &non_validator.hotkey()).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_proposal_new_block() { + let (engine, _rx) = create_test_engine(); + + let state_hash = { + let state = engine.chain_state.read(); + state.state_hash + }; + + let proposal = Proposal::new( + ProposalAction::NewBlock { state_hash }, + engine.keypair.hotkey(), + 0, + ); + + let result = engine.apply_proposal(proposal).await; + assert!(result.is_ok()); + + let block = engine.chain_state.read().block_height; + assert_eq!(block, 1); + } + + #[tokio::test] + async fn test_apply_proposal_job_completion() { + let (engine, _rx) = create_test_engine(); + + let proposal = Proposal::new( + ProposalAction::JobCompletion { + job_id: uuid::Uuid::new_v4(), + result: platform_core::Score::new(0.75, 1.0), + validator: engine.keypair.hotkey(), + }, + engine.keypair.hotkey(), + 0, + ); + + let result = engine.apply_proposal(proposal).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_sudo_action_all_variants() { + let (engine, _rx) = create_test_engine(); + let challenge_id = platform_core::ChallengeId::new(); + + // Test SetChallengeWeight + let action = SudoAction::SetChallengeWeight { + challenge_id, + mechanism_id: 1, + weight_ratio: 0.5, + }; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + drop(state); + + // Test SetMechanismBurnRate + let action = SudoAction::SetMechanismBurnRate { + mechanism_id: 1, + burn_rate: 0.2, + }; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + drop(state); + + // Test SetMechanismConfig + let action = SudoAction::SetMechanismConfig { + mechanism_id: 2, + config: platform_core::MechanismWeightConfig::new(2), + }; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + drop(state); + + // Test EmergencyPause + let action = SudoAction::EmergencyPause { + reason: "Test".to_string(), + }; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + drop(state); + + // Test Resume + let action = SudoAction::Resume; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + drop(state); + + // Test ForceStateUpdate + let new_state = ChainState::new(Hotkey([99u8; 32]), NetworkConfig::default()); + let action = SudoAction::ForceStateUpdate { + state: new_state.clone(), + }; + let mut state = engine.chain_state.write(); + assert!(engine.apply_sudo_action(&mut state, action).is_ok()); + } + + #[tokio::test] + async fn test_validate_sudo_action() { + let (engine, _rx) = create_test_engine(); + let state = engine.chain_state.read(); + + // Valid challenge + let config = platform_core::ChallengeContainerConfig::new( + "Valid", + "ghcr.io/platformnetwork/valid:latest", + 1, + 0.5, + ); + let action = SudoAction::AddChallenge { config }; + assert!(engine.validate_sudo_action(&state, &action)); + + // Invalid challenge (empty name) + let invalid_config = platform_core::ChallengeContainerConfig::new("", "", 1, 0.5); + let action = SudoAction::AddChallenge { + config: invalid_config, + }; + assert!(!engine.validate_sudo_action(&state, &action)); + + // Other actions validate true + let action = SudoAction::Resume; + assert!(engine.validate_sudo_action(&state, &action)); + } + + #[tokio::test] + async fn test_validate_proposal() { + let (engine, _rx) = create_test_engine(); + + // Valid proposal + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 0, + ); + assert!(engine.validate_proposal(&proposal)); + + // Invalid block height + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 100, + ); + assert!(!engine.validate_proposal(&proposal)); + + // Non-sudo proposing sudo action + let non_sudo = Keypair::generate(); + let action = SudoAction::UpdateConfig { + config: NetworkConfig::default(), + }; + let proposal = Proposal::new(ProposalAction::Sudo(action), non_sudo.hotkey(), 0); + assert!(!engine.validate_proposal(&proposal)); + } + + #[tokio::test] + async fn test_check_timeouts() { + let (engine, _rx) = create_test_engine(); + + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 0, + ); + engine.start_round(proposal); + + let timeouts = engine.check_timeouts(); + // Just verify it doesn't panic + assert_eq!(timeouts.len(), 0); + } + + #[tokio::test] + async fn test_consensus_threshold() { + let (engine, _rx) = create_test_engine(); + + // Add validators with specific stakes + { + let mut state = engine.chain_state.write(); + let v1 = Keypair::generate(); + let v2 = Keypair::generate(); + state + .add_validator(ValidatorInfo::new(v1.hotkey(), Stake::new(300_000_000_000))) + .unwrap(); + state + .add_validator(ValidatorInfo::new(v2.hotkey(), Stake::new(700_000_000_000))) + .unwrap(); + } + + let total = engine.total_stake(); + assert_eq!(total, 1_000_000_000_000); + + let status = engine.status(); + // Threshold is total/2 + 1 = 500_000_000_001 + assert_eq!(status.threshold_stake, 500_000_000_001); + } } diff --git a/crates/consensus/src/state.rs b/crates/consensus/src/state.rs index f50ed7bce..82e289087 100644 --- a/crates/consensus/src/state.rs +++ b/crates/consensus/src/state.rs @@ -226,4 +226,66 @@ mod tests { panic!("Should have been rejected"); } + + #[test] + fn test_completed_results() { + let state = ConsensusState::new(ConsensusConfig::default()); + state.set_validator_count(4); + + let proposer = Keypair::generate(); + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + proposer.hotkey(), + 1, + ); + + let proposal_id = state.start_round(proposal); + + // Add votes to reach consensus + for _ in 0..3 { + let voter = Keypair::generate(); + let vote = Vote::approve(proposal_id, voter.hotkey()); + state.add_vote(vote); + } + + // Check completed results + let completed = state.completed_results(); + assert_eq!(completed.len(), 1); + assert!(matches!(completed[0], ConsensusResult::Approved(_))); + } + + #[test] + fn test_clear_completed() { + let state = ConsensusState::new(ConsensusConfig::default()); + state.set_validator_count(4); + + let proposer = Keypair::generate(); + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + proposer.hotkey(), + 1, + ); + + let proposal_id = state.start_round(proposal); + + // Add votes to reach consensus + for _ in 0..3 { + let voter = Keypair::generate(); + let vote = Vote::approve(proposal_id, voter.hotkey()); + state.add_vote(vote); + } + + // Verify we have completed results + assert_eq!(state.completed_results().len(), 1); + + // Clear completed + state.clear_completed(); + + // Verify cleared + assert_eq!(state.completed_results().len(), 0); + } } From 7d96ec8a6829c51ae91ec0b6b8e07ff1601374c4 Mon Sep 17 00:00:00 2001 From: cuteolaf Date: Thu, 8 Jan 2026 17:55:45 +0100 Subject: [PATCH 2/6] test(consensus): comprehensive coverage improvements for platform-consensus --- .../consensus/src/governance_integration.rs | 177 ++++++++ crates/consensus/src/pbft.rs | 233 ++++++++++ crates/consensus/src/stake_governance.rs | 319 +++++++++++++ crates/consensus/src/stake_weighted_pbft.rs | 425 ++++++++++++++++++ crates/consensus/src/state.rs | 161 +++++++ 5 files changed, 1315 insertions(+) diff --git a/crates/consensus/src/governance_integration.rs b/crates/consensus/src/governance_integration.rs index 4bca762e0..19d3ba70b 100644 --- a/crates/consensus/src/governance_integration.rs +++ b/crates/consensus/src/governance_integration.rs @@ -955,4 +955,181 @@ mod tests { assert_eq!(sync.governance().block_height(), 1_000_000); assert_eq!(sync.governance().total_stake().0, 300_000_000_000); } + + #[tokio::test] + async fn test_governance_pbft_new() { + let keypair = Keypair::generate(); + let state = Arc::new(RwLock::new(ChainState::new( + keypair.hotkey(), + platform_core::NetworkConfig::default(), + ))); + let (tx, _rx) = tokio::sync::mpsc::channel(100); + let pbft = Arc::new(PBFTEngine::new(keypair.clone(), state.clone(), tx)); + + let gov_pbft = GovernancePBFT::new(pbft, state, keypair); + + // Verify initialization - governance status + let status = gov_pbft.governance_status(); + assert!(status.is_bootstrap_period); + } + + #[tokio::test] + async fn test_set_block_height() { + let keypair = Keypair::generate(); + let state = Arc::new(RwLock::new(ChainState::new( + keypair.hotkey(), + platform_core::NetworkConfig::default(), + ))); + let (tx, _rx) = tokio::sync::mpsc::channel(100); + let pbft = Arc::new(PBFTEngine::new(keypair.clone(), state.clone(), tx)); + + let gov_pbft = GovernancePBFT::new(pbft, state, keypair); + + gov_pbft.set_block_height(5000); + assert_eq!(gov_pbft.governance.stake_governance().block_height(), 5000); + } + + #[tokio::test] + async fn test_update_stakes_from_metagraph() { + let keypair = Keypair::generate(); + let state = Arc::new(RwLock::new(ChainState::new( + keypair.hotkey(), + platform_core::NetworkConfig::default(), + ))); + let (tx, _rx) = tokio::sync::mpsc::channel(100); + let pbft = Arc::new(PBFTEngine::new(keypair.clone(), state.clone(), tx)); + + let gov_pbft = GovernancePBFT::new(pbft, state, keypair); + + let validators = vec![ + ValidatorInfo::new(Hotkey([1u8; 32]), Stake(100_000_000_000)), + ValidatorInfo::new(Hotkey([2u8; 32]), Stake(200_000_000_000)), + ]; + + gov_pbft.update_stakes_from_metagraph(&validators); + + assert_eq!( + gov_pbft.governance.stake_governance().total_stake().0, + 300_000_000_000 + ); + } + + #[tokio::test] + async fn test_execute_sudo_action_bootstrap_non_owner() { + let keypair = Keypair::generate(); + let state = Arc::new(RwLock::new(ChainState::new( + keypair.hotkey(), + platform_core::NetworkConfig::default(), + ))); + let (tx, _rx) = tokio::sync::mpsc::channel(100); + let pbft = Arc::new(PBFTEngine::new(keypair.clone(), state.clone(), tx)); + + let gov_pbft = GovernancePBFT::new(pbft, state, keypair); + + gov_pbft.set_block_height(1000); // In bootstrap period + + let action = SudoAction::UpdateConfig { + config: platform_core::NetworkConfig::default(), + }; + + let result = gov_pbft.execute_sudo_action(action).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_governance_status() { + let keypair = Keypair::generate(); + let state = Arc::new(RwLock::new(ChainState::new( + keypair.hotkey(), + platform_core::NetworkConfig::default(), + ))); + let (tx, _rx) = tokio::sync::mpsc::channel(100); + let pbft = Arc::new(PBFTEngine::new(keypair.clone(), state.clone(), tx)); + + let gov_pbft = GovernancePBFT::new(pbft, state, keypair); + + let status = gov_pbft.governance_status(); + assert_eq!(status.current_block, 0); + assert!(status.is_bootstrap_period); + } + + #[tokio::test] + async fn test_active_proposals() { + let keypair = Keypair::generate(); + let state = Arc::new(RwLock::new(ChainState::new( + keypair.hotkey(), + platform_core::NetworkConfig::default(), + ))); + let (tx, _rx) = tokio::sync::mpsc::channel(100); + let pbft = Arc::new(PBFTEngine::new(keypair.clone(), state.clone(), tx)); + + let gov_pbft = GovernancePBFT::new(pbft, state, keypair); + + let proposals = gov_pbft.active_proposals(); + assert_eq!(proposals.len(), 0); + } + + #[tokio::test] + async fn test_stake_for_consensus() { + let keypair = Keypair::generate(); + let state = Arc::new(RwLock::new(ChainState::new( + keypair.hotkey(), + platform_core::NetworkConfig::default(), + ))); + let (tx, _rx) = tokio::sync::mpsc::channel(100); + let pbft = Arc::new(PBFTEngine::new(keypair.clone(), state.clone(), tx)); + + let gov_pbft = GovernancePBFT::new(pbft, state, keypair); + + let (required, threshold) = gov_pbft.stake_for_consensus(); + assert_eq!(threshold, crate::stake_governance::STAKE_THRESHOLD_PERCENT); + } + + #[tokio::test] + async fn test_execute_sudo_action_post_bootstrap() { + let keypair = Keypair::generate(); + let state = Arc::new(RwLock::new(ChainState::new( + keypair.hotkey(), + platform_core::NetworkConfig::default(), + ))); + let (tx, _rx) = tokio::sync::mpsc::channel(100); + let pbft = Arc::new(PBFTEngine::new(keypair.clone(), state.clone(), tx)); + + let gov_pbft = GovernancePBFT::new(pbft, state, keypair); + gov_pbft.set_block_height(crate::stake_governance::BOOTSTRAP_END_BLOCK + 1); + + // Add stake for the validator + let validators = vec![ValidatorInfo::new( + gov_pbft.keypair.hotkey(), + Stake(100_000_000_000), + )]; + gov_pbft.update_stakes_from_metagraph(&validators); + + let action = SudoAction::UpdateConfig { + config: platform_core::NetworkConfig::default(), + }; + + let result = gov_pbft.execute_sudo_action(action).await; + assert!(result.is_ok()); + assert!(matches!( + result.unwrap(), + SudoExecutionResult::ProposalCreated { .. } + )); + } + + #[tokio::test] + async fn test_can_use_bootstrap_false() { + let keypair = Keypair::generate(); + let state = Arc::new(RwLock::new(ChainState::new( + keypair.hotkey(), + platform_core::NetworkConfig::default(), + ))); + let (tx, _rx) = tokio::sync::mpsc::channel(100); + let pbft = Arc::new(PBFTEngine::new(keypair.clone(), state.clone(), tx)); + + let gov_pbft = GovernancePBFT::new(pbft, state, keypair); + gov_pbft.set_block_height(1000); + + assert!(!gov_pbft.can_use_bootstrap()); + } } diff --git a/crates/consensus/src/pbft.rs b/crates/consensus/src/pbft.rs index 98617b1e3..ba0952629 100644 --- a/crates/consensus/src/pbft.rs +++ b/crates/consensus/src/pbft.rs @@ -857,4 +857,237 @@ mod tests { assert_eq!(status.validator_count, 5); assert_eq!(status.threshold, 2); // ceil(5 * 0.33) = 2 } + + #[tokio::test] + async fn test_propose_block_flow() { + let (engine, mut rx) = create_test_engine(); + + // Add validators + { + let mut state = engine.chain_state.write(); + for _ in 0..3 { + let kp = Keypair::generate(); + let info = ValidatorInfo::new(kp.hotkey(), Stake::new(10_000_000_000)); + state.add_validator(info).unwrap(); + } + } + engine.sync_validators(); + + let result = engine.propose_block().await; + assert!(result.is_ok()); + + // Should broadcast proposal and vote + let _proposal_msg = rx.recv().await; + let _vote_msg = rx.recv().await; + } + + #[tokio::test] + async fn test_handle_consensus_result_rejected() { + let (engine, _rx) = create_test_engine(); + + let proposal_id = uuid::Uuid::new_v4(); + let result = ConsensusResult::Rejected { + proposal_id, + reason: "Test rejection".to_string(), + }; + + // Should not panic + let res = engine.handle_consensus_result(result).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn test_handle_consensus_result_pending() { + let (engine, _rx) = create_test_engine(); + + let result = ConsensusResult::Pending; + + // Should not panic + let res = engine.handle_consensus_result(result).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn test_apply_sudo_action_refresh_challenges_with_id() { + let (engine, _rx) = create_test_engine(); + + let challenge_id = platform_core::ChallengeId::new(); + let action = SudoAction::RefreshChallenges { + challenge_id: Some(challenge_id), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_sudo_action_refresh_challenges_all() { + let (engine, _rx) = create_test_engine(); + + let action = SudoAction::RefreshChallenges { challenge_id: None }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_sudo_action_add_validator() { + let (engine, _rx) = create_test_engine(); + + let new_val = ValidatorInfo::new(Hotkey([55u8; 32]), Stake::new(100_000_000_000)); + let action = SudoAction::AddValidator { + info: new_val.clone(), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + assert!(state.get_validator(&new_val.hotkey).is_some()); + } + + #[tokio::test] + async fn test_apply_sudo_action_remove_validator() { + let (engine, _rx) = create_test_engine(); + + let hotkey = Hotkey([66u8; 32]); + + // Add then remove + { + let mut state = engine.chain_state.write(); + let val = ValidatorInfo::new(hotkey.clone(), Stake::new(100_000_000_000)); + state.add_validator(val).unwrap(); + } + + let action = SudoAction::RemoveValidator { + hotkey: hotkey.clone(), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + assert!(state.get_validator(&hotkey).is_none()); + } + + #[tokio::test] + async fn test_apply_sudo_action_set_required_version() { + let (engine, _rx) = create_test_engine(); + + let action = SudoAction::SetRequiredVersion { + min_version: "2.0.0".to_string(), + recommended_version: "2.1.0".to_string(), + docker_image: "validator:2.0.0".to_string(), + mandatory: true, + deadline_block: Some(100000), + release_notes: Some("Important update".to_string()), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + assert!(state.required_version.is_some()); + } + + #[tokio::test] + async fn test_propose_sudo_non_sudo() { + let (engine, _rx) = create_test_engine(); + + // Create a different keypair (non-sudo) + let non_sudo = Keypair::generate(); + let state = Arc::new(RwLock::new(ChainState::new( + engine.keypair.hotkey(), + NetworkConfig::default(), + ))); + let (tx, _rx2) = mpsc::channel(100); + let non_sudo_engine = PBFTEngine::new(non_sudo, state, tx); + + let action = SudoAction::Resume; + let result = non_sudo_engine.propose_sudo(action).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_handle_vote_wrong_voter() { + let (engine, _rx) = create_test_engine(); + + let proposal_id = uuid::Uuid::new_v4(); + let wrong_hotkey = Keypair::generate().hotkey(); + let vote = Vote::approve(proposal_id, wrong_hotkey.clone()); + + // Try to handle vote signed by different key + let actual_signer = Keypair::generate().hotkey(); + let result = engine.handle_vote(vote, &actual_signer).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_apply_sudo_action_add_challenge_full() { + let (engine, _rx) = create_test_engine(); + + let config = platform_core::ChallengeContainerConfig::new( + "NewChallenge", + "ghcr.io/platformnetwork/new:latest", + 2, + 0.3, + ); + let challenge_id = config.challenge_id; + let action = SudoAction::AddChallenge { + config: config.clone(), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + assert!(state.challenge_configs.contains_key(&challenge_id)); + assert_eq!(state.challenge_configs[&challenge_id].name, "NewChallenge"); + } + + #[tokio::test] + async fn test_apply_sudo_action_update_challenge_full() { + let (engine, _rx) = create_test_engine(); + + let config = platform_core::ChallengeContainerConfig::new( + "UpdatedChallenge", + "ghcr.io/platformnetwork/updated:v2", + 3, + 0.4, + ); + let challenge_id = config.challenge_id; + + let action = SudoAction::UpdateChallenge { + config: config.clone(), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + assert!(state.challenge_configs.contains_key(&challenge_id)); + } + + #[tokio::test] + async fn test_apply_sudo_action_remove_challenge_full() { + let (engine, _rx) = create_test_engine(); + + let config = platform_core::ChallengeContainerConfig::new( + "ToRemove", + "ghcr.io/platformnetwork/remove:latest", + 1, + 0.2, + ); + let challenge_id = config.challenge_id; + + // First add the challenge + { + let mut state = engine.chain_state.write(); + state.challenge_configs.insert(challenge_id, config); + } + + let action = SudoAction::RemoveChallenge { id: challenge_id }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + assert!(!state.challenge_configs.contains_key(&challenge_id)); + } } diff --git a/crates/consensus/src/stake_governance.rs b/crates/consensus/src/stake_governance.rs index 034abb0a0..60f853f65 100644 --- a/crates/consensus/src/stake_governance.rs +++ b/crates/consensus/src/stake_governance.rs @@ -2171,4 +2171,323 @@ mod tests { assert!(gov.is_bootstrap_period()); assert_eq!(gov.active_proposals().len(), 0); } + + #[test] + fn test_check_consensus_no_validators() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, _) = create_test_validator(0); + + // Create proposal without any validators having stake + let proposal_id = uuid::Uuid::new_v4(); + let proposal = GovernanceProposal::new( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + kp.hotkey(), + 0, + ); + gov.proposals.write().insert(proposal_id, proposal); + + let result = gov.check_consensus(proposal_id); + assert!(result.is_err()); + } + + #[test] + fn test_proposal_voting_period_not_complete() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp1, stake1) = create_test_validator(600_000_000_000); + let (kp2, stake2) = create_test_validator(400_000_000_000); + gov.update_validator_stakes(vec![stake1, stake2]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp1.hotkey(), + &kp1, + ) + .unwrap(); + + // Vote immediately with kp2 to get enough stake + gov.vote(proposal.id, &kp2.hotkey(), true, &kp2).unwrap(); + + // Check consensus - should be pending because voting period not complete + let result = gov.check_consensus(proposal.id).unwrap(); + assert!(matches!(result, StakeConsensusResult::Pending { .. })); + } + + #[test] + fn test_double_vote_error() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + // First vote + gov.vote(proposal.id, &kp.hotkey(), true, &kp).ok(); + + // Second vote should fail + let result = gov.vote(proposal.id, &kp.hotkey(), false, &kp); + assert!(result.is_err()); + } + + #[test] + fn test_get_validator_stake_nonexistent() { + let gov = create_test_governance(); + + let hotkey = Hotkey([99u8; 32]); + let stake = gov.get_validator_stake(&hotkey); + assert!(stake.is_none()); + } + + #[test] + fn test_mark_executed_already_executed() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + // Mark as executed twice + gov.mark_executed(proposal.id).ok(); + let result = gov.mark_executed(proposal.id); + assert!(result.is_err()); + } + + #[test] + fn test_cancel_proposal_not_found() { + let gov = create_test_governance(); + + let fake_id = uuid::Uuid::new_v4(); + let hotkey = Hotkey([1u8; 32]); + + let result = gov.cancel_proposal(fake_id, &hotkey); + assert!(result.is_err()); + } + + #[test] + fn test_get_proposal_nonexistent() { + let gov = create_test_governance(); + + let fake_id = uuid::Uuid::new_v4(); + let result = gov.get_proposal(fake_id); + assert!(result.is_none()); + } + + #[test] + fn test_get_votes_no_votes() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let proposal = gov + .create_proposal( + GovernanceActionType::UpdateConfig, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + let votes = gov.get_votes(proposal.id); + assert_eq!(votes.len(), 0); + } + + #[test] + fn test_increment_proposal_count() { + let gov = create_test_governance(); + + let hotkey = Hotkey([1u8; 32]); + gov.increment_proposal_count(&hotkey); + + let counts = gov.proposal_counts.read(); + assert!(counts.contains_key(&hotkey)); + assert_eq!(counts.get(&hotkey).unwrap().1, 1); + } + + #[test] + fn test_status_summary() { + let gov = create_test_governance(); + gov.set_block_height(1000); + + let (_, stake) = create_test_validator(500_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let status = gov.status(); + assert_eq!(status.current_block, 1000); + assert!(status.is_bootstrap_period); + assert_eq!(status.total_stake.0, 500_000_000_000); + assert_eq!(status.validator_count, 1); + } + + #[test] + fn test_vote_proposal_not_found() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let fake_id = uuid::Uuid::new_v4(); + let result = gov.vote(fake_id, &kp.hotkey(), true, &kp); + assert!(result.is_err()); + } + + #[test] + fn test_check_consensus_proposal_not_found() { + let gov = create_test_governance(); + + let fake_id = uuid::Uuid::new_v4(); + let result = gov.check_consensus(fake_id); + assert!(result.is_err()); + } + + #[test] + fn test_check_consensus_cancelled() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let mut proposal = gov + .create_proposal( + GovernanceActionType::Resume, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + // Mark as cancelled + proposal.cancelled = true; + gov.proposals.write().insert(proposal.id, proposal.clone()); + + let result = gov.check_consensus(proposal.id).unwrap(); + assert!(matches!(result, StakeConsensusResult::Cancelled { .. })); + } + + #[test] + fn test_check_consensus_expired() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp, stake) = create_test_validator(1_000_000_000_000); + gov.update_validator_stakes(vec![stake]); + + let mut proposal = gov + .create_proposal( + GovernanceActionType::Resume, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp.hotkey(), + &kp, + ) + .unwrap(); + + // Expire the proposal + proposal.expires_at = chrono::Utc::now() - chrono::Duration::hours(1); + gov.proposals.write().insert(proposal.id, proposal.clone()); + + let result = gov.check_consensus(proposal.id).unwrap(); + assert!(matches!(result, StakeConsensusResult::Expired { .. })); + } + + #[test] + fn test_check_consensus_rejection_threshold() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp1, stake1) = create_test_validator(200_000_000_000); + let (kp2, stake2) = create_test_validator(800_000_000_000); + gov.update_validator_stakes(vec![stake1, stake2]); + + let proposal = gov + .create_proposal( + GovernanceActionType::Resume, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp1.hotkey(), + &kp1, + ) + .unwrap(); + + // kp2 votes NO with 80% stake + gov.vote(proposal.id, &kp2.hotkey(), false, &kp2).ok(); + + let result = gov.check_consensus(proposal.id).unwrap(); + assert!(matches!(result, StakeConsensusResult::Rejected { .. })); + } + + #[test] + fn test_check_consensus_pending_remaining_stake() { + let gov = create_test_governance(); + gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); + + let (kp1, stake1) = create_test_validator(300_000_000_000); + let (kp2, stake2) = create_test_validator(700_000_000_000); + gov.update_validator_stakes(vec![stake1, stake2]); + + let proposal = gov + .create_proposal( + GovernanceActionType::Resume, + "Test".to_string(), + "Test".to_string(), + vec![], + &kp1.hotkey(), + &kp1, + ) + .unwrap(); + + // Only kp1 has "voted" (30% stake) - not enough + let result = gov.check_consensus(proposal.id).unwrap(); + assert!(matches!(result, StakeConsensusResult::Pending { .. })); + } + + #[test] + fn test_mark_executed_proposal_not_found() { + let gov = create_test_governance(); + + let fake_id = uuid::Uuid::new_v4(); + let result = gov.mark_executed(fake_id); + assert!(result.is_err()); + } } diff --git a/crates/consensus/src/stake_weighted_pbft.rs b/crates/consensus/src/stake_weighted_pbft.rs index 42f955cb9..50c59b1a3 100644 --- a/crates/consensus/src/stake_weighted_pbft.rs +++ b/crates/consensus/src/stake_weighted_pbft.rs @@ -1167,4 +1167,429 @@ mod tests { // Threshold is total/2 + 1 = 500_000_000_001 assert_eq!(status.threshold_stake, 500_000_000_001); } + + #[tokio::test] + async fn test_propose_block_flow() { + let (engine, mut rx) = create_test_engine(); + + // Add validators + { + let mut state = engine.chain_state.write(); + for _ in 0..3 { + let kp = Keypair::generate(); + let info = ValidatorInfo::new(kp.hotkey(), Stake::new(10_000_000_000)); + state.add_validator(info).unwrap(); + } + } + + let result = engine.propose_block().await; + assert!(result.is_ok()); + + // Should broadcast proposal and vote + let _proposal_msg = rx.recv().await; + let _vote_msg = rx.recv().await; + } + + #[tokio::test] + async fn test_handle_result_pending() { + let (engine, _rx) = create_test_engine(); + + let result = StakeWeightedResult::Pending { + approve_stake: 100, + reject_stake: 50, + total_stake: 1000, + }; + + // Should not panic + let res = engine.handle_result(result).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn test_handle_result_timeout() { + let (engine, _rx) = create_test_engine(); + + let result = StakeWeightedResult::Timeout { + proposal_id: uuid::Uuid::new_v4(), + }; + + // Should not panic + let res = engine.handle_result(result).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn test_handle_result_rejected() { + let (engine, _rx) = create_test_engine(); + + let result = StakeWeightedResult::Rejected { + proposal_id: uuid::Uuid::new_v4(), + reject_stake: 600, + total_stake: 1000, + reason: "Test rejection".to_string(), + }; + + let res = engine.handle_result(result).await; + assert!(res.is_ok()); + } + + #[tokio::test] + async fn test_apply_proposal_job_completion_details() { + let (engine, _rx) = create_test_engine(); + + let job_id = uuid::Uuid::new_v4(); + let proposal = Proposal::new( + ProposalAction::JobCompletion { + job_id, + result: platform_core::Score::new(0.95, 1.0), + validator: engine.keypair.hotkey(), + }, + engine.keypair.hotkey(), + 0, + ); + + let result = engine.apply_proposal(proposal).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_sudo_action_update_config() { + let (engine, _rx) = create_test_engine(); + + let action = SudoAction::UpdateConfig { + config: NetworkConfig::default(), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_sudo_action_add_challenge() { + let (engine, _rx) = create_test_engine(); + + let config = platform_core::ChallengeContainerConfig::new( + "TestChallenge", + "ghcr.io/platformnetwork/test:latest", + 1, + 0.5, + ); + let action = SudoAction::AddChallenge { + config: config.clone(), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + assert!(state.challenge_configs.contains_key(&config.challenge_id)); + } + + #[tokio::test] + async fn test_apply_sudo_action_update_challenge() { + let (engine, _rx) = create_test_engine(); + + let config = platform_core::ChallengeContainerConfig::new( + "TestChallenge", + "ghcr.io/platformnetwork/updated:latest", + 1, + 0.6, + ); + let action = SudoAction::UpdateChallenge { + config: config.clone(), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_sudo_action_remove_challenge() { + let (engine, _rx) = create_test_engine(); + + let challenge_id = platform_core::ChallengeId::new(); + let action = SudoAction::RemoveChallenge { id: challenge_id }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_sudo_action_refresh_challenges() { + let (engine, _rx) = create_test_engine(); + + let action = SudoAction::RefreshChallenges { + challenge_id: Some(platform_core::ChallengeId::new()), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_sudo_action_set_required_version() { + let (engine, _rx) = create_test_engine(); + + let action = SudoAction::SetRequiredVersion { + min_version: "1.5.0".to_string(), + recommended_version: "1.6.0".to_string(), + docker_image: "validator:1.5.0".to_string(), + mandatory: false, + deadline_block: None, + release_notes: None, + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_apply_sudo_action_add_validator() { + let (engine, _rx) = create_test_engine(); + + let new_val = ValidatorInfo::new(Hotkey([77u8; 32]), Stake::new(150_000_000_000)); + let action = SudoAction::AddValidator { + info: new_val.clone(), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + assert!(state.get_validator(&new_val.hotkey).is_some()); + } + + #[tokio::test] + async fn test_apply_sudo_action_remove_validator() { + let (engine, _rx) = create_test_engine(); + + let hotkey = Hotkey([88u8; 32]); + + // Add validator first + { + let mut state = engine.chain_state.write(); + let val = ValidatorInfo::new(hotkey.clone(), Stake::new(100_000_000_000)); + state.add_validator(val).unwrap(); + } + + let action = SudoAction::RemoveValidator { + hotkey: hotkey.clone(), + }; + + let mut state = engine.chain_state.write(); + let result = engine.apply_sudo_action(&mut state, action); + assert!(result.is_ok()); + assert!(state.get_validator(&hotkey).is_none()); + } + + #[tokio::test] + async fn test_validate_proposal_sudo_action() { + let (engine, _rx) = create_test_engine(); + + // Valid sudo action from sudo key + let action = SudoAction::Resume; + let proposal = Proposal::new(ProposalAction::Sudo(action), engine.keypair.hotkey(), 0); + + assert!(engine.validate_proposal(&proposal)); + } + + #[tokio::test] + async fn test_check_timeouts_with_actual_timeout() { + let (engine, _rx) = create_test_engine(); + + // Create a proposal + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 0, + ); + engine.start_round(proposal); + + // Check timeouts (won't actually timeout immediately) + let timeouts = engine.check_timeouts(); + assert_eq!(timeouts.len(), 0); + } + + #[tokio::test] + async fn test_active_rounds_tracking() { + let (engine, _rx) = create_test_engine(); + + assert_eq!(engine.active_rounds(), 0); + + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 0, + ); + engine.start_round(proposal); + + assert_eq!(engine.active_rounds(), 1); + } + + #[test] + fn test_round_state_total_stake_zero() { + let kp = Keypair::generate(); + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + kp.hotkey(), + 0, + ); + + let round = StakeWeightedRoundState::new(proposal, 30); + + // With total_stake = 0, should return false + assert!(!round.has_stake_consensus(0)); + assert!(!round.is_stake_rejected(0)); + } + + #[test] + fn test_round_state_timeout() { + let kp = Keypair::generate(); + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + kp.hotkey(), + 0, + ); + + let round = StakeWeightedRoundState::new(proposal, -1); // Negative timeout for immediate timeout + assert!(round.is_timed_out()); + } + + #[tokio::test] + async fn test_vote_internal_with_zero_stake() { + let (engine, _rx) = create_test_engine(); + + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 0, + ); + let proposal_id = engine.start_round(proposal); + + // Vote should work even with zero stake (for the engine itself) + let result = engine.vote_internal(proposal_id, true).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_vote_wrong_voter_signature() { + let (engine, _rx) = create_test_engine(); + + let proposal_id = uuid::Uuid::new_v4(); + let wrong_voter = Keypair::generate().hotkey(); + let vote = Vote::approve(proposal_id, wrong_voter); + + let different_signer = Keypair::generate().hotkey(); + let result = engine.handle_vote(vote, &different_signer).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_handle_vote_non_validator_no_stake() { + let (engine, _rx) = create_test_engine(); + + let non_validator = Keypair::generate(); + let proposal_id = uuid::Uuid::new_v4(); + let vote = Vote::approve(proposal_id, non_validator.hotkey()); + + // Non-validator has no stake, should succeed but have no effect + let result = engine.handle_vote(vote, &non_validator.hotkey()).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_add_vote_with_stake_round_not_found() { + let (engine, _rx) = create_test_engine(); + + let fake_proposal_id = uuid::Uuid::new_v4(); + let vote = Vote::approve(fake_proposal_id, engine.keypair.hotkey()); + + let result = engine.add_vote_with_stake(vote, Stake::new(100)); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_apply_proposal_new_block_hash_mismatch() { + let (engine, _rx) = create_test_engine(); + + let wrong_hash = [99u8; 32]; + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: wrong_hash, + }, + engine.keypair.hotkey(), + 0, + ); + + // Should succeed but not increment block due to hash mismatch + let result = engine.apply_proposal(proposal).await; + assert!(result.is_ok()); + + let block = engine.chain_state.read().block_height; + assert_eq!(block, 0); // Block not incremented + } + + #[tokio::test] + async fn test_validate_proposal_invalid_block_height() { + let (engine, _rx) = create_test_engine(); + + // Proposal with future block height + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 1000, + ); + + assert!(!engine.validate_proposal(&proposal)); + } + + #[tokio::test] + async fn test_validate_proposal_non_sudo_proposer() { + let (engine, _rx) = create_test_engine(); + let non_sudo = Keypair::generate(); + + let action = SudoAction::Resume; + let proposal = Proposal::new(ProposalAction::Sudo(action), non_sudo.hotkey(), 0); + + assert!(!engine.validate_proposal(&proposal)); + } + + #[tokio::test] + async fn test_check_timeouts_removes_timed_out() { + let (engine, _rx) = create_test_engine(); + + // Create a round with very short timeout + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + engine.keypair.hotkey(), + 0, + ); + let proposal_id = proposal.id; + + // Manually insert a timed out round + let round = StakeWeightedRoundState::new(proposal, -1); + engine.rounds.write().insert(proposal_id, round); + + // Check timeouts should find and remove it + let timeouts = engine.check_timeouts(); + assert!(timeouts.len() > 0); + assert!(!engine.rounds.read().contains_key(&proposal_id)); + } } diff --git a/crates/consensus/src/state.rs b/crates/consensus/src/state.rs index 82e289087..bc872b097 100644 --- a/crates/consensus/src/state.rs +++ b/crates/consensus/src/state.rs @@ -288,4 +288,165 @@ mod tests { // Verify cleared assert_eq!(state.completed_results().len(), 0); } + + #[test] + fn test_get_round() { + let state = ConsensusState::new(ConsensusConfig::default()); + + let proposer = Keypair::generate(); + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + proposer.hotkey(), + 1, + ); + + let proposal_id = state.start_round(proposal.clone()); + + // Get round + let round = state.get_round(&proposal_id); + assert!(round.is_some()); + assert_eq!(round.unwrap().proposal.id, proposal_id); + + // Non-existent round + let fake_id = uuid::Uuid::new_v4(); + let non_existent = state.get_round(&fake_id); + assert!(non_existent.is_none()); + } + + #[test] + fn test_is_pending() { + let state = ConsensusState::new(ConsensusConfig::default()); + + let proposer = Keypair::generate(); + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + proposer.hotkey(), + 1, + ); + + let proposal_id = state.start_round(proposal); + + assert!(state.is_pending(&proposal_id)); + + let fake_id = uuid::Uuid::new_v4(); + assert!(!state.is_pending(&fake_id)); + } + + #[test] + fn test_active_rounds() { + let state = ConsensusState::new(ConsensusConfig::default()); + + // Initially empty + assert_eq!(state.active_rounds().len(), 0); + + let proposer = Keypair::generate(); + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + proposer.hotkey(), + 1, + ); + + state.start_round(proposal); + + // Now has one active round + assert_eq!(state.active_rounds().len(), 1); + } + + #[test] + fn test_check_timeouts_with_rounds() { + let state = ConsensusState::new(ConsensusConfig::default()); + + let proposer = Keypair::generate(); + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + proposer.hotkey(), + 1, + ); + + state.start_round(proposal); + + // Check timeouts (won't timeout immediately) + let timeouts = state.check_timeouts(); + assert_eq!(timeouts.len(), 0); + } + + #[test] + fn test_add_vote_to_nonexistent_round() { + let state = ConsensusState::new(ConsensusConfig::default()); + state.set_validator_count(4); + + let fake_id = uuid::Uuid::new_v4(); + let voter = Keypair::generate(); + let vote = Vote::approve(fake_id, voter.hotkey()); + + let result = state.add_vote(vote); + assert!(result.is_none()); + } + + #[test] + fn test_add_vote_triggers_rejection() { + let state = ConsensusState::new(ConsensusConfig::default()); + state.set_validator_count(4); // Threshold is ceil(4 * 0.33) = 2 + + let proposer = Keypair::generate(); + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + proposer.hotkey(), + 1, + ); + + let proposal_id = state.start_round(proposal); + + // Add 3 reject votes - makes consensus impossible + for _ in 0..3 { + let voter = Keypair::generate(); + let vote = Vote::reject(proposal_id, voter.hotkey()); + let result = state.add_vote(vote); + + if let Some(ConsensusResult::Rejected { .. }) = result { + // Successfully triggered rejection + assert!(!state.is_pending(&proposal_id)); + return; + } + } + + // Should have been rejected + assert!(!state.is_pending(&proposal_id)); + } + + #[test] + fn test_add_vote_updates_phase() { + let state = ConsensusState::new(ConsensusConfig::default()); + state.set_validator_count(10); + + let proposer = Keypair::generate(); + let proposal = Proposal::new( + ProposalAction::NewBlock { + state_hash: [0u8; 32], + }, + proposer.hotkey(), + 1, + ); + + let proposal_id = state.start_round(proposal); + + // Add one approve vote - should update phase + let voter = Keypair::generate(); + let vote = Vote::approve(proposal_id, voter.hotkey()); + state.add_vote(vote); + + // Round should still be active and phase updated + let round = state.get_round(&proposal_id); + assert!(round.is_some()); + } } From 10a53945b5cdf73dd27760ecce0be5fa6aeacaec Mon Sep 17 00:00:00 2001 From: cuteolaf Date: Thu, 8 Jan 2026 18:27:39 +0100 Subject: [PATCH 3/6] refactor(consensus): address code review feedback - Add test-only helper methods in stake_governance.rs to reduce coupling to internal fields - Improve test_handle_proposal_invalid_config to verify rejection behavior - Add documentation comment explaining intentional sudo action type aliasing in governance_integration.rs (SetMechanismConfig -> SetMechanismBurnRate, RefreshChallenges -> UpdateChallenge) - Fix test_add_vote_updates_phase to actually assert phase change to Prepare --- .../consensus/src/governance_integration.rs | 12 +++- crates/consensus/src/stake_governance.rs | 65 +++++++++++++------ crates/consensus/src/stake_weighted_pbft.rs | 9 +++ crates/consensus/src/state.rs | 5 +- 4 files changed, 68 insertions(+), 23 deletions(-) diff --git a/crates/consensus/src/governance_integration.rs b/crates/consensus/src/governance_integration.rs index 19d3ba70b..b7298b8cb 100644 --- a/crates/consensus/src/governance_integration.rs +++ b/crates/consensus/src/governance_integration.rs @@ -230,6 +230,14 @@ pub enum SudoExecutionResult { } /// Convert SudoAction to GovernanceActionType +/// +/// Note: Some SudoActions are intentionally mapped to the same GovernanceActionType +/// for governance classification and rate-limiting purposes: +/// - `SetMechanismConfig` → `SetMechanismBurnRate` (both are mechanism parameter changes) +/// - `RefreshChallenges` → `UpdateChallenge` (both affect challenge state) +/// +/// This aliasing is intentional to group related actions. If per-action visibility +/// or separate rate limits are needed in the future, add new GovernanceActionType variants. fn sudo_action_to_governance_type(action: &SudoAction) -> GovernanceActionType { match action { SudoAction::UpdateConfig { .. } => GovernanceActionType::UpdateConfig, @@ -238,6 +246,7 @@ fn sudo_action_to_governance_type(action: &SudoAction) -> GovernanceActionType { SudoAction::RemoveChallenge { .. } => GovernanceActionType::RemoveChallenge, SudoAction::SetChallengeWeight { .. } => GovernanceActionType::SetChallengeWeight, SudoAction::SetMechanismBurnRate { .. } => GovernanceActionType::SetMechanismBurnRate, + // Intentional aliasing: mechanism config changes grouped with burn rate changes SudoAction::SetMechanismConfig { .. } => GovernanceActionType::SetMechanismBurnRate, SudoAction::SetRequiredVersion { .. } => GovernanceActionType::SetRequiredVersion, SudoAction::AddValidator { .. } => GovernanceActionType::AddValidator, @@ -245,7 +254,8 @@ fn sudo_action_to_governance_type(action: &SudoAction) -> GovernanceActionType { SudoAction::EmergencyPause { .. } => GovernanceActionType::EmergencyPause, SudoAction::Resume => GovernanceActionType::Resume, SudoAction::ForceStateUpdate { .. } => GovernanceActionType::ForceStateUpdate, - SudoAction::RefreshChallenges { .. } => GovernanceActionType::UpdateChallenge, // Reuse UpdateChallenge type + // Intentional aliasing: refresh operations grouped with update operations + SudoAction::RefreshChallenges { .. } => GovernanceActionType::UpdateChallenge, } } diff --git a/crates/consensus/src/stake_governance.rs b/crates/consensus/src/stake_governance.rs index 60f853f65..bf313d660 100644 --- a/crates/consensus/src/stake_governance.rs +++ b/crates/consensus/src/stake_governance.rs @@ -1204,6 +1204,37 @@ mod tests { (kp, stake) } + // Test-only helpers to reduce coupling to internal fields + impl StakeGovernance { + #[cfg(test)] + fn mark_expired_for_test(&self, proposal_id: Uuid) { + if let Some(proposal) = self.proposals.write().get_mut(&proposal_id) { + proposal.expires_at = Utc::now() - Duration::hours(1); + } + } + + #[cfg(test)] + fn mark_executed_for_test(&self, proposal_id: Uuid) { + if let Some(proposal) = self.proposals.write().get_mut(&proposal_id) { + proposal.executed = true; + } + } + + #[cfg(test)] + fn mark_cancelled_for_test(&self, proposal_id: Uuid) { + if let Some(proposal) = self.proposals.write().get_mut(&proposal_id) { + proposal.cancelled = true; + } + } + + #[cfg(test)] + fn set_rate_limit_for_test(&self, hotkey: &Hotkey, count: usize, timestamp: DateTime) { + self.proposal_counts + .write() + .insert(hotkey.clone(), (timestamp, count)); + } + } + #[test] fn test_is_subnet_owner() { let owner = subnet_owner_hotkey(); @@ -1575,7 +1606,7 @@ mod tests { let (kp, stake) = create_test_validator(1_000_000_000_000); gov.update_validator_stakes(vec![stake]); - let mut proposal = gov + let proposal = gov .create_proposal( GovernanceActionType::UpdateConfig, "Test".to_string(), @@ -1586,9 +1617,8 @@ mod tests { ) .unwrap(); - // Manually expire the proposal - proposal.expires_at = chrono::Utc::now() - chrono::Duration::hours(1); - gov.proposals.write().insert(proposal.id, proposal.clone()); + // Mark proposal as expired using helper + gov.mark_expired_for_test(proposal.id); let result = gov.vote(proposal.id, &kp.hotkey(), true, &kp); assert!(matches!( @@ -1605,7 +1635,7 @@ mod tests { let (kp, stake) = create_test_validator(1_000_000_000_000); gov.update_validator_stakes(vec![stake]); - let mut proposal = gov + let proposal = gov .create_proposal( GovernanceActionType::UpdateConfig, "Test".to_string(), @@ -1616,9 +1646,8 @@ mod tests { ) .unwrap(); - // Mark as executed - proposal.executed = true; - gov.proposals.write().insert(proposal.id, proposal.clone()); + // Mark as executed using helper + gov.mark_executed_for_test(proposal.id); let result = gov.vote(proposal.id, &kp.hotkey(), true, &kp); assert!(result.is_err()); @@ -1632,7 +1661,7 @@ mod tests { let (kp, stake) = create_test_validator(1_000_000_000_000); gov.update_validator_stakes(vec![stake]); - let mut proposal = gov + let proposal = gov .create_proposal( GovernanceActionType::UpdateConfig, "Test".to_string(), @@ -1643,9 +1672,8 @@ mod tests { ) .unwrap(); - // Mark as cancelled - proposal.cancelled = true; - gov.proposals.write().insert(proposal.id, proposal.clone()); + // Mark as cancelled using helper + gov.mark_cancelled_for_test(proposal.id); let result = gov.vote(proposal.id, &kp.hotkey(), true, &kp); assert!(matches!( @@ -1920,7 +1948,7 @@ mod tests { let (kp, stake) = create_test_validator(1_000_000_000_000); gov.update_validator_stakes(vec![stake]); - let mut proposal = gov + let proposal = gov .create_proposal( GovernanceActionType::UpdateConfig, "Test".to_string(), @@ -1931,9 +1959,8 @@ mod tests { ) .unwrap(); - // Manually expire - proposal.expires_at = chrono::Utc::now() - chrono::Duration::hours(1); - gov.proposals.write().insert(proposal.id, proposal.clone()); + // Mark as expired using helper + gov.mark_expired_for_test(proposal.id); gov.cleanup_expired(); @@ -1993,11 +2020,9 @@ mod tests { ); assert!(result.is_ok()); - // Manually reset the counter date to yesterday + // Set rate limit to yesterday using helper let yesterday = chrono::Utc::now() - chrono::Duration::days(1); - gov.proposal_counts - .write() - .insert(kp.hotkey(), (yesterday, 1)); + gov.set_rate_limit_for_test(&kp.hotkey(), 1, yesterday); // Should be able to create another proposal (counter reset) let result = gov.create_proposal( diff --git a/crates/consensus/src/stake_weighted_pbft.rs b/crates/consensus/src/stake_weighted_pbft.rs index 50c59b1a3..6a341486d 100644 --- a/crates/consensus/src/stake_weighted_pbft.rs +++ b/crates/consensus/src/stake_weighted_pbft.rs @@ -949,12 +949,21 @@ mod tests { let sudo_key = engine.keypair.hotkey(); let config = platform_core::ChallengeContainerConfig::new("", "", 1, 0.5); + let challenge_id = config.challenge_id; let action = SudoAction::AddChallenge { config }; let proposal = Proposal::new(ProposalAction::Sudo(action), sudo_key.clone(), 0); let result = engine.handle_proposal(proposal, &sudo_key).await; + // Invalid config is accepted (doesn't error) but internally voted NO assert!(result.is_ok()); + + // Verify the invalid config was NOT added to chain state + assert!(!engine + .chain_state + .read() + .challenge_configs + .contains_key(&challenge_id)); } #[tokio::test] diff --git a/crates/consensus/src/state.rs b/crates/consensus/src/state.rs index bc872b097..e052dbd06 100644 --- a/crates/consensus/src/state.rs +++ b/crates/consensus/src/state.rs @@ -440,13 +440,14 @@ mod tests { let proposal_id = state.start_round(proposal); - // Add one approve vote - should update phase + // Add one approve vote - should update phase to Prepare let voter = Keypair::generate(); let vote = Vote::approve(proposal_id, voter.hotkey()); state.add_vote(vote); - // Round should still be active and phase updated + // Round should still be active and phase should be updated to Prepare let round = state.get_round(&proposal_id); assert!(round.is_some()); + assert_eq!(round.unwrap().phase, ConsensusPhase::Prepare); } } From 3ca747b3004645ab08f56e931d49e5f3dddd04f6 Mon Sep 17 00:00:00 2001 From: cuteolaf Date: Thu, 8 Jan 2026 18:38:57 +0100 Subject: [PATCH 4/6] nitpicks by coderabbitai --- crates/consensus/src/governance_integration.rs | 3 ++- crates/consensus/src/stake_governance.rs | 13 +++++++------ crates/consensus/src/stake_weighted_pbft.rs | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/consensus/src/governance_integration.rs b/crates/consensus/src/governance_integration.rs index b7298b8cb..2a23fe2f0 100644 --- a/crates/consensus/src/governance_integration.rs +++ b/crates/consensus/src/governance_integration.rs @@ -500,7 +500,6 @@ impl MetagraphGovernanceSync { #[cfg(test)] mod tests { use super::*; - use platform_core::ChallengeConfig; #[test] fn test_sudo_action_conversion() { @@ -1093,6 +1092,8 @@ mod tests { let (required, threshold) = gov_pbft.stake_for_consensus(); assert_eq!(threshold, crate::stake_governance::STAKE_THRESHOLD_PERCENT); + // With zero stake, required should be 0 + assert_eq!(required.0, 0); } #[tokio::test] diff --git a/crates/consensus/src/stake_governance.rs b/crates/consensus/src/stake_governance.rs index bf313d660..27bedfa7b 100644 --- a/crates/consensus/src/stake_governance.rs +++ b/crates/consensus/src/stake_governance.rs @@ -2205,6 +2205,8 @@ mod tests { let (kp, _) = create_test_validator(0); // Create proposal without any validators having stake + // Note: Direct field access used here because create_proposal would fail + // due to the no-stake validation check, which is what we're testing let proposal_id = uuid::Uuid::new_v4(); let proposal = GovernanceProposal::new( GovernanceActionType::UpdateConfig, @@ -2357,6 +2359,7 @@ mod tests { let gov = create_test_governance(); let hotkey = Hotkey([1u8; 32]); + // Direct test of private method to verify rate limiting internals gov.increment_proposal_count(&hotkey); let counts = gov.proposal_counts.read(); @@ -2420,9 +2423,8 @@ mod tests { ) .unwrap(); - // Mark as cancelled - proposal.cancelled = true; - gov.proposals.write().insert(proposal.id, proposal.clone()); + // Mark as cancelled using helper + gov.mark_cancelled_for_test(proposal.id); let result = gov.check_consensus(proposal.id).unwrap(); assert!(matches!(result, StakeConsensusResult::Cancelled { .. })); @@ -2447,9 +2449,8 @@ mod tests { ) .unwrap(); - // Expire the proposal - proposal.expires_at = chrono::Utc::now() - chrono::Duration::hours(1); - gov.proposals.write().insert(proposal.id, proposal.clone()); + // Expire the proposal using helper + gov.mark_expired_for_test(proposal.id); let result = gov.check_consensus(proposal.id).unwrap(); assert!(matches!(result, StakeConsensusResult::Expired { .. })); diff --git a/crates/consensus/src/stake_weighted_pbft.rs b/crates/consensus/src/stake_weighted_pbft.rs index 6a341486d..e3da3c719 100644 --- a/crates/consensus/src/stake_weighted_pbft.rs +++ b/crates/consensus/src/stake_weighted_pbft.rs @@ -1598,7 +1598,7 @@ mod tests { // Check timeouts should find and remove it let timeouts = engine.check_timeouts(); - assert!(timeouts.len() > 0); + assert!(!timeouts.is_empty()); assert!(!engine.rounds.read().contains_key(&proposal_id)); } } From 4817677670f6c74a12117a3e45f4fb191dbb10c1 Mon Sep 17 00:00:00 2001 From: cuteolaf Date: Thu, 8 Jan 2026 18:44:30 +0100 Subject: [PATCH 5/6] remove unnecessary `mut` --- crates/consensus/src/stake_governance.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/consensus/src/stake_governance.rs b/crates/consensus/src/stake_governance.rs index 27bedfa7b..676219e46 100644 --- a/crates/consensus/src/stake_governance.rs +++ b/crates/consensus/src/stake_governance.rs @@ -2412,7 +2412,7 @@ mod tests { let (kp, stake) = create_test_validator(1_000_000_000_000); gov.update_validator_stakes(vec![stake]); - let mut proposal = gov + let proposal = gov .create_proposal( GovernanceActionType::Resume, "Test".to_string(), @@ -2438,7 +2438,7 @@ mod tests { let (kp, stake) = create_test_validator(1_000_000_000_000); gov.update_validator_stakes(vec![stake]); - let mut proposal = gov + let proposal = gov .create_proposal( GovernanceActionType::Resume, "Test".to_string(), From de9d4921e27025f4d20cb483bc0761dd6175eb5b Mon Sep 17 00:00:00 2001 From: cuteolaf Date: Thu, 8 Jan 2026 18:52:14 +0100 Subject: [PATCH 6/6] fix(consensus): improve test accuracy for zero-stake scenarios --- crates/consensus/src/stake_governance.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/consensus/src/stake_governance.rs b/crates/consensus/src/stake_governance.rs index 676219e46..cd02705e5 100644 --- a/crates/consensus/src/stake_governance.rs +++ b/crates/consensus/src/stake_governance.rs @@ -1584,7 +1584,8 @@ mod tests { let gov = create_test_governance(); gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); - let (kp, _) = create_test_validator(0); + let (kp, stake) = create_test_validator(0); + gov.update_validator_stakes(vec![stake]); let result = gov.create_proposal( GovernanceActionType::AddChallenge, @@ -1688,8 +1689,9 @@ mod tests { gov.set_block_height(BOOTSTRAP_END_BLOCK + 1); let (kp1, stake1) = create_test_validator(1_000_000_000_000); - let (kp2, _) = create_test_validator(0); - gov.update_validator_stakes(vec![stake1]); + let (kp2, stake2) = create_test_validator(0); + // Register both validators, kp2 has zero stake + gov.update_validator_stakes(vec![stake1, stake2]); let proposal = gov .create_proposal( @@ -1702,6 +1704,7 @@ mod tests { ) .unwrap(); + // kp2 is a validator but has no stake, should fail let result = gov.vote(proposal.id, &kp2.hotkey(), true, &kp2); assert!(result.is_err()); } @@ -1730,7 +1733,7 @@ mod tests { // kp2 (80% stake) votes NO let result = gov.vote(proposal.id, &kp2.hotkey(), false, &kp2).unwrap(); - // Should be rejected due to >50% stake voting NO + // Should be rejected due to >33% stake voting NO (exceeds rejection threshold) assert!(matches!(result, StakeConsensusResult::Rejected { .. })); }