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 時に拒否 │
└──────────────────────────────┘順に見ていく。
- L1 は再実行しない: 実行は L2、L1 は state root の列を記録するだけ。取引データも L1 に載せる(data availability)。載せないと誰も検証できない
- Optimistic = 事後に暴く: 楽観的に受理し、challenge 期間内の fraud proof で覆す。安いが遅く、正直な監視者が前提
- ZK = 事前に証明: validity proof を commit 時に検証して即確定。証明生成は重いが速く、監視者不要
① L2 の実行と state root
まず L2 側。sequencer は取引を適用して状態を進める。L1 が記録するのは状態そのものではなく、その要約(state root)、すなわち全残高を決定的に畳んだハッシュだ。L1 は短い root だけ持てばよく、残高の全体は持たない:
// 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):
// 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 を主張すると Prove は Valid=false を返す。これが後で ZK の門番になる。
③ L1 コントラクト: 受理・告発・確定
L1 側が本章の中心だ。Commit はモードで挙動が分かれる。Optimistic は検証せずに Pending で受理(だから安い)。ZK は proof を検証して有効なら即 Final、無効なら拒否。そして Optimistic だけが Challenge(fraud proof)を持つ:
// 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 時に弾かれ、そもそも通らない。どちらが「事後」でどちらが「事前」かが見えるはずだ。
Optimistic: 検証せず受理し、challenge 期間内の fraud proof で覆す。安いが遅く、正直な監視者が前提
L1 は genesis root(g0)だけを持つ。実行は L2 に逃がし、L1 は state root の列を記録する
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 レイヤの中身は扱わない
参考資料
- Vitalik Buterin, "An Incomplete Guide to Rollups" — optimistic と zk の対比の定番
- Ethereum.org, "Layer 2" と "Rollups" — 概観
- evm 編(L2 が実行する中身)・blockchain 編(L1 の鎖)と合わせて読むとレイヤの分担が見える
- 実装: chain/rollup