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

417 lines
12 KiB
Rust
Raw Normal View History

// Automated determinism invariants для mt-consensus.
// M4 audit prep — proposal header / canonical proposer / control_set /
// canonical acceptance / finalization. Любое non-determinism = consensus fork.
// Invariants ловят regression если refactor ломает byte-exact encode header,
// proposal_hash R2, canonical_proposer ordering, control_set sort, или
// quorum threshold.
use mt_codec::CanonicalEncode;
use mt_consensus::{
canonical_proposer, compute_control_set, fallback_proposer, finalization_status,
leader_penalty_excluded_node, proposal_hash, validate_bundles_threshold,
validate_included_reveals, validate_proposer_is_canonical, validate_winner, AcceptanceError,
ControlObjectRef, ControlSetError, FinalizationStatus, HeaderError, ProposalHeader,
PROPOSAL_HEADER_SIZE,
};
use mt_crypto::{Signature, SIGNATURE_SIZE};
use mt_lottery::{Candidate, WINNER_CLASS_NODE};
use mt_state::NodeId;
// ---------- Helpers ----------
fn sample_header(window_index: u64, proposer_node_id: NodeId) -> ProposalHeader {
ProposalHeader {
prev_proposal_hash: [0x01; 32],
window_index,
protocol_version: 1,
control_root: [0x02; 32],
node_root: [0x03; 32],
candidate_root: [0x04; 32],
account_root: [0x05; 32],
state_root: [0x06; 32],
timechain_value: [0x07; 32],
included_bundles_root: [0x08; 32],
included_reveals_root: [0x09; 32],
winner_endpoint: [0x0A; 32],
winner_id: [0x0B; 32],
proposer_node_id,
target: 100u128,
fallback_depth: 1,
signature: Signature::from_array([0u8; SIGNATURE_SIZE]),
}
}
// ---------- Encoded size matches constant ([I-9] determinism) ----------
#[test]
fn proposal_header_encoded_size_constant() {
let h = sample_header(100, [0x42; 32]);
let mut buf = Vec::new();
h.encode(&mut buf);
assert_eq!(
buf.len(),
PROPOSAL_HEADER_SIZE,
"ProposalHeader encoded size drift: expected {}, got {}",
PROPOSAL_HEADER_SIZE,
buf.len()
);
}
// ---------- proposal_hash R2 invariant ----------
#[test]
fn proposal_hash_deterministic() {
let h = sample_header(100, [0x42; 32]);
assert_eq!(proposal_hash(&h), proposal_hash(&h));
}
#[test]
fn proposal_hash_stable_under_signature_mutation() {
// R2: signature НЕ входит в hash. Защита от scheme-specific signature randomness.
let mut a = sample_header(100, [0x42; 32]);
let mut b = a.clone();
b.signature = Signature::from_array([0xFF; SIGNATURE_SIZE]);
assert_eq!(proposal_hash(&a), proposal_hash(&b));
a.signature = Signature::from_array([0x99; SIGNATURE_SIZE]);
assert_eq!(proposal_hash(&a), proposal_hash(&b));
}
#[test]
fn proposal_hash_changes_on_field_mutation() {
let base = sample_header(100, [0x42; 32]);
let m_window = sample_header(101, [0x42; 32]);
let m_proposer = sample_header(100, [0xFF; 32]);
let m_state = {
let mut x = base.clone();
x.state_root = [0xFF; 32];
x
};
let m_target = {
let mut x = base.clone();
x.target = 99999u128;
x
};
let m_fallback = {
let mut x = base.clone();
x.fallback_depth = 5;
x
};
assert_ne!(proposal_hash(&base), proposal_hash(&m_window));
assert_ne!(proposal_hash(&base), proposal_hash(&m_proposer));
assert_ne!(proposal_hash(&base), proposal_hash(&m_state));
assert_ne!(proposal_hash(&base), proposal_hash(&m_target));
assert_ne!(proposal_hash(&base), proposal_hash(&m_fallback));
}
// ---------- canonical_proposer / fallback_proposer (Lookback Leadership) ----------
#[test]
fn canonical_proposer_genesis_bootstrap_window_zero_one() {
let bootstrap: NodeId = [0x77; 32];
let candidates: Vec<Candidate> = vec![Candidate {
ticket: 100,
class: WINNER_CLASS_NODE,
id: [0x01; 32],
}];
// window 0 и 1 — bootstrap (genesis bootstrap)
assert_eq!(canonical_proposer(0, bootstrap, &candidates), bootstrap);
assert_eq!(canonical_proposer(1, bootstrap, &candidates), bootstrap);
}
#[test]
fn canonical_proposer_picks_first_node_candidate() {
let bootstrap: NodeId = [0x77; 32];
let c1 = Candidate {
ticket: 100,
class: WINNER_CLASS_NODE,
id: [0x01; 32],
};
let c2 = Candidate {
ticket: 200,
class: WINNER_CLASS_NODE,
id: [0x02; 32],
};
let proposer = canonical_proposer(2, bootstrap, &[c1, c2]);
assert_eq!(proposer, c1.id);
}
#[test]
fn canonical_proposer_no_candidates_extended_genesis_bootstrap() {
let bootstrap: NodeId = [0x77; 32];
let empty: Vec<Candidate> = vec![];
// No node candidates → bootstrap fallback (extended genesis behavior)
assert_eq!(canonical_proposer(5, bootstrap, &empty), bootstrap);
}
#[test]
fn fallback_proposer_depth_1_equals_canonical() {
let bootstrap: NodeId = [0x77; 32];
let c1 = Candidate {
ticket: 100,
class: WINNER_CLASS_NODE,
id: [0x01; 32],
};
let c2 = Candidate {
ticket: 200,
class: WINNER_CLASS_NODE,
id: [0x02; 32],
};
let canonical = canonical_proposer(2, bootstrap, &[c1, c2]);
let fallback_1 = fallback_proposer(2, bootstrap, &[c1, c2], 1);
assert_eq!(canonical, fallback_1);
}
#[test]
fn fallback_proposer_depth_2_picks_second() {
let bootstrap: NodeId = [0x77; 32];
let c1 = Candidate {
ticket: 100,
class: WINNER_CLASS_NODE,
id: [0x01; 32],
};
let c2 = Candidate {
ticket: 200,
class: WINNER_CLASS_NODE,
id: [0x02; 32],
};
let fallback_2 = fallback_proposer(2, bootstrap, &[c1, c2], 2);
assert_eq!(fallback_2, c2.id);
}
#[test]
fn fallback_proposer_exhausted_cascade_falls_back_to_bootstrap() {
let bootstrap: NodeId = [0x77; 32];
let c1 = Candidate {
ticket: 100,
class: WINNER_CLASS_NODE,
id: [0x01; 32],
};
// Только 1 candidate, request fallback_depth 5 — cascade exhausted.
let proposer = fallback_proposer(5, bootstrap, &[c1], 5);
assert_eq!(proposer, bootstrap);
}
// ---------- compute_control_set canonical sort ----------
#[test]
fn compute_control_set_filter_by_window() {
let all = vec![
ControlObjectRef {
op_hash: [0x01; 32],
cemented_window: 5,
},
ControlObjectRef {
op_hash: [0x02; 32],
cemented_window: 10,
},
ControlObjectRef {
op_hash: [0x03; 32],
cemented_window: 15,
},
ControlObjectRef {
op_hash: [0x04; 32],
cemented_window: 20,
},
];
let result = compute_control_set(&all, 5, 15);
// window > 5 AND window <= 15 → [10, 15]
assert_eq!(result.len(), 2);
assert_eq!(result[0].cemented_window, 10);
assert_eq!(result[1].cemented_window, 15);
}
#[test]
fn compute_control_set_sort_canonical() {
// Same window — sort by op_hash lex asc
let all = vec![
ControlObjectRef {
op_hash: [0xFF; 32],
cemented_window: 10,
},
ControlObjectRef {
op_hash: [0x01; 32],
cemented_window: 10,
},
ControlObjectRef {
op_hash: [0x80; 32],
cemented_window: 10,
},
];
let result = compute_control_set(&all, 5, 15);
assert_eq!(result.len(), 3);
assert_eq!(result[0].op_hash, [0x01; 32]);
assert_eq!(result[1].op_hash, [0x80; 32]);
assert_eq!(result[2].op_hash, [0xFF; 32]);
}
#[test]
fn compute_control_set_input_order_independent() {
let a = vec![
ControlObjectRef {
op_hash: [0x01; 32],
cemented_window: 10,
},
ControlObjectRef {
op_hash: [0x02; 32],
cemented_window: 12,
},
ControlObjectRef {
op_hash: [0x03; 32],
cemented_window: 14,
},
];
let mut b = a.clone();
b.reverse();
assert_eq!(
compute_control_set(&a, 5, 15),
compute_control_set(&b, 5, 15)
);
}
// ---------- validate_proposer_is_canonical ----------
#[test]
fn validate_proposer_canonical_pass_at_depth_1() {
let bootstrap: NodeId = [0x77; 32];
let c1 = Candidate {
ticket: 100,
class: WINNER_CLASS_NODE,
id: [0x01; 32],
};
let header = sample_header(2, c1.id);
let result = validate_proposer_is_canonical(&header, bootstrap, &[c1]);
assert!(result.is_ok());
}
#[test]
fn validate_proposer_canonical_fail_on_wrong_proposer() {
let bootstrap: NodeId = [0x77; 32];
let c1 = Candidate {
ticket: 100,
class: WINNER_CLASS_NODE,
id: [0x01; 32],
};
// Header заявляет proposer = [0xAA; 32] но canonical = c1.id
let header = sample_header(2, [0xAA; 32]);
let result = validate_proposer_is_canonical(&header, bootstrap, &[c1]);
assert!(matches!(result, Err(AcceptanceError::ProposerNotCanonical)));
}
// ---------- validate_bundles_threshold ----------
#[test]
fn validate_bundles_threshold_ok_at_quorum() {
// active = 100, quorum = 67; cemented_sum = 67 → cemented
assert!(validate_bundles_threshold(67, 100).is_ok());
assert!(validate_bundles_threshold(100, 100).is_ok());
}
#[test]
fn validate_bundles_threshold_below_quorum() {
assert!(matches!(
validate_bundles_threshold(66, 100),
Err(AcceptanceError::InsufficientBundles)
));
}
// ---------- validate_included_reveals byte-exact equality ----------
#[test]
fn validate_included_reveals_equal_passes() {
let reveals = vec![[0x01; 32], [0x02; 32], [0x03; 32]];
assert!(validate_included_reveals(&reveals, &reveals).is_ok());
}
#[test]
fn validate_included_reveals_mismatch() {
let proposer = vec![[0x01; 32], [0x02; 32]];
let cemented = vec![[0x01; 32], [0x03; 32]];
assert!(matches!(
validate_included_reveals(&proposer, &cemented),
Err(AcceptanceError::IncludedRevealsMismatch)
));
}
// ---------- validate_winner argmin canonical ----------
#[test]
fn validate_winner_correct_argmin() {
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 mut header = sample_header(100, [0x77; 32]);
header.winner_id = c2.id; // argmin = c2 (ticket 100)
let result = validate_winner(&header, &[c1, c2]);
assert!(result.is_ok());
}
#[test]
fn validate_winner_wrong_winner_id() {
let c1 = Candidate {
ticket: 100,
class: WINNER_CLASS_NODE,
id: [0x02; 32],
};
let mut header = sample_header(100, [0x77; 32]);
header.winner_id = [0xFF; 32]; // wrong
assert!(matches!(
validate_winner(&header, &[c1]),
Err(AcceptanceError::WrongWinner)
));
}
#[test]
fn validate_winner_empty_candidates_rejected() {
let header = sample_header(100, [0x77; 32]);
assert!(matches!(
validate_winner(&header, &[]),
Err(AcceptanceError::WrongWinner)
));
}
// ---------- finalization_status ----------
#[test]
fn finalization_status_cemented_at_quorum() {
assert_eq!(finalization_status(67, 100), FinalizationStatus::Cemented);
}
#[test]
fn finalization_status_rejected_below_quorum() {
assert_eq!(finalization_status(66, 100), FinalizationStatus::Rejected);
}
// ---------- leader_penalty_excluded_node ----------
#[test]
fn leader_penalty_returns_proposer_node_id() {
let proposer: NodeId = [0xAB; 32];
let header = sample_header(100, proposer);
assert_eq!(leader_penalty_excluded_node(&header), proposer);
}
// ---------- Static API invariants ----------
#[test]
fn proposal_header_size_constant_positive() {
const _: () = assert!(PROPOSAL_HEADER_SIZE > 0);
const _: () = assert!(PROPOSAL_HEADER_SIZE == 3722);
}
#[test]
fn header_error_acceptance_error_variants_compile() {
// Compile-time verification of error enum surface (regression detection)
let _: HeaderError = HeaderError::FallbackDepthZero;
let _: HeaderError = HeaderError::WindowNotMonotone;
let _: AcceptanceError = AcceptanceError::ProposerNotCanonical;
let _: AcceptanceError = AcceptanceError::WrongWinner;
let _: ControlSetError = ControlSetError::Mismatch;
}