From 0811eda88141818725368dc00f5c660efedc4a5c Mon Sep 17 00:00:00 2001 From: folex1275 Date: Tue, 24 Mar 2026 09:20:08 +0100 Subject: [PATCH] feat: Treasury Storage, Fee Collection & Allocation Logic --- contracts/src/config.rs | 76 ++++++-- contracts/src/config_tests.rs | 74 ++++---- contracts/src/flexi.rs | 24 +-- contracts/src/goal.rs | 36 ++-- contracts/src/lib.rs | 65 ++++--- contracts/src/storage_types.rs | 18 +- contracts/src/strategy/harvest_tests.rs | 2 +- contracts/src/strategy/routing.rs | 14 +- contracts/src/treasury/mod.rs | 174 +++++++++++++++++++ contracts/src/treasury/types.rs | 51 ++++++ contracts/tests/strategy_integration_test.rs | 2 +- 11 files changed, 424 insertions(+), 112 deletions(-) create mode 100644 contracts/src/treasury/mod.rs create mode 100644 contracts/src/treasury/types.rs diff --git a/contracts/src/config.rs b/contracts/src/config.rs index fb1e89cf2..f702f9216 100644 --- a/contracts/src/config.rs +++ b/contracts/src/config.rs @@ -13,8 +13,10 @@ const MAX_FEE_BPS: u32 = 10_000; #[derive(Clone, Debug, Eq, PartialEq)] pub struct Config { pub admin: Address, - pub treasury: Address, - pub protocol_fee_bps: u32, + pub treasury: Address, // Treasury Address + pub deposit_fee_bps: u32, + pub withdrawal_fee_bps: u32, + pub performance_fee_bps: u32, pub paused: bool, } @@ -58,7 +60,9 @@ pub fn initialize_config( env: &Env, admin: Address, treasury: Address, - protocol_fee_bps: u32, + deposit_fee_bps: u32, + withdrawal_fee_bps: u32, + performance_fee_bps: u32, ) -> Result<(), SavingsError> { // Prevent re-initialization let already_init: bool = env @@ -74,21 +78,35 @@ pub fn initialize_config( require_admin(env, &admin)?; // Validate fee bounds - if protocol_fee_bps > MAX_FEE_BPS { + if deposit_fee_bps > MAX_FEE_BPS + || withdrawal_fee_bps > MAX_FEE_BPS + || performance_fee_bps > MAX_FEE_BPS + { return Err(SavingsError::InvalidFeeBps); } // Store config values - env.storage().instance().set(&DataKey::Treasury, &treasury); env.storage() .instance() - .set(&DataKey::ProtocolFeeBps, &protocol_fee_bps); + .set(&DataKey::TreasuryAddress, &treasury); + env.storage() + .instance() + .set(&DataKey::DepositFeeBps, &deposit_fee_bps); + env.storage() + .instance() + .set(&DataKey::WithdrawalFeeBps, &withdrawal_fee_bps); + env.storage() + .instance() + .set(&DataKey::PerformanceFeeBps, &performance_fee_bps); env.storage() .instance() .set(&DataKey::ConfigInitialized, &true); + // Initialize the treasury struct with default zero values + crate::treasury::initialize_treasury(env); + env.events() - .publish((symbol_short!("cfg_init"),), protocol_fee_bps); + .publish((symbol_short!("cfg_init"),), performance_fee_bps); Ok(()) } @@ -114,13 +132,25 @@ pub fn get_config(env: &Env) -> Result { let treasury: Address = env .storage() .instance() - .get(&DataKey::Treasury) + .get(&DataKey::TreasuryAddress) .unwrap_or(admin.clone()); - let protocol_fee_bps: u32 = env + let deposit_fee_bps: u32 = env + .storage() + .instance() + .get(&DataKey::DepositFeeBps) + .unwrap_or(0); + + let withdrawal_fee_bps: u32 = env .storage() .instance() - .get(&DataKey::ProtocolFeeBps) + .get(&DataKey::WithdrawalFeeBps) + .unwrap_or(0); + + let performance_fee_bps: u32 = env + .storage() + .instance() + .get(&DataKey::PerformanceFeeBps) .unwrap_or(0); let paused: bool = env @@ -132,7 +162,9 @@ pub fn get_config(env: &Env) -> Result { Ok(Config { admin, treasury, - protocol_fee_bps, + deposit_fee_bps, + withdrawal_fee_bps, + performance_fee_bps, paused, }) } @@ -151,7 +183,7 @@ pub fn set_treasury(env: &Env, admin: Address, new_treasury: Address) -> Result< env.storage() .instance() - .set(&DataKey::Treasury, &new_treasury); + .set(&DataKey::TreasuryAddress, &new_treasury); env.events() .publish((symbol_short!("set_trs"),), new_treasury); @@ -169,19 +201,31 @@ pub fn set_treasury(env: &Env, admin: Address, new_treasury: Address) -> Result< /// # Errors /// * `SavingsError::Unauthorized` - If caller is not the admin /// * `SavingsError::InvalidFeeBps` - If fee exceeds 10000 bps -pub fn set_protocol_fee(env: &Env, admin: Address, new_fee_bps: u32) -> Result<(), SavingsError> { +pub fn set_fees( + env: &Env, + admin: Address, + deposit_fee: u32, + withdrawal_fee: u32, + performance_fee: u32, +) -> Result<(), SavingsError> { require_admin(env, &admin)?; - if new_fee_bps > MAX_FEE_BPS { + if deposit_fee > MAX_FEE_BPS || withdrawal_fee > MAX_FEE_BPS || performance_fee > MAX_FEE_BPS { return Err(SavingsError::InvalidFeeBps); } env.storage() .instance() - .set(&DataKey::ProtocolFeeBps, &new_fee_bps); + .set(&DataKey::DepositFeeBps, &deposit_fee); + env.storage() + .instance() + .set(&DataKey::WithdrawalFeeBps, &withdrawal_fee); + env.storage() + .instance() + .set(&DataKey::PerformanceFeeBps, &performance_fee); env.events() - .publish((symbol_short!("set_fee"),), new_fee_bps); + .publish((symbol_short!("set_fee"),), performance_fee); Ok(()) } diff --git a/contracts/src/config_tests.rs b/contracts/src/config_tests.rs index 39a29e3bd..168977a2c 100644 --- a/contracts/src/config_tests.rs +++ b/contracts/src/config_tests.rs @@ -30,14 +30,14 @@ fn test_initialize_config_succeeds() { let treasury = Address::generate(&env); env.mock_all_auths(); - let result = client.try_initialize_config(&admin, &treasury, &100); + let result = client.try_initialize_config(&admin, &treasury, &100, &100, &100); assert!(result.is_ok(), "initialize_config should succeed"); // Verify stored values let config = client.get_config(); assert_eq!(config.admin, admin); assert_eq!(config.treasury, treasury); - assert_eq!(config.protocol_fee_bps, 100); + assert_eq!(config.performance_fee_bps, 100); assert!(!config.paused); } @@ -47,11 +47,11 @@ fn test_initialize_config_zero_fee() { let treasury = Address::generate(&env); env.mock_all_auths(); - let result = client.try_initialize_config(&admin, &treasury, &0); + let result = client.try_initialize_config(&admin, &treasury, &0, &0, &0); assert!(result.is_ok(), "zero fee should be valid"); let config = client.get_config(); - assert_eq!(config.protocol_fee_bps, 0); + assert_eq!(config.performance_fee_bps, 0); } #[test] @@ -60,11 +60,11 @@ fn test_initialize_config_max_fee() { let treasury = Address::generate(&env); env.mock_all_auths(); - let result = client.try_initialize_config(&admin, &treasury, &10_000); + let result = client.try_initialize_config(&admin, &treasury, &10_000, &10_000, &10_000); assert!(result.is_ok(), "max fee (10000 bps = 100%) should be valid"); let config = client.get_config(); - assert_eq!(config.protocol_fee_bps, 10_000); + assert_eq!(config.performance_fee_bps, 10_000); } #[test] @@ -74,14 +74,14 @@ fn test_reinitialize_config_fails() { env.mock_all_auths(); assert!(client - .try_initialize_config(&admin, &treasury, &100) + .try_initialize_config(&admin, &treasury, &100, &100, &100) .is_ok()); // Second initialization should fail let treasury2 = Address::generate(&env); assert_savings_error( client - .try_initialize_config(&admin, &treasury2, &200) + .try_initialize_config(&admin, &treasury2, &200, &200, &200) .unwrap_err(), SavingsError::ConfigAlreadyInitialized, ); @@ -95,7 +95,7 @@ fn test_initialize_config_fee_too_high() { env.mock_all_auths(); assert_savings_error( client - .try_initialize_config(&admin, &treasury, &10_001) + .try_initialize_config(&admin, &treasury, &10_001, &10_001, &10_001) .unwrap_err(), SavingsError::InvalidFeeBps, ); @@ -110,7 +110,7 @@ fn test_non_admin_cannot_initialize_config() { env.mock_all_auths(); assert_savings_error( client - .try_initialize_config(&non_admin, &treasury, &100) + .try_initialize_config(&non_admin, &treasury, &100, &100, &100) .unwrap_err(), SavingsError::Unauthorized, ); @@ -126,7 +126,7 @@ fn test_get_config_before_config_init() { // Config should still be retrievable with defaults even without initialize_config let config = client.get_config(); assert_eq!(config.admin, admin); - assert_eq!(config.protocol_fee_bps, 0); // default + assert_eq!(config.performance_fee_bps, 0); // default assert!(!config.paused); // default } @@ -137,17 +137,17 @@ fn test_get_config_reflects_updates() { let new_treasury = Address::generate(&env); env.mock_all_auths(); - client.initialize_config(&admin, &treasury, &100); + client.initialize_config(&admin, &treasury, &100, &100, &100); // Update treasury client.set_treasury(&admin, &new_treasury); // Update fee - client.set_protocol_fee(&admin, &500); + client.set_fees(&admin, &500, &500, &500); let config = client.get_config(); assert_eq!(config.treasury, new_treasury); - assert_eq!(config.protocol_fee_bps, 500); + assert_eq!(config.performance_fee_bps, 500); } // ========== set_treasury Tests ========== @@ -159,7 +159,7 @@ fn test_set_treasury_succeeds() { let new_treasury = Address::generate(&env); env.mock_all_auths(); - client.initialize_config(&admin, &treasury, &100); + client.initialize_config(&admin, &treasury, &100, &100, &100); let result = client.try_set_treasury(&admin, &new_treasury); assert!(result.is_ok(), "admin should be able to update treasury"); @@ -176,7 +176,7 @@ fn test_non_admin_cannot_set_treasury() { let new_treasury = Address::generate(&env); env.mock_all_auths(); - client.initialize_config(&admin, &treasury, &100); + client.initialize_config(&admin, &treasury, &100, &100, &100); assert_savings_error( client @@ -194,13 +194,13 @@ fn test_set_protocol_fee_succeeds() { let treasury = Address::generate(&env); env.mock_all_auths(); - client.initialize_config(&admin, &treasury, &100); + client.initialize_config(&admin, &treasury, &100, &100, &100); - let result = client.try_set_protocol_fee(&admin, &500); + let result = client.try_set_fees(&admin, &500, &500, &500); assert!(result.is_ok(), "admin should be able to update fee"); let config = client.get_config(); - assert_eq!(config.protocol_fee_bps, 500); + assert_eq!(config.performance_fee_bps, 500); } #[test] @@ -209,13 +209,13 @@ fn test_set_protocol_fee_to_zero() { let treasury = Address::generate(&env); env.mock_all_auths(); - client.initialize_config(&admin, &treasury, &100); + client.initialize_config(&admin, &treasury, &100, &100, &100); - let result = client.try_set_protocol_fee(&admin, &0); + let result = client.try_set_fees(&admin, &0, &0, &0); assert!(result.is_ok(), "setting fee to 0 should work"); let config = client.get_config(); - assert_eq!(config.protocol_fee_bps, 0); + assert_eq!(config.performance_fee_bps, 0); } #[test] @@ -224,9 +224,9 @@ fn test_set_protocol_fee_to_max() { let treasury = Address::generate(&env); env.mock_all_auths(); - client.initialize_config(&admin, &treasury, &100); + client.initialize_config(&admin, &treasury, &100, &100, &100); - let result = client.try_set_protocol_fee(&admin, &10_000); + let result = client.try_set_fees(&admin, &10_000, &10_000, &10_000); assert!(result.is_ok(), "setting fee to 10000 should work"); } @@ -236,10 +236,12 @@ fn test_set_protocol_fee_exceeds_max() { let treasury = Address::generate(&env); env.mock_all_auths(); - client.initialize_config(&admin, &treasury, &100); + client.initialize_config(&admin, &treasury, &100, &100, &100); assert_savings_error( - client.try_set_protocol_fee(&admin, &10_001).unwrap_err(), + client + .try_set_fees(&admin, &10_001, &10_001, &10_001) + .unwrap_err(), SavingsError::InvalidFeeBps, ); } @@ -251,10 +253,12 @@ fn test_non_admin_cannot_set_protocol_fee() { let non_admin = Address::generate(&env); env.mock_all_auths(); - client.initialize_config(&admin, &treasury, &100); + client.initialize_config(&admin, &treasury, &100, &100, &100); assert_savings_error( - client.try_set_protocol_fee(&non_admin, &500).unwrap_err(), + client + .try_set_fees(&non_admin, &500, &500, &500) + .unwrap_err(), SavingsError::Unauthorized, ); } @@ -414,11 +418,11 @@ fn test_full_config_lifecycle() { env.mock_all_auths(); // 1. Initialize config - client.initialize_config(&admin, &treasury1, &250); + client.initialize_config(&admin, &treasury1, &250, &250, &250); let config = client.get_config(); assert_eq!(config.treasury, treasury1); - assert_eq!(config.protocol_fee_bps, 250); + assert_eq!(config.performance_fee_bps, 250); assert!(!config.paused); // 2. Update treasury @@ -427,17 +431,19 @@ fn test_full_config_lifecycle() { assert_eq!(config.treasury, treasury2); // 3. Update fee - client.set_protocol_fee(&admin, &500); + client.set_fees(&admin, &500, &500, &500); let config = client.get_config(); - assert_eq!(config.protocol_fee_bps, 500); + assert_eq!(config.deposit_fee_bps, 500); + assert_eq!(config.withdrawal_fee_bps, 500); + assert_eq!(config.performance_fee_bps, 500); // 4. Pause client.pause_contract(&admin); assert!(client.get_config().paused); // 5. Admin can still update config while paused - client.set_protocol_fee(&admin, &300); - assert_eq!(client.get_config().protocol_fee_bps, 300); + client.set_fees(&admin, &300, &300, &300); + assert_eq!(client.get_config().performance_fee_bps, 300); // 6. Unpause client.unpause_contract(&admin); diff --git a/contracts/src/flexi.rs b/contracts/src/flexi.rs index 68c3bdd97..8dd39419b 100644 --- a/contracts/src/flexi.rs +++ b/contracts/src/flexi.rs @@ -24,7 +24,7 @@ pub fn flexi_deposit(env: Env, user: Address, amount: i128) -> Result<(), Saving let fee_bps: u32 = env .storage() .instance() - .get(&DataKey::PlatformFee) + .get(&DataKey::DepositFeeBps) .unwrap_or(0); let fee_amount = calculate_fee(amount, fee_bps)?; @@ -81,6 +81,8 @@ pub fn flexi_deposit(env: Env, user: Address, amount: i128) -> Result<(), Saving env.events() .publish((symbol_short!("dep_fee"), fee_recipient), fee_amount); } + // Record fee in treasury struct + crate::treasury::record_fee(&env, fee_amount, soroban_sdk::Symbol::new(&env, "deposit")); } Ok(()) @@ -108,7 +110,7 @@ pub fn flexi_withdraw(env: Env, user: Address, amount: i128) -> Result<(), Savin let fee_bps: u32 = env .storage() .instance() - .get(&DataKey::PlatformFee) + .get(&DataKey::WithdrawalFeeBps) .unwrap_or(0); let fee_amount = calculate_fee(amount, fee_bps)?; @@ -164,6 +166,8 @@ pub fn flexi_withdraw(env: Env, user: Address, amount: i128) -> Result<(), Savin env.events() .publish((symbol_short!("wth_fee"), fee_recipient), fee_amount); } + // Record fee in treasury struct + crate::treasury::record_fee(&env, fee_amount, soroban_sdk::Symbol::new(&env, "withdraw")); } Ok(()) @@ -221,14 +225,14 @@ mod tests { #[test] fn test_flexi_deposit_with_protocol_fee() { - let (env, client, _admin) = setup_admin_env(); + let (env, client, admin) = setup_admin_env(); let user = Address::generate(&env); let treasury = Address::generate(&env); env.mock_all_auths(); client.initialize_user(&user); assert!(client.try_set_fee_recipient(&treasury).is_ok()); - assert!(client.try_set_protocol_fee_bps(&500).is_ok()); // 5% + assert!(client.try_set_fees(&admin, &500, &500, &500).is_ok()); // 5% let deposit_amount = 10_000i128; client.deposit_flexi(&user, &deposit_amount); @@ -255,14 +259,14 @@ mod tests { #[test] fn test_flexi_withdraw_with_protocol_fee() { - let (env, client, _admin) = setup_admin_env(); + let (env, client, admin) = setup_admin_env(); let user = Address::generate(&env); let treasury = Address::generate(&env); env.mock_all_auths(); client.initialize_user(&user); assert!(client.try_set_fee_recipient(&treasury).is_ok()); - assert!(client.try_set_protocol_fee_bps(&250).is_ok()); // 2.5% + assert!(client.try_set_fees(&admin, &250, &250, &250).is_ok()); // 2.5% client.deposit_flexi(&user, &10_000); let balance_before = client.get_flexi_balance(&user); @@ -292,14 +296,14 @@ mod tests { #[test] fn test_flexi_fee_rounds_down() { - let (env, client, _admin) = setup_admin_env(); + let (env, client, admin) = setup_admin_env(); let user = Address::generate(&env); let treasury = Address::generate(&env); env.mock_all_auths(); client.initialize_user(&user); assert!(client.try_set_fee_recipient(&treasury).is_ok()); - assert!(client.try_set_protocol_fee_bps(&125).is_ok()); // 1.25% + assert!(client.try_set_fees(&admin, &125, &125, &125).is_ok()); // 1.25% client.deposit_flexi(&user, &3_333); @@ -311,14 +315,14 @@ mod tests { #[test] fn test_flexi_small_amount_edge_case() { - let (env, client, _admin) = setup_admin_env(); + let (env, client, admin) = setup_admin_env(); let user = Address::generate(&env); let treasury = Address::generate(&env); env.mock_all_auths(); client.initialize_user(&user); assert!(client.try_set_fee_recipient(&treasury).is_ok()); - assert!(client.try_set_protocol_fee_bps(&100).is_ok()); // 1% + assert!(client.try_set_fees(&admin, &100, &100, &100).is_ok()); // 1% // Small amount where fee would be < 1 client.deposit_flexi(&user, &50); diff --git a/contracts/src/goal.rs b/contracts/src/goal.rs index 58fbb0a05..12ce1a4da 100644 --- a/contracts/src/goal.rs +++ b/contracts/src/goal.rs @@ -34,7 +34,7 @@ pub fn create_goal_save( let fee_bps: u32 = env .storage() .instance() - .get(&DataKey::PlatformFee) + .get(&DataKey::DepositFeeBps) .unwrap_or(0); let fee_amount = calculate_fee(initial_deposit, fee_bps)?; @@ -87,6 +87,8 @@ pub fn create_goal_save( fee_amount, ); } + // Record fee in treasury struct + crate::treasury::record_fee(env, fee_amount, soroban_sdk::Symbol::new(env, "deposit")); } add_goal_to_user(env, &user, goal_id); @@ -129,7 +131,7 @@ pub fn deposit_to_goal_save( let fee_bps: u32 = env .storage() .instance() - .get(&DataKey::PlatformFee) + .get(&DataKey::DepositFeeBps) .unwrap_or(0); let fee_amount = calculate_fee(amount, fee_bps)?; @@ -181,9 +183,9 @@ pub fn deposit_to_goal_save( fee_amount, ); } + // Record fee in treasury struct + crate::treasury::record_fee(env, fee_amount, soroban_sdk::Symbol::new(env, "deposit")); } - - // Award deposit points storage::award_deposit_points(env, user.clone(), amount)?; Ok(()) @@ -219,7 +221,7 @@ pub fn withdraw_completed_goal_save( let fee_bps: u32 = env .storage() .instance() - .get(&DataKey::PlatformFee) + .get(&DataKey::WithdrawalFeeBps) .unwrap_or(0); let fee_amount = calculate_fee(goal_save.current_amount, fee_bps)?; @@ -269,6 +271,8 @@ pub fn withdraw_completed_goal_save( fee_amount, ); } + // Record fee in treasury struct + crate::treasury::record_fee(env, fee_amount, soroban_sdk::Symbol::new(env, "withdraw")); } Ok(net_amount) @@ -779,7 +783,7 @@ mod tests { #[test] #[should_panic(expected = "Error(Contract, #1)")] fn test_break_unauthorized_fails() { - let (env, client) = setup_test_env(); + let (env, client, _admin) = setup_admin_env(); let user1 = Address::generate(&env); let user2 = Address::generate(&env); @@ -828,14 +832,14 @@ mod tests { #[test] fn test_goal_create_with_protocol_fee() { - let (env, client, _admin) = setup_admin_env(); + let (env, client, admin) = setup_admin_env(); let user = Address::generate(&env); let treasury = Address::generate(&env); env.mock_all_auths(); client.initialize_user(&user); assert!(client.try_set_fee_recipient(&treasury).is_ok()); - assert!(client.try_set_protocol_fee_bps(&500).is_ok()); // 5% + assert!(client.try_set_fees(&admin, &500, &500, &500).is_ok()); // 5% let goal_name = Symbol::new(&env, "vacation"); let target = 10_000i128; @@ -851,14 +855,14 @@ mod tests { #[test] fn test_goal_deposit_with_protocol_fee() { - let (env, client, _admin) = setup_admin_env(); + let (env, client, admin) = setup_admin_env(); let user = Address::generate(&env); let treasury = Address::generate(&env); env.mock_all_auths(); client.initialize_user(&user); assert!(client.try_set_fee_recipient(&treasury).is_ok()); - assert!(client.try_set_protocol_fee_bps(&300).is_ok()); // 3% + assert!(client.try_set_fees(&admin, &300, &300, &300).is_ok()); // 3% let goal_name = Symbol::new(&env, "house"); let target = 10_000i128; @@ -880,14 +884,14 @@ mod tests { #[test] fn test_goal_withdraw_with_protocol_fee() { - let (env, client, _admin) = setup_admin_env(); + let (env, client, admin) = setup_admin_env(); let user = Address::generate(&env); let treasury = Address::generate(&env); env.mock_all_auths(); client.initialize_user(&user); assert!(client.try_set_fee_recipient(&treasury).is_ok()); - assert!(client.try_set_protocol_fee_bps(&250).is_ok()); // 2.5% + assert!(client.try_set_fees(&admin, &250, &250, &250).is_ok()); // 2.5% let goal_name = Symbol::new(&env, "laptop"); let target = 4_000i128; @@ -930,14 +934,14 @@ mod tests { #[test] fn test_goal_fee_calculation_correctness() { - let (env, client, _admin) = setup_admin_env(); + let (env, client, admin) = setup_admin_env(); let user = Address::generate(&env); let treasury = Address::generate(&env); env.mock_all_auths(); client.initialize_user(&user); assert!(client.try_set_fee_recipient(&treasury).is_ok()); - assert!(client.try_set_protocol_fee_bps(&1000).is_ok()); // 10% + assert!(client.try_set_fees(&admin, &1000, &1000, &1000).is_ok()); // 10% let goal_name = Symbol::new(&env, "test"); let target = 10_000i128; @@ -953,14 +957,14 @@ mod tests { #[test] fn test_goal_small_amount_fee_edge_case() { - let (env, client, _admin) = setup_admin_env(); + let (env, client, admin) = setup_admin_env(); let user = Address::generate(&env); let treasury = Address::generate(&env); env.mock_all_auths(); client.initialize_user(&user); assert!(client.try_set_fee_recipient(&treasury).is_ok()); - assert!(client.try_set_protocol_fee_bps(&100).is_ok()); // 1% + assert!(client.try_set_fees(&admin, &100, &100, &100).is_ok()); // 1% let goal_name = Symbol::new(&env, "small"); let target = 1_000i128; diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index cb9bca3fe..eeb0a0b85 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -19,6 +19,7 @@ mod lock; pub mod rewards; mod storage_types; pub mod strategy; +pub mod treasury; mod ttl; mod upgrade; mod users; @@ -477,17 +478,6 @@ impl NesteraContract { Ok(()) } - pub fn set_protocol_fee_bps(env: Env, bps: u32) -> Result<(), SavingsError> { - let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap(); - admin.require_auth(); - if bps > 10_000 { - return Err(SavingsError::InvalidAmount); - } - env.storage().instance().set(&DataKey::PlatformFee, &bps); - env.events().publish((symbol_short!("set_pfee"),), bps); - Ok(()) - } - pub fn pause(env: Env, caller: Address) -> Result<(), SavingsError> { caller.require_auth(); governance::validate_admin_or_governance(&env, &caller)?; @@ -725,13 +715,6 @@ impl NesteraContract { env.storage().instance().get(&DataKey::FeeRecipient) } - pub fn get_protocol_fee_bps(env: Env) -> u32 { - env.storage() - .instance() - .get(&DataKey::PlatformFee) - .unwrap_or(0) - } - pub fn get_protocol_fee_balance(env: Env, recipient: Address) -> i128 { env.storage() .persistent() @@ -897,9 +880,18 @@ impl NesteraContract { env: Env, admin: Address, treasury: Address, - protocol_fee_bps: u32, + deposit_fee_bps: u32, + withdrawal_fee_bps: u32, + performance_fee_bps: u32, ) -> Result<(), SavingsError> { - config::initialize_config(&env, admin, treasury, protocol_fee_bps) + config::initialize_config( + &env, + admin, + treasury, + deposit_fee_bps, + withdrawal_fee_bps, + performance_fee_bps, + ) } /// Returns the current global configuration @@ -917,12 +909,14 @@ impl NesteraContract { } /// Updates the protocol fee in basis points (admin only) - pub fn set_protocol_fee( + pub fn set_fees( env: Env, admin: Address, - new_fee_bps: u32, + deposit_fee: u32, + withdrawal_fee: u32, + performance_fee: u32, ) -> Result<(), SavingsError> { - config::set_protocol_fee(&env, admin, new_fee_bps) + config::set_fees(&env, admin, deposit_fee, withdrawal_fee, performance_fee) } /// Pauses the contract via config module (admin only) @@ -943,6 +937,31 @@ impl NesteraContract { upgrade::get_version(&env) } + // ========== Treasury Functions ========== + + /// Returns the current treasury state + pub fn get_treasury(env: Env) -> treasury::types::Treasury { + treasury::get_treasury(&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( + env: Env, + admin: Address, + reserve_percent: u32, + rewards_percent: u32, + operations_percent: u32, + ) -> Result { + treasury::allocate_treasury( + &env, + &admin, + reserve_percent, + rewards_percent, + operations_percent, + ) + } + // ========== Governance Functions ========== /// Initializes voting configuration (admin only) diff --git a/contracts/src/storage_types.rs b/contracts/src/storage_types.rs index abdd1e365..653fb1096 100644 --- a/contracts/src/storage_types.rs +++ b/contracts/src/storage_types.rs @@ -136,17 +136,19 @@ pub enum DataKey { /// Global pause flag for emergency control Paused, /// Treasury address for protocol fee collection + TreasuryAddress, + /// Protocol fee in basis points (100 = 1%) for deposits + DepositFeeBps, + /// Protocol fee in basis points for withdrawals + WithdrawalFeeBps, + /// Protocol fee in basis points for performance (yield harvest) + PerformanceFeeBps, + /// Store the Treasury struct metrics (from issue #321) Treasury, - /// Protocol fee in basis points (100 = 1%) - ProtocolFeeBps, /// Flag to track config initialization ConfigInitialized, - /// Minimum allowed deposit amount - MinimumDeposit, - /// Fee applied on withdrawals - WithdrawalFee, - /// Protocol fee configuration - PlatformFee, + /// Treasury allocation config (reserve/rewards/operations percentages) + AllocationConfig, /// Early break fee (basis points) for goal saves EarlyBreakFeeBps, /// Fee recipient for protocol/treasury fees diff --git a/contracts/src/strategy/harvest_tests.rs b/contracts/src/strategy/harvest_tests.rs index e7b3df66c..afdaabdfd 100644 --- a/contracts/src/strategy/harvest_tests.rs +++ b/contracts/src/strategy/harvest_tests.rs @@ -31,7 +31,7 @@ fn setup_with_treasury() -> ( env.mock_all_auths(); client.initialize(&admin, &admin_pk); // Initialize config so harvest_strategy can read treasury + protocol_fee_bps - client.initialize_config(&admin, &treasury, &1_000u32); // 10% fee + client.initialize_config(&admin, &treasury, &1_000u32, &1_000u32, &1_000u32); // 10% fee (env, client, admin, treasury, contract_id) } diff --git a/contracts/src/strategy/routing.rs b/contracts/src/strategy/routing.rs index b952cb734..335b7ad49 100644 --- a/contracts/src/strategy/routing.rs +++ b/contracts/src/strategy/routing.rs @@ -223,11 +223,11 @@ pub fn harvest_strategy(env: &Env, strategy_address: Address) -> Result 0 { + let treasury_fee = if performance_fee_bps > 0 { (actual_yield - .checked_mul(protocol_fee_bps as i128) + .checked_mul(performance_fee_bps as i128) .ok_or(SavingsError::Overflow)?) / 10_000 } else { @@ -269,5 +269,13 @@ pub fn harvest_strategy(env: &Env, strategy_address: Address) -> Result 0 { + crate::treasury::record_fee(env, treasury_fee, soroban_sdk::Symbol::new(env, "perf")); + } + if user_yield > 0 { + crate::treasury::record_yield(env, user_yield); + } + Ok(actual_yield) } diff --git a/contracts/src/treasury/mod.rs b/contracts/src/treasury/mod.rs new file mode 100644 index 000000000..62b7c201d --- /dev/null +++ b/contracts/src/treasury/mod.rs @@ -0,0 +1,174 @@ +pub mod types; + +use crate::errors::SavingsError; +use crate::storage_types::DataKey; +use soroban_sdk::{symbol_short, Address, Env}; +use types::{AllocationConfig, Treasury}; + +// ========== Treasury Storage Helpers ========== + +/// Retrieves the Treasury struct from persistent storage. +pub fn get_treasury(env: &Env) -> Treasury { + env.storage() + .persistent() + .get(&DataKey::Treasury) + .unwrap_or(Treasury::new()) +} + +/// Saves the Treasury struct to persistent storage. +fn set_treasury(env: &Env, treasury: &Treasury) { + env.storage().persistent().set(&DataKey::Treasury, treasury); +} + +// ========== Treasury Initialization ========== + +/// Initializes the treasury with default zero values. +/// Called during `initialize_config`. +pub fn initialize_treasury(env: &Env) { + let treasury = Treasury::new(); + set_treasury(env, &treasury); +} + +// ========== Fee Recording ========== + +/// Records a collected fee into the treasury and emits a FeeCollected event. +/// +/// # Arguments +/// * `env` - The contract environment +/// * `amount` - The fee amount collected +/// * `fee_type` - A short symbol describing the fee type (e.g., "dep", "wth", "perf") +pub fn record_fee(env: &Env, amount: i128, fee_type: soroban_sdk::Symbol) { + if amount <= 0 { + return; + } + let mut treasury = get_treasury(env); + treasury.total_fees_collected = treasury + .total_fees_collected + .checked_add(amount) + .unwrap_or(treasury.total_fees_collected); + treasury.treasury_balance = treasury + .treasury_balance + .checked_add(amount) + .unwrap_or(treasury.treasury_balance); + set_treasury(env, &treasury); + + env.events() + .publish((symbol_short!("fee_col"), fee_type), amount); +} + +/// Records yield earned into the treasury. +pub fn record_yield(env: &Env, amount: i128) { + if amount <= 0 { + return; + } + let mut treasury = get_treasury(env); + treasury.total_yield_earned = treasury + .total_yield_earned + .checked_add(amount) + .unwrap_or(treasury.total_yield_earned); + set_treasury(env, &treasury); +} + +// ========== Allocation Logic ========== + +/// Allocates the unallocated treasury balance into reserves, rewards, and operations. +/// +/// The allocation percentages are provided as basis points (e.g., 4000 = 40%). +/// They MUST sum to exactly 10_000 (100%). +/// +/// # Arguments +/// * `env` - The contract environment +/// * `admin` - The admin address (must match the stored admin) +/// * `reserve_percent` - Reserve allocation in basis points +/// * `rewards_percent` - Rewards allocation in basis points +/// * `operations_percent` - Operations allocation in basis points +/// +/// # Errors +/// * `SavingsError::Unauthorized` - If caller is not admin +/// * `SavingsError::InvalidAmount` - If percentages don't sum to 10_000 +pub fn allocate_treasury( + env: &Env, + admin: &Address, + reserve_percent: u32, + rewards_percent: u32, + operations_percent: u32, +) -> Result { + // Verify admin + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(SavingsError::Unauthorized)?; + if stored_admin != *admin { + return Err(SavingsError::Unauthorized); + } + admin.require_auth(); + + // Validate percentages sum to 100% + let total = reserve_percent + .checked_add(rewards_percent) + .and_then(|s| s.checked_add(operations_percent)) + .ok_or(SavingsError::Overflow)?; + + if total != 10_000 { + return Err(SavingsError::InvalidAmount); + } + + let mut treasury = get_treasury(env); + let available = treasury.treasury_balance; + + if available <= 0 { + return Ok(treasury); + } + + // Calculate splits + let reserve_amount = available + .checked_mul(reserve_percent as i128) + .ok_or(SavingsError::Overflow)? + / 10_000; + let rewards_amount = available + .checked_mul(rewards_percent as i128) + .ok_or(SavingsError::Overflow)? + / 10_000; + // Operations gets the remainder to avoid rounding dust + let operations_amount = available + .checked_sub(reserve_amount) + .and_then(|v| v.checked_sub(rewards_amount)) + .ok_or(SavingsError::Underflow)?; + + // Update balances + treasury.reserve_balance = treasury + .reserve_balance + .checked_add(reserve_amount) + .ok_or(SavingsError::Overflow)?; + treasury.rewards_balance = treasury + .rewards_balance + .checked_add(rewards_amount) + .ok_or(SavingsError::Overflow)?; + treasury.operations_balance = treasury + .operations_balance + .checked_add(operations_amount) + .ok_or(SavingsError::Overflow)?; + + // Zero out the unallocated balance + treasury.treasury_balance = 0; + + // Store the allocation config for reference + let alloc_config = AllocationConfig { + reserve_percent, + rewards_percent, + operations_percent, + }; + env.storage() + .persistent() + .set(&DataKey::AllocationConfig, &alloc_config); + + set_treasury(env, &treasury); + + env.events().publish( + (symbol_short!("alloc"),), + (reserve_amount, rewards_amount, operations_amount), + ); + + Ok(treasury) +} diff --git a/contracts/src/treasury/types.rs b/contracts/src/treasury/types.rs new file mode 100644 index 000000000..0e819aa39 --- /dev/null +++ b/contracts/src/treasury/types.rs @@ -0,0 +1,51 @@ +use soroban_sdk::contracttype; + +/// Represents the global state of the Nestera protocol treasury +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Treasury { + pub total_fees_collected: i128, + pub total_yield_earned: i128, + pub reserve_balance: i128, + pub treasury_balance: i128, + pub rewards_balance: i128, + pub operations_balance: i128, +} + +impl Default for Treasury { + fn default() -> Self { + Self::new() + } +} + +impl Treasury { + pub fn new() -> Self { + Self { + total_fees_collected: 0, + total_yield_earned: 0, + reserve_balance: 0, + treasury_balance: 0, + rewards_balance: 0, + operations_balance: 0, + } + } +} + +/// Contains allocation percentages for the treasury split +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AllocationConfig { + pub reserve_percent: u32, + pub rewards_percent: u32, + pub operations_percent: u32, +} + +impl AllocationConfig { + pub fn default_allocation() -> Self { + Self { + reserve_percent: 40_00, // 40% + rewards_percent: 40_00, // 40% + operations_percent: 20_00, // 20% + } + } +} diff --git a/contracts/tests/strategy_integration_test.rs b/contracts/tests/strategy_integration_test.rs index 644d020a5..1873dfda7 100644 --- a/contracts/tests/strategy_integration_test.rs +++ b/contracts/tests/strategy_integration_test.rs @@ -75,7 +75,7 @@ fn setup_env() -> ( let treasury = Address::generate(&env); let admin_pk = BytesN::from_array(&env, &[1u8; 32]); client.initialize(&admin, &admin_pk); - client.initialize_config(&admin, &treasury, &1_000u32); // 10% fee + client.initialize_config(&admin, &treasury, &1_000u32, &1_000u32, &1_000u32); // 10% fee let user1 = Address::generate(&env); let strategy_id = env.register(MockYieldStrategy, ());