Chapter8: 例外、イベント、ログ | Solidity Programming Essentialsを読む
イーサリアムの知識を整理するために2022年6月発売のSolidity Programming Essentials 2nd Editionを読み進める試みです。この記事ではChapter8「Exceptions, Events, and Logging」を読み進めます。
読書ログは以下のスクラップで逐次更新していきます。
この章で扱われるトピックは以下の通りです。
- 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
での検証に失敗するとrevert
opcodeが生成され、状態を元に戻し、未使用のガスを呼び出し元に戻す。ただしそれまでに消費したガスを返すことはできないので、一般的にrequire
関数は関数の先頭で使用することが重要である。また、失敗してもEtherを消費したり没収したりということはしない。
require
で失敗した場合はError(string)型の例外を呼び出し元へ返す。この章の後半のセクションでこのエラーのキャッチ方法について学ぶ。
revert
opcodeは呼び出し元にバブルアップする例外を発生させ、状態変化を元に戻す。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
assert
はrequire
とよく似ているが、検証失敗時の挙動が異なる。assert
による検証が失敗した場合、require
と同様に状態を元に戻し、送られてきたEtherは全て返却するが、与えられたガスは全て消費する。require
がopcode0xfd
(revert
)を生成するのに対して、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
ディフェンシブなコードを書くためのassert
やrequire
とは別に、revert
opcodeを生成する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