FABRKNT
Step 1. Consensus — cargo init から single-validator devnet 構築
CL types
レッスン 9 / 16·CONTENT35 分70 XP
コース
Step 1. Consensus — cargo init から single-validator devnet 構築
レッスンの役割
CONTENT
順序
9 / 16

レッスン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/ を軽量に保ち下流に重依存を漏らさない。