Solidity合约中如何避免算术错误

文章作者

Solidity智能合约中的算术错误通常发生在代币、份额或价格计算使用错误的公式、小数精度、舍入方向或数值假设时,导致用户能够铸造过多的权益、提取超过其存入价值的资产,或导致会计不变量失效。解决方案是显式定义单位,使用全精度有界算术,并测试公式必须保持的经济不变量。

其后果极为严重。Cetus CLMM于2025年5月22日在Sui链上因涉及代币和份额会计的算术错误事故,损失高达2.23亿美元。因此,算术安全不仅仅是防止Solidity溢出导致的回滚,而是确保每个铸造的份额都代表对资产的有力索赔。

什么是Solidity中的算术错误,攻击是如何发生的?

Solidity中的算术错误是指在计算存款、取款、余额、价格、费用或流动性以转换为经济权益时的缺陷。攻击者利用预期账务模型与实际实现公式之间的不匹配,通常通过选择交易顺序、资产数量或池状态,促使错误的换算对其有利。

在现代Solidity中,算术溢出和下溢默认会回滚。此保护固然有用,但并不能证明公式在经济上是正确的。计算结果即便在uint256的范围内,也可能铸造过多的库份额、收取过少的抵押品,或通过舍入使提款对用户有利。

一个典型的份额过多攻击流程:

  1. 资金库已有资产和份额,因此份额的价格不是1。
  2. 资金库收到收益、费用、捐赠或发生再基础上调。
  3. 由于资产总量增加而总份额未变,份额价格上升。
  4. 攻击者按假设“一份额等于一资产”的公式存款。
  5. 资金库铸造的份额多于按照当前价格存款应买到的份额。
  6. 攻击者赎回这些份额,获得超过实际贡献比例的资产。

核心不变量很简单:存款后,新用户的持有份额比例应当对应其贡献的价值。如果1%的资产存款产生的份额超过总份额的约1%,则现有持有者被稀释。

哪些Solidity模式导致代币和份额会计错误?

代币和份额会计错误通常源于单位混用、中间值截断或错误地将动态份额价格视为固定。高风险函数包括deposit、mint、withdraw、redeem、借贷额度、费用结算、流动性计算和奖励分配。

危险的模式是直接用存入资产单位铸造份额:

shares = assets * 1e18 / assetScale;

该转换仅适合首次存款,此时通过定义“一份额等于一个标准化资产单位”实现一致。一旦资金库有收益或亏损,存款必须使用当前的兑换率:

shares = assets * totalShares / totalAssets;

即便是这个表达式也需要谨慎对待。先乘可能在unchecked下溢出,先除可能损失精度。小数标准化也带来错误风险。类似USDC的资产通常小数位少于18位的份额代币,协议必须始终区别原始代币单位和标准化会计单位。

常见错误包括:

  • 硬编码1e18而资产使用不同小数位。
  • 份额价格变化后仍以固定一比一速率铸造份额。
  • 先除后乘导致重要精度丢失。
  • 存款或取款均向上舍入,产生累积价值流失。
  • 在用户控制值或比例因子周围使用unchecked。
  • 假设代币余额只会通过正常存取流动。
  • 忽略了带手续费传输、可再基础和生息代币可能使记账缓存失效。

如何修正Solidity份额计算中的算术错误?

修正算术错误应先对资产单位标准化,按照当前池比例计算份额,使用全精度乘除法,选择不会将价值从现有持有者转移给新存款人的舍入方法。合约必须把资产余额和份额供应视为互相关联的会计状态。

以下漏洞资金库接收可配置小数的代币,reportYield增加资产后仍按固定速率铸造标准化份额,用户可获得超过收益前应得的份额比例,然后在更大资产池中赎回:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function transfer(address to, uint256 amount) external returns (bool);
}

