// spec, разделы "Вход и регистрация" + "NodeRegistration" + "Selection event" // + "Adaptive VDF" + "apply_proposal steps 1/3a/3b". // // ============ Validate-then-apply ordering invariant ============ // // Все public `apply_*` функции в этом крейте принимают raw types и // предполагают что caller выполнил соответствующий `validate_*` ДО вызова. // Это design choice (per Code/CLAUDE.md "Validate-then-apply pattern"): // caller orchestrates validation gate explicitly, нет typestate enforcement // через wrapper types. Скрытие в одной функции скрыло бы invariant // ordering от каллера и усложнило бы fast-sync (validate-only) flows. // // Обязательный orchestration порядок per spec "apply_proposal steps": // // Step 1 (apply_noderegistrations_batch): // for each NR: // validate_noderegistration(nr, nodes, candidates, accounts)? // ДО batch apply // apply_noderegistrations_batch(pool, ...) // applies validated // // Step 3a (apply_candidate_expiry): // no validate phase — pure pruning by expires ≤ current_window // // Step 3b (apply_selection_event): // no validate phase — селекция из existing CandidatePool по // canonical sort_key; pool entries уже validated при Шаге 1 // // Caller (mt-account::apply_proposal либо external orchestrator) ОБЯЗАН: // 1. Provide cemented_noderegs которые passed validate_noderegistration // 2. Maintain CandidatePool в valid state (entries только через apply_*) // 3. Не bypass apply_* через прямую модификацию state tables // // Нарушение validate-then-apply ordering = protocol invariant breach, // caller errors visible через apply_proposal Step 4 state_root mismatch // (другие узлы recompute через canonical pipeline и detect divergence). use mt_codec::{domain, write_bytes, write_u16, write_u64, write_u8, CanonicalEncode}; use mt_crypto::{hash, suite_id_from_u16, verify, Hash32, PublicKey, Signature, SuiteId}; use mt_genesis::ProtocolParams; use mt_state::{ AccountId, AccountTable, CandidatePool, CandidateRecord, NodeId, NodeRecord, NodeTable, }; // ============ Phase A: NodeRegistration ============ // spec, "NodeRegistration" под ML-DSA-65: // type 1B <- 0x11 NodeRegistration // suite_id 2B <- u16 LE // node_pubkey 1952B // operator_account_id 32B // proof_endpoint 32B // W_start 8B <- u64 LE // vdf_chain_length 8B <- u64 LE // signature 3309B <- ML-DSA-65 (was Falcon-512 666) // Итого: 1 + 2 + 1952 + 32 + 32 + 8 + 8 + 3309 = 5344 pub const NODE_REGISTRATION_SIZE: usize = 5344; pub const TYPE_NODE_REGISTRATION: u8 = 0x11; #[derive(Clone, Debug, Eq, PartialEq)] pub struct NodeRegistration { pub suite_id: u16, pub node_pubkey: [u8; mt_crypto::PUBLIC_KEY_SIZE], pub operator_account_id: AccountId, pub proof_endpoint: Hash32, pub w_start: u64, pub vdf_chain_length: u64, pub signature: Signature, } impl NodeRegistration { // spec R1: signed_scope = canonical_bytes без signature (last SIGNATURE_SIZE bytes) pub fn encode_signed_scope(&self, buf: &mut Vec) { write_u8(buf, TYPE_NODE_REGISTRATION); write_u16(buf, self.suite_id); write_bytes(buf, &self.node_pubkey); write_bytes(buf, &self.operator_account_id); write_bytes(buf, &self.proof_endpoint); write_u64(buf, self.w_start); write_u64(buf, self.vdf_chain_length); } } impl CanonicalEncode for NodeRegistration { fn encode(&self, buf: &mut Vec) { self.encode_signed_scope(buf); write_bytes(buf, self.signature.as_bytes()); } } // spec R2: nodereg_hash = SHA-256("mt-nodereg" || signed_scope(nr)) pub fn nodereg_hash(nr: &NodeRegistration) -> Hash32 { let mut scope = Vec::new(); nr.encode_signed_scope(&mut scope); hash(domain::NODEREG, &[&scope]) } // spec: node_id = SHA-256("mt-node" || node_pubkey) — derive_node_id в mt-state pub fn compute_node_id(node_pubkey: &[u8; mt_crypto::PUBLIC_KEY_SIZE]) -> NodeId { mt_state::derive_node_id(node_pubkey) } #[derive(Clone, Debug, Eq, PartialEq)] pub enum NodeRegError { UnsupportedSuite, InvalidSignature, NodeIdAlreadyInNodeTable, NodeIdAlreadyInCandidatePool, OperatorAccountNotFound, OperatorAccountAlreadyNode, WStartOutOfRange, VdfChainTooShort, } // spec "Валидация NodeRegistration" строки 1783-1790. // Проверки 1, 2, 3 (структурные) здесь. Проверки 4, 5, 6 зависят от // canonical state на момент W_p — выполняются в apply_proposal step 1. // Отделяем для модульности: структурные в validate_noderegistration, // contextual в apply_noderegistration_batch (Phase E). pub fn validate_noderegistration( nr: &NodeRegistration, node_table: &NodeTable, candidate_pool: &CandidatePool, account_table: &AccountTable, ) -> Result<(), NodeRegError> { // (1) Suite supported match suite_id_from_u16(nr.suite_id) { Some(SuiteId::Mldsa65) => {}, None => return Err(NodeRegError::UnsupportedSuite), } // (1) Signature valid для payload.node_pubkey (SSI R1 signer_suite_id table) let mut scope = Vec::new(); nr.encode_signed_scope(&mut scope); let pk = PublicKey::from_array(nr.node_pubkey); if !verify(&pk, &scope, &nr.signature) { return Err(NodeRegError::InvalidSignature); } // (2) node_id уникален в Node Table и Candidate Pool let node_id = compute_node_id(&nr.node_pubkey); if node_table.contains(&node_id) { return Err(NodeRegError::NodeIdAlreadyInNodeTable); } if candidate_pool.contains(&node_id) { return Err(NodeRegError::NodeIdAlreadyInCandidatePool); } // (3) operator_account_id существует и is_node_operator = 0 let operator = account_table .get(&nr.operator_account_id) .ok_or(NodeRegError::OperatorAccountNotFound)?; if operator.is_node_operator { return Err(NodeRegError::OperatorAccountAlreadyNode); } Ok(()) } // ============ Phase B: candidate_vdf_init + Candidate Pool apply ============ // spec, "Шаг 2: Кандидатура" + "[I-8] compliance" строка 1794: // candidate_vdf_init = SHA-256( // "mt-candidate-vdf-init" || // timechain_value(W_start) || // cemented_bundle_aggregate(W_start - 2) || // node_id // ) pub fn candidate_vdf_init( timechain_value_w_start: &Hash32, cba_w_start_minus_2: &Hash32, node_id: &NodeId, ) -> Hash32 { hash( domain::CANDIDATE_VDF_INIT, &[timechain_value_w_start, cba_w_start_minus_2, node_id], ) } // spec: кандидатура истекает через `params.candidate_expiry_windows` от registration. // [C-1] SSOT: ранее жил как hardcoded `EXPIRY_TAU2_COUNT = 3` + multiplication // `EXPIRY_TAU2_COUNT × tau2_windows` локально; теперь читается напрямую из // params.candidate_expiry_windows (60_480 = 3τ₂ at genesis). M4-LOW-7 closure. pub fn compute_expiry_window(registration_window: u64, params: &ProtocolParams) -> u64 { registration_window + params.candidate_expiry_windows } // spec "apply_proposal шаг 3a": удалить кандидатов с expires ≤ current_window. // Возвращает Vec удалённых (для архивации / метрик). pub fn apply_candidate_expiry(pool: &mut CandidatePool, current_window: u64) -> Vec { let to_remove: Vec = pool .iter() .filter(|c| c.expires <= current_window) .map(|c| c.node_id) .collect(); for id in &to_remove { pool.remove(id); } to_remove } // ============ Phase C: Selection event ============ // spec, "Selection event": // slots = max(1, floor(active_nodes / params.admission_divisor)) — 1% upper bound per event // [C-1] SSOT: ранее жил как hardcoded `ADMISSION_DIVISOR = 130`; теперь // читается из params.admission_divisor (130 at genesis). M4-LOW-7 closure. pub fn selection_slots(active_nodes: u64, params: &ProtocolParams) -> u64 { (active_nodes / params.admission_divisor).max(1) } // spec "Selection event sort_key": // sort_key(c) = SHA-256("mt-selection" || timechain_value(W) || // cemented_bundle_aggregate(W-2) || c.node_id) pub fn selection_sort_key( timechain_value_w: &Hash32, cba_w_minus_2: &Hash32, node_id: &NodeId, ) -> Hash32 { hash( domain::SELECTION, &[timechain_value_w, cba_w_minus_2, node_id], ) } // spec "apply_proposal шаг 3b": // 1. Compute sort_key для каждого candidate // 2. Sort ascending by sort_key // 3. Take first `slots` кандидатов → add to Node Table, mark operator is_node_operator=1 // 4. Remove selected from CandidatePool // Trigger: каждые params.selection_interval окон (336 at genesis). // [C-1] SSOT: ранее жил как hardcoded `SELECTION_INTERVAL = 336`; теперь // читается из params.selection_interval. M4-LOW-7 closure. pub fn is_selection_window(window: u64, params: &ProtocolParams) -> bool { window != 0 && window % params.selection_interval == 0 } // Возвращает отсортированный список (sort_key, candidate) — caller применяет. pub fn rank_candidates_for_selection( pool: &CandidatePool, timechain_value_w: &Hash32, cba_w_minus_2: &Hash32, ) -> Vec<(Hash32, CandidateRecord)> { let mut scored: Vec<(Hash32, CandidateRecord)> = pool .iter() .map(|c| { let key = selection_sort_key(timechain_value_w, cba_w_minus_2, &c.node_id); (key, c.clone()) }) .collect(); // Canonical sort: sort_key ascending (32B lex) scored.sort_by_key(|s| s.0); scored } // spec "apply_proposal шаг 3b" + "Шаг 4: Регистрация" (строки 1798-1806): // На selection event: // 1. Rank candidates by sort_key asc // 2. Take первые `slots` (selection_slots(active_nodes)) // 3. Для каждого выбранного: // - Добавить в Node Table с start_window = W, chain_length = 1 // - Пометить operator_account_id как is_node_operator = 1 // - Удалить из Candidate Pool // Возвращает список активированных node_ids. #[allow(clippy::too_many_arguments)] pub fn apply_selection_event( pool: &mut CandidatePool, node_table: &mut NodeTable, account_table: &mut AccountTable, timechain_value_w: &Hash32, cba_w_minus_2: &Hash32, active_nodes: u64, w: u64, params: &ProtocolParams, ) -> Vec { let slots = selection_slots(active_nodes, params) as usize; let ranked = rank_candidates_for_selection(pool, timechain_value_w, cba_w_minus_2); let selected: Vec = ranked.into_iter().take(slots).map(|(_, c)| c).collect(); let mut activated = Vec::new(); for cand in selected { // Step 4: добавить в Node Table с chain_length = 1 (spec строка 1802) let node_record = NodeRecord { node_id: cand.node_id, node_pubkey: cand.node_pubkey, suite_id: cand.suite_id, operator_account_id: cand.operator_account_id, start_window: w, chain_length: 1, chain_length_snapshot: 0, chain_length_checkpoints: [0; 6], last_confirmation_window: 0, }; node_table.insert(node_record); // Пометить operator is_node_operator = 1 (spec строка 1806) if let Some(acc) = account_table.get(&cand.operator_account_id) { let mut updated = acc.clone(); updated.is_node_operator = true; account_table.insert(updated); } // Remove from Candidate Pool pool.remove(&cand.node_id); activated.push(cand.node_id); } activated } // ============ Phase D: Adaptive VDF length ============ // spec, "Adaptive VDF" строки 1816-1831: // candidate_pressure(W) = pending_candidates(W) / active_nodes(W) // // if candidate_pressure(W) > 0.01: // required_vdf_length(W) = τ₂_windows × candidate_pressure(W) × 100 // else: // required_vdf_length(W) = τ₂_windows (base) // // Integer form per [I-9]: // pressure_permille = (pending * 1000) / active [u64, 0..=1000+] // Если pressure_permille > 10 (= 1%): // required = τ₂_windows × pressure_permille × 100 / 1000 // = τ₂_windows × pressure_permille / 10 // Иначе: // required = τ₂_windows pub fn required_vdf_length(pending_candidates: u64, active_nodes: u64, tau2_windows: u64) -> u64 { if active_nodes == 0 { // Genesis / degenerate — нет активных узлов return tau2_windows; } // pressure_permille = (pending * 1000) / active // Overflow: pending ≤ 10^6, × 1000 ≤ 10^9, safe u64 let pressure_permille = (pending_candidates.saturating_mul(1000)) / active_nodes; if pressure_permille > 10 { // required = τ₂ × pressure_permille / 10 // Overflow: τ₂ ≤ 78000, × pressure_permille ≤ 78000 × 10^6 ≈ 10^11, safe u64 tau2_windows.saturating_mul(pressure_permille) / 10 } else { tau2_windows } } // ============ Phase E: Incremental apply in batch (apply_proposal step 1) ============ // spec "Incremental apply в батче" строки 1835-1854 + "nr_sort_key" 1837-1843: // nr_sort_key(nr) = SHA-256( // "mt-nodereg-sort" || // timechain_value(W_p) || // cemented_bundle_aggregate(W_p - 2) || // nr.node_pubkey // ) pub fn nr_sort_key( timechain_value_w_p: &Hash32, cba_w_p_minus_2: &Hash32, node_pubkey: &[u8; mt_crypto::PUBLIC_KEY_SIZE], ) -> Hash32 { hash( domain::NODEREG_SORT, &[timechain_value_w_p, cba_w_p_minus_2, node_pubkey], ) } // spec "apply_proposal шаг 1" + "Incremental apply": // 1. Sort cemented_noderegs by nr_sort_key asc // 2. Для каждой NR в порядке: // current_pending = pending_baseline + N_already_applied // current_pressure = current_pending / active_nodes // required = adaptive_formula(current_pressure) // if NR.vdf_chain_length >= required: apply; N += 1 // else: reject // 3. Apply = insert в CandidatePool с registration_window = W_p, // expires = W_p + 3τ₂. #[derive(Clone, Debug, Eq, PartialEq)] pub struct BatchOutcome { pub applied: Vec, pub rejected: Vec, } #[allow(clippy::too_many_arguments)] pub fn apply_noderegistrations_batch( pool: &mut CandidatePool, cemented_noderegs: &[NodeRegistration], timechain_value_w_p: &Hash32, cba_w_p_minus_2: &Hash32, pending_baseline: u64, active_nodes: u64, w_p: u64, params: &ProtocolParams, ) -> BatchOutcome { // Canonical sort by nr_sort_key let mut sorted: Vec<&NodeRegistration> = cemented_noderegs.iter().collect(); sorted.sort_by_key(|nr| nr_sort_key(timechain_value_w_p, cba_w_p_minus_2, &nr.node_pubkey)); let mut applied_count: u64 = 0; let mut applied = Vec::new(); let mut rejected = Vec::new(); for nr in sorted { let current_pending = pending_baseline + applied_count; let required = required_vdf_length(current_pending, active_nodes, params.tau2_windows); let node_id = compute_node_id(&nr.node_pubkey); if nr.vdf_chain_length >= required { let rec = CandidateRecord { node_id, node_pubkey: nr.node_pubkey, suite_id: nr.suite_id, operator_account_id: nr.operator_account_id, proof_endpoint: nr.proof_endpoint, w_start: nr.w_start, vdf_chain_length: nr.vdf_chain_length, registration_window: w_p, expires: compute_expiry_window(w_p, params), }; pool.insert(rec); applied.push(node_id); applied_count += 1; } else { rejected.push(node_id); } } BatchOutcome { applied, rejected } } #[cfg(test)] mod tests { use super::*; use mt_crypto::{keypair, sign, PublicKey, SECRET_KEY_SIZE, SIGNATURE_SIZE}; use mt_state::{AccountRecord, CandidateRecord}; const TAU2: u64 = 20_160; // per spec fn make_account(id_byte: u8, is_op: bool) -> AccountRecord { AccountRecord { account_id: [id_byte; 32], balance: 1000, suite_id: SuiteId::Mldsa65 as u16, is_node_operator: is_op, frontier_hash: [0; 32], op_height: 0, account_chain_length: 5, account_chain_length_snapshot: 5, current_pubkey: [0; mt_crypto::PUBLIC_KEY_SIZE], creation_window: 1, last_op_window: 2, last_activation_window: 0, } } fn stub_nr(pubkey: [u8; mt_crypto::PUBLIC_KEY_SIZE], vdf_len: u64) -> NodeRegistration { NodeRegistration { suite_id: SuiteId::Mldsa65 as u16, node_pubkey: pubkey, operator_account_id: [0x11; 32], proof_endpoint: [0x33; 32], w_start: 100, vdf_chain_length: vdf_len, signature: Signature::from_array([0u8; SIGNATURE_SIZE]), } } fn sign_nr(nr: &mut NodeRegistration, sk: &mt_crypto::SecretKey) { let mut scope = Vec::new(); nr.encode_signed_scope(&mut scope); nr.signature = sign(sk, &scope).expect("sign NodeRegistration scope"); } // Phase A tests #[test] fn nodereg_size_constant() { assert_eq!(NODE_REGISTRATION_SIZE, 5344); } #[test] fn nodereg_type_byte_0x11() { assert_eq!(TYPE_NODE_REGISTRATION, 0x11); } #[test] fn encode_matches_spec_layout() { let (pk, _sk) = keypair(); let nr = stub_nr(*pk.as_bytes(), 100); let mut buf = Vec::new(); nr.encode(&mut buf); assert_eq!(buf.len(), NODE_REGISTRATION_SIZE); assert_eq!(buf[0], TYPE_NODE_REGISTRATION); assert_eq!(&buf[1..3], &(SuiteId::Mldsa65 as u16).to_le_bytes()); // node_pubkey: [3..3+1952] = 1952 байт ML-DSA-65 pubkey let pk_end = 3 + mt_crypto::PUBLIC_KEY_SIZE; assert_eq!(&buf[3..pk_end], pk.as_bytes()); // operator_account_id 32B assert_eq!(&buf[pk_end..pk_end + 32], &[0x11; 32]); // proof_endpoint 32B assert_eq!(&buf[pk_end + 32..pk_end + 64], &[0x33; 32]); // w_start u64 LE assert_eq!(&buf[pk_end + 64..pk_end + 72], &100u64.to_le_bytes()); // vdf_chain_length u64 LE assert_eq!(&buf[pk_end + 72..pk_end + 80], &100u64.to_le_bytes()); } #[test] fn signed_scope_excludes_signature() { let (pk, _sk) = keypair(); let nr = stub_nr(*pk.as_bytes(), 100); let mut scope = Vec::new(); nr.encode_signed_scope(&mut scope); assert_eq!(scope.len(), NODE_REGISTRATION_SIZE - SIGNATURE_SIZE); } #[test] fn nodereg_hash_domain() { let (pk, _sk) = keypair(); let nr = stub_nr(*pk.as_bytes(), 100); let mut scope = Vec::new(); nr.encode_signed_scope(&mut scope); assert_eq!(nodereg_hash(&nr), hash(b"mt-nodereg", &[&scope])); } #[test] fn validate_accepts_valid() { let (pk, sk) = keypair(); let mut nr = stub_nr(*pk.as_bytes(), 100); let operator = make_account(0x11, false); let mut at = AccountTable::new(); at.insert(operator); sign_nr(&mut nr, &sk); let nt = NodeTable::new(); let pool = CandidatePool::new(); assert_eq!(validate_noderegistration(&nr, &nt, &pool, &at), Ok(())); } #[test] fn validate_rejects_unsupported_suite() { let (pk, _sk) = keypair(); let mut nr = stub_nr(*pk.as_bytes(), 100); nr.suite_id = 0xFFFF; let nt = NodeTable::new(); let pool = CandidatePool::new(); let at = AccountTable::new(); assert_eq!( validate_noderegistration(&nr, &nt, &pool, &at), Err(NodeRegError::UnsupportedSuite) ); } #[test] fn validate_rejects_bad_signature() { let (pk, sk) = keypair(); let mut nr = stub_nr(*pk.as_bytes(), 100); sign_nr(&mut nr, &sk); let mut sig_bytes = *nr.signature.as_bytes(); sig_bytes[0] ^= 0xFF; nr.signature = Signature::from_array(sig_bytes); let operator = make_account(0x11, false); let mut at = AccountTable::new(); at.insert(operator); let nt = NodeTable::new(); let pool = CandidatePool::new(); assert_eq!( validate_noderegistration(&nr, &nt, &pool, &at), Err(NodeRegError::InvalidSignature) ); } #[test] fn validate_rejects_operator_not_found() { let (pk, sk) = keypair(); let mut nr = stub_nr(*pk.as_bytes(), 100); sign_nr(&mut nr, &sk); let nt = NodeTable::new(); let pool = CandidatePool::new(); let at = AccountTable::new(); // no account assert_eq!( validate_noderegistration(&nr, &nt, &pool, &at), Err(NodeRegError::OperatorAccountNotFound) ); } #[test] fn validate_rejects_operator_already_node() { let (pk, sk) = keypair(); let mut nr = stub_nr(*pk.as_bytes(), 100); sign_nr(&mut nr, &sk); let operator = make_account(0x11, true); // already node operator let mut at = AccountTable::new(); at.insert(operator); let nt = NodeTable::new(); let pool = CandidatePool::new(); assert_eq!( validate_noderegistration(&nr, &nt, &pool, &at), Err(NodeRegError::OperatorAccountAlreadyNode) ); } #[test] fn compute_node_id_matches_mt_state() { let pk = [0x42u8; mt_crypto::PUBLIC_KEY_SIZE]; assert_eq!(compute_node_id(&pk), mt_state::derive_node_id(&pk)); } // Phase B tests #[test] fn candidate_vdf_init_formula() { let t_r: Hash32 = [0x10; 32]; let cba: Hash32 = [0x20; 32]; let node_id: NodeId = [0x30; 32]; let got = candidate_vdf_init(&t_r, &cba, &node_id); let expected = hash(b"mt-candidate-vdf-init", &[&t_r, &cba, &node_id]); assert_eq!(got, expected); } #[test] fn candidate_vdf_init_sensitivity() { let base = candidate_vdf_init(&[1; 32], &[2; 32], &[3; 32]); assert_ne!(candidate_vdf_init(&[9; 32], &[2; 32], &[3; 32]), base); assert_ne!(candidate_vdf_init(&[1; 32], &[9; 32], &[3; 32]), base); assert_ne!(candidate_vdf_init(&[1; 32], &[2; 32], &[9; 32]), base); } #[test] fn expiry_window_is_3_tau2_later() { let p = mt_genesis::genesis_params(); // params.candidate_expiry_windows = 3 × tau2 at genesis assert_eq!( compute_expiry_window(100, p), 100 + p.candidate_expiry_windows ); assert_eq!(p.candidate_expiry_windows, 3 * p.tau2_windows); } #[test] fn apply_candidate_expiry_removes_expired() { let mut pool = CandidatePool::new(); for i in 0..5u8 { pool.insert(CandidateRecord { node_id: [i; 32], node_pubkey: [0; mt_crypto::PUBLIC_KEY_SIZE], suite_id: 1, operator_account_id: [0; 32], proof_endpoint: [0; 32], w_start: 0, vdf_chain_length: 0, registration_window: 0, expires: 10 + i as u64, }); } let removed = apply_candidate_expiry(&mut pool, 12); // Expires 10, 11, 12 removed; 13, 14 remain assert_eq!(removed.len(), 3); assert_eq!(pool.len(), 2); } // Phase C tests #[test] fn selection_slots_at_least_one() { let p = mt_genesis::genesis_params(); assert_eq!(selection_slots(0, p), 1); assert_eq!(selection_slots(100, p), 1); assert_eq!(selection_slots(129, p), 1); } #[test] fn selection_slots_one_percent_cap() { let p = mt_genesis::genesis_params(); assert_eq!(selection_slots(130, p), 1); assert_eq!(selection_slots(260, p), 2); assert_eq!(selection_slots(1300, p), 10); assert_eq!(selection_slots(13000, p), 100); } #[test] fn selection_sort_key_formula() { let t_r: Hash32 = [0x10; 32]; let cba: Hash32 = [0x20; 32]; let node_id: NodeId = [0x30; 32]; let got = selection_sort_key(&t_r, &cba, &node_id); assert_eq!(got, hash(b"mt-selection", &[&t_r, &cba, &node_id])); } #[test] fn is_selection_window_at_intervals() { let p = mt_genesis::genesis_params(); assert!(!is_selection_window(0, p)); // Genesis exclusion assert!(!is_selection_window(1, p)); assert!(!is_selection_window(335, p)); assert!(is_selection_window(336, p)); assert!(!is_selection_window(337, p)); assert!(is_selection_window(672, p)); } #[test] fn rank_candidates_sort_deterministic() { let mut pool = CandidatePool::new(); for i in 0..3u8 { pool.insert(CandidateRecord { node_id: [i; 32], node_pubkey: [0; mt_crypto::PUBLIC_KEY_SIZE], suite_id: 1, operator_account_id: [0; 32], proof_endpoint: [0; 32], w_start: 0, vdf_chain_length: 0, registration_window: 0, expires: 1000, }); } let ranked = rank_candidates_for_selection(&pool, &[0x11; 32], &[0x22; 32]); assert_eq!(ranked.len(), 3); // Sort key is deterministic — check that two calls give same order let ranked2 = rank_candidates_for_selection(&pool, &[0x11; 32], &[0x22; 32]); for i in 0..3 { assert_eq!(ranked[i].0, ranked2[i].0); assert_eq!(ranked[i].1.node_id, ranked2[i].1.node_id); } } // Phase D tests #[test] fn required_vdf_base_low_pressure() { // pressure = 5/1000 = 0.5% < 1% threshold → base τ₂ assert_eq!(required_vdf_length(5, 1000, TAU2), TAU2); } #[test] fn required_vdf_exactly_1_percent_is_base() { // pressure_permille = 10 (1%) → не > 10, base assert_eq!(required_vdf_length(10, 1000, TAU2), TAU2); } #[test] fn required_vdf_moderate_pressure() { // pressure = 20/1000 = 2% = 20 permille → required = τ₂ × 20 / 10 = 2τ₂ assert_eq!(required_vdf_length(20, 1000, TAU2), 2 * TAU2); } #[test] fn required_vdf_high_pressure() { // pressure = 100/1000 = 10% = 100 permille → 10 × τ₂ assert_eq!(required_vdf_length(100, 1000, TAU2), 10 * TAU2); } #[test] fn required_vdf_attack_pressure() { // pressure = 1000/1000 = 100% = 1000 permille → 100 × τ₂ assert_eq!(required_vdf_length(1000, 1000, TAU2), 100 * TAU2); } #[test] fn required_vdf_active_zero_returns_base() { // Защита от division by zero assert_eq!(required_vdf_length(10, 0, TAU2), TAU2); } // Phase E tests #[test] fn nr_sort_key_formula() { let t_r: Hash32 = [0x10; 32]; let cba: Hash32 = [0x20; 32]; let pk = [0x30u8; mt_crypto::PUBLIC_KEY_SIZE]; let got = nr_sort_key(&t_r, &cba, &pk); assert_eq!(got, hash(b"mt-nodereg-sort", &[&t_r, &cba, &pk])); } #[test] fn batch_single_nr_applies() { let (pk, sk) = keypair(); let mut nr = stub_nr(*pk.as_bytes(), TAU2); sign_nr(&mut nr, &sk); let mut pool = CandidatePool::new(); let outcome = apply_noderegistrations_batch( &mut pool, &[nr], &[0; 32], &[0; 32], 0, 1000, 100, mt_genesis::genesis_params(), ); assert_eq!(outcome.applied.len(), 1); assert_eq!(outcome.rejected.len(), 0); assert_eq!(pool.len(), 1); } #[test] fn batch_insufficient_vdf_rejected() { let (pk, sk) = keypair(); let mut nr = stub_nr(*pk.as_bytes(), TAU2 - 1); // shortfall sign_nr(&mut nr, &sk); let mut pool = CandidatePool::new(); let outcome = apply_noderegistrations_batch( &mut pool, &[nr], &[0; 32], &[0; 32], 0, 1000, 100, mt_genesis::genesis_params(), ); assert_eq!(outcome.applied.len(), 0); assert_eq!(outcome.rejected.len(), 1); assert_eq!(pool.len(), 0); } #[test] fn batch_incremental_pending_increases() { // Publish 3 NR, each requires base initially — но после первой pending += 1 let mut nrs = Vec::new(); for _ in 0..3 { let (pk, sk) = keypair(); let mut nr = stub_nr(*pk.as_bytes(), TAU2); // Change operator_account to unique id per NR nr.operator_account_id = [nrs.len() as u8 + 1; 32]; sign_nr(&mut nr, &sk); nrs.push(nr); } // Starting pending = 0, active = 100 → pressure starts low, after each applied grows // pending = 0, 1, 2 → permille 0, 10, 20 // NR1: pressure=0 → base; NR2: pressure=10 → base; NR3: pressure=20 → 2×base // Third NR has TAU2 = base, not 2×base → rejected let mut pool = CandidatePool::new(); let outcome = apply_noderegistrations_batch( &mut pool, &nrs, &[0; 32], &[0; 32], 0, 100, 100, mt_genesis::genesis_params(), ); // First 2 applied, third rejected assert_eq!(outcome.applied.len(), 2); assert_eq!(outcome.rejected.len(), 1); } #[test] fn batch_sort_by_nr_sort_key() { // Two NRs — проверяем что sort применяется (deterministic order) let (pk1, sk1) = keypair(); let (pk2, sk2) = keypair(); let mut nr1 = stub_nr(*pk1.as_bytes(), 10 * TAU2); nr1.operator_account_id = [0x01; 32]; let mut nr2 = stub_nr(*pk2.as_bytes(), 10 * TAU2); nr2.operator_account_id = [0x02; 32]; sign_nr(&mut nr1, &sk1); sign_nr(&mut nr2, &sk2); let p = mt_genesis::genesis_params(); let mut pool1 = CandidatePool::new(); let o1 = apply_noderegistrations_batch( &mut pool1, &[nr1.clone(), nr2.clone()], &[0; 32], &[0; 32], 0, 1000, 100, p, ); let mut pool2 = CandidatePool::new(); let o2 = apply_noderegistrations_batch( &mut pool2, &[nr2, nr1], &[0; 32], &[0; 32], 0, 1000, 100, p, ); // Order applied должен быть same (sort deterministic) assert_eq!(o1.applied, o2.applied); } #[test] fn secret_key_size_sanity() { // ML-DSA-65 expanded secret key assert_eq!(SECRET_KEY_SIZE, 4032); } #[test] fn batch_registration_window_and_expiry_set() { let (pk, sk) = keypair(); let mut nr = stub_nr(*pk.as_bytes(), TAU2); sign_nr(&mut nr, &sk); let mut pool = CandidatePool::new(); apply_noderegistrations_batch( &mut pool, &[nr], &[0; 32], &[0; 32], 0, 1000, 100, mt_genesis::genesis_params(), ); let (_, rec) = pool.iter().next().map(|c| (c.node_id, c.clone())).unwrap(); assert_eq!(rec.registration_window, 100); assert_eq!(rec.expires, 100 + 3 * TAU2); } #[test] fn public_key_used_in_sort_key_not_node_id() { // nr_sort_key использует node_pubkey напрямую (не node_id) let pk = [0x42u8; mt_crypto::PUBLIC_KEY_SIZE]; let t_r: Hash32 = [0; 32]; let cba: Hash32 = [0; 32]; let key1 = nr_sort_key(&t_r, &cba, &pk); // Разные pubkey → разные keys let pk2 = [0x43u8; mt_crypto::PUBLIC_KEY_SIZE]; let key2 = nr_sort_key(&t_r, &cba, &pk2); assert_ne!(key1, key2); } #[test] fn pk_import_sanity() { // PublicKey import used let _ = PublicKey::from_array([0; mt_crypto::PUBLIC_KEY_SIZE]); } #[test] fn apply_selection_event_activates_top_k_candidates() { let mut pool = CandidatePool::new(); let mut nt = NodeTable::new(); let mut at = AccountTable::new(); // Create 3 candidates + 3 operator accounts for i in 1u8..=3 { at.insert(make_account(i, false)); pool.insert(CandidateRecord { node_id: [i; 32], node_pubkey: [i; mt_crypto::PUBLIC_KEY_SIZE], suite_id: 1, operator_account_id: [i; 32], proof_endpoint: [0; 32], w_start: 0, vdf_chain_length: 0, registration_window: 0, expires: 10_000, }); } // active_nodes=130 → slots = 1 (min) let activated = apply_selection_event( &mut pool, &mut nt, &mut at, &[0x11; 32], &[0x22; 32], 130, 336, mt_genesis::genesis_params(), ); assert_eq!(activated.len(), 1); assert_eq!(nt.len(), 1); assert_eq!(pool.len(), 2); // 2 остались в pool // Активированный operator помечен let op = at.get(&activated[0]).unwrap(); assert!(op.is_node_operator); } #[test] fn apply_selection_event_multiple_slots() { let mut pool = CandidatePool::new(); let mut nt = NodeTable::new(); let mut at = AccountTable::new(); for i in 1u8..=5 { at.insert(make_account(i, false)); pool.insert(CandidateRecord { node_id: [i; 32], node_pubkey: [i; mt_crypto::PUBLIC_KEY_SIZE], suite_id: 1, operator_account_id: [i; 32], proof_endpoint: [0; 32], w_start: 0, vdf_chain_length: 0, registration_window: 0, expires: 10_000, }); } // active_nodes=260 → slots = 2 let activated = apply_selection_event( &mut pool, &mut nt, &mut at, &[0x11; 32], &[0x22; 32], 260, 336, mt_genesis::genesis_params(), ); assert_eq!(activated.len(), 2); assert_eq!(nt.len(), 2); assert_eq!(pool.len(), 3); } #[test] fn apply_selection_event_empty_pool() { let mut pool = CandidatePool::new(); let mut nt = NodeTable::new(); let mut at = AccountTable::new(); let activated = apply_selection_event( &mut pool, &mut nt, &mut at, &[0; 32], &[0; 32], 130, 336, mt_genesis::genesis_params(), ); assert!(activated.is_empty()); assert!(nt.is_empty()); } #[test] fn apply_selection_event_new_node_chain_length_1() { // spec строка 1802: chain_length = 1 при активации let mut pool = CandidatePool::new(); let mut nt = NodeTable::new(); let mut at = AccountTable::new(); at.insert(make_account(1, false)); pool.insert(CandidateRecord { node_id: [1; 32], node_pubkey: [1; mt_crypto::PUBLIC_KEY_SIZE], suite_id: 1, operator_account_id: [1; 32], proof_endpoint: [0; 32], w_start: 0, vdf_chain_length: 0, registration_window: 0, expires: 10_000, }); let activated = apply_selection_event( &mut pool, &mut nt, &mut at, &[0x11; 32], &[0x22; 32], 130, 500, mt_genesis::genesis_params(), ); let new_node = nt.get(&activated[0]).unwrap(); assert_eq!(new_node.chain_length, 1); assert_eq!(new_node.start_window, 500); } }