tenzro_consensus/hotstuff2.rs
1//! HotStuff-2 BFT consensus implementation
2//!
3//! HotStuff-2 is a two-phase BFT consensus protocol with:
4//! - O(n) linear communication complexity per view
5//! - Two phases: PREPARE and COMMIT (simplified from original HotStuff)
6//! - Optimistic responsiveness
7//! - Leader rotation for liveness
8//!
9//! Protocol flow:
10//! 1. PREPARE: Leader proposes block, validators vote
11//! 2. COMMIT: Leader collects prepare QC, validators vote again
12//! 3. DECIDE: Leader collects commit QC, block is finalized
13
14use crate::config::{ConsensusConfig, ProposerElectionKind};
15use crate::epoch_manager::EpochManager;
16use crate::error::{ConsensusError, Result};
17use crate::finality::{FinalityNotification, FinalityTracker, ForkChoice};
18use crate::leader_reputation::LeaderReputation;
19use crate::mempool::Mempool;
20use crate::proposer::BlockProposer;
21use crate::traits::{ConsensusEngine, SlashingCallback};
22use crate::validator::{
23 ProposerElection, ReputationProposer, RoundRobinProposer, ValidatorSet,
24};
25use crate::vote_state::{LastSignState, MemoryVoteStateStore, VoteStateStore, VoteStep, VrsDecision};
26use crate::voter::{QuorumCertificate, Vote, VoteCollector, VoteType};
27use async_trait::async_trait;
28use dashmap::DashMap;
29use parking_lot::RwLock;
30use std::sync::Arc;
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::time::{Duration, Instant};
33use tokio::sync::broadcast;
34use tokio::time::sleep;
35use tenzro_crypto::bls::BlsKeyPair;
36use tenzro_crypto::composite::{CompositePublicKey, HybridSigner, InMemoryHybridSigner};
37use tenzro_crypto::pq::MlDsaSigningKey;
38use tenzro_crypto::signatures::Ed25519SignerImpl;
39use tenzro_crypto::KeyPair;
40use tenzro_types::block::Block;
41use tenzro_types::primitives::{Address, BlockHeight, Hash};
42use tenzro_types::transaction::{Transaction, SignedTransaction};
43
44/// Cap on the exponent applied to [`ViewChangeTimer::on_timeout`]. With
45/// `backoff_multiplier = 2.0` and `base_timeout = 1000ms`, a cap of 3 yields
46/// the schedule `1s → 2s → 4s → 8s` (saturated). Short cap matches Aptos
47/// AptosBFTv4 / CometBFT production tuning — long view timeouts on a
48/// multi-region fleet cause a pacemaker race where the proposer's block
49/// reaches nearby peers in time but distant peers (cross-Pacific RTT
50/// ~180ms) have already timed out and broadcast `TimeoutMsg`. The
51/// `max_timeout` constant in `HotStuff2Engine::new` provides an absolute
52/// 8-second ceiling on top of this exponent cap.
53const MAX_BACKOFF_EXPONENT: u32 = 3;
54
55/// Upper bound on failed rounds derived from a single committed view gap.
56///
57/// After a long stall the view gap between a block and its parent can be
58/// large; deriving a failure for every view would be O(gap) work for data
59/// that mostly falls outside the reputation window anyway. The cap keeps
60/// derivation bounded while staying deterministic — it is computed purely
61/// from committed metadata, so every node truncates identically.
62const MAX_DERIVED_GAP_FAILURES: u64 = 128;
63
64/// Trait for providing the current state root to the block proposer.
65///
66/// This provides clean dependency inversion: consensus calls this trait
67/// to get the state root without knowing about VM or storage internals.
68pub trait StateRootProvider: Send + Sync {
69 /// Returns the current state root hash.
70 fn current_state_root(&self) -> Hash;
71}
72
73/// Trait for fetching a finalized block by height.
74///
75/// Required for EIP-1559 base-fee derivation: the leader must read the
76/// parent block's metadata (`base_fee_per_gas`, `gas_used`, `gas_limit`)
77/// when proposing the next block, and validators must read the same
78/// parent when re-deriving the expected base fee. After a node restart
79/// `FinalityTracker::finalized_blocks` may be empty for old heights, so
80/// the implementation falls back to RocksDB via `BlockStorage`.
81pub trait BlockProvider: Send + Sync {
82 /// Returns the finalized block at the given height, or `None` if
83 /// missing. Implementations should consult the in-memory finality
84 /// tracker first and fall back to durable storage.
85 fn get_block(&self, height: BlockHeight) -> Option<Block>;
86}
87
88/// The current phase of the HotStuff-2 protocol
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum Phase {
91 /// Preparing a new block
92 Prepare,
93 /// Committing a prepared block
94 Commit,
95 /// Deciding (finalizing) a committed block
96 Decide,
97}
98
99/// View state for HotStuff-2
100#[derive(Debug, Clone)]
101struct ViewState {
102 /// Current view number
103 view: u64,
104
105 /// Current phase
106 phase: Phase,
107
108 /// Current block height
109 height: BlockHeight,
110
111 /// Proposed block for this view (if any)
112 proposed_block: Option<Block>,
113
114 /// Prepare QC for current block
115 prepare_qc: Option<QuorumCertificate>,
116
117 /// Commit QC for current block
118 commit_qc: Option<QuorumCertificate>,
119
120 /// Timestamp when this view started
121 view_start_time: Instant,
122}
123
124/// Manages view change timeouts with exponential backoff AND adaptive
125/// base-timeout tuning.
126///
127/// The base timeout self-tunes to the cluster's observed
128/// quorum-formation latency rather than relying on a hard-coded
129/// default. This is what makes consensus work across an open,
130/// permissionless validator set with arbitrary cross-region
131/// topology — a validator in Frankfurt joining a fleet whose other
132/// members are in Tokyo and São Paulo cannot share a fixed default
133/// with a single-datacenter testnet.
134///
135/// **Algorithm**: every successful view (Commit QC observed) records
136/// the wall-clock time it took from view-start to QC-formation. An
137/// EWMA tracks the rolling mean; the base timeout is set to
138/// `multiplier × ewma`, clamped to `[base_floor, base_ceiling]`. The
139/// multiplier is intentionally generous (2× by default) so a single
140/// slow view doesn't trigger premature view-change on the next.
141///
142/// **Convergence**: when the cluster composition shifts (validator
143/// joins or leaves, region distribution changes), the EWMA tracks the
144/// new steady-state within `~5/alpha` views. With `alpha=0.15` that's
145/// ~33 views. During the transient, exponential backoff (which is
146/// PRESERVED unchanged) absorbs spikes.
147///
148/// **Safety floor + ceiling**: `base_floor=200ms` prevents tight loops
149/// when the cluster is pathologically fast; `base_ceiling=10000ms`
150/// prevents runaway when an outlier delay would otherwise push the
151/// EWMA into the seconds range. Operators can override both via
152/// `ConsensusConfig::with_adaptive_bounds`.
153#[derive(Debug, Clone)]
154struct ViewChangeTimer {
155 /// Adaptive base timeout, retuned after every successful view.
156 base_timeout: Duration,
157
158 /// Current timeout duration (with backoff)
159 current_timeout: Duration,
160
161 /// Maximum timeout duration
162 max_timeout: Duration,
163
164 /// Backoff multiplier (default: 2.0)
165 backoff_multiplier: f64,
166
167 /// Number of consecutive timeouts
168 consecutive_timeouts: u32,
169
170 /// EWMA of observed view-to-QC formation latency, in milliseconds.
171 /// `None` until the first successful view completes.
172 observed_latency_ewma_ms: Option<f64>,
173
174 /// Smoothing factor for the EWMA. 0.15 = ~6-7 view memory.
175 ewma_alpha: f64,
176
177 /// Safety-multiplier applied to EWMA to derive base_timeout.
178 /// 2.0 = "wait 2× the observed mean before assuming view stalled".
179 safety_multiplier: f64,
180
181 /// Floor on the adaptive base timeout. Prevents tight loops.
182 base_floor: Duration,
183
184 /// Ceiling on the adaptive base timeout. Prevents runaway.
185 base_ceiling: Duration,
186}
187
188impl ViewState {
189 fn new(view: u64, height: BlockHeight) -> Self {
190 Self {
191 view,
192 phase: Phase::Prepare,
193 height,
194 proposed_block: None,
195 prepare_qc: None,
196 commit_qc: None,
197 view_start_time: Instant::now(),
198 }
199 }
200
201 fn reset_timer(&mut self) {
202 self.view_start_time = Instant::now();
203 }
204
205 fn time_in_view(&self) -> Duration {
206 Instant::now().duration_since(self.view_start_time)
207 }
208}
209
210impl ViewChangeTimer {
211 fn new(base_timeout: Duration, max_timeout: Duration) -> Self {
212 Self {
213 base_timeout,
214 current_timeout: base_timeout,
215 max_timeout,
216 backoff_multiplier: 2.0,
217 consecutive_timeouts: 0,
218 observed_latency_ewma_ms: None,
219 ewma_alpha: 0.15,
220 safety_multiplier: 2.0,
221 base_floor: Duration::from_millis(200),
222 base_ceiling: Duration::from_millis(10_000),
223 }
224 }
225
226 /// Returns the current timeout duration
227 fn current_timeout(&self) -> Duration {
228 self.current_timeout
229 }
230
231 /// Records a timeout and applies exponential backoff.
232 ///
233 /// The exponent is **capped** at [`MAX_BACKOFF_EXPONENT`] so that two
234 /// honest replicas at different consecutive-timeout counters converge to
235 /// the same tick rate within a bounded number of timeouts. Without the
236 /// cap, replicas at counters 8 and 12 ticked at 256× and 4096× base —
237 /// and `max_timeout` saturated them at different *real* schedules
238 /// because saturation depended on how recently the counter was last
239 /// reset. Capping the exponent makes saturation deterministic.
240 ///
241 /// See task #164 for the testnet halt this prevents.
242 fn on_timeout(&mut self) {
243 self.consecutive_timeouts = self.consecutive_timeouts.saturating_add(1);
244
245 let exponent = self.consecutive_timeouts.min(MAX_BACKOFF_EXPONENT) as i32;
246 let backoff_factor = self.backoff_multiplier.powi(exponent);
247 let new_timeout = self.base_timeout.mul_f64(backoff_factor);
248
249 self.current_timeout = new_timeout.min(self.max_timeout);
250
251 tracing::debug!(
252 consecutive_timeouts = self.consecutive_timeouts,
253 exponent = exponent,
254 current_timeout_ms = self.current_timeout.as_millis(),
255 "View timeout with capped exponential backoff"
256 );
257 }
258
259 /// Records the observed view-to-QC formation latency for a
260 /// successful view and updates the adaptive base timeout.
261 ///
262 /// This is the load-bearing adaptive mechanism. Each successful
263 /// view contributes its wall-clock latency to the EWMA; the
264 /// updated base timeout is `safety_multiplier × ewma`, clamped to
265 /// `[base_floor, base_ceiling]`. The timer then resets backoff
266 /// state (delegated to `on_success`).
267 ///
268 /// Call this from the consensus path that observes Commit QC
269 /// formation. Must be called from `on_success` so a single code
270 /// path owns the post-view bookkeeping.
271 fn record_observed_view_latency(&mut self, observed: Duration) {
272 let observed_ms = observed.as_millis() as f64;
273 let new_ewma = match self.observed_latency_ewma_ms {
274 Some(prev) => self.ewma_alpha * observed_ms + (1.0 - self.ewma_alpha) * prev,
275 None => observed_ms,
276 };
277 self.observed_latency_ewma_ms = Some(new_ewma);
278
279 let target_ms = new_ewma * self.safety_multiplier;
280 let target = Duration::from_millis(target_ms as u64)
281 .max(self.base_floor)
282 .min(self.base_ceiling);
283
284 if target != self.base_timeout {
285 tracing::info!(
286 old_base_ms = self.base_timeout.as_millis(),
287 new_base_ms = target.as_millis(),
288 ewma_ms = new_ewma,
289 observed_ms = observed_ms,
290 "Adaptive view_timeout retuned from observed cluster latency"
291 );
292 self.base_timeout = target;
293 }
294 }
295
296 /// Resets the timeout on successful view completion.
297 ///
298 /// Caller should pass the observed view latency so the adaptive
299 /// algorithm can retune the base timeout. Pass `None` when the
300 /// caller does not have a clean view-start timestamp (e.g.
301 /// recovery paths); the timer then preserves the existing base
302 /// without retuning.
303 fn on_success(&mut self, observed: Option<Duration>) {
304 if let Some(o) = observed {
305 self.record_observed_view_latency(o);
306 }
307 self.consecutive_timeouts = 0;
308 self.current_timeout = self.base_timeout;
309
310 tracing::debug!(
311 base_ms = self.base_timeout.as_millis(),
312 "View completed successfully, timeout reset to adaptive base"
313 );
314 }
315}
316
317/// Messages the consensus engine needs to broadcast via gossipsub.
318/// Uses an mpsc channel to avoid a circular crate dependency:
319/// tenzro-consensus cannot import tenzro-network.
320// `Proposal` carries a full `Block` plus optional TC and NEC; the enum is
321// intentionally large because it crosses an mpsc boundary exactly once per
322// view. Boxing every variant for the sake of one large arm would add an
323// allocation on the consensus hot path and obscure the wire shape — the
324// workspace `Cargo.toml` already lists `large_enum_variant = "allow"` for
325// exactly this reason.
326#[allow(clippy::large_enum_variant)]
327#[derive(Clone)]
328pub enum ConsensusOutMessage {
329 /// A vote this node cast that peers must receive to form quorum
330 Vote(Vote),
331 /// A block proposal the leader is broadcasting to all validators.
332 /// `timeout_certificate` is `Some(_)` only when the leader is recovering
333 /// from a view timeout — it carries 2f+1 timeout signatures from the
334 /// previous view so peers can verify the new view was legitimately
335 /// abandoned (Jolteon vote rule, DiemBFT v4 §3.5).
336 ///
337 /// `no_endorsement_certificate` is `Some(_)` when the leader is proposing
338 /// a *new* block at view N+1 after view N timed out — it carries f+1
339 /// no-endorsement signatures from view N proving the predecessor's QC
340 /// was not withheld. MonadBFT (arXiv:2502.20692) — closes the tail-fork
341 /// MEV vulnerability. If absent, receivers must require the proposal to
342 /// repropose the high-tip block; a fresh block with neither a NEC nor
343 /// a high-tip parent is rejected.
344 ///
345 /// `high_qc_view` is the leader's local highest-Prepare-QC view at the
346 /// moment of proposing (#171, Aptos SyncInfo pattern). Receivers use it to
347 /// fast-forward their own `high_qc_view` if the proposer is ahead, which
348 /// shrinks the gap a lagging replica has to close before it can vote.
349 /// Must satisfy `high_qc_view < view`.
350 Proposal {
351 block: Block,
352 proposer: Address,
353 round: u64,
354 view: u64,
355 high_qc_view: u64,
356 timeout_certificate: Option<crate::timeout::TimeoutCertificate>,
357 no_endorsement_certificate: Option<crate::timeout::NoEndorsementCertificate>,
358 },
359 /// A pacemaker timeout broadcast emitted on local view-timer expiry.
360 /// Carries the sender's current view so a lagging peer can fast-forward
361 /// (DiemBFT v4 §3.5 backward-sync channel; phase 1 of #164).
362 Timeout(crate::timeout::TimeoutMsg),
363 /// A no-endorsement attestation broadcast emitted on local view-timer
364 /// expiry. Aggregates into an f+1 NoEndorsementCertificate that the next
365 /// leader attaches to its proposal when proposing a new block (MonadBFT,
366 /// arXiv:2502.20692).
367 NoEndorsement(crate::timeout::NoEndorsementMsg),
368}
369
370/// HotStuff-2 consensus engine
371pub struct HotStuff2Engine {
372 /// Node's classical keypair (Ed25519). Used for the classical leg of the
373 /// composite signature and to derive the on-chain address.
374 keypair: Arc<KeyPair>,
375
376 /// Node's ML-DSA-65 signing key (FIPS 204). Used for the post-quantum leg
377 /// of the composite vote signature. Mandatory under Wave 3d — there is no
378 /// classical-only fallback.
379 pq_signing_key: Arc<MlDsaSigningKey>,
380
381 /// Node's BLS12-381 (min_pk) keypair. Used for the third signature leg
382 /// emitted with every vote (`Vote.bls_signature`); per-vote BLS sigs at a
383 /// given (view, height, block, vote_type) aggregate into the QC's
384 /// `bls_aggregate` 96-byte G2 point. Mandatory — there is no fallback to
385 /// classical-only or hybrid-only QCs.
386 bls_signing_key: Arc<BlsKeyPair>,
387
388 /// Cached composite public key (classical + ML-DSA-65 verifying key) that
389 /// is embedded into every vote this node casts. Validators receiving the
390 /// vote bind this against the registered hybrid key for the voter address.
391 composite_public_key: CompositePublicKey,
392
393 /// Node's address
394 address: Address,
395
396 /// Consensus configuration
397 config: Arc<ConsensusConfig>,
398
399 /// Epoch manager
400 epoch_manager: Arc<EpochManager>,
401
402 /// Transaction mempool
403 mempool: Arc<Mempool>,
404
405 /// Block proposer
406 proposer: Arc<BlockProposer>,
407
408 /// Vote collector
409 vote_collector: Arc<RwLock<Option<Arc<VoteCollector>>>>,
410
411 /// Finality tracker
412 finality_tracker: Arc<FinalityTracker>,
413
414 /// Current view state
415 view_state: Arc<RwLock<ViewState>>,
416
417 /// View change timer
418 view_timer: Arc<RwLock<ViewChangeTimer>>,
419
420 /// Proposed blocks by hash
421 blocks: Arc<DashMap<Hash, Block>>,
422
423 /// Fork choice: indexes proposed blocks alongside their QCs (when known)
424 /// and resolves "best block at height" via the highest-Prepare-QC-view
425 /// rule. Mirrors `self.blocks` for insert/evict and is updated with QCs
426 /// at QC-formation time (driven from `try_form_qc_and_drive_phase`).
427 /// Block production reads `select_best_block(parent_height)` to pick
428 /// a non-finalized parent on the canonical fork; falls back to the
429 /// finality tracker's last finalized hash when fork choice has no
430 /// candidate (cold start, post-restart pre-warmup).
431 fork_choice: Arc<ForkChoice>,
432
433 /// Running state
434 is_running: Arc<RwLock<bool>>,
435
436 /// Operator drain flag. When `true`, this replica stops proposing new
437 /// blocks while elected leader but keeps voting on peers' proposals —
438 /// used by `tenzro node drain` (the `tenzro_setDraining` admin RPC) for
439 /// graceful validator rollout. Mirrors the `is_running` lock idiom.
440 drain: Arc<RwLock<bool>>,
441
442 /// Shutdown signal
443 shutdown_tx: Arc<RwLock<Option<broadcast::Sender<()>>>>,
444
445 /// Slashing callback for punishing equivocating validators
446 slashing_callback: Option<Arc<dyn SlashingCallback>>,
447
448 /// State root provider for block proposals
449 state_root_provider: Option<Arc<dyn StateRootProvider>>,
450
451 /// Parent-block provider used during proposal and validation to
452 /// re-derive the EIP-1559 base fee. Optional only for tests; in
453 /// production the node wires a `BlockProvider` backed by the
454 /// finality tracker + RocksDB block store.
455 block_provider: Option<Arc<dyn BlockProvider>>,
456
457 /// Outbound channel for broadcasting consensus messages via gossipsub.
458 /// Uses mpsc to avoid a circular crate dependency (tenzro-consensus cannot
459 /// import tenzro-network). The event_loop in tenzro-node drains this channel
460 /// and publishes messages to the gossipsub swarm.
461 consensus_out_tx: Option<tokio::sync::mpsc::UnboundedSender<ConsensusOutMessage>>,
462
463 /// Persistent vote state — refuses to sign any (view, height, step) ≤
464 /// last persisted, and persists each new vote with fsync **before** the
465 /// vote is broadcast. Mirrors CometBFT `FilePVLastSignState`. Defaults to
466 /// an in-memory store for tests; production wires a `FileVoteStateStore`
467 /// rooted at `<data_dir>/consensus/last_sign.json`.
468 vote_state_store: Arc<dyn VoteStateStore>,
469
470 /// The highest view at which this replica has observed a Prepare QC.
471 /// Used as the `high_qc_view` field of any [`crate::timeout::TimeoutMsg`]
472 /// this replica emits. The new leader after a TC formation extends from
473 /// `max{tc.signers[i].high_qc_view}` so this monotonic value is the
474 /// load-bearing signal that the next view's proposal lands on the
475 /// freshest committed branch.
476 ///
477 /// Monotonic: only updated when a strictly higher view's Prepare QC is
478 /// observed, never reset on view advance. Initial value 0 means "no QC
479 /// observed yet" — the genesis case.
480 high_qc_view: Arc<RwLock<u64>>,
481
482 /// The most recent [`crate::timeout::TimeoutCertificate`] this replica
483 /// has formed or learned of. Set by `on_timeout_msg` once 2f+1 timeouts
484 /// at the same view aggregate; cleared (or replaced) when a new TC at a
485 /// higher view is formed. The leader at view N+1 attaches this TC to
486 /// its proposal so receivers can run `safe_to_extend` (Jolteon Fig 2
487 /// vote rule).
488 last_round_tc: Arc<RwLock<Option<crate::timeout::TimeoutCertificate>>>,
489
490 /// 2f+1 [`crate::timeout::TimeoutMsg`] aggregator. Initialized lazily on
491 /// `start()` once the validator set is known. Re-built on epoch
492 /// transition alongside the vote collector.
493 timeout_collector: Arc<RwLock<Option<Arc<crate::timeout::TimeoutCollector>>>>,
494
495 /// The most recent [`crate::timeout::NoEndorsementCertificate`] this
496 /// replica has formed or learned of. Set by `on_no_endorsement_msg` once
497 /// f+1 attestations at the same view aggregate; cleared (or replaced)
498 /// when a new NEC at a higher view is formed. The leader at view N+1
499 /// attaches this NEC to its proposal when it cannot repropose a high-tip
500 /// block — closes the tail-fork MEV vulnerability (MonadBFT,
501 /// arXiv:2502.20692).
502 last_round_nec: Arc<RwLock<Option<crate::timeout::NoEndorsementCertificate>>>,
503
504 /// f+1 [`crate::timeout::NoEndorsementMsg`] aggregator. Initialized
505 /// lazily on `start()` and rebuilt on epoch transition alongside
506 /// `timeout_collector`.
507 nec_collector: Arc<RwLock<Option<Arc<crate::timeout::NoEndorsementCollector>>>>,
508
509 /// Highest view at which this replica has emitted a proposal. Acts as a
510 /// monotonic guard so a leader CANNOT broadcast two different proposals
511 /// for the same view — even if `propose_block_internal()` awaited long
512 /// enough for the pacemaker to advance the view (e.g. Bracha boost from
513 /// remote TimeoutMsgs) and the next tick re-enters the propose path on
514 /// the new view. Both AptosBFT (`RoundManager::process_certificates`) and
515 /// DiemBFT (`EventProcessor::process_new_round_event`) gate proposal
516 /// generation on this same monotonic-round invariant; without it, the
517 /// honest leader can self-equivocate by racing a mempool-snapshot-drift
518 /// rebuild against its own view-change handler. Sentinel value `u64::MAX`
519 /// means "never proposed" (no view will compare-and-swap past it because
520 /// `view < u64::MAX` always); we initialise to `0` and use a CAS-style
521 /// fetch_max to enforce strict monotonicity.
522 last_proposed_view: Arc<AtomicU64>,
523
524 /// Receiver-side proposal dedup keyed by `(proposer, view, block_hash)`.
525 /// Set of `(view, proposer_addr_first_4_bytes_u32, block_hash)` so a
526 /// duplicate of a proposal we've already seen at this view from this
527 /// proposer is dropped at the door — BEFORE running the catchup, vote,
528 /// and vote-state-store paths. Mirrors AptosBFT's
529 /// `EpochManager::process_message` seen-set dedup. Without this, gossipsub
530 /// IHAVE/IWANT replay (and any pre-fix in-flight proposals from the buggy
531 /// run before the equivocation patch) keep firing the vote-state-store's
532 /// double-sign refuse path and noise up the equivocation telemetry —
533 /// even though the local replica correctly refuses to vote twice. The
534 /// dedup set is bounded by `PROPOSAL_DEDUP_VIEW_WINDOW` and pruned in
535 /// `advance_view` alongside the other per-view caches.
536 proposal_dedup: Arc<DashMap<(u64, Hash), ()>>,
537
538 /// Strategy for selecting the leader of a given round/view. Resolved
539 /// from [`ConsensusConfig::proposer_election`] at construction time.
540 /// `RoundRobinProposer` (`view % N`) for tests, `ReputationProposer`
541 /// (Aptos LeaderReputation) for production.
542 proposer_election: Arc<dyn ProposerElection>,
543
544 /// Optional shared reputation state. Populated when
545 /// `config.proposer_election == ProposerElectionKind::Reputation`. The
546 /// engine feeds this with `record_round_outcome` and `record_round_voters`
547 /// after each round closes so the proposer-election strategy reflects
548 /// observed behaviour.
549 reputation: Option<Arc<LeaderReputation>>,
550
551 /// Behind-tip hint published to the block-sync engine.
552 ///
553 /// When a peer proposal arrives at a height strictly above our local
554 /// height (`on_proposal` rejects it with `InvalidHeight`), the rejected
555 /// height is sent here. Block-sync subscribes via
556 /// [`subscribe_behind_hint`](Self::subscribe_behind_hint) and temporarily
557 /// drops its engage tolerance to zero, closing the 1..=SLOT_IMPORT_TOLERANCE
558 /// dead zone where catchup-on-proposal (exactly +1, in-memory parent only)
559 /// can't help but block-sync wouldn't normally engage. `watch` notifies on
560 /// every send, so repeated proposals keep re-arming the hint.
561 behind_hint_tx: tokio::sync::watch::Sender<u64>,
562}
563
564/// Evicts the lowest-`view` cached blocks until the map holds at most
565/// `max_entries`. Returns the number evicted.
566///
567/// The height-keyed `retain` that runs before this cannot bound growth when
568/// consensus is wedged at a single height: every failed view there proposes a
569/// *distinct* block hash all sharing the stuck height, so the height window
570/// reclaims none of them. This count cap is the backstop. Evicting the
571/// lowest-`view` entries is safe — those are stale competing proposals from
572/// old failed views; a future winning proposal extends the QC'd chain, never a
573/// stale same-height sibling, so none of the evicted blocks can be a future
574/// proposal's parent.
575fn evict_excess_blocks_by_view(blocks: &DashMap<Hash, Block>, max_entries: usize) -> usize {
576 if blocks.len() <= max_entries {
577 return 0;
578 }
579 let mut by_view: Vec<(Hash, u64)> = blocks
580 .iter()
581 .map(|e| (*e.key(), e.value().header.view))
582 .collect();
583 by_view.sort_unstable_by_key(|(_, view)| *view);
584 let excess = blocks.len().saturating_sub(max_entries);
585 let mut evicted = 0;
586 for (hash, _) in by_view.into_iter().take(excess) {
587 if blocks.remove(&hash).is_some() {
588 evicted += 1;
589 }
590 }
591 evicted
592}
593
594impl HotStuff2Engine {
595 /// Creates a new HotStuff-2 consensus engine.
596 ///
597 /// `keypair` is the classical Ed25519 keypair (for the address and the
598 /// classical leg of the composite signature). `pq_signing_key` is the
599 /// ML-DSA-65 (FIPS 204) signing key for the post-quantum leg.
600 /// `bls_signing_key` is the BLS12-381 (min_pk) keypair for the third leg
601 /// that aggregates into the QC `bls_aggregate`. All three are **mandatory**
602 /// — there is no classical-only or hybrid-only fallback path.
603 pub fn new(
604 keypair: KeyPair,
605 pq_signing_key: MlDsaSigningKey,
606 bls_signing_key: BlsKeyPair,
607 config: ConsensusConfig,
608 epoch_manager: EpochManager,
609 ) -> Self {
610 // Convert tenzro_crypto::Address (20 bytes) to tenzro_types::Address (32 bytes)
611 let crypto_addr = keypair.address();
612 let mut addr_bytes = [0u8; 32];
613 addr_bytes[..20].copy_from_slice(crypto_addr.as_bytes());
614 let address = Address::new(addr_bytes);
615 let config = Arc::new(config);
616 let epoch_manager = Arc::new(epoch_manager);
617 let mempool = Arc::new(Mempool::new(config.clone()));
618 let proposer = Arc::new(BlockProposer::new(mempool.clone(), config.clone()));
619 let finality_tracker = Arc::new(FinalityTracker::new());
620 let fork_choice = Arc::new(ForkChoice::new(finality_tracker.clone()));
621
622 let initial_height = finality_tracker.finalized_height() + 1u64;
623 let view_state = ViewState::new(0, initial_height);
624
625 // Create view timer with base timeout from config and an absolute
626 // 8-second ceiling. The exponent cap (`MAX_BACKOFF_EXPONENT = 3`)
627 // alone yields `base × 2^3 = 8×` base; for the default 1s base this
628 // is 8s. The explicit `max_timeout` here is the safety floor in case
629 // a deployment overrides `view_timeout_ms` upward.
630 let base_timeout = config.view_timeout();
631 let max_timeout = Duration::from_secs(8);
632 let view_timer = ViewChangeTimer::new(base_timeout, max_timeout);
633
634 let composite_public_key = CompositePublicKey::new(
635 keypair.public_key().clone(),
636 pq_signing_key.verifying_key_bytes().to_vec(),
637 );
638
639 // Resolve proposer-election strategy from config. The `Reputation`
640 // path also constructs the shared `LeaderReputation` state that the
641 // engine feeds with round outcomes — `RoundRobin` skips it entirely.
642 let n = epoch_manager.current_validator_set().len();
643 let (proposer_election, reputation): (
644 Arc<dyn ProposerElection>,
645 Option<Arc<LeaderReputation>>,
646 ) = match config.proposer_election {
647 ProposerElectionKind::RoundRobin => {
648 (Arc::new(RoundRobinProposer::new()), None)
649 }
650 ProposerElectionKind::Reputation => {
651 let rep = Arc::new(LeaderReputation::new(n));
652 (Arc::new(ReputationProposer::new(rep.clone())), Some(rep))
653 }
654 };
655
656 Self {
657 keypair: Arc::new(keypair),
658 pq_signing_key: Arc::new(pq_signing_key),
659 bls_signing_key: Arc::new(bls_signing_key),
660 composite_public_key,
661 address,
662 config,
663 epoch_manager,
664 mempool,
665 proposer,
666 vote_collector: Arc::new(RwLock::new(None)),
667 finality_tracker,
668 view_state: Arc::new(RwLock::new(view_state)),
669 view_timer: Arc::new(RwLock::new(view_timer)),
670 blocks: Arc::new(DashMap::new()),
671 fork_choice,
672 is_running: Arc::new(RwLock::new(false)),
673 drain: Arc::new(RwLock::new(false)),
674 shutdown_tx: Arc::new(RwLock::new(None)),
675 slashing_callback: None,
676 state_root_provider: None,
677 block_provider: None,
678 consensus_out_tx: None,
679 vote_state_store: Arc::new(MemoryVoteStateStore::new()),
680 high_qc_view: Arc::new(RwLock::new(0)),
681 last_round_tc: Arc::new(RwLock::new(None)),
682 timeout_collector: Arc::new(RwLock::new(None)),
683 last_round_nec: Arc::new(RwLock::new(None)),
684 nec_collector: Arc::new(RwLock::new(None)),
685 last_proposed_view: Arc::new(AtomicU64::new(0)),
686 proposal_dedup: Arc::new(DashMap::new()),
687 proposer_election,
688 reputation,
689 behind_hint_tx: tokio::sync::watch::Sender::new(0),
690 }
691 }
692
693 /// Sets the slashing callback for punishing equivocating validators
694 pub fn with_slashing_callback(mut self, callback: Arc<dyn SlashingCallback>) -> Self {
695 self.slashing_callback = Some(callback);
696 self
697 }
698
699 /// Sets the state root provider for block proposals.
700 ///
701 /// When set, the block proposer will query the current state root
702 /// from this provider instead of using a default empty hash.
703 pub fn with_state_root_provider(mut self, provider: Arc<dyn StateRootProvider>) -> Self {
704 self.state_root_provider = Some(provider);
705 self
706 }
707
708 /// Sets the parent-block provider used for EIP-1559 base-fee
709 /// derivation. Required in production; absent providers cause the
710 /// engine to fall back to a genesis-edge base fee on every block,
711 /// which is wrong on any non-genesis block.
712 pub fn with_block_provider(mut self, provider: Arc<dyn BlockProvider>) -> Self {
713 self.block_provider = Some(provider);
714 self
715 }
716
717 /// Wires up the outbound gossipsub channel so votes and proposals are
718 /// broadcast to peers. Must be called before `start()`.
719 pub fn with_consensus_out(
720 mut self,
721 tx: tokio::sync::mpsc::UnboundedSender<ConsensusOutMessage>,
722 ) -> Self {
723 self.consensus_out_tx = Some(tx);
724 self
725 }
726
727 /// Installs a persistent vote-state store. Production callers should use
728 /// `vote_state::open_default_file_store(data_dir)` to get a fsync-backed
729 /// `FileVoteStateStore`. Tests can pass a `MemoryVoteStateStore` (which
730 /// is also the default if this builder is never called).
731 pub fn with_vote_state_store(mut self, store: Arc<dyn VoteStateStore>) -> Self {
732 self.vote_state_store = store;
733 self
734 }
735
736 /// Wires the Spec-2 per-DID admission controller into the engine's
737 /// mempool. Forwards to [`Mempool::set_admission`]. May be called at
738 /// most once per engine instance (the underlying `OnceLock` rejects
739 /// double-wiring with `ConsensusError::AlreadyStarted`).
740 ///
741 /// Must be called **after** `HotStuff2Engine::new` (which constructs
742 /// the mempool) but **before** the engine starts admitting traffic in
743 /// production. Until this is called, the mempool falls back to the
744 /// legacy size/count-only path — fine for tests and the very first
745 /// genesis tick, not fine for a public-facing node.
746 pub fn set_admission(
747 &self,
748 admission: Arc<crate::admission::AdmissionController>,
749 ) -> crate::Result<()> {
750 self.mempool.set_admission(admission)
751 }
752
753 /// Read-only access to the mempool — exposed so node startup can hand
754 /// the same `Arc<Mempool>` to RPC handlers (`tenzro_getMempoolStats`,
755 /// `tenzro_getMempoolLane`) without going through this engine.
756 pub fn mempool(&self) -> &Arc<Mempool> {
757 &self.mempool
758 }
759
760 /// Snapshot of the highest view at which this replica has observed a
761 /// Prepare QC. Monotonic; 0 means "no QC observed yet" (genesis).
762 ///
763 /// Used by the HTTP `/ready` endpoint to gate liveness on consensus
764 /// progress: a pod that has caught up on block height but whose
765 /// `high_qc_view` lags the network is still mid-bootstrap and must
766 /// not be marked Ready.
767 pub fn high_qc_view(&self) -> u64 {
768 *self.high_qc_view.read()
769 }
770
771 /// Snapshot of the local view counter. Read-only — use carefully
772 /// (the view advances asynchronously as messages arrive).
773 pub fn current_view(&self) -> u64 {
774 self.view_state.read().view
775 }
776
777 /// Snapshot of the engine's working height — the NEXT height it will
778 /// vote on or propose (storage tip + 1 on a healthy node). Used by
779 /// block-sync to detect an engine that has fallen behind local storage
780 /// (gossip imports advance storage without touching the engine).
781 pub fn current_height(&self) -> BlockHeight {
782 self.view_state.read().height
783 }
784
785 /// Sets the operator drain flag. While draining, this replica does not
786 /// propose blocks as leader (it keeps voting on peers' proposals).
787 /// Toggled by the `tenzro_setDraining` admin RPC / `tenzro node drain`.
788 pub fn set_draining(&self, draining: bool) {
789 *self.drain.write() = draining;
790 }
791
792 /// Returns `true` if this replica is currently draining (not proposing).
793 pub fn is_draining(&self) -> bool {
794 *self.drain.read()
795 }
796
797 /// Returns `true` if this replica is the elected leader for *any* of
798 /// the next `lookahead_views` consecutive views. Used by the
799 /// `tenzro_gracefulExit` admin RPC to wait until we can step down
800 /// without forcing a TC round on the next leader rotation.
801 ///
802 /// Conservative: if leader election fails for any view in the window
803 /// (e.g. validator set unknown), returns `true` so the operator
804 /// keeps waiting rather than exiting blindly.
805 pub fn is_leader_in_next_views(&self, lookahead_views: u64) -> bool {
806 let base_view = self.view_state.read().view;
807 let validator_set = self.validator_set();
808 let current_epoch = self.epoch_manager.current_epoch();
809 let epoch = current_epoch.number;
810 // Per-epoch fixed seed anchor — identical on every node for the
811 // whole epoch, unlike the local finalized tip which differs by a
812 // block or two across the fleet at any instant.
813 let prev_block_id = current_epoch.seed_anchor.0;
814
815 for offset in 0..lookahead_views {
816 let view = base_view.saturating_add(offset);
817 match self
818 .proposer_election
819 .select_leader(view, epoch, prev_block_id, &validator_set)
820 {
821 Ok(leader) if leader == self.address => return true,
822 Ok(_) => continue,
823 Err(_) => return true, // conservative
824 }
825 }
826 false
827 }
828
829 /// Sends a message to the outbound gossipsub channel.
830 /// Silently drops the message if no channel has been wired up.
831 fn send_out(&self, msg: ConsensusOutMessage) {
832 let kind = match &msg {
833 ConsensusOutMessage::Vote(_) => "Vote",
834 ConsensusOutMessage::Proposal { .. } => "Proposal",
835 ConsensusOutMessage::Timeout(_) => "Timeout",
836 ConsensusOutMessage::NoEndorsement(_) => "NoEndorsement",
837 };
838 match self.consensus_out_tx {
839 Some(ref tx) => {
840 match tx.send(msg) {
841 Ok(()) => tracing::info!(kind = kind, "consensus.send_out: queued to event_loop"),
842 Err(e) => tracing::warn!(kind = kind, error = %e, "consensus.send_out: tx.send FAILED (rx dropped?)"),
843 }
844 }
845 None => {
846 tracing::warn!(kind = kind, "consensus.send_out: NO TX wired (consensus_out_tx is None)");
847 }
848 }
849 }
850
851 /// Resumes consensus from a previously persisted block height.
852 ///
853 /// Called during node startup to seed the consensus engine with the
854 /// last known finalized height from storage. Without this, the engine
855 /// always starts proposing from height 1, creating duplicate blocks
856 /// that overlap with already-persisted data.
857 ///
858 /// Also consults `vote_state_store` to advance the local view past any
859 /// vote already cast in a prior run. Without this jump, after an
860 /// unproductive run that votes through (say) view=62 at height=1
861 /// without finalizing, a fresh boot would propose at view=0,1,2,…
862 /// and every vote would be refused by the CometBFT-style CheckHRS rule
863 /// (`vote_state.rs::check_vrs`) — wedging the chain.
864 ///
865 /// **The persisted `last_signed_view` is a strict, height-independent
866 /// signing ceiling.** This mirrors:
867 /// - DiemBFT v4 `SafetyData::last_voted_round` — checked
868 /// synchronously in `verify_and_update_last_vote_round()` against
869 /// `round`, never against `(round, height)`.
870 /// - CometBFT privValidator `LastSignState{Height,Round,Step}` —
871 /// `is_strictly_after` enforces lex order with view first, so a
872 /// persisted `(v=N, h=H-1, Commit)` blocks signing any
873 /// `(v ≤ N, h=H, *)` next height.
874 ///
875 /// The previous implementation gated the view-jump on
876 /// `persisted.height == next_height.0`, which is wrong: in the
877 /// 2026-04-30 testnet wedge we observed persisted `(v=29999, h=29988,
878 /// Commit)` and `next_height = 29989`, so the guard skipped, the
879 /// engine booted at view ~29988, advanced via timeouts up to view
880 /// 29997 < persisted ceiling 29999, and `vote_state` correctly
881 /// refused → wedge. The fix: adopt the persisted view ceiling
882 /// unconditionally — `state.view = max(state.view, persisted.view + 1)`.
883 ///
884 /// Must be called **before** `start()`.
885 pub fn resume_from_height(&self, height: BlockHeight) {
886 // Compute the next height we're going to propose at:
887 // - if storage has a finalized tip > 0, propose at tip+1
888 // - if nothing has finalized (height=0), propose at height=1
889 let next_height = if height.0 == 0 {
890 BlockHeight(1)
891 } else {
892 // Update the finality tracker so it knows the chain tip
893 self.finality_tracker.set_initial_height(height);
894 height + 1u64
895 };
896
897 // Recover the certifying view of the latest finalized block via the
898 // wired BlockProvider. The block at `height` was finalized through a
899 // 2f+1 Commit-QC at `block.header.view`; this view is the highest
900 // Prepare-QC the network has produced, so `high_qc_view` must be at
901 // least `block.header.view` on boot. Without this, a freshly-booted
902 // leader proposes from a stale lock (default 0), the network already
903 // holds a higher lock and refuses to vote, the view times out, and
904 // the chain live-locks across all validators rebooting in sequence.
905 //
906 // This is the Diem/Aptos Safety Rules pattern: persisted highest_qc
907 // is restored on boot so the engine cannot regress its lock.
908 // Mirrors the `commit_qc_view` parameter accepted by
909 // `resume_from_synced_height`, but recovered from storage instead of
910 // passed by the block-sync caller.
911 let recovered_commit_qc_view: Option<u64> = if height.0 > 0 {
912 self.block_provider
913 .as_ref()
914 .and_then(|bp| bp.get_block(height))
915 .map(|block| block.header.view)
916 } else {
917 None
918 };
919
920 let mut state = self.view_state.write();
921 state.height = next_height;
922 // Set view to at least match height for consistency
923 if height.0 > state.view {
924 state.view = height.0;
925 }
926 // Bump view past the certifying QC view — we must not propose or
927 // vote at any view ≤ the view that already produced a Commit-QC.
928 if let Some(commit_qc_view) = recovered_commit_qc_view {
929 let target_view = commit_qc_view.saturating_add(1);
930 if target_view > state.view {
931 state.view = target_view;
932 }
933 }
934
935 // Consult persisted vote state. The persisted view is a
936 // monotonic signing ceiling regardless of which height it was
937 // recorded for — adopt it unconditionally so the engine cannot
938 // boot below the safety floor and wedge.
939 match self.vote_state_store.load() {
940 Ok(persisted) => {
941 let ceiling = persisted.view.saturating_add(1);
942 if ceiling > state.view {
943 let prev_view = state.view;
944 state.view = ceiling;
945 tracing::info!(
946 prev_view,
947 resumed_view = ceiling,
948 next_height = %next_height,
949 persisted_last_vote_view = persisted.view,
950 persisted_last_vote_height = persisted.height,
951 cross_height = persisted.height != next_height.0,
952 "Consensus engine: jumping view past persisted last_vote ceiling to avoid CheckHRS wedge"
953 );
954 }
955 }
956 Err(e) => {
957 tracing::warn!(error = %e, "vote_state_store.load() failed during resume — view not adjusted");
958 }
959 }
960
961 // Drop the view_state lock before acquiring high_qc_view to keep
962 // lock acquisition order consistent with the rest of the engine.
963 drop(state);
964
965 // Restore high_qc_view from the recovered commit-QC view. In
966 // HotStuff-2, high_qc_view tracks the highest Prepare-QC view; a
967 // Commit-QC at view V implies a Prepare-QC at view V (commits are
968 // produced one phase after Prepare in the same view), so using
969 // `recovered_commit_qc_view` here is sound.
970 if let Some(commit_qc_view) = recovered_commit_qc_view {
971 let mut hqc = self.high_qc_view.write();
972 if commit_qc_view > *hqc {
973 let prev_hqc = *hqc;
974 *hqc = commit_qc_view;
975 tracing::info!(
976 prev_high_qc_view = prev_hqc,
977 restored_high_qc_view = commit_qc_view,
978 from_height = %height,
979 "Consensus engine: restored high_qc_view from finalized block header"
980 );
981 }
982
983 // Record a synthetic LastSignState at (view = commit_qc_view,
984 // height = height, step = Commit) so the persistent signing
985 // ceiling reflects the certified state. A future
986 // `is_strictly_after` check will refuse any vote at
987 // (v ≤ commit_qc_view, *) on this height.
988 let synthetic = LastSignState {
989 version: 1,
990 view: commit_qc_view,
991 height: height.0,
992 step: VoteStep::Commit,
993 block_hash: None,
994 signature: None,
995 };
996 if let Err(e) = self.vote_state_store.record(&synthetic) {
997 tracing::warn!(
998 error = %e,
999 resumed_height = %height,
1000 commit_qc_view,
1001 "vote_state_store.record() failed during resume — engine \
1002 will still advance its in-memory state, but a crash \
1003 before the next legitimate vote could regress the \
1004 signing ceiling"
1005 );
1006 }
1007 } else if height.0 > 0 {
1008 // We have a finalized height but couldn't recover the block —
1009 // either no block_provider is wired (test paths) or storage
1010 // returned None. Log a warning so this is visible in operator
1011 // dashboards; the engine will still boot but with the lock
1012 // unrestored, which is the pre-fix wedge condition.
1013 tracing::warn!(
1014 resumed_height = %height,
1015 block_provider_wired = self.block_provider.is_some(),
1016 "Consensus engine: could not recover commit_qc_view from \
1017 storage on boot — high_qc_view NOT restored. This may \
1018 cause a consensus live-lock if peers hold a higher lock."
1019 );
1020 }
1021
1022 let final_view = self.view_state.read().view;
1023 tracing::info!(
1024 stored_height = %height,
1025 next_height = %next_height,
1026 view = final_view,
1027 recovered_commit_qc_view = ?recovered_commit_qc_view,
1028 "Consensus engine resuming from stored height"
1029 );
1030 }
1031
1032 /// Resumes the consensus engine after catching up to `height` via the
1033 /// block-sync RPC, with the certifying commit-QC view `commit_qc_view`.
1034 ///
1035 /// This differs from [`Self::resume_from_height`] in two ways:
1036 ///
1037 /// 1. It is called **mid-life** (after `start()` is already running on
1038 /// other nodes) — the local engine just imported a sequence of
1039 /// finalized blocks and needs to slot back into the live view
1040 /// cadence without proposing or voting at any view ≤ `commit_qc_view`.
1041 /// 2. The caller has a verified `QuorumCertificate` for the block at
1042 /// `height`, so we know the network has *already* committed at
1043 /// view `commit_qc_view`. The local view ceiling must be at least
1044 /// `commit_qc_view + 1` — anything lower would let us double-vote
1045 /// in a view that has already produced a 2f+1 commit.
1046 ///
1047 /// Concretely:
1048 /// * Updates the finality tracker to `height` so transaction
1049 /// execution and RPC `getBlock` queries return correct values.
1050 /// * Advances `view_state.height` to `height + 1` (next height to
1051 /// propose / vote at).
1052 /// * Sets `view_state.view = max(state.view, commit_qc_view + 1)`.
1053 /// * Bumps the persistent `vote_state_store` ceiling so that an
1054 /// immediate crash-and-restart cannot re-vote below
1055 /// `commit_qc_view`.
1056 /// * Updates `high_qc_view` to `commit_qc_view`. NOTE: `high_qc_view`
1057 /// in HotStuff-2 tracks the highest *Prepare-QC* view; a
1058 /// **commit-QC** at view V implies a Prepare-QC at view V (commits
1059 /// are produced one phase after Prepare in the same view), so
1060 /// using `commit_qc_view` here is sound.
1061 ///
1062 /// Modeled on Lighthouse's `BeaconChain::reset_post_sync` and
1063 /// Aptos' `ConsensusObserver::on_synced_to_block`.
1064 ///
1065 /// **Caller contract:** the QC at `commit_qc_view` MUST already be
1066 /// signature-verified against the validator set known at the
1067 /// certifying epoch. This method does NOT re-verify — it trusts the
1068 /// caller. The block-sync engine in `tenzro-node` is the canonical
1069 /// caller and performs verification before invoking this.
1070 pub fn resume_from_synced_height(&self, height: BlockHeight, commit_qc_view: u64) {
1071 // Tell the finality tracker we're synced to `height`. Mirrors the
1072 // genesis path in `resume_from_height`.
1073 self.finality_tracker.set_initial_height(height);
1074
1075 // Compute the next height we'll propose/vote at.
1076 let next_height = if height.0 == 0 {
1077 BlockHeight(1)
1078 } else {
1079 height + 1u64
1080 };
1081
1082 // Adopt the QC view as a signing ceiling. View must satisfy:
1083 // view > commit_qc_view (so we can't re-vote in the certified view)
1084 // view ≥ next_height (HRS lex order: view is leading dimension)
1085 let target_view = commit_qc_view.saturating_add(1).max(next_height.0);
1086
1087 // Apply the view-state update under one write lock.
1088 {
1089 let mut state = self.view_state.write();
1090 let prev_height = state.height;
1091 let prev_view = state.view;
1092
1093 state.height = next_height;
1094 if target_view > state.view {
1095 state.view = target_view;
1096 }
1097 // Reset phase/proposed_block/qc fields — we're crossing a sync
1098 // boundary; whatever in-flight state we had for the old view is
1099 // stale.
1100 state.phase = Phase::Prepare;
1101 state.proposed_block = None;
1102 state.prepare_qc = None;
1103 state.commit_qc = None;
1104 state.view_start_time = Instant::now();
1105
1106 tracing::info!(
1107 prev_view,
1108 resumed_view = state.view,
1109 prev_height = %prev_height,
1110 resumed_height = %next_height,
1111 synced_height = %height,
1112 commit_qc_view,
1113 "Consensus engine resuming after block-sync catchup"
1114 );
1115 }
1116
1117 // Advance the persisted high_qc_view so future TimeoutMsgs carry
1118 // the correct ceiling — without this, a TC built post-catchup
1119 // would advertise a stale max_high_qc_view and the next leader
1120 // could pick a too-low next-view target.
1121 {
1122 let mut hqc = self.high_qc_view.write();
1123 if commit_qc_view > *hqc {
1124 *hqc = commit_qc_view;
1125 }
1126 }
1127
1128 // Bump the persistent signing ceiling. We record a synthetic
1129 // `LastSignState` at (view = commit_qc_view, height = height,
1130 // step = Commit) — this is the strongest legal claim, since the
1131 // 2f+1 commit aggregate at that (view, height) is already on
1132 // chain. A future `vote_state.is_strictly_after` check will
1133 // refuse any vote at (v ≤ commit_qc_view, *) on this height,
1134 // closing the post-sync re-vote window.
1135 let synthetic = LastSignState {
1136 version: 1,
1137 view: commit_qc_view,
1138 height: height.0,
1139 step: VoteStep::Commit,
1140 // We don't have the actual signed bytes (sync arrived as a 2f+1
1141 // aggregate, not as our own vote), so we leave hash/signature
1142 // unset. The is_strictly_after check uses (view, height, step)
1143 // tuple ordering and does not require the hash.
1144 block_hash: None,
1145 signature: None,
1146 };
1147 if let Err(e) = self.vote_state_store.record(&synthetic) {
1148 tracing::warn!(
1149 error = %e,
1150 synced_height = %height,
1151 commit_qc_view,
1152 "vote_state_store.record() failed during sync resume — engine \
1153 will still advance its in-memory view, but a crash before the \
1154 next legitimate vote could allow re-signing below the commit \
1155 QC ceiling"
1156 );
1157 }
1158 }
1159
1160 /// Submit a validated transaction to the consensus mempool.
1161 ///
1162 /// Called by the event loop after transaction validation (signature, gas, nonce checks).
1163 /// The mempool orders transactions by gas price priority and enforces size limits.
1164 pub fn submit_transaction(&self, tx: SignedTransaction) -> Result<()> {
1165 self.mempool.add_transaction(tx)
1166 }
1167
1168 /// Subscribe to behind-tip hints.
1169 ///
1170 /// The receiver fires whenever `on_proposal` rejects a peer proposal at a
1171 /// height above ours — i.e. the network has finalized blocks we don't
1172 /// have and the in-band catchup path couldn't recover them. The carried
1173 /// value is the rejected proposal height. Block-sync uses this to drop
1174 /// its engage tolerance to zero so small gaps (1..=SLOT_IMPORT_TOLERANCE)
1175 /// don't strand a restarted replica one block behind the tip.
1176 pub fn subscribe_behind_hint(&self) -> tokio::sync::watch::Receiver<u64> {
1177 self.behind_hint_tx.subscribe()
1178 }
1179
1180 /// Subscribe to finality notifications.
1181 ///
1182 /// Returns a broadcast receiver that emits `FinalityNotification` each time
1183 /// a block is finalized by consensus. The event loop subscribes to this
1184 /// to execute transactions and persist state.
1185 pub fn subscribe_finality(&self) -> broadcast::Receiver<FinalityNotification> {
1186 self.finality_tracker.subscribe()
1187 }
1188
1189 /// Returns the current finalized block height.
1190 pub fn current_finalized_height(&self) -> BlockHeight {
1191 self.finality_tracker.finalized_height()
1192 }
1193
1194 /// Returns a clone of the shared epoch manager handle.
1195 ///
1196 /// Used by the staking lifecycle in `tenzro-node` to enqueue / dequeue
1197 /// validators for the next epoch (`add_pending_validator` /
1198 /// `remove_pending_validator`) when stake/unstake RPCs land or when
1199 /// slashing drops a validator below the minimum stake.
1200 pub fn epoch_manager(&self) -> Arc<EpochManager> {
1201 Arc::clone(&self.epoch_manager)
1202 }
1203
1204 /// Returns a clone of the shared fork-choice handle.
1205 ///
1206 /// Block production reads `select_best_block(parent_height)` to pick
1207 /// the canonical parent on a non-finalized fork; tests can inspect
1208 /// the index via `block_count` / `get_block` / `get_qc`.
1209 pub fn fork_choice(&self) -> Arc<ForkChoice> {
1210 Arc::clone(&self.fork_choice)
1211 }
1212
1213 /// Returns the current validator set
1214 pub fn validator_set(&self) -> ValidatorSet {
1215 self.epoch_manager.current_validator_set()
1216 }
1217
1218 /// Returns the validator set that was active at the given block height.
1219 ///
1220 /// This is the load-bearing accessor for cross-epoch block-sync: a node
1221 /// catching up from far behind must verify each historical block's
1222 /// commit-QC against the validator set that signed it, not the current
1223 /// epoch's set. Returns `None` if the epoch covering `height` has been
1224 /// pruned from history (signals "snapshot sync required" to the caller).
1225 pub fn validator_set_for_height(&self, height: BlockHeight) -> Option<ValidatorSet> {
1226 self.epoch_manager
1227 .get_epoch_for_height(height)
1228 .map(|e| e.validator_set)
1229 }
1230
1231 /// Checks if this node is a validator
1232 fn is_validator(&self) -> bool {
1233 self.validator_set().is_validator(&self.address)
1234 }
1235
1236 /// Gets the leader for the current view via the configured
1237 /// [`ProposerElection`] strategy.
1238 ///
1239 /// `prev_block_id` for the reputation seed is the epoch's fixed
1240 /// `seed_anchor` — the finalized block hash at the epoch's canonical
1241 /// boundary. Anchoring per-epoch (rather than on the local finalized
1242 /// tip, which differs by a block or two across nodes at any instant)
1243 /// makes leader election deterministic fleet-wide while preserving
1244 /// the anti-grinding invariant: the anchor is a finalized hash no
1245 /// proposer can grind on — see
1246 /// [`crate::leader_reputation::reputation_seed`].
1247 fn get_leader(&self) -> Result<Address> {
1248 let view = self.view_state.read().view;
1249 let validator_set = self.validator_set();
1250 let current_epoch = self.epoch_manager.current_epoch();
1251 self.proposer_election.select_leader(
1252 view,
1253 current_epoch.number,
1254 current_epoch.seed_anchor.0,
1255 &validator_set,
1256 )
1257 }
1258
1259 /// Resolves the elected leader for an arbitrary `view` under the
1260 /// current epoch's fixed seed anchor. Used by proposal-side proposer
1261 /// enforcement and committed-metadata reputation derivation.
1262 fn leader_for_view(&self, view: u64) -> Result<Address> {
1263 let validator_set = self.validator_set();
1264 let current_epoch = self.epoch_manager.current_epoch();
1265 self.proposer_election.select_leader(
1266 view,
1267 current_epoch.number,
1268 current_epoch.seed_anchor.0,
1269 &validator_set,
1270 )
1271 }
1272
1273 /// Advances to the next view
1274 async fn advance_view(&self) -> Result<()> {
1275 let new_view = {
1276 let mut state = self.view_state.write();
1277
1278 state.view += 1;
1279 state.phase = Phase::Prepare;
1280 state.proposed_block = None;
1281 state.prepare_qc = None;
1282 state.commit_qc = None;
1283 state.reset_timer();
1284
1285 tracing::info!(
1286 view = state.view,
1287 height = %state.height,
1288 "Advanced to new view"
1289 );
1290 state.view
1291 };
1292
1293 // Bound consensus memory. Without this, three caches accumulate
1294 // forever and eventually OOMKill the validator:
1295 // 1. `self.blocks` is inserted into on every `on_proposal` and
1296 // `propose_block_internal` but never evicted — each restart of
1297 // the gossipsub dedup window for an old block re-inserts it.
1298 // 2. `vote_collector.votes` and `.quorum_certificates` retain every
1299 // vote forever; with ML-DSA-65 hybrid signatures (~3.3 KB each)
1300 // and a stalled view counter that keeps ticking, this is the
1301 // dominant growth term.
1302 // 3. `finality_tracker.finalized_blocks` keeps full Block payloads
1303 // for every finalized height since genesis.
1304 // We retain `BLOCK_CACHE_HEIGHT_WINDOW` blocks/finality entries below
1305 // the current finalized height for ancestry checks and late gossip,
1306 // and `VOTE_CACHE_VIEW_WINDOW` views of vote/QC history. Both windows
1307 // are generous enough to absorb timeout backoff and short partitions
1308 // without wedging consensus.
1309 const BLOCK_CACHE_HEIGHT_WINDOW: u64 = 256;
1310 const VOTE_CACHE_VIEW_WINDOW: u64 = 256;
1311 // Hard ceiling on the number of cached blocks. The height window alone
1312 // cannot bound growth when consensus is wedged at a single height: every
1313 // failed view at that height proposes a *distinct* block hash, all
1314 // sharing the stuck height, so `retain(height >= min_height)` reclaims
1315 // none of them. A chain stuck for tens of thousands of views then
1316 // accumulates one full Block per view until the process OOMs — which in
1317 // turn starves the block-sync serving path that peers need to catch up,
1318 // wedging the whole fleet. We cap the cache by count and, on overflow,
1319 // evict the lowest-`view` entries (the stalest competing proposals)
1320 // first. The cap is comfortably above any legitimate working set:
1321 // BLOCK_CACHE_HEIGHT_WINDOW distinct heights plus generous slack for
1322 // concurrent same-height forks during a partition.
1323 const BLOCK_CACHE_MAX_ENTRIES: usize = 4096;
1324
1325 let finalized_height = self.finality_tracker.finalized_height();
1326 let min_height = BlockHeight(finalized_height.0.saturating_sub(BLOCK_CACHE_HEIGHT_WINDOW));
1327
1328 let blocks_before = self.blocks.len();
1329 self.blocks.retain(|_, block| block.height() >= min_height);
1330
1331 // Count-bounded backstop: if the height window left more than the cap
1332 // (single-height view churn), drop the lowest-view entries until under
1333 // it. We never evict the freshest views — sorting by view ascending
1334 // evicts the stale competing proposals first.
1335 evict_excess_blocks_by_view(&self.blocks, BLOCK_CACHE_MAX_ENTRIES);
1336
1337 let blocks_after = self.blocks.len();
1338
1339 if blocks_before != blocks_after {
1340 tracing::debug!(
1341 evicted = blocks_before - blocks_after,
1342 retained = blocks_after,
1343 min_height = %min_height,
1344 "Evicted old blocks from consensus cache"
1345 );
1346 }
1347
1348 // Mirror the eviction into the fork-choice index so it doesn't
1349 // hold references to blocks below the safe-to-evict watermark.
1350 // ForkChoice's `prune_below_finalized` reads finalized_height
1351 // from the FinalityTracker we share with it, so the prune
1352 // boundary stays consistent across the two structures.
1353 self.fork_choice.prune_below_finalized();
1354 self.finality_tracker.prune_blocks_below(min_height);
1355
1356 if let Some(collector) = self.vote_collector.read().as_ref() {
1357 let min_view = new_view.saturating_sub(VOTE_CACHE_VIEW_WINDOW);
1358 collector.cleanup_old_votes(min_view);
1359 }
1360
1361 // Prune the receiver-side proposal-dedup set the same way as
1362 // vote/timeout caches. Keep the last `VOTE_CACHE_VIEW_WINDOW`
1363 // views' worth of proposal-id sentinels so late-arriving gossip
1364 // replays can still be deduplicated. Anything older is dead
1365 // weight and gets evicted.
1366 {
1367 let min_view = new_view.saturating_sub(VOTE_CACHE_VIEW_WINDOW);
1368 self.proposal_dedup.retain(|(view, _hash), _| *view >= min_view);
1369 }
1370
1371 // Drop the TimeoutCollector's per-view caches for views that can no
1372 // longer be useful. New TC formation is only meaningful at view ≥
1373 // current view; everything below the cleanup window is dead weight.
1374 if let Some(tc_collector) = self.timeout_collector.read().as_ref() {
1375 let min_view = new_view.saturating_sub(VOTE_CACHE_VIEW_WINDOW);
1376 tc_collector.cleanup_below(min_view);
1377 }
1378
1379 // Same cleanup discipline for the NEC collector.
1380 if let Some(nec_collector) = self.nec_collector.read().as_ref() {
1381 let min_view = new_view.saturating_sub(VOTE_CACHE_VIEW_WINDOW);
1382 nec_collector.cleanup_below(min_view);
1383 }
1384
1385 // If our last_round_tc is for a view older than the new local view
1386 // it can never be attached to a future proposal — drop it. We keep
1387 // it if it's at-or-above the new view so the next leader has it.
1388 {
1389 let mut maybe_tc = self.last_round_tc.write();
1390 if let Some(tc) = maybe_tc.as_ref()
1391 && tc.view + 1 < new_view
1392 {
1393 *maybe_tc = None;
1394 }
1395 }
1396
1397 // Same discipline for the NEC: a NEC for view v authorises a fresh
1398 // proposal at view v+1. If new_view > v+1 the NEC is no longer
1399 // attachable.
1400 {
1401 let mut maybe_nec = self.last_round_nec.write();
1402 if let Some(nec) = maybe_nec.as_ref()
1403 && nec.view + 1 < new_view
1404 {
1405 *maybe_nec = None;
1406 }
1407 }
1408
1409 Ok(())
1410 }
1411
1412 /// Checks if the current view has timed out
1413 fn check_view_timeout(&self) -> bool {
1414 let state = self.view_state.read();
1415 let timer = self.view_timer.read();
1416 let time_in_view = state.time_in_view();
1417
1418 time_in_view >= timer.current_timeout()
1419 }
1420
1421 /// Handles view timeout.
1422 ///
1423 /// Per DiemBFT v4 §3.5 / Aptos `process_local_timeout`, this **must
1424 /// broadcast** a signed timeout message before advancing locally. The
1425 /// broadcast is the channel that lets a lagging peer fast-forward to
1426 /// our view (backward-sync). Silent `view += 1` — the previous
1427 /// behaviour — is the textbook livelock fault: under partial
1428 /// synchrony two replicas drift apart by N views and never reconverge,
1429 /// because the only existing forward-sync channel (`on_proposal`)
1430 /// requires the lagging proposer to produce a valid proposal at the
1431 /// higher view, which it cannot do (#164).
1432 async fn on_view_timeout(&self) -> Result<()> {
1433 let current_view = self.view_state.read().view;
1434 let (timeout_ms, prior_consecutive) = {
1435 let timer = self.view_timer.read();
1436 (
1437 timer.current_timeout().as_millis(),
1438 timer.consecutive_timeouts,
1439 )
1440 };
1441
1442 // Tiered logging: an isolated timeout is normal pacemaker behaviour
1443 // (leader rotation under jitter) and logs at info; repeated
1444 // consecutive timeouts indicate the cluster is failing to make
1445 // progress and escalate to warn.
1446 if prior_consecutive >= 1 {
1447 tracing::warn!(
1448 view = current_view,
1449 timeout_ms = timeout_ms,
1450 consecutive = prior_consecutive + 1,
1451 "Repeated view timeout, broadcasting TimeoutMsg and advancing to next view"
1452 );
1453 } else {
1454 tracing::info!(
1455 view = current_view,
1456 timeout_ms = timeout_ms,
1457 "View timeout, broadcasting TimeoutMsg and advancing to next view"
1458 );
1459 }
1460
1461 // Build & sign a TimeoutMsg for the timing-out view. Best-effort:
1462 // if signing fails (e.g. crypto subsystem error) we still advance
1463 // locally — silent advance is the existing pre-#164 behaviour, so
1464 // the worst case is we degrade to that. We do NOT block the
1465 // pacemaker on signing.
1466 match self.create_timeout_msg(current_view) {
1467 Ok(timeout_msg) => {
1468 self.send_out(ConsensusOutMessage::Timeout(timeout_msg));
1469 }
1470 Err(e) => {
1471 tracing::warn!(
1472 view = current_view,
1473 error = %e,
1474 "Failed to create TimeoutMsg; advancing view without broadcast"
1475 );
1476 }
1477 }
1478
1479 // Build & sign a NoEndorsementMsg for the timing-out view. The
1480 // attestation is "I observed no QC for view current_view - 1" — the
1481 // f+1 aggregation closes the tail-fork MEV vulnerability (MonadBFT,
1482 // arXiv:2502.20692). Skipped at view 0 (no predecessor) and the same
1483 // best-effort posture as the timeout broadcast: signing failure does
1484 // not block the pacemaker.
1485 if current_view > 0 {
1486 match self.create_no_endorsement_msg(current_view) {
1487 Ok(nec_msg) => {
1488 self.send_out(ConsensusOutMessage::NoEndorsement(nec_msg));
1489 }
1490 Err(e) => {
1491 tracing::warn!(
1492 view = current_view,
1493 error = %e,
1494 "Failed to create NoEndorsementMsg; advancing view without NEC broadcast"
1495 );
1496 }
1497 }
1498 }
1499
1500 // Apply capped exponential backoff
1501 self.view_timer.write().on_timeout();
1502
1503 // NOTE: reputation is deliberately NOT recorded here. Local view
1504 // timeouts are node-local observations (two nodes can disagree on
1505 // whether view V timed out), so feeding them into LeaderReputation
1506 // would diverge the histories — and therefore the elected leaders —
1507 // across the fleet. Failed rounds are instead derived
1508 // deterministically from committed metadata: every view in the gap
1509 // between a finalized block and its parent is recorded as a failure
1510 // in `record_committed_round_metadata`.
1511
1512 // Advance to next view (which will select a new leader via round-robin)
1513 self.advance_view().await?;
1514
1515 Ok(())
1516 }
1517
1518 /// Builds and hybrid-signs a [`TimeoutMsg`] for the given view.
1519 ///
1520 /// Mirrors the shape of `create_vote` but does **not** consult the
1521 /// double-sign vote-state store: a TimeoutMsg does not bind to a block,
1522 /// only to a view, so signing two TimeoutMsgs for the same view is
1523 /// harmless (both carry identical view bytes; receivers dedupe by
1524 /// `(voter, view)`).
1525 fn create_timeout_msg(&self, view: u64) -> Result<crate::timeout::TimeoutMsg> {
1526 // Carry the highest Prepare-QC view we have observed. Capped at
1527 // `view - 1`: a replica timing out on view V cannot honestly claim
1528 // it has observed a QC at V or beyond (it would have advanced past
1529 // V already). The cap also guarantees `high_qc_view < view` which
1530 // `TimeoutMsg::verify` enforces — so we never emit a self-rejected
1531 // message.
1532 let raw_high_qc = *self.high_qc_view.read();
1533 let high_qc_view = if view == 0 {
1534 0
1535 } else {
1536 raw_high_qc.min(view - 1)
1537 };
1538
1539 // Advertise our finalized height so peers stuck behind a
1540 // finalization skew (one replica finalized via a Commit QC the
1541 // others never received) can engage block-sync off our timeout.
1542 let finalized_height = self.finality_tracker.finalized_height().0;
1543
1544 let placeholder_sig =
1545 tenzro_crypto::composite::CompositeSignature::new(Vec::new(), Vec::new());
1546 let unsigned = crate::timeout::TimeoutMsg::new(
1547 view,
1548 high_qc_view,
1549 finalized_height,
1550 self.address,
1551 placeholder_sig,
1552 self.composite_public_key.clone(),
1553 );
1554 let payload = unsigned.signing_payload();
1555
1556 // Reconstruct the hybrid signer (same pattern as create_vote — the
1557 // engine's long-lived Arc<MlDsaSigningKey> can't be consumed).
1558 let keypair_bytes = self.keypair.to_bytes();
1559 let keypair_copy =
1560 KeyPair::from_bytes(self.keypair.key_type(), &keypair_bytes)?;
1561 let classical = Ed25519SignerImpl::new(keypair_copy)?;
1562 let pq_seed = self.pq_signing_key.seed_bytes();
1563 let pq_copy = MlDsaSigningKey::from_seed(pq_seed)?;
1564 let hybrid = InMemoryHybridSigner::new(Box::new(classical), pq_copy);
1565
1566 let signature = hybrid.sign(&payload)?;
1567
1568 Ok(crate::timeout::TimeoutMsg::new(
1569 view,
1570 high_qc_view,
1571 finalized_height,
1572 self.address,
1573 signature,
1574 self.composite_public_key.clone(),
1575 ))
1576 }
1577
1578 /// Handles an inbound [`crate::timeout::TimeoutMsg`] from a peer.
1579 ///
1580 /// Three layered effects:
1581 ///
1582 /// 1. **Backward view-counter sync** (DiemBFT v4 §3.5). If
1583 /// `msg.view > local_view`, advance our local view to match. The
1584 /// cryptographic gate is the hybrid signature on `(view,
1585 /// high_qc_view, voter)` — no numeric jump cap is applied, since
1586 /// a stuck replica may legitimately need to sync forward by many
1587 /// thousands of views and the signature itself is the only
1588 /// correctness-relevant authentication. Prevents permanent view
1589 /// divergence under partial synchrony.
1590 /// 2. **Bracha boost / `f+1` amplification**. The aggregator emits
1591 /// `BrachaBoost` when the f+1 threshold is crossed for the timing-out
1592 /// view. We respond by firing our local view-timer immediately —
1593 /// even if our timer has not yet expired — so honest replicas
1594 /// converge on the timeout decision in O(network) rather than
1595 /// waiting for the full local timeout.
1596 /// 3. **`2f+1` TimeoutCertificate formation**. When the quorum is
1597 /// reached the aggregator emits the formed
1598 /// [`crate::timeout::TimeoutCertificate`]. We store it as
1599 /// `last_round_tc` so the next leader (us, or the next view's leader
1600 /// after view-sync) can attach it to their proposal — which is what
1601 /// receivers' `safe_to_extend` predicate requires before voting on
1602 /// a non-consecutive view.
1603 pub async fn on_timeout_msg(&self, msg: &crate::timeout::TimeoutMsg) -> Result<()> {
1604 // Verify signature + validator binding before trusting the view
1605 // number. Without this, any peer could spoof a TimeoutMsg from a
1606 // validator address and drag every replica forward. After this
1607 // returns Ok, the (view, high_qc_view, voter) triple is hybrid-
1608 // signature-bound to a registered validator.
1609 //
1610 // Per DiemBFT v4 §3.5 `process_remote_timeout`, the cryptographic
1611 // gate is the signature itself — no numeric jump cap is applied. A
1612 // malicious validator's "I timed out at view N" message costs them
1613 // a publicly verifiable signature; they cannot drag the protocol
1614 // past their own observed state without producing one. Stuck
1615 // replicas may legitimately need to sync forward by tens of
1616 // thousands of views, and the signature is the only correctness-
1617 // relevant authentication needed to do so safely.
1618 let validator_set = self.validator_set();
1619 msg.verify(&validator_set)?;
1620
1621 // Finalization-skew heal: if the (signature-verified) sender
1622 // advertises a finalized height above ours, we may have missed a
1623 // Commit QC broadcast — the sender finalized a block we never did,
1624 // and the proposer keeps re-proposing a conflicting block at that
1625 // height which the sender rejects, deadlocking the view forever.
1626 // Fire the behind-hint so block-sync fetches the block (its Commit
1627 // QC is embedded in `consensus_proof.proof_data` and verified
1628 // cryptographically on import — a Byzantine lie here just triggers
1629 // a futile, bounded sync probe).
1630 let local_finalized = self.finality_tracker.finalized_height().0;
1631 if msg.finalized_height > local_finalized {
1632 tracing::info!(
1633 voter = %msg.voter,
1634 peer_finalized = msg.finalized_height,
1635 local_finalized = local_finalized,
1636 "TimeoutMsg advertises higher finalized height — engaging block-sync hint"
1637 );
1638 self.behind_hint_tx.send_replace(msg.finalized_height);
1639 }
1640
1641 let local_view = self.view_state.read().view;
1642
1643 // Aggregate into the per-view collector. We aggregate timeouts at
1644 // the local view *and* at views ahead of us — TC formation at a
1645 // future view is what lets us produce a safe extension once we
1646 // catch up.
1647 let collect_outcome = {
1648 let guard = self.timeout_collector.read();
1649 guard.as_ref().map(|collector| collector.add(msg))
1650 };
1651
1652 if let Some(outcome) = collect_outcome {
1653 use crate::timeout::CollectOutcome;
1654 match outcome {
1655 CollectOutcome::Added | CollectOutcome::Duplicate => {}
1656 CollectOutcome::BrachaBoost => {
1657 // f+1 honest replicas have timed out on this view —
1658 // amplify by treating this as if our own local timer had
1659 // expired. This is the Bracha-boost / DiemBFT
1660 // `process_remote_timeout` path that drives all honest
1661 // replicas to converge on the timeout decision in
1662 // network-delay time.
1663 tracing::warn!(
1664 view = msg.view,
1665 local_view = local_view,
1666 voter = %msg.voter,
1667 "Bracha boost: f+1 TimeoutMsgs at view {} — amplifying to local timeout",
1668 msg.view
1669 );
1670 if msg.view >= local_view {
1671 // Only amplify if the boosted view is at least our
1672 // current view. A boost at a view we've already
1673 // passed is irrelevant (we're already ahead).
1674 if let Err(e) = self.on_view_timeout().await {
1675 tracing::error!(
1676 error = %e,
1677 "Bracha-boosted local timeout failed"
1678 );
1679 }
1680 }
1681 }
1682 CollectOutcome::CertificateFormed(tc) => {
1683 tracing::info!(
1684 view = tc.view,
1685 signers = tc.signers.len(),
1686 max_high_qc_view = tc.max_high_qc_view(),
1687 "TimeoutCertificate formed for view {}",
1688 tc.view
1689 );
1690 // Store as last_round_tc, replacing any older TC. The
1691 // next proposer attaches this to their proposal; the
1692 // safe_to_extend predicate at receivers verifies it.
1693 let mut slot = self.last_round_tc.write();
1694 let should_update = match slot.as_ref() {
1695 Some(existing) => tc.view > existing.view,
1696 None => true,
1697 };
1698 if should_update {
1699 *slot = Some(tc);
1700 }
1701 }
1702 }
1703 }
1704
1705 // Adopt the TimeoutMsg's high_qc_view as a SyncInfo signal — the
1706 // signer has seen a Prepare QC at view `msg.high_qc_view`, which
1707 // is itself a 2f+1 aggregate. Any local view ≤ `high_qc_view` is
1708 // provably behind. The pacemaker target is `max(msg.view,
1709 // msg.high_qc_view + 1)` — both are valid evidence of ahead-state.
1710 {
1711 let mut hqc = self.high_qc_view.write();
1712 if msg.high_qc_view > *hqc {
1713 *hqc = msg.high_qc_view;
1714 }
1715 }
1716
1717 // Backward view-counter sync. Re-check the local view after
1718 // aggregation in case the Bracha boost above already advanced us.
1719 let post_agg_local_view = self.view_state.read().view;
1720
1721 // Pacemaker target combines two SyncInfo channels:
1722 // - msg.view: the peer is timing out at this view → engine
1723 // must be at least at msg.view to participate.
1724 // - msg.high_qc_view + 1: 2f+1 cert evidence; next legal
1725 // proposal is at msg.high_qc_view + 1.
1726 let target = std::cmp::max(msg.view, msg.high_qc_view.saturating_add(1));
1727
1728 if target <= post_agg_local_view {
1729 // Stale or equal after aggregation. We never *rewind* on a
1730 // peer's timeout (that would be a liveness regression and a
1731 // downgrade attack).
1732 tracing::trace!(
1733 msg_view = msg.view,
1734 msg_high_qc_view = msg.high_qc_view,
1735 local_view = post_agg_local_view,
1736 voter = %msg.voter,
1737 "TimeoutMsg target view ≤ local view after aggregation — no sync needed"
1738 );
1739 return Ok(());
1740 }
1741
1742 // Advance our local view to the SyncInfo target. Mirror the
1743 // structure of `on_proposal`'s forward-sync block: re-check
1744 // under the write lock in case another task already advanced
1745 // us, and reset all per-view state so the next proposal
1746 // arriving for `target` doesn't see stale prepare/commit QCs
1747 // from a previous view.
1748 let mut state = self.view_state.write();
1749 if target > state.view {
1750 tracing::info!(
1751 from_view = state.view,
1752 to_view = target,
1753 msg_view = msg.view,
1754 msg_high_qc_view = msg.high_qc_view,
1755 height = %state.height,
1756 voter = %msg.voter,
1757 "View sync: advancing local view to match peer TimeoutMsg SyncInfo"
1758 );
1759 state.view = target;
1760 state.phase = Phase::Prepare;
1761 state.proposed_block = None;
1762 state.prepare_qc = None;
1763 state.commit_qc = None;
1764 state.reset_timer();
1765 }
1766 Ok(())
1767 }
1768
1769 /// Builds and hybrid-signs a [`crate::timeout::NoEndorsementMsg`] for the
1770 /// given view. The attestation is "I observed no QC for view v - 1".
1771 ///
1772 /// Mirrors `create_timeout_msg` but with the NEC-specific signing
1773 /// payload (`TENZRO_NO_ENDORSEMENT:`-tagged, no `high_qc_view`).
1774 fn create_no_endorsement_msg(
1775 &self,
1776 view: u64,
1777 ) -> Result<crate::timeout::NoEndorsementMsg> {
1778 let placeholder_sig =
1779 tenzro_crypto::composite::CompositeSignature::new(Vec::new(), Vec::new());
1780 let unsigned = crate::timeout::NoEndorsementMsg::new(
1781 view,
1782 self.address,
1783 placeholder_sig,
1784 self.composite_public_key.clone(),
1785 );
1786 let payload = unsigned.signing_payload();
1787
1788 let keypair_bytes = self.keypair.to_bytes();
1789 let keypair_copy =
1790 KeyPair::from_bytes(self.keypair.key_type(), &keypair_bytes)?;
1791 let classical = Ed25519SignerImpl::new(keypair_copy)?;
1792 let pq_seed = self.pq_signing_key.seed_bytes();
1793 let pq_copy = MlDsaSigningKey::from_seed(pq_seed)?;
1794 let hybrid = InMemoryHybridSigner::new(Box::new(classical), pq_copy);
1795
1796 let signature = hybrid.sign(&payload)?;
1797
1798 Ok(crate::timeout::NoEndorsementMsg::new(
1799 view,
1800 self.address,
1801 signature,
1802 self.composite_public_key.clone(),
1803 ))
1804 }
1805
1806 /// Handles an inbound [`crate::timeout::NoEndorsementMsg`] from a peer.
1807 ///
1808 /// Verifies the message, then aggregates it into the per-view NEC
1809 /// collector. When f+1 attestations at the same view aggregate, the
1810 /// collector emits a [`crate::timeout::NoEndorsementCertificate`] which
1811 /// we store as `last_round_nec` so the next leader can attach it to a
1812 /// fresh proposal at view+1 (MonadBFT, arXiv:2502.20692).
1813 ///
1814 /// Unlike `on_timeout_msg`, this method does NOT advance the local view —
1815 /// the NEC is purely a certificate-formation channel; view-sync remains
1816 /// the responsibility of the TimeoutMsg path.
1817 pub async fn on_no_endorsement_msg(
1818 &self,
1819 msg: &crate::timeout::NoEndorsementMsg,
1820 ) -> Result<()> {
1821 let validator_set = self.validator_set();
1822 msg.verify(&validator_set)?;
1823
1824 let collect_outcome = {
1825 let guard = self.nec_collector.read();
1826 guard.as_ref().map(|collector| collector.add(msg))
1827 };
1828
1829 if let Some(outcome) = collect_outcome {
1830 use crate::timeout::NecCollectOutcome;
1831 match outcome {
1832 NecCollectOutcome::Added | NecCollectOutcome::Duplicate => {}
1833 NecCollectOutcome::CertificateFormed(nec) => {
1834 tracing::info!(
1835 view = nec.view,
1836 signers = nec.signers.len(),
1837 "NoEndorsementCertificate formed for view {}",
1838 nec.view
1839 );
1840 let mut slot = self.last_round_nec.write();
1841 let should_update = match slot.as_ref() {
1842 Some(existing) => nec.view > existing.view,
1843 None => true,
1844 };
1845 if should_update {
1846 *slot = Some(nec);
1847 }
1848 }
1849 }
1850 }
1851
1852 Ok(())
1853 }
1854
1855 /// Creates a vote for a block.
1856 ///
1857 /// Wave 3d hybrid path: rebuilds an `InMemoryHybridSigner` from this
1858 /// node's classical keypair and ML-DSA-65 signing key, signs the canonical
1859 /// vote payload with both legs, and embeds the composite public key into
1860 /// the resulting `Vote` so receiving validators can bind it against the
1861 /// registered hybrid key.
1862 ///
1863 /// # Double-sign protection
1864 ///
1865 /// Before signing, consults `vote_state_store` (mirrors CometBFT
1866 /// `FilePVLastSignState`):
1867 /// - If `(view, height, step)` is strictly past the last persisted tuple,
1868 /// sign and **persist with fsync before returning** so the broadcast
1869 /// downstream of this function can never reveal a vote that wasn't
1870 /// durably recorded.
1871 /// - If `(view, height, step)` matches the last persisted tuple AND the
1872 /// block hash matches, return the previously-persisted signature
1873 /// verbatim (idempotent retry).
1874 /// - Otherwise (same tuple, different hash, or earlier tuple) refuse with
1875 /// `ConsensusError::Equivocation` — a self-detected double-sign attempt.
1876 fn create_vote(
1877 &self,
1878 block: &Block,
1879 vote_type: VoteType,
1880 ) -> Result<Vote> {
1881 let (view, height) = {
1882 let state = self.view_state.read();
1883 (state.view, state.height)
1884 };
1885 let block_hash = block.hash();
1886 let step = VoteStep::from_vote_type(vote_type);
1887
1888 // SyncInfo piggyback (#171): every vote carries the highest Prepare-QC
1889 // view this replica has observed, capped at `view - 1` (a vote at view
1890 // V cannot honestly claim a Prepare-QC at view ≥ V — `add_vote`
1891 // enforces this on the receiver). Genesis case (view = 0) carries 0.
1892 let high_qc_view = {
1893 let raw = *self.high_qc_view.read();
1894 if view == 0 { 0 } else { raw.min(view - 1) }
1895 };
1896
1897 // Step 1: Consult persistent vote state. If we already signed this
1898 // exact (view, height, step, hash), reuse the persisted signature.
1899 // If we signed a *different* hash for the same (view, height, step),
1900 // refuse — this would be self-equivocation.
1901 let decision = self
1902 .vote_state_store
1903 .check_vrs(view, height.0, step, &block_hash)?;
1904
1905 match decision {
1906 VrsDecision::Reject { reason } => {
1907 tracing::error!(
1908 view = view,
1909 height = %height,
1910 step = ?step,
1911 block_hash = %block_hash,
1912 reason = %reason,
1913 "DOUBLE-SIGN PREVENTED: refusing to vote — would equivocate"
1914 );
1915 return Err(ConsensusError::Equivocation {
1916 validator: self.address.to_string(),
1917 view,
1918 });
1919 }
1920 VrsDecision::Reuse { signature: sig_bytes } => {
1921 // Idempotent retry — reconstruct the Vote from persisted
1922 // signature bytes. Wire format is `CompositeSignature` JSON.
1923 let signature: tenzro_crypto::composite::CompositeSignature =
1924 serde_json::from_slice(&sig_bytes).map_err(|e| {
1925 ConsensusError::Internal(format!(
1926 "vote_state_store: corrupt persisted signature: {}",
1927 e
1928 ))
1929 })?;
1930 tracing::info!(
1931 view = view,
1932 height = %height,
1933 step = ?step,
1934 block_hash = %block_hash,
1935 "Idempotent vote retry — reusing persisted signature"
1936 );
1937 // BLS is deterministic over (signing_key, message), so
1938 // re-signing the BLS leg here is equivalent to persisting
1939 // and replaying it — the wire bytes are byte-identical.
1940 // We rebuild the Vote with a placeholder BLS sig first to
1941 // construct the canonical bls payload, then replace.
1942 let placeholder_bls = self.bls_signing_key.sign(b"__placeholder__");
1943 let mut reused = Vote::new(
1944 view,
1945 height,
1946 block_hash,
1947 self.address,
1948 signature,
1949 self.composite_public_key.clone(),
1950 placeholder_bls,
1951 vote_type,
1952 high_qc_view,
1953 );
1954 let bls_payload = crate::voter::bls_payload_for_vote(&reused);
1955 reused.bls_signature = self.bls_signing_key.sign(&bls_payload);
1956 return Ok(reused);
1957 }
1958 VrsDecision::Sign => {
1959 // Fall through to sign + persist + return.
1960 }
1961 }
1962
1963 // Step 2: Build the unsigned vote (placeholder signature) so we can
1964 // compute the canonical signing payload — this MUST match what
1965 // VoteCollector::add_vote feeds to StandardHybridVerifier.
1966 let placeholder_sig = tenzro_crypto::composite::CompositeSignature::new(Vec::new(), Vec::new());
1967 let placeholder_bls = self.bls_signing_key.sign(b"__placeholder__");
1968 let unsigned_vote = Vote::new(
1969 view,
1970 height,
1971 block_hash,
1972 self.address,
1973 placeholder_sig,
1974 self.composite_public_key.clone(),
1975 placeholder_bls,
1976 vote_type,
1977 high_qc_view,
1978 );
1979 let payload = unsigned_vote.signing_payload();
1980
1981 // Step 3: Reconstruct the hybrid signer for this call. The classical
1982 // keypair is rebuilt from bytes (KeyPair is not Clone) and paired
1983 // with a fresh signing-key view rebuilt from the persisted seed so
1984 // that the engine's long-lived `Arc<MlDsaSigningKey>` is not
1985 // consumed.
1986 let keypair_bytes = self.keypair.to_bytes();
1987 let keypair_copy = KeyPair::from_bytes(self.keypair.key_type(), &keypair_bytes)?;
1988 let classical = Ed25519SignerImpl::new(keypair_copy)?;
1989 let pq_seed = self.pq_signing_key.seed_bytes();
1990 let pq_copy = MlDsaSigningKey::from_seed(pq_seed)?;
1991 let hybrid = InMemoryHybridSigner::new(Box::new(classical), pq_copy);
1992
1993 let signature = hybrid.sign(&payload)?;
1994
1995 // Step 4: Persist the new sign-state with fsync BEFORE returning the
1996 // signed vote. This is the critical ordering: if a crash happens
1997 // between sign and persist, on restart we'll re-sign — but since the
1998 // old signature never durably escaped, that's safe. If we persisted
1999 // *after* broadcast, a crash in that window would let us re-sign a
2000 // different block in the same view on restart, which is the textbook
2001 // self-equivocation that triggered the cascade.
2002 let sig_bytes = serde_json::to_vec(&signature).map_err(|e| {
2003 ConsensusError::Internal(format!(
2004 "vote_state_store: serialize composite signature: {}",
2005 e
2006 ))
2007 })?;
2008 let new_state = LastSignState {
2009 version: 1,
2010 view,
2011 height: height.0,
2012 step,
2013 block_hash: Some(block_hash),
2014 signature: Some(sig_bytes),
2015 };
2016 self.vote_state_store.record(&new_state)?;
2017
2018 // Step 5: BLS-sign the canonical QC payload (per
2019 // `bls_payload_for_vote`). BLS is deterministic over (sk, message)
2020 // so this is safe to compute after the hybrid persist — equivocation
2021 // is governed by the hybrid persist gate above; the BLS leg is
2022 // re-derivable on a clean retry.
2023 let placeholder_bls_final = self.bls_signing_key.sign(b"__placeholder__");
2024 let mut signed_vote = Vote::new(
2025 view,
2026 height,
2027 block_hash,
2028 self.address,
2029 signature,
2030 self.composite_public_key.clone(),
2031 placeholder_bls_final,
2032 vote_type,
2033 high_qc_view,
2034 );
2035 let bls_payload = crate::voter::bls_payload_for_vote(&signed_vote);
2036 signed_vote.bls_signature = self.bls_signing_key.sign(&bls_payload);
2037 Ok(signed_vote)
2038 }
2039
2040 /// Handles the prepare phase
2041 async fn handle_prepare_phase(&self, block: &Block) -> Result<Option<QuorumCertificate>> {
2042 // Validate the proposal
2043 let expected_height = self.view_state.read().height;
2044 self.proposer.validate_proposal(block, expected_height)?;
2045
2046 // EIP-1559 consensus rule: re-derive the expected base fee
2047 // from the parent block and reject if the proposer's stamped
2048 // value diverges. Mirrors go-ethereum
2049 // `consensus/misc/eip1559.VerifyEIP1559Header`. This MUST run
2050 // before signing the prepare vote — otherwise a malicious
2051 // proposer could pick an arbitrary base fee.
2052 let parent_height = block.header.height - 1u64;
2053 let parent_block = self
2054 .finality_tracker
2055 .get_finalized_block(parent_height)
2056 .or_else(|| {
2057 self.block_provider
2058 .as_ref()
2059 .and_then(|p| p.get_block(parent_height))
2060 });
2061 match parent_block {
2062 Some(parent) => {
2063 self.proposer.validate_base_fee(block, &parent)?;
2064 }
2065 None => {
2066 // Parent unavailable — refuse to vote rather than
2067 // rubber-stamping an unverifiable proposal.
2068 return Err(crate::error::ConsensusError::InvalidProposal(format!(
2069 "Cannot validate base fee for block at height {}: parent at height {} is unavailable",
2070 block.header.height, parent_height
2071 )));
2072 }
2073 }
2074
2075 // Create and add vote
2076 let vote = self.create_vote(block, VoteType::Prepare)?;
2077 self.send_out(ConsensusOutMessage::Vote(vote.clone()));
2078
2079 // Add to vote collector
2080 let vote_collector = self.vote_collector.read();
2081 if let Some(collector) = vote_collector.as_ref() {
2082 let qc = collector.add_vote(vote)?;
2083
2084 if let Some(qc) = qc.clone() {
2085 // We have a prepare QC, advance to commit phase
2086 let mut state = self.view_state.write();
2087 state.phase = Phase::Commit;
2088 state.prepare_qc = Some(qc.clone());
2089 // Track the highest prepare QC view we've witnessed — used as the
2090 // `high_qc_view` field of any future TimeoutMsg, so the resulting
2091 // TC's `max_high_qc_view()` lets the next leader compute a
2092 // safe-to-extend predicate per Jolteon §3.5.
2093 let qc_view = state.view;
2094
2095 tracing::info!(
2096 view = state.view,
2097 height = %state.height,
2098 "Prepare phase completed, advancing to commit"
2099 );
2100 drop(state);
2101
2102 let mut hqc = self.high_qc_view.write();
2103 if qc_view > *hqc {
2104 *hqc = qc_view;
2105 }
2106 }
2107
2108 Ok(qc)
2109 } else {
2110 Err(ConsensusError::Internal("Vote collector not initialized".to_string()))
2111 }
2112 }
2113
2114 /// Handles the commit phase
2115 async fn handle_commit_phase(&self, block: &Block) -> Result<Option<QuorumCertificate>> {
2116 let state = self.view_state.read();
2117
2118 // Ensure we have a prepare QC
2119 if state.prepare_qc.is_none() {
2120 return Err(ConsensusError::InvalidProposal(
2121 "No prepare QC for commit phase".to_string(),
2122 ));
2123 }
2124
2125 drop(state);
2126
2127 // Create and add commit vote
2128 let vote = self.create_vote(block, VoteType::Commit)?;
2129 self.send_out(ConsensusOutMessage::Vote(vote.clone()));
2130
2131 // FIX: Scope the read lock to JUST the add_vote call, then drop it before
2132 // the epoch transition which calls vote_collector.write(). Holding the read
2133 // lock through the epoch transition caused a parking_lot::RwLock deadlock
2134 // every time the chain reached the epoch boundary (block 9,999 → height 10,000).
2135 let qc = {
2136 let vote_collector = self.vote_collector.read();
2137 if let Some(collector) = vote_collector.as_ref() {
2138 collector.add_vote(vote)?
2139 } else {
2140 return Err(ConsensusError::Internal("Vote collector not initialized".to_string()));
2141 }
2142 }; // vote_collector read lock DROPPED HERE
2143
2144 if let Some(qc) = qc.clone() {
2145 // We have a commit QC, finalize the block
2146 let mut state = self.view_state.write();
2147 state.phase = Phase::Decide;
2148 state.commit_qc = Some(qc.clone());
2149
2150 tracing::info!(
2151 view = state.view,
2152 height = %state.height,
2153 "Commit phase completed, finalizing block"
2154 );
2155
2156 // Finalize the block.
2157 // If finalization fails due to a height mismatch (e.g. after a restart
2158 // where the finality tracker is ahead of view state), re-sync state.height
2159 // to match what the finality tracker expects and return without propagating
2160 // the error — the next proposal will be at the correct height.
2161 if let Err(e) = self.finality_tracker.finalize_block(block.clone(), qc.clone()) {
2162 let corrected_height = self.finality_tracker.finalized_height() + 1u64;
2163 tracing::warn!(
2164 error = %e,
2165 stale_height = %state.height,
2166 corrected_height = %corrected_height,
2167 "Finalization failed — re-syncing height to finality tracker"
2168 );
2169 state.height = corrected_height;
2170 state.phase = Phase::Prepare;
2171 state.proposed_block = None;
2172 state.prepare_qc = None;
2173 state.commit_qc = None;
2174 state.reset_timer();
2175 drop(state);
2176 return Ok(None);
2177 }
2178
2179 // Reset timeout on successful finalization, feeding the
2180 // observed view-to-QC latency into the adaptive base-timeout
2181 // tuner. This lets the cluster's `view_timeout_ms` track
2182 // whatever cross-region topology actually exists, without
2183 // any hardcoded default. See `ViewChangeTimer::record_observed_view_latency`.
2184 let observed_view_latency = state.time_in_view();
2185 self.view_timer
2186 .write()
2187 .on_success(Some(observed_view_latency));
2188
2189 // Advance height for next block
2190 state.height = state.height + 1u64;
2191 state.view += 1;
2192 state.phase = Phase::Prepare;
2193 state.proposed_block = None;
2194 state.prepare_qc = None;
2195 state.commit_qc = None;
2196 state.reset_timer();
2197
2198 // Remove finalized transactions from mempool
2199 let tx_hashes: Vec<Hash> = block.transactions.iter()
2200 .map(|tx| tx.transaction.hash())
2201 .collect();
2202 self.mempool.remove_transactions(&tx_hashes);
2203
2204 // Capture transition height before dropping state write lock
2205 let transition_height = state.height;
2206 drop(state);
2207
2208 // Feed LeaderReputation from committed metadata only — same
2209 // helper as finalize_with_commit_qc, so leader and followers
2210 // record identical histories.
2211 self.record_committed_round_metadata(block, &qc);
2212
2213 // Check for epoch transition — SAFE: no vote_collector read lock held here
2214 self.transition_epoch_if_due(transition_height)?;
2215 }
2216
2217 Ok(qc)
2218 }
2219
2220 /// Transitions the epoch at `height` when due and rebuilds the per-epoch
2221 /// collectors against the new validator set. Returns `Ok(true)` when a
2222 /// transition fired, `Ok(false)` when the height is not a boundary.
2223 ///
2224 /// Called from both live paths (DECIDE finalization, finalize-on-commit-QC)
2225 /// and the block-sync import path — a node importing finalized blocks
2226 /// across an epoch boundary must cross it exactly like a live node did, so
2227 /// that `validator_set_for_height` resolves for post-boundary blocks and
2228 /// QC verification runs against the same set.
2229 ///
2230 /// Callers must NOT hold the `vote_collector` / `timeout_collector` /
2231 /// `nec_collector` locks.
2232 pub fn transition_epoch_if_due(&self, height: BlockHeight) -> Result<bool> {
2233 if !self.epoch_manager.should_transition(height) {
2234 return Ok(false);
2235 }
2236
2237 // The due-check inside transition_epoch runs under the epoch write
2238 // lock — if a concurrent caller (engine finalize path vs. node
2239 // follower path) won the race, we get Ok(None) and report no-op.
2240 //
2241 // The anchor closure resolves the finalized hash at the new epoch's
2242 // canonical boundary (in-memory tracker first, durable BlockProvider
2243 // fallback for post-restart / catch-up walks) so every node seeds
2244 // leader election identically for the whole epoch.
2245 let anchor_of = |boundary: BlockHeight| -> Option<Hash> {
2246 self.finality_tracker
2247 .get_finalized_hash(boundary)
2248 .or_else(|| {
2249 self.block_provider
2250 .as_ref()
2251 .and_then(|p| p.get_block(boundary))
2252 .map(|b| b.hash())
2253 })
2254 };
2255 if self.epoch_manager.transition_epoch(height, anchor_of)?.is_none() {
2256 return Ok(false);
2257 }
2258 tracing::info!(height = %height, "Epoch transition triggered");
2259
2260 // Update collectors with the new validator set
2261 let new_validator_set = Arc::new(self.validator_set());
2262 *self.vote_collector.write() = Some(Arc::new(VoteCollector::new(
2263 new_validator_set.clone(),
2264 )));
2265 *self.timeout_collector.write() = Some(Arc::new(
2266 crate::timeout::TimeoutCollector::new(new_validator_set.clone()),
2267 ));
2268 *self.nec_collector.write() = Some(Arc::new(
2269 crate::timeout::NoEndorsementCollector::new(new_validator_set),
2270 ));
2271
2272 Ok(true)
2273 }
2274
2275 /// Main consensus loop
2276 async fn consensus_loop(&self) -> Result<()> {
2277 let mut shutdown_rx = {
2278 let shutdown_tx = self.shutdown_tx.read();
2279 match shutdown_tx.as_ref() {
2280 Some(tx) => tx.subscribe(),
2281 None => {
2282 tracing::error!("Consensus loop started without shutdown channel wired");
2283 return Err(ConsensusError::NotStarted);
2284 }
2285 }
2286 };
2287
2288 // Check interval for view timeout (100ms)
2289 let check_interval = Duration::from_millis(100);
2290
2291 loop {
2292 tokio::select! {
2293 _ = shutdown_rx.recv() => {
2294 tracing::info!("Consensus loop shutting down");
2295 break;
2296 }
2297 _ = sleep(check_interval) => {
2298 // Check for view timeout
2299 if self.check_view_timeout()
2300 && let Err(e) = self.on_view_timeout().await
2301 {
2302 tracing::error!(error = %e, "View timeout handling failed");
2303 }
2304
2305 // Run consensus step
2306 if let Err(e) = self.run_consensus_step().await {
2307 tracing::error!(error = %e, "Consensus step failed");
2308 }
2309 }
2310 }
2311 }
2312
2313 Ok(())
2314 }
2315
2316 /// Runs a single consensus step
2317 async fn run_consensus_step(&self) -> Result<()> {
2318 let is_leader = self.is_leader().await;
2319 let state = self.view_state.read().clone();
2320
2321 match state.phase {
2322 Phase::Prepare => {
2323 // While draining (graceful rollout) a leader skips proposing a
2324 // fresh block but still votes and re-broadcasts any block it
2325 // already proposed, so finality of in-flight work is unaffected.
2326 if is_leader && !self.is_draining() {
2327 // Leader proposes a block
2328 if state.proposed_block.is_none() {
2329 // PROPOSAL-GUARD (Aptos/Diem invariant): atomically
2330 // claim the current view as the "I am proposing for
2331 // view V" slot before we await the block builder. If
2332 // we already proposed for V (or any view ≥ V), bail.
2333 // Without this, the await on `propose_block_internal`
2334 // is a re-entrancy window: a Bracha-boost amplifying
2335 // to `on_view_timeout` can advance the view and the
2336 // next tick re-enters this branch, both ending up
2337 // calling `propose_block_internal` for the same
2338 // height with different mempool snapshots → two
2339 // different block hashes → self-equivocation. We use
2340 // `fetch_max` for the lock: it returns the previous
2341 // max; if `prev >= view` we already proposed for
2342 // this view (or a later one) and must NOT re-emit.
2343 let view_at_entry = state.view;
2344 let prev_proposed = self
2345 .last_proposed_view
2346 .fetch_max(view_at_entry, Ordering::SeqCst);
2347 if prev_proposed >= view_at_entry && view_at_entry != 0 {
2348 tracing::debug!(
2349 view = view_at_entry,
2350 last_proposed_view = prev_proposed,
2351 "proposal-guard: already proposed for this view; skipping re-entrant propose"
2352 );
2353 return Ok(());
2354 }
2355 drop(state);
2356 let block = self.propose_block_internal().await?;
2357 // Late-check: by the time the block is built, the
2358 // pacemaker may have advanced past the view we
2359 // committed to. Don't broadcast a stale-view
2360 // proposal — it can't be safely voted on by peers
2361 // (the inner `block.header.view` is now < current
2362 // view) and broadcasting it conflicts with the
2363 // proposal we'll emit for the new view.
2364 {
2365 let current_view = self.view_state.read().view;
2366 if current_view != view_at_entry {
2367 tracing::warn!(
2368 view_at_entry = view_at_entry,
2369 current_view = current_view,
2370 "proposal-guard: view advanced during block build; discarding stale proposal"
2371 );
2372 return Ok(());
2373 }
2374 }
2375 // Atomic check-and-set: only the FIRST winner of
2376 // the (view, propose) slot mutates `proposed_block`.
2377 // A concurrent re-entry (which we've already
2378 // returned from above) cannot reach this line, so
2379 // this write is the unique propose-record for the
2380 // view.
2381 self.view_state.write().proposed_block = Some(block.clone());
2382
2383 // Broadcast proposal to all peers so they can vote on it.
2384 // If we just recovered from a view timeout, attach the
2385 // TC we collected so peers can verify the previous view
2386 // was abandoned (Jolteon safe_to_extend predicate).
2387 let view = self.view_state.read().view;
2388 let timeout_certificate = self.last_round_tc.read().clone();
2389 // MonadBFT NEC (arXiv:2502.20692 §4): when proposing a
2390 // fresh block after a TC, attach the f+1 NEC we
2391 // collected so peers can verify no Prepare-QC formed at
2392 // the timed-out view. When reproposing the high_tip
2393 // (because a QC was observed), the NEC is omitted —
2394 // `propose_block_internal` returns the existing high_tip
2395 // block in that case, and `on_proposal` recognises the
2396 // hash match to skip the NEC requirement.
2397 let no_endorsement_certificate = {
2398 let is_repropose = timeout_certificate.as_ref().is_some_and(|tc| {
2399 self.fork_choice
2400 .select_best_block(block.header.height)
2401 .map(|b| b.hash() == block.hash())
2402 .unwrap_or(false)
2403 && *self.high_qc_view.read() >= tc.view
2404 });
2405 if timeout_certificate.is_some() && !is_repropose {
2406 self.last_round_nec.read().clone()
2407 } else {
2408 None
2409 }
2410 };
2411 // SyncInfo (#171): leader piggybacks its current
2412 // high_qc_view, capped at `view - 1` (genesis = 0).
2413 let high_qc_view = {
2414 let raw = *self.high_qc_view.read();
2415 if view == 0 { 0 } else { raw.min(view - 1) }
2416 };
2417 self.send_out(ConsensusOutMessage::Proposal {
2418 block: block.clone(),
2419 proposer: self.address,
2420 round: view,
2421 view,
2422 high_qc_view,
2423 timeout_certificate,
2424 no_endorsement_certificate,
2425 });
2426
2427 // Vote on our own proposal
2428 let _ = self.handle_prepare_phase(&block).await?;
2429 }
2430 } else {
2431 // Non-leader: wait for proposal or timeout
2432 // In production, this would receive proposals from the network
2433 }
2434 }
2435 Phase::Commit => {
2436 if is_leader && let Some(block) = state.proposed_block.clone() {
2437 drop(state);
2438 let _ = self.handle_commit_phase(&block).await?;
2439 }
2440 }
2441 Phase::Decide => {
2442 // Block finalized, advance to next view
2443 drop(state);
2444 self.advance_view().await?;
2445 }
2446 }
2447
2448 Ok(())
2449 }
2450
2451 /// Internal block proposal (called by consensus loop)
2452 async fn propose_block_internal(&self) -> Result<Block> {
2453 let (height, view) = {
2454 let state = self.view_state.read();
2455 (state.height, state.view)
2456 };
2457
2458 // MonadBFT tail-fork defence (arXiv:2502.20692 §4):
2459 // If we're recovering from a view timeout AND we observed a Prepare-QC
2460 // for the timed-out view (i.e. `high_qc_view >= tc.view`), we MUST
2461 // repropose the existing high_tip block at our current height — we
2462 // cannot legally fork it off without producing an NEC, which is
2463 // impossible in this state (an honest replica that saw the QC will
2464 // refuse to sign an NEC for that view). Honest leaders therefore
2465 // repropose; only when no QC was observed do they propose fresh
2466 // (with an attached NEC, which `run_consensus_step` builds before
2467 // broadcasting).
2468 let last_tc = self.last_round_tc.read().clone();
2469 let high_qc_view_local = *self.high_qc_view.read();
2470 if let Some(tc) = last_tc.as_ref()
2471 && high_qc_view_local >= tc.view
2472 && let Some(high_tip) = self.fork_choice.select_best_block(height)
2473 {
2474 tracing::info!(
2475 height = %height,
2476 view = %view,
2477 tc_view = tc.view,
2478 high_qc_view = high_qc_view_local,
2479 hash = %high_tip.hash(),
2480 "Reproposing high_tip after TC (QC observed at timed-out view)"
2481 );
2482 // The high_tip block is already in `self.blocks` and `fork_choice`.
2483 return Ok(high_tip);
2484 }
2485
2486 // Parent selection: prefer the fork-choice "highest QC view at
2487 // parent height" rule. Falls back to the last finalized hash if
2488 // fork choice has no candidate yet (cold start, post-restart pre-
2489 // warmup), preserving the previous behaviour as a safety net.
2490 let parent_height = height - 1u64;
2491 let prev_hash = self
2492 .fork_choice
2493 .select_best_block(parent_height)
2494 .map(|b| b.hash())
2495 .or_else(|| self.finality_tracker.get_finalized_hash(parent_height))
2496 .unwrap_or_default();
2497
2498 let state_root = self.state_root_provider
2499 .as_ref()
2500 .map(|p| p.current_state_root())
2501 .unwrap_or_default();
2502
2503 // Fetch parent metadata for EIP-1559 base-fee derivation. Try
2504 // the in-memory finality tracker first (fast path), then fall
2505 // back to the durable BlockProvider (post-restart path).
2506 // For the genesis-child case (height=1, parent height=0) this
2507 // resolves to the genesis block; if not found anywhere we fall
2508 // back to genesis-edge values which trigger `initial_base_fee`.
2509 let parent_block = self
2510 .finality_tracker
2511 .get_finalized_block(parent_height)
2512 .or_else(|| {
2513 self.block_provider
2514 .as_ref()
2515 .and_then(|p| p.get_block(parent_height))
2516 });
2517 let (parent_base_fee, parent_gas_used, parent_gas_limit) = match parent_block {
2518 Some(b) => (
2519 b.header.metadata.base_fee_per_gas,
2520 b.header.metadata.gas_used,
2521 b.header.metadata.gas_limit,
2522 ),
2523 None => {
2524 tracing::warn!(
2525 parent_height = %parent_height,
2526 "Parent block unavailable for base-fee derivation; falling back to initial fee"
2527 );
2528 (None, 0, 0)
2529 }
2530 };
2531
2532 let block = self.proposer.propose_block(
2533 height,
2534 view,
2535 prev_hash,
2536 self.address,
2537 state_root,
2538 parent_base_fee,
2539 parent_gas_used,
2540 parent_gas_limit,
2541 )?;
2542
2543 // Validate block size before broadcasting
2544 self.proposer.validate_block_size(&block)?;
2545
2546 self.blocks.insert(block.hash(), block.clone());
2547 // Mirror into fork choice (no QC yet — it'll be recorded by
2548 // `try_form_qc_and_drive_phase` once 2f+1 votes aggregate).
2549 self.fork_choice.add_block(block.clone(), None);
2550
2551 tracing::info!(
2552 height = %height,
2553 hash = %block.hash(),
2554 tx_count = block.tx_count(),
2555 "Block proposed"
2556 );
2557
2558 Ok(block)
2559 }
2560
2561 /// Finalizes a block on observation of a Commit QC.
2562 ///
2563 /// Replaces the trailing half of the original `handle_commit_phase` so the
2564 /// finalize logic is reachable from `on_vote` (peer-driven) as well as from
2565 /// the leader-driven step. Idempotent: if the block has already been
2566 /// finalized at a lower local height, the FinalityTracker rejects with
2567 /// `InvalidHeight` and we re-sync state without propagating the error.
2568 async fn finalize_with_commit_qc(
2569 &self,
2570 block: &Block,
2571 commit_qc: QuorumCertificate,
2572 ) -> Result<()> {
2573 // Idempotency guard — if we already finalized this height, skip.
2574 if self.finality_tracker.finalized_height() >= block.header.height {
2575 return Ok(());
2576 }
2577
2578 // A Commit QC at view V implies a Prepare QC at view V was observed —
2579 // bump high_qc_view if this is the highest we've seen.
2580 {
2581 let mut hqc = self.high_qc_view.write();
2582 if commit_qc.view > *hqc {
2583 *hqc = commit_qc.view;
2584 }
2585 }
2586
2587 // Embed the Commit QC into the block's `consensus_proof.proof_data` so
2588 // it persists alongside the block in storage and is carried over the
2589 // wire by the block-sync protocol. Without this, QCs live only in
2590 // memory and are lost on restart, and a syncing peer has no way to
2591 // verify that a served block was actually finalized.
2592 //
2593 // Pattern: HotStuff family (Aptos `BlockData::quorum_cert`,
2594 // Diem `BlockData::quorum_cert`, libhotstuff `Block::qc`) — each
2595 // block carries the QC certifying its parent. Here the block is the
2596 // newly-finalized block carrying the Commit QC over itself; the next
2597 // block produced will also point back to this block via `prev_hash`,
2598 // and the parent's QC chain is reconstructible from each successor's
2599 // embedded QC.
2600 //
2601 // Safety: `BlockHeader::hash()` does NOT cover `consensus_proof`, so
2602 // populating `proof_data` here does not change the block's hash.
2603 // Existing on-disk blocks (with empty `proof_data`) keep their
2604 // identities; new blocks finalized after this point carry verifiable
2605 // QCs.
2606 let mut block_with_qc = block.clone();
2607 match bincode::serialize(&commit_qc) {
2608 Ok(qc_bytes) => {
2609 block_with_qc.header.consensus_proof.proof_data = qc_bytes;
2610 }
2611 Err(e) => {
2612 // Serialization of an in-memory QC should never fail; if it
2613 // somehow does, we still proceed with the empty proof_data
2614 // path so finalization is not blocked. The receiving side
2615 // will simply be unable to verify this block via block-sync.
2616 tracing::error!(
2617 error = %e,
2618 height = %block.header.height,
2619 "Failed to serialize commit QC into block; proceeding without embedded QC"
2620 );
2621 }
2622 }
2623
2624 let transition_height = {
2625 let mut state = self.view_state.write();
2626 state.phase = Phase::Decide;
2627 state.commit_qc = Some(commit_qc.clone());
2628
2629 tracing::info!(
2630 view = state.view,
2631 height = %state.height,
2632 block_hash = %block_with_qc.hash(),
2633 "Commit QC observed — finalizing block"
2634 );
2635
2636 // Try to finalize. If the tracker disagrees with our height (rare,
2637 // happens after a restart where storage tip ≠ view state), re-sync
2638 // and bail without propagating.
2639 if let Err(e) = self
2640 .finality_tracker
2641 .finalize_block(block_with_qc.clone(), commit_qc.clone())
2642 {
2643 let corrected_height = self.finality_tracker.finalized_height() + 1u64;
2644 tracing::warn!(
2645 error = %e,
2646 stale_height = %state.height,
2647 corrected_height = %corrected_height,
2648 "Finalization failed — re-syncing height to finality tracker"
2649 );
2650 state.height = corrected_height;
2651 state.phase = Phase::Prepare;
2652 state.proposed_block = None;
2653 state.prepare_qc = None;
2654 state.commit_qc = None;
2655 state.reset_timer();
2656 return Ok(());
2657 }
2658
2659 // Feed the observed view latency into the adaptive base-
2660 // timeout tuner. The base_timeout self-tracks the cluster's
2661 // empirical quorum-formation latency so an open validator
2662 // set with heterogeneous topology converges to a stable
2663 // rate without operator hand-tuning.
2664 let observed_view_latency = state.time_in_view();
2665 self.view_timer
2666 .write()
2667 .on_success(Some(observed_view_latency));
2668
2669 // Advance height + view, reset phase
2670 state.height = state.height + 1u64;
2671 state.view += 1;
2672 state.phase = Phase::Prepare;
2673 state.proposed_block = None;
2674 state.prepare_qc = None;
2675 state.commit_qc = None;
2676 state.reset_timer();
2677 state.height
2678 };
2679
2680 // Feed LeaderReputation from committed metadata only — identical
2681 // inputs on every node (see record_committed_round_metadata).
2682 self.record_committed_round_metadata(block, &commit_qc);
2683
2684 // Remove finalized transactions from mempool
2685 let tx_hashes: Vec<Hash> =
2686 block.transactions.iter().map(|tx| tx.transaction.hash()).collect();
2687 self.mempool.remove_transactions(&tx_hashes);
2688
2689 // Epoch transition (vote_collector.write() — must not hold view_state lock here)
2690 self.transition_epoch_if_due(transition_height)?;
2691
2692 Ok(())
2693 }
2694
2695 /// Records leader-reputation metadata for a freshly-finalized block,
2696 /// derived exclusively from committed data so every node feeds its
2697 /// reputation history identical inputs (determinism by induction):
2698 ///
2699 /// - **Success** for `(block.header.view, block.header.proposer)` —
2700 /// the header binds the view at which the block was built, covering
2701 /// TC-justified reproposals too (a reproposed high-tip is
2702 /// byte-identical, so view/proposer remain the original leader's).
2703 /// - **Voters** from the finalizing Commit QC.
2704 /// - **Failures** for every view in the gap between the parent
2705 /// block's view and this block's view — those views demonstrably
2706 /// produced no finalized block; the elected leader for each is
2707 /// re-derived under the epoch's fixed seed anchor.
2708 ///
2709 /// Local view timeouts are deliberately NOT recorded — they are
2710 /// node-local observations that diverge across the fleet, which would
2711 /// diverge the reputation histories and therefore the elected
2712 /// leaders. See `on_view_timeout`.
2713 fn record_committed_round_metadata(
2714 &self,
2715 block: &Block,
2716 commit_qc: &QuorumCertificate,
2717 ) {
2718 let Some(reputation) = self.reputation.as_ref() else {
2719 return;
2720 };
2721
2722 let view = block.header.view;
2723 reputation.record_round_outcome(view, block.header.proposer, true);
2724 let voters: Vec<Address> = commit_qc.votes.iter().map(|v| v.voter).collect();
2725 reputation.record_round_voters(view, voters);
2726
2727 // Derive failed rounds from the committed view gap. The parent
2728 // block comes from the wired BlockProvider (in-memory tracker
2729 // first, durable storage fallback); when unavailable (genesis,
2730 // test harnesses without a provider) gap derivation is skipped.
2731 let height = block.header.height.as_u64();
2732 if height == 0 {
2733 return;
2734 }
2735 let parent_height = BlockHeight::from(height - 1);
2736 let Some(parent_view) = self
2737 .block_provider
2738 .as_ref()
2739 .and_then(|bp| bp.get_block(parent_height))
2740 .map(|parent| parent.header.view)
2741 else {
2742 return;
2743 };
2744 if parent_view + 1 >= view {
2745 return;
2746 }
2747 let gap_start = (parent_view + 1).max(view.saturating_sub(MAX_DERIVED_GAP_FAILURES));
2748 for failed_view in gap_start..view {
2749 // Skip silently when election cannot resolve (e.g. validator
2750 // unknown for the view) — better to drop one failure record
2751 // than to crash the finalize path.
2752 if let Ok(leader) = self.leader_for_view(failed_view) {
2753 reputation.record_round_outcome(failed_view, leader, false);
2754 }
2755 }
2756 }
2757}
2758
2759#[async_trait]
2760impl ConsensusEngine for HotStuff2Engine {
2761 async fn start(&mut self) -> Result<()> {
2762 let mut is_running = self.is_running.write();
2763 if *is_running {
2764 return Err(ConsensusError::AlreadyStarted);
2765 }
2766
2767 if !self.is_validator() {
2768 return Err(ConsensusError::InvalidValidatorSet(
2769 format!("Node {} is not a validator", self.address),
2770 ));
2771 }
2772
2773 // Initialize vote collector
2774 let validator_set = self.validator_set();
2775 let validator_set_arc = Arc::new(validator_set);
2776 *self.vote_collector.write() = Some(Arc::new(VoteCollector::new(
2777 validator_set_arc.clone(),
2778 )));
2779 // Initialize timeout collector (Bracha boost + 2f+1 TC formation)
2780 *self.timeout_collector.write() = Some(Arc::new(
2781 crate::timeout::TimeoutCollector::new(validator_set_arc.clone()),
2782 ));
2783 // Initialize no-endorsement collector (f+1 NEC formation, MonadBFT
2784 // tail-fork defence — arXiv:2502.20692)
2785 *self.nec_collector.write() = Some(Arc::new(
2786 crate::timeout::NoEndorsementCollector::new(validator_set_arc),
2787 ));
2788
2789 // Create shutdown channel
2790 let (shutdown_tx, _) = broadcast::channel(1);
2791 *self.shutdown_tx.write() = Some(shutdown_tx);
2792
2793 *is_running = true;
2794
2795 tracing::info!(
2796 address = %self.address,
2797 validators = self.validator_set().len(),
2798 "HotStuff-2 consensus engine started"
2799 );
2800
2801 // Spawn consensus loop
2802 let engine = self.clone();
2803 tokio::spawn(async move {
2804 if let Err(e) = engine.consensus_loop().await {
2805 tracing::error!(error = %e, "Consensus loop error");
2806 }
2807 });
2808
2809 Ok(())
2810 }
2811
2812 async fn stop(&mut self) -> Result<()> {
2813 let mut is_running = self.is_running.write();
2814 if !*is_running {
2815 return Err(ConsensusError::NotStarted);
2816 }
2817
2818 // Send shutdown signal
2819 if let Some(shutdown_tx) = self.shutdown_tx.read().as_ref() {
2820 let _ = shutdown_tx.send(());
2821 }
2822
2823 *is_running = false;
2824
2825 tracing::info!("HotStuff-2 consensus engine stopped");
2826
2827 Ok(())
2828 }
2829
2830 async fn propose_block(&self, _transactions: Vec<Transaction>) -> Result<Block> {
2831 if !self.is_leader().await {
2832 let view = self.view_state.read().view;
2833 return Err(ConsensusError::NotLeader(view));
2834 }
2835
2836 self.propose_block_internal().await
2837 }
2838
2839 async fn on_proposal(
2840 &self,
2841 block: &Block,
2842 timeout_certificate: Option<crate::timeout::TimeoutCertificate>,
2843 no_endorsement_certificate: Option<crate::timeout::NoEndorsementCertificate>,
2844 proposer_high_qc_view: u64,
2845 ) -> Result<Vote> {
2846 // RECEIVER-SIDE DEDUP (AptosBFT EpochManager::process_message
2847 // pattern): drop duplicate proposals at the door, keyed by
2848 // `(view, block_hash)`. A gossipsub IHAVE/IWANT replay, a peer-
2849 // forwarded copy of a proposal we already saw via the consensus-
2850 // direct overlay, or in-flight proposals from a previous buggy
2851 // run that were still circulating when we restarted on the fix
2852 // image — all land at `on_proposal` and would otherwise burn
2853 // CPU through the catchup / vote-state-store / equivocation
2854 // detector paths before the local replica's
2855 // `LastSignState::check_safe_to_sign` finally said "already
2856 // signed for this view; refusing". The vote-state-store is
2857 // correct end-of-line defence, but it noise up the equivocation
2858 // telemetry (each rejected vote logs a `DOUBLE-SIGN PREVENTED`
2859 // ERROR) and wastes a few hundred microseconds per re-deliver
2860 // on signature verification. Dropping at the door is cheaper
2861 // and keeps the equivocation log clean for real Byzantine
2862 // events. Pruned by `advance_view` alongside other per-view
2863 // caches.
2864 let dedup_key = (block.header.view, block.hash());
2865 // `entry().or_insert` is the canonical SeqCst-equivalent atomic
2866 // insertion-if-absent for `DashMap`. We don't care about the
2867 // returned ref — only the side effect of "was the slot empty?".
2868 // Re-check by trying to insert; if the slot was already populated
2869 // we drop the proposal silently.
2870 if !self.proposal_dedup.insert(dedup_key, ()).is_none() {
2871 tracing::debug!(
2872 view = block.header.view,
2873 hash = %block.hash(),
2874 "receiver-dedup: dropping duplicate proposal already seen at this view"
2875 );
2876 // Return a Vote-shaped error: the caller (event_loop)
2877 // doesn't broadcast on error, so a duplicate is silently
2878 // absorbed without re-broadcasting or re-voting.
2879 return Err(ConsensusError::Internal(
2880 "duplicate proposal (already processed at this view)".to_string(),
2881 ));
2882 }
2883
2884 // SyncInfo (#171, Aptos pattern): the proposer piggybacks its current
2885 // `high_qc_view` on every proposal. We adopt it if higher than our
2886 // own (subject to `< proposal_view`). This is the steady-state
2887 // backward-sync channel — a lagging replica that observes any honest
2888 // proposal can fast-forward without waiting for a TC or Prepare-QC of
2889 // its own. The bound prevents a Byzantine proposer from inflating the
2890 // signal to drag honest replicas into a forged future.
2891 let proposal_view_for_hqc = block.header.view;
2892 if proposer_high_qc_view < proposal_view_for_hqc {
2893 let mut hqc = self.high_qc_view.write();
2894 if proposer_high_qc_view > *hqc {
2895 tracing::debug!(
2896 local_high_qc_view = *hqc,
2897 proposer_high_qc_view,
2898 proposal_view = proposal_view_for_hqc,
2899 "Adopting proposer's high_qc_view from SyncInfo piggyback"
2900 );
2901 *hqc = proposer_high_qc_view;
2902 }
2903 }
2904
2905 // View sync: advance local view to match the proposal's view before
2906 // voting. This is the canonical HotStuff-2 Pacemaker rule (Malkhi &
2907 // Nayak 2023, Figure 2 step 3) and mirrors Aptos `round_manager.rs`
2908 // `ensure_round_and_sync_up`: when a proposal arrives at a higher view
2909 // than our local view, we sync up so our vote is stamped with the
2910 // proposer's view and lands in the same vote-collector bucket as the
2911 // proposer's self-vote and other peers' votes — otherwise validators
2912 // at drifted views never form a quorum at any single view (the bug
2913 // that pinned testnet at block_height=0).
2914 //
2915 // No numeric jump cap is applied here; safe_to_extend (below) is
2916 // the cryptographic gate. A proposal that skips views without a
2917 // verifiable TC for `proposal_view - 1` is rejected outright; a
2918 // proposal that carries one is provably safe to follow regardless
2919 // of how large the jump is.
2920 let proposal_view = block.header.view;
2921 let proposal_height = block.header.height;
2922 let (local_view, local_height) = {
2923 let state = self.view_state.read();
2924 (state.view, state.height)
2925 };
2926
2927 // Catchup-on-proposal (Aptos `ensure_blocks_in_storage` / Diem
2928 // `process_certificates` pattern): if the proposer is ahead by one
2929 // height, the proposal carries the parent's Commit QC in
2930 // `block.header.consensus_proof.proof_data`. We can apply that QC
2931 // locally to finalize the parent and advance our `view_state.height`
2932 // by one before height-checking the proposal. This unsticks the
2933 // 1-block tip fork that occurs when a validator misses a Commit
2934 // vote window (its view advances past `qc.view` before it can vote
2935 // Commit, so `on_vote` records the Prepare QC in `high_qc_view` but
2936 // never finalizes via `finalize_with_commit_qc`).
2937 //
2938 // Safe because: (1) the embedded QC is signed by 2f+1 of the current
2939 // epoch's validators (verified via `finalize_with_commit_qc` →
2940 // `finality_tracker.finalize_block` → QC signature verification);
2941 // (2) we only accept catchup-finalize for the parent of the
2942 // incoming proposal, never arbitrary jumps; (3) the parent block
2943 // must already be in `self.blocks` (every proposal forward-syncs
2944 // its block into the cache on `on_proposal`'s prelude).
2945 // Downward self-heal: `view_state.height` must never exceed
2946 // `finalized_height + 1`. Both finalize paths (on_vote and
2947 // finalize_with_commit_qc) advance `state.height` by `+1` relative
2948 // to its own in-memory value, gated on `finalize_block` SUCCESS.
2949 // If `view_state.height` ever drifts ahead of the FinalityTracker
2950 // (a transient finalize the tracker later rolled back, or a view
2951 // advancement without a matching tracker finalize), the gate at
2952 // line ~3006 then rejects every genuine `finalized_height + 1`
2953 // proposal as `InvalidHeight { expected: local_height, actual:
2954 // proposal_height }` because `proposal_height < local_height` — a
2955 // self-sustaining wedge, since block-sync can't bridge a NEGATIVE
2956 // gap either. The existing re-sync-down guards (the
2957 // `finalize_block` error arms in both finalize paths) only fire on
2958 // finalize FAILURE; they never catch a view_state that ran ahead
2959 // while the tracker stayed put.
2960 //
2961 // Heal it here: when an incoming proposal is for exactly the next
2962 // height the tracker expects (`finalized_height + 1`) yet our
2963 // `view_state.height` sits strictly above that, the proposal is the
2964 // real next block and we are the diverged node. Resync
2965 // `view_state.height` down to `finalized_height + 1` and re-read
2966 // `local_height` so the proposal passes the gate and we vote. This
2967 // is the mirror of the catchup-on-proposal below (which only heals
2968 // the proposer-AHEAD case); together they keep `view_state.height`
2969 // pinned to `finalized_height + 1` from both directions.
2970 let tracker_next_height = self.finality_tracker.finalized_height() + 1u64;
2971 if proposal_height == tracker_next_height && local_height > tracker_next_height {
2972 let mut state = self.view_state.write();
2973 // Re-check under the write lock — a concurrent finalize may have
2974 // already advanced the tracker.
2975 if state.height > tracker_next_height {
2976 tracing::warn!(
2977 stale_height = %state.height,
2978 corrected_height = %tracker_next_height,
2979 proposal_height = %proposal_height,
2980 "view_state.height ran ahead of finality tracker — \
2981 re-syncing down to finalized_height + 1 to accept the \
2982 genuine next proposal"
2983 );
2984 state.height = tracker_next_height;
2985 state.phase = Phase::Prepare;
2986 state.proposed_block = None;
2987 state.prepare_qc = None;
2988 state.commit_qc = None;
2989 state.reset_timer();
2990 }
2991 }
2992
2993 // Re-read local_height after the downward heal.
2994 let local_height = self.view_state.read().height;
2995
2996 if proposal_height == local_height + 1u64 {
2997 let parent_hash = block.header.prev_hash;
2998 let parent_known = self.blocks.contains_key(&parent_hash);
2999 let qc_bytes = block.header.consensus_proof.proof_data.as_slice();
3000 if parent_known && !qc_bytes.is_empty() {
3001 match bincode::deserialize::<QuorumCertificate>(qc_bytes) {
3002 Ok(parent_commit_qc) if parent_commit_qc.vote_type == VoteType::Commit
3003 && parent_commit_qc.block_hash == parent_hash
3004 && parent_commit_qc.height == local_height =>
3005 {
3006 // Look up the parent block from our local cache and
3007 // run the same finalize path that the leader-driven
3008 // commit takes. Idempotent if we somehow already
3009 // finalized.
3010 if let Some(parent_block) =
3011 self.blocks.get(&parent_hash).map(|r| r.clone())
3012 {
3013 tracing::info!(
3014 proposal_height = %proposal_height,
3015 parent_height = %local_height,
3016 qc_view = parent_commit_qc.view,
3017 "Catchup-on-proposal: finalizing missed parent \
3018 via QC embedded in incoming proposal"
3019 );
3020 if let Err(e) = self
3021 .finalize_with_commit_qc(&parent_block, parent_commit_qc)
3022 .await
3023 {
3024 tracing::warn!(
3025 error = %e,
3026 parent_height = %local_height,
3027 "Catchup-on-proposal: parent finalize failed; \
3028 falling through to regular height-mismatch reject"
3029 );
3030 }
3031 }
3032 }
3033 Ok(_) => {
3034 tracing::debug!(
3035 proposal_height = %proposal_height,
3036 "Catchup-on-proposal: embedded QC did not match parent (wrong height, \
3037 wrong block hash, or wrong vote_type) — skipping catchup"
3038 );
3039 }
3040 Err(e) => {
3041 tracing::debug!(
3042 error = %e,
3043 "Catchup-on-proposal: failed to deserialize parent commit QC; \
3044 skipping catchup"
3045 );
3046 }
3047 }
3048 }
3049 }
3050
3051 // Re-read local_height after the catchup attempt — finalize_with_commit_qc
3052 // bumps view_state.height on success.
3053 let local_height = self.view_state.read().height;
3054
3055 // Reject proposals for the wrong height outright — `validate_proposal`
3056 // will catch this too, but failing fast avoids an unnecessary view jump.
3057 if proposal_height != local_height {
3058 // We're behind the network and catchup-on-proposal couldn't bridge
3059 // the gap (parent not in cache, gap > 1, or finalize failed). Hint
3060 // block-sync so it engages even inside its normal tolerance window.
3061 if proposal_height > local_height {
3062 self.behind_hint_tx.send_replace(proposal_height.as_u64());
3063 }
3064 return Err(ConsensusError::InvalidHeight {
3065 expected: local_height,
3066 actual: proposal_height,
3067 });
3068 }
3069
3070 // Reject stale proposals (proposer was at a strictly lower view than
3071 // we are now). Voting on a stale proposal would re-introduce the bug.
3072 if proposal_view < local_view {
3073 tracing::debug!(
3074 proposal_view = proposal_view,
3075 local_view = local_view,
3076 height = %proposal_height,
3077 "Dropping stale proposal: proposer view < local view"
3078 );
3079 return Err(ConsensusError::InvalidProposal(format!(
3080 "stale proposal: view {} < local view {}",
3081 proposal_view, local_view
3082 )));
3083 }
3084
3085 // Proposer enforcement: the block's header must carry the leader
3086 // elected for the view it was built at. `header.view` binds the
3087 // original construction view, so this single rule covers both the
3088 // happy path and TC-justified reproposals (a reproposed high-tip
3089 // is byte-identical — header.view/proposer are the original
3090 // leader's, and the election re-derives the same answer). With
3091 // the per-epoch seed anchor the election is deterministic
3092 // fleet-wide, so an honest proposer can never be falsely
3093 // rejected. If election itself fails (validator set unknown for
3094 // the view) we fall through rather than reject — the QC quorum
3095 // still gates finalization.
3096 if let Ok(expected_leader) = self.leader_for_view(block.header.view) {
3097 if block.header.proposer != expected_leader {
3098 tracing::warn!(
3099 proposal_view = block.header.view,
3100 proposer = %block.header.proposer,
3101 expected = %expected_leader,
3102 height = %proposal_height,
3103 "Rejecting proposal: proposer is not the elected leader for its view"
3104 );
3105 return Err(ConsensusError::InvalidProposal(format!(
3106 "proposer {} is not the elected leader {} for view {}",
3107 block.header.proposer, expected_leader, block.header.view
3108 )));
3109 }
3110 }
3111
3112 // safe_to_extend (Jolteon §3.5 / DiemBFT v4 §3.5):
3113 // A proposal at round r is safe to vote on iff
3114 // (a) r == high_qc.round + 1 (happy path), OR
3115 // (b) r == tc.round + 1 (timeout recovery), AND
3116 // high_qc.round ≥ max(tc.high_qc_rounds across signers).
3117 //
3118 // We don't piggyback `qc` on every proposal yet (#171), so the strongest
3119 // tractable check is:
3120 // - If `proposal_view > local_view + 1`, the proposer skipped views.
3121 // They MUST attach a TC for view `proposal_view - 1`. Reject otherwise.
3122 // - If a TC is attached, verify its signatures and that
3123 // `tc.view + 1 == proposal_view`. Then advance our own
3124 // `high_qc_view` if the TC reveals a higher one (Bracha-style sync).
3125 if let Some(ref tc) = timeout_certificate {
3126 // Verify the TC is well-formed and signed by 2f+1 validators of the
3127 // current epoch.
3128 let validator_set = self.validator_set();
3129 if let Err(e) = tc.verify(&validator_set) {
3130 tracing::warn!(
3131 proposal_view = proposal_view,
3132 tc_view = tc.view,
3133 error = %e,
3134 "Rejecting proposal: attached TC failed verification"
3135 );
3136 return Err(ConsensusError::InvalidProposal(format!(
3137 "invalid timeout certificate: {}",
3138 e
3139 )));
3140 }
3141 // The TC must be for the round immediately preceding the proposal.
3142 if tc.view + 1 != proposal_view {
3143 tracing::warn!(
3144 proposal_view = proposal_view,
3145 tc_view = tc.view,
3146 "Rejecting proposal: TC view + 1 != proposal view"
3147 );
3148 return Err(ConsensusError::InvalidProposal(format!(
3149 "TC view {} + 1 != proposal view {}",
3150 tc.view, proposal_view
3151 )));
3152 }
3153 // Adopt the TC's max high_qc view if higher than ours — this is the
3154 // backward-sync mechanism that lets a lagging replica catch up to
3155 // the chain's true high_qc without waiting for a Prepare QC of
3156 // its own.
3157 let tc_max_hqc = tc.max_high_qc_view();
3158 {
3159 let mut hqc = self.high_qc_view.write();
3160 if tc_max_hqc > *hqc {
3161 *hqc = tc_max_hqc;
3162 }
3163 }
3164
3165 // MonadBFT tail-fork defence (arXiv:2502.20692 §4):
3166 // After a TC for view v-1, the leader has two legal options:
3167 // (a) repropose the existing `high_tip` (block at the highest
3168 // observed Prepare-QC), OR
3169 // (b) propose a fresh block, but only if it can prove that no
3170 // Prepare-QC formed at the timed-out view v-1 — by attaching
3171 // an f+1 NoEndorsementCertificate.
3172 //
3173 // Without this rule, a Byzantine leader could silently fork off a
3174 // QC that 2f+1 honest replicas observed, capturing tail-MEV. The
3175 // NEC forces public attestation: f+1 validators must sign that
3176 // they did not see the QC, which is impossible if a QC actually
3177 // formed (since it took 2f+1 votes — and at most f are Byzantine,
3178 // so at least f+1 honest replicas saw it and won't sign an NEC).
3179 let high_qc_view_local = *self.high_qc_view.read();
3180 let timed_out_view = tc.view;
3181 let is_repropose_of_high_tip = {
3182 // Compare against the most-recent fork-choice high_tip at the
3183 // proposal's height. If we have no high_tip record (cold start
3184 // or genesis-edge), fall back to permitting reproposal (the
3185 // safe-to-extend predicate above already filtered stale TCs).
3186 self.fork_choice
3187 .select_best_block(proposal_height)
3188 .map(|b| b.hash() == block.hash())
3189 .unwrap_or(false)
3190 };
3191 let has_qc_at_timed_out_view = high_qc_view_local >= timed_out_view;
3192
3193 if !is_repropose_of_high_tip {
3194 // Fresh block after TC: the leader claims no QC formed at the
3195 // timed-out view. Require an NEC to back that claim.
3196 let nec = no_endorsement_certificate.as_ref().ok_or_else(|| {
3197 tracing::warn!(
3198 proposal_view = proposal_view,
3199 tc_view = tc.view,
3200 block_hash = ?block.hash(),
3201 "Rejecting fresh block after TC with no NoEndorsementCertificate"
3202 );
3203 ConsensusError::InvalidProposal(format!(
3204 "fresh block at view {} after TC requires NoEndorsementCertificate \
3205 for view {}",
3206 proposal_view, timed_out_view
3207 ))
3208 })?;
3209 if nec.view != timed_out_view {
3210 tracing::warn!(
3211 proposal_view = proposal_view,
3212 tc_view = tc.view,
3213 nec_view = nec.view,
3214 "Rejecting fresh block after TC: NEC view mismatch"
3215 );
3216 return Err(ConsensusError::InvalidProposal(format!(
3217 "NEC view {} != timed-out view {}",
3218 nec.view, timed_out_view
3219 )));
3220 }
3221 let validator_set = self.validator_set();
3222 if let Err(e) = nec.verify(&validator_set) {
3223 tracing::warn!(
3224 proposal_view = proposal_view,
3225 nec_view = nec.view,
3226 error = %e,
3227 "Rejecting fresh block after TC: NEC failed verification"
3228 );
3229 return Err(ConsensusError::InvalidProposal(format!(
3230 "invalid NoEndorsementCertificate: {}",
3231 e
3232 )));
3233 }
3234 if has_qc_at_timed_out_view {
3235 tracing::warn!(
3236 proposal_view = proposal_view,
3237 tc_view = tc.view,
3238 local_high_qc_view = high_qc_view_local,
3239 "Rejecting fresh block after TC: NEC contradicts locally observed QC"
3240 );
3241 return Err(ConsensusError::InvalidProposal(format!(
3242 "NEC at view {} contradicts locally observed high_qc view {}",
3243 timed_out_view, high_qc_view_local
3244 )));
3245 }
3246 }
3247 } else if proposal_view > local_view + 1 {
3248 // No TC, but the leader skipped views. This violates safe_to_extend
3249 // — refuse to vote rather than risk extending an unsafe branch.
3250 tracing::warn!(
3251 proposal_view = proposal_view,
3252 local_view = local_view,
3253 "Rejecting proposal: view jump > 1 with no timeout certificate"
3254 );
3255 return Err(ConsensusError::InvalidProposal(format!(
3256 "proposal view {} jumps from local view {} without timeout certificate",
3257 proposal_view, local_view
3258 )));
3259 }
3260
3261 // Store the received block keyed by hash so that when the Prepare/Commit
3262 // QC forms (driven by peer votes arriving via `on_vote`), we can look
3263 // the block back up to drive phase transitions and finalization. Without
3264 // this, only the leader (which inserts in `propose_block_internal`) can
3265 // advance past Prepare — the bug that wedged height=1 indefinitely.
3266 self.blocks.insert(block.hash(), block.clone());
3267 // Mirror into fork choice — receivers learn about new blocks here
3268 // before any local QC observation; the QC is recorded later in
3269 // `try_form_qc_and_drive_phase` once votes aggregate locally.
3270 self.fork_choice.add_block(block.clone(), None);
3271
3272 // Advance local view to match the proposer's view. The
3273 // safe_to_extend block above (DiemBFT v4 §3.5) is the cryptographic
3274 // gate — by reaching this point the jump has been proven legal:
3275 // either `proposal_view == local_view + 1` (happy-path consecutive
3276 // view), or a verified TC for view `proposal_view - 1` was
3277 // attached, which is itself a 2f+1 cryptographic proof that the
3278 // timed-out view was abandoned by an honest super-majority. A
3279 // Byzantine proposer cannot forge that proof, so no numeric jump
3280 // cap is needed and any cap would simply wedge honest replicas
3281 // that have legitimately fallen behind by many thousands of views.
3282 if proposal_view > local_view {
3283 let mut state = self.view_state.write();
3284 // Re-check under the write lock — another task may have advanced
3285 // us in between.
3286 if proposal_view > state.view {
3287 tracing::info!(
3288 from_view = state.view,
3289 to_view = proposal_view,
3290 height = %state.height,
3291 "View sync: advancing local view to match proposal"
3292 );
3293 state.view = proposal_view;
3294 state.phase = Phase::Prepare;
3295 state.proposed_block = None;
3296 state.prepare_qc = None;
3297 state.commit_qc = None;
3298 state.reset_timer();
3299 }
3300 }
3301
3302 let phase = {
3303 let state = self.view_state.read();
3304 state.phase
3305 };
3306
3307 match phase {
3308 Phase::Prepare => {
3309 self.handle_prepare_phase(block).await?;
3310 self.create_vote(block, VoteType::Prepare)
3311 }
3312 Phase::Commit => {
3313 self.handle_commit_phase(block).await?;
3314 self.create_vote(block, VoteType::Commit)
3315 }
3316 Phase::Decide => {
3317 Err(ConsensusError::InvalidProposal(
3318 "Already in decide phase".to_string(),
3319 ))
3320 }
3321 }
3322 }
3323
3324 async fn on_vote(&self, vote: &Vote) -> Result<()> {
3325 // SyncInfo (#171, Aptos pattern): every vote piggybacks the voter's
3326 // `high_qc_view`. Adopt it if higher than our local view, so a lagging
3327 // replica receiving votes for a future view can fast-forward without
3328 // a separate sync RPC. The bound `< vote.view` is enforced upstream
3329 // by `add_vote()` in the voter — Byzantine voters can't claim a future
3330 // high_qc beyond what they're voting for.
3331 {
3332 let mut hqc = self.high_qc_view.write();
3333 if vote.high_qc_view > *hqc {
3334 tracing::debug!(
3335 voter = %vote.voter,
3336 voter_high_qc = vote.high_qc_view,
3337 local_high_qc = *hqc,
3338 "Adopting voter's high_qc_view from SyncInfo piggyback"
3339 );
3340 *hqc = vote.high_qc_view;
3341 }
3342 }
3343
3344 // Add the peer vote to the collector. If a QC forms, we must drive
3345 // phase progression (Prepare→Commit→Decide) here — the run_consensus_step
3346 // loop only progresses the *leader's* phase via handle_prepare/commit;
3347 // replicas otherwise sit in Phase::Prepare forever, which is exactly
3348 // the bug that wedged height=1 indefinitely on the live testnet
3349 // (Prepare QCs at view=135, 136, 137… all formed, all ignored).
3350 let qc_opt = {
3351 let vote_collector = self.vote_collector.read();
3352 let collector = match vote_collector.as_ref() {
3353 Some(c) => c,
3354 None => return Err(ConsensusError::NotStarted),
3355 };
3356 match collector.add_vote(vote.clone()) {
3357 Ok(qc) => qc,
3358 Err(ConsensusError::Equivocation { ref validator, view }) => {
3359 // Equivocation detected — trigger slashing via callback
3360 tracing::error!(
3361 validator = %validator,
3362 view = view,
3363 "EQUIVOCATION: Validator voted for conflicting blocks. \
3364 Triggering slashing."
3365 );
3366
3367 // Invoke the slashing callback to slash the validator's stake
3368 if let Some(ref callback) = self.slashing_callback
3369 && let Some(evidence) = collector.get_evidence_for(&vote.voter, view)
3370 {
3371 callback.report_equivocation(&vote.voter, view, &evidence);
3372 }
3373
3374 return Err(ConsensusError::Equivocation {
3375 validator: validator.clone(),
3376 view,
3377 });
3378 }
3379 Err(e) => return Err(e),
3380 }
3381 }; // vote_collector read lock dropped before driving phase progression
3382
3383 // Pacemaker advance via inbound SyncInfo (Jolteon §3.5 / DiemBFT v4):
3384 // a vote whose `high_qc_view = Q` is observable evidence of a 2f+1
3385 // Prepare QC at view Q — the QC is itself a 2f+1 aggregate, so a
3386 // single piece of evidence suffices to advance the pacemaker. The
3387 // next legal proposal will be at view Q+1, so any replica still
3388 // sitting at view ≤ Q is provably behind and should fast-forward.
3389 //
3390 // SECURITY: This adoption runs *after* `add_vote` returned Ok,
3391 // which means the vote's hybrid signature has been verified
3392 // against the registered validator's keys (voter.rs:454). The
3393 // Vote's canonical signing payload binds `(view, voter,
3394 // high_qc_view, …)` (voter.rs:106), so a Byzantine signer cannot
3395 // forge a `high_qc_view` value without invalidating their
3396 // signature. The cryptographic gate is sufficient on its own; no
3397 // numeric jump cap is applied, since any cap would wedge honest
3398 // replicas that have legitimately fallen behind by many thousands
3399 // of views.
3400 //
3401 // This is the canonical Aptos/DiemBFT recovery channel — see
3402 // `aptos-core/consensus/src/round_manager.rs::process_certificates`.
3403 if vote.high_qc_view > 0 {
3404 let target = vote.high_qc_view.saturating_add(1);
3405 let mut state = self.view_state.write();
3406 if target > state.view {
3407 tracing::info!(
3408 from_view = state.view,
3409 to_view = target,
3410 height = %state.height,
3411 voter = %vote.voter,
3412 "Pacemaker: advancing local view via vote SyncInfo high_qc_view+1"
3413 );
3414 state.view = target;
3415 state.phase = Phase::Prepare;
3416 state.proposed_block = None;
3417 state.prepare_qc = None;
3418 state.commit_qc = None;
3419 state.reset_timer();
3420 }
3421 }
3422
3423 let qc = match qc_opt {
3424 Some(qc) => qc,
3425 None => return Ok(()), // not a quorum yet, just collected
3426 };
3427
3428 // Record the QC into fork choice. `record_qc` is idempotent + monotonic
3429 // by view, so re-observing a QC for a block we already know is a
3430 // no-op, and a higher-view QC for the same block hash supersedes
3431 // a lower-view one. No-ops if the block isn't known locally yet
3432 // (the same warn path below catches that case).
3433 self.fork_choice.record_qc(qc.clone());
3434
3435 // Look up the block this QC commits to. Available locally because
3436 // `on_proposal` (and `propose_block_internal`) insert into self.blocks.
3437 let block = match self.blocks.get(&qc.block_hash).map(|r| r.clone()) {
3438 Some(b) => b,
3439 None => {
3440 tracing::warn!(
3441 view = qc.view,
3442 height = qc.height.0,
3443 block_hash = %qc.block_hash,
3444 vote_type = ?qc.vote_type,
3445 "QC formed but block not in local cache — cannot drive phase progression"
3446 );
3447 return Ok(());
3448 }
3449 };
3450
3451 match qc.vote_type {
3452 VoteType::Prepare => {
3453 // Emit our Commit vote on observation of the Prepare QC.
3454 //
3455 // Previously this was gated by `state.phase == Phase::Prepare
3456 // && state.view == qc.view`. That gate caused the 2026-06-08
3457 // testnet stall: when the local pacemaker advanced past `qc.view`
3458 // (Bracha boost, vote-piggybacked SyncInfo, etc.) before the
3459 // Prepare QC arrived locally, we'd suppress our Commit vote.
3460 // Multiple validators in the cluster hit this race per view, so
3461 // Commit-QC quorum never formed → block never finalized →
3462 // next-height proposals had no parent QC to carry → 1-block
3463 // tip fork → live-lock.
3464 //
3465 // The fix (Aptos/Diem/Jolteon Safety Rules pattern): drop the
3466 // local view gate and rely on `create_vote` + `vote_state_store`
3467 // for the safety guard. `create_vote` calls
3468 // `vote_state_store.record_or_reject` which enforces strict
3469 // monotonicity: any attempt to cast a vote at `(v, h, step)`
3470 // that is not strictly after the last persisted signing tuple
3471 // returns `Equivocation` and the vote is dropped. So even if
3472 // we re-enter this path for a Prepare QC after our view has
3473 // moved on, we cannot double-sign at a conflicting (view,
3474 // height, step) — the vote-state-store is the cryptographic
3475 // safety net, the local view was just a stale liveness gate
3476 // we don't actually need.
3477 //
3478 // Idempotency for the recovery case: if `create_vote` returns
3479 // an Equivocation error we treat it as "already voted, no
3480 // action" and continue. Phase/state are bumped only when our
3481 // view actually matched the QC — this preserves the proposer
3482 // path's invariants while letting recovery emit votes
3483 // unconditionally.
3484 let phase_matches = {
3485 let mut state = self.view_state.write();
3486 if state.phase == Phase::Prepare && state.view == qc.view {
3487 state.phase = Phase::Commit;
3488 state.prepare_qc = Some(qc.clone());
3489 if state.proposed_block.is_none() {
3490 state.proposed_block = Some(block.clone());
3491 }
3492 tracing::info!(
3493 view = state.view,
3494 height = %state.height,
3495 "Prepare QC observed via on_vote — advancing to Commit phase"
3496 );
3497 true
3498 } else {
3499 false
3500 }
3501 };
3502
3503 // Track high_qc_view independent of phase-advance decision —
3504 // even an "already past Prepare" replica should learn the
3505 // highest Prepare QC view for future TimeoutMsg construction.
3506 {
3507 let mut hqc = self.high_qc_view.write();
3508 if qc.view > *hqc {
3509 *hqc = qc.view;
3510 }
3511 }
3512
3513 // Emit Commit vote unconditionally (subject to vote-state-store
3514 // double-sign protection inside `create_vote`).
3515 match self.create_vote(&block, VoteType::Commit) {
3516 Ok(commit_vote) => {
3517 if !phase_matches {
3518 tracing::info!(
3519 view = qc.view,
3520 local_view = self.view_state.read().view,
3521 height = %qc.height,
3522 "Recovery: emitting Commit vote for Prepare QC at past view \
3523 — local pacemaker moved on but vote_state_store permits the vote"
3524 );
3525 }
3526 self.send_out(ConsensusOutMessage::Vote(commit_vote.clone()));
3527
3528 // Add our own Commit vote to the collector so we count
3529 // toward the Commit quorum without waiting for the gossip
3530 // round-trip.
3531 let collector_qc = {
3532 let vote_collector = self.vote_collector.read();
3533 match vote_collector.as_ref() {
3534 Some(collector) => collector.add_vote(commit_vote)?,
3535 None => return Err(ConsensusError::NotStarted),
3536 }
3537 };
3538
3539 // If our self-Commit vote completed the Commit quorum
3540 // (e.g. we were the last vote needed), recurse into
3541 // finalization immediately.
3542 if let Some(commit_qc) = collector_qc
3543 && commit_qc.vote_type == VoteType::Commit
3544 {
3545 self.finalize_with_commit_qc(&block, commit_qc).await?;
3546 }
3547 }
3548 Err(ConsensusError::Equivocation { .. }) => {
3549 // Vote-state-store says we've already signed at or
3550 // past this (view, height, step). Treat as idempotent
3551 // recovery no-op — DO NOT propagate the error: this
3552 // path is a recovery channel, not a misbehavior signal.
3553 tracing::debug!(
3554 view = qc.view,
3555 height = %qc.height,
3556 "Recovery Commit-vote suppressed by vote_state_store (already signed)"
3557 );
3558 }
3559 Err(e) => return Err(e),
3560 }
3561 }
3562 VoteType::Commit => {
3563 self.finalize_with_commit_qc(&block, qc).await?;
3564 }
3565 }
3566
3567 Ok(())
3568 }
3569
3570 async fn finalized_height(&self) -> BlockHeight {
3571 self.finality_tracker.finalized_height()
3572 }
3573
3574 async fn is_leader(&self) -> bool {
3575 self.get_leader().map(|l| l == self.address).unwrap_or(false)
3576 }
3577}
3578
3579// Test-only inherent impl: exposes private inbound handlers
3580// (`on_proposal`, `on_vote`) so multi-engine integration tests can
3581// wire the cluster directly without spinning the full event-loop
3582// layer. NOT a production message route — production receives via the
3583// consensus_out channel + the node's event loop.
3584impl HotStuff2Engine {
3585 /// Test-only wrapper around `on_proposal`. See module-level note.
3586 #[doc(hidden)]
3587 pub async fn test_on_proposal(
3588 &self,
3589 block: &Block,
3590 timeout_certificate: Option<crate::timeout::TimeoutCertificate>,
3591 no_endorsement_certificate: Option<crate::timeout::NoEndorsementCertificate>,
3592 proposer_high_qc_view: u64,
3593 ) -> Result<Vote> {
3594 self.on_proposal(
3595 block,
3596 timeout_certificate,
3597 no_endorsement_certificate,
3598 proposer_high_qc_view,
3599 )
3600 .await
3601 }
3602
3603 /// Test-only wrapper around `on_vote`. See module-level note.
3604 #[doc(hidden)]
3605 pub async fn test_on_vote(&self, vote: &Vote) -> Result<()> {
3606 self.on_vote(vote).await
3607 }
3608}
3609
3610// Manual Clone implementation
3611impl Clone for HotStuff2Engine {
3612 fn clone(&self) -> Self {
3613 Self {
3614 keypair: self.keypair.clone(),
3615 pq_signing_key: self.pq_signing_key.clone(),
3616 bls_signing_key: self.bls_signing_key.clone(),
3617 composite_public_key: self.composite_public_key.clone(),
3618 address: self.address,
3619 config: self.config.clone(),
3620 epoch_manager: self.epoch_manager.clone(),
3621 mempool: self.mempool.clone(),
3622 proposer: self.proposer.clone(),
3623 vote_collector: self.vote_collector.clone(),
3624 finality_tracker: self.finality_tracker.clone(),
3625 view_state: self.view_state.clone(),
3626 view_timer: self.view_timer.clone(),
3627 blocks: self.blocks.clone(),
3628 fork_choice: self.fork_choice.clone(),
3629 is_running: self.is_running.clone(),
3630 drain: self.drain.clone(),
3631 shutdown_tx: self.shutdown_tx.clone(),
3632 slashing_callback: self.slashing_callback.clone(),
3633 state_root_provider: self.state_root_provider.clone(),
3634 block_provider: self.block_provider.clone(),
3635 consensus_out_tx: self.consensus_out_tx.clone(),
3636 vote_state_store: self.vote_state_store.clone(),
3637 high_qc_view: self.high_qc_view.clone(),
3638 last_round_tc: self.last_round_tc.clone(),
3639 timeout_collector: self.timeout_collector.clone(),
3640 last_round_nec: self.last_round_nec.clone(),
3641 nec_collector: self.nec_collector.clone(),
3642 last_proposed_view: self.last_proposed_view.clone(),
3643 proposal_dedup: self.proposal_dedup.clone(),
3644 proposer_election: self.proposer_election.clone(),
3645 reputation: self.reputation.clone(),
3646 behind_hint_tx: self.behind_hint_tx.clone(),
3647 }
3648 }
3649}
3650
3651#[cfg(test)]
3652mod tests {
3653 use super::*;
3654 use crate::validator::ValidatorInfo;
3655 use tenzro_crypto::KeyType;
3656
3657 fn create_test_validators(count: usize) -> Vec<ValidatorInfo> {
3658 (0..count)
3659 .map(|i| {
3660 let keypair = KeyPair::generate(KeyType::Ed25519).unwrap();
3661 // Convert tenzro_crypto::Address (20 bytes) to tenzro_types::Address (32 bytes)
3662 let crypto_addr = keypair.address();
3663 let mut addr_bytes = [0u8; 32];
3664 addr_bytes[..20].copy_from_slice(crypto_addr.as_bytes());
3665 let address = tenzro_types::primitives::Address::new(addr_bytes);
3666 let pq = MlDsaSigningKey::generate();
3667 let bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
3668 ValidatorInfo::new(
3669 address,
3670 keypair.public_key().clone(),
3671 pq.verifying_key_bytes().to_vec(),
3672 bls.public_key().to_bytes().to_vec(),
3673 1000 * (i as u128 + 1),
3674 )
3675 })
3676 .collect()
3677 }
3678
3679 /// In-memory `BlockProvider` used by tests that drive `on_proposal` /
3680 /// `handle_prepare_phase` directly. Real production wiring uses
3681 /// `NodeBlockProvider` (RocksDB-backed); the tests need a parent block at
3682 /// height 0 in scope so the EIP-1559 base-fee validation path can re-derive
3683 /// the child's base fee from the genesis-edge parent (`gas_limit==0`,
3684 /// `base_fee_per_gas==Some(initial_base_fee)`).
3685 struct TestBlockProvider {
3686 blocks: parking_lot::RwLock<
3687 std::collections::HashMap<BlockHeight, Block>,
3688 >,
3689 }
3690
3691 impl TestBlockProvider {
3692 fn new() -> Self {
3693 Self {
3694 blocks: parking_lot::RwLock::new(std::collections::HashMap::new()),
3695 }
3696 }
3697
3698 fn insert(&self, block: Block) {
3699 self.blocks.write().insert(block.header.height, block);
3700 }
3701 }
3702
3703 impl BlockProvider for TestBlockProvider {
3704 fn get_block(&self, height: BlockHeight) -> Option<Block> {
3705 self.blocks.read().get(&height).cloned()
3706 }
3707 }
3708
3709 /// Stamp the expected genesis-edge EIP-1559 base fee on a child block at
3710 /// height 1. Tests that exercise `on_proposal` against `build_test_engine*`
3711 /// must use this — the new consensus rule rejects child blocks whose
3712 /// `base_fee_per_gas` does not match the value derived from the parent.
3713 fn stamp_genesis_edge_base_fee(mut block: Block) -> Block {
3714 use tenzro_types::block::FeeMarketParams;
3715 block.header.metadata.base_fee_per_gas =
3716 Some(FeeMarketParams::default().initial_base_fee);
3717 block
3718 }
3719
3720 /// Build a synthetic genesis block (height=0) suitable for satisfying the
3721 /// `validate_base_fee` parent lookup in tests. Mirrors the real genesis
3722 /// block stamp from `tenzro_node::genesis`: `gas_limit==0` triggers the
3723 /// genesis-edge in `calculate_next_base_fee`, so the child at height 1
3724 /// adopts `FeeMarketParams::default().initial_base_fee`.
3725 fn build_test_genesis() -> Block {
3726 use tenzro_types::block::{
3727 BlockHeader, BlockMetadata, ConsensusAlgorithm, ConsensusProof,
3728 FeeMarketParams,
3729 };
3730 use tenzro_types::primitives::Address;
3731 let mut header = BlockHeader::new_at_view(
3732 BlockHeight::from(0),
3733 0,
3734 Hash::default(),
3735 Hash::default(),
3736 Hash::default(),
3737 Address::new([0u8; 32]),
3738 ConsensusProof::new(ConsensusAlgorithm::PBFT, Vec::new()),
3739 );
3740 header.metadata = BlockMetadata {
3741 gas_used: 0,
3742 gas_limit: 0,
3743 tx_count: 0,
3744 protocol_version: 1,
3745 base_fee_per_gas: Some(FeeMarketParams::default().initial_base_fee),
3746 };
3747 Block::new(header, vec![])
3748 }
3749
3750 /// Build a synthetic competing proposal at a fixed `height` and given
3751 /// `view`. Distinct views produce distinct block hashes, mirroring the
3752 /// single-height view churn that drives the OOM the count cap guards.
3753 fn build_test_block_at_view(height: u64, view: u64) -> Block {
3754 use tenzro_types::block::{
3755 BlockHeader, ConsensusAlgorithm, ConsensusProof,
3756 };
3757 use tenzro_types::primitives::Address;
3758 let header = BlockHeader::new_at_view(
3759 BlockHeight::from(height),
3760 view,
3761 Hash::default(),
3762 Hash::default(),
3763 Hash::default(),
3764 Address::new([0u8; 32]),
3765 ConsensusProof::new(ConsensusAlgorithm::PBFT, Vec::new()),
3766 );
3767 Block::new(header, vec![])
3768 }
3769
3770 #[test]
3771 fn evict_excess_blocks_keeps_highest_views() {
3772 // 100 competing proposals at one stuck height, views 0..100.
3773 let blocks: DashMap<Hash, Block> = DashMap::new();
3774 for view in 0..100u64 {
3775 let b = build_test_block_at_view(192_745, view);
3776 blocks.insert(b.hash(), b);
3777 }
3778 assert_eq!(blocks.len(), 100);
3779
3780 let evicted = evict_excess_blocks_by_view(&blocks, 10);
3781 assert_eq!(evicted, 90);
3782 assert_eq!(blocks.len(), 10);
3783
3784 // The 10 survivors must be the highest views (90..100): eviction
3785 // drops the stalest competing proposals first.
3786 let mut surviving_views: Vec<u64> =
3787 blocks.iter().map(|e| e.value().header.view).collect();
3788 surviving_views.sort_unstable();
3789 assert_eq!(surviving_views, (90..100).collect::<Vec<_>>());
3790 }
3791
3792 #[test]
3793 fn evict_excess_blocks_noop_under_cap() {
3794 let blocks: DashMap<Hash, Block> = DashMap::new();
3795 for view in 0..50u64 {
3796 let b = build_test_block_at_view(192_745, view);
3797 blocks.insert(b.hash(), b);
3798 }
3799 let evicted = evict_excess_blocks_by_view(&blocks, 4096);
3800 assert_eq!(evicted, 0);
3801 assert_eq!(blocks.len(), 50);
3802 }
3803
3804 /// Regression test for the height=0 testnet wedge surfaced 2026-04-28.
3805 ///
3806 /// Failure mode: a previous run of the validator votes through views
3807 /// 0..62 at height=1 without ever finalizing (because the gossipsub mesh
3808 /// was not warm). `PersistentVoteState` correctly records the highest
3809 /// vote (view=62, height=1) before the pod restarts. On restart, the new
3810 /// run resets `current_view` to 0 — but `vote_state.rs::check_vrs`
3811 /// (CometBFT CheckHRS) refuses every vote whose `(view, height, step)`
3812 /// is not strictly past the persisted tuple. Since 0..62 are all <= 62
3813 /// at the same height, EVERY vote the new run tries to cast is refused
3814 /// as "double-sign prevented", and the chain wedges at height=0 forever.
3815 ///
3816 /// `resume_from_height` must consult `vote_state_store` and jump the
3817 /// local view past the persisted last-vote view at the same height.
3818 #[tokio::test]
3819 async fn test_resume_from_height_jumps_view_past_persisted_vote() {
3820 use crate::vote_state::{LastSignState, MemoryVoteStateStore, VoteStateStore, VoteStep};
3821 use tenzro_types::primitives::Hash;
3822
3823 // Pre-load a vote state store with a vote at (view=62, height=1) — the
3824 // exact shape observed in the live testnet logs at 2026-04-28T08:20:Z.
3825 let store = Arc::new(MemoryVoteStateStore::new());
3826 let persisted = LastSignState {
3827 version: 1,
3828 view: 62,
3829 height: 1,
3830 step: VoteStep::Prepare,
3831 block_hash: Some(Hash::default()),
3832 signature: Some(vec![0u8; 64]),
3833 };
3834 store.record(&persisted).expect("record");
3835
3836 let keypair = KeyPair::generate(KeyType::Ed25519).unwrap();
3837 let pq = MlDsaSigningKey::generate();
3838 let bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
3839 let config = ConsensusConfig::default();
3840 let validators = create_test_validators(4);
3841 let epoch_manager = EpochManager::new(validators, 100).unwrap();
3842 let engine = HotStuff2Engine::new(keypair, pq, bls, config, epoch_manager)
3843 .with_vote_state_store(store);
3844
3845 // Storage tip is height=0 (nothing finalized) — exactly the testnet
3846 // boot state. Without the fix, view stays at 0 and every vote at
3847 // (view≤62, height=1) is refused. With the fix, view jumps to 63.
3848 engine.resume_from_height(BlockHeight(0));
3849
3850 let state = engine.view_state.read();
3851 assert_eq!(state.height, BlockHeight(1), "height must advance to 1 (next-to-propose)");
3852 assert!(
3853 state.view >= 63,
3854 "view must jump past persisted last_vote view=62 to avoid CheckHRS wedge — got view={}",
3855 state.view
3856 );
3857 }
3858
3859 /// Regression test for the **cross-height** view-restoration wedge
3860 /// surfaced 2026-04-30 on testnet (validator-2 logs showed persisted
3861 /// `(view=29999, height=29988, Commit)` while `next_height=29989`).
3862 ///
3863 /// Failure mode: the persisted `(view, height)` tuple is
3864 /// `(29999, 29988)` — a vote cast in the previous run at the *just-
3865 /// finalized* height. On restart, the storage tip is height 29988,
3866 /// so `next_height = 29989`. The previous guard (`persisted.height
3867 /// == next_height.0`) was `29988 == 29989` — false — so the view
3868 /// jump was skipped. The engine booted at view ~29988, advanced via
3869 /// 700 ms timeouts, and got refused by `vote_state.rs::check_vrs`
3870 /// for every view ≤ 29999 → wedge.
3871 ///
3872 /// Fix: adopt the persisted view ceiling **unconditionally**.
3873 /// `persisted.view` is a height-independent strict signing floor
3874 /// (DiemBFT v4 §3.5: `last_voted_round` is checked against `round`,
3875 /// never against `(round, height)`).
3876 #[tokio::test]
3877 async fn test_resume_from_height_jumps_view_past_persisted_vote_cross_height() {
3878 use crate::vote_state::{LastSignState, MemoryVoteStateStore, VoteStateStore, VoteStep};
3879 use tenzro_types::primitives::Hash;
3880
3881 // Pre-load a vote state store with a vote at (view=29999, height=29988, Commit)
3882 // — exactly the shape observed in the live testnet logs at 2026-04-30.
3883 let store = Arc::new(MemoryVoteStateStore::new());
3884 let persisted = LastSignState {
3885 version: 1,
3886 view: 29999,
3887 height: 29988,
3888 step: VoteStep::Commit,
3889 block_hash: Some(Hash::default()),
3890 signature: Some(vec![0u8; 64]),
3891 };
3892 store.record(&persisted).expect("record");
3893
3894 let keypair = KeyPair::generate(KeyType::Ed25519).unwrap();
3895 let pq = MlDsaSigningKey::generate();
3896 let bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
3897 let config = ConsensusConfig::default();
3898 let validators = create_test_validators(4);
3899 let epoch_manager = EpochManager::new(validators, 100).unwrap();
3900 let engine = HotStuff2Engine::new(keypair, pq, bls, config, epoch_manager)
3901 .with_vote_state_store(store);
3902
3903 // Storage tip is 29988 (just-finalized at the moment of the previous vote).
3904 // next_height becomes 29989 — different from persisted.height. Without the
3905 // fix, the view-jump guard (`persisted.height == next_height.0`) was false
3906 // → engine booted at view ~29988 → wedge.
3907 engine.resume_from_height(BlockHeight(29988));
3908
3909 let state = engine.view_state.read();
3910 assert_eq!(
3911 state.height,
3912 BlockHeight(29989),
3913 "height must advance to 29989 (next-to-propose)"
3914 );
3915 assert!(
3916 state.view >= 30000,
3917 "view must jump past persisted last_vote view=29999 even when \
3918 persisted.height ({}) != next_height ({}) — got view={}",
3919 persisted.height,
3920 29989,
3921 state.view
3922 );
3923 }
3924
3925 /// Regression test for the **high_qc_view restoration** live-lock
3926 /// surfaced 2026-05-23 on testnet (block height stalled at 55,539 for
3927 /// 4 days across all 10 validators).
3928 ///
3929 /// Failure mode: validators reboot from storage tip=55,539 (a block
3930 /// finalized via Commit-QC at some view V ~= 239,800). The old
3931 /// `resume_from_height` only consulted `vote_state_store` for the
3932 /// **signing** ceiling — it did NOT restore `high_qc_view` (the
3933 /// **locking** ceiling). After the resume, `high_qc_view` defaulted
3934 /// to 0. The leader at view N proposed a block extending a stale
3935 /// parent (because its lock was unrestored), peers refused to vote
3936 /// because their locks were at the correct higher view, the view
3937 /// timed out, NEC formed, and the cycle repeated forever —
3938 /// observable as `DOUBLE-SIGN PREVENTED` at multiple views with
3939 /// vote heights spanning 49,282..55,540.
3940 ///
3941 /// Fix (Diem/Aptos Safety Rules pattern): on boot, read the latest
3942 /// finalized block via the wired `BlockProvider`, extract
3943 /// `header.view` (the certifying Commit-QC view), and restore
3944 /// `high_qc_view` to that value. Mirrors `resume_from_synced_height`'s
3945 /// behavior but recovers the QC view from storage instead of taking
3946 /// it as a parameter.
3947 #[tokio::test]
3948 async fn test_resume_from_height_restores_high_qc_view_from_block_header() {
3949 use tenzro_types::block::{
3950 BlockHeader, BlockMetadata, ConsensusAlgorithm, ConsensusProof, FeeMarketParams,
3951 };
3952 use tenzro_types::primitives::{Address, Hash};
3953
3954 // Minimal in-memory BlockProvider that returns a single block at a
3955 // configured height with a configured certifying view.
3956 struct TestBlockProvider {
3957 height: BlockHeight,
3958 view: u64,
3959 }
3960 impl BlockProvider for TestBlockProvider {
3961 fn get_block(&self, height: BlockHeight) -> Option<Block> {
3962 if height != self.height {
3963 return None;
3964 }
3965 let mut header = BlockHeader::new_at_view(
3966 self.height,
3967 self.view,
3968 Hash::default(),
3969 Hash::default(),
3970 Hash::default(),
3971 Address::new([0u8; 32]),
3972 ConsensusProof::new(ConsensusAlgorithm::PBFT, Vec::new()),
3973 );
3974 header.metadata = BlockMetadata {
3975 gas_used: 0,
3976 gas_limit: 30_000_000,
3977 tx_count: 0,
3978 protocol_version: 1,
3979 base_fee_per_gas: Some(FeeMarketParams::default().initial_base_fee),
3980 };
3981 Some(Block::new(header, vec![]))
3982 }
3983 }
3984
3985 let stored_height = BlockHeight(55_539);
3986 let certifying_view: u64 = 239_800;
3987
3988 let provider: Arc<dyn BlockProvider> = Arc::new(TestBlockProvider {
3989 height: stored_height,
3990 view: certifying_view,
3991 });
3992 let store = Arc::new(crate::vote_state::MemoryVoteStateStore::new());
3993
3994 let keypair = KeyPair::generate(KeyType::Ed25519).unwrap();
3995 let pq = MlDsaSigningKey::generate();
3996 let bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
3997 let config = ConsensusConfig::default();
3998 let validators = create_test_validators(4);
3999 let epoch_manager = EpochManager::new(validators, 100).unwrap();
4000 let engine = HotStuff2Engine::new(keypair, pq, bls, config, epoch_manager)
4001 .with_block_provider(provider)
4002 .with_vote_state_store(store.clone());
4003
4004 // Before resume: high_qc_view defaults to 0.
4005 assert_eq!(*engine.high_qc_view.read(), 0);
4006
4007 engine.resume_from_height(stored_height);
4008
4009 // After resume: high_qc_view must be restored to the certifying view.
4010 assert_eq!(
4011 *engine.high_qc_view.read(),
4012 certifying_view,
4013 "high_qc_view must be restored to the certifying Commit-QC view of the latest finalized block"
4014 );
4015
4016 // Current view must be > certifying view so the engine cannot
4017 // propose or vote at any view ≤ the view that already produced a
4018 // Commit-QC.
4019 let state = engine.view_state.read();
4020 assert_eq!(
4021 state.height,
4022 BlockHeight(55_540),
4023 "height must advance to next-to-propose"
4024 );
4025 assert!(
4026 state.view > certifying_view,
4027 "view must be > certifying_view={} — got view={}",
4028 certifying_view,
4029 state.view
4030 );
4031 drop(state);
4032
4033 // The synthetic LastSignState must have been recorded so that a
4034 // crash-and-restart cannot regress the signing ceiling below the
4035 // certified state.
4036 use crate::vote_state::VoteStateStore;
4037 let persisted = store.load().expect("synthetic LastSignState must be recorded");
4038 assert_eq!(
4039 persisted.view, certifying_view,
4040 "synthetic LastSignState.view must equal certifying_view"
4041 );
4042 assert_eq!(
4043 persisted.height, stored_height.0,
4044 "synthetic LastSignState.height must equal stored_height"
4045 );
4046 }
4047
4048 #[tokio::test]
4049 async fn test_hotstuff2_creation() {
4050 let keypair = KeyPair::generate(KeyType::Ed25519).unwrap();
4051 let pq = MlDsaSigningKey::generate();
4052 let bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
4053 let config = ConsensusConfig::default();
4054 let validators = create_test_validators(4);
4055 let epoch_manager = EpochManager::new(validators, 100).unwrap();
4056
4057 let engine = HotStuff2Engine::new(keypair, pq, bls, config, epoch_manager);
4058 assert_eq!(engine.finalized_height().await, BlockHeight::from(0));
4059 }
4060
4061 #[tokio::test]
4062 async fn test_leader_selection() {
4063 let keypair = KeyPair::generate(KeyType::Ed25519).unwrap();
4064 let pq = MlDsaSigningKey::generate();
4065 let bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
4066 let crypto_addr = keypair.address();
4067 let mut addr_bytes = [0u8; 32];
4068 addr_bytes[..20].copy_from_slice(crypto_addr.as_bytes());
4069 let address = tenzro_types::primitives::Address::new(addr_bytes);
4070
4071 let validators = vec![
4072 ValidatorInfo::new(
4073 address,
4074 keypair.public_key().clone(),
4075 pq.verifying_key_bytes().to_vec(),
4076 bls.public_key().to_bytes().to_vec(),
4077 1000,
4078 ),
4079 create_test_validators(3)[0].clone(),
4080 ];
4081
4082 let config = ConsensusConfig::default();
4083 let epoch_manager = EpochManager::new(validators, 100).unwrap();
4084
4085 let engine = HotStuff2Engine::new(keypair, pq, bls, config, epoch_manager);
4086 let leader = engine.get_leader().unwrap();
4087
4088 // Leader should be one of the validators
4089 assert!(engine.validator_set().is_validator(&leader));
4090 }
4091
4092 /// Helper: build an engine where `validators[0]` is this node, plus
4093 /// `extra` additional validators. Returns the engine and the address of
4094 /// validators[1] (a remote validator we use as a proposer in tests).
4095 async fn build_test_engine(extra: usize) -> (HotStuff2Engine, tenzro_types::primitives::Address) {
4096 let mut validators = create_test_validators(extra + 1);
4097 let local_keypair = KeyPair::generate(KeyType::Ed25519).unwrap();
4098 let local_pq = MlDsaSigningKey::generate();
4099 let local_bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
4100 let local_crypto_addr = local_keypair.address();
4101 let mut local_addr_bytes = [0u8; 32];
4102 local_addr_bytes[..20].copy_from_slice(local_crypto_addr.as_bytes());
4103 let local_address = tenzro_types::primitives::Address::new(local_addr_bytes);
4104 validators[0] = ValidatorInfo::new(
4105 local_address,
4106 local_keypair.public_key().clone(),
4107 local_pq.verifying_key_bytes().to_vec(),
4108 local_bls.public_key().to_bytes().to_vec(),
4109 1000,
4110 );
4111 let proposer_addr = validators[1].address;
4112
4113 let config = ConsensusConfig::default();
4114 let epoch_manager = EpochManager::new(validators, 100).unwrap();
4115 let mut engine = HotStuff2Engine::new(local_keypair, local_pq, local_bls, config, epoch_manager);
4116
4117 // Wire a synthetic genesis block at height 0 so EIP-1559
4118 // `validate_base_fee` can re-derive the child's base fee from a
4119 // genesis-edge parent. Real production wires `NodeBlockProvider`.
4120 let block_provider = Arc::new(TestBlockProvider::new());
4121 block_provider.insert(build_test_genesis());
4122 engine = engine.with_block_provider(block_provider);
4123
4124 // Initialize the vote collector — `on_proposal` exercises it via
4125 // `handle_prepare_phase`. This is what `start()` does, but we don't
4126 // want to spawn the consensus loop in tests.
4127 let validator_set = Arc::new(engine.validator_set());
4128 *engine.vote_collector.write() = Some(Arc::new(VoteCollector::new(
4129 validator_set.clone(),
4130 )));
4131 *engine.timeout_collector.write() = Some(Arc::new(
4132 crate::timeout::TimeoutCollector::new(validator_set.clone()),
4133 ));
4134 *engine.nec_collector.write() = Some(Arc::new(
4135 crate::timeout::NoEndorsementCollector::new(validator_set),
4136 ));
4137
4138 (engine, proposer_addr)
4139 }
4140
4141 /// Regression test for the height=0 testnet bug: when a proposal arrives at
4142 /// a higher view than the local view, `on_proposal` must advance the local
4143 /// view to match the proposer's view BEFORE constructing the vote, so the
4144 /// vote is bucketed at the proposer's view and a quorum can form.
4145 ///
4146 /// Mirrors Aptos `ensure_round_and_sync_up` (consensus/src/round_manager.rs)
4147 /// and HotStuff-2 paper Figure 1/2 (Malkhi & Nayak, eprint 2023/397).
4148 ///
4149 /// Without the fix: each validator votes at its own drifted view, votes
4150 /// scatter across view-buckets, and no view ever reaches the threshold.
4151 #[tokio::test]
4152 async fn test_on_proposal_advances_local_view_to_match_proposer() {
4153 use tenzro_types::block::{Block, BlockHeader, ConsensusAlgorithm, ConsensusProof};
4154 use tenzro_types::primitives::{BlockHeight, Hash};
4155
4156 let (engine, proposer_addr) = build_test_engine(3).await;
4157
4158 // Simulate the testnet failure mode: this node's local view is one
4159 // behind the proposer's (the canonical happy-path next-view case;
4160 // larger view jumps require a TC and are covered in a dedicated
4161 // safe_to_extend test).
4162 {
4163 let mut state = engine.view_state.write();
4164 state.view = 16;
4165 state.height = BlockHeight::from(1);
4166 state.phase = Phase::Prepare;
4167 }
4168
4169 // Build a proposal at height=1, view=17 from `proposer_addr`.
4170 let header = BlockHeader::new_at_view(
4171 BlockHeight::from(1),
4172 17, // proposer's view
4173 Hash::default(),
4174 Hash::default(),
4175 Hash::default(),
4176 proposer_addr,
4177 ConsensusProof::new(ConsensusAlgorithm::PBFT, Vec::new()),
4178 );
4179 let block = stamp_genesis_edge_base_fee(Block::new(header, vec![]));
4180
4181 // Process the proposal — should advance local view AND produce a vote.
4182 let vote = engine.on_proposal(&block, None, None, 0).await.expect("on_proposal succeeded");
4183
4184 // The vote MUST be stamped at the proposer's view, not the stale
4185 // local view. This is the entire point of the fix.
4186 assert_eq!(
4187 vote.view, 17,
4188 "vote must be stamped at proposer's view (17), got {} — \
4189 without view-sync, votes from drifted-view validators never \
4190 form a quorum (testnet height=0 bug)",
4191 vote.view
4192 );
4193
4194 // Local view state must have been advanced.
4195 let final_view = engine.view_state.read().view;
4196 assert_eq!(
4197 final_view, 17,
4198 "local view must advance to proposer's view, got {}",
4199 final_view
4200 );
4201 }
4202
4203 /// Stale proposals (proposer at a STRICTLY lower view than local) must be
4204 /// rejected — voting on them would re-introduce the bucketing bug.
4205 #[tokio::test]
4206 async fn test_on_proposal_rejects_stale_view() {
4207 use tenzro_types::block::{Block, BlockHeader, ConsensusAlgorithm, ConsensusProof};
4208 use tenzro_types::primitives::{BlockHeight, Hash};
4209
4210 let (engine, proposer_addr) = build_test_engine(3).await;
4211
4212 // Local view is well ahead of an inbound stale proposal.
4213 {
4214 let mut state = engine.view_state.write();
4215 state.view = 20;
4216 state.height = BlockHeight::from(1);
4217 state.phase = Phase::Prepare;
4218 }
4219
4220 let header = BlockHeader::new_at_view(
4221 BlockHeight::from(1),
4222 5, // stale view
4223 Hash::default(),
4224 Hash::default(),
4225 Hash::default(),
4226 proposer_addr,
4227 ConsensusProof::new(ConsensusAlgorithm::PBFT, Vec::new()),
4228 );
4229 let block = Block::new(header, vec![]);
4230
4231 let result = engine.on_proposal(&block, None, None, 0).await;
4232 assert!(
4233 matches!(result, Err(ConsensusError::InvalidProposal(_))),
4234 "stale proposal must be rejected, got {:?}",
4235 result
4236 );
4237
4238 // Local view must be unchanged.
4239 assert_eq!(engine.view_state.read().view, 20);
4240 }
4241
4242 /// Helper: build an engine plus the keypairs and PQ keys for the OTHER
4243 /// validators (so tests can hybrid-sign a TimeoutMsg "from" a peer).
4244 /// Returns (engine, peer_keypair, peer_pq_key, peer_address).
4245 async fn build_test_engine_with_peer_signer() -> (
4246 HotStuff2Engine,
4247 KeyPair,
4248 MlDsaSigningKey,
4249 tenzro_types::primitives::Address,
4250 ) {
4251 // Inline the body of `create_test_validators` + `build_test_engine`
4252 // so the peer's keypair stays in scope for the test to sign with.
4253 let local_keypair = KeyPair::generate(KeyType::Ed25519).unwrap();
4254 let local_pq = MlDsaSigningKey::generate();
4255 let local_bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
4256 let local_crypto_addr = local_keypair.address();
4257 let mut local_addr_bytes = [0u8; 32];
4258 local_addr_bytes[..20].copy_from_slice(local_crypto_addr.as_bytes());
4259 let local_address = tenzro_types::primitives::Address::new(local_addr_bytes);
4260 let local_validator = ValidatorInfo::new(
4261 local_address,
4262 local_keypair.public_key().clone(),
4263 local_pq.verifying_key_bytes().to_vec(),
4264 local_bls.public_key().to_bytes().to_vec(),
4265 1000,
4266 );
4267
4268 let peer_keypair = KeyPair::generate(KeyType::Ed25519).unwrap();
4269 let peer_pq = MlDsaSigningKey::generate();
4270 let peer_bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
4271 let peer_crypto_addr = peer_keypair.address();
4272 let mut peer_addr_bytes = [0u8; 32];
4273 peer_addr_bytes[..20].copy_from_slice(peer_crypto_addr.as_bytes());
4274 let peer_address = tenzro_types::primitives::Address::new(peer_addr_bytes);
4275 let peer_validator = ValidatorInfo::new(
4276 peer_address,
4277 peer_keypair.public_key().clone(),
4278 peer_pq.verifying_key_bytes().to_vec(),
4279 peer_bls.public_key().to_bytes().to_vec(),
4280 2000,
4281 );
4282
4283 // Two extra filler validators so the set is realistic (4 total => f=1)
4284 let mut validators = vec![local_validator, peer_validator];
4285 for i in 0..2 {
4286 let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
4287 let crypto_addr = kp.address();
4288 let mut addr_bytes = [0u8; 32];
4289 addr_bytes[..20].copy_from_slice(crypto_addr.as_bytes());
4290 let addr = tenzro_types::primitives::Address::new(addr_bytes);
4291 let pq = MlDsaSigningKey::generate();
4292 let bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
4293 validators.push(ValidatorInfo::new(
4294 addr,
4295 kp.public_key().clone(),
4296 pq.verifying_key_bytes().to_vec(),
4297 bls.public_key().to_bytes().to_vec(),
4298 1000 * (i as u128 + 1),
4299 ));
4300 }
4301
4302 let config = ConsensusConfig::default();
4303 let epoch_manager = EpochManager::new(validators, 100).unwrap();
4304 let mut engine = HotStuff2Engine::new(local_keypair, local_pq, local_bls, config, epoch_manager);
4305
4306 // Wire a synthetic genesis block at height 0 so EIP-1559
4307 // `validate_base_fee` can re-derive the child's base fee.
4308 let block_provider = Arc::new(TestBlockProvider::new());
4309 block_provider.insert(build_test_genesis());
4310 engine = engine.with_block_provider(block_provider);
4311
4312 let validator_set = Arc::new(engine.validator_set());
4313 *engine.vote_collector.write() = Some(Arc::new(VoteCollector::new(
4314 validator_set.clone(),
4315 )));
4316 *engine.timeout_collector.write() = Some(Arc::new(
4317 crate::timeout::TimeoutCollector::new(validator_set.clone()),
4318 ));
4319 *engine.nec_collector.write() = Some(Arc::new(
4320 crate::timeout::NoEndorsementCollector::new(validator_set),
4321 ));
4322
4323 (engine, peer_keypair, peer_pq, peer_address)
4324 }
4325
4326 /// Hybrid-sign a TimeoutMsg "from" the given peer for the given view.
4327 fn peer_sign_timeout(
4328 view: u64,
4329 peer_address: tenzro_types::primitives::Address,
4330 peer_keypair: &KeyPair,
4331 peer_pq: &MlDsaSigningKey,
4332 ) -> crate::timeout::TimeoutMsg {
4333 peer_sign_timeout_with_hqc(view, view.saturating_sub(1), peer_address, peer_keypair, peer_pq)
4334 }
4335
4336 /// Hybrid-sign a TimeoutMsg with an explicit `high_qc_view` (must be `< view`).
4337 fn peer_sign_timeout_with_hqc(
4338 view: u64,
4339 high_qc_view: u64,
4340 peer_address: tenzro_types::primitives::Address,
4341 peer_keypair: &KeyPair,
4342 peer_pq: &MlDsaSigningKey,
4343 ) -> crate::timeout::TimeoutMsg {
4344 peer_sign_timeout_full(view, high_qc_view, 0, peer_address, peer_keypair, peer_pq)
4345 }
4346
4347 /// Hybrid-sign a TimeoutMsg with explicit `high_qc_view` and
4348 /// `finalized_height`.
4349 fn peer_sign_timeout_full(
4350 view: u64,
4351 high_qc_view: u64,
4352 finalized_height: u64,
4353 peer_address: tenzro_types::primitives::Address,
4354 peer_keypair: &KeyPair,
4355 peer_pq: &MlDsaSigningKey,
4356 ) -> crate::timeout::TimeoutMsg {
4357 use tenzro_crypto::composite::{
4358 CompositePublicKey, CompositeSignature, HybridSigner, InMemoryHybridSigner,
4359 };
4360 use tenzro_crypto::signatures::Ed25519SignerImpl;
4361
4362 let composite_pk = CompositePublicKey::new(
4363 peer_keypair.public_key().clone(),
4364 peer_pq.verifying_key_bytes().to_vec(),
4365 );
4366 let placeholder = CompositeSignature::new(Vec::new(), Vec::new());
4367 let unsigned = crate::timeout::TimeoutMsg::new(
4368 view,
4369 high_qc_view,
4370 finalized_height,
4371 peer_address,
4372 placeholder,
4373 composite_pk.clone(),
4374 );
4375 let payload = unsigned.signing_payload();
4376
4377 let kp_bytes = peer_keypair.to_bytes();
4378 let kp_copy = KeyPair::from_bytes(peer_keypair.key_type(), &kp_bytes).unwrap();
4379 let classical = Ed25519SignerImpl::new(kp_copy).unwrap();
4380 let pq_copy = MlDsaSigningKey::from_seed(peer_pq.seed_bytes()).unwrap();
4381 let signer = InMemoryHybridSigner::new(Box::new(classical), pq_copy);
4382
4383 let signature = signer.sign(&payload).unwrap();
4384 crate::timeout::TimeoutMsg::new(
4385 view,
4386 high_qc_view,
4387 finalized_height,
4388 peer_address,
4389 signature,
4390 composite_pk,
4391 )
4392 }
4393
4394 /// Hybrid-sign a NoEndorsementMsg "from" the given peer for the given view.
4395 fn peer_sign_no_endorsement(
4396 view: u64,
4397 peer_address: tenzro_types::primitives::Address,
4398 peer_keypair: &KeyPair,
4399 peer_pq: &MlDsaSigningKey,
4400 ) -> crate::timeout::NoEndorsementMsg {
4401 use tenzro_crypto::composite::{
4402 CompositePublicKey, CompositeSignature, HybridSigner, InMemoryHybridSigner,
4403 };
4404 use tenzro_crypto::signatures::Ed25519SignerImpl;
4405
4406 let composite_pk = CompositePublicKey::new(
4407 peer_keypair.public_key().clone(),
4408 peer_pq.verifying_key_bytes().to_vec(),
4409 );
4410 let placeholder = CompositeSignature::new(Vec::new(), Vec::new());
4411 let unsigned = crate::timeout::NoEndorsementMsg::new(
4412 view,
4413 peer_address,
4414 placeholder,
4415 composite_pk.clone(),
4416 );
4417 let payload = unsigned.signing_payload();
4418
4419 let kp_bytes = peer_keypair.to_bytes();
4420 let kp_copy = KeyPair::from_bytes(peer_keypair.key_type(), &kp_bytes).unwrap();
4421 let classical = Ed25519SignerImpl::new(kp_copy).unwrap();
4422 let pq_copy = MlDsaSigningKey::from_seed(peer_pq.seed_bytes()).unwrap();
4423 let signer = InMemoryHybridSigner::new(Box::new(classical), pq_copy);
4424
4425 let signature = signer.sign(&payload).unwrap();
4426 crate::timeout::NoEndorsementMsg::new(view, peer_address, signature, composite_pk)
4427 }
4428
4429 /// Receiving a TimeoutMsg at a strictly higher view must advance the
4430 /// local view counter — this is the DiemBFT-style backward-sync channel
4431 /// (#164).
4432 #[tokio::test]
4433 async fn test_on_timeout_msg_advances_local_view() {
4434 use tenzro_types::primitives::BlockHeight;
4435
4436 let (engine, peer_kp, peer_pq, peer_addr) =
4437 build_test_engine_with_peer_signer().await;
4438
4439 // Local view stuck at 5; peer is at 17.
4440 {
4441 let mut state = engine.view_state.write();
4442 state.view = 5;
4443 state.height = BlockHeight::from(1);
4444 state.phase = Phase::Commit; // arbitrary mid-phase state
4445 state.proposed_block = None;
4446 }
4447
4448 let msg = peer_sign_timeout(17, peer_addr, &peer_kp, &peer_pq);
4449 engine.on_timeout_msg(&msg).await.expect("valid timeout accepted");
4450
4451 let state = engine.view_state.read();
4452 assert_eq!(state.view, 17, "local view advances to peer view");
4453 assert!(matches!(state.phase, Phase::Prepare), "phase reset to Prepare");
4454 assert!(state.prepare_qc.is_none(), "stale prepare QC cleared");
4455 assert!(state.commit_qc.is_none(), "stale commit QC cleared");
4456 }
4457
4458 /// Receiving a TimeoutMsg at a *lower* view must be a no-op — this is
4459 /// the downgrade-attack defence: a peer cannot drag us backwards.
4460 #[tokio::test]
4461 async fn test_on_timeout_msg_ignores_lower_view() {
4462 let (engine, peer_kp, peer_pq, peer_addr) =
4463 build_test_engine_with_peer_signer().await;
4464
4465 {
4466 let mut state = engine.view_state.write();
4467 state.view = 50;
4468 }
4469
4470 let msg = peer_sign_timeout(20, peer_addr, &peer_kp, &peer_pq);
4471 engine.on_timeout_msg(&msg).await.expect("verified timeout silently ignored");
4472
4473 assert_eq!(engine.view_state.read().view, 50, "local view unchanged");
4474 }
4475
4476 /// A verified TimeoutMsg advertising a finalized height above ours must
4477 /// fire the behind-hint watch channel — this is the finalization-skew
4478 /// heal: the sender finalized a block (via a Commit QC we missed) and
4479 /// block-sync must fetch it, or the view deadlocks forever on the
4480 /// proposer re-proposing a conflicting block at that height.
4481 #[tokio::test]
4482 async fn test_on_timeout_msg_fires_behind_hint_on_higher_finalized_height() {
4483 let (engine, peer_kp, peer_pq, peer_addr) =
4484 build_test_engine_with_peer_signer().await;
4485
4486 let mut hint_rx = engine.subscribe_behind_hint();
4487 assert_eq!(*hint_rx.borrow_and_update(), 0, "no hint before any timeout");
4488
4489 // Peer claims finalized height 7; our fresh tracker is at 0.
4490 let msg = peer_sign_timeout_full(17, 16, 7, peer_addr, &peer_kp, &peer_pq);
4491 engine.on_timeout_msg(&msg).await.expect("valid timeout accepted");
4492
4493 assert!(hint_rx.has_changed().unwrap(), "behind-hint fired");
4494 assert_eq!(*hint_rx.borrow_and_update(), 7, "hint carries peer finalized height");
4495
4496 // A second timeout advertising a height we already match must NOT
4497 // re-fire (peer height 0 == local 0 is not strictly greater).
4498 let msg2 = peer_sign_timeout_full(18, 17, 0, peer_addr, &peer_kp, &peer_pq);
4499 engine.on_timeout_msg(&msg2).await.expect("valid timeout accepted");
4500 assert!(!hint_rx.has_changed().unwrap(), "no hint when not behind");
4501 }
4502
4503 /// A TimeoutMsg with a tampered view (signature won't bind) must be
4504 /// rejected by the engine — this is the spoofing defence.
4505 #[tokio::test]
4506 async fn test_on_timeout_msg_rejects_tampered_signature() {
4507 let (engine, peer_kp, peer_pq, peer_addr) =
4508 build_test_engine_with_peer_signer().await;
4509
4510 {
4511 let mut state = engine.view_state.write();
4512 state.view = 5;
4513 }
4514
4515 let mut msg = peer_sign_timeout(20, peer_addr, &peer_kp, &peer_pq);
4516 msg.view = 99; // mutate after signing — signature no longer binds
4517
4518 let err = engine
4519 .on_timeout_msg(&msg)
4520 .await
4521 .expect_err("tampered timeout must be rejected");
4522 assert!(matches!(err, ConsensusError::InvalidSignature(_)), "got {err:?}");
4523
4524 assert_eq!(engine.view_state.read().view, 5, "view unchanged on bad sig");
4525 }
4526
4527 /// Builds a 4-validator engine and returns the local engine + each peer's
4528 /// (keypair, pq_key, address). The local node is index 0; peers are 1..3.
4529 /// This enables tests that need to construct multi-signer artifacts (TCs)
4530 /// without going through the gossip layer.
4531 async fn build_test_engine_with_all_signers() -> (
4532 HotStuff2Engine,
4533 Vec<(KeyPair, MlDsaSigningKey, tenzro_crypto::bls::BlsKeyPair, tenzro_types::primitives::Address)>,
4534 ) {
4535 let mut peers = Vec::new();
4536 let mut validators = Vec::new();
4537
4538 // Local validator (index 0).
4539 let local_kp = KeyPair::generate(KeyType::Ed25519).unwrap();
4540 let local_pq = MlDsaSigningKey::generate();
4541 let local_bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
4542 let local_crypto_addr = local_kp.address();
4543 let mut local_addr_bytes = [0u8; 32];
4544 local_addr_bytes[..20].copy_from_slice(local_crypto_addr.as_bytes());
4545 let local_addr = tenzro_types::primitives::Address::new(local_addr_bytes);
4546 validators.push(ValidatorInfo::new(
4547 local_addr,
4548 local_kp.public_key().clone(),
4549 local_pq.verifying_key_bytes().to_vec(),
4550 local_bls.public_key().to_bytes().to_vec(),
4551 1000,
4552 ));
4553
4554 // 3 peers (indices 1..3).
4555 for i in 0..3 {
4556 let kp = KeyPair::generate(KeyType::Ed25519).unwrap();
4557 let pq = MlDsaSigningKey::generate();
4558 let bls = tenzro_crypto::bls::BlsKeyPair::generate().unwrap();
4559 let crypto_addr = kp.address();
4560 let mut addr_bytes = [0u8; 32];
4561 addr_bytes[..20].copy_from_slice(crypto_addr.as_bytes());
4562 let addr = tenzro_types::primitives::Address::new(addr_bytes);
4563 validators.push(ValidatorInfo::new(
4564 addr,
4565 kp.public_key().clone(),
4566 pq.verifying_key_bytes().to_vec(),
4567 bls.public_key().to_bytes().to_vec(),
4568 1000 * (i as u128 + 2),
4569 ));
4570 peers.push((kp, pq, bls, addr));
4571 }
4572
4573 let local_kp_for_engine =
4574 KeyPair::from_bytes(local_kp.key_type(), &local_kp.to_bytes()).unwrap();
4575 let local_pq_for_engine = MlDsaSigningKey::from_seed(local_pq.seed_bytes()).unwrap();
4576 let local_bls_for_engine = tenzro_crypto::bls::BlsKeyPair::from_secret_key(
4577 tenzro_crypto::bls::BlsSecretKey::from_bytes(&local_bls.secret_key().to_bytes()).unwrap(),
4578 );
4579 let config = ConsensusConfig::default();
4580 let epoch_manager = EpochManager::new(validators, 100).unwrap();
4581 let mut engine = HotStuff2Engine::new(local_kp_for_engine, local_pq_for_engine, local_bls_for_engine, config, epoch_manager);
4582
4583 // Wire a synthetic genesis block at height 0 so EIP-1559
4584 // `validate_base_fee` can re-derive the child's base fee. See
4585 // `build_test_genesis` for rationale.
4586 let block_provider = Arc::new(TestBlockProvider::new());
4587 block_provider.insert(build_test_genesis());
4588 engine = engine.with_block_provider(block_provider);
4589
4590 let validator_set = Arc::new(engine.validator_set());
4591 *engine.vote_collector.write() = Some(Arc::new(VoteCollector::new(validator_set.clone())));
4592 *engine.timeout_collector.write() = Some(Arc::new(
4593 crate::timeout::TimeoutCollector::new(validator_set.clone()),
4594 ));
4595 *engine.nec_collector.write() = Some(Arc::new(
4596 crate::timeout::NoEndorsementCollector::new(validator_set),
4597 ));
4598
4599 (engine, peers)
4600 }
4601
4602 /// safe_to_extend (Jolteon §3.5): a proposal at view V where V > local_view + 1
4603 /// must carry a valid TimeoutCertificate at view V-1 signed by 2f+1 validators.
4604 /// Without the TC, the proposal must be rejected — the leader could otherwise
4605 /// fork the chain by skipping views unilaterally.
4606 #[tokio::test]
4607 async fn test_on_proposal_rejects_view_jump_without_tc() {
4608 use tenzro_types::block::{Block, BlockHeader, ConsensusAlgorithm, ConsensusProof};
4609 use tenzro_types::primitives::{BlockHeight, Hash};
4610
4611 let (engine, peers) = build_test_engine_with_all_signers().await;
4612 let proposer_addr = peers[0].3;
4613
4614 // Local view 0; proposer jumps to view 5 with no TC.
4615 {
4616 let mut state = engine.view_state.write();
4617 state.view = 0;
4618 state.height = BlockHeight::from(1);
4619 state.phase = Phase::Prepare;
4620 }
4621
4622 let header = BlockHeader::new_at_view(
4623 BlockHeight::from(1),
4624 5,
4625 Hash::default(),
4626 Hash::default(),
4627 Hash::default(),
4628 proposer_addr,
4629 ConsensusProof::new(ConsensusAlgorithm::PBFT, Vec::new()),
4630 );
4631 let block = Block::new(header, vec![]);
4632
4633 let result = engine.on_proposal(&block, None, None, 0).await;
4634 assert!(
4635 matches!(result, Err(ConsensusError::InvalidProposal(_))),
4636 "view jump > 1 without TC must be rejected, got {:?}",
4637 result
4638 );
4639 assert_eq!(engine.view_state.read().view, 0, "local view unchanged on rejection");
4640 }
4641
4642 /// safe_to_extend happy path: a proposal at view V > local_view + 1 with a
4643 /// valid TC for view V-1 must be accepted, and the local view advances.
4644 #[tokio::test]
4645 async fn test_on_proposal_accepts_view_jump_with_valid_tc() {
4646 use tenzro_types::block::{Block, BlockHeader, ConsensusAlgorithm, ConsensusProof};
4647 use tenzro_types::primitives::{BlockHeight, Hash};
4648
4649 let (engine, peers) = build_test_engine_with_all_signers().await;
4650 // Proposer enforcement: the block must come from the elected leader
4651 // for its view, so derive the leader rather than hardcoding a peer.
4652 let proposer_addr = engine.leader_for_view(5).unwrap();
4653
4654 // Local view 0; proposer jumps to view 5 with a TC at view 4 signed
4655 // by 3 of 4 validators (2f+1 with f=1).
4656 {
4657 let mut state = engine.view_state.write();
4658 state.view = 0;
4659 state.height = BlockHeight::from(1);
4660 state.phase = Phase::Prepare;
4661 }
4662
4663 // Build 3 TimeoutMsgs at view 4 (with high_qc_view = 0) from the 3
4664 // peers, then assemble into a TC.
4665 let tc_view = 4u64;
4666 let mut signers: Vec<crate::timeout::TcSigner> = Vec::new();
4667 for (kp, pq, _bls, addr) in peers.iter() {
4668 let msg = peer_sign_timeout_with_hqc(tc_view, 0, *addr, kp, pq);
4669 signers.push(crate::timeout::TcSigner {
4670 voter: msg.voter,
4671 high_qc_view: msg.high_qc_view,
4672 finalized_height: msg.finalized_height,
4673 signature: msg.signature,
4674 public_key: msg.public_key,
4675 });
4676 }
4677 let tc = crate::timeout::TimeoutCertificate {
4678 format_version: crate::timeout::TIMEOUT_CERTIFICATE_FORMAT_VERSION,
4679 view: tc_view,
4680 signers,
4681 };
4682
4683 // Build a NoEndorsementCertificate for view 4 (f+1 = 2 of 4 signers
4684 // suffice). MonadBFT tail-fork defence: the proposer claims no QC
4685 // formed at the timed-out view, and must back that claim with an
4686 // f+1 attestation. Engine local high_qc_view is 0 < 4, consistent
4687 // with "no QC observed" so the NEC will be accepted.
4688 let mut nec_signers: Vec<crate::timeout::NecSigner> = Vec::new();
4689 for (kp, pq, _bls, addr) in peers.iter().take(2) {
4690 let nec_msg = peer_sign_no_endorsement(tc_view, *addr, kp, pq);
4691 nec_signers.push(crate::timeout::NecSigner {
4692 voter: nec_msg.voter,
4693 signature: nec_msg.signature,
4694 public_key: nec_msg.public_key,
4695 });
4696 }
4697 let nec = crate::timeout::NoEndorsementCertificate {
4698 format_version: crate::timeout::NO_ENDORSEMENT_CERTIFICATE_FORMAT_VERSION,
4699 view: tc_view,
4700 signers: nec_signers,
4701 };
4702
4703 let header = BlockHeader::new_at_view(
4704 BlockHeight::from(1),
4705 5,
4706 Hash::default(),
4707 Hash::default(),
4708 Hash::default(),
4709 proposer_addr,
4710 ConsensusProof::new(ConsensusAlgorithm::PBFT, Vec::new()),
4711 );
4712 let block = stamp_genesis_edge_base_fee(Block::new(header, vec![]));
4713
4714 let vote = engine
4715 .on_proposal(&block, Some(tc), Some(nec), 0)
4716 .await
4717 .expect("proposal with valid TC + NEC must be accepted");
4718 assert_eq!(vote.view, 5, "vote stamped at proposer's view");
4719 assert_eq!(engine.view_state.read().view, 5, "local view advanced");
4720 }
4721
4722 /// A proposal carrying a TC for the wrong view (tc.view + 1 != proposal.view)
4723 /// must be rejected — even if the TC is otherwise well-formed.
4724 #[tokio::test]
4725 async fn test_on_proposal_rejects_tc_with_wrong_view() {
4726 use tenzro_types::block::{Block, BlockHeader, ConsensusAlgorithm, ConsensusProof};
4727 use tenzro_types::primitives::{BlockHeight, Hash};
4728
4729 let (engine, peers) = build_test_engine_with_all_signers().await;
4730 let proposer_addr = peers[0].3;
4731
4732 {
4733 let mut state = engine.view_state.write();
4734 state.view = 0;
4735 state.height = BlockHeight::from(1);
4736 state.phase = Phase::Prepare;
4737 }
4738
4739 // TC is at view 3, but the proposal is at view 5 (gap of 2). Rejected
4740 // because tc.view + 1 (= 4) != proposal_view (= 5).
4741 let tc_view = 3u64;
4742 let mut signers: Vec<crate::timeout::TcSigner> = Vec::new();
4743 for (kp, pq, _bls, addr) in peers.iter() {
4744 let msg = peer_sign_timeout_with_hqc(tc_view, 0, *addr, kp, pq);
4745 signers.push(crate::timeout::TcSigner {
4746 voter: msg.voter,
4747 high_qc_view: msg.high_qc_view,
4748 finalized_height: msg.finalized_height,
4749 signature: msg.signature,
4750 public_key: msg.public_key,
4751 });
4752 }
4753 let tc = crate::timeout::TimeoutCertificate {
4754 format_version: crate::timeout::TIMEOUT_CERTIFICATE_FORMAT_VERSION,
4755 view: tc_view,
4756 signers,
4757 };
4758
4759 let header = BlockHeader::new_at_view(
4760 BlockHeight::from(1),
4761 5,
4762 Hash::default(),
4763 Hash::default(),
4764 Hash::default(),
4765 proposer_addr,
4766 ConsensusProof::new(ConsensusAlgorithm::PBFT, Vec::new()),
4767 );
4768 let block = Block::new(header, vec![]);
4769
4770 let result = engine.on_proposal(&block, Some(tc), None, 0).await;
4771 assert!(
4772 matches!(result, Err(ConsensusError::InvalidProposal(_))),
4773 "TC with wrong view must be rejected, got {:?}",
4774 result
4775 );
4776 assert_eq!(engine.view_state.read().view, 0, "local view unchanged");
4777 }
4778
4779 /// A proposal carrying a TC signed by < 2f+1 validators must be rejected.
4780 /// This guards against a leader fabricating a "TC" from f or fewer signers.
4781 #[tokio::test]
4782 async fn test_on_proposal_rejects_tc_below_quorum() {
4783 use tenzro_types::block::{Block, BlockHeader, ConsensusAlgorithm, ConsensusProof};
4784 use tenzro_types::primitives::{BlockHeight, Hash};
4785
4786 let (engine, peers) = build_test_engine_with_all_signers().await;
4787 let proposer_addr = peers[0].3;
4788
4789 {
4790 let mut state = engine.view_state.write();
4791 state.view = 0;
4792 state.height = BlockHeight::from(1);
4793 state.phase = Phase::Prepare;
4794 }
4795
4796 // Only 1 signer — far below 2f+1 = 3 for a 4-validator set.
4797 let tc_view = 4u64;
4798 let (kp, pq, _bls, addr) = &peers[0];
4799 let msg = peer_sign_timeout_with_hqc(tc_view, 0, *addr, kp, pq);
4800 let signers = vec![crate::timeout::TcSigner {
4801 voter: msg.voter,
4802 high_qc_view: msg.high_qc_view,
4803 finalized_height: msg.finalized_height,
4804 signature: msg.signature,
4805 public_key: msg.public_key,
4806 }];
4807 let tc = crate::timeout::TimeoutCertificate {
4808 format_version: crate::timeout::TIMEOUT_CERTIFICATE_FORMAT_VERSION,
4809 view: tc_view,
4810 signers,
4811 };
4812
4813 let header = BlockHeader::new_at_view(
4814 BlockHeight::from(1),
4815 5,
4816 Hash::default(),
4817 Hash::default(),
4818 Hash::default(),
4819 proposer_addr,
4820 ConsensusProof::new(ConsensusAlgorithm::PBFT, Vec::new()),
4821 );
4822 let block = Block::new(header, vec![]);
4823
4824 let result = engine.on_proposal(&block, Some(tc), None, 0).await;
4825 assert!(
4826 matches!(result, Err(ConsensusError::InvalidProposal(_))),
4827 "TC below quorum must be rejected, got {:?}",
4828 result
4829 );
4830 }
4831}