Chapter7: 関数、修飾子、フォールバック | Solidity Programming Essentialsを読む
イーサリアムの知識を整理するために2022年6月発売のSolidity Programming Essentials 2nd Editionを読み進める試みです。この記事ではChapter7「Solidity Functions, Modifiers, and Fallbacks」を読み進めます。
読書ログは以下のスクラップで逐次更新していきます。
この章で扱われるトピックは以下の通りです。
- Function input and output
- Modifiers
- Visibility and scope
- Views, constants, and pure functions
- Address related functions
- Fallback functions
- Receive functions
関数の入力と出力
関数は引数でパラメータを受け取って、値を返すことができるという話。さらにSolidityの関数は多値を返すこともできる。
function multipleOutgoingParameter(int256 _data)
returns (int256 square, int256 half)
{
square = _data * _data;
half = _data / 2;
}
タプルの形でも返すことができる。
function multipleOutgoingTuple(int256 _data)
returns (int256 square, int256 half)
{
(square, half) = (_data * _data, _data / 2);
}
修飾子(modifier)
修飾子を使うと関数実行前に特定の処理(関数実行の条件テストなど)を挟むことができる。コントラクトのオーナーか?みたいな、いろいろな関数に共通する処理をまとめるのに便利。
例えばこんなコントラクトがあったとする。if (msg.sender == owner) ...
が重複していてDRYではない。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract ContractWithoutModifier {
address owner;
int256 public mydata;
constructor() {
owner = msg.sender;
}
function AssignDoubleValue(int256 _data) public {
if (msg.sender == owner) {
mydata = _data * 2;
}
}
function AssignTenerValue(int256 _data) public {
if (msg.sender == owner) {
mydata = _data * 10;
}
}
}
修飾子を使うと、こんな風にリファクタリングできる。可読性も向上する。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract ContractWithModifier {
address owner;
int256 public mydata;
constructor() {
owner = msg.sender;
}
// modifierキーワードで宣言する
modifier isOwner() {
if (msg.sender == owner) {
_;
}
}
// 関数定義内に並べる
function AssignDoubleValue(int256 _data) public isOwner {
mydata = _data * 2;
}
// 関数定義内に並べる
function AssignTenerValue(int256 _data) public isOwner {
mydata = _data * 10;
}
}
修飾子内の_;
はrubyでいうyield
みたいなもので、呼び出し元の関数のコードに置き換わる。isOwner
の例で言えば、msg.sender == owner
の場合のみ、呼び出し元の関数コードが実行されるという意味になる。
可視性の範囲
Solidityには4つの可視性スコープがある。
- private: 関数をprivateとした場合は、この関数が宣言されているコントラクト内でのみ呼び出せる関数となり、派生コントラクトやコントラクトの外から呼び出すことはできない。
- internal: コントラクトの外から呼び出すことはできないが、派生コントラクトからは呼び出すことができる。
- public: コントラクト内、派生コントラクト、外部コントラクト、外部アカウントから呼び出すことができる。つまりどこからでも呼び出せる。
- external: 他のコントラクトおよび外部アカウントからのみ呼び出せるスコープ。上記までのスコープと異なり、現在のコントラクトや派生コントラクトの内部からは呼び出すことができない。以降で登場する
receive
関数とfallback
関数はexternal
スコープを設定することが義務づけられている。
view
、pure
、constant
スマートコントラクトの関数は主に以下の用途に使用される。
- ステート変数をアップデートする
- ステート変数を参照する
- ロジックを実行する
関数やトランザクションの実行にはガスが必要であり、コストが発生する。全てのトランザクションはその実行内容に基づいて指定された量のガスを必要とし、呼び出し側はそのガスを供給する責任がある。イーサリアムのグローバルな状態を変更する全てのアクティビティにこれは当てはまる。具体的には以下のようなものが挙げられる。
- ステート変数への書き込み
- イベントの発行
- 他のコントラクトの作成
-
selfdestruct
によるコントラクトの破棄 -
send
とtransfer
によるEtherの送信 -
view
またはpure
とマークされていない関数呼び出し - 低レベルコールの使用
- 特定のopcodeを含むインラインアセンブリの使用
一方でステート変数の内容を参照して返すだけのような、イーサリアムの状態を変更しない処理もある。このような場合は関数にview
修飾子をつけることでEVMに状態変更しないことを伝えることができる(過去のSolidityバージョンではconstant
修飾子が使用されていた)。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract ViewFunction {
function GetTenerValue(int256 _data) public view returns (int256) {
return _data * 10;
}
}
また、view
関数よりも状態の変更可能性を制限しているpure
関数というものもある。pure
関数ではイーサリアムのグローバルな状態の読み取りも禁止されている。さらに具体的には、以下のようなアクティビティが禁止されている。
- ステート関数の参照
-
this.balance
または<address>.balance
へのアクセス -
block
、tx
、msg
のメンバーへのアクセス(msg.sig
とmsg.data
を除く) -
pure
とマークされていない関数の呼び出し - 特定のopcodeを含むインラインアセンブリの使用
前述のコードをpure
キーワードで書き直すと以下のようになる。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;
contract PureFunction {
function GetTenerValue(int256 _data) public pure returns (int256) {
return _data * 10;
}
}
アドレス関連の関数
send
関数
send
関数はコントラクトまたは外部アカウントにEtherを送信するために使用される。
<account>.send(amount);
send
関数は2,300ユニットのガスを定額で使用し、返り値としてtrue
またはfalse
のBoolean型を返す。次のコードはSimpleSendToAccount
関数の呼び出し元に1weiを送信するものである。
function SimpleTransferToAccount() public {
msg.sender.send(1);
}
send
関数は低レベル関数であり、fallback
関数やreceive
関数を呼び出すことができるため、呼び出し元のコントラクト内で再帰的に何度もコールバックされる可能性がある。このようなリエントランシー攻撃を防ぐため、CDF(Check-Detect-Tranfer)、またはCEI(Check-Effects-Interaction)と呼ばれるパターンがある。次の例はCEIパターンの実装例である。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract SimpleSender {
mapping(address => uint256) balance;
function SimpleSendToAccount(uint256 amount) public returns (bool) {
// 1. 呼び出し側の残高から出金できるかを確認
if (balance[msg.sender] >= amount) {
// 2. 呼び出し側の残高から出金金額を減らす
balance[msg.sender] -= amount;
// 3. send関数が成功したらtrueを返す
if (msg.sender.send(amount) == true) {
return true;
} else {
// 4. send関数が失敗したら2.で減らした金額を戻す
balance[msg.sender] += amount;
return false;
}
}
}
}
transfer
関数
transfer
関数はsend
関数とよく似ているが、実行に失敗した場合は例外を発生させ、全ての変更を元に戻してくれるという違いがある。send
関数のように失敗時のロールバック処理を書かなくて良いことから、send
関数よりもtransfer
関数を使用することが推奨されている。
function SimpleTransferToAccount() public {
msg.sender.transfer(1);
}
call
関数
アドレスデータ型が提供するcall
関数は、コントラクト内で利用可能な任意の関数の呼び出しを可能にする。ABI(Application Binary Interface)と呼ばれるコントラクトのインターフェースが利用できない場合はcall
関数を利用して関数呼び出しを行う。call
関数の実行時にはABIに対してチェックされず、コンパイル時にもチェックされないため本当に任意の関数を呼び出すことが可能だ。戻り値として関数呼び出しのBool値とステータスと、関数からの戻り値からなるタプルを返す。
コントラクト内の全ての関数は実行時に4バイトの識別子を用いて識別される。関数名とパラメータ型をハッシュ化した後の最初の4バイトが関数識別子となる。call
関数はこの4バイトを最初のパラメータとして受け取り、実際のパラメータ値を後続パラメータとして受け取り関数を呼び出す。
後続パラメータを持たないcall
関数の例:
myaddr.call(bytes4(sha3("SetBalance()")));
uint256型の引数を受け取る関数をcall
関数経由で呼び出す例:
myaddr.call(bytes4(sha3("SetBalance(uint256)")), 10);
また、以下のようなコントラクト内の関数をcall
関数で呼び出す例について考えてみる。
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract EtherBox {
uint256 balance;
event logme(string);
function SetBalance() public {
balance = balance + 10;
}
function GetBalance() public payable returns (uint256) {
return balance;
}
}
単純な呼び出し例
EtherBoxコントラクトのインスタンスを作成し、そのアドレスからcall
関数でSetBalance
を呼び出している。
function SimpleCall() public returns (uint256) {
EtherBox eb = new EtherBox();
address myaddr = address(eb);
bytes memory payload = abi.encodeWithSignature("SetBalance()");
(bool success, bytes memory returnData) = myaddr.call(payload);
require(success); // successがtrueでない場合は終了
return eb.GetBalance();
}
ガスを消費して呼び出す例
call
関数では関数のペイロードと同時にガスを送信することもできる。
function SimpleCallwithGas() public returns (bool) {
EtherBox eb = new EtherBox();
address myaddr = address(eb);
bytes memory payload = abi.encodeWithSignature("SetBalance()");
(bool success, bytes memory returnData) = myaddr.call{ gas: 200000 }(payload);
return success;
}
ガスとEtherを同時に送信する例
対象がpayable
な関数であればEtherを送ることもできる。
function SimpleCallwithGasAndValue() public returns (bool) {
EtherBox eb = new EtherBox();
address myaddr = address(eb);
bytes memory payload = abi.encodeWithSignature("GetBalance()");
(bool success, bytes memory returnData) = myaddr.call{
gas: 200000,
value: 1 ether
}(payload);
return success;
}
delegatecall
関数
呼び出し側のコントラクトAと、呼び出される側のコントラクトBがあり、外部アカウントからコントラクトAの関数を実行するケースを考える。call
関数ではコントラクトAから呼び出されるコントラクトBの関数のmsg.sender
は呼び出し元コントラクトのアドレスになるが、delegatecall
関数を使うとmsg.sender
は呼び出し元の外部アカウントのアドレスになる。
外部アカウントから見るとdelegatecall
関数を使用した場合はコントラクトAの関数内で全ての処理が実行されているように見えるため、ライブラリ関数を使用する場合はdelegatecall
関数を使用することが推奨されている。
staticcall
関数
call
関数とdelagatecall
関数との大きな違いは、staticcall
関数はステート変数を変更しないpure
、view
、constant
関数にのみ使用できることである。
fallback
関数とreceive
関数
fallback
関数はコントラクト内に存在しない関数呼び出しを見つけたときに、EVMによって自動的に呼び出される関数である。一つのコントラクト内には一つのfallback
関数しか存在できない。階層化されたコントラクトの場合はオーバーライドすることができる。
fallback
関数のコード例:
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
contract EtherBox {
event logme(string);
// fallback関数に名前はなく、externalで宣言する
fallback() external payable {
emit logme("fallback called");
}
}
上記のfallback
関数を呼び出す例が以下のコードである。存在しない関数を呼び出すためにcall
関数を使用している。実行するとEtherBoxコントラクトでfallback
関数が実行され、logme
イベントが発生する。
contract UsingCall {
function SimpleCallwithGasAndValueWithWrongName() public returns (bool) {
EtherBox eb = new EtherBox();
address myaddr = address(eb);
bytes memory payload = abi.encodeWithSignature("GetBalance1()");
(bool success, bytes memory returnData) = myaddr.call(payload);
return success;
}
}
fallback
関数はコントラクトが何らかのEtherを受け取ったときにも呼び出すことができる。これはアドレス型のsend
やtransfer
関数を使用したとき、またはweb3.jsでsendTransaction
関数を使用してコントラクトにEtherを送信するときに発生する。Etherを受け取れるようにするためにはfallback
関数をpayable
にしておく必要がある。
function SendEther() public returns (bool) {
EtherBox eb = new EtherBox();
address payable myaddr = payable(eb);
myaddr.transfer(1000000000000000000);
return true;
}
fallback
関数の設計を考える上で重要なのは、fallback
関数を実行するためにどれだけのガスが必要かということである。fallback
関数は明示的に呼び出すことができないため、明示的にガスを送ることができない。その代わり、EVMはこの関数に2,300ガスを固定で支給する。この制限を超えてガスを消費すると例外が発生し、元の関数と一緒に送信されたガスを全て消費した後に状態がロールバックされる。従って、fallback
関数が2,300ガス以上消費しないことを確認するために、fallback
関数をテストすることが重要である。
fallback
関数はあらゆるシチュエーションで呼び出される可能性があるために記述が複雑化しがちである。そこでSolidityではfallback
関数とは別にreceive
関数が提供されており、外部ソースからEtherを受け取る際はreceive
関数が実行されるようになっている。
receive() external payable {
emit logme("receive called");
}
Discussion