# Rebalancing on slot0.tick centres the band one tick off the price

Bug Deep Dive #34 · 8 August 2026

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

---

| Title | Strategy main ticks are set according to the tick in slot0, leading to incorrect allocation and loss of funds |
| Reward | $3738, Unique |
| Contest | [Yieldoor - 24 February 2025 on Sherlock](https://audits.sherlock.xyz/contests/791) |
| Author | [0x73696d616f (0xSimao)](https://0xsimao.com) |
| Context | Tick precision |

[Yieldoor](https://audits.sherlock.xyz/contests/791) is a concentrated liquidity manager. On every rebalance it picks a band around the current price and mints liquidity into it. Centring is the whole product: a band centred on the price holds roughly half its value in each token and earns fees on both sides of the market. Get the centre wrong and the position is lopsided from the moment it is minted.

So the number the strategy centres on has to be the price. [`rebalance()`](https://github.com/sherlock-audit/2025-02-yieldoor/blob/b5a0f779dce4236b02665606adb610099451a51a/yieldoor/src/Strategy.sol#L204-L214) reads both numbers out of one `slot0()` call and then sends each to a different place:

```solidity:204
_removeLiquidity();
(uint256 amount0, uint256 amount1) = idleBalances();

@>(uint160 sqrtPriceX96, int24 tick,,,,,) = IUniswapV3Pool(pool).slot0();

@>_setMainTicks(tick);
@>(amount0, amount1) = _addLiquidityToMainPosition(sqrtPriceX96, amount0, amount1);

_setSecondaryPositionsTicks(tick);
_addLiquidityToSecondaryPosition(sqrtPriceX96, amount0, amount1);
lastRebalance = block.timestamp;
```

[`_setMainTicks`](https://github.com/sherlock-audit/2025-02-yieldoor/blob/b5a0f779dce4236b02665606adb610099451a51a/yieldoor/src/Strategy.sol#L232-L244) picks the band, and it is a pure function of `tick`: it snaps the tick to the nearest boundary and hangs `positionWidth / 2` off each side. `_addLiquidityToMainPosition` then fills that band using `sqrtPriceX96`.

Both values come from the same `slot0()` read. The band is chosen from `tick` and filled at `sqrtPriceX96`, and those two agree everywhere except on a tick boundary.

**Two numbers, and only one of them is a price**

Both come out of the same storage slot, and the pool labels them itself in [`Slot0`](https://github.com/Uniswap/v3-core/blob/d0831dc6b8a318df3872b6d68f6de135c9f3ec29/contracts/UniswapV3Pool.sol#L56-L72):

```solidity:56
struct Slot0 {
    // the current price
@>    uint160 sqrtPriceX96;
    // the current tick
@>    int24 tick;
    // the most-recently updated index of the observations array
    uint16 observationIndex;
    // the current maximum number of observations that are being stored
    uint16 observationCardinality;
    // the next maximum number of observations to store, triggered in observations.write
    uint16 observationCardinalityNext;
    // the current protocol fee as a percentage of the swap fee taken on withdrawal
    // represented as an integer denominator (1/x)%
    uint8 feeProtocol;
    // whether the pool is locked
    bool unlocked;
}
```

[`initialize`](https://github.com/Uniswap/v3-core/blob/d0831dc6b8a318df3872b6d68f6de135c9f3ec29/contracts/UniswapV3Pool.sol#L271-L289) writes both, deriving the tick from the price:

```solidity:271
function initialize(uint160 sqrtPriceX96) external override {
    require(slot0.sqrtPriceX96 == 0, 'AI');

@>    int24 tick = TickMath.getTickAtSqrtRatio(sqrtPriceX96);

    (uint16 cardinality, uint16 cardinalityNext) = observations.initialize(_blockTimestamp());

    slot0 = Slot0({
        sqrtPriceX96: sqrtPriceX96,
        tick: tick,
        observationIndex: 0,
        observationCardinality: cardinality,
        observationCardinalityNext: cardinalityNext,
        feeProtocol: 0,
        unlocked: true
    });

    emit Initialize(sqrtPriceX96, tick);
}
```

They do not stay that way, because a swap does not always write both. At the [end of `swap`](https://github.com/Uniswap/v3-core/blob/d0831dc6b8a318df3872b6d68f6de135c9f3ec29/contracts/UniswapV3Pool.sol#L743-L752):

```solidity:743
    (slot0.sqrtPriceX96, slot0.tick, slot0.observationIndex, slot0.observationCardinality) = (
        state.sqrtPriceX96,
        state.tick,
        observationIndex,
        observationCardinality
    );
} else {
    // otherwise just update the price
@>    slot0.sqrtPriceX96 = state.sqrtPriceX96;
}
```

`tick` is only rewritten when it changed. Every swap that moves the price without leaving the current tick updates `sqrtPriceX96` alone. `tick` therefore carries the price to a resolution of one tick and no better, which is the whole of what Uniswap promises about it:

$$\mathrm{ratio}(\mathrm{tick}) \le \mathrm{sqrtPriceX96} < \mathrm{ratio}(\mathrm{tick} + 1)$$

Writing `ratio` for `getSqrtRatioAtTick`. Read it as a range, because that is what it is. Every tick is a cell with a boundary at each end, and the price lives somewhere inside the cell. The tick tells you which cell. It does not tell you where in the cell the price sits, and every price in the shaded stretch below returns the same tick.

```figure
<svg viewBox="0 0 660 200" style="display:block;width:100%;height:auto" role="img" aria-label="A tick is a cell between two boundaries, and every price inside the cell returns the same tick">
  <rect x="240" y="82" width="180" height="28" style="fill:var(--th-cell);opacity:.3"/>
  <line x1="40" y1="96" x2="610" y2="96" style="stroke:var(--th-cell);stroke-width:1"/>
  <polygon points="622,96 608,90 608,102" style="fill:var(--th-cell)"/>
  <text x="630" y="100" style="fill:var(--th-muted);font-size:12px">price</text>
  <g style="stroke:var(--th-cell);stroke-width:1">
    <line x1="60" y1="76" x2="60" y2="116"/><line x1="240" y1="76" x2="240" y2="116"/>
    <line x1="420" y1="76" x2="420" y2="116"/><line x1="600" y1="76" x2="600" y2="116"/>
  </g>
  <g style="fill:var(--th-muted);font-size:11px;text-anchor:middle">
    <text x="240" y="66">ratio(n)</text><text x="420" y="66">ratio(n+1)</text>
  </g>
  <g style="fill:var(--th-body);font-size:12.5px;text-anchor:middle">
    <text x="150" y="136">tick n-1</text><text x="330" y="136">tick n</text><text x="510" y="136">tick n+1</text>
  </g>
  <circle cx="300" cy="96" r="4.5" style="fill:var(--th-ink)"/>
  <circle cx="360" cy="96" r="4.5" style="fill:var(--th-ink)"/>
  <circle cx="405" cy="96" r="4.5" style="fill:var(--th-ink)"/>
  <text x="330" y="164" style="fill:var(--th-ink);font-size:12.5px;font-weight:700;text-anchor:middle">all three prices return tick n</text>
  <text x="330" y="184" style="fill:var(--th-body);font-size:12px;text-anchor:middle">and the rightmost is nearly a whole tick from the centre</text>
</svg>
```

Yieldoor mints a band four ticks wide, so one tick of error moves the centre by a quarter of the range. And there is one state where even the guarantee above stops holding.

**Where the relationship breaks**

There is one state where the invariant above does not hold at all, and a caller can put the pool into it deliberately.

The whole of it lives in the tail of the swap loop in [`UniswapV3Pool.swap`](https://github.com/Uniswap/v3-core/blob/d0831dc6b8a318df3872b6d68f6de135c9f3ec29/contracts/UniswapV3Pool.sol#L692-L729), which runs once per price step. The two lines that write `state.tick` are marked:

```solidity:692
// shift tick if we reached the next price
if (state.sqrtPriceX96 == step.sqrtPriceNextX96) {
    // if the tick is initialized, run the tick transition
    if (step.initialized) {
        // check for the placeholder value, which we replace with the actual value the first time the swap
        // crosses an initialized tick
        if (!cache.computedLatestObservation) {
            (cache.tickCumulative, cache.secondsPerLiquidityCumulativeX128) = observations.observeSingle(
                cache.blockTimestamp,
                0,
                slot0Start.tick,
                slot0Start.observationIndex,
                cache.liquidityStart,
                slot0Start.observationCardinality
            );
            cache.computedLatestObservation = true;
        }
        int128 liquidityNet =
            ticks.cross(
                step.tickNext,
                (zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128),
                (zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128),
                cache.secondsPerLiquidityCumulativeX128,
                cache.tickCumulative,
                cache.blockTimestamp
            );
        // if we're moving leftward, we interpret liquidityNet as the opposite sign
        // safe because liquidityNet cannot be type(int128).min
        if (zeroForOne) liquidityNet = -liquidityNet;

        state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);
    }

@>    state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext;
} else if (state.sqrtPriceX96 != step.sqrtPriceStartX96) {
    // recompute unless we're on a lower tick boundary (i.e. already transitioned ticks), and haven't moved
@>    state.tick = TickMath.getTickAtSqrtRatio(state.sqrtPriceX96);
}
```

Three branches, and only the first one is lossy.

The outer `if` fires when the price landed exactly on `step.sqrtPriceNextX96`, which is the boundary of `step.tickNext`. [Line 725](https://github.com/Uniswap/v3-core/blob/d0831dc6b8a318df3872b6d68f6de135c9f3ec29/contracts/UniswapV3Pool.sol#L725) then writes the tick: going up it is `tickNext`, going down `zeroForOne` is true and it is `tickNext - 1`. The price is sitting on the boundary in both cases, and only the downward branch steps back off it. That one wrote `-1770` while `sqrtPriceX96` was the ratio at `-1769`.

The `else if` is the ordinary case, where the price stopped somewhere inside a tick. There Uniswap does not guess. It derives the tick from the price with `TickMath.getTickAtSqrtRatio`, which is exactly what this finding ends up recommending the strategy do.

Getting the pool into that window costs one swap, because every router call takes a `sqrtPriceLimitX96` and the [loop respects it](https://github.com/Uniswap/v3-core/blob/d0831dc6b8a318df3872b6d68f6de135c9f3ec29/contracts/UniswapV3Pool.sol#L640-L671):

```solidity:640
// continue swapping as long as we haven't used the entire input/output and haven't reached the price limit
@>while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
    StepComputations memory step;

    step.sqrtPriceStartX96 = state.sqrtPriceX96;

    (step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(
        state.tick,
        tickSpacing,
        zeroForOne
    );

    // ensure that we do not overshoot the min/max tick, as the tick bitmap is not aware of these bounds
    if (step.tickNext < TickMath.MIN_TICK) {
        step.tickNext = TickMath.MIN_TICK;
    } else if (step.tickNext > TickMath.MAX_TICK) {
        step.tickNext = TickMath.MAX_TICK;
    }

    // get the price for the next tick
    step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(step.tickNext);

    // compute values to swap to the target tick, price limit, or point where input/output amount is exhausted
    (state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep(
        state.sqrtPriceX96,
@>        (zeroForOne ? step.sqrtPriceNextX96 < sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96)
            ? sqrtPriceLimitX96
            : step.sqrtPriceNextX96,
        state.liquidity,
        state.amountSpecifiedRemaining,
        fee
    );
```

[Line 641](https://github.com/Uniswap/v3-core/blob/d0831dc6b8a318df3872b6d68f6de135c9f3ec29/contracts/UniswapV3Pool.sol#L641) stops the loop the moment `state.sqrtPriceX96` reaches the limit. [Line 665](https://github.com/Uniswap/v3-core/blob/d0831dc6b8a318df3872b6d68f6de135c9f3ec29/contracts/UniswapV3Pool.sol#L665) is what makes that exact: each step swaps towards the closer of the next initialized tick and the limit, so a limit set to `getSqrtRatioAtTick(tickNext)` is both at once. The step ends on the boundary, the loop condition then holds, and the swap returns with the price sitting exactly there.

An attacker picks that limit deliberately. Nothing about it is unusual: it is the same parameter every aggregator sets for slippage.

**Impact**

The band is supposed to be symmetric around the price. This one is minted three ticks below and one above, which has two consequences and they compound.

The position holds the wrong ratio of the two tokens the moment it is created, so the rebalance that was meant to reset it to roughly 50/50 does the opposite. And the position sits one tick from going out of range on the upper side, where any trade at all pushes it out. Out of range, concentrated liquidity earns nothing. The strategy then keeps that dead position until the next rebalance.

The attacker's side of it is cheap. Landing the pool on a boundary is one swap with a chosen `sqrtPriceLimitX96`, and the rebalancer does the rest.

**Alpha:** when a protocol reads a value another protocol derives, go and read what the deriving protocol actually guarantees about it, rather than what the name implies. `slot0.tick` is not a rounded copy of `slot0.sqrtPriceX96`. It is a separate field the pool writes only when the tick changes, and on a downward cross it writes `tickNext - 1` rather than the tick the price is actually in. The one state where the two disagree is a state any caller can force for the price of a single swap, which is exactly where somebody will go looking. The same question is worth asking of every `slot0` field, every `latestRoundData` field and every `convertToAssets` result you have ever taken at face value.

**The proof**

Fork Base at block 26874136. The pool starts at tick -1769. One swap, limited to exactly that boundary:

```solidity
function test_POC_WrongTicks_DueToNotUsingSqrtPriceX96() public {
    (uint160 sqrtPriceX96, int24 tick,,,,,) = pool.slot0();
    assertEq(tick, -1769);

    //@audit swap to clear current tick token0 liquidity
    uint256 amountToSwap = 100e18;
    deal(address(wbtc), depositor, amountToSwap);
    vm.startPrank(depositor);
    IMainnetRouter.ExactInputSingleParamsV2 memory swapParams;
    swapParams.tokenIn = address(wbtc);
    swapParams.tokenOut = address(usdc);
    swapParams.recipient = depositor;
    swapParams.fee = 100;
    swapParams.amountIn = amountToSwap;
    swapParams.sqrtPriceLimitX96 = TickMath.getSqrtRatioAtTick(-1769);
    wbtc.approve(uniRouter, amountToSwap);
    IMainnetRouter(uniRouter).exactInputSingle(swapParams);

    vm.startPrank(rebalancer);
    skip(10 minutes);
    IStrategy(strategy).rebalance();

    IStrategy.Position memory mainPos = IStrategy(strategy).getMainPosition();
    (sqrtPriceX96, tick,,,,,) = pool.slot0();
    assertEq(sqrtPriceX96, TickMath.getSqrtRatioAtTick(tick + 1)); //@audit price is in tick -1769 actually
    assertEq(tick, -1770); //@audit but current tick is 1 more
    assertEq(mainPos.tickLower, -1772);
    assertEq(mainPos.tickUpper, -1768);
```

Three assertions carry the finding. `sqrtPriceX96` equals the ratio at `tick + 1`, so the price is at the -1769 boundary. `slot0.tick` reads -1770. And the rebalance, reading the stale number, minted the band at -1772 to -1768.

```figure
<svg viewBox="0 0 660 215" style="display:block;width:100%;height:auto" role="img" aria-label="The band is minted from -1772 to -1768 while the price sits at -1769, leaving one tick of room above and three below">
  <line x1="40" y1="96" x2="610" y2="96" style="stroke:var(--th-cell);stroke-width:1"/>
  <g style="stroke:var(--th-cell);stroke-width:1">
    <line x1="90" y1="88" x2="90" y2="104"/><line x1="220" y1="88" x2="220" y2="104"/>
    <line x1="350" y1="88" x2="350" y2="104"/><line x1="610" y1="88" x2="610" y2="104"/>
  </g>
  <line x1="480" y1="72" x2="480" y2="120" style="stroke:var(--th-ink);stroke-width:1.5"/>
  <circle cx="480" cy="96" r="4.5" style="fill:var(--th-ink)"/>
  <g style="fill:var(--th-muted);font-size:11.5px;text-anchor:middle">
    <text x="90" y="80">-1772</text><text x="220" y="80">-1771</text>
    <text x="350" y="80">-1770</text><text x="610" y="80">-1768</text>
  </g>
  <text x="480" y="60" style="fill:var(--th-ink);font-size:12px;font-weight:700;text-anchor:middle">-1769, the price</text>
  <text x="350" y="140" style="fill:var(--th-body);font-size:12px;text-anchor:middle">slot0.tick, the centre it used</text>
  <line x1="90" y1="168" x2="480" y2="168" style="stroke:var(--th-cell);stroke-width:2"/>
  <line x1="480" y1="168" x2="610" y2="168" style="stroke:var(--th-cell);stroke-width:2"/>
  <g style="stroke:var(--th-cell);stroke-width:1">
    <line x1="90" y1="162" x2="90" y2="174"/><line x1="480" y1="162" x2="480" y2="174"/><line x1="610" y1="162" x2="610" y2="174"/>
  </g>
  <text x="285" y="190" style="fill:var(--th-body);font-size:12px;text-anchor:middle">3 ticks of room below</text>
  <text x="545" y="190" style="fill:var(--th-body);font-size:12px;text-anchor:middle">1 above</text>
  <text x="40" y="212" style="fill:var(--th-muted);font-size:11.5px">a band four ticks wide, minted 3 and 1 instead of 2 and 2</text>
</svg>
```

The rest of the test swaps one wei back the other way, and the pool's tick moves to -1769. One wei is the entire distance between the freshly minted position and the edge of its own range.

**Conclusion**

The fix is one line: derive the tick from the price with `TickMath.getTickAtSqrtRatio(sqrtPriceX96)` and centre on that. It paid **\$3738** as a unique high, and it came from reading a Uniswap invariant carefully enough to notice the one boundary where it does not hold.

If ticks, Q64.96 prices and the swap loop are not yet second nature, the [Uniswap V3 Development Book](https://uniswapv3book.com/) is the clearest walkthrough there is. It rebuilds v3 from scratch, and the chapters on tick maths and on the swap loop are what make a bug like this one visible rather than surprising.

[**See more of the Yieldoor audit contest**](/reports/yieldoor)

Written by 0xSimao (https://0xsimao.com/) · Sherlock's 2025 Watson of the Year · 21 first places in 57 audit contests.

---

Older: [LP pool cap may be exceeded on drawing settlement](https://0xsimao.com/the-contest-academy/megapot-lp-pool-cap-exceeded)
