Skip to content

EVM(アカウントモデルとスタックVM、gas)

UTXO モデルは残高を持たないが、スマートコントラクトは状態を持つプログラムだ。だから EVM は口座ごとに残高とストレージを持つアカウントモデルを選び、その上でバイトコードをスタックマシンで実行する。普通の VM と違うのは 2 点。gas は命令ごとに価格を取り、尽きたら実行を止める。リバートは失敗時に状態を巻き戻す(ただし gas は消費されたまま)。

この章で作るもの

UTXO モデルは並列的だが、状態を素直に持てないという弱点があった。「今カウンタはいくつか」「この住所の残高は」を未使用出力の集合で表すのは不自然だ。スマートコントラクトが状態を持つプログラムである以上、口座に状態を直接置くアカウントモデルの方が相性がいい。

だが「信頼できない相手のプログラムを、世界中のノードが実行する」には難題がある。悪意ある無限ループをどう止めるか。失敗した実行の副作用をどう無かったことにするか。EVM の答えが gas とリバートだった。

   alice ──Call(value=5, gas=1000)──▶ contract(code)
      ① snapshot        状態のコピーを控える
      ② value を移す    alice.balance -= 5 ; contract.balance += 5
      ③ 実行            PUSH/SLOAD/ADD/SSTORE… 命令ごとに gas を引く

   成功 ─▶ 状態を確定、GasUsed = 1000 - 残り
   失敗 ─▶ 状態を snapshot へ巻き戻す。ただし GasUsed は消費済み(戻らない)
           └ SSTORE で書いた値も、移した value も、無かったことになる
Call の流れ。alice が value と gas を添えて contract を呼ぶ。まず状態のスナップショットを取り、残高を移し、バイトコードを実行する。成功すれば状態は確定。失敗(REVERT/out-of-gas)すれば状態は巻き戻るが、gas は消費されたまま

順に見ていく。

  1. アカウントモデル: 口座 → 残高 + nonce + ストレージ。状態(コントラクトの変数)を直接持つ
  2. gas 計量: 命令ごとに価格。前渡し gas を消費しながら実行し、尽きたら止める(有料化で停止性を担保)
  3. リバート: 失敗すると状態は巻き戻るが gas は消費される。スナップショット + 巻き戻しで実装

① アカウントモデル: 状態を直接持つ

世界の状態は「アドレス → アカウント」の写像だ。アカウントは残高に加え、コントラクトなら nonce・コード・ストレージ(スロット番号 → 値の永続 KV)を持つ。コントラクトの変数は、このストレージに載る:

go

// Address は口座の宛先。実物は 20 バイトのハッシュだが、ここでは読みやすさ優先で文字列。
type Address string

// Account は 1 つの口座。残高に加えて、コントラクトなら nonce・コード・ストレージを持つ。
// ストレージは「スロット番号 → 値」の永続 KV で、コントラクトの状態(変数)はここに載る。
type Account struct {
	Nonce   uint64
	Balance uint64
	Code    []byte            // 空なら EOA(ただの口座)、非空ならコントラクト
	Storage map[uint64]uint64 // 永続ストレージ(SLOAD/SSTORE が読み書きする)
}

// clone はアカウントの深いコピー(スナップショット用)。
func (a *Account) clone() *Account {
	st := make(map[uint64]uint64, len(a.Storage))
	for k, v := range a.Storage {
		st[k] = v
	}
	code := append([]byte(nil), a.Code...)
	return &Account{Nonce: a.Nonce, Balance: a.Balance, Code: code, Storage: st}
}

// IsContract はコード(=実行可能なプログラム)を持つ口座かどうか。
func (a *Account) IsContract() bool { return len(a.Code) > 0 }

// State はアドレス → アカウントの写像。これがチェーンの「世界の状態」。
type State struct {
	accounts map[Address]*Account
}

// NewState は空の状態を作る。
func NewState() *State {
	return &State{accounts: map[Address]*Account{}}
}

// get は既存アカウントを返す(無ければ nil)。
func (s *State) get(addr Address) *Account { return s.accounts[addr] }

// GetOrCreate はアカウントを返し、無ければ空の口座を作る。
func (s *State) GetOrCreate(addr Address) *Account {
	if a := s.accounts[addr]; a != nil {
		return a
	}
	a := &Account{Storage: map[uint64]uint64{}}
	s.accounts[addr] = a
	return a
}

