DeFi 程式設計:Uniswap V2(1)

stoneworld發表於2022-05-18

DeFi 程式設計:Uniswap V2(1)

什麼是 Uniswap ?

簡單來說,Uniswap 是一個去中心化交易所(DEX),旨在成為中心化交易所的替代品。它在以太坊區塊鏈上執行並且完全自動化:沒有管理員或具有特權訪問許可權的使用者。

更深層次的說法是,它是一種演算法,允許建立交易池或者代幣交易對,併為它們填充流動性,讓使用者使用這種流動性交換代幣。這種演算法稱為自動做市商或自動流動性提供者。

那什麼是做市商呢?

做市商 是向市場提供流動性(交易資產)的實體。交易的本質其實是流動性:如果您想出售某樣東西但沒有人購買,則不會進行交易。一些交易對具有高流動性(例如 BTC-USDT),但有些交易對的流動性低或根本沒有流動性(例如一些山寨幣)。

DEX(去中心化交易所) 必須有大量的流動性才能發揮作用,才有可能替換傳統的中心化交易所。獲得流動性的一個方法是 DEX 的開發者將他們自己的錢(或他們投資者的錢)投入其中,成為做市商。然而,這不是一個現實的解決方案,因為考慮到 DEX 允許任何代幣之間的交換,他們需要大量的資金來為所有貨幣對提供足夠的流動性。此外,這將使 DEX 中心化:作為唯一的做市商,開發人員將在他們手中擁有大量的權力,這與去中心化的理念相悖,所以肯定是行不通的。

更好的解決方案是允許任何人成為做市商,這就是 Uniswap 成為自動做市商的原因:任何使用者都可以將他們的資金存入交易對(並從中受益)。

Uniswap 扮演的另一個重要角色是價格預言機。價格預言機是從中心化交易所獲取代幣價格並將其提供給智慧合約的服務——這樣的價格通常難以操縱,因為中心化交易所的交易量通常非常大。然而,雖然沒有那麼大的交易量,Uniswap 仍然可以作為價格預言機。

Uniswap 作為一個二級市場,吸引了套利者,他們通過 UniswapCEX 之間的價格差異獲利,這使得 Uniswap 資金池上的價格儘可能地接近大交易所的價格。

恆定乘積做市商

Uniswap 核心是恆定乘積函式:

其中 X 是 ETH 儲備,Y 是代幣儲備(或反之),K 是一個常數。Uniswap 要求 K 保持不變,無論有多少 X 或 Y 的儲備。當你用以太坊換代幣時,你把你的以太存入合約,並得到一定數量的代幣作為回報。Uniswap 確保每次交易後 K 保持不變(這並不是真的,我們將在後面看到原因),這個公式也負責定價計算,隨後我們會看到具體的實現,至此 Uniswap 的實現原理已經講述完成了,隨後我們將實現一個 Uniswap V2

工具集

在本教程系列中,我這裡將使用 Foundry 進行合約開發和測試,Foundry 是用 Rust 編寫的現代化的以太坊工具包,相比 Hardhat 更快,更重要的是允許我們使用 Solidity 編寫測試程式碼,這對於一個後端開發更加友好和方便。

我們還將使用 solmate,代替 OpenZeppelin 來實現 ERC20,因為後者有些臃腫和固執己見。在這個專案中不使用 OpenZeppelin 來實現 ERC20 的一個具體原因是它不允許將代幣轉移到零地址。反過來,Solmate 是一系列 gas 優化合約,並沒有那麼限制。

還值得注意的是,自 2020 年 Uniswap V2 推出以來,許多事情都發生了變化。例如,SafeMath 自 Solidity 0.8 釋出以來,庫已經過時,它引入了本機溢位檢查。所以可以說,我們正在構建一個現代版本的 Uniswap。

Uniswap V2 架構

Uniswap V2 的核心架構思想是流動性池子:流動性提供者可以在合約中質押他們的流動性;抵押的流動性允許其他任何人以去中心化的方式進行交易。與 Uniswap V1 類似,交易者支付少量費用,這些費用在合約中累積,然後由所有流動性提供者共享。

