Uniswap V2 详解

admin
admin 2024年09月22日
  • 在其它设备中阅读本文章

一、新特性

  1. 提供任意 ERC20-ERC20 交易对,不再局限于 V1 的 ERC20-ETH 交易对
  2. 价格预言机,有效防止价格操纵,减缓价格波动
  3. 闪电兑换(闪电贷),自由借出并使用代币,并在该交易的最后归还代币
  4. 一个 0.05% 的协议手续费开关。如果开启,流动性提供者将获得 0.25% 手续费

ERC20 交易对

Uniswap v1 使用 ETH 作为桥梁货币,每个交易对都包含 ETH 作为其资产之一。这使得路由更简单,比如要想实现 ABC 和 XYZ 的交易,只需要分别使用 ETH/ABC 和 ETH/XYZ 交易对即可,减少了流动性的分散。

然而,这条规则给流动性提供者带来了巨大的成本。所有流动性提供者都接触 ETH,由于 x * y = k 引入的滑点,并根据其他资产相对于 ETH 的价格变化而遭受无常损失(类似股票的浮亏),简单而言就是在代币价格单方面(上涨或下跌)波动时,做市者手中持有的代币实际总价值反而减少。

使用 ETH 作为强制的交易代币也会增加交易成本。相比直接使用 ABC/XYZ 交易对,他们将支付两倍的交易手续费,同时承受两倍的滑点。因为在 v1,要想从 ABC 交易到 XYZ,必须依次交易 ABC/ETH 和 ETH/XYZ,因此手续费和滑点都需要两倍。v2 允许流动性提供者为任意两个 ERC-20 代币创建交易对合约。但交易对数量的激增将给寻找最优交易路径带来困难,但是路由问题可以在上层解决(比如通过链下或链上的路由器或聚合器)

v2 不直接支持 ETH 的交易对,它需要包装成遵循 ERC20 标准的 WETH Token。

价格预言机

Uniswap 流动池在某个时间点的代币边际价格(不包含手续费),可以通过代币 a 和代币 b 的总量相除得出:p=a/b。当提供的价格不正确时,套利者可以在 Uniswap 交易套利,因此 Uniswap 提供的代币价格将跟随市场价格。这意味着 Uniswap 提供的代币价格可以作为一种近似的价格预言机。

然而,Uniswap v1 无法提供安全的链上预言机,因为它的价格很容易被操控。假设其他合约使用当前 ETH-DAI 价格作为衍生品交易的基准价格。攻击者可以从 ETH-DAI 交易对买入 ETH 来操控价格,并触发衍生品合约的清算,接着再将 ETH 卖回以使价格回归正常。上述操作可以通过一个原子交易完成,或者被矿工通过排序同一区块中的不同的交易来实现。

注:由于 V1 的采样的价格是瞬时的,因此很容易通过买入卖出大额代币来操纵实时价格。

Uniswap v2 改进了预言机功能,通过在每个区块的第一笔交易前记录累计价格来实现,每个价格会以时间权重记录(基于当前区块与上一次更新价格的区块的时间差)。这意味着在任意时间点,该累计价格将是此合约历史上每秒的现货价格之和。操纵这个价格会比操纵区块中任意时间点的价格要困难。如果攻击者通过在区块的最后阶段提交一笔交易来操纵价格,其他套利者(发现价格差异后)可以在同一区块中提交另一笔交易来将价格恢复正常。矿工(或者支付了足够 gas 费用填充整个区块的攻击者)可以在区块的末尾操控价格,但是除非他们同时挖出了下一个区块,否则他们没有特殊的优势可以进行套利。

价格预言机.jpg

注:由于价格预言机仅在每个区块记录一次,因此除非同一个人控制了两个区块的所有交易,否则他们将没有足够的套利优势。

为了估算在 t1 到 t2 时间段内的时间加权平均价格(TWAP),外部调用者可以分别记录 t1 和 t2 的累计价格,将 t2 价格减去 t1 价格,并除以 t2-t1 的时间差。预言机的用户可以自行选择区间的开始和结束。选择一个更长的区间,意味着攻击者将花费更高的代价来操控该区间的时间加权平均价格,虽然这将导致该平均价格与实时价格相差较大。

注:由于合约仅记录当前的累计价格,因此如果需要计算区间的平均价格,外部应用要自己记录并保存历史价格,合约本身不保存历史数据。

