diff --git a/contract/vault/soroban/src/contract/entrypoints.rs b/contract/vault/soroban/src/contract/entrypoints.rs index 4672e820f..bd60aab46 100644 --- a/contract/vault/soroban/src/contract/entrypoints.rs +++ b/contract/vault/soroban/src/contract/entrypoints.rs @@ -902,7 +902,7 @@ impl SorobanVaultContract { ), (i128, i128, bool), (i128, i128, i128, i128), - (i128, u64, i128, i128), + (i128, u64, i128, i128, i128), ), ( soroban_sdk::Vec, @@ -921,11 +921,16 @@ impl SorobanVaultContract { let idle_assets = to_i128(state.idle_assets)?; let external_assets = to_i128(state.external_assets)?; let total_assets = to_i128(state.total_assets)?; + let fee_growth_rate = match config.fees.max_total_assets_growth_rate { + Some(rate) => to_i128(u128::from(rate))?, + None => 0, + }; let fee_info = ( - state.fee_anchor.total_assets as i128, + to_i128(state.fee_anchor.total_assets)?, state.fee_anchor.timestamp_ns.as_u64(), - u128::from(config.fees.management.fee_wad) as i128, - u128::from(config.fees.performance.fee_wad) as i128, + to_i128(u128::from(config.fees.management.fee_wad))?, + to_i128(u128::from(config.fees.performance.fee_wad))?, + fee_growth_rate, ); let policy_state = runtime_to_contract(storage.load_policy_state())?.unwrap_or_default(); for entry in policy_state.supply_queue().entries() { @@ -969,13 +974,55 @@ impl SorobanVaultContract { }; let (max_deposit_value, max_mint_value) = if state.op_state.is_idle() && !config.paused { - let max_assets = u128::MAX + let asset_headroom = u128::MAX .saturating_sub(state.total_assets) - .min(i128::MAX as u128) as i128; - let max_shares = u128::MAX - .saturating_sub(state.total_shares) - .min(i128::MAX as u128) as i128; - (max_assets, max_shares) + .min(u128::MAX.saturating_sub(state.idle_assets)); + let share_headroom = u128::MAX.saturating_sub(state.total_shares); + + let max_assets = asset_headroom.min(i128::MAX as u128); + let shares_for_max_assets = convert_to_shares_bounded( + &state, + &config, + max_assets, + u128::MAX, + InvalidStateCode::MintOverflowTotalShares, + ); + let max_deposit = if matches!(shares_for_max_assets, Ok(shares) if shares <= share_headroom) + { + max_assets + } else { + convert_to_assets_bounded( + &state, + &config, + share_headroom, + i128::MAX as u128, + InvalidStateCode::RequestWithdrawExpectedAssetsExceedTotalAssets, + ) + .map_err(|_| ContractError::ConversionOverflow)? + }; + + let max_shares = share_headroom.min(i128::MAX as u128); + let assets_for_max_shares = convert_to_assets_bounded( + &state, + &config, + max_shares, + u128::MAX, + InvalidStateCode::RequestWithdrawExpectedAssetsExceedTotalAssets, + ); + let max_mint = if matches!(assets_for_max_shares, Ok(assets) if assets <= asset_headroom) + { + max_shares + } else { + convert_to_shares_bounded( + &state, + &config, + asset_headroom, + i128::MAX as u128, + InvalidStateCode::MintOverflowTotalShares, + ) + .map_err(|_| ContractError::ConversionOverflow)? + }; + (to_i128(max_deposit)?, to_i128(max_mint)?) } else { (0, 0) }; diff --git a/contract/vault/soroban/src/fungible_vault.rs b/contract/vault/soroban/src/fungible_vault.rs index 805ab3d65..74f1a08ee 100644 --- a/contract/vault/soroban/src/fungible_vault.rs +++ b/contract/vault/soroban/src/fungible_vault.rs @@ -62,8 +62,12 @@ fn preview_state_with_fee_accrual( anchor.timestamp_ns.as_u64(), now_ns, ); + let max_supply = Number::from(u128::MAX); let supply_after_management = Number::from(state.total_shares).saturating_add(management_shares); + if supply_after_management > max_supply { + return Err(ContractError::ConversionOverflow); + } let profit = fee_assets_base.saturating_sub(anchor.total_assets); let performance_fee_assets = config @@ -77,9 +81,11 @@ fn preview_state_with_fee_accrual( supply_after_management, ); - state.total_shares = supply_after_management - .saturating_add(performance_shares) - .as_u128_saturating(); + let total_supply = supply_after_management.saturating_add(performance_shares); + if total_supply > max_supply { + return Err(ContractError::ConversionOverflow); + } + state.total_shares = total_supply.as_u128_trunc(); state.fee_anchor = FeeAccrualAnchor::new(current_assets, TimestampNs(now_ns)); Ok(state) diff --git a/contract/vault/soroban/tests/integration_tests.rs b/contract/vault/soroban/tests/integration_tests.rs index 5f53c119a..7df5a05a6 100644 --- a/contract/vault/soroban/tests/integration_tests.rs +++ b/contract/vault/soroban/tests/integration_tests.rs @@ -55,7 +55,7 @@ type ProxyCoreView = ( ), (i128, i128, bool), (i128, i128, i128, i128), - (i128, u64, i128, i128), + (i128, u64, i128, i128, i128), ); type ProxyPolicyView = ( soroban_sdk::Vec, @@ -174,6 +174,20 @@ impl<'a> VaultProxy<'a> { .7) } + fn max_deposit(&self) -> Result { + Ok(self + .view(soroban_sdk::Address::generate(self.env), 0, 0)? + .2 + .2) + } + + fn max_mint(&self) -> Result { + Ok(self + .view(soroban_sdk::Address::generate(self.env), 0, 0)? + .2 + .3) + } + fn execute( &self, command: &VaultCommand, @@ -587,6 +601,162 @@ fn soroban_contract_proxy_view_does_not_inflate_from_zero_fee_anchor( }); } +#[rstest] +fn soroban_contract_proxy_view_rejects_overlarge_fee_anchor( + soroban_contract_fixture: SorobanContractFixture, +) { + let env = soroban_contract_fixture.env; + let contract_id = soroban_contract_fixture.contract_id; + let proxy = VaultProxy::new(&env); + let owner = soroban_sdk::Address::generate(&env); + + env.as_contract(&contract_id, || { + let mut storage = SorobanStorage::new(&env); + storage + .save_state(&VaultState { + total_assets: 1_000, + total_shares: 1_000, + idle_assets: 0, + external_assets: 1_000, + fee_anchor: FeeAccrualAnchor::new( + i128::MAX as u128 + 1, + templar_vault_kernel::TimestampNs(123), + ), + ..Default::default() + }) + .expect("save state"); + + assert_eq!( + proxy.view(owner, 0, 0), + Err(templar_soroban_runtime::ContractError::ConversionOverflow) + ); + }); +} + +#[rstest] +fn soroban_contract_proxy_view_reports_fee_growth_cap( + soroban_contract_fixture: SorobanContractFixture, +) { + let env = soroban_contract_fixture.env; + let contract_id = soroban_contract_fixture.contract_id; + let proxy = VaultProxy::new(&env); + let owner = soroban_sdk::Address::generate(&env); + + env.as_contract(&contract_id, || { + let fees = FeesSpec::new( + FeeSlot::new(Wad::one() / 5, Address([1u8; 32])), + FeeSlot::new(Wad::one() / 10, Address([2u8; 32])), + Some(Wad::one() / 20), + ); + let mut bytes = Vec::with_capacity(113); + bytes.extend_from_slice(&fees.performance.fee_wad.as_u128_trunc().to_le_bytes()); + bytes.extend_from_slice(fees.performance.recipient.as_bytes()); + bytes.extend_from_slice(&fees.management.fee_wad.as_u128_trunc().to_le_bytes()); + bytes.extend_from_slice(fees.management.recipient.as_bytes()); + bytes.push(1); + bytes.extend_from_slice( + &fees + .max_total_assets_growth_rate + .expect("growth cap configured") + .as_u128_trunc() + .to_le_bytes(), + ); + env.storage().instance().set( + &templar_soroban_runtime::contract::VaultDataKey::FeesSpec, + &Bytes::from_slice(&env, &bytes), + ); + + let fee_info = proxy.view(owner, 0, 0).unwrap().0 .3; + assert_eq!(fee_info.4, (Wad::one() / 20).as_u128_trunc() as i128); + }); +} + +#[rstest] +fn soroban_contract_proxy_view_max_deposit_and_mint_respect_opposite_headroom( + soroban_contract_fixture: SorobanContractFixture, +) { + let env = soroban_contract_fixture.env; + let contract_id = soroban_contract_fixture.contract_id; + let proxy = VaultProxy::new(&env); + + env.as_contract(&contract_id, || { + let mut storage = SorobanStorage::new(&env); + storage + .save_state(&VaultState { + total_assets: 2, + total_shares: 1, + idle_assets: 2, + ..Default::default() + }) + .expect("save state"); + + assert_eq!(proxy.max_deposit().unwrap(), i128::MAX); + assert_eq!(proxy.max_mint().unwrap(), i128::MAX); + + storage + .save_state(&VaultState { + total_assets: 1, + total_shares: 2, + idle_assets: 1, + ..Default::default() + }) + .expect("save state"); + + let expected_max_deposit = (((i128::MAX as u128) * 2) / 3) as i128; + assert_eq!(proxy.max_deposit().unwrap(), expected_max_deposit); + assert_eq!(proxy.max_mint().unwrap(), i128::MAX); + }); +} + +#[rstest] +fn soroban_contract_fee_aware_preview_fails_on_supply_overflow( + soroban_contract_fixture: SorobanContractFixture, +) { + let env = soroban_contract_fixture.env; + let contract_id = soroban_contract_fixture.contract_id; + let proxy = VaultProxy::new(&env); + + env.ledger().set(LedgerInfo { + timestamp: 100, + protocol_version: 25, + ..Default::default() + }); + + env.as_contract(&contract_id, || { + let fees = FeesSpec::new( + FeeSlot::new(Wad::zero(), Address([1u8; 32])), + FeeSlot::new(Wad::one(), Address([2u8; 32])), + None, + ); + let mut bytes = Vec::with_capacity(97); + bytes.extend_from_slice(&fees.performance.fee_wad.as_u128_trunc().to_le_bytes()); + bytes.extend_from_slice(fees.performance.recipient.as_bytes()); + bytes.extend_from_slice(&fees.management.fee_wad.as_u128_trunc().to_le_bytes()); + bytes.extend_from_slice(fees.management.recipient.as_bytes()); + bytes.push(0); + env.storage().instance().set( + &templar_soroban_runtime::contract::VaultDataKey::FeesSpec, + &Bytes::from_slice(&env, &bytes), + ); + + let mut storage = SorobanStorage::new(&env); + storage + .save_state(&VaultState { + total_assets: u128::MAX, + total_shares: u128::MAX, + idle_assets: u128::MAX, + fee_anchor: FeeAccrualAnchor::new(1, templar_vault_kernel::TimestampNs(1)), + ..Default::default() + }) + .expect("save state"); + + assert_eq!( + proxy.preview_deposit(1), + Err(templar_soroban_runtime::ContractError::ConversionOverflow) + ); + }); +} + #[rstest] fn soroban_contract_refresh_fees_command_updates_anchor() { let env = Env::default();