What is Uniswap V4: Customizable Hooks & Superior Efficiency

What is Uniswap V4: Customizable Hooks & Superior Efficiency

Danail Yordanov
20 min read

What is Uniswap V4: Customizable Hooks & Superior Efficiency


Introduction

There’s probably not a single person in DeFi who hasn’t heard of Uniswap. Since launching in 2017, Uniswap has gone through three versions, and now we’re excited to see the fourth. Uniswap V1 started as a basic automated market maker (AMM), where trades could only be made with ETH as one of the tokens in a pair. V2 improved this by allowing direct swaps between any two tokens. V3 brought concentrated liquidity, letting liquidity providers set specific price ranges for their funds, making the system more efficient.

Uniswap V4 introduces a new feature called "hooks," which are smart contracts that let developers customize how pools behave at different stages of a trade. Uniswap V4 hooks opens up many possibilities, from dynamic fees and on-chain limit orders to a system that spreads large trades over time.

The underlying architecture has also been improved, with all pools now housed in a single smart contract (singleton pattern), making the system more efficient and cheaper to use. With hooks and this new setup, Uniswap V4 offers more flexibility, speed, and security for customizing and processing trades across different pools.

1. What's New with Uniswap?

1.1 Singleton Pool

The most notable change in the new architecture is the use of the Singleton Pattern for creating pools. Previously, each pool was deployed as a new smart contract via a factory pattern, which made creating new pools and performing swaps significantly more expensive. In V4, all Uniswap pools are housed within the same contract (PoolManager), reducing gas costs significantly.reducing gas costs by 99% when creating pools.

1.2 Uniswap Hooks

Uniswap V4 introduces an innovative feature called "hooks," which function as plugins that can be added to liquidity pools. A Hook is essentially a smart contract with logic that executes when specific events occur. Events that trigger hooks in Uniswap are grouped into three categories: pool deployment, liquidity provision and withdrawal, and swaps. This defines eight different types of hooks, allowing for a high degree of customization.

Hooks enable pools to go beyond simple swaps by adding advanced features such as limit orders, MEV revenue sharing, and tools to optimize liquidity provider earnings. By leveraging these hooks, developers can build new functionalities on top of Uniswap V4, creating a more dynamic and flexible ecosystem while maintaining core platform efficiency.

1.3 Flash Accounting

Another feature is the "flash accounting" system, which allows users to efficiently chain multiple actions, like swapping and adding liquidity, into a single transaction by using EIP-1153 transient storage. Transient storage works like regular storage but automatically clears data at the end of each transaction. Since the data is erased after each transaction, it doesn't increase the storage burden on Ethereum clients, while consuming up to 20 times less gas (100 gas) than traditional storage operations. Uniswap V4 leverages this system to perform calculations and verifications more cheaply during transactions.

The flash accounting system tracks the net balances of tokens moving in and out during a transaction. Previously, each pool operation involved exchanging tokens between pools, but now with the Singleton architecture and the flash accounting, at the end of the process, the contract just ensures all balances are settled. If they aren't, the transaction is fully reverted, ensuring security and efficiency. This concept is similar to flash loans and is part of Uniswap's broader effort to lower gas costs and improve transaction efficiency.

In Uniswap V3:

  • ETH is sent to the ETH/USDC pool contract.
  • USDC is withdrawn from the ETH/USDC pool and transferred to the USDC/DAI pool contract.
  • DAI is then withdrawn from the USDC/DAI pool and sent to the user.

In Uniswap V4:

  • The swap() function is called on the ETH/USDC pool.
  • The swap() function is called on the USDC/DAI pool, using the credited USDC from the previous swap as the input.
  • The user settles the deltas by paying ETH and receiving DAI.

As a result, the step of calling transfer() on the USDC contract can be skipped entirely.

This optimization scales effectively, allowing for any number of hops while requiring only two token transfers—one for the input token and one for the output token.

1.4 Unlimited Fee Tiers

Uniswap V4 introduces unlimited fee tiers for liquidity pools, offering greater flexibility to accommodate a wide variety of assets and trading strategies. Each pool can have customized fee structures, allowing for a more tailored approach that meets the specific needs of different users.

1.5 Native ETH

Uniswap's new version improves the user experience by allowing direct trading pairs with native ETH, removing the need for Wrapped ETH (WETH). This simplification makes the trading process more straightforward and reduces transaction costs.

1.6 Liquidity Fee Accounting

Accrued fees function as a credit when modifying liquidity. When liquidity is increased, the fee revenue is converted into additional liquidity within the position. Conversely, reducing liquidity automatically triggers the withdrawal of any unclaimed fee revenue.

An optional parameter, called salt, can be provided when creating liquidity. The salt differentiates positions with the same range in the same pool, which can be useful for simplifying fee accounting. If two users share the same range and state in the PoolManager, integrating contracts must handle fee management carefully to avoid conflicts.

