Uniswap V3:流动性提供

376 阅读10分钟

Image

作者:WongSSH

引言

本系列文章将带领读者从零实现Uniswap V3核心功能,深入解析其设计与实现。主要参考了 Constructor | Uniswap V3 Core Contract Explained 系列教程,并补充了 Uniswap V3 Development Book 和 Paco 博客 中的相关内容。所有示例代码可在 clamm 代码库中找到,以便实践和探索。

流动性提供

mint函数用于向流动性池内增加流动性。在本节中,我们将介绍 mint函数的构成及相关数学计算与代码。另外,为了简化文章内容,我们并不会在本文内涉及手续费和预言机逻辑,同时我们也去掉了mint函数内的回调函数。所以,我们可以得到以下mint函数的定义:

function mint(
    address recipient,
    int24 tickLower,
    int24 tickUpper,
    uint128 amount
) external lock returns (uint256 amount0, uint256 amount1) {}

mint函数定义内包含一个 lock 修饰器,该修饰器是为了避免重入的,其使用了 slot0内的unlocked状态变量:

modifier lock() {
    require(slot0.unlocked, 'LOK');
    slot0.unlocked = false;
    _;
    slot0.unlocked = true;
}

接下来,我们所编写的代码都位于 mint函数内部:

require(amount > 0);
(, int256 amount0Int, int256 amount1Int) = _modifyPosition(
    ModifyPositionParams({
        owner: recipient,
        tickLower: tickLower,
        tickUpper: tickUpper,
        liquidityDelta: int256(uint256(amount)).toInt128()
    })
);

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

if (amount0 > 0) {
    IERC20(token0).transferFrom(msg.sender, address(this), amount0);
}
if (amount1 > 0) {
    IERC20(token1).transferFrom(msg.sender, address(this), amount1);
}

此处的 _modifyPosition函数内部实际上计算了铸造 amount 数量的 Uniswap V3 LP 所需要的 token0 和 token1 的数量。由于我们没有使用 uniswap v3 的回调方案,所以此处直接使用了 transferFrom将固定数量的代币转移给池子。上述代码对于某些代币而言存在漏洞,建议用户不要在生产环境内使用。

在 Uniswap V3 的官方实现内,这部分使用了回调函数处理,但在本文中,我们并不会实现回调函数的相关逻辑。因为回调函数与 Uniswap v3 的外围合约存在一些依赖关系。

我们首先实现较为简单的 toInt128() 函数,该函数用于将 uint128类型转化为 int128类型。我们首先创建 clamm/src/libraries/SafeCast.sol文件,并在该文件内输入以下内容:

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.20;

library SafeCast {
    function toInt128(int256 y) internal pure returns (int128 z) {
        require((z = int128(y)) == y);
    }
}

上述代码通过判断转化后的 z 和 y 是否一致来实现安全的类型转化。然后,我们需要在 CLAMM.sol 内使用 import "./libraries/SafeCast.sol";语句导入该库,并且使用 using SafeCast for int256;语句将其使用在 int256类型上。

在 mint 函数内,目前唯一没有实现的就是 _modifyPosition 函数,这是一个相当复杂的函数。本节后续所有内容都将围绕该函数展开。我们先给出该函数的定义:

function _modifyPosition(ModifyPositionParams memory params) private returns (Position.Info storage position, int256 amount0, int256 amount1) {}

在此定义内,我们会发现 ModifyPositionParams结构体和 Position.Info  都没有此前进行过定义。所以第一步,我们先将这两个结构体进行定义。首先定义 ModifyPositionParams 结构体,该结构体用于传递用户流动性所在区间和流动性变化等参数。此处的 liquidityDelta是 int128 类型,是因为我们在提取流动性时也会使用此结构体,此时的 liquidityDelta就是负数,代表用户提取的流动性数量。

struct ModifyPositionParams {
    // the address that owns the position
    address owner;
    // the lower and upper tick of the position
    int24 tickLower;
    int24 tickUpper;
    // any change in liquidity
    int128 liquidityDelta;
}

