What is Uniswap V4: Customizable Hooks & Superior Efficiency
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.
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. ThePoolKey
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.
- 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.
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:
- Core calculation methods — responsible for performing key operations such as
_initialize_
,_swap_
,_modifyPosition_
, and_donate_
. - 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:
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()
.
Next, the function calculates the initial liquidity provider fee and the protocol fee.
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.
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.
libraries/Pool.sol
Lastly, the afterInitialize
hook is called, allowing arbitrary logic to be executed after the pool creation.
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:
Next, the beforeSwap
hook is triggered:
Then, the actual swap calculations and core functionality are executed inside the Pool library's swap() function:
Afterwards, the afterSwap
hook is triggered:
Finally, the pool balance delta is applied, accounting for the token changes within the pool:
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:
- The swap input amount is fully exhausted (swap completion).
- 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.
The CurrencyDelta and NonzeroDeltaCount libraries manage the caller’s currency deltas in transient storage:
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:
Next, the beforeModifyLiquidit
y hook is triggered:
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.
The afterModifyLiquidity
hook is triggered, and the ModifyLiquidity
event is emitted:
Finally, similar to the swap()
function, the delta is accounted for via the _accountPoolBalanceDelta()
function and settled through the settle()
and take()
functions.
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()
.
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.
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.
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 theIERC20.transferFrom
function. - The
amount0
delta is settled to 0 via thesettle()
function. - 2000 USDC is transferred to the user via the
take()
function, and theamount1
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.
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 theAFTER_SWAP
flag, meaning theafterSwap
function will be called during a swap. - The 8th bit is also set to
1
, representing theBEFORE_SWAP
flag, so thebeforeSwap
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
andafterSwapCount
mappings keep a count of how many times the hooks are called for each pool.- Only
beforeSwap
andafterSwap
hooks are enabled, as specified ingetHookPermissions().
- The counters are incremented inside the
beforeSwap()
andafterSwap()
functions. - The contract uses
PoolId
to manage state separately for each pool, enabling it to serve multiple pools from a single contract.
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.
5. Links
- Uniswap v4 whitepaper
- Uniswap v4 core contracts
- Uniswap v4 periphery contracts
- Uniswap v4 contribution guide
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.