banner
zach

zach

github
twitter
medium

Starting from Zero UniswapV2 | Part-1 Liquidity

UniswapV2 has undergone significant changes compared to V1 and is currently the most popular version of dex. Many protocols are forked from UniswapV2. In this series of articles, Foundry will be used as the contract testing framework instead of OpenZeppelin for the underlying protocol implementation of ERC20, and the core framework code of Uniswap will be reproduced from scratch.

The UniswapV2 codebase is divided into two parts: core and periphery. The core includes:

  • UniswapV2ERC20: an ERC20 extension for LP tokens
  • UniswapV2Factory: a factory contract for managing trading pairs
  • UniswapV2Pair: the core pair contract

The periphery mainly includes the contracts UniswapV2Router and UniswapV2Library, which are important auxiliary trading contracts.

Adding Liquidity#

Let's start with the core process of adding liquidity. In terms of design, Uniswap is similar to V1, with traders, LPs, and users still being the core participants of the protocol. Compared to V1, the code design of V2 has some differences. The injection of liquidity by LPs is divided into two parts: the underlying implementation is the UniswapV2Pair contract, and the entry point is the UniswapV2Router contract. Here, we will focus on the underlying implementation.

Adding liquidity means that LPs transfer two underlying assets into the pair contract in a certain proportion, and the pair mints a corresponding amount of LP tokens based on the assets invested.

As the underlying UniswapV2Pair contract, the functionality that needs to be implemented here is to calculate how much underlying assets the user has invested and calculate the corresponding amount of LP tokens to mint for the user.

function mint() public {
   uint256 balance0 = IERC20(token0).balanceOf(address(this));
   uint256 balance1 = IERC20(token1).balanceOf(address(this));
   uint256 amount0 = balance0 - reserve0;
   uint256 amount1 = balance1 - reserve1;
   // Calculate the amount of assets the user has invested, amount0 and amount1
   uint256 liquidity;
   // Differentiate between the first mint and subsequent mints, as the calculation of liquidity tokens is different
   if (totalSupply == 0) {
      liquidity = ???
      _mint(address(0), MINIMUM_LIQUIDITY);
   } else {
      liquidity = ???
   }

   if (liquidity <= 0) revert InsufficientLiquidityMinted();
   // Mint liquidity tokens
   _mint(msg.sender, liquidity);
   // Update the reserve cache of the pair
   _update(balance0, balance1);

   emit Mint(msg.sender, amount0, amount1);
}

From the code, it can be seen that the pair contract caches the quantities of the two tokens in the pair using the reserve0 and reserve1 variables, instead of directly using balanceOf to count (note: this is also for contract security to avoid external manipulation).

Before calling the mint function, the user should transfer token0 and token1 to the contract as expected. Here, the current balances of token0 and token1 in the contract are calculated by subtracting the previous cache, resulting in the amounts of token0 and token1 the user has transferred in this transaction.

When calculating how many LP tokens should be minted, it distinguishes whether the totalSupply is 0, i.e., whether it is the first time providing liquidity. Assuming the token situation in the pair is as follows:

token0token1liquidity
reserve0reserve1totalSupply
amount0amount1totalSupply+lp

According to a fixed ratio, there are two sources for the number of LP tokens to be minted this time, both of which are based on the two tokens the user has invested:

  • amount0/totalSupply*reserve0
  • amount1/totalSupply*reserve1

In actual development, UniswapV2 chooses the smaller of the two values. According to the rules of UniswapV2, the liquidity provided by the user is strictly proportional. The two values should be equal. However, if the user provides unbalanced liquidity, there will be differences between these two values. If the protocol calculates the LP tokens based on the larger value, it is equivalent to encouraging this behavior. Therefore, the smaller value is chosen as a punishment for the user.

