// Automated determinism invariants для mt-lottery. // M4 audit prep — node lottery / VDF reveal / bundled confirmation / // winner determination. Любое non-determinism = consensus fork. // Invariants ловят regression если refactor ломает byte-exact encode, // op_hash R2, weighted_ticket monotonicity, либо argmin canonical rule. use mt_crypto::{Hash32, Signature, SIGNATURE_SIZE}; use mt_lottery::{ bundle_hash, compute_endpoint, determine_winner, is_cemented, ln_q64, log2_q64, lottery_weight, quorum, reveal_hash, seniority_term, sorted_candidates_for_fallback, weighted_ticket_node, BundleError, BundledConfirmation, Candidate, RevealError, VdfReveal, BUNDLE_FIXED_OVERHEAD, REVEAL_SIZE, WINNER_CLASS_NODE, }; use mt_state::NodeId; // ---------- Helpers ---------- fn sample_bundle(node_id: NodeId, ops: Vec, reveals: Vec) -> BundledConfirmation { BundledConfirmation { node_id, endpoint: [0xAB; 32], window_index: 42, op_hashes: ops, reveal_hashes: reveals, signature: Signature::from_array([0u8; SIGNATURE_SIZE]), } } fn sample_reveal(node_id: NodeId) -> VdfReveal { VdfReveal { node_id, window_index: 42, endpoint: [0xCD; 32], signature: Signature::from_array([0u8; SIGNATURE_SIZE]), } } // ---------- bundle_hash / reveal_hash determinism (R2 invariant) ---------- #[test] fn bundle_hash_deterministic() { let bc = sample_bundle([0x01; 32], vec![[0x10; 32], [0x11; 32]], vec![[0x20; 32]]); assert_eq!(bundle_hash(&bc), bundle_hash(&bc)); } #[test] fn bundle_hash_stable_under_signature_mutation() { // R2 (spec): bundle_hash = SHA-256("mt-bundle" || signed_scope) — signature // НЕ входит в hash. Защита от scheme-specific signature randomness. let mut a = sample_bundle([0x01; 32], vec![[0x10; 32]], vec![[0x20; 32]]); let mut b = a.clone(); b.signature = Signature::from_array([0xFF; SIGNATURE_SIZE]); assert_eq!(bundle_hash(&a), bundle_hash(&b)); a.signature = Signature::from_array([0x42; SIGNATURE_SIZE]); assert_eq!(bundle_hash(&a), bundle_hash(&b)); } #[test] fn bundle_hash_changes_on_content_mutation() { let base = sample_bundle([0x01; 32], vec![[0x10; 32]], vec![[0x20; 32]]); let mutated_node_id = sample_bundle([0xFF; 32], vec![[0x10; 32]], vec![[0x20; 32]]); let mutated_endpoint = { let mut x = base.clone(); x.endpoint = [0xFF; 32]; x }; let mutated_window = { let mut x = base.clone(); x.window_index = 100; x }; let mutated_ops = sample_bundle([0x01; 32], vec![[0xFF; 32]], vec![[0x20; 32]]); let mutated_reveals = sample_bundle([0x01; 32], vec![[0x10; 32]], vec![[0xFF; 32]]); assert_ne!(bundle_hash(&base), bundle_hash(&mutated_node_id)); assert_ne!(bundle_hash(&base), bundle_hash(&mutated_endpoint)); assert_ne!(bundle_hash(&base), bundle_hash(&mutated_window)); assert_ne!(bundle_hash(&base), bundle_hash(&mutated_ops)); assert_ne!(bundle_hash(&base), bundle_hash(&mutated_reveals)); } #[test] fn reveal_hash_deterministic_and_stable_under_signature() { let r1 = sample_reveal([0x01; 32]); let mut r2 = r1.clone(); r2.signature = Signature::from_array([0xFF; SIGNATURE_SIZE]); assert_eq!(reveal_hash(&r1), reveal_hash(&r1)); assert_eq!(reveal_hash(&r1), reveal_hash(&r2)); } #[test] fn reveal_hash_distinct_from_bundle_hash() { // Domain separator различает: "mt-bundle" vs "mt-vdf-reveal". let bc = sample_bundle([0x01; 32], vec![], vec![]); let r = sample_reveal([0x01; 32]); // (Совпадение возможно теоретически, но крайне маловероятно.) assert_ne!(bundle_hash(&bc), reveal_hash(&r)); } // ---------- compute_endpoint determinism (R3 lottery formula) ---------- #[test] fn compute_endpoint_deterministic() { let t_r = [0x11; 32]; let cba = [0x22; 32]; let node_id: NodeId = [0x33; 32]; assert_eq!( compute_endpoint(&t_r, &cba, &node_id, 7), compute_endpoint(&t_r, &cba, &node_id, 7) ); } #[test] fn compute_endpoint_changes_on_each_input() { let t_r = [0x11; 32]; let cba = [0x22; 32]; let node_id: NodeId = [0x33; 32]; let base = compute_endpoint(&t_r, &cba, &node_id, 7); assert_ne!(base, compute_endpoint(&[0xFF; 32], &cba, &node_id, 7)); assert_ne!(base, compute_endpoint(&t_r, &[0xFF; 32], &node_id, 7)); assert_ne!(base, compute_endpoint(&t_r, &cba, &[0xFF; 32], 7)); assert_ne!(base, compute_endpoint(&t_r, &cba, &node_id, 8)); } // ---------- log2_q64 / ln_q64 / weighted_ticket monotonicity ---------- #[test] fn log2_q64_deterministic() { let endpoint = [0xAB; 32]; assert_eq!(log2_q64(&endpoint), log2_q64(&endpoint)); } #[test] fn log2_q64_zero_endpoint_saturates() { let zero = [0u8; 32]; assert_eq!(log2_q64(&zero), u128::MAX); } #[test] fn log2_q64_monotonic_smaller_endpoint_larger_log() { // Меньший endpoint → больший log2(2^256/endpoint). let small = { let mut e = [0u8; 32]; e[31] = 1; // = 1 e }; let large = { let mut e = [0u8; 32]; e[0] = 0x80; // ≈ 2^255 e }; assert!( log2_q64(&small) > log2_q64(&large), "log2(2^256/1) > log2(2^256/2^255)" ); } #[test] fn ln_q64_deterministic() { let endpoint = [0xCD; 32]; assert_eq!(ln_q64(&endpoint), ln_q64(&endpoint)); } #[test] fn ln_q64_zero_saturates() { let zero = [0u8; 32]; assert_eq!(ln_q64(&zero), u128::MAX); } #[test] fn ln_q64_monotonic() { let small = { let mut e = [0u8; 32]; e[31] = 1; e }; let large = { let mut e = [0u8; 32]; e[0] = 0x80; e }; assert!(ln_q64(&small) > ln_q64(&large)); } // ---------- seniority_term / lottery_weight ---------- #[test] fn seniority_term_first_13_windows_zero() { // chain_length / 13 = 0 при chain_length < 13. for cl in 0..13u64 { assert_eq!(seniority_term(cl, cl), 0, "cl={}", cl); } } #[test] fn seniority_term_capped_by_snapshot() { // min(chain_length / 13, snapshot) assert_eq!(seniority_term(10000, 5), 5); // snapshot caps assert_eq!(seniority_term(100, 1000), 100 / 13); // chain_length/13 < snapshot } #[test] fn lottery_weight_at_least_one_for_active_node() { // DS-2: snapshot ≥ 1 → lottery_weight ≥ 1. assert!(lottery_weight(0, 1) >= 1); assert!(lottery_weight(100, 1) >= 1); assert!(lottery_weight(10000, 5) >= 5); } // ---------- weighted_ticket_node determinism + zero-weight protection ---------- #[test] fn weighted_ticket_node_deterministic() { let endpoint = [0xAB; 32]; let a = weighted_ticket_node(&endpoint, 100, 5); let b = weighted_ticket_node(&endpoint, 100, 5); assert_eq!(a, b); } #[test] fn weighted_ticket_node_zero_weight_saturates() { // chain_length_snapshot = 0 → lottery_weight = 0 (DS-2 violation, // caller обязан не позволять). Защита: u128::MAX (invalid argmin // candidate, не crash). let endpoint = [0xAB; 32]; assert_eq!(weighted_ticket_node(&endpoint, 0, 0), u128::MAX); } #[test] fn weighted_ticket_node_inverse_to_weight() { // Larger weight → smaller ticket (ln_q64 / weight). let endpoint = [0x01; 32]; let small_w = weighted_ticket_node(&endpoint, 0, 1); let large_w = weighted_ticket_node(&endpoint, 0, 100); assert!(small_w > large_w); } // ---------- determine_winner argmin canonical rule ---------- #[test] fn determine_winner_empty_returns_none() { assert_eq!(determine_winner(&[]), None); } #[test] fn determine_winner_single_candidate() { let c = Candidate { ticket: 100, class: WINNER_CLASS_NODE, id: [0x01; 32], }; let w = determine_winner(&[c]).unwrap(); assert_eq!(w.ticket, 100); assert_eq!(w.id, [0x01; 32]); } #[test] fn determine_winner_argmin_by_ticket() { let c1 = Candidate { ticket: 200, class: WINNER_CLASS_NODE, id: [0x01; 32], }; let c2 = Candidate { ticket: 100, class: WINNER_CLASS_NODE, id: [0x02; 32], }; let c3 = Candidate { ticket: 300, class: WINNER_CLASS_NODE, id: [0x03; 32], }; let w = determine_winner(&[c1, c2, c3]).unwrap(); assert_eq!(w.ticket, 100, "minimum ticket wins"); assert_eq!(w.id, [0x02; 32]); } #[test] fn determine_winner_tie_broken_by_id_lex() { // Tie на ticket → tie-break by id lex asc (с учётом class — но class // одинаковый WINNER_CLASS_NODE; spec ambiguity закрыта canonical rule). let c1 = Candidate { ticket: 100, class: WINNER_CLASS_NODE, id: [0xFF; 32], }; let c2 = Candidate { ticket: 100, class: WINNER_CLASS_NODE, id: [0x01; 32], }; let w = determine_winner(&[c1, c2]).unwrap(); assert_eq!(w.id, [0x01; 32], "lex-smaller id wins on ticket tie"); } #[test] fn determine_winner_input_order_independent() { let c1 = Candidate { ticket: 200, class: WINNER_CLASS_NODE, id: [0x01; 32], }; let c2 = Candidate { ticket: 100, class: WINNER_CLASS_NODE, id: [0x02; 32], }; let c3 = Candidate { ticket: 150, class: WINNER_CLASS_NODE, id: [0x03; 32], }; let w_a = determine_winner(&[c1, c2, c3]).unwrap(); let w_b = determine_winner(&[c3, c1, c2]).unwrap(); let w_c = determine_winner(&[c2, c3, c1]).unwrap(); assert_eq!(w_a, w_b); assert_eq!(w_b, w_c); } #[test] fn sorted_candidates_for_fallback_canonical() { let c1 = Candidate { ticket: 200, class: WINNER_CLASS_NODE, id: [0x01; 32], }; let c2 = Candidate { ticket: 100, class: WINNER_CLASS_NODE, id: [0x02; 32], }; let c3 = Candidate { ticket: 150, class: WINNER_CLASS_NODE, id: [0x03; 32], }; let sorted = sorted_candidates_for_fallback(&[c1, c2, c3]); assert_eq!(sorted[0], c2); // ticket 100 assert_eq!(sorted[1], c3); // ticket 150 assert_eq!(sorted[2], c1); // ticket 200 } // ---------- quorum / is_cemented ---------- #[test] fn quorum_67_percent_ceiling() { // (67 × X + 99) / 100 = ceiling(0.67 × X) assert_eq!(quorum(0), 0); assert_eq!(quorum(100), 67); assert_eq!(quorum(1000), 670); assert_eq!(quorum(1), 1); // ceiling(0.67) = 1 assert_eq!(quorum(2), 2); // ceiling(1.34) = 2 assert_eq!(quorum(3), 3); // ceiling(2.01) = 3 } #[test] fn is_cemented_threshold() { let active = 100u64; let q = quorum(active); // = 67 assert!(!is_cemented(q - 1, active)); // 66 < 67 assert!(is_cemented(q, active)); // 67 ≥ 67 assert!(is_cemented(q + 1, active)); assert!(is_cemented(active, active)); } #[test] fn is_cemented_zero_active_zero_quorum() { // active = 0 → quorum = 0; 0 ≥ 0 = true. Edge case, defensive correctness. assert!(is_cemented(0, 0)); } // ---------- Static API invariants ---------- #[test] fn winner_class_node_is_one() { // SSOT [I-10]: WINNER_CLASS_NODE = 1. Изменение = consensus fork. assert_eq!(WINNER_CLASS_NODE, 1); } #[test] fn record_sizes_positive() { const _: () = assert!(BUNDLE_FIXED_OVERHEAD > 0); const _: () = assert!(REVEAL_SIZE > 0); } // ---------- M4-1 closure: Vec u16 length cap ---------- #[test] fn validate_bundle_rejects_too_many_ops() { // M4-1 closure: при op_hashes.len() > u16::MAX = 65535 — explicit error // ДО ML-DSA verify (защита от silent encode truncation). use mt_state::NodeTable; let node_table = NodeTable::new(); let too_many: Vec = (0..(u16::MAX as usize + 1)) .map(|i| { let mut h = [0u8; 32]; h[..8].copy_from_slice(&(i as u64).to_le_bytes()); h }) .collect(); let bc = sample_bundle([0x01; 32], too_many, vec![]); let result = mt_lottery::validate_bundle(&bc, &node_table, &[0xAB; 32]); // Note: validate_bundle сначала проверяет UnknownNode (node не зарегистрирован), // поэтому в этом тесте получаем UnknownNode. Positive test для TooManyOps // с registered node — ниже (validate_bundle_rejects_too_many_ops_with_registered_node). assert!(matches!(result, Err(BundleError::UnknownNode))); } // M4-LOW-6 closure: positive functional test для BundleError::TooManyOps // с registered node (предыдущий test перехватывался на UnknownNode и не // reach-ил cap-check). Этот test гарантирует что cap-check actually fires // при op_hashes.len() > u16::MAX даже когда node зарегистрирован и suite // supported. Регрессия — если кто-то reorder validate_bundle checks. #[test] fn validate_bundle_rejects_too_many_ops_with_registered_node() { use mt_crypto::{keypair, PUBLIC_KEY_SIZE}; use mt_state::{derive_node_id, NodeRecord, NodeTable}; let (pk, _sk) = keypair(); let node_id = derive_node_id(pk.as_bytes()); let node = NodeRecord { node_id, node_pubkey: *pk.as_bytes(), suite_id: 1, operator_account_id: [0u8; 32], start_window: 0, chain_length: 1, chain_length_snapshot: 1, chain_length_checkpoints: [1; 6], last_confirmation_window: 0, }; let mut nt = NodeTable::new(); nt.insert(node); let too_many: Vec = (0..(u16::MAX as usize + 1)) .map(|i| { let mut h = [0u8; 32]; h[..8].copy_from_slice(&(i as u64).to_le_bytes()); h }) .collect(); let mut bc = sample_bundle(node_id, too_many, vec![]); bc.endpoint = [0xAB; 32]; // Signature не валидна (placeholder), но cap-check должен fire ДО verify. // Validate_bundle order: UnknownNode → UnsupportedSuite → WrongEndpoint // → TooManyOps → TooManyReveals → OpsOutOfOrder → InvalidSignature // Node зарегистрирован, suite supported, endpoint совпадает — следующая // проверка TooManyOps должна fire. let result = mt_lottery::validate_bundle(&bc, &nt, &[0xAB; 32]); assert_eq!(result, Err(BundleError::TooManyOps)); let _ = PUBLIC_KEY_SIZE; // suppress unused import warning if не нужен } #[test] fn validate_bundle_rejects_too_many_reveals_with_registered_node() { // Симметричный test для TooManyReveals. use mt_crypto::keypair; use mt_state::{derive_node_id, NodeRecord, NodeTable}; let (pk, _sk) = keypair(); let node_id = derive_node_id(pk.as_bytes()); let node = NodeRecord { node_id, node_pubkey: *pk.as_bytes(), suite_id: 1, operator_account_id: [0u8; 32], start_window: 0, chain_length: 1, chain_length_snapshot: 1, chain_length_checkpoints: [1; 6], last_confirmation_window: 0, }; let mut nt = NodeTable::new(); nt.insert(node); let too_many_reveals: Vec = (0..(u16::MAX as usize + 1)) .map(|i| { let mut h = [0u8; 32]; h[..8].copy_from_slice(&(i as u64).to_le_bytes()); h }) .collect(); let mut bc = sample_bundle(node_id, vec![], too_many_reveals); bc.endpoint = [0xAB; 32]; let result = mt_lottery::validate_bundle(&bc, &nt, &[0xAB; 32]); assert_eq!(result, Err(BundleError::TooManyReveals)); } #[test] fn record_sizes_pos_and_winner_class_compile_consts() { // Compile-time проверка что constants > 0 let _: BundleError = BundleError::TooManyOps; let _: BundleError = BundleError::TooManyReveals; let _: RevealError = RevealError::WrongWindow; }