使用 Uniswap V3价格预言机的总结
前面两篇文章分别讲解了 Chainlink 和 UniswapV2 的 TWAP。Chainlink 属于 链下预言机 ,其价格源取自多个交易所,但所支持的 token 比较有限,主要适用于获取主流 token 的价格。UniswapV2 的 TWAP 则是 链上预言机 ,可适用于获取 Uniswap 上已有的任何 token 价格,主要缺陷就是需要链下程序定时触发更新价格,存在维护成本。UniswapV3 的 TWAP 则解决了这个缺陷问题,本文就来聊聊 UniswapV3 的 TWAP 机制,以及如何正式使用。
UniswapV3 的实现机制和 UniswapV2 有很大不同,在计算 TWAP 的数据源方面,UniswapV2 只存储了最新的 price0CumulativeLast 、price1CumulativeLast 和 blockTimestampLast 三个值而已。而 UniswapV3 则改为用一个容量可达 65535 的数组来存储历史数据,即 UniswapV3Pool 合约的 observations 状态变量,另外,触发数据的存储也不再需要链下程序去定时触发,而是在 Uniswap 发生交易时自动触发。
首先,UniswapV3 每个币对的底层合约为 UniswapV3Pool ,其 github 的代码地址为:
其次,对所存储的预言机数据 observations 的相关操作,基本封装在了 Oracle 库,其 github 的代码地址如下:
在 Oracle 库中,定义了数据结构 Observation ,即存储预言机数据的数据结构:
struct Observation {
// the block timestamp of the observation
uint32 blockTimestamp;
// the tick accumulator, i.e. tick * time elapsed since the pool was first initialized
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;
}
blockTimestamp 就是每个 observation 所存储的时间戳,initialized 表明该 observation 是否已经初始化。最关键的是 tickCumulative ,这是自池子创建之后的 *tick time 的累计值。需要注意的是,在 UniswapV2 中,存储的是价格累计值 priceCumulative,而 UniswapV3 并不直接计算价格累计值,而是计算 tick 累计值** 。
tick 是 UniswapV3 引入的新概念,因为在 UniswapV3 中,LP 提供的流动性是分为多个不同区间的,那为了方便计算不同区间的流动性和手续费分配,UniswapV3 就将整个价格范围划分为了多个离散的价格点,这些价格点就称为 tick ,每个价格点 tick 都对应于一个实际价格,两者的关系可以表示如下:
该公式表明了,当 tick 为 0 时,价格为 1;当 tick 为 1 时,价格为 1.0001;当 tick 为 2 时,价格为 1.0001^2。也即是说,相邻价格点之间的价差为 0.01%。当然,tick 也可以为负值,为负值时表明价格 p 小于 1。
所以,observation 中所记录的不是 priceCumulative,而是 tickCumulative,请先记住这一点。
Oracle 中所定义的 Observation ,主要就是在 UniswapV3Pool 使用。我们先来看看在 UniswapV3Pool 涉及预言机的都有哪些状态变量:
...
import './libraries/Oracle.sol';
contract UniswapV3Pool {
using Oracle for Oracle.Observation[65535];
struct Slot0 {
uint160 sqrtPriceX96;
int24 tick;
uint16 observationIndex;
uint16 observationCardinality;
uint16 observationCardinalityNext;
...
}
Slot0 public override slot0;
Oracle.Observation[65535] public override observations;
...
}
其实,主要就只涉及到 slot0 和 observations 两个状态变量而已。observations 就是保存 Oracle 中定义的 Observation 结构体的数组,该数组主要就是存储历史的累计值。slot0 则记录了当前的一些状态值,sqrtPriceX96 即当前的根号价格,tick 即当前价格对应的价格点,observationIndex 是 observations 数组中最新一条记录的索引值,observationCardinality 记录了 observations 数组中实际存储的容量值,observationCardinalityNext 表示 observations 即将要扩展到的容量值。
虽然 observations 最大容量为 65535,但实际存储的容量并不会这么大,这是由 observationCardinality 所决定的。默认情况下,observationCardinality 为 1,即 observations 实际容量只有 1,一直都只更新第一个元素,此时是无法适用于计算 TWAP 的,需要对其进行扩容。
可以通过调用 UniswapV3Pool 合约的 increaseObservationCardinalityNext 函数实现对 observations 数组的扩容,指定的参数就是想要扩容的容量。而扩容为多少合适呢?这就要看需要使用多长时间的 TWAP 了,还要看是用在 Layer1 还是 Layer2。假设 TWAP 的时间窗口为 1 小时,那如果是在 Layer1 的话,因为出块时间平均为 10 几秒,那 1 小时出块最大上限也不会超过 360,即是说扩容的容量最大也不需要超过 360。而如果是用在 Layer2 的话,因为 Layer2 定序器的原因,以 Arbitrum 为例,每隔 1 分钟才会有一次时间戳的更新,所以理论上,1 小时的 TWAP 只要有 60 的容量就足够,可以增加一点冗余扩容到 70。
扩展了容量之后,添加流动性、移除流动性、兑换的时候,一般都会调用 Oracle 库的 write 函数,来实现更新 observations 数据。在 write 函数中,会有一个时间戳的判断,当上一个 Observation 的时间戳和当前时间戳一致的时候,则不会更新。因此,在 Layer1 中,每个区块只会发生一次更新 observations;而在 Layer2,因为时间戳 1 分钟才会更新一次,所以也是 1 分钟才会发生一次更新 observations。
有了这些基础之后,就可以开始查询和计算 TWAP 了。
UniswapV3Pool 提供了一个查询函数 observe 用来查询指定时间段内的 tick 累计值,该函数也是计算 TWAP 的关键函数,其代码实现也是调用 Oracle 库的 observe 函数:
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 是一个数组,数组的每个元素可以指定离当前时间之前的秒数。比如我们想要获取最近 1 小时的 TWAP,那可传入数组 [3600, 0],会查询两个时间点的累计值,3600 表示查询 1 小时前的累计值,0 则表示当前时间的累计值。返回的 tickCumulatives 就是对应于入参数组的每个时间点的 tick 累计值,secondsPerLiquidityCumulativeX128s 则是对应每个时间点的每秒流动性累计值,这个一般很少用到,所以就不展开讲了。
得到了这两个时间点的 tickCumulatives 之后,就可以算出平均加权的 tick 了。以 1 小时的时间间隔为例,计算平均加权的 tick 公式为:
tickCumulative[1] 为当前时间的 tick 累计值,tickCumulative[0] 则为 1 小时前的 tick 累计值。
计算得到 averageTick 之后,还需要将其转换为价格,这时就需要使用另一个库 TickMath :
该库封装了 tick 和 sqrtPrice (根号价格)之间的转换函数,通过调用函数 getSqrtRatioAtTick 就可以将 averageTick 转换得到对应的 sqrtPriceX96 。
在 UniswapV3 中的价格,都是用 sqrtPriceX96 来表示的,其实是将根号价格扩展了 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 小时的 TWAP 的代码大致是怎样的:
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 小时。如果不满足这两个条件,在调用 pool.observe(secondsAgos) 函数时一般就会报错,因为会读取不到 1 小时前的 observation 数据。即是说,在扩容后的第一个 TWAP 时间窗口内,TWAP 本身其实是不可用的。如果 TWAP 的时间窗口是 24 小时,那就意味着前 24 个小时的 TWAP 都处于不可用的状态了。如果想让 TWAP 在第一个时间窗口内也可用的话,那就需要对以上实现进行优化。
要让第一个时间窗口内可用的话,其实也简单,在这第一个时间窗口内,计算 TWAP 的时间间隔不再是完整的一个时间窗口,而是 observations 数组中离当前时间最久的那个 observation 到目前为止的时间差 。
如果 observations 只扩容过一次,该 observation 一般也是 observations 数组中的第一个元素,即 observations[0]。但如果 observations 在之前已经扩容过,但扩展的容量比较小的话,而目前是第二次扩容,此时数组中离当前时间最久的 observation 一般就不是 observations[0] 了,而是离当前最近的元素的下一个元素。
当前元素的索引为 index ,那下一个元素的索引,一般就是 (index + 1)。** 但如果当前的 index 已经是当前容量的最后一个元素,那下一个元素索引其实就会回到了 0。因此,要获取下一个元素,精确的索引值应该为: (index + 1) % cardinality** 。
下面就是优化后的代码实现:
function getSqrtTWAP(address uniswapV3Pool, uint32 twapInterval) external view returns (uint160 sqrtPriceX96) {
IUniswapV3Pool pool = IUniswapV3Pool(uniswapV3Pool);
(, , uint16 index, uint16 cardinality, , , ) = pool.slot0();
(uint32 targetElementTime, , , bool initialized) = pool.observations((index + 1) % cardinality);
if (!initialized) {
(targetElementTime, , , ) = pool.observations(0);
}
uint32 delta = uint32(block.timestamp) - targetElementTime;
if (delta == 0) {
(sqrtPriceX96, , , , , , ) = pool.slot0();
} else {
if (delta < twapInterval) twapInterval = delta;
uint32[] memory secondsAgos = new uint32[](2);
secondsAgos[0] = twapInterval; // from (before)
secondsAgos[1] = 0; // to (now)
(int56[] memory tickCumulatives, ) = pool.observe(secondsAgos);
// tick(imprecise as it's an integer) to price
sqrtPriceX96 = TickMath.getSqrtRatioAtTick(
int24((tickCumulatives[1] - tickCumulatives[0]) / int56(uint56(twapInterval)))
);
}
}
其中,还有几个关键逻辑需要补充说明下。
第三行代码中,读取出索引值为 (index + 1) % cardinality 的元素之后,其会返回一个布尔值 initialized ,如果该值为 false,则表示该元素还没被初始化,因此目标元素则改为了获取索引值为 0 的元素。
targetElementTime 就是目标元素记录累计值时的时间戳,当前时间戳减去该时间戳,就得到了目标元素离当前时间的时间差 delta 。如果 delta 为 0 的话,那可以直接返回当前的 sqrtPriceX96 即可。否则,如果 delta 小于计算 TWAP 的时间间隔 twapInterval ,那就将 twapInterval 重置为 delta 。
如此一来,在 TWAP 的第一个时间窗口内也同样可以读取到 TWAP 了。
我们知道,在 UniswapV2 中,每个币对就组成了一个池子,即指定的 token0 和 token1 有且仅有一个 Pool。但在 UniswapV3 中,每个池子的唯一性组成,除了 token0 和 token1,还多了一个 手续费率 ,不同费率的币对分开为了不同的池子。所以,在实际应用中,很多情况下还需要针对不同费率的池子做过滤处理,寻找出最优的池子作为预言机的价格源。
在实际应用中,可能有不同维度来衡量哪个池子是最优的。但大部分场景下,可以认为 TVL 最高的池子就是最优的池子,但从合约层面计算得到 TVL 不太方便。好在,合约层面可以方便地读取到当前的流动性 liquidity,所以也可以将此作为一个参考值,即 liquidity 最高的池子,也可以认为是最优的池子。
那么,获取最优池子的代码实现逻辑可以大致如下:
function getTargetPool(address token0, address token1) public view returns (address) {
uint24[4] public v3Fees;
v3Fees[0] = 100;
v3Fees[1] = 500;
v3Fees[2] = 3000;
v3Fees[3] = 10000;
// find out the pool with best liquidity as target pool
address pool;
address tempPool;
uint256 poolLiquidity;
uint256 tempLiquidity;
for (uint256 i = 0; i < v3Fees.length; i++) {
tempPool = IUniswapV3Factory(v3Factory).getPool(token0, token1, v3Fees[i]);
if (tempPool == address(0)) continue;
tempLiquidity = uint256(IUniswapV3Pool(tempPool).liquidity());
// use the max liquidity pool as index price source
if (tempLiquidity > poolLiquidity) {
poolLiquidity = tempLiquidity;
pool = tempPool;
}
}
return pool;
}
其逻辑其实很简单,就是对同个币对的每个手续费率都进行遍历,如果池子不为空且 liquidity 最高的池子就是目标池子。
一般来说,只要确定了目标池子之后,后续就不再需要重新遍历不同费率的池子了,可以将该目标池子绑定为固定的价格源池子。
如果频繁地遍历不同费率的池子,反而存在安全风险,因为攻击者可以通过闪电贷等方式短期内操控某个费率的池子,可能可以瞬间达到最高的流动性,这时候如果选中了被攻击者操控的池子作为了价格源池子,那安全风险就极高了。
简而言之,使用 UniswapV3 的价格预言机,一般来说,可总结为以下几个步骤:
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!