contract VulnerableShareVault {
    IERC20 public immutable asset;
    uint8 public immutable assetDecimals;
    uint256 public totalAssets;
    uint256 public totalShares;
    mapping(address => uint256) public shares;

    constructor(address asset_, uint8 decimals_) {
        asset = IERC20(asset_);
        assetDecimals = decimals_;
    }

    function deposit(uint256 assets) external {
        require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");

        uint256 scale = 10 ** assetDecimals;
        // 漏洞:忽略了现有份额价格。
        uint256 minted = (assets * 1e18) / scale;

        totalAssets += assets;
        totalShares += minted;
        shares[msg.sender] += minted;
    }

    function reportYield(uint256 assets) external {
        require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");
        totalAssets += assets;
    }

    function withdraw(uint256 shareAmount) external {
        uint256 assets = (shareAmount * totalAssets) / totalShares;
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
        totalAssets -= assets;
        require(asset.transfer(msg.sender, assets), "transfer failed");
    }
}

修正版本将原始资产单位转换为18位标准化会计单位,按当前兑换率铸造份额。它使用OpenZeppelin的全精度Math.mulDiv,存取款均向下取整,并限制支持的小数位数:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

interface IERC20 {
    function transferFrom(address from, address to, uint256 amount) external returns (bool);
    function transfer(address to, uint256 amount) external returns (bool);
}

contract FixedShareVault {
    IERC20 public immutable asset;
    uint256 public immutable assetScale;
    uint256 public totalAssets;
    uint256 public totalShares;
    mapping(address => uint256) public shares;

    constructor(address asset_, uint8 decimals_) {
        require(decimals_ <= 18, "unsupported decimals");
        asset = IERC20(asset_);
        assetScale = 10 ** decimals_;
    }

    function deposit(uint256 assets) external {
        require(assets > 0, "zero assets");
        require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");

        uint256 normalizedAssets = Math.mulDiv(assets, 1e18, assetScale);
        uint256 normalizedPool = Math.mulDiv(totalAssets, 1e18, assetScale);

        // 修正:按照存款前池比例定价份额。
        uint256 minted = totalShares == 0
            ? normalizedAssets
            : Math.mulDiv(normalizedAssets, totalShares, normalizedPool);

        require(minted > 0, "deposit too small");
        totalAssets += assets;
        totalShares += minted;
        shares[msg.sender] += minted;
    }

    function withdraw(uint256 shareAmount) external {
        uint256 assets = Math.mulDiv(shareAmount, totalAssets, totalShares);
        shares[msg.sender] -= shareAmount;
        totalShares -= shareAmount;
        totalAssets -= assets;
        require(asset.transfer(msg.sender, assets), "transfer failed");
    }
}

如果您的协议通过自定义算术方法定价存款、取款、抵押或流动性,智能合约审计与安全评估能追踪整个交易生命周期中的单位转换和经济不变量。

开发者如何避免算术错误?

开发者应在实现公式前明确定义会计单位,采取保守舍入,并将每次转换视为安全边界。公式应说明每输入含义、小数精度、允许范围和舍入收益者。

技术 防止问题 Gas 或复杂度成本 限制
显式单位标准化 原始代币单位与18位份额单位混用 额外乘除法 需有文档化的小数位规则
Math.mulDiv a * b / c 中溢出和精度丢失 较简单运算更多Gas 不验证经济公式正确性
存取款向下舍入 多铸造与多赎回 可能产生灰尘余额 需有灰尘回收策略
最小份额铸造阈值 舍入得0份额的存款 一次比较 不应意外阻止合法小额用户
限制输入与小数位 不安全的指数运算及极端输入 校验开销 限制必须与支持的资产匹配
不变量测试 检测破坏池会计的状态变更 测试编写成本 不变量须涵盖对抗性序列

在无法保证发行份额安全时使用向下舍入。例如资金库通常存款份额向下舍入,取款资产向下舍入。若产品需用户有利的舍入,应明确补贴差额,而非默默由其他存款人承担。

安全洞察: 按Soken方法论,算术审查始于单位和守恒定律,而非运算符。即使乘法有校验,若结果表示错误的资产、份额、价格或所有权,仍存风险。

有哪些真实事件体现算术错误的影响?

算术错误事件表明,代币与份额会计是协议级安全属性,非单纯代码质量问题。受影响逻辑可能存在于AMM储备、借贷奖励、资金库份额、流动性计算或资产兑换流程中。

