错误处理

Solidity 如果遇到异常错误,是通过回退状态的方式来进行处理。发生异常时,会撤消当前调用和所有子调用改变的状态变量,同时给调用者返回一个错误标识。

调用者调用某个函数方法,要么成功修改了所有状态变量,要么遇到异常不修改任何状态变量,不存在成功修改部分变量的情况,

Solidity 提供了 requireassertrevert 来处理异常。同时可以使用 error 关键字来实现错误。

跟用错误字符串相比, error 更便宜并且允许你编码额外的数据,还可以用 NatSpec 为用户去描述错误。

Solidity 使用状态恢复异常来处理错误。这种异常将撤消对当前调用(及其所有子调用)中的状态所做的所有更改,并且还向调用者标记错误。

如果异常在子调用发生,那么异常会自动冒泡到顶层(例如:异常会重新抛出),除非他们在 try/catch 语句中捕获了错误。 但是如果是在 send 和 低级 call, delegatecallstaticcall 的调用里发生异常时, 他们会返回 false (第一个返回值) 而不是冒泡异常。

警告注意:根据 EVM 的设计,如果被调用的地址不存在,低级别函数 call, delegatecallstaticcall 第一个返回值同样是 true。 如果需要,请在调用之前检查账号的存在性。

异常可以包含错误数据,以 error 示例 的形式传回给调用者。 内置的错误 Error(string)Panic(uint256) 被作为特殊函数使用,下面将解释。 Error 用于 “常规” 错误条件,而 Panic 用于在(无 bug)代码中不应该出现的错误。

函数 assert 和 require 可用于检查条件并在条件不满足时抛出异常。

⚠️ 注意:永远不要相信错误数据。默认情况下,错误数据会通过外部调用链向上冒泡,这意味着一个合约可能会收到一个它直接调用的任何合约中没有定义的错误。此外,任何合约都可以通过返回与错误签名相匹配的数据来伪造任何错误,即使该错误没有在任何地方定义。

require

require 用来严查某些条件,如果不满足这些雕件,就会回退所有状态的变化。

语法

require(condition[, 'Something bad happened'])

如果条件不满足则撤销状态更改 ,用于检查由输入或者外部组件引起的错误。可以同时提供一个错误消息。

  • require 函数常常用来检查输入变量或状态变量是否满足条件,以及验证调用外部合约的返回值。

  • require 可以有返回值,例如:require(condition, 'Something bad happened');

  • require 的返回值不宜过长,因为返回信息需要消耗 gas。

    • 备注:在例子 2 测试中,并没有证明长度越长,消耗的 gas 越多。

例子

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

contract Demo {
    uint256 public amount = 0;

    function test(uint256 _x) external {
        require(_x < 10, "My error info 1"); // _x >= 10 时候会报错
        amount = _x;
        require(_x > 20); // _x <= 10 时候会报错
    }
}

注解 require 是一个像其他函数一样可被执行的函数。意味着,所有的参数在函数被执行之前就都会被执行。 尤其,在 require(condition, f()) 里,函数 f 会被执行,即便 condition 为 True .

gas 测试

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

contract Demo1 {
    // 21611 gas
    function test1(uint256 _x) external pure {
        require(
            _x < 10,
            "My error info 1 balalaba balalaba balalaba balalaba balalaba "
        );
    }
}
contract Demo2 {
    // 21611 gas
    function test2(uint256 _x) external pure {
        require(_x < 10, "Error");
    }
}
contract Demo3 {
    // 21611 gas
    function test3(uint256 _x) external pure {
        require(_x < 10, "error");
    }
}

使用场景

  • 验证用户输入,例如:require(input_var>100)

  • 验证外部合约的调用结果,例如:require(external.send(amount))

  • 在执行状态更改操作之前验证状态条件,例如:require(block.number > 49999)require(balance[msg.sender]>=amount)

  • require() 语句的失败报错,应该被看作一个正常的判断语句流程不能通过的事件。

一般来说,使用 require() 的频率更多,通常应用于函数的开头和函数修改器内。

一句话: require() 函数用于检测输入变量或状态变量是否满足条件,以及验证调用外部合约的返回值。

assert

语法

assert(bool condition)

如果不满足条件,则会导致 Panic 错误,则撤销状态更改 - 用于检查内部错误。

assert()require() 语句都需要满足括号中的条件,才能进行后续操作,若不满足则抛出错误。

  • assert:断言,不能包括报错信息的。

例子

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

