From f46b97882712a94861b3cf7d1323a0272a08de39 Mon Sep 17 00:00:00 2001 From: Daniel Cason Date: Thu, 12 Feb 2026 17:57:47 -0800 Subject: [PATCH 1/7] votekeeper: PrecommitValue and SkipRound test --- .../core-votekeeper/tests/vote_keeper.rs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/code/crates/core-votekeeper/tests/vote_keeper.rs b/code/crates/core-votekeeper/tests/vote_keeper.rs index 2196a144b..13730c4ba 100644 --- a/code/crates/core-votekeeper/tests/vote_keeper.rs +++ b/code/crates/core-votekeeper/tests/vote_keeper.rs @@ -1,6 +1,7 @@ -use malachitebft_core_types::{NilOrVal, Round, SignedVote}; +use malachitebft_core_types::{NilOrVal, Round, SignedVote, VoteType}; use informalsystems_malachitebft_core_votekeeper::keeper::{Output, VoteKeeper}; +use informalsystems_malachitebft_core_votekeeper::Threshold; use malachitebft_test::{ Address, Height, PrivateKey, Signature, TestContext, Validator, ValidatorSet, ValueId, Vote, @@ -249,6 +250,30 @@ fn no_skip_round_full_quorum_with_same_val() { assert_eq!(msg, None); } +#[test] +fn skip_round_and_precommit_value_future_round() { + let ([addr1, addr2, ..], mut keeper) = setup([2, 3, 2]); + + let id = ValueId::new(1); + let val = NilOrVal::Val(id); + let height = Height::new(1); + let cur_round = Round::new(0); + let fut_round = Round::new(1); + + let vote = new_signed_precommit(height, fut_round, val, addr1); + let msg = keeper.apply_vote(vote, cur_round); + assert_eq!(msg, None); + + let vote = new_signed_precommit(height, fut_round, val, addr2); + let msg = keeper.apply_vote(vote, cur_round); + assert_eq!(msg, Some(Output::SkipRound(fut_round))); + + // A PrecommitValue(id) could be produced + assert!(keeper.is_threshold_met(&fut_round, VoteType::Precommit, Threshold::Value(id))); + // FIXME: should we instead expect it to be produced? + // assert_eq!(msg, Some(Output::PrecommitValue(id))); +} + #[test] fn same_votes() { let ([addr1, ..], mut keeper) = setup([1, 1]); From 69cba54e82c386f091b28a597bde5d4b73519086 Mon Sep 17 00:00:00 2001 From: Daniel Cason Date: Thu, 12 Feb 2026 18:33:08 -0800 Subject: [PATCH 2/7] driver: test for round-1 decision during round 0 * Currently, it fails when the decision is reached with votes * It works when the decision is reached with a commit certificate --- code/crates/core-driver/tests/it/extra.rs | 107 ++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/code/crates/core-driver/tests/it/extra.rs b/code/crates/core-driver/tests/it/extra.rs index 21ec981a6..55f03bde5 100644 --- a/code/crates/core-driver/tests/it/extra.rs +++ b/code/crates/core-driver/tests/it/extra.rs @@ -3646,6 +3646,113 @@ fn sync_decision_certificate_then_proposal() { run_steps(&mut driver, steps); } +#[test] +fn round_1_decision_during_round_0() { + let value = Value::new(9999); + + let [(v1, _sk1), (v2, _sk2), (v3, sk3)] = make_validators([2, 3, 2]); + let (_my_sk, my_addr) = (sk3.clone(), v3.address); + + let height = Height::new(1); + let ctx = TestContext::new(); + let vs = ValidatorSet::new(vec![v1.clone(), v2.clone(), v3.clone()]); + + let proposal = Proposal::new( + Height::new(1), + Round::new(1), + value.clone(), + Round::Nil, + v1.address, + ); + + let mut driver = Driver::new(ctx, height, vs, my_addr, Default::default()); + + let steps = vec![ + TestStep { + desc: "Start round 0, we are not the proposer", + input: new_round_input(Round::new(0), v1.address), + expected_outputs: vec![start_propose_timer_output(Round::new(0))], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "v1 precommits a proposal in round 1", + input: precommit_input(Round::new(1), value.clone(), &v1.address), + expected_outputs: vec![], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "Receive proposal from round 1", + input: proposal_input_from_proposal(proposal.clone(), Validity::Valid), + expected_outputs: vec![], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "v2 precommits the same round 1 proposal, we have a decision", + input: precommit_input(Round::new(1), value.clone(), &v2.address), + expected_outputs: vec![decide_output(Round::new(0), proposal)], + expected_round: Round::new(0), + new_state: decided_state(Round::new(0), Round::new(1), value), + }, + ]; + + run_steps(&mut driver, steps); +} + +#[test] +fn round_1_decision_during_round_0_via_certificate() { + let value = Value::new(9999); + + let [(v1, _sk1), (v2, _sk2), (v3, sk3)] = make_validators([2, 3, 2]); + let (_my_sk, my_addr) = (sk3.clone(), v3.address); + + let height = Height::new(1); + let ctx = TestContext::new(); + let vs = ValidatorSet::new(vec![v1.clone(), v2.clone(), v3.clone()]); + + let proposal = Proposal::new( + Height::new(1), + Round::new(1), + value.clone(), + Round::Nil, + v1.address, + ); + + let mut driver = Driver::new(ctx, height, vs, my_addr, Default::default()); + + let steps = vec![ + TestStep { + desc: "Start round 0, we are not the proposer", + input: new_round_input(Round::new(0), v1.address), + expected_outputs: vec![start_propose_timer_output(Round::new(0))], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "we get a commit certificate for v at round 1", + input: commit_certificate_input_at( + Round::new(1), + value.clone(), + &[v1.address, v2.address], + ), + expected_outputs: vec![], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "Receive proposal from round 1", + input: proposal_input_from_proposal(proposal.clone(), Validity::Valid), + expected_outputs: vec![decide_output(Round::new(0), proposal)], + expected_round: Round::new(0), + new_state: decided_state(Round::new(0), Round::new(1), value), + }, + ]; + + run_steps(&mut driver, steps); +} + fn run_steps(driver: &mut Driver, steps: Vec) { let mut last_step = Step::Unstarted; for step in steps { From 0f44c185b75115b866f0836c5b7b7b43b4e393b6 Mon Sep 17 00:00:00 2001 From: Daniel Cason Date: Thu, 12 Feb 2026 20:27:57 -0800 Subject: [PATCH 3/7] driver: test upon commit, move to new round --- code/crates/core-driver/tests/it/extra.rs | 48 ++++++++++++++++------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/code/crates/core-driver/tests/it/extra.rs b/code/crates/core-driver/tests/it/extra.rs index 55f03bde5..5d301e83f 100644 --- a/code/crates/core-driver/tests/it/extra.rs +++ b/code/crates/core-driver/tests/it/extra.rs @@ -3647,7 +3647,7 @@ fn sync_decision_certificate_then_proposal() { } #[test] -fn round_1_decision_during_round_0() { +fn round_1_decision_during_round_0_with_votes() { let value = Value::new(9999); let [(v1, _sk1), (v2, _sk2), (v3, sk3)] = make_validators([2, 3, 2]); @@ -3690,11 +3690,21 @@ fn round_1_decision_during_round_0() { new_state: propose_state(Round::new(0)), }, TestStep { - desc: "v2 precommits the same round 1 proposal, we have a decision", + desc: "v2 precommits the same round 1 proposal, move to round 1", input: precommit_input(Round::new(1), value.clone(), &v2.address), - expected_outputs: vec![decide_output(Round::new(0), proposal)], - expected_round: Round::new(0), - new_state: decided_state(Round::new(0), Round::new(1), value), + expected_outputs: vec![new_round_output(Round::new(1))], + expected_round: Round::new(1), + new_state: new_round(Round::new(1)), + }, + TestStep { + desc: "Start round 1 and decide immediately", + input: new_round_input(Round::new(1), v2.address), + expected_outputs: vec![ + start_propose_timer_output(Round::new(1)), + decide_output(Round::new(1), proposal), + ], + expected_round: Round::new(1), + new_state: decided_state(Round::new(1), Round::new(1), value), }, ]; @@ -3731,22 +3741,32 @@ fn round_1_decision_during_round_0_via_certificate() { new_state: propose_state(Round::new(0)), }, TestStep { - desc: "we get a commit certificate for v at round 1", + desc: "Receive proposal from round 1", + input: proposal_input_from_proposal(proposal.clone(), Validity::Valid), + expected_outputs: vec![], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "we get a commit certificate for v at round 1, move to round 1", input: commit_certificate_input_at( Round::new(1), value.clone(), &[v1.address, v2.address], ), - expected_outputs: vec![], - expected_round: Round::new(0), - new_state: propose_state(Round::new(0)), + expected_outputs: vec![new_round_output(Round::new(1))], + expected_round: Round::new(1), + new_state: new_round(Round::new(1)), }, TestStep { - desc: "Receive proposal from round 1", - input: proposal_input_from_proposal(proposal.clone(), Validity::Valid), - expected_outputs: vec![decide_output(Round::new(0), proposal)], - expected_round: Round::new(0), - new_state: decided_state(Round::new(0), Round::new(1), value), + desc: "Start round 1 and decide immediately", + input: new_round_input(Round::new(1), v2.address), + expected_outputs: vec![ + start_propose_timer_output(Round::new(1)), + decide_output(Round::new(1), proposal), + ], + expected_round: Round::new(1), + new_state: decided_state(Round::new(1), Round::new(1), value), }, ]; From ec77f7fee071ef3fab7271bad906159b3313f69d Mon Sep 17 00:00:00 2001 From: Daniel Cason Date: Thu, 12 Feb 2026 20:30:40 -0800 Subject: [PATCH 4/7] driver: move to future round upon certificate --- code/crates/core-driver/src/mux.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/code/crates/core-driver/src/mux.rs b/code/crates/core-driver/src/mux.rs index a9f61d172..dcd07cb5e 100644 --- a/code/crates/core-driver/src/mux.rs +++ b/code/crates/core-driver/src/mux.rs @@ -214,7 +214,9 @@ where // Store the certificate self.commit_certificates.push(certificate); - if let Some((signed_proposal, validity)) = + if certificate_round > self.round() { + return Some(RoundInput::SkipRound(certificate_round)); + } else if let Some((signed_proposal, validity)) = self.proposal_and_validity_for_round_and_value(certificate_round, certificate_value_id) { if validity.is_valid() { From d28da9b3537c8d2c090c93681eec8d668430af35 Mon Sep 17 00:00:00 2001 From: Daniel Cason Date: Thu, 12 Feb 2026 19:15:53 -0800 Subject: [PATCH 5/7] driver: same tests but with invalid proposals --- code/crates/core-driver/tests/it/extra.rs | 107 ++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/code/crates/core-driver/tests/it/extra.rs b/code/crates/core-driver/tests/it/extra.rs index 5d301e83f..d2a9db4db 100644 --- a/code/crates/core-driver/tests/it/extra.rs +++ b/code/crates/core-driver/tests/it/extra.rs @@ -3773,6 +3773,113 @@ fn round_1_decision_during_round_0_via_certificate() { run_steps(&mut driver, steps); } +#[test] +fn round_1_invalid_decision_during_round_0() { + let value = Value::new(9999); + + let [(v1, _sk1), (v2, _sk2), (v3, sk3)] = make_validators([2, 3, 2]); + let (_my_sk, my_addr) = (sk3.clone(), v3.address); + + let height = Height::new(1); + let ctx = TestContext::new(); + let vs = ValidatorSet::new(vec![v1.clone(), v2.clone(), v3.clone()]); + + let proposal = Proposal::new( + Height::new(1), + Round::new(1), + value.clone(), + Round::Nil, + v1.address, + ); + + let mut driver = Driver::new(ctx, height, vs, my_addr, Default::default()); + + let steps = vec![ + TestStep { + desc: "Start round 0, we are not the proposer", + input: new_round_input(Round::new(0), v1.address), + expected_outputs: vec![start_propose_timer_output(Round::new(0))], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "v1 precommits a proposal in round 1", + input: precommit_input(Round::new(1), value.clone(), &v1.address), + expected_outputs: vec![], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "Receive proposal from round 1, which is invalid", + input: proposal_input_from_proposal(proposal.clone(), Validity::Invalid), + expected_outputs: vec![], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "v2 precommits the same round 1 proposal, but we don't decide", + input: precommit_input(Round::new(1), value.clone(), &v2.address), + expected_outputs: vec![new_round_output(Round::new(1))], + expected_round: Round::new(1), + new_state: new_round(Round::new(1)), + }, + ]; + + run_steps(&mut driver, steps); +} + +#[test] +fn round_1_invalid_decision_during_round_0_via_certificate() { + let value = Value::new(9999); + + let [(v1, _sk1), (v2, _sk2), (v3, sk3)] = make_validators([2, 3, 2]); + let (_my_sk, my_addr) = (sk3.clone(), v3.address); + + let height = Height::new(1); + let ctx = TestContext::new(); + let vs = ValidatorSet::new(vec![v1.clone(), v2.clone(), v3.clone()]); + + let proposal = Proposal::new( + Height::new(1), + Round::new(1), + value.clone(), + Round::Nil, + v1.address, + ); + + let mut driver = Driver::new(ctx, height, vs, my_addr, Default::default()); + + let steps = vec![ + TestStep { + desc: "Start round 0, we are not the proposer", + input: new_round_input(Round::new(0), v1.address), + expected_outputs: vec![start_propose_timer_output(Round::new(0))], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "we get a commit certificate for v at round 1", + input: commit_certificate_input_at( + Round::new(1), + value.clone(), + &[v1.address, v2.address], + ), + expected_outputs: vec![], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "Receive proposal from round 1, which is invalid", + input: proposal_input_from_proposal(proposal.clone(), Validity::Invalid), + expected_outputs: vec![], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + ]; + + run_steps(&mut driver, steps); +} + fn run_steps(driver: &mut Driver, steps: Vec) { let mut last_step = Step::Unstarted; for step in steps { From edbe43ed0c3ce020a7cb3335481ed7481b934e26 Mon Sep 17 00:00:00 2001 From: Daniel Cason Date: Thu, 12 Feb 2026 20:47:58 -0800 Subject: [PATCH 6/7] driver: updated tests to match this approach --- code/crates/core-driver/tests/it/extra.rs | 47 +++++++++++++++++------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/code/crates/core-driver/tests/it/extra.rs b/code/crates/core-driver/tests/it/extra.rs index d2a9db4db..ecc4af200 100644 --- a/code/crates/core-driver/tests/it/extra.rs +++ b/code/crates/core-driver/tests/it/extra.rs @@ -2977,7 +2977,7 @@ fn polka_nil_and_prevote_step_precommit_nil() { prevote_nil_output(Round::new(0), &my_addr), precommit_nil_output(Round::new(0), &my_addr), start_precommit_timer_output(Round::new(0)), - start_precommit_timer_output(Round::new(0)), + start_precommit_timer_output(Round::new(0)), // :'( ], expected_round: Round::new(0), new_state: precommit_state(Round::new(0)), @@ -3774,7 +3774,7 @@ fn round_1_decision_during_round_0_via_certificate() { } #[test] -fn round_1_invalid_decision_during_round_0() { +fn round_1_invalid_decision_during_round_0_with_votes() { let value = Value::new(9999); let [(v1, _sk1), (v2, _sk2), (v3, sk3)] = make_validators([2, 3, 2]); @@ -3817,12 +3817,24 @@ fn round_1_invalid_decision_during_round_0() { new_state: propose_state(Round::new(0)), }, TestStep { - desc: "v2 precommits the same round 1 proposal, but we don't decide", + desc: "v2 precommits the same round 1 proposal, move to round 1", input: precommit_input(Round::new(1), value.clone(), &v2.address), expected_outputs: vec![new_round_output(Round::new(1))], expected_round: Round::new(1), new_state: new_round(Round::new(1)), }, + TestStep { + desc: "Start round 1 but does not decide invalid proposal", + input: new_round_input(Round::new(1), v2.address), + expected_outputs: vec![ + start_propose_timer_output(Round::new(1)), + prevote_nil_output(Round::new(1), &my_addr), + start_precommit_timer_output(Round::new(1)), + start_precommit_timer_output(Round::new(1)), // :'( + ], + expected_round: Round::new(1), + new_state: prevote_state(Round::new(1)), + }, ]; run_steps(&mut driver, steps); @@ -3858,22 +3870,33 @@ fn round_1_invalid_decision_during_round_0_via_certificate() { new_state: propose_state(Round::new(0)), }, TestStep { - desc: "we get a commit certificate for v at round 1", + desc: "Receive proposal from round 1, which is invalid", + input: proposal_input_from_proposal(proposal.clone(), Validity::Invalid), + expected_outputs: vec![], + expected_round: Round::new(0), + new_state: propose_state(Round::new(0)), + }, + TestStep { + desc: "we get a commit certificate for v at round 1, move to round 1", input: commit_certificate_input_at( Round::new(1), value.clone(), &[v1.address, v2.address], ), - expected_outputs: vec![], - expected_round: Round::new(0), - new_state: propose_state(Round::new(0)), + expected_outputs: vec![new_round_output(Round::new(1))], + expected_round: Round::new(1), + new_state: new_round(Round::new(1)), }, TestStep { - desc: "Receive proposal from round 1, which is invalid", - input: proposal_input_from_proposal(proposal.clone(), Validity::Invalid), - expected_outputs: vec![], - expected_round: Round::new(0), - new_state: propose_state(Round::new(0)), + desc: "Start round 1 but does not decide invalid proposal", + input: new_round_input(Round::new(1), v2.address), + expected_outputs: vec![ + start_propose_timer_output(Round::new(1)), + prevote_nil_output(Round::new(1), &my_addr), + start_precommit_timer_output(Round::new(1)), + ], + expected_round: Round::new(1), + new_state: prevote_state(Round::new(1)), }, ]; From 5fe331d194f3d9b68244545a61f1e09caebb1511 Mon Sep 17 00:00:00 2001 From: Daniel Cason Date: Thu, 12 Feb 2026 21:01:23 -0800 Subject: [PATCH 7/7] driver: Commit for an invalid value = PrecommitAny --- code/crates/core-driver/src/driver.rs | 12 +++++++++++- code/crates/core-driver/src/mux.rs | 4 ++++ code/crates/core-driver/tests/it/extra.rs | 1 + 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/code/crates/core-driver/src/driver.rs b/code/crates/core-driver/src/driver.rs index 869bb37a7..badb2f081 100644 --- a/code/crates/core-driver/src/driver.rs +++ b/code/crates/core-driver/src/driver.rs @@ -241,7 +241,7 @@ where } } - /// Get a commit certificate for the given round and value id. + /// Get a commit certificate for the given round and value id, if any. pub fn commit_certificate( &self, round: Round, @@ -252,6 +252,16 @@ where .find(|c| &c.value_id == value_id && c.round == round && c.height == self.height()) } + /// Get a commit certificate for the given round, if any. + pub fn commit_certificate_for_round( + &self, + round: Round, + ) -> Option<&CommitCertificate> { + self.commit_certificates + .iter() + .find(|c| c.round == round && c.height == self.height()) + } + /// Return the commit certificates, if any. pub fn commit_certificates(&self) -> &[CommitCertificate] { self.commit_certificates.as_ref() diff --git a/code/crates/core-driver/src/mux.rs b/code/crates/core-driver/src/mux.rs index dcd07cb5e..a8bf48d39 100644 --- a/code/crates/core-driver/src/mux.rs +++ b/code/crates/core-driver/src/mux.rs @@ -401,6 +401,10 @@ where for threshold in find_non_value_threshold(&self.vote_keeper, round) { result.push(self.multiplex_vote_threshold(threshold, round)) } + // This is equivalent of having a VKOutput::PrecommitAny + if self.commit_certificate_for_round(round).is_some() { + result.push(self.multiplex_vote_threshold(VKOutput::PrecommitAny, round)); + } result } } diff --git a/code/crates/core-driver/tests/it/extra.rs b/code/crates/core-driver/tests/it/extra.rs index ecc4af200..79d6f14a8 100644 --- a/code/crates/core-driver/tests/it/extra.rs +++ b/code/crates/core-driver/tests/it/extra.rs @@ -3894,6 +3894,7 @@ fn round_1_invalid_decision_during_round_0_via_certificate() { start_propose_timer_output(Round::new(1)), prevote_nil_output(Round::new(1), &my_addr), start_precommit_timer_output(Round::new(1)), + start_precommit_timer_output(Round::new(1)), // :'( ], expected_round: Round::new(1), new_state: prevote_state(Round::new(1)),