banner
zach

zach

github
twitter
medium

從零開始UniswapV2 | Part-1 流動性

UniswapV2 相較於 V1 有了較大變動,也是目前最流行的 dex 版本,很多協議都是從 UniswapV2 fork 而來,在本系列的文章中,將使用 Foundry 作為合約的測試框架,使用 solmate 而非 OpenZeppelin 作為底層協議如 ERC20 的實現,將 uniswap 的核心框架代碼從零到一進行複現。

UniswapV2 的代碼庫分為 core 和 periphery 兩部分,core 包括:

  • UniswapV2ERC20:用於 lp 代幣的 ERC20 拓展
  • UniswapV2Factory:工廠合約,用於管理交易對
  • UniswapV2Pair:核心的交易對合約

periphery 主要包括的合約是 UniswapV2Router 和 UniswapV2Library,是重要的輔助交易合約

添加流動性#

先從核心的添加流動性入手,uniswap 在設計上與 v1 類似,交易者,lp,使用者仍然是協議的核心參與者。與 v1 相比,v2 的代碼設計有所差異,lp 注入流動性分為兩部分,底層的實現是 UniswapV2Pair 合約,上層的入口在 UniswapV2Router 合約,在此我們先關注底層的實現。

添加流動性:即 LP 向交易對合約內按照一定比例轉入兩個底層資產,交易對為其按照投入的資產鑄造出相應數量的流動性代幣的過程。

作為 UniswapV2Pair 底層,這裡需要實現的功能就是:計算使用者投入多少底層資產,計算出相應額度的 lp 代幣再 mint 給使用者

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;
	 // 計算出本次使用者投入的資產數量amount0和amount1
   uint256 liquidity;
	 // 區分是否是首次mint,流動性代幣的計算方式不同
   if (totalSupply == 0) {
      liquidity = ???
      _mint(address(0), MINIMUM_LIQUIDITY);
   } else {
      liquidity = ???
   }

   if (liquidity <= 0) revert InsufficientLiquidityMinted();
	 // mint流動性代幣
   _mint(msg.sender, liquidity);
	 // 更新交易對內儲備量快取
   _update(balance0, balance1);

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

從代碼中可以看出,這裡的交易對會通過 reserve0 和 reserve1 兩個變量來快取當前交易對中兩個 token 的數量,而不是直接使用 balanceOf 來計數(注:這裡也是為了合約安全起見,避免被外部操控)

在調用 mint 方法前,使用者應該按照預期向當前合約轉入 token0 和 token1,這裡再計算當前合約內的 token 餘額 balance0 和 balance1 ,減去之前的快取,得到的 amount0 和 amount1 就是本次使用者轉入的 token 數量

在計算應該鑄造出多少個 lp 代幣時會區分 totalSupply 是否為 0,即當前是否是初次提供流動性,假設當前交易對內的 token 情況如下所示:

token0token1liquidity
reserve0reserve1totalSupply
amount0amount1totalSupply+lp

按照固定的比例,本次待鑄造的 lp 代幣數目來源有兩個,使用者投入的兩個 token 都可以作為基準來計算本次鑄造的流動性代幣

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

在實際開發中,UniswapV2 的規則是選擇兩者中較小的那個,按照規定,使用者提供的流動性是嚴格按照比例來的,兩個值應該相等,但是若使用者提供不平衡的流動性,這兩個值就存在差異,如果協議按照大的來計算 lp,那麼相當於是對這種方式的鼓勵,因此選擇較小的流動性代幣數目作為對使用者的懲罰

回到 totalSupply=0 的條件分支,無法按照統一的比例計算 lp 代幣數量,uniswapV2 選擇的是計算 amount0*amount1 的根號值,並且會統一減去 MINIMUM_LIQUIDITY(1000)。

  • 假設某 lp 初次投入 token0 和 token1 各 1 wei,如果不減去 MINIMUM_LIQUIDITY,則會 mint 出 1 枚 lp 代幣,然後再直接轉入 1000 枚 token0 和 token1,則此時交易對內有 1000*10^18+1 個 token0 和 token1,但是只有 1 wei 的 lp,那麼對於後來的 lp 來說,即使只想提供最小單位的 1 wei 流動性,也要付出 2000 ether 的 token,解釋參考
  • 若統一減去 MINIMUM_LIQUIDITY,則存在 1000 的流動性下限,使用者可以不通過 mint 直接轉入 token,如果重新執行攻擊流程,流動性單價最大值為 (1001+2000×10^18) 1001≈2×10^18,相較於前面已經降低很多,但是這裡損失了首次流動性提供者的利益

整理後的代碼如下:

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
   );
}

配套的測試代碼:

	 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);
    }

移除流動性#

從添加流動性的流程可以看出整體的流程是:使用者轉入底層資產 token0 和 token1,mint 出對應數目的 lp 代幣

那麼移除流動性就是逆向的過程,移除的前提是使用者擁有 lp 代幣,這裡的 lp 代幣就是使用者提供流動性的憑證,具體的代碼如下:

  • 首先根據使用者持有的 lp 數目,重新計算出他應得的 amount0 和 amount1
  • 將使用者的全部 lp 代幣銷毀(可以看到這裡暫不支持移除部分流動性)
  • 將計算出的相應數目的 token0 和 token1 轉移回使用者
  • 更新交易對內的資金儲備量
function burn() external{
        uint256 balance0 = IERC20(token0).balanceOf(address(this));
        uint256 balance1 = IERC20(token1).balanceOf(address(this));
        uint256 liquidity = balanceOf[msg.sender];
        // 計算使用者的流動性占比的token數量
        uint256 amount0 = liquidity * balance0 / totalSupply;
        uint256 amount1 = liquidity * balance1 / totalSupply;
        if (amount0 <=0 || amount1 <=0) revert InsufficientLiquidityBurned();
        // 流動性代幣burn
        _burn(msg.sender, liquidity);
        // 轉移token回給使用者
        _safeTransfer(token0, msg.sender, amount0);
        _safeTransfer(token1, msg.sender, amount1);
        // 更新當前儲備金
        balance0 = IERC20(token0).balanceOf(address(this));
        balance1 = IERC20(token1).balanceOf(address(this)); 
        _update(balance0, balance1);
        emit Burn(msg.sender, amount0, amount1);
    }

測試代碼如下:

    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);
    }
載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。