レッスン5 — Malachite を読む(Informal Systems の Rust-native BFT)
問い
Reth ベース L1 が Tendermint 系 BFT を必要とするとき、選択肢は 3 つ。① 自前で書く(数か月 + セキュリティリスク) ② CometBFT(Go)にクロスプロセスで委譲(醜い糊コード) ③ informalsystems/malachite を使う(CometBFT を作ったのと同じチームによる Tendermint の Rust 書き直し)。選択肢 3 のアーキテクチャはどうなっているか?
原理(最小モデル)
- 3 つの差し替え可能なレイヤ. Driver(オーケストレータ)+ Vote Keeper(quorum ロジック)+ Round State Machine(Tendermint ルール)。
- 3 ラウンドの投票. Propose → Prevote → Precommit。2 ラウンド目(Precommit)が「2f+1 が同じブロックに合意」を保証 — 1 ラウンドだけだと Byzantine リーダーが split-vote を仕掛けて commit を混乱させられる。
- Application は Context trait で接続. 自分のブロック型 / validator set / 署名方式を提供すれば、Malachite Driver がプロトコルすべてを処理する。
- 本番事例 = Astria. Reth ベース共有 sequencer + CometBFT、Malachite に置き換え可能。
具体例
3 レイヤのアーキテクチャ:
flowchart TB
App["Application<br/>(自分の chain)"] -->|propose/validate| Driver["Driver<br/>(オーケストレータ)"]
Driver -->|Vote keeper| VK["Vote Keeper<br/>(quorum ロジック)"]
Driver -->|Round state machine| RSM["Round State Machine<br/>(Tendermint ルール)"]
VK -->|2f+1 達成?| Driver
RSM -->|次ステップ| Driver
Round State Machine:
pub enum Step {
NewRound,
Propose,
Prevote,
Precommit,
Commit,
}
各ラウンドの遷移:
- NewRound → ラウンドに入り、自分が proposer かを判定
- Propose → proposer ならブロックをブロードキャスト、違うなら待つ
- Prevote → 提案ブロックに「yes」または「nil」を投票。同じブロックへの 2f+1 prevote = polka
- Precommit → polka を見たら precommit をブロードキャスト。2f+1 precommit で commit
- Commit → 確定、次の height へ
Vote Keeper:
pub struct VoteKeeper<Ctx: Context> {
height: Ctx::Height,
threshold: ThresholdParam,
rounds: BTreeMap<Round, RoundVotes<Ctx>>,
}
impl<Ctx: Context> VoteKeeper<Ctx> {
pub fn add_vote(&mut self, vote: Ctx::Vote, weight: Weight) -> VoteKeeperOutput<Ctx::Value>;
pub fn get_polka(&self, round: Round) -> Option<Ctx::Value>;
pub fn get_commit(&self, round: Round) -> Option<Ctx::Value>;
}
操作は 3 つ — 票の追加、polka 確認、commit 確認。それだけ。Tendermint の quorum ロジックが 1 つの struct に収まる。
Driver の I/O:
pub enum Input<Ctx: Context> {
NewHeight(Ctx::Height, ValidatorSet),
Propose(Ctx::Proposal),
Vote(Ctx::Vote),
TimeoutElapsed(Timeout),
}
pub enum Output<Ctx: Context> {
Propose(Ctx::Value),
Vote(Ctx::Vote),
Decide(Ctx::Height, Round, Ctx::Value),
ScheduleTimeout(Timeout),
}
Context trait(application 側が実装):
pub trait Context {
type Address;
type Height;
type Vote: Vote<Self>;
type Proposal;
type Value; // = 自分のブロック型
type ValidatorSet;
type SigningScheme;
// ...
}
Tempo クラスの統合例:
以下は概念スニペット(associated type と配線の形を示すための抜粋)。
struct TempoContext;
impl Context for TempoContext {
type Address = ValidatorAddress;
type Height = BlockNumber;
type Vote = TempoVote;
type Proposal = TempoBlock; // Reth 互換 Block
type Value = BlockHash;
type ValidatorSet = TempoValidatorSet;
type SigningScheme = Ed25519;
}
// メインループ
let mut driver = Driver::<TempoContext>::new(/* params */);
loop {
let input = network.next_message().await;
let outputs = driver.process(input);
for output in outputs {
handle(output).await;
}
}
失敗例(誤解)
「投票が 1 ラウンドで十分」— 間違い。1 ラウンドだけだと Byzantine リーダーが split-vote を仕掛けられる(異なるバリデータに異なるブロックを送り、各バリデータが違うブロックに投票し、後の commit が混乱)。2 ラウンド目(Precommit)が「2f+1 が同じブロックに合意した」ことを確認 — これが safety を保証する。
「Malachite は CometBFT より速い」— 比較できない。同じプロトコル(Tendermint)の Rust 実装で、Astria のような Rust chain に組み込みやすいのが利点。性能比較ではなく 言語適合性 の選択。
🛑 予測。 Tendermint はブロックあたり 3 つの投票ラウンド(Propose / Prevote / Precommit)を持つ。各ラウンドで、バリデータは何を決定するのか? 各ラウンドの入力と出力を列挙。(答え: ① Propose: 入力 = リーダーの提案、出力 = 「これに賛成 / 反対」の準備、② Prevote: 入力 = 全バリデータの prevote、出力 = polka(同じブロックへの 2f+1 prevote)の検知、③ Precommit: 入力 = polka 観測 + 全バリデータの precommit、出力 = commit 決定(2f+1 precommit)。)
ステップで組み立てる
Step 1: 3 レイヤを言える
- Driver = オーケストレータ(メッセージ受け取り → ディスパッチ → output 発火)
- Vote Keeper = quorum 集計(add_vote / get_polka / get_commit)
- Round State Machine = Tendermint プロトコル(5 状態の遷移)
Step 2: 投票 2 ラウンドの必要性を 1 文で
「2 ラウンド目(Precommit)は 2f+1 が同じブロックに合意した ことを確認するため。1 ラウンドだけだと Byzantine リーダーの split-vote を許す。」
Step 3: Application 側の Context 実装
自分の chain で実装するのは Context trait の associated types のみ:
- ブロック型(Reth の
Block) - Validator set(カスタム struct)
- 署名方式(ECDSA / BLS / Ed25519)
あとは Driver がすべて処理する。
Step 4: リポで確認
code/crates/vote/src/lib.rs を開いて add_vote を追う。何が何で加重されているか? 2f+1 の閾値はどこから来ているか?
答え合わせ
- Astria の現状: Reth ベース共有 sequencer + CometBFT(Go 版 Tendermint)で本番運用中。Malachite に置き換え可能 = 同プロトコルの Rust 実装。
- Malachite が解放してくれるもの: Driver / Vote Keeper / RSM のすべて。Tendermint プロトコルを自分で書かずに済む。Application は primitives(block 型、validator set、署名)だけ提供すればよい。
- 本番までの距離: Astria を見ながら Reth ↔ Malachite の Engine API 配線をなぞれば、Tempo クラスの L1 chain が組み立てられる。
合格基準
- 3 レイヤ(Driver / Vote Keeper / RSM)の役割を即答できる。
- 5 状態(NewRound → Propose → Prevote → Precommit → Commit)の遷移を辿れる。
- Context trait に最低限必要な 5 つの associated type を言える。
- polka と commit の違いを 1 文で言える(polka = 2f+1 prevote、commit = 2f+1 precommit)。
まとめ(3行)
- Malachite = Tendermint の Rust 書き直し(CometBFT の元チームによる)、Reth ベース chain への組み込みが最もクリーンな選択肢。
- 3 レイヤ(Driver / Vote Keeper / RSM)+ Context trait で application が接続。Driver がプロトコルすべてを処理する。
- 3 投票ラウンド(Propose / Prevote / Precommit)の 2 ラウンド目が「2f+1 が同じブロックに合意」を保証 — split-vote 攻撃を潰す。