2024 Web3 Security ReportAccess control exploits account for nearly 80% of crypto hacks in 2024.
Discover report insights
  • Hacken
  • Blog
  • Discover
  • Uniswap V2 Core Contracts: Technical Details & Risks

Uniswap V2 Core Contracts: Technical Details & Risks

Uniswap V2 (main-net launch 18 May 2020) is still responsible for ~40 % of Uniswap TVL on Ethereum mainnet and many L2 roll-ups despite V3/V4 advances. Integrators therefore inherit its design trade-offs and latent attack surface. 

Many articles describe the main ideas of the protocol, and the contracts are considered secure due to their long lifespan and wide adoption within the ecosystem. This Ethereum blog article provides a good code overview for the initial acquaintance.

In this blog, we will focus specifically on the v2-core contracts, highlighting technical details that demand attention during integration and auditing.

UniswapV2ERC20

This contract provides the basic ERC-20 functionality inherited by UniswapV2Pair to represent liquidity provider (LP) shares. It also includes the permit function with sequential nonces.

Points worth noting:

  • Lack of Replay Protection Across Chain Forks

The contract implements ERC-20 Permit functionality, which allows using signatures for authorizing approvals. The functionality follows the EIP-712 standard; however, the DOMAIN_SEPARATOR value is calculated once and is not checked for compliance later. In the event of a chain fork with a chain ID change, the contract will still process Permit approvals signed for another chain.

  • Single Name and Symbol for All Pools

The contract hardcodes the token name and symbol as Uniswap V2 and UNI-V2. This contract is inherited by the UniswapV2Pair contract, thus sharing the name and symbol across all deployed pair contracts. While this may be confusing, it does not pose a risk. To identify which tokens the contract works with, the token0 and token1 methods of the UniswapV2Pair contract can be used.

  • Accepting Malleable Signatures in Permit Functionality

The permit functionality is a custom implementation, and it does not contain the common anti-malleable signature protection s <= secp256k1n / 2. While a unique tuple (user and nonce) per operation fully protects against the replay attack, unintended modifications may create a risk of breaking the replay protection. For example, the code below attempts to use a kind of deterministic nonce that does not depend on the smart contract state, which breaks the security control.

function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external {

  require(deadline >= block.timestamp, 'UniswapV2: EXPIRED');

  // This nonce does not make any sense in terms of security

  uint nonce = uint(keccak256(abi.encode(owner, spender, value, deadline));

  // This state variable is not included in the signed data

  nonces[owner]++;

  bytes32 digest = keccak256(

abi.encodePacked(

   '\x19\x01',

   DOMAIN_SEPARATOR,

   keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonce, deadline))

)

  ;

  address recoveredAddress = ecrecover(digest, v, r, s);

  require(recoveredAddress != address(0) && recoveredAddress == owner, 'UniswapV2: INVALID_SIGNATURE');

  _approve(owner, spender, value);

}

UniswapV2Factory

Factory contract for UniswapV2Pair contracts. Stores all created pairs, uses CREATE2 for deterministic pair address calculation, and provides token addresses for pair initialization in ascending order.

Point worth noting:

  • Single-Step Manager Update

According to modern best practices, the contract manager change process should occur in two steps: first, the pending manager is proposed, and then the new manager claims the role. This prevents transferring the manager role to a non-existent account and allows a pending manager to retain access in case the original manager loses their private key. However, the UniswapV2Factory contract implements this process in a single step through the setFeeToSetter function.

address public feeToSetter;

function setFeeToSetter(address _feeToSetter) external {

  require(msg.sender == feeToSetter, 'UniswapV2: FORBIDDEN');

  feeToSetter = _feeToSetter;

}

UniswapV2Pair

A liquidity pool contract that enables token swaps. Requires an additional wrapper for seamless EOA interactions.

Points worth noting:

  • Token Balances Isolation and Usage by EOA

The UniswapV2Pair contract isolates each pair with different tokens, synchronizes pool reserves with actual token balances, and expects funds to be transferred to the contract beforehand rather than triggering transferFrom. This functionality should not be used in implementations that support multiple pools within a single contract.

The design chosen by the Uniswap team ensures a high level of security and prevents tokens from being locked in pair contracts.

From another perspective, the UniswapV2Pair contract does not provide seamless EOA integration, as it expects the operations to be performed in multiple steps: token transfer and contract invocation. Any token transfer from an EOA can be immediately withdrawn using the skim function. The usual interaction strategy is using wrapping functionality such as UniswapV2Router, which performs the token transfers and operations on the pair in a single transaction.

