solidity:错误处理

526 阅读14分钟

理解assert与require

在Solidity中,assertrequire是两种用于处理错误和异常情况的关键函数,但它们各自有不同的用途和行为方式。

Assert

assert用于检查内部错误和验证不变性条件。它通常用于检测合约中的bug,比如逻辑错误或不应该发生的条件。

  • 用途assert主要用于确保代码在逻辑上始终如一,用于检查那些永远不应该为假的条件。例如,检查内部状态是否与预期一致,或验证函数后的某些后置条件。
  • 行为:如果assert的条件评估为false,交易会回滚,并消耗所有提供的Gas。这意味着assert应该只在表示严重的、不可恢复的错误(通常是合约内部的错误)时使用。
  • 异常类型:在Solidity 0.8及以后的版本中,失败的assert会触发一个Panic类型的异常。

Require

require用于验证函数的输入和外部组件的状态,它是保证函数正常运行的前提条件。

  • 用途require用于输入验证、条件检查,以及确保在执行操作之前满足特定条件。例如,检查用户输入、验证合约状态或确保满足外部合约调用的条件。
  • 行为:如果require的条件评估为false,交易同样会回滚,但只消耗到require语句为止的Gas。这使得require成为处理用户输入和条件错误的理想选择。
  • 异常类型:失败的require会触发一个Error类型的异常,并允许你提供一个错误信息,如require(x > 0, "x must be positive")

对比

  • 使用场景assert用于内部错误检查(比如检查不变量),而require用于验证外部条件(如输入有效性或合约状态)。
  • Gas消耗assert失败会消耗所有提供的Gas,因为它指示了一个严重的、不应该发生的错误。而require失败只会消耗到出错点为止的Gas,更适用于能够预料到的错误处理。
  • 错误类型:Solidity 0.8及以后的版本中,assert触发Panicrequire触发Error

使用这些函数时,选择合适的一个对于合约的正确性和Gas效率都非常重要。正确使用assertrequire可以帮助开发者创建更安全、更可靠的智能合约。

理解 assert 与 Panic

在Solidity中,assertPanic 是用来处理合约中的错误和异常的关键部分。它们各自有特定的用途和行为方式。

Assert

assert 函数用于检查代码的内部一致性和不变性。它通常用于检测合约中的bug,比如逻辑错误或不应该发生的条件。如果assert的条件计算结果为false,则会触发异常并回退(撤销)所有在当前交易中进行的状态更改。

用途示例:

  • 检查内部错误或逻辑不一致。
  • 验证不应该由用户输入或外部交互改变的不变量。

特点:

  • assert 不应该用于检查外部条件(如输入验证),而应用于内部一致性检查。
  • 如果assert失败,它会引发一个类型为Panic的错误,并使用所有剩余的Gas。

Panic

Panic 是一种特殊的错误类型,它在Solidity 0.8.0及以后版本中被引入。在以前的版本中,失败的assert会使用invalid操作码,这消耗了所有剩余的Gas。在0.8.0以后,Panic用于提供更多关于失败原因的信息。

Panic可以由以下情况触发:

  • assert语句失败。
  • 算术操作导致下溢或上溢(在未使用unchecked块的情况下)。
  • 零除错误。
  • 数组访问越界。
  • 其他低级错误。

异常处理的改变

Solidity 0.8.0之前的版本中,assert失败会消耗所有的Gas,使得调试和异常处理变得困难。但从0.8.0开始,Panic错误会提供一个错误代码,这使得开发者可以更容易地理解问题所在。同时,它不会像以前那样消耗所有的Gas,而是返回剩余的Gas。

总结

assert 应该用于那些“永远不应该发生”的情况,主要是用来检查代码的内部一致性。如果assert触发了,那么几乎可以肯定是合约代码中存在bug。而Panic错误提供了一种更有效的方式来处理这些严重的内部错误,相比于以前版本消耗所有Gas的行为,它提供了更多的灵活性和信息。

理解solidity错误处理

在Solidity中,处理错误主要使用状态回退异常。这种异常会撤销当前调用(及其所有子调用)中对状态的所有更改,并向调用者标记错误。

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

警告

由于EVM的设计,如果被调用的账户不存在,低级函数call、delegatecall和staticcall会将true作为其第一个返回值返回。如果需要,必须在调用前检查账户存在。

异常可以包含错误数据,这些数据以错误实例的形式传回给调用者。内置的错误Error(string)和Panic(uint256)由特殊函数使用,如下所述。Error用于“常规”错误条件,而Panic用于代码中不应存在的错误。

通过assert产生Panic,通过require产生Error 便捷函数assert和require可用于检查条件并在条件不满足时抛出异常。

assert函数会创建一个Panic(uint256)类型的错误。编译器在下列情况下也会创建相同的错误。

Assert应仅用于测试内部错误和检查不变量。正常运行的代码绝不应创建Panic,即使在无效的外部输入上也是如此。如果发生这种情况,则合约中存在您应该修复的错误。语言分析工具可以评估您的合约,以识别将导致Panic的条件和函数调用。