接下来,我们定义 Position.Info结构体。创建 clamm/src/libraries/Position.sol文件并写入以下内容。注意,在本节中,我们并不会涉及手续费计算问题,所以实际上只会使用 Info中的 liquidity参数。此处,我们也编写了 get 方法,该方法用于在存储映射内检索存储的结构体。

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.20;

library Position {
    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;
    }

    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))];
    }
}

然后,我们可以在 clamm/src/CLAMM.sol主合约使用 import "./libraries/Position.sol"; 导入上述库,并使用以下代码创建相关存储映射:


using Position for Position.Info;
mapping (bytes32 => Position.Info) public positions;
using Position formapping(bytes32 => Position.Info);

完成上述工作后,我们就可以编写 _modifyPosition 中的逻辑代码:

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

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

上述代码中,我们暂时缺失了 amount0和 amount1的计算,我们会在后文进行补充。

此处的 checkTicks函数用于确定输入的参数是否正确,其具体实现如下:

function checkTicks(int24 tickLower, int24 tickUpper) private pure {
    require(tickLower < tickUpper, "TLU");
    require(tickLower >= TickMath.MIN_TICK, "TLM");
    require(tickUpper <= TickMath.MAX_TICK, "TUM");
}

之后,我们可以看到 _modifyPosition使用了 Slot0 memory _slot0 = slot0;将位于存储内的 slot0 缓存到内存内部。如果读者希望更加深入的了解该优化的原理,可以参考笔者之前编写的 Solidity Gas 优化清单及其原理:存储、内存与操作符一文 。

而 _updatePosition则较为复杂,我们将一步步构建,我们首先构造一个最简单的 _updatePosition函数,实现如下:

function _updatePosition(address owner, int24 tickLower, int24 tickUpper, int128 liquidityDelta, int24 tick)
    private
    returns (Position.Info storage position)
{
    position = positions.get(owner, tickLower, tickUpper);

    // TODO: Fee
    uint256 _feeGrowthGlobal0X128 = 0;
    uint256 _feeGrowthGlobal1X128 = 0;


    // TODO: Fee
    position.update(liquidityDelta, 0, 0);
}

此处,我们跳过了所有的手续费计算环节,我们会在未来介绍此部分。此处使用了 position.update 函数,因为目前我们跳过了所有的手续费计算逻辑,所以 position.update的唯一功能就是更新 position 内的 liquidity字段。我们需要在 clamm/src/libraries/Position.sol内编写如下函数:

function update(
    Info storage self,
    int128 liquidityDelta,
    uint256 feeGrowthInside0X128,
    uint256 feeGrowthInside1X128
) internal {
    Info memory _self = self;

    uint128 liquidityNext;
    if (liquidityDelta == 0) {
        require(_self.liquidity > 0, "0 liquidity");
        liquidityNext = _self.liquidity;
    } else {
        liquidityNext = liquidityDelta < 0
            ? _self.liquidity - uint128(-liquidityDelta)
            : _self.liquidity + uint128(liquidityDelta);
    }

    if (liquidityDelta != 0) self.liquidity = liquidityNext;
    self.feeGrowthInside0LastX128 = feeGrowthInside0X128;
    self.feeGrowthInside1LastX128 = feeGrowthInside1X128;
}

上述函数中需要注意的是:

  1. 在 liquidityDelta == 0且 _self.liquidity == 0的情况下,不需要更新当前的 position的 liquidity字段
  2. liquidity 是一个 uint128类型,但 liquidityDelta是一个 int128 类型,我们需要手动处理当 liquidityDelta < 0 的情况

回到 _updatePosition函数,接下来我们要在该函数内部完成 tick部分的更新。 tick记录了流动性等信息,我们首先实现 tick 的结构体定义:

struct Info {
    uint128 liquidityGross;
    int128 liquidityNet;
    uint256 feeGrowthOutside0X128;
    uint256 feeGrowthOutside1X128;
    bool initialized;
}

