欢迎订阅专栏: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),并编写针对截断边界的测试用例。