From e21555a9cdece02244b6c005597be7ca442e6815 Mon Sep 17 00:00:00 2001 From: CathyZaks Date: Sat, 24 Oct 2026 03:18:12 +0100 Subject: [PATCH] Use total_assets as single conversion base and reconcile tests --- .../share-price-math/src/fuzz_invariants.rs | 9 ++- contracts/vault/src/lib.rs | 43 +++++++++----- contracts/vault/src/test.rs | 59 +++++++++++++++++-- 3 files changed, 88 insertions(+), 23 deletions(-) diff --git a/contracts/share-price-math/src/fuzz_invariants.rs b/contracts/share-price-math/src/fuzz_invariants.rs index 19b8e1b35..e3b5b31e3 100644 --- a/contracts/share-price-math/src/fuzz_invariants.rs +++ b/contracts/share-price-math/src/fuzz_invariants.rs @@ -10,9 +10,9 @@ fn assets_for(shares: i128, total_shares: i128, total_assets: i128) -> Result bool { - (total_shares == 0 && total_assets == 0) || (total_shares > 0 && total_assets > 0) + (total_shares == 0 && total_assets == 0) || (total_shares > 0 && total_assets >= 0) } /// Decode a little-endian `i128` from eight bytes (any value, including zero). @@ -143,4 +143,9 @@ mod tests { fn bootstrap_invariant_holds() { assert_share_price_invariants(1000, 0, 0, 1); } + + #[test] + fn zero_idle_state_invariant_holds() { + assert_share_price_invariants(100, 50, 0, 10); + } } diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 492bb3cfd..441fcb9fe 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -1878,13 +1878,16 @@ impl YieldVault { /// This exchange rate represents the value of one vault share in underlying /// assets and is the basis for frontend share-price reporting. When no shares /// are outstanding, the share price is defined as zero. + /// + /// Uses total vault assets (`total_assets()`, including idle tokens and strategy + /// valuation) as the canonical assets base for pricing. pub fn share_price(env: Env) -> i128 { let state = Self::get_state(&env); if state.total_shares == 0 { return 0; } - state - .total_assets + let total_assets = Self::total_assets(env).unwrap_or(state.total_assets); + total_assets .checked_mul(SHARE_PRICE_SCALE) .expect("overflow") .checked_div(state.total_shares) @@ -2521,12 +2524,15 @@ impl YieldVault { /// Always rounds DOWN to prevent over-minting shares. pub fn calculate_shares(env: Env, assets: i128) -> Result { let state = Self::get_state(&env); - crate::math::try_assets_to_shares(assets, state.total_shares, state.total_assets) + let total_assets = Self::total_assets(env)?; + crate::math::try_assets_to_shares(assets, state.total_shares, total_assets) .map_err(|_| VaultError::MathOverflow) } /// Calculates the number of assets that would be returned for a given share amount. /// + /// Uses total vault assets (`total_assets()`, including idle tokens and strategy + /// valuation) as the canonical assets base for conversion. /// Uses the deterministic round-down policy defined in the `math` module. /// See [`math::shares_to_assets`] for detailed rounding behavior. /// @@ -2540,7 +2546,8 @@ impl YieldVault { /// Always rounds DOWN to prevent over-withdrawal of assets. pub fn calculate_assets(env: Env, shares: i128) -> Result { let state = Self::get_state(&env); - crate::math::try_shares_to_assets(shares, state.total_shares, state.total_assets) + let total_assets = Self::total_assets(env)?; + crate::math::try_shares_to_assets(shares, state.total_shares, total_assets) .map_err(|_| VaultError::MathOverflow) } @@ -2571,8 +2578,9 @@ impl YieldVault { return Err(VaultError::InvalidAmount); } + let total_assets = Self::total_assets(env.clone())?; let limits = Self::load_protocol_limits(&env); - crate::risk_limits::check_deposit_tvl(state.total_assets, amount, limits.max_vault_tvl)?; + crate::risk_limits::check_deposit_tvl(total_assets, amount, limits.max_vault_tvl)?; // Goal 3: enforce minimum deposit let min_deposit: i128 = env @@ -2587,9 +2595,9 @@ impl YieldVault { let token_addr: Address = env.storage().instance().get(&DataKey::TokenAsset).unwrap(); let token_client = token::Client::new(&env, &token_addr); - // Use centralized conversion with deterministic round-down policy + // Use centralized conversion with deterministic round-down policy based on total_assets let shares_to_mint = - crate::math::try_assets_to_shares(amount, state.total_shares, state.total_assets) + crate::math::try_assets_to_shares(amount, state.total_shares, total_assets) .map_err(|_| VaultError::MathOverflow)?; // Prevent silent loss of funds if shares round down to 0 @@ -2619,7 +2627,7 @@ impl YieldVault { crate::math::try_shares_to_assets( shares_to_mint, state.total_shares, - state.total_assets, + total_assets, ) .map_err(|_| VaultError::MathOverflow)? }; @@ -2764,8 +2772,9 @@ impl YieldVault { return Err(VaultError::InvalidAmount); } + let total_assets = Self::total_assets(env.clone())?; let limits = Self::load_protocol_limits(&env); - crate::risk_limits::check_deposit_tvl(state.total_assets, amount, limits.max_vault_tvl)?; + crate::risk_limits::check_deposit_tvl(total_assets, amount, limits.max_vault_tvl)?; // Enforce minimum deposit let min_deposit: i128 = env @@ -2780,9 +2789,9 @@ impl YieldVault { let token_addr: Address = env.storage().instance().get(&DataKey::TokenAsset).unwrap(); let token_client = token::Client::new(&env, &token_addr); - // Compute shares using deterministic round-down policy + // Compute shares using deterministic round-down policy based on total_assets let shares_to_mint = - crate::math::try_assets_to_shares(amount, state.total_shares, state.total_assets) + crate::math::try_assets_to_shares(amount, state.total_shares, total_assets) .map_err(|_| VaultError::MathOverflow)?; if shares_to_mint == 0 { @@ -2810,7 +2819,7 @@ impl YieldVault { crate::math::try_shares_to_assets( shares_to_mint, state.total_shares, - state.total_assets, + total_assets, ) .map_err(|_| VaultError::MathOverflow)? }; @@ -3196,9 +3205,10 @@ impl YieldVault { .get(&DataKey::LargeWithdrawalThreshold) .unwrap_or(i128::MAX); - // Use centralized conversion with deterministic round-down policy + let total_assets = Self::total_assets(env.clone())?; + // Use centralized conversion with deterministic round-down policy based on total_assets let assets_to_return = - crate::math::try_shares_to_assets(shares, state.total_shares, state.total_assets) + crate::math::try_shares_to_assets(shares, state.total_shares, total_assets) .map_err(|_| VaultError::MathOverflow)?; if assets_to_return > threshold { @@ -3257,11 +3267,12 @@ impl YieldVault { let mut state = Self::get_state(&env); - // Use centralized conversion with deterministic round-down policy + let total_assets = Self::total_assets(env.clone())?; + // Use centralized conversion with deterministic round-down policy based on total_assets let assets_to_return = crate::math::try_shares_to_assets( pending.shares, state.total_shares, - state.total_assets, + total_assets, ) .map_err(|_| VaultError::MathOverflow)?; diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 88e5e89e9..d27122921 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -169,16 +169,65 @@ fn test_vault_with_benji_strategy() { assert_eq!(strategy.total_value(), 66); assert_eq!(vault.total_assets(), 106); // 40 idle + 66 in strategy - // Manually divest 10 USDC to cover the upcoming withdrawal - vault.divest(&10); + // Manually divest 13 USDC to cover the upcoming withdrawal (50 shares = 53 assets) + vault.divest(&13); + benji_admin_client.clawback(&strategy_id, &13); // 5. User Withdraws some shares. - // state.total_assets=100, state.total_shares=100 → 50 shares = 50 assets + // Total assets = 106 (53 idle + 53 in strategy), total shares = 100 → 50 shares = 53 assets let withdrawn = vault.withdraw(&user, &50); - assert_eq!(withdrawn, 50); // 50 shares * 100 state_assets / 100 shares = 50 + assert_eq!(withdrawn, 53); // 50 shares * 106 total_assets / 100 total_shares = 53 assert_eq!(vault.total_shares(), 50); - assert_eq!(vault.total_assets(), 56); // 0 idle + 56 BENJI still in strategy (burned 10 on withdraw) + assert_eq!(vault.total_assets(), 53); // 0 idle + 53 BENJI still in strategy +} + +#[test] +fn test_deposit_mints_at_correct_price_while_strategy_holds_part_of_assets() { + let env = Env::default(); + env.mock_all_auths_allowing_non_root_auth(); + + let admin = Address::generate(&env); + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + + let token_admin = Address::generate(&env); + let usdc = create_token(&env, &token_admin); + let usdc_admin_client = token::StellarAssetClient::new(&env, &usdc.address); + usdc_admin_client.mint(&user1, &1000); + usdc_admin_client.mint(&user2, &1000); + + let benji_token = create_token(&env, &token_admin); + let vault_id = env.register(YieldVault, ()); + let vault = YieldVaultClient::new(&env, &vault_id); + + let strategy_id = env.register(BenjiStrategy, ()); + let strategy = BenjiStrategyClient::new(&env, &strategy_id); + + vault.initialize(&admin, &usdc.address); + strategy.initialize(&vault_id, &usdc.address, &benji_token.address); + vault.whitelist_strategy(&strategy_id, &true); + vault.set_strategy(&strategy_id); + + // 1. User 1 deposits 100 USDC -> receives 100 shares (1:1 conversion on initial deposit) + let minted1 = vault.deposit(&user1, &100); + assert_eq!(minted1, 100); + assert_eq!(vault.total_assets(), 100); + assert_eq!(vault.total_shares(), 100); + + // 2. Invest 60 USDC into strategy -> vault has 40 idle USDC, 60 USDC in strategy + vault.invest(&60); + assert_eq!(strategy.total_value(), 60); + assert_eq!(vault.total_assets(), 100); + + // 3. User 2 deposits 50 USDC while strategy holds part of the assets + // Total assets is 100 (40 idle + 60 strategy), total shares is 100. + // Pricing base is total_assets() = 100, so 50 USDC mints exactly 50 shares. + let minted2 = vault.deposit(&user2, &50); + assert_eq!(minted2, 50); + assert_eq!(vault.total_assets(), 150); + assert_eq!(vault.total_shares(), 150); + assert_eq!(vault.share_price(), SHARE_PRICE_SCALE); } #[test]