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
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. 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:
- 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)
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):
| 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. Hyperliquid (HyperBFT) and Tempo (Simplex) already run stage-4-style BFT over a validator set.
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.