TL;DR
V2 相较于 V1 的主要特点:
- 支持创建由 ERC20 token 组成的交易对合约。
- 价格预言机使用时间加权平均价格,价格不易被操纵。
- 支持闪电兑。
- 可以开启或关闭 0.05% 的协议手续费,占总手续费 0.3% 的 $$\frac{1}{6}$$,收取的形式是铸造相应数量的 UNI token 转到受益方的地址。
- 用户可以提交合法签名来授权流动性池份额(UNI token)的转账或是获得转账的授权。
- 合约架构拆分成 core 和 periphery 两个合约:core 合约负责管理资产;periphery 合约支撑用户的使用场景,无状态,方便升级。
V2 白皮书
支持 ERC20 token 之间组成交易对
V1 不允许 ERC20 token 之间直接兑换,必须以 ETH 为中介。
- 优点:实现上兑换操作的路由关系简单清晰;减少了碎片化的流动性。
- 缺点:LPs 必须持有 ETH、承担 ETH 的敞口(更多的无常损失);兑换者需要支付两次手续费,承受两次滑点。
- When two assets ABC and XYZ are correlated —— for example, if they are both USD stablecoins —— liquidity providers on a Uniswap pair ABC/XYZ would generally be subject to less impermanent loss than the ABC/ETH or XYZ/ETH pairs.
V2 core 只支持创建由 ERC20 token 组成交易对合约。
ETH 作为 Ethereum 的原生资产,它的交易接口和 ERC20 不同。为了精简代码库,V2 不再支持 ETH,ETH 要包装成 WETH(wrapped ETH,实现了 ERC20)才能用来交易。
- V2 periphery 合约提供了 ETH 和 WETH 互相转换的功能。
价格预言机
在时刻 t,对于资产 a 和资产 b 组成的交易对,Uniswap 计算边际价格(marginal price)$$p_t$$ 的公式(不考虑手续费)是资产 a 的储量除以资产 b 的储量($$p_t = \frac {r^a_t} {r^b_t}$$)。
- $$p_t$$ 也就是 $$\frac{∆a}{∆b}$$;推导见 Uniswap V1 Core 一探。
由于套利者的存在,V1 的一个流动性池中两种资产的数量可以相对准确地反映这两种资产的市场价格,但在较短时间跨度内交易池中的资产价格可以很容易被攻击者操控,因此不适合作为链上价格预言机。
- Suppose some other contract uses the current ETH-DAI price to settle a derivative. An attacker who wishes to manipulate the measured price can buy ETH from the ETH-DAI pair, trigger settlement on the derivative contract (causing it to settle based on the inflated price), and then sell ETH back to the pair to trade it back to the true price. This might even be done as an atomic transaction, or by a miner who controls the ordering of transactions within a block.
- A real-world example: samczsun: Taking undercollateralized loans for fun and for profit ↗
为描述方便,将 Uniswap 交易定义为调用了合约核心逻辑的交易。
- 涉及的方法有
mint
,burn
,swap
,sync
.
V2 对每个区块中第一笔 Uniswap 交易出现时的资产价格进行累加处理,计算公式是合约生命周期内每一秒的现货价格的加和 $$a_t = \sum\limits^t_{t=1} p_i$$。
- 这一算法把两次累加时机之间的价格都按最近一次执行累加时的价格来进行累加。
- 选取“第一个”价格的好处是这一价格不容易操纵。
- If the attacker submits a transaction that attempts to manipulate the price at the end of a block, some other arbitrageur may be able to submit another transaction to trade back immediately afterward in the same block.
- A miner (or an attacker who uses enough gas to fill an entire block) could manipulate the price at the end of a block, but unless they mine the next block as well, they may not have a particular advantage in arbitraging the trade back.
由此可以推导出 $$t_1$$ 到 $$t_2$$ 时间范围内的时间加权平均价格(time-weighted average price, TWAP),用作价格预言机的价格:
$$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}$$。
使用 TWAP 作为价格预言机的价格引入两个问题:
- 使用 TWAP 后,资产 A 相对于资产 B 的价格与资产 B 相对于 资产 A 的价格之间不再存在倒数关系,用户可能选择资产 A 或资产 B 作为账户资产的单位,因此合约需要同时记录两个价格。
- If the USD/ETH price is 100 in block 1 and 300 in block 2, the average USD/ETH price will be 200 USD/ETH, but the average ETH/USD price will be 1/150 ETH/USD.
- 在新区块的第一笔 Uniswap 交易之前,用户直接向 a 和 b 组成的交易对合约转 ERC20 token(假设转了 n 个 a token),对预言机的价格进行操纵。
- 直接转 ERC20 token 的交易不会调用到合约逻辑,不属于 Uniswap 交易。
- 假设在这样一笔转账交易的 X 秒后,区块中出现了第一笔 Uniswap 交易,此时的边际价格 $$p_{cooked}$$ 实际上被操纵成 $$\frac {r^a + n} {r^b}$$,在价格 $$p_{cooked}$$ 上实际上并没有发生 Uniswap 交易,但在计算累计价格时却会被计算在内;多算的部分的大小是 $$n * X$$。
- 解决方法是在每次 Uniswap 交易后都缓存两种资产的储量,每次用最新的缓存的储量值来计算最新的边际价格,这样就能剔除非 Uniswap 交易的干扰。
V2 采用 UQ112.112 格式编码价格(主要是为了处理除法),小数点左右两边最多支持 112 位二进制数的精度,没有符号位。
|
|
- 1.0 用 UQ112.112 编码的结果是 $$1 * 2^{112}$$,1.5 则是 $$1 * 2^{112} + 0.5 * 2^{112} = 1 * 2^{112} + 1 * 2^{111}$$。
- 从十进制的角度去理解,假设编码成 UQ3.3,则 1.0 就是 $$1 * 1000$$,1.5 就是 $$1 * 1000 + 0.5 * 1000$$,也就是乘以 $$10^3 $$ 将整个浮点数放大,移除小数点。
- Numbers of UQ112.112 format can be stored in a
uint224
, this leaves 32 bits of a 256 bit storage slot free. - Although the price at any given moment (stored as a UQ112.112 number) is guaranteed to fit in 224 bits, the accumulation of this price over an interval is not. The extra 32 bits on the end of the storage slots for the accumulated price of A/B and B/A are used to store overflow bits resulting from repeated summations of prices.
- 某一时刻的价格的范围是 $$[0, 2**112 - 1]$$,以 UQ112.112 格式编码后可以存放在
uint224
中,但累计价格可以会超出uint224
能容纳的最大值,因此累计价格用uint
存,多出 32 位的余量。 - The contract itself does not store historical values for this accumulator——the caller has to call the contract at the beginning of the period to read and store this value. Users of the oracle can choose when to start and end this period. Choosing a longer period makes it more expensive for an attacker to manipulate the TWAP, although it results in a less up-to-date price.
- This design means that the price oracle only adds an additional three
SSTORE
operations (a current cost of about 15,000 gas) to the first (Uniswap) trade in each block.
- 某一时刻的价格的范围是 $$[0, 2**112 - 1]$$,以 UQ112.112 格式编码后可以存放在
- The reserves, each stored in a
uint112
, also leave 32 bits free in a (packed) 256 bit storage slot.- The reserves are stored alongside the timestamp of the most recent block with at least one trade, modded with $$2^{32}$$ so that it fits into 32 bits.
闪电兑
V1 中,用户用 XYZ 兑 ABC 时,必须先把 XYZ 转到合约地址后才能收到 ABC。假设其它平台上 ABC/XYZ 交易对的 XYZ 被低估,用户自然会用 ABC 去购买 XYZ 实施套利,可以分为几种情况讨论:
- 用户持有 ABC,直接发起套利交易,购买把其它平台的 XYZ 并在 Uniswap 中进行兑换;
- 用户持有其它币,但它们都锁定在其它合约中,需要用 ABC 才能解锁,此时形成循环依赖,在这样的情况下 V1 的用户无法实施套利;
- 用户不持有任何币(愿意支付 gas)但想空手套白狼,V1 不支持这种“好事”。
V2 新增了闪电兑(Flash Swap)的特性,在一个原子交易内,用户可以在支付之前就收到并使用它要购买的资产,只要保证在交易结束前将其归还。
- The
swap
function makes a call to an optional user-specified callback contract in between transferring out the tokens requested by the user and enforcing the invariant. Once the callback is complete, the contract checks the new balances and confirms that the invariant is satisfied (after adjusting for fees on the amounts paid in). If the contract does not have sufficient funds, it reverts the entire transaction.- callback 如何构造?
- A user can also repay the Uniswap pool using the same token, rather than completing the swap. This is effectively the same as letting anyone flash-borrow any of assets stored in a Uniswap pool (for the same 0.30% fee as Uniswap charges for trading).
- A user wants to pay the pair back using the same asset, rather than swapping.
V2 支持闪电兑可以鼓励套利者搬平价差,让平台更健康。
协议手续费
V2 可以开启或关闭 0.05% 的协议手续费,占总手续费 0.3% 的 $$\frac{1}{6}$$,收取的形式是铸造相应数量的 UNI token 转到受益方的地址。
- 如果每次一发生交易就收取手续费,每笔交易就需要额外的 gas 费,所以只在执行到提供流动性或移除流动性的逻辑之前结算累计的手续费。
- The contract computes the accumulated fees, and mints new liquidity tokens to the fee beneficiary, immediately before any tokens are minted or burned.
假设协议手续费收取开关在 $$t_1$$ 之前已开启,两次收取手续费的时点是 $$t_1$$ 和 $$t_2$$:
- 根据 mint 计算公式,$$t_1$$ 时刻 UNI 的总发行量是 $$\sqrt{x_1 \cdot y_1} = \sqrt{k_1}$$,$$t_2$$ 时刻则是 $$\sqrt{x_2 \cdot y_2} = \sqrt{k_2}$$,两者的差值就是这一时间范围内的总手续费 $$\sqrt{k_2} - \sqrt{k_1}$$,它占 $$t_2$$ 时刻的总发行量的占比是 $$\frac{\sqrt{k_2} - \sqrt{k_1}}{\sqrt{k_2}} = 1 - \frac{\sqrt{k_1}}{\sqrt{k_2}} = f_{1,2}$$,这其中属于受益方的部分还要乘以 $$\frac{1}{6}$$。
- 这一差值的 $$\frac{1}{6}$$ 就是 $$t_2$$ 时刻要铸造出来转给受益方的 UNI 的数量,记为 $$s_m$$。
- x 和 y 是储量,k 是恒定乘积。
- 假设 $$t_1$$ 时刻 UNI 总发行量是 $$s_1$$,令 $$ϕ = \frac{1}{6}$$,$$s_m$$ 要满足 $$\frac{s_m}{s_m + s_1} = ϕ \cdot f_{1,2}$$,推导得到 $$s_m = \frac{\sqrt{k_2} - \sqrt{k_1}}{(\frac{1}{ϕ} - 1) \cdot \sqrt{k_2} + \sqrt{k_1}} \cdot s_1 = \frac{\sqrt{k_2} - \sqrt{k_1}}{5 \cdot \sqrt{k_2} + \sqrt{k_1}} \cdot s_1$$。
- ⇒ 代码实现
流动性凭证 UNI 的初始化供应量
V1 UNI 铸造量的公式:
- 流动性池已经存在时:$$s_{minted} = \frac{x_{deposited}}{x_{starting}} \cdot s_{starting}$$
- $$x_{starting}$$ 是本次存入之前 UNI 的总供应量。
- $$x_{starting}$$ 为 0 时,UNI 的铸造量(此时也是 UNI 的初始供应量)是交易对合约中 ETH 数量(wei)。
- 假设交易对合约中一开始的 ETH 是 0,且存入的两种资产比率能反映它们的市场价,此时 1 个流动性池的(初始)份额大约值 2 ETH。
对于 V1 中 $$x_{starting}$$ 为 0 的情况,是以“存入的两种资产比率能反映它们的市场价”这一假设为前提的,但这一假设实际上无法得到保证,而且 V2 不再支持 ETH,无法再沿用此公式。
V2 用存入代币的储量的几何平均数计算 UNI 铸造量:$$\sqrt{x_{deposited} \cdot y_{deposited}}$$
- The above formula ensures that a liquidity pool share will never be worth less than the geometric mean of the reserves in that pool.
针对流动性池份额的元交易
以太坊的原生货币是 ETH,发送交易需要支付的 gas 费也是 ETH,用户若只有 ERC20 token 但没有 ETH,则不能向以太坊发起任何交易。原交易就是一种解决办法。
使用元交易的一种场景是用户 a 不持有 ETH 但持有 ERC20 token ABC,用户 b 持有 ETH,用户 a 想转一定数量的 ABC 给用户 b:
- 用户 a 链下构造授权用户 b 转走一定数量 ABC 的签名,在链下将签名发给用户 b;
- 用户 b 通过调用元交易方法,支付 gas 费,就可以得到用户 a 授权给他的 ABC。
V2 中用户可以提交合法签名来授权流动性池份额(UNI token)的转账或者获得授权,用户调用 permit
方法提交 UNI token 所有者的合法签名和被授权者的地址,该地址就可以获得该笔 UNI token 的转账权限。
合约架构
V2 由 core 和 periphery 两个合约组成:
- core 合约仅包含和用户资产相关的核心逻辑,保持极简,降低引入 bug 的可能性;
- periphery 负责支撑用户的使用场景,底层是对 core 合约的调用。
- 用户在前端操作时的交互合约就是 periphery。
- The periphery contracts are stateless and don’t hold any assets. They can be updated as needed.
V2 core 代码分析
核心内部方法
_update
:
|
|
- 以 ERC20 token 常见的 18 位小数位来计算,每种 token 池中最多能持有的 token 数量是 $$\frac{2^{112}-1}{10^{18}} \approx 5.19*10^{15}$$ 个。
- Prior to Solidity 0.8.0, arithmetic operations would always wrap in case of under- or overflow, leading to widespread use of libraries that introduce additional checks. Since Solidity 0.8.0, all arithmetic operations revert on over- and underflow by default, thus making the use of these libraries unnecessary.
_mintFee
:
|
|
kLast
在每一次mint
或burn
的逻辑中被更新。- We know that between the time
kLast
was calculated and the present no liquidity was added or removed (because we run this calculation every time liquidity is added or removed, before it actually changes), so any change inreserve0 * reserve1
has to come from transaction fees (without them we’d keepreserve0 * reserve1
constant).- Transaction fee 是用户承担的总手续费,即 0.3%。
_safeTransfer
:
|
|
- There are two ways in which an ERC-20 transfer call can report failure:
- Revert. If a call to an external contract reverts, then the boolean return value is
false
. - End normally but report a failure. In that case the return value buffer has a non-zero length, and when decoded as a boolean value it is
false
.
- Revert. If a call to an external contract reverts, then the boolean return value is
lock
modifier:
|
|
提供流动性
|
|
In the time of the first deposit we don’t know the relative value of the two tokens, so we just multiply the amounts and take a square root, assuming that the deposit provides us with equal value in both tokens. We can trust this because it is in the depositor’s interest to provide equal value, to avoid losing value to arbitrage.
With every subsequent deposit we already know the exchange rate between the two assets, and we expect liquidity providers to provide equal value in both. If they don’t, we give them liquidity tokens based on the lesser value they provided as a punishment.
对首次铸币攻击的防范:
1 2 3 4
if (_totalSupply == 0) { liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY); _mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens }
- 若没有
.sub(MINIMUM_LIQUIDITY)
部分,则可以这样发动首次铸币攻击:- 第一次提供流动性时,攻击者存入少量 token,比如 1 wei ABC 和 1 wei XYZ,铸造得到 1 wei UNI,此时
totalSupply
、reserve0
和reserve1
均为 1 wei; - 攻击者在另一笔交易中直接向交易对合约转入大额 ABC 和 XYZ,比如各 2000 ether;
- 攻击者调用
sync
方法将缓存池的 token 数量更新成和当下余额相同,攻击结束;- 此时
totalSupply
为 1 wei,reserve0
和reserve1
均为 1 wei 加上 2000 ether。 - 这步之后攻击者的流动性份额仍是 1 wei,损人不利己。
- 此时
- 后续提供流动性的计算公式是
liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
,假设其它用户向提供 1 wei 的流动性,则需要提供的 ABC 和 XYZ 的数量各约为 2000 ether,小资金用户根本无法参与提供流动性。
- 第一次提供流动性时,攻击者存入少量 token,比如 1 wei ABC 和 1 wei XYZ,铸造得到 1 wei UNI,此时
.sub(MINIMUM_LIQUIDITY)
让首次铸币新增的流动性必须大于 $$10^3$$,否则SafeMath.sub
会回滚交易;同时为了避免攻击者通过burn
将流动性销毁,又将 $$10^3$$ 份流动性发到address(0)
永久锁定。- 结果是,攻击者再次执行第 1 步攻击时,需要初始存入的 ABC 和 XYZ 数量的乘积需要大于 $$10^6$$,假设是 $$1001^2$$,铸造得到 1001 个 UNI,攻击者只能得到其中 1 个;其它用户提供 1 wei 流动性需要的 ABC 和 XYZ 的数量各约为 2 ether。
- 若没有
移除流动性
|
|
- The periphery contract transferred the liquidity to be burned to this contract before the call. That way we know how much liquidity to burn, and we can make sure that it gets burned.
ERC20 token 互换
|
|
- The periphery contract sends the core contract the tokens before calling the core contract for the swap. This makes it easy for the contract to check that it is not being cheated, a check that has to happen in the core contract (because we can be called by other entities than our periphery contract).
sync
, skim
To protect against bespoke token implementations that can update the pair contract’s balance, and to more gracefully handle tokens whose total supply can be greater than $$2^{112}$$, Uniswap v2 has two bail-out functions: sync()
and skim()
.
- An account can transfer tokens to the exchange without calling either
mint
orswap
.
sync
:
|
|
- This functions as a recovery mechanism in the case that a token asynchronously deflates the balance of a pair. In this case, trades will receive sub-optimal rates, and if no liquidity provider is willing to rectify the situation, the pair is stuck.
sync
exists to set the reserves of the contract to the current balances.
skim
:
|
|
- This functions as a recovery mechanism in case enough tokens are sent to an pair to overflow the two
uint112
storage slots for reserves, which could otherwise cause trades to fail. - Any account is allowed to call
skim
because we don’t know who deposited the tokens.
创建交易对合约
V2 同样采用用工厂合约为每个 ERC20 token 部署一个独立的交易所合约(exchange contract),不同的是它用了 CREATE2
opcode,可以生成确定性的地址(不依赖 nonce),V1 用的是 CREATE
opcode。
|
|
- We want the address of the new exchange to be deterministic, so it can be calculated in advance off chain (this can be useful for layer 2 transactions).
流动性池份额的元交易
|
|
V2 periphery 代码分析
初始化
|
|
提供流动性
|
|
移除流动性
|
|
- 对于 Fee-on-transfer 的 token,转账过程要扣除一定数量的 token 作为手续费,转账时设置的 token 数量不准,实际收到的数量才准。
ERC20 token 互换
|
|
- 如果为每一对 token 都创建交易对合约,合约数目会过于巨大。实现上采用了兑换路径的概念,例如 A 兑换 D 可以通过 A 兑换 B、B 兑换 C、C 兑换 D 来完成。
swapTokensForTokens
, allows a trader to specify an exact number of input tokens he is willing to give and the minimum number of output tokens he is willing to receive in return.swapTokensForExactTokens
lets a trader specify the number of output tokens he wants, and the maximum number of input tokens he is willing to pay for them.
examples 合约
说明:
WETH
是 Wrapped ETH ERC20 token;WETHPartner
是另一种 ERC20 token;WETHExchangeV1
是 V1 版 WETHPartner/ETH 交易对合约;WETHPair
是 V2 版的 WETHPartner/WETH 交易对合约;
ExampleFlashSwap
|
|
Flash swap:
Add liquidity to V1 at a rate of 1 ETH / 200 X;
- X 是 WETHPartner;ETH 被包装成 WETH;
1 2 3 4 5 6 7
const WETHPartnerAmountV1 = expandTo18Decimals(2000) const ETHAmountV1 = expandTo18Decimals(10) await WETHPartner.approve(WETHExchangeV1.address, WETHPartnerAmountV1) await WETHExchangeV1.addLiquidity(bigNumberify(1), WETHPartnerAmountV1, MaxUint256, { ...overrides, value: ETHAmountV1 })
Add liquidity to V2 at a rate of 1 ETH / 100 X;
1 2 3 4 5 6 7 8 9 10
const WETHPartnerAmountV2 = expandTo18Decimals(1000) const ETHAmountV2 = expandTo18Decimals(10) // 给 msg.sender 预存了 10000 token // const WETHPartner = await deployContract(wallet, ERC20, [expandTo18Decimals(10000)]) await WETHPartner.transfer(WETHPair.address, WETHPartnerAmountV2) // waffle wallet 中默认有 eth // const ethBalanceBefore = await provider.getBalance(wallet.address) await WETH.deposit({ value: ETHAmountV2 }) await WETH.transfer(WETHPair.address, ETHAmountV2) await WETHPair.mint(wallet.address, overrides)
- 相较于 V2,V1 中的 ETH 溢价,闪电兑的方式是从 V2 中借出 ETH 到 V1 中兑换 X,再用兑换得到的 X 归还 V2,归还后还剩下的 X 就是套利收益。
Execute arbitrage via
uniswapV2Call
:1 2 3 4 5 6 7 8 9 10 11 12 13
const balanceBefore = await WETHPartner.balanceOf(wallet.address) // receive 1 ETH from V2, get as much X from V1 as we can, repay V2 with minimum X, keep the rest! const arbitrageAmount = expandTo18Decimals(1) const amount0 = WETHPairToken0 === WETHPartner.address ? bigNumberify(0) : arbitrageAmount const amount1 = WETHPairToken0 === WETHPartner.address ? arbitrageAmount : bigNumberify(0) await WETHPair.swap( amount0, amount1, flashSwapExample.address, defaultAbiCoder.encode(['uint'], [bigNumberify(1)]), overrides ) // if (4thArgument.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0, amount1, 4thArgument);