レッスン3 — ConsensusBridge trait
問い
BFT consensus と EVM execution の間の契約を、いくつのメソッドで表すべきか? なぜ ちょうど 4 つ で、3 でも 5 でもないのか?
原理(最小モデル)
- メソッドは 4 つ:
build_payload / payload_ready / validate_payload / commit。 数は BFT round 構造(propose → vote → decide)で決まる。build_payloadとpayload_readyを 1 つにすると「投票中の先行ビルド」ができない。5 つ目(例notify_view_change)を足すと consensus 内部事情を EL API に漏らし層分離が崩れる。 #[async_trait]+: Send + Sync。async_traitはasync fnを boxed future へ desugar しdyn互換にする。Send + Syncsuper-trait bound でArc<dyn ConsensusBridge>を actor 間で共有しても安全だと compiler が検証できる。- error は 3 variant:
Rejected / Syncing / Internal。 consensus の 3 応答(反対 vote / 待つ / 停止)に対応。1 つの String error にすると consensus が文字列 parse して分岐する羽目になる。
具体例
build_payload(前 round 投票中に裏で開始)と payload_ready(自分が proposer の hot path で fetch)を分けることが、最も重要な latency trick だ。同期的に 1 メソッドでブロックを返すと、proposer は build を待ってから broadcast する。分けると build が裏で走り、hot path は「準備済みブロックを fetch」(microsecond)に縮む。sub-second block time はこれに依存する。
失敗例(誤解)
「メソッドは少ない方が良いから build と payload_ready を 1 つに」は誤り(先行ビルド不能)。「状態通知も足そうと notify_view_change を追加」も誤り(consensus 内部を EL に leak)。数は API デザインの好みでなく BFT round 構造が決める。
ここまでで「なぜ 4 メソッド・なぜ Send+Sync・なぜ 3 error」は着地した。ここから bridge.rs を組み立てる。コードは完全形。
🛑 予測。 4 メソッドのうち 3 つは CL → EL、1 つは EL → CL。どれが EL → CL 方向か? ヒント:その戻り値を consensus 側がどう待っているか。
ステップで組み立てる
Step 1: crates/consensus/Cargo.toml に依存を追加
[dependencies]
openhl-types = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
eyre = { workspace = true }
- openhl-types — trait signature がレッスン2 の 5 type を参照する。
- async-trait — trait 内
async fnをPin<Box<dyn Future>>へ desugar(native async-fn-in-trait はまだ Send bound / dyn 互換に粗さがある)。 - thiserror —
Display/Errorを手書きせず error enum を derive。 - eyre — catch-all な
Internal用。任意 error をバックトレース付きでラップ。
Step 2: crates/consensus/src/bridge.rs(新規ファイル全体)
//! The CL/EL contract: four messages between consensus and execution.
use async_trait::async_trait;
use openhl_types::{BlockHash, ExecutedBlock, PayloadAttrs, PayloadId, PayloadStatus};
use thiserror::Error;
/// The four-message contract between BFT consensus and EVM execution.
///
/// Every interaction between `openhl-consensus` and `openhl-evm` flows through one of these methods. Anything else is a contract leak.
#[async_trait]
pub trait ConsensusBridge: Send + Sync {
/// CL → EL: build a candidate block on `parent`. Returns immediately; await the block via [`Self::payload_ready`].
async fn build_payload(
&self,
parent: BlockHash,
attrs: PayloadAttrs,
) -> Result<PayloadId, BridgeError>;
/// EL → CL: wait for an in-flight build to complete.
async fn payload_ready(&self, id: PayloadId) -> Result<ExecutedBlock, BridgeError>;
/// CL → EL: would this peer-proposed block execute cleanly?
async fn validate_payload(
&self,
block: &ExecutedBlock,
) -> Result<PayloadStatus, BridgeError>;
/// CL → EL: finalize this block. Fire-and-forget; failure halts the chain.
async fn commit(&self, block_hash: BlockHash) -> Result<(), BridgeError>;
}
#[derive(Debug, Error)]
pub enum BridgeError {
#[error("execution layer rejected payload: {0}")]
Rejected(String),
#[error("execution layer is syncing")]
Syncing,
#[error("internal: {0}")]
Internal(#[from] eyre::Report),
}
Step 3: signature の必然性(BFT round のタイムライン)
──[ 前 round 投票中、proposer は自分のターンを準備 ]──────────────
CL ──( build_payload(parent, attrs) )──► EL ─ 裏でブロック構築開始(投票と並走)
──[ 自分が proposer になった瞬間 — hot path ]──────────────────
CL ──( payload_ready(id) )──► EL
CL ◄─( ExecutedBlock を返す )── EL ← 唯一 EL→CL にデータ逆流(予測の答え)
CL ─► Proposal を broadcast
──[ 他 peer から Proposal 受信 ]───────────────────────────────
CL ──( validate_payload(&ExecutedBlock) )──► EL
CL ◄─( PayloadStatus: Valid/Invalid/Syncing )── EL
CL ─► vote(賛成 / Nil / 棄権)
──[ 2/3+ Quorum → finalized ]──────────────────────────────────
CL ──( commit(hash) )──► EL ─ 永続化、new head 確定
各 signature の要点:
build_payload(parent, attrs) -> PayloadId— 即 return、不透明 handle を返す。payload_ready(id) -> ExecutedBlock— in-flight build 完了まで block するので async。唯一 EL → CL 方向にデータが逆流する seam(予測の答え)。validate_payload(&ExecutedBlock) -> PayloadStatus—&(borrowed)。inspect するだけで consume しない(consensus は同じブロックを複数回 inspect する)。commit(BlockHash) -> Result<()>— 最小 signature、fire-and-forget。&ExecutedBlockを取らない(commit 時点で EL は既にブロックを見ており、hash だけで CL は stateless を保てる)。失敗は chain halt(レッスン9)。
Step 4: BridgeError の 3 variant
- Rejected(String) — EL が「bad」と判定。consensus は nil 投票して次 round へ。String が human-readable な理由。
- Syncing — EL がまだ state を持たず答えられない(bad とは別)。後でリトライ、nil 投票にはしない。
- Internal(eyre::Report) — 予期せぬ破綻(disk full 等)。consensus は halt。
#[from]で?がeyre::Reportを自動ラップ。
PayloadStatus::Syncing(status)と BridgeError::Syncing(error)の二層: 前者は「call は完了し sync state を report した」、後者は「call そのものが完了できなかった」(build_payload / commit でよく出る)。
Step 5: crate に組み込む
crates/consensus/src/lib.rs:
//! Consensus layer — Malachite BFT.
pub mod bridge;
答え合わせ
cd ~/code/openhl-reference && git checkout 13113db
diff -u ~/code/my-openhl/crates/consensus/src/bridge.rs ./crates/consensus/src/bridge.rs
diff -u ~/code/my-openhl/crates/consensus/Cargo.toml ./crates/consensus/Cargo.toml
git checkout main
doc-comment の言い回しは違って OK。4 method signature・3 error variant・#[async_trait]・: Send + Sync は完全一致が必要。
合格基準
cargo check -p openhl-consensus
cargo check --workspace
→ Finished(unused 警告は OK、hard error は不可)。impl はまだない(レッスン4 から)。trait と error type だけ。
まとめ(3行)
- 契約はちょうど 4 メソッド — BFT round 構造(propose → vote → decide)が数を決める。
build_payloadとpayload_readyの分離が sub-second block time を支える latency trick。- error は 3 variant(Rejected / Syncing / Internal)= consensus の 3 応答(否決 / 待つ / 停止)。