ScaleBit

Jul 26, 2023

Uniswap V4:Analyzing the Security Landscape Beneath the Surface


From Uniswap V4 | Pools with hook contracts diagram

In the fast-paced world of decentralized finance (DeFi), exchanges are pivotal in enabling the smooth transfer of assets. Uniswap, a leader in this space, has transformed how users trade cryptocurrencies. Through this article, the ScaleBit Research Group explores the security aspects of Uniswap’s latest version, V4, which brings innovative features with substantial implications for the platform’s functionality and security.

Uniswap, a decentralized exchange on the Ethereum blockchain, has gained immense popularity due to its permissionless and non-custodial trading pools. With each upgrade, Uniswap has strived to overcome the challenges and limitations of its previous versions. As we delve into Uniswap V4, we’ll examine its new features and their security implications.

Evolution of Uniswap - Versions 1 to 3

Launched on the Ethereum mainnet on November 2, 2018, Uniswap V1 is built on the Automated Market Making (AMM) protocol. This protocol maintains that the product of trading token pairs should always remain constant, where x represents the total balance of token x, and y represents the total balance of token y. The formula can be represented as :1

$$
x*y = k
$$

All functions within Uniswap V1 rely on this simple yet effective formula. In the realm of cryptography, simplicity often brings about robust security.

However, Uniswap V1 only supports the swapping between Ether (ETH) and ERC20 tokens. Therefore, if users wanted to exchange USDC for DAI, they would first need to swap USDC for ETH, and then swap ETH for DAI. Technically, it’s possible to swap any two ERC20 tokens, provided there is a trading path between them. Nevertheless, this approach can lead to increased costs and significant slippage.


From Uniswap V2 | DAI to USDC Swap diagram

Building upon the success of V1, Uniswap V2 was introduced in May 2020. The team included the ability to swap arbitrary ERC20-ERC20 token pairs, effectively solving the “ETH bridging” problem. Another innovative feature was the flash swap, which allowed users to withdraw any quantity of ERC20 tokens without making upfront payments. Users could return the tokens or pay the fees at the conclusion of the transaction. This function greatly benefited traders without initial capital who still wished to trade, and is similar to the concept of flash loans.


From Uniswap V2 | Flash Swaps diagram

Alongside updates to business logic, Uniswap V2 also made significant strides in technical security :

  • Uniswap V2 properly handles “missing return” ERC20 tokens that do not work on Uniswap V1, such as USDT and OMG
  • Built in metaTransaction approve function for liquidity tokens
  • Increase re-entrancy protection using mutex (adds support for ERC777 and other non-standard ERC20 tokens)
  • Fix bug from Uniswap V1 that uses all remaining gas on failing transactions
  • More descriptive error messages. The above bug caused failing transactions to only return the error “Bad Jump Destination” 2

As such, Uniswap V2 was capable of handling distinct tokens and providing a superior user experience.

Uniswap V3, launched on March 23, 2021, offered enhanced capital efficiency compared to V2. By facilitating low-slippage transaction execution, Uniswap V3 aimed to outperform stablecoin-based AMMs and controlled exchanges.

Liquidity providers could create personalized pricing curves based on their preferences, enabling them to anticipate the AMM’s form. Liquidity providers’ capital could be concentrated within certain price bands, which improved liquidity at specific prices.


From Uniswap V3 | Concentrated liquidity diagram

One significant change was the enhancement of Oracle price fetching.

Time-Weighted Average Price (TWAP) oracles, introduced by Uniswap V2, are a vital component of the DeFi architecture and have been adopted in various projects, such as Compound and Reflexer.

V2 oracles function by maintaining per-second totals of Uniswap pair prices. To determine an accurate TWAP over a period, these pricing amounts can be checked twice: once at the start and once at the end.

Uniswap V3 significantly upgraded the TWAP oracle, allowing any recent TWAP within the last nine days to be calculated with a single on-chain call. This is accomplished by maintaining several cumulative sums instead of just one.


From Uniswap V3 | improved TWAP oracles diagram

By employing TWAP, Uniswap effectively reduced the risks of oracle price manipulations, thus increasing community safety.

Having discussed the main innovations that Uniswap has brought to DeFi and the associated security improvements, it is time to investigate Uniswap V4 and the potential risks it presents.

Uniswap V4: A New Era of Customizability and Potential Risks

Understanding Hooks in Uniswap V4

