Uniswap V2

Overview

源码仓库:https://github.com/Uniswap/v2-periphery

学习路径:机制层 -> 源码层

uniswap V1 与 uniswapV2的区别:uniswapV1中交易对只允许一个ERC20与一个WETH组成,WETH是以太币的ERC20封装版本,允许以太币与其其他ERC20之间直接交易,从而在uniswapV1中实现以太币流动性,uniswapV2还引入了一些新特性:(闪电贷和流动性挖矿)。其中一个最主要的改进是uniswapV2支持创建任意两个ERC20代表之间的交易对,而不限于WETH

机制层

uniswap V2要解决什么问题

在uniswap之前,交易所主要是订单薄模式,对交易对一一定价,存在以下问题:

  1. 必须有人挂单
  2. 流动性依赖做市商
  3. 小币没人做市就无法交易

Uniswap V2的核心是AMM(自动做市商): x * y = k

交易如何产生?

假设用户:

用 2000 USDT 买 ETH

用户把 USDT 加进池子:

ETH = ?
USDT = 22000

为了保持:

x * y = 200000

新的 ETH:

ETH = 200000 / 22000
ETH = 9.09

所以:

原来 ETH = 10
现在 ETH = 9.09

用户得到:

0.91 ETH

uniswapV2 swap中核心逻辑

balance0 * balance1 >= reserve0 * reservr1
balance = 当前池子余额(交易后)
reserve = 上次记录余额(交易前)

做了什么?

  1. 先把用户要拿走的币转出去
  2. 看用户转进来多少币
  3. 检查交易后池子的状态,不能让池子的乘积变小,也就是扣掉手续费之后,池子的k值不能下降

swap并不关心钱是怎么来的,闪电贷也可以,而只是关注前后的状态,也就是前后流动性k的变化,不能变得更糟了。pair合约并不是一个报价器,而是负责最后验账的

为什么要使用reverse变量?

源码里有两个变量:reverse0和reverse1,并不是实时余额,而是上一次交易结束时候的余额。

  • 防止闪电贷攻击,如果使用balance获取价格容易遭到价格操纵攻击

periphery 周边合约

合约:

  • migrator.sol:迁移合约
  • router01.sol:路由合约(已废弃)
  • router02.sol:包含01所有功能并且做了一个增强升级

流动性函数

用户在添加流动性时可能会面临以下问题:

  1. 流动性池是否已经存在?
    通过判断reserve是否为0
  2. 如何确定流动性提供数量?
    根据tokenA 用UniswaoV2Library.quote计算期望TokenB数量,给的tokenB必须要大于期望
  3. 最小代币数量要求?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
