本文详细探讨了Uniswap中的价格定义,强调价格作为一种比率的重要性,并介绍了时间加权平均价格(TWAP)的概念及其在防止价格操纵攻击中的作用。此外,文章深入分析了如何在Solidity中实现TWAP的计算和相关的智能合约设计,包括累积价格、快照机制和可能的溢出问题。
假设我们在一个池中有 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 通信以确定价格,但从链外交易所获取价格数据要困难得多。
然而,仅仅通过余额的比例来获取当前价格并不安全。
测量池中资产的瞬时快照留下了闪电贷攻击的机会。也就是说,某人可以利用闪电贷进行巨额交易,从而导致价格的暂时剧烈变化,然后利用另一个使用此价格做出决策的智能合约。
Uniswap V2 预言机以两种方式对其进行防御:
这不应给人留下印象,即使用移动平均的预言机对价格操纵攻击免疫。如果资产流动性不足,或者进行平均的时间窗口不够大,那么资源充足的攻击者仍然可以在足够长的时间内支撑或压制价格,以操纵测量时的平均价格。
TWAP(时间加权平均价格)类似于简单移动平均,只是时间价格“保持不变”的时间更长会得到更大的权重——TWAP 按 价格保持在某一水平的时间 加权价格。
一般来说,TWAP 的公式是
$$ \text{time-weighted average price} = \frac{P_1T_1+P_2T_2+\dots+P_nTn}{\sum{i=1}^nT_i} $$
这里 T 是持续时间,而不是时间戳。也就是说,价格在该水平保持了多久。
在我们上面的例子中,我们仅查看了过去 24 小时的价格,但如果你关心过去一小时、一周或其他某个时间段的价格呢?Uniswap 当然不能存储每个人可能感兴趣的每个回顾,并且也没有好的方法来始终快照价格,因为某人必须支付汽油费用。
解决方案是,Uniswap 仅存储值的分子——每当流动性比例发生变化(调用 mint、burn、swap 或 sync 时),它记录新的价格和 先前价格持续的时间。
变量 price0Cumulativelast
和 price1CumulativeLast
是公共的,因此感兴趣的方需要对它们进行快照。
但这一点非常重要,你应该始终记住,price0CumulativeLast
和 price1CumulativeLast
仅在上面代码的第 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 价格。
在图形上,这就是我们如何处理价格累加器。
如果我们想要 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 函数 _update
在 mint
、burn
和 swap
期间调用,但如果没有这些交互发生,那么 lastSnapshotTime
将记录较早的时间。解决方案是在进行快照时,由预言机调用 sync 函数,因为这将内部调用 _update
。
sync 函数的截图如下。
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
。
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
。溢出 timestamp
或 priceAccumulator
不会造成问题,因为模运算的工作原理。
当我们溢出时间戳时,情况也是一样的。因为我们使用 uint32
来表示它,所以不会有任何负值。同样,为了简化,我们假设我们在 100
处溢出。如果我们在时间 98
时快照,在时间 4
时咨询价格预言机,那么过了 6
秒。4 - 98 \mod 100 = 6
,如预期。
此材料是我们高级 Solidity 训练营 的一部分。有关更多信息,请查看该项目。
最初发表于 2023 年 11 月 3 日
- 原文链接: rareskills.io/post/twap-...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!