作者:WongSSH
引言
本系列文章将带领读者从零实现Uniswap V3核心功能,深入解析其设计与实现。主要参考了 Constructor | Uniswap V3 Core Contract Explained 系列教程,并补充了 Uniswap V3 Development Book 和 Paco 博客 中的相关内容。所有示例代码可在 clamm 代码库中找到,以便实践和探索。
构造器与初始化
初始化项目。我们首先创建一个文件夹用于存储 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 内,我们使用 \( p(i)=1.0001^i \) 来计算第 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 优化清单及其原理:存储、内存与操作符 。而 Slot0 就是一个这样的结构体。该结构体占据了第一个存储槽。本文目前使用了一个Slot0 的简化版本:
Slot0 public slot0;
struct Slot0 {
uint160 sqrtPriceX96;
int24 tick;
bool unlocked;
}
上述结构体内部 sqrtPriceX96 代表当前的方价格的开方,tick 则代表当前价格所位于的有效 Tick 数值,而 unlocked 则用于防止重入攻击。此处读者大概率好奇为啥使用价格的开方,这是因为 Uniswap 特殊的数学。假设 token0 的数量为 x,而 token1 的数量为 y。在 Uniswap V3 内,我们定义:
$$ L = \sqrt{xy} $$
$$ \sqrt{P} = \sqrt{\frac{y}{x}} $$
关于为什么 Uniswap V3 使用了 \( \sqrt{p} \),读者可以在后文的编码实践中体验到,或者去阅读 Uniswap V3 Development Book 中的数学推导部分。众所周知,Solidity 内不能存储浮点数,所以 Uniswap V3 使用了\( \sqrt{P} \cdot 2^{96} \)的方案来存储浮点数。正如上文所述,sqrtPriceX96 代表的价格与 Tick 是有关的,我们需要一个数学公式来转化:
$$ P = \frac{(sqrtPriceX96)^2}{2^{96}} = 1.0001^{\text{tick}} $$
$$ 2\log\left(\frac{sqrtPriceX96}{2^{96}}\right) = \text{tick} \cdot \log 1.0001 $$
$$ \text{tick} = \frac{2 \log\left(\frac{sqrtPriceX96}{2^{96}}\right)}{\log 1.0001} $$
在 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
});
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。