Uniswap V3:Swap

在这里插入图片描述
作者:WongSSH

引言

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

​Swap

swap 环节是 Uniswap 最复杂的环节之一,在本节内,我们将 swap 分为多个环节依次介绍。在 Uniswap 的白皮书内,给出了以下流程图:
在这里插入图片描述
当用户的代币输入后,会在当前区间进行 swap 操作,然后检查是否完成了用户所有资金的兑换,如果没有,则寻找下一个价格区间继续进行兑换;如果已完成,则进行最终的代币转移。

computeSwapStep

我们先分析 computeSwapStep 函数,该函数的作用是在当前价格区间内进行代币的互换,该函数定义如下:


function computeSwapStep(
    uint160 sqrtRatioCurrentX96,
    uint160 sqrtRatioTargetX96,
    uint128 liquidity,
    int256 amountRemaining,
    uint24 feePips
) internal pure returns (uint160 sqrtRatioNextX96, uint256 amountIn, uint256 amountOut, uint256 feeAmount) {
    
}

此函数的参数为:

  1. sqrtRatioCurrentX96 当前的池子内的价格
  2. sqrtRatioCurrentX96 当前的价格区间的下一个价格,我们现在可以认为该参数用于锁定价格区间,保证 computeSwapStep 只在某一区间内进行 swap 操作
  3. liquidity 流动性,当前可用的流动性数量
  4. amountRemaining 需要兑换的代币数量
  5. feePips 手续费。Uniswap V3 使用 1e6 的精度保存手续费,即 1e6 = 100%,而手续费的最小精度为 0.01%。所以 feePips = 1 相当于 0.01% 的手续费。

返回值内的 sqrtRatioCurrentX96 代表当前 swap 结束后的价格,假如用户可以在当前区间完成所有兑换,则该价格就是兑换后价格;假如用户在当前区间无法完成所有代币兑换,则该价格会变成当前流动性区间的最大价格。而 amountInamountOut 则代表兑换的结果输出,在后文,我们会介绍为什么会有两个兑换结果的输出。而 feeAmount 则是当前兑换所需要的手续费。

由于 swap 内,大量涉及代币的顺序问题,在此处,我们可以认为以下几种说法是一致的:

  1. token0 / x x x 资产
  2. token1 / y y y 资产

computeSwapStep 中的第一步是先确定兑换的方向,即是使用 token0 兑换 token1 还是使用 token1 兑换 token0。我们可以利用 computeSwapStep 输入的 sqrtRatioCurrentX96sqrtRatioTargetX96 参数确定

我们可以推导出当 sqrtRatioCurrentX96 >= sqrtRatioTargetX96 时,应该为 token 0 -> token1 ,即:

bool zeroForOne = sqrtRatioCurrentX96 >= sqrtRatioTargetX96;

Uniswap V3 中,进行代币兑换有两种模式,一种是给定输入,要求将所有输入代币转化为输出代币,比如我们给定 1000 USDT 输入,要求 Uniswap V3 给定足够数量的 ETH 输出。另一种模式是给定输出,要求 Uniswap V3 基于我们的输出计算我的输入代币,比如给定我们需要兑换 1 ETH,要求 Uniswap V3 计算所吸引的 USDT 的数量。在实现上,给定输入还是给定输出取决于 amountRemaining 的正负情况。

amountRemaining >= 0 时, amountRemaining 等于输入代币的数量。

bool exactIn = amountRemaining >= 0;

接下来,我们可以分情况计算两种不同的模式。我们首先计算给定输入的情况,即 exactIn = true 的情况。我们首先需要知道当前区间所能接受的最大代币输入。即给定价格区间和流动性,计算当前流动性对应的代币数量。在上文介绍 _modifyPosition 时,我们已经介绍了 getAmount0DeltagetAmount1Delta 函数,这些函数刚好可以用来计算指定流动性下可以接受的最大代币数量。代码实现如下:

amountIn = zeroForOne
    ? SqrtPriceMath.getAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true)
    : SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, true);

此处,需要注意 getAmount0Delta 要求第一个价格参数小于第二个价格参数,在此处,我们可以根据 zeroForOne 判断价格参数的大小。而且,注意 getAmount0Delta 计算是存在误差的,我们此处将 roundUp 设置为 true 使得用户承担误差。

