Uniswap V3 ticks - dive into concentrated liquidity

Author: Sergey Boogerwooger
Security researcher at MixBytes
Intro
There is a variety of modern DeFi protocols that use concentrated liquidity ideas. Uniswap V3 presented the implementation of this concept that is currently used on an everyday basis by many people. However, implementing concentrated liquidity in smart-contracts is tricky and requires a clever connection between mathematical concepts and highly optimized code of smart-contracts.

You cannot simply read the whitepaper of Uniswap V3 and easily figure out why it's made in such a way. Similarly, when looking at the Solidity code of Uniswap V3 you cannot fully understand why such a design was used. So, we need to bridge the gap and connect these two pieces of information to provide a comprehensive understanding of the concentrated liquidity concept and the intricacies involved. Perhaps some of these ideas will find a place in your own code or help audit protocols utilizing concentrated liquidity.
Main idea
We assume that the reader is familiar with the Uinswap V2 AMM concept and how the constant product works in Uniswap V2 pools. You may be aware of the issues faced by Uniswap V2 pools like capital inefficiency, slippage, and impermanent loss. A good example is the swap between stablecoins, where the price should not change significantly even with large swap amounts, or the situations where the amount of the liquidity in the pool is small and swapping leads to a significant price change and impermanent loss.

So, the main idea of Uniswap V3 was to give liquidity providers the ability to place their assets more precisely, targeting the desirable price range, for example, to concentrate their liquidity somewhere around "1.0" for stablecoins swaps.

To achieve this goal, all price range should be divided into parts ("ticks") and, within the swap process, the price should "cross" multiple price ticks, using only the liquidity "belonging" to the current tick. Let's see how it can be accomplished.
Starting maths
The first step: Uinswap V3 whitepaper and key definitions. The exchange rate between token0 and token1 (either for the entire pool or within a specific tick) is defined as the price. The entire price range is split into the so-called "ticks", where each tick represents an integer power of 1.0001. The price for the i-th tick is defined as follows:

So, each tick i (signed) represents a .01% (1 basis point) movement of the price from the tick with i=0. The price can move above and below "1.0" by small 0.01% steps in the integer space (−∞,+∞).


Two other important definitions are liquidity and sqrtPrice:

where x and y are the reserves of token0 and token1 respectively.

Uniswap V3 pool consists of multiple "small" swap pools, each identified by a separate tick. Each of these "small" pools behaves like a Uniswap V2 pool with a constant product, having its own reserves, but with an important difference - the reserves of the tick are depletable, having the maximun and minimum swap price and limited amounts of token0 and token1. When all reserves of token0 and token1 are fully removed from the pool, its liquidity drops to zero because x∗y=0. So, the constant product equation in UniV3 is slightly different (a hyperbole is shifted towards zero) and becomes:

(where Pa and Pb are the lowest and highest possible price for the given tick range)

The graphic of this function looks like:

In the event that one of the reserves is depleted and the swap process is not completed (the desired amount or price is not reached), UniV3 proceeds to the next active tick and performs the next "hop", swapping in the next tick. These swaps continue until the target amount of tokens is received or the target swap price is reached.
Cross-tick swaps
Let's start by illustrating cross-tick swap using this simplified picture:

The pool has a current state: the current price (tick) and the swap starts at the current tick (green), user gives token0 (blue) and recieves token1 (yellow). If the swap volume is small and the price doesn't exceed Pa or Pb (the lowest and highest prices for the current tick), then the target amounts are changed according to the constant product formula. Otherwise, when swapping token0 for token1
(moving to the right in the picture), the reserves of token1 (yellow) in the current tick are totally depleted, leaving only token0 (blue). The current tick then moves to the next tick, having token1 reserves ("i+n"" on the picture), with token1 reserves, and the swap continues. This results in all ticks left of the current tick containing only token0, while ticks to the right contain only token1. Swaps in the opposite direction (token1 for token0) work similarly.

[NOTE] The n parameter in the picture (representing the step between the ticks) is related to a tickSpacing parameter of the pool, and will be discussed later
Towards the implementation
Directly implementing this logic, with individual tick reserves, updates for each swap and liquidity change, and Uniswap V2-style fee calculations, would lead to a significant number of variables and high gas consumption.

Therefore, we need a mathematical approach to make cross-tick swaps and liquidity providing/fees calculations cheap. Our goal is to achieve near-constant gas costs and minimal storage usage. These requirements are the main cause for using liquidity(L and ΔL) and sqrtPrice*(√P and Δ√P) instead of direct token amounts (as it was done in Uniswap v2).

The first important aspect of using single liquidity(L) and squarePrice(√P) is that only one of these values changes during the swaps and minting/burning liquidity. √P changes when swap is performed within a tick, L changes when the tick is crossed or there is a mint/burn the liquidity.

In addition, the use of √P instead of P avoids the need for the sqrt() function, which doesn't have a constant gas cost. In Uniswap V2 sqrt() is implemented by using the Babylonian method that doesn't have a constant gas cost. In Uniswap V3, you will not find the calculation of sqrt() at all; instead, precalculated computation is used in TickMath.sol (here).

The usage of √P and L allows for the calculation of token amounts received within a swap and the change of the price √P with very simple formulas:

for token1 (Δy), and

for token0 (Δx)

You can find these calculations in the computation of the swap functions getAmount0Delta() and getAmount1Delta().

The next important point of the UniswapV3 implementation is tickSpacing. This variable sets the "step" between ticks that are allowed to have liquidity. For instance, if tickSpacing is set to 60, only ticks such as 0, 60, -60, 120, -120, … can be initialized and own real liquidity from providers. tickSpacing provides pool creators with the ability to choose whether they want a smaller step between ticks and more "dense" pool (more granular price steps, but more expensive cross-tick swaps because the swap will cross more ticks) or a more "sparse" pool, which would make the price steps larger (less ticks crossed = less gas, but less precision for LPs).
The swap process
Let's track the swap process in UniswapV3, stopping at interesting places in code and describing them. At this stage, we will skip or briefly touch on logic branches that are not directly related to liquidity, price, token amounts, and tick crossing.

Let's start with the swap() function. The input parameters related to the swap set the direction of the swap (zeroForOne) and the target values: the swap should either reach a specified target amount (amountSpecified) or price (sqrtPriceLimitX96). These values are used to set the slippage restrictions, acting as barriers that halt the swap when the required token amounts or price limit is reached.

After initializing the swap cache and setting the starting values, we enter the main swap loop here, working while
(state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96).

The core of the swap is this part (the computeSwapStep() function):

Here, we receive the new state.sqrtPriceX96 and calculate a new state.amountSpecifiedRemaining which will stop the crossing of next ticks if target values are reached.

Going deeper into the computeSwapStep() function, we encounter the segment of code that determines the next price here (showing only one branch):

So, the compute SwapStep() function returns us the new sqrtPriceX96 and the in/out amounts of tokens "left" after the current tick processing.

Let's return to the swap() function. We check if the price has changed to next available one here. If the price has changed and reached the next initialized tick, we perfom tick crossing here:

ticks.cross stores new values (fees, timestamp, oracle values) in the mapping with ticks (we will discuss it later) and returns liquidityNet that will be used to increase/decrease the liquidity of the swap (state.liquidity), and, later, the global liquidity of the pool (here).

At the end of the swap, we calculate target amounts (amount0 and amount1) here and perform callbacks.
Providing liquidity
Liquidity in Uniswap V3 is provided by creating "positions" owned by users. This mechanics differs from Uniswap V2, where liquidity ownership was implemented through ERC20 LP tokens. In UniswapV3, a "position" is an object owned by a user, holding a certain amount of liquidity plus a range of ticks (from tickLower to tickUpper). As can be seen, mint and burn operations are very similar. In case of minting, liquidity is added to a position, while in case of burning, liquidity is subtracted.

Now, let's dive deeper into the creation/deletion of a position to the _updatePosition()
function. It accepts tickLower and tickUpper, liquidity to add/remove (liquidityDelta) and the current pool's tick.

A very effective example of UniswapV3 mathematics is evident in this part of _updatePosition(). You may think that LPs should allocate liquidity across ALL ticks within the range from tickLower to tickUpper (matching tickSpacing, of course) but, in the code, we see only TWO updates:
flippedLower = ticks.update(
     tickLower,
     ...
);
flippedUpper = ticks.update(
     tickUpper,
     ...
     );
of the lower and upper ticks. It's very effective, but what's going on here? How to collect the fees from ALL ticks in this range without storing the liquidity values in each tick? First, let's address the fees.

A very detailed article, describing fees distribution is here.

The main concept of Uniswap V3 fees involves tracking the "outside" fees amounts "above" and "below" the tick and storing these values within the tick. These fees amounts are computed using the following formulas from the whitepaper:

(where fo(i) is the feeGrowthOutside value stored by the i-th tick, fg - total amount of accumulated fees in the pool)

But the implementation uses only ONE value to track these fees amounts, which is the feeGrowthOutside* value inside the tick (we discuss only one of them because there are two stored values for token0 and token1).

Applying the formulas above, the "outside fees" value for the given tick (feeGrowthOutside*) equals:

  • the cumulative number of fees BELOW tick i if it is to the LEFT of ic
  • the cumulative number of fees ABOVE ticki if it is to the RIGHT of ic

(ic - current price tick, i - observing tick)

The first case: i < = ic, we're moving to the right, feeGrowthOutside stores the "left" part of fees:

The second case, i>ic , we crossed current price tick, now feeGrowthOutside stores the "right" part of fees:

The ticks are crossed sequentially, so, each time the tick is crossed, feeGrowthOutside changes to the "left" or "right" part of the global fees.

This design simplifies the computation of the fees, "belonging" to a given range a lot (il - lower tick, iu - upper tick):

We simply subtract "fees below lower tick" and "fees above upper tick" from global.

In addition, let's recall that feesGrowth* is measured in "tokens-per-liquidity-unit", so the calculation of the target fees amounts is straightforward as well. We modify the user's position by removing liquidity(here), then calculate the fees here in the ticks.getFeeGrowthInside() function, which implements the formulas above here:
    if (tickCurrent >= tickLower) {
        feeGrowthBelow0X128 = lower.feeGrowthOutside0X128;
        feeGrowthBelow1X128 = lower.feeGrowthOutside1X128;
    } else {
        feeGrowthBelow0X128 = feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128;
        feeGrowthBelow1X128 = feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128;
    }
So, in order to calculate the fees collected within the tick range, we need to read the state of only two ticks and the global state, making it very efficient. The same algorithm works for providing liquidity, calculating, and updating new feeGrowthInside* values for lower and upper ticks.

[NOTE] Maybe you mentioned that there are no restrictions on tickSpacing anywhere, and, it seems like LPs can set any tickLower and tickUpper. But this check is performed inside the flipTick() function, which turns ticks on/off.
Ticks organisation
Now let's take a look at how the ticks are organized. Our first stop is the struct Info in Tick.sol - the main information about the tick:

There is no need to track the price inside the tick because the tick number itself represents the current price, and the swap price can be effectively calculated from the liquidity and swap amounts.

There are two liquidity variables: liquidityNet (ΔL) and liquidityGross(Lg). When we cross the tick, totally removing the reserves of one token, then liquidityNet of this tick becomes zero (L=√xy while x=0 or y=0). However, we anyway need something to indicate that some LP positions are referencing this tick. So, we track the liquidityGross value which represents the sum of liquidity, provided to this tick by LP(s).

To track the state of the ticks (initialized or not), we need a "map" of the ticks which can be used for searching the next tick when price goes up or down. In UniswapV3, ticks are organized in a 256-bit bitmap in a uint16 index (mapping(int16 => uint256)), where each tick number (int24) addresses the 16bit index word and one particular bit from 256 bit space:

When we want to find the next initialized tick, we use the nextInitializedTickWithinOneWord() function from TickBitmap.sol. Depending on the search direction, we set all bits below or above the current tick to zero and check if the result is non-zero. Then, we use BitMath.mostSignificantBit() or BitMath.leastSignificantBit() function to identify the nearest initialized tick. It's interesting that these functions use sequential shifts of bits, employing a binary search rather than a one-by-one search. This approach divides the search space by half on each iteration, resulting in at most log₂256 iterations instead of 256 iterations.

Then, we use the next tick price to continue the swap process here, as discussed earlier.
Conclusion
In this article we desribed the main technical design of Uniswap V3 concentrated liquidity. This design serves as an excellent example of how a solid mathematical model can lead to the effective implementation of a relatively complex algorithm. This design significantly reduces gas costs for operations like swaps and liquidity provision, making them close to constant even when involving large amounts of internal entities within the protocol.

Uniswap V3 effectively leverages the sequential nature of tick crossings, updating only the values required to maintain the pool state consistent. Meanwhile, the fee system is designed in such a way that enables thousands of liquidity providers to have positions over different price ranges without the need for "order books" or processing each position separately.

This approach does make the operations with Uniswap V3 more complicated for external services. They need to perform some external computations to provide end users with necessary information such as slippage, price ranges, state of the pools, etc. Though this is typical in the blockchain world. Fortunately, there are many projects and services available to assist users in working with Uniswap V3.

The "concentrated liquidity" concept is indeed popular in modern DeFi and is used in many projects. Therefore, if you want to implement your own solution or audit a project that uses such algorithms, remember that there are effective solutions available. Don't forget to examine them, as any logic working with many entities in one call in smart-contracts can be tricky. See you in our next articles!
  • Who is MixBytes?
    MixBytes is a team of expert blockchain auditors and security researchers specializing in providing comprehensive smart contract audits and technical advisory services for EVM-compatible and Substrate-based projects. Join us on X to stay up-to-date with the latest industry trends and insights.
  • Disclaimer
    The information contained in this Website is for educational and informational purposes only and shall not be understood or construed as financial or investment advice.
Other posts