HotStuff-2
HotStuff-2 is the BFT consensus protocol that gives ShardoChain its safety property — no two finalized blocks at the same height — and its liveness under fault — the chain advances as long as fewer than f = ⌊(N-1)/3⌋ validators are Byzantine. Affianca PoH (clock) without replacing it.
Three pillars
| Pillar | Role |
|---|---|
| Vote + QC | 2f+1 signatures over (slot, block_hash, view) form a Quorum Certificate. Embedded in the next header's justify_qc. |
| View-change | Single-round leader rotation when the default leader fails to ship a block within the deadline. |
| Lock-on-QC | Once a validator votes for a block, future view-change leaders must re-propose the same block. Cannot rewrite. |
Quorum sizes
| N | f | Quorum |
|---|---|---|
| 4 | 1 | 3 |
| 6 | 1 | 5 |
| 7 (mainnet target) | 2 | 5 |
| 10 | 3 | 7 |
| 21 | 6 | 14 |
Header v4 wire format
struct BlockHeader { version: u32, // = 4 since v5.1.1 height: u64, previous_hash: Hash256, state_root: Hash256, transactions_root: Hash256, receipts_root: Hash256, validator: Address, validator_signature: [u8; 64], slot: u64, // PoH-derived poh_hash: [u8; 32], poh_sequence: u64, justify_qc: Option<QuorumCertificate>, // parent's QC // v4 ADDITIONS view: u64, // 0 = default leader new_view_qc: Option<NewViewQC>, // present iff view > 0 }
Normal path (view = 0)
slot S starts
↓
each validator computes leader_for(active_set, height=tip+1, view=0)
↓
IF leader == self:
produce block H with view=0 + parent_qc
gossip GossipMessage::NewBlock
ELSE:
wait NewBlock(H) within view_timeout_ms
↓
on NewBlock(H) received:
validate (parent QC, signature, state_root)
import H
emit PohVote(slot, hash(H), view=0) signed
gossip
↓
on 2f+1 PohVotes accumulated for (slot, hash(H)):
construct QuorumCertificate(H)
embed in header(H+1).justify_qc
Typical timing on testnet (slot 700 ms, cross-DC RTT 30-80 ms):
- t = 0 ms — slot start
- t = 50–200 ms — block production (mempool drain + parallel exec)
- t = 200–300 ms — gossip propagates to 5 peers
- t = 350–450 ms — votes return
- t = 450–700 ms — next leader assembles parent QC
Finality ~1 slot in steady-state.
View-change algorithm
If the default leader fails to ship a block within
view_timeout_ms (default 800 ms = 1 slot,
mainnet target 2100 ms = 3 slots):
- Each validator increments local view.
- Signs
(height, current_view, locked)and broadcastsGossipMessage::NewView. - On receiving a NewView at
V' > local_view: jump local view to V' (mirror). - Records the signature in
NewViewCollector::pool[(H, V')]. - When
pool[(H, V')].len() ≥ 2f+1: buildNewViewQC<H, V'>. - If self is leader for
(H, V'): produce block withview = V'andnew_view_qc = Some(qc).
Mirror peer views (v5.1.0)
Pre-fix, each node emitted NewView at local view + 1 on
deadline → every node landed in different (H, V)
buckets → 2f+1 distinct signers never accumulated at the same view.
Fix (commit 72cff9c): the producer
reads NewViewCollector::max_view_for(H)
— the highest view observed via gossip — and mirrors it instead of
incrementing. All nodes converge at the highest view.
Lock-on-QC (v5.1.1)
Once a validator imports a block at view ≥ 1
with a valid new_view_qc, it stores
LockedBlock { view, block_hash } at that height.
The leader of the next view (V'+1) must re-propose
the locked block by hash, not produce a competing one.
This prevents a malicious leader at V' > V
from rewriting history. Every honest signer's NewView signature commits
to its lock — the leader cannot strip it without invalidating the
signature.
Accept-gate (v5.1.5)
NewViewCollector::record(...) drops
signatures whose height exceeds local_tip + 32.
Without this gate, a stranded validator broadcasting NewView at
tip+194 would pollute the pool's
prune-cutoff and evict valid entries at our own pending height.
Closes a wedge observed during the chain_id=586 soak.
The window is configurable via chain_config.new_view_accept_ahead (default 32).
Recovery paths
- Tip-divergence probe. StatusReply with same height but different hash arms a fork probe.
- Fork-probe binary search. Walks back through peer headers to find the common ancestor.
- Checkpoint rollback. If common ancestor <
MAX_REORG_DEPTH_PROBE: rollback locally + re-sync forward. - Fork-recovery wipe. If too deep: clear all column families (preserving block headers below cp_height since v5.1.6), apply checkpoint, re-sync from peer.
- Wipe cooldown 60 s + recovery debounce 10 s (v5.1.8) prevent cascade firings.
- Post-wipe peer-discovery retry. If
tip = 0and no peers ahead: re-broadcast GetStatus every 5 s instead of falling to Idle.
Hardening (v5.1.8)
is_finalizedre-verifies every QC signature against the active validator set, dedups duplicate signers, and validates the slot/hash envelope. Pre-fix, only vote count was checked — a malicious peer could fabricate a QC with bogus signatures.vote_guardpersistent on disk with explicit fsync prevents per-validator equivocation across crashes.poh_clock_skew_slotsPrometheus gauge surfaces silent slot drops from clock drift.
Differences from the academic paper
- No pipelined voting. Each height has an independent vote cycle. Simplicity over marginal throughput.
- Ed25519 individual signatures, not BLS aggregates. Trade-off: at N=7 the BLS setup cost outweighs the per-signature gain. BLS is on the post-mainnet roadmap.
- Probabilistic safety + recovery instead of provable forklessness. Sustained burst with cross-DC packet loss can briefly produce competing blocks; the recovery infrastructure reconverges automatically without operator intervention.