Uniswap v3: Power To Liquidity Providers

Zainab Hasan   |   

Apr 07, 2021

Apr 07, 2021

Introduction

Uniswap is quite well-known in the world of Defi. Uniswap v1 was launched in December 2018, with proof of concept for automated market makers. In contrast, Uniswap v2 was launched in May 2020 with new features. With $4.17Billions locked in Uniswap v2, the Uniswap team announced Uniswap v3, which is said to launch on the fifth of May, 2021.

The team publicized the protocols codebase and published a blog on it as well.

Learn about the Uniswap v2 protocol in-depth in our article.

Uniswap v3

Before we dive into the details of Uniswap v3, let's first see why the protocol is even needed.
The problem that lies in constant function market makers is capital inefficiency.
Uniswap v3 mitigates this by allowing liquidity providers to concentrate their liquidity. By doing so, they can provide the same liquidity depth as Uniswap v2 within specified price ranges while putting far less capital at risk.

Concentrated Liquidity

In the earlier versions, LPs provided liquidity in the entire price range, i.e. [0,∞]. In Uniswap v3, LPs can concentrate their liquidity in smaller price ranges.

Liquidity concentrated to a finite range is called position

Now, consider the following graph 

Graph 1

The above graph (graph 1) represents the liquidity in a pool. If a liquidity provider adds liquidity in the position [a,b], those will be real reserves, and the rest will be virtual reserves.

Let's say that L is the amount of liquidity provided in position, equal to √ 𝑘. (it is a simple change in notation).

To calculate the amount of liquidity that will be after a swap transaction, we will assume that x is decreasing and the amount of y is increasing.

Then, x-real is represented by the change in liquidity with respect to change in position b 

Formula: real X reserves
Formula: real X reserves

And y-real is represented as the change in liquidity with the increase in position a.

Formula: real Y reserves
Formula: real Y reserves

To get the total liquidity of that reserve, we add the virtual and real reserves, and as we know that x*y = k.

Uniswap v3: Formula for total liquidity of reserves
The formula for total liquidity of reserves

Active Liquidity and Range Orders

When the market prices move outside of a position, in this state, an LP's liquidity is converted entirely to the less valuable of the two assets until the market price moves back into their specified positions or they decide to update their range to account for current prices. If the market price moves back into that range, the position will be traded back, effectively reversing the trade, turning the liquidity into active liquidity.
LPs cannot earn fees, nor will they suffer impermanent loss if their liquidity is not active.

As stated above, when liquidity moves outside the specified position, the entire assets convert into less valuable tokens. However, liquidity providers can still provide liquidity in this range. To do so, they can use the conventional way, or they can add liquidity by using just one asset (the less valuable asset as the price is outside this range). As the price moves back in that position, liquidity will become active.

Code block for Active and Inactive liquidity
Code block for Active and Inactive liquidity

When the current ratio moves left concerning the current position, token0 (the amount of token0 in the pool) changes if the current ratio moves right concerning the current position, the amount of token1 changes. 

Graph 2

In terms of smart contracts, the assets' conversion is only done when adding or removing liquidity. 

Architectural Changes

This section states the architectural changes in Uniswap v3.

Multiple Pool Pairs

In the previous versions of Uniswap, every pair corresponds to a single liquidity pool. A total of 0.30% per swap is cut as part of the fee. This fee amount is low for the pools, which do not have much daily volume.
For this reason, Uniswap v3 introduces multiple pools for each pair of tokens. Each pool with a different swap fee. Factory contract allows the creation of pools. Since the maximum precision for fees is up to 4 decimal points. Factory contract can create three fee tiers: 0.05%, 0.30%, and 1%. Additional fee tiers can be enabled by UNI governance.

Code block for enabling fee amount
Code block for enabling fee amount

Non-fungible Liquidity

As LPs can create custom ranges, representing pool shares in ERC-20 tokens is complex in calculating the positions' accumulated fees. As positions are unique (in terms of the amount of provided liquidity or range selected), using non-fungible tokens is more practical. However, anyone can create an ERC-20 token contract wrapper in the periphery that makes a liquidity position more fungible. But additional logic to handle distribution or reinvestment collected fees is also required. The NonfungiblePositionManager.sol wraps Uniswap V3 positions in the ERC721 non-fungible token interface.

Also, because of customs liquidity positions, fees are not reinvested in the pool. It is not collected and held by the pool as individual tokens.

Governance:

In the previous Uniswap versions, the trading fee of 0.3% is cut off in each swap. Furthermore, Uniswap v2 introduced a switch to turn the protocol fee on or off, allowing the protocol to collect 1/N (where n equals 6) of the total fee amount paid by users.

In Uniswap v3, however, this N can be any number between 4 to 10 and can be 0. So, the protocol may collect 10% to 25% of the fee amount. Governance can change this fee amount.

