Solidity定点数算术完全指南

深入解析Solidity中定点数算术的原理与实现,涵盖Solady/Solmate/ABDK/UniswapV2库的对比分析及向上取整策略

14 分钟阅读
Solidity定点数算术完全指南

Solidity 定点数算术完全指南

目录

  1. 为什么Solidity需要定点数
  2. 定点数的基本概念
  3. 将整数转换为定点数
  4. 定点数乘法
  5. 定点数乘以整数
  6. 定点数除法
  7. 定点数除以整数
  8. 定点数的加法和减法
  9. 二进制与十进制定点数
  10. 向上取整与向下取整
  11. 综合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)
    }
}

溢出保护:因为 xy 都可能是接近 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

分步计算

  1. 1e18 × 1.15e18 = 1.15e36
  2. 1.15e36 / 1e18 = 1.15e18
  3. 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记法 表示。

类型分母存储类型说明
UQ112x1122¹¹²uint224112位”整数” + 112位”小数”,U = 无符号
UQ64x642⁶⁴uint12864位”整数” + 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 的定点数库非常简洁,因为它只做两种操作

  1. 定点数加法
  2. 定点数除以整数
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 都被转换为定点数再相除

步骤分解

  1. _reserve1 转换为 UQ112x112(乘以 2¹¹²)
  2. 除以 _reserve0(整数除法)
  3. 结果是一个 UQ112x112 定点数(分母为 2¹¹²)
  4. 累加到 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/33 (3.3333截断)4 (3.3334进位)
100/33334
12/43 (精确整除)3 (无余数,不变)

常用向上取整函数

  • mulWadUp:两个 Wad 相乘,除以 Wad 时向上取整
  • divWadUp:两个 Wad 相除,向上取整
  • mulDivUp:通用的 (a × b) / denominator 向上取整

11. 综合DEMO:构建一个精度敏感的DeFi计算器

项目目标

在 Sepolia 上部署一个 DeFi 计算器合约,实现所有定点数算术操作,并应用于实际 DeFi 场景(如 AMM 定价、利息计算、TWAP 价格累积)。

核心功能

  1. Wad 运算库:实现 mulWaddivWadmulWadUpdivWadUp
  2. 利息计算器:给定本金、利率和时间,计算到期本息
  3. AMM 定价:恒定乘积做市商(x×y=k)的定价计算
  4. 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 的价格累积逻辑
    }
}

第五步:测试用例

  1. 定点数乘法精度验证
  2. 定点数除法精度验证
  3. 向上取整 vs 向下取整的差异
  4. 利息计算器长时间复利精度
  5. AMM 定价与常数乘积不变性验证
  6. 边界情况:极大值、极小值、零值

进阶挑战

  1. 实现完整的 Logarithm 库:使用 ABDK 风格的对数运算,支持更高效的复利计算
  2. 精度损失分析工具:写一个链下脚本,对比定点数计算与 JavaScript float 计算的精度差异
  3. 实现 UQ64x64 的 Q64x64Math 库并对比 Wad 的性能

测试网部署

  1. 部署所有库合约和应用合约到 Sepolia
  2. 使用不同精度的参数测试
  3. 记录 Gas 消耗进行对比分析

💬 评论