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 e89dcef2..b69ab940 100644
--- a/apps/onchain/contracts/crowdfund_vault/src/lib.rs
+++ b/apps/onchain/contracts/crowdfund_vault/src/lib.rs
@@ -2200,10 +2200,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;