朋友们大家好!今天我们继续读UniswapV3的代码库。position.sol从名字上可以看出来这是处理仓位相关计算的库。具体来说是在处理仓位边界和费用增长,接下来我们详细来看。
当我们说仓位时在说什么?
在Uniswapv3中,position指的是仓位,通常当我们说仓位的时候,说的是在一段价格区间里用户提供了多少流动性。也就是对LP用户来说,它在什么价格区间上可以对外提供两种代币的兑换。
首先定义了一个结构体叫Info,在position中info存储了每个用户的仓位信息,一共有5个字段。

liquidity:该仓位当前拥有的流动性
feeGrowthInside0LastX128和feeGrowthInside1LastX128:两种token上一时刻区间内每单位流动性累计手续费;
tokensOwed0和tokensOwed1:两种token目前尚未领取的手续费数量。
get函数
从名字的get很容易理解这是一个获得某个仓位信息的函数,通过传入pool的全部用户仓位映射、仓位所有者的地址、价格的上下界,就能获得仓位的详细信息。

实现的方式是把用户地址、tick上下界紧打包作为参数,找到对应的仓位信息。
update函数
update就是更新,所以这是一个更新position信息的函数。主要的目的是当仓位的流动性发生变化时,更新流动性并且计算手续费。

入参的字段是需要更新的仓位info、流动性的变化量以及两个token累计手续费。
50行定义了一个内存变量_self,目的是一次性把存在storage中的仓位信息读取复制到内存中,后续读取成本更低。

如果流动性的变化量为0,就不用额外做计算,直接把当前值赋值给next。如果流动性有变化就调用addDelta函数在当前流动性的基础上做加减。
接下来是计算累计手续费。
由于全局累计的手续费的单位是“每单位流动性对应的”,所以64至66行的计算是:(新的全局累计-上次快照值)*旧流动性,FixedPoint128.Q128是将结果处理到真实手续费量级。

最后更新流动性、更新全局累计的手续费、累计待领取手续费。

仓位更新的逻辑比较简单,接下来我们继续看pool中用到的Oracle库。
Oracle.sol
为什么被翻译为“预言机”
Oracle是指预言机,它的原意是“神谕”,有一个非常有名的软件公司也叫Oracle,在国内的翻译叫“甲骨文”。甲骨文这个意象中国人非常熟悉,甲骨是古代用于占卜的工具,大巫师通过烧甲骨观察上边的裂纹来推断神的旨意,从某种程度上来说“甲骨文”确实也是“神谕”。回到计算机语境中,Oracle用来表示能够向系统提供关键外部信息的机制,在Uniswap V3中,Oracle库通过记录和存储历史价格数据,向外部调用者提供可信的时间加权平均价格,扮演了掌管价格的神。
回到代码中,在Oracle库中,首先定义了一个结构体叫Observation,可以被翻译为“观测点”,随着时间积累一串“观测点”累计成一个很长的数组。

Observation中有四个字段分别是:
blockTimestamp:区块时间戳,主要用来计算两个观测点之间的时间跨度;
tickCumulative:从池子创建到此刻,tick*时间的累计之和,由于乘上了时间所以可以用于计算用时间加权平均的价格;
secondsPerLiquidityCumulativeX128:从池子创建到此刻,每单位流动性的秒数累计值,相当于对价格用流动性做了加权,比单纯使用时间加权更难被操纵;
initialized:是否初始化,主要是数组中某些位置可能是空的尚未被初始化,用于标记区分。
函数transform
30行进入第一个函数transform,transform是改变的意思,也就是当时间和外界价格变化时,新的Observation会“改变”成什么样呢?

入参是四个字段:last是上一时刻的Observation,blockTimestamp当前时间戳,tick当前价格所在的tick,liquidity当前池子的流动性。
这是一个pure函数,也就是只完成计算不读写状态。返回值是基于最新时间、价格和流动性的Observation。
从39行开始能够清楚地看到Observation是如何被加工出来的。
首先把最新的时间戳写入;tickCumulative是tick*对应时间的累加;secondsPerLiquidityCumulativeX128是对应时间/Liquidity的累加;数值更新完成后最后给initialized赋值为1。
函数initialize
这是一个初始化函数,在池子创建时被调用,用来写入预言机数组的第一个观测点,给整个加权运算一个起始点。

