FABRKNT
Build OpenHL — from `cargo init` to a single-validator devnet
Contract types
Lesson 3 of 16·CONTENT30 min60 XP

Treat this page as a workbench, not a blog post. The goal is to extract a reusable mental model from the source and carry it into the rest of the Fabrknt stack.

Course
Build OpenHL — from `cargo init` to a single-validator devnet
Lesson role
CONTENT
Sequence
3 / 16

Lesson 2 — Shared contract types in openhl-types

Question

openhl-types is the shared vocabulary between consensus (Malachite) and execution (Reth). Without it, the two systems speak different languages. Build the 10 core contract types (Block, BlockHeader, Tx, Receipt, etc.).

Principle (minimum model)

  • 10 contract types. Block + BlockHeader + Transaction + Receipt + Address + B256 + Bytes + U256 + Signature + ValidatorId.
  • Why "contract" types. They're the shared schema both sides agree on. Changing one requires re-coordinating both sides.
  • Serde for JSON; ssz for wire. SSZ is the Ethereum-PoS-canonical serialization; openhl uses it for the consensus-side wire format.
  • Trait impls. Hash + Eq + Clone + Serialize + Deserialize + Encode + Decode. Match what both Reth and Malachite expect.
  • No conversions; just shared types. Both sides import from openhl-types. If a conversion is needed, it's a bug.
  • Tests. Round-trip serde + ssz for every type. Fail if encoding breaks.
  • Production parallel. Hyperliquid has the same separation; CL types live in a shared crate that both consensus + execution depend on.

Worked example + steps

Lesson 2 — Shared contract types in openhl-types

Goal

Concepts you'll grasp in this lesson:

  • The shared vocabulary crate — why BlockHash, PayloadId, etc. live in openhl-types and not in openhl-consensus or openhl-evm. Rust forbids dependency cycles, so a CL↔EL split forces a neutral third crate that both sides import.
  • The newtype pattern — wrapping [u8; 32] as BlockHash([u8; 32]) instead of using a type alias. The compiler then refuses to substitute a random byte array where a block hash is expected.
  • Three-valued payload status — why PayloadStatus is Valid / Invalid / Syncing instead of bool. A Syncing node treated as Invalid forks permanently from peers that could have helped it catch up.
  • Custom Display over default Debug — why every contract type that appears in logs needs a human-readable 0xab12… rendering. Logs are a debugger's primary tool; readable output is not optional.

Verification:

cargo test -p openhl-types

…passes 4 tests covering the 5 contract primitives you wrote. No application logic yet; just data definitions that the contract trait (Lesson 3) will reference.

Specific changes:

  • crates/types/src/lib.rs gains 5 types — BlockHash, PayloadId, PayloadAttrs, PayloadStatus, ExecutedBlock — plus a Display impl on BlockHash.
  • 4 unit tests added: hex display, status equality, executed-block clone, serde round-trip.
  • The openhl-types crate becomes the shared vocabulary that consensus and EVM both depend on.

Recap

After Lesson 1, your workspace looks like this:

~/code/my-openhl/
├── Cargo.toml          # workspace root with Reth + Malachite pinned
├── Cargo.lock          # full lock file (Reth/Malachite resolved)
├── rust-toolchain.toml # rustc 1.95.0
├── bin/openhl/         # binary that prints "openhl v0.1.0"
├── crates/
│   ├── types/          # empty — `//! Shared primitives...` doc comment only
│   ├── codec/
│   ├── clob/
│   ├── consensus/      # empty
│   ├── evm/            # empty
│   ... (6 more empty crates) ...
└── target/             # cached compilation

cargo check --workspace passes. cargo test -p openhl-types runs zero tests successfully.

Plan

You'll add 5 contract types to crates/types/src/lib.rs:

TypeShapeRole in the contract
BlockHashpub struct BlockHash(pub [u8; 32])32-byte hash, Ethereum convention. Used everywhere a block is referenced.
PayloadIdpub struct PayloadId(pub u64)Returned by build_payload; passed to payload_ready.
PayloadAttrspub struct PayloadAttrs { timestamp, fee_recipient, prev_randao }Inputs to a payload-build job.
PayloadStatuspub enum PayloadStatus { Valid, Invalid, Syncing }Verdict from validate_payload.
ExecutedBlockpub struct ExecutedBlock { hash, parent_hash, number, state_root }What a consensus round commits to.

Plus one Display impl on BlockHash (so logs print 0xab12... instead of BlockHash([171, 18, ...])).

Plus 4 unit tests covering: BlockHash hex display, PayloadStatus equality, ExecutedBlock cloneability, BlockHash serde round-trip.

