引言
本系列文章将带领读者从零实现Uniswap V3核心功能,深入解析其设计与实现。主要参考了 Constructor | Uniswap V3 Core Contract Explained[1] 系列教程,并补充了 Uniswap V3 Development Book[2] 和 Paco 博客[3] 中的相关内容。所有示例代码可在 clamm[4] 代码库中找到,以便实践和探索。
构造器与初始化
初始化项目。我们首先创建一个文件夹用于存储 Uniswap V3 和我们自己的代码:
mkdir uniswap & cd uniswap
git clone https://github.com/Uniswap/v3-core.git
mkdir clamm & cd clamm
forge init --vscode
最后,我们可以获得以下文件目录格式:
.
├── clamm
│ ├── README.md
│ ├── foundry.toml
│ ├── lib
│ ├── remappings.txt
│ ├── script
│ ├── src
│ └── test
└── v3-core
├── LICENSE
├── README.md
├── audits
├── bug-bounty.md
├── contracts
├── echidna.config.yml
├── hardhat.config.ts
├── package.json
├── test
├── tsconfig.json
└── yarn.lock
接下来,我们可以在clamm
的src
文件夹内创建 CLAMM.sol
文件,我们将在该文件内编写 Uniswap V3 Pool 合约。注意,在本文内,我们目前不会构造 Factory,所以我们需要将 Uniswap V3 的原版合约修改为构造器初始化版本。
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.20;
contractCLAMM {
addresspublic immutable token0;
addresspublic immutable token1;
uint24public immutable fee;
int24public immutable tickSpacing;
uint128public immutable maxLiquidityPerTick;
constructor (address _token0, address _token1, uint24 _fee, int24 _tickSpacing) {
token0 = _token0;
token1 = _token1;
fee = _fee;
tickSpacing = _tickSpacing;
maxLiquidityPerTick = Tick.tickSpacingToMaxLiquidityPerTick(_tickSpacing);
}
}
在此处,我们使用了Tick
库中的 tickSpacingToMaxLiquidityPerTick
函数。为了方便读者理解,我们首先介绍 Tick
的概念。众所周知,在 Uniswap V3 内部,存在价格区间概念,我们使用 Tick 标记价格区间的上限和下限。
在 Uniswap V3 内,我们使用
来计算第 i 个 TICK 对应的具体价格。在上文的代码内出现了 _tickSpacing
的概念。这是指在 Uniswap V3 内,我们不会使用 0, 1, 2
这种索引,在大部分情况下,我们都是使用的类似 0, 10, 20
这种更大区间的索引,而 _tickSpacing
则代表价格区间的长度。比如在 _tickSpacing=10
的情况下, 0, 10, 20,30
等数值就是有效 Tick,而 11
等就是无效的索引。大区间意味着更少的价格区间,但也意味着更低的价格精度。相反的,小区间意味着更高的价格精度,但也会带来更高的 gas 消耗,我们会在后文介绍其中的原因。Uniswap 允许使用 10、60 或 200 作为 _tickSpacing
的参数。
当了解了 _tickSpacing
的概念后,就可以理解 tickSpacingToMaxLiquidityPerTick
方法的含义。其功能在于计算每一个有效 Tick 下可允许的最大流动性。当使用小区间时,单个区间内的最大流动性会较低,反之则较高。我们可以在 clamm/src/libraries/Tick.sol
内编写 tickSpacingToMaxLiquidityPerTick
函数。
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.20;
import"./TickMath.sol";
libraryTick {
functiontickSpacingToMaxLiquidityPerTick(int24 tickSpacing) internalpurereturns (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;
}
}
此处使用的 TickMath.sol
是一个用于 Tick 相关计算的数学库,读者可以直接在 _v3-core/contracts/libraries/TickMath.sol
内复制。我们不会在本文介绍该数学库的具体原理,未来会有单独的文章介绍。
此处的MIN_TICK
和MIN_TICK
就是在tickSpacing = 1
的情况下,最大的索引值和最小的索引值。我们第一步使用(TickMath.MIN_TICK / tickSpacing) * tickSpacing;
计算出在当前tickSpacing
下的最小索引值。我们可以使用 chisel 工具看看上述代码的作用。
➜ int24 internal constant MIN_TICK = -887272;
➜ int24 tickSpacing = 10;
➜ int24 minTick = (MIN_TICK / tickSpacing) * tickSpacing;
➜ minTick
Type: int24
├ Hex: 0xf2761a
├ Hex (full word): 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffffff2761a
└ Decimal: -887270
可以看到 (TickMath.MIN_TICK / tickSpacing) * tickSpacing;
就是将原本的 MIN_TICK
修正为 tickSpacing
的倍数。根据我们上文的讨论,所有不是 tickSpacing
倍数的索引实际上都是无效的。简单来说,计算 minTick
和 maxTick
就是计算在当前 tickSpacing
下最大和最小的有效索引值。接下来,我们需要计算当前 tickSpacing
下有效 Tick 的数量。注意此处我们计算的是 有效 Tick 的数量而不是区间的数量,所以我们需要对 max - min / tickSpcing
计算出的区间数量增加 1 以计算 Tick 数量,即 uint24((maxTick - minTick) / tickSpacing) + 1
。最后,Uniswap 使用 uint128
存储流动性数量,所以此处只需要 type(uint128).max / numTicks;
就可以计算出每一个有效 Tick 对应的流动性数量。
在 TickMath.sol
的 getSqrtRatioAtTick
函数内,如果读者使用较新版本的 solidity 编译器,那么读者需要将 require(absTick <= uint256(MAX_TICK), 'T');
修改为 require(absTick <= uint256(int256(MAX_TICK)), 'T');
。
接下来,我们介绍 initialize
函数,initialize
函数用于初始化 Slot0
状态变量。众所周知,在 Solidity 内部,一个结构体内部所有元素如果长度累加到一起小于 256 bit ,那么将该结构体内的元素打包放在同一个存储槽内部。如果读者对存储部分不是特别熟悉,可以阅读 Solidity Gas 优化清单及其原理:存储、内存与操作符[5] 。而 Slot0
就是一个这样的结构体。该结构体占据了第一个存储槽。本文目前使用了一个Slot0
的简化版本:
Slot0 public slot0;
struct Slot0 {
uint160 sqrtPriceX96;
int24 tick;
bool unlocked;
}
上述结构体内部 sqrtPriceX96
代表当前的方价格的开方,tick
则代表当前价格所位于的有效 Tick 数值,而 unlocked
则用于防止重入攻击。此处读者大概率好奇为啥使用价格的开方,这是因为 Uniswap 特殊的数学。假设 token0 的数量为 x,而 token1 的数量为 y。在 Uniswap V3 内,我们定义:
关于为什么 Uniswap V3 使用了变量,读者可以在后文的编码实践中体验到,或者去阅读 Uniswap V3 Development Book 中的数学推导部分。众所周知,Solidity 内不能存储浮点数,所以 Uniswap V3 使用了
的方案来存储浮点数。正如上文所述,
sqrtPriceX96
代表的价格与 Tick 是有关的,我们需要一个数学公式来转化:
在 TickMath
内已经包含了上述 sqrtPriceX96
与 tick
的转换计算函数,该函数被命名为 getTickAtSqrtRatio
函数。当我们具有以上知识后,我们就可以编写如下初始化函数:
function initialize(uint160 sqrtPriceX96) external {
require(slot0.sqrtPriceX96 == 0, 'Already initialized');
int24 tick = TickMath.getTickAtSqrtRatio(sqrtPriceX96);
slot0 = Slot0({
sqrtPriceX96: sqrtPriceX96,
tick: tick,
unlocked: true
});
}
文内链接
[1]Uniswap V3 Core Contract Explained:
https://www.youtube.com/playlist?list=PLO5VPQH6OWdXp2_Nk8U7V-zh7suI05i0E
[2]Uniswap V3 Development Book:
https://uniswapv3book.com/
[3]Paco 博客:
https://paco0x.org/
[4]clamm 代码库:
https://github.com/t4sk/clamm
[5]Solidity Gas 优化清单及其原理:
https://blog.wssh.trade/posts/gas-optimize-part1/