1.7 Subscribers

Owners can now assign a subscriber to their positions. A subscriber contract will be notified whenever there is a change in the position's liquidity or ownership. This feature enables staking and liquidity mining without requiring users to transfer their ERC-721 tokens.

1.8 ERC-6909

Uniswap V4 integrates ERC-6909 to enhance gas efficiency for token claims and redemptions.

ERC-6909 is a lightweight, gas-optimized standard designed for managing multiple ERC-20 tokens from a single contract. It provides a simplified alternative to the more complex ERC-1155 multi-token standard.

Rather than transferring ERC-20 tokens in and out of the PoolManager, users can keep their tokens in the contract and receive ERC-6909 tokens that represent their claim. When users need to make payments in future interactions, they can simply burn their claim tokens instead of making actual ERC-20 transfers.

Traditional ERC-20 token transfers require external smart contract calls, which create gas overhead compared to internal accounting. These external contracts often include their own custom logic in their transfer functions—like USDC's blocked-address list—adding even more gas costs.

1.9 Dynamic Fees

Uniswap V4 introduces support for dynamic fees, enabling pools to adjust their fees either up or down. Unlike other AMMs that may enforce predefined logic for fee adjustments, V4 leaves the fee calculation entirely flexible, allowing developers to define their own logic. The frequency of fee updates is also customizable, ranging from updates on every swap or block to any arbitrary schedule, such as weekly, monthly, or even yearly. Dynamic fees are a type of swap fee that directly accrues to liquidity providers. They are also distinct from protocol fees and hook fees. The use of dynamic fees for a pool is decided at the time of its creation and cannot be altered later. This means that once a pool is set up to use dynamic fees, that choice is permanent.

2. Architecture

Most operations related to pool logic are managed by invoking the Pool library. A library functions like a contract but is deployed once at a fixed address and accessed repeatedly using DELEGATECALL. Centralizing core logic in the library improves readability and ensures consistency throughout the system.

2.1 Repository Structure

All contracts are located in the v4-core/src folder.

Note that helper contracts used for testing are stored in the v4-core/src/test subfolder within the src folder. Any new test helper contracts should be added here, while all Foundry tests are located in the v4-core/test folder.

1src/
2----interfaces/
3    | IPoolManager.sol
4    | ...
5----libraries/
6    | Position.sol
7    | Pool.sol
8    | ...
9----test
10----PoolManager.sol
11...
12test/
13----libraries/
14    | Position.t.sol
15    | Pool.t.sol

2.2 Structs

Before heading to the core contracts, it’s good to explain some common structs that appear throughout the codebase:

  • PoolKey (PoolManager.sol): This struct is used in the PoolManager contract to store the essential information that distinguishes one pool from another. It includes the two tokens in the pair, the LP fee, the tick spacing, and an array of hooks. This allows two pools to have the same tokens and fee, but different hooks, making them entirely separate pools, unlike in Uniswap V3. The PoolKey struct is hashed to generate a unique pool ID, which is used to differentiate each pool. Unlike Uniswap V3, which limited pool fees to 0.05%, 0.3%, and 1%, Uniswap V4 imposes no such restrictions, allowing for countless pools with the same configuration but different fees.
1struct PoolKey {
2    /// @notice The lower currency of the pool, sorted numerically
3    Currency currency0;
4    /// @notice The higher currency of the pool, sorted numerically
5    Currency currency1;
6    /// @notice The pool LP fee, capped at 1_000_000. If the highest bit is 1, the pool has a dynamic fee and must be exactly equal to 0x800000
7    uint24 fee;
8    /// @notice Ticks that involve positions must be a multiple of tick spacing
9    int24 tickSpacing;
10    /// @notice The hooks of the pool
11    IHooks hooks;
12}
  • Slot0 (libraries/Pool.sol): This struct contains the fee information of a pool, unlike Uniswap V3, which contained oracle information and the reentrancy lock. The struct includes the sqrtPriceX96 and the current tick, which were also present in V3, but now also contains the LP fee and the protocol fee, expressed in hundredths of a bip, which was enabled through governance for V4.
  • State (libraries/Pool.sol): This struct represents the state of a pool, including all positions, fee information from the Slot0 struct, current liquidity, accrued LP fees, crossed ticks, and data for each individually initialized tick.
1struct State {
2    Slot0 slot0;
3    uint256 feeGrowthGlobal0X128;
4    uint256 feeGrowthGlobal1X128;
5    uint128 liquidity;
6    mapping(int24 tick => TickInfo) ticks;
7    mapping(int16 wordPos => uint256) tickBitmap;
8    mapping(bytes32 positionKey => Position.State) positions;
9}

2.3 PoolManager.sol