These five types are the shared vocabulary of the CL↔EL contract. Both the consensus crate and the evm crate will import them. They live in openhl-types (a third crate) — not in openhl-consensus and not in openhl-evm — for a reason explored in §Design reflection below.

Walk-through

Step 1: Open crates/types/src/lib.rs

The current contents (from Lesson 1):

//! Shared primitives and CL/EL contract types.

You'll add type definitions below this comment.

Step 2: Verify serde is available in Cargo.toml

Lesson 1 set up crates/types/Cargo.toml with:

[dependencies]
serde = { workspace = true }

That's correct; we'll use it for the #[derive(Serialize, Deserialize)] lines. No edit needed.

Step 3: Add imports

Edit crates/types/src/lib.rs. After the doc comment, add:

//! Shared primitives and CL/EL contract types.

use std::fmt;

use serde::{Deserialize, Serialize};

std::fmt for the Display impl we'll add to BlockHash. serde::{Deserialize, Serialize} for the derives on every type — every contract type needs to round-trip through wire format eventually.

Step 4: Add BlockHash

/// 32-byte block hash, Ethereum convention.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct BlockHash(pub [u8; 32]);

impl fmt::Display for BlockHash {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("0x")?;
        for b in &self.0 {
            write!(f, "{b:02x}")?;
        }
        Ok(())
    }
}

Newtype pattern. BlockHash is a wrapper around [u8; 32], not a type alias. This matters: with a wrapper, the compiler rejects let h: BlockHash = [0u8; 32]; (must wrap explicitly). With a type alias (type BlockHash = [u8; 32];), they're interchangeable and you can pass a random [u8; 32] where a BlockHash was expected. Newtypes are how Rust type-checks "this is specifically a block hash, not just any 32 bytes."

Why Copy despite being 32 bytes? Copy semantics let you pass BlockHash by value without explicit .clone(). The cost is small (a memcpy of 32 bytes), and the ergonomics gain is large — you'll pass block hashes around constantly. The alternative (Clone only) requires .clone() at every call site and is noisy.

Why all 10 trait derives? Debug for {:?} formatting; Clone, Copy for value semantics; PartialEq, Eq for equality testing; PartialOrd, Ord for sorting (we'll need this when validators sort blocks); Hash for HashMap keys; Serialize, Deserialize for wire format. Every contract type needs roughly this same set.

Why a custom Display impl? Default Debug would print BlockHash([171, 18, 240, ...]), which is unreadable in logs. The custom Display prints 0xab12f0..., matching the Ethereum convention. Logs are a debugger's primary tool; making them human-readable is not optional.

Run cargo check -p openhl-types. Should pass.

Step 5: Add PayloadId

/// Identifier returned by `build_payload`; used to retrieve the assembled block via `payload_ready`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct PayloadId(pub u64);

Same newtype pattern, smaller backing type. No Display impl — Debug (PayloadId(42)) is fine in logs.

No PartialOrd, Ord here. Block hashes need ordering (for sorting); payload IDs don't (they're just opaque tokens between build_payload and payload_ready).

Step 6: Add PayloadAttrs

/// Inputs to a payload-build job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayloadAttrs {
    pub timestamp: u64,
    pub fee_recipient: [u8; 20],
    pub prev_randao: [u8; 32],
}

A real struct (not a newtype) — multiple fields. Three pieces:

  • timestamp — Unix seconds, picked by the proposer
  • fee_recipient — 20-byte Ethereum address, where gas fees go
  • prev_randao — 32-byte beacon-chain randomness (from previous block)

These three are the minimum Reth needs to assemble a payload. The Ethereum Engine API spec has more fields (suggestedFeeRecipient, parentBeaconBlockRoot, withdrawals, etc.); we omit them at v0 because openhl is single-validator and doesn't have withdrawal flows.

No Copy here — 60 bytes is past the comfortable Copy threshold. Callers will explicitly clone() when passing around.

Step 7: Add PayloadStatus

/// Verdict from `validate_payload`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PayloadStatus {
    Valid,
    Invalid,
    Syncing,
}

Three variants, each with a specific consensus-side response:

  • Valid — The EL applied the block and got the expected state. Vote for it.
  • Invalid — The EL applied the block and the result was wrong (state-root mismatch, gas-limit violation, etc.). Vote nil; treat this proposer as faulty.
  • Syncing — The EL doesn't have the state to answer yet (chain is behind). Don't vote yet; wait or fall to timeout.