接下来,我们要根据 amountIn 计算 swap 结束后的价格。此处也分为两种情况,第一种是在当前区间兑换没有全部完成,此时 swap 结束的价格就是当前价格区间的最大价格。我们可以根据 amountRemaining - fee >= amountIn 来判断。等同于我们支付了过多的输入代币,当前价格区间无法容纳。注意,我们在此处增加了手续费的计算。我们可以认为手续费是在用户的资金进入系统后立马扣除了,手续费部分不参与曲线上的计算。我们首先计算 amountRemaining - fee 的结果:

uint256 amountRemainingLessFee = FullMath.mulDiv(uint256(amountRemaining), 1e6 - feePips, 1e6);

然后,我们可以使用以下代码表示 amountRemainingLessFee >= amountIn 的情况,如下:

if (amountRemainingLessFee >= amountIn) {
    sqrtRatioNextX96 = sqrtRatioTargetX96;
} else {
    
}

另一种情况时,在当前区间,用户所有的输入都被耗尽,此时我们需要计算价格。在此处,我们需要使用 amountRemainingLessFee 作为参数,因为手续费是一个 AMM 曲线外逻辑。代码如下:

if (amountRemainingLessFee >= amountIn) {
    sqrtRatioNextX96 = sqrtRatioTargetX96;
} else {
    sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromInput(
        sqrtRatioCurrentX96, liquidity, amountRemainingLessFee, zeroForOne
    );
}

此处我们直接调用了 getNextSqrtPriceFromInput 函数。该函数的具体计算原理是较为简单,我们可以使用上文给出的:

L = Δ x 1 P A − 1 P B L = \frac{\Delta_x}{\frac{1}{\sqrt{P_A}} - \frac{1}{\sqrt{P_B}}} L=PA 1PB 1Δx

L = Δ y P B − P A L = \frac{\Delta_y}{\sqrt{P_B} - \sqrt{P_A}} L=PB PA Δy

使用上述公式可以求解出:

P A = P − Δ y L \sqrt{P_A} = \sqrt{P} - \frac{\Delta_y}{L} PA =P LΔy

P B = P + Δ y L \sqrt{P_B} = \sqrt{P} + \frac{\Delta_y}{L} PB =P +LΔy

也可以求解获得:

P A = L P B L + y P B \sqrt{P_A} = \frac{L \sqrt{P_B}}{L + y \sqrt{P_B}} PA =L+yPB LPB

P B = L P A L − y P A \sqrt{P_B} = \frac{L \sqrt{P_A}}{L - y \sqrt{P_A}} PB =LyPA LPA

所以,我们可以在已知 liquidity / sqrtRatioCurrentX96amountRemainingLessFee 的情况下计算出价格。

接下来,我们处理另一种情况,即 amountRemaining 数值代表代币输出数量的情况。

amountOut = zeroForOne
    ? SqrtPriceMath.getAmount1Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, false)
    : SqrtPriceMath.getAmount0Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, false);

我们还是首先计算在当前流动性下,可以输出的最大代币数量。当用户选择 token 0 -> token 1 时,即 zeroForOne = true 时,此时我们使用 getAmount1Delta 计算 token1 的数量。相反,当用户选择 token 1 -> token 0 时,我们使用 getAmount0Delta 计算 token 0 的数量。此处,我们没有将 roundUp 置为 true,是因为此时计算出的输出应该向下取整,避免用户获得更多的代币。

之后,我们依旧需要判断当前区间是否可以完成这笔兑换:

if (uint256(-amountRemaining) >= amountOut) {
    sqrtRatioNextX96 = sqrtRatioTargetX96;
} else {
    sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromOutput(
        sqrtRatioCurrentX96, liquidity, uint256(-amountRemaining), zeroForOne
    );
}

此处的代码也适用于计算 sqrtRatioNextX96 变量。在当前区间无法完成兑换时,直接将 sqrtRatioNextX96 置为 sqrtRatioTargetX96 。反之,则使用流动性等因子计算价格。

接下来,我们需要计算真正的 amountInamountOut 。在上文内,我们计算出的 amountIn 或者 amountOut 实际上都是在假设完全消耗区间流动性的情况下计算出的。而实际情况不一定消耗了所有的区间流动性。我们首先使用以下代码计算当前是否属于区间流动性被耗尽的情况:

bool max = sqrtRatioTargetX96 == sqrtRatioNextX96;

之后,我们可以分情况进行讨论:

  1. max = true && exactIn = true 的情况。此时等同于区间流动性被耗尽,此时 amountIn 可以直接使用,但输出 amountOut 需要单独计算
  2. max = true && exactIn = False 的情况。此 amountOut 可以直接使用,但 amountIn 需要单独计算
  3. 其他情况下,由于区间流动性没有被耗尽,我们之前计算出的 amountInamountOut 都没有作用,所以我们都需要重新计算

