From e02dcd8192c407928e4188646154b3dbc80de664 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Mon, 24 Aug 2026 19:11:48 +0200 Subject: [PATCH] fix(contracts): error on malformed DeFindex valuation, add vault zero-assets backstop DefindexAdapter::total_assets() is the denominator of the vault's share-pricing formula. On a malformed get_asset_amounts_per_shares response, amounts.get(0).unwrap_or(0) manufactured a valid-looking zero price, collapsing the denominator to OFFSET and letting any meaningful deposit mint a share count that dilutes every prior depositor. - defindex-adapter: total_assets() now panics with MalformedProtocolResponse when a held position cannot be valued, instead of returning zero. - vault: deposit() rejects with AdapterReportedNoAssets when shares are outstanding but the adapter reports zero/negative total assets, so no present or future adapter can drive the pricing denominator to zero. Closes #555 Signed-off-by: laurentketterle-hub --- .../contracts/defindex-adapter/src/lib.rs | 57 +++++++++- packages/contracts/vault/src/lib.rs | 104 ++++++++++++++++++ 2 files changed, 156 insertions(+), 5 deletions(-) diff --git a/packages/contracts/defindex-adapter/src/lib.rs b/packages/contracts/defindex-adapter/src/lib.rs index 24b8bfaa..aa811804 100644 --- a/packages/contracts/defindex-adapter/src/lib.rs +++ b/packages/contracts/defindex-adapter/src/lib.rs @@ -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, }; // --------------------------------------------------------------------------- @@ -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 for ContractError { @@ -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 @@ -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; @@ -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) { + env.storage().instance().set(&MDV_AAMT, &amounts); + } + pub fn deposit( env: Env, amounts_desired: Vec, @@ -280,7 +301,12 @@ mod tests { pub fn get_asset_amounts_per_shares(env: Env, desired_shares: i128) -> Vec { // 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]) } } @@ -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(); diff --git a/packages/contracts/vault/src/lib.rs b/packages/contracts/vault/src/lib.rs index 5da2079f..bf6a647d 100644 --- a/packages/contracts/vault/src/lib.rs +++ b/packages/contracts/vault/src/lib.rs @@ -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, } // --------------------------------------------------------------------------- @@ -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. @@ -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 @@ -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();