- name
- uniswap-v4-expert
- description
- Use when building on, integrating with, or analyzing Uniswap V4. Covers PoolManager singleton architecture, flash accounting via EIP-1153 transient storage, hook lifecycle, PoolKey structure, Currency type, dynamic fees, custom accounting, native ETH support, PositionManager (ERC-721 positions), and production deployment addresses.
# Uniswap V4 Expert
## Architecture Overview
Uniswap V4 replaces V3's factory-per-pool model with a **singleton PoolManager** — every pool lives inside a single contract. This eliminates redundant bytecode deployments and enables multi-hop swaps to settle only net token transfers. All state-changing operations use **flash accounting** via EIP-1153 transient storage: callers accumulate deltas during an `unlock()` callback and must zero out all balances before the callback returns.
### Singleton Design
```
┌────────────────────────────────────────────┐
│ PoolManager │
│ ┌──────────┐ ┌───────────┐ ┌──────────┐ │
│ │ Pool A │ │ Pool B │ │ Pool C │ │
│ │ ETH/USDC │ │ WBTC/USDC │ │ ETH/DAI │ │
│ └──────────┘ └───────────┘ └──────────┘ │
│ │
│ Transient Storage (EIP-1153) │
│ ┌──────────────────────────────────────┐ │
│ │ currency → delta mapping (per lock) │ │
│ └──────────────────────────────────────┘ │
└────────────────────────────────────────────┘
```
### Flash Accounting Flow
1. Caller invokes `poolManager.unlock(data)`
2. PoolManager calls `IUnlockCallback(msg.sender).unlockCallback(data)`
3. Inside the callback, caller executes operations (swap, modifyLiquidity, donate)
4. Each operation updates transient storage deltas — no token transfers yet
5. Caller resolves deltas via `settle()` (pay tokens in) and `take()` (withdraw tokens out)
6. On return from `unlockCallback`, PoolManager verifies all currency deltas are zero
7. If any delta is nonzero, the transaction reverts with `CurrencyNotSettled()`
This means multi-hop swaps (e.g., A→B→C) only require net token movements for A and C, saving gas on intermediate transfers.
### Functions Callable Outside unlock()
Only two functions do NOT require the unlock context:
- `initialize()` — creates a new pool (no balance changes)
- `updateDynamicLPFee()` — called by hook contracts to set the current dynamic fee
Everything else (`swap`, `modifyLiquidity`, `donate`, `take`, `settle`, `mint`, `burn`, `sync`, `clear`) requires being inside an active `unlockCallback`.
## Core Types
### PoolKey
The unique identifier for a pool. Defined in `v4-core/src/types/PoolKey.sol`:
```solidity
import {Currency} from "v4-core/src/types/Currency.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
struct PoolKey {
/// @notice The lower currency of the pool, sorted numerically
Currency currency0;
/// @notice The higher currency of the pool, sorted numerically
Currency currency1;
/// @notice The pool LP fee, capped at 1_000_000. If the highest bit is 1, the pool has a dynamic fee and must be exactly equal to 0x800000
uint24 fee;
/// @notice Ticks that involve positions must be a multiple of tick spacing
int24 tickSpacing;
/// @notice The hooks of the pool
IHooks hooks;
}
```
**Sorting invariant**: `currency0 < currency1` is enforced. The PoolManager reverts with `CurrenciesOutOfOrderOrEqual` if violated. When constructing a PoolKey, always sort currencies by address value.
### PoolId
A `bytes32` hash of the PoolKey, used as the storage key for pool state. Defined in `v4-core/src/types/PoolId.sol`:
```solidity
type PoolId is bytes32;
library PoolIdLibrary {
function toId(PoolKey memory poolKey) internal pure returns (PoolId poolId) {
assembly ("memory-safe") {
// 0xa0 = 5 slots × 32 bytes (total size of PoolKey struct)
poolId := keccak256(poolKey, 0xa0)
}
}
}
```
Usage: `using PoolIdLibrary for PoolKey;` then `key.toId()`.
### Currency
An address wrapper where `address(0)` represents native ETH. Defined in `v4-core/src/types/Currency.sol`:
```solidity
type Currency is address;
library CurrencyLibrary {
Currency public constant ADDRESS_ZERO = Currency.wrap(address(0));
function isAddressZero(Currency currency) internal pure returns (bool) {
return Currency.unwrap(currency) == Currency.unwrap(ADDRESS_ZERO);
}
function transfer(Currency currency, address to, uint256 amount) internal { /* handles ETH vs ERC-20 */ }
function balanceOfSelf(Currency currency) internal view returns (uint256) { /* handles ETH vs ERC-20 */ }
}
```
Native ETH pools use `Currency.wrap(address(0))` as one of the currencies. No WETH wrapping required.
### BalanceDelta
Two `int128` values packed into a single `int256`. Upper 128 bits = amount0, lower 128 bits = amount1. Defined in `v4-core/src/types/BalanceDelta.sol`:
```solidity
type BalanceDelta is int256;
library BalanceDeltaLibrary {
BalanceDelta public constant ZERO_DELTA = BalanceDelta.wrap(0);
function amount0(BalanceDelta balanceDelta) internal pure returns (int128 _amount0) {
assembly ("memory-safe") {
_amount0 := sar(128, balanceDelta)
}
}
function amount1(BalanceDelta balanceDelta) internal pure returns (int128 _amount1) {
assembly ("memory-safe") {
_amount1 := signextend(15, balanceDelta)
}
}
}
```
Delta semantics from the caller's perspective:
- **Negative** delta = caller owes tokens to PoolManager (must `settle()`)
- **Positive** delta = PoolManager owes tokens to caller (can `take()`)
### BeforeSwapDelta
Return type of the `beforeSwap` hook. Upper 128 bits = delta in **specified** tokens, lower 128 bits = delta in **unspecified** tokens. Defined in `v4-core/src/types/BeforeSwapDelta.sol`:
```solidity
type BeforeSwapDelta is int256;
function toBeforeSwapDelta(int128 deltaSpecified, int128 deltaUnspecified)
pure
returns (BeforeSwapDelta beforeSwapDelta)
{
assembly ("memory-safe") {
beforeSwapDelta := or(shl(128, deltaSpecified), and(sub(shl(128, 1), 1), deltaUnspecified))
}
}
library BeforeSwapDeltaLibrary {
BeforeSwapDelta public constant ZERO_DELTA = BeforeSwapDelta.wrap(0);
function getSpecifiedDelta(BeforeSwapDelta delta) internal pure returns (int128);
function getUnspecifiedDelta(BeforeSwapDelta delta) internal pure returns (int128);
}
```
## PoolManager Interface
Full interface from `v4-core/src/interfaces/IPoolManager.sol`. The PoolManager inherits `IProtocolFees`, `IERC6909Claims`, `IExtsload`, and `IExttload`.
### initialize
```solidity
function initialize(PoolKey memory key, uint160 sqrtPriceX96) external returns (int24 tick);
```
Creates a new pool. Does NOT require the unlock context. Reverts if `currency0 >= currency1`, if `tickSpacing` is zero or exceeds `type(int16).max`, or if the pool already exists. Emits `Initialize` event.
### unlock
```solidity
function unlock(bytes calldata data) external returns (bytes memory);
```
Entry point for all delta-accounting operations. Calls `IUnlockCallback(msg.sender).unlockCallback(data)`. After the callback returns, asserts all currency deltas are zero.
### swap
```solidity
function swap(PoolKey memory key, SwapParams memory params, bytes calldata hookData)
external
returns (BalanceDelta swapDelta);
```
Executes a swap. Only callable inside `unlockCallback`. Invokes `beforeSwap` and `afterSwap` hooks if the pool's hook contract has those permissions.
### modifyLiquidity
```solidity
function modifyLiquidity(PoolKey memory key, ModifyLiquidityParams memory params, bytes calldata hookData)
external
returns (BalanceDelta callerDelta, BalanceDelta feesAccrued);
```
Adds or removes liquidity. Returns both the principal delta and fees accrued. A zero `liquidityDelta` "pokes" the position to collect fees without changing liquidity.
### donate
```solidity
function donate(PoolKey memory key, uint256 amount0, uint256 amount1, bytes calldata hookData)
external
returns (BalanceDelta);
```
Distributes tokens to in-range liquidity providers. Useful for hook-driven fee distribution or protocol reward injection.
### Settlement Functions
```solidity
function settle() external payable returns (uint256 paid);
function settleFor(address recipient) external payable returns (uint256 paid);
function sync(Currency currency) external;
function take(Currency currency, address to, uint256 amount) external;
function clear(Currency currency, uint256 amount) external;
```
**settle()**: Pays what the caller owes. For ERC-20 tokens, the caller must first call `sync(currency)`, transfer tokens to the PoolManager, then call `settle()`. For native ETH, send value directly with `settle{value: amount}()`. Returns the amount credited.
**sync(currency)**: Snapshots the PoolManager's current ERC-20 balance into transient storage. MUST be called before transferring ERC-20 tokens for settlement. Not needed for native ETH.
**take(currency, to, amount)**: Withdraws tokens the PoolManager owes to the caller. Reverts if the caller's delta for that currency is insufficient.
**clear(currency, amount)**: Zeros out a positive delta WITHOUT transferring tokens. The tokens are permanently locked in the PoolManager. Use only for dust amounts.
### ERC-6909 Claims
```solidity
function mint(address to, uint256 id, uint256 amount) external;
function burn(address from, uint256 id, uint256 amount) external;
```
Converts currency deltas into ERC-6909 claim tokens (and vice versa). The `id` is the currency address cast to `uint256`. Useful for holding balances inside the PoolManager across transactions without actual token transfers.
## Parameter Structs
### SwapParams
Defined in `v4-core/src/types/PoolOperation.sol`:
```solidity
struct SwapParams {
/// Whether to swap token0 for token1 or vice versa
bool zeroForOne;
/// The desired input amount if negative (exactIn), or the desired output amount if positive (exactOut)
int256 amountSpecified;
/// The sqrt price at which, if reached, the swap will stop executing
uint160 sqrtPriceLimitX96;
}
```
**CRITICAL**: `amountSpecified` sign convention:
- **Negative** = exact input (caller specifies how much to spend)
- **Positive** = exact output (caller specifies how much to receive)
Price limits:
- `zeroForOne = true`: set `sqrtPriceLimitX96` to a value **less than** the current price (price decreases)
- `zeroForOne = false`: set `sqrtPriceLimitX96` to a value **greater than** the current price (price increases)
- Use `TickMath.MIN_SQRT_PRICE + 1` or `TickMath.MAX_SQRT_PRICE - 1` for unlimited slippage
### ModifyLiquidityParams
Defined in `v4-core/src/types/PoolOperation.sol`:
```solidity
struct ModifyLiquidityParams {
int24 tickLower;
int24 tickUpper;
int256 liquidityDelta;
bytes32 salt;
}
```
- `liquidityDelta > 0`: add liquidity
- `liquidityDelta < 0`: remove liquidity
- `liquidityDelta == 0`: poke (collect accrued fees only)
- `salt`: differentiates multiple positions at the same tick range from the same address
## Fee System
### Static Fees
Set at pool creation via `PoolKey.fee`. Denominated in **hundredths of a basis point** (1/100th of 1/10000th):
| PoolKey.fee | Effective Fee |
|-------------|--------------|
| 100 | 0.01% |
| 500 | 0.05% |
| 3000 | 0.30% |
| 10000 | 1.00% |
| 1000000 | 100% (MAX) |
### Dynamic Fees
From `v4-core/src/libraries/LPFeeLibrary.sol`:
```solidity
library LPFeeLibrary {
uint24 public constant DYNAMIC_FEE_FLAG = 0x800000;
uint24 public constant OVERRIDE_FEE_FLAG = 0x400000;
uint24 public constant REMOVE_OVERRIDE_MASK = 0xBFFFFF;
uint24 public constant MAX_LP_FEE = 1000000; // 100%
}
```
To create a dynamic fee pool, set `PoolKey.fee = LPFeeLibrary.DYNAMIC_FEE_FLAG` (exactly `0x800000`).
Two mechanisms for dynamic fee updates:
1. **Persistent update**: Hook calls `poolManager.updateDynamicLPFee(key, newFee)` (e.g., in `afterInitialize` or periodically). This sets the stored fee for subsequent swaps.
2. **Per-swap override**: `beforeSwap` returns a fee with the override flag set in the third return value (`uint24`). The returned fee is `desiredFee | LPFeeLibrary.OVERRIDE_FEE_FLAG`. This overrides the stored fee for that single swap only.
```solidity
function beforeSwap(address, PoolKey calldata, IPoolManager.SwapParams calldata, bytes calldata)
external
override
returns (bytes4, BeforeSwapDelta, uint24)
{
uint24 dynamicFee = _computeFee();
return (this.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, dynamicFee | LPFeeLibrary.OVERRIDE_FEE_FLAG);
}
```
### Protocol Fees
Set by the PoolManager owner via `IProtocolFees.setProtocolFee(PoolKey, uint24)`. Protocol fees are taken as a percentage of LP fees. The protocol fee is a `uint24` where the upper 12 bits are the fee for token0 and the lower 12 bits are the fee for token1.
## PositionManager (Periphery)
The `PositionManager` is the canonical periphery contract for managing liquidity positions as ERC-721 NFTs. Source: `v4-periphery/src/PositionManager.sol`.
```solidity
contract PositionManager is
IPositionManager,
ERC721Permit_v4, // ERC-721 + EIP-4494 permit
PoolInitializer_v4,
Multicall_v4,
DeltaResolver,
ReentrancyLock,
BaseActionsRouter, // action dispatch via unlock
Notifier, // subscriber/notification pattern
Permit2Forwarder, // Permit2 integration
NativeWrapper // WETH wrapping/unwrapping
{ ... }
```
**NFT metadata**: Name = `"Uniswap v4 Positions NFT"`, Symbol = `"UNI-V4-POSM"`.
### Entry Point
```solidity
function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable;
```
The standard entry point. Encodes a sequence of actions and their parameters. The `unlockData` is ABI-encoded as `(bytes actions, bytes[] params)` where `actions` is a packed byte array of action codes.
### Action Codes
From `v4-periphery/src/libraries/Actions.sol`:
```solidity
library Actions {
uint256 internal constant INCREASE_LIQUIDITY = 0x00;
uint256 internal constant DECREASE_LIQUIDITY = 0x01;
uint256 internal constant MINT_POSITION = 0x02;
uint256 internal constant BURN_POSITION = 0x03;
uint256 internal constant SWAP_EXACT_IN_SINGLE = 0x06;
uint256 internal constant SWAP_EXACT_IN = 0x07;
uint256 internal constant SWAP_EXACT_OUT_SINGLE = 0x08;
uint256 internal constant SWAP_EXACT_OUT = 0x09;
uint256 internal constant SETTLE = 0x0b;
uint256 internal constant SETTLE_ALL = 0x0c;
uint256 internal constant SETTLE_PAIR = 0x0d;
uint256 internal constant TAKE = 0x0e;
uint256 internal constant TAKE_ALL = 0x0f;
uint256 internal constant TAKE_PORTION = 0x10;
uint256 internal constant TAKE_PAIR = 0x11;
uint256 internal constant CLOSE_CURRENCY = 0x12;
uint256 internal constant CLEAR_OR_TAKE = 0x13;
uint256 internal constant SWEEP = 0x14;
uint256 internal constant WRAP = 0x15;
uint256 internal constant UNWRAP = 0x16;
}
```
**DEPRECATED** (vulnerable to sandwich attacks — lack slippage protection):
- `INCREASE_LIQUIDITY_FROM_DELTAS` (0x04)
- `MINT_POSITION_FROM_DELTAS` (0x05)
### Typical Action Sequences
Mint a new position:
```
[MINT_POSITION, SETTLE_PAIR, SWEEP] // or CLOSE_CURRENCY for each
```
Increase liquidity on existing position:
```
[INCREASE_LIQUIDITY, SETTLE_PAIR, SWEEP]
```
Decrease liquidity and collect:
```
[DECREASE_LIQUIDITY, TAKE_PAIR]
```
Burn an empty position:
```
[BURN_POSITION] // position must have zero liquidity
```
### Subscriber/Notification Pattern
The `Notifier` base enables position subscribers — external contracts that receive callbacks when a position is modified. Subscribers implement `ISubscriber`:
```solidity
interface ISubscriber {
function notifySubscribe(uint256 tokenId, bytes memory data) external;
function notifyUnsubscribe(uint256 tokenId) external;
function notifyModifyLiquidity(uint256 tokenId, int256 liquidityChange, BalanceDelta feesAccrued) external;
function notifyBurn(uint256 tokenId) external;
}
```
Subscribe via `positionManager.subscribe(tokenId, subscriber, data)`. The subscriber is notified on every liquidity modification or burn.
## Production Deployment Addresses
### Ethereum Mainnet (Chain ID: 1)
| Contract | Address |
|----------|---------|
| PoolManager | `0x000000000004444c5dc75cB358380D2e3dE08A90` |
| Universal Router | `0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af` |
| PositionManager | `0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e` |
Auf GitHub ansehen