入参是Observation格式的数组和当前时间戳。函数返回两个参数分别是:数组当前已填充的有效观测点数量,以及数组当前总长度。
56到61行在给第一个观测带点赋值,由于前序没有记录,所以tick和Liquidity的累计计算都是0。
返回值是(1,1)第一个1表示当前数组中已经有1个有效观测点,第二个1表示数组当前总长度是1。由于与预言机数组的每个存储槽都需要支付成本,默认值设置为1可以大幅降低创建时的部署和初始化费用,如果以后需要更长的时间窗口可以再调用别的函数来扩容。
函数write
顾名思义这是一个写入观测点数组的函数。

入参的字段有7个分别是Observation数组、上次写入Observation点的索引序号、当前时间戳、当前价格对应的tick、当前流动性以及数组有效观测点数量和数组总长度。返回值是更新后的观测点位的索引序号以及更新后的有效观测点数量。
根据上一个观测点在Observation数组中的索引序号index可以找到上一个观测点位的信息,把它写入last。
90行是在避免重复写入,同一个区块最多写入一次,如果上一个观测点的时间戳和当前时间戳一致,那就没必要再次写入了,避免价格被频繁采样浪费Gas。
93行在判断是否需要扩容。如果cardinalityNext > cardinality新容量>当前容量并且 index == (cardinality - 1)index指向当前已激活部分的最后一个位置,那么就会将已经扩容的位置全部激活:cardinalityUpdated = cardinalityNext;否则维持不变。
99行在更新索引,在上一个观测点index索引上加一,并对当前总长度取模,如果长度超了的话就会自动绕回0,实现循环缓冲。
第100行调用transform函数,基于上一个观测点计算出最新一个观测点,并将结果写入self[indexUpdated]。
函数grow
grow函数grow的是观测点位的数组,是一个扩容动作。

入参传入的三个字段分别是self观测点数组、current是当前预言机观测点位总长度cardinalityNext、next是期望扩容后的长度。
首先判定当前数组总长度需要大于0,并且期望扩充长度不能比当前总长度还小。扩容的方式是用循环将current到next-1位置上的每个新槽位的blockTimestamp赋值为1。
函数lte
lte是less than or equal的缩写,这是一个比较时间大小的函数,由于时间是用uint32格式来表示,长度有限所以时间是有可能溢出。当直接比较两个时间,溢出后的时间可能会比溢出前的时间小,这样就颠倒了。所以lte函数就是为了特别处理这个情况。

入参三个变量,time是某个参考时间,是用于判断溢出的锚点;a和b是两个待比较的时间。出参是a是否在时间上早于b。
134行是快速判断路径,如果a和b都比time小,说明它们都没有溢出,可以直接判断a和b的大小。
如果a或者b比time大了,那么就要把它还原到溢出前的时间,调整回uint256格式,然后再比较。
函数binarySearch
binarySearch的意思是二分法查找。找的是什么呢?最接近某个时间戳的两个观测点。这件事情的难点在于观测点位数组是个环,不断被覆盖。

函数输入有5个参数:self是预言机观测点数组、time当前时间戳、target希望查询时刻的时间戳、index最近写入观测点的索引、cardinality观测点数组已经激活的观测点数量。
出参是两个字段:before和after分别是最接近target时间戳前后的两个观测点。
160行进入函数体。

