阅读(3985) (10)

错误处理:断言、要求、还原和异常

2022-05-12 15:18:26 更新

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

当子调用中发生异常时,它们会自动“冒泡”(即异常被重新抛出),除非它们在try/catch语句中被捕获。此规则的例外是send 低级函数call,delegatecall和 staticcall: 它们false在出现异常时作为第一个返回值返回,而不是“冒泡”。

警告

作为 EVM 设计的一部分,低级函数call,如果调用的帐户不存在delegatecall, 则作为它们的第一个返回值返回staticcalltrue如果需要,必须在致电之前检查帐户是否存在。

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

恐慌通过assert和错误通过require

便利功能assertrequire可用于检查条件并在不满足条件时抛出异常。

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

Assert 只能用于测试内部错误和检查不变量。正常运行的代码不应该造成恐慌,即使是在无效的外部输入上也是如此。如果发生这种情况,那么您的合同中有一个错误,您应该修复它。语言分析工具可以评估你的合约以识别会导致恐慌的条件和函数调用。

在以下情况下会生成 Panic 异常。与错误数据一起提供的错误代码指示了恐慌的类型。

  1. 0x00:用于通用编译器插入的恐慌。

  2. 0x01:如果您assert使用评估为假的参数调用。

  3. 0x11:如果算术运算导致块外下溢或溢出。unchecked { ... }

  4. 0x12; 如果您将或除以零(例如或)。5 / 023 % 0

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

  6. 0x22:如果访问的存储字节数组编码不正确。

  7. 0x31:如果你调用.pop()一个空数组。

  8. 0x32:如果您在越界或负索引处访问数组bytesN或数组切片(即x[i]where或)。i >= x.lengthi < 0

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

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

require函数要么创建一个没有任何数据的错误,要么创建一个类型为 的错误Error(string)。它应该用于确保在执行之前无法检测到的有效条件。这包括对外部合约调用的输入或返回值的条件。

笔记

目前无法将自定义错误与require. 请改用。if (!condition) revert CustomError();

Error(string)编译器在以下情况下会生成异常(或没有数据的异常):

  1. 调用require(x)wherex计算结果为false

  2. 如果您使用revert()revert("description")

  3. 如果您针对不包含代码的合约执行外部函数调用。

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

  5. 如果你的合约通过公共 getter 函数接收以太币。

对于以下情况,将转发来自外部调用(如果提供)的错误数据。这意味着它可能会导致错误恐慌(或给出的任何其他内容):

  1. 如果一个.transfer()失败。

  2. 如果您通过消息调用调用一个函数,但它没有正确完成(即,它耗尽了气体,没有匹配的函数,或者本身抛出异常),除非使用低级操作 callsenddelegatecall,callcodestaticcall 。低级操作从不抛出异常,而是通过返回来指示失败false

  3. 如果您使用new关键字创建合同,但合同创建未正确完成

您可以选择为 提供消息字符串require,但不能为提供消息字符串assert

笔记

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

以下示例显示了如何使用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);
        // Since transfer throws an exception on failure and
        // cannot call back here, there should be no way for us to
        // still have half of the money.
        assert(address(this).balance == balanceBeforeTransfer - msg.value / 2);
        return address(this).balance;
    }
}

在内部,Solidity 执行还原操作(指令 0xfd)。这会导致 EVM 恢复对状态所做的所有更改。恢复的原因是没有安全的方法继续执行,因为没有发生预期的效果。因为我们要保持事务的原子性,所以最安全的做法是还原所有更改并使整个事务(或至少调用)无效。

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

笔记

用于使用invalidSolidity 0.8.0 之前的操作码的紧急异常,消耗了调用可用的所有气体。require在 Metropolis 发布之前,用于消耗所有 gas 的异常。

revert

可以使用revert语句和revert函数触发直接还原。

revert语句将自定义错误作为不带括号的直接参数:

恢复自定义错误(arg1,arg2);

出于向后兼容的原因,还有一个revert()函数,它使用括号并接受一个字符串:

恢复(); 还原(“描述”);

错误数据将被传递回调用者,并且可以在那里被捕获。使用revert()会导致没有任何错误数据的还原,而revert("description") 会产生Error(string)错误。

使用自定义错误实例通常会比字符串描述便宜得多,因为您可以使用错误名称来描述它,它仅编码为四个字节。可以通过 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.");
        // Alternative way to do it:
        require(
            amount <= msg.value / 2 ether,
            "Not enough Ether provided."
        );
        // Perform the purchase.
    }
    function withdraw() public {
        if (msg.sender != owner)
            revert Unauthorized();

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

这两种方式and是等价的,只要参数and没有副作用,例如,如果它们只是字符串。if (!condition) revert(...);require(condition, ...);revertrequire

笔记

require函数与任何其他函数一样被评估。这意味着在执行函数本身之前评估所有参数。特别是在函数被执行时即使 为真。require(condition, f())fcondition

提供的字符串是abi 编码的,就好像它是对函数的调用一样Error(string)。在上面的示例中,返回以下十六进制作为错误返回数据:revert("Not enough Ether provided.");

0x08c379a0                                                         // Function selector for Error(string)
0x0000000000000000000000000000000000000000000000000000000000000020 // Data offset
0x000000000000000000000000000000000000000000000000000000000000001a // String length
0x4e6f7420656e6f7567682045746865722070726f76696465642e000000000000 // String data

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

笔记

曾经有一个关键字与0.4.13 版中已弃用并在 0.5.0 版中删除的throw语义相同。revert()

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) {
        // Permanently disable the mechanism if there are
        // more than 10 errors.
        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()returnscatch

Solidity 根据错误类型支持不同类型的 catch 块:

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

  • catch Panic(uint errorCode) { ... }:如果错误是由恐慌引起的,即失败assert、被零除、无效数组访问、算术溢出等,则将运行此 catch 子句。

  • catch (bytes memory lowLevelData) { ... }:如果错误签名与任何其他子句不匹配,如果在解码错误消息时出现错误,或者如果没有提供错误数据和异常,则执行此子句。在这种情况下,声明的变量提供对低级错误数据的访问。

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

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

为了捕获所有错误情况,您至少必须有子句 或子句。catch { ...}catch (bytes memory lowLevelData) { ... }

returns和子句中声明的变量catch仅在后面的块中。

笔记

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

笔记

如果执行到达一个catch-block,则外部调用的状态改变效果已经恢复。如果执行到达成功块,则效果不会恢复。如果效果已恢复,则在 catch 块中继续执行或 try/catch 语句本身的执行恢复(例如,由于上述解码失败或由于未提供低级 catch 子句)。

笔记

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