--- 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() Functions callable outside `unlock()`: - `initialize()` — creates a new pool (no balance changes) - `sync(currency)` — snapshots reserves into transient storage; harmless outside a lock - `updateDynamicLPFee()` — called by hook contracts to set the current dynamic fee - the `IProtocolFees` admin functions (`setProtocolFeeController`, `setProtocolFee`, `collectProtocolFees`) `swap`, `modifyLiquidity`, `donate`, `take`, `settle`, `settleFor`, `clear`, `mint`, `burn` are all `onlyWhenUnlocked` and must run 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 import {SwapParams} from "v4-core/src/types/PoolOperation.sol"; // Inside a BaseHook subclass: override the internal _beforeSwap, not the external entry point function _beforeSwap(address, PoolKey calldata, SwapParams calldata, bytes calldata) internal override returns (bytes4, BeforeSwapDelta, uint24) { uint24 dynamicFee = _computeFee(); return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, dynamicFee | LPFeeLibrary.OVERRIDE_FEE_FLAG); } ``` ### Protocol Fees Set by the `protocolFeeController` (appointed by the PoolManager owner via `setProtocolFeeController`) through `IProtocolFees.setProtocolFee(PoolKey, uint24)`. The `uint24` packs two direction-specific fees in pips: lower 12 bits = zeroForOne, upper 12 bits = oneForZero, each `<= ProtocolFeeLibrary.MAX_PROTOCOL_FEE` (1000 pips = 0.1%). The protocol fee is charged on the swap input first; the LP fee applies to the remainder (`swapFee = protocolFee + lpFee - protocolFee * lpFee / 1e6`). Protocol fees are live on mainnet V4 pools since the governance proposal "Activate v4 Protocol Fees (Part 1/2)" executed on 2026-07-27. On Ethereum, `PoolManager.owner()` is the governance timelock `0x1a9C8182C09F50C8318d769245beA52c32BE35BC` and `protocolFeeController()` is `0x89A5D5bF00a27D55c02951E49078a5C5771051dB`; the native ETH/USDC 500/10 pool reports `protocolFee = 512125` (125 pips in each direction). Fee-revenue models for hooks and LPs must account for this cut. ## 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 DONATE = 0x0a; // not supported by PositionManager or V4Router 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; uint256 internal constant MINT_6909 = 0x17; // not supported by PositionManager or V4Router uint256 internal constant BURN_6909 = 0x18; // not supported by PositionManager or V4Router uint256 internal constant UNWIND_WITH_FALLBACK = 0x19; } ``` The library defines 26 constants (`0x00`–`0x19`); the two deprecated `*_FROM_DELTAS` codes are listed below. **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 import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol"; import {PositionInfo} from "v4-periphery/src/libraries/PositionInfoLibrary.sol"; 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, address owner, PositionInfo info, uint256 liquidity, BalanceDelta feesAccrued) 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 (V2) | `0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af` | | Universal Router 2.1.1 | `0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA` | | Universal Router 2.1.2 | `0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85` | | PositionManager | `0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e` | | PositionDescriptor | `0xd1428Ba554F4C8450b763a0B2040A4935c63f06C` | | StateView | `0x7fFE42C4a5DEeA5b0feC41C94C136Cf115597227` | | V4Quoter | `0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203` | | ReservesLens | `0x0000001b173C3bbF3984D417d8614E3eed34865B` | | Permit2 | `0x000000000022D473030F116dDEE9F6B43aC78BA3` | ### Unichain (Chain ID: 130) | Contract | Address | |----------|---------| | PoolManager | `0x1F98400000000000000000000000000000000004` | | PositionManager | `0x4529A01c7A0410167c5740C487A8DE60232617bf` | | StateView | `0x86e8631A016F9068C3f085fAF484Ee3F5fDee8f2` | | V4Quoter | `0x333E3C607B141b18fF6de9f258db6e77fE7491E0` | | Universal Router (V2) | `0xEf740bf23aCaE26f6492B10de645D6B98dC8Eaf3` | | Universal Router 2.1.2 | `0xD1b797D92d87B688193A2B976eFc8D577D204343` | RPC: `https://mainnet.unichain.org`. All Unichain addresses above were verified with `cast code`; the Ethereum PoolManager address has no code on Unichain. Unichain's `protocolFeeController()` is still `address(0)`, so protocol fees are not yet active there. ### Supported Chains V4 is deployed on 19 mainnets: **Ethereum, Unichain, Optimism, Base, Arbitrum One, Polygon, Zora, Worldchain, X Layer, Ink, Soneium, Avalanche, BNB Smart Chain, Celo, Monad, MegaETH, Tempo, Robinhood Chain, Arc**. Blast was removed from the official list (its onchain status is unverified). **CRITICAL**: Addresses are NOT the same across chains. Always verify per-chain at https://developers.uniswap.org/docs/protocols/v4/deployments. Use `cast code
--rpc-url