Uniswap V2 TWAP 预言机:时间加权平均价格详解

理解抗闪电贷操纵的链上价格预言机:累积价格、UQ112x112 定点数与溢出安全的模运算原理

10 分钟阅读
Uniswap V2 TWAP 预言机:时间加权平均价格详解

Uniswap V2 TWAP 预言机:时间加权平均价格详解

目录


一、Uniswap 中”价格”是什么

价格就是池中两种资产数量的比值。比如池里有 1 个 ETH 和 2,000 个 USDC,那么 ETH 的价格就是 2,000 USDC。

通用公式:

price(foo) = reserve(bar) / reserve(foo)

被定价的资产放在分母。直觉:要买的东西在池子里越稀缺(分母越小),价格越高。

价格通常带小数(比如 USDC 计价 ETH 是 2000,反过来 ETH 计价 USDC 就是 0.0005),而 Solidity 没有浮点数,所以 Uniswap V2 用 UQ112x112 定点数表示价格——小数点前后各 112 位(共 224 位),剩下 32 位刚好塞一个时间戳,凑满一个 256 位存储槽(详见第九节)。

二、预言机与现货价格的危险

预言机(oracle)= 价格的”事实来源”。Uniswap 池子隐含的价格可以免费供其他合约读取,天然是一个链上预言机。

直接读当前储备比值是极度危险的。闪电贷攻击的剧本:

  1. 闪电贷借入巨额代币;
  2. 在池子里巨量交易,把价格瞬间打到离谱位置;
  3. 调用受害合约(比如借贷协议),让它用这个被操纵的价格做清算/估值,从中获利;
  4. 把价格换回来、归还闪电贷——全程一笔交易,成本只有手续费

三、TWAP 为什么能抵抗操纵

TWAP(Time-Weighted Average Price,时间加权平均价格)的两道防线:

  1. 历史平均:消费者读取的是过去一段时间窗口的平均价。攻击者要影响平均价,必须跨多个区块持续维持被操纵的价格——这意味着真金白银地把池子价格挂在错误位置,套利者会蜂拥而至吃掉攻击者的钱,成本极其高昂;
  2. 排除当前状态:累积器更新发生在使用旧储备计算之后,当前交易内的操纵不会进入本次读数。

⚠️ TWAP 不是免疫:如果池子流动性太差、或者消费者用的时间窗口太短,资金雄厚的攻击者依然可能把平均价拉偏。流动性深度和窗口长度是安全参数。

四、TWAP 的计算方式(三个数值例子)

TWAP 与普通平均价的区别在于按价格持续的时长加权——维持了 10 小时的价格理应比只存在 1 小时的价格权重更高。

通用公式(T 是各价格的持续时长,不是时间戳):

TWAP = (P₁·T₁ + P₂·T₂ + … + Pₙ·Tₙ) / (T₁ + T₂ + … + Tₙ)

三个 24 小时窗口的例子

价格走势计算TWAP
$10 维持 12h,$11 维持 12h(10×12 + 11×12)/24$10.50
$10 维持 23h,$11 维持 1h(10×23 + 11×1)/24$10.04
$10 维持 1h,$11 维持 23h(10×1 + 11×23)/24$10.96

可以看到:短暂的价格异动(第二行)对 TWAP 影响很小——这正是抗操纵的数学来源。

五、Uniswap 的存储策略:只存分子

Uniswap 无法预知每个消费者想要多长的回看窗口(1 小时?1 天?1 周?),也不可能自己出 Gas 定时打快照。它的解法极其精简:

只在链上维护 TWAP 公式的”分子”——价格×时长的累积和。

两个公共变量:price0CumulativeLastprice1CumulativeLast。每当池子状态变化(mint、burn、swap、sync 触发 _update)时,执行:

priceCumulative += 上一个价格 × 距上次更新的时长

注意累加用的是上一个价格(旧储备的比值)——价格自上次更新以来一直保持不变,所以乘以这段时长是精确的。这两个变量从池子诞生起就一直累加,数值会非常大。

快照的责任归消费者:谁想用 TWAP,谁就自己定期把累积器的值抄下来存到自己合约里。

六、截取任意回看窗口:两次快照相减

累积器是”开天辟地以来”的总和:

