Uniswap v3 白皮书与代码解析

  • wongssh
  • 发布于 13小时前
  • 阅读 56

本文深入解析了Uniswap V3的核心代码和白皮书,详细介绍了其架构创新,包括多费率池、区间流动性、非同质化流动性的实现,以及Ticks机制、流动性计算、手续费模型和Swap交易流程的数学原理与Solidity代码实现。文章通过代码片段和数学公式,探讨了V3相较于V2的复杂性和精妙之处。

概述

在上一篇内容中,我们介绍 Uniswap v2 的代码,在本篇内容中,我们将介绍 Uniswap v3 的源代码。但是由于 Uniswap v3 的代码极其庞大,所以我们无法像介绍 v2 那样逐行进行分析,本文主要从阅读 白皮书 及相关代码的视角展开。

假如读者希望体验自己动手实现 Uniswap v3 的话,建议观看此 Youtube 系列视频。笔者就是依靠该系列视频完整学习了 Uniswap v3 的代码。另一个学习 Uniswap v3 的材料是笔者之前编写的博客 现代 DeFi: Uniswap V3

ARCHITECTURAL CHANGES

在白皮书内,该节主要介绍了 Uniswap v3 相比于 v2 在架构上的创新,主要介绍了以下内容:

  1. 允许 Pair 存在多个不同费率的 Pool。在 v2 中,每一个 Pair 只有一个硬编码的费率
  2. 区间流动性提供

Multiple Pools Per Pair

在 Uniswap v2 中,我们使用如下代码直接收取流动性手续费,此处的手续费比例 $25 \text{ bps}$ 是硬编码的,我们无法在 $factor$ 内进行设置。

uint256 balance0Adjusted = balance0 * 10000 - amount0In * 25

但在 Uniswap v3 中,我们可以在 contracts/UniswapV3Factory.sol 内看到 createPool 内的实现已经包含了 $fee$ 字段:

