Hayden Adams Noah Zinsmeister Dan Robinson
[email protected] [email protected] [email protected]
2020年3月
本技术白皮书解释了Uniswap v2核心合约背后的一些设计逻辑。它涵盖了合约的新功能–包括ERC20之间的任意配对,一个强化的价格预言,允许其他合约估计给定区间内的时间加权平均价格,“闪电交换”,允许交易者接收资产并将其用于其他地方,然后再在以后的交易中支付,以及一个可以在未来开启的协议费。它还重新架构了合约以减少其攻击面。本白皮书介绍了Uniswap v2的 "核心 "合约的机制,包括存储流动性提供者资金的配对合约,以及用于实例化配对合约的工厂合约。
Uniswap v1是Ethereum区块链上的一个智能合约链上系统,实现了基于“恒定乘积公式”的自动流动性协议[1]。每一个Uniswap v1交易对都会存储两种资产的集合储备,并为这两种资产提供流动性,保持储备的乘积不能减少的特性。交易者在交易中支付0.3%的费用,这些费用归流动性提供者所有。这些合约不可升级。
Uniswap v2也是基于“恒定乘积做市商”机制,它加了一些新的功能,具有一些新的非常理想的特性。最重要的是,它可以创建任意的ERC20/ERC20对,而不是只支持ERC20和ETH之间的配对。它还提供了一个硬化的价格预言,在每个区块开始时积累两种资产的相对价格。这允许Ethereum上的其他合约在任意时间间隔内估计两种资产的时间加权平均价格。最后,它可以实现闪电交换(闪电贷),用户可以自由地接收资产,并在链上的其他地方使用它们,只在交易结束时支付(或归还)这些资产。
虽然合约一般不能升级,但有一个私钥,可以更新工厂合约上的一个变量,开启交易的链上0.05%。这个费用最初处于关闭状态,但未来可以开启,之后流动性提供者将在每笔交易中赚取0.25%,而不是0.3%。
如第3节所述,Uniswap v2还修复了Uniswap v1的一些小问题,并重新架构了实现方式,减少了Uniswap的攻击面,并通过尽量减少持有流动性提供者资金的“核心”合约中的逻辑,使系统更容易升级。
本文介绍了核心合约的机制,以及用于实例化这些合约的工厂合约。实际上使用Uniswap v2需要通过一个路由合约来调用配对合约,该合约计算交易或存款金额,并将资金转移到配对合约中。
Uniswap v1使用ETH作为中介货币。每种货币对都将ETH作为其资产之一。这使得路由更简单——ABC和XYZ之间的每笔交易都要经过ETH/ABC对和ETH/XYZ对,并减少了流动性的分散。
然而,这一规则给流通性提供者带来巨大成本。所有流动性提供者都有ETH的风险,并根据其他资产相对于ETH的价格变化而遭受无常的损失。当两种资产ABC和XYZ相关时——例如,如果它们都是美元稳定币,在Uniswap里,ABC/XYZ代币对的流动性提供者,通常会比ABC/ETH或XYZ/ETH代币对,遭受较少的无常损失。
使用ETH作为强制性的中介货币也给交易者带来了成本。交易者需要支付的费用是直接使用ABC/XYZ货币对(也称交易对,或者代币对)的两倍,而且他们还要承受两倍的滑点。
Uniswap v2允许流动性提供者,为任意两个ERC-20创建配对合约。
任意ERC-20之间的交易对激增,可能会使找到交易特定交易对的最佳路径变得有些困难,但路由可以在更高一层处理(链外或通过链上路由器或聚合器)。
Uniswap公司在t时间提供的边际价格(不包括费用),可以通过资产a的储备金除以资产b的储备金来计算。
由于套利者会在这个价格不正确的情况下,与Uniswap进行交易(以足够的金额来弥补费用),因此,正如Angeris等人[2]所示,Uniswap提供的价格倾向于跟随资产的相对市场价格。这意味着它可以作为一个近似的价格预言。
然而,Uniswap v1作为链上价格预言并不安全,因为它非常容易被操纵。假设有其他合约使用当前的ETH-DAI价格来结算衍生品。希望操纵测算价格的攻击者可以从ETH-DAI对中买入ETH,触发衍生合约的结算(使其基于虚高的价格进行结算),然后将ETH卖回该对,将其交易回归真实价格。这甚至可以以原子交易的方式进行,或者由矿工控制区块内的交易顺序。
Uniswap v2通过测量和记录每个区块第一次交易前(或者等价于上一个区块的最后一次交易后)的价格,改进了这一预言功能。这个价格比一个区块中的价格更难操纵。如果攻击者提交了一笔交易,试图在一个区块结束时操纵价格,其他一些套利者可能会在同一区块中提交另一笔交易,紧接着再进行交易。矿工(或使用足够燃料填满整个区块的攻击者)可以在一个区块结束时操纵价格,但除非他们也在下一个区块进行开采,否则他们可能在套利交易中没有特别的优势[1.1]。
具体来说,Uniswap v2通过跟踪每个区块开始时有人与合约互动时的价格累积总和来累积这个价格。根据区块时间戳[1.2],每个价格都会根据上一个区块更新后的时间量进行加权。 这意味着在任何给定时间(被更新后)的累积器价值,应该是合约历史上每秒钟的现货价格之和。
预言机的用户可以选择何时开始和结束这个时期。选择一个较长的时期,虽然会导致价格不那么最新,但攻击者操纵TWAP的成本会更高。
一个复杂的问题是:我们应该用资产B来衡量资产A的价格,还是用资产A来衡量资产B的价格?虽然以B为单位的A的现货价格总是以A为单位的B的现货价格的倒数,但以B为单位的资产A在特定时间段内的平均价格并不等于以A为单位的资产B的平均价格的倒数[1.3]。例如,如果第1区块的USD/ETH价格是100,第2区块的USD/ETH价格是300,那么USD/ETH的平均价格将是200 USD/ETH,但ETH/USD的平均价格将是1/150 ETH/USD。由于合约无法知道用户想用这两种资产中的哪一种作为记账单位,所以Uniswap v2会跟踪两种价格。
另一个复杂的问题是,有人有可能在不与合约互动的情况下,向配对合约发送资产–从而改变其余额和边际价格,从而不触发预言更新。如果合约只是简单地检查自己的余额,并根据当前的价格更新预言,那么攻击者就可以通过在一个区块中第一次调用合约之前立即向合约发送资产来操纵预言。如果最后一次交易是在一个时间戳为X秒前的区块中进行的,那么合约就会在累积新价格之前错误地将其乘以X,即使没有人有机会在该价格进行交易。为了防止这种情况发生,核心合约在每次交互后都会缓存其储备,并使用从缓存储备中得出的价格而不是当前储备更新预言。除了保护预言不被操纵外,这一变化还实现了下面3.2节中描述的合约重新架构。
由于Solidity不提供对非整数数字数据类型的支持,Uniswap v2使用简单的二进制定点格式来编码和处理价格。具体来说,给定时刻的价格被存储为UQ112.112数字,这意味着在小数点的两边指定了112个小数位的精度,没有符号。这些数字的范围为[0,2^112-1] [1.4] ,精度为1/2^112 。
选择UQ112.112格式是出于一个实用的原因——因为这些数字可以存储在一个uint224中,这就使256位存储槽中的32位空闲出来。也恰好,每个存储在uint112中的储备,也会在一个(打包的)256位存储槽中留下32位空闲空间。这些空闲空间被用于上述的积累过程。具体来说,储备金与最近至少有一笔交易的区块的时间戳一起存储,用2^32修改,使其适合32位。此外,虽然任何给定时刻的价格(存储为UQ112.112数字)保证适合224位,但该价格在一个区间内的累积却不适合。A/B和B/A的累积价格的存储槽末端的额外32位用于存储价格重复相加所产生的溢出位。这种设计意味着,价格预言只在每个区块的第一次交易中增加了三次SSTORE操作(目前成本约为15000gas)。
主要的缺点是,32位还不足以存储合理地永远不会溢出的时间戳值。事实上,Unix时间戳溢出一个uint32的日期是02/07/2106。为了保证这个系统在这个日期之后,以及此后每隔2^32 −1秒的倍数继续正常运行,只需要oracles每隔一段时间(大约136年)至少检查一次价格。这是因为核心的累积方法(和修改时间戳),实际上是溢出安全的,这意味着跨溢出区间的交易可以适当地进行核算,因为oracles是使用适当的(简单的)溢出算术来计算增量。
在Uniswap v1中,用户用XYZ购买ABC时,需要先将XYZ发送到合约中,然后才能收到ABC。如果该用户需要他们购买的ABC来获得他们支付的XYZ,这就不方便了。例如,该用户可能会使用该ABC在其他合约中购买XYZ,以便从Uniswap套利价格差,或者他们可能会通过出售抵押品来偿还Uniswap来平仓Maker或Compound。
Uniswap v2增加了一个新的功能,允许用户在支付资产之前接收和使用该资产,只要他们在同一个原子交易中进行支付即可。swap函数在转出用户请求的代币和执行不变性之间调用一个用户指定的可选回调合约。一旦回调完成,合约就会检查新的余额,并确认不变性得到满足(在调整了支付金额的费用后)。如果合约没有足够的资金,就会还原整个交易。
用户也可以使用相同的代币来偿还Uniswap池,而不是完成交换。这实际上等于让任何人可以闪借Uniswap池中存储的任何资产(与Uniswap对交易收取0.30%的费用一样)[1.5]。
Uniswap v2包括0.05%的协议费,可以开启或关闭。如果开启,这笔费用将被发送到工厂合约中指定的feeTo收费地址。
初始不设置feeTo,也不收取费用。预先指定的feeToSetter地址,可以调用Uniswap v2工厂合约上的setFeeTo函数,将feeTo设置为不同的值,feeToSetter也可以调用setFeeToSetter来改变feeToSetter地址本身。
如果设置了feeTo地址,协议将开始收取0.05%的费用,这是从流动性提供者赚取的0.3%费用中抽取1/6来扣除。也就是说,交易者将继续对所有交易支付0.3%的费用:该费用的83.3%(从0.3%收费里抽取5/6)返回给LP,剩余的16.6%(从0.3%收费里抽取1/6)给feeTo账户。
如果在交易时收取这0.05%的费用,将会给每笔交易带来额外的燃料成本。为了避免这种情况,只有在存入或提取流动资金时才会收取累计费用。合约计算累计费用,并在任何代币被铸造或烧毁之前,立即向对应的LP和feeTo账户铸造新的流动性代币 (LP Token,名称为UNI V2)。
自上次收取费用以来,可以通过测量√K(即√(x∙y))的增长来计算收取的费用总额[1.6]。 这个公式给出了t1和t2之间的累计费用,占t2时池内流动性的百分比。
如果费用是在t1之前激活的,feeTo地址应该捕获t1和t2之间积累费用的1/6。因此,我们要向feeTo地址铸造新的流动性代币,其所占池子的比例为ϕ∙f_1,2 ,其中ϕ=1/6。
也就是说,我们要选择s_m满足以下关系,其中s_1为时间t_1的流通股总量。
用 1-√(k_1 )/√(k_2 ) = f_1,2 代入公式(5)中,得到如下:
当ϕ=1/6时,得到如下:
假设初始存款人将100 DAI和1 ETH投入到一个货币对中,获得10股。一段时间后(在没有任何其他存款人参与该货币对的情况下),他们试图提取该货币对,此时该货币对有96 DAI和1.5 ETH。将这些值插入上述公式,我们得到以下结果。
Uniswap v2对所铸成的池子份额原生支持元交易,这意味着用户可以通过签名授权转让他们的池子份额 ,而不是从他们的地址进行链上交易[1.7]。任何人都可以通过调用许可功能代表用户提交此签名,支付燃气费,并可能在同一交易中进行其他操作。
Uniswap v1是用类似于Python的智能合约语言Vyper实现的。Uniswap v2是在使用更广泛的Solidity中实现的,因为它需要一些在Vyper中还没有的功能(如解释非标准ERC-20代币的返回值的能力,以及通过内联汇编访问新的操作码,如chainid)。
Uniswap v2的一个设计重点是最大限度地减少核心配对合约——存储流动性提供者资产的合约的外接口和复杂性。该合约中的任何错误都可能是灾难性的,因为数百万美元的流动性可能被盗或冻结。
在评估这一核心合约的安全性时,最重要的问题是它是否能保护流动性提供者的资产不被窃取或锁定。任何旨在支持或保护交易者的功能——除了允许池中的某项资产换成另一项资产的基本功能之外,都可以在路由合约中处理。
事实上,甚至部分交换功能也可以拉到路由合约中。如上所述,Uniswap v2存储了每个资产的最后记录余额(以防止对预言机制的特殊操纵性利用)。新架构使用这个存储标记,进一步简化了Uniswap v1合约。
在Uniswap v2中,卖方在调用交换函数之前将资产发送到核心合约。然后,合约通过比较上次记录的余额和当前的余额来衡量它收到了多少资产。这意味着核心合约对交易者转移资产的方式是不可知的。而不是transferFrom(),它可以是一个元交易,或任何其他未来授权转让ERC-20的机制。
Uniswap v1的交易费是在执行恒定乘积做市商之前,将支付给合约的金额减少0.3%。该合约隐含地执行以下公式:
对于快速交换,Uniswap v2考虑到了x_in和y_in可能都是非零的可能性(当用户想使用相同的资产来偿还这对资产,而不是交换)。为了处理这种情况,同时正确适用费用,合约的编写强制地执行以下不等式[1.8]:
为了简化这个链上的计算,我们可以将不等式的两边各乘以1,000,000。
为了防止定制的代币实现能够更新配对合约的余额,并更优雅地处理总供应量可能大于2^112的代币,Uniswap v2有两个保底函数:sync()和skim()。
sync()在代币异步降低交易对的余额的情况下,作为一种恢复机制发挥作用。在这种情况下,交易将获得次优的利率,如果没有流动性提供者愿意纠正这种情况,该货币对就会被卡住。sync()的存在是为了将合约的储备金设置为当前的余额,为这种情况提供某种程度上的宽松恢复。
skim()的功能是作为一个恢复机制,当有足够的代币被发送到一个货币对,溢出两个uint112存储槽的储备,否则会导致交易失败。如果当前余额和2^112-1之间的差额大于0,则shim()函数允许用户把这些差额交给调用者。
ERC-20标准要求transfer()和transferFrom()返回一个表示调用成功或失败的布尔值[4]。这些函数的一个或两个在一些代币上的实现——包括像Tether (USDT)和Binance Coin (BNB)这样的流行代币,却没有返回值。Uniswap v1将这些定义不当的函数的缺失返回值解释为false:即表明转账不成功,并恢复交易,导致尝试的转账失败。
Uniswap v2以不同的方式处理非标准Token。具体来说,如果transfer()调用没有返回值[1.9] ,Uniswap v2将其解释为成功而不是失败。这个变化不应该影响任何符合标准的ERC-20令牌(因为在这些Token中,transfer()总是有一个返回值)。
Uniswap v1还假设调用transfer()和transferFrom()不能触发对Uniswap对合约的重入调用。这个假设被某些ERC-20代币所攻击,包括支持ERC-777的 “钩子”[5]。为了完全支持这样的代币,Uniswap v2包含了一个 “锁”,直接防止对所有公共状态变化函数的重入。这也防止了闪电贷中用户指定的回调的重入,如2.3节所述。
当新的流动性提供者将代币存入现有的Uniswap交易对时,将根据现有的代币数量计算铸造的流动性代币(LP Token)数量。
但如果他们是第一个存款人呢?在这种情况下,x_starting为0,所以这个(12)公式将无法使用。
Uniswap v1将初始份额供应量设置为存入的ETH数量(以wei为单位)。这是一个比较合理的值,因为如果初始流动性以正确的价格存入,那么1个流动性池份额LP token(和ETH一样,是18位精度的代币)大约价值2个ETH。
然而,这意味着流动性池份额的价值取决于流动性最初存入的比率,这是相当任意的,特别是,由于该比率无法保证反映出真实的价格。此外,Uniswap v2支持任意代币对,所以很多代币对根本不会包含ETH。
改进的方法是,Uniswap v2最初发行的LP Token总量为√K(每种TokenA/TokenB流动性池都有自己的LP Token,它们相互独立。如果有3个流动性池,则有对应的3种LP Token,虽然它们的symbol名字都叫UNI-V2),即为资金池里货币对x∙y的几何平均数:
这个公式保证了流动性池份额√K,在任何时候的价值与流动资金最初存入的比例基本无关。例如,假设1 ABC的价格目前是100 XYZ。如果最初存入的是2 ABC和200 XYZ(比例为1:100),那么储户将获得√(2∙200)=20股LP Token。现在这些股份LP Token的价值应该仍然是2 ABC和200 XYZ,加上累积的费用。
如果初始存款为2ABC和800XYZ(比例为1:400),则储户将获得√(2∙800)=40股LP token [1.10]。
上述公式确保流动性池份额的价值永远不会低于该池中储备金的几何平均数。然而,流动性池份额的价值有可能随着时间的推移而增长,无论是通过积累交易费还是通过对流通量池的 “捐赠”。理论上,这可能会导致流动性池最低数量的股份(1e-18个池内股份)价值如此之高,以至于小型流动性提供者无法提供任何流动性。
为了缓解这一问题,Uniswap v2在创建代币配对时,会烧掉1e-15 (0.000000000000001)个LP Token(1000倍最小数量的池份额 = 1000 wei),将它们发送到零地址,而不是发送到minter。这对于几乎所有的代币对来说,应该是一个可以忽略不计的成本[1.11]。 但它极大地增加了骇客攻击的成本,比如,为了将流动性池份额的价值提高到100美元,骇客攻击者需要向池子捐赠10万美元(10万 = 100*1000),而这10万美元会被Uniswap作为流动性资产,永久锁定在资源池中(即蒸发掉了)。
WETH是用于与Ethereum的原生ETH代币进行交互的接口,它不同于ERC-20代币交互的标准接口。因此,Ethereum上的许多其他协议不支持ETH,而是使用规范的“包装ETH”代币 [6]。
Uniswap v1是个例外。由于Uniswap v1的每一对都包含ETH,作为一种资产,所以直接处理ETH是合理的,这样略微节省gas。
由于Uniswap v2支持任意的ERC-20对,现在支持未包装的ETH已经没有意义了。增加这样的支持将使核心代码库的规模翻倍,并且有可能使ETH和WETH对之间的流动性分散[1.12]。在Uniswap v2上交易之前,原生ETH需要被封装到WETH中。
与Uniswap v1中一样,所有Uniswap v2配对合约都由一个工厂合约实例化。在Uniswap v1中,这些配对合约是使用CREATE操作码创建的,这意味着这种合约的地址取决于该代币对的创建顺序。Uniswap v2使用Ethereum新的CREATE2操作码[8]来生成具有确定性地址的配对合约。这意味着可以在链外计算一个配对合约的地址(如果它存在的话),而不必查看链上状态。
为了有效地实现预言机制,Uniswap v2只支持高达2^112-1的储备余额。这个数字足以支持18个小数点位的代币,总供应量超过1万亿。
如果储备金余额超过2^112-1,任何对swap()函数的调用都将失败(由于_update()函数的检查)。为了从这种情况中恢复过来,任何用户都可以调用skim()函数从流动性池中移除多余的资产。
注[1.1] 关于使用Uniswap v1作为预言机,如何使合约容易受到这种攻击的实际例子,见[3]。
注[1.2] 由于矿工在设置区块时间戳参数有一定的自由度,因此使用预言机的用户应该意识到,这些数值可能与现实世界的时间并不精确对应。
注[1.3] 某一时期A资产与B资产的算术平均价格等于该时期B资产与A资产的谐波平均价格的倒数。如果合同衡量的是几何平均价格,那么价格将是彼此的倒数。然而,几何平均TWAP不太常用,在Ethereum上很难计算。
注[1.4] 2112-(1/2112 )的理论上限在此环境下并不适用,因为Uniswap中的UQ112.112数总是由两个uint112的比值生成。最大的这种比值是(2^112-1)/1= 2^112-1。
注[1.5] 因为Uniswap是按投入金额收费的,所以相对于提现金额的费用其实略高:
1/(1-0.003)-1= 3/997=0.300903%。
注[1.6] 我们可以使用这个不变式,它不计入被铸造或烧毁的流动性代币,因为我们知道每次存入或提取流动性时都会收取费用。
注[1.7] 签名的消息符合EIP-712标准,与CHAI和DAI等代币的元交易使用的标准相同。
注[1.8] 在某一时期,A资产与B资产的算术平均价格等于该时期B资产与A资产的谐波平均价格的倒数。如果合同衡量的是几何平均价格,那么价格将是彼此的倒数。然而,几何均值TWAP不太常用,在Ethereum上很难计算。
注[1.9] 如上文3.2节所述,Uniswap v2 core没有使用transferFrom()。
注[1.10] 这也减少了四舍五入错误的可能性,因为股份数量的比特数,将近似于储备中资产X数量的比特数和储备中资产Y数量的比特数的平均值:
注[1.11] 理论上,在某些情况下,这种燃烧可能是不可忽略的,例如高价值的零十进制代币之间的配对。然而,这些交易对无论如何都不适合Uniswap,因为四舍五入的误差会让交易变得不可行。
注[1.12] 截至本文撰写时,Uniswap v1上流动性最高的交易对之一是ETH和WETH之间的交易对。
本文仅供参考。它不构成买卖任何投资的投资建议或招揽建议,也不应⽤于评估任何投资决定的优劣。不应将其作为会计,法律或税务建议或投资建议的依据。本文仅反映作者当前的观点,并不代表Paradigm或其关联公司,也不一定反映Paradigm,其附属公司或与Paradigm相关的个人的观点。本文中反映的观点如有更改,恕不更新。
[1] Hayden Adams. 2018. url:https://hackmd.io/@477aQ9OrQTCbVR3fq1Qzxg/HJ9jLsfTz?type=view.
[2] Guillermo Angeris et al. An analysis of Uniswap markets. 2019. arXiv: 1911.03380 [q-fin.TR].
[3] samczsun. Taking undercollateralized loans for fun and for profit. Sept. 2019. url:
https://samczsun.com/taking-undercollateralized-loans-for-fun-and-for-profit/
[4] Fabian Vogelsteller and Vitalik Buterin. Nov. 2015. url:
https://eips.ethereum.org/EIPS/eip-20
[5] Jordi Baylina Jacques Dafflon and Thomas Shababi. EIP 777: ERC777 Token Standard. Nov. 2017.url: https://eips.ethereum.org/EIPS/eip-777
[6] Radar. WTF is WETH? url: https://weth.io/
[7] Uniswap.info. Wrapped Ether (WETH). url:
https://uniswap.info/token/0xc02aaa39b223fe8d0a0e5c4f27
[8] Vitalik Buterin. EIP 1014: Skinny CREATE2. Apr. 2018. url:
https://eips.ethereum.org/EIPS/eip-1014