摘要
在文章 Uniswap 流动性机制及相关数学原理分析(以下简称原理分析)中,我们详细地推导了流动性和交易相关公式,这些公式都是在假设没有手续费和协议费的情况之下得出的。现实中,Uniswap 是存在手续费和协议费的,引入这些费率后,公式推导过程依然不变,但形式需要相应调整。Uniswap 从每笔交易中收取手续费和协议费,因此最符合直觉的记账方式应当是每笔交易均记录相关费用,这在中心化交易所里是可以接受的,但是在区块链上,任何额外的计算都需要支付 gas 费用,所以 Uniswap 只记录必要的状态,只在需要时才从这些状态计算相关费用,避免额外的成本。这种记账方式省去了不必要的计算,代价就是不直观且更难理解,因此这篇文章将详细剖析 Uniswap 中手续费和协议费机制,包括相关数学公式的推导以及设计和实现方式。此外,Uniswap 已从 V2 升级至了 V3 版本(截至文章写作时,V4 也已经发布,但对于费率机制来说 V3 和 V4 可看作一个版本),V2 和 V3 采用了完全不同的记账和结算方式,这篇文章也会一一分析。
V2 中的手续费和协议费
Uniswap V2 中规定每笔交易收取 3‰(千分之三)的手续费。但请注意,这个手续费不是直接从被交易的资产中扣除 3‰。假设交易者想要使用 \(\Delta{x}\) 的资产换取 \(\Delta{y}\) 的资产,那么手续费的计算过程如下(为了便于公式表达,手续费率使用 \(\gamma\) 表示,现实中 \(\gamma = 0.003\)):
- 从交易者提供的资产数量里扣除 \(\gamma\Delta{x}\),也就是说参与交易的部分只有 \((1-\gamma)\Delta{x}\)
- 根据乘积常数规则,计算出交易者可换取的资产数量 \(\Delta{y}\)
- 若不做扣除,根据原理分析中的公式 (36),交易者可换取更多的资产,假设为 \(\Delta{y'}\),则 \(\Delta{y'} - \Delta{y}\) 就是交易者支付的手续费
需要注意的是,只是在计算交易者可换取的资产数量 \(\Delta{y}\) 时扣除 \(\gamma\Delta{x}\),但交易完成时,交易者向池子里转入的资产数量仍为 \(\Delta{x}\)。
设交易前池子的乘积常数为 \(k\),则上述 1,2 写成数学公式表达就是:
解得:
从原理分析中的公式 (36) 我们知道无手续费时,交易者本应获得的资产数量为:
两式相除并化简,最终可以得到:
从公式 (4) 中可以看出,除非 \(\gamma = 0\),否则比值恒小于 1,其差值部分就是池子收取的手续费。举个例子,假设 \(x = 1000\),\(\Delta{x} = 1\),代入得:
可以看出来,结果非常接近手续费率 \(\gamma\),从公式 (4) 也可以看出来,\(\frac{\Delta{x}}{x}\) 越小,其值就越接近费率 \(\gamma\)。
值得注意的是,交易完成后,池子储量的乘积常数会变大一点点,不再是 \(k\),而是:
结果很好理解,如果没有手续费,交易后储量乘积肯定还是 \(k\),但是因为手续费的存在,转出去的 \(\Delta{y}\) 扣留了一部分在池子里,自然使得乘积的结果变大,这扣留的一部分,就是手续费的来源。
在原理分析中我们推导了提供流动性时,所需资产数量分别为:
其中 \(x_1\) 和 \(y_1\) 分别是此时资产 X 和 Y 的储量,\(L_1\) 是此时流动性代币的总供应量。
两式相乘可得:
开方得:
移除流动性时,可提取的资产数量:
两式相乘可得:
开方得:
其中 \(L_2\) 是此时池子总流动性代币供应量,\(k_2\) 是此时池子储量的乘积。请注意,前面已经分析过,如果有交易,\(k\) 会增大。因此,\(L\) 不一定等于 \(k\)。
公式 (6) 和 (5) 相减,可得:
公式 (7) 说明,在流动性意义下(\(\sqrt{\Delta{x}\Delta{y}}\)),流动性提供者(Liquidity Provider,以下简称 LP)赚取的交易手续费实际上是池子乘积常数的相对增量值。而且请注意,在 \(k_1\) 变到 \(k_2\),\(L_1\) 变到 \(L_2\) 的过程中,可能包含各种交易以及流动性的添加和移除。公式 (7) 的值并不能保证绝对为正数,这也从另一方面体现了所谓的无常损失。
接下来我们讨论协议费,协议费可以看成一个特殊的 LP 来分手续费,Uniswap 规定协议可拿走手续费的一定比例 \(\phi\),现实中 \(\phi = 0.05%\)。为了节省 gas,协议费不是在单笔交易中记录,而是在 LP 添加或者移除流动性时记录,记录方式为给协议地址铸造流动性代币 \(\Delta{L}\),稀释其他 LP 的份额来实现。
那么现在问题变为,新铸多少流动性代币,可以使得协议地址分得规定数量的协议费呢?
假设需要新铸 \(\Delta{L}\) 的代币,一方面根据公式 (7),由于协议费是在每次添加或者移除流动性时记录,在此过程中只有交易发生,因此 \(L_2 = L_1\),故公式 (7) 变为:
公式 (8) 说明,在只有交易发生的情况下,LP 赚取的手续费(流动性意义下)根据其贡献流动性的份额,分走常数乘积的增量 \(\sqrt{k_2}-\sqrt{k_1}\),所以协议需分走的量为 \(\phi(\sqrt{k_2}-\sqrt{k_1})\)。
另一方面,根据公式 (6),给协议地址新铸 \(\Delta{L}\) 流动性代币后,此时若其撤走流动性,其可以获得的代币数量等于 \(\frac{\Delta{L}}{L_1 + \Delta{L}}\sqrt{k_2}\)
因此可得:
解得:
公式 (9) 即为白皮书中的公式 (6),只是将 \(\Delta{L}\) 换为 \(s_m\),\(L_1\) 换为 \(s_1\)。
以上即为 Uniswap V2 手续费和协议费的机制,总结一下:
- LP 赚取的手续费分成由公式 (7) 给出。当然在实现上,LP 撤走流动性时,所提取的代币数量直接通过原理分析中的公式 (22) 和 (23) 计算,手续费分成已含在提取的代币中。
- 若池子启用了协议费,每次 LP 添加或者移除流动性是,根据公式 (9) 为协议地址铸造流动性代币,则可按规定收取协议费。
可以看到,为了计算手续费和协议费,我们需要各种数学公式推导,虽然优雅简洁,但是不容易理解。
接下来我们设计一套更加直观的记账方式,新记账方式的核心是记录每单位流动性可分配的手续费以及总协议费。
由于交易方向不同,抵扣手续费所使用的资产也不同。为此,我们设置了两个全局变量:feeGrowthGlobal0
和 feeGrowthGlobal1
,分别用于记录每单位流动性可分配到的资产 X 手续费和资产 Y 手续费。为了方便,后续的表述中不区分资产种类,统称为 feeGrowthGlobal
。
同理,再设置 2 个全局变量记录总的协议费,分别为 protocolFee0
和 protocolFee1
,统称为 protocolFee
。
每一次交易(swap),设总手续费是 feeAmount
,从总的手续费中拿走一部分 protocalFeeAmount
作为协议费,那么 protocolFee += protocalFeeAmount
。总的流动性是 LiquidityGlobal
,那么交易后 feeGrowthGlobal += (feeAmount - protocalFeeAmount) / LiquidityGlobal
。
以上只是记录了总的每单位流动性可分配的手续费收益,还需要根据每个 LP 提供的流动性占比,记录其可分配到的手续费收益。我们把 LP 提供的流动性叫做 Position,Position 中记录了:
- LP 提供的流动性数量
liquidity
- LP 上一次提取手续费收益时每单位流动性可分配的收益,统称为
feeGrowthGlobalLast
,变量的初始值设置为当前feeGrowthGlobal
。
那么 LP 可分配的手续费收益就是等于 (feeGrowthGlobal - feeGrowthGlobalLast) * liquidity
。可以看到,如果 LP 添加流动性后没有交易发生,此时 feeGrowthGlobal - feeGrowthGlobalLast
等于 0,可分配的收益为 0。此后随着交易发生,feeGrowthGlobal
不断累积增大,LP 就可以分配到其该得的手续费收益。
为什么要设计上面这种记账方式呢?因为随着 V3 引入了范围流动性,再用公式计算就变得很复杂了,V3 中采用的就是上面这种更加直观的记账方式。
V3 中的手续费和协议费
先来说明实践中 V3 对价格的处理方式。原理分析中说了 V3 将流动性限定在一个价格范围。如果价格是连续的,范围两端的价格可以是任意实数,这样价格就会分的太散。所以 V3 将价格离散化,价格只能在这些离散化的点跳动,而不是连续变化。
这样做有一定合理性,实际上中心化交易所也会这样做。盘口挂单时,订单不能是任意价格,通常都有一个最小价格变动单位,例如 BTC 卖一价为 10000.0,最小变动单位是 0.1,那么卖二价格可以挂 10000.10,但 10000.05 的价格永远不会出现。
Uniswap 将离散价格对应的点叫做 tick, tick \(i\) 和价格的映射关系如下:
由于 tick 和价格之间是一一对应的,文章后续表述中会不加区分地混用价格和 tick 。例如价格范围,也可以说成是 tick 范围,以及当前资产价格 \(P\)、其平方根 \(\sqrt{P}\) 和当前 tick 是一回事。
图 1 是价格离散化后,流动性池的一个示意。有 2 个 LP 在 tick \(i_1\) 和 \(i_2\) 间分别添加了流动性 \(L_1\) 和 \(L_2\);另一个 LP 在 \(i_3\) 和 \(i_4\) 间添加了流动性 \(L_3\)。区间 \((i_1,i_2)\) 和 \((i_3,i_4)\) 有一段重合,根据流动性叠加原理,重叠部分流动性为 \(L_1 + L_2 + L_3\)。图中还标出了当前价格对应的 tick \(i_c\)。
图 1
在流动性区间内发生的交易将在该区间内产生手续费,所有在此区间添加了流动性的 LP 都将获得手续费分成,所以现在问题的关键是如何计算流动性区间,例如 \((i_1,i_2)\) 或 \((i_3,i_4)\) 的手续费。
最简单的方法就是直接记录。即定义一个 map,将区间上下边界对应的 tick 区间作为键,值记录这个区间相关的所有信息,包括在这个区间内累积的手续费。这种方式很直观,但是缺点也很明显,一是很多区间可能共享同一个边界 tick,造成一定重复。二是在当前价格 \(i_c\) 发生的交易,需要遍历所有包含此价格的区间,更新其手续费。
Uniswap 采用了另外的记账方式,不是记录区间信息,而只记录区间边界 tick 的信息。当需要知道某个区间的信息时,从区间边界 tick 的信息就可以计算出区间的信息。例如我们首先记一个全局手续费变量 \(f_g\),这是整个池子累积的手续费。此外,我们还记录 \(i_1\) 左边的手续费 \(f_b(i_1)\),以及 \(i_2\) 右边的手续费 \(f_a(i_2)\),那么 \((i_1, i_2)\) 区间累积的手续费就等于 \(f_g - f_b(i_1) - f_a(i_2)\)。当然这种方式仍然存在缺点,就是需要同时记录一个 tick 左边和右边累积的手续费。Uniswap 进一步使用了更加巧妙的方式,这种方式只需记录单边手续费就足够了。
Uniswap 引入一个外边 (outside) 的概念,从图 1 来看,一个 tick 将区域分割成左边和右边(Uniswap V3 白皮书中称为下边 below 和上边 above,我们这里为了直观起见叫左边和右边,但依然沿用白皮书中的公式下标),那么外边就是不含当前价格的一边。例如图 1 中,\(i_1\) 的外边是蓝色区域,而 \(i_4\) 的外边是红色区域,\(i_2\) 和 \(i_3\) 的外边同理。
一个 tick 左边和右边的手续费可以通过外边表示。以 \(i_1\) 为例,其左边手续费 \(f_b(i_1)\) 就等于外边的手续费 \(f_o(i_1)\),而右边手续费 \(f_a(i_1) = f_g - f_o(i_1)\),即全局手续费减去外边手续费。对 \(i_2\) 则刚好相反,其右边手续费 \(f_a(i_2) = f_o(i_2)\),左边手续费 \(f_b(i_2) = f_g - f_o(i_2)\)。总结起来,对于任意 tick \(i\),如果其外边的手续费为 \(f_o(i)\),则左边手续费 \(f_b(i)\) 和右边手续费 \(f_a(i)\) 分别为:
其中 \(i_c\) 为当前价格对应的 tick。上述公式实际上就是白皮书中的公式 (6.17) 和 (6.18)。
对任意区间 \((i_l, i_u)\),可计算出累积手续费为:
上述公式实际上就是白皮书中的公式 (6.19)
外边手续费的初始值设置规则如下:
按白皮书中说的就是:
When \(f_o\) is initialized for a tick \(i\), the value—by convention—is chosen as if all of the fees earned to date had occurred below that tick.
当为一个 tick \(i\) 初始化 \(f_o\) 时,按照惯例,其取值应设定为仿佛截至目前赚取的所有费用都发生在该 tick 左边。
这样可以保证算出的 tick 区间初始累积的手续费是 0,同时也不会影响已初始化区间的手续费。要注意,已经初始化过的 tick 有新的流动性加入时,不需要再更新外边手续费的值。
值得注意的是,如果一笔交易在当前价格 \(i_c\) 发生,理论上来说需要更新所有外边包含 \(i_c\) 的 tick,但是实际上没有必要。看图 2 的例子,交易发生前价格为 \(i_c\),全局手续费为 \(f_g\),区间手续费为 \(f_g - f_o(i_l) - f_o(i_u)\)。交易发生后,价格变为 \(i_c'\),设交易手续费为 \(\Delta{f}\),则全局手续费变为 \(f_g+\Delta{f}\),区间手续费变为 \(f_g + \Delta{f} - f_o(i_l) - f_o(i_u)\),同交易前区间手续费的差值为 \(\Delta{f}\),这正是交易使得区间增加的手续费。
图 2
那么什么时候需要更新 tick 的 \(f_o\) 呢?答案是 tick 被穿越后。tick 被穿越后,对这个 tick 来说,外边发生了反向,因此按如下公式更新 \(f_o\) 的值:
为什么是按照这个公式来更新 \(f_o\)?我们以图 3 为例进行说明。全局手续费为 \(f_g\),\(i_u\) 外侧手续费为 \(f_o(i_u)\),在当前价格未穿越 \(i_u\) 仍为 \(i_c\) 前,\(i_u\) 外侧为其右边区域,此外我们也可以求得 \(i_u\) 左边区域的手续费为 \(f_g - f_o(i_u)\)。假设前几笔交易都在区间内进行,使得区间手续费增加了 \(\Delta{f}\),之前已经讨论过,\(i_u\) 未被穿越,其外侧手续费仍然为 \(f_o(i_u)\) 不变,只是全局手续费 \(f_g\) 变为 \(f_g+\Delta{f}\),计算得出 \(i_u\) 左边的手续费变为 \(f_g + \Delta{f} - f_o(i_u)\)。随后,一笔交易使得 \(i_c\) 穿过 \(i_u\) 到达 \(i_c'\),该笔手续费为 \(\Delta{f}_2\),此时全局手续费 \(f_g\) 变为 \(f_g+\Delta{f}+\Delta{f}_2\),\(i_u\) 左边的手续费变为 \(f_g + \Delta{f}+\Delta{f}_2 - f_o(i_u)\),而此时 \(i_u\) 的左侧变为了外侧,因此:
注意这里 \(f_g := f_g + \Delta{f}+\Delta{f_2}\) 是 \(i_u\) 被穿越后最新的全局手续费,赋值式子左边的 \(f_o(i_u)\) 是 \(i_u\) 被穿越前外侧手续费,替换一下即得到公式 (10)。
图 3
知道如何计算某个区间累积的手续费后,在此区间内,按 Position 分配手续费收益即可(其记账方式和 V2 中介绍的直观记账方式一样)。具体来说,一个 LP 如果要撤走其流动性,那么其可分配到的手续费收益计算过程如下:
- 按照上面讲过的方式,计算 Position 区间 \((i_l, i_u)\) 总累积的手续费 \(f\)(注意是每单位流动性)
- 假设该 Position 流动性为 \(L\),上一次提取收益时每单位流动性收益是 \(f_0\),则本次可分配到的手续费收益总量为 \((f-f_0)\cdot L\)
协议费的记账相比手续费简单很多,池子会维护一个总的协议费变量 protocolFee
,每一笔交易会计算应当收取的协议费 protocalFeeAmount
,然后更新总的协议费 protocolFee += protocalFeeAmount
。可以看到,和 Uniswap V2 相比,V3 的协议费在每笔交易中计算,虽然记账方式简单了很多,但需要消耗更多的 gas。
-- EOF --