Uniswap V2 协议费 mintFee 机制详解
目录
- 一、什么是 mintFee
- 二、为什么不在每笔 swap 时收协议费
- 三、术语与符号约定
- 四、机制成立的两个前提假设
- 五、数值例子:流动性从 10 涨到 40
- 六、η 公式的完整推导
- 七、_mintFee 代码结构解析
- 八、kLast 的管理:何时更新、为何不在 swap 时更新
- 九、五种分支场景
- 十、feeOn 与 feeTo 开关
- 十一、总结
- 十二、动手练习项目:给 SharePool 加协议费
一、什么是 mintFee
Uniswap V2 的交易手续费是 0.3%,全部归 LP。但合约里预留了一个开关:一旦打开,协议方(Uniswap 团队的金库)将抽走这 0.3% 中的 1/6(即交易额的 0.05%),LP 保留 5/6(0.25%)。
实现这个抽成的函数叫 _mintFee——名字的含义是”通过增发 LP 代币给协议方来收费”,而不是直接转走池子里的代币。
有趣的事实:这个开关在 Uniswap V2 主网上从未被打开过。但无数 fork(如 SushiSwap)沿用并启用了这套机制,所以理解它依然非常重要。
二、为什么不在每笔 swap 时收协议费
最直接的做法是每笔 swap 都把 0.05% 转给协议金库,但这”每笔交易都要做额外的 ERC20 转账,非常低效”。
Uniswap 的方案:平时不收,攒着;等到有人 mint 或 burn(低频操作)时一次性结算。这样把 N 笔转账压缩成 1 笔,大幅节省 Gas。
要做到”事后补收”,就需要一种方法来度量”从上次结算到现在,池子靠手续费赚了多少”——这正是本文的核心数学。
三、术语与符号约定
| 符号/术语 | 含义 |
|---|---|
| fee | 交易者付的 0.3% 手续费 |
| mintFee | 0.3% 中的 1/6 ≈ 0.05%,协议抽成 |
| 流动性 ℓ | √(x·y),上一篇讲过这是正确的深度度量 |
| ℓ₁ | 上次 mint/burn 时的流动性(代码中 kLast 的平方根) |
| ℓ₂ | 本次结算时的流动性(代码中 rootK) |
| s | 稀释前的 LP 代币总供应量(totalSupply) |
| η | 应增发给协议方的 LP 代币数量(待求) |
四、机制成立的两个前提假设
- 只要没人调用 mint/burn,池子的流动性 ℓ 只增不减——swap 必须保持 K 不减(实际因手续费而增),所以 √K 也只增不减;
- 这段时间内 ℓ 的增长完全来自手续费(或捐赠)——没有 mint/burn 改变本金。
有了这两条,“两次结算之间 ℓ 的增量”就是”这段时间的手续费总收益”,协议费有了精确的计费基数。
五、数值例子:流动性从 10 涨到 40
t₁ 时刻(上次 mint/burn):池子有 10 个 token0、10 个 token1
- ℓ₁ = √(10 × 10) = 10
t₂ 时刻(本次结算):经过大量交易,池子有 40 个 token0、40 个 token1
- ℓ₂ = √(40 × 40) = 40
分账:
- 流动性增长:40 − 10 = 30 个流动性单位(全部是手续费收益)
- 协议应得 1/6:30 / 6 = 5 个单位
- LP 应得 5/6:25 个单位
接下来的问题是:增发多少 LP 代币(η),才能让协议方恰好拥有价值 5 个流动性单位的份额? 注意不能简单按比例算,因为增发本身会稀释所有人的份额——这就需要下面的推导。
六、η 公式的完整推导
设增发 η 之后,协议方占总供应的比例应等于它应得的流动性占总流动性的比例:
- 协议应得的流动性:p = (ℓ₂ − ℓ₁) / 6
- LP 应得的流动性(含本金):d = ℓ₁ + (5/6)(ℓ₂ − ℓ₁)
增发后协议持有 η,老 LP 持有 s,两边份额之比必须等于两边流动性之比:
η / p = s / d
解出 η:
η = s · p / d
= s · [(ℓ₂ − ℓ₁)/6] / [ℓ₁ + 5(ℓ₂ − ℓ₁)/6]
分子分母同乘 6 并整理,得到合约中实际使用的形式:
η = s · (ℓ₂ − ℓ₁) / (5·ℓ₁ + ℓ₂)
用第五节的数字验证:假设 s = 10(首铸时 sqrt(10×10) = 10):
- η = 10 × (40 − 10) / (5×10 + 40) = 10 × 30 / 90 = 10/3 ≈ 3.333
- 增发后总供应 = 10 + 10/3 = 40/3
- 协议份额占比 = (10/3) / (40/3) = 1/4
- 池子总流动性 40 的 1/4 = 10?
等一下,协议应得的是 5 不是 10?再看:协议占比 1/4 × 40 = 10 个流动性单位中,包含的是增长部分的 1/6 = 5 加上……不对,我们直接验证原始等式:p = 5,d = 10 + 25 = 35,η = 10 × 5/35 = 10/7 ≈ 1.4286。
增发后总供应 = 10 + 10/7 = 80/7,协议占比 = (10/7)/(80/7) = 1/8,对应流动性 = 40 × 1/8 = 5 ✓ 恰好是协议应得的 5 个单位。
注意:合约源码中的公式写作
liquidity = totalSupply × (rootK − rootKLast) / (rootK × 5 + rootKLast),与 η = s(ℓ₂ − ℓ₁)/(5ℓ₂ + ℓ₁) 对应——分母里乘 5 的是 ℓ₂(当前值)而不是 ℓ₁。用数字验证:η = 10 × 30 / (5×40 + 10) = 300/210 = 10/7 ✓,与基本等式 η = s·p/d 的结果一致。推导时务必以 η/p = s/d 这条不变式为准,再代入化简核对。
这个推导是面试高频题,建议亲手从 η/p = s/d 出发推一遍。
七、_mintFee 代码结构解析
_mintFee(uint112 _reserve0, uint112 _reserve1) 的执行流程:
- 读取
feeTo地址,判断feeOn = feeTo != address(0); - 读取
kLast(上次 mint/burn 时存下的reserve0 × reserve1); - 若 feeOn 且 kLast ≠ 0:
rootK = sqrt(reserve0 × reserve1)(当前流动性 ℓ₂)rootKLast = sqrt(kLast)(上次流动性 ℓ₁)- 若
rootK > rootKLast(流动性确实增长了):按第六节公式计算 liquidity 并_mint(feeTo, liquidity);
- 若 feeOn 为 false 但 kLast ≠ 0:把 kLast 清零。
注意:这是一个状态变更函数,增发动作就发生在函数内部;它在 mint() 和 burn() 的开头被调用——即在新的注资/赎回改变本金之前,先把旧账结清。
八、kLast 的管理:何时更新、为何不在 swap 时更新
kLast 存的是上次 mint/burn 结束时的 reserve0 × reserve1:
- mint() 末尾:若 feeOn,
kLast = reserve0 × reserve1; - burn() 末尾:若 feeOn,
kLast = reserve0 × reserve1; - swap 时不更新。
为什么 swap 不更新?因为我们要度量的正是”两次注资/赎回事件之间,swap 手续费带来的增长”。如果每次 swap 都重置 kLast,增长量就永远测不到了。kLast 是协议费的”计费起点”,只能在结算(mint/burn)时刷新。
九、五种分支场景
| 场景 | feeOn | kLast | 行为 |
|---|---|---|---|
| 1 | false | 0 | 什么都不做 |
| 2 | false(刚关闭) | ≠ 0 | 清零 kLast,不增发 |
| 3 | true | 0(刚开启后第一次) | 不增发,等 mint/burn 设置 kLast 后开始计费 |
| 4 | true | ≠ 0 且 rootK ≤ rootKLast | 流动性没增长,不增发 |
| 5 | true | ≠ 0 且 rootK > rootKLast | 计算 η 并增发给 feeTo |
十、feeOn 与 feeTo 开关
feeTo:Factory 合约上的一个地址变量,由feeToSetter控制;feeOn:不是独立变量,而是feeTo != address(0)的判断结果;- 把 feeTo 设为非零地址 = 打开协议费;设回零地址 = 关闭。
主网上 Uniswap 从未设置过 feeTo,所以 0.3% 一直全归 LP。
十一、总结
- 协议费 = 手续费的 1/6,但攒到 mint/burn 时一次性结算,省 Gas;
- 计费基数 = 两次结算之间 √K 的增长(流动性增长全部来自手续费);
- 收费方式 = 增发 LP 代币稀释现有 LP,而非转移底层代币;
- η 公式从”份额比 = 流动性比”这条不变式推导而来;
- kLast 只在 mint/burn 更新,是计费周期的锚点。
十二、动手练习项目:给 SharePool 加协议费
项目目标
在上一篇练习的 SharePool 基础上实现完整的 _mintFee 机制,包括 feeTo 开关、kLast 管理,并用测试验证”协议份额价值恰好等于增长的 1/6”,部署到 Sepolia 实际触发一次协议费结算。
合约要求
1. PoolFactoryLite.sol(轻量工厂,承载费用治理)
address public feeTo;、address public feeToSetter;setFeeTo(address)/setFeeToSetter(address):仅 feeToSetter 可调用(自定义错误Forbidden());- 部署 SharePool 时把工厂地址传进去。
2. SharePool.sol 改造
- 新增
uint public kLast;和address public immutable factory; - 实现:
function _mintFee(uint _reserve0, uint _reserve1) private returns (bool feeOn);
严格按本文第七节流程:读 factory 的 feeTo → 判断 feeOn → 五种分支(对照第九节表格逐一实现)→ 增长时按 liquidity = totalSupply * (rootK - rootKLast) / (rootK * 5 + rootKLast) 增发给 feeTo;
- 在
mint()和burn()开头调用_mintFee,末尾按 feeOn 更新/维护 kLast; - 注意一个细节:
mint中计算份额用的totalSupply必须取_mintFee执行之后的值(Uniswap 源码注释特意强调了这一点),想清楚为什么并写成注释。
测试要求(Foundry)
test_FeeOff_NoMint:feeTo 为零地址时,多次 swap + mint,feeTo 余额始终为 0,kLast 保持 0;test_FeeOn_ExactOneSixth(核心测试,复现本文第五、六节):- 注入 10e18 × 10e18(ℓ₁ = 10e18,s ≈ 10e18);
- 开启 feeTo;先做一次小额 mint 让 kLast 被记录;
- 用
deal/直接转账把储备推到 40e18 × 40e18 再sync()(模拟手续费积累,避免构造海量 swap); - 触发一次 burn 或 mint,断言 feeTo 获得的 LP 份额按当前总供应折算的流动性 ≈ (40e18 − 10e18)/6 = 5e18(assertApproxEqRel 1e15 容差);
test_KLast_NotUpdatedOnSwap:swap 之后 kLast 不变,mint 之后 kLast 更新;test_FeeToggle_ClearsKLast:开启 → 积累 → 关闭 → 下一次 mint 时 kLast 清零且不增发(场景 2);testFuzz_ProtocolNeverOverpaid(uint growth):任意增长幅度下,feeTo 份额对应的流动性 ≤ 增长量的 1/6 + 1 wei。
Sepolia 部署与验证步骤
- 部署 PoolFactoryLite(feeToSetter = 你的地址)、TokenX/Y、SharePool;
setFeeTo(你的第二个地址);- addLiquidity → 做几笔 swap → 再 addLiquidity 一小笔触发结算;
- 在 Etherscan 上查看第二个地址收到的 LP 代币,手工核算它对应的流动性是否 ≈ √K 增长的 1/6;
setFeeTo(address(0))关闭,再触发一次 mint,确认不再增发。
进阶挑战(可选)
- 把 1/6 改成可配置的
feeDenominator(SushiSwap 等 fork 的常见改法),重新推导 η 公式的一般形式 η = s(ℓ₂−ℓ₁)/((n−1)ℓ₂+ℓ₁),用代码参数化实现; - 写一篇简短 README 回答:如果攻击者在 burn 之前用巨额 swap 把池子打偏再打回来(手续费暴增),协议费机制会被利用吗?谁付出了成本?