Skip to content
← Consensus Engineering — Building L1 Consensus on Reth
Building consensus on Reth
Lesson 10 of 12·CONTENT20 min50 XP
Course
Consensus Engineering — Building L1 Consensus on Reth
Lesson role
CONTENT
Sequence
10 / 12

Lesson 9 — Building a minimal single-leader BFT in Rust

Question

Build a minimal single-leader BFT — HotStuff-inspired — in ~100 lines of Rust. Drives the four phases (propose / prevote / precommit / commit) for a single height. Production engines (HotStuff-inspired HyperBFT, whose code is not public; Tendermint-style engines such as Malachite) are scaled-up versions of this skeleton.

Principle (minimum model)

  • Five components. State machine (Driver) + vote tallying (VoteKeeper) + leader selection + network broadcast + a CentralizedSequencer for testing.
  • Driver = state machine. enum Phase { Propose, Prevote, Precommit, Commit }; transitions on quorum or timeout. ~50 lines.
  • VoteKeeper. HashMap<(height, round, phase), Vec<(Address, Signature)>> + check 2/3 stake-weighted threshold. ~30 lines.
  • Leader selection. Round-robin: leader = validators[round % n]. Simple; production uses VRF or stake-weighted.
  • Network broadcast. Tokio mpsc channels for local testing; replace with libp2p::gossipsub or RLPx for production.
  • CentralizedSequencer for tests. A test harness that owns all validators and drives them through phases. Asserts that all reach commit after 4 rounds.
  • Add to NodeBuilder. Wrap the Driver in a Consensus trait impl + register via NodeBuilder::consensus(...) — now Reth runs your BFT.
  • ~100 lines = minimum viable BFT. Production-grade adds: bandwidth control + Byzantine attack defence + slashing + reorg handling. Each is its own lesson.

Worked example + steps

Building a minimal single-leader BFT in Rust

Open the OP Stack docs. Open the Arbitrum docs. Both say variations of "we will decentralize the sequencer over time." Translation: at launch, there is one machine producing every block, and a signature from a specific key is the only consensus. That's it.

You can ship this in ~100 lines of Rust on top of Reth. This lesson is that ~100 lines, plus the trajectory for what to add when "later" arrives.

1. The single-leader / centralized sequencer pattern

Almost every new chain launches with one trusted sequencer:

  • Owns the proposer role for every block
  • Validates blocks before broadcasting
  • Has emergency halt authority
  • Plans to decentralize "later"

Why this works:

  • Liveness: single point of failure but no view changes needed
  • Speed: 1 round trip = block finalized
  • Simplicity: no validator set management, no slashing infrastructure, no 2f+1 logic

Why this is acceptable initially:

  • Chain has nothing to attack yet (low TVL)
  • Decentralization is a roadmap, not a launch requirement
  • You ship faster

OP Stack chains and Arbitrum describe this setup. It's a reasonable way for your chain to start too. (It isn't universal: Hyperliquid runs HyperBFT and Tempo runs Simplex, both BFT protocols over a validator set.)

2. The architecture

[Mempool] → [Sequencer]  → [Reth EL] → [Network broadcast]
              │  │ │
              │  │ └─ ECDSA sign block
              │  └─ Pick tx order
              └─ Build block

Three jobs the sequencer does:

  1. Build: pick transactions, order them, set timestamp
  2. Sign: cryptographic proof "this came from the sequencer"
  3. Broadcast: send to Reth EL via Engine API + to other nodes via P2P

That's it. No voting. No quorums.

3. The minimal Rust impl

use alloy_primitives::{Address, B256};
use alloy_signer::Signer;
use alloy_signer_local::PrivateKeySigner;
use reth_engine_primitives::ForkchoiceState;
use reth_rpc_engine_api::EngineApiClient;

pub struct CentralizedSequencer {
    signer: PrivateKeySigner,
    sequencer_address: Address,
    engine_api: EngineApiClient,
    block_period: Duration,  // e.g., 2 seconds
}

impl CentralizedSequencer {
    pub async fn run(&self) -> eyre::Result<()> {
        let mut ticker = tokio::time::interval(self.block_period);
        loop {
            ticker.tick().await;
            self.produce_one_block().await?;
        }
    }

    async fn produce_one_block(&self) -> eyre::Result<()> {
        // 1. Ask Reth to build a payload on the current head
        let payload_attrs = PayloadAttributes {
            timestamp: now_seconds(),
            prev_randao: B256::random(),
            suggested_fee_recipient: self.sequencer_address,
            // ...
        };

        let forkchoice_state = self.current_forkchoice().await?;
        let response = self.engine_api
            .fork_choice_updated_v4(forkchoice_state, Some(payload_attrs))
            .await?;
        let payload_id = response.payload_id.expect("must have payload id");

        // 2. Wait a moment for Reth to build the payload
        tokio::time::sleep(Duration::from_millis(500)).await;

        // 3. Get the built payload
        let payload = self.engine_api.get_payload_v4(payload_id).await?;

        // 4. Sign the payload hash (your authority proof)
        let payload_hash = payload.execution_payload.block_hash();
        let signature = self.signer.sign_hash(&payload_hash).await?;

        // 5. Submit signed payload back to Reth (and broadcast to peers)
        let signed_block = SignedPayload {
            payload: payload.execution_payload,
            sequencer_signature: signature,
        };

        self.engine_api
            .new_payload_v4(signed_block.payload.clone())
            .await?;

        // 6. Mark as finalized (single sequencer = instant finality)
        let new_head = signed_block.payload.block_hash();
        let new_forkchoice = ForkchoiceState {
            head_block_hash: new_head,
            safe_block_hash: new_head,
            finalized_block_hash: new_head,
        };
        self.engine_api
            .fork_choice_updated_v4(new_forkchoice, None)
            .await?;

        // 7. Broadcast to peers (via P2P, not shown)
        self.broadcast(signed_block).await?;

        Ok(())
    }