contract Demo {
    uint256 public amount = 0;

    function test1(uint256 _x) external {
        require(_x < 10, "My error info 1"); // _x >= 10 时候会报错
        amount = _x;
        assert(amount == _x); // 必须等于_x,否则抛出错误
    }

    function test2(uint256 _x) external {
        require(_x < 10, "My error info 1"); // _x >= 10 时候会报错
        amount = _x;
        assert(amount == 8); // 必须等于8,否则抛出错误
    }
}

使用场景

  • 检查溢出

  • 检查不变量

  • 更改后验证状态

  • 预防永远不会发生的情况

  • assert()语句的失败报错,意味着发生了代码层面的错误事件,很大可能是合约中有一个 bug 需要修复。

  • 也可以智能合约写测试。

一般来说,使用 assert()的频率较少,通常用于函数的结尾。基本上,require() 应该用于检查条件,而 assert() 只是为了防止发生任何非常糟糕的事情。

扩展

assert 函数会创建一个 Panic(uint256) 类型的错误。同样的错误在以下列出的特定情形会被编译器创建。

assert 函数应该只用于测试内部错误,检查不变量,正常的函数代码永远不会产生 Panic, 甚至是基于一个无效的外部输入时。 如果发生了,那就说明出现了一个需要你修复的 bug。如果使用得当,语言分析工具可以识别出那些会导致 Panic 的 assert 条件和函数调用。

下列情况将会产生一个 Panic 异常: 错误数据会提供的错误码编号,用来指示 Panic 的类型:

  1. 0x00: 用于常规编译器插入的 Panic。

  2. 0x01: 如果你调用 assert 的参数(表达式)结果为 false 。

  3. 0x11: 在 unchecked { ... } 外,如果算术运算结果向上或向下溢出。

  4. 0x12; 如果你用零当除数做除法或模运算(例如 5 / 023 % 0 )。

  5. 0x21: 如果你将一个太大的数或负数值转换为一个枚举类型。

  6. 0x22: 如果你访问一个没有正确编码的存储 byte 数组.

  7. 0x31: 如果在空数组上 .pop()

  8. 0x32: 如果你访问 bytesN 数组(或切片)的索引太大或为负数。(例如: x[i]i >= x.lengthi < 0).

  9. 0x41: 如果你分配了太多的内内存或创建了太大的数组。

  10. 0x51: 如果你调用了零初始化内部函数类型变量。

revert

语法: revert([string memory reason])

  • 使用 revert:抛出错误,它使用圆括号接受一个字符串:语句将一个自定义的错误作为直接参数,没有括号:

    revert();
    revert("description");
    
  • 使用 revert() 会触发一个没有任何错误数据的回退,而 revert("description") 会产生一个 Error(string) 错误。

  • 使用 revert:触发自定义错误 ·revert CustomError(arg1, arg2);

    • 可以接收参数,方便判断。比如可以传入 msg.sender / 函数参数 等

终止运行并撤销状态更改。可以同时提供一个解释性的字符串。

例子

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

contract ErrorDemo {
    function testRevert(uint256 _x) external pure {
        if (_x > 10) {
            revert("_x > 10");
        }
    }

    // 自定义错误
    error MyError(address call, uint256 _i);

    function testCustomError(uint256 _x) external view {
        if (_x > 10) {
            revert MyError(msg.sender, _x);
        }
    }
}

只要参数没有额外的附加效果,使用 if (!condition) revert(...);require(condition, ...); 是等价的,例如当参数是字符串的情况。

三种方式的总结

require、assert 不同点

  • require(false) 编译为 0xfd,这是 revert() 的操作码,所以会退还所有剩余的 gas,同时可以返回一个自定义的报错信息

  • assert(false) 编译为 0xfe,这是一个无效的操作码,所以会消耗掉所有剩余的 gas,并恢复所有的操作

  • require 的 gas 消耗要小于 assert,而且可以有返回值,使用更为灵活。

错误信息:

require 函数可以创建无错误提示的错误,也可以创建一个 Error(string)类型的错误。 require函数应该用于确认条件有效性,例如输入变量,或合约状态变量是否满足条件,或验证外部合约调用返回的值。

当前不可以使用混合使用 require 和自定义错误,而是需要使用 if (!condition) revert CustomError();

下列情况将会产生一个 Error(string)(或无错误提示)的错误:

  1. 如果你调用 require(x) ,而 x 结果为 false

  2. 如果你使用 revert() 或者 revert("description")

  3. 如果你在不包含代码的合约上执行外部函数调用。

  4. 如果你通过合约接收以太币,而又没有 payable 修饰符的公有函数(包括构造函数和 fallback 函数)。

  5. 如果你的合约通过公有 getter 函数接收 Ether 。

