Introduction

Uniswap V3’s security model was simple: trust the protocol. The Router, Factory, and Pool contracts were audited monoliths. If Uniswap was secure, your integration was mostly secure. V4 shatters this assumption.

The new Singleton PoolManager delegates execution to arbitrary hook contracts at 14 different lifecycle points — before/after initialize, swap, add/remove liquidity, and donate. Hooks can modify accounting deltas, take custody of assets, and inject custom logic into every pool operation. The security model becomes a three-party trust problem, and history shows that responsibility gaps between parties are where exploits live.

This is not a theoretical concern. The Cork Protocol was exploited for $11M, and the root cause was a missing access-control modifier on a hook callback. This article maps the full V4 hook attack surface — from architecture and the permission bitmap, through reentrancy and delta manipulation, to upgrade patterns and audit methodology — with concrete Solidity throughout.


1. The Hooks Architecture and the Permission Bitmap

Uniswap V4 introduces Hooks, a system that allows developers to customize and extend the behavior of liquidity pools. Hooks are external smart contracts that can be attached to individual pools. Every pool can have one hook, but a hook can serve an infinite number of pools to intercept and modify the execution flow at specific points during pool-related actions.

The full V4 feature set includes: customizable hooks, through which developers can attach bespoke logic to the liquidity provision and swapping lifecycle; a singleton design, where all pool state and operations are managed by a single PoolManager contract; a flash accounting system, where all intermediary balance changes are recorded in transient storage and netted against each other before being settled at the end of the transaction; and dynamic fees with native ETH support.

How Permissions Are Encoded

The design decision that makes hooks simultaneously powerful and risky is how permissions are enforced: not in storage, but in the hook’s deployed address itself.

The mechanism that Uniswap V4 employs to determine the permission of the hooks involves examining the least significant bits of the deployed hook address. Once a hook is deployed, the permissions cannot be changed.

There are 14 Uniswap V4 permission bits, including beforeSwap, afterSwap, and the full set of lifecycle flags. Each bit corresponds to one lifecycle callback. If a bit is not set in the address, the PoolManager will never call that function — it is a compile-time constraint baked into deployment, not a runtime guard.

// Hooks.sol (Uniswap v4-core)
library Hooks {
    // Permission flags occupy the lower 14 bits of the hook address
    uint160 internal constant BEFORE_INITIALIZE_FLAG        = 1 << 13;
    uint160 internal constant AFTER_INITIALIZE_FLAG         = 1 << 12;
    uint160 internal constant BEFORE_ADD_LIQUIDITY_FLAG     = 1 << 11;
    uint160 internal constant AFTER_ADD_LIQUIDITY_FLAG      = 1 << 10;
    uint160 internal constant BEFORE_REMOVE_LIQUIDITY_FLAG  = 1 << 9;
    uint160 internal constant AFTER_REMOVE_LIQUIDITY_FLAG   = 1 << 8;
    uint160 internal constant BEFORE_SWAP_FLAG              = 1 << 7;
    uint160 internal constant AFTER_SWAP_FLAG               = 1 << 6;
    uint160 internal constant BEFORE_DONATE_FLAG            = 1 << 5;
    uint160 internal constant AFTER_DONATE_FLAG             = 1 << 4;
    uint160 internal constant BEFORE_SWAP_RETURNS_DELTA_FLAG = 1 << 3;
    uint160 internal constant AFTER_SWAP_RETURNS_DELTA_FLAG  = 1 << 2;
    uint160 internal constant BEFORE_ADD_LIQUIDITY_RETURNS_DELTA_FLAG  = 1 << 1;
    uint160 internal constant AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG   = 1 << 0;
}

To deploy a hook with specific permissions, the deployer must mine a CREATE2 salt that causes the resulting address to have the correct lower bits set. This means the hook address is its own security configuration.

The Permission-Address Mismatch Vulnerability

One of the primary vulnerabilities lies in a potential mismatch between a hook’s declared permissions and the permissions encoded in its mined address. If a hook’s address does not correctly encode the permission bits for a function it claims to support, the PoolManager will not recognize it, and transactions could revert, leading to a denial-of-service attack.

A related and dangerous variant affects hook upgrades. Although a hook may implement new functions in future upgrades, its deployment address does not encode permissions for those functions. If afterSwap() is added in an upgrade, PoolManager will not recognize it because the contract’s address lacks the required permission bits.

