值得信赖的区块链资讯!
价格预言机的使用总结(三):UniswapV3篇
前言
前面两篇文章分别讲解了 Chainlink 和 UniswapV2 的 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
其次,对所存储的预言机数据 observations 的相关操作,基本封装在了 Oracle 库,其 github 的代码地址如下:
-
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 都对应于一个实际价格,两者的关系可以表示如下:
该公式表明了,当 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 了。
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 公式为:
-
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
该库封装了 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 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
要让第一个时间窗口内可用的话,其实也简单,在这第一个时间窗口内,计算 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 的价格预言机,一般来说,可总结为以下几个步骤:
-
遍历同个币对不同手续费率的池子,找出流动性 liquidity 最高的池子作为价格源的目标池子; -
调用目标池子的 increaseObservationCardinalityNext 函数对 observations 进行扩容; -
指定目标池子和 TWAP 的时间窗口,调用封装的 getSqrtTWAP 函数计算得到扩展后的加权平均根号价格 sqrtPriceX96; -
根据实际需要将 sqrtPriceX96 转换为其他格式的价格。
比推快讯
更多 >>- 今日美国比特币 ETF 净流出 2873 枚 BTC,以太坊 ETF 净流入 13500 枚 ETH
- 美股开盘加密板块普跌,Circle 下跌 4.91%
- 某巨鲸做空 BTC 浮盈 1250 万美元,持仓 2 个月资金费收益达 960 万美元
- OpenAI 正考虑在 ChatGPT 中投放广告
- 纳斯达克 100 指数期货涨至盘中高位
- 美股散户资金流入较去年激增 53%,2026 年将持续主导市场交易
- Aave 创始人千万美元购币被指控疑似操纵治理投票
- 数据:过去 24 小时全网爆仓 2.36 亿美元,多单爆仓 1.69 亿美元,空单爆仓 6,703.11 万美元
- 初请数据公布后现货黄金、美元指数 DXY 短线变动不大
- 美国至 12 月 20 日当周初请失业金人数 21.4 万人,预期 22.4 万人
- 彭博社:比特币错失圣诞狂欢暂无复苏迹象,价格在 8.5-9 万美元区间徘徊
- CryptoQuant:12 月比特币巨鲸向币安流入量腰斩
- 比特币财库公司 Genius Group 收购 Lighthouse Studios 将拓展内容市场
- 彭博社:比特币未能跟随华尔街的乐观情绪,在 8.7 万美元附近徘徊
- 华尔街 15 大投行展望被 AI 总结为“岌岌可危”,小摩警告 AI 泡沫风险
- Vitalik 预言:未来 15 年内或将出现无 bug 代码
- 贝莱德:美联储 2026 年降息幅度或有限
- 香港证监会:提防可疑投资产品“胜”酱香原浆酒(VSFOLT)/ “胜•酱香原浆酒”RWA 代币
- Metaplanet 董事会批准增持比特币计划
- 分析:比特币陷入 8.5–9 万美元“等待期”,圣诞波动或由期权到期驱动
- 巴克莱上调美国四季度 GDP 增速预期至 2%
- 贝莱德向 Coinbase 存入 2292 枚 BTC 和 9976 枚 ETH
- 数据:昨日以太坊 L1 交易量超 191.3 万笔,创年内单日交易量最高纪录
- Wintermute OTC 负责人:主流币跌破关键均线,趋势资金短期退场
- 分析师:黄金作为分散投资工具吸引力凸显
- 马斯克 DOGE 年度成绩单:美国政府雇员数量下降约 9%,支出反增至 7.5 万亿美元
- 英国 FCA 批准 Sling Money 提供加密支付服务,稳定币跨境转账获监管认可
- 富达研究总监 Jurrien Timmer:比特币 2026 年或迎“休整年”,支撑位在 6.5 万美元
- 数据:2186.48 枚 ETH 转入 Kraken,价值约 640 万美元
- 某鲸鱼平仓 BTC、ETH 与 SOL 空单,获利超 396 万美元
- 比特币本周五将迎史上规模最大的期权到期日,或推动比特币上涨
- 比特币下跌周期约持续 364 天,预计底部将在明年 10 月出现
- 主流 Perp DEX 一览:假期流动性影响交易量回落,Lighter 再度领跑
- 某以太坊 ICO OG 在休眠逾 10 年后转移 2000 枚 ETH,回报率高达 9435 倍
- OneBullEx 宣布将推出生态积分体系 OneBullEx Points(OBE)
- 休眠 10.4 年的以太坊地址被激活,持有价值近 600 万美元的 ETH
- 日本央行 10 月政策会议纪要:多位委员警告通胀风险
- 菲律宾打击无证虚拟资产服务提供商,屏蔽 Coinbase 和 Gemini
- 门头沟黑客过去一周卖出约 1300 枚比特币
- CryptoQuant:比特币 BCMI 指标下降,可能预示熊市阶段
- 数据:999.99 枚 BTC 从 Fidelity Custody 转出,价值约 8671 万美元
- 数据:某巨鲸在 Hyperliquid 以 1-2 倍杠杆做多 1.274 亿枚 TST,疑在操纵价格
- 数据:2025 年上线的 VC 项目市值均低于估值
- 分析师:黄金的中长期前景依然乐观
- 特朗普政府对曾要求审查马斯克 X 平台的欧盟前委员实施签证禁令
- 币安:拥有至少 226 个币安 Alpha 积分用户可于 21:00 领取空投
- 做市商 Jump Trading 近 4 小时向 Binance 存入 1 亿枚 USD1,以缓解后者暴增的流动性需求
- 某巨鲸 HYPE 空单浮动回报率超 500%,且持仓期间多次反向开多赚取波段收益
- 交易员 Daan:大多数山寨币已在 2024 年初触顶,比特币将在明年一季度证明自己
- ether.fi CEO:比特币和以太坊都不是优质货币,但相信优质货币的最终形态将建立在以太坊上
比推专栏
更多 >>观点
比推热门文章
- 今日美国比特币 ETF 净流出 2873 枚 BTC,以太坊 ETF 净流入 13500 枚 ETH
- 美股开盘加密板块普跌,Circle 下跌 4.91%
- 某巨鲸做空 BTC 浮盈 1250 万美元,持仓 2 个月资金费收益达 960 万美元
- 2025年Meme币“从夯到拉”排行榜
- OpenAI 正考虑在 ChatGPT 中投放广告
- 纳斯达克 100 指数期货涨至盘中高位
- 美股散户资金流入较去年激增 53%,2026 年将持续主导市场交易
- Cardano 代币 NIGHT 如何引爆单日近百亿美元交易量?
- 中美“斩杀线”对比:中产阶级的经济压力与生存真相
- Aave 创始人千万美元购币被指控疑似操纵治理投票
比推 APP



