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