// Balance はアドレスの残高(無ければ 0)。
func (s *State) Balance(addr Address) uint64 {
	if a := s.accounts[addr]; a != nil {
		return a.Balance
	}
	return 0
}

// Storage はコントラクトのスロット値を返す(状態照会・デモ表示用)。
func (s *State) Storage(addr Address, slot uint64) uint64 {
	if a := s.accounts[addr]; a != nil {
		return a.Storage[slot]
	}
	return 0
}

// snapshot は状態全体の深いコピー。実行失敗時に巻き戻すために取る。
func (s *State) snapshot() *State {
	cp := &State{accounts: make(map[Address]*Account, len(s.accounts))}
	for addr, a := range s.accounts {
		cp.accounts[addr] = a.clone()
	}
	return cp
}

// restore はスナップショットで状態を差し替える(revert)。
func (s *State) restore(snap *State) { s.accounts = snap.accounts }

snapshotrestore が後で効いてくる。実行を始める前に状態全体のコピーを控えておき、失敗したらそれで差し替える。これがリバートの土台だ。UTXO では「消費で集合から消える」ことが正しさを支えたが、アカウントモデルでは「巻き戻せること」がそれに当たる。

② スタックVM と gas

コントラクトのコードはバイトの列で、EVM は 1 命令ずつ fetch-decode-execute で回す。スタックマシンなので、命令はスタック上位を読み書きする。普通の VM と違うのは、命令を実行する前に gas を引くことだ。足りなければ実行を止める:

go

// 実行が中断する理由。out-of-gas と revert は「gas は消費するが状態は巻き戻す」で共通。
var (
	ErrOutOfGas       = errors.New("evm: gas が尽きた")
	ErrStackUnderflow = errors.New("evm: スタックが空なのに pop しようとした")
	ErrInvalidJump    = errors.New("evm: JUMPDEST でない位置へ飛ぼうとした")
	ErrInvalidOp      = errors.New("evm: 未知の命令")
	ErrRevert         = errors.New("evm: REVERT で実行が中止された")
)

// addrWord はアドレスを 1 ワード(uint64)に畳む。CALLER が積む値・比較に使う
// (本物の EVM は 160bit をそのまま 256bit ワードに載せる。ここは簡略化)。
func addrWord(a Address) uint64 {
	h := fnv.New64a()
	_, _ = h.Write([]byte(a))
	return h.Sum64()
}

// vm は 1 回の実行の内部状態。スタックマシンなので、命令はスタック上位を読み書きする。
type vm struct {
	code   []byte
	stack  []uint64
	pc     int
	gas    uint64 // 残り gas
	self   *Account
	caller Address
	value  uint64
	jumpOK map[int]bool // 有効な JUMPDEST 位置(PUSH データ内を除外して事前計算)
}

// jumpdests は PUSH のオペレータ・バイトを飛ばしつつ、JUMPDEST の位置を集める。
// PUSH1 の次の 1 バイトはデータなので、そこにたまたま 0x5b があっても飛び先にしない。
func analyzeJumpdests(code []byte) map[int]bool {
	dests := map[int]bool{}
	for i := 0; i < len(code); i++ {
		op := Op(code[i])
		if op == OpJUMPDEST {
			dests[i] = true
		}
		if op == OpPUSH1 {
			i++ // 次の 1 バイトはデータ。読み飛ばす
		}
	}
	return dests
}

func (m *vm) push(v uint64) { m.stack = append(m.stack, v) }

func (m *vm) pop() (uint64, error) {
	if len(m.stack) == 0 {
		return 0, ErrStackUnderflow
	}
	v := m.stack[len(m.stack)-1]
	m.stack = m.stack[:len(m.stack)-1]
	return v, nil
}

// useGas は命令ぶんの gas を引く。足りなければ out-of-gas。
func (m *vm) useGas(cost uint64) error {
	if m.gas < cost {
		m.gas = 0
		return ErrOutOfGas
	}
	m.gas -= cost
	return nil
}

