@@ -225,16 +225,19 @@ fn test_node_recovery() {
225
225
}
226
226
227
227
#[ test]
228
- /// Test that demonstrates QBFT incorrectly drops commit messages when no proposal accepted
228
+ /// Test that verifies QBFT can achieve consensus when commit messages arrive before proposal
229
229
///
230
- /// In a proper QBFT implementation, commit messages should be buffered when they arrive
231
- /// before a proposal, allowing catch-up scenarios where a node can achieve consensus
232
- /// based on a commit quorum even without seeing the original proposal.
230
+ /// This test simulates a realistic catch-up scenario in distributed systems where a node
231
+ /// receives commit messages from other nodes before receiving the original proposal.
232
+ /// According to QBFT specification, if we have:
233
+ /// 1. A valid proposal for value X
234
+ /// 2. A quorum of commit messages for value X
233
235
///
234
- /// Current bug: Individual commit messages are dropped when
235
- /// proposal_accepted_for_current_round is false, preventing nodes from ever
236
- /// reaching commit quorum in catch-up scenarios.
237
- fn test_commit_messages_dropped_without_proposal_acceptance ( ) {
236
+ /// Then we should achieve consensus on value X, regardless of message arrival order.
237
+ ///
238
+ /// Current bug: This test FAILS because commit messages are dropped instead of buffered,
239
+ /// preventing consensus achievement in out-of-order scenarios.
240
+ fn test_consensus_with_commits_before_proposal ( ) {
238
241
if ENABLE_TEST_LOGGING {
239
242
let env_filter = EnvFilter :: new ( "debug" ) ;
240
243
let _ = tracing_subscriber:: fmt ( )
@@ -267,23 +270,20 @@ fn test_commit_messages_dropped_without_proposal_acceptance() {
267
270
|_| { } ,
268
271
) ;
269
272
270
- // Verify initial state: no proposal accepted
273
+ // Verify initial state: no proposal accepted, no consensus
271
274
assert ! ( !qbft_instance. proposal_accepted_for_current_round) ;
272
275
assert ! ( matches!(
273
276
qbft_instance. state,
274
277
InstanceState :: AwaitingProposal
275
278
) ) ;
279
+ assert ! ( qbft_instance. completed. is_none( ) ) ;
276
280
277
- // STEP 1: Send commit messages BEFORE accepting any proposal (catch-up scenario)
278
- // This simulates a node that missed the proposal but receives commit messages from other nodes
279
-
280
- let _commit_messages_before = qbft_instance
281
- . commit_container
282
- . get_messages_for_round ( 1 . into ( ) )
283
- . len ( ) ;
281
+ // STEP 1: Send commit messages FIRST (out-of-order scenario)
282
+ // This simulates receiving commits from other nodes before seeing the proposal
283
+ println ! ( "Sending commit messages before proposal..." ) ;
284
284
285
- // Create 3 valid commit messages for the same data
286
- for operator_id in [ 1 , 2 , 3 ] {
285
+ for operator_id in [ 2 , 3 , 4 ] {
286
+ // From operators 2, 3, 4 (not from leader 1)
287
287
let commit_msg = QbftMessage {
288
288
qbft_message_type : QbftMessageType :: Commit ,
289
289
height : 0 ,
@@ -315,36 +315,92 @@ fn test_commit_messages_dropped_without_proposal_acceptance() {
315
315
qbft_message : commit_msg,
316
316
} ;
317
317
318
- // Send the commit message - this should be buffered, not dropped
318
+ // Send the commit message - should be buffered for later processing
319
319
qbft_instance. receive ( wrapped_commit) ;
320
320
}
321
321
322
- let commit_messages_after = qbft_instance
323
- . commit_container
324
- . get_messages_for_round ( 1 . into ( ) )
325
- . len ( ) ;
326
-
327
- // The commit messages should be buffered for catch-up scenario processing
328
- // This assertion FAILS due to the bug in commit message handling
329
- assert_eq ! (
330
- commit_messages_after, 3 ,
331
- "BUG: Commit messages should be buffered when no proposal accepted for catch-up scenarios. \
332
- Expected: 3 commit messages buffered. Actual: {} messages. \
333
- Commit messages are dropped when proposal_accepted_for_current_round \
334
- is false, preventing nodes from achieving consensus in catch-up scenarios where they \
335
- receive commits before proposals.",
336
- commit_messages_after
322
+ // After commits, should still be awaiting proposal (no consensus yet)
323
+ assert ! ( matches!(
324
+ qbft_instance. state,
325
+ InstanceState :: AwaitingProposal
326
+ ) ) ;
327
+ assert ! ( qbft_instance. completed. is_none( ) ) ;
328
+
329
+ // STEP 2: Now send the proposal (completing the consensus scenario)
330
+ println ! ( "Sending proposal after commits..." ) ;
331
+
332
+ let proposal = QbftMessage {
333
+ qbft_message_type : QbftMessageType :: Proposal ,
334
+ height : 0 ,
335
+ round : 1 ,
336
+ identifier : [ 0 ; 56 ] . to_vec ( ) . into ( ) ,
337
+ root : test_data. hash ( ) ,
338
+ data_round : 0 ,
339
+ round_change_justification : vec ! [ ] ,
340
+ prepare_justification : vec ! [ ] ,
341
+ } ;
342
+
343
+ let proposal_ssv_message = SSVMessage :: new (
344
+ MsgType :: SSVConsensusMsgType ,
345
+ MessageId :: from ( [ 0 ; 56 ] ) ,
346
+ proposal. as_ssz_bytes ( ) ,
347
+ )
348
+ . expect ( "should create proposal SSVMessage" ) ;
349
+
350
+ let signed_proposal = SignedSSVMessage :: new (
351
+ vec ! [ vec![ 0 ; RSA_SIGNATURE_SIZE ] ] ,
352
+ vec ! [ OperatorId :: from( 1 ) ] , // From leader (operator 1)
353
+ proposal_ssv_message,
354
+ test_data. as_ssz_bytes ( ) , // full_data for proposal
355
+ )
356
+ . expect ( "should create signed proposal" ) ;
357
+
358
+ let wrapped_proposal = WrappedQbftMessage {
359
+ signed_message : signed_proposal,
360
+ qbft_message : proposal,
361
+ } ;
362
+
363
+ // Send the proposal - this should trigger re-evaluation of buffered commits
364
+ qbft_instance. receive ( wrapped_proposal) ;
365
+
366
+ // STEP 3: Verify consensus is achieved with correct value
367
+ // After receiving the proposal, the instance should:
368
+ // 1. Accept the proposal
369
+ // 2. Re-evaluate buffered commit messages
370
+ // 3. Detect commit quorum (3 commits for same value)
371
+ // 4. Achieve consensus with Success(test_data.hash())
372
+
373
+ println ! ( "Verifying consensus achievement..." ) ;
374
+
375
+ // Should have accepted the proposal
376
+ assert ! (
377
+ qbft_instance. proposal_accepted_for_current_round,
378
+ "Proposal should be accepted after receiving it"
337
379
) ;
338
380
339
- // Instance should still be in AwaitingProposal state since commits were dropped
381
+ // Should have achieved consensus with the correct value
340
382
assert ! (
341
- matches!( qbft_instance. state, InstanceState :: AwaitingProposal ) ,
342
- "Instance should remain in AwaitingProposal state since commits were incorrectly dropped"
383
+ qbft_instance. completed. is_some( ) ,
384
+ "BUG: Instance should have completed consensus after receiving proposal + buffered commits. \
385
+ Current behavior drops commit messages when received before proposal, preventing \
386
+ consensus in out-of-order scenarios."
343
387
) ;
344
388
345
- // The instance should NOT have reached consensus due to dropped commits
389
+ // Verify the consensus result is correct
390
+ if let Some ( completed) = qbft_instance. completed {
391
+ assert ! (
392
+ matches!( completed, Completed :: Success ( hash) if hash == test_data. hash( ) ) ,
393
+ "Consensus should succeed with the correct data hash. Got: {:?}, Expected: Success({})" ,
394
+ completed,
395
+ hex:: encode( test_data. hash( ) )
396
+ ) ;
397
+ }
398
+
399
+ // Should be in Complete state
346
400
assert ! (
347
- qbft_instance. completed . is_none ( ) ,
348
- "Instance should not have completed consensus due to dropped commit messages "
401
+ matches! ( qbft_instance. state , InstanceState :: Complete ) ,
402
+ "Instance should be in Complete state after achieving consensus "
349
403
) ;
404
+
405
+ println ! ( "SUCCESS: Consensus achieved correctly despite out-of-order message delivery!" ) ;
350
406
}
0 commit comments