bitbank techblog

EVMバイトコードの理解

概要

先日ブログを執筆させて頂いた際、初めてsolidity assemblyを知りました。solidity assemblyはsolidityのコード内で扱うことが出来るインラインアセンブリで、EVMをより厳密に扱うことが出来ます。ただ今までassemblyのような低レベル層の仕組みについては全く理解がなかったので、取っ掛かりとしてして今回はEVM上でバイトコードがどのように実行されるのか以下の項目に注目して調べてみました。

  • EVMバイトコードの構造
  • EVMバイトコードの仕組み
  • EVMバイトコードとOP codeの対応
  • EVMがコントラクトを実行する際に使用する記憶領域(stack, memory, storage, calldata, returndata)
  • 外部コントラクトの関数呼び出し(function selector)

※今回はsolidityのコードをRemixで実行し、Debug機能を使い調べました。バイトコードはコンパイルをすると「Bytecode」が画面の左下に表示されていますので、そこクリックするとコピーすることが出来ます。

7bc523a5-a932-4a06-b3db-a05ba98d4cd8

コントラクトの説明

今回は以下のコントラクトのバイトコードがどのように実行されるのか調べます。

このコントラクトはEOA(Externally Owned Account)からC1コントラクトの2つの関数を実行します。callSetNum関数はC1コントラクトからC2コントラクトのsetNum関数をcallで、delegatecallSetNum関数はdelegatecallで呼び出します。callでsetNum関数を呼び出した場合、senderはC1のコントラクトアドレスになります。C2が参照するストレージはC2なので、C2のnumにsetNum関数で指定した_numが入ります。delegatecallでsetNum関数を呼び出した場合、senderは呼び出し元のEOAアドレスになります。C2が参照するストレージはC1なので、C1のnumにsetNum関数で指定した_numが入ります。

23b4c584-b918-45d4-a6ad-2538fa0ce2fe-2

// SPDX-License-Identifier: MIT
pragma solidity 0.6.11;
contract C1 {
    uint256 public num;
    address public sender;

    function callSetNum(address c2, uint256 _num) public {
        (bool success, ) =
            c2.call(
                abi.encodeWithSelector(
                    bytes4(keccak256('setNum(uint256)')),
                    _num
                )
            );
        require(success);
    }

    function delegatecallSetNum(address c2, uint256 _num) public {
        (bool success, ) =
            c2.delegatecall(
                abi.encodeWithSelector(
                    bytes4(keccak256('setNum(uint256)')),
                    _num
                )
            );
        require(success);
    }
}

contract C2 {
    uint256 public num;
    address public sender;

    function setNum(uint256 _num) public {
        num = _num;
        sender = msg.sender;
    }
}

上記のコードをコンパルしたバイトコードです

608060405234801561001057600080fd5b50610489806100206000396000f3fe608060405234801561001057600080fd5b506004361061004c5760003560e01c806334333560146...

バイトコードをオペコードに変換したものです

PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0x10 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH2 0x489 DUP1 PUSH2 0x20 PUSH1 0x0...

EVMの構造

バイトコードの構造はcreation code、runtime code、metadataの大きく3つに大別されます。それぞれの詳しい説明はこの後にします。

e53fe137-729c-4c26-9861-3c0e6f1a426e-1

記憶領域

EVMは以下の記憶領域を使ってスマートコントラクトの実行を行います。

stack

  • ローカル変数を保持するために使われる領域
  • PUSH, DUP, POP, SWAP, ADD, SUBなどのopcodeで利用する

memory

  • PCのメモリと同じで一時的なデータを保持するために使用する領域。外部からの関数が呼び出されると削除される
  • MSTORE(p, v), MLOAD(p)などのopcodeで利用する

storage

  • ブロックチェーンに書き込まれる領域
  • storageはキーバリューストアで永続的にデータは保持される
  • SSTORE(p, v), SLOAD(p)などのopcodeで利用する

calldata

  • 関数の引数が格納される変更不可能な領域
  • calldataload, calldatasizeなどのopcodeで利用する

returndata

  • 他のコントラクトの関数を呼び出した際の返却データの格納領域
  • returndatasize, returndatacopyなどのopcodeで利用する

バイトコードの実行

それでは具体的に上記のcallSetNum関数を実行した際のバイトコードを実行順に追っていきます。バイトコードだけだと理解しにくいので、OP codeと対応させて見ていきます。

  1. creation code(C1コントラクト)
  2. runtime code(C1コントラクト)
  3. function selector(C1コントラクト)
  4. calldata (引数 C1コントラクト)
  5. memory(C1コントラクト)
  6. creation code(C2コントラクト)
  7. runtime code(C2コントラクト)
  8. function selector(C2コントラクト)
  9. calldata(C2コントラクト)
  10. strage(C2コントラクト)
  11. return data(C1コントラクト)
  12. metadata

