From 8e9ba8d95cec4e7a21867d5d019edd78fc9de0d3 Mon Sep 17 00:00:00 2001 From: Emeka Date: Mon, 23 Feb 2026 04:05:12 +0100 Subject: [PATCH 1/2] 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/2] 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% } // =============================================================================