Skip to content
Request an audit

‹ All findings

RangePool::adjustToTick() desyncs when nextTick == tick leading to stolen fees

Crit/HighBMX·Sherlock · Perpetuals DEX · 2nd September 2025H-5

Summary

RangePool::adjustToTick() breaks when the nextTick equals the current tick:

solidity
function adjustToTick(State storage self, int24 tickSpacing, int24 tick, address[] memory tokens) internal {
    int24 currentTick = self.tick;
    int128 liquidityChange = 0;
    bool lte = tick <= currentTick;

    if (lte) {
        while (tick < currentTick) {
            (int24 nextTick, bool initialized) =
                self.tickBitmap.nextInitializedTickWithinOneWord(currentTick, tickSpacing, true);

            if (nextTick <= tick) { //@audit here
                break;
            }
    ...

However, this will desync with the Uniswap pool whenever the sqrtPrice reaches the next tick.

For example, consider that a swap moved the price to tick 60, tick spacing is 60, and there is an initialized tick at 60. Looking at the Uniswap pool code, it will cross the tick, and positions from ticks 0 to 60 will now accrue fees, not 60 to 120.

But, this won't happen in the DailyEpochGauge nor IncentiveGauge. These pools will be poked after the swap, so the tick at 60 has been crossed in the Uniswap pool, but in the RangePool::adjustToTick() logic, it won't cross the tick, as the nextTick is 60, and so is the tick, which breaks and doesn't flip the tick nor update liquidity.

As a result, the following happens:

  1. Positions with tick lower at 60 should be out of range (real Uniswap pool tick becomes 59 when the sqrtPrice reaches 60), but in the DailyEpochGauge and IncentiveGauge they will in reality be in range.
  2. Thus, an attacker may exploit this to add liquidity to the Uniswap pool at ticks 60 to 120, out of range, where regular users and bots won't add liquidity because it is out of range. The attacker will get away with most of the fees.
  3. An attacker can force this to happen by specifying the sqrtLimit in the swap to be exactly the next initialized tick, and add liquidity to the out of range position to steal fees.

Root Cause

In RangePool.sol:174, the break condition is incorrect and makes the pool desync.

Internal Pre-conditions

None.

External Pre-conditions

None.

Attack Path

  1. Attacker swaps in PoolManager.swap(), setting the sqrtPrice to the next initialized tick. The Uniswap pool will cross the tick, but the DailyEpochGauge/IncentiveGauge won't.
  2. Attacker adds liquidity to the position out of range in the Uniswap pool, stealing the fees from honest users that placed liquidity in the in range position.

Impact

100% of the fees are stolen by the out of range positions.

PoC

See above.