Solidity错误处理及异常:Assert, Require, Revert和Exceptions

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

如果异常在子调用发生,那么异常会自动 冒泡 到顶层(异常会重新抛出)。除非它们在 try/catch 语句中被捕获。 但是如果是在 send底层函数(low-level functions)如:call, delegatecallstaticcall 的调用里发生异常时, 他们会返回 false (第一个返回值) 而不是 冒泡异常

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

外部调用的异常可以被 try/catch 捕获。

异常包含的错误数据,以错误实例的形式传递回调用者。内置的 Error(string)Panic(uint256) 被特殊函数使用,如下所述。Error 用于“常规”错误条件,而 Panic 用于不应该出现在无错误代码中的错误。

assert 检查异常(Panic) 和 require 检查错误(Error)

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

assert函数创建一个类型的错误Panic(uint256)。在某些情况下,编译器会创建相同的错误,如下所示。

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

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

  • 0x01: 如果你调用 assert 的参数(表达式)结果为 false
  • 0x11: 在unchecked { … }外,如果算术运算结果向上或向下溢出。
  • 0x12; 如果你用零当除数做除法或模运算(例如 或 )。5 / 0 23 % 0
  • 0x21: 如果你将一个太大的数或负数值转换为一个枚举类型。
  • 0x22: 如果你访问一个没有正确编码的存储 byte 数组.
  • 0x31: 如果在空数组上 .pop()
  • 0x32: 如果你访问 bytesN 数组(或切片)的索引太大或为负数。
    (例如: x[i] 而 或 ).i >= x.length i < 0
  • 0x41: 如果你分配了内存过多或创建了的数组太大。
  • 0x51: 如果调用内部函数类型的零初始化变量。

require 函数要么创建一个 Error(string) 类型的错误,要么创建创建一个没有任何数据的错误,并且 require 函数应该用于确认条件有效性,例如输入变量,或合约状态变量是否满足条件,或验证外部合约调用返回的值。

下列情况将会产生一个 Error(string) (或没有数据)的错误:

  1. 如果你调用 require 的参数(表达式)最终结果为 false
  2. 如果你使用 revert()revert("description")
  3. 如果你在不包含代码的合约上执行外部函数调用。
  4. 如果你通过合约接收以太币,而又没有 payable 修饰符的公有函数(包括构造函数和 fallback 函数)。
  5. 如果你的合约通过公有 getter 函数接收 Ether 。

对于以下情况,来自外部调用(如果提供)的错误数据将被转发。这意味着它可以引起 ErrorPanic (或任何其他给出的):

  1. 如果 .transfer() 失败。
  2. 如果你通过消息调用调用某个函数,但该函数没有正确结束(例如, 它耗尽了 gas,没有匹配函数,或者本身抛出一个异常),不包括使用底层操作 callsenddelegatecallcallcodestaticcall 的函数调用。底层操作不会抛出异常,而通过返回 false 来指示失败。
  3. 如果你使用 new 关键字创建合约,但合约创建 没有正确完成

如果您不向 require 提供字符串参数,它将返回空错误数据,甚至不包括错误选择器。

可以给 require 提供一个消息字符串,而 assert 不行。
在下例中,你可以看到如何轻松使用require 检查输入条件以及如何使用 assert 检查内部错误。

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

contract Sharer {
    function sendHalf(address payable addr) public payable returns (uint balance) {
        require(msg.value % 2 == 0, "Even value required.");
        uint balanceBeforeTransfer = address(this).balance;
        addr.transfer(msg.value / 2);
        // 因为转账失败时抛出一个异常,并且不能回调到这里,所以应该没有办法让我们仍然有一半的钱。
        assert(address(this).balance == balanceBeforeTransfer - msg.value / 2);
        return address(this).balance;
    }
}

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

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

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

