diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 3cbcb80b0..353f481af 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -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( diff --git a/contracts/src/strategy/harvest_tests.rs b/contracts/src/strategy/harvest_tests.rs index 6ad69dd79..344f38ccf 100644 --- a/contracts/src/strategy/harvest_tests.rs +++ b/contracts/src/strategy/harvest_tests.rs @@ -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}; @@ -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" + ); + }); +} diff --git a/contracts/src/strategy/routing.rs b/contracts/src/strategy/routing.rs index 27ab4f138..941dbd0c1 100644 --- a/contracts/src/strategy/routing.rs +++ b/contracts/src/strategy/routing.rs @@ -328,19 +328,6 @@ pub fn harvest_strategy(env: &Env, strategy_address: Address) -> Result 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); @@ -364,7 +351,23 @@ pub fn harvest_strategy(env: &Env, strategy_address: Address) -> Result 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. diff --git a/contracts/src/treasury/views_tests.rs b/contracts/src/treasury/views_tests.rs new file mode 100644 index 000000000..5ed7b2f1b --- /dev/null +++ b/contracts/src/treasury/views_tests.rs @@ -0,0 +1,243 @@ +/// Treasury Read-Only View Tests +/// +/// Validates: +/// 1. All view functions return 0 before any activity +/// 2. get_treasury_balance reflects only unallocated protocol fees +/// 3. get_total_fees accumulates across multiple fee recordings +/// 4. get_total_yield accumulates across multiple yield recordings +/// 5. get_reserve_balance reflects post-allocation reserve amounts +/// 6. No state mutation occurs when calling view functions +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 treasury_addr = Address::generate(&env); + let admin_pk = BytesN::from_array(&env, &[2u8; 32]); + + env.mock_all_auths(); + client.initialize(&admin, &admin_pk); + client.initialize_config(&admin, &treasury_addr, &500u32, &500u32, &1_000u32); + + (env, client, admin, treasury_addr) +} + +// ========== Zero State (Before Any Activity) ========== + +#[test] +fn test_views_return_zero_before_activity() { + let (_env, client, _admin, _treasury) = setup(); + + assert_eq!( + client.get_treasury_balance(), + 0, + "treasury_balance starts at 0" + ); + assert_eq!(client.get_total_fees(), 0, "total_fees starts at 0"); + assert_eq!(client.get_total_yield(), 0, "total_yield starts at 0"); + assert_eq!( + client.get_reserve_balance(), + 0, + "reserve_balance starts at 0" + ); +} + +// ========== get_treasury_balance ========== + +#[test] +fn test_get_treasury_balance_reflects_fees() { + let (env, client, _admin, treasury_addr) = setup(); + let contract_id = env.register(NesteraContract, ()); + + env.as_contract(&contract_id, || { + // Simulate fee recording (as happens during deposit/harvest) + crate::treasury::record_fee(&env, 2_000, soroban_sdk::Symbol::new(&env, "dep")); + }); + + // Use a fresh client pointed at the same contract + let client2 = NesteraContractClient::new(&env, &treasury_addr); + // Re-register so the contract is the one that received fees + let _ = treasury_addr; // suppress unused warning + + // Verify via the contract that was actually initialized + let _ = client; + // Direct storage check via as_contract on the original registered contract + let orig_id = env.register(NesteraContract, ()); + env.as_contract(&orig_id, || { + crate::treasury::record_fee(&env, 3_000, soroban_sdk::Symbol::new(&env, "dep")); + let bal = crate::treasury::get_treasury_balance(&env); + assert_eq!( + bal, 3_000, + "get_treasury_balance must reflect recorded fees" + ); + }); + let _ = client2; // suppress +} + +/// Tests via the public contract API using a single contract instance. +#[test] +fn test_get_treasury_balance_via_internal_storage() { + let (env, _client, _admin, _treasury) = setup(); + let contract_id = env.register(NesteraContract, ()); + let client = NesteraContractClient::new(&env, &contract_id); + let admin2 = Address::generate(&env); + let treasury2 = Address::generate(&env); + let pk2 = BytesN::from_array(&env, &[3u8; 32]); + + env.mock_all_auths(); + client.initialize(&admin2, &pk2); + client.initialize_config(&admin2, &treasury2, &500u32, &500u32, &1_000u32); + + env.as_contract(&contract_id, || { + crate::treasury::record_fee(&env, 5_000, soroban_sdk::Symbol::new(&env, "dep")); + }); + + assert_eq!( + client.get_treasury_balance(), + 5_000, + "get_treasury_balance returns unallocated fee balance" + ); +} + +// ========== get_total_fees ========== + +#[test] +fn test_get_total_fees_accumulates() { + let (env, _client, _admin, _treasury) = setup(); + let contract_id = env.register(NesteraContract, ()); + let client = NesteraContractClient::new(&env, &contract_id); + let admin2 = Address::generate(&env); + let treasury2 = Address::generate(&env); + let pk2 = BytesN::from_array(&env, &[4u8; 32]); + + env.mock_all_auths(); + client.initialize(&admin2, &pk2); + client.initialize_config(&admin2, &treasury2, &500u32, &500u32, &1_000u32); + + env.as_contract(&contract_id, || { + crate::treasury::record_fee(&env, 1_000, soroban_sdk::Symbol::new(&env, "dep")); + crate::treasury::record_fee(&env, 2_000, soroban_sdk::Symbol::new(&env, "wth")); + crate::treasury::record_fee(&env, 500, soroban_sdk::Symbol::new(&env, "perf")); + }); + + assert_eq!( + client.get_total_fees(), + 3_500, + "get_total_fees must sum all fee recordings" + ); +} + +// ========== get_total_yield ========== + +#[test] +fn test_get_total_yield_accumulates() { + let (env, _client, _admin, _treasury) = setup(); + let contract_id = env.register(NesteraContract, ()); + let client = NesteraContractClient::new(&env, &contract_id); + let admin2 = Address::generate(&env); + let treasury2 = Address::generate(&env); + let pk2 = BytesN::from_array(&env, &[5u8; 32]); + + env.mock_all_auths(); + client.initialize(&admin2, &pk2); + client.initialize_config(&admin2, &treasury2, &500u32, &500u32, &1_000u32); + + env.as_contract(&contract_id, || { + crate::treasury::record_yield(&env, 4_000); + crate::treasury::record_yield(&env, 6_000); + }); + + assert_eq!( + client.get_total_yield(), + 10_000, + "get_total_yield must sum all yield recordings" + ); +} + +// ========== get_reserve_balance ========== + +#[test] +fn test_get_reserve_balance_after_allocation() { + let (env, _client, _admin, _treasury) = setup(); + let contract_id = env.register(NesteraContract, ()); + let client = NesteraContractClient::new(&env, &contract_id); + let admin2 = Address::generate(&env); + let treasury2 = Address::generate(&env); + let pk2 = BytesN::from_array(&env, &[6u8; 32]); + + env.mock_all_auths(); + client.initialize(&admin2, &pk2); + client.initialize_config(&admin2, &treasury2, &500u32, &500u32, &1_000u32); + + // Seed treasury_balance so allocation has something to split + env.as_contract(&contract_id, || { + crate::treasury::record_fee(&env, 10_000, soroban_sdk::Symbol::new(&env, "dep")); + }); + + assert_eq!( + client.get_reserve_balance(), + 0, + "reserve starts at 0 before allocation" + ); + + // Allocate: 40% reserve, 40% rewards, 20% operations + client.allocate_treasury(&admin2, &4_000u32, &4_000u32, &2_000u32); + + assert_eq!( + client.get_reserve_balance(), + 4_000, + "get_reserve_balance must reflect 40% of 10_000 after allocation" + ); +} + +// ========== No State Mutation ========== + +#[test] +fn test_views_do_not_mutate_state() { + let (env, _client, _admin, _treasury) = setup(); + let contract_id = env.register(NesteraContract, ()); + let client = NesteraContractClient::new(&env, &contract_id); + let admin2 = Address::generate(&env); + let treasury2 = Address::generate(&env); + let pk2 = BytesN::from_array(&env, &[7u8; 32]); + + env.mock_all_auths(); + client.initialize(&admin2, &pk2); + client.initialize_config(&admin2, &treasury2, &500u32, &500u32, &1_000u32); + + env.as_contract(&contract_id, || { + crate::treasury::record_fee(&env, 8_000, soroban_sdk::Symbol::new(&env, "dep")); + }); + + // Call each view multiple times — state must not change + let bal1 = client.get_treasury_balance(); + let bal2 = client.get_treasury_balance(); + assert_eq!( + bal1, bal2, + "Repeated get_treasury_balance calls must be idempotent" + ); + + let fees1 = client.get_total_fees(); + let fees2 = client.get_total_fees(); + assert_eq!( + fees1, fees2, + "Repeated get_total_fees calls must be idempotent" + ); + + let yield1 = client.get_total_yield(); + let yield2 = client.get_total_yield(); + assert_eq!( + yield1, yield2, + "Repeated get_total_yield calls must be idempotent" + ); + + let res1 = client.get_reserve_balance(); + let res2 = client.get_reserve_balance(); + assert_eq!( + res1, res2, + "Repeated get_reserve_balance calls must be idempotent" + ); +} diff --git a/contracts/tests/strategy_integration_test.rs b/contracts/tests/strategy_integration_test.rs index 1873dfda7..348dd659e 100644 --- a/contracts/tests/strategy_integration_test.rs +++ b/contracts/tests/strategy_integration_test.rs @@ -133,11 +133,30 @@ fn test_strategy_full_lifecycle() { }); assert_eq!(current_yield, 900); - // Actually checking treasury balance via Nestera contract config functions: - // (Assuming Nestera handles treasury balance mapping natively if we're not using tokens) - // Wait, get_protocol_fee_balance exists on NesteraContract - let treasury_balances = client.get_protocol_fee_balance(&treasury); - assert_eq!(treasury_balances, 100); + // Verify treasury balance via the Treasury struct. + // treasury_balance accumulates ALL protocol fees: + // - 5,000 deposit fee (10% of 50,000 flexi deposit) + // - 100 performance fee (10% of 1,000 harvested yield) + // Total expected = 5,100 + let treasury_balances = client.get_treasury_balance(); + assert_eq!( + treasury_balances, 5_100, + "treasury_balance must include deposit + performance fees" + ); + + // Also verify the performance fee is correctly embedded in the total + let total_fees = client.get_total_fees(); + assert_eq!( + total_fees, 5_100, + "total_fees_collected must match treasury_balance before allocation" + ); + + // User yield: 900 (90% of 1,000 harvest) + let total_yield = client.get_total_yield(); + assert_eq!( + total_yield, 900, + "total_yield_earned must equal user portion of harvest" + ); // 6. Withdraw user funds (strategy position) let withdrawn_from_strat = client.withdraw_lock_strategy(&user1, &lock_id, &user1);