Skip to main content

tenzro_token/
validator_registry.rs

1//! Permissionless validator registry — dynamic active-set membership.
2//!
3//! This module is the on-chain source of truth for who is a validator. It is
4//! consulted at every epoch boundary by the consensus epoch manager to decide
5//! which validators are active in the next epoch. The registry implements a
6//! 2026-SOTA hybrid pattern combining ideas from:
7//!
8//! - **Ethereum Pectra (EIP-7251 / EIP-7002 / EIP-8061)** — typed transactions
9//!   for register/exit, weight-based churn caps with separate activation and
10//!   exit budgets per epoch.
11//! - **Sui** — explicit `Candidate` intermediate state where metadata is
12//!   published before the validator can be activated.
13//! - **Aptos** — five-state machine
14//!   (`Candidate` → `PendingActive` → `Active` → `PendingExit` → `Exited`)
15//!   plus a separate `Jailed` quarantine state reachable from `Active`.
16//! - **Cosmos** — absolute-power `ValidatorUpdates` returned at epoch
17//!   boundaries with a fixed effective-date delay so HotStuff-2 high-QC chains
18//!   stay safe across reconfigurations.
19//!
20//! The TEE multiplier is **not** stored in the registry. It is applied only at
21//! `select_active_set_for_next_epoch()` boundaries — operators can lose TEE
22//! attestation without losing validator status.
23
24use crate::error::{Result, TokenError};
25use dashmap::DashMap;
26use serde::{Deserialize, Serialize};
27use std::sync::Arc;
28use tenzro_types::primitives::{Address, Timestamp};
29use tracing::{info, warn};
30
31/// Default minimum self-bonded stake to register as a **Tier 2** (staked)
32/// validator: 10 000 TNZO. This is intentionally above
33/// [`crate::staking::DEFAULT_MIN_STAKE`] (1 000 TNZO) — validator slots
34/// cost more than service-provider slots. Per WHITEPAPER §5 +
35/// TOKENOMICS §7, Tier 1 (resource-only) validators require **no
36/// stake**; only Tier 2 must meet this minimum.
37pub const DEFAULT_MIN_VALIDATOR_SELF_STAKE: u128 = 10_000 * 1_000_000_000_000_000_000;
38
39/// Default minimum self-bonded stake to register as a **Tier 3** (RPC
40/// provider) validator: 100 000 TNZO. Tier 3 implies Tier 2 — the
41/// 100k bond satisfies the 10k Tier 2 minimum, so the effective bond
42/// is 100k total (not 110k).
43///
44/// Per the 2026-06-10 canonical model (memory:
45/// `project_validator_and_rpc_provider_model_2026_06_10`), Tier 3
46/// requires the higher bond because RPC providers carry the larger
47/// trust surface: they mint scoped `tnz_...` API keys for tenants,
48/// route signed transactions, broker access to operator-held upstream
49/// credentials (Canton participants, AI provider keys, data feed
50/// subscriptions), front cross-chain mint/burn flows, and mediate
51/// per-tenant billing. The 10× multiple over Tier 2 reflects this.
52pub const DEFAULT_MIN_RPC_PROVIDER_STAKE: u128 = 100_000 * 1_000_000_000_000_000_000;
53
54/// Default activation churn cap: percentage of active set, in basis points.
55/// 400 bps = 4% per epoch (matching EIP-8061 conservative profile).
56pub const DEFAULT_ACTIVATION_CHURN_BPS: u32 = 400;
57
58/// Default exit churn cap, in basis points. Exits are capped at the same rate
59/// as activations — symmetry keeps the active set size bounded.
60pub const DEFAULT_EXIT_CHURN_BPS: u32 = 400;
61
62/// Minimum number of validators that may activate per epoch even when the
63/// percentage cap rounds to zero (bootstrap phase). Per EIP-8061 §5.
64pub const MIN_CHURN_PER_EPOCH: u32 = 1;
65
66/// Re-entry cooldown after a voluntary exit, in epochs. A validator that exits
67/// must wait this many epochs before they can register again — prevents
68/// thrashing the active set.
69pub const DEFAULT_REENTRY_COOLDOWN_EPOCHS: u64 = 4;
70
71/// Effective-date delay between governance/registry decision and active-set
72/// inclusion, in HotStuff-2 blocks. Three blocks is the standard safety
73/// margin to ensure any in-flight high-QC has finalised before the validator
74/// set rotates. Cosmos uses the same delay.
75pub const ACTIVATION_EFFECTIVE_DELAY_BLOCKS: u64 = 3;
76
77/// Storage key prefix for individual registry entries.
78pub const VALIDATOR_PREFIX: &str = "validator:";
79/// Storage key for the registry index (list of all addresses).
80pub const VALIDATOR_INDEX_KEY: &str = "validator:index";
81/// Storage key for the registry config.
82pub const VALIDATOR_CONFIG_KEY: &str = "validator:config";
83
84/// Column family used for registry persistence. Co-located with staking under
85/// the unified token storage.
86const REGISTRY_CF: &str = tenzro_storage::CF_TOKENS;
87
88/// Participation tier of a registered validator.
89///
90/// Per the canonical three-tier model (WHITEPAPER §5 + TOKENOMICS §7,
91/// crystallized in memory
92/// `project_validator_and_rpc_provider_model_2026_06_10`):
93///
94/// - **Tier 1 — ResourceOnly:** open entry, no stake required. Standard
95///   block classes only. Reputation + TEE multiplier weight. No
96///   independent governance vote. No financial slash (ejection +
97///   reputation collapse only).
98/// - **Tier 2 — Staked:** ≥ 10,000 TNZO bonded. All block classes
99///   including high-trust. Stake-weighted weight. Governance vote.
100///   10% slash on equivocation + other slashable conditions.
101/// - **Tier 3 — RpcProvider:** ≥ 100,000 TNZO bonded (implies Tier 2).
102///   Sanctioned to mint scoped `tnz_...` API keys, serve public RPC,
103///   broker upstream credentials, route cross-chain mint/burn, front
104///   per-tenant billing. Tier 2 slashable conditions + censorship,
105///   frontrunning, mishandled tenant secrets, billing fraud,
106///   persistent SLA failures.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(rename_all = "snake_case")]
109pub enum ValidatorTier {
110    ResourceOnly,
111    Staked,
112    RpcProvider,
113}
114
115impl ValidatorTier {
116    /// Returns the tier inferred from the validator's self-stake
117    /// amount, used during admission and on every stake-change event.
118    pub fn from_stake(self_stake: u128, cfg: &ValidatorRegistryConfig) -> Self {
119        if self_stake >= cfg.min_rpc_provider_stake {
120            Self::RpcProvider
121        } else if self_stake >= cfg.min_self_stake {
122            Self::Staked
123        } else {
124            Self::ResourceOnly
125        }
126    }
127
128    /// Returns true if this tier is eligible for HighTrust block class
129    /// proposer election. Per the spec, Tier 1 is excluded from
130    /// high-trust blocks; Tier 2 and Tier 3 are admitted.
131    pub fn admits_high_trust(&self) -> bool {
132        !matches!(self, Self::ResourceOnly)
133    }
134
135    /// Returns true if this tier has independent governance voting
136    /// weight via its stake. Tier 1 has none; Tier 2 and Tier 3 are
137    /// stake-weighted.
138    pub fn has_governance_weight(&self) -> bool {
139        !matches!(self, Self::ResourceOnly)
140    }
141
142    /// Returns true if this tier has financial slashing exposure on
143    /// equivocation. Tier 1 does not; Tier 2 and Tier 3 do.
144    pub fn has_financial_slashing(&self) -> bool {
145        !matches!(self, Self::ResourceOnly)
146    }
147
148    /// Returns true if this tier is sanctioned to mint scoped tenant
149    /// API keys (`tenzro_createApiKey`) and serve public RPC. Tier 3
150    /// only.
151    pub fn admits_rpc_role(&self) -> bool {
152        matches!(self, Self::RpcProvider)
153    }
154
155    pub fn as_str(&self) -> &'static str {
156        match self {
157            Self::ResourceOnly => "resource_only",
158            Self::Staked => "staked",
159            Self::RpcProvider => "rpc_provider",
160        }
161    }
162}
163
164impl Default for ValidatorTier {
165    fn default() -> Self {
166        // Old persisted rows without a tier field default to Staked —
167        // matches the pre-tier behaviour where every registered
168        // validator carried at least `min_self_stake`. On a stake
169        // change, `from_stake` recomputes the correct tier and
170        // overwrites this default.
171        Self::Staked
172    }
173}
174
175/// Lifecycle status of a registered validator.
176///
177/// Transitions (the `next_epoch` annotation means the move happens at the
178/// next epoch boundary, not synchronously on transaction execution):
179///
180/// ```text
181///                              register
182///                                 │
183///                                 ▼
184///                            ┌─────────┐
185///        re-entry cooldown   │Candidate│
186///        ────────────────────┤         │
187///                            └────┬────┘
188///                                 │ activate-call admitted by churn cap
189///                                 ▼
190///                          ┌──────────────┐
191///                          │PendingActive │
192///                          └──────┬───────┘
193///                                 │ next_epoch
194///                                 ▼
195///                            ┌─────────┐  slash       ┌────────┐
196///                            │ Active  │─────────────▶│ Jailed │
197///                            └────┬────┘              └────┬───┘
198///                                 │ exit-call               │ governance
199///                                 ▼                          ▼
200///                          ┌────────────┐              (re-enter Candidate)
201///                          │PendingExit │
202///                          └─────┬──────┘
203///                                │ next_epoch
204///                                ▼
205///                            ┌────────┐
206///                            │Exited  │
207///                            └────────┘
208/// ```
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210pub enum ValidatorRegistryStatus {
211    /// Registered and metadata published; not yet promoted to the active set.
212    /// Awaiting churn-budget admission.
213    Candidate,
214    /// Promoted by the activation churn cap; will become `Active` at the next
215    /// epoch boundary (after [`ACTIVATION_EFFECTIVE_DELAY_BLOCKS`]).
216    PendingActive,
217    /// Currently a member of the active validator set producing blocks.
218    Active,
219    /// Voluntary exit requested; will become `Exited` at the next epoch
220    /// boundary subject to the exit churn cap.
221    PendingExit,
222    /// Exited and stake fully unbonded. Re-registration permitted after
223    /// [`DEFAULT_REENTRY_COOLDOWN_EPOCHS`] epochs.
224    Exited,
225    /// Slashed for misbehaviour. Stays jailed until governance re-instates
226    /// (out of scope for this wave; jailed validators stay jailed).
227    Jailed,
228}
229
230impl ValidatorRegistryStatus {
231    /// Returns true when the validator is counted toward the active set total
232    /// for churn-cap calculations.
233    pub fn counts_toward_active(&self) -> bool {
234        matches!(self, Self::Active | Self::PendingExit)
235    }
236}
237
238/// Full per-validator record persisted in the registry.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ValidatorRegistryEntry {
241    /// Operator / consensus address (also the staking address — unified).
242    pub address: Address,
243    /// Ed25519 consensus public key (32 bytes).
244    pub consensus_pubkey: Vec<u8>,
245    /// ML-DSA-65 PQ verifying key (1952 bytes, FIPS 204).
246    pub pq_pubkey: Vec<u8>,
247    /// BLS12-381 G1-compressed verifying key (`min_pk` scheme, 48 bytes).
248    /// Used by HotStuff-2 to aggregate per-validator vote signatures into
249    /// a single QC-level aggregate.
250    pub bls_pubkey: Vec<u8>,
251    /// Address that receives staking rewards and withdrawn principal.
252    /// May differ from `address` to allow cold-storage payouts.
253    pub withdrawal_address: Address,
254    /// Self-bonded stake. Counted as voting power when active.
255    pub self_stake: u128,
256    /// Participation tier — derived from `self_stake` against the
257    /// registry config's `min_self_stake` / `min_rpc_provider_stake`
258    /// thresholds. Recomputed on every stake change. The field is
259    /// stored explicitly (rather than re-derived on every read) so
260    /// downstream code (leader election, governance, slashing,
261    /// API-key minting) can branch on tier without taking the config
262    /// lock.
263    ///
264    /// Persistence-safe `#[serde(default)]` for back-compat read of
265    /// pre-tier registry entries: those rows default to `Staked` (see
266    /// `ValidatorTier::default`) because pre-tier code required at
267    /// least `min_self_stake`.
268    #[serde(default)]
269    pub tier: ValidatorTier,
270    /// Current lifecycle status.
271    pub status: ValidatorRegistryStatus,
272    /// Epoch in which the candidate was first registered.
273    pub registered_at_epoch: u64,
274    /// Epoch in which the validator entered the `Active` state, if ever.
275    pub activated_at_epoch: Option<u64>,
276    /// Epoch in which the validator exited, if ever. Used to enforce the
277    /// re-entry cooldown.
278    pub exited_at_epoch: Option<u64>,
279    /// Epoch until which a jailed validator remains jailed (exclusive).
280    /// `None` means jailed indefinitely until governance acts.
281    pub jailed_until_epoch: Option<u64>,
282    /// Optional 32-byte hash of the most recent TEE attestation report.
283    /// Stored as opaque bytes — verification happens in tenzro-tee.
284    pub tee_attestation_hash: Option<[u8; 32]>,
285    /// Free-form operator metadata URL (moniker, contact, region…). Capped at
286    /// 256 bytes to bound on-chain storage. May be empty.
287    pub metadata_uri: String,
288    /// Last update timestamp.
289    pub updated_at: Timestamp,
290}
291
292impl ValidatorRegistryEntry {
293    /// Constructs a fresh `Candidate` entry. Validates key lengths and
294    /// derives the participation tier from `self_stake` against
295    /// `cfg.min_self_stake` / `cfg.min_rpc_provider_stake`.
296    ///
297    /// Tier 1 (resource-only) admission: `self_stake == 0` is the
298    /// canonical case but any value below `cfg.min_self_stake` is
299    /// admitted as Tier 1.
300    /// Tier 2 (staked) admission: `self_stake >= cfg.min_self_stake`.
301    /// Tier 3 (RPC provider) admission: `self_stake >= cfg.min_rpc_provider_stake`.
302    pub fn new_candidate(
303        address: Address,
304        consensus_pubkey: Vec<u8>,
305        pq_pubkey: Vec<u8>,
306        bls_pubkey: Vec<u8>,
307        withdrawal_address: Address,
308        self_stake: u128,
309        registered_at_epoch: u64,
310        metadata_uri: String,
311        cfg: &ValidatorRegistryConfig,
312    ) -> Result<Self> {
313        if consensus_pubkey.len() != 32 {
314            return Err(TokenError::InvalidParameter(format!(
315                "consensus public key must be 32 bytes, got {}",
316                consensus_pubkey.len()
317            )));
318        }
319        if pq_pubkey.len() != tenzro_crypto::pq::ML_DSA_65_VK_LEN {
320            return Err(TokenError::InvalidParameter(format!(
321                "PQ verifying key must be {} bytes (ML-DSA-65), got {}",
322                tenzro_crypto::pq::ML_DSA_65_VK_LEN,
323                pq_pubkey.len()
324            )));
325        }
326        if bls_pubkey.len() != 48 {
327            return Err(TokenError::InvalidParameter(format!(
328                "BLS verifying key must be 48 bytes (BLS12-381 G1-compressed, min_pk), got {}",
329                bls_pubkey.len()
330            )));
331        }
332        if metadata_uri.len() > 256 {
333            return Err(TokenError::InvalidParameter(format!(
334                "metadata_uri exceeds 256 bytes (got {})",
335                metadata_uri.len()
336            )));
337        }
338        let tier = ValidatorTier::from_stake(self_stake, cfg);
339        Ok(Self {
340            address,
341            consensus_pubkey,
342            pq_pubkey,
343            bls_pubkey,
344            withdrawal_address,
345            self_stake,
346            tier,
347            status: ValidatorRegistryStatus::Candidate,
348            registered_at_epoch,
349            activated_at_epoch: None,
350            exited_at_epoch: None,
351            jailed_until_epoch: None,
352            tee_attestation_hash: None,
353            metadata_uri,
354            updated_at: Timestamp::now(),
355        })
356    }
357}
358
359/// Registry configuration (mutable via governance).
360#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
361pub struct ValidatorRegistryConfig {
362    /// Minimum self-bonded stake to register as a **Tier 2 (staked)**
363    /// validator. Tier 1 (resource-only) registrations are not gated
364    /// by this — they require `self_stake == 0` and meet only the
365    /// resource + stability profile.
366    pub min_self_stake: u128,
367    /// Minimum self-bonded stake to register as a **Tier 3 (RPC
368    /// provider)** validator. Implies the Tier 2 minimum is also met.
369    /// 10× Tier 2 by default, reflecting the larger trust surface
370    /// RPC providers carry (tenant API keys, upstream credentials,
371    /// cross-chain mint routing, billing).
372    #[serde(default = "default_min_rpc_provider_stake")]
373    pub min_rpc_provider_stake: u128,
374    /// Activation churn cap, in basis points of current active-set size.
375    pub activation_churn_bps: u32,
376    /// Exit churn cap, in basis points of current active-set size.
377    pub exit_churn_bps: u32,
378    /// Re-entry cooldown after voluntary exit, in epochs.
379    pub reentry_cooldown_epochs: u64,
380}
381
382/// Serde-default for the RPC provider stake on a config row written
383/// before the field existed. Read-side compat for pre-tier registry
384/// snapshots.
385fn default_min_rpc_provider_stake() -> u128 {
386    DEFAULT_MIN_RPC_PROVIDER_STAKE
387}
388
389impl Default for ValidatorRegistryConfig {
390    fn default() -> Self {
391        Self {
392            min_self_stake: DEFAULT_MIN_VALIDATOR_SELF_STAKE,
393            min_rpc_provider_stake: DEFAULT_MIN_RPC_PROVIDER_STAKE,
394            activation_churn_bps: DEFAULT_ACTIVATION_CHURN_BPS,
395            exit_churn_bps: DEFAULT_EXIT_CHURN_BPS,
396            reentry_cooldown_epochs: DEFAULT_REENTRY_COOLDOWN_EPOCHS,
397        }
398    }
399}
400
401/// Result of an epoch-boundary transition computed by
402/// [`ValidatorRegistry::compute_epoch_transition`].
403///
404/// The caller (the node's epoch-transition hook in `event_loop.rs`) consumes
405/// this to drive the consensus [`tenzro_consensus::EpochManager`]'s pending
406/// queues.
407#[derive(Debug, Clone, Default)]
408pub struct EpochTransitionPlan {
409    /// Validators promoted from `Candidate` → `PendingActive` this epoch.
410    /// They will appear in the consensus active set in the next epoch.
411    pub activations: Vec<Address>,
412    /// Validators demoted from `Active` → `PendingExit` this epoch (because
413    /// they requested exit, were slashed, or were jailed).
414    pub exits: Vec<Address>,
415    /// Validators that completed `PendingActive` → `Active` (effective now).
416    pub effective_activations: Vec<Address>,
417    /// Validators that completed `PendingExit` → `Exited` (effective now).
418    pub effective_exits: Vec<Address>,
419}
420
421/// Permissionless validator registry.
422///
423/// Concurrent in-memory state with optional write-through persistence.
424pub struct ValidatorRegistry {
425    /// Per-address registry entries.
426    entries: DashMap<Address, ValidatorRegistryEntry>,
427    /// Mutable configuration.
428    config: parking_lot::RwLock<ValidatorRegistryConfig>,
429    /// Optional persistent storage backend.
430    storage: Option<Arc<dyn tenzro_storage::KvStore>>,
431}
432
433impl ValidatorRegistry {
434    /// Creates an in-memory-only registry.
435    pub fn new() -> Self {
436        Self {
437            entries: DashMap::new(),
438            config: parking_lot::RwLock::new(ValidatorRegistryConfig::default()),
439            storage: None,
440        }
441    }
442
443    /// Creates a registry backed by persistent storage. Hydrates state on
444    /// construction; logs and continues on partial-decode errors.
445    pub fn with_storage(storage: Arc<dyn tenzro_storage::KvStore>) -> Self {
446        let registry = Self {
447            entries: DashMap::new(),
448            config: parking_lot::RwLock::new(ValidatorRegistryConfig::default()),
449            storage: Some(storage),
450        };
451        if let Err(e) = registry.load_from_storage() {
452            warn!("Failed to hydrate validator registry: {}", e);
453        }
454        registry
455    }
456
457    fn persist_entry(&self, entry: &ValidatorRegistryEntry) {
458        if let Some(storage) = &self.storage {
459            let key = format!("{}{}", VALIDATOR_PREFIX, hex::encode(entry.address.as_bytes()));
460            match bincode::serialize(entry) {
461                Ok(data) => {
462                    if let Err(e) = storage.put(REGISTRY_CF, key.as_bytes(), &data) {
463                        warn!("Failed to persist validator {}: {}", entry.address, e);
464                    }
465                }
466                Err(e) => warn!("Failed to serialize validator {}: {}", entry.address, e),
467            }
468        }
469    }
470
471    fn persist_index(&self) {
472        if let Some(storage) = &self.storage {
473            let addresses: Vec<String> = self
474                .entries
475                .iter()
476                .map(|e| hex::encode(e.key().as_bytes()))
477                .collect();
478            match bincode::serialize(&addresses) {
479                Ok(data) => {
480                    if let Err(e) = storage.put(REGISTRY_CF, VALIDATOR_INDEX_KEY.as_bytes(), &data)
481                    {
482                        warn!("Failed to persist validator index: {}", e);
483                    }
484                }
485                Err(e) => warn!("Failed to serialize validator index: {}", e),
486            }
487        }
488    }
489
490    fn persist_config(&self) {
491        if let Some(storage) = &self.storage {
492            let cfg = *self.config.read();
493            match bincode::serialize(&cfg) {
494                Ok(data) => {
495                    if let Err(e) = storage.put(REGISTRY_CF, VALIDATOR_CONFIG_KEY.as_bytes(), &data)
496                    {
497                        warn!("Failed to persist validator config: {}", e);
498                    }
499                }
500                Err(e) => warn!("Failed to serialize validator config: {}", e),
501            }
502        }
503    }
504
505    fn load_from_storage(&self) -> Result<()> {
506        let storage = match &self.storage {
507            Some(s) => s,
508            None => return Ok(()),
509        };
510
511        if let Ok(Some(cfg_data)) = storage.get(REGISTRY_CF, VALIDATOR_CONFIG_KEY.as_bytes())
512            && let Ok(cfg) = bincode::deserialize::<ValidatorRegistryConfig>(&cfg_data)
513        {
514            *self.config.write() = cfg;
515        }
516
517        if let Ok(Some(idx_data)) = storage.get(REGISTRY_CF, VALIDATOR_INDEX_KEY.as_bytes()) {
518            let addresses: Vec<String> = match bincode::deserialize(&idx_data) {
519                Ok(v) => v,
520                Err(e) => {
521                    warn!("Failed to deserialize validator index: {}", e);
522                    return Ok(());
523                }
524            };
525            for hex_addr in &addresses {
526                let key = format!("{}{}", VALIDATOR_PREFIX, hex_addr);
527                if let Ok(Some(data)) = storage.get(REGISTRY_CF, key.as_bytes()) {
528                    match bincode::deserialize::<ValidatorRegistryEntry>(&data) {
529                        Ok(entry) => {
530                            self.entries.insert(entry.address, entry);
531                        }
532                        Err(e) => warn!("Failed to deserialize validator {}: {}", hex_addr, e),
533                    }
534                }
535            }
536            info!("Loaded {} validators from registry", addresses.len());
537        }
538
539        Ok(())
540    }
541
542    /// Returns the current registry config.
543    pub fn config(&self) -> ValidatorRegistryConfig {
544        *self.config.read()
545    }
546
547    /// Updates the registry config (governance-gated; the caller is
548    /// responsible for authorization).
549    pub fn set_config(&self, cfg: ValidatorRegistryConfig) {
550        *self.config.write() = cfg;
551        self.persist_config();
552    }
553
554    /// Returns a snapshot of an entry by address.
555    pub fn get(&self, address: &Address) -> Option<ValidatorRegistryEntry> {
556        self.entries.get(address).map(|e| e.value().clone())
557    }
558
559    /// Returns all entries (snapshot).
560    pub fn list(&self) -> Vec<ValidatorRegistryEntry> {
561        self.entries.iter().map(|e| e.value().clone()).collect()
562    }
563
564    /// Returns active validators only (snapshot).
565    pub fn list_active(&self) -> Vec<ValidatorRegistryEntry> {
566        self.entries
567            .iter()
568            .filter(|e| e.value().status == ValidatorRegistryStatus::Active)
569            .map(|e| e.value().clone())
570            .collect()
571    }
572
573    /// Counts current active-set members. Used as the denominator for churn
574    /// caps. Includes `Active` + `PendingExit` (an exit-pending validator
575    /// still produces blocks until the next epoch boundary).
576    fn active_set_size(&self) -> usize {
577        self.entries
578            .iter()
579            .filter(|e| e.value().status.counts_toward_active())
580            .count()
581    }
582
583    /// Computes the per-epoch activation/exit budgets given the current
584    /// active-set size and config.
585    fn churn_budgets(&self) -> (u32, u32) {
586        let cfg = *self.config.read();
587        let n = self.active_set_size() as u64;
588        let act = ((n.saturating_mul(cfg.activation_churn_bps as u64)) / 10_000) as u32;
589        let exit = ((n.saturating_mul(cfg.exit_churn_bps as u64)) / 10_000) as u32;
590        (act.max(MIN_CHURN_PER_EPOCH), exit.max(MIN_CHURN_PER_EPOCH))
591    }
592
593    /// Registers a new candidate validator. Idempotent only when no entry
594    /// exists for `address` — re-registration of an existing address must
595    /// route through `Exited → Candidate` (after cooldown) explicitly.
596    #[allow(clippy::too_many_arguments)]
597    pub fn register_candidate(
598        &self,
599        address: Address,
600        consensus_pubkey: Vec<u8>,
601        pq_pubkey: Vec<u8>,
602        bls_pubkey: Vec<u8>,
603        withdrawal_address: Address,
604        self_stake: u128,
605        current_epoch: u64,
606        metadata_uri: String,
607    ) -> Result<()> {
608        let cfg = *self.config.read();
609
610        // Per the canonical three-tier model: Tier 1 (resource-only)
611        // registrations are admitted with `self_stake == 0` (or any
612        // amount below `min_self_stake`). Tier 2 (staked) admitted at
613        // `>= min_self_stake`. Tier 3 (RPC provider) at
614        // `>= min_rpc_provider_stake`. The tier is derived inside
615        // `ValidatorRegistryEntry::new_candidate`. No stake floor is
616        // enforced here — all three tiers are valid registration
617        // outcomes.
618
619        // Re-registration path: must have exited and waited the cooldown.
620        if let Some(existing) = self.entries.get(&address) {
621            match existing.status {
622                ValidatorRegistryStatus::Exited => {
623                    if let Some(exit_epoch) = existing.exited_at_epoch
624                        && current_epoch < exit_epoch.saturating_add(cfg.reentry_cooldown_epochs)
625                    {
626                        return Err(TokenError::Unauthorized {
627                            reason: format!(
628                                "re-entry cooldown: must wait until epoch {}",
629                                exit_epoch.saturating_add(cfg.reentry_cooldown_epochs)
630                            ),
631                        });
632                    }
633                }
634                _ => {
635                    return Err(TokenError::InvalidParameter(format!(
636                        "validator {} already registered with status {:?}",
637                        address, existing.status
638                    )));
639                }
640            }
641        }
642
643        let entry = ValidatorRegistryEntry::new_candidate(
644            address,
645            consensus_pubkey,
646            pq_pubkey,
647            bls_pubkey,
648            withdrawal_address,
649            self_stake,
650            current_epoch,
651            metadata_uri,
652            &cfg,
653        )?;
654        let tier = entry.tier;
655        self.entries.insert(address, entry.clone());
656        self.persist_entry(&entry);
657        self.persist_index();
658        info!(
659            "Registered validator candidate {} at epoch {} with stake {} (tier={})",
660            address, current_epoch, self_stake, tier.as_str()
661        );
662        Ok(())
663    }
664
665    /// Idempotently seed an `Active` genesis entry.
666    ///
667    /// Used at node startup to populate the registry with the validators
668    /// declared in `genesis.toml` so testnet bootstrap doesn't require each
669    /// genesis validator to re-register via `RegisterValidator` after the
670    /// chain comes up. Skips writing if an entry for `address` already
671    /// exists — restart-safe.
672    ///
673    /// Validates key lengths and metadata bounds. Returns `Ok(false)` when
674    /// the entry was already present (no-op), `Ok(true)` when a new Active
675    /// entry was inserted.
676    pub fn seed_genesis_active(
677        &self,
678        address: Address,
679        consensus_pubkey: Vec<u8>,
680        pq_pubkey: Vec<u8>,
681        bls_pubkey: Vec<u8>,
682        withdrawal_address: Address,
683        self_stake: u128,
684        metadata_uri: String,
685    ) -> Result<bool> {
686        if self.entries.contains_key(&address) {
687            return Ok(false);
688        }
689        if consensus_pubkey.len() != 32 {
690            return Err(TokenError::InvalidParameter(format!(
691                "consensus public key must be 32 bytes, got {}",
692                consensus_pubkey.len()
693            )));
694        }
695        if pq_pubkey.len() != tenzro_crypto::pq::ML_DSA_65_VK_LEN {
696            return Err(TokenError::InvalidParameter(format!(
697                "PQ verifying key must be {} bytes (ML-DSA-65), got {}",
698                tenzro_crypto::pq::ML_DSA_65_VK_LEN,
699                pq_pubkey.len()
700            )));
701        }
702        if bls_pubkey.len() != 48 {
703            return Err(TokenError::InvalidParameter(format!(
704                "BLS verifying key must be 48 bytes (BLS12-381 G1-compressed, min_pk), got {}",
705                bls_pubkey.len()
706            )));
707        }
708        if metadata_uri.len() > 256 {
709            return Err(TokenError::InvalidParameter(format!(
710                "metadata_uri exceeds 256 bytes (got {})",
711                metadata_uri.len()
712            )));
713        }
714        // Derive the genesis validator's tier from its self-stake.
715        // The genesis-prod.toml may seed validators at any tier; the
716        // first Tenzro Labs validator is Tier 3 (RPC provider) per
717        // the canonical model, others may be Tier 1 / 2 / 3 depending
718        // on the genesis allocation.
719        let cfg = *self.config.read();
720        let tier = ValidatorTier::from_stake(self_stake, &cfg);
721        let entry = ValidatorRegistryEntry {
722            address,
723            consensus_pubkey,
724            pq_pubkey,
725            bls_pubkey,
726            withdrawal_address,
727            self_stake,
728            tier,
729            status: ValidatorRegistryStatus::Active,
730            registered_at_epoch: 0,
731            activated_at_epoch: Some(0),
732            exited_at_epoch: None,
733            jailed_until_epoch: None,
734            tee_attestation_hash: None,
735            metadata_uri,
736            updated_at: Timestamp::now(),
737        };
738        self.entries.insert(address, entry.clone());
739        self.persist_entry(&entry);
740        self.persist_index();
741        info!(
742            "Seeded genesis Active validator {} with stake {} (tier={})",
743            address, self_stake, tier.as_str()
744        );
745        Ok(true)
746    }
747
748    /// Records a request to exit. Moves `Active → PendingExit`. The actual
749    /// state machine advance to `Exited` happens at the next epoch boundary
750    /// in `compute_epoch_transition`.
751    pub fn request_exit(&self, address: &Address) -> Result<()> {
752        let mut entry = self
753            .entries
754            .get_mut(address)
755            .ok_or_else(|| TokenError::NotFound(format!("validator {}", address)))?;
756
757        match entry.status {
758            ValidatorRegistryStatus::Active => {
759                entry.status = ValidatorRegistryStatus::PendingExit;
760                entry.updated_at = Timestamp::now();
761            }
762            ValidatorRegistryStatus::Candidate | ValidatorRegistryStatus::PendingActive => {
763                // Never made it to Active — we can short-circuit straight to
764                // Exited on the next epoch boundary.
765                entry.status = ValidatorRegistryStatus::PendingExit;
766                entry.updated_at = Timestamp::now();
767            }
768            ValidatorRegistryStatus::PendingExit | ValidatorRegistryStatus::Exited => {
769                return Err(TokenError::InvalidParameter(format!(
770                    "validator {} already exiting (status {:?})",
771                    address, entry.status
772                )));
773            }
774            ValidatorRegistryStatus::Jailed => {
775                return Err(TokenError::InvalidParameter(format!(
776                    "validator {} is jailed; exit forbidden until governance restores",
777                    address
778                )));
779            }
780        }
781        let snapshot = entry.clone();
782        drop(entry);
783        self.persist_entry(&snapshot);
784        info!("Validator {} requested exit", address);
785        Ok(())
786    }
787
788    /// Updates the operator metadata URI and (optionally) the TEE attestation
789    /// hash. Permitted in any state except `Exited`.
790    pub fn update_metadata(
791        &self,
792        address: &Address,
793        metadata_uri: Option<String>,
794        tee_attestation_hash: Option<[u8; 32]>,
795    ) -> Result<()> {
796        let mut entry = self
797            .entries
798            .get_mut(address)
799            .ok_or_else(|| TokenError::NotFound(format!("validator {}", address)))?;
800
801        if entry.status == ValidatorRegistryStatus::Exited {
802            return Err(TokenError::InvalidParameter(
803                "cannot update metadata for exited validator".to_string(),
804            ));
805        }
806        if let Some(uri) = metadata_uri {
807            if uri.len() > 256 {
808                return Err(TokenError::InvalidParameter(format!(
809                    "metadata_uri exceeds 256 bytes (got {})",
810                    uri.len()
811                )));
812            }
813            entry.metadata_uri = uri;
814        }
815        if let Some(h) = tee_attestation_hash {
816            entry.tee_attestation_hash = Some(h);
817        }
818        entry.updated_at = Timestamp::now();
819        let snapshot = entry.clone();
820        drop(entry);
821        self.persist_entry(&snapshot);
822        Ok(())
823    }
824
825    /// Rotates the three-tuple of consensus keys (Ed25519 + ML-DSA-65 +
826    /// BLS12-381) for an existing validator entry, preserving every other
827    /// field (stake, tier, status, lifecycle counters, withdrawal address,
828    /// metadata).
829    ///
830    /// Key lengths are enforced (32 / 1952 / 48). The caller is
831    /// responsible for authorisation — typically a signed
832    /// `tenzro_rotateValidatorKey` RPC where the signature is verified
833    /// against the *current* `consensus_pubkey` before this call lands.
834    ///
835    /// Refused on Exited or Jailed entries — a rotation must follow
836    /// reactivation, not precede it. Idempotent on identical-keys input
837    /// (returns Ok without touching disk).
838    pub fn rotate_keys(
839        &self,
840        address: &Address,
841        new_consensus_pubkey: Vec<u8>,
842        new_pq_pubkey: Vec<u8>,
843        new_bls_pubkey: Vec<u8>,
844    ) -> Result<()> {
845        // Length checks mirror new_candidate.
846        if new_consensus_pubkey.len() != 32 {
847            return Err(TokenError::InvalidParameter(format!(
848                "rotate_keys: new consensus_pubkey must be 32 bytes (Ed25519), got {}",
849                new_consensus_pubkey.len()
850            )));
851        }
852        if new_pq_pubkey.len() != 1952 {
853            return Err(TokenError::InvalidParameter(format!(
854                "rotate_keys: new pq_pubkey must be 1952 bytes (ML-DSA-65), got {}",
855                new_pq_pubkey.len()
856            )));
857        }
858        if new_bls_pubkey.len() != 48 {
859            return Err(TokenError::InvalidParameter(format!(
860                "rotate_keys: new bls_pubkey must be 48 bytes (BLS12-381 G1 compressed), got {}",
861                new_bls_pubkey.len()
862            )));
863        }
864
865        let mut entry = self
866            .entries
867            .get_mut(address)
868            .ok_or_else(|| TokenError::NotFound(format!("validator {}", address)))?;
869
870        // Refuse rotation on terminal / penalty states. Operator must
871        // reactivate first.
872        if matches!(
873            entry.status,
874            ValidatorRegistryStatus::Exited | ValidatorRegistryStatus::Jailed
875        ) {
876            return Err(TokenError::InvalidParameter(format!(
877                "cannot rotate keys for validator {} in state {:?} — \
878                 reactivate first",
879                address, entry.status
880            )));
881        }
882
883        // Idempotent on no-op rotations.
884        if entry.consensus_pubkey == new_consensus_pubkey
885            && entry.pq_pubkey == new_pq_pubkey
886            && entry.bls_pubkey == new_bls_pubkey
887        {
888            return Ok(());
889        }
890
891        entry.consensus_pubkey = new_consensus_pubkey;
892        entry.pq_pubkey = new_pq_pubkey;
893        entry.bls_pubkey = new_bls_pubkey;
894        entry.updated_at = Timestamp::now();
895        let snapshot = entry.clone();
896        drop(entry);
897        self.persist_entry(&snapshot);
898        info!(
899            "Validator {} keys rotated — change takes effect at next epoch boundary",
900            address
901        );
902        Ok(())
903    }
904
905    /// Marks a validator as jailed in response to a slash event. Forces an
906    /// exit at the next epoch boundary. Idempotent.
907    pub fn jail(&self, address: &Address, current_epoch: u64) -> Result<()> {
908        let mut entry = self
909            .entries
910            .get_mut(address)
911            .ok_or_else(|| TokenError::NotFound(format!("validator {}", address)))?;
912
913        entry.status = ValidatorRegistryStatus::Jailed;
914        entry.jailed_until_epoch = None; // governance-gated reactivation
915        entry.exited_at_epoch = Some(current_epoch);
916        entry.updated_at = Timestamp::now();
917        let snapshot = entry.clone();
918        drop(entry);
919        self.persist_entry(&snapshot);
920        warn!(
921            "Validator {} jailed at epoch {} — pending governance restoration",
922            address, current_epoch
923        );
924        Ok(())
925    }
926
927    /// Computes the validator-set transition for the new epoch.
928    ///
929    /// This is the **single point** the consensus epoch manager calls at
930    /// every epoch boundary. The plan tells the caller which validators move
931    /// in and out of the active set; the caller is responsible for handing
932    /// those movements to `EpochManager`'s `pending_validators` /
933    /// `pending_removals` queues.
934    pub fn compute_epoch_transition(&self, new_epoch: u64) -> EpochTransitionPlan {
935        let (act_budget, exit_budget) = self.churn_budgets();
936        let mut plan = EpochTransitionPlan::default();
937
938        // Phase 1: complete in-flight transitions from prior epoch.
939        // PendingActive → Active, PendingExit → Exited.
940        let to_finalize: Vec<(Address, ValidatorRegistryStatus)> = self
941            .entries
942            .iter()
943            .filter_map(|e| {
944                let status = e.value().status;
945                if matches!(
946                    status,
947                    ValidatorRegistryStatus::PendingActive | ValidatorRegistryStatus::PendingExit
948                ) {
949                    Some((e.value().address, status))
950                } else {
951                    None
952                }
953            })
954            .collect();
955
956        for (addr, status) in to_finalize {
957            if let Some(mut entry) = self.entries.get_mut(&addr) {
958                match status {
959                    ValidatorRegistryStatus::PendingActive => {
960                        entry.status = ValidatorRegistryStatus::Active;
961                        entry.activated_at_epoch = Some(new_epoch);
962                        entry.updated_at = Timestamp::now();
963                        plan.effective_activations.push(addr);
964                    }
965                    ValidatorRegistryStatus::PendingExit => {
966                        entry.status = ValidatorRegistryStatus::Exited;
967                        entry.exited_at_epoch = Some(new_epoch);
968                        entry.updated_at = Timestamp::now();
969                        plan.effective_exits.push(addr);
970                    }
971                    _ => unreachable!(),
972                }
973                let snapshot = entry.clone();
974                drop(entry);
975                self.persist_entry(&snapshot);
976            }
977        }
978
979        // Phase 2: admit new candidates up to the activation budget.
980        // Order by stake descending, then by registered_at_epoch ascending
981        // (earlier registration wins ties) for deterministic ordering across
982        // all validators.
983        let mut candidates: Vec<(Address, u128, u64)> = self
984            .entries
985            .iter()
986            .filter(|e| e.value().status == ValidatorRegistryStatus::Candidate)
987            .map(|e| {
988                (
989                    e.value().address,
990                    e.value().self_stake,
991                    e.value().registered_at_epoch,
992                )
993            })
994            .collect();
995        candidates.sort_by(|a, b| {
996            b.1.cmp(&a.1)
997                .then(a.2.cmp(&b.2))
998                .then(a.0.as_bytes().cmp(b.0.as_bytes()))
999        });
1000
1001        for (addr, _, _) in candidates.into_iter().take(act_budget as usize) {
1002            if let Some(mut entry) = self.entries.get_mut(&addr) {
1003                entry.status = ValidatorRegistryStatus::PendingActive;
1004                entry.updated_at = Timestamp::now();
1005                plan.activations.push(addr);
1006                let snapshot = entry.clone();
1007                drop(entry);
1008                self.persist_entry(&snapshot);
1009            }
1010        }
1011
1012        // Phase 3: enforce the exit budget. PendingExit entries already
1013        // counted, so exits beyond the budget are deferred to next epoch by
1014        // simply not being finalised this round. We enforce the cap by
1015        // capping how many we *finalise* in phase 1; redo that cleanly: any
1016        // effective_exits over budget get rolled back to PendingExit.
1017        // (Phase 1 already finalised them; clamp here.)
1018        if plan.effective_exits.len() > exit_budget as usize {
1019            let overflow = plan.effective_exits.split_off(exit_budget as usize);
1020            for addr in &overflow {
1021                if let Some(mut entry) = self.entries.get_mut(addr) {
1022                    entry.status = ValidatorRegistryStatus::PendingExit;
1023                    entry.exited_at_epoch = None;
1024                    entry.updated_at = Timestamp::now();
1025                    let snapshot = entry.clone();
1026                    drop(entry);
1027                    self.persist_entry(&snapshot);
1028                }
1029            }
1030        }
1031
1032        info!(
1033            "Epoch {} transition: +{} activations ({} effective), -{} exits ({} effective)",
1034            new_epoch,
1035            plan.activations.len(),
1036            plan.effective_activations.len(),
1037            plan.exits.len(),
1038            plan.effective_exits.len()
1039        );
1040
1041        plan
1042    }
1043}
1044
1045impl Default for ValidatorRegistry {
1046    fn default() -> Self {
1047        Self::new()
1048    }
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054    use tenzro_crypto::pq::ML_DSA_65_VK_LEN;
1055
1056    fn make_address(seed: u8) -> Address {
1057        let mut bytes = [0u8; 32];
1058        bytes[0] = seed;
1059        Address::new(bytes)
1060    }
1061
1062    fn make_keys() -> (Vec<u8>, Vec<u8>, Vec<u8>) {
1063        (vec![0u8; 32], vec![0u8; ML_DSA_65_VK_LEN], vec![0u8; 48])
1064    }
1065
1066    #[test]
1067    fn seed_genesis_active_idempotent() {
1068        let reg = ValidatorRegistry::new();
1069        let (ck, pk, bk) = make_keys();
1070        let a = make_address(7);
1071
1072        // First seed: inserted
1073        let inserted = reg
1074            .seed_genesis_active(
1075                a,
1076                ck.clone(),
1077                pk.clone(),
1078                bk.clone(),
1079                a,
1080                DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1081                String::new(),
1082            )
1083            .unwrap();
1084        assert!(inserted);
1085        let entry = reg.get(&a).unwrap();
1086        assert_eq!(entry.status, ValidatorRegistryStatus::Active);
1087        assert_eq!(entry.activated_at_epoch, Some(0));
1088        assert_eq!(entry.registered_at_epoch, 0);
1089        assert_eq!(entry.self_stake, DEFAULT_MIN_VALIDATOR_SELF_STAKE);
1090
1091        // Second seed: skipped (already present), entry untouched
1092        let inserted_again = reg
1093            .seed_genesis_active(
1094                a,
1095                ck.clone(),
1096                pk.clone(),
1097                bk.clone(),
1098                a,
1099                DEFAULT_MIN_VALIDATOR_SELF_STAKE * 2,
1100                String::from("changed"),
1101            )
1102            .unwrap();
1103        assert!(!inserted_again);
1104        let entry2 = reg.get(&a).unwrap();
1105        assert_eq!(entry2.self_stake, DEFAULT_MIN_VALIDATOR_SELF_STAKE);
1106        assert_eq!(entry2.metadata_uri, "");
1107
1108        // Active validators include the seeded entry
1109        assert_eq!(reg.list_active().len(), 1);
1110    }
1111
1112    #[test]
1113    fn seed_genesis_active_rejects_bad_keys() {
1114        let reg = ValidatorRegistry::new();
1115        let a = make_address(8);
1116
1117        // Wrong consensus pubkey length
1118        let err = reg
1119            .seed_genesis_active(
1120                a,
1121                vec![0u8; 31],
1122                vec![0u8; ML_DSA_65_VK_LEN],
1123                vec![0u8; 48],
1124                a,
1125                DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1126                String::new(),
1127            )
1128            .unwrap_err();
1129        assert!(matches!(err, TokenError::InvalidParameter(_)));
1130
1131        // Wrong PQ pubkey length
1132        let err = reg
1133            .seed_genesis_active(
1134                a,
1135                vec![0u8; 32],
1136                vec![0u8; 100],
1137                vec![0u8; 48],
1138                a,
1139                DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1140                String::new(),
1141            )
1142            .unwrap_err();
1143        assert!(matches!(err, TokenError::InvalidParameter(_)));
1144
1145        // Wrong BLS pubkey length
1146        let err = reg
1147            .seed_genesis_active(
1148                a,
1149                vec![0u8; 32],
1150                vec![0u8; ML_DSA_65_VK_LEN],
1151                vec![0u8; 47],
1152                a,
1153                DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1154                String::new(),
1155            )
1156            .unwrap_err();
1157        assert!(matches!(err, TokenError::InvalidParameter(_)));
1158    }
1159
1160    #[test]
1161    fn register_below_min_stake_admits_as_tier_1() {
1162        // Per the canonical three-tier model (WHITEPAPER §5 +
1163        // TOKENOMICS §7): registrations with `self_stake == 0` (or
1164        // any amount below `min_self_stake`) are admitted as Tier 1
1165        // (resource-only) validators. Stake-floor enforcement is
1166        // gone; tier is derived from stake instead.
1167        let reg = ValidatorRegistry::new();
1168        let (ck, pk, bk) = make_keys();
1169        let a = make_address(1);
1170
1171        // Zero stake → Tier 1 admission succeeds.
1172        reg.register_candidate(a, ck.clone(), pk.clone(), bk.clone(), a, 0, 0, String::new())
1173            .unwrap();
1174        let entry = reg.get(&a).unwrap();
1175        assert_eq!(entry.tier, ValidatorTier::ResourceOnly);
1176        assert_eq!(entry.self_stake, 0);
1177        assert_eq!(entry.status, ValidatorRegistryStatus::Candidate);
1178    }
1179
1180    #[test]
1181    fn register_at_tier_2_threshold_sets_staked() {
1182        let reg = ValidatorRegistry::new();
1183        let (ck, pk, bk) = make_keys();
1184        let a = make_address(2);
1185        reg.register_candidate(
1186            a,
1187            ck,
1188            pk,
1189            bk,
1190            a,
1191            DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1192            0,
1193            String::new(),
1194        )
1195        .unwrap();
1196        assert_eq!(reg.get(&a).unwrap().tier, ValidatorTier::Staked);
1197    }
1198
1199    #[test]
1200    fn register_at_tier_3_threshold_sets_rpc_provider() {
1201        let reg = ValidatorRegistry::new();
1202        let (ck, pk, bk) = make_keys();
1203        let a = make_address(3);
1204        reg.register_candidate(
1205            a,
1206            ck,
1207            pk,
1208            bk,
1209            a,
1210            DEFAULT_MIN_RPC_PROVIDER_STAKE,
1211            0,
1212            String::new(),
1213        )
1214        .unwrap();
1215        assert_eq!(reg.get(&a).unwrap().tier, ValidatorTier::RpcProvider);
1216    }
1217
1218    #[test]
1219    fn register_then_activate_then_exit_full_lifecycle() {
1220        let reg = ValidatorRegistry::new();
1221        let (ck, pk, bk) = make_keys();
1222        let a = make_address(1);
1223        reg.register_candidate(
1224            a,
1225            ck.clone(),
1226            pk.clone(),
1227            bk.clone(),
1228            a,
1229            DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1230            0,
1231            String::new(),
1232        )
1233        .unwrap();
1234        assert_eq!(
1235            reg.get(&a).unwrap().status,
1236            ValidatorRegistryStatus::Candidate
1237        );
1238
1239        // Epoch 1: candidate → pending active
1240        let plan = reg.compute_epoch_transition(1);
1241        assert_eq!(plan.activations, vec![a]);
1242        assert_eq!(
1243            reg.get(&a).unwrap().status,
1244            ValidatorRegistryStatus::PendingActive
1245        );
1246
1247        // Epoch 2: pending active → active
1248        let plan = reg.compute_epoch_transition(2);
1249        assert_eq!(plan.effective_activations, vec![a]);
1250        assert_eq!(reg.get(&a).unwrap().status, ValidatorRegistryStatus::Active);
1251
1252        // Request exit
1253        reg.request_exit(&a).unwrap();
1254        assert_eq!(
1255            reg.get(&a).unwrap().status,
1256            ValidatorRegistryStatus::PendingExit
1257        );
1258
1259        // Epoch 3: pending exit → exited
1260        let plan = reg.compute_epoch_transition(3);
1261        assert_eq!(plan.effective_exits, vec![a]);
1262        assert_eq!(reg.get(&a).unwrap().status, ValidatorRegistryStatus::Exited);
1263        assert_eq!(reg.get(&a).unwrap().exited_at_epoch, Some(3));
1264    }
1265
1266    #[test]
1267    fn reentry_blocked_during_cooldown() {
1268        let reg = ValidatorRegistry::new();
1269        let (ck, pk, bk) = make_keys();
1270        let a = make_address(1);
1271        reg.register_candidate(
1272            a,
1273            ck.clone(),
1274            pk.clone(),
1275            bk.clone(),
1276            a,
1277            DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1278            0,
1279            String::new(),
1280        )
1281        .unwrap();
1282        reg.compute_epoch_transition(1);
1283        reg.compute_epoch_transition(2);
1284        reg.request_exit(&a).unwrap();
1285        reg.compute_epoch_transition(3);
1286        assert_eq!(reg.get(&a).unwrap().status, ValidatorRegistryStatus::Exited);
1287
1288        // Same epoch — re-entry blocked.
1289        let err = reg
1290            .register_candidate(
1291                a,
1292                ck.clone(),
1293                pk.clone(),
1294                bk.clone(),
1295                a,
1296                DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1297                3,
1298                String::new(),
1299            )
1300            .unwrap_err();
1301        assert!(matches!(err, TokenError::Unauthorized { .. }));
1302
1303        // After cooldown, re-entry permitted.
1304        reg.register_candidate(
1305            a,
1306            ck,
1307            pk,
1308            bk,
1309            a,
1310            DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1311            3 + DEFAULT_REENTRY_COOLDOWN_EPOCHS,
1312            String::new(),
1313        )
1314        .unwrap();
1315        assert_eq!(
1316            reg.get(&a).unwrap().status,
1317            ValidatorRegistryStatus::Candidate
1318        );
1319    }
1320
1321    #[test]
1322    fn jail_forces_exit() {
1323        let reg = ValidatorRegistry::new();
1324        let (ck, pk, bk) = make_keys();
1325        let a = make_address(1);
1326        reg.register_candidate(
1327            a,
1328            ck,
1329            pk,
1330            bk,
1331            a,
1332            DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1333            0,
1334            String::new(),
1335        )
1336        .unwrap();
1337        reg.compute_epoch_transition(1);
1338        reg.compute_epoch_transition(2);
1339        assert_eq!(reg.get(&a).unwrap().status, ValidatorRegistryStatus::Active);
1340        reg.jail(&a, 2).unwrap();
1341        assert_eq!(reg.get(&a).unwrap().status, ValidatorRegistryStatus::Jailed);
1342        // Jailed validators don't auto-exit — governance-gated.
1343    }
1344
1345    #[test]
1346    fn churn_cap_limits_activations() {
1347        let reg = ValidatorRegistry::new();
1348        // Set up 5 active validators by hand so the cap (4% of 5 = 0, floored
1349        // to MIN_CHURN_PER_EPOCH = 1) admits exactly one new candidate.
1350        for i in 1..=5u8 {
1351            let (ck, pk, bk) = make_keys();
1352            let a = make_address(i);
1353            reg.register_candidate(
1354                a,
1355                ck,
1356                pk,
1357                bk,
1358                a,
1359                DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1360                0,
1361                String::new(),
1362            )
1363            .unwrap();
1364        }
1365        // Bootstrap: all 5 admitted in epoch 1 (active set is empty so the
1366        // floor lets MIN_CHURN_PER_EPOCH through; in practice genesis seeds
1367        // the initial set differently — see register_initial_active for that
1368        // path). For this test we drive them to Active by hand:
1369        for i in 1..=5u8 {
1370            let a = make_address(i);
1371            if let Some(mut entry) = reg.entries.get_mut(&a) {
1372                entry.status = ValidatorRegistryStatus::Active;
1373            }
1374        }
1375        assert_eq!(reg.list_active().len(), 5);
1376
1377        // Add 3 fresh candidates and run a transition.
1378        for i in 6..=8u8 {
1379            let (ck, pk, bk) = make_keys();
1380            let a = make_address(i);
1381            reg.register_candidate(
1382                a,
1383                ck,
1384                pk,
1385                bk,
1386                a,
1387                DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1388                0,
1389                String::new(),
1390            )
1391            .unwrap();
1392        }
1393        let plan = reg.compute_epoch_transition(1);
1394        // 4% of 5 = 0, floored to MIN_CHURN_PER_EPOCH = 1 — exactly one
1395        // candidate should be promoted.
1396        assert_eq!(plan.activations.len(), 1);
1397    }
1398
1399    #[test]
1400    fn rotate_keys_updates_registry_in_place() {
1401        let reg = ValidatorRegistry::new();
1402        let (ck0, pk0, bk0) = make_keys();
1403        let a = make_address(11);
1404        reg.register_candidate(
1405            a,
1406            ck0.clone(),
1407            pk0.clone(),
1408            bk0.clone(),
1409            a,
1410            DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1411            0,
1412            String::new(),
1413        )
1414        .unwrap();
1415
1416        // Rotate to fresh keys (bytes 1 instead of 0 for the consensus key).
1417        let new_ck = vec![1u8; 32];
1418        let new_pk = vec![2u8; ML_DSA_65_VK_LEN];
1419        let new_bk = vec![3u8; 48];
1420        reg.rotate_keys(&a, new_ck.clone(), new_pk.clone(), new_bk.clone())
1421            .unwrap();
1422
1423        let entry = reg.get(&a).unwrap();
1424        assert_eq!(entry.consensus_pubkey, new_ck);
1425        assert_eq!(entry.pq_pubkey, new_pk);
1426        assert_eq!(entry.bls_pubkey, new_bk);
1427        // Stake, tier, status, withdrawal — all unchanged.
1428        assert_eq!(entry.self_stake, DEFAULT_MIN_VALIDATOR_SELF_STAKE);
1429        assert_eq!(entry.tier, ValidatorTier::Staked);
1430        assert_eq!(entry.status, ValidatorRegistryStatus::Candidate);
1431        assert_eq!(entry.withdrawal_address, a);
1432    }
1433
1434    #[test]
1435    fn rotate_keys_rejects_wrong_lengths() {
1436        let reg = ValidatorRegistry::new();
1437        let (ck, pk, bk) = make_keys();
1438        let a = make_address(12);
1439        reg.register_candidate(a, ck, pk, bk, a, 0, 0, String::new()).unwrap();
1440
1441        // 31-byte consensus key — rejected.
1442        let bad = reg.rotate_keys(&a, vec![1u8; 31], vec![2u8; ML_DSA_65_VK_LEN], vec![3u8; 48]);
1443        assert!(bad.is_err(), "31-byte consensus_pubkey must be rejected");
1444
1445        // Wrong PQ length — rejected.
1446        let bad = reg.rotate_keys(&a, vec![1u8; 32], vec![2u8; 1951], vec![3u8; 48]);
1447        assert!(bad.is_err(), "wrong-length pq_pubkey must be rejected");
1448
1449        // Wrong BLS length — rejected.
1450        let bad = reg.rotate_keys(&a, vec![1u8; 32], vec![2u8; ML_DSA_65_VK_LEN], vec![3u8; 47]);
1451        assert!(bad.is_err(), "wrong-length bls_pubkey must be rejected");
1452    }
1453
1454    #[test]
1455    fn rotate_keys_refuses_exited_validator() {
1456        let reg = ValidatorRegistry::new();
1457        let (ck, pk, bk) = make_keys();
1458        let a = make_address(13);
1459        reg.register_candidate(
1460            a,
1461            ck.clone(),
1462            pk.clone(),
1463            bk.clone(),
1464            a,
1465            DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1466            0,
1467            String::new(),
1468        )
1469        .unwrap();
1470
1471        // Force the entry into Exited state without going through the
1472        // governance path — direct mutation for the test fixture.
1473        reg.entries
1474            .alter(&a, |_, mut e| {
1475                e.status = ValidatorRegistryStatus::Exited;
1476                e
1477            });
1478
1479        let new_ck = vec![1u8; 32];
1480        let err = reg
1481            .rotate_keys(&a, new_ck, vec![2u8; ML_DSA_65_VK_LEN], vec![3u8; 48])
1482            .unwrap_err();
1483        let msg = format!("{}", err);
1484        assert!(
1485            msg.contains("Exited"),
1486            "expected Exited-state refusal, got: {}",
1487            msg
1488        );
1489    }
1490
1491    #[test]
1492    fn rotate_keys_idempotent_on_noop() {
1493        let reg = ValidatorRegistry::new();
1494        let (ck, pk, bk) = make_keys();
1495        let a = make_address(14);
1496        reg.register_candidate(
1497            a,
1498            ck.clone(),
1499            pk.clone(),
1500            bk.clone(),
1501            a,
1502            DEFAULT_MIN_VALIDATOR_SELF_STAKE,
1503            0,
1504            String::new(),
1505        )
1506        .unwrap();
1507
1508        // Same keys → no-op success.
1509        reg.rotate_keys(&a, ck.clone(), pk.clone(), bk.clone())
1510            .unwrap();
1511        let entry = reg.get(&a).unwrap();
1512        assert_eq!(entry.consensus_pubkey, ck);
1513    }
1514}