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 returningResult<_, 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 eachasync fninto a boxedSendfuture so the trait can be used asdyn 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 commit13113db.
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 / commitfollows 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 likenotify_view_changewould leak consensus internals into the EL. #[async_trait]andSend + Syncbounds — whatasync_traitactually desugars to (boxed futures with object-safety), and why: Send + Syncis a compile-time guarantee that anyArc<dyn ConsensusBridge>shared between Malachite actors stays sound.- The three-error taxonomy —
Rejected / Syncing / Internalmap 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 theConsensusBridgetrait (4 async methods) andBridgeErrorenum (3 variants).crates/consensus/src/lib.rs— wirespub 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:
- Add 4 dependencies to
crates/consensus/Cargo.toml:openhl-types(to use the types from Lesson 2),async-trait(the macro that makesasync fnlegal in trait methods),thiserror(derive macro for nice error types),eyre(aResultlibrary that pairs well withthiserror). - Create
crates/consensus/src/bridge.rswith theConsensusBridgetrait (4 async methods) and theBridgeErrorenum (3 variants). - Wire
bridgeinto the crate by addingpub mod bridge;tocrates/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-typesbecause the trait signatures referenceBlockHash,PayloadAttrs,PayloadId,ExecutedBlock,PayloadStatus— all five types from Lesson 2.async-traitbecause Rust's nativeasync fnin traits (stable since 1.75) makes the trait notdyn-compatible, and gives callers no way to require that the returned futures areSend. The#[async_trait]macro handles both by desugaring toPin<Box<dyn Future<...> + Send>>. Slightly slower (one allocation per call), butdyn-compatible.thiserrorto derive a custom error enum without writing boilerplateimpl Display/impl Errorby hand.eyrefor the catch-allInternalerror variant.eyre::Reportwraps 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::Syncingfromvalidate_payloadmeans "the EL processed the request and reports its sync state."BridgeError::Syncingfrom any method means "the call itself couldn't complete." More commonly applies tobuild_payload(can't build if you don't have parent state) andcommit(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-traitisn't in[dependencies]. Re-check Step 1.cannot find type 'BlockHash' in this scope—openhl-typesisn't imported. Re-check theuseline inbridge.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 orArc<dyn ConsensusBridge>is notSend. Match the reference now. - An error from thiserror about
#[from]—#[from]must sit on a variant whose only field is the source error, likeInternal(#[from] eyre::Report).
You can also try compiling the whole workspace:
cargo check --workspace
Should still pass.
Design reflection
Three load-bearing decisions encoded:
-
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_readyinto one would remove the option to build ahead. Adding a fifth (e.g.,notify_view_change) would leak consensus internals into execution. -
Send + Syncbound on the trait. Forces every implementation to be thread-safe. Without this, anArc<dyn ConsensusBridge>shared between actors won't compile. With this, implementers know up-front that mutable state must be behindMutexor atomics. The compiler enforces what would otherwise be a runtime-bug discipline. -
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 inRejected.
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 + Syncmake it usable as a shareddynobject.- Consensus calls the trait; InMemory, RethEvmBridge and LiveRethEvmBridge implement it. Next module: EL test double.