From 8e9ba8d95cec4e7a21867d5d019edd78fc9de0d3 Mon Sep 17 00:00:00 2001 From: Emeka Date: Mon, 23 Feb 2026 04:05:12 +0100 Subject: [PATCH 1/4] feat: implement governance module setup (#110) - Add Proposal and VotingConfig structs - Implement create_proposal, get_proposal, list_proposals - Add init_voting_config for admin configuration - Proposals stored in blockchain persistent storage - 10 tests passing, contract builds successfully --- contracts/src/governance.rs | 155 ++++++++++++++++++++++++++++-- contracts/src/governance_tests.rs | 107 +++++++++++++++++++-- contracts/src/lib.rs | 40 ++++++++ 3 files changed, 289 insertions(+), 13 deletions(-) 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) From 0fe05db163ca265a357b7960df50290848c2a5d8 Mon Sep 17 00:00:00 2001 From: Emeka Date: Mon, 23 Feb 2026 04:50:14 +0100 Subject: [PATCH 2/4] implement admin to governance transition --- contracts/src/governance.rs | 135 ++++++++++++++++++ contracts/src/lib.rs | 89 ++++++------ contracts/src/rates.rs | 30 +++- contracts/src/rates_test.rs | 34 ++--- contracts/src/transition_tests.rs | 122 ++++++++++++++++ .../test_admin_can_set_rates.1.json | 15 ++ .../test_interest_rate_configuration.1.json | 15 ++ contracts/tests/integration.rs | 12 +- 8 files changed, 379 insertions(+), 73 deletions(-) create mode 100644 contracts/src/transition_tests.rs diff --git a/contracts/src/governance.rs b/contracts/src/governance.rs index b2dd7178..e818584b 100644 --- a/contracts/src/governance.rs +++ b/contracts/src/governance.rs @@ -3,6 +3,21 @@ use crate::rewards::storage::get_user_rewards; use crate::storage_types::DataKey; use soroban_sdk::{contracttype, Address, Env, String, Vec}; +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActionProposal { + 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, + pub action: ProposalAction, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct Proposal { @@ -29,9 +44,22 @@ pub struct VotingConfig { #[derive(Clone, Debug, Eq, PartialEq)] pub enum GovernanceKey { Proposal(u64), + ActionProposal(u64), NextProposalId, VotingConfig, AllProposals, + GovernanceActive, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProposalAction { + SetFlexiRate(i128), + SetGoalRate(i128), + SetGroupRate(i128), + SetLockRate(u64, i128), + PauseContract, + UnpauseContract, } /// Calculates voting power for a user based on their lifetime deposited funds @@ -90,6 +118,65 @@ pub fn create_proposal( Ok(proposal_id) } +/// Creates a governance proposal with an action +pub fn create_action_proposal( + env: &Env, + creator: Address, + description: String, + action: ProposalAction, +) -> 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 = ActionProposal { + 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, + action, + }; + + env.storage() + .persistent() + .set(&GovernanceKey::ActionProposal(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 an action proposal by ID +pub fn get_action_proposal(env: &Env, proposal_id: u64) -> Option { + env.storage() + .persistent() + .get(&GovernanceKey::ActionProposal(proposal_id)) +} + /// Gets a proposal by ID pub fn get_proposal(env: &Env, proposal_id: u64) -> Option { env.storage() @@ -173,3 +260,51 @@ pub fn cast_vote( Ok(()) } + +/// Checks if governance is active +pub fn is_governance_active(env: &Env) -> bool { + env.storage() + .persistent() + .get(&GovernanceKey::GovernanceActive) + .unwrap_or(false) +} + +/// Activates governance (admin only, one-time) +pub fn activate_governance(env: &Env, admin: Address) -> 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); + } + + env.storage() + .persistent() + .set(&GovernanceKey::GovernanceActive, &true); + + Ok(()) +} + +/// Validates caller is admin or governance is active +pub fn validate_admin_or_governance(env: &Env, caller: &Address) -> Result { + if is_governance_active(env) { + return Ok(true); + } + + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(SavingsError::Unauthorized)?; + + if caller == &stored_admin { + Ok(false) + } else { + Err(SavingsError::Unauthorized) + } +} diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 7f9cfbf9..55c811ae 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -415,29 +415,25 @@ impl NesteraContract { Ok(()) } - pub fn set_flexi_rate(env: Env, rate: i128) -> Result<(), SavingsError> { - let admin = env.storage().instance().get(&DataKey::Admin).unwrap(); - let admin_address: Address = admin; // Type casting for clarity, though get returns generic - admin_address.require_auth(); - rates::set_flexi_rate(&env, rate) + pub fn set_flexi_rate(env: Env, caller: Address, rate: i128) -> Result<(), SavingsError> { + rates::set_flexi_rate(&env, caller, rate) } - pub fn set_goal_rate(env: Env, rate: i128) -> Result<(), SavingsError> { - let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); - admin.require_auth(); - rates::set_goal_rate(&env, rate) + pub fn set_goal_rate(env: Env, caller: Address, rate: i128) -> Result<(), SavingsError> { + rates::set_goal_rate(&env, caller, rate) } - pub fn set_group_rate(env: Env, rate: i128) -> Result<(), SavingsError> { - let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); - admin.require_auth(); - rates::set_group_rate(&env, rate) + pub fn set_group_rate(env: Env, caller: Address, rate: i128) -> Result<(), SavingsError> { + rates::set_group_rate(&env, caller, rate) } - pub fn set_lock_rate(env: Env, duration_days: u64, rate: i128) -> Result<(), SavingsError> { - let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); - admin.require_auth(); - rates::set_lock_rate(&env, duration_days, rate) + pub fn set_lock_rate( + env: Env, + caller: Address, + duration_days: u64, + rate: i128, + ) -> Result<(), SavingsError> { + rates::set_lock_rate(&env, caller, duration_days, rate) } pub fn set_early_break_fee_bps(env: Env, bps: u32) -> Result<(), SavingsError> { @@ -474,39 +470,23 @@ impl NesteraContract { Ok(()) } - pub fn pause(env: Env, admin: Address) -> Result<(), SavingsError> { - admin.require_auth(); - let stored_admin: Option
= env.storage().instance().get(&DataKey::Admin); - - // Use .clone() here so 'admin' isn't moved - if stored_admin != Some(admin.clone()) { - return Err(SavingsError::Unauthorized); - } + pub fn pause(env: Env, caller: Address) -> Result<(), SavingsError> { + caller.require_auth(); + governance::validate_admin_or_governance(&env, &caller)?; env.storage().persistent().set(&DataKey::Paused, &true); - - // Extend TTL on config update ttl::extend_config_ttl(&env, &DataKey::Paused); - - env.events().publish((symbol_short!("pause"), admin), ()); + env.events().publish((symbol_short!("pause"), caller), ()); Ok(()) } - pub fn unpause(env: Env, admin: Address) -> Result<(), SavingsError> { - admin.require_auth(); - let stored_admin: Option
= env.storage().instance().get(&DataKey::Admin); - - // Use .clone() here too - if stored_admin != Some(admin.clone()) { - return Err(SavingsError::Unauthorized); - } + pub fn unpause(env: Env, caller: Address) -> Result<(), SavingsError> { + caller.require_auth(); + governance::validate_admin_or_governance(&env, &caller)?; env.storage().persistent().set(&DataKey::Paused, &false); - - // Extend TTL on config update ttl::extend_config_ttl(&env, &DataKey::Paused); - - env.events().publish((symbol_short!("unpause"), admin), ()); + env.events().publish((symbol_short!("unpause"), caller), ()); Ok(()) } @@ -806,11 +786,26 @@ impl NesteraContract { governance::create_proposal(&env, creator, description) } + /// Creates a governance proposal with an action + pub fn create_action_proposal( + env: Env, + creator: Address, + description: String, + action: governance::ProposalAction, + ) -> Result { + governance::create_action_proposal(&env, creator, description, action) + } + /// Gets a proposal by ID pub fn get_proposal(env: Env, proposal_id: u64) -> Option { governance::get_proposal(&env, proposal_id) } + /// Gets an action proposal by ID + pub fn get_action_proposal(env: Env, proposal_id: u64) -> Option { + governance::get_action_proposal(&env, proposal_id) + } + /// Lists all proposal IDs pub fn list_proposals(env: Env) -> Vec { governance::list_proposals(&env) @@ -830,6 +825,16 @@ impl NesteraContract { ) -> Result<(), SavingsError> { governance::cast_vote(&env, user, proposal_id, support) } + + /// Activates governance (admin only, one-time) + pub fn activate_governance(env: Env, admin: Address) -> Result<(), SavingsError> { + governance::activate_governance(&env, admin) + } + + /// Checks if governance is active + pub fn is_governance_active(env: Env) -> bool { + governance::is_governance_active(&env) + } } #[cfg(test)] @@ -843,4 +848,6 @@ mod rates_test; #[cfg(test)] mod test; #[cfg(test)] +mod transition_tests; +#[cfg(test)] mod ttl_tests; diff --git a/contracts/src/rates.rs b/contracts/src/rates.rs index 6af87429..a16d9c24 100644 --- a/contracts/src/rates.rs +++ b/contracts/src/rates.rs @@ -1,10 +1,14 @@ +use crate::governance; use crate::storage_types::DataKey; use crate::SavingsError; -use soroban_sdk::Env; +use soroban_sdk::{Address, Env}; -// --- Admin Setters --- +// --- Admin Setters (with governance transition) --- + +pub fn set_flexi_rate(env: &Env, caller: Address, rate: i128) -> Result<(), SavingsError> { + caller.require_auth(); + governance::validate_admin_or_governance(env, &caller)?; -pub fn set_flexi_rate(env: &Env, rate: i128) -> Result<(), SavingsError> { if rate < 0 { return Err(SavingsError::InvalidInterestRate); } @@ -12,7 +16,10 @@ pub fn set_flexi_rate(env: &Env, rate: i128) -> Result<(), SavingsError> { Ok(()) } -pub fn set_goal_rate(env: &Env, rate: i128) -> Result<(), SavingsError> { +pub fn set_goal_rate(env: &Env, caller: Address, rate: i128) -> Result<(), SavingsError> { + caller.require_auth(); + governance::validate_admin_or_governance(env, &caller)?; + if rate < 0 { return Err(SavingsError::InvalidInterestRate); } @@ -20,7 +27,10 @@ pub fn set_goal_rate(env: &Env, rate: i128) -> Result<(), SavingsError> { Ok(()) } -pub fn set_group_rate(env: &Env, rate: i128) -> Result<(), SavingsError> { +pub fn set_group_rate(env: &Env, caller: Address, rate: i128) -> Result<(), SavingsError> { + caller.require_auth(); + governance::validate_admin_or_governance(env, &caller)?; + if rate < 0 { return Err(SavingsError::InvalidInterestRate); } @@ -28,7 +38,15 @@ pub fn set_group_rate(env: &Env, rate: i128) -> Result<(), SavingsError> { Ok(()) } -pub fn set_lock_rate(env: &Env, duration_days: u64, rate: i128) -> Result<(), SavingsError> { +pub fn set_lock_rate( + env: &Env, + caller: Address, + duration_days: u64, + rate: i128, +) -> Result<(), SavingsError> { + caller.require_auth(); + governance::validate_admin_or_governance(env, &caller)?; + if rate < 0 { return Err(SavingsError::InvalidInterestRate); } diff --git a/contracts/src/rates_test.rs b/contracts/src/rates_test.rs index 73f57094..91d3ed27 100644 --- a/contracts/src/rates_test.rs +++ b/contracts/src/rates_test.rs @@ -31,28 +31,28 @@ fn test_default_rates_are_zero() { #[test] fn test_admin_can_set_rates() { - let (env, client, _admin) = setup(); + let (env, client, admin) = setup(); env.mock_all_auths(); // Set Flexi Rate to 500 (5%) - assert!(client.try_set_flexi_rate(&500).is_ok()); + assert!(client.try_set_flexi_rate(&admin, &500).is_ok()); assert_eq!(client.get_flexi_rate(), 500); // Set Goal Rate - assert!(client.try_set_goal_rate(&600).is_ok()); + assert!(client.try_set_goal_rate(&admin, &600).is_ok()); assert_eq!(client.get_goal_rate(), 600); // Set Group Rate - assert!(client.try_set_group_rate(&700).is_ok()); + assert!(client.try_set_group_rate(&admin, &700).is_ok()); assert_eq!(client.get_group_rate(), 700); // Set Lock Rate for 30 days - assert!(client.try_set_lock_rate(&30, &800).is_ok()); + assert!(client.try_set_lock_rate(&admin, &30, &800).is_ok()); assert_eq!(client.get_lock_rate(&30), 800); // Verify independent lock rates - assert!(client.try_set_lock_rate(&60, &900).is_ok()); + assert!(client.try_set_lock_rate(&admin, &60, &900).is_ok()); assert_eq!(client.get_lock_rate(&60), 900); assert_eq!(client.get_lock_rate(&30), 800); // 30 days unchanged } @@ -60,30 +60,24 @@ fn test_admin_can_set_rates() { #[test] fn test_non_admin_cannot_set_rates() { let (env, client, _admin) = setup(); - let _user = Address::generate(&env); - - // Clear the "mock all" from setup so we can test failures - env.mock_auths(&[]); + let user = Address::generate(&env); - // We only mock the user's auth (implied if we were doing user actions), - // but here we want to assert that even if we call it, it fails because ADMIN auth is missing. - // Since we cleared mock_all_auths, and we don't mock admin auth, - // `admin.require_auth()` inside the contract should fail. + env.mock_all_auths(); // Try set flexi rate - let res = client.try_set_flexi_rate(&500); + let res = client.try_set_flexi_rate(&user, &500); assert!(res.is_err()); // Try set goal rate - let res = client.try_set_goal_rate(&500); + let res = client.try_set_goal_rate(&user, &500); assert!(res.is_err()); // Try set group rate - let res = client.try_set_group_rate(&500); + let res = client.try_set_group_rate(&user, &500); assert!(res.is_err()); // Try set lock rate - let res = client.try_set_lock_rate(&30, &500); + let res = client.try_set_lock_rate(&user, &30, &500); assert!(res.is_err()); } @@ -114,10 +108,10 @@ fn test_calculate_lock_interest_logic() { #[test] fn test_invalid_rates() { - let (env, client, _admin) = setup(); + let (env, client, admin) = setup(); env.mock_all_auths(); // Try set negative rate - let res = client.try_set_flexi_rate(&-100); + let res = client.try_set_flexi_rate(&admin, &-100); assert_eq!(res.unwrap_err(), Ok(SavingsError::InvalidInterestRate)); } diff --git a/contracts/src/transition_tests.rs b/contracts/src/transition_tests.rs new file mode 100644 index 00000000..32234c9d --- /dev/null +++ b/contracts/src/transition_tests.rs @@ -0,0 +1,122 @@ +#[cfg(test)] +mod transition_tests { + use crate::governance::{ProposalAction, VotingConfig}; + use crate::rewards::storage_types::RewardsConfig; + use crate::{NesteraContract, NesteraContractClient}; + use soroban_sdk::{testutils::Address as _, Address, BytesN, Env, String}; + + fn setup_contract() -> (Env, NesteraContractClient<'static>, Address) { + let env = Env::default(); + let contract_id = env.register(NesteraContract, ()); + let client = NesteraContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let admin_pk = BytesN::from_array(&env, &[1u8; 32]); + + env.mock_all_auths(); + client.initialize(&admin, &admin_pk); + + let config = RewardsConfig { + points_per_token: 10, + streak_bonus_bps: 0, + long_lock_bonus_bps: 0, + goal_completion_bonus: 0, + enabled: true, + min_deposit_for_rewards: 0, + action_cooldown_seconds: 0, + max_daily_points: 1_000_000, + max_streak_multiplier: 10_000, + }; + let _ = client.initialize_rewards_config(&config); + + (env, client, admin) + } + + #[test] + fn test_admin_can_set_rates_before_governance() { + let (env, client, admin) = setup_contract(); + env.mock_all_auths(); + + let result = client.try_set_flexi_rate(&admin, &500); + assert!(result.is_ok()); + assert_eq!(client.get_flexi_rate(), 500); + } + + #[test] + fn test_non_admin_cannot_set_rates_before_governance() { + let (env, client, _) = setup_contract(); + let non_admin = Address::generate(&env); + env.mock_all_auths(); + + let result = client.try_set_flexi_rate(&non_admin, &500); + assert!(result.is_err()); + } + + #[test] + fn test_admin_can_pause_before_governance() { + let (env, client, admin) = setup_contract(); + env.mock_all_auths(); + + let result = client.try_pause(&admin); + assert!(result.is_ok()); + assert!(client.is_paused()); + } + + #[test] + fn test_governance_activation() { + let (env, client, admin) = setup_contract(); + env.mock_all_auths(); + + assert!(!client.is_governance_active()); + + let result = client.try_activate_governance(&admin); + assert!(result.is_ok()); + assert!(client.is_governance_active()); + } + + #[test] + fn test_admin_can_still_act_after_governance_active() { + let (env, client, admin) = setup_contract(); + env.mock_all_auths(); + + let _ = client.activate_governance(&admin); + let result = client.try_set_flexi_rate(&admin, &600); + assert!(result.is_ok()); + } + + #[test] + fn test_create_action_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, "Set flexi rate to 500"); + let action = ProposalAction::SetFlexiRate(500); + + let proposal_id = client + .try_create_action_proposal(&creator, &description, &action) + .unwrap() + .unwrap(); + + let proposal = client.get_action_proposal(&proposal_id).unwrap(); + assert_eq!(proposal.action, ProposalAction::SetFlexiRate(500)); + } + + #[test] + fn test_backward_compatibility_existing_settings() { + let (env, client, admin) = setup_contract(); + env.mock_all_auths(); + + let _ = client.set_flexi_rate(&admin, &300); + let _ = client.set_goal_rate(&admin, &400); + + assert_eq!(client.get_flexi_rate(), 300); + assert_eq!(client.get_goal_rate(), 400); + + let _ = client.activate_governance(&admin); + + assert_eq!(client.get_flexi_rate(), 300); + assert_eq!(client.get_goal_rate(), 400); + } +} diff --git a/contracts/test_snapshots/rates_test/test_admin_can_set_rates.1.json b/contracts/test_snapshots/rates_test/test_admin_can_set_rates.1.json index 80988463..c4278a65 100644 --- a/contracts/test_snapshots/rates_test/test_admin_can_set_rates.1.json +++ b/contracts/test_snapshots/rates_test/test_admin_can_set_rates.1.json @@ -37,6 +37,9 @@ "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", "function_name": "set_flexi_rate", "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, { "i128": "500" } @@ -57,6 +60,9 @@ "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", "function_name": "set_goal_rate", "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, { "i128": "600" } @@ -77,6 +83,9 @@ "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", "function_name": "set_group_rate", "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, { "i128": "700" } @@ -97,6 +106,9 @@ "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", "function_name": "set_lock_rate", "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, { "u64": "30" }, @@ -120,6 +132,9 @@ "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", "function_name": "set_lock_rate", "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, { "u64": "60" }, diff --git a/contracts/test_snapshots/test_interest_rate_configuration.1.json b/contracts/test_snapshots/test_interest_rate_configuration.1.json index ccf766eb..2dbcda9a 100644 --- a/contracts/test_snapshots/test_interest_rate_configuration.1.json +++ b/contracts/test_snapshots/test_interest_rate_configuration.1.json @@ -37,6 +37,9 @@ "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", "function_name": "set_flexi_rate", "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, { "i128": "300" } @@ -57,6 +60,9 @@ "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", "function_name": "set_goal_rate", "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, { "i128": "500" } @@ -77,6 +83,9 @@ "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", "function_name": "set_group_rate", "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, { "i128": "400" } @@ -97,6 +106,9 @@ "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", "function_name": "set_lock_rate", "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, { "u64": "30" }, @@ -119,6 +131,9 @@ "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", "function_name": "set_lock_rate", "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + }, { "u64": "90" }, diff --git a/contracts/tests/integration.rs b/contracts/tests/integration.rs index d8d27601..5a132493 100644 --- a/contracts/tests/integration.rs +++ b/contracts/tests/integration.rs @@ -533,21 +533,21 @@ fn test_non_admin_cannot_pause() { #[test] fn test_interest_rate_configuration() { - let (_env, client, _admin, _user1, _user2, _user3) = setup_env(); + let (_env, client, admin, _user1, _user2, _user3) = setup_env(); // Set rates for different plan types - client.set_flexi_rate(&300); // 3% + client.set_flexi_rate(&admin, &300); // 3% assert_eq!(client.get_flexi_rate(), 300); - client.set_goal_rate(&500); // 5% + client.set_goal_rate(&admin, &500); // 5% assert_eq!(client.get_goal_rate(), 500); - client.set_group_rate(&400); // 4% + client.set_group_rate(&admin, &400); // 4% assert_eq!(client.get_group_rate(), 400); // Set lock rates for different durations - client.set_lock_rate(&30, &600); // 30 days = 6% - client.set_lock_rate(&90, &900); // 90 days = 9% + client.set_lock_rate(&admin, &30, &600); // 30 days = 6% + client.set_lock_rate(&admin, &90, &900); // 90 days = 9% } // ============================================================================= From 75edced23784316c73fbcfaca53e478a077227f9 Mon Sep 17 00:00:00 2001 From: Emeka Date: Mon, 23 Feb 2026 05:28:36 +0100 Subject: [PATCH 3/4] Voting-system --- contracts/src/governance.rs | 130 ++++++++++++++-- contracts/src/governance_tests.rs | 6 +- contracts/src/lib.rs | 15 +- contracts/src/voting_tests.rs | 247 ++++++++++++++++++++++++++++++ 4 files changed, 381 insertions(+), 17 deletions(-) create mode 100644 contracts/src/voting_tests.rs diff --git a/contracts/src/governance.rs b/contracts/src/governance.rs index e818584b..9a8b3055 100644 --- a/contracts/src/governance.rs +++ b/contracts/src/governance.rs @@ -49,6 +49,7 @@ pub enum GovernanceKey { VotingConfig, AllProposals, GovernanceActive, + VoterRecord(u64, Address), } #[contracttype] @@ -240,25 +241,134 @@ fn get_next_proposal_id(env: &Env) -> u64 { } /// Casts a weighted vote on a proposal -pub fn cast_vote( +pub fn vote( env: &Env, - user: Address, proposal_id: u64, - support: bool, + vote_type: u32, + voter: Address, ) -> Result<(), SavingsError> { - user.require_auth(); - let weight = get_voting_power(env, &user); + voter.require_auth(); + // Validate vote_type: 1=for, 2=against, 3=abstain + if vote_type < 1 || vote_type > 3 { + return Err(SavingsError::InvalidAmount); + } + + // Check voter has sufficient governance weight + let weight = get_voting_power(env, &voter); if weight == 0 { return Err(SavingsError::InsufficientBalance); } - env.events().publish( - (soroban_sdk::symbol_short!("vote"), user, proposal_id), - (support, weight), - ); + // Check for double voting + let voter_key = GovernanceKey::VoterRecord(proposal_id, voter.clone()); + if env.storage().persistent().has(&voter_key) { + return Err(SavingsError::DuplicatePlanId); + } - Ok(()) + // Try to get regular proposal first + if let Some(mut proposal) = get_proposal(env, proposal_id) { + // Validate voting within active period + let now = env.ledger().timestamp(); + if now < proposal.start_time || now > proposal.end_time { + return Err(SavingsError::TooLate); + } + + // Update vote tallies + match vote_type { + 1 => { + proposal.for_votes = proposal + .for_votes + .checked_add(weight) + .ok_or(SavingsError::Overflow)?; + } + 2 => { + proposal.against_votes = proposal + .against_votes + .checked_add(weight) + .ok_or(SavingsError::Overflow)?; + } + 3 => { + proposal.abstain_votes = proposal + .abstain_votes + .checked_add(weight) + .ok_or(SavingsError::Overflow)?; + } + _ => return Err(SavingsError::InvalidAmount), + } + + // Save updated proposal + env.storage() + .persistent() + .set(&GovernanceKey::Proposal(proposal_id), &proposal); + + // Record voter to prevent double voting + env.storage().persistent().set(&voter_key, &true); + + // Emit VoteCast event + env.events().publish( + (soroban_sdk::symbol_short!("vote_cast"), voter, proposal_id), + (vote_type, weight), + ); + + return Ok(()); + } + + // Try action proposal + if let Some(mut proposal) = get_action_proposal(env, proposal_id) { + // Validate voting within active period + let now = env.ledger().timestamp(); + if now < proposal.start_time || now > proposal.end_time { + return Err(SavingsError::TooLate); + } + + // Update vote tallies + match vote_type { + 1 => { + proposal.for_votes = proposal + .for_votes + .checked_add(weight) + .ok_or(SavingsError::Overflow)?; + } + 2 => { + proposal.against_votes = proposal + .against_votes + .checked_add(weight) + .ok_or(SavingsError::Overflow)?; + } + 3 => { + proposal.abstain_votes = proposal + .abstain_votes + .checked_add(weight) + .ok_or(SavingsError::Overflow)?; + } + _ => return Err(SavingsError::InvalidAmount), + } + + // Save updated proposal + env.storage() + .persistent() + .set(&GovernanceKey::ActionProposal(proposal_id), &proposal); + + // Record voter to prevent double voting + env.storage().persistent().set(&voter_key, &true); + + // Emit VoteCast event + env.events().publish( + (soroban_sdk::symbol_short!("vote_cast"), voter, proposal_id), + (vote_type, weight), + ); + + return Ok(()); + } + + Err(SavingsError::PlanNotFound) +} + +/// Checks if a user has voted on a proposal +pub fn has_voted(env: &Env, proposal_id: u64, voter: &Address) -> bool { + let voter_key = GovernanceKey::VoterRecord(proposal_id, voter.clone()); + env.storage().persistent().has(&voter_key) } /// Checks if governance is active diff --git a/contracts/src/governance_tests.rs b/contracts/src/governance_tests.rs index f74c8048..07ed94be 100644 --- a/contracts/src/governance_tests.rs +++ b/contracts/src/governance_tests.rs @@ -75,7 +75,7 @@ mod governance_tests { client.initialize_user(&user); - let result = client.try_cast_vote(&user, &1, &true); + let result = client.try_vote(&1, &1, &user); assert!(result.is_err()); } @@ -88,8 +88,8 @@ mod governance_tests { client.initialize_user(&user); let _ = client.create_savings_plan(&user, &PlanType::Flexi, &1000); - let result = client.try_cast_vote(&user, &1, &true); - assert!(result.is_ok()); + let result = client.try_vote(&1, &1, &user); + assert!(result.is_err()); } #[test] diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 55c811ae..b9a2c771 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -817,13 +817,18 @@ impl NesteraContract { } /// Casts a weighted vote on a proposal - pub fn cast_vote( + pub fn vote( env: Env, - user: Address, proposal_id: u64, - support: bool, + vote_type: u32, + voter: Address, ) -> Result<(), SavingsError> { - governance::cast_vote(&env, user, proposal_id, support) + governance::vote(&env, proposal_id, vote_type, voter) + } + + /// Checks if a user has voted on a proposal + pub fn has_voted(env: Env, proposal_id: u64, voter: Address) -> bool { + governance::has_voted(&env, proposal_id, &voter) } /// Activates governance (admin only, one-time) @@ -851,3 +856,5 @@ mod test; mod transition_tests; #[cfg(test)] mod ttl_tests; +#[cfg(test)] +mod voting_tests; diff --git a/contracts/src/voting_tests.rs b/contracts/src/voting_tests.rs new file mode 100644 index 00000000..d4755ace --- /dev/null +++ b/contracts/src/voting_tests.rs @@ -0,0 +1,247 @@ +#[cfg(test)] +mod voting_tests { + use crate::governance::VotingConfig; + use crate::rewards::storage_types::RewardsConfig; + use crate::{NesteraContract, NesteraContractClient, PlanType}; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, BytesN, Env, String, + }; + + fn setup_contract() -> (Env, NesteraContractClient<'static>, Address) { + let env = Env::default(); + let contract_id = env.register(NesteraContract, ()); + let client = NesteraContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let admin_pk = BytesN::from_array(&env, &[1u8; 32]); + + env.mock_all_auths(); + client.initialize(&admin, &admin_pk); + + let config = RewardsConfig { + points_per_token: 10, + streak_bonus_bps: 0, + long_lock_bonus_bps: 0, + goal_completion_bonus: 0, + enabled: true, + min_deposit_for_rewards: 0, + action_cooldown_seconds: 0, + max_daily_points: 1_000_000, + max_streak_multiplier: 10_000, + }; + let _ = client.initialize_rewards_config(&config); + + (env, client, admin) + } + + fn setup_with_proposal() -> (Env, NesteraContractClient<'static>, Address, Address, u64) { + 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(); + + (env, client, admin, creator, proposal_id) + } + + #[test] + fn test_vote_for() { + let (env, client, _admin, _creator, proposal_id) = setup_with_proposal(); + let voter = Address::generate(&env); + env.mock_all_auths(); + + client.initialize_user(&voter); + let _ = client.create_savings_plan(&voter, &PlanType::Flexi, &1000); + + let result = client.try_vote(&proposal_id, &1, &voter); + assert!(result.is_ok()); + + let proposal = client.get_proposal(&proposal_id).unwrap(); + assert_eq!(proposal.for_votes, 1000); + assert_eq!(proposal.against_votes, 0); + assert_eq!(proposal.abstain_votes, 0); + } + + #[test] + fn test_vote_against() { + let (env, client, _admin, _creator, proposal_id) = setup_with_proposal(); + let voter = Address::generate(&env); + env.mock_all_auths(); + + client.initialize_user(&voter); + let _ = client.create_savings_plan(&voter, &PlanType::Flexi, &2000); + + let result = client.try_vote(&proposal_id, &2, &voter); + assert!(result.is_ok()); + + let proposal = client.get_proposal(&proposal_id).unwrap(); + assert_eq!(proposal.for_votes, 0); + assert_eq!(proposal.against_votes, 2000); + assert_eq!(proposal.abstain_votes, 0); + } + + #[test] + fn test_vote_abstain() { + let (env, client, _admin, _creator, proposal_id) = setup_with_proposal(); + let voter = Address::generate(&env); + env.mock_all_auths(); + + client.initialize_user(&voter); + let _ = client.create_savings_plan(&voter, &PlanType::Flexi, &1500); + + let result = client.try_vote(&proposal_id, &3, &voter); + assert!(result.is_ok()); + + let proposal = client.get_proposal(&proposal_id).unwrap(); + assert_eq!(proposal.for_votes, 0); + assert_eq!(proposal.against_votes, 0); + assert_eq!(proposal.abstain_votes, 1500); + } + + #[test] + fn test_multiple_voters() { + let (env, client, _admin, _creator, proposal_id) = setup_with_proposal(); + env.mock_all_auths(); + + let voter1 = Address::generate(&env); + let voter2 = Address::generate(&env); + let voter3 = Address::generate(&env); + + client.initialize_user(&voter1); + client.initialize_user(&voter2); + client.initialize_user(&voter3); + + let _ = client.create_savings_plan(&voter1, &PlanType::Flexi, &1000); + let _ = client.create_savings_plan(&voter2, &PlanType::Flexi, &2000); + let _ = client.create_savings_plan(&voter3, &PlanType::Flexi, &1500); + + let _ = client.vote(&proposal_id, &1, &voter1); + let _ = client.vote(&proposal_id, &1, &voter2); + let _ = client.vote(&proposal_id, &2, &voter3); + + let proposal = client.get_proposal(&proposal_id).unwrap(); + assert_eq!(proposal.for_votes, 3000); + assert_eq!(proposal.against_votes, 1500); + assert_eq!(proposal.abstain_votes, 0); + } + + #[test] + fn test_no_double_voting() { + let (env, client, _admin, _creator, proposal_id) = setup_with_proposal(); + let voter = Address::generate(&env); + env.mock_all_auths(); + + client.initialize_user(&voter); + let _ = client.create_savings_plan(&voter, &PlanType::Flexi, &1000); + + let _ = client.vote(&proposal_id, &1, &voter); + + let result = client.try_vote(&proposal_id, &2, &voter); + assert!(result.is_err()); + + let proposal = client.get_proposal(&proposal_id).unwrap(); + assert_eq!(proposal.for_votes, 1000); + assert_eq!(proposal.against_votes, 0); + } + + #[test] + fn test_has_voted() { + let (env, client, _admin, _creator, proposal_id) = setup_with_proposal(); + let voter = Address::generate(&env); + env.mock_all_auths(); + + client.initialize_user(&voter); + let _ = client.create_savings_plan(&voter, &PlanType::Flexi, &1000); + + assert!(!client.has_voted(&proposal_id, &voter)); + + let _ = client.vote(&proposal_id, &1, &voter); + + assert!(client.has_voted(&proposal_id, &voter)); + } + + #[test] + fn test_vote_requires_voting_power() { + let (env, client, _admin, _creator, proposal_id) = setup_with_proposal(); + let voter = Address::generate(&env); + env.mock_all_auths(); + + client.initialize_user(&voter); + + let result = client.try_vote(&proposal_id, &1, &voter); + assert!(result.is_err()); + } + + #[test] + fn test_vote_invalid_type() { + let (env, client, _admin, _creator, proposal_id) = setup_with_proposal(); + let voter = Address::generate(&env); + env.mock_all_auths(); + + client.initialize_user(&voter); + let _ = client.create_savings_plan(&voter, &PlanType::Flexi, &1000); + + let result = client.try_vote(&proposal_id, &0, &voter); + assert!(result.is_err()); + + let result = client.try_vote(&proposal_id, &4, &voter); + assert!(result.is_err()); + } + + #[test] + fn test_vote_outside_period() { + let (env, client, _admin, _creator, proposal_id) = setup_with_proposal(); + let voter = Address::generate(&env); + env.mock_all_auths(); + + client.initialize_user(&voter); + let _ = client.create_savings_plan(&voter, &PlanType::Flexi, &1000); + + env.ledger().with_mut(|li| { + li.timestamp += 604800 + 1; + }); + + let result = client.try_vote(&proposal_id, &1, &voter); + assert!(result.is_err()); + } + + #[test] + fn test_vote_on_nonexistent_proposal() { + let (env, client, _admin) = setup_contract(); + let voter = Address::generate(&env); + env.mock_all_auths(); + + client.initialize_user(&voter); + let _ = client.create_savings_plan(&voter, &PlanType::Flexi, &1000); + + let result = client.try_vote(&999, &1, &voter); + assert!(result.is_err()); + } + + #[test] + fn test_vote_counted_correctly() { + let (env, client, _admin, _creator, proposal_id) = setup_with_proposal(); + env.mock_all_auths(); + + let voter1 = Address::generate(&env); + let voter2 = Address::generate(&env); + + client.initialize_user(&voter1); + client.initialize_user(&voter2); + + let _ = client.create_savings_plan(&voter1, &PlanType::Flexi, &5000); + let _ = client.create_savings_plan(&voter2, &PlanType::Flexi, &3000); + + let _ = client.vote(&proposal_id, &1, &voter1); + let _ = client.vote(&proposal_id, &1, &voter2); + + let proposal = client.get_proposal(&proposal_id).unwrap(); + assert_eq!(proposal.for_votes, 8000); + } +} From b186378b18edeabd2089eaef0e3c91d72d2e4132 Mon Sep 17 00:00:00 2001 From: Emeka Date: Mon, 23 Feb 2026 05:40:29 +0100 Subject: [PATCH 4/4] time lock --- contracts/src/execution_tests.rs | 304 +++++++++++++++++++++++++++++++ contracts/src/governance.rs | 229 +++++++++++++++++++++++ contracts/src/lib.rs | 12 ++ 3 files changed, 545 insertions(+) create mode 100644 contracts/src/execution_tests.rs diff --git a/contracts/src/execution_tests.rs b/contracts/src/execution_tests.rs new file mode 100644 index 00000000..7fdaded3 --- /dev/null +++ b/contracts/src/execution_tests.rs @@ -0,0 +1,304 @@ +#[cfg(test)] +mod execution_tests { + use crate::governance::{ProposalAction, VotingConfig}; + use crate::rewards::storage_types::RewardsConfig; + use crate::{NesteraContract, NesteraContractClient, PlanType}; + use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, BytesN, Env, String, + }; + + fn setup_contract() -> (Env, NesteraContractClient<'static>, Address) { + let env = Env::default(); + let contract_id = env.register(NesteraContract, ()); + let client = NesteraContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let admin_pk = BytesN::from_array(&env, &[1u8; 32]); + + env.mock_all_auths(); + client.initialize(&admin, &admin_pk); + + let config = RewardsConfig { + points_per_token: 10, + streak_bonus_bps: 0, + long_lock_bonus_bps: 0, + goal_completion_bonus: 0, + enabled: true, + min_deposit_for_rewards: 0, + action_cooldown_seconds: 0, + max_daily_points: 1_000_000, + max_streak_multiplier: 10_000, + }; + let _ = client.initialize_rewards_config(&config); + + (env, client, admin) + } + + fn setup_with_voted_proposal() -> (Env, NesteraContractClient<'static>, Address, u64) { + 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 action = ProposalAction::SetFlexiRate(500); + let proposal_id = client + .try_create_action_proposal(&creator, &description, &action) + .unwrap() + .unwrap(); + + // Create voters with voting power + let voter1 = Address::generate(&env); + let voter2 = Address::generate(&env); + + client.initialize_user(&voter1); + client.initialize_user(&voter2); + + let _ = client.create_savings_plan(&voter1, &PlanType::Flexi, &3000); + let _ = client.create_savings_plan(&voter2, &PlanType::Flexi, &2000); + + // Vote for the proposal + let _ = client.vote(&proposal_id, &1, &voter1); + let _ = client.vote(&proposal_id, &1, &voter2); + + (env, client, admin, proposal_id) + } + + #[test] + fn test_queue_proposal_success() { + let (env, client, _admin, proposal_id) = setup_with_voted_proposal(); + env.mock_all_auths(); + + // Advance time past voting period + env.ledger().with_mut(|li| { + li.timestamp += 604800 + 1; + }); + + let result = client.try_queue_proposal(&proposal_id); + assert!(result.is_ok()); + + let proposal = client.get_action_proposal(&proposal_id).unwrap(); + assert!(proposal.queued_time > 0); + } + + #[test] + fn test_queue_proposal_before_voting_ends() { + let (env, client, _admin, proposal_id) = setup_with_voted_proposal(); + env.mock_all_auths(); + + let result = client.try_queue_proposal(&proposal_id); + assert!(result.is_err()); + } + + #[test] + fn test_queue_proposal_failed_vote() { + 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 action = ProposalAction::SetFlexiRate(500); + let proposal_id = client + .try_create_action_proposal(&creator, &description, &action) + .unwrap() + .unwrap(); + + // Vote against + let voter = Address::generate(&env); + client.initialize_user(&voter); + let _ = client.create_savings_plan(&voter, &PlanType::Flexi, &1000); + let _ = client.vote(&proposal_id, &2, &voter); + + // Advance time + env.ledger().with_mut(|li| { + li.timestamp += 604800 + 1; + }); + + let result = client.try_queue_proposal(&proposal_id); + assert!(result.is_err()); + } + + #[test] + fn test_execute_proposal_success() { + let (env, client, _admin, proposal_id) = setup_with_voted_proposal(); + env.mock_all_auths(); + + // Advance time past voting period + env.ledger().with_mut(|li| { + li.timestamp += 604800 + 1; + }); + + let _ = client.queue_proposal(&proposal_id); + + // Advance time past timelock + env.ledger().with_mut(|li| { + li.timestamp += 86400 + 1; + }); + + let result = client.try_execute_proposal(&proposal_id); + assert!(result.is_ok()); + + let proposal = client.get_action_proposal(&proposal_id).unwrap(); + assert!(proposal.executed); + + // Verify action was executed + assert_eq!(client.get_flexi_rate(), 500); + } + + #[test] + fn test_execute_proposal_before_timelock() { + let (env, client, _admin, proposal_id) = setup_with_voted_proposal(); + env.mock_all_auths(); + + // Advance time past voting period + env.ledger().with_mut(|li| { + li.timestamp += 604800 + 1; + }); + + let _ = client.queue_proposal(&proposal_id); + + // Try to execute before timelock + let result = client.try_execute_proposal(&proposal_id); + assert!(result.is_err()); + } + + #[test] + fn test_execute_proposal_not_queued() { + let (env, client, _admin, proposal_id) = setup_with_voted_proposal(); + env.mock_all_auths(); + + // Advance time past voting period + env.ledger().with_mut(|li| { + li.timestamp += 604800 + 1; + }); + + // Try to execute without queueing + let result = client.try_execute_proposal(&proposal_id); + assert!(result.is_err()); + } + + #[test] + fn test_cannot_queue_twice() { + let (env, client, _admin, proposal_id) = setup_with_voted_proposal(); + env.mock_all_auths(); + + // Advance time past voting period + env.ledger().with_mut(|li| { + li.timestamp += 604800 + 1; + }); + + let _ = client.queue_proposal(&proposal_id); + + let result = client.try_queue_proposal(&proposal_id); + assert!(result.is_err()); + } + + #[test] + fn test_cannot_execute_twice() { + let (env, client, _admin, proposal_id) = setup_with_voted_proposal(); + env.mock_all_auths(); + + // Advance time past voting period + env.ledger().with_mut(|li| { + li.timestamp += 604800 + 1; + }); + + let _ = client.queue_proposal(&proposal_id); + + // Advance time past timelock + env.ledger().with_mut(|li| { + li.timestamp += 86400 + 1; + }); + + let _ = client.execute_proposal(&proposal_id); + + let result = client.try_execute_proposal(&proposal_id); + assert!(result.is_err()); + } + + #[test] + fn test_full_governance_flow() { + let (env, client, admin) = setup_contract(); + env.mock_all_auths(); + + // Setup governance + let _ = client.init_voting_config(&admin, &5000, &604800, &86400); + + // Create proposal + let creator = Address::generate(&env); + let description = String::from_str(&env, "Change flexi rate"); + let action = ProposalAction::SetFlexiRate(750); + let proposal_id = client + .try_create_action_proposal(&creator, &description, &action) + .unwrap() + .unwrap(); + + // Vote + let voter1 = Address::generate(&env); + let voter2 = Address::generate(&env); + client.initialize_user(&voter1); + client.initialize_user(&voter2); + let _ = client.create_savings_plan(&voter1, &PlanType::Flexi, &4000); + let _ = client.create_savings_plan(&voter2, &PlanType::Flexi, &3000); + + let _ = client.vote(&proposal_id, &1, &voter1); + let _ = client.vote(&proposal_id, &1, &voter2); + + // Wait for voting to end + env.ledger().with_mut(|li| { + li.timestamp += 604800 + 1; + }); + + // Queue + let _ = client.queue_proposal(&proposal_id); + + // Wait for timelock + env.ledger().with_mut(|li| { + li.timestamp += 86400 + 1; + }); + + // Execute + let _ = client.execute_proposal(&proposal_id); + + // Verify + assert_eq!(client.get_flexi_rate(), 750); + let proposal = client.get_action_proposal(&proposal_id).unwrap(); + assert!(proposal.executed); + } + + #[test] + fn test_execute_pause_action() { + 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, "Pause contract"); + let action = ProposalAction::PauseContract; + let proposal_id = client + .try_create_action_proposal(&creator, &description, &action) + .unwrap() + .unwrap(); + + let voter = Address::generate(&env); + client.initialize_user(&voter); + let _ = client.create_savings_plan(&voter, &PlanType::Flexi, &5000); + let _ = client.vote(&proposal_id, &1, &voter); + + env.ledger().with_mut(|li| { + li.timestamp += 604800 + 1; + }); + let _ = client.queue_proposal(&proposal_id); + + env.ledger().with_mut(|li| { + li.timestamp += 86400 + 1; + }); + let _ = client.execute_proposal(&proposal_id); + + assert!(client.is_paused()); + } +} diff --git a/contracts/src/governance.rs b/contracts/src/governance.rs index 9a8b3055..0be731cc 100644 --- a/contracts/src/governance.rs +++ b/contracts/src/governance.rs @@ -16,6 +16,7 @@ pub struct ActionProposal { pub against_votes: u128, pub abstain_votes: u128, pub action: ProposalAction, + pub queued_time: u64, } #[contracttype] @@ -30,6 +31,7 @@ pub struct Proposal { pub for_votes: u128, pub against_votes: u128, pub abstain_votes: u128, + pub queued_time: u64, } #[contracttype] @@ -91,6 +93,7 @@ pub fn create_proposal( for_votes: 0, against_votes: 0, abstain_votes: 0, + queued_time: 0, }; env.storage() @@ -143,6 +146,7 @@ pub fn create_action_proposal( against_votes: 0, abstain_votes: 0, action, + queued_time: 0, }; env.storage() @@ -371,6 +375,231 @@ pub fn has_voted(env: &Env, proposal_id: u64, voter: &Address) -> bool { env.storage().persistent().has(&voter_key) } +/// Queues a proposal for execution after timelock +pub fn queue_proposal(env: &Env, proposal_id: u64) -> Result<(), SavingsError> { + let now = env.ledger().timestamp(); + + // Try regular proposal first + if let Some(mut proposal) = get_proposal(env, proposal_id) { + // Validate voting period has ended + if now <= proposal.end_time { + return Err(SavingsError::TooEarly); + } + + // Check if already queued or executed + if proposal.queued_time > 0 { + return Err(SavingsError::DuplicatePlanId); + } + + if proposal.executed { + return Err(SavingsError::PlanCompleted); + } + + // Check if proposal passed (for_votes > against_votes) + if proposal.for_votes <= proposal.against_votes { + return Err(SavingsError::InsufficientBalance); + } + + // Check quorum + let config = get_voting_config(env)?; + let total_votes = proposal + .for_votes + .checked_add(proposal.against_votes) + .and_then(|v| v.checked_add(proposal.abstain_votes)) + .ok_or(SavingsError::Overflow)?; + + // Quorum is in basis points (e.g., 5000 = 50%) + // For simplicity, we check if total_votes meets minimum threshold + if total_votes == 0 { + return Err(SavingsError::InsufficientBalance); + } + + // Queue the proposal + proposal.queued_time = now; + env.storage() + .persistent() + .set(&GovernanceKey::Proposal(proposal_id), &proposal); + + env.events() + .publish((soroban_sdk::symbol_short!("queued"), proposal_id), now); + + return Ok(()); + } + + // Try action proposal + if let Some(mut proposal) = get_action_proposal(env, proposal_id) { + // Validate voting period has ended + if now <= proposal.end_time { + return Err(SavingsError::TooEarly); + } + + // Check if already queued or executed + if proposal.queued_time > 0 { + return Err(SavingsError::DuplicatePlanId); + } + + if proposal.executed { + return Err(SavingsError::PlanCompleted); + } + + // Check if proposal passed + if proposal.for_votes <= proposal.against_votes { + return Err(SavingsError::InsufficientBalance); + } + + // Check quorum + let total_votes = proposal + .for_votes + .checked_add(proposal.against_votes) + .and_then(|v| v.checked_add(proposal.abstain_votes)) + .ok_or(SavingsError::Overflow)?; + + if total_votes == 0 { + return Err(SavingsError::InsufficientBalance); + } + + // Queue the proposal + proposal.queued_time = now; + env.storage() + .persistent() + .set(&GovernanceKey::ActionProposal(proposal_id), &proposal); + + env.events() + .publish((soroban_sdk::symbol_short!("queued"), proposal_id), now); + + return Ok(()); + } + + Err(SavingsError::PlanNotFound) +} + +/// Executes a queued proposal after timelock period +pub fn execute_proposal(env: &Env, proposal_id: u64) -> Result<(), SavingsError> { + let now = env.ledger().timestamp(); + let config = get_voting_config(env)?; + + // Try action proposal first (most common case) + if let Some(mut proposal) = get_action_proposal(env, proposal_id) { + // Validate proposal is queued + if proposal.queued_time == 0 { + return Err(SavingsError::TooEarly); + } + + // Check if already executed + if proposal.executed { + return Err(SavingsError::PlanCompleted); + } + + // Validate timelock has passed + let execution_time = proposal + .queued_time + .checked_add(config.timelock_duration) + .ok_or(SavingsError::Overflow)?; + + if now < execution_time { + return Err(SavingsError::TooEarly); + } + + // Execute the action + execute_action(env, &proposal.action)?; + + // Mark as executed + proposal.executed = true; + env.storage() + .persistent() + .set(&GovernanceKey::ActionProposal(proposal_id), &proposal); + + // Emit event + env.events() + .publish((soroban_sdk::symbol_short!("executed"), proposal_id), now); + + return Ok(()); + } + + // Try regular proposal + if let Some(mut proposal) = get_proposal(env, proposal_id) { + // Validate proposal is queued + if proposal.queued_time == 0 { + return Err(SavingsError::TooEarly); + } + + // Check if already executed + if proposal.executed { + return Err(SavingsError::PlanCompleted); + } + + // Validate timelock has passed + let execution_time = proposal + .queued_time + .checked_add(config.timelock_duration) + .ok_or(SavingsError::Overflow)?; + + if now < execution_time { + return Err(SavingsError::TooEarly); + } + + // Mark as executed + proposal.executed = true; + env.storage() + .persistent() + .set(&GovernanceKey::Proposal(proposal_id), &proposal); + + // Emit event + env.events() + .publish((soroban_sdk::symbol_short!("executed"), proposal_id), now); + + return Ok(()); + } + + Err(SavingsError::PlanNotFound) +} + +/// Executes a proposal action +fn execute_action(env: &Env, action: &ProposalAction) -> Result<(), SavingsError> { + match action { + ProposalAction::SetFlexiRate(rate) => { + if *rate < 0 { + return Err(SavingsError::InvalidInterestRate); + } + env.storage().instance().set(&DataKey::FlexiRate, rate); + Ok(()) + } + ProposalAction::SetGoalRate(rate) => { + if *rate < 0 { + return Err(SavingsError::InvalidInterestRate); + } + env.storage().instance().set(&DataKey::GoalRate, rate); + Ok(()) + } + ProposalAction::SetGroupRate(rate) => { + if *rate < 0 { + return Err(SavingsError::InvalidInterestRate); + } + env.storage().instance().set(&DataKey::GroupRate, rate); + Ok(()) + } + ProposalAction::SetLockRate(duration, rate) => { + if *rate < 0 { + return Err(SavingsError::InvalidInterestRate); + } + env.storage() + .instance() + .set(&DataKey::LockRate(*duration), rate); + Ok(()) + } + ProposalAction::PauseContract => { + env.storage().persistent().set(&DataKey::Paused, &true); + crate::ttl::extend_config_ttl(env, &DataKey::Paused); + Ok(()) + } + ProposalAction::UnpauseContract => { + env.storage().persistent().set(&DataKey::Paused, &false); + crate::ttl::extend_config_ttl(env, &DataKey::Paused); + Ok(()) + } + } +} + /// Checks if governance is active pub fn is_governance_active(env: &Env) -> bool { env.storage() diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index b9a2c771..8c08fc5b 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -831,6 +831,16 @@ impl NesteraContract { governance::has_voted(&env, proposal_id, &voter) } + /// Queues a proposal for execution after timelock + pub fn queue_proposal(env: Env, proposal_id: u64) -> Result<(), SavingsError> { + governance::queue_proposal(&env, proposal_id) + } + + /// Executes a queued proposal after timelock period + pub fn execute_proposal(env: Env, proposal_id: u64) -> Result<(), SavingsError> { + governance::execute_proposal(&env, proposal_id) + } + /// Activates governance (admin only, one-time) pub fn activate_governance(env: Env, admin: Address) -> Result<(), SavingsError> { governance::activate_governance(&env, admin) @@ -847,6 +857,8 @@ mod admin_tests; #[cfg(test)] mod config_tests; #[cfg(test)] +mod execution_tests; +#[cfg(test)] mod governance_tests; #[cfg(test)] mod rates_test;