# 浅析 Uniswap V3

By [DavidCai.eth](https://paragraph.com/@davidcai) · 2021-12-22

---

参考
--

*   [白皮书](https://uniswap.org/whitepaper-v3.pdf)
    

V2 的实现与问题
---------

Uniswap V2 作为老牌的基于自动化做市的 DEX ，其核心公式十分简单优雅，即是：

    x * y = k
    

![x \* y = k](https://storage.googleapis.com/papyrus_images/35d8eb9eb95307970e3fbf77ee1fc3ded33e3f75a5614599b8210cc9232ced7b.png)

x \\\* y = k

我们假设 x 轴为 x 代币的数量，y 轴为 y 代币的数量。所以当我们使用 x 代币去交换 y 代币时，流动性池中 x，y 代币的数量将会从 A 点移至 B 点。由于双曲线的特性，所以所有做市商所注入的流动性，做市区间都为 (0, ∞)。正由于所有的流动性都被摊在这个一整个广阔的开区间内，所以就导致了一个问题，即每次交换时，实际流动性的资金利用率较低，最后提取收益时，也被摊薄得厉害。

![x \* y = k (2)](https://storage.googleapis.com/papyrus_images/12913618852188396470c5830807fa012a857c45db16d934ccb77c1098d2d5b7.png)

x \\\* y = k (2)

如上图例子所示，在 x,y 货币数量通过交换从 A 点移动至 B 点的过程中，区间内可用的流动性为黑色区域。而实际利用的流动性，仅有红色区域。

V3 的实现
------

所以为了解决上述问题，在 Uniswap V3 中，开始允许用户在提供流动性时，可以自定义该流动性所支持的价格区间，仅当交易价格处于指定的交易区间内时，提供的头寸（position）才会被激活。

但同时，又为了维持公式的一致性，所以 V3 中提出了“虚拟流动性”（x\_virtual, y\_virtual）的概念，即是：

    (x + x_virtual) * (y + y_virtual) = L^2
    

注：由于后面的相关交易公式推导涉及到了开根号，所以为了方便计算，V3 使用了 L^2 来替代 k ，实质上两者是一样的（L^2 = k）。

![(x + x_virtual) \* (y + y_virtual) = L^2](https://storage.googleapis.com/papyrus_images/97bdba6c7269fb9f67af71725464e0db489cd21f21dccad4ef2387a2ab5a55c8.png)

(x + x\_virtual) \\\* (y + y\_virtual) = L^2

如上图所示，当用户的头寸被激活进行交换时，V3 会注入虚拟流动性来保持公式的计算一致性，使曲线从橙色曲线拉抬成了青色曲线。但是， x\_virtual, y\_virtual 并不会参与真实的交易。

如此一来，理想状态下，由于每个用户对币价的预期不同，大家都会选择自认为流动性较大的区间做市，从而自高了头寸的资金利用率，获得更多的手续费收益。但是，在得到收益的机会变大的同时，其实风险也增加了。举个例子，某用户在一个 USDC/山寨币 交换池里，往 1 USDC 换 150 - 200 枚个山寨币的流动性区间，即（150, 200）中注入了流动性头寸进行做市。若此时，与用户期望的正好相反，USDC 对山寨币升值，变成 1 USDC 换 220 枚山寨币，根据曲线，此时价格点就会离开用户的流动性区间，这时，用户提供的头寸池内构成，就会全变成了山寨币。而相比 V2 ，由于流动性区间是 (0, ∞) ，所以用户的头寸池中仍会有 USDC，相当于所有人均摊了风险。

所以，V3 的改变，相当于是给用户提供了一种高风险高回报的收益模式。当然，如果用户对市场趋势判断的信心不足，愿意降低收益的同时降低风险，也可以将提供头寸的流动性区间手动设置为 (0, ∞) （V3 的前端 UI 中也是支持这么做的），如此一来，风险收益模型就和 V2 没有区别了。

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 的交换公式
--------

![v3 pool](https://storage.googleapis.com/papyrus_images/26070791a1b03d7a927a2c860c2d660c898996a54c67a6eba1d166f2f08b42d3.png)

v3 pool

V3 的流动性池中，由于用户可以单独指定提供流动性的价格区间，故上图的整体流动性池例子中，相比 V2 是一条平滑的横线（图一），V3 更多情况下，在不同的区间，深度都不同（图三），图中的完全正态分布只是特殊情况。

例如，以下是 MATIC/ETH （手续费为 0.3%）池的头寸分布，可以看出许多头寸都在比当前价格更高的位置，或许可理解为当前市场内的做市商未来看涨 MATIC：

![MATIC/ETH Liquidty](https://storage.googleapis.com/papyrus_images/81170709522fe2df68f8ba0c8cc209f59d88f94dbac3676bdbe8885576379ea4.png)

MATIC/ETH Liquidty

所以，我们可知，在 V3 中，一个交易是可能横跨多个用户的不同头寸的，故实际执行的，是横跨多个头寸的聚合交易。

我们先将目光限定在交易只影响一个单独头寸的情况，已知代币 X,Y 的数量为 x , y ， x \* y = L^2 ，以及价格 P = y / x （即可以用 P 个 X 代币，交换到 1 个 Y 代币）。可得：

![](https://storage.googleapis.com/papyrus_images/7bde3cf083726f1fcc989847519e95f22e8d2faaa79231378f7b400a579fa606.png)

通过带入，我们又可得：

![](https://storage.googleapis.com/papyrus_images/7e9e76edb778e2585409e1fa51b795fb29d76cd7ef805395b9f89d2f72f886ba.png)

即：

![](https://storage.googleapis.com/papyrus_images/afa81c0ba8569ea2062e6abcd8228f1d3748dd301e5a7e959fef352d3e17690b.png)

这样一来，我们仅只需知道 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 白皮书](https://uniswap.org/whitepaper-v3.pdf)的学习笔记，若有不准确之处，欢迎指出。:)

---

*Originally published on [DavidCai.eth](https://paragraph.com/@davidcai/uniswap-v3)*
