- name
- uniswap-v4-testing
- description
- Use when writing Foundry tests for Uniswap V4 hooks, router integrations, or pool interactions. Covers test setup with Deployers, HookMiner for address mining, swap/liquidity test patterns, gas profiling, fork testing against production pools, and invariant testing for custom hooks.
# Uniswap V4 Testing with Foundry
## Test Setup with Deployers
The `Deployers` base contract from v4-core bootstraps a complete V4 environment — PoolManager, test routers, currencies, and standard constants.
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {Deployers} from "v4-core/test/utils/Deployers.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {Currency, CurrencyLibrary} from "v4-core/src/types/Currency.sol";
import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
import {Hooks} from "v4-core/src/libraries/Hooks.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {MyHook} from "../src/MyHook.sol";
contract MyHookTest is Test, Deployers {
using PoolIdLibrary for PoolKey;
using StateLibrary for IPoolManager;
using CurrencyLibrary for Currency;
MyHook hook;
PoolKey key;
PoolId poolId;
function setUp() public {
deployFreshManagerAndRouters();
deployMintAndApprove2Currencies();
// Deploy hook to mined address (see HookMiner section)
_deployHook();
key = PoolKey(currency0, currency1, 3000, 60, IHooks(address(hook)));
manager.initialize(key, SQRT_PRICE_1_1);
poolId = key.toId();
modifyLiquidityRouter.modifyLiquidity(
key,
IPoolManager.ModifyLiquidityParams({
tickLower: -120,
tickUpper: 120,
liquidityDelta: 10 ether,
salt: bytes32(0)
}),
ZERO_BYTES
);
}
}
```
### What `Deployers` Provides
| Member | Type | Description |
|--------|------|-------------|
| `manager` | `IPoolManager` | Singleton PoolManager instance |
| `swapRouter` | `PoolSwapTest` | Test router for swaps |
| `modifyLiquidityRouter` | `PoolModifyLiquidityTest` | Test router for liquidity operations |
| `donateRouter` | `PoolDonateTest` | Test router for donations |
| `currency0`, `currency1` | `Currency` | Sorted test ERC-20 tokens (currency0 < currency1) |
| `SQRT_PRICE_1_1` | `uint160` | sqrtPriceX96 for a 1:1 price ratio |
| `SQRT_PRICE_1_2` | `uint160` | sqrtPriceX96 for a 1:2 price ratio |
| `SQRT_PRICE_2_1` | `uint160` | sqrtPriceX96 for a 2:1 price ratio |
| `SQRT_PRICE_1_4` | `uint160` | sqrtPriceX96 for a 1:4 price ratio |
| `SQRT_PRICE_4_1` | `uint160` | sqrtPriceX96 for a 4:1 price ratio |
| `ZERO_BYTES` | `bytes` | Empty bytes constant for hookData |
| `MAX_TICK_SPACING` | `int24` | Maximum allowed tick spacing |
### Key Deployer Functions
```solidity
// Deploy PoolManager + all test routers
deployFreshManagerAndRouters();
// Deploy two sorted ERC-20 tokens, mint to address(this), approve all routers
deployMintAndApprove2Currencies();
// Deploy PoolManager only
deployFreshManager();
```
## HookMiner for Address Mining
Hook addresses encode permissions in their leading bits. `HookMiner` brute-forces a CREATE2 salt that produces an address with the correct bit pattern.
```solidity
import {HookMiner} from "v4-periphery/src/utils/HookMiner.sol";
function _deployHook() internal {
uint160 flags = uint160(
Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG | Hooks.AFTER_INITIALIZE_FLAG
);
bytes memory constructorArgs = abi.encode(manager);
(address hookAddress, bytes32 salt) = HookMiner.find(
address(this),
flags,
type(MyHook).creationCode,
constructorArgs
);
hook = new MyHook{salt: salt}(manager);
require(address(hook) == hookAddress, "hook address mismatch");
}
```
### Common Flag Combinations
```solidity
// Swap-only hook
uint160 swapFlags = uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG);
// Liquidity management hook
uint160 liqFlags = uint160(
Hooks.BEFORE_ADD_LIQUIDITY_FLAG
| Hooks.AFTER_ADD_LIQUIDITY_FLAG
| Hooks.BEFORE_REMOVE_LIQUIDITY_FLAG
| Hooks.AFTER_REMOVE_LIQUIDITY_FLAG
);
// Full lifecycle hook
uint160 fullFlags = uint160(
Hooks.BEFORE_INITIALIZE_FLAG
| Hooks.AFTER_INITIALIZE_FLAG
| Hooks.BEFORE_SWAP_FLAG
| Hooks.AFTER_SWAP_FLAG
| Hooks.BEFORE_ADD_LIQUIDITY_FLAG
| Hooks.AFTER_ADD_LIQUIDITY_FLAG
| Hooks.BEFORE_REMOVE_LIQUIDITY_FLAG
| Hooks.AFTER_REMOVE_LIQUIDITY_FLAG
);
// Hook that modifies swap deltas
uint160 deltaFlags = uint160(
Hooks.BEFORE_SWAP_FLAG
| Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG
);
```
## Swap Test Patterns
### Exact Input — zeroForOne
```solidity
function test_swapExactInput_zeroForOne() public {
uint256 balance0Before = currency0.balanceOf(address(this));
uint256 balance1Before = currency1.balanceOf(address(this));
BalanceDelta delta = swapRouter.swap(
key,
IPoolManager.SwapParams({
zeroForOne: true,
amountSpecified: 1 ether, // positive = exact input
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
PoolSwapTest.TestSettings({
takeClaims: false,
settleUsingBurn: false
}),
ZERO_BYTES
);
assertLt(delta.amount0(), 0, "should spend token0");
assertGt(delta.amount1(), 0, "should receive token1");
assertLt(currency0.balanceOf(address(this)), balance0Before, "token0 balance decreased");
assertGt(currency1.balanceOf(address(this)), balance1Before, "token1 balance increased");
}
```
### Exact Output — oneForZero
```solidity
function test_swapExactOutput_oneForZero() public {
BalanceDelta delta = swapRouter.swap(
key,
IPoolManager.SwapParams({
zeroForOne: false,
amountSpecified: -0.5 ether, // negative = exact output
sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
assertEq(delta.amount0(), -0.5 ether, "should receive exactly 0.5 token0");
assertGt(delta.amount1(), 0, "should spend token1");
}
```
### Swap Direction Reference
| `zeroForOne` | `amountSpecified` | Meaning |
|---|---|---|
| `true` | `> 0` | Exact input of token0, receive token1 |
| `true` | `< 0` | Receive exact output of token1, spend token0 |
| `false` | `> 0` | Exact input of token1, receive token0 |
| `false` | `< 0` | Receive exact output of token0, spend token1 |
### Price Limits
Always set price limits to avoid reverts:
```solidity
// zeroForOne = true → price goes DOWN → use MIN as limit
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
// zeroForOne = false → price goes UP → use MAX as limit
sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1
```
### Fuzz Testing Swaps
```solidity
function testFuzz_swap(uint256 amountIn, bool zeroForOne) public {
amountIn = bound(amountIn, 1e15, 5 ether);
uint160 priceLimit = zeroForOne
? TickMath.MIN_SQRT_PRICE + 1
: TickMath.MAX_SQRT_PRICE - 1;
BalanceDelta delta = swapRouter.swap(
key,
IPoolManager.SwapParams({
zeroForOne: zeroForOne,
amountSpecified: int256(amountIn),
sqrtPriceLimitX96: priceLimit
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
if (zeroForOne) {
assertLt(delta.amount0(), 0);
assertGt(delta.amount1(), 0);
} else {
assertGt(delta.amount0(), 0);
assertLt(delta.amount1(), 0);
}
}
```
## Liquidity Test Patterns
### Adding Liquidity
```solidity
function test_addLiquidity() public {
uint256 balance0Before = currency0.balanceOf(address(this));
uint256 balance1Before = currency1.balanceOf(address(this));
BalanceDelta delta = modifyLiquidityRouter.modifyLiquidity(
key,
IPoolManager.ModifyLiquidityParams({
tickLower: -600,
tickUpper: 600,
liquidityDelta: 5 ether,
salt: bytes32(0)
}),
ZERO_BYTES
);
assertLt(delta.amount0(), 0, "should deposit token0");
assertLt(delta.amount1(), 0, "should deposit token1");
assertLt(currency0.balanceOf(address(this)), balance0Before);
assertLt(currency1.balanceOf(address(this)), balance1Before);
}
```
### Removing Liquidity
```solidity
function test_removeLiquidity() public {
uint256 balance0Before = currency0.balanceOf(address(this));
uint256 balance1Before = currency1.balanceOf(address(this));
BalanceDelta delta = modifyLiquidityRouter.modifyLiquidity(
key,
IPoolManager.ModifyLiquidityParams({
tickLower: -120,
tickUpper: 120,
liquidityDelta: -5 ether, // negative = remove
salt: bytes32(0)
}),
ZERO_BYTES
);
assertGt(delta.amount0(), 0, "should withdraw token0");
assertGt(delta.amount1(), 0, "should withdraw token1");
assertGt(currency0.balanceOf(address(this)), balance0Before);
assertGt(currency1.balanceOf(address(this)), balance1Before);
}
```
### Out-of-Range Liquidity
```solidity
function test_addLiquidity_outOfRange() public {
(uint160 sqrtPriceX96, int24 currentTick,,) = manager.getSlot0(poolId);
// Add liquidity entirely above current price (only token1 deposited)
int24 tickLower = currentTick + 120;
int24 tickUpper = currentTick + 600;
// Round to tick spacing
tickLower = (tickLower / key.tickSpacing) * key.tickSpacing;
tickUpper = (tickUpper / key.tickSpacing) * key.tickSpacing;
BalanceDelta delta = modifyLiquidityRouter.modifyLiquidity(
key,
IPoolManager.ModifyLiquidityParams({
tickLower: tickLower,
tickUpper: tickUpper,
liquidityDelta: 1 ether,
salt: bytes32(0)
}),
ZERO_BYTES
);
assertEq(delta.amount0(), 0, "no token0 for above-range position");
assertLt(delta.amount1(), 0, "should deposit token1 only");
}
```
## Hook Callback Testing
### Verifying Hook Side Effects
```solidity
function test_hookCalledOnSwap() public {
uint256 swapCountBefore = hook.swapCount(poolId);
swapRouter.swap(
key,
IPoolManager.SwapParams({
zeroForOne: true,
amountSpecified: 1 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
assertEq(hook.swapCount(poolId), swapCountBefore + 1, "hook should increment counter");
}
```
### Verifying Hook Receives Correct Parameters
```solidity
function test_hookReceivesCorrectParams() public {
bytes memory hookData = abi.encode(uint256(42));
vm.expectEmit(address(hook));
emit MyHook.BeforeSwapCalled(
address(swapRouter),
key,
IPoolManager.SwapParams({
zeroForOne: true,
amountSpecified: 1 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
hookData
);
swapRouter.swap(
key,
IPoolManager.SwapParams({
zeroForOne: true,
amountSpecified: 1 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
hookData
);
}
```
### Testing Hooks That Return Deltas
```solidity
function test_hookReturnsDelta() public {
// For hooks with BEFORE_SWAP_RETURNS_DELTA_FLAG, the hook can take/give tokens
uint256 hookBalance0Before = currency0.balanceOf(address(hook));
BalanceDelta delta = swapRouter.swap(
key,
IPoolManager.SwapParams({
zeroForOne: true,
amountSpecified: 1 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
ZERO_BYTES
);
// Verify the hook captured its fee or modified the delta
uint256 hookBalance0After = currency0.balanceOf(address(hook));
assertGt(hookBalance0After, hookBalance0Before, "hook should have taken fee");
}
```
### Testing afterInitialize
```solidity
function test_afterInitialize_setsState() public {
// Deploy a second pool to test initialization
PoolKey memory key2 = PoolKey(
currency0, currency1, 500, 10, IHooks(address(hook))
);
manager.initialize(key2, SQRT_PRICE_1_1);
PoolId id2 = key2.toId();
assertEq(hook.poolInitTimestamp(id2), block.timestamp);
}
```
## Reading Pool State in Tests
```solidity
using StateLibrary for IPoolManager;
function test_poolStateAfterSwap() public {
(uint160 sqrtPriceBefore, int24 tickBefore,,) = manager.getSlot0(poolId);
swapRouter.swap(
key,
IPoolManager.SwapParams({
zeroForOne: true,
Auf GitHub ansehen