其中 liquidityGross代表该 tick 内具有的流动性数量,该数值主要用于判断当前 tick是否还存在流动性。 initialized表示当前 tick 是被初始化。 feeGrowthOutside0X128 和 feeGrowthOutside1X128用于计算手续费,我们目前并不会编写此部分代码。

而 liquidityNet是一个重要的变量,该变量用于计算当前池子内活跃的流动性数量。liquidityNet的运作原理非常有趣,我们可以观察下图(该图来自 Uniswap v3 详解(一):设计原理)

Image

上图内显示了两段不同的区间流动性,其中 L1L_1 是自 a tick 开始到 c tick 结束的价格区间,而 L2L_2 是从 b tick 开始到 d tick 结束的价格区间。当用户添加  L1L_1 流动性时,我们会在流动性区间的下限 a tick 的的 liquidityNet字段内记录添加的流动性数量 500,而在区间上限记录添加的流动性的相反数。让我们设想当起流动性池内启用了 L 单位流动性,且当前的价格为 p ,当价格 p 从 a 点的左侧移动到 a 点的右侧时, L1L_1 单位的流动性会被启用,此时池子内启用的流动性为 L+500L+500 。当进一步向右移动超过 b 点时,池子内启用的流动性为 L+500+700=L+1200L+500+700=L+1200。当价格进一步向右移动超过过 c 点时, L1L_1 段流动性完全退出,此时直接使用 c 点记录的的 liquidityNet = -500 就可以计算出当前池子内启用的流动性,数值为 L+1200500=L+700L+1200-500=L+700 。当价格进一步向右移动超过 d 点时, L2L_2也退出,最终流动性变为 L+700700=LL+700-700=L 。所以我们只需要记录修改价格区间的下限和上限就可以表示区间流动性。

接下来,我们把上述介绍的一些逻辑进行实现。我们先实现 tick 的更新函数,该函数定义如下:

function update(
    mapping(int24 => Tick.Info) storage self,
    int24 tick,
    int24 tickCurrent,
    int128 liquidityDelta,
    uint256 feeGrowthGlobal0X128,
    uint256 feeGrowthGlobal1X128,
    bool upper,
    uint128 maxLiquidity
) internal returns (bool flipped) {}

此处我们删除了 Uniswap V3 原版代码内的预言机部分参数。该函数的返回值 flipped代表更新后,tick 的状态是否有变化。当出现以下情况时,filpped为 true:

  1. update前,tick 的流动性,即 liquidityGross参数为零值,但 update后,liquidityGross不为零
  2. update前,tick 的流动性,即 liquidityGross参数为非零值,但   update后,liquidityGross为零

在有了上述函数定义后,我们开始编写函数的逻辑部分:

Tick.Info storage info = self[tick];

uint128 liuquidityGrossBefore = info.liquidityGross;
uint128 liquidityGrossAfter = liquidityDelta < 0
    ? liuquidityGrossBefore - uint128(-liquidityDelta)
    : liuquidityGrossBefore + uint128(liquidityDelta);

require(liquidityGrossAfter <= maxLiquidity, "liquidity > max");

// flipped = (liuquidityGrossBefore == 0 && liquidityGrossAfter != 0)
//     || (liuquidityGrossBefore != 0 && liquidityGrossAfter == 0);

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

if (liuquidityGrossBefore == 0) {
    info.initialized = true;
}

info.liquidityGross = liquidityGrossAfter;

info.liquidityNet = upper 
 ? info.liquidityNet - liquidityDelta 
 : info.liquidityNet + liquidityDelta;

此处我们首先计算了 liquidityGrossAfter即更新后的 tick 对应的流动性数值,然后计算了 flipped变量。我们使用了异或运算简化了原有的 (liuquidityGrossBefore == 0 && liquidityGrossAfter != 0) || (liuquidityGrossBefore != 0 && liquidityGrossAfter == 0);的复杂逻辑计算。本质原因是因为以下布尔计算的成立:

pq=(pq)(¬p¬q)=(p+q)(p+q)p \oplus q = (p \lor q) \land (\neg p \lor \neg q) = (p + q)(\overline{p} + \overline{q})

在上述代码的最后,我们计算了最重要的 liquidityNet 参数。在  update传参过程中,我们使用了 upper标识是否为流动性区间上限。根据上文的推导,当 tick 位于某一流动性区间上限时,我们需要减去 liquidityDelta,反之则加上 liquidityDelta 。上述代码的最后一段就完成了此任务。

在 Tick.sol内,我们还缺少一个移除 tick 的函数 clear,该函数相当简单

function clear(mapping(int24 => Tick.Info) storage self, int24 tick) internal {
    delete self[tick];
}

当我们完成上述 Tick.sol内的代码后,我们首先在clamm.sol内导入上述定义和相关库函数:

using Tick formapping(int24 => Tick.Info);
mapping(int24 => Tick.Info) public ticks;

然后,我们就可以继续完成我们的 _updatePosition函数内部的 tick 更新逻辑:

function _updatePosition(address owner, int24 tickLower, int24 tickUpper, int128 liquidityDelta, int24 tick)
    private
    returns (Position.Info storage position)
{
    position = positions.get(owner, tickLower, tickUpper);

    // TODO: Fee
    uint256 _feeGrowthGlobal0X128 = 0;
    uint256 _feeGrowthGlobal1X128 = 0;

    bool flippedLower;
    bool flippedUpper;

    if (liquidityDelta != 0) {
        flippedLower = ticks.update(
            tickLower,
            tick,
            liquidityDelta,
            _feeGrowthGlobal0X128,
            _feeGrowthGlobal1X128,
            false,
            maxLiquidityPerTick
        );

        flippedUpper = ticks.update(
            tickUpper, tick, liquidityDelta, _feeGrowthGlobal0X128, _feeGrowthGlobal1X128, true, maxLiquidityPerTick
        );
    }

    // TODO: Fee
    position.update(liquidityDelta, 0, 0);

    if (liquidityDelta < 0) {
        if (flippedLower) {
            ticks.clear(tickLower);
        }
        if (flippedUpper) {
            ticks.clear(tickUpper);
        }
    }
}

此处,我们使用传入的 tickLower 和 tickUpper来更新 ticks。并在最后使用 flippedLower和 flippedUpper 的情况清空了不包含流动性的 tick。

至此,我们完成了 position和 tick的更新。position用来表示用户添加的流动性区间的相关数据,而tick则主要用于存储流动性和手续费相关数据。

接下来,我们需要计算指定流动性所需要的代币数量。我们首先推导一下 Uniswap V3 的 AMM 曲线。首先,Uniswap V3 仍使用了 xy=L2x∗y=L^2 的曲线,但是在 Uniswap V3 内存在区间流动性,最终造成了以下结果:

2.png

上图中的 virtual reserves就是 Uniswap V3 曲线,但实际上 real reserves才是真实情况。因为当价格位于 PbP_b 时,区间内只存在 y 资产;而当价格位于 PbP_b 时,区间内只存在 x 资产。我们需要建立价格 PP 与 xx 和 yy 代币数量的关系。在后文内,我们使用 xRx_R 和 yRy_R 代表当前区间内 xx 和 yy 代币的数量。

Uniswap V3 AMM Solve

上图给出了更多的内容,以方便我们进行推导。其中靠近原点轴的曲线是实际代币构成的曲线,而远离原点的曲线则是在 AMM 计算过程中使用的 xy=L2x∗y=L^2 的曲线。推导如下:

xy=L2(1)x \cdot y = L^2 \tag{1}
(xR+xv)(yR+yv)=L2(2)(x_R + x_v)(y_R + y_v) = L^2 \tag{2}

此时,我们不知道 xvx_vyvy_v 的数值,我们需要借助 xy=L2x \cdot y = L^2xy=P\frac{x}{y} = P 进行推导,可以得到如下式:

x=LPx = \frac{L}{\sqrt{P}}
y=LPy = L\sqrt{P}