/// @inheritdoc IUniswapV3Factory
function createPool(
    address tokenA,
    address tokenB,
    uint24 fee
) external override noDelegateCall returns (address pool) {
    require(tokenA != tokenB);
    (address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
    require(token0 != address(0));
    int24 tickSpacing = feeAmountTickSpacing[fee];
    require(tickSpacing != 0);
    require(getPool[token0][token1][fee] == address(0));
    pool = deploy(address(this), token0, token1, fee, tickSpacing);
    getPool[token0][token1][fee] = pool;
    // populate mapping in the reverse direction, deliberate choice to avoid the cost of comparing addresses
    getPool[token1][token0][fee] = pool;
    emit PoolCreated(token0, token1, fee, tickSpacing, pool);
}

此处需要注意 $fee$ 并不是可以任意指定的,而是与 $tickSpacing$ 是挂钩的。$tickSpacing$ 实际上代表了添加区间流动性时的最小价格间隔,简单来说,$tickSpacing$ 越小,那么用户添加流动性的颗粒度越高,用户可以选择流动性上下限更加精确,比如 $tickSpacing = 10$,那么用户可以选择作为流动性区间边界的 ticks 值位于 $(\dots, -20, -10, 0, 10, 20, \dots)$ 内,而 $tickSpacing = 1$,那么用户可以选择的 ticks 位于 $(\dots, -2, -1, 0, 1, 2, \dots)$ 范围内。但是反之在 Uniswap v3 进行 swap 时,由于 $tickSpacing$ 较小,那么可以被放置流动性的点位就更多,所以 v3 需要循环更多次以搜索可用的流动性。

一般来说,$tickSpacing$ 越小,意味着预期 Pair 内的价格变动越小,比如 $tickSpacing = 1$ 一般被用于稳定币交易对。价格波动小,流动性提供者应该获得更低的手续费,所以 Uniswap v3 在 Factory 内硬编码了几个 $tickSpacing$$fee$ 的对应关系,具体如下表(数据来源为 Unisawp 文档):

Fee Fee Value Tick Spacing
0.01% 100 1
0.05% 500 10
0.30% 3000 60
1.00% 10_000 200

对于 createPool 的具体实现,读者可以自行阅读相关代码,与 uniswap v2 一致,v3 也使用了 create2 确定性地址部署方法,部署的具体代码位于 UniswapV3PoolDeployer 内部:

parameters = Parameters({factory: factory, token0: token0, token1: token1, fee: fee, tickSpacing: tickSpacing});
pool = address(new UniswapV3Pool{salt: keccak256(abi.encode(token0, token1, fee))}());

这意味着任何代币 Pair 的 v3 地址都可以被预先计算出来,这对于一些创建 Pair 后进行自动交易的协议产生了威胁,比如 Four.meme 在过去使用了 v3 作为代币发射后的交易平台,但是由于代币 Pair 地址可以预先计算,所以黑客在 foure.meme 自动部署 Pair 前抢先部署了 Pair 然后使用不合理价格初始化,导致 four.meme 转移流动性时出现损失,具体可以 DeFiHackLabs 的 PoC

关于具体的 $tickSpacing$ 的使用和遍历方法,我们会在后文内介绍 ticks 概念及相关代码时一并分析。

Non-Fungible Liquidity

非同质的流动性其实就是区间流动性的另一种表达方法,但是需要注意在 Uniswap v3 的核心合约内并不存在 ERC721 的部分,大家经常看到的使用 ERC721 表示区间流动性其实只是一个辅助功能,相关代码位于 v3-peripherNonfungiblePositionManager 内部。

在 v3-core 内部,用户提供的流动性被存储在 positions 状态变量内部,该状态变量的定义是:

mapping(bytes32 => Position.Info) public override positions;

关于上述 mapping 内 bytes32 含义,我们可以参考 contracts/libraries/Position.sol 内的 get 函数:

function get(
    mapping(bytes32 => Info) storage self,
    address owner,
    int24 tickLower,
    int24 tickUpper
) internal view returns (Position.Info storage position) {
    position = self[keccak256(abi.encodePacked(owner, tickLower, tickUpper))];
}

通过上述 get 函数,我们可以看到在 v3-core 内,我们通过 $owner$ / $tickLower$$tickUpper$ 确定某一个用户的某一个流动性头寸。而 Position.Info 的具体定义为:

struct Info {
    // the amount of liquidity owned by this position
    uint128 liquidity;
    // fee growth per unit of liquidity as of the last update to liquidity or fees owed
    uint256 feeGrowthInside0LastX128;
    uint256 feeGrowthInside1LastX128;
    // the fees owed to the position owner in token0/token1
    uint128 tokensOwed0;
    uint128 tokensOwed1;
}

此处的 $liquidity$ 代表用户持有的流动性数量,我们常使用 $liquidity$ 计算对应的 $token0$$token1$ 的数量。$feeGrowthInside0LastX128$$feeGrowthInside1LastX128$ 都是用于计算 LP 应获得的手续费收入的,该部分逻辑较为复杂,我们会在后文介绍手续费逻辑时详细介绍。而 $tokensOwed0$$tokensOwed1$ 代表实际的手续费,每次更新 LP 头寸时,合约会自动使用 $feeGrowthInside0LastX128$$feeGrowthInside1LastX128$ 计算手续费收入,然后累加到 $tokensOwed0$$tokensOwed1$ 内部。

我们首先回到 Info 结构体内的 $liquidity$ 计算。该部分计算由 contracts/libraries/SqrtPriceMath.sol 内的 getAmount0DeltagetAmount1Delta 完成。这两个函数基本都是纯粹的数学函数,具体形式如下:

Uniswap v3 liquidity to amount delta

上述的 $$i_c$$ 是当前 Pair 内的价格(即 slot0.tick),而 $$i_l$$ 和 $$i_u$$ 分别代表 $tickLower$$tickUpper$。限于篇幅,我们无法介绍这两个函数背后依赖的数学公式的推导流程,读者可以参考 LIQUIDITY MATH IN UNISWAP V3 这篇论文内的推导。读者可能会注意到对于上述公式中 $\Delta Y$ 而言,$i_c \ge i_u$ 可以被视为 $i_l \le i_c < i_u$ 的一种特殊情况,即 $P = p(i_u)$ 的情况。对于 $\Delta X$ 而言同理,这就是为什么在 SqrtPriceMath 内部只存在两个函数 getAmount0DeltagetAmount1Delta,而不是存在四个函数。

基于上述知识,读者不难看懂 _modifyPosition 内的核心逻辑:

function _modifyPosition(ModifyPositionParams memory params)
    private
    noDelegateCall
    returns (
        Position.Info storage position,
        int256 amount0,
        int256 amount1
    )
{
    checkTicks(params.tickLower, params.tickUpper);

    Slot0 memory _slot0 = slot0; // SLOAD for gas optimization

    position = _updatePosition(
        params.owner,
        params.tickLower,
        params.tickUpper,
        params.liquidityDelta,
        _slot0.tick
    );

    if (params.liquidityDelta != 0) {
        if (_slot0.tick < params.tickLower) {
            // current tick is below the passed range; liquidity can only become in range by crossing from left to
            // right, when we'll need _more_ token0 (it's becoming more valuable) so user must provide it
            amount0 = SqrtPriceMath.getAmount0Delta(
                TickMath.getSqrtRatioAtTick(params.tickLower),
                TickMath.getSqrtRatioAtTick(params.tickUpper),
                params.liquidityDelta
            );
        } else if (_slot0.tick < params.tickUpper) {
            // current tick is inside the passed range
            uint128 liquidityBefore = liquidity; // SLOAD for gas optimization

            // write an oracle entry
            (slot0.observationIndex, slot0.observationCardinality) = observations.write(
                _slot0.observationIndex,
                _blockTimestamp(),
                _slot0.tick,
                liquidityBefore,
                _slot0.observationCardinality,
                _slot0.observationCardinalityNext
            );

            amount0 = SqrtPriceMath.getAmount0Delta(
                _slot0.sqrtPriceX96,
                TickMath.getSqrtRatioAtTick(params.tickUpper),
                params.liquidityDelta
            );
            amount1 = SqrtPriceMath.getAmount1Delta(
                TickMath.getSqrtRatioAtTick(params.tickLower),
                _slot0.sqrtPriceX96,
                params.liquidityDelta
            );

            liquidity = LiquidityMath.addDelta(liquidityBefore, params.liquidityDelta);
        } else {
            // current tick is above the passed range; liquidity can only become in range by crossing from right to
            // left, when we'll need _more_ token1 (it's becoming more valuable) so user must provide it
            amount1 = SqrtPriceMath.getAmount1Delta(
                TickMath.getSqrtRatioAtTick(params.tickLower),
                TickMath.getSqrtRatioAtTick(params.tickUpper),
                params.liquidityDelta
            );
        }
    }
}

对于 _updatePosition 函数,该函数内部存在一些与流动性手续费计算的内容,在该函数内完成了上文介绍的 $feeGrowthInside0LastX128$ / $feeGrowthInside1LastX128$ 计算后累加到 $tokensOwed0$$tokensOwed1$ 的过程,在后文分析手续费逻辑时,我们会再次讨论。

对于后续的 $liquidityDelta$$amount0$$amount1$ 的转换,结合上述公式,读者应该可以理解。比如对于 _slot0.tick < params.tickLower$i_c < i_l$ 的情况,此时 $\Delta Y = 0$ 即 $amount1 = 0$,我们只需要调用函数计算 $amount0$ 的数值。对于 else if (_slot0.tick < params.tickUpper),该分支意味着 $i_l \le i_c < i_u$,所以我们需要分别计算 $amount0$$amount1$ 的数值。

上述函数中的 getSqrtRatioAtTick 是将 $tick$ 转化为 $$p$$ 的函数,本质上实现了以下数学公式: $$p(i) = 1.0001^i \quad \text{and} \quad \sqrt{p(i)} = 1.0001^{i/2}$$ 实际上这也是 tick 与价格之间关系。我们会经常使用 getSqrtRatioAtTickgetTickAtSqrtRatio 函数,前者用于 $tick$ 转化价格(后文所有的价格均指 $$p$$ ,这是因为 uniswap v3 内不存在其他形式的价格),而后者用于价格转化为 $tick$

在 Uniswap v3 内,与流动性直接相关的函数包括:

  1. mint 函数用于添加流动性
  2. burn 函数用于提取流动性及 LP 获得的手续费
  3. collect 函数用于提取 LP 手续费

其中 collect 函数实现最为简单,依赖上文介绍的 Position.Info 内的 $tokensOwed0$$tokensOwed1$ 字段,代码如下:

function collect(
    address recipient,
    int24 tickLower,
    int24 tickUpper,
    uint128 amount0Requested,
    uint128 amount1Requested
) external override lock returns (uint128 amount0, uint128 amount1) {
    // we don't need to checkTicks here, because invalid positions will never have non-zero tokensOwed{0,1}
    Position.Info storage position = positions.get(msg.sender, tickLower, tickUpper);

    amount0 = amount0Requested > position.tokensOwed0 ? position.tokensOwed0 : amount0Requested;
    amount1 = amount1Requested > position.tokensOwed1 ? position.tokensOwed1 : amount1Requested;

    if (amount0 > 0) {
        position.tokensOwed0 -= amount0;
        TransferHelper.safeTransfer(token0, recipient, amount0);
    }
    if (amount1 > 0) {
        position.tokensOwed1 -= amount1;
        TransferHelper.safeTransfer(token1, recipient, amount1);
    }

    emit Collect(msg.sender, recipient, tickLower, tickUpper, amount0, amount1);
}

burn 函数复杂度也很低,代码如下:

function burn(
    int24 tickLower,
    int24 tickUpper,
    uint128 amount
) external override lock returns (uint256 amount0, uint256 amount1) {
    (Position.Info storage position, int256 amount0Int, int256 amount1Int) =
        _modifyPosition(
            ModifyPositionParams({
                owner: msg.sender,
                tickLower: tickLower,
                tickUpper: tickUpper,
                liquidityDelta: -int256(amount).toInt128()
            })
        );

    amount0 = uint256(-amount0Int);
    amount1 = uint256(-amount1Int);

    if (amount0 > 0 || amount1 > 0) {
        (position.tokensOwed0, position.tokensOwed1) = (
            position.tokensOwed0 + uint128(amount0),
            position.tokensOwed1 + uint128(amount1)
        );
    }

    emit Burn(msg.sender, tickLower, tickUpper, amount, amount0, amount1);
}

在此处,我们需要补充在上文内没有介绍的 $liquidityDelta$int128 类型,数值为正数代表流动性添加,而为负数代表流动性减少。注意,即使包含符号,上述给出的所有数学公式也是成立的。在 Uniswap v3 和 Uniswap v4 中,开发团队经常使用 int 类型并使用正负代表不同的含义,以此简化数学计算。比如在 Uniswap v3 中,另一个重要的存在符号的变量是 swap 函数中的 $amountSpecified$,其符号含义可以从 bool exactInput = amountSpecified > 0; 代码内看出。该数值为正数,代表 exactInput 模式,即用户指定输入代币的数量,要求 Pool 计算输出代币的数量;与之相反的是 exactOut 模式,即用户给定输出代币的数量,要求 Pool 计算输入代币的数量。另外,swap 函数的输出值 amount0amount1 也是包含符号的,其含义为假如 $amount < 0$,那么合约就会将对应数量的 $token1$ 发送给用户,代码如下:

if (zeroForOne) {
    if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));

