Skip to main content

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 LL (L=xyL= \sqrt{xy})

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".