Uniswap v2 中的 TWAP 预言机工作机制

本文详细探讨了Uniswap中的价格定义,强调价格作为一种比率的重要性,并介绍了时间加权平均价格(TWAP)的概念及其在防止价格操纵攻击中的作用。此外,文章深入分析了如何在Solidity中实现TWAP的计算和相关的智能合约设计,包括累积价格、快照机制和可能的溢出问题。

在 Uniswap 中“价格”到底是什么?

假设我们在一个池中有 1 个以太币和 2,000 个 USDC。这意味着以太币的价格是 2,000 USDC。具体来说,以太币的价格是 2,000 USDC / 1 Ether(忽略小数)。

更一般地说,资产的价格,在另一资产的价格方面,是一个比例,其中“你关心的资产”处于分母中。

$$ \mathsf{price}(\text{foo})=\frac{\mathsf{reserve}(\text{foo})}{\mathsf{reserve}(\text{bar})} $$

在上面的例子中,在问“需要多少个 bar 才能获得一个 foo”(忽略手续费)。

价格是一个比例

因为价格是一个比例,它们需要以具有小数点的数据类型来存储(而 Solidity 类型默认没有小数点)。

也就是说,我们说以太坊的价格是 2000,而 USDC(在以太坊价格中)是 0.0005(这是忽略了两个资产的小数部分)。

Uniswap 使用 固定点数字,在小数点的每一侧有 112 位的精度,这总共占用 224 位,当与 32 位数字打包时,它仅使用一个插槽。

预言机定义

在计算机科学术语中,预言机是“真相的来源”。价格预言机是价格的来源。当持有两个资产时,Uniswap 有一个隐含的价格,其他智能合约可以将其用作价格预言机。

预言机的预期用户是其他智能合约,因为其他智能合约可以轻松与 Uniswap 通信以确定价格,但从链外交易所获取价格数据要困难得多。

然而,仅仅通过余额的比例来获取当前价格并不安全。

TWAP 背后的动机

测量池中资产的瞬时快照留下了闪电贷攻击的机会。也就是说,某人可以利用闪电贷进行巨额交易,从而导致价格的暂时剧烈变化,然后利用另一个使用此价格做出决策的智能合约。

Uniswap V2 预言机以两种方式对其进行防御:

  1. 它为价格的使用者(通常是智能合约)提供了一种机制,以对先前一段时间的平均值进行计算(由用户决定)。这意味着攻击者必须持续操纵价格数个区块,这比使用闪电贷要昂贵得多。
  2. 它不将当前余额纳入预言机计算中。

这不应给人留下印象,即使用移动平均的预言机对价格操纵攻击免疫。如果资产流动性不足,或者进行平均的时间窗口不够大,那么资源充足的攻击者仍然可以在足够长的时间内支撑或压制价格,以操纵测量时的平均价格。

TWAP 的工作原理

TWAP(时间加权平均价格)类似于简单移动平均,只是时间价格“保持不变”的时间更长会得到更大的权重——TWAP 按 价格保持在某一水平的时间 加权价格。

  • 在过去的一天中,资产的价格在前 12 小时为 \$10,后 12 小时为 \$11。平均价格与时间加权平均价格相同:\$10.5。
  • 在过去的一天中,资产的价格在前 23 小时为 \$10,最近一小时为 \$11。预期的平均价格应更接近 \$10 而非 \$11,但仍在这两个值之间。具体来说,它将是 ($10 23 + \$11 1) / 24 = \$10.0417
  • 在过去的一天中,资产的价格在第一个小时为 \$10,最近 23 小时为 \$11。我们预计 TWAP 更接近 \$11 而非 10。具体来说,它将是 ($10 1 + \$11 23) / 24 = \$10.9583

一般来说,TWAP 的公式是

$$ \text{time-weighted average price} = \frac{P_1T_1+P_2T_2+\dots+P_nTn}{\sum{i=1}^nT_i} $$

这里 T 是持续时间,而不是时间戳。也就是说,价格在该水平保持了多久。

Uniswap V2 不存储回顾或分母

在我们上面的例子中,我们仅查看了过去 24 小时的价格,但如果你关心过去一小时、一周或其他某个时间段的价格呢?Uniswap 当然不能存储每个人可能感兴趣的每个回顾,并且也没有好的方法来始终快照价格,因为某人必须支付汽油费用。

解决方案是,Uniswap 仅存储值的分子——每当流动性比例发生变化(调用 mint、burn、swap 或 sync 时),它记录新的价格和 先前价格持续的时间。

twap 代码注释

变量 price0Cumulativelastprice1CumulativeLast 是公共的,因此感兴趣的方需要对它们进行快照。

但这一点非常重要,你应该始终记住,price0CumulativeLastprice1CumulativeLast 仅在上面代码的第 79 和 80 行(橙色圆圈)中更新,并且它们只会增加直到溢出。没有机制可以让它们“下降”。它们在每次调用 _update 时都会增加。这意味着它们自池启动以来已累计价格,这可能是很长一段时间。

限制回顾窗口

显然,我们通常不关心自池存在以来的平均价格。我们只想回顾某段时间(1小时、1天等)的价格。

这里是 TWAP 公式:

$$\text{all time TWAP price}=\frac{P_1T_1+P_2T_2+P_3T_3+P_4T_4+P_5T_5+P_6T_6}{\sum T}$$

如果我们只关心自 T4 以来的价格,那么我们想要做的是:

$$\text{TWAP price since T4}=\frac{P_4T_4+P_5T_5+P_6T_6}{T_4+T_5+T_6}$$

我们该如何用代码实现这个呢?因为 price0Cumulativelast 不断记录

$$\text{price0CumulativeLast}=P_1T_1+P_2T_2+P_3T_3+P_4T_4+P_5T_5+P_6T_6$$

