Uniswap V2 TWAP 预言机:时间加权平均价格详解
目录
- 一、Uniswap 中”价格”是什么
- 二、预言机与现货价格的危险
- 三、TWAP 为什么能抵抗操纵
- 四、TWAP 的计算方式(三个数值例子)
- 五、Uniswap 的存储策略:只存分子
- 六、截取任意回看窗口:两次快照相减
- 七、实现一个 1 小时 TWAP 消费者合约
- 八、为什么需要两个独立的累积器
- 九、UQ112x112 定点数
- 十、溢出为什么是安全的(模运算详解)
- 十一、关键公式速查
- 十二、动手练习项目:抗操纵价格预言机 TwapGuard
一、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 池子隐含的价格可以免费供其他合约读取,天然是一个链上预言机。
但直接读当前储备比值是极度危险的。闪电贷攻击的剧本:
- 闪电贷借入巨额代币;
- 在池子里巨量交易,把价格瞬间打到离谱位置;
- 调用受害合约(比如借贷协议),让它用这个被操纵的价格做清算/估值,从中获利;
- 把价格换回来、归还闪电贷——全程一笔交易,成本只有手续费。
三、TWAP 为什么能抵抗操纵
TWAP(Time-Weighted Average Price,时间加权平均价格)的两道防线:
- 历史平均:消费者读取的是过去一段时间窗口的平均价。攻击者要影响平均价,必须跨多个区块持续维持被操纵的价格——这意味着真金白银地把池子价格挂在错误位置,套利者会蜂拥而至吃掉攻击者的钱,成本极其高昂;
- 排除当前状态:累积器更新发生在使用旧储备计算之后,当前交易内的操纵不会进入本次读数。
⚠️ 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 公式的”分子”——价格×时长的累积和。
两个公共变量:price0CumulativeLast 和 price1CumulativeLast。每当池子状态变化(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?两个原因:
- 对称容量:整数部分和小数部分各 112 位,价格和它的倒数(一个很大、一个很小)都能精确表达——应对代币间巨大的估值差异;
- 存储打包: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 操纵时间)
test_CumulativeAccrues:seed 后vm.warp(+100)→ sync → 断言 price0CumulativeLast == encode(r1)/r0 × 100;test_TwapMatchesConstantPrice:价格不变时,TWAP == 现货价;test_TwapWeightsByDuration(复现第四节表格):构造”价格 P1 维持 23 小时、P2 维持 1 小时”的场景,断言 TWAP ≈ (23·P1 + P2)/24;test_ManipulationResistance(核心测试):快照 → 1 小时正常 → 攻击者巨额 swap 把现货价打偏 50% → 同一区块内 consult,断言 TWAP 偏移 < 0.1% 而 spotPrice 偏移 ≈ 50%;test_OverflowSafe:用vm.store把 price0CumulativeLast 预置为type(uint).max − 小量,跑一段时间后 consult 仍返回正确价格(验证第十节的模运算);test_RevertWhen_SnapshotStale:超过 3 小时不更新,consult revert。
Sepolia 部署与验证步骤
- 部署带 TWAP 的池子 + TwapGuard,seed 流动性(建议 1000e18 : 2000e18,让”价格 2.0”好认);
- 调
snapshot(),等 1 小时(现实时间); - 用大额 swap 把现货价打偏,立刻分别读
spotPrice()和consultOneHour(),截图对比——这是整个练习最有成就感的一步; - 等 3 小时后验证
StaleSnapshot确实触发,然后sync()+ 重新snapshot()恢复。
进阶挑战(可选)
- 实现滑动窗口版本:环形缓冲区存最近 8 个快照(granularity 思想,对标 Uniswap 官方 ExampleSlidingWindowOracle);
- 写 README 回答:在出块时间 12 秒、池子流动性 $10k 的 Sepolia 池子上,攻击者把 30 分钟 TWAP 拉偏 10% 大约要押多少资金、承担什么套利损失?