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