Skip to content
← Build OpenHL — from `cargo init` to a single-validator devnet
Contract types
Lesson 4 of 16·CONTENT30 min60 XP
Course
Build OpenHL — from `cargo init` to a single-validator devnet
Lesson role
CONTENT
Sequence
4 / 16

Lesson 3 — The ConsensusBridge trait

Question

Write ConsensusBridge, the four-method async trait (build_payload, payload_ready, validate_payload, commit) that openhl's consensus side calls to drive execution.

Principle (minimum model)

  • Four methods. build_payload(parent, attrs) -> PayloadId, payload_ready(id) -> ExecutedBlock, validate_payload(&ExecutedBlock) -> PayloadStatus, commit(BlockHash) -> (), each returning Result<_, BridgeError>. They mirror the Engine API's shape: start a build and get an id, fetch the built payload, validate a peer's block, set the new head.
  • A trait, not a struct. One contract, several impls: InMemoryEvmBridge (Lesson 4), RethEvmBridge (Lesson 5), LiveRethEvmBridge (Lessons 12–14). The consensus code calls the trait and never learns which one it got.
  • #[async_trait] + Send + Sync. The macro turns each async fn into a boxed Send future so the trait can be used as dyn ConsensusBridge; the supertrait bounds let one bridge be shared across tokio tasks.
  • Three error variants. BridgeError::{Rejected(String), Syncing, Internal(eyre::Report)} — reject the block, try again later, or stop.
  • No impls yet. The lesson ends at cargo check -p openhl-consensus; this is openhl commit 13113db.

Worked example + steps

Lesson 3 — The ConsensusBridge trait

Goal

Concepts you'll grasp in this lesson:

  • Why these four methods — build_payload / payload_ready / validate_payload / commit follows the BFT round structure (propose → vote → decide) and the Engine API's build/fetch split. Collapsing build/ready removes the option to build during voting; adding something like notify_view_change would leak consensus internals into the EL.
  • #[async_trait] and Send + Sync bounds — what async_trait actually desugars to (boxed futures with object-safety), and why : Send + Sync is a compile-time guarantee that any Arc<dyn ConsensusBridge> shared between Malachite actors stays sound.
  • The three-error taxonomy — Rejected / Syncing / Internal map to three distinct consensus responses (vote-against / wait / halt). One stringy variant would force the consensus side to parse strings; many variants would leak EL internals.
  • Trait-as-contract programming — once this file compiles, every later lesson is either "implement this method" or "call this method." Lessons 4–5 are impls; Lessons 10–14 are callers. The shape of the codebase from here on is set.

Verification:

cargo check -p openhl-consensus

…passes. The openhl-consensus crate now contains the four-message ConsensusBridge trait — the typed API surface that consensus calls into and execution implements. No impls yet (those start in Lesson 4); just the trait and its associated error type.

Specific changes:

  • 4 dependencies added to crates/consensus/Cargo.toml: openhl-types, async-trait, thiserror, eyre.
  • crates/consensus/src/bridge.rs — new file with the ConsensusBridge trait (4 async methods) and BridgeError enum (3 variants).
  • crates/consensus/src/lib.rs — wires pub mod bridge;.

Recap

After Lesson 2:

crates/types/src/lib.rs:
  - BlockHash, PayloadId, PayloadAttrs, PayloadStatus, ExecutedBlock
  - + Display impl for BlockHash
  - + 4 unit tests passing

The other crates (including openhl-consensus) are still empty stubs:

crates/consensus/src/lib.rs:
  //! Consensus layer — Malachite BFT.
crates/consensus/Cargo.toml:
  [dependencies]   ← empty

Plan