Uniswap v2 的 TWAP 计算方式实际上使用的是(加权)算数平均数。在数学上有一个毕达哥拉斯平均的概念,指的是三种经典平均数,分别是:算数平均数、几何平均数和调和平均数。

平均数.png

其中,算术平均数是最常见的一种平均数,其优点是计算简单,缺点是容易受到极端数据的影响,导致均值误差;几何平均数相比算术平均数,更适用于在金融市场场景,因为金融市场价格本身是一种布朗运动;调和平均数更易受到极小值的影响,一般应用于计算平均速率等场景。

从应用场景上,Uniswap 价格均值应该使用几何平均数更合适,均值的误差更小,但由于几何平均数在以太坊合约上实现难度较大,所以 Uniswap v2 版本采用算数平均数;但是 Uniswap v3 则使用几何平均数计算价格预言机。

一个难题:我们应该计算以 B 代币计价的 A 代币价格,还是以 A 代币计价的 B 代币价格?虽然在现货价格上,以 B 代币计价的 A 代币价格(B/A)与以 A 代币计价的 B 代币价格(A/B)总是互为倒数,但在计算某个时间区间的算数平均数时,二者却不是互为倒数关系。比如,假设在区块 1 的价格为 100 USD/ETH(B 为 USD,A 为 ETH),区块 2 的价格为 300 USD/ETH,则其平均价格为 200 USD/ETH,但 ETH/USD 的平均价格却是 1 /150 ETH/USD。因为合约无法知道交易对中哪一个代币将被用户用作计价单位,因此 Uniswap v2 同时记录了两个代币的价格。

价格计算.png

另一个难题是用户可以不通过交易而直接向交易对合约发送代币(这将改变代币余额并影响价格),此时将无法触发预言机价格更新。因为预言机价格需要在区块的第一笔交易之前更新,因此如果不交易,将绕开预言机更新。

如果合约只是简单地检查它的余额,并使用当前余额计算价格来更新预言机,那么攻击者可以在区块的第一笔交易之前,立即向合约发送代币来操控预言机价格。如果上一笔交易是在 X 秒之前的某个区块,合约将错误的使用(被操纵后的)新价格乘以 X 来累计,即使并没有人使用该价格交易过。为了防止这个问题,core 合约在每次交互后缓存了两种代币余额,并且使用缓存余额(而非实时余额)更新预言机价格。

精度

因为 Solidity 原生不支持非整数数据类型,Uniswap v2 使用了简单的二进制定点制进行编码和操作价格。确切地说,任意时间的价格都被保存为 UQ112.112 格式的数据,它表示在小数点的左右两边都有 112 位比特表示精度,无符号(注:非负数)。这个格式能表示的范围为 [0, (2 的 112 次方)-1],精度为(2 的 112 次方) 分之一。

择 UQ112.112 格式是出于(Solidty 合约)编程实践的考虑,因为这些格式的数字能够使用一个 uint224 类型(占用 224 位比特,28 个字节)的变量表示,在一个 256 位(比特)的存储槽(注:EVM 中一个 Storage Slot 是 256 位)中正好剩余 32 位可用。而对于缓存的代币余额变量,每一个代币余额可以使用一个 uint112 类型(112 比特位,14 个字节)的变量,(在声明时)也正好在 256 位的存储槽中剩余 32 位可用。这些剩余空间可用于上述的累计运算使用。具体来说,代币余额与最近一个有交易区块的时间戳一起保存,该时间戳针对 2 的 32 次方取模,以确保可以使用 32 位表示。此外,虽然在任意时间点的价格(使用 UQ112.112 格式的数字)一定符合 224 位,但是一段时间的累计价格却不是这样。在存储槽末尾的多余 32 位空间将用于存储由于重复累计价格导致的溢出数据。这样的设计意味着价格预言机仅仅在每个区块的第一笔交易增加了 3 个 SSTORE 操作(当前消耗 15,000 gas)。为了避免每次交易都更新预言机给用户带来额外交易成本,Uniswap v2 设计成只在每个区块的第一笔交易之前更新。

