@@ -264,6 +264,13 @@ impl ConsensusSession {
264264 }
265265 }
266266
267+ // Each distinct voter can vote at most once, so the batch size
268+ // is bounded by expected_voters_count (u32). Reject early if violated.
269+ if votes. len ( ) > self . proposal . expected_voters_count as usize {
270+ self . state = ConsensusState :: Failed ;
271+ return Err ( ConsensusError :: MaxRoundsExceeded ) ;
272+ }
273+
267274 validate_vote_chain ( & votes) ?;
268275 for vote in & votes {
269276 validate_vote ( vote, expiration_timestamp, creation_time) ?;
@@ -287,6 +294,12 @@ impl ConsensusSession {
287294 /// - For P2P: Calculates `(current_round - 1) + vote_count`.
288295 /// - For Gossipsub: Moves to Round 2 if `vote_count > 0`.
289296 fn check_round_limit ( & mut self , vote_count : usize ) -> Result < ( ) , ConsensusError > {
297+ // vote_count cannot exceed expected_voters_count (u32); reject if it does
298+ if vote_count > self . proposal . expected_voters_count as usize {
299+ self . state = ConsensusState :: Failed ;
300+ return Err ( ConsensusError :: MaxRoundsExceeded ) ;
301+ }
302+
290303 // Determine the value to compare against the limit based on configuration
291304 let projected_value = if self . config . use_gossipsub_rounds {
292305 // Gossipsub Logic:
@@ -303,6 +316,7 @@ impl ConsensusSession {
303316 // RFC Section 2.5.3: Round increments per vote.
304317 // Current existing votes = round - 1.
305318 // Projected total = Existing votes + New votes.
319+ // vote_count is bounded by expected_voters_count (u32), safe to cast
306320 let current_votes = self . proposal . round . saturating_sub ( 1 ) ;
307321 current_votes. saturating_add ( vote_count as u32 )
308322 } ;
@@ -336,6 +350,7 @@ impl ConsensusSession {
336350 } else {
337351 // RFC Section 2.5.3: P2P
338352 // Round increments for every vote added.
353+ // vote_count is bounded by expected_voters_count (u32), safe to cast
339354 self . proposal . round = self . proposal . round . saturating_add ( vote_count as u32 ) ;
340355 }
341356 }
@@ -381,11 +396,13 @@ impl ConsensusSession {
381396
382397#[ cfg( test) ]
383398mod tests {
399+ use std:: time:: Duration ;
400+
384401 use alloy:: signers:: local:: PrivateKeySigner ;
385402
386403 use crate :: {
387404 error:: ConsensusError ,
388- session:: { ConsensusConfig , ConsensusSession } ,
405+ session:: { ConsensusConfig , ConsensusSession , ConsensusState } ,
389406 types:: CreateProposalRequest ,
390407 utils:: build_vote,
391408 } ;
@@ -489,4 +506,170 @@ mod tests {
489506 let err = session. add_vote ( vote5) . unwrap_err ( ) ;
490507 assert ! ( matches!( err, ConsensusError :: MaxRoundsExceeded ) ) ;
491508 }
509+
510+ #[ test]
511+ fn consensus_config_builder_and_getters_cover_edges ( ) {
512+ let cfg = ConsensusConfig :: gossipsub ( )
513+ . with_threshold ( 0.75 )
514+ . unwrap ( )
515+ . with_timeout ( Duration :: from_secs ( 42 ) )
516+ . unwrap ( )
517+ . with_liveness_criteria ( false ) ;
518+
519+ assert_eq ! ( cfg. consensus_threshold( ) , 0.75 ) ;
520+ assert_eq ! ( cfg. consensus_timeout( ) , Duration :: from_secs( 42 ) ) ;
521+ assert ! ( !cfg. liveness_criteria( ) ) ;
522+
523+ let err = ConsensusConfig :: gossipsub ( )
524+ . with_threshold ( 1.1 )
525+ . unwrap_err ( ) ;
526+ assert ! ( matches!( err, ConsensusError :: InvalidConsensusThreshold ) ) ;
527+
528+ let err = ConsensusConfig :: gossipsub ( )
529+ . with_timeout ( Duration :: from_secs ( 0 ) )
530+ . unwrap_err ( ) ;
531+ assert ! ( matches!( err, ConsensusError :: InvalidTimeout ) ) ;
532+
533+ // Covers max_round_limit branch when P2P-like mode uses explicit max_rounds (non-zero).
534+ let explicit = ConsensusConfig :: new ( 2.0 / 3.0 , Duration :: from_secs ( 60 ) , 7 , false , true ) ;
535+ assert_eq ! ( explicit. max_round_limit( 100 ) , 7 ) ;
536+ }
537+
538+ #[ tokio:: test]
539+ async fn add_vote_rejects_non_active_and_reports_reached_when_finalized ( ) {
540+ let signer = PrivateKeySigner :: random ( ) ;
541+ let request = CreateProposalRequest :: new (
542+ "Test" . into ( ) ,
543+ "" . into ( ) ,
544+ signer. address ( ) . as_slice ( ) . to_vec ( ) ,
545+ 3 ,
546+ 60 ,
547+ true ,
548+ )
549+ . unwrap ( ) ;
550+ let proposal = request. into_proposal ( ) . unwrap ( ) ;
551+
552+ // Failed sessions reject new votes.
553+ let mut failed_session =
554+ ConsensusSession :: new ( proposal. clone ( ) , ConsensusConfig :: gossipsub ( ) ) ;
555+ failed_session. state = ConsensusState :: Failed ;
556+ let vote = build_vote ( & failed_session. proposal , true , signer. clone ( ) )
557+ . await
558+ . unwrap ( ) ;
559+ let err = failed_session. add_vote ( vote) . unwrap_err ( ) ;
560+ assert ! ( matches!( err, ConsensusError :: SessionNotActive ) ) ;
561+
562+ // Finalized sessions return existing transition/result.
563+ let mut finalized_session = ConsensusSession :: new ( proposal, ConsensusConfig :: gossipsub ( ) ) ;
564+ finalized_session. state = ConsensusState :: ConsensusReached ( true ) ;
565+ let vote = build_vote ( & finalized_session. proposal , true , signer)
566+ . await
567+ . unwrap ( ) ;
568+ let transition = finalized_session. add_vote ( vote) . unwrap ( ) ;
569+ assert ! ( matches!(
570+ transition,
571+ crate :: types:: SessionTransition :: ConsensusReached ( true )
572+ ) ) ;
573+ }
574+
575+ #[ tokio:: test]
576+ async fn initialize_with_votes_non_active_duplicate_and_zero_votes_paths ( ) {
577+ let signer = PrivateKeySigner :: random ( ) ;
578+ let request = CreateProposalRequest :: new (
579+ "Test" . into ( ) ,
580+ "" . into ( ) ,
581+ signer. address ( ) . as_slice ( ) . to_vec ( ) ,
582+ 4 ,
583+ 60 ,
584+ true ,
585+ )
586+ . unwrap ( ) ;
587+ let proposal = request. into_proposal ( ) . unwrap ( ) ;
588+
589+ // Non-active sessions reject initialization.
590+ let mut inactive = ConsensusSession :: new ( proposal. clone ( ) , ConsensusConfig :: gossipsub ( ) ) ;
591+ inactive. state = ConsensusState :: Failed ;
592+ let err = inactive
593+ . initialize_with_votes ( vec ! [ ] , proposal. expiration_timestamp , proposal. timestamp )
594+ . unwrap_err ( ) ;
595+ assert ! ( matches!( err, ConsensusError :: SessionNotActive ) ) ;
596+
597+ // Duplicate owners are rejected before chain/signature checks.
598+ let mut dup_session = ConsensusSession :: new ( proposal. clone ( ) , ConsensusConfig :: gossipsub ( ) ) ;
599+ let vote1 = build_vote ( & dup_session. proposal , true , signer. clone ( ) )
600+ . await
601+ . unwrap ( ) ;
602+ let vote2 = build_vote ( & dup_session. proposal , false , signer)
603+ . await
604+ . unwrap ( ) ;
605+ let err = dup_session
606+ . initialize_with_votes (
607+ vec ! [ vote1, vote2] ,
608+ proposal. expiration_timestamp ,
609+ proposal. timestamp ,
610+ )
611+ . unwrap_err ( ) ;
612+ assert ! ( matches!( err, ConsensusError :: DuplicateVote ) ) ;
613+
614+ // Explicitly exercise gossipsub projected round branch where vote_count == 0.
615+ let mut zero_votes = ConsensusSession :: new ( proposal, ConsensusConfig :: gossipsub ( ) ) ;
616+ zero_votes. check_round_limit ( 0 ) . unwrap ( ) ;
617+ }
618+
619+ #[ test]
620+ fn p2p_round_limit_should_reject_effectively_huge_vote_count ( ) {
621+ if usize:: BITS <= 32 {
622+ return ;
623+ }
624+
625+ let signer = PrivateKeySigner :: random ( ) ;
626+ let request = CreateProposalRequest :: new (
627+ "TruncationTest" . into ( ) ,
628+ vec ! [ ] ,
629+ signer. address ( ) . as_slice ( ) . to_vec ( ) ,
630+ 1 ,
631+ 60 ,
632+ true ,
633+ )
634+ . unwrap ( ) ;
635+
636+ let proposal = request. into_proposal ( ) . unwrap ( ) ;
637+ let mut session = ConsensusSession :: new ( proposal, ConsensusConfig :: p2p ( ) ) ;
638+
639+ let wrapped_vote_count = ( u32:: MAX as usize ) + 1 ;
640+
641+ // Desired behavior: an effectively huge batch must be rejected by round-limit checks.
642+ let result = session. check_round_limit ( wrapped_vote_count) ;
643+ assert ! (
644+ result. is_err( ) ,
645+ "effectively huge vote_count should not pass round-limit checks"
646+ ) ;
647+ }
648+
649+ #[ test]
650+ fn p2p_update_round_should_advance_for_max_u32_vote_count ( ) {
651+ let signer = PrivateKeySigner :: random ( ) ;
652+ let request = CreateProposalRequest :: new (
653+ "RoundUpdateMax" . into ( ) ,
654+ vec ! [ ] ,
655+ signer. address ( ) . as_slice ( ) . to_vec ( ) ,
656+ u32:: MAX ,
657+ 60 ,
658+ true ,
659+ )
660+ . unwrap ( ) ;
661+
662+ let proposal = request. into_proposal ( ) . unwrap ( ) ;
663+ let mut session = ConsensusSession :: new ( proposal, ConsensusConfig :: p2p ( ) ) ;
664+ let starting_round = session. proposal . round ;
665+
666+ // vote_count at the u32 boundary should still advance the round via saturating_add
667+ session. update_round ( u32:: MAX as usize ) ;
668+
669+ assert ! (
670+ session. proposal. round > starting_round,
671+ "round should advance when max u32 vote_count is applied"
672+ ) ;
673+ assert_eq ! ( session. proposal. round, u32 :: MAX ) ;
674+ }
492675}
0 commit comments