首發於【Keegan小鋼】公衆號,歡迎關注獲取更多文章,原文連接: mp.weixin.qq.com/s/ICE77y_Gx…前端
在 DeFi 賽道中,DEX 無疑是最核心的一塊,而 Uniswap 又是整個 DEX 領域中的龍頭,如 SushiSwap、PancakeSwap 等都是 Fork 了 Uniswap 的。雖然網上關於 Uniswap 的文章已經挺多,但大多都只是從機制上進行介紹,不多談及具體實現,也存在一些問題沒能解答,好比:手續費分配是如何實現的?最優路徑是如何得出的?TWAP 怎麼用?注入流動性時返回多少 LP Token 是如何計算的?所以,我從代碼層面去剖析 Uniswap,搞清楚這些問題,同時也對 Uniswap 從總體到細節都有所理解。git
如今,Uniswap 有 V2 和 V3 兩個版本,咱們先來聊聊 V2。github
整個 UniswapV2 產品拆分出了多個小型的開源項目,主要包括:typescript
前三個是前端 App 項目,即提供交易的項目,對應於 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 月份的版本。markdown
uniswap-info 則是 Uniswap Analytics 項目,對應於官網頁面 info.uniswap.org ,展現了一些統計分析數據,其數據主要是從 Subgraph 讀取。uniswap-v2-subgraph 則是 Subgraph 項目了。app
最後三個則是合約項目了,uniswap-v2-core 就是核心合約的實現; uniswap-v2-periphery 則提供了和 UniswapV2 進行交互的外圍合約,主要就是路由合約;uniswap-lib 則封裝了一些工具合約。core 和 periphery 裏的合約實現是咱們後面要重點講解的內容。ide
另外,Uniswap 其實還有一個流動性挖礦合約項目 liquidity-staker ,由於 Uniswap 的流動性挖礦只在去年上線過一段短暫的時間,因此不多人知道這個項目,但我以爲也有必要剖析下這塊的實現,畢竟不少仿盤也都帶有流動性挖礦功能。函數
最後,強烈推薦你們有時間能夠看看崔棉大師的視頻教程,前後發佈過兩套教程:工具
後文中有些關鍵內容也是我從以上視頻中學到的。接着咱們就來聊聊一些關鍵的合約實現了。
core 核心主要有三個合約文件:
配對合約管理着流動性資金池,不一樣幣對有着不一樣的配對合約實例,好比 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 是流動性代幣合約,也稱爲 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() 函數,主要是經過同時注入兩種代幣資產來獲取流動性代幣:
既然這是一個添加流動性的底層函數,那參數裏爲何沒有兩個代幣投入的數量呢?這多是大部分人會想到的第一個問題。其實,調用該函數以前,路由合約已經完成了將用戶的代幣數量劃轉到該配對合約的操做。所以,你看前五行代碼,經過獲取兩個幣的當前餘額 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() 函數了,這是移除流動性的底層函數:
該函數主要就是銷燬掉流動性代幣並提取相應的兩種代幣資產給到用戶。
這裏面第一個不太好理解的就是第 6 行代碼,獲取當前合約地址的流動性代幣餘額。正常狀況下,配對合約裏是不會有流動性代幣的,由於全部流動性代幣都是給到了流動性提供者的。而這裏有值,實際上是由於路由合約會先把用戶的流動性代幣劃轉到該配對合約裏。
第 7 行代碼計算協議費用和 mint() 函數同樣的。
接着就是計算兩個代幣分別能夠提取的數量了,計算公式也很簡單:
amount = liquidity / totalSupply * balance
提取數量 = 用戶流動性 / 總流動性 * 代幣總餘額
複製代碼
我調整了下計算順序,這樣就能更好理解了。用戶流動性除以總流動性就得出了用戶在整個流動性池子裏的佔比是多少,再乘以代幣總餘額就得出用戶應該分得多少代幣了。舉例:用戶的 liquidity 爲 1000,totalSupply 有 10000,便是說用戶的流動性佔比爲 10%,那假如池子裏如今代幣總額有 2000 枚,那用戶就可分得這 2000 枚的 10% 即 200 枚。
後面的邏輯就是調用 _burn() 銷燬掉流動性代幣,且將兩個代幣資產計算所得數量劃轉給到用戶,最後更新兩個代幣的 reserve。
最後兩行代碼也和 mint() 函數同樣,就不贅述了。
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。該公式成立就說明在進行這個底層的兌換以前的確已經收過交易手續費了。
限於篇幅,本篇內容就先講到這裏,剩下的部分留待下篇再繼續講解。
首發於【Keegan小鋼】公衆號,歡迎關注獲取更多文章,原文連接: mp.weixin.qq.com/s/ICE77y_Gx…