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);
    }
加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。