Uniswap Part Ⅴ | 预言机

预言机的实现

前言

UniswapV2的预言机称为 TWAP(Time-Weighted Average Price) ,即时间加权平均价格 。属于是 链上预言机

UniswapV3 的实现机制和 UniswapV2 有很大不同

  • 在计算 TWAP 的数据源方面,UniswapV2 只存储了最新的 price0CumulativeLastprice1CumulativeLastblockTimestampLast 三个值而已。而 UniswapV3 则改为用一个容量可达 65535 的数组来存储历史数据,即 UniswapV3Pool 合约的 observations 状态变量,这样第三方开发者不再需要自己实现合约存储历史信息。

  • 触发数据的存储不再需要链下程序去定时触发,而是在 Uniswap 发生交易时自动触发。

  • Oracle 中不光记录了价格信息,还记录了对应流动性的时间累积值,因为 v3 中相同交易对在不同费率时时不同的交易池,这样在使用 Oracle 时,可以选择流动性较大的池最为价格参考来源

  • Uniswap v2 中可以计算出时间加权平均价格(算术平均值),而 v3 中计算出来的是时间加权价时几何平均值

    算术平均数的优势是其简单性,也是最符合直觉的平均数。当用于计算算术平均数的数据系列越长,其算术平均值就有越高的机率接近期望值,这种现象在统计学中被称为「大数法则」

    几何平均数一般来说都会小于算术平均数,因此但是对于波动较大的数字而言,使用几何平均数,其受波动性的影响会更小。

代码实现

Oracle 实现的代码都在 uniswap-v3-core 的合约中实现。

存储

Oracle 库中,定义了数据结构 Observation ,即存储预言机数据的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Observation {
// the block timestamp of the observation
// 区块的时间戳
uint32 blockTimestamp;
// the tick accumulator, i.e. tick * time elapsed since the pool was first initialized
// tick index 的时间加权累积值
// 自池子创建之后的 tick * time 的累计值
int56 tickCumulative;
// the seconds per liquidity, i.e. seconds elapsed / max(1, liquidity) since the pool was first initialized
uint160 secondsPerLiquidityCumulativeX128;
// whether or not the observation is initialized
// 是否已经被初始化
bool initialized;
}

在交易池的合约中,使用一个数组来存储交易池最近的的 Oracle 数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
contract UniswapV3Pool is IUniswapV3Pool, NoDelegateCall {
...
// Oracle 相关操作的库
using Oracle for Oracle.Observation[65535];
...
struct Slot0 {
// 当前的根号价格
uint160 sqrtPriceX96;
// 当前价格对应的价格点
int24 tick;
// 记录了最近一次 Oracle 记录在 Oracle 数组中的索引位置
uint16 observationIndex;
// 已经存储的 Oracle 数量
uint16 observationCardinality;
// 可用的 Oracle 空间,此值初始时会被设置为 1,后续根据需要来可以扩展
uint16 observationCardinalityNext;
...
}
Slot0 public override slot0;
...
// 使用数据记录 Oracle 的值
// 是保存 Oracle 中定义的 Observation 结构体的数组
Oracle.Observation[65535] public override observations;

}

数组的大小为 65535,但是实际上在初始阶段这个数据并不会被全部使用,而仅使用其中一部分空间(初始为 1,observationCardinality 为 1,即 observations 实际容量只有 1,一直都只更新第一个元素)。这样做的目的是,如果没有必要,那么仅存储最近一份 Oracle 数据即可,因为写入数据到数组中需要比较高昂的 gas 费用(SSTORE 操作)。

当第三方对某个交易池的 Oracle 有需求时,可以主动调用合约的接口扩展这个数据的可用空间,这样后续合约会存储更多的 Oracle 数据。

当数组可用大小写满之后,它会重新从 0 开始写入,即使用上类似一个 ring buffer(环形缓冲区)。

初始化

创建交易对时,UniswapV3Factory 会调用新创建交易对的 UniswapV3Pool.initialize() 函数对合约进行初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function initialize(uint160 sqrtPriceX96) external override {
require(slot0.sqrtPriceX96 == 0, 'AI');

int24 tick = TickMath.getTickAtSqrtRatio(sqrtPriceX96);

// 初始化Oracle
(uint16 cardinality, uint16 cardinalityNext) = observations.initialize(_blockTimestamp());

slot0 = Slot0({
sqrtPriceX96: sqrtPriceX96,
tick: tick,
observationIndex: 0,
observationCardinality: cardinality,
observationCardinalityNext: cardinalityNext,
feeProtocol: 0,
unlocked: true
});

emit Initialize(sqrtPriceX96, tick);
}

