跳到主要内容

第三篇:Uniswap V3 价格机制与Tick系统源码分析

深入解析Tick系统的设计与实现,以及TickBitmap的极致优化


📋 目录

  1. Tick系统概述
  2. Tick数据结构详解
  3. TickBitmap极致优化
  4. Tick跨越机制
  5. 手续费在Tick中的追踪
  6. 边界条件与安全检查
  7. 实战案例分析
  8. 总结与思考

1. Tick系统概述

1.1 为什么需要Tick系统

V2的问题

价格是连续的:P ∈ (0, ∞)
无法有效管理集中流动性

V3的解决方案

将连续价格空间离散化
P = 1.0001^tick
tick ∈ [-887272, 887272]

1.2 Tick的核心作用

┌─────────────────────────────────────────────────┐
│ Tick系统的三大作用 │
├─────────────────────────────────────────────────┤
│ 1. 价格离散化 │
│ - 将无限价格空间映射到有限个Tick │
│ - 每个Tick代表一个精确的价格点 │
│ │
│ 2. 流动性管理 │
│ - 记录每个Tick的流动性变化 │
│ - 支持集中流动性的添加/移除 │
│ │
│ 3. 效率优化 │
│ - TickBitmap快速查找下一个激活的Tick │
│ - 避免遍历所有Tick │
└─────────────────────────────────────────────────┘

1.3 Tick间距的设计

// 不同手续费对应不同Tick间距
mapping(uint24 => int24) public override feeAmountTickSpacing;

初始值:
feeAmountTickSpacing[500] = 10; // 0.05% fee
feeAmountTickSpacing[3000] = 60; // 0.3% fee
feeAmountTickSpacing[10000] = 200; // 1% fee

为什么需要间距?

  1. 减少存储
不使用间距:
可用Tick数 = 887272 - (-887272) + 1 = 1,774,545个
存储需求 = 1,774,545 * 256 bytes = 454 MB

使用间距60:
可用Tick数 = 1,774,545 / 60 = 29,576个
存储需求 = 29,576 * 256 bytes = 7.6 MB
节省:98.3%
  1. 匹配波动性
低波动(稳定币):
- 需要精细价格控制
- tickSpacing = 10

高波动(山寨币):
- 不需要太精细
- tickSpacing = 200
  1. 防止溢出
// 每个Tick的最大流动性
uint128 maxLiquidityPerTick = type(uint128).max / numTicks;

如果tickSpacing太小 -> numTicks太大 -> maxLiquidityPerTick太小

2. Tick数据结构详解

2.1 Tick.Info结构

struct Info {
// 1. 流动性数据(32 bytes)
uint128 liquidityGross; // 总流动性(所有仓位的和)
int128 liquidityNet; // 净流动性变化

// 2. 手续费追踪(64 bytes)
uint256 feeGrowthOutside0X128; // token0外部手续费增长
uint256 feeGrowthOutside1X128; // token1外部手续费增长

// 3. 预言机数据(32 bytes)
int56 tickCumulativeOutside; // 累计Tick
uint160 secondsPerLiquidityOutsideX128; // 每流动性秒数
uint32 secondsOutside; // 累计秒数

// 4. 状态标记(1 byte)
bool initialized; // 是否已初始化
}
// 总计:129 bytes(占用5个存储槽)

2.2 liquidityGross vs liquidityNet

liquidityGross(总流动性)

// 所有引用此Tick的仓位的流动性之和
liquidityGross = sum(所有仓位的liquidity)

用途:
1. 判断Tick是否初始化(liquidityGross > 0)
2. 检查是否超过maxLiquidityPerTick

liquidityNet(净流动性)

// 跨越Tick时全局流动性的变化量
liquidityNet = 向上跨越时的变化

计算规则:
- 作为下边界(tickLower): liquidityNet += liquidityDelta
- 作为上边界(tickUpper): liquidityNet -= liquidityDelta

示例

场景:3个仓位

仓位A:[tick=100, tick=200], liquidity=1000
仓位B:[tick=100, tick=300], liquidity=500
仓位C:[tick=150, tick=200], liquidity=300

Tick 100(两个仓位的下边界):
liquidityGross = 1000 + 500 = 1500
liquidityNet = +1000 + 500 = +1500 (向上跨越时增加)

Tick 150(一个仓位的下边界):
liquidityGross = 300
liquidityNet = +300

Tick 200(两个仓位的上边界):
liquidityGross = 1000 + 300 = 1300
liquidityNet = -1000 - 300 = -1300 (向上跨越时减少)

Tick 300(一个仓位的上边界):
liquidityGross = 500
liquidityNet = -500

跨越Tick时的流动性更新

// 向上跨越(zeroForOne = false)
if (price crosses tick upward) {
globalLiquidity += tick.liquidityNet;
}

// 向下跨越(zeroForOne = true)
if (price crosses tick downward) {
globalLiquidity -= tick.liquidityNet;
}

2.3 feeGrowthOutside的精妙设计

核心概念:相对位置追踪

feeGrowthOutside = "另一侧"的手续费增长

"另一侧"的定义取决于当前价格:
- 如果 currentTick >= tick: Outside = 下方
- 如果 currentTick < tick: Outside = 上方

初始化规则

if (liquidityGrossBefore == 0) {  // 首次初始化
if (tick <= tickCurrent) {
// Tick在当前价格下方
// Outside = 下方 = 从0到现在的所有手续费
info.feeGrowthOutside0X128 = feeGrowthGlobal0X128;
info.feeGrowthOutside1X128 = feeGrowthGlobal1X128;
} else {
// Tick在当前价格上方
// Outside = 上方 = 0(未来的手续费)
info.feeGrowthOutside0X128 = 0;
info.feeGrowthOutside1X128 = 0;
}
}

跨越时的翻转

function cross(
mapping(int24 => Tick.Info) storage self,
int24 tick,
uint256 feeGrowthGlobal0X128,
uint256 feeGrowthGlobal1X128,
...
) internal returns (int128 liquidityNet) {
Info storage info = self[tick];

// 翻转Outside值(因为"另一侧"变了)
info.feeGrowthOutside0X128 = feeGrowthGlobal0X128 - info.feeGrowthOutside0X128;
info.feeGrowthOutside1X128 = feeGrowthGlobal1X128 - info.feeGrowthOutside1X128;

// 翻转预言机数据
info.tickCumulativeOutside = tickCumulative - info.tickCumulativeOutside;
info.secondsPerLiquidityOutsideX128 = secondsPerLiquidityCumulativeX128 - info.secondsPerLiquidityOutsideX128;
info.secondsOutside = time - info.secondsOutside;

return info.liquidityNet;
}

2.4 计算仓位内的手续费增长

function getFeeGrowthInside(
mapping(int24 => Tick.Info) storage self,
int24 tickLower,
int24 tickUpper,
int24 tickCurrent,
uint256 feeGrowthGlobal0X128,
uint256 feeGrowthGlobal1X128
) internal view returns (uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) {
Info storage lower = self[tickLower];
Info storage upper = self[tickUpper];

// 步骤1:计算下方的手续费增长
uint256 feeGrowthBelow0X128;
if (tickCurrent >= tickLower) {
// 当前价格在tickLower之上
// Below = Outside(因为Outside指向下方)
feeGrowthBelow0X128 = lower.feeGrowthOutside0X128;
} else {
// 当前价格在tickLower之下
// Below = Total - Outside(因为Outside指向上方)
feeGrowthBelow0X128 = feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128;
}

// 步骤2:计算上方的手续费增长
uint256 feeGrowthAbove0X128;
if (tickCurrent < tickUpper) {
// 当前价格在tickUpper之下
// Above = Outside(因为Outside指向上方)
feeGrowthAbove0X128 = upper.feeGrowthOutside0X128;
} else {
// 当前价格在tickUpper之上
// Above = Total - Outside(因为Outside指向下方)
feeGrowthAbove0X128 = feeGrowthGlobal0X128 - upper.feeGrowthOutside0X128;
}

// 步骤3:Inside = Total - Below - Above
feeGrowthInside0X128 = feeGrowthGlobal0X128 - feeGrowthBelow0X128 - feeGrowthAbove0X128;
feeGrowthInside1X128 = feeGrowthGlobal1X128 - feeGrowthBelow1X128 - feeGrowthAbove1X128;
}

图解

                         tickUpper

─────────────────────────┼───────────── feeGrowthAbove

tickCurrent


│ feeGrowthInside
tickLower

─────────────────────────┼───────────── feeGrowthBelow


feeGrowthInside = feeGrowthGlobal - feeGrowthBelow - feeGrowthAbove

3. TickBitmap极致优化

3.1 问题的提出

场景:在swap中需要找到下一个有流动性的Tick

朴素方案

// ❌ 极其低效
int24 nextTick = currentTick + tickSpacing;
while (ticks[nextTick].liquidityGross == 0) {
nextTick += tickSpacing;
}

时间复杂度:O(n),其中n可能达到29,576
Gas成本:每次SLOAD约2100 gas,总计可能数万gas

V3的方案:TickBitmap

// ✓ 极其高效
(int24 nextTick, bool initialized) = tickBitmap.nextInitializedTickWithinOneWord(...);

时间复杂度:O(1)或O(log n)
Gas成本:2-3次SLOAD,约6000 gas

3.2 TickBitmap数据结构

// 位图映射
mapping(int16 => uint256) public override tickBitmap;

结构:
- Key: int16(word位置)
- Value: uint256(256个bit)

每个bit代表一个Tick是否初始化:
- bit = 1: Tick已初始化(有流动性)
- bit = 0: Tick未初始化(无流动性)

索引计算

function position(int24 tick) private pure returns (int16 wordPos, uint8 bitPos) {
// wordPos:哪个word(256个tick为一组)
wordPos = int16(tick >> 8); // tick / 256

// bitPos:word内的哪一位
bitPos = uint8(tick % 256);
}

示例

tick = 1000:
wordPos = 1000 / 256 = 3
bitPos = 1000 % 256 = 232

tick = -500:
wordPos = -500 / 256 = -2
bitPos = -500 % 256 = 12

3.3 flipTick操作

function flipTick(
mapping(int16 => uint256) storage self,
int24 tick,
int24 tickSpacing
) internal {
// 确保tick是tickSpacing的倍数
require(tick % tickSpacing == 0);

// 计算位置
(int16 wordPos, uint8 bitPos) = position(tick / tickSpacing);

// 创建mask
uint256 mask = 1 << bitPos;

// XOR翻转对应bit
self[wordPos] ^= mask;
}

工作原理

假设 bitPos = 5

mask = 1 << 5 = 0b...00100000

原值 = 0b...10101010
mask = 0b...00100000
XOR ─────────────────
结果 = 0b...10001010
↑ 第5位被翻转

应用场景

// 添加流动性时
if (流动性从0变为非0) {
tickBitmap.flipTick(tickLower); // 设置为1
tickBitmap.flipTick(tickUpper);
}

// 移除流动性时
if (流动性从非0变为0) {
tickBitmap.flipTick(tickLower); // 设置为0
tickBitmap.flipTick(tickUpper);
}

3.4 nextInitializedTickWithinOneWord算法

目标:找到下一个初始化的Tick

