From ba5ad3ba0fa7bf28d97e29586f3c858338b5e91f Mon Sep 17 00:00:00 2001 From: Samuel Ojetunde Date: Mon, 31 Aug 2026 09:29:05 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat(yield-vault):=20implement=20guards=20a?= =?UTF-8?q?nd=20tests=20(#732=E2=80=93#735)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three guard layers and comprehensive tests to the yield vault contract: - #732: rate_limit_deposits — integrates with bc_forge_rate_limit module to throttle deposit frequency and prevent whale manipulation. - #733: pause_vault_deposits — adds require_not_paused to deposit while keeping withdraw always active so users can exit. - #734: rescue_tokens — admin-only function to recover non-underlying tokens; reverts if the requested token matches the vault's core underlying asset. - #735: deposit-to-mint ratio tests — verifies share calculation across initial deposits, secondary deposits, reward distributions, and multi-user pro-rata scenarios. Also removes yield_vault from workspace exclude list and adds dependencies on bc-forge-admin, bc-forge-lifecycle, bc-forge-rate-limit, and bc-forge-ttl. Co-Authored-By: Samuel Ojetunde --- Cargo.toml | 2 - contracts/yield_vault/Cargo.toml | 12 +- contracts/yield_vault/src/events.rs | 43 ++ contracts/yield_vault/src/lib.rs | 365 ++++++++++++++- contracts/yield_vault/src/test.rs | 680 ++++++++++++++++++++++++++-- 5 files changed, 1043 insertions(+), 59 deletions(-) create mode 100644 contracts/yield_vault/src/events.rs 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..caf1cc1c --- /dev/null +++ b/contracts/yield_vault/src/events.rs @@ -0,0 +1,43 @@ +//! # 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..c9d6d5cf 100644 --- a/contracts/yield_vault/src/lib.rs +++ b/contracts/yield_vault/src/lib.rs @@ -1,54 +1,371 @@ +//! # 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_admin(env: &Env) -> Result { + if bc_forge_admin::has_admin(env) { + Ok(bc_forge_admin::get_admin(env)) + } else { + Err(VaultError::NotInitialized) + } + } + + 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(); + + 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); + } - let shares_out = Self::calculate_shares_out(&env, token_amount); + // #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 user_balance = Self::get_share_balance(&env, &user); - if shares_in > user_balance { - panic!("InsufficientShares: requested shares exceed user balance"); + 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)?; + + 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); - fn calculate_shares_out(_env: &Env, token_amount: i128) -> i128 { - token_amount + // Transfer underlying tokens to caller. + underlying_client.transfer(&env.current_contract_address(), &caller, &tokens_out); + + 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..324cf632 100644 --- a/contracts/yield_vault/src/test.rs +++ b/contracts/yield_vault/src/test.rs @@ -1,74 +1,692 @@ -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::testutils::Ledger; +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); + + (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. + 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. + 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); + + 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. + 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. + 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. + 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. + 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, _user, _vault_id) = setup(&env); + + assert_eq!(vault.underlying_token().unwrap(), underlying.address); +} + +#[test] +fn test_initial_supply_is_zero() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, _user, _vault_id) = setup(&env); + + assert_eq!(vault.supply().unwrap(), 0); +} + +#[test] +fn test_share_balance_zero_for_unknown_address() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _underlying, _admin, _user, _vault_id) = setup(&env); + let stranger = Address::generate(&env); + + assert_eq!(vault.share_balance(&stranger).unwrap(), 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); } From 76198d36e360c714e2206706eb321cacb5973997 Mon Sep 17 00:00:00 2001 From: Damilare <305186152+damilareojetunde@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:53:11 +0000 Subject: [PATCH 2/8] style(yield-vault): fix cargo fmt formatting violations Apply rustfmt formatting to events.rs, lib.rs, and test.rs to pass the CI formatting check. Co-Authored-By: Samuel Ojetunde --- contracts/yield_vault/src/events.rs | 14 +++----------- contracts/yield_vault/src/lib.rs | 6 +++++- contracts/yield_vault/src/test.rs | 9 ++++++++- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/contracts/yield_vault/src/events.rs b/contracts/yield_vault/src/events.rs index caf1cc1c..13b565da 100644 --- a/contracts/yield_vault/src/events.rs +++ b/contracts/yield_vault/src/events.rs @@ -6,10 +6,8 @@ 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()), - ); + env.events() + .publish((symbol_short!("init"),), (admin.clone(), token.clone())); } /// Emitted when assets are deposited and shares are minted. @@ -29,13 +27,7 @@ pub fn emit_withdraw(env: &Env, caller: &Address, shares: i128, tokens_out: i128 } /// 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, -) { +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 c9d6d5cf..5ab90a77 100644 --- a/contracts/yield_vault/src/lib.rs +++ b/contracts/yield_vault/src/lib.rs @@ -257,7 +257,11 @@ impl YieldVaultContract { } // Mint shares to caller. - Self::write_balance(&env, &caller, Self::read_balance(&env, &caller) + shares_out); + 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); diff --git a/contracts/yield_vault/src/test.rs b/contracts/yield_vault/src/test.rs index 324cf632..d1261ed1 100644 --- a/contracts/yield_vault/src/test.rs +++ b/contracts/yield_vault/src/test.rs @@ -13,7 +13,14 @@ use soroban_sdk::{Address, Env, String}; /// Register an underlying token + yield vault, returning both clients plus the /// admin and vault contract id. -fn setup(env: &Env) -> (YieldVaultContractClient<'_>, BcForgeTokenClient<'_>, Address, Address) { +fn setup( + env: &Env, +) -> ( + YieldVaultContractClient<'_>, + BcForgeTokenClient<'_>, + Address, + Address, +) { let admin = Address::generate(env); let underlying_id = env.register(BcForgeToken, ()); From dfe8afcf35805a66ace062d7b55188485918e61c Mon Sep 17 00:00:00 2001 From: Damilare <305186152+damilareojetunde@users.noreply.github.com> Date: Mon, 31 Aug 2026 09:57:02 +0000 Subject: [PATCH 3/8] fix(yield-vault): remove unused read_admin to fix clippy dead_code error Co-Authored-By: Samuel Ojetunde --- contracts/yield_vault/src/lib.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/contracts/yield_vault/src/lib.rs b/contracts/yield_vault/src/lib.rs index 5ab90a77..b38c33f0 100644 --- a/contracts/yield_vault/src/lib.rs +++ b/contracts/yield_vault/src/lib.rs @@ -110,13 +110,6 @@ impl YieldVaultContract { } } - fn read_admin(env: &Env) -> Result { - if bc_forge_admin::has_admin(env) { - Ok(bc_forge_admin::get_admin(env)) - } else { - Err(VaultError::NotInitialized) - } - } fn read_underlying(env: &Env) -> Address { env.storage() From 224edba6d5983f2214af56343137f25798e89185 Mon Sep 17 00:00:00 2001 From: Damilare <305186152+damilareojetunde@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:06:40 +0000 Subject: [PATCH 4/8] fix(yield-vault): fix write_supply formatting (body on same line as signature) Co-Authored-By: Samuel Ojetunde --- contracts/yield_vault/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/contracts/yield_vault/src/lib.rs b/contracts/yield_vault/src/lib.rs index b38c33f0..8d24cce9 100644 --- a/contracts/yield_vault/src/lib.rs +++ b/contracts/yield_vault/src/lib.rs @@ -110,7 +110,6 @@ impl YieldVaultContract { } } - fn read_underlying(env: &Env) -> Address { env.storage() .instance() From 0c7c8767279caebb93527a666557b46cdaf7b086 Mon Sep 17 00:00:00 2001 From: Damilare <305186152+damilareojetunde@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:16:02 +0000 Subject: [PATCH 5/8] fix(yield-vault): fix test compilation errors - Remove & from numeric literals in assert_eq! (E0277: can't compare i128 with &{integer}) - Fix tuple destructuring: setup_and_fund returns 5 values, setup returns 4 (E0308: mismatched types) Co-Authored-By: Samuel Ojetunde --- contracts/yield_vault/src/test.rs | 38 +++++++++++++++---------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/contracts/yield_vault/src/test.rs b/contracts/yield_vault/src/test.rs index d1261ed1..49d7ec7b 100644 --- a/contracts/yield_vault/src/test.rs +++ b/contracts/yield_vault/src/test.rs @@ -97,7 +97,7 @@ impl AdminContract { fn test_deposit_to_mint_ratio_initial_deposit_1_to_1() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, user) = setup_and_fund(&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); @@ -307,7 +307,7 @@ fn test_rate_limit_enforced_before_amount_validation() { fn test_pause_blocks_deposit() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, user) = setup_and_fund(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); // Pause the contract. bc_forge_lifecycle::set_paused(&env, true); @@ -320,7 +320,7 @@ fn test_pause_blocks_deposit() { fn test_pause_allows_withdraw() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, user) = setup_and_fund(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); // Deposit first while unpaused. vault.deposit(&user, &1_000_000, &0); @@ -340,7 +340,7 @@ fn test_pause_allows_withdraw() { fn test_unpause_resumes_deposits() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, admin, user) = setup_and_fund(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); bc_forge_lifecycle::set_paused(&env, true); @@ -360,7 +360,7 @@ fn test_unpause_resumes_deposits() { 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); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); vault.deposit(&user, &2_000_000, &0); assert_eq!(vault.supply(), 2_000_000); @@ -384,7 +384,7 @@ fn test_full_deposit_withdrawl_cycle_through_pause() { fn test_withdraw_full_balance_while_paused() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, user) = setup_and_fund(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); vault.deposit(&user, &1_000_000, &0); @@ -424,8 +424,8 @@ fn test_rescue_tokens_transfers_non_underlying() { // 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); + assert_eq!(stuck.balance(&recipient), 500_000); + assert_eq!(stuck.balance(&vault_id), 0); } #[test] @@ -549,7 +549,7 @@ fn test_rescue_tokens_preserves_vault_deposit_integrity() { fn test_deposit_slippage_revert() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, user) = setup_and_fund(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); // Depositing 1000 assets → 1000 shares; requiring 1050 should fail. vault.deposit(&user, &1000, &1050); @@ -559,7 +559,7 @@ fn test_deposit_slippage_revert() { fn test_deposit_slippage_success() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, user) = setup_and_fund(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); let shares = vault.deposit(&user, &1000, &950); assert_eq!(shares, 1000); @@ -570,7 +570,7 @@ fn test_deposit_slippage_success() { fn test_withdraw_slippage_revert() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, user) = setup_and_fund(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); vault.deposit(&user, &1000, &0); @@ -582,7 +582,7 @@ fn test_withdraw_slippage_revert() { fn test_withdraw_slippage_success() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, user) = setup_and_fund(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); vault.deposit(&user, &1000, &0); let tokens = vault.withdraw(&user, &1000, &950); @@ -595,7 +595,7 @@ fn test_withdraw_slippage_success() { fn test_deposit_zero_fails() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, user) = setup_and_fund(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); let res = vault.try_deposit(&user, &0, &0); assert_eq!(res, Err(Ok(VaultError::InvalidAmount))); @@ -605,7 +605,7 @@ fn test_deposit_zero_fails() { fn test_deposit_negative_fails() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, user) = setup_and_fund(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); let res = vault.try_deposit(&user, &-100, &0); assert_eq!(res, Err(Ok(VaultError::InvalidAmount))); @@ -615,7 +615,7 @@ fn test_deposit_negative_fails() { fn test_withdraw_insufficient_balance_fails() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, user) = setup_and_fund(&env); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); let res = vault.try_withdraw(&user, &100, &0); assert_eq!(res, Err(Ok(VaultError::InsufficientBalance))); @@ -637,7 +637,7 @@ fn test_uninitialized_deposit_fails() { fn test_underlying_token_query() { let env = Env::default(); env.mock_all_auths(); - let (vault, underlying, _admin, _user, _vault_id) = setup(&env); + let (vault, underlying, _admin, _vault_id) = setup(&env); assert_eq!(vault.underlying_token().unwrap(), underlying.address); } @@ -646,7 +646,7 @@ fn test_underlying_token_query() { fn test_initial_supply_is_zero() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, _user, _vault_id) = setup(&env); + let (vault, _underlying, _admin, _vault_id) = setup(&env); assert_eq!(vault.supply().unwrap(), 0); } @@ -655,7 +655,7 @@ fn test_initial_supply_is_zero() { fn test_share_balance_zero_for_unknown_address() { let env = Env::default(); env.mock_all_auths(); - let (vault, _underlying, _admin, _user, _vault_id) = setup(&env); + let (vault, _underlying, _admin, _vault_id) = setup(&env); let stranger = Address::generate(&env); assert_eq!(vault.share_balance(&stranger).unwrap(), 0); @@ -665,7 +665,7 @@ fn test_share_balance_zero_for_unknown_address() { 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); + let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); vault.deposit(&user, &100_000, &0); vault.deposit(&user, &200_000, &0); From da912243a2e20a282b49b42ce3b5473014af1f0f Mon Sep 17 00:00:00 2001 From: Damilare <305186152+damilareojetunde@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:25:02 +0000 Subject: [PATCH 6/8] fix(yield-vault): remove invalid .unwrap() calls and fix unused warnings - Soroban client auto-unwraps Result return types, so vault.supply(), vault.underlying_token(), vault.share_balance() return values directly - Remove unused import soroban_sdk::testutils::Ledger - Prefix unused variable underlying with underscore Co-Authored-By: Samuel Ojetunde --- contracts/yield_vault/src/test.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/contracts/yield_vault/src/test.rs b/contracts/yield_vault/src/test.rs index 49d7ec7b..d6eaea88 100644 --- a/contracts/yield_vault/src/test.rs +++ b/contracts/yield_vault/src/test.rs @@ -6,7 +6,6 @@ use crate::{VaultError, YieldVaultContract, YieldVaultContractClient}; use bc_forge_token::{BcForgeToken, BcForgeTokenClient}; use soroban_sdk::testutils::Address as _; -use soroban_sdk::testutils::Ledger; use soroban_sdk::{Address, Env, String}; // ─── Test helpers ──────────────────────────────────────────────────────────── @@ -514,7 +513,7 @@ fn test_rescue_tokens_requires_admin() { 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 (vault, _underlying, admin, user, vault_id) = setup_and_fund(&env); let stuck_id = env.register(BcForgeToken, ()); let stuck = BcForgeTokenClient::new(&env, &stuck_id); @@ -639,7 +638,7 @@ fn test_underlying_token_query() { env.mock_all_auths(); let (vault, underlying, _admin, _vault_id) = setup(&env); - assert_eq!(vault.underlying_token().unwrap(), underlying.address); + assert_eq!(vault.underlying_token(), underlying.address); } #[test] @@ -648,7 +647,7 @@ fn test_initial_supply_is_zero() { env.mock_all_auths(); let (vault, _underlying, _admin, _vault_id) = setup(&env); - assert_eq!(vault.supply().unwrap(), 0); + assert_eq!(vault.supply(), 0); } #[test] @@ -658,7 +657,7 @@ fn test_share_balance_zero_for_unknown_address() { let (vault, _underlying, _admin, _vault_id) = setup(&env); let stranger = Address::generate(&env); - assert_eq!(vault.share_balance(&stranger).unwrap(), 0); + assert_eq!(vault.share_balance(&stranger), 0); } #[test] From d603507e167a980c317e4d900bbe8664501e80ed Mon Sep 17 00:00:00 2001 From: Promise Raji Date: Mon, 31 Aug 2026 11:57:49 +0100 Subject: [PATCH 7/8] Merge origin/main and fix clippy needless-borrow in yield vault tests. Co-authored-by: Cursor --- contracts/yield_vault/src/test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/yield_vault/src/test.rs b/contracts/yield_vault/src/test.rs index d6eaea88..69295bad 100644 --- a/contracts/yield_vault/src/test.rs +++ b/contracts/yield_vault/src/test.rs @@ -68,7 +68,7 @@ fn set_vault_rate_limit(env: &Env, vault_id: &Address, limit: u64, window_second let op = String::from_str(env, "deposit"); env.as_contract(vault_id, || { bc_forge_rate_limit::BcForgeRateLimit::internal_set_global_rate_limit( - &env, + env, &op, limit, window_seconds, From b28a75b7d66e6e449728ac7be404c013cf88ca53 Mon Sep 17 00:00:00 2001 From: Promise Raji Date: Mon, 31 Aug 2026 12:07:01 +0100 Subject: [PATCH 8/8] Write vault pause state inside the vault contract storage in tests. Co-authored-by: Cursor --- contracts/yield_vault/src/test.rs | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/contracts/yield_vault/src/test.rs b/contracts/yield_vault/src/test.rs index 69295bad..a4d33be0 100644 --- a/contracts/yield_vault/src/test.rs +++ b/contracts/yield_vault/src/test.rs @@ -34,6 +34,9 @@ fn setup( 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) } @@ -309,7 +312,9 @@ fn test_pause_blocks_deposit() { let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); // Pause the contract. - bc_forge_lifecycle::set_paused(&env, true); + env.as_contract(&vault.address, || { + bc_forge_lifecycle::set_paused(&env, true); + }); // Deposit should revert. vault.deposit(&user, &1_000_000, &0); @@ -326,7 +331,9 @@ fn test_pause_allows_withdraw() { assert_eq!(vault.supply(), 1_000_000); // Pause the contract. - bc_forge_lifecycle::set_paused(&env, true); + 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); @@ -341,14 +348,18 @@ fn test_unpause_resumes_deposits() { env.mock_all_auths(); let (vault, _underlying, _admin, user, _) = setup_and_fund(&env); - bc_forge_lifecycle::set_paused(&env, true); + 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. - bc_forge_lifecycle::set_paused(&env, false); + env.as_contract(&vault.address, || { + bc_forge_lifecycle::set_paused(&env, false); + }); // Deposit succeeds again. let shares = vault.deposit(&user, &1000, &0); @@ -365,7 +376,9 @@ fn test_full_deposit_withdrawl_cycle_through_pause() { assert_eq!(vault.supply(), 2_000_000); // Pause — deposit blocked. - bc_forge_lifecycle::set_paused(&env, true); + 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. @@ -374,7 +387,9 @@ fn test_full_deposit_withdrawl_cycle_through_pause() { assert_eq!(vault.supply(), 0); // Unpause and deposit again. - bc_forge_lifecycle::set_paused(&env, false); + env.as_contract(&vault.address, || { + bc_forge_lifecycle::set_paused(&env, false); + }); vault.deposit(&user, &500_000, &0); assert_eq!(vault.supply(), 500_000); } @@ -388,7 +403,9 @@ fn test_withdraw_full_balance_while_paused() { vault.deposit(&user, &1_000_000, &0); // Pause. - bc_forge_lifecycle::set_paused(&env, true); + 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);