Skip to content

Rollup(Layer2 と fraud proof / validity proof)

L1 は安全な代わりに遅く高い。ロールアップは実行を L2 に逃がし、結果の要約(state root)と取引データだけを L1 に記録する。問題は、L1 が再実行しないなら sequencer の嘘をどう見抜くか。答えは 2 つ。事後に暴く optimistic(fraud proof)と、事前に証明する zk(validity proof)で、確定の速さと検証コストが逆になる。

この章で作るもの

evm 編で見たとおり、コントラクトの実行は全ノードがなぞる。だから L1 のスループットには天井がある。速くするには「みんなが実行する量」を減らすしかない。ロールアップは実行を 1 台の sequencer(L2)に集約し、L1 は結果の root を記録する監査役に徹する。

問題は、監査役が再実行しないなら不正をどう見抜くか。これが本章の主題で、答えは 2 通りある。optimistic は受理してから事後に fraud proof で罰する。zk は commit のときに validity proof を要求する。

   L2 (sequencer が実行)          L1 (再実行しない、root を記録)
   ┌─────────────────┐           ┌──────────────────────────────┐
   │ txs を適用        │  batch    │ Optimistic:                   │
   │ PrevRoot→PostRoot │ ───────▶ │   受理(Pending)→ 期間内に       │
   │ (+ 取引データ)     │           │   fraud proof 無ければ Final    │
   └─────────────────┘           │   不正なら巻き戻し + 保証金没収    │
                                  │ ZK:                            │
                                  │   validity proof を検証 → 即Final │
                                  │   証明無効なら commit 時に拒否    │
                                  └──────────────────────────────┘
ロールアップの 2 系統。L2 で実行し、バッチ(PrevRoot→PostRoot + 取引)を L1 に投稿する。Optimistic は楽観的に受理し challenge 期間内の fraud proof で覆す。ZK は validity proof を commit 時に検証して即確定する

順に見ていく。

  1. L1 は再実行しない: 実行は L2、L1 は state root の列を記録するだけ。取引データも L1 に載せる(data availability)。載せないと誰も検証できない
  2. Optimistic = 事後に暴く: 楽観的に受理し、challenge 期間内の fraud proof で覆す。安いが遅く、正直な監視者が前提
  3. ZK = 事前に証明: validity proof を commit 時に検証して即確定。証明生成は重いが速く、監視者不要

① L2 の実行と state root

まず L2 側。sequencer は取引を適用して状態を進める。L1 が記録するのは状態そのものではなく、その要約(state root)、すなわち全残高を決定的に畳んだハッシュだ。L1 は短い root だけ持てばよく、残高の全体は持たない:

go

// L2State は L2 のアカウント残高。ここでは口座 → 残高の単純な写像に絞る。
type L2State struct {
	Balances map[string]uint64
}

// NewL2State は初期残高から L2 状態を作る。
func NewL2State(initial map[string]uint64) *L2State {
	b := make(map[string]uint64, len(initial))
	for k, v := range initial {
		b[k] = v
	}
	return &L2State{Balances: b}
}

// clone は状態の深いコピー。バッチ適用を副作用なく試すために使う。
func (s *L2State) clone() *L2State {
	return NewL2State(s.Balances)
}

