在 Solidity 智能合约开发中,失败并不可怕,可怕的是失败后状态不明确、资金不安全、调用方摸不着头脑。EVM 的一个重要特性是:当合约执行中发生错误时,会回滚所有状态更改,并退还未使用的 Gas。因此,正确使用错误处理机制,能够让合约在异常情况下安全地停止,而不是留下一地鸡毛。


一、 三种主要的错误处理方式

语句 用途 特点
require(condition, "msg") 检查外部输入、函数前置条件 条件不满足时抛错并回滚,退还剩余 Gas,带错误信息
revert("msg") 主动触发错误并中断执行 常用于多层逻辑判断中提前退出
assert(condition) 检查内部不变量(invariant) 条件为 false 时触发 Panic 错误,消耗所有剩余 Gas,表示严重逻辑错误

二、 自定义错误(Custom Error)

Solidity 0.8.4 引入了 Custom Error,可以用来代替 require/revert 的字符串错误信息,优势是 更节省 Gas

error Unauthorized(address caller);
error InsufficientBalance(uint256 available, uint256 required);

触发方法:

if (msg.sender != owner) {
    revert Unauthorized(msg.sender);
}

三、 错误触发后的状态回滚

  • 原子性:Solidity 中一次交易内的所有状态修改要么全部生效,要么全部回滚。
  • 资金安全:如果中途发生 require/revert/assert 抛错,之前的转账、变量修改统统不生效。
  • 多步操作:需要考虑调用链上其他合约的回滚影响。

四、 Foundry 示例

src/Bank.sol

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

contract Bank {
    mapping(address => uint256) public balances;
    address public owner;

    error Unauthorized(address caller);
    error InsufficientBalance(uint256 available, uint256 required);

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {
        require(msg.value > 0, "Deposit must be > 0");
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        uint256 bal = balances[msg.sender];
        if (bal < amount) {
            revert InsufficientBalance(bal, amount);
        }
        balances[msg.sender] -= amount;
        payable(msg.sender).transfer(amount);
    }

    function emergencyWithdraw() external {
        if (msg.sender != owner) {
            revert Unauthorized(msg.sender);
        }
        payable(owner).transfer(address(this).balance);
    }

    function internalCheck() external pure {
        // 如果条件不满足,将触发 Panic(uint256) 错误
        assert(false);
    }
}

test/Bank.t.sol

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

import "forge-std/Test.sol";
import "../src/Bank.sol";

contract BankTest is Test {
    Bank bank;
    address user1 = address(0x123);
    address user2 = address(0x456);

    function setUp() public {
        bank = new Bank();
        vm.deal(user1, 5 ether);
        vm.deal(user2, 2 ether);
    }

    function testDeposit() public {
        vm.prank(user1);
        bank.deposit{value: 1 ether}();
        assertEq(bank.balances(user1), 1 ether);
    }

    function testWithdrawSuccess() public {
        vm.startPrank(user1);
        bank.deposit{value: 2 ether}();
        bank.withdraw(1 ether);
        assertEq(bank.balances(user1), 1 ether);
        vm.stopPrank();
    }

    function testWithdrawFail_CustomError() public {
        vm.prank(user1);
        vm.expectRevert(
            abi.encodeWithSelector(
                Bank.InsufficientBalance.selector,
                0,
                1 ether
            )
        );
        bank.withdraw(1 ether);
    }

    function testEmergencyWithdrawFail_Unauthorized() public {
        vm.prank(user2);
        vm.expectRevert(
            abi.encodeWithSelector(
                Bank.Unauthorized.selector,
                user2
            )
        );
        bank.emergencyWithdraw();
    }

    function testAssertPanic() public {
        vm.expectRevert(); // Panic(uint256) is a generic revert for assert
        bank.internalCheck();
    }
}

执行测试:

➜  counter git:(main) ✗ forge test --match-path test/Bank.t.sol -vvv
[⠊] Compiling...
[⠒] Compiling 2 files with Solc 0.8.30
[⠑] Solc 0.8.30 finished in 528.49ms
Compiler run successful!

Ran 5 tests for test/Bank.t.sol:BankTest
[PASS] testAssertPanic() (gas: 8270)
[PASS] testDeposit() (gas: 42157)
[PASS] testEmergencyWithdrawFail_Unauthorized() (gas: 14240)
[PASS] testWithdrawFail_CustomError() (gas: 14957)
[PASS] testWithdrawSuccess() (gas: 51239)
Suite result: ok. 5 passed; 0 failed; 0 skipped; finished in 4.35ms (2.73ms CPU time)

Ran 1 test suite in 207.34ms (4.35ms CPU time): 5 tests passed, 0 failed, 0 skipped (5 total tests)

五、 安全建议

  1. 外部输入用 require 检查,防止无效参数进入业务逻辑。
  2. 多分支逻辑中可用 revert 提前退出,保持代码可读性。
  3. 关键不变量用 assert 保证,若断言失败说明合约存在漏洞。
  4. 推荐使用 Custom Error 代替字符串错误信息,节省部署和执行 Gas。
  5. 测试必须覆盖失败场景,验证合约在异常情况下的安全性和可预期行为。

孟斯特

声明:本作品采用署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)进行许可,使用时请注明出处。
Author: mengbin
blog: mengbin
Github: mengbin92
腾讯云开发者社区:孟斯特