Provide Liquidity Overview
Compared with initializing, providing liquidity is a little complicated. Let's start from test_ffi_addLiqudity_weirdPool_0_returnsCorrectLiquidityDelta
function in test/ModifyLiquidity.t.sol
function test_ffi_addLiqudity_weirdPool_0_returnsCorrectLiquidityDelta() public {
// Use a pool with TickSpacing of MAX_TICK_SPACING
(PoolKey memory wp0, PoolId wpId0) =
initPool(currency0, currency1, IHooks(address(0)), 500, TickMath.MAX_TICK_SPACING, SQRT_PRICE_1_1);
// Set the params to add random amount of liquidity to random tick boundary.
int24 tickUpper = TickMath.MAX_TICK_SPACING * 4;
int24 tickLower = TickMath.MAX_TICK_SPACING * -9;
IPoolManager.ModifyLiquidityParams memory params = IPoolManager.ModifyLiquidityParams({
tickLower: tickLower, // int24
tickUpper: tickUpper, // int24
liquidityDelta: 16787899214600939458, // int256
salt: 0 // bytes32
});
(BalanceDelta delta) = modifyLiquidityRouter.modifyLiquidity(wp0, params, ZERO_BYTES);
...
}
In this function, we initialize the pool first, and then we set ModifyLiquidityParams
structure parameters. The salt
parameter allows users to create multiple independent positions within the same price range. This enables better management of different liquidity strategies, such as market making, arbitrage, or yield optimization. By using salt
, liquidity providers can track and manage liquidity from various sources separately.
Practical using cases such as:
- Market makers can isolate liquidity for different risk strategies.
- DeFi protocols can distinguish between user deposits and protocol-owned liquidity.
- Multiple positions within the same price range can have distinct yield distribution strategies.
liquidityDelta
represent ()
Later, we input key
and params
into modifyLiquidity
function.
Now let's turn around into src/test/PoolModifyLiquidityTest.sol
function modifyLiquidity(
PoolKey memory key,
IPoolManager.ModifyLiquidityParams memory params,
bytes memory hookData
) external payable returns (BalanceDelta delta) {
delta = modifyLiquidity(key, params, hookData, false, false);
}
This function takes three input parameters:
-
key - Contains identifying information about the pool.
-
params - Specifies the parameters for modifying liquidity.
-
hookData - Represents data related to hooks.
The function modifyLiquidity
presented here is essentially a wrapper that delegates execution to an overloaded method with additional parameters (). To better understand the underlying logic, let's dive into the implementation of the overloaded modifyLiquidity
function below.
In the overloaded method, two additional parameters enhance control over liquidity settlement and claims:
settleUsingBurn
:
- True: Settle by burning tokens.
- False: Settle by direct transfer.
takeClaims
:
- True: Receive tokens via claims (debt mechanism).
- False: Receive tokens through direct transfer.
function modifyLiquidity(
PoolKey memory key,
IPoolManager.ModifyLiquidityParams memory params,
bytes memory hookData,
bool settleUsingBurn,
bool takeClaims
) public payable returns (BalanceDelta delta) {
delta = abi.decode(
manager.unlock(abi.encode(CallbackData(msg.sender, key, params, hookData, settleUsingBurn, takeClaims))),
(BalanceDelta)
);
uint256 ethBalance = address(this).balance;
if (ethBalance > 0) {
CurrencyLibrary.ADDRESS_ZERO.transfer(msg.sender, ethBalance);
}
}
The modifyLiquidity
function directly triggers the unlock
function from src/PoolManager.sol
. The unlock function is critical for managing interactions with liquidity pools. It temporarily unlocks the PoolManager contract, allowing a series of operations to be executed atomically. This ensures that all state changes occur within a single transaction, maintaining consistency and security.
function unlock(bytes calldata data) external override returns (bytes memory result) {
if (Lock.isUnlocked()) AlreadyUnlocked.selector.revertWith();
Lock.unlock();
// the caller does everything in this callback, including paying what they owe via calls to settle
result = IUnlockCallback(msg.sender).unlockCallback(data);
if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
Lock.lock();
}
When a user calls the unlock
function, it requires the caller to implement the IUnlockCallback
interface, specifically the unlockCallback
function. This callback is invoked during the unlocking process, enabling the caller to perform various operations such as swaps, liquidity modifications, or donations within the unlocked context. The unlock
function accepts a bytes parameter, which is passed to the unlockCallback
to facilitate the execution of these operations.
For a detailed explanation of the unlock function and its associated callback mechanism, refer to the official Uniswap V4 documentation here.
Now that we've covered the unlock
function, let's look at how to implement unlockCallback
. This function handles liquidity adjustment and ensures proper state settlement. Here's the implementation from src/test/PoolModifyLiquidityTest.sol
.
function unlockCallback(bytes calldata rawData) external returns (bytes memory) {
require(msg.sender == address(manager));
CallbackData memory data = abi.decode(rawData, (CallbackData));
(uint128 liquidityBefore,,) = manager.getPositionInfo(
data.key.toId(), address(this), data.params.tickLower, data.params.tickUpper, data.params.salt
);
(BalanceDelta delta,) = manager.modifyLiquidity(data.key, data.params, data.hookData);
(uint128 liquidityAfter,,) = manager.getPositionInfo(
data.key.toId(), address(this), data.params.tickLower, data.params.tickUpper, data.params.salt
);
(,, int256 delta0) = _fetchBalances(data.key.currency0, data.sender, address(this));
(,, int256 delta1) = _fetchBalances(data.key.currency1, data.sender, address(this));
require(
int128(liquidityBefore) + data.params.liquidityDelta == int128(liquidityAfter), "liquidity change incorrect"
);
if (data.params.liquidityDelta < 0) {
assert(delta0 > 0 || delta1 > 0);
assert(!(delta0 < 0 || delta1 < 0));
} else if (data.params.liquidityDelta > 0) {
assert(delta0 < 0 || delta1 < 0);
assert(!(delta0 > 0 || delta1 > 0));
}
if (delta0 < 0) data.key.currency0.settle(manager, data.sender, uint256(-delta0), data.settleUsingBurn);
if (delta1 < 0) data.key.currency1.settle(manager, data.sender, uint256(-delta1), data.settleUsingBurn);
if (delta0 > 0) data.key.currency0.take(manager, data.sender, uint256(delta0), data.takeClaims);
if (delta1 > 0) data.key.currency1.take(manager, data.sender, uint256(delta1), data.takeClaims);
return abi.encode(delta);
}
The first line of this function verifies that the call originates from the trusted manager contract itself. Since the unlockCallback
function modifies the manager's data, it is essential to ensure that only the trusted manager can perform this operation.
Next, review the getPositionInfo
function for details on retrieving position-related data. This function is implemented in src/libraries/StateLibrary.sol
.
function getPositionInfo(
IPoolManager manager,
PoolId poolId,
address owner,
int24 tickLower,
int24 tickUpper,
bytes32 salt
) internal view returns (uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128) {
// positionKey = keccak256(abi.encodePacked(owner, tickLower, tickUpper, salt))
bytes32 positionKey = Position.calculatePositionKey(owner, tickLower, tickUpper, salt);
(liquidity, feeGrowthInside0LastX128, feeGrowthInside1LastX128) = getPositionInfo(manager, poolId, positionKey);
}
positionKey
is calculated using keccak256 hash method. The getPositionInfo
overload function then uses this key and other parameters, to return the provided liquidity and the last recorded feeGrowthInside0LastX128
and feeGrowthInside1LastX128
for token 0 and token 1.
To better understand the significance of feeGrowthInside0LastX128
and feeGrowthInside1LastX128
, let's break down the meaning of Inside and InsideLast in the context of Uniswap.
- Inside represents the total accumulated fee growth inside the tick range for token 0 since the pool's creation. It continues to grow as trades occur within the range.
- InsideLast is a snapshot of Inside, recorded the last time liquidity was modified or fees were collected.
With Inside and InsideLast, when the liquidity provider's earned fees can be calculated using the following formula:
feesEarned = (feeGrowthInsideX128 - feeGrowthInsideLastX128) * liquidity / (1 << 128)
The difference between feeGrowthInsideX128
and feeGrowthInsideLastX128
represents the newly accumulated fees since the last recorded snapshot. If no fees have been withdraw, the difference continues to grow. Upon withdrawal, the snapshot (feeGrowthInsideLastX128
) is updated to match the current value (feeGrowthInsideX128
). This ensures that future calculations only account for newly accrued fees.
Returning to unlockCallback
, we use getPositionInfo
to retrieve the liquidity value before and after modifying liquidity. Expecting the conditionliquidityBefore + liquidityDelta == liquidityAfter
.
Now let's dive into src/PoolManager.sol
to examine the liquidity modification process.
function modifyLiquidity(
PoolKey memory key,
IPoolManager.ModifyLiquidityParams memory params,
bytes calldata hookData
) external onlyWhenUnlocked noDelegateCall returns (BalanceDelta callerDelta, BalanceDelta feesAccrued) {
{
...
BalanceDelta principalDelta;
(principalDelta, feesAccrued) = pool.modifyLiquidity(
Pool.ModifyLiquidityParams({
owner: msg.sender,
tickLower: params.tickLower,
tickUpper: params.tickUpper,
liquidityDelta: params.liquidityDelta.toInt128(),
tickSpacing: key.tickSpacing,
salt: params.salt
})
);
// fee delta and principal delta are both accrued to the caller
callerDelta = principalDelta + feesAccrued;
}
...
_accountPoolBalanceDelta(key, callerDelta, msg.sender);
}
The core functionality of the modifyLiquidity
function revolves around invoking and executing pool.modifyLiquidity
, which directly modifies the liquidity within the pool. The execution details of pool.modifyLiquidity
will be analyzed in the next section.
After execution, the returned parameter principalDelta
indicates the change in pool token balances.
• A negative delta means the liquidity provider must deposit tokens into the pool.
• A positive delta means the liquidity provider can withdraw tokens from the pool.
delta is an int256
structure, where the upper int128
represents token0 and the lower int128
represents token1.
feesAccrued
represents the amount of fees earned when liquidity is withdrawn. This value is only relevant during liquidity withdrawal operations. When providing liquidity, feesAccrued
is set to 0 as no fees are collected at that time.
callerDelta
represents the actual amount of tokens the user needs to provide or receive. During liquidity provision, feesaccrued
is set to 0, making callerDelta
equal to principalDelta
. During liquidity withdrawal, callerDelta
equals the sum of principalDelta
and feesAccrued
, allowing users to withdraw both principal and earned fees.
The _accountPoolBalanceDelta
function temporarily records balance changes without directly modifying the user's token balance. This deferred settlement is finalized within the unlockCallback
function, where all balance adjustments are processed collectively. Details of the _accountPoolBalanceDelta` implementation will be covered in the section "flash accounting".
Now let's revisit the unlockCallback
function. Here, we use _fetchBalances
function to retrieve user token balances that were temporarily recorded by _accountPoolBalanceDelta
ealier. Following this, the settle
or take
method are invoked to either transfer tokens to the pool or return tokens from the pool to the user. Also, the details of settle
and take
method will be covered in the section "flash accounting".