每个代币余额使用 uint112 表示,时间戳使用 32 位表示,总共 112+112+32=256 位,正好占用一个 storage slot。更少的 storage slot 意味着交互时需要花费的 gas 更小,有利于减少用户操作成本。累计价格则采用 256 位表示。这个设计最主要的缺点是 32 位无法确保时间戳永不溢出。事实上,Unix 时间戳溢出 32 位(可表示的最大值)将发生在 02/07/2106。

闪电贷

在 Uniswap v1,用户如果想使用 XYZ 购买 ABC,则需要先将 XYZ 发送到合约才能收到 ABC。这将给那些希望使用 ABC 购买 XYZ 的用户带来不便。比如,当 Uniswap 与其他合约出现套利机会时,用户可能希望使用 ABC 在别的合约购买 XYZ;或者用户希望通过卖出抵押物来释放他们在 Maker 或 Compound 的头寸,以此偿还 Uniswap 的借款。

Uniswap v2 增加了一个新特性,允许用户在支付费用前先收到并使用代币,只要他们在同一个交易中完成支付。swap 方法会在转出代币和检查 k 值两个步骤之间,调用一个可选的用户指定的回调合约。一旦回调完成,Uniswap 合约会检查当前代币余额,并且确认其满足 k 值条件(在扣除手续费后)。如果当前合约没有足够的余额,整个交易将被回滚。

闪电兑换.jpg

用户可以只归还原始代币,而不需要执行交易操作。这个功能将使得任何人可以闪电借出 Uniswap 池子中的任意数量的代币(闪电贷手续费与交易手续费一致,都是 0.30%)。Uniswap v2 合约中的闪电贷与交易功能实际上使用同一个 swap 方法。

协议手续费

Uniswap v2 包含一个 0.05% 的协议手续费开关。如果打开,该手续费将被发送到合约中的 feeTo 地址。默认情况下没有设置 feeTo 地址,因此不收取协议手续费。预定义的 feeToSetter 地址可以调用 Uniswap v2 工厂合约中的 setFeeTo 方法来修改 feeTo 地址。feeToSetter 也可以调用 setFeeToSetter 修改合约中 feeToSetter 地址。

如果 feeTo 地址被设置了,协议将开始收取 5 个基点(0.05%)的手续费,也就是流动性提供者收取的 30 个基点(0.30%)手续费中的 1 / 6 将分配给协议。如果在每笔交易时收取 0.05% 的手续费,将带来额外的 gas 消耗。为了避免这个问题,累计的手续费只在提供或销毁流动性时收取。合约计算累计手续费,并且在流动性代币铸造或销毁的时候,为手续费受益者铸造新的流动性代币。

二、改动

Solidity
Uniswap v1 使用 Vyper 语言实现,这是一个类 Python 的智能合约语言。Uniswap v2 使用更流行的 Solidity 语言实现。

合约重构
Uniswap v2 的一个设计重点在于最小化 core 交易对合约的对外接口范围和复杂度。core 合约仅保留最基础最重要的功能,以保证安全性,因为所有流动性资产将存放在 core 合约中。其它功能都被抽取放到 router(路由)合约,由路由合约对外提供接口和服务。

在 Uniswap v2,卖方在执行 swap 方法前,会发送代币到 core 合约。合约将通过比较缓存余额和当前余额来判断收到多少代币。这意味着 core 合约无法知道交易者是通过什么方式发送代币。事实上,他可以通过离线签名的元交易方式

手续费调整
Uniswap v1 的交易手续费是通过减少存入合约的代币数量来实现,在比较 k 恒等式之前,需要先减去 0.3% 的交易手续费。合约隐式约束为:(xin - 0.003xin)yin>=k,通过闪电贷功能,Uniswap v2 引入了一种可能性,即 xin 和 yin 可能同时不为 0(当一个用户希望通过归还借出的代币,而不是做交易时)。为了处理这种情况下的手续费问题,合约强制要求约束为:(xin - 0.003xin)(yin - 0.003*yin)>=k。因为当通过闪电贷同时借出 x 和 y 两种代币时,需要分别对 x 和 y 收取 0.3% 的手续费,因此需要先扣除手续费,再保证余额满足 k 值约束。

