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
13 changes: 13 additions & 0 deletions contracts/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,16 @@ pub enum SavingsError {
///
/// Config initialization can only happen once.
ConfigAlreadyInitialized = 91,

// ========== Strategy Errors (92-99) ==========
/// Returned when a yield strategy is not found in the registry.
StrategyNotFound = 92,

/// Returned when attempting to register a strategy that already exists.
StrategyAlreadyRegistered = 93,

/// Returned when attempting to deposit into a disabled strategy.
StrategyDisabled = 94,
}

#[cfg(test)]
Expand Down Expand Up @@ -254,6 +264,9 @@ mod tests {
SavingsError::InvariantViolation as u32,
SavingsError::InvalidFeeBps as u32,
SavingsError::ConfigAlreadyInitialized as u32,
SavingsError::StrategyNotFound as u32,
SavingsError::StrategyAlreadyRegistered as u32,
SavingsError::StrategyDisabled as u32,
];

let mut sorted = errors.clone();
Expand Down
10 changes: 7 additions & 3 deletions contracts/src/governance_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ pub fn emit_proposal_created(env: &Env, proposal_id: u64, creator: Address, desc
description,
};

env.events().publish((symbol_short!("gov"), symbol_short!("created"), creator), event);
env.events().publish(
(symbol_short!("gov"), symbol_short!("created"), creator),
event,
);
}

pub fn emit_vote_cast(env: &Env, proposal_id: u64, voter: Address, vote_type: u32, weight: u128) {
Expand All @@ -56,7 +59,8 @@ pub fn emit_vote_cast(env: &Env, proposal_id: u64, voter: Address, vote_type: u3
weight,
};

env.events().publish((symbol_short!("gov"), symbol_short!("voted"), voter), event);
env.events()
.publish((symbol_short!("gov"), symbol_short!("voted"), voter), event);
}