// run は code を fetch-decode-execute で回す。STOP/RETURN で正常終了、
// REVERT/エラーで異常終了する。異常時も gas は消費済みのまま返る。
func (m *vm) run() error {
	for m.pc < len(m.code) {
		op := Op(m.code[m.pc])
		if _, known := opTable[op]; !known {
			return fmt.Errorf("%w: 0x%02x @%d", ErrInvalidOp, byte(op), m.pc)
		}
		if err := m.useGas(gasCost(op)); err != nil {
			return err
		}
		m.pc++

		switch op {
		case OpSTOP, OpRETURN:
			return nil
		case OpREVERT:
			return ErrRevert
		case OpPUSH1:
			if m.pc >= len(m.code) {
				return fmt.Errorf("%w: PUSH1 のデータが無い", ErrInvalidOp)
			}
			m.push(uint64(m.code[m.pc]))
			m.pc++
		case OpPOP:
			if _, err := m.pop(); err != nil {
				return err
			}
		case OpADD, OpMUL, OpSUB, OpLT, OpGT, OpEQ:
			if err := m.binOp(op); err != nil {
				return err
			}
		case OpISZERO:
			a, err := m.pop()
			if err != nil {
				return err
			}
			m.push(b2i(a == 0))
		case OpDUP1:
			if len(m.stack) == 0 {
				return ErrStackUnderflow
			}
			m.push(m.stack[len(m.stack)-1])
		case OpSWAP1:
			if len(m.stack) < 2 {
				return ErrStackUnderflow
			}
			n := len(m.stack)
			m.stack[n-1], m.stack[n-2] = m.stack[n-2], m.stack[n-1]
		case OpCALLER:
			m.push(addrWord(m.caller))
		case OpCALLVALUE:
			m.push(m.value)
		case OpSLOAD:
			slot, err := m.pop()
			if err != nil {
				return err
			}
			m.push(m.self.Storage[slot])
		case OpSSTORE:
			slot, err := m.pop()
			if err != nil {
				return err
			}
			val, err := m.pop()
			if err != nil {
				return err
			}
			m.self.Storage[slot] = val // 状態の書き換え。失敗すれば呼び出し側が巻き戻す
		case OpJUMP:
			dest, err := m.pop()
			if err != nil {
				return err
			}
			if !m.jumpOK[int(dest)] {
				return fmt.Errorf("%w: @%d", ErrInvalidJump, dest)
			}
			m.pc = int(dest)
		case OpJUMPI:
			dest, err := m.pop()
			if err != nil {
				return err
			}
			cond, err := m.pop()
			if err != nil {
				return err
			}
			if cond != 0 {
				if !m.jumpOK[int(dest)] {
					return fmt.Errorf("%w: @%d", ErrInvalidJump, dest)
				}
				m.pc = int(dest)
			}
		case OpJUMPDEST:
			// マーカー。何もしない
		}
	}
	return nil // コード末尾に達したら STOP と同じ扱い
}

func (m *vm) binOp(op Op) error {
	a, err := m.pop()
	if err != nil {
		return err
	}
	b, err := m.pop()
	if err != nil {
		return err
	}
	switch op {
	case OpADD:
		m.push(a + b)
	case OpMUL:
		m.push(a * b)
	case OpSUB:
		m.push(a - b)
	case OpLT:
		m.push(b2i(a < b))
	case OpGT:
		m.push(b2i(a > b))
	case OpEQ:
		m.push(b2i(a == b))
	}
	return nil
}

func b2i(b bool) uint64 {
	if b {
		return 1
	}
	return 0
}

命令の価格を見ると、SSTORE(ストレージ書き込み)が飛び抜けて高い。理由は、ストレージを全ノードが永続的に保持するからだ。状態を増やすことはネットワーク全体にずっと負担を課す。gas は単なる手数料ではなく、共有資源の使用量に価格を付けて濫用を防ぐメカニズムだ。

analyzeJumpdests にも一手間ある。JUMP の飛び先は JUMPDEST マーカーの位置に限られるが、PUSH1 0x5b0x5b は命令ではなくデータだ。データの中の 0x5b を飛び先にできてしまうと、コードの途中に潜り込める。だから PUSH のデータ・バイトを除外して、本物の JUMPDEST だけを事前に集める。

③ リバート: 失敗しても gas は消費する

呼び出しの全体像が Call だ。スナップショットを取り、残高を移し、コードを実行する。失敗したら状態を巻き戻す。ただし gas は消費されたまま報告する:

go

// ErrInsufficientBalance は value を送るだけの残高が送り主に無いとき。
var ErrInsufficientBalance = errors.New("evm: 残高が送金額に足りない")

// Result は 1 回の呼び出しの結果。
//
// 最重要なのは Reverted と GasUsed の組み合わせだ: 実行が失敗(revert / out-of-gas)
// しても GasUsed は消費される——「計算させた分の対価は払う」。一方で状態変更は
// 巻き戻る。つまり「gas は減るが、ストレージや残高は元通り」。ここが EVM の肝。
type Result struct {
	Success  bool     // 正常終了したか
	Reverted bool     // REVERT / out-of-gas / 実行時エラーで巻き戻したか
	GasUsed  uint64   // 消費した gas(失敗しても発生する)
	Stack    []uint64 // 終了時のスタック(RETURN 値の確認・デモ表示用)
	Err      error    // 失敗理由
}

// EVM は状態を持ち、その上で呼び出し(Call)と配備(Deploy)を実行する。
type EVM struct {
	state *State
}

// NewEVM は与えた状態に対して動く EVM を作る。
func NewEVM(state *State) *EVM { return &EVM{state: state} }

// State は現在の世界の状態を返す(残高・ストレージの照会用)。
func (e *EVM) State() *State { return e.state }

// Deploy は from が code を持つコントラクト口座を配備する。
// アドレスは from と nonce から決定的に導く(実 EVM と同じ考え方)。
func (e *EVM) Deploy(from Address, code []byte) Address {
	acc := e.state.GetOrCreate(from)
	addr := contractAddress(from, acc.Nonce)
	acc.Nonce++
	c := e.state.GetOrCreate(addr)
	c.Code = append([]byte(nil), code...)
	return addr
}

// Call は from から to へ value を送り、to がコントラクトならコードを実行する。
//
// 手順: (1)状態のスナップショットを取る (2)残高を移す (3)コードを実行する。
// 途中で失敗したら状態をスナップショットへ巻き戻す。ただし gas は消費されたまま。
func (e *EVM) Call(from, to Address, value, gasLimit uint64) Result {
	snap := e.state.snapshot()

	fromAcc := e.state.GetOrCreate(from)
	toAcc := e.state.GetOrCreate(to)

	// 残高不足は実行前に弾く(gas も使っていないので巻き戻すだけ)。
	if fromAcc.Balance < value {
		e.state.restore(snap)
		return Result{Reverted: true, Err: fmt.Errorf("%w: 残高 %d < value %d", ErrInsufficientBalance, fromAcc.Balance, value)}
	}
	fromAcc.Balance -= value
	toAcc.Balance += value

	// 受け手がコントラクトでなければ、ただの送金で終わり。
	if !toAcc.IsContract() {
		return Result{Success: true, GasUsed: 0}
	}

	m := &vm{
		code:   toAcc.Code,
		gas:    gasLimit,
		self:   toAcc,
		caller: from,
		value:  value,
		jumpOK: analyzeJumpdests(toAcc.Code),
	}
	err := m.run()
	gasUsed := gasLimit - m.gas

	if err != nil {
		// 失敗: 状態を巻き戻す。だが gas は消費済みとして報告する。
		e.state.restore(snap)
		return Result{Reverted: true, GasUsed: gasUsed, Stack: m.stack, Err: err}
	}
	return Result{Success: true, GasUsed: gasUsed, Stack: m.stack}
}

// contractAddress は配備者アドレスと nonce から決定的にコントラクトアドレスを導く。
func contractAddress(from Address, nonce uint64) Address {
	h := fnv.New64a()
	_, _ = fmt.Fprintf(h, "%s:%d", from, nonce)
	return Address(fmt.Sprintf("0xC%08x", h.Sum64()&0xffffffff))
}

この if err != nil { restore(snap) }GasUsed: gasUsed の 2 行が、EVM の安全性の核心だ。攻撃者がわざと失敗するコードや無限ループを送りつけても、(1) gas 上限で必ず止まり、(2) 状態は汚されず、(3) それでも計算させた分の gas は取られる。だから「信頼できない相手のコードを走らせても、こちらは損をしない」。out-of-gas も REVERT も、この同じ経路を通る。