sync() 和 skim()
为了防止某些可以修改交易对合约余额的定制代币,同时也为了更优雅地解决那些总量超过 2 的 112 次方的代币,Uniswap v2 提供了两个方法:sync()和 skim()。
简单来说,由于某些(非 Uniswap 导致的)外部因素,交易对合约中的缓存余额与实际余额可能出现算法外的不一致问题。sync()方法可以更新缓存余额到实际余额(以流动池实际持有的代币数量进行修正),skim()方法可以更新实际余额到缓存余额(取出多余的代币,大于 2 的 112 次方部分),从而保证系统继续运行。任何人都可以执行这两个方法。如果有人误将交易对中的代币转入合约,任何人都可以取出这些代币。

处理非标准和罕见代币
ERC-20 标准要求 transfer() 和 transferFrom()返回一个布尔值表示该请求是否成功。然而某些代币在实现这两个(或其中一个)方法时并没有返回值,比如 USDT 和 BNB。Uniswap v1 在解析无返回值的方法时,将其当作失败处理,因此将回滚交易,从而导致交易失败。
Uniswap v2 引入了”lock”机制用来解决所有公开修改状态方法的重入问题。这也可以防止在闪电贷中用户自定义回调的重入问题。lock 实际上是一个 Solidity modifer,通过一个 unlock 变量控制同步锁

初始化流动性代币供应
当一个新的流动性提供者将代币存入一个已存在的 Uniswap 交易对,新铸造的流动性代币数量可根据当前代币数量计算,按照提供的份额等比例增发。

因为 Uniswap v1/v2 提供流动性时需要注入两边等值的代币,如果份额等同于 ETH 数量,则 1 份额表示需要存入 1ETH,而在价格正确时,另一个代币的价值也同样是 1ETH,因此 1 个流动性份额的流动性总价值是 2ETH。理论上可能存在这种情况,最小的流动性代币单位(1 的 18 次方分之一,即 1 wei)的价值太高,以至于无法让其他(小)流动性提供者加入。

为了解决这个问题,Uniswap v2 销毁首次铸造 10 的 15 次方分之一(最小代币单位的 1000 倍)流动性代币。这个损耗对于大部分交易对而言都是微不足道的。但是这将极大提到首次铸币攻击的代价。

首次铸币攻击是指攻击者在第一次添加流动性时存入最小单位(10 的 -18 次方,即 1 wei)的流动性,比如 1 wei ABC 和 1 wei XYZ,此时将铸造 1 wei 流动性代币(根号 1);同时,攻击者在同一个交易中继续向池子转入(非铸造)100 万个 ABC 和 100 万个 XYZ,接着调用 sync()方法更新缓存余额,此时 1 wei 的流动性代币价值 100 万 +(10 的 -18 次方)ABC 和 100 万 +(10 的 -18 次方)XYZ,其他流动性参与者要想添加流动性,需要等价的大量代币,其价格可能高到大部分人无法参与。

包装 ETH 为 WETH
由于 Uniswap v2 支持任意 ERC-20 交易对,因此没有必要支持原生 ETH 交易。增加这种支持将使 core 合约代码量翻倍,并且将使流动性分裂为 ETH 和 WETH 交易对。原生 ETH 需要先封装为 WETH 才能在 Uniswap v2 交易。

事实上,Uniswap v2 只是 core 合约不支持原生 ETH,periphery 合约仍然支持原生 ETH 交易,合约会自动将 ETH 转为 WETH,然后再调用 core 合约进行交易。这里也反映出 Uniswap 一直倡导的开发原则,保持 core 合约最简化,应用和用户体验的逻辑依靠 periphery 合约解决。

确定的交易对地址
与 Uniswap v1 一样,所有 Uniswap v2 交易对合约都由一个统一的工厂合约初始化生成。在 Uniswap v1,这些合约使用 CREATE 操作码创建,这意味着这些合约的地址依赖于合约生成的顺序。Uniswap v2 使用以太坊新的 CREATE2 操作码生成具有确定地址的交易对合约。这意味着交易对合约的地址是可以通过链下计算的,而无需查询链上状态。

最大代币余额
为了更有效地实现预言机功能,Uniswap v2 只支持缓存代币余额的最大值为 (2 的 112 次方)-1。该数字已经大到可以支持代币总量超过千万亿的 18 位小数代币。

如果任意一种代币余额超过最大值,swap 方法的调用将会失败(由于_update()方法的检查导致)。为了从这种状况中恢复,任何人都可以调用 skim()方法来从池子中移除多余的代币。

Uniswap v2 合约概览