uniswap v4 TickBitmap库函数解析
我们知道uniswap v3/v4版本的智能合约需要管理887272*2个tick,为了高效的管理它们,采用了位图的数据结构:
mapping(int16 => uint256) public override tickBitmap;function position(int24 tick) private pure returns (int16 wordPos, uint8 bitPos) {wordPos = int16(tick >> 8);bitPos = uint8(tick % 256);
}
887272*2个tick合约中用int24来表示;int16(tick >> 8)代表取高16位,uint8(tick % 256)代表取低8位,在tickBitmap中高16位作为key,那么为什么用uint256作为value呢?剩下的低8位是2的8次方一共256个数。也就是说tickBitmap每一条记录需要管理256个tick状态,最高效的方法就是使用位图,把256个数转换成256个二进制数表示,也就是uint256,相应的位上为1代表当前的tick已经被初始化。
以上是tick的存储结构,本章介绍的就是对上述结构进行存取的类库。位置如下:
compressed
这个方法的作用是:把 tick 映射到 tick bitmap 的压缩索引,即计算 compressed = tick / tickSpacing
,即把 tick 映射到以 tickSpacing 为单位的整数索引。对于负数 tick,如果 tick 不是 tickSpacing 的整数倍,需要向下取整
function compress(int24 tick, int24 tickSpacing) internal pure returns (int24 compressed) {// compressed = tick / tickSpacing;// if (tick < 0 && tick % tickSpacing != 0) compressed--;assembly ("memory-safe") {tick := signextend(2, tick)tickSpacing := signextend(2, tickSpacing)compressed :=sub(sdiv(tick, tickSpacing),// if (tick < 0 && tick % tickSpacing != 0) then tick % tickSpacing < 0, vice versaslt(smod(tick, tickSpacing), 0))}}
伪代码:
compressed = tick / tickSpacing;
if (tick < 0 && tick % tickSpacing != 0) compressed--;
例如:tick = -7, tickSpacing = 5 ;-7 / 5 = -1.4,向下取整为 -2
该方法基于汇编实现:具体解析如下:
signextend
(2
,tick
)
:用于把小位宽的有符号整数扩展为 256 位,保证正负号不变,其中2
表示 tick 占用 3 字节(24 位,0-based),即 int24sdiv(tick, tickSpacing)
:有符号除法,得到商(但对负数不是地板除法)。smod(tick, tickSpacing)
:有符号取模,得到余数。slt(smod(...), 0)
:如果余数为负,返回 1,否则返回 0。sub(...)
:如果 tick < 0 且余数不为 0,则商再减 1,实现地板除法。
flipTick
function flipTick(mapping(int16 => uint256) storage self, int24 tick, int24 tickSpacing) internal {// Equivalent to the following Solidity:// if (tick % tickSpacing != 0) revert TickMisaligned(tick, tickSpacing);// (int16 wordPos, uint8 bitPos) = position(tick / tickSpacing);// uint256 mask = 1 << bitPos;// self[wordPos] ^= mask;assembly ("memory-safe") {tick := signextend(2, tick)tickSpacing := signextend(2, tickSpacing)// ensure that the tick is spacedif smod(tick, tickSpacing) {let fmp := mload(0x40)mstore(fmp, 0xd4d8f3e6) // selector for TickMisaligned(int24,int24)mstore(add(fmp, 0x20), tick)mstore(add(fmp, 0x40), tickSpacing)revert(add(fmp, 0x1c), 0x44)}tick := sdiv(tick, tickSpacing)// calculate the storage slot corresponding to the tick// wordPos = tick >> 8mstore(0, sar(8, tick))mstore(0x20, self.slot)// the slot of self[wordPos] is keccak256(abi.encode(wordPos, self.slot))let slot := keccak256(0, 0x40)// mask = 1 << bitPos = 1 << (tick % 256)// self[wordPos] ^= masksstore(slot, xor(sload(slot), shl(and(tick, 0xff), 1)))}}
先看下官方提供的等效代码:
function flipTick(mapping(int16 => uint256) storage self, int24 tick, int24 tickSpacing) internal {// 确保 tick 是 tickSpacing 的整数倍if (tick % tickSpacing != 0) {revert TickMisaligned(tick, tickSpacing);}// 计算压缩后的 tick 值tick /= tickSpacing;// 计算 wordPos 和 bitPosint16 wordPos = int16(tick >> 8); // tick / 256uint8 bitPos = uint8(tick % 256);// 计算掩码并翻转对应位uint256 mask = 1 << bitPos;self[wordPos] ^= mask;
}
逻辑很清晰:
- 确保 tick 是 tickSpacing 的整数倍
- 计算压缩后的 tick 值
- 计算 wordPos 和 bitPos
- 计算掩码并翻转对应位
下面是汇编代码的解析:
tick := signextend(2, tick)
tickSpacing := signextend(2, tickSpacing)
- 使用
signextend
将tick
和tickSpacing
从 24 位扩展为 256 位。这是因为 EVM 的操作数默认是 256 位,signextend可以确保负数的符号位正确保留。
if smod(tick, tickSpacing) {let fmp := mload(0x40)mstore(fmp, 0xd4d8f3e6) // selector for TickMisaligned(int24,int24)mstore(add(fmp, 0x20), tick)mstore(add(fmp, 0x40), tickSpacing)revert(add(fmp, 0x1c), 0x44)
}
-
smod(tick, tickSpacing)
:计算tick % tickSpacing
。如果结果不为 0,说明tick
不是tickSpacing
的整数倍。此时需要抛出异常!
tick := sdiv(tick, tickSpacing)
- 将
tick
除以tickSpacing
,得到压缩后的tick
值。tickBitmap
只存储压缩后的tick
状态,而不是原始的tick
。
mstore(0, sar(8, tick))
mstore(0x20, self.slot)
let slot := keccak256(0, 0x40)
-
sar(8, tick)
:计算wordPos
,即tickBitMap的key。等价于tick >> 8
,将tick
右移 8 位取高16位。 -
self.slot
:self
是tickBitmap
的存储槽位置。 -
keccak256(0, 0x40)
:计算存储槽位置slot
,表示tickBitmap[wordPos]
的存储位置。
在 Solidity 中,某一个key在
mapping
的存储位置是通过这个公式计算的:slot= keccak256(abi.encode(key, mappingSlot))
abi.encode(key, mappingSlot)的意思就是把key和mappingSlot连接到一起,前面两个部分攻占用了64个字节:[key (32 字节)][mappingSlot (32 字节)]
故而:keccak256(0, 0x40)=keccak256(abi.encode(key, mappingSlot))
也就说keccak256(0, 0x40)计算的是tick所在位图的存储位置
sstore(slot, xor(sload(slot), shl(and(tick, 0xff), 1)))
-
and(tick, 0xff)
:计算bitPos
,即tick
在 256 位位图中的具体位置。等价于tick % 256
。 -
shl(bitPos, 1)
:计算掩码mask = 1 << bitPos
,用于定位tick
对应的位。 -
sload(slot)
:加载存储槽slot
的当前值。 -
xor(sload(slot), mask)
:使用按位异或(^
)翻转tick
对应的位:如果该位原来是0
,则变为1
。如果该位原来是1
,则变为0
。 -
sstore(slot, ...)
:将更新后的值存储回slot
。
flipTick这个方法可以总结出两点:
- 用户初始化的tick必须是tickspace的整数倍
- tick的数量有887272*2个,但真正需要初始化的只有887272*2/tickSpace个
nextInitializedTickWithinOneWord
这个方法是 Uniswap v4 中用于查找下一个已初始化 tick 的核心逻辑。初始化过的tick 用于标记流动性变化的点,swap 时需要知道下一个会遇到的流动性边界。
function nextInitializedTickWithinOneWord(mapping(int16 => uint256) storage self,int24 tick,int24 tickSpacing,bool lte) internal view returns (int24 next, bool initialized) {unchecked {int24 compressed = compress(tick, tickSpacing);if (lte) {(int16 wordPos, uint8 bitPos) = position(compressed);// all the 1s at or to the right of the current bitPosuint256 mask = type(uint256).max >> (uint256(type(uint8).max) - bitPos);uint256 masked = self[wordPos] & mask;// if there are no initialized ticks to the right of or at the current tick, return rightmost in the wordinitialized = masked != 0;// overflow/underflow is possible, but prevented externally by limiting both tickSpacing and ticknext = initialized? (compressed - int24(uint24(bitPos - BitMath.mostSignificantBit(masked)))) * tickSpacing: (compressed - int24(uint24(bitPos))) * tickSpacing;} else {// start from the word of the next tick, since the current tick state doesn't matter(int16 wordPos, uint8 bitPos) = position(++compressed);// all the 1s at or to the left of the bitPosuint256 mask = ~((1 << bitPos) - 1);uint256 masked = self[wordPos] & mask;// if there are no initialized ticks to the left of the current tick, return leftmost in the wordinitialized = masked != 0;// overflow/underflow is possible, but prevented externally by limiting both tickSpacing and ticknext = initialized? (compressed + int24(uint24(BitMath.leastSignificantBit(masked) - bitPos))) * tickSpacing: (compressed + int24(uint24(type(uint8).max - bitPos))) * tickSpacing;}}}
lte参数代表交易的方向,lte代表tick变小,我们需要寻找小于当前tick的第一个tick,这里我们以lte为例:
第一步是计算tick压当前缩后的值:
int24 compressed = compress(tick, tickSpacing);
由前面的flipTick方法可知,tick存储的时候进行了压缩处理,即(tick/tickSpace),所以在读取的时候也要先计算当前tick压缩后的值。
接下来对压缩后的值进行分解,前面我们说过所有的tick做了二级管理,第一级是的wordPos,使用tick的高16位作为key,后8位是一个位图转换成的数字也就是bitPos,
uint256 mask = type(uint256).max >> (uint256(type(uint8).max) - bitPos);
uint256 masked = self[wordPos] & mask;
-
type(uint256).max
:代表 256 位全为 1 的二进制数(即 0xFFFF...FFFF,共 256 个 1)。 -
type(uint8).max
:代表 8 位全为 1 的二进制数(即 255,0xFF)。 -
uint256(type(uint8).max) - bitPos
:计算出从 bitPos 到 255 之间有多少位。 -
>>
(右移):把高位的 1 右移,留下 bitPos 及其右侧的 1。
图解说明
假设 bitmap 的一个 word 是 256 位(bit),每一位代表一个 tick 是否被初始化:
word: [b255][b254]...[b6][b5][b4][b3][b2][b1][b0]
index: 255 254 ... 6 5 4 3 2 1 0
假设 bitPos = 5
,那么:mask = type(uint256).max >> (255 - 5):
实际效果是:只保留 bit 5 及其右侧(低位)的所有位为 1,其余为 0。
mask: 000...0001 1 1 1 1 1 (高位全0,低6位全1)
index: 255........65 4 3 2 1 0
masked = self[wordPos] & mask:
只保留当前 word 中 bitPos 及其右侧的初始化状态。
这样就可以可以快速查找从 bitPos 开始,向右(tick 变小)最近的已初始化 tick。
假设原始的word如下:
self[wordPos]: ... 0 1 0 0 1 1 0 1 0 0
index: ... 9 8 7 6 5 4 3 2 1 0
经过masked = self[wordPos] & mask:后
masked: ... 0 0 0 0 1 0 1 0 0 1 (只看 5~0 的初始化状态)
index: .... 9 8 7 6 5 4 3 2 1 0
最后同过msb算法计算出masked中最左边的1所在的位置:
initialized = masked != 0;
next = initialized? (compressed - int24(uint24(bitPos - BitMath.mostSignificantBit(masked)))) * tickSpacing: (compressed - int24(uint24(bitPos))) * tickSpacing;
首先判断在当前 word 内,从 bitPos(含)及其右侧是否有已初始化的 tick。masked
只保留了 bitPos 及其右侧的位,如果有任何一位是 1,说明有已初始化 tick。
如果找到了已初始化 tick(initialized == true
)
BitMath.mostSignificantBit(masked)
找到 masked 中最高位的 1(即最靠左的 1),这就是距离 bitPos 最近的已初始化 tick。bitPos - ...
得到从当前 bitPos 到最近已初始化 tick 的距离(向右,tick 变小)。compressed - ...
得到最近已初始化 tick 的 compressed 编号。- 乘以 tickSpacing 得到实际 tick 值。
如果没有找到已初始化 tick(initialized == false
):
-
说明当前 word 内 bitPos 及其右侧都没有已初始化 tick。返回当前 word 最右侧(最低位)的 tick(即 compressed - bitPos),作为“极限”tick。
非lte的算法和lte类似,唯一需要解释的是这行代码:
uint256 mask = ~((1 << bitPos) - 1);
假设 bitPos = 5
:
1 << 5 = 0b0000000000000000000000000000000000000000000000000000000000100000
(1 << 5) - 1 = 0b0000000000000000000000000000000000000000000000000000000000011111
~((1 << 5) - 1) = 0b1111111111111111111111111111111111111111111111111111111111100000
在 Solidity 里,
1
默认是uint256
类型(256 位)1 << bitPos
表示把最低位的 1 左移bitPos
位,结果依然是 256 位的 uint256。