Returning to the condition branch of totalSupply=0, it is not possible to calculate the number of LP tokens based on a unified ratio. UniswapV2 chooses to calculate the square root of amount0*amount1 and subtract MINIMUM_LIQUIDITY (1000).

  • Assuming an LP initially invests 1 wei of token0 and token1, if MINIMUM_LIQUIDITY is not subtracted, 1 LP token will be minted, and then 1000 tokens of token0 and token1 will be directly transferred in. At this time, there are 1000*10^18+1 tokens of token0 and token1 in the pair, but only 1 wei of LP tokens. Therefore, for later LPs, even if they only want to provide the minimum unit of 1 wei of liquidity, they have to pay 2000 ether in tokens. Explanation reference
  • If MINIMUM_LIQUIDITY is subtracted uniformly, there is a lower limit of 1000 liquidity, and users can transfer tokens without minting. If the attack process is executed again, the maximum unit price of liquidity is (1001+2000×10^18)1001≈2×10^18, which is much lower than before, but the benefit of the initial liquidity provider is lost.

The revised code is as follows:

if (totalSupply == 0) {
   liquidity = Math.sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
   _mint(address(0), MINIMUM_LIQUIDITY);
} else {
   liquidity = Math.min(
      (amount0 * totalSupply) / _reserve0,
      (amount1 * totalSupply) / _reserve1
   );
}

The corresponding test code is as follows:

function testInitialMint() public {
        vm.startPrank(lp);
        token0.transfer(address(pair),1 ether);
        token1.transfer(address(pair),1 ether);
        
        pair.mint();
        uint256 lpToken = pair.balanceOf(lp);
        assertEq(lpToken, 1e18-1000);
    }

    function testExistLiquidity() public {
        testInitialMint();
        vm.startPrank(lp);
        token0.transfer(address(pair),1 ether);
        token1.transfer(address(pair),1 ether);
        
        pair.mint();
        uint256 lpToken = pair.balanceOf(lp);
        assertEq(lpToken, 2e18-1000);
    }

    function testUnbalancedLiquidity() public {
        testInitialMint();
        vm.startPrank(lp);
        token0.transfer(address(pair),2 ether);
        token1.transfer(address(pair),1 ether);
        
        pair.mint();
        uint256 lpToken = pair.balanceOf(lp);
        assertEq(lpToken, 2e18-1000);
    }

Removing Liquidity#

From the process of adding liquidity, we can see that the overall process is: users transfer underlying assets token0 and token1, and LP tokens are minted accordingly.

Therefore, removing liquidity is the reverse process. The prerequisite for removing liquidity is that the user owns LP tokens, which are the proof of the liquidity provided by the user. The specific code is as follows:

  • First, calculate the corresponding amount0 and amount1 that the user should receive based on the number of LP tokens they hold.
  • Burn all of the user's LP tokens (partial liquidity removal is not supported here).
  • Transfer the calculated amounts of token0 and token1 back to the user.
  • Update the reserve balances of the pair.
function burn() external{
        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));
        uint256 liquidity = balanceOf[msg.sender];
        // Calculate the token amounts based on the user's liquidity proportion
        uint256 amount0 = liquidity * balance0 / totalSupply;
        uint256 amount1 = liquidity * balance1 / totalSupply;
        if (amount0 <=0 || amount1 <=0) revert InsufficientLiquidityBurned();
        // Burn liquidity tokens
        _burn(msg.sender, liquidity);
        // Transfer tokens back to the user
        _safeTransfer(token0, msg.sender, amount0);
        _safeTransfer(token1, msg.sender, amount1);
        // Update the current reserves
        balance0 = IERC20(token0).balanceOf(address(this));
        balance1 = IERC20(token1).balanceOf(address(this)); 
        _update(balance0, balance1);
        emit Burn(msg.sender, amount0, amount1);
    }

The test code is as follows:

    function testBurn() public{
        testInitialMint();
        vm.startPrank(lp);
        pair.burn();
        assertEq(pair.balanceOf(lp), 0);
        assertEq(token0.balanceOf(lp), 10 ether-1000);
        assertEq(token1.balanceOf(lp), 10 ether-1000);
    }

    function testUnbalancedBurn() public {
        testInitialMint();
        vm.startPrank(lp);
        token0.transfer(address(pair),2 ether);
        token1.transfer(address(pair),1 ether);
        
        pair.mint();
        uint256 lpToken = pair.balanceOf(lp);
        assertEq(lpToken, 2e18-1000);

        pair.burn();
        assertEq(pair.balanceOf(lp), 0);
        assertEq(token0.balanceOf(lp), 10 ether-1500);
        assertEq(token1.balanceOf(lp), 10 ether-1000);
    }
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.