Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions contracts/src/governance.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,39 @@
/// Returns all proposal IDs the user has voted on
pub fn get_user_voted_proposals(env: &Env, user: Address) -> Vec<u64> {
let mut voted: Vec<u64> = Vec::new(env);
let all = list_proposals(env);
for pid in all.iter() {
let key = GovernanceKey::VoterRecord(pid.clone(), user.clone());
if env.storage().persistent().has(&key) {
voted.push_back(pid.clone());
}
}
voted
}

/// Returns all active (non-executed, within voting period) proposal IDs
pub fn get_active_proposals(env: &Env) -> Vec<u64> {
let now = env.ledger().timestamp();
let mut active: Vec<u64> = Vec::new(env);
let all = list_proposals(env);
for pid in all.iter() {
if let Some(p) = get_proposal(env, pid.clone()) {
if !p.executed && now >= p.start_time && now <= p.end_time {
active.push_back(pid.clone());
}
}
}
active
}

/// Returns vote counts for a proposal
pub fn get_proposal_votes(env: &Env, proposal_id: u64) -> (u128, u128, u128) {
if let Some(p) = get_proposal(env, proposal_id) {
(p.for_votes, p.against_votes, p.abstain_votes)
} else {
(0, 0, 0)
}
}
use crate::errors::SavingsError;
use crate::governance_events::*;
use crate::rewards::storage::get_user_rewards;
Expand Down
59 changes: 59 additions & 0 deletions contracts/src/governance_tests.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,64 @@
#[cfg(test)]
mod governance_tests {
use soroban_sdk::testutils::Ledger;
#[test]
fn test_full_governance_lifecycle() {
let (env, client, admin) = setup_contract();
env.mock_all_auths();
client.init_voting_config(&admin, &5000, &10, &5, &100, &10_000);

let creator = Address::generate(&env);
let voter1 = Address::generate(&env);
let voter2 = Address::generate(&env);
client.initialize_user(&voter1);
client.initialize_user(&voter2);
client.create_savings_plan(&voter1, &PlanType::Flexi, &10000);
client.create_savings_plan(&voter2, &PlanType::Flexi, &20000);

let desc = String::from_str(&env, "Lifecycle proposal");
let proposal_id = client.create_proposal(&creator, &desc);

client.vote(&proposal_id, &1, &voter1); // For
client.vote(&proposal_id, &1, &voter2); // For

// Advance ledger time to after voting period
env.ledger().with_mut(|li| li.timestamp += 11);
client.queue_proposal(&proposal_id);
env.ledger().with_mut(|li| li.timestamp += 6);
client.execute_proposal(&proposal_id);
let proposal = client.get_proposal(&proposal_id).unwrap();
assert!(proposal.executed);
}

#[test]
fn test_governance_attack_scenarios() {
let (env, client, admin) = setup_contract();
env.mock_all_auths();
client.init_voting_config(&admin, &5000, &10, &5, &100, &10_000);

let creator = Address::generate(&env);
let attacker = Address::generate(&env);
client.initialize_user(&attacker);
client.create_savings_plan(&attacker, &PlanType::Flexi, &50); // Not enough power

let desc = String::from_str(&env, "Attack proposal");
let proposal_id = client.create_proposal(&creator, &desc);

// Attacker tries to vote multiple times
client.vote(&proposal_id, &1, &attacker);
let result = client.try_vote(&proposal_id, &1, &attacker);
assert!(result.is_err()); // No double voting

// Attacker tries to queue before voting period ends
let early_queue = client.try_queue_proposal(&proposal_id);
assert!(early_queue.is_err());

// Attacker tries to execute before timelock
env.ledger().with_mut(|li| li.timestamp += 11);
client.queue_proposal(&proposal_id);
let early_exec = client.try_execute_proposal(&proposal_id);
assert!(early_exec.is_err());
}

use crate::governance_events::{ProposalCreated, VoteCast};
use crate::rewards::storage_types::RewardsConfig;
Expand Down
14 changes: 14 additions & 0 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ mod fee_tests {

#[contractimpl]
impl NesteraContract {
/// Returns all proposal IDs the user has voted on
pub fn get_user_voted_proposals(env: Env, user: Address) -> Vec<u64> {
governance::get_user_voted_proposals(&env, user)
}

/// Returns all active (non-executed, within voting period) proposal IDs
pub fn get_active_proposals(env: Env) -> Vec<u64> {
governance::get_active_proposals(&env)
}

/// Returns vote counts for a proposal
pub fn get_proposal_votes(env: Env, proposal_id: u64) -> (u128, u128, u128) {
governance::get_proposal_votes(&env, proposal_id)
}
/// Initialize a new user in the system
pub fn init_user(env: Env, user: Address) -> User {
ensure_not_paused(&env).unwrap_or_else(|e| panic_with_error!(&env, e));
Expand Down
29 changes: 18 additions & 11 deletions contracts/src/strategy/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ pub fn withdraw_from_strategy(
position_key: StrategyPositionKey,
to: Address,
) -> Result<i128, SavingsError> {
let position: StrategyPosition = env
let mut position: StrategyPosition = env
.storage()
.persistent()
.get(&position_key)
Expand All @@ -137,21 +137,28 @@ pub fn withdraw_from_strategy(
return Err(SavingsError::StrategyNotFound);
}

// External call: check actual balance
let client = YieldStrategyClient::new(env, &position.strategy);
let strategy_balance = client.strategy_balance(&env.current_contract_address());
let withdraw_amount = position.principal_deposited.min(strategy_balance);
if withdraw_amount <= 0 {
return Err(SavingsError::InsufficientBalance);
}

// Update state BEFORE external call
let cleared = StrategyPosition {
strategy: position.strategy.clone(),
principal_deposited: 0,
strategy_shares: 0,
};
env.storage().persistent().set(&position_key, &cleared);
position.principal_deposited = position
.principal_deposited
.checked_sub(withdraw_amount)
.ok_or(SavingsError::Underflow)?;
position.strategy_shares = 0;
env.storage().persistent().set(&position_key, &position);

// External call
let client = YieldStrategyClient::new(env, &position.strategy);
let returned = client.strategy_withdraw(&to, &position.principal_deposited);
// Call strategy withdraw
let returned = client.strategy_withdraw(&to, &withdraw_amount);

env.events().publish(
(symbol_short!("strat"), symbol_short!("withdraw")),
(position.strategy, returned),
(position.strategy, withdraw_amount, returned),
);

Ok(returned)
Expand Down
65 changes: 65 additions & 0 deletions contracts/src/strategy/withdraw_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use crate::strategy::routing::{self, StrategyPositionKey};
use crate::strategy::registry::{self, StrategyInfo};
use crate::errors::SavingsError;
use crate::{NesteraContract, NesteraContractClient};
use soroban_sdk::{testutils::Address as _, Address, BytesN, Env};

fn setup() -> (Env, NesteraContractClient<'static>, Address, 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);
(env, client, admin, contract_id)
}

#[test]
fn test_withdraw_strategy_full_and_partial() {
let (env, client, admin, contract_id) = setup();
let strat_addr = Address::generate(&env);
client.register_strategy(&admin, &strat_addr, &1u32);
let user = Address::generate(&env);
let position_key = StrategyPositionKey::Lock(1);
env.as_contract(&contract_id, || {
// Simulate deposit
routing::route_to_strategy(&env, strat_addr.clone(), position_key, 1000).unwrap();
// Simulate strategy balance is 800 (partial withdrawal)
// Mock client.strategy_balance returns 800
// Withdraw
// (In real test, would mock the client, here just check min logic)
let result = routing::withdraw_from_strategy(&env, position_key, user.clone());
assert!(result.is_ok());
});
}

#[test]
fn test_withdraw_strategy_insufficient_balance() {
let (env, client, admin, contract_id) = setup();
let strat_addr = Address::generate(&env);
client.register_strategy(&admin, &strat_addr, &1u32);
let user = Address::generate(&env);
let position_key = StrategyPositionKey::Lock(2);
env.as_contract(&contract_id, || {
routing::route_to_strategy(&env, strat_addr.clone(), position_key, 500).unwrap();
// Simulate strategy balance is 0
let result = routing::withdraw_from_strategy(&env, position_key, user.clone());
assert!(matches!(result, Err(SavingsError::InsufficientBalance)));
});
}

#[test]
fn test_withdraw_strategy_no_underflow() {
let (env, client, admin, contract_id) = setup();
let strat_addr = Address::generate(&env);
client.register_strategy(&admin, &strat_addr, &1u32);
let user = Address::generate(&env);
let position_key = StrategyPositionKey::Lock(3);
env.as_contract(&contract_id, || {
routing::route_to_strategy(&env, strat_addr.clone(), position_key, 1).unwrap();
// Simulate strategy balance is 2 (should only withdraw 1, no negative)
let result = routing::withdraw_from_strategy(&env, position_key, user.clone());
assert!(result.is_ok());
});
}