通用 swap #
本节将会是这个 milestone 中最难的一个部分。在更新代码之前,我们首先需要知道 Uniswap V3 中的 swap 是如何工作的。
我们可以把一笔交易看作是满足一个订单:一个用户提交了一个订单,需要从池子中购买一定数量的某种 token。池子会使用可用的流动性来将投入的 token 数量“转换”成输出的 token 数量。如果在当前价格区间中没有足够的流动性,它将会尝试在其他价格区间中寻找流动性(使用我们前一节实现的函数)。
现在,我们要实现 swap
函数内部的逻辑,但仍然保证交易可以在当前价格区间内完成——跨 tick 的交易将会在下一个 milestone 中实现。
function swap(
address recipient,
bool zeroForOne,
uint256 amountSpecified,
bytes calldata data
) public returns (int256 amount0, int256 amount1) {
...
在 swap
函数中,我们新增了两个参数:zeroForOne
和 amountSpecified
。zeroForOne
是用来控制交易方向的 flag:当设置为 true,是用 token0
兑换 token1
;false 则相反。例如,如果 token0
是ETH,token1
是USDC,将 zeroForOne
设置为 true 意味着用 ETH 购买 USDC。amountSpecified
是用户希望卖出的 token 数量。
填满订单 #
由于在 Uniswap V3 中,流动性存储在不同的价格区间中,池子合约需要找到“填满当前订单”所需要的所有流动性。这个操作是通过沿着某个方向遍历所有初始化的 tick 来实现的。
在继续之前,我们需要定义两个新的结构体:
struct SwapState {
uint256 amountSpecifiedRemaining;
uint256 amountCalculated;
uint160 sqrtPriceX96;
int24 tick;
}
struct StepState {
uint160 sqrtPriceStartX96;
int24 nextTick;
uint160 sqrtPriceNextX96;
uint256 amountIn;
uint256 amountOut;
}
SwapState
维护了当前 swap 的状态。amoutSpecifiedRemaining
跟踪了还需要从池子中获取的 token 数量:当这个数量为 0 时,这笔订单就被填满了。amountCalculated
是由合约计算出的输出数量。sqrtPriceX96
和 tick
是交易结束后的价格和 tick
。
StepState
维护了当前交易“一步”的状态。这个结构体跟踪“填满订单”过程中一个循环的状态。sqrtPriceStartX96
跟踪循环开始时的价格。nextTick
是能够为交易提供流动性的下一个已初始化的tick
,sqrtPriceNextX96
是下一个 tick
的价格。amountIn
和 amountOut
是当前循环中流动性能够提供的数量。
在我们实现跨 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
的实例。我们将会循环直到 amoutSpecified
变成0,也即池子拥有足够的流动性来买用户的 amountSpecified
数量的token。
...
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.sqrtPriceX96
到 step.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
是这个价格区间可以从用户手中买走的token数量了;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
)
{
...
这是整个swap的核心逻辑所在。这个函数计算了一个价格区间内部的交易数量以及对应的流动性。它的返回值是:新的现价、输入 token 数量、输出 token 数量。尽管输入 token 数量是由用户提供的,我们仍然需要进行计算在对于 computeSwapStep
的一次调用中可以处理多少用户提供的 token。
bool zeroForOne = sqrtPriceCurrentX96 >= sqrtPriceTargetX96;
sqrtPriceNextX96 = Math.getNextSqrtPriceFromInput(
sqrtPriceCurrentX96,
liquidity,
amountRemaining,
zeroForOne
);
通过检查价格大小我们来确认交易的方向。知道交易方向后,我们就可以计算交易 amountRemaining
数量 token 之后的价格。在下面我们还会回过头来看这个函数。
找到新的价格后,我们根据之前已有的函数能够计算出输入和输出的数量(与 mint
里面用到的,根据流动性计算 token 数量的函数相同):
amountIn = Math.calcAmount0Delta(
sqrtPriceCurrentX96,
sqrtPriceNextX96,
liquidity
);
amountOut = Math.calcAmount1Delta(
sqrtPriceCurrentX96,
sqrtPriceNextX96,
liquidity
);
And swap the amounts if the direction is opposite:
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 #
现在,让我们回到 swap
函数并且完成它。
到目前为止,我们已经能够沿着下一个初始化过的tick进行循环、填满用户指定的 amoutSpecified
、计算输入和输出数量,并且找到新的价格和 tick。由于在本章节中我们只实现在一个价格区间内的交易,这些功能就已经足够了。我们现在只需要去更新合约状态、将 token 发送给用户,并从用户处获得 token。
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();
}
接下来,我们根据交易方向与用户交换 token。这个部分和我们在 milestone 1 中实现的部分一样,除了要考虑交易方向之外。
现在 Swap 已经完成了!
测试 #
测试的改变并不大,我们仅仅需要把 amoutSpecified
和 zeroForOne
传参给 swap
函数。输出的数量会略微有不同,因为这里是用 Solidity 计算的。
我们现在也可以测试相反方向的交易了!此测试留作作业给读者完成(记得选择一个较小的金额,来确保交易发生在同一个价格区间)。如果遇到困难,可以参考作者的测试。