    async fn current_forkchoice(&self) -> eyre::Result<ForkchoiceState> {
        // Track current head locally or query EL
        todo!()
    }

    async fn broadcast(&self, signed: SignedPayload) -> eyre::Result<()> {
        // P2P broadcast (libp2p, devp2p, custom — your choice)
        todo!()
    }
}

~100 lines total. That's a working sequencer. It produces blocks every block_period, signs them with ECDSA, and broadcasts.

4. The consensus side (validation on receivers)

Other nodes receive the signed block and validate via your custom Consensus impl:

impl<B: Block> Consensus<B> for CentralizedConsensus {
    fn validate_block_pre_execution(
        &self,
        block: &SealedBlock<B>,
    ) -> Result<(), ConsensusError> {
        // The only "consensus" check: was this signed by the sequencer?
        let signature = block.sequencer_signature()?;
        let signer = signature.recover_address(&block.hash())?;

        if signer != self.expected_sequencer {
            return Err(ConsensusError::InvalidSequencer);
        }

        // Standard Ethereum-style checks
        self.validate_basic_block(block)?;

        Ok(())
    }
    // ...
}

Three lines of consensus logic: recover signer, compare to expected, reject if wrong. The rest is standard EVM validation.

Because consensus is "agreement on a single value." With one decider, the value is whatever the decider says. The trade-off is trust assumption (1 honest sequencer) for liveness (no view changes needed). Acceptable for launch; gradually relaxed as decentralization progresses.

5. Step 1 of decentralization: 2-of-3 multisig sequencer

Move from a single signer to a multisig — block validity now requires signatures from 2 of 3 designated keys instead of 1:

pub struct MultisigSequencer {
    signers: Vec<PrivateKeySigner>,  // 3 signers
    threshold: usize,                 // = 2
    engine_api: EngineApiClient,
}

impl MultisigSequencer {
    async fn produce_block(&self) -> eyre::Result<SignedPayload> {
        // 1. Lead signer (rotated) builds the payload
        let payload = self.build_payload().await?;

        // 2. Collect signatures from 2-of-3 signers
        let mut signatures = Vec::new();
        for signer in &self.signers {
            if let Some(sig) = self.try_sign(signer, &payload).await {
                signatures.push(sig);
                if signatures.len() >= self.threshold {
                    break;
                }
            }
        }

        if signatures.len() < self.threshold {
            return Err(eyre!("not enough signers available"));
        }

        Ok(SignedPayload {
            payload: payload.execution_payload,
            sequencer_signatures: signatures,
        })
    }
}

Validation now checks 2-of-3 signatures. This:

  • Adds HA: any 2 of 3 signers can produce a block
  • Adds a liveness mode: if one signer is down, the other 2 keep producing
  • Still no Byzantine tolerance (assumes signers are honest)

Treat this as an illustrative intermediate step (it adds HA before full BFT), not as a documented launch pattern of any particular chain.

6. Step 2 of decentralization: leader rotation with eligibility

Add proposer rotation:

fn current_proposer(slot: u64, validator_set: &[Address]) -> Address {
    validator_set[(slot as usize) % validator_set.len()]
}

Now each slot has a different leader, picked deterministically. If the leader misses their slot (no block within timeout), the next leader takes over.

This is single-slot finality with rotating leaders — almost a real BFT but without the 2f+1 vote.

The missing piece for full BFT: actual votes that hold the leader accountable. That's the jump to Tendermint/HotStuff (Malachite gives you that).

7. The realistic L1 launch sequence

An illustrative staging (a teaching sketch, not a survey of real chains):

StageConsensusDecentralizationTVL safety
Day 0Single sequencerNoneTrust the team
Month 32-of-3 multisig3 operatorsTrust the 2-of-3 set
Month 12Rotating proposers~10 validatorsLiveness if any 1 stays up
Year 2Real BFT (Tendermint/HotStuff)30+ validators2/3+ Byzantine tolerance

You can ship the L1 at Day 0 — and then progressively decentralize. Hyperliquid (HyperBFT) and Tempo (Simplex) already run stage-4-style BFT over a validator set.

8. Practice

Code-along (no need to run):

  1. Write the SignedPayload struct (your custom block envelope with sequencer signature)
  2. Write CentralizedConsensus::validate_block_pre_execution — full ECDSA signature recovery + comparison
  3. Sketch current_forkchoice — how do you track the current head locally?
  4. Think about: where does the mempool live? (Hint: not in the sequencer — separate component.)

Final check: in one sentence, why is "single-leader consensus" both legitimate and sufficient for launching an L1? If your answer isn't "trust assumption explicitly stated + progressively relaxed," re-read §4 and §7.

Summary (3 lines)

  • ~100-line minimal BFT: Driver (Propose/Prevote/Precommit/Commit) + VoteKeeper (2/3 quorum) + round-robin leader + mpsc broadcast + CentralizedSequencer test harness.
  • Wrap in Consensus trait impl + register via NodeBuilder::consensus → Reth runs your BFT.
  • Production grade adds: bandwidth control + Byzantine attack defence + slashing + reorg handling. Next: validator economics.