diff --git a/packages/contracts/defindex-adapter/src/lib.rs b/packages/contracts/defindex-adapter/src/lib.rs index 101cbc7c..03d551e3 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, }; // --------------------------------------------------------------------------- @@ -54,6 +54,10 @@ pub enum ContractError { AlreadyInitialized = 1, /// A state-mutating call was made before `initialize`. NotInitialized = 2, + /// The DeFindex vault returned a response that cannot be interpreted as + /// a valid asset valuation (e.g. empty vector or missing element at + /// index 0). + MalformedProtocolResponse = 3, } impl From for ContractError { @@ -162,9 +166,10 @@ impl MeridianDefindexAdapter { let amounts = DefindexVaultClient::new(&env, &dfx).withdraw(&shares, &vec![&env, 0_i128], &adapter); - // Vec.get() returns Option which safely defaults to 0 if the vector doesn't contain - // index 0 or is empty, so this unwrap_or is safe and intentional. - let usdc_out: i128 = amounts.get(0).unwrap_or(0); + let usdc_out: i128 = match amounts.get(0) { + Some(value) => value, + None => panic_with_error!(&env, ContractError::MalformedProtocolResponse), + }; if usdc_out > 0 { TokenClient::new(&env, &usdc).transfer(&adapter, &recipient, &usdc_out); } @@ -188,9 +193,10 @@ impl MeridianDefindexAdapter { } let amounts = client.get_asset_amounts_per_shares(&shares); - // Vec.get() returns Option which safely defaults to 0 if the vector doesn't contain - // index 0 or is empty, so this unwrap_or is safe and intentional. - amounts.get(0).unwrap_or(0) + match amounts.get(0) { + Some(value) => value, + None => panic_with_error!(&env, ContractError::MalformedProtocolResponse), + } } /// No-op: DeFindex's total_assets() already prices live on every call @@ -229,13 +235,14 @@ mod tests { // MockDefindexVault: a minimal DeFindex vault double. Tracks the adapter's // dfToken balance 1:1 with USDC deposited/withdrawn. `set_withdraw_amounts` // lets tests configure exactly what `withdraw` returns, so the - // `amounts.get(0).unwrap_or(0)` edge case in the real adapter (a + // MalformedProtocolResponse edge case in the real adapter (a // differently-shaped or empty return vector) can be exercised directly. // ----------------------------------------------------------------------- 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; @@ -253,6 +260,12 @@ mod tests { env.storage().instance().set(&MDV_WAMT, &amounts); } + // Overrides what the next get_asset_amounts_per_shares() call returns, + // to simulate a malformed DeFindex response. + pub fn set_asset_amounts_per_shares(env: Env, amounts: Vec) { + env.storage().instance().set(&MDV_AAMT, &amounts); + } + pub fn deposit( env: Env, amounts_desired: Vec, @@ -313,8 +326,11 @@ 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] + let amounts_override: Option> = env.storage().instance().get(&MDV_AAMT); + match amounts_override { + Some(amounts) => amounts, + None => vec![&env, desired_shares], + } } } @@ -413,7 +429,7 @@ mod tests { } #[test] - fn withdraw_returns_zero_when_defindex_returns_no_amounts() { + fn withdraw_errs_on_malformed_defindex_response() { let (env, vault, usdc_id, adapter, dfx) = setup(); let amount = 100_0000000_i128; @@ -421,15 +437,13 @@ mod tests { adapter.deposit(&amount); // Simulate a shape mismatch: DeFindex returns an empty vector instead - // of the expected [usdc_amount] — amounts.get(0).unwrap_or(0) must not - // panic, and no USDC should move. + // of the expected [usdc_amount] — withdraw() must fail loudly with + // MalformedProtocolResponse, not silently return zero. dfx.set_withdraw_amounts(&Vec::new(&env)); let recipient = Address::generate(&env); - let usdc_out = adapter.withdraw(&amount, &recipient); - - assert_eq!(usdc_out, 0); - assert_eq!(TokenClient::new(&env, &usdc_id).balance(&recipient), 0); + let result = adapter.try_withdraw(&amount, &recipient); + assert!(result.is_err()); } #[test] @@ -452,6 +466,23 @@ mod tests { assert_eq!(adapter.total_assets(), amount); } + #[test] + fn total_assets_errs_on_malformed_defindex_response() { + 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 not silently + // return zero, which would cause massive share dilution on deposit. + dfx.set_asset_amounts_per_shares(&Vec::new(&env)); + + let result = adapter.try_total_assets(); + assert!(result.is_err()); + } + #[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 315ec1d0..2da40543 100644 --- a/packages/contracts/vault/src/lib.rs +++ b/packages/contracts/vault/src/lib.rs @@ -136,6 +136,11 @@ pub enum ContractError { /// `transfer_admin` call has happened, or a previous nomination was /// already accepted). NoPendingAdmin = 16, + /// The adapter reported zero or negative total assets while the vault + /// still has shares outstanding, indicating a broken adapter or + /// malformed protocol response. Depositing would dilute all existing + /// holders. + AdapterReportedNoAssets = 17, } // --------------------------------------------------------------------------- @@ -199,6 +204,12 @@ impl MeridianVault { // Share price is based on the adapter's total assets (includes yield). let total_assets = AdapterClient::new(&env, &adapter_addr).total_assets(); + // A held position that cannot be valued is an error, not a zero price. + // Minting here would dilute every existing holder. + 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. @@ -1917,6 +1928,27 @@ mod tests { assert_eq!(result, Err(Ok(ContractError::WithdrawalTooSmall))); } + #[test] + fn deposit_fails_when_adapter_reports_zero_assets_with_shares_outstanding() { + // If the adapter reports zero total_assets while the vault has shares + // outstanding, the vault must reject the deposit rather than minting + // massively inflated shares (which would dilute all existing holders). + let (env, _admin, user, usdc_id, _musdc, adapter_id, vault) = setup(); + + let amount = 100_0000000_i128; + vault.deposit(&user, &amount); + + // Drain the adapter's USDC balance to zero so total_assets() returns 0, + // simulating a malformed DeFindex response. + let drain_sink = Address::generate(&env); + TokenClient::new(&env, &usdc_id).transfer(&adapter_id, &drain_sink, &amount); + + let user2 = Address::generate(&env); + StellarAssetClient::new(&env, &usdc_id).mint(&user2, &100_0000000_i128); + let result = vault.try_deposit(&user2, &100_0000000_i128); + assert_eq!(result, Err(Ok(ContractError::AdapterReportedNoAssets))); + } + // Acceptance-criteria tests for the refresh() cache mechanism ----------- #[test]