Uniswap V3:构造器与初始化

225 阅读6分钟

1.png

作者:WongSSH

引言

本系列文章将带领读者从零实现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

接下来,我们可以在clammsrc文件夹内创建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 标记价格区间的上限和下限。

2.png

在 Uniswap V3 内,我们使用

p(i)=1.0001ip(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_TICKMIN_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 内,我们定义:

L=xyL = \sqrt{xy}
P=yx\sqrt{P} = \sqrt{\frac{y}{x}}

关于为什么 Uniswap V3 使用了P\sqrt{P}变量,读者可以在后文的编码实践中体验到,或者去阅读 Uniswap V3 Development Book 中的数学推导部分。众所周知,Solidity 内不能存储浮点数,所以 Uniswap V3 使用了P296\sqrt{P} \cdot 2^{96}的方案来存储浮点数。正如上文所述,sqrtPriceX96 代表的价格与 Tick 是有关的,我们需要一个数学公式来转化:

P=(sqrtPriceX96)2296=1.0001tickP = \frac{(\text{sqrtPriceX96})^2}{2^{96}} = 1.0001^{\text{tick}}
2log(sqrtPriceX96296)=ticklog1.00012\log\left(\frac{\text{sqrtPriceX96}}{2^{96}}\right) = \text{tick} \cdot \log 1.0001
tick=2log(sqrtPriceX96296)log1.0001\text{tick} = \frac{2\log\left(\frac{\text{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  
    });  
}

文内链接

[1]Uniswap V3 Core Contract Explained:
www.youtube.com/playlist?li…

[2]Uniswap V3 Development Book: uniswapv3book.com/

[3]Paco 博客: paco0x.org/

[4]clamm 代码库: github.com/t4sk/clamm

[5]Solidity Gas 优化清单及其原理: blog.wssh.trade/posts/gas-o…