Modern DEXes,
how they're made: Uniswap V4

Author: Sergey Boogerwooger, Dmitry Zakharov
Security researchers at MixBytes
Intro
This article marks the beginning of a series focused on modern DEXes implementations. The goal of these articles (as for the previous "lendings" series) is to provide DeFi developers and auditors with a comprehensive review of modern DeFi implementations, including the algorithms, key data structures, and functions used. Unlike other resources, we will not delve into the economical and financial aspects of the protocols, which can be studied through project documentation. Instead, we will concentrate on the significant code segments and discuss them in detail.

Uniswap V4 is the next iteration of the well-known Uniswap protocols. However, Uniswap V4 boasts a multitude of significant changes compared to its predecessors. Firstly, Uniswap V4 enables the creation of pools with diverse logic types. While V2 pools, with liquidity spread over the entire price range, and V3 pools, with concentrated liquidity and price ticks, remain popular, Uniswap V4 allows for the addition of new pool types using its hooks system. Furthermore, considerable attention has been devoted to optimizing transaction costs, with pool management organized as a singleton rather than a factory. Notably, Uniswap V4 also reinstates the ability to swap native ETH, a feature that was previously only possible by wrapping ETH in ERC20 WETH tokens.

Let's dive deeper into "how it's made".

[NOTE] At present, Uniswap V4 is not deployed to the mainnet and is currently only operational in test environments. Please, note that all topics discussed are relevant to the project's current state and are subject to change in the future.
Core
The main architectural change in Uniswap V4 is the shift from the factory to the singleton pattern. In all previous versions, new pools were deployed using a pool factory; now new pools can be created and managed using a single contract. Let's start from the PoolManager.sol.

The main operational structure of the PoolManager is the PoolKey struct, which contains the following elements: two addresses of swappable tokens (currency0 and currency1), the amount and type of fees (static or dynamic), hooks addresses for this pool, and the ticks spacing parameter (refer to the article about concentrated liquidity). The id of the pool is constructed from the PoolKey structure by hashing all the struct contents.

The deployment of the pool is performed by the initialize() function, which accepts the PoolKey struct (containing token addresses), the initial sqrtPrice, and initial parameters for pool hooks (set in the PoolKey struct).

After the first checks, the beforeInitialize hook is called, allowing hooks to setup the initial state. Then the pool is initialized to its original state, returning the tick number, corresponding to the initial price. Then the afterInitialize hook is called, and the function is finished.

The PoolKey described above is a unique identifier in all Uniswap V4 pool operations (like swap() or modifyLiquidity()). By outsourcing pool address calculations to external parties, Uniswap reduces gas costs and improves efficiency.

Now, it's time to look at the main security mechanic of Uniswap V4 - the "unlock" pattern and token deltas. Every operation in Uniswap V4 requires the unlocking of the PoolManager. The locking is made using transient storage, which keeps the lock status persistent until the end of the transaction. It is necessary because memory cannot be used in reentrancy scenarios, while using storage for reentrancy locks is too expensive.

Any operations with liquidity include the onlyWhenUnlocked() modifier. The "lock/unlock" mechanic works like a reentrancy lock, denying reentry into the function from external calls. However, in addition, at the end of the unlock() function, there is an important check for non-zero token deltas. At the end of the swap() function (here), liquidity management (here), and the donate() function (here), we see the _accountPoolBalanceDelta() function, which calculates the amount of "non-zero" token balance changes after the operations. This counter must be equal to zero when unlock() finishes its work, meaning that any operations inside swaps and liquidity providing must end with a zero credit/debit balance.

This pattern, along with the usage of transient storage, is very powerful and should be studied by DeFi developers to keep the main protocol consistent while allowing external protocols to perform multiple arbitrary actions. In Uniswap V4, it's the pools that can be flexibly customized by the usage of hooks. Let's look at the Uniswap V4 Pools.
Pools
Pool initialization includes the setup of a Pool.State struct in the initialize() function in the Pool.sol which is similar to the pools, used in Uniswap V3 (they were described in our previous article about Uniswap V3). If no hooks are set in the pool being created, then the pool will work like Uniswap V3 pools with concentrated liquidity. You can look at the Pool.sol's swap() function and mention that it uses the same code as Uinswap V3 pools, price ticks, sqrt prices, and fees management. But in case of Uniswap V4, the creation of the pool is much cheaper, as no additional contracts are needed to be deployed. Liquidity management (in the absence of custom hooks) also works similarly to Uniswap V3.

