Skip to content
141 changes: 136 additions & 5 deletions packages/contracts/defindex-adapter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,37 @@ use soroban_sdk::{

const DFX_VAULT: Symbol = symbol_short!("DFXVAULT");

// ---------------------------------------------------------------------------
// Slippage tolerance
// ---------------------------------------------------------------------------

/// Maximum tolerated execution-price slippage on either leg of a DeFindex
/// interaction, in basis points. #117 and #432 established this floor
/// off-chain in stellar-sdk-helpers/src/defindex.ts, but the on-chain path
/// MeridianVault::deposit/withdraw actually invokes still passed a literal 0
/// minimum, accepting any price the DeFindex vault happened to offer (#558).
/// A floor set to the exact expected amount would revert on ordinary
/// rounding, so this leaves headroom rather than demanding an exact match.
/// Matches buildDefindexDepositTx/buildDefindexWithdrawTx's own default
/// (packages/stellar-sdk-helpers/src/defindex.ts), so the on-chain floor
/// this adapter enforces is no looser than the off-chain path already does.
const SLIPPAGE_BPS: i128 = 10; // 0.1%
const BPS_DENOMINATOR: i128 = 10_000;

/// Floors `amount` by `SLIPPAGE_BPS`, giving the minimum acceptable amount to
/// pass as the DeFindex vault's `amounts_min` / `min_amounts_out` leg. Uses
/// checked arithmetic, matching the equivalent slippage-floor computation in
/// `vault/src/lib.rs`'s `migrate_adapter`, so an overflow traps with a typed
/// error instead of an unrecoverable panic.
fn min_after_slippage(amount: i128) -> Result<i128, ContractError> {
let discount = amount
.checked_mul(SLIPPAGE_BPS)
.ok_or(ContractError::Overflow)?
.checked_div(BPS_DENOMINATOR)
.ok_or(ContractError::Overflow)?;
amount.checked_sub(discount).ok_or(ContractError::Overflow)
}

// ---------------------------------------------------------------------------
// DeFindex vault interface
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -135,6 +166,11 @@ impl MeridianDefindexAdapter {
/// Called by the vault after transferring `amount` USDC to this adapter.
/// Deposits USDC into the DeFindex vault on behalf of the adapter and
/// returns the dfToken shares received.
///
/// `amounts_min` is floored `SLIPPAGE_BPS` below `amount` (the exact
/// desired deposit), not 0, so the DeFindex vault call rejects execution
/// that lands materially below what was requested instead of accepting
/// any price it happens to offer.
pub fn deposit(env: Env, amount: i128) -> i128 {
require_vault_auth(&env);

Expand All @@ -146,7 +182,13 @@ impl MeridianDefindexAdapter {

let client = DefindexVaultClient::new(&env, &dfx);
let shares_before = client.balance(&adapter);
let _ = client.deposit(&vec![&env, amount], &vec![&env, 0_i128], &adapter, &true);
let min_amount = min_after_slippage(amount).unwrap_or_else(|e| panic_with_error!(&env, e));
let _ = client.deposit(
&vec![&env, amount],
&vec![&env, min_amount],
&adapter,
&true,
);
let shares_after = client.balance(&adapter);

// checked_sub alone only catches i128 overflow, not a merely
Expand All @@ -167,6 +209,15 @@ impl MeridianDefindexAdapter {
/// Called by the vault to redeem `shares` dfTokens from the DeFindex vault.
/// DeFindex sends USDC to this adapter; the adapter forwards it to
/// `recipient`. Returns the USDC amount received.
///
/// `min_amounts_out` is floored `SLIPPAGE_BPS` below the payout DeFindex's
/// own `get_asset_amounts_per_shares` quotes for `shares` just before the
/// call, not 0, so a withdrawal executing at a materially worse rate than
/// quoted fails instead of paying out whatever DeFindex offers. The quote
/// itself is required to have a valid first element: a malformed or empty
/// response must not silently collapse the floor to 0, which would
/// reintroduce the exact "accept any price" bug this floor exists to
/// close, precisely when the DeFindex vault is misbehaving.
pub fn withdraw(env: Env, shares: i128, recipient: Address) -> i128 {
require_vault_auth(&env);

Expand All @@ -177,8 +228,13 @@ impl MeridianDefindexAdapter {
let usdc = get_usdc(&env);
let adapter = env.current_contract_address();

let amounts =
DefindexVaultClient::new(&env, &dfx).withdraw(&shares, &vec![&env, 0_i128], &adapter);
let client = DefindexVaultClient::new(&env, &dfx);
let expected = match client.get_asset_amounts_per_shares(&shares).get(0) {
Some(value) => value,
None => panic_with_error!(&env, ContractError::MalformedProtocolResponse),
};
let min_out = min_after_slippage(expected).unwrap_or_else(|e| panic_with_error!(&env, e));
let amounts = client.withdraw(&shares, &vec![&env, min_out], &adapter);

let usdc_out: i128 = match amounts.get(0) {
Some(value) => value,
Expand Down Expand Up @@ -263,13 +319,18 @@ mod tests {
// lets tests configure exactly what `withdraw` returns, so the
// MalformedProtocolResponse edge case in the real adapter (a
// differently-shaped or empty return vector) can be exercised directly.
// `deposit`/`withdraw` also record the `amounts_min`/`min_amounts_out`
// leg they were called with, so #558's regression tests can assert the
// adapter sent a real floor instead of a literal 0.
// -----------------------------------------------------------------------

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_FAULTY: Symbol = symbol_short!("MDV_FT");
const MDV_AAMT: Symbol = symbol_short!("MDV_AAMT");
const MDV_DMIN: Symbol = symbol_short!("MDV_DMIN");
const MDV_WMIN: Symbol = symbol_short!("MDV_WMIN");

#[contract]
pub struct MockDefindexVault;
Expand All @@ -293,10 +354,20 @@ mod tests {
env.storage().instance().set(&MDV_AAMT, &amounts);
}

// The `amounts_min` the last `deposit()` call was made with.
pub fn last_deposit_min(env: Env) -> i128 {
env.storage().instance().get(&MDV_DMIN).unwrap_or(0)
}

// The `min_amounts_out` the last `withdraw()` call was made with.
pub fn last_withdraw_min(env: Env) -> i128 {
env.storage().instance().get(&MDV_WMIN).unwrap_or(0)
}

pub fn deposit(
env: Env,
amounts_desired: Vec<i128>,
_amounts_min: Vec<i128>,
amounts_min: Vec<i128>,
from: Address,
_invest: bool,
) -> Val {
Expand All @@ -307,6 +378,9 @@ mod tests {
);
// Vec.get() safely returns Option which defaults to 0, so unwrap_or is safe.
let amount = amounts_desired.get(0).unwrap_or(0);
env.storage()
.instance()
.set(&MDV_DMIN, &amounts_min.get(0).unwrap_or(0));
TokenClient::new(&env, &usdc).transfer(&from, &env.current_contract_address(), &amount);

let faulty: bool = env.storage().instance().get(&MDV_FAULTY).unwrap_or(false);
Expand All @@ -326,9 +400,13 @@ mod tests {
pub fn withdraw(
env: Env,
withdraw_shares: i128,
_min_amounts_out: Vec<i128>,
min_amounts_out: Vec<i128>,
from: Address,
) -> Vec<i128> {
env.storage()
.instance()
.set(&MDV_WMIN, &min_amounts_out.get(0).unwrap_or(0));

let prev: i128 = env.storage().instance().get(&MDV_SH).unwrap_or(0);
env.storage()
.instance()
Expand Down Expand Up @@ -504,6 +582,59 @@ mod tests {
assert_eq!(TokenClient::new(&env, &usdc_id).balance(&recipient), amount);
}

#[test]
fn deposit_passes_a_real_slippage_floor_not_zero() {
// Regression test for #558: deposit() must not pass 0 as
// amounts_min, letting the DeFindex vault execute at any price.
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);

let expected_min = amount - (amount * 10) / 10_000;
assert!(expected_min > 0);
assert_eq!(dfx.last_deposit_min(), expected_min);
}

#[test]
fn withdraw_passes_a_real_slippage_floor_not_zero() {
// Regression test for #558: withdraw() must not pass 0 as
// min_amounts_out, letting the DeFindex vault execute at any price.
// The mock values 1:1, so the quoted payout equals `shares`.
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);

let recipient = Address::generate(&env);
adapter.withdraw(&amount, &recipient);

let expected_min = amount - (amount * 10) / 10_000;
assert!(expected_min > 0);
assert_eq!(dfx.last_withdraw_min(), expected_min);
}

#[test]
fn withdraw_errs_on_malformed_price_quote() {
// The gap the original #558 fix left open: get_asset_amounts_per_shares
// (the pre-withdraw price quote used to compute the slippage floor)
// returning an empty vector must fail loudly, not silently collapse
// the floor to 0 and let the withdrawal proceed at any price.
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);

dfx.set_asset_amounts_per_shares(&Vec::new(&env));

let recipient = Address::generate(&env);
let result = adapter.try_withdraw(&amount, &recipient);
assert!(result.is_err());
}

#[test]
fn withdraw_errs_on_malformed_defindex_response() {
let (env, vault, usdc_id, adapter, dfx) = setup();
Expand Down
Loading