Uniswap 中的“价格”到底是什么?
假设我们在一个池子中有 1 个 Ether 和 2,000 个 USDC。这意味着 Ether 的价格是 2,000 USDC。具体来说,Ether 的价格是 2,000 USDC / 1 Ether(忽略精度)。
更一般地讲,一项资产以交易对中另一种资产计价时的价格是一个比率,其中“你所关注的资产”位于分母中。
在上面的例子中,它的意思是“你需要支付多少个 bar 才能获得一个 foo”(忽略手续费)。
价格是一个比率
由于价格是一个比率,它们需要使用带有小数的数据类型来存储(而 Solidity 默认的数据类型没有小数)。
也就是说,我们可以说 Ethereum 是 2000,而 USDC(以 Ethereum 计价)是 0.0005(这里忽略了这两种资产的精度)。
Uniswap 使用了小数点两侧各具 112 位精度的定点数,总共占用 224 位,当它与一个 32 位的数字打包在一起时,恰好用掉一个 slot。
预言机的定义
在计算机科学术语中,预言机(oracle)是一个“事实来源”(source of truth)。价格预言机即价格的来源。Uniswap 持有两种资产时存在一个隐含价格,其他智能合约可以将其用作价格预言机。
该预言机的目标用户是其他智能合约,因为其他智能合约可以很容易地与 Uniswap 通信来确定价格,但从链下交易所获取价格数据则要困难得多。
然而,仅仅通过获取余额的比率来得出当前价格是不安全的。
TWAP 背后的动机
测量池中资产的瞬时快照会给闪电贷攻击留下可乘之机。也就是说,有人可以使用闪电贷进行巨额交易,导致价格出现暂时的剧烈波动,然后利用另一个依赖该价格做决定的智能合约来牟利。
Uniswap V2 预言机通过以下两种方式防御这种情况:
- 它为价格的消费者(通常是智能合约)提供了一种机制,用于获取过去一段时间(由用户决定)内的平均价格。这意味着攻击者必须在多个区块中持续操纵价格,这比使用闪电贷的成本高得多。
- 它不会将当前余额纳入预言机的计算之中。
这不应让人误以为使用移动平均值的预言机对价格操纵攻击免疫。如果资产流动性不足,或者计算平均值的时间窗口不够长,那么资金充裕的攻击者仍然可以在足够长的时间内拉升(或打压)价格,从而在测量时操纵平均价格。
TWAP 是如何工作的
TWAP(时间加权平均价格)类似于简单移动平均线,不同之处在于,价格“保持不变”时间较长的时段会获得更大的权重——TWAP 是根据价格在某一水平停留的时间长短来进行加权的。
- 在过去的一天里,某项资产的价格在前 12 小时为 $10,后 12 小时为 $11。其平均价格与时间加权平均价格相同:$10.5。
- 在过去的一天里,某项资产的价格在前 23 小时为 $10,在最近 1 小时为 $11。预期的平均价格应更接近 $10 而不是 $11,但它仍将介于这两个数值之间。具体来说,它将是 ($10 * 23 + $11 * 1) / 24 = $10.0417
- 在过去的一天里,某项资产的价格在第 1 个小时为 $10,在最近的 23 小时为 $11。我们预期 TWAP 将更接近 $11 而不是 10。具体来说,它将是 ($10 * 1 + $11 * 23) / 24 = $10.9583
一般来说,TWAP 的公式为:
这里的 T 是一个持续时间,而不是时间戳。即价格在该水平停留了多长时间。
Uniswap V2 不存储回溯窗口或分母
在上面的例子中,我们只观察了过去 24 小时的价格,但如果你关心的是过去 1 小时、过去一周或其他时间间隔的价格呢?Uniswap 当然无法存储所有人们可能感兴趣的回溯数据,同时也没有一个好方法去持续地对价格进行快照,因为这需要有人来支付 gas 费用。
解决方案是,Uniswap 仅存储计算值的分子——每次流动性比率发生变化时(当 mint、burn、swap 或 sync 被调用时),它会记录新价格以及前一个价格持续了多长时间。

变量 price0Cumulativelast 和 price1CumulativeLast 是 public 的,因此感兴趣的相关方需要对它们进行快照。
但请务必记住重要的一点:price0CumulativeLast 和 price1CumulativeLast 仅在上方代码的第 79 行和第 80 行(橙色圆圈)被更新,并且它们只能不断增加直到溢出。没有任何机制能让它们“减小”。每次调用 _update 时它们总是增加。这意味着它们累加了自流动性池启动以来的所有价格,这可能是一段非常漫长的时间。
限制回溯窗口
显然,我们通常对自流动性池创建以来的平均价格并不感兴趣。我们只想回溯特定的一段时间(例如 1 小时、1 天等)。
下面是之前展示过的 TWAP 公式。
如果我们只对 T4 以后的价格感兴趣,那么我们需要做如下计算:
我们如何在代码中实现这一点呢?由于 price0Cumulativelast 持续记录着:
我们需要一种方法将我们关心的部分分离出来。请考虑以下等式:
如果我们在 结束时对价格进行快照,我们会得到值 UpToTime3。如果我们等到 结束,执行 price0Cumulativelast - UpToTime3,然后我们就将得到仅仅是近期窗口的累加价格。如果我们将它除以 RecentWindow 的持续时间 ,就能得到近期窗口的 TWAP 价格。
在图解上,这就是我们在处理价格累加器时所做的事情。