在下面的情况下,来自外部调用的错误数据(如果提供的话)被转发,这意味可能ErrorPanic 都有可能触发。

  1. 如果 .transfer() 失败。

  2. 如果你通过消息调用调用某个函数,但该函数没有正确结束(例如, 它耗尽了 gas,没有匹配函数,或者本身抛出一个异常),不包括使用低级别 callsenddelegatecallcallcodestaticcall 的函数调用。低级操作不会抛出异常,而通过返回 false 来指示失败。

  3. 如果你使用 new 关键字创建合约,但合约创建没有正确结束。

你可以选择给 require 提供一个消息字符串,但 assert 不行。

如果你没有为 require 提供一个字符串参数,它会用空错误数据进行 revert, 甚至不包括错误选择器。

在下例中,你可以看到如何轻松使用 require 检查输入条件以及如何使用 assert 检查内部错误.


    // SPDX-License-Identifier: GPL-3.0
    pragma solidity >=0.5.0 <0.9.0;

    contract Sharer {
        function sendHalf(address addr) public payable returns (uint balance) {
            require(msg.value % 2 == 0, "Even value required.");
            uint balanceBeforeTransfer = this.balance;
            addr.transfer(msg.value / 2);

            // 由于转账函数在失败时抛出异常并且不会调用到以下代码,因此我们应该没有办法检查仍然有一半的钱。
            assert(this.balance == balanceBeforeTransfer - msg.value / 2);
            return this.balance;
        }
    }

在内部, Solidity 对异常执行回退操作(指令 0xfd ),从而让 EVM 回退对状态所做的所有更改。回退的原因是无法安全地继续执行,因为无法达到预期的结果。 因为我们想要保持交易的原子性,最安全的动作是回退所有的更改,并让整个交易(或至少调用)没有任何新影响。

在这两种情况下,调用者都可以使用 try/ catch来应对此类失败,但是被调用函数的更改将始终被还原。

请注意, 在 0.8.0 之前,Panic 异常使用 invalid 指令,其会消耗了所有可用的 gas。 使用 require 的异常,在 Metropolis 版本之前会消耗所有的 gas。

require、assert、revert 共同点

以下三个语句的功能完全相同:

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

// assert
assert(msg.sender == owner);

例子

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

contract ErrorDemo {
    function testRequire(uint256 _x) external pure {
        require(_x > 10, "_x > 10");
    }

    function testRevert(uint256 _x) external pure {
        if (_x > 10) {
            revert("_x > 10");
        }
    }

    function testAssert(uint256 _x) external pure {
        assert(_x == 10);
    }

    error MyError(address call, uint256 _i);

    function testCustomError(uint256 _x) external view {
        if (_x > 10) {
            revert MyError(msg.sender, _x);
        }
    }
}

自定义 Error

Solidity 中的错误(关键字 error)提供了一种方便且省 gas 的方式来向用户解释为什么一个操作会失败。它们可以被定义在合约(包括接口和库)内部和外部。

  • error 只能通过 revert 触发

  • 使用自定义 error 抛出错误,向调用者描述错误信息。

  • 开发者可以在任何时候,任何条件下触发 自定义 Error

  • error 花费更少的 gas。

  • error 可以定义在 contract 之外。

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

// 自定义错误
error MyError1(address call, uint256 _i);

contract ErrorDemo {
    // 自定义错误
    error MyError2(address call, uint256 _i);

    function testCustom1(uint256 _x) external view {
        if (_x > 10) {
            revert MyError1(msg.sender, _x);
        }
    }

    function testCustom2(uint256 _x) external view {
        if (_x > 10) {
            revert MyError2(msg.sender, _x);
        }
    }
}

错误必须与 revert 语句 一起使用。它会还原当前调用中的发生的所有变化,并将错误数据传回给调用者。

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

/// 转账时,没有足够的余额。
/// @param available balance available.
/// @param required requested amount to transfer.
error InsufficientBalance(uint256 available, uint256 required);

contract TestToken {
    mapping(address => uint) balance;
    function transfer(address to, uint256 amount) public {
        if (amount > balance[msg.sender])
            revert InsufficientBalance({
                available: balance[msg.sender],
                required: amount
            });
        balance[msg.sender] -= amount;
        balance[to] += amount;
    }
    // ...
}

错误不能被重写或覆盖,但是可以继承。只要作用域不同,同一个错误可以在多个地方定义。只能使用 revert 语句创建错误实例。