协议 日期 损失金额 链
Cetus CLMM 2025-05-22 $223.0M Sui
Compound V2 2021-09-29 $147.0M Ethereum
Uranium Finance 2021-04-28 $57.2M BSC
ValueDefi 2021-05-07 $11.0M BSC
KiiChain 2026-08-22 $9.7M KiiChain
Truebit 2026-01-08 $26.4M Ethereum
Notional V2 2026-09-04 $1.7M Ethereum
USM 2026-08-09 $136K Ethereum

这些事件均有一共性:计算涉及链上价值的分配或估值。失败不必然是未检查的uint256加法错误,而是储备、供应、价格与用户权益间关系的不正确。

DefiLlama记录了2020年至2026年间35起智能合约算术错误事件,总计损失5.61亿美元。对于协议团队,这个历史表明审查会计路径应与访问控制、外部调用处理同等重要。

如何在部署前检测算术错误?

算术错误最好通过审查状态变更和多序列测试经济属性发现,而非手动检查孤立公式。静态分析可识别风险运算符和unchecked块,模糊测试和不变量测试验证账务模型在存取款、收益、亏损及异常小数值时是否成立。

请参考以下专注审查清单:

  1. 列出每个资产、份额、价格、利率和费用变量及其单位、小数位。
  2. 确保每笔存款使用存款前的兑换率。
  3. 确保每笔取款使用与烧毁份额一致的兑换率。
  4. 搜索会计路径中原始乘除、unchecked和硬编码比例常数。
  5. 测试不同小数资产及接近最小存款的情况。
  6. 确认舍入不会铸造不支持的份额或释放不支持的资产。
  7. 在每次用户操作前模拟再基础、捐赠、费用及收益导致的余额变化。
  8. 检查总用户权益不得超过可赎回资产。

Slither工具能标示危险算术模式、未检查块和可疑精度运算,但无法判定份额价格公式经济合理性。Foundry模糊测试结合如totalAssets覆盖所有赎回权益、存取款循环无无效盈利等断言更有效。

不变量测试应生成多用户序列:存款、收益报告、赎回、部分赎回、捐赠及重复小额存款。Soken审查实践还比较预期数学模型与每次状态更新,因为正确公式如果应用于状态变更之前而非之后,依然可能定价错误。

算术安全是跨每次转换维护经济真实性的纪律。资金库可采用Solidity 0.8的检查算术却仍然脆弱,如果它以错误价格铸造权益、假设错误的小数位或向外舍入价值。

首先为每个会计变量编写单位和不变量,然后在添加新资产类型或定价路径前在Foundry测试中编码这些不变量。欲深入研究Solidity安全模式,请访问Soken Hub。

文章作者

常见问题

Solidity智能合约中的算术错误是什么?

Solidity中的算术错误是指在存款、取款、余额、价格、费用、流动性、代币或份额的计算中存在缺陷。这些错误导致超额认领、过度取款或会计不变量失败,往往源于公式、十进制比例、取整方向或数值假设错误。

算术错误如何导致用户取款过多?

当代币、份额或价格计算中公式、十进制比例、取整方向或数值假设错误时,用户可能会铸造超额权利或取款金额超出存入数额。该计算错误地分配了资产无法支撑的经济权利,从而导致过度取款。

如何防止Solidity中的算术错误?

防止算术错误需明确定义每个计算的单位,使用全精度有界算术,并测试公式必须保持的经济不变量。这样能避免错误的公式、十进制比例、取整方向和数值假设,确保代币、份额或价格的计算准确。

仅靠溢出回退能否防止Solidity中的算术错误?

溢出回退不能完全消除算术错误。算术安全还需正确的公式、明确单位、适当的十进制比例、正确取整和合理数值假设。即使没有溢出回退,计算仍可能导致难以辩护的份额或取款权利。

Cetus CLMM损失教会我们算术错误什么?

Cetus CLMM在Sui链上损失2.23亿美元,事件被归类为涉及代币和份额核算的算术错误。该事件提醒我们算术安全不仅是防止溢出回退,每一份铸造的份额都必须代表可辩护的资产权利。

聊天