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. The reference HyperBFT and bera-reth implementations are scaled-up versions of this skeleton.
Principle (minimum model)
- Five components. State machine (Driver) + vote tallying (VoteKeeper) + leader selection + network broadcast + a
CentralizedSequencerfor 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::gossipsubor RLPx for production. CentralizedSequencerfor 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
Consensustrait impl + register viaNodeBuilder::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. Open Hyperliquid's blog from launch. They all 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
Hyperliquid launched this way. Tempo will almost certainly launch this way. You will too.
2. The architecture
[Mempool] → [Sequencer] → [Reth EL] → [Network broadcast]
│ │ │
│ │ └─ ECDSA sign block
│ └─ Pick tx order
└─ Build block
Three jobs the sequencer does:
- Build: pick transactions, order them, set timestamp
- Sign: cryptographic proof "this came from the sequencer"
- 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)
The economics shift: 2-of-3 multisig is the launch pattern for most production L2s. Optimism, Arbitrum, Base — all run something like this initially, with multisig keys held by team + auditor + node operator.
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
Most L1s launch through these stages:
| Stage | Consensus | Decentralization | TVL safety |
|---|---|---|---|
| Day 0 | Single sequencer | None | Trust the team |
| Month 3 | 2-of-3 multisig | 3 operators | Trust the 2-of-3 set |
| Month 12 | Rotating proposers | ~10 validators | Liveness if any 1 stays up |
| Year 2 | Real BFT (Tendermint/HotStuff) | 30+ validators | 2/3+ Byzantine tolerance |
You can ship the L1 at Day 0 — and then progressively decentralize. Tempo and Hyperliquid are likely at stage 2-3 today, planning for 4 over years.
8. Practice
Code-along (no need to run):
- Write the
SignedPayloadstruct (your custom block envelope with sequencer signature) - Write
CentralizedConsensus::validate_block_pre_execution— full ECDSA signature recovery + comparison - Sketch
current_forkchoice— how do you track the current head locally? - 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.