10分钟智能合约:进阶实战-3.6 计算精度损失

61 阅读5分钟

欢迎订阅专栏10分钟智能合约:进阶实战

计算精度损失:定义、原理与防御

计算精度损失 在智能合约中特指整数除法截断导致的数值误差。由于 Solidity 整数除法会无条件舍去小数部分(向零舍入),连续运算或比例计算可能累积误差,造成资金损失、利益分配不公或逻辑错误。

注意:这里讨论的不是浮点数精度(EVM 不支持浮点),而是固定精度整数运算中的截断问题


1. 核心原理

Solidity 中 uint 和 int 类型的除法 / 返回整数商,直接丢弃余数。例如:

solidity

5 / 2 = 2   // 损失 0.5
1 / 3 = 0   // 完全损失

这种截断在以下场景危害最大:

  • 比例计算(如根据份额分配收益)
  • 利率 / 费率计算(如借贷利息、手续费)
  • 代币兑换(如 AMM 中的输出数量)
  • 时间加权平均值(如 TWAP 预言机)

2. 典型漏洞示例

2.1 收益分配截断

solidity

// 漏洞合约:按比例分配奖励
contract RewardDistributor {
    mapping(address => uint) public shares;   // 用户的份额
    uint public totalShares;
    uint public rewardPool;

    function claimReward() public {
        uint userShare = shares[msg.sender];
        require(userShare > 0, "No shares");

        // ❌ 先除后乘,严重截断
        uint reward = rewardPool * (userShare / totalShares);
        
        // 更新状态
        rewardPool -= reward;
        shares[msg.sender] = 0;
        payable(msg.sender).transfer(reward);
    }
}

问题

  • 若 rewardPool * userShare 不能整除 totalShares,剩余部分将永远留在合约中。
  • 多个用户依次领取时,最后一位用户可能因截断无法领到应得奖励(或合约残留少量资金无人可领)。

2.2 兑换价格计算误差(AMM 类)

solidity

// 简单兑换:用 ETH 买 Token
function buyToken(uint ethAmount) public {
    uint tokenAmount = ethAmount * reserveToken / reserveETH;  // ❌ 截断
    require(tokenAmount > 0, "Too little ETH");
    // 更新储备
    reserveETH += ethAmount;
    reserveToken -= tokenAmount;
    token.transfer(msg.sender, tokenAmount);
}

问题:当 ethAmount * reserveToken < reserveETH 时,tokenAmount 为 0,用户支付 ETH 却得不到任何代币。攻击者可利用此特性进行粉尘攻击。

2.3 时间加权平均价格(TWAP)累积误差

solidity

// 简化 TWAP 更新
uint public cumulativePrice;
uint public lastTimestamp;

function update(uint currentPrice) public {
    uint timeElapsed = block.timestamp - lastTimestamp;
    cumulativePrice += currentPrice * timeElapsed;   // ❌ 未考虑 price * time 的精度
    lastTimestamp = block.timestamp;
}

function getTWAP() public view returns (uint) {
    uint timeElapsed = block.timestamp - lastTimestamp;
    return cumulativePrice / timeElapsed;   // 截断
}

问题:若 currentPrice 很小(如 6 位小数),乘以 timeElapsed 后仍可能小于 1,导致累积值不增加,TWAP 长期为 0。


3. 精度损失的影响

场景损失类型潜在后果
代币分配用户所得少于应得资金残留、公平性问题
手续费计算协议收入截断累积损失、套利空间
借贷清算抵押率计算偏差不当清算或坏账
投票权份额比例截断治理攻击
随机数取模偏差可预测性

4. 防御措施

✅ 先乘后除(Mul before Div)

永远先做乘法,最后做除法,以保留尽可能多的精度。

solidity

// 正确做法
uint reward = rewardPool * userShare / totalShares;
// 而不是 rewardPool / totalShares * userShare

✅ 放大精度(使用比例因子)

定义一个精度因子(如 1e18),将所有小数放大为整数运算。

solidity

uint constant PRECISION = 1e18;

// 计算 30% 的奖励
uint reward = amount * 30 * PRECISION / 100 / PRECISION;
// 或者直接 amount * 30 / 100,但中间会丢失精度
// 更好的写法:
uint reward = amount * 30 / 100;    // 百分比无精度问题
// 更通用的比例:
uint share = amount * numerator * PRECISION / denominator / PRECISION;

更简洁的标准化写法:分子乘以精度,分母保持不变

solidity

function calculateShare(uint amount, uint ratio, uint denominator) internal pure returns (uint) {
    return amount * ratio / denominator;   // ratio 本身已包含精度(如 1e18 表示 100%)
}

✅ 最小化截断次数

将同一表达式中的除法尽可能延迟到最后一步。

solidity

// 不好:多次除法
uint a = x / y;
uint b = a / z;
// 好:合并为一次除法
uint c = x / (y * z);   // 注意 y*z 可能溢出,需用 SafeMath 或 0.8+ 自动检查

✅ 处理余数(Rounding)

使用 向上取整 或 向下取整 配合业务逻辑。

solidity

// 向下取整(默认)
uint down = amount / divisor;

// 向上取整:确保不亏待用户(如协议收费)
uint up = (amount + divisor - 1) / divisor;

// 取余数并单独处理
uint quotient = amount / divisor;
uint remainder = amount % divisor;
if (remainder > 0) {
    // 特殊处理(如累积到下次)
}

✅ 使用高精度库

OpenZeppelin 的 Math 库提供 mulDiv 函数(Solidity 0.8+),自动处理乘法溢出和精度。

solidity

import "@openzeppelin/contracts/utils/math/Math.sol";

uint result = Math.mulDiv(amount, numerator, denominator);  // 先乘后除,带溢出保护

✅ 浮点数近似(不推荐)

Solidity 官方不支持浮点,但可借助 ABDKMath64x64 等定点库,一般不建议增加复杂度。


5. 真实案例

  • Uniswap V1 的精度损失:早期版本因除法截断导致部分交易无法执行,后通过增加最小流动性要求缓解。
  • Yam Finance 首次 rebase:因精度误差导致部分用户获得异常份额,虽未造成直接损失,但暴露了比例计算的脆弱性。
  • dYdX 的费率计算:早期版本中因向下取整导致协议累计损失数十万美元手续费(社区披露)。

6. 现代最佳实践

实践说明
始终先乘后除最核心的规则。
使用 mulDiv 系列OpenZeppelin 或 Solmate 的实现安全且高效。
明确处理余数业务逻辑决定余数归谁(协议/用户/销毁)。
测试边界条件特别是分母为 1、分子为 0 或极小值的情况。
文档化精度明确说明使用的精度因子(如 1e18)和舍入规则。

总结

精度损失是 Solidity 开发中最常见但容易被忽视的问题。整数除法截断本身不是漏洞,但错误的运算顺序或未处理的余数会导致经济模型偏离预期。防御的核心是:先乘后除,延迟截断,处理余数。对于复杂的金融计算,优先使用经过审计的数学库(如 OpenZeppelin Math),并编写针对截断边界的测试用例。