Skip to content

Commit

Permalink
hotfix(MessageValidation): first slot proposals [main] (#1892)
Browse files Browse the repository at this point in the history
* implement first slot proposal handling

* remove RANDAO requirement

* remove unnecessary ResetEpoch

* set duties to empty despite failure

* revert RANDAO check removal

* slot time check

* post-fork

* add mocked beacon network behavior

* use mock network in tests

---------

Co-authored-by: moshe-blox <[email protected]>
  • Loading branch information
olegshmuelov and moshe-blox authored Dec 3, 2024
1 parent 9c7bb18 commit 7f928b8
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 18 deletions.
16 changes: 14 additions & 2 deletions message/validation/common_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,22 @@ func (mv *messageValidator) validateBeaconDuty(
role spectypes.RunnerRole,
slot phase0.Slot,
indices []phase0.ValidatorIndex,
randaoMsg bool,
) error {
epoch := mv.netCfg.Beacon.EstimatedEpochAtSlot(slot)

// Rule: For a proposal duty message, we check if the validator is assigned to it
if role == spectypes.RoleProposer {
epoch := mv.netCfg.Beacon.EstimatedEpochAtSlot(slot)
// Tolerate missing duties for RANDAO signatures during the first slot of an epoch,
// while duties are still being fetched from the Beacon node.
//
// Note: we allow current slot to be lower because of the ErrEarlyMessage rule.
if randaoMsg && mv.netCfg.Beacon.IsFirstSlotOfEpoch(slot) && mv.netCfg.Beacon.EstimatedCurrentSlot() <= slot {
if !mv.dutyStore.Proposer.IsEpochSet(epoch) {
return nil
}
}

// Non-committee roles always have one validator index.
validatorIndex := indices[0]
if mv.dutyStore.Proposer.ValidatorDuty(epoch, slot, validatorIndex) == nil {
Expand All @@ -130,7 +142,7 @@ func (mv *messageValidator) validateBeaconDuty(

// Rule: For a sync committee aggregation duty message, we check if the validator is assigned to it
if role == spectypes.RoleSyncCommitteeContribution {
period := mv.netCfg.Beacon.EstimatedSyncCommitteePeriodAtEpoch(mv.netCfg.Beacon.EstimatedEpochAtSlot(slot))
period := mv.netCfg.Beacon.EstimatedSyncCommitteePeriodAtEpoch(epoch)
// Non-committee roles always have one validator index.
validatorIndex := indices[0]
if mv.dutyStore.SyncCommittee.Duty(period, validatorIndex) == nil {
Expand Down
3 changes: 2 additions & 1 deletion message/validation/consensus_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ func (mv *messageValidator) validateQBFTMessageByDutyLogic(
}

msgSlot := phase0.Slot(consensusMessage.Height)
if err := mv.validateBeaconDuty(signedSSVMessage.SSVMessage.GetID().GetRoleType(), msgSlot, validatorIndices); err != nil {
randaoMsg := false
if err := mv.validateBeaconDuty(signedSSVMessage.SSVMessage.GetID().GetRoleType(), msgSlot, validatorIndices, randaoMsg); err != nil {
return err
}

Expand Down
5 changes: 3 additions & 2 deletions message/validation/partial_validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func (mv *messageValidator) validatePartialSigMessagesByDutyLogic(
signerStateBySlot := state.GetOrCreate(signer)

// Rule: Height must not be "old". I.e., signer must not have already advanced to a later slot.
if signedSSVMessage.SSVMessage.MsgID.GetRoleType() != types.RoleCommittee { // Rule only for validator runners
if role != types.RoleCommittee { // Rule only for validator runners
maxSlot := signerStateBySlot.MaxSlot()
if maxSlot != 0 && maxSlot > partialSignatureMessages.Slot {
e := ErrSlotAlreadyAdvanced
Expand All @@ -155,7 +155,8 @@ func (mv *messageValidator) validatePartialSigMessagesByDutyLogic(
}
}

if err := mv.validateBeaconDuty(signedSSVMessage.SSVMessage.GetID().GetRoleType(), messageSlot, committeeInfo.indices); err != nil {
randaoMsg := partialSignatureMessages.Type == spectypes.RandaoPartialSig
if err := mv.validateBeaconDuty(signedSSVMessage.SSVMessage.GetID().GetRoleType(), messageSlot, committeeInfo.indices, randaoMsg); err != nil {
return err
}

Expand Down
99 changes: 99 additions & 0 deletions message/validation/validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

eth2apiv1 "github.com/attestantio/go-eth2-client/api/v1"
"github.com/attestantio/go-eth2-client/spec/phase0"
"github.com/herumi/bls-eth-go-binary/bls"
pubsub "github.com/libp2p/go-libp2p-pubsub"
pspb "github.com/libp2p/go-libp2p-pubsub/pb"
specqbft "github.com/ssvlabs/ssv-spec/qbft"
Expand All @@ -38,6 +39,7 @@ import (
"github.com/ssvlabs/ssv/registry/storage/mocks"
"github.com/ssvlabs/ssv/storage/basedb"
"github.com/ssvlabs/ssv/storage/kv"
"github.com/ssvlabs/ssv/utils"
)

func Test_ValidateSSVMessage(t *testing.T) {
Expand Down Expand Up @@ -630,6 +632,77 @@ func Test_ValidateSSVMessage(t *testing.T) {
require.NoError(t, err)
})

t.Run("accept pre-consensus randao message when epoch duties are not set", func(t *testing.T) {
currentSlot := &utils.SlotValue{}
mockNetworkConfig := networkconfig.NetworkConfig{
Beacon: utils.SetupMockBeaconNetwork(t, currentSlot),
}

const epoch = 1
currentSlot.SetSlot(netCfg.Beacon.FirstSlotAtEpoch(epoch))

ds := dutystore.New()

validator := New(mockNetworkConfig, validatorStore, ds, signatureVerifier).(*messageValidator)

messages := generateRandaoMsg(ks.Shares[1], 1, epoch, currentSlot.GetSlot())
encodedMessages, err := messages.Encode()
require.NoError(t, err)

dutyExecutorID := shares.active.ValidatorPubKey[:]
ssvMessage := &spectypes.SSVMessage{
MsgType: spectypes.SSVPartialSignatureMsgType,
MsgID: spectypes.NewMsgID(mockNetworkConfig.DomainType(), dutyExecutorID, spectypes.RoleProposer),
Data: encodedMessages,
}

signedSSVMessage := spectestingutils.SignedSSVMessageWithSigner(1, ks.OperatorKeys[1], ssvMessage)

receivedAt := mockNetworkConfig.Beacon.GetSlotStartTime(currentSlot.GetSlot())
topicID := commons.CommitteeTopicID(committeeID)[0]

require.False(t, ds.Proposer.IsEpochSet(epoch))

_, err = validator.handleSignedSSVMessage(signedSSVMessage, topicID, receivedAt)
require.NoError(t, err)
})

t.Run("reject pre-consensus randao message when epoch duties are set", func(t *testing.T) {
currentSlot := &utils.SlotValue{}
mockNetworkConfig := networkconfig.NetworkConfig{
Beacon: utils.SetupMockBeaconNetwork(t, currentSlot),
}

const epoch = 1
currentSlot.SetSlot(mockNetworkConfig.Beacon.FirstSlotAtEpoch(epoch))

ds := dutystore.New()
ds.Proposer.Set(epoch, make([]dutystore.StoreDuty[eth2apiv1.ProposerDuty], 0))

validator := New(mockNetworkConfig, validatorStore, ds, signatureVerifier).(*messageValidator)

messages := generateRandaoMsg(ks.Shares[1], 1, epoch, currentSlot.GetSlot())
encodedMessages, err := messages.Encode()
require.NoError(t, err)

dutyExecutorID := shares.active.ValidatorPubKey[:]
ssvMessage := &spectypes.SSVMessage{
MsgType: spectypes.SSVPartialSignatureMsgType,
MsgID: spectypes.NewMsgID(mockNetworkConfig.DomainType(), dutyExecutorID, spectypes.RoleProposer),
Data: encodedMessages,
}

signedSSVMessage := spectestingutils.SignedSSVMessageWithSigner(1, ks.OperatorKeys[1], ssvMessage)

receivedAt := mockNetworkConfig.Beacon.GetSlotStartTime(currentSlot.GetSlot())
topicID := commons.CommitteeTopicID(committeeID)[0]

require.True(t, ds.Proposer.IsEpochSet(epoch))

_, err = validator.handleSignedSSVMessage(signedSSVMessage, topicID, receivedAt)
require.ErrorContains(t, err, ErrNoDuty.Error())
})

//// Get error when receiving a message with over 13 partial signatures
t.Run("partial message too big", func(t *testing.T) {
slot := netCfg.Beacon.FirstSlotAtEpoch(1)
Expand Down Expand Up @@ -1882,3 +1955,29 @@ func generateMultiSignedMessage(

return signedSSVMessage
}

var generateRandaoMsg = func(
sk *bls.SecretKey,
id spectypes.OperatorID,
epoch phase0.Epoch,
slot phase0.Slot,
) *spectypes.PartialSignatureMessages {
signer := spectestingutils.NewTestingKeyManager()
beacon := spectestingutils.NewTestingBeaconNode()
d, _ := beacon.DomainData(epoch, spectypes.DomainRandao)
signed, root, _ := signer.SignBeaconObject(spectypes.SSZUint64(epoch), d, sk.GetPublicKey().Serialize(), spectypes.DomainRandao)

msgs := spectypes.PartialSignatureMessages{
Type: spectypes.RandaoPartialSig,
Slot: slot,
Messages: []*spectypes.PartialSignatureMessage{},
}
msgs.Messages = append(msgs.Messages, &spectypes.PartialSignatureMessage{
PartialSignature: signed[:],
SigningRoot: root,
Signer: id,
ValidatorIndex: spectestingutils.TestingValidatorIndex,
})

return &msgs
}
2 changes: 1 addition & 1 deletion networkconfig/holesky-stage.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ var HoleskyStage = NetworkConfig{
GenesisEpoch: 1,
RegistrySyncOffset: new(big.Int).SetInt64(84599),
RegistryContractAddr: "0x0d33801785340072C452b994496B19f196b7eE15",
AlanForkEpoch: 999999999,
AlanForkEpoch: 0,
DiscoveryProtocolID: [6]byte{'s', 's', 'v', 'd', 'v', '5'},
Bootnodes: []string{
// Public bootnode:
Expand Down
8 changes: 8 additions & 0 deletions operator/duties/dutystore/duties.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,11 @@ func (d *Duties[D]) ResetEpoch(epoch phase0.Epoch) {

delete(d.m, epoch)
}

func (d *Duties[D]) IsEpochSet(epoch phase0.Epoch) bool {
d.mu.RLock()
defer d.mu.RUnlock()

_, exists := d.m[epoch]
return exists
}
5 changes: 3 additions & 2 deletions operator/duties/proposer.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,9 @@ func (h *ProposerHandler) processFetching(ctx context.Context, epoch phase0.Epoc
defer cancel()

if err := h.fetchAndProcessDuties(ctx, epoch); err != nil {
// Set empty duties to inform DutyStore that fetch for this epoch is done.
h.duties.Set(epoch, []dutystore.StoreDuty[eth2apiv1.ProposerDuty]{})

h.logger.Error("failed to fetch duties for current epoch", zap.Error(err))
return
}
Expand Down Expand Up @@ -178,8 +181,6 @@ func (h *ProposerHandler) fetchAndProcessDuties(ctx context.Context, epoch phase
return fmt.Errorf("failed to fetch proposer duties: %w", err)
}

h.duties.ResetEpoch(epoch)

specDuties := make([]*spectypes.ValidatorDuty, 0, len(duties))
storeDuties := make([]dutystore.StoreDuty[eth2apiv1.ProposerDuty], 0, len(duties))
for _, d := range duties {
Expand Down
37 changes: 27 additions & 10 deletions utils/testutils.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package utils
import (
"sync"
"testing"
"time"

"github.com/attestantio/go-eth2-client/spec/phase0"
spectypes "github.com/ssvlabs/ssv-spec/types"
Expand Down Expand Up @@ -38,25 +39,41 @@ func SetupMockBeaconNetwork(t *testing.T, currentSlot *SlotValue) *mocknetwork.M
}

beaconNetwork := spectypes.HoleskyNetwork // it must be something known by ekm

mockBeaconNetwork := mocknetwork.NewMockBeaconNetwork(ctrl)
mockBeaconNetwork.EXPECT().GetBeaconNetwork().Return(beaconNetwork).AnyTimes()

mockBeaconNetwork.EXPECT().EstimatedCurrentEpoch().DoAndReturn(
func() phase0.Epoch {
return phase0.Epoch(currentSlot.GetSlot() / 32)
},
).AnyTimes()

mockBeaconNetwork.EXPECT().GetBeaconNetwork().Return(beaconNetwork).AnyTimes()
mockBeaconNetwork.EXPECT().SlotsPerEpoch().Return(beaconNetwork.SlotsPerEpoch()).AnyTimes()
mockBeaconNetwork.EXPECT().EstimatedCurrentSlot().DoAndReturn(
func() phase0.Slot {
return currentSlot.GetSlot()
},
).AnyTimes()

mockBeaconNetwork.EXPECT().EstimatedCurrentEpoch().DoAndReturn(
func() phase0.Epoch {
return phase0.Epoch(uint64(currentSlot.GetSlot()) / beaconNetwork.SlotsPerEpoch())
},
).AnyTimes()
mockBeaconNetwork.EXPECT().EstimatedEpochAtSlot(gomock.Any()).DoAndReturn(
func(slot phase0.Slot) phase0.Epoch {
return phase0.Epoch(slot / 32)
return beaconNetwork.EstimatedEpochAtSlot(slot)
},
).AnyTimes()
mockBeaconNetwork.EXPECT().FirstSlotAtEpoch(gomock.Any()).DoAndReturn(
func(epoch phase0.Epoch) phase0.Slot {
return beaconNetwork.FirstSlotAtEpoch(epoch)
},
).AnyTimes()
mockBeaconNetwork.EXPECT().IsFirstSlotOfEpoch(gomock.Any()).DoAndReturn(
func(slot phase0.Slot) bool {
return uint64(slot)%mockBeaconNetwork.SlotsPerEpoch() == 0
},
).AnyTimes()
mockBeaconNetwork.EXPECT().GetSlotStartTime(gomock.Any()).DoAndReturn(
func(slot phase0.Slot) time.Time {
timeSinceGenesisStart := int64(uint64(slot) * uint64(beaconNetwork.SlotDurationSec().Seconds())) // #nosec G115
minGenesisTime := int64(mockBeaconNetwork.GetBeaconNetwork().MinGenesisTime()) // #nosec G115
start := time.Unix(minGenesisTime+timeSinceGenesisStart, 0)
return start
},
).AnyTimes()

Expand Down

0 comments on commit 7f928b8

Please sign in to comment.