本文深入解析了Uniswap V3的核心代码和白皮书,详细介绍了其架构创新,包括多费率池、区间流动性、非同质化流动性的实现,以及Ticks机制、流动性计算、手续费模型和Swap交易流程的数学原理与Solidity代码实现。文章通过代码片段和数学公式,探讨了V3相较于V2的复杂性和精妙之处。
在上一篇内容中,我们介绍 Uniswap v2 的代码,在本篇内容中,我们将介绍 Uniswap v3 的源代码。但是由于 Uniswap v3 的代码极其庞大,所以我们无法像介绍 v2 那样逐行进行分析,本文主要从阅读 白皮书 及相关代码的视角展开。
假如读者希望体验自己动手实现 Uniswap v3 的话,建议观看此 Youtube 系列视频。笔者就是依靠该系列视频完整学习了 Uniswap v3 的代码。另一个学习 Uniswap v3 的材料是笔者之前编写的博客 现代 DeFi: Uniswap V3。
在白皮书内,该节主要介绍了 Uniswap v3 相比于 v2 在架构上的创新,主要介绍了以下内容:
在 Uniswap v2 中,我们使用如下代码直接收取流动性手续费,此处的手续费比例 $25 \text{ bps}$ 是硬编码的,我们无法在 $factor$ 内进行设置。
uint256 balance0Adjusted = balance0 * 10000 - amount0In * 25
但在 Uniswap v3 中,我们可以在 contracts/UniswapV3Factory.sol 内看到 createPool 内的实现已经包含了 $fee$ 字段:
/// @inheritdoc IUniswapV3Factory
function createPool(
address tokenA,
address tokenB,
uint24 fee
) external override noDelegateCall returns (address pool) {
require(tokenA != tokenB);
(address token0, address token1) = tokenA < tokenB ? (tokenA, tokenB) : (tokenB, tokenA);
require(token0 != address(0));
int24 tickSpacing = feeAmountTickSpacing[fee];
require(tickSpacing != 0);
require(getPool[token0][token1][fee] == address(0));
pool = deploy(address(this), token0, token1, fee, tickSpacing);
getPool[token0][token1][fee] = pool;
// populate mapping in the reverse direction, deliberate choice to avoid the cost of comparing addresses
getPool[token1][token0][fee] = pool;
emit PoolCreated(token0, token1, fee, tickSpacing, pool);
}
此处需要注意 $fee$ 并不是可以任意指定的,而是与 $tickSpacing$ 是挂钩的。$tickSpacing$ 实际上代表了添加区间流动性时的最小价格间隔,简单来说,$tickSpacing$ 越小,那么用户添加流动性的颗粒度越高,用户可以选择流动性上下限更加精确,比如 $tickSpacing = 10$,那么用户可以选择作为流动性区间边界的 ticks 值位于
$(\dots, -20, -10, 0, 10, 20, \dots)$ 内,而 $tickSpacing = 1$,那么用户可以选择的 ticks 位于
$(\dots, -2, -1, 0, 1, 2, \dots)$ 范围内。但是反之在 Uniswap v3 进行 swap 时,由于 $tickSpacing$ 较小,那么可以被放置流动性的点位就更多,所以 v3 需要循环更多次以搜索可用的流动性。
一般来说,$tickSpacing$ 越小,意味着预期 Pair 内的价格变动越小,比如 $tickSpacing = 1$ 一般被用于稳定币交易对。价格波动小,流动性提供者应该获得更低的手续费,所以 Uniswap v3 在 Factory 内硬编码了几个 $tickSpacing$ 与 $fee$ 的对应关系,具体如下表(数据来源为 Unisawp 文档):
| Fee | Fee Value | Tick Spacing |
|---|---|---|
| 0.01% | 100 | 1 |
| 0.05% | 500 | 10 |
| 0.30% | 3000 | 60 |
| 1.00% | 10_000 | 200 |
对于 createPool 的具体实现,读者可以自行阅读相关代码,与 uniswap v2 一致,v3 也使用了 create2 确定性地址部署方法,部署的具体代码位于 UniswapV3PoolDeployer 内部:
parameters = Parameters({factory: factory, token0: token0, token1: token1, fee: fee, tickSpacing: tickSpacing});
pool = address(new UniswapV3Pool{salt: keccak256(abi.encode(token0, token1, fee))}());
这意味着任何代币 Pair 的 v3 地址都可以被预先计算出来,这对于一些创建 Pair 后进行自动交易的协议产生了威胁,比如 Four.meme 在过去使用了 v3 作为代币发射后的交易平台,但是由于代币 Pair 地址可以预先计算,所以黑客在 foure.meme 自动部署 Pair 前抢先部署了 Pair 然后使用不合理价格初始化,导致 four.meme 转移流动性时出现损失,具体可以 DeFiHackLabs 的 PoC。
关于具体的 $tickSpacing$ 的使用和遍历方法,我们会在后文内介绍 ticks 概念及相关代码时一并分析。
非同质的流动性其实就是区间流动性的另一种表达方法,但是需要注意在 Uniswap v3 的核心合约内并不存在 ERC721 的部分,大家经常看到的使用 ERC721 表示区间流动性其实只是一个辅助功能,相关代码位于 v3-peripher 的 NonfungiblePositionManager 内部。
在 v3-core 内部,用户提供的流动性被存储在 positions 状态变量内部,该状态变量的定义是:
mapping(bytes32 => Position.Info) public override positions;
关于上述 mapping 内 bytes32 含义,我们可以参考 contracts/libraries/Position.sol 内的 get 函数:
function get(
mapping(bytes32 => Info) storage self,
address owner,
int24 tickLower,
int24 tickUpper
) internal view returns (Position.Info storage position) {
position = self[keccak256(abi.encodePacked(owner, tickLower, tickUpper))];
}
通过上述 get 函数,我们可以看到在 v3-core 内,我们通过 $owner$ / $tickLower$ 和 $tickUpper$ 确定某一个用户的某一个流动性头寸。而 Position.Info 的具体定义为:
struct Info {
// the amount of liquidity owned by this position
uint128 liquidity;
// fee growth per unit of liquidity as of the last update to liquidity or fees owed
uint256 feeGrowthInside0LastX128;
uint256 feeGrowthInside1LastX128;
// the fees owed to the position owner in token0/token1
uint128 tokensOwed0;
uint128 tokensOwed1;
}
此处的 $liquidity$ 代表用户持有的流动性数量,我们常使用 $liquidity$ 计算对应的 $token0$ 和 $token1$ 的数量。$feeGrowthInside0LastX128$ 和 $feeGrowthInside1LastX128$ 都是用于计算 LP 应获得的手续费收入的,该部分逻辑较为复杂,我们会在后文介绍手续费逻辑时详细介绍。而 $tokensOwed0$ 和 $tokensOwed1$ 代表实际的手续费,每次更新 LP 头寸时,合约会自动使用 $feeGrowthInside0LastX128$ 和 $feeGrowthInside1LastX128$ 计算手续费收入,然后累加到 $tokensOwed0$ 和 $tokensOwed1$ 内部。
我们首先回到 Info 结构体内的 $liquidity$ 计算。该部分计算由 contracts/libraries/SqrtPriceMath.sol 内的 getAmount0Delta 和 getAmount1Delta 完成。这两个函数基本都是纯粹的数学函数,具体形式如下:

