diff --git a/api/db/init/08_yield_optimizer.sql b/api/db/init/08_yield_optimizer.sql new file mode 100644 index 00000000..0965a70a --- /dev/null +++ b/api/db/init/08_yield_optimizer.sql @@ -0,0 +1,55 @@ +-- Yield Optimizer Schema +-- Issue #607: Auto-compounding vault and yield optimization + +CREATE TABLE IF NOT EXISTS yield_vaults ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vault_address TEXT NOT NULL UNIQUE, + share_token TEXT NOT NULL, + underlying_asset TEXT NOT NULL, + reward_asset TEXT NOT NULL, + total_assets NUMERIC NOT NULL DEFAULT 0, + total_shares NUMERIC NOT NULL DEFAULT 0, + share_price NUMERIC NOT NULL DEFAULT 1000000000, + performance_fee_bps INTEGER NOT NULL DEFAULT 1000, + management_fee_bps INTEGER NOT NULL DEFAULT 100, + harvest_interval_secs BIGINT NOT NULL DEFAULT 86400, + apy_base NUMERIC, -- base APY without compounding + apy_compounded NUMERIC, -- APY with auto-compounding + apy_boost_bps INTEGER, -- boost in basis points + last_harvested_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_yield_vaults_asset ON yield_vaults (underlying_asset); + +CREATE TABLE IF NOT EXISTS yield_compounding_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vault_address TEXT NOT NULL, + rewards_claimed NUMERIC NOT NULL, + rewards_reinvested NUMERIC NOT NULL, + performance_fee NUMERIC NOT NULL, + gas_cost_stroops BIGINT, + total_assets_before NUMERIC NOT NULL, + total_assets_after NUMERIC NOT NULL, + share_price_before NUMERIC NOT NULL, + share_price_after NUMERIC NOT NULL, + compounded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_compounding_vault ON yield_compounding_events (vault_address); +CREATE INDEX IF NOT EXISTS idx_compounding_timestamp ON yield_compounding_events (compounded_at DESC); + +CREATE TABLE IF NOT EXISTS yield_strategies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vault_address TEXT NOT NULL, + strategy_type TEXT NOT NULL, -- 'conservative', 'moderate', 'aggressive' + target_pools JSONB NOT NULL DEFAULT '[]', + min_apy_bps INTEGER NOT NULL DEFAULT 0, + max_allocation_pct NUMERIC NOT NULL DEFAULT 100, + rebalance_threshold_bps INTEGER NOT NULL DEFAULT 100, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_yield_strategies_vault ON yield_strategies (vault_address); diff --git a/api/src/controllers/yieldOptimizer.controller.ts b/api/src/controllers/yieldOptimizer.controller.ts new file mode 100644 index 00000000..d6290427 --- /dev/null +++ b/api/src/controllers/yieldOptimizer.controller.ts @@ -0,0 +1,129 @@ +import { Request, Response, Router } from 'express'; + +const router = Router(); + +interface YieldVault { + address: string; + shareToken: string; + underlyingAsset: string; + rewardAsset: string; + totalAssets: number; + totalShares: number; + sharePrice: number; + apyBase: number; + apyCompounded: number; + apyBoostBps: number; + lastHarvestedAt: number | null; + createdAt: number; +} + +const vaults: Map = new Map(); + +// POST /api/yield/vault - Create or update vault info +router.post('/vault', (req: Request, res: Response) => { + const { address, shareToken, underlyingAsset, rewardAsset } = req.body; + const vault: YieldVault = { + address, + shareToken, + underlyingAsset, + rewardAsset, + totalAssets: 0, + totalShares: 0, + sharePrice: 1000000000, + apyBase: 0, + apyCompounded: 0, + apyBoostBps: 0, + lastHarvestedAt: null, + createdAt: Math.floor(Date.now() / 1000), + }; + vaults.set(address, vault); + res.status(201).json({ success: true, vault }); +}); + +// GET /api/yield/vault/:address - Get vault details +router.get('/vault/:address', (req: Request, res: Response) => { + const vault = vaults.get(req.params.address); + if (!vault) return res.status(404).json({ error: 'Vault not found' }); + res.json({ vault }); +}); + +// GET /api/yield/vaults - Get all vaults +router.get('/vaults', (req: Request, res: Response) => { + res.json({ vaults: Array.from(vaults.values()) }); +}); + +// POST /api/yield/vault/:address/harvest - Execute harvest/compound +router.post('/vault/:address/harvest', (req: Request, res: Response) => { + const vault = vaults.get(req.params.address); + if (!vault) return res.status(404).json({ error: 'Vault not found' }); + + const { minRewards } = req.body; + const now = Math.floor(Date.now() / 1000); + + // Simulate harvest (in production this calls the contract) + const rewardsClaimed = Math.floor(vault.totalAssets * 0.01); // 1% simulation + const performanceFee = Math.floor(rewardsClaimed * 0.1); // 10% perf fee + const rewardsReinvested = rewardsClaimed - performanceFee; + + vault.totalAssets += rewardsReinvested; + vault.sharePrice = vault.totalShares > 0 + ? Math.floor(vault.totalAssets * 1000000000 / vault.totalShares) + : 1000000000; + vault.lastHarvestedAt = now; + vaults.set(req.params.address, vault); + + res.json({ + success: true, + event: { + rewardsClaimed, + rewardsReinvested, + performanceFee, + newTotalAssets: vault.totalAssets, + timestamp: now, + }, + }); +}); + +// GET /api/yield/vault/:address/preview-deposit +router.get('/vault/:address/preview-deposit', (req: Request, res: Response) => { + const vault = vaults.get(req.params.address); + if (!vault) return res.status(404).json({ error: 'Vault not found' }); + + const amount = parseInt(req.query.amount as string) || 0; + let shares = amount; + if (vault.totalShares > 0 && vault.totalAssets > 0) { + shares = Math.floor(amount * vault.totalShares / vault.totalAssets); + } + res.json({ shares }); +}); + +// GET /api/yield/vault/:address/preview-withdraw +router.get('/vault/:address/preview-withdraw', (req: Request, res: Response) => { + const vault = vaults.get(req.params.address); + if (!vault) return res.status(404).json({ error: 'Vault not found' }); + + const shares = parseInt(req.query.shares as string) || 0; + let assets = 0; + if (vault.totalShares > 0) { + assets = Math.floor(shares * vault.totalAssets / vault.totalShares); + } + res.json({ assets }); +}); + +// POST /api/yield/strategy - Set yield strategy +router.post('/strategy', (req: Request, res: Response) => { + const { vaultAddress, strategyType, targetPools, minApyBps, maxAllocationPct } = req.body; + res.json({ + success: true, + strategy: { + vaultAddress, + strategyType, + targetPools: targetPools || [], + minApyBps: minApyBps || 0, + maxAllocationPct: maxAllocationPct || 100, + isActive: true, + }, + }); +}); + +export default router; diff --git a/api/src/services/yield/auto-compound.service.ts b/api/src/services/yield/auto-compound.service.ts new file mode 100644 index 00000000..2719f654 --- /dev/null +++ b/api/src/services/yield/auto-compound.service.ts @@ -0,0 +1,106 @@ +import axios from 'axios'; + +interface VaultConfig { + performanceFeeBps: number; + managementFeeBps: number; + harvestIntervalSecs: number; + slippageToleranceBps: number; + depositPaused: boolean; + withdrawPaused: boolean; + active: boolean; +} + +interface VaultSnapshot { + totalAssets: number; + totalShares: number; + sharePrice: number; + lastHarvestedAt: number; + accruedManagementFees: number; + accruedPerformanceFees: number; +} + +interface CompoundingEvent { + rewardsClaimed: number; + rewardsReinvested: number; + performanceFee: number; + newTotalAssets: number; + timestamp: number; +} + +type YieldStrategy = 'conservative' | 'moderate' | 'aggressive'; + +export class AutoCompoundService { + private contractEndpoint: string; + + constructor(contractEndpoint: string) { + this.contractEndpoint = contractEndpoint; + } + + async getVaultSnapshot(vault: string): Promise { + const res = await axios.get(`${this.contractEndpoint}/vault/${vault}/snapshot`); + return res.data.snapshot; + } + + async getCompoundSchedule(vault: string): Promise<{ + canCompound: boolean; + nextCompoundAt: number; + estimatedGasStroops: number; + }> { + const res = await axios.get(`${this.contractEndpoint}/vault/${vault}/compound-schedule`); + return res.data; + } + + async executeCompound(vault: string, caller: string, minRewards: number): Promise { + const res = await axios.post(`${this.contractEndpoint}/vault/${vault}/harvest`, { + caller, + minRewards, + }); + return res.data.event; + } + + async previewDeposit(vault: string, amount: number): Promise<{ shares: number }> { + const res = await axios.get(`${this.contractEndpoint}/vault/${vault}/preview-deposit`, { + params: { amount }, + }); + return res.data; + } + + async previewWithdraw(vault: string, shares: number): Promise<{ assets: number }> { + const res = await axios.get(`${this.contractEndpoint}/vault/${vault}/preview-withdraw`, { + params: { shares }, + }); + return res.data; + } + + calculateApyBoost(baseApy: number, compoundFrequency: number, compoundIntervalDays: number): number { + if (baseApy <= 0) return 0; + const ratePerPeriod = baseApy / compoundFrequency; + const compoundedApy = (1 + ratePerPeriod) ** compoundFrequency - 1; + return compoundedApy - baseApy; + } + + estimateOptimalCompoundInterval( + gasCostStroops: number, + rewardAmount: number, + assetPrice: number, + baseApy: number, + ): number { + if (rewardAmount <= 0 || gasCostStroops <= 0) return 86400; // default 1 day + const profitPerCompound = rewardAmount * assetPrice; + const gasCostUsd = gasCostStroops * 0.00001; // approximate + const minProfitRatio = gasCostUsd / profitPerCompound; + if (minProfitRatio <= 0) return 3600; // min 1 hour + const optimalDays = Math.ceil(1 / (minProfitRatio * 365)); + return Math.max(3600, Math.min(604800, optimalDays * 86400)); // between 1 hour and 7 days + } + + selectBestStrategy(poolApys: { pool: string; apy: number; risk: number }[], riskTolerance: 'low' | 'medium' | 'high'): YieldStrategy { + if (riskTolerance === 'low') return 'conservative'; + if (riskTolerance === 'high') return 'aggressive'; + return 'moderate'; + } +} + +export const autoCompoundService = new AutoCompoundService( + process.env.CONTRACT_API_URL || 'http://localhost:3001/api' +); diff --git a/stellar-lend/contracts/auto-compound-vault/src/lib.rs b/stellar-lend/contracts/auto-compound-vault/src/lib.rs index 366386f4..b59371d3 100644 --- a/stellar-lend/contracts/auto-compound-vault/src/lib.rs +++ b/stellar-lend/contracts/auto-compound-vault/src/lib.rs @@ -1,5 +1,5 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractevent, contractimpl, contracttype, token::StellarAssetClient, Address, Env}; +use soroban_sdk::{contract, contracterror, contractevent, contractimpl, contracttype, token::StellarAssetClient, Address, Env, Vec}; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -23,6 +23,18 @@ pub enum VaultError { ShareMintFailed = 16, ShareBurnFailed = 17, NoRewardsToHarvest = 18, + InvalidStrategy = 19, + YieldRouterNotSet = 20, + NoPoolsAvailable = 21, + InsufficientRewardsForGas = 22, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum YieldStrategy { + Conservative, + Moderate, + Aggressive, } #[contracttype] @@ -48,6 +60,32 @@ pub struct VaultSnapshot { pub accrued_performance_fees: i128, } +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct CompoundSchedule { + pub can_compound: bool, + pub next_compound_at: u64, + pub estimated_gas_stroops: u64, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ApySnapshot { + pub apy_base: i128, + pub apy_compounded: i128, + pub apy_boost_bps: i128, + pub tracked_since: u64, + pub compound_count: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PoolApy { + pub pool: Address, + pub apy_bps: u32, + pub risk_score: u32, +} + #[contracttype] #[derive(Clone, Debug)] pub enum DataKey { @@ -68,6 +106,17 @@ pub enum DataKey { MinHarvestInterval, MaxPerformanceFeeBps, MaxManagementFeeBps, + YieldStrategy, + YieldRouter, + PoolApys, + ApyBase, + ApyCompounded, + CompoundFrequency, + ApyHistory, + CompoundSchedule, + LastGasCostStroops, + ApyTrackedSince, + CompoundCount, } #[contractevent] @@ -108,8 +157,40 @@ pub struct FeeCollectedEvent { pub timestamp: u64, } +#[contractevent] +#[derive(Clone, Debug)] +pub struct StrategySetEvent { + pub strategy: YieldStrategy, + pub timestamp: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct ApyUpdatedEvent { + pub apy_base: i128, + pub apy_compounded: i128, + pub apy_boost_bps: i128, + pub timestamp: u64, +} + +#[contractevent] +#[derive(Clone, Debug)] +pub struct AutoCompoundEvent { + pub target_pool: Address, + pub rewards_claimed: i128, + pub rewards_reinvested: i128, + pub performance_fee: i128, + pub new_total_assets: i128, + pub timestamp: u64, +} + const MAX_BPS: u32 = 10_000; const SHARE_PRECISION: i128 = 1_000_000_000; +const DEFAULT_HARVEST_INTERVAL: u64 = 3600; +const MIN_COMPOUND_INTERVAL: u64 = 3600; +const MAX_COMPOUND_INTERVAL: u64 = 604_800; +const BPS_DENOMINATOR: i128 = 10_000; +const DAYS_PER_YEAR: i128 = 365; #[contract] pub struct AutoCompoundVault; @@ -160,7 +241,7 @@ impl AutoCompoundVault { .set(&DataKey::AccruedPerformanceFees, &0i128); env.storage() .instance() - .set(&DataKey::MinHarvestInterval, &3600u64); + .set(&DataKey::MinHarvestInterval, &DEFAULT_HARVEST_INTERVAL); env.storage() .instance() .set(&DataKey::MaxPerformanceFeeBps, &2_000u32); @@ -170,6 +251,27 @@ impl AutoCompoundVault { env.storage() .instance() .set(&DataKey::HarvestCaller, &admin); + env.storage() + .instance() + .set(&DataKey::YieldStrategy, &YieldStrategy::Moderate); + env.storage() + .instance() + .set(&DataKey::ApyBase, &0i128); + env.storage() + .instance() + .set(&DataKey::ApyCompounded, &0i128); + env.storage() + .instance() + .set(&DataKey::CompoundFrequency, &1u32); + env.storage() + .instance() + .set(&DataKey::CompoundCount, &0u32); + env.storage() + .instance() + .set(&DataKey::ApyTrackedSince, &env.ledger().timestamp()); + env.storage() + .instance() + .set(&DataKey::LastGasCostStroops, &0u64); Ok(()) } @@ -206,6 +308,94 @@ impl AutoCompoundVault { .unwrap() } + pub fn set_yield_strategy( + env: Env, + admin: Address, + strategy: YieldStrategy, + ) -> Result<(), VaultError> { + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(VaultError::Unauthorized)?; + if admin != stored_admin { + return Err(VaultError::Unauthorized); + } + admin.require_auth(); + + env.storage() + .instance() + .set(&DataKey::YieldStrategy, &strategy); + + StrategySetEvent { + strategy, + timestamp: env.ledger().timestamp(), + } + .publish(&env); + + Ok(()) + } + + pub fn get_yield_strategy(env: Env) -> YieldStrategy { + env.storage() + .instance() + .get(&DataKey::YieldStrategy) + .unwrap_or(YieldStrategy::Moderate) + } + + pub fn set_yield_router( + env: Env, + admin: Address, + router: Address, + ) -> Result<(), VaultError> { + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(VaultError::Unauthorized)?; + if admin != stored_admin { + return Err(VaultError::Unauthorized); + } + admin.require_auth(); + + env.storage() + .instance() + .set(&DataKey::YieldRouter, &router); + Ok(()) + } + + pub fn get_yield_router(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::YieldRouter) + } + + pub fn set_pool_apys( + env: Env, + admin: Address, + pool_apys: Vec, + ) -> Result<(), VaultError> { + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(VaultError::Unauthorized)?; + if admin != stored_admin { + return Err(VaultError::Unauthorized); + } + admin.require_auth(); + + env.storage() + .instance() + .set(&DataKey::PoolApys, &pool_apys); + Ok(()) + } + + pub fn get_pool_apys(env: Env) -> Vec { + env.storage() + .instance() + .get(&DataKey::PoolApys) + .unwrap_or(Vec::new(&env)) + } + pub fn deposit( env: Env, user: Address, @@ -430,11 +620,7 @@ impl AutoCompoundVault { .get(&DataKey::TotalAssets) .unwrap_or(0); - let rewards_claimed = total_assets - .checked_mul(100) - .ok_or(VaultError::Overflow)? - .checked_div(10_000) - .ok_or(VaultError::Overflow)?; + let rewards_claimed = Self::calculate_dynamic_rewards(&env, total_assets)?; if rewards_claimed < min_rewards { return Err(VaultError::NoRewardsToHarvest); @@ -473,6 +659,17 @@ impl AutoCompoundVault { .instance() .set(&DataKey::AccruedPerformanceFees, &new_accrued); + let compound_count: u32 = env + .storage() + .instance() + .get(&DataKey::CompoundCount) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::CompoundCount, &(compound_count + 1)); + + Self::update_apy_tracking(&env, total_assets, new_total_assets, now); + HarvestEvent { rewards_claimed, rewards_reinvested, @@ -485,6 +682,520 @@ impl AutoCompoundVault { Ok(rewards_reinvested) } + pub fn auto_compound( + env: Env, + caller: Address, + min_rewards: i128, + ) -> Result { + caller.require_auth(); + + let config: VaultConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .ok_or(VaultError::NotInitialized)?; + + if !config.active { + return Err(VaultError::VaultNotActive); + } + + let last_harvest: u64 = env + .storage() + .instance() + .get(&DataKey::LastHarvestedAt) + .unwrap_or(0); + let now = env.ledger().timestamp(); + + if last_harvest > 0 && (now - last_harvest) < config.harvest_interval_secs { + return Err(VaultError::MinHarvestIntervalNotMet); + } + + let target_pool = Self::select_best_pool(&env)?; + + let total_assets: i128 = env + .storage() + .instance() + .get(&DataKey::TotalAssets) + .unwrap_or(0); + + let rewards_claimed = Self::calculate_dynamic_rewards(&env, total_assets)?; + + if rewards_claimed < min_rewards { + return Err(VaultError::NoRewardsToHarvest); + } + + let gas_cost = Self::estimate_gas_cost(&env, &config); + let rewards_after_gas = rewards_claimed + .checked_sub(gas_cost as i128) + .ok_or(VaultError::InsufficientRewardsForGas)?; + + if rewards_after_gas <= 0 { + return Err(VaultError::InsufficientRewardsForGas); + } + + let performance_fee = rewards_after_gas + .checked_mul(config.performance_fee_bps as i128) + .ok_or(VaultError::Overflow)? + .checked_div(MAX_BPS as i128) + .ok_or(VaultError::Overflow)?; + + let rewards_reinvested = rewards_after_gas + .checked_sub(performance_fee) + .ok_or(VaultError::Overflow)?; + + let new_total_assets = total_assets + .checked_add(rewards_reinvested) + .ok_or(VaultError::Overflow)?; + + let accrued_perf_fees: i128 = env + .storage() + .instance() + .get(&DataKey::AccruedPerformanceFees) + .unwrap_or(0); + let new_accrued = accrued_perf_fees + .checked_add(performance_fee) + .ok_or(VaultError::Overflow)?; + + env.storage() + .instance() + .set(&DataKey::TotalAssets, &new_total_assets); + env.storage() + .instance() + .set(&DataKey::LastHarvestedAt, &now); + env.storage() + .instance() + .set(&DataKey::AccruedPerformanceFees, &new_accrued); + env.storage() + .instance() + .set(&DataKey::LastGasCostStroops, &(gas_cost as u64)); + + let compound_count: u32 = env + .storage() + .instance() + .get(&DataKey::CompoundCount) + .unwrap_or(0); + env.storage() + .instance() + .set(&DataKey::CompoundCount, &(compound_count + 1)); + + Self::update_apy_tracking(&env, total_assets, new_total_assets, now); + + AutoCompoundEvent { + target_pool, + rewards_claimed, + rewards_reinvested, + performance_fee, + new_total_assets, + timestamp: now, + } + .publish(&env); + + Ok(rewards_reinvested) + } + + pub fn calculate_dynamic_rewards(env: &Env, total_assets: i128) -> Result { + let pool_apys: Vec = env + .storage() + .instance() + .get(&DataKey::PoolApys) + .unwrap_or(Vec::new(env)); + + let strategy: YieldStrategy = env + .storage() + .instance() + .get(&DataKey::YieldStrategy) + .unwrap_or(YieldStrategy::Moderate); + + let apy_bps = if pool_apys.is_empty() { + match strategy { + YieldStrategy::Conservative => 200u32, + YieldStrategy::Moderate => 500u32, + YieldStrategy::Aggressive => 1000u32, + } + } else { + match strategy { + YieldStrategy::Conservative => { + let mut min_risk = u32::MAX; + let mut best_apy = 0u32; + for p in pool_apys.iter() { + if p.risk_score < min_risk || (p.risk_score == min_risk && p.apy_bps > best_apy) { + min_risk = p.risk_score; + best_apy = p.apy_bps; + } + } + best_apy + } + YieldStrategy::Moderate => { + let mut best = 0u32; + for p in pool_apys.iter() { + if p.risk_score <= 500 && p.apy_bps > best { + best = p.apy_bps; + } + } + if best == 0 { + let mut avg = 0u64; + let mut count = 0u64; + for p in pool_apys.iter() { + avg += p.apy_bps as u64; + count += 1; + } + if count > 0 { (avg / count) as u32 } else { 500 } + } else { + best + } + } + YieldStrategy::Aggressive => { + let mut best = 0u32; + for p in pool_apys.iter() { + if p.apy_bps > best { + best = p.apy_bps; + } + } + best + } + } + }; + + let estimated_yield = total_assets + .checked_mul(apy_bps as i128) + .ok_or(VaultError::Overflow)? + .checked_mul(Self::get_harvest_interval_days(env)) + .ok_or(VaultError::Overflow)? + .checked_div(DAYS_PER_YEAR) + .ok_or(VaultError::Overflow)? + .checked_div(BPS_DENOMINATOR) + .ok_or(VaultError::Overflow)?; + + if estimated_yield < 1 { + Ok(total_assets + .checked_mul(100) + .ok_or(VaultError::Overflow)? + .checked_div(10_000) + .ok_or(VaultError::Overflow)?) + } else { + Ok(estimated_yield) + } + } + + pub fn select_best_pool(env: &Env) -> Result { + let pool_apys: Vec = env + .storage() + .instance() + .get(&DataKey::PoolApys) + .ok_or(VaultError::NoPoolsAvailable)?; + + if pool_apys.is_empty() { + return Err(VaultError::NoPoolsAvailable); + } + + let strategy: YieldStrategy = env + .storage() + .instance() + .get(&DataKey::YieldStrategy) + .unwrap_or(YieldStrategy::Moderate); + + let best = match strategy { + YieldStrategy::Conservative => { + let mut selected = pool_apys.first().unwrap(); + for p in pool_apys.iter() { + if p.risk_score < selected.risk_score + || (p.risk_score == selected.risk_score && p.apy_bps > selected.apy_bps) + { + selected = p; + } + } + selected + } + YieldStrategy::Moderate => { + let mut selected = pool_apys.first().unwrap(); + for p in pool_apys.iter() { + if p.risk_score <= 500 && p.apy_bps > selected.apy_bps { + selected = p; + } + } + if selected.apy_bps == pool_apys.first().unwrap().apy_bps && pool_apys.len() > 1 { + let mut best_apy = pool_apys.first().unwrap(); + for p in pool_apys.iter() { + if p.apy_bps > best_apy.apy_bps { + best_apy = p; + } + } + selected = best_apy; + } + selected + } + YieldStrategy::Aggressive => { + let mut selected = pool_apys.first().unwrap(); + for p in pool_apys.iter() { + if p.apy_bps > selected.apy_bps { + selected = p; + } + } + selected + } + }; + + Ok(best.pool) + } + + pub fn estimate_gas_cost(env: &Env, config: &VaultConfig) -> i128 { + let last_gas: u64 = env + .storage() + .instance() + .get(&DataKey::LastGasCostStroops) + .unwrap_or(50_000); + + let base_cost = 30_000i128; + let harvest_cost = 20_000i128; + let reinvest_cost = 15_000i128; + let strategy_overhead = match Self::get_yield_strategy(env.clone()) { + YieldStrategy::Conservative => 5_000i128, + YieldStrategy::Moderate => 10_000i128, + YieldStrategy::Aggressive => 15_000i128, + }; + + let estimated = base_cost + + harvest_cost + + reinvest_cost + + strategy_overhead + + (last_gas as i128); + + estimated.checked_div(2).unwrap_or(50_000) + } + + pub fn estimate_optimal_interval(env: &Env) -> u64 { + let last_gas: u64 = env + .storage() + .instance() + .get(&DataKey::LastGasCostStroops) + .unwrap_or(50_000); + + let total_assets: i128 = env + .storage() + .instance() + .get(&DataKey::TotalAssets) + .unwrap_or(0); + + if total_assets <= 0 || last_gas == 0 { + return DEFAULT_HARVEST_INTERVAL; + } + + let gas_cost_i128 = last_gas as i128; + let min_profit_ratio = if total_assets > 0 { + gas_cost_i128 + .checked_mul(BPS_DENOMINATOR) + .unwrap_or(BPS_DENOMINATOR) + .checked_div(total_assets) + .unwrap_or(100) + } else { + 100 + }; + + if min_profit_ratio <= 0 { + return MIN_COMPOUND_INTERVAL; + } + + let optimal_days = (BPS_DENOMINATOR as u64) + .checked_div(min_profit_ratio as u64) + .unwrap_or(1) + .checked_mul(DAYS_PER_YEAR as u64) + .unwrap_or(365) + .checked_div(365) + .unwrap_or(1); + + let interval_secs = optimal_days + .checked_mul(86400) + .unwrap_or(DEFAULT_HARVEST_INTERVAL); + + if interval_secs < MIN_COMPOUND_INTERVAL { + MIN_COMPOUND_INTERVAL + } else if interval_secs > MAX_COMPOUND_INTERVAL { + MAX_COMPOUND_INTERVAL + } else { + interval_secs + } + } + + fn get_harvest_interval_days(env: &Env) -> i128 { + let config: VaultConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .unwrap(); + (config.harvest_interval_secs as i128) + .checked_div(86400) + .unwrap_or(1) + .max(1) + } + + pub fn get_compound_schedule(env: Env) -> CompoundSchedule { + let config: VaultConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .unwrap(); + + let last_harvest: u64 = env + .storage() + .instance() + .get(&DataKey::LastHarvestedAt) + .unwrap_or(0); + let now = env.ledger().timestamp(); + + let elapsed = if last_harvest > 0 { now - last_harvest } else { u64::MAX }; + let can_compound = last_harvest == 0 || elapsed >= config.harvest_interval_secs; + + let next_compound_at = if last_harvest > 0 { + last_harvest + config.harvest_interval_secs + } else { + now + }; + + let estimated_gas = Self::estimate_gas_cost(&env, &config) as u64; + + CompoundSchedule { + can_compound, + next_compound_at, + estimated_gas_stroops: estimated_gas, + } + } + + pub fn get_apy_snapshot(env: Env) -> ApySnapshot { + let apy_base: i128 = env + .storage() + .instance() + .get(&DataKey::ApyBase) + .unwrap_or(0); + let apy_compounded: i128 = env + .storage() + .instance() + .get(&DataKey::ApyCompounded) + .unwrap_or(0); + let tracked_since: u64 = env + .storage() + .instance() + .get(&DataKey::ApyTrackedSince) + .unwrap_or(0); + let compound_count: u32 = env + .storage() + .instance() + .get(&DataKey::CompoundCount) + .unwrap_or(0); + + let apy_boost_bps = if apy_base > 0 && apy_compounded > apy_base { + apy_compounded + .checked_sub(apy_base) + .unwrap_or(0) + } else { + 0 + }; + + ApySnapshot { + apy_base, + apy_compounded, + apy_boost_bps, + tracked_since, + compound_count, + } + } + + fn update_apy_tracking(env: &Env, total_assets_before: i128, total_assets_after: i128, now: u64) { + if total_assets_before <= 0 { + return; + } + + let growth = total_assets_after + .checked_sub(total_assets_before) + .unwrap_or(0); + + if growth <= 0 { + return; + } + + let growth_rate_bps = growth + .checked_mul(BPS_DENOMINATOR) + .unwrap_or(0) + .checked_div(total_assets_before) + .unwrap_or(0); + + let config: VaultConfig = env + .storage() + .instance() + .get(&DataKey::Config) + .unwrap_or(VaultConfig { + performance_fee_bps: 0, + management_fee_bps: 0, + harvest_interval_secs: 86400, + slippage_tolerance_bps: 100, + deposit_paused: false, + withdraw_paused: false, + active: true, + }); + + let compounds_per_year = if config.harvest_interval_secs > 0 { + (DAYS_PER_YEAR as u64) + .checked_mul(86400) + .unwrap_or(31_536_000) + .checked_div(config.harvest_interval_secs) + .unwrap_or(365) as i128 + } else { + 365 + }; + + let apy_base = growth_rate_bps + .checked_mul(compounds_per_year) + .unwrap_or(0); + + let compound_freq: u32 = env + .storage() + .instance() + .get(&DataKey::CompoundFrequency) + .unwrap_or(1); + + let rate_per_period = growth_rate_bps + .checked_div(compound_freq as i128) + .unwrap_or(0); + let one_bps = BPS_DENOMINATOR; + + let compounded_growth = if rate_per_period > 0 { + let mut result = one_bps; + let mut i = 0u32; + while i < compound_freq { + result = result + .checked_mul(one_bps + rate_per_period) + .unwrap_or(result) + .checked_div(one_bps) + .unwrap_or(result); + i += 1; + } + result + .checked_sub(one_bps) + .unwrap_or(0) + } else { + 0 + }; + + let apy_compounded = apy_base + compounded_growth; + + env.storage() + .instance() + .set(&DataKey::ApyBase, &apy_base); + env.storage() + .instance() + .set(&DataKey::ApyCompounded, &apy_compounded); + env.storage() + .instance() + .set(&DataKey::CompoundFrequency, &(compounds_per_year as u32)); + + ApyUpdatedEvent { + apy_base, + apy_compounded, + apy_boost_bps: apy_compounded.checked_sub(apy_base).unwrap_or(0), + timestamp: now, + } + .publish(env); + } + pub fn preview_deposit(env: Env, amount: i128) -> Result { if amount <= 0 { return Err(VaultError::InvalidAmount); diff --git a/stellar-lend/contracts/auto-compound-vault/src/test.rs b/stellar-lend/contracts/auto-compound-vault/src/test.rs index a7cda63a..85b27cf4 100644 --- a/stellar-lend/contracts/auto-compound-vault/src/test.rs +++ b/stellar-lend/contracts/auto-compound-vault/src/test.rs @@ -1,6 +1,9 @@ #![cfg(test)] -use crate::{AutoCompoundVault, AutoCompoundVaultClient, VaultConfig, VaultError}; -use soroban_sdk::{testutils::Address as _, Address, Env}; +use crate::{ + AutoCompoundVault, AutoCompoundVaultClient, CompoundSchedule, PoolApy, VaultConfig, VaultError, + YieldStrategy, +}; +use soroban_sdk::{testutils::Address as _, vec, Address, Env, Vec}; fn setup() -> (Env, Address, Address, AutoCompoundVaultClient<'static>) { let env = Env::default(); @@ -35,6 +38,9 @@ fn test_initialize() { assert!(!config.deposit_paused); assert!(!config.withdraw_paused); assert!(config.active); + + let strategy = client.get_yield_strategy(); + assert_eq!(strategy, YieldStrategy::Moderate); } #[test] @@ -132,3 +138,252 @@ fn test_invalid_config_fees() { let result = client.try_set_config(&admin, &bad_config); assert_eq!(result, Err(Ok(VaultError::PerformanceFeeExceedsMax))); } + +#[test] +fn test_set_yield_strategy() { + let (_env, admin, _share_token, client) = setup(); + + client.set_yield_strategy(&admin, &YieldStrategy::Conservative); + let strategy = client.get_yield_strategy(); + assert_eq!(strategy, YieldStrategy::Conservative); + + client.set_yield_strategy(&admin, &YieldStrategy::Aggressive); + let strategy = client.get_yield_strategy(); + assert_eq!(strategy, YieldStrategy::Aggressive); + + client.set_yield_strategy(&admin, &YieldStrategy::Moderate); + let strategy = client.get_yield_strategy(); + assert_eq!(strategy, YieldStrategy::Moderate); +} + +#[test] +fn test_set_yield_strategy_unauthorized() { + let (env, _admin, _share_token, client) = setup(); + env.mock_all_auths_allowing_non_root_auth(); + let hacker = Address::generate(&env); + + let result = client.try_set_yield_strategy(&hacker, &YieldStrategy::Conservative); + assert_eq!(result, Err(Ok(VaultError::Unauthorized))); +} + +#[test] +fn test_set_pool_apys() { + let (env, admin, _share_token, client) = setup(); + let pool1 = Address::generate(&env); + let pool2 = Address::generate(&env); + let pool3 = Address::generate(&env); + + let pool_apys: Vec = vec![ + &env, + PoolApy { + pool: pool1.clone(), + apy_bps: 500, + risk_score: 100, + }, + PoolApy { + pool: pool2.clone(), + apy_bps: 800, + risk_score: 300, + }, + PoolApy { + pool: pool3.clone(), + apy_bps: 1200, + risk_score: 700, + }, + ]; + + client.set_pool_apys(&admin, &pool_apys); + let stored = client.get_pool_apys(); + assert_eq!(stored.len(), 3); + assert_eq!(stored.get(0).unwrap().apy_bps, 500); + assert_eq!(stored.get(2).unwrap().apy_bps, 1200); +} + +#[test] +fn test_select_best_pool_conservative() { + let (env, admin, _share_token, client) = setup(); + let pool1 = Address::generate(&env); + let pool2 = Address::generate(&env); + let pool3 = Address::generate(&env); + + let pool_apys: Vec = vec![ + &env, + PoolApy { + pool: pool1.clone(), + apy_bps: 1000, + risk_score: 700, + }, + PoolApy { + pool: pool2.clone(), + apy_bps: 500, + risk_score: 100, + }, + PoolApy { + pool: pool3.clone(), + apy_bps: 300, + risk_score: 50, + }, + ]; + + client.set_pool_apys(&admin, &pool_apys); + client.set_yield_strategy(&admin, &YieldStrategy::Conservative); + + let result = client.select_best_pool(); + assert_eq!(result, pool2); +} + +#[test] +fn test_select_best_pool_aggressive() { + let (env, admin, _share_token, client) = setup(); + let pool1 = Address::generate(&env); + let pool2 = Address::generate(&env); + let pool3 = Address::generate(&env); + + let pool_apys: Vec = vec![ + &env, + PoolApy { + pool: pool1.clone(), + apy_bps: 1000, + risk_score: 700, + }, + PoolApy { + pool: pool2.clone(), + apy_bps: 500, + risk_score: 100, + }, + PoolApy { + pool: pool3.clone(), + apy_bps: 1500, + risk_score: 900, + }, + ]; + + client.set_pool_apys(&admin, &pool_apys); + client.set_yield_strategy(&admin, &YieldStrategy::Aggressive); + + let result = client.select_best_pool(); + assert_eq!(result, pool3); +} + +#[test] +fn test_compound_schedule_initial() { + let (env, _admin, _share_token, client) = setup(); + let schedule = client.get_compound_schedule(); + assert!(schedule.can_compound); + assert!(schedule.estimated_gas_stroops > 0); +} + +#[test] +fn test_apy_snapshot_initial() { + let (_env, _admin, _share_token, client) = setup(); + let snapshot = client.get_apy_snapshot(); + assert_eq!(snapshot.apy_base, 0); + assert_eq!(snapshot.apy_compounded, 0); + assert_eq!(snapshot.apy_boost_bps, 0); + assert_eq!(snapshot.compound_count, 0); +} + +#[test] +fn test_deposit_and_withdraw() { + let (_env, _admin, _share_token, client) = setup(); + let user = Address::generate(&_env); + + let shares = client.deposit(&user, &1000, &0); + assert_eq!(shares, 1000); + + let price = client.get_share_price(); + assert_eq!(price, 1_000_000_000); + + let assets = client.withdraw(&user, &500, &0); + assert_eq!(assets, 500); +} + +#[test] +fn test_deposit_min_shares_slippage() { + let (_env, _admin, _share_token, client) = setup(); + let user = Address::generate(&_env); + + let result = client.try_deposit(&user, &1000, &1001); + assert_eq!(result, Err(Ok(VaultError::SlippageExceeded))); +} + +#[test] +fn test_withdraw_excessive_shares() { + let (_env, _admin, _share_token, client) = setup(); + let user = Address::generate(&_env); + + let result = client.try_withdraw(&user, &1, &0); + assert_eq!(result, Err(Ok(VaultError::InsufficientShares))); +} + +#[test] +fn test_harvest_with_rewards() { + let (_env, _admin, _share_token, client) = setup(); + let user = Address::generate(&_env); + let caller = Address::generate(&_env); + + client.deposit(&user, &1_000_000, &0); + + let rewards = client.harvest(&caller, &0); + assert!(rewards > 0); + + let snapshot = client.get_vault_snapshot(); + assert!(snapshot.total_assets > 1_000_000); +} + +#[test] +fn test_auto_compound_no_pools() { + let (_env, _admin, _share_token, client) = setup(); + let caller = Address::generate(&_env); + + let result = client.try_auto_compound(&caller, &0); + assert_eq!(result, Err(Ok(VaultError::NoPoolsAvailable))); +} + +#[test] +fn test_auto_compound_with_pools() { + let (env, admin, _share_token, client) = setup(); + let user = Address::generate(&_env); + let caller = Address::generate(&_env); + let pool = Address::generate(&_env); + + let pool_apys: Vec = vec![ + &env, + PoolApy { + pool: pool.clone(), + apy_bps: 800, + risk_score: 300, + }, + ]; + + client.set_pool_apys(&admin, &pool_apys); + client.deposit(&user, &10_000_000, &0); + + let rewards = client.auto_compound(&caller, &0); + assert!(rewards > 0); +} + +#[test] +fn test_get_yield_router_not_set() { + let (_env, _admin, _share_token, client) = setup(); + let router = client.get_yield_router(); + assert!(router.is_none()); +} + +#[test] +fn test_set_yield_router() { + let (env, admin, _share_token, client) = setup(); + let router = Address::generate(&env); + + client.set_yield_router(&admin, &router); + let stored = client.get_yield_router(); + assert_eq!(stored.unwrap(), router); +} + +#[test] +fn test_estimate_optimal_interval() { + let (_env, _admin, _share_token, client) = setup(); + let interval = client.estimate_optimal_interval(); + assert!(interval >= 3600); + assert!(interval <= 604800); +}