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

497 lines
16 KiB
Rust
Raw Permalink Normal View History

// 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;
}