diff --git a/swaptrade-contracts/counter/src/errors.rs b/swaptrade-contracts/counter/src/errors.rs index fef7bac..00e9452 100644 --- a/swaptrade-contracts/counter/src/errors.rs +++ b/swaptrade-contracts/counter/src/errors.rs @@ -84,6 +84,19 @@ pub enum SwapTradeError { PositionLimitExceeded = 802, CircuitBreakerTripped = 803, InvalidRiskConfig = 804, + + // ── Governance ─────────────────────────────────────────────────────────── + ProposalNotFound = 900, + ProposalNotActive = 901, + VotingPeriodEnded = 902, + AlreadyVoted = 903, + InsufficientVotingPower = 904, + QuorumNotReached = 905, + ProposalNotPassed = 906, + ProposalAlreadyExecuted = 907, + ExecutionDelayNotElapsed = 908, + InvalidProposalType = 909, + ProposalCooldownActive = 910, } /// Alias kept for modules that still import `ContractError` by name. diff --git a/swaptrade-contracts/counter/src/governance_system.rs b/swaptrade-contracts/counter/src/governance_system.rs new file mode 100644 index 0000000..c51c8c2 --- /dev/null +++ b/swaptrade-contracts/counter/src/governance_system.rs @@ -0,0 +1,419 @@ +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec, Map}; +use crate::errors::SwapTradeError; +use crate::governance_types::*; +use crate::events::Events; + +/// On-chain governance system for SwapTrade +pub struct GovernanceSystem; + +impl GovernanceSystem { + /// Create a new governance proposal + pub fn create_proposal( + env: &Env, + proposer: &Address, + proposal_type: ProposalType, + description: Symbol, + voting_period: u64, + ) -> Result { + proposer.require_auth(); + + let config = Self::get_config(env); + let current_time = env.ledger().timestamp(); + + // Validate voting period + if voting_period < config.min_voting_period || voting_period > config.max_voting_period { + return Err(SwapTradeError::InvalidAmount); + } + + // Check proposal cooldown + let last_proposal_time = env.storage() + .persistent() + .get(&GovernanceKey::VoterLastProposal(proposer.clone())) + .unwrap_or(0u64); + + if current_time - last_proposal_time < config.proposal_cooldown { + return Err(SwapTradeError::RateLimitExceeded); + } + + // Get voting power snapshot + let voting_power = Self::get_voting_power(env, proposer); + if voting_power == 0 { + return Err(SwapTradeError::InvalidAmount); // No voting power + } + + // Generate proposal ID + let proposal_id = Self::get_next_proposal_id(env); + + // Create proposal + let proposal = Proposal { + id: proposal_id, + proposer: proposer.clone(), + proposal_type, + description, + start_time: current_time, + end_time: current_time + voting_period, + execution_time: None, + status: ProposalStatus::Active, + votes_for: 0, + votes_against: 0, + votes_abstain: 0, + total_voting_power: Self::get_total_voting_power(env), + quorum_required: (config.quorum_threshold as u128 * Self::get_total_voting_power(env)) / 10000, + approval_threshold: config.approval_threshold, + executed: false, + }; + + // Store proposal + env.storage() + .persistent() + .set(&GovernanceKey::Proposal(proposal_id), &proposal); + + // Update last proposal time + env.storage() + .persistent() + .set(&GovernanceKey::VoterLastProposal(proposer.clone()), ¤t_time); + + // Emit event + env.events().publish( + (Symbol::short("gov"), Symbol::short("proposal_created")), + (proposal_id, proposer.clone(), description), + ); + + Ok(proposal_id) + } + + /// Cast a vote on a proposal + pub fn cast_vote( + env: &Env, + voter: &Address, + proposal_id: u64, + vote_option: VoteOption, + ) -> Result<(), SwapTradeError> { + voter.require_auth(); + + let mut proposal = Self::get_proposal(env, proposal_id)?; + let current_time = env.ledger().timestamp(); + + // Validate proposal state + if proposal.status != ProposalStatus::Active { + return Err(SwapTradeError::InvalidAmount); + } + + if current_time < proposal.start_time || current_time > proposal.end_time { + return Err(SwapTradeError::InvalidAmount); + } + + // Check if voter already voted + let votes_key = GovernanceKey::ProposalVotes(proposal_id); + let mut votes: Map = env.storage() + .persistent() + .get(&votes_key) + .unwrap_or_else(|| Map::new(env)); + + if votes.contains_key(voter.clone()) { + return Err(SwapTradeError::InvalidAmount); // Already voted + } + + // Get voting power + let voting_power = Self::get_voting_power(env, voter); + if voting_power == 0 { + return Err(SwapTradeError::InvalidAmount); + } + + // Record vote + let vote = Vote { + voter: voter.clone(), + option: vote_option.clone(), + voting_power, + timestamp: current_time, + }; + + votes.set(voter.clone(), vote); + + // Update proposal vote counts + match vote_option { + VoteOption::For => proposal.votes_for += voting_power, + VoteOption::Against => proposal.votes_against += voting_power, + VoteOption::Abstain => proposal.votes_abstain += voting_power, + } + + // Store updated data + env.storage().persistent().set(&votes_key, &votes); + env.storage().persistent().set(&GovernanceKey::Proposal(proposal_id), &proposal); + + // Emit event + env.events().publish( + (Symbol::short("gov"), Symbol::short("vote_cast")), + (proposal_id, voter.clone(), vote_option, voting_power), + ); + + Ok(()) + } + + /// Execute a passed proposal + pub fn execute_proposal( + env: &Env, + executor: &Address, + proposal_id: u64, + ) -> Result<(), SwapTradeError> { + executor.require_auth(); + + let mut proposal = Self::get_proposal(env, proposal_id)?; + let current_time = env.ledger().timestamp(); + let config = Self::get_config(env); + + // Validate proposal can be executed + if proposal.status != ProposalStatus::Active && proposal.status != ProposalStatus::Passed { + return Err(SwapTradeError::InvalidAmount); + } + + if proposal.executed { + return Err(SwapTradeError::InvalidAmount); + } + + // Check if voting period has ended + if current_time < proposal.end_time { + return Err(SwapTradeError::InvalidAmount); + } + + // Finalize proposal status + Self::finalize_proposal(env, &mut proposal); + + // Check if proposal passed + if proposal.status != ProposalStatus::Passed { + return Err(SwapTradeError::InvalidAmount); + } + + // Check execution delay + if let Some(execution_time) = proposal.execution_time { + if current_time < execution_time { + return Err(SwapTradeError::InvalidAmount); + } + } else { + // Set execution time if not set + proposal.execution_time = Some(current_time + config.execution_delay); + env.storage().persistent().set(&GovernanceKey::Proposal(proposal_id), &proposal); + return Err(SwapTradeError::InvalidAmount); // Not ready for execution yet + } + + // Execute the proposal + Self::execute_proposal_action(env, &proposal)?; + + // Mark as executed + proposal.executed = true; + proposal.status = ProposalStatus::Executed; + env.storage().persistent().set(&GovernanceKey::Proposal(proposal_id), &proposal); + + // Emit event + env.events().publish( + (Symbol::short("gov"), Symbol::short("proposal_executed")), + (proposal_id, executor.clone()), + ); + + Ok(()) + } + + /// Cancel a proposal (only by proposer before voting ends) + pub fn cancel_proposal( + env: &Env, + canceller: &Address, + proposal_id: u64, + ) -> Result<(), SwapTradeError> { + canceller.require_auth(); + + let mut proposal = Self::get_proposal(env, proposal_id)?; + let current_time = env.ledger().timestamp(); + + // Only proposer can cancel + if proposal.proposer != canceller.clone() { + return Err(SwapTradeError::NotAdmin); + } + + // Can only cancel active proposals before voting ends + if proposal.status != ProposalStatus::Active || current_time > proposal.end_time { + return Err(SwapTradeError::InvalidAmount); + } + + proposal.status = ProposalStatus::Cancelled; + env.storage().persistent().set(&GovernanceKey::Proposal(proposal_id), &proposal); + + // Emit event + env.events().publish( + (Symbol::short("gov"), Symbol::short("proposal_cancelled")), + (proposal_id, canceller.clone()), + ); + + Ok(()) + } + + /// Get proposal details + pub fn get_proposal(env: &Env, proposal_id: u64) -> Result { + env.storage() + .persistent() + .get(&GovernanceKey::Proposal(proposal_id)) + .ok_or(SwapTradeError::InvalidAmount) + } + + /// Get votes for a proposal + pub fn get_proposal_votes(env: &Env, proposal_id: u64) -> Map { + env.storage() + .persistent() + .get(&GovernanceKey::ProposalVotes(proposal_id)) + .unwrap_or_else(|| Map::new(env)) + } + + /// Get governance configuration + pub fn get_config(env: &Env) -> GovernanceConfig { + env.storage() + .persistent() + .get(&GovernanceKey::Config) + .unwrap_or_default() + } + + /// Set governance configuration (admin only) + pub fn set_config(env: &Env, admin: &Address, config: &GovernanceConfig) -> Result<(), SwapTradeError> { + admin.require_auth(); + crate::admin::require_admin(env, admin)?; + + // Validate config + if config.min_voting_period == 0 || config.max_voting_period < config.min_voting_period { + return Err(SwapTradeError::InvalidAmount); + } + if config.quorum_threshold > 10000 || config.approval_threshold > 10000 { + return Err(SwapTradeError::InvalidAmount); + } + + env.storage().persistent().set(&GovernanceKey::Config, config); + Ok(()) + } + + // Internal helper methods + + fn finalize_proposal(env: &Env, proposal: &mut Proposal) { + let total_votes = proposal.votes_for + proposal.votes_against; + let total_voting_power = proposal.total_voting_power; + + // Check quorum + if total_votes < proposal.quorum_required { + proposal.status = ProposalStatus::Rejected; + return; + } + + // Check approval threshold + let approval_rate = if total_votes > 0 { + (proposal.votes_for * 10000) / total_votes + } else { + 0 + }; + + if approval_rate >= proposal.approval_threshold as u128 { + proposal.status = ProposalStatus::Passed; + let config = Self::get_config(env); + proposal.execution_time = Some(env.ledger().timestamp() + config.execution_delay); + } else { + proposal.status = ProposalStatus::Rejected; + } + } + + fn execute_proposal_action(env: &Env, proposal: &Proposal) -> Result<(), SwapTradeError> { + match &proposal.proposal_type { + ProposalType::ParameterChange { param_key, new_value } => { + Self::execute_parameter_change(env, param_key, *new_value) + } + ProposalType::AdminUpgrade { new_admin } => { + Self::execute_admin_upgrade(env, new_admin) + } + ProposalType::EmergencyAction { pause } => { + Self::execute_emergency_action(env, *pause) + } + ProposalType::Custom { .. } => { + // Custom proposals require manual execution + Ok(()) + } + } + } + + fn execute_parameter_change(env: &Env, param_key: &ParamKey, new_value: i128) -> Result<(), SwapTradeError> { + match param_key { + ParamKey::MaxSwapAmount => { + // Update max swap amount + env.storage().instance().set(&Symbol::short("max_swap"), &new_value); + } + ParamKey::FeeBps => { + // Update fee + env.storage().instance().set(&Symbol::short("fee_bps"), &(new_value as u32)); + } + ParamKey::RateLimitWindow => { + // Update rate limit window + env.storage().instance().set(&Symbol::short("rate_win"), &new_value); + } + ParamKey::MaxSlippageBps => { + // Update max slippage + env.storage().instance().set(&Symbol::short("max_slip"), &(new_value as u32)); + } + ParamKey::EmergencyPause => { + // Emergency pause/unpause + env.storage().instance().set(&Symbol::short("paused"), &(new_value != 0)); + } + ParamKey::RiskConfigMaxPosition => { + // Update risk config + let mut config = crate::risk_management::PositionLimits::get_risk_config(env); + config.max_position_per_user = new_value as i128; + crate::risk_management::PositionLimits::set_risk_config(env, &config); + } + ParamKey::RiskConfigConcentrationLimit => { + // Update concentration limit + let mut config = crate::risk_management::PositionLimits::get_risk_config(env); + config.concentration_limit_threshold = new_value as u32; + crate::risk_management::PositionLimits::set_risk_config(env, &config); + } + ParamKey::GovernanceQuorumThreshold => { + // Update governance quorum + let mut config = Self::get_config(env); + config.quorum_threshold = new_value as u32; + Self::set_config(env, &crate::admin::get_admin(env), &config)?; + } + ParamKey::GovernanceApprovalThreshold => { + // Update governance approval threshold + let mut config = Self::get_config(env); + config.approval_threshold = new_value as u32; + Self::set_config(env, &crate::admin::get_admin(env), &config)?; + } + } + Ok(()) + } + + fn execute_admin_upgrade(env: &Env, new_admin: &Address) -> Result<(), SwapTradeError> { + crate::admin::set_admin(env, new_admin); + Ok(()) + } + + fn execute_emergency_action(env: &Env, pause: bool) -> Result<(), SwapTradeError> { + env.storage().instance().set(&Symbol::short("paused"), &pause); + Ok(()) + } + + fn get_voting_power(env: &Env, voter: &Address) -> u128 { + // For now, voting power is based on staked tokens + // In production, this could be more complex (LP tokens, etc.) + crate::staking_bonus::StakingBonusManager::get_user_total_staked(env, voter.clone()) as u128 + } + + fn get_total_voting_power(env: &Env) -> u128 { + // Total voting power across all stakers + crate::staking_bonus::StakingBonusManager::get_total_staked(env) as u128 + } + + fn get_next_proposal_id(env: &Env) -> u64 { + let current_id: u64 = env.storage() + .persistent() + .get(&GovernanceKey::NextProposalId) + .unwrap_or(1); + + env.storage() + .persistent() + .set(&GovernanceKey::NextProposalId, &(current_id + 1)); + + current_id + } +} \ No newline at end of file diff --git a/swaptrade-contracts/counter/src/governance_tests.rs b/swaptrade-contracts/counter/src/governance_tests.rs new file mode 100644 index 0000000..a68589f --- /dev/null +++ b/swaptrade-contracts/counter/src/governance_tests.rs @@ -0,0 +1,527 @@ +#[cfg(test)] +mod governance_tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Address, Env, Symbol, symbol_short}; + use crate::governance_types::*; + use crate::governance_system::GovernanceSystem; + use crate::staking_bonus::StakingBonusManager; + + // ===== GOVERNANCE PROPOSAL TESTS ===== + + #[test] + fn test_create_proposal_basic() { + let env = Env::default(); + let proposer = Address::generate(&env); + + // Set up staked tokens for voting power + StakingBonusManager::stake(&env, proposer.clone(), 1000, 30).unwrap(); + + let proposal_type = ProposalType::ParameterChange { + param_key: ParamKey::FeeBps, + new_value: 25, + }; + let description = symbol_short!("test_proposal"); + + let result = GovernanceSystem::create_proposal( + &env, + &proposer, + proposal_type, + description, + 86400, // 1 day + ); + + assert!(result.is_ok()); + let proposal_id = result.unwrap(); + + // Verify proposal was created + let proposal = GovernanceSystem::get_proposal(&env, proposal_id).unwrap(); + assert_eq!(proposal.id, proposal_id); + assert_eq!(proposal.proposer, proposer); + assert_eq!(proposal.status, ProposalStatus::Active); + } + + #[test] + fn test_create_proposal_insufficient_voting_power() { + let env = Env::default(); + let proposer = Address::generate(&env); + + // No staked tokens = no voting power + let proposal_type = ProposalType::ParameterChange { + param_key: ParamKey::FeeBps, + new_value: 25, + }; + let description = symbol_short!("test_proposal"); + + let result = GovernanceSystem::create_proposal( + &env, + &proposer, + proposal_type, + description, + 86400, + ); + + assert!(result.is_err()); + } + + #[test] + fn test_create_proposal_invalid_voting_period() { + let env = Env::default(); + let proposer = Address::generate(&env); + + // Set up staked tokens + StakingBonusManager::stake(&env, proposer.clone(), 1000, 30).unwrap(); + + let proposal_type = ProposalType::ParameterChange { + param_key: ParamKey::FeeBps, + new_value: 25, + }; + let description = symbol_short!("test_proposal"); + + // Too short voting period + let result = GovernanceSystem::create_proposal( + &env, + &proposer, + proposal_type.clone(), + description, + 3600, // 1 hour - below minimum + ); + + assert!(result.is_err()); + } + + #[test] + fn test_cast_vote_for() { + let env = Env::default(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + + // Set up voting power for both + StakingBonusManager::stake(&env, proposer.clone(), 1000, 30).unwrap(); + StakingBonusManager::stake(&env, voter.clone(), 500, 30).unwrap(); + + // Create proposal + let proposal_type = ProposalType::ParameterChange { + param_key: ParamKey::FeeBps, + new_value: 25, + }; + let proposal_id = GovernanceSystem::create_proposal( + &env, + &proposer, + proposal_type, + symbol_short!("test"), + 86400, + ).unwrap(); + + // Cast vote + let result = GovernanceSystem::cast_vote( + &env, + &voter, + proposal_id, + VoteOption::For, + ); + + assert!(result.is_ok()); + + // Verify vote was recorded + let votes = GovernanceSystem::get_proposal_votes(&env, proposal_id); + assert_eq!(votes.len(), 1); + + let vote = votes.get(voter).unwrap(); + assert_eq!(vote.option, VoteOption::For); + assert_eq!(vote.voting_power, 500); + } + + #[test] + fn test_cast_vote_twice_fails() { + let env = Env::default(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + + // Set up voting power + StakingBonusManager::stake(&env, proposer.clone(), 1000, 30).unwrap(); + StakingBonusManager::stake(&env, voter.clone(), 500, 30).unwrap(); + + // Create proposal + let proposal_id = GovernanceSystem::create_proposal( + &env, + &proposer, + ProposalType::ParameterChange { + param_key: ParamKey::FeeBps, + new_value: 25, + }, + symbol_short!("test"), + 86400, + ).unwrap(); + + // Cast first vote + GovernanceSystem::cast_vote(&env, &voter, proposal_id, VoteOption::For).unwrap(); + + // Try to vote again - should fail + let result = GovernanceSystem::cast_vote(&env, &voter, proposal_id, VoteOption::Against); + assert!(result.is_err()); + } + + #[test] + fn test_execute_passed_proposal() { + let env = Env::default(); + let proposer = Address::generate(&env); + let voter1 = Address::generate(&env); + let voter2 = Address::generate(&env); + let executor = Address::generate(&env); + + // Set up voting power - total 2000, need >50% approval + StakingBonusManager::stake(&env, proposer.clone(), 500, 30).unwrap(); + StakingBonusManager::stake(&env, voter1.clone(), 800, 30).unwrap(); + StakingBonusManager::stake(&env, voter2.clone(), 700, 30).unwrap(); + + // Create proposal + let proposal_id = GovernanceSystem::create_proposal( + &env, + &proposer, + ProposalType::ParameterChange { + param_key: ParamKey::FeeBps, + new_value: 25, + }, + symbol_short!("fee_change"), + 86400, + ).unwrap(); + + // Cast votes - 800 + 700 = 1500 votes for (>50% of 2000 total) + GovernanceSystem::cast_vote(&env, &voter1, proposal_id, VoteOption::For).unwrap(); + GovernanceSystem::cast_vote(&env, &voter2, proposal_id, VoteOption::For).unwrap(); + + // Fast forward past voting period + env.ledger().set_timestamp(env.ledger().timestamp() + 86500); + + // Execute proposal + let result = GovernanceSystem::execute_proposal(&env, &executor, proposal_id); + assert!(result.is_ok()); + + // Verify proposal was executed + let proposal = GovernanceSystem::get_proposal(&env, proposal_id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Executed); + assert!(proposal.executed); + } + + #[test] + fn test_execute_failed_proposal() { + let env = Env::default(); + let proposer = Address::generate(&env); + let voter1 = Address::generate(&env); + let voter2 = Address::generate(&env); + let executor = Address::generate(&env); + + // Set up voting power + StakingBonusManager::stake(&env, proposer.clone(), 500, 30).unwrap(); + StakingBonusManager::stake(&env, voter1.clone(), 800, 30).unwrap(); + StakingBonusManager::stake(&env, voter2.clone(), 700, 30).unwrap(); + + // Create proposal + let proposal_id = GovernanceSystem::create_proposal( + &env, + &proposer, + ProposalType::ParameterChange { + param_key: ParamKey::FeeBps, + new_value: 25, + }, + symbol_short!("fee_change"), + 86400, + ).unwrap(); + + // Cast votes - not enough for quorum (20% required) + GovernanceSystem::cast_vote(&env, &voter1, proposal_id, VoteOption::For).unwrap(); + + // Fast forward past voting period + env.ledger().set_timestamp(env.ledger().timestamp() + 86500); + + // Try to execute - should fail + let result = GovernanceSystem::execute_proposal(&env, &executor, proposal_id); + assert!(result.is_err()); + } + + #[test] + fn test_cancel_proposal() { + let env = Env::default(); + let proposer = Address::generate(&env); + let other_user = Address::generate(&env); + + // Set up voting power + StakingBonusManager::stake(&env, proposer.clone(), 1000, 30).unwrap(); + + // Create proposal + let proposal_id = GovernanceSystem::create_proposal( + &env, + &proposer, + ProposalType::ParameterChange { + param_key: ParamKey::FeeBps, + new_value: 25, + }, + symbol_short!("test"), + 86400, + ).unwrap(); + + // Cancel by proposer + let result = GovernanceSystem::cancel_proposal(&env, &proposer, proposal_id); + assert!(result.is_ok()); + + // Verify cancelled + let proposal = GovernanceSystem::get_proposal(&env, proposal_id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Cancelled); + + // Try to cancel by someone else - should fail + let result2 = GovernanceSystem::cancel_proposal(&env, &other_user, proposal_id); + assert!(result2.is_err()); + } + + // ===== PARAMETER CHANGE EXECUTION TESTS ===== + + #[test] + fn test_execute_fee_change() { + let env = Env::default(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + let executor = Address::generate(&env); + + // Set up voting power + StakingBonusManager::stake(&env, proposer.clone(), 1000, 30).unwrap(); + StakingBonusManager::stake(&env, voter.clone(), 1000, 30).unwrap(); + + // Create fee change proposal + let proposal_id = GovernanceSystem::create_proposal( + &env, + &proposer, + ProposalType::ParameterChange { + param_key: ParamKey::FeeBps, + new_value: 50, // 0.5% + }, + symbol_short!("fee_change"), + 86400, + ).unwrap(); + + // Vote for + GovernanceSystem::cast_vote(&env, &voter, proposal_id, VoteOption::For).unwrap(); + + // Fast forward and execute + env.ledger().set_timestamp(env.ledger().timestamp() + 86500); + GovernanceSystem::execute_proposal(&env, &executor, proposal_id).unwrap(); + + // Verify fee was changed + let new_fee: u32 = env.storage().instance().get(&symbol_short!("fee_bps")).unwrap_or(30); + assert_eq!(new_fee, 50); + } + + #[test] + fn test_execute_admin_upgrade() { + let env = Env::default(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + let executor = Address::generate(&env); + let new_admin = Address::generate(&env); + + // Set up voting power + StakingBonusManager::stake(&env, proposer.clone(), 1000, 30).unwrap(); + StakingBonusManager::stake(&env, voter.clone(), 1000, 30).unwrap(); + + // Create admin upgrade proposal + let proposal_id = GovernanceSystem::create_proposal( + &env, + &proposer, + ProposalType::AdminUpgrade { new_admin: new_admin.clone() }, + symbol_short!("admin_upgrade"), + 86400, + ).unwrap(); + + // Vote for + GovernanceSystem::cast_vote(&env, &voter, proposal_id, VoteOption::For).unwrap(); + + // Fast forward and execute + env.ledger().set_timestamp(env.ledger().timestamp() + 86500); + GovernanceSystem::execute_proposal(&env, &executor, proposal_id).unwrap(); + + // Verify admin was changed + let current_admin = crate::admin::get_admin(&env); + assert_eq!(current_admin, new_admin); + } + + // ===== GOVERNANCE CONFIG TESTS ===== + + #[test] + fn test_governance_config_validation() { + let env = Env::default(); + let admin = Address::generate(&env); + + let mut config = GovernanceConfig::default(); + + // Valid config + let result = GovernanceSystem::set_config(&env, &admin, &config); + assert!(result.is_ok()); + + // Invalid config - min > max voting period + config.max_voting_period = config.min_voting_period - 1; + let result2 = GovernanceSystem::set_config(&env, &admin, &config); + assert!(result2.is_err()); + } + + // ===== VOTING POWER TESTS ===== + + #[test] + fn test_voting_power_calculation() { + let env = Env::default(); + let user = Address::generate(&env); + + // No stake = no voting power + let power = GovernanceSystem::get_voting_power(&env, &user); + assert_eq!(power, 0); + + // Add stake + StakingBonusManager::stake(&env, user.clone(), 1500, 30).unwrap(); + let power2 = GovernanceSystem::get_voting_power(&env, &user); + assert_eq!(power2, 1500); + } + + // ===== INTEGRATION TESTS ===== + + #[test] + fn test_full_governance_flow() { + let env = Env::default(); + let proposer = Address::generate(&env); + let voter1 = Address::generate(&env); + let voter2 = Address::generate(&env); + let voter3 = Address::generate(&env); + let executor = Address::generate(&env); + + // Set up diverse voting power + StakingBonusManager::stake(&env, proposer.clone(), 1000, 30).unwrap(); + StakingBonusManager::stake(&env, voter1.clone(), 2000, 30).unwrap(); + StakingBonusManager::stake(&env, voter2.clone(), 1500, 30).unwrap(); + StakingBonusManager::stake(&env, voter3.clone(), 800, 30).unwrap(); + + // Total voting power: 1000 + 2000 + 1500 + 800 = 5300 + + // 1. Create proposal + let proposal_id = CounterContract::create_governance_proposal( + env.clone(), + proposer.clone(), + ProposalType::ParameterChange { + param_key: ParamKey::MaxSwapAmount, + new_value: 1000000, + }, + symbol_short!("increase_max_swap"), + 86400, + ).unwrap(); + + // 2. Cast votes (3500 for, 800 abstain = 72% participation, 87% approval) + CounterContract::cast_governance_vote( + env.clone(), + voter1.clone(), + proposal_id, + VoteOption::For, + ).unwrap(); + + CounterContract::cast_governance_vote( + env.clone(), + voter2.clone(), + proposal_id, + VoteOption::For, + ).unwrap(); + + CounterContract::cast_governance_vote( + env.clone(), + voter3.clone(), + proposal_id, + VoteOption::Abstain, + ).unwrap(); + + // 3. Fast forward past voting period + env.ledger().set_timestamp(env.ledger().timestamp() + 86500); + + // 4. Execute proposal + CounterContract::execute_governance_proposal( + env.clone(), + executor.clone(), + proposal_id, + ).unwrap(); + + // 5. Verify execution + let proposal = CounterContract::get_governance_proposal(env.clone(), proposal_id).unwrap(); + assert_eq!(proposal.status, ProposalStatus::Executed); + + // 6. Verify parameter change + let max_swap: i128 = env.storage().instance().get(&symbol_short!("max_swap")).unwrap_or(0); + assert_eq!(max_swap, 1000000); + } + + #[test] + fn test_quorum_failure() { + let env = Env::default(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + let executor = Address::generate(&env); + + // Set up low voting power (below 20% quorum threshold) + StakingBonusManager::stake(&env, proposer.clone(), 1000, 30).unwrap(); + StakingBonusManager::stake(&env, voter.clone(), 100, 30).unwrap(); // Only 100/1100 = 9% + + // Create proposal + let proposal_id = CounterContract::create_governance_proposal( + env.clone(), + proposer.clone(), + ProposalType::EmergencyAction { pause: true }, + symbol_short!("emergency_pause"), + 86400, + ).unwrap(); + + // Cast vote (only 9% participation) + CounterContract::cast_governance_vote( + env.clone(), + voter.clone(), + proposal_id, + VoteOption::For, + ).unwrap(); + + // Fast forward and try to execute + env.ledger().set_timestamp(env.ledger().timestamp() + 86500); + let result = CounterContract::execute_governance_proposal( + env.clone(), + executor.clone(), + proposal_id, + ); + + // Should fail due to insufficient quorum + assert!(result.is_err()); + } + + #[test] + fn test_proposal_cooldown() { + let env = Env::default(); + let proposer = Address::generate(&env); + + StakingBonusManager::stake(&env, proposer.clone(), 1000, 30).unwrap(); + + // Create first proposal + CounterContract::create_governance_proposal( + env.clone(), + proposer.clone(), + ProposalType::ParameterChange { + param_key: ParamKey::FeeBps, + new_value: 20, + }, + symbol_short!("proposal1"), + 86400, + ).unwrap(); + + // Try to create another proposal immediately - should fail + let result = CounterContract::create_governance_proposal( + env.clone(), + proposer.clone(), + ProposalType::ParameterChange { + param_key: ParamKey::FeeBps, + new_value: 30, + }, + symbol_short!("proposal2"), + 86400, + ); + + assert!(result.is_err()); + } +} \ No newline at end of file diff --git a/swaptrade-contracts/counter/src/governance_types.rs b/swaptrade-contracts/counter/src/governance_types.rs new file mode 100644 index 0000000..40f528c --- /dev/null +++ b/swaptrade-contracts/counter/src/governance_types.rs @@ -0,0 +1,144 @@ +use soroban_sdk::{contracttype, Address, Env, Symbol, Vec, Map}; +use crate::errors::SwapTradeError; + +/// Governance proposal types +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ProposalType { + /// Change protocol parameters (fees, limits, etc.) + ParameterChange { + param_key: ParamKey, + new_value: i128, + }, + /// Upgrade admin address + AdminUpgrade { + new_admin: Address, + }, + /// Emergency pause/unpause + EmergencyAction { + pause: bool, + }, + /// Custom proposal with description + Custom { + title: Symbol, + description: Symbol, + }, +} + +/// Governance proposal status +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ProposalStatus { + Active, + Passed, + Rejected, + Executed, + Cancelled, + Expired, +} + +/// Vote options +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum VoteOption { + For, + Against, + Abstain, +} + +/// Individual vote record +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Vote { + pub voter: Address, + pub option: VoteOption, + pub voting_power: u128, + pub timestamp: u64, +} + +/// Governance proposal +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct Proposal { + pub id: u64, + pub proposer: Address, + pub proposal_type: ProposalType, + pub description: Symbol, + pub start_time: u64, + pub end_time: u64, + pub execution_time: Option, + pub status: ProposalStatus, + pub votes_for: u128, + pub votes_against: u128, + pub votes_abstain: u128, + pub total_voting_power: u128, + pub quorum_required: u128, + pub approval_threshold: u32, // Basis points (5000 = 50%) + pub executed: bool, +} + +/// Governance configuration +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct GovernanceConfig { + /// Minimum voting period in seconds + pub min_voting_period: u64, + /// Maximum voting period in seconds + pub max_voting_period: u64, + /// Minimum quorum as percentage of total voting power (basis points) + pub quorum_threshold: u32, + /// Approval threshold for passing proposals (basis points) + pub approval_threshold: u32, + /// Execution delay after proposal passes (seconds) + pub execution_delay: u64, + /// Proposal creation cooldown (seconds) + pub proposal_cooldown: u64, +} + +/// Voting power snapshot for proposals +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct VotingPowerSnapshot { + pub total_voting_power: u128, + pub voter_powers: Map, +} + +/// Governance storage keys +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum GovernanceKey { + Config, + Proposal(u64), + ProposalVotes(u64), + VoterLastProposal(Address), + NextProposalId, + VotingPowerSnapshot(u64), +} + +/// Parameter keys that can be governed +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ParamKey { + MaxSwapAmount, + FeeBps, + RateLimitWindow, + MaxSlippageBps, + EmergencyPause, + RiskConfigMaxPosition, + RiskConfigConcentrationLimit, + GovernanceQuorumThreshold, + GovernanceApprovalThreshold, +} + +impl Default for GovernanceConfig { + fn default() -> Self { + Self { + min_voting_period: 86400, // 1 day + max_voting_period: 604800, // 7 days + quorum_threshold: 2000, // 20% + approval_threshold: 5000, // 50% + execution_delay: 172800, // 2 days + proposal_cooldown: 3600, // 1 hour + } + } +} \ No newline at end of file diff --git a/swaptrade-contracts/counter/src/lib.rs b/swaptrade-contracts/counter/src/lib.rs index be1c716..2334594 100644 --- a/swaptrade-contracts/counter/src/lib.rs +++ b/swaptrade-contracts/counter/src/lib.rs @@ -44,6 +44,8 @@ mod analytics_dashboard_tests; mod oracle_adapter_tests; #[cfg(test)] mod multihop_swap_tests; +mod governance_types; +mod governance_system; mod governance_params; mod nonce; mod risk_management; @@ -1384,6 +1386,93 @@ impl CounterContract { } // ──────────────────────────────────────────────────────────────────────── + // On-Chain Governance System + // ──────────────────────────────────────────────────────────────────────── + + /// Create a new governance proposal + pub fn create_governance_proposal( + env: Env, + proposer: Address, + proposal_type: governance_types::ProposalType, + description: Symbol, + voting_period: u64, + ) -> Result { + governance_system::GovernanceSystem::create_proposal( + &env, + &proposer, + proposal_type, + description, + voting_period, + ) + } + + /// Cast a vote on a governance proposal + pub fn cast_governance_vote( + env: Env, + voter: Address, + proposal_id: u64, + vote_option: governance_types::VoteOption, + ) -> Result<(), SwapTradeError> { + governance_system::GovernanceSystem::cast_vote(&env, &voter, proposal_id, vote_option) + } + + /// Execute a passed governance proposal + pub fn execute_governance_proposal( + env: Env, + executor: Address, + proposal_id: u64, + ) -> Result<(), SwapTradeError> { + governance_system::GovernanceSystem::execute_proposal(&env, &executor, proposal_id) + } + + /// Cancel a governance proposal (only by proposer) + pub fn cancel_governance_proposal( + env: Env, + canceller: Address, + proposal_id: u64, + ) -> Result<(), SwapTradeError> { + governance_system::GovernanceSystem::cancel_proposal(&env, &canceller, proposal_id) + } + + /// Get governance proposal details + pub fn get_governance_proposal( + env: Env, + proposal_id: u64, + ) -> Result { + governance_system::GovernanceSystem::get_proposal(&env, proposal_id) + } + + /// Get votes for a governance proposal + pub fn get_governance_proposal_votes( + env: Env, + proposal_id: u64, + ) -> soroban_sdk::Map { + governance_system::GovernanceSystem::get_proposal_votes(&env, proposal_id) + } + + /// Get governance configuration + pub fn get_governance_config(env: Env) -> governance_types::GovernanceConfig { + governance_system::GovernanceSystem::get_config(&env) + } + + /// Set governance configuration (admin only) + pub fn set_governance_config( + env: Env, + admin: Address, + config: governance_types::GovernanceConfig, + ) -> Result<(), SwapTradeError> { + governance_system::GovernanceSystem::set_config(&env, &admin, &config) + } + + /// Get voting power for an address + pub fn get_voting_power(env: Env, voter: Address) -> u128 { + // For now, voting power is based on staked tokens + crate::staking_bonus::StakingBonusManager::get_user_total_staked(&env, voter.clone()) as u128 + } + + /// Get total voting power in the system + pub fn get_total_voting_power(env: Env) -> u128 { + crate::staking_bonus::StakingBonusManager::get_total_staked(&env) as u128 // Risk Management System // ──────────────────────────────────────────────────────────────────────── @@ -1539,5 +1628,5 @@ impl CounterContract { #[cfg(all(test, feature = "experimental"))] mod migration_tests; - -// trading tests are provided as integration/unit tests in the repository tests/ folder +mod risk_management_tests; +mod governance_tests;