Skip to content

Commit 92c13cf

Browse files
Merge pull request #9 from raylsnetwork/feat/unit-test-followers
Add unit tests for follower node consensus behavior
2 parents fa8c2a9 + d3cfb5c commit 92c13cf

1 file changed

Lines changed: 127 additions & 0 deletions

File tree

crates/rbft/src/tests.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ struct NodeSwarm {
6161
private_keys: Vec<B256>,
6262
enabled: Vec<bool>,
6363
messages_in_transit: Vec<DelayedMessage>,
64+
message_counts: Vec<usize>,
6465
}
6566

6667
impl NodeSwarm {
@@ -131,9 +132,34 @@ impl NodeSwarm {
131132
private_keys,
132133
enabled: vec![true; num_nodes],
133134
messages_in_transit: vec![],
135+
message_counts: vec![0; num_nodes],
134136
}
135137
}
136138

139+
/// Add a follower node to the swarm. The follower has no private key and is
140+
/// not in the validator set, so it only processes NewBlock messages.
141+
/// Returns the index of the new follower node.
142+
fn add_follower(&mut self) -> usize {
143+
use alloy_signer_local::PrivateKeySigner;
144+
145+
let index = self.nodes.len();
146+
let private_key = B256::from([(index + 1) as u8; 32]);
147+
let signer =
148+
PrivateKeySigner::from_bytes(&private_key).expect("Private key should be valid");
149+
let follower_id = signer.address();
150+
151+
let reference_node = &self.nodes[0];
152+
let configuration = reference_node.configuration().clone();
153+
let genesis_block = configuration.genesis_block.clone();
154+
let blockchain = Blockchain::new(VecDeque::from([genesis_block]));
155+
156+
let node = NodeState::new(blockchain, configuration, follower_id, None, 0);
157+
self.nodes.push(node);
158+
self.enabled.push(true);
159+
self.message_counts.push(0);
160+
index
161+
}
162+
137163
/// Get one of the nodes in the swarm. Panics if index is out of bounds.
138164
#[allow(dead_code)]
139165
fn node(&self, index: usize) -> &NodeState {
@@ -194,6 +220,7 @@ impl NodeSwarm {
194220
let o = summarise_messages(out.iter().map(|m| &m.message), quorum_size);
195221
info!(target: "swarm", "{after} -> {o}");
196222
}
223+
self.message_counts[i] += out.len();
197224
outgoing.extend(out);
198225
}
199226
}
@@ -2354,3 +2381,103 @@ fn provoke_upon_round_change_case_2() {
23542381
"At least {expected_blocks} blocks should have been produced, but got {min_height}"
23552382
);
23562383
}
2384+
2385+
/// Test that a follower node correctly catches up with the chain tip,
2386+
/// does not emit any consensus messages, and remains connected.
2387+
///
2388+
/// Verifies GitHub issue #2 requirements:
2389+
/// * Follower does not disconnect.
2390+
/// * Follower does not consume excess bandwidth.
2391+
/// * Follower correctly catches up with the tip.
2392+
#[test]
2393+
fn test_follower_catches_up() {
2394+
setup_tracing();
2395+
let mut swarm = NodeSwarm::new(4);
2396+
let follower_idx = swarm.add_follower();
2397+
2398+
for t in 0..20 {
2399+
for tick in 0..NodeSwarm::TICKS_PER_SECOND {
2400+
swarm.tick(t, tick);
2401+
}
2402+
}
2403+
2404+
// Validators should have produced blocks.
2405+
let validator_min_height = swarm.nodes()[..4].iter().map(|n| n.height()).min().unwrap();
2406+
assert!(
2407+
validator_min_height >= 2,
2408+
"Validators should produce at least 2 blocks, got {validator_min_height}"
2409+
);
2410+
2411+
// Follower catches up with the tip.
2412+
let follower_height = swarm.node(follower_idx).height();
2413+
assert_eq!(
2414+
follower_height, validator_min_height,
2415+
"Follower height {follower_height} should match validator min height \
2416+
{validator_min_height}"
2417+
);
2418+
2419+
// Follower is still a follower (no private key, not a validator).
2420+
assert!(
2421+
swarm.node(follower_idx).private_key().is_none(),
2422+
"Follower should have no private key"
2423+
);
2424+
assert!(
2425+
swarm.node(follower_idx).validator_index().is_none(),
2426+
"Follower should not be in the validator set"
2427+
);
2428+
2429+
// Follower emitted zero messages (no bandwidth consumption).
2430+
assert_eq!(
2431+
swarm.message_counts[follower_idx], 0,
2432+
"Follower should not emit any messages"
2433+
);
2434+
}
2435+
2436+
/// Test that a follower node joining mid-chain catches up with validators.
2437+
/// Simulates a late-joining follower that starts from genesis while
2438+
/// validators have already produced blocks.
2439+
#[test]
2440+
fn test_follower_late_join() {
2441+
setup_tracing();
2442+
let mut swarm = NodeSwarm::new(4);
2443+
2444+
// Run validators for a while to produce blocks.
2445+
for t in 0..10 {
2446+
for tick in 0..NodeSwarm::TICKS_PER_SECOND {
2447+
swarm.tick(t, tick);
2448+
}
2449+
}
2450+
2451+
let height_before_follower = swarm.min_height();
2452+
assert!(
2453+
height_before_follower >= 1,
2454+
"Validators should produce blocks before follower joins"
2455+
);
2456+
2457+
// Add a follower that starts from genesis (height 1, ready for block 1).
2458+
let follower_idx = swarm.add_follower();
2459+
assert_eq!(swarm.node(follower_idx).height(), 1);
2460+
2461+
// Continue running so the follower can catch up.
2462+
for t in 10..30 {
2463+
for tick in 0..NodeSwarm::TICKS_PER_SECOND {
2464+
swarm.tick(t, tick);
2465+
}
2466+
}
2467+
2468+
let validator_min_height = swarm.nodes()[..4].iter().map(|n| n.height()).min().unwrap();
2469+
2470+
// Follower catches up with the tip.
2471+
let follower_height = swarm.node(follower_idx).height();
2472+
assert_eq!(
2473+
follower_height, validator_min_height,
2474+
"Late-joining follower height {follower_height} should match validator min height \
2475+
{validator_min_height}"
2476+
);
2477+
2478+
// Follower emitted zero messages.
2479+
assert_eq!(
2480+
swarm.message_counts[follower_idx], 0,
2481+
"Late-joining follower should not emit any messages"
2482+
);
2483+
}

0 commit comments

Comments
 (0)