在初始化的代码中,调用了 observations.initialize(_blockTimestamp()) 来进行 Oracle 的初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
function initialize(Observation[65535] storage self, uint32 time)
internal
returns (uint16 cardinality, uint16 cardinalityNext)
{
self[0] = Observation({
blockTimestamp: time,
tickCumulative: 0,
secondsPerLiquidityCumulativeX128: 0,
initialized: true
});
// 返回当前 Oracle 的个数和最大可用个数
return (1, 1);
}

最大可用个数即 cardinalityNext 为 1,表示合约初始化时,只会记录最近的一次 Oracle 数据。

更新

Oracle 数据的更新发生在价格变动的时候,为了防止攻击(攻击者在同一个区块中,在 Oracle 写入的前后先买入再卖出某种资产,以实现低成本操纵 Oracle 数据的目的),同一个区块内,只会在第一次发生价格变动时写入 Oracle 信息。在 UniswapV3Pool.swap() 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// update tick and write an oracle entry if the tick change
// 检查价格是否发生了变化,当价格变化时,需要写入一个 Oracle 数据
if (state.tick != slot0Start.tick) {
// 写入 Oracle 数据
(uint16 observationIndex, uint16 observationCardinality) =
observations.write(
// 交易前的最新 Oracle 索引
slot0Start.observationIndex,
// 当前区块的时间
cache.blockTimestamp,
// 交易前的价格的 tick ,用于防止攻击
slot0Start.tick,
//交易前的价格对应的流动性
cache.liquidityStart,
// 当前的 Oracle 数量
slot0Start.observationCardinality,
// 可用的 Oracle 数量
slot0Start.observationCardinalityNext
);
// 更新最新 Oracle 指向的索引信息以及当前 Oracle 数据的总数目
(slot0.sqrtPriceX96, slot0.tick, slot0.observationIndex, slot0.observationCardinality) = (
state.sqrtPriceX96,
state.tick,
observationIndex,
observationCardinality
);
} else {
// otherwise just update the price
slot0.sqrtPriceX96 = state.sqrtPriceX96;
}

这里首先检查价格是否发生了变化,当价格变化时,需要写入 Oracle 数据,调用的是 observations.write 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function write(
Observation[65535] storage self,
uint16 index,
uint32 blockTimestamp,
int24 tick,
uint128 liquidity,
uint16 cardinality,
uint16 cardinalityNext
) internal returns (uint16 indexUpdated, uint16 cardinalityUpdated) {
// 获取当前的 Oracle 数据
Observation memory last = self[index];

// early return if we've already written an observation this block
// 同一个区块内,只会在第一笔交易中写入 Oracle 数据
// 在 Layer1 中,每个区块只会发生一次更新 observations
// 而在 Layer2,因为时间戳 1 分钟才会更新一次,所以也是 1 分钟才会发生一次更新 observations。
if (last.blockTimestamp == blockTimestamp) return (index, cardinality);

// if the conditions are right, we can bump the cardinality
// 检查是否需要使用新的数组空间
if (cardinalityNext > cardinality && index == (cardinality - 1)) {
cardinalityUpdated = cardinalityNext;
} else {
cardinalityUpdated = cardinality;
}

// 本次写入的索引,使用余数实现 ring buffer
indexUpdated = (index + 1) % cardinalityUpdated;
// 写入 Oracle 数据
self[indexUpdated] = transform(last, blockTimestamp, tick, liquidity);
}

写入时会计算出需要使用的索引数,如果可用空间用满会重新从头开始写入。Oracle 数据使用 transform 函数生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function transform(
Observation memory last,
uint32 blockTimestamp,
int24 tick,
uint128 liquidity
) private pure returns (Observation memory) {
// 上次 Oracle 数据和本次的时间差
uint32 delta = blockTimestamp - last.blockTimestamp;
return
Observation({
blockTimestamp: blockTimestamp,
// 计算 tick index 的时间加权累积值
tickCumulative: last.tickCumulative + int56(tick) * delta,
secondsPerLiquidityCumulativeX128: last.secondsPerLiquidityCumulativeX128 +
((uint160(delta) << 128) / (liquidity > 0 ? liquidity : 1)),
initialized: true
});
}

