UniswapV2 は V1 に比べて大きな変更があり、現在最も人気のある DEX バージョンであり、多くのプロトコルは UniswapV2 からフォークされています。本シリーズの記事では、Foundry を契約のテストフレームワークとして使用し、OpenZeppelin ではなく solmate を ERC20 などの基盤プロトコルの実装として使用し、Uniswap のコアフレームワークコードをゼロから再現します。
UniswapV2 のコードベースは core と periphery の 2 つの部分に分かれており、core には以下が含まれます:
- UniswapV2ERC20:LP トークンのための ERC20 拡張
- UniswapV2Factory:取引ペアを管理するためのファクトリー契約
- UniswapV2Pair:コアの取引ペア契約
periphery には主に UniswapV2Router と UniswapV2Library が含まれ、重要な補助取引契約です。
流動性の追加#
まずコアの流動性追加から始めます。Uniswap は設計上 V1 と似ており、トレーダー、LP、ユーザーは依然としてプロトコルのコア参加者です。V1 と比較して、V2 のコード設計には違いがあります。LP が流動性を注入するのは 2 つの部分に分かれており、基盤の実装は UniswapV2Pair 契約であり、上層のエントリは UniswapV2Router 契約です。ここでは基盤の実装に焦点を当てます。
流動性の追加:つまり、LP が取引ペア契約内に一定の割合で 2 つの基盤資産を転送し、取引ペアが投入された資産に応じて相応の数量の流動性トークンを鋳造するプロセスです。
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 の 2 つの変数を通じて現在の取引ペア内の 2 つのトークンの数量をキャッシュし、balanceOf を直接使用してカウントするのではありません(注:これは契約の安全性を考慮して、外部からの操作を避けるためでもあります)。
mint メソッドを呼び出す前に、ユーザーは期待通りに現在の契約に token0 と token1 を転送する必要があります。ここで現在の契約内のトークン残高 balance0 と balance1 を計算し、以前のキャッシュを引いた amount0 と amount1 が今回ユーザーが転入したトークンの数量になります。
鋳造すべき LP トークンの数を計算する際には、totalSupply が 0 かどうかを区別します。つまり、現在が初めて流動性を提供するかどうかです。現在の取引ペア内のトークン状況は以下のようになります:
token0 | token1 | liquidity |
---|---|---|
reserve0 | reserve1 | totalSupply |
amount0 | amount1 | totalSupply+lp |
固定の割合に従い、今回鋳造される LP トークンの数の出所は 2 つあり、ユーザーが投入した 2 つのトークンのいずれも基準として使用できます。
- amount0/totalSupply*reserve0
- amount1/totalSupply*reserve1
実際の開発において、UniswapV2 のルールは 2 つのうち小さい方を選択します。規定により、ユーザーが提供する流動性は厳密に割合に従います。2 つの値は等しいべきですが、ユーザーが不均衡な流動性を提供した場合、これらの値には差異が生じます。もしプロトコルが大きい方で LP を計算すると、これはこの方法を奨励することになります。したがって、小さい流動性トークンの数をユーザーへの罰として選択します。
totalSupply=0 の条件分岐に戻ると、統一された割合で LP トークンの数量を計算できないため、UniswapV2 は amount0*amount1 の平方根を計算し、MINIMUM_LIQUIDITY(1000)を統一的に引きます。
- ある LP が初めて token0 と token1 をそれぞれ 1 wei 投入したと仮定します。MINIMUM_LIQUIDITY を引かなければ、1 枚の LP トークンが mint され、その後直接 1000 枚の token0 と token1 が転入されると、取引ペア内には 1000*10^18+1 個の token0 と token1 が存在しますが、LP は 1 wei しか持っていません。したがって、後の LP にとって、最小単位の 1 wei の流動性を提供したい場合でも、2000 ether のトークンを支払わなければなりません。説明参照
- MINIMUM_LIQUIDITY を統一的に引くと、1000 の流動性下限が存在し、ユーザーは mint を通さずに直接トークンを転入できます。攻撃プロセスを再実行すると、流動性単価の最大値は (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 を転入し、対応する数の LP トークンを mint します。
したがって、流動性の削除は逆のプロセスです。削除の前提は、ユーザーが 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];
// ユーザーの流動性占有比率に基づくトークン数量を計算
uint256 amount0 = liquidity * balance0 / totalSupply;
uint256 amount1 = liquidity * balance1 / totalSupply;
if (amount0 <=0 || amount1 <=0) revert InsufficientLiquidityBurned();
// 流動性トークンをburn
_burn(msg.sender, liquidity);
// トークンをユーザーに転送
_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);
}