pub fn emit_proposal_queued(env: &Env, proposal_id: u64, queued_at: u64) {
Expand Down Expand Up @@ -84,4 +88,4 @@ pub fn emit_proposal_canceled(env: &Env, proposal_id: u64, canceled_at: u64) {
};
env.events()
.publish((symbol_short!("gov"), symbol_short!("canceled")), event);
}
}
116 changes: 57 additions & 59 deletions contracts/src/governance_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
mod governance_tests {

use crate::governance_events::{ProposalCreated, VoteCast};
use soroban_sdk::symbol_short;
use soroban_sdk::IntoVal;
use crate::rewards::storage_types::RewardsConfig;
use crate::{NesteraContract, NesteraContractClient, PlanType};
use soroban_sdk::symbol_short;
use soroban_sdk::IntoVal;
use soroban_sdk::{
testutils::{Address as _, Events},
Address, BytesN, Env, String, Symbol,
Expand Down Expand Up @@ -171,75 +171,73 @@ mod governance_tests {
// NEW TESTS: Governance Event Logging
// ────────────────────────────────────────────────────────────────────────────────

#[test]
fn test_proposal_created_emits_event() {
let (env, client, admin) = setup_contract();
env.mock_all_auths();

client.init_voting_config(&admin, &5000, &604800, &86400, &100, &10_000);

let creator = Address::generate(&env);
let description = String::from_str(&env, "Test proposal description");

let proposal_id = client.create_proposal(&creator, &description);
#[test]
fn test_proposal_created_emits_event() {
let (env, client, admin) = setup_contract();
env.mock_all_auths();

let events = env.events().all();
client.init_voting_config(&admin, &5000, &604800, &86400, &100, &10_000);

let created_event_opt = events.iter().rev().find(|e| {
e.0 == client.address
&& e.1
== (
symbol_short!("gov"),
symbol_short!("created"),
creator.clone(),
)
.into_val(&env)
});
let creator = Address::generate(&env);
let description = String::from_str(&env, "Test proposal description");

assert!(created_event_opt.is_some(), "ProposalCreated event not emitted");
let event_data: ProposalCreated = created_event_opt.unwrap().2.clone().into_val(&env);
let proposal_id = client.create_proposal(&creator, &description);

assert_eq!(event_data.proposal_id, proposal_id);
assert_eq!(event_data.creator, creator);
assert_eq!(event_data.description, description);
}
let events = env.events().all();

let created_event_opt = events.iter().rev().find(|e| {
e.0 == client.address
&& e.1
== (
symbol_short!("gov"),
symbol_short!("created"),
creator.clone(),
)
.into_val(&env)
});

assert!(
created_event_opt.is_some(),
"ProposalCreated event not emitted"
);
let event_data: ProposalCreated = created_event_opt.unwrap().2.clone().into_val(&env);

assert_eq!(event_data.proposal_id, proposal_id);
assert_eq!(event_data.creator, creator);
assert_eq!(event_data.description, description);
}

#[test]
fn test_vote_cast_emits_event() {
let (env, client, admin) = setup_contract();
env.mock_all_auths();
#[test]
fn test_vote_cast_emits_event() {
let (env, client, admin) = setup_contract();
env.mock_all_auths();

client.init_voting_config(&admin, &5000, &604800, &86400, &100, &10_000);
client.init_voting_config(&admin, &5000, &604800, &86400, &100, &10_000);

let creator = Address::generate(&env);
let voter = Address::generate(&env);
let creator = Address::generate(&env);
let voter = Address::generate(&env);

client.initialize_user(&voter);
client.create_savings_plan(&voter, &PlanType::Flexi, &10000);
client.initialize_user(&voter);
client.create_savings_plan(&voter, &PlanType::Flexi, &10000);

let proposal_id = client.create_proposal(&creator, &String::from_str(&env, "Vote test"));
let proposal_id = client.create_proposal(&creator, &String::from_str(&env, "Vote test"));

client.vote(&proposal_id, &1, &voter);
client.vote(&proposal_id, &1, &voter);

let events = env.events().all();
let events = env.events().all();

let vote_event_opt = events.iter().rev().find(|e| {
e.0 == client.address
&& e.1
== (
symbol_short!("gov"),
symbol_short!("voted"),
voter.clone(),
)
.into_val(&env)
});
let vote_event_opt = events.iter().rev().find(|e| {
e.0 == client.address
&& e.1
== (symbol_short!("gov"), symbol_short!("voted"), voter.clone()).into_val(&env)
});

assert!(vote_event_opt.is_some(), "VoteCast event not emitted");
let event_data: VoteCast = vote_event_opt.unwrap().2.clone().into_val(&env);
assert!(vote_event_opt.is_some(), "VoteCast event not emitted");
let event_data: VoteCast = vote_event_opt.unwrap().2.clone().into_val(&env);

assert_eq!(event_data.proposal_id, proposal_id);
assert_eq!(event_data.voter, voter);
assert_eq!(event_data.vote_type, 1);
assert!(event_data.weight > 0);
}
assert_eq!(event_data.proposal_id, proposal_id);
assert_eq!(event_data.voter, voter);
assert_eq!(event_data.vote_type, 1);
assert!(event_data.weight > 0);
}
}
96 changes: 96 additions & 0 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ mod lock;

pub mod rewards;
mod storage_types;
pub mod strategy;
mod ttl;
mod upgrade;
mod users;
Expand All @@ -34,6 +35,8 @@ pub use crate::storage_types::{
AutoSave, DataKey, GoalSave, GoalSaveView, GroupSave, GroupSaveView, LockSave, LockSaveView,
MintPayload, PlanType, SavingsPlan, User,
};
pub use crate::strategy::registry::StrategyInfo;
pub use crate::strategy::routing::{StrategyPosition, StrategyPositionKey};

/// Custom error codes for the contract administration
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -855,6 +858,99 @@ impl NesteraContract {
pub fn is_governance_active(env: Env) -> bool {
governance::is_governance_active(&env)
}

// ========== Strategy Functions ==========

/// Registers a new yield strategy (admin/governance only).
pub fn register_strategy(
env: Env,
caller: Address,
strategy_address: Address,
risk_level: u32,
) -> Result<(), SavingsError> {
strategy::registry::register_strategy(&env, caller, strategy_address, risk_level)
}

/// Disables a registered yield strategy (admin/governance only).
pub fn disable_strategy(
env: Env,
caller: Address,
strategy_address: Address,
) -> Result<(), SavingsError> {
strategy::registry::disable_strategy(&env, caller, strategy_address)
}

/// Returns info about a registered strategy.
pub fn get_strategy(env: Env, strategy_address: Address) -> Result<StrategyInfo, SavingsError> {
strategy::registry::get_strategy(&env, strategy_address)
}

/// Returns all registered strategy addresses.
pub fn get_all_strategies(env: Env) -> Vec<Address> {
strategy::registry::get_all_strategies(&env)
}

/// Routes a LockSave deposit to a yield strategy.
pub fn route_lock_to_strategy(
env: Env,
caller: Address,
lock_id: u64,
strategy_address: Address,
amount: i128,
) -> Result<i128, SavingsError> {
caller.require_auth();
ensure_not_paused(&env)?;
let position_key = StrategyPositionKey::Lock(lock_id);
strategy::routing::route_to_strategy(&env, strategy_address, position_key, amount)
}

/// Routes a GroupSave pooled deposit to a yield strategy.
pub fn route_group_to_strategy(
env: Env,
caller: Address,
group_id: u64,
strategy_address: Address,
amount: i128,
) -> Result<i128, SavingsError> {
caller.require_auth();
ensure_not_paused(&env)?;
let position_key = StrategyPositionKey::Group(group_id);
strategy::routing::route_to_strategy(&env, strategy_address, position_key, amount)
}

/// Returns the strategy position for a lock plan.
pub fn get_lock_strategy_position(env: Env, lock_id: u64) -> Option<StrategyPosition> {
strategy::routing::get_position(&env, StrategyPositionKey::Lock(lock_id))
}

/// Returns the strategy position for a group plan.
pub fn get_group_strategy_position(env: Env, group_id: u64) -> Option<StrategyPosition> {
strategy::routing::get_position(&env, StrategyPositionKey::Group(group_id))
}

/// Withdraws funds from a lock's strategy position.
pub fn withdraw_lock_strategy(
env: Env,
caller: Address,
lock_id: u64,
to: Address,
) -> Result<i128, SavingsError> {
caller.require_auth();
ensure_not_paused(&env)?;
strategy::routing::withdraw_from_strategy(&env, StrategyPositionKey::Lock(lock_id), to)
}

/// Withdraws funds from a group's strategy position.
pub fn withdraw_group_strategy(
env: Env,
caller: Address,
group_id: u64,
to: Address,
) -> Result<i128, SavingsError> {
caller.require_auth();
ensure_not_paused(&env)?;
strategy::routing::withdraw_from_strategy(&env, StrategyPositionKey::Group(group_id), to)
}
}

#[cfg(test)]
Expand Down
64 changes: 64 additions & 0 deletions contracts/src/strategy/interface.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/// Yield Strategy Interface for Nestera Protocol
///
/// External strategy contracts must implement this interface to be compatible
/// with the Nestera savings protocol. Strategies are invoked via Soroban
/// cross-contract calls through the strategy registry.
///
/// # Security Assumptions
/// - Strategy contracts are audited and registered via governance/admin.
/// - All state updates in Nestera happen BEFORE external strategy calls (CEI pattern).
/// - Strategy contracts must not hold user funds beyond what is deposited via `deposit`.
/// - `get_total_balance` must reflect the actual deposited principal + any accrued yield.
/// - `withdraw` must return exactly the requested amount or revert.
/// - `harvest` collects accrued yield and returns the harvested amount.
use soroban_sdk::{contractclient, Address, Env};

/// Client interface for external yield strategy contracts.
///
/// Any contract registered as a yield strategy must expose these entry points.
/// The `contractclient` macro generates a `YieldStrategyClient` that can be used
/// for cross-contract invocation on Soroban.
#[contractclient(name = "YieldStrategyClient")]
pub trait YieldStrategy {
/// Deposits funds into the yield strategy.
///
/// # Arguments
/// * `env` - The contract environment
/// * `from` - The address depositing (the Nestera contract)
/// * `amount` - The amount of tokens to deposit (must be > 0)
///
/// # Returns
/// The number of strategy shares minted for this deposit.
fn strategy_deposit(env: Env, from: Address, amount: i128) -> i128;

/// Withdraws funds from the yield strategy.
///
/// # Arguments
/// * `env` - The contract environment
/// * `to` - The address to receive withdrawn funds
/// * `amount` - The amount of tokens to withdraw (must be > 0)
///
/// # Returns
/// The actual amount of tokens returned.
fn strategy_withdraw(env: Env, to: Address, amount: i128) -> i128;

/// Harvests accrued yield from the strategy.
///
/// # Arguments
/// * `env` - The contract environment
/// * `to` - The address to receive harvested yield
///
/// # Returns
/// The amount of yield harvested.
fn strategy_harvest(env: Env, to: Address) -> i128;

/// Returns the total balance held by this strategy for the caller.
///
/// # Arguments
/// * `env` - The contract environment
/// * `addr` - The address to query balance for
///
/// # Returns
/// The total balance (principal + accrued yield) denominated in the deposit token.
fn strategy_balance(env: Env, addr: Address) -> i128;
}
6 changes: 6 additions & 0 deletions contracts/src/strategy/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pub mod interface;
pub mod registry;
pub mod routing;

#[cfg(test)]
mod tests;
Loading