译者注
本文是对《Uniswap v2 Core》的中文翻译,原文的版权属于原作者或原著作权持有人。本翻译文档受到 MIT 许可协议的保护。请在使用本文档之前仔细阅读并遵守 MIT 许可协议的规定。
原文链接:https://docs.uniswap.org/whitepaper.pdf
Uniswap V2 核心
作者:
Hayden Adams - [email protected]
Noah Zinsmeister - [email protected]
Dan Robinson - [email protected]
2020年3月
摘要
这份技术白皮书解释了 Uniswap V2 核心合约背后的若干设计决策。其内容涵盖了合约的一些新特性,其中包括
- 任意 ERC20 代币的交易币对;
- 一个经过加强的预言机:这个预言机使得其他合约可以预估某个给定的时间范围内的时间加权均价;
- “闪电兑”:这种交换方式允许交易者先收到资产并在其他地方使用资产,然后再在交易的最后为这些资产支付费用;
- 一个可以在未来开启的协议费。
这份白皮书描述了 Uniswap V2 的“核心”合约的机制,这些合约包括存有流动性提供者资金的币对合约,以及用于实例化币对合约的工厂合约。
1 介绍
Uniswap V1 是一个以太坊区块链上的链上智能合约系统,实现了一个基于“恒定乘积公式”的流动性协议。每个 Uniswap V1 币对都存放着两种资产的合并存量,并且为这两种资产提供流动性,保持两种资产的数量之积是一个不会下降的常量。交易者在交易时支付 30 个基点的费用,作为给流动性提供者的报酬。这些合约是不可升级的。
Uniswap V2 是一个基于相同公式的全新实现,同时具有一些万众瞩目的新特性。其中最重要的是, Uniswap V2 使得创建任意的 ERC20/ERC20 交易对成为可能,而不是只支持 ERC20 和 ETH 之间的交易对。 Uniswap V2 也提供了一个经过加强的价格预言机,这个预言机在每个区块开始时就收集两种资产相关的价格信息。这使得以太坊上的其他合约可以估算两个资产在任意时间范围内的时间加权均价。最后, Uniswap V2 使得“闪电兑”成为可能,通过闪电兑,用户可以自由地收到资产并且将他们用在链上的其他地方,只需要在交易的最后支付或归还这些资产即可。
尽管合约通常是不可升级的,但是依然存在一个私钥拥有更新工厂合约中的一个变量来开启链上 5 个基点交易费的能力。这个费用起初处于关闭状态,但可以在未来被开启。开启后,流动性提供者每次交易将赚取 25 个基点的报酬,而不是 30 个基点。
正如第 3 部分所述, Uniswap V2 也修复了 Uniswap V1 中的一些小错误,同时重构了实现方式,通过最小化存放流动性提供者资金的“核心”合约的逻辑降低了 Uniswap 的攻击面,并且使得该系统更容易升级。
这篇论文描述了核心合约以及用于实例化核心合约的工厂合约的运行机制。实际上使用 Uniswap V2 将需要通过一个“路由”合约来调用币对合约,而“路由”合约可以计算交易或储蓄的数量并向币对合约发送资产。
2 新特性
2.1 ERC-20 交易对
Uniswap V1 使用 ETH 作为过渡货币。每一个交易对都包含 ETH 作为其中一项资产。这使得寻找交易路径变得更加简单(代币 ABC 和代币 XYZ 之间的交易可以通过 ETH/ABC 交易对和 ETH/XYZ 交易对来实现),也减少了流动性的分散。
然而,这个规则给流动性提供者带来了巨大的费用。所有的流动性提供者都有 ETH 的敞口,并且承受了基于其他与 ETH 有关的资产价格变动而带来的无常损失。当 ABC 和 XYZ 两种资产价格相关时(例如他们俩都是锚定美元的稳定币), ABC/XYZ 交易对的流动性提供者所承受的无常损失通常小于 ABC/ETH 交易对或 XYZ/ETH 交易对的流动性提供者。
使用 ETH 作为强制性的过渡货币也给交易者带来了费用。相比于直接交易 ABC/XYZ 币对,交易者需要支付两倍的交易费,还要承受两次滑点。
Uniswap V2 允许流动性提供者创建任意两种 ERC-20 代币的交易对合约。
任意 ERC20 代币之间的交易对数量激增,可能会导致为一个特定交易对寻找最佳交易路径的过程变得稍加困难,但是路由工作可以在更高的层级被解决(要么在链下解决,要么通过链上的路由器或聚合器)。
2.2 价格预言机
在 时刻, Uniswap 提供的边际价格(不包括费用)可以通过 资产储备量除以 资产储备量计算出来。
\begin{equation} p_t=\frac{r_t^a}{r_t^b} \end{equation}
由于如果价格偏离了正确值,套利者就会用 Uniswap 交易套利使得价格回归正确值(如果价格偏离程度足够弥补交易费用的话), Uniswap 提供的价格倾向于追踪该资产的相关市场价格,正如参考文献 2 中 Angeris 展示的那样。这意味着 Uniswap 可以被用作一个大致性的价格预言机。
然而, Uniswap V1 作为链上价格预言机并不安全,因为它很容易被操纵。假设某个其他合约使用当前的 ETH-DAI 价格来结算一种衍生品。一个希望操纵测定价格的攻击者可以从 ETH-DAI 交易对中购买 ETH ,触发衍生品合约的结算(这导致衍生品以一个过高的价格结算了),然后以正确的价格将 ETH 卖回交易对中。这甚至有可能在一次基本交易中完成,或者被一个在区块内控制交易顺序的矿工完成。
Uniswap V2 通过测量并记录每个区块第一笔交易之前的价格来提高这个预言机的功能性(或者等价地,每个区块最后一笔交易之后的价格)。这个价格比区块挖掘期间的价格更加难以操纵。如果攻击者在区块的最后发送了一个试图操纵价格的交易,某些其他的套利者就可以在同一个区块靠后的位置发送另一笔的交易从而立即将价格归位。一个矿工(或者一个使用足够多燃料费填满整个区块的攻击者)可以操纵区块末尾的价格,但是,如果他们没能继续担任下一个区块的矿工,他们就没法阻止套利者将价格归位。
具体来说, Uniswap V2 通过追踪每一个包含交互调用的区块开始时的价格累和,将这些价格加总在一起。根据区块的时间戳,每一个价格将按照该价格所持续的时间为依据赋予权重。这意味着在价格更新后任意给定时间的累计值应该等于合约历史上每一秒的价格之和。
\begin{equation} a_t=\sum_{i=1}^t p_i \end{equation}
想要预估从 时刻到 时刻的时间加权均价,一个外部的调用者可以读取 时刻和 时刻的累计价格,用 时刻的累计价格减去 时刻的累计价格,然后除以经过的时间。(需要注意的是,合约自身并不存储价格的累计值,调用者不得在周期开始的时候自行调用合约读取并存储这个值。)
\begin{equation} p_{t_1,t_2} =\frac{\sum_{i=t_1}^{t_2} p_i}{t_2-t_1} =\frac{\sum_{i=1}^{t_2} p_i - \sum_{i=1}^{t_1} p_i}{t_2-t_1} =\frac{a_{t_2}-a{t_1}}{t_2-t_1} \end{equation}
预言机的用户可以选择周期开始和结束的时间。选择更长的周期可以让攻击者为操纵时间加权均价付出更大的代价,但这样也会使得价格有所滞后。
一个复杂之处是:为什么应该测量资产 A 相对于资产 B 的价格,还是测量资产 B 相对于资产 A 的价格?尽管这两个价格总是互为倒数,但某一段时间内前者的均价却并与后者的均价互为倒数。例如,如果 USD/ETH 在第 1 区块的价格是 100, 在第 2 区块的价格是 300 ,那么 USD/ETH 的均价就是 200 USD/ETH ,但是 ETH/USD 的均价就是 1/150 ETH/USD 。由于合约不能够知道用户想要使用这两个资产中的哪一个作为计量单位, Uniswap V2 都会追踪这两个价格。
另一个复杂之处是:有人可能会向交易对合约发送资产但不与合约交互(因此改变了合约的余额和边际价格),并且因此没有触发预言机更新。如果合约简单地检查自身的余额并基于当时的价格更新预言机,攻击者可能会通过在区块内第一次调用合约之前直接向合约发送资产的方式来操纵预言机。如果上一个交易在 X 秒前区块内,合约将会错误地将新价乘以 X 并累加,尽管并没有人以此价格真的成交过。为了避免这一点,核心合约在每次交互之后缓存自己的资金储备,并且使用缓存的资金储备来更新预言机,而不是使用当前的资金储备。除了保护预言机免于被操纵,这个变动使得合约的重构成为可能,该重构将在下文的 3.2 部分给出描述。
2.2.1 精度
由于 Solidity 没有对于非整数数据类型的一级支持, Uniswap V2 使用一个简单的二分固定点格式来加密并操控价格。具体来说,一个给定时刻的价格以 UQ122.122 数字的格式存储,也就是说小数点前后各有 112 个数位,没有符号位。这些数字的值域范围是 ,其精度为 。
这种 UQ122.122 格式的选择是出于实用的原因——因为这些数字可以被存储在一个 uint224 变量中,这些数字为 256 位的存储槽留下了 32 位的剩余空间。这对于资产储备也适用,资产储备存储在 uint112 变量中,也为(打包的) 256 位存储槽留下了 32 位 剩余空间。这些剩余的空间被用作上述的累加过程。具体来说,资产储备随着最近一次又至少一次交易的区块的时间戳以前存储,并模上 使得其能够存入 32 位空间中。另外,虽然任意给定时刻的价格(以 UQ122.122 格式数字存储)一定能存入 224 位空间中,但是一段时间内的价格累和却并不一定能。在那些存放 A/B 与 B/A 价格累和的存储槽的末尾,多余的 32 位空间被用于存储由于价格的重复累和而溢出的数位。这个设计意味着价格预言机只需要向每个区块的第一笔交易添加三次多余的 SSTORE 操作(目前的费用大概是 15000 gas)。
这样设计主要的弊端是 32 位并不够大,无法保证存储的时间戳数值永不溢出。实际上, Unix 时间戳溢出 uint32 的日期是 2106 年 7 月 2 日。为了确保此系统在这一天后以及此后的每一次溢出后继续正常工作,预言机只需要简单地在每个周期(大约 136 年)中至少检查一次价格。这是因为累加与模以时间戳的核心方法实际上是溢出安全的,这意味着,鉴于预言机使用简单溢出算数来计算改变量(译者注:改变量即 ),跨越溢出周期的交易可以被合理地计算。
2.3 闪电兑
在 Uniswap V1 中,一个使用 XYZ 币购买 ABC 币的用户需要先将 XYZ 币发送到合约,然后才能收到 ABC 币。如果用户需要使用他们想买的 ABC 币去获得他们用来支付的 XYZ 币, Uniswap V1 的流程很不方便。例如,用户可能需要使用 ABC 币去购买某个其他合约中的 XYZ 币来实现价差套利,或者他们可能需要在 Maker 或 Compound 卖掉抵押物平仓然后将代币返还给 Uniswap 。
Uniswap V2 添加了一个新功能,允许用户先收到并使用一项资产然后再支付,只要他们能够在同一个基本交易中完成支付即可。这个 swap
函数可以在发送用户请求的代币与强制执行不变量(译者注:也就是调整两种资产的数量使得其乘积依然等于常量)中间的某个时刻调用一个用户指定的回调函数。一旦回调函数完成,合约就会检查新的余额,并确保资产数量的乘积依然是之前的定值(在根据支付数量调整交易费后)。如果合约没有足够的资金,整个交易都会回滚。
一个交易者也可以使用相同的代币重新支付给 Uniswap ,而不是完成交换。这和闪电贷的原理基本一致,但是需要支付 的 Uniswap 交易费。
2.4 协议费
Uniswap V2 包含了一个可以开启也可以关闭的 协议费。如果开启,这个费用将会被发送给在工厂合约中声明的 feeTo
地址。
最开始, feeTo
没有被设置任何值,也不会收集费用。一个预先明确的地址 feeToSetter
可以调用 Uniswap V2 工厂合约转给你的 setFeeTo
函数,将 feeTo
设置为一个不同的值。 feeToSetter
也可以调用 setFeeToSetter
来改变 feeToSetter
自己的地址。
如果 feeTo
地址被设置了,协议将会开始收取 5 个基点的费用,也就是从流动性提供者赚取的 30 个基点费用中拿走 。这就是说,交易者仍然在所有交易中支付 的费用; 的费用(交易数量的 )归流动性提供者, 的费用(交易数量的 )归 feeTo
地址。
交易的时候收取这 的费用会增加一笔额外的交易 gas 成本。为了避免这一点,只有当提取或存入流动性的时候,累加的费用才会被收集。这个合约计算了累加的费用,并且一旦有任何代币被铸造或销毁,就铸造新的流动性代币发送给交易费的受益人,
收集的总费用可以通过测量从上一次收集费用后 (也就是 )的增长量来计算。这个公式给出了从 到 的累计费用,以 时刻占流动性池的百分比表示:
\begin{equation} f_{1,2} = 1 - \frac{\sqrt{k_1}}{\sqrt{k_2}} \end{equation}
如果这个费用在 之前就被开启了, feeTo
地址应该获取从 到 的累计的费用的 。因此,我们想要铸造新的流动性代币发送给 feeTo
地址,表示为流动性池的 ,其中 是 。
这就是说,我们想要选择一个 满足下面的关系,其中 是 时刻的总流动份额数量:
\begin{equation} \frac{s_m}{s_m+s_1} = \phi \cdot f_{1,2} \end{equation}
一些操控之后,将 代入 并解出 ,我们可以把式子重写成这样:
\begin{equation} s_m = \frac{\sqrt{k_2}-\sqrt{k_1}}{(\frac{1}{\phi}-1)\cdot\sqrt{k_2}+\sqrt{k_1}}\cdot s_1 \end{equation}
将 设置为 公式可以变成这样:
\begin{equation} s_m = \frac{\sqrt{k_2}-\sqrt{k_1}}{5 \cdot\sqrt{k_2}+\sqrt{k_1}}\cdot s_1 \end{equation}
假设起初存款者向交易对中放入 100 DAI 和 1 ETH ,收到 10 个份额。一段时间后(假设没有其他存款者加入这个交易对),他们尝试取款,此时交易对中有 96 DAI 和 1.5 ETH 。将这些数值代入上面的公式,得到如下结果:
\begin{equation} s_m = \frac{\sqrt{1.5 \cdot 96} - \sqrt{1 \cdot 100}}{5 \cdot \sqrt{1.5 \cdot 96} + \sqrt{1 \cdot 100}} \cdot 10 \approx 0.0286 \end{equation}
2.5 流动性池份额的元交易
Uniswap V2 铸造的流动性池的份额原生支持元交易。这意味着用户可以用一个签名授权他们的流动性池份额的转账,而不是从他们地址发出的链上交易。任何人都可以通过调用 permit
函数代表用户在同一笔交易中提交签名、支付 费或执行其他操作。
3 其他改变
3.1 Solidity
Uniswap V1 使用 Vyper 实现。Vyper 是一个类似于 Python 的智能合约语言。 Uniswap V2 用使用更为广泛的 Solidity 语言实现,因为它需要某些在开发时 Vyper 中不支持的功能(例如翻译非标准 ERC20 代币的返回值,以及通过内联汇编访问诸如 chainid
的新的字节码)。
3.2 合约重构
Uniswap V2 设计时优先考虑的一个问题是最小化攻击面和核心合约的复杂性,即存放流动性提供者资产的合约。这个合约中的任何 bug 都是灾难性的,因为数百万美元的流动性都可能会被窃取或冻结。
在评估此核心合约的安全性时,最重要的问题是它是否能够保护流动性提供者免于资产被窃取或被冻结。任何意在支持或保护交易者的特性(除了将流动性池中的一种资产兑换为另一种资产的基本功能)都在“路由”合约中实现。
实际上,连兑换功能的一部分都可以被提取出来放到路由合约中去。正如上面所提及的, Uniswap V2 存储了上次记录的每种资产的余额(这是为了避免一种特别的预言机机制操纵攻击)。新的重构发挥了这一优势,可以进一步简化 Uniswap V1 合约。
在 Uniswap V2 中,卖者先向核心合约发送资产,然后再调用 swap
函数。然后,合约通过比较之前记录的余额和当前余额计算它收到了多少资产。这意味着核心合约并不知道交易者发送资产的具体方式。除了 tansferFrom
之外,这还可能是一次元交易,或者其他任何未来的用于授权 ERC20 代币交易的机制。
3.2.1 费用调整
Uniswap V1 交易费的收取方法是,先将支付给合约的代币数量抽取 ,再去恢复恒定乘积常量。该合约可以含蓄地理解成下面的公式:
\begin{equation} (x_1-0.003\cdot x_{in})\cdot y_1 >= x_0 \cdot y_0 \end{equation}
有了闪电兑, Uniswap V2 中 和 都可能是非零的数字(也就是当一个用户使用相同的资产支付回合约,而不是兑换)。为了解决这样的情况同时合理地收取交易费用,合约满足以下公式:
\begin{equation} (x_1-0.003\cdot x_{in}) \cdot (y_1-0.003\cdot y_{in}) >= x_0 \cdot y_0 \end{equation}
为了简化这个链上计算过程,我们可以在不等式两边同时乘上 :
3.2.2 sync() 函数和 skim() 函数
为了防止订做的更新交易对合约的余额代币实现,也为了更优雅地解决某些总供给量大于 的代币, Uniswap V2 有两个备用的函数: sync()
和 skim()
。
sync()
函数是一个恢复机制,以防万一