通用交换

这将是本里程碑中最困难的章节。在更新代码之前,我们需要理解Uniswap V3中交换算法的工作原理。

你可以将交换视为填充订单:用户向池子提交一个订单,以购买指定数量的代币。池子将使用可用的流动性将输入数量"转换"为另一种代币的输出数量。如果当前价格范围内没有足够的流动性,它将尝试在其他价格范围内寻找流动性(使用我们在上一章实现的函数)。

我们现在将在swap函数中实现这个逻辑,但目前仅限于当前价格范围内——我们将在下一个里程碑中实现跨tick交换。

function swap(
    address recipient,
    bool zeroForOne,
    uint256 amountSpecified,
    bytes calldata data
) public returns (int256 amount0, int256 amount1) {
    ...

swap函数中,我们添加了两个新参数:zeroForOneamountSpecifiedzeroForOne是控制交换方向的标志:当为true时,token0被交换成token1;当为false时,则相反。例如,如果token0是ETH,token1是USDC,将zeroForOne设置为true意味着用ETH购买USDC。amountSpecified是用户想要出售的代币数量。

填充订单

由于在Uniswap V3中,流动性存储在多个价格范围内,Pool合约需要找到所有满足用户"填充订单"所需的流动性。这是通过按用户选择的方向迭代已初始化的ticks来完成的。

在继续之前,我们需要定义两个新的结构:

struct SwapState {
    uint256 amountSpecifiedRemaining;
    uint256 amountCalculated;
    uint160 sqrtPriceX96;
    int24 tick;
}

struct StepState {
    uint160 sqrtPriceStartX96;
    int24 nextTick;
    uint160 sqrtPriceNextX96;
    uint256 amountIn;
    uint256 amountOut;
}

SwapState维护当前交换的状态。amountSpecifiedRemaining跟踪池子需要购买的剩余代币数量。当它为零时,交换完成。amountCalculated是合约计算的输出数量。sqrtPriceX96tick是交换完成后的新当前价格和tick。

StepState维护当前交换步骤的状态。这个结构跟踪"填充订单"的一次迭代的状态。sqrtPriceStartX96跟踪迭代开始时的价格。nextTick是将为交换提供流动性的下一个已初始化tick,sqrtPriceNextX96是下一个tick的价格。amountInamountOut是当前迭代的流动性可以提供的数量。

在我们实现跨tick交换(即发生在多个价格范围内的交换)之后,迭代的概念将会更加清晰。

// src/UniswapV3Pool.sol

function swap(...) {
    Slot0 memory slot0_ = slot0;

    SwapState memory state = SwapState({
        amountSpecifiedRemaining: amountSpecified,
        amountCalculated: 0,
        sqrtPriceX96: slot0_.sqrtPriceX96,
        tick: slot0_.tick
    });
    ...

在填充订单之前,我们初始化一个SwapState实例。我们将循环直到amountSpecifiedRemaining为0,这意味着池子有足够的流动性从用户那里购买amountSpecified数量的代币。

...
while (state.amountSpecifiedRemaining > 0) {
    StepState memory step;

    step.sqrtPriceStartX96 = state.sqrtPriceX96;

    (step.nextTick, ) = tickBitmap.nextInitializedTickWithinOneWord(
        state.tick,
        1,
        zeroForOne
    );

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

在循环中,我们设置一个应该为交换提供流动性的价格范围。这个范围从state.sqrtPriceX96step.sqrtPriceNextX96,其中后者是下一个已初始化tick的价格(由nextInitializedTickWithinOneWord返回——我们在前一章节中了解了这个函数)。

(state.sqrtPriceX96, step.amountIn, step.amountOut) = SwapMath
    .computeSwapStep(
        state.sqrtPriceX96,
        step.sqrtPriceNextX96,
        liquidity,
        state.amountSpecifiedRemaining
    );

接下来,我们计算当前价格范围可以提供的数量,以及交换将导致的新的当前价格。

    state.amountSpecifiedRemaining -= step.amountIn;
    state.amountCalculated += step.amountOut;
    state.tick = TickMath.getTickAtSqrtRatio(state.sqrtPriceX96);
}

循环的最后一步是更新SwapState。step.amountIn是价格范围可以从用户那里购买的代币数量;step.amountOut是池子可以卖给用户的相关的另一种代币的数量。state.sqrtPriceX96是交换后将设置的当前价格(回想一下,交易会改变当前价格)。

SwapMath合约

让我们仔细看看SwapMath.computeSwapStep

// src/lib/SwapMath.sol
function computeSwapStep(
    uint160 sqrtPriceCurrentX96,
    uint160 sqrtPriceTargetX96,
    uint128 liquidity,
    uint256 amountRemaining
)
    internal
    pure
    returns (
        uint160 sqrtPriceNextX96,
        uint256 amountIn,
        uint256 amountOut
    )
{
    ...

这是交换的核心逻辑。该函数在一个价格范围内计算交换数量,并考虑可用的流动性。它将返回:新的当前价格以及输入和输出代币数量。尽管输入数量是由用户提供的,我们仍然计算它,以了解一次computeSwapStep调用处理了用户指定输入数量的多少。

bool zeroForOne = sqrtPriceCurrentX96 >= sqrtPriceTargetX96;

sqrtPriceNextX96 = Math.getNextSqrtPriceFromInput(
    sqrtPriceCurrentX96,
    liquidity,
    amountRemaining,
    zeroForOne
);

通过检查价格,我们可以确定交换的方向。知道方向后,我们可以计算交换amountRemaining代币后的价格。我们稍后会回到这个函数。

在找到新价格后,我们可以使用我们已有的函数计算交换的输入和输出数量(这些函数与我们在mint函数中用于从流动性计算代币数量的函数相同):

amountIn = Math.calcAmount0Delta(
    sqrtPriceCurrentX96,
    sqrtPriceNextX96,
    liquidity
);
amountOut = Math.calcAmount1Delta(
    sqrtPriceCurrentX96,
    sqrtPriceNextX96,
    liquidity
);

如果方向相反,则交换这些数量:

if (!zeroForOne) {
    (amountIn, amountOut) = (amountOut, amountIn);
}

这就是computeSwapStep的全部内容!

通过交换数量找到价格

现在让我们看看Math.getNextSqrtPriceFromInput——这个函数根据另一个、流动性和输入数量计算。它告诉我们在给定当前价格和流动性的情况下,交换指定输入数量的代币后价格将会是多少。

好消息是我们已经知道这些公式:回想一下我们在Python中是如何计算price_next的:

# When amount_in is token0
price_next = int((liq * q96 * sqrtp_cur) // (liq * q96 + amount_in * sqrtp_cur))
# When amount_in is token1
price_next = sqrtp_cur + (amount_in * q96) // liq

我们将在Solidity中实现这个:

// src/lib/Math.sol
function getNextSqrtPriceFromInput(
    uint160 sqrtPriceX96,
    uint128 liquidity,
    uint256 amountIn,
    bool zeroForOne
) internal pure returns (uint160 sqrtPriceNextX96) {
    sqrtPriceNextX96 = zeroForOne
        ? getNextSqrtPriceFromAmount0RoundingUp(
            sqrtPriceX96,
            liquidity,
            amountIn
        )
        : getNextSqrtPriceFromAmount1RoundingDown(
            sqrtPriceX96,
            liquidity,
            amountIn
        );
}

这个函数处理两个方向的交换。由于计算方法不同,我们将在单独的函数中实现它们。

function getNextSqrtPriceFromAmount0RoundingUp(
    uint160 sqrtPriceX96,
    uint128 liquidity,
    uint256 amountIn
) internal pure returns (uint160) {
    uint256 numerator = uint256(liquidity) << FixedPoint96.RESOLUTION;
    uint256 product = amountIn * sqrtPriceX96;

    if (product / amountIn == sqrtPriceX96) {
        uint256 denominator = numerator + product;
        if (denominator >= numerator) {
            return
                uint160(
                    mulDivRoundingUp(numerator, sqrtPriceX96, denominator)
                );
        }
    }

    return
        uint160(
            divRoundingUp(numerator, (numerator / sqrtPriceX96) + amountIn)
        );
}

在这个函数中,我们实现了两个公式。在第一个return处,它实现了我们在Python中实现的相同公式。这是最精确的公式,但在将amountIn乘以sqrtPriceX96时可能会溢出。这个公式是(我们在"输出数量计算"中讨论过):

当它溢出时,我们使用一个替代公式,这个公式精度较低:

这实际上就是将前一个公式的分子和分母都除以,以消除分子中的乘法。

另一个函数的数学计算更简单:

function getNextSqrtPriceFromAmount1RoundingDown(
    uint160 sqrtPriceX96,
    uint128 liquidity,
    uint256 amountIn
) internal pure returns (uint160) {
    return
        sqrtPriceX96 +
        uint160((amountIn << FixedPoint96.RESOLUTION) / liquidity);
}

完成交换

现在,让我们回到swap函数并完成它。

到目前为止,我们已经循环遍历了下一个初始化的ticks,填充了用户指定的amountSpecified,计算了输入和输出数量,并找到了新的价格和tick。由于在这个里程碑中,我们只实现一个价格范围内的交换,这就足够了。现在我们需要更新合约的状态,向用户发送代币,并获取交换的代币。

if (state.tick != slot0_.tick) {
    (slot0.sqrtPriceX96, slot0.tick) = (state.sqrtPriceX96, state.tick);
}

首先,我们设置新的价格和tick。由于这个操作会写入合约的存储,为了优化gas消耗,我们只在新的tick不同时才执行这个操作。

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

接下来,我们根据交换方向和在交换循环中计算的数量来计算交换金额。

if (zeroForOne) {
    IERC20(token1).transfer(recipient, uint256(-amount1));

    uint256 balance0Before = balance0();
    IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(
        amount0,
        amount1,
        data
    );
    if (balance0Before + uint256(amount0) > balance0())
        revert InsufficientInputAmount();
} else {
    IERC20(token0).transfer(recipient, uint256(-amount0));

    uint256 balance1Before = balance1();
    IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(
        amount0,
        amount1,
        data
    );
    if (balance1Before + uint256(amount1) > balance1())
        revert InsufficientInputAmount();
}

接下来,我们根据交换方向与用户交换代币。这部分与里程碑2中的内容相同,只是增加了处理另一个交换方向的逻辑。

就是这样!交换完成了!

测试

测试不会有太大变化,我们只需要将amountSpecifiedzeroForOne传递给swap函数。不过,输出数量会有微小的变化,因为现在是在Solidity中计算的。

我们现在可以测试相反方向的交换了!我将把这个作为作业留给你(只需确保选择一个小的输入数量,以便我们的单一价格范围可以处理整个交换)。如果感到困难,不要犹豫查看我的测试