Solidity 定点数算术完全指南
为什么 Solidity 需要定点数?
Solidity 和大多数区块链智能合约语言一样,不支持原生浮点数(如 0.5、1.15 这样的小数)。这是出于确定性和安全性的考虑——不同机器上的浮点运算结果可能存在微小差异,这在区块链上是不可接受的。
但现实业务中经常需要处理小数:
- 利率:5.5% 年化
- 汇率:1 ETH = 2350.75 USD
- 代币价格:1 DAI = 0.9998 USDC
定点数(Fixed Point Number) 就是解决这个问题的核心方案。
什么是定点数?
定点数是一个只存储分子(numerator)的整数,而其分母(denominator)是隐含固定的。
换句话说,我们用一个普通整数来”伪装”成小数,方法是:提前约定好分母是多少。
直观理解
假设我们约定分母为 100:
- 整数
150实际表示150/100 = 1.5 - 整数
275实际表示275/100 = 2.75 - 整数
100实际表示100/100 = 1.0
这就是定点数的本质——分母在代码层面是”约定好的常数”,不需要显式存储。
WAD 标准:以太坊最常用的分母
在以太坊生态中,最流行的约定分母是 10^18,被称为 WAD(这个术语由 MakerDAO 引入)。
为什么是 10^18?因为 1 Ether = 10^18 Wei,ETH 和大多数 ERC-20 代币本身就使用 18 位小数精度。这让 WAD 与代币单位天然契合。
WAD = 1e18 = 1_000_000_000_000_000_000
Solady 库(高度 gas 优化的 Solidity 工具库)的 FixedPointMathLib 就使用 WAD 作为标准分母。
一、整数转换为定点数
规则
将整数乘以分母 d,得到对应的定点数表示。
$$\text{定点数} = \text{整数} \times d$$
举例
使用 WAD(d = 10^18):
- 整数
2→ 定点数2 × 10^18 = 2e18 - 整数
1→ 定点数1 × 10^18 = 1e18(等价于1 ether)
这也是为什么在 Solidity 里:
uint256 oneEther = 1 ether; // 实际值是 1e18
1 ether 本质上就是 WAD 定点数中的”整数 1”。
二、定点数的乘法
数学推导
两个定点数 x/d 和 y/d 相乘:
$$\frac{x}{d} \times \frac{y}{d} = \frac{x \times y}{d^2}$$
结果的分母变成了 d²,但我们希望保持分母为 d,所以需要**除以一个 d**来修正:
$$\text{结果} = \frac{x \times y}{d}$$
Solady 的 mulWad() 实现
// 计算 (x * y) / WAD,其中 WAD = 1e18
function mulWad(uint256 x, uint256 y) internal pure returns (uint256 z) {
assembly {
// 检查溢出
if mul(y, gt(x, div(not(0), y))) {
mstore(0x00, 0xbac65e5b)
revert(0x1c, 0x04)
}
z := div(mul(x, y), WAD)
}
}
使用 assembly 的原因是极致的 gas 优化。逻辑上等价于:
function mulWad(uint256 x, uint256 y) internal pure returns (uint256 z) {
z = (x * y) / 1e18;
}
真实世界示例:计算利息
假设你有 1 DAI(即 1e18 的定点数),要计算 15% 的年化收益:
uint256 balance = 1e18; // 1 DAI(定点数表示)
uint256 rate = 1.15e18; // 1.15 的定点数表示(本金 + 15% 利息)
uint256 result = balance.mulWad(rate);
// result = (1e18 * 1.15e18) / 1e18 = 1.15e18
// 即 1.15 DAI
如果不使用定点数乘法,直接 balance * 1.15 在 Solidity 中根本无法编译,因为 1.15 不是整数。
三、定点数乘以整数
规则
定点数 x/d 乘以整数 y:
$$\frac{x}{d} \times y = \frac{x \times y}{d}$$
分母不变,直接相乘即可,无需额外调整。
举例
uint256 price = 2.5e18; // 定点数 2.5
uint256 qty = 3; // 普通整数
uint256 total = price * qty;
// total = 2.5e18 * 3 = 7.5e18
// 即 7.5(定点数表示正确)
注意:这里不需要除以
1e18,因为整数3的”隐含分母”是1,不是1e18。
四、定点数的除法
数学推导
两个定点数 x/d 和 y/d 相除:
$$\frac{x/d}{y/d} = \frac{x}{y}$$
结果的分母消失了(变成 1),但我们需要保持分母为 d,所以需要乘以一个 d 来修正:
$$\text{结果} = \frac{x \times d}{y}$$
Solady 的 divWad() 实现
function divWad(uint256 x, uint256 y) internal pure returns (uint256 z) {
assembly {
if iszero(mul(y, iszero(mul(WAD, gt(x, div(not(0), WAD)))))) {
mstore(0x00, 0x7c5f487d)
revert(0x1c, 0x04)
}
z := div(mul(x, WAD), y)
}
}
逻辑等价于:
function divWad(uint256 x, uint256 y) internal pure returns (uint256 z) {
z = (x * 1e18) / y;
}
举例
uint256 a = 3e18; // 定点数 3.0
uint256 b = 2e18; // 定点数 2.0
uint256 result = a.divWad(b);
// result = (3e18 * 1e18) / 2e18 = 1.5e18
// 即 1.5(正确!)
五、定点数除以整数
规则
定点数 x/d 除以整数 y:
$$\frac{x/d}{y} = \frac{x/y}{d}$$
只除分子,分母不变,直接做整除即可。
举例
uint256 total = 9e18; // 定点数 9.0
uint256 parts = 3; // 整数,分成 3 份
uint256 share = total / parts;
// share = 9e18 / 3 = 3e18
// 即 3.0(正确!)
六、定点数的加减法
规则
同分母的分数直接相加减分子即可:
$$\frac{a}{d} + \frac{b}{d} = \frac{a + b}{d}$$
定点数的加减法和普通整数加减完全一样,无需任何转换。
举例
uint256 x = 1.5e18; // 定点数 1.5
uint256 y = 2.3e18; // 定点数 2.3
uint256 sum = x + y; // 3.8e18,即定点数 3.8(正确!)
但要注意:两个定点数必须使用相同的分母才能直接相加减,混用不同精度的定点数会得到错误结果。
七、二进制定点数
十进制 vs 二进制
前面讨论的都是十进制定点数,分母是 10^18(WAD)。
二进制定点数的分母是 2^n,例如 2^112 或 2^64。
| 类型 | 分母 | 使用场景 |
|---|---|---|
| 十进制 WAD | 10^18 | Solady, MakerDAO, 大多数 DeFi |
| 二进制 UQ112x112 | 2^112 | Uniswap V2 |
| 二进制 UQ64x64 | 2^64 | ABDK 库 |
二进制定点数的优势:位移运算
十进制需要乘除法:
x * 1e18 // 乘以 10^18(较贵的乘法)
x / 1e18 // 除以 10^18(较贵的除法)
二进制可以用位移运算(bitshift),gas 更低:
x << 64 // 等价于 x * 2^64(便宜的位移)
x >> 64 // 等价于 x / 2^64(便宜的位移)
Q 记法
UQnxm 中:
U表示无符号(unsigned)n表示整数部分占用的位数m表示小数部分占用的位数
例如 UQ112x112:用 224 位存储,112 位整数 + 112 位小数,分母为 2^112。
八、Uniswap V2 的 UQ112x112 库
Uniswap V2 使用 UQ112x112 来存储时间加权平均价格(TWAP)。
完整源码
pragma solidity =0.5.16;
library UQ112x112 {
uint224 constant Q112 = 2**112;
// 将 uint112 整数编码为 UQ112x112 定点数
function encode(uint112 y) internal pure returns (uint224 z) {
z = uint224(y) * Q112; // 乘以 2^112,即左移 112 位
}
// 将 UQ112x112 定点数除以 uint112 整数
function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) {
z = x / uint224(y);
}
}
为什么用 uint224(224 位)?
UQ112x112 需要 224 位(112 + 112),而 EVM 最大支持 uint256(256 位)。使用 uint224 而不是 uint256,是为了在累加价格时防止溢出——剩余的 32 位作为溢出缓冲区。
使用场景:价格累积
// 来自 Uniswap V2 Pair 合约(简化版)
price0CumulativeLast += uint(UQ112x112.encode(_reserve1).uqdiv(_reserve0)) * timeElapsed;
encode(_reserve1)将储备量转为定点数(乘以2^112)uqdiv(_reserve0)除以另一储备量,得到价格比- 乘以时间间隔,累积到全局价格累积器
- TWAP 计算时取两个时间点的差值再除以时间差
九、ABDK 库:带符号的 64 位二进制定点数
ABDK Math 64x64 是一个处理有符号定点数的库,使用 int128 存储,分母为 2^64。
整数转换
// 将 uint256 转为 64.64 定点数
function fromUInt(uint256 x) internal pure returns (int128) {
require(x <= 0x7FFFFFFFFFFFFFFF);
return int128(x << 64); // 左移 64 位 = 乘以 2^64
}
乘法
// 两个 64.64 定点数相乘
function mul(int128 x, int128 y) internal pure returns (int128) {
int256 result = int256(x) * y >> 64; // 右移 64 位 = 除以 2^64
// ...检查溢出...
return int128(result);
}
位移运算代替乘除法,大幅节省 gas。
十、取整策略:向下 vs 向上
Solidity 的默认行为:向下取整(截断)
10 / 3 = 3 // 而不是 3.333...
7 / 2 = 3 // 而不是 3.5
何时需要向上取整?
考虑这个场景:协议收取手续费,1000 wei 按 0.3% 计算手续费:
手续费 = 1000 * 0.003 = 3 wei(向下取整)
用户实际支付: 1000 - 3 = 997 wei
如果手续费计算时向下取整有利于用户,而用户可以反复利用这个误差,就存在被薅羊毛的风险。所以收费时应向上取整,发放奖励时向下取整。
mulDivUp() 实现(Solmate 风格)
function mulDivUp(
uint256 x,
uint256 y,
uint256 denominator
) internal pure returns (uint256 result) {
result = mulDiv(x, y, denominator);
// 如果有余数,结果加 1
if (mulmod(x, y, denominator) > 0) {
require(result < type(uint256).max);
result++;
}
}
原理:先做向下取整,再检查是否有余数(用 mulmod 判断)。如果有余数说明结果被截断了,向上加 1。
Solady 的向上取整变体
// 向上取整的 WAD 乘法
function mulWadUp(uint256 x, uint256 y) internal pure returns (uint256 z) {
z = (x * y + WAD - 1) / WAD;
}
等价写法(利用整数除法性质):(x * y + d - 1) / d 等价于向上取整的 (x * y) / d。
十一、各库对比总结
| 库 | 分母 | 类型 | 适用场景 |
|---|---|---|---|
| Solady | 10^18 (WAD) | uint256 | 通用 DeFi,gas 优化 |
| Solmate | 自定义 | uint256 | 通用,简洁 |
| ABDK 64x64 | 2^64 | int128 | 需要负数,科学计算 |
| Uniswap V2 | 2^112 | uint224 | 价格累积,TWAP 预言机 |
十二、实战:手写一个简单的定点数库
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
library FixedPoint {
uint256 constant WAD = 1e18;
/// @notice 两个 WAD 定点数相乘
function mul(uint256 x, uint256 y) internal pure returns (uint256) {
return (x * y) / WAD;
}
/// @notice 两个 WAD 定点数相除
function div(uint256 x, uint256 y) internal pure returns (uint256) {
return (x * WAD) / y;
}
/// @notice 向上取整的乘法
function mulUp(uint256 x, uint256 y) internal pure returns (uint256) {
return (x * y + WAD - 1) / WAD;
}
}
十三、常见陷阱与注意事项
1. 精度损失顺序
先乘后除,而不是先除后乘:
// 错误:先除会损失精度
uint256 bad = (x / WAD) * y;
// 正确:先乘后除
uint256 good = (x * y) / WAD;
2. 溢出风险
两个 uint256 相乘可能溢出:2^256 * 2^256 超出范围。Solady 和 Solmate 使用 mulmod + assembly 处理这个问题,生产环境请使用经过审计的库。
3. 混用不同精度
不要将 WAD(1e18)定点数和 RAY(1e27,用于 Aave 等)定点数直接相加,必须先统一精度。
4. 取整方向
- 计算用户应付金额(手续费、还款)→ 向上取整(保护协议)
- 计算协议应发奖励(利息、奖励)→ 向下取整(保护协议)
总结
| 操作 | 公式 | 说明 |
|---|---|---|
| 整数 → 定点数 | x * d | 乘以分母 |
| 定点数 × 定点数 | (x * y) / d | 相乘后除以分母 |
| 定点数 × 整数 | x * y | 直接相乘 |
| 定点数 ÷ 定点数 | (x * d) / y | 先乘分母再除 |
| 定点数 ÷ 整数 | x / y | 直接相除 |
| 定点数 + 定点数 | x + y | 直接相加 |
| 定点数 - 定点数 | x - y | 直接相减 |
定点数算术是 DeFi 开发的基础技能。理解其本质(分母是隐含的约定),掌握每种运算的调整规则,并在生产代码中使用经过审计的库(Solady、Solmate),是编写安全可靠智能合约的关键。