price0CumulativeLast = P₁T₁ + P₂T₂ + P₃T₃ + P₄T₄ + P₅T₅ + P₆T₆

想要最近一段(比如从 T₄ 开始)的 TWAP?用当前值减去 T₃ 末尾的快照

窗口 TWAP = (当前累积值 − T₃ 时刻的快照) / (T₄ + T₅ + T₆)
        = (P₄T₄ + P₅T₅ + P₆T₆) / (T₄ + T₅ + T₆)

减法干净地消掉了所有更早的历史,分母就是两次快照之间实际流逝的秒数。这和”前缀和数组求区间和”是同一个思想。

七、实现一个 1 小时 TWAP 消费者合约

文中给出的示例合约包含两个函数:

// 打快照:要求距上次快照至少 1 小时
function snapshot() external {
    // 通过 getReserves() 拿到 blockTimestampLast
    // 存储当前的 price0CumulativeLast 和时间戳
}

// 读价格:要求快照新鲜度在 1~3 小时之间
function getOneHourPrice() external view returns (uint price) {
    // price = (当前 price0CumulativeLast − 快照值) / 经过的秒数
}

一个关键陷阱:累积器只在池子发生交易时才更新。如果交易对3 小时没有任何交易,存储的时间戳就过期了。解决办法:任何人都可以调用 Pair 的 sync() 函数——它内部触发 _update(),在不发生交易的情况下刷新累积器和时间戳。

八、为什么需要两个独立的累积器

直觉上 ETH/USDC = 2000,那 USDC/ETH = 1/2000,存一个方向就够了?对累积值不成立

1/(2+3) ≠ 1/2 + 1/3
0.2    ≠ 0.8333…

倒数运算不能穿透加法。对累积和取倒数得到的不是反方向的累积和,所以必须分别维护 price0CumulativeLast(token0 计价)和 price1CumulativeLast(token1 计价)。

九、UQ112x112 定点数

UQ112x112:Unsigned,小数点(Q)前 112 位整数 + 后 112 位小数,共 224 位。

  • 编码:encoded = value × 2¹¹²,即把数值左移 112 位;
  • 解码:除以 2¹¹²;
  • 例:价格 1.5 存储为 1.5 × 2¹¹²。

为什么是 112?两个原因:

  1. 对称容量:整数部分和小数部分各 112 位,价格和它的倒数(一个很大、一个很小)都能精确表达——应对代币间巨大的估值差异;
  2. 存储打包:224 位价格 + 32 位时间戳 = 256 位,恰好一个 storage slot,省 Gas。

十、溢出为什么是安全的(模运算详解)

累积器永远累加,终将溢出;32 位时间戳到 2106 年也会溢出。Uniswap V2 对此完全不设防——因为模运算让减法跨越溢出边界依然正确

⚠️ 注意:V2 写于 Solidity 0.8 之前,溢出默认回绕;如果你用 0.8+ 复刻,必须把这些运算放进 unchecked 块,否则会 revert。

用”模 100”的迷你世界演示(假设变量最大只能存到 99):

累积器溢出的例子:

  • 快照时累积器 = 80
  • 真实累积值涨到 110,但存储溢出回绕成 10
  • 计算:10 − 80 = −70 → 无符号数下回绕为 −70 mod 100 = 30
  • 真实差值 110 − 80 = 30 ✓ 完全一致

时间戳溢出的例子:

  • 快照时间 = 98(模 100 的时钟)
  • 当前时间 = 4(真实是 104)
  • 流逝时间:4 − 98 mod 100 = 6 秒

只要两次快照的间隔本身不超过一个完整的回绕周期(对 32 位时间戳是 136 年,对 256 位累积器更是天文数字),溢出发生在哪个位置都不影响差值的正确性。这是无符号模运算的优雅性质。

十一、关键公式速查

项目公式
价格price(A) = reserve(B) / reserve(A)
累积器更新priceCumulative += 旧价格 × 距上次更新时长
TWAP(当前累积值 − 快照累积值) / 两次快照间隔秒数
定点格式UQ112x112(112 位整数 + 112 位小数)
溢出安全无符号模运算保证差值正确

十二、动手练习项目:抗操纵价格预言机 TwapGuard

