Skip to content
← Build OpenHL — from `cargo init` to a single-validator devnet
Contract types
Lesson 3 of 16·CONTENT30 min60 XP
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

Write the five types consensus and execution share — BlockHash, PayloadId, PayloadAttrs, PayloadStatus, ExecutedBlock — in openhl-types, with a hex Display and 4 passing tests.

Principle (minimum model)

  • Five contract types. BlockHash([u8; 32]), PayloadId(u64), PayloadAttrs { timestamp, fee_recipient, prev_randao }, PayloadStatus { Valid, Invalid, Syncing }, ExecutedBlock { hash, parent_hash, number, state_root }. They are the arguments and return values of the ConsensusBridge trait in Lesson 3.
  • A neutral third crate. Consensus and EVM both depend on openhl-types, so neither has to depend on the other for type definitions.
  • Newtypes, not aliases. BlockHash wraps [u8; 32], so the compiler rejects a random byte array where a block hash is expected.
  • Derives. Debug, Clone, PartialEq/Eq and serde Serialize/Deserialize throughout; Copy and Hash on the small ones; PartialOrd/Ord plus a hex Display on BlockHash, because Malachite's Value::Id bound requires Display + Ord (used from Lesson 6).
  • Three-valued status. PayloadStatus mirrors the Engine API's VALID / INVALID / SYNCING: "behind" is not the same as "wrong".
  • Tests. 4 unit tests: hex display, status equality, clone, and a serde JSON round-trip of BlockHash.
  • Design note. Keeping consensus-layer types in one shared crate that both consensus and execution depend on is OpenHL's choice; Hyperliquid's internals are not public.

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 node that is merely behind must not report a good block as bad.
  • 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          # small: Reth/Malachite are declared but not resolved yet
├── 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; Hash for HashMap keys; Serialize, Deserialize for wire format. PartialOrd, Ord (and the Display below) are there for Malachite: in Lesson 6, BlockHash becomes the Value::Id of your consensus value, and Malachite requires Value::Id: Clone + Debug + Display + Eq + Ord + Send + Sync. (openhl added these three to BlockHash in that later commit, 784785b; this lesson adds them up front.)

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 Malachite's Value::Id); 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 exactly the Engine API's original PayloadAttributesV1 (timestamp, prevRandao, suggestedFeeRecipient). Later versions add withdrawals (V2) and parentBeaconBlockRoot (V3); 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 (the same three verdicts the Ethereum Engine API's engine_newPayload returns as VALID / INVALID / SYNCING; the real API also has ACCEPTED), each calling for a different consensus-side response:

  • Valid — The EL checked the block and it is good. Vote for it.
  • Invalid — The EL checked the block and it is wrong (state-root mismatch, gas-limit violation, etc.). Vote nil.
  • Syncing — The EL doesn't have the state to answer yet (this node is behind). It can't vouch for the block either way; catch up before judging it.

The three variants are not interchangeable. Treating Syncing like Invalid makes a node that is merely behind reject good blocks. Treating Invalid like Syncing turns "this block is wrong" into "ask again later". Lesson 3 on the trait will get into this; for now, you encoded the three distinct verdicts. (openhl's own consensus loop in this course never receives Syncing — the bridges you write return Valid or Invalid — but the type leaves room for it.)

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. 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 matters for liveness — a node that is only behind would start rejecting good blocks.

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
  ⏳ Syncing  → can't judge yet: catch up from peers first

What happens if you collapse to a bool:
  Syncing treated as Invalid → you Nil-vote a good block just because you're behind
                                → if enough validators are behind, rounds fail and the chain stalls
  Invalid treated as Syncing → you treat a genuinely wrong block as "ask again later"
                                → you wait and retry on a block that will never become valid

Valid / Invalid / Syncing correspond to "vote / nil-vote / not yet" at the consensus layer. Squashing them into a bool deletes "not yet", 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

Expect three differences, all deliberate: at 13113db the reference BlockHash has no PartialOrd, Ord and no Display impl (openhl added them in 784785b, when Malachite's Value::Id bound required them — run git diff 13113db 784785b -- crates/types to see it), and the reference has no unit tests or serde_json dev-dependency in this crate. Everything else should match: every field, the other derives, and the PayloadStatus 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? At v0 the struct only has fixed-size fields, so it could be Copy. We omit the derive deliberately: the doc comment promises transactions and receipts later, and a Vec field would force us to remove Copy (a breaking change for every caller that relied on it). 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 = 5 contract types (BlockHash / PayloadId / PayloadAttrs / PayloadStatus / ExecutedBlock), shared by consensus and EVM so neither depends on the other.
  • Newtypes for type safety; serde derives; BlockHash gets Ord + hex Display because Malachite's Value::Id needs them.
  • PayloadStatus has three states, not a bool. 4 unit tests pass. Next: ConsensusBridge trait.