上述的
$$i_c$$
是当前 Pair 内的价格(即 slot0.tick),而
$$i_l$$
和
$$i_u$$
分别代表 $tickLower$ 和 $tickUpper$。限于篇幅,我们无法介绍这两个函数背后依赖的数学公式的推导流程,读者可以参考 LIQUIDITY MATH IN UNISWAP V3 这篇论文内的推导。读者可能会注意到对于上述公式中
$\Delta Y$ 而言,$i_c \ge i_u$ 可以被视为 $i_l \le i_c < i_u$ 的一种特殊情况,即 $P = p(i_u)$ 的情况。对于 $\Delta X$ 而言同理,这就是为什么在 SqrtPriceMath 内部只存在两个函数 getAmount0Delta 和 getAmount1Delta,而不是存在四个函数。
基于上述知识,读者不难看懂 _modifyPosition 内的核心逻辑:
function _modifyPosition(ModifyPositionParams memory params)
private
noDelegateCall
returns (
Position.Info storage position,
int256 amount0,
int256 amount1
)
{
checkTicks(params.tickLower, params.tickUpper);
Slot0 memory _slot0 = slot0; // SLOAD for gas optimization
position = _updatePosition(
params.owner,
params.tickLower,
params.tickUpper,
params.liquidityDelta,
_slot0.tick
);
if (params.liquidityDelta != 0) {
if (_slot0.tick < params.tickLower) {
// current tick is below the passed range; liquidity can only become in range by crossing from left to
// right, when we'll need _more_ token0 (it's becoming more valuable) so user must provide it
amount0 = SqrtPriceMath.getAmount0Delta(
TickMath.getSqrtRatioAtTick(params.tickLower),
TickMath.getSqrtRatioAtTick(params.tickUpper),
params.liquidityDelta
);
} else if (_slot0.tick < params.tickUpper) {
// current tick is inside the passed range
uint128 liquidityBefore = liquidity; // SLOAD for gas optimization
// write an oracle entry
(slot0.observationIndex, slot0.observationCardinality) = observations.write(
_slot0.observationIndex,
_blockTimestamp(),
_slot0.tick,
liquidityBefore,
_slot0.observationCardinality,
_slot0.observationCardinalityNext
);
amount0 = SqrtPriceMath.getAmount0Delta(
_slot0.sqrtPriceX96,
TickMath.getSqrtRatioAtTick(params.tickUpper),
params.liquidityDelta
);
amount1 = SqrtPriceMath.getAmount1Delta(
TickMath.getSqrtRatioAtTick(params.tickLower),
_slot0.sqrtPriceX96,
params.liquidityDelta
);
liquidity = LiquidityMath.addDelta(liquidityBefore, params.liquidityDelta);
} else {
// current tick is above the passed range; liquidity can only become in range by crossing from right to
// left, when we'll need _more_ token1 (it's becoming more valuable) so user must provide it
amount1 = SqrtPriceMath.getAmount1Delta(
TickMath.getSqrtRatioAtTick(params.tickLower),
TickMath.getSqrtRatioAtTick(params.tickUpper),
params.liquidityDelta
);
}
}
}
对于 _updatePosition 函数,该函数内部存在一些与流动性手续费计算的内容,在该函数内完成了上文介绍的 $feeGrowthInside0LastX128$ / $feeGrowthInside1LastX128$ 计算后累加到 $tokensOwed0$ 和 $tokensOwed1$ 的过程,在后文分析手续费逻辑时,我们会再次讨论。
对于后续的 $liquidityDelta$ 向 $amount0$ 和 $amount1$ 的转换,结合上述公式,读者应该可以理解。比如对于 _slot0.tick < params.tickLower 即
$i_c < i_l$ 的情况,此时 $\Delta Y = 0$ 即 $amount1 = 0$,我们只需要调用函数计算 $amount0$ 的数值。对于 else if (_slot0.tick < params.tickUpper),该分支意味着 $i_l \le i_c < i_u$,所以我们需要分别计算 $amount0$ 和 $amount1$ 的数值。
上述函数中的 getSqrtRatioAtTick 是将 $tick$ 转化为
$$p$$
的函数,本质上实现了以下数学公式:
$$p(i) = 1.0001^i \quad \text{and} \quad \sqrt{p(i)} = 1.0001^{i/2}$$
实际上这也是 tick 与价格之间关系。我们会经常使用 getSqrtRatioAtTick 和 getTickAtSqrtRatio 函数,前者用于 $tick$ 转化价格(后文所有的价格均指
$$p$$
,这是因为 uniswap v3 内不存在其他形式的价格),而后者用于价格转化为 $tick$。
在 Uniswap v3 内,与流动性直接相关的函数包括:
mint 函数用于添加流动性burn 函数用于提取流动性及 LP 获得的手续费collect 函数用于提取 LP 手续费其中 collect 函数实现最为简单,依赖上文介绍的 Position.Info 内的 $tokensOwed0$ 和 $tokensOwed1$ 字段,代码如下:
function collect(
address recipient,
int24 tickLower,
int24 tickUpper,
uint128 amount0Requested,
uint128 amount1Requested
) external override lock returns (uint128 amount0, uint128 amount1) {
// we don't need to checkTicks here, because invalid positions will never have non-zero tokensOwed{0,1}
Position.Info storage position = positions.get(msg.sender, tickLower, tickUpper);
amount0 = amount0Requested > position.tokensOwed0 ? position.tokensOwed0 : amount0Requested;
amount1 = amount1Requested > position.tokensOwed1 ? position.tokensOwed1 : amount1Requested;
if (amount0 > 0) {
position.tokensOwed0 -= amount0;
TransferHelper.safeTransfer(token0, recipient, amount0);
}
if (amount1 > 0) {
position.tokensOwed1 -= amount1;
TransferHelper.safeTransfer(token1, recipient, amount1);
}
emit Collect(msg.sender, recipient, tickLower, tickUpper, amount0, amount1);
}
而 burn 函数复杂度也很低,代码如下:
function burn(
int24 tickLower,
int24 tickUpper,
uint128 amount
) external override lock returns (uint256 amount0, uint256 amount1) {
(Position.Info storage position, int256 amount0Int, int256 amount1Int) =
_modifyPosition(
ModifyPositionParams({
owner: msg.sender,
tickLower: tickLower,
tickUpper: tickUpper,
liquidityDelta: -int256(amount).toInt128()
})
);
amount0 = uint256(-amount0Int);
amount1 = uint256(-amount1Int);
if (amount0 > 0 || amount1 > 0) {
(position.tokensOwed0, position.tokensOwed1) = (
position.tokensOwed0 + uint128(amount0),
position.tokensOwed1 + uint128(amount1)
);
}
emit Burn(msg.sender, tickLower, tickUpper, amount, amount0, amount1);
}
在此处,我们需要补充在上文内没有介绍的 $liquidityDelta$ 是 int128 类型,数值为正数代表流动性添加,而为负数代表流动性减少。注意,即使包含符号,上述给出的所有数学公式也是成立的。在 Uniswap v3 和 Uniswap v4 中,开发团队经常使用 int 类型并使用正负代表不同的含义,以此简化数学计算。比如在 Uniswap v3 中,另一个重要的存在符号的变量是 swap 函数中的 $amountSpecified$,其符号含义可以从 bool exactInput = amountSpecified > 0; 代码内看出。该数值为正数,代表 exactInput 模式,即用户指定输入代币的数量,要求 Pool 计算输出代币的数量;与之相反的是 exactOut 模式,即用户给定输出代币的数量,要求 Pool 计算输入代币的数量。另外,swap 函数的输出值 amount0 和 amount1 也是包含符号的,其含义为假如 $amount < 0$,那么合约就会将对应数量的 $token1$ 发送给用户,代码如下:
if (zeroForOne) {
if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));
上述代码内的 $zeroForOne$ 也是 swap 的重要入参,用于指定代币兑换的情况,比如 $zeroForOne = \text{true}$ 意味着用户希望给出输入 token 0 并获得 token 1 代币。这意味着 $zeroForOne$ 是对用户而言,站在 Pool 的角度,$zeroForOne$ 反而意味着输出 token 0 代币并从用户处获得 token 1 代币。对于最初接触 Uniswap v3 代码的读者,由于长时间阅读 Pool 合约,很容易站在 Pool 的角度思考问题,对于 $zeroForOne$ 的含义时常搞反。
最后,我们介绍稍微复杂一些的 mint 函数,该函数体现了 Uniswap v3 内是如何处理代币转移问题的:
function mint(
address recipient,
int24 tickLower,
int24 tickUpper,
uint128 amount,
bytes calldata data
) external override lock returns (uint256 amount0, uint256 amount1) {
require(amount > 0);
(, int256 amount0Int, int256 amount1Int) =
_modifyPosition(
ModifyPositionParams({
owner: recipient,
tickLower: tickLower,
tickUpper: tickUpper,
liquidityDelta: int256(amount).toInt128()
})
);
amount0 = uint256(amount0Int);
amount1 = uint256(amount1Int);
uint256 balance0Before;
uint256 balance1Before;
if (amount0 > 0) balance0Before = balance0();
if (amount1 > 0) balance1Before = balance1();
IUniswapV3MintCallback(msg.sender).uniswapV3MintCallback(amount0, amount1, data);
if (amount0 > 0) require(balance0Before.add(amount0) <= balance0(), 'M0');
if (amount1 > 0) require(balance1Before.add(amount1) <= balance1(), 'M1');
emit Mint(msg.sender, recipient, tickLower, tickUpper, amount, amount0, amount1);
}
我们可以看到与 Uniswap v2 一致,在 Uniswap v3 内,我们仍使用了 Callback 的方法通知交易者支付资产,然后利用余额判断用户输入的代币数量是否足够。
我们跳过了白皮书内关于治理和 Oracle 的两节内容,对于治理部分,这部分与核心功能关联较少,而 Oracle 部分其实被大量嵌入了核心的代码,但是 Oracle 部分稍微有一些复杂,我们可能会在未来单独编写一篇文章介绍,但假如读者对如何在 Uniswap v3 基础上实现流动性挖矿感兴趣,Oracle 部分是一定要读的。
本节内容在白皮书内主要介绍 Uniswap v3 的区间流动性提供是如何实现的,主要介绍了以下内容:
$tick$ 的任务swap 过程中穿过某一个 tick 后如何进行计算(cross tick)swap 内的代码,但主要代码仍位于 Position 及其相关部分本节内容将不完全与白皮书内的内容对应。
在上文,我们已经介绍了如下数学公式:
$$p(i) = 1.0001^i \quad \text{and} \quad \sqrt{p(i)} = 1.0001^{i/2}$$
用户提交的流动性都存在 $tickLower$(
$$i_l$$
) 和 $tickUpper$(
$$i_u$$
) 两个核心参数。每一个 Pool 在初始化时就会选定 $tickSpacing$ 作为核心参数。在上文中,我们介绍了 $tickSpacing$ 与手续费之间的映射关系,但 $tickSpacing$ 在初始化时会被用于计算另一个数值 $maxLiquidityPerTick$,该数值代表每一个 tick 上可以容纳的最大流动性数量,计算方法如下:
function tickSpacingToMaxLiquidityPerTick(int24 tickSpacing) internal pure returns (uint128) {
int24 minTick = (TickMath.MIN_TICK / tickSpacing) * tickSpacing;
int24 maxTick = (TickMath.MAX_TICK / tickSpacing) * tickSpacing;
uint24 numTicks = uint24((maxTick - minTick) / tickSpacing) + 1;
return type(uint128).max / numTicks;
}
首先,我们需要明确在 Uniswap v3 内记录当前 Pool 的流动性的变量 uint128 public override liquidity; 的类型是 uint128,所以我们接下来的任务是计算当前系统内到底存在多少在当前 $tickSpacing$ 下有效的 tick,具体分为两步:
$tickSpacing$ 下有效的最小 $minTick$ 和最大 $maxTick$,注意都需要向 0 舍入,避免计算出的结果小于 TickMath.MIN_TICK 或者大于 TickMath.MAX_TICK。代码中的 TickMath.MIN_TICK / tickSpacing 内的除法是向 0 舍入的,所以此处计算出的结果已经完成向 0 舍入$minTick$ 和 $maxTick$ 的差值计算 $numTicks$,使用 $(maxTick - minTick) / tickSpacing$ 但是此处存在经典的 Fencepost error,即间隔与个数差 1,所以最终的计算方法就是代码内的方法那么计算出的常量 $maxLiquidityPerTick$ 如何使用? 在 contracts/libraries/Tick.sol 内的 update 函数展示了该常量的用法:
Tick.Info storage info = self[tick];
uint128 liquidityGrossBefore = info.liquidityGross;
uint128 liquidityGrossAfter = LiquidityMath.addDelta(liquidityGrossBefore, liquidityDelta);
require(liquidityGrossAfter <= maxLiquidity, 'LO');
flipped = (liquidityGrossAfter == 0) != (liquidityGrossBefore == 0);
上述代码内的 $maxLiquidity$ 就是传入的 $maxLiquidityPerTick$ 常量。在 Tick.Info 内存在 $liquidityGross$ 变量,记录当前 $tick$ 内的流动性总量,每次用户进行流动性调整都会更新该变量。显然,假如每一个 tick 的 $liquidityGross < maxLiquidityPerTick$,那么流动性总和不可能大于 type(uint128).max。此处额外注意 $liquidityGross$ 与当前系统内活跃的流动性并不等同,比如下图中的一个用户在 tick 0 - tick3 之间添加了流动性,此时 tick 2 的 $liquidityGross = 0$,但并不代表此处没有流动性。我们会在下一节内详细介绍如何计算每一个区间的真实流动性。
上述代码中的 $flipped$ 也是 $liquidityGross$ 存在的重要原因,$flipped$ 代表某一个 tick 是否需要“翻转”,更加具体说,是否需要从完成初始化状态修改为未初始化状态,或者反之从未初始化状态修改为初始化状态。假如当前 tick 需要 flipTick,那么我们就会调用 tickBitmap.flipTick 函数。
tickBitmap 是我们在本节要介绍的核心数据类型之一。回到上图,假如当前价格位于 tick 0 与 tick 1 之间,Swap 的计算实际上也使用了上文介绍的 getAmount0Delta 或 getAmount1Delta 函数,这些函数要求给定当前价格与下一个价格,在上图中,下一个价格指的是 tick 3,因为中间的 tick 2 是未初始化的,所以不需要考虑。那么如何基于当前价格搜索到下一个价格,这对应 tickBitmap.nextInitializedTickWithinOneWord 函数。
tickBitmap 是一个可以容纳 1774545 bit 的序列(tick 所在地范围为
$[-887272, 887272]$,总共存在 1774545 个 tick)。但是显然在 solidity 内没有一个长度如此长的数据类型,所以 Uniswap v3 的开发团队使用了分组方法,设置了 $2^{16}$ 个分组,每一个分组都是 256 bit 长度,即:
mapping(int16 => uint256) public override tickBitmap;
我们可以使用 wordPos = int16(tick >> 8); 快速定位 $tick$ 位于的分组序号,使用 bitPos = uint8(tick % 256); 计算出在分组内的具体位置,实际上也就是 contracts/libraries/TickBitmap.sol 内的 position 函数。使用该函数,我们可以快速实现 flipTick,具体实现就是定位到需要翻转的 bit 在 tickBitmap 的 position 然后使用 XOR 函数写入:
function flipTick(
mapping(int16 => uint256) storage self,
int24 tick,
int24 tickSpacing
) internal {
require(tick % tickSpacing == 0); // ensure that the tick is spaced
(int16 wordPos, uint8 bitPos) = position(tick / tickSpacing);
uint256 mask = 1 << bitPos;
self[wordPos] ^= mask;
}
从上述代码内,我们可以看到我们会将 $tick$ 使用 $tick / tickSpacing$ 进行修正再进行写入,这其实意味着对于 $tickSpacing$ 设置较大的 Pool,其内部的 TickBitmap 被占用的 bit 会越少。
然后我们介绍如何根据当前 tick 搜索下一个价格,注意此处使用的 nextInitializedTickWithinOneWord 获得的并不一定是下一个已经被初始化的 tick,正如函数名称中的 WithinOneWord ,该函数只是返回当前分组(word) 内的下一个初始化 tick,假如当前分组内没有任何初始化的 tick,那么返回值是当前分组的末尾 tick。此处的末尾有两种不同的情况:
$tick$ 的位于当前 word 的 next tick$tick$ 且位于当前 word 的 next ticl我们以向左搜索为例,代码如下:
int24 compressed = tick / tickSpacing;
if (tick < 0 && tick % tickSpacing != 0) compressed--; // round towards negative infinity
if (lte) {
(int16 wordPos, uint8 bitPos) = position(compressed);
// all the 1s at or to the right of the current bitPos
uint256 mask = (1 << bitPos) - 1 + (1 << bitPos);
uint256 masked = self[wordPos] & mask;
// if there are no initialized ticks to the right of or at the current tick, return rightmost in the word
initialized = masked != 0;
// overflow/underflow is possible, but prevented externally by limiting both tickSpacing and tick
next = initialized
? (compressed - int24(bitPos - BitMath.mostSignificantBit(masked))) * tickSpacing
: (compressed - int24(bitPos)) * tickSpacing;
这段代码的核心难点是如何处理舍入问题。我们可以看到 int24 compressed = tick / tickSpacing; 是向 0 舍入的,但是为了保证 bitmap 的连续性,我们利用 if (tick < 0 && tick % tickSpacing != 0) compressed--; 将不是 $tickSpacing$ 整数倍的 $tick$ 向下舍入。假如此处不进行上述操作,那么会导致 0 两侧的正负数 tick 进行除法计算后结果都为 0,这会导致 bitmap 的不连续。
然后我们构建一个以当前 $bitPos$ 最为最右侧位,且 $bitPos$ 左侧都为 1 的 mask 序列,举例说明:
bitPos = 3
mask = 0b1111
获得 $mask$ 后,我们需要将当前 word 与 mask 进行异或操作,获得的结果是只包含当前 $bitPos$ 及其左侧 bit 的序列。假如异或后的结果为 0 意味着当前 word 内已经不存在下一个初始化的 tick,我们只需要执行 $(compressed - \text{int24}(\text{bitPos})) * \text{tickSpacing}$ 操作直接获得当前 word 最左侧的 tick 返回即可。部分读者可能好奇为什么此处不能直接返回 0,这是因为返回的 $tick$ 应该是包含 $wordPos$ 和 $bitPos$,确实 word 中最右侧的 tick 的 $bitPos = 0$,但是我们不能忽略 $wordPos$,所以最简单的计算方法就是 $(compressed - \text{int24}(\text{bitPos})) * \text{tickSpacing}$
假如异或后的结果不为 0 ,那么就说明当前 word 内在指定 tick 的自身及其左侧存在一些已经被初始化的 tick。我们接下来的任务是找到该 tick。该任务等同为寻找异或结果中的最右侧且值为 1 的 bit 的位置。在计算机领域,该任务被称为 mostSignificantBit。假如读者对该算法内部实现感兴趣,可以阅读笔者之前编写的 现代 DeFi: Uniswap V4 数学库分析 内的介绍。所以我们可以使用 $(compressed - \text{int24}(\text{bitPos} - \text{BitMath.mostSignificantBit}(\text{masked}))) * \text{tickSpacing}$ 计算出寻找到下一个 word 内已初始化的 tick。
注意,在上文中,我们对于 $lte$ 向左搜索的表述是找到小于等于当前 tick 的下一个 tick,所以该函数返回值有可能就是输入的 $tick$,对于这种情况,实际上 swap 函数是可以处理的,但是会导致一次 swap 循环的空转(即此次 swap 循环不消耗任何输入代币且不产生任何输出),假如读者在 swap 时追求极致的 gas 效率,请考虑这种情况的影响。
对于另一种向右搜索的情况,读者可以自行研究,这种情况就永远不会返回输入的 $tick$,只会返回大于当前 $tick$ 的最小 tick。实际以下代码中的 $+1$ 保证了永远不会返回输入的 $compressed$ 数值。
next = initialized
? (compressed + 1 + int24(BitMath.leastSignificantBit(masked) - bitPos)) * tickSpacing
: (compressed + 1 + int24(type(uint8).max - bitPos)) * tickSpacing;
在本节中,我们暂时脱离白皮书的内容,因为对于 Pool 内的流动性,我们会涉及到 Global State 和 Tick-Indexed State 的内容。众所周知,在 swap 过程中,我们依赖于
$$L$$
即当前系统内的流动性数量计算代币 swap 后的输出等。在 Pool 层面,我们使用 uint128 public override liquidity; 来跟踪该数值。由于 Uniswap v3 使用了区间流动性方法,经常出现多个 LP 头寸都为当前流动性进行了贡献的情况,比如下图中的 LP #0 / LP #1 与 LP #2 都为当前的价格贡献了流动性:
所以我该如何正确更新当前价格可用的流动性?最简单的情况是修改流动性头寸时,比如用户直接修改了上图中 LP #0 内的流动性数量,此时我们会直接在全局变量 $liquidity$ 内进行修改。在 _modifyPosition 内,我们可以看到如下代码:
liquidity = LiquidityMath.addDelta(liquidityBefore, params.liquidityDelta);
除了这种直接在修改流动性时进行的 $liquidity$ 更新,更加常见的是由于 swap 出现价格变化而进一步导致部分流动性变化,下图展示
$$L_1$$
和
$$L_2$$
两部分流动性,其中
$$L_1$$
占据了 a - c 区间,而
$$L_2$$
占据了 b - d 区间。假如价格当前穿过 c 价格,那么
$$L_1$$
就会失效,而
$$L_2$$
仍发挥作用。

那么在存在大量 LP 的情况下, Uniswap v3 如何计算出当前有效的 LP 贡献的总流动性数量?实际上,Uniswap v3 在 Tick.Info 结构体内存储了 $liquidityNet$ 变量。该变量表示当前 tick 从左往右穿过对全局流动性的变化。以上图为例,我们假设最初 Pool 内的价格位于 a 的左侧,那么当价格从左往右移动穿过 a 时,liquidity 增加 500,这其实代表着
$$L_1$$
已经生效,当价格继续向右移动穿过 b 时,liquidity 再次增加 700,因为此时
$$L_2$$
生效;但价格继续向右移动穿过 c 时,liquidity 会减少 500,这是因为
$$L_1$$
已经失效。
我们可以看到只需要在每次修改 LP 的流动性时,将当前流动性修改数量增加到 $tickLower$ ,而将修改数量的相反数增加到 $tickUpper$ 即可。如此就可以实现价格自左向右穿过 $tickLower$ 时,自动增加当前的流动性修改,而自左向右穿过 $tickUpper$ 时,减少当前的流动性修改。
在 contracts/libraries/Tick.sol 内,我们可以看到 update 函数的如下实现:
info.liquidityNet = upper
? int256(info.liquidityNet).sub(liquidityDelta).toInt128()
: int256(info.liquidityNet).add(liquidityDelta).toInt128();
其中 $upper$ 是一个表示当前 tick 是否是 LP 的 $tickUpper$。接下来,我们要实现穿过 tick 后根据 tick 内的 $liquidityNet$ 数据修改 $liquidity$ 变量的代码。在 UniswapV3Pool 内的 swap 函数内,我们可以看到如下代码:
int128 liquidityNet =
ticks.cross(
step.tickNext,
(zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128),
(zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128),
cache.secondsPerLiquidityCumulativeX128,
cache.tickCumulative,
cache.blockTimestamp
);
// if we're moving leftward, we interpret liquidityNet as the opposite sign
// safe because liquidityNet cannot be type(int128).min
if (zeroForOne) liquidityNet = -liquidityNet;
state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);
此处的 tick.cross 就是一个处理 tick 被穿过的函数,该函数内部大部分内容都是与流动性手续费计算有关的,我们会在下一节分析。在上文中,我们介绍了从左往右穿过 tick 是在 $liquidity$ 基础上增加 $liquidityNet$,反之,从右往左穿过 tick 就会减少 $liquidityNet$。$zeroForOne$ 代表用户使用 token 0 换取 token 1,对于 Pool 来说会支出 token 1 获得 token 0,根据价格计算方法:
$$P = \frac{y}{x}$$
其中 $y$ 代表 token 1 的数量而 $x$ 代表 token 0 的数量,显然,$zeroForOne$ 会似的价格下降,即从右往左穿过 tick,所以此处存在 $liquidityNet = -liquidityNet;$ 的代码。
Uniswap v3 的手续费计算是一个较为复杂的内容,这部分内容的特点是数学公式较为简单,但背后的数学原理很难理解。为了简化本文,我们只介绍数学公式及其实现,对于数学原理,笔者在 博客 中给出了推导分析。本节的内容基本上与白皮书内的 Tick-Indexed State 一节有关。在后文中,如无特殊说明,所有的手续费都是指 LP 由于提供流动性以供交易者交易而获得的交易手续费。
在 Tick.Info 结构体内的 $feeGrowthOutside0X128$ 和 $feeGrowthOutside1X128$ 都与手续费计算有关,前者与 token 0 的手续费计算有关,而后者与 token 1 的手续费计算有关,在数学表达式内,我们一般使用
$$f_o(i)$$
表示。从经济含义上,我们认为
$$f_o$$
表示当前 tick 一侧所有 tick 历史累计的手续费总量。我们定义对于小于当前价格的 tick,
$$f_o(i)$$
的含义为:

即当前 tick i 右侧累计的所有手续费。反之, $$f_o(i)$$ 代表当前左侧累计的所有手续费:

基于上述定义,我们引入代表当前 tick 右侧(above) 累计手续费 $$f_a(i)$$ 和当前 tick 左侧(below) 累计的手续费 $$f_b(i)$$
两个公式:
$$ f_a(i)= \begin{cases} f_g-f_o(i) & i_c \ge i \ f_o(i) & i_c < i \end{cases} $$
$$ f_b(i)= \begin{cases} f_o(i) & i_c \ge i \ f_g-f_o(i) & i_c < i \end{cases} $$
另外,在 Pool 的状态中,我们会使用 $feeGrowthGlobal0X128$ 和 $feeGrowthGlobal0X128$ 记录全局手续费,在数学表达式内,我们一般使用
$$f_g$$
表示。显然,对于任何情况,都存在
$$f_g - f_a(i_u) - f_b(i_l)$$
代表当前价格区间的手续费,我们可以使用下图理解:
所以,在代码中,我们首先计算 $$f_b(i_l)$$ 和 $$f_a(i_u)$$ 的值:
// calculate fee growth below
uint256 feeGrowthBelow0X128;
uint256 feeGrowthBelow1X128;
if (tickCurrent >= tickLower) {
feeGrowthBelow0X128 = lower.feeGrowthOutside0X128;
feeGrowthBelow1X128 = lower.feeGrowthOutside1X128;
} else {
feeGrowthBelow0X128 = feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128;
feeGrowthBelow1X128 = feeGrowthGlobal1X128 - lower.feeGrowthOutside1X128;
}
// calculate fee growth above
uint256 feeGrowthAbove0X128;
uint256 feeGrowthAbove1X128;
if (tickCurrent < tickUpper) {
feeGrowthAbove0X128 = upper.feeGrowthOutside0X128;
feeGrowthAbove1X128 = upper.feeGrowthOutside1X128;
} else {
feeGrowthAbove0X128 = feeGrowthGlobal0X128 - upper.feeGrowthOutside0X128;
feeGrowthAbove1X128 = feeGrowthGlobal1X128 - upper.feeGrowthOutside1X128;
}
最后,使用公式 $$ f_g - f_a(i_u) - f_b(i_l) $$ 计算当前区间手续费情况:
feeGrowthInside0X128 = feeGrowthGlobal0X128 - feeGrowthBelow0X128 - feeGrowthAbove0X128;
feeGrowthInside1X128 = feeGrowthGlobal1X128 - feeGrowthBelow1X128 - feeGrowthAbove1X128;
注意,其实上述所有的计算都是代表单位流动性可以获得的手续费,所以我们最终会使用如下算法计算真正的代币数量:
// calculate accumulated fees
uint128 tokensOwed0 =
uint128(
FullMath.mulDiv(
feeGrowthInside0X128 - _self.feeGrowthInside0LastX128,
_self.liquidity,
FixedPoint128.Q128
)
);
uint128 tokensOwed1 =
uint128(
FullMath.mulDiv(
feeGrowthInside1X128 - _self.feeGrowthInside1LastX128,
_self.liquidity,
FixedPoint128.Q128
)
);
接下来,我们需要确定 tick 内的 $feeGrowthOutside0X128$ 和 $feeGrowthOutside1X128$ 的更新方法。根据上文中给出的
$$f_o$$
的图示,大家不难发现每次
$$f_o$$
只有被穿过的时候才会被更新,更新后的数值为
$$f_g - f_o$$
。在 cross 的代码中有所体现:
info.feeGrowthOutside0X128 = feeGrowthGlobal0X128 - info.feeGrowthOutside0X128;
info.feeGrowthOutside1X128 = feeGrowthGlobal1X128 - info.feeGrowthOutside1X128;
最后,我们要确定 tick 的初始化方法。这其实是一个难点,初始化的代码位于 update 内部,代码为:
if (liquidityGrossBefore == 0) {
// by convention, we assume that all growth before a tick was initialized happened _below_ the tick
if (tick <= tickCurrent) {
info.feeGrowthOutside0X128 = feeGrowthGlobal0X128;
info.feeGrowthOutside1X128 = feeGrowthGlobal1X128;
}
info.initialized = true;
}
实际上,从初始化的角度看,tick 内的 $feeGrowthOutside0X128$ 和 $feeGrowthOutside1X128$ 并不代表真正的
$$f_o$$
,但是实际上初始化的数值并不影响最终的手续费计算。背后的原理是因为手续费计算本质上是增量计算,初始化数值并不影响最终的计算结果。
除了流动性手续费外,实际上 Uniswap v3 也支持收取协议手续费且只对输入代币收取,而对输出代币不收取。但不同于 Uniswap v2,Uniswap v3 内的协议手续费是直接根据 swap 结果收取,而不是转化为 LP。在 Pool 的 Slot0 内部,存在 $feeProtocol$ 变量,其中该变量的后 4 bit 代表对 token 0 收取的手续费,而前 4 bit 代表对 token 1 收取的手续费。
在 swap 函数内,我们可以看到如下代码:
SwapCache memory cache =
SwapCache({
liquidityStart: liquidity,
blockTimestamp: _blockTimestamp(),
feeProtocol: zeroForOne ? (slot0Start.feeProtocol % 16) : (slot0Start.feeProtocol >> 4),
secondsPerLiquidityCumulativeX128: 0,
tickCumulative: 0,
computedLatestObservation: false
});
其中 feeProtocol: zeroForOne ? (slot0Start.feeProtocol % 16) : (slot0Start.feeProtocol >> 4), 完成了根据 $zeroForOne$ 获得手续费比率的功能。在 swap 过程中,由于区间流动性的存在,单个区间不一定可以满足 swap 的需求,所以 swap 会以循环的方法进行,不断在可用的流动性区间中进行交易,每次交易结果都会存放在 step 结构体内,其中 $step.feeAmount$ 代表交易过程中流动性手续费,而 $protocolFee$ 就会从流动性手续费中拿出一部分,代码如下:
if (cache.feeProtocol > 0) {
uint256 delta = step.feeAmount / cache.feeProtocol;
step.feeAmount -= delta;
state.protocolFee += uint128(delta);
}
我们来到了 Uniswap v3 最核心的部分,就是如何进行 Swap? 我们需要将上述介绍的一系列知识组合起来完成该任务。在白皮书内存在如下经典的流程图:

第一步是检查用户输入的数值以及初始化用于 swap 流程的中间结构体 SwapState。对于输入检查,主要是检查用户输入的用于控制滑点的 $sqrtPriceLimitX96$ 是否正确。$sqrtPriceLimitX96$ 是用户希望 swap 停止的价格,比如当前价格为 1,用户希望将价格推动到 0.9 时就停止 swap,此时就可以将 $sqrtPriceLimitX96$ 设置为 0.9。额外的未交易完成的代币会退还给用户。
require(
zeroForOne
? sqrtPriceLimitX96 < slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 > TickMath.MIN_SQRT_RATIO
: sqrtPriceLimitX96 > slot0Start.sqrtPriceX96 && sqrtPriceLimitX96 < TickMath.MAX_SQRT_RATIO,
'SPL'
);
我们在之前提到 $zeroForOne$ 会推动 Pool 内的价格下降,所以显然 $sqrtPriceLimitX96$ 需要小于当前价格,但要大于最低价格(MIN_SQRT_RATIO),对于 oneForZero 的情况同理。
另外,Uniswap v3 也会进行一次防止重入的检查 require(slot0Start.unlocked, 'LOK');。最后初始化 SwapState 暂存 swap 过程中的中间量:
SwapState memory state =
SwapState({
amountSpecifiedRemaining: amountSpecified,
amountCalculated: 0,
sqrtPriceX96: slot0Start.sqrtPriceX96,
tick: slot0Start.tick,
feeGrowthGlobalX128: zeroForOne ? feeGrowthGlobal0X128 : feeGrowthGlobal1X128,
protocolFee: 0,
liquidity: cache.liquidityStart
});
其中 $sqrtPriceX96$ 和 $tick$ 代表当前价格和 tick,而 $feeGrowthGlobalX128$ 代表当前全局手续费情况。
接下来,我们进入 swap 的核心循环。第一步是确定 swap 循环退出的条件:
$sqrtPriceLimitX96$上述两个条件满足其一就可以退出循环,反之只有两个条件都不满足,那么就需要一直进行 swap 循环:
while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
进入 swap 后,我们第一步是找到可供交易的流动性区间,那么首先调用 tickBitmap.nextInitializedTickWithinOneWord 就是一个好办法。然后调用 computeSwapStep 计算在当前流动性区间内进行交易,交易后到价格、交易需要的代币输入和代币输出以及交易的手续费:
(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
);
SwapMath.computeSwapStep 接受的参数如下:
function computeSwapStep(
uint160 sqrtRatioCurrentX96,
uint160 sqrtRatioTargetX96,
uint128 liquidity,
int256 amountRemaining,
uint24 feePips
)
其中第二个参数 $sqrtRatioTargetX96$ 在 Pool 的 swap 函数内构建稍微复杂:
(zeroForOne ? step.sqrtPriceNextX96 < sqrtPriceLimitX96 : step.sqrtPriceNextX96 > sqrtPriceLimitX96)
? sqrtPriceLimitX96
: step.sqrtPriceNextX96,
上述三目表达式含义如下:
$zeroForOne = \text{true}$ 的情况下
$step.sqrtPriceNextX96 < sqrtPriceLimitX96$ 成立,此时返回 $sqrtPriceLimitX96$,$step.sqrtPriceNextX96 > sqrtPriceLimitX96$ 成立,此时返回 $step.sqrtPriceNextX96$$zeroForOne = \text{false}$ 的情况下
$step.sqrtPriceNextX96 < sqrtPriceLimitX96$ 成立,此时返回 $step.sqrtPriceNextX96$$step.sqrtPriceNextX96 > sqrtPriceLimitX96$ 成立,此时返回 $sqrtPriceLimitX96$简单来说,就是为了通过比较 $step.sqrtPriceNextX96$ 和 $sqrtPriceLimitX96$ 的大小,输出合理的目标价格。合理的目标价格满足:
$zeroForOne = \text{true}$ 时,$sqrtRatioTargetX96 = \max(\text{next}, \text{limit})$$zeroForOne = \text{false}$ 时,$sqrtRatioTargetX96 = \min(\text{next}, \text{limit})$我们先暂时跳过 computeSwapStep 内部的分析。继续阅读后续代码。后续代码中,我们首先更新了 $state.amountSpecifiedRemaining$ 和 $state.amountCalculated$ :
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());
}
上述更新逻辑中,我们要注意 $state.amountSpecifiedRemaining > 0$ 代表 exactInput 模式,所以此处要使用 -= 进行计算。$state.amountCalculated$ 代表计算出代币数量。注意 $state.amountCalculated$ 也是存在符号的,我们认为为负数代表需要支出给用户的代币数量。在 exactInput 模式下,计算出的 $state.amountCalculated$ 一定会支付给用户,所以此处使用了 sub 计算。对于 exactOut 同理,此时 $state.amountSpecifiedRemaining < 0$ 所以使用 += 计算,而 $state.amountCalculated$ 代表用户支付给 Pool 的代币数量,此时使用 add 计算。
之后在 Pool 内进行 $feeProtocol$ 计算,此部分在前文已有介绍。然后更新全局的 $feeGrowthGlobalX128$,在上文,我们已经提到 $feeGrowthGlobalX128$ 代表单位流动性累计的手续费,所以计算方法如下:
if (state.liquidity > 0)
state.feeGrowthGlobalX128 += FullMath.mulDiv(step.feeAmount, FixedPoint128.Q128, state.liquidity);
最后,我们判断 computeSwapStep 输出的在当前流动性区间进行 swap 后的结果 $state.sqrtPriceX96$ 与我们在最初使用 nextInitializedTickWithinOneWord 搜索到的 $step.sqrtPriceNextX96$ 之间的关系。的一种关系是 $state.sqrtPriceX96 == step.sqrtPriceNextX96$,这说明我们已经耗尽了当前流动性区间的流动性,我们需要将移动 tick 进行新的搜索,核心代码如下:
if (state.sqrtPriceX96 == step.sqrtPriceNextX96) {
// if the tick is initialized, run the tick transition
if (step.initialized) {
// crosses an initialized tick
int128 liquidityNet =
ticks.cross(
step.tickNext,
(zeroForOne ? state.feeGrowthGlobalX128 : feeGrowthGlobal0X128),
(zeroForOne ? feeGrowthGlobal1X128 : state.feeGrowthGlobalX128),
cache.secondsPerLiquidityCumulativeX128,
cache.tickCumulative,
cache.blockTimestamp
);
// if we're moving leftward, we interpret liquidityNet as the opposite sign
// safe because liquidityNet cannot be type(int128).min
if (zeroForOne) liquidityNet = -liquidityNet;
state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);
}
state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext;
}
上述代码第一部分 if (step.initialized) 处理了要穿过已初始化的 tick 的 cross 过程,主要进行 feeGrowth 的更新,然后更新 $state.liquidity$,我们在上文刚刚完成这两部分的介绍。最后,修改 $state.tick$ 的值,这里的逻辑与 tick 搜索的逻辑稍有关系。在上文,我们介绍
$tick$ 的位于当前 word 的 next tick$tick$ 且位于当前 word 的 next ticl由于要触发不包含当前 $step.tickNext$ 的搜索,所以对于 $zeroForOne$(也就是向左搜索),由于包含 tick 自身,所以必须要使用 $step.tickNext - 1$ 更新 $state.tick$ 的值,而对于向右搜索,搜索本身就会不包含当前 tick,所以直接使用 $step.tickNext$ 即可。
对于另一种情况,即 $state.sqrtPriceX96 != step.sqrtPriceStartX96$ 的情况,这种情况实际代表 swap 已经结束,因为当前的流动性区间已经可以满足用户所有需求,此时我们要进行的额外工作是 state.tick = TickMath.getTickAtSqrtRatio(state.sqrtPriceX96);。这是因为 computeSwapStep 内部只使用价格进行计算,而不考虑 tick,此时我们要使用价格计算出正确的 tick 以方便未来使用。
上述过程实际上就完成了 Swap 内的最核心的循环,然后我们就可以将大量处于内存中的中间状态写入存储,包括 $sqrtPriceX96$ / $tick$ / $liquidity$ / $feeGrowthGlobal0X128$ / $feeGrowthGlobal1X128$ 等变量。以下所有的 if 其实都可以不存在,此处存在的唯一理由是为了避免重复写入导致的 gas 浪费。
if (state.tick != slot0Start.tick) {
(slot0.sqrtPriceX96, slot0.tick, slot0.observationIndex, slot0.observationCardinality) = (
state.sqrtPriceX96,
state.tick,
observationIndex,
observationCardinality
);
} else {
// otherwise just update the price
slot0.sqrtPriceX96 = state.sqrtPriceX96;
}
// update liquidity if it changed
if (cache.liquidityStart != state.liquidity) liquidity = state.liquidity;
// update fee growth global and, if necessary, protocol fees
// overflow is acceptable, protocol has to withdraw before it hits type(uint128).max fees
if (zeroForOne) {
feeGrowthGlobal0X128 = state.feeGrowthGlobalX128;
if (state.protocolFee > 0) protocolFees.token0 += state.protocolFee;
} else {
feeGrowthGlobal1X128 = state.feeGrowthGlobalX128;
if (state.protocolFee > 0) protocolFees.token1 += state.protocolFee;
}
在 Swap 函数的最后,我们处理用户的代币转账问题:
(amount0, amount1) = zeroForOne == exactInput
? (amountSpecified - state.amountSpecifiedRemaining, state.amountCalculated)
: (state.amountCalculated, amountSpecified - state.amountSpecifiedRemaining);
// do the transfers and collect payment
if (zeroForOne) {
if (amount1 < 0) TransferHelper.safeTransfer(token1, recipient, uint256(-amount1));
uint256 balance0Before = balance0();
IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');
} else {
if (amount0 < 0) TransferHelper.safeTransfer(token0, recipient, uint256(-amount0));
uint256 balance1Before = balance1();
IUniswapV3SwapCallback(msg.sender).uniswapV3SwapCallback(amount0, amount1, data);
require(balance1Before.add(uint256(amount1)) <= balance1(), 'IIA');
}
emit Swap(msg.sender, recipient, amount0, amount1, state.sqrtPriceX96, state.liquidity, state.tick);
slot0.unlocked = true;
对 $(amount0, amount1)$ 的计算是一个难点,代码内的三目表达式的展开是:
// 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$)
在本节的最后,我们介绍 Swap 中的 computeSwapStep 函数的具体实现。我们首先分析 exactIn 的情况,代码如下:
uint256 amountRemainingLessFee = FullMath.mulDiv(uint256(amountRemaining), 1e6 - feePips, 1e6);
amountIn = zeroForOne
? SqrtPriceMath.getAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true)
: SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, true);
if (amountRemainingLessFee >= amountIn) sqrtRatioNextX96 = sqrtRatioTargetX96;
else
sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromInput(
sqrtRatioCurrentX96,
liquidity,
amountRemainingLessFee,
zeroForOne
);
我们首先计算在手续费处理后的用户输入的代币数量 $amountRemainingLessFee$,然后使用 getAmount0Delta 或 getAmount1Delta 计算在 $sqrtRatioTargetX96$ 与 $sqrtRatioCurrentX96$ 之间的流动性等效的代币数量 $amountIn$。假如 $amountRemainingLessFee \ge amountIn$,就说明用户输入的代币数量可以填满 $sqrtRatioTargetX96$ 与 $sqrtRatioCurrentX96$ 区间,所以 $sqrtRatioNextX96$ 就是 $sqrtRatioTargetX96$。反之,则说明用户输入代币无法将价格推动到 $sqrtRatioTargetX96$,此时我们就会使用 getNextSqrtPriceFromInput 利用 $liquidity$ 和 $amountRemainingLessFee$ 以及 $sqrtRatioCurrentX96$ 计算当前代币输入后的价格,具体的数学公式为:
$$p_a = P - \frac{y}{L}$$ $$p_b = \frac{L}{P L - P \cdot x}$$
其中
$$p_b$$
用于计算 $zeroForOne$ 情况,此时输入代币为 token 0,计算结果是在当前
$$L$$
和
$$P$$
下交易后的价格,具体对应 getNextSqrtPriceFromAmount0RoundingUp 函数。而
$$p_a$$
用于计算 oneForZero 的情况,对应函数为 getNextSqrtPriceFromAmount1RoundingDown 函数。限于篇幅,我们不再介绍具体的函数实现。
对于 exactOut 的模式,其实代码类似。我们首先计算 $sqrtRatioTargetX96$ 与 $sqrtRatioCurrentX96$ 价格区间内的代币数量 $amountOut$ 。如果发现用户需要的 $amountRemaining$ 大于等于 $amountOut$,那么我可以直接将 $sqrtRatioNextX96$ 设置为价格区间的端点 $sqrtRatioTargetX96$。反之,我们则需要进行新的价格计算。使用的数学公式其实与上文一致。额外注意的是 $amountRemaining$ 在 exactOut 情况下为负数。
amountOut = zeroForOne
? SqrtPriceMath.getAmount1Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, false)
: SqrtPriceMath.getAmount0Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, false);
if (uint256(-amountRemaining) >= amountOut) sqrtRatioNextX96 = sqrtRatioTargetX96;
else
sqrtRatioNextX96 = SqrtPriceMath.getNextSqrtPriceFromOutput(
sqrtRatioCurrentX96,
liquidity,
uint256(-amountRemaining),
zeroForOne
);
最后,我们会根据多种情况判断代币的真实输出。首先,我们引入 bool max = sqrtRatioTargetX96 == sqrtRatioNextX96;,这种情况其实代表当前价格区间被完全耗尽,在这种情况下,计算出的 $amountIn$ 就是真实的 $amountIn$,反之,我们则需要利用 $sqrtRatioNextX96$ 和 $sqrtRatioCurrentX96$ 再次计算兑换的代币数量。最终,完整的条件判断如下,此处的一个难点是舍入问题,getAmount0Delta 和 getAmount1Delta 的最后的布尔参数代表是否需要 roundUp。
// get the input/output amounts
if (zeroForOne) {
amountIn = max && exactIn
? amountIn
: SqrtPriceMath.getAmount0Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, true);
amountOut = max && !exactIn
? amountOut
: SqrtPriceMath.getAmount1Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, false);
} else {
amountIn = max && exactIn
? amountIn
: SqrtPriceMath.getAmount1Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, true);
amountOut = max && !exactIn
? amountOut
: SqrtPriceMath.getAmount0Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, false);
上述结果也并不是最终结果,我们需要对于 exactOut 的情况需要进行额外的控制,因为计算可能存在误差,这可能导致 exactOut 大于用户预期,对于这种情况,我们默认将额外的超额误差部分舍入:
// cap the output amount to not exceed the remaining output amount
if (!exactIn && amountOut > uint256(-amountRemaining)) {
amountOut = uint256(-amountRemaining);
}
最后,我们计算交易过程中的 $feeAmount$:
if (exactIn && sqrtRatioNextX96 != sqrtRatioTargetX96) {
// we didn't reach the target, so take the remainder of the maximum input as fee
feeAmount = uint256(amountRemaining) - amountIn;
} else {
feeAmount = FullMath.mulDivRoundingUp(amountIn, feePips, 1e6 - feePips);
}
对于 $exactIn \land \text{sqrtRatioNextX96} \neq \text{sqrtRatioTargetX96}$ 其实就是 exactIn 模式下,代币在当前价格区间完成所有兑换。在之前计算时,我们使用了 $amountRemainingLessFee$ 进行比较,所以此处直接使用 $feeAmount = \text{uint256}(\text{amountRemaining}) - \text{amountIn};$ 就可以计算获得最终结果。对于其他情况,我们都需要使用如下公式进行计算:
$$\text{feeAmount} = \frac{\text{amountIn} \times \text{feePips}}{1 - \text{feePips}}$$
上述公式的推导流程为:
$$\text{total Amount In} = \frac{\text{amountIn}}{1 - \text{feePips}}$$ $$\text{feeAmount} = \text{total Amount In} \times \text{feePips} = \frac{\text{amountIn} \times \text{feePips}}{1 - \text{feePips}}$$
上述计算中我们认为给定的 $amountIn$ 是已经扣完手续费后的净额,在计算 $feeAmount$ 时,我们第一步是将 $amountIn$ 还原为含手续费的全额,然后进行手续费计算。
在本文中,我们介绍了关于 Uniswap v3 的大部分内容,但没有给出部分数学公式的计算方法,假如读者希望更加全面的认为 Uniswap v3,可以将笔者的另一篇 博客 也阅读一遍。本文忽略了所有关于 Oracle 的部分,实际上笔者的另一篇文章也没有介绍 Oracle,读者可以自行根据白皮书内容和代码理解这一部分。Uniswap v3 的 Oracle 带来的最有趣的 DeFi 原语就是流动性质押,假如读者的项目涉及该部分,可以研究一下 Uniswap v3 内的 Oracle 部分。
- 本文转载自: hackmd.io/@wongssh/unisw... , 如有侵权请联系管理员删除。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!