21 min read
Overview
Uniswap V4 introduces hooks, a powerful way to add custom logic at important points during pool operations. Hooks are separate smart contracts that work with Uniswap pools, allowing developers to change swap behavior, create complex strategies, and make custom AMM logic without changing the entire protocol.
In this guide, we'll show you how to build a simple hook that adds a basic feature. This will help you learn how to develop more complex custom hooks. Let's get started!
What You Will Do
- Learn about Uniswap V4 Hooks
- Create a custom Hook smart contract
- Test the Hook smart contract with scripts and with Anvil
- Deploy and test the Hook smart contract on a testnet
What You Will Need
- Experience with Ethereum and DeFi
- Foundry installed
Uniswap Hooks
Uniswap V4 hooks are smart contracts that can step in at specific times during a swap, letting developers customize and expand what liquidity pools can do. These hooks work with the main protocol at set times, allowing for more features without making the core contracts less secure.
Key points about Uniswap V4 hooks:
- Hooks can be triggered before or after important actions like swaps, adding or removing liquidity, and starting a pool
- Each hook is its own smart contract, so you can develop and test it on its own
- You can use multiple hooks together to create complex pool behaviors
- Custom logic runs within the swap transaction, which uses less gas than separate contracts
- Devs can add new hooks without changing the main Uniswap protocol
Hooks let you control pool operations in detail. They allow for custom fee structures, changing liquidity distribution, and complex trading strategies right in the pool logic. By stepping in at key moments in the swap process, hooks can add risk controls, make capital use more efficient, and even help create new financial tools on the blockchain.
Some examples of what you can do with hooks:
- Orders that only happen under certain conditions (like limit orders or stop-loss)
- Changing fees based on market conditions
- Automatic liquidity management strategies
- Running arbitrage between different pools
- Creating new AMM curves or ways to set prices
This level of customization lets you create pools that fit specific market conditions or trading needs. You can develop and use hooks separately, and combine them to create complex pool behaviors, running custom logic right in the swap process to save gas. This design lets you add features that usually need multiple transactions or coordination off the blockchain.
In the following sections, we'll cover the core concepts of hooks you'll want to know such as Hooks lifecycle, IHook interface, and Hook flags.
Hook Lifecycle
Hooks can work with the swap process at several important points:
- Before Initialize: Runs before a pool starts
- After Initialize: Runs after a pool starts
- Before Swap: Runs before a swap happens
- After Swap: Runs after a swap happens
- Before Add Liquidity: Runs before liquidity is added to a pool
- After Add Liquidity: Runs after liquidity is added to a pool
- Before Remove Liquidity: Runs before liquidity is taken out of a pool
- After Remove Liquidity: Runs after liquidity is taken out of a pool
By creating these hook functions, developers can make custom behaviors that happen at specific times during pool operations.
IHook Interface
The IHook interface is what your hook contract needs to implement. It defines the functions that Uniswap V4 will call at different points in the pool's operations. Here's a simplified version of what it might look like:
interface IHook {
function beforeInitialize(address sender, uint160 sqrtPriceX96) external returns (bytes4);
function afterInitialize(address sender, uint160 sqrtPriceX96) external returns (bytes4);
function beforeSwap(address sender, address recipient, bool zeroForOne, uint256 amountSpecified, uint160 sqrtPriceLimitX96) external returns (bytes4);
function afterSwap(address sender, address recipient, bool zeroForOne, uint256 amountSpecified, uint160 sqrtPriceLimitX96) external returns (bytes4);
function beforeAddLiquidity(address sender, uint256 amount0, uint256 amount1) external returns (bytes4);
function afterAddLiquidity(address sender, uint256 amount0, uint256 amount1) external returns (bytes4);
function beforeRemoveLiquidity(address sender, uint256 amount0, uint256 amount1) external returns (bytes4);
function afterRemoveLiquidity(address sender, uint256 amount0, uint256 amount1) external returns (bytes4);
}
Hook Flags
When you create a hook, you need to specify which functions it implements. This is done using hook flags. These flags are bit flags that indicate which hook functions are active. For example:
uint160 constant BEFORE_SWAP_FLAG = 1 << 0;
uint160 constant AFTER_SWAP_FLAG = 1 << 1;
uint160 constant BEFORE_ADD_LIQUIDITY_FLAG = 1 << 2;
// ... and so on for other hook functions
You combine these flags to indicate which functions your hook implements. For instance, if your hook implements beforeSwap and afterSwap, you would use:
uint160 public constant FLAGS = BEFORE_SWAP_FLAG | AFTER_SWAP_FLAG;
In the following sections, we'll walk through creating a simple hook to show how you can use these tools in practice.
Project Prerequisite: Create a QuickNode Endpoint
Before getting into the code, let's set up some prerequisites like getting a RPC URL. You're welcome to use public nodes or deploy and manage your own infrastructure; however, if you'd like 8x faster response times, you can leave the heavy lifting to us. Sign up for a free account here.
Once logged into QuickNode, click the Create an endpoint button, then select the Ethereum chain and Sepolia network.
After creating your endpoint, copy the HTTP Provider URL link and keep it handy, as you'll need it in the local testnet section.
Implementing Your First Uniswap Hook
Now that we've covered the core concepts and got our prerequisites completed, let's dive into creating your first Uniswap V4 hook. We'll use the v4-template as our starting point, which provides a solid foundation for hook development.
Directory Set Up
Use the v4-template as a starting point. You can do this by clicking "Use this Template" on the GitHub repository, or by cloning it:
git clone git@github.com:uniswapfoundation/v4-template.git
cd v4-template
The template includes an example hook Counter.sol
which demonstrates beforeSwap()
and afterSwap()
hooks (feel free to take a glance now). The test template Counter.t.sol
preconfigures the v4 pool manager, test tokens, and test liquidity, which will be helpful for testing our hook.
Install Dependencies
First, ensure you have Foundry installed and up to date:
foundryup
Next, install the project dependencies:
forge install
Although you could technically run the forge tests
command now, we will hold off until we add our own custom hook logic.
Customize the Hook Contract
Now, let's create our own hook. We'll implement a "Swap Limiter" hook that restricts the number of swaps a single address can perform within a certain time frame. This adds a basic form of rate limiting to the pool.
First, create a file called src/SwapLimiterHook.sol
. Then, open the file and include the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {BaseHook} from "v4-periphery/src/base/hooks/BaseHook.sol";
import {Hooks} from "v4-core/src/libraries/Hooks.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "v4-core/src/types/BeforeSwapDelta.sol";
contract SwapLimiterHook is BaseHook {
using PoolIdLibrary for PoolKey;
uint256 public constant MAX_SWAPS_PER_HOUR = 5;
uint256 public constant HOUR = 3600;
mapping(address => uint256) public lastResetTime;
mapping(address => uint256) public swapCount;
event SwapLimitReached(address indexed user, uint256 timestamp);
constructor(IPoolManager _poolManager) BaseHook(_poolManager) {}
function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
return Hooks.Permissions({
beforeInitialize: false,
afterInitialize: false,
beforeAddLiquidity: false,
afterAddLiquidity: false,
beforeRemoveLiquidity: false,
afterRemoveLiquidity: false,
beforeSwap: true,
afterSwap: false,
beforeDonate: false,
afterDonate: false,
beforeSwapReturnDelta: false,
afterSwapReturnDelta: false,
afterAddLiquidityReturnDelta: false,
afterRemoveLiquidityReturnDelta: false
});
}
// Main function to enforce swap limit
function beforeSwap(address sender, PoolKey calldata, IPoolManager.SwapParams calldata, bytes calldata)
external
override
returns (bytes4, BeforeSwapDelta, uint24)
{
uint256 currentTime = block.timestamp;
if (currentTime - lastResetTime[sender] >= HOUR) {
swapCount[sender] = 0;
lastResetTime[sender] = currentTime;
}
require(swapCount[sender] < MAX_SWAPS_PER_HOUR, "Swap limit reached for this hour");
swapCount[sender]++;
if (swapCount[sender] == MAX_SWAPS_PER_HOUR) {
emit SwapLimitReached(sender, currentTime);
}
return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
}
function getRemainingSwaps(address user) public view returns (uint256) {
if (block.timestamp - lastResetTime[user] >= HOUR) {
return MAX_SWAPS_PER_HOUR;
}
return MAX_SWAPS_PER_HOUR - swapCount[user];
}
}
This "Swap Limiter" hook contract implements the following features:
- Swap Limit: It restricts each address to a maximum of 5 swaps per hour (configurable via
MAX_SWAPS_PER_HOUR
). - Time-based Reset: The swap count for each address resets every hour.
- beforeSwap Hook: This function checks if the sender has exceeded their swap limit for the current hour. If not, it increments their swap count.
- Remaining Swaps Check: A
getRemainingSwaps
function allows users (or the front-end) to check how many swaps they have left in the current hour. - Event Logging: An event is emitted when a user reaches their swap limit, which could be useful for monitoring or alerting purposes.
Optionally, to check that your file syntax is set up properly, you can run the forge compile
command.
Create a Test File
Now that we've created our hook, we need to create a test file. First, create a file called test/SwapLimiterHook.t.sol
. Then, include the following code:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {Hooks} from "v4-core/src/libraries/Hooks.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {CurrencyLibrary, Currency} from "v4-core/src/types/Currency.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {SwapLimiterHook} from "../src/SwapLimiterHook.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {IPositionManager} from "v4-periphery/src/interfaces/IPositionManager.sol";
import {EasyPosm} from "./utils/EasyPosm.sol";
import {Fixtures} from "./utils/Fixtures.sol";
contract SwapLimiterHookTest is Test, Fixtures {
using EasyPosm for IPositionManager;
using PoolIdLibrary for PoolKey;
using CurrencyLibrary for Currency;
using StateLibrary for IPoolManager;
SwapLimiterHook hook;
PoolId poolId;
uint256 tokenId;
int24 tickLower;
int24 tickUpper;
event SwapLimitReached(address indexed user, uint256 timestamp);
function setUp() public {
// creates the pool manager, utility routers, and test tokens
deployFreshManagerAndRouters();
deployMintAndApprove2Currencies();
deployAndApprovePosm(manager);
// Deploy the hook to an address with the correct flags
address flags = address(
uint160(Hooks.BEFORE_SWAP_FLAG) ^ (0x4444 << 144) // Namespace the hook to avoid collisions
);
bytes memory constructorArgs = abi.encode(manager);
deployCodeTo("SwapLimiterHook.sol:SwapLimiterHook", constructorArgs, flags);
hook = SwapLimiterHook(flags);
// Create the pool
key = PoolKey(currency0, currency1, 3000, 60, IHooks(hook));
poolId = key.toId();
manager.initialize(key, SQRT_PRICE_1_1, ZERO_BYTES);
// Provide full-range liquidity to the pool
tickLower = TickMath.minUsableTick(key.tickSpacing);
tickUpper = TickMath.maxUsableTick(key.tickSpacing);
(tokenId,) = posm.mint(
key,
tickLower,
tickUpper,
10_000e18,
MAX_SLIPPAGE_ADD_LIQUIDITY,
MAX_SLIPPAGE_ADD_LIQUIDITY,
address(this),
block.timestamp,
ZERO_BYTES
);
}
function testDirectBeforeSwap() public {
address sender = address(this);
IPoolManager.SwapParams memory params;
bytes memory hookData;
for (uint i = 0; i < 5; i++) {
(bytes4 selector,,) = hook.beforeSwap(sender, key, params, hookData);
assertEq(selector, SwapLimiterHook.beforeSwap.selector);
console.log("Swap %d, Remaining swaps: %d", i + 1, hook.getRemainingSwaps(sender));
}
vm.expectRevert("Swap limit reached for this hour");
hook.beforeSwap(sender, key, params, hookData);
}
function testSwapLimiter() public {
bool zeroForOne = true;
int256 amountSpecified = -1e18; // negative number indicates exact input swap
console.log("Initial remaining swaps: %d", hook.getRemainingSwaps(address(this)));
// Perform 5 swaps (should succeed)
for (uint i = 0; i < 5; i++) {
// Manually call beforeSwap to simulate the hook being triggered
(bytes4 selector,,) = hook.beforeSwap(address(this), key, IPoolManager.SwapParams({zeroForOne: zeroForOne, amountSpecified: amountSpecified, sqrtPriceLimitX96: 0}), ZERO_BYTES);
assertEq(selector, SwapLimiterHook.beforeSwap.selector);
BalanceDelta swapDelta = swap(key, zeroForOne, amountSpecified, ZERO_BYTES);
assertEq(int256(swapDelta.amount0()), amountSpecified);
console.log("Swap %d succeeded. Remaining swaps: %d", i + 1, hook.getRemainingSwaps(address(this)));
}
// The 6th swap should revert
vm.expectRevert("Swap limit reached for this hour");
hook.beforeSwap(address(this), key, IPoolManager.SwapParams({zeroForOne: zeroForOne, amountSpecified: amountSpecified, sqrtPriceLimitX96: 0}), ZERO_BYTES);
// Attempt the 6th swap (should fail)
vm.expectRevert(abi.encodeWithSignature("Wrap__FailedHookCall(address,bytes)", address(hook), abi.encodeWithSignature("Error(string)", "Swap limit reached for this hour")));
swap(key, zeroForOne, amountSpecified, ZERO_BYTES);
// Check remaining swaps
uint256 remainingSwaps = hook.getRemainingSwaps(address(this));
console.log("Final remaining swaps: %d", remainingSwaps);
assertEq(remainingSwaps, 0, "Should have 0 remaining swaps");
}
function testSwapLimitReachedEvent() public {
address sender = address(this);
IPoolManager.SwapParams memory params;
bytes memory hookData;
for (uint i = 0; i < 4; i++) {
hook.beforeSwap(sender, key, params, hookData);
}
vm.expectEmit(true, false, false, true);
emit SwapLimitReached(sender, block.timestamp);
hook.beforeSwap(sender, key, params, hookData);
}
}
These tests cover the main functionality of our SwapLimiterHook
contract which enforces the swap limit, resets the swap count after an hour, and checks the remaining swaps for a user.
Let's recap the main functionality in the test code.
testDirectBeforeSwap
function:- Directly calls the
beforeSwap
function of the hook 5 times - Checks that each call returns the correct selector
- Logs remaining swaps after each call
- Expects the 6th call to revert with
"Swap limit reached for this hour"
- Directly calls the
testSwapLimiter
function:- Logs initial remaining swaps
- Performs 5 swaps, each time:
- Manually calls
beforeSwap
to simulate the hook trigger - Performs the actual swap
- Logs successful swap and remaining swaps
- Manually calls
- Expects the 6th
beforeSwap
call to revert - Expects the 6th actual swap to revert with a wrapped error message
- Checks that final remaining swaps is 0
testSwapLimitReachedEvent
function:- Calls
beforeSwap
4 times - Expects the
SwapLimitReached
event to be emitted on the 5th call - Performs the 5th call to
beforeSwap
- Calls
Execute the tests with the following command:
forge test
You'll see this when the tests finish:
Ran 3 tests for test/SwapLimiterHookTest.t.sol:SwapLimiterHookTest
[PASS] testDirectBeforeSwap() (gas: 74596)
[PASS] testSwapLimitReachedEvent() (gas: 58075)
[PASS] testSwapLimiter() (gas: 506599)
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 24.54ms (4.06ms CPU time)
If you want to see the console.log statements, you can add the -vv
flag to your test command. Optionally, pass v
multiple times to increase the verbosity (e.g. -v, -vv, -vvv) to see more in-depth information (e.g., print execution traces)
Remember to adjust the tests as needed based on your specific implementation and any additional features you may add to the hook.
Local Development with Anvil and QuickNode
To test your hook in a more realistic environment, you can use Anvil, a local testnet node, forked from a live network using QuickNode.
First, start Anvil in a separate terminal window, forking from your QuickNode RPC:
anvil --fork-url https://your-quicknode-endpoint.quiknode.pro/your-api-key/
This will start a local testnet node that's a fork of the current state of the network your QuicknNde RPC is connected to.
Then update the Anvil.s.sol
file and replace the existing code with the following:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Script.sol";
import "forge-std/console.sol";
import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {Hooks} from "v4-core/src/libraries/Hooks.sol";
import {PoolManager} from "v4-core/src/PoolManager.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {PoolModifyLiquidityTest} from "v4-core/src/test/PoolModifyLiquidityTest.sol";
import {PoolSwapTest} from "v4-core/src/test/PoolSwapTest.sol";
import {PoolDonateTest} from "v4-core/src/test/PoolDonateTest.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {MockERC20} from "solmate/src/test/utils/mocks/MockERC20.sol";
import {Constants} from "v4-core/src/../test/utils/Constants.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {CurrencyLibrary, Currency} from "v4-core/src/types/Currency.sol";
import {SwapLimiterHook} from "../src/SwapLimiterHook.sol";
import {HookMiner} from "../test/utils/HookMiner.sol";
contract SwapLimiterScript is Script {
address constant CREATE2_DEPLOYER = address(0x4e59b44847b379578588920cA78FbF26c0B4956C);
function setUp() public {}
function run() public {
vm.broadcast();
IPoolManager manager = deployPoolManager();
uint160 permissions = uint160(Hooks.BEFORE_SWAP_FLAG);
(address hookAddress, bytes32 salt) = HookMiner.find(
CREATE2_DEPLOYER,
permissions,
type(SwapLimiterHook).creationCode,
abi.encode(address(manager))
);
vm.broadcast();
SwapLimiterHook swapLimiter = new SwapLimiterHook{salt: salt}(manager);
require(address(swapLimiter) == hookAddress, "SwapLimiterScript: hook address mismatch");
vm.startBroadcast();
(PoolModifyLiquidityTest lpRouter, PoolSwapTest swapRouter,) = deployRouters(manager);
vm.stopBroadcast();
vm.startBroadcast();
testLifecycle(manager, address(swapLimiter), lpRouter, swapRouter);
vm.stopBroadcast();
}
function deployPoolManager() internal returns (IPoolManager) {
return IPoolManager(address(new PoolManager()));
}
function deployRouters(IPoolManager manager)
internal
returns (PoolModifyLiquidityTest lpRouter, PoolSwapTest swapRouter, PoolDonateTest donateRouter)
{
lpRouter = new PoolModifyLiquidityTest(manager);
swapRouter = new PoolSwapTest(manager);
donateRouter = new PoolDonateTest(manager);
}
function deployTokens() internal returns (MockERC20 token0, MockERC20 token1) {
MockERC20 tokenA = new MockERC20("MockA", "A", 18);
MockERC20 tokenB = new MockERC20("MockB", "B", 18);
if (uint160(address(tokenA)) < uint160(address(tokenB))) {
token0 = tokenA;
token1 = tokenB;
} else {
token0 = tokenB;
token1 = tokenA;
}
}
function testLifecycle(
IPoolManager manager,
address hook,
PoolModifyLiquidityTest lpRouter,
PoolSwapTest swapRouter
) internal {
(MockERC20 token0, MockERC20 token1) = deployTokens();
token0.mint(msg.sender, 100_000 ether);
token1.mint(msg.sender, 100_000 ether);
bytes memory ZERO_BYTES = new bytes(0);
int24 tickSpacing = 60;
PoolKey memory poolKey = PoolKey(
Currency.wrap(address(token0)),
Currency.wrap(address(token1)),
3000,
tickSpacing,
IHooks(hook)
);
manager.initialize(poolKey, Constants.SQRT_PRICE_1_1, ZERO_BYTES);
token0.approve(address(lpRouter), type(uint256).max);
token1.approve(address(lpRouter), type(uint256).max);
token0.approve(address(swapRouter), type(uint256).max);
token1.approve(address(swapRouter), type(uint256).max);
lpRouter.modifyLiquidity(
poolKey,
IPoolManager.ModifyLiquidityParams(
TickMath.minUsableTick(tickSpacing),
TickMath.maxUsableTick(tickSpacing),
100 ether,
0
),
ZERO_BYTES
);
console.log("Starting swap tests...");
for (uint256 i = 0; i < 6; i++) {
console.log("Attempting swap %d", i + 1);
try swapRouter.swap(
poolKey,
IPoolManager.SwapParams({
zeroForOne: true,
amountSpecified: 1 ether,
sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
}),
PoolSwapTest.TestSettings({takeClaims: false, settleUsingBurn: false}),
new bytes(0)
) {
console.log("Swap %d successful", i + 1);
} catch Error(string memory reason) {
console.log("Swap %d failed: %s", i + 1, reason);
} catch (bytes memory /*lowLevelData*/) {
console.log("Swap %d failed", i + 1);
}
}
console.log("Swap tests completed.");
SwapLimiterHook swapLimiter = SwapLimiterHook(hook);
uint256 remainingSwaps = swapLimiter.getRemainingSwaps(address(swapRouter));
console.log("Remaining swaps for the sender: %d", remainingSwaps);
}
}
Then, in another new terminal window and run the following command:
forge script script/Anvil.s.sol \
--rpc-url http://localhost:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--broadcast
The command above executes the Anvil.s.sol
script with the local testnet node running on port 8545. The private key corresponds to the accounts generated when running a local testnet code.
You will see a response like this:
Notice the log statements that show you the state after each swap until the swap reverts because it has hit the swap limit.
Wrap Up
Congrats! You've now learned how to create, test, and deploy a custom hook for Uniswap V4. From understanding the basics of hooks to implementing a custom hook and deploying it to the Sepolia testnet, you've covered the essential steps in Uniswap V4 hook development.
Stay up to date with the latest in blockchain development by following QuickNode on Twitter (@QuickNode) or joining the Discord community.
We ❤️ Feedback!
Let us know if you have any feedback or requests for new topics. We'd love to hear from you.