You'll do three things:

  1. Add 4 dependencies to crates/consensus/Cargo.toml: openhl-types (to use the types from Lesson 2), async-trait (the macro that makes async fn legal in trait methods), thiserror (derive macro for nice error types), eyre (a Result library that pairs well with thiserror).
  2. Create crates/consensus/src/bridge.rs with the ConsensusBridge trait (4 async methods) and the BridgeError enum (3 variants).
  3. Wire bridge into the crate by adding pub mod bridge; to crates/consensus/src/lib.rs.

This trait is the single most-referenced artifact in the entire course. Lesson 4 implements it (InMemoryEvmBridge). Lesson 5 implements it again (RethEvmBridge). Lesson 10 calls into it from the actor pipeline (run_engine_app). Lessons 12–14 implement it a third time (LiveRethEvmBridge). The signatures you write now propagate everywhere downstream.

Walk-through

Step 1: Add dependencies to crates/consensus/Cargo.toml

Open crates/consensus/Cargo.toml. The [dependencies] section is currently empty (just a header). Replace it with:

[dependencies]
openhl-types = { workspace = true }
async-trait  = { workspace = true }
thiserror    = { workspace = true }
eyre         = { workspace = true }

That's all four deps. Each uses workspace = true to inherit the pinned version from the root Cargo.toml. Save the file and run:

cargo check -p openhl-consensus

This should still pass — we just declared deps we haven't used yet. Cargo will fetch any that aren't already in the lock file. async-trait and thiserror are small; this should be ~5 seconds.

Why these four specifically?

  • openhl-types because the trait signatures reference BlockHash, PayloadAttrs, PayloadId, ExecutedBlock, PayloadStatus — all five types from Lesson 2.
  • async-trait because Rust's native async fn in traits (stable since 1.75) makes the trait not dyn-compatible, and gives callers no way to require that the returned futures are Send. The #[async_trait] macro handles both by desugaring to Pin<Box<dyn Future<...> + Send>>. Slightly slower (one allocation per call), but dyn-compatible.
  • thiserror to derive a custom error enum without writing boilerplate impl Display/impl Error by hand.
  • eyre for the catch-all Internal error variant. eyre::Report wraps any error together with its chain of causes; we use it for "something unexpected went wrong" without enumerating every internal failure mode.

Step 2: Create crates/consensus/src/bridge.rs

A new file. The full content:

//! The CL/EL contract: four messages between consensus and execution.

use async_trait::async_trait;
use openhl_types::{BlockHash, ExecutedBlock, PayloadAttrs, PayloadId, PayloadStatus};
use thiserror::Error;

/// The four-message contract between BFT consensus and EVM execution.
///
/// Every interaction between `openhl-consensus` and `openhl-evm` flows through one of these methods. Anything else is a contract leak.
#[async_trait]
pub trait ConsensusBridge: Send + Sync {
    /// CL → EL: build a candidate block on `parent`. Returns immediately; await the block via [`Self::payload_ready`].
    async fn build_payload(
        &self,
        parent: BlockHash,
        attrs: PayloadAttrs,
    ) -> Result<PayloadId, BridgeError>;

    /// EL → CL: wait for an in-flight build to complete.
    async fn payload_ready(&self, id: PayloadId) -> Result<ExecutedBlock, BridgeError>;

    /// CL → EL: would this peer-proposed block execute cleanly?
    async fn validate_payload(
        &self,
        block: &ExecutedBlock,
    ) -> Result<PayloadStatus, BridgeError>;

    /// CL → EL: finalize this block. Fire-and-forget; failure halts the chain.
    async fn commit(&self, block_hash: BlockHash) -> Result<(), BridgeError>;
}

#[derive(Debug, Error)]
pub enum BridgeError {
    #[error("execution layer rejected payload: {0}")]
    Rejected(String),

    #[error("execution layer is syncing")]
    Syncing,

