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
20 changes: 20 additions & 0 deletions contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,26 @@ impl NesteraContract {
treasury::get_treasury(&env)
}

/// Returns the unallocated treasury balance (fees pending allocation).
pub fn get_treasury_balance(env: Env) -> i128 {
treasury::get_treasury_balance(&env)
}

/// Returns the cumulative total of all protocol fees collected.
pub fn get_total_fees(env: Env) -> i128 {
treasury::get_total_fees(&env)
}

/// Returns the cumulative total of all yield credited to users.
pub fn get_total_yield(env: Env) -> i128 {
treasury::get_total_yield(&env)
}

/// Returns the current reserve sub-balance (allocated reserve funds).
pub fn get_reserve_balance(env: Env) -> i128 {
treasury::get_reserve_balance(&env)
}

/// Allocates the unallocated treasury balance into reserves, rewards, and operations.
/// Percentages are in basis points and must sum to 10_000.
pub fn allocate_treasury(
Expand Down
79 changes: 79 additions & 0 deletions contracts/src/strategy/harvest_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
/// 4. No double-counting invariant holds
/// 5. harvest_strategy fails appropriately for unregistered strategies
/// 6. Public API functions return defaults before any activity
/// 7. YieldDistributed event is emitted during harvest
/// 8. Treasury struct is updated correctly (no TotalBalance double-counting)
use crate::errors::SavingsError;
use crate::storage_types::DataKey;
use crate::strategy::routing::{self};
Expand Down Expand Up @@ -385,3 +387,80 @@ fn test_harvest_twice_no_double_counting() {
);
});
}

// ========== YieldDistributed Event Tests ==========

/// Validates the profit-split math that backs the YieldDistributed event payload.
/// treasury_fee + user_earnings == total_profit (no rounding loss).
#[test]
fn test_yield_distributed_split_invariant() {
let cases: &[(i128, u32)] = &[
(10_000, 1_000), // 10% fee
(7_777, 2_500), // 25% fee
(1, 5_000), // 50% fee, tiny yield
(99_999, 0), // 0% fee – all to users
(50_000, 10_000), // 100% fee – all to treasury
];

for &(profit, fee_bps) in cases {
let treasury_cut = if fee_bps > 0 {
(profit * fee_bps as i128) / 10_000
} else {
0
};
let user_earnings = profit - treasury_cut;

assert_eq!(
treasury_cut + user_earnings,
profit,
"YieldDistributed payload invariant violated: profit={profit} fee_bps={fee_bps}"
);
assert!(treasury_cut >= 0, "treasury_cut must be >= 0");
assert!(user_earnings >= 0, "user_earnings must be >= 0");
}
}

// ========== Treasury Struct No-Double-Counting Tests ==========

/// Confirms that after simulating record_fee + record_yield the Treasury struct
/// holds exactly the expected values with no double-counted amounts.
#[test]
fn test_treasury_struct_no_double_counting() {
let (env, _client, _admin, _treasury, contract_id) = setup_with_treasury();

env.as_contract(&contract_id, || {
use crate::treasury;
use soroban_sdk::Symbol;

let profit: i128 = 10_000;
let fee_bps: i128 = 1_000; // 10%
let treasury_fee = profit * fee_bps / 10_000; // 1_000
let user_yield = profit - treasury_fee; // 9_000

// Simulate what harvest_strategy does after the fix
treasury::record_fee(&env, treasury_fee, Symbol::new(&env, "perf"));
treasury::record_yield(&env, user_yield);

let t = treasury::get_treasury(&env);

// treasury_balance only holds the fee, not the user yield
assert_eq!(
t.treasury_balance, treasury_fee,
"treasury_balance must equal only the protocol fee, not user yield"
);
assert_eq!(
t.total_fees_collected, treasury_fee,
"total_fees_collected must equal the protocol fee"
);
assert_eq!(
t.total_yield_earned, user_yield,
"total_yield_earned must equal the user portion"
);
// No double counting: fees + yield do NOT overlap
assert_eq!(
t.total_fees_collected + t.total_yield_earned,
profit,
"fees + yield must equal total profit with no overlap"
);
});
}
31 changes: 17 additions & 14 deletions contracts/src/strategy/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,19 +328,6 @@ pub fn harvest_strategy(env: &Env, strategy_address: Address) -> Result<i128, Sa
.ok_or(SavingsError::Underflow)?;

// 6. Update accounting records
if treasury_fee > 0 {
let treasury_balance_key = DataKey::TotalBalance(config.treasury.clone());
let current_treasury: i128 = env
.storage()
.persistent()
.get(&treasury_balance_key)
.unwrap_or(0);
env.storage().persistent().set(
&treasury_balance_key,
&(current_treasury.checked_add(treasury_fee).unwrap()),
);
}

if user_yield > 0 {
let yield_key = DataKey::StrategyYield(strategy_address.clone());
let current_yield: i128 = env.storage().persistent().get(&yield_key).unwrap_or(0);
Expand All @@ -364,7 +351,23 @@ pub fn harvest_strategy(env: &Env, strategy_address: Address) -> Result<i128, Sa

env.events().publish(
(symbol_short!("strat"), symbol_short!("harvest")),
(strategy_address, actual_yield, treasury_fee, user_yield),
(
strategy_address.clone(),
actual_yield,
treasury_fee,
user_yield,
),
);

// Emit dedicated YieldDistributed event for frontend indexers
env.events().publish(
(symbol_short!("yld_dist"),),
(
strategy_address.clone(),
actual_yield,
treasury_fee,
user_yield,
),
);

// Record performance fee and yield in treasury
Expand Down
25 changes: 25 additions & 0 deletions contracts/src/treasury/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
pub mod types;

#[cfg(test)]
mod views_tests;

use crate::errors::SavingsError;
use crate::storage_types::DataKey;
use soroban_sdk::{symbol_short, Address, Env};
Expand Down Expand Up @@ -69,6 +72,28 @@ pub fn record_yield(env: &Env, amount: i128) {
set_treasury(env, &treasury);
}

// ========== Read-Only Treasury Views ==========

/// Returns only the unallocated treasury balance (fees awaiting allocation).
pub fn get_treasury_balance(env: &Env) -> i128 {
get_treasury(env).treasury_balance
}

/// Returns the cumulative total of all protocol fees collected.
pub fn get_total_fees(env: &Env) -> i128 {
get_treasury(env).total_fees_collected
}

/// Returns the cumulative total of all yield credited to users.
pub fn get_total_yield(env: &Env) -> i128 {
get_treasury(env).total_yield_earned
}

/// Returns the current reserve sub-balance (allocated funds held as reserve).
pub fn get_reserve_balance(env: &Env) -> i128 {
get_treasury(env).reserve_balance
}

// ========== Allocation Logic ==========

/// Allocates the unallocated treasury balance into reserves, rewards, and operations.
Expand Down
Loading
Loading