creation code(C1コントラクト)

callSetNum関数はC1コントラクトの関数なので、まず初めにC1コントラクトのcreation codeから始まります。creation codeはruntime codeをデプロイする為のコードで、必ずこのcreation codeから始まります。

490ca83b-c4c8-4f89-aff5-0b6d1a74d567-1

runtime code(C1コントラクト)

ここからがsolidityで書いたコードの本体部分です。

1293bb50-6c37-42df-8f5e-d18943a941ad-1

※functionIDの詳細についてはこちらのMethod IDをご参考下さい

function selector(C1コントラクト)

function Idからそれぞれの関数の処理に振り分けます。

7b5c6828-5eb5-4031-848c-aae53eacb23b-1

calldata (C1コントラクト)

calldataはcallSetNum関数の引数(address c2, uint256 _num)格納する領域になります。ここではcalldataから引数をstackに積む作業を行なっています。

28dd6b4e-cd37-4bb4-85ec-85ea79391fce-2

5de6037f-1cc1-4891-8fa9-02a6a13a6131-1

memory(C1コントラクト)

C1コントラクトのcallSetNum関数の実行部分になります。ここではC2コントラクトのsetNum関数をcallで呼び出す処理を行なっています。

18d7ced4-5f85-42dd-8566-905dfa570bb6-1
3230c78c-38b6-4adf-bcd2-94e6681788a0-1
d1524c33-fec9-4ad5-9d2a-204987a8dbb9-1
78d77c9a-51b6-4d13-b487-ecb95bf24098-1
62ead3da-a47d-4f02-90c5-1ad0f7b9689c-1
7070ece3-d7b1-4e80-8ca8-965057cf11f8-1

creation code (C2コントラクト)

C1コントラクトのcallSetNum関数でC2コントラクトのsetNum関数をcallで呼び出したので、C2コントラクトのcreation codeから始まります。

96ec78d4-5dd4-491c-9046-a8698d7aace0-2

runtime code(C2コントラクト)

ここもC1と同様で、ここからがC2コントラクトの実行コードになります。

89d50ec5-0130-4f8c-aae2-ee98331cf4dc-1

function selector(C2コントラクト)

ここもC1と同様で、function IdからsetNum関数の処理に移行します。

04e8aa46-57f9-4f19-b3c2-2d98c215b14b-1

call data(C2コントラクト)

ここでsetNum関数の引数に設定したuint256 _numをcalldataから読み込みます。

268e6c3d-a579-45f3-94ac-5066466b3086-1
37b9887c-00d8-4d15-a8f4-c40210a525a3-1

strage(C2コントラクト)

ここで初めてストレージが使われます。変数numとsenderにそれぞれ代入したデータ(_numとmsg.sender)をストレージ記録します。

6011fdc2-3efe-45c0-a29e-4403529fc846-1
daab0f84-fec3-4f2c-80ec-d3e95be9b67a-1
93653d56-230e-4641-aa93-6af00f08cee2-1

return data(C1コントラクト)

callしたC2コントラウトの処理が終わったので、C1コントラクトの処理に戻ります。callSetNum関数にreturnがないのでreturn dataはありません。

888626e4-4f57-4bc6-a4f1-a227620686a3-1
1ab53a4d-3afc-4c76-8bf4-946c3a3f470b-1
619462a0-6b83-4ed9-a6a4-524ea3c5d3c5-1

metadata

solcでコンパイルした際に自動的に生成されるJSONファイルで、コントラクトのメタデータになります。このメタデータにはIPFSのハッシュ値とsolcのバージョン情報が含まれます。IPFSのハッシュ値はソースコードやコンパイル方法などのコントラクトに関する情報が含まれており、Etherscanのようなサービスを使用することで、分散型ファイルストレージ(IPFS)内のどこにメタデータがあるのか確認することが出来ます。

{"ipfs": <IPFS hash>, "solc": <compiler version>}

まとめ

コントラクトコード自体はsolidityのような高級言語を使えば、比較的簡単に書くことは出来ます。しかし重要なのはセキュリティーを担保しつついかにガスコストを抑えるコードが書けるかだと考えています。そういう意味ではEVMcodeを理解することで、コードのバグの原因特定やアーキテクチャの設計などに役立ち、よりセキュアにコードを書くことが出来るようになるかと思います。またバイトコードレベルで理解することでsolidity assemblyを正しく使用することが出来るようになれば、ガスコストを抑えたコードが書けるようになるかと思います。

参考文献

Author image
About adrenaline
expand_less