Uniswap V2 Mint 与 Burn 函数:LP 份额的铸造与销毁

解析 LP 代币的数学:首次注资的几何平均数、后续注资的最小比例法、按比例赎回与通胀攻击防御

9 分钟阅读
Uniswap V2 Mint 与 Burn 函数:LP 份额的铸造与销毁

Uniswap V2 Mint 与 Burn 函数:LP 份额的铸造与销毁

目录


一、LP 生命周期概览

一个流动性池的完整生命周期:

  1. 首次注资(initial mint)——池子从空到有,确定初始份额;
  2. 后续注资(mint)——新 LP 加入,按比例铸造份额;
  3. 交易发生——手续费累积,池子增值;
  4. 赎回(burn)——LP 烧掉份额,按比例取回两种代币。

原文按”先易后难”的顺序倒着讲:先 burn,再 mint,最后讲最微妙的首次 mint。本文遵循同样顺序。

二、Burn:赎回流动性

2.1 执行步骤

burn(address to) 的流程:

  1. 检测收到的 LP 代币:合约读取自己地址上的 LP 代币余额——调用者需要在调用 burn 之前(通常同一笔交易内,由 Router 完成,否则会被别人抢走)把 LP 代币转给 Pair 合约。这和 swap 的”先转币后调用”是同一套模式;
  2. 按比例计算应得资产
  3. 销毁 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·y10×10 = 10020×20 = 4004 倍
sqrt(x·y)√100 = 10√400 = 202 倍

哪个符合经济现实?想一想:池子里每种代币都翻倍了,你能从池子取出的代币量、池子能承受的交易深度,都是翻倍而不是变成 4 倍。所以 sqrt(x·y) 才是正确的”流动性深度”度量——乘积本身会平方级地夸大增长。

5.3 与手续费计算的关系

这个度量的正确性在计算协议费时尤为关键:如果池子增长了 100%,协议费的计费基数也应该是 100%,而不是按乘积算出来的 300%。虽然 Uniswap V2 实际上从未开启协议费(mintFee 篇详述),但 sqrt 公式为它提供了理论上正确的计费基础。

六、关键数字速查表

属性值/公式用途
MINIMUM_LIQUIDITY1000(wei 份额)防止单一持有者独占供应、抗通胀攻击
Burn 赎回liquidity / totalSupply 按比例公平退出
后续 Mintmin(amount0/reserve0, amount1/reserve1) × totalSupply防单边注资盗窃
首次 Mintsqrt(amount0 × amount1) − 1000与初始比例无关的份额定价

七、总结

  • Burn 按比例:简单直接,按实时余额分配;
  • Mint 取最小:把”偏离比例”变成捐赠,从机制上消灭注资盗窃;
  • 首次 Mint 取几何平均:让份额价值与开池比例脱钩,并正确度量流动性深度;
  • 烧掉 1000 份额:四两拨千斤地防御通胀攻击。

八、动手练习项目:完整 LP 份额池 SharePool

项目目标

把前几篇练习的池子升级为份额管理完全对标 Uniswap V2SharePool,亲手实现 sqrt 首铸、min 追铸、按比例赎回和 MINIMUM_LIQUIDITY 防御,并在测试中亲手复现一次通胀攻击(在自己的合约上做安全实验,理解防御为何有效),最后部署到 Sepolia。

合约要求

SharePool.sol(继承 OpenZeppelin ERC20 作为 LP 代币)

  • 常量:uint public constant MINIMUM_LIQUIDITY = 1000;
  • 状态:token0token1reserve0reserve1
  • 内部函数 _sqrt(uint y):照抄 Uniswap V2 的巴比伦法实现(while 循环迭代收敛),自己加注释解释每一步;
  • mint(address to) returns (uint liquidity)
    • 读取实时 balance,amount0 = balance0 - reserve0amount1 = 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)
  • 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(...)
  • 保留上一篇练习的 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)

  1. test_FirstMint_SqrtFormula:注入 400e18 × 100e18,断言铸得份额 = sqrt(4e40) − 1000 = 2e20 − 1000,且 0xdead 持有 1000;
  2. test_SecondMint_ProportionalDeposit:按比例追加 25%,份额恰好增加 25%;
  3. test_SecondMint_LopsidedDeposit:复现 3.2 节场景——池子 100:1、总份额 1e18,单边注入 1 个 token1,断言铸得份额为 0 并 revert;再写对照测试证明如果当时取 max 会让攻击者偷走价值(注释里写出 $50 的推导);
  4. test_Burn_ProRata:烧 10% 份额拿回 10% 的两种代币(用 2.2 节的 1000/100/500/2000 数字);
  5. test_InflationAttack_Mitigated:攻击者首铸最小份额后向池子捐赠 1000e18,受害者随后正常注资,断言受害者损失被 MINIMUM_LIQUIDITY 限制在可忽略范围(对比注释:如果没有 MINIMUM_LIQUIDITY 损失会是多少);
  6. test_FeesAccrueToLPs:mint → 做 10 笔 swap → burn,断言取回的资产价值 > 注入值。

Sepolia 部署与验证步骤

  1. 部署 TokenX/TokenY/SharePool/PoolRouterLite;
  2. 通过 Router addLiquidity(400e18, 100e18, 0),在 Etherscan 上确认自己的 LP 余额 = 2e20 − 1000,0xdead 余额 = 1000;
  3. 做一笔 swap,然后 removeLiquidity 一半份额,核对取回数量与公式手算一致;
  4. 验证合约源码,截图保存 Mint/Burn 事件作为学习记录。

进阶挑战(可选)

  • _update 中的 reserve 改用 uint112 并打包进单个 storage slot(加 uint32 blockTimestampLast),对比 Gas 报告,理解 Uniswap 为什么这么省;
  • 思考题(写在 README):burn 用实时 balance 而不是 reserve 计算,如果有人在你 burn 前给池子捐了一笔币,谁受益?这能被 MEV 利用吗?

💬 评论