上述代码内的 $zeroForOne$ 也是 swap 的重要入参,用于指定代币兑换的情况,比如 $zeroForOne = \text{true}$ 意味着用户希望给出输入 token 0 并获得 token 1 代币。这意味着 $zeroForOne$ 是对用户而言,站在 Pool 的角度,$zeroForOne$ 反而意味着输出 token 0 代币并从用户处获得 token 1 代币。对于最初接触 Uniswap v3 代码的读者,由于长时间阅读 Pool 合约,很容易站在 Pool 的角度思考问题,对于 $zeroForOne$ 的含义时常搞反。

最后,我们介绍稍微复杂一些的 mint 函数,该函数体现了 Uniswap v3 内是如何处理代币转移问题的:

function mint(
    address recipient,
    int24 tickLower,
    int24 tickUpper,
    uint128 amount,
    bytes calldata data
) external override lock returns (uint256 amount0, uint256 amount1) {
    require(amount > 0);
    (, int256 amount0Int, int256 amount1Int) =
        _modifyPosition(
            ModifyPositionParams({
                owner: recipient,
                tickLower: tickLower,
                tickUpper: tickUpper,
                liquidityDelta: int256(amount).toInt128()
            })
        );

    amount0 = uint256(amount0Int);
    amount1 = uint256(amount1Int);

    uint256 balance0Before;
    uint256 balance1Before;
    if (amount0 > 0) balance0Before = balance0();
    if (amount1 > 0) balance1Before = balance1();
    IUniswapV3MintCallback(msg.sender).uniswapV3MintCallback(amount0, amount1, data);
    if (amount0 > 0) require(balance0Before.add(amount0) <= balance0(), 'M0');
    if (amount1 > 0) require(balance1Before.add(amount1) <= balance1(), 'M1');

    emit Mint(msg.sender, recipient, tickLower, tickUpper, amount, amount0, amount1);
}

我们可以看到与 Uniswap v2 一致,在 Uniswap v3 内,我们仍使用了 Callback 的方法通知交易者支付资产,然后利用余额判断用户输入的代币数量是否足够。

IMPLEMENTING CONCENTRATED LIQUIDITY

我们跳过了白皮书内关于治理和 Oracle 的两节内容,对于治理部分,这部分与核心功能关联较少,而 Oracle 部分其实被大量嵌入了核心的代码,但是 Oracle 部分稍微有一些复杂,我们可能会在未来单独编写一篇文章介绍,但假如读者对如何在 Uniswap v3 基础上实现流动性挖矿感兴趣,Oracle 部分是一定要读的。

本节内容在白皮书内主要介绍 Uniswap v3 的区间流动性提供是如何实现的,主要介绍了以下内容:

  1. Tick 和 Tick Spacing 机制,在上文稍有介绍,但在本节中,我们要解决如何查找下一个 $tick$ 的任务
  2. 流动性计算机制,介绍 Uniswap v3 Pool 如何实时监控系统内的流动性,特别是在 swap 过程中穿过某一个 tick 后如何进行计算(cross tick)
  3. 手续费机制,在上一节中我们忽视了此内容,在本节中,我们将介绍手续费是如何累计和计算的,将涉及到部分 swap 内的代码,但主要代码仍位于 Position 及其相关部分
  4. Swap 内的具体计算,结合上述 cross tick 机制,我们可以获得当前的流动性数值,然后就可以完成 swap 的其他部分

本节内容将不完全与白皮书内的内容对应。

Ticks and Ranges

在上文,我们已经介绍了如下数学公式: $$p(i) = 1.0001^i \quad \text{and} \quad \sqrt{p(i)} = 1.0001^{i/2}$$ 用户提交的流动性都存在 $tickLower$( $$i_l$$ ) 和 $tickUpper$( $$i_u$$ ) 两个核心参数。每一个 Pool 在初始化时就会选定 $tickSpacing$ 作为核心参数。在上文中,我们介绍了 $tickSpacing$ 与手续费之间的映射关系,但 $tickSpacing$ 在初始化时会被用于计算另一个数值 $maxLiquidityPerTick$,该数值代表每一个 tick 上可以容纳的最大流动性数量,计算方法如下:

function tickSpacingToMaxLiquidityPerTick(int24 tickSpacing) internal pure returns (uint128) {
    int24 minTick = (TickMath.MIN_TICK / tickSpacing) * tickSpacing;
    int24 maxTick = (TickMath.MAX_TICK / tickSpacing) * tickSpacing;
    uint24 numTicks = uint24((maxTick - minTick) / tickSpacing) + 1;
    return type(uint128).max / numTicks;
}

首先,我们需要明确在 Uniswap v3 内记录当前 Pool 的流动性的变量 uint128 public override liquidity; 的类型是 uint128,所以我们接下来的任务是计算当前系统内到底存在多少在当前 $tickSpacing$ 下有效的 tick,具体分为两步:

  1. 确认当前 $tickSpacing$ 下有效的最小 $minTick$ 和最大 $maxTick$,注意都需要向 0 舍入,避免计算出的结果小于 TickMath.MIN_TICK 或者大于 TickMath.MAX_TICK。代码中的 TickMath.MIN_TICK / tickSpacing 内的除法是向 0 舍入的,所以此处计算出的结果已经完成向 0 舍入
  2. 使用计算出的 $minTick$$maxTick$ 的差值计算 $numTicks$,使用 $(maxTick - minTick) / tickSpacing$ 但是此处存在经典的 Fencepost error,即间隔与个数差 1,所以最终的计算方法就是代码内的方法

那么计算出的常量 $maxLiquidityPerTick$ 如何使用? 在 contracts/libraries/Tick.sol 内的 update 函数展示了该常量的用法:

Tick.Info storage info = self[tick];

uint128 liquidityGrossBefore = info.liquidityGross;
uint128 liquidityGrossAfter = LiquidityMath.addDelta(liquidityGrossBefore, liquidityDelta);

require(liquidityGrossAfter <= maxLiquidity, 'LO');

flipped = (liquidityGrossAfter == 0) != (liquidityGrossBefore == 0);

上述代码内的 $maxLiquidity$ 就是传入的 $maxLiquidityPerTick$ 常量。在 Tick.Info 内存在 $liquidityGross$ 变量,记录当前 $tick$ 内的流动性总量,每次用户进行流动性调整都会更新该变量。显然,假如每一个 tick 的 $liquidityGross < maxLiquidityPerTick$,那么流动性总和不可能大于 type(uint128).max。此处额外注意 $liquidityGross$ 与当前系统内活跃的流动性并不等同,比如下图中的一个用户在 tick 0 - tick3 之间添加了流动性,此时 tick 2 的 $liquidityGross = 0$,但并不代表此处没有流动性。我们会在下一节内详细介绍如何计算每一个区间的真实流动性。

Uniswap v3 Gross

