diff --git a/app/app.go b/app/app.go index 0c1a96e62..4e204a380 100644 --- a/app/app.go +++ b/app/app.go @@ -834,6 +834,9 @@ func setFeeRecipient(eth2Cl eth2wrap.Client, pubkeys []eth2p0.BLSPubKey, feeReci var activeVals []*eth2v1.Validator for _, validator := range vals { + if validator == nil { + return errors.New("validator data cannot be nil") + } if validator.Status != eth2v1.ValidatorStateActiveOngoing { continue } @@ -846,6 +849,10 @@ func setFeeRecipient(eth2Cl eth2wrap.Client, pubkeys []eth2p0.BLSPubKey, feeReci var preps []*eth2v1.ProposalPreparation for _, val := range activeVals { + if val == nil || val.Validator == nil { + return errors.New("validator data cannot be nil") + } + feeRecipient := feeRecipientFunc(core.PubKeyFrom48Bytes(val.Validator.PublicKey)) var addr bellatrix.ExecutionAddress diff --git a/core/scheduler/scheduler.go b/core/scheduler/scheduler.go index 51aca193b..2eb860be3 100644 --- a/core/scheduler/scheduler.go +++ b/core/scheduler/scheduler.go @@ -284,6 +284,13 @@ func (s *Scheduler) resolveAttDuties(ctx context.Context, slot core.Slot, vals v return err } + // Check if any of the attester duties returned are nil.. + for _, duty := range attDuties { + if duty == nil { + return errors.New("attester duty cannot be nil") + } + } + remaining := make(map[eth2p0.ValidatorIndex]bool) for _, index := range vals.Indexes() { remaining[index] = true @@ -347,6 +354,13 @@ func (s *Scheduler) resolveProDuties(ctx context.Context, slot core.Slot, vals v return err } + // Check if any of the proposer duties returned are nil. + for _, duty := range proDuties { + if duty == nil { + return errors.New("proposer duty cannot be nil") + } + } + for _, proDuty := range proDuties { if proDuty.Slot < eth2p0.Slot(slot.Slot) { // Skip duties for earlier slots in initial epoch. @@ -389,6 +403,13 @@ func (s *Scheduler) resolveSyncCommDuties(ctx context.Context, slot core.Slot, v return err } + // Check if any of the sync committee duties returned are nil. + for _, duty := range duties { + if duty == nil { + return errors.New("sync committee duty cannot be nil") + } + } + for _, syncCommDuty := range duties { vIdx := syncCommDuty.ValidatorIndex pubkey, ok := vals.PubKeyFromIndex(vIdx) @@ -585,6 +606,10 @@ func resolveActiveValidators(ctx context.Context, eth2Cl eth2wrap.Client, var resp []validator for index, val := range vals { + if val == nil || val.Validator == nil { + return nil, errors.New("validator data cannot be nil") + } + pubkey, err := core.PubKeyFromBytes(val.Validator.PublicKey[:]) if err != nil { return nil, err diff --git a/core/tracker/incldelay.go b/core/tracker/incldelay.go index fecc9e313..5cc4bf1e9 100644 --- a/core/tracker/incldelay.go +++ b/core/tracker/incldelay.go @@ -67,6 +67,10 @@ func newInclDelayFunc(eth2Cl eth2wrap.Client, dutiesFunc dutiesFunc, callback fu var delays []int64 for _, att := range atts { + if att == nil || att.Data == nil { + return errors.New("attestation fields cannot be nil") + } + attSlot := att.Data.Slot if int64(attSlot) < startSlot { continue diff --git a/core/validatorapi/validatorapi.go b/core/validatorapi/validatorapi.go index 8ba7310c1..eafef38bc 100644 --- a/core/validatorapi/validatorapi.go +++ b/core/validatorapi/validatorapi.go @@ -923,6 +923,10 @@ func (c Component) ProposerDuties(ctx context.Context, epoch eth2p0.Epoch, valid // Replace root public keys with public shares for i := 0; i < len(duties); i++ { + if duties[i] == nil { + return nil, errors.New("proposer duty cannot be nil") + } + pubshare, ok := c.getPubShareFunc(duties[i].PubKey) if !ok { // Ignore unknown validators since ProposerDuties returns ALL proposers for the epoch if validatorIndices is empty. @@ -942,6 +946,10 @@ func (c Component) AttesterDuties(ctx context.Context, epoch eth2p0.Epoch, valid // Replace root public keys with public shares. for i := 0; i < len(duties); i++ { + if duties[i] == nil { + return nil, errors.New("attester duty cannot be nil") + } + pubshare, ok := c.getPubShareFunc(duties[i].PubKey) if !ok { return nil, errors.New("pubshare not found") @@ -960,6 +968,10 @@ func (c Component) SyncCommitteeDuties(ctx context.Context, epoch eth2p0.Epoch, // Replace root public keys with public shares. for i := 0; i < len(duties); i++ { + if duties[i] == nil { + return nil, errors.New("sync committee duty cannot be nil") + } + pubshare, ok := c.getPubShareFunc(duties[i].PubKey) if !ok { return nil, errors.New("pubshare not found") @@ -1011,6 +1023,10 @@ func (Component) NodeVersion(context.Context) (string, error) { func (c Component) convertValidators(vals map[eth2p0.ValidatorIndex]*eth2v1.Validator) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) { resp := make(map[eth2p0.ValidatorIndex]*eth2v1.Validator) for vIdx, val := range vals { + if val == nil || val.Validator == nil { + return nil, errors.New("validator data cannot be nil") + } + var ok bool val.Validator.PublicKey, ok = c.getPubShareFunc(val.Validator.PublicKey) if !ok { diff --git a/testutil/beaconmock/beaconmock_fuzz.go b/testutil/beaconmock/beaconmock_fuzz.go index a5b403e8d..de4d139fb 100644 --- a/testutil/beaconmock/beaconmock_fuzz.go +++ b/testutil/beaconmock/beaconmock_fuzz.go @@ -4,6 +4,7 @@ package beaconmock import ( "context" + "sync" eth2api "github.com/attestantio/go-eth2-client/api" eth2v1 "github.com/attestantio/go-eth2-client/api/v1" @@ -15,15 +16,78 @@ import ( // WithBeaconMockFuzzer configures the beaconmock to return random responses for the all the functions consumed by charon. func WithBeaconMockFuzzer() Option { + var ( + valsMu sync.Mutex + validators map[eth2p0.ValidatorIndex]*eth2v1.Validator + ) + + setValidators := func(pubkeys []eth2p0.BLSPubKey) { + valsMu.Lock() + defer valsMu.Unlock() + + if len(validators) != 0 { + return + } + + validators = make(map[eth2p0.ValidatorIndex]*eth2v1.Validator) + for i, pubkey := range pubkeys { + vIdx := eth2p0.ValidatorIndex(i) + + validators[vIdx] = ð2v1.Validator{ + Balance: eth2p0.Gwei(31300000000), + Index: vIdx, + Status: eth2v1.ValidatorStateActiveOngoing, + Validator: ð2p0.Validator{ + WithdrawalCredentials: []byte("12345678901234567890123456789012"), + EffectiveBalance: eth2p0.Gwei(31300000000), + PublicKey: pubkey, + ExitEpoch: 18446744073709551615, + WithdrawableEpoch: 18446744073709551615, + }, + } + } + } + + getValidators := func() map[eth2p0.ValidatorIndex]*eth2v1.Validator { + valsMu.Lock() + defer valsMu.Unlock() + + return validators + } + return func(mock *Mock) { - mock.AttesterDutiesFunc = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.AttesterDuty, error) { + mock.AttesterDutiesFunc = func(_ context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.AttesterDuty, error) { var duties []*eth2v1.AttesterDuty - fuzz.New().Fuzz(&duties) + f := fuzz.New().Funcs( + func(duties *[]*eth2v1.AttesterDuty, c fuzz.Continue) { + if c.RandBool() { + fuzz.New().Fuzz(duties) + + return + } + + // Return expected attester duties + vals := getValidators() + var resp []*eth2v1.AttesterDuty + for _, vIdx := range indices { + var duty eth2v1.AttesterDuty + c.Fuzz(&duty) + + duty.PubKey = vals[vIdx].Validator.PublicKey + duty.ValidatorIndex = vIdx + duty.Slot = eth2p0.Slot(int(epoch*16) + c.Intn(16)) + resp = append(resp, &duty) + } + + *duties = resp + }, + ) + f.Fuzz(&duties) return duties, nil } - mock.ProposerDutiesFunc = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { + mock.ProposerDutiesFunc = func(_ context.Context, epoch eth2p0.Epoch, indices []eth2p0.ValidatorIndex) ([]*eth2v1.ProposerDuty, error) { var duties []*eth2v1.ProposerDuty fuzz.New().Fuzz(&duties) @@ -73,19 +137,30 @@ func WithBeaconMockFuzzer() Option { } mock.ValidatorsByPubKeyFunc = func(_ context.Context, _ string, pubkeys []eth2p0.BLSPubKey) (map[eth2p0.ValidatorIndex]*eth2v1.Validator, error) { + f := fuzz.New().Funcs( + func(vals *map[eth2p0.ValidatorIndex]*eth2v1.Validator, c fuzz.Continue) { + if c.RandBool() { + fuzz.New().Funcs( + func(state *eth2v1.ValidatorState, c fuzz.Continue) { + *state = eth2v1.ValidatorState(c.Intn(10)) + }, + ).Fuzz(vals) + + return + } + + // Return validators with expected keys 50% of the time. + setValidators(pubkeys) + *vals = getValidators() + }, + ) + var vals map[eth2p0.ValidatorIndex]*eth2v1.Validator - fuzz.New().Fuzz(&vals) + f.Fuzz(&vals) return vals, nil } - mock.SlotsPerEpochFunc = func(context.Context) (uint64, error) { - var slots uint64 - fuzz.New().Fuzz(&slots) - - return slots, nil - } - mock.SyncCommitteeDutiesFunc = func(context.Context, eth2p0.Epoch, []eth2p0.ValidatorIndex) ([]*eth2v1.SyncCommitteeDuty, error) { var duties []*eth2v1.SyncCommitteeDuty fuzz.New().Fuzz(&duties) diff --git a/testutil/compose/fuzz/beacon_fuzz_test.go b/testutil/compose/fuzz/beacon_fuzz_test.go new file mode 100644 index 000000000..f6f44552d --- /dev/null +++ b/testutil/compose/fuzz/beacon_fuzz_test.go @@ -0,0 +1,60 @@ +// Copyright © 2022-2023 Obol Labs Inc. Licensed under the terms of a Business Source License 1.1 + +package fuzz_test + +import ( + "context" + "flag" + "fmt" + "os" + "path" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/obolnetwork/charon/testutil" + "github.com/obolnetwork/charon/testutil/compose" +) + +//go:generate go test . -run=TestBeaconFuzz -integration -v + +var ( + integration = flag.Bool("integration", false, "Enable docker based integration test") + sudoPerms = flag.Bool("sudo-perms", false, "Enables changing all compose artefacts file permissions using sudo.") + logDir = flag.String("log-dir", "", "Specifies the directory to store test docker-compose logs. Empty defaults to stdout.") + fuzzTimeout = flag.Duration("fuzz-timeout", time.Minute*10, "Specifies the duration of the beacon fuzz test.") +) + +func TestBeaconFuzz(t *testing.T) { + if !*integration { + t.Skip("Skipping beacon fuzz integration test") + } + + dir, err := os.MkdirTemp("", "") + require.NoError(t, err) + + conf := compose.NewDefaultConfig() + conf.SyntheticBlockProposals = true + conf.Fuzz = true + conf.DisableMonitoringPorts = true + conf.BuildLocal = true + conf.ImageTag = "local" + conf.InsecureKeys = true + require.NoError(t, compose.WriteConfig(dir, conf)) + + os.Args = []string{"cobra.test"} + + autoConfig := compose.AutoConfig{ + Dir: dir, + AlertTimeout: *fuzzTimeout, + SudoPerms: *sudoPerms, + } + + if *logDir != "" { + autoConfig.LogFile = path.Join(*logDir, fmt.Sprintf("%s.log", t.Name())) + } + + err = compose.Auto(context.Background(), autoConfig) + testutil.RequireNoError(t, err) +}