Skip to content
Merged
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
9 changes: 7 additions & 2 deletions contracts/share-price-math/src/fuzz_invariants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ fn assets_for(shares: i128, total_shares: i128, total_assets: i128) -> Result<i1
try_shares_to_assets(shares, total_shares, total_assets)
}

/// Real vaults are either empty or have both shares and assets outstanding.
/// Real vaults are either empty or have shares outstanding (including zero idle assets).
fn is_valid_vault_state(total_shares: i128, total_assets: i128) -> 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).
Expand Down Expand Up @@ -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);
}
}
43 changes: 27 additions & 16 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -2521,12 +2524,15 @@ impl YieldVault {
/// Always rounds DOWN to prevent over-minting shares.
pub fn calculate_shares(env: Env, assets: i128) -> Result<i128, VaultError> {
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.
///
Expand All @@ -2540,7 +2546,8 @@ impl YieldVault {
/// Always rounds DOWN to prevent over-withdrawal of assets.
pub fn calculate_assets(env: Env, shares: i128) -> Result<i128, VaultError> {
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)
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)?
};
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)?
};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)?;

Expand Down
59 changes: 54 additions & 5 deletions contracts/vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading