11.Uniswap V3 Tick 与价格表示

核心摘要 (Key Takeaways)

  • tick 引入目的与作用: Uniswap V3 引入了 tick 机制,将连续的价格范围离散化,以解决集中流动性中用户随意设置价格区间导致的高 Gas 消耗问题,从而大幅节省 Gas 费用。
  • 价格与 tick 的数学关系: Uniswap V3 中的价格 PPtick 索引 TT 之间通过公式 P=1.0001TP = 1.0001^T 建立精确的指数关系。
  • 合约价格存储: Uniswap V3 合约的 slot0 结构中,价格信息并非直接存储为 PP,而是以 P×296\sqrt{P} \times 2^{96} 的形式(即 sqrtPriceX96)存储,并采用 Q64.96 定点数格式,以适应 Solidity 对浮点数处理的限制并优化 Gas 消耗。
  • 实际价格计算的复杂性: 从合约中读取的价格需要考虑 Token 精度差异token0/token1 顺序(可能导致价格倒数)进行修正,才能得到真实的市场价格。
  • tick 范围的由来: tick 的范围被限制在 -887272 到 +887272,这一范围是根据 Q64.96 定点数格式所能表示的 P\sqrt{P} 的上下限,通过价格与 tick 的数学关系推导而来,并硬编码在合约中。

1. tick 的引入与作用

1.1 问题背景:集中流动性的 Gas 消耗

  • Uniswap V3 的核心创新: 引入了集中流动性 (Concentrated Liquidity),允许用户在任意价格区间 PaP_aPbP_b 内提供流动性。
  • 潜在问题: 如果用户可以随意设置 PaP_aPbP_b (例如 1795.02341795.02341800.000121800.00012),会带来以下问题:
    • 每个用户设置的价格范围都是独一无二且随意的。
    • 维护和记录每个用户添加的 PaP_aPbP_b 范围会消耗巨量的 Gas 费用,因为合约状态需要存储大量不规则的数据。

1.2 解决方案:引入 tick 进行离散化

An illustration of Uniswap price curve getting sliced by ticks

  • 核心思想: 将连续的价格空间离散化。用户添加流动性的最小单位是 tick
  • tick 的定义: tick 可以理解为价格轴上的一个“刻度点”。用户只能在两个 tick 之间选择 PaP_aPbP_b 来添加流动性,即 PaP_a 必须是 lowerTickPbP_b 必须是 upperTick
  • 类比: 就像高速公路的里程表,你可以连续地报出 60.2234560.22345 公里,但更常见和高效的方式是离散地报出 6060 公里、6161 公里等整数里程。tick 机制就是将连续的价格报数离散化。
  • 好处:
    • 节省 Gas 费用: 通过将连续的价格范围转换为离散的 tick 范围,大大减少了需要维护的状态数量和计算复杂度。
    • 标准化: 统一了流动性添加的边界,使得不同用户的流动性可以更有效地聚合和管理。
  • 价格表示: 在 Uniswap V3 的图示中,tick 之间的斜率表示价格。

An illustration of a price range between ticks

1.3 Uniswap V2 与 V3 合约记录逻辑对比

Uniswap V2 和 V3 在合约中记录流动性和价格的方式存在根本性差异,以下流程图展示了它们的逻辑对比:

graph TD
    subgraph Uniswap V2
        A[记录 Token X 数量] --> B[推导价格 P]
        A --> C[推导流动性 L]
        D[记录 Token Y 数量] --> B
        D --> C
    end

    subgraph Uniswap V3
        E[记录价格 P] --> F[推导特定 tick 范围内的 Token X 数量]
        E --> G[推导特定 tick 范围内的 Token Y 数量]
        H[记录流动性 L]
    end
graph TD
    subgraph Uniswap V2
        A[记录 Token X 数量] --> B[推导价格 P]
        A --> C[推导流动性 L]
        D[记录 Token Y 数量] --> B
        D --> C
    end

    subgraph Uniswap V3
        E[记录价格 P] --> F[推导特定 tick 范围内的 Token X 数量]
        E --> G[推导特定 tick 范围内的 Token Y 数量]
        H[记录流动性 L]
    end
graph TD
    subgraph Uniswap V2
        A[记录 Token X 数量] --> B[推导价格 P]
        A --> C[推导流动性 L]
        D[记录 Token Y 数量] --> B
        D --> C
    end

    subgraph Uniswap V3
        E[记录价格 P] --> F[推导特定 tick 范围内的 Token X 数量]
        E --> G[推导特定 tick 范围内的 Token Y 数量]
        H[记录流动性 L]
    end

解释:

  • Uniswap V2: 合约直接记录 Token XToken Y 的数量,然后通过这些数量推导出当前价格 PP 和总流动性 LL
  • Uniswap V3: 合约直接记录当前价格 PP (通过 ticksqrtPriceX96 表示) 以及总流动性 LL。然后,反过来根据这些信息推导出在每个特定 tick 范围内的 Token XToken Y 数量。这意味着直接查看 V3 合约的 Token XToken Y 余额并不能直接计算出当前价格,因为流动性是分散在不同价格区间内的。

2. tick 与价格的数学关系

2.1 核心价格公式

Uniswap V3 使用以下公式将 tick 索引 TT 转换为价格 PP:

P=1.0001T P = 1.0001^T
  • TT: 表示 tick 索引,可以是正数、负数或零。
  • PP: 表示价格。

2.2 tick 变化对价格的影响

  • T=0T=0: 任何非零数的零次方都是 1。因此,当 T=0T=0 时,P=1.00010=1P = 1.0001^0 = 1
  • T>0T > 0: 随着 TT 增大,价格 PP 会不断增大。
  • T<0T < 0: 随着 TT 减小(变为负数),价格 PP 会不断减小,可以表示非常小的价格(例如 0.00...0.00...)。
  • tick 边界: 当价格在 tick 之间移动并越过一个 tick 边界时,合约会记录当前价格向下取整tick 索引。

2.3 tick 的有效范围

Uniswap V3 将 tick 的有效范围限制在:

  • 最小 tick: -887272
  • 最大 tick: +887272

这个范围的由来将在后面的章节详细解释。

3. Uniswap V3 合约中的价格存储

3.1 slot0 结构

Uniswap V3 合约(例如 UniswapV3Pool)内部定义了一个 STRUCTslot0 来存储当前池子的关键状态信息,其中包括:

  • uint160 sqrtPriceX96: 当前价格的平方根,经过 2962^{96} 缩放后的值。
  • int24 tick: 当前的 tick 索引。
struct Slot0 {
// the current price
uint160 sqrtPriceX96;
// the current tick
int24 tick;
// the most-recently updated index of the observations array
uint16 observationIndex;
// the current maximum number of observations that are being stored
uint16 observationCardinality;
// the next maximum number of observations to store, triggered in observations.write
uint16 observationCardinalityNext;
// the current protocol fee as a percentage of the swap fee taken on withdrawal
// represented as an integer denominator (1/x)%
uint8 feeProtocol;
// whether the pool is locked
bool unlocked;
}