我们首先计算在 zeroForOne = true 的情况下, amountInamountOut 的数值:

amountIn = max && exactIn
    ? amountIn
    : SqrtPriceMath.getAmount0Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, true);
amountOut = max && !exactIn
    ? amountOut
    : SqrtPriceMath.getAmount1Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, false);

max && exactIn 都成立的情况下, amountIn 的数值为 amountIn ,否则就需要使用更新后的 sqrtRatioNextX96 进行计算。 amountOut 同理。

然后,我们需要计算 zeroForOne = false 的情况下的 amountInamountOut 的数值:

amountIn = max && exactIn
    ? amountIn
    : SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, true);
amountOut = max && !exactIn
    ? amountOut
    : SqrtPriceMath.getAmount0Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, false);

最后,我们需要限制 amountOut 的输出数值,如下:

if (!exactIn && amountOut > uint256(-amountRemaining)) {
    amountOut = uint256(-amountRemaining);
}

这是为了防止计算出现误差,导致合约输出了大于用户要求的代币数量。这个误差会发生在 !exactIn && !max 的情况下,此时使用 sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPrice FromOutput(sqrtRatioCurrentX96, liquidity, uint256(-amountRemaining), zeroForOne ); 计算下一个价格,然后使用 getAmount0DeltagetAmount1Delta 计算真正的输入和输出。在此环节内,由于 sqrtRatioNextX96 可能存在误差,导致计算出的 amountOut 稍大于 uint256(-amountRemaining)

最后,我们完成手续费的计算。手续费的计算需要以下推导:

x = a + f e e = a + x ⋅ f x = a + fee = a + x \cdot f x=a+fee=a+xf

x = a 1 − f x = \frac{a}{1 - f} x=1fa

f e e = x ⋅ f = a ⋅ f 1 − f fee = x \cdot f = \frac{a \cdot f}{1 - f} fee=xf=1faf

上述公式内的 a 代表不带手续费的代表输入, 代表包含手续费的代币输入, 代表手续费率。基于上述推导,我们可以得到以下代码:

if (exactIn && sqrtRatioNextX96 != sqrtRatioTargetX96) {
    feeAmount = uint256(amountRemaining) - amountIn;
} else {
    feeAmount = FullMath.mulDivRoundingUp(amountIn, feePips, 1e6 - feePips);
}

此处,我们简化了部分计算。即当用户给定输入代币数量并且 swap 后价格没有移动到价格区间外时,我们直接将用户所有输入减去 amountIn 的部分作为手续费。这实际上也是让用户承担误差的一种手段。

swap 非核心代码

在本节中,我们将分析 swap 函数中的参数校验部分,也是本节开始时给出的流程图中的 S0环节。我们依旧先给出 swap 的函数定义:

function swap(address recipient, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96)
    external
    returns (int256 amount0, int256 amount1)
{}

swap 函数的 recipient 参数表示代币兑换后的接收方。 zeroForOne 表示代币兑换的方向,当 zeroForOne = true 时,代表用户是使用 token 0 兑换 token 1 ,反之则表示用户使用 token 1 兑换 token 0amountSpecified 表示代币兑换的数量,当该数值为正时,代表用户给定了输入代币的数量,要求池子给出输出代币的数量;当该数值为负时,代表用户给定了输出代币的数量,要求池子给出输入代币的数量。sqrtPriceLimitX96 用于限定兑换价格,当兑换价格到达 sqrtPriceLimitX96 时,swap 就会终止。

除此外,Uniswap V3 定义了一系列结构体用于 swap 过程中缓存数据。定义如下:

struct SwapCache {
    // liquidity at the beginning of the swap
    uint128 liquidityStart;
}

struct SwapState {
    // the amount remaining to be swapped in/out of the input/output asset
    int256 amountSpecifiedRemaining;
    // the amount already swapped out/in of the output/input asset
    int256 amountCalculated;
    // current sqrt(price)
    uint160 sqrtPriceX96;
    // the tick associated with the current price
    int24 tick;
    // the global fee growth of the input token
    uint256 feeGrowthGlobalX128;
    // the current liquidity in range
    uint128 liquidity;
}