扩容

虽然合约定义了 Oracle 使用 65535 长度的数组,但是并不会在一开始就使用这么多的空间,这样做是因为:

  • 向空数组中写入 Oracle 数据是比较昂贵的操作(SSTORE)
  • 写入 Oracle 数据的操作发生在交易的操作中
  • 这些操作如果由交易者负担,是不公平的,因为代币交易者并不一定是 Oracle 的使用者

因此 Uniswap v3 在初始时 Oracle 数组仅可以写入一个数据,这个是通过交易池合约的 slot0.observationCardinalityNext 变量控制的。

当初始设置不满足需求时,合约提供了单独接口,让对 Oracle 历史数据有需求的开发者,自行调用接口来扩展交易池 Oracle 中存储数据的上限。这样就将 Oracle 数组存储空间初始化操作的 gas 费转移到了 Oracle 的需求方,而不是由代币交易者承担。

通过调用 UniswapV3Pool 合约的 increaseObservationCardinalityNext() 可以扩展交易池的 Oracle 数组可用容量,传入的参数为期望存储的历史数据个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
function increaseObservationCardinalityNext(uint16 observationCardinalityNext)
external
override
lock
noDelegateCall
{
uint16 observationCardinalityNextOld = slot0.observationCardinalityNext; // for the event
uint16 observationCardinalityNextNew =
observations.grow(observationCardinalityNextOld, observationCardinalityNext);
slot0.observationCardinalityNext = observationCardinalityNextNew;
if (observationCardinalityNextOld != observationCardinalityNextNew)
emit IncreaseObservationCardinalityNext(observationCardinalityNextOld, observationCardinalityNextNew);
}

这个函数调用了 observations.grow() 完成底层存储空间的初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function grow(
Observation[65535] storage self,
uint16 current,
uint16 next
) internal returns (uint16) {
require(current > 0, 'I');
// no-op if the passed next value isn't greater than the current next value
if (next <= current) return current;
// store in each slot to prevent fresh SSTOREs in swaps
// this data will not be used because the initialized boolean is still false
// 对数组中将来可能会用到的槽位进行写入,以初始化其空间,避免在 swap 中初始化
for (uint16 i = current; i < next; i++) self[i].blockTimestamp = 1;
return next;
}

这里可以看到,通过循环的方式,将 Oracle 数组中未来可能被使用的空间中写入数据。这样做的目的是将数据进行初始化,这样在代币交易写入新的 Oracle 数据时,不需要再进行初始化,可以让交易时更新 Oracle 不至于花费太多的 gas,SSTORE 指令由 20000 降至 5000。

当 Oracle 数据可使用空间被扩容至最大,即 65535 时,假设平均出块时间为 13 秒,那么此时至少可以存储最近 9.8 天的历史数据。

使用

UniswapV3Pool 提供了一个查询函数 observe 用来查询指定时间段内的 tick 累计值,该函数也是计算 TWAP 的关键函数,其代码实现也是调用 Oracle 库的 observe 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/// @inheritdoc IUniswapV3PoolDerivedState
function observe(uint32[] calldata secondsAgos)
external
view
override
noDelegateCall
returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s)
{
return
observations.observe(
_blockTimestamp(),
secondsAgos,
slot0.tick,
slot0.observationIndex,
liquidity,
slot0.observationCardinality
);
}

该函数指定的参数 secondsAgos 是一个数组,顾名思义表示请求 N 秒前的数据,数组的每个元素可以指定离当前时间之前的秒数。比如我们想要获取最近 1 小时的 TWAP,那可传入数组 [3600, 0],会查询两个时间点的累计值,3600 表示查询 1 小时前的累计值,0 则表示当前时间的累计值。返回的 tickCumulatives 就是对应于入参数组的每个时间点的 tick 累计值,secondsPerLiquidityCumulativeX128s 则是对应每个时间点的每秒流动性累计值,

