In this article, we will explore the entire Liquidity concepts of the Uniswap V3 protocol. Requisite to following along in this guide 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 to determine the current pool liquidity and price. We have reserves for token A and B as state variables. While the Uniswap V3 tracks liquidity and price.
To determine the current price of token A in terms of token B, we will divide, Reserve B / Reserve A.
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 put things to perspective as we progress.
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 at a price point is used to facilitate trade. While this creates a passive yield strategy (like the set-and-forget pattern) - as the LPs rely on if the liquidity will be used later - it doesn’t incentivize LPs enough to make markets because their earning capacity is based on chances, and what is the chance that price will fall below or rise above? 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 centimeter (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 and leaving it will create impermanent loss. This means, liquidity will no longer be passively managed because the LPs will have to remove their liquidity from an inactive pool thereby avoiding impermanent loss, 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 AMM design unlocks a level of possibility where LPs can provide liquidity in a range where the current price on the curve sits above the upper or below the lower range specified by the user. 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 (mini-V2).
This poses 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.
Math Calculations: 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
Reserve of X = L / √p - L / √pb
Note: The reserve formula for X is the reserve available for trading in a price range.
Where the reserve of Y is depleted, the current price would have shifted above the upper price.
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.
Reserve of Y = L(√p - √pa)
However, where X is depleted in the range, the reserve of y is:
Y = L(√pa - √pb).
Note: The following formulas apply to different cases:
x = L / √p - L / √pb
y = L√p - L √pa
1. When you want to derive the amount of X and Y (reserves) backing the current liquidity - it uses the current pool's state.
2. When you want to derive the amount of X or Y to add when adding liquidity to a pool - it uses the user specified ranges.
For case 1:
If the following parameters are true:
L - current liquidity.
p - current price (it could be the lower or upper price if it's an active range.
pb- current upper price of a range.
pa - current lower price of a range.
function getAmount0ForLiquidity(
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint128 liquidity
) internal pure returns (uint256 amount0) {
if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
return
FullMath.mulDiv(
uint256(liquidity) << FixedPoint96.RESOLUTION,
sqrtRatioBX96 - sqrtRatioAX96,
sqrtRatioBX96
) / sqrtRatioAX96;
}
/// @notice Computes the amount of token1 for a given amount of liquidity and a price range
/// @param sqrtRatioAX96 A sqrt price representing the first tick boundary
/// @param sqrtRatioBX96 A sqrt price representing the second tick boundary
/// @param liquidity The liquidity being valued
/// @return amount1 The amount of token1
function getAmount1ForLiquidity(
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint128 liquidity
) internal pure returns (uint256 amount1) {
if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
return FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96);
}
Note: Based on the formula used to derive the amount of token 0 and token 1 backing a particular amount of pool liquidity. We can equally get the Liquidity backing the given amounts simply by making Liquidity (L) the subject of formula.
Another case is that, we can get the liquidity to add if we have a given amounts desired to add.
If the following parameters is true:
Pa - price lower specified by the user based on the given tick lower.
Pb - price upper specified by the user based on the given tick upper.
Amount0 - amount desired to add for token 0.
Amount1 - amount desired to add for token 1.
function getLiquidityForAmount0(
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint256 amount0
) internal pure returns (uint128 liquidity) {
if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
uint256 intermediate = FullMath.mulDiv(sqrtRatioAX96, sqrtRatioBX96, FixedPoint96.Q96);
return toUint128(FullMath.mulDiv(amount0, intermediate, sqrtRatioBX96 - sqrtRatioAX96));
}
/// @notice Computes the amount of liquidity received for a given amount of token1 and price range
/// @dev Calculates amount1 / (sqrt(upper) - sqrt(lower)).
/// @param sqrtRatioAX96 A sqrt price representing the first tick boundary
/// @param sqrtRatioBX96 A sqrt price representing the second tick boundary
/// @param amount1 The amount1 being sent in
/// @return liquidity The amount of returned liquidity
function getLiquidityForAmount1(
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint256 amount1
) internal pure returns (uint128 liquidity) {
if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
return toUint128(FullMath.mulDiv(amount1, FixedPoint96.Q96, sqrtRatioBX96 - sqrtRatioAX96));
}
However, while using those formulas to calculate the Liquidity (L) by making it the subject of formula, we can't tell the liquidity to add based on where the current price is in the curve. the code above only gets the liquidity based on which token is dominant in the pool where the other is depleted.
But the code below does justice to deriving Liquidity based on the amounts desired regardless of where price sits in the curve.
function getLiquidityForAmounts(
uint160 sqrtRatioX96,
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint256 amount0,
uint256 amount1
) internal pure returns (uint128 liquidity) {
if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
if (sqrtRatioX96 <= sqrtRatioAX96) {
liquidity = getLiquidityForAmount0(sqrtRatioAX96, sqrtRatioBX96, amount0);
} else if (sqrtRatioX96 < sqrtRatioBX96) {
uint128 liquidity0 = getLiquidityForAmount0(sqrtRatioX96, sqrtRatioBX96, amount0);
uint128 liquidity1 = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioX96, amount1);
// If you follow along with our explanation on Uniswap V2, you would be aware that liquidity is the minimum when compared.
liquidity = liquidity0 < liquidity1 ? liquidity0 : liquidity1;
} else {
liquidity = getLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1);
}
}
For case 2:
If the following parameters are true;
L - Liquidity Delta (Liquidity to add or remove)
p - current price (it could be the lower or upper price if it's an active range.
pb - upper price specified by the user based on the tick upper.
pa - lower price specified by the user based on the tick lower.
function getAmount1Delta(
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
uint128 liquidity,
bool roundUp
) internal pure returns (uint256 amount1) {
if (sqrtRatioAX96 > sqrtRatioBX96) (sqrtRatioAX96, sqrtRatioBX96) = (sqrtRatioBX96, sqrtRatioAX96);
return
roundUp
? FullMath.mulDivRoundingUp(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96)
: FullMath.mulDiv(liquidity, sqrtRatioBX96 - sqrtRatioAX96, FixedPoint96.Q96);
}
/// @notice Helper that gets signed token0 delta
/// @param sqrtRatioAX96 A sqrt price
/// @param sqrtRatioBX96 Another sqrt price
/// @param liquidity The change in liquidity for which to compute the amount0 delta
/// @return amount0 Amount of token0 corresponding to the passed liquidityDelta between the two prices
function getAmount0Delta(
uint160 sqrtRatioAX96,
uint160 sqrtRatioBX96,
int128 liquidity
) internal pure returns (int256 amount0) {
return
liquidity < 0
? -getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(-liquidity), false).toInt256()
: getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(liquidity), true).toInt256();
}
Note: If you want to calculate an estimate amounts, the second case does the calculation as it provides the flexibility to choose the rounding direction based on the amounts. There will be a difference in Wei if you use the first case.
The second case is in a function _modifyPosition
and even though it's internal, you can copy it for use in your code if you need to make an estimate.
library PositionModifier {
function modifyPosition(ModifyPositionParams memory params)
public
noDelegateCall
returns (
Position.Info storage position,
int256 amount0,
int256 amount1
)
{
checkTicks(params.tickLower, params.tickUpper);
Slot0 memory _slot0 = pool.slot0(); // SLOAD for gas optimization
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) {
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
);
}
}
}
}
Towards the end of this article, you will understand how everything fits in by creating a connection.
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. 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 (Virtual liquidity - liquidity in V2), 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 (Real liquidity - liquidity in V3 concentrated range) to make the same trade happen. The former explains the concept of Virtual reserves because the actual amount is supposed to have been provided or pre-computed just to get the real amount to concentrate 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.
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 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.
paul elisha