Most concentrated liquidity AMMs implement a tick based design.

Each tick represents the start and end of a small price range, and liquidity providers can deposit their liquidity between arbitrary ticks. cl-AMMs need to keep track of available liquidity because liquidity is distributed over various tick ranges.

Updating liquidity on tick passages is probably the most sensitive operation that a concentrated liquidity AMM needs to perform, so it’s important that it’s handled correctly.

In this post we’ll look at a common approach to tick passage and a serious potential pitfall that accompanies it!

Forward Price Movement

To keep code simple many cl-AMM implementations will use the start prices of ticks to recognise tick passages.

A price increasing swap either ends before or on the start price of the next tick. Only if the price hits the next tick boundary will the liquidity update logic be executed.

Info

cl-AMMs usually split up trades in such a way that the price never passes the price of the next active tick.

This approach is simple for forward price movements.

Backward Price Movement

cl-AMMs which use the start of a tick range to denote passing a tick boundary encounter an interesting problem.

Let’s say we have these ticks: | X | inactive(Y) | A | B |

When does a swap that starts in tick B reach tick A?

A common design is this: once the tick start price of tick B is reached! This is interesting, as this price arguably belongs to tick B. This is a serious edge case that might lead to trouble down the line.

Square Root

To understand how this situation is problematic there is another detail of cl-AMM designs. Namely, that a common pattern is to use the square root of the price in all calculations instead of the actual price.

One result of this design choice is that it’s possible to make small swaps where the price changes, but the sqrt of the price does not.

Swapping on tick boundaries

The system is in a super interesting state after a swap like the one described above. The current tick (according to the cl-AMM) is A, even though the current price is technically in tick B.

Normally the system recomputes the current tick at the end of a swap. In cases where the next tick boundary isn’t reached it will simply do so by checking which tick the current sqrt price belongs to.

pseudo code:

tick = getTickAtSqrt(currentPrice);

However, the system is in a state where the current tick is A even though the current sqrt price belongs to B. This method will do something weird if we make a tiny swap where the sqrt price stays the same. Namely even though we made a downward swap, it’ll move the tick to B (upward). It would do so because the current sqrt of the price is still in tick B!

This unexpected tick change will not incur the liquidity changes that would normally come with a change of tick A to B, and result in an invalid state and exploitable contract!

Dealing with the edge case

The solution is simple!

Ensuring that the tick is not recomputed if the square root of the price doesn’t change solves the issue at hand. This is also what you’ll find if you inspect the Uniswap v3 codebase.

Take aways

  • (general) Design choices that simplify an implementation in one area, can lead to additional edge cases.
  • (cl-AMM) Many cl-AMMs have a complicated edge case where the current price doesn’t actually fall in the current tick.

In practice

Here is how Uniswap v3 solves this edge case: https://github.com/Uniswap/v3-core/blob/main/contracts/UniswapV3Pool.sol#L726-L729

Here is an example of this edge case as a bug in the wild: https://100proof.org/kyberswap-post-mortem.html