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 inopenhl-typesand not inopenhl-consensusoropenhl-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]asBlockHash([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
PayloadStatusisValid / Invalid / Syncinginstead ofbool. ASyncingnode treated asInvalidforks permanently from peers that could have helped it catch up. - Custom
Displayover defaultDebug— why every contract type that appears in logs needs a human-readable0xab12…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.rsgains 5 types —BlockHash,PayloadId,PayloadAttrs,PayloadStatus,ExecutedBlock— plus aDisplayimpl onBlockHash.- 4 unit tests added: hex display, status equality, executed-block clone, serde round-trip.
- The
openhl-typescrate 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:
| Type | Shape | Role in the contract |
|---|---|---|
BlockHash | pub struct BlockHash(pub [u8; 32]) | 32-byte hash, Ethereum convention. Used everywhere a block is referenced. |
PayloadId | pub struct PayloadId(pub u64) | Returned by build_payload; passed to payload_ready. |
PayloadAttrs | pub struct PayloadAttrs { timestamp, fee_recipient, prev_randao } | Inputs to a payload-build job. |
PayloadStatus | pub enum PayloadStatus { Valid, Invalid, Syncing } | Verdict from validate_payload. |
ExecutedBlock | pub 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 proposerfee_recipient— 20-byte Ethereum address, where gas fees goprev_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 hashparent_hash— the previous block's hash, forming the chainnumber— 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. Displayimpl missing forBlockHash.format!("{h}")requiresDisplay, notDebug.- Forgot to add
serde_jsonto[dev-dependencies].serde_json::to_stringwon't resolve.
Design reflection
Two load-bearing decisions:
-
Contract types live in
openhl-types, a separate crate. Not inopenhl-consensusand not inopenhl-evm. The reason is the Rust crate-graph constraint: ifBlockHashlived inopenhl-consensus, thenopenhl-evmwould have to depend onopenhl-consensus(to use the type). Butopenhl-consensusalso needs to call methods thatopenhl-evmimplements — meaningopenhl-consensuswould need to depend onopenhl-evm. A→B and B→A is a dependency cycle, which Rust does not allow. The fix is the shared vocabulary crate: bothopenhl-consensusandopenhl-evmdepend onopenhl-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 usesalloy-primitivesandreth-primitives-traitsfor the same purpose. -
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.