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. 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 trading 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. Uniswap, similar to v1, still has traders, LPs, and users as 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 trading pair contract in a certain proportion, and the trading pair mints a corresponding amount of LP tokens based on the assets invested.

As the underlying contract of UniswapV2Pair, the functionality that needs to be implemented here is: calculating how much underlying assets the user has invested and calculating 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 whether it is the first mint, 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 trading pair
   _update(balance0, balance1);

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

From the code, we can see that the trading pair uses the reserve0 and reserve1 variables to cache the current quantities of the two tokens in the trading pair, instead of directly using balanceOf to count (Note: this is also for contract security reasons, to avoid external manipulation).

Before calling the mint function, the user should transfer token0 and token1 to the current contract as expected. Here, the balance0 and balance1 of the current contract are calculated after deducting the previous cache, resulting in amount0 and amount1, which are the amounts of tokens the user has transferred in this time.

When calculating how many LP tokens should be minted, it distinguishes whether totalSupply is 0, i.e., whether it is the first time providing liquidity. Assuming the token situation in the current trading 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's rule is to choose the smaller of the two, according to the rules, the liquidity provided by the user is strictly proportional. The two values should be equal, but if the user provides unbalanced liquidity, there will be differences between these two values. If the protocol calculates LP based on the larger value, it is equivalent to encouraging this approach, so the smaller number of liquidity tokens 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 according to 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, resulting in 1000*10^18+1 token0 and token1 in the trading pair, but only 1 wei of LP. 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 here.

The updated 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 accompanying 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, mint the corresponding number of LP tokens.

Therefore, removing liquidity is the reverse process. The premise of 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 amount0 and amount1 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 fund reserves in the trading 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 amount of tokens 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.