From ec3fb62baf3dc5ee0afc69dab629dae1d2ceef46 Mon Sep 17 00:00:00 2001 From: Youngjoon Lee Date: Thu, 22 Jun 2023 15:15:38 +0900 Subject: [PATCH] refactor(fuzz): modularize fuzz testing (#211) --- consensus-engine/src/lib.rs | 37 +- consensus-engine/tests/fuzz/mod.rs | 3 + consensus-engine/tests/fuzz/ref_state.rs | 319 +++++++++++++++ consensus-engine/tests/fuzz/sut.rs | 132 ++++++ consensus-engine/tests/fuzz/transition.rs | 16 + consensus-engine/tests/fuzz_test.rs | 464 +--------------------- 6 files changed, 495 insertions(+), 476 deletions(-) create mode 100644 consensus-engine/tests/fuzz/mod.rs create mode 100644 consensus-engine/tests/fuzz/ref_state.rs create mode 100644 consensus-engine/tests/fuzz/sut.rs create mode 100644 consensus-engine/tests/fuzz/transition.rs diff --git a/consensus-engine/src/lib.rs b/consensus-engine/src/lib.rs index 4d38db68..246ea521 100644 --- a/consensus-engine/src/lib.rs +++ b/consensus-engine/src/lib.rs @@ -416,7 +416,7 @@ mod test { let mut next_id = block.id; next_id[0] += 1; - return Block { + Block { view: block.view + 1, id: next_id, parent_qc: Qc::Standard(StandardQc { @@ -424,7 +424,7 @@ mod test { id: block.id, }), leader_proof: LeaderProof::LeaderId { leader_id: [0; 32] }, - }; + } } #[test] @@ -463,8 +463,8 @@ mod test { let mut block2 = next_block(&block1); block2.id = block1.id; - engine = engine.receive_block(block2.clone()).unwrap(); - assert_eq!(engine.blocks_in_view(1), vec![block1.clone()]); + engine = engine.receive_block(block2).unwrap(); + assert_eq!(engine.blocks_in_view(1), vec![block1]); } #[test] @@ -484,7 +484,7 @@ mod test { leader_proof: LeaderProof::LeaderId { leader_id: [0; 32] }, }; - let _ = engine.receive_block(block.clone()); + let _ = engine.receive_block(block); } #[test] @@ -518,6 +518,7 @@ mod test { let block2 = next_block(&block1); engine = engine.receive_block(block2.clone()).unwrap(); + #[allow(clippy::redundant_clone)] let mut block3 = block2.clone(); block3.id = [3; 32]; // use a new ID, so that this block isn't ignored engine = engine.receive_block(block3.clone()).unwrap(); @@ -554,7 +555,7 @@ mod test { ); let block4 = next_block(&block3); - engine = engine.receive_block(block4.clone()).unwrap(); + engine = engine.receive_block(block4).unwrap(); assert_eq!(engine.latest_committed_block(), block2); assert_eq!( engine.committed_blocks(), @@ -589,7 +590,7 @@ mod test { let engine = init_from_genesis(); let block = next_block(&engine.genesis_block()); - let _ = engine.approve_block(block.clone()); + let _ = engine.approve_block(block); } #[test] @@ -611,7 +612,7 @@ mod test { fn local_timeout() { let mut engine = init_from_genesis(); let block = next_block(&engine.genesis_block()); - engine = engine.receive_block(block.clone()).unwrap(); // received but not approved yet + engine = engine.receive_block(block).unwrap(); // received but not approved yet let (engine, send) = engine.local_timeout(); assert_eq!(engine.highest_voted_view, 1); // updated from 0 (genesis) to 1 (current_view) @@ -637,7 +638,7 @@ mod test { fn receive_timeout_qc_after_local_timeout() { let mut engine = init_from_genesis(); let block = next_block(&engine.genesis_block()); - engine = engine.receive_block(block.clone()).unwrap(); // received but not approved yet + engine = engine.receive_block(block).unwrap(); // received but not approved yet let (mut engine, _) = engine.local_timeout(); @@ -652,7 +653,7 @@ mod test { }; engine = engine.receive_timeout_qc(timeout_qc.clone()); assert_eq!(engine.local_high_qc, timeout_qc.high_qc); - assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc.clone())); + assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc)); assert_eq!(engine.current_view(), 2); } @@ -661,7 +662,7 @@ mod test { fn receive_timeout_qc_before_local_timeout() { let mut engine = init_from_genesis(); let block = next_block(&engine.genesis_block()); - engine = engine.receive_block(block.clone()).unwrap(); // received but not approved yet + engine = engine.receive_block(block).unwrap(); // received but not approved yet // before local_timeout occurs @@ -676,7 +677,7 @@ mod test { }; engine = engine.receive_timeout_qc(timeout_qc.clone()); assert_eq!(engine.local_high_qc, timeout_qc.high_qc); - assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc.clone())); + assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc)); assert_eq!(engine.current_view(), 2); } @@ -685,7 +686,7 @@ mod test { fn approve_new_view() { let mut engine = init_from_genesis(); let block = next_block(&engine.genesis_block()); - engine = engine.receive_block(block.clone()).unwrap(); // received but not approved yet + engine = engine.receive_block(block).unwrap(); // received but not approved yet assert_eq!(engine.current_view(), 1); // still waiting for a QC(view=1) let timeout_qc = TimeoutQc { @@ -707,12 +708,12 @@ mod test { assert_eq!(engine.current_view(), 2); // not changed assert_eq!(engine.highest_voted_view, 2); assert_eq!( - send.clone().payload, + send.payload, Payload::NewView(NewView { view: 2, sender: [0; 32], timeout_qc: timeout_qc.clone(), - high_qc: timeout_qc.clone().high_qc, + high_qc: timeout_qc.high_qc, }) ); } @@ -722,7 +723,7 @@ mod test { fn approve_new_view_not_bigger_than_timeout_qc() { let mut engine = init_from_genesis(); let block = next_block(&engine.genesis_block()); - engine = engine.receive_block(block.clone()).unwrap(); // received but not approved yet + engine = engine.receive_block(block).unwrap(); // received but not approved yet assert_eq!(engine.current_view(), 1); let timeout_qc1 = TimeoutQc { @@ -746,9 +747,9 @@ mod test { sender: [0; 32], }; engine = engine.receive_timeout_qc(timeout_qc2.clone()); - assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc2.clone())); + assert_eq!(engine.last_view_timeout_qc, Some(timeout_qc2)); // we expect new_view(timeout_qc2), but... - let _ = engine.approve_new_view(timeout_qc1.clone(), HashSet::new()); + let _ = engine.approve_new_view(timeout_qc1, HashSet::new()); } } diff --git a/consensus-engine/tests/fuzz/mod.rs b/consensus-engine/tests/fuzz/mod.rs new file mode 100644 index 00000000..e8d18602 --- /dev/null +++ b/consensus-engine/tests/fuzz/mod.rs @@ -0,0 +1,3 @@ +mod ref_state; +pub mod sut; +mod transition; diff --git a/consensus-engine/tests/fuzz/ref_state.rs b/consensus-engine/tests/fuzz/ref_state.rs new file mode 100644 index 00000000..aa207bfc --- /dev/null +++ b/consensus-engine/tests/fuzz/ref_state.rs @@ -0,0 +1,319 @@ +use std::collections::{BTreeMap, HashSet}; + +use consensus_engine::{Block, LeaderProof, NodeId, Qc, StandardQc, TimeoutQc, View}; +use proptest::prelude::*; +use proptest::strategy::BoxedStrategy; +use proptest_state_machine::ReferenceStateMachine; + +use crate::fuzz::transition::Transition; + +// A reference state machine (RefState) is used to generated state transitions. +// To generate some kinds of transition, we may need to keep historical blocks in RefState. +// Also, RefState can be used to check invariants of the real state machine in some cases. +// +// We should try to design this reference state as simple/intuitive as possible, +// so that we don't need to replicate the logic implemented in consensus-engine. +#[derive(Clone, Debug)] +pub struct RefState { + chain: BTreeMap, + highest_voted_view: View, +} + +#[derive(Clone, Debug, Default, PartialEq)] +struct ViewEntry { + blocks: HashSet, + timeout_qcs: HashSet, +} + +const LEADER_PROOF: LeaderProof = LeaderProof::LeaderId { leader_id: [0; 32] }; +const INITIAL_HIGHEST_VOTED_VIEW: View = -1; +const SENDER: NodeId = [0; 32]; + +impl ReferenceStateMachine for RefState { + type State = Self; + + type Transition = Transition; + + // Initialize the reference state machine + fn init_state() -> BoxedStrategy { + let genesis_block = Block { + view: 0, + id: [0; 32], + parent_qc: Qc::Standard(StandardQc::genesis()), + leader_proof: LEADER_PROOF.clone(), + }; + + Just(RefState { + chain: BTreeMap::from([( + genesis_block.view, + ViewEntry { + blocks: HashSet::from([genesis_block]), + timeout_qcs: Default::default(), + }, + )]), + highest_voted_view: INITIAL_HIGHEST_VOTED_VIEW, + }) + .boxed() + } + + // Generate transitions based on the current reference state machine + fn transitions(state: &Self::State) -> BoxedStrategy { + // Instead of using verbose `if` statements here to filter out the types of transitions + // which cannot be created based on the current reference state, + // each `state.transition_*` function returns a Nop transition + // if it cannot generate the promised transition for the current reference state. + // Both reference and real state machine do nothing for Nop transitions. + prop_oneof![ + state.transition_receive_safe_block(), + state.transition_receive_unsafe_block(), + state.transition_approve_block(), + state.transition_approve_past_block(), + state.transition_local_timeout(), + state.transition_receive_timeout_qc_for_current_view(), + state.transition_receive_timeout_qc_for_old_view(), + ] + .boxed() + } + + // Check if the transition is valid for a given reference state, before applying the transition + // If invalid, the transition will be ignored and a new transition will be generated. + // + // Also, preconditions are used for shrinking in failure cases. + // Preconditions check if the transition is still valid after some shrinking is applied. + // If the transition became invalid for the shrinked state, the shrinking is stopped or + // is continued to other directions. + fn preconditions(state: &Self::State, transition: &Self::Transition) -> bool { + // In most cases, we need to check the same conditions again used to create transitions. + // This is redundant for success cases, but is necessary for shrinking in failure cases, + // because some transitions may no longer be valid after some shrinking is applied. + match transition { + Transition::Nop => true, + Transition::ReceiveSafeBlock(block) => block.view >= state.current_view(), + Transition::ReceiveUnsafeBlock(block) => block.view < state.current_view(), + Transition::ApproveBlock(block) => state.highest_voted_view < block.view, + Transition::ApprovePastBlock(block) => state.highest_voted_view >= block.view, + Transition::LocalTimeout => true, + Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => { + timeout_qc.view == state.current_view() + } + Transition::ReceiveTimeoutQcForOldView(timeout_qc) => { + timeout_qc.view < state.current_view() + } + } + } + + // Apply the given transition on the reference state machine, + // so that it can be used to generate next transitions. + fn apply(mut state: Self::State, transition: &Self::Transition) -> Self::State { + match transition { + Transition::Nop => {} + Transition::ReceiveSafeBlock(block) => { + state + .chain + .entry(block.view) + .or_default() + .blocks + .insert(block.clone()); + } + Transition::ReceiveUnsafeBlock(_) => { + // Nothing to do because we expect the state doesn't change. + } + Transition::ApproveBlock(block) => { + state.highest_voted_view = block.view; + } + Transition::ApprovePastBlock(_) => { + // Nothing to do because we expect the state doesn't change. + } + Transition::LocalTimeout => { + state.highest_voted_view = state.current_view(); + } + Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => { + state + .chain + .entry(timeout_qc.view) + .or_default() + .timeout_qcs + .insert(timeout_qc.clone()); + } + Transition::ReceiveTimeoutQcForOldView(_) => { + // Nothing to do because we expect the state doesn't change. + } + } + + state + } +} + +impl RefState { + // Generate a Transition::ReceiveSafeBlock. + fn transition_receive_safe_block(&self) -> BoxedStrategy { + let recent_parents = self + .chain + .range(self.current_view() - 1..) + .flat_map(|(_view, entry)| entry.blocks.iter().cloned()) + .collect::>(); + + if recent_parents.is_empty() { + Just(Transition::Nop).boxed() + } else { + // proptest::sample::select panics if the input is empty + proptest::sample::select(recent_parents) + .prop_map(move |parent| -> Transition { + Transition::ReceiveSafeBlock(Self::consecutive_block(&parent)) + }) + .boxed() + } + } + + // Generate a Transition::ReceiveUnsafeBlock. + fn transition_receive_unsafe_block(&self) -> BoxedStrategy { + let old_parents = self + .chain + .range(..self.current_view() - 1) + .flat_map(|(_view, entry)| entry.blocks.iter().cloned()) + .collect::>(); + + if old_parents.is_empty() { + Just(Transition::Nop).boxed() + } else { + // proptest::sample::select panics if the input is empty + proptest::sample::select(old_parents) + .prop_map(move |parent| -> Transition { + Transition::ReceiveUnsafeBlock(Self::consecutive_block(&parent)) + }) + .boxed() + } + } + + // Generate a Transition::ApproveBlock. + fn transition_approve_block(&self) -> BoxedStrategy { + let blocks_not_voted = self + .chain + .range(self.highest_voted_view + 1..) + .flat_map(|(_view, entry)| entry.blocks.iter().cloned()) + .collect::>(); + + if blocks_not_voted.is_empty() { + Just(Transition::Nop).boxed() + } else { + // proptest::sample::select panics if the input is empty + proptest::sample::select(blocks_not_voted) + .prop_map(Transition::ApproveBlock) + .boxed() + } + } + + // Generate a Transition::ApprovePastBlock. + fn transition_approve_past_block(&self) -> BoxedStrategy { + let past_blocks = self + .chain + .range(INITIAL_HIGHEST_VOTED_VIEW..self.highest_voted_view) + .flat_map(|(_view, entry)| entry.blocks.iter().cloned()) + .collect::>(); + + if past_blocks.is_empty() { + Just(Transition::Nop).boxed() + } else { + // proptest::sample::select panics if the input is empty + proptest::sample::select(past_blocks) + .prop_map(Transition::ApprovePastBlock) + .boxed() + } + } + + // Generate a Transition::LocalTimeout. + fn transition_local_timeout(&self) -> BoxedStrategy { + Just(Transition::LocalTimeout).boxed() + } + + // Generate a Transition::ReceiveTimeoutQcForCurrentView + fn transition_receive_timeout_qc_for_current_view(&self) -> BoxedStrategy { + let view = self.current_view(); + let high_qc = self + .chain + .iter() + .rev() + .find_map(|(_, entry)| entry.high_qc()); + + if let Some(high_qc) = high_qc { + Just(Transition::ReceiveTimeoutQcForCurrentView(TimeoutQc { + view, + high_qc, + sender: SENDER, + })) + .boxed() + } else { + Just(Transition::Nop).boxed() + } + } + + // Generate a Transition::ReceiveTimeoutQcForOldView + fn transition_receive_timeout_qc_for_old_view(&self) -> BoxedStrategy { + let old_view_entries: Vec<(View, ViewEntry)> = self + .chain + .range(..self.current_view()) + .filter(|(_, entry)| !entry.is_empty()) + .map(|(&view, entry)| (view, entry.clone())) + .collect(); + + if old_view_entries.is_empty() { + Just(Transition::Nop).boxed() + } else { + proptest::sample::select(old_view_entries) + .prop_map(move |(view, entry)| { + Transition::ReceiveTimeoutQcForOldView(TimeoutQc { + view, + high_qc: entry.high_qc().unwrap(), + sender: SENDER, + }) + }) + .boxed() + } + } + + pub fn highest_voted_view(&self) -> View { + self.highest_voted_view + } + + pub fn current_view(&self) -> View { + let (&last_view, last_entry) = self.chain.last_key_value().unwrap(); + if last_entry.timeout_qcs.is_empty() { + last_view + } else { + let timeout_qc = last_entry.timeout_qcs.iter().next().unwrap(); + RefState::new_view_from(timeout_qc) + } + } + + pub fn new_view_from(timeout_qc: &TimeoutQc) -> View { + timeout_qc.view + 1 + } + + fn consecutive_block(parent: &Block) -> Block { + Block { + // use rand because we don't want this to be shrinked by proptest + id: rand::thread_rng().gen(), + view: parent.view + 1, + parent_qc: Qc::Standard(StandardQc { + view: parent.view, + id: parent.id, + }), + leader_proof: LEADER_PROOF.clone(), + } + } +} + +impl ViewEntry { + fn is_empty(&self) -> bool { + self.blocks.is_empty() && self.timeout_qcs.is_empty() + } + + fn high_qc(&self) -> Option { + let iter1 = self.blocks.iter().map(|block| block.parent_qc.high_qc()); + let iter2 = self + .timeout_qcs + .iter() + .map(|timeout_qc| timeout_qc.high_qc.clone()); + iter1.chain(iter2).max_by_key(|qc| qc.view) + } +} diff --git a/consensus-engine/tests/fuzz/sut.rs b/consensus-engine/tests/fuzz/sut.rs new file mode 100644 index 00000000..6cae6a87 --- /dev/null +++ b/consensus-engine/tests/fuzz/sut.rs @@ -0,0 +1,132 @@ +use std::panic; + +use consensus_engine::{ + overlay::{FlatOverlay, RoundRobin, Settings}, + *, +}; +use proptest_state_machine::{ReferenceStateMachine, StateMachineTest}; + +use crate::fuzz::ref_state::RefState; +use crate::fuzz::transition::Transition; + +// ConsensusEngineTest defines a state that we want to test. +// This is called as SUT (System Under Test). +#[derive(Clone, Debug)] +pub struct ConsensusEngineTest { + pub engine: Carnot>, +} + +impl ConsensusEngineTest { + pub fn new() -> Self { + let engine = Carnot::from_genesis( + [0; 32], + Block { + view: 0, + id: [0; 32], + parent_qc: Qc::Standard(StandardQc::genesis()), + leader_proof: LeaderProof::LeaderId { leader_id: [0; 32] }, + }, + FlatOverlay::new(Settings { + nodes: vec![[0; 32]], + leader: RoundRobin::default(), + }), + ); + + ConsensusEngineTest { engine } + } +} + +// StateMachineTest defines how transitions are applied to the real state machine +// and what checks should be performed. +impl StateMachineTest for ConsensusEngineTest { + // SUT is the real state machine that we want to test. + type SystemUnderTest = Self; + + // A reference state machine that should be compared against the SUT. + type Reference = RefState; + + // Initialize the SUT state + fn init_test( + _ref_state: &::State, + ) -> Self::SystemUnderTest { + ConsensusEngineTest::new() + } + + // Apply the transition on the SUT state and check post-conditions + fn apply( + state: Self::SystemUnderTest, + _ref_state: &::State, + transition: ::Transition, + ) -> Self::SystemUnderTest { + println!("{transition:?}"); + + match transition { + Transition::Nop => state, + Transition::ReceiveSafeBlock(block) => { + // Because we generate/apply transitions sequentially, + // this transition will always be applied to the same state that it was generated against. + // In other words, we can assume that the `block` is always safe for the current state. + let engine = state.engine.receive_block(block.clone()).unwrap(); + assert!(engine.blocks_in_view(block.view).contains(&block)); + + ConsensusEngineTest { engine } + } + Transition::ReceiveUnsafeBlock(block) => { + let result = panic::catch_unwind(|| state.engine.receive_block(block)); + assert!(result.is_err() || result.unwrap().is_err()); + + state + } + Transition::ApproveBlock(block) => { + let (engine, _) = state.engine.approve_block(block.clone()); + assert_eq!(engine.highest_voted_view(), block.view); + + ConsensusEngineTest { engine } + } + Transition::ApprovePastBlock(block) => { + let result = panic::catch_unwind(|| state.engine.approve_block(block)); + assert!(result.is_err()); + + state + } + Transition::LocalTimeout => { + let (engine, _) = state.engine.local_timeout(); + assert_eq!(engine.highest_voted_view(), engine.current_view()); + + ConsensusEngineTest { engine } + } + Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => { + let engine = state.engine.receive_timeout_qc(timeout_qc.clone()); + assert_eq!(engine.current_view(), RefState::new_view_from(&timeout_qc)); + + ConsensusEngineTest { engine } + } + Transition::ReceiveTimeoutQcForOldView(timeout_qc) => { + #[allow(clippy::redundant_clone)] + let prev_engine = state.engine.clone(); + let engine = state.engine.receive_timeout_qc(timeout_qc); + + // Check that the engine state didn't change. + assert_eq!(engine, prev_engine); + + ConsensusEngineTest { engine } + } + } + } + + // Check invariants after every transition. + // We have the option to use RefState for comparison in some cases. + fn check_invariants( + state: &Self::SystemUnderTest, + ref_state: &::State, + ) { + assert_eq!(state.engine.current_view(), ref_state.current_view()); + + assert_eq!( + state.engine.highest_voted_view(), + ref_state.highest_voted_view() + ); + + //TODO: add more invariants with more public functions of Carnot + } +} diff --git a/consensus-engine/tests/fuzz/transition.rs b/consensus-engine/tests/fuzz/transition.rs new file mode 100644 index 00000000..19bbaca3 --- /dev/null +++ b/consensus-engine/tests/fuzz/transition.rs @@ -0,0 +1,16 @@ +use consensus_engine::{Block, TimeoutQc}; + +// State transtitions that will be picked randomly +#[derive(Clone, Debug)] +pub enum Transition { + Nop, + ReceiveSafeBlock(Block), + ReceiveUnsafeBlock(Block), + ApproveBlock(Block), + ApprovePastBlock(Block), + LocalTimeout, + ReceiveTimeoutQcForCurrentView(TimeoutQc), + ReceiveTimeoutQcForOldView(TimeoutQc), + //TODO: add more invalid transitions that must be rejected by consensus-engine + //TODO: add more transitions +} diff --git a/consensus-engine/tests/fuzz_test.rs b/consensus-engine/tests/fuzz_test.rs index 7ff9c563..4cb10569 100644 --- a/consensus-engine/tests/fuzz_test.rs +++ b/consensus-engine/tests/fuzz_test.rs @@ -1,13 +1,12 @@ -use std::{ - collections::{BTreeMap, HashSet}, - panic, -}; +mod fuzz; + +use std::panic; -use consensus_engine::{Block, LeaderProof, NodeId, Qc, StandardQc, TimeoutQc, View}; use proptest::prelude::*; use proptest::test_runner::Config; use proptest_state_machine::{prop_state_machine, ReferenceStateMachine, StateMachineTest}; -use system_under_test::ConsensusEngineTest; + +use fuzz::sut::ConsensusEngineTest; prop_state_machine! { #![proptest_config(Config { @@ -19,456 +18,5 @@ prop_state_machine! { #[test] // run 50 state transitions per test case - fn happy_path(sequential 1..50 => ConsensusEngineTest); -} - -// A reference state machine (RefState) is used to generated state transitions. -// To generate some kinds of transition, we may need to keep historical blocks in RefState. -// Also, RefState can be used to check invariants of the real state machine in some cases. -// -// We should try to design this reference state as simple/intuitive as possible, -// so that we don't need to replicate the logic implemented in consensus-engine. -#[derive(Clone, Debug)] -pub struct RefState { - chain: BTreeMap, - highest_voted_view: View, -} - -#[derive(Clone, Debug, Default, PartialEq)] -struct ViewEntry { - blocks: HashSet, - timeout_qcs: HashSet, -} - -impl ViewEntry { - fn is_empty(&self) -> bool { - self.blocks.is_empty() && self.timeout_qcs.is_empty() - } - - fn high_qc(&self) -> Option { - let iter1 = self.blocks.iter().map(|block| block.parent_qc.high_qc()); - let iter2 = self - .timeout_qcs - .iter() - .map(|timeout_qc| timeout_qc.high_qc.clone()); - iter1.chain(iter2).max_by_key(|qc| qc.view) - } -} - -// State transtitions that will be picked randomly -#[derive(Clone, Debug)] -pub enum Transition { - Nop, - ReceiveSafeBlock(Block), - ReceiveUnsafeBlock(Block), - ApproveBlock(Block), - ApprovePastBlock(Block), - LocalTimeout, - ReceiveTimeoutQcForCurrentView(TimeoutQc), - ReceiveTimeoutQcForOldView(TimeoutQc), - //TODO: add more invalid transitions that must be rejected by consensus-engine - //TODO: add more transitions -} - -const LEADER_PROOF: LeaderProof = LeaderProof::LeaderId { leader_id: [0; 32] }; -const INITIAL_HIGHEST_VOTED_VIEW: View = -1; -const SENDER: NodeId = [0; 32]; - -impl ReferenceStateMachine for RefState { - type State = Self; - - type Transition = Transition; - - // Initialize the reference state machine - fn init_state() -> BoxedStrategy { - let genesis_block = Block { - view: 0, - id: [0; 32], - parent_qc: Qc::Standard(StandardQc::genesis()), - leader_proof: LEADER_PROOF.clone(), - }; - - Just(RefState { - chain: BTreeMap::from([( - genesis_block.view, - ViewEntry { - blocks: HashSet::from([genesis_block]), - timeout_qcs: Default::default(), - }, - )]), - highest_voted_view: INITIAL_HIGHEST_VOTED_VIEW, - }) - .boxed() - } - - // Generate transitions based on the current reference state machine - fn transitions(state: &Self::State) -> BoxedStrategy { - // Instead of using verbose `if` statements here to filter out the types of transitions - // which cannot be created based on the current reference state, - // each `state.transition_*` function returns a Nop transition - // if it cannot generate the promised transition for the current reference state. - // Both reference and real state machine do nothing for Nop transitions. - prop_oneof![ - state.transition_receive_safe_block(), - state.transition_receive_unsafe_block(), - state.transition_approve_block(), - state.transition_approve_past_block(), - state.transition_local_timeout(), - state.transition_receive_timeout_qc_for_current_view(), - state.transition_receive_timeout_qc_for_old_view(), - ] - .boxed() - } - - // Check if the transition is valid for a given reference state, before applying the transition - // If invalid, the transition will be ignored and a new transition will be generated. - // - // Also, preconditions are used for shrinking in failure cases. - // Preconditions check if the transition is still valid after some shrinking is applied. - // If the transition became invalid for the shrinked state, the shrinking is stopped or - // is continued to other directions. - fn preconditions(state: &Self::State, transition: &Self::Transition) -> bool { - // In most cases, we need to check the same conditions again used to create transitions. - // This is redundant for success cases, but is necessary for shrinking in failure cases, - // because some transitions may no longer be valid after some shrinking is applied. - match transition { - Transition::Nop => true, - Transition::ReceiveSafeBlock(block) => block.view >= state.current_view(), - Transition::ReceiveUnsafeBlock(block) => block.view < state.current_view(), - Transition::ApproveBlock(block) => state.highest_voted_view < block.view, - Transition::ApprovePastBlock(block) => state.highest_voted_view >= block.view, - Transition::LocalTimeout => true, - Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => { - timeout_qc.view == state.current_view() - } - Transition::ReceiveTimeoutQcForOldView(timeout_qc) => { - timeout_qc.view < state.current_view() - } - } - } - - // Apply the given transition on the reference state machine, - // so that it can be used to generate next transitions. - fn apply(mut state: Self::State, transition: &Self::Transition) -> Self::State { - match transition { - Transition::Nop => {} - Transition::ReceiveSafeBlock(block) => { - state - .chain - .entry(block.view) - .or_default() - .blocks - .insert(block.clone()); - } - Transition::ReceiveUnsafeBlock(_) => { - // Nothing to do because we expect the state doesn't change. - } - Transition::ApproveBlock(block) => { - state.highest_voted_view = block.view; - } - Transition::ApprovePastBlock(_) => { - // Nothing to do because we expect the state doesn't change. - } - Transition::LocalTimeout => { - state.highest_voted_view = state.current_view(); - } - Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => { - state - .chain - .entry(timeout_qc.view) - .or_default() - .timeout_qcs - .insert(timeout_qc.clone()); - } - Transition::ReceiveTimeoutQcForOldView(_) => { - // Nothing to do because we expect the state doesn't change. - } - } - - state - } -} - -impl RefState { - // Generate a Transition::ReceiveSafeBlock. - fn transition_receive_safe_block(&self) -> BoxedStrategy { - let recent_parents = self - .chain - .range(self.current_view() - 1..) - .flat_map(|(_view, entry)| entry.blocks.iter().cloned()) - .collect::>(); - - if recent_parents.is_empty() { - Just(Transition::Nop).boxed() - } else { - // proptest::sample::select panics if the input is empty - proptest::sample::select(recent_parents) - .prop_map(move |parent| -> Transition { - Transition::ReceiveSafeBlock(Self::consecutive_block(&parent)) - }) - .boxed() - } - } - - // Generate a Transition::ReceiveUnsafeBlock. - fn transition_receive_unsafe_block(&self) -> BoxedStrategy { - let old_parents = self - .chain - .range(..self.current_view() - 1) - .flat_map(|(_view, entry)| entry.blocks.iter().cloned()) - .collect::>(); - - if old_parents.is_empty() { - Just(Transition::Nop).boxed() - } else { - // proptest::sample::select panics if the input is empty - proptest::sample::select(old_parents) - .prop_map(move |parent| -> Transition { - Transition::ReceiveUnsafeBlock(Self::consecutive_block(&parent)) - }) - .boxed() - } - } - - // Generate a Transition::ApproveBlock. - fn transition_approve_block(&self) -> BoxedStrategy { - let blocks_not_voted = self - .chain - .range(self.highest_voted_view + 1..) - .flat_map(|(_view, entry)| entry.blocks.iter().cloned()) - .collect::>(); - - if blocks_not_voted.is_empty() { - Just(Transition::Nop).boxed() - } else { - // proptest::sample::select panics if the input is empty - proptest::sample::select(blocks_not_voted) - .prop_map(Transition::ApproveBlock) - .boxed() - } - } - - // Generate a Transition::ApprovePastBlock. - fn transition_approve_past_block(&self) -> BoxedStrategy { - let past_blocks = self - .chain - .range(INITIAL_HIGHEST_VOTED_VIEW..self.highest_voted_view) - .flat_map(|(_view, entry)| entry.blocks.iter().cloned()) - .collect::>(); - - if past_blocks.is_empty() { - Just(Transition::Nop).boxed() - } else { - // proptest::sample::select panics if the input is empty - proptest::sample::select(past_blocks) - .prop_map(Transition::ApprovePastBlock) - .boxed() - } - } - - // Generate a Transition::LocalTimeout. - fn transition_local_timeout(&self) -> BoxedStrategy { - Just(Transition::LocalTimeout).boxed() - } - - // Generate a Transition::ReceiveTimeoutQcForCurrentView - fn transition_receive_timeout_qc_for_current_view(&self) -> BoxedStrategy { - let view = self.current_view(); - let high_qc = self - .chain - .iter() - .rev() - .find_map(|(_, entry)| entry.high_qc()); - - if let Some(high_qc) = high_qc { - Just(Transition::ReceiveTimeoutQcForCurrentView(TimeoutQc { - view: view.clone(), - high_qc, - sender: SENDER, - })) - .boxed() - } else { - Just(Transition::Nop).boxed() - } - } - - // Generate a Transition::ReceiveTimeoutQcForOldView - fn transition_receive_timeout_qc_for_old_view(&self) -> BoxedStrategy { - let old_view_entries: Vec<(View, ViewEntry)> = self - .chain - .range(..self.current_view()) - .filter(|(_, entry)| !entry.is_empty()) - .map(|(&view, entry)| (view, entry.clone())) - .collect(); - - if old_view_entries.is_empty() { - Just(Transition::Nop).boxed() - } else { - proptest::sample::select(old_view_entries) - .prop_map(move |(view, entry)| { - Transition::ReceiveTimeoutQcForOldView(TimeoutQc { - view: view.clone(), - high_qc: entry.high_qc().unwrap(), - sender: SENDER, - }) - }) - .boxed() - } - } - - fn current_view(&self) -> View { - let (&last_view, last_entry) = self.chain.last_key_value().unwrap(); - if last_entry.timeout_qcs.is_empty() { - last_view - } else { - let timeout_qc = last_entry.timeout_qcs.iter().next().unwrap(); - RefState::new_view_from(&timeout_qc) - } - } - - fn new_view_from(timeout_qc: &TimeoutQc) -> View { - timeout_qc.view + 1 - } - - fn consecutive_block(parent: &Block) -> Block { - Block { - // use rand because we don't want this to be shrinked by proptest - id: rand::thread_rng().gen(), - view: parent.view + 1, - parent_qc: Qc::Standard(StandardQc { - view: parent.view, - id: parent.id, - }), - leader_proof: LEADER_PROOF.clone(), - } - } -} - -// StateMachineTest defines how transitions are applied to the real state machine -// and what checks should be performed. -impl StateMachineTest for ConsensusEngineTest { - // SUT is the real state machine that we want to test. - type SystemUnderTest = Self; - - // A reference state machine that should be compared against the SUT. - type Reference = RefState; - - // Initialize the SUT state - fn init_test( - _ref_state: &::State, - ) -> Self::SystemUnderTest { - ConsensusEngineTest::new() - } - - // Apply the transition on the SUT state and check post-conditions - fn apply( - state: Self::SystemUnderTest, - _ref_state: &::State, - transition: ::Transition, - ) -> Self::SystemUnderTest { - println!("{transition:?}"); - - match transition { - Transition::Nop => state, - Transition::ReceiveSafeBlock(block) => { - // Because we generate/apply transitions sequentially, - // this transition will always be applied to the same state that it was generated against. - // In other words, we can assume that the `block` is always safe for the current state. - let engine = state.engine.receive_block(block.clone()).unwrap(); - assert!(engine.blocks_in_view(block.view).contains(&block)); - - ConsensusEngineTest { engine } - } - Transition::ReceiveUnsafeBlock(block) => { - let result = panic::catch_unwind(|| state.engine.receive_block(block)); - assert!(result.is_err() || result.unwrap().is_err()); - - state - } - Transition::ApproveBlock(block) => { - let (engine, _) = state.engine.approve_block(block.clone()); - assert_eq!(engine.highest_voted_view(), block.view); - - ConsensusEngineTest { engine } - } - Transition::ApprovePastBlock(block) => { - let result = panic::catch_unwind(|| state.engine.approve_block(block)); - assert!(result.is_err()); - - state - } - Transition::LocalTimeout => { - let (engine, _) = state.engine.local_timeout(); - assert_eq!(engine.highest_voted_view(), engine.current_view()); - - ConsensusEngineTest { engine } - } - Transition::ReceiveTimeoutQcForCurrentView(timeout_qc) => { - let engine = state.engine.receive_timeout_qc(timeout_qc.clone()); - assert_eq!(engine.current_view(), RefState::new_view_from(&timeout_qc)); - - ConsensusEngineTest { engine } - } - Transition::ReceiveTimeoutQcForOldView(timeout_qc) => { - let prev_engine = state.engine.clone(); - let engine = state.engine.receive_timeout_qc(timeout_qc); - - // Check that the engine state didn't change. - assert_eq!(engine, prev_engine); - - ConsensusEngineTest { engine } - } - } - } - - // Check invariants after every transition. - // We have the option to use RefState for comparison in some cases. - fn check_invariants( - state: &Self::SystemUnderTest, - ref_state: &::State, - ) { - assert_eq!(state.engine.current_view(), ref_state.current_view()); - - assert_eq!( - state.engine.highest_voted_view(), - ref_state.highest_voted_view - ); - - //TODO: add more invariants with more public functions of Carnot - } -} - -// SUT is a system (state) that we want to test. -mod system_under_test { - use consensus_engine::{ - overlay::{FlatOverlay, RoundRobin, Settings}, - *, - }; - - use crate::LEADER_PROOF; - - #[derive(Clone, Debug)] - pub struct ConsensusEngineTest { - pub engine: Carnot>, - } - - impl ConsensusEngineTest { - pub fn new() -> Self { - let engine = Carnot::from_genesis( - [0; 32], - Block { - view: 0, - id: [0; 32], - parent_qc: Qc::Standard(StandardQc::genesis()), - leader_proof: LEADER_PROOF.clone(), - }, - FlatOverlay::new(Settings { - nodes: vec![[0; 32]], - leader: RoundRobin::default(), - }), - ); - - ConsensusEngineTest { engine } - } - } + fn consensus_engine_test(sequential 1..50 => ConsensusEngineTest); }