First announced on June 13, 2023, Uniswap V4 introduced the concept of hook contracts alongside other innovative features designed to customize liquidity pools. But what exactly is a hook contract and what potential security risks could arise with V4?

Hooks are externally deployed contracts that execute certain developer-defined logic at a predetermined stage of a pool’s execution. These hooks allow integrators to construct a specialized liquidity pool with adaptable and flexible execution.

Hooks can introduce new features and functionalities, modify pool properties, or implement various features, such as:

  • Executing huge orders gradually using TWAMM
  • Onchain limit orders that fill at tick prices
  • Dynamic fees that fluctuate with volatility;
  • Mechanisms for liquidity providers to internalize MEV;
  • Median, truncated, or other special implementations of Oracle 5

Due to their complexity, which often matches that of full protocols, future separate whitepapers will likely be written to explain specific hook designs.

When developers create pools on Uniswap V4, they can specify which hook contract to call during the pool’s execution. As of now, Uniswap V4 supports eight such hook callbacks:

  • beforeInitialize/afterInitialize
  • beforeModifyPosition/afterModifyPosition
  • beforeSwap/afterSwap
  • beforeDonate/afterDonate 5

By applying a specific hook contract during execution, the behaviors and outcomes of such calls are deterministic. The following diagram depicts how the beforeSwap and afterSwap hooks function during the swap execution flow:


From Uniswap V4 | Swap hook flow

However, with great flexibility comes with great risks. User-defined hook contract allow malicious developers create trap contracts to steal the toke or break the system. Though currently we don’t have

Security Vulnerabilities in Uniswap V4: A Case Study

Right after the whitepaper and draft code of Uniswap V4 were viewable in public, Philogy opened an issue describing how a bad owner of poolManager could break initialize new pools, or selectively censor the creation of pools using sandwich calls.

First of all, the malicious/compromised owner of the PoolManager can call the setProtocolFeeController to set the protocolFeeController

function setProtocolFeeController(IProtocolFeeController controller) external onlyOwner {
        protocolFeeController = controller;
        emit ProtocolFeeControllerUpdated(address(controller));
    }

Then protocolFeeController is called during _fetchProtocolFees to fetch the protocol fees

function _fetchProtocolFees(PoolKey memory key)
        internal
        view
        returns (uint8 protocolSwapFee, uint8 protocolWithdrawFee)
    {
        if (address(protocolFeeController) != address(0)) {
            // note that EIP-150 mandates that calls requesting more than 63/64ths of remaining gas
            // will be allotted no more than this amount, so controllerGasLimit must be set with this
            // in mind.
            if (gasleft() < controllerGasLimit) revert ProtocolFeeCannotBeFetched();
            try protocolFeeController.protocolFeesForPool{gas: controllerGasLimit}(key) returns (
                uint8 updatedProtocolSwapFee, uint8 updatedProtocolWithdrawFee
            ) {
                protocolSwapFee = updatedProtocolSwapFee;
                protocolWithdrawFee = updatedProtocolWithdrawFee;
            } catch {}

            _checkProtocolFee(protocolSwapFee);
            _checkProtocolFee(protocolWithdrawFee);
        }
    }

And finally _fetchProtocolFees is called inside initialize to initialize both protocolSwapFee and protocolWithdrawFee .

/// @inheritdoc IPoolManager
    function initialize(PoolKey memory key, uint160 sqrtPriceX96) external override returns (int24 tick) {
        if (key.fee & Fees.STATIC_FEE_MASK >= 1000000) revert FeeTooLarge();

        // see TickBitmap.sol for overflow conditions that can arise from tick spacing being too large
        if (key.tickSpacing > MAX_TICK_SPACING) revert TickSpacingTooLarge();
        if (key.tickSpacing < MIN_TICK_SPACING) revert TickSpacingTooSmall();
        if (!key.hooks.isValidHookAddress(key.fee)) revert Hooks.HookAddressNotValid(address(key.hooks));

        if (key.hooks.shouldCallBeforeInitialize()) {
            if (key.hooks.beforeInitialize(msg.sender, key, sqrtPriceX96) != IHooks.beforeInitialize.selector) {
                revert Hooks.InvalidHookResponse();
            }
        }

        PoolId id = key.toId();
        (uint8 protocolSwapFee, uint8 protocolWithdrawFee) = _fetchProtocolFees(key);
        (uint8 hookSwapFee, uint8 hookWithdrawFee) = _fetchHookFees(key);
        tick = pools[id].initialize(sqrtPriceX96, protocolSwapFee, hookSwapFee, protocolWithdrawFee, hookWithdrawFee);

        if (key.hooks.shouldCallAfterInitialize()) {
            if (key.hooks.afterInitialize(msg.sender, key, sqrtPriceX96, tick) != IHooks.afterInitialize.selector) {
                revert Hooks.InvalidHookResponse();
            }
        }

        emit Initialize(id, key.currency0, key.currency1, key.fee, key.tickSpacing, key.hooks);
    }

