github.com/onflow/flow-go@v0.35.7-crescendo-preview.23-atree-inlining/consensus/hotstuff/safetyrules/safety_rules_test.go (about) 1 package safetyrules 2 3 import ( 4 "errors" 5 "testing" 6 7 "github.com/stretchr/testify/mock" 8 "github.com/stretchr/testify/require" 9 "github.com/stretchr/testify/suite" 10 11 "github.com/onflow/flow-go/consensus/hotstuff" 12 "github.com/onflow/flow-go/consensus/hotstuff/helper" 13 "github.com/onflow/flow-go/consensus/hotstuff/mocks" 14 "github.com/onflow/flow-go/consensus/hotstuff/model" 15 "github.com/onflow/flow-go/model/flow" 16 "github.com/onflow/flow-go/utils/unittest" 17 ) 18 19 func TestSafetyRules(t *testing.T) { 20 suite.Run(t, new(SafetyRulesTestSuite)) 21 } 22 23 // SafetyRulesTestSuite is a test suite for testing SafetyRules related functionality. 24 // SafetyRulesTestSuite setups mocks for injected modules and creates hotstuff.SafetyData 25 // based on next configuration: 26 // R <- B[QC_R] <- P[QC_B] 27 // B.View = S.View + 1 28 // B - bootstrapped block, we are creating SafetyRules at block B 29 // Based on this HighestAcknowledgedView = B.View and 30 type SafetyRulesTestSuite struct { 31 suite.Suite 32 33 bootstrapBlock *model.Block 34 proposal *model.Proposal 35 proposerIdentity *flow.Identity 36 ourIdentity *flow.Identity 37 signer *mocks.Signer 38 persister *mocks.Persister 39 committee *mocks.DynamicCommittee 40 safetyData *hotstuff.SafetyData 41 safety *SafetyRules 42 } 43 44 func (s *SafetyRulesTestSuite) SetupTest() { 45 s.ourIdentity = unittest.IdentityFixture() 46 s.signer = &mocks.Signer{} 47 s.persister = &mocks.Persister{} 48 s.committee = &mocks.DynamicCommittee{} 49 s.proposerIdentity = unittest.IdentityFixture() 50 51 // bootstrap at random bootstrapBlock 52 s.bootstrapBlock = helper.MakeBlock(helper.WithBlockView(100)) 53 s.proposal = helper.MakeProposal( 54 helper.WithBlock( 55 helper.MakeBlock( 56 helper.WithParentBlock(s.bootstrapBlock), 57 helper.WithBlockView(s.bootstrapBlock.View+1), 58 helper.WithBlockProposer(s.proposerIdentity.NodeID)), 59 )) 60 61 s.committee.On("Self").Return(s.ourIdentity.NodeID).Maybe() 62 s.committee.On("IdentityByBlock", mock.Anything, s.ourIdentity.NodeID).Return(s.ourIdentity, nil).Maybe() 63 s.committee.On("IdentityByBlock", s.proposal.Block.BlockID, s.proposal.Block.ProposerID).Return(s.proposerIdentity, nil).Maybe() 64 s.committee.On("IdentityByEpoch", mock.Anything, s.ourIdentity.NodeID).Return(&s.ourIdentity.IdentitySkeleton, nil).Maybe() 65 66 s.safetyData = &hotstuff.SafetyData{ 67 LockedOneChainView: s.bootstrapBlock.View, 68 HighestAcknowledgedView: s.bootstrapBlock.View, 69 } 70 71 s.persister.On("GetSafetyData").Return(s.safetyData, nil).Once() 72 var err error 73 s.safety, err = New(s.signer, s.persister, s.committee) 74 require.NoError(s.T(), err) 75 } 76 77 // TestProduceVote_ShouldVote test basic happy path scenario where we vote for first block after bootstrap 78 // and next view ended with TC 79 func (s *SafetyRulesTestSuite) TestProduceVote_ShouldVote() { 80 expectedSafetyData := &hotstuff.SafetyData{ 81 LockedOneChainView: s.proposal.Block.QC.View, 82 HighestAcknowledgedView: s.proposal.Block.View, 83 } 84 85 expectedVote := makeVote(s.proposal.Block) 86 s.signer.On("CreateVote", s.proposal.Block).Return(expectedVote, nil).Once() 87 s.persister.On("PutSafetyData", expectedSafetyData).Return(nil).Once() 88 89 vote, err := s.safety.ProduceVote(s.proposal, s.proposal.Block.View) 90 require.NoError(s.T(), err) 91 require.NotNil(s.T(), vote) 92 require.Equal(s.T(), expectedVote, vote) 93 94 s.persister.AssertCalled(s.T(), "PutSafetyData", expectedSafetyData) 95 96 // producing vote for same view yields an error since we have voted already for this view 97 otherVote, err := s.safety.ProduceVote(s.proposal, s.proposal.Block.View) 98 require.True(s.T(), model.IsNoVoteError(err)) 99 require.Nil(s.T(), otherVote) 100 101 lastViewTC := helper.MakeTC( 102 helper.WithTCView(s.proposal.Block.View+1), 103 helper.WithTCNewestQC(s.proposal.Block.QC)) 104 105 // voting on proposal where last view ended with TC 106 proposalWithTC := helper.MakeProposal( 107 helper.WithBlock( 108 helper.MakeBlock( 109 helper.WithParentBlock(s.bootstrapBlock), 110 helper.WithBlockView(s.proposal.Block.View+2), 111 helper.WithBlockProposer(s.proposerIdentity.NodeID))), 112 helper.WithLastViewTC(lastViewTC)) 113 114 expectedSafetyData = &hotstuff.SafetyData{ 115 LockedOneChainView: s.proposal.Block.QC.View, 116 HighestAcknowledgedView: proposalWithTC.Block.View, 117 } 118 119 expectedVote = makeVote(proposalWithTC.Block) 120 s.signer.On("CreateVote", proposalWithTC.Block).Return(expectedVote, nil).Once() 121 s.persister.On("PutSafetyData", expectedSafetyData).Return(nil).Once() 122 s.committee.On("IdentityByBlock", proposalWithTC.Block.BlockID, proposalWithTC.Block.ProposerID).Return(s.proposerIdentity, nil).Maybe() 123 124 vote, err = s.safety.ProduceVote(proposalWithTC, proposalWithTC.Block.View) 125 require.NoError(s.T(), err) 126 require.NotNil(s.T(), vote) 127 require.Equal(s.T(), expectedVote, vote) 128 s.signer.AssertExpectations(s.T()) 129 s.persister.AssertCalled(s.T(), "PutSafetyData", expectedSafetyData) 130 } 131 132 // TestProduceVote_IncludedQCHigherThanTCsQC checks specific scenario where previous round resulted in TC and leader 133 // knows about QC which is not part of TC and qc.View > tc.NewestQC.View. We want to allow this, in this case leader 134 // includes his QC into proposal satisfies next condition: Block.QC.View > lastViewTC.NewestQC.View 135 func (s *SafetyRulesTestSuite) TestProduceVote_IncludedQCHigherThanTCsQC() { 136 lastViewTC := helper.MakeTC( 137 helper.WithTCView(s.proposal.Block.View+1), 138 helper.WithTCNewestQC(s.proposal.Block.QC)) 139 140 // voting on proposal where last view ended with TC 141 proposalWithTC := helper.MakeProposal( 142 helper.WithBlock( 143 helper.MakeBlock( 144 helper.WithParentBlock(s.proposal.Block), 145 helper.WithBlockView(s.proposal.Block.View+2), 146 helper.WithBlockProposer(s.proposerIdentity.NodeID))), 147 helper.WithLastViewTC(lastViewTC)) 148 149 expectedSafetyData := &hotstuff.SafetyData{ 150 LockedOneChainView: proposalWithTC.Block.QC.View, 151 HighestAcknowledgedView: proposalWithTC.Block.View, 152 } 153 154 require.Greater(s.T(), proposalWithTC.Block.QC.View, proposalWithTC.LastViewTC.NewestQC.View, 155 "for this test case we specifically require that qc.View > lastViewTC.NewestQC.View") 156 157 expectedVote := makeVote(proposalWithTC.Block) 158 s.signer.On("CreateVote", proposalWithTC.Block).Return(expectedVote, nil).Once() 159 s.persister.On("PutSafetyData", expectedSafetyData).Return(nil).Once() 160 s.committee.On("IdentityByBlock", proposalWithTC.Block.BlockID, proposalWithTC.Block.ProposerID).Return(s.proposerIdentity, nil).Maybe() 161 162 vote, err := s.safety.ProduceVote(proposalWithTC, proposalWithTC.Block.View) 163 require.NoError(s.T(), err) 164 require.NotNil(s.T(), vote) 165 require.Equal(s.T(), expectedVote, vote) 166 s.signer.AssertExpectations(s.T()) 167 s.persister.AssertCalled(s.T(), "PutSafetyData", expectedSafetyData) 168 } 169 170 // TestProduceVote_UpdateLockedOneChainView tests that LockedOneChainView is updated when sees a higher QC. 171 // Note: `LockedOneChainView` is only updated when the replica votes. 172 func (s *SafetyRulesTestSuite) TestProduceVote_UpdateLockedOneChainView() { 173 s.safety.safetyData.LockedOneChainView = 0 174 175 require.NotEqual(s.T(), s.safety.safetyData.LockedOneChainView, s.proposal.Block.QC.View, 176 "in this test LockedOneChainView is lower so it needs to be updated") 177 178 expectedSafetyData := &hotstuff.SafetyData{ 179 LockedOneChainView: s.proposal.Block.QC.View, 180 HighestAcknowledgedView: s.proposal.Block.View, 181 } 182 183 expectedVote := makeVote(s.proposal.Block) 184 s.signer.On("CreateVote", s.proposal.Block).Return(expectedVote, nil).Once() 185 s.persister.On("PutSafetyData", expectedSafetyData).Return(nil).Once() 186 187 vote, err := s.safety.ProduceVote(s.proposal, s.proposal.Block.View) 188 require.NoError(s.T(), err) 189 require.NotNil(s.T(), vote) 190 require.Equal(s.T(), expectedVote, vote) 191 s.signer.AssertExpectations(s.T()) 192 s.persister.AssertCalled(s.T(), "PutSafetyData", expectedSafetyData) 193 } 194 195 // TestProduceVote_InvalidCurrentView tests that no vote is created if `curView` has invalid values. 196 // In particular, `SafetyRules` requires that: 197 // - the block's view matches `curView` 198 // - that values for `curView` are monotonously increasing 199 // 200 // Failing any of these conditions is a symptom of an internal bug; hence `SafetyRules` should 201 // _not_ return a `NoVoteError`. 202 func (s *SafetyRulesTestSuite) TestProduceVote_InvalidCurrentView() { 203 204 s.Run("block-view-does-not-match", func() { 205 vote, err := s.safety.ProduceVote(s.proposal, s.proposal.Block.View+1) 206 require.Nil(s.T(), vote) 207 require.Error(s.T(), err) 208 require.False(s.T(), model.IsNoVoteError(err)) 209 }) 210 s.Run("view-not-monotonously-increasing", func() { 211 // create block with view < HighestAcknowledgedView 212 proposal := helper.MakeProposal( 213 helper.WithBlock( 214 helper.MakeBlock( 215 func(block *model.Block) { 216 block.QC = helper.MakeQC(helper.WithQCView(s.safetyData.HighestAcknowledgedView - 2)) 217 }, 218 helper.WithBlockView(s.safetyData.HighestAcknowledgedView-1)))) 219 vote, err := s.safety.ProduceVote(proposal, proposal.Block.View) 220 require.Nil(s.T(), vote) 221 require.Error(s.T(), err) 222 require.False(s.T(), model.IsNoVoteError(err)) 223 }) 224 225 s.persister.AssertNotCalled(s.T(), "PutSafetyData") 226 } 227 228 // TestProduceVote_NodeEjected tests that no vote is created if block proposer is ejected 229 func (s *SafetyRulesTestSuite) TestProduceVote_ProposerEjected() { 230 *s.committee = mocks.DynamicCommittee{} 231 s.committee.On("IdentityByBlock", s.proposal.Block.BlockID, s.proposal.Block.ProposerID).Return(nil, model.NewInvalidSignerErrorf("node-ejected")).Once() 232 233 vote, err := s.safety.ProduceVote(s.proposal, s.proposal.Block.View) 234 require.Nil(s.T(), vote) 235 require.True(s.T(), model.IsNoVoteError(err)) 236 s.persister.AssertNotCalled(s.T(), "PutSafetyData") 237 } 238 239 // TestProduceVote_InvalidProposerIdentity tests that no vote is created if there was an exception retrieving proposer identity 240 // We are specifically testing that unexpected errors are handled correctly, i.e. 241 // that SafetyRules does not erroneously wrap unexpected exceptions into the expected NoVoteError. 242 func (s *SafetyRulesTestSuite) TestProduceVote_InvalidProposerIdentity() { 243 *s.committee = mocks.DynamicCommittee{} 244 exception := errors.New("invalid-signer-identity") 245 s.committee.On("IdentityByBlock", s.proposal.Block.BlockID, s.proposal.Block.ProposerID).Return(nil, exception).Once() 246 247 vote, err := s.safety.ProduceVote(s.proposal, s.proposal.Block.View) 248 require.Nil(s.T(), vote) 249 require.ErrorIs(s.T(), err, exception) 250 require.False(s.T(), model.IsNoVoteError(err)) 251 s.persister.AssertNotCalled(s.T(), "PutSafetyData") 252 } 253 254 // TestProduceVote_NodeNotAuthorizedToVote tests that no vote is created if the voter is not authorized to vote. 255 // Nodes have zero weight in the grace periods around the epochs where they are authorized to participate. 256 // We don't want zero-weight nodes to vote in the first place, to avoid unnecessary traffic. 257 // Note: this also covers ejected nodes. In both cases, the committee will return an `InvalidSignerError`. 258 func (s *SafetyRulesTestSuite) TestProduceVote_NodeEjected() { 259 *s.committee = mocks.DynamicCommittee{} 260 s.committee.On("Self").Return(s.ourIdentity.NodeID) 261 s.committee.On("IdentityByBlock", s.proposal.Block.BlockID, s.ourIdentity.NodeID).Return(nil, model.NewInvalidSignerErrorf("node-ejected")).Once() 262 s.committee.On("IdentityByBlock", s.proposal.Block.BlockID, s.proposal.Block.ProposerID).Return(s.proposerIdentity, nil).Maybe() 263 264 vote, err := s.safety.ProduceVote(s.proposal, s.proposal.Block.View) 265 require.Nil(s.T(), vote) 266 require.True(s.T(), model.IsNoVoteError(err)) 267 s.persister.AssertNotCalled(s.T(), "PutSafetyData") 268 } 269 270 // TestProduceVote_InvalidVoterIdentity tests that no vote is created if there was an exception retrieving voter identity 271 // We are specifically testing that unexpected errors are handled correctly, i.e. 272 // that SafetyRules does not erroneously wrap unexpected exceptions into the expected NoVoteError. 273 func (s *SafetyRulesTestSuite) TestProduceVote_InvalidVoterIdentity() { 274 *s.committee = mocks.DynamicCommittee{} 275 s.committee.On("Self").Return(s.ourIdentity.NodeID) 276 exception := errors.New("invalid-signer-identity") 277 s.committee.On("IdentityByBlock", s.proposal.Block.BlockID, s.proposal.Block.ProposerID).Return(s.proposerIdentity, nil).Maybe() 278 s.committee.On("IdentityByBlock", s.proposal.Block.BlockID, s.ourIdentity.NodeID).Return(nil, exception).Once() 279 280 vote, err := s.safety.ProduceVote(s.proposal, s.proposal.Block.View) 281 require.Nil(s.T(), vote) 282 require.ErrorIs(s.T(), err, exception) 283 require.False(s.T(), model.IsNoVoteError(err)) 284 s.persister.AssertNotCalled(s.T(), "PutSafetyData") 285 } 286 287 // TestProduceVote_CreateVoteException tests that no vote is created if vote creation raised an exception 288 func (s *SafetyRulesTestSuite) TestProduceVote_CreateVoteException() { 289 exception := errors.New("create-vote-exception") 290 s.signer.On("CreateVote", s.proposal.Block).Return(nil, exception).Once() 291 vote, err := s.safety.ProduceVote(s.proposal, s.proposal.Block.View) 292 require.Nil(s.T(), vote) 293 require.ErrorIs(s.T(), err, exception) 294 require.False(s.T(), model.IsNoVoteError(err)) 295 s.persister.AssertNotCalled(s.T(), "PutSafetyData") 296 } 297 298 // TestProduceVote_PersistStateException tests that no vote is created if persisting state failed 299 func (s *SafetyRulesTestSuite) TestProduceVote_PersistStateException() { 300 exception := errors.New("persister-exception") 301 s.persister.On("PutSafetyData", mock.Anything).Return(exception) 302 303 vote := makeVote(s.proposal.Block) 304 s.signer.On("CreateVote", s.proposal.Block).Return(vote, nil).Once() 305 vote, err := s.safety.ProduceVote(s.proposal, s.proposal.Block.View) 306 require.Nil(s.T(), vote) 307 require.ErrorIs(s.T(), err, exception) 308 } 309 310 // TestProduceVote_VotingOnInvalidProposals tests different scenarios where we try to vote on unsafe blocks 311 // SafetyRules contain a variety of checks to confirm that QC and TC have the desired relationship to each other. 312 // In particular, we test: 313 // 314 // (i) A TC should be included in a proposal, if and only of the QC is not the prior view. 315 // (ii) When the proposal includes a TC (i.e. the QC not being for the prior view), the TC must be for the prior view. 316 // (iii) The QC in the block must have a smaller view than the block. 317 // (iv) If the block contains a TC, the TC cannot contain a newer QC than the block itself. 318 // 319 // Conditions (i) - (iv) are validity requirements for the block and all blocks that SafetyRules processes 320 // are supposed to be pre-validated. Hence, failing any of those conditions means we have an internal bug. 321 // Consequently, we expect SafetyRules to return exceptions but _not_ `NoVoteError`, because the latter 322 // indicates that the input block was valid, but we didn't want to vote. 323 func (s *SafetyRulesTestSuite) TestProduceVote_VotingOnInvalidProposals() { 324 325 // a proposal which includes a QC for the previous round should not contain a TC 326 s.Run("proposal-includes-last-view-qc-and-tc", func() { 327 proposal := helper.MakeProposal( 328 helper.WithBlock( 329 helper.MakeBlock( 330 helper.WithParentBlock(s.bootstrapBlock), 331 helper.WithBlockView(s.bootstrapBlock.View+1))), 332 helper.WithLastViewTC(helper.MakeTC())) 333 s.committee.On("IdentityByBlock", proposal.Block.BlockID, proposal.Block.ProposerID).Return(s.proposerIdentity, nil).Maybe() 334 vote, err := s.safety.ProduceVote(proposal, proposal.Block.View) 335 require.Error(s.T(), err) 336 require.False(s.T(), model.IsNoVoteError(err)) 337 require.Nil(s.T(), vote) 338 }) 339 s.Run("no-last-view-tc", func() { 340 // create block where Block.View != Block.QC.View+1 and LastViewTC = nil 341 proposal := helper.MakeProposal( 342 helper.WithBlock( 343 helper.MakeBlock( 344 helper.WithParentBlock(s.bootstrapBlock), 345 helper.WithBlockView(s.bootstrapBlock.View+2)))) 346 vote, err := s.safety.ProduceVote(proposal, proposal.Block.View) 347 require.Error(s.T(), err) 348 require.False(s.T(), model.IsNoVoteError(err)) 349 require.Nil(s.T(), vote) 350 }) 351 s.Run("last-view-tc-invalid-view", func() { 352 // create block where Block.View != Block.QC.View+1 and 353 // Block.View != LastViewTC.View+1 354 proposal := helper.MakeProposal( 355 helper.WithBlock( 356 helper.MakeBlock( 357 helper.WithParentBlock(s.bootstrapBlock), 358 helper.WithBlockView(s.bootstrapBlock.View+2))), 359 helper.WithLastViewTC( 360 helper.MakeTC( 361 helper.WithTCView(s.bootstrapBlock.View)))) 362 vote, err := s.safety.ProduceVote(proposal, proposal.Block.View) 363 require.Error(s.T(), err) 364 require.False(s.T(), model.IsNoVoteError(err)) 365 require.Nil(s.T(), vote) 366 }) 367 s.Run("proposal-includes-QC-for-higher-view", func() { 368 // create block where Block.View != Block.QC.View+1 and 369 // Block.View == LastViewTC.View+1 and Block.QC.View >= Block.View 370 // in this case block is not safe to extend since proposal includes QC which is newer than the proposal itself. 371 proposal := helper.MakeProposal( 372 helper.WithBlock( 373 helper.MakeBlock( 374 helper.WithParentBlock(s.bootstrapBlock), 375 helper.WithBlockView(s.bootstrapBlock.View+2), 376 func(block *model.Block) { 377 block.QC = helper.MakeQC(helper.WithQCView(s.bootstrapBlock.View + 10)) 378 })), 379 helper.WithLastViewTC( 380 helper.MakeTC( 381 helper.WithTCView(s.bootstrapBlock.View+1)))) 382 vote, err := s.safety.ProduceVote(proposal, proposal.Block.View) 383 require.Error(s.T(), err) 384 require.False(s.T(), model.IsNoVoteError(err)) 385 require.Nil(s.T(), vote) 386 }) 387 s.Run("last-view-tc-invalid-highest-qc", func() { 388 // create block where Block.View != Block.QC.View+1 and 389 // Block.View == LastViewTC.View+1 and Block.QC.View < LastViewTC.NewestQC.View 390 // in this case block is not safe to extend since proposal is built on top of QC, which is lower 391 // than QC presented in LastViewTC. 392 TONewestQC := helper.MakeQC(helper.WithQCView(s.bootstrapBlock.View + 1)) 393 proposal := helper.MakeProposal( 394 helper.WithBlock( 395 helper.MakeBlock( 396 helper.WithParentBlock(s.bootstrapBlock), 397 helper.WithBlockView(s.bootstrapBlock.View+2))), 398 helper.WithLastViewTC( 399 helper.MakeTC( 400 helper.WithTCView(s.bootstrapBlock.View+1), 401 helper.WithTCNewestQC(TONewestQC)))) 402 vote, err := s.safety.ProduceVote(proposal, proposal.Block.View) 403 require.Error(s.T(), err) 404 require.False(s.T(), model.IsNoVoteError(err)) 405 require.Nil(s.T(), vote) 406 }) 407 408 s.signer.AssertNotCalled(s.T(), "CreateVote") 409 s.persister.AssertNotCalled(s.T(), "PutSafetyData") 410 } 411 412 // TestProduceVote_VoteEquivocation tests scenario when we try to vote twice in same view. We require that replica 413 // follows next rules: 414 // - replica votes once per view 415 // - replica votes in monotonously increasing views 416 // 417 // Voting twice per round on equivocating proposals is considered a byzantine behavior. 418 // Expect a `model.NoVoteError` sentinel in such scenario. 419 func (s *SafetyRulesTestSuite) TestProduceVote_VoteEquivocation() { 420 expectedVote := makeVote(s.proposal.Block) 421 s.signer.On("CreateVote", s.proposal.Block).Return(expectedVote, nil).Once() 422 s.persister.On("PutSafetyData", mock.Anything).Return(nil).Once() 423 424 vote, err := s.safety.ProduceVote(s.proposal, s.proposal.Block.View) 425 require.NoError(s.T(), err) 426 require.NotNil(s.T(), vote) 427 require.Equal(s.T(), expectedVote, vote) 428 429 equivocatingProposal := helper.MakeProposal( 430 helper.WithBlock( 431 helper.MakeBlock( 432 helper.WithParentBlock(s.bootstrapBlock), 433 helper.WithBlockView(s.bootstrapBlock.View+1), 434 helper.WithBlockProposer(s.proposerIdentity.NodeID)), 435 )) 436 437 // voting at same view(event different proposal) should result in NoVoteError 438 vote, err = s.safety.ProduceVote(equivocatingProposal, s.proposal.Block.View) 439 require.True(s.T(), model.IsNoVoteError(err)) 440 require.Nil(s.T(), vote) 441 } 442 443 // TestProduceVote_AfterTimeout tests a scenario where we first timeout for view and then try to produce a vote for 444 // same view, this should result in error since producing a timeout means that we have given up on this view 445 // and are in process of moving forward, no vote should be created. 446 func (s *SafetyRulesTestSuite) TestProduceVote_AfterTimeout() { 447 view := s.proposal.Block.View 448 newestQC := helper.MakeQC(helper.WithQCView(view - 1)) 449 expectedTimeout := &model.TimeoutObject{ 450 View: view, 451 NewestQC: newestQC, 452 } 453 s.signer.On("CreateTimeout", view, newestQC, (*flow.TimeoutCertificate)(nil)).Return(expectedTimeout, nil).Once() 454 s.persister.On("PutSafetyData", mock.Anything).Return(nil).Once() 455 456 // first timeout, then try to vote 457 timeout, err := s.safety.ProduceTimeout(view, newestQC, nil) 458 require.NoError(s.T(), err) 459 require.NotNil(s.T(), timeout) 460 461 // voting in same view after producing timeout is not allowed 462 vote, err := s.safety.ProduceVote(s.proposal, view) 463 require.True(s.T(), model.IsNoVoteError(err)) 464 require.Nil(s.T(), vote) 465 466 s.signer.AssertExpectations(s.T()) 467 s.persister.AssertExpectations(s.T()) 468 } 469 470 // TestProduceTimeout_ShouldTimeout tests that we can produce timeout in cases where 471 // last view was successful or not. Also tests last timeout caching. 472 func (s *SafetyRulesTestSuite) TestProduceTimeout_ShouldTimeout() { 473 view := s.proposal.Block.View 474 newestQC := helper.MakeQC(helper.WithQCView(view - 1)) 475 expectedTimeout := &model.TimeoutObject{ 476 View: view, 477 NewestQC: newestQC, 478 } 479 480 expectedSafetyData := &hotstuff.SafetyData{ 481 LockedOneChainView: s.safetyData.LockedOneChainView, 482 HighestAcknowledgedView: view, 483 LastTimeout: expectedTimeout, 484 } 485 s.signer.On("CreateTimeout", view, newestQC, (*flow.TimeoutCertificate)(nil)).Return(expectedTimeout, nil).Once() 486 s.persister.On("PutSafetyData", expectedSafetyData).Return(nil).Once() 487 timeout, err := s.safety.ProduceTimeout(view, newestQC, nil) 488 require.NoError(s.T(), err) 489 require.Equal(s.T(), expectedTimeout, timeout) 490 491 s.persister.AssertCalled(s.T(), "PutSafetyData", expectedSafetyData) 492 493 // producing timeout with same arguments should return cached version but with incremented timeout tick 494 expectedSafetyData.LastTimeout = &model.TimeoutObject{} 495 *expectedSafetyData.LastTimeout = *expectedTimeout 496 expectedSafetyData.LastTimeout.TimeoutTick++ 497 s.persister.On("PutSafetyData", expectedSafetyData).Return(nil).Once() 498 499 otherTimeout, err := s.safety.ProduceTimeout(view, newestQC, nil) 500 require.NoError(s.T(), err) 501 require.Equal(s.T(), timeout.ID(), otherTimeout.ID()) 502 require.Equal(s.T(), timeout.TimeoutTick+1, otherTimeout.TimeoutTick) 503 504 // to create new TO we need to provide a TC 505 lastViewTC := helper.MakeTC(helper.WithTCView(view), 506 helper.WithTCNewestQC(newestQC)) 507 508 expectedTimeout = &model.TimeoutObject{ 509 View: view + 1, 510 NewestQC: newestQC, 511 LastViewTC: lastViewTC, 512 } 513 s.signer.On("CreateTimeout", view+1, newestQC, lastViewTC).Return(expectedTimeout, nil).Once() 514 expectedSafetyData = &hotstuff.SafetyData{ 515 LockedOneChainView: s.safetyData.LockedOneChainView, 516 HighestAcknowledgedView: view + 1, 517 LastTimeout: expectedTimeout, 518 } 519 s.persister.On("PutSafetyData", expectedSafetyData).Return(nil).Once() 520 521 // creating new timeout should invalidate cache 522 otherTimeout, err = s.safety.ProduceTimeout(view+1, newestQC, lastViewTC) 523 require.NoError(s.T(), err) 524 require.NotNil(s.T(), otherTimeout) 525 } 526 527 // TestProduceTimeout_NotSafeToTimeout tests that we don't produce a timeout when it's not safe 528 // We expect that the EventHandler to feed only request timeouts for the current view, providing valid set of inputs. 529 // Hence, the cases tested here would be symptoms of an internal bugs, and therefore should not result in an NoVoteError. 530 func (s *SafetyRulesTestSuite) TestProduceTimeout_NotSafeToTimeout() { 531 532 s.Run("newest-qc-nil", func() { 533 // newestQC cannot be nil 534 timeout, err := s.safety.ProduceTimeout(s.safetyData.LockedOneChainView, nil, nil) 535 require.Error(s.T(), err) 536 require.Nil(s.T(), timeout) 537 }) 538 // if a QC for the previous view is provided, a last view TC is unnecessary and must not be provided 539 s.Run("includes-last-view-qc-and-tc", func() { 540 newestQC := helper.MakeQC(helper.WithQCView(s.safetyData.LockedOneChainView)) 541 542 // tc not needed but included 543 timeout, err := s.safety.ProduceTimeout(newestQC.View+1, newestQC, helper.MakeTC()) 544 require.Error(s.T(), err) 545 require.Nil(s.T(), timeout) 546 }) 547 s.Run("last-view-tc-nil", func() { 548 newestQC := helper.MakeQC(helper.WithQCView(s.safetyData.LockedOneChainView)) 549 550 // tc needed but not included 551 timeout, err := s.safety.ProduceTimeout(newestQC.View+2, newestQC, nil) 552 require.Error(s.T(), err) 553 require.Nil(s.T(), timeout) 554 }) 555 s.Run("last-view-tc-for-wrong-view", func() { 556 newestQC := helper.MakeQC(helper.WithQCView(s.safetyData.LockedOneChainView)) 557 // lastViewTC should be for newestQC.View+1 558 lastViewTC := helper.MakeTC(helper.WithTCView(newestQC.View)) 559 560 timeout, err := s.safety.ProduceTimeout(newestQC.View+2, newestQC, lastViewTC) 561 require.Error(s.T(), err) 562 require.Nil(s.T(), timeout) 563 }) 564 s.Run("cur-view-equal-to-highest-QC", func() { 565 newestQC := helper.MakeQC(helper.WithQCView(s.safetyData.LockedOneChainView)) 566 lastViewTC := helper.MakeTC(helper.WithTCView(s.safetyData.LockedOneChainView - 1)) 567 568 timeout, err := s.safety.ProduceTimeout(s.safetyData.LockedOneChainView, newestQC, lastViewTC) 569 require.Error(s.T(), err) 570 require.Nil(s.T(), timeout) 571 }) 572 s.Run("cur-view-below-highest-QC", func() { 573 newestQC := helper.MakeQC(helper.WithQCView(s.safetyData.LockedOneChainView)) 574 lastViewTC := helper.MakeTC(helper.WithTCView(newestQC.View - 2)) 575 576 timeout, err := s.safety.ProduceTimeout(newestQC.View-1, newestQC, lastViewTC) 577 require.Error(s.T(), err) 578 require.Nil(s.T(), timeout) 579 }) 580 s.Run("last-view-tc-is-newer", func() { 581 newestQC := helper.MakeQC(helper.WithQCView(s.safetyData.LockedOneChainView)) 582 // newest QC included in TC cannot be higher than the newest QC known to replica 583 lastViewTC := helper.MakeTC(helper.WithTCView(newestQC.View+1), 584 helper.WithTCNewestQC(helper.MakeQC(helper.WithQCView(newestQC.View+1)))) 585 586 timeout, err := s.safety.ProduceTimeout(newestQC.View+2, newestQC, lastViewTC) 587 require.Error(s.T(), err) 588 require.Nil(s.T(), timeout) 589 }) 590 s.Run("highest-qc-below-locked-round", func() { 591 newestQC := helper.MakeQC(helper.WithQCView(s.safetyData.LockedOneChainView - 1)) 592 593 timeout, err := s.safety.ProduceTimeout(newestQC.View+1, newestQC, nil) 594 require.Error(s.T(), err) 595 require.Nil(s.T(), timeout) 596 }) 597 s.Run("cur-view-below-highest-acknowledged-view", func() { 598 newestQC := helper.MakeQC(helper.WithQCView(s.safetyData.LockedOneChainView)) 599 // modify highest acknowledged view in a way that it's definitely bigger than the newest QC view 600 s.safetyData.HighestAcknowledgedView = newestQC.View + 10 601 602 timeout, err := s.safety.ProduceTimeout(newestQC.View+1, newestQC, nil) 603 require.Error(s.T(), err) 604 require.Nil(s.T(), timeout) 605 }) 606 607 s.signer.AssertNotCalled(s.T(), "CreateTimeout") 608 s.signer.AssertNotCalled(s.T(), "PutSafetyData") 609 } 610 611 // TestProduceTimeout_CreateTimeoutException tests that no timeout is created if timeout creation raised an exception 612 func (s *SafetyRulesTestSuite) TestProduceTimeout_CreateTimeoutException() { 613 view := s.proposal.Block.View 614 newestQC := helper.MakeQC(helper.WithQCView(view - 1)) 615 616 exception := errors.New("create-timeout-exception") 617 s.signer.On("CreateTimeout", view, newestQC, (*flow.TimeoutCertificate)(nil)).Return(nil, exception).Once() 618 vote, err := s.safety.ProduceTimeout(view, newestQC, nil) 619 require.Nil(s.T(), vote) 620 require.ErrorIs(s.T(), err, exception) 621 require.False(s.T(), model.IsNoVoteError(err)) 622 s.persister.AssertNotCalled(s.T(), "PutSafetyData") 623 } 624 625 // TestProduceTimeout_PersistStateException tests that no timeout is created if persisting state failed 626 func (s *SafetyRulesTestSuite) TestProduceTimeout_PersistStateException() { 627 exception := errors.New("persister-exception") 628 s.persister.On("PutSafetyData", mock.Anything).Return(exception) 629 630 view := s.proposal.Block.View 631 newestQC := helper.MakeQC(helper.WithQCView(view - 1)) 632 expectedTimeout := &model.TimeoutObject{ 633 View: view, 634 NewestQC: newestQC, 635 } 636 637 s.signer.On("CreateTimeout", view, newestQC, (*flow.TimeoutCertificate)(nil)).Return(expectedTimeout, nil).Once() 638 timeout, err := s.safety.ProduceTimeout(view, newestQC, nil) 639 require.Nil(s.T(), timeout) 640 require.ErrorIs(s.T(), err, exception) 641 } 642 643 // TestProduceTimeout_AfterVote tests a case where we first produce a vote and then try to timeout 644 // for same view. This behavior is expected and should result in valid timeout without any errors. 645 func (s *SafetyRulesTestSuite) TestProduceTimeout_AfterVote() { 646 expectedVote := makeVote(s.proposal.Block) 647 s.signer.On("CreateVote", s.proposal.Block).Return(expectedVote, nil).Once() 648 s.persister.On("PutSafetyData", mock.Anything).Return(nil).Times(2) 649 650 view := s.proposal.Block.View 651 652 // first produce vote, then try to timeout 653 vote, err := s.safety.ProduceVote(s.proposal, view) 654 require.NoError(s.T(), err) 655 require.NotNil(s.T(), vote) 656 657 newestQC := helper.MakeQC(helper.WithQCView(view - 1)) 658 659 expectedTimeout := &model.TimeoutObject{ 660 View: view, 661 NewestQC: newestQC, 662 } 663 664 s.signer.On("CreateTimeout", view, newestQC, (*flow.TimeoutCertificate)(nil)).Return(expectedTimeout, nil).Once() 665 666 // timing out for same view should be possible 667 timeout, err := s.safety.ProduceTimeout(view, newestQC, nil) 668 require.NoError(s.T(), err) 669 require.NotNil(s.T(), timeout) 670 671 s.persister.AssertExpectations(s.T()) 672 s.signer.AssertExpectations(s.T()) 673 } 674 675 // TestProduceTimeout_InvalidProposerIdentity tests that no timeout is created if there was an exception retrieving proposer identity 676 // We are specifically testing that unexpected errors are handled correctly, i.e. 677 // that SafetyRules does not erroneously wrap unexpected exceptions into the expected model.NoTimeoutError. 678 func (s *SafetyRulesTestSuite) TestProduceTimeout_InvalidProposerIdentity() { 679 view := s.proposal.Block.View 680 newestQC := helper.MakeQC(helper.WithQCView(view - 1)) 681 *s.committee = mocks.DynamicCommittee{} 682 exception := errors.New("invalid-signer-identity") 683 s.committee.On("IdentityByEpoch", view, s.ourIdentity.NodeID).Return(nil, exception).Once() 684 s.committee.On("Self").Return(s.ourIdentity.NodeID) 685 686 timeout, err := s.safety.ProduceTimeout(view, newestQC, nil) 687 require.Nil(s.T(), timeout) 688 require.ErrorIs(s.T(), err, exception) 689 require.False(s.T(), model.IsNoTimeoutError(err)) 690 s.persister.AssertNotCalled(s.T(), "PutSafetyData") 691 } 692 693 // TestProduceTimeout_NodeEjected tests that no timeout is created if the replica is not authorized to create timeout. 694 // Nodes have zero weight in the grace periods around the epochs where they are authorized to participate. 695 // We don't want zero-weight nodes to participate in the first place, to avoid unnecessary traffic. 696 // Note: this also covers ejected nodes. In both cases, the committee will return an `InvalidSignerError`. 697 func (s *SafetyRulesTestSuite) TestProduceTimeout_NodeEjected() { 698 view := s.proposal.Block.View 699 newestQC := helper.MakeQC(helper.WithQCView(view - 1)) 700 *s.committee = mocks.DynamicCommittee{} 701 s.committee.On("Self").Return(s.ourIdentity.NodeID) 702 s.committee.On("IdentityByEpoch", view, s.ourIdentity.NodeID).Return(nil, model.NewInvalidSignerErrorf("")).Maybe() 703 704 timeout, err := s.safety.ProduceTimeout(view, newestQC, nil) 705 require.Nil(s.T(), timeout) 706 require.True(s.T(), model.IsNoTimeoutError(err)) 707 s.persister.AssertNotCalled(s.T(), "PutSafetyData") 708 } 709 710 func makeVote(block *model.Block) *model.Vote { 711 return &model.Vote{ 712 BlockID: block.BlockID, 713 View: block.View, 714 SigData: nil, // signature doesn't matter in this test case 715 } 716 }