前言
在 DeFi 賽道中,DEX 無疑是最核心的一塊,而 Uniswap 又是整個 DEX 領域中的龍頭,如 SushiSwap、PancakeSwap 等都是 Fork 了 Uniswap 的。雖然網上關于 Uniswap 的文章已經挺多,但大多都隻是從機制上進行介紹,很少談及具體實作,也存在一些問題沒能解答,比如:手續費配置設定是如何實作的?最優路徑是如何得出的?TWAP 怎麼用?注入流動性時傳回多少 LP Token 是如何計算的?是以,我從代碼層面去剖析 Uniswap,搞清楚這些問題,同時也對 Uniswap 從整體到細節都有所了解。
現在,Uniswap 有 V2 和 V3 兩個版本,我們先來聊聊 V2。
開源項目
整個 UniswapV2 産品拆分出了多個小型的開源項目,主要包括:
- uniswap-interface
- uniswap-v2-sdk
- uniswap-sdk-core
- uniswap-info
- uniswap-v2-subgraph
- uniswap-v2-core
- uniswap-v2-periphery
- uniswap-lib
前三個是前端 App 項目,即提供交易的項目,對應于 https://app.uniswap.org 網頁功能,展示頁面都寫在 uniswap-interface 項目中,uniswap-v2-sdk 和 uniswap-sdk-core 則是作為 SDK 而存在,uniswap-interface 會引用到 v2-sdk 和 sdk-core,通過 @uniswap/v2-sdk 和 @uniswap/sdk-core的方式引入到需要使用的 TS 檔案中。
不過,uniswap-interface 最新代碼其實是跟線上同步的,即是內建了 V3 版本的。如果隻想部署 V2 版本的前端,那可以找出曆史版本的項目代碼進行部署,如果是不帶流動性挖礦功能,推薦 2020 年 9 月份的版本,如果是帶挖礦功能,那可以試試 2020 年 10 月份的版本。
uniswap-info 則是 Uniswap Analytics 項目,對應于官網頁面 https://info.uniswap.org ,展示了一些統計分析資料,其資料主要是從 Subgraph 讀取。uniswap-v2-subgraph 則是 Subgraph 項目了。
最後三個則是合約項目了,uniswap-v2-core 就是核心合約的實作; uniswap-v2-periphery 則提供了和 UniswapV2 進行互動的外圍合約,主要就是路由合約;uniswap-lib 則封裝了一些工具合約。core和 periphery 裡的合約實作是我們後面要重點講解的内容。
另外,Uniswap 其實還有一個流動性挖礦合約項目 liquidity-staker ,因為 Uniswap 的流動性挖礦隻在去年上線過一段短暫的時間,是以很少人知道這個項目,但我覺得也有必要剖析下這塊的實作,畢竟很多仿盤也都帶有流動性挖礦功能。
最後,強烈推薦大家有時間可以看看崔棉大師的視訊教程,先後釋出過兩套教程:
- 手把手教你開發去中心化交易所:https://www.bilibili.com/video/BV1jk4y1y7t9?p=1
- 将UniswapV2部署到所有區塊鍊--去中心化交易所Uniswap多鍊部署教學視訊:https://www.bilibili.com/video/BV1ph411e7bT?p=1
後文中有些關鍵内容也是我從以上視訊中學到的。接着我們就來聊聊一些關鍵的合約實作了。
uniswap-v2-core
core 核心主要有三個合約檔案:
- UniswapV2Factory.sol:工廠合約
- UniswapV2Pair.sol:配對合約
- UniswapV2ERC20.sol:LP Token 合約
配對合約管理着流動性資金池,不同币對有着不同的配對合約執行個體,比如 USDT-WETH 這一個币對,就對應一個配對合約執行個體,DAI-WETH 又對應另一個配對合約執行個體。
LP Token 則是使用者往資金池裡注入流動性的一種憑證,也稱為流動性代币,本質上和 Compound 的 cToken 類似。當使用者往某個币對的配對合約裡轉入兩種币,即添加流動性,就可以得到配對合約傳回的 LP Token,享受手續費分成收益。
每個配對合約都有對應的一種 LP Token 與之綁定。其實,UniswapV2Pair 繼承了 UniswapV2ERC20,是以配對合約本身其實也是 LP Token 合約。
工廠合約則是用來部署配對合約的,通過工廠合約的 createPair() 函數來建立新的配對合約執行個體。
三個合約之間的關系如下圖(引自崔棉大師教程視訊中的圖):
工廠合約
工廠合約最核心的函數就是 createPair() ,其實作代碼如下:
裡面建立合約采用了 create2,這是一個彙編 opcode,這是我要重點講解的部分。
很多小夥伴應該都知道,一般建立新合約可以使用 new 關鍵字,比如,建立一個新配對合約,也可以這麼寫:
UniswapV2Pair newPair = new UniswapV2Pair();
複制
那為什麼不使用 new 的方式,而是調用 create2 操作碼來建立合約呢?使用 create2 最大的好處其實在于:可以在部署智能合約前預先計算出合約的部署位址。最關鍵的就是以下這幾行代碼:
bytes memory bytecode = type(UniswapV2Pair).creationCode;
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
}
複制
第一行擷取 UniswapV2Pair 合約代碼的建立位元組碼 creationCode,結果值一般是這樣:
0x0cf061edb29fff92bda250b607ac9973edf2282cff7477decd42a678e4f9b868
複制
類似的,其實還有運作時的位元組碼 runtimeCode,但這裡沒有用到。
這個建立位元組碼其實會在 periphery 項目中的 UniswapV2Library 庫中用到,是被寫死設定的值。是以為了友善,可以在工廠合約中添加一行代碼儲存這個建立位元組碼:
bytes32 public constant INIT_CODE_PAIR_HASH = keccak256(abi.encodePacked(type(UniswapV2Pair).creationCode));
複制
回到上面代碼,第二行根據兩個代币位址計算出一個鹽值,對于任意币對,計算出的鹽值也是固定的,是以也可以線下計算出該币對的鹽值。
接着就用 assembly 關鍵字包起一段内嵌彙編代碼,裡面調用 create2 操作碼來建立新合約。因為 UniswapV2Pair 合約的建立位元組碼是固定的,兩個币對的鹽值也是固定的,是以最終計算出來的 pair 位址其實也是固定的。
除了 create2 建立新合約的這部分代碼之外,其他的都很好了解,我就不展開說明了。
UniswapV2ERC20合約
配對合約繼承了 UniswapV2ERC20 合約,我們先來看看 UniswapV2ERC20 合約的實作,這個比較簡單。
UniswapV2ERC20 是流動性代币合約,也稱為 LP Token,但代币實際名稱為 Uniswap V2,簡稱為 UNI-V2,都是直接在代碼中定義好的:
string public constant name = 'Uniswap V2';
string public constant symbol = 'UNI-V2';
複制
而代币的總量 totalSupply 最初為 0,可通過調用 _mint() 函數鑄造出來,還可通過調用 _burn() 進行銷毀。這兩個函數的代碼實作非常簡單,就是直接在 totalSupply 和指定賬戶的 balance 上進行加減,隻是,兩個函數都是 internal 的,是以無法外部調用,代碼如下:
function _mint(address to, uint value) internal {
totalSupply = totalSupply.add(value);
balanceOf[to] = balanceOf[to].add(value);
emit Transfer(address(0), to, value);
}
function _burn(address from, uint value) internal {
balanceOf[from] = balanceOf[from].sub(value);
totalSupply = totalSupply.sub(value);
emit Transfer(from, address(0), value);
}
複制
另外,UniswapV2ERC20 還提供了一個 permit() 函數,它允許使用者在鍊下簽署授權(approve)的交易,生成任何人都可以使用并送出給區塊鍊的簽名。關于 permit 函數具體的作用和用法,網上已經有很多介紹文章,我這裡就不展開了。
除此之後,剩下的都是符合 ERC20 标準的函數了。
配對合約
前面說過,配對合約是由工廠合約建立的,我們從構造函數和初始化函數中就可以看出來:
constructor() public {
factory = msg.sender;
}
// called once by the factory at time of deployment
function initialize(address _token0, address _token1) external {
require(msg.sender == factory, 'UniswapV2: FORBIDDEN'); // sufficient check
token0 = _token0;
token1 = _token1;
}
複制
構造函數直接将 msg.sender 設為了 factory ,factory 就是工廠合約位址。初始化函數 require 調用者需是工廠合約,而且工廠合約中隻會初始化一次。
不過,不知道你有沒有想到,為什麼還要另外定義一個初始化函數,而不直接将 _token0 和 _token1在構造函數中作為入參進行初始化呢?這是因為用 create2 建立合約的方式限制了構造函數不能有參數。
另外,配對合約中最核心的函數有三個:mint()、burn()、swap() 。分别是添加流動性、移除流動性、兌換三種操作的底層函數。
mint() 函數
先來看看 mint() 函數,主要是通過同時注入兩種代币資産來擷取流動性代币:
既然這是一個添加流動性的底層函數,那參數裡為什麼沒有兩個代币投入的數量呢?這可能是大部分人會想到的第一個問題。其實,調用該函數之前,路由合約已經完成了将使用者的代币數量劃轉到該配對合約的操作。是以,你看前五行代碼,通過擷取兩個币的目前餘額 balance0 和 balance1,再分别減去 _reserve0 和 _reserve1,即池子裡兩個代币原有的數量,就計算得出了兩個代币的投入數量 amount0和 amount1。另外,還給該函數添加了 lock 的修飾器,這是一個防止重入的修飾器,保證了每次添加流動性時不會有多個使用者同時往配對合約裡轉賬,不然就沒法計算使用者的 amount0 和 amount1 了。
第 6 行代碼是計算協定費用的。在工廠合約中有一個 feeTo 的位址,如果設定了該位址不為零位址,就表示添加和移除流動性時會收取協定費用,但 Uniswap 一直到現在都沒有設定該位址。
接着從第 7 行到第 15 行代碼則是計算使用者能得到多少流動性代币了。當 totalSupply 為 0 時則是最初的流動性,計算公式為:
liquidity = √(amount0*amount1) - MINIMUM_LIQUIDITY
複制
即兩個代币投入的數量相乘後求平方根,結果再減去最小流動性。最小流動性為 1000,該最小流動性會永久鎖在零位址。這麼做,主要還是為了安全,具體原因可以檢視白皮書和官方文檔的說明。
如果不是提供最初流動性的話,那流動性則是取以下兩個值中較小的那個:
liquidity1 = amount0 * totalSupply / reserve0
liquidity2 = amount1 * totalSupply / reserve1
複制
計算出使用者該得的流動性 liquidity 之後,就會調用前面說的 _mint() 函數鑄造出 liquidity 數量的 LP Token 并給到使用者。
接着就會調用 _update() 函數,該函數主要做兩個事情,一是更新 reserve0 和 reserve1,二是累加計算 price0CumulativeLast 和 price1CumulativeLast,這兩個價格是用來計算 TWAP 的,後面再講。
倒數第 2 行則是判斷如果協定費用開啟的話,更新 kLast 值,即 reserve0 和 reserve1 的乘積值,該值其實隻在計算協定費用時用到。
最後一行就是觸發一個 Mint() 事件的發出。
burn() 函數
接着就來看看 burn() 函數了,這是移除流動性的底層函數:
該函數主要就是銷毀掉流動性代币并提取相應的兩種代币資産給到使用者。
這裡面第一個不太好了解的就是第 6 行代碼,擷取目前合約位址的流動性代币餘額。正常情況下,配對合約裡是不會有流動性代币的,因為所有流動性代币都是給到了流動性提供者的。而這裡有值,其實是因為路由合約會先把使用者的流動性代币劃轉到該配對合約裡。
第 7 行代碼計算協定費用和 mint() 函數一樣的。
接着就是計算兩個代币分别可以提取的數量了,計算公式也很簡單:
amount = liquidity / totalSupply * balance
提取數量 = 使用者流動性 / 總流動性 * 代币總餘額
複制
我調整了下計算順序,這樣就能更好了解了。使用者流動性除以總流動性就得出了使用者在整個流動性池子裡的占比是多少,再乘以代币總餘額就得出使用者應該分得多少代币了。舉例:使用者的 liquidity 為 1000,totalSupply 有 10000,即是說使用者的流動性占比為 10%,那假如池子裡現在代币總額有 2000 枚,那使用者就可分得這 2000 枚的 10% 即 200 枚。
後面的邏輯就是調用 _burn() 銷毀掉流動性代币,且将兩個代币資産計算所得數量劃轉給到使用者,最後更新兩個代币的 reserve。
最後兩行代碼也和 mint() 函數一樣,就不贅述了。
swap() 函數
swap() 就是做兌換交易的底層函數了,來看看代碼:
該函數有 4 個入參,amount0Out 和 amount1Out 表示兌換結果要轉出的 token0 和 token1 的數量,這兩個值通常情況下是一個為 0,一個不為 0,但使用閃電交易時可能兩個都不為 0。to 參數則是接收者位址,最後的 data 參數是執行回調時的傳遞資料,通過路由合約兌換的話,該值為 0。
前 3 行代碼很好了解,第一步先校驗兌換結果的數量是否有一個大于 0,然後讀取出兩個代币的 reserve,之後再校驗兌換數量是否小于 reserve。
從第 6 行開始,到第 15 行結束,用了一對大括号,這主要是為了限制 _token{0,1} 這兩個臨時變量的作用域,防止堆棧太深導緻錯誤。
接着,看看第 10 和 11 行,就開始将代币劃轉到接收者位址了。看到這裡,有些小夥伴可能會産生疑問:這是個 external 函數,任何使用者都可以自行調用的,沒有校驗就直接劃轉了,那不是誰都可以随便提币了?其實,在後面是有校驗的,我們往下看就知道了。
第 12 行,如果 data 參數長度大于 0,則将 to 位址轉為 IUniswapV2Callee 并調用其 uniswapV2Call() 函數,這其實就是一個回調函數,to 位址需要實作該接口。
第 13 和 14 行,擷取兩個代币目前的餘額 balance{0,1} ,而這個餘額是扣減了轉出代币後的餘額。
第 16 和 17 行則是計算出實際轉入的代币數量了。實際轉入的數量其實也通常是一個為 0,一個不為 0 的。要了解計算公式的原理,我舉一個執行個體來說明。
假設轉入的是 token0,轉出的是 token1,轉入數量為 100,轉出數量為 200。那麼,下面幾個值将如下:
amount0In = 100
amount1In = 0
amount0Out = 0
amount1Out = 200
複制
而 reserve0 和 reserve1 假設分别為 1000 和 2000,沒進行兌換交易之前,balance{0,1} 和 reserve{0,1} 是相等的。而完成了代币的轉入和轉出之後,其實,balance0 就變成了 1000 + 100 - 0 = 1100,balance1 變成了 2000 + 0 - 200 = 1800。整理成公式則如下:
balance0 = reserve0 + amount0In - amout0Out
balance1 = reserve1 + amount1In - amout1Out
複制
反推一下就得到:
amountIn = balance - (reserve - amountOut)
複制
這下就明白代碼裡計算 amountIn 背後的邏輯了吧。
之後的代碼則是進行扣減交易手續費後的恒定乘積校驗,使用以下公式:
其中,0.003 是交易手續費率,X0 和 Y0 就是 reserve0 和 reserve1,X1 和 Y1 則是 balance0 和 balance1,Xin 和 Yin 則對應于 amount0In 和 amount1In。該公式成立就說明在進行這個底層的兌換之前的确已經收過交易手續費了。
總結
限于篇幅,本篇内容就先講到這裡,剩下的部分留待下篇再繼續講解。