项目目标

给前几篇练习的池子加上完整的 TWAP 累积器,并实现一个独立的消费者合约 TwapGuard,在 Sepolia 上实测:现货价可以被一笔大额 swap 瞬间打飞,而 TWAP 纹丝不动。

合约要求

1. 给 SharePool 加 TWAP 累积器

  • 状态变量(模仿 Uniswap 打包):uint112 reserve0; uint112 reserve1; uint32 blockTimestampLast; 以及 uint public price0CumulativeLast; uint public price1CumulativeLast;
  • 实现 UQ112x112 库(两个函数就够):
library UQ112x112 {
    uint224 constant Q112 = 2**112;
    function encode(uint112 y) internal pure returns (uint224) { return uint224(y) * Q112; }
    function uqdiv(uint224 x, uint112 y) internal pure returns (uint224) { return x / uint224(y); }
}
  • 改造 _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1)
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed;
unchecked { timeElapsed = blockTimestamp - blockTimestampLast; } // 故意允许回绕
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
    unchecked {
        price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
        price1CumulativeLast += uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
    }
}
// 然后才更新 reserve 和时间戳

注意顺序:先用旧储备累加,再写入新储备——这是”排除当前状态”防线的代码实现;

  • sync() 外部函数:强制 _update 一次(解决长时间无交易的陈旧问题)。

2. TwapGuard.sol(消费者合约)

  • 状态:目标 pair 地址、uint snapshotPrice0Cumulative; uint32 snapshotTimestamp;
  • snapshot():距上次快照 ≥ 1 小时才允许(错误 TooSoon()),存当前累积值和时间戳;
  • consultOneHour() view returns (uint priceQ112):快照存在且年龄在 1~3 小时之间(错误 StaleSnapshot() / TooSoon()),返回 (当前累积值 − 快照) / 流逝秒数,整个减法放 unchecked
  • spotPrice() view:直接返回 encode(reserve1)/reserve0,用于和 TWAP 对比;
  • 辅助 toHuman(uint priceQ112) pure returns (uint)priceQ112 * 1e18 >> 112,把 Q112 转成 18 位小数方便 Etherscan 上肉眼读数。

测试要求(Foundry,重点用 vm.warp 操纵时间)

  1. test_CumulativeAccrues:seed 后 vm.warp(+100) → sync → 断言 price0CumulativeLast == encode(r1)/r0 × 100;
  2. test_TwapMatchesConstantPrice:价格不变时,TWAP == 现货价;
  3. test_TwapWeightsByDuration(复现第四节表格):构造”价格 P1 维持 23 小时、P2 维持 1 小时”的场景,断言 TWAP ≈ (23·P1 + P2)/24;
  4. test_ManipulationResistance(核心测试):快照 → 1 小时正常 → 攻击者巨额 swap 把现货价打偏 50% → 同一区块内 consult,断言 TWAP 偏移 < 0.1% 而 spotPrice 偏移 ≈ 50%;
  5. test_OverflowSafe:用 vm.store 把 price0CumulativeLast 预置为 type(uint).max − 小量,跑一段时间后 consult 仍返回正确价格(验证第十节的模运算);
  6. test_RevertWhen_SnapshotStale:超过 3 小时不更新,consult revert。

Sepolia 部署与验证步骤

  1. 部署带 TWAP 的池子 + TwapGuard,seed 流动性(建议 1000e18 : 2000e18,让”价格 2.0”好认);
  2. snapshot(),等 1 小时(现实时间);
  3. 用大额 swap 把现货价打偏,立刻分别读 spotPrice()consultOneHour(),截图对比——这是整个练习最有成就感的一步;
  4. 等 3 小时后验证 StaleSnapshot 确实触发,然后 sync() + 重新 snapshot() 恢复。

进阶挑战(可选)

  • 实现滑动窗口版本:环形缓冲区存最近 8 个快照(granularity 思想,对标 Uniswap 官方 ExampleSlidingWindowOracle);
  • 写 README 回答:在出块时间 12 秒、池子流动性 $10k 的 Sepolia 池子上,攻击者把 30 分钟 TWAP 拉偏 10% 大约要押多少资金、承担什么套利损失?

💬 评论