Uniswap V2 源码学习第九篇:手续费机制,0.3% 交易费和 protocol fee 是怎么实现的?

tomenengr 发布于 2026-05-11 阅读 268

手续费机制

前面我们已经把主交易路径走通了:

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?
    防止协议后来重新开启时追溯收取关闭期间的增长。

相关文章

0 条评论