diff --git a/anchor/common/qbft/src/tests.rs b/anchor/common/qbft/src/tests.rs index a607b5c9b..74dd49084 100644 --- a/anchor/common/qbft/src/tests.rs +++ b/anchor/common/qbft/src/tests.rs @@ -223,3 +223,184 @@ fn test_node_recovery() { let num_consensus = test_instance.wait_until_end(); assert_eq!(num_consensus, 5); // Should reach full consensus after recovery } + +#[test] +/// Test that verifies QBFT can achieve consensus when commit messages arrive before proposal +/// +/// This test simulates a realistic catch-up scenario in distributed systems where a node +/// receives commit messages from other nodes before receiving the original proposal. +/// According to QBFT specification, if we have: +/// 1. A valid proposal for value X +/// 2. A quorum of commit messages for value X +/// +/// Then we should achieve consensus on value X, regardless of message arrival order. +/// +/// Current bug: This test FAILS because commit messages are dropped instead of buffered, +/// preventing consensus achievement in out-of-order scenarios. +fn test_consensus_with_commits_before_proposal() { + if ENABLE_TEST_LOGGING { + let env_filter = EnvFilter::new("debug"); + let _ = tracing_subscriber::fmt() + .compact() + .with_env_filter(env_filter) + .try_init(); + } + + use ssv_types::{ + consensus::QbftMessage, + message::{MsgType, RSA_SIGNATURE_SIZE, SSVMessage, SignedSSVMessage}, + }; + + // Create QBFT instance with 4 nodes (f=1, quorum=3) + let config = ConfigBuilder::::new( + 1.into(), + InstanceHeight::default(), + (1..=4).map(OperatorId::from).collect(), // 4 nodes, quorum = 3 + ) + .with_operator_id(OperatorId::from(1)) + .build() + .expect("config should be valid"); + + let test_data = TestData(789); + let mut qbft_instance = Qbft::new( + config, + test_data.clone(), + Box::new(NoDataValidation), + MessageId::from([0; 56]), + |_| {}, + ); + + // Verify initial state: no proposal accepted, no consensus + assert!(!qbft_instance.proposal_accepted_for_current_round); + assert!(matches!( + qbft_instance.state, + InstanceState::AwaitingProposal + )); + assert!(qbft_instance.completed.is_none()); + + // STEP 1: Send commit messages FIRST (out-of-order scenario) + // This simulates receiving commits from other nodes before seeing the proposal + println!("Sending commit messages before proposal..."); + + for operator_id in [2, 3, 4] { + // From operators 2, 3, 4 (not from leader 1) + let commit_msg = QbftMessage { + qbft_message_type: QbftMessageType::Commit, + height: 0, + round: 1, + identifier: [0; 56].to_vec().into(), + root: test_data.hash(), + data_round: 0, + round_change_justification: vec![], + prepare_justification: vec![], + }; + + let commit_ssv_message = SSVMessage::new( + MsgType::SSVConsensusMsgType, + MessageId::from([0; 56]), + commit_msg.as_ssz_bytes(), + ) + .expect("should create commit SSVMessage"); + + let signed_commit = SignedSSVMessage::new( + vec![vec![0; RSA_SIGNATURE_SIZE]], + vec![OperatorId::from(operator_id)], + commit_ssv_message, + vec![], // no full_data for commit + ) + .expect("should create signed commit"); + + let wrapped_commit = WrappedQbftMessage { + signed_message: signed_commit, + qbft_message: commit_msg, + }; + + // Send the commit message - should be buffered for later processing + qbft_instance.receive(wrapped_commit); + } + + // After commits, should still be awaiting proposal (no consensus yet) + assert!(matches!( + qbft_instance.state, + InstanceState::AwaitingProposal + )); + assert!(qbft_instance.completed.is_none()); + + // STEP 2: Now send the proposal (completing the consensus scenario) + println!("Sending proposal after commits..."); + + let proposal = QbftMessage { + qbft_message_type: QbftMessageType::Proposal, + height: 0, + round: 1, + identifier: [0; 56].to_vec().into(), + root: test_data.hash(), + data_round: 0, + round_change_justification: vec![], + prepare_justification: vec![], + }; + + let proposal_ssv_message = SSVMessage::new( + MsgType::SSVConsensusMsgType, + MessageId::from([0; 56]), + proposal.as_ssz_bytes(), + ) + .expect("should create proposal SSVMessage"); + + let signed_proposal = SignedSSVMessage::new( + vec![vec![0; RSA_SIGNATURE_SIZE]], + vec![OperatorId::from(1)], // From leader (operator 1) + proposal_ssv_message, + test_data.as_ssz_bytes(), // full_data for proposal + ) + .expect("should create signed proposal"); + + let wrapped_proposal = WrappedQbftMessage { + signed_message: signed_proposal, + qbft_message: proposal, + }; + + // Send the proposal - this should trigger re-evaluation of buffered commits + qbft_instance.receive(wrapped_proposal); + + // STEP 3: Verify consensus is achieved with correct value + // After receiving the proposal, the instance should: + // 1. Accept the proposal + // 2. Re-evaluate buffered commit messages + // 3. Detect commit quorum (3 commits for same value) + // 4. Achieve consensus with Success(test_data.hash()) + + println!("Verifying consensus achievement..."); + + // Should have accepted the proposal + assert!( + qbft_instance.proposal_accepted_for_current_round, + "Proposal should be accepted after receiving it" + ); + + // Should have achieved consensus with the correct value + assert!( + qbft_instance.completed.is_some(), + "BUG: Instance should have completed consensus after receiving proposal + buffered commits. \ + Current behavior drops commit messages when received before proposal, preventing \ + consensus in out-of-order scenarios." + ); + + // Verify the consensus result is correct + if let Some(completed) = qbft_instance.completed { + assert!( + matches!(completed, Completed::Success(hash) if hash == test_data.hash()), + "Consensus should succeed with the correct data hash. Got: {:?}, Expected: Success({})", + completed, + hex::encode(test_data.hash()) + ); + } + + // Should be in Complete state + assert!( + matches!(qbft_instance.state, InstanceState::Complete), + "Instance should be in Complete state after achieving consensus" + ); + + println!("SUCCESS: Consensus achieved correctly despite out-of-order message delivery!"); +}