14.Uniswap V3 Tick Bitmap

核心摘要 (Key Takeaways)

  • Tick BitMap 的核心目的: 为了高效、低成本地跟踪 Uniswap V3 池中哪些价格点(Ticks)被用作了流动性区间的边界,从而知道哪些价格范围是活跃的。
  • 精巧的数据压缩: Tick BitMap 抛弃了为每个 Tick 单独存储状态的朴素方案(成本过高),而是通过位运算将 256 个连续的 Tick 状态压缩存储在一个 uint256 变量中,极大地节省了 Gas 费和存储空间。
  • 关键转换公式: 任何一个 tick (类型为 int24) 都可以通过数学运算定位到它在 BitMap 中的具体位置。其核心是两个坐标:wordPosition (确定在哪一个 uint256 组里) 和 bitPosition (确定在该组的第几位)。
    • wordPosition = tick / 256
    • bitPosition = tick % 256
  • 数据结构: Tick BitMap 在合约中实现为一个 mapping(int16 => uint256)。其中 key (int16)wordPositionvalue (uint256) 是一个 256 位的位图,每一位代表一个 tick 的活跃状态(1 表示活跃,0 表示不活跃)。
  • 状态更新机制: 添加或移除流动性时,通过生成一个特定的掩码 (Mask),并与 BitMap 中对应的 uint256 值进行按位或 (OR)按位与 (AND) 运算,来精确地将某一位从 0 改为 1 或从 1 改为 0,而不影响其他位。

1. Tick 的回顾与作用

  • 定义: Tick (也称 Tick Index) 是 Uniswap V3 中用来标记离散化价格点的单位。
  • 价格公式: 价格 Ptick T 的关系通过以下公式定义: P=1.0001T P = 1.0001^T 其中 T 就是 tick 的索引。当 T 增大时,价格 P 上升;当 T 减小时,价格 P 下降。
  • Tick 范围:
    • 合约中 tick 的类型为 int24,其理论范围是从 -887272+887272
    • 案例: 这个范围足以表示极其宽广的价格区间。
      • tick-887272 时,价格约为 2.938×10392.938 \times 10^{-39}
      • tick+887272 时,价格约为 3.4×10383.4 \times 10^{38}
      • 这个范围远超比特币等主流资产的价格波动范围,因此在设计上是足够用的。

2. 核心问题:如何跟踪活跃的流动性区间?

  • 背景: 在 Uniswap V3 中,用户在自定义的价格区间 [PA, PB] (对应 [Tick_A, Tick_B]) 内添加流动性。
  • 挑战: 一个活跃的交易对(如 BTC/USDT)可能有成千上万的用户在各种不同的、甚至重叠的价格区间添加流动性。合约需要一种高效的方式来跟踪哪些 tick 成为了这些流动性区间的边界(即被激活)。
    • 案例: 假设一个用户在 tick 范围 [-10, -5] 添加了流动性,而另一个用户在 [-4, 1] 添加了流动性。此外,还可能存在重叠区间,例如用户 A 在 [6, 10] 添加流动性,用户 B 在 [7, 12] 添加流动性,那么在 [7, 10] 区间内流动性是重叠的。合约必须有效管理这一切。
  • 从 ERC20 到 ERC721:
    • V2: 所有流动性提供者的 LP 凭证是同质化的 ERC20 代币。
    • V3: 由于每个人的流动性价格区间和数量都可能不同,LP 凭证变成了非同质化的 ERC721 代币(NFT)。
    • 案例: 一个 V3 LP NFT (通过 NonfungiblePositionManager 合约管理) 记录了如 tickLowertickUpperliquidity (uint128) 等唯一信息,代表一个独一无二的流动性仓位。
  • 根本问题: 尽管 NFT 记录了每个仓位的具体信息,但 Pool 合约本身如何宏观地、低成本地知道在任意一个 tick 上,是否有流动性边界存在?