我们需要一种方法来隔离我们关心的部分。考虑以下内容:

$$\text{RecentWindow}=\text{price0CumulativeLast}-\text{UpToTime3}$$

如果我们在 $T_3$ 结束时快照价格,我们得到值 UpToTime3。如果我们等到 $T_6$ 完成,然后执行 price0Cumulativelast - UpToTime3,我们将仅获得最近窗口的累积价格。如果将其除以最近窗口的持续时间 $(T_4 + T_5 + T_6)$,那么我们得到最近窗口的 TWAP 价格。

在图形上,这就是我们如何处理价格累加器。

twap 窗口图解

仅在 Solidity 中计算最近 1 小时的 TWAP

如果我们想要 1 小时的 TWAP,我们需要 预期 在一个小时后需要快照累加器。所以我们需要访问公共变量 price0CumulativeLast 和公共函数 getReserves() 以获取上次更新时间,并快照这些值。(请参见下面的 snapshot() 函数)。

在至少经过 1 小时后,我们可以调用 getOneHourPrice(),并从 Uniswap V2 访问 price0CumulativeLast 的最新值。

自从我们快照了旧的价格,以后 Uniswap 一直在更新累加器

$$P_iTi+P{i+1}T_{i+1}+…+P_nT_n$$

以下代码是为了说明目的尽可能简单,不建议用于生产环境。

contract OneHourOracle {
    using UQ112x112 for uint224; // 需要导入 UQ112x112

    IUniswapV2Pair uniswapV2pair;

    UQ112x112 snapshotPrice0Cumulative;
    uint32 lastSnapshotTime;

    function getTimeElapsed() internal view returns (uint32 t) {
        unchecked {
            t = uint32(block.timestamp % 2**32) - lastSnapshotTime;
        }
    }

    function snapshot() public returns (UQ112x112 twapPrice) {
        require(getTimeElapsed() >= 1 hours, "快照不新鲜");

        // 我们不使用储备,只需要最后的时间戳更新
        ( , , lastSnapshotTime) = uniswapV2pair.getReserves();
        snapshotPrice0Cumulative = uniswapV2pair.price0CumulativeLast;
    }

    function getOneHourPrice() public view returns (UQ112x112 price) {
        require(getTimeElapsed() >= 1 hours, "快照不够老");
        require(getTimeElapsed() < 3 hours, "价格太旧");

        uint256 recentPriceCumul = uniswapV2pair.price0CumulativeLast;

        unchecked {
            twapPrice = (recentPriceCumul - snapshotPrice0Cumulative) / timeElapsed;
        }
    }
}

如果最后一次快照超过三小时怎么办?

聪明的读者可能会注意到,以上合约将无法快照,如果它交互的交易对在过去三小时内没有进行过交互。Uniswap V2 函数 _updatemintburnswap 期间调用,但如果没有这些交互发生,那么 lastSnapshotTime 将记录较早的时间。解决方案是在进行快照时,由预言机调用 sync 函数,因为这将内部调用 _update

sync 函数的截图如下。

sync 函数截图

为什么 TWAP 必须跟踪两个比率

A 相对于 B 的价格只是 A/B,反之亦然。例如,如果我们在池中有 2000 个 USDC(忽略小数)和 1 个以太币,那么 1 个以太币的价格只是 2000 USDC / 1 ETH。

以 USDC 表示的价格,分母和分子翻转。

然而,当我们积累价格时,我们不能仅通过“反转”其中一个价格来获得另一个价格。考虑以下情况。如果我们的价格累加器从 2 开始增加到 3,我们不能只用累加器的倒数:

$$ \frac{1}{2+3}\neq\frac{1}{2}+\frac{1}{3} $$

然而,价格仍然是“有些对称”的,因此固定点算法的表示方式必须具有相同的整数和小数容量。如果以太币比 USDC “更有价值” 1,000 倍,那么 USDC 相对于以太币也应该 “少价值” 1,000 倍。为了准确存储这一点,固定点数字在小数两侧应具有相同的大小,因此 Uniswap 选择了 u112x112

PriceCumulativeLast 永远增加直到溢出,然后继续增加

Uniswap V2 在 Solidity 0.8.0 之前构建,因此算术默认会溢出和下溢。正确的现代实现的价格预言机需使用 unchecked 块以确保所有内容按预期溢出。

最终,priceAccumulators 和区块时间戳将会溢出。在这种情况下,之前的储备将高于新的储备。当预言机计算价格变化时,他们会得到一个负值。然而,由于模运算的规则,这并不重要。

为了简单起见,我们使用一个在 100 处溢出的假想无符号整数。

我们在 80 时快照了 priceAccumulator,在几次交易/区块后,priceAccumulator 增加到 110,但它溢出到 10。我们从 10 中减去 80,得到 -70。但是,这个值被存储为无符号整数,因此它给出了 -70 \mod(100),结果是 30。这与如果没有溢出的预期结果相同(110-80=30)。

对于所有溢出边界,这一规则都是适用的,而不仅仅是我们例子中的 100。溢出 timestamppriceAccumulator 不会造成问题,因为模运算的工作原理。

溢出时间戳

当我们溢出时间戳时,情况也是一样的。因为我们使用 uint32 来表示它,所以不会有任何负值。同样,为了简化,我们假设我们在 100 处溢出。如果我们在时间 98 时快照,在时间 4 时咨询价格预言机,那么过了 6 秒。4 - 98 \mod 100 = 6,如预期。

在 RareSkills 上了解更多信息

此材料是我们高级 Solidity 训练营 的一部分。有关更多信息,请查看该项目。

最初发表于 2023 年 11 月 3 日

  • 原文链接: rareskills.io/post/twap-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
RareSkills
RareSkills
https://www.rareskills.io/