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  }