function _addLiquidity(

        address tokenA,

        address tokenB,

        uint amountADesired,

        uint amountBDesired,

        uint amountAMin,

        uint amountBMin

    ) internal virtual returns (uint amountA, uint amountB) {

        // 拿到lpToken的地址,若不存在则创建一个交易对

        if (IUniswapV2Factory(factory).getPair(tokenA, tokenB) == address(0)) {

            IUniswapV2Factory(factory).createPair(tokenA, tokenB);

        }

        // 获取两种token的储备量

        (uint reserveA, uint reserveB) = UniswapV2Library.getReserves(factory, tokenA, tokenB);

        if (reserveA == 0 && reserveB == 0) {

            // 首次添加流动性

            (amountA, amountB) = (amountADesired, amountBDesired);

        } else {

            // 根据两种token的储备量和期望tokenA的数额获取tokenB最佳的数额

            uint amountBOptimal = UniswapV2Library.quote(amountADesired, reserveA, reserveB);

            // 如果amountBOptimal不大于amountBDesired并且amountBOptimal不小于amountBMin,则返回amountADesired, amountBOptimal

            if (amountBOptimal <= amountBDesired) {

                require(amountBOptimal >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');

                (amountA, amountB) = (amountADesired, amountBOptimal);

            } else {

                // 如果amountBOptimal大于amountBDesired,则根据两种token的储备量和期望tokenB的数额获取tokenA最佳的数额

                uint amountAOptimal = UniswapV2Library.quote(amountBDesired, reserveB, reserveA);

                // 断言

                assert(amountAOptimal <= amountADesired);

                // 并且amountAOptimal不小于amountAMin,则返回amountAOptimal, amountBDesired

                require(amountAOptimal >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');

                (amountA, amountB) = (amountAOptimal, amountBDesired);

            }

        }

    }

这里的流动性和Pair里面的流动性一样吗?

这是两个完全不同的概念,这里的流动性(amount0、amount1)是实际投入池子的资产数量,由第一个人主观决定,用于初始化池子的储备金,并确定池子的初始价格,而之前的流动性(LP Token)是一个数值,表示在池子中所占的份额比例,由合约算法计算得出,用于计算分成手续费以及提款等

为什么这里可以任意选择流动性?

因为池子是空的,没有之前的流动性作为参考,所以合约信任用户输入,如果用户故意以错误价格(市场价1000U,输入1U)会怎么样?会被套利机器人瞬间买入便宜的ETH,直到价格回归市场价,添加流动性的人会瞬间遭受损失(无常损失的极端情况)。但是后续添加的时候就需要强制按照amountA/amountB = reserveA/reserveB,保证不改变池子价格

源码层

UniswapV2Pair.sol

swap函数

1
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock

功能作用:完成两种token的交换,并在交换后检查池子余额校验是否满足恒定乘积公式
swap = 从池子取 token → 用户输入 token → 合约验证余额是否满足 AMM 规则

参数分析:

  • amount0Out:表示要从池子里取走多少token0
  • amount1Out:表示要从池子里取走多少token1
    • 为什么有两个out?因为uniswap是双资产池子,通常只拿一个币,但是你也可以拿两个币
  • to:接收token的地址(为什么需要to地址:因为swap可用于多跳交易,第一次输出的token发送到下一跳池子,to不一定是用户地址,也可能是下一条pair/Router/其他合约)
  • data:用于支持闪电交换,如果data.length > 0,Pair会调用IUniswapV2Callee(to).uniswapV2Call(…),也就是回调to合约

lock modifer:

1
2
3
4
5
6
7
8
9
10
11
modifier lock() {

        require(unlocked == 1, 'UniswapV2: LOCKED');

        unlocked = 0;

        _;

        unlocked = 1;

    }

当开始执行swap时设置unlocked=0,结束swap时回复unlocked=1,防止重入攻击

1
2
3
4
5
        // 获得合约两种token的数量,前提是(balance > _reserve - amountOut),就是当前合约拥有的token数量应该是大于(储备值-输出到to地址的值),返回之间的差值

        uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;

        uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;

对于swap函数中的这个部分:

由于swap函数没有amountIn函数,所以需要自己根据余额变化推算出来,如果没有转入token,reverse - amountOut 就是剩余值,如果转入了x token,那么,当前的balance就会大于reverse - amountOut,所以用balance - (reverse - amountOut) 就可以推算出amountIn。

合约不知道(不使用)用户转入余额多少,只是根据余额变化推算,执行流程:先把tokenOut转给to,用户转入tokenIn,合约检查余额变化

mint函数

1
function mint(address to) external lock returns (uint liquidity)

功能作用:当用户向池子提供流动性之后,根据新增资产比例铸造LPtoken,表示用户在流动性池中所占份额

参数分析:

  • 输入
    • address to:接收lptoken的地址
  • 输出
    • uint liquidity:

流动性池是什么?

流动性池(Liquidity Pool)是DeFi/AMM(自动做市商)中的核心概念,简单来说就是一个智能合约立存放两种或多种token的资金池,用于给用户进行兑换交易

在mint()函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
        if (_totalSupply == 0) {

            liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);

            // 永久锁定MINIMUM_LIQUIDITY

            _mint(address(0), MINIMUM_LIQUIDITY);

        } else {

            // 添加流动性所获得的lptoken数量(进行添加流动性的两种token的数量*目前lptoken的数量/当前token的储备量-->取较小值)

            liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);

        }

逻辑:
如果是第一次创建流动性池,例如:用户存入(10 ETH 、 20000 USDC),那么LP token是sqrt(10 * 20000),这样可以保证LP token与池子规模相关。如果是第二次之后添加流动性,例如当池子(100 ETH、200000 USDC、 LP = 1000),用户添加(10 ETH、 20000 USDC),那么LP=100

为什么第一次LP数量为sqrt(amount0 * amount1)?
因为LP token要和池子的流动性规模成比例,而流动性规模与x * y相关。Uniswap的价格模型为x * y = k。

  • amount0:用户新存入的token0
  • amount1:用户新存入的token1
  • reserve0:当前池子的token0储备
  • reserve1:当前池子的 token1储备
  • totalSupply:当前LP token总量
  • liquidity:需要铸造的LP token

