// spec, разделы "Proposal header" + "Signed scope, identity и aggregation" (R1, R2) // + "Canonical acceptance" + "Fallback cascade" + "Lookback Leadership". use mt_codec::{domain, write_bytes, write_u128, write_u32, write_u64, write_u8, CanonicalEncode}; use mt_crypto::{hash, suite_id_from_u16, verify, Hash32, PublicKey, Signature, SuiteId}; use mt_lottery::{Candidate, WINNER_CLASS_NODE}; use mt_state::{NodeId, NodeTable}; // Header layout per spec v31.0.0 (winner_class byte удалён; лотерея single-class, // winner всегда узел; signature ML-DSA-65): // prev_proposal_hash 32 // window_index 8 u64 LE // protocol_version 4 u32 LE // control_root 32 // node_root 32 // candidate_root 32 // account_root 32 // state_root 32 // timechain_value 32 // included_bundles_root 32 // included_reveals_root 32 // winner_endpoint 32 // winner_id 32 // proposer_node_id 32 // target 16 u128 LE Q64.64 (per [I-9] P5) // fallback_depth 1 u8 ∈ [1, 255] // signature 3309 ML-DSA-65 (was Falcon-512 666) // ------------------------ // total 3722 pub const PROPOSAL_HEADER_SIZE: usize = 3722; #[derive(Clone, Debug, Eq, PartialEq)] pub struct ProposalHeader { pub prev_proposal_hash: Hash32, pub window_index: u64, pub protocol_version: u32, pub control_root: Hash32, pub node_root: Hash32, pub candidate_root: Hash32, pub account_root: Hash32, pub state_root: Hash32, pub timechain_value: Hash32, pub included_bundles_root: Hash32, pub included_reveals_root: Hash32, pub winner_endpoint: Hash32, pub winner_id: Hash32, pub proposer_node_id: NodeId, pub target: u128, pub fallback_depth: u8, pub signature: Signature, } impl ProposalHeader { // spec R1: signed_scope = canonical_bytes без signature (last SIGNATURE_SIZE bytes) pub fn encode_signed_scope(&self, buf: &mut Vec) { write_bytes(buf, &self.prev_proposal_hash); write_u64(buf, self.window_index); write_u32(buf, self.protocol_version); write_bytes(buf, &self.control_root); write_bytes(buf, &self.node_root); write_bytes(buf, &self.candidate_root); write_bytes(buf, &self.account_root); write_bytes(buf, &self.state_root); write_bytes(buf, &self.timechain_value); write_bytes(buf, &self.included_bundles_root); write_bytes(buf, &self.included_reveals_root); write_bytes(buf, &self.winner_endpoint); write_bytes(buf, &self.winner_id); write_bytes(buf, &self.proposer_node_id); write_u128(buf, self.target); write_u8(buf, self.fallback_depth); } } impl CanonicalEncode for ProposalHeader { fn encode(&self, buf: &mut Vec) { self.encode_signed_scope(buf); write_bytes(buf, self.signature.as_bytes()); } } // spec R2: proposal_hash = SHA-256("mt-proposal" || signed_scope(header)) pub fn proposal_hash(header: &ProposalHeader) -> Hash32 { let mut scope = Vec::new(); header.encode_signed_scope(&mut scope); hash(domain::PROPOSAL, &[&scope]) } #[derive(Clone, Debug, Eq, PartialEq)] pub enum HeaderError { UnknownProposer, UnsupportedSuite, InvalidSignature, WindowNotMonotone, ProtocolVersionDecreased, ProtocolVersionUnsupported, FallbackDepthZero, } // spec "Инварианты Proposal header" + R1 signature verification. // Проверки: // (a) proposer_node_id зарегистрирован в NodeTable, suite Mldsa65 // (b) signature verify с NodeTable[proposer_node_id].node_pubkey над signed_scope // (c) window_index == prev.window_index + 1 (caller даёт prev.window_index) // (d) protocol_version >= prev.protocol_version // (e) protocol_version <= local_max_supported_version (узел обязан отклонить unknown) // (f) fallback_depth ≥ 1 (spec: 1 = первое место, 0 невалидно) pub fn validate_header( header: &ProposalHeader, node_table: &NodeTable, prev_window_index: u64, prev_protocol_version: u32, local_max_supported_version: u32, ) -> Result<(), HeaderError> { // fallback_depth check (структурный) if header.fallback_depth == 0 { return Err(HeaderError::FallbackDepthZero); } // window monotone — checked_add защищает от u64::MAX overflow // (M4-LOW-4 closure; horizon ~3.5×10^12 лет at τ₁=60s, practically // unreachable но defense-in-depth: на overflow trigger возвращаем // WindowNotMonotone вместо silent wrap до 0). let expected = prev_window_index .checked_add(1) .ok_or(HeaderError::WindowNotMonotone)?; if header.window_index != expected { return Err(HeaderError::WindowNotMonotone); } // protocol version monotone if header.protocol_version < prev_protocol_version { return Err(HeaderError::ProtocolVersionDecreased); } if header.protocol_version > local_max_supported_version { return Err(HeaderError::ProtocolVersionUnsupported); } // proposer lookup + suite check + signature let proposer = node_table .get(&header.proposer_node_id) .ok_or(HeaderError::UnknownProposer)?; match suite_id_from_u16(proposer.suite_id) { Some(SuiteId::Mldsa65) => {}, None => return Err(HeaderError::UnsupportedSuite), } let mut scope = Vec::new(); header.encode_signed_scope(&mut scope); let pk = PublicKey::from_array(proposer.node_pubkey); if !verify(&pk, &scope, &header.signature) { return Err(HeaderError::InvalidSignature); } Ok(()) } // ============ Phase B: Lookback Leadership ============ // spec, "Определение winner-а (Lookback Leadership)" строка 977: // proposer_W = winner_{W-2} (канонически из proposal_{W-1}). // Genesis bootstrap (строка 1007): // proposer_0 и proposer_1 = bootstrap-узел. // Когда winner_{W-2} = account (winner_class=2, физически не подписывает proposal): // proposer = ближайший node кандидат по weighted_ticket (строка 1315). // Реализация: первый Candidate с class=Node в sorted candidates of W-2. // // **M4-INFO-10: degraded-mode behavior при empty W-2 cemented set.** // // Если sorted_candidates_w_minus_2 пуст либо не содержит ни одного // `WINNER_CLASS_NODE` — fallback к bootstrap_node_id. Это означает что // при N consecutive окнах с empty W-2 cemented set (degenerate scenario: // все nodes одновременно offline или сеть в degraded mode) bootstrap // узел **в одиночку** генерирует proposals для этих окон. // // Это **defense-in-depth fallback**, не steady-state design: // - В стационарном режиме сеть имеет ≥1 cemented BundledConfirmation // per окно от ~100 confirmer-узлов, sorted_candidates_w_minus_2 // гарантированно содержит ≥1 WINNER_CLASS_NODE entry // - Empty W-2 cemented set возникает только при network partition либо // simultaneous offline всех confirmers — concentration-of-power у // bootstrap acceptable как failsafe для liveness восстановления // - Operator monitoring: отдельный alert когда proposer_id N окон подряд // == bootstrap_node_id post-genesis (current_window ≥ 2) — сигнал // degraded mode либо attempted attack на bootstrap node // // Liveness threshold не специфицирован в spec — это design choice failsafe: // сеть продолжает производить proposals без cemented quorum, recovery // автоматическая когда any confirmer возобновит publishing BundledConfirmation. pub fn canonical_proposer( current_window: u64, bootstrap_node_id: NodeId, sorted_candidates_w_minus_2: &[Candidate], ) -> NodeId { // Genesis bootstrap: первые два окна bootstrap_node if current_window < 2 { return bootstrap_node_id; } // Извлечь первого node-кандидата из sorted list (минимальный weighted_ticket среди nodes) for c in sorted_candidates_w_minus_2 { if c.class == WINNER_CLASS_NODE { return c.id; } } // No node candidates в cemented set W-2 → extended genesis bootstrap // (degraded mode failsafe — см. doc выше M4-INFO-10). bootstrap_node_id } // spec, "Fallback cascade" строка 1329: // fallback_1 = second_min(weighted_ticket) окна W-2, fallback_2 = third_min, etc. // fallback_depth 1 = canonical proposer, 2 = first fallback, и т.д. pub fn fallback_proposer( current_window: u64, bootstrap_node_id: NodeId, sorted_candidates_w_minus_2: &[Candidate], fallback_depth: u8, ) -> NodeId { if current_window < 2 { return bootstrap_node_id; } let mut skip = (fallback_depth as usize).saturating_sub(1); for c in sorted_candidates_w_minus_2 { if c.class == WINNER_CLASS_NODE { if skip == 0 { return c.id; } skip -= 1; } } // Cascade exhausted — bootstrap (extended genesis behavior) bootstrap_node_id } // ============ Phase C: control_set формула ============ // spec, "control_set(proposal окна W)" строки 1192-1202: // control_set = { c : c.cemented_window > previous_proposal.window // AND c.cemented_window <= W } // сортировка: (cemented_window asc, op_hash lex asc) // // ControlObject представлен его op_hash + cemented_window (достаточно для формулы). // Полная структура ControlObject (NodeRegistration 0x11 etc.) применяется через // mt-entry / mt-account. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct ControlObjectRef { pub op_hash: Hash32, pub cemented_window: u64, } // Детерминированный фильтр + сортировка. Возвращает sorted Vec per spec. // Proposer ОБЯЗАН включить весь control_set целиком; валидатор сверяет через равенство. pub fn compute_control_set( all_cemented: &[ControlObjectRef], previous_proposal_window: u64, current_window: u64, ) -> Vec { let mut filtered: Vec = all_cemented .iter() .filter(|c| { c.cemented_window > previous_proposal_window && c.cemented_window <= current_window }) .copied() .collect(); // Canonical sort: (cemented_window asc, op_hash lex asc) filtered.sort_by(|a, b| { a.cemented_window .cmp(&b.cemented_window) .then_with(|| a.op_hash.cmp(&b.op_hash)) }); filtered } #[derive(Clone, Debug, Eq, PartialEq)] pub enum ControlSetError { Mismatch, } // Проверка: proposer's control_set == expected control_set (каноничен). // Используется валидатором в Canonical acceptance step. pub fn validate_control_set( proposer_set: &[ControlObjectRef], all_cemented: &[ControlObjectRef], previous_proposal_window: u64, current_window: u64, ) -> Result<(), ControlSetError> { let expected = compute_control_set(all_cemented, previous_proposal_window, current_window); if proposer_set == expected.as_slice() { Ok(()) } else { Err(ControlSetError::Mismatch) } } // ============ Phase D: Canonical acceptance validation ============ // spec, "Canonical acceptance" (строка 1114): // (a) proposer = winner_{W-2} // (b) included_bundles ≥ 67% active_chain_length // (c) included_reveals = cemented set VDF_Reveals окна W-1 // (d) winner_{W-1} = argmin из (cemented reveals ∪ account_candidates) // (e) state_root корректен (independent recomputation — delegated в mt-account::apply_proposal) #[derive(Clone, Debug, Eq, PartialEq)] pub enum AcceptanceError { ProposerNotCanonical, InsufficientBundles, IncludedRevealsMismatch, WrongWinner, } // (a) proposer canonical check. pub fn validate_proposer_is_canonical( header: &ProposalHeader, bootstrap_node_id: NodeId, sorted_candidates_w_minus_2: &[Candidate], ) -> Result<(), AcceptanceError> { // При fallback_depth > 1 proposer = fallback_N, не canonical. // Валидация: proposer совпадает с fallback_proposer(depth). let expected = fallback_proposer( header.window_index, bootstrap_node_id, sorted_candidates_w_minus_2, header.fallback_depth, ); if header.proposer_node_id != expected { return Err(AcceptanceError::ProposerNotCanonical); } Ok(()) } // (b) included_bundles ≥ 67% active_chain_length. // cemented_sum = Σ chain_length узлов чьи BundledConfirmation попали в included_bundles. pub fn validate_bundles_threshold( cemented_sum: u64, active_chain_length: u64, ) -> Result<(), AcceptanceError> { if mt_lottery::is_cemented(cemented_sum, active_chain_length) { Ok(()) } else { Err(AcceptanceError::InsufficientBundles) } } // (c) included_reveals == cemented set VDF_Reveals W-1 (каноничен). // Compare via sorted Vec equality — caller sorts both before call. pub fn validate_included_reveals( proposer_reveal_hashes: &[Hash32], cemented_reveal_hashes: &[Hash32], ) -> Result<(), AcceptanceError> { // Both are canonical sorted (by lex asc per spec «Canonical ordering» строки 2520-2521). if proposer_reveal_hashes == cemented_reveal_hashes { Ok(()) } else { Err(AcceptanceError::IncludedRevealsMismatch) } } // (d) winner_{W-1} == argmin by canonical rule из (cemented reveals ∪ account candidates). // // **Caller contract (M4-MED-2):** // // Эта функция **строго отвергает** любой winner_id если cemented set окна W-1 // пуст (т.е. нет ни одного VDF_Reveal от node-кандидата). Это правильно для // **post-genesis** окон: в стационарном режиме сеть всегда имеет ≥1 candidate // в W-1; пустой cemented set = либо все nodes одновременно offline (degenerate // scenario, network в degraded mode), либо attacker подаёт fabricated proposal. // // **Для genesis bootstrap** (первые окна где cemented W-1 candidates пустые // потому что сеть ещё не накопила VDF_Reveals) caller ОБЯЗАН skip // `validate_winner` и применять fallback proposer logic из `canonical_proposer` // (которая возвращает bootstrap_node_id при `current_window < 2` либо при // empty W-2 cemented set). Genesis bypass — caller responsibility (mt-account // orchestrator знает window_index и может skip validate_winner для окон где // cemented W-1 set по design пуст). // // Не вводим `validate_winner_genesis_aware` отдельно — это усложнит API // без structural benefit (caller всё равно знает window_index и canonical // fallback path). Документация contract в этом комментарии — authoritative. pub fn validate_winner( header: &ProposalHeader, sorted_candidates_w_minus_1: &[Candidate], ) -> Result<(), AcceptanceError> { let expected = mt_lottery::determine_winner(sorted_candidates_w_minus_1); match expected { Some(w) => { if w.class == WINNER_CLASS_NODE && header.winner_id == w.id { Ok(()) } else { Err(AcceptanceError::WrongWinner) } }, None => { // Empty W-1 cemented set: post-genesis это либо degraded mode // (all nodes offline), либо attacker fabricated proposal — оба // случая reject. Genesis bootstrap — caller skip-ит validate_winner // (см. doc выше). Err(AcceptanceError::WrongWinner) }, } } // ============ Phase E: Finalization flow ============ // spec, "Закрытие окна" + "Finalization" (строки 1045-1049): // Если 67% active_chain_length подписывают proposal_W → cemented. // Winner_{W-1} получает reward(W-1). Winner_{W-1} становится proposer_{W+1}. // Если < 67% → proposal отклонён, fallback cascade. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FinalizationStatus { Cemented, Rejected, } pub fn finalization_status( signatures_chain_length_sum: u64, active_chain_length: u64, ) -> FinalizationStatus { if mt_lottery::is_cemented(signatures_chain_length_sum, active_chain_length) { FinalizationStatus::Cemented } else { FinalizationStatus::Rejected } } // spec строка 1333 "Leader penalty при отклонении": // endpoint proposer-а, чей proposal отклонён, исключается из lottery пула окна W. // Helper: возвращает node_id для exclusion (caller использует в lottery candidate filter). pub fn leader_penalty_excluded_node(header: &ProposalHeader) -> NodeId { header.proposer_node_id } #[cfg(test)] mod tests { use super::*; use mt_crypto::{keypair, sign, SECRET_KEY_SIZE, SIGNATURE_SIZE}; use mt_state::{derive_node_id, NodeRecord}; fn make_node(pubkey: [u8; mt_crypto::PUBLIC_KEY_SIZE]) -> (NodeId, NodeRecord) { let node_id = derive_node_id(&pubkey); let rec = NodeRecord { node_id, node_pubkey: pubkey, suite_id: SuiteId::Mldsa65 as u16, operator_account_id: [0x11; 32], start_window: 1, chain_length: 10, chain_length_snapshot: 10, chain_length_checkpoints: [10; 6], last_confirmation_window: 10, }; (node_id, rec) } fn stub_header(proposer_node_id: NodeId) -> ProposalHeader { ProposalHeader { prev_proposal_hash: [0x01; 32], window_index: 100, protocol_version: 1, control_root: [0x02; 32], node_root: [0x03; 32], candidate_root: [0x04; 32], account_root: [0x05; 32], state_root: [0x06; 32], timechain_value: [0x07; 32], included_bundles_root: [0x08; 32], included_reveals_root: [0x09; 32], winner_endpoint: [0x0A; 32], winner_id: [0x0B; 32], proposer_node_id, target: 0x1234_5678_9ABC_DEF0_1122_3344_5566_7788u128, fallback_depth: 1, signature: Signature::from_array([0u8; SIGNATURE_SIZE]), } } fn sign_header(header: &mut ProposalHeader, sk: &mt_crypto::SecretKey) { let mut scope = Vec::new(); header.encode_signed_scope(&mut scope); header.signature = sign(sk, &scope).expect("sign ProposalHeader scope"); } #[test] fn header_size_constant() { assert_eq!(PROPOSAL_HEADER_SIZE, 3722); } #[test] fn encode_matches_spec_layout() { let h = stub_header([0xAA; 32]); let mut buf = Vec::new(); h.encode(&mut buf); assert_eq!(buf.len(), PROPOSAL_HEADER_SIZE); // prev_proposal_hash 0..32 assert_eq!(&buf[0..32], &[0x01; 32]); // window_index 32..40 u64 LE = 100 assert_eq!(&buf[32..40], &100u64.to_le_bytes()); // protocol_version 40..44 u32 LE = 1 assert_eq!(&buf[40..44], &1u32.to_le_bytes()); // control_root 44..76 assert_eq!(&buf[44..76], &[0x02; 32]); // node_root 76..108 assert_eq!(&buf[76..108], &[0x03; 32]); // candidate_root 108..140 assert_eq!(&buf[108..140], &[0x04; 32]); // account_root 140..172 assert_eq!(&buf[140..172], &[0x05; 32]); // state_root 172..204 assert_eq!(&buf[172..204], &[0x06; 32]); // timechain_value 204..236 assert_eq!(&buf[204..236], &[0x07; 32]); // included_bundles_root 236..268 assert_eq!(&buf[236..268], &[0x08; 32]); // included_reveals_root 268..300 assert_eq!(&buf[268..300], &[0x09; 32]); // winner_endpoint 300..332 assert_eq!(&buf[300..332], &[0x0A; 32]); // winner_id 332..364 assert_eq!(&buf[332..364], &[0x0B; 32]); // proposer_node_id 364..396 assert_eq!(&buf[364..396], &[0xAA; 32]); // target 396..412 u128 LE let expected_target = 0x1234_5678_9ABC_DEF0_1122_3344_5566_7788u128.to_le_bytes(); assert_eq!(&buf[396..412], &expected_target); // fallback_depth 412 = 1 assert_eq!(buf[412], 1); // signature 413..3722 (3309B ML-DSA-65) assert_eq!(&buf[413..3722], &[0u8; SIGNATURE_SIZE]); } #[test] fn signed_scope_excludes_signature() { let h = stub_header([0xAA; 32]); let mut scope = Vec::new(); h.encode_signed_scope(&mut scope); let mut full = Vec::new(); h.encode(&mut full); assert_eq!(full.len(), scope.len() + SIGNATURE_SIZE); assert_eq!(scope.len(), PROPOSAL_HEADER_SIZE - SIGNATURE_SIZE); assert_eq!(scope.len(), 413); // 3722 - 3309 (ML-DSA-65 signature) } #[test] fn signed_scope_stable_across_resign() { let mut h = stub_header([0xAA; 32]); let mut scope1 = Vec::new(); h.encode_signed_scope(&mut scope1); h.signature = Signature::from_array([0xFF; SIGNATURE_SIZE]); let mut scope2 = Vec::new(); h.encode_signed_scope(&mut scope2); assert_eq!(scope1, scope2); } #[test] fn proposal_hash_domain_mt_proposal() { let h = stub_header([0xAA; 32]); let mut scope = Vec::new(); h.encode_signed_scope(&mut scope); let expected = hash(b"mt-proposal", &[&scope]); assert_eq!(proposal_hash(&h), expected); } #[test] fn proposal_hash_stable_across_resign() { let mut h = stub_header([0x01; 32]); let h1 = proposal_hash(&h); h.signature = Signature::from_array([0xCD; SIGNATURE_SIZE]); let h2 = proposal_hash(&h); assert_eq!(h1, h2); } #[test] fn proposal_hash_sensitive_to_content() { let mut h = stub_header([0x01; 32]); let h1 = proposal_hash(&h); h.target = h.target.wrapping_add(1); let h2 = proposal_hash(&h); assert_ne!(h1, h2); } #[test] fn target_encoding_is_u128_le() { let mut h = stub_header([0xAA; 32]); h.target = 1u128; let mut buf = Vec::new(); h.encode(&mut buf); // target at offset 396..412 let mut expected = [0u8; 16]; expected[0] = 1; // LE: byte[0] = low assert_eq!(&buf[396..412], &expected); } #[test] fn validate_accepts_valid_header() { let (pk, sk) = keypair(); let (node_id, rec) = make_node(*pk.as_bytes()); let mut nt = NodeTable::new(); nt.insert(rec); let mut h = stub_header(node_id); sign_header(&mut h, &sk); assert_eq!(validate_header(&h, &nt, 99, 1, 1), Ok(())); } #[test] fn validate_rejects_unknown_proposer() { let (pk, sk) = keypair(); let (node_id, _rec) = make_node(*pk.as_bytes()); let nt = NodeTable::new(); let mut h = stub_header(node_id); sign_header(&mut h, &sk); assert_eq!( validate_header(&h, &nt, 99, 1, 1), Err(HeaderError::UnknownProposer) ); } #[test] fn validate_rejects_unsupported_suite() { let (pk, sk) = keypair(); let (node_id, mut rec) = make_node(*pk.as_bytes()); rec.suite_id = 0xFFFF; let mut nt = NodeTable::new(); nt.insert(rec); let mut h = stub_header(node_id); sign_header(&mut h, &sk); assert_eq!( validate_header(&h, &nt, 99, 1, 1), Err(HeaderError::UnsupportedSuite) ); } #[test] fn validate_rejects_window_not_monotone() { let (pk, sk) = keypair(); let (node_id, rec) = make_node(*pk.as_bytes()); let mut nt = NodeTable::new(); nt.insert(rec); let mut h = stub_header(node_id); sign_header(&mut h, &sk); // prev window = 100, header says 100 (should be 101) assert_eq!( validate_header(&h, &nt, 100, 1, 1), Err(HeaderError::WindowNotMonotone) ); } #[test] fn validate_rejects_protocol_decreased() { let (pk, sk) = keypair(); let (node_id, rec) = make_node(*pk.as_bytes()); let mut nt = NodeTable::new(); nt.insert(rec); let mut h = stub_header(node_id); h.protocol_version = 1; sign_header(&mut h, &sk); assert_eq!( validate_header(&h, &nt, 99, 2, 5), Err(HeaderError::ProtocolVersionDecreased) ); } #[test] fn validate_rejects_protocol_unsupported() { let (pk, sk) = keypair(); let (node_id, rec) = make_node(*pk.as_bytes()); let mut nt = NodeTable::new(); nt.insert(rec); let mut h = stub_header(node_id); h.protocol_version = 10; sign_header(&mut h, &sk); assert_eq!( validate_header(&h, &nt, 99, 1, 5), Err(HeaderError::ProtocolVersionUnsupported) ); } #[test] fn validate_rejects_fallback_depth_zero() { let (pk, sk) = keypair(); let (node_id, rec) = make_node(*pk.as_bytes()); let mut nt = NodeTable::new(); nt.insert(rec); let mut h = stub_header(node_id); h.fallback_depth = 0; sign_header(&mut h, &sk); assert_eq!( validate_header(&h, &nt, 99, 1, 1), Err(HeaderError::FallbackDepthZero) ); } // spec v30.7.0+: winner_class byte удалён из proposal header. // Тесты validate_rejects_invalid_winner_class и validate_accepts_valid_winner_classes // удалены как obsolete. Лотерея single-class, winner всегда узел. #[test] fn validate_rejects_bad_signature() { let (pk, sk) = keypair(); let (node_id, rec) = make_node(*pk.as_bytes()); let mut nt = NodeTable::new(); nt.insert(rec); let mut h = stub_header(node_id); sign_header(&mut h, &sk); let mut sig_bytes = *h.signature.as_bytes(); sig_bytes[0] ^= 0xFF; sig_bytes[200] ^= 0xAA; h.signature = Signature::from_array(sig_bytes); assert_eq!( validate_header(&h, &nt, 99, 1, 1), Err(HeaderError::InvalidSignature) ); } #[test] fn validate_rejects_signature_from_different_key() { let (pk, _sk) = keypair(); let (_other_pk, other_sk) = keypair(); let (node_id, rec) = make_node(*pk.as_bytes()); let mut nt = NodeTable::new(); nt.insert(rec); let mut h = stub_header(node_id); sign_header(&mut h, &other_sk); assert_eq!( validate_header(&h, &nt, 99, 1, 1), Err(HeaderError::InvalidSignature) ); } #[test] fn encode_determinism() { let h = stub_header([0x42; 32]); let mut a = Vec::new(); h.encode(&mut a); let mut b = Vec::new(); h.encode(&mut b); assert_eq!(a, b); } #[test] fn secret_key_size_sanity() { assert_eq!(SECRET_KEY_SIZE, 4032); } #[test] fn target_u128_max_encodes_correctly() { // Проверка что u128::MAX target encoded как 16 bytes 0xFF let mut h = stub_header([0xAA; 32]); h.target = u128::MAX; let mut buf = Vec::new(); h.encode(&mut buf); assert_eq!(&buf[396..412], &[0xFFu8; 16]); } #[test] fn target_zero_encodes_correctly() { let mut h = stub_header([0xAA; 32]); h.target = 0; let mut buf = Vec::new(); h.encode(&mut buf); assert_eq!(&buf[396..412], &[0u8; 16]); } #[test] fn header_size_sum_matches_layout() { // prev_hash 32 + window 8 + version 4 // + 8 × 32-byte roots (control/node/candidate/account/state/timechain/bundles/reveals) // + (winner_endpoint + winner_id + proposer_node_id) × 32 // + target 16 + fallback_depth 1 + signature 3309 (ML-DSA-65) = 3722 let calc = 32 + 8 + 4 + 32 * 8 + 32 * 3 + 16 + 1 + SIGNATURE_SIZE; assert_eq!(calc, PROPOSAL_HEADER_SIZE); } // ============ Phase B: Lookback Leadership ============ use mt_lottery::{Candidate, WINNER_CLASS_NODE}; fn node_cand(ticket: u128, id_byte: u8) -> Candidate { Candidate { ticket, class: WINNER_CLASS_NODE, id: [id_byte; 32], } } #[test] fn proposer_window_0_is_bootstrap() { let bootstrap: NodeId = [0x42; 32]; assert_eq!(canonical_proposer(0, bootstrap, &[]), bootstrap); } #[test] fn proposer_window_1_is_bootstrap() { let bootstrap: NodeId = [0x42; 32]; let cands = vec![node_cand(100, 0x11)]; assert_eq!(canonical_proposer(1, bootstrap, &cands), bootstrap); } #[test] fn proposer_window_2_is_first_node_candidate() { let bootstrap: NodeId = [0x42; 32]; let cands = vec![ node_cand(100, 0x11), node_cand(200, 0x22), node_cand(300, 0x33), ]; let p = canonical_proposer(2, bootstrap, &cands); assert_eq!(p, [0x11; 32]); } #[test] fn proposer_empty_candidates_falls_back_to_bootstrap() { let bootstrap: NodeId = [0x42; 32]; assert_eq!(canonical_proposer(100, bootstrap, &[]), bootstrap); } #[test] fn fallback_depth_1_is_canonical() { let bootstrap: NodeId = [0x42; 32]; let cands = vec![ node_cand(100, 0x11), node_cand(200, 0x22), node_cand(300, 0x33), ]; let canon = canonical_proposer(10, bootstrap, &cands); let fallback_1 = fallback_proposer(10, bootstrap, &cands, 1); assert_eq!(canon, fallback_1); assert_eq!(fallback_1, [0x11; 32]); } #[test] fn fallback_depth_2_is_second_node() { let bootstrap: NodeId = [0x42; 32]; let cands = vec![ node_cand(100, 0x11), node_cand(200, 0x22), node_cand(300, 0x33), ]; let f2 = fallback_proposer(10, bootstrap, &cands, 2); assert_eq!(f2, [0x22; 32]); } // spec: лотерея single-class, кандидаты только узлы; fallback_skips_accounts удалён как obsolete. #[test] fn fallback_exhausted_goes_to_bootstrap() { let bootstrap: NodeId = [0x42; 32]; let cands = vec![node_cand(100, 0x11), node_cand(200, 0x22)]; let f100 = fallback_proposer(10, bootstrap, &cands, 100); assert_eq!(f100, bootstrap); } #[test] fn fallback_genesis_bootstrap() { let bootstrap: NodeId = [0x42; 32]; let cands = vec![node_cand(100, 0x11)]; // Even with candidates, window < 2 → bootstrap assert_eq!(fallback_proposer(0, bootstrap, &cands, 5), bootstrap); assert_eq!(fallback_proposer(1, bootstrap, &cands, 5), bootstrap); } // ============ Phase C: control_set ============ fn co(op_hash_byte: u8, cemented_window: u64) -> ControlObjectRef { ControlObjectRef { op_hash: [op_hash_byte; 32], cemented_window, } } #[test] fn control_set_empty_input() { let r = compute_control_set(&[], 5, 10); assert!(r.is_empty()); } #[test] fn control_set_filters_cemented_window_range() { let all = vec![ co(0x01, 3), // ≤ prev (5), excluded co(0x02, 6), // in range (5, 10] co(0x03, 10), // in range (inclusive upper) co(0x04, 11), // > current (10), excluded ]; let r = compute_control_set(&all, 5, 10); assert_eq!(r.len(), 2); assert_eq!(r[0].op_hash, [0x02; 32]); assert_eq!(r[1].op_hash, [0x03; 32]); } #[test] fn control_set_sorts_by_window_then_hash() { // Two objects with same window — sort by op_hash lex asc let all = vec![co(0xFF, 6), co(0x11, 6), co(0xAA, 7)]; let r = compute_control_set(&all, 5, 10); assert_eq!(r[0].op_hash, [0x11; 32]); // window 6, hash 0x11 assert_eq!(r[1].op_hash, [0xFF; 32]); // window 6, hash 0xFF assert_eq!(r[2].op_hash, [0xAA; 32]); // window 7 } #[test] fn control_set_strict_lower_bound() { // cemented_window > previous_proposal.window (strictly greater) let all = vec![co(0x01, 5), co(0x02, 6)]; let r = compute_control_set(&all, 5, 10); assert_eq!(r.len(), 1); assert_eq!(r[0].op_hash, [0x02; 32]); } #[test] fn control_set_inclusive_upper_bound() { // cemented_window <= W (inclusive) let all = vec![co(0x01, 10)]; let r = compute_control_set(&all, 5, 10); assert_eq!(r.len(), 1); } #[test] fn control_set_deterministic_on_permutation() { let obj1 = co(0x02, 6); let obj2 = co(0x03, 7); let obj3 = co(0x04, 6); let r1 = compute_control_set(&[obj1, obj2, obj3], 5, 10); let r2 = compute_control_set(&[obj3, obj2, obj1], 5, 10); assert_eq!(r1, r2); } #[test] fn validate_control_set_accepts_canonical() { let all = vec![co(0x02, 6), co(0x03, 7)]; let expected = compute_control_set(&all, 5, 10); assert_eq!(validate_control_set(&expected, &all, 5, 10), Ok(())); } #[test] fn validate_control_set_rejects_missing_object() { let all = vec![co(0x02, 6), co(0x03, 7)]; let proposer_set = vec![co(0x02, 6)]; // missing 0x03 assert_eq!( validate_control_set(&proposer_set, &all, 5, 10), Err(ControlSetError::Mismatch) ); } #[test] fn validate_control_set_rejects_extra_object() { let all = vec![co(0x02, 6)]; let proposer_set = vec![co(0x02, 6), co(0x99, 6)]; // extra 0x99 not in cemented assert_eq!( validate_control_set(&proposer_set, &all, 5, 10), Err(ControlSetError::Mismatch) ); } #[test] fn validate_control_set_rejects_wrong_order() { let all = vec![co(0xFF, 6), co(0x11, 6)]; let wrong_order = vec![co(0xFF, 6), co(0x11, 6)]; // should be (0x11, 0xFF) sorted assert_eq!( validate_control_set(&wrong_order, &all, 5, 10), Err(ControlSetError::Mismatch) ); } // ============ Phase D: Canonical acceptance ============ #[test] fn validate_proposer_canonical_depth_1() { let bootstrap: NodeId = [0x42; 32]; let cands = vec![node_cand(100, 0x11), node_cand(200, 0x22)]; let (pk, sk) = keypair(); let (node_id, rec) = make_node(*pk.as_bytes()); let _ = (node_id, rec, pk, sk); let mut h = stub_header([0x11; 32]); // matches first node candidate h.fallback_depth = 1; assert_eq!( validate_proposer_is_canonical(&h, bootstrap, &cands), Ok(()) ); } #[test] fn validate_proposer_rejects_mismatch() { let bootstrap: NodeId = [0x42; 32]; let cands = vec![node_cand(100, 0x11), node_cand(200, 0x22)]; let mut h = stub_header([0x99; 32]); // doesn't match h.fallback_depth = 1; assert_eq!( validate_proposer_is_canonical(&h, bootstrap, &cands), Err(AcceptanceError::ProposerNotCanonical) ); } #[test] fn validate_proposer_canonical_genesis() { // window < 2: proposer_node_id must be bootstrap let bootstrap: NodeId = [0x42; 32]; let mut h = stub_header(bootstrap); h.window_index = 0; h.fallback_depth = 1; assert_eq!(validate_proposer_is_canonical(&h, bootstrap, &[]), Ok(())); } #[test] fn validate_bundles_threshold_at_quorum() { assert_eq!(validate_bundles_threshold(67, 100), Ok(())); } #[test] fn validate_bundles_threshold_below() { assert_eq!( validate_bundles_threshold(66, 100), Err(AcceptanceError::InsufficientBundles) ); } #[test] fn validate_bundles_threshold_above() { assert_eq!(validate_bundles_threshold(100, 100), Ok(())); } #[test] fn validate_included_reveals_matching() { let reveals = vec![[0x11; 32], [0x22; 32], [0x33; 32]]; assert_eq!(validate_included_reveals(&reveals, &reveals), Ok(())); } #[test] fn validate_included_reveals_missing() { let proposer = vec![[0x11; 32], [0x22; 32]]; let cemented = vec![[0x11; 32], [0x22; 32], [0x33; 32]]; assert_eq!( validate_included_reveals(&proposer, &cemented), Err(AcceptanceError::IncludedRevealsMismatch) ); } #[test] fn validate_included_reveals_extra() { let proposer = vec![[0x11; 32], [0x22; 32], [0x99; 32]]; let cemented = vec![[0x11; 32], [0x22; 32]]; assert_eq!( validate_included_reveals(&proposer, &cemented), Err(AcceptanceError::IncludedRevealsMismatch) ); } #[test] fn validate_winner_matches_argmin() { let cands = vec![node_cand(100, 0x11), node_cand(200, 0x22)]; let mut h = stub_header([0x55; 32]); h.winner_id = [0x11; 32]; assert_eq!(validate_winner(&h, &cands), Ok(())); } #[test] fn validate_winner_mismatch_id() { let cands = vec![node_cand(100, 0x11)]; let mut h = stub_header([0x55; 32]); h.winner_id = [0x99; 32]; // wrong id assert_eq!( validate_winner(&h, &cands), Err(AcceptanceError::WrongWinner) ); } #[test] fn validate_winner_empty_candidates() { let h = stub_header([0x55; 32]); assert_eq!(validate_winner(&h, &[]), Err(AcceptanceError::WrongWinner)); } // ============ Phase E: Finalization ============ #[test] fn finalization_cemented_at_quorum() { assert_eq!(finalization_status(67, 100), FinalizationStatus::Cemented); } #[test] fn finalization_rejected_below_quorum() { assert_eq!(finalization_status(66, 100), FinalizationStatus::Rejected); } #[test] fn finalization_cemented_above_quorum() { assert_eq!(finalization_status(100, 100), FinalizationStatus::Cemented); } #[test] fn leader_penalty_returns_proposer() { let h = stub_header([0xDE; 32]); assert_eq!(leader_penalty_excluded_node(&h), [0xDE; 32]); } }