Skip to content

Substrate(ランタイム・pallet 合成・forkless upgrade)

多くのチェーンは規則をノードのソフトに焼き込むので、変更には全ノードの更新とハードフォークが要る。Substrate はランタイム(規則)をチェーン上の差し替え可能なコードとして持ち、変更を取引 1 本で行う(forkless upgrade)。ランタイムは pallet の合成でできた状態遷移関数で、1 ブロックの仕事量は weight で予算化する。

この章で作るもの

これまで作ってきたチェーンでは、取引を処理する規則はコードに焼き込まれていた。UTXO 編の送金規則も、EVM 編の gas 価格も、変えるには全ノードがソフトを更新して足並みを揃える必要がある。これがハードフォークで、調整も合意も重い。

Substrate の発想は、その規則(ランタイム)自体をチェーンの状態として持つことだ。ノードは焼き込まれたロジックではなく、チェーン上のランタイムを読んで実行する。だから規則を変えるのは「ランタイムを差し替える取引」を 1 本流すだけで済む。ノードの更新もフォークも要らない。これが forkless upgrade で、この章の主眼になる。あわせて、ランタイムを機能モジュール(pallet)の合成で組む枠組み(FRAME)と、資源を計る weight を作る。

   extrinsic ──▶ ┌──────── Runtime(状態遷移関数)────────┐
   (取引)         │  pallet を合成: system / balances /… │
                  │     │ dispatch(pallet.method)        │
                  │     ▼                                │
                  │  共有ストレージ(状態トライ)          │
                  └───────────────┬─────────────────────┘
                                  │ system.set_code(新コード)
                  ┌───────────────▼─────────────────────┐
                  │  ロジックだけ差し替え(spec v1→v2)    │
                  │  ストレージ(残高など)はそのまま      │
                  └──────────────────────────────────────┘
ランタイムは状態遷移関数。extrinsic を受けて pallet に dispatch し、共有ストレージ(状態)を進める。ランタイム(ロジック)はチェーン上の差し替え可能なコードで、set_code で入れ替えても状態はそのまま引き継がれる

順に見ていく。

  1. pallet 合成(FRAME): ランタイムは system・balances などの pallet の合成。各 pallet は dispatchable な呼び出しと weight を持つ
  2. ランタイム = 状態遷移関数: extrinsic を pallet に dispatch して共有ストレージを進める。1 ブロックの仕事量は weight で予算化する
  3. forkless upgrade: ランタイムをチェーン上の差し替え可能なコードとして持ち、set_code 取引 1 本で入れ替える。状態は引き継がれ、フォークは要らない

① pallet: ランタイムを機能モジュールに分ける

まず部品を定める。Call は extrinsic の中身(どの pallet のどの呼び出しを、どんな引数で)。Pallet は dispatchable の集合とそれぞれの weight を持つモジュールで、RuntimeCode は spec_version と pallet 集合、つまり「チェーンに載るランタイムそのもの」だ:

go

// Call は extrinsic の中身(どの pallet のどの dispatchable を、どんな引数で呼ぶか)。
// 教育用に引数は固定フィールドで持つ(本物は SCALE エンコードされた任意のバイト列)。
type Call struct {
	Pallet string
	Method string
	From   string
	To     string
	Amount uint64
	Code   *RuntimeCode // system.set_code 用の新ランタイム
}

// Extrinsic はブロックに含まれる 1 件の取引(署名は簡略化して省く)。
type Extrinsic struct {
	Call Call
}

// Event は dispatch が発火する記録。
type Event struct {
	Pallet  string
	Message string
}

// Dispatch は 1 つの dispatchable の本体。共有ストレージ(rt)と呼び出しを受けて状態を進める。
type Dispatch func(rt *Runtime, c Call) ([]Event, error)

// Pallet は機能モジュール。dispatchable の集合と、それぞれの weight を持つ。
// ランタイムはこの Pallet を複数合成して作る(FRAME の考え方)。
type Pallet struct {
	Name    string
	methods map[string]Dispatch
	weights map[string]uint64
}

// RuntimeCode は「チェーンに載るランタイムそのもの」。spec_version と pallet 集合からなる。
// これを差し替えるのが forkless upgrade——ノードもフォークも要らず、ロジックだけ入れ替わる。
type RuntimeCode struct {
	SpecVersion uint32
	Pallets     []Pallet
}

RuntimeCode を値として持てることが、あとで forkless upgrade を可能にする。ランタイムは焼き込まれた固定物ではなく、差し替えられるデータになる。

具体的な pallet が balances だ。dispatch は check-then-write で書く。先に検査し、通ってから状態を触るので、失敗しても状態は汚れない:

go

// balancesPallet は残高を扱う pallet。transfer は from → to へ amount を移す。
// dispatch は check-then-write で書く——先に検査し、通ってから状態を触るので、
// 失敗しても状態は汚れない(weight だけは消費される)。
func balancesPallet() Pallet {
	return Pallet{
		Name:    "balances",
		weights: map[string]uint64{"transfer": 100},
		methods: map[string]Dispatch{
			"transfer": func(rt *Runtime, c Call) ([]Event, error) {
				if rt.Get(balKey(c.From)) < c.Amount {
					return nil, fmt.Errorf("%w: %s", ErrInsufficient, c.From)
				}
				rt.Set(balKey(c.From), rt.Get(balKey(c.From))-c.Amount)
				rt.Set(balKey(c.To), rt.Get(balKey(c.To))+c.Amount)
				return []Event{{Pallet: "balances", Message: fmt.Sprintf("transfer %d: %s%s", c.Amount, c.From, c.To)}}, nil
			},
		},
	}
}

// stakingPallet は v2 で追加される新機能。bond は残高を staking へ移す。
// v1 には存在しないので、v1 で staking.bond を呼ぶと ErrUnknownPallet になる。
// forkless upgrade 後に初めて使えるようになる——「フォークせず機能を足す」を体現する。
func stakingPallet() Pallet {
	return Pallet{
		Name:    "staking",
		weights: map[string]uint64{"bond": 150},
		methods: map[string]Dispatch{
			"bond": func(rt *Runtime, c Call) ([]Event, error) {
				if rt.Get(balKey(c.From)) < c.Amount {
					return nil, fmt.Errorf("%w: %s", ErrInsufficient, c.From)
				}
				rt.Set(balKey(c.From), rt.Get(balKey(c.From))-c.Amount)
				rt.Set(stakeKey(c.From), rt.Get(stakeKey(c.From))+c.Amount)
				return []Event{{Pallet: "staking", Message: fmt.Sprintf("bond %d: %s", c.Amount, c.From)}}, nil
			},
		},
	}
}

staking pallet は v1 には無く、あとで足す新機能として用意してある。ここが forkless upgrade の見せ場になる。

② ランタイム: 状態遷移関数と weight

ランタイムは状態遷移関数そのものだ。共有ストレージ(状態トライ)と pallet 集合を持ち、Execute が extrinsic を pallet に振り分ける。振り分ける前に weight の予算を確認し、超えるなら実行しない:

go

// Runtime は状態遷移関数そのもの。共有ストレージ(状態トライ)と、それを操作する pallet の
// 集合(=ランタイムコード)を持つ。ブロックの実行とは、extrinsic をこの Runtime に順に
// 適用してストレージを進めることに等しい。
type Runtime struct {
	specVersion uint32
	storage     map[string]uint64 // 状態トライの最小版(pallet:key → 値)
	pallets     map[string]Pallet // 差し替え可能なロジック = ランタイムコード
	weightLimit uint64            // 1 ブロックが使える weight の上限
	used        uint64            // 現ブロックで消費した weight
	events      []Event
}

// NewRuntime は初期ランタイムコードと weight 上限、初期状態(genesis)から Runtime を作る。
func NewRuntime(code RuntimeCode, weightLimit uint64, genesis map[string]uint64) *Runtime {
	rt := &Runtime{
		storage:     map[string]uint64{},
		weightLimit: weightLimit,
	}
	for k, v := range genesis {
		rt.storage[k] = v
	}
	rt.applyCode(code)
	return rt
}

// SpecVersion は現在のランタイムのバージョン。forkless upgrade で単調に上がる。
func (rt *Runtime) SpecVersion() uint32 { return rt.specVersion }

// Get / Set はストレージ(状態トライ)への最小アクセス。pallet はこれ越しに状態を触る。
func (rt *Runtime) Get(key string) uint64 { return rt.storage[key] }
func (rt *Runtime) Set(key string, v uint64) {
	if v == 0 {
		delete(rt.storage, key)
		return
	}
	rt.storage[key] = v
}

// Events は現ブロックで発火したイベント。
func (rt *Runtime) Events() []Event { return rt.events }

// Weight は現ブロックの消費 weight と上限。
func (rt *Runtime) Weight() (used, limit uint64) { return rt.used, rt.weightLimit }

// applyCode はランタイムコード(pallet 集合)を差し替え、spec_version を更新する。
// ストレージ(状態)には触れない——ここが forkless upgrade の肝。ロジックだけ入れ替わり、
// 残高などの状態はそのまま引き継がれる。
func (rt *Runtime) applyCode(code RuntimeCode) {
	rt.specVersion = code.SpecVersion
	rt.pallets = make(map[string]Pallet, len(code.Pallets))
	for _, p := range code.Pallets {
		rt.pallets[p.Name] = p
	}
}

// Execute は 1 つの extrinsic を dispatch する。pallet → method を引き、weight 予算を
// 確認してから実行する。weight は「試みた分」として実行前に確保する(失敗しても消費される)。
func (rt *Runtime) Execute(ex Extrinsic) ([]Event, uint64, error) {
	p, ok := rt.pallets[ex.Call.Pallet]
	if !ok {
		return nil, 0, fmt.Errorf("%w: %s", ErrUnknownPallet, ex.Call.Pallet)
	}
	d, ok := p.methods[ex.Call.Method]
	if !ok {
		return nil, 0, fmt.Errorf("%w: %s.%s", ErrUnknownMethod, ex.Call.Pallet, ex.Call.Method)
	}
	w := p.weights[ex.Call.Method]
	if rt.used+w > rt.weightLimit {
		return nil, 0, ErrWeightExceeded // ブロックに入らない(実行しない)
	}
	rt.used += w // 試みた時点で weight を消費(失敗しても返らない)
	evs, err := d(rt, ex.Call)
	if err != nil {
		return nil, w, err // 失敗: weight は消費済み、状態は各 dispatch が check-then-write で汚さない
	}
	rt.events = append(rt.events, evs...)
	return evs, w, nil
}

weight が gas を一般化したものだ。EVM 編の gas が主に計算量に価格を付けたのに対し、weight は計算・ストレージ・呼び出しの重さをまとめて「1 ブロックがこなせる仕事量」として予算化する。Execute は実行前に weight を確保するので、失敗した extrinsic も weight を消費する。試みた分の対価を必ず取る、という点は gas と同じだ。

applyCode に注目してほしい。pallet 集合を差し替えても、ストレージには触れない。ロジックだけが入れ替わり、残高などの状態はそのまま残る。次の forkless upgrade は、この性質の上に成り立つ。

③ forkless upgrade: 取引 1 本でランタイムを変える

ランタイムの差し替えは、特別な儀式ではない。system pallet の set_code という、ただの dispatchable だ。新しい RuntimeCode を受け取り、spec_version が今より新しいことだけを確かめて差し替える:

go

// systemPallet はランタイム自身を管理する pallet。set_code が forkless upgrade の入口。
// 新ランタイムコードを受け取り、spec_version が今より新しいことだけを確かめて差し替える。
// これ自体がただの dispatchable なので、アップグレードは「取引 1 本」で起きる。
func systemPallet() Pallet {
	return Pallet{
		Name:    "system",
		weights: map[string]uint64{"set_code": 200},
		methods: map[string]Dispatch{
			"set_code": func(rt *Runtime, c Call) ([]Event, error) {
				if c.Code == nil {
					return nil, ErrNoCode
				}
				if c.Code.SpecVersion <= rt.specVersion {
					return nil, fmt.Errorf("%w: %d <= %d", ErrStaleUpgrade, c.Code.SpecVersion, rt.specVersion)
				}
				old := rt.specVersion
				rt.applyCode(*c.Code) // pallet 集合を差し替え。ストレージ(状態)は保たれる
				return []Event{{Pallet: "system", Message: fmt.Sprintf("runtime upgraded: spec %d%d", old, rt.specVersion)}}, nil
			},
		},
	}
}

// RuntimeV1 は初期ランタイム。system と balances だけを持つ。
func RuntimeV1() RuntimeCode {
	return RuntimeCode{SpecVersion: 1, Pallets: []Pallet{systemPallet(), balancesPallet()}}
}

// RuntimeV2 は staking を足したアップグレード版。set_code でこれを載せると、
// 既存の残高はそのままに、staking.bond が新たに使えるようになる。
func RuntimeV2() RuntimeCode {
	return RuntimeCode{SpecVersion: 2, Pallets: []Pallet{systemPallet(), balancesPallet(), stakingPallet()}}
}

set_code が普通の呼び出しであることが要になる。ランタイムの更新が、送金と同じ「取引 1 本」として起きる。全ノードがソフトを更新して足並みを揃える必要はない。ノードはチェーン上のランタイムを読んで実行するので、差し替えられた瞬間から新しい規則で動く。v1 には無かった staking.bond が、v2 に上げた直後から使えるようになる。そのとき残高は applyCode によって保たれたままだ。

spec_version の単調増加チェックが、古いランタイムへの巻き戻しや同一版の無意味な差し替えを弾く。実際の Substrate では、この version の一致でノードが「ネイティブ実行できるか、チェーン上の Wasm を解釈実行するか」を切り替える。

動かす

