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)
ConsensusBridgetrait. ~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.
proposebuilds a block. Called when this validator is leader; gathers pending txs from mempool; builds Block; returns to Malachite for consensus.validate_payloadchecks before applying. Stateless validation (signature checks, basic structure).apply_payloadexecutes. Reth (or test double) runs every tx; returns Receipts. Drives the state machine.commitfinalises. 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 methods —
build_payload / payload_ready / validate_payload / commitis 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]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 / NotReady / 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 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-typesbecause the trait signatures referenceBlockHash,PayloadAttrs,PayloadId,ExecutedBlock,PayloadStatus— all five types from Lesson 2.async-traitbecause Rust's nativeasync fnin trait is still gated behind several caveats (Send bounds,dyncompatibility). The#[async_trait]macro handles them by desugaring toPin<Box<dyn Future<...>>>. Verbose, but stable anddyn-compatible.thiserrorto derive a custom error enum without writing boilerplateimpl Display/impl Errorby hand.eyrefor the catch-allInternalerror variant.eyre::Reportwraps 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::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:
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-traitisn't in[dependencies]. Re-check Step 1.cannot find type 'BlockHash' in this scope—openhl-typesisn't imported. Re-check theuseline inbridge.rs.expected type parameter 'Send + Sync', found...— you wrotepub trait ConsensusBridgewithout: 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:
-
Four methods, not three or five. Every BFT-L1 implementation converges on exactly these four. Collapsing
build_payload+payload_readyinto 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. -
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 — 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)
ConsensusBridgetrait = ~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.