The three variants are not interchangeable. Treating Syncing like Invalid permanently forks you from peers who could have answered. Treating Invalid like Syncing lets bad proposals through. Lesson 3 on the trait will get into this; for now, you encoded the three distinct verdicts.

Step 8: Add ExecutedBlock

/// An executed block — the artifact a consensus round commits to. Minimal v0 shape; txs and receipts land per Module 2.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutedBlock {
    pub hash: BlockHash,
    pub parent_hash: BlockHash,
    pub number: u64,
    pub state_root: [u8; 32],
}

The fields:

  • hash — this block's hash
  • parent_hash — the previous block's hash, forming the chain
  • number — block height (parent.number + 1, monotonic)
  • state_root — Merkle root of the post-execution state (32 bytes)

What's not here (deliberately):

  • Transactions list — Module 2 (CLOB) lands transactions; v0 produces empty blocks
  • Receipts list — same
  • Logs bloom — same
  • Difficulty / mix hash — post-merge defaults

This is the minimum shape needed for the consensus round to close. As Modules 2-5 land, ExecutedBlock gets more fields. By keeping it minimal now, we avoid encoding Module 2's design before we've designed it.

Run cargo check -p openhl-types — should still pass.

Step 9: Add unit tests

The tests actually exercise serde's round-trip, so add serde_json as a dev-dependency first (before the test code lands). Edit crates/types/Cargo.toml:

[dev-dependencies]
serde_json = { workspace = true }

(Adding the dep before writing the test prevents your IDE / rust-analyzer from flashing serde_json::to_string as unresolved and triggering an unnecessary rebuild.)

Then append to crates/types/src/lib.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn block_hash_display_is_hex() {
        let h = BlockHash([0xab; 32]);
        let s = format!("{h}");
        assert!(s.starts_with("0x"));
        assert_eq!(s.len(), 2 + 64); // "0x" + 64 hex chars
        assert!(s.ends_with("ab"));
    }

    #[test]
    fn payload_status_equality() {
        assert_eq!(PayloadStatus::Valid, PayloadStatus::Valid);
        assert_ne!(PayloadStatus::Valid, PayloadStatus::Invalid);
        assert_ne!(PayloadStatus::Syncing, PayloadStatus::Valid);
    }

    #[test]
    fn executed_block_is_cloneable() {
        let original = ExecutedBlock {
            hash: BlockHash([1u8; 32]),
            parent_hash: BlockHash([0u8; 32]),
            number: 1,
            state_root: [2u8; 32],
        };
        let cloned = original.clone();
        assert_eq!(cloned.number, original.number);
        assert_eq!(cloned.hash, original.hash);
    }

    #[test]
    fn block_hash_serde_round_trips() {
        let original = BlockHash([0x42; 32]);
        let json = serde_json::to_string(&original).unwrap();
        let round_tripped: BlockHash = serde_json::from_str(&json).unwrap();
        assert_eq!(original, round_tripped);
    }
}

Test

cargo test -p openhl-types

Expected:

running 4 tests
test tests::block_hash_display_is_hex ... ok
test tests::executed_block_is_cloneable ... ok
test tests::payload_status_equality ... ok
test tests::block_hash_serde_round_trips ... ok

test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

If a test fails, the typical mistakes are:

  • Forgot #[derive(Clone)] or #[derive(PartialEq)] on a type. The compiler error names the missing trait.
  • Display impl missing for BlockHash. format!("{h}") requires Display, not Debug.
  • Forgot to add serde_json to [dev-dependencies]. serde_json::to_string won't resolve.

Design reflection

Two load-bearing decisions:

  1. Contract types live in openhl-types, a separate crate. Not in openhl-consensus and not in openhl-evm. The reason is the Rust crate-graph constraint: if BlockHash lived in openhl-consensus, then openhl-evm would have to depend on openhl-consensus (to use the type). But openhl-consensus also needs to call methods that openhl-evm implements — meaning openhl-consensus would need to depend on openhl-evm. A→B and B→A is a dependency cycle, which Rust does not allow. The fix is the shared vocabulary crate: both openhl-consensus and openhl-evm depend on openhl-types, and neither depends on the other for type definitions. This is a standard pattern in any Rust workspace with a CL↔EL split — Reth uses alloy-primitives and reth-primitives-traits for the same purpose.

  2. PayloadStatus is an enum, not a bool. Lesson 0 / your prediction above flagged this. The three states are not interchangeable: the consensus-side response depends on which not-Valid state the EL is in. Collapsing them to bool { is_valid } would lose information that's load-bearing for chain liveness — a Syncing node treated as Invalid permanently forks from peers who could have helped it.

