Skip to content
Request an audit

Asymmetric liquidity provision to geometric pool can allow attacker to purchase at flat oracle price, irrespective of trade size

TitleAsymmetric liquidity provision to geometric pool can allow attacker to purchase at flat oracle price, irrespective of trade size
Reward$4480, 3 dups
ContestDango Dex - 15 Sep 2025 on Sherlock
Authorhaxagon
ContextBusiness Logic

The geometric pool reflects passive orders centered at the oracle price, independent of pool balances.

Prices across levels are spaced by a fixed tick spacing and order sizes follow a geometric ratio of remaining inventory.

rust
pub fn reflect_curve(
    oracle_querier: &mut OracleQuerier,
    base_denom: &Denom,
    quote_denom: &Denom,
    base_reserve: Uint128,
    quote_reserve: Uint128,
    params: Geometric,
    swap_fee_rate: Bounded<Udec128, ZeroExclusiveOneExclusive>,
) -> anyhow::Result<(
    Box<dyn Iterator<Item = (Price, Uint128)>>,
    Box<dyn Iterator<Item = (Price, Uint128)>>,
)> {
    // Compute the price of the base asset denominated in the quote asset.
    // We will place orders above and below this price.
    //
    // Note that we aren't computing the price in the human units, but in their
    // base units. In other words, we don't want to know how many BTC is per USDC;
    // we want to know how many sat (1e-8 BTC) is per 1e-6 USDC.
    let marginal_price = {
        const PRECISION: Uint128 = Uint128::new(1_000_000);

        let base_price: Price = oracle_querier
            .query_price(base_denom, None)?
            .value_of_unit_amount(PRECISION)?;
        let quote_price: Price = oracle_querier
            .query_price(quote_denom, None)?
            .value_of_unit_amount(PRECISION)?;

        base_price.checked_div(quote_price)?
    };

    // Construct bid price iterator with decreasing prices.
    let bids = {
        let one_sub_fee_rate = Udec128::ONE.checked_sub(*swap_fee_rate)?;
        let bid_starting_price = marginal_price.checked_mul(one_sub_fee_rate)?;
        let mut maybe_price = Some(bid_starting_price);
        let mut remaining_quote = quote_reserve;

        iter::from_fn(move || {
            let price = maybe_price?;
            if price.is_zero() {
                return None;
            }

            let size_in_quote = remaining_quote.checked_mul_dec(*params.ratio).ok()?;
            let size = size_in_quote.checked_div_dec_floor(price).ok()?;
            if size.is_zero() {
                return None;
            }

            maybe_price = price.checked_sub(params.spacing).ok();
            remaining_quote.checked_sub_assign(size_in_quote).ok()?;

            Some((price, size))
        })
        .take(params.limit)
    };

    // Construct ask price iterator with increasing prices.
    let asks = {
        let one_plus_fee_rate = Udec128::ONE.checked_add(*swap_fee_rate)?;
        let ask_starting_price = marginal_price.checked_mul(one_plus_fee_rate)?;
        let mut maybe_price = Some(ask_starting_price);
        let mut remaining_base = base_reserve;

        iter::from_fn(move || {
            let price = maybe_price?;

            let size = remaining_base.checked_mul_dec(*params.ratio).ok()?;
            if size.is_zero() {
                return None;
            }

            maybe_price = price.checked_add(params.spacing).ok();
            remaining_base.checked_sub_assign(size).ok()?;

            Some((price, size))
        })
        .take(params.limit)
    };

    Ok((Box::new(bids), Box::new(asks)))
}

The problem is that asymmetric liquidity provision performs a virtual swap at the oracle price, ignoring the spacing and geometric ratio parameters. That allows buying up to the entire liquidity of the pool while bypassing the price increase the geometric pool applies as size grows.

Initial State

Suppose we have a geometric pool consisting of 10 ETH and 20000 USDC. The price is 1 ETH = 2000 USDC and the swap fee is 0.1%.

An attacker wants to swap 1 ETH for USDC in this pool.

Path 1

They call add_liquidity with 2.222 ETH, then immediately burn the LP tokens via withdraw_liquidity to take back ETH and USDC, realising the virtual swap performed during the asymmetric deposit.

USD value of the virtual swap:

x = 4444 · 2000020000 + 20000 + 4444 = 2000
FeeRate = 2000 · 0.0014444 = 0.00045
ETH = (1 − 0.00045) · (4444440000 − 1) · (4444440000)−1 · 12.2222 = 1.2215
USDC = (1 − 0.00045) · (4444440000 − 1) · (4444440000)−1 · 20000 = 1999

Notably, we can always obtain a 1 ETH to 2000 USDC trade from the geometric pool regardless of params.spacing and params.ratio.

Path 2

By comparison, suppose params.ratio is 0.05 and params.spacing is 100 USD.
We obtain the following relevant price bands:
0.5 ETH @ 1998 USDC
0.475 ETH @ 1898 USDC
0.45125 ETH @ 1798 USDC
To fill our 1 ETH ask we execute 0.5 ETH @ 1998 USDC, 0.475 ETH @ 1898 USDC and 0.025 ETH @ 1798 USDC, yielding about 1946 USDC. The asymmetric liquidity provision therefore bypasses the price bands of the geometric pool and gets a better swap at the flat oracle price.

Conclusion

This finding would earn you $4480, and is another example of asking how the same outcome can be reached more cheaply. Here, single-sided liquidity provision is abused and then withdrawn as USDC, simulating a swap at a better price.

Full Report
Codebase

Message on Telegram All 62 posts