Uniswap V2 源码学习第十篇:Oracle,TWAP 价格累计机制是怎么藏在 Pair 里的?
_update函数拆解
前面我们已经讲完了 Uniswap V2 的主交易路径:
Factory 创建 Pair
Router 组织用户操作
Library 计算报价
Pair 执行 mint / burn / swap
上一篇讲手续费时,我们看到了 kLast 和 _mintFee()。
这一篇进入另一个非常经典的设计:
Uniswap V2 Oracle
更准确地说,是:
基于累计价格的 TWAP Oracle
很多人第一次读 V2 Pair,会注意到这几个变量:
uint public price0CumulativeLast;
uint public price1CumulativeLast;
然后心里犯嘀咕:
这俩东西到底干嘛的?为什么 Pair 里还藏着预言机?
Uniswap V2 的设计很有意思。
它没有直接说:
function getPrice() external view returns (uint price)
也没有直接把“当前价格”暴露成一个安全预言机。
它做的是:
Pair 在每次状态更新时,顺手记录价格随时间的累计值。外部协议可以用两个时间点的累计价格差,自己计算 TWAP。
这就是 V2 Oracle 的核心。
1. 为什么不能直接用当前 reserve 当价格?
Pair 的当前价格大致来自 reserve 比例:
price0 = reserve1 / reserve0
price1 = reserve0 / reserve1
比如池子里有:
reserve0 = 10 ETH
reserve1 = 30,000 USDC
那么:
1 ETH ≈ 3000 USDC
所以最直接的想法是:
我直接读取 Pair.getReserves()
然后用 reserve1 / reserve0 当价格
但这在 DeFi 里非常危险。
因为当前 reserve 可以被短时间操纵。
攻击者可以在同一个区块里:
1. 用闪电贷借一大笔资金
2. 大额 swap 推动 Uniswap 池子价格
3. 让某个协议读取被操纵后的 reserve
4. 从协议中获利
5. 再反向 swap 或还闪电贷
这类攻击非常经典。
所以 Uniswap V2 没有把“当前 reserve 比例”包装成安全预言机。
它提供的是一种更抗操纵的基础设施:
累计价格 cumulative price
外部协议可以用它计算:
TWAP:Time Weighted Average Price,时间加权平均价格
2. TWAP 是什么?
TWAP 的意思是:
一段时间内的平均价格
不是某一瞬间价格。
比如我们观察 30 分钟。
如果前 10 分钟价格是:
1 ETH = 3000 USDC
后 20 分钟价格是:
1 ETH = 3300 USDC
那么 30 分钟 TWAP 是:
(3000 * 10 + 3300 * 20) / 30
= 3200
也就是:
时间越长的价格,对平均值影响越大。
TWAP 的好处是:
攻击者不能只在一个瞬间把价格打歪就成功。想影响 TWAP,需要让价格在一段时间里持续偏离,这会更贵。
当然,TWAP 不是万能的。
但它比直接读取瞬时 reserve 安全得多。
3. Pair 里和 Oracle 有关的状态变量
在 UniswapV2Pair.sol 里,和 Oracle 直接相关的是这些:
uint112 private reserve0;
uint112 private reserve1;
uint32 private blockTimestampLast;
uint public price0CumulativeLast;
uint public price1CumulativeLast;
我们之前讲过:
reserve0 / reserve1:
Pair 上一次更新时记录的储备量。
blockTimestampLast:
上一次更新 reserve 的时间戳。
price0CumulativeLast:
token0 相对于 token1 的累计价格。
price1CumulativeLast:
token1 相对于 token0 的累计价格。
这几个变量都在 _update() 里维护。
4. _update() 是 Oracle 的核心入口
Pair 的状态更新都通过这个函数完成:
function _update(
uint balance0,
uint balance1,
uint112 _reserve0,
uint112 _reserve1
) private {
require(
balance0 <= uint112(-1) && balance1 <= uint112(-1),
'UniswapV2: OVERFLOW'
);
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
price0CumulativeLast +=
uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
price1CumulativeLast +=
uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
}
reserve0 = uint112(balance0);
reserve1 = uint112(balance1);
blockTimestampLast = blockTimestamp;
emit Sync(reserve0, reserve1);
}
这个函数做了三类事情:
1. 检查 balance 是否能放进 uint112
2. 如果时间经过了,就更新累计价格
3. 把 reserve 更新为最新 balance,并记录时间戳
其中 Oracle 的核心就是这一段:
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
price0CumulativeLast +=
uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
price1CumulativeLast +=
uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
}
5. _update() 什么时候被调用?
_update() 不会自己定时执行。
它只在 Pair 状态变化时被调用。
主要有三个地方:
mint()
burn()
swap()
也就是:
添加流动性后,更新 reserve
移除流动性后,更新 reserve
交易完成后,更新 reserve
还有两个辅助函数:
skim()
sync()
其中 sync() 也会调用 _update()。
所以 Oracle 的更新不是靠后台任务,也不是每个区块自动更新。
它是懒更新:
只有当有人和 Pair 交互时,Pair 才更新累计价格。
这点非常重要。
6. 价格累计到底在累计什么?
看这一行:
price0CumulativeLast +=
uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
拆开看。
_reserve1 / _reserve0 是 token0 的价格。
为什么?
如果:
token0 = ETH
token1 = USDC
reserve0 = 10 ETH
reserve1 = 30,000 USDC
那么:
price0 = reserve1 / reserve0
= 30,000 / 10
= 3000 USDC per ETH
所以:
UQ112x112.encode(_reserve1).uqdiv(_reserve0)
表示:
token0 用 token1 计价的价格
然后乘以:
timeElapsed
表示这个价格持续了多少秒。
所以:
price0CumulativeLast += price0 * timeElapsed;
这就是累计价格。
同理:
price1CumulativeLast +=
uint(UQ112x112.encode(_reserve0).uqdiv(_reserve1)) * timeElapsed;
表示:
token1 用 token0 计价的累计价格
也就是:
price1 = reserve0 / reserve1
7. 一个简单例子理解 cumulative price
假设某个 ETH/USDC Pair:
token0 = ETH
token1 = USDC
初始 reserve:
reserve0 = 10 ETH
reserve1 = 30,000 USDC
所以:
price0 = 3000 USDC / ETH
如果这个价格持续了 100 秒,那么:
price0CumulativeLast 增加:
3000 * 100 = 300,000
之后有人 swap,价格变成:
1 ETH = 3200 USDC
如果这个价格又持续了 50 秒,那么:
price0CumulativeLast 再增加:
3200 * 50 = 160,000
这 150 秒之后,累计价格总增加:
300,000 + 160,000 = 460,000
那么这 150 秒的 TWAP 就是:
460,000 / 150
= 3066.67 USDC / ETH
也就是:
累计价格差 / 时间差 = 时间加权平均价格
这就是 TWAP 的核心公式。
8. 外部协议如何计算 TWAP?
外部协议通常会记录两个时间点的数据。
比如在时间点 A 记录:
price0CumulativeA
timestampA
过一段时间,在时间点 B 记录:
price0CumulativeB
timestampB
然后计算:
TWAP =
(price0CumulativeB - price0CumulativeA)
/
(timestampB - timestampA)
也就是:
累计价格差 / 时间差
伪代码:
uint priceAverage =
(price0CumulativeB - price0CumulativeA)
/ (timestampB - timestampA);
不过源码里价格是 UQ112x112 格式,所以实际使用时会保留定点数精度。
核心思想就是这么简单。
9. 为什么 cumulative price 可以抗操纵?
如果协议读取的是当前 reserve,攻击者只要在一个瞬间操纵价格即可。
但如果协议读取的是 30 分钟 TWAP,攻击者需要让价格在这 30 分钟中产生足够影响。
假设正常价格是:
3000
攻击者在最后 1 秒把价格拉到:
6000
如果 TWAP 窗口是 30 分钟,也就是 1800 秒。
那么平均价格大概是:
(3000 * 1799 + 6000 * 1) / 1800
≈ 3001.67
影响非常小。
如果攻击者想把 30 分钟 TWAP 明显拉高,就必须让价格在更长时间内偏离,这意味着:
1. 需要更多资金
2. 会遭遇套利者
3. 需要承担价格偏离期间的损失
所以 TWAP 提高了攻击成本。
一句话:
瞬时价格容易被打歪,时间平均价格更难被打歪。
10. 为什么要用上一次 reserve,而不是当前 balance?
_update() 的参数是:
_update(balance0, balance1, _reserve0, _reserve1)
在累计价格时,用的是旧 reserve:
UQ112x112.encode(_reserve1).uqdiv(_reserve0)
而不是:
balance1 / balance0
为什么?
因为 _update() 被调用时,balance0/balance1 已经是这次操作后的新余额。
但过去这段 timeElapsed 内真正持续存在的价格,是旧 reserve 对应的价格。
比如:
12:00 时 reserve = 10 ETH / 30,000 USDC,价格 3000
12:10 有人 swap,把价格变成 3200
在 12:10 调用 _update() 时:
timeElapsed = 10 分钟
这 10 分钟里持续的价格是旧价格 3000,而不是 swap 后的新价格 3200。
所以累计价格应该加:
3000 * 10 分钟
然后再把 reserve 更新成新价格。
这就是为什么源码使用 _reserve0/_reserve1。
这一点特别关键。
_update()先用旧 reserve 给过去这段时间记账,再把 reserve 更新成当前 balance,作为下一段时间的价格基础。
11. 为什么 timeElapsed > 0?
源码判断:
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
...
}
如果 timeElapsed == 0,说明在同一个区块时间戳内已经更新过。
这时不累计价格。
原因很直接:
价格持续时间为 0 秒
累计价格增加也应该是 0
这还有一个效果:
同一个区块内的多次操作,不会反复累加价格时间。
这让区块内瞬时操纵更难直接污染累计价格。
不过要注意,V2 的 Oracle 安全性仍然依赖使用者选择合理的时间窗口。
窗口太短,还是容易被操纵。
12. 为什么 reserve 不能是 0?
源码判断:
_reserve0 != 0 && _reserve1 != 0
如果某一边 reserve 是 0,就不能计算价格。
因为会出现除以 0,或者价格没有意义。
这通常发生在:
1. Pair 刚创建但还没有流动性
2. 极端情况下流动性几乎被移除
所以只有两边都有储备时,才累计价格。
13. UQ112x112 是什么?
现在讲这个库:
UQ112x112
源码大概是:
library UQ112x112 {
uint224 constant Q112 = 2**112;
function encode(uint112 y) internal pure returns (uint224 z) {
z = uint224(y) * Q112;
}
function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) {
z = x / uint224(y);
}
}
它是一个定点数库。
Solidity 没有浮点数。
但价格经常是小数。
比如:
reserve0 = 3
reserve1 = 10
price0 = 10 / 3 = 3.333...
整数除法会丢精度。
所以 Uniswap V2 用 Q112.112 定点数表示价格。
14. Q112.112 怎么理解?
UQ112x112 可以理解成:
前 112 位表示整数部分
后 112 位表示小数部分
具体做法是把数放大:
真实值 * 2^112
比如要表示:
3
实际存的是:
3 * 2^112
要表示:
10 / 3
源码先把 10 编码:
UQ112x112.encode(10)
得到:
10 * 2^112
再除以 3:
10 * 2^112 / 3
这样小数精度就保留在低 112 位里。
所以这行:
UQ112x112.encode(_reserve1).uqdiv(_reserve0)
不是普通的:
reserve1 / reserve0
而是:
reserve1 / reserve0 的 Q112.112 定点数表示
这让价格累计有非常高的精度。
15. 为什么用 uint224?
encode() 返回:
uint224
因为输入是:
uint112
乘以:
2^112
最大需要:
112 + 112 = 224 bits
所以用 uint224 正好。
这和 Pair 里 reserve 用 uint112 是配套的。
reserve 是 uint112
价格编码成 uint224
再累计到 uint
V2 里的数字类型都不是随便选的。
很多地方都是为了:
1. 防止溢出
2. 节省 storage
3. 保留足够精度
16. blockTimestampLast 为什么是 uint32?
_update() 里:
uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
blockTimestampLast 是:
uint32 private blockTimestampLast;
之前讲 Pair 状态变量时提过,uint32 可以和两个 uint112 reserve 打包进一个 storage slot:
uint112 reserve0
uint112 reserve1
uint32 blockTimestampLast
112 + 112 + 32 = 256
刚好一个槽。
这能省 gas。
但 uint32 会溢出,大约 136 年回绕一次。
源码故意这么做。
因为:
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
在 Solidity 0.5 的无检查算术下,这种减法在模 2^32 意义下工作。
只要两次更新间隔不超过 136 年,计算出的时间差就没问题。
源码注释里甚至说:
overflow is desired
这属于老派 Solidity 里很精致的溢出利用。
17. currentCumulativePrices:外部库如何补当前区块价格?
这里有一个细节。
Pair 的累计价格只有在 _update() 时才更新。
如果某个外部协议想读取当前 TWAP,但最近没有人和 Pair 交互,price0CumulativeLast 可能还停留在上一次更新时。
那怎么办?
Uniswap V2 periphery 里有一个 Oracle 相关库,常见逻辑叫:
currentCumulativePrices
它会做一件事:
如果当前时间戳大于 Pair 的 blockTimestampLast,
就基于当前 reserve 计算“虚拟累计值”。
伪代码大概是:
price0Cumulative = pair.price0CumulativeLast();
price1Cumulative = pair.price1CumulativeLast();
(uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast) =
pair.getReserves();
uint32 blockTimestamp = currentBlockTimestamp();
if (blockTimestampLast != blockTimestamp) {
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
price0Cumulative +=
uint(UQ112x112.encode(reserve1).uqdiv(reserve0)) * timeElapsed;
price1Cumulative +=
uint(UQ112x112.encode(reserve0).uqdiv(reserve1)) * timeElapsed;
}
注意,这里不会真的写 Pair 状态。
它只是 view 层面帮你算出:
如果现在更新 Pair,累计价格应该是多少
这很实用。
否则 Oracle 使用者必须主动调用 sync() 才能更新累计价格,太麻烦。
18. 为什么 V2 Oracle 不是“开箱即用价格源”?
Uniswap V2 Pair 只提供:
price0CumulativeLast
price1CumulativeLast
blockTimestampLast
它没有直接告诉你:
当前 ETH/USD TWAP 是多少
外部协议需要自己做:
1. 记录某个时间点的 cumulative price
2. 等待一段时间
3. 再读取新的 cumulative price
4. 用差值除以时间差
也就是说,V2 提供的是 Oracle building block。
不是完整 Oracle 产品。
如果协议不会正确使用时间窗口,还是可能出问题。
19. 一个 TWAP Oracle 合约大概怎么写?
最简化版本思路如下。
状态变量:
address public pair;
uint public price0CumulativeLast;
uint public price1CumulativeLast;
uint32 public blockTimestampLast;
uint public price0Average;
uint public price1Average;
初始化时记录:
price0CumulativeLast = pair.price0CumulativeLast();
price1CumulativeLast = pair.price1CumulativeLast();
(,, blockTimestampLast) = pair.getReserves();
更新时:
function update() external {
(
uint price0Cumulative,
uint price1Cumulative,
uint32 blockTimestamp
) = currentCumulativePrices(pair);
uint32 timeElapsed = blockTimestamp - blockTimestampLast;
require(timeElapsed >= PERIOD, 'PERIOD_NOT_ELAPSED');
price0Average =
(price0Cumulative - price0CumulativeLast) / timeElapsed;
price1Average =
(price1Cumulative - price1CumulativeLast) / timeElapsed;
price0CumulativeLast = price0Cumulative;
price1CumulativeLast = price1Cumulative;
blockTimestampLast = blockTimestamp;
}
查询时:
function consult(address token, uint amountIn)
external
view
returns (uint amountOut)
{
if (token == token0) {
amountOut = price0Average * amountIn;
} else {
amountOut = price1Average * amountIn;
}
}
当然真实代码要处理定点数、token decimals、精度缩放等问题。
但核心就是:
累计价格差 / 时间差
20. TWAP 窗口怎么选?
窗口越短:
价格越灵敏
但越容易被操纵
窗口越长:
越抗操纵
但越滞后
比如:
5 分钟 TWAP:
反应更快,但攻击成本较低。
1 小时 TWAP:
更稳,但遇到真实市场剧烈变化时会滞后。
24 小时 TWAP:
很稳,但作为实时抵押品价格可能太慢。
所以窗口选择要看使用场景。
对于借贷协议、清算系统、衍生品协议来说,Oracle 设计是高风险部分,不能只说“用了 TWAP 就安全”。
TWAP 只是提高操纵成本,不是绝对安全。
21. V2 Oracle 的一个重要特性:区块时间累计
Uniswap V2 白皮书里强调过一个点:
V2 的价格累计使用的是每个区块开始时的价格,也就是上一笔交易结束后的价格。
放到源码里理解,可以这样说:
_update() 使用旧 reserve 给过去 timeElapsed 记账;
然后才把 reserve 更新为本次操作后的新 balance。
因此,本次交易改变后的新价格,不会被记入刚刚过去的时间段。
同一区块内:
timeElapsed = 0
不会累计。
因此短时间、同区块内的操纵不容易直接进入累计价格。
这也是 V2 Oracle 相比直接 reserve 读取更安全的原因之一。
不过,矿工/验证者排序、低流动性池子、短窗口等问题仍然要认真考虑。
22. _update() 和 swap 的关系再看一遍
在 swap() 里,顺序是:
1. 读取旧 reserve
2. 转出 token
3. 可选回调
4. 读取当前 balance
5. 反推 amountIn
6. K 检查
7. _update(balance0, balance1, oldReserve0, oldReserve1)
_update() 做两件事:
先用 oldReserve 记录过去这段时间的价格累计
再把 reserve 更新成新 balance
这意味着:
本次 swap 改变后的新价格,不会被记入过去这段时间。
它会成为下一段时间的价格。
这个顺序非常重要。
如果顺序反了,Oracle 就会把刚刚被交易改变的价格错误地应用到过去时间段。
23. Oracle 和 sync() 的关系
Pair 里有一个函数:
function sync() external lock {
_update(
IERC20(token0).balanceOf(address(this)),
IERC20(token1).balanceOf(address(this)),
reserve0,
reserve1
);
}
sync() 会强制把 reserve 同步到当前 token balance。
它也会触发 _update(),因此会更新累计价格。
什么时候用?
比如有人直接给 Pair 转了 token,导致:
balance != reserve
调用 sync() 可以让 Pair 的 reserve 跟上真实余额。
不过对于 Oracle 来说,普通外部协议通常不需要依赖主动 sync()。
更常见的是用 currentCumulativePrices() 在读取时补算当前累计价格。
24. Oracle 和 skim() 的区别
Pair 里还有:
function skim(address to) external lock {
address _token0 = token0;
address _token1 = token1;
_safeTransfer(
_token0,
to,
IERC20(_token0).balanceOf(address(this)).sub(reserve0)
);
_safeTransfer(
_token1,
to,
IERC20(_token1).balanceOf(address(this)).sub(reserve1)
);
}
skim() 的作用是把多余 balance 转走。
也就是:
如果 balance > reserve,把多出来的部分转给 to
它不会调用 _update()。
所以:
sync:把 reserve 更新到 balance,会影响 Oracle 累计更新
skim:把 balance 拉回 reserve,不更新 reserve
这两个函数后面安全细节篇还会讲。
这里先记住:
sync 会触发 _update
skim 不会触发 _update
25. V2 Oracle 的优点
Uniswap V2 Oracle 的设计优点很明显。
第一,不需要额外喂价者
价格来自真实交易池。
没有中心化喂价者。
第二,抗瞬时操纵能力更强
它鼓励使用 TWAP,而不是当前 spot price。
第三,维护成本低
Pair 在正常 mint / burn / swap 时顺手累计价格。
不需要复杂外部系统。
第四,组合性强
任何协议都可以基于 cumulative price 构建自己的 Oracle。
第五,gas 成本相对可控
累计价格更新只发生在状态变化时。
没有每个区块自动写入。
26. V2 Oracle 的局限
当然,它也有局限。
第一,低流动性池子仍然容易被操纵
TWAP 提高攻击成本,但如果池子很浅,操纵一段时间也可能不贵。
第二,窗口选择困难
窗口短容易被操纵,窗口长又滞后。
第三,价格来自单一池子
如果只依赖一个 Pair,可能受到该 Pair 特定流动性和交易行为影响。
第四,不自动更新
如果长期没人交易,Pair 状态不会自动写入更新。
虽然可以用 currentCumulativePrices() 补算,但协议必须正确实现。
第五,不能直接处理复杂市场结构
比如跨多个交易所、多个链、多个池子的综合价格,V2 Pair 本身不提供。
所以 V2 Oracle 是一个基础组件,不是万能安全价格源。
27. 用一句话理解 V2 Oracle
如果只记一句:
Uniswap V2 Pair 不直接提供当前安全价格,而是记录“价格 × 时间”的累计值,让外部协议通过两个时间点的差值计算 TWAP。
再短一点:
V2 Oracle = cumulative price / elapsed time
它的核心不是“现在价格是多少”,而是:
过去这段时间,平均价格是多少。
28. _update() 的完整直觉
最后我们把 _update() 用中文重写一遍:
function _update(balance0, balance1, oldReserve0, oldReserve1):
1. 确保当前余额能塞进 uint112。
2. 取当前区块时间戳,压成 uint32。
3. 计算距离上次更新过了多少秒。
4. 如果时间确实经过了,并且旧 reserve 不为 0:
用旧 reserve 计算过去这段时间的价格。
price0Cumulative += price0 * timeElapsed。
price1Cumulative += price1 * timeElapsed。
5. 把 reserve0/reserve1 更新为当前 balance。
6. 更新 blockTimestampLast。
7. 发出 Sync 事件。
这个函数看着不长,但它同时服务于:
1. reserve 状态同步
2. TWAP 价格累计
3. Pair 状态事件通知
很密。
小结
这一篇我们拆了 Uniswap V2 的 Oracle 机制。
现在你应该能回答:
1. 为什么不能直接用 reserve 比例当安全价格?
因为当前 reserve 容易被短时间操纵。
2. TWAP 是什么?
Time Weighted Average Price,时间加权平均价格。
3. price0CumulativeLast 累计的是什么?
token0 用 token1 计价的价格 × 持续时间。
4. price1CumulativeLast 累计的是什么?
token1 用 token0 计价的价格 × 持续时间。
5. 为什么 _update 用旧 reserve 算价格?
因为旧 reserve 对应的是过去 timeElapsed 这段时间内持续的价格。
6. 为什么同一区块内 timeElapsed 为 0 不累计?
因为价格没有持续时间,不能贡献累计值。
7. UQ112x112 是什么?
一种 Q112.112 定点数格式,用来在 Solidity 中表示小数价格。
8. 外部协议怎么计算 TWAP?
用两个时间点的 cumulative price 差值除以时间差。
9. V2 Oracle 是完整价格源吗?
不是。它是构建 TWAP Oracle 的基础组件。
10. TWAP 一定安全吗?
不一定。它提高操纵成本,但仍依赖流动性深度、时间窗口和协议实现。
到这里,Pair 的几个核心机制已经基本覆盖:
mint / burn / swap
交易手续费
protocol fee
TWAP Oracle