struct StepComputations {
    // the price at the beginning of the step
    uint160 sqrtPriceStartX96;
    // the next tick to swap to from the current tick in the swap direction
    int24 tickNext;
    // whether tickNext is initialized or not
    bool initialized;
    // sqrt(price) for the next tick (1/0)
    uint160 sqrtPriceNextX96;
    // how much is being swapped in in this step
    uint256 amountIn;
    // how much is being swapped out
    uint256 amountOut;
    // how much fee is being paid in
    uint256 feeAmount;
}

这些结构体都会在 swap 过程中使用。我们首先校验 amountSpecified 参数,该参数不应该为 0 ,因为该参数表示兑换的代币数量。对应的校验代码如下:

require(amountSpecified != 0, "AS");

为了避免重入攻击,此处我们也需要校验重入锁部分:

Slot0 memory slot0Start = slot0;

require(slot0Start.unlocked, "LOK");

然后,我们校验 sqrtPriceLimitX96 参数,该参数的取值取决于 OneForZero 参数。我们再次给出上文的结论:当 sqrtRatioCurrentX96 >= sqrtRatioTargetX96 时,应该为 token 0 -> token1。所以,我们可以获得以下结论:

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

即当 zeroForOne = true 时, sqrtPriceLimitX96 应该小于当前的价格 slot0Start.sqrtPriceX9,但是需要大于最小价格;当 zeroForOne = false 时,sqrtPriceLimitX96 应该大于当前价格 slot0Start.sqrtPriceX9,但应当小于最大价格。

完成上述校验后,我们最后将重入锁重新锁定:

slot0.unlocked = false;

完成上述的入参校验后,我们将相关数据缓存到上文给出的价格结构体内部:

SwapCache memory cache = SwapCache({liquidityStart: liquidity});

bool exactInput = amountSpecified > 0;

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

在此处,我们完成了 SwapCacheSwapState 的初始化。在 SwapState 内, amountCalculated 代表在当前兑换过程中,所需要的另一种代币数量,该数值会不断累积。而 feeGrowthGlobalX128 则用于缓存当前的手续费情况,并会在 swap 过程中不断更新。上述缓存内的大部分数据我们最终都需要写回存储内部。

在完成上述数据缓存后,swap 函数会进入真正的兑换流程。兑换流程是一个循环操作,首先找到可用的流动性区间,然后在可用的流动性区间内调用我们上文编写的 computeSwapStep 函数进行兑换操作,在完成兑换操作后更新缓存内的数据,并最终判断是否需要跳出循环。该部分代码较为复杂,我们会在下一节介绍。

当上述 swap 循环完成后,缓存中的数据需要同步到存储内部,本节将继续介绍缓存写回存储的相关代码。代码如下:

// Update slot0 tick and sqrtPriceX96
if (state.tick != slot0Start.tick) {
    (slot0.sqrtPriceX96, slot0.tick) = (state.sqrtPriceX96, state.tick);
} else {
    slot0.sqrtPriceX96 = state.sqrtPriceX96;
}

// Update liquidity
if (cache.liquidityStart != state.liquidity) liquidity = state.liquidity;

// Update fee growth
if (zeroForOne) {
    feeGrowthGlobal0X128 = state.feeGrowthGlobalX128;
} else {
    feeGrowthGlobal1X128 = state.feeGrowthGlobalX128;
}

结束上述的缓存写回流程后,我们继续完成 swap 的最后逻辑,实现代币的转移。对于代币的转移,我们存在以下几种情况:

  1. exactIn = true && zeroForOne = true 此时 amount 0 = amountSpecified - amountSpecifiedRemaining 。原因在于当指定 token 0 数量时,可能即使兑换到用户指定的极限目标价格 (sqrtPriceLimitX96) 仍无法完成全部兑换。而 amount 1 = amountCalculated
  2. exactIn = true && zeroForOne = false 此时等同于 exactIn = true && zeroForOne = true 的反向操作,所以 amount 0 = amountCalculatedamount 0 = amountSpecified - amountSpecifiedRemaining
  3. exactIn = false && zeroForOne = true 等同于 exactIn = true && zeroForOne = false,所以 amount 0 = amountCalculatedamount 0 = amountSpecified - amountSpecifiedRemaining
  4. exactIn = false && zeroForOne = false 实际上等同于 exactIn = true && zeroForOne = true 的情况,因为此时也是使用 token 0 兑换 token 1 的场景

我们也可以使用以下表格归纳:

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

