From 7bda0a7d177133b3a56c9c86e37e98b2073a24c2 Mon Sep 17 00:00:00 2001 From: Collins C Augustine Date: Tue, 28 Apr 2026 21:10:23 +0100 Subject: [PATCH] feat: implement Yield-bearing Vault Extensions (Mock Integration) --- .../contracts/crowdfund_vault/src/errors.rs | 2 + .../contracts/crowdfund_vault/src/lib.rs | 224 +++++++++++- .../contracts/crowdfund_vault/src/storage.rs | 1 + .../crowdfund_vault/src/test_yield.rs | 324 +++++++++++++++++- .../crowdfund_vault/src/yield_provider.rs | 2 +- 5 files changed, 549 insertions(+), 4 deletions(-) diff --git a/apps/onchain/contracts/crowdfund_vault/src/errors.rs b/apps/onchain/contracts/crowdfund_vault/src/errors.rs index aa0b3174..1b97d28c 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/errors.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/errors.rs @@ -35,4 +35,6 @@ pub enum CrowdfundError { RefundWindowClosed = 29, RefundWindowNotOpen = 30, Reentrancy = 31, + AlreadyExists = 32, + NotFound = 33, } diff --git a/apps/onchain/contracts/crowdfund_vault/src/lib.rs b/apps/onchain/contracts/crowdfund_vault/src/lib.rs index 2faffa9c..1d2e8bb5 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/lib.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/lib.rs @@ -2124,10 +2124,232 @@ impl CrowdfundVaultContract { let contract_address = env.current_contract_address(); let yield_client = yield_provider::YieldProviderClient::new(env, &yield_provider_addr); - yield_client.withdraw(&contract_address, &amount); + let returned_amount = yield_client.withdraw(&contract_address, &amount); + + if returned_amount > amount { + let interest = returned_amount - amount; + let balance_key = DataKey::ProjectBalance(project_id, project.token_address.clone()); + let current_balance: i128 = env.storage().persistent().get(&balance_key).unwrap_or(0); + env.storage() + .persistent() + .set(&balance_key, &(current_balance + interest)); + } Ok(()) } + + /// Add a yield provider to the list for a token (admin only) + pub fn add_yield_provider( + env: Env, + admin: Address, + token_address: Address, + yield_provider: Address, + ) -> Result<(), CrowdfundError> { + Self::verify_admin(&env, &admin)?; + + let key = DataKey::YieldProviders(token_address.clone()); + let mut providers: Vec
= env + .storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(&env)); + + // Check if provider already exists + for provider in providers.iter() { + if provider == yield_provider { + return Err(CrowdfundError::AlreadyExists); + } + } + + providers.push_back(yield_provider); + env.storage().persistent().set(&key, &providers); + + Ok(()) + } + + /// Remove a yield provider from the list for a token (admin only) + pub fn remove_yield_provider( + env: Env, + admin: Address, + token_address: Address, + yield_provider: Address, + ) -> Result<(), CrowdfundError> { + Self::verify_admin(&env, &admin)?; + + let key = DataKey::YieldProviders(token_address.clone()); + let providers: Vec
= env + .storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(&env)); + + let mut new_providers = Vec::new(&env); + let mut found = false; + + for provider in providers.iter() { + if provider != yield_provider { + new_providers.push_back(provider); + } else { + found = true; + } + } + + if !found { + return Err(CrowdfundError::NotFound); + } + + env.storage().persistent().set(&key, &new_providers); + + Ok(()) + } + + /// Get all yield providers for a token + pub fn get_yield_providers(env: Env, token_address: Address) -> Vec
{ + let key = DataKey::YieldProviders(token_address); + env.storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(&env)) + } + + /// Invest idle funds across multiple yield providers (distributes evenly) + pub fn invest_idle_funds_distributed( + env: Env, + caller: Address, + project_id: u64, + amount: i128, + ) -> Result<(), CrowdfundError> { + Self::with_reentrancy_guard(&env, || { + Self::require_current_storage_version(&env)?; + caller.require_auth(); + + let project: ProjectData = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .ok_or(CrowdfundError::ProjectNotFound)?; + + if !project.is_active { + return Err(CrowdfundError::ProjectNotActive); + } + + let stored_admin = Self::get_admin_address(&env)?; + + if caller != stored_admin && caller != project.owner { + return Err(CrowdfundError::Unauthorized); + } + + Self::invest_funds_distributed_internal(&env, project_id, amount) + }) + } + + /// Internal function to invest funds across multiple providers + fn invest_funds_distributed_internal( + env: &Env, + project_id: u64, + amount: i128, + ) -> Result<(), CrowdfundError> { + let project: ProjectData = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .ok_or(CrowdfundError::ProjectNotFound)?; + + let providers_key = DataKey::YieldProviders(project.token_address.clone()); + let providers: Vec
= env + .storage() + .persistent() + .get(&providers_key) + .unwrap_or(Vec::new(env)); + + if providers.is_empty() { + return Err(CrowdfundError::YieldProviderNotFound); + } + + let balance_key = DataKey::ProjectBalance(project_id, project.token_address.clone()); + let total_balance: i128 = env.storage().persistent().get(&balance_key).unwrap_or(0); + + let invested_key = DataKey::ProjectInvestedBalance(project_id); + let current_invested: i128 = env.storage().persistent().get(&invested_key).unwrap_or(0); + + let local_balance = total_balance - current_invested; + if local_balance < amount { + return Err(CrowdfundError::InsufficientBalance); + } + + // Distribute amount evenly across providers + let num_providers = providers.len() as i128; + let amount_per_provider = amount / num_providers; + let remainder = amount % num_providers; + + let contract_address = env.current_contract_address(); + let token_client = TokenClient::new(env, &project.token_address); + + for (i, provider) in providers.iter().enumerate() { + let mut invest_amount = amount_per_provider; + if i < remainder as usize { + invest_amount += 1; // Distribute remainder + } + + if invest_amount > 0 { + token_client.transfer(&contract_address, &provider, &invest_amount); + let yield_client = yield_provider::YieldProviderClient::new(env, &provider); + yield_client.deposit(&contract_address, &invest_amount); + } + } + + env.storage() + .persistent() + .set(&invested_key, &(current_invested + amount)); + + Ok(()) + } + + /// Auto-invest idle funds when they exceed a threshold (can be called by anyone) + pub fn auto_invest_idle_funds( + env: Env, + project_id: u64, + min_threshold: i128, + ) -> Result<(), CrowdfundError> { + Self::with_reentrancy_guard(&env, || { + Self::require_current_storage_version(&env)?; + + let project: ProjectData = env + .storage() + .persistent() + .get(&DataKey::Project(project_id)) + .ok_or(CrowdfundError::ProjectNotFound)?; + + if !project.is_active { + return Err(CrowdfundError::ProjectNotActive); + } + + let balance_key = DataKey::ProjectBalance(project_id, project.token_address.clone()); + let total_balance: i128 = env.storage().persistent().get(&balance_key).unwrap_or(0); + + let invested_key = DataKey::ProjectInvestedBalance(project_id); + let current_invested: i128 = env.storage().persistent().get(&invested_key).unwrap_or(0); + + let idle_balance = total_balance - current_invested; + + if idle_balance >= min_threshold { + // Try distributed investment first, fall back to single provider + if let Ok(()) = + Self::invest_funds_distributed_internal(&env, project_id, idle_balance) + { + return Ok(()); + } else if let Some(_single_provider) = env + .storage() + .persistent() + .get::<_, Address>(&DataKey::YieldProvider(project.token_address.clone())) + { + return Self::invest_funds_internal(&env, project_id, idle_balance); + } + } + + Ok(()) + }) + } } #[cfg(test)] diff --git a/apps/onchain/contracts/crowdfund_vault/src/storage.rs b/apps/onchain/contracts/crowdfund_vault/src/storage.rs index 06259b8f..e03c86b4 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/storage.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/storage.rs @@ -34,6 +34,7 @@ pub enum DataKey { Paused, ProjectStatus(u64), YieldProvider(Address), // token_address -> yield_provider_address + YieldProviders(Address), // token_address -> Vec ProjectInvestedBalance(u64), // project_id -> i128 FeeBps, // -> u32 Treasury, // -> Address diff --git a/apps/onchain/contracts/crowdfund_vault/src/test_yield.rs b/apps/onchain/contracts/crowdfund_vault/src/test_yield.rs index f2a63afc..000f2a30 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/test_yield.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/test_yield.rs @@ -2,7 +2,7 @@ use crate::yield_provider::YieldProviderTrait; use crate::{CrowdfundVaultContract, CrowdfundVaultContractClient}; use soroban_sdk::{ contract, contractimpl, symbol_short, - testutils::Address as _, + testutils::{Address as _, Ledger}, token::{StellarAssetClient, TokenClient}, Address, Env, }; @@ -34,7 +34,7 @@ impl YieldProviderTrait for MockYieldProvider { env.storage().persistent().set(&from, &(current + amount)); } - fn withdraw(env: Env, to: Address, amount: i128) { + fn withdraw(env: Env, to: Address, amount: i128) -> i128 { let token_addr: Address = env .storage() .instance() @@ -51,6 +51,7 @@ impl YieldProviderTrait for MockYieldProvider { token.transfer(&env.current_contract_address(), &to, &amount); env.storage().persistent().set(&to, &(current - amount)); + amount } fn balance(env: Env, address: Address) -> i128 { @@ -189,3 +190,322 @@ fn test_yield_refund_divests_automatically() { // User started with 10_000_000, deposited 500_000, should have 10_000_000 again. assert_eq!(token_client.balance(&user), 10_000_000); } + +// Mock Lending Protocol - simulates lending tokens and earning interest +#[contract] +pub struct MockLendingProtocol; + +#[contractimpl] +impl MockLendingProtocol { + pub fn initialize(env: Env, token: Address, interest_rate: u32) { + // interest_rate in basis points (e.g., 500 = 5%) + env.storage() + .instance() + .set(&symbol_short!("token"), &token); + env.storage() + .instance() + .set(&symbol_short!("rate"), &interest_rate); + } +} + +#[contractimpl] +impl YieldProviderTrait for MockLendingProtocol { + fn deposit(env: Env, from: Address, amount: i128) { + let _token_addr: Address = env + .storage() + .instance() + .get(&symbol_short!("token")) + .unwrap(); + + // Tokens are transferred to this provider contract by the vault before calling deposit. + let current_principal: i128 = env.storage().persistent().get(&from).unwrap_or(0); + env.storage() + .persistent() + .set(&from, &(current_principal + amount)); + } + + fn withdraw(env: Env, to: Address, amount: i128) -> i128 { + let token_addr: Address = env + .storage() + .instance() + .get(&symbol_short!("token")) + .unwrap(); + let token = TokenClient::new(&env, &token_addr); + + let current_principal: i128 = env.storage().persistent().get(&to).unwrap_or(0); + if current_principal < amount { + panic!("insufficient principal balance"); + } + + // Calculate interest earned (simplified: 5% APY for simplicity) + let interest_rate: u32 = env + .storage() + .instance() + .get(&symbol_short!("rate")) + .unwrap(); + let interest = (amount * interest_rate as i128) / 10000; // basis points + + let total_withdrawal = amount + interest; + + // Transfer tokens back (assuming contract has enough tokens) + token.transfer(&env.current_contract_address(), &to, &total_withdrawal); + + env.storage() + .persistent() + .set(&to, &(current_principal - amount)); + total_withdrawal + } + + fn balance(env: Env, address: Address) -> i128 { + env.storage().persistent().get(&address).unwrap_or(0) + } +} + +// Mock Staking Protocol - simulates staking tokens for rewards +#[contract] +pub struct MockStakingProtocol; + +#[contractimpl] +impl MockStakingProtocol { + pub fn initialize(env: Env, token: Address, reward_rate: u32) { + // reward_rate in basis points + env.storage() + .instance() + .set(&symbol_short!("token"), &token); + env.storage() + .instance() + .set(&symbol_short!("rewards"), &reward_rate); + } +} + +#[contractimpl] +impl YieldProviderTrait for MockStakingProtocol { + fn deposit(env: Env, from: Address, amount: i128) { + let _token_addr: Address = env + .storage() + .instance() + .get(&symbol_short!("token")) + .unwrap(); + + // Tokens are transferred to this provider contract by the vault before calling deposit. + let current_staked: i128 = env.storage().persistent().get(&from).unwrap_or(0); + env.storage() + .persistent() + .set(&from, &(current_staked + amount)); + + // Track stake timestamp for reward calculation + let timestamp_key = symbol_short!("staketime"); + env.storage() + .persistent() + .set(&(timestamp_key, from.clone()), &env.ledger().timestamp()); + } + + fn withdraw(env: Env, to: Address, amount: i128) -> i128 { + let token_addr: Address = env + .storage() + .instance() + .get(&symbol_short!("token")) + .unwrap(); + let token = TokenClient::new(&env, &token_addr); + + let current_staked: i128 = env.storage().persistent().get(&to).unwrap_or(0); + if current_staked < amount { + panic!("insufficient staked balance"); + } + + // Calculate staking rewards based on time + let reward_rate: u32 = env + .storage() + .instance() + .get(&symbol_short!("rewards")) + .unwrap(); + let timestamp_key = symbol_short!("staketime"); + let stake_time: u64 = env + .storage() + .persistent() + .get(&(timestamp_key, to.clone())) + .unwrap(); + let time_elapsed = env.ledger().timestamp() - stake_time; + + // Simplified reward calculation: reward_rate basis points per day + let days_elapsed = time_elapsed / (24 * 60 * 60); + let rewards = (amount * reward_rate as i128 * days_elapsed as i128) / 10000; + + let total_withdrawal = amount + rewards; + + // Transfer tokens back + token.transfer(&env.current_contract_address(), &to, &total_withdrawal); + + env.storage() + .persistent() + .set(&to, &(current_staked - amount)); + total_withdrawal + } + + fn balance(env: Env, address: Address) -> i128 { + env.storage().persistent().get(&address).unwrap_or(0) + } +} + +// Mock AMM Protocol - simulates providing liquidity to an AMM pool +#[contract] +pub struct MockAMMProtocol; + +#[contractimpl] +impl MockAMMProtocol { + pub fn initialize(env: Env, token: Address, pair_token: Address, fee_rate: u32) { + // fee_rate in basis points + env.storage() + .instance() + .set(&symbol_short!("token"), &token); + env.storage() + .instance() + .set(&symbol_short!("pairtok"), &pair_token); + env.storage() + .instance() + .set(&symbol_short!("fee_rate"), &fee_rate); + } +} + +#[contractimpl] +impl YieldProviderTrait for MockAMMProtocol { + fn deposit(env: Env, from: Address, amount: i128) { + let _token_addr: Address = env + .storage() + .instance() + .get(&symbol_short!("token")) + .unwrap(); + + // Tokens are transferred to this provider contract by the vault before calling deposit. + let current_liquidity: i128 = env.storage().persistent().get(&from).unwrap_or(0); + env.storage() + .persistent() + .set(&from, &(current_liquidity + amount)); + } + + fn withdraw(env: Env, to: Address, amount: i128) -> i128 { + let token_addr: Address = env + .storage() + .instance() + .get(&symbol_short!("token")) + .unwrap(); + let token = TokenClient::new(&env, &token_addr); + + let current_liquidity: i128 = env.storage().persistent().get(&to).unwrap_or(0); + if current_liquidity < amount { + panic!("insufficient liquidity position"); + } + + // Calculate trading fees earned (simplified) + let fee_rate: u32 = env + .storage() + .instance() + .get(&symbol_short!("fee_rate")) + .unwrap(); + let fees_earned = (amount * fee_rate as i128) / 10000; + + let total_withdrawal = amount + fees_earned; + + // Transfer tokens back + token.transfer(&env.current_contract_address(), &to, &total_withdrawal); + + env.storage() + .persistent() + .set(&to, &(current_liquidity - amount)); + total_withdrawal + } + + fn balance(env: Env, address: Address) -> i128 { + env.storage().persistent().get(&address).unwrap_or(0) + } +} + +#[test] +fn test_staking_protocol_yield() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault_client, admin, owner, user, token_client, _) = setup_yield_test(&env); + + // Register staking protocol + let staking_id = env.register(MockStakingProtocol, ()); + let staking_client = MockStakingProtocolClient::new(&env, &staking_id); + staking_client.initialize(&token_client.address, &300); // 3% daily reward rate + + // Give tokens to staking protocol + let token_admin_client = StellarAssetClient::new(&env, &token_client.address); + token_admin_client.mint(&staking_id, &1_000_000); + + // Initialize and set yield provider + vault_client.initialize(&admin); + vault_client.set_yield_provider(&admin, &token_client.address, &staking_id); + + // Create project and deposit + let project_id = vault_client.create_project( + &owner, + &symbol_short!("StakePrj"), + &1_000_000, + &token_client.address, + ); + vault_client.deposit(&user, &project_id, &500_000); + + // Invest in staking protocol + vault_client.invest_idle_funds(&owner, &project_id, &300_000); + + // Simulate time passing for reward accrual + env.ledger() + .set_timestamp(env.ledger().timestamp() + 10 * 24 * 60 * 60); // 10 days + + // Approve milestone and withdraw enough to trigger a divest and earn yield + vault_client.approve_milestone(&admin, &project_id, &0); + vault_client.withdraw(&project_id, &0, &400_000); + + assert_eq!(token_client.balance(&owner), 400_000); + assert_eq!(vault_client.get_balance(&project_id), 160_000); +} + +#[test] +fn test_amm_protocol_yield() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault_client, admin, owner, user, token_client, _) = setup_yield_test(&env); + + // Create a pair token for AMM + let pair_token_admin = Address::generate(&env); + let pair_token_addr = env.register_stellar_asset_contract_v2(pair_token_admin.clone()); + let pair_token_client = TokenClient::new(&env, &pair_token_addr.address()); + let pair_token_admin_client = StellarAssetClient::new(&env, &pair_token_addr.address()); + + // Register AMM protocol + let amm_id = env.register(MockAMMProtocol, ()); + let amm_client = MockAMMProtocolClient::new(&env, &amm_id); + amm_client.initialize(&token_client.address, &pair_token_addr.address(), &30); // 0.3% fee + + // Give tokens to AMM protocol + let token_admin_client = StellarAssetClient::new(&env, &token_client.address); + token_admin_client.mint(&amm_id, &1_000_000); + + // Initialize and set yield provider + vault_client.initialize(&admin); + vault_client.set_yield_provider(&admin, &token_client.address, &amm_id); + + // Create project and deposit + let project_id = vault_client.create_project( + &owner, + &symbol_short!("AMMPrj"), + &1_000_000, + &token_client.address, + ); + vault_client.deposit(&user, &project_id, &500_000); + + // Invest in AMM protocol + vault_client.invest_idle_funds(&owner, &project_id, &300_000); + + // Approve milestone and withdraw enough to trigger a divest and earn fees + vault_client.approve_milestone(&admin, &project_id, &0); + vault_client.withdraw(&project_id, &0, &400_000); + + assert_eq!(token_client.balance(&owner), 400_000); + assert_eq!(vault_client.get_balance(&project_id), 100_600); +} diff --git a/apps/onchain/contracts/crowdfund_vault/src/yield_provider.rs b/apps/onchain/contracts/crowdfund_vault/src/yield_provider.rs index dc063902..969ca9ab 100644 --- a/apps/onchain/contracts/crowdfund_vault/src/yield_provider.rs +++ b/apps/onchain/contracts/crowdfund_vault/src/yield_provider.rs @@ -7,7 +7,7 @@ pub trait YieldProviderTrait { fn deposit(env: Env, from: Address, amount: i128); /// Withdraw funds from the yield provider - fn withdraw(env: Env, to: Address, amount: i128); + fn withdraw(env: Env, to: Address, amount: i128) -> i128; /// Get the balance of an address in the yield provider (in principal tokens) fn balance(env: Env, address: Address) -> i128;