Always use BaseHook and explicitly declare permissions at deployment:

// SECURE: Explicit permission declaration via BaseHook override

contract SecureSwapHook is BaseHook {
    constructor(IPoolManager _manager) BaseHook(_manager) {}

    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: true,
            beforeDonate: false,
            afterDonate: false,
            beforeSwapReturnDelta: false,
            afterSwapReturnDelta: true,  // Must match address bits
            beforeAddLiquidityReturnDelta: false,
            afterAddLiquidityReturnDelta: false
        });
    }
}

The AFTER_SWAP_RETURNS_DELTA_FLAG is a particularly common misconfiguration. The absence of the AFTER_SWAP_RETURNS_DELTA_FLAG permission when a protocol fee is intended to be taken in the afterSwap() hook results in a denial-of-service of all swaps once the protocol fee is enabled.


2. Hook Callback Reentrancy into the Pool

The very nature of hooks reintroduces the risk of reentrancy attacks, a class of vulnerability that was largely solved in the core contracts of V2 and V3. Although Uniswap V4’s flash accounting mitigates some internal reentrancy risks, hooks can reintroduce them.

V4 includes a global reentrancy lock on the PoolManager. A key feature is the global reentrancy lock that prevents unauthorized reentrant calls into sensitive functions. However, this lock does not protect against hook-internal reentrancy — a hook calling an external contract that re-enters the hook itself.

Consider a reward-distribution hook that violates CEI (Checks-Effects-Interactions):

// VULNERABLE: Reentrancy via ERC-777 callback or malicious reward token
contract RewardHook is BaseHook {
    IERC20 public rewardToken;
    uint256 public totalRewardsDistributed;

    function afterSwap(
        address sender,
        PoolKey calldata key,
        IPoolManager.SwapParams calldata params,
        BalanceDelta delta,
        bytes calldata hookData
    ) external override onlyPoolManager returns (bytes4, int128) {
        uint256 reward = _calculateReward(delta);

        // DANGER: external call before state update
        // If rewardToken has a transfer callback (ERC-777),
        // attacker re-enters afterSwap before totalRewardsDistributed
        // is updated, draining the reward pool.
        rewardToken.transfer(sender, reward);

        // State update AFTER external call — too late
        totalRewardsDistributed += reward;

        return (this.afterSwap.selector, 0);
    }
}

If rewardToken is a malicious ERC-777 or has a callback, the attacker can reenter afterSwap before totalRewardsDistributed is updated, claiming rewards multiple times.

The fix is a pull-payment pattern — accumulate state first, transfer later:

// SECURE: Pull pattern — no external calls inside the hook callback
contract SecureRewardHook is BaseHook {
    IERC20 public rewardToken;
    mapping(address => uint256) public pendingRewards;
    uint256 public totalRewardsDistributed;

    function afterSwap(
        address sender,
        PoolKey calldata key,
        IPoolManager.SwapParams calldata params,
        BalanceDelta delta,
        bytes calldata hookData
    ) external override onlyPoolManager returns (bytes4, int128) {
        // Effect: update state first
        uint256 reward = _calculateReward(delta);
        pendingRewards[sender] += reward;
        totalRewardsDistributed += reward;

        return (this.afterSwap.selector, 0);
    }

    // Interaction: user pulls their own reward in a separate tx
    function claimReward() external nonReentrant {
        uint256 owed = pendingRewards[msg.sender];
        require(owed > 0, "Nothing to claim");
        pendingRewards[msg.sender] = 0;
        rewardToken.transfer(msg.sender, owed);
    }
}

Pool-Registration Reentrancy

A subtler form of reentrancy exploits V4’s open pool creation. Uniswap V4 does not restrict who can create new liquidity pools, or which hook address to use in a new liquidity pool. If a hook is not restricted to a specific pool or set of pools, an attacker could deploy a malicious pool with fake tokens and use/abuse the hook through attack vectors such as reentrancy or manipulation of the internal accounting.

Defend with pool-key validation in afterInitialize:

// SECURE: Whitelist the pool key on initialization
contract PoolBoundHook is BaseHook {
    PoolKey public registeredKey;
    bool public initialized;

    function afterInitialize(
        address,
        PoolKey calldata key,
        uint160,
        int24,
        bytes calldata
    ) external override onlyPoolManager returns (bytes4) {
        require(!initialized, "Hook: already initialized");
        require(
            Currency.unwrap(key.currency0) == address(TOKEN_A) &&
            Currency.unwrap(key.currency1) == address(TOKEN_B),
            "Hook: unauthorized token pair"
        );
        registeredKey = key;
        initialized = true;
        return BaseHook.afterInitialize.selector;
    }

    modifier onlyRegisteredPool(PoolKey calldata key) {
        require(
            PoolId.unwrap(key.toId()) ==
            PoolId.unwrap(registeredKey.toId()),
            "Hook: wrong pool"
        );
        _;
    }
}

3. The beforeSwap and afterSwap Manipulation Surface

The two hook calls have return values that potentially impact the logic of the swap. The beforeSwap function call returns an lpFeeOverride that can affect swap fees, and the afterSwap function returns a hookDelta that can affect the distribution of currency deltas between the msg.sender and the hook address.

This bidirectional delta channel is one of the richest attack surfaces in V4.

Unprotected Direct Calls (CVE Pattern: Cork Protocol)

Anyone can call the beforeSwap() function directly with arbitrary function arguments, including the swapper address encoded in the data field. Given that users need to approve the hook, anyone can force another user to transfer their tokens to the hook contract. The intended design should be to only let users trigger the transaction when they intend to do so, and only the PoolManager contract should have the ability to call the beforeSwap() function.

An important vulnerability in the CorkHook contract was a critical oversight directly echoing a common pitfall warned about by many security researchers. Cork’s Uniswap hooks were called by the attacker’s smart contract directly, mid-transaction.

// VULNERABLE: No access control on hook callbacks
contract VulnerableHook is IHooks {
    function beforeSwap(
        address sender,
        PoolKey calldata key,
        IPoolManager.SwapParams calldata params,
        bytes calldata hookData
    ) external returns (bytes4, BeforeSwapDelta, uint24) {
        // ANYONE can call this — including an attacker
        // The sender parameter is entirely caller-controlled
        _processSwap(sender, key, params, hookData);
        return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
    }
}

// SECURE: Restrict to PoolManager only
contract SecureHook is BaseHook {
    // BaseHook already provides onlyPoolManager via SafeCallback
    // All overridden callbacks are automatically guarded

    function beforeSwap(
        address sender,
        PoolKey calldata key,
        IPoolManager.SwapParams calldata params,
        bytes calldata hookData
    ) external override onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) {
        // sender here is the Router / user as passed by PoolManager
        // This is still manipulable — do not use sender as an auth key
        return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
    }
}

A real audit finding — C-06 in Guardian Audits’ review of Gamma UniV4 Limit Orders — found that insufficient access control allowed any address to call beforeSwap() and afterSwap(), undermining the core limit order mechanism.

Asymmetric Swap Handling

Hooks must handle both exact-input and exact-output swaps symmetrically across beforeSwap and afterSwap callbacks. Asymmetric implementations create arbitrage opportunities where attackers can exploit differences in swap direction handling to extract value from the protocol.

// VULNERABLE: Asymmetric delta handling creates arbitrage
function beforeSwap(
    address sender,
    PoolKey calldata key,
    IPoolManager.SwapParams calldata params,
    bytes calldata hookData
) external override onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) {
    // Fee only applied to zeroForOne, not the reverse
    // An attacker exploits the free direction repeatedly
    if (params.zeroForOne) {
        int128 fee = int128(int256(params.amountSpecified) / 1000); // 0.1%
        BeforeSwapDelta delta = toBeforeSwapDelta(-fee, 0);
        return (IHooks.beforeSwap.selector, delta, 0);
    }
    // No fee on reverse direction — exploitable
    return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
}

Always validate that fee and delta logic is symmetric across zeroForOne == true and zeroForOne == false, and across exact-input vs. exact-output.


4. donate() Hook Exploitation

Donate hooks provide a way to customize the behavior of token donations to liquidity providers. The donate() function sends tokens directly to in-range liquidity providers proportionally, bypassing the normal swap path. This creates a distinct set of risks.

Unwanted Fee-Growth Inflation

A simple but dangerous example: when business logic determines it is not desirable to allow donations, without activating the beforeDonate() hook to always revert on donations, calls to PoolManager::donate would succeed in increasing the fee growth.

If your hook tracks fee growth internally for accounting purposes, an attacker can call donate() to inflate the fee growth accumulators and cause your hook to mis-account balances:

// VULNERABLE: Hook does not block donations, causing inflated fee accounting
contract OracleHook is BaseHook {
    uint256 internal feeGrowthSnapshot;

    function afterSwap(...) external override onlyPoolManager returns (...) {
        // Reads PoolManager fee growth — manipulable via donate()
        // if beforeDonate is not guarded
        feeGrowthSnapshot = _readPoolFeeGrowth(key);
        return (this.afterSwap.selector, 0);
    }
    // Missing: beforeDonate() to block unwanted donations
}

// SECURE: Block donations if not part of intended design
contract SecureOracleHook is BaseHook {
    function beforeDonate(
        address,
        PoolKey calldata,
        uint256,
        uint256,
        bytes calldata
    ) external override onlyPoolManager returns (bytes4) {
        revert("Donations not permitted");
    }
}

When a hook uses pool price or fee-growth data as an oracle, the donate() function can be used in combination with a flash loan to momentarily inflate liquidity rewards without a trade, skewing TWAP or internal accounting:

// ATTACK PATTERN (pseudocode):
// 1. Flash loan large amounts of token0 and token1
// 2. Call PoolManager.donate(key, largeAmount0, largeAmount1, "")
//    → fee growth spikes in current block
// 3. Hook reading feeGrowth as a price signal is manipulated
// 4. Repay flash loan
// The TWAP-based oracle is now temporarily distorted

Mitigations: use TWAP over sufficiently long windows, treat feeGrowthGlobal values as manipulable, and always activate beforeDonate to control or reject donations.


5. Dynamic Fee Manipulation

V4 introduced first-class support for pools whose fee changes per-swap. The beforeSwap function call returns an lpFeeOverride that can affect swap fees. This is powerful for volatility-responsive AMMs, but the power is also a risk.

Centralized Fee Extraction

If an attacker gains control of the owner address, they could set swap fees to 100%, effectively locking user funds in the pool.

// VULNERABLE: Owner-controlled fee with no cap or timelock
contract CentralizedFeeHook is BaseHook, Ownable {
    uint24 public swapFee; // no upper bound

    function setSwapFee(uint24 newFee) external onlyOwner {
        swapFee = newFee; // can be set to 1_000_000 = 100%
    }

    function beforeSwap(
        address,
        PoolKey calldata,
        IPoolManager.SwapParams calldata,
        bytes calldata
    ) external override onlyPoolManager returns (bytes4, BeforeSwapDelta, uint24) {
        return (
            IHooks.beforeSwap.selector,
            BeforeSwapDeltaLibrary.ZERO_DELTA,
            swapFee // passes to PoolManager as override
        );
    }
}

Can dynamic swap fees be set manually or adjusted by an actor other than the protocol administrator? Are reasonable bounds applied to prevent manipulation and loss to other fee recipients? These are the two questions auditors must answer.

The secure approach enforces an immutable cap and uses governance delay:

// SECURE: Fee bounded and governed with a timelock
contract BoundedDynamicFeeHook is BaseHook {
    uint24 public constant MAX_FEE = 10_000; // 1% hard cap
    uint24 public swapFee = 3_000; // default 0.3%
    address public governance;
    uint256 public feeChangeDelay = 2 days;

    struct PendingFeeChange {
        uint24 newFee;
        uint256 unlockTime;
    }
    PendingFeeChange public pendingChange;

    function proposeFeeChange(uint24 newFee) external {
        require(msg.sender == governance, "Not governance");
        require(newFee <= MAX_FEE, "Fee exceeds cap");
        pendingChange = PendingFeeChange({
            newFee: newFee,
            unlockTime: block.timestamp + feeChangeDelay
        });
    }

    function executeFeeChange() external {
        require(block.timestamp >= pendingChange.unlockTime, "Timelock active");
        swapFee = pendingChange.newFee;
        delete pendingChange;
    }
}

Dynamic Fee Overflow and Underflow

The calculation of dynamic fees can underflow, overflow, or produce a value over 100%. All fee arithmetic must be performed in checked math with explicit range validation, especially when fee values are derived from external oracle data (e.g., implied volatility feeds).

// VULNERABLE: Unchecked fee math from external oracle
function _computeVolatilityFee(address pair) internal view returns (uint24) {
    uint256 iv = IVolOracle(oracle).getIV(pair); // could be manipulated
    // Unchecked multiplication — can overflow uint24
    return uint24(iv * 100);
}

