montana/Монтана-Протокол/Код/crates/mt-lottery/tests/determinism_invariants.rs

497 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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<Hash32>, reveals: Vec<Hash32>) -> 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<Hash32> = (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<Hash32> = (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<Hash32> = (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;
}