在 Solidity 中仅计算过去 1 小时的 TWAP
如果我们想要一个 1 小时 TWAP,我们需要预先知道将在一小时后用到累加器的快照。因此,我们需要访问公开变量 price0CumulativeLast 和公开函数 getReserves() 来获取上一次的更新时间,并将这些值进行快照。(见下文的 snapshot() 函数)。
在经过至少 1 小时后,我们可以调用 getOneHourPrice(),接着将从 Uniswap V2 中访问 price0CumulativeLast 的最新值。
自我们对旧价格进行快照以来,Uniswap 一直在更新这个累加器:
以下代码仅为说明目的尽可能地简化了,不建议在生产环境中使用。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@uniswap/v2-core/contracts/interfaces/IUniswapV2Pair.sol";
import "@uniswap/lib/contracts/libraries/UQ112x112.sol";
contract OneHourOracle {
using UQ112x112 for uint224;
IUniswapV2Pair public uniswapV2Pair;
uint256 public snapshotPrice0Cumulative;
uint32 public lastSnapshotTime;
constructor(address _uniswapV2Pair) {
uniswapV2Pair = IUniswapV2Pair(_uniswapV2Pair);
}
function getTimeElapsed() internal view returns (uint32 t) {
unchecked {
t = uint32(block.timestamp % 2**32) - lastSnapshotTime;
}
}
function snapshot() public {
require(getTimeElapsed() >= 1 hours, "snapshot is not stale");
// we don't use the reserves, just need the last timestamp update
(, , lastSnapshotTime) = uniswapV2Pair.getReserves();
snapshotPrice0Cumulative = uniswapV2Pair.price0CumulativeLast();
}
function getOneHourPrice() public view returns (uint224 price) {
require(getTimeElapsed() >= 1 hours, "snapshot not old enough");
require(getTimeElapsed() < 3 hours, "price is too stale");
uint256 recentPriceCumulative = uniswapV2Pair.price0CumulativeLast();
uint32 timeElapsed = getTimeElapsed();
unchecked {
price = uint224((recentPriceCumulative - snapshotPrice0Cumulative) / timeElapsed);
}
}
}
如果上一次快照是在三小时之前怎么办?
敏锐的读者可能会注意到,如果该合约交互的交易对在过去三小时内没有任何交互,上述合约将无法执行快照。Uniswap V2 的 _update 函数会在 mint、burn 和 swap 期间被调用,如果这些交互都没有发生,那么 lastSnapshotTime 记录的就会是之前某个时间点。解决方案是在预言机执行快照的时候调用 sync 函数,因为该函数内部会调用 _update。
sync 函数的截图如下所示。

为什么 TWAP 必须跟踪两个比率
A 相对于 B 的价格就是简单的 A/B,反之亦然。例如,如果我们池中有 2000 个 USDC(忽略精度)和 1 个 Ether,那么 1 个 Ether 的价格就是简单的 2000 USDC / 1 ETH。
以 ETH 计价的 USDC 的价格,只需将该数字的分子和分母翻转即可。
然而,在累加价格时,我们不能仅仅通过“取倒数”来从一个价格得出另一个价格。请看下面的例子。如果我们的价格累加器从 2 开始并且加了 3,我们不能只对累加器求倒数:
然而,价格在某种程度上仍然是“对称的”,因此在选择定点数的算术表示时,整数和小数部分必须具有相同的容量。如果 Eth 比 USDC “贵重” 1,000 倍,那么 USDC 的“价值”就比 Eth 低 1,000 倍。为了准确存储这一点,定点数在小数点两侧应该具有相同的大小,这就是 Uniswap 选择 u112x112 的原因。
PriceCumulativeLast 始终增加,直到溢出,然后继续增加
Uniswap V2 是在 Solidity 0.8.0 之前构建的,因此默认情况下算术运算会发生溢出(overflow)和下溢(underflow)。现代价格预言机的正确实现需要使用 unchecked 代码块,以确保一切都按预期发生溢出。
最终,priceAccumulators 和区块时间戳(timestamp)都会溢出。在那种情况下,前一个储备值将高于新的储备值。当预言机计算价格的变化时,将得到一个负值。然而,由于模算术(modular arithmetic)的规则,这并不会产生影响。
为了简单起见,让我们想象一个会在 100 溢出的无符号整数。
我们在 80 时对 priceAccumulator 进行快照,在几笔交易/几个区块之后,priceAccumulator 达到了 110,但它溢出变为了 10。我们从 10 中减去 80,得到 -70。但由于该值被存储为无符号整数,所以实际计算的是 -70 mod(100),即 30。这与未溢出时我们预期的结果(110-80=30)是一致的。
这适用于所有的溢出边界,而不仅仅是我们例子中的 100。得益于模算术的工作原理,timestamp 或 priceAccumulator 的溢出不会引起任何问题。
时间戳溢出
当 timestamp 溢出时,情况也是一样的。因为我们使用的是 uint32 来表示它,所以不会有任何负数。再次为了简单起见,让我们假设在 100 处溢出。如果我们在时间 98 进行快照,然后在时间 4 查询价格预言机,那么此时已经过去了 6 秒。正如预期的一样,4 - 98 % 100 = 6。
通过 RareSkills 了解更多
此材料是我们高级 Solidity Bootcamp 的一部分。请查看该项目以了解更多信息。
首发于 2023 年 11 月 3 日