In this article, we will explore the entire Liquidity concepts of the Uniswap V3 protocol. Requisite to understanding is a proper understanding of the Liquidity concepts in Uniswap V2.
In the link attached, we touched some keywords like: reserve, price, shares,quote, current or total pool liquidity. We used the following Uniswap smart contracts to explain the highlighted concepts: Uniswap V2 Library, Uniswap V2 Pool contract, and the Uniswap V2 Router contract.
To explain the liquidity concepts in Uniswap V3 for maximum comprehension, we will make a sync with the V2 concepts to further easier explain the V3 concepts.
In Uniswap V2, the pool contract tracks reserves and spot price to determine the current pool liquidity and price. We have reserves for token A and B in the state variable. While the Uniswap V3 tracks liquidity and price.
To determine the current price of token A in terms of token B, we will divide the Reserve A / Reserve B.
Reserve A - total amount of token X in the pool.
Reserve B - total amount of token Y in the pool.
Using the constant product formula: X * Y = K (invariant).
Price = Y / X.
This represents the spot price of token A in terms of token B.
As mentioned in my article on Uniswap V2, the formula to derive liquidity in Uniswap V2 is:
√xy = L
:: xy = L^2.
Let us digress a bit.
Uniswap V3 unlike Uniswap V2 aims to enable capital efficiency. Know that Uniswap V2 allows LPs to provide liquidity uniformly across all price ranges in a price curve from 0 to ∞. But it affects capital efficiency because the utilization of liquidity either below or above the current market price is least probable. And LPs only earn swap fees when their capital spread at a price point is used to facilitate trade. While this creates a passive yield strategy, it doesn’t incentivize LPs enough to make markets. This is what V3 does significantly as it limits liquidity fragmentation.
For example: A liquidity of $1 million USDC in Uniswap V2 distributed uniformly across all price ranges in a price curve could only facilitate trades of 200 USDC while the same liquidity in a Uniswap V3 pool can facilitate a trade of up to 500,000 USDC. This is what capital efficiency means as it means that LPs earn more swap fees for making markets for volume trades.
This is made possible by an AMM algorithm called Concentrated Liquidity AMM or CLAMM. This enables LPs to provide liquidity in a specific price range. Imagine a ruler, with each line of the CM called tick. If 0 cm to the end (assume 30 cm) is a price curve in a liquidity pool. V3 enables LPs to only provide liquidity in a range, let’s say 10 cm to 15 cm. That is called a price range. Instead of spreading liquidity uniformly to the entire price points, you have the flexibility of determining a tick or price range where you want to concentrate liquidity into. This removes Idle liquidity and creates Liquidity depth because more trades will be facilitated in a price range with more depth than when it’s uniformly distributed.
Also, this means that LPs can only earn swap fees in a range where their liquidity is active. If a large swap occurs that shifts liquidity from the left (lower price range) to the right (upper price range) and it uses up the liquidity in that particular range, it shifts liquidity to the inactive state, that is, LPs will no longer be able to earn fees on their liquidity in that range. This means, liquidity will no longer be passively managed because the LPs will have to remove their liquidity from an inactive pool to an active pool leading to an active management of liquidity. While this creates stress for the LP, it reduces slippage, removes liquidity fragmentation and makes the market efficient.
In addition, this unlocks a level of possibility where LPs can provide liquidity in a future price range in a pool where liquidity is most likely to get to. That is what the Uniswap V3 range limit order is all about and LPs can create multiple positions across varying price ranges providing an opportunity to hedge against risks and capture more opportunities.
Note: Liquidity concentrated in a range is called A Position and the tick-based pool functions similarly to Uniswap V2.
This asks a question, how do you track reserves in a tick-based pool?
Like I said earlier, Reserve is the total amount of token X and token Y in a pool. Since the Uniswap V3 tracks liquidity and price, we can determine reserves using the formula for liquidity and price below..
x.y = L^2
y / x = price
Using the formula for liquidity and price mentioned earlier, let us determine reserve X in the price range.
Expressing reserve X in terms of L and P.
xy = L^2 ………..eq 1
y / x = price …….eq 2.
y = price x :: px.
Putting y in eq 1.
x (px) = L^2
px^2 = L^2
x^2 = L^2 / p
x = L / √p
Putting the value for x = L / √p in eq 1.
L / √p y = L^2
Ly / √p = L^2
Ly = L^2 √p
y = L^2 √p / L
y = L√p
These are the values of reserve of X and Y of the entire pool.
To factor in the price ranges, the reserve of X will change when all of Y is depleted or when X is in an active range; we can calculate the value for reserve X between the prices Pa and Pb in an active range using the formula:
If x = L / √p
x = L / √p - L / √pb
Note: The reserve formula for X is the reserve available for trading in a price range.
when the reserve of Y is depleted, the current price would have shifted above the upper price.
Reserve of X = L (1 / √pa - 1 / √pb).
Since Y is depleted, this can also be likened to the maximum amount of X the price range can hold or a position can hold.
For Reserve Y.
If y = L√p
y = L√p - L √pa
This is the formula for Y when liquidity is in active range.
y = L(√pb - √pa)
However, when x is depleted in the range, the reserve of y is:
y = L(√pa - √pb).
// code
In Uniswap V2, the constant product formula ensures that liquidity is evenly distributed across all price ranges making them fragmented at price points where trading is least probable which makes capital inefficient. As a result, you can’t zoom in to specific price ranges, this makes the concept of concentrated liquidity a significant solution as liquidity is only available in price ranges that liquidity providers choose.
As I earlier said, the Uniswap V3 ticks act like mini-V2s but with concentrated liquidity. Liquidity is only available in ranges where liquidity providers specify, in an active range. It makes it possible to facilitate trade with less liquidity but with much more efficiency than V2.
For instance, if you want to swap in a range in V2, and liquidity of 10 ETH and 20,000 USDC is provided, that would eventually be spread across all price points in the price curve from 0 to ∞. In the Uniswap V3 tick-based system,the same amount won’t be needed to facilitate trade within that range because it’s concentrated, it may require 1 ETH and 2000 USDC to make the same trade happen. That is the concept of Virtual reserves because the same amount is supposed to have been provided but in a V3 specific price range. It ensures that the amount of tradable assets of token X and token Y is equal to the real liquidity that has been deposited into only that range.The real liquidity deposited is called the real reserves. If liquidity reaches either bounds of the tokens, they are no more concentrated and liquidity is only in one token. So, virtual reserves make it possible to avoid impermanent loss, or avoid holding just one token and to predict which range to add liquidity into.
Note: Virtual reserves allows the V3 AMM to behave like Uniswap V2 but they don’t represent actual deposited tokens. Real reserves are the actual tokens supplied by LPs.
In Uniswap V2, Price is the value of y / x. But in Uniswap V3, Price is stored as √p (sqrtPriceX96). This is because of precision. In Solidity, 5 / 2 = 2 because the EVM does not store decimal value. But it can be rewritten as 25 / 10 = 2.5. This way, it doesn’t lose its precision and it’s contained with 256 bits. This is how we derive values for less than 1 ether.
For Instance,
1 Ether = 10^18.
0.5 Ether = 5 * 10^17
Now, we have scaled the fractional part of “.5” by 10 ^ 17 to avoid losing precision. Precision is always lost when Solidity doesn’t allow representing fractional numbers.
However, this uses a mathematical notation called “Standard Notation”. This notation scales fractions using decimal (10).
Uniswap uses “Q Notation”, this type scales fractions using binary. It now depends on how many bits you want to scale by.
For instance, assuming we want to scale 1.5 in Q notation format by 8 bits, that is Q8.
1 * 2^8 = 256
0.5 * 2^8 = 128
Total is 384 in Q notation. The fraction has been converted to an integer without losing its precision. We can always get our value back by dividing by 2^8. Why this is easy for Uniswap to use is because the multiplication and division operations can be performed using bit shifting operations.
E.g. X * 2^n = x << n
Q number notation is usually written as Qm.n, where m = the integer part and n is the fractional part, that we are trying to scale.
Earlier, we used Q8. If we write it using the Qm.n format, we can write Q8.8, the integer value must contain 8 bits and the fraction can be scaled using binary scaling by bits.
QX96 used by Uniswap means the fraction is scaled by 96 bits. So we can express 1.5X96 as 1.5 * 2 ^96 or 1.5 << 96 but to store Q8.8 in a solidity integer, we need 8 + 8 = 16 bits because it’s both the integer and fractional bits that we want to store. This perfectly fits into uint16 or max uint256, which is uint16 variableX16
.
To get the sqrtPriceX96 used in Uniswap, we will use the spot price of an AMM, Y / X.
If token X is 500 and token Y is 20.
Price is 500 / 20 = 25.
So, the price is 25.
SqrtPrice: √25 = 5
SqrtPriceX96: 5 * 2 ^96.
Since we scale the fractional part by 96, Uniswap stores the integer in a uint160 SqrtPriceX96
variable to make up the max number of bits of an unsigned integer in Solidity. This adds to optimize gas by storing all the state variables in the first slot - slot0.
When providing liquidity, like in Uniswap V2, we specify the token amounts to send to the pool as liquidity, these are the desired amounts. Then it maps the amounts of both tokens to a certain amount of liquidity to ensure that we can re-compute the delta for those amounts.
Note: Liquidity, in the context of AMMs, measures the combined reserves of the token pair x and y.
For instance, amount0Desired → amount1Desired → liquidity.
The liquidity is also called Liquidity Delta. Delta is a financial term used to describe the amount needed to move an asset from point A to point B. Basically, it’s the change in liquidity (∆ liquidity).
What Uniswap does is that it first calculates the liquidity delta for the amounts to be provided. And then re-calculate the delta of both amount 0 and amount1. Now, this calculation might be seen as circling about but it’s needed because using the amounts desired, you can’t know where price sits in the curve if the amountsDelta is not computed from the liquidity.
Let’s examine the following smart contract that is put together to ensure we provide liquidity the Uniswap way.
Pool.sol
LiquidityManagement.sol
NonfungiblePositionManager.sol
Before we dive deep, keep in mind that a Uniswap V3 Position is an object:
Position – > {
tickLower;
tickUpper;
liquidityDelta;
}
To create a position by supplying liquidity, it’s important to know the pool state, especially the current tick of the pool.
If the current tick of the pool is less than the tick Lower, then the pool is out of range.
If the current tick of the pool is greater than the tick Upper, then the pool is out of range.
If the current tick of the pool is less than the tick Upper, then the pool is in the active range.
When a pool is in active range, we can supply amount0Delta and amount1Delta
When a pool tick is less than the tickLower, then we can only supply the amount0Delta because that’s the amount depleted in the pool.
When a pool tick is greater than the tickUpper, then we can only supply the amount1Delta because that’s the amount depleted in the pool.
The amountsDelta are the amount required to shift the pool price from point A to point B. Uniswap uses the same formula mentioned earlier to calculate the estimated amounts required to shift the range from price lower to upper.
Estimated amountdelta of X and Y.
x = L / √pL - L / √pU. --> amount0Delta
y = L√pU - L √pL -->. amount1Delta
Although, they are used in different contexts that require different parameters. We use the price lower and price upper specified by the user to calculate the estimates here whenever we want to add liquidity.
Let’s check the code.
The mint
function is the core function that mints liquidity to an LP after confirming the Balance updates. It calls the `_modifyPosition` function which does modify liquidity(either adding or removing) which is + or - liquidityDelta which is a int256 type. Note that the delta value returned (amount0Int and amount1Int) is pre-computed and hasn't been sent into the pool until the uniswapV3MintCallback
is called.
function mint(
address recipient,
int24 tickLower,
int24 tickUpper,
uint128 amount,
bytes calldata data
) external override lock returns (uint256 amount0, uint256 amount1) {
require(amount > 0);
(, int256 amount0Int, int256 amount1Int) =
_modifyPosition(
ModifyPositionParams({
owner: recipient,
tickLower: tickLower,
tickUpper: tickUpper,
liquidityDelta: int256(amount).toInt128()
})
);
amount0 = uint256(amount0Int);
amount1 = uint256(amount1Int);
uint256 balance0Before;
uint256 balance1Before;
if (amount0 > 0) balance0Before = balance0();
if (amount1 > 0) balance1Before = balance1();
IUniswapV3MintCallback(msg.sender).uniswapV3MintCallback(amount0, amount1, data);
if (amount0 > 0) require(balance0Before.add(amount0) <= balance0(), 'M0');
if (amount1 > 0) require(balance1Before.add(amount1) <= balance1(), 'M1');
emit Mint(msg.sender, recipient, tickLower, tickUpper, amount, amount0, amount1);
}
The _modifyPosition
function then confirms if the liquidityDelta is non-zero, and as we said earlier a position comprises the tick lower and upper, we must confirm the pool state by checking in the slot0
variable to check where the current pool tick is placed. If it's lesser than the specified tick lower (out of range) or greater than the tick Upper(out of range) or less than the tick Upper (in active range). That is the conditional branching you can see in the code to determine which token and amountDelta will be transferred to the pool.
Note: The amountDelta returned here (amount0Int and amount1Int) can only be positive when adding liquidity.
Amount Owed in this context is the amount0Delta pre-calculated to be sent to the pool and it must be greater than 0 when adding liquidity. Basically, it means you owe the pool some amounts of liquidity to be sent.
When removing liquidity, I call the delta amounts, amount owned, that is the amount the pool owe you when removing liquidity.
For context, see the Uniswap's comment in the code below.
/// @dev Effect some changes to a position
/// @param params the position details and the change to the position's liquidity to effect
/// @return position a storage pointer referencing the position with the given owner and tick range
/// @return amount0 the amount of token0 owed to the pool, negative if the pool should pay the recipient
/// @return amount1 the amount of token1 owed to the pool, negative if the pool should pay the recipient
function _modifyPosition(ModifyPositionParams memory params)
private
noDelegateCall
returns (
Position.Info storage position,
int256 amount0,
int256 amount1
)
{
checkTicks(params.tickLower, params.tickUpper);
Slot0 memory _slot0 = slot0; // SLOAD for gas optimization
position = _updatePosition(
params.owner,
params.tickLower,
params.tickUpper,
params.liquidityDelta,
_slot0.tick
);
if (params.liquidityDelta != 0) {
if (_slot0.tick < params.tickLower) {
// current tick is below the passed range; liquidity can only become in range by crossing from left to
// right, when we'll need _more_ token0 (it's becoming more valuable) so user must provide it
amount0 = SqrtPriceMath.getAmount0Delta(
TickMath.getSqrtRatioAtTick(params.tickLower),
TickMath.getSqrtRatioAtTick(params.tickUpper),
params.liquidityDelta
);
} else if (_slot0.tick < params.tickUpper) {
// current tick is inside the passed range
uint128 liquidityBefore = liquidity; // SLOAD for gas optimization
// write an oracle entry
(slot0.observationIndex, slot0.observationCardinality) = observations.write(
_slot0.observationIndex,
_blockTimestamp(),
_slot0.tick,
liquidityBefore,
_slot0.observationCardinality,
_slot0.observationCardinalityNext
);
amount0 = SqrtPriceMath.getAmount0Delta(
_slot0.sqrtPriceX96,
TickMath.getSqrtRatioAtTick(params.tickUpper),
params.liquidityDelta
);
amount1 = SqrtPriceMath.getAmount1Delta(
TickMath.getSqrtRatioAtTick(params.tickLower),
_slot0.sqrtPriceX96,
params.liquidityDelta
);
liquidity = LiquidityMath.addDelta(liquidityBefore, params.liquidityDelta);
} else {
// current tick is above the passed range; liquidity can only become in range by crossing from right to
// left, when we'll need _more_ token1 (it's becoming more valuable) so user must provide it
amount1 = SqrtPriceMath.getAmount1Delta(
TickMath.getSqrtRatioAtTick(params.tickLower),
TickMath.getSqrtRatioAtTick(params.tickUpper),
params.liquidityDelta
);
}
}
}
The LiquidityManagement
contract in Uniswap V3 manages liquidity.
The flow:
- The LP sends in the amount0Desired and amount1Desired.
- Earlier we said, these amounts map to a liquidityDelta which is the amount of liquidity required to change the pool's original liquidity. It is delta because we could either add or remove liquidity.
- The liquidityDelta is sent to the pool to pre-calculate the amountsDelta (amount0, amount1) = pool.mint
- The pool calls back the `uniswapV3MintCallback` to pay the pre-calculated amounts `pay(decoded.poolKey.token0, decoded.payer, msg.sender, amount0Owed);`
- The CallbackValidation.verifyCallback(factory, decoded.poolKey)
verifies the caller is the pool.
- In this case, amount0Owed > 0
must be true because LiquidityDelta is positive when we want to add liquidity.
abstract contract LiquidityManagement is IUniswapV3MintCallback, PeripheryImmutableState, PeripheryPayments {
struct MintCallbackData {
PoolAddress.PoolKey poolKey;
address payer;
}
/// @inheritdoc IUniswapV3MintCallback
function uniswapV3MintCallback(
uint256 amount0Owed,
uint256 amount1Owed,
bytes calldata data
) external override {
MintCallbackData memory decoded = abi.decode(data, (MintCallbackData));
CallbackValidation.verifyCallback(factory, decoded.poolKey);
if (amount0Owed > 0) pay(decoded.poolKey.token0, decoded.payer, msg.sender, amount0Owed);
if (amount1Owed > 0) pay(decoded.poolKey.token1, decoded.payer, msg.sender, amount1Owed);
}
struct AddLiquidityParams {
address token0;
address token1;
uint24 fee;
address recipient;
int24 tickLower;
int24 tickUpper;
uint256 amount0Desired;
uint256 amount1Desired;
uint256 amount0Min;
uint256 amount1Min;
}
/// @notice Add liquidity to an initialized pool
function addLiquidity(AddLiquidityParams memory params)
internal
returns (
uint128 liquidity,
uint256 amount0,
uint256 amount1,
IUniswapV3Pool pool
)
{
PoolAddress.PoolKey memory poolKey =
PoolAddress.PoolKey({token0: params.token0, token1: params.token1, fee: params.fee});
pool = IUniswapV3Pool(PoolAddress.computeAddress(factory, poolKey));
// compute the liquidity amount
{
(uint160 sqrtPriceX96, , , , , , ) = pool.slot0();
uint160 sqrtRatioAX96 = TickMath.getSqrtRatioAtTick(params.tickLower);
uint160 sqrtRatioBX96 = TickMath.getSqrtRatioAtTick(params.tickUpper);
liquidity = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96,
sqrtRatioAX96,
sqrtRatioBX96,
params.amount0Desired,
params.amount1Desired
);
}
(amount0, amount1) = pool.mint(
params.recipient,
params.tickLower,
params.tickUpper,
liquidity,
abi.encode(MintCallbackData({poolKey: poolKey, payer: msg.sender}))
);
require(amount0 >= params.amount0Min && amount1 >= params.amount1Min, 'Price slippage check');
}
}
The actual contract to call whenever an LP wants to add liquidity is the NonfungibleManager.sol
. It has an external mint function that calls into the addLiquidity
function in the LiquidityManagement.sol
contract.
/// @inheritdoc INonfungiblePositionManager
function mint(MintParams calldata params)
external
payable
override
checkDeadline(params.deadline)
returns (
uint256 tokenId,
uint128 liquidity,
uint256 amount0,
uint256 amount1
)
{
IUniswapV3Pool pool;
(liquidity, amount0, amount1, pool) = addLiquidity(
AddLiquidityParams({
token0: params.token0,
token1: params.token1,
fee: params.fee,
recipient: address(this),
tickLower: params.tickLower,
tickUpper: params.tickUpper,
amount0Desired: params.amount0Desired,
amount1Desired: params.amount1Desired,
amount0Min: params.amount0Min,
amount1Min: params.amount1Min
})
);
_mint(params.recipient, (tokenId = _nextId++));
bytes32 positionKey = PositionKey.compute(address(this), params.tickLower, params.tickUpper);
(, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, , ) = pool.positions(positionKey);
// idempotent set
uint80 poolId =
cachePoolKey(
address(pool),
PoolAddress.PoolKey({token0: params.token0, token1: params.token1, fee: params.fee})
);
_positions[tokenId] = Position({
nonce: 0,
operator: address(0),
poolId: poolId,
tickLower: params.tickLower,
tickUpper: params.tickUpper,
liquidity: liquidity,
feeGrowthInside0LastX128: feeGrowthInside0LastX128,
feeGrowthInside1LastX128: feeGrowthInside1LastX128,
tokensOwed0: 0,
tokensOwed1: 0
});
emit IncreaseLiquidity(tokenId, liquidity, amount0, amount1);
}
Uniswap V3 introduces a groundbreaking approach to liquidity provision through its Concentrated Liquidity Market Maker (CLAMM) model, significantly enhancing capital efficiency compared to Uniswap V2. By allowing liquidity providers (LPs) to concentrate their funds within specific price ranges (ticks), Uniswap V3 ensures that liquidity is deployed where it is most likely to be utilized, reducing idle capital and maximizing fee earnings for active market makers.
Key takeaways from this exploration include:
Capital Efficiency: Unlike V2, where liquidity is spread uniformly across all price ranges, V3 enables LPs to allocate funds to targeted price intervals. This means the same amount of capital can facilitate larger trades within a specified range, improving capital utilization and reducing slippage.
Tick-Based System: Liquidity in V3 is managed within discrete price ranges (ticks), functioning like mini-V2 pools but with concentrated depth. This allows LPs to create multiple positions, hedge risks, and implement advanced strategies such as limit orders.
Reserves & Price Calculation: V3 tracks liquidity (L) and price (√P) rather than raw reserves. Virtual reserves ensure that liquidity behaves like a traditional AMM within an active range while optimizing gas and precision using Q notation (sqrtPriceX96).
Active Liquidity Management: LPs must monitor their positions and adjust liquidity as price moves out of their designated range to remain active and earn fees. This shifts liquidity provision from a passive to a more active strategy.
Minting Liquidity: Adding liquidity in V3 involves specifying a price range, calculating liquidity delta (L), and pre-computing token amounts (amount0Delta and amount1Delta) to ensure optimal capital allocation. The NonfungiblePositionManager facilitates this process, minting NFTs representing LP positions.
Uniswap V3’s innovations make it a powerful tool for both retail and professional liquidity providers, offering greater control, efficiency, and earning potential. However, this comes with increased complexity, requiring LPs to actively manage their positions to maximize returns. By understanding these core concepts—liquidity concentration, tick-based pricing, and virtual reserves—users can better navigate Uniswap V3’s advanced features and leverage its capabilities for decentralized market-making.
The evolution from V2 to V3 marks a significant step forward in decentralized finance (DeFi), paving the way for more sophisticated and capital-efficient AMM designs in the future.