Introduction
The Position Manager in v4 provides a streamlined way to manage liquidity positions through a command-based interface. Unlike previous versions where each operation required separate function calls, v4’s Position Manager uses a batched command pattern that allows multiple operations to be executed in a single transaction.
Command-Based Design
At its core, the Position Manager works by executing a sequence of commands:
// Example: Minting a new position requires two commands
bytes memory actions = abi.encodePacked(
Actions.MINT_POSITION, // Create the position
Actions.SETTLE_PAIR // Provide the tokens
);
Each command (or action) represents a specific operation, and these actions can be:
- Liquidity Operations: Creating, modifying, or removing positions
- Delta-Resolving Operations: Handling token transfers and settlements
How Commands Work Together
When executing operations through the Position Manager, you’ll always:
- Define what actions to perform
- Provide the parameters for each action
- Execute them through a single function call
// 1. Define actions
bytes memory actions = abi.encodePacked(action1, action2);
// 2. Encode parameters for each action
bytes[] memory params = new bytes[](2);
params[0] = abi.encode(/* parameters for action1 */);
params[1] = abi.encode(/* parameters for action2 */);
// 3. Execute through modifyLiquidities
positionManager.modifyLiquidities(
abi.encode(actions, params),
deadline
);
This design enables efficient operations by:
- Minimizing transaction costs through batching
- Allowing complex position management in single transactions
- Providing flexibility in how operations are combined
In the following sections, we’ll explore how to work with these commands and implement common liquidity management operations.
Core Concepts
Before diving into specific operations, let’s understand the key concepts that make up the Position Manager’s architecture.
Action Types
The Position Manager operates through a system of actions that work in pairs: when you perform a liquidity operation that changes position balances, you must also include actions to handle the resulting token movements.
Understanding the Flow
When you execute a liquidity operation:
- The operation creates deltas (token obligations)
- These deltas represent tokens that need to be paid or received
- Delta-resolving operations are then used to handle these token movements
Liquidity Operations
Actions that modify positions in the pool:
// Common liquidity operations
uint8 constant MINT_POSITION = 1; // Creates negative deltas (tokens needed for position)
uint8 constant INCREASE_LIQUIDITY = 2; // Creates negative deltas (tokens to add)
uint8 constant DECREASE_LIQUIDITY = 3; // Creates positive deltas (tokens to receive)
uint8 constant BURN_POSITION = 4; // Creates positive deltas (tokens to receive)
Each operation creates specific deltas that must be resolved:
- Negative deltas when you need to provide tokens (mint, increase)
- Positive deltas when you’re receiving tokens (decrease, burn)
Delta-Resolving Operations
Actions that handle the token transfers needed to resolve deltas:
// Common delta-resolving operations
uint8 constant SETTLE_PAIR = 10; // For negative deltas: Pay tokens to the pool
uint8 constant TAKE_PAIR = 11; // For positive deltas: Receive tokens from the pool
uint8 constant CLOSE_CURRENCY = 12; // Handles either direction based on final delta
uint8 constant CLEAR_OR_TAKE = 13; // For small amounts: Take if worth it, else ignore
Operation Order
Understanding how operations create and resolve deltas helps in ordering them efficiently:
// Efficient: Group operations that create deltas, then resolve them together
bytes memory actions = abi.encodePacked(
Actions.MINT_POSITION, // First delta: -100 tokens
Actions.INCREASE_LIQUIDITY, // Second delta: -50 tokens
Actions.SETTLE_PAIR // Resolve total: -150 tokens at once
);
// Less efficient: Resolving deltas multiple times
bytes memory actions = abi.encodePacked(
Actions.MINT_POSITION, // Delta: -100 tokens
Actions.SETTLE_PAIR, // Resolve: -100 tokens
Actions.INCREASE_LIQUIDITY, // New delta: -50 tokens
Actions.SETTLE_PAIR // Resolve again: -50 tokens
);
Best practices for ordering:
- Group liquidity operations that create similar deltas (e.g., all negative or all positive)
- Resolve all deltas together at the end when possible
- Use
CLOSE_CURRENCY
when you can't predict the final delta
Working with Liquidity Positions
When building on v4, you’ll need to manage liquidity positions through the Position Manager. Let’s walk through each operation, starting with creating new positions.
Minting New Positions
To create a new liquidity position in v4, you’ll need to:
- Define your position parameters (pool, price range, amount)
- Mint the position NFT
- Provide the initial tokens
Understanding Position Parameters
Before minting, you need to determine:
- Which pool you’re providing liquidity to
- Your price range (defined by tick bounds)
- How much liquidity to provide
- Maximum amounts of tokens you’re willing to spend
// Example position parameters
PoolKey poolKey = // Your pool key
int24 tickLower = -887272; // Price range lower bound
int24 tickUpper = 887272; // Price range upper bound
uint128 liquidity = 1000000; // Liquidity amount
uint256 amount0Max = 1e18; // Max 1 token0
uint256 amount1Max = 1e18; // Max 1 token1
Implementation
Let’s implement a function to mint new liquidity positions step by step:
/// @notice Mints a new liquidity position
/// @param poolKey The pool to provide liquidity to
/// @param tickLower Lower bound of the price range
/// @param tickUpper Upper bound of the price range
/// @param liquidity Amount of liquidity to provide
/// @param amount0Max Maximum amount of token0 to spend
/// @param amount1Max Maximum amount of token1 to spend
/// @param recipient Address that will own the position
function mintNewPosition(
PoolKey calldata poolKey,
int24 tickLower,
int24 tickUpper,
uint128 liquidity,
uint256 amount0Max,
uint256 amount1Max,
address recipient
) external returns (uint256 tokenId) {
Define the sequence of operations needed for minting:
// Define the sequence of operations:
// 1. MINT_POSITION - Creates the position and calculates token requirements
// 2. SETTLE_PAIR - Provides the tokens needed
bytes memory actions = abi.encodePacked(
Actions.MINT_POSITION,
Actions.SETTLE_PAIR
);
Set up parameters for each action:
bytes[] memory params = new bytes[](2);
// Parameters for MINT_POSITION
params[0] = abi.encode(
poolKey, // Which pool to mint in
tickLower, // Position's lower price bound
tickUpper, // Position's upper price bound
liquidity, // Amount of liquidity to mint
amount0Max, // Maximum amount of token0 to use
amount1Max, // Maximum amount of token1 to use
recipient, // Who receives the NFT
"" // No hook data needed
);
// Parameters for SETTLE_PAIR - specify tokens to provide
params[1] = abi.encode(
poolKey.currency0, // First token to settle
poolKey.currency1 // Second token to settle
);
Finally, execute the minting operation:
// Execute the mint operation
positionManager.modifyLiquidities(
abi.encode(actions, params),
block.timestamp + 60 // 60 second deadline
);
}
Increasing Liquidity
After a position is created, you might want to add more liquidity to it. This operation requires understanding how fee accumulation works since fees are credited to your position during an increase.
Understanding the Operation
When increasing liquidity:
- The operation calculates the tokens needed based on current prices
- Any accumulated fees are automatically credited to your position
- In some cases, fee revenue might partially or fully cover the tokens needed
Choosing the Right Delta Resolution
Unlike minting where we always use SETTLE_PAIR, increasing liquidity has different delta-resolving options depending on your scenario:
- Standard Case: When you’re providing new tokens
bytes memory actions = abi.encodePacked(
Actions.INCREASE_LIQUIDITY,
Actions.SETTLE_PAIR
);
2. Fee Conversion: When converting accumulated fees to liquidity
bytes memory actions = abi.encodePacked(
Actions.INCREASE_LIQUIDITY,
Actions.CLOSE_CURRENCY, // For token0
Actions.CLOSE_CURRENCY // For token1
);
Implementation
Here’s how to implement a flexible increase liquidity function:
/// @notice Increases liquidity in an existing position
/// @param tokenId The ID of the position
/// @param liquidityIncrease Amount of liquidity to add
/// @param amount0Max Maximum amount of token0 to spend
/// @param amount1Max Maximum amount of token1 to spend
/// @param useFeesAsLiquidity Whether to use accumulated fees
function increaseLiquidity(
uint256 tokenId,
uint128 liquidityIncrease,
uint256 amount0Max,
uint256 amount1Max,
bool useFeesAsLiquidity
) external {
Choose the appropriate delta resolution based on whether we’re using fees:
// Define the sequence of operations:
// If using fees: Handle potential fee conversion
// If not: Standard liquidity addition
bytes memory actions;
if (useFeesAsLiquidity) {
actions = abi.encodePacked(
Actions.INCREASE_LIQUIDITY, // Add liquidity
Actions.CLOSE_CURRENCY, // Handle token0 (might need to pay or receive)
Actions.CLOSE_CURRENCY // Handle token1 (might need to pay or receive)
);
} else {
actions = abi.encodePacked(
Actions.INCREASE_LIQUIDITY, // Add liquidity
Actions.SETTLE_PAIR // Provide tokens
);
}
Prepare parameters based on our chosen strategy:
// Number of parameters depends on our strategy
bytes[] memory params = new bytes[](
useFeesAsLiquidity ? 3 : 2
);
// Parameters for INCREASE_LIQUIDITY
params[0] = abi.encode(
tokenId, // Position to increase
liquidityIncrease, // Amount to add
amount0Max, // Maximum token0 to spend
amount1Max, // Maximum token1 to spend
"" // No hook data needed
);
Set up delta resolution parameters:
if (useFeesAsLiquidity) {
// Using CLOSE_CURRENCY for automatic handling of each token
params[1] = abi.encode(currency0); // Handle token0
params[2] = abi.encode(currency1); // Handle token1
} else {
// Standard SETTLE_PAIR for providing tokens
params[1] = abi.encode(currency0, currency1);
}
Execute the operation:
// Execute the increase
positionManager.modifyLiquidities(
abi.encode(actions, params),
block.timestamp + 60 // 60 second deadline
);
}
Decreasing Liquidity
When you want to remove liquidity from a position, you’ll need to handle both the liquidity reduction and any accumulated fees. Let’s understand how to implement this effectively.
Understanding the Operation
Decreasing liquidity involves:
- Specifying how much liquidity to remove
- Setting minimum amounts to receive (slippage protection)
- Collecting both the removed liquidity and any accumulated fees
Delta Resolution Options
When decreasing liquidity, you’ll receive tokens, so we have different options for handling the positive deltas:
- Standard Case: Receive all tokens
bytes memory actions = abi.encodePacked(
Actions.DECREASE_LIQUIDITY,
Actions.TAKE_PAIR
);
2. Dust Handling: Ignore small amounts
bytes memory actions = abi.encodePacked(
Actions.DECREASE_LIQUIDITY,
Actions.CLEAR_OR_TAKE, // For token0
Actions.CLEAR_OR_TAKE // For token1
);
Implementation
When removing liquidity from a position, you’ll be able to receive tokens and any accumulated fees. Let’s break down the implementation step by step.
/// @notice Removes liquidity from a position
/// @param tokenId The ID of the position
/// @param liquidityDecrease Amount of liquidity to remove
/// @param amount0Min Minimum amount of token0 to receive
/// @param amount1Min Minimum amount of token1 to receive
/// @param ignoreDust Whether to ignore small amounts
/// @param recipient Address to receive the tokens
function decreaseLiquidity(
uint256 tokenId,
uint128 liquidityDecrease,
uint256 amount0Min,
uint256 amount1Min,
bool ignoreDust,
address recipient
) external {
Choose the appropriate delta resolution based on dust handling preference:
// Define the sequence of operations:
// If ignoring dust: Use CLEAR_OR_TAKE for each token
// If not: Use TAKE_PAIR to receive all tokens
bytes memory actions;
if (ignoreDust) {
actions = abi.encodePacked(
Actions.DECREASE_LIQUIDITY, // Remove liquidity
Actions.CLEAR_OR_TAKE, // Handle token0 with dust threshold
Actions.CLEAR_OR_TAKE // Handle token1 with dust threshold
);
} else {
actions = abi.encodePacked(
Actions.DECREASE_LIQUIDITY, // Remove liquidity
Actions.TAKE_PAIR // Receive both tokens
);
}
Prepare the parameters array:
// Number of parameters depends on our strategy
bytes[] memory params = new bytes[](
ignoreDust ? 3 : 2
);
// Parameters for DECREASE_LIQUIDITY
params[0] = abi.encode(
tokenId, // Position to decrease
liquidityDecrease, // Amount to remove
amount0Min, // Minimum token0 to receive
amount1Min, // Minimum token1 to receive
"" // No hook data needed
);
Set up delta resolution parameters:
if (ignoreDust) {
// Using CLEAR_OR_TAKE with dust thresholds
uint256 dustThreshold = 1000; // Example threshold
params[1] = abi.encode(currency0, dustThreshold);
params[2] = abi.encode(currency1, dustThreshold);
} else {
// Standard TAKE_PAIR for receiving tokens
params[1] = abi.encode(
currency0,
currency1,
recipient
);
}
Execute the operation:
// Execute the decrease
positionManager.modifyLiquidities(
abi.encode(actions, params),
block.timestamp + 60 // 60 second deadline
);
}
Collecting Fees
In v4’s Position Manager, there isn’t a dedicated COLLECT command. Instead, fees are collected by using DECREASE_LIQUIDITY with zero liquidity. This pattern leverages the fact that fees are automatically credited during liquidity operations.
Understanding Fee Collection
When collecting fees, you need to:
- Perform a DECREASE_LIQUIDITY operation with zero liquidity
- Handle the positive deltas (the fees you’re collecting)
- Specify where the fees should go
Implementation
/// @notice Collects accumulated fees from a position
/// @param tokenId The ID of the position to collect fees from
/// @param recipient Address that will receive the fees
function collectFees(
uint256 tokenId,
address recipient
) external {
Set up our operation sequence based on dust handling preference:
// Define sequence of operations:
// 1. DECREASE_LIQUIDITY with zero amount to collect fees
// 2. Either TAKE_PAIR or CLEAR_OR_TAKE for receiving tokens
bytes memory actions;
if (ignoreDust) {
actions = abi.encodePacked(
Actions.DECREASE_LIQUIDITY, // Zero liquidity decrease
Actions.CLEAR_OR_TAKE, // Handle token0 fees
Actions.CLEAR_OR_TAKE // Handle token1 fees
);
} else {
actions = abi.encodePacked(
Actions.DECREASE_LIQUIDITY, // Zero liquidity decrease
Actions.TAKE_PAIR // Take all fees
);
}
Prepare the parameters array:
// Set up parameters array
bytes[] memory params = new bytes[](
ignoreDust ? 3 : 2
);
// Parameters for DECREASE_LIQUIDITY
// All zeros since we're only collecting fees
params[0] = abi.encode(
tokenId, // Position to collect from
0, // No liquidity change
0, // No minimum for token0 (fees can't be manipulated)
0, // No minimum for token1
"" // No hook data needed
);
When collecting fees, we use a zero-liquidity decrease operation - this means we're not actually removing any liquidity from the position, we're just collecting accumulated fees.
And note that we set minimums to 0 for fee collection because fees cannot be manipulated in a front-run attack. This is different from other liquidity operations where setting appropriate minimum amounts is crucial for slippage protection.
Set up the fee collection parameters:
if (ignoreDust) {
// Using CLEAR_OR_TAKE with dust threshold
params[1] = abi.encode(currency0, dustThreshold);
params[2] = abi.encode(currency1, dustThreshold);
} else {
// Standard TAKE_PAIR for receiving all fees
params[1] = abi.encode(
currency0,
currency1,
recipient
);
}
Execute the fee collection:
// Execute fee collection
positionManager.modifyLiquidities(
abi.encode(actions, params),
block.timestamp + 60 // 60 second deadline
);
}
Burning Positions
When you want to completely exit a position, burning is more efficient than removing liquidity and collecting fees separately. The BURN_POSITION command handles everything in a single operation.
Understanding Position Burning
A burn operation:
- Removes all remaining liquidity from the pool
- Collects any accumulated fees
- Burns the position NFT
- Settles all tokens to a specified recipient
Implementation
Let’s implement a position burning function step by step:
/// @notice Burns a position and receives all tokens
/// @param tokenId The ID of the position to burn
/// @param recipient Address that will receive the tokens
/// @param amount0Min Minimum amount of token0 to receive
/// @param amount1Min Minimum amount of token1 to receive
function burnPosition(
uint256 tokenId,
address recipient,
uint256 amount0Min,
uint256 amount1Min
) external {
// Define the sequence of operations:
// 1. BURN_POSITION - Removes the position and creates positive deltas
// 2. TAKE_PAIR - Sends all tokens to the recipient
bytes memory actions = abi.encodePacked(
Actions.BURN_POSITION,
Actions.TAKE_PAIR
);
The burn operation requires two sets of parameters:
bytes[] memory params = new bytes[](2);
// Parameters for BURN_POSITION
params[0] = abi.encode(
tokenId, // Position to burn
amount0Min, // Minimum token0 to receive
amount1Min, // Minimum token1 to receive
"" // No hook data needed
);
// Parameters for TAKE_PAIR - where tokens will go
params[1] = abi.encode(
currency0, // First token
currency1, // Second token
recipient // Who receives the tokens
);
Finally, execute the operation:
positionManager.modifyLiquidities(
abi.encode(actions, params),
block.timestamp + 60
);
}
Batch-Operating Liquidity
The Position Manager’s command-based design enables you to perform multiple liquidity operations in a single transaction. This is particularly valuable when managing multiple positions or performing complex liquidity management strategies, such as taking tokens from one position to increase liquidity of another position.
Benefits of Batch Operations
When managing liquidity across multiple positions, batching operations provides significant advantages:
- Reduced gas costs by combining token settlements
- Atomic execution of related operations
- Simplified token handling through combined delta resolution
Implementation Guide
Let’s implement a common scenario: rebalancing liquidity by creating a new position while closing an old one and collecting its fees. We’ll go through it step by step.
First, let’s define our parameters:
/// @notice Rebalances liquidity by creating a new position and closing an old one
/// @param newPositionParams Parameters for the new position
/// @param oldPositionId Position to close and collect fees from
/// @param recipient Address to receive tokens from closed position
struct NewPositionParams {
PoolKey poolKey;
int24 tickLower;
int24 tickUpper;
uint128 liquidity;
uint256 amount0Max;
uint256 amount1Max;
}
In this example, we will rebalance liquidity by closing the old position and opening a new position. For the sake of example, let's assume the user will have to transfer additional tokens. Note that capital from the first position is automatically used towards the second position through flash accounting.
function rebalanceLiquidity(
NewPositionParams calldata newPositionParams,
uint256 oldPositionId,
address recipient
) external {
// Group liquidity operations first, then delta resolutions
bytes memory actions = abi.encodePacked(
Actions.BURN_POSITION, // Remove old position
Actions.MINT_POSITION, // Create new position
Actions.SETTLE_PAIR // Provide tokens for new position
);
Notice how we order our operations: liquidity operations first (MINT and BURN), followed by delta resolutions (SETTLE and TAKE). This ordering is crucial for gas efficiency.
Next, let’s prepare our parameters array:
// We need one parameter set for each action
bytes[] memory params = new bytes[](3);
Now, let’s encode parameters for the old position:
// Parameters for BURN_POSITION
params[0] = abi.encode(
oldPositionId,
0, // No minimum for token0 (consider adding slippage protection)
0, // No minimum for token1
"" // No hook data
);
Then for minting the new position:
// Parameters for MINT_POSITION
params[1] = abi.encode(
newPositionParams.poolKey,
newPositionParams.tickLower,
newPositionParams.tickUpper,
newPositionParams.liquidity,
newPositionParams.amount0Max,
newPositionParams.amount1Max,
address(this), // New position owner
"" // No hook data
);
Next, we handle token settlements. First for the new position:
// Parameters for SETTLE_PAIR (providing tokens for new position)
params[2] = abi.encode(
newPositionParams.poolKey.currency0,
newPositionParams.poolKey.currency1
);
With everything prepared, we can execute our batch operation:
// Execute all operations atomically
positionManager.modifyLiquidities(
abi.encode(actions, params),
block.timestamp + 60
);
}
Delta-Resolving Operations
While we’ve seen basic delta resolution using SETTLE_PAIR and TAKE_PAIR in previous sections, v4’s Position Manager provides additional operations for handling more complex scenarios. Let’s understand when and how to use each one.
CLOSE_CURRENCY: Handling Unknown Deltas
When you can’t predict whether you’ll need to pay or receive tokens, CLOSE_CURRENCY automatically handles either case.
// Example scenario: Converting fees to liquidity
bytes memory actions = abi.encodePacked(
Actions.INCREASE_LIQUIDITY,
Actions.CLOSE_CURRENCY // Will automatically settle or take based on final delta
);
bytes[] memory params = new bytes[](2);
// Parameters for INCREASE_LIQUIDITY
params[0] = abi.encode(
tokenId,
liquidityIncrease,
amount0Max,
amount1Max,
""
);
// CLOSE_CURRENCY only needs the currency
params[1] = abi.encode(currency0);
This is particularly useful when:
- Converting fees to liquidity (fees might fully cover the increase)
- Complex operations where final deltas are uncertain
- Reducing code complexity by letting the protocol handle the direction
CLEAR_OR_TAKE: Optimizing for Dust
Sometimes receiving small token amounts costs more in gas than they’re worth. CLEAR_OR_TAKE lets you specify a threshold:
// Parameters for CLEAR_OR_TAKE
params[0] = abi.encode(
currency, // The token to handle
threshold // Minimum amount worth taking
);
If the amount to receive is:
- Above threshold: Tokens are taken (like TAKE_PAIR)
- Below threshold: Amount is forfeited, saving gas
This is valuable for:
- Fee collection with minimum amounts
- Operations where dust amounts can be ignored
- Gas optimization in production systems
SWEEP: Handling Excess Payments
SWEEP helps recover any excess tokens sent to the PoolManager:
bytes memory actions = abi.encodePacked(
Actions.YOUR_MAIN_OPERATION,
Actions.SWEEP // Add at the end to collect any excess
);
// Parameters for SWEEP
params[1] = abi.encode(
currency, // Token to sweep
recipient // Where to send excess tokens
);
Use SWEEP when:
- Dealing with native ETH operations
- Conservative token approvals might result in excess
- Need to ensure all tokens are properly accounted for
Understanding modifyLiquiditiesWithoutUnlock
This function follows the same encoding and command patterns as modifyLiquidity
, but serves a specific purpose: it's used when the PoolManager is already unlocked. This is particularly useful in certain scenarios:
- When called from hooks that are already executing within the PoolManager's lock/unlock cycle
- For operations like automatic fee compounding, where a hook might want to reinvest fees for users
For example, a hook that automatically compounds fees for users would use this function because the hook is already executing within the PoolManager's unlock context, there's no need for another unlock cycle and it's more efficient than calling the standard modifyLiquidity
.