5/5
## Managing Liquidity with the PositionManager Contract The `PositionManager` is the primary smart contract for user interactions involving liquidity, such as minting a new position, adding liquidity to an existing one, or removing it. It serves as a router and state manager, interacting with the core `PoolManager` contract to execute these changes. The system is designed around an "action-based" model. Instead of calling separate functions for each task, users encode a sequence of commands and their parameters into a single `bytes` payload. This payload is then passed in a single transaction to the `PositionManager`, which decodes and executes the actions in order. This approach is highly efficient and flexible, enabling complex, multi-step operations atomically. ### Core Functions for Liquidity Management Two external functions in `PositionManager.sol` serve as the entry points for all liquidity modifications. While they appear similar, their use cases are distinct and depend on the contract's state. 1. **`modifyLiquidities(bytes calldata unlockData, uint256 deadline)`**: This is the main function used for most external calls. It initiates a critical security pattern by first calling the `unlock` function on the `PoolManager`, which then executes the user's requested actions via a callback mechanism. 2. **`modifyLiquiditiesWithoutUnlock(bytes calldata actions, bytes[] calldata params)`**: This is a more specialized function designed to be called when the `PoolManager` has already been unlocked by another contract, such as a hook. Calling `unlock` on a contract that is already unlocked would cause the transaction to revert, so this function bypasses that step and proceeds directly to action execution. The key distinction is simple: `modifyLiquidities` **initiates the unlock-callback pattern**, while `modifyLiquiditiesWithoutUnlock` **operates within an existing unlocked state**. ```solidity // In PositionManager.sol /// @inheritdoc IPositionManager function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable isNotLocked checkDeadline(deadline) { _executeActions(unlockData); } /// @inheritdoc IPositionManager function modifyLiquiditiesWithoutUnlock(bytes calldata actions, bytes[] calldata params) external payable isNotLocked { _executeActionsWithoutUnlock(actions, params); } ``` ### The Unlock-Callback Execution Flow To ensure that all state changes occur securely and in the correct context, the system employs a robust unlock-callback pattern. This flow guarantees that liquidity modifications can only happen after the `PoolManager` has been properly "unlocked," preventing reentrancy and other vulnerabilities. **Step 1: Encode and Initiate** A user bundles their desired commands (e.g., `MINT_POSITION` followed by `SETTLE`) and the necessary parameters into a single `bytes` variable. This data is passed as the `unlockData` argument to the `modifyLiquidities` function. **Step 2: Trigger the Unlock** The `modifyLiquidities` function calls the internal `_executeActions` function, which is inherited from `BaseActionsRouter.sol`. This function has a single responsibility: to call the `unlock` function on the `PoolManager`, passing the encoded `unlockData`. ```solidity // In BaseActionsRouter.sol /// @notice internal function that triggers the execution of a set of actions /// @dev inheriting contracts should call this function to trigger execution function _executeActions(bytes calldata unlockData) internal { poolManager.unlock(unlockData); } ``` **Step 3: The `PoolManager` Callback** After performing its state checks, the `PoolManager`'s `unlock` function calls back to the `PositionManager` (the `msg.sender`) via the `unlockCallback` function. This callback design ensures that the subsequent logic runs only within the context of an authenticated, unlocked pool. The original `unlockData` is passed back to the `PositionManager` in this call. **Step 4: Decode Actions and Parameters** Inside `unlockCallback` (also in `BaseActionsRouter.sol`), the `unlockData` is decoded into two distinct arrays: an `actions` array containing command codes (`uint8`) and a `params` array containing the corresponding encoded parameters for each action. The function then calls `_executeActionsWithoutUnlock` to handle the logic. ```solidity // In BaseActionsRouter.sol /// @notice function that is called by the PoolManager through the SafeCallback function unlockCallback(bytes calldata data) internal override returns (bytes memory) { (bytes calldata actions, bytes[] calldata params) = data.decodeActionsRouter(); _executeActionsWithoutUnlock(actions, params); return ""; } ``` **Step 5: Loop and Execute Actions** The `_executeActionsWithoutUnlock` function iterates through the decoded `actions` array. In each iteration, it calls an internal router, `_handleAction`, passing the specific action code and its associated parameters for processing. ```solidity // In BaseActionsRouter.sol function _executeActionsWithoutUnlock(bytes calldata actions, bytes[] calldata params) internal { uint256 numActions = actions.length; if (numActions != params.length) revert InputLengthMismatch(); for (uint256 actionIndex = 0; actionIndex < numActions; actionIndex++) { uint256 action = uint8(actions[actionIndex]); _handleAction(action, params[actionIndex]); } } ``` **Step 6: Route to Final Logic** The `_handleAction` function, implemented in `PositionManager.sol`, acts as the final routing mechanism. It uses a series of `if/else if` statements to match the action code to its corresponding internal function. It decodes the `params` into strongly-typed arguments and calls the final implementation logic, such as `_mint`, `_increase`, or `_settle`. ```solidity // In PositionManager.sol function _handleAction(uint256 action, bytes calldata params) internal virtual override { // ... if (action == Actions.INCREASE_LIQUIDITY) { (uint256 tokenId, uint256 liquidity, uint128 amount0Max, uint128 amount1Max, bytes calldata hookData) = params.decodeModifyLiquidityParams(); _increase(tokenId, liquidity, amount0Max, amount1Max, hookData); return; } else if (action == Actions.MINT_POSITION) { ( PoolKey calldata poolKey, int24 tickLower, int24 tickUpper, uint256 liquidity, uint128 amount0Max, uint128 amount1Max, address owner, bytes calldata hookData ) = params.decodeMintParams(); _mint(poolKey, tickLower, tickUpper, liquidity, amount0Max, amount1Max, owner, hookData); return; } // ... } ``` ### The Two-Step Process: Modifying State and Settling Debt A crucial concept to understand is that modifying liquidity is a two-part process within a single transaction. 1. **Modify Pool State and Accrue Debt**: When an action like `MINT_POSITION` or `INCREASE_LIQUIDITY` is executed, the `_mint` or `_increase` function calls `poolManager.modifyLiquidity`. This updates the pool's internal accounting but does not immediately pull tokens from the user. Instead, the `PoolManager` returns a negative `BalanceDelta` to the `PositionManager`, representing a "debt" of tokens that the `PositionManager` now owes to the pool. 2. **Settle the Debt**: The initial `_mint` or `_increase` functions do not handle this debt. To complete the interaction, the user must include a `SETTLE` or `SETTLE_PAIR` action in their initial encoded `unlockData`. When the `_handleAction` router gets to this action, it calls the `_settle` function. This function is responsible for pulling the required tokens from the user and transferring them to the `PoolManager`, thus clearing the negative balance and settling the debt. For example, a complete flow to mint a new position would involve encoding two actions: 1. `Actions.MINT_POSITION`: This creates the NFT position and tells the pool to update its liquidity, creating a token debt for the `PositionManager`. 2. `Actions.SETTLE`: This pulls tokens from the user to the `PositionManager`, which then pays the debt to the `PoolManager`. Both actions must be included to ensure the transaction successfully completes. ### What's Next? This lesson covered the high-level architecture for managing liquidity. In the next part, we will dive deeper into the practical implementation, answering key questions such as: * What is the difference between the `SETTLE` and `SETTLE_PAIR` actions? * What is the exact sequence of actions and parameters required to correctly mint a new position or modify existing liquidity from start to finish?
The PositionManager is the primary smart contract for user interactions involving liquidity, such as minting a new position, adding liquidity to an existing one, or removing it. It serves as a router and state manager, interacting with the core PoolManager contract to execute these changes.
The system is designed around an "action-based" model. Instead of calling separate functions for each task, users encode a sequence of commands and their parameters into a single bytes payload. This payload is then passed in a single transaction to the PositionManager, which decodes and executes the actions in order. This approach is highly efficient and flexible, enabling complex, multi-step operations atomically.
Two external functions in PositionManager.sol serve as the entry points for all liquidity modifications. While they appear similar, their use cases are distinct and depend on the contract's state.
modifyLiquidities(bytes calldata unlockData, uint256 deadline): This is the main function used for most external calls. It initiates a critical security pattern by first calling the unlock function on the PoolManager, which then executes the user's requested actions via a callback mechanism.
modifyLiquiditiesWithoutUnlock(bytes calldata actions, bytes[] calldata params): This is a more specialized function designed to be called when the PoolManager has already been unlocked by another contract, such as a hook. Calling unlock on a contract that is already unlocked would cause the transaction to revert, so this function bypasses that step and proceeds directly to action execution.
The key distinction is simple: modifyLiquidities initiates the unlock-callback pattern, while modifyLiquiditiesWithoutUnlock operates within an existing unlocked state.
To ensure that all state changes occur securely and in the correct context, the system employs a robust unlock-callback pattern. This flow guarantees that liquidity modifications can only happen after the PoolManager has been properly "unlocked," preventing reentrancy and other vulnerabilities.
Step 1: Encode and Initiate
A user bundles their desired commands (e.g., MINT_POSITION followed by SETTLE) and the necessary parameters into a single bytes variable. This data is passed as the unlockData argument to the modifyLiquidities function.
Step 2: Trigger the Unlock
The modifyLiquidities function calls the internal _executeActions function, which is inherited from BaseActionsRouter.sol. This function has a single responsibility: to call the unlock function on the PoolManager, passing the encoded unlockData.
Step 3: The PoolManager Callback
After performing its state checks, the PoolManager's unlock function calls back to the PositionManager (the msg.sender) via the unlockCallback function. This callback design ensures that the subsequent logic runs only within the context of an authenticated, unlocked pool. The original unlockData is passed back to the PositionManager in this call.
Step 4: Decode Actions and Parameters
Inside unlockCallback (also in BaseActionsRouter.sol), the unlockData is decoded into two distinct arrays: an actions array containing command codes (uint8) and a params array containing the corresponding encoded parameters for each action. The function then calls _executeActionsWithoutUnlock to handle the logic.
Step 5: Loop and Execute Actions
The _executeActionsWithoutUnlock function iterates through the decoded actions array. In each iteration, it calls an internal router, _handleAction, passing the specific action code and its associated parameters for processing.
Step 6: Route to Final Logic
The _handleAction function, implemented in PositionManager.sol, acts as the final routing mechanism. It uses a series of if/else if statements to match the action code to its corresponding internal function. It decodes the params into strongly-typed arguments and calls the final implementation logic, such as _mint, _increase, or _settle.
A crucial concept to understand is that modifying liquidity is a two-part process within a single transaction.
Modify Pool State and Accrue Debt: When an action like MINT_POSITION or INCREASE_LIQUIDITY is executed, the _mint or _increase function calls poolManager.modifyLiquidity. This updates the pool's internal accounting but does not immediately pull tokens from the user. Instead, the PoolManager returns a negative BalanceDelta to the PositionManager, representing a "debt" of tokens that the PositionManager now owes to the pool.
Settle the Debt: The initial _mint or _increase functions do not handle this debt. To complete the interaction, the user must include a SETTLE or SETTLE_PAIR action in their initial encoded unlockData. When the _handleAction router gets to this action, it calls the _settle function. This function is responsible for pulling the required tokens from the user and transferring them to the PoolManager, thus clearing the negative balance and settling the debt.
For example, a complete flow to mint a new position would involve encoding two actions:
Actions.MINT_POSITION: This creates the NFT position and tells the pool to update its liquidity, creating a token debt for the PositionManager.
Actions.SETTLE: This pulls tokens from the user to the PositionManager, which then pays the debt to the PoolManager.
Both actions must be included to ensure the transaction successfully completes.
This lesson covered the high-level architecture for managing liquidity. In the next part, we will dive deeper into the practical implementation, answering key questions such as:
What is the difference between the SETTLE and SETTLE_PAIR actions?
What is the exact sequence of actions and parameters required to correctly mint a new position or modify existing liquidity from start to finish?
An operational deep-dive to Managing Liquidity with the PositionManager Contract - Explore the core unlock-callback pattern that powers all liquidity modifications and learn why actions like `MINT_POSITION` must be paired with `SETTLE` to resolve token debts.
Previous lesson
Previous
Next lesson
Next
Note:
Last updated on November 3, 2025
Course Overview
About the course
Difference between Uniswap v3 and v4
Uniswap v4 PoolManager
Uniswap v4 Hooks
Uniswap v4 PositionManager
Uniswap v4 Universal Router
Uniswap v4 Singleton architecture and flash accounting
Uniswap v4 operations and lifecycle
Uniswap v4 multihopping and quoting
How to build a Uniswap v4 swap router
How to build a smart contract a liquidation bot executes
Last updated on November 6, 2025
Duration: 6min
Duration: 8min
Duration: 39min
Duration: 35min
Duration: 34min
Duration: 31min
Course Overview
About the course
Difference between Uniswap v3 and v4
Uniswap v4 PoolManager
Uniswap v4 Hooks
Uniswap v4 PositionManager
Uniswap v4 Universal Router
Uniswap v4 Singleton architecture and flash accounting
Uniswap v4 operations and lifecycle
Uniswap v4 multihopping and quoting
How to build a Uniswap v4 swap router
How to build a smart contract a liquidation bot executes
Last updated on November 6, 2025