diff --git a/beacon-chain/core/electra/BUILD.bazel b/beacon-chain/core/electra/BUILD.bazel index 92d647c5de64..1eb12407f98c 100644 --- a/beacon-chain/core/electra/BUILD.bazel +++ b/beacon-chain/core/electra/BUILD.bazel @@ -46,6 +46,7 @@ go_test( "consolidations_test.go", "deposits_test.go", "effective_balance_updates_test.go", + "registry_updates_test.go", "upgrade_test.go", "validator_test.go", "withdrawals_test.go", diff --git a/beacon-chain/core/electra/registry_updates.go b/beacon-chain/core/electra/registry_updates.go index 546413058d31..f7991ea594d9 100644 --- a/beacon-chain/core/electra/registry_updates.go +++ b/beacon-chain/core/electra/registry_updates.go @@ -2,12 +2,18 @@ package electra import ( "context" + "fmt" + "github.com/prysmaticlabs/prysm/v5/beacon-chain/core/helpers" + "github.com/prysmaticlabs/prysm/v5/beacon-chain/core/time" + "github.com/prysmaticlabs/prysm/v5/beacon-chain/core/validators" "github.com/prysmaticlabs/prysm/v5/beacon-chain/state" + "github.com/prysmaticlabs/prysm/v5/config/params" + "github.com/prysmaticlabs/prysm/v5/consensus-types/primitives" ) -// ProcessRegistryUpdates rotates validators in and out of active pool. -// the amount to rotate is determined churn limit. +// ProcessRegistryUpdates processes all validators eligible for the activation queue, all validators +// which should be ejected, and all validators which are eligible for activation from the queue. // // Spec pseudocode definition: // @@ -28,7 +34,72 @@ import ( // for validator in state.validators: // if is_eligible_for_activation(state, validator): // validator.activation_epoch = activation_epoch -func ProcessRegistryUpdates(ctx context.Context, state state.BeaconState) (state.BeaconState, error) { - // TODO: replace with real implementation - return state, nil +func ProcessRegistryUpdates(ctx context.Context, st state.BeaconState) error { + currentEpoch := time.CurrentEpoch(st) + ejectionBal := params.BeaconConfig().EjectionBalance + activationEpoch := helpers.ActivationExitEpoch(currentEpoch) + + // To avoid copying the state validator set via st.Validators(), we will perform a read only pass + // over the validator set while collecting validator indices where the validator copy is actually + // necessary, then we will process these operations. + eligibleForActivationQ := make([]primitives.ValidatorIndex, 0) + eligibleForEjection := make([]primitives.ValidatorIndex, 0) + eligibleForActivation := make([]primitives.ValidatorIndex, 0) + + if err := st.ReadFromEveryValidator(func(idx int, val state.ReadOnlyValidator) error { + // Collect validators eligible to enter the activation queue. + if helpers.IsEligibleForActivationQueue(val, currentEpoch) { + eligibleForActivationQ = append(eligibleForActivationQ, primitives.ValidatorIndex(idx)) + } + + // Collect validators to eject. + if val.EffectiveBalance() <= ejectionBal && helpers.IsActiveValidatorUsingTrie(val, currentEpoch) { + eligibleForEjection = append(eligibleForEjection, primitives.ValidatorIndex(idx)) + } + + // Collect validators eligible for activation and not yet dequeued for activation. + if helpers.IsEligibleForActivationUsingTrie(st, val) { + eligibleForActivation = append(eligibleForActivation, primitives.ValidatorIndex(idx)) + } + + return nil + }); err != nil { + return fmt.Errorf("failed to read validators: %w", err) + } + + // Handle validators eligible to join the activation queue. + for _, idx := range eligibleForActivationQ { + v, err := st.ValidatorAtIndex(idx) + if err != nil { + return err + } + v.ActivationEligibilityEpoch = currentEpoch + 1 + if err := st.UpdateValidatorAtIndex(idx, v); err != nil { + return fmt.Errorf("failed to updated eligible validator at index %d: %w", idx, err) + } + } + + // Handle validator ejections. + for _, idx := range eligibleForEjection { + var err error + // exitQueueEpoch and churn arguments are not used in electra. + st, _, err = validators.InitiateValidatorExit(ctx, st, idx, 0 /*exitQueueEpoch*/, 0 /*churn*/) + if err != nil { + return fmt.Errorf("failed to initiate validator exit at index %d: %w", idx, err) + } + } + + for _, idx := range eligibleForActivation { + // Activate all eligible validators. + v, err := st.ValidatorAtIndex(idx) + if err != nil { + return err + } + v.ActivationEpoch = activationEpoch + if err := st.UpdateValidatorAtIndex(idx, v); err != nil { + return fmt.Errorf("failed to activate validator at index %d: %w", idx, err) + } + } + + return nil } diff --git a/beacon-chain/core/electra/registry_updates_test.go b/beacon-chain/core/electra/registry_updates_test.go new file mode 100644 index 000000000000..44449d66ca89 --- /dev/null +++ b/beacon-chain/core/electra/registry_updates_test.go @@ -0,0 +1,145 @@ +package electra_test + +import ( + "context" + "testing" + + "github.com/prysmaticlabs/prysm/v5/beacon-chain/core/electra" + "github.com/prysmaticlabs/prysm/v5/beacon-chain/core/helpers" + "github.com/prysmaticlabs/prysm/v5/beacon-chain/state" + state_native "github.com/prysmaticlabs/prysm/v5/beacon-chain/state/state-native" + fieldparams "github.com/prysmaticlabs/prysm/v5/config/fieldparams" + "github.com/prysmaticlabs/prysm/v5/config/params" + "github.com/prysmaticlabs/prysm/v5/consensus-types/primitives" + eth "github.com/prysmaticlabs/prysm/v5/proto/prysm/v1alpha1" + "github.com/prysmaticlabs/prysm/v5/testing/require" + "github.com/prysmaticlabs/prysm/v5/testing/util" +) + +func TestProcessRegistryUpdates(t *testing.T) { + finalizedEpoch := primitives.Epoch(4) + + tests := []struct { + name string + state state.BeaconState + check func(*testing.T, state.BeaconState) + }{ + { + name: "No rotation", + state: func() state.BeaconState { + base := ð.BeaconStateElectra{ + Slot: 5 * params.BeaconConfig().SlotsPerEpoch, + Validators: []*eth.Validator{ + {ExitEpoch: params.BeaconConfig().MaxSeedLookahead}, + {ExitEpoch: params.BeaconConfig().MaxSeedLookahead}, + }, + Balances: []uint64{ + params.BeaconConfig().MaxEffectiveBalance, + params.BeaconConfig().MaxEffectiveBalance, + }, + FinalizedCheckpoint: ð.Checkpoint{Root: make([]byte, fieldparams.RootLength)}, + } + st, err := state_native.InitializeFromProtoElectra(base) + require.NoError(t, err) + return st + }(), + check: func(t *testing.T, st state.BeaconState) { + for i, val := range st.Validators() { + require.Equal(t, params.BeaconConfig().MaxSeedLookahead, val.ExitEpoch, "validator updated unexpectedly at index %d", i) + } + }, + }, + { + name: "Validators are activated", + state: func() state.BeaconState { + base := ð.BeaconStateElectra{ + Slot: 5 * params.BeaconConfig().SlotsPerEpoch, + FinalizedCheckpoint: ð.Checkpoint{Epoch: finalizedEpoch, Root: make([]byte, fieldparams.RootLength)}, + } + for i := uint64(0); i < 10; i++ { + base.Validators = append(base.Validators, ð.Validator{ + ActivationEligibilityEpoch: finalizedEpoch, + EffectiveBalance: params.BeaconConfig().MaxEffectiveBalance, + ActivationEpoch: params.BeaconConfig().FarFutureEpoch, + }) + } + st, err := state_native.InitializeFromProtoElectra(base) + require.NoError(t, err) + return st + }(), + check: func(t *testing.T, st state.BeaconState) { + activationEpoch := helpers.ActivationExitEpoch(5) + // All validators should be activated. + for i, val := range st.Validators() { + require.Equal(t, activationEpoch, val.ActivationEpoch, "failed to update validator at index %d", i) + } + }, + }, + { + name: "Validators are exited", + state: func() state.BeaconState { + base := ð.BeaconStateElectra{ + Slot: 5 * params.BeaconConfig().SlotsPerEpoch, + FinalizedCheckpoint: ð.Checkpoint{Epoch: finalizedEpoch, Root: make([]byte, fieldparams.RootLength)}, + } + for i := uint64(0); i < 10; i++ { + base.Validators = append(base.Validators, ð.Validator{ + EffectiveBalance: params.BeaconConfig().EjectionBalance - 1, + ExitEpoch: params.BeaconConfig().FarFutureEpoch, + WithdrawableEpoch: params.BeaconConfig().FarFutureEpoch, + }) + } + st, err := state_native.InitializeFromProtoElectra(base) + require.NoError(t, err) + return st + }(), + check: func(t *testing.T, st state.BeaconState) { + // All validators should be exited + for i, val := range st.Validators() { + require.NotEqual(t, params.BeaconConfig().FarFutureEpoch, val.ExitEpoch, "failed to update exit epoch on validator %d", i) + require.NotEqual(t, params.BeaconConfig().FarFutureEpoch, val.WithdrawableEpoch, "failed to update withdrawable epoch on validator %d", i) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := electra.ProcessRegistryUpdates(context.TODO(), tt.state) + require.NoError(t, err) + if tt.check != nil { + tt.check(t, tt.state) + } + }) + } +} + +func Benchmark_ProcessRegistryUpdates_MassEjection(b *testing.B) { + bal := params.BeaconConfig().EjectionBalance - 1 + ffe := params.BeaconConfig().FarFutureEpoch + genValidators := func(num uint64) []*eth.Validator { + vals := make([]*eth.Validator, num) + for i := range vals { + vals[i] = ð.Validator{ + EffectiveBalance: bal, + ExitEpoch: ffe, + } + } + return vals + } + + st, err := util.NewBeaconStateElectra() + require.NoError(b, err) + + for i := 0; i < b.N; i++ { + b.StopTimer() + if err := st.SetValidators(genValidators(100000)); err != nil { + panic(err) + } + b.StartTimer() + + if err := electra.ProcessRegistryUpdates(context.TODO(), st); err != nil { + panic(err) + } + } +} diff --git a/beacon-chain/core/electra/transition.go b/beacon-chain/core/electra/transition.go index 12accfccad78..5a153e9b015e 100644 --- a/beacon-chain/core/electra/transition.go +++ b/beacon-chain/core/electra/transition.go @@ -74,8 +74,7 @@ func ProcessEpoch(ctx context.Context, state state.BeaconState) error { return errors.Wrap(err, "could not process rewards and penalties") } - state, err = ProcessRegistryUpdates(ctx, state) - if err != nil { + if err := ProcessRegistryUpdates(ctx, state); err != nil { return errors.Wrap(err, "could not process registry updates") } diff --git a/testing/spectest/mainnet/electra/epoch_processing/BUILD.bazel b/testing/spectest/mainnet/electra/epoch_processing/BUILD.bazel index cd8b0de62edf..9315ef295420 100644 --- a/testing/spectest/mainnet/electra/epoch_processing/BUILD.bazel +++ b/testing/spectest/mainnet/electra/epoch_processing/BUILD.bazel @@ -12,6 +12,7 @@ go_test( "pending_balance_updates_test.go", "pending_consolidations_test.go", "randao_mixes_reset_test.go", + "registry_updates_test.go", "rewards_and_penalties_test.go", "slashings_reset_test.go", "slashings_test.go", diff --git a/testing/spectest/mainnet/electra/epoch_processing/registry_updates_test.go b/testing/spectest/mainnet/electra/epoch_processing/registry_updates_test.go new file mode 100644 index 000000000000..8eb9a05227a2 --- /dev/null +++ b/testing/spectest/mainnet/electra/epoch_processing/registry_updates_test.go @@ -0,0 +1,11 @@ +package epoch_processing + +import ( + "testing" + + "github.com/prysmaticlabs/prysm/v5/testing/spectest/shared/electra/epoch_processing" +) + +func TestMainnet_Electra_EpochProcessing_RegistryUpdates(t *testing.T) { + epoch_processing.RunRegistryUpdatesTests(t, "mainnet") +} diff --git a/testing/spectest/minimal/electra/epoch_processing/BUILD.bazel b/testing/spectest/minimal/electra/epoch_processing/BUILD.bazel index 162e4f072fe7..66efa96a1037 100644 --- a/testing/spectest/minimal/electra/epoch_processing/BUILD.bazel +++ b/testing/spectest/minimal/electra/epoch_processing/BUILD.bazel @@ -12,6 +12,7 @@ go_test( "pending_balance_updates_test.go", "pending_consolidations_test.go", "randao_mixes_reset_test.go", + "registry_updates_test.go", "rewards_and_penalties_test.go", "slashings_reset_test.go", "slashings_test.go", diff --git a/testing/spectest/minimal/electra/epoch_processing/registry_updates_test.go b/testing/spectest/minimal/electra/epoch_processing/registry_updates_test.go new file mode 100644 index 000000000000..b6986071f8dc --- /dev/null +++ b/testing/spectest/minimal/electra/epoch_processing/registry_updates_test.go @@ -0,0 +1,11 @@ +package epoch_processing + +import ( + "testing" + + "github.com/prysmaticlabs/prysm/v5/testing/spectest/shared/electra/epoch_processing" +) + +func TestMinimal_Electra_EpochProcessing_RegistryUpdates(t *testing.T) { + epoch_processing.RunRegistryUpdatesTests(t, "minimal") +} diff --git a/testing/spectest/shared/electra/epoch_processing/BUILD.bazel b/testing/spectest/shared/electra/epoch_processing/BUILD.bazel index f651ce5c648e..f9b321866e33 100644 --- a/testing/spectest/shared/electra/epoch_processing/BUILD.bazel +++ b/testing/spectest/shared/electra/epoch_processing/BUILD.bazel @@ -14,6 +14,7 @@ go_library( "pending_balance_updates.go", "pending_consolidations.go", "randao_mixes_reset.go", + "registry_updates.go", "rewards_and_penalties.go", "slashings.go", "slashings_reset.go", diff --git a/testing/spectest/shared/electra/epoch_processing/registry_updates.go b/testing/spectest/shared/electra/epoch_processing/registry_updates.go new file mode 100644 index 000000000000..bc00a1f85731 --- /dev/null +++ b/testing/spectest/shared/electra/epoch_processing/registry_updates.go @@ -0,0 +1,32 @@ +package epoch_processing + +import ( + "context" + "path" + "testing" + + "github.com/prysmaticlabs/prysm/v5/beacon-chain/core/electra" + "github.com/prysmaticlabs/prysm/v5/beacon-chain/core/helpers" + "github.com/prysmaticlabs/prysm/v5/beacon-chain/state" + "github.com/prysmaticlabs/prysm/v5/testing/require" + "github.com/prysmaticlabs/prysm/v5/testing/spectest/utils" +) + +// RunRegistryUpdatesTests executes "epoch_processing/registry_updates" tests. +func RunRegistryUpdatesTests(t *testing.T, config string) { + require.NoError(t, utils.SetConfig(t, config)) + + testFolders, testsFolderPath := utils.TestFolders(t, config, "electra", "epoch_processing/registry_updates/pyspec_tests") + for _, folder := range testFolders { + t.Run(folder.Name(), func(t *testing.T) { + // Important to clear cache for every test or else the old value of active validator count gets reused. + helpers.ClearCache() + folderPath := path.Join(testsFolderPath, folder.Name()) + RunEpochOperationTest(t, folderPath, processRegistryUpdatesWrapper) + }) + } +} + +func processRegistryUpdatesWrapper(_ *testing.T, state state.BeaconState) (state.BeaconState, error) { + return state, electra.ProcessRegistryUpdates(context.Background(), state) +}