// SECURE: Clamp and validate
function _computeVolatilityFee(address pair) internal view returns (uint24) {
    uint256 iv = IVolOracle(oracle).getIV(pair);
    uint256 fee = iv * 100;
    if (fee > MAX_FEE) return MAX_FEE;
    return uint24(fee);
}

6. Hook Upgrade Patterns and Their Risks

Since hooks are external contracts, they may be upgradeable, centrally controlled, or have privileged roles that can modify key protocol parameters. These factors introduce risks that could allow a single entity to manipulate swap fees, pause trading, or extract liquidity from users.

The Proxy Trap

If a hook inherits from an upgradeable proxy pattern (e.g., UUPSUpgradeable), its logic can be modified after deployment, potentially introducing vulnerabilities or backdoors that did not exist at the time of audit.

The combination of upgradeability and the permission-bitmap-in-address design creates a unique class of vulnerability: the address is immutable (and its permission bits are fixed), but the logic at that address can be completely replaced via upgradeTo().

// DANGEROUS PATTERN: UUPS hook holding user funds
contract UpgradeableHook is UUPSUpgradeable, BaseHook {
    // Permissions encoded in deployed address
    // But logic is entirely replaceable by upgradeAdmin

    function _authorizeUpgrade(address) internal override onlyOwner {}

    // After upgrade, a malicious implementation could:
    // 1. Return fraudulent hookDeltas draining LPs
    // 2. Redirect fee flows to attacker address
    // 3. Block all removeLiquidity calls
}

For example, if an upgradeable hook holds user funds, the address with the upgrade authority is capable of injecting a malicious withdrawal function via contract upgrade.

If a hook has a privileged owner or is upgradeable via a proxy, this introduces a significant centralization risk that must be secured by robust governance mechanisms, such as a timelock or a multi-signature wallet.

The recommendation from Composable Security: build immutable hooks (no upgradeability). If upgradeability is a business requirement, the upgrade path must be protected by a multi-sig and a minimum timelock that gives liquidity providers time to exit.

Reinitialization Attacks

Hook reinitialization — the reinitialization of the hook through a different pool — can break the current state of the hook.

// VULNERABLE: Missing initialization guard
contract ReinitializableHook is BaseHook {
    address public token0;
    address public token1;

    function afterInitialize(
        address,
        PoolKey calldata key,
        uint160,
        int24,
        bytes calldata
    ) external override onlyPoolManager returns (bytes4) {
        // No check: can be called with a malicious pool key
        // Attacker creates a new pool pointing at this hook
        // and overwrites token0/token1 state
        token0 = Currency.unwrap(key.currency0);
        token1 = Currency.unwrap(key.currency1);
        return BaseHook.afterInitialize.selector;
    }
}

// SECURE: Initialization guard
contract SecureInitHook is BaseHook {
    bool private _initialized;
    address public token0;
    address public token1;

    function afterInitialize(
        address,
        PoolKey calldata key,
        uint160,
        int24,
        bytes calldata
    ) external override onlyPoolManager returns (bytes4) {
        require(!_initialized, "Hook: already initialized");
        token0 = Currency.unwrap(key.currency0);
        token1 = Currency.unwrap(key.currency1);
        _initialized = true;
        return BaseHook.afterInitialize.selector;
    }
}

7. Malicious Hooks in Composable Protocols

A hook is arbitrary code. A malicious hook could be designed to siphon a percentage of a swap, front-run its own users, or block withdrawals under certain conditions.

BlockSec’s threat model “Thorns in the Rose: Exploring Security Risks in Uniswap v4’s Novel Hook Mechanism” illustrates that hooks can be either ‘benign but vulnerable’ or ‘intentionally malicious.’ When hooks are composed with other DeFi protocols — lending, yield aggregators, options — the blast radius expands dramatically.

Cross-Pool State Contamination

When designing hooks that support multiple pools, developers must implement strict state isolation to prevent cross-pool contamination. Without proper separation, malicious pools can overwrite or corrupt state variables belonging to legitimate pools, leading to accounting errors, fund misallocation, or complete protocol breakdown. Each pool must maintain its own dedicated storage space within the hook contract, with clear boundaries preventing unauthorized access to other pools’ data.

