イエロー・ペーパーのEVM仕様を理解する
イエロー・ペーパー (opens in a new tab)は、イーサリアムの正式な仕様書です。EIPプロセスによって修正された場合を除き、すべての仕組みの正確な説明が含まれています。数学の論文として書かれているため、プログラマーには馴染みのない用語が含まれている場合があります。この記事では、その読み方を学び、さらに他の関連する数学論文の読み方も学びます。
どのイエロー・ペーパーか?
イーサリアムの他のほぼすべてのものと同様に、イエロー・ペーパーも時間の経過とともに進化します。特定のバージョンを参照できるように、執筆時点での最新バージョンをアップロードしました。ここで使用するセクション、ページ、および方程式の番号は、そのバージョンを参照しています。このドキュメントを読む間、別のウィンドウで開いておくことをお勧めします。
なぜEVMなのか?
オリジナルのイエロー・ペーパーは、イーサリアムの開発が始まった直後に書かれました。そこには、ネットワークを保護するために元々使用されていた、プルーフ・オブ・ワーク (PoW) ベースのコンセンサス・メカニズムについて説明されています。しかし、イーサリアムは2022年9月にプルーフ・オブ・ワークを停止し、プルーフ・オブ・ステーク (PoS) ベースのコンセンサスを使用し始めました。このチュートリアルでは、イエロー・ペーパーの中でイーサリアム仮想マシン(EVM)を定義している部分に焦点を当てます。EVMは、プルーフ・オブ・ステークへの移行によって変更されていません(DIFFICULTYオペコードの戻り値を除く)。
9 実行モデル
このセクション(12〜14ページ)には、EVMの定義の大部分が含まれています。
システム状態(system state)という用語には、システムを実行するために知っておくべきすべてのことが含まれます。一般的なコンピューターでは、これはメモリやレジスタの内容などを意味します。
チューリングマシン (opens in a new tab)は計算モデルです。基本的にはコンピューターの簡略版であり、通常のコンピューターと同じ計算能力を持つことが証明されています(コンピューターが計算できるものはすべてチューリングマシンでも計算でき、その逆も同様です)。このモデルにより、何が計算可能で何が計算不可能かに関するさまざまな定理を証明しやすくなります。
チューリング完全 (opens in a new tab)という用語は、チューリングマシンと同じ計算を実行できるコンピューターを意味します。チューリングマシンは無限ループに陥る可能性がありますが、EVMはガスが不足するため無限ループには陥りません。そのため、EVMは準チューリング完全(quasi-Turing-complete)にすぎません。
9.1 基本
このセクションでは、EVMの基本と、他の計算モデルとの比較について説明します。
スタックマシン (opens in a new tab)は、中間データをレジスタではなくスタック (opens in a new tab)に保存するコンピューターです。これは実装が容易であり、バグやセキュリティの脆弱性が発生する可能性がはるかに低いため、仮想マシンに推奨されるアーキテクチャです。スタック内のメモリは256ビットのワードに分割されています。これは、ケチャック・256のハッシュ化や楕円曲線計算など、イーサリアムのコアとなる暗号化操作に便利であるため選択されました。スタックの最大サイズは1024アイテム(1024 x 256ビット)です。オペコードが実行されるとき、通常はスタックからパラメーターを取得します。POP(スタックの一番上からアイテムを削除する)、DUP_N(スタック内のN番目のアイテムを複製する)など、スタック内の要素を再編成するための専用のオペコードがあります。
EVMには、実行中にデータを保存するために使用されるメモリと呼ばれる揮発性スペースもあります。このメモリは32バイトのワードで構成されています。すべてのメモリの場所はゼロに初期化されます。このYul (opens in a new tab)コードを実行してメモリにワードを追加すると、ワード内の空きスペースをゼロで埋めることで32バイトのメモリが埋められます。つまり、0〜29の場所にゼロ、30に0x60、31に0xA7を持つ1つのワードが作成されます。
mstore(0, 0x60A7)
mstoreは、EVMがメモリと対話するために提供する3つのオペコードの1つであり、ワードをメモリにロードします。他の2つは、1バイトをメモリにロードするmstore8と、ワードをメモリからスタックに移動するmloadです。
EVMには、システム状態の一部として維持される、独立した不揮発性のストレージモデルもあります。このメモリは(スタック内のワードアドレス指定可能なバイト配列とは対照的に)ワード配列として構成されています。このストレージは、コントラクトが永続的なデータを保持する場所です。コントラクトは自身のストレージとのみ対話できます。ストレージはキーと値のマッピングで構成されています。
イエロー・ペーパーのこのセクションでは言及されていませんが、4番目のタイプのメモリがあることを知っておくことも役立ちます。コールデータは、トランザクションのdataパラメーターで渡された値を保存するために使用される、バイトアドレス指定可能な読み取り専用メモリです。EVMには、calldataを管理するための特定のオペコードがあります。calldatasizeはデータのサイズを返します。calldataloadはデータをスタックにロードします。calldatacopyはデータをメモリにコピーします。
標準的なフォン・ノイマン・アーキテクチャ (opens in a new tab)では、コードとデータを同じメモリに保存します。EVMはセキュリティ上の理由からこの標準に従っていません。揮発性メモリを共有すると、プログラムコードを変更できる可能性があるためです。代わりに、コードはストレージに保存されます。
コードがメモリから実行されるケースは2つだけです。
- コントラクトが別のコントラクトを作成する場合(
CREATE(opens in a new tab)またはCREATE2(opens in a new tab)を使用)、コントラクトのコンストラクタのコードはメモリから取得されます。 - あらゆるコントラクトの作成中、コンストラクタコードが実行され、実際のコントラクトのコード(これもメモリから取得)とともに戻ります。
例外的実行(exceptional execution)という用語は、現在のコントラクトの実行を停止させる例外を意味します。
9.2 手数料の概要
このセクションでは、ガス代の計算方法について説明します。3つのコストがあります。
オペコードのコスト
特定のオペコードの固有のコストです。この値を取得するには、付録H(28ページ、方程式(327)の下)でオペコードのコストグループを見つけ、方程式(324)でコストグループを見つけます。これによりコスト関数が得られます。ほとんどの場合、付録G(27ページ)のパラメーターを使用します。
たとえば、オペコードCALLDATACOPY (opens in a new tab)はグループ_Wcopy_のメンバーです。そのグループのオペコードコストは_Gverylow+Gcopy×⌈μs[2]÷32⌉_です。付録Gを見ると、両方の定数が3であることがわかります。これにより、_3+3×⌈μs[2]÷32⌉_となります。
まだ_⌈μs[2]÷32⌉という式を解読する必要があります。最も外側の部分である⌈ <value> ⌉は天井関数(ceiling function)であり、値が与えられたときに、その値より小さくない最小の整数を返す関数です。たとえば、⌈2.5⌉ = ⌈3⌉ = 3_です。内側の部分は_μs[2]÷32_です。3ページのセクション3(Conventions)を見ると、_μ_はマシン状態(machine state)です。マシン状態は13ページのセクション9.4.1で定義されています。そのセクションによると、マシン状態のパラメーターの1つはスタックを表す_s_です。これらを総合すると、_μs[2]はスタック内の場所#2であると思われます。オペコード (opens in a new tab)を見ると、スタック内の場所#2はデータのサイズ(バイト単位)です。グループWcopyの他のオペコードであるCODECOPY (opens in a new tab)とRETURNDATACOPY (opens in a new tab)を見ると、これらも同じ場所にデータのサイズを持っています。したがって、⌈μs[2]÷32⌉_は、コピーされるデータを保存するために必要な32バイトワードの数です。すべてを総合すると、CALLDATACOPY (opens in a new tab)の固有のコストは、3ガスに加えて、コピーされるデータの1ワードにつき3ガスとなります。
実行コスト
呼び出しているコードを実行するためのコストです。
CREATE(opens in a new tab)およびCREATE2(opens in a new tab)の場合、新しいコントラクトのコンストラクタ。CALL(opens in a new tab)、CALLCODE(opens in a new tab)、STATICCALL(opens in a new tab)、またはDELEGATECALL(opens in a new tab)の場合、呼び出すコントラクト。
メモリ拡張コスト
メモリを拡張するためのコスト(必要な場合)。
方程式324では、この値は_Cmem(μi')-Cmem(μi)_と記述されています。セクション9.4.1をもう一度見ると、_μi_はメモリ内のワード数であることがわかります。したがって、_μi_はオペコード実行前のメモリ内のワード数であり、_μi'_はオペコード実行後のメモリ内のワード数です。
関数_Cmemは方程式326で定義されています:Cmem(a) = Gmemory × a + ⌊a2 ÷ 512⌋。⌊x⌋は床関数(floor function)であり、値が与えられたときに、その値より大きくない最大の整数を返す関数です。たとえば、⌊2.5⌋ = ⌊2⌋ = 2_です。_a < √512_の場合、a2 < 512_となり、床関数の結果はゼロになります。したがって、最初の22ワード(704バイト)については、コストは必要なメモリワード数に比例して直線的に上昇します。その時点を超えると、⌊a2 ÷ 512⌋_は正の値になります。必要なメモリが十分に大きい場合、ガス代はメモリ量の2乗に比例します。
注: これらの要因は_固有の_ガス代にのみ影響します。エンドユーザーが支払う必要のある金額を決定する手数料市場やバリデータへのチップは考慮されていません。これは、EVMで特定の操作を実行するための純粋なコストにすぎません。
9.3 実行環境
実行環境は、ブロックチェーンの状態やEVMの一部ではない情報を含むタプル_I_です。
| パラメーター | データにアクセスするためのオペコード | データにアクセスするためのSolidityコード |
|---|---|---|
| Ia | ADDRESS (opens in a new tab) | address(this) |
| Io | ORIGIN (opens in a new tab) | tx.origin |
| Ip | GASPRICE (opens in a new tab) | tx.gasprice |
| Id | CALLDATALOAD (opens in a new tab)など | msg.data |
| Is | CALLER (opens in a new tab) | msg.sender |
| Iv | CALLVALUE (opens in a new tab) | msg.value |
| Ib | CODECOPY (opens in a new tab) | address(this).code |
| IH | NUMBER (opens in a new tab)やDIFFICULTY (opens in a new tab)などのブロック・ヘッダーフィールド | block.number、block.difficultyなど |
| Ie | コントラクト間の呼び出し(コントラクトの作成を含む)のコールスタックの深さ | |
| Iw | EVMが状態を変更することを許可されているか、または静的に実行されているか |
セクション9の残りの部分を理解するには、他にもいくつかのパラメーターが必要です。
| パラメーター | 定義されているセクション | 意味 |
|---|---|---|
| σ | 2(2ページ、方程式1) | ブロックチェーンの状態 |
| g | 9.3(13ページ) | 残りのガス |
| A | 6.1(8ページ) | 発生したサブステート(トランザクション終了時に予定されている変更) |
| o | 9.3(13ページ) | 出力 - 内部トランザクション(あるコントラクトが別のコントラクトを呼び出す場合)およびview関数の呼び出し(単に情報を要求しているだけであり、トランザクションを待つ必要がない場合)の際に返される結果 |
9.4 実行の概要
すべての準備が整ったので、いよいよEVMの仕組みについて見ていきましょう。
方程式137〜142は、EVMを実行するための初期条件を示しています。
| 記号 | 初期値 | 意味 |
|---|---|---|
| μg | g | 残りのガス |
| μpc | 0 | プログラムカウンター、次に実行する命令のアドレス |
| μm | (0, 0, ...) | メモリ、すべてゼロに初期化される |
| μi | 0 | 使用された最も高いメモリの場所 |
| μs | () | スタック、最初は空 |
| μo | ∅ | 出力。戻り値データあり(RETURN (opens in a new tab)またはREVERT (opens in a new tab))または戻り値データなし(STOP (opens in a new tab)またはSELFDESTRUCT (opens in a new tab))で停止しない限り、空集合となる。 |
方程式143は、実行中の各時点で4つの可能な条件があり、それらをどう処理するかを示しています。
Z(σ,μ,A,I)。Zは、操作が無効な状態遷移を引き起こすかどうかをテストする関数を表します(例外的停止を参照)。これがTrueと評価された場合、変更は実装されていないため、新しい状態は古い状態と同じになります(ガスが消費されることを除く)。- 実行されているオペコードが
REVERT(opens in a new tab)の場合、新しい状態は古い状態と同じになり、一部のガスが失われます。 RETURN(opens in a new tab))で示されるように、一連の操作が終了した場合、状態は新しい状態に更新されます。- 終了条件1〜3のいずれにも該当しない場合は、実行を継続します。
9.4.1 マシン状態
このセクションでは、マシン状態についてさらに詳しく説明します。ここでは、_w_が現在のオペコードであることを指定しています。_μpcがコードの長さである||Ib||_より小さい場合、そのバイト(Ib[μpc])がオペコードになります。それ以外の場合、オペコードはSTOP (opens in a new tab)として定義されます。
これはスタックマシン (opens in a new tab)であるため、各オペコードによってポップされた(δ)アイテムとプッシュされた(α)アイテムの数を追跡する必要があります。
9.4.2 例外的停止
このセクションでは、異常終了が発生するタイミングを指定する_Z_関数を定義します。これはブール (opens in a new tab)関数であるため、論理和には_∨_ (opens in a new tab)を使用し、論理積には_∧_ (opens in a new tab)を使用します。
以下の条件のいずれかが当てはまる場合、例外的停止が発生します。
-
μg < C(σ,μ,A,I) セクション9.2で見たように、_C_はガス代を指定する関数です。次のオペコードをカバーするのに十分なガスが残っていません。
-
δw=∅ オペコードに対してポップされるアイテムの数が未定義の場合、オペコード自体が未定義になります。
-
|| μs || < δw スタックアンダーフロー。現在のオペコードに対してスタック内のアイテムが不足しています。
-
w = JUMP ∧ μs[0]∉D(Ib) オペコードが
JUMP(opens in a new tab)であり、アドレスがJUMPDEST(opens in a new tab)ではありません。ジャンプは、宛先がJUMPDEST(opens in a new tab)である場合に_のみ_有効です。 -
w = JUMPI ∧ μs[1]≠0 ∧ μs[0] ∉ D(Ib) オペコードが
JUMPI(opens in a new tab)であり、条件が真(ゼロではない)であるためジャンプが発生するはずですが、アドレスがJUMPDEST(opens in a new tab)ではありません。ジャンプは、宛先がJUMPDEST(opens in a new tab)である場合に_のみ_有効です。 -
w = RETURNDATACOPY ∧ μs[1]+μs[2]>|| μo || オペコードは
RETURNDATACOPY(opens in a new tab)です。このオペコードでは、スタック要素_μs[1]_は戻り値データバッファから読み取るオフセットであり、スタック要素_μs[2]_はデータの長さです。この条件は、戻り値データバッファの末尾を超えて読み取ろうとした場合に発生します。コールデータやコード自体には同様の条件がないことに注意してください。これらのバッファの末尾を超えて読み取ろうとすると、単にゼロが返されます。 -
|| μs || - δw + αw > 1024
スタックオーバーフロー。オペコードを実行した結果、スタックが1024アイテムを超える場合は中止します。
-
¬Iw ∧ W(w,μ) 静的に実行しているか(¬は否定 (opens in a new tab)であり、ブロックチェーンの状態を変更することが許可されている場合、_Iw_は真になります)?もしそうであり、状態を変更する操作を試みている場合、それは実行できません。
関数_W(w,μ)_は後の方程式150で定義されています。以下の条件のいずれかが当てはまる場合、_W(w,μ)_は真になります。
-
w ∈ {CREATE, CREATE2, SSTORE, SELFDESTRUCT} これらのオペコードは、新しいコントラクトの作成、値の保存、または現在のコントラクトの自己破壊のいずれかによって状態を変更します。
-
LOG0≤w ∧ w≤LOG4 静的に呼び出された場合、ログエントリを発行することはできません。 ログのオペコードはすべて、
LOG0(A0) (opens in a new tab)からLOG4(A4) (opens in a new tab)の範囲にあります。 ログのオペコードの後の数字は、ログエントリに含まれるトピックの数を指定します。 -
w=CALL ∧ μs[2]≠0 静的な状態でも別のコントラクトを呼び出すことはできますが、その場合、ETHを送金することはできません。
-
-
w = SSTORE ∧ μg ≤ Gcallstipend Gcallstipend(付録Gで2300と定義)を超えるガスがない限り、
SSTORE(opens in a new tab)を実行することはできません。
9.4.3 ジャンプ先の有効性
ここでは、JUMPDEST (opens in a new tab)オペコードとは何かを正式に定義します。バイト値0x5Bを探すだけでは不十分です。なぜなら、それがPUSHの内部にある可能性があるからです(その場合、オペコードではなくデータになります)。
方程式(153)では、関数_N(i,w)_を定義します。最初のパラメーター_i_は、オペコードの場所です。2番目の_w_は、オペコード自体です。_w∈[PUSH1, PUSH32]_の場合、オペコードがPUSHであることを意味します(角括弧は両端を含む範囲を定義します)。その場合、次のオペコードは_i+2+(w−PUSH1)_にあります。PUSH1 (opens in a new tab)の場合は2バイト(PUSH自体と1バイトの値)進める必要があり、PUSH2 (opens in a new tab)の場合は2バイトの値であるため3バイト進める必要があります。他のすべてのEVMオペコードは1バイト長であるため、それ以外のすべてのケースでは_N(i,w)=i+1_となります。
この関数は方程式(152)で使用され、_DJ(c,i)_を定義します。これは、オペコードの場所_i_から始まる、コード_c_内のすべての有効なジャンプ先の集合 (opens in a new tab)です。この関数は再帰的に定義されています。_i≥||c||_の場合、コードの末尾またはそれ以降にいることを意味します。これ以上ジャンプ先は見つからないため、単に空集合を返します。
それ以外のすべてのケースでは、次のオペコードに進み、そこから始まる集合を取得することで、コードの残りの部分を調べます。_c[i]_は現在のオペコードであるため、_N(i,c[i])_は次のオペコードの場所になります。したがって、_DJ(c,N(i,c[i]))_は、次のオペコードから始まる有効なジャンプ先の集合です。現在のオペコードがJUMPDESTでない場合は、単にその集合を返します。JUMPDESTである場合は、それを結果の集合に含めて返します。
9.4.4 正常な停止
停止関数_H_は、3種類の値を返すことができます。
- 停止オペコードにいない場合は、空集合である_∅_を返します。慣例により、この値はブール値の偽(false)として解釈されます。
- 出力を生成しない停止オペコード(
STOP(opens in a new tab)またはSELFDESTRUCT(opens in a new tab))がある場合は、戻り値としてサイズゼロのバイトシーケンスを返します。これは空集合とは大きく異なることに注意してください。この値は、EVMが実際に停止したことを意味しますが、読み取る戻り値データがないだけです。 - 出力を生成する停止オペコード(
RETURN(opens in a new tab)またはREVERT(opens in a new tab))がある場合は、そのオペコードで指定されたバイトシーケンスを返します。このシーケンスはメモリから取得され、スタックの一番上の値(μs[0])が最初のバイトであり、その後の値(μs[1])が長さになります。
H.2 命令セット
EVMの最後のサブセクションである9.5に進む前に、命令自体を見てみましょう。これらは29ページから始まる付録H.2で定義されています。その特定のオペコードで変更されると指定されていないものはすべて、同じままであると想定されます。変更される変数は、<something>′として指定されます。
たとえば、ADD (opens in a new tab)オペコードを見てみましょう。
| 値 | ニーモニック | δ | α | 説明 |
|---|---|---|---|---|
| 0x01 | ADD | 2 | 1 | 加算操作。 |
| μ′s[0] ≡ μs[0] + μs[1] |
δはスタックからポップする値の数です。この場合は、上位2つの値を加算するため、2になります。
αはプッシュバックする値の数です。この場合は、合計である1になります。
したがって、新しいスタックのトップ(μ′s[0])は、古いスタックのトップ(μs[0])とその下の古い値(μs[1])の合計になります。
「目がかすむようなリスト」ですべてのオペコードを確認する代わりに、この記事では新しい何かを導入するオペコードのみを説明します。
| 値 | ニーモニック | δ | α | 説明 |
|---|---|---|---|---|
| 0x20 | KECCAK256 | 2 | 1 | ケチャック・256ハッシュを計算します。 |
| μ′s[0] ≡ KEC(μm[μs[0] . . . (μs[0] + μs[1] − 1)]) | ||||
| μ′i ≡ M(μi,μs[0],μs[1]) |
これはメモリにアクセスする最初のオペコードです(この場合は読み取り専用)。ただし、メモリの現在の制限を超えて拡張される可能性があるため、_μi_を更新する必要があります。これは、29ページの方程式328で定義されている_M_関数を使用して行います。
| 値 | ニーモニック | δ | α | 説明 |
|---|---|---|---|---|
| 0x31 | BALANCE | 1 | 1 | 指定されたアカウントの残高を取得します。 |
| ... |
残高を調べる必要があるアドレスは_μs[0] mod 2160_です。スタックの一番上はアドレスですが、アドレスは160ビットしかないため、2160を法として(モジュロ) (opens in a new tab)値を計算します。
σ[μs[0] mod 2160] ≠ ∅の場合、このアドレスに関する情報があることを意味します。その場合、_σ[μs[0] mod 2160]b_がそのアドレスの残高になります。_σ[μs[0] mod 2160] = ∅_の場合、このアドレスは初期化されておらず、残高はゼロであることを意味します。アカウント情報フィールドのリストは、4ページのセクション4.1で確認できます。
2番目の方程式_A'a ≡ Aa ∪ {μs[0] mod 2160}_は、ウォームストレージ(最近アクセスされたためキャッシュされている可能性が高いストレージ)とコールドストレージ(アクセスされておらず、取得コストが高い低速なストレージにある可能性が高いストレージ)へのアクセス間のコストの違いに関連しています。_Aa_は、トランザクションによって以前にアクセスされたアドレスのリストであり、8ページのセクション6.1で定義されているように、アクセスが安価になるはずです。このテーマの詳細については、EIP-2929 (opens in a new tab)で読むことができます。
| 値 | ニーモニック | δ | α | 説明 |
|---|---|---|---|---|
| 0x8F | DUP16 | 16 | 17 | 16番目のスタックアイテムを複製します。 |
| μ′s[0] ≡ μs[15] |
スタックアイテムを使用するには、それをポップする必要があることに注意してください。つまり、その上にあるすべてのスタックアイテムもポップする必要があります。DUP<n> (opens in a new tab)およびSWAP<n> (opens in a new tab)の場合、これは最大16個の値をポップしてからプッシュする必要があることを意味します。
9.5 実行サイクル
すべてのパーツが揃ったので、EVMの実行サイクルがどのように文書化されているかをようやく理解できます。
方程式(155)は、以下の状態が与えられた場合について述べています。
- σ(グローバルなブロックチェーンの状態)
- μ(EVMの状態)
- A(サブステート、トランザクション終了時に発生する変更)
- I(実行環境)
新しい状態は_(σ', μ', A', I')_になります。
方程式(156)〜(158)は、スタックと、オペコード(μs)によるスタックの変更を定義しています。方程式(159)は、ガス(μg)の変更です。方程式(160)は、プログラムカウンター(μpc)の変更です。最後に、方程式(161)〜(164)は、オペコードによって明示的に変更されない限り、他のパラメーターが同じままであることを指定しています。
これでEVMは完全に定義されました。
まとめ
数学的表記は正確であり、イエロー・ペーパーでイーサリアムのあらゆる詳細を指定することを可能にしました。しかし、いくつかの欠点もあります。
- 人間にしか理解できないため、コンプライアンステスト (opens in a new tab)を手動で記述する必要があります。
- プログラマーはコンピューターコードを理解します。 しかし、数学的表記を理解できるとは限りません。
おそらくこれらの理由から、新しいコンセンサス・レイヤーの仕様 (opens in a new tab)はPythonで書かれています。Pythonで書かれた実行レイヤーの仕様 (opens in a new tab)もありますが、完全ではありません。イエロー・ペーパー全体がPythonや類似の言語に翻訳されない限り、イエロー・ペーパーは引き続き使用されるため、それを読めるようにしておくことは役立ちます。