以下情况会产生Panic异常。错误数据所提供的错误代码指示恐慌的种类。

  • 0x00:用于泛型编译器插入的恐慌。
  • 0x01:如果您调用assert,其参数计算结果为false。
  • 0x11:在未经unchecked { ... } 块的情况下,算术运算导致下溢或上溢。
  • 0x12;如果你除以或模零(例如 5 / 0 或 23 % 0)。
  • 0x21:如果将过大或负数的值转换为枚举类型。
  • 0x22:如果访问编码不正确的存储字节数组。
  • 0x31:如果对空数组调用.pop()。
  • 0x32:如果访问数组、bytesN或数组切片的越界或负索引(即x[i],其中i >= x.length或i < 0)。
  • 0x41:如果分配了过多内存或创建了过大的数组。
  • 0x51:如果调用内部函数类型的零初始化变量。

require函数要么创建没有数据的错误,要么创建Error(string)类型的错误。它应该用于确保直到执行时才能检测到的有效条件。这包括对输入的条件或从对外部合约的调用返回值的条件。

注意

目前无法结合使用require和自定义错误。请改用if (!condition) revert CustomError();。

Error(string)异常(或无数据异常)由编译器在以下情况下生成:

  • 调用require(x),其中x计算结果为false。
  • 如果使用revert()或revert("description")。
  • 如果执行针对不包含代码的合约的外部函数调用。
  • 如果您的合约通过公共函数接收Ether且没有payable修饰符(包括构造函数和回退函数)。
  • 如果您的合约通过公共getter函数接收Ether。

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

  • 如果.transfer()失败。
  • 如果您通过消息调用调用函数,但未正常完成(即,它耗尽了气体,没有匹配的函数,或者自身抛出异常),除非使用低级操作调用、发送、delegatecall、callcode或staticcall。低级操作不会抛出异常,但通过返回false来指示失败。
  • 如果您使用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);
        // 由于transfer在失败时抛出异常,并且不能回调此处,所以我们不可能还拥有一半的Ether。
        assert(address(this).balance == balanceBeforeTransfer - msg.value / 2);
        return address(this).balance;
    }
}

在内部,Solidity执行一个回退操作(指令0xfd)。这会导致EVM撤销对状态所做的所有更改。回退的原因是因为预期的效果没有发生。由于我们希望保持事务的原子性,最安全的操作是撤销所有更改并使整个事务(或至少是调用)无效。

在这两种情况下,调用者可以使用try/catch对这种失败作出反应,但被调用者中的更改始终会被撤销。

注意

在Solidity 0.8.0之前,Panic异常曾经使用无效操作码,这消耗了调用中可用的所有气体。直到大都会发布之前,使用require的异常曾经消耗所有气体。

revert 可以使用revert语句和revert函数直接触发回退。

revert语句采用直接参数为自定义错误,无需括号:

revert CustomError(arg1, arg2);

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

revert(); revert("description");

错误数据将被传回给调用者,并且可以在那里捕获。使用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.");
        // 另一种方式:
        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);
    }
}

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

注意

require函数就像其他函数一样进行评估。这意味着在执行函数本身之前,会评估所有参数。特别是,在require(condition, f())中,即使condition为真,也会执行函数f()。

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

0x08c379a0                                                         // Error(string)函数选择器
0x0000000000000000000000000000000000000000000000000000000000000020 // 数据偏移
0x000000000000000000000000000000000000000000000000000000000000001a // 字符串长度
0x4e6f7420656e6f7567682045746865722070726f76696465642e000000000000 // 字符串数据

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

注意

曾经存在一个名为throw的关键字,与revert()具有相同的语义,在版本0.4.13中被弃用,并在版本0.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*/) {
            // 如果发生了恐慌,即严重错误,如除以零
            // 或溢出。可以使用错误代码来确定错误类型。
            errorCount++;
            return (0, false);
        } catch (bytes memory /*lowLevelData*/) {
            // 如果使用revert()。这在执行时会被捕获。
            errorCount++;
            return (0, false);
        }
    }
}

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

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

  • catch Error(string memory reason) { ... }:如果错误是由revert("reasonString")或require(false, "reasonString")(或导致此类异常的内部错误)引起的,将执行此catch子句。
  • catch Panic(uint errorCode) { ... }:如果错误是由恐慌引起的,即由失败的assert、除以零、无效数组访问、算术溢出等引起的,将运行此catch子句。
  • catch (bytes memory lowLevelData) { ... }:如果错误签名与任何其他子句不匹配,如果在解码错误消息时出错,或者如果未提供异常的错误数据,将执行此子句。在这种情况下,声明的变量提供了对低级错误数据的访问。
  • catch { ... }:如果您对错误数据不感兴趣,可以只使用catch { ... }(甚至作为唯一的catch子句),而不是前面的子句。

未来计划支持其他类型的错误数据。字符串Error和Panic目前按原样解析,并且不被视为标识符。

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

在returns和catch子句中声明的变量只在其后的块中有效。

注意

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

注意

如果执行到达catch块,则外部调用的状态更改效果已经被回滚。如果执行到达成功块,则效果未被回滚。如果效果已被回滚,那么执行将在catch块中继续,或者try/catch语句本身的执行将回滚(例如,由于上述解码失败或未提供低级catch子句而导致的)。

注意

调用失败的原因可能是多方面的。不要假设错误消息直接来自被调用的合约:错误可能发生在调用链更深处,被调用的合约只是转发了它。此外,它可能是由于耗尽气体而不是故意的错误条件:调用者在调用中总是保留至少1/64的气体,因此即使被调用的合约耗尽了气体,调用者仍然有一些剩余的气体。