I made a First Flight contest on CodeHawks and noticed the submissions were very low compared to previous contests. I thought the market was down, but the contest after mine included Yul code and received more submissions than mine. The problem wasn't the market—it was developers fearing V4. So I decided to make this the easiest and simplest entrance to Uniswap hooks.
Understanding why hooks exist and what problem they solve makes them much easier to grasp.
Before V4, every new idea meant new logic and new pools. Since Uniswap V3 logic was immutable, developers couldn't extend it safely, so they either forked the protocol or added features on top. This fragmented liquidity and pushed innovation away from Uniswap itself. Worse, many forks relied on the dangerous assumption that "Uniswap is already well audited," skipped proper security reviews, and introduced subtle changes that broke core assumptions, leading to repeated hacks. In short, innovation required copying the protocol, and copying the protocol repeatedly put both liquidity and users at risk.
With hooks you can introduce new ideas without copying the protocol.
Hooks let you customize pool behavior without changing the core logic or creating new pools, keeping liquidity unified instead of fragmented. V4 introduces opt-in hook contracts, while the Uniswap core remains minimal, stable, and auditable. This removes the need to fork "just to add a feature," reduces unsafe assumptions about inherited security, and allows new mechanisms to be built on top of shared liquidity rather than away from it.
Unlike V2/V3 where each pool is a separate contract, V4 uses a singleton architecture - ONE contract (PoolManager) contains ALL pools.
Benefits:
Gas Efficiency: Multi-hop swaps don't transfer tokens between contracts
Flash Accounting: Settle debts at the end rather than on each step
Shared State: More efficient storage patterns
Think of a Uniswap V4 pool like a smartphone. In older versions (V2 and V3), the phone came with only pre-installed, unchangeable settings. In V4, hooks are like apps you can install on the phone. While the phone (the core protocol) handles the basic calls and texts (swaps and liquidity), the apps (hooks) allow you to add a custom apps, instagram maybe, or a high-security lock to specific actions.
Hooks are external smart contracts that act as plugins for liquidity pools, allowing developers to customize and extend the protocol's behavior without changing the core code.
Every hook must implement the IHooks interface. Here's what that looks like:
interface IHooks {
// Initialization hooks
function beforeInitialize(address sender, PoolKey calldata key, uint160 sqrtPriceX96)
external returns (bytes4);
function afterInitialize(address sender, PoolKey calldata key, uint160 sqrtPriceX96, int24 tick)
external returns (bytes4);
// Liquidity modification hooks
function beforeAddLiquidity(address sender, PoolKey calldata key,
IPoolManager.ModifyLiquidityParams calldata params, bytes calldata hookData)
external returns (bytes4);
function afterAddLiquidity(address sender, PoolKey calldata key,
IPoolManager.ModifyLiquidityParams calldata params,
BalanceDelta delta, bytes calldata hookData)
external returns (bytes4, BalanceDelta);
// And more... (beforeRemoveLiquidity, beforeSwap, afterSwap, etc.)
}
Critical Rule: Every hook function MUST return its function selector (the first 4 bytes of the function signature). This validates that the hook implemented the correct function.
Because hooks can modify swap parameters and fees, they enable advanced features that were previously impossible on Uniswap, such as:
• Dynamic Fees: Adjusting trading fees automatically based on market volatility or trading volume.
• Limit Orders: Executing trades only when a specific on-chain price is reached.
• TWAMM (Time-Weighted Average Market Maker): Breaking up large orders to execute them slowly over time to reduce price impact.
• Custom AMM Curves: Bypassing standard math to create unique pricing models for specific asset types.
beforeInitialize / afterInitialize : Called before and after initialization of the pool.
beforeAddLiquidity / afterAddLiquidity : Called before and after adding liquidity.
beforeRemoveLiquidity / afterRemoveLiquidity : Called before and after liquidity removal.
beforeSwap / afterSwap : Called before and after token exchange.
beforeDonate / afterDonate : Called before and after token donation.
beforeSwapReturnDelta / afterSwapReturnDelta : These two permissions are special permissions that allow the hook to modify the tokens associated with the hook address during the exchange process (Delta). In other words, if a hook is granted this permission, it can control a portion of the tokens during the exchange process.
afterAddLiquidityReturnDelta / afterRemoveLiquidityReturnDelta : These are also special permissions, meaning the hook can modify the hook contract's token Delta before liquidity is added and after liquidity is removed.
The "ReturnDelta" permissions are powerful - they let hooks modify token amounts:
beforeSwapReturnDelta:
// Hook can take input OR provide output
struct BeforeSwapDelta {
int128 deltaSpecified; // Modify input amount
int128 deltaUnspecified; // Modify output amount
}
// Example: Hook takes 1% of input
return (
selector,
BeforeSwapDelta({
deltaSpecified: -int128(inputAmount / 100), // Hook takes tokens
deltaUnspecified: 0
}),
0
);
afterSwapReturnDelta:
// Hook can take from output
function afterSwap(...) external returns (bytes4, int128) {
int128 feeFromOutput = outputAmount / 100;
return (selector, -feeFromOutput); // Hook takes from output
}
Here's where it gets interesting. The hook contract's address itself encodes its permissions.
Hook addresses aren't random! The first 14 bits of the address encode which hooks are enabled:
Complete Permission Bitmap (First 14 bits):
Bit 0: beforeInitialize
Bit 1: afterInitialize
Bit 2: beforeAddLiquidity
Bit 3: afterAddLiquidity
Bit 4: beforeRemoveLiquidity
Bit 5: afterRemoveLiquidity
Bit 6: beforeSwap
Bit 7: afterSwap
Bit 8: beforeDonate
Bit 9: afterDonate
Bit 10: beforeSwapReturnDelta
Bit 11: afterSwapReturnDelta
Bit 12: afterAddLiquidityReturnDelta
Bit 13: afterRemoveLiquidityReturnDelta
The PoolManager checks permissions using bitwise operations:
function hasPermission(IHooks self, uint256 flag) internal pure returns (bool) {
// Right-shift the address to get the flag bits
// AND with the flag to check if that bit is set
return uint256(uint160(address(self))) & flag == flag;
}Why This Design?
Gas Efficiency: Check permissions with simple bitwise operations
Immutability: Can't change permissions after deployment
Transparency: Anyone can see what a hook can do from its address
No Storage: Don't need to store permission mappings
Traditional AMM: User → Swap Function → Execute Trade → Done
Uniswap V4 with Hooks: User → beforeSwap Hook → Swap Function → afterSwap Hook → Done
User calls swap()
│
├─→ PoolManager.swap()
│ │
│ ├─→ Lock pool
│ │
│ ├─→ IF beforeSwap permission set:
│ │ └─→ Call hook.beforeSwap()
│ │ │
│ │ ├─→ Hook executes custom logic
│ │ ├─→ Hook returns: (selector, delta, fee)
│ │ └─→ Validate selector matches
│ │
│ ├─→ Execute core swap logic
│ │ ├─→ Calculate amounts
│ │ ├─→ Update pool state
│ │ └─→ Produce BalanceDelta
│ │
│ ├─→ IF afterSwap permission set:
│ │ └─→ Call hook.afterSwap()
│ │ │
│ │ ├─→ Hook executes post-swap logic
│ │ └─→ Returns selector (and optional delta)
│ │
│ ├─→ Settle all balances (flash accounting)
│ │ ├─→ User pays what they owe
│ │ └─→ User receives what they're owed
│ │
│ └─→ Unlock pool
│
└─→ Return to user
Every pool is identified by a PoolKey struct:
struct PoolKey {
Currency currency0; // First token
Currency currency1; // Second token
uint24 fee; // Fee tier (in hundredths of a bip)
int24 tickSpacing; // Tick spacing for concentrated liquidity
IHooks hooks; // Hook contract address
}
The hook address is part of the pool's identity.
Two pools with the same tokens but different hooks are completely different pools.
PoolManager.modifyLiquidity()
│
├─→ Validate parameters
├─→ Lock pool (reentrancy protection)
│
├─→ IF hook has beforeAddLiquidity permission:
│ │
│ └─→ Call hook.beforeAddLiquidity(sender, key, params, hookData)
│ │
│ ├─→ Hook can:
│ │ • Validate liquidity provider (KYC)
│ │ • Charge fees
│ │ • Modify parameters
│ │ • Revert to block the operation
│ │
│ └─→ Return selector
│
├─→ Execute core liquidity modification
│ ├─→ Calculate token amounts needed
│ ├─→ Update position state
│ ├─→ Update pool reserves
│ └─→ Calculate BalanceDelta (amounts owed/owed to user)
│
├─→ IF hook has afterAddLiquidity permission:
│ │
│ └─→ Call hook.afterAddLiquidity(sender, key, params, delta, hookData)
│ │
│ ├─→ Hook can:
│ │ • Take fees from added liquidity
│ │ • Mint LP tokens
│ │ • Update oracle
│ │ • Trigger external actions
│ │
│ ├─→ IF afterAddLiquidityReturnDelta enabled:
│ │ └─→ Return (selector, BalanceDelta adjustment)
│ │ Hook can modify how much user owes/receives
│ │
│ └─→ ELSE: Return selector only
│
├─→ Settle balances (flash accounting)
│ ├─→ User pays tokens owed
│ └─→ User receives tokens owed to them
│
└─→ Unlock pool
Now let's dive into a real hook implementation - the ReFiSwapRebateHook. This hook demonstrates a practical use case: implementing different fees for buying vs. selling a specific token (ReFi).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Hooks} from "v4-core/libraries/Hooks.sol";
import {PoolKey} from "v4-core/types/PoolKey.sol";
import {SwapParams} from "v4-core/types/PoolOperation.sol";
import {BalanceDelta} from "v4-core/types/BalanceDelta.sol";
import {BaseHook} from "v4-periphery/src/utils/BaseHook.sol";
import {PoolId, PoolIdLibrary} from "v4-core/types/PoolId.sol";
import {LPFeeLibrary} from "v4-core/libraries/LPFeeLibrary.sol";
import {IPoolManager} from "v4-core/interfaces/IPoolManager.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Currency, CurrencyLibrary} from "v4-core/types/Currency.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {safeERC20} from "@openzeppelin/contracts/token/safeERC20";
import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "v4-core/types/BeforeSwapDelta.sol";
/// @title ReFiSwapRebateHook - The ReFi Swap Rebate Hook
/// @author ChoasSR (https://x.com/0xlinguin)
contract ReFiSwapRebateHook is BaseHook, Ownable {
using CurrencyLibrary for Currency;
using PoolIdLibrary for PoolKey;
using LPFeeLibrary for uint24;
/* ================================================== */
/* IMMUTABLE */
/* ================================================== */
address public immutable ReFi;
/* ================================================== */
/* STATE VARIABLES */
/* ================================================== */
uint24 public buyFee = 0; // 0% fee when buying ReFi
uint24 public sellFee = 3000; // 0.3% fee when selling ReFi
/* ================================================== */
/* CUSTOM EVENTS */
/* ================================================== */
event ReFiBought(address indexed buyer, uint256 amount);
event ReFiSold(address indexed seller, uint256 amount, uint256 fee);
event TokensWithdrawn(address indexed token, address indexed to, uint256 amount);
/* ================================================== */
/* CUSTOM ERRORS */
/* ================================================== */
error ReFiNotInPool();
error MustUseDynamicFee();
/* ================================================== */
/* CONSTRUCTOR */
/* ================================================== */
/// @notice Initializes the hook with pool manager and ReFi token address
/// @param _poolManager Address of the Uniswap V4 pool manager
/// @param _ReFi Address of the ReFi token
constructor(IPoolManager _poolManager, address _ReFi)
BaseHook(_poolManager)
Ownable(msg.sender)
{
ReFi = _ReFi;
}
/* ================================================== */
/* ADMIN FUNCTIONS */
/* ================================================== */
/// @notice Withdraws tokens from the hook contract
/// @param token Address of the token to withdraw
/// @param to Address to send the tokens to
/// @param amount Amount of tokens to withdraw
/// @dev Only callable by owner
function withdrawTokens(address token, address to, uint256 amount) external onlyOwner {
IERC20(token).safeTransfer(to, amount);
emit TokensWithdrawn(token ,to ,amount);
}
/// @notice Updates the buy and/or sell fee percentages
/// @param _isBuyFee Whether to update the buy fee
/// @param _buyFee New buy fee value (if _isBuyFee is true)
/// @param _isSellFee Whether to update the sell fee
/// @param _sellFee New sell fee value (if _isSellFee is true)
/// @dev Only callable by owner
function ChangeFee(
bool _isBuyFee,
uint24 _buyFee,
bool _isSellFee,
uint24 _sellFee
) external onlyOwner {
if (_isBuyFee) buyFee = _buyFee;
if (_isSellFee) sellFee = _sellFee;
}
/* ================================================== */
/* UNISWAP FUNCTIONS */
/* ================================================== */
/// @notice Defines which hook permissions this contract uses
/// @return Hooks.Permissions struct with enabled hooks
function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
return Hooks.Permissions({
beforeInitialize: true,
afterInitialize: true,
beforeAddLiquidity: false,
afterAddLiquidity: false,
beforeRemoveLiquidity: false,
afterRemoveLiquidity: false,
beforeSwap: true,
afterSwap: false,
beforeDonate: false,
afterDonate: false,
beforeSwapReturnDelta: false,
afterSwapReturnDelta: false,
afterAddLiquidityReturnDelta: false,
afterRemoveLiquidityReturnDelta: false
});
}
/// @notice Validates that ReFi token is in the pool before initialization
/// @param key The pool key containing currency pair information
/// @return Function selector for success
function _beforeInitialize(address, PoolKey calldata key, uint160)
internal
view
override
returns (bytes4)
{
if (Currency.unwrap(key.currency0) != ReFi &&
Currency.unwrap(key.currency1) != ReFi) {
revert ReFiNotInPool();
}
return BaseHook.beforeInitialize.selector;
}
/// @notice Validates that the pool uses dynamic fees after initialization
/// @param key The pool key to validate
/// @return Function selector for success
function _afterInitialize(address, PoolKey calldata key, uint160, int24)
internal
pure
override
returns (bytes4)
{
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
return BaseHook.afterInitialize.selector;
}
/// @notice Applies dynamic fees before each swap based on buy/sell direction
/// @param sender Address initiating the swap
/// @param key The pool key for the swap
/// @param params Swap parameters including direction and amount
/// @return Function selector, delta (always zero), and the dynamic fee to apply
function _beforeSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
bytes calldata
) internal override returns (bytes4, BeforeSwapDelta, uint24) {
bool isReFiBuy = _isReFiBuy(key, params.zeroForOne);
uint256 swapAmount = params.amountSpecified < 0
? uint256(-params.amountSpecified)
: uint256(params.amountSpecified);
uint24 fee;
if (isReFiBuy) {
fee = buyFee;
emit ReFiBought(sender, swapAmount);
} else {
fee = sellFee;
uint256 feeAmount = (swapAmount * sellFee) / 1000000;
emit ReFiSold(sender, swapAmount, feeAmount);
}
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
fee | LPFeeLibrary.OVERRIDE_FEE_FLAG
);
}
/* ================================================== */
/* INTERNAL FUNCTIONS */
/* ================================================== */
/// @notice Determines if a swap is buying or selling ReFi
/// @param key The pool key containing currency information
/// @param zeroForOne The swap direction
/// @return True if buying ReFi, false if selling
function _isReFiBuy(PoolKey calldata key, bool zeroForOne)
internal
view
returns (bool)
{
bool isReFiCurrency0 = Currency.unwrap(key.currency0) == ReFi;
if (isReFiCurrency0) {
return !zeroForOne;
} else {
return zeroForOne;
}
}
/* ================================================== */
/* VIEW FUNCTIONS */
/* ================================================== */
/// @notice Returns the current fee configuration
/// @return buyFee The current buy fee
/// @return sellFee The current sell fee
function getFeeConfig() external view returns (uint24, uint24) {
return (buyFee, sellFee);
}
}
Let's break down each component and understand how this hook achieves its goal of differential buy/sell fees.
contract ReFiSwapRebateHook is BaseHook, Ownable {
using CurrencyLibrary for Currency;
using PoolIdLibrary for PoolKey;
using LPFeeLibrary for uint24;
Key Points:
BaseHook: Provides base implementation of IHooks interface with helper functions
Ownable: Gives admin control for fee adjustments and token withdrawals
Library Imports: Use Uniswap's utility libraries for currency handling and fee management
address public immutable ReFi;
uint24 public buyFee = 0; // 0% fee when buying ReFi
uint24 public sellFee = 3000; // 0.3% fee when selling ReFi
Why This Design:
ReFi is immutable: The token address can't change after deployment (security)
Fees are mutable: Owner can adjust fees based on market conditions
Fee format: Fees in hundredths of a basis point (3000 = 0.3%)
function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
return Hooks.Permissions({
beforeInitialize: true, // ✓ Validate ReFi is in pool
afterInitialize: true, // ✓ Validate dynamic fees enabled
beforeSwap: true, // ✓ Apply buy/sell fee logic
// All others: false
});
}
This hook only needs 3 permissions:
beforeInitialize: Check that ReFi token is one of the pool's currencies
afterInitialize: Ensure the pool uses dynamic fees (required for fee overriding)
beforeSwap: Determine if buy/sell and apply appropriate fee
Step 1: beforeInitialize - Validate ReFi is in Pool
function _beforeInitialize(address, PoolKey calldata key, uint160)
internal view override returns (bytes4)
{
// Check if either currency0 or currency1 is ReFi
if (Currency.unwrap(key.currency0) != ReFi &&
Currency.unwrap(key.currency1) != ReFi) {
revert ReFiNotInPool();
}
return BaseHook.beforeInitialize.selector;
}
What This Does:
Unwraps the Currency type to get the actual token address
Checks if ReFi is either currency0 or currency1 in the pool
Reverts if ReFi is NOT in the pool - this hook only works for ReFi pairs
Returns the correct selector to validate the hook executed properly
Step 2: afterInitialize - Validate Dynamic Fees
function _afterInitialize(address, PoolKey calldata key, uint160, int24)
internal pure override returns (bytes4)
{
if (!key.fee.isDynamicFee()) {
revert MustUseDynamicFee();
}
return BaseHook.afterInitialize.selector;
}
What This Does:
Checks if the pool was created with dynamic fee flag enabled
Dynamic fees are required because the hook overrides fees on each swap
Without dynamic fees, the hook can't change fees per transaction
This is a safety check to ensure the pool is configured correctly
This is where the magic happens:
function _beforeSwap(
address sender,
PoolKey calldata key,
SwapParams calldata params,
bytes calldata
) internal override returns (bytes4, BeforeSwapDelta, uint24) {
// Step 1: Determine if this is buying or selling ReFi
bool isReFiBuy = _isReFiBuy(key, params.zeroForOne);
// Step 2: Get the swap amount (handle negative for exactOutput swaps)
uint256 swapAmount = params.amountSpecified < 0
? uint256(-params.amountSpecified)
: uint256(params.amountSpecified);
// Step 3: Apply appropriate fee
uint24 fee;
if (isReFiBuy) {
fee = buyFee; // 0% for buying ReFi
emit ReFiBought(sender, swapAmount);
} else {
fee = sellFee; // 0.3% for selling ReFi
uint256 feeAmount = (swapAmount * sellFee) / 100000;
emit ReFiSold(sender, swapAmount, feeAmount);
}
// Step 4: Return results
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA, // Not taking tokens directly
fee | LPFeeLibrary.OVERRIDE_FEE_FLAG // Override pool's default fee
);
}
Let's Break This Down:
bool isReFiBuy = _isReFiBuy(key, params.zeroForOne);
This calls a helper function that determines the swap direction:
function _isReFiBuy(PoolKey calldata key, bool zeroForOne)
internal view returns (bool)
{
bool isReFiCurrency0 = Currency.unwrap(key.currency0) == ReFi;
if (isReFiCurrency0) {
return !zeroForOne; // If ReFi is currency0, buying = swapping 1→0
} else {
return zeroForOne; // If ReFi is currency1, buying = swapping 0→1
}
}
Understanding zeroForOne:
zeroForOne = truemeans: swap currency0 for currency1zeroForOne = falsemeans: swap currency1 for currency0
Examples:
ReFi/USDC pool (ReFi = currency0, USDC = currency1)
Buy ReFi: Trade USDC → ReFi = swap currency1 for currency0 =
zeroForOne = falseSell ReFi: Trade ReFi → USDC = swap currency0 for currency1 =
zeroForOne = true
uint256 swapAmount = params.amountSpecified < 0
? uint256(-params.amountSpecified)
: uint256(params.amountSpecified);
Why the negative check?
Uniswap V4 supports two swap modes:
ExactInput: Specify how much you're selling (positive value)
ExactOutput: Specify how much you want to receive (negative value)
The negative indicates "I want to receive this exact amount" vs "I want to sell this exact amount."
We need the absolute value for fee calculations and event emissions.
return (
BaseHook.beforeSwap.selector,
BeforeSwapDeltaLibrary.ZERO_DELTA,
fee | LPFeeLibrary.OVERRIDE_FEE_FLAG
);
Three return values explained:
Selector: Validates the hook executed correctly
BeforeSwapDelta: Set to ZERO_DELTA because this hook doesn't take tokens directly
If we wanted to take a fee to the hook contract, we'd return a negative delta here
Instead, we let the pool collect the fee and distribute to LPs
Fee with OVERRIDE_FEE_FLAG:
fee | LPFeeLibrary.OVERRIDE_FEE_FLAGsets the override bitThis tells the PoolManager: "Use this fee instead of the pool's default fee"
Different fee on every swap depending on buy/sell direction!
Let's trace a complete swap through this hook:
Pool: USDC/ReFi (USDC = currency0, ReFi = currency1)
1. User calls Router.swap()
│
├─→ Router calls PoolManager.swap(key, params, hookData)
│
2. PoolManager.swap() begins
│
├─→ Lock the pool
│
├─→ Check hook permissions: beforeSwap = true ✓
│
3. PoolManager calls hook.beforeSwap()
│
├─→ Hook receives:
│ • sender = user's address // not always user address
│ • key = {USDC, ReFi, fee, tickSpacing, hooks}
│ • params = {zeroForOne: true, amountSpecified: 100, ...}
│
├─→ Hook logic executes:
│ │
│ ├─→ _isReFiBuy(key, true)
│ │ • ReFi is currency1
│ │ • zeroForOne = true
│ │ • Returns: true (this IS a buy)
│ │
│ ├─→ swapAmount = 100
│ │
│ ├─→ isReFiBuy = true, so:
│ │ • fee = 0 (buyFee)
│ │ • emit ReFiBought(user, 100)
│ │
│ └─→ Return:
│ • selector ✓
│ • ZERO_DELTA (no tokens taken by hook)
│ • 0 | OVERRIDE_FEE_FLAG (0% fee!)
│
4. PoolManager continues with 0% fee applied
│
├─→ Calculate swap: 100 ReFi costs X USDC
│
├─→ NO FEE TAKEN (buyFee = 0%)
│
├─→ Update pool state
│
5. Settle balances
│
├─→ User pays X USDC
│
├─→ User receives 100 ReFi
│
6. Unlock pool
│
└─→ Return to userThe beauty of V4 is that all these features are opt-in. Users who want this behavior use pools with this hook. Users who don't simply use pools without it. No fragmentation, no forking, just choice.
I believe you're now ready for the next hook first flight on Codehawks.

