跳转至

Uniswap V2 价格精度解析

从 DeFi 开发者的角度,深入解析 Uniswap V2 白皮书中关于价格精度的内容。这是 Uniswap V2 工程设计的精华之一,体现了在 EVM 约束下的极致优化思维。

Uniswap V2 白皮书 2.2.1 这章节主要在回答三个问题:

  1. 链上怎么表示“价格”这种小数?
  2. 为什么是 112+112,跟 224 / 256 位、gas、存储布局有什么关系?
  3. 为什么要把时间戳压成 32 位、溢出之后怎么还能正确算 TWAP?

一、UQ112.112 是什么?

1.1 Q格式的通用定义

Qm.n 是一种表示定点数的格式,“Q” 代表“二进制小数点位置”(binary point position)。它是一种命名惯例,用于明确指示在一个整数中,哪里是整数部分和小数部分的分界线。其中:

  • m:代表整数部分(在小数点左边)占用的位数;
  • n:代表小数部分(在小数点右边)占用的位数;
  • 总数:m + n 位,通常对应一个标准整数类型(如 int32, uint16 等)。

1.2 为什么需要定点数格式?

Solidity(以及EVM)原生只支持整数运算,没有浮点数类型,但价格天然是小数,例如:1 ETH = 3049.5 USDC。

传统方案的问题:如果直接用 reserve_a / reserve_b 计算价格,Solidity 会做整数除法,丢失所有小数部分,比如 1 / 2000 = 0(整数除法)。

Uniswap V2 的解决方案:使用定点数(Fixed Point)格式 UQ112.112UQ112.112Qm.n 格式的一个变体:

  • U:代表 Unsigned(无符号),这个数永远是正的,因为价格不可能为负;
  • Q112.112:表示这是一个定点数,其中:

    • 小数点左边有 112 位(用于表示整数部分);
  • 小数点右边有 112 位(用于表示小数部分);
  • 总计 224 位 (112 + 112)。

Uniswap V2 白皮书原文:

prices at a given moment are stored as UQ112.112 numbers, meaning that 112 fractional bits of precision are specified on either side of the decimal point, with no sign. These numbers have a range of [0, 2¹¹² − 1] and a precision of 1 / 2¹¹².

翻译一下,就是:“我们没有浮点数,只好自己造一个二进制定点小数格式:把一个数拆成两部分:

  • 前 112 位:整数部分(0 ~ 2¹¹² − 1)
  • 后 112 位:小数部分(精度 2⁻¹¹²)
  • 不带符号(unsigned),只表示非负。”

1.3 范围为什么是 [0, 2¹¹² − 1]?

这里指的是价格的整数部分的范围,不是整个 224 位数值整体的范围。

白皮书脚注解释得很清楚:Uniswap 里这个 UQ112.112 的价格永远来自两个 uint112 储备的比值

UQ112.112 numbers in Uniswap are always generated from the ratio of two uint112s. The largest such ratio is (2¹¹²−1) / 1 = 2¹¹² − 1.

在 UniswapV2Pair.sol 合约中,reserve0reserve1 被定义为 uint112 类型,最大价格情况是:一边储备接近 2¹¹²−1,另一边接近 1,所以价格最大 ≈ (2¹¹²−1) / 1 ≈ 2¹¹²−1

补充:储备金 reserve0reserve1 是整数计数(uint112),没有小数位,表示池子里真实存在的 Token 数量,是不可再细分的最小单位(wei、最小 ERC20 单位),理论上储备最小能到 1,最大为 2¹¹²−1

这保证了整数部分最多需要 112 位就够了(0 ~ 2¹¹²−1),小数部分再给 112 位,整体 224 位,刚好放进 uint224

概念 值范围 数据类型 用途
reserve0, reserve1 0 ~ 2¹¹²−1 uint112 储备数量(整数)
price = reserve1/reserve0 0 ~ 2¹¹²−1.xxx UQ112.112 价格(小数 + 高精度)
UQ112.112 小数位 2⁻¹¹² 底层位移 表示价格的小数部分

精度上,一个 UQ112.112 的最小单位是 1 / 2¹¹²,大约 10⁻³⁴ 量级,远远比 1e-18 还细,对于现实中 token 18 decimals、价格几位小数的应用,这个精度完全够用且浪费一点没关系,重点是方便数学和存储布局。

