Uniswap V2 源码学习 (三). 手续费和交易池估值

2022-05-25 16:00:39 浏览数 (2)

本文作者:tonyh[1]

前面我们已经大致了解了 uniswap 的交易算法[2], 今天我们一起看看 Uniswap 手续费是怎么计算的

可能很多读者认为手续费计算并不重要, 因为手续费对于用户而言就是扣掉千分之三的 input 而已, 没什么难度. 但是我在阅读 pair 的 mintFee 函数时, 一开始有些看不懂, 琢磨了两三天才把它的逻辑搞明白, 所以今天就跟大家分享一下心得体会, 实际上平台的协议手续费收取算法是比较有意思的内容, 我们通过对手续费计算过程的学习, 可以窥探系统设计者背后的设计思路, 以及代码实现中用到的 gas 优化技巧

手续费的产生

假设 swap 前两个代币数量为 A1, B2, swap 后为 A2, B2

前面已经有介绍, 在不收手续费的情况下, swap 前后满足 A * B = K 不变.

但是在收取手续费的情况下, 实际的有效输入是 effectiveInput = amountIn _ 0.997, 这部分有效输入 effectiveInput 进行 swap 交易后满足交易后的 A2' _ B2' = A1 * B1

但是实际的输入量是 effectiveInput _ 1000 / 997, 这样池子里真实的 A2 _ B2 将会大于原有的 A * B, 池子的财富增加了 在 LP 代币总量不变的情况下, 每个 LP 持有人分享到的财富就会增加, 这个增量就是手续费

下面是 core/UniswapV2Pair.sol 中的 swap 的代码片段:

代码语言:javascript复制
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
    ...
    { // scope for reserve{0,1}Adjusted, avoids stack too deep errors
        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');
    }
    ...
}

上面的代码中, 池子要求 扣掉 3/1000 的输入后, 仍然满足 A2'B2' >= A1B1 而真实的 A2, B2 中至少有一个满足 A2 = A2' input _ 3/1000 或者 B2 = B2' input _ 3/1000, 因此 A2B2 > A1B2, 池子的总财富得到增加

做市商(LP)的手续费 下面的讨论中, 用 lp 表示部分 pair 代币, lp_supply 表示 pair 代币总量 做市商的手续费不需要计算, 在池子财富增加的情况下, lp 代币总量不变, 那么每个做市商持有的 lp 将会分到更多的 tokenA 和 tokenB, 在 removeLiquidity 时, 根据 lp/lp_supply 这个比值获得更多的 tokenA 和 tokenB

平台收取的协议手续费 平台收取的手续费占手续费产生的财富增量的 1/6, 如果开启协议手续费, 那么做市商收取 2.5/1000, 平台收取 0.5/1000

平台手续费是通过定向增发 lp 给项目方的 feeTo 账户实现的

那么问题来了, 这个增发量应该是多少呢?

相信很多同学在看 _mintFee 这个函数的时候, 和我开始拿到代码一样没有看明白,

所以这是我们今天介绍的重点.

首先我们假设最简单的情况: 在一系列 swap 后, 池子的财富 tokenA 和 tokenB 是等比例增加的, 例如: A 和 B 都增加了 12%, 那么很简单, feeTo 账户将会得到两个代币增量的 1/6 , 即总量的 2/112, 因此发行的 lp = lp_supply * 2/110, 这样增发之后 , 平台方持有的 lp/新的 lp_supply 刚好等于 2/112

但是如果 A 和 B 不是等比例增加, 应该发多少 lp 给项目方呢? 例如 A 增加了 10%, B 增加了 20%, 那么应该分配多少的 tokenA 和 tokenB 给项目方? 15%, 还是 12%, or 18% ??? 注意 lp 增发的分配方式下, 项目方得到 tokenA 和 tokenB 的数量, 相对于库存 A, B 应该是相同的比例, 不可能 得到 10%/6 的 tokenA, 20% /6 的 tokenB, 只能是 x% 的 tokenA 和 tokenB

那么这个 x% 应该是多少?

