📖

Chapter8: 例外、イベント、ログ | Solidity Programming Essentialsを読む

2022/08/05に公開

イーサリアムの知識を整理するために2022年6月発売のSolidity Programming Essentials 2nd Editionを読み進める試みです。この記事ではChapter8「Exceptions, Events, and Logging」を読み進めます。

Solidity Programming Essentials: A guide to building smart contracts and tokens using the widely used Solidity language, 2nd Edition (English Edition)

読書ログは以下のスクラップで逐次更新していきます。
https://zenn.dev/mah/scraps/ea8c79961ae8c8


この章で扱われるトピックは以下の通りです。

  • Understanding exception handling in Solidity
  • Error handling with require
  • Error handling with assert
  • Error handling with revert
  • Custom errors with revert
  • try-catch exception handling
  • Understanding events
  • Declaring an event
  • Using an event
  • Writing to logs

require

requireを使用すると、関数内でロジックを実行する前にコンテキストの状態や入力される引数の値を検証することができる。

requireでの検証に失敗するとrevertopcodeが生成され、状態を元に戻し、未使用のガスを呼び出し元に戻す。ただしそれまでに消費したガスを返すことはできないので、一般的にrequire関数は関数の先頭で使用することが重要である。また、失敗してもEtherを消費したり没収したりということはしない。

requireで失敗した場合はError(string)型の例外を呼び出し元へ返す。この章の後半のセクションでこのエラーのキャッチ方法について学ぶ。

revertopcodeは呼び出し元にバブルアップする例外を発生させ、状態変化を元に戻す。require関数の前にグローバルなステート変数やストレージ変数に変更が加えられた場合、それらの変更は全て元の値に戻る。

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

contract SimpleRequireContract {
  function SimpleRequireFunction(uint256 myNumber)
    public
    payable
    returns (bool status, uint256)
  {
    require(myNumber > 0);

    uint256 tempNumber = 200 / myNumber;

    if (tempNumber > 10) status = true;
    else status = false;

    return (status, tempNumber);
  }
}

このコードの場合、引数myNumberの値が0以下だった場合、requireでの検証に失敗してコードの実行が停止する。

また、第二引数にエラー時のメッセージを設定することもできる。

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

contract RequireUsageContract {
  function RequireValidInt8(uint256 myNumber) public returns (uint256) {
    require(myNumber >= 0, "Number cannot be lessthan 0");
    require(myNumber <= 255, "Number cannot be greaterthan 255");
    return myNumber;
  }

  function RequireIsEven(uint256 myNumber) public returns (bool) {
    require(myNumber % 2 == 0);
    return true;
  }
}

可能な限りrequireで全ての入力値のチェックを行うことが必要である。引数が単純な符号付き整数や符号なし整数の場合、上限値や下限値のチェックを行う必要がある。機能的な必要性に応じて、エラーを発生させる可能性のある全ての異常値に対するチェックも行うべきである。入力される引数が計算に使用される場合、整数のオーバーフローとアンダーフローもチェックされるべきで、引数がアドレス型の場合は、NULLやゼロアドレスなどでない有効なアドレスに対するチェックも行うべきである。

以下はrequireを利用して整数のオーバーフローと共に、アドレス値のチェックも行う例である。

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

contract MoreRequireUsageContract {
  uint256 _totalSupply = 1000000000000;
  address _king = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;

  function MoreRequireUsageFunction(uint256 myNumber, address owner)
    public
    payable
    returns (bool status)
  {
    require(_totalSupply + myNumber > _totalSupply);
    require(myNumber >= 0);
    require(owner != address(0));
    require(owner == _king);

    return true;
  }
}

修飾子(modifier)を使って条件を使い回せるようにするとコードがよりDRYになる。

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

contract RequireRefactoredContract {
  uint256 _totalSupply = 1000000000000;
  address _king = 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4;

  modifier CheckAddressAndIntegers(uint256 myNumber, address owner) {
    require(myNumber >= 0);
    require(_totalSupply + myNumber > _totalSupply);
    require(owner != address(0));
    require(owner == _king);
    _;
  }

  function RequireRefactoredFunction(uint256 myNumber, address owner)
    public
    CheckAddressAndIntegers(myNumber, owner)
    returns (bool status)
  {
    return true;
  }
}

assert

assertrequireとよく似ているが、検証失敗時の挙動が異なる。assertによる検証が失敗した場合、requireと同様に状態を元に戻し、送られてきたEtherは全て返却するが、与えられたガスは全て消費する。requireがopcode0xfdrevert)を生成するのに対して、assertが生成するopcodeは0xfeで、未定義のopcodeのため動作が停止する。

また、検証に失敗した場合はPanic(uint256)型の例外を発生させる。

次のコード例のように、assertは関数の実行結果が意図通りかの検証のために使用される。

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

