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

Bug Deep Dive #10 · 6 December 2025

Bug Deep Dives · [The Contest Academy](https://0xsimao.com/the-contest-academy) · 0xSimao

---

| Title | Asymmetric liquidity provision to geometric pool can allow attacker to purchase at flat oracle price, irrespective of trade size |
| Reward | $4480, 3 dups |
| Contest | Dango Dex - 15 Sep 2025 on Sherlock |
| Author | haxagon |
| Context | Business 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 = \frac{4444 \cdot 20000}{20000 + 20000 + 4444} = 2000$$

$$\text{FeeRate} = \frac{2000 \cdot 0.001}{4444} = 0.00045$$

$$\text{ETH} = (1 - 0.00045) \cdot \left(\frac{44444}{40000} - 1\right) \cdot \left(\frac{44444}{40000}\right)^{-1} \cdot 12.2222 = 1.2215$$

$$\text{USDC} = (1 - 0.00045) \cdot \left(\frac{44444}{40000} - 1\right) \cdot \left(\frac{44444}{40000}\right)^{-1} \cdot 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**](https://audits.sherlock.xyz/contests/1066/report)\
[**Codebase**](https://github.com/left-curve/left-curve/tree/main/dango/dex)

---

Newer: [XYK reflect_curve omits swap fee in order sizing, leaking LP fees](https://0xsimao.com/the-contest-academy/bug-deep-dive-11) · Older: [Users can swap at the best price by splitting swap into several small swap](https://0xsimao.com/the-contest-academy/bug-deep-dive-9)