Uniswap V2 的核心合約是 UniswapV2Pair。該合約的主要目的是接受使用者的代幣,並使用累積的代幣儲備來進行交換。這就是為什麼它是一個彙集合約。每個UniswapV2Pair合約只能彙集一對代幣,並且只允許在這兩個代幣之間進行交換——這就是它被稱為“Pair”的原因。

Uniswap V2 合約的程式碼庫分為兩個儲存庫:

  1. 核心(v2-core)
  2. 外圍(v2-periphery)

核心儲存庫儲存這些合約:

  1. UniswapV2ERC20 – 用於 LP 代幣的擴充套件 ERC20 實現。它還實現了 EIP-2612 用來支援代幣轉移的鏈下批准。
  2. UniswapV2Factory – 這是一個工廠合約,它建立 Pair 合約並充當它們的登錄檔,其用 create2 的方式生成配對地址 —— 後續我們將詳細瞭解它是如何工作的。
  3. UniswapV2Pair – 負責核心邏輯的主合約。

外圍儲存庫包含多個使 Uniswap 更易於使用的合約。其中包括 UniswapV2Router,它是 Uniswap UI 和其他在 Uniswap 之上工作的去中心化應用程式的主要入口點。

外圍儲存庫中的另一個重要合約是 UniswapV2Library,它是實現重要計算的輔助函式的集合。我們將實現這兩個合約。

好吧,讓我們開始吧!

流動性資金池

沒有流動性,就不可能有交易。因此,我們需要實現的第一個功能是流動資金池。它是如何工作的?

流動性池只是儲存代幣流動性的合約,並允許執行使用這種流動性的互換。因此,"流動性池 "意味著將代幣傳送到一個智慧合約,並在那裡儲存一段時間。

你可能已經知道,每個合同都有自己的儲存空間,ERC20代幣也是如此,每個代幣都有一個連線地址和餘額的 mapping 。而我們的資金池將在 ERC20 合約中擁有自己的餘額。這足以讓資金池有流動性嗎?事實證明,不會。

主要原因是,僅依靠 ERC20 餘額將使價格操縱成為可能:想象一下,有人向一個池子傳送大量的代幣,進行有利可圖的交換,並在最後兌現。為了避免這種情況,我們需要跟蹤我們這邊的資金池儲備,而且我們還需要控制它們的更新時間。
我們將使用 reserve0 和 reserve1 變數來跟蹤池子裡的儲備。

contract ZuniswapV2Pair is ERC20, Math {
  ...

  uint256 private reserve0;
  uint256 private reserve1;

  ...
}
為了簡潔起見,我省略了很多的程式碼。請檢視 GitHub repo 的完整程式碼。

Uniswap V2 在外圍合約 UniswapV2Router 中實現了一個增加流動性的方法,但其底層的流動性其實還是存在於配對合約中:流動性管理被簡單地看作是 LP-tokens 管理。當你向一個配對新增流動性時,合約就會 mint LP-tokens;當你移除流動性時,LP-tokens 就會被 burn,核心合約是較底層的合約,只執行核心操作。

如下是存入流動性的底層函式:

function mint() public {
   uint256 balance0 = IERC20(token0).balanceOf(address(this));
   uint256 balance1 = IERC20(token1).balanceOf(address(this));
   uint256 amount0 = balance0 - reserve0; // 尚未被計算的新存入的金額
   uint256 amount1 = balance0 - reserve1; // 尚未被計算的新存入的金額

   uint256 liquidity;

   if (totalSupply == 0) {
      liquidity = ???
      _mint(address(0), MINIMUM_LIQUIDITY);
   } else {
      liquidity = ???
   }

   if (liquidity <= 0) revert InsufficientLiquidityMinted();

   _mint(msg.sender, liquidity);

   _update(balance0, balance1);

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

首先,我們需要計算尚未被計算的新存入的金額(儲存在儲備金中),註釋中也有寫清除,然後,我們計算必須發行的 LP 代幣的數量,作為對提供流動性的獎勵。然後,我們發行代幣並更新儲備(函式 _update 簡單地將餘額儲存到儲備變數中),整個流動性提供的方法已經完成了。對於最初的 LP 金額,Uniswap V2 最終使用了存入金額的幾何平均值,現在,讓我們計算一下在已經有一些流動性的情況下發行的 LP 代幣。

這裡的主要要求是:

  1. 與存入的金額成正比。
  2. 與LP-tokens的總髮行量成比例。

白皮書上給到了這樣一個公式:

新的 LP 代幣數量,與存入的代幣數量成正比,被鑄造出來。但是,在V2中,有兩個基礎代幣--我們應該在公式中使用哪一個?

我們可以選擇其中之一,但有一個有趣的問題:存入金額的比例與儲備金的比例越接近,差異越小。因此,如果存入金額的比例不同,LP 金額也會不同,其中一個會比另一個大。如果我們選擇較大的那個,那麼我們就會通過提供流動性來激勵價格變化,這就導致了價格操縱。如果我們選擇較小的一個,我們將懲罰存放不平衡的流動性(流動性提供者將得到較少的LP-tokens)。很明顯,選擇較小的數字更有利,這就是 Uniswap 正在做的事情,其實你會發現這裡並沒有去計算你質押 A Token,需要多少的 B Token 來平衡流動性,這個事情其實是放到了外圍的路由合約中實現的,但底層的合約足夠簡單,任何人也可以不通過路由合約直接呼叫 pair 合約本身去提供流動性。

舉個例子,假設 A 使用者按照 100:100 提供了流動性,他當前 LP-Token 是 100,此時 B 按照 200:100 去提供了流動性,如果按照 200 去計算發現,B 的 LP-Token 是 200,但對於 A 而言是不公平的,而且會導致價格波動太大。如果按照 100 去計算,那對 B 而言其實提供 100:100 就可以獲得 100 的流動性,但卻多付出了 100 的 A Token,導致最後使用者提取流動性的時候會損失一部分代幣,算是對 B 使用者提供流動性不平衡的懲罰,所以最終的程式碼如下:

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

在 totalSupply == 0 時,我們在提供初始流動性時減去 MINIMUM_LIQUIDITY(這是一個常數1000)。這可以防止一個流動性池的代幣份額(1e-18)變得太貴,這將拒絕小型流動性提供者。簡單地從初始流動性中減去1000,使得一個流動性份額的價格便宜了1000倍。這裡有一篇文章分析了這個問題。Uniswap V2 設計迷思

在 Solidity 中編寫測試

正如我上面所說的,我將使用 Foundry 來測試我們的智慧合約--這將使我們能夠快速建立我們的測試,並且不與 JavaScript 有任何業務。

首先我們先初始化測試合約:

contract ZuniswapV2PairTest is DSTest {
  ERC20Mintable token0;
  ERC20Mintable token1;
  ZuniswapV2Pair pair;

  function setUp() public {
    token0 = new ERC20Mintable("Token A", "TKNA");
    token1 = new ERC20Mintable("Token B", "TKNB");
    pair = new ZuniswapV2Pair(address(token0), address(token1));

    token0.mint(10 ether);
    token1.mint(10 ether);
  }

  // Any function starting with "test" is a test case.
}

讓我們為提供初始流動性新增一個測試:

function testMintBootstrap() public {
  token0.transfer(address(pair), 1 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint();

  assertEq(pair.balanceOf(address(this)), 1 ether - 1000);
  assertReserves(1 ether, 1 ether);
  assertEq(pair.totalSupply(), 1 ether);
}

1 ether token0 和1 ether token1 被新增到測試池中。結果,1 ether LP 代幣被髮行,我們得到了1 ether -1000(減去最小流動性)。池子的儲備和總供應量得到相應的改變。

當平衡的流動性被提供給一個已經有一些流動性的池子時會發生什麼?讓我們來看看。

function testMintWhenTheresLiquidity() public {
  token0.transfer(address(pair), 1 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint(); // + 1 LP

  token0.transfer(address(pair), 2 ether);
  token1.transfer(address(pair), 2 ether);

  pair.mint(); // + 2 LP

  assertEq(pair.balanceOf(address(this)), 3 ether - 1000);
  assertEq(pair.totalSupply(), 3 ether);
  assertReserves(3 ether, 3 ether);
}

這裡的一切看起來都是正確的。讓我們看看當提供不平衡的流動性時會發生什麼:

function testMintUnbalanced() public {
  token0.transfer(address(pair), 1 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint(); // + 1 LP
  assertEq(pair.balanceOf(address(this)), 1 ether - 1000);
  assertReserves(1 ether, 1 ether);

  token0.transfer(address(pair), 2 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint(); // + 1 LP
  assertEq(pair.balanceOf(address(this)), 2 ether - 1000);
  assertReserves(3 ether, 2 ether);
}

這就是我們所說的:即使使用者提供的 token0 流動性多於 token1 流動性,他們仍然只得到 1 個 LP-token。 現在讓我們轉向流動性移除。

移除流動性

流動性的消除與供應相反。同樣地,燃燒與鑄造相反。從池中移除流動性意味著燃燒 LP 代幣以換取相應數量的基礎代幣。返回給提供流動性的代幣數量計算公式如下:

簡單地說:返回的代幣數量與持有的 LP 代幣數量與 LP 代幣的總供應量成正比。你的 LP 代幣份額越大,你燃燒後得到的儲備份額就越大。
功能實現如下:

function burn() public {
  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(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);
}

可以看到 uniswap 是不支援移除部分流動性的,當然上述程式碼其實是存在部分問題,後續我們會解決這些問題。下面我們繼續完善合約測試部分。

function testBurn() public {
  token0.transfer(address(pair), 1 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint();
  pair.burn();

  assertEq(pair.balanceOf(address(this)), 0);
  assertReserves(1000, 1000);
  assertEq(pair.totalSupply(), 1000);
  assertEq(token0.balanceOf(address(this)), 10 ether - 1000);
  assertEq(token1.balanceOf(address(this)), 10 ether - 1000);
}

我們看到,除了傳送到零地址的最低流動性外,資金池回到了未初始化的狀態。

現在,讓我們看看當我們在提供不平衡的流動性後燃燒時會發生什麼:

function testBurnUnbalanced() public {
  token0.transfer(address(pair), 1 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint();

  token0.transfer(address(pair), 2 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint(); // + 1 LP

  pair.burn();

  assertEq(pair.balanceOf(address(this)), 0);
  assertReserves(1500, 1000);
  assertEq(pair.totalSupply(), 1000);
  assertEq(token0.balanceOf(address(this)), 10 ether - 1500);
  assertEq(token1.balanceOf(address(this)), 10 ether - 1000);
}

我們在這裡看到的是,我們已經失去了 500 wei 的 token0!這是我們在上面談到的對價格操縱的懲罰。但這個數額小得離譜,看起來一點都不重要。這是因為我們目前的使用者(測試合約)是唯一的流動性提供者。如果我們向一個由另一個使用者初始化的池子提供不平衡的流動性,會怎麼樣?讓我們來看看。

function testBurnUnbalancedDifferentUsers() public {
  testUser.provideLiquidity(
    address(pair),
    address(token0),
    address(token1),
    1 ether,
    1 ether
  );

  assertEq(pair.balanceOf(address(this)), 0);
  assertEq(pair.balanceOf(address(testUser)), 1 ether - 1000);
  assertEq(pair.totalSupply(), 1 ether);

  token0.transfer(address(pair), 2 ether);
  token1.transfer(address(pair), 1 ether);

  pair.mint(); // + 1 LP

  assertEq(pair.balanceOf(address(this)), 1);

  pair.burn();

  assertEq(pair.balanceOf(address(this)), 0);
  assertReserves(1.5 ether, 1 ether);
  assertEq(pair.totalSupply(), 1 ether);
  assertEq(token0.balanceOf(address(this)), 10 ether - 0.5 ether);
  assertEq(token1.balanceOf(address(this)), 10 ether);
}

我們現在損失了 0.5 ether,這是我們存入的 1/4。現在這是一個很大的數量! 那麼是誰最終得到了這 0.5 ether:配對還是測試使用者呢?寫個測試函式試試呢?

結論

今天的文章到此就結束了,如果有什麼問題請給我留言。

後續更新請關注公眾號

相關文章