The power of governance initially lies in the hands of UNI token holders. The owner of the factory is a timelock contract.

Governance can add fee tiers as well. 

Lastly, the UNI governance can transfer its governance authority to another contract.

.

Code block for transferring ownership
Code block for transferring ownership

Oracles:

Instead of accumulating the sum of prices like in Uniswap v2, and letting users compute the arithmetic mean for TWAP. Uniswap v3 tracks the sum of log prices which allows users to compute the geometric mean for TWAP.

The reason to calculate the geometric mean for the TWAP is that there are now at least 3 pools per pair. So arithmetic mean would give the wrong average price as it will only track how much the price is increasing. But calculating at which ratio they are increasing is more precise. 

Lastly, Uniswap v3 adds a liquidity accumulator that is tracked alongside the price accumulator. This liquidity accumulator can be used by other contracts to decide which of the pools corresponding to a pair will have the most reliable TWAP. As Uniswap v3 has multiple pools for the same pairs, see section 4.1.

Oracle Observations

Uniswap v3 offers improvements to the TWAP oracle of Uniswap v2 by making it possible to calculate any recent TWAP up to the past ~9 days in a single on-chain call as the checkpoints are in the core contract. This is achieved by storing an array of cumulative sums instead of just one.

So, users of oracles don't have to track previous values of the accumulator.

Code block for observe function
Code block for observe function

The above observe function is used to get the data of the defined time window. 

The function returns the cumulative tick and liquidity as of each timestamp `secondsAgo` from the current block timestamp. To get a time-weighted average tick or liquidity-in-range, you must call this with two values, one representing the beginning of the period and another for the end of the period. 

For example: to get the last hour time-weighted average tick, you must call it with secondsAgos = [3600, 0].

For each individual pool, the contract is maintaining the arithmetic mean for the TWAP. The time-weighted average tick represents the arithmetic time-weighted average price of the pool, in log base sqrt(1.0001) of token1 / token0. The TickMath library can be used to go from a tick value to a ratio.

Code block for increaseObservationCardinalityNext function
Code block for increaseObservationCardinalityNext function

The increaseObservationCardinalityNext function increases the length of the accumulator window. It essentially increases the maximum number of price and liquidity observations that this pool will store. 

Liquidity Oracle

The Uniswap v3 oracle also tracks an accumulator of the current value of the virtual liquidity currently in range at the beginning of each block, stored in the liquidityCumulative variable. 

Implementing Concentrated Liquidity

Price And Liquidity

As mentioned in section 3, the amount of liquidity added is L which is equivalent to √k. This turns the CFMM into the following equation

Uniswap v3: Formula for CFMM
Formula for CFMM

As price is just ratio between two tokens, taking the square root, we get 

Uniswap v3: Formula for Price
Formula for Price

We already went through the calculation to get the real value of x and y reserves in equations (a) and (b).

Using 𝐿 and √ 𝑃 is convenient because only one of them changes at a time. Price changes when swapping within a position; liquidity changes when moving outside of the position or when liquidity is minted or burned. 

Alternatively, liquidity can be considered the amount that token1 reserves change for a given change in √ 𝑃.

Uniswap v3: Formula for liquidity
Formula for liquidity

Derived from equation (b)

To take advantage of this relationship and avoid taking any square roots when computing swaps, √ 𝑃 is tracked. 

Ticks

Ticks are price positions on the curve, and when two price positions, i.e., two ticks, are plotted on the curve, we get a range. TickSpacing is the spread between two ticks. Ticks can only be used at multiples of this value.

This means that ticks are tickSpacing away from each other, and so ticks cannot be initialized at every value. 

We will go forward with the stated assumption that prices are always expressed as the price of token0 in terms of token1.

Price can be used to calculate a tick and vice versa.

There is a virtual tick at every price change that is an integer power of 1.0001. 

If i represents tick and p represents price, then,

Formula for price at current tick (i)
Formula for price at current tick (i)

This has the property of plotting each tick at a 0.01% (1 basis point) price movement away from each adjacent tick. For reasons stated in section 7.1, √ 𝑃 is tracked.

Hence,

Formula for price at current tick (ii)
Formula for price at current tick (ii)

When liquidity is added to a position, if even one of the ticks isn't in use, its initialization is triggered.

And, to calculate current tick (ic):

Formula for current tick
Formula for current tick

Tick Bitmap

Ticks that are not initialized can be skipped during a swap. Furthermore, a bitmap is named TickBitmap. The bitmap position corresponding to the tick index is set to 1 if the tick is initialized and 0 if it is not initialized. On removing liquidity, the initialized tick can be uninitialized.

Fees

