Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 57 additions & 10 deletions contract/vault/soroban/src/contract/entrypoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
Expand All @@ -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,
};
Comment on lines +924 to +927

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve growth-cap absence in proxy_view

When the fee config has no max_total_assets_growth_rate (the default uncapped mode), this maps it to 0, which is indistinguishable from a configured Some(Wad::zero()). Those two states are materially different because total_assets_for_fee_accrual treats None as uncapped growth but Some(0) as no growth beyond the anchor, and governance currently accepts Some(0). Downstream consumers of the new fee snapshot cannot reconstruct the actual fee policy and may simulate fee accrual incorrectly; return a presence flag/optional encoding or another unambiguous sentinel instead of collapsing None to zero.

Useful? React with 👍 / 👎.

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() {
Expand Down Expand Up @@ -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)
};
Expand Down
12 changes: 9 additions & 3 deletions contract/vault/soroban/src/fungible_vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
172 changes: 171 additions & 1 deletion contract/vault/soroban/tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>,
Expand Down Expand Up @@ -174,6 +174,20 @@ impl<'a> VaultProxy<'a> {
.7)
}

fn max_deposit(&self) -> Result<i128, templar_soroban_runtime::ContractError> {
Ok(self
.view(soroban_sdk::Address::generate(self.env), 0, 0)?
.2
.2)
}

fn max_mint(&self) -> Result<i128, templar_soroban_runtime::ContractError> {
Ok(self
.view(soroban_sdk::Address::generate(self.env), 0, 0)?
.2
.3)
}

fn execute(
&self,
command: &VaultCommand,
Expand Down Expand Up @@ -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();
Expand Down