FABRKNT
Build OpenHL — from `cargo init` to a single-validator devnet
Contract types
Lesson 4 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
4 / 16

Lesson 3 — The ConsensusBridge trait

Question

ConsensusBridge is the trait that Malachite calls into the execution side through. Build the interface; later lessons supply concrete impls (InMemory test double + RethEvmBridge production).

Principle (minimum model)

  • ConsensusBridge trait. ~7 methods: propose(parent_hash) -> Block, validate_payload(payload) -> Result<()>, apply_payload(payload) -> Receipts, commit(block_hash), get_parent(hash), get_best_block(), genesis_hash().
  • Why a trait, not a struct. Allows multiple impls — test double for unit tests + RethEvmBridge for production. Malachite is decoupled from execution choice.
  • propose builds a block. Called when this validator is leader; gathers pending txs from mempool; builds Block; returns to Malachite for consensus.
  • validate_payload checks before applying. Stateless validation (signature checks, basic structure).
  • apply_payload executes. Reth (or test double) runs every tx; returns Receipts. Drives the state machine.
  • commit finalises. When Malachite has consensus, commit the block; persist.
  • async fn. Tokio-based. All methods async; Malachite drives in a tokio runtime.

Worked example + steps

Lesson 3 — The ConsensusBridge trait

Goal

Concepts you'll grasp in this lesson:

  • Why exactly four methodsbuild_payload / payload_ready / validate_payload / commit is determined by the BFT round structure (propose → vote → decide), not language preference. Collapsing build/ready kills build-during-voting; adding a fifth leaks 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 taxonomyRejected / NotReady / 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 9 calls into it from the actor pipeline. Lessons 11–13 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 trait is still gated behind several caveats (Send bounds, dyn compatibility). The #[async_trait] macro handles them by desugaring to Pin<Box<dyn Future<...>>>. Verbose, but stable and 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 with a backtrace; 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 】

──[ The previous round is still voting; the proposer is prepping their turn ]─────────
   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 are called at different round phases — the former during the previous round's voting, the latter on the proposer's hot path. This is the "most important latency trick" we'll spell out a few paragraphs down. (b) CL always initiates the call, but payload_ready is the one seam where data flows back EL → CL (the answer to the §Plan quiz). 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.

(This is the §Plan quiz answer. 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 thread back into CL — and synchronizes. Of the four methods, only payload_ready carries data EL → CL.)

Why split into build_payload + payload_ready instead of one build_payload -> ExecutedBlock? Because the EL needs to build during the previous round's voting. If build_payload returned the block synchronously, the proposer would have to wait for build before broadcasting; with the split, build runs in the background while voting happens, and the proposer's hot path becomes "fetch the prepared block" (microseconds). This is the single most important latency trick in the design. Sub-second block times depend on 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 — see Lesson 9), 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:

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

You might get warnings about unused imports (e.g., ExecutedBlock if you typo'd a method signature) or unused trait. Hard errors are not OK; warnings are fine for now.

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 scopeopenhl-types isn't imported. Re-check the use line in bridge.rs.
  • expected type parameter 'Send + Sync', found... — you wrote pub trait ConsensusBridge without : Send + Sync. Add it back.
  • #[from] is only allowed on a single field — you have more than one #[from] on a variant, or wrote #[from] on a variant without a tuple field.

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. Every BFT-L1 implementation converges on exactly these four. Collapsing build_payload + payload_ready into one would kill build-during-voting. Adding a fifth (e.g., notify_view_change) would leak consensus internals into execution. The number is determined by the BFT round structure (propose → vote → decide), not by language preference.

  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 — checking a BlockHash against a parent_hash is microseconds. But production validate_payload runs the EVM against the parent state, which requires async DB access. 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 captures a chain of causes with source-location info. When debugging a chain halt, you want to see "DB write failed: disk full: at io.rs:142" 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 11 onward) — that's the polymorphism win we're paying for with the Send + Sync bound and async_trait macro.

Summary (3 lines)

  • ConsensusBridge trait = ~7 async methods. propose builds; validate_payload checks; apply_payload executes; commit finalises.
  • Decouples Malachite from execution. Test double (InMemory) + production (RethEvmBridge) both impl this trait.
  • Tokio async throughout. Next module: EL test double.