二、 为什么是 112+112?存储槽的精妙设计

这是整个设计最巧妙的地方,涉及到Gas优化存储布局

EVM 存储槽基础:EVM 的一个存储槽(storage slot)= 256位,每次 SSTORE 操作(写入存储)消耗大量 Gas(~20,000 gas),打包存储(packing)可以在一个槽内存多个变量,节省 Gas。

白皮书关键句:

The UQ112.112 format was chosen for a pragmatic reason — because these numbers can be stored in a uint224, this leaves 32 bits of a 256 bit storage slot free. It also happens that the reserves, each stored in a uint112, also leave 32 bits free in a (packed) 256 bit storage slot.

从以太坊合约存储视角来看就是:

  • 储备金存储:每个储备金(uint112)用了 112 位,两个 uint112 可以紧密打包在一个 256 位的存储槽里,留下 256 - 2*112 = 32 位空闲。
  • 价格存储:价格作为两个 uint112 储备金的比率,自然可以表示为一个 UQ112.112 数,它刚好能装进一个 uint224(224位)。
  • 空闲位的利用:核心来了!那个空闲的 32 位(来自储备金存储槽)和价格存储槽末尾的 32 位,被巧妙地用来:

    • 存储时间戳(模 2^32,刚好 32 位);
  • 存储价格累加过程中的溢出位(因为连续累加可能超过 224 位)。

不支持浮点数的 EVM 中,用整数运算来模拟高精度的小数运算,同时完美契合存储布局,将预言机更新的 Gas 成本降至最低(仅首次交易增加约 15,000 gas)。

2.1 Uniswap V2的存储布局

实际代码结构(简化版):

contract UniswapV2Pair {
    uint112 private reserve0;           // 112位
    uint112 private reserve1;           // 112位  
    uint32  private blockTimestampLast; // 32位
    // 总共: 112 + 112 + 32 = 256位 = 1个存储槽!

    uint224 public price0CumulativeLast; // 224位
    uint32  private overflowBits0;       // 32位(溢出部分)
    // 总共: 224 + 32 = 256位 = 1个存储槽!

    uint224 public price1CumulativeLast; // 224位
    uint32  private overflowBits1;       // 32位(溢出部分)
    // 总共: 224 + 32 = 256位 = 1个存储槽!
}

Solidity 的 storage packing 规则:在同一个 slot(256bit) 里尽量塞满紧挨着的小类型:

  • 112 + 112 + 32 = 256
  • 刚好塞满一个 256 位槽,不浪费、不多用 gas。

这就是“packed 256 bit storage slot”的含义:两个储备 + 1 个 32bit 时间戳,整整齐齐放在一个槽里。

2.2 累积价格布局:为什么需要额外的32位?

白皮书接着说:

although the price at any given moment (stored as a UQ112.112 number) is guaranteed to fit in 224 bits, the accumulation of this price over an interval is not. The extra 32 bits on the end of the storage slots for the accumulated price of A/B and B/A are used to store overflow bits resulting from repeated summations of prices.

白皮书说:"虽然任何给定时刻的价格保证适合224位,但该价格在一个区间内的累积则不然。"

  • 瞬时价格:UQ112.112,用 uint224 就够存;
  • 累积价格:priceCumulativeLast += price * timeElapsedprice 是 UQ112.112(224 位),timeElapseduint32 秒数,每次乘法结果可能需要 224 + 32 = 256 位,再不断累加,肯定会“理论上溢出 224 位”。
if (timeElapsed > 0 && _reserve0 != 0 && _reserve1 != 0) {
    price0CumulativeLast += uint(
        UQ112x112.encode(_reserve1).uqdiv(_reserve0)
    ) * timeElapsed;
    price1CumulativeLast += uint(
        UQ112x112.encode(_reserve0).uqdiv(_reserve1)
    ) * timeElapsed;
}

注意几点:

  • UQ112x112.encode(...).uqdiv(...) 返回的是一个 uint224 的 UQ112.112 数。
  • uint(...) 转成 uint256,再乘 timeElapsed (uint32),整体用 256 位做算术。
  • 溢出行为是按 mod 2²⁵⁶ 来的(Solidity 0.5/0.6 下,默认 wrap around)。

