Uniswap V2 源码学习第九篇:手续费机制,0.3% 交易费和 protocol fee 是怎么实现的?
手续费机制
前面我们已经把主交易路径走通了:
Library:计算报价
Router:组织流程
Pair:执行 mint / burn / swap,并更新状态
这一篇进入 Uniswap V2 里一个非常容易被低估的主题:
手续费机制
Uniswap V2 里其实有两层手续费:
第一层:LP 交易手续费
每笔 swap 默认 0.3%,直接留在池子里,属于 LP。
第二层:协议手续费 protocol fee
默认关闭。开启后,协议从 LP 收益里分走一部分。
这两层手续费的实现方式完全不同。
0.3% 交易手续费是在 swap() 的 K 检查里体现的。
协议手续费则是在 mint() / burn() 时通过 _mintFee() 铸造 LP Token 实现的。
这一篇我们专门拆:
_mintFee()
kLast
feeTo
feeOn
重点回答一个问题:
为什么协议手续费不是直接转 token,而是 mint LP Token 给 feeTo?
1. 先区分两种手续费
很多人第一次读 Uniswap V2,会把手续费混在一起。
其实要分清楚。
交易手续费:0.3%
每次 swap,用户输入的 token 里有 0.3% 会作为手续费留在池子里。
比如用户输入:
1000 DAI
真正参与兑换计算的是:
997 DAI
剩下:
3 DAI
留在池子里。
注意,这 3 DAI 没有被单独转给 LP,也没有被记录到某个账本里。
它只是留在 Pair 的余额中。
LP 持有池子的份额,所以池子资产变多,LP 的份额自然更值钱。
这就是交易手续费。
协议手续费:protocol fee
协议手续费默认关闭。
它由 Factory 里的 feeTo 控制:
address public feeTo;
如果:
feeTo == address(0)
说明协议手续费关闭。
如果:
feeTo != address(0)
说明协议手续费开启。
协议手续费不是每笔 swap 时立刻转账。
它是在后续有人添加或移除流动性时,通过 _mintFee() 结算。
2. 交易手续费在哪里实现?
我们先回顾 swap() 里的 K 检查:
uint balance0Adjusted = balance0.mul(1000).sub(amount0In.mul(3));
uint balance1Adjusted = balance1.mul(1000).sub(amount1In.mul(3));
require(
balance0Adjusted.mul(balance1Adjusted)
>= uint(_reserve0).mul(_reserve1).mul(1000**2),
'UniswapV2: K'
);
这就是 0.3% 交易手续费的实现。
假设用户输入 token0:
amount0In = 1000
amount1In = 0
那么:
balance0Adjusted = balance0 * 1000 - amount0In * 3;
等价于:
最终 balance 里,用户输入的 1000 只有 997 参与 K 检查。
所以用户想换出 token1,必须满足:
扣掉 0.3% 手续费后的有效输入,仍然让 K 不变小。
这部分手续费的效果是:
用户付进来的全部 amountIn 都留在池子里
但只有 99.7% 被当作有效输入参与定价
剩下 0.3% 留给 LP
这就是 LP 交易费。
3. LP 为什么能赚到交易费?
因为交易费留在池子里。
举个简单例子。
初始池子:
reserve0 = 10,000 DAI
reserve1 = 10,000 USDC
LP totalSupply = 10,000
用户 swap,输入:
1000 DAI
假设输出:
906 USDC
交易后池子变成:
reserve0 = 11,000 DAI
reserve1 = 9,094 USDC
如果没有手续费,在相同输入下,用户本来可以拿到更多 USDC。
但因为手续费,池子少付了一点输出。
于是池子的总体价值相对增加了。
LP 没有收到一笔单独的“手续费转账”,但他们持有的 LP Token 对应的池子资产变多了。
所以 LP 收益的实现方式是:
不是给 LP 发钱
而是让池子变肥
这很省状态。
协议不需要记录每个 LP 的手续费收益。
LP 退出时,burn() 按份额返还当前真实余额:
amount0 = liquidity.mul(balance0) / _totalSupply;
amount1 = liquidity.mul(balance1) / _totalSupply;
手续费收益自然包含在里面。
4. protocol fee 是什么?
Uniswap V2 里还有一个协议手续费开关。
Factory 里有:
address public feeTo;
address public feeToSetter;
Pair 里 _mintFee() 会读取:
address feeTo = IUniswapV2Factory(factory).feeTo();
bool feeOn = feeTo != address(0);
如果 feeTo 非零,说明协议手续费开启。
协议手续费的目标是:
从 LP 赚到的手续费增长中,分一部分给协议。
注意这个措辞:
不是从每笔 swap 输入中直接抽走
而是从池子增长中分一部分
Uniswap V2 选择的实现方式是:
当 k 增长时,给 feeTo mint 一些 LP Token。
这会让 feeTo 拥有池子的一部分份额。
5. kLast 是什么?
Pair 里有一个变量:
uint public kLast;
它记录的是上一次协议手续费结算时的:
reserve0 * reserve1
准确一点说,它在 mint() / burn() 后更新:
if (feeOn) kLast = uint(reserve0).mul(reserve1);
也就是说:
kLast 是协议手续费结算的基准。
为什么要记录 k?
因为在 Uniswap V2 中,LP 的手续费收益会体现在池子乘积 k 的增长上。
没有手续费时,在理想恒定乘积交易中:
x * y = k
交易前后 k 大致不变。
有手续费时,用户付入的手续费留在池子里,导致:
k 增大
所以 k 的增长可以用来衡量池子因为交易手续费变肥了多少。
更准确地说,源码里用的是:
sqrt(k)
也就是:
sqrt(reserve0 * reserve1)
因为 LP Token 的总供应量和 sqrt(k) 有对应关系。
6. 先看 _mintFee 源码
_mintFee() 是 Pair 里的私有函数:
function _mintFee(uint112 _reserve0, uint112 _reserve1)
private
returns (bool feeOn)
{
address feeTo = IUniswapV2Factory(factory).feeTo();
feeOn = feeTo != address(0);
uint _kLast = kLast;
if (feeOn) {
if (_kLast != 0) {
uint rootK = Math.sqrt(uint(_reserve0).mul(_reserve1));
uint rootKLast = Math.sqrt(_kLast);
if (rootK > rootKLast) {
uint numerator = totalSupply.mul(rootK.sub(rootKLast));
uint denominator = rootK.mul(5).add(rootKLast);
uint liquidity = numerator / denominator;
if (liquidity > 0) _mint(feeTo, liquidity);
}
}
} else if (_kLast != 0) {
kLast = 0;
}
}
这段是整个 V2 里比较绕的地方。
先别急着推公式,我们先看它在做什么。
7. _mintFee 的整体流程
压缩一下:
1. 从 Factory 读取 feeTo
2. feeTo != address(0),说明 feeOn
3. 读取旧的 kLast
4. 如果 feeOn 且 kLast 不为 0:
计算当前 rootK = sqrt(reserve0 * reserve1)
计算旧 rootKLast = sqrt(kLast)
如果 rootK 增长了:
根据增长量 mint 一些 LP Token 给 feeTo
5. 如果 feeOff 且 kLast 不为 0:
清空 kLast
一句话:
如果协议手续费开启,并且池子的 sqrt(k) 比上次结算时变大了,就给 feeTo 铸造 LP Token。
8. 为什么用 sqrt(k),而不是 k?
前面讲 mint() 时说过,首次流动性铸造 LP Token 的公式是:
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
也就是说,LP Token 的供应规模和:
sqrt(reserve0 * reserve1)
有关。
如果池子按比例扩大,比如:
reserve0 从 1000 到 2000
reserve1 从 1000 到 2000
那么:
k 从 1,000,000 到 4,000,000
sqrt(k) 从 1000 到 2000
LP Token 总供应量也应该大致翻倍。
所以在计算协议手续费时,用 sqrt(k) 更符合 LP 份额的增长尺度。
k 是面积。
sqrt(k) 更像池子的“线性规模”。
9. 什么时候调用 _mintFee?
_mintFee() 不是每笔 swap 都调用。
它在 mint() 和 burn() 里调用。
mint() 里:
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply;
burn() 里也是:
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply;
注意,swap() 里不会调用 _mintFee()。
为什么?
因为如果每次 swap 都结算协议手续费,会增加每笔交易 gas。
Uniswap V2 选择延迟结算:
swap 只让手续费留在池子里
等到下一次 mint 或 burn 时,再统一结算 protocol fee
这是一种 lazy accounting。
延迟记账,减少高频 swap 的成本。
10. 为什么 _mintFee 要在 totalSupply 之前调用?
在 mint() 和 burn() 里,顺序都是:
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply;
原因是:
_mintFee 可能会 mint LP Token 给 feeTo,从而改变 totalSupply。
后面用户的 LP 份额计算必须基于更新后的 totalSupply。
比如 burn() 里:
amount0 = liquidity.mul(balance0) / _totalSupply;
amount1 = liquidity.mul(balance1) / _totalSupply;
如果协议手续费开启,那么协议方应该先获得自己那部分 LP 份额。
然后用户再按稀释后的总供应量退出。
否则用户会多拿,协议手续费就收不到了。
11. feeOff 时为什么要清空 kLast?
源码最后:
} else if (_kLast != 0) {
kLast = 0;
}
意思是:
如果 feeTo 是零地址,说明协议手续费关闭。
如果之前 kLast 不是 0,就清掉。
为什么要清?
因为 kLast 是协议手续费计算基准。
如果 fee 关闭期间,池子发生大量交易,k 继续增长。
后来协议手续费重新开启。
如果不清空旧 kLast,协议可能会把“手续费关闭期间的增长”也算进去。
这不合理。
所以 feeOff 时清空 kLast。
重新开启后,会在下一次 mint/burn 之后设置新的 kLast。
12. protocol fee 的核心公式
最绕的是这几行:
uint numerator = totalSupply.mul(rootK.sub(rootKLast));
uint denominator = rootK.mul(5).add(rootKLast);
uint liquidity = numerator / denominator;
也就是:
liquidity =
totalSupply * (rootK - rootKLast)
/
(rootK * 5 + rootKLast)
这个公式用于计算应该给 feeTo mint 多少 LP Token。
它的目标是:
让协议拿到 LP 手续费收益的 1/6。
注意,不是拿总交易量的 1/6。
而是拿 LP 因手续费产生的增长收益的 1/6。
在 V2 的设计语境里,LP swap fee 是 0.3%。
协议手续费开启后,协议目标效果是分享其中的 1/6,约等于:
0.3% / 6 = 0.05%
LP 最终约保留:
0.25%
协议约拿:
0.05%
不过源码不是每笔 swap 直接拆成 0.25% 和 0.05%。
它是通过 _mintFee() 延迟 mint LP Token 实现。
13. 直觉理解这个公式
假设:
rootKLast = 上一次结算时池子的规模
rootK = 当前池子的规模
增长量是:
rootK - rootKLast
这部分增长代表池子因为手续费等原因变大了。
协议要拿其中的 1/6。
但协议不是直接拿走 token0/token1。
它是拿 LP Token。
所以需要计算:
应该 mint 多少 LP Token,才能让 feeTo 持有“增长收益的 1/6”?
这里有一个稀释问题。
如果直接 mint 新 LP Token 给 feeTo,总供应量会变大,原 LP 会被稀释。
所以公式不能简单写成:
totalSupply * (rootK - rootKLast) / rootK / 6
因为 mint 本身会改变分母。
源码里的:
rootK * 5 + rootKLast
就是把“mint 后总供应量变化”考虑进去后推出来的结果。
这行代码看起来魔法味很重,但背后就是:
用新铸造的 LP Token 表示协议应得的那部分池子增长。
14. 一个简化数字例子
假设某个 Pair 上次结算时:
rootKLast = 1000
totalSupply = 1000 LP
经过很多 swap,手续费留在池子里,现在:
rootK = 1100
说明池子规模增长了:
100
协议要拿增长收益的 1/6。
源码计算:
numerator = totalSupply * (rootK - rootKLast)
= 1000 * (1100 - 1000)
= 100,000
denominator = rootK * 5 + rootKLast
= 1100 * 5 + 1000
= 6,500
liquidity = 100,000 / 6,500
≈ 15.38 LP
所以会给 feeTo mint 大约:
15 LP
由于 Solidity 整数除法,会向下取整。
这 15 LP 不是从某个 LP 账户扣的。
它是新铸造出来的。
效果是:
feeTo 拥有了一小部分池子份额
原 LP 被轻微稀释
协议间接拿到了手续费增长的一部分
15. 为什么 protocol fee 不直接转 token?
这是核心问题。
协议完全可以每次 swap 时直接转:
0.05% 给 feeTo
0.25% 留给 LP
为什么 V2 不这么做?
主要有几个原因。
第一,减少 swap gas
swap 是最高频操作。
如果每次 swap 都额外计算协议手续费、转账给 feeTo,会让所有交易者都付更多 gas。
V2 选择:
swap 保持简单
协议手续费延迟到 mint/burn 时结算
这降低了高频路径成本。
第二,避免复杂记账
如果直接收 token,协议要在每个 Pair、每种 token 上处理收款。
而 Pair 里的资产有两种:
token0
token1
手续费增长可能以不同 token 形式体现。
直接转 token 会让逻辑复杂很多。
通过 mint LP Token,则非常统一:
不管池子里是什么资产
协议都拿这个 Pair 的 LP Token
以后协议想退出,可以自己 burn LP Token,拿到底层 token0/token1。
第三,和 LP 收益模型一致
LP 的收益本来就不是单独发钱,而是池子资产增长。
协议也用同样的方式拿收益:
LP 持有 LP Token
协议也持有 LP Token
这很一致。
第四,避免影响 swap 执行路径
swap() 只需要做 K 检查和 reserve 更新。
协议手续费不进入 swap 主路径,逻辑更干净。
16. protocol fee 是谁支付的?
从效果上看,协议手续费由 LP 支付。
因为 _mintFee() 是给 feeTo 新 mint LP Token。
这会稀释原有 LP 的份额。
举个简单例子。
原来:
totalSupply = 1000 LP
Alice 持有 1000 LP
协议手续费结算后:
feeTo 新获得 15 LP
totalSupply = 1015 LP
Alice 仍然持有 1000 LP
Alice 的份额从:
1000 / 1000 = 100%
变成:
1000 / 1015 ≈ 98.52%
协议拿到:
15 / 1015 ≈ 1.48%
所以 protocol fee 不是向 trader 额外收费。
trader 仍然按 0.3% 交易费交易。
只是这 0.3% 产生的收益中,有一部分通过 LP 稀释转给协议。
17. protocol fee 和 0.3% fee 的关系
普通情况下,feeTo 关闭:
trader 支付 0.3%
LP 获得全部 0.3%
协议获得 0
协议手续费开启后:
trader 仍然支付 0.3%
LP 最终约获得 0.25%
协议获得约 0.05%
所以不是:
trader 支付 0.35%
而是:
trader 支付 0.3%
LP 和协议分这 0.3%
这点非常容易混。
18. _mintFee 为什么用旧 reserve?
_mintFee() 的参数是:
_mintFee(_reserve0, _reserve1)
这里传的是旧 reserve。
在 mint() 和 burn() 里,调用 _mintFee() 的时机都在 _update() 之前。
例如 mint():
(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);
为什么用旧 reserve?
因为协议手续费要针对“上一次状态到现在之前已经发生的增长”结算。
在 mint() 里,用户刚刚转入的新流动性已经让 balance 变大了。
如果用 balance0/balance1 计算 rootK,就会把这次用户新增流动性也当成手续费增长。
这不对。
协议应该只从 swap fee 产生的增长里分成,不应该从用户新增本金里抽成。
所以 _mintFee() 使用旧 reserve。
这非常关键。
19. mint 里的顺序为什么这么重要?
我们把 mint() 中相关顺序拿出来:
(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;
// 计算用户 liquidity
...
_mint(to, liquidity);
_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1);
关键点:
1. 用户转入的新增流动性体现在 balance 里
2. _mintFee 用旧 reserve,避免把新增流动性当手续费
3. _mintFee 后读取 totalSupply,确保用户 LP 计算考虑协议 LP
4. 用户 LP mint 完后,_update 到新 balance
5. 最后更新 kLast,作为下次协议费基准
顺序非常精细。
这种源码就是不能只看“每行干什么”,还要看“为什么在这个位置”。
20. burn 里的顺序也一样重要
burn() 里类似:
(uint112 _reserve0, uint112 _reserve1,) = getReserves();
uint balance0 = IERC20(_token0).balanceOf(address(this));
uint balance1 = IERC20(_token1).balanceOf(address(this));
uint liquidity = balanceOf[address(this)];
bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply;
amount0 = liquidity.mul(balance0) / _totalSupply;
amount1 = liquidity.mul(balance1) / _totalSupply;
_burn(address(this), liquidity);
_safeTransfer(_token0, to, amount0);
_safeTransfer(_token1, to, amount1);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1);
这里 _mintFee() 在用户计算可取回资产之前执行。
效果是:
如果 protocol fee 应该收,就先 mint LP 给 feeTo
然后用户 burn 时按新的 totalSupply 计算份额
这让协议手续费通过稀释实现。
21. 为什么 swap 不更新 kLast?
swap() 结束后只做:
_update(balance0, balance1, _reserve0, _reserve1);
没有:
kLast = reserve0 * reserve1;
为什么?
因为如果每次 swap 后都更新 kLast,那 rootK - rootKLast 就几乎没有累积增长了。
协议手续费没法延迟结算。
kLast 的意义是:
上一次协议手续费结算基准
而不是:
每次 swap 后的最新 k
所以只有在 mint/burn 结算协议手续费之后,才更新 kLast。
22. feeTo 开启后,第一笔 mint/burn 会怎样?
假设之前协议手续费关闭,kLast = 0。
后来 Factory 设置:
feeTo = someAddress;
此时 feeOn = true。
下一次 mint() 或 burn() 调用 _mintFee():
if (feeOn) {
if (_kLast != 0) {
...
}
}
因为 _kLast == 0,所以不会 mint 协议 LP。
然后函数末尾:
if (feeOn) kLast = uint(reserve0).mul(reserve1);
设置新的基准。
也就是说:
开启 protocol fee 后,先建立 kLast 基准;
之后的增长才会被用于协议手续费结算。
这避免协议追溯收取开启之前的收益。
23. feeTo 关闭后会怎样?
假设之前开启,kLast 有值。
后来 Factory 设置:
feeTo = address(0);
下一次 mint() 或 burn() 时:
feeOn = false;
于是执行:
} else if (_kLast != 0) {
kLast = 0;
}
把 kLast 清空。
这意味着关闭期间不会累积协议手续费基准。
以后再开启,会重新建立新基准。
24. k 增长一定都是手续费吗?
严格来说,不一定。
k 增长可能来自:
1. swap 手续费
2. 有人直接给 Pair 转 token
3. 某些特殊 token 行为
Uniswap V2 的设计并不区分这些来源。
只要在协议手续费开启期间,sqrt(k) 相比 kLast 增长了,_mintFee() 就可能 mint 协议 LP。
但在正常路径中,主要增长来源是 swap fee。
这也是为什么直接给 Pair 捐 token,会提高 LP 份额价值,并可能影响 protocol fee 计算。
25. protocol fee 对普通 trader 有什么影响?
普通交易者感受到的 swap fee 仍然是 0.3%。
getAmountOut() 里还是:
amountInWithFee = amountIn.mul(997);
swap() 里还是:
balanceAdjusted = balance * 1000 - amountIn * 3
也就是说,trader 不需要关心 feeTo 是否开启。
交易报价不变。
变化的是 LP 的收益分配。
如果 protocol fee 开启,LP 会被协议 LP 稍微稀释。
26. protocol fee 对 LP 有什么影响?
LP 的总交易费来源仍然是 0.3%。
但开启 protocol fee 后,协议会拿走手续费收益的 1/6。
所以 LP 的有效收益从:
0.3%
变成约:
0.25%
当然,这不是每笔交易直接扣。
它通过延迟 mint LP Token 的方式体现。
LP 在 burn() 退出时,按稀释后的份额拿资产。
27. 用一句话理解 _mintFee
如果你只记一句,记这个:
_mintFee 通过比较当前 sqrt(k) 和上次 kLast 的 sqrt,估算池子因手续费产生的增长,并把其中约 1/6 以 LP Token 的形式 mint 给 feeTo。
更短:
protocol fee = 延迟铸造 LP Token。
不是转 token。
不是每笔 swap 扣。
不是额外向 trader 收费。
而是从 LP 手续费收益里分一份。
28. 这一篇的源码直觉
到这里,手续费机制就比较完整了。
把两个层次再合起来看:
swap 时:
0.3% fee 通过 adjusted balance 体现在 K 检查里。
手续费直接留在池子中。
mint / burn 时:
如果 feeTo 开启,_mintFee 根据 sqrt(k) 增长 mint LP 给 feeTo。
这让协议分享 LP 收益的一部分。
一条线看 LP 手续费:
trader 输入 token
↓
只有 99.7% 参与兑换计算
↓
0.3% 留在池子里
↓
池子资产变多
↓
LP Token 更值钱
一条线看 protocol fee:
swap 手续费让 k 增长
↓
mint/burn 时调用 _mintFee
↓
比较 sqrt(k) 与 sqrt(kLast)
↓
给 feeTo mint LP Token
↓
协议获得池子份额
↓
原 LP 被轻微稀释
小结
这一篇我们拆了 Uniswap V2 的手续费机制。
现在你应该能回答:
1. 0.3% 交易手续费在哪里实现?
在 swap 的 adjusted balance K 检查里。
2. LP 为什么能赚手续费?
手续费留在池子里,LP Token 代表池子份额,退出时自然分到。
3. feeTo 是什么?
Factory 里的协议手续费接收地址。非零表示 protocol fee 开启。
4. kLast 是什么?
上次协议手续费结算时的 reserve0 * reserve1。
5. _mintFee 什么时候调用?
mint 和 burn 时调用,swap 不调用。
6. 为什么 protocol fee 不每笔 swap 结算?
为了降低高频 swap 的 gas,并简化记账。
7. 为什么 _mintFee 使用 sqrt(k)?
因为 LP Token 供应规模和 sqrt(reserve0 * reserve1) 对应。
8. protocol fee 怎么收?
通过给 feeTo mint LP Token,稀释原 LP。
9. protocol fee 是 trader 额外支付的吗?
不是。trader 仍支付 0.3%,协议从 LP 收益中分 1/6。
10. feeOff 时为什么清空 kLast?
防止协议后来重新开启时追溯收取关闭期间的增长。