@@ -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
6667impl 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