Brief Analysis of New Features in Uniswap V4
Overview
On June 13th, Uniswap Labs released a blog post announcing the release of the draft code for Uniswap V4, which includes an early open-source version of the Uniswap V4 core and peripheral libraries, as well as a draft of the technical whitepaper. Uniswap V4 introduces many new features, and this article will provide an analysis based on its draft code and whitepaper.
V4 New Features
Uniswap V4 introduces hooks, which allow pool creators to customize the functionality of their pools. Additionally, it abandons the previous method of creating liquidity pools through a factory contract, and instead consolidates all pools into a single contract. It adopts the Flash accounting method to save gas costs and also supports native ETH. Furthermore, there are several other new features incorporated in Uniswap V4.
Singleton Replaces Factory
In Uniswap V2 and V3, liquidity pools were created using a factory contract, where each pool was represented by a separate contract. This means that creating a new liquidity pool involved deploying a new contract, which incurred significant gas costs. However, in Uniswap V4, a Singleton pattern is employed. All liquidity pools are consolidated into a single contract, eliminating the need to create new contracts for each pool. The data for each liquidity pool is stored in a mapping within the contract, with the poolId pointing to the pool’s state. The poolId is derived by hashing and converting the PoolKey to a uint. The pool’s data is stored in the pool.State, significantly reducing the gas required for creating new liquidity pools.
Next, let’s examine the code and analyze how the PoolManager contract in V4 creates a new liquidity pool.
In V4, a new liquidity pool is created by calling the initialize function in the PoolManager contract. This function requires the PoolKey struct and the price as parameters. The structure of the PoolKey is as follows:
The PoolKey structure includes the addresses of the two tokens involved in the liquidity pool, as well as the fee for the exchange. The upper 4 bits of the fee determine whether fees are charged in the hook. Additionally, it includes the tickSpacing and the address of the hook. Then look at the initialize
function :
The function will check whether the range of fee and tickSpacing is correct, and then use the isValidHookAddress
function to verify whether the hook address is valid:
If the hook address is the zero address (0x0), the fee must be a static fee, and it must not charge fees in the hook to pass the validation. If the hook address is not the zero address, the function checks whether the hook functionality is enabled. It converts the hook address to a uint160 integer and compares it with the minimum FLAG value. If it is greater than the minimum FLAG value, it indicates that the hook functionality is enabled.
Alternatively, if the fee satisfies the dynamic fee condition or the swap fee and withdraw are enabled in the hook, any one of these four conditions being met allows the validation to pass.
Next, if the hook is set to be called before initialization, the function will invoke the beforeInitialize
function of the hook contract to execute the specified logic in the hook.
Then, the passed PoolKey is converted to a bytes32 type poolId, and the values of various fees are obtained through the protocolFeeController contract and the hook contract.
Finally, the initialize
function is called to initialize the State of the pool:
The State struct holds the information of the liquidity pool, and the initialized parameters are stored in the Slot0 struct:
Finally, it checks whether the hook is set to be called after initialization. If it is, the PoolManager contract will invoke the afterInitialize
function of the hook contract to execute its designated logic.
In Uniswap V4, the creation of new liquidity pools no longer involves creating new contracts using Create2. Instead, the information of the pools is stored in the pools
mapping within the PoolManager contract. This approach significantly reduces the gas cost associated with creating new liquidity pools. All the information of the liquidity pools is centralized in the PoolManager contract.
Use Hooks to Provide Custom Functions
In Uniswap V4, the concept of hooks has been introduced, allowing for the execution of custom logic at certain specified points in the lifecycle of liquidity pools. The hook functionality enhances the flexibility of liquidity pools and enables the implementation of more creative functionalities.
Currently, Uniswap V4 supports hook callbacks at eight specific positions:
- beforeInitialize / afterInitialize
- beforeModifyPosition / afterModifyPosition
- beforeSwap / afterSwap
- beforeDonate / afterDonate
The official whitepaper provides a hook flowchart for the swap process in which flag checks are performed before and after the swap to determine whether to execute the hook :
The address of the hook contract determines where the callbacks will take place. If the first two bytes of the hook contract address are 0x01 (0000 0001), the callback will occur at the afterDonate position. If it is 0x90 (1001 0000), the callbacks will occur at the beforeInitialize and afterModifyPosition positions. The PoolManager contract contains a series of functions with hook invocation checks. For example, in the initialize
function for creating a new liquidity pool, there are checks for shouldCallBeforeInitialize
and shouldCallAfterInitialize
:
In the official v4-periphery project, there are several example hook contracts provided by the Uniswap team in the example directory. These include:
- Geometric Mean Oracle (GeomeanOracle.sol)
- Limit Order (LimitOrder.sol)
- Time-Weighted Average Market Maker (TWAMM.sol)
- Volatility Oracle (VolatilityOracle.sol)
To create a custom hook contract, you generally inherit from the BaseHook contract provided in v4-periphery and override the corresponding logic. In the BaseHook contract, functions like beforeInitialize
directly revert, so we need to override the specific hook functions we want to use. Additionally, the BaseHook contract provides modifiers that allow us to restrict the callers of hook functions based on specific conditions:
Flash accounting
In Uniswap V4, a new design feature called Flash accounting has been introduced. In earlier versions of Uniswap, operations like swaps or adding liquidity were finalized with token transfers. However, in V4, each operation updates an internal net balance (delta), and at the end of all operations, this value must be checked to ensure it is zero for the transaction to succeed. When Flash accounting is combined with Singleton, it greatly simplifies multi-hop trades. The PoolManager contract introduces new functions, take
and settle
, which are used to borrow and deposit funds into the pool. By calling these functions, both the PoolManager contract and the caller ensure that no tokens are owed at the end of the call.
Let’s analyze the donate
function in the official Foundry test contract as an example. The general flow is as follows:
- The donateRouter contract calls the
lock
function in the PoolManager contract. - The
lock
function in the PoolManager contract triggers thelockAcquired
function in the donateRouter contract (the caller). - The
lockAcquired
function in the donateRouter contract calls thedonate
function in the PoolManager contract, transfers tokens, and then calls thesettle
function. - Returning to the
lock
function, the delta values are checked to ensure they are all zero.
Let’s take a closer look at the code. In the donate
function of the donateRouter contract, the parameters are first packed, and then the lock
function of the PoolManager contract is called with the packed parameters. Finally, at the end of the process, if there is any excess ETH, it will be returned to the caller :
In the lock
function of the PoolManager contract, the caller is first added to the lockedBy
array. Then, the lockAcquired
function of the caller is called back, with the packed data
and the corresponding id
of the caller (which is the current length of the lockedBy
array) being passed as arguments :
In the lockAcquired
function of the donateRouter contract, the caller first verifies if it is the PoolManager contract. Then, it decodes the data
parameter and proceeds to call the donate
function of the PoolManager contract :
The donate
function in the PoolManager contract is decorated with the onlyByLocker
and noDelegateCall
modifiers, which restrict it from being invoked through delegatecall and only allow it to be called in the locked state.
Within the donate
function, the pool's state is modified by calling pool.donate
, which returns a delta
value. Then, an internal function _accountPoolBalanceDelta
is called to update the corresponding currency value in the lockState
:
In the _accountPoolBalanceDelta
function, the _accountDelta
function is called to update the delta value of the corresponding currency :
In _accountDelta
, the current lockState data is retrieved, and based on the provided delta, the current net balance is calculated. If the net balance is 0, the nonzeroDeltaCount in lockState is decremented. Conversely, if the original net balance was 0, the nonzeroDeltaCount is incremented. Finally, the value of the corresponding currency in currencyDelta in lockState is modified.
After the execution of the logic in the donate
function of the PoolManager contract, the flow returns to the lockAcquired
function, where subsequent operations such as transfers and calling the settle
function of the PoolManager are performe :
First, the net balance of the token is checked. If it is greater than 0, the function checks if it is native ETH. If it is, the ETH is directly passed and the settle
function of the PoolManager contract is called. If it is an ERC20 token, the function first calls transferFrom
to transfer the tokens to the PoolManager contract, and then calls the settle
function.
In the settle
function, the reserves of the token are retrieved using reservesOf
to obtain the internally recorded token balance. Then, currency.balanceOfSelf()
is used to get the actual token balance. The difference between these two values represents the quantity of tokens transferred by the user. The _accountDelta
function is called to modify the corresponding net balance, and the value is passed as negative to ensure that if the calculations are correct, the net balance will be modified to 0 :
Finally, after the completion of the lockAcquired
function, the execution flow returns to the lock
function of the PoolManager contract. The corresponding LockState data is retrieved, and it is checked whether all net balances are 0, meaning that neither the PoolManager contract nor the caller owe each other. Finally, the last element is popped out from the lockedBy
array :
The entire process concludes here. The key aspect is the modification of the delta, representing the net balance, in various operations, as well as the final verification. Moreover, most functions in the PoolManager contract follow a similar calling flow, such as modifyPosition
, swap
, mint
, etc., which are all decorated with the onlyByLocker
modifier :
Support Native ETH
In Uniswap V2 and V3, it was not possible to directly use native ETH as a token to create liquidity pools. Instead, ETH had to be wrapped into WETH (Wrapped Ether) for usage. However, in Uniswap V4, due to the design of Singleton and Flash accounting, it is now possible to use native ETH to create liquidity pools. This approach is more gas-efficient, as the gas cost for transferring native ETH is only half of that for transferring ERC20 tokens. Additionally, there is no need to convert ETH to WETH when using it.
In the v4-core-main/contract/libraries/CurrencyLibrary.sol
file of Uniswap V4, the condition for identifying native ETH is that the token's address is the zero address (0x0). The file also provides a transfer
method that uses call
to send ETH when it is the token being transferred :
Summary
Uniswap V4, introduced by Uniswap Labs, brings forth a draft proposal that includes various innovations and optimizations. These include the introduction of Hooks, the design of Singleton mode, and the implementation of Flash accounting, among others. It is believed that there will be an increasing number of exciting experiments taking place on the V4 platform.
With the introduction of Hooks, developers can create innovative code to enhance the functionality of liquidity pools. The design of Singleton mode allows users to significantly reduce the costs associated with creating liquidity pools. Furthermore, the support for Flash accounting and the use of NATIVE ETH enable users to enjoy lower transaction fees and enhanced convenience.
Overall, Uniswap V4 presents a promising platform for experimentation, and it is expected to bring forth new and interesting possibilities for developers and users alike.
About us
At Eocene Research, we provide the insights of intentions and security behind everything you know or don’t know of blockchain, and empower every individual and organization to answer complex questions we hadn’t even dreamed of back then.
Learn more: Website | Medium | Twitter