Uniswap v4 Explained: What Changed From v3, How Hooks Work, and a Complete Liquidity Management Tutorial
Thomas CosiallsUniswap v4 went live in January 2025, and it is not a v3 with better gas. It is a redesign of how an AMM is built: every pool now lives inside a single contract, tokens only move once per transaction, and pools became programmable through hooks. For traders the difference is mostly invisible. For liquidity providers, and for anyone writing code that manages liquidity, almost everything about the day-to-day mechanics changed, starting with the fact that there is no collect() function anymore.
This article is in two parts. First, a structured review of what actually changed from v3 and why it matters. Then a complete, hands-on tutorial: managing a real v4 position from TypeScript, covering every operation of the lifecycle: mint, increase liquidity, decrease liquidity, collect fees, and burn, with the exact action encodings the protocol expects.
Part 1: What changed from v3
One singleton instead of a contract per pool
In v3, UniswapV3Factory.createPool() deploys a full contract for every pool, and each pool contract holds its own ERC-20 balances. In v4, a single PoolManager contract holds every pool as internal state: creating a pool is just a state write, which makes it roughly 99.99% cheaper. A pool is identified by its PoolKey, the struct you will use in every operation of the tutorial below:
struct PoolKey { Currency currency0; // lower-sorted token; address(0) for native ETH Currency currency1; uint24 fee; // static fee in pips, or the dynamic-fee flag int24 tickSpacing; IHooks hooks; // hook contract, or address(0) for none }

Two consequences follow. The same token pair can now exist with any fee and any tick spacing, because fee tiers are no longer restricted to the classic 0.05% / 0.30% / 1% set: any static fee up to 100% is valid, or a dynamic fee that a hook can update. And native ETH is a first-class currency again: no more mandatory WETH wrapping, which saves gas and a step for every ETH pair.
Flash accounting
v4 settles balances with flash accounting built on EIP-1153 transient storage. During a transaction, operations only accumulate deltas (who owes what to the PoolManager); actual ERC-20 transfers happen once, net, at the end of the unlock. A multi-hop swap that would have moved tokens between three pool contracts in v3 moves them exactly once in v4. The rule that makes everything work: by the end of the transaction, all deltas must be resolved to zero, or the whole thing reverts. This rule shapes the position-management API you will use below: every operation ends with a settlement action (SETTLE_PAIR when you owe tokens, TAKE_PAIR when you are owed).
Hooks: pools become programmable
The headline feature. A hook is an external contract attached to a pool at creation time (it is part of the PoolKey, so the same pair with two different hooks is two different pools). The PoolManager calls into it at up to ten lifecycle points: before and after initialize, add liquidity, remove liquidity, swap, and donate, plus variants that can return balance deltas and take a cut or subsidize an operation.
A neat implementation detail: a hook's permissions are encoded in its own address. The 14 least-significant bits of the hook contract address declare which callbacks it implements, so deployers mine a CREATE2 salt until the address carries the right flags, and the PoolManager knows what to call without any registry.
For liquidity providers, hooks are both the opportunity and the fine print:
- Dynamic fee hooks reprice the swap fee per block or per swap, based on volatility or inventory, which changes the fee APR you actually earn.
- Strategy hooks implement onchain limit orders, TWAMM (time-weighted average market making), auto-compounding, or full rebalancing vaults on top of a pool.
- The fine print: a hook can also take a share of swap fees or of your withdrawal, block operations, or forward your
hookDatato arbitrary logic. Reading the hook contract before providing liquidity to a hooked pool is not optional; it is part of due diligence. Every position operation in v4 carries ahookDataparameter precisely because hooks may expect input from you.
Positions still exist, but management is command-based
Like v3, a v4 position is an ERC-721 minted by a periphery PositionManager. Unlike v3, that contract no longer exposes one function per operation. It has a single entry point:
function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable;
unlockData is an ABI-encoded pair of (actions, params): a packed list of action bytes and one params blob per action. You batch what you want to do (for example, decrease liquidity, then take both tokens) and the PositionManager executes the batch atomically inside one unlock. It feels lower-level than v3, and it is, but it is also strictly more powerful: any combination of operations settles as one transaction with one net token movement.
The v3 → v4 differences that matter to an LP, side by side:
| Uniswap v3 | Uniswap v4 | |
|---|---|---|
| Pool deployment | One contract per pool | State inside the singleton PoolManager |
| Native ETH | WETH only | Native ETH pairs supported |
| Fee tiers | Fixed set (0.01–1%) | Any static fee, or dynamic via hook |
| Customization | None | Hooks at 10+ lifecycle points |
| Position API | One function per operation | One modifyLiquidities() with encoded actions |
| Collect fees | collect() | DECREASE_LIQUIDITY with zero liquidity |
| Token settlement | Transfers per operation | Flash accounting, net settlement (EIP-1153) |
| Approvals | Approve the position manager | Approve via Permit2 |
Part 2: The liquidity management tutorial
Everything below runs against the canonical ETH/USDC 0.05% pool on Base, from TypeScript with viem. The same code works on any v4 chain once you swap the addresses from the official deployments page. We will do the whole lifecycle in order:

Setup: addresses, clients, and the pool key
npm install viem @uniswap/v3-sdk @uniswap/sdk-core jsbi
We pull @uniswap/v3-sdk only for its math (tick ↔ price conversion and liquidity computation); the tick math is identical between v3 and v4.
import { createPublicClient, createWalletClient, http, parseAbi } from 'viem'
import { base } from 'viem/chains'
import { privateKeyToAccount } from 'viem/accounts'
// Uniswap v4 on Base (docs.uniswap.org/contracts/v4/deployments)
export const POSITION_MANAGER = '0x7c5f5a4bbd8fd63184577525326123b519429bdc'
export const STATE_VIEW = '0xa3c0c9b65bad0b08107aa264b0f3db444b867a71'
export const PERMIT2 = '0x000000000022D473030F116dDEE9F6B43aC78BA3'
export const USDC = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'
export const NATIVE_ETH = '0x0000000000000000000000000000000000000000'
export const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`)
export const publicClient = createPublicClient({ chain: base, transport: http() })
export const walletClient = createWalletClient({ account, chain: base, transport: http() })
export const posmAbi = parseAbi([
'function modifyLiquidities(bytes unlockData, uint256 deadline) payable',
'function nextTokenId() view returns (uint256)',
'function getPositionLiquidity(uint256 tokenId) view returns (uint128)',
])
export const stateViewAbi = parseAbi([
'function getSlot0(bytes32 poolId) view returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee)',
])
// The ETH/USDC 0.05% pool. currency0 < currency1, and native ETH is address(0),
// so ETH is always currency0 in its pairs.
export const poolKey = {
currency0: NATIVE_ETH,
currency1: USDC,
fee: 500,
tickSpacing: 10,
hooks: '0x0000000000000000000000000000000000000000',
} as constThe encoding helper
Every operation is (actions, params) encoded into one bytes blob. The action byte values come from Actions.sol in v4-periphery:
import { encodeAbiParameters, encodePacked, keccak256 } from 'viem'
import { poolKey } from './config'
export const Actions = {
INCREASE_LIQUIDITY: 0x00,
DECREASE_LIQUIDITY: 0x01,
MINT_POSITION: 0x02,
BURN_POSITION: 0x03,
SETTLE_PAIR: 0x0d,
TAKE_PAIR: 0x11,
SWEEP: 0x14,
} as const
export const POOL_KEY_ABI = {
type: 'tuple',
components: [
{ name: 'currency0', type: 'address' },
{ name: 'currency1', type: 'address' },
{ name: 'fee', type: 'uint24' },
{ name: 'tickSpacing', type: 'int24' },
{ name: 'hooks', type: 'address' },
],
} as const
export function encodeUnlockData(actions: number[], params: `0x${string}`[]) {
return encodeAbiParameters(
[{ type: 'bytes' }, { type: 'bytes[]' }],
[encodePacked(actions.map(() => 'uint8'), actions), params],
)
}
// A pool's id is the hash of its key
export const poolId = keccak256(encodeAbiParameters([POOL_KEY_ABI], [poolKey]))
export const deadline = () => BigInt(Math.floor(Date.now() / 1000) + 600)One-time approvals, through Permit2
v4's PositionManager does not pull tokens with a direct ERC-20 allowance; it uses Permit2. So each ERC-20 needs two approvals, once: the token approves Permit2, and Permit2 approves the PositionManager. Native ETH needs no approval at all; it travels as msg.value.
import { maxUint160, maxUint256, parseAbi } from 'viem'
import { PERMIT2, POSITION_MANAGER, USDC, walletClient } from './config'
const erc20Abi = parseAbi(['function approve(address spender, uint256 amount) returns (bool)'])
const permit2Abi = parseAbi([
'function approve(address token, address spender, uint160 amount, uint48 expiration)',
])
await walletClient.writeContract({
address: USDC, abi: erc20Abi, functionName: 'approve',
args: [PERMIT2, maxUint256],
})
await walletClient.writeContract({
address: PERMIT2, abi: permit2Abi, functionName: 'approve',
args: [USDC, POSITION_MANAGER, maxUint160, 2 ** 48 - 1],
})Mint a position
Minting is three actions: MINT_POSITION creates the position, SETTLE_PAIR pays the two tokens, and, because one side is native ETH, SWEEP returns any excess ETH we sent. We compute the liquidity amount from the tokens we are willing to deposit, using the v3 SDK math (a tick step is 0.01%, so ±500 ticks is roughly a ±5% range):
import { TickMath, maxLiquidityForAmounts, nearestUsableTick } from '@uniswap/v3-sdk'
import JSBI from 'jsbi'
import { encodeAbiParameters, parseEther } from 'viem'
import { account, poolKey, posmAbi, publicClient, walletClient,
POSITION_MANAGER, STATE_VIEW, stateViewAbi, NATIVE_ETH } from './config'
import { Actions, POOL_KEY_ABI, deadline, encodeUnlockData, poolId } from './encoding'
// 1. Where is the pool trading right now?
const [sqrtPriceX96, tick] = await publicClient.readContract({
address: STATE_VIEW, abi: stateViewAbi, functionName: 'getSlot0', args: [poolId],
})
// 2. A ±5% range around the current tick, aligned to the pool's tick spacing
const tickLower = nearestUsableTick(tick - 500, poolKey.tickSpacing)
const tickUpper = nearestUsableTick(tick + 500, poolKey.tickSpacing)
// 3. How much liquidity do 0.05 ETH + 120 USDC buy in that range?
const amount0 = parseEther('0.05')
const amount1 = 120_000_000n // USDC has 6 decimals
const liquidity = maxLiquidityForAmounts(
JSBI.BigInt(sqrtPriceX96.toString()),
TickMath.getSqrtRatioAtTick(tickLower),
TickMath.getSqrtRatioAtTick(tickUpper),
amount0.toString(), amount1.toString(), true,
)
// 4. Slippage caps: revert if the pool asks for more than +0.5%
const amount0Max = (amount0 * 1005n) / 1000n
const amount1Max = (amount1 * 1005n) / 1000n
// The next minted position will get this id
const tokenId = await publicClient.readContract({
address: POSITION_MANAGER, abi: posmAbi, functionName: 'nextTokenId',
})
const params = [
encodeAbiParameters(
[POOL_KEY_ABI, { type: 'int24' }, { type: 'int24' }, { type: 'uint256' },
{ type: 'uint128' }, { type: 'uint128' }, { type: 'address' }, { type: 'bytes' }],
[poolKey, tickLower, tickUpper, BigInt(liquidity.toString()),
amount0Max, amount1Max, account.address, '0x'],
),
encodeAbiParameters(
[{ type: 'address' }, { type: 'address' }],
[poolKey.currency0, poolKey.currency1],
),
encodeAbiParameters([{ type: 'address' }, { type: 'address' }], [NATIVE_ETH, account.address]),
]
const hash = await walletClient.writeContract({
address: POSITION_MANAGER, abi: posmAbi, functionName: 'modifyLiquidities',
args: [
encodeUnlockData([Actions.MINT_POSITION, Actions.SETTLE_PAIR, Actions.SWEEP], params),
deadline(),
],
value: amount0Max, // currency0 is native ETH; SWEEP refunds the unused part
})
console.log(`Position ${tokenId} minted in tx ${hash}`)Two details worth pausing on. hookData is '0x' because this pool has no hook; on a hooked pool this is where the hook's expected input goes. And we read nextTokenId before minting: modifyLiquidities does not return the id, so this is the standard way to know which ERC-721 you just received.
Increase liquidity
Adding to an existing position reuses the same pattern with INCREASE_LIQUIDITY, which takes the tokenId instead of a pool key and range. One v4 subtlety: fee revenue is automatically credited when you increase, so if the position has accrued fees, they offset part of what you owe, and the settlement only pulls the difference.
const params = [
encodeAbiParameters(
[{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint128' },
{ type: 'uint128' }, { type: 'bytes' }],
[tokenId, BigInt(addedLiquidity.toString()), amount0Max, amount1Max, '0x'],
),
encodeAbiParameters(
[{ type: 'address' }, { type: 'address' }],
[poolKey.currency0, poolKey.currency1],
),
encodeAbiParameters([{ type: 'address' }, { type: 'address' }], [NATIVE_ETH, account.address]),
]
await walletClient.writeContract({
address: POSITION_MANAGER, abi: posmAbi, functionName: 'modifyLiquidities',
args: [
encodeUnlockData([Actions.INCREASE_LIQUIDITY, Actions.SETTLE_PAIR, Actions.SWEEP], params),
deadline(),
],
value: amount0Max,
})If you want the accrued fees themselves to fund the increase, v4 has dedicated settlement actions for it: CLOSE_CURRENCY decides per currency whether to settle or take the remainder, and CLEAR_OR_TAKE forfeits dust below a threshold instead of paying more in gas than the dust is worth. Those two exist precisely because "compound my fees into the position" is the most common liquidity-management move there is.
Collect fees
Here is the operation that surprises every v3 developer: v4 has no collect function. Fees are collected by decreasing liquidity by zero. The DECREASE_LIQUIDITY action credits accrued fees as a side effect, so a zero decrease credits only the fees, and TAKE_PAIR sends them to your address:
const params = [
// liquidity = 0, amount mins = 0: a pure fee collection.
// Zero mins are safe here because fee collection cannot be front-run.
encodeAbiParameters(
[{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint128' },
{ type: 'uint128' }, { type: 'bytes' }],
[tokenId, 0n, 0n, 0n, '0x'],
),
encodeAbiParameters(
[{ type: 'address' }, { type: 'address' }, { type: 'address' }],
[poolKey.currency0, poolKey.currency1, account.address],
),
]
await walletClient.writeContract({
address: POSITION_MANAGER, abi: posmAbi, functionName: 'modifyLiquidities',
args: [
encodeUnlockData([Actions.DECREASE_LIQUIDITY, Actions.TAKE_PAIR], params),
deadline(),
],
})There is no event or return value that tells you the collected amounts directly: read your token balances before and after the call. If you run this on a schedule (and you should, idle fees earn nothing), that balance diff is also your fee-income accounting.
Decrease liquidity
A real decrease is the same batch with a non-zero liquidity amount, and here the minimum-amount parameters stop being optional: they are your slippage protection against the pool moving (or being moved) between quote and execution. Compute the expected amounts for the liquidity you are removing off-chain, then pass a small haircut as the floor:
// Current liquidity, straight from the position manager
const currentLiquidity = await publicClient.readContract({
address: POSITION_MANAGER, abi: posmAbi, functionName: 'getPositionLiquidity',
args: [tokenId],
})
const liquidityToRemove = currentLiquidity / 4n // trim 25%
// expected0/expected1: quote them off-chain from sqrtPriceX96 and your range
// (the SDK's Position class does this), then floor them at -0.5%:
const amount0Min = (expected0 * 995n) / 1000n
const amount1Min = (expected1 * 995n) / 1000n
const params = [
encodeAbiParameters(
[{ type: 'uint256' }, { type: 'uint256' }, { type: 'uint128' },
{ type: 'uint128' }, { type: 'bytes' }],
[tokenId, liquidityToRemove, amount0Min, amount1Min, '0x'],
),
encodeAbiParameters(
[{ type: 'address' }, { type: 'address' }, { type: 'address' }],
[poolKey.currency0, poolKey.currency1, account.address],
),
]
await walletClient.writeContract({
address: POSITION_MANAGER, abi: posmAbi, functionName: 'modifyLiquidities',
args: [
encodeUnlockData([Actions.DECREASE_LIQUIDITY, Actions.TAKE_PAIR], params),
deadline(),
],
})Because fee revenue is auto-credited on every decrease, the tokens you receive are principal plus all accrued fees. A decrease is always implicitly a collect as well.
Burn the position
Exiting completely does not require decreasing first. BURN_POSITION withdraws all remaining liquidity (and fees), deletes the ERC-721, and TAKE_PAIR pays everything out:
const params = [
encodeAbiParameters(
[{ type: 'uint256' }, { type: 'uint128' }, { type: 'uint128' }, { type: 'bytes' }],
[tokenId, amount0Min, amount1Min, '0x'],
),
encodeAbiParameters(
[{ type: 'address' }, { type: 'address' }, { type: 'address' }],
[poolKey.currency0, poolKey.currency1, account.address],
),
]
await walletClient.writeContract({
address: POSITION_MANAGER, abi: posmAbi, functionName: 'modifyLiquidities',
args: [
encodeUnlockData([Actions.BURN_POSITION, Actions.TAKE_PAIR], params),
deadline(),
],
})The cheat sheet
| Operation | Actions | Settlement direction |
|---|---|---|
| Mint | MINT_POSITION + SETTLE_PAIR (+ SWEEP for ETH) | You pay |
| Increase | INCREASE_LIQUIDITY + SETTLE_PAIR (+ SWEEP for ETH) | You pay (minus accrued fees) |
| Collect fees | DECREASE_LIQUIDITY (liquidity = 0) + TAKE_PAIR | You receive |
| Decrease | DECREASE_LIQUIDITY + TAKE_PAIR | You receive (principal + fees) |
| Burn | BURN_POSITION + TAKE_PAIR | You receive everything |
And the habits that transfer from v3 with a twist: approvals go through Permit2, not the position manager; slippage bounds are per-action parameters (amountMax on the way in, amountMin on the way out, zero only for pure fee collection); native ETH means msg.value plus a SWEEP; and on hooked pools, always check what the hook does before your capital touches it.
From one script to a strategy
Everything above manages one position, once. A real liquidity strategy is this loop running forever: watch the pool tick against your range, collect and compound fees when they cover gas, rebalance when the price walks out of range, pull liquidity when volatility or a hook's behavior turns against you, and account for every operation. Between v4 on EVM chains, Slipstream on Aerodrome, Algebra forks, and Raydium or Orca on Solana, each venue speaks a different dialect of the same lifecycle you just learned.
That loop is what we build at Etherwave Labs: non-custodial automation that runs mint, increase, decrease, collect, and rebalance around the clock, with simulation before every transaction and monitoring after it, across Uniswap v3/v4, Slipstream, Algebra V1, Raydium, and Orca. We wrote up how we approach it on our liquidity management automation service page, and if you would rather have your positions manage themselves, talk to us.

