剛看完 Uniswap v2 的代碼,本來打算寫一個 Uniswap v2 設計與實現,結果 Uniswap v3 就發布了。趁著這個機會就先寫一個 Uniswap v3 設計與實現吧。
因為 v3 版本的實現復雜度和 v2 已經不在一個量級了,難免會有理解上的偏差,本文權當是拋磚引玉,也希望有更多的人參與討論。因為實現比較復雜,本系列會拆分成多篇文章,持續更新。
本文假定讀者都能理解 AMM 的基本概念,并且閱讀過 v3 的實現細節(最好讀過白皮書)來撰寫的,因此不會具體的解釋每一個概念的實現邏輯。
設計原理
官方的白皮書已經比較詳盡的描述了 v3 的設計原理,這里僅對白皮書中的內容做一些補充,包含本人對其中一些機制的理解和思考。
Uniswap v2 版本使用 x⋅y=kx⋅y=k 這樣一個簡潔的公式實現了 AMM Dex,正是由于其簡潔易用性,使其在短短的一年的時間內迅速成長為 DeFi 領域的龍頭項目。但是隨著 DeFi 生態走過了「從無到有」的階段,因為 v2 無法滿足某些特定需求,從而誕生了 Curve, Balancer 這些針對某些功能進行改進的 AMM。
簡單來說,官方認為 v2 版本最大的痛點是資金利用率(Capital Efficiency)太低,v3 版本在解決這個問題的同時,還帶了了新的改進,總體總結如下:
可靈活選擇價格區間提供流動性
更好用的預言機
order book 功能
靈活的費率
提升資金利用率
解決資金利用問題之前,我們可以觀察到大部分的交易對的價格,在大部分時間內都只是在一個固定范圍內波動。例如 ETH/DAI 交易對,在近一個月時間內都是在 1300 ~ 2200 DAI/ETH 這個范圍內波動。更極端的例子是 DAI/USDC 這樣的穩定幣交易對,在大部分時間內都只是在 1.001 ~ 1.002 DAI/USDC 范圍內波動。
v2 的問題
我們先來看一看 v2 版本的資金利用率是怎樣的,假設 ETH/DAI 交易對的實時價格為 1500 DAI/ETH,交易對的流動性池中共有資金:4500 DAI 和 3 ETH,根據 x⋅y=kx⋅y=k,可以算出池內的 k 值:
k=4500×3=13500k=4500×3=13500
假設 xx 表示 DAI,yy 表示 ETH,即初始階段 x1=4500,y1=3×1=4500,y1=3,當價格下降到 1300 DAI/ETH 時:
{x2⋅y2=13500x2y2=1300{x2⋅y2=13500x2y2=1300
得出 x2=4192.54,y2=3.22×2=4192.54,y2=3.22,資金利用率為:Δxx1=6.84%Δxx1=6.84%。同樣的計算方式,當價格變為 2200 DAI/ETH 時,資金利用率約為 21.45%.
也就是說,在大部分的時間內池子中的資金利用與低于 25%. 這個問題對于穩定幣池來說更加嚴重。
解決方案
v3 版本的解決方案是允許用戶只在一段價格區間內提供流動性。如下圖:
此圖展示了一個 x⋅y=kx⋅y=k 的函數曲線圖。為了滿足讓用戶可以選擇只在 [a,b][a,b] 價格區間內提供流動性。對于圖中 [a,b][a,b] 區間的任意點,都有:
x=xvirtual+xrealy=yvirtual+yrealx=xvirtual+xrealy=yvirtual+yreal
其中 xreal,yrealxreal,yreal 分別表示用戶提供的 x token, y token 數量,xvirtual,yvirtualxvirtual,yvirtual 分別表示流動池虛擬出的 x token y token 數量。當流動池的價格來到用戶設置的零界點時(例如圖中的 a 點或者 b 點),用戶實際提供的 x token 或者 y token 將為 0,x 或 y 將完全由虛擬 token 組成。當價格進一步變動,移動到用戶設定的價格區間之外時,流動池將移除這部分流動性,以保證虛擬的 x token 或 y token 數量不會減少,因此這部分虛擬的 token 只會在價格處于設定的區間內時參與價格的計算,而不會真的參與流動性提供。
例如,當價格到達 a 點時,用戶的所有資金轉換為 x,此時 yreal=0,y=yvirtualyreal=0,y=yvirtual,當價格繼續降低時,流動池將移除這部分流動性。用戶的資金狀態將停留在 a 點,直至價格再次回到 a 點并進入 [a,b][a,b] 價格區間。
通過這樣的設計,用戶的資金只會在 [a,b][a,b]價格區間內提供流動性,并且因為虛擬 token xvirtual,yvirtualxvirtual,yvirtual 的參與,這部分流動性也滿足 x⋅y=kx⋅y=k 公式,計算價格的方式并沒有產生變化。
上圖展示了用戶選擇在價格 [a,b][a,b] 之間提供流動性時,通過虛擬 token 的參與,將曲線 f(real)f(real) (橘紅色)向右上方移動至 f(virtual)f(virtual)(綠色),實現了價格計算的一致性(即滿足x⋅y=kx⋅y=k)。
交易過程
v2 版本
在 v2 版本中,用戶與一個交易對發生交易時,假設用戶提供 x token,資金量為 ΔxΔx,AMM 需要計算出用戶可以得到的 y token,即 ΔyΔy. 如下圖所示,池中資金從 a 點隨著曲線移動到 b 點:
可以用過下面步驟計算 ΔyΔy:
x⋅y=(x+Δx)(y−Δy)=kx⋅y=(x+Δx)(y−Δy)=k
計算出:
Δy=y−x⋅yx+Δx=Δxyx+ΔxΔy=y−x⋅yx+Δx=Δxyx+Δx
具體的實現,可以參考 v2 代碼實現。
v3 版本
在 v3 版本中,因為一個交易池中會有多個不同深度的流動池(每一個可以單獨設置交易價格區間),因此一次交易的過程可能跨越多個不同的深度:
如上圖最右邊所示,當價格變化時,流動池中的總流動性也會隨之變化。因此 v3 版本流動池中資金的關系不能像 v2 版本一樣用一個平滑的 bonding curve 曲線來表示。那麼如何計算交易結果呢?
因為 v3 版本交易可能并不在一個平滑的曲線中進行,需要根據池中資金的價格,選用不用的流動性來進行計算。流動性可以用 kk 表示,即 k=x⋅yk=x⋅y,用 PP 表示 xx 的價格,即 P=yxP=yx
因為每一次的價格變動都可能會引起流動性的變化,v3 需要圍繞價格來進行交易相關的計算,例如當使用 x token 交換 y token 時:
交易至指定價格(通常是某一個流動性的價格邊界)PP,需要的 x token 數 ΔxΔx,可以獲得的 y token 數 ΔyΔy
給定 x token 數 ΔxΔx(假設此交易不會引起流動性發生變化),可以獲得的 y token 數 ΔyΔy,以及最終的價格 PP
當 k 值不變時,根據定義:
{x⋅y=kP=yx{x⋅y=kP=yx
可以推導出:
⎧⎩⎨⎪⎪x=kP−−√y=kP−−−√⇒⎧⎩⎨Δx=Δ1P−−√⋅k−−√Δy=ΔP−−√⋅k−−√{x=kPy=kP⇒{Δx=Δ1P⋅kΔy=ΔP⋅k
這樣一來計算過程并不需要關注池中的 x token 和 y token 余額,通過 kk 值和價格 PP 就可以完成交易過程的計算。
為了減少計算過程中的開根號運算,v3 合約直接存儲 P−−√P 的值,同時合約中沒有存儲 kk 的值而是存儲 L=k−−√L=k,通過 LL 來表示池中當前的流動性大小(存儲 LL 還有一個好處是減少溢出的可能性)。
在實際交易過程中,一個交易可能跨越不同的流動性階段,因此合約需要維護每個用戶提供流動性的價格邊界,當價格到達邊界時,需要增加或移除對應流動性。通過分段計算的方式完成交易結果的計算,具體的實現過程會在后面的代碼分析中講解。
價格精度問題
因為用戶可以在任意 [P0,P1][P0,P1] 價格區間內提供流動性,Uniswap v3 需要保存每一個用戶提供流動性的邊界價格,即 P0P0 和 P1P1。這樣就引入了一個新的問題,假設兩個用戶提供的流動性價格下限分別是 5.00000001 和 5.00000002,那麼 Uniswap 需要標記價格為 5.00000001 和 5.00000002 的對應的流動性大小。同時當交易發生時,需要將 [5.00000001,5.00000002][5.00000001,5.00000002] 作為一個單獨的價格區間進行計算。這樣會導致:
幾乎很難有兩個流動性設置相同的價格邊界,這樣會導致消耗大量合約存儲空間保存這些狀態
當進行交易計算時,價格變化被切分成很多個小的范圍區間,需要逐一分段進行計算,這會消耗大量的 gas,并且如果范圍的價差太小,可能會引發計算精度的問題
Uniswap v3 解決這個問題的方式是,將 [Pmin,Pmax][Pmin,Pmax] 這一段連續的價格范圍為,分割成有限個離散的價格點。每兩個相鄰的價格區間稱為一個 tick,用戶在設置流動性的價格區間時,只能選擇這些離散的價格點中的某一個作為流動性的邊界價格。
Uniswap v3 采用了等比數列的形式確定價格數列,公比為 1.0001。即下一個價格點為當前價格點的 100.01%,前面我們說過 Uniswap v3 實際存儲的是 P−−√P,那麼下一個價格與當前價格的關系為
Pnext−−−−√=1.0001−−−−−√⋅Pcurrent−−−−−−√Pnext=1.0001⋅Pcurrent
如此一來 Uniswap v3 可以提供比較細粒度的價格選擇范圍(每個可選價格之間的差值為 0.01%),同時又可以將計算的復雜度控制在一定范圍內。
tick 管理
簡單說,一個 tick 就代表 Uniswap 價格的等比數列中的某一個價格,因此每一個用戶提供的流動性的價格邊界可以用 ticklowerticklower 和 tickuppertickupper 來表示。為了計算的方便,對于每一個交易對,uni 都定義有一個價格為 1 的 tick。將所有 tick 通過索引來表示,定義整數 ii 表示 tick 的索引:
i=log1.0001√p–√i=log1.0001p
這樣一來,只需要通過整數索引 ii 就能找到對應的 tick,并且 ii 為 0 時價格為 1.
Uniswap 不需要記錄每個 tick 所有的信息,只需要記錄所有作為 upper/lower tick 所包含的流動性元數據即可。看下面這個例子:
兩個用戶分別在 [a,c][a,c] 和 [b,d][b,d] 兩個區間提供了流動性 L1L1 和 L2L2,對于 Uniswap 來說它會在 a, b, c, d 四個 tick 上記錄對應的流動性增減情況。例如當價格從圖中從左向右移動時,代幣池的流動性需要做對應的增減(即從左側 tick 進入一個流動性時增加流動性,移出流動性的右側 tick 時減去相應的流動性)。
靈活的手續費選擇
v3 版本內置了三種梯度的手續費率(0.05%, 0.30%, and 1.00%),同時可以在未來增加更多的費率值。關于手續費的計算過程,這部分放在后文來詳解(鏈接:交易手續費)。需要注意的是,由于需要支持多種費率,同一個代幣對 v3 版本會有多個不同的流動池。例如 ETH/DAI 代幣對,會分成三個池,分別對應 0.05%, 0.30%, 1.00% 的手續費。
更多的費率選擇性,這樣做會更加靈活,但是同時也會帶來一定的流動性分裂,uni 官方表示后續可以通過治理添加更多的費率可選值,這也勢必會讓流動性更加分裂。那麼可能會出現一種情況是,即使是只使用 uniswap v3 這單個 AMM 來完成一筆交易,但是因為代幣對的流通性分散在多個池子中。那麼最優的交易策略是使用交易聚合器(例如 1inch)來進行交易,即將單筆交易拆散,同時使用多個流動性池來完成交易。就目前 uniswap v3 前端代碼情況來看,官方的界面是不支持這種聚合交易的,其 sdk 代碼中的注釋也說明了這個問題:SDK 代碼。
手續費與 tick 的關系
前文說過,為了減少開根號的計算,Uniswap 記錄的是 P−−√P,v3 使用 Q64.96
精度的定點數來存儲 P−−√P 的內容,那麼可以支持的 Pmax−−−−√≈264Pmax≈264,為了對應,讓 Pmin−−−−√=2−64Pmin=2−64
那麼可以計算出對于 tick 來說, imin=−887272,imax=887272imin=−887272,imax=887272
我們知道 tick 越多,價格可選的值越精細,但是合約在計算時候的價格區間就可能越多,那麼 gas 消耗也會更加的多,因此我們需要讓 tick 的數量保持在一個合理的范圍內。Uniswap 針對不同類型的代幣對推薦使用不同類型的費率。
例如穩定幣交易對 USDC/USDT,它的范圍波動比較小,我們需要給它更精細的價格可選值,并且設置一個比較低的手續費(0.05%)。Uniswap 引入了 tickSpacing
的概念,即每個 tick 之間跳過 N 個 tick,這樣讓合約在計算的時候,gas 更可控。
對于價格波動較小的交易池,我們希望 tickSpacing 更小,這樣價格可選值更多,同時也希望費率更低。反之波動大的交易對,可以讓 tickSpacing 更大,這樣更節約 gas,但是我們希望它的費率更高。
Uniswap 默認設置了費率和 tickSpacing 的關系:
費率 | tickSpacing |
---|---|
0.05% | 10 |
0.30% | 60 |
1.00% | 200 |
代碼架構
Uniswap v3 在代碼層面的架構和 v2 基本保持一致,將合約分成了兩個倉庫:
uniswap-v3-core
uniswap-v3-periphery
core 倉庫的功能主要包含在以下 2 個合約中:
UniswapV3Factory: 提供創建 pool 的接口,并且追蹤所有的 pool
UniswapV3Pool: 實現代幣交易,流動性管理,交易手續費的收取,oracle 數據管理。接口的實現粒度比較低,不適合普通用戶使用,錯誤的調用其中的接口可能會造成經濟上的損失。
peirphery 倉庫的功能主要包含在以下 2 個合約:
SwapRouter: 提供代幣交易的接口,它是對 UniswapV3Pool 合約中交易相關接口的進一步封裝,前端界面主要與這個合約來進行對接。
NonfungiblePositionManager: 用來增加/移除/修改 Pool 的流動性,并且通過 NFT token 將流動性代幣化。使用 ERC721 token(v2 使用的是 ERC20)的原因是同一個池的多個流動性并不能等價替換(v3 的集中流性動功能)。
這些合約間的關系大致如下圖:
本系列后續會從常用的 Uniswap v3 操作入手,講解代碼調用流程。一般來說,用戶的操作都是與 uniswap-v3-periphery 開始。
Update 05-23
本系列文章主要參考的是 Uniswap v3 3月底的代碼,已經和其最新代碼又一定差異,但是這部分差異不大,并不會影響主體業務邏輯的理解。
發文者:鏈站長,轉載請註明出處:https://www.jmb-bio.com/4166.html