レッスン8 — OpenHlCodec — エンジンが要求する codec スロット
問い
single-validator devnet では vote を送る相手(peer)がいない。なのに、なぜ Malachite エンジンはネットワーク・メッセージの encode/decode 方法を 型で要求 するのか? そして 8 つの codec スロットのうち、本当に実装が要るのは何個か?
原理(最小モデル)
- stub による trait 充足(型レベル incremental dev)。 Malachite が呼ばないパスに 50 行の protobuf encoder を書くより、未実装を名乗る 4 行の stub が正しい。万一呼ばれたら大声でエラーを返す。
- blanket impl。
WalCodec / ConsensusCodec / SyncCodecは適切な構成Codec<T>を実装すれば自動で付く。impl WalCodecは書かない — Malachite の blanket impl が無料でくれる。 - codec は
consensus/に住む(types/ではない)。informalsystems-malachitebft-app(libp2p / ractor)に依存するから。types/に置くとBlockHashだけ欲しい下流 crate まで libp2p を引きずる。 - wire format ≠ canonical signing format。 レッスン7 の canonical encoding は 署名される対象、本レッスンの codec は ネットワークに流れるもの。wire には framing/versioning/length-prefix が乗り、署名は及ばない。
具体例
8 つの Codec<T> impl のうち、実際に発火するのは 1 つだけ:
ProposalPart → real(unit struct → 空 bytes、退化的だが完全な実装)
SignedConsensusMsg ┐
LivenessMsg │ gossip — peer がいない single-validator では発火しない
StreamMessage ┘
ProposedValue → WAL(crash recovery)— in-process test では発火しない
sync::{Status,Request,Response} → peer catch-up — peer がいないので発火しない
失敗例(誤解)
「#[derive(Serialize,Deserialize)] + bincode で全部済ませる」は誤り(多くの型が generic / Box<dyn Trait> を含み serde で簡単に扱えない。reference は ~400 行の手書き protobuf)。「codec を types/ に置く」も誤り — types/ が malachitebft-app(libp2p) に依存し、EVM 側を 1 行直すだけで libp2p までビルドが走る。
ここまでで「stub・blanket impl・配置」は着地した。ここから codec.rs を組み立てる。コードは完全形。
🛑 予測。 なぜエンジンは、送る相手がいない single-validator でも codec の実装を強制するのか? ヒント: trait bound は 型 に関するもので runtime 挙動 ではない。エンジンは codec に対して generic で、peer の有無を知らないから codec スロットを要求する。だが impl が完全でなくてよいのは、テストで gossip パスが実行されないから。
ステップで組み立てる
Step 1: crates/consensus/Cargo.toml
informalsystems-malachitebft-app = { workspace = true }
app はメタ crate で Codec/ConsensusCodec/SyncCodec/WalCodec/SignedConsensusMsg/StreamMessage/ProposedValue/sync::{Status,Request,Response} を re-export。初回ビルドは非常に重い(libp2p + ractor、近代マルチコアで ~38 秒以上)。dev に static_assertions は不要(下のテストは関数 bound で代用)。
Step 2: crates/consensus/src/codec.rs — doc + struct + error
//! Stub `Codec<T>` impls so `OpenHlCodec` satisfies `WalCodec`, `ConsensusCodec`,
//! and `SyncCodec` via Malachite's blanket impls.
//!
//! In single-validator mode none of these codecs fire — they're for network
//! gossip (Consensus), peer sync (Sync), and crash-recovery WAL writes. The
//! engine requires them to exist by trait bound, but the methods are not
//! invoked on the happy path.
//!
//! When レッスン 9 spins up actors and one of these stubs IS hit, the error
//! message names the type that needs a real impl — that's the cue to swap
//! the stub for a Protobuf/JSON implementation.
use bytes::Bytes;
use informalsystems_malachitebft_app::types::codec::Codec;
use informalsystems_malachitebft_app::types::streaming::StreamMessage;
use informalsystems_malachitebft_app::types::sync::{Request, Response, Status};
use informalsystems_malachitebft_app::types::{ProposedValue, SignedConsensusMsg};
use informalsystems_malachitebft_core_consensus::LivenessMsg;
use thiserror::Error;
use crate::context::OpenHlContext;
use crate::types::OpenHlProposalPart;
#[derive(Copy, Clone, Debug, Default)]
pub struct OpenHlCodec;
#[derive(Debug, Error)]
#[error("codec for {0} is a Stage 6b stub; implement before this path can fire")]
pub struct CodecStub(pub &'static str);
CodecStub が &'static str を持つ struct(enum でない)なのは、新 stub 追加時に enum 定義を編集せず型名リテラルを渡すだけで済むから。
Step 3: 唯一の real impl — ProposalPart
// ---- ProposalPart ---------------------------------------------------------
// ProposalPart is a unit struct in OpenHL (ValuePayload::ProposalOnly), so its
// encoding is genuinely empty — this one is real, not a stub.
impl Codec<OpenHlProposalPart> for OpenHlCodec {
type Error = CodecStub;
fn decode(&self, _bytes: Bytes) -> Result<OpenHlProposalPart, Self::Error> {
Ok(OpenHlProposalPart)
}
fn encode(&self, _msg: &OpenHlProposalPart) -> Result<Bytes, Self::Error> {
Ok(Bytes::new())
}
}
unit struct なので encode は空 Bytes、decode は入力を無視して唯一の値を返す(ゴミバイトでも失敗しようがない)。stub ではなく完全に正しい実装で、たまたま自明なだけ。
Step 4: 7 つの stub impl
// ---- Consensus messages (gossip) -----------------------------------------
impl Codec<SignedConsensusMsg<OpenHlContext>> for OpenHlCodec {
type Error = CodecStub;
fn decode(&self, _bytes: Bytes) -> Result<SignedConsensusMsg<OpenHlContext>, Self::Error> {
Err(CodecStub("SignedConsensusMsg<OpenHlContext>"))
}
fn encode(&self, _msg: &SignedConsensusMsg<OpenHlContext>) -> Result<Bytes, Self::Error> {
Err(CodecStub("SignedConsensusMsg<OpenHlContext>"))
}
}
impl Codec<LivenessMsg<OpenHlContext>> for OpenHlCodec {
type Error = CodecStub;
fn decode(&self, _bytes: Bytes) -> Result<LivenessMsg<OpenHlContext>, Self::Error> {
Err(CodecStub("LivenessMsg<OpenHlContext>"))
}
fn encode(&self, _msg: &LivenessMsg<OpenHlContext>) -> Result<Bytes, Self::Error> {
Err(CodecStub("LivenessMsg<OpenHlContext>"))
}
}
impl Codec<StreamMessage<OpenHlProposalPart>> for OpenHlCodec {
type Error = CodecStub;
fn decode(&self, _bytes: Bytes) -> Result<StreamMessage<OpenHlProposalPart>, Self::Error> {
Err(CodecStub("StreamMessage<OpenHlProposalPart>"))
}
fn encode(&self, _msg: &StreamMessage<OpenHlProposalPart>) -> Result<Bytes, Self::Error> {
Err(CodecStub("StreamMessage<OpenHlProposalPart>"))
}
}
// ---- WAL (crash recovery) -------------------------------------------------
impl Codec<ProposedValue<OpenHlContext>> for OpenHlCodec {
type Error = CodecStub;
fn decode(&self, _bytes: Bytes) -> Result<ProposedValue<OpenHlContext>, Self::Error> {
Err(CodecStub("ProposedValue<OpenHlContext>"))
}
fn encode(&self, _msg: &ProposedValue<OpenHlContext>) -> Result<Bytes, Self::Error> {
Err(CodecStub("ProposedValue<OpenHlContext>"))
}
}
// ---- Sync (peer catch-up) -------------------------------------------------
impl Codec<Status<OpenHlContext>> for OpenHlCodec {
type Error = CodecStub;
fn decode(&self, _bytes: Bytes) -> Result<Status<OpenHlContext>, Self::Error> {
Err(CodecStub("sync::Status<OpenHlContext>"))
}
fn encode(&self, _msg: &Status<OpenHlContext>) -> Result<Bytes, Self::Error> {
Err(CodecStub("sync::Status<OpenHlContext>"))
}
}
impl Codec<Request<OpenHlContext>> for OpenHlCodec {
type Error = CodecStub;
fn decode(&self, _bytes: Bytes) -> Result<Request<OpenHlContext>, Self::Error> {
Err(CodecStub("sync::Request<OpenHlContext>"))
}
fn encode(&self, _msg: &Request<OpenHlContext>) -> Result<Bytes, Self::Error> {
Err(CodecStub("sync::Request<OpenHlContext>"))
}
}
impl Codec<Response<OpenHlContext>> for OpenHlCodec {
type Error = CodecStub;
fn decode(&self, _bytes: Bytes) -> Result<Response<OpenHlContext>, Self::Error> {
Err(CodecStub("sync::Response<OpenHlContext>"))
}
fn encode(&self, _msg: &Response<OpenHlContext>) -> Result<Bytes, Self::Error> {
Err(CodecStub("sync::Response<OpenHlContext>"))
}
}
3 カテゴリ: gossip(SignedConsensusMsg/LivenessMsg/StreamMessage、peer 間 libp2p)/ WAL(ProposedValue、crash recovery)/ sync(Status/Request/Response、peer catch-up)。single-validator では peer も crash recovery もないので全て未発火。
Step 5: 2 テスト(codec.rs 末尾)
#[cfg(test)]
mod tests {
use super::*;
use informalsystems_malachitebft_app::types::codec::{
ConsensusCodec, SyncCodec, WalCodec,
};
// Compile-time assertions: by implementing the constituent Codec<T>
// traits, OpenHlCodec automatically satisfies all three super-traits.
fn assert_wal_codec<C: WalCodec<OpenHlContext>>() {}
fn assert_consensus_codec<C: ConsensusCodec<OpenHlContext>>() {}
fn assert_sync_codec<C: SyncCodec<OpenHlContext>>() {}
#[test]
fn openhl_codec_satisfies_all_three_super_traits() {
assert_wal_codec::<OpenHlCodec>();
assert_consensus_codec::<OpenHlCodec>();
assert_sync_codec::<OpenHlCodec>();
}
#[test]
fn proposal_part_round_trips() {
let codec = OpenHlCodec;
let part = OpenHlProposalPart;
let bytes = codec.encode(&part).unwrap();
let decoded = codec.decode(bytes).unwrap();
assert_eq!(part, decoded);
}
}
openhl_codec_satisfies_all_three_super_traits は コンパイル時 アサーション — bound チェックを関数呼び出しに変換。1 つでも Codec<T> impl が欠けると test 失敗でなく コンパイルエラー。本体は no-op で検証は型チェック時に起こる。proposal_part_round_trips が唯一の real impl を exercise。
Step 6: lib.rs
//! Consensus layer — Malachite BFT.
pub mod bridge;
pub mod codec;
pub mod context;
pub mod signing;
pub mod signing_provider;
pub mod types;
pub use context::OpenHlContext;
答え合わせ
cd ~/code/openhl-reference && git checkout 4229502
diff -u ~/code/my-openhl/crates/consensus/src/codec.rs ./crates/consensus/src/codec.rs
git checkout main
4229502 には Cargo.lock 変更(libp2p ツリー)と 166 行の codec.rs。stub を繰り返す実装パターンは厳密一致するべき。
合格基準
cargo test -p openhl-consensus
→ 16 テスト pass(レッスン7 の 14 + codec 2)。初回 ~30-40 秒。よくあるミス: 8 impl のどれか欠落 → OpenHlCodec: WalCodec 未充足 / CodecStub を引数なしや波括弧で書く / Cargo.toml に app 依存追加忘れ。
まとめ(3行)
- エンジンは codec を 型 で要求する(runtime 挙動でなく trait bound)— peer の有無を知らないから。
- 構成
Codec<T>を実装すればWalCodec/ConsensusCodec/SyncCodecが blanket impl で自動充足。発火しないパスは stub。 - codec は libp2p 依存ゆえ
consensus/に置く —types/を軽量に保ち下流に重依存を漏らさない。