错误产生的数据,会通过 revert 操作传递给调用者,可以交由链外组件处理或在 try/catch 语句 中捕获它。注意,只有外部调用的错误才能被捕获。发生在内部调用或同一函数内的 revert 不能被捕获。

如果是调用 Error(string) 函数,这里提供的字符串将经过 ABI 编码。revert("Not enough Ether provided."); 会产生如下的十六进制错误返回值:

// Error(string) 的函数选择器
0x08c379a0

// 数据的偏移量(32)
0x0000000000000000000000000000000000000000000000000000000000000020

// 字符串长度(26)
0x000000000000000000000000000000000000000000000000000000000000001a

// 字符串数据("Not enough Ether provided." 的 ASCII 编码,26字节)
0x4e6f7420656e6f7567682045746865722070726f76696465642e000000000000

提示信息可以通过 try/catch (下面介绍)来获取到。

revert()之前有一个同样用法的 throw,它在 0.4.13 版本弃用,在 0.5.0 移除。

Natspec Error

使用一个自定义的错误实例通常会比字符串描述便宜得多。因为你可以使用错误名来描述它,它只被编码为四个字节。更长的描述可以通过 NatSpec 提供,这不会产生任何费用。

通过三个斜杠 /// 定义的错误,它比require更省 gas。推荐代替 require 使用。

如果错误没有任何参数,错误只需要四个字节的数据,你可以使用 NatSpec,来进一步解释错误背后的原因,NatSpec 不会存储在链上。这个方式使得它同时也是一个非常便宜和方便的错误报告功能。

更具体地说,一个错误实例的 ABI 编码方式与调用相同名称和类型的函数的方式相同,它作为revert 操作码的返回数据使用。 这意味着错误数据由一个 4 字节的选择器和 ABI-encoded 数据组成。选择器是错误的签名的 keccak256 哈希的前四个字节组成。

代码结构如下

/// this is netspec error info
error MyError1();

例子如下:

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

contract ErrorDemo {
    // netspec error
    /// this is netspec error info,this is netspec error info,this is netspec error info,this is netspec error info,this is netspec error info
    error MyError1();

    /// 这是一个错误!老铁,你的输入参数错啦,必须要大于10的数字才可以通过!
    error MyError2();

    // 21647 gas
    function test1(uint256 _x) external pure {
        if (_x < 10) {
            revert MyError1();
        }
    }

    // 21691 gas
    function test2(uint256 _x) external pure {
        if (_x < 10) {
            revert MyError2();
        }
    }

    // 22036 gas
    function test3(uint256 _x) external pure {
        require(
            _x > 10,
            "this is netspec error info,this is netspec error info,this is netspec error info,this is netspec error info,this is netspec error info"
        );
    }

    // 21974 gas
    function test4(uint256 _x) external pure {
        require(
            _x > 10,
            unicode"这是一个错误!老铁,你的输入参数错啦,必须要大于10的数字才可以通过!"
        );
    }
}

try catch

在当前合约发起对外部合约的调用,如果外部合约调用执行失败被 revert,外部合约状态会被回滚,当前合约状态也会被回滚。这是正常的逻辑。

但有时候我们并不想这样,要是能够捕获外部合约调用异常,然后根据情况做自己的处理会更好吗!所以,这种场景下适应于使用 try...catch 语句。

try catch仅用于 外部函数调用 和合约创建调用。

  • 外部函数调用

  • 合约创建调用

语法

try this.count() {
    // 成功逻辑
    return "success";
} catch Error(string memory reason) {
    // 失败的逻辑: require / revert
    // 调用 count() 失败时执行,通常是不满足 require 语句条件或触发 revert 语句时所引起的调用失败
    return reason;
} catch (bytes memory) {
    // 失败逻辑
    // 调用 count() 异常时执行,通常是触发 assert 语句或除 0 等比较严重错误时会执行
    return "assert error";
}

上面的逻辑也可以简写如下

try this.count() {
    // 成功逻辑
    return "success";
} catch (bytes memory) {
    // 失败逻辑: require / revert / assert
     return "assert error";
}

例子

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

contract Manager {
    // function count() public pure returns (int256) {
    //     require(1 == 2, "require error");
    //     return 2;
    // }

    function count() public pure returns (int256) {
        assert(1 == 2);
        return 2;
    }

    function test() public view returns (string memory) {
        // this 代表当前函数
        try this.count() {
            return "success";
        } catch Error(string memory reason) {
            // reason 是出错原因
            // 调用 count() 失败时执行,通常是不满足 require 语句条件
            // 或触发 revert 语句时所引起的调用失败
            return reason;
        } catch (bytes memory) {
            // 调用 count() 异常时执行,通常是触发 assert 语句或除 0 等比较严重错误时会执行
            return "assert error";
        }
    }
}

