338 lines
9.1 KiB
Rust
338 lines
9.1 KiB
Rust
|
|
// Automated determinism invariants для mt-state.
|
|||
|
|
// M2 batch 2 audit prep — state layer = consensus state foundation;
|
|||
|
|
// любое non-determinism = consensus fork. Эти invariants ловят regression
|
|||
|
|
// если refactor случайно ломает determinism / SSOT / сериализацию.
|
|||
|
|
|
|||
|
|
use mt_codec::CanonicalEncode;
|
|||
|
|
use mt_crypto::PUBLIC_KEY_SIZE;
|
|||
|
|
use mt_state::{
|
|||
|
|
compute_state_root, derive_account_id, derive_node_id, is_active, AccountRecord, AccountTable,
|
|||
|
|
CandidatePool, CandidateRecord, NodeRecord, NodeTable, ACCOUNT_RECORD_SIZE,
|
|||
|
|
CANDIDATE_RECORD_SIZE, NODE_RECORD_SIZE, WINNER_CLASS_NODE,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
fn sample_account(id_byte: u8) -> AccountRecord {
|
|||
|
|
AccountRecord {
|
|||
|
|
account_id: [id_byte; 32],
|
|||
|
|
balance: 1_000_000_000_000u128,
|
|||
|
|
suite_id: 1,
|
|||
|
|
is_node_operator: false,
|
|||
|
|
frontier_hash: [0xBB; 32],
|
|||
|
|
op_height: 5,
|
|||
|
|
account_chain_length: 10,
|
|||
|
|
account_chain_length_snapshot: 9,
|
|||
|
|
current_pubkey: [0xCC; PUBLIC_KEY_SIZE],
|
|||
|
|
creation_window: 100,
|
|||
|
|
last_op_window: 200,
|
|||
|
|
last_activation_window: 0,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn sample_node(id_byte: u8) -> NodeRecord {
|
|||
|
|
NodeRecord {
|
|||
|
|
node_id: [id_byte; 32],
|
|||
|
|
node_pubkey: [0x33; PUBLIC_KEY_SIZE],
|
|||
|
|
suite_id: 1,
|
|||
|
|
operator_account_id: [0x77; 32],
|
|||
|
|
start_window: 1000,
|
|||
|
|
chain_length: 42,
|
|||
|
|
chain_length_snapshot: 40,
|
|||
|
|
chain_length_checkpoints: [0u64; 6],
|
|||
|
|
last_confirmation_window: 5000,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn sample_candidate(id_byte: u8) -> CandidateRecord {
|
|||
|
|
CandidateRecord {
|
|||
|
|
node_id: [id_byte; 32],
|
|||
|
|
node_pubkey: [0x44; PUBLIC_KEY_SIZE],
|
|||
|
|
suite_id: 1,
|
|||
|
|
operator_account_id: [0x55; 32],
|
|||
|
|
proof_endpoint: [0x66; 32],
|
|||
|
|
w_start: 500,
|
|||
|
|
vdf_chain_length: 100,
|
|||
|
|
registration_window: 500,
|
|||
|
|
expires: 5000,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Derivation determinism ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn derive_account_id_deterministic() {
|
|||
|
|
let pk = [0xAAu8; PUBLIC_KEY_SIZE];
|
|||
|
|
let a = derive_account_id(1, &pk);
|
|||
|
|
let b = derive_account_id(1, &pk);
|
|||
|
|
assert_eq!(a, b);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn derive_account_id_changes_on_suite_id() {
|
|||
|
|
let pk = [0xAAu8; PUBLIC_KEY_SIZE];
|
|||
|
|
let a = derive_account_id(1, &pk);
|
|||
|
|
let b = derive_account_id(2, &pk);
|
|||
|
|
assert_ne!(a, b, "suite_id должен влиять на account_id");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn derive_account_id_changes_on_pubkey() {
|
|||
|
|
let a = derive_account_id(1, &[0xAA; PUBLIC_KEY_SIZE]);
|
|||
|
|
let b = derive_account_id(1, &[0xBB; PUBLIC_KEY_SIZE]);
|
|||
|
|
assert_ne!(a, b);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn derive_node_id_deterministic() {
|
|||
|
|
let pk = [0xCCu8; PUBLIC_KEY_SIZE];
|
|||
|
|
let a = derive_node_id(&pk);
|
|||
|
|
let b = derive_node_id(&pk);
|
|||
|
|
assert_eq!(a, b);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn derive_account_node_id_different() {
|
|||
|
|
// Same pubkey → different account_id и node_id (разные domain separators).
|
|||
|
|
let pk = [0xDD; PUBLIC_KEY_SIZE];
|
|||
|
|
let aid = derive_account_id(1, &pk);
|
|||
|
|
let nid = derive_node_id(&pk);
|
|||
|
|
assert_ne!(
|
|||
|
|
aid, nid,
|
|||
|
|
"account_id и node_id должны различаться даже от того же pubkey"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Encoded sizes match constants ([I-9] determinism) ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn account_record_encoded_size_constant() {
|
|||
|
|
let mut buf = Vec::new();
|
|||
|
|
sample_account(0xAA).encode(&mut buf);
|
|||
|
|
assert_eq!(
|
|||
|
|
buf.len(),
|
|||
|
|
ACCOUNT_RECORD_SIZE,
|
|||
|
|
"AccountRecord encoded size drift: expected {}, got {}",
|
|||
|
|
ACCOUNT_RECORD_SIZE,
|
|||
|
|
buf.len()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn node_record_encoded_size_constant() {
|
|||
|
|
let mut buf = Vec::new();
|
|||
|
|
sample_node(0x11).encode(&mut buf);
|
|||
|
|
assert_eq!(
|
|||
|
|
buf.len(),
|
|||
|
|
NODE_RECORD_SIZE,
|
|||
|
|
"NodeRecord encoded size drift: expected {}, got {}",
|
|||
|
|
NODE_RECORD_SIZE,
|
|||
|
|
buf.len()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn candidate_record_encoded_size_constant() {
|
|||
|
|
let mut buf = Vec::new();
|
|||
|
|
sample_candidate(0x22).encode(&mut buf);
|
|||
|
|
assert_eq!(
|
|||
|
|
buf.len(),
|
|||
|
|
CANDIDATE_RECORD_SIZE,
|
|||
|
|
"CandidateRecord encoded size drift: expected {}, got {}",
|
|||
|
|
CANDIDATE_RECORD_SIZE,
|
|||
|
|
buf.len()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Encoding determinism ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn account_record_encoding_deterministic() {
|
|||
|
|
let r = sample_account(0xCC);
|
|||
|
|
let mut a = Vec::new();
|
|||
|
|
let mut b = Vec::new();
|
|||
|
|
r.encode(&mut a);
|
|||
|
|
r.encode(&mut b);
|
|||
|
|
assert_eq!(a, b);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Table determinism (BTreeMap canonical sort) ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn account_table_root_deterministic_across_insertion_orders() {
|
|||
|
|
let r1 = sample_account(0x01);
|
|||
|
|
let r2 = sample_account(0x02);
|
|||
|
|
let r3 = sample_account(0x03);
|
|||
|
|
|
|||
|
|
let mut t_forward = AccountTable::new();
|
|||
|
|
t_forward.insert(r1.clone());
|
|||
|
|
t_forward.insert(r2.clone());
|
|||
|
|
t_forward.insert(r3.clone());
|
|||
|
|
|
|||
|
|
let mut t_reverse = AccountTable::new();
|
|||
|
|
t_reverse.insert(r3.clone());
|
|||
|
|
t_reverse.insert(r2.clone());
|
|||
|
|
t_reverse.insert(r1.clone());
|
|||
|
|
|
|||
|
|
assert_eq!(
|
|||
|
|
t_forward.root(),
|
|||
|
|
t_reverse.root(),
|
|||
|
|
"AccountTable root зависит от insertion order — нарушение [I-3] determinism"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn node_table_root_deterministic_across_insertion_orders() {
|
|||
|
|
let n1 = sample_node(0x10);
|
|||
|
|
let n2 = sample_node(0x20);
|
|||
|
|
let n3 = sample_node(0x30);
|
|||
|
|
|
|||
|
|
let mut t1 = NodeTable::new();
|
|||
|
|
t1.insert(n1.clone());
|
|||
|
|
t1.insert(n2.clone());
|
|||
|
|
t1.insert(n3.clone());
|
|||
|
|
|
|||
|
|
let mut t2 = NodeTable::new();
|
|||
|
|
t2.insert(n3.clone());
|
|||
|
|
t2.insert(n1.clone());
|
|||
|
|
t2.insert(n2.clone());
|
|||
|
|
|
|||
|
|
assert_eq!(t1.root(), t2.root());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn candidate_pool_root_deterministic_across_insertion_orders() {
|
|||
|
|
let c1 = sample_candidate(0x40);
|
|||
|
|
let c2 = sample_candidate(0x50);
|
|||
|
|
|
|||
|
|
let mut p1 = CandidatePool::new();
|
|||
|
|
p1.insert(c1.clone());
|
|||
|
|
p1.insert(c2.clone());
|
|||
|
|
|
|||
|
|
let mut p2 = CandidatePool::new();
|
|||
|
|
p2.insert(c2.clone());
|
|||
|
|
p2.insert(c1.clone());
|
|||
|
|
|
|||
|
|
assert_eq!(p1.root(), p2.root());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- compute_state_root determinism + dependence ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn compute_state_root_deterministic() {
|
|||
|
|
let n = [0x11u8; 32];
|
|||
|
|
let c = [0x22u8; 32];
|
|||
|
|
let a = [0x33u8; 32];
|
|||
|
|
assert_eq!(
|
|||
|
|
compute_state_root(&n, &c, &a),
|
|||
|
|
compute_state_root(&n, &c, &a)
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn compute_state_root_changes_on_each_root() {
|
|||
|
|
let n = [0x11u8; 32];
|
|||
|
|
let c = [0x22u8; 32];
|
|||
|
|
let a = [0x33u8; 32];
|
|||
|
|
let base = compute_state_root(&n, &c, &a);
|
|||
|
|
|
|||
|
|
assert_ne!(base, compute_state_root(&[0x00u8; 32], &c, &a));
|
|||
|
|
assert_ne!(base, compute_state_root(&n, &[0x00u8; 32], &a));
|
|||
|
|
assert_ne!(base, compute_state_root(&n, &c, &[0x00u8; 32]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn compute_state_root_order_sensitive() {
|
|||
|
|
// Order аргументов влияет — node_root vs candidate_root vs account_root
|
|||
|
|
// нельзя поменять местами.
|
|||
|
|
let a = [0x11u8; 32];
|
|||
|
|
let b = [0x22u8; 32];
|
|||
|
|
let c = [0x33u8; 32];
|
|||
|
|
assert_ne!(
|
|||
|
|
compute_state_root(&a, &b, &c),
|
|||
|
|
compute_state_root(&b, &a, &c),
|
|||
|
|
"compute_state_root должен быть order-sensitive"
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- is_active boundary ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn is_active_window_zero_correct_with_saturating_sub() {
|
|||
|
|
// current_window < last_confirmation_window → saturating_sub = 0 → active
|
|||
|
|
let n = NodeRecord {
|
|||
|
|
last_confirmation_window: 100,
|
|||
|
|
..sample_node(0x99)
|
|||
|
|
};
|
|||
|
|
assert!(is_active(&n, 50, 1000));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn is_active_at_2_tau2_boundary_inclusive() {
|
|||
|
|
let n = NodeRecord {
|
|||
|
|
last_confirmation_window: 1000,
|
|||
|
|
..sample_node(0x88)
|
|||
|
|
};
|
|||
|
|
let tau2 = 100;
|
|||
|
|
// current - last = 200 = 2×τ₂ — inclusive, active
|
|||
|
|
assert!(is_active(&n, 1200, tau2));
|
|||
|
|
// current - last = 201 — beyond, inactive
|
|||
|
|
assert!(!is_active(&n, 1201, tau2));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- Static API invariants ----------
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn winner_class_node_is_one() {
|
|||
|
|
// SSOT [I-10]: WINNER_CLASS_NODE = 1 константа.
|
|||
|
|
// Если кто-то случайно меняет — этот test fails.
|
|||
|
|
assert_eq!(WINNER_CLASS_NODE, 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn record_sizes_positive() {
|
|||
|
|
// Compile-time const checks — defensive sanity, не runtime check.
|
|||
|
|
const _: () = assert!(ACCOUNT_RECORD_SIZE > 0);
|
|||
|
|
const _: () = assert!(NODE_RECORD_SIZE > 0);
|
|||
|
|
const _: () = assert!(CANDIDATE_RECORD_SIZE > 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn empty_tables_have_consistent_empty_root() {
|
|||
|
|
let a = AccountTable::new();
|
|||
|
|
let b = AccountTable::new();
|
|||
|
|
assert_eq!(
|
|||
|
|
a.root(),
|
|||
|
|
b.root(),
|
|||
|
|
"Empty AccountTable roots должны совпадать"
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
let n1 = NodeTable::new();
|
|||
|
|
let n2 = NodeTable::new();
|
|||
|
|
assert_eq!(n1.root(), n2.root());
|
|||
|
|
|
|||
|
|
let c1 = CandidatePool::new();
|
|||
|
|
let c2 = CandidatePool::new();
|
|||
|
|
assert_eq!(c1.root(), c2.root());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#[test]
|
|||
|
|
fn account_table_remove_inverse_of_insert() {
|
|||
|
|
let r = sample_account(0xEE);
|
|||
|
|
let id = r.account_id;
|
|||
|
|
|
|||
|
|
let mut t = AccountTable::new();
|
|||
|
|
let empty_root = t.root();
|
|||
|
|
|
|||
|
|
t.insert(r);
|
|||
|
|
assert!(t.contains(&id));
|
|||
|
|
let after_insert_root = t.root();
|
|||
|
|
assert_ne!(empty_root, after_insert_root);
|
|||
|
|
|
|||
|
|
t.remove(&id);
|
|||
|
|
assert!(!t.contains(&id));
|
|||
|
|
assert_eq!(
|
|||
|
|
t.root(),
|
|||
|
|
empty_root,
|
|||
|
|
"Insert + remove должно возвращать root к empty state"
|
|||
|
|
);
|
|||
|
|
}
|