# 价格预言机的使用总结（三）：UniswapV3篇

By [Keegan小钢](https://paragraph.com/@keeganlee) · 2022-06-05

---

前言
--

前面两篇文章分别讲解了 [Chainlink](https://mp.weixin.qq.com/s?__biz=MzA5OTI1NDE0Mw==&mid=2652494404&idx=1&sn=9ab4817ecf08d25e9e77c42f7b861a98&chksm=8b685054bc1fd94222705f677be927831187c3aea23fc81a77a30fa0d4a6c9ce7d7bb8d9115c&token=1474167991&lang=zh_CN#rd) 和 [UniswapV2](https://mp.weixin.qq.com/s?__biz=MzA5OTI1NDE0Mw==&mid=2652494441&idx=1&sn=57a97690390b93770c5a906dce4157c8&chksm=8b685079bc1fd96f9ab60cc1b41b8642abf807a13a37c12f05a280be2e03f3a9288a047b5739&token=1474167991&lang=zh_CN#rd) 的 TWAP。Chainlink 属于**链下预言机**，其价格源取自多个交易所，但所支持的 token 比较有限，主要适用于获取主流 token 的价格。UniswapV2 的 TWAP 则是**链上预言机**，可适用于获取 Uniswap 上已有的任何 token 价格，主要缺陷就是需要链下程序定时触发更新价格，存在维护成本。**UniswapV3** 的 TWAP 则解决了这个缺陷问题，本文就来聊聊 UniswapV3 的 TWAP 机制，以及如何正式使用。

UniswapV3
---------

UniswapV3 的实现机制和 UniswapV2 有很大不同，在计算 TWAP 的数据源方面，UniswapV2 只存储了最新的 **price0CumulativeLast**、**price1CumulativeLast** 和 **blockTimestampLast** 三个值而已。而 UniswapV3 则改为用一个容量可达 65535 的数组来存储历史数据，即 **UniswapV3Pool** 合约的 **observations** 状态变量，另外，触发数据的存储也不再需要链下程序去定时触发，而是在 Uniswap 发生交易时自动触发。

首先，UniswapV3 每个币对的底层合约为 **UniswapV3Pool**，其 github 的代码地址为：

*   [https://github.com/Uniswap/v3-core/blob/main/contracts/UniswapV3Pool.sol](https://github.com/Uniswap/v3-core/blob/main/contracts/UniswapV3Pool.sol)
    

其次，对所存储的预言机数据 **observations** 的相关操作，基本封装在了 **Oracle** 库，其 github 的代码地址如下：

*   [https://github.com/Uniswap/v3-core/blob/main/contracts/libraries/Oracle.sol](https://github.com/Uniswap/v3-core/blob/main/contracts/libraries/Oracle.sol)
    

Observation
-----------

在 **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 都对应于一个实际价格，两者的关系可以表示如下：

![](https://storage.googleapis.com/papyrus_images/ddda40231549579cb9242e5642bf2d9f6730570bf3809f5263dd89d57f8ae25c.png)

该公式表明了，当 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**](https://github.com/Uniswap/v3-core/blob/main/contracts/UniswapV3Pool.sol#L255) 函数实现对 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 了。

TWAP 的计算
--------

**UniswapV3Pool** 提供了一个查询函数 [observe](https://github.com/Uniswap/v3-core/blob/main/contracts/UniswapV3Pool.sol#L236) 用来查询指定时间段内的 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 公式为：

*   **averageTick = tickCumulative\[1\] - tickCumulative\[0\] / 3600**
    

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

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

*   [https://github.com/Uniswap/v3-core/blob/main/contracts/libraries/TickMath.sol](https://github.com/Uniswap/v3-core/blob/main/contracts/libraries/TickMath.sol)
    

该库封装了 **tick** 和 **sqrtPrice**（根号价格）之间的转换函数，通过调用函数 **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 小时的 TWAP 的代码大致是怎样的：

    function getSqrtTWAP(address uniswapV3Pool) external view returns (uint160 sqrtPriceX96) {
        IUniswapV3Pool pool = IUniswapV3Pool(uniswapV3Pool);
        uint32[] memory secondsAgos = new uint32Unsupported embed;
        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
-------

要让第一个时间窗口内可用的话，其实也简单，在这第一个时间窗口内，计算 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 uint32Unsupported embed;
        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 的价格预言机，一般来说，可总结为以下几个步骤：

1.  遍历同个币对不同手续费率的池子，找出流动性 **liquidity** 最高的池子作为价格源的目标池子；
    
2.  调用目标池子的 [**increaseObservationCardinalityNext**](https://github.com/Uniswap/v3-core/blob/main/contracts/UniswapV3Pool.sol#L255) 函数对 observations 进行扩容；
    
3.  指定目标池子和 TWAP 的时间窗口，调用封装的 **getSqrtTWAP** 函数计算得到扩展后的加权平均根号价格 **sqrtPriceX96**；
    
4.  根据实际需要将 sqrtPriceX96 转换为其他格式的价格。
    

* * *

文章首发于「Keegan小钢」公众号：

[https://mp.weixin.qq.com/s?\_\_biz=MzA5OTI1NDE0Mw==&mid=2652494455&idx=1&sn=79b855a19261a9647fc83a1b9504b2ff&chksm=8b685067bc1fd971f356b0b10f534200733413f37fdec8876618501b1009711b8d262914d6b9&token=720743368&lang=zh\_CN#rd](https://mp.weixin.qq.com/s?__biz=MzA5OTI1NDE0Mw==&mid=2652494455&idx=1&sn=79b855a19261a9647fc83a1b9504b2ff&chksm=8b685067bc1fd971f356b0b10f534200733413f37fdec8876618501b1009711b8d262914d6b9&token=720743368&lang=zh_CN#rd)

---

*Originally published on [Keegan小钢](https://paragraph.com/@keeganlee/uniswapv3)*
