📖

Chapter7: 関数、修飾子、フォールバック | Solidity Programming Essentialsを読む

2022/08/04に公開

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

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


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

  • 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スコープを設定することが義務づけられている。

viewpureconstant

スマートコントラクトの関数は主に以下の用途に使用される。

  • ステート変数をアップデートする
  • ステート変数を参照する
  • ロジックを実行する

関数やトランザクションの実行にはガスが必要であり、コストが発生する。全てのトランザクションはその実行内容に基づいて指定された量のガスを必要とし、呼び出し側はそのガスを供給する責任がある。イーサリアムのグローバルな状態を変更する全てのアクティビティにこれは当てはまる。具体的には以下のようなものが挙げられる。

  • ステート変数への書き込み
  • イベントの発行
  • 他のコントラクトの作成
  • selfdestructによるコントラクトの破棄
  • sendtransferによる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へのアクセス
  • blocktxmsgのメンバーへのアクセス(msg.sigmsg.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関数はステート変数を変更しないpureviewconstant関数にのみ使用できることである。

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を受け取ったときにも呼び出すことができる。これはアドレス型のsendtransfer関数を使用したとき、または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