// Root は状態の要約(state root)。全残高を決定的に畳んだハッシュ。
// L1 が記録するのはこの短い値だけで、残高そのものは持たない——ここがロールアップの肝。
func (s *L2State) Root() string {
	keys := make([]string, 0, len(s.Balances))
	for k := range s.Balances {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	h := sha256.New()
	for _, k := range keys {
		_, _ = fmt.Fprintf(h, "%s=%d;", k, s.Balances[k])
	}
	return hex.EncodeToString(h.Sum(nil))[:16]
}

// Tx は L2 の送金。from が to へ amount を送る。
type Tx struct {
	From   string
	To     string
	Amount uint64
}

// apply は 1 つの取引を状態に反映する。残高不足なら false(この取引はスキップ扱い)。
// 正直な実行はこの規則に従う。不正な sequencer は、これに従わない state root を主張する。
func (s *L2State) apply(tx Tx) bool {
	if s.Balances[tx.From] < tx.Amount {
		return false
	}
	s.Balances[tx.From] -= tx.Amount
	s.Balances[tx.To] += tx.Amount
	return true
}

// Execute は取引列を順に適用した「正直な結果状態」を返す(元の状態は変えない)。
// fraud proof と validity proof は、どちらも「この正直な結果」と主張値を突き合わせる。
func Execute(pre *L2State, txs []Tx) *L2State {
	next := pre.clone()
	for _, tx := range txs {
		next.apply(tx)
	}
	return next
}

Root が鍵だ。L1 はこの 16 文字を記録するだけで「L2 が今どういう状態と主張しているか」を指させる。そして Execute は「正直に実行したらどうなるか」を返す。fraud proof も validity proof も、最終的にはこの正直な結果と sequencer の主張を突き合わせる作業に帰着する。

② バッチと 2 種類の「正しさの担保」

sequencer が L1 に投稿する単位が Batch だ。「PrevRoot から始めて、これらの Txs を実行し、PostRoot になった」という主張。取引データを丸ごと載せるのは、誰でも再実行して検証できるようにするため(data availability):

go

// Batch は sequencer が L1 に投稿する 1 単位。
//
// 中身は「どの状態(PrevRoot)から始めて、これらの取引(Txs)を実行し、
// この状態(PostRoot)になった」という主張だ。L1 はこの主張を再実行せずに記録する。
// PostRoot が本当に正しいかは、Optimistic なら後で fraud proof が、
// ZK なら添えられた Proof が担保する。
//
// Txs を丸ごと L1 に載せるのは、誰でも再実行して検証できるようにするため
// (data availability)。L1 に取引データが無いと、fraud proof を作れない。
type Batch struct {
	Index    int
	PrevRoot string // 開始状態の root(直前バッチの PostRoot と繋がる)
	PostRoot string // 主張する結果状態の root
	Txs      []Tx   // 実行した取引(calldata として L1 に載る)
	Proof    *Proof // ZK モードでのみ添付。Optimistic では nil
}

// Proof は ZK ロールアップの validity proof を模したもの。
//
// 本物は「PostRoot が Txs を PrevRoot に適用した正しい結果である」ことを、
// 状態そのものを明かさずに検証できる暗号学的証明。ここではその暗号は自作せず、
// 「正直な prover だけが正しい PostRoot に対して有効な証明を作れる」という
// 性質だけをモデル化する: Valid は prover が正直に計算した真偽で、
// 嘘の PostRoot に対しては有効な証明を作れない(Valid=false になる)。
type Proof struct {
	Valid bool
}

// Prove は正直な prover として、この主張に対する証明を作る。
// 主張(PostRoot)が実際の実行結果と一致するときだけ Valid=true。
// 不正な sequencer はここで嘘の PostRoot を主張しても、有効な証明を作れない。
func Prove(pre *L2State, txs []Tx, claimedPostRoot string) *Proof {
	trueRoot := Execute(pre, txs).Root()
	return &Proof{Valid: trueRoot == claimedPostRoot}
}

Proof が ZK ロールアップの validity proof を模したものだ。本物は「PostRoot が正しい結果である」ことを状態を明かさずに検証できる暗号学的証明だが、その暗号自体は crypto 編の領域なのでここでは自作しない。代わりに「正直な prover だけが、正しい PostRoot に対して有効な証明を作れる」という性質だけをモデル化する。嘘の PostRoot を主張すると ProveValid=false を返す。これが後で ZK の門番になる。

③ L1 コントラクト: 受理・告発・確定

L1 側が本章の中心だ。Commit はモードで挙動が分かれる。Optimistic は検証せずに Pending で受理(だから安い)。ZK は proof を検証して有効なら即 Final、無効なら拒否。そして Optimistic だけが Challenge(fraud proof)を持つ:

go

// Mode はロールアップの検証方式。
type Mode int

const (
	Optimistic Mode = iota // 楽観的に受理し、challenge 期間内の fraud proof で覆す
	ZK                     // validity proof を commit 時に検証し、即確定する
)

func (m Mode) String() string {
	if m == ZK {
		return "zk"
	}
	return "optimistic"
}

// Status は記録されたバッチの状態。
type Status int

const (
	Pending  Status = iota // Optimistic: challenge 期間中(まだ覆りうる)
	Final                  // 確定(ZK は即、Optimistic は期間経過後)
	Reverted               // fraud proof で覆された
)

func (s Status) String() string {
	switch s {
	case Final:
		return "final"
	case Reverted:
		return "reverted"
	default:
		return "pending"
	}
}

// Record は L1 に記録された 1 バッチ。
type Record struct {
	Batch       Batch
	Status      Status
	CommittedAt int
}

var (
	ErrRootMismatch     = errors.New("rollup: PrevRoot が現在の確定 root と繋がらない")
	ErrInvalidProof     = errors.New("rollup: validity proof が無効(ZK)")
	ErrProofRequired    = errors.New("rollup: ZK モードでは proof が必須")
	ErrBadWitness       = errors.New("rollup: witness が batch の PrevRoot と一致しない")
	ErrNotChallengeable = errors.New("rollup: このバッチは challenge 対象でない(確定済み or 期間切れ)")
	ErrWrongMode        = errors.New("rollup: このモードでは使えない操作")
)

// Rollup は L1 側のロールアップコントラクト。取引を再実行せず、state root の列を記録する。
type Rollup struct {
	mode            Mode
	challengePeriod int // Optimistic の異議申立て可能期間(論理時間)
	clock           int
	genesisRoot     string
	records         []*Record
	sequencerBond   uint64 // sequencer が積んだ保証金。fraud が証明されると没収される
	slashed         uint64
}

// New は初期状態の root を起点にロールアップを作る。
// challengePeriod は Optimistic 用(ZK では使わない)。bond は sequencer の保証金。
func New(mode Mode, genesis *L2State, challengePeriod int, bond uint64) *Rollup {
	return &Rollup{
		mode:            mode,
		challengePeriod: challengePeriod,
		genesisRoot:     genesis.Root(),
		sequencerBond:   bond,
	}
}

// Now は現在の論理時間。
func (r *Rollup) Now() int { return r.clock }

// Tick は論理時間を進める(challenge 期間の経過を表す)。
func (r *Rollup) Tick(d int) { r.clock += d }

// CanonicalRoot は現在の確定 root。覆っていない最後のバッチの PostRoot、無ければ genesis。
func (r *Rollup) CanonicalRoot() string {
	for i := len(r.records) - 1; i >= 0; i-- {
		if r.records[i].Status != Reverted {
			return r.records[i].Batch.PostRoot
		}
	}
	return r.genesisRoot
}

// Records は記録されたバッチ列(表示・検査用)。
func (r *Rollup) Records() []*Record { return r.records }

// Slashed は没収された保証金の累計。
func (r *Rollup) Slashed() uint64 { return r.slashed }

// Commit はバッチを投稿する。モードにより挙動が違う:
//   - Optimistic: 再実行も検証もせず記録する(だから安い)。PostRoot の正しさは
//     challenge 期間中の fraud proof に委ねられる。状態は Pending。
//   - ZK: proof を検証し、有効なら即 Final。無効なら拒否(不正はそもそも入れない)。
func (r *Rollup) Commit(b Batch) error {
	if b.PrevRoot != r.CanonicalRoot() {
		return fmt.Errorf("%w: batch.PrevRoot=%s canonical=%s", ErrRootMismatch, b.PrevRoot, r.CanonicalRoot())
	}
	b.Index = len(r.records)

	if r.mode == ZK {
		if b.Proof == nil {
			return ErrProofRequired
		}
		if !b.Proof.Valid {
			return ErrInvalidProof // 嘘の PostRoot には有効な証明が作れない → ここで弾かれる
		}
		r.records = append(r.records, &Record{Batch: b, Status: Final, CommittedAt: r.clock})
		return nil
	}

	// Optimistic: 検証せず Pending で受理する。
	r.records = append(r.records, &Record{Batch: b, Status: Pending, CommittedAt: r.clock})
	return nil
}

// Challenge は Optimistic モードで、バッチ index の不正を fraud proof で告発する。
//
// 告発者は witness(そのバッチの開始状態そのもの)を提示する。L1 は
//
//	(1) witness が batch.PrevRoot と一致するか
//	(2) witness に Txs を適用した正しい PostRoot が、主張値と一致するか
//
// を確かめる。食い違えば不正が証明され、そのバッチ以降を巻き戻し保証金を没収する。
// 一致すれば(=正直だった)challenge は失敗する。
//
// 返り値: 不正が証明されて巻き戻したなら true。
func (r *Rollup) Challenge(index int, witness *L2State) (bool, error) {
	if r.mode != Optimistic {
		return false, ErrWrongMode
	}
	if index < 0 || index >= len(r.records) {
		return false, fmt.Errorf("rollup: index %d は範囲外", index)
	}
	rec := r.records[index]
	// 確定済み・期間切れ・すでに覆ったものは対象外。
	if rec.Status != Pending || r.clock-rec.CommittedAt >= r.challengePeriod {
		return false, ErrNotChallengeable
	}
	if witness.Root() != rec.Batch.PrevRoot {
		return false, ErrBadWitness
	}

	trueRoot := Execute(witness, rec.Batch.Txs).Root()
	if trueRoot == rec.Batch.PostRoot {
		return false, nil // 正直だった。challenge は不成立
	}

	// 不正確定: このバッチと、それに積み上がった後続を全て巻き戻す。
	for i := index; i < len(r.records); i++ {
		r.records[i].Status = Reverted
	}
	r.slashed += r.sequencerBond // 保証金を没収(不正のコストを sequencer に負わせる)
	return true, nil
}

// Finalize は challenge 期間を過ぎた Pending バッチを Final にする(Optimistic)。
// 一度 Final になると、もう覆せない。
func (r *Rollup) Finalize() {
	if r.mode != Optimistic {
		return
	}
	for _, rec := range r.records {
		if rec.Status == Pending && r.clock-rec.CommittedAt >= r.challengePeriod {
			rec.Status = Final
		}
	}
}

Challenge の中身が fraud proof の本質だ。L1 は状態を持たないので、告発者が **witness(そのバッチの開始状態そのもの)**を提示する。L1 は「witness が PrevRoot と一致するか」「witness に Txs を適用した正しい PostRoot が主張値と一致するか」を確かめ、食い違えば不正確定となる。そのバッチ以降を全部巻き戻し、sequencer の保証金を没収する。この没収(slashing)が、不正のコストを攻撃者に負わせる経済的な抑止になる。

対して ZK の Commit!b.Proof.Valid を commit の瞬間に弾く。不正はそもそも入れない。Optimistic が「入れてしまってから暴く」のと対照的だ。

動かす

下のデモは、この対比をそのままブラウザで動かしている。sequencer が正直なバッチと、残高を水増しした不正バッチを投稿する。右の切り替えで Optimistic / ZK を比べられる。Optimistic では不正が一旦通り、challenge で暴かれて巻き戻る(保証金も没収)。ZK では不正が commit 時に弾かれ、そもそも通らない。どちらが「事後」でどちらが「事前」かが見えるはずだ。

デモrollup(Optimistic vs ZK)step 1
OptimisticZK
L1 に記録されたバッチ
(まだバッチなし)
L1 の状態
canonical rootg0
没収した保証金0

Optimistic: 検証せず受理し、challenge 期間内の fraud proof で覆す。安いが遅く、正直な監視者が前提

初期状態

L1 は genesis root(g0)だけを持つ。実行は L2 に逃がし、L1 は state root の列を記録する

1 / 5

L1 は取引を再実行しない——root の列を記録するだけ。正しさの担保が、 Optimistic では事後の fraud proof(+保証金没収)、ZK では 事前の validity proofに分かれる。「暴くか、証明するか」が Layer2 設計の中心。

設計の観点: なぜ 2 系統あるか

  • なぜ L1 は再実行しないのか: 再実行したら L1 の負荷が減らず、スケールしない。ロールアップの価値は「L1 を監査役に徹させ、実行を 1 か所に集約する」点にある。だから正しさの担保を「再実行以外の方法」で用意する必要が生まれる
  • Optimistic の前提と弱点: 「1 人でも正直な監視者がいて、challenge 期間内に fraud proof を出す」ことに安全性が乗っている。だから確定まで待つ(実際は約 7 日)。監視者がいない・検閲されると危うい。長所は commit が安いこと(検証しないから)
  • ZK の前提と弱点: 暗号的に正しさが保証されるので監視者不要・即ファイナリティ。弱点は証明生成が計算的に重く、対応できる計算に制約があること(汎用 EVM の ZK 化は難所だった)
  • data availability が要: 取引データが L1 に載っていないと、fraud proof も再構築もできない。「root だけ載せてデータは別」にすると、データを隠されて出金不能になりうる(data withholding)。EIP-4844 の blob はこの DA コストを下げる仕組み
  • なぜ保証金を没収するか: 不正が「バレても損しない」なら、何度でも試せる。bond の slashing で「不正の期待値をマイナス」にして抑止する。fraud proof は「暴く仕組み」、slashing は「割に合わなくする仕組み」

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

方式確定の速さcommit コスト前提実例
Optimistic rollup遅い(challenge 期間)安い(検証しない)正直な監視者が 1 人以上Arbitrum、Optimism、Base
ZK rollup速い(即)高い(証明生成)証明系の健全性zkSync、StarkNet、Polygon zkEVM、Scroll
Validium速い高いDA を L1 外に置く(信頼要)StarkEx(一部)

裏どり:

  • Arbitrum / Optimism / Base: optimistic rollup。fraud proof と challenge 期間(約 7 日)を持ち、出金にその待ち時間がかかる。Arbitrum の対話的 fraud proof は「争点を二分探索で 1 命令まで絞る」洗練版
  • zkSync / StarkNet / Polygon zkEVM / Scroll: zk rollup。validity proof で即ファイナリティ。汎用 EVM の ZK 化(zkEVM)は長く難所とされ、各社が実装を競った
  • EIP-4844(proto-danksharding): ロールアップのボトルネックだった DA コストを、専用の blob 領域で大幅に下げた(2024)。「data availability が要」を地で行く改善
  • The DAO 以降の設計思想: 「信頼せず検証する」を、実行そのものでなく root + 証明/告発で成立させたのがロールアップ。L1 の安全性を借りつつスケールする

簡略化したこと

  • ZK 暗号は模型: SNARK/STARK は自作せず「正しい主張にだけ有効な証明が付く」性質のみ再現
  • 対話的 fraud proof の二分探索なし: 実際の optimistic は 1 手ずつ争うが、ここはバッチ全体を一度に再実行する簡略版
  • 単一 sequencer: 分散 sequencer・強制 include・検閲耐性は扱わない
  • 状態は残高のみ / DA は前提: 一般的なコントラクト状態は evm 編に、DA レイヤの中身は扱わない

参考資料