侧边栏壁纸
博主头像
秋码记录

一个游离于山间之上的Java爱好者 | A Java lover living in the mountains

  • 累计撰写 29 篇文章
  • 累计创建 40 个标签
  • 累计创建 185 个分类

玩以太坊链上项目的必备技能(错误处理以及异常-Solidity之旅十四)

它有许多功能来解决在编译时或运行时可能发生的潜在问题。即使语法错误检查发生在编译时,运行时错误也很难捕捉,主要发生在合约执行过程中。一些运行时错误的例子包括除以0的类型错误,数组超出索引错误,等等。

错误处理

作为开发者的我们知道,我们所编写出来的程序难免会出现 bug ,而要做的是捕获异常,给用户抛出一个友好地错误提示。

而在 Solidity 中,根据状态恢复异常来处理错误,该异常将撤销在当前调用中对状态所做的所有修改,与此同时,还向调用者标记错误。

它有许多功能来解决在编译时或运行时可能发生的潜在问题。即使语法错误检查发生在编译时,运行时错误也很难捕捉,主要发生在合约执行过程中。一些运行时错误的例子包括除以0的类型错误,数组超出索引错误,等等。

实际上,Solidity的错误处理确保了原子性这一属性。当一个智能合约调用因错误而终止时,所有的状态变化(即对变量、余额等的改变)都会被恢复,一直到合约调用链。

Solidity 有三种处理错误的方式:requireassert*revert*

require

require(condition);
require(condition, "description");

require 语句声明了运行该函数的先决条件,即它声明了在执行代码之前应该满足的约束条件。它接受一个参数并在评估后返回一个布尔值,它也有一个自定义的字符串信息选项。如果是false,就会产生异常并终止执行。未使用的gas被返回给调用者,状态被逆转为原始状态。以下是require类型的异常被触发的一些情况。

  • 1、当require(条件表达式)被调用时,其条件表达式结果为false
  • 2、当一个被消息调用的函数没有正确结束时。
  • 3、当使用 new 关键字创建一个合约,而这个过程没有正常结束。
  • 4、当一个无代码的合约被定位到一个外部函数时。
  • 5、当使用公共getter方法将以太坊发送到合约时。
  • 6、当.transfer()方法失败时。
    • 6.1、当一个断言被调用时,其结果是假的。
    • 6.2、当一个函数的零初始化变量被调用时。
    • 6.3、当一个大值或负值被转换为一个枚举值时。
    • 6.4、当一个值被零除或模数化的时候。
    • 6.5、当在一个索引中访问一个数组,这个数组太大或者是负数。
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

contract requireContract{

    function requireTest(uint256 _number) pure public {
        require(_number < 100, "number too big");
    }
 }

img

引入require语句的原因是为了使代码更具可读性。require 语句应在以下情况下使用。

  • 1、验证用户输入,正如我们上面所做的。
  • 2、在任何行动之前验证状态条件
  • 3、验证来自外部合同的响应
  • 4、检查溢出和欠溢出的情况

assert

assert语句也可以撤销所有的状态变化,但它会消耗事务中提供的所有气体,即使它没有被使用。这使得它不如revert或require宽松,因此使用频率较低。Assert是为 “真正糟糕的事情 “而存在的,应该只用于内部错误。

assert应该在以下情况下使用。

  • 1、检查常量,如this.balance > totalSupply
  • 2、执行后验证状态
  • 3、溢出和下溢检查,如果revert/require不合适的话

Solidity 在某些情况下自动创建assert型异常。

  • 1、被零除法或模数
  • 2、访问一个超出其界限的数组
  • 3、将过大或负数转换为枚数
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

contract assertContract{

    function assertDiv(int x,int y) pure public returns(int){
        assert(y == 0);
        return x / y;
    }
 }

img

revert

revert语句将停止执行并撤销所有的状态变化。剩余的gas将被退还给调用者(但到目前为止所使用的gas将消失)。revert允许返回一个值。这可以作为一个错误信息来澄清为什么执行revert语句。

有两种方式来触发revert

revert CustomError(arg1, arg2)
revert("description")

第一种是自定义的error类型(Solidity 0.8.4 出现的新类型)。

使用自定义错误实例通常会比传递一个字符串作为描述在消耗gas方面更便宜。

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

contract revertContract{
    //自定义错误实例
    error WrongNumber(uint256 threshold, uint256 number);
    string public status;
    uint256 myThreshold = 100;

    function revertString(uint256 _number) public{
        status = "error";
        if(_number > myThreshold){
            revert("Number too big");
        }
        status = "success";
    }
    function revertCustomError(uint256 _number) public{
        status = "error";
        if(_number > myThreshold){
            revert WrongNumber({ threshold: myThreshold, number: _number});
        }
        status = "success";
    }

 }

img

img

三种方式处理错误消耗 gas 对比

  • 1、revert 语句,从示例可以看出最终耗费了 46607 gas

img

  • 2、revert 语句与自定义 error 相配合,反而比比上述消耗跟多的gas,它最终消耗了46733 gas

img

  • 3、 我们在示例中使用了assert语句,明显比revert语句消耗gas更少,它最终消耗了22077 gas

img

  • 4、 我们最后来看看equire语句。第一种情况是带了错误描述,而它却又比assert语句少耗费了gas,从示例可以看出它消耗了21898 gas

img

  • 5、而require第二种情况,便是只有条件表达式,可以看出在示例中它以上几种情况消耗gas最少的,它最终消耗了21605 gas

img

从上述的示例可以看出,使用require语句检查错误,最终消耗的gas是最少的,这也证明了很多项目使用require的原因了。

try catch 捕获异常

现在我们可以用它们来处理外部函数调用的失败,而不需要回滚整个事务(被调用函数中的状态变化仍然会被回滚,但调用函数中的变化不会)。

//CharitySplitter.sol
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;

contract CharitySplitter {
    address public owner;
    constructor (address _owner) {
        require(_owner != address(0), "no-owner-provided");
        owner = _owner;
    }
}
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
import "./CharitySplitter.sol";

contract CharitySplitterFactory {
    mapping (address => CharitySplitter) public charitySplitters;
    uint public errorCount;

    event ErrorHandled(string reason);
    event ErrorNotHandled(bytes reason);

    function createCharitySplitter(address charityOwner) public {
        try new CharitySplitter(charityOwner)
            returns (CharitySplitter newCharitySplitter)
        {
            charitySplitters[msg.sender] = newCharitySplitter;
        } catch {
            errorCount++;
        }
    }
}

img

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 子句中声明的变量只在后面的块的范围内有效。