Uniswap V2 作为老牌的基于自动化做市的 DEX ,其核心公式十分简单优雅,即是:
x * y = k
我们假设 x 轴为 x 代币的数量,y 轴为 y 代币的数量。所以当我们使用 x 代币去交换 y 代币时,流动性池中 x,y 代币的数量将会从 A 点移至 B 点。由于双曲线的特性,所以所有做市商所注入的流动性,做市区间都为 (0, ∞)。正由于所有的流动性都被摊在这个一整个广阔的开区间内,所以就导致了一个问题,即每次交换时,实际流动性的资金利用率较低,最后提取收益时,也被摊薄得厉害。
如上图例子所示,在 x,y 货币数量通过交换从 A 点移动至 B 点的过程中,区间内可用的流动性为黑色区域。而实际利用的流动性,仅有红色区域。
所以为了解决上述问题,在 Uniswap V3 中,开始允许用户在提供流动性时,可以自定义该流动性所支持的价格区间,仅当交易价格处于指定的交易区间内时,提供的头寸(position)才会被激活。
但同时,又为了维持公式的一致性,所以 V3 中提出了“虚拟流动性”(x_virtual, y_virtual)的概念,即是:
(x + x_virtual) * (y + y_virtual) = L^2
注:由于后面的相关交易公式推导涉及到了开根号,所以为了方便计算,V3 使用了 L^2 来替代 k ,实质上两者是一样的(L^2 = k)。
如上图所示,当用户的头寸被激活进行交换时,V3 会注入虚拟流动性来保持公式的计算一致性,使曲线从橙色曲线拉抬成了青色曲线。但是, x_virtual, y_virtual 并不会参与真实的交易。
如此一来,理想状态下,由于每个用户对币价的预期不同,大家都会选择自认为流动性较大的区间做市,从而自高了头寸的资金利用率,获得更多的手续费收益。但是,在得到收益的机会变大的同时,其实风险也增加了。举个例子,某用户在一个 USDC/山寨币 交换池里,往 1 USDC 换 150 - 200 枚个山寨币的流动性区间,即(150, 200)中注入了流动性头寸进行做市。若此时,与用户期望的正好相反,USDC 对山寨币升值,变成 1 USDC 换 220 枚山寨币,根据曲线,此时价格点就会离开用户的流动性区间,这时,用户提供的头寸池内构成,就会全变成了山寨币。而相比 V2 ,由于流动性区间是 (0, ∞) ,所以用户的头寸池中仍会有 USDC,相当于所有人均摊了风险。
所以,V3 的改变,相当于是给用户提供了一种高风险高回报的收益模式。当然,如果用户对市场趋势判断的信心不足,愿意降低收益的同时降低风险,也可以将提供头寸的流动性区间手动设置为 (0, ∞) (V3 的前端 UI 中也是支持这么做的),如此一来,风险收益模型就和 V2 没有区别了。
为了更方便的理解 V3 交换公式的推导,我们先来推导下更直观的 V2 公式。
交换的公式与其实现代码,其实是在解决如下题目:在一个流动性池中,有 X,Y 两种代币,已知 X 代币的数量为 x ,Y 代币的数量为 y ,现有用户提供了 Δx 个 X 代币,求能交换出多少 Y 代币(Δy)?
我们根据核心公式:
x * y = (x + Δx) * (y − Δy) = k
通过左右变换与带入,可得:
Δy = y − (xy / x + Δx) = Δxy / (x + Δx)
并且,由于 V2 每组代币对只有一个流动性池,且手续费在每个代币对的池子里都是固定的 0.3% (V3 是允许多种手续费的,细节后文会提到),故 V2 代码直接对交易数量进行抽成。
// https://github.com/Uniswap/v2-core/blob/master/contracts/UniswapV2Pair.sol
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
// ...
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
// ...
}
V3 的流动性池中,由于用户可以单独指定提供流动性的价格区间,故上图的整体流动性池例子中,相比 V2 是一条平滑的横线(图一),V3 更多情况下,在不同的区间,深度都不同(图三),图中的完全正态分布只是特殊情况。
例如,以下是 MATIC/ETH (手续费为 0.3%)池的头寸分布,可以看出许多头寸都在比当前价格更高的位置,或许可理解为当前市场内的做市商未来看涨 MATIC:
所以,我们可知,在 V3 中,一个交易是可能横跨多个用户的不同头寸的,故实际执行的,是横跨多个头寸的聚合交易。
我们先将目光限定在交易只影响一个单独头寸的情况,已知代币 X,Y 的数量为 x , y , x * y = L^2 ,以及价格 P = y / x (即可以用 P 个 X 代币,交换到 1 个 Y 代币)。可得:
通过带入,我们又可得:
即:
这样一来,我们仅只需知道 L 与 P ,就可知道包含了虚拟流动性的 x 和 y,不用再关心其他变量。并且有一个好处是,在同一段头寸中 L 是不变的,在切换头寸区间池的瞬间,P 是不变的。
所以当我们在使用 Δx 枚 X 代币去交换 Y 代币时时,会先用上图第一行的公式,先计算出消耗完当前所在池流动性后,新的价格 P ,然后再使用第二行公式,计算出具体在池内可交换出的 Δy 。若第一个公式中的 ΔP 已经跨过了当前池的价格区间(意味着即使当前流动性区间池的流动性被消耗完毕,依然不足以消化掉所有的 Δx ),那么就进入下一个池,继续重复上述逻辑。直到能消耗完所有的 Δx ,此时累计的 Δy 即是可交换到的 Y 代币数量。
在 V3 代码实现中,用户提供的流动性头寸的价格区间的两头,被称为两个 Tick ,上述计算,是跨一个个 Tick 来进行。
既然 Tick 是用于表示价格区间中的某个具体价格的,理论上在 (0, ∞) 这个范围内,可以有无穷多个 Tick 点。但是显然,在 Solidity 编程中,一个无限膨胀的 storage 变量是昂贵且难以接受的。
所以 V3 中,为 Tick 提供了固定的可选值,即 1.0001^i ,所以 Tick 其实是一个等幂数列。这是基于,当价格很低,用户对细小的价格变化更敏感,反之,在价格很高时,用户很大概率并不在意汇率里小数点最后面几位的区别。每一个 Tick 之间,V3 还提供一个最小间隔 i 的限制(Tickspacing),例如当 Tickspacing 为 10 时,第一个可用 Tick 是 1.0001^1 的话,那么往大第二个可用 Tick 就是 1.0001^11 。
并且,目前 V3 中只设定了三个可选费率(更多费率可经由社区治理投票在未来给出),且为三个费率设定了固定 Tickspacing ,进一步规范化计算消耗:
| 费率 | Tickspacing | 建议的使用范围 |
| ----------- | ----------- | ----------- |
| 0.05% | 10 | 稳定币交易对 |
| 0.3% | 60 | 适用大多数交易对 |
| 1% | 200 | 波动极大的交易对 |
关于手续费提现的问题,在 V2 中处理的比较直观。在 V2 中,由于只存在一个总流动性池,当用户注入流动性时,合约会同时给予 ERC20 代币作为凭证,当用户提现时,合约根据所持代币所占比例,给予用户总手续费收益中的提成。
但是当使用 V3 版本的自定区间实现时,如果还使用 V2 的办法,就会遇到问题。每当一比交易穿过多个 Tick 时,包含着每个 Tick 上的各头寸都要作单独记录且按比例分配。这不仅会产生大量额外的 Gas 费。且这个费用会让交换代币的用户而不是提现者承担,也是不公平的。
所以 V3 的解决方案是,在做市商每一次提供区间头寸的时候,都会给与一个 ERC721 代币,即 NFT ,里面包含了价格区间以及提供的具体流动性数量。而当用户进行代币交换时,合约会维护一个全局的手续费收入并且追踪每个 Tick 参与收集到的手续费数量。在用户提现时,先获取到头寸所有包含的 Tick 收集的总费用以及总流动性,然后根据用户 NFT 中的流动性数量占比,给与用户收益。
本文为个人学习 Uniswap V3 白皮书的学习笔记,若有不准确之处,欢迎指出。:)