为什么 Uniswap 要永久锁定 MINIMUM_LIQUIDITY = 1000 LP token?

首先第一次创建池子的时候,

1
2
liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY;
_mint(address(0), MINIMUM_LIQUIDITY);

计算LP token,减去1000,把1000LP mint到address(0),永久锁定了1000 LP到address(0)。

减去MINUNUM_LIQUIDITY背后的攻击与防御机制:

  • 一、攻击方法:

    1. solidity在做除法的时候,没有小数,只会向下取整,加入在一个没有最小值得池子里,一个攻击者作为第一个提供流动性的人,存入极小量(1 wei)得Token A和tokenB,根据初始公式,会获得 根号(1 * 1) 也就是1LP Token
    2. (关键)攻击者绕过mint函数,直接通过transfer转账,向池子转入10000 TokenA和10000 TokenB。此时LP Token总量仍然是1 wei,但是这唯一的一份LP Token,价值达到了10000,因为1 LP Token占据100份额
    3. 此时受害者进场,存入19000 TokenA和19000 TokenB,根据公式:
      image.png
      代入数字:$Liquidity = \frac{19,000 \times 1}{10,000} = 1.9\text{ wei}$。因为 Solidity 向下取整,$1.9\text{ wei}$ 变成了 1 wei
    4. 受害者存了19000,却只拿到了1 wei的LP Token,此时攻击者销毁1 wei LP Token离场,将会获取50%份额的币,29000 / 2 = 14500枚!净赚4500
  • 二、防御方法:

    1. uniswap中添加了一个初始值1000,如果攻击者存入1wei,就会导致1-1000导致下溢出,使得交易直接失败,使得初始LP大于1000
    2. 假设攻击者老师存入了代币,获取1001个LP Token,UNiswap会把最初的1000个LP发给address(0),即销毁,攻击者手里就只有1 LP。现在总supply变成了1001,如果攻击者想要同样抬高1LP的价值,他就必须捐赠10000 * 1000 = 10,000,000枚代币,才能制造出同样的抹零
    3. 由于1000份LP锁在了0地址,为了抬高物价而卷进去的1000/1001相当于打水漂了
  • 三、核心总结:

    1. 没有底座时:分母可以极小(1wei),攻击者可以用极低成本控制LP价值
    2. 有底座时:分母被提高了1000倍,攻击者想要操控及必须要付出巨大的资金成本,并且会永远所在0地址

对于uniswap V2 core部分的理解:

uniswap V2的core合约里面,factory是主合约,负责生产交易对,需要创建交易对则调用pair合约创建交易对,erc20.sol负责定义uniswap LP Token

Factory(工厂合约/主):负责创建交易对,记录pair地址,保证一个token队一个pair,不做交易也不管资金,只是登记加创建

Pair(交易对合约/核心):负责维护两个token的储备,实现AMM逻辑,执行swap/mint/burn等操作,负责管钱,也是core的主要逻辑所在

ERC20(LP Token):负责实现ERC20标准,主要是为了让Pair合约调用发币,是pair里面提供流动性的权益证明

UniswapV2Router02.sol

addLiquidity函数

在uniswapV2中2,添加流动性的本质不是随便存两种币,而是按当前池子的价格比例存入,否则就会改变价格导致被套利。
quote函数的本质是: amountBOptimal = amountADesired * reserveB / reserveA
所以Router的策略是“不改变池子价格,而是调整你的输入”

因为UniswapV2中池子里两种代币的价格由储备量决定:PriceA​=ReserveB/ReserveA

removeLiquidityETHSupportingFeeOnTransferTokens类函数

可以拆分成四部分来看:

  • removeLiquidityETH:从流动性池中移除流动性,换回一部分ETH和Token,将LPToken换回来
  • WithPermit:使用permit签名授权(EIP-2612),正常流程是先approve后remove,这里直接使用(off-chain签名)授权,一笔交易完成
  • SupportingFeeOnTransferTokens:支持转账扣手续费的Token,某些币转账会burn 2%(# 双燃烧的机制的燃烧币)或者会抽税,普通的removeLiquidity会失败,因为实际到账数量小于预期。这个版本不会严格依赖transfer前后的精确数量
  • ETH:说明其中一个资产是ETH(实际是WETH和ETH自动转换)

容易产生的攻击:

  1. Fee Token绕过检查
    余额变化不等于transfer数量,容易造成accounting错误/滑点判断失效
  2. Permit 签名攻击
    签名重放(nonce没处理好)、钓鱼签名