Solidity 定点数算术完全指南

深入解析 Solidity 中定点数(Fixed Point)的原理与实践,涵盖整数与定点数的转换、四则运算规则、二进制定点数、WAD 标准、Uniswap V2 价格累积器,以及 Solady/Solmate/ABDK 等主流库的实现细节与取整策略。

· ☕ 10 分钟阅读
Solidity 定点数算术完全指南

Solidity 定点数算术完全指南

为什么 Solidity 需要定点数?

Solidity 和大多数区块链智能合约语言一样,不支持原生浮点数(如 0.51.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/dy/d 相乘:

$$\frac{x}{d} \times \frac{y}{d} = \frac{x \times y}{d^2}$$

结果的分母变成了 ,但我们希望保持分母为 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/dy/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^1122^64

类型分母使用场景
十进制 WAD10^18Solady, MakerDAO, 大多数 DeFi
二进制 UQ112x1122^112Uniswap V2
二进制 UQ64x642^64ABDK 库

二进制定点数的优势:位移运算

十进制需要乘除法:

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 wei0.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


十一、各库对比总结

分母类型适用场景
Solady10^18 (WAD)uint256通用 DeFi,gas 优化
Solmate自定义uint256通用,简洁
ABDK 64x642^64int128需要负数,科学计算
Uniswap V22^112uint224价格累积,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),是编写安全可靠智能合约的关键。

💬 评论