Read OSS

ブロック実行:Blockchain、StateDB、そして EVM

上級

前提知識

  • 第 1・2 回:アーキテクチャとブート処理
  • Ethereum の状態モデル(アカウント、ストレージ、ステートトライ)
  • Merkle Patricia Trie の基礎知識

ブロック実行:Blockchain、StateDB、そして EVM

ここからが Ethereum の本番です。これまで見てきた CLI フレームワーク、Node コンテナ、サービスの配線はすべて、ひとつの目的のために存在しています。ブロックを実行し、ワールドステートを前進させることです。この記事では、ブロックが到着してから状態がコミットされるまでの完全な経路を追います。対象となる主要サブシステムは3つ — 正規チェーンを管理する BlockChain マネージャ、ワールドステートをメモリ上に保持する StateDB、そしてコントラクトバイトコードを実行する EVM です。

BlockChain:正規チェーンのマネージャ

BlockChain 構造体は、チェーン状態の中央コーディネーターです。正規チェーンの管理、ブロックの挿入と再編成(reorg)の処理、そして最近アクセスしたデータの LRU キャッシュの維持を担っています。

type BlockChain struct {
    chainConfig *params.ChainConfig
    cfg         *BlockChainConfig
    db          ethdb.Database
    // ... caches, trie database, snapshot tree, etc.
}

BlockChainConfigparams.ChainConfig とは別物です)は、トライキャッシュの上限、スナップショットの設定、アーカイブモード、ステートのプルーニングポリシー、VM 設定といったランタイム動作を制御します。eth.New() が CLI フラグとプロトコルのデフォルト値をもとに組み立てる設定がまさにこれです。

キャッシュの定数を見ると、パフォーマンス上の優先順位がよく分かります。

const (
    bodyCacheLimit     = 256
    blockCacheLimit    = 256
    receiptsCacheLimit = 32
    txLookupCacheLimit = 1024
)

トランザクションルックアップのキャッシュが最大の 1024 エントリに設定されているのは、eth_getTransactionByHash が最も呼び出し頻度の高い RPC メソッドのひとつだからです。

classDiagram
    class BlockChain {
        -chainConfig *ChainConfig
        -cfg *BlockChainConfig
        -db ethdb.Database
        -triedb *triedb.Database
        -snaps *snapshot.Tree
        -bodyCache LRU
        -blockCache LRU
        -txLookupCache LRU
        +InsertChain(blocks) error
        +CurrentBlock() *Header
        +StateAt(root) (*StateDB, error)
        +GetBlock(hash, number) *Block
    }
    class BlockChainConfig {
        +TrieCleanLimit int
        +TrieDirtyLimit int
        +StateScheme string
        +ArchiveMode bool
        +SnapshotLimit int
        +VmConfig vm.Config
        +triedbConfig() *triedb.Config
    }
    BlockChain --> BlockChainConfig

triedbConfig() メソッドは、ブロックチェーン層とストレージ層をつなぐ重要な橋渡し役です。BlockChainConfig のパラメータを triedb.Config に変換し、ハッシュベースとパスベースのどちらのストレージスキームを使うかを決定します。この仕組みは第 4 回で詳しく掘り下げます。

コンセンサスエンジン:検証と実行の分離

consensus.Engine インターフェースは、ブロックの検証実行を明確に分離しています。この分離は根本的に重要です。検証はブロックが「有効である可能性があるか」を確認します(難易度が正しいか、シールが有効か、アンクル参照が適切かなど)。一方、実行はそのブロックを適用して次のステートを生成します。

classDiagram
    class Engine {
        <<interface>>
        +Author(header) address, error
        +VerifyHeader(chain, header) error
        +VerifyHeaders(chain, headers) chan, chan
        +VerifyUncles(chain, block) error
        +Prepare(chain, header) error
        +Finalize(chain, header, state, body)
        +FinalizeAndAssemble(chain, header, state, body, receipts) block, error
        +Seal(chain, block, results, stop) error
    }
    class Beacon {
        -ethone Engine
    }
    class Clique {
        -config *CliqueConfig
    }
    class EthashFaker {
    }
    Beacon ..|> Engine
    Clique ..|> Engine
    EthashFaker ..|> Engine
    Beacon --> Clique : wraps
    Beacon --> EthashFaker : wraps

The Merge 以降、すべてのコンセンサスエンジンは beacon.New() でラップされます。Beacon エンジンはプルーフオブステーク固有の処理を担いながら、The Merge 以前のロジックは内部エンジンに委譲します。Geth は現在 post-Merge ネットワークのみをサポートしており(CreateConsensusEngine で強制されています)、TerminalTotalDifficulty は常に設定されている必要があります。