function swap(...) ... {

  ...

  (uint112 _reserve0, uint112 _reserve1,) = getReserves();

  ...

  balance0 = IERC20(_token0).balanceOf(address(this));

  balance1 = IERC20(_token1).balanceOf(address(this));

  ...

  uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;

  uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;

  ...

  _update(balance0, balance1, _reserve0, _reserve1);

  ...

}

function _update(uint balance0, uint balance1, ...) ... {

  ...

  reserve0 = uint112(balance0);

  reserve1 = uint112(balance1);

  ...

}
  • Exceeding uint112 Reserves Limit

The UniswapV2Pair contract is designed to handle token reserves (and balances) up to 2 ** 112 - 1. Exceeding this limit causes Uniswap operations to consistently revert. In this case it is possible to withdraw the excessive funds using the skim function to make the contract operational again.

function _update(uint balance0, uint balance1, ...) ... {

  require(balance0 <= uint112(-1) && balance1 <= uint112(-1), 'UniswapV2: OVERFLOW');

  ...

  reserve0 = uint112(balance0);

  reserve1 = uint112(balance1);

  ...

}

function mint(...) ... {

  ...

  _update(balance0, balance1, _reserve0, _reserve1);

  ...

}

function burn(...) ... {

  ...

  _update(balance0, balance1, _reserve0, _reserve1);

  ...

}

function swap(...) ... {

  ...

  _update(balance0, balance1, _reserve0, _reserve1);

  ...

}

Tokens with an unlimited total supply or a total supply exceeding 2 ** 112 may not be suitable for Uniswap operations due to the contract providing low liquidity relative to the swap amount.

It is important to note that changing the reserve state variables to uint256 would not bypass this limit. The contract performs various operations that could revert due to unexpected overflows if reserves exceed uint112. For example, the expression uint(_reserve0).mul(_reserve1).mul(1000**2) in the swap function does not revert while reserves are stored as uint112, as 2 ** 112 * 2 ** 112 * 1000 ** 2 remains within the limit. However, declaring reserves as uint256 may cause failures.

Additionally, cumulative price value calculations use UQ112x112 fractions, which expect numerators and denominators to be of type uint112.

  • Desired Integer Overflow

The UniswapV2Pair contract has several cases where integer overflow is expected.

uint32 blockTimestamp = uint32(block.timestamp % 2**32);

uint32 timeElapsed = blockTimestamp - blockTimestampLast;

price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;

price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;

Forcing an update to Solidity 0.8.0+ would result in a denial-of-service (DoS) scenario in the year 2106, locking swaps, liquidity minting, and burning functionality.

It is important to note that this functionality relies on block.timestamp being provided in seconds and assumes that the pool is updated at least once every 136 years. If the blockchain is not fully EVM-compatible and stores block.timestamp in milliseconds or nanoseconds, the overflow could occur much earlier, potentially breaking values consistency.

  • Fee-on-Transfer Token Processing

The UniswapV2Pair contract supports processing fee-on-transfer tokens, as it expects funds to be transferred to the pair contract either in a callback or prior to execution. The contract validates the general oldReserve0 * oldReserve1 <= newReserve0 * newReserve1 invariant to maintain fund consistency and mints liquidity based on the actual deposited funds. While this is technically possible, due to lack of fee-on-transfer tokens standardization, accurate calculation of swap-in and swap-out amounts remains a challenge for intermediaries such as UniswapV2Router.

To mitigate this challenge, it is recommended to wrap the tokens into a standard ERC-20 implementation and use the wrapped tokens for Uniswap V2 operations.

  • Rebasing and Reflection Token Processing

The UniswapV2Pair contract is not designed to process rebasing and reflection tokens. The balance increase resulting from rebasing balance income can be withdrawn from the contract using the skim function or treated as additional funds for the next swap or liquidity addition.

function skim(address to) external lock {

  address _token0 = token0;

  address _token1 = token1;

  _safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));

  _safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));

}

As described above, liquidity providers will not receive the expected rebasing balance income. Modifications such as forcing a reserve update before any pair operation and removing the skim functionality may render the contract inoperable if the contract balance exceeds the 2 ** 112 maximum reserve size.

An EIP-4626-compliant vault should be created to wrap the reflection token into a standard ERC-20 token, with the wrapped tokens used for Uniswap V2 operations.

  • Using Cumulative Prices for Oracles

The UniswapV2Pair contract provides cumulative prices that can be used for a TWAP (Time-Weighted Average Price) oracle. Cumulative prices can be recorded at initial and final time points. The cumulative increase is divided by the time elapsed, and the TWAP price is obtained in this way. A reference implementation can be found in the v2-periphery examples.