revert语句/函数

  • 可以使用revert语句revert函数来触发直接还原。
  • revert语句接受一个自定义错误作为不带括号的直接参数: revert CustomError(arg1, arg2);
  • 出于向后兼容的原因,还有revert()函数,它使用圆括号并接受一个字符串:revert(); revert(“description”);
  • 错误数据将被传递回调用者,并可以在那里捕获。使用revert()导致不带任何错误数据的恢复,而revert("description")将创建一个Error(string)错误。
  • 使用自定义错误实例通常比使用字符串描述便宜得多,因为你可以使用错误名称来描述它,该名称仅用4个字节编码。更长的描述可以通过NatSpec提供,而不会产生任何费用。
    下边的例子展示了错误字符串如何使用 revert (等价于 require ) :
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;

contract VendingMachine {
    address owner;
    error Unauthorized();
    function buy(uint amount) public payable {
        if (amount > msg.value / 2 ether)
            revert("Not enough Ether provided.");
        // 另一种方法
        require(
            amount <= msg.value / 2 ether,
            "Not enough Ether provided."
        );
        // 执行购买操作...
    }
    function withdraw() public {
        if (msg.sender != owner)
            revert Unauthorized();

        payable(msg.sender).transfer(address(this).balance);
    }
}

如果直接提供错误原因字符串,则这两个语法是等效的,根据开发人员的偏好选择。

注意:这个 require 函数的求值方式与任何其他函数一样。这意味着在执行函数本身之前会计算所有参数。特别是,在 require(condition, f()) 该函数 f将被执行,即使在 condition 是真的。

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

0x08c379a0                                                         // Error(string) 的函数选择器
0x0000000000000000000000000000000000000000000000000000000000000020 // 数据的偏移量(32)
0x000000000000000000000000000000000000000000000000000000000000001a // 字符串长度(26)
0x4e6f7420656e6f7567682045746865722070726f76696465642e000000000000 // 字符串数据("Not enough Ether provided." 的 ASCII 编码,26字节)

调用者可以使用try/catch检索所提供的消息,如下所示。

revert()之前有一个同样用法的throw关键字,它在v0.4.13版本弃用,在v0.5.0移除。

try/catch

外部调用的失败,可以通过 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*/) {
            //如果在getData内部调用 revert,并提供了一个原因字符串,则执行此操作。 
            errorCount++;
            return (0, false);
        } catch Panic(uint /*errorCode*/) {
            // 这个是在Panic情况下执行,例如一个严重的错误,除以0或溢出。
            //错误代码可以用来确定错误的类型。
            errorCount++;
            return (0, false);
        } catch (bytes memory /*lowLevelData*/) {
            // 这是在使用revert()时执行的
            errorCount++;
            return (0, false);
        }
    }
}

try 关键字后面必须跟一个表示外部函数调用,或合约创建的表达式(new ContractName())
表达式内部的错误不会被捕获(例如,如果它是一个包含内部函数调用的复杂表达式),只会在外部调用本身内部发生还原。
这个 returns 后面的部分(可选)声明与外部调用返回的类型匹配的返回变量。
在没有错误的情况下,这些变量被赋值,并在第一个成功块内继续执行合约。如果到达成功块的末尾,则在 catch 块之后继续执行。

Solidity支持不同类型的 catch 块,具体取决于错误类型:

  • 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子句)而不是前面的子句。

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

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

returnscatch子句中声明的变量仅在后面的块中的作用域中。

注意:如果在对try/catch语句中的返回数据进行解码期间发生错误,则会d导致当前执行的合约出现异常,因此,catch子句中不会捕获该异常。如果在catch Error(string memory reason) 的解码过程中出现错误,并且存在底层catch子句,则会在那里捕获该错误。

注意:如果执行到达catch代码块,则外部调用的状态更改效果已恢复。如果执行达到成功块,效果不会恢复。如果效果已恢复,则执行要么在catch块中继续,要么try/catch语句本身的执行恢复(例如,由于如上所述的解码失败,或者由于没有提供底层catch子句)。

注意:失败调用的原因可能是多方面的。不要假设错误消息直接来自被调用的合约:错误可能发生在调用链的更深处,而被调用的合约只是转发了它。这可能是由于 gas 不足的情况,而不是故意的错误情况:调用者始终在调用中保留至少 1/64 的gas,因此即使被调用的合约耗尽gas,调用者还剩一些gas

你可能感兴趣的:(Solidity错误处理及异常:Assert, Require, Revert和Exceptions)