レッスン8 — 最小の単一リーダー BFT を Rust で作る
問い
OP Stack のドキュメントを開く。Arbitrum のドキュメントを開く。Hyperliquid の launch 時のブログを開く。どれも「sequencer を時間をかけて分散化する」のバリエーションを言っている。翻訳すれば: launch 時には、毎ブロックを生成する 1 台のマシンがあり、特定の鍵による署名だけが唯一のコンセンサス。それだけ。これは Reth の上に Rust 約 100 行で出荷できる。 その約 100 行は何か?
原理(最小モデル)
- 単一 sequencer = launch 時のコンセンサス. 全ブロックの proposer 役を独占 + 署名 + 緊急停止権限。Liveness は単一障害点だが view change 不要。速度 = 1 RTT。
- 3 つの仕事. 構築(tx 順序 + timestamp)+ 署名(ECDSA で authority 証明)+ ブロードキャスト(Engine API → Reth EL + P2P)。
- 検証は「sequencer 署名か?」の 3 行. ECDSA 復元 → expected と比較 → 違えば拒否。あとは標準 Ethereum 検証。
- 段階的分散化が標準パターン. 単一 → 2-of-3 multisig → リーダーローテーション → 本物の BFT(Tendermint / HotStuff)。
具体例
CentralizedSequencer 実装(~100 行):
以下は概念スニペット(流れを示すための抜粋。実行時は不足型・実装を補う)。
use alloy_primitives::{Address, B256};
use alloy_signer::Signer;
use alloy_signer_local::PrivateKeySigner;
use reth_engine_primitives::ForkchoiceState;
use reth_rpc_engine_api::EngineApiClient;
pub struct CentralizedSequencer {
signer: PrivateKeySigner,
sequencer_address: Address,
engine_api: EngineApiClient,
block_period: Duration, // e.g., 2 秒
}
impl CentralizedSequencer {
pub async fn run(&self) -> eyre::Result<()> {
let mut ticker = tokio::time::interval(self.block_period);
loop {
ticker.tick().await;
self.produce_one_block().await?;
}
}
async fn produce_one_block(&self) -> eyre::Result<()> {
// 1. 現 head 上に payload を構築するよう Reth に依頼
let payload_attrs = PayloadAttributes {
timestamp: now_seconds(),
prev_randao: B256::random(),
suggested_fee_recipient: self.sequencer_address,
// ...
};
let forkchoice_state = self.current_forkchoice().await?;
let response = self.engine_api
.fork_choice_updated_v4(forkchoice_state, Some(payload_attrs))
.await?;
let payload_id = response.payload_id.expect("must have payload id");
// 2. Reth が payload を構築する時間を少し待つ
tokio::time::sleep(Duration::from_millis(500)).await;
// 3. 構築済みの payload を取得
let payload = self.engine_api.get_payload_v4(payload_id).await?;
// 4. Payload hash に署名する (authority の証明)
let payload_hash = payload.execution_payload.block_hash();
let signature = self.signer.sign_hash(&payload_hash).await?;
// 5. 署名済 payload を Reth に投入 (peer にもブロードキャスト)
let signed_block = SignedPayload {
payload: payload.execution_payload,
sequencer_signature: signature,
};
self.engine_api
.new_payload_v4(signed_block.payload.clone())
.await?;
// 6. Finalize マーク (単一 sequencer = 即時 finality)
let new_head = signed_block.payload.block_hash();
let new_forkchoice = ForkchoiceState {
head_block_hash: new_head,
safe_block_hash: new_head,
finalized_block_hash: new_head,
};
self.engine_api
.fork_choice_updated_v4(new_forkchoice, None)
.await?;
// 7. peer にブロードキャスト
self.broadcast(signed_block).await?;
Ok(())
}
}
検証側(カスタム Consensus 実装、3 行のみ):
impl<B: Block> Consensus<B> for CentralizedConsensus {
fn validate_block_pre_execution(
&self,
block: &SealedBlock<B>,
) -> Result<(), ConsensusError> {
// 唯一の「コンセンサス」チェック: sequencer の署名か?
let signature = block.sequencer_signature()?;
let signer = signature.recover_address(&block.hash())?;
if signer != self.expected_sequencer {
return Err(ConsensusError::InvalidSequencer);
}
// 標準的な Ethereum 系のチェック
self.validate_basic_block(block)?;
Ok(())
}
}
2-of-3 multisig への進化:
pub struct MultisigSequencer {
signers: Vec<PrivateKeySigner>,
threshold: usize, // = 2
engine_api: EngineApiClient,
}
impl MultisigSequencer {
async fn produce_block(&self) -> eyre::Result<SignedPayload> {
let payload = self.build_payload().await?;
let mut signatures = Vec::new();
for signer in &self.signers {
if let Some(sig) = self.try_sign(signer, &payload).await {
signatures.push(sig);
if signatures.len() >= self.threshold {
break;
}
}
}
if signatures.len() < self.threshold {
return Err(eyre!("not enough signers available"));
}
Ok(SignedPayload {
payload: payload.execution_payload,
sequencer_signatures: signatures,
})
}
}
L1 launch のシーケンス:
| ステージ | コンセンサス | 分散化の度合い | TVL safety |
|---|---|---|---|
| Day 0 | 単一 sequencer | なし | チームを信頼 |
| Month 3 | 2-of-3 multisig | 3 オペレータ | 2-of-3 セットを信頼 |
| Month 12 | ローテーション proposer | ~10 バリデータ | 1 つでも生きていれば liveness |
| Year 2 | 本物の BFT(Tendermint/HotStuff) | 30 以上のバリデータ | 2/3+ Byzantine 耐性 |
失敗例(誤解)
「これはコンセンサスではない、ただの信頼権威」— 部分的に正しい。これも コンセンサス ではある — 1 つの決定(次のブロック)への合意。意思決定者が 1 人 いるだけ。トレードオフは 信頼仮定(sequencer 1 人が正直)を liveness(view change 不要)と引き換えに。launch 時点なら許容できる仮定。
「最初から本物 BFT を入れるべき」— 間違い。Slashing ロジックのバグは壊滅的 + 検証に時間がかかる。よくあるパターン = 有効化するが上限を低く設定して launch、信頼が積み上がるにつれて上限を上げる。
🛑 予測。 Hyperliquid、Tempo、すべての OP Stack chain、Arbitrum — launch 時にすべてが走らせているコンセンサスは何か? HotStuff ではない。Tendermint でもない。(ヒント: そのどちらよりも単純。)(答え: 単一 sequencer。1 つの鍵が全ブロックに署名、view change なし、quorum なし、validator set なし。100 行の Rust で実装可能。)
ステップで組み立てる
Step 1: 3 つの仕事を言える
構築 + 署名 + ブロードキャスト。
Step 2: produce_one_block の 7 ステップ
- forkchoice_updated で payload 構築リクエスト
- 500ms 待つ
- get_payload で取得
- payload_hash に署名
- new_payload で Reth に投入
- forkchoice_updated で finalize マーク
- P2P ブロードキャスト
Step 3: 検証の 3 行を覚える
let signer = signature.recover_address(&block.hash())?;
if signer != self.expected_sequencer { return Err(...); }
self.validate_basic_block(block)?;
Step 4: 段階的分散化を辿れる
Day 0(単一)→ Month 3(2-of-3)→ Month 12(ローテーション + slot 適格性)→ Year 2(本物の BFT、Malachite で)。
Step 5: スケッチ
実装スケッチ(コンパイル不要):
SignedPayloadstruct(sequencer 署名付きカスタムブロックエンベロープ)CentralizedConsensus::validate_block_pre_execution(ECDSA 復元と比較を完備)current_forkchoiceのスケッチ(現 head をローカルでどう追跡?)- Mempool の置き場所(ヒント: sequencer ではない、別コンポーネント)
答え合わせ
- 「単一リーダーコンセンサス」がコンセンサスと呼べる理由: 1 つの値(次のブロック)への合意がコンセンサスの定義。意思決定者が 1 人なら値はその意思決定者の言う通りになる。
- 信頼仮定 → liveness の交換: launch 時には許容できる。低 TVL + ロードマップ上の項目 + 速く出荷できる、で正当化される。
- 2-of-3 multisig が現実的な launch パターン: Optimism / Arbitrum / Base はこの形で launch、鍵はチーム + 監査人 + ノードオペレータ。HA 向上 + 1 鍵ダウンでも継続。
合格基準
produce_one_blockの 7 ステップを順に言える。- カスタム Consensus の 3 行検証を書ける。
- 段階的分散化 4 ステージを辿れる。
- 「launch 時の信頼仮定は liveness の代償」を 1 文で説明できる。
まとめ(3行)
- 単一 sequencer = Reth + ~100 行の Rust + 3 行の Consensus 検証で出荷できる「コンセンサス」。
- Day 0(単一)→ Month 3(2-of-3)→ Month 12(ローテーション)→ Year 2(本物の BFT)が L1 分散化の標準シーケンス。
- 速く出荷して段階的に分散化する — それが Hyperliquid / Optimism / Arbitrum がやった道。