上述代码中的 $flipped$ 也是 $liquidityGross$ 存在的重要原因,$flipped$ 代表某一个 tick 是否需要“翻转”,更加具体说,是否需要从完成初始化状态修改为未初始化状态,或者反之从未初始化状态修改为初始化状态。假如当前 tick 需要 flipTick,那么我们就会调用 tickBitmap.flipTick 函数。

tickBitmap 是我们在本节要介绍的核心数据类型之一。回到上图,假如当前价格位于 tick 0 与 tick 1 之间,Swap 的计算实际上也使用了上文介绍的 getAmount0DeltagetAmount1Delta 函数,这些函数要求给定当前价格与下一个价格,在上图中,下一个价格指的是 tick 3,因为中间的 tick 2 是未初始化的,所以不需要考虑。那么如何基于当前价格搜索到下一个价格,这对应 tickBitmap.nextInitializedTickWithinOneWord 函数。

tickBitmap 是一个可以容纳 1774545 bit 的序列(tick 所在地范围为 $[-887272, 887272]$,总共存在 1774545 个 tick)。但是显然在 solidity 内没有一个长度如此长的数据类型,所以 Uniswap v3 的开发团队使用了分组方法,设置了 $2^{16}$ 个分组,每一个分组都是 256 bit 长度,即:

mapping(int16 => uint256) public override tickBitmap;

我们可以使用 wordPos = int16(tick >> 8); 快速定位 $tick$ 位于的分组序号,使用 bitPos = uint8(tick % 256); 计算出在分组内的具体位置,实际上也就是 contracts/libraries/TickBitmap.sol 内的 position 函数。使用该函数,我们可以快速实现 flipTick,具体实现就是定位到需要翻转的 bit 在 tickBitmap 的 position 然后使用 XOR 函数写入:

function flipTick(
    mapping(int16 => uint256) storage self,
    int24 tick,
    int24 tickSpacing
) internal {
    require(tick % tickSpacing == 0); // ensure that the tick is spaced
    (int16 wordPos, uint8 bitPos) = position(tick / tickSpacing);
    uint256 mask = 1 << bitPos;
    self[wordPos] ^= mask;
}

从上述代码内,我们可以看到我们会将 $tick$ 使用 $tick / tickSpacing$ 进行修正再进行写入,这其实意味着对于 $tickSpacing$ 设置较大的 Pool,其内部的 TickBitmap 被占用的 bit 会越少。

然后我们介绍如何根据当前 tick 搜索下一个价格,注意此处使用的 nextInitializedTickWithinOneWord 获得的并不一定是下一个已经被初始化的 tick,正如函数名称中的 WithinOneWord ,该函数只是返回当前分组(word) 内的下一个初始化 tick,假如当前分组内没有任何初始化的 tick,那么返回值是当前分组的末尾 tick。此处的末尾有两种不同的情况:

  1. 对于向左搜索的情况,寻找小于或者等于当前 $tick$ 的位于当前 word 的 next tick
  2. 对于向右搜索的情况,寻找大于当前 $tick$ 且位于当前 word 的 next ticl

我们以向左搜索为例,代码如下:

int24 compressed = tick / tickSpacing;
if (tick < 0 && tick % tickSpacing != 0) compressed--; // round towards negative infinity

if (lte) {
    (int16 wordPos, uint8 bitPos) = position(compressed);
    // all the 1s at or to the right of the current bitPos
    uint256 mask = (1 << bitPos) - 1 + (1 << bitPos);
    uint256 masked = self[wordPos] & mask;

    // if there are no initialized ticks to the right of or at the current tick, return rightmost in the word
    initialized = masked != 0;
    // overflow/underflow is possible, but prevented externally by limiting both tickSpacing and tick
    next = initialized
        ? (compressed - int24(bitPos - BitMath.mostSignificantBit(masked))) * tickSpacing
        : (compressed - int24(bitPos)) * tickSpacing;

这段代码的核心难点是如何处理舍入问题。我们可以看到 int24 compressed = tick / tickSpacing; 是向 0 舍入的,但是为了保证 bitmap 的连续性,我们利用 if (tick < 0 && tick % tickSpacing != 0) compressed--; 将不是 $tickSpacing$ 整数倍的 $tick$ 向下舍入。假如此处不进行上述操作,那么会导致 0 两侧的正负数 tick 进行除法计算后结果都为 0,这会导致 bitmap 的不连续。

然后我们构建一个以当前 $bitPos$ 最为最右侧位,且 $bitPos$ 左侧都为 1 的 mask 序列,举例说明:

bitPos = 3
mask = 0b1111

获得 $mask$ 后,我们需要将当前 word 与 mask 进行异或操作,获得的结果是只包含当前 $bitPos$ 及其左侧 bit 的序列。假如异或后的结果为 0 意味着当前 word 内已经不存在下一个初始化的 tick,我们只需要执行 $(compressed - \text{int24}(\text{bitPos})) * \text{tickSpacing}$ 操作直接获得当前 word 最左侧的 tick 返回即可。部分读者可能好奇为什么此处不能直接返回 0,这是因为返回的 $tick$ 应该是包含 $wordPos$$bitPos$,确实 word 中最右侧的 tick 的 $bitPos = 0$,但是我们不能忽略 $wordPos$,所以最简单的计算方法就是 $(compressed - \text{int24}(\text{bitPos})) * \text{tickSpacing}$

假如异或后的结果不为 0 ,那么就说明当前 word 内在指定 tick 的自身及其左侧存在一些已经被初始化的 tick。我们接下来的任务是找到该 tick。该任务等同为寻找异或结果中的最右侧且值为 1 的 bit 的位置。在计算机领域,该任务被称为 mostSignificantBit。假如读者对该算法内部实现感兴趣,可以阅读笔者之前编写的 现代 DeFi: Uniswap V4 数学库分析 内的介绍。所以我们可以使用 $(compressed - \text{int24}(\text{bitPos} - \text{BitMath.mostSignificantBit}(\text{masked}))) * \text{tickSpacing}$ 计算出寻找到下一个 word 内已初始化的 tick。

注意,在上文中,我们对于 $lte$ 向左搜索的表述是找到小于等于当前 tick 的下一个 tick,所以该函数返回值有可能就是输入的 $tick$,对于这种情况,实际上 swap 函数是可以处理的,但是会导致一次 swap 循环的空转(即此次 swap 循环不消耗任何输入代币且不产生任何输出),假如读者在 swap 时追求极致的 gas 效率,请考虑这种情况的影响。

对于另一种向右搜索的情况,读者可以自行研究,这种情况就永远不会返回输入的 $tick$,只会返回大于当前 $tick$ 的最小 tick。实际以下代码中的 $+1$ 保证了永远不会返回输入的 $compressed$ 数值。

next = initialized
    ? (compressed + 1 + int24(BitMath.leastSignificantBit(masked) - bitPos)) * tickSpacing
    : (compressed + 1 + int24(type(uint8).max - bitPos)) * tickSpacing;

流动性计算

在本节中,我们暂时脱离白皮书的内容,因为对于 Pool 内的流动性,我们会涉及到 Global StateTick-Indexed State 的内容。众所周知,在 swap 过程中,我们依赖于 $$L$$ 即当前系统内的流动性数量计算代币 swap 后的输出等。在 Pool 层面,我们使用 uint128 public override liquidity; 来跟踪该数值。由于 Uniswap v3 使用了区间流动性方法,经常出现多个 LP 头寸都为当前流动性进行了贡献的情况,比如下图中的 LP #0 / LP #1 与 LP #2 都为当前的价格贡献了流动性:

Uniswap v3 multi lp

所以我该如何正确更新当前价格可用的流动性?最简单的情况是修改流动性头寸时,比如用户直接修改了上图中 LP #0 内的流动性数量,此时我们会直接在全局变量 $liquidity$ 内进行修改。在 _modifyPosition 内,我们可以看到如下代码:

liquidity = LiquidityMath.addDelta(liquidityBefore, params.liquidityDelta);

除了这种直接在修改流动性时进行的 $liquidity$ 更新,更加常见的是由于 swap 出现价格变化而进一步导致部分流动性变化,下图展示 $$L_1$$ 和 $$L_2$$ 两部分流动性,其中 $$L_1$$ 占据了 a - c 区间,而 $$L_2$$ 占据了 b - d 区间。假如价格当前穿过 c 价格,那么 $$L_1$$ 就会失效,而 $$L_2$$ 仍发挥作用。

Tick with liquidity

那么在存在大量 LP 的情况下, Uniswap v3 如何计算出当前有效的 LP 贡献的总流动性数量?实际上,Uniswap v3 在 Tick.Info 结构体内存储了 $liquidityNet$ 变量。该变量表示当前 tick 从左往右穿过对全局流动性的变化。以上图为例,我们假设最初 Pool 内的价格位于 a 的左侧,那么当价格从左往右移动穿过 a 时,liquidity 增加 500,这其实代表着 $$L_1$$ 已经生效,当价格继续向右移动穿过 b 时,liquidity 再次增加 700,因为此时 $$L_2$$ 生效;但价格继续向右移动穿过 c 时,liquidity 会减少 500,这是因为 $$L_1$$ 已经失效。

我们可以看到只需要在每次修改 LP 的流动性时,将当前流动性修改数量增加到 $tickLower$ ,而将修改数量的相反数增加到 $tickUpper$ 即可。如此就可以实现价格自左向右穿过 $tickLower$ 时,自动增加当前的流动性修改,而自左向右穿过 $tickUpper$ 时,减少当前的流动性修改。

contracts/libraries/Tick.sol 内,我们可以看到 update 函数的如下实现:

info.liquidityNet = upper
    ? int256(info.liquidityNet).sub(liquidityDelta).toInt128()
    : int256(info.liquidityNet).add(liquidityDelta).toInt128();

其中 $upper$ 是一个表示当前 tick 是否是 LP 的 $tickUpper$。接下来,我们要实现穿过 tick 后根据 tick 内的 $liquidityNet$ 数据修改 $liquidity$ 变量的代码。在 UniswapV3Pool 内的 swap 函数内,我们可以看到如下代码:

int128 liquidityNet =
    ticks.cross(
        step.tickNext,
        (zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128),
        (zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128),
        cache.secondsPerLiquidityCumulativeX128,
        cache.tickCumulative,
        cache.blockTimestamp
    );
// if we're moving leftward, we interpret liquidityNet as the opposite sign
// safe because liquidityNet cannot be type(int128).min
if (zeroForOne) liquidityNet = -liquidityNet;

state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);