定义了一个变量l意思是left,是指整个搜索范围内最左边,对应的r是right是搜索范围最右边。由于预言机数组是循环缓冲结构,位置用完之后可以覆盖之前的位点,index是最新的观测点,index+1就能绕回起点对应的就是整个搜索范围的最左边,为了防止溢出用cardinality取模。算出来l作为最左侧,加上cardinality有效观测点数量再减一,就得到了最新观测点的索引。
162行定义了一个变量i,用于算计算二分位点i=(l+r)/2。
此时l、r和i都是逻辑坐标,观测点位数组从0开始往后计数,如果某个位点被覆盖了,就被抹去,所以观测点位可能出现的序号是连续的但不是从0开始。与此对应还有一个概念是“物理索引”,对应的是槽位的位置,数字大小不会超过cardinality,因为最多只有cardinality个槽位。所以逻辑坐标%cardinality=物理索引。
所以166行对i取模cardinality就能获得最中间的观测点位。
其实这是一个循环,在找符合target目标时间前后最近的观测点。是如何完成二分法的呢?

当前beforeOrAt中存储的是观测点数组的最中间的一个点或者中间两个点里时间偏早的那个。如果beforeOrAt的观测点尚未被初始化,可以直接跳过,未被初始化的节点可以认为存储了最“古老”的时间,默认写入的时间戳是1。最左端直接移动到中间位点的向下一位,重新计算left和right和中间点。
如果beforeOrAt已经被初始化了,那么AtOrAfter就是紧挨着before的下一个位点。
176和179行是在校验target是否恰好位于before和after中间,用到了上边介绍的lte函数,返回两个时间戳的大小比较。
如果target在beforeOrAt之前,说明目标在左半区,右边边界r左移;如果target在AtOrAfter之后,说明目标在右半区,左边边界l右移。
如此循环直到找到恰好的位置跳出循环。
函数getSurroundingObservations
这个函数想实现的结果和二分法查找目的一样,找到目标时间戳相邻的观测点。差异是是通过三步决策来实现的,更省劲。
优先检查最新观测点,其次查找最旧观测点,最后实在不行再调用二分法查找。
入参信息没有什么新花样,出参返回的也是target前后两个观测点。
首先乐观搜索,先看最新的观测点。

208行直接给beforeOrAt赋值为最新的观测点,如果target目标时刻不早于beforeOrAt的话,那就命中乐观搜索了,不用再做复杂检索。如果target恰好等于beforeOrAt,可以直接返回结果,这时AtOrAfter是没有意义的直接为空返回就行。
如果beforeOrAt<target,同时beforeOrAt又是最新一个观测点,那可以直接调用transform函数算出下一个观测点。
如果乐观预测没有命中,那就看看最远时间的情况,把index+1,由于这个数组是循环写入的,最新点的下一个点就是最旧的时间,如果这个位置还没有被初始化,那就去更早的时间直接去看self[0],如果target比这个最早时间还早,就直接返回‘OLD’报错吧,没存那么久的时间。

最早不行最近也没有,就只能调用二分法查找了。
函数observeSingle
这是一个查询某个时间点tick累计和流动性累计的函数。简而言之就是告诉这个函数“我要查xx秒之前的数据”,函数就会返回对应时刻的两个累计。
这篇文章的上半部分一直在讨论如何查找某一时刻的前后的两个观测点,这里就用上了。
如果要查找的时刻恰好是最新观测点,太好了直接返回两个累计值。
如果不是最新观测点,就返回目标时刻前后的两个观测点,恰好在前后观测点上吗?太好了直接返回对应观测点的累计值。如果不是,就用插值法现场计算一下。
插值法计算的逻辑也很简单,认为两个观测点之间的tick累计值的增长是线性的,所以用时间比例算一下就得到了。同理流动性的累计也是这样算的。
函数observe
observe是observeSingle的进阶版,它可以支持查询多个历史时间点的累计值。逻辑也非常简单,把要查询的时间点存在数组里,然后循环着调用observeSingle,再把结果也写进数组里返回。
总结
在读pool之前的准备工作终于差不多完事了,读了一系列负责计算和逻辑的库,知道怎么换算√P和tick和△X和L,知道怎么快速找到下一个 tick 位置,还知道怎么计算仓位、预言机,感觉已经强得不行了(但愿),准备好开始读Pool.sol了么!
夜雨聆风