417 lines
12 KiB
Rust
417 lines
12 KiB
Rust
|
|
// 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;
|
|||
|
|
}
|