Uniswap V2 的数学引擎:UQ112x112 定点数库解析
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 可严格证明。用最坏情况代入:
uint112最大值 =2^112 - 1encode结果 =y * 2^112,当 y 取最大时结果最大- 最大结果 =
(2^112 - 1) * 2^112 = 2^224 - 2^112 uint224最大值 =2^224 - 1- 比较:
2^224 - 2^112与2^224 - 1。两边都从2^224里减去一个数,左边减2^112,右边减1。2^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上界
理论上极端构造下会溢出,但这里有三层保护:
- Solidity 0.5.16 没有溢出检查(0.8.0 才有),乘法溢出静默回绕,不 revert
+ overflow is desired:TWAP 链下算的是两次采样的差值,模 2^256 下差值依然正确,只要采样间隔内没绕回一整圈(实际不可能)- 真实市场中
reserve1/reserve0远小于2^224,timeElapsed通常十几秒,离溢出边界有天文数字距离
所以这处注释是工程意义上的安全,不是数学意义上的绝对安全。
四、与 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 读出三个值。_update、mint、burn、swap 开头都调它。
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。blockTimestampLast 用 uint32(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^256在y >= 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^256,y >= 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 精度退化到整数级。
正确做法
- 没有明确理由,别动 UQ112x112
- 要更大范围,考虑 ABDKMathQuad 或 Solady 的 FixedPointMathLib,评估 Gas 和兼容性
- 要更高精度,用
uint256存 UQ128.128,接受多 slot 代价,重写链下工具 - 永远别用裸整数除法算价格
七、总结
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
版权声明
本文仅代表作者观点,不代表区块链技术网立场。
本文系作者授权本站发表,未经许可,不得转载。
鸿途知科网
发表评论:
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。