// VULNERABLE: Shared state across pools
contract MultiPoolHook is BaseHook {
    uint256 public totalVolume; // accumulates from ALL pools
    mapping(address => uint256) public userVolume;

    function afterSwap(
        address sender,
        PoolKey calldata key,
        IPoolManager.SwapParams calldata params,
        BalanceDelta delta,
        bytes calldata
    ) external override onlyPoolManager returns (bytes4, int128) {
        // Attacker creates a malicious pool with fake high-volume tokens
        // and inflates totalVolume / userVolume for any address
        uint256 vol = _absoluteAmount(delta);
        totalVolume += vol;
        userVolume[sender] += vol;
        return (this.afterSwap.selector, 0);
    }
}

// SECURE: Pool-keyed state isolation
contract IsolatedMultiPoolHook is BaseHook {
    mapping(PoolId => uint256) public poolVolume;
    mapping(PoolId => mapping(address => uint256)) public userPoolVolume;
    mapping(PoolId => bool) public registeredPools;

    function afterSwap(
        address sender,
        PoolKey calldata key,
        IPoolManager.SwapParams calldata params,
        BalanceDelta delta,
        bytes calldata
    ) external override onlyPoolManager returns (bytes4, int128) {
        PoolId id = key.toId();
        require(registeredPools[id], "Hook: unregistered pool");
        uint256 vol = _absoluteAmount(delta);
        poolVolume[id] += vol;
        userPoolVolume[id][sender] += vol;
        return (this.afterSwap.selector, 0);
    }
}

TWAP Oracle Manipulation via Hooks

A scenario described in audit findings shows that a malicious manager can manipulate the TWAP (time-weighted average price) while avoiding arbitrage. If the oracle is used to determine asset prices in an external lending protocol, it is possible for the manager to exploit this by borrowing against overvalued collateral.

Any hook that writes oracle data into storage during a swap callback is at risk from this pattern. Flash-loan-assisted price manipulation combined with hook callback timing can produce a skewed TWAP snapshot within a single block.

The ERC-6909 Accounting Attack

Custom accounting is a powerful feature of Uniswap V4. At the same time, it can easily put entire protocol liquidity at risk. Unlike vanilla hooks, those that leverage custom accounting take control of the underlying liquidity, and any bug in the business logic of these hooks, or other related contracts that store or handle ERC-6909 claim tokens, is likely to be catastrophic.

High-impact issues such as C-05 demonstrate how a mismatch between raw ERC-20 and ERC-6909 accounting can be abused to drain the underlying pool currency. An exploit is predicated on utilizing the LP token of one pool stored in the target contract as rent payments, as a currency of the recursive malicious pool.


8. Auditing a V4 Integration vs. a V3 Integration

The security model shifts dramatically from trusting a single, monolithic protocol to a more complex, shared responsibility model. What this means practically for auditors is a fundamentally different scope and methodology.

What Changed at the Protocol Level

| Dimension | Uniswap V3 | Uniswap V4 |

DimensionUniswap V3Uniswap V4
Liquidity modelConcentrated, tick-basedConcentrated + singleton PoolManager
Pool statePer-pair contractsSingle PoolManager contract
CustomizationNoneHooks at every lifecycle point
Fee modelFixed tiersDynamic via hook
Flash accountingSeparate flash loansNative delta accounting

Uniswap V4 Hooks Security Audit Checklist

Hook callback safety

  • All hook callbacks follow CEI — no external calls before pool state is settled
  • Hook callbacks have nonReentrant or equivalent protection against re-entry into the PoolManager
  • No hook callback reads slot0 for pricing — pool state is mid-transition during callbacks

Permission minimization

  • Hook permissions are minimally scoped — only the callbacks needed for the hook’s function are enabled
  • The hook address encodes only the permissions it actually uses
  • No permission allows the hook to manipulate pool state in ways LPs did not explicitly consent to

Custom accounting and delta integrity

  • All flash deltas opened in an unlock callback are settled before the callback returns
  • Custom accounting overrides cannot produce free swaps (zero input, non-zero output)
  • Arithmetic in custom accounting is reviewed for overflow, rounding manipulation, and precision loss

Dynamic fee safety

  • Dynamic fee updates cannot be front-run to extract value from pending swaps
  • Fee changes are bounded — no hook can set fees above the protocol maximum
  • Fee logic is tested under extreme conditions: zero liquidity, single-tick range, max fee

Composability and upgrade

  • The hook does not assume ordering relative to other hooks on the same pool
  • If the hook is upgradeable, the upgrade path is access-controlled and timelocked
  • Hook state is isolated — it does not rely on external protocol state that could be manipulated