3.2 为什么存储 sqrtPriceX96 而不是 PP

  • 原因: Uniswap V3 的流动性计算公式中大量使用了价格的平方根 (P\sqrt{P}),例如: ΔX=L(1Pa1Pb) \Delta X = L \left( \frac{1}{\sqrt{P_a}} - \frac{1}{\sqrt{P_b}} \right) ΔY=L(PbPa) \Delta Y = L (\sqrt{P_b} - \sqrt{P_a}) 如果合约中存储的是 PP,每次计算都需要进行开平方根运算,这在 EVM (以太坊虚拟机) 上是非常消耗 Gas 的操作。
  • 优化: 为了节省 Gas,合约直接存储 P\sqrt{P} 的值。
  • Solidity 浮点数限制: Solidity 语言不支持浮点数运算。为了在整数运算中保留浮点数的精度,Uniswap V3 采用了 Q64.96 定点数格式
    • 存储方式: sqrtPriceX96 存储的是 P\sqrt{P} 乘以 2962^{96} 后的整数值。
    • 公式: stored_sqrtPriceX96=P×296stored\_sqrtPriceX96 = \sqrt{P} \times 2^{96}
    • 逆运算(获取 P\sqrt{P}: P=stored_sqrtPriceX96/296\sqrt{P} = stored\_sqrtPriceX96 / 2^{96}
    • 最终价格 PP: P=(stored_sqrtPriceX96/296)2P = (stored\_sqrtPriceX96 / 2^{96})^2
  • 精度: 乘以 2962^{96} (一个非常大的数) 后,小数部分被“提升”为整数,从而在整数运算中保留了足够的精度,避免了直接存储小数可能导致的精度损失。

3.3 案例:从 sqrtPriceX96 计算价格 (ETH/DAI)

假设从区块链浏览器查询到 ETH/DAI 交易对的 sqrtPriceX96 值为 3364239860641323386927909307

  1. 计算 P\sqrt{P}: C=3364239860641323386927909307/296C = 3364239860641323386927909307 / 2^{96} C42.493106C \approx 42.493106
  2. 计算 PP: P=C242.49310621805.66P = C^2 \approx 42.493106^2 \approx 1805.66 (这与 ETH 的市场价格 180318061803 \sim 1806 接近)
    • 注意: ETH 和 DAI 的精度都是 18,因此无需额外精度修正。

4. 实际价格计算中的精度与方向问题

在从 Uniswap V3 合约中读取 ticksqrtPriceX96 并计算实际价格时,需要注意以下两个常见问题:

4.1 Token 精度差异

  • 问题: 如果交易对中的两个 Token (例如 ETH 和 USDC) 具有不同的 decimals (精度),直接通过 ticksqrtPriceX96 计算出的价格将不正确。
    • 案例: ETH (18 位精度) / USDC (6 位精度)
      • 从合约 slot0 读取 tick = -201344。
      • 直接计算 P=1.00012013441.803×109P = 1.0001^{-201344} \approx 1.803 \times 10^{-9}。这个价格显然是错误的,因为 ETH 价格远高于此。
  • 解决方案: 需要根据 Token 的精度差异进行修正。
    • 修正公式: 真实价格 = 计算价格 ×10(Token0_decimalsToken1_decimals)\times 10^{(\text{Token0\_decimals} - \text{Token1\_decimals})}
    • 案例修正: 假设 ETH 是 token0 (18 精度),USDC 是 token1 (6 精度)。 真实价格 = (1.803×109)×10(186)(1.803 \times 10^{-9}) \times 10^{(18 - 6)} 真实价格 = (1.803×109)×1012(1.803 \times 10^{-9}) \times 10^{12} 真实价格 = 1.803×103=18031.803 \times 10^3 = 1803 (这与 ETH 的市场价格相符)
  • 无精度差异: 如果两个 Token 精度相同 (例如 ETH/DAI 都是 18 位精度),则无需进行此修正。

4.2 token0/token1 顺序导致的价格倒数问题

  • 问题: Uniswap V3 合约内部定义了 token0token1,但这两个 Token 在合约中的顺序可能与我们预期计算价格时使用的 X 轴和 Y 轴的 Token 顺序不一致。例如,如果合约将 USDC 视为 token0 (X 轴),ETH 视为 token1 (Y 轴),那么通过 P=Y/XP = Y/X 计算出的价格可能是 USDC/ETH 的价格,而不是我们通常期望的 ETH/USDC 价格。
  • 表现: 通过 sqrtPriceX96 计算出的价格与实际市场价格互为倒数。
  • 解决方案: 如果发现通过 sqrtPriceX96 计算出的价格与预期价格互为倒数,则需要对计算结果再取一次倒数。
    • 修正步骤:
      1. sqrtPriceX96 计算出 PrawP_{raw}
      2. 如果 PrawP_{raw} 与预期价格互为倒数,则 Pcorrected=1/PrawP_{corrected} = 1 / P_{raw}
      3. 再应用精度修正 (如果需要)。
    • 案例: 假设 ETH/USDC 交易对,通过 sqrtPriceX96 计算出的 PrawP_{raw} 经过精度修正后是 5.538×1085.538 \times 10^8 (一个非常大的错误数字)。这表明合约内部的 token0token1 顺序与我们期望的 ETH/USDC 顺序相反。
      • 取倒数: 1/Praw=1/(5.538×108)1.805×1091 / P_{raw} = 1 / (5.538 \times 10^8) \approx 1.805 \times 10^{-9}
      • 再应用精度修正 (假设 ETH 是 18 精度,USDC 是 6 精度): (1.805×109)×10(186)=1.805×109×1012=1805(1.805 \times 10^{-9}) \times 10^{(18 - 6)} = 1.805 \times 10^{-9} \times 10^{12} = 1805。这个价格与 ETH 实际价格相符。

5. tick 范围的由来

Uniswap V3 tick 范围 (-887272 到 +887272) 并非随意设置,而是由 sqrtPriceX96 采用的 Q64.96 定点数格式所能表示的价格范围推导而来。

5.1 Q64.96 格式对 P\sqrt{P} 的限制

  • uint160 类型用于存储 sqrtPriceX96,意味着它可以存储一个 160 位的无符号整数。
  • Q64.96 格式表示这个 160 位整数中,有 64 位用于整数部分,96 位用于小数部分。
  • 为了保证价格的对称性 (即 PP1/P1/P 都能被表示),Uniswap V3 将 P\sqrt{P} 的有效范围限制在:
    • P\sqrt{P} 最小值: 2642^{-64}
    • P\sqrt{P} 最大值: 2642^{64}

5.2 价格 PP 的有效范围

基于 P\sqrt{P} 的范围,价格 PP 的有效范围为 (P)2(\sqrt{P})^2:

  • PP 最小值: (264)2=2128(2^{-64})^2 = 2^{-128}
  • PP 最大值: (264)2=2128(2^{64})^2 = 2^{128}

5.3 从价格范围推导 tick 范围

我们已知价格与 tick 的关系公式为 P=1.0001TP = 1.0001^T。为了求出 TT,我们可以对两边取以 1.00011.0001 为底的对数:

T=log1.0001(P) T = \log_{1.0001}(P)

现在,我们将 PP 的最大值和最小值代入此公式:

  1. 最大 tick (TmaxT_{max}):

    Tmax=log1.0001(2128) T_{max} = \log_{1.0001}(2^{128})

    使用换底公式 logb(a)=ln(a)ln(b)\log_b(a) = \frac{\ln(a)}{\ln(b)}

    Tmax=128×ln(2)ln(1.0001) T_{max} = \frac{128 \times \ln(2)}{\ln(1.0001)}

    计算结果约为 887272.975...887272.975...。向下取整,得到 +887272

  2. 最小 tick (TminT_{min}):

    Tmin=log1.0001(2128) T_{min} = \log_{1.0001}(2^{-128})

    Tmin=128×ln(2)ln(1.0001) T_{min} = \frac{-128 \times \ln(2)}{\ln(1.0001)}

    计算结果约为 887272.975...-887272.975...。向上取整,得到 -887272

5.4 合约硬编码

这些计算出的 tick 范围值被硬编码在 Uniswap V3 Core 合约中,例如 MIN_TICKMAX_TICK 常量。这确保了所有 Uniswap V3 池子的 tick 都在一个预定义的、数学上合理的范围内运行。

REF