Therefore, there are two ways that a bad protocol fee controller can cause initialize always revert:

  1. if the returned value of _fetchProtocolFees is not a (uint8,uint8) pair, then it will fall into catch and revert.
  2. Even if the value pair is within the uint8 type, if they are out of range of _checkProtocolFee it will also revert.

Here is the code for checking that the protocol fee is in the valid range:

function _checkProtocolFee(uint8 fee) internal pure {
        if (fee != 0) {
            uint8 fee0 = fee % 16;
            uint8 fee1 = fee >> 4;
            // The fee is specified as a denominator so it cannot be LESS than the MIN_PROTOCOL_FEE_DENOMINATOR (unless it is 0).
            if (
                (fee0 != 0 && fee0 < MIN_PROTOCOL_FEE_DENOMINATOR) || (fee1 != 0 && fee1 < MIN_PROTOCOL_FEE_DENOMINATOR)
            ) {
                revert FeeTooLarge();
            }
        }
    }

In the _checkProtocolFee function, every 8-bit fee is split into two 4-bit values: fee0 and fee1 , if any of these values is non-zero and less than MIN_PROTOCOL_FEE_DENOMINATOR (set to 4 in prior codes ) then the function will revert with FeeTooLarge error.

This means that the actual fee fraction for each part of the fee (the swap and withdraw fees) can’t be more than 25%. Note that since these fees are expressed as fractions, a larger value means a smaller fee. For instance, a fee of 8 means “1/8” or 12.5%.

If you are a bit lost, no worries, we provided a function execution flow diagram co-created by GPT-4.

Uni

To reproduce the revert case, you can copy this PoC created by Philogy into test/foundry-tests/BrickInitialize.t.sol, and run forge test --mp test/foundry-tests/BrickInitialize.t.sol -vvvv with the foundry. This PoC shows both two revert scenarios.

To mitigate this vulnerability, Philogy suggested that there should be a default fee of 0 as the try{...} catch{...}pattern implies. Alternatively, it should be made clear that the owner of the poolManager could control and censor the creation of new pools. It is not sure how Uniswap will solve this issue in the end, but it’s already been moved to their TODOs.

Conclusion: Balancing Innovation and Security in Uniswap V4

This blog provides both roadmap of Uniswap and in-depth exploration of the security vulnerability of V4, highlighting the balance between innovation and security in the world of decentralized finance. While Uniswap V4 brings exciting new possibilities, it also underscores the importance of rigorous security practices in the development and use of such platforms. As we continue to navigate this rapidly evolving landscape, it is our hope that this analysis will serve as a valuable resource for both users and developers in the DeFi community

Reference

[1] https://hackmd.io/@HaydenAdams/HJ9jLsfTz?type=view

[2] https://blog.uniswap.org/uniswap-v2

[3] https://blog.uniswap.org/uniswap-v3

[4] https://blog.uniswap.org/uniswap-v4

[5] https://github.com/Uniswap/v4-core/blob/main/whitepaper-v4-draft.pdf

[6] https://github.com/Uniswap/v4-core/issues/221

[7] https://gist.github.com/Philogy/6a7e4360a677cb38b477542d94222bc5

About ScaleBit

ScaleBit is a blockchain security team that provides security solutions for Mass Adoption of Web3. With expertise in scaling technologies like blockchain interoperability and zero-knowledge proofs, we provide meticulous and cutting-edge security audits for blockchain applications. The team comprises security professionals with extensive experience in both academia and enterprise. Our mission is to provide security solutions for Web3 Mass Adoption and make security accessible for all.

Website: https://www.scalebit.xyz/

Twitter: https://twitter.com/scalebit_


Write by 0xNorman from ScaleBit Research Group

OLDER > < NEWER