diff --git a/Cargo.toml b/Cargo.toml index 553a8e79..7137bcd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,8 +8,6 @@ members = [ exclude = [ "contracts/compound_fees", "contracts/flash_loan_guard", - "contracts/yield_vault", - ] default-members = ["contracts/*"] diff --git a/contracts/yield_vault/Cargo.toml b/contracts/yield_vault/Cargo.toml index 64603ea4..addf4776 100644 --- a/contracts/yield_vault/Cargo.toml +++ b/contracts/yield_vault/Cargo.toml @@ -3,16 +3,24 @@ name = "bc-forge-yield-vault" version = "0.1.0" edition = "2021" publish = false -description = "Yield vault helpers for slippage and share-balance guards" +description = "Yield vault for bc-forge contracts — deposit/withdraw with guards and rate limiting" license = "MIT" repository = "https://github.com/BCPathway/bc-forge" homepage = "https://github.com/BCPathway/bc-forge" [lib] -crate-type = ["rlib"] +crate-type = ["cdylib", "rlib"] [dependencies] soroban-sdk = "22.0.11" +bc-forge-admin = { path = "../admin" } +bc-forge-lifecycle = { path = "../lifecycle" } +bc-forge-rate-limit = { path = "../rate-limit" } +bc-forge-ttl = { path = "../ttl" } [dev-dependencies] soroban-sdk = { version = "22.0.11", features = ["testutils"] } +bc-forge-token = { path = "../token", features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/yield_vault/src/events.rs b/contracts/yield_vault/src/events.rs new file mode 100644 index 00000000..13b565da --- /dev/null +++ b/contracts/yield_vault/src/events.rs @@ -0,0 +1,35 @@ +//! # bc-forge Yield Vault Events +//! +//! Structured event emission for all yield vault operations. + +use soroban_sdk::{symbol_short, Address, Env}; + +/// Emitted when the yield vault is initialized. +pub fn emit_initialized(env: &Env, admin: &Address, token: &Address) { + env.events() + .publish((symbol_short!("init"),), (admin.clone(), token.clone())); +} + +/// Emitted when assets are deposited and shares are minted. +pub fn emit_deposit(env: &Env, caller: &Address, assets: i128, shares: i128) { + env.events().publish( + (symbol_short!("deposit"),), + (caller.clone(), assets, shares), + ); +} + +/// Emitted when shares are withdrawn for underlying tokens. +pub fn emit_withdraw(env: &Env, caller: &Address, shares: i128, tokens_out: i128) { + env.events().publish( + (symbol_short!("withdraw"),), + (caller.clone(), shares, tokens_out), + ); +} + +/// Emitted when non-underlying tokens are rescued by an admin. +pub fn emit_rescue_tokens(env: &Env, admin: &Address, token: &Address, to: &Address, amount: i128) { + env.events().publish( + (symbol_short!("rescue"),), + (admin.clone(), token.clone(), to.clone(), amount), + ); +} diff --git a/contracts/yield_vault/src/lib.rs b/contracts/yield_vault/src/lib.rs index 7fc17ab3..8d24cce9 100644 --- a/contracts/yield_vault/src/lib.rs +++ b/contracts/yield_vault/src/lib.rs @@ -1,54 +1,367 @@ +//! # bc-forge Yield Vault Contract +//! +//! A yield-bearing vault that accepts SEP-41 underlying token deposits in +//! exchange for proportional vault shares. Implements three guard layers: +//! +//! - **Rate-limit guard** (#732): hooks into the `bc_forge_rate_limit` module +//! to throttle deposit frequency and prevent whale manipulation. +//! - **Pause guard** (#733): allows a Pauser-role address to halt incoming +//! deposits while keeping withdrawals always active so users can exit. +//! - **Rescue tokens** (#734): admin-only function to recover non-underlying +//! tokens accidentally sent to the vault; reverts if the requested token is +//! the vault's core underlying asset. +//! +//! Share math follows the same pro-rata formula used by the wrapper contract: +//! +//! ```text +//! shares_out = assets * total_shares / total_assets (post-bootstrap) +//! shares_out = assets (first deposit: 1:1) +//! ``` +//! +//! Rounding is always in favour of the protocol (floor division). + #![no_std] -use soroban_sdk::{contract, contractimpl, Address, Env}; +mod events; + +#[cfg(test)] +mod test; + +use bc_forge_rate_limit::BcForgeRateLimit; +use soroban_sdk::token::TokenClient; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, String}; + +// ─── Storage ───────────────────────────────────────────────────────────────── + +#[derive(Clone)] +#[contracttype] +pub enum DataKey { + /// Whether the contract has been initialized (stores admin address). + Admin, + /// The underlying SEP-41 token this vault wraps. + UnderlyingToken, + /// Total vault share supply in circulation. + Supply, + /// Per-user share balance. + Balance(Address), +} + +// ─── Errors ────────────────────────────────────────────────────────────────── + +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[contracterror] +#[repr(u32)] +pub enum VaultError { + /// Contract has already been initialized. + AlreadyInitialized = 1, + /// Contract has not been initialized. + NotInitialized = 2, + /// Amount is non-positive or math overflow occurred. + InvalidAmount = 3, + /// Caller balance is insufficient for the requested withdrawal. + InsufficientBalance = 4, + /// Contract is paused; deposits are blocked. + ContractPaused = 5, + /// The requested rescue token is the vault's underlying token — not allowed. + CannotRescueUnderlying = 6, + /// Rate limit check failed for this deposit. + RateLimited = 7, +} + +// ─── Contract ──────────────────────────────────────────────────────────────── #[contract] pub struct YieldVaultContract; +impl YieldVaultContract { + // ── Guards ─────────────────────────────────────────────────────────────── + + fn ensure_initialized(env: &Env) -> Result<(), VaultError> { + if bc_forge_admin::has_admin(env) { + Ok(()) + } else { + Err(VaultError::NotInitialized) + } + } + + /// #733 – pause guard: blocks deposits when the contract is paused. + /// + /// Withdrawals bypass this guard so users can always exit. + fn ensure_not_paused(env: &Env) -> Result<(), VaultError> { + if bc_forge_lifecycle::is_paused(env) { + Err(VaultError::ContractPaused) + } else { + Ok(()) + } + } + + /// #732 – rate-limit guard: enforces the global per-address deposit rate + /// limit via the `bc_forge_rate_limit` module. + /// + /// Returns `Ok(())` when the deposit is within limits and `Err(RateLimited)` + /// when the caller has exceeded the configured threshold. + fn rate_limit_deposits(env: &Env, caller: &Address, amount: i128) -> Result<(), VaultError> { + let amount_u64 = if amount < 0 { 0 } else { amount as u64 }; + let op = String::from_str(env, "deposit"); + if BcForgeRateLimit::internal_check_rate_limit(env, Some(caller), &op, amount_u64) { + Ok(()) + } else { + Err(VaultError::RateLimited) + } + } + + fn read_underlying(env: &Env) -> Address { + env.storage() + .instance() + .get(&DataKey::UnderlyingToken) + .expect("underlying token not set") + } + + fn read_supply(env: &Env) -> i128 { + env.storage().instance().get(&DataKey::Supply).unwrap_or(0) + } + + fn write_supply(env: &Env, supply: i128) { + env.storage().instance().set(&DataKey::Supply, &supply); + } + + fn read_balance(env: &Env, id: &Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::Balance(id.clone())) + .unwrap_or(0) + } + + fn write_balance(env: &Env, id: &Address, balance: i128) { + env.storage() + .persistent() + .set(&DataKey::Balance(id.clone()), &balance); + } + + fn read_total_assets(env: &Env) -> i128 { + let underlying_id = Self::read_underlying(env); + let client = TokenClient::new(env, &underlying_id); + client.balance(&env.current_contract_address()) + } +} + +// ─── Public Interface ───────────────────────────────────────────────────────── + #[contractimpl] impl YieldVaultContract { - pub fn deposit(env: Env, user: Address, token_amount: i128, min_shares_out: i128) -> i128 { - user.require_auth(); + /// Initialize the vault with an admin and underlying token. + /// + /// Can only be called once (by the deployer contract address). + pub fn initialize( + env: Env, + admin: Address, + token_contract_id: Address, + ) -> Result<(), VaultError> { + env.current_contract_address().require_auth(); - let shares_out = Self::calculate_shares_out(&env, token_amount); + if bc_forge_admin::has_admin(&env) { + return Err(VaultError::AlreadyInitialized); + } + + bc_forge_admin::set_admin(&env, &admin); + env.storage() + .instance() + .set(&DataKey::UnderlyingToken, &token_contract_id); + Self::write_supply(&env, 0); + + events::emit_initialized(&env, &admin, &token_contract_id); + Ok(()) + } + + /// Deposit `assets` of the underlying token and receive proportional vault shares. + /// + /// # Guards applied (in order) + /// 1. **Pause** (#733): reverts with [`VaultError::ContractPaused`] when the + /// vault is paused. Withdrawals are unaffected. + /// 2. **Rate-limit** (#732): reverts with [`VaultError::RateLimited`] when the + /// caller exceeds the configured per-address deposit rate. + /// + /// # Share formula + /// ```text + /// shares_out = assets (first deposit) + /// shares_out = assets * total_shares / total_assets (subsequent) + /// ``` + /// + /// Rounding is floor (in favour of the protocol). Reverts if `shares_out` + /// would round to zero. + pub fn deposit( + env: Env, + caller: Address, + assets: i128, + min_shares_out: i128, + ) -> Result { + Self::ensure_initialized(&env)?; + // #733 – pause guard on deposit only; withdraw is always open. + Self::ensure_not_paused(&env)?; + caller.require_auth(); + + if assets <= 0 { + return Err(VaultError::InvalidAmount); + } + + // #732 – rate-limit guard prevents whale manipulation. + Self::rate_limit_deposits(&env, &caller, assets)?; + + let underlying_id = Self::read_underlying(&env); + let underlying_client = TokenClient::new(&env, &underlying_id); + + // Pull underlying tokens from caller into this contract. + underlying_client.transfer_from( + &env.current_contract_address(), + &caller, + &env.current_contract_address(), + &assets, + ); + + // Calculate shares to mint. + let total_shares = Self::read_supply(&env); + let shares_out: i128 = if total_shares == 0 { + // First deposit — bootstrap 1:1. + assets + } else { + // total_assets now includes the freshly transferred tokens; + // subtract them back to get the pre-deposit asset total. + let total_assets_after = underlying_client.balance(&env.current_contract_address()); + let total_assets_before = total_assets_after.checked_sub(assets).unwrap_or(0); + + if total_assets_before <= 0 { + // Vault had zero assets but nonzero shares — treat as first deposit. + assets + } else { + assets + .checked_mul(total_shares) + .and_then(|p| p.checked_div(total_assets_before)) + .ok_or(VaultError::InvalidAmount)? + } + }; + + if shares_out <= 0 { + return Err(VaultError::InvalidAmount); + } if shares_out < min_shares_out { - panic!("SlippageExceeded: minted shares are less than min_shares_out"); + return Err(VaultError::InvalidAmount); } - shares_out + // Mint shares to caller. + Self::write_balance( + &env, + &caller, + Self::read_balance(&env, &caller) + shares_out, + ); + Self::write_supply(&env, total_shares + shares_out); + + events::emit_deposit(&env, &caller, assets, shares_out); + Ok(shares_out) } - pub fn withdraw(env: Env, user: Address, shares_in: i128, min_tokens_out: i128) -> i128 { - user.require_auth(); + /// Withdraw `shares` and receive a proportional amount of underlying tokens. + /// + /// **Not affected by the pause guard** — users can always exit (#733). + pub fn withdraw( + env: Env, + caller: Address, + shares: i128, + min_tokens_out: i128, + ) -> Result { + Self::ensure_initialized(&env)?; + caller.require_auth(); + + if shares <= 0 { + return Err(VaultError::InvalidAmount); + } + + let balance = Self::read_balance(&env, &caller); + if balance < shares { + return Err(VaultError::InsufficientBalance); + } + + let underlying_id = Self::read_underlying(&env); + let underlying_client = TokenClient::new(&env, &underlying_id); + + let total_shares = Self::read_supply(&env); + let total_assets = underlying_client.balance(&env.current_contract_address()); + let tokens_out = shares + .checked_mul(total_assets) + .and_then(|p| p.checked_div(total_shares)) + .ok_or(VaultError::InvalidAmount)?; - let user_balance = Self::get_share_balance(&env, &user); - if shares_in > user_balance { - panic!("InsufficientShares: requested shares exceed user balance"); + if tokens_out <= 0 { + return Err(VaultError::InvalidAmount); } - let tokens_out = Self::calculate_tokens_out(&env, shares_in); if tokens_out < min_tokens_out { - panic!("SlippageExceeded: returned tokens are less than min_tokens_out"); + return Err(VaultError::InvalidAmount); } - tokens_out - } + // Burn shares. + Self::write_balance(&env, &caller, balance - shares); + Self::write_supply(&env, total_shares - shares); + + // Transfer underlying tokens to caller. + underlying_client.transfer(&env.current_contract_address(), &caller, &tokens_out); - fn calculate_shares_out(_env: &Env, token_amount: i128) -> i128 { - token_amount + events::emit_withdraw(&env, &caller, shares, tokens_out); + Ok(tokens_out) } - fn calculate_tokens_out(_env: &Env, shares_in: i128) -> i128 { - shares_in + /// #734 – Rescue non-underlying tokens accidentally sent to the vault. + /// + /// Admin-only. Transfers `amount` of `token` held by the vault to `to`. + /// Reverts with [`VaultError::CannotRescueUnderlying`] if `token` matches + /// the vault's core underlying asset — those tokens belong to depositors. + pub fn rescue_tokens( + env: Env, + admin: Address, + token: Address, + to: Address, + amount: i128, + ) -> Result<(), VaultError> { + Self::ensure_initialized(&env)?; + bc_forge_admin::require_admin(&env, &admin); + + let underlying = Self::read_underlying(&env); + if token == underlying { + return Err(VaultError::CannotRescueUnderlying); + } + + if amount <= 0 { + return Err(VaultError::InvalidAmount); + } + + let token_client = TokenClient::new(&env, &token); + token_client.transfer(&env.current_contract_address(), &to, &amount); + + events::emit_rescue_tokens(&env, &admin, &token, &to, amount); + Ok(()) } + /// Returns the underlying SEP-41 token address. + pub fn underlying_token(env: Env) -> Result { + Self::ensure_initialized(&env)?; + Ok(Self::read_underlying(&env)) + } - fn get_share_balance(_env: &Env, _user: &Address) -> i128 { - 1000 + /// Returns the total vault share supply in circulation. + pub fn supply(env: Env) -> Result { + Self::ensure_initialized(&env)?; + Ok(Self::read_supply(&env)) } -} -#[cfg(test)] -mod test; + /// Returns `user`'s vault share balance. + pub fn share_balance(env: Env, user: Address) -> Result { + Self::ensure_initialized(&env)?; + Ok(Self::read_balance(&env, &user)) + } + /// Returns the total underlying token assets held by the vault. + pub fn total_assets(env: Env) -> Result { + Self::ensure_initialized(&env)?; + Ok(Self::read_total_assets(&env)) + } +} diff --git a/contracts/yield_vault/src/test.rs b/contracts/yield_vault/src/test.rs index f979a6c3..a4d33be0 100644 --- a/contracts/yield_vault/src/test.rs +++ b/contracts/yield_vault/src/test.rs @@ -1,74 +1,715 @@ -use super::*; +//! # bc-forge Yield Vault Tests +//! +//! Covers issues #732 (rate-limit deposits), #733 (pause vault deposits), +//! #734 (rescue stuck funds), and #735 (deposit-to-mint ratio). + +use crate::{VaultError, YieldVaultContract, YieldVaultContractClient}; +use bc_forge_token::{BcForgeToken, BcForgeTokenClient}; use soroban_sdk::testutils::Address as _; -use soroban_sdk::{Address, Env}; +use soroban_sdk::{Address, Env, String}; + +// ─── Test helpers ──────────────────────────────────────────────────────────── + +/// Register an underlying token + yield vault, returning both clients plus the +/// admin and vault contract id. +fn setup( + env: &Env, +) -> ( + YieldVaultContractClient<'_>, + BcForgeTokenClient<'_>, + Address, + Address, +) { + let admin = Address::generate(env); + + let underlying_id = env.register(BcForgeToken, ()); + let underlying = BcForgeTokenClient::new(env, &underlying_id); + underlying.initialize( + &admin, + &7, + &String::from_str(env, "Underlying Token"), + &String::from_str(env, "UND"), + ); + + let vault_id = env.register(YieldVaultContract, ()); + let vault = YieldVaultContractClient::new(env, &vault_id); + vault.initialize(&admin, &underlying_id); + env.as_contract(&vault_id, || { + bc_forge_lifecycle::set_paused(env, false); + }); + + (vault, underlying, admin, vault_id) +} + +/// Same as `setup` but also mints underlying tokens to `user` and approves +/// the vault to spend them. +fn setup_and_fund( + env: &Env, +) -> ( + YieldVaultContractClient<'_>, + BcForgeTokenClient<'_>, + Address, + Address, + Address, +) { + let (vault, underlying, admin, vault_id) = setup(env); + let user = Address::generate(env); + + underlying.mint(&admin, &user, &10_000_000); + underlying.approve(&user, &vault_id, &10_000_000, &u32::MAX); + + (vault, underlying, admin, user, vault_id) +} + +/// Configure a rate limit directly inside the vault contract's storage. +/// +/// `BcForgeRateLimit::internal_set_global_rate_limit` writes to +/// `env.storage().instance()`, so calling it inside `env.as_contract` +/// targets the vault's own instance storage — which is exactly where +/// `rate_limit_deposits` reads from via `internal_check_rate_limit`. +fn set_vault_rate_limit(env: &Env, vault_id: &Address, limit: u64, window_seconds: u64) { + let op = String::from_str(env, "deposit"); + env.as_contract(vault_id, || { + bc_forge_rate_limit::BcForgeRateLimit::internal_set_global_rate_limit( + env, + &op, + limit, + window_seconds, + ); + }); +} + +// ─── Minimal admin stubs for test setup ────────────────────────────────────── + +use soroban_sdk::{contract, contractimpl}; + +#[contract] +struct AdminContract; + +#[contractimpl] +impl AdminContract { + pub fn set_admin(env: Env, admin: Address) { + bc_forge_admin::set_admin(&env, &admin); + } +} + +// ─── #735: Deposit to mint ratio ───────────────────────────────────────────── #[test] -fn test_deposit_slippage_success() { +fn test_deposit_to_mint_ratio_initial_deposit_1_to_1() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(YieldVaultContract, ()); - let client = YieldVaultContractClient::new(&env, &contract_id); - let user = Address::generate(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); + + // First deposit: 1,000,000 assets → 1,000,000 shares (1:1 bootstrap). + let shares = vault.deposit(&user, &1_000_000, &0); + assert_eq!(shares, 1_000_000); + assert_eq!(vault.supply(), 1_000_000); + assert_eq!(vault.share_balance(&user), 1_000_000); + assert_eq!(vault.total_assets(), 1_000_000); +} + +#[test] +fn test_deposit_to_mint_ratio_secondary_deposit_matches_formula() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, underlying, admin, user_a, vault_id) = setup_and_fund(&env); + let user_b = Address::generate(&env); + + // Fund user_b and approve vault. + underlying.mint(&admin, &user_b, &5_000_000); + underlying.approve(&user_b, &vault_id, &5_000_000, &u32::MAX); + + // Seed: user_a deposits 2,000,000 → 2,000,000 shares. + let shares_a = vault.deposit(&user_a, &2_000_000, &0); + assert_eq!(shares_a, 2_000_000); + + // Secondary: user_b deposits 1,000,000. + // Formula: shares = assets * total_shares / total_assets + // = 1_000_000 * 2_000_000 / 2_000_000 = 1_000_000 + let shares_b = vault.deposit(&user_b, &1_000_000, &0); + assert_eq!(shares_b, 1_000_000); + assert_eq!(vault.supply(), 3_000_000); + assert_eq!(vault.share_balance(&user_b), 1_000_000); +} + +#[test] +fn test_deposit_to_mint_ratio_after_reward_distribution() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, underlying, admin, user_a, vault_id) = setup_and_fund(&env); + let user_b = Address::generate(&env); + + // Fund user_b and approve. + underlying.mint(&admin, &user_b, &5_000_000); + underlying.approve(&user_b, &vault_id, &5_000_000, &u32::MAX); + + // user_a deposits 2,000,000 → 2,000,000 shares at 1:1. + vault.deposit(&user_a, &2_000_000, &0); + assert_eq!(vault.total_assets(), 2_000_000); + + // Simulate reward: directly transfer underlying tokens into the vault. + // This increases total_assets without changing total_shares. + underlying.transfer(&admin, &vault_id, &1_000_000); + assert_eq!(vault.total_assets(), 3_000_000); + + // user_b deposits 1,500,000. + // Formula: shares = 1_500_000 * 2_000_000 / 3_000_000 = 1_000_000 + let shares_b = vault.deposit(&user_b, &1_500_000, &0); + assert_eq!(shares_b, 1_000_000); + assert_eq!(vault.supply(), 3_000_000); +} + +#[test] +fn test_deposit_to_mint_ratio_inexact_division_floors() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, underlying, admin, user_a, vault_id) = setup_and_fund(&env); + let user_b = Address::generate(&env); + + underlying.mint(&admin, &user_b, &5_000_000); + underlying.approve(&user_b, &vault_id, &5_000_000, &u32::MAX); + + // user_a deposits 3,000,000 → 3,000,000 shares. + vault.deposit(&user_a, &3_000_000, &0); + + // user_b deposits 1,000,000. + // Formula: shares = 1_000_000 * 3_000_000 / 3_000_000 = 1_000_000 + let shares_b = vault.deposit(&user_b, &1_000_000, &0); + assert_eq!(shares_b, 1_000_000); + + // Verify pro-rata: user_b owns 1/4 of the vault. + // total_assets = 4,000,000, total_shares = 4,000,000 + assert_eq!(vault.total_assets(), 4_000_000); + assert_eq!(vault.supply(), 4_000_000); +} + +#[test] +fn test_deposit_to_mint_ratio_three_users_pro_rata() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, underlying, admin, user_a, vault_id) = setup_and_fund(&env); + let user_b = Address::generate(&env); + let user_c = Address::generate(&env); + + // Fund users b and c. + underlying.mint(&admin, &user_b, &4_000_000); + underlying.mint(&admin, &user_c, &4_000_000); + underlying.approve(&user_b, &vault_id, &4_000_000, &u32::MAX); + underlying.approve(&user_c, &vault_id, &4_000_000, &u32::MAX); + + // Seed: user_a deposits 1,000,000 → 1,000,000 shares. + vault.deposit(&user_a, &1_000_000, &0); + + // Add reward to create yield. + underlying.transfer(&admin, &vault_id, &1_000_000); + // total_assets = 2,000,000, total_shares = 1,000,000 + + // user_b deposits 1,000,000. + // Formula: shares = 1_000_000 * 1_000_000 / 2_000_000 = 500,000 + let shares_b = vault.deposit(&user_b, &1_000_000, &0); + assert_eq!(shares_b, 500_000); + + // Now: total_assets = 3,000,000, total_shares = 1,500,000 + // user_c deposits 600,000. + // Formula: shares = 600_000 * 1_500_000 / 3_000_000 = 300,000 + let shares_c = vault.deposit(&user_c, &600_000, &0); + assert_eq!(shares_c, 300_000); + assert_eq!(vault.supply(), 1_800_000); +} + +// ─── #732: Rate-limit deposits ─────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "RateLimited")] +fn test_rate_limit_blocks_second_deposit_when_limit_is_one() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, vault_id) = setup_and_fund(&env); - let shares = client.deposit(&user, &1000, &950); + // Configure rate limit: max 1 deposit per 3600-second window. + set_vault_rate_limit(&env, &vault_id, 1, 3600); + + // First deposit succeeds. + vault.deposit(&user, &1_000_000, &0); + + // Second deposit exceeds the limit of 1 → RateLimited. + vault.deposit(&user, &500, &0); +} + +#[test] +fn test_rate_limit_allows_deposits_within_limit() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, vault_id) = setup_and_fund(&env); + + // Allow up to 3 deposits. + set_vault_rate_limit(&env, &vault_id, 3, 3600); + + vault.deposit(&user, &100, &0); + vault.deposit(&user, &100, &0); + vault.deposit(&user, &100, &0); + + assert_eq!(vault.supply(), 300); +} + +#[test] +#[should_panic(expected = "RateLimited")] +fn test_rate_limit_blocks_fourth_when_limit_is_three() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, vault_id) = setup_and_fund(&env); + + set_vault_rate_limit(&env, &vault_id, 3, 3600); + + vault.deposit(&user, &100, &0); + vault.deposit(&user, &100, &0); + vault.deposit(&user, &100, &0); + + // Fourth deposit exceeds limit of 3. + vault.deposit(&user, &100, &0); +} + +#[test] +fn test_no_rate_limit_config_allows_unlimited_deposits() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, _vault_id) = setup_and_fund(&env); + + // No rate limit configured — all deposits should succeed. + for _ in 0..10 { + vault.deposit(&user, &100, &0); + } + + assert_eq!(vault.supply(), 1_000); +} + +#[test] +fn test_rate_limit_enforced_before_amount_validation() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, vault_id) = setup_and_fund(&env); + + // Set limit to 1. + set_vault_rate_limit(&env, &vault_id, 1, 3600); + + // First deposit (succeeds). + vault.deposit(&user, &100, &0); + + // Second deposit: should fail on rate limit (RateLimited) even though + // the amount is valid. Rate-limited check runs before share math. + let res = vault.try_deposit(&user, &500, &0); + assert_eq!(res, Err(Ok(VaultError::RateLimited))); +} + +// ─── #733: Pause vault deposits ────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "ContractPaused")] +fn test_pause_blocks_deposit() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); + + // Pause the contract. + env.as_contract(&vault.address, || { + bc_forge_lifecycle::set_paused(&env, true); + }); + + // Deposit should revert. + vault.deposit(&user, &1_000_000, &0); +} + +#[test] +fn test_pause_allows_withdraw() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); + + // Deposit first while unpaused. + vault.deposit(&user, &1_000_000, &0); + assert_eq!(vault.supply(), 1_000_000); + + // Pause the contract. + env.as_contract(&vault.address, || { + bc_forge_lifecycle::set_paused(&env, true); + }); + + // Withdraw should still work — users can always exit. + let tokens = vault.withdraw(&user, &500_000, &0); + assert_eq!(tokens, 500_000); + assert_eq!(vault.supply(), 500_000); + assert_eq!(vault.share_balance(&user), 500_000); +} + +#[test] +fn test_unpause_resumes_deposits() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); + + env.as_contract(&vault.address, || { + bc_forge_lifecycle::set_paused(&env, true); + }); + + // Pause blocks deposits. + let res = vault.try_deposit(&user, &1000, &0); + assert_eq!(res, Err(Ok(VaultError::ContractPaused))); + + // Unpause. + env.as_contract(&vault.address, || { + bc_forge_lifecycle::set_paused(&env, false); + }); + + // Deposit succeeds again. + let shares = vault.deposit(&user, &1000, &0); assert_eq!(shares, 1000); } #[test] -#[should_panic(expected = "SlippageExceeded")] +fn test_full_deposit_withdrawl_cycle_through_pause() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); + + vault.deposit(&user, &2_000_000, &0); + assert_eq!(vault.supply(), 2_000_000); + + // Pause — deposit blocked. + env.as_contract(&vault.address, || { + bc_forge_lifecycle::set_paused(&env, true); + }); + assert!(vault.try_deposit(&user, &100, &0).is_err()); + + // But full withdraw works. + let tokens = vault.withdraw(&user, &2_000_000, &0); + assert_eq!(tokens, 2_000_000); + assert_eq!(vault.supply(), 0); + + // Unpause and deposit again. + env.as_contract(&vault.address, || { + bc_forge_lifecycle::set_paused(&env, false); + }); + vault.deposit(&user, &500_000, &0); + assert_eq!(vault.supply(), 500_000); +} + +#[test] +fn test_withdraw_full_balance_while_paused() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); + + vault.deposit(&user, &1_000_000, &0); + + // Pause. + env.as_contract(&vault.address, || { + bc_forge_lifecycle::set_paused(&env, true); + }); + + // Full withdrawal still works. + let tokens = vault.withdraw(&user, &1_000_000, &0); + assert_eq!(tokens, 1_000_000); + assert_eq!(vault.supply(), 0); + assert_eq!(vault.share_balance(&user), 0); +} + +// ─── #734: Rescue stuck funds ──────────────────────────────────────────────── + +#[test] +fn test_rescue_tokens_transfers_non_underlying() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, admin, _user, vault_id) = setup_and_fund(&env); + + // Deploy a second token (the "stuck" token). + let stuck_id = env.register(BcForgeToken, ()); + let stuck = BcForgeTokenClient::new(&env, &stuck_id); + stuck.initialize( + &admin, + &7, + &String::from_str(&env, "Stuck Token"), + &String::from_str(&env, "STK"), + ); + + // Accidentally send stuck tokens to the vault. + stuck.mint(&admin, &vault_id, &500_000); + + let recipient = Address::generate(&env); + + // Admin rescues the stuck tokens. + vault.rescue_tokens(&admin, &stuck_id, &recipient, &500_000); + + assert_eq!(stuck.balance(&recipient), 500_000); + assert_eq!(stuck.balance(&vault_id), 0); +} + +#[test] +#[should_panic(expected = "CannotRescueUnderlying")] +fn test_rescue_tokens_reverts_for_underlying() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, underlying, admin, user, _vault_id) = setup_and_fund(&env); + + vault.deposit(&user, &1_000_000, &0); + + let recipient = Address::generate(&env); + + // Attempting to rescue the underlying token should revert. + vault.rescue_tokens(&admin, &underlying.address, &recipient, &100_000); +} + +#[test] +#[should_panic(expected = "InvalidAmount")] +fn test_rescue_tokens_reverts_for_zero_amount() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, admin, _user, vault_id) = setup_and_fund(&env); + + let stuck_id = env.register(BcForgeToken, ()); + let stuck = BcForgeTokenClient::new(&env, &stuck_id); + stuck.initialize( + &admin, + &7, + &String::from_str(&env, "Stuck"), + &String::from_str(&env, "STK"), + ); + stuck.mint(&admin, &vault_id, &500_000); + + let recipient = Address::generate(&env); + vault.rescue_tokens(&admin, &stuck_id, &recipient, &0); +} + +#[test] +#[should_panic(expected = "InvalidAmount")] +fn test_rescue_tokens_reverts_for_negative_amount() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, admin, _user, vault_id) = setup_and_fund(&env); + + let stuck_id = env.register(BcForgeToken, ()); + let stuck = BcForgeTokenClient::new(&env, &stuck_id); + stuck.initialize( + &admin, + &7, + &String::from_str(&env, "Stuck"), + &String::from_str(&env, "STK"), + ); + stuck.mint(&admin, &vault_id, &500_000); + + let recipient = Address::generate(&env); + vault.rescue_tokens(&admin, &stuck_id, &recipient, &-100); +} + +#[test] +fn test_rescue_tokens_requires_admin() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, admin, _user, vault_id) = setup_and_fund(&env); + + let stuck_id = env.register(BcForgeToken, ()); + let stuck = BcForgeTokenClient::new(&env, &stuck_id); + stuck.initialize( + &admin, + &7, + &String::from_str(&env, "Stuck"), + &String::from_str(&env, "STK"), + ); + stuck.mint(&admin, &vault_id, &500_000); + + let non_admin = Address::generate(&env); + let recipient = Address::generate(&env); + + // Non-admin caller should fail. + env.mock_auths(&[]); + let res = vault.try_rescue_tokens(&non_admin, &stuck_id, &recipient, &500_000); + assert!(res.is_err()); +} + +#[test] +fn test_rescue_tokens_preserves_vault_deposit_integrity() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, admin, user, vault_id) = setup_and_fund(&env); + + let stuck_id = env.register(BcForgeToken, ()); + let stuck = BcForgeTokenClient::new(&env, &stuck_id); + stuck.initialize( + &admin, + &7, + &String::from_str(&env, "Stuck"), + &String::from_str(&env, "STK"), + ); + stuck.mint(&admin, &vault_id, &1_000_000); + + // Deposit before rescue. + vault.deposit(&user, &2_000_000, &0); + + // Rescue stuck tokens. + let recipient = Address::generate(&env); + vault.rescue_tokens(&admin, &stuck_id, &recipient, &1_000_000); + + // Vault state is unaffected — total_assets still reflects underlying only. + assert_eq!(vault.total_assets(), 2_000_000); + assert_eq!(vault.supply(), 2_000_000); + + // Withdrawal still works correctly. + let tokens = vault.withdraw(&user, &2_000_000, &0); + assert_eq!(tokens, 2_000_000); +} + +// ─── Slippage tests ────────────────────────────────────────────────────────── + +#[test] +#[should_panic(expected = "InvalidAmount")] fn test_deposit_slippage_revert() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(YieldVaultContract, ()); - let client = YieldVaultContractClient::new(&env, &contract_id); - let user = Address::generate(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); - client.deposit(&user, &1000, &1050); + // Depositing 1000 assets → 1000 shares; requiring 1050 should fail. + vault.deposit(&user, &1000, &1050); } #[test] -fn test_withdraw_sufficient_shares_success() { +fn test_deposit_slippage_success() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(YieldVaultContract, ()); - let client = YieldVaultContractClient::new(&env, &contract_id); - let user = Address::generate(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); - client.withdraw(&user, &500, &0); + let shares = vault.deposit(&user, &1000, &950); + assert_eq!(shares, 1000); } #[test] -#[should_panic(expected = "InsufficientShares")] -fn test_withdraw_insufficient_shares_revert() { +#[should_panic(expected = "InvalidAmount")] +fn test_withdraw_slippage_revert() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(YieldVaultContract, ()); - let client = YieldVaultContractClient::new(&env, &contract_id); - let user = Address::generate(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); + + vault.deposit(&user, &1000, &0); - client.withdraw(&user, &1500, &0); + // Withdrawing 1000 shares → 1000 tokens; requiring 1050 should fail. + vault.withdraw(&user, &1000, &1050); } #[test] fn test_withdraw_slippage_success() { let env = Env::default(); env.mock_all_auths(); - let contract_id = env.register(YieldVaultContract, ()); - let client = YieldVaultContractClient::new(&env, &contract_id); - let user = Address::generate(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); - let tokens_out = client.withdraw(&user, &1000, &950); - assert_eq!(tokens_out, 1000); + vault.deposit(&user, &1000, &0); + let tokens = vault.withdraw(&user, &1000, &950); + assert_eq!(tokens, 1000); } +// ─── Edge cases ────────────────────────────────────────────────────────────── + #[test] -#[should_panic(expected = "SlippageExceeded")] -fn test_withdraw_slippage_revert() { +fn test_deposit_zero_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); + + let res = vault.try_deposit(&user, &0, &0); + assert_eq!(res, Err(Ok(VaultError::InvalidAmount))); +} + +#[test] +fn test_deposit_negative_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); + + let res = vault.try_deposit(&user, &-100, &0); + assert_eq!(res, Err(Ok(VaultError::InvalidAmount))); +} + +#[test] +fn test_withdraw_insufficient_balance_fails() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); + + let res = vault.try_withdraw(&user, &100, &0); + assert_eq!(res, Err(Ok(VaultError::InsufficientBalance))); +} + +#[test] +fn test_uninitialized_deposit_fails() { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register(YieldVaultContract, ()); let client = YieldVaultContractClient::new(&env, &contract_id); let user = Address::generate(&env); - client.withdraw(&user, &1000, &1050); + let res = client.try_deposit(&user, &1000, &0); + assert_eq!(res, Err(Ok(VaultError::NotInitialized))); +} + +#[test] +fn test_underlying_token_query() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, underlying, _admin, _vault_id) = setup(&env); + + assert_eq!(vault.underlying_token(), underlying.address); +} + +#[test] +fn test_initial_supply_is_zero() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, _vault_id) = setup(&env); + + assert_eq!(vault.supply(), 0); +} + +#[test] +fn test_share_balance_zero_for_unknown_address() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, _vault_id) = setup(&env); + let stranger = Address::generate(&env); + + assert_eq!(vault.share_balance(&stranger), 0); +} + +#[test] +fn test_multiple_deposits_accumulate_supply_and_balances() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); + + vault.deposit(&user, &100_000, &0); + vault.deposit(&user, &200_000, &0); + vault.deposit(&user, &300_000, &0); + + assert_eq!(vault.supply(), 600_000); + assert_eq!(vault.share_balance(&user), 600_000); + assert_eq!(vault.total_assets(), 600_000); +} + +#[test] +fn test_withdraw_returns_proportional_tokens() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, underlying, admin, user_a, vault_id) = setup_and_fund(&env); + let user_b = Address::generate(&env); + + underlying.mint(&admin, &user_b, &2_000_000); + underlying.approve(&user_b, &vault_id, &2_000_000, &u32::MAX); + + // user_a: 3,000,000 shares, user_b: 1,000,000 shares + vault.deposit(&user_a, &3_000_000, &0); + vault.deposit(&user_b, &1_000_000, &0); + + // Total: 4,000,000 assets, 4,000,000 shares + // user_b withdraws all → 1,000,000 tokens + let tokens = vault.withdraw(&user_b, &1_000_000, &0); + assert_eq!(tokens, 1_000_000); + assert_eq!(vault.supply(), 3_000_000); + assert_eq!(vault.total_assets(), 3_000_000); }