点击量:770
Uniswap V3
序言
我们知道Uniswap V3相比于V2有一个最大的变化就是:集中流动性(Concentrated Liquidity),它可以让LP选择在某段价格区间内提供流动性,从而大幅度提高资金使用效率,官方声称在0.1%的价格区间上最大能达到4000倍,不可谓不疯狂。为什么可以提高资金效率,原理也很简单:我们知道V2里面LP其实是在\([0, \infty]\) 上提供流动性,然而实际swap的价格波动大概率在一个很有限的范围内,因此实际用到的资金其实很少。集中新流动性这个想法听上去很好,但它是如何实现的呢?想要深入理解V3是如何实现的,强烈推荐先看下官方的白皮书:UniswapV3 WhitePaper,工程实现其实是非常复杂的,理解起来也不容易,这正是本文的目的所在。(另外,关于V3以及V2/V1的功能性介绍强烈推荐这篇文章:Uniswap v3 Explained – All You Need to Know )
流动性
在V3里,当我们说添加流动性的时候,这个流动性到底指的是什么?
解释这点之前,我们先思考一个问题:既然LP可以选择在区间内提供流动性,那么就说明这种流动性是不连续的,而且不同LP选择的价格区间又不一样,很有可能重叠。那么这种情况下,还如何按照恒定乘积公式去swap呢?这里就有一个非常重要的概念:虚拟流动性(Virtual Liquidity),我们把它标记为\(L\)。我们把所有包含当前价格的价格区间所提供的流动性总和定义为总的虚拟流动性\(L\),当价格穿过不同区间时虚拟流动性\(L\)会发生增减。假设当前的价格是\(c\),当我们在\([a,b)\)的价格区间往池子里贡献了\(\Delta x\)和\(\Delta y\)的虚拟流动性\(\Delta L\)。这意味着,我们提供的这部分真实资产\(\Delta x\)和\(\Delta y\)可以支撑价格在虚拟流动性曲线\(L\)上从\(c\)点移动到\([a, b)\)点。也可以说,虚拟流动性曲线\(L\)上价格从\(c\)点移动到\([a, b)\)点所需要的真实资产数量分别是\(\Delta x\)和\(\Delta y\)。更概括的说,就是把不同LP在不同区间上提供的流动性都映射到同一条连续的曲线\(L\)上,然后按照恒定乘积公式(\(x*y=L^2\))进行swap。这点是理解V3的核心,我之前总是纠结于每个tick上的流动性,导致白皮书看的云里雾里,其实更应该关注的是价格变化和总的流动性。
资产数量与虚拟流动性的换算
理解了上面这一点之后,这个计算就很容易了。假设当前价格是\(c\),假如我们想在价格区间是\([a, b)\)提供\(x\)数量的资产,那么它对应的虚拟流动性是多少?以及相对应的\(y\)资产的数量?
首先,根据恒定乘积公式我们可以推导出:
$$
\begin{align*}
& \sqrt P = \sqrt {\frac{y}{x}} \\
& L = \sqrt {x y}
\end{align*}
$$
然后我们只需要把\(y\)替换掉,即可得到\(x\),\(L\)和\(P\)的关系:
$$
x = \frac{L}{\sqrt P}
$$
我们知道实际提供的资产x的数量是 \(x_{real} = x_c – x_b\),于是可以得到:
$$
x_{real} = L (\frac{1}{\sqrt P_c} – \frac{1}{\sqrt P_b})
$$
于是,根据我们想要提供的\(x_{real}\)以及价格区间,我们就可以很容易算出对应的虚拟流动性\(L\),有了\(L\)之后同理可得\(y\)的值(还有两种区间是不包括当前价格的,计算方法类似,不再赘述)
其实,上面的公式其实可以更通用一点:
$$
\begin{align*}
& \Delta{x} = \frac{1}{\Delta {\sqrt P }} L \\
& \Delta{y} = \Delta{\sqrt P} L \\
\end{align*}
$$
上面这个公式很重要,它可以帮助我们在swap的时候计算某个tick上能提供多少数量的资产,也就是真实的流动性。我们只需要知道当前的总的虚拟流动性\(L\)和价格变化\(\Delta P\)就可以很容易的知道\(\Delta{x}\)和\(\Delta{y}\)。在没有理解虚拟流动性之前,我以为这会是个很复杂的计算,没想到这么巧妙。
流动性增减
上面我们说过虚拟流动性L的计算方式:我们把所有包含当前价格的价格区间所提供的流动性总和定义为总的虚拟流动性。如果我们从tick区间的方式去计算这个流动性,计算会比较复杂,相反,我们可以采用一个比较简便的方式,以tick为单位去计算某个tick点上的净流动性,这里就引入了一个liquidityNet的概念;
假设当前的流动性分布如下图线段所示:
$$
——a——b——
$$
当价格从左向右移动到达a点时,我们会发现所有以a作为下界(lower)的区间开始变得active,我们需要把这部分流动性加进来(kick in),同时,所有以a为上界(upper)的区间流动性都变成inactive,因此我们需要从全局流动性\(L\)中移除这部分流动性(kick out)。也就是说,一个tick既可以作为某个区间的上界,也可以作为另一个区间的下届,在这个点上,两个方向上的流动性是相反的,一增一减。因此我们需要一个字段liquidityNet用于表示该tick上的净流动性。
当我们在[a, b)区间提供数量为deltaLiquidity的流动性时,两个tick上对应的liquidityNet的计算方式如下:
1 2 3 |
tick_a.liquidityNet = tick_a.liquidityNet + deltaLiquidity; tick_b.liquidityNet = tick_b.liquidityNet - deltaLiquidity; |
从另一个角度理解:我们假设价格从a移动到b,当到达在a点时,我们会把用户的流动性加进去,而到b时,该用户已经不提供流动性,这时候再把他贡献的这部分流动性减掉;反向移动,则操作相反;
我们看下Uniswap的合约代码关于这部分的实现,在添加流动性时mint方法会调用_updatePosition方法(UniswapV3Pool.sol-Mint):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
<br />flippedLower = ticks.update( tickLower, tick, liquidityDelta, _feeGrowthGlobal0X128, _feeGrowthGlobal1X128, secondsPerLiquidityCumulativeX128, tickCumulative, time, false, maxLiquidityPerTick ); flippedUpper = ticks.update( tickUpper, tick, liquidityDelta, _feeGrowthGlobal0X128, _feeGrowthGlobal1X128, secondsPerLiquidityCumulativeX128, tickCumulative, time, true, maxLiquidityPerTick ); |
这个方法对LowerTick和UpperTick各调用了一次Tick.Update方法,那么我们看下这个方法是如何实现的(Tick.sol-Update):
1 2 3 4 5 |
// when the lower (upper) tick is crossed left to right (right to left), liquidity must be added (removed) info.liquidityNet = upper ? int256(info.liquidityNet).sub(liquidityDelta).toInt128() : int256(info.liquidityNet).add(liquidityDelta).toInt128(); |
这样,我们就可以以tick为单位计算总体的虚拟流动性了:
- 从左到右穿过tick时,
1 2 |
totalLiquidity = totalLiquidity + tick.liquidityNet; |
- 从右到左穿过tick时,
1 2 |
totalLiquidity = totalLiquidity - tick.liquidityNet; |
Uniswap的合约代码(UniswapV3Pool.sol-Swap):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
int128 liquidityNet = ticks.cross( step.tickNext, (zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128), (zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128), cache.secondsPerLiquidityCumulativeX128, cache.tickCumulative, cache.blockTimestamp ); // if we're moving leftward, we interpret liquidityNet as the opposite sign // safe because liquidityNet cannot be type(int128).min if (zeroForOne) liquidityNet = -liquidityNet; state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet); |
于此同时,Uniswap还使用了一个字段liquidityGross,表示这个tick上Liquidity的绝对值。因为即使liquidityNet=0也不能判断这个tick上已经没有人提供流动性了。而绝对值为0时表示该tick已经没有引用,会把该tick置为unintilized状态。
Swap
接下来,我们理解下swap的流程。根据上文的介绍,根据当前流动性\(L\)和\(\Delta P\),我们很容易知道这个tick上可以swap的资产数量是多少。那么根据这个数量就可以很容易的推断出swap的流程:
1、如果需要swap的目标资产数量小于当前tick能够支撑的资产数量,则继续在当前tick内swap;
2、如果当前tick上的资产数量不够swap,这时会发生cross-ticking,也就是要move到下一个tick,重新计算流动性。然后到新的tick上再继续swap剩余的数量;
Fee
首先V3里的fee是按照token0和token1单独记录的,而不是像V2那样记录到总的Liquidity。那么计算方式也很简单,swap的数量乘以费率,具体可以看下白皮书,这部分不介绍了。下面重点介绍下,V3是如何track某段tick区间上的fee的。
假设我们不考虑存储限制,如果是你设计fee模块,你会怎么设计?很简单,把每个tick上收到的fee都记录下来,fee per unit liquidity,要计算某段区间上的流动性,只需要把这段区间上的所有tick上的流动性累加起来即可;这是很容易理解,但是,这种方式是低效的;区块链应用对存储有着严苛的要求,在密度极高的tick区间上频繁的更新和读取数据,会产生极高的gas fee,导致工程上是不可实现的;那么,Uniswap是怎么做的呢?
Uniswap的整体设计原则都是从tick区间出发的,比如添加流动性时只记录这段区间上总的流动性,而不是这段区间上每个tick的流动性;那么自然而然的,Uniswap希望fee的记录也遵循这一原则。
feeGrowthInRange
如果我们可以记录下upper tick \(i_u\)的右侧(above)以及lower tick\(i_l\)左侧(below)所累计的fee,那么计算出这段区间(下图蓝色区间)上的fee就变得很容易了:
于是我们有:
$$
f_r = f_g – f_a(i_u) – f_b(i_l)
$$
其中:\(f_g\)是一个全局变量,表示这个pool收到的累计的fee,是不断增长的;那么,接下来,我们该怎么计算\(f_a(i_u)\)和\(f_b(i_l)\)呢?
feeGrowthOutside
白皮书里介绍,每个tick上有一个结构体:tick-Indexed State,里面有两个字段:feeGrowthOutside0X128和feeGrowthOutside1X128,分别标记为:\(f_{o,0}\),\(f_{o,1}\)。其中,o代表的是outside(并不是0);0和1分别表示token0和token1,讨论的时候我们只需要讨论一种token即可;因此,我们把这两个变量简化为一个:\(f_{o}(i)\),它表示的是:tick \(i\)的外侧所累计的总的fee。那么什么是外侧呢?某个tick \(i\)的外侧指的是相对于当前tick \(i_c\)的相反侧(另一侧);也就是说:如果tick \(i_c\)在tick \(i\)的左侧,那么tick \(i\)的外侧指的就是tick \(i\)的右侧;而如果tick \(i_c\)在tick \(i\)的由侧,那么tick \(i\)的外侧指的就是tick \(i\)的左侧;这是一个随着价格动态变化的值,外侧这个概念是理解fee的关键;
有了feeGrowthOutside这个变量,计算上节提到的\(f_a(i_u)\)和\(f_b(i_l)\)就变得很容易了:
$$
\begin{equation}
f_{a}(i)=
\begin{cases}
f_g – f_o(i) & i_c \geq i \\
f_o(i) & i_c < i \\
\end{cases}
\end{equation}
$$
$$
\begin{equation}
f_{b}(i)=
\begin{cases}
f_o(i) & i_c \geq i \\
f_g – f_o(i) & i_c < i \\
\end{cases}
\end{equation}
$$
注意:这里的a和b并不是价格区间,而是above和below;所以,\(f_a(i)\)表示的是tick \(i\)右侧累计的fee;\(f_b(i)\)表示的是tick \(i\)左侧累计的fee;那么,结合上图这个公式应该不难理解了;
\(f_o\)的更新
首先要明白它什么时候会被更新:很显然,只有tick \(i\) 和当前tick \(i_c\)的相对位置发生变化时,也就是cross tick时才需要更新;更新的方式也很简单:只需要用\(f_g\)减去旧值就可以得到新的outside fee:
$$
f_o(i) = f_g – f_o(i)
$$
\(f_o\)的初始化
当tick \(i\)第一次被访问到的时候,也就是 \(i_c \geq i\),那么很明显,\(i\)的外侧所收的fee应该就等于\(f_g\),于是,我们有:
$$
\begin{equation}
f_{o}=
\begin{cases}
f_g & i_c \geq i \\
0 & i_c < i \\
\end{cases}
\end{equation}
$$
看到这里,我相信你已经完全理解了UniswapV3的fee模块是如何实现的。回过头来看,这是一个令人赞叹的设计,他们只用了两个变量:一个全局手续费\(f_g\)和每个tick上的外侧手续费\(f_o(i)\)就实现了高效查询任意一段区间上的手续费,一个字:秒!
求分享主题文件,谢谢大佬
感谢分享 我卡在cross tick这里很久了 看了你的解释好像懂了 真厉害
谢谢,获益匪浅!!