function nextInitializedTickWithinOneWord(
mapping(int16 => uint256) storage self,
int24 tick,
int24 tickSpacing,
bool lte // less than or equal(向左查找)or greater than(向右查找)
) internal view returns (int24 next, bool initialized) {
// 压缩tick(考虑tickSpacing)
int24 compressed = tick / tickSpacing;
if (tick < 0 && tick % tickSpacing != 0) compressed--;

if (lte) {
// 向左查找(寻找 <= currentTick 的初始化Tick)
(int16 wordPos, uint8 bitPos) = position(compressed);

// 创建mask:保留bitPos及其右边的所有bit
// 例如 bitPos=5: mask = 0b...00111111
uint256 mask = (1 << bitPos) - 1 + (1 << bitPos);

// 只保留感兴趣的bit
uint256 masked = self[wordPos] & mask;

initialized = masked != 0;

next = initialized
? (compressed - int24(bitPos - BitMath.mostSignificantBit(masked))) * tickSpacing
: (compressed - int24(bitPos)) * tickSpacing;

} else {
// 向右查找(寻找 > currentTick 的初始化Tick)
(int16 wordPos, uint8 bitPos) = position(compressed + 1);

// 创建mask:保留bitPos及其左边的所有bit
// 例如 bitPos=5: mask = 0b11111111...11100000
uint256 mask = ~((1 << bitPos) - 1);

// 只保留感兴趣的bit
uint256 masked = self[wordPos] & mask;

initialized = masked != 0;

next = initialized
? (compressed + 1 + int24(BitMath.leastSignificantBit(masked) - bitPos)) * tickSpacing
: (compressed + 1 + int24(type(uint8).max - bitPos)) * tickSpacing;
}
}

3.5 BitMath库

mostSignificantBit(MSB)

// 找到最高位的1
function mostSignificantBit(uint256 x) internal pure returns (uint8 r) {
require(x > 0);

// 二分查找
if (x >= 0x100000000000000000000000000000000) { x >>= 128; r += 128; }
if (x >= 0x10000000000000000) { x >>= 64; r += 64; }
if (x >= 0x100000000) { x >>= 32; r += 32; }
if (x >= 0x10000) { x >>= 16; r += 16; }
if (x >= 0x100) { x >>= 8; r += 8; }
if (x >= 0x10) { x >>= 4; r += 4; }
if (x >= 0x4) { x >>= 2; r += 2; }
if (x >= 0x2) r += 1;
}

示例:
mostSignificantBit(0b...010100) = 4
↑ 最高位的1在第4位

leastSignificantBit(LSB)

// 找到最低位的1
function leastSignificantBit(uint256 x) internal pure returns (uint8 r) {
require(x > 0);

r = 255;
if (x & type(uint128).max > 0) { r -= 128; } else { x >>= 128; }
if (x & type(uint64).max > 0) { r -= 64; } else { x >>= 64; }
if (x & type(uint32).max > 0) { r -= 32; } else { x >>= 32; }
if (x & type(uint16).max > 0) { r -= 16; } else { x >>= 16; }
if (x & type(uint8).max > 0) { r -= 8; } else { x >>= 8; }
if (x & 0xf > 0) { r -= 4; } else { x >>= 4; }
if (x & 0x3 > 0) { r -= 2; } else { x >>= 2; }
if (x & 0x1 > 0) r -= 1;
}

示例:
leastSignificantBit(0b...010100) = 2
↑ 最低位的1在第2位

3.6 性能分析

场景对比

场景:在10,000个Tick中找到下一个初始化的Tick

方案1:遍历查找
for (int24 i = currentTick; i <= MAX_TICK; i += tickSpacing) {
if (ticks[i].liquidityGross > 0) return i;
}
Gas成本:
- 最坏情况:10,000次SLOAD = 21,000,000 gas
- 平均情况:5,000次SLOAD = 10,500,000 gas

方案2:TickBitmap
tickBitmap.nextInitializedTickWithinOneWord(...)
Gas成本:
- 最好情况:1次SLOAD = 2,100 gas
- 最坏情况:2次SLOAD = 4,200 gas

性能提升:约5000倍!

4. Tick跨越机制

4.1 跨越流程