数据的处理是在 observations.observe() 中完成的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function observe(
Observation[65535] storage self,
uint32 time,
uint32[] memory secondsAgos,
int24 tick,
uint16 index,
uint128 liquidity,
uint16 cardinality
) internal view returns (int56[] memory tickCumulatives, uint160[] memory secondsPerLiquidityCumulativeX128s) {
require(cardinality > 0, 'I');

tickCumulatives = new int56[](secondsAgos.length);
secondsPerLiquidityCumulativeX128s = new uint160[](secondsAgos.length);
// 遍历传入的时间参数,获取结果
for (uint256 i = 0; i < secondsAgos.length; i++) {
(tickCumulatives[i], secondsPerLiquidityCumulativeX128s[i]) = observeSingle(
self,
time,
secondsAgos[i],
tick,
index,
liquidity,
cardinality
);
}
}

这个函数就是通过遍历请求参数,获取每一个请求时间点的 Oracle 数据,具体数据通过 observeSingle() 函数来获取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
function observeSingle(
Observation[65535] storage self,
uint32 time,
uint32 secondsAgo,
int24 tick,
uint16 index,
uint128 liquidity,
uint16 cardinality
) internal view returns (int56 tickCumulative, uint160 secondsPerLiquidityCumulativeX128) {
// secondsAgo 为 0 表示当前的最新 Oracle 数据
if (secondsAgo == 0) {
Observation memory last = self[index];
if (last.blockTimestamp != time) last = transform(last, time, tick, liquidity);
return (last.tickCumulative, last.secondsPerLiquidityCumulativeX128);
}

// 计算出请求的时间戳
uint32 target = time - secondsAgo;

// 计算出请求时间戳最近的两个 Oracle 数据
(Observation memory beforeOrAt, Observation memory atOrAfter) =
getSurroundingObservations(self, time, target, tick, index, liquidity, cardinality);

// 如果请求时间和返回的左侧时间戳吻合,那么可以直接使用
if (target == beforeOrAt.blockTimestamp) {
// we're at the left boundary
return (beforeOrAt.tickCumulative, beforeOrAt.secondsPerLiquidityCumulativeX128);
// 如果请求时间和返回的右侧时间戳吻合,那么可以直接使用
} else if (target == atOrAfter.blockTimestamp) {
// we're at the right boundary
return (atOrAfter.tickCumulative, atOrAfter.secondsPerLiquidityCumulativeX128);
} else {
// we're in the middle
// 当请求时间在中间时,计算根据增长率计算出请求的时间点的 Oracle 值并返回
uint32 observationTimeDelta = atOrAfter.blockTimestamp - beforeOrAt.blockTimestamp;
uint32 targetDelta = target - beforeOrAt.blockTimestamp;
return (
beforeOrAt.tickCumulative +
((atOrAfter.tickCumulative - beforeOrAt.tickCumulative) / observationTimeDelta) *
targetDelta,
beforeOrAt.secondsPerLiquidityCumulativeX128 +
uint160(
(uint256(
atOrAfter.secondsPerLiquidityCumulativeX128 - beforeOrAt.secondsPerLiquidityCumulativeX128
) * targetDelta) / observationTimeDelta
)
);
}
}

在这函数中,会先调用 getSurroundingObservations() 找出的时间点前后,最近的两个 Oracle 数据。然后通过时间差的比较计算出需要返回的数据:

  • 如果和其中的某一个的时间戳相等,那么可以直接返回
  • 如果在两个时间点的中间,那么通过计算增长率的方式,计算出请求时间点的 Oracle 数据并返回