3. 一种被否决的朴素方案

  • 思路: 设计一个简单的键值对(Key-Value)结构来记录每个 tick 的状态。
    • mapping(int24 => bool)
    • Key: tick 索引 (例如: -10000, 20000)
    • Value: true (被用作边界) 或 false (未被用作边界)
  • 致命缺陷:
    1. 存储量巨大: tick 的总数约有 177 万个。为每一个 tick 都创建一个存储槽位在区块链上是无法承受的,成本极高。
    2. 计算量巨大 (高 Gas 费): 当一笔交易(swap)需要跨越成千上万个 tick 时,如果需要逐一查询这些 tick 的状态,将会导致交易的 Gas 费极高,用户无法接受。

4. Uniswap V3 的精巧解决方案:Tick BitMap

Tick indexes in tick bitmap

4.1 Tick BitMap 的核心思想

Tick BitMap 是一种数据压缩技术,它没有为每个 tick 单独存储信息,而是将连续的 256 个 tick 的状态打包到一个 256 位的整数 (uint256) 中进行管理。

4.2 从 Tick 到 Word Position 和 Bit Position 的转换

为了定位任意一个 tick 在 BitMap 中的精确位置,需要将其转换为两个坐标:wordPositionbitPosition。这本质上是将一个 int24 类型的 tick 值,在概念上拆分为两部分:

  • 前 16 位: 用于确定 wordPosition,对应 mappingkey (int16)。
  • 后 8 位: 用于确定 bitPosition,对应 value (uint256) 中的具体位置 (uint8)。

转换公式:

  • wordPosition (商): 决定了该 tick 属于哪一个 uint256 “字” (word)。

    wordPosition=tick256 wordPosition = \lfloor \frac{tick}{256} \rfloor
  • bitPosition (余数): 决定了该 tick 在其所属的 uint256 “字” 中的具体哪一位 (从 0 到 255)。

    bitPosition=tick(mod256) bitPosition = tick \pmod{256}
  • 反向还原公式:

    tick=(wordPosition×256)+bitPosition tick = (wordPosition \times 256) + bitPosition
  • 代码实现: 在 Solidity 合约中,为了节省 Gas,通常使用位运算实现:

    • 除法 / 256 等价于 右移 8 位 >> 8
    • 取余 % 256 可以直接计算。
  • 案例 (视频中的核心例子):

    • 假设 tick = -200697
    • 计算 wordPosition: -200697 / 256 = -783.97...,向负无穷方向取整得到 -784
    • 计算 bitPosition: -200697 % 256 = 7
    • 结论: tick -200697 的状态信息,存储在 wordPosition-784 的那个 uint256 值的第 7 位上(索引从0开始)。

4.3 数据结构与工作原理

  • 合约内定义:
mapping(int16 => uint256) public tickBitmaps;
  • key (int16): 就是计算出的 wordPosition
  • value (uint256) : 一个 256 位的位图。它本身不是一个有数值意义的数字,而是一个 标记序列 ,其中每一位(0或1)代表对应 tick 的活跃状态。

4.4 流动性的更新操作 (添加与移除)

更新特定 tick 的状态是通过位运算完成的,这比直接读写存储要高效得多。

  • 添加流动性 (将某位置为 1)

    1. 目标: 将 wordPosition 对应 uint256 值的第 bitPosition 位置为 1,且不改变其他位。
    2. 生成掩码 (Mask): 创建一个 uint256 的掩码,其中只有第 bitPosition 位是 1,其余所有位都是 0。(通过 1 << bitPosition 实现)。
    3. 执行运算: 将原始的 uint256 值与这个掩码进行 按位或 (OR|) 运算。
      • X OR 1 = 1 (将目标位置为 1)
      • X OR 0 = X (其他位保持不变)
  • 移除流动性 (将某位置为 0)

    1. 目标: 将 wordPosition 对应 uint256 值的第 bitPosition 位置为 0,且不改变其他位。
    2. 生成掩码 (Mask): 创建一个 uint256 的掩码,其中只有第 bitPosition 位是 0,其余所有位都是 1。(通过对"添加"掩码进行按位取反 (NOT~) 实现)。
    3. 执行运算: 将原始的 uint256 值与这个掩码进行 按位与 (AND&) 运算。
      • X AND 0 = 0 (将目标位置为 0)
      • X AND 1 = X (其他位保持不变)