// 在swap函数中
while (state.amountSpecifiedRemaining != 0 && state.sqrtPriceX96 != sqrtPriceLimitX96) {
StepComputations memory step;

// 步骤1:找到下一个Tick
(step.tickNext, step.initialized) = tickBitmap.nextInitializedTickWithinOneWord(
state.tick,
tickSpacing,
zeroForOne
);

// 步骤2:计算在当前Tick内的交换
(state.sqrtPriceX96, step.amountIn, step.amountOut, step.feeAmount) = SwapMath.computeSwapStep(
state.sqrtPriceX96,
(zeroForOne ? step.tickNext < TickMath.MIN_TICK : step.tickNext > TickMath.MAX_TICK)
? sqrtPriceLimitX96
: TickMath.getSqrtRatioAtTick(step.tickNext),
state.liquidity,
state.amountSpecifiedRemaining,
fee
);

// 步骤3:更新累计值
state.amountSpecifiedRemaining -= (step.amountIn + step.feeAmount).toInt256();
state.amountCalculated = state.amountCalculated.sub(step.amountOut.toInt256());

// 步骤4:如果到达边界,跨越Tick
if (state.sqrtPriceX96 == TickMath.getSqrtRatioAtTick(step.tickNext)) {
if (step.initialized) {
// 跨越Tick,更新流动性
int128 liquidityNet = ticks.cross(
step.tickNext,
feeGrowthGlobal0X128,
feeGrowthGlobal1X128,
secondsPerLiquidityCumulativeX128,
tickCumulative,
time
);

// 更新全局流动性
if (zeroForOne) liquidityNet = -liquidityNet;
state.liquidity = LiquidityMath.addDelta(state.liquidity, liquidityNet);
}

// 移动到下一个Tick
state.tick = zeroForOne ? step.tickNext - 1 : step.tickNext;
} else {
// 没有到达边界,更新Tick(不跨越)
state.tick = TickMath.getTickAtSqrtRatio(state.sqrtPriceX96);
}
}

4.2 跨越时的流动性更新

价格向上移动(买入token1):
before: ────┼────[Position]────┼────
lower upper

after: ────┼────[Position]────┼────
lower ←current upper

跨越tickLower:
globalLiquidity += tickLower.liquidityNet(正值)

跨越tickUpper:
globalLiquidity += tickUpper.liquidityNet(负值)

价格向下移动(卖出token1):
before: ────┼────[Position]────┼────
lower upper

after: ────┼────[Position]────┼────
lower current→ upper

跨越tickUpper:
globalLiquidity -= tickUpper.liquidityNet(相当于加负的负值=正值)

跨越tickLower:
globalLiquidity -= tickLower.liquidityNet(相当于减正值)

5. 手续费在Tick中的追踪

5.1 全局手续费增长率

// 全局手续费增长率(每单位流动性)
uint256 public override feeGrowthGlobal0X128;
uint256 public override feeGrowthGlobal1X128;

// 每次swap后更新
feeGrowthGlobal0X128 += feeAmount0 * FixedPoint128.Q128 / liquidity;
feeGrowthGlobal1X128 += feeAmount1 * FixedPoint128.Q128 / liquidity;

5.2 feeGrowthOutside的维护

初始化时

if (tick <= tickCurrent) {
// Tick在当前价格下方,Outside=下方=历史所有
feeGrowthOutside0X128 = feeGrowthGlobal0X128;
} else {
// Tick在当前价格上方,Outside=上方=0
feeGrowthOutside0X128 = 0;
}

跨越时

// 翻转Outside值
info.feeGrowthOutside0X128 = feeGrowthGlobal0X128 - info.feeGrowthOutside0X128;

5.3 计算仓位应得手续费

// 步骤1:获取仓位内的手续费增长
(uint256 feeGrowthInside0X128, uint256 feeGrowthInside1X128) =
ticks.getFeeGrowthInside(tickLower, tickUpper, tick, ...);

// 步骤2:计算增量
uint256 feeGrowthInside0DeltaX128 = feeGrowthInside0X128 - position.feeGrowthInside0LastX128;
uint256 feeGrowthInside1DeltaX128 = feeGrowthInside1X128 - position.feeGrowthInside1LastX128;

