Lesson 8 — OpenHlCodec — codec slot the engine demands
Question
OpenHlCodec provides encode/decode for the wire formats Malachite needs — Proposal, Vote, Extension, etc. SSZ canonical encoding throughout.
Principle (minimum model)
Codectrait (Malachite).encode_X(...) -> Bytes+decode_X(&Bytes) -> Xfor each wire type.- SSZ everywhere. Same encoding as Ethereum consensus layer. Deterministic; canonical.
OpenHlCodecstruct. Stateless; just a marker type. All methods are pure.encode_proposal. Build SSZ-encoded Proposal bytes. Usessz_rs::Encode. Wire-format identical across all nodes.decode_proposal. Inverse; fail fast on invalid bytes.- Why a slot. Malachite is generic over codec; you can swap SSZ for Borsh / Cap'n Proto / etc. Hyperliquid chose SSZ for Ethereum compat.
- Tests. Round-trip every wire type; assert encode → decode → encode produces same bytes.
Worked example + steps
Lesson 8 — OpenHlCodec — codec slot the engine demands
Goal
Concepts you'll grasp in this lesson:
- Stub-as-trait-satisfier — incremental development at the type level. Writing 4-line stubs that name what's unimplemented beats writing 50-line protobuf encoders for paths the engine never exercises. The stub fires loudly if Malachite ever does call it.
- Sub-trait blanket impls —
WalCodec / ConsensusCodec / SyncCodecare automatic once you implement the rightCodec<T>constituents. Astatic_assertions::assert_impl_all!test verifies the blanket impls fire and the compile-time bound is real. - Where the codec belongs in the crate graph — codec lives in
openhl-consensus, notopenhl-types, because it depends on Malachite'sinformalsystems-malachitebft-app(libp2p, ractor). Putting it intypes/would force every downstream crate that wantsBlockHashto pull libp2p too. - Wire format vs. canonical signing format — Lesson 7's canonical encoding is what gets signed; Lesson 8's codec is what gets sent over the wire. They overlap but aren't the same: wire format adds framing, versioning, length prefixes — none of which the signature covers.
- Why one real codec is enough at Lesson 8 — only
ProposalPartround-trips in our single-validator devnet. The other 7 are gossip / sync / WAL paths that don't fire until you add peers or recover from a crash.
Verification:
cargo test -p openhl-consensus
…passes 16 tests (14 from Lesson 7 + 2 new ones for the codec). The 2 new tests are: a compile-time assertion that OpenHlCodec satisfies all three super-traits, and a runtime round-trip test for ProposalPart.
You also unblock a much heavier dependency: informalsystems-malachitebft-app pulls in libp2p, ractor, and the rest of the engine surface — your first compile after this is genuinely heavy (~38 seconds on a modern multi-core machine; can stretch to several minutes on single-core-bound or resource-constrained environments). The investment buys you the actor system you'll spawn in Lesson 9.
Specific changes:
crates/consensus/Cargo.tomladdsinformalsystems-malachitebft-app+static_assertions(dev).crates/consensus/src/codec.rs— new file withOpenHlCodecstruct,CodecStuberror type, 8Codec<T>impls (1 real forProposalPart, 7 stubs), 2 unit tests.crates/consensus/src/lib.rs— wirespub mod codec;.
Recap
After Lesson 7 your openhl-consensus crate has:
crates/consensus/src/lib.rs — pub mod bridge, context, signing, signing_provider, types
crates/consensus/src/signing.rs — canonical encoding + low-level sign/verify
crates/consensus/src/signing_provider.rs — OpenHlSigningProvider impls SigningProvider<OpenHlContext>
crates/consensus/src/types/ — 7 type files + mod.rs
crates/consensus/src/context.rs — OpenHlContext + Context impl
cargo test -p openhl-consensus passes 14 tests. The engine still won't compile — start_engine is generic over a codec, and we haven't provided one yet.
Plan
Five things:
- Add
informalsystems-malachitebft-apptocrates/consensus/Cargo.toml. This is the heavy lift — it pulls libp2p, ractor, and the full app surface transitively. First compile after this is genuinely heavy (~38s on a modern multi-core box; up to several minutes on slower or more constrained machines). - Create
crates/consensus/src/codec.rswith theOpenHlCodecunit struct, aCodecStuberror, and 8Codec<T>impls. - Wire
pub mod codec;intolib.rs. - Run
cargo test -p openhl-consensus— 16 tests pass. - Observe that the compile-time assertion compiles. This is the signal that you've satisfied the engine's codec trait bound.
This lesson teaches one pattern that matters more than any specific impl: stub trait methods with a clear failure mode. When you need to satisfy a large trait bound but the methods aren't on your hot path, you can stub them. The stub error message should name what was called so the reader knows what to implement next. This is incremental development at the type-system level — you don't have to implement every codec at once; you provide enough to compile, fail loudly on the actual call sites.
Walk-through
Step 1: Add the app dependency to Cargo.toml
Open crates/consensus/Cargo.toml. Add one line in the [dependencies] section:
informalsystems-malachitebft-app = { workspace = true }
Place it next to the other malachite deps. The app crate is a meta-crate that re-exports types from across the engine — Codec, ConsensusCodec, SyncCodec, WalCodec, SignedConsensusMsg, StreamMessage, ProposedValue, sync::{Status, Request, Response} all live here.
Run a quick sanity check:
cargo check -p openhl-consensus 2>&1 | tail -5
The first build is genuinely heavy (libp2p + ractor + dependencies compile for the first time — ~38s on a modern multi-core machine, up to several minutes on slower or single-core-bound environments). If the progress log looks stuck, it isn't — pour another coffee. Subsequent builds use the cache and the incremental rebuild is back to seconds.
Step 2: Create crates/consensus/src/codec.rs
Top of the file:
//! 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 Lesson 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);
OpenHlCodec is a unit struct — no state. Codecs in Malachite are pure functions; the receiver only exists so the trait can dispatch. CodecStub is the error type that all eight Codec impls share. The &'static str field carries the name of the type whose codec is missing, so when an unimplemented path does fire, the error message tells you exactly what to write.
Step 3: The one real impl — ProposalPart
Next:
// ---- 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())
}
}
This one is real. OpenHlProposalPart is a unit struct (zero fields), so:
- Encode returns an empty
Bytes— a unit struct's wire representation is the empty string. - Decode ignores the input bytes and returns
OpenHlProposalPart— the only possible value of the type. Even if someone hands you garbage bytes, decoding into a unit type can't fail.
This is not a stub — it's a complete, correct implementation that happens to be trivial. Unit types have a degenerate wire format. The empty-bytes encoding gets exercised by proposal_part_round_trips in signing_provider.rs and by anything else that asks "encode/decode a ProposalPart."
Step 4: The 7 stub impls
Now the seven impls that aren't real:
// ---- 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>"))
}
}
Seven Codec impls, all the same pattern: decode returns Err(CodecStub(...)), encode returns Err(CodecStub(...)), the type name is passed as a literal so the stub error names itself.
Three categories:
- Consensus messages (gossip) —
SignedConsensusMsg,LivenessMsg,StreamMessage. These go over libp2p between validators. In a single-validator devnet, no peers exist, so these never get called. - WAL (crash recovery) —
ProposedValue. The engine writes proposals to disk for crash recovery. We run in-process tests so this never fires. - Sync (peer catch-up) —
Status,Request,Response. When a validator falls behind it asks peers to send it past blocks. No peers means no falling-behind means no sync.
Step 5: Add the test module
At the bottom of 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);
}
}
Two tests:
openhl_codec_satisfies_all_three_super_traits— this is a compile-time assertion disguised as a test.WalCodec<Ctx>,ConsensusCodec<Ctx>, andSyncCodec<Ctx>are super-traits in Malachite — they're automatically satisfied if a type implements all the rightCodec<T>constituent impls. The threeassert_*functions exist only to force the compiler to check the bound. If you forgot a singleCodec<T>impl, this would fail to compile, not fail at runtime. The runtime test body is just a no-op — the verification happens at type-check time.proposal_part_round_trips— exercises the one real codec impl. Encode an emptyProposalPart, decode the resulting bytes, assert equality. This proves the real impl works; the seven stub impls aren't tested at runtime here because they're meant to panic-via-error if anything ever calls them.
Step 6: Wire codec into lib.rs
Open crates/consensus/src/lib.rs. Currently:
//! Consensus layer — Malachite BFT.
pub mod bridge;
pub mod context;
pub mod signing;
pub mod signing_provider;
pub mod types;
pub use context::OpenHlContext;
Add one line:
//! 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;
Test
First compile will be slow — first time fetching libp2p, ractor, and ~200 transitive deps:
cargo test -p openhl-consensus
After about 30-40 seconds:
running 16 tests
test bridge::tests::... ... ok # (consensus has bridge tests from Lesson 3? — depends on workspace)
test codec::tests::openhl_codec_satisfies_all_three_super_traits ... ok
test codec::tests::proposal_part_round_trips ... ok
test context::tests::... (5 tests) ... ok
test signing::tests::... (2 tests) ... ok
test signing_provider::tests::... (7 tests) ... ok
test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Common errors and fixes:
error[E0277]: the trait bound 'OpenHlCodec: WalCodec<OpenHlContext>' is not satisfied— you're missing one of the eightCodec<T>impls. Re-check Step 3 and Step 4 — all eight constituent types must haveimpl Codec<T> for OpenHlCodec.error[E0061]: this function takes 1 argument but 0 arguments were supplied(orerror[E0308]: mismatched types) —CodecStubis a tuple struct that takes a singlepub &'static str, so writingCodecStubwith no argument or using the brace formCodecStub { ... }won't compile. Pass the type name literal explicitly:Err(CodecStub("SignedConsensusMsg<OpenHlContext>")).error[E0432]: unresolved import 'informalsystems_malachitebft_app::types::codec::ConsensusCodec'— you forgot to addinformalsystems-malachitebft-appto Cargo.toml. Re-check Step 1.- Build takes 60+ seconds even on a recompile — try
cargo build(no--release). If still slow, the issue is libp2p; let it run.
Design reflection
Three load-bearing decisions encoded here:
-
Stubs with a clear failure name beat full impls that aren't needed yet. A real
SignedConsensusMsgcodec is ~50 lines of protobuf encoding. We don't write it because we don't need it. We write a 4-line stub that, if it ever fires, names what wasn't implemented. Incremental development at the type level. -
One trait impl can satisfy multiple super-traits via blanket impls.
WalCodec<Ctx>is automaticallyimpl<C> WalCodec<Ctx> for C where C: Codec<ProposedValue<Ctx>>(and similar for Consensus/Sync). By implementing the right constituentCodec<T>impls, you don't have to writeimpl WalCodec— Malachite gives you the blanket impl free. The compile-time assertion test verifies this is real. -
The codec is in
consensus/, nottypes/. Codecs depend on the engine's notion of "what gets wired" —SignedConsensusMsg,ProposedValue,sync::Status— which is a consensus-layer concern, not a base-type concern. Putting codec intypes/would forcetypes/to depend oninformalsystems-malachitebft-app, which would makeopenhl-typesa heavy dep for downstream crates that don't need the engine.
The "no codec inside types/" discipline is easiest to see by drawing the two dependency graphs side by side:
🟢 The design we picked (clean dependency graph)
┌─────────────────────────┐ ┌──────────────────────────────────┐
│ openhl-evm │ ─┐ │ openhl-consensus (holds Codec) │
├─────────────────────────┤ ▼ │ - bridge.rs / types/ / codec.rs │
│ openhl-node │ ─► openhl-types │ - signing*.rs / context.rs │
├─────────────────────────┤ ▲ (lightweight ────►─── informalsystems-malachitebft-app
│ (other downstream) │ ─┘ zero-dep) (libp2p / ractor / heavy)
└─────────────────────────┘ └──────────────────────────────────┘
Editing the EVM side never recompiles consensus / libp2p / ractor → fast inner loop
🔴 Anti-pattern (codec colocated inside types/)
┌─────────────────────────┐
│ openhl-evm │ ─┐
├─────────────────────────┤ ▼
│ openhl-node │ ─► openhl-types (codec also lives here) ─► informalsystems-malachitebft-app
├─────────────────────────┤ ▲ (libp2p / ractor / heavy)
│ (other downstream) │ ─┘
└─────────────────────────┘
A single-line tweak in EVM logic drags libp2p and the actor system into every rebuild;
the ~38s first-build cost gets paid again and again.
In the picked layout (left), openhl-types stays "the lightweight shared dictionary everyone references," and the heavy consensus / libp2p / ractor surface is sealed inside openhl-consensus. In the anti-pattern (right), every downstream crate that touches openhl-types is forced to build through libp2p. "Keep types light; introduce heavy dependencies in the layer that actually needs them" is the discipline this crates/ topology bakes in.
Answer key
cd ~/code/openhl-reference
git checkout 4229502
diff -u ~/code/my-openhl/crates/consensus/src/codec.rs ./crates/consensus/src/codec.rs
diff -u ~/code/my-openhl/crates/consensus/Cargo.toml ./crates/consensus/Cargo.toml
diff -u ~/code/my-openhl/crates/consensus/src/lib.rs ./crates/consensus/src/lib.rs
The reference at 4229502 includes Cargo.lock changes (the libp2p tree) and the 166 lines of codec.rs. The implementation pattern (one stub, repeated) should match closely. Doc comments can vary.
Return:
git checkout main
Common questions
Q: Why do I need the _msg and _bytes underscore-prefixed parameter names?
Rust requires unused parameters to be prefixed with _ to suppress the unused-variable warning. The &self is needed by trait dispatch but never read; the _msg/_bytes are similarly ignored. Some stubs use them (we don't here), but the underscore is the idiom to tell Rust "I see this exists, I'm not using it."
Q: What's the difference between WalCodec, ConsensusCodec, and SyncCodec?
They're sub-traits that group related codec impls. WalCodec requires you to encode ProposedValue. ConsensusCodec requires SignedConsensusMsg + LivenessMsg + StreamMessage<ProposalPart> + ProposalPart. SyncCodec requires Status + Request + Response. By implementing the individual Codec<T> traits, you get all three super-traits for free.
Q: If the stubs never fire, why do they exist at all?
Because Rust's trait system can't conditionally include or exclude impls based on runtime configuration. The engine's start_engine function has a trait bound C: ConsensusCodec<Ctx> + WalCodec<Ctx> + SyncCodec<Ctx>, and the bound is checked at compile time, regardless of whether the codec methods ever execute. The stubs are there to satisfy the type system, not the runtime.
Q: When would I replace a stub with a real impl?
When the engine actually calls it. Lesson 9's smoke test will spawn the actor system and exercise some paths; if a stub fires, the error message tells you which one. The most likely first call is Codec<ProposedValue<OpenHlContext>> (WAL), because the engine writes the very first proposal to disk for crash recovery before any peer gossip happens. You'd swap that one for a protobuf-backed encoder.
Next lesson (Lesson 9)
You've satisfied the codec trait bound — start_engine's signature is now satisfiable. But you don't yet have the value you'd pass for the codec, the node config, or the validator set, all of which start_engine also requires. Lesson 9 implements Node trait on OpenHlNode: ~300 lines covering OpenHlConfig (NodeConfig impl), OpenHlGenesis, OpenHlPrivateKeyFile, OpenHlNodeHandle, and the Node impl itself with 5 associated types and 12 methods. The capstone of Lesson 9 is start_engine_smoke_spawns_and_kills — a test that calls start_engine and proves the actor system spawns and tears down cleanly in ~0.02 seconds. After Lesson 9, the engine boots; we'll spend Lessons 10–15 wiring the AppMsg loop and the live Reth integration.
Summary (3 lines)
OpenHlCodecimpls Malachite'sCodectrait via SSZ. encode/decode each wire type (Proposal / Vote / Extension).- Stateless; marker type only. SSZ everywhere for Ethereum compat + canonicalisation.
- Round-trip tests assert byte-identity. Codec slot lets you swap encodings; SSZ is the choice. Next: OpenHlNode.