此处的 tick.cross 就是一个处理 tick 被穿过的函数,该函数内部大部分内容都是与流动性手续费计算有关的,我们会在下一节分析。在上文中,我们介绍了从左往右穿过 tick 是在 $liquidity$ 基础上增加 $liquidityNet$,反之,从右往左穿过 tick 就会减少 $liquidityNet$$zeroForOne$ 代表用户使用 token 0 换取 token 1,对于 Pool 来说会支出 token 1 获得 token 0,根据价格计算方法: $$P = \frac{y}{x}$$ 其中 $y$ 代表 token 1 的数量而 $x$ 代表 token 0 的数量,显然,$zeroForOne$ 会似的价格下降,即从右往左穿过 tick,所以此处存在 $liquidityNet = -liquidityNet;$ 的代码。

手续费计算

Uniswap v3 的手续费计算是一个较为复杂的内容,这部分内容的特点是数学公式较为简单,但背后的数学原理很难理解。为了简化本文,我们只介绍数学公式及其实现,对于数学原理,笔者在 博客 中给出了推导分析。本节的内容基本上与白皮书内的 Tick-Indexed State 一节有关。在后文中,如无特殊说明,所有的手续费都是指 LP 由于提供流动性以供交易者交易而获得的交易手续费。

Tick.Info 结构体内的 $feeGrowthOutside0X128$$feeGrowthOutside1X128$ 都与手续费计算有关,前者与 token 0 的手续费计算有关,而后者与 token 1 的手续费计算有关,在数学表达式内,我们一般使用 $$f_o(i)$$ 表示。从经济含义上,我们认为 $$f_o$$ 表示当前 tick 一侧所有 tick 历史累计的手续费总量。我们定义对于小于当前价格的 tick, $$f_o(i)$$ 的含义为:

i Above

即当前 tick i 右侧累计的所有手续费。反之, $$f_o(i)$$ 代表当前左侧累计的所有手续费:

i below

基于上述定义,我们引入代表当前 tick 右侧(above) 累计手续费 $$f_a(i)$$ 和当前 tick 左侧(below) 累计的手续费 $$f_b(i)$$

两个公式:

$$ f_a(i)= \begin{cases} f_g-f_o(i) & i_c \ge i \ f_o(i) & i_c < i \end{cases} $$

$$ f_b(i)= \begin{cases} f_o(i) & i_c \ge i \ f_g-f_o(i) & i_c < i \end{cases} $$

另外,在 Pool 的状态中,我们会使用 $feeGrowthGlobal0X128$$feeGrowthGlobal0X128$ 记录全局手续费,在数学表达式内,我们一般使用 $$f_g$$ 表示。显然,对于任何情况,都存在 $$f_g - f_a(i_u) - f_b(i_l)$$ 代表当前价格区间的手续费,我们可以使用下图理解:

Current Fee internal

所以,在代码中,我们首先计算 $$f_b(i_l)$$ 和 $$f_a(i_u)$$ 的值:

// calculate fee growth below
uint256 feeGrowthBelow0X128;
uint256 feeGrowthBelow1X128;
if (tickCurrent >= tickLower) {
    feeGrowthBelow0X128 = lower.feeGrowthOutside0X128;
    feeGrowthBelow1X128 = lower.feeGrowthOutside1X128;
} else {
    feeGrowthBelow0X128 = feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128;
    feeGrowthBelow1X128 = feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128;
}

// calculate fee growth above
uint256 feeGrowthAbove0X128;
uint256 feeGrowthAbove1X128;
if (tickCurrent &lt; tickUpper) {
    feeGrowthAbove0X128 = upper.feeGrowthOutside0X128;
    feeGrowthAbove1X128 = upper.feeGrowthOutside1X128;
} else {
    feeGrowthAbove0X128 = feeGrowthGlobal0X128 - upper.feeGrowthOutside0X128;
    feeGrowthAbove1X128 = feeGrowthGlobal1X128 - upper.feeGrowthOutside1X128;
}

最后,使用公式 $$ f_g - f_a(i_u) - f_b(i_l) $$ 计算当前区间手续费情况:

feeGrowthInside0X128 = feeGrowthGlobal0X128 - feeGrowthBelow0X128 - feeGrowthAbove0X128;
feeGrowthInside1X128 = feeGrowthGlobal1X128 - feeGrowthBelow1X128 - feeGrowthAbove1X128;

