Skip to content

Commit 0e51b51

Browse files
apollo_consensus: add honest nodes consensus simulation
1 parent 0c60611 commit 0e51b51

File tree

4 files changed

+373
-0
lines changed

4 files changed

+373
-0
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/apollo_consensus/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ apollo_test_utils.workspace = true
4444
assert_matches.workspace = true
4545
enum-as-inner.workspace = true
4646
mockall.workspace = true
47+
rand.workspace = true
4748
rstest.workspace = true
4849
tempfile.workspace = true
4950
test-case.workspace = true
Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
//! Discrete event simulation test for consensus protocol.
2+
//!
3+
//! This test uses a discrete event simulation approach with a timeline-based
4+
//! event queue.
5+
//!
6+
//! Messages are scheduled with random delays to simulate network jitter.
7+
8+
use std::cmp::Ordering;
9+
use std::collections::{BinaryHeap, HashSet, VecDeque};
10+
11+
use apollo_consensus_config::config::TimeoutsConfig;
12+
use apollo_protobuf::consensus::{ProposalInit, Vote, VoteType};
13+
use lazy_static::lazy_static;
14+
use rand::rngs::StdRng;
15+
use rand::{Rng, SeedableRng};
16+
use starknet_api::block::BlockNumber;
17+
use starknet_types_core::felt::Felt;
18+
19+
use crate::single_height_consensus::{ShcReturn, SingleHeightConsensus};
20+
use crate::state_machine::{SMRequest, StateMachineEvent, Step};
21+
use crate::types::{Decision, ProposalCommitment, Round, ValidatorId};
22+
use crate::votes_threshold::QuorumType;
23+
24+
const HEIGHT_0: BlockNumber = BlockNumber(0);
25+
const PROPOSAL_COMMITMENT: ProposalCommitment = ProposalCommitment(Felt::ONE);
26+
const TOTAL_NODES: usize = 100;
27+
const THRESHOLD: usize = (2 * TOTAL_NODES / 3) + 1;
28+
const SIMULATION_SEED: u64 = 100;
29+
const DEADLINE_TICKS: u64 = 200;
30+
31+
lazy_static! {
32+
static ref VALIDATOR_ID: ValidatorId = ValidatorId::from(0u64);
33+
}
34+
35+
/// Represents an input event in the simulation.
36+
#[derive(Debug, Clone)]
37+
enum InputEvent {
38+
/// A vote message from peer node.
39+
Vote(Vote),
40+
/// A proposal message.
41+
Proposal(ProposalInit),
42+
/// An internal event.
43+
Internal(StateMachineEvent),
44+
}
45+
46+
/// A timed event in the discrete event simulation.
47+
///
48+
/// Events are ordered by ascending tick (earliest first).
49+
#[derive(Debug)]
50+
struct TimedEvent {
51+
/// The simulation tick at which this event should occur.
52+
tick: u64,
53+
/// The event to process.
54+
event: InputEvent,
55+
}
56+
57+
impl PartialEq for TimedEvent {
58+
fn eq(&self, other: &Self) -> bool {
59+
self.tick == other.tick
60+
}
61+
}
62+
63+
impl Eq for TimedEvent {}
64+
65+
impl PartialOrd for TimedEvent {
66+
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
67+
Some(self.cmp(other))
68+
}
69+
}
70+
71+
impl Ord for TimedEvent {
72+
fn cmp(&self, other: &Self) -> Ordering {
73+
other.tick.cmp(&self.tick)
74+
}
75+
}
76+
77+
/// Discrete event simulation for consensus protocol.
78+
///
79+
/// Uses a timeline-based approach where events are scheduled at specific
80+
/// ticks and processed in chronological order.
81+
struct DiscreteEventSimulation {
82+
/// Random number generator for scheduling delays.
83+
rng: StdRng,
84+
/// The single height consensus instance.
85+
shc: SingleHeightConsensus,
86+
/// All validators in the network.
87+
validators: Vec<ValidatorId>,
88+
/// Priority queue of timed events (min-heap by tick).
89+
timeline: BinaryHeap<TimedEvent>,
90+
/// Current simulation tick.
91+
current_tick: u64,
92+
/// History of all processed events.
93+
processed_history: Vec<InputEvent>,
94+
}
95+
96+
impl DiscreteEventSimulation {
97+
fn new(total_nodes: usize, seed: u64) -> Self {
98+
let rng = StdRng::seed_from_u64(seed);
99+
let validators: Vec<ValidatorId> =
100+
(0..total_nodes).map(|i| ValidatorId::from(u64::try_from(i).unwrap())).collect();
101+
102+
let shc = SingleHeightConsensus::new(
103+
HEIGHT_0,
104+
false,
105+
*VALIDATOR_ID,
106+
validators.clone(),
107+
QuorumType::Byzantine,
108+
TimeoutsConfig::default(),
109+
);
110+
111+
Self {
112+
rng,
113+
shc,
114+
validators,
115+
timeline: BinaryHeap::new(),
116+
current_tick: 0,
117+
processed_history: Vec::new(),
118+
}
119+
}
120+
121+
fn get_leader(round: Round) -> ValidatorId {
122+
let round_u64 = u64::from(round);
123+
let hash = SIMULATION_SEED.wrapping_mul(31).wrapping_add(round_u64);
124+
let idx = hash % u64::try_from(TOTAL_NODES).unwrap();
125+
ValidatorId::from(idx)
126+
}
127+
128+
/// Schedules an event to occur after the specified delay.
129+
fn schedule(&mut self, delay: u64, event: InputEvent) {
130+
self.timeline.push(TimedEvent { tick: self.current_tick + delay, event });
131+
}
132+
133+
/// Generates traffic for a specific round with only honest nodes.
134+
///
135+
/// - Proposer sends: Proposal -> Prevote -> Precommit (in order)
136+
/// - Other validators send: Prevote -> Precommit (in order)
137+
///
138+
/// Messages are scheduled with random delays to simulate network jitter,
139+
/// but each node's messages maintain correct ordering.
140+
fn generate_round_traffic(&mut self, round: Round) {
141+
let leader_id = Self::get_leader(round);
142+
143+
// 1. Proposal from leader (if not self)
144+
if leader_id != *VALIDATOR_ID {
145+
self.schedule(
146+
1,
147+
InputEvent::Proposal(ProposalInit {
148+
height: HEIGHT_0,
149+
round,
150+
proposer: leader_id,
151+
valid_round: None,
152+
}),
153+
);
154+
}
155+
156+
// 2. Votes from other honest validators
157+
// Skip index 0 (self) - our votes are handled by the state machine
158+
for i in 1..self.validators.len() {
159+
let voter = self.validators[i];
160+
let commitment = Some(PROPOSAL_COMMITMENT);
161+
162+
// Random delays to simulate network jitter
163+
let prevote_delay = self.rng.gen_range(2..20);
164+
let precommit_delta = self.rng.gen_range(5..20);
165+
166+
// Schedule prevote
167+
self.schedule(
168+
prevote_delay,
169+
InputEvent::Vote(Vote {
170+
vote_type: VoteType::Prevote,
171+
height: HEIGHT_0,
172+
round,
173+
proposal_commitment: commitment,
174+
voter,
175+
}),
176+
);
177+
178+
// Schedule precommit (after prevote)
179+
self.schedule(
180+
prevote_delay + precommit_delta,
181+
InputEvent::Vote(Vote {
182+
vote_type: VoteType::Precommit,
183+
height: HEIGHT_0,
184+
round,
185+
proposal_commitment: commitment,
186+
voter,
187+
}),
188+
);
189+
}
190+
}
191+
192+
/// Runs the simulation until a decision is reached or the deadline is exceeded.
193+
///
194+
/// Returns `Some(Decision)` if consensus is reached, `None` if the deadline
195+
/// is reached without a decision.
196+
fn run(&mut self, deadline_ticks: u64) -> Option<Decision> {
197+
let leader_fn = |r: Round| Self::get_leader(r);
198+
199+
// Start the single height consensus
200+
match self.shc.start(&leader_fn) {
201+
ShcReturn::Decision(d) => return Some(d),
202+
ShcReturn::Requests(reqs) => self.handle_requests(reqs),
203+
}
204+
205+
// Main event loop
206+
while let Some(timed_event) = self.timeline.pop() {
207+
if timed_event.tick > deadline_ticks {
208+
break;
209+
}
210+
211+
self.current_tick = timed_event.tick;
212+
self.processed_history.push(timed_event.event.clone());
213+
214+
// Process the event
215+
let res = match timed_event.event {
216+
InputEvent::Vote(v) => self.shc.handle_vote(&leader_fn, v),
217+
InputEvent::Proposal(p) => self.shc.handle_proposal(&leader_fn, p),
218+
InputEvent::Internal(e) => self.shc.handle_event(&leader_fn, e),
219+
};
220+
221+
match res {
222+
ShcReturn::Decision(d) => return Some(d),
223+
ShcReturn::Requests(reqs) => self.handle_requests(reqs),
224+
}
225+
}
226+
227+
None
228+
}
229+
230+
/// Handles state machine requests by scheduling appropriate events.
231+
///
232+
/// This simulates the manager's role in handling consensus requests,
233+
/// such as validation results, proposal building, and timeouts.
234+
fn handle_requests(&mut self, reqs: VecDeque<SMRequest>) {
235+
for req in reqs {
236+
match req {
237+
SMRequest::StartValidateProposal(init) => {
238+
let delay = self.rng.gen_range(15..30);
239+
let result = StateMachineEvent::FinishedValidation(
240+
Some(PROPOSAL_COMMITMENT),
241+
init.round,
242+
None,
243+
);
244+
self.schedule(delay, InputEvent::Internal(result));
245+
}
246+
SMRequest::StartBuildProposal(round) => {
247+
let delay = self.rng.gen_range(15..30);
248+
let result =
249+
StateMachineEvent::FinishedBuilding(Some(PROPOSAL_COMMITMENT), round);
250+
self.schedule(delay, InputEvent::Internal(result));
251+
}
252+
SMRequest::ScheduleTimeout(step, round) => {
253+
let (delay, event) = match step {
254+
Step::Propose => {
255+
(self.rng.gen_range(15..30), StateMachineEvent::TimeoutPropose(round))
256+
}
257+
Step::Prevote => {
258+
(self.rng.gen_range(5..10), StateMachineEvent::TimeoutPrevote(round))
259+
}
260+
Step::Precommit => {
261+
(self.rng.gen_range(5..10), StateMachineEvent::TimeoutPrecommit(round))
262+
}
263+
};
264+
self.schedule(delay, InputEvent::Internal(event));
265+
}
266+
_ => {
267+
// Ignore other request types
268+
}
269+
}
270+
}
271+
}
272+
}
273+
274+
/// Verifies that the simulation reached a valid decision with honest nodes.
275+
///
276+
/// Checks:
277+
/// - Decision was reached (not None)
278+
/// - Correct block commitment
279+
/// - Decision in round 0
280+
/// - Quorum threshold is met
281+
fn verify_honest_success(sim: &DiscreteEventSimulation, result: Option<&Decision>) {
282+
let decision = result.unwrap_or_else(|| {
283+
panic!(
284+
"FAILURE: Simulation timed out! Honest network should always decide. History: {:?}",
285+
sim.processed_history
286+
)
287+
});
288+
289+
let decided_block = decision.block;
290+
let decided_round = decision.precommits[0].round;
291+
292+
// 1. Verify correct block commitment
293+
assert_eq!(
294+
decided_block, PROPOSAL_COMMITMENT,
295+
"Block commitment mismatch. History: {:?}",
296+
sim.processed_history
297+
);
298+
299+
// 2. Verify decision in round 0 (honest network should decide immediately)
300+
assert_eq!(
301+
decided_round, 0,
302+
"Honest network should decide in Round 0. History: {:?}",
303+
sim.processed_history
304+
);
305+
306+
// 3. Verify that decision has the same precommits as history.
307+
let history_precommits: HashSet<_> = sim
308+
.processed_history
309+
.iter()
310+
.filter_map(|e| {
311+
if let InputEvent::Vote(v) = e {
312+
if v.vote_type == VoteType::Precommit
313+
&& v.round == decided_round
314+
&& v.proposal_commitment == Some(decided_block)
315+
{
316+
Some(v.clone())
317+
} else {
318+
None
319+
}
320+
} else {
321+
None
322+
}
323+
})
324+
.collect();
325+
326+
let decision_precommits: HashSet<_> = decision.precommits.iter().cloned().collect();
327+
328+
// Decision should contain all history precommits, plus possibly the self vote
329+
assert!(
330+
history_precommits.is_subset(&decision_precommits),
331+
"Decision precommits don't contain all history precommits. Decision: {:?}, History: {:?}",
332+
decision,
333+
sim.processed_history
334+
);
335+
336+
// Decision should have at most one extra vote (the self vote)
337+
let extra_votes = decision_precommits.difference(&history_precommits).count();
338+
assert!(
339+
extra_votes <= 1,
340+
"Decision has {} extra precommits, expected at most 1 (self vote). Decision: {:?}, \
341+
History: {:?}",
342+
extra_votes,
343+
decision,
344+
sim.processed_history
345+
);
346+
347+
// Verify quorum threshold is met
348+
assert!(
349+
decision.precommits.len() >= THRESHOLD,
350+
"Insufficient precommits in decision: {}/{}. Decision: {:?}, History: {:?}",
351+
decision.precommits.len(),
352+
THRESHOLD,
353+
decision,
354+
sim.processed_history
355+
);
356+
}
357+
358+
#[test]
359+
fn test_honest_nodes_only() {
360+
let mut sim = DiscreteEventSimulation::new(TOTAL_NODES, SIMULATION_SEED);
361+
362+
sim.generate_round_traffic(0);
363+
364+
let result = sim.run(DEADLINE_TICKS);
365+
366+
verify_honest_success(&sim, result.as_ref());
367+
}

crates/apollo_consensus/src/single_height_consensus.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
#[path = "single_height_consensus_test.rs"]
99
mod single_height_consensus_test;
1010

11+
#[cfg(test)]
12+
#[path = "simulation_test.rs"]
13+
mod simulation_test;
14+
1115
use std::collections::{HashSet, VecDeque};
1216

1317
use crate::state_machine::VoteStatus;

0 commit comments

Comments
 (0)