Skip to content
Open
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
57 changes: 52 additions & 5 deletions packages/contracts/defindex-adapter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use adapter_common::{
get_usdc, require_not_initialized, require_vault_auth, store_vault_and_usdc, AdapterError,
};
use soroban_sdk::{
contract, contractclient, contracterror, contractimpl, symbol_short, token::TokenClient, vec,
Address, Env, Symbol, Val, Vec,
contract, contractclient, contracterror, contractimpl, panic_with_error, symbol_short,
token::TokenClient, vec, Address, Env, Symbol, Val, Vec,
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -52,6 +52,12 @@ pub trait DefindexVaultInterface {
pub enum ContractError {
/// `initialize` was called on an adapter that already has a vault set.
AlreadyInitialized = 1,
/// `get_asset_amounts_per_shares` returned a vector without a value at
/// index 0 for a held position. The adapter refuses to manufacture a zero
/// price out of a failed or malformed protocol read, because a zero
/// valuation on the share-pricing path would collapse the vault's
/// denominator and dilute every existing depositor.
MalformedProtocolResponse = 2,
}

impl From<AdapterError> for ContractError {
Expand Down Expand Up @@ -168,8 +174,15 @@ impl MeridianDefindexAdapter {
return 0;
}

let amounts = client.get_asset_amounts_per_shares(&shares);
amounts.get(0).unwrap_or(0)
// A held position that cannot be valued is an error, not a zero price.
// `get_asset_amounts_per_shares` returning a vector without an element
// at index 0 (a DeFindex-side shape change or misbehaviour) must fail
// the call, rather than silently manufacture a zero that collapses the
// vault's share-pricing denominator and dilutes existing holders.
match client.get_asset_amounts_per_shares(&shares).get(0) {
Some(value) => value,
None => panic_with_error!(&env, ContractError::MalformedProtocolResponse),
}
}

/// No-op: DeFindex's total_assets() already prices live on every call
Expand Down Expand Up @@ -212,6 +225,7 @@ mod tests {
const MDV_USDC: Symbol = symbol_short!("MDV_USDC");
const MDV_SH: Symbol = symbol_short!("MDV_SH");
const MDV_WAMT: Symbol = symbol_short!("MDV_WAMT");
const MDV_AAMT: Symbol = symbol_short!("MDV_AAMT");

#[contract]
pub struct MockDefindexVault;
Expand All @@ -229,6 +243,13 @@ mod tests {
env.storage().instance().set(&MDV_WAMT, &amounts);
}

// Overrides what get_asset_amounts_per_shares() returns, to simulate a
// differently-shaped (e.g. empty) response from DeFindex on the pricing
// path.
pub fn set_asset_amounts(env: Env, amounts: Vec<i128>) {
env.storage().instance().set(&MDV_AAMT, &amounts);
}

pub fn deposit(
env: Env,
amounts_desired: Vec<i128>,
Expand Down Expand Up @@ -280,7 +301,12 @@ mod tests {

pub fn get_asset_amounts_per_shares(env: Env, desired_shares: i128) -> Vec<i128> {
// 1:1 valuation, matching the deposit/withdraw rate used above.
vec![&env, desired_shares]
// Overridable via set_asset_amounts so tests can simulate a
// differently-shaped (e.g. empty) response on the pricing path.
env.storage()
.instance()
.get(&MDV_AAMT)
.unwrap_or_else(|| vec![&env, desired_shares])
}
}

Expand Down Expand Up @@ -397,6 +423,27 @@ mod tests {
assert_eq!(adapter.total_assets(), amount);
}

#[test]
#[should_panic]
fn total_assets_errors_on_malformed_protocol_response() {
// A held position that cannot be valued must fail the call, not return
// a manufactured zero. #555: the identical unwrap_or(0) pattern was
// previously the pricing-path denominator, so a malformed response
// collapsed it to OFFSET and diluted every existing depositor.
let (env, vault, usdc_id, adapter, dfx) = setup();
let amount = 100_0000000_i128;

TokenClient::new(&env, &usdc_id).transfer(&vault, &adapter.address, &amount);
adapter.deposit(&amount);

// Simulate a shape mismatch: DeFindex returns an empty vector instead
// of the expected [usdc_amount]. total_assets() must panic with
// MalformedProtocolResponse rather than report zero.
dfx.set_asset_amounts(&Vec::new(&env));

adapter.total_assets();
}

#[test]
fn reinitializing_fails() {
let (_env, vault, usdc_id, adapter, dfx) = setup();
Expand Down
104 changes: 104 additions & 0 deletions packages/contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,11 @@ pub enum ContractError {
NoAdapterPosition = 13,
/// `migrate_adapter` was called with `max_slippage_bps > 10_000`.
InvalidSlippageBps = 14,
/// The active adapter reported zero (or negative) total assets while the
/// vault still has shares outstanding. Shares outstanding against zero
/// reported assets is not a price, it is a broken adapter; minting on top
/// of it would dilute every existing holder. (#555)
AdapterReportedNoAssets = 16,
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -176,6 +181,14 @@ impl MeridianVault {
// Share price is based on the adapter's total assets (includes yield).
let total_assets = AdapterClient::new(&env, &adapter_addr).total_assets();

// Shares outstanding against zero reported assets is not a price, it is
// a broken adapter. The virtual offset alone cannot bound a denominator
// that has failed to zero: minting here would issue a share count that
// dwarfs the existing supply and dilute every prior depositor (#555).
if total_shares > 0 && total_assets <= 0 {
return Err(ContractError::AdapterReportedNoAssets);
}

// shares_to_mint = amount * (total_shares + OFFSET) / (total_assets + OFFSET)
// The virtual offset makes the first-deposit price 1 share = 1 stroop while
// neutralising the inflation attack on every subsequent deposit.
Expand Down Expand Up @@ -742,6 +755,54 @@ mod tests {
}
}

// -----------------------------------------------------------------------
// ZeroAssetsMockAdapter: simulates a broken adapter whose total_assets()
// reports zero even though it holds a real position (shares outstanding).
// Exercises the vault's AdapterReportedNoAssets backstop: with shares
// outstanding, a deposit priced against a zero denominator would mint a
// share count that dwarfs the existing supply, so the vault must reject it.
// -----------------------------------------------------------------------
mod zero_assets_mock {
use super::*;

const ZA_USDC: Symbol = symbol_short!("ZA_USDC");
const ZA_SH: Symbol = symbol_short!("ZA_SH");

#[contract]
pub struct ZeroAssetsMockAdapter;

#[contractimpl]
impl ZeroAssetsMockAdapter {
pub fn initialize(env: Env, usdc: Address) {
env.storage().instance().set(&ZA_USDC, &usdc);
env.storage().instance().set(&ZA_SH, &0_i128);
}

pub fn deposit(env: Env, amount: i128) -> i128 {
let prev: i128 = env.storage().instance().get(&ZA_SH).unwrap_or(0);
env.storage().instance().set(&ZA_SH, &(prev + amount));
amount
}

pub fn withdraw(env: Env, shares: i128, recipient: Address) -> i128 {
let usdc: Address = env.storage().instance().get(&ZA_USDC).unwrap();
mock_proportional_withdraw(&env, &usdc, &ZA_SH, shares, &recipient)
}

pub fn total_assets(_env: Env) -> i128 {
// Broken adapter: reports no assets regardless of its actual
// holdings, matching the #555 failure mode where a malformed
// protocol read silently collapses the valuation to zero.
0
}

pub fn refresh(_env: Env) {
// No-op, but total_assets() still reports zero: the point is
// that the adapter cannot be trusted to value its position.
}
}
}

// -----------------------------------------------------------------------
// CachedMockAdapter: mimics BlendAdapter's caching behavior. total_assets()
// returns a cached value that only updates on refresh(), letting these
Expand Down Expand Up @@ -906,6 +967,49 @@ mod tests {
assert_eq!(vault.get_total_shares(), amount);
}

#[test]
fn deposit_rejected_when_adapter_reports_zero_assets_with_shares_outstanding() {
use zero_assets_mock::{ZeroAssetsMockAdapter, ZeroAssetsMockAdapterClient};

let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let user = Address::generate(&env);

let usdc_id = env
.register_stellar_asset_contract_v2(admin.clone())
.address();
let musdc_id = env
.register_stellar_asset_contract_v2(admin.clone())
.address();

let adapter_id = env.register(ZeroAssetsMockAdapter, ());
ZeroAssetsMockAdapterClient::new(&env, &adapter_id).initialize(&usdc_id);

let vault_id = env.register(MeridianVault, ());
let vault = MeridianVaultClient::new(&env, &vault_id);
vault.initialize(&admin, &usdc_id, &musdc_id, &adapter_id);

StellarAssetClient::new(&env, &musdc_id).set_admin(&vault_id);
StellarAssetClient::new(&env, &usdc_id).mint(&user, &10_000_000_000_i128);

// First deposit: no shares outstanding yet, so zero reported assets is
// the honest "empty vault" price and the deposit is allowed.
let first = 100_0000000_i128;
let shares = vault.deposit(&user, &first);
assert!(shares > 0);

// The adapter holds a real position but reports zero assets. A second
// deposit must be rejected before minting shares against a zero
// denominator, rather than dilute the existing holder.
let result = vault.try_deposit(&user, &first);
assert_eq!(result, Err(Ok(ContractError::AdapterReportedNoAssets)));

// The rejected deposit must not have moved any state.
assert_eq!(vault.get_total_shares(), shares);
}

#[test]
fn withdraw_returns_usdc() {
let (env, _admin, user, usdc_id, _musdc, _adapter, vault) = setup();
Expand Down