contract Order {
  uint256 _balance;

  constructor() public payable {
    _balance = msg.value;
  }

  function NewOrder(address payable _seller) public returns (uint256) {
    uint256 oldBalance = _balance;
    _balance = _balance - 10000000000000000;
    _seller.send(1 ether);
    assert(_balance == (oldBalance - 10000000000000000));
  }
}

revert

ディフェンシブなコードを書くためのassertrequireとは別に、revertopcodeを生成するrevert関数も用意されている。

バージョン0.4.10では単純にrevert()として呼び出すか、エラーメッセージを返す文字列をrevert("balances do not match")のように設定して呼び出すかする関数としてリリースされたが、バージョン0.8.4のリリースではCustomErrorオブジェクトで動作する新しいrevert文が用意されるようになった。これに伴いrevert関数も、内部的にはError(string)型のエラーオブジェクトを生成して呼び出し元に返す使用になっている。

revert関数は以下のように記述する。

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

contract RevertContract {
  function RevertValidInt8(uint256 myNumber) public returns (uint8) {
    if (myNumber < 0 || myNumber > 255) {
      revert("not a valid int8 value");
    }

    return uint8(myNumber);
  }
}

一方でrevert文は以下のように記述する。errorキーワードでカスタムエラー型InvalidIntegerValueを定義し、revert文を利用して例外を発生させている。

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

error InvalidIntegerValue(uint256 intValue);

contract RevertContract {
  function RevertValidInt8(uint256 myNumber) public returns (uint8) {
    if (myNumber < 0 || myNumber > 255) {
      revert InvalidIntegerValue(myNumber);
    }

    return uint8(myNumber);
  }
}

try-catch

いわゆるtry-catch構文だが、一般的なプログラミング言語とは異なりtryブロック内で例外が発生したときにcatchが実行されるのではなく、例外が発生する可能性のある関数をtry文の中に記述し、その関数が例外を発生させることなく動作が終了した際にtryブロックの中身が実行され、その関数で例外が発生した際にcatchが実行される。

例えば以下のようなゼロ除算エラーが発生するようなコントラクトがあったとする。

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

contract SimpleDivision {
  function Divide2Nums(uint256 numone, uint256 numtwo)
    public
    returns (uint256)
  {
    return numone / numtwo;
  }
}

この関数実行をtry-catchで例外を捕捉できるように書くと以下のコードになる。

contract TryCatchExample {
  SimpleDivision sa;

  event myevent(uint256, string);
  event mybytes(bytes);

  constructor() {
    sa = new SimpleDivision();
  }

  function GetDivision(uint256 a, uint256 b) public returns (bool) {
    try sa.Divide2Nums(a, b) returns (uint256 ab) {
      // 関数の戻り値がabという名前でtryブロック内部に渡る
      emit myevent(ab, "pure success");
      return true;
    } catch (bytes memory lowLevelData) {
      emit mybytes(lowLevelData);
      return false;
    }
  }
}

また、Divide2Nums関数をrequire文を利用した形に書き換えたとき、

function Divide2Nums(uint256 numone, uint256 numtwo) public returns (uint256) {
  require(numtwo > 0, "numtwo is less than or equal to zero");
  return numone / numtwo;
}

requireに失敗した場合はError(string)型の例外を発生させるため、このようなcatchを書くとrequireのエラー時メッセージを取得することができる。

function GetDivision(uint256 a, uint256 b) public returns (bool) {
  try sa.Divide2Nums(a, b) returns (uint256 ab) {
    emit myevent(b, "pure success");
    return true;
  } catch Error(string memory reason) {
    emit myevent(b, reason);
    return false;
  }
}

また、assert失敗時にはPanic型の例外が投げられる。これもcatchで拾おうとすると以下のコードになる。

function GetDivision(uint256 a, uint256 b) public returns (bool) {
  try sa.Divide2Nums(a, b) returns (uint256 ab) {
    emit myevent(b, "pure success");
    return true;
  } catch Error(string memory reason) {
    emit myevent(b, reason);
    return false;
  } catch Panic(uint256 errorCode) {
    emit myevent(errorCode, "pure failure");
  } catch (bytes memory lowLevelData) {
    emit mybytes(lowLevelData);
    return false;
  }
}

イベントとログ

イベントはコントラクトの関数呼び出し元のクライアントに非同期でメッセージを送信するために利用される。イベントが発生した際には、イーサリアムプラットフォームがクライアントに通知する。

イベントはeventキーワードで定義し、emit文で発行する。以下のLogFunctionFlowイベントの場合、与えられたテキストでイベントを発生させ、テキストはブロックの一部としてログに記録される。

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

contract EventContract {
  event LogFunctionFlow(string);

  function ValidInt8(uint256 myNumber) public returns (uint8) {
    emit LogFunctionFlow("within function ValidInt8");
    require(myNumber < 0 || myNumber > 255, "not a valid int8 value");
    emit LogFunctionFlow("Value is within expected range !");
    emit LogFunctionFlow("returning value from function");
    return uint8(myNumber);
  }
}

Discussion