Uniswap V2 源码学习第五篇:burn,移除流动性时资产是怎么返还的?
移除流动性时,销毁LP Token并返还资产
上一篇我们讲了 mint()。
添加流动性的核心思路是:
用户先把 token0/token1 转进 Pair
Pair 用 balance - reserve 算新增资产
再根据新增资产占池子的比例 mint LP Token
这一篇看反向操作:
function burn(address to)
external
lock
returns (uint amount0, uint amount1)
它对应的用户行为是:
移除流动性 remove liquidity
从产品视角看,用户做的是:
我把 LP Token 还给池子
池子按我的份额返还 token0 和 token1
从源码视角看,Pair 做的是:
我检查自己现在持有多少 LP Token
根据这部分 LP Token 占 totalSupply 的比例
把对应比例的 token0/token1 转给用户
然后 burn 掉这些 LP Token
更新 reserve
这一篇我们专门拆 burn()。
1. 先看完整的 burn 源码
Uniswap V2 Pair 里的 burn() 大概是这样:
function burn(address to)
external
lock
returns (uint amount0, uint amount1)
{
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
address _token0 = token0;
address _token1 = token1;
uint balance0 = IERC20(_token0).balanceOf(address(this));
uint balance1 = IERC20(_token1).balanceOf(address(this));
uint liquidity = balanceOf[address(this)];
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply;
amount0 = liquidity.mul(balance0) / _totalSupply;
amount1 = liquidity.mul(balance1) / _totalSupply;
require(
amount0 > 0 && amount1 > 0,
'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED'
);
_burn(address(this), liquidity);
_safeTransfer(_token0, to, amount0);
_safeTransfer(_token1, to, amount1);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1);
emit Burn(msg.sender, amount0, amount1, to);
}
它可以拆成九步:
1. 读取旧 reserve
2. 读取 token0/token1 地址
3. 读取当前 token balance
4. 读取 Pair 当前持有的 LP Token 数量
5. 处理协议手续费 _mintFee
6. 根据 LP 份额计算应返还的 token0/token1
7. burn 掉 LP Token
8. 转出 token0/token1 给用户
9. 重新读取余额,更新 reserve 和 kLast
主线其实很简单:
Pair 收到多少 LP Token,就按这部分 LP Token 占总供应量的比例退还底层资产。
2. burn 也不是主动拉 LP Token
先抓最重要的一点。
burn() 的函数签名是:
function burn(address to)
external
lock
returns (uint amount0, uint amount1)
它没有参数:
uint liquidity
也没有主动调用:
transferFrom(user, address(this), liquidity)
也就是说,Pair 的 burn() 不负责从用户钱包里拉 LP Token。
正常流程是 Router 做的:
用户调用 Router.removeLiquidity()
↓
Router 把用户的 LP Token transferFrom 到 Pair
↓
Router 调用 Pair.burn(to)
↓
Pair 检查自己当前持有多少 LP Token
↓
Pair 按比例返还 token0/token1
这和 mint() 很像。
mint() 不接收 amount0/amount1,而是看:
token balance - reserve
burn() 不接收 liquidity 参数,而是看:
uint liquidity = balanceOf[address(this)];
也就是:
Pair 自己现在持有多少自己的 LP Token。
这又是一个 Uniswap V2 风格特别浓的设计:
不要相信调用者说自己交了多少 LP
直接看 Pair 自己收到多少 LP
3. 读取旧 reserve
函数开头:
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
和 mint() 一样,先读取旧 reserve。
这些旧 reserve 后面会传给 _update(),用于:
1. 计算价格累计
2. 更新 reserve
3. 更新 blockTimestampLast
注意,burn() 真正计算返还金额时,使用的不是 _reserve0/_reserve1,而是 balance0/balance1。
这是个重点,我们马上讲。
4. 缓存 token0/token1:省 gas,也避免 stack too deep
接下来:
address _token0 = token0;
address _token1 = token1;
看起来像废话,但不是。
这类写法在 Solidity 里很常见,目的通常有两个:
1. 减少多次 SLOAD,省 gas
2. 缓解 stack too deep
token0 和 token1 是 storage 变量。
把它们读到 memory/local variable 里,后面多次使用就更便宜。
源码注释里也写了:
// gas savings
address _token0 = token0;
address _token1 = token1;
5. 读取当前 token balance
接下来读取 Pair 当前真实持仓:
uint balance0 = IERC20(_token0).balanceOf(address(this));
uint balance1 = IERC20(_token1).balanceOf(address(this));
这两个是当前真实余额。
例如:
DAI.balanceOf(pair)
USDC.balanceOf(pair)
这里不是 reserve,而是 balance。
为什么?
因为 Pair 返还用户的资产,应该基于当前实际拥有的 token 数量。
尤其考虑到:
1. 有人可能直接给 Pair 转了 token
2. 交易手续费会留在池子资产里
3. 协议手续费逻辑可能会影响 LP 总供应量
4. reserve 和 balance 在某些时刻可能不同步
所以 burn 先看真实余额。
6. liquidity = Pair 自己持有的 LP Token
关键来了:
uint liquidity = balanceOf[address(this)];
这个 balanceOf 不是 token0/token1 的余额。
它是 Pair 自己作为 LP Token 合约时的 balanceOf。
也就是说:
balanceOf[address(this)]
= Pair 合约地址持有多少 Pair LP Token
如果这是 DAI/USDC Pair,那这句的意思是:
DAI-USDC Pair 合约自己持有多少 DAI-USDC LP Token
听起来有点绕,但其实就是:
用户想退出流动性,需要先把 LP Token 转给 Pair。Pair 再 burn 掉自己收到的 LP Token。
所以标准 remove liquidity 流程是:
用户持有 LP Token
↓
用户 approve Router
↓
Router transferFrom(user, pair, liquidity)
↓
Pair.balanceOf[pair] 增加 liquidity
↓
Router 调用 Pair.burn(to)
↓
Pair burn 掉这些 LP Token
↓
Pair 返还 token0/token1 给 to
Pair 不问调用者“你要 burn 多少”。
它只看自己收到多少。
7. 先 _mintFee,再读 totalSupply
和 mint() 一样,burn() 里也有:
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply;
顺序依然是故意的。
_mintFee() 可能会给 feeTo 铸造协议手续费 LP Token,从而增加 totalSupply。
而用户能取回多少资产,取决于:
liquidity / totalSupply
所以必须在 _mintFee() 之后读取最新的 totalSupply。
这意味着如果协议手续费开启,协议方会先被 mint 一部分 LP Token,用户这次 burn 的份额会基于稀释后的总供应量计算。
这就是 protocol fee 的实现方式:
不是直接从池子里转 token 给协议,而是 mint LP Token 给协议,稀释现有 LP 的份额。
这块后面讲 _mintFee() 时会完整展开。
8. 核心公式:按 LP 份额返还资产
burn() 最核心的两行:
amount0 = liquidity.mul(balance0) / _totalSupply;
amount1 = liquidity.mul(balance1) / _totalSupply;
这就是按份额返还资产。
公式很直观:
用户交回的 LP 数量 / LP 总供应量
= 用户拥有的池子份额
返还 token0 = 池子 token0 当前余额 * 用户份额
返还 token1 = 池子 token1 当前余额 * 用户份额
如果池子当前有:
balance0 = 10,000 DAI
balance1 = 10,000 USDC
totalSupply = 10,000 LP
用户交回:
liquidity = 1,000 LP
那么:
amount0 = 1,000 * 10,000 / 10,000 = 1,000 DAI
amount1 = 1,000 * 10,000 / 10,000 = 1,000 USDC
用户拿回 10% 的池子资产。
这就是 LP Token 的含义。
9. burn 为什么用 balance,而不是 reserve?
这是 burn() 里最值得注意的问题。
源码注释里写得很直接:
// using balances ensures pro-rata distribution
也就是:
使用 balance 可以确保按比例分配。
那为什么不用 reserve?
假设当前:
reserve0 = 1000
reserve1 = 1000
但有人直接给 Pair 转了:
100 token0
还没有调用 sync()。
于是:
balance0 = 1100
balance1 = 1000
reserve0 = 1000
reserve1 = 1000
如果 burn 用 reserve 算,那么退出的 LP 只能按 1000/1000 分资产。
那这额外的 100 token0 怎么办?
它会被留在池子里,导致退出者拿不到自己按比例应得的当前真实资产。
而如果用 balance 算,所有 LP 都按当前真实余额比例分享这笔额外 token。
这才符合 LP Token 的含义:
LP Token 代表的是 Pair 当前真实资产的份额
而不是旧 reserve 记录的份额
所以 burn 用 balance。
这点非常漂亮。
10. 和 mint 对比一下
这里可以顺手和 mint() 对比。
mint() 里:
amount0 = balance0 - reserve0;
amount1 = balance1 - reserve1;
它关心的是:
这次新增了多少资产?
burn() 里:
amount0 = liquidity * balance0 / totalSupply;
amount1 = liquidity * balance1 / totalSupply;
它关心的是:
这部分 LP Token 占当前真实池子资产的多少?
所以:
mint:看增量
burn:看份额
这个对比很好记。
11. amount0 和 amount1 必须大于 0
算完后检查:
require(
amount0 > 0 && amount1 > 0,
'UniswapV2: INSUFFICIENT_LIQUIDITY_BURNED'
);
这防止 burn 数量太小,导致整数除法后得到 0。
比如:
liquidity 很小
balance 很小或 totalSupply 很大
最后可能:
amount0 = 0
amount1 = 0
这种情况下不能继续。
否则用户的 LP Token 被 burn 了,却拿不到有效资产。
注意这里要求两个都大于 0。
如果其中一个算出来为 0,也会 revert。
12. burn 掉 LP Token
检查通过后:
_burn(address(this), liquidity);
这会销毁 Pair 自己持有的 LP Token。
也就是前面用户或 Router 转进来的那些 LP Token。
_burn() 来自 UniswapV2ERC20,大概会做:
1. balanceOf[address(this)] -= liquidity
2. totalSupply -= liquidity
3. emit Transfer(address(this), address(0), liquidity)
注意,burn 的地址是:
address(this)
不是用户地址。
因为用户的 LP Token 在调用 burn() 之前已经转给 Pair 了。
这和很多 ERC20 burn 函数不一样。
通常用户直接 burnFrom(user)。
但 Uniswap V2 的流程是:
先 transfer LP 到 Pair
再 Pair burn 自己持有的 LP
依然是“先转账,再根据余额执行”的风格。
13. 转出 token0/token1 给用户
销毁 LP 后:
_safeTransfer(_token0, to, amount0);
_safeTransfer(_token1, to, amount1);
Pair 把底层资产转给 to。
同样,to 不一定是 msg.sender。
如果用户通过 Router 调用,通常是:
msg.sender = Router
to = 用户地址
如果某个合约代用户操作,也可以把资产发送到指定地址。
这里用的是 _safeTransfer(),它兼容那些不标准返回 bool 的 ERC20。
14. 为什么 burn 先 _burn LP,再转 token?
源码顺序是:
_burn(address(this), liquidity);
_safeTransfer(_token0, to, amount0);
_safeTransfer(_token1, to, amount1);
为什么不是先转 token,再 burn LP?
安全直觉上,先销毁凭证更稳。
一旦底层 token 转出,用户不应该还持有可赎回同一份资产的 LP Token。
当然,整个函数有 lock 防重入保护。
但从状态一致性的角度看:
先销毁份额凭证
再支付底层资产
是更自然的顺序。
15. 转账后重新读取 balance
转出 token 后,源码又读了一次余额:
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
为什么不直接:
balance0 = balance0 - amount0;
balance1 = balance1 - amount1;
因为真实世界的 token 可能不标准。
尤其是一些 fee-on-transfer token,转账时可能扣手续费,实际减少数量和你以为的不一样。
更稳的方式是:
转账后重新查真实余额
然后用真实余额更新 reserve。
这也是 Pair 的一贯风格:
不猜,不信参数,直接看余额。
16. _update 更新 reserve
接下来:
_update(balance0, balance1, _reserve0, _reserve1);
这会把 Pair 的 reserve 更新为转账后的真实余额。
比如移除流动性前:
reserve0 = 10,000
reserve1 = 10,000
balance0 = 10,000
balance1 = 10,000
用户交回 1,000 LP,拿走:
1,000 token0
1,000 token1
转账后余额变成:
balance0 = 9,000
balance1 = 9,000
_update() 后:
reserve0 = 9,000
reserve1 = 9,000
状态同步完成。
17. 如果 feeOn,更新 kLast
最后:
if (feeOn) kLast = uint(reserve0).mul(reserve1);
和 mint() 一样。
如果协议手续费开启,则把最新的:
reserve0 * reserve1
记录到 kLast。
这个值会作为下次 _mintFee() 判断 k 增长的基准。
如果协议手续费关闭,_mintFee() 内部会负责把 kLast 清零。
18. 触发 Burn 事件
最后:
emit Burn(msg.sender, amount0, amount1, to);
事件记录:
msg.sender:谁调用了 burn
amount0:返还多少 token0
amount1:返还多少 token1
to:资产接收者
如果用户通过 Router 移除流动性:
msg.sender = Router
to = 用户地址
所以链下看事件时要注意,事件里的 sender 不一定是最终用户。
19. 用完整例子走一遍 burn
假设当前池子:
reserve0 = 10,000 DAI
reserve1 = 10,000 USDC
balance0 = 10,000 DAI
balance1 = 10,000 USDC
totalSupply = 10,000 LP
Alice 持有:
1,000 LP
她想移除全部流动性。
Router 先把 Alice 的 LP Token 转到 Pair:
Pair.balanceOf[pair] = 1,000 LP
然后调用:
pair.burn(alice);
Pair 读取:
liquidity = balanceOf[address(this)] = 1,000
balance0 = 10,000
balance1 = 10,000
totalSupply = 10,000
计算:
amount0 = 1,000 * 10,000 / 10,000 = 1,000 DAI
amount1 = 1,000 * 10,000 / 10,000 = 1,000 USDC
然后:
burn 1,000 LP
转 1,000 DAI 给 Alice
转 1,000 USDC 给 Alice
更新 reserve 为 9,000 / 9,000
totalSupply 变成 9,000 LP
这就是标准移除流动性。
20. 加上手续费收益的例子
现在看一个更真实的例子。
一开始池子是:
reserve0 = 10,000 DAI
reserve1 = 10,000 USDC
totalSupply = 10,000 LP
Alice 持有:
1,000 LP
占 10%。
后来很多用户交易,手续费都留在池子里。
为了简化理解,假设池子当前真实余额变成:
balance0 = 10,500 DAI
balance1 = 10,500 USDC
totalSupply = 10,000 LP
Alice 交回 1,000 LP。
计算:
amount0 = 1,000 * 10,500 / 10,000 = 1,050 DAI
amount1 = 1,000 * 10,500 / 10,000 = 1,050 USDC
Alice 拿回的不只是当初那 1,000 / 1,000。
她还拿到了属于自己份额的手续费收益。
这就是 LP 收益的基本来源。
Uniswap V2 不需要单独记录:
Alice 赚了多少手续费
Bob 赚了多少手续费
它只让手续费留在池子里。
退出时,LP 按份额拿当前池子资产。
简单粗暴,但优雅。
21. 如果有人直接给 Pair 捐 token,会发生什么?
假设池子是:
reserve0 = 10,000
reserve1 = 10,000
balance0 = 10,000
balance1 = 10,000
totalSupply = 10,000 LP
有人直接给 Pair 转了:
1,000 token0
但没有调用 sync()。
状态变成:
reserve0 = 10,000
reserve1 = 10,000
balance0 = 11,000
balance1 = 10,000
现在 Alice burn 10% LP:
liquidity = 1,000
因为 burn 使用 balance,所以她拿:
amount0 = 1,000 * 11,000 / 10,000 = 1,100 token0
amount1 = 1,000 * 10,000 / 10,000 = 1,000 token1
也就是她按份额分享了别人捐进来的 token0。
这符合 LP Token 的语义:
谁持有 LP,谁拥有池子当前资产的一部分
不是谁更新了 reserve,谁才拥有。
这也是为什么源码注释说:
using balances ensures pro-rata distribution
22. burn 和 removeLiquidity 的 Router 流程
用户正常不会直接调用 Pair.burn。
Router 里的 removeLiquidity() 大概流程是:
function removeLiquidity(
address tokenA,
address tokenB,
uint liquidity,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) public returns (uint amountA, uint amountB) {
address pair = UniswapV2Library.pairFor(factory, tokenA, tokenB);
IUniswapV2Pair(pair).transferFrom(
msg.sender,
pair,
liquidity
);
(uint amount0, uint amount1) = IUniswapV2Pair(pair).burn(to);
(address token0,) = UniswapV2Library.sortTokens(tokenA, tokenB);
(amountA, amountB) = tokenA == token0
? (amount0, amount1)
: (amount1, amount0);
require(amountA >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');
require(amountB >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');
}
Router 做了几件 Pair 不做的事:
1. 根据 tokenA/tokenB 算 Pair 地址
2. 把用户 LP Token 转到 Pair
3. 调用 Pair.burn(to)
4. 根据 tokenA/tokenB 顺序调整 amountA/amountB
5. 检查滑点保护 amountAMin/amountBMin
Pair 只负责:
收到 LP,返还底层资产
用户体验、参数校验、顺序转换、滑点保护,都是 Router 做的。
23. 为什么 burn 里没有滑点保护?
Pair.burn 只接受一个参数:
address to
没有:
amount0Min
amount1Min
deadline
这意味着 Pair 不负责用户层面的交易保护。
滑点保护在 Router 里做:
require(amountA >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');
require(amountB >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');
为什么这样分层?
因为 Pair 是核心规则层,不关心用户意图。
Pair 只保证:
给我多少 LP
我按当前规则返还多少资产
至于用户是否接受这个结果,是 Router 负责的。
这就是 core/periphery 分离:
core:协议规则
periphery:用户保护和便利功能
24. burn 的设计哲学
第一,LP Token 是池子资产的份额凭证
核心公式:
amount0 = liquidity.mul(balance0) / _totalSupply;
amount1 = liquidity.mul(balance1) / _totalSupply;
这句话定义了 LP Token 的意义。
第二,不相信参数,相信余额
它不接收 liquidity 参数,而是看:
balanceOf[address(this)]
也就是 Pair 实际收到多少 LP。
第三,用 balance 而不是 reserve
这样可以确保 LP 按当前真实资产比例退出。
第四,手续费收益不需要单独记账
手续费留在池子里,burn 时自然按份额分配。
第五,Pair 不负责用户体验
滑点、deadline、token 顺序、ETH/WETH,都由 Router 处理。
25. burn 完整流程再压缩一遍
最后压缩成一版:
1. 用户/Router 先把 LP Token 转到 Pair
2. Pair 读取旧 reserve
3. Pair 读取当前 token0/token1 balance
4. Pair 读取自己持有的 LP Token 数量 liquidity
5. 如果协议手续费开启,先 _mintFee
6. 读取最新 totalSupply
7. 按 liquidity / totalSupply 计算 amount0/amount1
8. 检查 amount0/amount1 都大于 0
9. burn 掉 Pair 自己持有的 LP Token
10. 转出 token0/token1 给 to
11. 重新读取真实 balance
12. 更新 reserve
13. 如果 feeOn,更新 kLast
14. emit Burn
一句话版本:
你交回多少 LP,Pair 就按这部分 LP 占总供应量的比例,把当前真实池子资产还给你。
小结
这一篇我们拆了 Pair.burn()。
现在你应该能回答:
1. burn 为什么没有 liquidity 参数?
因为 Pair 直接看自己收到多少 LP Token。
2. 用户的 LP Token 是什么时候转给 Pair 的?
通常由 Router.removeLiquidity 先 transferFrom 到 Pair。
3. amount0/amount1 怎么算?
liquidity * balance / totalSupply。
4. burn 为什么用 balance 而不是 reserve?
因为 LP Token 代表当前真实池子资产份额。
5. 为什么先 _mintFee 再读 totalSupply?
因为协议手续费可能 mint 新 LP,改变总供应量。
6. 手续费收益怎么返还给 LP?
手续费留在池子里,burn 时按 LP 份额自然分配。
7. burn 为什么没有滑点保护?
Pair 只管协议规则,滑点保护由 Router 处理。