345 lines
11 KiB
Rust
345 lines
11 KiB
Rust
|
|
// Automated determinism invariants для mt-entry.
|
|||
|
|
// M4 audit prep — node admission flow: NodeRegistration / candidate_vdf_init /
|
|||
|
|
// selection event / Adaptive VDF / nr_sort_key. Любое non-determinism =
|
|||
|
|
// consensus fork. Invariants ловят regression в byte-exact encode,
|
|||
|
|
// hash composition, sort orders, либо state transition apply_*_batch.
|
|||
|
|
|
|||
|
|
use mt_codec::CanonicalEncode;
|
|||
|
|
use mt_crypto::{Hash32, Signature, PUBLIC_KEY_SIZE, SIGNATURE_SIZE};
|
|||
|
|
use mt_entry::{
|
|||
|
|
candidate_vdf_init, compute_expiry_window, compute_node_id, is_selection_window, nodereg_hash,
|
|||
|
|
nr_sort_key, rank_candidates_for_selection, required_vdf_length, selection_slots,
|
|||
|
|
selection_sort_key, NodeRegistration, NODE_REGISTRATION_SIZE, TYPE_NODE_REGISTRATION,
|
|||
|
|
};
|
|||
|
|
use mt_state::{CandidatePool, CandidateRecord, NodeId};
|
|||
|
|
|
|||
|
|
// ---------- Helpers ----------
|
|||
|
|
|
|||
|
|
fn sample_node_registration(seed: u8) -> NodeRegistration {
|
|||
|
|
NodeRegistration {
|
|||
|
|
suite_id: 1,
|
|||
|
|
node_pubkey: [seed; PUBLIC_KEY_SIZE],
|
|||
|
|
operator_account_id: [seed.wrapping_add(1); 32],
|
|||
|
|
proof_endpoint: [seed.wrapping_add(2); 32],
|
|||
|
|
w_start: 100,
|
|||
|
|
vdf_chain_length: 20_160,
|
|||
|
|
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn sample_candidate(seed: u8, w_start: u64) -> CandidateRecord {
|
|||
|
|
CandidateRecord {
|
|||
|
|
node_id: [seed; 32],
|
|||
|
|
node_pubkey: [seed; PUBLIC_KEY_SIZE],
|
|||
|
|
suite_id: 1,
|
|||
|
|
operator_account_id: [seed.wrapping_add(1); 32],
|
|||
|
|
proof_endpoint: [seed.wrapping_add(2); 32],
|
|||
|
|
w_start,
|
|||
|
|
vdf_chain_length: 20_160,
|
|||
|
|
registration_window: w_start,
|
|||
|
|
expires: w_start + 60_480,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Encoded size matches constant ([I-9] determinism) ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn node_registration_encoded_size_constant() {
|
|||
|
|
let nr = sample_node_registration(0xAB);
|
|||
|
|
let mut buf = Vec::new();
|
|||
|
|
nr.encode(&mut buf);
|
|||
|
|
assert_eq!(
|
|||
|
|
buf.len(),
|
|||
|
|
NODE_REGISTRATION_SIZE,
|
|||
|
|
"NodeRegistration encoded size drift: expected {}, got {}",
|
|||
|
|
NODE_REGISTRATION_SIZE,
|
|||
|
|
buf.len()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn type_node_registration_stable() {
|
|||
|
|
// SSOT [I-10]: type code immutable. Изменение = consensus fork.
|
|||
|
|
assert_eq!(TYPE_NODE_REGISTRATION, 0x11);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- nodereg_hash R2 invariant ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn nodereg_hash_deterministic() {
|
|||
|
|
let nr = sample_node_registration(0xAB);
|
|||
|
|
assert_eq!(nodereg_hash(&nr), nodereg_hash(&nr));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn nodereg_hash_stable_under_signature_mutation() {
|
|||
|
|
// R2: signature НЕ входит в hash.
|
|||
|
|
let mut a = sample_node_registration(0xAB);
|
|||
|
|
let mut b = a.clone();
|
|||
|
|
b.signature = Signature::from_array([0xFF; SIGNATURE_SIZE]);
|
|||
|
|
assert_eq!(nodereg_hash(&a), nodereg_hash(&b));
|
|||
|
|
a.signature = Signature::from_array([0x42; SIGNATURE_SIZE]);
|
|||
|
|
assert_eq!(nodereg_hash(&a), nodereg_hash(&b));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn nodereg_hash_changes_on_field_mutation() {
|
|||
|
|
let base = sample_node_registration(0xAB);
|
|||
|
|
let m_pubkey = sample_node_registration(0xFF);
|
|||
|
|
let m_w_start = {
|
|||
|
|
let mut x = base.clone();
|
|||
|
|
x.w_start = 999;
|
|||
|
|
x
|
|||
|
|
};
|
|||
|
|
let m_vdf = {
|
|||
|
|
let mut x = base.clone();
|
|||
|
|
x.vdf_chain_length = 99999;
|
|||
|
|
x
|
|||
|
|
};
|
|||
|
|
let m_operator = {
|
|||
|
|
let mut x = base.clone();
|
|||
|
|
x.operator_account_id = [0xFF; 32];
|
|||
|
|
x
|
|||
|
|
};
|
|||
|
|
assert_ne!(nodereg_hash(&base), nodereg_hash(&m_pubkey));
|
|||
|
|
assert_ne!(nodereg_hash(&base), nodereg_hash(&m_w_start));
|
|||
|
|
assert_ne!(nodereg_hash(&base), nodereg_hash(&m_vdf));
|
|||
|
|
assert_ne!(nodereg_hash(&base), nodereg_hash(&m_operator));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- compute_node_id determinism ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn compute_node_id_deterministic() {
|
|||
|
|
let pubkey = [0xCC; PUBLIC_KEY_SIZE];
|
|||
|
|
assert_eq!(compute_node_id(&pubkey), compute_node_id(&pubkey));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn compute_node_id_changes_on_pubkey() {
|
|||
|
|
assert_ne!(
|
|||
|
|
compute_node_id(&[0xAA; PUBLIC_KEY_SIZE]),
|
|||
|
|
compute_node_id(&[0xBB; PUBLIC_KEY_SIZE])
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- candidate_vdf_init / [I-8] binding ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn candidate_vdf_init_deterministic() {
|
|||
|
|
let t_r = [0x11; 32];
|
|||
|
|
let cba = [0x22; 32];
|
|||
|
|
let node_id: NodeId = [0x33; 32];
|
|||
|
|
assert_eq!(
|
|||
|
|
candidate_vdf_init(&t_r, &cba, &node_id),
|
|||
|
|
candidate_vdf_init(&t_r, &cba, &node_id)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn candidate_vdf_init_changes_on_each_input() {
|
|||
|
|
let t_r = [0x11; 32];
|
|||
|
|
let cba = [0x22; 32];
|
|||
|
|
let node_id: NodeId = [0x33; 32];
|
|||
|
|
let base = candidate_vdf_init(&t_r, &cba, &node_id);
|
|||
|
|
assert_ne!(base, candidate_vdf_init(&[0xFF; 32], &cba, &node_id));
|
|||
|
|
assert_ne!(base, candidate_vdf_init(&t_r, &[0xFF; 32], &node_id));
|
|||
|
|
assert_ne!(base, candidate_vdf_init(&t_r, &cba, &[0xFF; 32]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- compute_expiry_window ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn compute_expiry_window_three_tau2() {
|
|||
|
|
// [C-1] SSOT: candidate_expiry_windows читается из ProtocolParams (60_480 = 3τ₂ at genesis)
|
|||
|
|
let p = mt_genesis::genesis_params();
|
|||
|
|
assert_eq!(p.candidate_expiry_windows, 3 * p.tau2_windows);
|
|||
|
|
assert_eq!(p.candidate_expiry_windows, 60_480);
|
|||
|
|
assert_eq!(compute_expiry_window(100, p), 100 + 60_480);
|
|||
|
|
assert_eq!(compute_expiry_window(0, p), 60_480);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- selection_slots + is_selection_window ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn selection_slots_admission_divisor_130() {
|
|||
|
|
// [C-1] SSOT: admission_divisor читается из ProtocolParams (130 at genesis)
|
|||
|
|
let p = mt_genesis::genesis_params();
|
|||
|
|
assert_eq!(p.admission_divisor, 130);
|
|||
|
|
// active = 0 → max(1, 0) = 1 (защита от division/zero edge)
|
|||
|
|
assert_eq!(selection_slots(0, p), 1);
|
|||
|
|
// 130 → 130/130 = 1; max(1, 1) = 1
|
|||
|
|
assert_eq!(selection_slots(130, p), 1);
|
|||
|
|
// 1300 → 10
|
|||
|
|
assert_eq!(selection_slots(1300, p), 10);
|
|||
|
|
// 1% rule sanity: 100k active → 100k/130 ≈ 769 slots
|
|||
|
|
assert_eq!(selection_slots(100_000, p), 100_000 / 130);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn selection_slots_at_least_one() {
|
|||
|
|
// max(1, ...) гарантирует ≥ 1 slot для bootstrap (genesis with 1 node)
|
|||
|
|
let p = mt_genesis::genesis_params();
|
|||
|
|
for active in [0u64, 1, 50, 129] {
|
|||
|
|
assert_eq!(selection_slots(active, p), 1, "active={}", active);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn is_selection_window_every_336() {
|
|||
|
|
// [C-1] SSOT: selection_interval читается из ProtocolParams (336 at genesis)
|
|||
|
|
let p = mt_genesis::genesis_params();
|
|||
|
|
assert_eq!(p.selection_interval, 336);
|
|||
|
|
assert!(!is_selection_window(0, p)); // window 0 — особый случай (нет selection)
|
|||
|
|
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));
|
|||
|
|
assert!(is_selection_window(336 * 100, p));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- selection_sort_key + rank_candidates_for_selection ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn selection_sort_key_deterministic() {
|
|||
|
|
let t_r = [0x11; 32];
|
|||
|
|
let cba = [0x22; 32];
|
|||
|
|
let node_id: NodeId = [0x33; 32];
|
|||
|
|
assert_eq!(
|
|||
|
|
selection_sort_key(&t_r, &cba, &node_id),
|
|||
|
|
selection_sort_key(&t_r, &cba, &node_id)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn rank_candidates_canonical_order() {
|
|||
|
|
// Sort by sort_key asc — input order не влияет.
|
|||
|
|
let mut p1 = CandidatePool::new();
|
|||
|
|
p1.insert(sample_candidate(0x10, 100));
|
|||
|
|
p1.insert(sample_candidate(0x20, 100));
|
|||
|
|
p1.insert(sample_candidate(0x30, 100));
|
|||
|
|
|
|||
|
|
let mut p2 = CandidatePool::new();
|
|||
|
|
p2.insert(sample_candidate(0x30, 100));
|
|||
|
|
p2.insert(sample_candidate(0x10, 100));
|
|||
|
|
p2.insert(sample_candidate(0x20, 100));
|
|||
|
|
|
|||
|
|
let r1 = rank_candidates_for_selection(&p1, &[0xAA; 32], &[0xBB; 32]);
|
|||
|
|
let r2 = rank_candidates_for_selection(&p2, &[0xAA; 32], &[0xBB; 32]);
|
|||
|
|
// Same sort_keys derived → same order
|
|||
|
|
let keys1: Vec<Hash32> = r1.iter().map(|(k, _)| *k).collect();
|
|||
|
|
let keys2: Vec<Hash32> = r2.iter().map(|(k, _)| *k).collect();
|
|||
|
|
assert_eq!(keys1, keys2);
|
|||
|
|
// Sorted asc
|
|||
|
|
for w in keys1.windows(2) {
|
|||
|
|
assert!(w[0] <= w[1]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- required_vdf_length / Adaptive VDF ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn required_vdf_length_no_active_returns_tau2() {
|
|||
|
|
// Genesis edge case: no active nodes
|
|||
|
|
assert_eq!(required_vdf_length(5, 0, 20_160), 20_160);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn required_vdf_length_below_threshold_returns_tau2() {
|
|||
|
|
// pressure_permille ≤ 10 (1%) → base τ₂
|
|||
|
|
// 5 pending / 1000 active = 5 permille (= 0.5%, < 1%)
|
|||
|
|
assert_eq!(required_vdf_length(5, 1000, 20_160), 20_160);
|
|||
|
|
// 10 pending / 1000 active = 10 permille = 1% (boundary, ≤ 10)
|
|||
|
|
assert_eq!(required_vdf_length(10, 1000, 20_160), 20_160);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn required_vdf_length_above_threshold_scales() {
|
|||
|
|
// 20 pending / 1000 active = 20 permille (2%, > 10)
|
|||
|
|
// → required = τ₂ × 20 / 10 = τ₂ × 2 = 40_320
|
|||
|
|
assert_eq!(required_vdf_length(20, 1000, 20_160), 40_320);
|
|||
|
|
// 100 pending / 1000 active = 100 permille = 10%
|
|||
|
|
// → required = τ₂ × 100 / 10 = τ₂ × 10
|
|||
|
|
assert_eq!(required_vdf_length(100, 1000, 20_160), 201_600);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- nr_sort_key ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn nr_sort_key_deterministic() {
|
|||
|
|
let t_r = [0x11; 32];
|
|||
|
|
let cba = [0x22; 32];
|
|||
|
|
let pubkey = [0x33; PUBLIC_KEY_SIZE];
|
|||
|
|
assert_eq!(
|
|||
|
|
nr_sort_key(&t_r, &cba, &pubkey),
|
|||
|
|
nr_sort_key(&t_r, &cba, &pubkey)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn nr_sort_key_changes_on_each_input() {
|
|||
|
|
let t_r = [0x11; 32];
|
|||
|
|
let cba = [0x22; 32];
|
|||
|
|
let pubkey = [0x33; PUBLIC_KEY_SIZE];
|
|||
|
|
let base = nr_sort_key(&t_r, &cba, &pubkey);
|
|||
|
|
assert_ne!(base, nr_sort_key(&[0xFF; 32], &cba, &pubkey));
|
|||
|
|
assert_ne!(base, nr_sort_key(&t_r, &[0xFF; 32], &pubkey));
|
|||
|
|
assert_ne!(base, nr_sort_key(&t_r, &cba, &[0xFF; PUBLIC_KEY_SIZE]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Cross-distinctness of similar hash compositions ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn distinct_domains_for_three_hash_compositions() {
|
|||
|
|
// selection_sort_key, candidate_vdf_init, nr_sort_key все принимают (timechain, cba, identity).
|
|||
|
|
// Domain separators различают:
|
|||
|
|
// selection_sort_key: "mt-selection"
|
|||
|
|
// candidate_vdf_init: "mt-candidate-vdf-init"
|
|||
|
|
// nr_sort_key: "mt-nodereg-sort"
|
|||
|
|
let t_r = [0x11; 32];
|
|||
|
|
let cba = [0x22; 32];
|
|||
|
|
let node_id: NodeId = [0x33; 32];
|
|||
|
|
let pubkey = [0x33; PUBLIC_KEY_SIZE];
|
|||
|
|
|
|||
|
|
let sel = selection_sort_key(&t_r, &cba, &node_id);
|
|||
|
|
let vdf = candidate_vdf_init(&t_r, &cba, &node_id);
|
|||
|
|
let nr = nr_sort_key(&t_r, &cba, &pubkey);
|
|||
|
|
|
|||
|
|
assert_ne!(
|
|||
|
|
sel, vdf,
|
|||
|
|
"selection vs candidate-vdf-init domains must differ"
|
|||
|
|
);
|
|||
|
|
assert_ne!(
|
|||
|
|
vdf, nr,
|
|||
|
|
"candidate-vdf-init vs nodereg-sort domains must differ"
|
|||
|
|
);
|
|||
|
|
assert_ne!(sel, nr, "selection vs nodereg-sort domains must differ");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Static API invariants ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn record_size_positive() {
|
|||
|
|
const _: () = assert!(NODE_REGISTRATION_SIZE > 0);
|
|||
|
|
const _: () = assert!(NODE_REGISTRATION_SIZE == 5344);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn admission_divisor_one_percent_cap() {
|
|||
|
|
// 1% / 100 = 1/100; admission_divisor = 130 даёт ≤ ~0.77% per event
|
|||
|
|
// (130 ≥ 100 чтобы slots = 1% или меньше). [C-1] SSOT: значение читается
|
|||
|
|
// из ProtocolParams, не hardcoded const.
|
|||
|
|
let p = mt_genesis::genesis_params();
|
|||
|
|
assert!(p.admission_divisor >= 100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn selection_interval_factors_into_tau2() {
|
|||
|
|
// selection_interval = 336 ≤ τ₂ = 20_160 (60×336 = 20_160). [C-1] SSOT:
|
|||
|
|
// значение читается из ProtocolParams, не hardcoded const.
|
|||
|
|
let p = mt_genesis::genesis_params();
|
|||
|
|
assert!(p.selection_interval < p.tau2_windows);
|
|||
|
|
assert_eq!(p.tau2_windows % p.selection_interval, 0);
|
|||
|
|
}
|