区块链 区块链技术 比特币公众号手机端

Uniswap V2 的数学引擎:UQ112x112 定点数库解析

liumuhui 3小时前 阅读数 1 #区块链

Uniswap V2 的数学引擎:UQ112x112 定点数库解析

Uniswap V2 在没有浮点数的 EVM 上实现了精确的价格计算,核心是一个仅 20 行的定点数库 UQ112x112.sol。本文拆解它的二进制布局、溢出边界、与 TWAP 的联动,以及为什么选 112 这个数字。


一、前置知识

1.1 EVM 没有浮点数

EVM 指令集里没有浮点运算 opcode,Solidity 只有整数类型。直接做除法会截断小数:

uint a = 1;
uint b = 3;
uint c = a / b;  // c = 0,不是 0.333...

对 DeFi 来说这是致命的——价格 1/3 会算成 0,TWAP 预言机直接失效。

1.2 Q 格式定点数

定点数的思路是把小数放大 2 的 n 次方倍,用整数存储,运算时保持放大倍数不变,需要时再缩放回去。就像没有小数时把 1.5 元存成 150 分,全程按分运算,展示时再除以 100。

记作 Qm.n:m 位整数 + n 位小数,共 m+n 位。Uniswap V2 选的是 UQ112.112(U = 无符号):112 位整数 + 112 位小数 = 224 位,装入 uint224

小数精度为 1/2^112 ≈ 1.93e-34,比普朗克长度还细。这个精度看似过剩,实则是为了在 TWAP 长期累积中消灭舍入误差。


二、源码与二进制布局

2.1 完整源码

library UQ112x112 {
    uint224 constant Q112 = 2**112;

    // encode a uint112 as a UQ112x112
    function encode(uint112 y) internal pure returns (uint224 z) {
        z = uint224(y) * Q112; // never overflows
    }

    // divide a UQ112x112 by a uint112, returning a UQ112x112
    function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) {
        z = x / uint224(y);
    }
}

2.2 二进制布局

224 位串中,隐含小数点在第 112 位与第 113 位之间:

 高 112 位 (整数部分)      低 112 位 (小数部分)
┌──────────────────────┬──────────────────────┐
│  Integer  (111..0)   │  Fraction (111..0)   │
│     [bit 223]        │      [bit 0]         │
└──────────────────────┴──────────────────────┘
                       ▲
                  隐含小数点

真实数值 = 存储值 / 2^112。例如存储值 3 * 2^112 对应真实值 3.0,存储值 5 * 2^111 对应真实值 2.5

2.3 encode:左移 112 位

encode(y) = y * 2^112,二进制层面就是把 y 左移 112 位、低位补零。和十进制里乘以 10 末尾补零同理,5 * 1000 = 5000

y 的二进制:        0000...0101  (112 位)
                  ↓ 左移 112 位
y * 2^112:  0000...0101 0000...0000  (224 位)
            ├─整数部分─┤├─小数部分─┤

encode(5) 的真实值就是 5.0。用乘法而非 << 移位,是因为语义更清晰,且 Q112 在编译期就确定为常量,运行时零开销。

2.4 uqdiv:保持精度的除法

设 x 是 UQ112x112 数,存储值 = 真实值 * 2^112:

z = x / y
  = (真实值 * 2^112) / y
  = (真实值 / y) * 2^112

放大系数 2^112 原封不动保留,结果仍是 UQ112x112。被除数已预放大,除法不损失小数精度。

对比整数除法:

直接整数除法:  100 / 3 = 33        (丢失 0.333...)
UQ112x112:    encode(100) / 3
            = (100 * 2^112) / 3
            = 33.333... * 2^112    (小数部分完整保留)

价格 price0 = reserve1 / reserve0,用整数除法当 reserve0 > reserve1 时价格归零;用 UQ112x112 则保留完整小数。


三、溢出证明

3.1 encode 永不溢出 uint224

注释 // never overflows 可严格证明。用最坏情况代入:

  1. uint112 最大值 = 2^112 - 1
  2. encode 结果 = y * 2^112,当 y 取最大时结果最大
  3. 最大结果 = (2^112 - 1) * 2^112 = 2^224 - 2^112
  4. uint224 最大值 = 2^224 - 1
  5. 比较:2^224 - 2^1122^224 - 1。两边都从 2^224 里减去一个数,左边减 2^112,右边减 12^112 远大于 1,所以 2^224 - 2^112 < 2^224 - 1,就像 100 - 50 < 100 - 1

