From a5d52bb293aa502373d75462704cc0b1a2534f76 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 18 May 2026 16:41:41 +0200 Subject: [PATCH 1/4] fix: report proxy fee growth cap (#FIND-085 Nexus 86ddc60c-b4d9-4e2e-a9ad-705890b3fc89) Trace: 863a2b62aec29e0bb039272586f07f578b99d985 --- .../vault/soroban/src/contract/entrypoints.rs | 7 +++- .../vault/soroban/tests/integration_tests.rs | 40 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/contract/vault/soroban/src/contract/entrypoints.rs b/contract/vault/soroban/src/contract/entrypoints.rs index 4672e820f..ce7fb1c54 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, 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, + fee_growth_rate, ); let policy_state = runtime_to_contract(storage.load_policy_state())?.unwrap_or_default(); for entry in policy_state.supply_queue().entries() { diff --git a/contract/vault/soroban/tests/integration_tests.rs b/contract/vault/soroban/tests/integration_tests.rs index 5f53c119a..b47397559 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, @@ -587,6 +587,44 @@ fn soroban_contract_proxy_view_does_not_inflate_from_zero_fee_anchor( }); } +#[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_refresh_fees_command_updates_anchor() { let env = Env::default(); From 49786112a63aaa37e161d91f4654073da401b739 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 18 May 2026 16:42:23 +0200 Subject: [PATCH 2/4] fix: check proxy fee snapshot conversions (#FIND-077 Nexus 082f9cb2-f33c-4006-8528-ef7a7b2918cc) Trace: 5842847b8f3549da0ee5accd98f95d261a630e19 --- .../vault/soroban/src/contract/entrypoints.rs | 6 ++-- .../vault/soroban/tests/integration_tests.rs | 32 +++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/contract/vault/soroban/src/contract/entrypoints.rs b/contract/vault/soroban/src/contract/entrypoints.rs index ce7fb1c54..f9bc2f0d3 100644 --- a/contract/vault/soroban/src/contract/entrypoints.rs +++ b/contract/vault/soroban/src/contract/entrypoints.rs @@ -926,10 +926,10 @@ impl SorobanVaultContract { 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(); diff --git a/contract/vault/soroban/tests/integration_tests.rs b/contract/vault/soroban/tests/integration_tests.rs index b47397559..33faabb0a 100644 --- a/contract/vault/soroban/tests/integration_tests.rs +++ b/contract/vault/soroban/tests/integration_tests.rs @@ -587,6 +587,38 @@ 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, From dcb55f2321aeefc833a035c700b1afb2705461dd Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 18 May 2026 16:43:25 +0200 Subject: [PATCH 3/4] fix: bound proxy max deposit and mint by conversion headroom (#FIND-039 Nexus ae756926-e93c-4d54-a4a6-3a706ed20867) Trace: 335a946bb94246ace588f7ad62cf689ac1e0a244 --- .../vault/soroban/src/contract/entrypoints.rs | 54 ++++++++++++++++--- .../vault/soroban/tests/integration_tests.rs | 51 ++++++++++++++++++ 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/contract/vault/soroban/src/contract/entrypoints.rs b/contract/vault/soroban/src/contract/entrypoints.rs index f9bc2f0d3..bd60aab46 100644 --- a/contract/vault/soroban/src/contract/entrypoints.rs +++ b/contract/vault/soroban/src/contract/entrypoints.rs @@ -974,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/tests/integration_tests.rs b/contract/vault/soroban/tests/integration_tests.rs index 33faabb0a..4b57cce94 100644 --- a/contract/vault/soroban/tests/integration_tests.rs +++ b/contract/vault/soroban/tests/integration_tests.rs @@ -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, @@ -657,6 +671,43 @@ fn soroban_contract_proxy_view_reports_fee_growth_cap( }); } +#[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_refresh_fees_command_updates_anchor() { let env = Env::default(); From 3118c26dacbf2372bb91ce1d4d9f6baa6c253654 Mon Sep 17 00:00:00 2001 From: carrion256 Date: Mon, 18 May 2026 16:44:24 +0200 Subject: [PATCH 4/4] fix: fail fee previews on supply overflow (#FIND-080 Nexus 4460259b-12db-44b2-9396-262b531275c7) Trace: 35b3685e726befc8d078293b5eeaed30617e5595 --- contract/vault/soroban/src/fungible_vault.rs | 12 +++-- .../vault/soroban/tests/integration_tests.rs | 49 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) 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 4b57cce94..7df5a05a6 100644 --- a/contract/vault/soroban/tests/integration_tests.rs +++ b/contract/vault/soroban/tests/integration_tests.rs @@ -708,6 +708,55 @@ fn soroban_contract_proxy_view_max_deposit_and_mint_respect_opposite_headroom( }); } +#[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();