This contract manages the state for all pools, ensuring that no outstanding token balances exist between users and pools. It does this by first calculating any debts and then settling them. The functions within this contract fall into two categories:

  1. Core calculation methods — responsible for performing key operations such as _initialize_, _swap_, _modifyPosition_, and _donate_.
  2. Settlement methods — which handle the execution of calculated results and the actual token transfers, including _settle_, _take_, and _lock_.
2.3.1 initialize() - Pool Creation

For a pool to be created, the _pools mapping needs to be populated with a pool ID as the key and the pool state as the value:

1mapping(PoolId id => Pool.State) internal _pools;

The entry point is the PoolManager's initialize() function, which acts as the interface for pool creation. It accepts a PoolKey struct, an initial price (sqrtPriceX96), and optional hook data, essentially setting up the fundamental characteristics of the pool.

The function starts with input validation, checking if the tick spacing is within valid bounds, verifying that the two tokens are sorted correctly, and ensuring the hook addresses in the PoolKey struct are valid using isValidHookAddress().

1function initialize(PoolKey memory key, uint160 sqrtPriceX96, bytes calldata hookData)
2	external
3	noDelegateCall
4	returns (int24 tick)
5{
6	// see TickBitmap.sol for overflow conditions that can arise from tick spacing being too large
7	if (key.tickSpacing > MAX_TICK_SPACING) TickSpacingTooLarge.selector.revertWith(key.tickSpacing);
8	if (key.tickSpacing < MIN_TICK_SPACING) TickSpacingTooSmall.selector.revertWith(key.tickSpacing);
9	if (key.currency0 >= key.currency1) {
10	    CurrenciesOutOfOrderOrEqual.selector.revertWith(
11	        Currency.unwrap(key.currency0), Currency.unwrap(key.currency1)
12	    );
13	}
14	if (!key.hooks.isValidHookAddress(key.fee)) Hooks.HookAddressNotValid.selector.revertWith(address(key.hooks));
15	...

Next, the function calculates the initial liquidity provider fee and the protocol fee.

1...
2uint24 lpFee = key.fee.getInitialLPFee();
3uint24 protocolFee = _fetchProtocolFee(key);
4...

Before initializing the pool, the function invokes the beforeInitialize hook, allowing for any custom logic developers want to execute before the pool is fully initialized.

1...
2key.hooks.beforeInitialize(key, sqrtPriceX96, hookData);
3...

The pool ID is then retrieved and added to the _pools mapping. After that, the initialize() function from the Pool library is called on the Pool struct, which populates the slot0 struct in the pool state.

1...
2PoolId id = key.toId();
3
4tick = _pools[id].initialize(sqrtPriceX96, protocolFee, lpFee);
5...

libraries/Pool.sol

1function initialize(State storage self, uint160 sqrtPriceX96, uint24 protocolFee, uint24 lpFee)
2	internal
3	returns (int24 tick)
4{
5	if (self.slot0.sqrtPriceX96() != 0) PoolAlreadyInitialized.selector.revertWith();
6
7	tick = TickMath.getTickAtSqrtPrice(sqrtPriceX96);
8
9	self.slot0 = Slot0.wrap(bytes32(0)).setSqrtPriceX96(sqrtPriceX96).setTick(tick).setProtocolFee(protocolFee)
10		.setLpFee(lpFee);
11}

Lastly, the afterInitialize hook is called, allowing arbitrary logic to be executed after the pool creation.

1...
2key.hooks.afterInitialize(key, sqrtPriceX96, tick, hookData);
3...

Upon success, the transaction announces the creation of a new pool by emitting an Initialize event. Unlike Uniswap V3, where a new contract is deployed for each newly created pool, the process in Uniswap V4 is significantly simplified by just creating a record in the mapping.

PoolManagerInitialize.t.sol is a test suite that demonstrates the initialization process and verifies its functionality.

2.3.2 swap() - Swapping

The swap() function from the PoolManager is the entry point for executing a swap.

First, input validation is performed:

1function swap(PoolKey memory key, IPoolManager.SwapParams memory params, bytes calldata hookData)
2    external
3    onlyWhenUnlocked
4    noDelegateCall
5    returns (BalanceDelta swapDelta)
6{
7    if (params.amountSpecified == 0) SwapAmountCannotBeZero.selector.revertWith();
8    PoolId id = key.toId();
9    Pool.State storage pool = _getPool(id);
10    pool.checkPoolInitialized();
11    ...

Next, the beforeSwap hook is triggered:

1...
2BeforeSwapDelta beforeSwapDelta;
3{
4    int256 amountToSwap;
5    uint24 lpFeeOverride;
6    (amountToSwap, beforeSwapDelta, lpFeeOverride) = key.hooks.beforeSwap(key, params, hookData);
7...

Then, the actual swap calculations and core functionality are executed inside the Pool library's swap() function:

1...
2// execute swap, account protocol fees, and emit swap event
3// _swap is needed to avoid stack too deep error
4swapDelta = _swap(
5    pool,
6    id,
7    Pool.SwapParams({
8        tickSpacing: key.tickSpacing,
9        zeroForOne: params.zeroForOne,
10        amountSpecified: amountToSwap,
11        sqrtPriceLimitX96: params.sqrtPriceLimitX96,
12        lpFeeOverride: lpFeeOverride
13    }),
14    params.zeroForOne ? key.currency0 : key.currency1 // input token
15);
16
17...
18
19function _swap(Pool.State storage pool, PoolId id, Pool.SwapParams memory params, Currency inputCurrency)
20    internal
21    returns (BalanceDelta)
22{
23    (BalanceDelta delta, uint256 amountToProtocol, uint24 swapFee, Pool.SwapResult memory result) =
24        pool.swap(params);
25
26    // the fee is on the input currency
27    if (amountToProtocol > 0) _updateProtocolFees(inputCurrency, amountToProtocol);
28
29    // event is emitted before the afterSwap call to ensure events are always emitted in order
30    emit Swap(
31        id,
32        msg.sender,
33        delta.amount0(),
34        delta.amount1(),
35        result.sqrtPriceX96,
36        result.liquidity,
37        result.tick,
38        swapFee
39    );
40
41    return delta;
42}

Afterwards, the afterSwap hook is triggered:

1...
2BalanceDelta hookDelta;
3(swapDelta, hookDelta) = key.hooks.afterSwap(key, params, swapDelta, hookData, beforeSwapDelta);
4...

Finally, the pool balance delta is applied, accounting for the token changes within the pool:

1...
2// if the hook doesn't have the flag to return deltas, hookDelta will always be 0
3if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) _accountPoolBalanceDelta(key, hookDelta, address(key.hooks));
4
5_accountPoolBalanceDelta(key, swapDelta, msg.sender);
6}

As in V3, the swap() function in the Pool library performs calculations until the swap is complete (using a while loop). The swap ends under two conditions:

  1. The swap input amount is fully exhausted (swap completion).
  2. The set price limit (slippage limit) is reached.

The key difference in V4 is that, upon swap completion, a delta value is returned. The delta represents the change in the pool’s balnce due to operations such as swaps or liquidity provisioning.

1function _accountPoolBalanceDelta(PoolKey memory key, BalanceDelta delta, address target) internal {
2    _accountDelta(key.currency0, delta.amount0(), target);
3    _accountDelta(key.currency1, delta.amount1(), target);
4}
5...
6
7function _accountDelta(Currency currency, int128 delta, address target) internal {
8    if (delta == 0) return;
9
10    (int256 previous, int256 next) = currency.applyDelta(target, delta);
11
12    if (next == 0) {
13        NonzeroDeltaCount.decrement();
14    } else if (previous == 0) {
15        NonzeroDeltaCount.increment();
16    }
17}

The CurrencyDelta and NonzeroDeltaCount libraries manage the caller’s currency deltas in transient storage:

1library CurrencyDelta {
2    /// @notice calculates which storage slot a delta should be stored in for a given account and currency
3    function _computeSlot(address target, Currency currency) internal pure returns (bytes32 hashSlot) {
4        assembly ("memory-safe") {
5            mstore(0, and(target, 0xffffffffffffffffffffffffffffffffffffffff))
6            mstore(32, and(currency, 0xffffffffffffffffffffffffffffffffffffffff))
7            hashSlot := keccak256(0, 64)
8        }
9    }
10
11    function getDelta(Currency currency, address target) internal view returns (int256 delta) {
12        bytes32 hashSlot = _computeSlot(target, currency);
13        assembly ("memory-safe") {
14            delta := tload(hashSlot)
15        }
16    }
17
18    /// @notice applies a new currency delta for a given account and currency
19    /// @return previous The prior value
20    /// @return next The modified result
21    function applyDelta(Currency currency, address target, int128 delta)
22        internal
23        returns (int256 previous, int256 next)
24    {
25        bytes32 hashSlot = _computeSlot(target, currency);
26
27        assembly ("memory-safe") {
28            previous := tload(hashSlot)
29        }
30        next = previous + delta;
31        assembly ("memory-safe") {
32            tstore(hashSlot, next)
33        }
34    }
35}
36
1library NonzeroDeltaCount {
2    // The slot holding the number of nonzero deltas. bytes32(uint256(keccak256("NonzeroDeltaCount")) - 1)
3    bytes32 internal constant NONZERO_DELTA_COUNT_SLOT =
4        0x7d4b3164c6e45b97e7d87b7125a44c5828d005af88f9d751cfd78729c5d99a0b;
5
6    function read() internal view returns (uint256 count) {
7        assembly ("memory-safe") {
8            count := tload(NONZERO_DELTA_COUNT_SLOT)
9        }
10    }
11
12    function increment() internal {
13        assembly ("memory-safe") {
14            let count := tload(NONZERO_DELTA_COUNT_SLOT)
15            count := add(count, 1)
16            tstore(NONZERO_DELTA_COUNT_SLOT, count)
17        }
18    }
19
20    /// @notice Potential to underflow. Ensure checks are performed by integrating contracts to ensure this does not happen.
21    /// Current usage ensures this will not happen because we call decrement with known boundaries (only up to the number of times we call increment).
22    function decrement() internal {
23        assembly ("memory-safe") {
24            let count := tload(NONZERO_DELTA_COUNT_SLOT)
25            count := sub(count, 1)
26            tstore(NONZERO_DELTA_COUNT_SLOT, count)
27        }
28    }
29}
30

In these libraries, previous represents the prior balance of a currency, and next is the new result (previous + delta). If the modified value equals zero, the user’s delta is decremented, effectively settling the balance within the pool via the settle() function. Conversely, if the previous balance was zero, the user receives the corresponding currency from the pool through the take() function.

2.3.3 modifyLiquidity() - Liquidity Provision

Similar to how swaps work, providing liquidity is done via the modifyLiquidity() function.

The function starts with input validation:

1function modifyLiquidity(
2    PoolKey memory key,
3    IPoolManager.ModifyLiquidityParams memory params,
4    bytes calldata hookData
5) external onlyWhenUnlocked noDelegateCall returns (BalanceDelta callerDelta, BalanceDelta feesAccrued) {
6    PoolId id = key.toId();
7    {
8        Pool.State storage pool = _getPool(id);
9        pool.checkPoolInitialized();
10        ...

Next, the beforeModifyLiquidity hook is triggered:

1...
2key.hooks.beforeModifyLiquidity(key, params, hookData);
3...

The core calculations for providing liquidity are handled in the Pool library's modifyLiquidity() function. This function activates (flips) a specific tick, returns and updates the accumulated fees for the liquidity provider (LP), and returns the delta.

1...
2BalanceDelta principalDelta;
3(principalDelta, feesAccrued) = pool.modifyLiquidity(
4    Pool.ModifyLiquidityParams({
5        owner: msg.sender,
6        tickLower: params.tickLower,
7        tickUpper: params.tickUpper,
8        liquidityDelta: params.liquidityDelta.toInt128(),
9        tickSpacing: key.tickSpacing,
10        salt: params.salt
11    })
12);
13
14// fee delta and principal delta are both accrued to the caller
15callerDelta = principalDelta + feesAccrued;
16...

The afterModifyLiquidity hook is triggered, and the ModifyLiquidity event is emitted:

1...
2// event is emitted before the afterModifyLiquidity call to ensure events are always emitted in order
3emit ModifyLiquidity(id, msg.sender, params.tickLower, params.tickUpper, params.liquidityDelta, params.salt);
4
5BalanceDelta hookDelta;
6(callerDelta, hookDelta) = key.hooks.afterModifyLiquidity(key, params, callerDelta, feesAccrued, hookData);
7...
8

Finally, similar to the swap() function, the delta is accounted for via the _accountPoolBalanceDelta() function and settled through the settle() and take() functions.

1...
2// if the hook doesnt have the flag to be able to return deltas, hookDelta will always be 0
3if (hookDelta != BalanceDeltaLibrary.ZERO_DELTA) _accountPoolBalanceDelta(key, hookDelta, address(key.hooks));
4
5_accountPoolBalanceDelta(key, callerDelta, msg.sender);
6}
2.3.4 settle() - Settling user’s debt

The settle() function is where the user repays the pool what is owed. This function is called after the user transfers the owed amount to the PoolManager. The contract then checks the previously saved currency reserves and compares them with the new balances to verify whether the owed amount has been fully repaid.

The function retrieves the synced currency and reserves from transient storage via the CurrencyReserves library. It then gets the current balance, calculates the delta between the two, and updates the pool’s state using _accountDelta().

1function settle() external payable onlyWhenUnlocked returns (uint256) {
2    return _settle(msg.sender);
3}
4
5...
6
7function _settle(address recipient) internal returns (uint256 paid) {
8    Currency currency = CurrencyReserves.getSyncedCurrency();
9
10    // if not previously synced, or the syncedCurrency slot has been reset, expects native currency to be settled
11    if (currency.isAddressZero()) {
12        paid = msg.value;
13    } else {
14        if (msg.value > 0) NonzeroNativeValue.selector.revertWith();
15        // Reserves are guaranteed to be set because currency and reserves are always set together
16        uint256 reservesBefore = CurrencyReserves.getSyncedReserves();
17        uint256 reservesNow = currency.balanceOfSelf();
18        paid = reservesNow - reservesBefore;
19        CurrencyReserves.resetCurrency();
20    }
21
22    _accountDelta(currency, paid.toInt128(), recipient);
23}
1library CurrencyReserves {
2    using CustomRevert for bytes4;
3
4    /// bytes32(uint256(keccak256("ReservesOf")) - 1)
5    bytes32 constant RESERVES_OF_SLOT = 0x1e0745a7db1623981f0b2a5d4232364c00787266eb75ad546f190e6cebe9bd95;
6    /// bytes32(uint256(keccak256("Currency")) - 1)
7    bytes32 constant CURRENCY_SLOT = 0x27e098c505d44ec3574004bca052aabf76bd35004c182099d8c575fb238593b9;
8
9    function getSyncedCurrency() internal view returns (Currency currency) {
10        assembly ("memory-safe") {
11            currency := tload(CURRENCY_SLOT)
12        }
13    }
14
15    function resetCurrency() internal {
16        assembly ("memory-safe") {
17            tstore(CURRENCY_SLOT, 0)
18        }
19    }
20
21    function syncCurrencyAndReserves(Currency currency, uint256 value) internal {
22        assembly ("memory-safe") {
23            tstore(CURRENCY_SLOT, and(currency, 0xffffffffffffffffffffffffffffffffffffffff))
24            tstore(RESERVES_OF_SLOT, value)
25        }
26    }
27
28    function getSyncedReserves() internal view returns (uint256 value) {
29        assembly ("memory-safe") {
30            value := tload(RESERVES_OF_SLOT)
31        }
32    }
33}

Example:

The pool has 10 ETH, and Bob owes 1 ETH to the pool. The getSyncedCurrency() function returns ETH, and getSyncedReserves() retrieves the last saved ETH reserves, which are stored in the reservesBefore variable. The currency.balanceOfSelf() function returns the current reserves, which should be 11 ETH after the transfer. The paid variable will then be calculated as 11 - 10 = 1 ETH. Afterward, the transient storage is reset, and the 1 ETH is accounted for in the pool's delta, clearing Bob’s debt to the pool.

2.3.5 take() - Transferring to user

The take() function is used to transfer the funds that the pool owes to the user. First, the funds are accounted for by subtracting the delta from the pool’s balance using the _accountDelta() function. Afterward, the owed amount is transferred to the user.

1function take(Currency currency, address to, uint256 amount) external onlyWhenUnlocked {
2    unchecked {
3        // negation must be safe as amount is not negative
4        _accountDelta(currency, -(amount.toInt128()), msg.sender);
5        currency.transfer(to, amount);
6    }
7}
2.3.6 unlock() - Flash Accounting

The new architecture uses flash accounting, meaning that the caller who unlocks the PoolManager is allowed to cause balance-changing operations (multiple liquidity modifications, swaps, etc) and only needs to perform the actual token transfers at the end of the sequence. This flow is managed through the Lock library, which uses transient storage to control the unlocking of functions. Every pool action starts with an initial call to the unlock() function, and integrators must implement the unlockCallback() before proceeding with any pool-related actions, such as swap, modifyLiquidity, donate, take, settle, mint, or burn.

Note that pool initialization can occur independently, outside of the context of unlocking the PoolManager.

During the unlock process, only the net balances owed to the user (positive) or to the pool (negative) are tracked using the delta field, which represents the outstanding balances. Multiple actions can be performed within the pools during an unlock, as long as the accumulated deltas resolve to zero by the time the unlock is released. This unlock-and-call architecture provides integrators with maximum flexibility when interacting with the core code.

The function begins by checking if the function is already unlocked. It then unlocks the function and calls the unlockCallback() on the msg.sender, where the caller performs all necessary actions within the callback, including repaying what they owe via calls to settle(). Afterward, it checks if any delta is still owed. If so, the transaction is reverted. Finally, the function is locked again to ensure reentrancy protection.

1function unlock(bytes calldata data) external override returns (bytes memory result) {
2    if (Lock.isUnlocked()) AlreadyUnlocked.selector.revertWith();
3
4    Lock.unlock();
5
6    // the caller does everything in this callback, including paying what they owe via calls to settle
7    result = IUnlockCallback(msg.sender).unlockCallback(data);
8
9    if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
10    Lock.lock();
11}
1library Lock {
2    // The slot holding the unlocked state, transiently. bytes32(uint256(keccak256("Unlocked")) - 1)
3    bytes32 internal constant IS_UNLOCKED_SLOT = 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;
4
5    function unlock() internal {
6        assembly ("memory-safe") {
7            // unlock
8            tstore(IS_UNLOCKED_SLOT, true)
9        }
10    }
11
12    function lock() internal {
13        assembly ("memory-safe") {
14            tstore(IS_UNLOCKED_SLOT, false)
15        }
16    }
17
18    function isUnlocked() internal view returns (bool unlocked) {
19        assembly ("memory-safe") {
20            unlocked := tload(IS_UNLOCKED_SLOT)
21        }
22    }
23}

Example:

If a user wants to perform a swap in an ETH/USDC pool, exchanging 1 ETH for 2600 USDC, they need to call a contract that implements the unlockCallback() and then triggers the unlock() function. Inside the callback, the swap() function of the PoolManager must be called. If the transaction doesn’t hit the set slippage limit, the calculated delta value from the swap might look as follows (with a + sign indicating an amount owed to the pool, and a sign indicating an amount to be received from the pool):

ETH Delta (amount0) | +1
USDC Delta (amount1) | -2000

Next, the following steps are executed in sequence within the callback:

  • 1 ETH is transferred from the user to the PoolManager using the IERC20.transferFrom function.
  • The amount0 delta is settled to 0 via the settle() function.
  • 2000 USDC is transferred to the user via the take() function, and the amount1 delta is settled to 0.
  • At the end of the unlock() function, the contract checks if there are any outstanding debts owed by either party and ensures that the delta is zero.
2.3.7 donate() - Donation

The function allows users to donate funds to the LPs, serving as an incentive for providing liquidity. Similar to the modifyLiquidity() and swap() functions, it first performs input validation, then calls the beforeDonate hook. After that, it executes the Pool library’s donate() function, which adds the amounts to the accumulated pool fees (feeGrowthGlobal0X128 and feeGrowthGlobal1X128), then it accounts for the delta in the pool via _accountPoolBalanceDelta(), and finally emits the Donate event and triggers the afterDonate hook.

1function donate(PoolKey memory key, uint256 amount0, uint256 amount1, bytes calldata hookData)
2    external
3    onlyWhenUnlocked
4    noDelegateCall
5    returns (BalanceDelta delta)
6{
7    PoolId poolId = key.toId();
8    Pool.State storage pool = _getPool(poolId);
9    pool.checkPoolInitialized();
10
11    key.hooks.beforeDonate(key, amount0, amount1, hookData);
12
13    delta = pool.donate(amount0, amount1);
14
15    _accountPoolBalanceDelta(key, delta, msg.sender);
16
17    // event is emitted before the afterDonate call to ensure events are always emitted in order
18    emit Donate(poolId, msg.sender, amount0, amount1);
19
20    key.hooks.afterDonate(key, amount0, amount1, hookData);
21}

3. Hooks

/image

As discussed earlier, hooks are triggered at various stages in actions like modifyLiquidity, swap, donate, etc. Hooks are external smart contracts that can be attached to individual pools, allowing customization at specific points in the pool's lifecycle. Each pool can have one hook, but a hook can serve multiple pools, intercepting and modifying execution flow during pool-related actions.

3.1 Types of Hooks:

3.1.1 Initialize Hooks
  • beforeInitialize: Called before a new pool is initialized.
  • afterInitialize: Called after a new pool is initialized.
  • These hooks allow developers to add custom actions or validations during pool initialization, but they can only be invoked once.
3.1.2 Liquidity Modification Hooks

Liquidity modification hooks are designed to be highly granular for security reasons.

  • beforeAddLiquidity: Called before liquidity is added to a pool.
  • afterAddLiquidity: Called after liquidity is added.
  • beforeRemoveLiquidity: Called before liquidity is removed from a pool.
  • afterRemoveLiquidity: Called after liquidity is removed.
3.1.3 Swap Hooks
  • beforeSwap: Called before a swap is executed in a pool.
  • afterSwap: Called after a swap is executed.
3.1.4 Donate Hooks
  • beforeDonate: Called before a donation is made to a pool.
  • afterDonate: Called after a donation is made.
  • These hooks allow customization of token donations to liquidity providers.
3.2 How Hook Flags Work:

Hooks indicate their functionality by encoding flags within the address of the contract. The PoolManager checks these flags to determine which hook functions to invoke for a given pool.

Each hook function, such as beforeSwap, is associated with a specific flag. For example, the beforeSwap function corresponds to the BEFORE_SWAP_FLAG, which has a value of 1 << 7.

These flags represent specific bits in the address of the hook contract, where the value of the bit (either 1 or 0) indicates whether the corresponding flag is enabled. For example:

Ethereum addresses are 20 bytes (160 bits) long. Consider the address:

0x00000000000000000000000000000000000000C0

In binary, it is:

0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 1100 0000

The trailing 8 bits of this address (1100 0000) represent two active flags:

  • The 7th bit is set to 1, corresponding to the AFTER_SWAP flag, meaning the afterSwap function will be called during a swap.
  • The 8th bit is also set to 1, representing the BEFORE_SWAP flag, so the beforeSwap function will also be called during the swap.

The PoolManager observes these flags during execution and calls the appropriate hook functions based on the bits set in the address.

You can find a complete list of flags here.

3.3 Complete Hook Example

The SwapHook contract is a custom Uniswap V4 hook that tracks the number of swaps for multiple pools. It extends BaseHook and implements the beforeSwap and afterSwap hooks.

  • beforeSwapCount and afterSwapCount mappings keep a count of how many times the hooks are called for each pool.
  • Only beforeSwap and afterSwap hooks are enabled, as specified in getHookPermissions().
  • The counters are incremented inside the beforeSwap() and afterSwap() functions.
  • The contract uses PoolId to manage state separately for each pool, enabling it to serve multiple pools from a single contract.
1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.24;
3
4import {BaseHook} from "v4-periphery/src/base/hooks/BaseHook.sol";
5
6import {Hooks} from "v4-core/src/libraries/Hooks.sol";
7import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
8import {PoolKey} from "v4-core/src/types/PoolKey.sol";
9import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
10import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol";
11import {BeforeSwapDelta, BeforeSwapDeltaLibrary} from "v4-core/src/types/BeforeSwapDelta.sol";
12
13contract SwapHook is BaseHook {
14    using PoolIdLibrary for PoolKey;
15
16    // NOTE: ---------------------------------------------------------
17    // state variables should typically be unique to a pool
18    // a single hook contract should be able to service multiple pools
19    // ---------------------------------------------------------------
20
21    mapping(PoolId => uint256 count) public beforeSwapCount;
22    mapping(PoolId => uint256 count) public afterSwapCount;
23
24    constructor(IPoolManager _poolManager) BaseHook(_poolManager) {}
25
26    function getHookPermissions() public pure override returns (Hooks.Permissions memory) {
27        return Hooks.Permissions({
28            beforeInitialize: false,
29            afterInitialize: false,
30            beforeAddLiquidity: true,
31            afterAddLiquidity: false,
32            beforeRemoveLiquidity: true,
33            afterRemoveLiquidity: false,
34            beforeSwap: true,
35            afterSwap: true,
36            beforeDonate: false,
37            afterDonate: false,
38            beforeSwapReturnDelta: false,
39            afterSwapReturnDelta: false,
40            afterAddLiquidityReturnDelta: false,
41            afterRemoveLiquidityReturnDelta: false
42        });
43    }
44
45    // -----------------------------------------------
46    // NOTE: see IHooks.sol for function documentation
47    // -----------------------------------------------
48
49    function beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata, bytes calldata)
50        external
51        override
52        returns (bytes4, BeforeSwapDelta, uint24)
53    {
54        beforeSwapCount[key.toId()]++;
55        return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0);
56    }
57
58    function afterSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata, BalanceDelta, bytes calldata)
59        external
60        override
61        returns (bytes4, int128)
62    {
63        afterSwapCount[key.toId()]++;
64        return (BaseHook.afterSwap.selector, 0);
65    }
66}Other hook examples can be found here.

