Uniswap V2 源码学习第五篇:burn,移除流动性时资产是怎么返还的?

tomenengr 发布于 2026-05-11 阅读 194

移除流动性时,销毁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

token0token1 是 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 处理。

相关文章

0 条评论