注意,其实上述所有的计算都是代表单位流动性可以获得的手续费,所以我们最终会使用如下算法计算真正的代币数量:

// calculate accumulated fees
uint128 tokensOwed0 =
    uint128(
        FullMath.mulDiv(
            feeGrowthInside0X128 - _self.feeGrowthInside0LastX128,
            _self.liquidity,
            FixedPoint128.Q128
        )
    );
uint128 tokensOwed1 =
    uint128(
        FullMath.mulDiv(
            feeGrowthInside1X128 - _self.feeGrowthInside1LastX128,
            _self.liquidity,
            FixedPoint128.Q128
        )
    );

接下来,我们需要确定 tick 内的 $feeGrowthOutside0X128$$feeGrowthOutside1X128$ 的更新方法。根据上文中给出的 $$f_o$$ 的图示,大家不难发现每次 $$f_o$$ 只有被穿过的时候才会被更新,更新后的数值为 $$f_g - f_o$$ 。在 cross 的代码中有所体现:

info.feeGrowthOutside0X128 = feeGrowthGlobal0X128 - info.feeGrowthOutside0X128;
info.feeGrowthOutside1X128 = feeGrowthGlobal1X128 - info.feeGrowthOutside1X128;

最后,我们要确定 tick 的初始化方法。这其实是一个难点,初始化的代码位于 update 内部,代码为:

if (liquidityGrossBefore == 0) {
    // by convention, we assume that all growth before a tick was initialized happened _below_ the tick
    if (tick &lt;= tickCurrent) {
        info.feeGrowthOutside0X128 = feeGrowthGlobal0X128;
        info.feeGrowthOutside1X128 = feeGrowthGlobal1X128;
    }
    info.initialized = true;
}

实际上,从初始化的角度看,tick 内的 $feeGrowthOutside0X128$$feeGrowthOutside1X128$ 并不代表真正的 $$f_o$$ ,但是实际上初始化的数值并不影响最终的手续费计算。背后的原理是因为手续费计算本质上是增量计算,初始化数值并不影响最终的计算结果。

除了流动性手续费外,实际上 Uniswap v3 也支持收取协议手续费且只对输入代币收取,而对输出代币不收取。但不同于 Uniswap v2,Uniswap v3 内的协议手续费是直接根据 swap 结果收取,而不是转化为 LP。在 Pool 的 Slot0 内部,存在 $feeProtocol$ 变量,其中该变量的后 4 bit 代表对 token 0 收取的手续费,而前 4 bit 代表对 token 1 收取的手续费。

swap 函数内,我们可以看到如下代码:

SwapCache memory cache =
    SwapCache({
        liquidityStart: liquidity,
        blockTimestamp: _blockTimestamp(),
        feeProtocol: zeroForOne ? (slot0Start.feeProtocol % 16) : (slot0Start.feeProtocol >> 4),
        secondsPerLiquidityCumulativeX128: 0,
        tickCumulative: 0,
        computedLatestObservation: false
    });

其中 feeProtocol: zeroForOne ? (slot0Start.feeProtocol % 16) : (slot0Start.feeProtocol >> 4), 完成了根据 $zeroForOne$ 获得手续费比率的功能。在 swap 过程中,由于区间流动性的存在,单个区间不一定可以满足 swap 的需求,所以 swap 会以循环的方法进行,不断在可用的流动性区间中进行交易,每次交易结果都会存放在 step 结构体内,其中 $step.feeAmount$ 代表交易过程中流动性手续费,而 $protocolFee$ 就会从流动性手续费中拿出一部分,代码如下:

if (cache.feeProtocol > 0) {
    uint256 delta = step.feeAmount / cache.feeProtocol;
    step.feeAmount -= delta;
    state.protocolFee += uint128(delta);
}

Swap 过程

我们来到了 Uniswap v3 最核心的部分,就是如何进行 Swap? 我们需要将上述介绍的一系列知识组合起来完成该任务。在白皮书内存在如下经典的流程图:

Swap Flow

第一步是检查用户输入的数值以及初始化用于 swap 流程的中间结构体 SwapState。对于输入检查,主要是检查用户输入的用于控制滑点的 $sqrtPriceLimitX96$ 是否正确。$sqrtPriceLimitX96$ 是用户希望 swap 停止的价格,比如当前价格为 1,用户希望将价格推动到 0.9 时就停止 swap,此时就可以将 $sqrtPriceLimitX96$ 设置为 0.9。额外的未交易完成的代币会退还给用户。

require(
    zeroForOne
        ? sqrtPriceLimitX96 &lt; slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 > TickMath.MIN_SQRT_RATIO
        : sqrtPriceLimitX96 > slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 &lt; TickMath.MAX_SQRT_RATIO,
    'SPL'
);

我们在之前提到 $zeroForOne$ 会推动 Pool 内的价格下降,所以显然 $sqrtPriceLimitX96$ 需要小于当前价格,但要大于最低价格(MIN_SQRT_RATIO),对于 oneForZero 的情况同理。

另外,Uniswap v3 也会进行一次防止重入的检查 require(slot0Start.unlocked, 'LOK');。最后初始化 SwapState 暂存 swap 过程中的中间量:

SwapState memory state =
    SwapState({
        amountSpecifiedRemaining: amountSpecified,
        amountCalculated: 0,
        sqrtPriceX96: slot0Start.sqrtPriceX96,
        tick: slot0Start.tick,
        feeGrowthGlobalX128: zeroForOne ? feeGrowthGlobal0X128 : feeGrowthGlobal1X128,
        protocolFee: 0,
        liquidity: cache.liquidityStart
    });

其中 $sqrtPriceX96$$tick$ 代表当前价格和 tick,而 $feeGrowthGlobalX128$ 代表当前全局手续费情况。

接下来,我们进入 swap 的核心循环。第一步是确定 swap 循环退出的条件:

  1. 将所有代币兑换完成
  2. 将价格推动到 $sqrtPriceLimitX96$

上述两个条件满足其一就可以退出循环,反之只有两个条件都不满足,那么就需要一直进行 swap 循环:

while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {

进入 swap 后,我们第一步是找到可供交易的流动性区间,那么首先调用 tickBitmap.nextInitializedTickWithinOneWord 就是一个好办法。然后调用 computeSwapStep 计算在当前流动性区间内进行交易,交易后到价格、交易需要的代币输入和代币输出以及交易的手续费:

(state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep(
    state.sqrtPriceX96,
    (zeroForOne ? step.sqrtPriceNextX96 &lt; sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96)
        ? sqrtPriceLimitX96
        : step.sqrtPriceNextX96,
    state.liquidity,
    state.amountSpecifiedRemaining,
    fee
);

SwapMath.computeSwapStep 接受的参数如下:

function computeSwapStep(
    uint160 sqrtRatioCurrentX96,
    uint160 sqrtRatioTargetX96,
    uint128 liquidity,
    int256 amountRemaining,
    uint24 feePips
)

其中第二个参数 $sqrtRatioTargetX96$ 在 Pool 的 swap 函数内构建稍微复杂:

(zeroForOne ? step.sqrtPriceNextX96 &lt; sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96)
    ? sqrtPriceLimitX96
    : step.sqrtPriceNextX96,

上述三目表达式含义如下:

  1. $zeroForOne = \text{true}$ 的情况下
    1. $step.sqrtPriceNextX96 &lt; sqrtPriceLimitX96$ 成立,此时返回 $sqrtPriceLimitX96$,
    2. $step.sqrtPriceNextX96 > sqrtPriceLimitX96$ 成立,此时返回 $step.sqrtPriceNextX96$
  2. $zeroForOne = \text{false}$ 的情况下
    1. $step.sqrtPriceNextX96 &lt; sqrtPriceLimitX96$ 成立,此时返回 $step.sqrtPriceNextX96$
    2. $step.sqrtPriceNextX96 > sqrtPriceLimitX96$ 成立,此时返回 $sqrtPriceLimitX96$

简单来说,就是为了通过比较 $step.sqrtPriceNextX96$$sqrtPriceLimitX96$ 的大小,输出合理的目标价格。合理的目标价格满足:

  1. $zeroForOne = \text{true}$ 时,$sqrtRatioTargetX96 = \max(\text{next}, \text{limit})$
  2. $zeroForOne = \text{false}$ 时,$sqrtRatioTargetX96 = \min(\text{next}, \text{limit})$

我们先暂时跳过 computeSwapStep 内部的分析。继续阅读后续代码。后续代码中,我们首先更新了 $state.amountSpecifiedRemaining$$state.amountCalculated$ :

if (exactInput) {
    state.amountSpecifiedRemaining -= (step.amountIn + step.feeAmount).toInt256();
    state.amountCalculated = state.amountCalculated.sub(step.amountOut.toInt256());
} else {
    state.amountSpecifiedRemaining += step.amountOut.toInt256();
    state.amountCalculated = state.amountCalculated.add((step.amountIn + step.feeAmount).toInt256());
}

上述更新逻辑中,我们要注意 $state.amountSpecifiedRemaining > 0$ 代表 exactInput 模式,所以此处要使用 -= 进行计算。$state.amountCalculated$ 代表计算出代币数量。注意 $state.amountCalculated$ 也是存在符号的,我们认为为负数代表需要支出给用户的代币数量。在 exactInput 模式下,计算出的 $state.amountCalculated$ 一定会支付给用户,所以此处使用了 sub 计算。对于 exactOut 同理,此时 $state.amountSpecifiedRemaining &lt; 0$ 所以使用 += 计算,而 $state.amountCalculated$ 代表用户支付给 Pool 的代币数量,此时使用 add 计算。

之后在 Pool 内进行 $feeProtocol$ 计算,此部分在前文已有介绍。然后更新全局的 $feeGrowthGlobalX128$,在上文,我们已经提到 $feeGrowthGlobalX128$ 代表单位流动性累计的手续费,所以计算方法如下:

if (state.liquidity > 0)
    state.feeGrowthGlobalX128 += FullMath.mulDiv(step.feeAmount, FixedPoint128.Q128, state.liquidity);

最后,我们判断 computeSwapStep 输出的在当前流动性区间进行 swap 后的结果 $state.sqrtPriceX96$ 与我们在最初使用 nextInitializedTickWithinOneWord 搜索到的 $step.sqrtPriceNextX96$ 之间的关系。的一种关系是 $state.sqrtPriceX96 == step.sqrtPriceNextX96$,这说明我们已经耗尽了当前流动性区间的流动性,我们需要将移动 tick 进行新的搜索,核心代码如下:

if (state.sqrtPriceX96 == step.sqrtPriceNextX96) {
    // if the tick is initialized, run the tick transition
    if (step.initialized) {
        // crosses an initialized tick
        int128 liquidityNet =
            ticks.cross(
                step.tickNext,
                (zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128),
                (zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128),
                cache.secondsPerLiquidityCumulativeX128,
                cache.tickCumulative,
                cache.blockTimestamp
            );
        // if we're moving leftward, we interpret liquidityNet as the opposite sign
        // safe because liquidityNet cannot be type(int128).min
        if (zeroForOne) liquidityNet = -liquidityNet;

        state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);
    }

    state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext;
}

上述代码第一部分 if (step.initialized) 处理了要穿过已初始化的 tick 的 cross 过程,主要进行 feeGrowth 的更新,然后更新 $state.liquidity$,我们在上文刚刚完成这两部分的介绍。最后,修改 $state.tick$ 的值,这里的逻辑与 tick 搜索的逻辑稍有关系。在上文,我们介绍

  1. 对于向左搜索的情况,寻找小于或者等于当前 $tick$ 的位于当前 word 的 next tick
  2. 对于向右搜索的情况,寻找大于当前 $tick$ 且位于当前 word 的 next ticl

由于要触发不包含当前 $step.tickNext$ 的搜索,所以对于 $zeroForOne$(也就是向左搜索),由于包含 tick 自身,所以必须要使用 $step.tickNext - 1$ 更新 $state.tick$ 的值,而对于向右搜索,搜索本身就会不包含当前 tick,所以直接使用 $step.tickNext$ 即可。

对于另一种情况,即 $state.sqrtPriceX96 != step.sqrtPriceStartX96$ 的情况,这种情况实际代表 swap 已经结束,因为当前的流动性区间已经可以满足用户所有需求,此时我们要进行的额外工作是 state.tick = TickMath.getTickAtSqrtRatio(state.sqrtPriceX96);。这是因为 computeSwapStep 内部只使用价格进行计算,而不考虑 tick,此时我们要使用价格计算出正确的 tick 以方便未来使用。

上述过程实际上就完成了 Swap 内的最核心的循环,然后我们就可以将大量处于内存中的中间状态写入存储,包括 $sqrtPriceX96$ / $tick$ / $liquidity$ / $feeGrowthGlobal0X128$ / $feeGrowthGlobal1X128$ 等变量。以下所有的 if 其实都可以不存在,此处存在的唯一理由是为了避免重复写入导致的 gas 浪费。

if (state.tick != slot0Start.tick) {
    (slot0.sqrtPriceX96, slot0.tick, slot0.observationIndex, slot0.observationCardinality) = (
        state.sqrtPriceX96,
        state.tick,
        observationIndex,
        observationCardinality
    );
} else {
    // otherwise just update the price
    slot0.sqrtPriceX96 = state.sqrtPriceX96;
}

// update liquidity if it changed
if (cache.liquidityStart != state.liquidity) liquidity = state.liquidity;

// update fee growth global and, if necessary, protocol fees
// overflow is acceptable, protocol has to withdraw before it hits type(uint128).max fees
if (zeroForOne) {
    feeGrowthGlobal0X128 = state.feeGrowthGlobalX128;
    if (state.protocolFee > 0) protocolFees.token0 += state.protocolFee;
} else {
    feeGrowthGlobal1X128 = state.feeGrowthGlobalX128;
    if (state.protocolFee > 0) protocolFees.token1 += state.protocolFee;
}

在 Swap 函数的最后,我们处理用户的代币转账问题:

(amount0, amount1) = zeroForOne == exactInput
    ? (amountSpecified - state.amountSpecifiedRemaining, state.amountCalculated)
    : (state.amountCalculated, amountSpecified - state.amountSpecifiedRemaining);

// do the transfers and collect payment
if (zeroForOne) {
    if (amount1 &lt; 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));

    uint256 balance0Before = balance0();
    IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
    require(balance0Before.add(uint256(amount0)) &lt;= balance0(), 'IIA');
} else {
    if (amount0 &lt; 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0));

    uint256 balance1Before = balance1();
    IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
    require(balance1Before.add(uint256(amount1)) &lt;= balance1(), 'IIA');
}

emit Swap(msg.sender, recipient, amount0, amount1, state.sqrtPriceX96, state.liquidity, state.tick);
slot0.unlocked = true;