3.4 Hook Examples

Hooks can be used for a wide array of use cases. In this GitHub page more examples and resources on Uniswap V4 Hooks can be found. GitHub. Some examples of hooks are,

Dynamic Fee Adjustment (Volatility Fee Hook)

Market volatility often leads to reduced returns or impermanent loss for liquidity providers. The Dynamic Fee Adjustment hook solves this by adjusting fees based on market conditions, optimizing returns during volatile periods, as well as potentially addressing LVR and MEV concerns.

The hook monitors asset volatility and adjusts the fee structure accordingly. Higher fees during volatile periods help protect liquidity providers from price fluctuations, encouraging them to stay in pools. This dynamic mechanism helps manage liquidity efficiently during market swings, benefiting all participants in volatile environments.

On-Chain Limit Orders

The hook allows users to set price conditions for trades. When the market hits the target price, the order is automatically triggered, executing the trade on-chain. This automated functionality enables more strategic trading without constant monitoring, simplifying decentralized trading for retail users.

Volatility Oracle

The Volatility Oracle hook addresses the lack of on-chain volatility metrics in DeFi, providing important data for pricing derivatives like options. This allows DeFi to offer more advanced products, bringing it closer to traditional financial markets.

Another option of the volatility oracle would be to combine it with the dynamic fee hook, as the fee can be based on the volatility fed by this oracle.

KYC / Identity Hooks (Civic, WorldID, etc.)

The KYC/Identity Hooks integrate KYC checks and identity verification into the platform, ensuring only verified users can trade. Depending on how the regulatory environment changes, this could be mandatory, sadly.

This hook integrates identity verification into the smart contract, requiring users to complete KYC before trading or providing liquidity. By doing this, the platform remains decentralized while ensuring regulatory compliance, making it easier for institutional participants to engage safely in DeFi.

4. License

Uniswap V4 operates under the Business Source License 1.1 (BSL 1.1), which limits commercial use for a four-year period. Once this period ends, the code transitions to GPL and becomes fully open-source.

Both Uniswap Governance and Uniswap Labs retain the ability to grant exceptions, providing flexibility for specific use cases while maintaining oversight of commercial implementations.


Explore our article on how Uniswap's ERC-7683 standard drives liquidity growth in the DeFi ecosystem, providing valuable context to the discussion on Uniswap's latest features.