// 步骤3:计算应得手续费
uint128 tokensOwed0 = FullMath.mulDiv(feeGrowthInside0DeltaX128, position.liquidity, FixedPoint128.Q128);
uint128 tokensOwed1 = FullMath.mulDiv(feeGrowthInside1DeltaX128, position.liquidity, FixedPoint128.Q128);

6. 边界条件与安全检查

6.1 Tick范围限制

int24 internal constant MIN_TICK = -887272;
int24 internal constant MAX_TICK = 887272;

require(tickLower >= MIN_TICK && tickLower < MAX_TICK);
require(tickUpper > MIN_TICK && tickUpper <= MAX_TICK);
require(tickLower < tickUpper);

6.2 最大流动性限制

function tickSpacingToMaxLiquidityPerTick(int24 tickSpacing) internal pure returns (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;
}

// 检查
require(liquidityGrossAfter <= maxLiquidity, 'LO');

原因

如果单个Tick的流动性过大:
1. liquidityNet可能溢出int128
2. 跨越Tick时全局流动性计算可能溢出
3. 影响价格计算精度

7. 实战案例分析

7.1 案例:添加流动性到[1000, 2000]

// 初始状态
currentTick = 1500
tickSpacing = 60

// 添加流动性
liquidity = 1000000

// 步骤1:更新tick 1000
ticks[1000].liquidityGross += 1000000
ticks[1000].liquidityNet += 1000000
if (之前liquidityGross == 0) {
ticks[1000].initialized = true
tickBitmap.flipTick(1000) // 设置bit为1
ticks[1000].feeGrowthOutside0X128 = feeGrowthGlobal0X128 // 因为1000 < 1500
}

// 步骤2:更新tick 2000
ticks[2000].liquidityGross += 1000000
ticks[2000].liquidityNet -= 1000000
if (之前liquidityGross == 0) {
ticks[2000].initialized = true
tickBitmap.flipTick(2000) // 设置bit为1
ticks[2000].feeGrowthOutside0X128 = 0 // 因为2000 > 1500
}

// 步骤3:更新全局流动性(因为当前价格在范围内)
globalLiquidity += 1000000

7.2 案例:Swap跨越多个Tick

// 初始状态
currentTick = 1000
currentPrice = 1.0001^1000
liquidity = 1000000
amountIn = 10000 token0

// Tick状态
tick 1000: initialized, liquidityNet = +500000
tick 1200: initialized, liquidityNet = +300000
tick 1500: initialized, liquidityNet = -400000

// Swap过程(token0 -> token1,价格上升)

// 第1步:在[1000, 1200)内交换
amountUsed1 = calculateSwapInTick(1000, 1200, liquidity=1000000)
amountRemaining = 10000 - amountUsed1

// 第2步:跨越tick 1200
liquidity += tick[1200].liquidityNet // +300000
currentLiquidity = 1300000
cross tick 1200(翻转feeGrowthOutside等)

// 第3步:在[1200, 1500)内交换
amountUsed2 = calculateSwapInTick(1200, 1500, liquidity=1300000)
amountRemaining -= amountUsed2

// 第4步:跨越tick 1500
liquidity += tick[1500].liquidityNet // -400000
currentLiquidity = 900000
cross tick 1500

// 继续...直到amountRemaining = 0

8. 总结与思考

8.1 核心要点

  1. Tick系统:将连续价格空间离散化,实现集中流动性
  2. liquidityNet:精妙地追踪跨越Tick时的流动性变化
  3. feeGrowthOutside:相对追踪手续费,避免每次更新所有仓位
  4. TickBitmap:位运算极致优化,实现O(1)查找
  5. 跨越机制:高效处理价格穿越多个Tick的情况

8.2 思考题

  1. 为什么feeGrowthOutside要在跨越时翻转,而不是重新计算?
  2. 如果tickSpacing = 1会有什么问题?
  3. TickBitmap的"within one word"限制会影响什么?
  4. liquidityGross和liquidityNet的区别本质是什么?

8.3 延伸阅读


本文是"Uniswap V3源码赏析系列"的第三篇

📢 Share this article