以太坊合约错误处理

admin
admin 2022年02月24日
  • 在其它设备中阅读本文章

Solidity使用状态恢复异常来处理错误。这种异常将撤消对当前调用(及其所有子调用)中的状态所做的所有更改,并且还向调用者标记错误。当子调用中发生异常时,它们会自动“冒泡”(即异常被重新抛出),除非它们在try/catch语句中被捕获。但是如果是在send和低级别函数(call, delegatecallstaticcall)调用里发生异常时,他们会返回 false(第一个返回值) 而不是冒泡异常。
注意:根据 EVM 的设计,如果被调用的地址不存在,低级别函数call,delegatecallstaticcall的第一个返回值同样是 true。如果需要,请在调用之前检查账号的存在性。
异常可以包含传递给调用者的数据。该数据由一个 4 字节函数选择器和随后的 ABI 编码数据组成。异常可以以错误实例的形式返回错误数据给调用者。目前,Solidity支持两种错误函数签名:Error(string) 和 Panic(uint256)。Error用于“常规”错误条件,而Panic用于无错误代码中意外出现的错误。

assert检查异常 (Panic)

Assert只能用于测试内部错误和检查不变量。正常运行的代码不应该造成Panic,即使是在无效的外部输入上也是如此。如果发生这种情况,那么您的合同中有一个错误,您应该修复它。在以下情况下会生成Panic异常,提供的错误码编号,用来指示 Panic 的类型。

  1. 0x00:用于通用编译器插入的Panic
  2. 0x01:如果你调用assert的参数(表达式)结果为 false。
  3. 0x11:在unchecked { … }外,如果算术运算结果向上或向下溢出。
  4. 0x12:如果你用零当除数做除法或模运算(例如:5 / 023 % 0
  5. 0x21:如果你将一个太大的数或负数值转换为一个枚举类型。
  6. 0x22:如果你访问一个没有正确编码的存储类型字节数组。
  7. 0x31:如果在空数组上.pop()
  8. 0x32:如果你访问 bytesN 数组(或切片)的索引太大或为负数。
  9. 0x41:如果你分配了太多的内内存或创建了太大的数组。
  10. 0x51:如果你调用了零初始化内部函数类型变量。

可以提供消息字符串给 require(第二个参数)

require检查错误 (Error)

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

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

错误“冒泡”

Solidity会转发来自子(外部)调用的错误(如果提供),这意味着它可能会导致ErrorPanic(或给出的任何其他内容):

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

revert

revert函数是另一个可以在代码块中处理异常的方法, 可以用来标记错误并回退当前的调用。revert调用中还可以包含有关错误信息的参数,这个信息会被返回给调用者,并且产生一个Error(string)错误。下边的例子展示了错误字符串如何使用 revert (等价于 require) :

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

contract VendingMachine {
    function buy(uint amount) payable {
        if (amount > msg.value / 2 ether)
            revert("Not enough Ether provided.");
        // 下边是等价的方法来做同样的检查:
        require(
            amount <= msg.value / 2 ether,
            "Not enough Ether provided."
        );
        // 执行购买操作
    }
}

如果直接提供错误原因字符串,则这两个语法是等效的。

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

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

try/catch

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

pragma solidity ^0.6.0;

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 (bytes memory /*lowLevelData*/) {
            // This is executed in case revert() was used。
            errorCount++;
            return (0, false);
        }
    }
}

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

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

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

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