Drawing how PayloadStatus flows between CL and EL, and how each verdict drives a different CL action, makes the necessity of three states immediate:

┌────────────────────────────────────────────────────────────────────────────┐
│                       Consensus Layer (CL)                                  │
│                                                                             │
│         asks the Execution Layer: validate_payload(block)                   │
│                                  │                                          │
└──────────────────────────────────┼──────────────────────────────────────────┘
                                   │ ▲
                                   ▼ │ PayloadStatus
┌────────────────────────────────────┼──────────────────────────────────────┐
│                Execution Layer (EL)│                                       │
│                                    │                                       │
│   ┌────────────────────────────────┴──────────────────────────────────┐    │
│   │  Run the block → classify the outcome into one of three:           │    │
│   │                                                                    │    │
│   │  ✅ Valid    : state-root matches, gas-limit OK, all rules pass    │    │
│   │  ❌ Invalid  : ran the block, result is wrong (state-root mismatch) │    │
│   │  ⏳ Syncing  : don't have the state needed to run it yet            │    │
│   └────────────────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────────────────┘

CL-side response (the reason three branches are needed):
  ✅ Valid    → vote for the block (carry it into consensus)
  ❌ Invalid  → Nil vote, treat the proposer as faulty (slashing surface)
  ⏳ Syncing  → don't vote yet, wait or fall through to timeout, retry sync from a peer

What happens if you collapse to a bool:
  Syncing treated as Invalid → you Nil-vote a legitimate proposer while you're just behind
                                → you fork permanently from peers who saw it as valid
  Invalid treated as Syncing → you treat a genuinely wrong block as "this will resolve itself"
                                → a bad proposal slips through via timeout and the chain rots

Valid / Invalid / Syncing correspond 1:1 to "vote / nil-vote / abstain" at the consensus layer. Squashing them into a bool deletes "abstain", and with it the only correct response when you're the one out of sync. Lesson 3 (the ConsensusBridge trait) is where these three states land in actual function signatures.

Answer key

cd ~/code/openhl-reference
git checkout 13113db
diff -u ~/code/my-openhl/crates/types/src/lib.rs ./crates/types/src/lib.rs

Your code should be effectively identical, modulo whitespace and possibly the test names. Important things to match: type definitions (every field, every derive), the BlockHash::Display impl logic, the PayloadStatus enum variants (in the same order).

Return to main:

git checkout main

Common questions

Q: My BlockHash::Display test fails — "expected 2+64 chars, got X." You probably wrote write!(f, "{b:x}") (single hex digit) instead of write!(f, "{b:02x}") (two hex digits, zero-padded). For a byte value of 0x05, {b:x} produces "5" but {b:02x} produces "05". The test expects 2 chars per byte.

Q: Can ExecutedBlock be Copy? Not as written — it contains a Vec<...> in production (transactions list), and Vec isn't Copy. At v0 the struct only has fixed-size fields so it could be Copy, but we omit the derive deliberately to avoid having to remove it later. Cloning is cheap when fields are bytes; the call sites that need it can .clone() explicitly.

Q: Why is prev_randao 32 bytes if it's "randomness"? It's the RANDAO mix at the time of the previous block — Ethereum's beacon chain accumulates each slot's validator reveals via XOR into a running mixing value. Strictly speaking it's not a single hash output, but the result is always a fixed 32-byte pseudo-random blob ([u8; 32]). The entropy lives on the beacon-chain side; the execution layer's PayloadAttrs just receives the 32-byte mix as an input. So openhl's type matches: [u8; 32].

Q: Should BlockHash derive Default? It can (Default for [u8; 32] is all-zeros), but we don't here — the openhl convention is that block hashes are computed from real data; a default-constructed BlockHash([0u8; 32]) is a code smell. Let test code that needs a sentinel write BlockHash([0u8; 32]) explicitly.

Next lesson (Lesson 3)

openhl-types now has 5 contract types. Lesson 3 is the ConsensusBridge trait — the 4-method API surface that consensus calls into. The trait will reference the types you just wrote: build_payload(BlockHash, PayloadAttrs) -> PayloadId, payload_ready(PayloadId) -> ExecutedBlock, etc. After Lesson 3 the contract is fully specified at the type level; Lesson 4 starts implementing it.

Summary (3 lines)

  • openhl-types = 10 contract types (Block / BlockHeader / Tx / Receipt / Address / B256 / Bytes / U256 / Signature / ValidatorId).
  • Serde for JSON; ssz for wire. Trait impls match both Reth + Malachite expectations.
  • No conversions; shared types. Round-trip tests. Next: ConsensusBridge trait.