也可以去掉catch Error(string memory reason),只使用 catch (bytes memory)

如下的测试

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

contract Manager {
    function count() public pure returns (int256) {
        require(1 == 2, "require error");
        return 2;
    }

    function test() public view returns (string memory) {
        // this 代表当前函数
        try this.count() {
            return "success";
        } catch (bytes memory) {
            // 调用 count() 异常时执行,通常是触发 assert 语句或除 0 等比较严重错误时会执行
            return "assert error";
        }
    }
}

外部调用的失败,可以通过 try/catch 语句来捕获,例如:

 // SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.1;

interface DataFeed { function getData(address token) external returns (uint value); }

contract FeedConsumer {
    DataFeed feed;
    uint errorCount;
    function rate(address token) public returns (uint value, bool success) {
        // 如果错误超过 10 次,永久关闭这个机制
        require(errorCount < 10);
        try feed.getData(token) returns (uint v) {
            return (v, true);
        } catch Error(string memory /*reason*/) {
            // This is executed in case
            // revert was called inside getData
            // and a reason string was provided.
            errorCount++;
            return (0, false);
        }  catch Panic(uint /*errorCode*/) {
            // This is executed in case of a panic,
            // i.e. a serious error like division by zero
            // or overflow. The error code can be used
            // to determine the kind of error.
            errorCount++;
            return (0, false);
        } catch (bytes memory /*lowLevelData*/) {
            // This is executed in case revert() was used。
            errorCount++;
            return (0, false);
        }
    }
}

try 关键词后面必须有一个表达式,代表外部函数调用或合约创建(new ContractName())。

以下内容摘自文档:

在表达式上的错误不会被捕获(例如,如果它是一个复杂的表达式,还涉及内部函数调用),只有外部调用本身发生的 revert 可以捕获。 接下来的 returns 部分(是可选的)声明了与外部调用返回的类型相匹配的返回变量。在没有错误的情况下,这些变量被赋值,合约将继续执行第一个成功块内代码。如果到达成功块的末尾,则在 catch 块之后继续执行。

Solidity 根据错误的类型,支持不同种类的捕获代码块:

  • catch Error(string memory reason) { ... }: 如果错误是由 revert("reasonString")require(false, "reasonString")(或导致这种异常的内部错误)引起的,则执行这个 catch 子句。

  • catch Panic(uint errorCode) { ... }: 如果错误是由 panic 引起的(如: assert 失败,除以 0,无效的数组访问,算术溢出等),将执行这个 catch 子句。

  • catch (bytes memory lowLevelData) { ... }: 如果错误签名不符合任何其他子句,如果在解码错误信息时出现了错误,或者如果异常没有一起提供错误数据。在这种情况下,子句声明的变量提供了对低级错误数据的访问。

  • catch { ... }: 如果你对错误数据不感兴趣,你可以直接使用 catch { ... } (甚至是作为唯一的 catch 子句) 而不是前面几个 catch 子句。

有计划在未来支持其他类型的错误数据。 ErrorPanic 字符串目前是按原样解析的,不作为标识符处理。

为了捕捉所有的错误情况,你至少要有子句 catch { ... }catch (bytes memory lowLevelData) { ... }.

returnscatch 子句中声明的变量只在后面的块的范围内有效。

注解: 如果在 try/catch 语句内部返回的数据解码过程中发生错误,这将导致当前执行的合约出现异常,如此,它不会在 catch 子句中被捕获到。如果在 catch Error(string memory reason) 的解码过程中出现错误,并且有一个低级的 catch 子句,那么这个错误就会在低级 catch 子句被捕获。

注解: 如果执行到一个 catch 子句,那么外部调用的状态改变已经被回退了。如果执行到了成功块,那么外部调用的状态改变是有效的。如果状态改变已经被回退,那么要么在 catch 块中继续执行,要么是 try/catch 语句的执行本身被回退(例如由于上面提到的解码失败或由于没有提供低级别的 catch 子句时)。

注解:调用失败背后的原因可能是多方面的。请不要认为错误信息是直接来自被调用的合约。错误可能发生在调用链的更深处,而被调用的合约只是转发了(冒泡)错误。 另外,这可能是由于 out-of-gas 情况,而不是一个逻辑错误状况:调用者总是在调用中保留至少 1/64 的 gas,这样即使被调合约 gas 用完,调用方仍有一些 gas 预留(处理剩余逻辑)。