5. 可视化流程:更新 Tick BitMap

图表选择说明: 此处选择流程图 (Flowchart) 是最合适的。因为我们的目标是清晰地展示一个算法的内部处理步骤和决策逻辑

  • 时序图 (Sequence Diagram) 更侧重于不同对象/角色之间的消息传递和交互顺序,不适合展现内部计算逻辑。
  • 甘特图 (Gantt Chart) 用于项目管理和时间规划,与此场景完全无关。
graph LR
    A[开始: 更新Tick的活跃状态] --> B{输入 Tick 值};
    B --> C[1:计算坐标
wordPosition = tick / 256
bitPosition = tick % 256]; C --> D[2:根据 wordPosition
从 tickBitmaps 中读取原始的 uint256 值]; D --> E{操作是添加还是移除流动性?}; E -- 添加 --> F[3a. 生成'添加'掩码 Mask
即第 bitPosition 位为1, 其余为0]; F --> G[4a. 执行按位'或'操作
新值 = 原始值 OR Mask]; E -- 移除 --> H[3b. 生成'移除'掩码 Mask
即第 bitPosition 位为0, 其余为1]; H --> I[4b. 执行按位'与'操作
新值 = 原始值 AND Mask]; G --> J[5: 将新值写回 tickBitmaps]; I --> J; J --> K[结束];
graph LR
    A[开始: 更新Tick的活跃状态] --> B{输入 Tick 值};
    B --> C[1:计算坐标
wordPosition = tick / 256
bitPosition = tick % 256]; C --> D[2:根据 wordPosition
从 tickBitmaps 中读取原始的 uint256 值]; D --> E{操作是添加还是移除流动性?}; E -- 添加 --> F[3a. 生成'添加'掩码 Mask
即第 bitPosition 位为1, 其余为0]; F --> G[4a. 执行按位'或'操作
新值 = 原始值 OR Mask]; E -- 移除 --> H[3b. 生成'移除'掩码 Mask
即第 bitPosition 位为0, 其余为1]; H --> I[4b. 执行按位'与'操作
新值 = 原始值 AND Mask]; G --> J[5: 将新值写回 tickBitmaps]; I --> J; J --> K[结束];
graph LR
    A[开始: 更新Tick的活跃状态] --> B{输入 Tick 值};
    B --> C[1:计算坐标
wordPosition = tick / 256
bitPosition = tick % 256]; C --> D[2:根据 wordPosition
从 tickBitmaps 中读取原始的 uint256 值]; D --> E{操作是添加还是移除流动性?}; E -- 添加 --> F[3a. 生成'添加'掩码 Mask
即第 bitPosition 位为1, 其余为0]; F --> G[4a. 执行按位'或'操作
新值 = 原始值 OR Mask]; E -- 移除 --> H[3b. 生成'移除'掩码 Mask
即第 bitPosition 位为0, 其余为1]; H --> I[4b. 执行按位'与'操作
新值 = 原始值 AND Mask]; G --> J[5: 将新值写回 tickBitmaps]; I --> J; J --> K[结束];

流程解释:

  1. 当需要更新一个 tick 的状态时,首先输入该 tick 的值。
  2. 合约通过除以 256 和对 256 取余,计算出该 tick 对应的 wordPositionbitPosition
  3. 使用 wordPosition 作为键,从 tickBitmaps 这个 mapping 中找到对应的 256 位 uint256 值。
  4. 根据操作是添加还是移除流动性,生成不同的掩码(Mask)并执行相应的位运算(ORAND)。
  5. 最后,将运算后得到的新 uint256 值更新回 tickBitmaps 中,完成状态更新。这个过程只修改了一位,效率极高。