# XYK reflect_curve is incorrect and decreases K over time leading to loss of funds for LP providers

Bug Deep Dive #14 · 10 December 2025

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

---

| Title | XYK reflect_curve is incorrect and decreases K over time leading to loss of funds for LP providers |
| Reward | $1493, 2 dups |
| Contest | Dango Dex - 15 Sep 2025 on Sherlock |
| Author | haxagon |
| Context | Incorrect Math |

The XYK `reflect_curve` algorithm that generates passive orders works against the pool, computing worse prices than the actual AMM algorithm would give.

```rust
pub fn reflect_curve(
    mut base_reserve: Uint128,
    mut quote_reserve: Uint128,
    params: Xyk,
    swap_fee_rate: Bounded<Udec128, ZeroExclusiveOneExclusive>,
) -> anyhow::Result<(
    Box<dyn Iterator<Item = (Price, Uint128)>>,
    Box<dyn Iterator<Item = (Price, Uint128)>>,
)> {
    // Withhold the funds corresponding to the reserve requirement.
    // These funds will not be used to place orders.
    let one_sub_reserve_ratio = Udec128::ONE - *params.reserve_ratio;
    base_reserve.checked_mul_dec_floor_assign(one_sub_reserve_ratio)?;
    quote_reserve.checked_mul_dec_floor_assign(one_sub_reserve_ratio)?;

    // If either reserve is zero, return empty iterators as there's no liquidity to reflect
    if base_reserve.is_zero() || quote_reserve.is_zero() {
        return Ok((
            Box::new(iter::empty()),
            Box::new(iter::empty()),
        ));
    }

    // Compute the marginal price. We will place orders above and below this price.
    let marginal_price = Price::checked_from_ratio(quote_reserve, base_reserve)?;

    // Construct the bid order iterator.
    // Start from the marginal price minus the swap fee rate.
    let bids = {
        let one_sub_fee_rate = Udec128::ONE.checked_sub(*swap_fee_rate)?;
        let mut maybe_price = marginal_price.checked_mul(one_sub_fee_rate).ok();
        let mut prev_size = Uint128::ZERO;
        let mut prev_size_quote = Uint128::ZERO;

        iter::from_fn(move || {
            // Terminate if price is less or equal to zero.
            let price = match maybe_price {
                Some(price) if price.is_non_zero() => price,
                _ => return None,
            };

            // Compute the total order size (in base asset) at this price.
            let quote_reserve_div_price = quote_reserve.checked_div_dec(price).ok()?;
            let mut size = quote_reserve_div_price.checked_sub(base_reserve).ok()?;

            // Compute the order size (in base asset) at this price.
            //
            // This is the difference between the total order size at
            // this price, and that at the previous price.
            let mut amount = size.checked_sub(prev_size).ok()?;

            // Compute the total order size (in quote asset) at this price.
            let mut amount_quote = amount.checked_mul_dec_ceil(price).ok()?;
            let mut size_quote = prev_size_quote.checked_add(amount_quote).ok()?;

            // If total order size (in quote asset) is greater than the
            // reserve, cap it to the reserve size.
            if size_quote > quote_reserve {
                size_quote = quote_reserve;
                amount_quote = size_quote.checked_sub(prev_size_quote).ok()?;
                amount = amount_quote.checked_div_dec_floor(price).ok()?;
                size = prev_size.checked_add(amount).ok()?;
            }

            // If order size is zero, we have ran out of liquidity.
            // Terminate the iterator.
            if amount.is_zero() {
                return None;
            }

            // Update the iterator state.
            prev_size = size;
            prev_size_quote = size_quote;
            maybe_price = price.checked_sub(params.spacing).ok();

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

    // Construct the ask order iterator.
    let asks = {
        let one_plus_fee_rate = Udec128::ONE.checked_add(*swap_fee_rate)?;
        let mut maybe_price = marginal_price.checked_mul(one_plus_fee_rate).ok();
        let mut prev_size = Uint128::ZERO;

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

            // Compute the total order size (in base asset) at this price.
            let quote_reserve_div_price = quote_reserve.checked_div_dec(price).ok()?;
            let size = base_reserve.checked_sub(quote_reserve_div_price).ok()?;

            // If total order size (in base asset) exceeds the base asset
            // reserve, cap it to the reserve size.
            let size = cmp::min(size, base_reserve);

            // Compute the order size (in base asset) at this price.
            //
            // This is the difference between the total order size at
            // this price, and that at the previous price.
            let amount = size.checked_sub(prev_size).ok()?;

            // If order size is zero, we have ran out of liquidity.
            // Terminate the iterator.
            if amount.is_zero() {
                return None;
            }

            // Update the iterator state.
            prev_size = size;
            maybe_price = price.checked_add(params.spacing).ok();

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

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

The reason for this is that for instance, for bid orders, the function uses the following to determine the order size at step:​

$$\Delta b_n = \frac{Q_0}{P_n} - B_{n-1}$$

The flaw here is that we are using the **initial quote reserve $Q_0$** when dividing by price. This assumes the quote assets do not change as we move away from our initial point on the AMM, but in reality they do. The only thing that should not change is the invariant $K = Q \cdot B$. Instead, it should use the formula:

$$\Delta b_n = \sqrt{\frac{K}{P_n}} - B_{n-1}$$

This works because for a given AMM, at the price $P_n = \frac{Q_n}{B_n}$, we can compute the value of $B_n$ using:

$$\sqrt{\frac{K}{P_n}} = \sqrt{\frac{B_n \cdot Q_n}{P_n}} = B_n$$

**Alpha:** this finding can be reached by plugging in some numbers and checking that the output adds up. For an AMM, the $K$ invariant is supposed to stay the same or increase to pay fees, but here it was found to decrease, so liquidity providers lose value. There is a [Python notebook](https://github.com/left-curve/left-curve/blob/main/notebooks/passive_liquidity.ipynb) that helps with running values; otherwise just write some tests.

Because of the incorrect pricing, the pool charges less than a regular CP-AMM would. This causes $K$ to decrease rather than increase over time, causing a loss of funds for liquidity providers.

**Conclusion**

This finding would earn you **\$1493**, and you get to it by plugging in numbers and verifying that $K$ increases after pool operations. With enough experience, intuition also flags using a fixed initial price when calculating the delta assets to swap.

[**Full Report**](https://audits.sherlock.xyz/contests/1066/report)\
**Codebase**

---

Newer: [Ineffective minimum order size check for ASK limit orders can lead to Denial of Service](https://0xsimao.com/the-contest-academy/bug-deep-dive-15) · Older: [Price cannot be represented when a high-value base asset is quoted in a high-decimal asset](https://0xsimao.com/the-contest-academy/bug-deep-dive-13)
