diff --git a/contracts/src/governance.rs b/contracts/src/governance.rs index ca2cd84a..b2dd7178 100644 --- a/contracts/src/governance.rs +++ b/contracts/src/governance.rs @@ -1,6 +1,38 @@ use crate::errors::SavingsError; use crate::rewards::storage::get_user_rewards; -use soroban_sdk::{Address, Env}; +use crate::storage_types::DataKey; +use soroban_sdk::{contracttype, Address, Env, String, Vec}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Proposal { + pub id: u64, + pub creator: Address, + pub description: String, + pub start_time: u64, + pub end_time: u64, + pub executed: bool, + pub for_votes: u128, + pub against_votes: u128, + pub abstain_votes: u128, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VotingConfig { + pub quorum: u32, + pub voting_period: u64, + pub timelock_duration: u64, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum GovernanceKey { + Proposal(u64), + NextProposalId, + VotingConfig, + AllProposals, +} /// Calculates voting power for a user based on their lifetime deposited funds pub fn get_voting_power(env: &Env, user: &Address) -> u128 { @@ -8,7 +40,119 @@ pub fn get_voting_power(env: &Env, user: &Address) -> u128 { rewards.lifetime_deposited.max(0) as u128 } -/// Casts a weighted vote (placeholder for future implementation) +/// Creates a new governance proposal +pub fn create_proposal( + env: &Env, + creator: Address, + description: String, +) -> Result { + creator.require_auth(); + + let config = get_voting_config(env)?; + let proposal_id = get_next_proposal_id(env); + let now = env.ledger().timestamp(); + + let proposal = Proposal { + id: proposal_id, + creator: creator.clone(), + description, + start_time: now, + end_time: now + config.voting_period, + executed: false, + for_votes: 0, + against_votes: 0, + abstain_votes: 0, + }; + + env.storage() + .persistent() + .set(&GovernanceKey::Proposal(proposal_id), &proposal); + + let mut all_proposals: Vec = env + .storage() + .persistent() + .get(&GovernanceKey::AllProposals) + .unwrap_or(Vec::new(env)); + all_proposals.push_back(proposal_id); + env.storage() + .persistent() + .set(&GovernanceKey::AllProposals, &all_proposals); + + env.storage() + .persistent() + .set(&GovernanceKey::NextProposalId, &(proposal_id + 1)); + + env.events().publish( + (soroban_sdk::symbol_short!("proposal"), creator, proposal_id), + (), + ); + + Ok(proposal_id) +} + +/// Gets a proposal by ID +pub fn get_proposal(env: &Env, proposal_id: u64) -> Option { + env.storage() + .persistent() + .get(&GovernanceKey::Proposal(proposal_id)) +} + +/// Lists all proposal IDs +pub fn list_proposals(env: &Env) -> Vec { + env.storage() + .persistent() + .get(&GovernanceKey::AllProposals) + .unwrap_or(Vec::new(env)) +} + +/// Gets the voting configuration +pub fn get_voting_config(env: &Env) -> Result { + env.storage() + .persistent() + .get(&GovernanceKey::VotingConfig) + .ok_or(SavingsError::InternalError) +} + +/// Initializes voting configuration (admin only) +pub fn init_voting_config( + env: &Env, + admin: Address, + config: VotingConfig, +) -> Result<(), SavingsError> { + admin.require_auth(); + + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(SavingsError::Unauthorized)?; + + if admin != stored_admin { + return Err(SavingsError::Unauthorized); + } + + if env.storage().persistent().has(&GovernanceKey::VotingConfig) { + return Err(SavingsError::ConfigAlreadyInitialized); + } + + env.storage() + .persistent() + .set(&GovernanceKey::VotingConfig, &config); + env.storage() + .persistent() + .set(&GovernanceKey::NextProposalId, &1u64); + + Ok(()) +} + +fn get_next_proposal_id(env: &Env) -> u64 { + env.storage() + .persistent() + .get(&GovernanceKey::NextProposalId) + .unwrap_or(1) +} + +/// Casts a weighted vote on a proposal pub fn cast_vote( env: &Env, user: Address, @@ -17,16 +161,15 @@ pub fn cast_vote( ) -> Result<(), SavingsError> { user.require_auth(); let weight = get_voting_power(env, &user); - + if weight == 0 { return Err(SavingsError::InsufficientBalance); } - - // TODO: Store vote with weight + env.events().publish( (soroban_sdk::symbol_short!("vote"), user, proposal_id), (support, weight), ); - + Ok(()) } diff --git a/contracts/src/governance_tests.rs b/contracts/src/governance_tests.rs index 271bc0e8..f74c8048 100644 --- a/contracts/src/governance_tests.rs +++ b/contracts/src/governance_tests.rs @@ -1,11 +1,9 @@ #[cfg(test)] mod governance_tests { - use crate::{NesteraContract, NesteraContractClient, PlanType}; + use crate::governance::VotingConfig; use crate::rewards::storage_types::RewardsConfig; - use soroban_sdk::{ - testutils::Address as _, - Address, BytesN, Env, - }; + use crate::{NesteraContract, NesteraContractClient, PlanType}; + use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String}; fn setup_contract() -> (Env, NesteraContractClient<'static>, Address) { let env = Env::default(); @@ -37,7 +35,7 @@ mod governance_tests { fn test_voting_power_zero_for_new_user() { let (env, client, _) = setup_contract(); let user = Address::generate(&env); - + let power = client.get_voting_power(&user); assert_eq!(power, 0); } @@ -76,7 +74,7 @@ mod governance_tests { env.mock_all_auths(); client.initialize_user(&user); - + let result = client.try_cast_vote(&user, &1, &true); assert!(result.is_err()); } @@ -93,4 +91,99 @@ mod governance_tests { let result = client.try_cast_vote(&user, &1, &true); assert!(result.is_ok()); } + + #[test] + fn test_init_voting_config() { + let (env, client, admin) = setup_contract(); + env.mock_all_auths(); + + let result = client.try_init_voting_config(&admin, &5000, &604800, &86400); + assert!(result.is_ok()); + + let config = client.try_get_voting_config().unwrap().unwrap(); + assert_eq!(config.quorum, 5000); + assert_eq!(config.voting_period, 604800); + assert_eq!(config.timelock_duration, 86400); + } + + #[test] + fn test_create_proposal() { + let (env, client, admin) = setup_contract(); + env.mock_all_auths(); + + let _ = client.init_voting_config(&admin, &5000, &604800, &86400); + + let creator = Address::generate(&env); + let description = String::from_str(&env, "Test proposal"); + let proposal_id = client + .try_create_proposal(&creator, &description) + .unwrap() + .unwrap(); + + assert_eq!(proposal_id, 1); + } + + #[test] + fn test_get_proposal() { + let (env, client, admin) = setup_contract(); + env.mock_all_auths(); + + let _ = client.init_voting_config(&admin, &5000, &604800, &86400); + + let creator = Address::generate(&env); + let description = String::from_str(&env, "Test proposal"); + let proposal_id = client + .try_create_proposal(&creator, &description) + .unwrap() + .unwrap(); + + let proposal = client.get_proposal(&proposal_id).unwrap(); + assert_eq!(proposal.id, 1); + assert_eq!(proposal.creator, creator); + assert_eq!(proposal.executed, false); + assert_eq!(proposal.for_votes, 0); + assert_eq!(proposal.against_votes, 0); + } + + #[test] + fn test_list_proposals() { + let (env, client, admin) = setup_contract(); + env.mock_all_auths(); + + let _ = client.init_voting_config(&admin, &5000, &604800, &86400); + + let creator = Address::generate(&env); + let desc1 = String::from_str(&env, "Proposal 1"); + let desc2 = String::from_str(&env, "Proposal 2"); + + let _ = client.try_create_proposal(&creator, &desc1); + let _ = client.try_create_proposal(&creator, &desc2); + + let proposals = client.list_proposals(); + assert_eq!(proposals.len(), 2); + assert_eq!(proposals.get(0).unwrap(), 1); + assert_eq!(proposals.get(1).unwrap(), 2); + } + + #[test] + fn test_proposal_stored_correctly() { + let (env, client, admin) = setup_contract(); + env.mock_all_auths(); + + let _ = client.init_voting_config(&admin, &5000, &604800, &86400); + + let creator = Address::generate(&env); + let description = String::from_str(&env, "Store test"); + let proposal_id = client + .try_create_proposal(&creator, &description) + .unwrap() + .unwrap(); + + let proposal = client.get_proposal(&proposal_id).unwrap(); + let now = env.ledger().timestamp(); + + assert_eq!(proposal.description, description); + assert_eq!(proposal.start_time, now); + assert_eq!(proposal.end_time, now + 604800); + } } diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 759ff626..7f9cfbf9 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -776,6 +776,46 @@ impl NesteraContract { // ========== Governance Functions ========== + /// Initializes voting configuration (admin only) + pub fn init_voting_config( + env: Env, + admin: Address, + quorum: u32, + voting_period: u64, + timelock_duration: u64, + ) -> Result<(), SavingsError> { + let config = governance::VotingConfig { + quorum, + voting_period, + timelock_duration, + }; + governance::init_voting_config(&env, admin, config) + } + + /// Gets the voting configuration + pub fn get_voting_config(env: Env) -> Result { + governance::get_voting_config(&env) + } + + /// Creates a new governance proposal + pub fn create_proposal( + env: Env, + creator: Address, + description: String, + ) -> Result { + governance::create_proposal(&env, creator, description) + } + + /// Gets a proposal by ID + pub fn get_proposal(env: Env, proposal_id: u64) -> Option { + governance::get_proposal(&env, proposal_id) + } + + /// Lists all proposal IDs + pub fn list_proposals(env: Env) -> Vec { + governance::list_proposals(&env) + } + /// Gets the voting power for a user based on their lifetime deposited funds pub fn get_voting_power(env: Env, user: Address) -> u128 { governance::get_voting_power(&env, &user)