    #[error("internal: {0}")]
    Internal(#[from] eyre::Report),
}

Walk through what each piece does — this is the most important file in the course.

Step 3: Understand the trait declaration

#[async_trait]
pub trait ConsensusBridge: Send + Sync {

#[async_trait] is an attribute macro. It rewrites the trait so each async fn returns Pin<Box<dyn Future<Output = ...> + Send + 'a>> behind the scenes. Without this macro, Rust gives you an error trying to use async fn in a trait you want to call via dyn ConsensusBridge (which we will).

pub trait ConsensusBridge makes the trait part of the public API — both openhl-consensus and downstream crates (like the upcoming openhl-evm impls) can name it.

: Send + Sync are super-trait bounds. They say: every type that implements ConsensusBridge must also be Send (movable across thread boundaries) and Sync (referenceable from multiple threads). We need this because the bridge will be held in an Arc<dyn ConsensusBridge> shared between actor tasks; each actor may live on a different thread.

Step 4: Understand the four method signatures

Before reading the signatures one at a time, holding a timeline of when each of the four methods fires during a BFT round — and which direction the data flows — makes each signature land on intuition rather than memorization:

【 CL / EL interaction flow — along a BFT round timeline 】

──[ Earlier — e.g. while the previous height is still voting (an option the split allows) ]──
   CL ──────( build_payload(parent, attrs) )──────►  EL
                                                     │
                                                     └─ kicks off block construction
                                                        in the background, in parallel
                                                        with the previous round's votes

──[ The moment we become the proposer — hot path, where microseconds matter ]──────
   CL ──────( payload_ready(id) )─────────────────►  EL
   CL ◄────( returns ExecutedBlock ) ────────────────── EL
   CL ─► (broadcasts the Proposal onto the network)

──[ A Proposal arrives from a peer — every validator passes through here ]────────────
   CL ──────( validate_payload(&ExecutedBlock) )───►  EL
   CL ◄────( PayloadStatus: Valid / Invalid / Syncing ) ── EL
   CL ─► votes accordingly (yes / Nil / abstain)

──[ Quorum (2/3+) is reached — the block becomes finalized ]──────────────────────
   CL ──────( commit(hash) )──────────────────────►  EL
                                                     │
                                                     └─ persists the block,
                                                        promotes it to the new head

Two things to notice: (a) build_payload and payload_ready can be called at different phases — the former early, the latter on the proposer's hot path. That is the latency trick spelled out a few paragraphs down. (b) CL always initiates the call, but payload_ready is the one seam where a built block flows back EL → CL. With that, the individual signatures:

async fn build_payload(
    &self,
    parent: BlockHash,
    attrs: PayloadAttrs,
) -> Result<PayloadId, BridgeError>;

Inputs: parent block hash and payload attributes. Output: a PayloadId, which is an opaque handle — the bridge has started building, but the block isn't ready yet. Returns immediately.

async fn payload_ready(&self, id: PayloadId) -> Result<ExecutedBlock, BridgeError>;

The companion. Hand back the PayloadId from build_payload; receive the ExecutedBlock. Async because the call may block until the in-flight build finishes.

(The call still originates from CL, but it's the one seam where a completed ExecutedBlock flows the other way — from the EL's build back into CL. Of the four methods, only payload_ready returns a block EL → CL.)

Why split into build_payload + payload_ready instead of one build_payload -> ExecutedBlock? So the EL can build ahead of time. If build_payload returned the block, the proposer would have to wait for the build before broadcasting; with the split, the build can run in the background and the proposer's hot path becomes "fetch the prepared block". This is the same reason Ethereum's Engine API separates engine_forkchoiceUpdated (with payload attributes → payloadId) from engine_getPayload. Note that openhl's own app loop (Lesson 10) calls the two back-to-back when Malachite asks for a value, so the course never exercises the overlap — the contract just leaves room for it.

async fn validate_payload(
    &self,
    block: &ExecutedBlock,
) -> Result<PayloadStatus, BridgeError>;

Different shape: &ExecutedBlock (borrowed, not owned). The bridge is examining the block, not consuming it. Returns PayloadStatus (the enum from Lesson 2): Valid / Invalid / Syncing.

Why borrowed? Because consensus may need to inspect the same block multiple times (broadcast it, persist it, then validate). Taking ownership would consume the value at the call site, forcing the caller to clone. Borrowing lets the caller keep it.

async fn commit(&self, block_hash: BlockHash) -> Result<(), BridgeError>;

Smallest signature: hash in, unit out. Fire-and-forget. When consensus has decided on a block, this method tells the EL to finalize it. The EL applies it to state, updates fork-choice, and never sees this hash unset later. Returning Result<()> lets the EL signal a hard failure (which halts the chain — in Lesson 10, a failed commit makes the app loop return an error), but successful commits return nothing.

Notice no &ExecutedBlock argument. By the time commit is called, the bridge already saw this block during payload_ready or validate_payload. Asking for just the hash forces consensus to remember nothing — the EL keeps state, the CL stays stateless.

Step 5: Understand the BridgeError enum

#[derive(Debug, Error)]
pub enum BridgeError {
    #[error("execution layer rejected payload: {0}")]
    Rejected(String),

