Uniswap V2 Mint 与 Burn 函数:LP 份额的铸造与销毁
目录
- 一、LP 生命周期概览
- 二、Burn:赎回流动性
- 三、Mint:向非空池子追加流动性
- 四、首位 LP 问题与通胀攻击
- 五、首次注资为什么用平方根(几何平均数)
- 六、关键数字速查表
- 七、总结
- 八、动手练习项目:完整 LP 份额池 SharePool
一、LP 生命周期概览
一个流动性池的完整生命周期:
- 首次注资(initial mint)——池子从空到有,确定初始份额;
- 后续注资(mint)——新 LP 加入,按比例铸造份额;
- 交易发生——手续费累积,池子增值;
- 赎回(burn)——LP 烧掉份额,按比例取回两种代币。
原文按”先易后难”的顺序倒着讲:先 burn,再 mint,最后讲最微妙的首次 mint。本文遵循同样顺序。
二、Burn:赎回流动性
2.1 执行步骤
burn(address to) 的流程:
- 检测收到的 LP 代币:合约读取自己地址上的 LP 代币余额——调用者需要在调用 burn 之前(通常同一笔交易内,由 Router 完成,否则会被别人抢走)把 LP 代币转给 Pair 合约。这和 swap 的”先转币后调用”是同一套模式;
- 按比例计算应得资产;
- 销毁 LP 代币、转出两种底层代币、调用
_update()同步储备和 TWAP。
2.2 按比例赎回的数学(数值例子)
amount0 = liquidity × balance0 / totalSupply
amount1 = liquidity × balance1 / totalSupply
例子:LP 代币总供应量 1,000,你烧掉 100(占 10%):
- 池中有 500 个 token0 → 你拿走 500 × 100/1000 = 50 个 token0
- 池中有 2,000 个 token1 → 你拿走 2,000 × 100/1000 = 200 个 token1
注意公式用的是实时 balance 而非账面 reserve——这样捐赠进来的代币也会按比例分给赎回者。
2.3 Burn 的安全注意事项
池子的比例在你签名交易和交易上链之间可能发生变化(有人插队交易了)。如果你对取回的数量有硬性要求(比如取回的钱要去还闪电贷),上层合约必须实现滑点检查:取回数量低于设定下限就 revert。Pair 本身不做这个检查——还是那个原则,用户保护放在外围。
三、Mint:向非空池子追加流动性
3.1 最小比例公式
向已有流动性的池子注资时,铸造的份额取两个比例中较小的那个:
liquidity = min(
amount0 × totalSupply / reserve0,
amount1 × totalSupply / reserve1
)
直觉:你提供的两种代币各自相对于池子的占比,按占比低的那一侧给你计算份额。
3.2 为什么必须取最小值(防盗窃案例详解)
这是本文最重要的案例,一步步算:
初始状态:
- 池中 100 个 token0(总价值 $100,即每个 $1)
- 池中 1 个 token1(总价值 $100,即每个 $100)
- LP 总供应量:1 个
- 池子总价值:$200
攻击设想:如果用 max 而不是 min 会怎样?
攻击者只注入 1 个 token1(成本 $100),token0 注入 0 个:
- 比例一:amount0/reserve0 × totalSupply = 0/100 × 1 = 0
- 比例二:amount1/reserve1 × totalSupply = 1/1 × 1 = 1
如果取 max,攻击者获得 1 个 LP 代币。此时总供应量变成 2,攻击者占 50%。
而池子现在的总价值 = $100(token0) + $200(token1) = $300。攻击者的 50% 份额价值 $150——他只花了 $100,凭空从老 LP 手里偷走了 $50。
如果取 min,攻击者获得 0 个 LP 代币——单边注资一无所得,盗窃被阻止。
3.3 激励结构
min 规则形成了一个自我执行的激励:
- 严格按当前池子比例注资 → 两个比例相等,一分不亏;
- 偏离比例注资 → 多出来的那部分相当于无偿捐赠给全体 LP,自己吃亏。
所以理性的 LP 一定会按比例注资,池子价格不会被注资行为扭曲。
3.4 Mint 的安全注意事项
两个外部风险,Pair 都不在内部处理:
- 比例风险:交易排序可能让池子比例在你上链前变化,导致你”偏离比例”而损失份额;
- 供应量风险:totalSupply 也可能在期间变化,影响你的份额计算。
Uniswap 故意不强制精确数量(那会导致大量 revert),缓解方式是调用方(Router)实现最小铸造量滑点检查。
四、首位 LP 问题与通胀攻击
第一个 LP 拥有 100% 的份额,这带来一个著名漏洞——通胀攻击(inflation attack):
攻击思路(与 ERC-4626 金库的攻击同源):攻击者作为首位 LP 铸造极少量份额(比如 1 wei 的份额),然后向池子直接捐赠大量代币,把”每份份额对应的资产”抬到天价,使后来者的注资因为整除向下取整而铸造出 0 份额,资产却进了池子被攻击者独占。
Uniswap 的防御:首次注资时,强制销毁 MINIMUM_LIQUIDITY(1000 wei)份额,把它发送到死地址。效果:
- 任何人都无法拥有 100% 的份额供应;
- 把通胀攻击的成本抬高到不划算的程度(攻击者每抬高一倍份额单价,就要给死地址里那 1000 份白送一倍的钱)。
五、首次注资为什么用平方根(几何平均数)
5.1 公式
首次注资铸造的份额:
liquidity = sqrt(amount0 × amount1) − MINIMUM_LIQUIDITY
白皮书的解释:初始份额等于注资数量的几何平均数,这保证了”一份流动性份额的价值基本不依赖于初始注资的比例”。也就是说,无论首位 LP 用什么价格比例开池,份额的”含金量”度量是一致的。
5.2 直觉案例:流动性翻倍
场景:池子从 (10, 10) 增长到 (20, 20)。
| 度量方式 | 之前 | 之后 | 增长倍数 |
|---|---|---|---|
| 乘积 x·y | 10×10 = 100 | 20×20 = 400 | 4 倍 |
| sqrt(x·y) | √100 = 10 | √400 = 20 | 2 倍 |
哪个符合经济现实?想一想:池子里每种代币都翻倍了,你能从池子取出的代币量、池子能承受的交易深度,都是翻倍而不是变成 4 倍。所以 sqrt(x·y) 才是正确的”流动性深度”度量——乘积本身会平方级地夸大增长。
5.3 与手续费计算的关系
这个度量的正确性在计算协议费时尤为关键:如果池子增长了 100%,协议费的计费基数也应该是 100%,而不是按乘积算出来的 300%。虽然 Uniswap V2 实际上从未开启协议费(mintFee 篇详述),但 sqrt 公式为它提供了理论上正确的计费基础。
六、关键数字速查表
| 属性 | 值/公式 | 用途 |
|---|---|---|
| MINIMUM_LIQUIDITY | 1000(wei 份额) | 防止单一持有者独占供应、抗通胀攻击 |
| Burn 赎回 | liquidity / totalSupply 按比例 | 公平退出 |
| 后续 Mint | min(amount0/reserve0, amount1/reserve1) × totalSupply | 防单边注资盗窃 |
| 首次 Mint | sqrt(amount0 × amount1) − 1000 | 与初始比例无关的份额定价 |
七、总结
- Burn 按比例:简单直接,按实时余额分配;
- Mint 取最小:把”偏离比例”变成捐赠,从机制上消灭注资盗窃;
- 首次 Mint 取几何平均:让份额价值与开池比例脱钩,并正确度量流动性深度;
- 烧掉 1000 份额:四两拨千斤地防御通胀攻击。
八、动手练习项目:完整 LP 份额池 SharePool
项目目标
把前几篇练习的池子升级为份额管理完全对标 Uniswap V2 的 SharePool,亲手实现 sqrt 首铸、min 追铸、按比例赎回和 MINIMUM_LIQUIDITY 防御,并在测试中亲手复现一次通胀攻击(在自己的合约上做安全实验,理解防御为何有效),最后部署到 Sepolia。
合约要求
SharePool.sol(继承 OpenZeppelin ERC20 作为 LP 代币)
- 常量:
uint public constant MINIMUM_LIQUIDITY = 1000; - 状态:
token0、token1、reserve0、reserve1; - 内部函数
_sqrt(uint y):照抄 Uniswap V2 的巴比伦法实现(while 循环迭代收敛),自己加注释解释每一步; mint(address to) returns (uint liquidity):- 读取实时 balance,
amount0 = balance0 - reserve0,amount1 = balance1 - reserve1(调用者先转币,保持与 Uniswap 一致的”先转后调”模式); totalSupply == 0时:liquidity = _sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY,并_mint(address(0xdead), MINIMUM_LIQUIDITY)(OpenZeppelin 不允许 mint 到零地址,用 0xdead 代替);- 否则:
liquidity = min(amount0 * totalSupply / reserve0, amount1 * totalSupply / reserve1); liquidity == 0时 revert 自定义错误InsufficientLiquidityMinted();_mint(to, liquidity),_update(),发事件Mint(msg.sender, amount0, amount1);
- 读取实时 balance,
burn(address to) returns (uint amount0, uint amount1):- 读取本合约持有的 LP 余额
liquidity = balanceOf(address(this)); amount0 = liquidity * balance0 / totalSupply,amount1 同理;- 两者任一为 0 则 revert
InsufficientLiquidityBurned(); _burn(address(this), liquidity),转出两种代币给 to,_update(),发事件Burn(...);
- 读取本合约持有的 LP 余额
- 保留上一篇练习的
swap(让池子能产生手续费)。
PoolRouterLite.sol
addLiquidity(uint amount0Desired, uint amount1Desired, uint minLiquidity):按当前 reserve 比例计算实际注入量(取不超过 desired 的最优比例组合),transferFrom → pool.mint,校验铸得份额 ≥ minLiquidity(错误SlippageExceeded());removeLiquidity(uint liquidity, uint min0, uint min1):把用户的 LP 转给 pool → burn → 校验两个下限。这两个函数就是 3.4 节和 2.3 节”安全检查放外围”的落地。
测试要求(Foundry)
test_FirstMint_SqrtFormula:注入 400e18 × 100e18,断言铸得份额 = sqrt(4e40) − 1000 = 2e20 − 1000,且 0xdead 持有 1000;test_SecondMint_ProportionalDeposit:按比例追加 25%,份额恰好增加 25%;test_SecondMint_LopsidedDeposit:复现 3.2 节场景——池子 100:1、总份额 1e18,单边注入 1 个 token1,断言铸得份额为 0 并 revert;再写对照测试证明如果当时取 max 会让攻击者偷走价值(注释里写出 $50 的推导);test_Burn_ProRata:烧 10% 份额拿回 10% 的两种代币(用 2.2 节的 1000/100/500/2000 数字);test_InflationAttack_Mitigated:攻击者首铸最小份额后向池子捐赠 1000e18,受害者随后正常注资,断言受害者损失被 MINIMUM_LIQUIDITY 限制在可忽略范围(对比注释:如果没有 MINIMUM_LIQUIDITY 损失会是多少);test_FeesAccrueToLPs:mint → 做 10 笔 swap → burn,断言取回的资产价值 > 注入值。
Sepolia 部署与验证步骤
- 部署 TokenX/TokenY/SharePool/PoolRouterLite;
- 通过 Router
addLiquidity(400e18, 100e18, 0),在 Etherscan 上确认自己的 LP 余额 = 2e20 − 1000,0xdead 余额 = 1000; - 做一笔 swap,然后
removeLiquidity一半份额,核对取回数量与公式手算一致; - 验证合约源码,截图保存 Mint/Burn 事件作为学习记录。
进阶挑战(可选)
- 把
_update中的 reserve 改用uint112并打包进单个 storage slot(加uint32 blockTimestampLast),对比 Gas 报告,理解 Uniswap 为什么这么省; - 思考题(写在 README):burn 用实时 balance 而不是 reserve 计算,如果有人在你 burn 前给池子捐了一笔币,谁受益?这能被 MEV 利用吗?