关于以上表格内部的 > 0 或者 < 0 的关系,我们可以非常简单的使用 bool exactInput = amountSpecified > 0; 的条件进行判断。当 exactInput = true 时,那么 amountSpecified > 0 ,最终计算出的结果也大于 0。反之,则小于 0。综上所述,我们可以得到以下结论:

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

最后,我们完成代币的转移,具体代码如下:

if (zeroForOne) {
    if (amount1 < 0) {
        IERC20(token1).transfer(recipient, uint256(-amount1));
        IERC20(token0).transferFrom(address(msg.sender), recipient, uint256(amount0));
    }
} else {
    if (amount0 < 0) {
        IERC20(token0).transfer(recipient, uint256(-amount0));
        IERC20(token1).transferFrom(address(msg.sender), recipient, uint256(amount1));
    }
}

此处需要注意 amount1amount00 之间的关系,在上文给出的表格内,我们已经给出了相关关系。

初试核心循环

正如上文所述,swap 的核心部分是一个循环,该循环不断寻找符合要求的含有流动性的区间使用 computeSwapStep 进行兑换操作,兑换完成后更新数据,然后进行一步决定是否继续循环还是跳出循环进入最终的结算环节。

我们首先编写循环的条件:

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

此处的 amountSpecifiedRemaining 是指用户给定代币的剩余数量。在 swap 过程中,每进行一次 swap ,我们都会在该数值内减去 swap 已经消耗的代币,所以当 state.amountSpecifiedRemaining != 0 时,说明用户仍存在代币未被兑换,此时可以继续循环。而 sqrtPriceLimitX96 是用户指定的兑换价格的极限,如果当前价格 state.sqrtPriceX96 没有达到 sqrtPriceLimitX96 ,我们可以考虑继续循环。但是当两者中,任意一个条件被满足,我们就需要跳出循环。

在本节内部,我们不会完成核心循环的所有逻辑,我们只会介绍核心循环的第一次循环。因为核心循环依赖于其他较为复杂的库函数,这些库函数我们会在下文进行介绍。我们首先编写第一次循环过程中的基础代码:

StepComputations memory step;
step.sqrtPriceStartX96 = state.sqrtPriceX96;

step.tickNext = zeroForOne ? state.tick - 1 : state.tick + 1;
if (step.tickNext < TickMath.MIN_TICK) {
    step.tickNext = TickMath.MIN_TICK;
} else if (step.tickNext > TickMath.MAX_TICK) {
    step.tickNext = TickMath.MAX_TICK;
}

step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(step.tickNext);

在此部分代码内,我们直接假设 step.tickNext 就是当 state.tick 的下一个 tick。我们会在后文引入 bitmap 进行下一个流动性区间的搜索,但在此处,我们直接使用了一个 mock 数值。当然,我们需要保证 step.tickNext 在预期的 tick 范围内部。

接下来,我们需要在流动性区间内调用 computeSwapStep 函数进行相关计算。在 computeSwapStep 函数内部,我们需要输入 sqrtRatioTargetX96 变量,此变量较为复杂,具有以下几种情况:

  1. zeroForOne = true 时, sqrtRatioTargetX96 = max(next, limit)
  2. zeroForOne = false 时,sqrtRatioTargetX96 = min(next, limit)

我们最终可以获得如下代码:

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

对于最为复杂的 sqrtRatioTargetX96 的三目表达式计算,我们可以展开为:

  1. zeroForOne = true 的情况下

    • step.sqrtPriceNextX96 < sqrtPriceLimitX96 成立,此时返回 sqrtPriceLimitX96
    • step.sqrtPriceNextX96 > sqrtPriceLimitX96 成立,此时返回 step.sqrtPriceNextX96
  2. zeroForOne = false 的情况下

    • step.sqrtPriceNextX96 < sqrtPriceLimitX96 成立,此时返回 step.sqrtPriceNextX96
    • step.sqrtPriceNextX96 > sqrtPriceLimitX96 成立,此时返回 sqrtPriceLimitX96

上述三目表达式实际上就是以上四种情况的总结版本。然后,我们根据计算结果更新状态变量:

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

此处当 exactInput = true 时,我们可以知道 state.amountSpecifiedRemaining > 0,所以我们需要减去兑换消耗的 step.amountInstep.feeAmount 。当 exactInput = false 时,我们可以知道 state.amountSpecifiedRemaining < 0 ,所以我们会使用 step.amountOut 进行相加操作,对于 state.amountCalculated 也同理。

由于本节只是初步介绍,所以跳过了 step.nextTick 的计算和跨区间的流动性修改的部分函数。