朋友们大家好,今天我们继续 UniswapV3 代码库的解读。
TickBitmap 是用位图(Bitmap)高效查找下一个有流动性的 tick。
什么是位图?
在二进制的世界里,所有的数字都可以用 0 和 1 表示,位图的意思我们不再把 0 1 的组合看作一个整体的数字,而认为每一“位”的0和 1 的排列是张“图”。
在这里位图是为了管理和查找 tick 的。想象一个价格坐标轴,tick是价格轴上的离散刻度, 0 或者 1 就能表示这个每个位置上的 tick 是否有流动性。
在 UniswapV3 中用 uint256 类型的整数来做位图,它恰好由 256 个 0 或者 1 组成,每个位置就对应一个 tick,而具体是 0 还是 1 就代表对应 tick 是否有流动性。一个 uint256 可以管理 256 个连续的 tick,如果有更多的 tick 就用很多个 uint256 组成一个大的图。
这样就实现了把一维的 tick,映射到了二维的空间里。
位图如何实现快速查找
位图的核心优势就是不用逐个遍历,如果需要找到下一个有流动性的 Tick,首先定位到它归属于哪一个 uint256,然后找到这个数字内部下一个为 1 的比特位。
在 TickMath 中我们理解了如何找到某个数字的最高位,逻辑是用一定长度的绳子去量,如果超过了绳子长度就把测量的数字向右移动,然后换短一点的绳子量剩下的部分,不断累加就能找到数字的最高位。详细的逻辑可以参考这里DeFi 源码逐行读 #11 | 定价与数量转换:UniswapV3 SqrtPriceMath.sol,原始的代码可以参考BitMath.sol。
回到TickBitmap.sol 的代码中,第四行引入了BitMath.sol。

这是一个完成查询数字最高位或者最低位的计算文件,逻辑和TickMath中用的计算逻辑类似,就不再赘述。
Position 函数
14 行是一个叫 position 的函数,position 通常代表仓位的意思,但这里就是原始的含义“位置”。给每一个 tick 在位图中的一个位置。

入参是 tick,出参两个字段分别是wordPos和bitPos,也就是归属哪个 word 以及在 word 中的哪一位。
位图是一系列uint256的“字”组成的,因此 15 行将 tick 右移 8 为相当于除以 2^8,能够计算出落在了哪个 uint256 的字上。16 行对 tick除以 256 取余数,就能知道在某个“字”的多少位。
函数flipTick
23 行函数名叫flipTick,意思是tick 的翻转,在 tick 被首次初始化或者流动性从有变 0 或者从 0 变有的时候。

入参三个字段分别是 self、要翻转的 tick和对应的 tickSpacing。
这是一个内部函数,28 行首先校验 tick 和tickSpacing是不是匹配,要求tick能被tickSpacing除尽。
tick / tickSpacing是将 tick 压缩成了序号,只需要知道是对应tickSpacing的多少倍就能还原出真实的tick,将算出商调用上边提到的position函数,获得了tick 具体的位图。
创建一个掩码 mask,只在 tick 对应的bitPos位置上是 1,其他位置均为 0。
第 31 行,^是按位异或的运算符, 异或是说两者不同为 1 两者相同为 0。self[wordPos]是指位图映射中为wordPos的那个 uint256 的字,表示这一组内不同 tick 上的是否有流动性。
self[wordPos]和mask按位异或,可以精准实现只翻转目标位的流动性。对于目标位 mask 这一位是 1,如果self[wordPos]对应为位置原本是 0那么0^1=1,如果原本位置是 1那么1^1=0,实现了目标位的流动性翻转。对于其他位置由于 mask 全部为 0,self[wordPos]原本位置是 1 则 1^0=1,原本位置为 0 则 0^0=0,不改变其他位置的原始值。
函数nextInitializedTickWithinOneWord
42 行进入新的函数nextInitializedTickWithinOneWord,这个函数的意思是在同一个 word 中下一个已初始化的 tick。

入参四个变量self、tick、tickSpacing和lte,lte是less than or equal的缩写,如果lte为1意思是要找小于或等于当前tick的tick,因此向左搜索,否则向右搜索。
出参是两个字段next是搜索到的下一个可用的tick值,initialized是说这个tick是否被初始化过当前是否有流动性,如果该word里没有已经初始化的tick,则返回该方向上的边界tick并且initialized=0。
48行进入函数体,定义了一个int24类型的compressed,compressed的意思是压缩,正好用来存储tick在tickSpacing中的编号。
当前函数中的tick是池子当前价格对应的tick,可能不是和tickSpacing对齐,因此需要49行的额外处理。如果tick是大于0的,整数除法算完之后舍弃了小数部分,相当于向左对齐了最近一个标准tick刻度。但tick是负数时,舍弃小数部分会使负数变大,因此需要对compressed-1。
51行进入向左搜寻下一个更小价格tick的分支。

调用position函数获得左对齐标准tick的位图坐标。
54行定义了一个掩码mask,从第0位到第bitPos位全部为1,其余高位都为0的数。1 << bitPos是指只有bitPos位为1其他都为0,对这个数减一,就得到了从第0位到第bitPos位全部为1,bitPos位为0的数。然后再加上1 << bitPos,就实现了从第0位到bitPos全部为1的一个掩码。
55行,self[wordPos] 是这个“字”下当前所有tick的流动性情况,和mask做“按位与”运算,两者都为1则为1,否则为0。结果是从第0位到bitPos,由于mask本身是1,所以不改变self[wordPos]的原值;对应的位置(bitPos,255],由于mask是0,所以self[wordPos]对应位置全部被清0。
对于tick来说,tick越大对应的价格越高,compressed计算的结果越大,因此在position中的位置坐标就越大。通过掩码计算,在同一个“字”中以bitPos为界把更高价格段的流动性归0,使得搜寻能够聚焦在比tick小的位置上。

如果掩码计算后的masked为0,意味着在同一个“字”的搜寻范围内,没有一个具有流动性的tick,所以把0赋值给initialized,没有找到。否则赋值为1总会有一个符合条件有流动性的tick。
next是具体符合条件的tick。如果initialized为0,返回当前字方向的边界tick,compressed是当前tick的压缩序号,bitPos是它在这个字里排第几位(从0开始)。用总序号减去页内偏移compressed- bitPos,就得到这个字覆盖的最小压缩tick序号(即该字的第0位)。再乘上tickSpacing,就还原为真实的边界tick。这是向左搜索能返回的最左合法tick,再往左就属于前一个字了。
如果initialized不为0,也就是有符合条件的tick。BitMath.mostSignificantBit(masked))是找到masked这个数里最高位不为0的位数,compressed - int24(bitPos)相当于定位到了这个“字”的边缘位置,然后再加上找到的最高位所在位置乘上tickSpacing复原,就得到了距离当前价格最近左侧的的tick。
同样的道理63行开始的else部分是在向右更高价格寻找。

逻辑是类似的只不过掩码计算时会把对应更低价格的tick流动性抹除,只保留更高价格部分。在计算具体的next中,用的是leastSignificantBit,找到最低位为1的位数,这样对应的是超过当前tick价格但距离最近有流动性的合法tick。
总结
TickBitmap的文件不长,只有78行。我们了解了什么是位图,Uniswap如何通过位图快速完成流动性的检索。这样的位运算把需要遍历的复杂性优化为常数级别的Gas消耗,是 V3 集中流动性高效运转的索引引擎。
朋友们下次再见!
夜雨聆风