uint256 public price0CumulativeLast;

uint256 public price1CumulativeLast;

uint32 public blockTimestampLast;

FixedPoint.uq112x112 public price0Average;

FixedPoint.uq112x112 public price1Average;

function update() external {

  uint256 price0Cumulative = IUniswapV2Pair(pair).price0CumulativeLast();

  uint256 price1Cumulative = IUniswapV2Pair(pair).price1CumulativeLast();

  uint32 blockTimestamp = IUniswapV2Pair(pair).blockTimestampLast();

  uint32 timeElapsed = blockTimestamp - blockTimestampLast;

  require(timeElapsed >= PERIOD, 'Oracle: PERIOD_NOT_ELAPSED');

  price0Average = FixedPoint.uq112x112(uint224((price0Cumulative - price0CumulativeLast) / timeElapsed));

  price1Average = FixedPoint.uq112x112(uint224((price1Cumulative - price1CumulativeLast) / timeElapsed));

  price0CumulativeLast = price0Cumulative;

  price1CumulativeLast = price1Cumulative;

  blockTimestampLast = blockTimestamp;

}

While this functionality provides an elegant way to obtain a time-weighted average price for a given token pair, it has several limitations:

The functionality guarantees a TWAP for a period equal to or longer than the specified one. This means the Oracle may provide outdated prices due to a low refresh rate.

For certain TWAP periods, the functionality can be manipulated through a sequential block sandwich attack: the last transaction in a block performs a large swap, and the first transaction in the next block restores reserves to the original ratio. The price impact is proportional to the time difference between points divided by the TWAP period. Since the block.timestamp value can be manipulated by the block creator, even a 5-minute TWAP might be subject to a 20% manipulation. This insight is especially critical when deploying on L2 solutions with custom transaction sequencers.

  • Liquidity Deposit Sandwich

The UniswapV2Pair contract calculates the liquidity to be minted for a deposit according to the formula: amount * totalSupply / reserve. However, since two tokens need to be deposited into the pair, the minimum of the liquidity values is taken.

This causes a situation where the liquidity deposit can be “sandwiched” by a large swap, changing the reserves ratio and leading to the user receiving less liquidity than expected, while the liquidity token’s value increases.

The usual mitigation strategy is using wrapping functionality such as UniswapV2Router, which validates whether the contract minted the expected amount of liquidity.

function mint(address to) (...) {

  ...

  if (_totalSupply == 0) {

...

  } else {

   liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);

  }

  ...

  _mint(to, liquidity);

  ...

}

This insight is especially important during the early stages of the UniswapV2Pair contract, as the MINIMUM_LIQUIDITY is subtracted from the initial minted liquidity.

  • Initial Liquidity Deposit Front Running

The UniswapV2Pair contract mint function behavior depends on whether the contract liquidity is initialized. While during the first deposit the amount of minted LP is calculated as sqrt(amount0 * amount1), during the next deposits the amount is calculated as minimal of the amount * totalSupply / reserve proportions.

function mint(address to) external lock returns (uint liquidity) {

  ...

  if (_totalSupply == 0) {

    liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);

    _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens

  } else {

    liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);

  }

  ...

}

In such a way, the initial deposit or even the pool creation front running allows an attacker to set an arbitrary price in the pair and completely drain the upcoming deposit. To mitigate the possibility, it is recommended to use a wrapper such as UniswapV2Router which provides a function automatically deploying the pair if it does not exist, and supplies funds to the pair according to the current reserves ratio.

It should be mentioned that as the UniswapV2Pair contact address is calculated deterministically, it can be funded before deployment and this may confuse smart contracts expecting specific price in the pair right after the initial liquidity deposit. The excessive funds can be withdrawn from the contract using the skim function.

Summary

Uniswap V2, deployed in May 2020, remains one of the most widely adopted decentralized exchange protocols. Its core smart contracts are considered secure due to extensive use and time-tested reliability. However, their specific implementation details – from permit signature handling and uint112 limits to balance checking mechanisms and oracle properties – create critical considerations for secure integration.

Subscribe
to our newsletter

Be the first to receive our latest company updates, Web3 security insights, and exclusive content curated for the blockchain enthusiasts.

Speaker Img

Table of contents

  • UniswapV2ERC20
  • UniswapV2Factory
  • UniswapV2Pair
  • Summary

Tell us about your project

Follow Us

Read next:

More related

Trusted Web3 Security Partner