Uniswap V2 源码学习第四篇:mint,添加流动性时 LP Token 是怎么算出来的?
mint时LP Token的计算方式
前一篇我们讲了 UniswapV2Pair 的基础结构。
现在我们进入 Pair 的第一个核心函数:
function mint(address to) external lock returns (uint liquidity)
它对应的用户行为是:
添加流动性 add liquidity
从产品视角看,用户做的是:
我往池子里存入 token0 和 token1
然后获得 LP Token
从源码视角看,Pair 做的是:
我检查自己当前余额比 reserve 多了多少
根据新增的 token 数量计算应该 mint 多少 LP Token
更新 reserve
这一篇我们就专门拆 mint()。
1. 先看完整的 mint 源码
Uniswap V2 Pair 里的 mint() 大概长这样:
function mint(address to) external lock returns (uint liquidity) {
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
uint amount0 = balance0.sub(_reserve0);
uint amount1 = balance1.sub(_reserve1);
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply;
if (_totalSupply == 0) {
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
_mint(address(0), MINIMUM_LIQUIDITY);
} else {
liquidity = Math.min(
amount0.mul(_totalSupply) / _reserve0,
amount1.mul(_totalSupply) / _reserve1
);
}
require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
_mint(to, liquidity);
_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1);
emit Mint(msg.sender, amount0, amount1);
}
它做的事情可以分成八步:
1. 读取旧 reserve
2. 读取当前 token balance
3. 用 balance - reserve 算出新增 token 数量
4. 处理协议手续费 _mintFee
5. 读取 LP Token 当前总供应量
6. 计算本次应该 mint 多少 LP Token
7. 给用户 mint LP Token
8. 更新 reserve 和 kLast
看起来有点多,但主线其实很清楚:
用户先把钱转进来,Pair 再根据“多出来的钱”发 LP Token。
2. mint 不是主动转账函数
这是第一个必须抓住的点。
mint() 的函数签名是:
function mint(address to) external lock returns (uint liquidity)
你会发现,它没有这两个参数:
uint amount0
uint amount1
也没有:
transferFrom(msg.sender, address(this), amount)
也就是说,Pair 的 mint() 不负责从用户钱包里拉 token。
真正的流程通常是 Router 做的:
用户调用 Router.addLiquidity()
↓
Router 计算应该转入多少 token0/token1
↓
Router 调用 token0.transferFrom(user, pair, amount0)
Router 调用 token1.transferFrom(user, pair, amount1)
↓
Router 调用 Pair.mint(to)
↓
Pair 根据余额变化 mint LP Token
所以 Pair 的思路是:
我不管是谁转的
我也不相信你说你转了多少
我直接看我自己的余额比 reserve 多了多少
这就是下面这几行的意义:
uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
uint amount0 = balance0.sub(_reserve0);
uint amount1 = balance1.sub(_reserve1);
amount0 和 amount1 不是用户传进来的,而是 Pair 自己算出来的。
这点非常重要。
3. reserve 和 balance 的差值,就是本次新增流动性
先取旧状态:
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
然后取当前真实余额:
uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
接着算差值:
uint amount0 = balance0.sub(_reserve0);
uint amount1 = balance1.sub(_reserve1);
假设添加流动性之前,Pair 记录的 reserve 是:
reserve0 = 1000 DAI
reserve1 = 1000 USDC
用户通过 Router 转进来:
100 DAI
100 USDC
那么当前 balance 会变成:
balance0 = 1100 DAI
balance1 = 1100 USDC
于是:
amount0 = balance0 - reserve0 = 100 DAI
amount1 = balance1 - reserve1 = 100 USDC
这就是本次添加的流动性。
注意,这里用的是当前余额,而不是 Router 传参。
这意味着即使有人不用 Router,直接先转 token 到 Pair,再调用 mint(),也可以成功添加流动性。
Pair 不关心入口是谁。
它只关心:
你是不是真的把 token 放进来了。
4. 为什么要先调用 _mintFee?
接下来这一行看起来有点突兀:
bool feeOn = _mintFee(_reserve0, _reserve1);
_mintFee() 是协议手续费相关逻辑。
先不用完全展开,后面会专门讲。
这里先理解它在 mint() 里的位置。
Uniswap V2 有两种手续费:
1. LP 手续费
每笔 swap 的 0.3%,自动留在池子里,属于 LP
2. 协议手续费 protocol fee
默认关闭;开启后,协议方从 LP 收益中分走一部分
_mintFee() 做的是:
如果 feeTo 开启,并且 k 增长了
就给 feeTo mint 一些 LP Token
也就是说,它可能会改变:
totalSupply
所以源码紧接着才读取:
uint _totalSupply = totalSupply;
这不是随便写的。
它的顺序非常讲究:
先 _mintFee()
再读取 totalSupply
再计算用户应该拿多少 LP
因为如果协议手续费被 mint 出来了,LP 总供应量已经变化了,用户本次流动性应该基于新的 totalSupply 计算。
源码里的注释也点出了这一点:
// must be defined here since totalSupply can update in _mintFee
uint _totalSupply = totalSupply;
这就是读源码的小乐趣:顺序经常藏着设计意图。
5. mint 分两种情况
核心逻辑是这一段:
if (_totalSupply == 0) {
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
_mint(address(0), MINIMUM_LIQUIDITY);
} else {
liquidity = Math.min(
amount0.mul(_totalSupply) / _reserve0,
amount1.mul(_totalSupply) / _reserve1
);
}
添加流动性要分两类:
1. 第一次添加流动性
2. 后续添加流动性
这两种情况的 LP Token 计算方式不一样。
为什么?
因为第一次池子里没有价格,没有 reserve,也没有 LP Token 总供应量。
后续添加时,池子已经有比例了,新 LP 必须按照现有池子比例加入,不能白嫖原 LP。
6. 第一次添加流动性:sqrt(amount0 * amount1)
如果是第一次添加流动性:
if (_totalSupply == 0) {
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
_mint(address(0), MINIMUM_LIQUIDITY);
}
这里使用的公式是:
liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY
为什么是平方根?
因为 Uniswap V2 是双资产池,LP Token 的初始供应量需要同时反映两种资产的投入规模。
如果直接用:
amount0 + amount1
会受 token 单位影响很大。
比如:
1 WETH + 3000 USDC
如果直接相加,完全没意义。
因为 WETH 和 USDC 的数量单位不同,价格也不同。
而使用几何平均数:
sqrt(amount0 * amount1)
有一个很好的性质:
初始 LP 供应量和两边资产的乘积规模相关,而不是简单偏向某一边。
这和恒定乘积模型 x * y = k 也很搭。
第一次加入后:
k = amount0 * amount1
所以:
sqrt(k)
可以作为初始 LP Token 总规模。
这里味儿就很对。
7. 举个第一次添加流动性的例子
假设一个新池子第一次加入:
amount0 = 1,000,000
amount1 = 1,000,000
那么:
sqrt(amount0 * amount1)
= sqrt(1,000,000 * 1,000,000)
= 1,000,000
扣掉:
MINIMUM_LIQUIDITY = 1000
用户实际得到:
999,000 LP Token 最小单位
同时 Pair 会 mint:
_mint(address(0), MINIMUM_LIQUIDITY);
也就是把 1000 个 LP Token 最小单位发送到零地址,永久锁死。
所以最终:
LP totalSupply = 1,000,000
用户持有 = 999,000
零地址持有 = 1,000
这里容易误解的一点是:
不是用户先拿 1,000,000 再扣 1000,而是 totalSupply 里永久有 1000 锁在零地址。
这 1000 个 LP Token 最小单位永远不能 burn,也不能赎回底层资产。
8. MINIMUM_LIQUIDITY 的作用再看一眼
源码:
_mint(address(0), MINIMUM_LIQUIDITY);
为什么 mint 到 address(0)?
因为零地址没人能控制。
这等价于永久锁死。
它的作用是防止初始 LP Token 单价被恶意操纵。
一个极端例子:
攻击者用很小的金额初始化池子
让 LP Token 总供应量极低
然后通过捐赠资产等方式让每个 LP Token 对应的资产变得极贵
从而让后续流动性提供者被迫面对不合理的份额结构
锁死一小部分 LP Token 后,初始 LP 总供应量永远不会小到离谱。
这不是为了让协议赚这 1000 个最小单位。
它更像是一颗小小的“防畸形初始化钉子”。
9. 后续添加流动性:按已有份额比例 mint
如果池子已经存在,也就是:
_totalSupply != 0
就走这段:
liquidity = Math.min(
amount0.mul(_totalSupply) / _reserve0,
amount1.mul(_totalSupply) / _reserve1
);
这段是 mint() 的核心。
它的意思是:
你新增的 token0 能换算出一份 LP 数量
你新增的 token1 也能换算出一份 LP 数量
最终取较小值
为什么取较小值?
因为添加流动性必须按照池子当前比例来。
假设当前池子是:
reserve0 = 1000 DAI
reserve1 = 1000 USDC
totalSupply = 1000 LP
现在用户加入:
amount0 = 100 DAI
amount1 = 100 USDC
那么:
amount0 * totalSupply / reserve0
= 100 * 1000 / 1000
= 100 LP
amount1 * totalSupply / reserve1
= 100 * 1000 / 1000
= 100 LP
所以 mint:
100 LP
这很合理。
用户相对于旧池子增加了 10% 的资产,最终获得新增 LP 供应量里的对应份额。
10. 为什么要 Math.min?
来看一个比例不匹配的例子。
当前池子:
reserve0 = 1000 DAI
reserve1 = 1000 USDC
totalSupply = 1000 LP
用户错误地转入:
amount0 = 100 DAI
amount1 = 50 USDC
分别计算:
按 token0 看:
100 * 1000 / 1000 = 100 LP
按 token1 看:
50 * 1000 / 1000 = 50 LP
最终:
liquidity = Math.min(100, 50);
也就是:
liquidity = 50 LP
为什么不是 100?
因为用户只提供了足够支撑 50 LP 的 USDC。
如果给他 100 LP,他就会白嫖其他 LP 的 USDC 份额。
所以必须取较小值。
这也说明一个事实:
如果用户添加流动性的比例不符合当前池子比例,多出来的那一边不会给你带来更多 LP Token。
在 Router 正常调用时,Router 会提前计算最优转入数量,尽量避免这种浪费。
但如果你直接和 Pair 交互,转多了,Pair 不会主动退给你。
这就是 Pair 的冷酷之处:它只维护规则,不照顾体验。
照顾体验是 Router 的工作。
11. 后续添加流动性的本质:按比例获得份额
标准的添加流动性路径不应该改变池子价格。
池子的现价由 reserve 比例决定:
price ≈ reserve1 / reserve0
如果当前池子是:
reserve0 = 1000
reserve1 = 2000
比例是:
1 : 2
那么新增流动性也应该按:
1 : 2
加入。
比如:
100 token0 + 200 token1
这是合理的。
如果你加入:
100 token0 + 300 token1
那多出来的 100 token1 不会给你额外份额。
直接和 Pair 交互时,这部分超额 token 会留在池子里,并在 _update() 后反映到新的 reserve 中;从份额角度看,它更像是对现有 LP 的捐赠。
所以后续 LP mint 公式强制你按照现有比例获得份额。
这也是 AMM 里非常核心的公平性要求。
12. liquidity 必须大于 0
计算完后,源码检查:
require(liquidity > 0, 'UniswapV2: INSUFFICIENT_LIQUIDITY_MINTED');
这防止添加数量过小,导致整数除法后 LP 数量为 0。
比如:
amount0 很小
amount1 很小
totalSupply 很大
计算时可能因为整数截断变成 0。
如果 liquidity == 0,说明这次添加流动性没有实际铸造任何 LP Token。
那必须 revert。
否则用户会白白把 token 放进池子,拿不到份额。
这个错误通常说明:
1. 输入金额太小
2. 比例严重不匹配
3. 池子已有规模很大,本次投入低于最小可计份额
13. 给用户 mint LP Token
通过检查后:
_mint(to, liquidity);
这行调用的是 UniswapV2ERC20 里的 _mint()。
它会做几件事:
1. totalSupply 增加 liquidity
2. balanceOf[to] 增加 liquidity
3. emit Transfer(address(0), to, liquidity)
这就是 LP Token 发放。
注意参数是:
address to
而不是 msg.sender。
也就是说,调用 mint() 的人和接收 LP Token 的人可以不是同一个地址。
比如 Router 调用 Pair.mint,但 LP Token 应该给用户。
所以 Router 会传:
pair.mint(to)
其中 to 通常是用户地址。
这个设计很灵活。
14. _update:更新 reserve
LP Token mint 完之后,Pair 更新储备:
_update(balance0, balance1, _reserve0, _reserve1);
这里传入的是:
balance0:当前 token0 真实余额
balance1:当前 token1 真实余额
_reserve0:旧 reserve0
_reserve1:旧 reserve1
_update() 会把:
reserve0 = balance0
reserve1 = balance1
并更新:
blockTimestampLast
price0CumulativeLast
price1CumulativeLast
所以添加流动性结束后,Pair 的记账状态就同步到了当前余额。
流程变成:
转账前:
reserve = 1000 / 1000
balance = 1000 / 1000
转账后、mint 前:
reserve = 1000 / 1000
balance = 1100 / 1100
mint 后、update 后:
reserve = 1100 / 1100
balance = 1100 / 1100
这就是 reserve 和 balance 的同步过程。
15. 更新 kLast
最后:
if (feeOn) kLast = uint(reserve0).mul(reserve1);
如果协议手续费开启,就记录新的:
kLast = reserve0 * reserve1
注意这里使用的是 reserve0 和 reserve1,而不是 _reserve0/_reserve1。
因为 _update() 已经把 reserve 更新到了最新值。
这个顺序也很讲究:
先 _update()
再 kLast = reserve0 * reserve1
kLast 是协议手续费计算用的锚点。
如果 feeOn 关闭,kLast 会在 _mintFee() 里被清零。
这个我们后面专门讲 _mintFee() 时再细拆。
16. 触发 Mint 事件
最后一行:
emit Mint(msg.sender, amount0, amount1);
这个事件记录:
谁调用了 mint
本次新增了多少 token0
本次新增了多少 token1
注意事件里是 msg.sender,不是 to。
也就是说,如果用户通过 Router 添加流动性:
msg.sender = Router
to = 用户地址
事件记录的 sender 是 Router。
这个事件主要给链下索引器、分析工具、前端使用。
17. 用完整例子走一遍 mint
假设一个已有池子:
reserve0 = 10,000 DAI
reserve1 = 10,000 USDC
totalSupply = 10,000 LP
Alice 想添加:
1,000 DAI
1,000 USDC
Router 先转账:
DAI.balanceOf(pair) = 11,000
USDC.balanceOf(pair) = 11,000
Alice 或 Router 调用:
pair.mint(alice);
Pair 执行:
amount0 = 11,000 - 10,000 = 1,000
amount1 = 11,000 - 10,000 = 1,000
计算 LP:
liquidity0 = amount0 * totalSupply / reserve0
= 1,000 * 10,000 / 10,000
= 1,000
liquidity1 = amount1 * totalSupply / reserve1
= 1,000 * 10,000 / 10,000
= 1,000
liquidity = min(1,000, 1,000) = 1,000
然后:
mint 1,000 LP 给 Alice
reserve 更新为 11,000 / 11,000
totalSupply 更新为 11,000 LP
Alice 拥有新增后的:
1,000 / 11,000 ≈ 9.09%
这很合理。
因为她加入后,池子总资产从 10,000/10,000 变成 11,000/11,000,她贡献了新池子的约 9.09%。
注意不是 10%。
很多人这里会小小绊一下。
她相对于旧池子增加了 10%,但加入后总池子变大了,所以她在新总池子里的份额是:
1000 / 11000 = 9.09%
18. 比例不匹配的完整例子
当前池子:
reserve0 = 10,000 DAI
reserve1 = 10,000 USDC
totalSupply = 10,000 LP
Bob 直接转进 Pair:
1,000 DAI
500 USDC
当前余额:
balance0 = 11,000
balance1 = 10,500
Pair 计算:
amount0 = 1,000
amount1 = 500
LP 计算:
liquidity0 = 1,000 * 10,000 / 10,000 = 1,000
liquidity1 = 500 * 10,000 / 10,000 = 500
最终:
liquidity = min(1,000, 500) = 500
Bob 只得到 500 LP。
那多出来的 500 DAI 怎么办?
很扎心:
Pair 不会自动退
它会留在池子里
这等于 Bob 给现有 LP 捐了一部分资产。
当然,正常用户通过 Router 添加流动性时,Router 会避免这种情况。
Router 的 _addLiquidity() 会根据当前 reserve 计算最佳数量,只转入合适比例的 token。
但 Pair 本身不会帮你优化。
这再次体现了分工:
Router 负责用户体验
Pair 负责规则正确
19. 第一次添加流动性为什么决定初始价格?
如果是第一次添加流动性,池子没有 reserve。
这时候用户提供的比例会决定初始价格。
比如第一次加入:
10 WETH
30,000 USDC
那么初始价格大概就是:
1 WETH = 3000 USDC
如果第一次加入:
10 WETH
20,000 USDC
那么初始价格就是:
1 WETH = 2000 USDC
所以创建池子和第一次添加流动性是很敏感的动作。
如果初始价格偏离市场价,套利者会立刻通过 swap 把价格拉回市场,同时赚走价差。
因此,第一次 LP 需要非常小心初始比例。
Factory 只负责创建 Pair,不负责设置价格。
Pair 也不判断价格是否合理。
价格来自第一笔流动性的比例。
这很自由,也很残酷。
20. mint 的设计哲学
mint() 这个函数的代码不长,但设计特别典型。
它体现了 Uniswap V2 的几个核心风格。
第一,不相信参数,相信余额
mint() 不接收 amount 参数。
它只看:
balance - reserve
这让 Pair 对 Router 没有依赖。
第二,后续 LP 必须按比例加入
后续流动性通过:
Math.min(
amount0.mul(_totalSupply) / _reserve0,
amount1.mul(_totalSupply) / _reserve1
)
保证新 LP 不会因为比例不匹配占便宜。
第三,Pair 不负责用户体验
转多了,Pair 不自动退。
比例算错了,Pair 只按规则 mint。
体验优化交给 Router。
第四,手续费逻辑嵌入 LP 份额模型
_mintFee() 不是转账,而是 mint LP Token。
协议手续费通过稀释 LP 的方式实现。
这很抽象,但很省状态。
21. mint 的完整流程再压缩一遍
最后把 mint() 压缩成一个清晰版本:
1. 读取旧 reserve
2. 读取当前 token 余额
3. amount0 = balance0 - reserve0
4. amount1 = balance1 - reserve1
5. 如果开启协议手续费,先 mint protocol fee
6. 读取 totalSupply
7. 如果是首次流动性:
liquidity = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY
永久锁死 MINIMUM_LIQUIDITY
否则:
liquidity = min(
amount0 * totalSupply / reserve0,
amount1 * totalSupply / reserve1
)
8. 检查 liquidity > 0
9. mint LP Token 给 to
10. 更新 reserve
11. 如果 feeOn,更新 kLast
12. emit Mint
或者更直觉一点:
你给池子增加了多少资产?
↓
这部分资产相当于池子的多少份额?
↓
Pair 给你 mint 对应数量的 LP Token
小结
这一篇我们拆了 Pair.mint()。
你现在应该能回答这些问题:
1. mint 为什么没有 amount0/amount1 参数?
因为 Pair 通过 balance - reserve 自己计算新增资产。
2. 第一次添加流动性为什么用 sqrt(amount0 * amount1)?
因为 LP 初始供应量应该和两种资产的乘积规模相关,也对应 sqrt(k)。
3. MINIMUM_LIQUIDITY 为什么要锁死?
防止初始 LP Token 供应量过低导致份额操纵。
4. 后续添加流动性为什么取 min?
因为两边资产必须按池子比例加入,少的那边决定可获得份额。
5. 转多的一边会怎样?
Pair 不主动退,多出来的部分留在池子里,等于便宜现有 LP。
6. 为什么 _mintFee 在 totalSupply 之前?
因为协议手续费可能改变 totalSupply。
7. mint 后为什么要 _update?
因为 reserve 要同步到当前真实 balance。
下一篇我们看和 mint() 对称的函数:
function burn(address to)
external
lock
returns (uint amount0, uint amount1)
也就是:
移除流动性时,Uniswap V2 如何根据 LP Token 返还 token0 和 token1?
这篇会非常适合和 mint() 对照着读。
核心公式是:
amount0 = liquidity.mul(balance0) / _totalSupply;
amount1 = liquidity.mul(balance1) / _totalSupply;
它看起来比 mint() 简单,但里面有一个特别值得注意的问题:
burn 为什么用 balance,而不是 reserve?