为了求解 xvx_v,我们计算当 xR=0x_R = 0 的情况,此时 P=PBP = P_B

xv(yR+yv)=xvLPB=L2x_v (y_R + y_v) = x_v L \sqrt{P_B} = L^2
xv=LPBx_v = \frac{L}{\sqrt{P_B}}

同理,我们计算当 yR=0y_R = 0 的情况,此时 P=PAP = P_A

(xR+xv)yv=yvLPA=L2(x_R + x_v)y_v = y_v \frac{L}{\sqrt{P_A}} = L^2
yv=LPAy_v = L \sqrt{P_A}

最后,将上述计算获得 xvx_vyvy_v 带入 (xR+xv)(yR+yv)=L2(x_R + x_v)(y_R + y_v) = L^2 可以获得如下结果:

(xR+LPB)(yR+LPA)=L2\left(x_R + \frac{L}{\sqrt{P_B}}\right)\left(y_R + L\sqrt{P_A}\right) = L^2

上述就是 AMM 曲线的最终形式,我们可以看到在此曲线内只使用了 P\sqrt{P},这也是为什么我们在上文最开始的 initialize 函数内要求用户输入 sqrtPriceX96 变量。接下来,我们需要推导在已知代币变化量 Δx\Delta_x 或者 Δy\Delta_y 的情况下计算 LL 的数值。

我们首先计算 P<PAP < P_A 的情况,此时区间内只存在数量为 Δx\Delta_xxx 资产,即 yR=0y_R = 0,所以我们可以得到以下等式:

(Δx+LPB)LPA=L2\left(\Delta_x + \frac{L}{\sqrt{P_B}}\right)L\sqrt{P_A} = L^2
(Δx+LPB)PA=L\left(\Delta_x + \frac{L}{\sqrt{P_B}}\right)\sqrt{P_A} = L
Δx=LPALPB\Delta_x = \frac{L}{\sqrt{P_A}} - \frac{L}{\sqrt{P_B}}
L=Δx1PA1PBL = \frac{\Delta_x}{\frac{1}{\sqrt{P_A}} - \frac{1}{\sqrt{P_B}}}

然后,我们计算 P>PBP > P_B 的情况,此时区间内只存在数量为 Δy\Delta_y 的 y 资产,即 xR=0x_R = 0,所以我们得到以下等式:

LPB(Δy+LPA)=L2\frac{L}{\sqrt{P_B}}(\Delta_y + L\sqrt{P_A}) = L^2
Δy=LPBLPA\Delta_y = L\sqrt{P_B} - L\sqrt{P_A}
L=ΔyPBPAL = \frac{\Delta_y}{\sqrt{P_B} - \sqrt{P_A}}

最后,我们计算 PB<P<PAP_B < P < P_A 的情况,此时我们将该区间分割为 (PB,P)(P_B, P)(P,PA)(P, P_A) 的情况。对于 (PB,P)(P_B, P) 情况内,我们可以视为 P>PBP > P_B 的情况,只不过此处的 PB=PP_B = P。对于 (P,PA)(P, P_A) 的情况,我们可以视为 P<PAP < P_A 的情况,但是此处的 PB=PP_B = P。故而可以直接套用上述公式:

L=Δx1P1PB=ΔyPPAL = \frac{\Delta_x}{\frac{1}{\sqrt{P}} - \frac{1}{\sqrt{P_B}}} = \frac{\Delta_y}{\sqrt{P} - \sqrt{P_A}}

此等式可以用于添加流动性时的计算,在已知添加 x 代币数量的情况下,计算需要添加的 y 代币数量。反之也可以计算。

关于 PB<P<PAP_B < P < P_A 的情况,读者也可以使用下图理解:

UniswapV3DeltaXAndY.png

我们继续推导我们的目标,即在已知 ΔL\Delta_L 的情况下,计算 Δx\Delta_xΔy\Delta_y。我们还是进行分情况讨论。

P<PAP < P_A 时,我们可以看到:

UniswapV3DeltaL.png