也就是说, 当池子中 A 和 B 不是等比例增加的时候, 应该认为池子的财富增加了多少 ? 假设在状态 1, 池子中的代币余额为 A1, B1, 此时规定池子的财富是 w1 经过一系列交换后, 到达状态 2, 此时代币余额为 A2, B2, (假设 A2/A1 != B2/B1, 两个代币不是等比例增加) 那么我们认为池子的财富 w2 是多少?

这个问题对于手续费计算很重要, 因为只有定义了池子中财富度量的标准, 我们才能计算出财富增加的比例, 从而 mint 正确的 lp 代币 作为协议手续费

交易池的财富度量: rootK

下面的讨论中, 我们用 A, B 表示交易池的两个代币数量, w 表示交易池的财富值

现在我们需要制定一个度量标准 f, 并规定交易池的财富值 w = f(A,B)

只有制定了这样的度量标准, 我们才能计算出任意时刻交易池的财富值 w, 进而计算出需要 mint 给项目方的 lp : lp = lp*supply * (w2 - w1) _ (1/6) / w1

可能阅读过 Uniswap 源代码的同学可能已经知道了, f 的定义方法, 就是取两个代币数量的几何平均值 sqrt(A*B) 这个值我们将他命名为 rootK

但是为什么是 rootK? 为什么是几何平均值, 而不是代数平均值 (A B)/2, 或者为什么不直接取 A*B ?

接下来我们将会证明, 为什么 rootK 可以作为, 而且必须是 rootK(或者 rootK * 常系数)作为交易池的财富度量.

首先我们规定两条公理:

下面是推导过程:

问题: 假设交易池在初始状态 A1, B1 的时候, 规定财富是 w1, 经过一系列 swap 交易后, 交易池的代币数量为 A2, B2, 且 A2/A1 != B2/B1 求此状态下财富值 w2 = ?

证明: 现在我们邀请一名交易者来做 swap, 让他用 A2/A1 和 B2/B1 中比值较小的币种, 换取比值较大的币种, 确保交易之后的 A3, B3 刚好满足 A3/A1 = B3/B1, 这笔交易不收手续费

根据前面的第二条假设前提 , 状态 2 到状态 3 财富不变. w3 = w2

同时又可以根据第一条等比例原则, 得出等式: w3 / w1 = A3 / A1

但是由于不知道 A3 的值, 还不能直接算出 w3 不过由于 A3/A1 = B3/B1, 所以可以得到 w3/w1 = sqrt[ (A3B3) / (A1B1) ]

将 w3 换成 w2, A3B3 换成 A2B2, 就可以计算出了 w2 的值: w2/w1 = sqrt(A2B2) / sqrt(A1B2)

可以看到, 财富值 w 的度量要满足前面两个前提条件, 只能是 sqrt(A*B)的常数倍, 为了简化, 这个常数就取为 1.

因此, Uniswap 定义的交易池财富值度量值 w = sqrt(A*B), 这个值在代码里的变量名为 rootK

  • 如果 A, B 是等比例增加, 那么财富值也按照相同的比例增加: 即 w2/w1 = A2/A1 , 同时 w2/w1 = B2/B1 这一点很好理解, 只有成比例增加, 才能确保 lp 持有人分到的财富是公平的, 否则先撤销流动性和后撤销流动性得到的代币不相等.

kLast 变量 和协议手续费的算法

平台的协议手续费, 在每次 addLiquidity 和 removeLiquidity 之前征收.

代码语言:javascript复制
// if fee is on, mint liquidity equivalent to 1/6th of the growth in sqrt(k)
function _mintFee(uint112 _reserve0, uint112 _reserve1) private returns (bool feeOn) {
    address feeTo = IUniswapV2Factory(factory).feeTo();
    feeOn = feeTo != address(0);
    uint _kLast = kLast; // gas savings
    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;
    }
}

可能很多朋友有疑问, 上面的代码中, mint 的数量 为什么是 (rootK - rootKLast)/(5 倍 rootK 1 倍 rootKLast) 而不是 (rootK - rootKLast)/6 倍 rootKLast 呢? 别急, 我们通过下图, 看看要增发多少 lp, 使得 lp 分到的财富刚好是增量的 1/6:

如上图所示, 为了得到新增财富的 1/6, 需要增发的 lp 应该满足: lp/lp_supply = (∆/6) / [(∆5/6) rootKLast ], 这里 ∆ = rootK - rootKLast 解出 lp = lp_supply * ∆ / (5rootK rootKLast), 与源代码的计算方法一致, 证实了 Uniswap 收取的协议手续费就是总手续费的 1/6.

手续费的记录和结算:

为了记录手续费, UniswapV2Pair 使用了一个变量 kLast, 用来记录最后一次结算后的 K 值 (reserve0 * reserve1)

我们记录手续费真正需要的是 rootK - rootKLast 但是为什么记录的是 kLast 而不是 rootKLast 呢? 为了节省 gas,

只有当程序检查到 当前 K > kLast 时 , 才会执行开方运算, 计算出 rootK - rootKLast 如果当前 K 和之前的 kLast 一致, 那就没必要开方.

所以记录手续费虽然真正关注的是 rootK 的差值, 但是保存的变量是 kLast.

需要注意的一点是, K 值不仅包含了手续费产生的财富增量, 他还会受到 addLiquidity 和 removeLiquidity 的影响, 如果上次记录 kLast 后,发生了添加/撤销流动性事件, 那么交易池的财富增量包含了添加流动性的增量,手续费产生的增量, 同时还会受到撤销流动性的抵消, 这样就无法正确的追踪 "因手续费而产生的财富增量了", 那要怎么解决呢.

办法就是在任何添加/撤销流动性之前把迄今为止的手续费结清, 在流动性操作结束后重新开始记录 K 值, 这样我们每次结算时 rootK - rootKLat 就不存在加减流动性产生的变化了, 全部都是手续费产生的增量.

因此, 可以看到在 Pair 合约的 mint() 和 burn()中, 每次添加/撤销流动性之前, 都会调用 _mintFee() 结算协议手续费, 而在函数的最后都有一条语句重新记录 kLast:

代码语言:javascript复制
// this low-level function should be called from a contract which performs important safety checks
function mint(address to) external lock returns (uint liquidity) {
    ...
    /****************************************************************************
     *  流动性操作之前, 结清迄今为止产生的手续费
     ****************************************************************************/
    bool feeOn = _mintFee(_reserve0, _reserve1);

    ...
    //按照输入的 balance-reserve, mint 代币给 to(即项目方钱包)
    ...

    _update(balance0, balance1, _reserve0, _reserve1);

    /****************************************************************************
     *  流动性操作结束后, 重新记录 kLast, 使得rootK - rootKLast 始终不受增减流动性的影响
     ****************************************************************************/
    if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
    emit Mint(msg.sender, amount0, amount1);
}

那么为什么不使用更直观的方式记录手续费呢? 比如下面我们用一个变量记录 "迄今为止未结算手续费"

代码语言:javascript复制
uint256 public cumulatedFee;

function swap(xxx, xxx, xxx ...){

    uint256 rootKBefore = sqrt(reserve0 * reserve1);

    ...
    //执行swap交易
    ...

    _update(balance0, balance1, _reserve0, _reserve1);

    cumulatedFee  = ( sqrt(reserve0 * reserve1) - rootKBefore );

}

虽然上面的代码可以更直观的方式实现手续费结算, 但是, 每次 swap 需要读取和存储一次 cumulatedFee, 计算两次开平方, 通常 swap 执行的次数要远大于添加/撤销流动性的次数, 从 gas 经济性考虑, 使用前面的 kLast 方法更好.

好的, 今天我们一起分析了 Uniswap 的手续费计算方法和代码实现细节, 相信大家应该对 Uniswap 的手续费算法有了更加深入的理解, 我们下期再见!

作者 mail:star4evar@gmail.com

参考资料

[1]

tonyh: https://learnblockchain.cn/people/8619

[2]

uniswap 的交易算法: https://learnblockchain.cn/article/3952

0 人点赞