Brief Analysis of New Features in Uniswap V4

Eocene | Security
10 min readJul 5, 2023

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 the lockAcquired function in the donateRouter contract (the caller).
  • The lockAcquired function in the donateRouter contract calls the donate function in the PoolManager contract, transfers tokens, and then calls the settle 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

Reference

--

--

Eocene | Security

Smart contract audit, attack analysis, web3 security research, on-chain monitor and alert. Powered by Eceone Research.