P>PBP > P_B 时,我们可以推导:

L0=yPBPAL_0 = \frac{y}{\sqrt{P_B} - \sqrt{P_A}}
L1=y+ΔyPBPAL_1 = \frac{y + \Delta_y}{\sqrt{P_B} - \sqrt{P_A}}
Δy=L1L0=ΔyPBPA\Delta_y = L_1 - L_0 = \frac{\Delta_y}{\sqrt{P_B} - \sqrt{P_A}}

PB<P<PAP_B < P < P_A 时,我们依旧是将原区间划分为两个区间来计算 Δx\Delta_xΔy\Delta_y,如下:

Complex Delta L

完成上述公式推导后,我们可以最终完成 _modifyPosition 函数,即该函数的计算指定流动性所需要的代币数量的部分。我们首先将上述公式在 clamm/src/libraries/SqrtPriceMath.sol 内实现,读者可以直接在 Uniswap 内直接将上述代码摘抄一下。此处的具体的实现读者可以自行参考相关代码。

此处我们可以讨论一下舍入问题,我们以 getAmount0Delta 为例:

function getAmount0Delta(uint160 sqrtRatioAX96, uint160 sqrtRatioBX96, int128 liquidity)
    internal
    pure
    returns (int256 amount0)
{
    return liquidity < 0
        ? -getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(-liquidity), false).toInt256()
        : getAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, uint128(liquidity), true).toInt256();
}

此处的调用的 getAmount1Delta(uint160 sqrtRatioAX96, uint160 sqrtRatioBX96, uint128 liquidity, bool roundUp) 的最后一个参数 roundUp 表示是否需要向上舍入。在此处,我们需要牢记一个原则,即所有误差都是由用户承担,流动性池不可以因为误差产生亏空。经过上文介绍,读者应该知道 getAmount0Delta 用于计算用户在指定流动性和价格的情况下,用户所需要的代币数量,所以此处我们在 liquidity >= 0 的情况下,选择向上舍入,要求用户支付更多代币;而在 liquidity < 0 的情况下,则放弃向上舍入,目标也是要求用户支付更多代币。

我们在 _modifyPosition 内按照上述描述的情况进行实现。

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

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

    if (params.liquidityDelta != 0) {
        if (_slot0.tick < params.tickLower) {
            amount0 = SqrtPriceMath.getAmount0Delta(
                TickMath.getSqrtRatioAtTick(params.tickLower),
                TickMath.getSqrtRatioAtTick(params.tickUpper),
                params.liquidityDelta
            );
        } else if (_slot0.tick > params.tickUpper) {
            amount1 = SqrtPriceMath.getAmount1Delta(
                TickMath.getSqrtRatioAtTick(params.tickLower),
                TickMath.getSqrtRatioAtTick(params.tickUpper),
                params.liquidityDelta
            );
        } else {
            uint128 liquidityBefore = liquidity;

            amount0 = SqrtPriceMath.getAmount0Delta(
                _slot0.sqrtPriceX96, TickMath.getSqrtRatioAtTick(params.tickUpper), params.liquidityDelta
            );
            amount1 = SqrtPriceMath.getAmount1Delta(
                TickMath.getSqrtRatioAtTick(params.tickLower), _slot0.sqrtPriceX96, params.liquidityDelta
            );
            liquidity = params.liquidityDelta < 0
                ? liquidityBefore - uint128(-params.liquidityDelta)
                : liquidityBefore + uint128(params.liquidityDelta);
        }
    }
}

在此处,如果添加流动性的区间包括 _slot0.tick,此处会对 liquidity 进行修改。此处的 liquidity 指在当前价格下生效的流动性数量。

至此,我们就完成了流动性提供的所有流程。在此流程内,我们以此使用了以下函数:

  1. _modifyPosition 用来修改流动性提供区间的参数并计算指定流动性提供所需要的代币数量
  2. _updatePosition 在 _modifyPosition 内部更新价格区间,目前我们实现了更新区间上限和下限 tick 的相关功能