结论:encode 结果最大到 2^224 - 2^112,永远到不了 uint224 的天花板。证毕。

3.2 TWAP 累积的 "never overflows"

UniswapV2Pair.sol 的 _update 中:

// * never overflows, and + overflow is desired
price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;

这是 uint256 * uint32,极端情况下会触及边界:

  • 价格部分最大:reserve1 取最大、reserve0 = 1 时,约 2^224
  • 时间部分最大:uint32 上限约 2^32
  • 乘积上界:2^224 * 2^32 = 2^256,恰好是 uint256 上界

理论上极端构造下会溢出,但这里有三层保护:

  1. Solidity 0.5.16 没有溢出检查(0.8.0 才有),乘法溢出静默回绕,不 revert
  2. + overflow is desired:TWAP 链下算的是两次采样的差值,模 2^256 下差值依然正确,只要采样间隔内没绕回一整圈(实际不可能)
  3. 真实市场中 reserve1/reserve0 远小于 2^224timeElapsed 通常十几秒,离溢出边界有天文数字距离

所以这处注释是工程意义上的安全,不是数学意义上的绝对安全。


四、与 TWAP 预言机的联动

4.1 存储布局

uint112 private reserve0;           // uses single storage slot
uint112 private reserve1;           // uses single storage slot
uint32  private blockTimestampLast; // uses single storage slot

三者共用一个 256 位存储槽:

┌──────────────┬──────────────┬────────────────────────────┐
│  reserve0    │  reserve1    │  blockTimestampLast        │
│  (112 bits)  │  (112 bits)  │  (32 bits)                 │
│  112 + 112 + 32 = 256 bits                              │
└──────────────┴──────────────┴────────────────────────────┘

getReserves() 一次 SLOAD 读出三个值。_updatemintburnswap 开头都调它。

4.2 _update 函数

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; // overflow is desired
    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);
}

核心逻辑提炼:

if timeElapsed > 0 and reserve0_old > 0 and reserve1_old > 0:
    # price0 = reserve1 / reserve0,用 UQ112x112 避免截断
    price0_fixed = encode(reserve1_old) / reserve0_old
                 = (reserve1_old * 2^112) / reserve0_old
                 = (reserve1_old / reserve0_old) * 2^112
    # 累积:价格 * 时间
    price0CumulativeLast += price0_fixed * timeElapsed

4.3 为什么 UQ112x112 是 TWAP 的关键

看个实际场景(e 记法:4e20 = 4 后面 20 个零):

  • reserve0(USDC,6 位小数)= 100 万 USDC = 1e12
  • reserve1(WETH,18 位小数)= 400 WETH = 4e20
  • price0 = 4e20 / 1e12 = 4e8

池子比例反过来,USDC 多 WETH 少时:

  • reserve0 = 1e12,reserve1 = 1e6
  • 整数除法:1e6 / 1e12 = 0,价格归零,TWAP 失真
  • UQ112x112:encode(1e6) / 1e12 = (1e6 * 2^112) / 1e12 = 1e-6 * 2^112,真实值约 1e-6,精度完整保留

TWAP 是长期累积值。每次更新丢 1 bit,数百万区块累积后误差指数放大。112 位小数确保任何合理周期内舍入误差低于 1 wei。

4.4 链下计算

外部预言机读 price0CumulativeLast 算 TWAP:

TWAP = (累积值(t2) - 累积值(t1)) / (t2 - t1) / 2^112

累积值存的是"价格 * 2^112 * 时间"的累加和,差分除时间得"价格 * 2^112",再除 2^112 还原。


五、为什么是 112

112 是三个独立约束的交集,不是随便选的。

约束一:Slot 打包

EVM 存储槽 256 位,要塞下 reserve0 + reserve1 + blockTimestampLast。blockTimestampLastuint32(2^32 秒 ≈ 136 年,够用),留给两个 reserve:

256 - 32 = 224,每个 reserve 112 位
112 + 112 + 32 = 256,零浪费

