Overview
源码仓库:https://github.com/Uniswap/v2-periphery
学习路径:机制层 -> 源码层
uniswap V1 与 uniswapV2的区别:uniswapV1中交易对只允许一个ERC20与一个WETH组成,WETH是以太币的ERC20封装版本,允许以太币与其其他ERC20之间直接交易,从而在uniswapV1中实现以太币流动性,uniswapV2还引入了一些新特性:(闪电贷和流动性挖矿)。其中一个最主要的改进是uniswapV2支持创建任意两个ERC20代表之间的交易对,而不限于WETH
机制层
uniswap V2要解决什么问题
在uniswap之前,交易所主要是订单薄模式,对交易对一一定价,存在以下问题:
- 必须有人挂单
- 流动性依赖做市商
- 小币没人做市就无法交易
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 = 上次记录余额(交易前)
做了什么?
- 先把用户要拿走的币转出去
- 看用户转进来多少币
- 检查交易后池子的状态,不能让池子的乘积变小,也就是扣掉手续费之后,池子的k值不能下降
swap并不关心钱是怎么来的,闪电贷也可以,而只是关注前后的状态,也就是前后流动性k的变化,不能变得更糟了。pair合约并不是一个报价器,而是负责最后验账的
为什么要使用reverse变量?
源码里有两个变量:reverse0和reverse1,并不是实时余额,而是上一次交易结束时候的余额。
- 防止闪电贷攻击,如果使用balance获取价格容易遭到价格操纵攻击
periphery 周边合约
合约:
- migrator.sol:迁移合约
- router01.sol:路由合约(已废弃)
- router02.sol:包含01所有功能并且做了一个增强升级
流动性函数
用户在添加流动性时可能会面临以下问题:
- 流动性池是否已经存在?
通过判断reserve是否为0 - 如何确定流动性提供数量?
根据tokenA 用UniswaoV2Library.quote计算期望TokenB数量,给的tokenB必须要大于期望 - 最小代币数量要求?
1 | function _addLiquidity( |
这里的流动性和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 | modifier lock() { |
当开始执行swap时设置unlocked=0,结束swap时回复unlocked=1,防止重入攻击
1 | // 获得合约两种token的数量,前提是(balance > _reserve - amountOut),就是当前合约拥有的token数量应该是大于(储备值-输出到to地址的值),返回之间的差值 |
对于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 | if (_totalSupply == 0) { |
逻辑:
如果是第一次创建流动性池,例如:用户存入(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 | liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY; |
计算LP token,减去1000,把1000LP mint到address(0),永久锁定了1000 LP到address(0)。
减去MINUNUM_LIQUIDITY背后的攻击与防御机制:
一、攻击方法:
- solidity在做除法的时候,没有小数,只会向下取整,加入在一个没有最小值得池子里,一个攻击者作为第一个提供流动性的人,存入极小量(1 wei)得Token A和tokenB,根据初始公式,会获得 根号(1 * 1) 也就是1LP Token
- (关键)攻击者绕过mint函数,直接通过transfer转账,向池子转入10000 TokenA和10000 TokenB。此时LP Token总量仍然是1 wei,但是这唯一的一份LP Token,价值达到了10000,因为1 LP Token占据100份额
- 此时受害者进场,存入19000 TokenA和19000 TokenB,根据公式:

代入数字:$Liquidity = \frac{19,000 \times 1}{10,000} = 1.9\text{ wei}$。因为 Solidity 向下取整,$1.9\text{ wei}$ 变成了1 wei。 - 受害者存了19000,却只拿到了1 wei的LP Token,此时攻击者销毁1 wei LP Token离场,将会获取50%份额的币,29000 / 2 = 14500枚!净赚4500
二、防御方法:
- uniswap中添加了一个初始值1000,如果攻击者存入1wei,就会导致1-1000导致下溢出,使得交易直接失败,使得初始LP大于1000
- 假设攻击者老师存入了代币,获取1001个LP Token,UNiswap会把最初的1000个LP发给address(0),即销毁,攻击者手里就只有1 LP。现在总supply变成了1001,如果攻击者想要同样抬高1LP的价值,他就必须捐赠10000 * 1000 = 10,000,000枚代币,才能制造出同样的抹零
- 由于1000份LP锁在了0地址,为了抬高物价而卷进去的1000/1001相当于打水漂了
三、核心总结:
- 没有底座时:分母可以极小(1wei),攻击者可以用极低成本控制LP价值
- 有底座时:分母被提高了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自动转换)
容易产生的攻击:
- Fee Token绕过检查
余额变化不等于transfer数量,容易造成accounting错误/滑点判断失效 - Permit 签名攻击
签名重放(nonce没处理好)、钓鱼签名