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 情况如下所示:
token0 | token1 | liquidity |
---|---|---|
reserve0 | reserve1 | totalSupply |
amount0 | amount1 | totalSupply+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);
}