動かす

下のデモは、この筋書きをそのままブラウザで動かしている。カウンタ・コントラクトを呼ぶと、スタックが積まれ、gas が減り、ストレージが書き換わる。gas を絞ると途中で out-of-gas になり、それまでの書き込みが巻き戻るのに gas は消費される様子が見える。命令ごとの gas 消費とスタックの動きを追ってほしい。

デモevm(スタックVM + gas + リバート)step 1
gas 1000(十分)gas 40(不足)
counter コントラクト(逆アセンブル)
0: PUSH1 0
1: SLOAD
2: PUSH1 1
3: ADD
4: PUSH1 0
5: SSTORE
6: STOP
スタック
(空)
gas 残り1000
gas 消費0
storage[0]0
(実行前)

gas 1000 を渡して counter を呼ぶ。storage[0] は 0。命令ごとに gas を引きながら実行する

1 / 8

各命令は gas を消費し、尽きれば out-of-gas で止まる。失敗すると storage への書き込みは巻き戻るが、gas は消費されたまま—— 信頼できない相手のコードを走らせても、こちらは損をしない仕掛け。

設計の観点: なぜ gas とリバートか

  • なぜ gas が要るか: 公開ネットワークでは誰でも任意のコードを実行させられる。無限ループや重い計算を止める手段がないと、全ノードが巻き添えになる(DoS)。gas は「各命令に価格を付け、前払いした分だけ実行する」ことで停止性を強制する。停止問題は一般には解けないが、「gas が尽きたら止める」で回避している
  • なぜ SSTORE は高いか: ストレージは全ノードが永続的に保持する。CPU 時間(一過性)より、状態(恒久)を増やす方がネットワークに重い。だから gas 価格は「その命令が課す共有コスト」を反映する
  • リバートの意味: トランザクションは原子的であるべきだ。途中で失敗したら、副作用を残さず無かったことにする。DB のトランザクションと同じ。スナップショット + 巻き戻しで実現する。ただし gas は返さない(計算はさせたから)
  • アカウント vs UTXO: アカウントは状態を直接持てるがゆえに、同じ口座への取引が直列化される(nonce 順)。UTXO は独立出力で並列的だが状態を持てない。用途が「状態を持つコントラクト」なら前者、「並列な価値移転」なら後者
  • リエントランシー: コントラクトが外部を呼び、その先が呼び戻すと、状態更新前の中途半端な状態で再入されうる(The DAO 事件)。本章は 1 段の呼び出しに限っているが、実装では「効果を先に、外部呼び出しを後に(checks-effects-interactions)」が定石

メリット・デメリットと実例

論点仕組み効果実例
停止性gas 前払い + 命令ごと消費無限ループを有料化して止めるEthereum の gasLimit
状態濫用の抑制SSTORE を高価にストレージ肥大を経済的に抑制EIP-2200 等の SSTORE 価格
原子性snapshot + revert失敗した副作用を残さないrequire/revert、トランザクション巻き戻し
対価失敗でも gas 消費攻撃コストを攻撃者に負わせるout-of-gas でも gas は取られる

裏どり:

  • Ethereum Yellow Paper: EVM をスタックマシンとして厳密に定義。各命令の gas コストもここに規定される
  • The DAO(2016): リエントランシーで約 360 万 ETH が抜かれた。「状態更新の前に外部呼び出しした」のが原因。以後 checks-effects-interactions が定石に
  • gas 価格改定(EIP): SSTORE や SLOAD の価格は EIP-1884・EIP-2929 等で何度も見直された。「共有コストに見合う価格」を追い続けている証拠
  • 他チェーンの weight: Polkadot/Substrate は gas の一般化として weight(実行時間 + ストレージ)を使う。次章以降で扱う

簡略化したこと

  • 256bit でなく 64bit ワード: 本物は 256bit。桁あふれは wrap で近似
  • メモリ命令・CALLDATA・LOG なし: MLOAD/MSTORE・イベントログは省略。ストレージと計算に集中
  • コントラクト間 CALL なし: 1 段の呼び出しのみ。リエントランシーは設計の観点の節で言及するに留める
  • 状態ルートなし: 状態のハッシュ(Merkle Patricia Trie)は扱わない。状態は素の map

参考資料