Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions anchor/common/qbft/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<DefaultLeaderFunction>::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!");
}
Loading