Uniswap V2 协议费 mintFee 机制详解

解析从未启用却被无数 fork 沿用的协议费设计:用流动性增长量一次性结算 1/6 的手续费分成

9 分钟阅读
Uniswap V2 协议费 mintFee 机制详解

Uniswap V2 协议费 mintFee 机制详解

目录


一、什么是 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% 手续费
mintFee0.3% 中的 1/6 ≈ 0.05%,协议抽成
流动性 ℓ√(x·y),上一篇讲过这是正确的深度度量
ℓ₁上次 mint/burn 时的流动性(代码中 kLast 的平方根)
ℓ₂本次结算时的流动性(代码中 rootK
s稀释前的 LP 代币总供应量(totalSupply
η应增发给协议方的 LP 代币数量(待求)

四、机制成立的两个前提假设

  1. 只要没人调用 mint/burn,池子的流动性 ℓ 只增不减——swap 必须保持 K 不减(实际因手续费而增),所以 √K 也只增不减;
  2. 这段时间内 ℓ 的增长完全来自手续费(或捐赠)——没有 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) 的执行流程:

  1. 读取 feeTo 地址,判断 feeOn = feeTo != address(0)
  2. 读取 kLast(上次 mint/burn 时存下的 reserve0 × reserve1);
  3. 若 feeOn 且 kLast ≠ 0:
    • rootK = sqrt(reserve0 × reserve1)(当前流动性 ℓ₂)
    • rootKLast = sqrt(kLast)(上次流动性 ℓ₁)
    • rootK > rootKLast(流动性确实增长了):按第六节公式计算 liquidity 并 _mint(feeTo, liquidity)
  4. 若 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)时刷新。

九、五种分支场景

场景feeOnkLast行为
1false0什么都不做
2false(刚关闭)≠ 0清零 kLast,不增发
3true0(刚开启后第一次)不增发,等 mint/burn 设置 kLast 后开始计费
4true≠ 0 且 rootK ≤ rootKLast流动性没增长,不增发
5true≠ 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)

  1. test_FeeOff_NoMint:feeTo 为零地址时,多次 swap + mint,feeTo 余额始终为 0,kLast 保持 0;
  2. 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 容差);
  3. test_KLast_NotUpdatedOnSwap:swap 之后 kLast 不变,mint 之后 kLast 更新;
  4. test_FeeToggle_ClearsKLast:开启 → 积累 → 关闭 → 下一次 mint 时 kLast 清零且不增发(场景 2);
  5. testFuzz_ProtocolNeverOverpaid(uint growth):任意增长幅度下,feeTo 份额对应的流动性 ≤ 增长量的 1/6 + 1 wei。

Sepolia 部署与验证步骤

  1. 部署 PoolFactoryLite(feeToSetter = 你的地址)、TokenX/Y、SharePool;
  2. setFeeTo(你的第二个地址)
  3. addLiquidity → 做几笔 swap → 再 addLiquidity 一小笔触发结算;
  4. 在 Etherscan 上查看第二个地址收到的 LP 代币,手工核算它对应的流动性是否 ≈ √K 增长的 1/6;
  5. setFeeTo(address(0)) 关闭,再触发一次 mint,确认不再增发。

进阶挑战(可选)

  • 把 1/6 改成可配置的 feeDenominator(SushiSwap 等 fork 的常见改法),重新推导 η 公式的一般形式 η = s(ℓ₂−ℓ₁)/((n−1)ℓ₂+ℓ₁),用代码参数化实现;
  • 写一篇简短 README 回答:如果攻击者在 burn 之前用巨额 swap 把池子打偏再打回来(手续费暴增),协议费机制会被利用吗?谁付出了成本?

💬 评论