交易过程
v3 的 UniswapV3Pool
提供了比较底层的交易接口,而在 SwapRouter
合约中封装了面向用户的交易接口:
exactInput
:指定交易对路径,付出的 x token 数和预期得到的最小 y token 数(x, y 可以互换)exactOutput
:指定交易路径,付出的 x token 最大数和预期得到的 y token 数(x, y 可以互换)
这里我们讲解 exactInput
这个接口,调用流程如下:
路径选择
在进行两个代币交易时,是首先需要在链下计算出交易的路径,例如使用 ETH
-> DAI
:
- 可以直接通过
ETH/DAI
的交易池完成 - 也可以通过
ETH
->USDC
->DAI
路径,即经过ETH/USDC
,ETH/DAI
两个交易池完成交易
Uniswap 的前端会帮用户实时计算出最优路径(即交易的收益最高),作为参数传给合约调用,这部分计算需要考虑价格,深度两个因素来进行最优选择。
事实上因为 v3 引入了费率的原因,在路径选择的过程中还需要考虑费率的因素,即路径中需要包含每个交易对所选择的费率。
交易入口
交易的入口函数是 exactInput
函数,代码如下:
struct ExactInputParams {
bytes path; // 路径
address recipient; // 收款地址
uint256 deadline; // 交易有效期
uint256 amountIn; // 输入的 token 数(输入的 token 地址就是 path 中的第一个地址)
uint256 amountOutMinimum; // 预期交易最少获得的 token 数(获得的 token 地址就是 path 中最后一个地址)
}
function exactInput(ExactInputParams memory params)
external
payable
override
checkDeadline(params.deadline)
returns (uint256 amountOut)
{
// 通过循环,遍历传入的路径,进行交易
while (true) {
bool hasPools = params.path.hasPools();
// 完成当前路径的交易
params.amountIn = exactInputSingle(
params.amountIn,
// 如果是中间交易,又合约代为收取和支付中间代币
hasPools ? address(this) : params.recipient,
// 给回调函数用的参数
SwapData({
path: params.path.getFirstPool(),
payer: msg.sender
})
);
// 如果路径全部遍历完成,则退出循环,交易完成
if (hasPools) {
// 步进 path 中的值
params.path = params.path.skipToken();
} else {
amountOut = params.amountIn;
break;
}
}
// 检查交易是否满足预期
require(amountOut >= params.amountOutMinimum, 'Too little received');
}
这里使用一个循环遍历传入的路径,路径中包含了交易过程中所有的 token,每相邻的两个 token 组成了一个交易对。例如当需要通过 ETH
-> USDC
-> DAI
路径进行交易时,会经过两个池:ETH/USDC
和 USDC/DAI
,最终得到 DAI
代币。如前所述,这里其实还包含了每个交易对所选择的费率。
路径编码/解码
上面输入的参数中 path
字段是 byte32
类型,通过这种类型可以实现更紧凑的编码。Uniswap 会将 byte32
作为一个数组使用,其内部编码结构如下图:
图中展示了一个包含 2个路径(pool0, 和 pool1)的 path 编码。Uniswap 将编码解码操作封装在了 Path
库中,本文不再赘述其过程。每次交易时,会取出头部的 tokenIn
, tokenOut
, fee
,使用这三个参数找到对应的交易池,完成交易。
单个池的交易过程
单个池的交易在 exactInputSingle
函数中:
function exactInputSingle(
uint256 amountIn,
address recipient,
SwapData memory data
) private returns (uint256 amountOut) {
// 将 path 解码,获取头部的 tokenIn, tokenOut, fee
(address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();
// 因为交易池只保存了 token x 的价格,这里我们需要知道输入的 token 是交易池 x token 还是 y token
bool zeroForOne = tokenIn < tokenOut;
// 完成交易
(int256 amount0, int256 amount1) =
getPool(tokenIn, tokenOut, fee).swap(
recipient,
zeroForOne,
amountIn.toInt256(),
zeroForOne ? MIN_SQRT_RATIO : MAX_SQRT_RATIO,
// 给回调函数用的参数
abi.encode(data)
);
return uint256(-(zeroForOne ? amount1 : amount0));
}
交易过程就是先获取交易池,然后需要确定本次交易输入的是交易池的 x token, 还是 y token,这是因为交易池中只保存了 x 的价格 $\sqrt P = \sqrt {\frac yx}$,x token 和 y token 的计价公式是不一样的。最后调用 UniswapV3Pool
的 swap
函数完成交易。
交易分解
UniswapV3Pool.swap
函数比较长,这里先简要描述其交易步骤:
假设支付的 token 为 x
- 根据买入/卖出行为,$\sqrt P$ 会随着交易下降或上升,即 tick 减小或增大
- 在 tickBitmap 中找到和当前 tick 对应的 $i_c$ 在一个 word 中的下一个 tick 对应的 $i_n$,根据买入/卖出行为,这里分成向下查找和向上查找两种情况
- 如果当前 word 中没有记录其他 tick index ,那么取这个 word 的最小/最大 tick index
- 在 $[i_c, i_n]$ 价格区间内,流动性 $L$ 的值是不变的,我们可以根据 $L$ 的值计算出交易运行到 $i_n$ 时,所需要最多的 $\Delta x$ 数量
- 根据上一步计算的 $\Delta x$ 数量,如果满足 $\Delta x < x_{remaining}$,那么将 $i$ 设置为 $i_n$,并将 $x_remaining$ 减去需要支付的 $\Delta x$,随后跳至第 2 步继续计算(这里需要将 $i \pm tickSpace$ 使其进入位图中的下一个 word),计算之前还需要根据元数据修改当前的流动性 $L = L \pm \Delta L$
- 如果上一步计算 $\Delta x$,满足 $\Delta x \geq x_{remaining}$,则表示 x token 将被耗尽,则交易在此结束。
- 记录下结束时的价格 $\sqrt P$,将所有交易阶段的 tokenOut 数量总和返回,即为用户得到的 token 数量
- 上一步的计算过程还需要考虑费率的因素,为了让计算简单化,可能会多收费
我们逐步拆解 swap
函数中的代码:
...
// 将交易前的元数据保存在内存中,后续的访问通过 `MLOAD` 完成,节省 gas
Slot0 memory slot0Start = slot0;
...
// 防止交易过程中回调到合约中其他的函数中修改状态变量
slot0.unlocked = false;
// 这里也是缓存交易钱的数据,节省 gas
SwapCache memory cache =
SwapCache({
liquidityStart: liquidity,
blockTimestamp: _blockTimestamp(),
feeProtocol: zeroForOne ? (slot0Start.feeProtocol % 16) : (slot0Start.feeProtocol >> 4)
});
// 判断是否指定了 tokenIn 的数量
bool exactInput = amountSpecified > 0;
// 保存交易过程中计算所需的中间变量,这些值在交易的步骤中可能会发生变化
SwapState memory state =
SwapState({
amountSpecifiedRemaining: amountSpecified,
amountCalculated: 0,
sqrtPriceX96: slot0Start.sqrtPriceX96,
tick: slot0Start.tick,
feeGrowthGlobalX128: zeroForOne ? feeGrowthGlobal0X128 : feeGrowthGlobal1X128,
protocolFee: 0,
liquidity: cache.liquidityStart
});
...
上面的代码都是交易前的准备工作,实际的交易在一个循环中发生:
// 只要 tokenIn
while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
// 交易过程每一次循环的状态变量
StepComputations memory step;
// 交易的起始价格
step.sqrtPriceStartX96 = state.sqrtPriceX96;
// 通过位图找到下一个可以选的交易价格,这里可能是下一个流动性的边界,也可能还是在本流动性中
(step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(
state.tick,
tickSpacing,
zeroForOne
);
...
// 从 tick index 计算 sqrt(price)
step.sqrtPriceNextX96 = TickMath.getSqrtRatioAtTick(step.tickNext);
// 计算当价格到达下一个交易价格时,tokenIn 是否被耗尽,如果被耗尽,则交易结束,还需要重新计算出 tokenIn 耗尽时的价格
// 如果没被耗尽,那么还需要继续进入下一个循环
(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
);
// 更新 tokenIn 的余额,以及 tokenOut 数量,注意当指定 tokenIn 的数量进行交易时,这里的 tokenOut 是负数
if (exactInput) {
state.amountSpecifiedRemaining -= (step.amountIn + step.feeAmount).toInt256();
state.amountCalculated = state.amountCalculated.sub(step.amountOut.toInt256());
} else {
state.amountSpecifiedRemaining += step.amountOut.toInt256();
state.amountCalculated = state.amountCalculated.add((step.amountIn + step.feeAmount).toInt256());
}
...
// 按需决定是否需要更新流动性 L 的值
if (state.sqrtPriceX96 == step.sqrtPriceNextX96) {
// 检查 tick index 是否为另一个流动性的边界
if (step.initialized) {
int128 liquidityNet =
ticks.cross(
step.tickNext,
(zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128),
(zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128)
);
// 根据价格增加/减少,即向左或向右移动,增加/减少相应的流动性
if (zeroForOne) liquidityNet = -liquidityNet;
secondsOutside.cross(step.tickNext, tickSpacing, cache.blockTimestamp);
// 更新流动性
state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);
}
// 在这里更 tick 的值,使得下一次循环时让 tickBitmap 进入下一个 word 中查询
state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext;
} else if (state.sqrtPriceX96 != step.sqrtPriceStartX96) {
// 如果 tokenIn 被耗尽,那么计算当前价格对应的 tick
state.tick = TickMath.getTickAtSqrtRatio(state.sqrtPriceX96);
}
}
上面的代码即交易的主循环,实现思路即以一个 tickBitmap 的 word 为最大单位,在此单位内计算相同流动性区间的交易数值,如果交易没有完成,那么更新流动性的值,进入下一个流动性区间计算,如果 tick index 移动到 word 的边界,那么步进到下一个 word.
关于 tickBitmap 中下一个可用价格 tick index 的查找,在函数 TickBitmap
中实现,这里不做详细描述。
拆分后的交易计算
交易是否能够结束的关键计算在 SwapMath.computeSwapStep
中完成,这里计算了交易是否能在目标价格范围内结束,以及消耗的 tokenIn
和得到的 tokenOut
. 这里摘取此函数部分代码进行分析(这里仅摘取 exactIn
时的代码):
function computeSwapStep(
uint160 sqrtRatioCurrentX96,
uint160 sqrtRatioTargetX96,
uint128 liquidity,
int256 amountRemaining,
uint24 feePips
)
internal
pure
returns (
uint160 sqrtRatioNextX96,
uint256 amountIn,
uint256 amountOut,
uint256 feeAmount
)
{
// 判断交易的方向,即价格降低或升高
bool zeroForOne = sqrtRatioCurrentX96 >= sqrtRatioTargetX96;
// 判断是否指定了精确的 tokenIn 数量
bool exactIn = amountRemaining >= 0;
...
函数的输入参数是当前价格,目标价格,当前的流动性,以及 tokenIn 的余额。
if (exactIn) {
// 先将 tokenIn 的余额扣除掉最大所需的手续费
uint256 amountRemainingLessFee = FullMath.mulDiv(uint256(amountRemaining), 1e6 - feePips, 1e6);
// 通过公式计算出到达目标价所需要的 tokenIn 数量,这里对 x token 和 y token 计算的公式是不一样的
amountIn = zeroForOne
? SqrtPriceMath.getAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true)
: SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, true);
// 判断余额是否充足,如果充足,那么这次交易可以到达目标交易价格,否则需要计算出当前 tokenIn 能到达的目标交易价
if (amountRemainingLessFee >= amountIn) sqrtRatioNextX96 = sqrtRatioTargetX96;
else
// 当余额不充足的时候计算能够到达的目标交易价
sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromInput(
sqrtRatioCurrentX96,
liquidity,
amountRemainingLessFee,
zeroForOne
);
} else {
...
}
这里再次调用了 SqrtPriceMath.getAmount0Delta
或者 SqrtPriceMath.getAmount1Delta
来计算到达目标价是所需的 token 数量。即已知 $\sqrt P_c,\ \sqrt P_n,\ L$,求 $\Delta x$ 和 $\Delta y$. 计算的过程在上一章已经讲过了,运用的公式是:
假设交易是输入 x token ,余额为 $x$(预先扣除最大所需的手续费后的余额,以防止手续费不足),在计算得到 $\Delta x$ 后,比较:
- 当 $x \geq \Delta x$ 时,表示交易可以到达目标价格
- 当 $x < \Delta x$ 时,表示交易不足以到达目标价格,此时还需要进一步当前余额 $x_remaining$ 全部耗尽时所能够达到的价格
如果 $x < \Delta x$,我们需要计算 x 耗尽时的价格,即已知 $\Delta x,\ \sqrt P_c,\ L$,求 $\sqrt P_n$. 根据:
\[\Delta x = \Delta {\frac 1{\sqrt P}} \cdot L = \pm({\frac 1{\sqrt P_c} - \frac 1{\sqrt P_n}}) \cdot L\]得出:
\[\sqrt {P_n} = \frac {L \sqrt {P_c}}{L \pm \Delta x \sqrt {P_c}}\]具体上述公式计算仅对通过 x token 余额求出下一个价格的公式进行了推导,如果输入的时 y token,也可以额进行类似的推导。代码中具体的实现已经封装在在 SqrtPriceMath.getNextSqrtPriceFromInput
函数中,这里不再进一步详细解释。我们接着看 computeSwapStep
的剩余步骤:
// 判断是否能够到达目标价
bool max = sqrtRatioTargetX96 == sqrtRatioNextX96;
// get the input/output amounts
if (zeroForOne) {
// 根据是否到达目标价格,计算 amountIn/amountOut 的值,这里把之前的结果重新计算一次时是了保证 amountIn/amountOut 的准确性
amountIn = max && exactIn
? amountIn
: SqrtPriceMath.getAmount0Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, true);
amountOut = max && !exactIn
? amountOut
: SqrtPriceMath.getAmount1Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, false);
} else {
...
}
...
if (exactIn && sqrtRatioNextX96 != sqrtRatioTargetX96) {
// 如果没能到达目标价,即交易结束,剩余的 tokenIn 将全部作为手续费
// 为了不让计算进一步复杂化,这里直接将剩余的 tokenIn 将全部作为手续费
// 因此会多收取一部分手续费,即按本次交易的最大手续费收取
feeAmount = uint256(amountRemaining) - amountIn;
} else {
feeAmount = FullMath.mulDivRoundingUp(amountIn, feePips, 1e6 - feePips);
}
后续的步骤即重新计算了需要支付的手续费用和付出的 tokenIn
, tokenOut
数量,这一步的交易就结束了,函数会将手续费,到达的目标价以及 tokenIn
, tokenOut
返回。
交易收尾阶段
我们再回到 swap
函数中循环检查条件:
while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
...
}
即通过通过 tokenIn
是否还有余额来判断是否还需要继续循环,进入下一步的进行交易计算。当 tokenIn
全部被耗尽后,交易就结束了。当交易结束后,我们还需要做这些事情:
- 更新预言机
- 更新当前交易对的价格 $\sqrt P$,流动性 $L$
- 更新手续费累计值
- 扣除用户需要支付的 token
关于手续费,预言机的相关内容,会在其他章节讲解,我们先跳过这部分代码,直接看 swap
函数的末尾:
// 确定最终用户支付的 token 数和得到的 token 数
(amount0, amount1) = zeroForOne == exactInput
? (amountSpecified - state.amountSpecifiedRemaining, state.amountCalculated)
: (state.amountCalculated, amountSpecified - state.amountSpecifiedRemaining);
// 扣除用户需要支付的 token
if (zeroForOne) {
// 将 tokenOut 支付给用户,前面说过 tokenOut 记录的是负数
if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));
uint256 balance0Before = balance0();
// 还是通过回调的方式,扣除用户需要支持的 token
IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
// 校验扣除是否成功
require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');
} else {
...
}
// 记录日志
emit Swap(msg.sender, recipient, amount0, amount1, state.sqrtPriceX96, state.tick);
// 接触防止回调的锁
slot0.unlocked = true;
}
这里还是通过回调完成用户支付 token 的费用。需要注意的是如果本次交易是交易路径中的一次中间交易,那么扣除的 token 是从 SwapRouter
中扣除的,交易完成获得的 token 也会发送给 SwapRouter
以便其进行下一步的交易,我们回到 SwapRouter
中的 exactInput
函数:
params.amountIn = exactInputSingle(
params.amountIn,
// 这里会判断是否是最后一次交易,当是最后一次交易时,获取的 token 的地址才是用户的指定的地址
hasPools ? address(this) : params.recipient,
SwapData({
path: params.path.getFirstPool(),
payer: msg.sender
})
);
再来看一下支付的回调函数:
function uniswapV3SwapCallback(
int256 amount0Delta,
int256 amount1Delta,
bytes calldata _data
) external override {
SwapData memory data = abi.decode(_data, (SwapData));
(address tokenIn, address tokenOut, uint24 fee) = data.path.decodeFirstPool();
CallbackValidation.verifyCallback(factory, tokenIn, tokenOut, fee);
// 这里有点绕,目的就是判断函数的参数中哪个是本次支付需要支付的代币
(bool isExactInput, uint256 amountToPay) =
amount0Delta > 0
? (tokenIn < tokenOut, uint256(amount0Delta))
: (tokenOut < tokenIn, uint256(amount1Delta));
if (isExactInput) {
// 调用 pay 函数支付代币
pay(tokenIn, data.payer, msg.sender, amountToPay);
} else {
...
}
}
回调完成后,swap
函数会返回本次交易得到的代币数量。exactInput
将判断是否进行下一个路径的交易,直至所有的交易完成,进行输入约束的检查:
require(amountOut >= params.amountOutMinimum, 'Too little received');
如果交易的获得 token 数满足约束,则本次交易结束。
本文仅对 exactInput
这一种交易情况进行了分析,理解了这个交易的整个流程后,就可以触类旁通理解 exactOutput
的交易过程。
Uniswap v3 详解系列
本系列所有文章:
- Uniswap v3 详解(一):设计原理
- Uniswap v3 详解(二):创建交易对/提供流动性
- Uniswap v3 详解(三):交易过程
- Uniswap v3 详解(四):交易手续费
- Uniswap v3 详解(五):Oracle 预言机
- Uniswap v3 详解(六):闪电贷