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 も、無かったことになる順に見ていく。
- アカウントモデル: 口座 → 残高 + nonce + ストレージ。状態(コントラクトの変数)を直接持つ
- gas 計量: 命令ごとに価格。前渡し gas を消費しながら実行し、尽きたら止める(有料化で停止性を担保)
- リバート: 失敗すると状態は巻き戻るが gas は消費される。スナップショット + 巻き戻しで実装
① アカウントモデル: 状態を直接持つ
世界の状態は「アドレス → アカウント」の写像だ。アカウントは残高に加え、コントラクトなら nonce・コード・ストレージ(スロット番号 → 値の永続 KV)を持つ。コントラクトの変数は、このストレージに載る:
// 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 }snapshot と restore が後で効いてくる。実行を始める前に状態全体のコピーを控えておき、失敗したらそれで差し替える。これがリバートの土台だ。UTXO では「消費で集合から消える」ことが正しさを支えたが、アカウントモデルでは「巻き戻せること」がそれに当たる。
② スタックVM と gas
コントラクトのコードはバイトの列で、EVM は 1 命令ずつ fetch-decode-execute で回す。スタックマシンなので、命令はスタック上位を読み書きする。普通の VM と違うのは、命令を実行する前に gas を引くことだ。足りなければ実行を止める:
// 実行が中断する理由。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 0x5b の 0x5b は命令ではなくデータだ。データの中の 0x5b を飛び先にできてしまうと、コードの途中に潜り込める。だから PUSH のデータ・バイトを除外して、本物の JUMPDEST だけを事前に集める。
③ リバート: 失敗しても gas は消費する
呼び出しの全体像が Call だ。スナップショットを取り、残高を移し、コードを実行する。失敗したら状態を巻き戻す。ただし gas は消費されたまま報告する:
// 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 消費とスタックの動きを追ってほしい。
gas 1000 を渡して counter を呼ぶ。storage[0] は 0。命令ごとに gas を引きながら実行する
各命令は 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
参考資料
- Gavin Wood, "Ethereum: A Secure Decentralised Generalised Transaction Ledger"(Yellow Paper) — EVM の定義と gas
- Ethereum.org, "EVM" と "Gas" — 概観
- utxo 編(対のモデル)・bytecode 編(自前スタックVM)と合わせて読むと実行系の系譜がつながる
- 実装: chain/evm