Another important feature to note is the donate() function, which allows directly adding tokens to the feeGrowthGlobal* accumulators of the pool fees. It can be used to incentivize pools directly. However, keep in mind that donate() can be frontrunned. In addition, this part of the code clearly shows how token deltas are handled. Here, we simply "add" negative amounts to the token deltas, and the protocol now records that it has a "negative balance".

The accumulated debit/credit tokens balances need to be sent from/to the protocol, and these operations are processed by two functions:

  • take(): used to take tokens from the protocol
  • settle(): used to check if the token balance of the protocol was increased. Inside the _settle() function, we see another pack of transient storage operations in the CurrencyReserves.sol.

This design of tokens settlement allows the periphery contracts to perform multiple operations while the PoolManager is unlocked: providing liquidity, performing swaps (with additional hooks), donating fees, and taking/settling only the final token amounts to the protocol.

The hooks system is the next step. As mentioned above, hooks are the key mechanism for modifying pool behaviour. Without hooks, we receive a well-known Uniswap V3 pool with concentrated liquidity. But, if a developer wishes, they can set up hooks for the pool, described here. For example, two hooks: beforeSwap() and afterSwap(), returning token deltas, can modify the pool's behaviour; for instance, implementing regular Uniswap V2 pools. The same approach can be used for providing liquidity and incentivization of pools.
Implementation details
Uniswap V4 code introduces many new features and differences compared to previous versions. Let's discuss some of them.

One notable feature is the return of native ETH support. In previous versions, wrapping/unwrapping ETH-WETH was a cumbersome process and required extra gas. Since the top swap pools of Uniswap are typically pairs with ETH (e.g., ETH/USDC), this change is particularly significant. Uniswap V4 now supports operations with native ETH without the need for wrapping/unwrapping, incorporating additional logic branches to handle ETH directly (like here).

The next interesting part is fetching protocol fees. In some cases, an invalid protocol fee controller (set by the owner of the protocol) can break the initialization of the pools. To avoid this situation, the call to the protocol fee manager is analyzed, and, in case of revert (i.e., the call didn't return success), a zero fee is returned. Moreover, the protocol fee manager can spend too much gas, breaking pool initialization as well. This situation is mitigated by additional gas restrictions here.

Another important aspect is protocol maths. Uniswap V4 works with "full scale" 256-bit values, unlike early versions of Uniswap worked with 128-bit values. As a result, multiplication and division operations need to handle situations with overflows, which can occur in intermediate results. For example, in case of d=(a*b)/c, overflows can occur in the (a*b) part. To handle these situations, Uniswap V4 implements a mulDiv() function supporting full 512-bit precision. This requires additional computations, but in return, Uniswap V4 receives the ability to correctly operate with any tokens, having any 256-bit balances and precision, and avoid any additional conversions in the protocol.
Conclusion
Uniswap V4 introduces significant changes and optimizations over its previous iterations. New architecture allows for combining swap pools with different logic, making the creation of these pools and interactions with them much cheaper.

Notably, the factory pattern is gone, and operations with pools now go through the PoolManager, which uses a transient storage for "lock/unlock" pattern, effectively avoiding attacks related to reentrancy and oracle price manipulations.

Overall, the design choices and code structure of Uniswap V4 reflect a methodical evolution of its ecosystem, focusing on gas efficiency and usability.

Stay tuned for our new articles!
  • Who is MixBytes?
    MixBytes is a team of expert blockchain auditors and security researchers specializing in providing comprehensive smart contract audits and technical advisory services for EVM-compatible and Substrate-based projects. Join us on X to stay up-to-date with the latest industry trends and insights.
  • Disclaimer
    The information contained in this Website is for educational and informational purposes only and shall not be understood or construed as financial or investment advice.
Other posts