选 128 的话 128 + 128 = 256,时间戳得单独占 slot,每次 getReserves() 多一次 SLOAD(+2100 gas),mint/burn/swap 高频路径全部变贵。

约束二:encode 结果装得下

encode(y) = y * 2^n,结果位数 = 输入位数 + n。

  • UQ112x112:112 + 112 = 224,装入 uint224/uint256,还有 32 位余量给后续乘 timeElapsed
  • UQ128x128:128 + 128 = 256,逼近 uint256 上界,后续乘 timeElapsed 直接溢出
  • UQ256x256:y * 2^256y >= 1 时就溢出,数学上不可能

约束三:储备量上限够用

uint112 最大值 2^112 ≈ 5.19e33:

  • 18 位小数代币(WETH/DAI):可表示 5.19e15 个整币,ETH 当前供应约 1.2 亿,余量 8 个数量级
  • 6 位小数代币(USDC):5.19e27 个整币
  • 8 位小数代币(WBTC):5.19e25 个,远超 2100 万 BTC 上限

汇总

候选 Slot 打包 encode 不溢出 上限够用 结论
UQ112x112 112+112+32=256 224 位 唯一可行
UQ128x128 无时间戳位 ️ 256 位 打包失败
UQ144x144 288>256 打包失败
UQ256x256 不可能 不可能

112 是"能和 32 位时间戳打包的最大 2 的幂等分",同时双倍 224 又留出乘法空间。


六、Fork 到 uint256 的陷阱

陷阱一:定点数用整数范围换小数精度

定点数本质是用整数位宽换小数精度。UQ112x112 拿 112 位做小数,整数只剩 112 位。想用 uint256 做定点数:

  • UQ256.256:y * 2^256y >= 1 就溢出,不可能
  • UQ128.128:reserve 得是 uint128 才不溢出 encode,进而破坏 slot 打包

整数范围和小数精度不能兼得。

陷阱二:Slot 打包被破坏

uint128 储备:128 + 128 + 32 = 288,需要两个 slot,getReserves() 多一次 SLOAD。MEV 竞争下这直接拖累 DEX 竞争力。

陷阱三:累积溢出周期缩短

  • UQ112x112:溢出周期约 2^144 秒
  • UQ128.128:溢出周期约 2^128 秒

都远超宇宙寿命,但极端价格下 128 位版本绕回更快,链下要更频繁处理模运算。

陷阱四:生态不兼容

UniswapV2OracleLibrary 等链下工具硬编码了 / 2^112 缩放。改格式后套利机器人、借贷协议、监控面板全部读错价格。

陷阱五:裸整数除法

// 危险
price0CumulativeLast += (reserve1 / reserve0) * timeElapsed;

reserve1 < reserve0 时价格归零;即便不归零,小数全丢,TWAP 精度退化到整数级。

正确做法

  1. 没有明确理由,别动 UQ112x112
  2. 要更大范围,考虑 ABDKMathQuad 或 Solady 的 FixedPointMathLib,评估 Gas 和兼容性
  3. 要更高精度,用 uint256 存 UQ128.128,接受多 slot 代价,重写链下工具
  4. 永远别用裸整数除法算价格

七、总结

UQ112x112 这 20 行代码的每个决策都有据可依:

决策 理由
112 位 112+112+32=256,一个 slot 装下
uint224 存储 encode 不溢出,且留出乘法空间
乘法而非移位 语义清晰,编译期常量零开销
加法溢出是期望 TWAP 用差分,模运算天然兼容

112 这个数字同时满足三个约束:112×2+32=256(打包)、112×2=224<256(溢出)、2^112≈5e33(够用)。缺一个,整个设计就崩。

这给开发者的启示:EVM 没有浮点数是约束不是缺陷,定点数是标准解法但要精算位宽;storage 布局是 Gas 优化第一战场;预言机精度就是金钱;别盲目 fork,每个看似能改的地方背后可能有看不见的约束链。


参考

  • UQ112x112.sol
  • UniswapV2Pair.sol
  • Q Number Format - Wikipedia
  • Uniswap V2 Whitepaper
版权声明

本文仅代表作者观点,不代表区块链技术网立场。
本文系作者授权本站发表,未经许可,不得转载。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门