As with swaps, a fee amount is cut off, this was relatively straightforward in Uniswap v2, but in Uniswap v3, positions and ticks need to be catered.

Again, the fee amount is collected in terms of tokens rather than in terms of liquidity.

Consider global variable states as stated in the whitepaper.

Global state variables

The feeGrowthGlobal0X128 and feeGrowthGlobal1X128 represent the global fee (in terms of token0 and token1) accrued by LPs. The values of all the above variables change when a swap takes place. However, when liquidity is added or removed, only L changes.

Tick indexed state variables

The contract needs to store information about each tick to track the amount of net liquidity that should be added or removed when the tick is crossed and track the fees earned above and below that tick.  When ticks are updated, the variables present in the tick-indexed state are updated. You can consider that after updating the global state of the contract, the pool is updating the fees collected and liquidity updated at the specific price point, which is tickUpper and tickLower.

Now, Coming towards how the fee distribution is done.

The tick-indexed state variables track how much fees are earned at an indexed tick. To calculate the fees that were accumulated within a given range, feeGrowthOutside{0,1} is subtracted from the global accumulated fees.

If fee between [a,b] is fa and fb at point a and b respectively, current tick is ic, and we’re calculating the value for tick i. Then the above ranges make sense. Keeping in mind that the ratio for price is token0/token1. Then ic >= i for fa and ic <= i for fb.

Ranges of current
Ranges of current

 So, to compute the total amount of cumulative fees per share fia,ib in the range between two ticks ia and ib.

Code block for fee calculation
Code block for fee calculation

Now, we have the accumulated fee inside a range. But, many liquidity providers may have provided different liquidity values in the same range. The tokens protocol owes to them will differ now.

The amount of tokens owed to LPs in a range is calculated in the positions library. This makes use of the formula.

Uncollected fee(y) = L . (feeGrowthInside - feeGrowthInsideLast)

Uncollected fee(x) = L / (1/ feeGrowthInside - 1/ feeGrowthInsideLast)

The positions are keeping track of how much fees have been earned in feeGrowthInside{0,1} the Last variable. Whereas, to calculate the latest amount of fees, the current state of ticks is used. Subtracting both values will give us the difference and increase in change. Multiplying this by the position's liquidity gives us the total uncollected fees in token0 for this position.

Code block for fee calculation (i)
Code block for fee calculation (i)

Whereas, during a swap, feeGrowthOutside changes. 

Code block for fee calculation (ii)
Code block for fee calculation (ii)

And when LPs collect the fee amount, feeGrowthInside is calculated in the same way as stated above. This is then divided by the amount of liquidity present in the position, which gives the amount owed, i.e., tokenOwed{0,1}.

Tokenowed represents how many uncollected tokens are owed to the position as of the last computation and adds it to the last accumulated amount of accrued fee.

In-To The CodeBase:

We already explored parts of the code in the previous sections. We’ll look at different code blocks in this section. However, we’ll skip the obvious ones.

Uniswap v3 Core:

The factory contract facilitates the creation of pools. It also enables control of the protocol’s fee.

The function createPool is used to create a pool. This function takes the pool tokens and fee as input. 

Uniswap v3: Code block for create pool function
Code block for create pool function

The function runs the necessary checks on token addresses; it stores the token addresses and fees in the getPool mapping. The tickSpacing is retrieved from the fee. The call will revert if the pool already exists, the fee is invalid, or the token arguments are invalid.

The Uniswap pool contract facilitates swapping and automated market-making between any two assets that strictly conform to the ERC20 specification.

The purpose of initialize function is to initialize the price of the pool. Price is represented as a sqrt(amountToken1/amountToken0) Q64.96 value, i.e. rational number with 64bit precision before the decimal and 96 bits precision value succeeding decimal point.

The 0th storage slot in the pool stores many values and is exposed as a single method to save gas when accessed externally.

Uniswap v3: Code block for initialize function
Code block for initialize function

The function mint is used to add liquidity in a position.

Uniswap v3: code block for mint function
Code block for mint function

This function adds liquidity for the recipient in specified positions. This function takes the address for which liquidity is being added, the upper and lower ticks (i.e. boundary of a position), the amount of liquidity to mint, data (if any), and returns token0 and token1 given to mint the amount of liquidity. 

The caller of this method receives a callback because of IUniswapV3MintCallback’s function uniswapV3MintCallback implemented in this function. They must pay any token0 or token1 owed for the liquidity.

In contrast, to burn a token ID, which will delete it from the NFT contract, the function burn is used. But, the token must have 0 liquidity, and LPs must first collect all tokens.

Liquidity providers can collect the tokens that the protocol owes them. Tokens may be owed for providing liquidity or by burning liquidity. Recipients (LPs) use the collect function for this.