状態実行パイプライン

実行パイプラインは、ブロックが状態遷移へと変換される場所です。起点となるのは StateProcessor.Process() で、ブロック内のすべてのトランザクションを順に処理していきます。

flowchart TD
    A["StateProcessor.Process(block, statedb)"] --> B["Apply pre-execution system calls<br/>(beacon root, parent hash)"]
    B --> C["For each transaction:"]
    C --> D["TransactionToMessage()"]
    D --> E["ApplyTransactionWithEVM()"]
    E --> F["State Transition:<br/>intrinsic gas → EVM execution → refunds"]
    F --> G["Generate receipt"]
    G --> C
    C --> H["postExecution()<br/>(withdrawals, requests)"]
    H --> I["Finalize via consensus engine"]
    I --> J["Return receipts, logs, gas used"]

この関数はまずシステムコールを適用します。具体的には、beacon block root(EIP-4788)と parent block hash(EIP-2935、Prague と Verkle 向け)の処理です。そのあとトランザクションをひとつずつ ApplyTransactionWithEVM で適用していきます。EVM インスタンスはブロック全体で一つだけ生成され、トランザクション間で使い回されます。トランザクションごとに切り替わるのは TxContext のみです。

各トランザクションの実行結果は ExecutionResult に格納されます。

type ExecutionResult struct {
    UsedGas    uint64
    MaxUsedGas uint64
    Err        error
    ReturnData []byte
}

ヒント: EVM の実行問題をデバッグするとき、ExecutionResult.Err を「何かがおかしくなった」という Go のエラーとして捉えてはいけません。これはプロトコルレベルの結果です。ErrExecutionReverted が返り、ReturnData が nil でない場合は、コントラクトが明示的に revert したことを意味し、return data にはその理由が含まれています。

IntrinsicGas 関数は、EVM 実行が始まる前のベースラインガスコストを算出します。コントラクト作成かコール呼び出しか、calldata のゼロバイト・非ゼロバイトのコスト、アクセスリストのエントリ、認可リストのエントリなどを考慮して計算します。EIP-2028 による calldata コストの削減や、EIP-3860 による initcode サイズ制限もここで適用されます。

StateDB:メモリ上のワールドステート

StateDB は、Ethereum のワールドステートをメモリ上にキャッシュする層です。EVM と永続化トライの間に位置し、高速な読み書きを提供しながら、ロールバックに備えてすべての変更を追跡します。

type StateDB struct {
    db         Database
    prefetcher *triePrefetcher
    reader     Reader
    trie       Trie

    originalRoot common.Hash
    stateObjects map[common.Address]*stateObject
    stateObjectsDestruct map[common.Address]*stateObject
    mutations    map[common.Address]*mutation

    refund uint64
    // journal for revert snapshots, logs, etc.
}

設計上の重要な判断がいくつかあります。

  1. 遅延トライ解決trie フィールドは「初回アクセス時に解決」されます。つまり、実際に状態が読み込まれるまで Merkle トライのルートはロードされません。

  2. オブジェクトの追跡stateObjects は変更中のライブなアカウントオブジェクトを保持します。stateObjectsDestruct は破棄されたアカウントを追跡し、mutations はトランザクション境界でのアカウントレベルの変更を記録します。

  3. ジャーナルによるスナップショット — StateDB はリバートポイントの作成にジャーナルパターンを採用しています。EVM が REVERT オペコードに到達すると、ジャーナルを逆順に再生して、そのコールフレーム内のすべての状態変更を取り消します。

  4. プリフェッチtriePrefetcher は必要になる前にバックグラウンドのゴルーチンでトライノードを読み込みます。実行中のブロッキングなディスク読み込みを減らすのが目的です。

classDiagram
    class StateDB {
        -db Database
        -trie Trie
        -stateObjects map~Address → stateObject~
        -mutations map~Address → mutation~
        -refund uint64
        +GetBalance(addr) uint256
        +SetState(addr, key, value)
        +CreateAccount(addr)
        +Snapshot() int
        +RevertToSnapshot(id)
        +Commit(block, collectLeaf) (Hash, error)
    }
    class stateObject {
        -address Address
        -data types.StateAccount
        -code []byte
        -dirtyStorage Storage
        -originStorage Storage
    }
    StateDB o-- stateObject

EVM アーキテクチャ:テーブル駆動型インタープリタ