下のデモは、この筋書きをそのままブラウザで動かしている。左の切り替えで 2 つの場面を比べられる。「extrinsic + weight」では、transfer を dispatch するたびに weight メータが埋まり、上限を超える 3 件目がブロックに入らず弾かれる。「forkless upgrade」では、v1 で staking.bond が弾かれ、set_code で v2 に上げた直後に同じ呼び出しが通る。残高がアップグレードをまたいで保たれることも見えるはずだ。

デモsubstrate(ランタイムと forkless upgrade)step 1
extrinsic + weightforkless upgrade
ランタイム spec v1
systembalances
block weight0 / 250
ストレージ(状態トライ)
balances:alice1000
balances:bob0
初期状態(genesis)

ランタイムは system と balances の 2 pallet の合成(FRAME)。ブロックが使える weight は 250 まで。extrinsic を順に dispatch して状態を進める

1 / 4

ランタイムは pallet の合成でできた状態遷移関数で、extrinsic を dispatch して状態を進める。 1 ブロックの仕事量は weight で予算化する。ランタイム自体をチェーン上の差し替え可能なコードとして持つので、 機能追加や規則変更は取引 1 本で済み、ノードの更新もハードフォークも要らない。

設計の観点: なぜ forkless か

  • ハードフォークの何が重いか: 規則をノードのソフトに焼き込むと、変更には全運用者の協調更新が要る。タイミングがずれるとチェーンが分岐(フォーク)する。Substrate はランタイムをチェーン上に置くことで、この協調を「オンチェーンの取引」に畳み込み、分岐リスクを消す
  • なぜ Wasm か: 実際のランタイムは Wasm バイナリとしてチェーンに載る(wasm 編で作った、移植可能で検証できるバイトコード)。どのノードも同じ Wasm を同じ結果で実行できるので、決定性が保たれる。本章は Wasm 実行までは踏み込まず、「ロジックが差し替え可能なデータである」ことに集中した
  • weight と gas の違い: gas は主に計算量への課金、weight は計算 + ストレージ + 呼び出しを含む「ブロックの処理予算」。事前に各 dispatchable の weight を見積もり、ブロックが上限を超えないように詰める。手数料は weight から算出される
  • state migration の必要: ストレージの形(スキーマ)が変わるアップグレードでは、古い状態を新しい形に移す migration が要る。本章は残高の形を変えないので不要だが、実際は set_code と同時に migration を走らせる。「状態は保たれる」は「形が同じなら」という条件付き
  • ガバナンス: set_code を誰が呼べるかは重大だ。実際は sudo(単一の管理者)や on-chain 投票(referenda)で権限を絞る。ランタイムを自由に差し替えられる力は、そのまま統治の力になる

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

論点仕組み効果実例
フォークレス更新ランタイムをオンチェーンの Wasm に協調更新なしで規則を変えるPolkadot/Kusama の runtime upgrade
モジュール合成pallet の組み合わせで構築機能の付け外しが宣言的FRAME の pallet 群
資源計量weight でブロック予算化処理量の上限と手数料を決めるweights + transaction payment
更新権限set_code をガバナンスで制御誰が規則を変えるかを統治するsudo / 投票(referenda)

裏どり:

  • Polkadot / Kusama: Substrate 製。ランタイムを何度も forkless upgrade してきた。Kusama は「先行して壊す」実験場として、活発にアップグレードを試す
  • FRAME と pallet: pallet-balancespallet-stakingpallet-democracy などの pallet を合成してランタイムを組む。本章の system/balances/staking はその最小の写し
  • Wasm ランタイム: ランタイムは Wasm にコンパイルされチェーンに載る。ノードは spec_version を見て、ネイティブに持っていればネイティブ実行、無ければ Wasm を解釈実行する。wasm 編の「移植可能・検証可能」がここで効く
  • on-chain ガバナンス: Polkadot の OpenGov では、ランタイム更新を含む提案をトークン保有者の投票で決める。「規則を変える力」を分散した統治に載せている

簡略化したこと

  • Wasm 実行なし: 実ランタイムは Wasm バイナリだが、ここでは pallet を Go の値として持ち、差し替えの意味論だけを取り出す。Wasm 実行は wasm 編を参照
  • SCALE エンコードなし: extrinsic の引数は固定フィールド。本物は SCALE コーデックで任意の型をバイト列にする
  • 署名・手数料・nonce なし: extrinsic の署名検証、weight からの手数料算出、リプレイ防止の nonce は扱わない
  • state migration なし: ストレージの形を変えるアップグレードと、その移行処理は扱わない(設計の観点の節で言及)
  • ガバナンス・合意なし: set_code の権限制御(sudo / 投票)とブロック生成の合意(BABE/GRANDPA)は範囲外

参考資料