v4-security-foundations
v4 Hook Security Foundations
Security-first guide for building Uniswap v4 hooks. Hook vulnerabilities can drain user funds—understand these concepts before writing any hook code.
Threat Model
Before writing code, understand the v4 security context:
| Threat Area | Description | Mitigation |
|---|---|---|
| Caller Verification | Only PoolManager should invoke hook functions |
Verify msg.sender == address(poolManager) |
| Sender Identity | msg.sender always equals PoolManager, never the end user |
Use sender parameter for user identity |
| Router Context | The sender parameter identifies the router, not the user |
Implement router allowlisting |
| State Exposure | Hook state is readable during mid-transaction execution | Avoid storing sensitive data on-chain |
| Reentrancy Surface | External calls from hooks can enable reentrancy | Use reentrancy guards; minimize external calls |
Permission Flags Risk Matrix
All 14 hook permissions with associated risk levels:
| Permission Flag | Risk Level | Description | Security Notes |
|---|---|---|---|
beforeInitialize |
LOW | Called before pool creation | Validate pool parameters |
afterInitialize |
LOW | Called after pool creation | Safe for state initialization |
beforeAddLiquidity |
MEDIUM | Before LP deposits | Can block legitimate LPs |
afterAddLiquidity |
LOW | After LP deposits | Safe for tracking/rewards |
beforeRemoveLiquidity |
HIGH | Before LP withdrawals | Can trap user funds |
afterRemoveLiquidity |
LOW | After LP withdrawals | Safe for tracking |
beforeSwap |
HIGH | Before swap execution | Can manipulate prices |
afterSwap |
MEDIUM | After swap execution | Can observe final state |
beforeDonate |
LOW | Before donations | Access control only |
afterDonate |
LOW | After donations | Safe for tracking |
beforeSwapReturnDelta |
CRITICAL | Returns custom swap amounts | NoOp attack vector |
afterSwapReturnDelta |
HIGH | Modifies post-swap amounts | Can extract value |
afterAddLiquidityReturnDelta |
HIGH | Modifies LP token amounts | Can shortchange LPs |
afterRemoveLiquidityReturnDelta |
HIGH | Modifies withdrawal amounts | Can steal funds |
Risk Thresholds
- LOW: Unlikely to cause fund loss
- MEDIUM: Requires careful implementation
- HIGH: Can cause fund loss if misimplemented
- CRITICAL: Can enable complete fund theft
CRITICAL: NoOp Rug Pull Attack
The BEFORE_SWAP_RETURNS_DELTA permission (bit 10) is the most dangerous hook permission. A malicious hook can:
- Return a delta claiming it handled the entire swap
- PoolManager accepts this and settles the trade
- Hook keeps all input tokens without providing output
- User loses entire swap amount
Attack Pattern
// MALICIOUS - DO NOT USE
function beforeSwap(
address,
PoolKey calldata,
IPoolManager.SwapParams calldata params,
bytes calldata
) external override returns (bytes4, BeforeSwapDelta, uint24) {
// Claim to handle the swap but steal tokens
int128 amountSpecified = int128(params.amountSpecified);
BeforeSwapDelta delta = toBeforeSwapDelta(amountSpecified, 0);
return (BaseHook.beforeSwap.selector, delta, 0);
}
Detection
Before interacting with ANY hook that has beforeSwapReturnDelta: true:
- Audit the hook code - Verify legitimate use case
- Check ownership - Is it upgradeable? By whom?
- Verify track record - Has it been audited by reputable firms?
- Start small - Test with minimal amounts first
Legitimate Uses
NoOp patterns are valid for:
- Just-in-time liquidity (JIT)
- Custom AMM curves
- Intent-based trading systems
- RFQ/PMM integrations
But each requires careful implementation and audit.
Delta Accounting Fundamentals
v4 uses a credit/debit system through the PoolManager:
Core Invariant
For every transaction: sum(deltas) == 0
The PoolManager tracks what each address owes or is owed. At transaction end, all debts must be settled.
Key Functions
| Function | Purpose | Direction |
|---|---|---|
take(currency, to, amount) |
Withdraw tokens from PoolManager | You receive tokens |
settle(currency) |
Pay tokens to PoolManager | You send tokens |
sync(currency) |
Update PoolManager balance tracking | Preparation for settle |
Settlement Pattern
// Correct pattern: sync before settle
poolManager.sync(currency);
currency.transfer(address(poolManager), amount);
poolManager.settle(currency);
Common Mistakes
- Forgetting sync: Settlement fails without sync
- Wrong order: Must sync → transfer → settle
- Partial settlement: Leaves transaction in invalid state
- Double settlement: Causes accounting errors
Access Control Patterns
PoolManager Verification
Every hook callback MUST verify the caller:
modifier onlyPoolManager() {
require(msg.sender == address(poolManager), "Not PoolManager");
_;
}
function beforeSwap(
address sender,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
bytes calldata hookData
) external override onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) {
// Safe to proceed
}
Why This Matters
Without this check:
- Anyone can call hook functions directly
- Attackers can manipulate hook state
- Funds can be drained through fake callbacks
Router Verification Patterns
The sender parameter is the router, not the end user. For hooks that need user identity:
Allowlisting Pattern
mapping(address => bool) public allowedRouters;
function beforeSwap(
address sender, // This is the router
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
bytes calldata hookData
) external override onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) {
require(allowedRouters[sender], "Router not allowed");
// Proceed with swap
}
User Identity via hookData
function beforeSwap(
address sender,
PoolKey calldata key,
IPoolManager.SwapParams calldata params,
bytes calldata hookData
) external override onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) {
// Decode user address from hookData (router must include it)
address user = abi.decode(hookData, (address));
// CAUTION: Router must be trusted to provide accurate user
}
msg.sender Trap
// WRONG - msg.sender is always PoolManager in hooks
function beforeSwap(...) external {
require(msg.sender == someUser); // Always fails or wrong
}
// CORRECT - Use sender parameter
function beforeSwap(address sender, ...) external {
require(allowedRouters[sender], "Invalid router");
}
Token Handling Hazards
Not all tokens behave like standard ERC-20s:
| Token Type | Hazard | Mitigation |
|---|---|---|
| Fee-on-transfer | Received amount < sent amount | Measure actual balance changes |
| Rebasing | Balance changes without transfers | Avoid storing raw balances |
| ERC-777 | Transfer callbacks enable reentrancy | Use reentrancy guards |
| Pausable | Transfers can be blocked | Handle transfer failures gracefully |
| Blocklist | Specific addresses blocked | Test with production addresses |
| Low decimals | Precision loss in calculations | Use appropriate scaling |
Safe Balance Check Pattern
function safeTransferIn(
IERC20 token,
address from,
uint256 amount
) internal returns (uint256 received) {
uint256 balanceBefore = token.balanceOf(address(this));
token.safeTransferFrom(from, address(this), amount);
received = token.balanceOf(address(this)) - balanceBefore;
}
Base Hook Template
Start with all permissions disabled. Enable only what you need:
// 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 {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "v4-core/src/types/BeforeSwapDelta.sol";
contract SecureHook is BaseHook {
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: false, // Enable only if needed
afterSwap: false, // Enable only if needed
beforeDonate: false,
afterDonate: false,
beforeSwapReturnDelta: false, // DANGER: NoOp attack vector
afterSwapReturnDelta: false, // DANGER: Can extract value
afterAddLiquidityReturnDelta: false,
afterRemoveLiquidityReturnDelta: false
});
}
// Implement only the callbacks you enabled above
}
See references/base-hook-template.md for a complete implementation template.
Security Checklist
Before deploying any hook:
| # | Check | Status |
|---|---|---|
| 1 | All hook callbacks verify msg.sender == poolManager |
[ ] |
| 2 | Router allowlisting implemented if needed | [ ] |
| 3 | No unbounded loops that can cause OOG | [ ] |
| 4 | Reentrancy guards on external calls | [ ] |
| 5 | Delta accounting sums to zero | [ ] |
| 6 | Fee-on-transfer tokens handled | [ ] |
| 7 | No hardcoded addresses | [ ] |
| 8 | Slippage parameters respected | [ ] |
| 9 | No sensitive data stored on-chain | [ ] |
| 10 | Upgrade mechanisms secured (if applicable) | [ ] |
| 11 | beforeSwapReturnDelta justified if enabled |
[ ] |
| 12 | Fuzz testing completed | [ ] |
| 13 | Invariant testing completed | [ ] |
Gas Budget Guidelines
Hook callbacks execute inside the PoolManager's transaction context. Excessive gas consumption can make swaps revert or become economically unviable.
Gas Budgets by Callback
| Callback | Target Budget | Hard Ceiling | Notes |
|---|---|---|---|
beforeSwap |
< 50,000 gas | 150,000 gas | Runs on every swap; keep lean |
afterSwap |
< 30,000 gas | 100,000 gas | Analytics/tracking only |
beforeAddLiquidity |
< 50,000 gas | 200,000 gas | May include access control |
afterAddLiquidity |
< 30,000 gas | 100,000 gas | Reward tracking |
beforeRemoveLiquidity |
< 50,000 gas | 200,000 gas | Lock validation |
afterRemoveLiquidity |
< 30,000 gas | 100,000 gas | Tracking/accounting |
| Callbacks with external calls | < 100,000 gas | 300,000 gas | External DEX routing, oracles |
Common Gas Pitfalls
- Unbounded loops: Iterating over dynamic arrays (e.g., all active positions) can exceed block gas limits. Cap array sizes or use pagination.
- SSTORE in hot paths: Each new storage slot costs ~20,000 gas. Prefer transient storage (
tstore/tload) for data that doesn't persist beyond the transaction. Requires Solidity >= 0.8.24 with EVM target set tocancunor later. - External calls: Each cross-contract call adds ~2,600 gas base cost plus the callee's execution. Batch calls where possible.
- String operations: Avoid
stringmanipulation in callbacks; usebytes32for identifiers. - Redundant reads: Cache
poolManagercalls — repeatedgetSlot0()orgetLiquidity()reads cost gas each time.
Measuring Gas
# Profile a specific hook callback with Foundry
forge test --match-test test_beforeSwapGas --gas-report
# Snapshot gas usage across all tests
forge snapshot --match-contract MyHookTest
Risk Scoring System
Calculate your hook's risk score (0-33):
| Category | Points | Criteria |
|---|---|---|
| Permissions | 0-14 | Sum of enabled permission risk levels |
| External Calls | 0-5 | Number and type of external interactions |
| State Complexity | 0-5 | Amount of mutable state |
| Upgrade Mechanism | 0-5 | Proxy, admin functions, etc. |
| Token Handling | 0-4 | Non-standard token support |
Audit Tier Recommendations
| Score | Risk Level | Recommendation |
|---|---|---|
| 0-5 | Low | Self-audit + peer review |
| 6-12 | Medium | Professional audit recommended |
| 13-20 | High | Professional audit required |
| 21-33 | Critical | Multiple audits required |
Absolute Prohibitions
Never do these things in a hook:
- Never trust
msg.senderfor user identity - It's always PoolManager - Never enable
beforeSwapReturnDeltawithout understanding NoOp attacks - Never store passwords, keys, or PII on-chain
- Never use
transfer()for ETH - Usecall{value:}("") - Never assume token decimals - Always query the token
- Never use
block.timestampfor randomness - Never hardcode gas limits in calls
- Never ignore return values from external calls
- Never use
tx.originfor authorization - It's a phishing vector; malicious contracts can relay calls with the original user'stx.origin
Pre-Deployment Audit Checklist
| # | Item | Required For |
|---|---|---|
| 1 | Code review by security-focused developer | All hooks |
| 2 | Unit tests for all callbacks | All hooks |
| 3 | Fuzz testing with Foundry | All hooks |
| 4 | Invariant testing | Hooks with delta returns |
| 5 | Fork testing on mainnet | All hooks |
| 6 | Gas profiling | All hooks |
| 7 | Formal verification | Critical hooks |
| 8 | Slither/Mythril analysis | All hooks |
| 9 | External audit | Medium+ risk hooks |
| 10 | Bug bounty program | High+ risk hooks |
| 11 | Monitoring/alerting setup | All production hooks |
See references/audit-checklist.md for detailed audit requirements.
Production Hook References
Learn from audited, production hooks:
| Project | Description | Notable Security Features |
|---|---|---|
| Flaunch | Token launch platform | Multi-sig admin, timelocks |
| EulerSwap | Lending integration | Isolated risk per market |
| Zaha TWAMM | Time-weighted AMM | Gradual execution reduces MEV |
| Bunni | LP management | Concentrated liquidity guards |
External Resources
Official Documentation
Security Resources
Community
- v4-hooks-skill by @igoryuzo - Community skill that inspired this guide
- v4hooks.dev - Community hook resources
Additional References
- Base Hook Template - Complete implementation starter
- Vulnerabilities Catalog - Common patterns and mitigations
- Audit Checklist - Detailed pre-deployment checklist