getSurroundingObservations() 函数的作用是在已记录的 Oracle 数组中,找到时间戳离其最近的两个 Oracle 数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
function getSurroundingObservations(
Observation[65535] storage self,
uint32 time,
uint32 target,
int24 tick,
uint16 index,
uint128 liquidity,
uint16 cardinality
) private view returns (Observation memory beforeOrAt, Observation memory atOrAfter) {
// optimistically set before to the newest observation
// 先把 beforeOrAt 设置为当前最新数据
beforeOrAt = self[index];

// if the target is chronologically at or after the newest observation, we can early return
// 检查 beforeOrAt 是否 <= target
if (lte(time, beforeOrAt.blockTimestamp, target)) {
if (beforeOrAt.blockTimestamp == target) {
// if newest observation equals target, we're in the same block, so we can ignore atOrAfter
// 如果时间戳相等,那么可以忽略 atOrAfter 直接返回
return (beforeOrAt, atOrAfter);
} else {
// otherwise, we need to transform
// 当前区块中发生代币对的交易之前请求此函数时可能会发生这种情况
// 需要将当前还未持久化的数据,封装成一个 Oracle 数据返回,
return (beforeOrAt, transform(beforeOrAt, target, tick, liquidity));
}
}

// now, set before to the oldest observation
// 将 beforeOrAt 调整至 Oracle 数组中最老的数据
// 即为当前 index 的下一个数据,或者 index 为 0 的数据
beforeOrAt = self[(index + 1) % cardinality];
if (!beforeOrAt.initialized) beforeOrAt = self[0];

// ensure that the target is chronologically at or after the oldest observation
require(lte(time, beforeOrAt.blockTimestamp, target), 'OLD');

// if we've reached this point, we have to binary search
// 然后通过二分查找的方式找到离目标时间点最近的前后两个 Oracle 数据
return binarySearch(self, time, target, index, cardinality);
}

这个函数会调用 binarySearch() 通过二分查找的方式,找到目标离目标时间点最近的前后两个 Oracle 数据,其中的具体实现这里就不再描述了。

最终,UniswapV3Pool.observe() 将会返回请求者所请求的每一个时间点的 Oracle 数据,请求者可以根据这些数据计算出交易对的 TWAP(时间加权平均价,几何平均数)

TWAP 的计算

假如我们想要获取最近 1 小时的 TWAP,在 observe 中传入了数组 [3600, 0],可以得到这两个时间点的 tickCumulatives ,借此算出平均加权的 tick 。以 1 小时的时间间隔为例,计算平均加权的 tick 公式为:

  • averageTick = tickCumulative[1] - tickCumulative[0] / 3600

tickCumulative[1] 为当前时间的 tick 累计值,tickCumulative[0] 则为 1 小时前的 tick 累计值。

计算得到 averageTick 之后,还需要将其转换为价格,这时就需要使用另一个库 TickMath

该库封装了 ticksqrtPrice (根号价格)之间的转换函数,通过调用函数 getSqrtRatioAtTick 就可以将 averageTick 转换得到对应的 sqrtPriceX96

在 UniswapV3 中的价格,都是用 sqrtPriceX96 来表示的,其实是将根号价格扩展了 2 的 96 次方,即:

  • sqrtPriceX96 = sqrt(price) * 2^96

另外,需要注意的是,这里说的 price 其实是 token1Amount / token0Amount = token0Price,即 token0 的价格。为了方便理解,我们直接举例来说明。假设 token1 为 USDC,token0 为 WETH,那 token1 在合约里的精度数为 6,token0 的精度数则为 18,也即是说,1 USDC 在合约里表示为 1000000(1e6),而 1 WETH 则表示为 1e18。那么,如果 WETH/USDC 的十进制价格为 2000 的话,公式中的 price 就是指 2000 * 1e6 / 1e18 = 2000 / 1e12,该值其实是小于 1 的,在合约层面就无法表示,所以才需要对其扩展。

1
2
3
4
5
6
7
8
9
10
function getSqrtTWAP(address uniswapV3Pool) external view returns (uint160 sqrtPriceX96) {
IUniswapV3Pool pool = IUniswapV3Pool(uniswapV3Pool);
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = 3600;
secondsAgos[1] = 0;
(int56[] memory tickCumulatives, ) = pool.observe(secondsAgos);
int56 averageTick = (tickCumulatives[1] - tickCumulatives[0]) / 3600;
// tick(imprecise as it's an integer) to price
sqrtPriceX96 = TickMath.getSqrtRatioAtTick(averageTick);
}

该函数用来获取指定 pool 在最近 1 小时内的时间加权平均价格,且表示为 sqrtPriceX96 的价格。

该函数要可行的话,主要有两个前提,一是该 pool 的 observations 已经有足够的扩容,二是扩容之后该池子已经交易了至少 1 小时。


Uniswap Part Ⅴ | 预言机
http://sissice.github.io/2022/09/29/uniswap-v3-5/
作者
Sissice
发布于
2022年9月29日
许可协议