EVM は Ethereum の計算モデルの中核です。Geth ではテーブル駆動型インタープリタとして実装されており、オペコードを取得して 256 エントリのテーブルからハンドラを引き、それを実行するという処理をタイトなループで繰り返します。

EVM 構造体はすべての実行コンテキストを保持しています。

type EVM struct {
    Context BlockContext  // Block-level: coinbase, gas limit, block number, time
    TxContext             // Tx-level: origin, gas price, blob hashes
    StateDB StateDB       // World state access
    table   *JumpTable    // Fork-specific opcode handlers
    depth   int           // Call stack depth
    chainConfig *params.ChainConfig
    chainRules  params.Rules
    Config  Config        // VM config: tracer, extra EIPs
}

BlockContextTxContext の分離は意図的な設計です。ブロックレベルのコンテキスト(coinbase、タイムスタンプ、ブロック番号)はブロック内のすべてのトランザクションで変わりません。一方、トランザクションレベルのコンテキスト(送信者アドレス、ガス価格)はトランザクションごとに変わります。そのため NewEVM をブロックにつき一度だけ呼び出し、トランザクションの切り替え時に SetTxContext を使うだけで済みます。

Run() メソッドがインタープリタループの本体です。

flowchart TD
    START["Run(contract, input, readOnly)"] --> CHECK["Code empty?"]
    CHECK -->|yes| RET_NIL["Return nil, nil"]
    CHECK -->|no| INIT["Create Memory, Stack, ScopeContext"]
    INIT --> LOOP["Main Loop"]
    LOOP --> FETCH["op = contract.GetOp(pc)"]
    FETCH --> LOOKUP["operation = jumpTable[op]"]
    LOOKUP --> VALIDATE["Check stack bounds"]
    VALIDATE --> GAS["Deduct constantGas"]
    GAS --> DYNGAS["Calculate & deduct dynamicGas"]
    DYNGAS --> EXEC["Execute operation.execute()"]
    EXEC --> NEXT["Advance pc"]
    NEXT --> LOOP
    EXEC -->|STOP/RETURN/REVERT| EXIT["Return result"]

ジャンプテーブルの各エントリは operation 構造体です。

type operation struct {
    execute     executionFunc
    constantGas uint64
    dynamicGas  gasFunc
    minStack    int
    maxStack    int
    memorySize  memorySizeFunc
    undefined   bool
}

この設計のおかげで、オペコードの動作に関するすべての情報 — 実行関数、ガスコスト、スタック要件、メモリサイズの算出 — がひとつの構造体に収まっています。新しいオペコードを追加するには、テーブルにエントリを加えるだけです。

フォークの積み重ね:EIP がオペコードを追加する仕組み

フォークごとの命令セットは積み重ねによって構築されます。新しいフォークは直前のフォークのテーブルをコピーし、該当するエントリを上書きします。jump_table.go のチェーンは Frontier から始まり、Amsterdam まで続いています。

var (
    frontierInstructionSet         = newFrontierInstructionSet()
    homesteadInstructionSet        = newHomesteadInstructionSet()
    // ... each one builds on the previous
    pragueInstructionSet           = newPragueInstructionSet()
    osakaInstructionSet            = newOsakaInstructionSet()
    amsterdamInstructionSet        = newAmsterdamInstructionSet()
)

例えば newAmsterdamInstructionSet() は Osaka の命令セットをコピーしたうえで、EIP-7843(SLOTNUM オペコード)と EIP-8024 を有効化します。

func newAmsterdamInstructionSet() JumpTable {
    instructionSet := newOsakaInstructionSet()
    enable7843(&instructionSet)
    enable8024(&instructionSet)
    return validate(instructionSet)
}

NewEVM() が呼ばれると、現在のブロック番号とタイムスタンプから ChainConfig をもとに導出されたブール値フラグの集合 chainRules を参照して、適切な命令セットを選択します。選択には逆時系列の switch 文が使われており、最新のフォークが最初にチェックされます。

ヒント: 特定の EIP がどのように実装されたかを追跡したいときは、対応する enable 関数(例:enable4844)を探しましょう。これらの関数がジャンプテーブルをインプレースで変更し、影響を受けるオペコードの executeconstantGasdynamicGas、スタックパラメータをセットしています。

BlockChain → StateProcessor → EVM → StateDB という実行パイプラインこそが、Geth の本質です。しかし、コミットされた状態は実際にどこへ行くのでしょうか?第 4 回ではその疑問に答えます。メモリ上の StateDB からデータが Merkle Patricia Trie を通り、ディスク上のキーバリューストアへと書き込まれるまでを追っていきましょう。