Solidity 定点数算术完全指南
目录
- 为什么Solidity需要定点数
- 定点数的基本概念
- 将整数转换为定点数
- 定点数乘法
- 定点数乘以整数
- 定点数除法
- 定点数除以整数
- 定点数的加法和减法
- 二进制与十进制定点数
- 向上取整与向下取整
- 综合DEMO:构建一个精度敏感的DeFi计算器
1. 为什么Solidity需要定点数
在大多数编程语言中,处理小数可以直接使用浮点数(float/double)。但在 Solidity 中,只有整数类型,而我们经常需要对分数进行运算(计算利息、价格比率、汇率等)。
举个例子:如果你想计算 100 DAI 存一年获得 15% 利息后的余额,在其他语言中直接写 100 * 1.15 即可。但在 Solidity 中,没有 1.15 这种小数——这就是定点数发挥作用的地方。
2. 定点数的基本概念
定点数本质上是一个整数,仅存储分数的分子,而分母是隐含的(约定俗成、不在代码中显式存储)。
对于相同的数字 0.1:
- 如果隐含分母是 100,则分子存储 10(因为 10/100 = 0.1)
- 如果隐含分母是 1000,则分子存储 100(因为 100/1000 = 0.1)
Wad——Solidity世界中最常见的定点数
Solidity 中最常见的定点数分母是 10¹⁸。这是以太坊和大多数 ERC-20 代币使用的”小数位数”。
如果我们读取一个以太坊地址的余额,得到 10¹⁹(即 10,000,000,000,000,000,000),我们隐式地将该数字除以 10¹⁸,得知该地址拥有 10 Ether。
Wad 这个名称由 MakerDAO 首次引入,在 Solidity 社区中广泛使用。一个 Wad 就是一个分母为 10¹⁸ 的定点数。
心智模型
定点数保存分子,10¹⁸ 分母是隐含的。
3. 将整数转换为定点数
要将整数转换为定点数,将整数乘以隐含分母。
整数 2 转换为 Wad:2 × 10¹⁸ = 2 × 10¹⁸
“2 ether” 在 Solidity 中表示为 2e18,即 2000000000000000000。
这里隐含分母 10¹⁸ 与乘数中的 10¹⁸ 相互”抵消”——2 本身是 2/1,乘以 10¹⁸/10¹⁸ 后等同于 (2×10¹⁸)/10¹⁸,解释时再除以 10¹⁸ 等于 2。
代码示例:
uint256 integerValue = 2;
uint256 wadValue = integerValue * 1e18; // 2e18 = 2 个 Wad
4. 定点数乘法
4.1 两个定点数相乘
两个定点数相乘遵循分数乘法的规则:
步骤1:分子相乘
步骤2:分母相乘
步骤3:简化结果
数学推导:
x/d × y/d = (x × y) / (d × d) = (x × y) / d²
但 (x×y)/d² 的分母是 d²,我们不希望返回一个与所选隐含分母 不兼容 的结果。因此需要将结果再次除以 d:
(x×y)/d² ÷ d/d = (x×y/d)/d
最终公式:
mulWad(x, y) = (x × y) / d
其中 d 是隐含分母(对于 Wad 而言 d = 10¹⁸)。
4.2 Solady 的 mulWad 实现
Solady 库提供了一个 mulWad 函数,核心算法就是 (x × y) / WAD:
/// @dev 等价于 `(x * WAD) / y` 向下取整。
function mulWad(uint256 x, uint256 y) internal pure returns (uint256 z) {
/// @solidity memory-safe-assembly
assembly {
// 使用 assembly 进行高效的 512 位乘法,防止溢出
// 实际上 Solady 使用了更加优化的实现
if iszero(mul(y, iszero(mul(WAD, gt(x, div(not(0), WAD)))))) {
mstore(0x00, 0x7c5f487d) // 错误选择器
revert(0x1c, 0x04)
}
z := div(mul(x, y), WAD)
}
}
溢出保护:因为 x 和 y 都可能是接近 type(uint256).max 的数,它们的乘积可能超过 uint256 的范围。所以在相乘前需要检查是否会溢出。
4.3 实际案例:计算利息
场景:用户持有 1 DAI(有18位小数,即 1e18),存款获得 15% 的利息。需要计算新余额。
错误写法:在 Solidity 中不能写 tokenBalance * 1.15。
正确写法:
// 1.15 表示为定点数:1.15 × 10¹⁸ = 1.15e18
uint256 tokenBalance = 1e18; // 1 DAI
uint256 interestRate = 1.15e18; // 1.15(作为 Wad)
uint256 newBalance = mulWad(tokenBalance, interestRate);
// newBalance = (1e18 × 1.15e18) / 1e18 = 1.15e18
// 解释为 1.15 DAI
分步计算:
1e18 × 1.15e18 = 1.15e361.15e36 / 1e18 = 1.15e18- 1.15e18 解释为 1.15 DAI(隐含除以 10¹⁸)
注意:你不能真的”除以 10¹⁸”来显示小数,那会抹去所有小数位。比如 115e16 除以 1e18 会得到 1(不是 1.15)。所以必须在定点数形式下进行所有计算,只在最终展示给用户时才除以 10¹⁸。
5. 定点数乘以整数
将分数 x/d 乘以整数 y 等同于将 x 乘以 y/1:
35/100 × 3 = 35/100 × 3/1 = 105/100
结论:当定点数乘以整数时,不需要任何额外步骤。只需将返回值解释为具有相同分母的定点数。
// 定点数 0.35(分母 100)× 整数 3 = 定点数 1.05(分母 100)
uint256 fixedPoint = 35; // 35/100 = 0.35
uint256 integer = 3;
uint256 result = fixedPoint * integer; // 105/100 = 1.05
分母保持不变。
6. 定点数除法
6.1 除以定点数
分数除法是”翻转”第二个分数然后相乘:
2/5 ÷ 1/2 = 2/5 × 2/1 = 4/5
对于具有相同分母的例子:
6/10 ÷ 3/10 = 6/10 × 10/3 = 60/30 = 2
注意:公分母 10 被约掉了!如果要用隐含分母 10 来表示结果 2(即作为分母为 10 的定点数),需要再次乘以 10:
2 = (2 × 10) / 10
最终公式:
divWad(x, y) = (x × d) / y
其中 d 是隐含分母(对于 Wad 而言 d = 10¹⁸)。
完整推导:
x/d ÷ y/d = x/d × d/y = x/y (但 x/y 的分母不是 d)
(x/y) × (d/d) = (x × d) / y / d (现在分母恢复为 d)
所以 divWad(x, y) = (x × d) / y
6.2 Solady 的 divWad 实现
/// @dev 等价于 `(x * WAD) / y` 向下取整。
function divWad(uint256 x, uint256 y) internal pure returns (uint256 z) {
/// @solidity memory-safe-assembly
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)
}
}
对比 mulWad 和 divWad:
mulWad(x, y) = (x × y) / WAD
divWad(x, y) = (x × WAD) / y
唯一的区别是 divWad 通过乘以一个倒置的分数实现除法——先乘分母再除以第二个分子。
7. 定点数除以整数
将分数 x/d 除以整数 y 等同于将 x 的分子除以 y:
35/100 ÷ 3 = (35 ÷ 3)/100 = 11/100
注意:35 ÷ 3 = 11(不是 11.666),因为使用的是 Solidity 整数除法(向下取整)。
结论:只需将定点数除以整数,结果仍解释为定点数。分母保持不变。
uint256 fixedPoint = 35; // 35/100 = 0.35
uint256 integer = 3;
uint256 result = fixedPoint / integer; // 11/100 = 0.11(精度损失)
8. 定点数的加法和减法
具有相同分母的分数相加(减)时,只需将分子相加(减),忽略分母:
a/d + b/d = (a + b)/d
a/d - b/d = (a - b)/d
结论:添加或减去具有相同分母的定点数时,像普通整数一样加减即可。
// 50/100 + 40/100 = 90/100
uint256 a = 50; // 50/100 = 0.5
uint256 b = 40; // 40/100 = 0.4
uint256 sum = a + b; // 90/100 = 0.9
这是定点数最简单的操作——因为分母相同,不需要任何缩放。
9. 二进制与十进制定点数
9.1 Q记法
二进制定点数的分母是 2ⁿ,通常用 Q记法 表示。
| 类型 | 分母 | 存储类型 | 说明 |
|---|---|---|---|
| UQ112x112 | 2¹¹² | uint224 | 112位”整数” + 112位”小数”,U = 无符号 |
| UQ64x64 | 2⁶⁴ | uint128 | 64位”整数” + 64位”小数” |
另一种理解方式:
- “小数部分”保存在最右边的 N 位中
- “整数部分”保存在最左边的 N 位中
二进制定点数的优势:
x × 2ⁿ等同于x << n(左移,Gas 更低)x / 2ⁿ等同于x >> n(右移,Gas 更低)- 位移操作比乘除法便宜得多!
9.2 ABDK 库
ABDK 库使用隐含分母 2⁶⁴。
将整数转换为 ABDK 定点数:
// ABDKMath64x64.sol 中的 fromInt 函数
function fromInt(int256 x) internal pure returns (int128) {
require(x >= -0x8000000000000000 && x <= 0x7FFFFFFFFFFFFFFF);
return int128(x << 64); // 左移64位 = 乘以 2⁶⁴
}
require 确保 x 不超过 int64 的范围(因为 ABDK 使用有符号定点数)。
乘法操作:
function mul(int128 x, int128 y) internal pure returns (int128) {
int256 result = (int256(x) * y) >> 64; // 右移64位 = 除以 2⁶⁴
require(result >= MIN_64x64 && result <= MAX_64x64);
return int128(result);
}
注意:不是 (x × y) / 2⁶⁴,而是 右移 64 位——Gas 更低、效率更高。
9.3 Uniswap V2 的定点数库
Uniswap V2 的定点数库非常简洁,因为它只做两种操作:
- 定点数加法
- 定点数除以整数
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; // 等同于左移112位
}
// UQ112x112 除以 uint112,返回 UQ112x112
function uqdiv(uint224 x, uint112 y) internal pure returns (uint224 z) {
z = x / uint224(y);
}
}
encode() 详解
将 uint112 转换为存储在 uint224 中的定点数。隐含分母是 2**112。
位级分析(用较小尺寸简化说明):
数字 125 的二进制:01111101(8位)
乘以 2⁸(左移8位):
01111101 00000000(16位)= 32000
即(125 × 256)= 32000,作为分母为256的定点数 = 32000/256 = 125
Uniswap V2 的实现中,uint112 数字的位被有效”移位”到 uint224 的最高有效 112 位中。encode() 使用乘法而非位移(文章指出这可能是开发者的一个小失误,位移更省 Gas)。
uqdiv() 详解
uqdiv() 执行定点数除以整数——根据规则,不需要任何额外步骤,直接做除法即可,结果仍是定点数。
实际用途:TWAP 价格累积器
Uniswap V2 数据库用于累积 TWAP(时间加权平均价格) 预言机的价格:
uint112 private _reserve0; // 池中 token0 余额
uint112 private _reserve1; // 池中 token1 余额
UQ112x112 public price0CumulativeLast; // token0 累积价格(定点数)
UQ112x112 public price1CumulativeLast; // token1 累积价格(定点数)
累积逻辑(简化):
// 将 reserve1/reserve0 的比率编码为定点数并累加
price0CumulativeLast += UQ112x112.encode(
uint112(_reserve1)
).uqdiv(uint112(_reserve0));
// 实际上 reserve1 和 reserve0 都被转换为定点数再相除
步骤分解:
- 将
_reserve1转换为 UQ112x112(乘以 2¹¹²) - 除以
_reserve0(整数除法) - 结果是一个 UQ112x112 定点数(分母为 2¹¹²)
- 累加到
price0CumulativeLast
10. 向上取整与向下取整
Solidity 的除法总是向下取整。例如 10 / 3 = 3。
但在金融场景中,计算信用或价格时应始终向有利于协议的方向取整,对用户不利:
- 计算用户应支付的数量 → 向上取整
- 计算用户应获得的数量 → 向下取整
Solmate 的 mulDivUp 实现
function mulDivUp(
uint256 a,
uint256 b,
uint256 denominator
) internal pure returns (uint256 result) {
result = mulDiv(a, b, denominator);
// 如果除法有余数,结果 +1
if (mulmod(a, b, denominator) > 0) {
result += 1;
}
}
关键:mulmod(a, b, denominator) 计算 (a × b) % denominator。如果余数 > 0,说明除法结果不精确,向上取整就是 +1。
示例对比
| 操作 | 向下取整 | 向上取整 |
|---|---|---|
| 10/3 | 3 (3.3333截断) | 4 (3.3334进位) |
| 100/3 | 33 | 34 |
| 12/4 | 3 (精确整除) | 3 (无余数,不变) |
常用向上取整函数
mulWadUp:两个 Wad 相乘,除以 Wad 时向上取整divWadUp:两个 Wad 相除,向上取整mulDivUp:通用的(a × b) / denominator向上取整
11. 综合DEMO:构建一个精度敏感的DeFi计算器
项目目标
在 Sepolia 上部署一个 DeFi 计算器合约,实现所有定点数算术操作,并应用于实际 DeFi 场景(如 AMM 定价、利息计算、TWAP 价格累积)。
核心功能
- Wad 运算库:实现
mulWad、divWad、mulWadUp、divWadUp - 利息计算器:给定本金、利率和时间,计算到期本息
- AMM 定价:恒定乘积做市商(x×y=k)的定价计算
- TWAP 累积器:模拟 Uniswap V2 的价格累积逻辑
架构设计
FixedPointMath
├── WadMath(基于 10¹⁸ 的定点数运算)
│ ├── mulWad(uint256 x, uint256 y) → uint256
│ ├── divWad(uint256 x, uint256 y) → uint256
│ ├── mulWadUp(uint256 x, uint256 y) → uint256
│ ├── divWadUp(uint256 x, uint256 y) → uint256
│ ├── toWad(uint256 x) → uint256
│ └── fromWad(uint256 x) → uint256
│
├── Q64x64Math(基于 2⁶⁴ 的定点数运算)
│ ├── toQ64(uint256 x) → uint128
│ ├── mulQ64(uint128 x, uint128 y) → uint128
│ └── divQ64(uint128 x, uint128 y) → uint128
│
└── Applications
├── InterestCalculator
│ └── calculateCompoundInterest(principal, rate, periods) → uint256
├── SimpleAMM
│ ├── getSwapAmount(reserveIn, reserveOut, amountIn) → uint256
│ └── getSwapAmountWithFee(reserveIn, reserveOut, amountIn, feeBps) → uint256
└── TWAPAccumulator
├── update(uint112 price0, uint112 price1)
└── consult() → uint256
实现步骤建议
第一步:实现 WadMath 库
关键点:
- 使用
(x * y) / 1e18实现乘法 - 注意溢出:使用
unchecked块或uint256最大值的预检查 - 向上取整版本使用
mulmod检查余数
library WadMath {
uint256 constant WAD = 1e18;
function mulWad(uint256 x, uint256 y) internal pure returns (uint256) {
return (x * y) / WAD;
}
function mulWadUp(uint256 x, uint256 y) internal pure returns (uint256) {
uint256 result = (x * y) / WAD;
if (x * y % WAD != 0) {
result += 1;
}
return result;
}
// ...更多函数
}
第二步:实现利息计算器
// 复利计算公式:A = P × (1 + r)^n
// 但因为 Solidity 不支持小数幂,使用循环逼近或简化公式
function calculateCompoundInterest(
uint256 principal, // Wad
uint256 rate, // Wad (如 0.05e18 = 5%)
uint256 periods // 整数
) internal pure returns (uint256) {
uint256 result = principal;
for (uint256 i = 0; i < periods; i++) {
result = WadMath.mulWad(result, WAD + rate);
}
return result;
}
第三步:实现 AMM 定价
恒定乘积公式:x × y = k
// 用户输入 dx 个 token0,应获得多少 token1?
// (x + dx)(y - dy) = x × y
// dy = y × dx / (x + dx)
function getSwapAmount(
uint256 reserveIn, // Wad
uint256 reserveOut, // Wad
uint256 amountIn // Wad
) internal pure returns (uint256) {
return WadMath.mulWad(amountIn, reserveOut) / (reserveIn + amountIn);
}
第四步:实现 TWAP 累积器
使用 UQ112x112 风格的定点数:
contract TWAPAccumulator {
using UQ112x112Math for uint224;
uint224 public priceCumulativeLast;
uint32 public blockTimestampLast;
function update() external {
// 实现类似 Uniswap V2 的价格累积逻辑
}
}
第五步:测试用例
- 定点数乘法精度验证
- 定点数除法精度验证
- 向上取整 vs 向下取整的差异
- 利息计算器长时间复利精度
- AMM 定价与常数乘积不变性验证
- 边界情况:极大值、极小值、零值
进阶挑战
- 实现完整的 Logarithm 库:使用 ABDK 风格的对数运算,支持更高效的复利计算
- 精度损失分析工具:写一个链下脚本,对比定点数计算与 JavaScript float 计算的精度差异
- 实现 UQ64x64 的 Q64x64Math 库并对比 Wad 的性能
测试网部署
- 部署所有库合约和应用合约到 Sepolia
- 使用不同精度的参数测试
- 记录 Gas 消耗进行对比分析