$(amount0, amount1)$ 的计算是一个难点,代码内的三目表达式的展开是:

// Set amount0 and amount1
// zero for one | exact input |
//    true      |    true     | amount 0 = specified - remaining ($> 0$)
//              |             | amount 1 = calculated            ($&lt; 0$)
//    false     |    false    | amount 0 = specified - remaining ($&lt; 0$)
//              |             | amount 1 = calculated            ($> 0$)
//    false     |    true     | amount 0 = calculated            ($&lt; 0$)
//              |             | amount 1 = specified - remaining ($> 0$)
//    true      |    false    | amount 0 = calculated            ($> 0$)
//              |             | amount 1 = specified - remaining ($&lt; 0$)

在本节的最后,我们介绍 Swap 中的 computeSwapStep 函数的具体实现。我们首先分析 exactIn 的情况,代码如下:

uint256 amountRemainingLessFee = FullMath.mulDiv(uint256(amountRemaining), 1e6 - feePips, 1e6);
amountIn = zeroForOne
    ? SqrtPriceMath.getAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true)
    : SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, true);
if (amountRemainingLessFee >= amountIn) sqrtRatioNextX96 = sqrtRatioTargetX96;
else
    sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromInput(
        sqrtRatioCurrentX96,
        liquidity,
        amountRemainingLessFee,
        zeroForOne
    );

我们首先计算在手续费处理后的用户输入的代币数量 $amountRemainingLessFee$,然后使用 getAmount0DeltagetAmount1Delta 计算在 $sqrtRatioTargetX96$$sqrtRatioCurrentX96$ 之间的流动性等效的代币数量 $amountIn$。假如 $amountRemainingLessFee \ge amountIn$,就说明用户输入的代币数量可以填满 $sqrtRatioTargetX96$$sqrtRatioCurrentX96$ 区间,所以 $sqrtRatioNextX96$ 就是 $sqrtRatioTargetX96$。反之,则说明用户输入代币无法将价格推动到 $sqrtRatioTargetX96$,此时我们就会使用 getNextSqrtPriceFromInput 利用 $liquidity$$amountRemainingLessFee$ 以及 $sqrtRatioCurrentX96$ 计算当前代币输入后的价格,具体的数学公式为:

$$p_a = P - \frac{y}{L}$$ $$p_b = \frac{L}{P L - P \cdot x}$$

其中 $$p_b$$ 用于计算 $zeroForOne$ 情况,此时输入代币为 token 0,计算结果是在当前 $$L$$ 和 $$P$$ 下交易后的价格,具体对应 getNextSqrtPriceFromAmount0RoundingUp 函数。而 $$p_a$$ 用于计算 oneForZero 的情况,对应函数为 getNextSqrtPriceFromAmount1RoundingDown 函数。限于篇幅,我们不再介绍具体的函数实现。

对于 exactOut 的模式,其实代码类似。我们首先计算 $sqrtRatioTargetX96$$sqrtRatioCurrentX96$ 价格区间内的代币数量 $amountOut$ 。如果发现用户需要的 $amountRemaining$ 大于等于 $amountOut$,那么我可以直接将 $sqrtRatioNextX96$ 设置为价格区间的端点 $sqrtRatioTargetX96$。反之,我们则需要进行新的价格计算。使用的数学公式其实与上文一致。额外注意的是 $amountRemaining$exactOut 情况下为负数。

amountOut = zeroForOne
    ? SqrtPriceMath.getAmount1Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, false)
    : SqrtPriceMath.getAmount0Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, false);
if (uint256(-amountRemaining) >= amountOut) sqrtRatioNextX96 = sqrtRatioTargetX96;
else
    sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromOutput(
        sqrtRatioCurrentX96,
        liquidity,
        uint256(-amountRemaining),
        zeroForOne
    );

最后,我们会根据多种情况判断代币的真实输出。首先,我们引入 bool max = sqrtRatioTargetX96 == sqrtRatioNextX96;,这种情况其实代表当前价格区间被完全耗尽,在这种情况下,计算出的 $amountIn$ 就是真实的 $amountIn$,反之,我们则需要利用 $sqrtRatioNextX96$$sqrtRatioCurrentX96$ 再次计算兑换的代币数量。最终,完整的条件判断如下,此处的一个难点是舍入问题,getAmount0DeltagetAmount1Delta 的最后的布尔参数代表是否需要 roundUp

// get the input/output amounts
if (zeroForOne) {
    amountIn = max && exactIn
        ? amountIn
        : SqrtPriceMath.getAmount0Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, true);
    amountOut = max && !exactIn
        ? amountOut
        : SqrtPriceMath.getAmount1Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, false);
} else {
    amountIn = max && exactIn
        ? amountIn
        : SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, true);
    amountOut = max && !exactIn
        ? amountOut
        : SqrtPriceMath.getAmount0Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, false);

上述结果也并不是最终结果,我们需要对于 exactOut 的情况需要进行额外的控制,因为计算可能存在误差,这可能导致 exactOut 大于用户预期,对于这种情况,我们默认将额外的超额误差部分舍入:

// cap the output amount to not exceed the remaining output amount
if (!exactIn && amountOut > uint256(-amountRemaining)) {
    amountOut = uint256(-amountRemaining);
}

最后,我们计算交易过程中的 $feeAmount$

if (exactIn && sqrtRatioNextX96 != sqrtRatioTargetX96) {
    // we didn't reach the target, so take the remainder of the maximum input as fee
    feeAmount = uint256(amountRemaining) - amountIn;
} else {
    feeAmount = FullMath.mulDivRoundingUp(amountIn, feePips, 1e6 - feePips);
}

对于 $exactIn \land \text{sqrtRatioNextX96} \neq \text{sqrtRatioTargetX96}$ 其实就是 exactIn 模式下,代币在当前价格区间完成所有兑换。在之前计算时,我们使用了 $amountRemainingLessFee$ 进行比较,所以此处直接使用 $feeAmount = \text{uint256}(\text{amountRemaining}) - \text{amountIn};$ 就可以计算获得最终结果。对于其他情况,我们都需要使用如下公式进行计算:

$$\text{feeAmount} = \frac{\text{amountIn} \times \text{feePips}}{1 - \text{feePips}}$$

上述公式的推导流程为:

$$\text{total Amount In} = \frac{\text{amountIn}}{1 - \text{feePips}}$$ $$\text{feeAmount} = \text{total Amount In} \times \text{feePips} = \frac{\text{amountIn} \times \text{feePips}}{1 - \text{feePips}}$$

上述计算中我们认为给定的 $amountIn$ 是已经扣完手续费后的净额,在计算 $feeAmount$ 时,我们第一步是将 $amountIn$ 还原为含手续费的全额,然后进行手续费计算。

总结

在本文中,我们介绍了关于 Uniswap v3 的大部分内容,但没有给出部分数学公式的计算方法,假如读者希望更加全面的认为 Uniswap v3,可以将笔者的另一篇 博客 也阅读一遍。本文忽略了所有关于 Oracle 的部分,实际上笔者的另一篇文章也没有介绍 Oracle,读者可以自行根据白皮书内容和代码理解这一部分。Uniswap v3 的 Oracle 带来的最有趣的 DeFi 原语就是流动性质押,假如读者的项目涉及该部分,可以研究一下 Uniswap v3 内的 Oracle 部分。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
wongssh
wongssh
https://learnblockchain.cn/wangshouh