直觉理解:“我用 224 位存价格小数,额外开了 32 位当缓冲区,给长期累加留空间。真正拿来算 TWAP 是用差值除以时间间隔,所以只要下游用同样的 ‘取模 + 溢出安全算术’,就能正确恢复平均值。”

三、时间戳的32位限制与溢出安全

白皮书这段话是容易疑惑的地方:

The primary downside is that 32 bits isn't quite enough to store timestamp values that will reasonably never overflow. In fact, the date when the Unix timestamp overflows a uint32 is 02/07/2106. To ensure that this system continues to function properly after this date, and every multiple of 2³² - 1 seconds thereafter, oracles are simply required to check prices at least once per interval (approximately 136 years). This is because the core method of accumulation (and modding of timestamp), is actually overflow-safe, meaning that trades across overflow intervals can be appropriately accounted for given that oracles are using the proper (simple) overflow arithmetic to compute deltas.

翻译成中文来理解:

32 位的主要缺点在于:它不足以存储“永远不会溢出”的时间戳值。事实上,Unix 时间戳在 uint32 中溢出的日期是 2106 年 2 月 7 日。为了确保这个系统在该日期之后仍能正常运行,并在之后每经过 2³² - 1 秒(大约 136 年)再次溢出时继续正常工作,预言机(oracles)只需要做到一件事:在每个周期里至少检查一次价格(也就是大约每 136 年检查一次)。

这是因为累积(accumulation)以及对时间戳进行取模(modding of timestamp)的核心方法本身是溢出安全的。只要预言机在计算时间差(delta)时使用正确的(简单的)溢出算术,即使交易跨越了时间戳溢出的间隔,也仍然能够被正确记账。

3.1 为什么时间戳要 mod 2³²?

这跟前面说的“两个 uint112 + 一个 uint32 拼在同一槽”是同一个设计:

block.timestamp 是一个不断增大的 Unix 时间戳(秒),为了塞进 32 位,要做:uint32 blockTimestamp = uint32(block.timestamp % 2**32),存的时候只保留低 32 位(对 2³² 取模)。

更新时 _update() 的流程(简化):

uint32 blockTimestamp = uint32(block.timestamp % 2**32);
uint32 timeElapsed = blockTimestamp - blockTimestampLast; // 注意:这是 uint32 减法
// timeElapsed 就是“自上次更新以来经过的秒数”,但有溢出语义

这一步的关键:uint32 的减法在溢出时自动按 2³² 取模。

在没溢出的一般情况下:timeElapsed = current - last

在经历过一次 2³² 溢出(时间戳绕了一圈):假设 last = 2³² - 10current = 5(绕了一圈 +5 秒),timeElapsed = 5 - (2³² - 10),在 uint32 下等价于:timeElapsed = 5 + 10 = 15,也就是:实际经过的时间(10 秒到圈尾 + 5 秒到当前)。

这就是白皮书所说的“overflow-safe accumulation”:只要始终用同样的 32 位取模 + 溢出语义,不需要关心时间戳“绕了几圈”,每次 delta 都是正确的。

3.2 为什么说“溢出安全”?

2³² 秒 ≈ 4,294,967,296 秒 ≈ 136 年。

核心问题在于:TWAP 的调用方也要用同样的溢出算术

调用方通常这样算价格(来自白皮书 2.2):

  • 在 t1 时刻读取一次 priceCumulativeLast,记作 a1
  • 在 t2 时刻再读一次 a2
  • TWAP ≈ (a2 - a1) / (t2 - t1)

如果:

  • t1 和 t2 之间跨越了多次 2³² 溢出周期(比如隔了 300 年,绕了两圈多),那么单纯用 32 位时间戳差值就不够区分了。
  • 但只要每个 2³² 秒(约 136 年)内至少读一次,你不会跨越超过一圈的模数,t2 - t1 的 32 位溢出语义仍能正确代表真实经过的秒数。

举个例子:假设

  • t1 =2^32-100(溢出前100秒)
  • t2 =50(溢出后50秒,实际时间戳)

直接相减会出错:50 - (2^32 - 100) = 负数,但使用 uint32 的模运算:

时间差 = uint32(t2 - t1) = uint32(50-(2^32-100)) = uint32(-2^32+150) = 150 秒

只要两次采样间隔 < 2^32秒(136年),计算就是正确的。