广义的兑换

  • Jeiwan
  • 发布于 2025-10-03 13:45
  • 阅读 12

这将是本里程碑中最难的一章。在更新代码之前,我们需要了解 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 合约需要找到所有 "填充 "用户订单所需的流动性。这是通过在用户选择的方向上迭代初始化后的 tick 来完成的。

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

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——该函数根据另一个 $\sqrt{P}$、流动性和输入量来计算 $\sqrt{P}$。它告诉你在兑换指定数量的代币输入量后,给定当前价格和流动性,价格会是多少。

好消息是,我们已经知道了公式:回想一下我们如何在 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 时,它可能会溢出。该公式是(我们在 "输出金额计算 "中讨论过): $$\sqrt{P_{target}} = \frac{\sqrt{P}L}{\Delta x \sqrt{P} + L}$$

当它溢出时,我们使用替代公式,该公式不太精确: $$\sqrt{P_{target}} = \frac{L}{\Delta x + \frac{L}{\sqrt{P}}}$$

这仅仅是前一个公式,分子和分母都除以 $\sqrt{P}$ 以消除分子中的乘法。

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

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

完成兑换

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

至此,我们已经循环遍历了下一个初始化后的 tick,填充了用户指定的 amountSpecified,计算了输入和数量,并找到了新的价格和 tick。由于在本里程碑中,我们只实现一个价格范围内的兑换,因此这已经足够了。我们现在需要更新合约的状态,将代币发送给用户,并换取代币。

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

首先,我们设置一个新的价格和 tick。由于此操作会写入合约的存储,因此我们只想在新 tick 不同的情况下才执行此操作,以优化 gas 消耗。

(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 中计算的。

我们现在可以测试相反方向的兑换!我将把这作为家庭作业留给你(只需确保选择一个小的输入量,以便我们单个价格范围可以处理整个兑换)。如果这感觉很困难,请随时查看我的测试

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

0 条评论

请先 登录 后评论