Skip to main content

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}