use crate::error::{Result, TokenError};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tenzro_types::primitives::{Address, Timestamp};
use tracing::{info, warn};
pub const DEFAULT_MIN_VALIDATOR_SELF_STAKE: u128 = 10_000 * 1_000_000_000_000_000_000;
pub const DEFAULT_MIN_RPC_PROVIDER_STAKE: u128 = 100_000 * 1_000_000_000_000_000_000;
pub const DEFAULT_ACTIVATION_CHURN_BPS: u32 = 400;
pub const DEFAULT_EXIT_CHURN_BPS: u32 = 400;
pub const MIN_CHURN_PER_EPOCH: u32 = 1;
pub const DEFAULT_REENTRY_COOLDOWN_EPOCHS: u64 = 4;
pub const ACTIVATION_EFFECTIVE_DELAY_BLOCKS: u64 = 3;
pub const VALIDATOR_PREFIX: &str = "validator:";
pub const VALIDATOR_INDEX_KEY: &str = "validator:index";
pub const VALIDATOR_CONFIG_KEY: &str = "validator:config";
const REGISTRY_CF: &str = tenzro_storage::CF_TOKENS;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ValidatorTier {
ResourceOnly,
Staked,
RpcProvider,
}
impl ValidatorTier {
pub fn from_stake(self_stake: u128, cfg: &ValidatorRegistryConfig) -> Self {
if self_stake >= cfg.min_rpc_provider_stake {
Self::RpcProvider
} else if self_stake >= cfg.min_self_stake {
Self::Staked
} else {
Self::ResourceOnly
}
}
pub fn admits_high_trust(&self) -> bool {
!matches!(self, Self::ResourceOnly)
}
pub fn has_governance_weight(&self) -> bool {
!matches!(self, Self::ResourceOnly)
}
pub fn has_financial_slashing(&self) -> bool {
!matches!(self, Self::ResourceOnly)
}
pub fn admits_rpc_role(&self) -> bool {
matches!(self, Self::RpcProvider)
}
pub fn as_str(&self) -> &'static str {
match self {
Self::ResourceOnly => "resource_only",
Self::Staked => "staked",
Self::RpcProvider => "rpc_provider",
}
}
}
impl Default for ValidatorTier {
fn default() -> Self {
Self::Staked
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ValidatorRegistryStatus {
Candidate,
PendingActive,
Active,
PendingExit,
Exited,
Jailed,
}
impl ValidatorRegistryStatus {
pub fn counts_toward_active(&self) -> bool {
matches!(self, Self::Active | Self::PendingExit)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidatorRegistryEntry {
pub address: Address,
pub consensus_pubkey: Vec<u8>,
pub pq_pubkey: Vec<u8>,
pub bls_pubkey: Vec<u8>,
pub withdrawal_address: Address,
pub self_stake: u128,
#[serde(default)]
pub tier: ValidatorTier,
pub status: ValidatorRegistryStatus,
pub registered_at_epoch: u64,
pub activated_at_epoch: Option<u64>,
pub exited_at_epoch: Option<u64>,
pub jailed_until_epoch: Option<u64>,
pub tee_attestation_hash: Option<[u8; 32]>,
pub metadata_uri: String,
pub updated_at: Timestamp,
}
impl ValidatorRegistryEntry {
pub fn new_candidate(
address: Address,
consensus_pubkey: Vec<u8>,
pq_pubkey: Vec<u8>,
bls_pubkey: Vec<u8>,
withdrawal_address: Address,
self_stake: u128,
registered_at_epoch: u64,
metadata_uri: String,
cfg: &ValidatorRegistryConfig,
) -> Result<Self> {
if consensus_pubkey.len() != 32 {
return Err(TokenError::InvalidParameter(format!(
"consensus public key must be 32 bytes, got {}",
consensus_pubkey.len()
)));
}
if pq_pubkey.len() != tenzro_crypto::pq::ML_DSA_65_VK_LEN {
return Err(TokenError::InvalidParameter(format!(
"PQ verifying key must be {} bytes (ML-DSA-65), got {}",
tenzro_crypto::pq::ML_DSA_65_VK_LEN,
pq_pubkey.len()
)));
}
if bls_pubkey.len() != 48 {
return Err(TokenError::InvalidParameter(format!(
"BLS verifying key must be 48 bytes (BLS12-381 G1-compressed, min_pk), got {}",
bls_pubkey.len()
)));
}
if metadata_uri.len() > 256 {
return Err(TokenError::InvalidParameter(format!(
"metadata_uri exceeds 256 bytes (got {})",
metadata_uri.len()
)));
}
let tier = ValidatorTier::from_stake(self_stake, cfg);
Ok(Self {
address,
consensus_pubkey,
pq_pubkey,
bls_pubkey,
withdrawal_address,
self_stake,
tier,
status: ValidatorRegistryStatus::Candidate,
registered_at_epoch,
activated_at_epoch: None,
exited_at_epoch: None,
jailed_until_epoch: None,
tee_attestation_hash: None,
metadata_uri,
updated_at: Timestamp::now(),
})
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ValidatorRegistryConfig {
pub min_self_stake: u128,
#[serde(default = "default_min_rpc_provider_stake")]
pub min_rpc_provider_stake: u128,
pub activation_churn_bps: u32,
pub exit_churn_bps: u32,
pub reentry_cooldown_epochs: u64,
}
fn default_min_rpc_provider_stake() -> u128 {
DEFAULT_MIN_RPC_PROVIDER_STAKE
}
impl Default for ValidatorRegistryConfig {
fn default() -> Self {
Self {
min_self_stake: DEFAULT_MIN_VALIDATOR_SELF_STAKE,
min_rpc_provider_stake: DEFAULT_MIN_RPC_PROVIDER_STAKE,
activation_churn_bps: DEFAULT_ACTIVATION_CHURN_BPS,
exit_churn_bps: DEFAULT_EXIT_CHURN_BPS,
reentry_cooldown_epochs: DEFAULT_REENTRY_COOLDOWN_EPOCHS,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct EpochTransitionPlan {
pub activations: Vec<Address>,
pub exits: Vec<Address>,
pub effective_activations: Vec<Address>,
pub effective_exits: Vec<Address>,
}
pub struct ValidatorRegistry {
entries: DashMap<Address, ValidatorRegistryEntry>,
config: parking_lot::RwLock<ValidatorRegistryConfig>,
storage: Option<Arc<dyn tenzro_storage::KvStore>>,
}
impl ValidatorRegistry {
pub fn new() -> Self {
Self {
entries: DashMap::new(),
config: parking_lot::RwLock::new(ValidatorRegistryConfig::default()),
storage: None,
}
}
pub fn with_storage(storage: Arc<dyn tenzro_storage::KvStore>) -> Self {
let registry = Self {
entries: DashMap::new(),
config: parking_lot::RwLock::new(ValidatorRegistryConfig::default()),
storage: Some(storage),
};
if let Err(e) = registry.load_from_storage() {
warn!("Failed to hydrate validator registry: {}", e);
}
registry
}
fn persist_entry(&self, entry: &ValidatorRegistryEntry) {
if let Some(storage) = &self.storage {
let key = format!("{}{}", VALIDATOR_PREFIX, hex::encode(entry.address.as_bytes()));
match bincode::serialize(entry) {
Ok(data) => {
if let Err(e) = storage.put(REGISTRY_CF, key.as_bytes(), &data) {
warn!("Failed to persist validator {}: {}", entry.address, e);
}
}
Err(e) => warn!("Failed to serialize validator {}: {}", entry.address, e),
}
}
}
fn persist_index(&self) {
if let Some(storage) = &self.storage {
let addresses: Vec<String> = self
.entries
.iter()
.map(|e| hex::encode(e.key().as_bytes()))
.collect();
match bincode::serialize(&addresses) {
Ok(data) => {
if let Err(e) = storage.put(REGISTRY_CF, VALIDATOR_INDEX_KEY.as_bytes(), &data)
{
warn!("Failed to persist validator index: {}", e);
}
}
Err(e) => warn!("Failed to serialize validator index: {}", e),
}
}
}
fn persist_config(&self) {
if let Some(storage) = &self.storage {
let cfg = *self.config.read();
match bincode::serialize(&cfg) {
Ok(data) => {
if let Err(e) = storage.put(REGISTRY_CF, VALIDATOR_CONFIG_KEY.as_bytes(), &data)
{
warn!("Failed to persist validator config: {}", e);
}
}
Err(e) => warn!("Failed to serialize validator config: {}", e),
}
}
}
fn load_from_storage(&self) -> Result<()> {
let storage = match &self.storage {
Some(s) => s,
None => return Ok(()),
};
if let Ok(Some(cfg_data)) = storage.get(REGISTRY_CF, VALIDATOR_CONFIG_KEY.as_bytes())
&& let Ok(cfg) = bincode::deserialize::<ValidatorRegistryConfig>(&cfg_data)
{
*self.config.write() = cfg;
}
if let Ok(Some(idx_data)) = storage.get(REGISTRY_CF, VALIDATOR_INDEX_KEY.as_bytes()) {
let addresses: Vec<String> = match bincode::deserialize(&idx_data) {
Ok(v) => v,
Err(e) => {
warn!("Failed to deserialize validator index: {}", e);
return Ok(());
}
};
for hex_addr in &addresses {
let key = format!("{}{}", VALIDATOR_PREFIX, hex_addr);
if let Ok(Some(data)) = storage.get(REGISTRY_CF, key.as_bytes()) {
match bincode::deserialize::<ValidatorRegistryEntry>(&data) {
Ok(entry) => {
self.entries.insert(entry.address, entry);
}
Err(e) => warn!("Failed to deserialize validator {}: {}", hex_addr, e),
}
}
}
info!("Loaded {} validators from registry", addresses.len());
}
Ok(())
}
pub fn config(&self) -> ValidatorRegistryConfig {
*self.config.read()
}
pub fn set_config(&self, cfg: ValidatorRegistryConfig) {
*self.config.write() = cfg;
self.persist_config();
}
pub fn get(&self, address: &Address) -> Option<ValidatorRegistryEntry> {
self.entries.get(address).map(|e| e.value().clone())
}
pub fn list(&self) -> Vec<ValidatorRegistryEntry> {
self.entries.iter().map(|e| e.value().clone()).collect()
}
pub fn list_active(&self) -> Vec<ValidatorRegistryEntry> {
self.entries
.iter()
.filter(|e| e.value().status == ValidatorRegistryStatus::Active)
.map(|e| e.value().clone())
.collect()
}
fn active_set_size(&self) -> usize {
self.entries
.iter()
.filter(|e| e.value().status.counts_toward_active())
.count()
}
fn churn_budgets(&self) -> (u32, u32) {
let cfg = *self.config.read();
let n = self.active_set_size() as u64;
let act = ((n.saturating_mul(cfg.activation_churn_bps as u64)) / 10_000) as u32;
let exit = ((n.saturating_mul(cfg.exit_churn_bps as u64)) / 10_000) as u32;
(act.max(MIN_CHURN_PER_EPOCH), exit.max(MIN_CHURN_PER_EPOCH))
}
#[allow(clippy::too_many_arguments)]
pub fn register_candidate(
&self,
address: Address,
consensus_pubkey: Vec<u8>,
pq_pubkey: Vec<u8>,
bls_pubkey: Vec<u8>,
withdrawal_address: Address,
self_stake: u128,
current_epoch: u64,
metadata_uri: String,
) -> Result<()> {
let cfg = *self.config.read();
if let Some(existing) = self.entries.get(&address) {
match existing.status {
ValidatorRegistryStatus::Exited => {
if let Some(exit_epoch) = existing.exited_at_epoch
&& current_epoch < exit_epoch.saturating_add(cfg.reentry_cooldown_epochs)
{
return Err(TokenError::Unauthorized {
reason: format!(
"re-entry cooldown: must wait until epoch {}",
exit_epoch.saturating_add(cfg.reentry_cooldown_epochs)
),
});
}
}
_ => {
return Err(TokenError::InvalidParameter(format!(
"validator {} already registered with status {:?}",
address, existing.status
)));
}
}
}
let entry = ValidatorRegistryEntry::new_candidate(
address,
consensus_pubkey,
pq_pubkey,
bls_pubkey,
withdrawal_address,
self_stake,
current_epoch,
metadata_uri,
&cfg,
)?;
let tier = entry.tier;
self.entries.insert(address, entry.clone());
self.persist_entry(&entry);
self.persist_index();
info!(
"Registered validator candidate {} at epoch {} with stake {} (tier={})",
address, current_epoch, self_stake, tier.as_str()
);
Ok(())
}
pub fn seed_genesis_active(
&self,
address: Address,
consensus_pubkey: Vec<u8>,
pq_pubkey: Vec<u8>,
bls_pubkey: Vec<u8>,
withdrawal_address: Address,
self_stake: u128,
metadata_uri: String,
) -> Result<bool> {
if self.entries.contains_key(&address) {
return Ok(false);
}
if consensus_pubkey.len() != 32 {
return Err(TokenError::InvalidParameter(format!(
"consensus public key must be 32 bytes, got {}",
consensus_pubkey.len()
)));
}
if pq_pubkey.len() != tenzro_crypto::pq::ML_DSA_65_VK_LEN {
return Err(TokenError::InvalidParameter(format!(
"PQ verifying key must be {} bytes (ML-DSA-65), got {}",
tenzro_crypto::pq::ML_DSA_65_VK_LEN,
pq_pubkey.len()
)));
}
if bls_pubkey.len() != 48 {
return Err(TokenError::InvalidParameter(format!(
"BLS verifying key must be 48 bytes (BLS12-381 G1-compressed, min_pk), got {}",
bls_pubkey.len()
)));
}
if metadata_uri.len() > 256 {
return Err(TokenError::InvalidParameter(format!(
"metadata_uri exceeds 256 bytes (got {})",
metadata_uri.len()
)));
}
let cfg = *self.config.read();
let tier = ValidatorTier::from_stake(self_stake, &cfg);
let entry = ValidatorRegistryEntry {
address,
consensus_pubkey,
pq_pubkey,
bls_pubkey,
withdrawal_address,
self_stake,
tier,
status: ValidatorRegistryStatus::Active,
registered_at_epoch: 0,
activated_at_epoch: Some(0),
exited_at_epoch: None,
jailed_until_epoch: None,
tee_attestation_hash: None,
metadata_uri,
updated_at: Timestamp::now(),
};
self.entries.insert(address, entry.clone());
self.persist_entry(&entry);
self.persist_index();
info!(
"Seeded genesis Active validator {} with stake {} (tier={})",
address, self_stake, tier.as_str()
);
Ok(true)
}
pub fn request_exit(&self, address: &Address) -> Result<()> {
let mut entry = self
.entries
.get_mut(address)
.ok_or_else(|| TokenError::NotFound(format!("validator {}", address)))?;
match entry.status {
ValidatorRegistryStatus::Active => {
entry.status = ValidatorRegistryStatus::PendingExit;
entry.updated_at = Timestamp::now();
}
ValidatorRegistryStatus::Candidate | ValidatorRegistryStatus::PendingActive => {
entry.status = ValidatorRegistryStatus::PendingExit;
entry.updated_at = Timestamp::now();
}
ValidatorRegistryStatus::PendingExit | ValidatorRegistryStatus::Exited => {
return Err(TokenError::InvalidParameter(format!(
"validator {} already exiting (status {:?})",
address, entry.status
)));
}
ValidatorRegistryStatus::Jailed => {
return Err(TokenError::InvalidParameter(format!(
"validator {} is jailed; exit forbidden until governance restores",
address
)));
}
}
let snapshot = entry.clone();
drop(entry);
self.persist_entry(&snapshot);
info!("Validator {} requested exit", address);
Ok(())
}
pub fn update_metadata(
&self,
address: &Address,
metadata_uri: Option<String>,
tee_attestation_hash: Option<[u8; 32]>,
) -> Result<()> {
let mut entry = self
.entries
.get_mut(address)
.ok_or_else(|| TokenError::NotFound(format!("validator {}", address)))?;
if entry.status == ValidatorRegistryStatus::Exited {
return Err(TokenError::InvalidParameter(
"cannot update metadata for exited validator".to_string(),
));
}
if let Some(uri) = metadata_uri {
if uri.len() > 256 {
return Err(TokenError::InvalidParameter(format!(
"metadata_uri exceeds 256 bytes (got {})",
uri.len()
)));
}
entry.metadata_uri = uri;
}
if let Some(h) = tee_attestation_hash {
entry.tee_attestation_hash = Some(h);
}
entry.updated_at = Timestamp::now();
let snapshot = entry.clone();
drop(entry);
self.persist_entry(&snapshot);
Ok(())
}
pub fn rotate_keys(
&self,
address: &Address,
new_consensus_pubkey: Vec<u8>,
new_pq_pubkey: Vec<u8>,
new_bls_pubkey: Vec<u8>,
) -> Result<()> {
if new_consensus_pubkey.len() != 32 {
return Err(TokenError::InvalidParameter(format!(
"rotate_keys: new consensus_pubkey must be 32 bytes (Ed25519), got {}",
new_consensus_pubkey.len()
)));
}
if new_pq_pubkey.len() != 1952 {
return Err(TokenError::InvalidParameter(format!(
"rotate_keys: new pq_pubkey must be 1952 bytes (ML-DSA-65), got {}",
new_pq_pubkey.len()
)));
}
if new_bls_pubkey.len() != 48 {
return Err(TokenError::InvalidParameter(format!(
"rotate_keys: new bls_pubkey must be 48 bytes (BLS12-381 G1 compressed), got {}",
new_bls_pubkey.len()
)));
}
let mut entry = self
.entries
.get_mut(address)
.ok_or_else(|| TokenError::NotFound(format!("validator {}", address)))?;
if matches!(
entry.status,
ValidatorRegistryStatus::Exited | ValidatorRegistryStatus::Jailed
) {
return Err(TokenError::InvalidParameter(format!(
"cannot rotate keys for validator {} in state {:?} — \
reactivate first",
address, entry.status
)));
}
if entry.consensus_pubkey == new_consensus_pubkey
&& entry.pq_pubkey == new_pq_pubkey
&& entry.bls_pubkey == new_bls_pubkey
{
return Ok(());
}
entry.consensus_pubkey = new_consensus_pubkey;
entry.pq_pubkey = new_pq_pubkey;
entry.bls_pubkey = new_bls_pubkey;
entry.updated_at = Timestamp::now();
let snapshot = entry.clone();
drop(entry);
self.persist_entry(&snapshot);
info!(
"Validator {} keys rotated — change takes effect at next epoch boundary",
address
);
Ok(())
}
pub fn jail(&self, address: &Address, current_epoch: u64) -> Result<()> {
let mut entry = self
.entries
.get_mut(address)
.ok_or_else(|| TokenError::NotFound(format!("validator {}", address)))?;
entry.status = ValidatorRegistryStatus::Jailed;
entry.jailed_until_epoch = None; entry.exited_at_epoch = Some(current_epoch);
entry.updated_at = Timestamp::now();
let snapshot = entry.clone();
drop(entry);
self.persist_entry(&snapshot);
warn!(
"Validator {} jailed at epoch {} — pending governance restoration",
address, current_epoch
);
Ok(())
}
pub fn compute_epoch_transition(&self, new_epoch: u64) -> EpochTransitionPlan {
let (act_budget, exit_budget) = self.churn_budgets();
let mut plan = EpochTransitionPlan::default();
let to_finalize: Vec<(Address, ValidatorRegistryStatus)> = self
.entries
.iter()
.filter_map(|e| {
let status = e.value().status;
if matches!(
status,
ValidatorRegistryStatus::PendingActive | ValidatorRegistryStatus::PendingExit
) {
Some((e.value().address, status))
} else {
None
}
})
.collect();
for (addr, status) in to_finalize {
if let Some(mut entry) = self.entries.get_mut(&addr) {
match status {
ValidatorRegistryStatus::PendingActive => {
entry.status = ValidatorRegistryStatus::Active;
entry.activated_at_epoch = Some(new_epoch);
entry.updated_at = Timestamp::now();
plan.effective_activations.push(addr);
}
ValidatorRegistryStatus::PendingExit => {
entry.status = ValidatorRegistryStatus::Exited;
entry.exited_at_epoch = Some(new_epoch);
entry.updated_at = Timestamp::now();
plan.effective_exits.push(addr);
}
_ => unreachable!(),
}
let snapshot = entry.clone();
drop(entry);
self.persist_entry(&snapshot);
}
}
let mut candidates: Vec<(Address, u128, u64)> = self
.entries
.iter()
.filter(|e| e.value().status == ValidatorRegistryStatus::Candidate)
.map(|e| {
(
e.value().address,
e.value().self_stake,
e.value().registered_at_epoch,
)
})
.collect();
candidates.sort_by(|a, b| {
b.1.cmp(&a.1)
.then(a.2.cmp(&b.2))
.then(a.0.as_bytes().cmp(b.0.as_bytes()))
});
for (addr, _, _) in candidates.into_iter().take(act_budget as usize) {
if let Some(mut entry) = self.entries.get_mut(&addr) {
entry.status = ValidatorRegistryStatus::PendingActive;
entry.updated_at = Timestamp::now();
plan.activations.push(addr);
let snapshot = entry.clone();
drop(entry);
self.persist_entry(&snapshot);
}
}
if plan.effective_exits.len() > exit_budget as usize {
let overflow = plan.effective_exits.split_off(exit_budget as usize);
for addr in &overflow {
if let Some(mut entry) = self.entries.get_mut(addr) {
entry.status = ValidatorRegistryStatus::PendingExit;
entry.exited_at_epoch = None;
entry.updated_at = Timestamp::now();
let snapshot = entry.clone();
drop(entry);
self.persist_entry(&snapshot);
}
}
}
info!(
"Epoch {} transition: +{} activations ({} effective), -{} exits ({} effective)",
new_epoch,
plan.activations.len(),
plan.effective_activations.len(),
plan.exits.len(),
plan.effective_exits.len()
);
plan
}
}
impl Default for ValidatorRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tenzro_crypto::pq::ML_DSA_65_VK_LEN;
fn make_address(seed: u8) -> Address {
let mut bytes = [0u8; 32];
bytes[0] = seed;
Address::new(bytes)
}
fn make_keys() -> (Vec<u8>, Vec<u8>, Vec<u8>) {
(vec![0u8; 32], vec![0u8; ML_DSA_65_VK_LEN], vec![0u8; 48])
}
#[test]
fn seed_genesis_active_idempotent() {
let reg = ValidatorRegistry::new();
let (ck, pk, bk) = make_keys();
let a = make_address(7);
let inserted = reg
.seed_genesis_active(
a,
ck.clone(),
pk.clone(),
bk.clone(),
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
String::new(),
)
.unwrap();
assert!(inserted);
let entry = reg.get(&a).unwrap();
assert_eq!(entry.status, ValidatorRegistryStatus::Active);
assert_eq!(entry.activated_at_epoch, Some(0));
assert_eq!(entry.registered_at_epoch, 0);
assert_eq!(entry.self_stake, DEFAULT_MIN_VALIDATOR_SELF_STAKE);
let inserted_again = reg
.seed_genesis_active(
a,
ck.clone(),
pk.clone(),
bk.clone(),
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE * 2,
String::from("changed"),
)
.unwrap();
assert!(!inserted_again);
let entry2 = reg.get(&a).unwrap();
assert_eq!(entry2.self_stake, DEFAULT_MIN_VALIDATOR_SELF_STAKE);
assert_eq!(entry2.metadata_uri, "");
assert_eq!(reg.list_active().len(), 1);
}
#[test]
fn seed_genesis_active_rejects_bad_keys() {
let reg = ValidatorRegistry::new();
let a = make_address(8);
let err = reg
.seed_genesis_active(
a,
vec![0u8; 31],
vec![0u8; ML_DSA_65_VK_LEN],
vec![0u8; 48],
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
String::new(),
)
.unwrap_err();
assert!(matches!(err, TokenError::InvalidParameter(_)));
let err = reg
.seed_genesis_active(
a,
vec![0u8; 32],
vec![0u8; 100],
vec![0u8; 48],
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
String::new(),
)
.unwrap_err();
assert!(matches!(err, TokenError::InvalidParameter(_)));
let err = reg
.seed_genesis_active(
a,
vec![0u8; 32],
vec![0u8; ML_DSA_65_VK_LEN],
vec![0u8; 47],
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
String::new(),
)
.unwrap_err();
assert!(matches!(err, TokenError::InvalidParameter(_)));
}
#[test]
fn register_below_min_stake_admits_as_tier_1() {
let reg = ValidatorRegistry::new();
let (ck, pk, bk) = make_keys();
let a = make_address(1);
reg.register_candidate(a, ck.clone(), pk.clone(), bk.clone(), a, 0, 0, String::new())
.unwrap();
let entry = reg.get(&a).unwrap();
assert_eq!(entry.tier, ValidatorTier::ResourceOnly);
assert_eq!(entry.self_stake, 0);
assert_eq!(entry.status, ValidatorRegistryStatus::Candidate);
}
#[test]
fn register_at_tier_2_threshold_sets_staked() {
let reg = ValidatorRegistry::new();
let (ck, pk, bk) = make_keys();
let a = make_address(2);
reg.register_candidate(
a,
ck,
pk,
bk,
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
0,
String::new(),
)
.unwrap();
assert_eq!(reg.get(&a).unwrap().tier, ValidatorTier::Staked);
}
#[test]
fn register_at_tier_3_threshold_sets_rpc_provider() {
let reg = ValidatorRegistry::new();
let (ck, pk, bk) = make_keys();
let a = make_address(3);
reg.register_candidate(
a,
ck,
pk,
bk,
a,
DEFAULT_MIN_RPC_PROVIDER_STAKE,
0,
String::new(),
)
.unwrap();
assert_eq!(reg.get(&a).unwrap().tier, ValidatorTier::RpcProvider);
}
#[test]
fn register_then_activate_then_exit_full_lifecycle() {
let reg = ValidatorRegistry::new();
let (ck, pk, bk) = make_keys();
let a = make_address(1);
reg.register_candidate(
a,
ck.clone(),
pk.clone(),
bk.clone(),
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
0,
String::new(),
)
.unwrap();
assert_eq!(
reg.get(&a).unwrap().status,
ValidatorRegistryStatus::Candidate
);
let plan = reg.compute_epoch_transition(1);
assert_eq!(plan.activations, vec![a]);
assert_eq!(
reg.get(&a).unwrap().status,
ValidatorRegistryStatus::PendingActive
);
let plan = reg.compute_epoch_transition(2);
assert_eq!(plan.effective_activations, vec![a]);
assert_eq!(reg.get(&a).unwrap().status, ValidatorRegistryStatus::Active);
reg.request_exit(&a).unwrap();
assert_eq!(
reg.get(&a).unwrap().status,
ValidatorRegistryStatus::PendingExit
);
let plan = reg.compute_epoch_transition(3);
assert_eq!(plan.effective_exits, vec![a]);
assert_eq!(reg.get(&a).unwrap().status, ValidatorRegistryStatus::Exited);
assert_eq!(reg.get(&a).unwrap().exited_at_epoch, Some(3));
}
#[test]
fn reentry_blocked_during_cooldown() {
let reg = ValidatorRegistry::new();
let (ck, pk, bk) = make_keys();
let a = make_address(1);
reg.register_candidate(
a,
ck.clone(),
pk.clone(),
bk.clone(),
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
0,
String::new(),
)
.unwrap();
reg.compute_epoch_transition(1);
reg.compute_epoch_transition(2);
reg.request_exit(&a).unwrap();
reg.compute_epoch_transition(3);
assert_eq!(reg.get(&a).unwrap().status, ValidatorRegistryStatus::Exited);
let err = reg
.register_candidate(
a,
ck.clone(),
pk.clone(),
bk.clone(),
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
3,
String::new(),
)
.unwrap_err();
assert!(matches!(err, TokenError::Unauthorized { .. }));
reg.register_candidate(
a,
ck,
pk,
bk,
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
3 + DEFAULT_REENTRY_COOLDOWN_EPOCHS,
String::new(),
)
.unwrap();
assert_eq!(
reg.get(&a).unwrap().status,
ValidatorRegistryStatus::Candidate
);
}
#[test]
fn jail_forces_exit() {
let reg = ValidatorRegistry::new();
let (ck, pk, bk) = make_keys();
let a = make_address(1);
reg.register_candidate(
a,
ck,
pk,
bk,
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
0,
String::new(),
)
.unwrap();
reg.compute_epoch_transition(1);
reg.compute_epoch_transition(2);
assert_eq!(reg.get(&a).unwrap().status, ValidatorRegistryStatus::Active);
reg.jail(&a, 2).unwrap();
assert_eq!(reg.get(&a).unwrap().status, ValidatorRegistryStatus::Jailed);
}
#[test]
fn churn_cap_limits_activations() {
let reg = ValidatorRegistry::new();
for i in 1..=5u8 {
let (ck, pk, bk) = make_keys();
let a = make_address(i);
reg.register_candidate(
a,
ck,
pk,
bk,
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
0,
String::new(),
)
.unwrap();
}
for i in 1..=5u8 {
let a = make_address(i);
if let Some(mut entry) = reg.entries.get_mut(&a) {
entry.status = ValidatorRegistryStatus::Active;
}
}
assert_eq!(reg.list_active().len(), 5);
for i in 6..=8u8 {
let (ck, pk, bk) = make_keys();
let a = make_address(i);
reg.register_candidate(
a,
ck,
pk,
bk,
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
0,
String::new(),
)
.unwrap();
}
let plan = reg.compute_epoch_transition(1);
assert_eq!(plan.activations.len(), 1);
}
#[test]
fn rotate_keys_updates_registry_in_place() {
let reg = ValidatorRegistry::new();
let (ck0, pk0, bk0) = make_keys();
let a = make_address(11);
reg.register_candidate(
a,
ck0.clone(),
pk0.clone(),
bk0.clone(),
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
0,
String::new(),
)
.unwrap();
let new_ck = vec![1u8; 32];
let new_pk = vec![2u8; ML_DSA_65_VK_LEN];
let new_bk = vec![3u8; 48];
reg.rotate_keys(&a, new_ck.clone(), new_pk.clone(), new_bk.clone())
.unwrap();
let entry = reg.get(&a).unwrap();
assert_eq!(entry.consensus_pubkey, new_ck);
assert_eq!(entry.pq_pubkey, new_pk);
assert_eq!(entry.bls_pubkey, new_bk);
assert_eq!(entry.self_stake, DEFAULT_MIN_VALIDATOR_SELF_STAKE);
assert_eq!(entry.tier, ValidatorTier::Staked);
assert_eq!(entry.status, ValidatorRegistryStatus::Candidate);
assert_eq!(entry.withdrawal_address, a);
}
#[test]
fn rotate_keys_rejects_wrong_lengths() {
let reg = ValidatorRegistry::new();
let (ck, pk, bk) = make_keys();
let a = make_address(12);
reg.register_candidate(a, ck, pk, bk, a, 0, 0, String::new()).unwrap();
let bad = reg.rotate_keys(&a, vec![1u8; 31], vec![2u8; ML_DSA_65_VK_LEN], vec![3u8; 48]);
assert!(bad.is_err(), "31-byte consensus_pubkey must be rejected");
let bad = reg.rotate_keys(&a, vec![1u8; 32], vec![2u8; 1951], vec![3u8; 48]);
assert!(bad.is_err(), "wrong-length pq_pubkey must be rejected");
let bad = reg.rotate_keys(&a, vec![1u8; 32], vec![2u8; ML_DSA_65_VK_LEN], vec![3u8; 47]);
assert!(bad.is_err(), "wrong-length bls_pubkey must be rejected");
}
#[test]
fn rotate_keys_refuses_exited_validator() {
let reg = ValidatorRegistry::new();
let (ck, pk, bk) = make_keys();
let a = make_address(13);
reg.register_candidate(
a,
ck.clone(),
pk.clone(),
bk.clone(),
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
0,
String::new(),
)
.unwrap();
reg.entries
.alter(&a, |_, mut e| {
e.status = ValidatorRegistryStatus::Exited;
e
});
let new_ck = vec![1u8; 32];
let err = reg
.rotate_keys(&a, new_ck, vec![2u8; ML_DSA_65_VK_LEN], vec![3u8; 48])
.unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("Exited"),
"expected Exited-state refusal, got: {}",
msg
);
}
#[test]
fn rotate_keys_idempotent_on_noop() {
let reg = ValidatorRegistry::new();
let (ck, pk, bk) = make_keys();
let a = make_address(14);
reg.register_candidate(
a,
ck.clone(),
pk.clone(),
bk.clone(),
a,
DEFAULT_MIN_VALIDATOR_SELF_STAKE,
0,
String::new(),
)
.unwrap();
reg.rotate_keys(&a, ck.clone(), pk.clone(), bk.clone())
.unwrap();
let entry = reg.get(&a).unwrap();
assert_eq!(entry.consensus_pubkey, ck);
}
}