Uniswap v3: Code block for collect function (Core contract)
Code block for collect function (Core contract)

This function takes the recipient, the upper and lower ticks, and the amount of tokens 0 and/or 1 they want to collect the fee in. If they want to collect a fee in any one of the tokens, they may provide zero as the value in amount0Requested/amount1Requested.

LPs may burn their liquidity by using the burn function. 

Uniswap v3: Code block for burn function
Code block for burn function

Burn functions take the position boundaries, i.e. tick upper and tick lower, and the amount of liquidity to be burnt as input and adds the token amount to the tokensOwed. 

The function which executes the main swapping feature is the swap function.

Uniswap v3: Code block for swap function
Code block for swap function

Swap function takes the address of the swapper (recipient), The direction of the swap, true for token0 to token1, false for token1 to token0 (zeroForOne), the amount of the swap, which implicitly configures the amount as exact input and output (amountSpecified), the value for input will be positive while negative for output.

The function is the only function with a loop. This while loop enables swapping as long as we haven’t swapped all of the tokens and haven’t reached the price limit. While executing a trade, we specify the limit for price. The token cannot exceed this price limit.

Code block inside swap function
Code block inside swap function

Calculates the protocol fee, if any.

Code block for protocol fee inside swap function
Code block for protocol fee inside swap function

The change in liquidity is already discussed in section 3.1, which is also part of the swap function. 

To execute flashSwaps, Uniswap v3 uses a separate function named flash, unlike Uniswap v2.

Code block for swap function
Code block for swap function

Recipients may receive token0 or token1 or both, but they must pay the received amount plus fee (per reserves loaned) in the same atomic transaction. The recipient must implement the IUniswapV3FlashCallback interface and its function uniswapV3FlashCallback to execute a flashSwap. 

Uniswap v3 Periphery:

The NonfungiblePositionManager contract wraps Uniswap v3 positions in ERC-721 non-fungible interface, allowing them to be transferred and authorized.

To create a new position wrapped in an NFT, the function mint (this is different from the core contract’s mint) is used. This function is to be called when the pool exists and is initialized. If the pool is created but not initialized, a method does not exist, which means the pool is assumed to be initialized.

As we know, LPs can increase the amount of liquidity that they provide. The increaseLiquidity function is to be used for that. 

It increases the amount of liquidity in a position, with tokens paid by the `msg.sender`.

Code block for increaseLiquidity function
Code block for increaseLiquidity function

This function takes the ID of the token for which liquidity is being increased (tokenId), the amount by which liquidity will be increased (amount), the maximum amount of token0 that should be paid to (amount0Max), the maximum amount of token1 that should be (amount1Max), and the time by which the transaction must be included to affect the change (deadline).

Code block for decreaseLiquidity function
Code block for decreaseLiquidity function

The decreaseLiquidity function takes the same inputs as the increaseLiquidity function. Still, unlike increaseLiquidity, it decreases the amount of liquidity in a position. In contrast, inceaseLiquidity increases the amount of liquidity in a position, and then both functions account for the changes to the position.

We also have the collect function, which calls and uses the collect function of Uniswap v3 Core’s pool smart contract. However, this collect function updates the liquidity and position in the NFT as well.

Code block for collect function (Periphery contract)
Code block for collect function (Periphery contract)

The SwapRouter contract routes the swaps against Uniswap v3 and contains functions for swapping tokens.

Code block to execute swap along a specified path
Code block to execute swap along a specified path

The function executes the swap along a specified path. AmountOut from a reserve is amountIn for the next reserve along the path, the while loop ensures the looping over the specified path.

Glossary

  • Capital Inefficiency: The ratio between spent amount and return amount.
  • Liquidity Depth: The amount of tokens that exist in the circulating supply.
  • Concentrated Liquidity: Liquidity Bounded within some price range.
  • CFMM (constant function market makers): Any trade must change the reserves in such a way that the product of those reserves remains unchanged (i.e. equal to a constant).
  • Position: Liquidity concentrated to a finite range.
  • LPs: Liquidity providers.
  • Flash Swaps: Uniswap's flash swaps allow withdrawing up to the full reserves of any ERC20 token on Uniswap and execute arbitrary logic at no upfront cost. But, you either pay for the withdrawn ERC20 tokens with the corresponding pair tokens or return the withdrawn ERC20 tokens along with a small fee in the same transaction.

References:

Written by

Researcher. Blockchain Enthusiast. ZK Maximalist. Interested in scalability and privacy-preserving.

Similar Articles

January 9, 2021
Author: Zainab Hasan
January 28, 2021
Author: Zainab Hasan
March 15, 2021
Author: Zainab Hasan
1 2 3 16

Get notified on our latest Web3 researches and catch Xord at a glance.

    By checking this box , I agree to receive email communication from Xord.