    #[error("execution layer is syncing")]
    Syncing,

    #[error("internal: {0}")]
    Internal(#[from] eyre::Report),
}

Three variants — same number as PayloadStatus, but not a 1:1 correspondence. The distinction:

  • Rejected(String) — the EL applied logic to the block and said "no, this is bad." The String holds a human-readable reason. Consensus should treat the block as invalid: vote nil, move to the next round.
  • Syncing — the EL doesn't have the state to give an answer yet. Different from rejection: we don't know if the block is bad, we just can't tell yet. Consensus should retry later, not vote nil.
  • Internal(eyre::Report) — something unexpected broke. Disk full, mutex poisoned, panic caught. Consensus should halt — this is not recoverable at the chain level.

Why is Syncing an error variant, when PayloadStatus::Syncing is also a status? Because the contract has two layers:

  • PayloadStatus::Syncing from validate_payload means "the EL processed the request and reports its sync state."
  • BridgeError::Syncing from any method means "the call itself couldn't complete." More commonly applies to build_payload (can't build if you don't have parent state) and commit (can't finalize what you can't apply).

#[from] eyre::Report auto-derives From<eyre::Report> for BridgeError::Internal. That means bridge implementations can write let foo = some_call()?; where some_call() returns Result<_, eyre::Report>, and the ? automatically wraps it as BridgeError::Internal. This is the canonical way to bubble up "unexpected" errors.

Step 6: Wire bridge into the crate

Open crates/consensus/src/lib.rs. Currently:

//! Consensus layer — Malachite BFT.

Replace with:

//! Consensus layer — Malachite BFT.

pub mod bridge;

pub mod bridge; tells Rust "this crate has a public module called bridge, sourced from src/bridge.rs." Without this line, your bridge.rs is invisible from outside the crate.

Test

Run:

cargo check -p openhl-consensus

Expected:

    Checking openhl-types v0.1.0 (...)
    Checking openhl-consensus v0.1.0 (...)
    Finished `dev` profile [optimized + debuginfo] target(s) in ...

The file above checks with no warnings (also under cargo clippy with the workspace's pedantic lints). If you see an unused-import warning, you probably mistyped a type in one of the signatures. Hard errors are not OK.

Common errors and fixes:

  • use of undeclared crate or module 'async_trait' — async-trait isn't in [dependencies]. Re-check Step 1.
  • cannot find type 'BlockHash' in this scope — openhl-types isn't imported. Re-check the use line in bridge.rs.
  • Forgot : Send + Sync? This file still compiles — the missing bound only bites later, when a bridge is shared across tokio tasks (Lesson 10) and the compiler reports that the future or Arc<dyn ConsensusBridge> is not Send. Match the reference now.
  • An error from thiserror about #[from] — #[from] must sit on a variant whose only field is the source error, like Internal(#[from] eyre::Report).

You can also try compiling the whole workspace:

cargo check --workspace

Should still pass.

Design reflection

Three load-bearing decisions encoded:

  1. Four methods, not three or five. They follow the round structure (propose → vote → decide) and the Engine API's split between starting a build and fetching it. Collapsing build_payload + payload_ready into one would remove the option to build ahead. Adding a fifth (e.g., notify_view_change) would leak consensus internals into execution.

  2. Send + Sync bound on the trait. Forces every implementation to be thread-safe. Without this, an Arc<dyn ConsensusBridge> shared between actors won't compile. With this, implementers know up-front that mutable state must be behind Mutex or atomics. The compiler enforces what would otherwise be a runtime-bug discipline.

  3. Three error variants, not one or many. Three corresponds to three distinct consensus-side actions: vote-against, wait, halt. One BridgeError(String) would make the consensus side parse strings. Five+ variants (e.g., Rejected.Hash, Rejected.Number, Rejected.BaseFee) would either leak EL internals to the consensus side or rapidly drift out of sync as EL changes. Three is the cardinality of the consensus response to errors; the EL's internal taxonomy stays opaque behind the String in Rejected.

Answer key

cd ~/code/openhl-reference
git checkout 13113db
diff -u ~/code/my-openhl/crates/consensus/src/bridge.rs ./crates/consensus/src/bridge.rs
diff -u ~/code/my-openhl/crates/consensus/Cargo.toml ./crates/consensus/Cargo.toml

Expected: doc-comment wording can vary slightly (you might have written commit or Commit for the variant — both ok). The 4 method signatures, the 3 error variants, the #[async_trait] attribute, and the : Send + Sync bound must match exactly.

Return:

git checkout main

Common questions

Q: My cargo check complains about pub mod bridge and bridge.rs not found. The file is at crates/consensus/src/bridge.rs, not crates/consensus/bridge.rs. The convention is that modules declared in lib.rs live as siblings to it.

Q: Why is validate_payload async if it just inspects bytes? At v0 it could be sync — the in-memory bridge just returns Valid. But later implementations look things up in a live Reth node (Lesson 13 loads the parent header from Reth's provider and runs EthBeaconConsensus against it), and a real EL would also execute the block. Marking it async now means we don't have to break the trait later. Cost is ~0 (an immediate-ready future is essentially free).

Q: Can I rename the methods? build_payload is verbose. You can in your own code, but you'll diverge from openhl. The names match the Ethereum Engine API (engine_forkchoiceUpdated returns a PayloadId that you fetch via engine_getPayload), which makes the openhl ↔ Ethereum mapping recognizable to anyone familiar with the latter.

Q: What's eyre::Report and why not just String? eyre::Report keeps the original error and its chain of causes (and can capture a backtrace when RUST_BACKTRACE is set). When debugging a chain halt, you want to see "DB write failed: disk full" with its source, not just "internal error". Report does this; String doesn't. We use it for the catch-all variant.

Next lesson (Lesson 4)

The contract is now fully specified at the type level. Lesson 4 starts implementing it. We write InMemoryEvmBridge — a test double that stores fake blocks in a Mutex<HashMap> and returns synthesized hashes. No real EVM, no real state — just enough to make the trait satisfiable and the consensus side testable. Critically, the same trait ConsensusBridge covers both InMemoryEvmBridge (Lesson 4) and LiveRethEvmBridge (Lesson 12 onward) — that's the polymorphism win we're paying for with the Send + Sync bound and async_trait macro.

Summary (3 lines)

  • ConsensusBridge = 4 async methods: build_payload starts a build, payload_ready fetches it, validate_payload judges a peer's block, commit finalises.
  • BridgeError = Rejected / Syncing / Internal. #[async_trait] + Send + Sync make it usable as a shared dyn object.
  • Consensus calls the trait; InMemory, RethEvmBridge and LiveRethEvmBridge implement it. Next module: EL test double.